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 { useChatStore } from "@/stores/chat";
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 { useVoiceRecorder } from "@/composables/useVoiceRecorder";
import { useVoiceAudio, setVoiceVolume } from "@/composables/useVoiceAudio";
@@ -32,37 +33,11 @@ const audio = useVoiceAudio();
const voiceEnabled = computed(() => settingsStore.voiceSttReady);
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady);
const listenMode = useListenMode();
const synthesising = ref(false);
const showVolumeSlider = ref(false);
async function speakLastAssistantMessage() {
const messages = store.currentConversation?.messages ?? [];
const last = [...messages].reverse().find((m) => m.role === "assistant");
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();
}
const tts = useStreamingTts({
streamingContent: computed(() => store.streamingContent),
streaming: computed(() => store.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
});
async function startPtt() {
@@ -916,12 +891,12 @@ onUnmounted(() => {
<div class="volume-wrapper">
<button
class="btn-attach btn-listen"
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': synthesising || audio.playing.value }"
@click="listenMode = !listenMode; if (listenMode) speakLastAssistantMessage()"
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': tts.speaking.value }"
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
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"/>
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
@@ -951,9 +926,9 @@ onUnmounted(() => {
</div>
</div>
<button
v-if="synthesising || audio.playing.value"
v-if="tts.speaking.value"
class="btn-attach"
@click="audio.stop(); synthesising = false"
@click="tts.stop()"
title="Stop playback"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>