/** * 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; } }