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.
- wirenote/__init__.py +3 -0
- wirenote/calendar/__init__.py +2 -0
- wirenote/calendar/ics.py +236 -0
- wirenote/capture/__init__.py +2 -0
- wirenote/capture/recorder.py +304 -0
- wirenote/capture/wav.py +58 -0
- wirenote/cli.py +262 -0
- wirenote/config/__init__.py +2 -0
- wirenote/config/apps.py +158 -0
- wirenote/config/migrate.py +130 -0
- wirenote/config/perms.py +61 -0
- wirenote/config/secrets.py +98 -0
- wirenote/config/settings.py +176 -0
- wirenote/config/vocabulary.py +32 -0
- wirenote/data/wirenote-ui.service +15 -0
- wirenote/data/wirenote.desktop +10 -0
- wirenote/data/wirenote.service +15 -0
- wirenote/detector/__init__.py +2 -0
- wirenote/detector/core.py +115 -0
- wirenote/detector/daemon.py +743 -0
- wirenote/detector/graph.py +102 -0
- wirenote/detector/identity.py +205 -0
- wirenote/detector/streams.py +69 -0
- wirenote/detector/tracker.py +304 -0
- wirenote/net/__init__.py +2 -0
- wirenote/net/peer.py +101 -0
- wirenote/notify/__init__.py +2 -0
- wirenote/notify/clipboard.py +43 -0
- wirenote/notify/dbus.py +89 -0
- wirenote/process/__init__.py +2 -0
- wirenote/process/jobs.py +122 -0
- wirenote/process/merge.py +87 -0
- wirenote/process/models.py +57 -0
- wirenote/process/note.py +124 -0
- wirenote/process/speakers.py +295 -0
- wirenote/process/summarize.py +287 -0
- wirenote/process/transcribe.py +97 -0
- wirenote/process/worker.py +229 -0
- wirenote/tools/__init__.py +2 -0
- wirenote/tools/dump_streams.py +595 -0
- wirenote/tools/install.py +140 -0
- wirenote/tools/setup.py +338 -0
- wirenote/ui/__init__.py +2 -0
- wirenote/ui/data.py +783 -0
- wirenote/ui/launch.py +88 -0
- wirenote/ui/server.py +423 -0
- wirenote/ui/static/app.css +907 -0
- wirenote/ui/static/app.js +1267 -0
- wirenote/ui/static/fonts/OFL.txt +93 -0
- wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
- wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
- wirenote/ui/static/icon.svg +1 -0
- wirenote/ui/static/icons.svg +31 -0
- wirenote/ui/static/index.html +57 -0
- wirenote/ui/static/login.html +19 -0
- wirenote-0.1.0.dist-info/METADATA +106 -0
- wirenote-0.1.0.dist-info/RECORD +60 -0
- wirenote-0.1.0.dist-info/WHEEL +4 -0
- wirenote-0.1.0.dist-info/entry_points.txt +2 -0
- wirenote-0.1.0.dist-info/licenses/LICENSE +661 -0
wirenote/__init__.py
ADDED
wirenote/calendar/ics.py
ADDED
|
@@ -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,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
|
wirenote/capture/wav.py
ADDED
|
@@ -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
|