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:
parent
ff43624969
commit
45a22e7ed6
4 changed files with 686 additions and 48 deletions
115
announcer.py
115
announcer.py
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue