208 lines
5.4 KiB
JavaScript
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 };
|
||
|
|
}
|
||
|
|
}
|