/** * STT records audio via a MediaRecorder, posts it to whisper-server, * and returns the transcription. No concept of modes — the App * decides what to do with the result. * * Recording lifecycle: acquire() requests microphone access (async, * may prompt) and creates the MediaRecorder; start() begins capturing * (synchronous); stop() resolves the audio blob. The stream and * recorder are reused across recordings to avoid codec warmup delay. * Before each acquire(), the track state is checked; if iOS/WebKit * has killed the track due to TTS playback or an audio-session * interruption, a fresh stream is obtained. */ /** * VOICE_MAP maps whisper language names to default Kokoro voices. * Used for auto-selecting a voice when the selector is set to "Auto". */ export const VOICE_MAP = { chinese: 'zf_xiaobei', english: 'af_heart', french: 'ff_siwis', hindi: 'hf_alpha', italian: 'if_sara', japanese: 'jf_alpha', korean: 'kf_sarah', portuguese: 'pf_dora', spanish: 'ef_dora', }; export class STT { /** * @param {Object} options * @param {string} options.url - whisper-server base URL */ constructor({ url }) { this.url = url; this.mediaRecorder = null; this.audioChunks = []; this._ext = 'webm'; } /** * acquire requests microphone access and creates the MediaRecorder. * If an existing recorder has a live track, it is reused to avoid * codec warmup delay. If the track is dead (e.g., killed by iOS * after TTS playback), a fresh stream and recorder are created. * Must be called before start. */ async acquire() { // Reuse if the existing track is still live. const track = this.mediaRecorder?.stream?.getAudioTracks()[0]; if (track?.readyState === 'live') { this.audioChunks = []; return; } // Track is dead or doesn't exist — acquire fresh. this._release(); const stream = await navigator.mediaDevices.getUserMedia({ audio: true, }); const [newTrack] = stream.getAudioTracks(); if (newTrack?.readyState !== 'live') { for (const t of stream.getTracks()) t.stop(); throw new Error('microphone unavailable'); } let mimeType = null; for (const candidate of [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/mp4;codecs=mp4a.40.2', 'audio/mp4', ]) { if (MediaRecorder.isTypeSupported?.(candidate)) { mimeType = candidate; break; } } this._ext = mimeType?.includes('mp4') ? 'm4a' : 'webm'; this.mediaRecorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); this.audioChunks = []; this.mediaRecorder.addEventListener('dataavailable', (event) => { if (event.data.size > 0) { this.audioChunks.push(event.data); } }); } /** * start begins recording. Requires acquire() to have completed. */ start() { this.audioChunks = []; this.mediaRecorder.start(); } /** * stop stops recording, transcribes the audio, and returns the result. * The stream and recorder are kept alive for reuse. * @returns {Promise<{text: string, detectedLanguage: string}>} */ async stop() { const audioBlob = await this._stopRecording(); if (audioBlob.size === 0) { return { text: '', detectedLanguage: '' }; } return this._transcribe(audioBlob); } /** @returns {boolean} */ get isRecording() { return this.mediaRecorder?.state === 'recording'; } /** * destroy stops tracks and releases the media stream. Called on page * teardown. */ destroy() { this._release(); } /** * _stopRecording stops the MediaRecorder and resolves with the * assembled audio blob. Does not release the stream or recorder, * so they can be reused. Returns an empty blob if there is no * active recording, so it is safe to call before start has run * (e.g., if the user released while acquire was still pending). * @returns {Promise} * @private */ _stopRecording() { return new Promise((resolve, reject) => { const mr = this.mediaRecorder; if (!mr || mr.state === 'inactive') { resolve(new Blob([])); return; } mr.addEventListener( 'stop', () => { const type = mr.mimeType; const blob = new Blob(this.audioChunks, { type }); if (type?.includes('mp4') || type?.includes('m4a')) { this._ext = 'm4a'; } this.audioChunks = []; resolve(blob); }, { once: true }, ); mr.addEventListener( 'error', (event) => { this.audioChunks = []; reject(event.error); }, { once: true }, ); mr.stop(); }); } /** * _release stops all tracks and clears recorder state. * @private */ _release() { for (const track of this.mediaRecorder?.stream?.getTracks() ?? []) { track.stop(); } this.mediaRecorder = null; this.audioChunks = []; } /** * _transcribe sends an audio blob to whisper-server and returns the * transcription result. * @param {Blob} audioBlob * @returns {Promise<{text: string, detectedLanguage: string}>} * @private */ async _transcribe(audioBlob) { if (!this.url) { throw new Error('STT URL not configured'); } const formData = new FormData(); formData.append('file', audioBlob, `audio.${this._ext}`); formData.append('response_format', 'verbose_json'); const res = await fetch(`${this.url}/inference`, { method: 'POST', body: formData, }); if (!res.ok) { const errText = await res.text().catch(() => ''); throw new Error(`STT error ${res.status}: ${errText}`); } const data = await res.json(); const text = (data.text || '').trim(); const detectedLanguage = data.detected_language || data.language || ''; return { text, detectedLanguage }; } }