Refactor TTS architecture
This commit is contained in:
@@ -4,7 +4,6 @@ const MODELS_ENDPOINT = '/v1/models';
|
||||
const MODEL_KEY = 'odidere_model';
|
||||
const STORAGE_KEY = 'odidere_history';
|
||||
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
||||
const VOICES_ENDPOINT = '/v1/voices';
|
||||
const VOICE_KEY = 'odidere_voice';
|
||||
|
||||
/**
|
||||
@@ -38,6 +37,14 @@ class Odidere {
|
||||
this.leafId = null;
|
||||
this.rootId = null;
|
||||
|
||||
// TTS state
|
||||
this.ttsUrl = document.querySelector('meta[name="tts-url"]')?.content || '';
|
||||
this.ttsDefaultVoice =
|
||||
document.querySelector('meta[name="tts-default-voice"]')?.content || '';
|
||||
this.playQueue = [];
|
||||
this.isPlaying = false;
|
||||
this.currentMessageId = null;
|
||||
|
||||
// Auto-scroll: enabled by default, disabled when user scrolls up,
|
||||
// re-enabled when they scroll back to bottom or send a new message.
|
||||
this.isAutoScrollEnabled = true;
|
||||
@@ -92,6 +99,7 @@ class Odidere {
|
||||
track.stop();
|
||||
}
|
||||
this.#stopCurrentAudio();
|
||||
this.#stopTTS();
|
||||
this.currentController?.abort();
|
||||
this.#releaseWakeLock();
|
||||
}
|
||||
@@ -102,6 +110,7 @@ class Odidere {
|
||||
*/
|
||||
reset() {
|
||||
this.#stopCurrentAudio();
|
||||
this.#stopTTS();
|
||||
|
||||
if (this.currentController) {
|
||||
this.currentController.abort();
|
||||
@@ -234,7 +243,7 @@ class Odidere {
|
||||
|
||||
// ====================
|
||||
// CONVERSATION TREE
|
||||
// =====================
|
||||
// ====================
|
||||
|
||||
/**
|
||||
* #loadHistory loads chat history from localStorage and renders it.
|
||||
@@ -466,7 +475,7 @@ class Odidere {
|
||||
let current = startId;
|
||||
while (current) {
|
||||
const msg = this.messagesMap.get(current);
|
||||
if (!msg || !msg.children || msg.children.length === 0) break;
|
||||
if (!msg?.children || msg.children.length === 0) break;
|
||||
// Follow the first child. With branching, this picks the first branch.
|
||||
current = msg.children[0];
|
||||
}
|
||||
@@ -811,6 +820,7 @@ class Odidere {
|
||||
*/
|
||||
#stopConversation() {
|
||||
this.#stopCurrentAudio();
|
||||
this.#stopTTS();
|
||||
|
||||
if (this.currentController) {
|
||||
this.currentController.abort();
|
||||
@@ -833,6 +843,7 @@ class Odidere {
|
||||
if (this.isProcessing) return;
|
||||
|
||||
this.#stopCurrentAudio();
|
||||
this.#stopTTS();
|
||||
|
||||
// Initialize MediaRecorder on first use
|
||||
if (!this.mediaRecorder) {
|
||||
@@ -911,20 +922,92 @@ class Odidere {
|
||||
}
|
||||
|
||||
// ====================
|
||||
// AUDIO PLAYBACK
|
||||
// TTS PLAYBACK
|
||||
// ====================
|
||||
|
||||
/**
|
||||
* #playAudio decodes and plays base64-encoded WAV audio.
|
||||
* @param {string} base64Audio
|
||||
* #enqueueTTS adds a message to the TTS playback queue and starts
|
||||
* playback if not already playing.
|
||||
* @param {string} text
|
||||
* @param {string} voice
|
||||
* @param {string} messageId
|
||||
*/
|
||||
async #playAudio(base64Audio) {
|
||||
#enqueueTTS(text, voice, messageId) {
|
||||
if (!text || !this.ttsUrl) return;
|
||||
this.playQueue.push({ text, voice, messageId });
|
||||
if (!this.isPlaying) {
|
||||
this.#processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #processQueue pops the next item from the TTS queue and synthesizes it.
|
||||
*/
|
||||
async #processQueue() {
|
||||
if (this.playQueue.length === 0) {
|
||||
this.isPlaying = false;
|
||||
this.currentMessageId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
const item = this.playQueue.shift();
|
||||
this.currentMessageId = item.messageId;
|
||||
|
||||
// Update the play button on the message to show stop state.
|
||||
this.#setPlayButtonState(item.messageId, true);
|
||||
|
||||
await this.#synthesizeAndPlay(item.text, item.voice, item.messageId);
|
||||
|
||||
// Reset this message's button back to play state before moving on.
|
||||
this.#setPlayButtonState(item.messageId, false);
|
||||
|
||||
// Move to next item in queue.
|
||||
this.#processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* #stopTTS stops current audio playback, clears the queue, and resets UI.
|
||||
*/
|
||||
#stopTTS() {
|
||||
this.#stopCurrentAudio();
|
||||
this.playQueue = [];
|
||||
this.isPlaying = false;
|
||||
|
||||
// Reset all play buttons.
|
||||
if (this.currentMessageId) {
|
||||
this.#setPlayButtonState(this.currentMessageId, false);
|
||||
}
|
||||
this.currentMessageId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #synthesizeAndPlay calls kokoro-fastapi to synthesize text and plays
|
||||
* the resulting audio. On error, logs and returns without blocking the queue.
|
||||
* @param {string} text
|
||||
* @param {string} voice
|
||||
* @param {string} _messageId
|
||||
*/
|
||||
async #synthesizeAndPlay(text, voice, _messageId) {
|
||||
try {
|
||||
this.#stopCurrentAudio();
|
||||
|
||||
const audioData = Uint8Array.from(atob(base64Audio), (c) =>
|
||||
c.charCodeAt(0),
|
||||
);
|
||||
const audioBlob = new Blob([audioData], { type: 'audio/wav' });
|
||||
const res = await fetch(`${this.ttsUrl}/v1/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'tts-1',
|
||||
input: text,
|
||||
voice: voice || this.$voice.value || this.ttsDefaultVoice,
|
||||
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;
|
||||
@@ -951,11 +1034,59 @@ class Odidere {
|
||||
this.currentAudio.play().catch(reject);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('failed to play audio:', e);
|
||||
console.error('TTS synthesis failed:', e);
|
||||
this.#cleanupAudio();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #setPlayButtonState updates the play/stop button icon and state for a
|
||||
* given message.
|
||||
* @param {string} messageId
|
||||
* @param {boolean} playing
|
||||
*/
|
||||
#setPlayButtonState(messageId, playing) {
|
||||
const $msg = this.$chat.querySelector(`[data-id="${messageId}"]`);
|
||||
if (!$msg) return;
|
||||
const $btn = $msg.querySelector('[data-action="play"]');
|
||||
if (!$btn) return;
|
||||
|
||||
if (playing) {
|
||||
$btn.classList.add('playing');
|
||||
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#stop"></use></svg>`;
|
||||
$btn.setAttribute('aria-label', 'Stop');
|
||||
} else {
|
||||
$btn.classList.remove('playing');
|
||||
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#volume"></use></svg>`;
|
||||
$btn.setAttribute('aria-label', 'Play');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #handlePlayClick handles the play/stop button on an assistant message.
|
||||
* If not playing: stop any current audio, enqueue this message, start playing.
|
||||
* If currently playing this message: stop playback, clear remaining queue, reset.
|
||||
* @param {MouseEvent} event
|
||||
* @param {string} messageId
|
||||
* @param {string} text
|
||||
* @param {string} voice
|
||||
*/
|
||||
#handlePlayClick(event, messageId, text, voice) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.currentMessageId === messageId) {
|
||||
// Currently playing this message: stop everything.
|
||||
this.#stopTTS();
|
||||
} else {
|
||||
// Not playing this message: stop current playback, enqueue this one.
|
||||
this.#stopTTS();
|
||||
this.#enqueueTTS(text, voice, messageId);
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
// AUDIO PLAYBACK
|
||||
// ====================
|
||||
/**
|
||||
* #stopCurrentAudio stops and cleans up any playing audio.
|
||||
*/
|
||||
@@ -1002,13 +1133,17 @@ class Odidere {
|
||||
*/
|
||||
async #fetchVoices() {
|
||||
try {
|
||||
const res = await fetch(VOICES_ENDPOINT);
|
||||
const res = await fetch(`${this.ttsUrl}/v1/audio/voices`);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
// Filter out legacy v0 voices.
|
||||
const filtered = data.voices.filter((v) => !v.includes('_v0'));
|
||||
this.#populateVoiceSelect(filtered);
|
||||
const filtered = data.voices.filter((v) => {
|
||||
const id = typeof v === 'string' ? v : v.id;
|
||||
return !id.includes('_v0');
|
||||
});
|
||||
const voiceIds = filtered.map((v) => (typeof v === 'string' ? v : v.id));
|
||||
this.#populateVoiceSelect(voiceIds);
|
||||
} catch (e) {
|
||||
console.error('failed to fetch voices:', e);
|
||||
this.$voice.innerHTML = '';
|
||||
@@ -1034,6 +1169,7 @@ class Odidere {
|
||||
if (this.isProcessing) return;
|
||||
|
||||
this.#stopCurrentAudio();
|
||||
this.#stopTTS();
|
||||
this.$textInput.value = '';
|
||||
this.$textInput.style.height = 'auto';
|
||||
|
||||
@@ -1050,7 +1186,7 @@ class Odidere {
|
||||
*
|
||||
* Messages are rendered incrementally as SSE events arrive:
|
||||
* - user (transcription), assistant (tool calls), tool (results),
|
||||
* and a final assistant message with synthesized audio.
|
||||
* and a final assistant message with content.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Blob} [options.audio] - Recorded audio blob
|
||||
@@ -1234,10 +1370,13 @@ class Odidere {
|
||||
const appended = this.#appendHistory([message], meta);
|
||||
this.#renderMessages(appended, meta);
|
||||
|
||||
// For the final assistant message with audio, play it.
|
||||
if (event.audio) {
|
||||
this.#setLoadingState(false);
|
||||
await this.#playAudio(event.audio);
|
||||
// Enqueue TTS for the final assistant message with content.
|
||||
if (message.content) {
|
||||
this.#enqueueTTS(
|
||||
message.content,
|
||||
event.voice || this.$voice.value || '',
|
||||
message.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1766,6 +1905,17 @@ class Odidere {
|
||||
$inspect.setAttribute('aria-expanded', String(isOpen));
|
||||
});
|
||||
|
||||
// Play button: only for messages with non-empty content.
|
||||
const $play = $msg.querySelector('[data-action="play"]');
|
||||
if (message.content) {
|
||||
const voice = meta?.voice || this.$voice.value || '';
|
||||
$play.addEventListener('click', (e) =>
|
||||
this.#handlePlayClick(e, message.id, message.content, voice),
|
||||
);
|
||||
} else {
|
||||
$play.remove();
|
||||
}
|
||||
|
||||
// Assemble text from all available content.
|
||||
const copyParts = [];
|
||||
if (message.reasoning_content) copyParts.push(message.reasoning_content);
|
||||
|
||||
Reference in New Issue
Block a user