From 756a234b13ea13bc1be370c89a891af3bd65f3ae Mon Sep 17 00:00:00 2001 From: Kent Date: Tue, 6 Oct 2026 03:50:48 +0200 Subject: [PATCH] Valgbare jingler: seks innebygde + egne filer i chimes/ Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 7 +- announcer.py | 174 +++++++++++++++++++++++++++++++++++++++------- chimes/README.txt | 2 + index.html | 14 ++-- 4 files changed, 166 insertions(+), 31 deletions(-) create mode 100644 chimes/README.txt diff --git a/AGENTS.md b/AGENTS.md index 46786ef..0be4874 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,12 @@ ingen PIN), `ANNOUNCER_SINK` (pw-play `--target`; tom = standard utgang). - Knappen «Send ut» er låst til nøyaktig den teksten/stemmen/hastigheten som sist ble forhåndslyttet. Det er med vilje: ingen skal sende noe de ikke har hørt. - Ferdige WAV-er bufres i `cache/` etter innholdshash. Trygt å slette. -- Chimen («ding-dong») genereres i Python, ingen lydfil. +- Jingler: de innebygde (`BUILTIN_CHIMES`) genereres i Python med `_tone`/ + `_mix`/`_overlay`, ingen lydfiler. Egne filer i `chimes/` (wav/mp3/ogg/flac) + dukker opp i menyen som `file:`; ffmpeg kutter til 6 s, normaliserer + til -18 LUFS og fader ut. Mappa leses ved hver forespørsel, så nye filer + vises uten restart. Toppnivå på de innebygde ligger mellom -4 og -10 dBFS, + talen går til 0 dBFS, så jinglen skal aldri være høyere enn stemmen. ## Stemmer og motorer diff --git a/announcer.py b/announcer.py index bceb8db..077bee9 100644 --- a/announcer.py +++ b/announcer.py @@ -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(" 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: @@ -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), } diff --git a/chimes/README.txt b/chimes/README.txt new file mode 100644 index 0000000..5fa76d6 --- /dev/null +++ b/chimes/README.txt @@ -0,0 +1,2 @@ +Legg egne jingler her (wav/mp3/ogg/flac). Filnavnet blir navnet i menyen. +Maks 6 sekunder brukes, volumet normaliseres. diff --git a/index.html b/index.html index 1fee223..af2cb03 100644 --- a/index.html +++ b/index.html @@ -86,8 +86,9 @@ -
-
+
+ +
@@ -132,7 +133,7 @@ const QUICK = [ function key() { return JSON.stringify(payload()); } function payload() { - return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.checked }; + return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.value }; } function headers() { const h = { 'Content-Type': 'application/json' }; @@ -163,6 +164,11 @@ async function init() { } voice.value = localStorage.getItem('voice') || cfg.default_voice; if (!voice.value) voice.value = cfg.default_voice; + for (const c of cfg.chimes) { + const o = document.createElement('option'); o.value = c.id; o.textContent = c.name; chime.append(o); + } + chime.value = localStorage.getItem('chime') || cfg.default_chime; + if (!chime.value) chime.value = cfg.default_chime; $('#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) { @@ -176,7 +182,7 @@ async function init() { text.addEventListener('input', () => { $('#count').textContent = text.value.length; updateAnnounce(); }); voice.addEventListener('change', () => { localStorage.setItem('voice', voice.value); updateAnnounce(); }); speed.addEventListener('input', () => { $('#speedval').textContent = parseFloat(speed.value).toFixed(2).replace(/0$/, '') + '×'; updateAnnounce(); }); -chime.addEventListener('change', updateAnnounce); +chime.addEventListener('change', () => { localStorage.setItem('chime', chime.value); updateAnnounce(); }); $('#pin').addEventListener('input', e => localStorage.setItem('pin', e.target.value)); btnPreview.addEventListener('click', async () => {