39ab5d69a9
Building on the kokoro→piper swap (B1), this adds the admin-side voice management story so additional voices can be installed without rebuilding the image. The bundled two voices stay as immediate defaults; everything else is opt-in via a one-click install from the catalog. Backend (services/voice_library.py): - fetch_catalog() pulls voices.json from the piper-voices HF repo with a 24h in-memory TTL. Manual refresh available via ?refresh=1 on the library endpoint. - shape_catalog_for_ui() projects the raw HF dict (~250 voices, lots of nesting) into UI-friendly cards: id, name, language, country, quality, size, install state. Sorted by language_code then name for stable display. Install state distinguishes bundled (read-only) from user (admin-installed, can be removed). - install_voice() downloads .onnx + .onnx.json into /data/voices with atomic .tmp → rename so a failed partial download can't leave a corrupt model around. Idempotent — re-installing an already-present voice is a no-op. - uninstall_voice() removes /data voices; bundled /opt voices raise PermissionError (403 at the route layer). - Strict voice-id regex prevents path traversal in install/uninstall. Routes (admin-only, since these write to shared /data and affect all users on the instance): - GET /api/voice/voices/library - POST /api/voice/voices/install - DELETE /api/voice/voices/<voice_id> Frontend: - New "Voice Library" section in Settings → Voice, visible only to admin users. Collapsed by default; expand to load the catalog on-demand (doesn't hammer HF for non-admins). - Free-text filter across id, language code, language name, country, and dataset name. Refresh button forces a catalog re-fetch. - Per-voice row shows id, language/country/quality/speaker count, size, and either an Install button, a Remove button (user voices), or a "bundled" badge (read-only voices in /opt/piper-voices). - Installs and uninstalls refresh both the library list AND the active voice picker so the new voice is immediately selectable. - VoiceLibraryEntry exported from api/client.ts; new client helpers getVoiceLibrary/installVoice/uninstallVoice. Tests: - Pure-transformation unit tests for shape_catalog_for_ui, _resolve_file_urls, and the voice-id regex (path-traversal coverage). - DB/network paths (fetch_catalog, install_voice) need a real environment — left to CI integration tests or device verification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
259 lines
9.2 KiB
Python
259 lines
9.2 KiB
Python
"""Voice (Speech-to-Speech) routes at /api/voice."""
|
|
import logging
|
|
import time
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from fabledassistant.auth import admin_required, login_required
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
voice_bp = Blueprint("voice", __name__, url_prefix="/api/voice")
|
|
|
|
|
|
@voice_bp.route("/status", methods=["GET"])
|
|
@login_required
|
|
async def voice_status():
|
|
"""Return availability of STT and TTS services."""
|
|
from fabledassistant.services.voice_config import get_voice_config
|
|
from fabledassistant.services.stt import stt_available
|
|
from fabledassistant.services.tts import tts_available
|
|
|
|
config = await get_voice_config()
|
|
enabled = config.get("voice_enabled", "false").lower() in ("1", "true", "yes")
|
|
|
|
if not enabled:
|
|
return jsonify({"enabled": False, "stt": False, "tts": False})
|
|
|
|
return jsonify({
|
|
"enabled": True,
|
|
"stt": stt_available(),
|
|
"tts": tts_available(),
|
|
"stt_model": config.get("voice_stt_model", "base.en"),
|
|
"tts_backend": "piper",
|
|
})
|
|
|
|
|
|
@voice_bp.route("/voices", methods=["GET"])
|
|
@login_required
|
|
async def list_voices():
|
|
"""Return available piper voice IDs and metadata.
|
|
|
|
Scans /opt/piper-voices (bundled) + /data/voices (admin-downloaded)
|
|
on every call so newly-downloaded voices show up without a restart.
|
|
Does NOT require tts_available() — even if the active voice failed
|
|
to load, the catalog is still useful for picking a different one.
|
|
"""
|
|
from fabledassistant.services.voice_config import is_voice_enabled
|
|
if not await is_voice_enabled():
|
|
return jsonify({"error": "Voice feature is disabled"}), 503
|
|
|
|
from fabledassistant.services.tts import list_voices
|
|
|
|
return jsonify({"voices": list_voices()})
|
|
|
|
|
|
@voice_bp.route("/transcribe", methods=["POST"])
|
|
@login_required
|
|
async def transcribe_audio():
|
|
"""Accept a multipart audio file and return the transcript.
|
|
|
|
Request: multipart/form-data with field 'audio' (WebM/Opus blob)
|
|
Response: {"transcript": "...", "duration_ms": 123}
|
|
"""
|
|
from fabledassistant.services.voice_config import is_voice_enabled
|
|
if not await is_voice_enabled():
|
|
return jsonify({"error": "Voice feature is disabled"}), 503
|
|
|
|
from fabledassistant.services.stt import stt_available, transcribe
|
|
|
|
if not stt_available():
|
|
return jsonify({"error": "STT not available — model may still be loading"}), 503
|
|
|
|
files = await request.files
|
|
audio_file = files.get("audio")
|
|
if audio_file is None:
|
|
return jsonify({"error": "No audio file provided"}), 400
|
|
|
|
audio_bytes = audio_file.read()
|
|
if not audio_bytes:
|
|
return jsonify({"error": "Empty audio file"}), 400
|
|
|
|
if len(audio_bytes) > 25 * 1024 * 1024: # 25 MB hard cap
|
|
return jsonify({"error": "Audio file too large (max 25 MB)"}), 413
|
|
|
|
mime_type = audio_file.content_type or "audio/webm"
|
|
form = await request.form
|
|
context = (form.get("context") or "").strip() or None
|
|
|
|
t0 = time.monotonic()
|
|
try:
|
|
transcript = await transcribe(audio_bytes, mime_type, initial_prompt=context)
|
|
except Exception:
|
|
logger.exception("STT transcription failed")
|
|
return jsonify({"error": "Transcription failed"}), 500
|
|
|
|
duration_ms = round((time.monotonic() - t0) * 1000)
|
|
return jsonify({"transcript": transcript, "duration_ms": duration_ms})
|
|
|
|
|
|
@voice_bp.route("/synthesise", methods=["POST"])
|
|
@login_required
|
|
async def synthesise_speech():
|
|
"""Convert text to speech and return WAV bytes.
|
|
|
|
Request body: {"text": "...", "voice": "af_heart", "speed": 1.0}
|
|
Response: audio/wav bytes
|
|
"""
|
|
from fabledassistant.services.voice_config import is_voice_enabled
|
|
if not await is_voice_enabled():
|
|
return jsonify({"error": "Voice feature is disabled"}), 503
|
|
|
|
from fabledassistant.services.tts import synthesise, tts_available
|
|
|
|
if not tts_available():
|
|
return jsonify({"error": "TTS not available — model may still be loading"}), 503
|
|
|
|
data = await request.get_json()
|
|
if not data:
|
|
return jsonify({"error": "JSON body required"}), 400
|
|
|
|
text = str(data.get("text", "")).strip()
|
|
if not text:
|
|
return jsonify({"error": "text is required"}), 400
|
|
|
|
char_count = len(text)
|
|
if char_count > 8000:
|
|
logger.warning(
|
|
"TTS request rejected: text too long (%d chars, limit 8000). Preview: %r",
|
|
char_count, text[:120],
|
|
)
|
|
return jsonify({"error": "text too long (max 8000 characters)"}), 400
|
|
|
|
# Piper voice file basename (e.g. "en_US-amy-medium"). Default is read
|
|
# from user settings if not in the request body.
|
|
voice = str(data.get("voice", "")) or "en_US-amy-medium"
|
|
try:
|
|
speed = float(data.get("speed", 1.0))
|
|
except (TypeError, ValueError):
|
|
speed = 1.0
|
|
|
|
# Pull saved settings only when caller didn't override.
|
|
if "voice" not in data and "speed" not in data:
|
|
from fabledassistant.services.settings import get_setting
|
|
from fabledassistant.auth import get_current_user_id
|
|
try:
|
|
uid = get_current_user_id()
|
|
saved_voice = await get_setting(uid, "voice_tts_voice", "")
|
|
if saved_voice:
|
|
voice = saved_voice
|
|
saved_speed = await get_setting(uid, "voice_tts_speed", "")
|
|
if saved_speed:
|
|
try:
|
|
speed = float(saved_speed)
|
|
except ValueError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
logger.info("TTS synthesis start: %d chars, voice=%s, speed=%.2f", char_count, voice, speed)
|
|
t0 = time.monotonic()
|
|
try:
|
|
wav_bytes = await synthesise(text, voice=voice, speed=speed)
|
|
except Exception:
|
|
logger.exception(
|
|
"TTS synthesis failed: %d chars, voice=%s. Preview: %r",
|
|
char_count, voice, text[:120],
|
|
)
|
|
return jsonify({"error": "Synthesis failed"}), 500
|
|
|
|
duration_ms = round((time.monotonic() - t0) * 1000)
|
|
if not wav_bytes:
|
|
logger.warning(
|
|
"TTS synthesis returned empty audio: %d chars, voice=%s, %dms. Preview: %r",
|
|
char_count, voice, duration_ms, text[:120],
|
|
)
|
|
else:
|
|
logger.info(
|
|
"TTS synthesis complete: %d chars → %d bytes in %dms (voice=%s)",
|
|
char_count, len(wav_bytes), duration_ms, voice,
|
|
)
|
|
|
|
from quart import Response
|
|
return Response(wav_bytes, mimetype="audio/wav")
|
|
|
|
|
|
# ── Voice library (admin only) ──────────────────────────────────────────────
|
|
# Browse the piper-voices catalog, download new voices into /data/voices,
|
|
# remove user-installed voices. Bundled voices in /opt/piper-voices are
|
|
# read-only and cannot be touched via these endpoints.
|
|
# These are admin-only because installs consume shared disk and affect
|
|
# every user on the instance (voices are picked per-user, but the files
|
|
# themselves are shared).
|
|
|
|
|
|
@voice_bp.route("/voices/library", methods=["GET"])
|
|
@admin_required
|
|
async def voice_library():
|
|
"""Return the piper-voices catalog with install state annotations.
|
|
|
|
Query params:
|
|
?refresh=1 — force a fresh fetch from HuggingFace (bypass the 24h
|
|
in-memory cache). Use sparingly; HF doesn't appreciate hammering.
|
|
"""
|
|
from fabledassistant.services import voice_library as lib
|
|
|
|
force = (request.args.get("refresh") or "").lower() in ("1", "true", "yes")
|
|
try:
|
|
catalog = await lib.fetch_catalog(force_refresh=force)
|
|
except Exception:
|
|
logger.exception("Voice catalog fetch failed")
|
|
return jsonify({"error": "Failed to fetch voice catalog"}), 502
|
|
voices = lib.shape_catalog_for_ui(catalog)
|
|
return jsonify({"voices": voices, "count": len(voices)})
|
|
|
|
|
|
@voice_bp.route("/voices/install", methods=["POST"])
|
|
@admin_required
|
|
async def install_voice_route():
|
|
"""Download a voice into /data/voices.
|
|
|
|
Body: {"voice_id": "en_US-amy-medium"}
|
|
Idempotent — already-installed voices return {"skipped": true} without
|
|
re-downloading.
|
|
"""
|
|
from fabledassistant.services import voice_library as lib
|
|
|
|
data = await request.get_json()
|
|
voice_id = str((data or {}).get("voice_id") or "").strip()
|
|
if not voice_id:
|
|
return jsonify({"error": "voice_id is required"}), 400
|
|
try:
|
|
result = await lib.install_voice(voice_id)
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
except KeyError as e:
|
|
return jsonify({"error": str(e)}), 404
|
|
except Exception:
|
|
logger.exception("Voice install failed: %s", voice_id)
|
|
return jsonify({"error": "Voice install failed"}), 500
|
|
return jsonify(result)
|
|
|
|
|
|
@voice_bp.route("/voices/<voice_id>", methods=["DELETE"])
|
|
@admin_required
|
|
async def uninstall_voice_route(voice_id: str):
|
|
"""Remove a /data/voices voice. Bundled voices return 403."""
|
|
from fabledassistant.services import voice_library as lib
|
|
|
|
try:
|
|
result = await lib.uninstall_voice(voice_id)
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
except PermissionError as e:
|
|
return jsonify({"error": str(e)}), 403
|
|
except Exception:
|
|
logger.exception("Voice uninstall failed: %s", voice_id)
|
|
return jsonify({"error": "Voice uninstall failed"}), 500
|
|
return jsonify(result)
|