Files
musicmouse/python-backend/musicmouse/blubb.py
Martin Bauer a55cc4f67f Clock: a "Delphin Uhr" overlay with the school bus and a parents' timer
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>
2026-10-03 19:45:45 +02:00

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)