From 98986a884aa7fd19c477aea2d402dd13e2b5fa8e Mon Sep 17 00:00:00 2001 From: dwrz Date: Wed, 8 Jul 2026 18:34:04 +0000 Subject: [PATCH] Refactor STT --- internal/service/static/stt.js | 46 +++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/internal/service/static/stt.js b/internal/service/static/stt.js index 81492a5..1bd57e8 100644 --- a/internal/service/static/stt.js +++ b/internal/service/static/stt.js @@ -4,12 +4,12 @@ * 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. + * 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. */ /** @@ -41,19 +41,29 @@ export class STT { } /** - * 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. + * 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 [track] = stream.getAudioTracks(); - if (track?.readyState !== 'live') { + const [newTrack] = stream.getAudioTracks(); + if (newTrack?.readyState !== 'live') { for (const t of stream.getTracks()) t.stop(); throw new Error('microphone unavailable'); } @@ -93,6 +103,7 @@ export class STT { /** * 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() { @@ -118,10 +129,10 @@ export class STT { /** * _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). + * 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 */ @@ -129,7 +140,6 @@ export class STT { return new Promise((resolve, reject) => { const mr = this.mediaRecorder; if (!mr || mr.state === 'inactive') { - this._release(); resolve(new Blob([])); return; } @@ -142,7 +152,7 @@ export class STT { if (type?.includes('mp4') || type?.includes('m4a')) { this._ext = 'm4a'; } - this._release(); + this.audioChunks = []; resolve(blob); }, { once: true }, @@ -151,7 +161,7 @@ export class STT { mr.addEventListener( 'error', (event) => { - this._release(); + this.audioChunks = []; reject(event.error); }, { once: true },