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,229 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""One processing job: transcribe, tell speakers apart and name them, summarize, write the note.
|
|
4
|
+
|
|
5
|
+
Runs as its own process (`wirenote process <job id>`, started by the daemon at
|
|
6
|
+
nice 10), so a slow or crashing transcription never stalls detection. A
|
|
7
|
+
summary failure still writes the note with the transcript; only a failure
|
|
8
|
+
before the note exists counts as a failed attempt.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
import traceback
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..capture.recorder import read_manifest, write_manifest
|
|
20
|
+
from ..config.settings import Settings
|
|
21
|
+
from ..config.vocabulary import load_vocabulary
|
|
22
|
+
from .jobs import Job, Jobs
|
|
23
|
+
from .merge import Line, as_text, dedupe_echo, paragraphs
|
|
24
|
+
from .note import action_line, note_path, own_note, render, segment_file, title_for
|
|
25
|
+
from .speakers import Voices, display_name, guess_names
|
|
26
|
+
from .summarize import Summary, SummaryError, make_backend, summarize
|
|
27
|
+
|
|
28
|
+
TRANSCRIPT = "transcript.json"
|
|
29
|
+
SUMMARY = "summary.json"
|
|
30
|
+
MY_NOTES = "my_notes.md"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def log(msg: str) -> None:
|
|
34
|
+
print(f"worker: {msg}", file=sys.stderr, flush=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_job(job_id: int, settings: Settings, jobs: Jobs, transcriber=None,
|
|
38
|
+
backend_factory=make_backend, voices: Voices | None = None) -> Job:
|
|
39
|
+
job = jobs.get(job_id)
|
|
40
|
+
if job is None:
|
|
41
|
+
raise SystemExit(f"no job {job_id}")
|
|
42
|
+
try:
|
|
43
|
+
_run(job, settings, jobs, transcriber, backend_factory, voices)
|
|
44
|
+
except Exception: # noqa: BLE001 - recorded on the job, retried by the daemon
|
|
45
|
+
err = traceback.format_exc()
|
|
46
|
+
log(f"job {job_id} failed:\n{err}")
|
|
47
|
+
return jobs.fail(job_id, err)
|
|
48
|
+
return jobs.get(job_id)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_my_notes(rec: Path) -> str:
|
|
52
|
+
try:
|
|
53
|
+
return (rec / MY_NOTES).read_text().strip()
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def speaker_names(speakers: dict) -> dict[str, str]:
|
|
59
|
+
return {label: display_name(label, speakers) for label in speakers}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run(job: Job, settings: Settings, jobs: Jobs, transcriber, backend_factory,
|
|
63
|
+
voices: Voices | None) -> None:
|
|
64
|
+
rec = job.recording
|
|
65
|
+
manifest = read_manifest(rec)
|
|
66
|
+
cache = rec / TRANSCRIPT
|
|
67
|
+
vocabulary = load_vocabulary()
|
|
68
|
+
backend, backend_error = None, None
|
|
69
|
+
try:
|
|
70
|
+
backend = backend_factory(settings)
|
|
71
|
+
except SummaryError as e:
|
|
72
|
+
backend_error = str(e)
|
|
73
|
+
|
|
74
|
+
if job.summary_only and cache.exists():
|
|
75
|
+
data = json.loads(cache.read_text())
|
|
76
|
+
lines = [Line(**l) for l in data["lines"]]
|
|
77
|
+
speakers = data.get("speakers", {})
|
|
78
|
+
log(f"job {job.id}: reusing {cache}")
|
|
79
|
+
else:
|
|
80
|
+
jobs.set_state(job.id, "transcribing")
|
|
81
|
+
log(f"job {job.id}: transcribing {rec.name} with {settings.whisper_model}")
|
|
82
|
+
if transcriber is None:
|
|
83
|
+
from .transcribe import Transcriber
|
|
84
|
+
transcriber = Transcriber(settings, vocabulary)
|
|
85
|
+
raw, runs, book = transcriber.recording(rec, manifest)
|
|
86
|
+
kept, dropped = dedupe_echo(raw, settings.echo_overlap, settings.echo_similarity)
|
|
87
|
+
lines = paragraphs(kept, settings.merge_gap_s)
|
|
88
|
+
speakers = _name_speakers(book, lines, manifest, settings, backend, voices)
|
|
89
|
+
cache.write_text(json.dumps({
|
|
90
|
+
"model": settings.whisper_model, "runs": runs,
|
|
91
|
+
"lines": [l.as_dict() for l in lines],
|
|
92
|
+
"speakers": speakers,
|
|
93
|
+
"dropped_echo": [l.as_dict() for l in dropped]}, indent=2, ensure_ascii=False))
|
|
94
|
+
log(f"job {job.id}: {len(lines)} paragraphs, {len(speakers)} other speaker(s), "
|
|
95
|
+
f"{len(dropped)} echo lines dropped")
|
|
96
|
+
|
|
97
|
+
jobs.set_state(job.id, "summarizing")
|
|
98
|
+
names = speaker_names(speakers)
|
|
99
|
+
my_notes = read_my_notes(rec)
|
|
100
|
+
summary, error, backend_name = None, backend_error, None
|
|
101
|
+
if backend is not None and lines:
|
|
102
|
+
backend_name = backend.name
|
|
103
|
+
log(f"job {job.id}: summarizing with {backend_name}")
|
|
104
|
+
try:
|
|
105
|
+
summary = summarize(lines, backend, settings.summary_chunk_s, settings.summary_language,
|
|
106
|
+
context=_context(manifest, vocabulary, my_notes), names=names)
|
|
107
|
+
except SummaryError as e:
|
|
108
|
+
error = str(e)
|
|
109
|
+
log(f"job {job.id}: no summary: {error}")
|
|
110
|
+
if summary:
|
|
111
|
+
(rec / SUMMARY).write_text(json.dumps(summary.as_dict(), indent=2, ensure_ascii=False))
|
|
112
|
+
|
|
113
|
+
path = _write_note(rec, manifest, lines, summary, settings, backend_name, error, names, my_notes)
|
|
114
|
+
manifest.update(note=str(path), summary_error=error, summary_backend=backend_name,
|
|
115
|
+
note_written=dt.datetime.now().astimezone().isoformat(timespec="seconds"))
|
|
116
|
+
write_manifest(rec, manifest)
|
|
117
|
+
jobs.set_state(job.id, "done", note=str(path))
|
|
118
|
+
log(f"job {job.id}: note written") # no path: it carries the title
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _name_speakers(book, lines: list[Line], manifest: dict, settings: Settings, backend,
|
|
122
|
+
voices: Voices | None) -> dict:
|
|
123
|
+
"""{label: {name, source, evidence, seconds, embedding}} for every S label."""
|
|
124
|
+
if book is None or not book.prints:
|
|
125
|
+
return {}
|
|
126
|
+
voices = voices if voices is not None else Voices()
|
|
127
|
+
speakers: dict[str, dict] = {}
|
|
128
|
+
taken: set[str] = set()
|
|
129
|
+
for label, vec in sorted(book.prints.items(), key=lambda kv: int(kv[0][1:])):
|
|
130
|
+
name, score = voices.match(vec, settings.voice_match)
|
|
131
|
+
if name in taken:
|
|
132
|
+
name = None
|
|
133
|
+
speakers[label] = {"name": name, "source": "voice" if name else None,
|
|
134
|
+
"score": round(score, 2), "evidence": "",
|
|
135
|
+
"seconds": round(book.seconds.get(label, 0.0), 1),
|
|
136
|
+
"embedding": [round(float(x), 6) for x in vec]}
|
|
137
|
+
if name:
|
|
138
|
+
taken.add(name)
|
|
139
|
+
open_labels = [l for l, s in speakers.items() if not s["name"]]
|
|
140
|
+
if backend is not None and open_labels and lines:
|
|
141
|
+
attendees = (manifest.get("calendar") or {}).get("attendees") or []
|
|
142
|
+
try:
|
|
143
|
+
guesses = guess_names(as_text(lines), open_labels, attendees, backend)
|
|
144
|
+
except Exception as e: # noqa: BLE001 - names are a bonus; keep the labels
|
|
145
|
+
log(f"speaker names not guessed: {e!r}")
|
|
146
|
+
guesses = {}
|
|
147
|
+
for label, g in guesses.items():
|
|
148
|
+
if g["name"] not in taken:
|
|
149
|
+
speakers[label].update(name=g["name"], source="transcript", evidence=g["evidence"])
|
|
150
|
+
taken.add(g["name"])
|
|
151
|
+
log("speakers: " + ", ".join(f"{l}={'named' if s['name'] else '?'}({s['source'] or '-'})"
|
|
152
|
+
for l, s in speakers.items()))
|
|
153
|
+
return speakers
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _write_note(rec: Path, manifest: dict, lines: list[Line], summary: Summary | None,
|
|
157
|
+
settings: Settings, backend_name: str | None, error: str | None,
|
|
158
|
+
names: dict[str, str], my_notes: str) -> Path:
|
|
159
|
+
title = title_for(manifest, summary)
|
|
160
|
+
started = dt.datetime.fromisoformat(manifest["started"])
|
|
161
|
+
path = note_path(settings.notes_path(), started, title)
|
|
162
|
+
old = own_note(manifest, settings.notes_path())
|
|
163
|
+
if old and old != path and old.exists():
|
|
164
|
+
old.unlink() # reprocessed with a new title
|
|
165
|
+
segs = manifest.get("segments", [])
|
|
166
|
+
duration = max((s.get("end_offset_s") or 0.0 for s in segs), default=0.0) / 60
|
|
167
|
+
audio = [str(f) for f in (segment_file(rec, s) for s in segs) if f and f.exists()]
|
|
168
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
169
|
+
tmp = path.with_suffix(".md.tmp")
|
|
170
|
+
tmp.write_text(render(manifest, lines, summary, title=title, duration_min=duration,
|
|
171
|
+
audio=audio, transcript_model=settings.whisper_model,
|
|
172
|
+
summary_backend=backend_name, summary_error=error, names=names,
|
|
173
|
+
my_notes=my_notes))
|
|
174
|
+
tmp.replace(path)
|
|
175
|
+
return path
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def rewrite_note(rec: Path, settings: Settings) -> Path | None:
|
|
179
|
+
"""Re-render the Markdown note from saved data (after renaming a speaker or editing
|
|
180
|
+
notes), without a model."""
|
|
181
|
+
manifest = read_manifest(rec)
|
|
182
|
+
try:
|
|
183
|
+
data = json.loads((rec / TRANSCRIPT).read_text())
|
|
184
|
+
except (OSError, ValueError):
|
|
185
|
+
return None
|
|
186
|
+
lines = [Line(**l) for l in data["lines"]]
|
|
187
|
+
try:
|
|
188
|
+
s = json.loads((rec / SUMMARY).read_text())
|
|
189
|
+
summary = Summary(s.get("title", ""), s.get("summary", []), s.get("decisions", []), [],
|
|
190
|
+
s.get("open_questions", []))
|
|
191
|
+
from .summarize import ActionItem
|
|
192
|
+
summary.action_items = [ActionItem(a["owner"], a["task"], a.get("due", ""))
|
|
193
|
+
for a in s.get("action_items", [])]
|
|
194
|
+
done = [bool(a.get("done")) for a in s.get("action_items", [])]
|
|
195
|
+
except (OSError, ValueError):
|
|
196
|
+
summary, done = None, []
|
|
197
|
+
names = speaker_names(data.get("speakers", {}))
|
|
198
|
+
path = _write_note(rec, manifest, lines, summary, settings, manifest.get("summary_backend"),
|
|
199
|
+
manifest.get("summary_error"), names, read_my_notes(rec))
|
|
200
|
+
if done and any(done):
|
|
201
|
+
text = path.read_text()
|
|
202
|
+
for a, d in zip(summary.action_items, done):
|
|
203
|
+
if d:
|
|
204
|
+
text = text.replace(action_line(a.owner, a.task, a.due, False),
|
|
205
|
+
action_line(a.owner, a.task, a.due, True), 1)
|
|
206
|
+
path.write_text(text)
|
|
207
|
+
manifest["note"] = str(path)
|
|
208
|
+
write_manifest(rec, manifest)
|
|
209
|
+
return path
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _context(manifest: dict, vocabulary: list[str], my_notes: str) -> str:
|
|
213
|
+
bits = [f"Call started {manifest['started']} in {manifest.get('name') or manifest.get('app')}"]
|
|
214
|
+
if manifest.get("service"):
|
|
215
|
+
room = f", room {manifest['room']}" if manifest.get("room") else ""
|
|
216
|
+
bits.append(f"Service: {manifest['service']}{room}")
|
|
217
|
+
cal = manifest.get("calendar") or {}
|
|
218
|
+
if cal.get("title"):
|
|
219
|
+
bits.append(f"Calendar event: {cal['title']}")
|
|
220
|
+
if cal.get("attendees"):
|
|
221
|
+
bits.append("Invited: " + ", ".join(cal["attendees"]))
|
|
222
|
+
if vocabulary:
|
|
223
|
+
bits.append("Correct spellings of names and terms that may be misheard: "
|
|
224
|
+
+ ", ".join(vocabulary[:80]))
|
|
225
|
+
if my_notes:
|
|
226
|
+
bits.append("Notes the recording person (Me) took during the call. They show what "
|
|
227
|
+
"mattered to them: keep their points, add the details from the transcript, "
|
|
228
|
+
"and correct them where the transcript says otherwise:\n" + my_notes[:4000])
|
|
229
|
+
return "\n".join(bits)
|