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:
parent
1c3e17afa0
commit
ff43624969
3 changed files with 52 additions and 19 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
51
announcer.py
51
announcer.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue