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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""In-memory mirror of the PipeWire object graph, fed by `pw-dump --monitor`.
|
|
4
|
+
|
|
5
|
+
Output format (docs/dev/m0-findings.md §2): pretty-printed JSON arrays written
|
|
6
|
+
back to back, each ending with "]" alone at column 0. The first array is the
|
|
7
|
+
full graph; later arrays are update batches. Updates carry the whole object,
|
|
8
|
+
so they replace; a removal is {"id": N, "info": null} with no "type".
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Iterable, Iterator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BatchFramer:
|
|
18
|
+
"""Collects pw-dump output lines; feed() returns a batch once one is complete."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._buf: list[str] = []
|
|
22
|
+
|
|
23
|
+
def feed(self, line: str) -> list[dict] | None:
|
|
24
|
+
self._buf.append(line)
|
|
25
|
+
if line.rstrip("\n") != "]":
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
batch = json.loads("".join(self._buf))
|
|
29
|
+
except json.JSONDecodeError:
|
|
30
|
+
return None # a nested "]" at column 0; keep reading
|
|
31
|
+
self._buf.clear()
|
|
32
|
+
return batch
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def iter_batches(lines: Iterable[str]) -> Iterator[list[dict]]:
|
|
36
|
+
"""Yield each top-level JSON array from pw-dump output, line by line."""
|
|
37
|
+
framer = BatchFramer()
|
|
38
|
+
for line in lines:
|
|
39
|
+
batch = framer.feed(line)
|
|
40
|
+
if batch is not None:
|
|
41
|
+
yield batch
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_removal(obj: dict) -> bool:
|
|
45
|
+
return obj.get("info") is None and "type" not in obj
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Graph:
|
|
49
|
+
"""Objects keyed by id. Ids are reused by PipeWire; object.serial is not."""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self.objects: dict[int, dict] = {}
|
|
53
|
+
|
|
54
|
+
def apply(self, batch: list[dict]) -> None:
|
|
55
|
+
for obj in batch:
|
|
56
|
+
if is_removal(obj):
|
|
57
|
+
self.objects.pop(obj["id"], None)
|
|
58
|
+
else:
|
|
59
|
+
self.objects[obj["id"]] = obj
|
|
60
|
+
|
|
61
|
+
def clear(self) -> None:
|
|
62
|
+
self.objects.clear()
|
|
63
|
+
|
|
64
|
+
def props(self, oid: int | None) -> dict:
|
|
65
|
+
obj = self.objects.get(oid) if oid is not None else None
|
|
66
|
+
return ((obj or {}).get("info") or {}).get("props") or {}
|
|
67
|
+
|
|
68
|
+
def of_type(self, suffix: str) -> Iterator[dict]:
|
|
69
|
+
for obj in self.objects.values():
|
|
70
|
+
if obj.get("type", "").endswith(suffix):
|
|
71
|
+
yield obj
|
|
72
|
+
|
|
73
|
+
def nodes(self) -> Iterator[dict]:
|
|
74
|
+
return self.of_type(":Node")
|
|
75
|
+
|
|
76
|
+
def links(self) -> Iterator[dict]:
|
|
77
|
+
return self.of_type(":Link")
|
|
78
|
+
|
|
79
|
+
def default_node(self, key: str = "default.audio.source") -> str | None:
|
|
80
|
+
"""node.name from the "default" Metadata object. Metadata keeps its props
|
|
81
|
+
at the top level, not under info."""
|
|
82
|
+
for obj in self.of_type(":Metadata"):
|
|
83
|
+
if (obj.get("props") or {}).get("metadata.name") != "default":
|
|
84
|
+
continue
|
|
85
|
+
for entry in obj.get("metadata") or []:
|
|
86
|
+
if entry.get("key") == key:
|
|
87
|
+
value = entry.get("value")
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
try:
|
|
90
|
+
value = json.loads(value)
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
return value or None
|
|
93
|
+
return (value or {}).get("name") if isinstance(value, dict) else None
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def info(obj: dict | None) -> dict:
|
|
98
|
+
return (obj or {}).get("info") or {}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def props(obj: dict | None) -> dict:
|
|
102
|
+
return info(obj).get("props") or {}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""Which app owns a mic stream (docs/dev/PLAN.md › Identifying the app).
|
|
4
|
+
|
|
5
|
+
PID rule (docs/dev/m0-findings.md §3): for pulse-protocol clients
|
|
6
|
+
(client.api == "pipewire-pulse") pipewire.sec.pid is the pipewire-pulse
|
|
7
|
+
server, so the self-reported application.process.id is used; for native
|
|
8
|
+
clients pipewire.sec.pid comes from the socket and is used. The chosen PID
|
|
9
|
+
must pass a check: its exe basename or comm equals the reported
|
|
10
|
+
application.process.binary. If it fails (say a Flatpak sandbox PID that
|
|
11
|
+
names an unrelated host process), the PID-derived steps are skipped.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Protocol
|
|
20
|
+
|
|
21
|
+
from ..config.apps import AppRule, AppRules
|
|
22
|
+
|
|
23
|
+
PARENT_DEPTH = 5
|
|
24
|
+
COMM_LEN = 15 # the kernel truncates /proc/<pid>/comm to 15 characters
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProcReader(Protocol):
|
|
28
|
+
def read(self, pid: int) -> dict | None:
|
|
29
|
+
"""exe_basename, comm, cmdline0, flatpak_id, snap_name, unit, ppid; None if gone."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Evidence:
|
|
34
|
+
pid: int | None = None
|
|
35
|
+
pid_source: str | None = None
|
|
36
|
+
pid_ok: bool = False
|
|
37
|
+
flatpak_id: str | None = None
|
|
38
|
+
snap_name: str | None = None
|
|
39
|
+
unit: str | None = None
|
|
40
|
+
portal_app_id: str | None = None
|
|
41
|
+
exe: str | None = None
|
|
42
|
+
cmdline0: str | None = None
|
|
43
|
+
parents: tuple[str, ...] = ()
|
|
44
|
+
binary: str | None = None
|
|
45
|
+
app_name: str | None = None
|
|
46
|
+
|
|
47
|
+
def as_dict(self) -> dict:
|
|
48
|
+
return {k: v for k, v in self.__dict__.items() if v not in (None, ())}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Identity:
|
|
53
|
+
rule: AppRule | None
|
|
54
|
+
matched_by: str | None
|
|
55
|
+
evidence: Evidence = field(default_factory=Evidence)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def key(self) -> str | None:
|
|
59
|
+
return self.rule.key if self.rule else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def gather_evidence(client_props: dict, node_props: dict, proc: ProcReader) -> Evidence:
|
|
63
|
+
def prop(name):
|
|
64
|
+
return client_props.get(name) or node_props.get(name)
|
|
65
|
+
|
|
66
|
+
binary = prop("application.process.binary")
|
|
67
|
+
if client_props.get("client.api") == "pipewire-pulse":
|
|
68
|
+
pid_source = "application.process.id"
|
|
69
|
+
pid = _as_int(prop("application.process.id"))
|
|
70
|
+
else:
|
|
71
|
+
pid_source = "pipewire.sec.pid"
|
|
72
|
+
pid = _as_int(client_props.get("pipewire.sec.pid"))
|
|
73
|
+
if pid is None:
|
|
74
|
+
pid_source = "application.process.id"
|
|
75
|
+
pid = _as_int(prop("application.process.id"))
|
|
76
|
+
|
|
77
|
+
base = dict(
|
|
78
|
+
pid=pid,
|
|
79
|
+
pid_source=pid_source if pid is not None else None,
|
|
80
|
+
portal_app_id=client_props.get("pipewire.access.portal.app_id"),
|
|
81
|
+
binary=binary,
|
|
82
|
+
app_name=prop("application.name"),
|
|
83
|
+
)
|
|
84
|
+
p = proc.read(pid) if pid is not None else None
|
|
85
|
+
if not p:
|
|
86
|
+
return Evidence(**base)
|
|
87
|
+
|
|
88
|
+
names = {p.get("exe_basename"), p.get("comm")}
|
|
89
|
+
pid_ok = binary is None or binary in names or binary[:COMM_LEN] in names
|
|
90
|
+
if not pid_ok:
|
|
91
|
+
return Evidence(**base)
|
|
92
|
+
|
|
93
|
+
parents = []
|
|
94
|
+
ppid = p.get("ppid")
|
|
95
|
+
while ppid and ppid > 1 and len(parents) < PARENT_DEPTH:
|
|
96
|
+
pp = proc.read(ppid)
|
|
97
|
+
if not pp:
|
|
98
|
+
break
|
|
99
|
+
parents.append(pp.get("exe_basename") or pp.get("comm") or "?")
|
|
100
|
+
ppid = pp.get("ppid")
|
|
101
|
+
|
|
102
|
+
return Evidence(
|
|
103
|
+
**base,
|
|
104
|
+
pid_ok=True,
|
|
105
|
+
flatpak_id=p.get("flatpak_id"),
|
|
106
|
+
snap_name=p.get("snap_name"),
|
|
107
|
+
unit=p.get("unit"),
|
|
108
|
+
exe=p.get("exe_basename"),
|
|
109
|
+
cmdline0=p.get("cmdline0"),
|
|
110
|
+
parents=tuple(parents),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve(ev: Evidence, rules: AppRules) -> Identity:
|
|
115
|
+
"""Walk the lookup order and stop at the first exact rule match."""
|
|
116
|
+
steps: list[tuple[str, str, str | None]] = []
|
|
117
|
+
if ev.pid_ok:
|
|
118
|
+
steps += [("cgroup.flatpak", "flatpak", ev.flatpak_id),
|
|
119
|
+
("cgroup.snap", "snap", ev.snap_name)]
|
|
120
|
+
steps.append(("portal_app_id", "flatpak", ev.portal_app_id))
|
|
121
|
+
if ev.pid_ok:
|
|
122
|
+
steps += [("exe", "exe", ev.exe), ("cmdline0", "exe", ev.cmdline0)]
|
|
123
|
+
steps += [(f"parent{i + 1}", "exe", name) for i, name in enumerate(ev.parents)]
|
|
124
|
+
steps += [("binary", "binary", ev.binary), ("binary", "exe", ev.binary),
|
|
125
|
+
("app_name", "app_name", ev.app_name)]
|
|
126
|
+
for label, fld, value in steps:
|
|
127
|
+
rule = rules.lookup(fld, value)
|
|
128
|
+
if rule:
|
|
129
|
+
return Identity(rule, label, ev)
|
|
130
|
+
return Identity(None, None, ev)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class LiveProc:
|
|
134
|
+
"""ProcReader over /proc."""
|
|
135
|
+
|
|
136
|
+
def read(self, pid: int) -> dict | None:
|
|
137
|
+
base = f"/proc/{pid}"
|
|
138
|
+
try:
|
|
139
|
+
exe = os.readlink(f"{base}/exe").removesuffix(" (deleted)")
|
|
140
|
+
except OSError:
|
|
141
|
+
exe = None
|
|
142
|
+
comm = _read(f"{base}/comm")
|
|
143
|
+
if comm is None and exe is None:
|
|
144
|
+
return None
|
|
145
|
+
cmdline = _read(f"{base}/cmdline") or ""
|
|
146
|
+
argv = [a for a in cmdline.split("\0") if a]
|
|
147
|
+
stat = _read(f"{base}/stat") or ""
|
|
148
|
+
rest = stat.rsplit(")", 1)[-1].split()
|
|
149
|
+
out = {
|
|
150
|
+
"exe_basename": os.path.basename(exe) if exe else None,
|
|
151
|
+
"comm": comm.strip() if comm else None,
|
|
152
|
+
"cmdline0": cmdline0(argv),
|
|
153
|
+
"ppid": int(rest[1]) if len(rest) > 1 else None,
|
|
154
|
+
}
|
|
155
|
+
out.update(parse_cgroup(_read(f"{base}/cgroup")))
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmdline0(argv: list[str]) -> str | None:
|
|
160
|
+
if not argv:
|
|
161
|
+
return None
|
|
162
|
+
# Chrome and Electron rewrite argv into one space-joined string.
|
|
163
|
+
first = argv[0].split(" ", 1)[0] if len(argv) == 1 else argv[0]
|
|
164
|
+
return os.path.basename(first)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_cgroup(text: str | None) -> dict:
|
|
168
|
+
out = {"flatpak_id": None, "snap_name": None, "unit": None}
|
|
169
|
+
path = None
|
|
170
|
+
for line in (text or "").splitlines():
|
|
171
|
+
parts = line.split(":", 2)
|
|
172
|
+
if len(parts) == 3 and (parts[0] == "0" or parts[1] == "name=systemd"):
|
|
173
|
+
path = parts[2]
|
|
174
|
+
if parts[0] == "0":
|
|
175
|
+
break
|
|
176
|
+
if not path:
|
|
177
|
+
return out
|
|
178
|
+
segs = path.split("/")
|
|
179
|
+
units = [s for s in segs if s.endswith((".scope", ".service"))]
|
|
180
|
+
out["unit"] = units[-1] if units else None
|
|
181
|
+
for seg in reversed(segs):
|
|
182
|
+
m = re.match(r"app-flatpak-(.+)-\d+\.scope$", seg)
|
|
183
|
+
if m:
|
|
184
|
+
out["flatpak_id"] = m.group(1)
|
|
185
|
+
break
|
|
186
|
+
m = re.match(r"snap\.([^.]+)\.", seg)
|
|
187
|
+
if m:
|
|
188
|
+
out["snap_name"] = m.group(1)
|
|
189
|
+
break
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _read(path: str) -> str | None:
|
|
194
|
+
try:
|
|
195
|
+
with open(path, "rb") as f:
|
|
196
|
+
return f.read().decode("utf-8", "replace")
|
|
197
|
+
except OSError:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _as_int(v) -> int | None:
|
|
202
|
+
try:
|
|
203
|
+
return int(v)
|
|
204
|
+
except (TypeError, ValueError):
|
|
205
|
+
return None
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""Turn the graph mirror into the set of running microphone streams.
|
|
4
|
+
|
|
5
|
+
A mic stream (docs/dev/PLAN.md › What counts as a mic stream): a Stream/Input/Audio
|
|
6
|
+
node in state "running", linked from at least one Audio/Source* node, and not
|
|
7
|
+
owned by this daemon.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .graph import Graph, info, props
|
|
15
|
+
|
|
16
|
+
CAPTURE_CLASS = "Stream/Input/Audio"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class MicStream:
|
|
21
|
+
serial: int # object.serial; node ids get reused
|
|
22
|
+
node_id: int
|
|
23
|
+
client_serial: int | None # identity is cached per client
|
|
24
|
+
media_name: str | None
|
|
25
|
+
node_props: dict
|
|
26
|
+
client_props: dict
|
|
27
|
+
source_names: tuple[str, ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def mic_streams(graph: Graph, own_pids: set[int], own_prefix: str) -> list[MicStream]:
|
|
31
|
+
sources_by_node: dict[int, list[str]] = {}
|
|
32
|
+
for link in graph.links():
|
|
33
|
+
li = info(link)
|
|
34
|
+
src = graph.props(li.get("output-node-id"))
|
|
35
|
+
if src.get("media.class", "").startswith("Audio/Source"):
|
|
36
|
+
sources_by_node.setdefault(li.get("input-node-id"), []).append(
|
|
37
|
+
src.get("node.name", "?"))
|
|
38
|
+
|
|
39
|
+
out = []
|
|
40
|
+
for node in graph.nodes():
|
|
41
|
+
np = props(node)
|
|
42
|
+
if np.get("media.class") != CAPTURE_CLASS or info(node).get("state") != "running":
|
|
43
|
+
continue
|
|
44
|
+
if node["id"] not in sources_by_node:
|
|
45
|
+
continue # fed only from a sink monitor, or not linked yet
|
|
46
|
+
if str(np.get("node.name", "")).startswith(own_prefix):
|
|
47
|
+
continue
|
|
48
|
+
cp = graph.props(np.get("client.id"))
|
|
49
|
+
claimed = {cp.get("application.process.id"), np.get("application.process.id"),
|
|
50
|
+
cp.get("pipewire.sec.pid")}
|
|
51
|
+
if any(_as_int(p) in own_pids for p in claimed):
|
|
52
|
+
continue
|
|
53
|
+
out.append(MicStream(
|
|
54
|
+
serial=np.get("object.serial"),
|
|
55
|
+
node_id=node["id"],
|
|
56
|
+
client_serial=cp.get("object.serial"),
|
|
57
|
+
media_name=np.get("media.name"),
|
|
58
|
+
node_props=np,
|
|
59
|
+
client_props=cp,
|
|
60
|
+
source_names=tuple(sorted(set(sources_by_node[node["id"]]))),
|
|
61
|
+
))
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _as_int(v) -> int | None:
|
|
66
|
+
try:
|
|
67
|
+
return int(v)
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
return None
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""Per-app state machine (docs/dev/PLAN.md › State machine, Mermaid diagram).
|
|
4
|
+
|
|
5
|
+
Pure: no I/O, no clock of its own. The daemon calls update() after every
|
|
6
|
+
PipeWire batch and on the 1 s reconcile tick with the apps currently on the
|
|
7
|
+
mic; user clicks come in through record(), ignore() and stop(). Every call
|
|
8
|
+
returns a list of actions for the daemon to carry out and log.
|
|
9
|
+
|
|
10
|
+
Two refinements of the diagram, both to avoid surprising prompts:
|
|
11
|
+
- StopPending stays put while a stop guard (recording < 30 s, app runtime
|
|
12
|
+
< 20 s) blocks, and re-checks on every update, instead of bouncing back to
|
|
13
|
+
Recording each tick. It returns to Recording only when the app comes back.
|
|
14
|
+
- Manual Stop and the 4 h cap go to Suppressed, not Idle, so a call that is
|
|
15
|
+
still running does not prompt again right away.
|
|
16
|
+
Prompted also waits out the grace before withdrawing, so a mic reopen (Chrome
|
|
17
|
+
closes and reopens it at Join, docs/dev/m0-findings.md) does not flash the prompt.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from enum import StrEnum
|
|
24
|
+
|
|
25
|
+
from ..config.settings import Settings
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
MANUAL_KEY = "manual"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class State(StrEnum):
|
|
32
|
+
IDLE = "idle"
|
|
33
|
+
CANDIDATE = "candidate"
|
|
34
|
+
PROMPTED = "prompted"
|
|
35
|
+
RECORDING = "recording"
|
|
36
|
+
STOP_PENDING = "stop_pending"
|
|
37
|
+
SUPPRESSED = "suppressed"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Observation:
|
|
42
|
+
"""One app on the mic right now: all of its running mic streams."""
|
|
43
|
+
key: str
|
|
44
|
+
name: str
|
|
45
|
+
kind: str # "meeting" or "browser", after title rules
|
|
46
|
+
streams: frozenset[int] # object.serial of each running stream
|
|
47
|
+
service: str | None = None # e.g. "Jitsi", from a title rule
|
|
48
|
+
room: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Actions -------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Transition:
|
|
55
|
+
key: str
|
|
56
|
+
old: State
|
|
57
|
+
new: State
|
|
58
|
+
reason: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Note:
|
|
63
|
+
key: str
|
|
64
|
+
message: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ShowPrompt:
|
|
69
|
+
key: str
|
|
70
|
+
name: str
|
|
71
|
+
kind: str
|
|
72
|
+
service: str | None
|
|
73
|
+
room: str | None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class WithdrawPrompt:
|
|
78
|
+
key: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class StartRecording:
|
|
83
|
+
key: str
|
|
84
|
+
name: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class StopRecording:
|
|
89
|
+
key: str
|
|
90
|
+
reason: str
|
|
91
|
+
duration_s: float
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class ShowNudge:
|
|
96
|
+
key: str
|
|
97
|
+
name: str
|
|
98
|
+
dismisses: int
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
Action = Transition | Note | ShowPrompt | WithdrawPrompt | StartRecording | StopRecording | ShowNudge
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class AppTrack:
|
|
106
|
+
key: str
|
|
107
|
+
state: State = State.IDLE
|
|
108
|
+
obs: Observation | None = None
|
|
109
|
+
session_start: float | None = None # first mic use of this session
|
|
110
|
+
gone_since: float | None = None # set while the app is off the mic
|
|
111
|
+
candidate_since: float | None = None
|
|
112
|
+
recording_since: float | None = None
|
|
113
|
+
note: str | None = None # last Note, so it is emitted once
|
|
114
|
+
manual: bool = False # started with "Record a meeting": no app to leave
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Tracker:
|
|
118
|
+
def __init__(self, settings: Settings, dismisses: dict[str, int] | None = None) -> None:
|
|
119
|
+
self.s = settings
|
|
120
|
+
self.apps: dict[str, AppTrack] = {}
|
|
121
|
+
self.dismisses: dict[str, int] = dict(dismisses or {})
|
|
122
|
+
self.asleep = False
|
|
123
|
+
|
|
124
|
+
# Queries ---------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def recording_key(self) -> str | None:
|
|
128
|
+
return next((t.key for t in self.apps.values()
|
|
129
|
+
if t.state in (State.RECORDING, State.STOP_PENDING)), None)
|
|
130
|
+
|
|
131
|
+
def snapshot(self, now: float) -> dict:
|
|
132
|
+
out = {}
|
|
133
|
+
for t in self.apps.values():
|
|
134
|
+
entry = {"state": str(t.state), "name": t.obs.name if t.obs else t.key}
|
|
135
|
+
if t.session_start is not None:
|
|
136
|
+
entry["on_mic_for_s"] = round(now - t.session_start, 1)
|
|
137
|
+
if t.recording_since is not None:
|
|
138
|
+
entry["recording_for_s"] = round(now - t.recording_since, 1)
|
|
139
|
+
if t.gone_since is not None:
|
|
140
|
+
entry["gone_for_s"] = round(now - t.gone_since, 1)
|
|
141
|
+
out[t.key] = entry
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
# Inputs ----------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def update(self, now: float, observations: dict[str, Observation]) -> list[Action]:
|
|
147
|
+
if self.asleep:
|
|
148
|
+
return []
|
|
149
|
+
actions: list[Action] = []
|
|
150
|
+
for key in sorted(set(self.apps) | set(observations)):
|
|
151
|
+
t = self.apps.setdefault(key, AppTrack(key))
|
|
152
|
+
obs = observations.get(key)
|
|
153
|
+
if obs is not None:
|
|
154
|
+
t.obs = obs
|
|
155
|
+
t.gone_since = None
|
|
156
|
+
elif t.gone_since is None:
|
|
157
|
+
t.gone_since = now
|
|
158
|
+
self._step(t, now, obs is not None, actions)
|
|
159
|
+
if t.state is State.IDLE:
|
|
160
|
+
del self.apps[key]
|
|
161
|
+
return actions
|
|
162
|
+
|
|
163
|
+
def record(self, key: str, now: float) -> list[Action]:
|
|
164
|
+
t = self.apps.get(key)
|
|
165
|
+
if t is None or t.state is not State.PROMPTED:
|
|
166
|
+
return [Note(key, "record ignored: no prompt showing")]
|
|
167
|
+
if self.recording_key is not None:
|
|
168
|
+
return [Note(key, f"record refused: {self.recording_key} is recording")]
|
|
169
|
+
actions: list[Action] = []
|
|
170
|
+
t.recording_since = now
|
|
171
|
+
self._go(t, State.RECORDING, "user clicked Record", actions)
|
|
172
|
+
actions.append(StartRecording(key, t.obs.name if t.obs else key))
|
|
173
|
+
return actions
|
|
174
|
+
|
|
175
|
+
def start_manual(self, now: float, key: str = MANUAL_KEY,
|
|
176
|
+
name: str = "In-person meeting") -> list[Action]:
|
|
177
|
+
"""Record without a call app (docs/dev/features-2-design.md › 2). Only Stop,
|
|
178
|
+
the 4 h cap and sleep end it."""
|
|
179
|
+
if self.recording_key is not None:
|
|
180
|
+
return [Note(key, f"record refused: {self.recording_key} is recording")]
|
|
181
|
+
actions: list[Action] = []
|
|
182
|
+
t = self.apps[key] = AppTrack(key, obs=Observation(key, name, "meeting", frozenset()),
|
|
183
|
+
session_start=now, recording_since=now, manual=True)
|
|
184
|
+
self._go(t, State.RECORDING, "user started a recording", actions)
|
|
185
|
+
actions.append(StartRecording(key, name))
|
|
186
|
+
return actions
|
|
187
|
+
|
|
188
|
+
def ignore(self, key: str, now: float) -> list[Action]:
|
|
189
|
+
t = self.apps.get(key)
|
|
190
|
+
if t is None or t.state is not State.PROMPTED:
|
|
191
|
+
return [Note(key, "ignore ignored: no prompt showing")]
|
|
192
|
+
actions: list[Action] = []
|
|
193
|
+
self._go(t, State.SUPPRESSED, "user ignored the prompt", actions)
|
|
194
|
+
n = self.dismisses[key] = self.dismisses.get(key, 0) + 1
|
|
195
|
+
if n == self.s.nudge_after_dismisses:
|
|
196
|
+
actions.append(ShowNudge(key, t.obs.name if t.obs else key, n))
|
|
197
|
+
return actions
|
|
198
|
+
|
|
199
|
+
def stop(self, now: float, reason: str = "manual stop") -> list[Action]:
|
|
200
|
+
key = self.recording_key
|
|
201
|
+
if key is None:
|
|
202
|
+
return []
|
|
203
|
+
actions: list[Action] = []
|
|
204
|
+
self._stop(self.apps[key], now, State.SUPPRESSED, reason, actions)
|
|
205
|
+
return actions
|
|
206
|
+
|
|
207
|
+
def sleep(self, now: float) -> list[Action]:
|
|
208
|
+
actions: list[Action] = []
|
|
209
|
+
for t in list(self.apps.values()):
|
|
210
|
+
if t.state is State.PROMPTED:
|
|
211
|
+
actions.append(WithdrawPrompt(t.key))
|
|
212
|
+
if t.state in (State.RECORDING, State.STOP_PENDING):
|
|
213
|
+
self._stop(t, now, State.IDLE, "system sleep", actions)
|
|
214
|
+
elif t.state is not State.IDLE:
|
|
215
|
+
self._go(t, State.IDLE, "system sleep", actions)
|
|
216
|
+
self.apps.clear()
|
|
217
|
+
self.asleep = True
|
|
218
|
+
return actions
|
|
219
|
+
|
|
220
|
+
def resume(self, now: float, observations: dict[str, Observation]) -> list[Action]:
|
|
221
|
+
"""Apps already on the mic after wake count as seen: no prompt."""
|
|
222
|
+
self.asleep = False
|
|
223
|
+
actions: list[Action] = []
|
|
224
|
+
for key, obs in sorted(observations.items()):
|
|
225
|
+
t = self.apps.setdefault(key, AppTrack(key, obs=obs, session_start=now))
|
|
226
|
+
self._go(t, State.SUPPRESSED, "on the mic at resume", actions)
|
|
227
|
+
return actions
|
|
228
|
+
|
|
229
|
+
# Internals -------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def _step(self, t: AppTrack, now: float, present: bool, actions: list[Action]) -> None:
|
|
232
|
+
s = self.s
|
|
233
|
+
gone_for = now - t.gone_since if t.gone_since is not None else 0.0
|
|
234
|
+
|
|
235
|
+
if t.state is State.IDLE:
|
|
236
|
+
if present:
|
|
237
|
+
t.session_start = t.candidate_since = now
|
|
238
|
+
self._go(t, State.CANDIDATE, "app opened the mic", actions)
|
|
239
|
+
|
|
240
|
+
elif t.state is State.CANDIDATE:
|
|
241
|
+
if not present:
|
|
242
|
+
self._go(t, State.IDLE, "mic closed before dwell", actions)
|
|
243
|
+
return
|
|
244
|
+
other = self.recording_key
|
|
245
|
+
if other is not None:
|
|
246
|
+
self._note(t, f"prompt held: {other} is recording", actions)
|
|
247
|
+
return
|
|
248
|
+
dwell = s.dwell_meeting_s if t.obs.kind == "meeting" else s.dwell_browser_s
|
|
249
|
+
if now - t.candidate_since >= dwell:
|
|
250
|
+
self._go(t, State.PROMPTED, f"on the mic for {dwell:g} s", actions)
|
|
251
|
+
o = t.obs
|
|
252
|
+
actions.append(ShowPrompt(t.key, o.name, o.kind, o.service, o.room))
|
|
253
|
+
|
|
254
|
+
elif t.state is State.PROMPTED:
|
|
255
|
+
if not present and gone_for >= s.grace_s:
|
|
256
|
+
self._go(t, State.IDLE, "app left the mic (prompt withdrawn)", actions)
|
|
257
|
+
actions.append(WithdrawPrompt(t.key))
|
|
258
|
+
|
|
259
|
+
elif t.state is State.SUPPRESSED:
|
|
260
|
+
if not present and gone_for >= max(s.cooldown_s, s.grace_s):
|
|
261
|
+
self._go(t, State.IDLE, f"stream ended {s.cooldown_s:g} s ago", actions)
|
|
262
|
+
|
|
263
|
+
elif t.state is State.RECORDING:
|
|
264
|
+
if now - t.recording_since >= s.max_recording_s:
|
|
265
|
+
self._stop(t, now, State.SUPPRESSED, "4 h cap", actions)
|
|
266
|
+
elif t.manual:
|
|
267
|
+
pass
|
|
268
|
+
elif not present and gone_for >= s.grace_s:
|
|
269
|
+
self._go(t, State.STOP_PENDING, f"app gone > {s.grace_s:g} s", actions)
|
|
270
|
+
self._check_stop(t, now, actions)
|
|
271
|
+
|
|
272
|
+
elif t.state is State.STOP_PENDING:
|
|
273
|
+
if present:
|
|
274
|
+
self._go(t, State.RECORDING, "app back on the mic", actions)
|
|
275
|
+
elif now - t.recording_since >= s.max_recording_s:
|
|
276
|
+
self._stop(t, now, State.SUPPRESSED, "4 h cap", actions)
|
|
277
|
+
else:
|
|
278
|
+
self._check_stop(t, now, actions)
|
|
279
|
+
|
|
280
|
+
def _check_stop(self, t: AppTrack, now: float, actions: list[Action]) -> None:
|
|
281
|
+
s = self.s
|
|
282
|
+
if now - t.recording_since < s.min_recording_age_s:
|
|
283
|
+
self._note(t, f"stop held: recording younger than {s.min_recording_age_s:g} s", actions)
|
|
284
|
+
elif now - t.session_start < s.min_app_runtime_s:
|
|
285
|
+
self._note(t, f"stop held: app on the mic less than {s.min_app_runtime_s:g} s", actions)
|
|
286
|
+
else:
|
|
287
|
+
self._stop(t, now, State.IDLE, "app left the mic", actions)
|
|
288
|
+
|
|
289
|
+
def _stop(self, t: AppTrack, now: float, new: State, reason: str,
|
|
290
|
+
actions: list[Action]) -> None:
|
|
291
|
+
duration = now - t.recording_since if t.recording_since is not None else 0.0
|
|
292
|
+
self._go(t, new, reason, actions)
|
|
293
|
+
actions.append(StopRecording(t.key, reason, round(duration, 1)))
|
|
294
|
+
t.recording_since = None
|
|
295
|
+
|
|
296
|
+
def _go(self, t: AppTrack, new: State, reason: str, actions: list[Action]) -> None:
|
|
297
|
+
actions.append(Transition(t.key, t.state, new, reason))
|
|
298
|
+
t.state = new
|
|
299
|
+
t.note = None
|
|
300
|
+
|
|
301
|
+
def _note(self, t: AppTrack, message: str, actions: list[Action]) -> None:
|
|
302
|
+
if t.note != message:
|
|
303
|
+
t.note = message
|
|
304
|
+
actions.append(Note(t.key, message))
|
wirenote/net/__init__.py
ADDED