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

213 lines
5.1 KiB
JavaScript
Raw Normal View History

2026-07-04 12:44:59 +00:00
/**
* TTSPlayer synthesizes text via kokoro-fastapi, manages a playback
* queue, and plays audio. Extends EventTarget for playback state
* notifications.
*
* Events dispatched:
* 'playbackstart' { detail: { messageId } }
* 'playbackend' { detail: { messageId } }
* 'stopped' (no detail)
*/
export class TTSPlayer extends EventTarget {
/**
* @param {Object} options
* @param {string} options.url - kokoro-fastapi base URL
* @param {string} options.defaultVoice
*/
constructor({ url, defaultVoice }) {
super();
this.url = url;
this.defaultVoice = defaultVoice;
this.playQueue = [];
this._playing = false;
this._currentMessageId = null;
this.currentAudio = null;
this.currentAudioURL = null;
this._muted = false;
2026-07-08 18:31:12 +00:00
this._abortController = null;
2026-07-04 12:44:59 +00:00
}
/**
* enqueue adds a message to the TTS playback queue and starts
* playback if not already playing.
* @param {string} text
* @param {string} voice
* @param {string} messageId
*/
enqueue(text, voice, messageId) {
if (!text || !this.url) return;
this.playQueue.push({ text, voice, messageId });
if (!this._playing) {
this._processQueue();
}
}
/**
* stop stops current audio playback, clears the queue, and resets.
*/
stop() {
this._stopCurrentAudio();
2026-07-08 18:31:12 +00:00
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
}
2026-07-04 12:44:59 +00:00
this.playQueue = [];
this._playing = false;
if (this.currentMessageId) {
this.dispatchEvent(
new CustomEvent('playbackend', {
detail: { messageId: this.currentMessageId },
}),
);
}
this._currentMessageId = null;
this.dispatchEvent(new CustomEvent('stopped'));
}
/**
* setMuted updates the mute state for current and future playback.
* @param {boolean} muted
*/
setMuted(muted) {
this._muted = muted;
if (this.currentAudio) {
this.currentAudio.muted = muted;
}
}
/** @returns {boolean} */
get isPlaying() {
return this._playing;
}
/** @returns {string|null} */
get currentMessageId() {
return this._currentMessageId;
}
// Private implementation
/**
* _processQueue pops the next item and synthesizes/plays it.
* @private
*/
async _processQueue() {
if (this.playQueue.length === 0) {
this._playing = false;
this._currentMessageId = null;
return;
}
this._playing = true;
const item = this.playQueue.shift();
this._currentMessageId = item.messageId;
this.dispatchEvent(
new CustomEvent('playbackstart', {
detail: { messageId: item.messageId },
}),
);
await this._synthesizeAndPlay(item.text, item.voice);
this.dispatchEvent(
new CustomEvent('playbackend', {
detail: { messageId: item.messageId },
}),
);
// Move to next item in queue.
this._processQueue();
}
/**
* _synthesizeAndPlay calls kokoro-fastapi to synthesize text and
* plays the resulting audio.
* @param {string} text
* @param {string} voice
* @private
*/
async _synthesizeAndPlay(text, voice) {
2026-07-08 18:31:12 +00:00
this._abortController = new AbortController();
const { signal } = this._abortController;
2026-07-04 12:44:59 +00:00
try {
this._stopCurrentAudio();
const res = await fetch(`${this.url}/v1/audio/speech`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'tts-1',
input: text,
voice: voice || this.defaultVoice,
response_format: 'wav',
}),
2026-07-08 18:31:12 +00:00
signal,
2026-07-04 12:44:59 +00:00
});
if (!res.ok) {
throw new Error(`TTS error ${res.status}`);
}
const audioBlob = await res.blob();
const audioURL = URL.createObjectURL(audioBlob);
this.currentAudioURL = audioURL;
this.currentAudio = new Audio(audioURL);
this.currentAudio.muted = this._muted;
await new Promise((resolve, reject) => {
this.currentAudio.addEventListener(
'ended',
() => {
this._cleanupAudio();
resolve();
},
{ once: true },
);
this.currentAudio.addEventListener(
'error',
(e) => {
this._cleanupAudio();
reject(e);
},
{ once: true },
);
this.currentAudio.play().catch(reject);
});
} catch (e) {
2026-07-08 18:31:12 +00:00
if (e.name === 'AbortError') return;
2026-07-04 12:44:59 +00:00
console.error('TTS synthesis failed:', e);
this._cleanupAudio();
2026-07-08 18:31:12 +00:00
} finally {
this._abortController = null;
2026-07-04 12:44:59 +00:00
}
}
/**
* _stopCurrentAudio stops and cleans up any playing audio.
* @private
*/
_stopCurrentAudio() {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
}
this._cleanupAudio();
}
/**
* _cleanupAudio revokes the object URL and clears audio references.
* @private
*/
_cleanupAudio() {
if (this.currentAudioURL) {
URL.revokeObjectURL(this.currentAudioURL);
this.currentAudioURL = null;
}
this.currentAudio = null;
}
}