201 lines
4.7 KiB
JavaScript
201 lines
4.7 KiB
JavaScript
|
|
/**
|
||
|
|
* 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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();
|
||
|
|
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) {
|
||
|
|
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',
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
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) {
|
||
|
|
console.error('TTS synthesis failed:', e);
|
||
|
|
this._cleanupAudio();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* _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;
|
||
|
|
}
|
||
|
|
}
|