1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
|
# 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
# A calendar's name is its `displayname` file (vdirsyncer metadata), else
# its directory name. [defaults] keys and the picker use that name.
snooze = "5m"
rofi_theme = "~/.config/rofi/udt/list.rasi"
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"`
(one offset of 26h15m). Several offsets are a list in TOML, or
comma-separated in the picker (`1d, 1h` is two alarms).
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), when the config or overrides file's
mtime changed, and every hour regardless, so the window keeps moving even
when nothing 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 on rescan, which also runs every hour so the window keeps
moving.
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`)
Both menus follow the unified-desktop-theme (udt) rofi theming: every call
is `rofi -dmenu -i -theme ~/.config/rofi/udt/list.rasi`, the same layout
rofipass uses. `list.rasi` is the udt layout that keeps both the inputbar
(needed to type free-text durations) and the `message` widget (needed for
`-mesg`); `menu.rasi` has neither. No colors or layout are set by
cal-notif itself, so a udt scheme switch applies with no change here. The
theme path is a config key (`rofi_theme`) defaulting to that file.
1. rofi lists occurrences in the next 30 days:
`dd.mm HH:MM · calendar · summary · [current offsets]`.
2. Second rofi menu for the chosen event, with the event's summary and time
in `-mesg`: presets `10m`, `30m`, `1h`, `1d`, `1d, 1h`, `none` (silence),
`reset` (drop override). Free text is accepted and parsed as durations;
invalid input re-prompts with the error shown in `-mesg`.
rofi exits non-zero on Escape: treated as cancel, never as an error.
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.
|