Refactor STT

This commit is contained in:
dwrz
2026-07-08 18:34:04 +00:00
parent 90f7439161
commit 98986a884a

View File

@@ -4,12 +4,12 @@
* decides what to do with the result. * decides what to do with the result.
* *
* Recording lifecycle: acquire() requests microphone access (async, * Recording lifecycle: acquire() requests microphone access (async,
* may prompt) and creates a fresh MediaRecorder; start() begins * may prompt) and creates the MediaRecorder; start() begins capturing
* capturing (synchronous); stop() resolves the audio blob and * (synchronous); stop() resolves the audio blob. The stream and
* releases the stream. A fresh stream is acquired per recording and * recorder are reused across recordings to avoid codec warmup delay.
* torn down on every stop() so iOS/WebKit never reuses a capture * Before each acquire(), the track state is checked; if iOS/WebKit
* track that TTS playback or an audio-session interruption has * has killed the track due to TTS playback or an audio-session
* quietly killed. * interruption, a fresh stream is obtained.
*/ */
/** /**
@@ -41,19 +41,29 @@ export class STT {
} }
/** /**
* acquire requests microphone access and creates a fresh * acquire requests microphone access and creates the MediaRecorder.
* MediaRecorder. Always starts clean so iOS never reuses a dead * If an existing recorder has a live track, it is reused to avoid
* capture track. Must be called before start. * 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() { 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(); this._release();
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
audio: true, audio: true,
}); });
const [track] = stream.getAudioTracks(); const [newTrack] = stream.getAudioTracks();
if (track?.readyState !== 'live') { if (newTrack?.readyState !== 'live') {
for (const t of stream.getTracks()) t.stop(); for (const t of stream.getTracks()) t.stop();
throw new Error('microphone unavailable'); throw new Error('microphone unavailable');
} }
@@ -93,6 +103,7 @@ export class STT {
/** /**
* stop stops recording, transcribes the audio, and returns the result. * 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}>} * @returns {Promise<{text: string, detectedLanguage: string}>}
*/ */
async stop() { async stop() {
@@ -118,10 +129,10 @@ export class STT {
/** /**
* _stopRecording stops the MediaRecorder and resolves with the * _stopRecording stops the MediaRecorder and resolves with the
* assembled audio blob, releasing the stream. Returns an empty blob * assembled audio blob. Does not release the stream or recorder,
* if there is no active recording, so it is safe to call before * so they can be reused. Returns an empty blob if there is no
* start has run (e.g. if the user released while acquire was still * active recording, so it is safe to call before start has run
* pending). * (e.g., if the user released while acquire was still pending).
* @returns {Promise<Blob>} * @returns {Promise<Blob>}
* @private * @private
*/ */
@@ -129,7 +140,6 @@ export class STT {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const mr = this.mediaRecorder; const mr = this.mediaRecorder;
if (!mr || mr.state === 'inactive') { if (!mr || mr.state === 'inactive') {
this._release();
resolve(new Blob([])); resolve(new Blob([]));
return; return;
} }
@@ -142,7 +152,7 @@ export class STT {
if (type?.includes('mp4') || type?.includes('m4a')) { if (type?.includes('mp4') || type?.includes('m4a')) {
this._ext = 'm4a'; this._ext = 'm4a';
} }
this._release(); this.audioChunks = [];
resolve(blob); resolve(blob);
}, },
{ once: true }, { once: true },
@@ -151,7 +161,7 @@ export class STT {
mr.addEventListener( mr.addEventListener(
'error', 'error',
(event) => { (event) => {
this._release(); this.audioChunks = [];
reject(event.error); reject(event.error);
}, },
{ once: true }, { once: true },