fow-cli 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 (46) hide show
  1. fly_on_the_wall/__init__.py +3 -0
  2. fly_on_the_wall/audio.py +164 -0
  3. fly_on_the_wall/audio_metadata.py +241 -0
  4. fly_on_the_wall/cache.py +26 -0
  5. fly_on_the_wall/cleanup.py +29 -0
  6. fly_on_the_wall/cli.py +641 -0
  7. fly_on_the_wall/cli_costs.py +81 -0
  8. fly_on_the_wall/cli_menu.py +163 -0
  9. fly_on_the_wall/cli_publish.py +141 -0
  10. fly_on_the_wall/cli_speaker_review.py +315 -0
  11. fly_on_the_wall/cli_watch.py +209 -0
  12. fly_on_the_wall/config.py +92 -0
  13. fly_on_the_wall/costs.py +169 -0
  14. fly_on_the_wall/db.py +508 -0
  15. fly_on_the_wall/doctor.py +142 -0
  16. fly_on_the_wall/embeddings.py +142 -0
  17. fly_on_the_wall/exporting.py +155 -0
  18. fly_on_the_wall/glossary.py +31 -0
  19. fly_on_the_wall/meetings.py +382 -0
  20. fly_on_the_wall/normalization.py +166 -0
  21. fly_on_the_wall/people.py +82 -0
  22. fly_on_the_wall/people_embeddings.py +68 -0
  23. fly_on_the_wall/pipeline.py +120 -0
  24. fly_on_the_wall/processing.py +427 -0
  25. fly_on_the_wall/providers/__init__.py +1 -0
  26. fly_on_the_wall/providers/elevenlabs.py +145 -0
  27. fly_on_the_wall/providers/openai_analysis.py +195 -0
  28. fly_on_the_wall/providers/openai_cleanup.py +91 -0
  29. fly_on_the_wall/publishing.py +410 -0
  30. fly_on_the_wall/reanalysis.py +172 -0
  31. fly_on_the_wall/recording_quality.py +141 -0
  32. fly_on_the_wall/rendering.py +115 -0
  33. fly_on_the_wall/secrets.py +93 -0
  34. fly_on_the_wall/service_pricing.py +75 -0
  35. fly_on_the_wall/setup.py +221 -0
  36. fly_on_the_wall/speaker_identity.py +173 -0
  37. fly_on_the_wall/speaker_matching.py +134 -0
  38. fly_on_the_wall/speakers.py +221 -0
  39. fly_on_the_wall/storage.py +53 -0
  40. fly_on_the_wall/voice_samples.py +125 -0
  41. fly_on_the_wall/watch.py +347 -0
  42. fow_cli-0.1.0.dist-info/METADATA +447 -0
  43. fow_cli-0.1.0.dist-info/RECORD +46 -0
  44. fow_cli-0.1.0.dist-info/WHEEL +4 -0
  45. fow_cli-0.1.0.dist-info/entry_points.txt +2 -0
  46. fow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+ from shutil import which
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from fly_on_the_wall.db import database
12
+ from fly_on_the_wall.doctor import run_checks
13
+ from fly_on_the_wall.people import create_person, get_person, get_user_person, set_user_person
14
+ from fly_on_the_wall.people_embeddings import people_embedding_status
15
+ from fly_on_the_wall.publishing import add_publish_target, list_publish_targets
16
+ from fly_on_the_wall.secrets import SecretError, get_api_key_status, set_api_key
17
+ from fly_on_the_wall.storage import storage_paths
18
+ from fly_on_the_wall.watch import add_watch_folder, list_watch_folders
19
+
20
+
21
+ def run_setup(console: Console) -> None:
22
+ """Run the interactive first-run setup wizard."""
23
+ console.print("Fly on the Wall setup")
24
+ console.print("This command checks required setup and can configure optional features.")
25
+ console.print("")
26
+
27
+ _show_runtime_summary(console)
28
+ _setup_secrets(console)
29
+ _setup_user_identity(console)
30
+ _setup_speaker_identity(console)
31
+ _setup_publishing(console)
32
+ _setup_watch_folders(console)
33
+ _show_final_summary(console)
34
+
35
+
36
+ def _show_runtime_summary(console: Console) -> None:
37
+ checks = run_checks()
38
+ table = Table(title="Setup Checks")
39
+ table.add_column("Check")
40
+ table.add_column("Status")
41
+ table.add_column("Detail")
42
+ for check in checks:
43
+ table.add_row(check.name, "ok" if check.ok else "missing", check.detail)
44
+ console.print(table)
45
+
46
+ if which("ffmpeg") is None:
47
+ console.print("FFmpeg is required for audio processing.")
48
+ console.print("Install it with your OS package manager, then run `fow doctor`.")
49
+ console.print("")
50
+
51
+
52
+ def _setup_secrets(console: Console) -> None:
53
+ console.print("Secrets")
54
+ for provider in ["elevenlabs", "openai"]:
55
+ _setup_secret(console, provider)
56
+
57
+ console.print("")
58
+
59
+
60
+ def _setup_secret(console: Console, provider: str) -> None:
61
+ status = get_api_key_status(provider)
62
+ if status.available:
63
+ console.print(f"- {provider}: configured via {status.source}")
64
+ return
65
+ if not typer.confirm(f"Store a {provider} API key in the OS keyring?", default=False):
66
+ console.print(f"- {provider}: skipped")
67
+ return
68
+ value = typer.prompt(f"{provider} API key", hide_input=True).strip()
69
+ if not value:
70
+ console.print(f"- {provider}: skipped empty value")
71
+ return
72
+ try:
73
+ set_api_key(provider, value)
74
+ except SecretError as exc:
75
+ console.print(str(exc))
76
+ _print_secret_env_fallback(console, provider)
77
+ return
78
+ console.print(f"- {provider}: stored in OS keyring")
79
+
80
+
81
+ def _print_secret_env_fallback(console: Console, provider: str) -> None:
82
+ status = get_api_key_status(provider)
83
+ if status.env_var:
84
+ console.print(f" Alternative: set {status.env_var} in your shell environment.")
85
+
86
+
87
+ def _setup_user_identity(console: Console) -> None:
88
+ with database() as connection:
89
+ user = get_user_person(connection)
90
+ if user is not None:
91
+ console.print(f"User identity: {user.display_name}")
92
+ console.print("")
93
+ return
94
+
95
+ if not typer.confirm("Set your own person identity for speaker matching?", default=True):
96
+ console.print("User identity: skipped")
97
+ console.print("")
98
+ return
99
+
100
+ display_name = typer.prompt("Your display name").strip()
101
+ if not display_name:
102
+ console.print("User identity: skipped empty name")
103
+ console.print("")
104
+ return
105
+ person = get_person(connection, display_name) or create_person(connection, display_name)
106
+ set_user_person(connection, person.id)
107
+ console.print(f"User identity: {display_name}")
108
+ console.print("")
109
+
110
+
111
+ def _setup_speaker_identity(console: Console) -> None:
112
+ console.print("Speaker identity")
113
+ if _identity_dependencies_available():
114
+ with database() as connection:
115
+ status = people_embedding_status(connection)
116
+ console.print("- local speaker identity dependencies: available")
117
+ console.print(f"- voice sample embeddings: {status.embedded_voice_samples}/{status.voice_samples} embedded")
118
+ if status.missing_voice_sample_embeddings:
119
+ console.print("Run `fow people embeddings backfill` to embed missing voice samples.")
120
+ console.print("")
121
+ return
122
+
123
+ console.print("- local speaker identity dependencies: missing")
124
+ if typer.confirm("Do you want guidance for enabling recurring speaker identity?", default=True):
125
+ console.print("Install or upgrade the package with the identity extra, then rerun setup:")
126
+ console.print(' uv tool install "fly-on-the-wall[identity]"')
127
+ console.print(' uv tool upgrade --reinstall "fly-on-the-wall[identity]"')
128
+ console.print(' pipx install "fly-on-the-wall[identity]"')
129
+ console.print(' pipx inject fly-on-the-wall "fly-on-the-wall[identity]"')
130
+ console.print("If you installed from source, run `uv sync --extra identity`.")
131
+ console.print("")
132
+
133
+
134
+ def _identity_dependencies_available() -> bool:
135
+ return all(_module_available(module) for module in ["pyannote.audio", "torch", "torchaudio"])
136
+
137
+
138
+ def _setup_publishing(console: Console) -> None:
139
+ with database() as connection:
140
+ targets = list_publish_targets(connection)
141
+ if targets:
142
+ console.print("Publishing")
143
+ for target in targets:
144
+ console.print(f"- {target.name}: {target.path}")
145
+ console.print("")
146
+ return
147
+
148
+ if not typer.confirm("Publish notes to an Obsidian folder?", default=False):
149
+ console.print("Publishing: skipped")
150
+ console.print("")
151
+ return
152
+
153
+ path = Path(typer.prompt("Obsidian folder path")).expanduser()
154
+ name = typer.prompt("Target name", default="obsidian").strip() or "obsidian"
155
+ auto_publish = typer.confirm("Auto-publish processed/refreshed meetings?", default=True)
156
+ path.mkdir(parents=True, exist_ok=True)
157
+ target = add_publish_target(connection, "obsidian", path, name, auto_publish=auto_publish)
158
+ console.print(f"Publishing: added {target.name} -> {target.path}")
159
+ console.print("")
160
+
161
+
162
+ def _setup_watch_folders(console: Console) -> None:
163
+ with database() as connection:
164
+ folders = list_watch_folders(connection)
165
+ if folders:
166
+ _print_watch_folders(console, folders)
167
+ console.print("")
168
+ return
169
+
170
+ if not typer.confirm("Watch folders for new recordings?", default=False):
171
+ console.print("Watched folders: skipped")
172
+ console.print("")
173
+ return
174
+
175
+ while True:
176
+ _prompt_watch_folder(console, connection)
177
+ if not typer.confirm("Add another watched folder?", default=False):
178
+ break
179
+ console.print("")
180
+
181
+
182
+ def _print_watch_folders(console: Console, folders: list) -> None:
183
+ console.print("Watched folders")
184
+ for folder in folders:
185
+ state = "enabled" if folder.enabled else "disabled"
186
+ console.print(f"- {folder.path} ({state})")
187
+
188
+
189
+ def _prompt_watch_folder(console: Console, connection) -> None:
190
+ path_text = typer.prompt("Folder path to watch").strip()
191
+ if not path_text:
192
+ return
193
+ name = typer.prompt("Folder name", default="").strip() or None
194
+ folder = add_watch_folder(connection, Path(path_text), name)
195
+ console.print(f"Added watch folder: {folder.path}")
196
+
197
+
198
+ def _show_final_summary(console: Console) -> None:
199
+ paths = storage_paths()
200
+ checks = run_checks()
201
+ required_failures = [
202
+ check for check in checks if check.name in {"python", "ffmpeg", "elevenlabs api key"} and not check.ok
203
+ ]
204
+
205
+ console.print("Setup summary")
206
+ console.print(f"- App data: {paths.root}")
207
+ console.print(f"- Required setup: {'ready' if not required_failures else 'incomplete'}")
208
+ if required_failures:
209
+ for check in required_failures:
210
+ console.print(f" missing: {check.name} ({check.detail})")
211
+ console.print("Run `fow doctor` after fixing missing items.")
212
+ else:
213
+ console.print("Fly on the Wall is ready.")
214
+ console.print("Next: `fow process path/to/meeting.m4a`")
215
+
216
+
217
+ def _module_available(module_name: str) -> bool:
218
+ try:
219
+ return importlib.util.find_spec(module_name) is not None
220
+ except ModuleNotFoundError:
221
+ return False
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from sqlite3 import Connection
6
+
7
+ from fly_on_the_wall.embeddings import (
8
+ EmbeddingBackend,
9
+ PyannoteEmbeddingBackend,
10
+ cache_local_speaker_embedding,
11
+ cache_voice_sample_embedding,
12
+ )
13
+ from fly_on_the_wall.people import create_person, get_person
14
+ from fly_on_the_wall.speaker_matching import SpeakerMatch, match_local_speakers
15
+ from fly_on_the_wall.speakers import assign_speaker_to_person
16
+ from fly_on_the_wall.storage import StoragePaths, storage_paths
17
+ from fly_on_the_wall.voice_samples import VoiceSample, create_voice_sample_from_span
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class SpeakerClip:
22
+ local_speaker_id: str
23
+ meeting_id: str
24
+ source_audio_path: Path
25
+ start_time: float
26
+ end_time: float
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class VoiceIdentityResult:
31
+ local_speaker_id: str
32
+ person_id: str
33
+ person_name: str
34
+ voice_sample: VoiceSample
35
+ embedded: bool
36
+
37
+
38
+ def create_voice_identity_from_speaker(
39
+ connection: Connection,
40
+ local_speaker_id: str,
41
+ person_id_or_name: str,
42
+ create_missing_person: bool = False,
43
+ storage: StoragePaths | None = None,
44
+ backend: EmbeddingBackend | None = None,
45
+ ) -> VoiceIdentityResult:
46
+ person = get_person(connection, person_id_or_name)
47
+ if person is None and create_missing_person:
48
+ person = create_person(connection, person_id_or_name)
49
+ if person is None:
50
+ raise ValueError(f"Person not found: {person_id_or_name}")
51
+
52
+ clip = representative_speaker_clip(connection, local_speaker_id)
53
+ if clip is None:
54
+ raise ValueError(f"Local speaker has no timestamped audio: {local_speaker_id}")
55
+
56
+ paths = storage or storage_paths()
57
+ sample = create_voice_sample_from_span(
58
+ connection,
59
+ person.id,
60
+ clip.source_audio_path,
61
+ clip.meeting_id,
62
+ local_speaker_id,
63
+ clip.start_time,
64
+ clip.end_time,
65
+ paths,
66
+ )
67
+ assign_speaker_to_person(connection, local_speaker_id, person.id)
68
+
69
+ embedded = False
70
+ if backend is not None:
71
+ cache_voice_sample_embedding(connection, sample.id, backend, paths)
72
+ cache_local_speaker_embedding(connection, local_speaker_id, sample.audio_path, backend, paths)
73
+ embedded = True
74
+
75
+ return VoiceIdentityResult(
76
+ local_speaker_id=local_speaker_id,
77
+ person_id=person.id,
78
+ person_name=person.display_name,
79
+ voice_sample=sample,
80
+ embedded=embedded,
81
+ )
82
+
83
+
84
+ def prepare_speaker_review_clip(
85
+ connection: Connection,
86
+ local_speaker_id: str,
87
+ storage: StoragePaths | None = None,
88
+ ) -> Path | None:
89
+ clip = representative_speaker_clip(connection, local_speaker_id)
90
+ if clip is None:
91
+ return None
92
+
93
+ paths = storage or storage_paths()
94
+ output_path = paths.artifacts / clip.meeting_id / "review-clips" / f"{local_speaker_id}.wav"
95
+ from fly_on_the_wall.audio import extract_clip
96
+
97
+ return extract_clip(clip.source_audio_path, output_path, clip.start_time, clip.end_time)
98
+
99
+
100
+ def cache_provider_run_speaker_embeddings(
101
+ connection: Connection,
102
+ provider_run_id: str,
103
+ backend: EmbeddingBackend,
104
+ storage: StoragePaths | None = None,
105
+ ) -> int:
106
+ paths = storage or storage_paths()
107
+ rows = connection.execute(
108
+ "SELECT id FROM local_speakers WHERE provider_run_id = ? ORDER BY label",
109
+ (provider_run_id,),
110
+ ).fetchall()
111
+ count = 0
112
+ for row in rows:
113
+ review_clip = prepare_speaker_review_clip(connection, row["id"], paths)
114
+ if review_clip is None:
115
+ continue
116
+ cache_local_speaker_embedding(connection, row["id"], review_clip, backend, paths)
117
+ count += 1
118
+ return count
119
+
120
+
121
+ def match_provider_run_speakers(
122
+ connection: Connection,
123
+ provider_run_id: str,
124
+ backend: EmbeddingBackend | None = None,
125
+ storage: StoragePaths | None = None,
126
+ ) -> list[SpeakerMatch]:
127
+ if backend is None and not _has_voice_sample_embeddings(connection):
128
+ return match_local_speakers(connection, provider_run_id)
129
+
130
+ resolved_backend = backend or PyannoteEmbeddingBackend()
131
+ cache_provider_run_speaker_embeddings(connection, provider_run_id, resolved_backend, storage)
132
+ return match_local_speakers(connection, provider_run_id)
133
+
134
+
135
+ def _has_voice_sample_embeddings(connection: Connection) -> bool:
136
+ return (
137
+ connection.execute("SELECT 1 FROM voice_samples WHERE embedding_path IS NOT NULL LIMIT 1").fetchone()
138
+ is not None
139
+ )
140
+
141
+
142
+ def representative_speaker_clip(
143
+ connection: Connection,
144
+ local_speaker_id: str,
145
+ ) -> SpeakerClip | None:
146
+ row = connection.execute(
147
+ """
148
+ SELECT local_speakers.meeting_id,
149
+ meetings.imported_audio_path,
150
+ segments.start_time,
151
+ segments.end_time
152
+ FROM segments
153
+ JOIN local_speakers ON local_speakers.id = segments.local_speaker_id
154
+ JOIN meetings ON meetings.id = local_speakers.meeting_id
155
+ WHERE segments.local_speaker_id = ?
156
+ AND segments.start_time IS NOT NULL
157
+ AND segments.end_time IS NOT NULL
158
+ AND meetings.imported_audio_path IS NOT NULL
159
+ ORDER BY (segments.end_time - segments.start_time) DESC, segments.sequence
160
+ LIMIT 1
161
+ """,
162
+ (local_speaker_id,),
163
+ ).fetchone()
164
+ if row is None:
165
+ return None
166
+
167
+ return SpeakerClip(
168
+ local_speaker_id=local_speaker_id,
169
+ meeting_id=row["meeting_id"],
170
+ source_audio_path=Path(row["imported_audio_path"]),
171
+ start_time=float(row["start_time"]),
172
+ end_time=float(row["end_time"]),
173
+ )
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from sqlite3 import Connection
7
+ from uuid import uuid4
8
+
9
+ from fly_on_the_wall.config import ConfidenceThresholds
10
+ from fly_on_the_wall.embeddings import cosine_similarity, read_embedding
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SpeakerMatch:
15
+ local_speaker_id: str
16
+ person_id: str | None
17
+ status: str
18
+ confidence: float | None
19
+ margin: float | None
20
+
21
+
22
+ def match_local_speakers(
23
+ connection: Connection,
24
+ provider_run_id: str,
25
+ thresholds: ConfidenceThresholds | None = None,
26
+ ) -> list[SpeakerMatch]:
27
+ resolved_thresholds = thresholds or ConfidenceThresholds()
28
+ local_speakers = connection.execute(
29
+ "SELECT id FROM local_speakers WHERE provider_run_id = ? ORDER BY label",
30
+ (provider_run_id,),
31
+ ).fetchall()
32
+
33
+ matches: list[SpeakerMatch] = []
34
+ for local_speaker in local_speakers:
35
+ match = match_local_speaker(connection, local_speaker["id"], resolved_thresholds)
36
+ _store_assignment(connection, match)
37
+ matches.append(match)
38
+ return matches
39
+
40
+
41
+ def match_local_speaker(
42
+ connection: Connection,
43
+ local_speaker_id: str,
44
+ thresholds: ConfidenceThresholds,
45
+ ) -> SpeakerMatch:
46
+ local_embedding = connection.execute(
47
+ """
48
+ SELECT embedding_path FROM local_speaker_embeddings
49
+ WHERE local_speaker_id = ?
50
+ ORDER BY created_at DESC
51
+ LIMIT 1
52
+ """,
53
+ (local_speaker_id,),
54
+ ).fetchone()
55
+ if local_embedding is None:
56
+ return SpeakerMatch(local_speaker_id, None, "unknown", None, None)
57
+
58
+ local_vector = read_embedding(Path(local_embedding["embedding_path"]))
59
+ scores = _score_people(connection, local_vector)
60
+ if not scores:
61
+ return SpeakerMatch(local_speaker_id, None, "unknown", None, None)
62
+
63
+ best = scores[0]
64
+ second_score = scores[1]["score"] if len(scores) > 1 else 0.0
65
+ margin = best["score"] - second_score
66
+ if best["score"] >= thresholds.named:
67
+ status = "known"
68
+ person_id = best["person_id"]
69
+ elif best["score"] >= thresholds.uncertain:
70
+ status = "uncertain"
71
+ person_id = best["person_id"]
72
+ else:
73
+ status = "unknown"
74
+ person_id = None
75
+ return SpeakerMatch(local_speaker_id, person_id, status, best["score"], margin)
76
+
77
+
78
+ def _score_people(connection: Connection, local_vector: list[float]) -> list[dict[str, float | str]]:
79
+ rows = connection.execute(
80
+ """
81
+ SELECT person_id, id AS voice_sample_id, embedding_path
82
+ FROM voice_samples
83
+ WHERE embedding_path IS NOT NULL
84
+ """
85
+ ).fetchall()
86
+ best_by_person: dict[str, dict[str, float | str]] = {}
87
+ for row in rows:
88
+ score = cosine_similarity(local_vector, read_embedding(Path(row["embedding_path"])))
89
+ current = best_by_person.get(row["person_id"])
90
+ if current is None or score > current["score"]:
91
+ best_by_person[row["person_id"]] = {
92
+ "person_id": row["person_id"],
93
+ "voice_sample_id": row["voice_sample_id"],
94
+ "score": score,
95
+ }
96
+ return sorted(best_by_person.values(), key=lambda item: item["score"], reverse=True)
97
+
98
+
99
+ def _store_assignment(connection: Connection, match: SpeakerMatch) -> None:
100
+ existing = connection.execute(
101
+ "SELECT evidence_json FROM speaker_assignments WHERE local_speaker_id = ?",
102
+ (match.local_speaker_id,),
103
+ ).fetchone()
104
+ if existing is not None:
105
+ try:
106
+ evidence = json.loads(existing["evidence_json"])
107
+ except json.JSONDecodeError:
108
+ evidence = {}
109
+ if evidence.get("method") == "user_correction":
110
+ return
111
+
112
+ with connection:
113
+ connection.execute(
114
+ """
115
+ INSERT INTO speaker_assignments(
116
+ id, local_speaker_id, person_id, status, confidence, margin, evidence_json
117
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
118
+ ON CONFLICT(local_speaker_id) DO UPDATE SET
119
+ person_id = excluded.person_id,
120
+ status = excluded.status,
121
+ confidence = excluded.confidence,
122
+ margin = excluded.margin,
123
+ evidence_json = excluded.evidence_json
124
+ """,
125
+ (
126
+ str(uuid4()),
127
+ match.local_speaker_id,
128
+ match.person_id,
129
+ match.status,
130
+ match.confidence,
131
+ match.margin,
132
+ json.dumps({"method": "embedding_cosine"}),
133
+ ),
134
+ )