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
`VOICES` i `announcer.py` er eneste sted en stemme legges til. Navnet er
Piper-ID-en (`<lang>-<navn>-<kvalitet>`); 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
(`<lang>-<navn>-<kvalitet>`) 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

View file

@ -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)

View file

@ -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) {