/** * 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 a fresh MediaRecorder; start() begins * capturing (synchronous); stop() resolves the audio blob and * releases the stream. A fresh stream is acquired per recording and * torn down on every stop() so iOS/WebKit never reuses a capture * track that TTS playback or an audio-session interruption has * quietly killed. */ /** * 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 a fresh * MediaRecorder. Always starts clean so iOS never reuses a dead * capture track. Must be called before start. */ async acquire() { this._release(); const stream = await navigator.mediaDevices.getUserMedia({ audio: true, }); const [track] = stream.getAudioTracks(); if (track?.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. * @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, releasing the stream. 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') { this._release(); 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._release(); resolve(blob); }, { once: true }, ); mr.addEventListener( 'error', (event) => { this._release(); 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 }; } }