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

@ -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 - 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. 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. - 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:<stem>`; 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 ## Stemmer og motorer

View file

@ -98,21 +98,136 @@ def load_voice(name: str):
return _voice_cache[name] 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.""" # Jingler (chimes). Alle de innebygde genereres i Python; egne lydfiler
out = bytearray() # (wav/mp3/ogg/flac) i CHIME_DIR dukker opp automatisk i menyen.
for freq, dur in ((784.0, 0.28), (523.25, 0.42)): # --------------------------------------------------------------------------
n = int(rate * dur)
for i in range(n): CHIME_DIR = Path(os.environ.get("ANNOUNCER_CHIMES", HERE / "chimes"))
t = i / rate
env = math.exp(-5.5 * t) # rask utklang
s = ( def _tone(rate: int, freq: float, dur: float, vol: float = 0.35, wave_: str = "sine",
math.sin(2 * math.pi * freq * t) decay: float = 5.5, attack: float = 0.005, harm2: float = 0.0, slide: float = 0.0) -> list[float]:
+ 0.3 * math.sin(2 * math.pi * freq * 2 * t) """Én tone som liste av floats. slide = frekvensendring (multiplikator) over tonen."""
) * env * volume n = int(rate * dur)
out += struct.pack("<h", int(max(-1.0, min(1.0, s)) * 32767)) out = []
out += b"\x00\x00" * int(rate * 0.35) # pause før talen phase = 0.0
return bytes(out) 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: 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) mp3.unlink(missing_ok=True)
def synth_key(text: str, voice: str, speed: float, chime: bool) -> str: def synth_key(text: str, voice: str, speed: float, chime: str) -> str:
h = hashlib.sha1(f"{voice}|{speed:.2f}|{int(chime)}|{text}".encode()).hexdigest() h = hashlib.sha1(f"{voice}|{speed:.2f}|{chime}|{text}".encode()).hexdigest()
return h[:20] 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).""" """Returnerer sti til ferdig WAV (bufres på disk etter innhold)."""
from piper.config import SynthesisConfig from piper.config import SynthesisConfig
@ -156,15 +271,13 @@ def synthesize(text: str, voice_name: str, speed: float, chime: bool) -> Path:
pcm = bytearray() pcm = bytearray()
if spec["engine"] == "edge": if spec["engine"] == "edge":
rate = EDGE_RATE rate = EDGE_RATE
if chime: pcm += chime_pcm(chime, rate)
pcm += make_chime(rate)
pcm += edge_pcm(text, spec["model"], speed) pcm += edge_pcm(text, spec["model"], speed)
else: else:
voice = load_voice(spec["model"]) voice = load_voice(spec["model"])
rate = voice.config.sample_rate rate = voice.config.sample_rate
cfg = SynthesisConfig(length_scale=1.0 / speed, speaker_id=spec["speaker"]) cfg = SynthesisConfig(length_scale=1.0 / speed, speaker_id=spec["speaker"])
if chime: pcm += chime_pcm(chime, rate)
pcm += make_chime(rate)
for chunk in voice.synthesize(text, cfg): for chunk in voice.synthesize(text, cfg):
pcm += chunk.audio_int16_bytes pcm += chunk.audio_int16_bytes
pcm += b"\x00\x00" * int(rate * 0.3) pcm += b"\x00\x00" * int(rate * 0.3)
@ -190,7 +303,7 @@ class Item:
text: str text: str
voice: str voice: str
speed: float speed: float
chime: bool chime: str
sender: str sender: str
created: float = field(default_factory=time.time) created: float = field(default_factory=time.time)
status: str = "queued" # queued | playing | done | stopped | failed status: str = "queued" # queued | playing | done | stopped | failed
@ -218,7 +331,7 @@ class Player:
self.seq = 0 self.seq = 0
threading.Thread(target=self._loop, daemon=True, name="player").start() 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: with self.lock:
self.seq += 1 self.seq += 1
item = Item(self.seq, text, voice, speed, chime, sender) item = Item(self.seq, text, voice, speed, chime, sender)
@ -297,7 +410,7 @@ def check_pin() -> None:
abort(401) 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 {} data = request.get_json(silent=True) or {}
text = " ".join(str(data.get("text", "")).split()) text = " ".join(str(data.get("text", "")).split())
if not text: if not text:
@ -312,7 +425,14 @@ def parse_request() -> tuple[str, str, float, bool]:
except (TypeError, ValueError): except (TypeError, ValueError):
abort(400, "Ugyldig hastighet") abort(400, "Ugyldig hastighet")
speed = max(0.6, min(1.6, speed)) 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 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], "voices": [{"id": v["id"], "name": v["name"], "group": v["group"], "engine": v["engine"]} for v in VOICES_LIST],
"default_voice": DEFAULT_VOICE, "default_voice": DEFAULT_VOICE,
"chimes": list_chimes(),
"default_chime": "dingdong",
"max_chars": MAX_CHARS, "max_chars": MAX_CHARS,
"pin_required": bool(PIN), "pin_required": bool(PIN),
} }

2
chimes/README.txt Normal file
View file

@ -0,0 +1,2 @@
Legg egne jingler her (wav/mp3/ogg/flac). Filnavnet blir navnet i menyen.
Maks 6 sekunder brukes, volumet normaliseres.

View file

@ -86,8 +86,9 @@
<label for="speed">Hastighet <span id="speedval">1.0×</span></label> <label for="speed">Hastighet <span id="speedval">1.0×</span></label>
<input id="speed" type="range" min="0.7" max="1.4" step="0.05" value="1.0"> <input id="speed" type="range" min="0.7" max="1.4" step="0.05" value="1.0">
</div> </div>
<div style="flex:0 1 auto"> <div>
<div class="check"><input id="chime" type="checkbox" checked><label for="chime" style="margin:0;text-transform:none;letter-spacing:0;color:var(--fg)">Ding-dong først</label></div> <label for="chime">Jingle først</label>
<select id="chime"></select>
</div> </div>
</div> </div>
@ -132,7 +133,7 @@ const QUICK = [
function key() { return JSON.stringify(payload()); } function key() { return JSON.stringify(payload()); }
function 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() { function headers() {
const h = { 'Content-Type': 'application/json' }; const h = { 'Content-Type': 'application/json' };
@ -163,6 +164,11 @@ async function init() {
} }
voice.value = localStorage.getItem('voice') || cfg.default_voice; voice.value = localStorage.getItem('voice') || cfg.default_voice;
if (!voice.value) voice.value = 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; $('#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) {
@ -176,7 +182,7 @@ async function init() {
text.addEventListener('input', () => { $('#count').textContent = text.value.length; updateAnnounce(); }); text.addEventListener('input', () => { $('#count').textContent = text.value.length; updateAnnounce(); });
voice.addEventListener('change', () => { localStorage.setItem('voice', voice.value); updateAnnounce(); }); voice.addEventListener('change', () => { localStorage.setItem('voice', voice.value); updateAnnounce(); });
speed.addEventListener('input', () => { $('#speedval').textContent = parseFloat(speed.value).toFixed(2).replace(/0$/, '') + '×'; 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)); $('#pin').addEventListener('input', e => localStorage.setItem('pin', e.target.value));
btnPreview.addEventListener('click', async () => { btnPreview.addEventListener('click', async () => {