#!/usr/bin/env python3 """SG Announcer – tekst inn, forhåndslytt i nettleseren, les ut på PC-ens lydutgang. Kjøres på PC-en som er koblet til høyttaleranlegget. Alle på nettet kan åpne sida og sende meldinger; selve avspillingen skjer alltid lokalt på denne maskinen via pw-play (PipeWire). """ from __future__ import annotations import argparse import hashlib import io import logging import math import os import queue import struct import subprocess import threading import time import wave from dataclasses import dataclass, field from pathlib import Path from flask import Flask, Response, abort, jsonify, request, send_file HERE = Path(__file__).resolve().parent VOICE_DIR = Path(os.environ.get("ANNOUNCER_VOICES", HERE / "voices")) CACHE_DIR = Path(os.environ.get("ANNOUNCER_CACHE", HERE / "cache")) 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. 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] = [ _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 = "finn" EDGE_RATE = 24000 log = logging.getLogger("announcer") app = Flask(__name__, static_folder=None) # -------------------------------------------------------------------------- # TTS # -------------------------------------------------------------------------- _voice_cache: dict[str, object] = {} _voice_lock = threading.Lock() 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 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) def load_voice(name: str): from piper import PiperVoice with _voice_lock: if name not in _voice_cache: _voice_cache[name] = PiperVoice.load(VOICE_DIR / f"{name}.onnx") return _voice_cache[name] # -------------------------------------------------------------------------- # Jingler (chimes). Alle de innebygde genereres i Python; egne lydfiler # (wav/mp3/ogg/flac) i CHIME_DIR dukker opp automatisk i menyen. # -------------------------------------------------------------------------- CHIME_DIR = Path(os.environ.get("ANNOUNCER_CHIMES", HERE / "chimes")) def _tone(rate: int, freq: float, dur: float, vol: float = 0.35, wave_: str = "sine", decay: float = 5.5, attack: float = 0.005, harm2: float = 0.0, slide: float = 0.0) -> list[float]: """Én tone som liste av floats. slide = frekvensendring (multiplikator) over tonen.""" n = int(rate * dur) out = [] phase = 0.0 for i in range(n): t = i / rate f = freq * (slide ** (t / dur)) if slide else freq phase += 2 * math.pi * f / rate if wave_ == "square": v = 1.0 if math.sin(phase) >= 0 else -1.0 elif wave_ == "saw": v = 2.0 * ((phase / (2 * math.pi)) % 1.0) - 1.0 else: v = math.sin(phase) + harm2 * math.sin(2 * phase) env = min(1.0, t / attack) * math.exp(-decay * t) out.append(v * env * vol) return out def _mix(*parts: list[float], gap: float = 0.0, rate: int = 22050) -> list[float]: out: list[float] = [] for p in parts: out += p out += [0.0] * int(rate * gap) return out def _overlay(base: list[float], layer: list[float], at: float, rate: int) -> list[float]: start = int(at * rate) if len(base) < start + len(layer): base = base + [0.0] * (start + len(layer) - len(base)) for i, v in enumerate(layer): base[start + i] += v return base def _dingdong(rate: int) -> list[float]: return _mix(_tone(rate, 784.0, 0.28, harm2=0.3), _tone(rate, 523.25, 0.42, harm2=0.3), rate=rate) def _airport(rate: int) -> list[float]: """Tre stigende toner med lang klang, som på en flyplass.""" out: list[float] = [] for i, f in enumerate((523.25, 659.25, 783.99)): out = _overlay(out, _tone(rate, f, 1.2, vol=0.3, decay=2.2, harm2=0.2), i * 0.38, rate) return out def _chiptune(rate: int) -> list[float]: """8-bit arpeggio, som et power-up i et gammelt spill.""" notes = (523.25, 659.25, 783.99, 1046.5, 1318.5, 1567.98) parts = [_tone(rate, f, 0.09, vol=0.3, wave_="square", decay=3.0) for f in notes] parts.append(_tone(rate, 2093.0, 0.35, vol=0.3, wave_="square", decay=6.0)) return _mix(*parts, rate=rate) def _scifi(rate: int) -> list[float]: """Sveip opp + to blipp, som en kommunikator.""" sweep = _tone(rate, 300.0, 0.35, vol=0.3, decay=1.5, slide=5.0) blips = _mix(_tone(rate, 1800.0, 0.07, vol=0.25, decay=10), _tone(rate, 2400.0, 0.12, vol=0.25, decay=10), gap=0.03, rate=rate) return _mix(sweep, blips, rate=rate) def _horn(rate: int) -> list[float]: """Air horn: to lag av sagtann med litt vibrato, derav grovheten.""" out: list[float] = [] for f, v in ((233.08, 0.22), (349.23, 0.16), (466.16, 0.1)): out = _overlay(out, _tone(rate, f, 0.9, vol=v, wave_="saw", decay=1.3, attack=0.03), 0.0, rate) return out def _gong(rate: int) -> list[float]: """Dyp gong med uharmoniske overtoner.""" out: list[float] = [] for f, v, d in ((110.0, 0.3, 1.0), (164.8, 0.18, 1.4), (263.0, 0.12, 2.0), (411.0, 0.07, 3.0), (650.0, 0.04, 4.0)): out = _overlay(out, _tone(rate, f, 2.5, vol=v, decay=d, attack=0.004), 0.0, rate) return out BUILTIN_CHIMES: dict[str, tuple[str, object]] = { "dingdong": ("Ding-dong", _dingdong), "airport": ("Flyplass (tre toner)", _airport), "chiptune": ("8-bit power-up", _chiptune), "scifi": ("Sci-fi kommunikator", _scifi), "horn": ("Air horn", _horn), "gong": ("Gong", _gong), } CHIME_EXT = {".wav", ".mp3", ".ogg", ".flac", ".m4a"} def file_chimes() -> dict[str, Path]: if not CHIME_DIR.is_dir(): return {} return {f"file:{p.stem}": p for p in sorted(CHIME_DIR.iterdir()) if p.suffix.lower() in CHIME_EXT} def list_chimes() -> list[dict]: out = [{"id": "none", "name": "Ingen"}] out += [{"id": k, "name": v[0]} for k, v in BUILTIN_CHIMES.items()] out += [{"id": k, "name": f"{p.stem} (egen fil)"} for k, p in file_chimes().items()] return out def chime_pcm(chime: str, rate: int) -> bytes: """Jingle som 16-bit mono PCM i gitt samplerate, etterfulgt av en kort pause.""" if chime == "none": return b"" if chime in BUILTIN_CHIMES: samples = BUILTIN_CHIMES[chime][1](rate) pcm = b"".join(struct.pack(" 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: str) -> str: h = hashlib.sha1(f"{voice}|{speed:.2f}|{chime}|{text}".encode()).hexdigest() return h[:20] def synthesize(text: str, voice_name: str, speed: float, chime: str) -> Path: """Returnerer sti til ferdig WAV (bufres på disk etter innhold).""" from piper.config import SynthesisConfig CACHE_DIR.mkdir(parents=True, exist_ok=True) path = CACHE_DIR / f"{synth_key(text, voice_name, speed, chime)}.wav" if path.exists(): return path spec = VOICES[voice_name] pcm = bytearray() if spec["engine"] == "edge": rate = EDGE_RATE pcm += chime_pcm(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"]) pcm += chime_pcm(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") with wave.open(str(tmp), "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(rate) w.writeframes(bytes(pcm)) tmp.rename(path) return path # -------------------------------------------------------------------------- # Avspillingskø # -------------------------------------------------------------------------- @dataclass class Item: id: int text: str voice: str speed: float chime: str sender: str created: float = field(default_factory=time.time) status: str = "queued" # queued | playing | done | stopped | failed error: str = "" def public(self) -> dict: return { "id": self.id, "text": self.text, "voice": VOICES[self.voice]["name"] if self.voice in VOICES else self.voice, "sender": self.sender, "created": self.created, "status": self.status, "error": self.error, } class Player: def __init__(self) -> None: self.q: queue.Queue[Item] = queue.Queue() self.history: list[Item] = [] self.current: Item | None = None self.proc: subprocess.Popen | None = None self.lock = threading.Lock() self.seq = 0 threading.Thread(target=self._loop, daemon=True, name="player").start() def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str) -> Item: with self.lock: self.seq += 1 item = Item(self.seq, text, voice, speed, chime, sender) self.history.append(item) del self.history[:-50] self.q.put(item) return item def stop(self) -> None: with self.lock: if self.proc and self.proc.poll() is None: self.proc.terminate() if self.current: self.current.status = "stopped" def clear(self) -> None: while True: try: item = self.q.get_nowait() except queue.Empty: break item.status = "stopped" self.stop() def snapshot(self) -> dict: with self.lock: return { "current": self.current.public() if self.current else None, "queued": [i.public() for i in self.history if i.status == "queued"], "history": [i.public() for i in reversed(self.history) if i.status not in ("queued", "playing")][:15], } def _loop(self) -> None: while True: item = self.q.get() if item.status != "queued": continue item.status = "playing" with self.lock: self.current = item try: path = synthesize(item.text, item.voice, item.speed, item.chime) cmd = ["pw-play"] if SINK: cmd += ["--target", SINK] cmd.append(str(path)) with self.lock: self.proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) _, err = self.proc.communicate() if item.status == "playing": if self.proc.returncode == 0: item.status = "done" else: item.status = "failed" item.error = err.decode(errors="replace").strip()[-200:] except Exception as e: # noqa: BLE001 log.exception("avspilling feilet") item.status = "failed" item.error = str(e) finally: with self.lock: self.current = None self.proc = None player = Player() # -------------------------------------------------------------------------- # HTTP # -------------------------------------------------------------------------- def check_pin() -> None: if PIN and request.headers.get("X-Pin", "") != PIN: abort(401) def parse_request() -> tuple[str, str, float, str]: data = request.get_json(silent=True) or {} text = " ".join(str(data.get("text", "")).split()) if not text: abort(400, "Tom melding") if len(text) > MAX_CHARS: abort(400, f"Maks {MAX_CHARS} tegn") voice = str(data.get("voice", DEFAULT_VOICE)) if voice not in VOICES: abort(400, "Ukjent stemme") try: speed = float(data.get("speed", 1.0)) except (TypeError, ValueError): abort(400, "Ugyldig hastighet") speed = max(0.6, min(1.6, speed)) chime = data.get("chime", "dingdong") if chime is True: chime = "dingdong" elif chime is False or chime is None: chime = "none" chime = str(chime) if chime != "none" and chime not in BUILTIN_CHIMES and chime not in file_chimes(): abort(400, "Ukjent jingle") return text, voice, speed, chime @app.get("/") def index() -> Response: return send_file(HERE / "index.html") @app.get("/api/config") def config() -> Response: return jsonify( { "voices": [{"id": v["id"], "name": v["name"], "group": v["group"], "engine": v["engine"]} for v in VOICES_LIST], "default_voice": DEFAULT_VOICE, "chimes": list_chimes(), "default_chime": "dingdong", "max_chars": MAX_CHARS, "pin_required": bool(PIN), } ) @app.post("/api/preview") def preview() -> Response: check_pin() text, voice, speed, chime = parse_request() 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) @app.post("/api/announce") def announce() -> Response: check_pin() text, voice, speed, chime = parse_request() sender = request.remote_addr or "?" item = player.enqueue(text, voice, speed, chime, sender) log.info("kø #%d fra %s: %s", item.id, sender, text) return jsonify(item.public()) @app.post("/api/stop") def stop() -> Response: check_pin() player.stop() return jsonify({"ok": True}) @app.post("/api/clear") def clear() -> Response: check_pin() player.clear() return jsonify({"ok": True}) @app.get("/api/status") def status() -> Response: return jsonify(player.snapshot()) @app.errorhandler(400) 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 def main() -> None: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--host", default="0.0.0.0") ap.add_argument("--port", type=int, default=int(os.environ.get("ANNOUNCER_PORT", 8090))) args = ap.parse_args() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.getLogger("werkzeug").setLevel(logging.WARNING) ensure_voices() 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) if __name__ == "__main__": main()