Files
odidere/internal/service/static/stt.js
2026-07-05 00:48:56 +00:00

208 lines
5.4 KiB
JavaScript

/**
* 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<Blob>}
* @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 };
}
}