wake-alarm/wake_alarm/_ring_wav.py

121 lines
4.2 KiB
Python

"""The PC fallback alarm's sound, generated with stdlib ``wave``.
Two files, because the players can only loop a file from its start:
* ``ring.wav`` -- 60 s of the alarm pattern rising from about -26 dB to full
scale (linear in dB, so it is heard as a steady climb);
* ``ring_loop.wav`` -- the same pattern at full scale, looped after the rise.
The pattern is a classic alarm: four short beeps alternating 880/1320 Hz with
a third harmonic for bite, then a pause. Generated, never committed (the
binary hook forbids it): ``install.sh`` writes the system copy, and the user
backend regenerates its own if that copy is missing.
python -m wake_alarm._ring_wav --write /some/dir
"""
from __future__ import annotations
import argparse
from array import array
import math
from pathlib import Path
import struct
import sys
import wave
SAMPLE_RATE = 16_000
RISE_SECONDS = 60.0
LOOP_SECONDS = 20.0
RISE_FILENAME = "ring.wav"
LOOP_FILENAME = "ring_loop.wav"
# install.sh writes the pair here (world-readable, for the root ALSA unit and
# the user alike); the user backend falls back to generating its own copy.
SYSTEM_WAV_DIR = Path("/usr/local/share/wake-alarm")
USER_WAV_DIR = Path.home() / ".local" / "share" / "wake_alarm"
_FLOOR = 0.05 # -26 dB: audible at the start, never silent
_PEAK = 32_000 # just under int16 full scale, so the harmonic never clips
_BEEP = 0.12
_GAP = 0.08
_BEEPS = 4
_PERIOD = 1.2
_PITCHES = (880.0, 1320.0)
def _tone(t: float) -> float:
"""The alarm pattern at time ``t``, in ``[-1, 1]``."""
phase = t % _PERIOD
slot = int(phase // (_BEEP + _GAP))
if slot >= _BEEPS or phase - slot * (_BEEP + _GAP) >= _BEEP:
return 0.0
pitch = _PITCHES[slot % len(_PITCHES)]
angle = 2 * math.pi * pitch * t
# 0.75 sin x + 0.25 sin 3x peaks at 1/sqrt(2); scale it to full scale.
return math.sqrt(2) * (0.75 * math.sin(angle) + 0.25 * math.sin(3 * angle))
def _gain(t: float, rise_seconds: float) -> float:
"""Loudness at ``t``: the dB-linear climb, then full scale."""
if rise_seconds <= 0 or t >= rise_seconds:
return 1.0
return float(_FLOOR * (1 / _FLOOR) ** (t / rise_seconds))
def samples(seconds: float, *, rise_seconds: float = 0.0) -> array[int]:
"""``seconds`` of 16-bit mono samples, rising over ``rise_seconds``."""
out = array("h")
for index in range(int(seconds * SAMPLE_RATE)):
t = index / SAMPLE_RATE
out.append(round(_PEAK * _gain(t, rise_seconds) * _tone(t)))
return out
def write_wav(path: Path, data: array[int]) -> None:
"""Write ``data`` as a 16 kHz mono 16-bit WAV at ``path``."""
path.parent.mkdir(parents=True, exist_ok=True)
# WAV is little-endian whatever the host is; pack explicitly.
frames = struct.pack(f"<{len(data)}h", *data)
with wave.Wave_write(str(path)) as handle:
handle.setnchannels(1)
handle.setsampwidth(2)
handle.setframerate(SAMPLE_RATE)
handle.writeframes(frames)
def write_pair(
directory: Path,
*,
rise_seconds: float = RISE_SECONDS,
loop_seconds: float = LOOP_SECONDS,
) -> tuple[Path, Path]:
"""Write ``ring.wav`` and ``ring_loop.wav`` into ``directory``."""
rise, loop = directory / RISE_FILENAME, directory / LOOP_FILENAME
write_wav(rise, samples(rise_seconds, rise_seconds=rise_seconds))
write_wav(loop, samples(loop_seconds))
return rise, loop
def ensure_pair(preferred: Path, fallback: Path) -> tuple[Path, Path]:
"""The installed pair if both exist, else a pair generated in ``fallback``."""
for directory in (preferred, fallback):
rise, loop = directory / RISE_FILENAME, directory / LOOP_FILENAME
if rise.is_file() and loop.is_file():
return rise, loop
return write_pair(fallback)
def main(argv: list[str] | None = None) -> int:
"""Write the pair into ``--write DIR``."""
parser = argparse.ArgumentParser(description="Generate the fallback ring WAVs")
parser.add_argument("--write", type=Path, required=True, metavar="DIR")
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
for path in write_pair(args.write):
sys.stdout.write(f"{path}\n")
return 0
if __name__ == "__main__":
sys.exit(main())