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.
*
* 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<Blob>}
* @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 },