diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-27 19:39:06 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-27 19:39:06 +0200 |
| commit | 38127fcdb57f51be31df637147c5db31bad9e10f (patch) | |
| tree | ff2868a61372d3f85845637ba4cd3494244bbda5 /docs | |
| download | cal-notif-38127fcdb57f51be31df637147c5db31bad9e10f.tar.gz cal-notif-38127fcdb57f51be31df637147c5db31bad9e10f.zip | |
Add GPLv2 license and cal-notif design spec
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/specs/2026-09-27-cal-notif-design.md | 170 |
1 files changed, 170 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-09-27-cal-notif-design.md b/docs/superpowers/specs/2026-09-27-cal-notif-design.md new file mode 100644 index 0000000..53138d6 --- /dev/null +++ b/docs/superpowers/specs/2026-09-27-cal-notif-design.md @@ -0,0 +1,170 @@ +# cal-notif design + +Date: 2026-09-27 + +## Goal + +A desktop daemon that notifies of upcoming calendar events. Each event's own +alarms (VALARM) set the cadence. Events without alarms can get custom +notification offsets, set from a rofi picker. Every notification is a popup +plus a spoken announcement rendered locally. + +## Scope + +In: VEVENTs from local `.ics` files, popups with a snooze action, local TTS, +a rofi picker for per-event overrides. + +Out: VTODO, network access of any kind (vdirsyncer already syncs), EMAIL and +AUDIO alarm actions (every alarm becomes popup + voice), persistent +fired-alarm state. + +## Stack + +- One Python file, `cal-notif`, run by system `/usr/bin/python3`. +- Libraries already on the system: `icalendar`, `dateutil` (RRULE parsing), + `kokoro_onnx` (TTS). Stdlib `tomllib` for config, `zoneinfo` for timezones. +- External programs: `notify-send`, `rofi`, `aplay`. +- License GPLv2 (v2-only). + +## Interface + +- `cal-notif daemon`: long-running loop. Started from the compositor's + autostart (no systemd on the target system). +- `cal-notif pick`: rofi picker, bound to a key. +- `cal-notif say "<text>"`: speak text through the configured voice and exit. + The daemon runs this as a subprocess; it doubles as a manual voice test. + +## Config + +`~/.config/cal-notif/config.toml`. Every key has a built-in default, so a +missing file works (VALARMs only, voice on). + +```toml +calendars_dir = "~/.local/share/calendars" # vdirsyncer filesystem storage +snooze = "5m" +late = "10m" # deliver alarms missed by at most this much + +[defaults] # per calendar directory name, only for events with no VALARM +birthdays = ["1d", "9h"] +"*" = [] # any other calendar: silent unless VALARM + +[voice] +enabled = true +model = "/data/voice-models/kokoro/kokoro-v1.0.onnx" +voices = "/data/voice-models/kokoro/voices-v1.0.bin" +lang = "it" +blend = { if_sara = 0.8, af_bella = 0.2 } +lead_silence = 0.3 # seconds of silence before speech, see Voice +say = "Tra {in}: {summary}" +say_now = "Adesso: {summary}" +``` + +Per-event overrides live in a separate file, +`~/.config/cal-notif/overrides.toml`, written only by `pick` (hand edits +also work). Keeping them apart means `pick` never rewrites the hand-edited +config and its comments. + +```toml +"some-uid@example.org" = ["1h", "10m"] # keyed by event UID; replaces VALARMs +"other-uid@example.org" = [] # silenced +``` + +Durations are strings of `<n>d`, `<n>h`, `<n>m` parts, e.g. `"1d 2h 15m"`. +A calendar in `[defaults]` may also set `voice = false` via a table form +(`birthdays = { offsets = ["1d"], voice = false }`). + +## Alarm precedence + +For each event occurrence, the offsets are the first of: + +1. `overrides.toml[UID]`, if present (an empty list silences the event). +2. The event's own VALARM triggers: relative (`-PT15M`, relative to start or + end) and absolute (`VALUE=DATE-TIME`). +3. `[defaults][<calendar>]`, else `[defaults]["*"]`. + +## Daemon loop + +Every 30 s: + +1. **Rescan** the `.ics` files when any calendar directory's mtime changed + (vdirsyncer rewrites files there), and when the config or overrides file's + mtime changed. +2. **Expand** events into concrete occurrences within a window + `[now, now + largest offset in use + 1 day]`. The window follows the + largest offset, so a "1 week before" alarm is never dropped. + - RRULE via `dateutil.rrule`, EXDATE removes occurrences. + - A VEVENT with RECURRENCE-ID replaces the occurrence it names, with its + own summary, time and alarms. + - All-day events (`VALUE=DATE`) start at local midnight; offsets count + from there. + - Floating times are local time. + The result is a sorted list of `(fire_time, occurrence, offset)`, + recomputed only on rescan. +3. **Fire** every entry with `last_tick < fire_time <= now`, skipping those + older than `now - late` and those whose occurrence already started (except + offset 0, the "now" alarm). +4. **Notify**: spawn `notify-send --wait --app-name=cal-notif + --action=snooze=Snooze --action=dismiss=Dismiss` with summary, start + time, location. Urgency critical for the offset-0 alarm. Spawn + `cal-notif say` alongside. Neither blocks the loop; finished children are + polled each tick. +5. **Snooze**: when a `notify-send` child prints `snooze`, queue a one-off + re-fire at `now + snooze` for the same occurrence. It speaks again. + +The notification server on the target system advertises `actions` and +returns the clicked action id to `notify-send --wait` (verified 2026-09-27). + +Deliberate simplification: fired alarms are kept in memory only. A restart +within the `late` window repeats those popups once. + +## Voice + +`cal-notif say` loads Kokoro, builds the voice from the blend of style +vectors, synthesizes the text and pipes 16-bit mono PCM to +`aplay -q -r <rate> -f S16_LE -c 1`. + +- Kokoro loads per announcement (about 0.3 s) instead of staying resident + in the daemon: alarms are rare, and a resident model costs 300+ MB. +- **Leading silence**: `lead_silence` seconds of zero samples are written + before the speech. A freshly started playback stream wakes the suspended + audio sink and loses its first ~0.2 s, which ate the first word of + announcements in a previous project. Default 0.3 s, tunable per machine. +- `{in}` is the offset spoken in words ("dieci minuti", "un giorno e due + ore"), from a small Italian number-to-words function covering 1-99 plus + units. Kokoro reads "10m" badly. +- If voice is disabled, or the model files are missing, only the popup + is shown; a missing model is reported once on stderr. + +## Picker (`pick`) + +1. rofi `-dmenu` lists occurrences in the next 30 days: + `dd.mm HH:MM · calendar · summary · [current offsets]`. +2. Second rofi menu for the chosen event: presets `10m`, `30m`, `1h`, `1d`, + `1d 1h`, `none` (silence), `reset` (drop override). Free text is accepted + and parsed as durations; invalid input shows a rofi error and re-prompts. +3. `overrides.toml` is rewritten whole (flat `"uid" = [..]` lines, trivial + to emit without a TOML writer) via a temp file and rename. The daemon + picks it up on its next tick through the mtime check. + +## Error handling + +- An unparsable `.ics` file is skipped with one stderr line naming it; the + others still load. +- An invalid config at startup exits with the error. An invalid config on + reload keeps the previous one and logs. +- `notify-send`, `aplay` or `rofi` missing: fail at startup for the daemon + (`notify-send`) or the picker (`rofi`); `aplay` missing disables voice. + +## Testing + +One `test_cal_notif.py` with plain asserts, fixtures as inline `.ics` +strings: + +- duration parsing and Italian spoken form +- precedence: override, VALARM, calendar default, `*` +- expansion: RRULE, EXDATE, RECURRENCE-ID, all-day, window sized by largest + offset +- fire window: `late` cutoff, started occurrences skipped, snooze re-fire + +Voice and popups are checked by hand: `cal-notif say "prova"` and an event +created a few minutes ahead. |
