diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 0db3609..6b4e139 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -3,7 +3,6 @@ import { onMounted, onUnmounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import AppHeader from "@/components/AppHeader.vue";
import ToastNotification from "@/components/ToastNotification.vue";
-import VoiceOverlay from "@/components/VoiceOverlay.vue";
import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
@@ -26,6 +25,15 @@ function startAppServices() {
settingsStore.checkVoiceStatus();
// Sync browser timezone to the server on every login/page load.
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
+ // Re-check voice status when the tab becomes visible again (model may
+ // have finished loading while the user was away).
+ document.addEventListener("visibilitychange", onVisibilityChange);
+}
+
+function onVisibilityChange() {
+ if (document.visibilityState === "visible" && authStore.isAuthenticated) {
+ settingsStore.checkVoiceStatus();
+ }
}
function stopAppServices() {
@@ -121,10 +129,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
router.push("/chat");
}
break;
- case " ":
- e.preventDefault();
- document.dispatchEvent(new CustomEvent("voice:ptt-toggle"));
- break;
}
}
@@ -155,6 +159,7 @@ watch(
onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown);
+ document.removeEventListener("visibilitychange", onVisibilityChange);
stopAppServices();
});
@@ -274,10 +279,6 @@ onUnmounted(() => {
Enter
New line
-
- Space
- Tap to speak (voice, when enabled)
-
@@ -287,7 +288,6 @@ onUnmounted(() => {
-
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index c97b290..c0585b2 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -649,9 +649,10 @@ export const getVoiceStatus = () => apiGet('/api/voice/status
export const getVoiceList = () =>
apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices)
-export async function transcribeAudio(blob: Blob): Promise<{ transcript: string; duration_ms: number }> {
+export async function transcribeAudio(blob: Blob, context?: string): Promise<{ transcript: string; duration_ms: number }> {
const form = new FormData()
form.append('audio', blob, 'audio.webm')
+ if (context) form.append('context', context)
const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
diff --git a/frontend/src/components/ChatInputBar.vue b/frontend/src/components/ChatInputBar.vue
index 218961e..da56ec4 100644
--- a/frontend/src/components/ChatInputBar.vue
+++ b/frontend/src/components/ChatInputBar.vue
@@ -2,8 +2,10 @@
import { ref, computed, nextTick } from 'vue'
import { apiGet, transcribeAudio } from '@/api/client'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
+import { useSilenceDetector } from '@/composables/useSilenceDetector'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
+import { useToastStore } from '@/stores/toast'
import type { Note } from '@/types/note'
const props = withDefaults(defineProps<{
@@ -107,21 +109,46 @@ function removeAttachedNote() {
attachedNote.value = null
}
-// ── PTT ───────────────────────────────────────────────────────────────────────
+// ── Voice (click-to-toggle + silence detection) ─────────────────────────────
const transcribingVoice = ref(false)
const recorder = useVoiceRecorder()
+const silenceDetector = useSilenceDetector()
-async function startPtt() {
- if (!voiceEnabled.value || recorder.recording.value) return
- await recorder.startRecording()
+async function toggleVoice() {
+ if (transcribingVoice.value) return
+ if (recorder.recording.value) {
+ await stopRecording()
+ } else {
+ await startRecording()
+ }
}
-async function stopPtt() {
+async function startRecording() {
+ if (!voiceEnabled.value || recorder.recording.value) return
+ if (!recorder.isSupported) {
+ useToastStore().show('Microphone requires HTTPS or localhost', 'error')
+ return
+ }
+ await recorder.startRecording()
+ if (recorder.error.value) {
+ useToastStore().show(recorder.error.value, 'error')
+ return
+ }
+ if (recorder.stream.value) {
+ silenceDetector.start(recorder.stream.value, () => stopRecording())
+ }
+}
+
+async function stopRecording() {
+ silenceDetector.stop()
if (!recorder.recording.value) return
transcribingVoice.value = true
try {
const blob = await recorder.stopRecording()
- const { transcript } = await transcribeAudio(blob)
+ // Pass last assistant message as context to reduce STT mishearings
+ const msgs = store.currentConversation?.messages ?? []
+ const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content
+ const { transcript } = await transcribeAudio(blob, lastAssistant)
if (transcript.trim()) {
messageInput.value = transcript.trim()
await nextTick()
@@ -205,16 +232,13 @@ defineExpose({ focus, prefill })