Files
tkmind_go/ui/desktop/src/hooks/useAudioRecorder.ts
T

271 lines
8.4 KiB
TypeScript

import { useState, useRef, useCallback, useEffect } from 'react';
import { getDictationConfig, transcribeDictation } from '../acp/dictation';
import { useConfig } from '../components/ConfigContext';
import type { DictationProvider } from '../types/dictation';
import { errorMessage } from '../utils/conversionUtils';
interface UseAudioRecorderOptions {
onTranscription: (text: string) => void;
onError: (message: string) => void;
}
const SAMPLE_RATE = 16000;
const SILENCE_MS = 800;
const MIN_SPEECH_MS = 200;
// RMS threshold for speech detection. Audio samples are Float32 in [-1, 1] range.
// 0.015 (~1.5% of full-scale) distinguishes normal speech from background noise
// without clipping early speech onsets. Determined empirically for 16kHz mono input.
const RMS_THRESHOLD = 0.015;
// Resolve worklet URL at runtime from window.location so it works under both
// the dev server (http://localhost) and packaged builds (file://).
const WORKLET_URL = new URL('audio-capture-worklet.js', window.location.href.split('#')[0]).href;
function encodeWav(samples: Float32Array, sampleRate: number): ArrayBuffer {
const buf = new ArrayBuffer(44 + samples.length * 2);
const v = new DataView(buf);
const w = (o: number, s: string) => {
for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i));
};
w(0, 'RIFF');
v.setUint32(4, 36 + samples.length * 2, true);
w(8, 'WAVE');
w(12, 'fmt ');
v.setUint32(16, 16, true);
v.setUint16(20, 1, true);
v.setUint16(22, 1, true);
v.setUint32(24, sampleRate, true);
v.setUint32(28, sampleRate * 2, true);
v.setUint16(32, 2, true);
v.setUint16(34, 16, true);
w(36, 'data');
v.setUint32(40, samples.length * 2, true);
let o = 44;
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
v.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7fff, true);
o += 2;
}
return buf;
}
function rms(samples: Float32Array): number {
let sum = 0;
for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i];
return Math.sqrt(sum / samples.length);
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onloadend = () => resolve((r.result as string).split(',')[1]);
r.onerror = reject;
r.readAsDataURL(blob);
});
}
export const useAudioRecorder = ({ onTranscription, onError }: UseAudioRecorderOptions) => {
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const [isEnabled, setIsEnabled] = useState(false);
const [provider, setProvider] = useState<DictationProvider | null>(null);
const { read, config } = useConfig();
const audioContextRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null);
// VAD state (all refs to avoid re-render/stale closure issues)
const samplesRef = useRef<Float32Array[]>([]);
const isSpeakingRef = useRef(false);
const silenceStartRef = useRef(0);
const speechStartRef = useRef(0);
const pendingTranscriptions = useRef(0);
const providerRef = useRef(provider);
providerRef.current = provider;
// Keep callback refs fresh
const onTranscriptionRef = useRef(onTranscription);
onTranscriptionRef.current = onTranscription;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
useEffect(() => {
const check = async () => {
try {
const val = await read('voice_dictation_provider', false);
const pref = (val as DictationProvider) || null;
if (!pref) {
setIsEnabled(false);
setProvider(null);
return;
}
const providers = await getDictationConfig();
setIsEnabled(!!providers[pref]?.configured);
setProvider(pref);
} catch (error) {
console.error('Failed to check dictation config:', error);
setIsEnabled(false);
setProvider(null);
}
};
check();
}, [read, config]);
const transcribeChunk = useCallback(async (samples: Float32Array) => {
const prov = providerRef.current;
if (!prov) return;
pendingTranscriptions.current++;
setIsTranscribing(true);
try {
const wav = new Blob([encodeWav(samples, SAMPLE_RATE)], { type: 'audio/wav' });
const base64 = await blobToBase64(wav);
const text = await transcribeDictation(base64, 'audio/wav', prov);
if (text) {
onTranscriptionRef.current(text);
}
} catch (error) {
onErrorRef.current(errorMessage(error));
} finally {
pendingTranscriptions.current--;
if (pendingTranscriptions.current === 0) setIsTranscribing(false);
}
}, []);
const flush = useCallback(() => {
const chunks = samplesRef.current;
if (chunks.length === 0) return;
const total = chunks.reduce((n, c) => n + c.length, 0);
const merged = new Float32Array(total);
let off = 0;
for (const c of chunks) {
merged.set(c, off);
off += c.length;
}
samplesRef.current = [];
transcribeChunk(merged);
}, [transcribeChunk]);
const flushRef = useRef(flush);
flushRef.current = flush;
const handleSamples = useCallback((samples: Float32Array) => {
const now = Date.now();
if (rms(samples) > RMS_THRESHOLD) {
if (!isSpeakingRef.current) {
isSpeakingRef.current = true;
speechStartRef.current = now;
}
silenceStartRef.current = 0;
samplesRef.current.push(new Float32Array(samples));
} else if (isSpeakingRef.current) {
samplesRef.current.push(new Float32Array(samples));
if (silenceStartRef.current === 0) {
silenceStartRef.current = now;
} else if (now - silenceStartRef.current > SILENCE_MS) {
if (now - speechStartRef.current > MIN_SPEECH_MS) {
flushRef.current();
} else {
samplesRef.current = [];
}
isSpeakingRef.current = false;
silenceStartRef.current = 0;
}
}
}, []);
const stopRecording = useCallback(() => {
if (isSpeakingRef.current && samplesRef.current.length > 0) {
flushRef.current();
}
isSpeakingRef.current = false;
silenceStartRef.current = 0;
audioContextRef.current?.close();
audioContextRef.current = null;
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
setIsRecording(false);
}, []);
const startRecording = useCallback(async () => {
if (!isEnabled) {
onError('Voice dictation is not enabled');
return;
}
try {
const preferredMic = await read('voice_dictation_preferred_mic', false);
const audioConstraints: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
if (preferredMic && typeof preferredMic === 'string') {
audioConstraints.deviceId = { exact: preferredMic };
}
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints });
} catch (e) {
if (
preferredMic &&
e instanceof DOMException &&
(e.name === 'NotFoundError' || e.name === 'OverconstrainedError')
) {
delete audioConstraints.deviceId;
stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints });
} else {
throw e;
}
}
streamRef.current = stream;
const ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
audioContextRef.current = ctx;
await ctx.audioWorklet.addModule(WORKLET_URL);
const source = ctx.createMediaStreamSource(stream);
const worklet = new AudioWorkletNode(ctx, 'audio-capture');
worklet.port.onmessage = (e: MessageEvent<Float32Array>) => handleSamples(e.data);
// Connect through silent gain to keep worklet processing alive
const silence = ctx.createGain();
silence.gain.value = 0;
source.connect(worklet);
worklet.connect(silence);
silence.connect(ctx.destination);
setIsRecording(true);
} catch (error) {
stopRecording();
onError(errorMessage(error));
}
}, [isEnabled, onError, handleSamples, stopRecording, read]);
useEffect(() => {
return () => {
audioContextRef.current?.close();
streamRef.current?.getTracks().forEach((t) => t.stop());
};
}, []);
return {
isEnabled,
dictationProvider: provider,
isRecording,
isTranscribing,
startRecording,
stopRecording,
};
};