mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
121 lines
4.2 KiB
Python
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())
|