218 lines
5.9 KiB
JavaScript
218 lines
5.9 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 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.
|
|
*/
|
|
|
|
/**
|
|
* 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 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 [newTrack] = stream.getAudioTracks();
|
|
if (newTrack?.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.
|
|
* The stream and recorder are kept alive for reuse.
|
|
* @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. 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
|
|
*/
|
|
_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';
|
|
}
|
|
this.audioChunks = [];
|
|
resolve(blob);
|
|
},
|
|
{ once: true },
|
|
);
|
|
|
|
mr.addEventListener(
|
|
'error',
|
|
(event) => {
|
|
this.audioChunks = [];
|
|
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 };
|
|
}
|
|
}
|