Files
odidere/internal/service/static/js/stt.js

218 lines
5.9 KiB
JavaScript
Raw Normal View History

2026-07-04 12:44:59 +00:00
/**
* 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,
2026-07-08 18:34:04 +00:00
* 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.
2026-07-04 12:44:59 +00:00
*/
/**
* 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';
}
/**
2026-07-08 18:34:04 +00:00
* 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.
2026-07-04 12:44:59 +00:00
*/
async acquire() {
2026-07-08 18:34:04 +00:00
// 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.
2026-07-04 12:44:59 +00:00
this._release();
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
2026-07-08 18:34:04 +00:00
const [newTrack] = stream.getAudioTracks();
if (newTrack?.readyState !== 'live') {
2026-07-04 12:44:59 +00:00
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.
2026-07-08 18:34:04 +00:00
* The stream and recorder are kept alive for reuse.
2026-07-04 12:44:59 +00:00
* @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
2026-07-08 18:34:04 +00:00
* 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).
2026-07-04 12:44:59 +00:00
* @returns {Promise<Blob>}
* @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';
}
2026-07-08 18:34:04 +00:00
this.audioChunks = [];
2026-07-04 12:44:59 +00:00
resolve(blob);
},
{ once: true },
);
mr.addEventListener(
'error',
(event) => {
2026-07-08 18:34:04 +00:00
this.audioChunks = [];
2026-07-04 12:44:59 +00:00
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 };
}
}