feat(tts): wire useStreamingTts into ChatView

This commit is contained in:
2026-04-03 13:09:52 -04:00
parent 7ffd412603
commit fda6a7acc1
+11 -36
View File
@@ -3,7 +3,8 @@ import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
import { apiGet, transcribeAudio, synthesiseSpeech } from "@/api/client"; import { apiGet, transcribeAudio } from "@/api/client";
import { useStreamingTts } from "@/composables/useStreamingTts";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import { useVoiceRecorder } from "@/composables/useVoiceRecorder"; import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
import { useVoiceAudio, setVoiceVolume } from "@/composables/useVoiceAudio"; import { useVoiceAudio, setVoiceVolume } from "@/composables/useVoiceAudio";
@@ -32,37 +33,11 @@ const audio = useVoiceAudio();
const voiceEnabled = computed(() => settingsStore.voiceSttReady); const voiceEnabled = computed(() => settingsStore.voiceSttReady);
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady); const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady);
const listenMode = useListenMode(); const listenMode = useListenMode();
const synthesising = ref(false);
const showVolumeSlider = ref(false); const showVolumeSlider = ref(false);
const tts = useStreamingTts({
async function speakLastAssistantMessage() { streamingContent: computed(() => store.streamingContent),
const messages = store.currentConversation?.messages ?? []; streaming: computed(() => store.streaming),
const last = [...messages].reverse().find((m) => m.role === "assistant"); enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
if (!last?.content) return;
const plain = last.content
.replace(/```[\s\S]*?```/g, "")
.replace(/`[^`]+`/g, (m: string) => m.slice(1, -1))
.replace(/#{1,6}\s+/g, "")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/\n{2,}/g, " ")
.trim();
if (!plain) return;
synthesising.value = true;
try {
const blob = await synthesiseSpeech(plain);
await audio.play(blob);
} catch { /* TTS failure non-critical */ }
finally { synthesising.value = false; }
}
watch(() => store.streaming, async (streaming) => {
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
await new Promise((r) => setTimeout(r, 200));
await speakLastAssistantMessage();
}
}); });
async function startPtt() { async function startPtt() {
@@ -916,12 +891,12 @@ onUnmounted(() => {
<div class="volume-wrapper"> <div class="volume-wrapper">
<button <button
class="btn-attach btn-listen" class="btn-attach btn-listen"
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': synthesising || audio.playing.value }" :class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': tts.speaking.value }"
@click="listenMode = !listenMode; if (listenMode) speakLastAssistantMessage()" @click="listenMode = !listenMode; if (!listenMode) tts.stop()"
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'" :title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
aria-label="Toggle listen mode" aria-label="Toggle listen mode"
> >
<svg v-if="!synthesising && !audio.playing.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor"> <svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/> <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg> </svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor"> <svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
@@ -951,9 +926,9 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<button <button
v-if="synthesising || audio.playing.value" v-if="tts.speaking.value"
class="btn-attach" class="btn-attach"
@click="audio.stop(); synthesising = false" @click="tts.stop()"
title="Stop playback" title="Stop playback"
> >
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg> <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>