mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 13:47:31 +01:00
The failsafe silences AUDIO ONLY at 300 s. The lock, the typed challenge and
the workout gate all persist, so waiting it out in bed is not an escape --
it only stops a ring nobody is present to dismiss from sounding forever.
Adding it exposed two collisions that would each have quietly defeated the
alarm:
1. The escalation ladder ended outside the audible window. PHASE_SOFT_END
was 5 minutes and PHASE_MEDIUM_END 15, so with the failsafe at 5 minutes
the alarm was silenced before it ever left its SOFTEST rung -- the medium
and loud phases became unreachable code. Printed timeline before the fix:
0s soft 440Hz ... 299s soft 440Hz | 300s failsafe-silent
Phases are now 1 and 2 minutes, so the alarm spends 3 of its 5 audible
minutes on the loud alternating rung, which is what makes lying in bed
for five minutes unattractive.
2. ESCAPE_OFFER_AFTER_SECONDS was also 300, so the escape hatch appeared in
the same instant the sound stopped -- at its most tempting, precisely when
the alarm had just stopped being annoying. Moved to 900.
Both invariants are now locked in by tests, so retuning one constant without
the other fails instead of silently degrading the alarm.
Also fixes a latent test bug this surfaced: TestBeepLoopPhases anchored
elapsed time to "one minute past the previous boundary", so a phase retune
made it miss its rung, never set _stop_beep, and spin forever calling the
REAL audio stack -- hanging the whole suite rather than failing. It now uses
the midpoint of the band and patches every rung, so a mistuned boundary fails
fast.
test_alarm_part3.py split (372 -> 195) for the 250-line gate.
413 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
83 lines
2.6 KiB
Python
83 lines
2.6 KiB
Python
"""Decide what the alarm should sound like at a given point in the ring.
|
|
|
|
Split out of ``_alarm.py`` so the escalation ladder -- and, more importantly,
|
|
the failsafe that silences it -- is testable without a Tk window, a lock, or a
|
|
sound card.
|
|
|
|
The failsafe silences **audio only**. The lock, the typed challenge and the
|
|
workout gate all persist: a five-minute wait must not become a way to get back
|
|
into bed. What it protects against is a runaway alarm nobody is present to
|
|
dismiss (an empty flat, a forgotten travel day), which is a neighbour problem,
|
|
not a wake-up problem.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from wake_alarm._constants import (
|
|
FAILSAFE_SILENCE_SECONDS,
|
|
LOUD_TOGGLE_INTERVAL,
|
|
MEDIUM_BEEP_INTERVAL,
|
|
PHASE_MEDIUM_END,
|
|
PHASE_SOFT_END,
|
|
SOFT_BEEP_INTERVAL,
|
|
)
|
|
|
|
# Tone frequencies for each rung of the ladder.
|
|
SOFT_FREQUENCY = 440
|
|
MEDIUM_FREQUENCY = 1000
|
|
LOUD_LOW_FREQUENCY = 800
|
|
LOUD_HIGH_FREQUENCY = 1200
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BeepPlan:
|
|
"""What the beep loop should do for one iteration.
|
|
|
|
Attributes:
|
|
frequency: Tone to play, or None when the failsafe has silenced audio.
|
|
interval: Seconds to wait before reconsidering.
|
|
phase: Name of the rung, for logging.
|
|
silenced: True once the failsafe has stopped the sound.
|
|
"""
|
|
|
|
frequency: int | None
|
|
interval: float
|
|
phase: str
|
|
silenced: bool
|
|
|
|
|
|
def plan_for(elapsed_seconds: float) -> BeepPlan:
|
|
"""Choose the beep behaviour for a ring that started *elapsed_seconds* ago.
|
|
|
|
Args:
|
|
elapsed_seconds: Seconds since the alarm began sounding.
|
|
|
|
Returns:
|
|
The plan for this iteration.
|
|
"""
|
|
if elapsed_seconds >= FAILSAFE_SILENCE_SECONDS:
|
|
# Audio only. The caller keeps the lock and the challenge up.
|
|
return BeepPlan(
|
|
frequency=None,
|
|
interval=LOUD_TOGGLE_INTERVAL,
|
|
phase="failsafe-silent",
|
|
silenced=True,
|
|
)
|
|
elapsed_minutes = elapsed_seconds / 60.0
|
|
if elapsed_minutes < PHASE_SOFT_END:
|
|
return BeepPlan(SOFT_FREQUENCY, SOFT_BEEP_INTERVAL, "soft", silenced=False)
|
|
if elapsed_minutes < PHASE_MEDIUM_END:
|
|
return BeepPlan(
|
|
MEDIUM_FREQUENCY,
|
|
MEDIUM_BEEP_INTERVAL,
|
|
"medium",
|
|
silenced=False,
|
|
)
|
|
# Alternate two tones: a changing pitch stays salient where a constant one
|
|
# is tuned out.
|
|
alternate = int(elapsed_minutes * 10) % 2 == 0
|
|
frequency = LOUD_LOW_FREQUENCY if alternate else LOUD_HIGH_FREQUENCY
|
|
return BeepPlan(frequency, LOUD_TOGGLE_INTERVAL, "loud", silenced=False)
|