Valgbare jingler: seks innebygde + egne filer i chimes/
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
45a22e7ed6
commit
756a234b13
4 changed files with 166 additions and 31 deletions
174
announcer.py
174
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("<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),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue