Flere stemmer: nvcc (10 norske talere), flere engelske, svensk og dansk

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-06 03:37:46 +02:00
parent 1c3e17afa0
commit ff43624969
3 changed files with 52 additions and 19 deletions

View file

@ -31,10 +31,17 @@ ingen PIN), `ANNOUNCER_SINK` (pw-play `--target`; tom = standard utgang).
## Stemmer ## Stemmer
`VOICES` i `announcer.py` er eneste sted en stemme legges til. Navnet er `VOICES_LIST` i `announcer.py` er eneste sted en stemme legges til: egen
Piper-ID-en (`<lang>-<navn>-<kvalitet>`); fila lastes ned fra kort `id`, visningsnavn, gruppe (optgroup i menyen), Piper-modell
rhasspy/piper-voices ved første oppstart til `voices/` (gitignored, ~63 MB (`<lang>-<navn>-<kvalitet>`) og evt. `speaker` for flerstemmemodeller.
per stemme). Norsk: `no_NO-talesyntese-medium` er den eneste norske Piper-stemmen. 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 ## Feller

View file

@ -32,14 +32,36 @@ PIN = os.environ.get("ANNOUNCER_PIN", "").strip()
SINK = os.environ.get("ANNOUNCER_SINK", "").strip() # pw-play --target SINK = os.environ.get("ANNOUNCER_SINK", "").strip() # pw-play --target
MAX_CHARS = 600 MAX_CHARS = 600
# Stemmene som tilbys i menyen. Lastes ned fra rhasspy/piper-voices ved # Stemmene som tilbys i menyen. Modellene lastes ned fra rhasspy/piper-voices
# første oppstart om de mangler i VOICE_DIR. # ved første oppstart om de mangler i VOICE_DIR. «speaker» er bare for
VOICES: dict[str, str] = { # flerstemmemodeller (nvcc har ti norske talere i én modell).
"no_NO-talesyntese-medium": "Norsk (talesyntese)", def _v(id: str, name: str, group: str, model: str, speaker: int | None = None) -> dict:
"en_US-lessac-medium": "English US (Lessac)", return {"id": id, "name": name, "group": group, "model": model, "speaker": speaker}
"en_GB-alan-medium": "English UK (Alan)",
}
DEFAULT_VOICE = "no_NO-talesyntese-medium" 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") log = logging.getLogger("announcer")
app = Flask(__name__, static_folder=None) app = Flask(__name__, static_folder=None)
@ -57,7 +79,7 @@ def ensure_voices() -> None:
import piper.download_voices as dl import piper.download_voices as dl
VOICE_DIR.mkdir(parents=True, exist_ok=True) 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(): if not (VOICE_DIR / f"{name}.onnx").exists():
log.info("laster ned stemme %s", name) log.info("laster ned stemme %s", name)
dl.download_voice(name, VOICE_DIR) 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(): if path.exists():
return path return path
voice = load_voice(voice_name) spec = VOICES[voice_name]
voice = load_voice(spec["model"])
rate = voice.config.sample_rate 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() pcm = bytearray()
if chime: if chime:
@ -145,7 +168,7 @@ class Item:
return { return {
"id": self.id, "id": self.id,
"text": self.text, "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, "sender": self.sender,
"created": self.created, "created": self.created,
"status": self.status, "status": self.status,
@ -270,7 +293,7 @@ def index() -> Response:
def config() -> Response: def config() -> Response:
return jsonify( 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, "default_voice": DEFAULT_VOICE,
"max_chars": MAX_CHARS, "max_chars": MAX_CHARS,
"pin_required": bool(PIN), "pin_required": bool(PIN),
@ -333,7 +356,7 @@ def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("werkzeug").setLevel(logging.WARNING) logging.getLogger("werkzeug").setLevel(logging.WARNING)
ensure_voices() 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") 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) app.run(host=args.host, port=args.port, threaded=True)

View file

@ -156,10 +156,13 @@ async function api(path, body) {
async function init() { async function init() {
cfg = await (await fetch('/api/config')).json(); cfg = await (await fetch('/api/config')).json();
const groups = {};
for (const v of cfg.voices) { 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; 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; $('#max').textContent = cfg.max_chars; text.maxLength = cfg.max_chars;
if (cfg.pin_required) { $('#pinbox').style.display = 'block'; $('#pin').value = localStorage.getItem('pin') || ''; } if (cfg.pin_required) { $('#pinbox').style.display = 'block'; $('#pin').value = localStorage.getItem('pin') || ''; }
for (const q of QUICK) { for (const q of QUICK) {