From ff4362496981f1cc56d1725923a3882b817771c7 Mon Sep 17 00:00:00 2001 From: Kent Date: Tue, 6 Oct 2026 03:37:46 +0200 Subject: [PATCH] Flere stemmer: nvcc (10 norske talere), flere engelske, svensk og dansk Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 15 +++++++++++---- announcer.py | 51 +++++++++++++++++++++++++++++++++++++-------------- index.html | 5 ++++- 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa2174c..46ec764 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,10 +31,17 @@ ingen PIN), `ANNOUNCER_SINK` (pw-play `--target`; tom = standard utgang). ## Stemmer -`VOICES` i `announcer.py` er eneste sted en stemme legges til. Navnet er -Piper-ID-en (`--`); fila lastes ned fra -rhasspy/piper-voices ved første oppstart til `voices/` (gitignored, ~63 MB -per stemme). Norsk: `no_NO-talesyntese-medium` er den eneste norske Piper-stemmen. +`VOICES_LIST` i `announcer.py` er eneste sted en stemme legges til: egen +kort `id`, visningsnavn, gruppe (optgroup i menyen), Piper-modell +(`--`) og evt. `speaker` for flerstemmemodeller. +Modellene lastes ned fra rhasspy/piper-voices ved første oppstart til +`voices/` (gitignored, 63–120 MB per modell). Fullstendig liste: +`https://huggingface.co/rhasspy/piper-voices/resolve/main/voices.json`. + +Norsk finnes bare i to modeller: `talesyntese-medium` (én nøytral stemme) og +`nvcc-medium` (ti talere i én modell, kodene KNN/MSV osv. er fra datasettet, +K = kvinne, M = mann). Ingen av dem er «morsomme» stemmer; vil noen ha +Vader/robot må det legges effekter på etterpå (ffmpeg-filter i `synthesize`). ## Feller diff --git a/announcer.py b/announcer.py index d61a102..9679696 100644 --- a/announcer.py +++ b/announcer.py @@ -32,14 +32,36 @@ 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. Lastes ned fra rhasspy/piper-voices ved -# første oppstart om de mangler i VOICE_DIR. -VOICES: dict[str, str] = { - "no_NO-talesyntese-medium": "Norsk (talesyntese)", - "en_US-lessac-medium": "English US (Lessac)", - "en_GB-alan-medium": "English UK (Alan)", -} -DEFAULT_VOICE = "no_NO-talesyntese-medium" +# 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} + + +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"), +] +VOICES: dict[str, dict] = {v["id"]: v for v in VOICES_LIST} +DEFAULT_VOICE = "no-tale" log = logging.getLogger("announcer") app = Flask(__name__, static_folder=None) @@ -57,7 +79,7 @@ def ensure_voices() -> None: import piper.download_voices as dl VOICE_DIR.mkdir(parents=True, exist_ok=True) - for name in VOICES: + for name in sorted({v["model"] for v in VOICES_LIST}): if not (VOICE_DIR / f"{name}.onnx").exists(): log.info("laster ned stemme %s", name) dl.download_voice(name, VOICE_DIR) @@ -103,9 +125,10 @@ def synthesize(text: str, voice_name: str, speed: float, chime: bool) -> Path: if path.exists(): return path - voice = load_voice(voice_name) + spec = VOICES[voice_name] + voice = load_voice(spec["model"]) rate = voice.config.sample_rate - cfg = SynthesisConfig(length_scale=1.0 / speed) + cfg = SynthesisConfig(length_scale=1.0 / speed, speaker_id=spec["speaker"]) pcm = bytearray() if chime: @@ -145,7 +168,7 @@ class Item: return { "id": self.id, "text": self.text, - "voice": VOICES.get(self.voice, self.voice), + "voice": VOICES[self.voice]["name"] if self.voice in VOICES else self.voice, "sender": self.sender, "created": self.created, "status": self.status, @@ -270,7 +293,7 @@ def index() -> Response: def config() -> Response: return jsonify( { - "voices": [{"id": k, "name": v} for k, v in VOICES.items()], + "voices": [{"id": v["id"], "name": v["name"], "group": v["group"]} for v in VOICES_LIST], "default_voice": DEFAULT_VOICE, "max_chars": MAX_CHARS, "pin_required": bool(PIN), @@ -333,7 +356,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(DEFAULT_VOICE) # varm opp + load_voice(VOICES[DEFAULT_VOICE]["model"]) # varm opp 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) diff --git a/index.html b/index.html index 6c3e0d8..1fee223 100644 --- a/index.html +++ b/index.html @@ -156,10 +156,13 @@ async function api(path, body) { async function init() { cfg = await (await fetch('/api/config')).json(); + const groups = {}; for (const v of cfg.voices) { - const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; voice.append(o); + if (!groups[v.group]) { groups[v.group] = document.createElement('optgroup'); groups[v.group].label = v.group; voice.append(groups[v.group]); } + const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; groups[v.group].append(o); } voice.value = localStorage.getItem('voice') || cfg.default_voice; + if (!voice.value) voice.value = cfg.default_voice; $('#max').textContent = cfg.max_chars; text.maxLength = cfg.max_chars; if (cfg.pin_required) { $('#pinbox').style.display = 'block'; $('#pin').value = localStorage.getItem('pin') || ''; } for (const q of QUICK) {