feat: voice S2S — faster-whisper STT, Kokoro TTS, PTT overlay

Implements full speech-to-speech pipeline (all 4 phases):

Backend (Phase 1):
- services/stt.py: lazy WhisperModel singleton, run_in_executor transcription
- services/tts.py: lazy KPipeline singleton, WAV synthesis at 24kHz/16-bit
- routes/voice.py: /api/voice/status, /voices, /transcribe, /synthesise
- config.py: VOICE_ENABLED, STT_BACKEND, STT_MODEL, TTS_BACKEND env vars
- app.py: load STT/TTS models at startup when VOICE_ENABLED=true
- llm.py: voice_mode + voice_speech_style params inject speak-naturally prefix
- generation_task.py: voice_mode passed through from chat route
- chat.py: "voice" conversation type allowed + excluded from retention cleanup
- pyproject.toml + Dockerfile: faster-whisper, kokoro, soundfile dependencies

Frontend (Phases 2–4):
- composables/useVoiceRecorder.ts: MediaRecorder PTT wrapper
- composables/useVoiceAudio.ts: AudioContext WAV playback wrapper
- BriefingView.vue: Listen button (TTS read-aloud), auto-TTS mode, mic PTT
- VoiceOverlay.vue: global floating PTT button; creates/reuses voice conv;
  full record→transcribe→stream→TTS flow; Space bar hold-to-talk via App.vue
- SettingsView.vue: Voice tab (status badge, speech style, voice/speed)
- App.vue: mounts VoiceOverlay; Space keydown/keyup fires voice:ptt-toggle
- api/client.ts: getVoiceStatus, getVoiceList, transcribeAudio, synthesiseSpeech

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 20:03:38 -04:00
parent 3581cc1582
commit 6f84d90dff
18 changed files with 1524 additions and 6 deletions
+207 -3
View File
@@ -3,7 +3,7 @@ import { ref, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed, type VoiceStatusResult, type VoiceEntry } from "@/api/client";
import { usePushStore } from "@/stores/push";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
@@ -40,7 +40,7 @@ const appVersion = ref('dev');
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]);
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "voice", "apikeys", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
@@ -51,6 +51,7 @@ function _loadTabContent(tab: string) {
else if (tab === "groups") loadGroupsPanel();
}
if (tab === "briefing") loadBriefingTab();
if (tab === "voice") loadVoiceTab();
if (tab === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
}
@@ -452,6 +453,49 @@ const searchResults = ref<{ url: string; title: string; snippet: string }[]>([])
const searchLoading = ref(false);
const searchError = ref("");
// Voice settings
const voiceStatus = ref<VoiceStatusResult | null>(null);
const voiceStatusLoading = ref(false);
const availableVoices = ref<VoiceEntry[]>([]);
const voiceTtsVoice = ref("af_heart");
const voiceTtsSpeed = ref(1.0);
const voiceSpeechStyle = ref("conversational");
const savingVoice = ref(false);
const voiceSaved = ref(false);
const voiceTabLoaded = ref(false);
async function loadVoiceTab() {
if (voiceTabLoaded.value) return;
voiceTabLoaded.value = true;
voiceStatusLoading.value = true;
try {
voiceStatus.value = await getVoiceStatus();
if (voiceStatus.value.tts) {
availableVoices.value = await getVoiceList();
}
} catch {
// Voice feature may be disabled
} finally {
voiceStatusLoading.value = false;
}
}
async function saveVoiceSettings() {
savingVoice.value = true;
voiceSaved.value = false;
try {
await apiPut("/api/settings", {
voice_tts_voice: voiceTtsVoice.value,
voice_tts_speed: String(voiceTtsSpeed.value),
voice_speech_style: voiceSpeechStyle.value,
});
voiceSaved.value = true;
setTimeout(() => { voiceSaved.value = false; }, 2000);
} finally {
savingVoice.value = false;
}
}
onMounted(async () => {
try {
const v = await apiGet<{ version: string }>('/api/version')
@@ -485,6 +529,11 @@ onMounted(async () => {
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
}
// Load voice settings
if (allSettings.voice_tts_voice) voiceTtsVoice.value = allSettings.voice_tts_voice;
if (allSettings.voice_tts_speed) voiceTtsSpeed.value = parseFloat(allSettings.voice_tts_speed);
if (allSettings.voice_speech_style) voiceSpeechStyle.value = allSettings.voice_speech_style;
// Load CalDAV settings
try {
const caldavConfig = await apiGet<Record<string, string>>("/api/settings/caldav");
@@ -1134,7 +1183,7 @@ function formatUserDate(iso: string): string {
<div class="sidebar-group">
<div class="sidebar-group-label">User</div>
<button
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing', 'apikeys']"
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing', 'voice', 'apikeys']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -1810,6 +1859,105 @@ function formatUserDate(iso: string): string {
</div>
<!-- ── Voice ── -->
<div v-show="activeTab === 'voice'" class="settings-grid">
<section class="settings-section full-width">
<h2>Voice</h2>
<p class="section-desc">
Configure text-to-speech and speech-to-text for voice conversations.
Requires <code>VOICE_ENABLED=true</code> on the server.
</p>
<!-- Status indicator -->
<div v-if="voiceStatusLoading" class="field-hint">Loading voice status…</div>
<div v-else-if="voiceStatus === null" class="field-hint">Voice status unavailable.</div>
<div v-else class="voice-status-row">
<span :class="['status-badge', voiceStatus.enabled ? 'status-on' : 'status-off']">
{{ voiceStatus.enabled ? 'Enabled' : 'Disabled' }}
</span>
<span v-if="voiceStatus.enabled" class="status-badge" :class="voiceStatus.stt ? 'status-on' : 'status-off'">
STT {{ voiceStatus.stt ? 'ready' : 'loading' }}
</span>
<span v-if="voiceStatus.enabled" class="status-badge" :class="voiceStatus.tts ? 'status-on' : 'status-off'">
TTS {{ voiceStatus.tts ? 'ready' : 'loading' }}
</span>
<span v-if="voiceStatus.stt_model" class="field-hint" style="margin-left:0.5rem;">
Model: {{ voiceStatus.stt_model }}
</span>
</div>
<div v-if="!voiceStatus?.enabled" class="field-hint" style="margin-top:0.75rem;">
Set <code>VOICE_ENABLED=true</code> in your server environment to activate voice features.
Optionally set <code>STT_MODEL</code> (tiny.en / base.en / small.en / medium.en) and
install <code>pip install fabledassistant[voice]</code>.
</div>
</section>
<section v-if="voiceStatus?.enabled" class="settings-section full-width">
<h2>Speech Style</h2>
<p class="section-desc">Controls how the assistant phrases spoken responses.</p>
<div class="field-group">
<label class="field-label">Style</label>
<div class="radio-group">
<label v-for="opt in [
{ value: 'conversational', label: 'Conversational', hint: 'Warm and natural, like speaking to a friend' },
{ value: 'concise', label: 'Concise', hint: 'Short and to the point' },
{ value: 'detailed', label: 'Detailed', hint: 'Thorough explanations spoken aloud' },
]" :key="opt.value" class="radio-option">
<input type="radio" v-model="voiceSpeechStyle" :value="opt.value" />
<span>
<strong>{{ opt.label }}</strong>
<span class="field-hint">{{ opt.hint }}</span>
</span>
</label>
</div>
</div>
</section>
<section v-if="voiceStatus?.enabled" class="settings-section full-width">
<h2>Voice &amp; Speed</h2>
<p class="section-desc">Choose the TTS voice and playback speed.</p>
<div class="field-group">
<label class="field-label" for="voice-select">Voice</label>
<select id="voice-select" class="input" v-model="voiceTtsVoice" :disabled="availableVoices.length === 0">
<option v-if="availableVoices.length === 0" value="af_heart">af_heart (default)</option>
<option v-for="v in availableVoices" :key="v.id" :value="v.id">{{ v.label }}</option>
</select>
<p class="field-hint">Voice character and accent. Applies to all voice conversations.</p>
</div>
<div class="field-group">
<label class="field-label" for="voice-speed">
Speed: {{ voiceTtsSpeed.toFixed(1) }}×
</label>
<input
id="voice-speed"
type="range"
min="0.7"
max="1.3"
step="0.05"
v-model.number="voiceTtsSpeed"
class="range-input"
/>
<div class="range-labels">
<span>0.7× (slow)</span>
<span>1.0× (normal)</span>
<span>1.3× (fast)</span>
</div>
</div>
<div class="form-actions">
<button class="btn-save" @click="saveVoiceSettings" :disabled="savingVoice">
{{ voiceSaved ? 'Saved ' : savingVoice ? 'Saving' : 'Save Voice Settings' }}
</button>
</div>
</section>
</div>
<!-- ── API Keys ── -->
<div v-show="activeTab === 'apikeys'" class="settings-grid">
<section class="settings-section full-width">
@@ -3528,4 +3676,60 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
word-break: break-all;
overflow-x: auto;
}
/* Voice tab */
.voice-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.status-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.55rem;
border-radius: 999px;
}
.status-on {
background: color-mix(in srgb, var(--color-success, #22c55e) 15%, transparent);
color: var(--color-success, #22c55e);
border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 40%, transparent);
}
.status-off {
background: color-mix(in srgb, var(--color-text-muted) 10%, transparent);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.radio-group {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 0.35rem;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 0.55rem;
cursor: pointer;
}
.radio-option input[type="radio"] { margin-top: 0.2rem; flex-shrink: 0; }
.radio-option span { display: flex; flex-direction: column; gap: 0.15rem; }
.radio-option strong { font-size: 0.875rem; }
.range-input {
width: 100%;
max-width: 360px;
accent-color: var(--color-primary);
margin: 0.35rem 0 0.2rem;
}
.range-labels {
display: flex;
justify-content: space-between;
max-width: 360px;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.form-actions {
margin-top: 1rem;
}
</style>