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,295 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""Who spoke when on the Others track, and who they are (docs/dev/features-2-design.md › Speakers).
|
|
4
|
+
|
|
5
|
+
- Diarization: sherpa-onnx, pyannote segmentation 3.0 plus a speaker-embedding
|
|
6
|
+
model, CPU only. Speakers are labelled S1, S2, ... in order of first speech.
|
|
7
|
+
- Whisper's word timestamps are split at speaker changes, so one line never
|
|
8
|
+
mixes two people.
|
|
9
|
+
- Names, in this order: a remembered voice (voices.json, taught by renaming a
|
|
10
|
+
speaker in the app), then the summary backend's reading of the transcript
|
|
11
|
+
("Thanks, Anna") checked against the calendar attendees, else "Speaker N".
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime as dt
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from ..config.settings import Settings, data_dir
|
|
26
|
+
from .models import EMB_REPO, SEG_FILE, SEG_REPO, hub_file # noqa: F401 - re-exported
|
|
27
|
+
|
|
28
|
+
EMBED_SECONDS = 30.0 # speech per speaker used for its voice print
|
|
29
|
+
MIN_TURN_S = 0.8 # shorter turns are too short for a reliable voice print
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Turn:
|
|
34
|
+
start: float
|
|
35
|
+
end: float
|
|
36
|
+
label: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Word:
|
|
41
|
+
start: float
|
|
42
|
+
end: float
|
|
43
|
+
text: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def label_name(label: str) -> str:
|
|
47
|
+
"""S3 -> Speaker 3; Me and legacy Others stay."""
|
|
48
|
+
m = re.fullmatch(r"S(\d+)", label)
|
|
49
|
+
return f"Speaker {m.group(1)}" if m else label
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def display_name(label: str, speakers: dict) -> str:
|
|
53
|
+
info = speakers.get(label) or {}
|
|
54
|
+
return info.get("name") or label_name(label)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _unit(v) -> np.ndarray:
|
|
58
|
+
v = np.asarray(v, dtype=np.float32)
|
|
59
|
+
n = float(np.linalg.norm(v))
|
|
60
|
+
return v / n if n else v
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Diarizer:
|
|
64
|
+
def __init__(self, settings: Settings) -> None:
|
|
65
|
+
import sherpa_onnx
|
|
66
|
+
|
|
67
|
+
seg = hub_file(SEG_REPO, SEG_FILE)
|
|
68
|
+
emb = hub_file(EMB_REPO, settings.diarize_model)
|
|
69
|
+
cfg = sherpa_onnx.OfflineSpeakerDiarizationConfig(
|
|
70
|
+
segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
|
|
71
|
+
pyannote=sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(model=seg)),
|
|
72
|
+
embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(model=emb),
|
|
73
|
+
clustering=sherpa_onnx.FastClusteringConfig(num_clusters=-1,
|
|
74
|
+
threshold=settings.diarize_threshold),
|
|
75
|
+
min_duration_on=0.3, min_duration_off=0.5)
|
|
76
|
+
if not cfg.validate():
|
|
77
|
+
raise RuntimeError("speaker separation models are incomplete")
|
|
78
|
+
self.sd = sherpa_onnx.OfflineSpeakerDiarization(cfg)
|
|
79
|
+
self.extractor = sherpa_onnx.SpeakerEmbeddingExtractor(
|
|
80
|
+
sherpa_onnx.SpeakerEmbeddingExtractorConfig(model=emb))
|
|
81
|
+
|
|
82
|
+
def run(self, audio: np.ndarray, rate: int = 16000) -> tuple[list[Turn], dict[int, np.ndarray]]:
|
|
83
|
+
"""Speaker turns of one file, plus one voice print per speaker."""
|
|
84
|
+
turns = [Turn(s.start, s.end, s.speaker) for s in self.sd.process(audio).sort_by_start_time()]
|
|
85
|
+
prints = {}
|
|
86
|
+
for label in {t.label for t in turns}:
|
|
87
|
+
mine = sorted((t for t in turns if t.label == label), key=lambda t: t.start - t.end)
|
|
88
|
+
chunks, total = [], 0.0
|
|
89
|
+
for t in mine:
|
|
90
|
+
if t.end - t.start < MIN_TURN_S and chunks:
|
|
91
|
+
continue
|
|
92
|
+
chunks.append(audio[int(t.start * rate):int(t.end * rate)])
|
|
93
|
+
total += t.end - t.start
|
|
94
|
+
if total >= EMBED_SECONDS:
|
|
95
|
+
break
|
|
96
|
+
stream = self.extractor.create_stream()
|
|
97
|
+
stream.accept_waveform(rate, np.concatenate(chunks))
|
|
98
|
+
stream.input_finished()
|
|
99
|
+
prints[label] = _unit(self.extractor.compute(stream))
|
|
100
|
+
return turns, prints
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def split_by_speaker(words: list[Word], turns: list[Turn]) -> list[tuple[int | None, list[Word]]]:
|
|
104
|
+
"""Group consecutive words by the speaker whose turn holds each word's middle
|
|
105
|
+
(the nearest turn when none does)."""
|
|
106
|
+
runs: list[tuple[int | None, list[Word]]] = []
|
|
107
|
+
for w in words:
|
|
108
|
+
mid = (w.start + w.end) / 2
|
|
109
|
+
inside = [t for t in turns if t.start <= mid <= t.end]
|
|
110
|
+
if inside:
|
|
111
|
+
label = min(inside, key=lambda t: t.end - t.start).label
|
|
112
|
+
elif turns:
|
|
113
|
+
label = min(turns, key=lambda t: min(abs(t.start - mid), abs(t.end - mid))).label
|
|
114
|
+
else:
|
|
115
|
+
label = None
|
|
116
|
+
if runs and runs[-1][0] == label:
|
|
117
|
+
runs[-1][1].append(w)
|
|
118
|
+
else:
|
|
119
|
+
runs.append((label, [w]))
|
|
120
|
+
return runs
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
SENTENCE_END = (".", "?", "!", "…", "。", "?", "!")
|
|
124
|
+
MAX_PAUSE_S = 1.0 # a longer pause ends a sentence even without punctuation
|
|
125
|
+
SPLIT_BELOW_SHARE = 0.6 # a sentence is split only if no speaker has this share of it
|
|
126
|
+
MIN_SPLIT_WORDS = 6 # shorter sentences are never split
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def sentences(words: list[Word]) -> list[list[Word]]:
|
|
130
|
+
"""Words grouped into sentences: at end punctuation or a pause. Whisper's own
|
|
131
|
+
segments are not used, because a vocabulary prompt turns them into one block."""
|
|
132
|
+
out: list[list[Word]] = []
|
|
133
|
+
for w in words:
|
|
134
|
+
if out and not out[-1][-1].text.strip().endswith(SENTENCE_END) \
|
|
135
|
+
and w.start - out[-1][-1].end <= MAX_PAUSE_S:
|
|
136
|
+
out[-1].append(w)
|
|
137
|
+
else:
|
|
138
|
+
out.append([w])
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _overlap(w: Word, t: Turn) -> float:
|
|
143
|
+
return max(0.0, min(w.end, t.end) - max(w.start, t.start))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def assign_sentence(words: list[Word], turns: list[Turn]) -> list[tuple[int | None, list[Word]]]:
|
|
147
|
+
"""A sentence goes to the speaker who covers most of it. Only a long sentence
|
|
148
|
+
that really spans two people is split; 1-word runs then rejoin a neighbour."""
|
|
149
|
+
share: dict[int, float] = {}
|
|
150
|
+
for w in words:
|
|
151
|
+
for t in turns:
|
|
152
|
+
o = _overlap(w, t)
|
|
153
|
+
if o:
|
|
154
|
+
share[t.label] = share.get(t.label, 0.0) + o
|
|
155
|
+
if not share: # between turns: the nearest speaker takes the whole sentence
|
|
156
|
+
runs = split_by_speaker(words, turns)
|
|
157
|
+
return [(runs[0][0] if runs else None, words)]
|
|
158
|
+
best = max(share, key=share.get)
|
|
159
|
+
if len(words) < MIN_SPLIT_WORDS or share[best] / sum(share.values()) >= SPLIT_BELOW_SHARE:
|
|
160
|
+
return [(best, words)]
|
|
161
|
+
runs = split_by_speaker(words, turns)
|
|
162
|
+
merged: list[tuple[int | None, list[Word]]] = []
|
|
163
|
+
for label, ws in runs:
|
|
164
|
+
if merged and (len(ws) < 2 or merged[-1][0] == label):
|
|
165
|
+
merged[-1] = (merged[-1][0], merged[-1][1] + ws)
|
|
166
|
+
else:
|
|
167
|
+
merged.append((label, list(ws)))
|
|
168
|
+
if len(merged) > 1 and len(merged[0][1]) < 2:
|
|
169
|
+
merged[1] = (merged[1][0], merged[0][1] + merged[1][1])
|
|
170
|
+
merged.pop(0)
|
|
171
|
+
return merged
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class SpeakerBook:
|
|
175
|
+
"""Speakers of one recording across its segment files, labelled S1, S2, ..."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, same_voice: float) -> None:
|
|
178
|
+
self.same_voice = same_voice
|
|
179
|
+
self.prints: dict[str, np.ndarray] = {}
|
|
180
|
+
self.seconds: dict[str, float] = {}
|
|
181
|
+
|
|
182
|
+
def label_for(self, voice: np.ndarray) -> str:
|
|
183
|
+
best, score = None, -1.0
|
|
184
|
+
for label, v in self.prints.items():
|
|
185
|
+
s = float(v @ voice)
|
|
186
|
+
if s > score:
|
|
187
|
+
best, score = label, s
|
|
188
|
+
if best is not None and score >= self.same_voice:
|
|
189
|
+
return best
|
|
190
|
+
label = f"S{len(self.prints) + 1}"
|
|
191
|
+
self.prints[label] = voice
|
|
192
|
+
return label
|
|
193
|
+
|
|
194
|
+
def add_time(self, label: str, seconds: float) -> None:
|
|
195
|
+
self.seconds[label] = self.seconds.get(label, 0.0) + seconds
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# Remembered voices ---------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def voices_path() -> Path:
|
|
201
|
+
return data_dir() / "voices.json"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Voices:
|
|
205
|
+
"""Voice prints the user named in the app. Stay on this machine."""
|
|
206
|
+
|
|
207
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
208
|
+
self.path = path or voices_path()
|
|
209
|
+
try:
|
|
210
|
+
self.data: dict = json.loads(self.path.read_text())
|
|
211
|
+
except (OSError, ValueError):
|
|
212
|
+
self.data = {}
|
|
213
|
+
|
|
214
|
+
def names(self) -> list[dict]:
|
|
215
|
+
return [{"name": n, "samples": v.get("count", 1), "updated": v.get("updated")}
|
|
216
|
+
for n, v in sorted(self.data.items())]
|
|
217
|
+
|
|
218
|
+
def match(self, voice, threshold: float) -> tuple[str | None, float]:
|
|
219
|
+
best, score = None, -1.0
|
|
220
|
+
for name, v in self.data.items():
|
|
221
|
+
s = float(_unit(v["embedding"]) @ _unit(voice))
|
|
222
|
+
if s > score:
|
|
223
|
+
best, score = name, s
|
|
224
|
+
return (best, score) if score >= threshold else (None, score)
|
|
225
|
+
|
|
226
|
+
def remember(self, name: str, voice) -> None:
|
|
227
|
+
old = self.data.get(name)
|
|
228
|
+
if old:
|
|
229
|
+
n = old.get("count", 1)
|
|
230
|
+
merged = _unit((_unit(old["embedding"]) * n + _unit(voice)) / (n + 1))
|
|
231
|
+
count = n + 1
|
|
232
|
+
else:
|
|
233
|
+
merged, count = _unit(voice), 1
|
|
234
|
+
self.data[name] = {"embedding": [round(float(x), 6) for x in merged], "count": count,
|
|
235
|
+
"updated": dt.datetime.now().astimezone().isoformat(timespec="seconds")}
|
|
236
|
+
self._save()
|
|
237
|
+
|
|
238
|
+
def forget(self, name: str) -> bool:
|
|
239
|
+
if self.data.pop(name, None) is None:
|
|
240
|
+
return False
|
|
241
|
+
self._save()
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
def _save(self) -> None:
|
|
245
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
246
|
+
tmp = self.path.with_suffix(".tmp")
|
|
247
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
248
|
+
with os.fdopen(fd, "w") as f:
|
|
249
|
+
json.dump(self.data, f)
|
|
250
|
+
tmp.replace(self.path)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Names from the transcript ---------------------------------------------------
|
|
254
|
+
|
|
255
|
+
NAME_SYSTEM = """You identify the speakers of a call transcript.
|
|
256
|
+
Lines are labelled "Me" (the person who recorded) or S1, S2, ... (other people).
|
|
257
|
+
For each S label, give the person's name only if the transcript makes it clear: \
|
|
258
|
+
they introduce themselves, someone addresses them by name right before they answer, \
|
|
259
|
+
or they are greeted by name. Otherwise give "".
|
|
260
|
+
{attendees}evidence: the short quote that shows it, or "".
|
|
261
|
+
Never give the same name to two labels. Never guess from voice or topic."""
|
|
262
|
+
|
|
263
|
+
NAME_SCHEMA = {"type": "object", "properties": {"speakers": {"type": "array", "items": {
|
|
264
|
+
"type": "object",
|
|
265
|
+
"properties": {"label": {"type": "string"}, "name": {"type": "string"},
|
|
266
|
+
"evidence": {"type": "string"}},
|
|
267
|
+
"required": ["label", "name", "evidence"], "additionalProperties": False}}},
|
|
268
|
+
"required": ["speakers"], "additionalProperties": False}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def guess_names(text: str, labels: list[str], attendees: list[str], backend) -> dict[str, dict]:
|
|
272
|
+
"""{label: {"name", "evidence"}} for labels the transcript names, checked:
|
|
273
|
+
the name must occur in the transcript (or be an invited attendee whose first
|
|
274
|
+
name occurs there)."""
|
|
275
|
+
if not labels:
|
|
276
|
+
return {}
|
|
277
|
+
who = (f"People invited to the call: {', '.join(attendees)}. Prefer these spellings.\n"
|
|
278
|
+
if attendees else "")
|
|
279
|
+
data = backend.complete_json(NAME_SYSTEM.format(attendees=who),
|
|
280
|
+
f"Labels to name: {', '.join(labels)}\n\nTranscript:\n{text}",
|
|
281
|
+
NAME_SCHEMA)
|
|
282
|
+
low = text.lower()
|
|
283
|
+
out: dict[str, dict] = {}
|
|
284
|
+
used: set[str] = set()
|
|
285
|
+
for item in data.get("speakers", []):
|
|
286
|
+
label, name = str(item.get("label", "")), str(item.get("name", "")).strip()
|
|
287
|
+
if label not in labels or not name or name.lower() in used:
|
|
288
|
+
continue
|
|
289
|
+
first = name.split()[0].lower()
|
|
290
|
+
if not re.search(rf"\b{re.escape(first)}\b", low):
|
|
291
|
+
continue # not supported by the transcript
|
|
292
|
+
full = next((a for a in attendees if a.lower().split()[0] == first), name)
|
|
293
|
+
out[label] = {"name": full, "evidence": str(item.get("evidence", ""))[:200]}
|
|
294
|
+
used.add(full.lower())
|
|
295
|
+
return out
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""Meeting summary through a pluggable backend (docs/dev/PLAN.md › Processing, step 3).
|
|
4
|
+
|
|
5
|
+
Backends: Ollama on this machine (the default, nothing leaves the laptop) or
|
|
6
|
+
the Claude API. Both return JSON for one schema: title, summary points,
|
|
7
|
+
decisions, action items with owners, open questions. Transcripts longer than
|
|
8
|
+
summary_chunk_s are summarized per chunk, then the chunk notes are combined.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ipaddress
|
|
14
|
+
import json
|
|
15
|
+
import socket
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
19
|
+
from dataclasses import asdict, dataclass, field
|
|
20
|
+
from typing import Protocol
|
|
21
|
+
|
|
22
|
+
from ..config.secrets import load_secrets, secrets_path
|
|
23
|
+
from ..config.settings import Settings
|
|
24
|
+
from ..net.peer import listener_uid, trusted_owner
|
|
25
|
+
from .merge import Line, as_text
|
|
26
|
+
|
|
27
|
+
_LIST = {"type": "array", "items": {"type": "string"}}
|
|
28
|
+
_ACTIONS = {"type": "array", "items": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {"owner": {"type": "string"}, "task": {"type": "string"},
|
|
31
|
+
"due": {"type": "string"}},
|
|
32
|
+
"required": ["owner", "task", "due"], "additionalProperties": False}}
|
|
33
|
+
_NOTES = {"summary": _LIST, "decisions": _LIST, "action_items": _ACTIONS,
|
|
34
|
+
"open_questions": _LIST}
|
|
35
|
+
|
|
36
|
+
CHUNK_SCHEMA = {"type": "object", "properties": _NOTES, "required": list(_NOTES),
|
|
37
|
+
"additionalProperties": False}
|
|
38
|
+
FINAL_SCHEMA = {"type": "object", "properties": {"title": {"type": "string"}, **_NOTES},
|
|
39
|
+
"required": ["title", *_NOTES], "additionalProperties": False}
|
|
40
|
+
|
|
41
|
+
SYSTEM = """You write meeting notes from a call transcript.
|
|
42
|
+
Speakers: "Me" is the person who recorded the call. Other people appear by name, \
|
|
43
|
+
as "Speaker N" when their name is unknown, or as "Others" when several people share \
|
|
44
|
+
one label. Use names as owners where you know them.
|
|
45
|
+
The transcript comes from speech recognition, so expect some misheard words; \
|
|
46
|
+
infer the meaning, do not quote errors.
|
|
47
|
+
Only state what the transcript supports. Do not invent names, dates or decisions.
|
|
48
|
+
Write every field, including the title, in {language}.
|
|
49
|
+
|
|
50
|
+
Fields:
|
|
51
|
+
- summary: 3 to 8 short bullet points covering the main topics and outcomes.
|
|
52
|
+
- decisions: things the participants agreed on. Empty if none.
|
|
53
|
+
- action_items: every task someone said they or another person will do, \
|
|
54
|
+
including tasks "Me" took on. One entry per task. owner is "Me", the name \
|
|
55
|
+
mentioned for it, or "Others" when unclear. due is the deadline said for that \
|
|
56
|
+
task, or "" if none was said for it; do not reuse another task's date.
|
|
57
|
+
- open_questions: questions raised but not resolved. Empty if none."""
|
|
58
|
+
|
|
59
|
+
TITLE_FIELD = "\n- title: a short title for the meeting, at most 8 words."
|
|
60
|
+
|
|
61
|
+
COMBINE = """These are notes for consecutive parts of one call, in order. Merge them \
|
|
62
|
+
into notes for the whole call: remove repeats, keep every distinct decision, \
|
|
63
|
+
action item and open question, and keep the summary to at most 8 points."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SummaryError(Exception):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class ActionItem:
|
|
72
|
+
owner: str
|
|
73
|
+
task: str
|
|
74
|
+
due: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Summary:
|
|
79
|
+
title: str = ""
|
|
80
|
+
summary: list[str] = field(default_factory=list)
|
|
81
|
+
decisions: list[str] = field(default_factory=list)
|
|
82
|
+
action_items: list[ActionItem] = field(default_factory=list)
|
|
83
|
+
open_questions: list[str] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
def as_dict(self) -> dict:
|
|
86
|
+
return asdict(self)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Backend(Protocol):
|
|
90
|
+
name: str
|
|
91
|
+
|
|
92
|
+
def complete_json(self, system: str, user: str, schema: dict) -> dict: ...
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Backends --------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def pin_local(url: str) -> tuple[str, str, int] | None:
|
|
98
|
+
"""For a URL on this machine: (the URL with its host replaced by the numeric
|
|
99
|
+
loopback address, that address, the port); None for another machine.
|
|
100
|
+
|
|
101
|
+
The owner check and the request must use one address: "localhost" may
|
|
102
|
+
resolve to ::1, where someone else can listen while Ollama has 127.0.0.1."""
|
|
103
|
+
u = urlsplit(url)
|
|
104
|
+
host = u.hostname or ""
|
|
105
|
+
port = u.port or (443 if u.scheme == "https" else 80)
|
|
106
|
+
if host == "localhost":
|
|
107
|
+
ip = "127.0.0.1"
|
|
108
|
+
else:
|
|
109
|
+
try:
|
|
110
|
+
ip = str(ipaddress.ip_address(host))
|
|
111
|
+
except ValueError:
|
|
112
|
+
try:
|
|
113
|
+
ip = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)[0][4][0]
|
|
114
|
+
except (OSError, IndexError):
|
|
115
|
+
return None
|
|
116
|
+
if not ipaddress.ip_address(ip).is_loopback:
|
|
117
|
+
return None
|
|
118
|
+
netloc = f"[{ip}]:{port}" if ":" in ip else f"{ip}:{port}"
|
|
119
|
+
return urlunsplit((u.scheme, netloc, u.path, u.query, u.fragment)).rstrip("/"), ip, port
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class OllamaBackend:
|
|
123
|
+
"""Ollama's /api/chat with a JSON schema as the format."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, url: str, model: str, timeout_s: float = 900.0) -> None:
|
|
126
|
+
self.url = url.rstrip("/")
|
|
127
|
+
self.local = pin_local(self.url)
|
|
128
|
+
self.model = model
|
|
129
|
+
self.timeout_s = timeout_s
|
|
130
|
+
self.name = f"ollama:{model}"
|
|
131
|
+
|
|
132
|
+
def complete_json(self, system: str, user: str, schema: dict) -> dict:
|
|
133
|
+
# Other accounts can listen on loopback too: never hand a transcript to
|
|
134
|
+
# their server. Nothing listening is fine; the request fails below.
|
|
135
|
+
url = self.url
|
|
136
|
+
if self.local:
|
|
137
|
+
url, ip, port = self.local
|
|
138
|
+
owner = listener_uid(port, ip)
|
|
139
|
+
if owner is not None and not trusted_owner(owner):
|
|
140
|
+
raise SummaryError(f"Ollama on port {port} belongs to another user; "
|
|
141
|
+
"not sending the transcript")
|
|
142
|
+
body = {"model": self.model, "stream": False, "format": schema, "think": False,
|
|
143
|
+
"messages": [{"role": "system", "content": system},
|
|
144
|
+
{"role": "user", "content": user}],
|
|
145
|
+
# 8k tokens holds a 15 min chunk; the model unloads soon after, so
|
|
146
|
+
# its RAM is free again for the next call or transcription.
|
|
147
|
+
"keep_alive": "60s", "options": {"num_ctx": 8192, "temperature": 0.2}}
|
|
148
|
+
req = urllib.request.Request(f"{url}/api/chat", data=json.dumps(body).encode(),
|
|
149
|
+
headers={"Content-Type": "application/json"})
|
|
150
|
+
try:
|
|
151
|
+
with urllib.request.urlopen(req, timeout=self.timeout_s) as resp:
|
|
152
|
+
data = json.loads(resp.read())
|
|
153
|
+
except urllib.error.HTTPError as e:
|
|
154
|
+
detail = e.read().decode("utf-8", "replace")[:300]
|
|
155
|
+
raise SummaryError(f"Ollama returned {e.code}: {detail}") from e
|
|
156
|
+
except (urllib.error.URLError, OSError) as e:
|
|
157
|
+
raise SummaryError(f"Ollama not reachable at {self.url} ({e}); is it installed "
|
|
158
|
+
f"and running, and is the model pulled "
|
|
159
|
+
f"(ollama pull {self.model})?") from e
|
|
160
|
+
return _parse_json(data.get("message", {}).get("content", ""))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class AnthropicBackend:
|
|
164
|
+
"""Claude API with structured output. Transcripts leave this machine."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, model: str, api_key: str | None = None) -> None:
|
|
167
|
+
import anthropic
|
|
168
|
+
|
|
169
|
+
# No key in secrets.toml: the SDK falls back to ANTHROPIC_API_KEY or an
|
|
170
|
+
# `ant auth login` profile.
|
|
171
|
+
self.client = anthropic.Anthropic(api_key=api_key) if api_key else anthropic.Anthropic()
|
|
172
|
+
self.model = model
|
|
173
|
+
self.name = f"anthropic:{model}"
|
|
174
|
+
|
|
175
|
+
def complete_json(self, system: str, user: str, schema: dict) -> dict:
|
|
176
|
+
import anthropic
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
resp = self.client.beta.messages.create(
|
|
180
|
+
model=self.model,
|
|
181
|
+
max_tokens=16000,
|
|
182
|
+
system=system,
|
|
183
|
+
messages=[{"role": "user", "content": user}],
|
|
184
|
+
output_config={"effort": "medium",
|
|
185
|
+
"format": {"type": "json_schema", "schema": schema}},
|
|
186
|
+
# On a policy decline, re-run on Anthropic's recommended fallback model.
|
|
187
|
+
betas=["server-side-fallback-2026-07-01"],
|
|
188
|
+
fallbacks="default",
|
|
189
|
+
)
|
|
190
|
+
except anthropic.AuthenticationError as e:
|
|
191
|
+
raise SummaryError("Claude API: no valid API key (run `wirenote setup claude`, "
|
|
192
|
+
f"or set anthropic_api_key in {secrets_path()})") from e
|
|
193
|
+
except anthropic.RateLimitError as e:
|
|
194
|
+
raise SummaryError("Claude API: rate limited, retry later") from e
|
|
195
|
+
except anthropic.APIStatusError as e:
|
|
196
|
+
raise SummaryError(f"Claude API error {e.status_code}: {e.message}") from e
|
|
197
|
+
except anthropic.APIConnectionError as e:
|
|
198
|
+
raise SummaryError(f"Claude API not reachable: {e}") from e
|
|
199
|
+
if resp.stop_reason == "refusal":
|
|
200
|
+
raise SummaryError("Claude API declined to summarize this transcript")
|
|
201
|
+
if resp.stop_reason == "max_tokens":
|
|
202
|
+
raise SummaryError("Claude API output was cut off (max_tokens)")
|
|
203
|
+
text = next((b.text for b in resp.content if b.type == "text"), "")
|
|
204
|
+
return _parse_json(text)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def make_backend(settings: Settings) -> Backend | None:
|
|
208
|
+
if settings.summary_backend == "none":
|
|
209
|
+
return None
|
|
210
|
+
if settings.summary_backend == "ollama":
|
|
211
|
+
return OllamaBackend(settings.ollama_url, settings.ollama_model)
|
|
212
|
+
if settings.summary_backend == "anthropic":
|
|
213
|
+
return AnthropicBackend(settings.anthropic_model, load_secrets().get("anthropic_api_key"))
|
|
214
|
+
raise SummaryError(f"unknown summary_backend {settings.summary_backend!r} "
|
|
215
|
+
"(ollama, anthropic or none)")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# Summarizing -----------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def chunks(lines: list[Line], chunk_s: float) -> list[list[Line]]:
|
|
221
|
+
"""Split at paragraph boundaries into windows of about chunk_s seconds."""
|
|
222
|
+
out: list[list[Line]] = []
|
|
223
|
+
for line in lines:
|
|
224
|
+
if not out or line.start - out[-1][0].start >= chunk_s:
|
|
225
|
+
out.append([])
|
|
226
|
+
out[-1].append(line)
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def summarize(lines: list[Line], backend: Backend, chunk_s: float, language: str,
|
|
231
|
+
context: str = "", names: dict[str, str] | None = None) -> Summary:
|
|
232
|
+
if not lines:
|
|
233
|
+
raise SummaryError("empty transcript")
|
|
234
|
+
lang = "the same language as the transcript" if language == "auto" else language
|
|
235
|
+
system = SYSTEM.format(language=lang)
|
|
236
|
+
head = f"{context}\n\n" if context else ""
|
|
237
|
+
parts = chunks(lines, chunk_s)
|
|
238
|
+
if len(parts) == 1:
|
|
239
|
+
data = _ask(backend, system + TITLE_FIELD,
|
|
240
|
+
f"{head}Transcript:\n{as_text(lines, names)}", FINAL_SCHEMA)
|
|
241
|
+
return _to_summary(data)
|
|
242
|
+
notes = []
|
|
243
|
+
for i, part in enumerate(parts, 1):
|
|
244
|
+
user = f"{head}Transcript, part {i} of {len(parts)}:\n{as_text(part, names)}"
|
|
245
|
+
notes.append(_ask(backend, system, user, CHUNK_SCHEMA))
|
|
246
|
+
user = f"{head}{COMBINE}\n\n" + "\n\n".join(
|
|
247
|
+
f"Part {i}:\n{json.dumps(n, ensure_ascii=False)}" for i, n in enumerate(notes, 1))
|
|
248
|
+
return _to_summary(_ask(backend, system + TITLE_FIELD, user, FINAL_SCHEMA))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _ask(backend: Backend, system: str, user: str, schema: dict) -> dict:
|
|
252
|
+
"""One call, retried once if the JSON does not match the schema."""
|
|
253
|
+
last = None
|
|
254
|
+
for _ in range(2):
|
|
255
|
+
try:
|
|
256
|
+
data = backend.complete_json(system, user, schema)
|
|
257
|
+
_check(data, schema)
|
|
258
|
+
return data
|
|
259
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
260
|
+
last = e
|
|
261
|
+
raise SummaryError(f"{backend.name} returned unusable JSON twice: {last}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _parse_json(text: str) -> dict:
|
|
265
|
+
data = json.loads(text)
|
|
266
|
+
if not isinstance(data, dict):
|
|
267
|
+
raise ValueError("expected a JSON object")
|
|
268
|
+
return data
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _check(data: dict, schema: dict) -> None:
|
|
272
|
+
for key in schema["required"]:
|
|
273
|
+
want = schema["properties"][key]["type"]
|
|
274
|
+
value = data[key]
|
|
275
|
+
if want == "string" and not isinstance(value, str):
|
|
276
|
+
raise TypeError(f"{key}: expected a string")
|
|
277
|
+
if want == "array" and not isinstance(value, list):
|
|
278
|
+
raise TypeError(f"{key}: expected a list")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _to_summary(data: dict) -> Summary:
|
|
282
|
+
strs = lambda xs: [str(x).strip() for x in xs if str(x).strip()] # noqa: E731
|
|
283
|
+
items = [ActionItem(str(a.get("owner") or "Unassigned").strip(), str(a.get("task", "")).strip(),
|
|
284
|
+
str(a.get("due") or "").strip())
|
|
285
|
+
for a in data.get("action_items", []) if isinstance(a, dict) and a.get("task")]
|
|
286
|
+
return Summary(str(data.get("title", "")).strip(), strs(data.get("summary", [])),
|
|
287
|
+
strs(data.get("decisions", [])), items, strs(data.get("open_questions", [])))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""Transcribe each recorded segment with faster-whisper (docs/dev/PLAN.md › Processing, step 1).
|
|
4
|
+
|
|
5
|
+
CPU, int8; voice-activity filter on, word timestamps on, language auto-detect
|
|
6
|
+
unless configured. Samples are decoded here (16 kHz mono s16 from pw-record)
|
|
7
|
+
because faster-whisper's own decoder breaks with the installed PyAV.
|
|
8
|
+
|
|
9
|
+
The vocabulary (names, product terms) goes in as hotwords, which faster-whisper
|
|
10
|
+
puts into every window's prompt. On the system track, speakers are separated
|
|
11
|
+
(process/speakers.py) and lines are split at speaker changes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..capture import wav
|
|
20
|
+
from ..config.settings import Settings
|
|
21
|
+
from .merge import Line
|
|
22
|
+
from .speakers import Diarizer, SpeakerBook, Word, assign_sentence, sentences
|
|
23
|
+
|
|
24
|
+
MIN_SEGMENT_S = 0.5
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def log(msg: str) -> None:
|
|
28
|
+
print(f"worker: {msg}", file=sys.stderr, flush=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Transcriber:
|
|
32
|
+
def __init__(self, settings: Settings, vocabulary: list[str] | None = None) -> None:
|
|
33
|
+
from faster_whisper import WhisperModel
|
|
34
|
+
|
|
35
|
+
self.settings = settings
|
|
36
|
+
from .models import whisper_path
|
|
37
|
+
|
|
38
|
+
self.model = WhisperModel(whisper_path(settings.whisper_model), device="cpu",
|
|
39
|
+
compute_type="int8")
|
|
40
|
+
# Each term as its own sentence: Whisper copies the prompt's style, and a bare
|
|
41
|
+
# comma list made it drop sentence punctuation (docs/dev/features-2-design.md).
|
|
42
|
+
self.hotwords = " ".join(f"{t.rstrip('.')}." for t in vocabulary or [])[:600] or None
|
|
43
|
+
self.diarizer = None
|
|
44
|
+
if settings.diarize:
|
|
45
|
+
try:
|
|
46
|
+
self.diarizer = Diarizer(settings)
|
|
47
|
+
except Exception as e: # noqa: BLE001 - fall back to a single "Others"
|
|
48
|
+
log(f"speaker separation unavailable, using 'Others': {e!r}")
|
|
49
|
+
|
|
50
|
+
def segment(self, path: Path, offset_s: float, track: str,
|
|
51
|
+
book: SpeakerBook) -> tuple[list[Line], dict]:
|
|
52
|
+
"""Lines of one WAV, shifted by its offset in the recording, plus run info."""
|
|
53
|
+
if wav.duration_s(path) < MIN_SEGMENT_S:
|
|
54
|
+
return [], {"file": path.name, "skipped": "too short"}
|
|
55
|
+
audio = wav.load_float(path)
|
|
56
|
+
lang = None if self.settings.whisper_language == "auto" else self.settings.whisper_language
|
|
57
|
+
segments, info = self.model.transcribe(audio, language=lang, vad_filter=True,
|
|
58
|
+
word_timestamps=True, hotwords=self.hotwords)
|
|
59
|
+
segments = [s for s in segments if s.text.strip()]
|
|
60
|
+
run = {"file": path.name, "language": info.language,
|
|
61
|
+
"language_probability": round(info.language_probability, 2),
|
|
62
|
+
"audio_s": round(info.duration, 1), "speech_s": round(info.duration_after_vad, 1)}
|
|
63
|
+
|
|
64
|
+
words = [Word(w.start, w.end, w.word) for s in segments for w in (s.words or [])]
|
|
65
|
+
text = lambda ws: "".join(w.text for w in ws).strip() # noqa: E731
|
|
66
|
+
line = lambda who, ws: Line(who, round(offset_s + ws[0].start, 2), # noqa: E731
|
|
67
|
+
round(offset_s + ws[-1].end, 2), text(ws))
|
|
68
|
+
|
|
69
|
+
if track == "mic":
|
|
70
|
+
return [line("Me", ws) for ws in sentences(words) if text(ws)], run
|
|
71
|
+
if self.diarizer is None:
|
|
72
|
+
return [line("Others", ws) for ws in sentences(words) if text(ws)], run
|
|
73
|
+
|
|
74
|
+
turns, prints = self.diarizer.run(audio)
|
|
75
|
+
labels = {local: book.label_for(v) for local, v in prints.items()}
|
|
76
|
+
for t in turns:
|
|
77
|
+
book.add_time(labels[t.label], t.end - t.start)
|
|
78
|
+
lines = []
|
|
79
|
+
for sent in sentences(words):
|
|
80
|
+
for local, ws in assign_sentence(sent, turns):
|
|
81
|
+
if text(ws):
|
|
82
|
+
lines.append(line(labels.get(local, "Others"), ws))
|
|
83
|
+
run["speakers"] = len(prints)
|
|
84
|
+
return lines, run
|
|
85
|
+
|
|
86
|
+
def recording(self, rec_dir: Path, manifest: dict) -> tuple[list[Line], list[dict], SpeakerBook]:
|
|
87
|
+
book = SpeakerBook(self.settings.voice_match)
|
|
88
|
+
lines, runs = [], []
|
|
89
|
+
for seg in manifest.get("segments", []):
|
|
90
|
+
path = rec_dir / seg["file"]
|
|
91
|
+
if not path.exists():
|
|
92
|
+
runs.append({"file": seg["file"], "skipped": "missing (audio deleted?)"})
|
|
93
|
+
continue
|
|
94
|
+
got, run = self.segment(path, seg["start_offset_s"], seg["track"], book)
|
|
95
|
+
lines += got
|
|
96
|
+
runs.append(run)
|
|
97
|
+
return lines, runs, book
|