fix: voice status global store, per-view mic reactivity, single-voice preview

- Move voice status into settings store (voiceSttReady, voiceTtsReady),
  checked once at login and refreshed after admin model reload
- ChatView, BriefingView, DashboardChatInput now use computed refs from
  the store — mic buttons appear reactively without needing a page reload
- BriefingView: separate STT-only guard for mic PTT vs TTS-only guard for
  listen mode / speak buttons
- Add ▶ Preview button to Voice & Speed section in Settings for single-
  voice testing without enabling blend mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 20:53:06 -04:00
parent b4b4b0d9d6
commit 71ca0ecb5c
7 changed files with 70 additions and 37 deletions
+1
View File
@@ -0,0 +1 @@
2298268
+1
View File
@@ -22,6 +22,7 @@ const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
function startAppServices() { function startAppServices() {
chatStore.startStatusPolling(); chatStore.startStatusPolling();
settingsStore.fetchSettings(); settingsStore.fetchSettings();
settingsStore.checkVoiceStatus();
// Sync browser timezone to the server on every login/page load. // Sync browser timezone to the server on every login/page load.
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {}); apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
} }
+5 -10
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, onMounted } from "vue"; import { ref, computed, nextTick } from "vue";
import { apiGet, getVoiceStatus, transcribeAudio } from "@/api/client"; import { apiGet, transcribeAudio } from "@/api/client";
import { useVoiceRecorder } from "@/composables/useVoiceRecorder"; import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import type { Note } from "@/types/note"; import type { Note } from "@/types/note";
const emit = defineEmits<{ const emit = defineEmits<{
@@ -108,17 +109,11 @@ function focus() {
} }
// Voice PTT // Voice PTT
const voiceEnabled = ref(false); const settingsStore = useSettingsStore();
const voiceEnabled = computed(() => settingsStore.voiceSttReady);
const transcribingVoice = ref(false); const transcribingVoice = ref(false);
const recorder = useVoiceRecorder(); const recorder = useVoiceRecorder();
onMounted(async () => {
try {
const status = await getVoiceStatus();
voiceEnabled.value = status.enabled && status.stt;
} catch { /* voice absent */ }
});
async function startPtt() { async function startPtt() {
if (!voiceEnabled.value || recorder.recording.value) return; if (!voiceEnabled.value || recorder.recording.value) return;
await recorder.startRecording(); await recorder.startRecording();
+23 -1
View File
@@ -1,6 +1,6 @@
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { apiGet, apiPut } from "@/api/client"; import { apiGet, apiPut, getVoiceStatus } from "@/api/client";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import type { AppSettings } from "@/types/settings"; import type { AppSettings } from "@/types/settings";
@@ -16,6 +16,24 @@ export const useSettingsStore = defineStore("settings", () => {
() => settings.value.default_model || "" () => settings.value.default_model || ""
); );
// Voice status — checked once on login, refreshable from Settings
const voiceEnabled = ref(false);
const voiceSttReady = ref(false);
const voiceTtsReady = ref(false);
async function checkVoiceStatus() {
try {
const s = await getVoiceStatus();
voiceEnabled.value = s.enabled;
voiceSttReady.value = s.enabled && s.stt;
voiceTtsReady.value = s.enabled && s.tts;
} catch {
voiceEnabled.value = false;
voiceSttReady.value = false;
voiceTtsReady.value = false;
}
}
async function fetchSettings() { async function fetchSettings() {
loading.value = true; loading.value = true;
try { try {
@@ -44,6 +62,10 @@ export const useSettingsStore = defineStore("settings", () => {
loading, loading,
assistantName, assistantName,
defaultModel, defaultModel,
voiceEnabled,
voiceSttReady,
voiceTtsReady,
checkVoiceStatus,
fetchSettings, fetchSettings,
updateSettings, updateSettings,
}; };
+9 -15
View File
@@ -4,6 +4,7 @@ import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder' import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useVoiceAudio } from '@/composables/useVoiceAudio' import { useVoiceAudio } from '@/composables/useVoiceAudio'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import ChatMessage from '@/components/ChatMessage.vue' import ChatMessage from '@/components/ChatMessage.vue'
import WeatherCard from '@/components/WeatherCard.vue' import WeatherCard from '@/components/WeatherCard.vue'
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue' import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
@@ -17,7 +18,6 @@ import {
postRssReaction, postRssReaction,
deleteRssReaction, deleteRssReaction,
getNewsItems, getNewsItems,
getVoiceStatus,
transcribeAudio, transcribeAudio,
synthesiseSpeech, synthesiseSpeech,
type BriefingConversation, type BriefingConversation,
@@ -39,6 +39,7 @@ interface WeatherData {
} }
const chatStore = useChatStore() const chatStore = useChatStore()
const settingsStore = useSettingsStore()
// Setup wizard // Setup wizard
const showWizard = ref(false) const showWizard = ref(false)
@@ -230,7 +231,9 @@ function convLabel(c: BriefingConversation): string {
} }
// ─── Voice ──────────────────────────────────────────────────────────────────── // ─── Voice ────────────────────────────────────────────────────────────────────
const voiceEnabled = ref(false) // Mic PTT needs STT; listen/speak mode also needs TTS
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
const listenMode = ref(false) const listenMode = ref(false)
const transcribing = ref(false) const transcribing = ref(false)
const synthesising = ref(false) const synthesising = ref(false)
@@ -238,14 +241,6 @@ const synthesising = ref(false)
const recorder = useVoiceRecorder() const recorder = useVoiceRecorder()
const audio = useVoiceAudio() const audio = useVoiceAudio()
// Check voice availability once on mount
async function checkVoice() {
try {
const status = await getVoiceStatus()
voiceEnabled.value = status.enabled && status.stt && status.tts
} catch { /* voice feature absent */ }
}
// Read the latest assistant message aloud // Read the latest assistant message aloud
async function listenToLatest() { async function listenToLatest() {
const lastAssistant = [...messages.value].reverse().find((m) => m.role === 'assistant') const lastAssistant = [...messages.value].reverse().find((m) => m.role === 'assistant')
@@ -254,7 +249,7 @@ async function listenToLatest() {
} }
async function speakText(text: string) { async function speakText(text: string) {
if (!voiceEnabled.value || synthesising.value) return if (!voiceTtsEnabled.value || synthesising.value) return
// Strip markdown for cleaner TTS output // Strip markdown for cleaner TTS output
const plain = text const plain = text
.replace(/```[\s\S]*?```/g, '') .replace(/```[\s\S]*?```/g, '')
@@ -304,7 +299,7 @@ async function stopPtt() {
// Auto-TTS when listen mode is on and streaming ends // Auto-TTS when listen mode is on and streaming ends
watch(() => chatStore.streaming, async (streaming) => { watch(() => chatStore.streaming, async (streaming) => {
if (!streaming && listenMode.value && voiceEnabled.value) { if (!streaming && listenMode.value && voiceTtsEnabled.value) {
// Small delay to let messages update after stream // Small delay to let messages update after stream
await new Promise((r) => setTimeout(r, 200)) await new Promise((r) => setTimeout(r, 200))
await listenToLatest() await listenToLatest()
@@ -348,7 +343,6 @@ useBackgroundRefresh(
onMounted(async () => { onMounted(async () => {
await checkSetup() await checkSetup()
if (!showWizard.value) await loadAll() if (!showWizard.value) await loadAll()
checkVoice()
}) })
</script> </script>
@@ -430,7 +424,7 @@ onMounted(async () => {
<div v-if="isToday" class="briefing-input-bar"> <div v-if="isToday" class="briefing-input-bar">
<!-- Listen toggle --> <!-- Listen toggle -->
<button <button
v-if="voiceEnabled" v-if="voiceTtsEnabled"
class="btn-icon" class="btn-icon"
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': synthesising || audio.playing.value }" :class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': synthesising || audio.playing.value }"
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())" @click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
@@ -446,7 +440,7 @@ onMounted(async () => {
</button> </button>
<!-- Stop TTS playback --> <!-- Stop TTS playback -->
<button <button
v-if="voiceEnabled && (synthesising || audio.playing.value)" v-if="voiceTtsEnabled && (synthesising || audio.playing.value)"
class="btn-icon btn-icon-danger" class="btn-icon btn-icon-danger"
@click="audio.stop(); synthesising = false" @click="audio.stop(); synthesising = false"
title="Stop playback" title="Stop playback"
+4 -11
View File
@@ -3,7 +3,7 @@ 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, getVoiceStatus, transcribeAudio } from "@/api/client"; import { apiGet, transcribeAudio } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import { useVoiceRecorder } from "@/composables/useVoiceRecorder"; import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
import ChatMessage from "@/components/ChatMessage.vue"; import ChatMessage from "@/components/ChatMessage.vue";
@@ -23,17 +23,10 @@ const sending = ref(false);
const summarizing = ref(false); const summarizing = ref(false);
const sidebarOpen = ref(false); const sidebarOpen = ref(false);
// Voice PTT // Voice PTT — use store so status is globally reactive
const voiceEnabled = ref(false);
const transcribingVoice = ref(false); const transcribingVoice = ref(false);
const recorder = useVoiceRecorder(); const recorder = useVoiceRecorder();
const voiceEnabled = computed(() => settingsStore.voiceSttReady);
async function checkVoice() {
try {
const status = await getVoiceStatus();
voiceEnabled.value = status.enabled && status.stt;
} catch { /* voice absent */ }
}
async function startPtt() { async function startPtt() {
if (!voiceEnabled.value || recorder.recording.value) return; if (!voiceEnabled.value || recorder.recording.value) return;
@@ -178,7 +171,7 @@ const inputPlaceholder = computed(() => {
onMounted(async () => { onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown); document.addEventListener("keydown", onGlobalKeydown);
await Promise.all([store.fetchConversations(), loadProjects(), checkVoice()]); await Promise.all([store.fetchConversations(), loadProjects()]);
if (convId.value) { if (convId.value) {
if (store.currentConversation?.id !== convId.value) { if (store.currentConversation?.id !== convId.value) {
await store.fetchConversation(convId.value); await store.fetchConversation(convId.value);
+27
View File
@@ -484,6 +484,8 @@ async function reloadVoiceModels() {
if (status.stt && status.tts) { if (status.stt && status.tts) {
voiceStatus.value = status; voiceStatus.value = status;
voiceTabLoaded.value = true; voiceTabLoaded.value = true;
// Propagate to global store so mic buttons appear everywhere
store.checkVoiceStatus();
break; break;
} }
} catch { /* keep polling */ } } catch { /* keep polling */ }
@@ -569,6 +571,28 @@ async function loadVoiceTab() {
} }
} }
const voicePreviewing = ref(false);
let _voicePreviewUrl = "";
async function previewVoice() {
if (voicePreviewing.value) return;
voicePreviewing.value = true;
try {
const blob = await synthesiseSpeech(
"Hello! I'm your assistant. How can I help you today?",
voiceTtsVoice.value,
voiceTtsSpeed.value,
);
if (_voicePreviewUrl) URL.revokeObjectURL(_voicePreviewUrl);
_voicePreviewUrl = URL.createObjectURL(blob);
new Audio(_voicePreviewUrl).play();
} catch {
toastStore.show("Preview failed — TTS may not be ready", "error");
} finally {
voicePreviewing.value = false;
}
}
async function saveVoiceSettings() { async function saveVoiceSettings() {
savingVoice.value = true; savingVoice.value = true;
voiceSaved.value = false; voiceSaved.value = false;
@@ -2250,6 +2274,9 @@ function formatUserDate(iso: string): string {
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn-secondary" @click="previewVoice" :disabled="voicePreviewing || !voiceStatus?.tts">
{{ voicePreviewing ? 'Generating' : ' Preview' }}
</button>
<button class="btn-save" @click="saveVoiceSettings" :disabled="savingVoice"> <button class="btn-save" @click="saveVoiceSettings" :disabled="savingVoice">
{{ voiceSaved ? 'Saved ' : savingVoice ? 'Saving' : 'Save Voice Settings' }} {{ voiceSaved ? 'Saved ' : savingVoice ? 'Saving' : 'Save Voice Settings' }}
</button> </button>