edge-tts som standardmotor (Finn/Pernille), Piper som lokal reserve

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-06 03:42:16 +02:00
parent ff43624969
commit 45a22e7ed6
4 changed files with 686 additions and 48 deletions

View file

@ -32,36 +32,40 @@ PIN = os.environ.get("ANNOUNCER_PIN", "").strip()
SINK = os.environ.get("ANNOUNCER_SINK", "").strip() # pw-play --target
MAX_CHARS = 600
# Stemmene som tilbys i menyen. Modellene lastes ned fra rhasspy/piper-voices
# ved første oppstart om de mangler i VOICE_DIR. «speaker» er bare for
# flerstemmemodeller (nvcc har ti norske talere i én modell).
def _v(id: str, name: str, group: str, model: str, speaker: int | None = None) -> dict:
return {"id": id, "name": name, "group": group, "model": model, "speaker": speaker}
# Stemmene som tilbys i menyen. To motorer:
# edge – Microsofts nevrale stemmer via edge-tts (gratis, krever internett,
# klart mest naturlig; ~2–3 s per melding)
# piper – lokalt, ingen nett, robotaktig; reserve hvis nettet faller ut.
# Piper-modellene lastes ned fra rhasspy/piper-voices ved første oppstart.
# «speaker» er bare for Piper-flerstemmemodeller (nvcc har ti norske talere).
def _edge(id: str, name: str, group: str, voice: str) -> dict:
return {"id": id, "name": name, "group": group, "engine": "edge", "model": voice, "speaker": None}
def _piper(id: str, name: str, group: str, model: str, speaker: int | None = None) -> dict:
return {"id": id, "name": name, "group": group, "engine": "piper", "model": model, "speaker": speaker}
VOICES_LIST: list[dict] = [
_v("no-tale", "Talesyntese (nøytral)", "Norsk", "no_NO-talesyntese-medium"),
_v("no-knn", "Kvinne – KNN", "Norsk", "no_NO-nvcc-medium", 0),
_v("no-ksv", "Kvinne – KSV", "Norsk", "no_NO-nvcc-medium", 1),
_v("no-kon", "Kvinne – KON", "Norsk", "no_NO-nvcc-medium", 3),
_v("no-kmn", "Kvinne – KMN", "Norsk", "no_NO-nvcc-medium", 8),
_v("no-knv", "Kvinne – KNV", "Norsk", "no_NO-nvcc-medium", 9),
_v("no-mmn", "Mann – MMN", "Norsk", "no_NO-nvcc-medium", 2),
_v("no-mnn", "Mann – MNN", "Norsk", "no_NO-nvcc-medium", 4),
_v("no-msv", "Mann – MSV", "Norsk", "no_NO-nvcc-medium", 5),
_v("no-mon", "Mann – MON", "Norsk", "no_NO-nvcc-medium", 6),
_v("no-mnv", "Mann – MNV", "Norsk", "no_NO-nvcc-medium", 7),
_v("en-ryan", "Ryan (US, mann)", "English", "en_US-ryan-high"),
_v("en-amy", "Amy (US, kvinne)", "English", "en_US-amy-medium"),
_v("en-lessac", "Lessac (US, nøytral)", "English", "en_US-lessac-medium"),
_v("en-alan", "Alan (UK, mann)", "English", "en_GB-alan-medium"),
_v("en-north", "Northern English (UK, mann)", "English", "en_GB-northern_english_male-medium"),
_v("en-jenny", "Jenny (UK, kvinne)", "English", "en_GB-jenny_dioco-medium"),
_v("sv-nst", "Svensk (NST)", "Nabospråk", "sv_SE-nst-medium"),
_v("da-tale", "Dansk (talesyntese)", "Nabospråk", "da_DK-talesyntese-medium"),
_edge("finn", "Finn (mann)", "Norsk", "nb-NO-FinnNeural"),
_edge("pernille", "Pernille (kvinne)", "Norsk", "nb-NO-PernilleNeural"),
_edge("andrew", "Andrew (US, mann)", "English", "en-US-AndrewMultilingualNeural"),
_edge("ava", "Ava (US, kvinne)", "English", "en-US-AvaMultilingualNeural"),
_edge("brian", "Brian (US, mann)", "English", "en-US-BrianMultilingualNeural"),
_edge("emma", "Emma (US, kvinne)", "English", "en-US-EmmaMultilingualNeural"),
_edge("ryan-uk", "Ryan (UK, mann)", "English", "en-GB-RyanNeural"),
_edge("sonia", "Sonia (UK, kvinne)", "English", "en-GB-SoniaNeural"),
_edge("sv", "Svensk – Mattias", "Nabospråk", "sv-SE-MattiasNeural"),
_edge("da", "Dansk – Christel", "Nabospråk", "da-DK-ChristelNeural"),
_piper("no-tale", "Talesyntese (nøytral)", "Lokal reserve (uten nett)", "no_NO-talesyntese-medium"),
_piper("no-knn", "Kvinne – KNN", "Lokal reserve (uten nett)", "no_NO-nvcc-medium", 0),
_piper("no-msv", "Mann – MSV", "Lokal reserve (uten nett)", "no_NO-nvcc-medium", 5),
_piper("en-ryan", "Ryan (US)", "Lokal reserve (uten nett)", "en_US-ryan-high"),
_piper("en-jenny", "Jenny (UK)", "Lokal reserve (uten nett)", "en_GB-jenny_dioco-medium"),
]
VOICES: dict[str, dict] = {v["id"]: v for v in VOICES_LIST}
DEFAULT_VOICE = "no-tale"
DEFAULT_VOICE = "finn"
EDGE_RATE = 24000
log = logging.getLogger("announcer")
app = Flask(__name__, static_folder=None)
@ -79,7 +83,7 @@ def ensure_voices() -> None:
import piper.download_voices as dl
VOICE_DIR.mkdir(parents=True, exist_ok=True)
for name in sorted({v["model"] for v in VOICES_LIST}):
for name in sorted({v["model"] for v in VOICES_LIST if v["engine"] == "piper"}):
if not (VOICE_DIR / f"{name}.onnx").exists():
log.info("laster ned stemme %s", name)
dl.download_voice(name, VOICE_DIR)
@ -111,6 +115,29 @@ def make_chime(rate: int, volume: float = 0.35) -> bytes:
return bytes(out)
def edge_pcm(text: str, voice: str, speed: float) -> bytes:
"""Henter MP3 fra edge-tts og dekoder til 16-bit mono PCM i EDGE_RATE."""
import asyncio
import tempfile
import edge_tts
rate_pct = int(round((speed - 1.0) * 100))
rate = f"{rate_pct:+d}%"
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False, dir=CACHE_DIR) as tmp:
mp3 = Path(tmp.name)
try:
asyncio.run(edge_tts.Communicate(text, voice, rate=rate).save(str(mp3)))
out = subprocess.run(
["ffmpeg", "-v", "error", "-i", str(mp3), "-f", "s16le", "-ac", "1", "-ar", str(EDGE_RATE), "-"],
capture_output=True,
check=True,
)
return out.stdout
finally:
mp3.unlink(missing_ok=True)
def synth_key(text: str, voice: str, speed: float, chime: bool) -> str:
h = hashlib.sha1(f"{voice}|{speed:.2f}|{int(chime)}|{text}".encode()).hexdigest()
return h[:20]
@ -126,15 +153,20 @@ def synthesize(text: str, voice_name: str, speed: float, chime: bool) -> Path:
return path
spec = VOICES[voice_name]
voice = load_voice(spec["model"])
rate = voice.config.sample_rate
cfg = SynthesisConfig(length_scale=1.0 / speed, speaker_id=spec["speaker"])
pcm = bytearray()
if chime:
pcm += make_chime(rate)
for chunk in voice.synthesize(text, cfg):
pcm += chunk.audio_int16_bytes
if spec["engine"] == "edge":
rate = EDGE_RATE
if chime:
pcm += make_chime(rate)
pcm += edge_pcm(text, spec["model"], speed)
else:
voice = load_voice(spec["model"])
rate = voice.config.sample_rate
cfg = SynthesisConfig(length_scale=1.0 / speed, speaker_id=spec["speaker"])
if chime:
pcm += make_chime(rate)
for chunk in voice.synthesize(text, cfg):
pcm += chunk.audio_int16_bytes
pcm += b"\x00\x00" * int(rate * 0.3)
tmp = path.with_suffix(".tmp")
@ -293,7 +325,7 @@ def index() -> Response:
def config() -> Response:
return jsonify(
{
"voices": [{"id": v["id"], "name": v["name"], "group": v["group"]} for v in VOICES_LIST],
"voices": [{"id": v["id"], "name": v["name"], "group": v["group"], "engine": v["engine"]} for v in VOICES_LIST],
"default_voice": DEFAULT_VOICE,
"max_chars": MAX_CHARS,
"pin_required": bool(PIN),
@ -305,7 +337,11 @@ def config() -> Response:
def preview() -> Response:
check_pin()
text, voice, speed, chime = parse_request()
path = synthesize(text, voice, speed, chime)
try:
path = synthesize(text, voice, speed, chime)
except Exception as e: # noqa: BLE001
log.exception("syntese feilet")
abort(502, f"Syntese feilet ({VOICES[voice]['name']}): {e}. Prøv en lokal reserve-stemme.")
return send_file(path, mimetype="audio/wav", max_age=3600)
@ -343,6 +379,11 @@ def bad_request(e): # noqa: ANN001
return jsonify({"error": e.description}), 400
@app.errorhandler(502)
def bad_gateway(e): # noqa: ANN001
return jsonify({"error": e.description}), 502
@app.errorhandler(401)
def unauthorized(e): # noqa: ANN001
return jsonify({"error": "Feil PIN"}), 401
@ -356,7 +397,7 @@ def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("werkzeug").setLevel(logging.WARNING)
ensure_voices()
load_voice(VOICES[DEFAULT_VOICE]["model"]) # varm opp
load_voice("no_NO-talesyntese-medium") # varm opp reserven
log.info("klar på http://%s:%d (PIN: %s, sink: %s)", args.host, args.port, "på" if PIN else "av", SINK or "standard")
app.run(host=args.host, port=args.port, threaded=True)