feat: implement experimental doppler detection engine and UI toggle
This commit is contained in:
108
src/hooks/useAudioAnalyzer.ts
Normal file
108
src/hooks/useAudioAnalyzer.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export interface AudioAnalyzerState {
|
||||
isInitialized: boolean;
|
||||
isStreaming: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const useAudioAnalyzer = (fftSize: number = 8192) => {
|
||||
const [state, setState] = useState<AudioAnalyzerState>({
|
||||
isInitialized: false,
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
|
||||
const init = useCallback(async () => {
|
||||
try {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("Microphone API not available (requires HTTPS)");
|
||||
}
|
||||
|
||||
const AudioContextCtor = window.AudioContext || (window as any).webkitAudioContext;
|
||||
const ctx = new AudioContextCtor({ sampleRate: 48000 });
|
||||
|
||||
// iOS requires AudioContext to be resumed synchronously during user interaction
|
||||
if (ctx.state === 'suspended') {
|
||||
await ctx.resume();
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
autoGainControl: false,
|
||||
noiseSuppression: false,
|
||||
},
|
||||
});
|
||||
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = fftSize;
|
||||
analyser.smoothingTimeConstant = 0.3;
|
||||
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
source.connect(analyser);
|
||||
|
||||
audioContextRef.current = ctx;
|
||||
analyserRef.current = analyser;
|
||||
streamRef.current = stream;
|
||||
sourceRef.current = source;
|
||||
|
||||
setState({
|
||||
isInitialized: true,
|
||||
isStreaming: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Failed to init audio context', err);
|
||||
setState(s => ({ ...s, error: err.message || 'Microphone access denied' }));
|
||||
return false;
|
||||
}
|
||||
}, [fftSize]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
}
|
||||
setState({
|
||||
isInitialized: false,
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
// Expose a method to grab the latest frequency data
|
||||
const getFrequencyData = useCallback(() => {
|
||||
if (!analyserRef.current || !audioContextRef.current) return null;
|
||||
|
||||
const analyser = analyserRef.current;
|
||||
// Uint8Array for performance. Values 0-255.
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
return {
|
||||
data: dataArray,
|
||||
sampleRate: audioContextRef.current.sampleRate,
|
||||
fftSize: analyser.fftSize,
|
||||
binCount: analyser.frequencyBinCount
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...state, init, stop, getFrequencyData };
|
||||
};
|
||||
Reference in New Issue
Block a user