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.
- fly_on_the_wall/__init__.py +3 -0
- fly_on_the_wall/audio.py +164 -0
- fly_on_the_wall/audio_metadata.py +241 -0
- fly_on_the_wall/cache.py +26 -0
- fly_on_the_wall/cleanup.py +29 -0
- fly_on_the_wall/cli.py +641 -0
- fly_on_the_wall/cli_costs.py +81 -0
- fly_on_the_wall/cli_menu.py +163 -0
- fly_on_the_wall/cli_publish.py +141 -0
- fly_on_the_wall/cli_speaker_review.py +315 -0
- fly_on_the_wall/cli_watch.py +209 -0
- fly_on_the_wall/config.py +92 -0
- fly_on_the_wall/costs.py +169 -0
- fly_on_the_wall/db.py +508 -0
- fly_on_the_wall/doctor.py +142 -0
- fly_on_the_wall/embeddings.py +142 -0
- fly_on_the_wall/exporting.py +155 -0
- fly_on_the_wall/glossary.py +31 -0
- fly_on_the_wall/meetings.py +382 -0
- fly_on_the_wall/normalization.py +166 -0
- fly_on_the_wall/people.py +82 -0
- fly_on_the_wall/people_embeddings.py +68 -0
- fly_on_the_wall/pipeline.py +120 -0
- fly_on_the_wall/processing.py +427 -0
- fly_on_the_wall/providers/__init__.py +1 -0
- fly_on_the_wall/providers/elevenlabs.py +145 -0
- fly_on_the_wall/providers/openai_analysis.py +195 -0
- fly_on_the_wall/providers/openai_cleanup.py +91 -0
- fly_on_the_wall/publishing.py +410 -0
- fly_on_the_wall/reanalysis.py +172 -0
- fly_on_the_wall/recording_quality.py +141 -0
- fly_on_the_wall/rendering.py +115 -0
- fly_on_the_wall/secrets.py +93 -0
- fly_on_the_wall/service_pricing.py +75 -0
- fly_on_the_wall/setup.py +221 -0
- fly_on_the_wall/speaker_identity.py +173 -0
- fly_on_the_wall/speaker_matching.py +134 -0
- fly_on_the_wall/speakers.py +221 -0
- fly_on_the_wall/storage.py +53 -0
- fly_on_the_wall/voice_samples.py +125 -0
- fly_on_the_wall/watch.py +347 -0
- fow_cli-0.1.0.dist-info/METADATA +447 -0
- fow_cli-0.1.0.dist-info/RECORD +46 -0
- fow_cli-0.1.0.dist-info/WHEEL +4 -0
- fow_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
fly_on_the_wall/setup.py
ADDED
|
@@ -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
|
+
)
|