Refactor UI
This commit is contained in:
200
internal/service/static/tts.js
Normal file
200
internal/service/static/tts.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user