wirenote 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. wirenote/__init__.py +3 -0
  2. wirenote/calendar/__init__.py +2 -0
  3. wirenote/calendar/ics.py +236 -0
  4. wirenote/capture/__init__.py +2 -0
  5. wirenote/capture/recorder.py +304 -0
  6. wirenote/capture/wav.py +58 -0
  7. wirenote/cli.py +262 -0
  8. wirenote/config/__init__.py +2 -0
  9. wirenote/config/apps.py +158 -0
  10. wirenote/config/migrate.py +130 -0
  11. wirenote/config/perms.py +61 -0
  12. wirenote/config/secrets.py +98 -0
  13. wirenote/config/settings.py +176 -0
  14. wirenote/config/vocabulary.py +32 -0
  15. wirenote/data/wirenote-ui.service +15 -0
  16. wirenote/data/wirenote.desktop +10 -0
  17. wirenote/data/wirenote.service +15 -0
  18. wirenote/detector/__init__.py +2 -0
  19. wirenote/detector/core.py +115 -0
  20. wirenote/detector/daemon.py +743 -0
  21. wirenote/detector/graph.py +102 -0
  22. wirenote/detector/identity.py +205 -0
  23. wirenote/detector/streams.py +69 -0
  24. wirenote/detector/tracker.py +304 -0
  25. wirenote/net/__init__.py +2 -0
  26. wirenote/net/peer.py +101 -0
  27. wirenote/notify/__init__.py +2 -0
  28. wirenote/notify/clipboard.py +43 -0
  29. wirenote/notify/dbus.py +89 -0
  30. wirenote/process/__init__.py +2 -0
  31. wirenote/process/jobs.py +122 -0
  32. wirenote/process/merge.py +87 -0
  33. wirenote/process/models.py +57 -0
  34. wirenote/process/note.py +124 -0
  35. wirenote/process/speakers.py +295 -0
  36. wirenote/process/summarize.py +287 -0
  37. wirenote/process/transcribe.py +97 -0
  38. wirenote/process/worker.py +229 -0
  39. wirenote/tools/__init__.py +2 -0
  40. wirenote/tools/dump_streams.py +595 -0
  41. wirenote/tools/install.py +140 -0
  42. wirenote/tools/setup.py +338 -0
  43. wirenote/ui/__init__.py +2 -0
  44. wirenote/ui/data.py +783 -0
  45. wirenote/ui/launch.py +88 -0
  46. wirenote/ui/server.py +423 -0
  47. wirenote/ui/static/app.css +907 -0
  48. wirenote/ui/static/app.js +1267 -0
  49. wirenote/ui/static/fonts/OFL.txt +93 -0
  50. wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
  51. wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
  52. wirenote/ui/static/icon.svg +1 -0
  53. wirenote/ui/static/icons.svg +31 -0
  54. wirenote/ui/static/index.html +57 -0
  55. wirenote/ui/static/login.html +19 -0
  56. wirenote-0.1.0.dist-info/METADATA +106 -0
  57. wirenote-0.1.0.dist-info/RECORD +60 -0
  58. wirenote-0.1.0.dist-info/WHEEL +4 -0
  59. wirenote-0.1.0.dist-info/entry_points.txt +2 -0
  60. wirenote-0.1.0.dist-info/licenses/LICENSE +661 -0
wirenote/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ __version__ = "0.1.0"
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
@@ -0,0 +1,236 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """ICS calendar matching (docs/dev/PLAN.md › Defaults › Calendar window and refresh).
4
+
5
+ Sources are ICS links (https, webcal) or file paths, refreshed every
6
+ calendar_refresh_s. An event is in the window when it starts within
7
+ calendar_before_s, started at most calendar_after_start_s ago, or is in
8
+ progress. Tie-break (docs/dev/m2-m4-design.md › M4): prefer an event with a link
9
+ for the detected service, else the only event in the window, else none.
10
+
11
+ A calendar feed is outside input: anyone can send an invite. So links must be
12
+ https, a feed is capped in size, each event series expands to at most
13
+ MAX_PER_EVENT occurrences, a series that would take too many recurrence steps to
14
+ reach the window is skipped, and errors never repeat the (secret) link.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import datetime as dt
20
+ import math
21
+ import re
22
+ import urllib.error
23
+ import urllib.request
24
+ from dataclasses import asdict, dataclass, field
25
+ from pathlib import Path
26
+ from urllib.parse import urlsplit
27
+
28
+ LINK = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE)
29
+ # Hosts that mean "this event is an online call", by service name.
30
+ SERVICE_HOSTS = {
31
+ "Jitsi": ("jitsi", "meet.jit.si"),
32
+ "Zoom": ("zoom.us",),
33
+ "Teams": ("teams.microsoft.com", "teams.live.com"),
34
+ "Meet": ("meet.google.com",),
35
+ "Rocket.Chat": ("rocket",),
36
+ "Discord": ("discord.com", "discord.gg"),
37
+ "Whereby": ("whereby.com",),
38
+ }
39
+ LOOKAHEAD = dt.timedelta(hours=24)
40
+ LOOKBEHIND = dt.timedelta(hours=12)
41
+ MAX_BYTES = 10_000_000
42
+ MAX_PER_EVENT = 50
43
+ MAX_EVENTS = 2000
44
+ MAX_TITLE = 200
45
+ # Expanding a series walks every occurrence from its DTSTART, also those before
46
+ # the window. Estimate those steps and skip series (and feeds) that cost too much:
47
+ # 500,000 steps take about a second.
48
+ FREQ_S = {"SECONDLY": 1, "MINUTELY": 60, "HOURLY": 3600, "DAILY": 86400,
49
+ "WEEKLY": 7 * 86400, "MONTHLY": 28 * 86400, "YEARLY": 365 * 86400}
50
+ BY_PARTS = ("BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", "BYMONTHDAY", "BYYEARDAY",
51
+ "BYWEEKNO", "BYMONTH", "BYSETPOS")
52
+ MAX_SERIES_STEPS = 200_000
53
+ MAX_FEED_STEPS = 500_000
54
+ SCHEME = re.compile(r"^(https?|webcal)://", re.IGNORECASE)
55
+
56
+
57
+ class CalendarError(Exception):
58
+ pass
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Event:
63
+ title: str
64
+ start: dt.datetime
65
+ end: dt.datetime
66
+ links: tuple[str, ...] = ()
67
+ attendees: tuple[str, ...] = field(default=())
68
+ emails: tuple[str, ...] = field(default=())
69
+
70
+ def as_meta(self) -> dict:
71
+ d = asdict(self)
72
+ d["start"], d["end"] = self.start.isoformat(), self.end.isoformat()
73
+ d["attendees"], d["links"] = list(self.attendees), list(self.links)
74
+ d["emails"] = list(self.emails)
75
+ return d
76
+
77
+ def service_links(self, service: str | None) -> list[str]:
78
+ hosts = SERVICE_HOSTS.get(service, ()) if service else \
79
+ tuple(h for hs in SERVICE_HOSTS.values() for h in hs)
80
+ return [l for l in self.links if any(h in l.lower() for h in hosts)]
81
+
82
+
83
+ def parse(text: str | bytes, now: dt.datetime) -> list[Event]:
84
+ """Timed events (all-day events are skipped) around now, recurrences expanded.
85
+
86
+ Each series (one UID) is expanded on its own and lazily, so one event that
87
+ repeats every second costs MAX_PER_EVENT occurrences, not the whole window."""
88
+ import icalendar
89
+ import recurring_ical_events
90
+
91
+ cal = icalendar.Calendar.from_ical(text)
92
+ start, end = now - LOOKBEHIND, now + LOOKAHEAD
93
+ zones = list(cal.walk("VTIMEZONE"))
94
+ series: dict[str, list] = {}
95
+ for i, ev in enumerate(cal.walk("VEVENT")):
96
+ series.setdefault(str(ev.get("UID") or f"#{i}"), []).append(ev)
97
+ out: list[Event] = []
98
+ budget = MAX_FEED_STEPS
99
+ for comps in series.values():
100
+ cost = _steps_before(comps, start)
101
+ if cost > MAX_SERIES_STEPS or cost > budget:
102
+ continue
103
+ budget -= cost
104
+ sub = icalendar.Calendar()
105
+ for c in zones + comps:
106
+ sub.add_component(c)
107
+ kept = 0
108
+ for seen, ev in enumerate(recurring_ical_events.of(sub).after(start)):
109
+ if seen >= 4 * MAX_PER_EVENT or kept >= MAX_PER_EVENT:
110
+ break
111
+ first = ev.decoded("DTSTART", None)
112
+ if not isinstance(first, dt.datetime):
113
+ if isinstance(first, dt.date) and first > end.date():
114
+ break
115
+ continue
116
+ if _aware(first) >= end:
117
+ break
118
+ out.append(_event(ev))
119
+ kept += 1
120
+ if len(out) >= MAX_EVENTS:
121
+ return out[:MAX_EVENTS]
122
+ return out
123
+
124
+
125
+ def _steps_before(comps: list, start: dt.datetime) -> float:
126
+ """About how many recurrence steps expanding comps walks before reaching start.
127
+ Overestimates (BY* lists may limit rather than expand); unknown counts as too many."""
128
+ total = 0.0
129
+ for ev in comps:
130
+ rules = ev.get("RRULE")
131
+ if rules is None:
132
+ continue
133
+ try:
134
+ first = ev.decoded("DTSTART")
135
+ if not isinstance(first, dt.datetime):
136
+ first = dt.datetime.combine(first, dt.time(), start.tzinfo)
137
+ span = max(0.0, (start - _aware(first)).total_seconds())
138
+ for rule in rules if isinstance(rules, list) else [rules]:
139
+ step = FREQ_S[str(rule["FREQ"][0]).upper()] * max(1, int(rule.get("INTERVAL", [1])[0]))
140
+ factor = math.prod(max(1, len(rule.get(part) or [])) for part in BY_PARTS)
141
+ total += span / step * factor
142
+ except (KeyError, ValueError, TypeError, IndexError):
143
+ return math.inf
144
+ return total
145
+
146
+
147
+ def _event(ev) -> Event:
148
+ start, end = ev.decoded("DTSTART"), ev.decoded("DTEND", None)
149
+ if not isinstance(end, dt.datetime):
150
+ end = start + (ev.decoded("DURATION", None) or dt.timedelta(hours=1))
151
+ start, end = _aware(start), _aware(end)
152
+ text_fields = " ".join(str(ev.get(k, "")) for k in ("LOCATION", "DESCRIPTION", "URL"))
153
+ links = tuple(dict.fromkeys(m.rstrip(".,;)") for m in LINK.findall(text_fields)))
154
+ att = ev.get("ATTENDEE", [])
155
+ att = att if isinstance(att, list) else [att]
156
+ emails = tuple(re.sub(r"^mailto:", "", str(a), flags=re.IGNORECASE) for a in att)
157
+ names = tuple(str(a.params.get("CN") or e) for a, e in zip(att, emails))
158
+ title = str(ev.get("SUMMARY", "")).strip()[:MAX_TITLE] or "(no title)"
159
+ return Event(title, start, end, links, names, tuple(e for e in emails if "@" in e))
160
+
161
+
162
+ def _aware(t: dt.datetime) -> dt.datetime:
163
+ return t if t.tzinfo else t.astimezone()
164
+
165
+
166
+ def in_window(events: list[Event], now: dt.datetime, before_s: float,
167
+ after_start_s: float) -> list[Event]:
168
+ before, after = dt.timedelta(seconds=before_s), dt.timedelta(seconds=after_start_s)
169
+ return [e for e in events
170
+ if e.start - before <= now and (now <= e.start + after or now < e.end)]
171
+
172
+
173
+ def pick(events: list[Event], service: str | None) -> Event | None:
174
+ linked = [e for e in events if e.service_links(service)]
175
+ if len(linked) == 1:
176
+ return linked[0]
177
+ if len(events) == 1:
178
+ return events[0]
179
+ return None
180
+
181
+
182
+ def has_call_link(events: list[Event]) -> bool:
183
+ return any(e.service_links(None) for e in events)
184
+
185
+
186
+ class _HttpsOnlyRedirect(urllib.request.HTTPRedirectHandler):
187
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
188
+ if urlsplit(newurl).scheme.lower() != "https":
189
+ raise CalendarError("the calendar redirected away from https; refused")
190
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
191
+
192
+
193
+ def _open(req: urllib.request.Request, timeout_s: float):
194
+ return urllib.request.build_opener(_HttpsOnlyRedirect).open(req, timeout=timeout_s)
195
+
196
+
197
+ def source_label(source: str) -> str:
198
+ """What may be shown or logged for a source: the host of a link, the name of a file."""
199
+ if SCHEME.match(source):
200
+ return urlsplit(source).hostname or "calendar link"
201
+ return Path(source).name or "calendar file"
202
+
203
+
204
+ def fetch(source: str, timeout_s: float = 30.0) -> bytes:
205
+ if SCHEME.match(source):
206
+ url = re.sub(r"^webcal://", "https://", source, flags=re.IGNORECASE)
207
+ if urlsplit(url).scheme.lower() != "https":
208
+ raise CalendarError("calendar links must use https")
209
+ req = urllib.request.Request(url, headers={"User-Agent": "wirenote"})
210
+ with _open(req, timeout_s) as resp:
211
+ data = resp.read(MAX_BYTES + 1)
212
+ else:
213
+ with Path(source).expanduser().open("rb") as f:
214
+ data = f.read(MAX_BYTES + 1)
215
+ if len(data) > MAX_BYTES:
216
+ raise CalendarError(f"calendar too large (over {MAX_BYTES // 1_000_000} MB)")
217
+ return data
218
+
219
+
220
+ def describe_error(source: str, e: Exception) -> str:
221
+ """An error line without the link or path, which can be secret."""
222
+ detail = f": {e}" if isinstance(e, CalendarError) else ""
223
+ if isinstance(e, urllib.error.HTTPError):
224
+ detail = f": HTTP {e.code}"
225
+ return f"{source_label(source)}: {type(e).__name__}{detail}"
226
+
227
+
228
+ def load(sources: tuple[str, ...], now: dt.datetime) -> tuple[list[Event], list[str]]:
229
+ """All events from all sources, plus one error string per failed source."""
230
+ events, errors = [], []
231
+ for i, src in enumerate(sources):
232
+ try:
233
+ events += parse(fetch(src), now)
234
+ except Exception as e: # noqa: BLE001 - one bad calendar must not hide the others
235
+ errors.append(f"calendar {i + 1}: {describe_error(src, e)}")
236
+ return events, errors
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
@@ -0,0 +1,304 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Two-track recording with pw-record (docs/dev/PLAN.md › Capture, docs/dev/m2-m4-design.md › M2).
4
+
5
+ One directory per recording holds manifest.json plus one WAV per segment:
6
+ mic-000.wav, system-000.wav, ... A new segment starts when a track's child
7
+ is restarted or the mic target changes; processing stitches segments by
8
+ their start offsets.
9
+
10
+ - Mic: the Audio/Source the meeting app is linked to. With no target the
11
+ child follows the default source, like the app does.
12
+ - System: the default sink's monitor (stream.capture.sink=true), no target.
13
+ WirePlumber moves it when the default sink changes
14
+ (linking.follow-default-target, checked 2026-10-26), so a sink switch does
15
+ not need a new segment.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import ctypes
22
+ import datetime as dt
23
+ import json
24
+ import os
25
+ import signal
26
+ import time
27
+ from collections.abc import Awaitable, Callable
28
+ from dataclasses import asdict, dataclass, field
29
+ from pathlib import Path
30
+
31
+ from . import wav
32
+
33
+ MANIFEST = "manifest.json"
34
+ STOP_WAIT_S = 3.0
35
+ MAX_RESTARTS = 3
36
+
37
+
38
+ def now_iso() -> str:
39
+ return dt.datetime.now().astimezone().isoformat(timespec="seconds")
40
+
41
+
42
+ def _die_with_parent() -> None:
43
+ """Child setup: get SIGINT (clean WAV close) if the daemon dies."""
44
+ try:
45
+ ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, signal.SIGINT) # PR_SET_PDEATHSIG
46
+ except OSError:
47
+ pass
48
+
49
+
50
+ async def spawn_pw_record(argv: list[str]):
51
+ return await asyncio.create_subprocess_exec(
52
+ *argv, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
53
+ stderr=asyncio.subprocess.DEVNULL, preexec_fn=_die_with_parent)
54
+
55
+
56
+ Spawner = Callable[[list[str]], Awaitable]
57
+
58
+
59
+ @dataclass
60
+ class Segment:
61
+ track: str # "mic" or "system"
62
+ file: str
63
+ target: str | None # node.name, or None for the default
64
+ start_offset_s: float
65
+ pid: int | None = None
66
+ end_offset_s: float | None = None
67
+ end_reason: str | None = None
68
+
69
+
70
+ @dataclass
71
+ class _Track:
72
+ name: str
73
+ target: str | None
74
+ proc: object = None
75
+ segment: Segment | None = None
76
+ last_size: int = 0
77
+ last_growth: float = 0.0
78
+ stalled: bool = False
79
+ restarts: int = 0
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class TrackEvent:
84
+ track: str
85
+ kind: str # "stalled", "recovered", "restarted", "gave_up"
86
+ detail: str = ""
87
+
88
+
89
+ @dataclass
90
+ class RecordingMeta:
91
+ app: str
92
+ name: str
93
+ service: str | None = None
94
+ room: str | None = None
95
+ calendar: dict | None = None
96
+ extra: dict = field(default_factory=dict)
97
+
98
+
99
+ class Recording:
100
+ def __init__(self, root: Path, meta: RecordingMeta, node_prefix: str = "wirenote-",
101
+ heartbeat_s: float = 5.0, spawn: Spawner = spawn_pw_record,
102
+ clock: Callable[[], float] = time.monotonic, system_target: str = "") -> None:
103
+ self.started_wall = dt.datetime.now().astimezone()
104
+ self.id = f"{self.started_wall:%Y%m%d-%H%M%S}-{meta.app}"
105
+ self.dir = root / self.id
106
+ self.meta = meta
107
+ self.prefix = node_prefix
108
+ self.heartbeat_s = heartbeat_s
109
+ self.spawn = spawn
110
+ self.clock = clock
111
+ self.system_target = system_target or None
112
+ self.t0 = clock()
113
+ self.segments: list[Segment] = []
114
+ self.tracks: dict[str, _Track] = {}
115
+ self.ended: str | None = None
116
+ self.end_reason: str | None = None
117
+
118
+ # Lifecycle -------------------------------------------------------------
119
+
120
+ async def start(self, mic_target: str | None) -> None:
121
+ self.dir.mkdir(mode=0o700, parents=True, exist_ok=False)
122
+ self.tracks = {"mic": _Track("mic", mic_target),
123
+ "system": _Track("system", self.system_target)}
124
+ try:
125
+ for t in self.tracks.values():
126
+ await self._open_segment(t)
127
+ except BaseException:
128
+ await self.stop("capture failed to start")
129
+ raise
130
+ self._write_manifest()
131
+
132
+ async def stop(self, reason: str) -> Path:
133
+ for t in self.tracks.values():
134
+ await self._close_segment(t, reason)
135
+ self.ended, self.end_reason = now_iso(), reason
136
+ self._write_manifest()
137
+ return self.dir
138
+
139
+ async def retarget_mic(self, target: str | None) -> bool:
140
+ t = self.tracks.get("mic")
141
+ if t is None or t.target == target:
142
+ return False
143
+ await self._close_segment(t, f"mic moved to {target or 'default'}")
144
+ t.target = target
145
+ await self._open_segment(t)
146
+ self._write_manifest()
147
+ return True
148
+
149
+ async def heartbeat(self) -> list[TrackEvent]:
150
+ """Check each track; call about once a second (docs/dev/PLAN.md › Capture heartbeat)."""
151
+ events: list[TrackEvent] = []
152
+ now = self.clock()
153
+ for t in self.tracks.values():
154
+ if t.proc is None:
155
+ continue
156
+ rc = t.proc.returncode
157
+ if rc is not None:
158
+ await self._close_segment(t, f"pw-record exited with {rc}")
159
+ if t.restarts >= MAX_RESTARTS:
160
+ t.proc = None
161
+ events.append(TrackEvent(t.name, "gave_up", f"exited {t.restarts + 1} times"))
162
+ continue
163
+ t.restarts += 1
164
+ await self._open_segment(t)
165
+ events.append(TrackEvent(t.name, "restarted", f"pw-record exited with {rc}"))
166
+ continue
167
+ size = self._size(t)
168
+ if size > t.last_size:
169
+ t.last_size, t.last_growth = size, now
170
+ if t.stalled:
171
+ t.stalled = False
172
+ events.append(TrackEvent(t.name, "recovered"))
173
+ elif not t.stalled and now - t.last_growth >= self.heartbeat_s:
174
+ t.stalled = True
175
+ events.append(TrackEvent(t.name, "stalled",
176
+ f"no audio for {self.heartbeat_s:g} s"))
177
+ if events:
178
+ self._write_manifest()
179
+ return events
180
+
181
+ # Internals -------------------------------------------------------------
182
+
183
+ def _argv(self, t: _Track, path: Path) -> list[str]:
184
+ props = f"node.name={self.prefix}{t.name} media.name={self.prefix}{t.name}"
185
+ if t.name == "system":
186
+ props += " stream.capture.sink=true"
187
+ argv = ["pw-record", "--rate", str(wav.RATE), "--channels", "1", "--format", "s16",
188
+ "-P", "{ " + props + " }"]
189
+ if t.target:
190
+ argv += ["--target", t.target]
191
+ return argv + [str(path)]
192
+
193
+ async def _open_segment(self, t: _Track) -> None:
194
+ n = sum(1 for s in self.segments if s.track == t.name)
195
+ name = f"{t.name}-{n:03d}.wav"
196
+ seg = Segment(t.name, name, t.target, round(self.clock() - self.t0, 3))
197
+ t.proc = await self.spawn(self._argv(t, self.dir / name))
198
+ seg.pid = t.proc.pid
199
+ # The header alone is not audio.
200
+ t.segment, t.last_size, t.last_growth, t.stalled = seg, wav.HEADER, self.clock(), False
201
+ self.segments.append(seg)
202
+
203
+ async def _close_segment(self, t: _Track, reason: str) -> None:
204
+ seg, proc = t.segment, t.proc
205
+ if seg is None:
206
+ return
207
+ if proc is not None and proc.returncode is None:
208
+ try:
209
+ proc.send_signal(signal.SIGINT)
210
+ await asyncio.wait_for(proc.wait(), STOP_WAIT_S)
211
+ except ProcessLookupError:
212
+ pass
213
+ except asyncio.TimeoutError:
214
+ proc.kill()
215
+ await proc.wait()
216
+ path = self.dir / seg.file
217
+ if path.exists():
218
+ wav.repair(path)
219
+ seg.end_offset_s = round(self.clock() - self.t0, 3)
220
+ seg.end_reason = reason
221
+ t.segment = None
222
+
223
+ def _size(self, t: _Track) -> int:
224
+ try:
225
+ return (self.dir / t.segment.file).stat().st_size if t.segment else 0
226
+ except OSError:
227
+ return 0
228
+
229
+ def manifest(self) -> dict:
230
+ m = self.meta
231
+ return {"version": 1, "id": self.id, "app": m.app, "name": m.name,
232
+ "service": m.service, "room": m.room, "calendar": m.calendar, **m.extra,
233
+ "started": self.started_wall.isoformat(timespec="seconds"),
234
+ "ended": self.ended, "end_reason": self.end_reason,
235
+ "segments": [asdict(s) for s in self.segments]}
236
+
237
+ def _write_manifest(self) -> None:
238
+ write_manifest(self.dir, self.manifest())
239
+
240
+
241
+ def read_manifest(rec_dir: Path) -> dict:
242
+ return json.loads((rec_dir / MANIFEST).read_text())
243
+
244
+
245
+ def write_manifest(rec_dir: Path, data: dict) -> None:
246
+ tmp = rec_dir / (MANIFEST + ".tmp")
247
+ tmp.write_text(json.dumps(data, indent=2))
248
+ tmp.replace(rec_dir / MANIFEST)
249
+
250
+
251
+ def recover(root: Path) -> list[Path]:
252
+ """Close recordings left open by a crash: stop orphaned children, fix headers.
253
+
254
+ Returns the recordings that were recovered, so the caller can queue them.
255
+ """
256
+ out = []
257
+ if not root.is_dir():
258
+ return out
259
+ for rec_dir in sorted(p for p in root.iterdir() if (p / MANIFEST).is_file()):
260
+ try:
261
+ m = read_manifest(rec_dir)
262
+ except (OSError, ValueError):
263
+ continue
264
+ if m.get("ended"):
265
+ continue
266
+ for seg in m.get("segments", []):
267
+ path = rec_dir / seg["file"]
268
+ _stop_orphan(seg.get("pid"), path)
269
+ if path.exists():
270
+ wav.repair(path)
271
+ if seg.get("end_offset_s") is None:
272
+ seg["end_offset_s"] = round(seg["start_offset_s"] + wav.duration_s(path), 3)
273
+ seg["end_reason"] = "crash"
274
+ mtimes = [(rec_dir / s["file"]).stat().st_mtime for s in m.get("segments", [])
275
+ if (rec_dir / s["file"]).exists()]
276
+ end = dt.datetime.fromtimestamp(max(mtimes) if mtimes else time.time()).astimezone()
277
+ m["ended"], m["end_reason"] = end.isoformat(timespec="seconds"), "crash"
278
+ write_manifest(rec_dir, m)
279
+ out.append(rec_dir)
280
+ return out
281
+
282
+
283
+ def _stop_orphan(pid: int | None, path: Path) -> None:
284
+ """SIGINT a pw-record that outlived us, but only if it still writes this file."""
285
+ if not pid:
286
+ return
287
+ try:
288
+ cmdline = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
289
+ except OSError:
290
+ return
291
+ if str(path).encode() not in cmdline:
292
+ return
293
+ try:
294
+ os.kill(pid, signal.SIGINT)
295
+ except OSError:
296
+ return
297
+ for _ in range(30):
298
+ if not Path(f"/proc/{pid}").exists():
299
+ return
300
+ time.sleep(0.1)
301
+ try:
302
+ os.kill(pid, signal.SIGKILL)
303
+ except OSError:
304
+ pass
@@ -0,0 +1,58 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """WAV helpers for pw-record output: header repair and sample loading.
4
+
5
+ pw-record writes a 44-byte PCM header and fixes the sizes on SIGINT. After a
6
+ kill -9 the header still says RIFF size 8 and data size 0 while the samples
7
+ are intact (checked 2026-10-26, PipeWire 1.6.8), so repair rewrites the two
8
+ size fields from the file length.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import struct
14
+ import wave
15
+ from pathlib import Path
16
+
17
+ HEADER = 44
18
+ RATE = 16000 # docs/dev/PLAN.md › Defaults › Audio format: 16 kHz mono 16-bit
19
+
20
+
21
+ def repair(path: Path) -> bool:
22
+ """Fix the RIFF and data sizes of a truncated WAV. Returns True if changed."""
23
+ size = path.stat().st_size
24
+ if size < HEADER:
25
+ return False
26
+ with path.open("r+b") as f:
27
+ head = f.read(HEADER)
28
+ if head[:4] != b"RIFF" or head[8:12] != b"WAVE" or head[36:40] != b"data":
29
+ return False
30
+ riff, data = struct.unpack_from("<I", head, 4)[0], struct.unpack_from("<I", head, 40)[0]
31
+ want_data = (size - HEADER) // 2 * 2
32
+ if riff == want_data + 36 and data == want_data:
33
+ return False
34
+ f.seek(4)
35
+ f.write(struct.pack("<I", want_data + 36))
36
+ f.seek(40)
37
+ f.write(struct.pack("<I", want_data))
38
+ return True
39
+
40
+
41
+ def duration_s(path: Path) -> float:
42
+ try:
43
+ with wave.open(str(path)) as w:
44
+ return w.getnframes() / w.getframerate()
45
+ except (OSError, wave.Error, EOFError):
46
+ return 0.0
47
+
48
+
49
+ def load_float(path: Path):
50
+ """Samples as float32 in [-1, 1], for faster-whisper (16 kHz mono expected)."""
51
+ import numpy as np
52
+
53
+ with wave.open(str(path)) as w:
54
+ if (w.getframerate(), w.getnchannels(), w.getsampwidth()) != (RATE, 1, 2):
55
+ raise ValueError(f"{path.name}: expected 16 kHz mono s16, got "
56
+ f"{w.getframerate()} Hz, {w.getnchannels()} ch, {w.getsampwidth() * 8} bit")
57
+ raw = w.readframes(w.getnframes())
58
+ return np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0