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:
@@ -131,7 +131,8 @@ async def cleanup_old_conversations(user_id: int, days: int) -> int:
|
||||
.where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.updated_at < cutoff,
|
||||
Conversation.conversation_type != "mcp", # preserve MCP audit trail
|
||||
Conversation.conversation_type != "mcp", # preserve MCP audit trail
|
||||
Conversation.conversation_type != "voice", # voice convs managed separately
|
||||
)
|
||||
.returning(Conversation.id)
|
||||
)
|
||||
|
||||
@@ -152,6 +152,7 @@ async def run_generation(
|
||||
rag_project_id: int | None = None,
|
||||
workspace_project_id: int | None = None,
|
||||
user_timezone: str | None = None,
|
||||
voice_mode: bool = False,
|
||||
) -> None:
|
||||
"""Stream LLM response into buffer with periodic DB flushes."""
|
||||
MAX_TOOL_ROUNDS = 5
|
||||
@@ -177,6 +178,12 @@ async def run_generation(
|
||||
# Phase 3: Build context and wait for model in parallel.
|
||||
model_load_task = asyncio.create_task(wait_for_model_loaded(model, timeout=180.0))
|
||||
|
||||
# Fetch voice_speech_style from user settings when voice_mode is active.
|
||||
voice_speech_style = "conversational"
|
||||
if voice_mode:
|
||||
from fabledassistant.services.settings import get_setting
|
||||
voice_speech_style = await get_setting(user_id, "voice_speech_style", "conversational")
|
||||
|
||||
context_task = asyncio.create_task(build_context(
|
||||
user_id, history_to_use, context_note_id, user_content,
|
||||
history_summary=history_summary,
|
||||
@@ -186,6 +193,8 @@ async def run_generation(
|
||||
workspace_project_id=workspace_project_id,
|
||||
user_timezone=user_timezone,
|
||||
conv_id=conv_id,
|
||||
voice_mode=voice_mode,
|
||||
voice_speech_style=voice_speech_style,
|
||||
))
|
||||
|
||||
messages, context_meta = await context_task
|
||||
|
||||
@@ -454,6 +454,8 @@ async def build_context(
|
||||
workspace_project_id: int | None = None,
|
||||
user_timezone: str | None = None,
|
||||
conv_id: int | None = None,
|
||||
voice_mode: bool = False,
|
||||
voice_speech_style: str = "conversational",
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""Build messages array for Ollama with system prompt and context.
|
||||
|
||||
@@ -516,6 +518,19 @@ async def build_context(
|
||||
f"{tool_guidance}"
|
||||
]
|
||||
|
||||
if voice_mode:
|
||||
_style_hints = {
|
||||
"conversational": "Be warm, natural, and conversational — like speaking to a friend.",
|
||||
"concise": "Be brief and to the point. One or two sentences maximum unless detail is essential.",
|
||||
"detailed": "Give thorough, informative responses as if narrating an explanation aloud.",
|
||||
}
|
||||
style_hint = _style_hints.get(voice_speech_style, _style_hints["conversational"])
|
||||
system_parts.insert(0,
|
||||
"VOICE MODE: Respond naturally as if speaking aloud. "
|
||||
"No markdown, bullet points, headers, or code blocks. Complete sentences only. "
|
||||
f"{style_hint}\n\n"
|
||||
)
|
||||
|
||||
context_meta: dict = {
|
||||
"context_note_id": None,
|
||||
"context_note_title": None,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Speech-to-text service using faster-whisper (in-process, CPU/GPU)."""
|
||||
import asyncio
|
||||
import logging
|
||||
import tempfile
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_model: "WhisperModel | None" = None
|
||||
_model_lock = asyncio.Lock()
|
||||
_load_error: str | None = None
|
||||
|
||||
|
||||
async def load_stt_model() -> None:
|
||||
"""Load the Whisper model. Called once at startup when VOICE_ENABLED=true."""
|
||||
global _model, _load_error
|
||||
from fabledassistant.config import Config
|
||||
|
||||
if not Config.VOICE_ENABLED:
|
||||
return
|
||||
|
||||
async with _model_lock:
|
||||
if _model is not None:
|
||||
return
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
logger.info("Loading Whisper STT model '%s'...", Config.STT_MODEL)
|
||||
loop = asyncio.get_running_loop()
|
||||
_model = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: WhisperModel(Config.STT_MODEL, device="cpu", compute_type="int8"),
|
||||
)
|
||||
logger.info("Whisper STT model '%s' loaded", Config.STT_MODEL)
|
||||
except Exception:
|
||||
_load_error = f"Failed to load Whisper model '{Config.STT_MODEL}'"
|
||||
logger.exception(_load_error)
|
||||
|
||||
|
||||
def stt_available() -> bool:
|
||||
return _model is not None
|
||||
|
||||
|
||||
async def transcribe(audio_bytes: bytes, mime_type: str = "audio/webm") -> str:
|
||||
"""Transcribe audio bytes to text. Runs the model in a thread executor."""
|
||||
if _model is None:
|
||||
raise RuntimeError("STT model not loaded")
|
||||
|
||||
suffix = ".webm"
|
||||
if "ogg" in mime_type:
|
||||
suffix = ".ogg"
|
||||
elif "wav" in mime_type:
|
||||
suffix = ".wav"
|
||||
elif "mp4" in mime_type or "m4a" in mime_type:
|
||||
suffix = ".mp4"
|
||||
|
||||
def _run() -> str:
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as f:
|
||||
f.write(audio_bytes)
|
||||
f.flush()
|
||||
t0 = time.monotonic()
|
||||
segments, _ = _model.transcribe(f.name, beam_size=5) # type: ignore[union-attr]
|
||||
text = " ".join(seg.text.strip() for seg in segments).strip()
|
||||
logger.debug("STT transcription took %.2fs", time.monotonic() - t0)
|
||||
return text
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, _run)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Text-to-speech service using Kokoro TTS (in-process)."""
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from kokoro import KPipeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pipeline: "KPipeline | None" = None
|
||||
_pipeline_lock = asyncio.Lock()
|
||||
_load_error: str | None = None
|
||||
|
||||
# Static list of supported Kokoro voice IDs and display labels
|
||||
_VOICES: list[dict] = [
|
||||
{"id": "af_heart", "label": "Heart (American Female, warm)"},
|
||||
{"id": "af_bella", "label": "Bella (American Female, expressive)"},
|
||||
{"id": "af_nicole", "label": "Nicole (American Female, intimate)"},
|
||||
{"id": "af_sarah", "label": "Sarah (American Female, clear)"},
|
||||
{"id": "af_sky", "label": "Sky (American Female, bright)"},
|
||||
{"id": "am_adam", "label": "Adam (American Male, neutral)"},
|
||||
{"id": "am_michael", "label": "Michael (American Male, deep)"},
|
||||
{"id": "bf_emma", "label": "Emma (British Female)"},
|
||||
{"id": "bf_isabella", "label": "Isabella (British Female, formal)"},
|
||||
{"id": "bm_george", "label": "George (British Male)"},
|
||||
{"id": "bm_lewis", "label": "Lewis (British Male, casual)"},
|
||||
]
|
||||
|
||||
|
||||
async def load_tts_model() -> None:
|
||||
"""Load the Kokoro pipeline. Called once at startup when VOICE_ENABLED=true."""
|
||||
global _pipeline, _load_error
|
||||
from fabledassistant.config import Config
|
||||
|
||||
if not Config.VOICE_ENABLED:
|
||||
return
|
||||
|
||||
async with _pipeline_lock:
|
||||
if _pipeline is not None:
|
||||
return
|
||||
try:
|
||||
from kokoro import KPipeline
|
||||
|
||||
logger.info("Loading Kokoro TTS pipeline...")
|
||||
loop = asyncio.get_running_loop()
|
||||
_pipeline = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: KPipeline(lang_code="a"), # "a" = American English
|
||||
)
|
||||
logger.info("Kokoro TTS pipeline loaded")
|
||||
except Exception:
|
||||
_load_error = "Failed to load Kokoro TTS pipeline"
|
||||
logger.exception(_load_error)
|
||||
|
||||
|
||||
def tts_available() -> bool:
|
||||
return _pipeline is not None
|
||||
|
||||
|
||||
def list_voices() -> list[dict]:
|
||||
return _VOICES
|
||||
|
||||
|
||||
async def synthesise(text: str, voice: str = "af_heart", speed: float = 1.0) -> bytes:
|
||||
"""Synthesise text to WAV bytes (24kHz, 16-bit mono). Runs in executor."""
|
||||
if _pipeline is None:
|
||||
raise RuntimeError("TTS pipeline not loaded")
|
||||
|
||||
speed = max(0.7, min(1.3, speed))
|
||||
|
||||
def _run() -> bytes:
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
t0 = time.monotonic()
|
||||
audio_chunks: list = []
|
||||
for _, _, audio in _pipeline(text, voice=voice, speed=speed): # type: ignore[misc]
|
||||
if audio is not None:
|
||||
audio_chunks.append(audio)
|
||||
|
||||
if not audio_chunks:
|
||||
return b""
|
||||
|
||||
combined = np.concatenate(audio_chunks)
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, combined, samplerate=24000, format="WAV", subtype="PCM_16")
|
||||
logger.debug("TTS synthesis took %.2fs for %d chars", time.monotonic() - t0, len(text))
|
||||
return buf.getvalue()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, _run)
|
||||
Reference in New Issue
Block a user