wirenote 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. wirenote/__init__.py +3 -0
  2. wirenote/calendar/__init__.py +2 -0
  3. wirenote/calendar/ics.py +236 -0
  4. wirenote/capture/__init__.py +2 -0
  5. wirenote/capture/recorder.py +304 -0
  6. wirenote/capture/wav.py +58 -0
  7. wirenote/cli.py +262 -0
  8. wirenote/config/__init__.py +2 -0
  9. wirenote/config/apps.py +158 -0
  10. wirenote/config/migrate.py +130 -0
  11. wirenote/config/perms.py +61 -0
  12. wirenote/config/secrets.py +98 -0
  13. wirenote/config/settings.py +176 -0
  14. wirenote/config/vocabulary.py +32 -0
  15. wirenote/data/wirenote-ui.service +15 -0
  16. wirenote/data/wirenote.desktop +10 -0
  17. wirenote/data/wirenote.service +15 -0
  18. wirenote/detector/__init__.py +2 -0
  19. wirenote/detector/core.py +115 -0
  20. wirenote/detector/daemon.py +743 -0
  21. wirenote/detector/graph.py +102 -0
  22. wirenote/detector/identity.py +205 -0
  23. wirenote/detector/streams.py +69 -0
  24. wirenote/detector/tracker.py +304 -0
  25. wirenote/net/__init__.py +2 -0
  26. wirenote/net/peer.py +101 -0
  27. wirenote/notify/__init__.py +2 -0
  28. wirenote/notify/clipboard.py +43 -0
  29. wirenote/notify/dbus.py +89 -0
  30. wirenote/process/__init__.py +2 -0
  31. wirenote/process/jobs.py +122 -0
  32. wirenote/process/merge.py +87 -0
  33. wirenote/process/models.py +57 -0
  34. wirenote/process/note.py +124 -0
  35. wirenote/process/speakers.py +295 -0
  36. wirenote/process/summarize.py +287 -0
  37. wirenote/process/transcribe.py +97 -0
  38. wirenote/process/worker.py +229 -0
  39. wirenote/tools/__init__.py +2 -0
  40. wirenote/tools/dump_streams.py +595 -0
  41. wirenote/tools/install.py +140 -0
  42. wirenote/tools/setup.py +338 -0
  43. wirenote/ui/__init__.py +2 -0
  44. wirenote/ui/data.py +783 -0
  45. wirenote/ui/launch.py +88 -0
  46. wirenote/ui/server.py +423 -0
  47. wirenote/ui/static/app.css +907 -0
  48. wirenote/ui/static/app.js +1267 -0
  49. wirenote/ui/static/fonts/OFL.txt +93 -0
  50. wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
  51. wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
  52. wirenote/ui/static/icon.svg +1 -0
  53. wirenote/ui/static/icons.svg +31 -0
  54. wirenote/ui/static/index.html +57 -0
  55. wirenote/ui/static/login.html +19 -0
  56. wirenote-0.1.0.dist-info/METADATA +106 -0
  57. wirenote-0.1.0.dist-info/RECORD +60 -0
  58. wirenote-0.1.0.dist-info/WHEEL +4 -0
  59. wirenote-0.1.0.dist-info/entry_points.txt +2 -0
  60. wirenote-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -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)
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav