Einfg on the kiosk keyboard, or Guide (KEY_EPG) on the IR remote, shows a big analog watch over whatever page is open, built from the Delphin Uhr mockup without its swimming dolphin. The overlay stays mounted and is only hidden, so it appears at once. Beside the watch, only while they run: - The bus: on school days, a silent countdown to `general.clock.bus_time` (07:25) that starts `bus_lead_minutes` (60) before. School days skip weekends and the school and public holidays of `general.clock.subdivision` (DE-BY), fetched daily from openholidaysapi.org and cached under the library cache. A badge on the clock says whether there is school today, and why not. - The dolphin timer, set in parent mode (5/10/15/30 minutes, or until a time). It lives in the backend, so a parent's phone can start it for the kiosk. When it runs out the kiosk opens the clock at 00:00 and a soft bell chimes on the device; it clears after a minute, or at the next key, tap or remote press. Parent mode is now a way in rather than a page: `?parentMode=1` adds a 🔧 button next to the ⌨ one, or an "Eltern" tab in the phone's dock, so the URL can stay bookmarked. The timer comes first there, the device settings below it. Unmapped IR buttons are logged at debug level, to find out what irserver calls a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 lines
3.8 KiB
Python
105 lines
3.8 KiB
Python
"""A synthesised "blubb": two bubbles, for the screen switching on or off.
|
|
|
|
Built from samples rather than shipped as a file - the same idea as the front-end's
|
|
WebAudio blips (``web/src/lib/tippen/pop.ts``), and it lets the loudness follow the
|
|
player's volume instead of being fixed in a recording.
|
|
|
|
A bubble is a sine whose pitch rises as it decays (an air bubble's resonance climbs as
|
|
it reaches the surface). Two of them make the "blubb"; switching on the second one is
|
|
higher, switching off it is lower, so the two directions are told apart by ear.
|
|
|
|
The parents' timer has its own sound here too: a soft "ding-dong" bell
|
|
(:func:`chime_wav`), made the same way.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import math
|
|
import wave
|
|
from array import array
|
|
|
|
__all__ = ["RATE", "blubb_wav", "chime_wav"]
|
|
|
|
RATE = 44100
|
|
|
|
#: (start Hz, end Hz, start s, length s) of each bubble.
|
|
_ON = ((260.0, 620.0, 0.0, 0.11), (380.0, 900.0, 0.13, 0.13))
|
|
_OFF = ((380.0, 900.0, 0.0, 0.11), (240.0, 560.0, 0.13, 0.15))
|
|
|
|
|
|
def blubb_wav(*, on: bool, gain: float) -> bytes:
|
|
"""A mono 16-bit WAV. ``gain`` is the peak, 0..1 of full scale."""
|
|
bubbles = _ON if on else _OFF
|
|
length = max(start + duration for _, _, start, duration in bubbles)
|
|
samples = [0.0] * (int(length * RATE) + 1)
|
|
for start_hz, end_hz, start, duration in bubbles:
|
|
_add_bubble(samples, start_hz, end_hz, int(start * RATE), duration)
|
|
|
|
peak = max(abs(s) for s in samples) or 1.0
|
|
scale = 32767 * max(0.0, min(1.0, gain)) / peak
|
|
return _wav(samples, scale)
|
|
|
|
|
|
#: (Hz, start s) of each bell strike: a falling major third, "ding-dong".
|
|
_CHIME = ((1318.5, 0.0), (1046.5, 0.45))
|
|
|
|
#: How long each strike rings out.
|
|
_CHIME_DECAY = 1.6
|
|
|
|
|
|
def chime_wav(*, gain: float) -> bytes:
|
|
"""A soft two-note bell for the parents' timer running out. Same format as
|
|
:func:`blubb_wav`."""
|
|
length = max(start for _, start in _CHIME) + _CHIME_DECAY
|
|
samples = [0.0] * (int(length * RATE) + 1)
|
|
for frequency, start in _CHIME:
|
|
_add_strike(samples, frequency, int(start * RATE))
|
|
peak = max(abs(s) for s in samples) or 1.0
|
|
scale = 32767 * max(0.0, min(1.0, gain)) / peak
|
|
return _wav(samples, scale)
|
|
|
|
|
|
def _wav(samples: list[float], scale: float) -> bytes:
|
|
pcm = array("h", (round(s * scale) for s in samples))
|
|
buffer = io.BytesIO()
|
|
with wave.open(buffer, "wb") as out:
|
|
out.setnchannels(1)
|
|
out.setsampwidth(2)
|
|
out.setframerate(RATE)
|
|
out.writeframes(pcm.tobytes())
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _add_bubble(
|
|
samples: list[float], start_hz: float, end_hz: float, offset: int, duration: float
|
|
) -> None:
|
|
count = int(duration * RATE)
|
|
attack = int(0.004 * RATE) # a few ms of fade-in, or the first sample clicks
|
|
phase = 0.0
|
|
for i in range(count):
|
|
t = i / count
|
|
# Exponential sweep, fast at first like the real thing.
|
|
frequency = start_hz * (end_hz / start_hz) ** (1 - (1 - t) ** 2)
|
|
phase += 2 * math.pi * frequency / RATE
|
|
envelope = min(1.0, i / attack) * math.exp(-5 * t)
|
|
index = offset + i
|
|
if index < len(samples):
|
|
samples[index] += math.sin(phase) * envelope
|
|
|
|
|
|
def _add_strike(samples: list[float], frequency: float, offset: int) -> None:
|
|
"""A bell: the fundamental plus two quieter, faster-fading overtones."""
|
|
count = int(_CHIME_DECAY * RATE)
|
|
attack = int(0.006 * RATE)
|
|
partials = ((1.0, 1.0, 3.0), (2.0, 0.35, 5.0), (3.0, 0.12, 8.0)) # ratio, level, decay
|
|
for i in range(count):
|
|
t = i / RATE
|
|
value = sum(
|
|
level * math.exp(-decay * t) * math.sin(2 * math.pi * frequency * ratio * t)
|
|
for ratio, level, decay in partials
|
|
)
|
|
index = offset + i
|
|
if index < len(samples):
|
|
samples[index] += value * min(1.0, i / attack)
|