diff --git a/solstone/apps/speakers/audio.py b/solstone/apps/speakers/audio.py new file mode 100644 index 000000000..4afaf0704 --- /dev/null +++ b/solstone/apps/speakers/audio.py @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Audio file resolution helpers for the speakers app.""" + +from __future__ import annotations + +from pathlib import Path + +from solstone.think.media import FORMATS +from solstone.think.utils import segment_path + +__all__ = ["audio_serve_url", "resolve_audio_file", "resolve_audio_url"] + +_ORDERED_AUDIO_EXTENSIONS: tuple[str, ...] = tuple( + ext for ext, _mime, kind in FORMATS if kind == "audio" +) + + +def resolve_audio_file(segment_dir: Path, source: str) -> Path | None: + """Return the registered audio file for source in segment_dir, if present. + + Only checks {source}. files directly inside + segment_dir, in solstone.think.media.FORMATS order. + """ + for suffix in _ORDERED_AUDIO_EXTENSIONS: + path = segment_dir / f"{source}{suffix}" + if path.is_file(): + return path + return None + + +def audio_serve_url(day: str, stream: str, segment_key: str, filename: str) -> str: + """Return the speakers audio-serving URL for a segment audio filename.""" + return f"/app/speakers/api/serve_audio/{day}/{stream}/{segment_key}/{filename}" + + +def resolve_audio_url( + day: str, + stream: str, + segment_key: str, + source: str, +) -> str | None: + """Resolve the speakers audio-serving URL for a segment source, if present.""" + segment_dir = segment_path(day, segment_key, stream, create=False) + audio_path = resolve_audio_file(segment_dir, source) + if audio_path is None: + return None + return audio_serve_url(day, stream, segment_key, audio_path.name) diff --git a/solstone/apps/speakers/discovery.py b/solstone/apps/speakers/discovery.py index 7451f071c..e409504f7 100644 --- a/solstone/apps/speakers/discovery.py +++ b/solstone/apps/speakers/discovery.py @@ -12,6 +12,7 @@ from datetime import datetime from pathlib import Path from typing import Any +from solstone.apps.speakers.audio import resolve_audio_url from solstone.think.journal_io import atomic_replace from solstone.think.utils import day_dirs, day_path, get_journal, now_ms, segment_path @@ -49,11 +50,6 @@ def _owner_helpers(): return load_owner_centroid -def _audio_url(day: str, stream: str, segment_key: str, source: str) -> str: - """Build the existing speakers audio-serving URL for a sample.""" - return f"/app/speakers/api/serve_audio/{day}/{stream}/{segment_key}/{source}.flac" - - def _discovery_cache_path() -> Path: """Return the temporary cache path for discovery cluster assignments.""" awareness_dir = Path(get_journal()) / "awareness" @@ -255,7 +251,7 @@ def discover_unknown_speakers() -> dict[str, Any]: samples.append( { **record, - "audio_url": _audio_url( + "audio_url": resolve_audio_url( record["day"], record["stream"], record["segment_key"], @@ -280,7 +276,7 @@ def discover_unknown_speakers() -> dict[str, Any]: ) sample = { **record, - "audio_url": _audio_url( + "audio_url": resolve_audio_url( record["day"], record["stream"], record["segment_key"], diff --git a/solstone/apps/speakers/owner.py b/solstone/apps/speakers/owner.py index 2ce2b28e5..8686c84e6 100644 --- a/solstone/apps/speakers/owner.py +++ b/solstone/apps/speakers/owner.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any from solstone.apps.speakers._overlap import _read_segment_overlap_fraction +from solstone.apps.speakers.audio import resolve_audio_url from solstone.apps.speakers.encoder_config import ( NOISY_FLYWHEEL_OVERLAP_MAX, OWNER_BOOTSTRAP_MIN_INTRA_COSINE_P25, @@ -215,11 +216,6 @@ def _iso_now() -> str: return datetime.now(UTC).isoformat().replace("+00:00", "Z") -def _audio_url(day: str, stream: str, segment_key: str, source: str) -> str: - """Build the existing speakers audio-serving URL for a sample.""" - return f"/app/speakers/api/serve_audio/{day}/{stream}/{segment_key}/{source}.flac" - - def _fallback_statement_durations(jsonl_path: Path) -> dict[int, float | None]: """Estimate statement durations from adjacent transcript start times.""" if not jsonl_path.exists(): @@ -967,7 +963,7 @@ def _owner_candidate_samples( samples.append( { **record, - "audio_url": _audio_url( + "audio_url": resolve_audio_url( record["day"], record["stream"], record["segment_key"], @@ -982,7 +978,7 @@ def _owner_candidate_samples( record = provenance[int(position)] sample = { **record, - "audio_url": _audio_url( + "audio_url": resolve_audio_url( record["day"], record["stream"], record["segment_key"], diff --git a/solstone/apps/speakers/routes.py b/solstone/apps/speakers/routes.py index 7136830b7..51c704426 100644 --- a/solstone/apps/speakers/routes.py +++ b/solstone/apps/speakers/routes.py @@ -33,6 +33,7 @@ from solstone.apps.speakers.attribution import ( backfill_segments, save_speaker_labels, ) +from solstone.apps.speakers.audio import audio_serve_url, resolve_audio_file from solstone.apps.speakers.bootstrap import ( bootstrap_voiceprints, link_import, @@ -107,6 +108,7 @@ from solstone.think.entities.journal import ( ) from solstone.think.journal_io.errors import LockTimeout from solstone.think.journal_io.npz import load_npz, update_npz +from solstone.think.media import MIME_TYPES from solstone.think.utils import ( STREAM_RE, day_dirs, @@ -967,10 +969,11 @@ def api_review(day: str, stream: str, segment_key: str, source: str) -> Any: all_entities.sort(key=lambda x: (not x["is_principal"], x["name"].lower())) audio_file = None - audio_path = segment_dir / f"{source}.flac" - if audio_path.exists(): - rel_path = f"{stream}/{segment_key}/{source}.flac" - audio_file = f"/app/speakers/api/serve_audio/{day}/{rel_path}" + audio_mimetype = None + audio_path = resolve_audio_file(segment_dir, source) + if audio_path is not None: + audio_file = audio_serve_url(day, stream, segment_key, audio_path.name) + audio_mimetype = MIME_TYPES[audio_path.suffix] parsed = segment_parse(segment_key) start_time, end_time = parsed if parsed[0] else (None, None) @@ -992,6 +995,7 @@ def api_review(day: str, stream: str, segment_key: str, source: str) -> Any: "sentences": review_sentences, "all_entities": all_entities, "audio_file": audio_file, + "audio_mimetype": audio_mimetype, "has_labels": labels_data is not None, "summary": { "total": len(review_sentences), @@ -1937,4 +1941,7 @@ def serve_audio(day: str, rel_path: str) -> Any: return error if not path.is_file(): return error_response(FILE_NOT_FOUND, detail="File not found") - return send_file(path, mimetype="audio/flac") + mimetype = MIME_TYPES.get(path.suffix.lower()) + if mimetype is None: + raise ValueError(f"unregistered media extension for serve_audio: {path.suffix}") + return send_file(path, conditional=True, mimetype=mimetype) diff --git a/solstone/apps/speakers/tests/conftest.py b/solstone/apps/speakers/tests/conftest.py index 101d05aa0..24e5be8c1 100644 --- a/solstone/apps/speakers/tests/conftest.py +++ b/solstone/apps/speakers/tests/conftest.py @@ -92,6 +92,7 @@ def speakers_env(tmp_path, monkeypatch): *, stream: str | None = None, embeddings: np.ndarray | None = None, + audio_extension: str = ".flac", ) -> Path: """Create a segment with sentence embeddings. @@ -114,7 +115,11 @@ def speakers_env(tmp_path, monkeypatch): ) for source in sources: - lines = [json.dumps({"raw": f"{source}.flac", "model": "medium.en"})] + lines = [ + json.dumps( + {"raw": f"{source}{audio_extension}", "model": "medium.en"} + ) + ] # Parse segment_key to get base time (e.g., "143022_300" -> 14:30:22) # This matches real transcriber output which uses absolute timestamps @@ -159,7 +164,7 @@ def speakers_env(tmp_path, monkeypatch): embeddings=source_embeddings, statement_ids=statement_ids, ) - (segment_dir / f"{source}.flac").write_bytes(b"") + (segment_dir / f"{source}{audio_extension}").write_bytes(b"") return flat_dir diff --git a/solstone/apps/speakers/tests/test_audio.py b/solstone/apps/speakers/tests/test_audio.py new file mode 100644 index 000000000..7da660081 --- /dev/null +++ b/solstone/apps/speakers/tests/test_audio.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Tests for speakers audio file resolution.""" + +from __future__ import annotations + +from solstone.apps.speakers.audio import ( + _ORDERED_AUDIO_EXTENSIONS, + audio_serve_url, + resolve_audio_file, + resolve_audio_url, +) +from solstone.think.media import AUDIO_EXTENSIONS + + +def test_ordered_audio_extensions_match_media_registry() -> None: + assert frozenset(_ORDERED_AUDIO_EXTENSIONS) == AUDIO_EXTENSIONS + assert _ORDERED_AUDIO_EXTENSIONS[0] == ".flac" + + +def test_resolve_audio_file_prefers_media_format_order(tmp_path) -> None: + opus_path = tmp_path / "mic_audio.opus" + flac_path = tmp_path / "mic_audio.flac" + opus_path.write_bytes(b"opus") + flac_path.write_bytes(b"flac") + + assert resolve_audio_file(tmp_path, "mic_audio") == flac_path + + +def test_resolve_audio_file_ignores_missing_and_unregistered_audio(tmp_path) -> None: + (tmp_path / "mic_audio.aac").write_bytes(b"aac") + + assert resolve_audio_file(tmp_path, "mic_audio") is None + + +def test_audio_serve_url_builds_expected_route() -> None: + assert ( + audio_serve_url( + "20240101", + "test", + "143022_300", + "mic_audio.m4a", + ) + == "/app/speakers/api/serve_audio/20240101/test/143022_300/mic_audio.m4a" + ) + + +def test_resolve_audio_url_uses_registered_audio_file(speakers_env) -> None: + env = speakers_env() + env.create_segment( + "20240101", + "143022_300", + ["mic_audio"], + audio_extension=".m4a", + ) + + assert ( + resolve_audio_url( + "20240101", + "test", + "143022_300", + "mic_audio", + ) + == "/app/speakers/api/serve_audio/20240101/test/143022_300/mic_audio.m4a" + ) + + +def test_resolve_audio_url_returns_none_without_registered_audio(speakers_env) -> None: + env = speakers_env() + _flat_dir, chronicle_dir = env._segment_dirs("20240101", "143022_300") + (chronicle_dir / "mic_audio.aac").write_bytes(b"aac") + + assert resolve_audio_url("20240101", "test", "143022_300", "mic_audio") is None diff --git a/solstone/apps/speakers/tests/test_discovery.py b/solstone/apps/speakers/tests/test_discovery.py index 033aae05c..37a8eaed4 100644 --- a/solstone/apps/speakers/tests/test_discovery.py +++ b/solstone/apps/speakers/tests/test_discovery.py @@ -65,7 +65,12 @@ def _setup_owner_centroid( return centroid -def _create_cluster_segments(env, embeddings: np.ndarray) -> list[tuple[str, str, int]]: +def _create_cluster_segments( + env, + embeddings: np.ndarray, + *, + audio_extension: str = ".flac", +) -> list[tuple[str, str, int]]: """Create four segments with one qualifying cluster and one filtered cluster.""" segments = [ ("20240101", "090000_300"), @@ -79,7 +84,13 @@ def _create_cluster_segments(env, embeddings: np.ndarray) -> list[tuple[str, str segment_embeddings = embeddings if idx < 2: segment_embeddings = np.vstack([embeddings, alt_embeddings]) - env.create_segment(day, segment_key, ["audio"], embeddings=segment_embeddings) + env.create_segment( + day, + segment_key, + ["audio"], + embeddings=segment_embeddings, + audio_extension=audio_extension, + ) results.append((day, segment_key, segment_embeddings.shape[0])) return results @@ -156,6 +167,38 @@ def test_discover_clusters_found(speakers_env): assert len(cluster["samples"]) == 3 +def test_discover_samples_use_registered_audio_extension(speakers_env): + env = speakers_env() + _setup_owner_centroid(env.journal, [0.0, 1.0]) + embeddings = _make_speaker_embeddings([1.0, 0.0], 5) + _create_cluster_segments(env, embeddings, audio_extension=".m4a") + + result = discover_unknown_speakers() + + samples = result["clusters"][0]["samples"] + assert len(samples) == 3 + for sample in samples: + assert sample["audio_url"] == ( + f"/app/speakers/api/serve_audio/{sample['day']}/" + f"{sample['stream']}/{sample['segment_key']}/{sample['source']}.m4a" + ) + + +def test_discover_samples_allow_missing_audio(speakers_env): + env = speakers_env() + _setup_owner_centroid(env.journal, [0.0, 1.0]) + embeddings = _make_speaker_embeddings([1.0, 0.0], 5) + segments = _create_cluster_segments(env, embeddings) + for day, segment_key, _sentence_count in segments: + (env.journal / "chronicle" / day / "test" / segment_key / "audio.flac").unlink() + + result = discover_unknown_speakers() + + samples = result["clusters"][0]["samples"] + assert len(samples) == 3 + assert all(sample["audio_url"] is None for sample in samples) + + def test_discover_filters_attributed(speakers_env): env = speakers_env() _setup_owner_centroid(env.journal, [0.0, 1.0]) diff --git a/solstone/apps/speakers/tests/test_owner.py b/solstone/apps/speakers/tests/test_owner.py index 48597b52e..121f1e585 100644 --- a/solstone/apps/speakers/tests/test_owner.py +++ b/solstone/apps/speakers/tests/test_owner.py @@ -452,6 +452,81 @@ def test_detect_owner_candidate_pool_ready(speakers_env): assert get_current()["voiceprint"]["status"] == "candidate" +def test_owner_candidate_samples_use_registered_audio_extension(speakers_env): + from solstone.apps.speakers.owner import _owner_candidate_samples + + env = speakers_env() + embeddings = _owner_embeddings(3, np.random.default_rng(1)) + provenance = [] + for idx, segment_key in enumerate( + ("090000_300", "091000_300", "092000_300"), + start=1, + ): + env.create_segment( + "20240101", + segment_key, + ["mic_audio"], + num_sentences=1, + embeddings=embeddings[idx - 1 : idx], + audio_extension=".m4a", + ) + provenance.append( + { + "day": "20240101", + "stream": "test", + "segment_key": segment_key, + "source": "mic_audio", + "sentence_id": 1, + "duration_s": 5.0, + } + ) + + samples = _owner_candidate_samples(embeddings, embeddings[0], provenance) + + assert len(samples) == 3 + for sample in samples: + assert sample["audio_url"] == ( + f"/app/speakers/api/serve_audio/{sample['day']}/" + f"{sample['stream']}/{sample['segment_key']}/{sample['source']}.m4a" + ) + + +def test_owner_candidate_samples_allow_missing_audio(speakers_env): + from solstone.apps.speakers.owner import _owner_candidate_samples + + env = speakers_env() + embeddings = _owner_embeddings(1, np.random.default_rng(1)) + env.create_segment( + "20240101", + "090000_300", + ["mic_audio"], + num_sentences=1, + embeddings=embeddings, + ) + ( + env.journal + / "chronicle" + / "20240101" + / "test" + / "090000_300" + / "mic_audio.flac" + ).unlink() + provenance = [ + { + "day": "20240101", + "stream": "test", + "segment_key": "090000_300", + "source": "mic_audio", + "sentence_id": 1, + "duration_s": 5.0, + } + ] + + samples = _owner_candidate_samples(embeddings, embeddings[0], provenance) + + assert samples[0]["audio_url"] is None + + def test_detect_owner_candidate_selection_skips_rejected_and_non_principal( speakers_env, ): diff --git a/solstone/apps/speakers/tests/test_routes.py b/solstone/apps/speakers/tests/test_routes.py index 408ce5d97..8e356bf11 100644 --- a/solstone/apps/speakers/tests/test_routes.py +++ b/solstone/apps/speakers/tests/test_routes.py @@ -49,9 +49,11 @@ def serve_audio_client(tmp_path, monkeypatch): ) segment_dir.mkdir(parents=True) (segment_dir / f"{SERVE_AUDIO_SOURCE}.flac").write_bytes(b"fLaC") + (segment_dir / f"{SERVE_AUDIO_SOURCE}.m4a").write_bytes(b"m4a") monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) app = create_app(str(journal)) + app.config["TESTING"] = True return app.test_client(), journal @@ -750,6 +752,41 @@ def test_serve_audio_sets_flac_mimetype(serve_audio_client): assert response.mimetype == "audio/flac" +def test_serve_audio_sets_registered_mimetype(serve_audio_client): + """Serve audio endpoint returns the registered mimetype for sample playback.""" + client, _journal = serve_audio_client + + response = client.get( + f"/app/speakers/api/serve_audio/{SERVE_AUDIO_DAY}/" + f"{SERVE_AUDIO_STREAM}/{SERVE_AUDIO_SEGMENT}/{SERVE_AUDIO_SOURCE}.m4a" + ) + + assert response.status_code == 200 + assert response.mimetype == "audio/mp4" + + +def test_serve_audio_unregistered_extension_raises(serve_audio_client): + """Serve audio refuses existing files with unregistered extensions.""" + client, journal = serve_audio_client + segment_dir = ( + journal + / "chronicle" + / SERVE_AUDIO_DAY + / SERVE_AUDIO_STREAM + / SERVE_AUDIO_SEGMENT + ) + (segment_dir / f"{SERVE_AUDIO_SOURCE}.aac").write_bytes(b"aac") + + with pytest.raises( + ValueError, + match=r"unregistered media extension for serve_audio: \.aac", + ): + client.get( + f"/app/speakers/api/serve_audio/{SERVE_AUDIO_DAY}/" + f"{SERVE_AUDIO_STREAM}/{SERVE_AUDIO_SEGMENT}/{SERVE_AUDIO_SOURCE}.aac" + ) + + def test_serve_audio_path_traversal_is_forbidden(serve_audio_client): """A rel_path resolving to a real file outside the day dir is refused 403.""" client, journal = serve_audio_client @@ -947,6 +984,10 @@ def test_api_review_with_labels(speakers_env): data = resp.get_json() assert data["has_labels"] is True assert data["summary"]["total"] > 0 + assert data["audio_file"] == ( + "/app/speakers/api/serve_audio/20240101/test/143022_300/mic_audio.flac" + ) + assert data["audio_mimetype"] == "audio/flac" assert data["all_entities"][0]["name"] == "Alice Test" sentences = data["sentences"] s1 = next(s for s in sentences if s["id"] == 1) @@ -975,6 +1016,61 @@ def test_api_review_no_labels(speakers_env): assert data["summary"]["needs_review"] == 0 +def test_api_review_uses_registered_audio_extension(speakers_env): + """Review endpoint reports the actual registered segment audio file.""" + from flask import Flask + + from solstone.apps.speakers.routes import speakers_bp + + env = speakers_env() + env.create_segment( + "20240101", + "143022_300", + ["mic_audio"], + audio_extension=".m4a", + ) + + app = Flask(__name__) + app.register_blueprint(speakers_bp) + + with app.test_client() as client: + resp = client.get("/app/speakers/api/review/20240101/test/143022_300/mic_audio") + assert resp.status_code == 200 + data = resp.get_json() + assert data["audio_file"] == ( + "/app/speakers/api/serve_audio/20240101/test/143022_300/mic_audio.m4a" + ) + assert data["audio_mimetype"] == "audio/mp4" + + +def test_api_review_omits_audio_when_registered_audio_is_missing(speakers_env): + """Review endpoint returns null audio metadata when source audio is purged.""" + from flask import Flask + + from solstone.apps.speakers.routes import speakers_bp + + env = speakers_env() + env.create_segment("20240101", "143022_300", ["mic_audio"]) + ( + env.journal + / "chronicle" + / "20240101" + / "test" + / "143022_300" + / "mic_audio.flac" + ).unlink() + + app = Flask(__name__) + app.register_blueprint(speakers_bp) + + with app.test_client() as client: + resp = client.get("/app/speakers/api/review/20240101/test/143022_300/mic_audio") + assert resp.status_code == 200 + data = resp.get_json() + assert data["audio_file"] is None + assert data["audio_mimetype"] is None + + def test_api_review_corrections_excludes_confirmed(speakers_env): """Corrections summary/filter state excludes user_confirmed labels.""" import json diff --git a/solstone/apps/speakers/workspace.html b/solstone/apps/speakers/workspace.html index fd5f4b738..99934d424 100644 --- a/solstone/apps/speakers/workspace.html +++ b/solstone/apps/speakers/workspace.html @@ -1464,7 +1464,7 @@
${escapeHtml(sample.day)} · ${escapeHtml(sample.stream)} · ${escapeHtml(sample.segment_key)}
- + ${sample.audio_url ? `` : ''} `).join('')} @@ -1628,7 +1628,7 @@ ${cluster.samples.map(sample => `
${escapeHtml(sample.text || '(no transcript)')}
- + ${sample.audio_url ? `` : ''}
`).join('')}
@@ -2036,14 +2036,14 @@ } function renderReview(data) { - const { audio_file } = data; + const { audio_file, audio_mimetype } = data; const audioContainer = document.getElementById('spkAudioContainer'); if (audio_file) { audioContainer.innerHTML = `
@@ -2823,7 +2823,7 @@
sample ${index + 1}
${escapeHtml(sample.day)} · ${escapeHtml(sample.stream)} · ${escapeHtml(sample.segment_key)}
- + ${sample.audio_url ? `` : ''}
`).join('')} diff --git a/tests/baselines/api/speakers/review.json b/tests/baselines/api/speakers/review.json index 4715a319d..0d7eb29bb 100644 --- a/tests/baselines/api/speakers/review.json +++ b/tests/baselines/api/speakers/review.json @@ -167,6 +167,7 @@ } ], "audio_file": null, + "audio_mimetype": null, "has_labels": true, "segment": { "end": "09:05",