Architecture: - Feature-sliced directory: entities/, shared/, features/, widgets/ - Consolidated API client (shared/api/client.ts) — all endpoints in one file - Single WebSocket manager (shared/ws/socket.ts) with typed event bus - Extracted hooks: useAudioPlayback, useAudioTransmit, useLocalStorage, useUIState - UI primitives moved to shared/ui/ with barrel export - Added Skeleton, Toast, MobileTabBar components Bug fixes (8/8): 1. useMemo→useEffect in RecordingsSubPanel (async side-effect anti-pattern) 2. ArrayBuffer.slice() before WebSocket send (shared buffer bug) 3. Proper useEffect dependency arrays throughout 4. Stable React keys (no index fallbacks) 5. onReanalyze properly awaited (Promise<void> return) 6. monitorGuild memoized with useMemo 7. localStorage validation with shape checking 8. Deleted duplicate socket logic (ws/client.ts removed) UI polish: - Glass-morphism design tokens (backdrop-blur, translucent cards) - Gradient mesh background with subtle radial overlays - Expandable sidebar + mobile bottom tab bar - Skeleton loading placeholders - Audio visualizer with CSS pulse animation Deleted: src/api/, src/components/, src/hooks/, src/types/, src/ws/, src/lib/ Added: 40 new files across feature-sliced structure Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
58 lines
2.5 KiB
TypeScript
58 lines
2.5 KiB
TypeScript
// ─── Audio playback hook — receives PCM from WebSocket and plays through Web Audio API ──
|
|
import { useCallback, useRef, useState } from "react";
|
|
|
|
const SAMPLE_RATE = 24000;
|
|
const CHANNELS = 1;
|
|
|
|
export function useAudioPlayback() {
|
|
const [isListening, setIsListening] = useState(false);
|
|
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
|
|
const audioContextRef = useRef<AudioContext | null>(null);
|
|
const userTimelinesRef = useRef(new Map<number, number>());
|
|
|
|
const handleIncomingPcm = useCallback((data: ArrayBuffer) => {
|
|
const headerView = new DataView(data, 0, 4);
|
|
const userIdHash = headerView.getInt32(0, true);
|
|
const audioData = data.slice(4);
|
|
const int16Array = new Int16Array(audioData);
|
|
let sum = 0;
|
|
for (const sample of int16Array) sum += Math.abs(sample / 32768);
|
|
const average = int16Array.length ? sum / int16Array.length : 0;
|
|
setLevels((prev) =>
|
|
prev.map((_, index) =>
|
|
Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5),
|
|
),
|
|
);
|
|
|
|
const audioContext = audioContextRef.current;
|
|
if (!isListening || !audioContext) return;
|
|
const float32Array = new Float32Array(int16Array.length);
|
|
for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768;
|
|
const audioBuffer = audioContext.createBuffer(CHANNELS, float32Array.length / SAMPLE_RATE, SAMPLE_RATE);
|
|
audioBuffer.getChannelData(0).set(float32Array);
|
|
const source = audioContext.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
source.connect(audioContext.destination);
|
|
const currentTime = audioContext.currentTime;
|
|
let nextStart = userTimelinesRef.current.get(userIdHash) || 0;
|
|
if (nextStart < currentTime) nextStart = currentTime + 0.05;
|
|
source.start(nextStart);
|
|
userTimelinesRef.current.set(userIdHash, nextStart + audioBuffer.duration);
|
|
}, [isListening]);
|
|
|
|
const toggleListening = useCallback(async () => {
|
|
if (isListening) {
|
|
await audioContextRef.current?.suspend();
|
|
userTimelinesRef.current.clear();
|
|
setIsListening(false);
|
|
return;
|
|
}
|
|
const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
|
audioContextRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
|
await audioContextRef.current.resume();
|
|
setIsListening(true);
|
|
}, [isListening]);
|
|
|
|
return { isListening, levels, handleIncomingPcm, toggleListening, audioContextRef };
|
|
}
|