Valgbare jingler: seks innebygde + egne filer i chimes/

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-06 03:50:48 +02:00
parent 45a22e7ed6
commit 756a234b13
4 changed files with 166 additions and 31 deletions

View file

@ -98,21 +98,136 @@ def load_voice(name: str):
return _voice_cache[name]
def make_chime(rate: int, volume: float = 0.35) -> bytes:
"""To-tone «ding-dong» som 16-bit mono PCM i gitt samplerate."""
out = bytearray()
for freq, dur in ((784.0, 0.28), (523.25, 0.42)):
n = int(rate * dur)
for i in range(n):
t = i / rate
env = math.exp(-5.5 * t) # rask utklang
s = (
math.sin(2 * math.pi * freq * t)
+ 0.3 * math.sin(2 * math.pi * freq * 2 * t)
) * env * volume
out += struct.pack("<h", int(max(-1.0, min(1.0, s)) * 32767))
out += b"\x00\x00" * int(rate * 0.35) # pause før talen
return bytes(out)
# --------------------------------------------------------------------------
# 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("<h", int(max(-1.0, min(1.0, v)) * 32767)) for v in samples)
else:
path = file_chimes().get(chime)
if path is None:
raise ValueError("Ukjent jingle")
pcm = subprocess.run(
["ffmpeg", "-v", "error", "-i", str(path), "-t", "6", "-f", "s16le", "-ac", "1", "-ar", str(rate),
"-af", "loudnorm=I=-18,afade=t=out:st=5:d=1", "-"],
capture_output=True, check=True,
).stdout
return pcm + b"\x00\x00" * int(rate * 0.35)
def edge_pcm(text: str, voice: str, speed: float) -> bytes:
@ -138,12 +253,12 @@ def edge_pcm(text: str, voice: str, speed: float) -> bytes:
mp3.unlink(missing_ok=True)
def synth_key(text: str, voice: str, speed: float, chime: bool) -> str:
h = hashlib.sha1(f"{voice}|{speed:.2f}|{int(chime)}|{text}".encode()).hexdigest()
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: bool) -> Path:
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
@ -156,15 +271,13 @@ def synthesize(text: str, voice_name: str, speed: float, chime: bool) -> Path:
pcm = bytearray()
if spec["engine"] == "edge":
rate = EDGE_RATE
if chime:
pcm += make_chime(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"])
if chime:
pcm += make_chime(rate)
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)
@ -190,7 +303,7 @@ class Item:
text: str
voice: str
speed: float
chime: bool
chime: str
sender: str
created: float = field(default_factory=time.time)
status: str = "queued" # queued | playing | done | stopped | failed
@ -218,7 +331,7 @@ class Player:
self.seq = 0
threading.Thread(target=self._loop, daemon=True, name="player").start()
def enqueue(self, text: str, voice: str, speed: float, chime: bool, sender: str) -> Item:
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)
@ -297,7 +410,7 @@ def check_pin() -> None:
abort(401)
def parse_request() -> tuple[str, str, float, bool]:
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:
@ -312,7 +425,14 @@ def parse_request() -> tuple[str, str, float, bool]:
except (TypeError, ValueError):
abort(400, "Ugyldig hastighet")
speed = max(0.6, min(1.6, speed))
chime = bool(data.get("chime", True))
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
@ -327,6 +447,8 @@ def config() -> Response:
{
"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),
}