109 lines
3.0 KiB
TypeScript
109 lines
3.0 KiB
TypeScript
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 };
|
|
};
|