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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
168
announcer.py
168
announcer.py
|
|
@ -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)):
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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)
|
n = int(rate * dur)
|
||||||
|
out = []
|
||||||
|
phase = 0.0
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
t = i / rate
|
t = i / rate
|
||||||
env = math.exp(-5.5 * t) # rask utklang
|
f = freq * (slide ** (t / dur)) if slide else freq
|
||||||
s = (
|
phase += 2 * math.pi * f / rate
|
||||||
math.sin(2 * math.pi * freq * t)
|
if wave_ == "square":
|
||||||
+ 0.3 * math.sin(2 * math.pi * freq * 2 * t)
|
v = 1.0 if math.sin(phase) >= 0 else -1.0
|
||||||
) * env * volume
|
elif wave_ == "saw":
|
||||||
out += struct.pack("<h", int(max(-1.0, min(1.0, s)) * 32767))
|
v = 2.0 * ((phase / (2 * math.pi)) % 1.0) - 1.0
|
||||||
out += b"\x00\x00" * int(rate * 0.35) # pause før talen
|
else:
|
||||||
return bytes(out)
|
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
2
chimes/README.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
Legg egne jingler her (wav/mp3/ogg/flac). Filnavnet blir navnet i menyen.
|
||||||
|
Maks 6 sekunder brukes, volumet normaliseres.
|
||||||
14
index.html
14
index.html
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue