diff --git a/AGENTS.md b/AGENTS.md index 59f16c039..cc477556a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -358,7 +358,7 @@ Each domain has exactly **one** write-owning module (or one tightly-scoped famil | Provider install status records and proof cache (`health/providers/{local,parakeet}.json`, `health/providers/{local,parakeet}.proof-cache.json`) | `solstone/think/providers/install_state.py` + `solstone/think/providers/artifact_proof.py` | | Provider install leases (`health/providers/{local,parakeet}.lease`) | `solstone/think/providers/install_lease.py` | | Provider runtime health and retry-token records (`health/providers/runtime/{local,parakeet}.json`, `health/providers/runtime/{local,parakeet}.retry-token.json`, `health/providers/runtime/{local,parakeet}.operation.lock`) | `solstone/think/providers/runtime_health.py` | -| Native speakers-analyze circuit breaker (`health/speakers-analyze/circuit-breaker.json`) | `solstone/think/speakers_analyze_runtime.py` | +| Native speakers-analyze install generation (`health/speakers-analyze/install-generation.json`, `health/speakers-analyze/install-generation.lock`) | `solstone/think/speakers_analyze_installation.py` | | Provider artifact manifests (`cache/providers/**/.solstone-provider-manifest.json`, `cache/providers/local/mlx/**/*.manifest.json`) | `solstone/think/providers/artifact_proof.py` | | nvattest appraiser cache (`cache/providers/nvattest/**`) | `solstone/think/providers/nvattest_install.py` | | Media offload ledger (`health/offload/.jsonl`) | `solstone/think/offload_ledger.py` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 597dd6725..a677022e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to solstone (the Python package) will be documented in this Format adapted from [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), aligned with `cmo/brand/changelog-voice.md`. +## [Unreleased] + +### Changed + +- speaker analysis now uses the native helper only. if the helper is missing, damaged, or returns invalid data, journal startup and transcription fail loudly instead of falling back to the old python speaker path. +- new transcript headers name `solstone-core-speakers-analyze-v1` as the speaker-analysis producer whenever the helper actually ran, including when it declines speaker labels. transcripts with no speech-bearing statements still omit the field because no speaker-analysis producer touched them. + ## [1.0.18] - 2026-07-28 ### Added diff --git a/INSTALL.md b/INSTALL.md index 918bac0b1..cd51dd9a7 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -76,7 +76,7 @@ let your human know: **open http://localhost:5015 in a browser**. the first-run a `solstone-journal` install bundles everything a journal host needs — PDF rendering, whisper, and the default CPU transcription stack are all included; `journal setup` downloads the transcription model. there are no separate à-la-carte extras to add. if the readiness doctor step (`journal doctor --readiness`) finds missing system libraries, it will tell you the exact install command to run for your platform. -Pick one of `solstone-journal` or `solstone-journal-cuda` — the CPU and GPU ONNX runtimes share the same files and must not both be installed. `journal doctor` reports whether the transcription runtime and model are ready. +Pick one of `solstone-journal` or `solstone-journal-cuda` — the CPU and GPU ONNX runtimes share the same files and must not both be installed. `journal doctor` reports whether the transcription runtime, native speaker-analysis helper, and bundled models are ready. This CUDA extra is only for transcription. The Linux local model provider picks its own GPU backend: on NVIDIA GPUs from the RTX 30 series up with a current NVIDIA driver (580 series or newer), it runs natively on CUDA; the runtime downloads from updates.solstone.app as a checksum-pinned bundle. On other hardware GPUs (AMD, Intel, or older NVIDIA) it uses Vulkan. CPU/software Vulkan devices are rejected instead of falling back silently. On AMD, the local model path runs through Mesa/RADV Vulkan, while transcription stays on the bundled CPU runtime. diff --git a/docs/OBSERVE.md b/docs/OBSERVE.md index 629ca2433..b93524428 100644 --- a/docs/OBSERVE.md +++ b/docs/OBSERVE.md @@ -64,7 +64,7 @@ client) live in the per-platform observer repos (`solstone-linux`, What remains in this package is the home-side ingest-and-processing pipeline: - **sense.py** — File watcher that dispatches transcription and description jobs -- **transcribe/** — Audio transcription with sentence-level embeddings. Exit-code contract, retry/deferral semantics, and the `observe.transcribed` field table: [transcribe/failure-and-telemetry.md](../solstone/observe/transcribe/failure-and-telemetry.md) +- **transcribe/** — Audio transcription with native speaker-analysis embeddings. Exit-code contract, retry/deferral semantics, and the `observe.transcribed` field table: [transcribe/failure-and-telemetry.md](../solstone/observe/transcribe/failure-and-telemetry.md) - **describe.py** — Provider-routed vision analysis with category-based prompts - **categories/** — Category-specific prompts for screen content (see [SCREEN_CATEGORIES.md](SCREEN_CATEGORIES.md)) diff --git a/docs/PORTING.md b/docs/PORTING.md index e6f4d245c..bd231b542 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -76,13 +76,6 @@ and macOS lanes, carrying the pinned CPU ONNX Runtime shared library that the helper dynamically links. Do not design toward linking `solstone-core-speakers-onnx` into the `solstone-core` bin. -The config-gated speaker cutover seam is bounded migration scaffolding, not a -compatibility shim. In 1.0.18, the seam ships inert: absent -`core.speakers_analyze` means Python, and the selection key remains out of -`journal_default.json` because this is a two-release migration control: flip the -absent-key default in 1.0.19, then delete the Python orchestration path and key -in 1.0.20. - The musl-to-glibc substitution is local to that helper wheel. The `solstone-core` wheel stays on the existing static-musl Linux lanes and must remain byte-identical to the core wheel that would be produced without the speaker analyzer helper. diff --git a/pyproject.toml b/pyproject.toml index 920dd6b91..2d57b223f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,7 +150,6 @@ journal-host = [ "rapidfuzz", # Audio processing "soundfile", - "kaldi-native-fbank>=1.22", # solstone/apps/speakers/{owner,discovery}.py use sklearn.cluster.HDBSCAN. # Previously pulled transitively via resemblyzer; now a direct dep. "scikit-learn>=1.3", @@ -213,8 +212,8 @@ build_py = "build_hooks.BuildPyWithoutTests" # Some published dependencies must NOT resolve during workspace development. # Tombstones are build-fails-by-design distributions; the speakers-analyze helper # is a real workspace member, but its editable build needs ONNX Runtime staging -# that only the release wheel-build targets produce. The dev/test plane runs the -# Python speaker path by default, so the dev venv does not need the helper binary. +# that only the release wheel-build targets produce. Unit tests exercise the +# native adapter with fakes and keep the frozen Python oracle under tests/. # The marker `python_version < '3.12'` contradicts requires-python = ">=3.12", so # uv drops each override from the active dev environment. [tool.uv] is never # published, so end-user wheel METADATA still carries the real pins. @@ -268,6 +267,8 @@ filterwarnings = [ [dependency-groups] dev = [ "freezegun", + # Test-only: frozen Python speaker oracle under tests/speaker_oracle/. + "kaldi-native-fbank>=1.22", "onnx", "pytest", "pytest-asyncio", diff --git a/scripts/build_core_fixtures.py b/scripts/build_core_fixtures.py index 2607a3656..4704b7e9c 100644 --- a/scripts/build_core_fixtures.py +++ b/scripts/build_core_fixtures.py @@ -24,6 +24,10 @@ from typing import Any import numpy as np +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + from solstone.apps.speakers.encoder_config import ( ACOUSTIC_HIGH, ACOUSTIC_MARGIN_MIN, @@ -68,7 +72,17 @@ from solstone.apps.speakers.encoder_config import ( VP_OUTLIER_MIN_SIMILARITY, ) from solstone.convey.contract.assemble import CALLOSUM_REGISTRY -from solstone.observe.transcribe.diarize import ( +from solstone.think import markdown as markdown_formatter +from solstone.think.cogitate_contract import ( + COGITATE_ACCESS_TIERS, + COGITATE_READ_TOOL_NAMES, + COGITATE_RUNTIME_PREAMBLE, + FUTURE_ACCESS_TIERS, + TALENT_FINALIZATION_MODES, + capabilities_for_access_tier, +) +from solstone.think.indexer.edges import EDGES_SCHEMA_VERSION, _ensure_edges_schema +from tests.speaker_oracle.diarize import ( AHC_LINKAGE, AHC_METRIC, FRAMES_PER_WINDOW, @@ -85,45 +99,34 @@ from solstone.observe.transcribe.diarize import ( _silhouette, _wespeaker_features, ) -from solstone.observe.transcribe.diarize import ( +from tests.speaker_oracle.diarize import ( SAMPLE_RATE as DIARIZE_SAMPLE_RATE, ) -from solstone.observe.transcribe.diarize import ( +from tests.speaker_oracle.diarize import ( STRIDE_S as DIARIZE_STRIDE_S, ) -from solstone.observe.transcribe.diarize import ( +from tests.speaker_oracle.diarize import ( WINDOW_S as DIARIZE_WINDOW_S, ) -from solstone.observe.transcribe.main import _compute_wespeaker_features -from solstone.observe.transcribe.overlap import ( +from tests.speaker_oracle.embedder import _compute_wespeaker_features +from tests.speaker_oracle.overlap import ( _DIARIZE_STRIDE_S as OVERLAP_DIARIZE_STRIDE_S, ) -from solstone.observe.transcribe.overlap import ( +from tests.speaker_oracle.overlap import ( FRAMES_PER_WINDOW as OVERLAP_FRAMES_PER_WINDOW, ) -from solstone.observe.transcribe.overlap import ( +from tests.speaker_oracle.overlap import ( OVERLAP_CLASSES, _speaker_window_stats, decide_speaker_evidence, ) -from solstone.observe.transcribe.overlap import ( +from tests.speaker_oracle.overlap import ( STRIDE_S as OVERLAP_STRIDE_S, ) -from solstone.observe.transcribe.overlap import ( +from tests.speaker_oracle.overlap import ( WINDOW_S as OVERLAP_WINDOW_S, ) -from solstone.think import markdown as markdown_formatter -from solstone.think.cogitate_contract import ( - COGITATE_ACCESS_TIERS, - COGITATE_READ_TOOL_NAMES, - COGITATE_RUNTIME_PREAMBLE, - FUTURE_ACCESS_TIERS, - TALENT_FINALIZATION_MODES, - capabilities_for_access_tier, -) -from solstone.think.indexer.edges import EDGES_SCHEMA_VERSION, _ensure_edges_schema -ROOT = Path(__file__).resolve().parent.parent FIXTURE_DIR = ROOT / "core" / "fixtures" CALLOSUM_ARTIFACT_PATH = FIXTURE_DIR / "callosum_registry.json" COGITATE_ARTIFACT_PATH = FIXTURE_DIR / "cogitate_contract.json" diff --git a/scripts/check_journal_io_access.py b/scripts/check_journal_io_access.py index 5c8a4b7ce..fd1461225 100644 --- a/scripts/check_journal_io_access.py +++ b/scripts/check_journal_io_access.py @@ -152,8 +152,8 @@ OWNER_FILES: frozenset[str] = frozenset( # Provider cache-local nvattest artifacts and install single-flight lock. "solstone/think/providers/nvattest_install.py", "solstone/think/providers/runtime_health.py", - # Native speakers-analyze circuit breaker. - "solstone/think/speakers_analyze_runtime.py", + # Native speakers-analyze install-generation proof record and lease. + "solstone/think/speakers_analyze_installation.py", "solstone/think/schedule_config.py", "solstone/think/push/devices.py", # Backup hosted-tier binding (0600 broker-token cache). diff --git a/scripts/release_install_smoke.py b/scripts/release_install_smoke.py index c5baa2b88..35ac572e8 100644 --- a/scripts/release_install_smoke.py +++ b/scripts/release_install_smoke.py @@ -39,7 +39,7 @@ from scripts.check_wheel_contents import ( from scripts.release_digest import file_sha256_size from scripts.release_public_evidence import validate_public_evidence_tree from solstone.apps.speakers.encoder_config import WESPEAKER_EMBEDDING_WIDTH -from solstone.observe.model_assets import ( +from solstone.think.model_assets import ( PYANNOTE_SEGMENTATION_MODEL_FILENAME, WESPEAKER_MODEL_FILENAME, ) diff --git a/solstone/apps/speakers/_overlap.py b/solstone/apps/speakers/_overlap.py index 7826d4704..fd9fb4976 100644 --- a/solstone/apps/speakers/_overlap.py +++ b/solstone/apps/speakers/_overlap.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import NamedTuple from solstone.apps.speakers.encoder_config import SPEAKER_EVIDENCE_VERSION +from solstone.apps.speakers.evidence import VALID_SPEAKER_EVIDENCE_DECISIONS logger = logging.getLogger(__name__) @@ -69,7 +70,7 @@ def _read_segment_speaker_evidence(jsonl_path: Path) -> SegmentSpeakerEvidence: speaker_evidence = header.get("speaker_evidence") version = header.get("speaker_evidence_version") if ( - speaker_evidence not in {"none", "single", "multi"} + speaker_evidence not in VALID_SPEAKER_EVIDENCE_DECISIONS or version != SPEAKER_EVIDENCE_VERSION ): return UNKNOWN_SPEAKER_EVIDENCE diff --git a/solstone/apps/speakers/encoder_config.py b/solstone/apps/speakers/encoder_config.py index e41901b45..6af263c65 100644 --- a/solstone/apps/speakers/encoder_config.py +++ b/solstone/apps/speakers/encoder_config.py @@ -7,6 +7,9 @@ import math ENCODER_ID: str = "wespeaker-resnet34-256" WESPEAKER_EMBEDDING_WIDTH: int = 256 +WESPEAKER_MODEL_SHA256: str = ( + "5ef208a9da1453335308a6b6f4e6dfbd7e183a38b604de0a57664f45d257fe94" +) OWNER_THRESHOLD: float = 0.43 # Benchmark sweep: 0.05 cuts L1 owner false-claims on non-owner statements from 14.1% to 2.6% at a cost of 69% -> 65% per-statement L1 owner recall. diff --git a/solstone/apps/speakers/evidence.py b/solstone/apps/speakers/evidence.py new file mode 100644 index 000000000..5a110c111 --- /dev/null +++ b/solstone/apps/speakers/evidence.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Shared speaker-evidence wire types.""" + +from __future__ import annotations + +from typing import NamedTuple + +VALID_SPEAKER_EVIDENCE_DECISIONS = frozenset({"none", "single", "multi"}) + + +class SpeakerEvidenceDecision(NamedTuple): + speaker_evidence: str + multi_window_fraction: float + mean_window_overlap_share: float + + +__all__ = [ + "SpeakerEvidenceDecision", + "VALID_SPEAKER_EVIDENCE_DECISIONS", +] diff --git a/solstone/apps/speakers/tests/test_discovery.py b/solstone/apps/speakers/tests/test_discovery.py index 050a4ab18..af1c7f486 100644 --- a/solstone/apps/speakers/tests/test_discovery.py +++ b/solstone/apps/speakers/tests/test_discovery.py @@ -1499,8 +1499,8 @@ def test_identify_fault_resume_forward_stages(speakers_env, monkeypatch, stage): _create_identify_cluster(env, 40, "120000_300") calls = {"failed": False} - def fail_once(seam: str) -> None: - if seam == stage and not calls["failed"]: + def fail_once(stage_name: str) -> None: + if stage_name == stage and not calls["failed"]: calls["failed"] = True raise RuntimeError(f"forced {stage}") diff --git a/solstone/apps/speakers/tests/test_encoder_config.py b/solstone/apps/speakers/tests/test_encoder_config.py index a9f563a81..23ef55419 100644 --- a/solstone/apps/speakers/tests/test_encoder_config.py +++ b/solstone/apps/speakers/tests/test_encoder_config.py @@ -7,14 +7,14 @@ import math from pathlib import Path from solstone.apps.speakers import attribution, candidate_tracker, encoder_config, owner -from solstone.observe.transcribe.main import ( - OVERLAP_DETECTOR_ID as MAIN_OVERLAP_DETECTOR_ID, -) -from solstone.observe.transcribe.main import PYANNOTE_OVERLAP_MODEL_SHA256 def test_locked_constants(): assert encoder_config.ENCODER_ID == "wespeaker-resnet34-256" + assert ( + encoder_config.WESPEAKER_MODEL_SHA256 + == "5ef208a9da1453335308a6b6f4e6dfbd7e183a38b604de0a57664f45d257fe94" + ) assert encoder_config.OWNER_THRESHOLD == 0.43 assert encoder_config.OWNER_MARGIN_MIN == 0.05 assert encoder_config.SOLO_CLUSTER_MIN_COSINE == 0.43 @@ -40,8 +40,11 @@ def test_locked_constants(): assert encoder_config.SPEAKER_EVIDENCE_SINGLE_MAX == 0.05 assert encoder_config.DIARIZE_MIN_OVERLAP == 0.05 assert encoder_config.SPEAKER_EVIDENCE_VERSION == "windowed-slots-v1" - assert encoder_config.OVERLAP_DETECTOR_ID == MAIN_OVERLAP_DETECTOR_ID - assert encoder_config.OVERLAP_DETECTOR_SHA256 == PYANNOTE_OVERLAP_MODEL_SHA256 + assert encoder_config.OVERLAP_DETECTOR_ID == "pyannote-segmentation-3.0-onnx" + assert ( + encoder_config.OVERLAP_DETECTOR_SHA256 + == "057ee564753071c0b09b5b611648b50ac188d50846bff5f01e9f7bbf1591ea25" + ) assert encoder_config.MERGE_THRESHOLD == 0.72 assert encoder_config.SPLIT_THRESHOLD == 0.55 assert encoder_config.STABILITY_THRESHOLD == 0.25 diff --git a/solstone/observe/_silero_vad.py b/solstone/observe/_silero_vad.py index ec53a2b60..0dad9c184 100644 --- a/solstone/observe/_silero_vad.py +++ b/solstone/observe/_silero_vad.py @@ -18,7 +18,7 @@ from typing import Dict, List, Optional, Tuple import numpy as np -from solstone.observe.model_assets import resolve_silero_vad_model +from solstone.think.model_assets import resolve_silero_vad_model # The code below is adapted from https://github.com/snakers4/silero-vad. diff --git a/solstone/observe/sense.py b/solstone/observe/sense.py index 395e2fcc5..400f3751a 100644 --- a/solstone/observe/sense.py +++ b/solstone/observe/sense.py @@ -17,6 +17,7 @@ import logging import os import signal import subprocess +import sys import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed @@ -1407,6 +1408,20 @@ def main(): for ext in IMAGE_EXTENSIONS: sensor.register(f"*{ext}", "depict", ["journal", "depict", "{file}"]) + from solstone.think.speakers_analyze_installation import ( + begin_speakers_analyze_generation, + ) + + try: + sensor._speakers_analyze_generation = begin_speakers_analyze_generation( + journal_path=journal + ) + except Exception as exc: + message = f"Speakers-analyze installation is incomplete: {exc}" + logger.error(message) + print(message, file=sys.stderr) + raise SystemExit(78) from exc + if args.day: day_dir = day_path(args.day) diff --git a/solstone/observe/transcribe/__init__.py b/solstone/observe/transcribe/__init__.py index 26ecd2744..2d712e63d 100644 --- a/solstone/observe/transcribe/__init__.py +++ b/solstone/observe/transcribe/__init__.py @@ -34,7 +34,7 @@ Backend Interface: "end": float, # seconds "text": str, # transcribed text "words": list[dict] | None, # word-level data if available - "speaker": int | None, # speaker ID assigned by local diarization + "speaker": int | None, # speaker ID assigned by native speaker analysis } Word format (when available): diff --git a/solstone/observe/transcribe/audio.schema.json b/solstone/observe/transcribe/audio.schema.json index 323d83ba8..2574cede1 100644 --- a/solstone/observe/transcribe/audio.schema.json +++ b/solstone/observe/transcribe/audio.schema.json @@ -25,7 +25,7 @@ "speaker_evidence": { "type": "string", "enum": ["single", "multi", "none"], - "description": "Windowed slot evidence decision for local diarization engagement. This records engagement, not clustering outcome: a multi value with no integer speaker labels means diarization engaged and clustering produced nothing or failed." + "description": "Windowed slot evidence decision from native speaker analysis. This records engagement, not clustering outcome: a multi value with no integer speaker labels means diarization engaged and clustering produced nothing or failed." }, "speaker_evidence_multi_fraction": { "type": "number", diff --git a/solstone/observe/transcribe/failure-and-telemetry.md b/solstone/observe/transcribe/failure-and-telemetry.md index 27b8bc21c..6d0e26e59 100644 --- a/solstone/observe/transcribe/failure-and-telemetry.md +++ b/solstone/observe/transcribe/failure-and-telemetry.md @@ -63,6 +63,7 @@ Backend-specific policy: | Stranded confidential channel before dispatch and local RAM below floor | Fail (`1`) | `resolve_default_backend` surfaces the local-STT requirement before `_process_one`; no JSONL is written, input audio stays on disk, and the segment remains `incomplete`. | | Confidential hosted STT 400/413 or bad 200 contract | Defer (`69`) | Hosted STT is operated infrastructure. A 400 can be an engine-side regression, and owner audio is irreplaceable; preserving it for a post-fix drain is safer than failing permanently. | | Confidential hosted STT unreachable, backpressured, or unexpected status | Defer (`69`) | These are service-side or lane-side conditions. The deferred event carries the reason so health surfaces can show the condition without leaking content. | +| Native speakers-analyze helper exits non-zero, times out, exceeds stream bounds, emits malformed JSON, or violates the response schema | Fail (`1`) | Speaker analysis is now the only speaker plane. A failed helper leaves the input audio on disk and writes no transcript or embedding archive. | | HTTP 5xx from a live server, malformed JSON, contract violation | Fail (`1`) | The server answered — it is broken, not absent. Retrying the same request reproduces it. | | Malformed request or bad URL scheme (`LocalProtocolError`, `UnsupportedProtocol`) | Fail (`1`) | These are transport errors, but the bug is on *our* side of the wire. A retry cannot fix them, and deferring would hide the bug behind a daily retry forever. | | Anything else unexpected | Fail (`1`) | Surface it. | @@ -112,6 +113,7 @@ Every deferred and failed event carries a machine-readable `reason`. | `hosted_transcribe_unreachable` | confidential backend | The hosted STT POST timed out or failed at the transport layer, or required local credential/device header data was unavailable. | | `hosted_transcribe_contract_failed` | confidential backend | Hosted STT returned 200 with invalid JSON, a non-object body, or a body that violated the expected word-timing contract. | | `hosted_transcribe_unexpected_status` | confidential backend | Hosted STT returned a non-200 status outside the named rejected/backpressure buckets. | +| `speaker_analysis_native_failure` | native speakers-analyze typed failure | Speaker analysis failed after STT. The event carries content-free native attribution fields below; the local log carries full details. | | *(provider reason code)* | failed path | On a hard failure from a provider error — e.g. `transcription_http_error`, `invalid_json`, `contract_violation`. | | *(exception type name)* | failed path | On any other hard failure. | @@ -145,6 +147,10 @@ One event name, five outcomes. Every attempt emits exactly one event. | `vad_duration`, `vad_speech`, `noisy`, RMS/loud stats | VAD summary | always | | `duration_ms` | total wall-clock of `process_audio` | success | | `day`, `segment`, `observer` | provenance | when derivable | +| `speaker_analysis_failure_path` | currently `native` | failed native speakers-analyze path only | +| `speaker_analysis_failure_stage` | `request` \| `invoke` \| `parse` \| `payload` | failed native speakers-analyze path only | +| `speaker_analysis_failure_reason` | lowercase machine label, e.g. `timeout`, `stdout-too-large`, `malformed-response`, `embedding-payload-size-mismatch` | failed native speakers-analyze path only | +| `speaker_analysis_failure_native_exit_code` | helper exit code, including negative signal codes | failed native speakers-analyze path only, when known | ### Timing stages @@ -158,9 +164,7 @@ the jsonl and the npz) reports its total. | `vad_ms` | `run_vad` | | `reduce_ms` | `reduce_audio` (absent when reduction was skipped) | | `asr_ms` | `stt_transcribe` — the STT call itself | -| `embed_ms` | sentence-embedding generation | -| `overlap_ms` | overlap + log-prob computation | -| `diarize_ms` | local diarization (absent when skipped — the common case) | +| `speakers_analyze_ms` | native helper request construction, invocation, response validation, speaker evidence, diarization, and statement embeddings | | `write_ms` | jsonl + npz writes | Deferred and failed events carry whatever completed before the failure — typically diff --git a/solstone/observe/transcribe/main.py b/solstone/observe/transcribe/main.py index 0b457765c..85dbb50f8 100644 --- a/solstone/observe/transcribe/main.py +++ b/solstone/observe/transcribe/main.py @@ -1,18 +1,18 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Transcribe audio files with pluggable STT backends and sentence-level embeddings. +"""Transcribe audio files with pluggable STT and native speaker analysis. Transcription pipeline: 1. VAD stage: Run Silero VAD to detect speech and filter silent files early 2. Audio reduction: Trim long silence gaps for faster processing 3. Transcription: Dispatch to the configured or resource-aware STT backend -4. Embeddings: Generate voice embeddings for each sentence using wespeaker-resnet34 +4. Speaker analysis: Call the native helper for labels, evidence, and embeddings 5. Output: JSONL format compatible with format_audio() in observe/hear.py Output files: - .jsonl: Transcript with HH:MM:SS timestamps and optional speaker labels -- .npz: Sentence-level voice embeddings indexed by statement id +- .npz: Native helper embeddings indexed by statement id Configuration (journal config transcribe section): - transcribe.backend: STT backend ("parakeet", "parakeet-cpp", "confidential"). If unset, auto-selected by lane and resources. @@ -44,7 +44,6 @@ import datetime import json import logging import os -import platform import resource import sys import time @@ -61,13 +60,10 @@ from solstone.apps.settings.install_copy import ( STT_NO_LOCAL_STT_RECOVERY, ) from solstone.apps.speakers.encoder_config import ( - ENCODER_ID, OVERLAP_DETECTOR_ID, - OVERLAP_DETECTOR_SHA256, SPEAKER_EVIDENCE_VERSION, ) from solstone.observe.exit_codes import EXIT_PROVIDER_BLOCKED -from solstone.observe.model_assets import resolve_wespeaker_model from solstone.observe.processing_record import ( HANDLER_TRANSCRIBE, REASON_CORRUPT_INPUT, @@ -94,6 +90,11 @@ from solstone.observe.transcribe.resource import ( stt_local_floor_bytes, ) from solstone.observe.transcribe.sound_tags import tag_audio +from solstone.observe.transcribe.speakers_analyze_errors import ( + SPEAKER_ANALYSIS_FAILURE_LABEL, + SPEAKER_ANALYSIS_FAILURE_REASON, + SpeakerAnalyzeError, +) from solstone.observe.utils import ( SAMPLE_RATE, AudioDecodeError, @@ -119,11 +120,12 @@ from solstone.think.utils import ( setup_cli, ) +SPEAKERS_ANALYZE_EX_CONFIG = 78 + if TYPE_CHECKING: import numpy as np - import onnxruntime as ort - from solstone.observe.transcribe.overlap import SpeakerEvidenceDecision + from solstone.apps.speakers.evidence import SpeakerEvidenceDecision from solstone.observe.vad import AudioReduction, VadResult # Re-export defaults for backwards compatibility @@ -140,15 +142,6 @@ DEFAULT_MIN_SPEECH_SECONDS = 1.0 # Minimum statement duration for embedding (seconds) MIN_STATEMENT_DURATION = 0.3 -EMBEDDER_NAME = ENCODER_ID -WESPEAKER_MODEL_SHA256 = ( - "5ef208a9da1453335308a6b6f4e6dfbd7e183a38b604de0a57664f45d257fe94" -) -PYANNOTE_OVERLAP_MODEL_SHA256 = OVERLAP_DETECTOR_SHA256 - -# Module-level embedder cache -_embedder_session: ort.InferenceSession | None = None - def _join_missing_fields(fields: list[str]) -> str: if len(fields) == 1: @@ -278,79 +271,6 @@ def _surface_stt_requirement( logging.error("%s %s %s", requirement, detected, STT_NO_LOCAL_STT_RECOVERY) -def _select_onnx_providers() -> list[str]: - """Return the ONNX Runtime provider list for this host. - - Darwin (any arch) prefers CoreML with CPU fallback; elsewhere, CPU only. - """ - if platform.system() == "Darwin": - return ["CoreMLExecutionProvider", "CPUExecutionProvider"] - return ["CPUExecutionProvider"] - - -def _get_embedder_session() -> ort.InferenceSession: - """Return a cached ONNX InferenceSession for the WeSpeaker encoder.""" - global _embedder_session - - if _embedder_session is None: - import onnxruntime as ort - - wespeaker_model_path = resolve_wespeaker_model() - if not wespeaker_model_path.is_file(): - raise FileNotFoundError( - f"WeSpeaker model asset not found at {wespeaker_model_path}. " - "Run `make install` to verify the bundled asset." - ) - providers = _select_onnx_providers() - start = time.monotonic() - _embedder_session = ort.InferenceSession( - str(wespeaker_model_path), - providers=providers, - ) - elapsed = time.monotonic() - start - logging.info( - "wespeaker session loaded providers=%s elapsed=%.2fs", - _embedder_session.get_providers(), - elapsed, - ) - - return _embedder_session - - -def _compute_wespeaker_features(audio: np.ndarray, sample_rate: int) -> np.ndarray: - """Compute Kaldi-style fbank features for the bundled WeSpeaker encoder.""" - import kaldi_native_fbank as knf - import numpy as np - - if sample_rate != SAMPLE_RATE: - raise ValueError( - f"WeSpeaker embedder requires {SAMPLE_RATE} Hz audio, got {sample_rate}" - ) - - opts = knf.FbankOptions() - opts.frame_opts.samp_freq = float(sample_rate) - opts.frame_opts.dither = 0.0 - opts.frame_opts.snip_edges = True - opts.frame_opts.frame_length_ms = 25.0 - opts.frame_opts.frame_shift_ms = 10.0 - opts.mel_opts.num_bins = 80 - opts.energy_floor = 0.0 - opts.use_energy = False - - fbank = knf.OnlineFbank(opts) - scaled = (audio.astype(np.float32) * 32768.0).tolist() - fbank.accept_waveform(float(sample_rate), scaled) - fbank.input_finished() - - frames = [fbank.get_frame(i) for i in range(fbank.num_frames_ready)] - if not frames: - return np.zeros((0, 80), dtype=np.float32) - - feats = np.stack(frames, axis=0).astype(np.float32) - feats = feats - feats.mean(axis=0, keepdims=True) - return feats - - def _get_jsonl_path(audio_path: Path) -> Path: """Generate the corresponding JSONL path.""" return audio_path.with_suffix(".jsonl") @@ -530,6 +450,8 @@ def _failure_reason(exc: Exception) -> str: Provider errors already carry a reason code; anything else is labelled by its exception type. """ + if isinstance(exc, SpeakerAnalyzeError): + return SPEAKER_ANALYSIS_FAILURE_REASON if isinstance(exc, ParakeetProviderError): return exc.reason_code return type(exc).__name__ @@ -546,6 +468,8 @@ def _failure_label(exc: Exception) -> str: guarantee structural instead of a per-exception audit that any new provider error could quietly break. """ + if isinstance(exc, SpeakerAnalyzeError): + return SPEAKER_ANALYSIS_FAILURE_LABEL return type(exc).__name__ @@ -602,114 +526,6 @@ def _build_base_event( return event -def _embed_statements( - audio: np.ndarray, - statements: list[dict], - sample_rate: int, -) -> dict[str, np.ndarray] | None: - """Generate voice embeddings for each statement. - - Args: - audio: Audio buffer (float32, mono) - statements: List of statements - sample_rate: Sample rate in Hz - - Returns: - Dict with embedding data or None on error: - - embeddings: (N, 256) float32 array - - statement_ids: (N,) int32 array of statement IDs - - encoder: 0-d array naming the embedder - """ - import numpy as np - - try: - session = _get_embedder_session() - audio_duration = len(audio) / sample_rate - input_name = session.get_inputs()[0].name - output_name = session.get_outputs()[0].name - - # Filter statements with valid timestamps and sufficient duration - # Defensive: handle None timestamps, clamp to audio bounds - valid_statements = [] - for s in statements: - start = s.get("start") - end = s.get("end") - - # Skip if timestamps are None or invalid - if start is None or end is None: - continue - if not isinstance(start, (int, float)) or not isinstance(end, (int, float)): - continue - - # Clamp to audio bounds - start = max(0.0, min(start, audio_duration)) - end = max(0.0, min(end, audio_duration)) - - # Check duration after clamping - if end - start >= MIN_STATEMENT_DURATION: - valid_statements.append({"id": s["id"], "start": start, "end": end}) - - if not valid_statements: - logging.info("No statements with sufficient duration for embedding") - return None - - logging.info(f"Embedding {len(valid_statements)} statements...") - t0 = time.perf_counter() - - embeddings = [] - statement_ids = [] - durations = [] - skipped = 0 - - for stmt in valid_statements: - start_sample = int(stmt["start"] * sample_rate) - end_sample = int(stmt["end"] * sample_rate) - stmt_audio = audio[start_sample:end_sample] - - # Skip if too short after slicing - if len(stmt_audio) < int(MIN_STATEMENT_DURATION * sample_rate): - skipped += 1 - continue - - try: - feats = _compute_wespeaker_features(stmt_audio, sample_rate) - if feats.shape[0] == 0: - skipped += 1 - continue - emb = session.run([output_name], {input_name: feats[None, :, :]})[0] - embeddings.append(emb[0].astype(np.float32)) - statement_ids.append(stmt["id"]) - durations.append((end_sample - start_sample) / SAMPLE_RATE) - except Exception: - logging.exception( - "wespeaker embedding failed for statement %s", stmt["id"] - ) - skipped += 1 - continue - - embed_time = time.perf_counter() - t0 - - if not embeddings: - logging.warning("No embeddings generated") - return None - - logging.info( - f" Embedded {len(embeddings)} statements " - f"(skipped {skipped}) in {embed_time:.2f}s" - ) - - return { - "embeddings": np.stack(embeddings, axis=0).astype(np.float32), - "statement_ids": np.asarray(statement_ids, dtype=np.int32), - "durations_s": np.asarray(durations, dtype=np.float32), - "encoder": np.array(EMBEDDER_NAME), - } - - except Exception: - logging.exception("failed to load WeSpeaker embedder") - return None - - def _statements_to_jsonl( statements: list[dict], raw_filename: str, @@ -743,7 +559,7 @@ def _statements_to_jsonl( backend: Optional STT backend name (e.g., "parakeet") overlap_fraction: Optional fraction of speech containing overlapping speakers overlap_detector: Optional overlap detector identifier - speaker_evidence: Optional local diarization engagement decision + speaker_evidence: Optional native speaker-evidence decision processing_record: Optional _solstone_processing record sound_tags: Optional ambient sound-tag metadata speaker_analysis_producer: Optional segment-level speaker analysis producer id @@ -813,7 +629,7 @@ def _statements_to_jsonl( if source: entry["source"] = source - # Pass through speaker ID if present from local diarization. + # Pass through speaker ID if present from native speaker analysis. if "speaker" in stmt: entry["speaker"] = stmt["speaker"] @@ -894,7 +710,7 @@ def process_audio( This is the main orchestration function that coordinates: - STT backend dispatch - - Embedding generation + - Native speaker analysis - Output file writing - Event emission @@ -1048,18 +864,11 @@ def process_audio( if suffix.endswith("_audio") and suffix != "audio": source = suffix[:-6] # Remove "_audio" suffix - speaker_analysis_event_fields: dict[str, object] = {} - speaker_analysis_producer: str | None = None - native_completed = False - - from solstone.observe.transcribe.speakers_analyze_seam import ( - EXIT_CONFIG as SPEAKERS_ANALYZE_EXIT_CONFIG, - ) - from solstone.observe.transcribe.speakers_analyze_seam import ( + from solstone.observe.transcribe.speakers_analyze_adapter import ( PRODUCER_ID as SPEAKERS_ANALYZE_PRODUCER_ID, ) - from solstone.observe.transcribe.speakers_analyze_seam import ( - maybe_run_native_speaker_analysis, + from solstone.observe.transcribe.speakers_analyze_adapter import ( + analyze_speakers, ) def native_restored_statements() -> list[dict]: @@ -1069,116 +878,22 @@ def process_audio( return restore_statement_timestamps(statements, reduction) - native_result = maybe_run_native_speaker_analysis( - journal=journal_path, - raw_path=raw_path, - full_audio=audio_buffer, - statement_audio=stt_buffer, - reduced_audio=reduced_audio, - statements_pre_restore=statements, - statements_restored=native_restored_statements, - sample_rate=SAMPLE_RATE, - min_statement_duration=MIN_STATEMENT_DURATION, - ) - if native_result.status == "config_error": - event.update(native_result.event_fields) - _emit_transcribed( - event, - outcome="failed", - timings=timings, - backend=resolved_backend, - model_info=model_info, - backend_config=backend_config, - audio_seconds=audio_seconds, - reduced_seconds=reduced_seconds, - reason=str( - native_result.event_fields.get( - "speaker_analysis_reason", "speaker-analysis-config" - ) - ), - error="speaker_analysis_config", - ) - raise SystemExit(SPEAKERS_ANALYZE_EXIT_CONFIG) - if native_result.status == "accepted": - assert native_result.statements is not None - assert native_result.speaker_evidence is not None - assert native_result.overlap_fraction is not None - statements = native_result.statements - embeddings_data = native_result.embeddings_data - speaker_evidence = native_result.speaker_evidence - overlap_fraction_value = native_result.overlap_fraction - speaker_analysis_event_fields.update(native_result.event_fields) - speaker_analysis_producer = SPEAKERS_ANALYZE_PRODUCER_ID - native_completed = True - elif native_result.status == "fallback": - speaker_analysis_event_fields.update(native_result.event_fields) - - if not native_completed: - # Generate embeddings before timestamp restoration - # Use reduced audio buffer if available for consistent timestamps - with timings.time("embed"): - embeddings_data = _embed_statements(stt_buffer, statements, SAMPLE_RATE) - from solstone.observe.transcribe.overlap import ( - compute_overlap_and_logprobs, - decide_speaker_evidence, + with timings.time("speakers_analyze"): + speaker_result = analyze_speakers( + raw_path=raw_path, + full_audio=audio_buffer, + statement_audio=stt_buffer, + reduced_audio=reduced_audio, + statements_pre_restore=statements, + statements_restored=native_restored_statements, + sample_rate=SAMPLE_RATE, + min_statement_duration=MIN_STATEMENT_DURATION, ) - - with timings.time("overlap"): - overlap_result = compute_overlap_and_logprobs(audio_buffer) - speaker_evidence = decide_speaker_evidence( - overlap_result.overlap_fraction, - overlap_result.window_stats, - ) - overlap_fraction_value = overlap_result.overlap_fraction - pyannote_logprobs = overlap_result.avg_log_probs - - # Restore original timestamps if audio was reduced. - if reduction: - from solstone.observe.vad import restore_statement_timestamps - - statements = restore_statement_timestamps(statements, reduction) - logging.info( - f" Restored timestamps from reduced audio " - f"({reduction.reduced_duration:.1f}s -> {reduction.original_duration:.1f}s)" - ) - - # Local speaker diarization for backends that produce no speaker labels. - # Reuse the pyannote log-probs computed above so the diarizer skips its - # own pyannote pass when the speaker-evidence gate engages it. - if speaker_evidence.speaker_evidence != "multi": - logging.info( - " Skipping diarization: speaker_evidence=%s overlap=%.2f " - "multi_window_fraction=%.2f", - speaker_evidence.speaker_evidence, - overlap_fraction_value, - speaker_evidence.multi_window_fraction, - ) - else: - try: - from solstone.observe.transcribe.diarize import diarize_auto_k - - with timings.time("diarize"): - labels = diarize_auto_k( - raw_path, - statements, - avg_log_probs=pyannote_logprobs, - audio=audio_buffer, - ) - assigned = 0 - for stmt, lbl in zip(statements, labels): - if lbl is not None: - stmt["speaker"] = lbl - assigned += 1 - logging.info( - " Local diarization: %d/%d sentences labeled (overlap=%.2f)", - assigned, - len(statements), - overlap_fraction_value, - ) - except Exception: - logging.exception( - "Local diarization failed; speaker labels will be absent" - ) + statements = speaker_result.statements + embeddings_data = speaker_result.embeddings_data + speaker_evidence = speaker_result.speaker_evidence + overlap_fraction_value = speaker_result.overlap_fraction + speaker_analysis_producer = SPEAKERS_ANALYZE_PRODUCER_ID # Convert to JSONL format (now with original timestamps) raw_filename = f"{raw_path.stem}{raw_path.suffix}" @@ -1251,7 +966,6 @@ def process_audio( except ValueError: rel_output = jsonl_path event["output"] = rel_output - event.update(speaker_analysis_event_fields) _emit_transcribed( event, @@ -1328,12 +1042,36 @@ def process_audio( ) raise SystemExit(EXIT_PROVIDER_BLOCKED) from e + except SpeakerAnalyzeError as e: + logging.error( + "Native speaker analysis failed for %s: %s", + raw_path, + e, + exc_info=True, + ) + try: + event = _build_base_event(raw_path, vad_result, segment, observer) + event.update(e.event_fields()) + _emit_transcribed( + event, + outcome="failed", + timings=timings, + backend=resolved_backend, + model_info=model_info, + backend_config=backend_config, + audio_seconds=audio_seconds, + reduced_seconds=reduced_seconds, + reason=_failure_reason(e), + error=_failure_label(e), + ) + except Exception: + logging.exception("Failed to emit transcription failure event") + raise + except Exception as e: logging.error(f"Failed to transcribe {raw_path}: {e}", exc_info=True) try: event = _build_base_event(raw_path, vad_result, segment, observer) - if "speaker_analysis_event_fields" in locals(): - event.update(speaker_analysis_event_fields) _emit_transcribed( event, outcome="failed", @@ -1536,7 +1274,7 @@ def _process_one( def main(): parser = argparse.ArgumentParser( - description="Transcribe audio files using pluggable STT with sentence embeddings" + description="Transcribe audio files using pluggable STT and native speaker analysis" ) parser.add_argument( "audio_path", @@ -1576,6 +1314,15 @@ def main(): transcribe_config, journal_config=config, ) + from solstone.think.speakers_analyze_installation import ( + check_speakers_analyze_installation, + ) + + speakers_installation = check_speakers_analyze_installation() + if not speakers_installation.ok: + print(speakers_installation.message, file=sys.stderr) + logging.error(speakers_installation.message) + raise SystemExit(SPEAKERS_ANALYZE_EX_CONFIG) if args.all: processed = 0 @@ -1602,6 +1349,13 @@ def main(): default_backend, ) processed += 1 + except SpeakerAnalyzeError: + logging.error( + "Native speaker analysis failed for %s", + audio_file, + exc_info=True, + ) + failed += 1 except SystemExit as exit_signal: # A provider deferral is per-file, not per-batch: the audio is # preserved for the next run and the batch moves on. SystemExit @@ -1611,12 +1365,6 @@ def main(): raise logging.info("Deferred (provider not ready): %s", audio_file) deferred += 1 - except Exception: - logging.error( - f"Failed to transcribe {audio_file}", exc_info=True - ) - failed += 1 - summary = f"{processed} processed, {skipped} skipped (already transcribed)" if deferred: summary += f", {deferred} deferred (provider not ready, will retry)" @@ -1653,7 +1401,10 @@ def main(): f"but parent is: {audio_path.parent.name}" ) - _process_one(audio_path, args, transcribe_config, default_backend) + try: + _process_one(audio_path, args, transcribe_config, default_backend) + except SpeakerAnalyzeError as exc: + raise SystemExit(1) from exc if __name__ == "__main__": diff --git a/solstone/observe/transcribe/speakers_analyze_adapter.py b/solstone/observe/transcribe/speakers_analyze_adapter.py new file mode 100644 index 000000000..ec1b11343 --- /dev/null +++ b/solstone/observe/transcribe/speakers_analyze_adapter.py @@ -0,0 +1,739 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Native speakers-analyze adapter for transcribed audio segments.""" + +from __future__ import annotations + +import json +import math +import os +import selectors +import shutil +import subprocess +import tempfile +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +from solstone.apps.speakers.encoder_config import ( + ENCODER_ID, + WESPEAKER_EMBEDDING_WIDTH, +) +from solstone.apps.speakers.evidence import ( + VALID_SPEAKER_EVIDENCE_DECISIONS, + SpeakerEvidenceDecision, +) +from solstone.observe.transcribe.speakers_analyze_errors import SpeakerAnalyzeError +from solstone.think.model_assets import ( + resolve_pyannote_segmentation_model, + resolve_wespeaker_model, +) +from solstone.think.speakers_analyze_installation import ( + speakers_analyze_path_for_executable, +) + +REQUEST_SCHEMA = "solstone-speaker-analyze-request-v1" +RESPONSE_SCHEMA = "solstone-speaker-analyze-response-v1" +ERROR_SCHEMA = "solstone-speaker-analyze-error-v1" +PRODUCER_ID = "solstone-core-speakers-analyze-v1" +EXIT_UNAVAILABLE = 69 +TEMP_ROOT = Path("/var/tmp") +TEMP_PREFIX = "solstone-speakers-analyze-" +TEMP_DIR_MODE = 0o700 +TEMP_FILE_MODE = 0o600 + +RestoredStatements = list[dict[str, Any]] | Callable[[], list[dict[str, Any]]] +HelperLocator = Callable[[], Path] +ModelPathResolver = Callable[[], tuple[Path, Path]] +TempDirFactory = Callable[[Path], Path] +HelperInvoker = Callable[[list[str], str, Path], "HelperInvocationResult"] + + +@dataclass(frozen=True) +class SpeakerAnalyzeResult: + statements: list[dict[str, Any]] + embeddings_data: dict[str, np.ndarray] | None + speaker_evidence: SpeakerEvidenceDecision + overlap_fraction: float + statement_labels: list[int | None] | None + + +@dataclass(frozen=True) +class HelperInvocationResult: + returncode: int + stdout: str + stderr: str + + +@dataclass(frozen=True) +class SpeakersAnalyzeBudget: + timeout_s: float = 2400.0 + stdout_limit_bytes: int = 1024 * 1024 + stderr_limit_bytes: int = 64 * 1024 + terminate_grace_s: float = 5.0 + kill_grace_s: float = 5.0 + + +DEFAULT_INVOCATION_BUDGET = SpeakersAnalyzeBudget() + + +def create_speakers_analyze_temp_dir(raw_path: Path) -> Path: + day = _safe_temp_part( + raw_path.parent.parent.parent.name if raw_path.parents else "x" + ) + segment = _safe_temp_part(raw_path.parent.name) + source = _safe_temp_part(raw_path.stem) + prefix = f"{TEMP_PREFIX}{day}-{segment}-{source}-{os.getpid()}-" + path = Path(tempfile.mkdtemp(prefix=prefix, dir=TEMP_ROOT)) + path.chmod(TEMP_DIR_MODE) + return path + + +def sweep_stale_speakers_analyze_dirs(max_age_seconds: int = 86400) -> int: + swept = 0 + now = time.time() + for path in TEMP_ROOT.glob(f"{TEMP_PREFIX}*"): + if not path.is_dir(): + continue + try: + age_seconds = now - path.stat().st_mtime + except OSError: + continue + if age_seconds <= max_age_seconds: + continue + shutil.rmtree(path, ignore_errors=True) + if not path.exists(): + swept += 1 + return swept + + +def analyze_speakers( + *, + raw_path: Path, + full_audio: np.ndarray, + statement_audio: np.ndarray, + reduced_audio: np.ndarray | None, + statements_pre_restore: list[dict[str, Any]], + statements_restored: RestoredStatements, + sample_rate: int, + min_statement_duration: float, + helper_locator: HelperLocator = speakers_analyze_path_for_executable, + helper_invoker: HelperInvoker = lambda argv, stdin, path: ( + invoke_speakers_analyze_helper(argv, stdin, path) + ), + model_path_resolver: ModelPathResolver = lambda: ( + resolve_wespeaker_model(), + resolve_pyannote_segmentation_model(), + ), + temp_dir_factory: TempDirFactory = create_speakers_analyze_temp_dir, +) -> SpeakerAnalyzeResult: + temp_dir: Path | None = None + try: + wespeaker_model_path, pyannote_model_path = model_path_resolver() + temp_dir = temp_dir_factory(raw_path) + restored_statements = _realize_statements_restored(statements_restored) + request, payload_path = _build_request( + temp_dir=temp_dir, + full_audio=full_audio, + statement_audio=statement_audio, + reduced_audio=reduced_audio, + statements_pre_restore=statements_pre_restore, + statements_restored=restored_statements, + sample_rate=sample_rate, + wespeaker_model_path=wespeaker_model_path, + pyannote_model_path=pyannote_model_path, + ) + request_ids = [int(statement["id"]) for statement in statements_pre_restore] + expected_statement_ids = _python_admitted_statement_ids( + statement_audio, + statements_pre_restore, + sample_rate=sample_rate, + min_statement_duration=min_statement_duration, + ) + completed = helper_invoker( + [str(helper_locator())], + json.dumps(request, sort_keys=True), + raw_path, + ) + _raise_for_returncode(raw_path, completed) + try: + response = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise SpeakerAnalyzeError( + path=raw_path, stage="parse", reason="malformed-response" + ) from exc + return _accepted_result_from_response( + response, + raw_path=raw_path, + payload_path=payload_path, + statements_restored=restored_statements, + expected_statement_ids=expected_statement_ids, + request_statement_ids=request_ids, + sample_rate=sample_rate, + ) + except SpeakerAnalyzeError: + raise + except NativePayloadError as exc: + raise SpeakerAnalyzeError( + path=raw_path, stage=exc.stage, reason=exc.reason + ) from exc + except OSError as exc: + raise SpeakerAnalyzeError( + path=raw_path, stage="request", reason=type(exc).__name__.lower() + ) from exc + except Exception as exc: + raise SpeakerAnalyzeError( + path=raw_path, stage="request", reason=type(exc).__name__.lower() + ) from exc + finally: + if temp_dir is not None: + shutil.rmtree(temp_dir, ignore_errors=True) + + +def invoke_speakers_analyze_helper( + argv: list[str], + stdin_text: str, + raw_path: Path, + *, + budget: SpeakersAnalyzeBudget = DEFAULT_INVOCATION_BUDGET, + popen_factory=subprocess.Popen, + selector_factory=selectors.DefaultSelector, + clock: Callable[[], float] = time.monotonic, +) -> HelperInvocationResult: + try: + proc = popen_factory( + argv, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except OSError as exc: + raise SpeakerAnalyzeError( + path=raw_path, stage="invoke", reason=type(exc).__name__.lower() + ) from exc + assert proc.stdin is not None + assert proc.stdout is not None + assert proc.stderr is not None + try: + proc.stdin.write(stdin_text.encode("utf-8")) + proc.stdin.close() + except BrokenPipeError: + pass + + stdout = bytearray() + stderr = bytearray() + deadline = clock() + budget.timeout_s + with selector_factory() as selector: + os.set_blocking(proc.stdout.fileno(), False) + os.set_blocking(proc.stderr.fileno(), False) + selector.register(proc.stdout, selectors.EVENT_READ, "stdout") + selector.register(proc.stderr, selectors.EVENT_READ, "stderr") + while selector.get_map(): + remaining = deadline - clock() + if remaining <= 0: + _terminate_and_reap(proc, budget) + raise SpeakerAnalyzeError( + path=raw_path, + stage="invoke", + reason="timeout", + native_exit_code=proc.returncode, + ) + for key, _events in selector.select(timeout=min(0.1, remaining)): + stream_name = key.data + chunk = os.read(key.fileobj.fileno(), 8192) + if not chunk: + selector.unregister(key.fileobj) + continue + target = stdout if stream_name == "stdout" else stderr + limit = ( + budget.stdout_limit_bytes + if stream_name == "stdout" + else budget.stderr_limit_bytes + ) + if len(target) + len(chunk) > limit: + _terminate_and_reap(proc, budget) + raise SpeakerAnalyzeError( + path=raw_path, + stage="invoke", + reason=f"{stream_name}-too-large", + native_exit_code=proc.returncode, + ) + target.extend(chunk) + returncode = proc.wait() + return HelperInvocationResult( + returncode=returncode, + stdout=stdout.decode("utf-8", errors="replace"), + stderr=stderr.decode("utf-8", errors="replace"), + ) + + +def _terminate_and_reap(proc, budget: SpeakersAnalyzeBudget) -> None: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=budget.terminate_grace_s) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=budget.kill_grace_s) + + +def _raise_for_returncode(raw_path: Path, completed: HelperInvocationResult) -> None: + if completed.returncode == 0: + return + if completed.returncode < 0: + reason = f"signal-{abs(completed.returncode)}" + else: + reason = _helper_reason(completed.stderr) or f"exit-{completed.returncode}" + raise SpeakerAnalyzeError( + path=raw_path, + stage="invoke", + reason=reason, + native_exit_code=completed.returncode, + ) + + +def _realize_statements_restored( + statements_restored: RestoredStatements, +) -> list[dict[str, Any]]: + if callable(statements_restored): + return statements_restored() + return statements_restored + + +def _build_request( + *, + temp_dir: Path, + full_audio: np.ndarray, + statement_audio: np.ndarray, + reduced_audio: np.ndarray | None, + statements_pre_restore: list[dict[str, Any]], + statements_restored: list[dict[str, Any]], + sample_rate: int, + wespeaker_model_path: Path, + pyannote_model_path: Path, +) -> tuple[dict[str, Any], Path]: + full_audio_path = temp_dir / "full-audio.f32le" + _write_f32le(full_audio_path, full_audio) + reduced_audio_path: Path | None = None + if reduced_audio is not None: + reduced_audio_path = temp_dir / "reduced-audio.f32le" + _write_f32le(reduced_audio_path, reduced_audio) + payload_path = temp_dir / "statement-embeddings.f32le" + + statement_spans = _spans_from_statements(statements_pre_restore) + diarization_spans = _spans_from_statements(statements_restored) + _ensure_span_parity(statement_spans, diarization_spans) + + request: dict[str, Any] = { + "schema": REQUEST_SCHEMA, + "sample_rate_hz": sample_rate, + "full_audio_f32le_path": str(full_audio_path), + "models": { + "pyannote_segmentation_onnx_path": str(pyannote_model_path), + "wespeaker_onnx_path": str(wespeaker_model_path), + }, + "output_payload_f32le_path": str(payload_path), + "interval_embedding_payload_f32le_path": None, + "statement_embedding": {"spans": statement_spans}, + "diarization": {"spans": diarization_spans}, + } + if reduced_audio_path is not None: + request["reduced_audio_f32le_path"] = str(reduced_audio_path) + return request, payload_path + + +def _write_f32le(path: Path, audio: np.ndarray) -> None: + data = np.asarray(audio, dtype=" list[dict[str, Any]]: + spans: list[dict[str, Any]] = [] + seen: set[int] = set() + for statement in statements: + statement_id = int(statement["id"]) + if statement_id in seen: + raise NativePayloadError("request", "duplicate-statement-id") + seen.add(statement_id) + spans.append( + { + "statement_id": statement_id, + "start_s": _optional_float(statement.get("start")), + "end_s": _optional_float(statement.get("end")), + } + ) + return spans + + +def _optional_float(value: object) -> float | None: + if isinstance(value, bool) or value is None: + return None + if not isinstance(value, int | float): + return None + value = float(value) + return value if math.isfinite(value) else None + + +def _ensure_span_parity( + statement_spans: list[dict[str, Any]], diarization_spans: list[dict[str, Any]] +) -> None: + if len(statement_spans) != len(diarization_spans): + raise NativePayloadError("request", "span-parity-length") + for left, right in zip(statement_spans, diarization_spans): + if left["statement_id"] != right["statement_id"]: + raise NativePayloadError("request", "span-parity-statement-id") + + +def _python_admitted_statement_ids( + audio: np.ndarray, + statements: list[dict[str, Any]], + *, + sample_rate: int, + min_statement_duration: float, +) -> list[int]: + audio_duration = len(audio) / sample_rate + admitted: list[int] = [] + for statement in statements: + start = statement.get("start") + end = statement.get("end") + if start is None or end is None: + continue + if not isinstance(start, int | float) or not isinstance(end, int | float): + continue + start = max(0.0, min(float(start), audio_duration)) + end = max(0.0, min(float(end), audio_duration)) + if end - start < min_statement_duration: + continue + start_sample = int(start * sample_rate) + end_sample = int(end * sample_rate) + if end_sample - start_sample < int(min_statement_duration * sample_rate): + continue + admitted.append(int(statement["id"])) + if len(admitted) != len(set(admitted)): + raise NativePayloadError("request", "duplicate-admitted-statement-id") + return admitted + + +def _accepted_result_from_response( + response: object, + *, + raw_path: Path, + payload_path: Path, + statements_restored: list[dict[str, Any]], + expected_statement_ids: list[int], + request_statement_ids: list[int], + sample_rate: int, +) -> SpeakerAnalyzeResult: + if not isinstance(response, dict): + raise NativePayloadError("parse", "response-not-object") + if response.get("schema") != RESPONSE_SCHEMA: + raise NativePayloadError("parse", "unknown-schema") + if response.get("sample_rate_hz") != sample_rate: + raise NativePayloadError("payload", "sample-rate-mismatch") + + for key in ( + "inputs", + "statement_embeddings", + "pyannote", + "evidence", + "diarization", + ): + if key not in response: + raise NativePayloadError("payload", f"missing-{_reason_key(key)}") + _validate_inputs(response, request_statement_ids) + _validate_pyannote(response) + + statement_embeddings = _required_object(response, "statement_embeddings") + _require_equal(statement_embeddings, "audio_buffer", {"full", "reduced"}) + _require_value(statement_embeddings, "encoder", ENCODER_ID) + _require_value(statement_embeddings, "payload_format", "f32le") + _require_value(statement_embeddings, "payload_path", str(payload_path)) + _require_value(statement_embeddings, "dtype", "float32") + statement_ids = _required_int_list(statement_embeddings, "statement_ids") + if len(statement_ids) != len(set(statement_ids)): + raise NativePayloadError("payload", "duplicate-statement-id") + if any( + statement_id not in set(request_statement_ids) for statement_id in statement_ids + ): + raise NativePayloadError("payload", "foreign-statement-id") + if statement_ids != expected_statement_ids: + raise NativePayloadError("payload", "statement-id-divergence") + durations_s = _required_float_list(statement_embeddings, "durations_s") + if any(not math.isfinite(duration) for duration in durations_s): + raise NativePayloadError("payload", "nonfinite-duration") + rows = len(statement_ids) + if len(durations_s) != rows: + raise NativePayloadError("payload", "duration-count-mismatch") + shape = statement_embeddings.get("shape") + if shape != [rows, WESPEAKER_EMBEDDING_WIDTH]: + raise NativePayloadError("payload", "embedding-shape-mismatch") + expected_bytes = rows * WESPEAKER_EMBEDDING_WIDTH * 4 + if _required_int(statement_embeddings, "byte_count") != expected_bytes: + raise NativePayloadError("payload", "embedding-byte-count-mismatch") + if _required_int(statement_embeddings, "admitted_count") != rows: + raise NativePayloadError("payload", "embedding-admitted-count-mismatch") + skipped_count = _required_int(statement_embeddings, "skipped_count") + if skipped_count != len(request_statement_ids) - rows: + raise NativePayloadError("payload", "embedding-skipped-count-mismatch") + + payload_bytes = _read_payload_bytes(payload_path, expected_bytes) + embeddings_data: dict[str, np.ndarray] | None + if rows > 0: + embeddings = np.frombuffer(payload_bytes, dtype=" None: + inputs = _required_object(response, "inputs") + for section_name in ("statement_embedding", "diarization"): + section = _required_object(inputs, section_name) + if _required_int_list(section, "statement_ids") != request_statement_ids: + raise NativePayloadError( + "payload", f"{_reason_key(section_name)}-input-id-mismatch" + ) + spans_s = section.get("spans_s") + if not isinstance(spans_s, list) or len(spans_s) != len(request_statement_ids): + raise NativePayloadError( + "payload", f"invalid-{_reason_key(section_name)}-spans" + ) + for span in spans_s: + if not isinstance(span, list) or len(span) != 2: + raise NativePayloadError( + "payload", f"invalid-{_reason_key(section_name)}-spans" + ) + for value in span: + if value is None: + continue + if ( + isinstance(value, bool) + or not isinstance(value, int | float) + or not math.isfinite(float(value)) + ): + raise NativePayloadError( + "payload", f"invalid-{_reason_key(section_name)}-spans" + ) + + +def _validate_pyannote(response: dict[str, Any]) -> None: + pyannote = _required_object(response, "pyannote") + window_stats = pyannote.get("window_stats") + if not isinstance(window_stats, list): + raise NativePayloadError("payload", "invalid-pyannote-window-stats") + for item in window_stats: + if not isinstance(item, dict): + raise NativePayloadError("payload", "invalid-pyannote-window-stats") + for key in ("speech_frames", "active_slot_count", "overlap_frames"): + value = item.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise NativePayloadError("payload", "invalid-pyannote-window-stats") + + +def _validate_diarization_keys(response: dict[str, Any]) -> None: + diarization = _required_object(response, "diarization") + for key in ( + "intervals", + "valid_intervals", + "interval_embeddings", + "cluster_labels", + "statement_labels", + "silhouette_k", + "effective_k", + ): + if key not in diarization: + raise NativePayloadError( + "payload", f"missing-diarization-{_reason_key(key)}" + ) + if diarization["interval_embeddings"] is not None: + raise NativePayloadError("payload", "unexpected-interval-embeddings") + + +def _required_object(container: dict[str, Any], key: str) -> dict[str, Any]: + value = container.get(key) + if not isinstance(value, dict): + raise NativePayloadError("payload", f"missing-{_reason_key(key)}") + return value + + +def _required_int(container: dict[str, Any], key: str) -> int: + value = container.get(key) + if isinstance(value, bool) or not isinstance(value, int): + raise NativePayloadError("payload", f"invalid-{_reason_key(key)}") + return int(value) + + +def _required_int_list(container: dict[str, Any], key: str) -> list[int]: + value = container.get(key) + if not isinstance(value, list) or any( + isinstance(item, bool) or not isinstance(item, int) for item in value + ): + raise NativePayloadError("payload", f"invalid-{_reason_key(key)}") + return [int(item) for item in value] + + +def _required_float_list(container: dict[str, Any], key: str) -> list[float]: + value = container.get(key) + if not isinstance(value, list) or any( + isinstance(item, bool) or not isinstance(item, int | float) for item in value + ): + raise NativePayloadError("payload", f"invalid-{_reason_key(key)}") + return [float(item) for item in value] + + +def _required_str(container: dict[str, Any], key: str) -> str: + value = container.get(key) + if not isinstance(value, str): + raise NativePayloadError("payload", f"invalid-{_reason_key(key)}") + return value + + +def _fraction(container: dict[str, Any], key: str) -> float: + value = container.get(key) + if isinstance(value, bool) or not isinstance(value, int | float): + raise NativePayloadError("payload", f"invalid-{_reason_key(key)}") + value = float(value) + if not math.isfinite(value) or value < 0.0 or value > 1.0: + raise NativePayloadError("payload", f"invalid-{_reason_key(key)}") + return value + + +def _require_value(container: dict[str, Any], key: str, expected: object) -> None: + if container.get(key) != expected: + raise NativePayloadError("payload", f"invalid-{_reason_key(key)}") + + +def _require_equal( + container: dict[str, Any], key: str, expected_values: set[str] +) -> None: + value = container.get(key) + if value not in expected_values: + raise NativePayloadError("payload", f"invalid-{_reason_key(key)}") + + +def _reason_key(key: str) -> str: + return key.replace("_", "-") + + +def _read_payload_bytes(path: Path, expected_bytes: int) -> bytes: + try: + actual_bytes = path.stat().st_size + except OSError as exc: + raise NativePayloadError("payload", "embedding-payload-missing") from exc + if actual_bytes != expected_bytes: + raise NativePayloadError("payload", "embedding-payload-size-mismatch") + return path.read_bytes() + + +def _statement_labels(response: dict[str, Any]) -> list[int | None] | None: + diarization = _required_object(response, "diarization") + value = diarization.get("statement_labels") + if value is None: + return None + if not isinstance(value, list): + raise NativePayloadError("payload", "invalid-statement-labels") + labels: list[int | None] = [] + for item in value: + if item is None: + labels.append(None) + elif isinstance(item, bool) or not isinstance(item, int) or int(item) <= 0: + raise NativePayloadError("payload", "invalid-statement-labels") + else: + labels.append(int(item)) + return labels + + +def _helper_reason(stderr: str) -> str | None: + for line in stderr.splitlines(): + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if ( + isinstance(payload, dict) + and payload.get("schema") == ERROR_SCHEMA + and isinstance(payload.get("reason"), str) + ): + return str(payload["reason"]) + return None + + +def _safe_temp_part(value: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in value) + return cleaned[:80] or "x" + + +class NativePayloadError(RuntimeError): + def __init__(self, stage: str, reason: str) -> None: + super().__init__(reason) + self.stage = stage + self.reason = reason + + +__all__ = [ + "DEFAULT_INVOCATION_BUDGET", + "EXIT_UNAVAILABLE", + "PRODUCER_ID", + "RESPONSE_SCHEMA", + "SpeakerAnalyzeResult", + "SpeakersAnalyzeBudget", + "analyze_speakers", + "create_speakers_analyze_temp_dir", + "invoke_speakers_analyze_helper", + "sweep_stale_speakers_analyze_dirs", +] diff --git a/solstone/observe/transcribe/speakers_analyze_errors.py b/solstone/observe/transcribe/speakers_analyze_errors.py new file mode 100644 index 000000000..eeda2ee0b --- /dev/null +++ b/solstone/observe/transcribe/speakers_analyze_errors.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Typed native speaker-analysis failures.""" + +from __future__ import annotations + +import re +from pathlib import Path + +SPEAKER_ANALYSIS_FAILURE_PATH = "native" +SPEAKER_ANALYSIS_FAILURE_REASON = "speaker_analysis_native_failure" +SPEAKER_ANALYSIS_FAILURE_LABEL = "speaker-analysis-native-failure" +_REASON_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") + + +class SpeakerAnalyzeError(RuntimeError): + """Content-free attribution for a failed native speaker-analysis attempt.""" + + def __init__( + self, + *, + path: Path, + stage: str, + reason: str, + native_exit_code: int | None = None, + ) -> None: + safe_reason = ( + reason if _REASON_RE.fullmatch(reason) else "invalid-helper-reason" + ) + super().__init__(f"speaker analysis failed: {stage}/{safe_reason}") + self.path = Path(path) + self.stage = stage + self.reason = safe_reason + self.native_exit_code = native_exit_code + + def event_fields(self) -> dict[str, object]: + fields: dict[str, object] = { + "speaker_analysis_failure_path": SPEAKER_ANALYSIS_FAILURE_PATH, + "speaker_analysis_failure_stage": self.stage, + "speaker_analysis_failure_reason": self.reason, + } + if self.native_exit_code is not None: + fields["speaker_analysis_failure_native_exit_code"] = self.native_exit_code + return fields + + +__all__ = [ + "SPEAKER_ANALYSIS_FAILURE_LABEL", + "SPEAKER_ANALYSIS_FAILURE_PATH", + "SPEAKER_ANALYSIS_FAILURE_REASON", + "SpeakerAnalyzeError", +] diff --git a/solstone/observe/transcribe/speakers_analyze_seam.py b/solstone/observe/transcribe/speakers_analyze_seam.py deleted file mode 100644 index 4523a788f..000000000 --- a/solstone/observe/transcribe/speakers_analyze_seam.py +++ /dev/null @@ -1,680 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Config-gated native speaker analysis for transcribed audio segments. - -This seam is bounded migration scaffolding, not a compatibility shim. It lets a -configured journal route speaker embeddings, evidence, and local diarization -through `solstone-core-speakers-analyze` while the absent key and explicit -`python` path remain byte-for-byte Python. The selection key remains out of -journal_default.json because this is a two-release migration control: flip the -absent-key default in 1.0.19, then delete the Python orchestration path and key -in 1.0.20. - -Only `core.speakers_analyze` exists. A second decline key would only spend a -subprocess and ONNX model load on solo recordings to reach the same not-multi -answer both implementations already produce. Native evidence-gate decline is -therefore fixed: accept the native embeddings/evidence and write no labels. - -Real-helper end-to-end proof is VPE-direct post-ship. Unit tests use an injected -runner; helper presence in unit tests must not be redefined as a stubbed green -path. -""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import tempfile -import time -from collections.abc import Callable -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Literal - -import numpy as np - -from solstone.apps.speakers.encoder_config import ( - ENCODER_ID, - WESPEAKER_EMBEDDING_WIDTH, -) -from solstone.observe.model_assets import ( - resolve_pyannote_segmentation_model, - resolve_wespeaker_model, -) -from solstone.observe.transcribe.overlap import SpeakerEvidenceDecision -from solstone.think import speakers_analyze_runtime -from solstone.think.journal_config import read_journal_config -from solstone.think.speakers_analyze_handshake import ( - SpeakersAnalyzeHandshakeResult, - check_speakers_analyze_handshake, - speakers_analyze_path_for_executable, -) - -REQUEST_SCHEMA = "solstone-speaker-analyze-request-v1" -RESPONSE_SCHEMA = "solstone-speaker-analyze-response-v1" -ERROR_SCHEMA = "solstone-speaker-analyze-error-v1" -CONFIG_KEY = "core.speakers_analyze" -PRODUCER_ID = "solstone-core-speakers-analyze-v1" -EXIT_CONFIG = 78 -EXIT_UNAVAILABLE = 69 -TEMP_ROOT = Path("/var/tmp") -TEMP_PREFIX = "solstone-speakers-analyze-" -TEMP_DIR_MODE = 0o700 -TEMP_FILE_MODE = 0o600 - -INVALID_SPEAKERS_ANALYZE_MESSAGE = ( - "transcribe speakers analyze selected implementation 'invalid' from config key " - "core.speakers_analyze; found {value!r}; expected 'python' or 'native'. " - "Set core.speakers_analyze to 'python' to revert." -) - -NativeStatus = Literal["python", "accepted", "fallback", "config_error"] -ConfigReader = Callable[[str | Path | None], dict[str, Any]] -HandshakeChecker = Callable[[], SpeakersAnalyzeHandshakeResult] -HelperLocator = Callable[[], Path] -NativeRunner = Callable[..., subprocess.CompletedProcess[Any]] -ModelPathResolver = Callable[[], tuple[Path, Path]] -TempDirFactory = Callable[[Path], Path] -RestoredStatements = list[dict[str, Any]] | Callable[[], list[dict[str, Any]]] - - -def create_speakers_analyze_temp_dir(raw_path: Path) -> Path: - day = _safe_temp_part( - raw_path.parent.parent.parent.name if raw_path.parents else "x" - ) - segment = _safe_temp_part(raw_path.parent.name) - source = _safe_temp_part(raw_path.stem) - prefix = f"{TEMP_PREFIX}{day}-{segment}-{source}-{os.getpid()}-" - path = Path(tempfile.mkdtemp(prefix=prefix, dir=TEMP_ROOT)) - path.chmod(TEMP_DIR_MODE) - return path - - -def _resolve_model_paths() -> tuple[Path, Path]: - return resolve_wespeaker_model(), resolve_pyannote_segmentation_model() - - -@dataclass(frozen=True) -class NativeSpeakerAnalysisResult: - status: NativeStatus - statements: list[dict[str, Any]] | None = None - embeddings_data: dict[str, np.ndarray] | None = None - speaker_evidence: SpeakerEvidenceDecision | None = None - overlap_fraction: float | None = None - event_fields: dict[str, Any] = field(default_factory=dict) - error_message: str | None = None - - -def maybe_run_native_speaker_analysis( - *, - journal: str | Path | None, - raw_path: Path, - full_audio: np.ndarray, - statement_audio: np.ndarray, - reduced_audio: np.ndarray | None, - statements_pre_restore: list[dict[str, Any]], - statements_restored: RestoredStatements, - sample_rate: int, - min_statement_duration: float, - config_reader: ConfigReader = read_journal_config, - handshake_checker: HandshakeChecker = check_speakers_analyze_handshake, - helper_locator: HelperLocator = speakers_analyze_path_for_executable, - native_runner: NativeRunner = subprocess.run, - model_path_resolver: ModelPathResolver = _resolve_model_paths, - temp_dir_factory: TempDirFactory = create_speakers_analyze_temp_dir, - breaker_blocked: Callable[..., tuple[bool, dict[str, Any]]] = ( - speakers_analyze_runtime.native_blocked - ), - record_native_success: Callable[..., dict[str, Any]] = ( - speakers_analyze_runtime.record_native_success - ), - record_native_failure: Callable[..., dict[str, Any]] = ( - speakers_analyze_runtime.record_native_failure - ), -) -> NativeSpeakerAnalysisResult: - """Run the native helper when selected, otherwise return the Python sentinel.""" - selected, error_message = _resolve_config(config_reader(journal)) - if error_message is not None: - return _config_error(error_message, stage="config", reason="invalid-config") - if selected == "python": - return NativeSpeakerAnalysisResult(status="python") - - handshake = handshake_checker() - if handshake.status != "ok": - return _config_error( - handshake.message or f"speakers-analyze handshake {handshake.status}", - stage="handshake", - reason=handshake.status, - ) - - blocked, breaker_record = breaker_blocked(journal_path=journal) - if blocked: - return _fallback( - stage="breaker", - reason="consecutive-native-failures", - degradation="breaker_open", - extra={ - "speaker_analysis_consecutive_failures": breaker_record.get( - "consecutive_failures" - ) - }, - ) - - try: - wespeaker_model_path, pyannote_model_path = model_path_resolver() - temp_dir = temp_dir_factory(raw_path) - except Exception as exc: - record_native_failure( - stage="request", - reason=type(exc).__name__, - native_exit_code=None, - journal_path=journal, - ) - return _fallback(stage="request", reason=type(exc).__name__) - - try: - try: - restored_statements = _realize_statements_restored(statements_restored) - request, payload_path = _build_request( - temp_dir=temp_dir, - full_audio=full_audio, - statement_audio=statement_audio, - reduced_audio=reduced_audio, - statements_pre_restore=statements_pre_restore, - statements_restored=restored_statements, - sample_rate=sample_rate, - wespeaker_model_path=wespeaker_model_path, - pyannote_model_path=pyannote_model_path, - ) - expected_statement_ids = _python_admitted_statement_ids( - statement_audio, - statements_pre_restore, - sample_rate=sample_rate, - min_statement_duration=min_statement_duration, - ) - completed = native_runner( - [str(helper_locator())], - input=json.dumps(request, sort_keys=True), - capture_output=True, - text=True, - check=False, - ) - except OSError as exc: - record_native_failure( - stage="invoke", - reason=type(exc).__name__, - native_exit_code=None, - journal_path=journal, - ) - return _fallback(stage="invoke", reason=type(exc).__name__) - except NativePayloadError as exc: - record_native_failure( - stage=exc.stage, - reason=exc.reason, - native_exit_code=None, - journal_path=journal, - ) - return _fallback(stage=exc.stage, reason=exc.reason) - except Exception as exc: - record_native_failure( - stage="request", - reason=type(exc).__name__, - native_exit_code=None, - journal_path=journal, - ) - return _fallback(stage="request", reason=type(exc).__name__) - - if completed.returncode == EXIT_UNAVAILABLE: - reason = _helper_reason(completed.stderr) or "native-exit-69" - return _config_error( - "solstone-core-speakers-analyze exited 69 " - f"({reason}); set core.speakers_analyze to 'python' to revert.", - stage="invoke", - reason=reason, - native_exit_code=completed.returncode, - ) - if completed.returncode < 0: - reason = f"signal-{abs(completed.returncode)}" - record_native_failure( - stage="invoke", - reason=reason, - native_exit_code=completed.returncode, - journal_path=journal, - ) - return _fallback( - stage="invoke", - reason=reason, - native_exit_code=completed.returncode, - ) - if completed.returncode != 0: - reason = _helper_reason(completed.stderr) or f"exit-{completed.returncode}" - record_native_failure( - stage="invoke", - reason=reason, - native_exit_code=completed.returncode, - journal_path=journal, - ) - return _fallback( - stage="invoke", - reason=reason, - native_exit_code=completed.returncode, - ) - - try: - response = json.loads(completed.stdout) - except json.JSONDecodeError: - record_native_failure( - stage="parse", - reason="malformed-response", - native_exit_code=completed.returncode, - journal_path=journal, - ) - return _fallback(stage="parse", reason="malformed-response") - - try: - accepted = _accepted_result_from_response( - response, - payload_path=payload_path, - statements_restored=restored_statements, - expected_statement_ids=expected_statement_ids, - ) - except NativePayloadError as exc: - record_native_failure( - stage=exc.stage, - reason=exc.reason, - native_exit_code=completed.returncode, - journal_path=journal, - ) - return _fallback(stage=exc.stage, reason=exc.reason) - - record_native_success(journal_path=journal) - return accepted - finally: - shutil.rmtree(temp_dir, ignore_errors=True) - - -def sweep_stale_speakers_analyze_dirs(max_age_seconds: int = 86400) -> int: - swept = 0 - now = time.time() - for path in TEMP_ROOT.glob(f"{TEMP_PREFIX}*"): - if not path.is_dir(): - continue - try: - age_seconds = now - path.stat().st_mtime - except OSError: - continue - if age_seconds <= max_age_seconds: - continue - shutil.rmtree(path, ignore_errors=True) - if not path.exists(): - swept += 1 - return swept - - -def _resolve_config(config: dict[str, Any]) -> tuple[str, str | None]: - core = config.get("core", {}) - if not isinstance(core, dict): - return "invalid", INVALID_SPEAKERS_ANALYZE_MESSAGE.format(value=core) - selected = core.get("speakers_analyze", "python") - if selected not in ("python", "native"): - return "invalid", INVALID_SPEAKERS_ANALYZE_MESSAGE.format(value=selected) - return str(selected), None - - -def _realize_statements_restored( - statements_restored: RestoredStatements, -) -> list[dict[str, Any]]: - if callable(statements_restored): - return statements_restored() - return statements_restored - - -def _build_request( - *, - temp_dir: Path, - full_audio: np.ndarray, - statement_audio: np.ndarray, - reduced_audio: np.ndarray | None, - statements_pre_restore: list[dict[str, Any]], - statements_restored: list[dict[str, Any]], - sample_rate: int, - wespeaker_model_path: Path, - pyannote_model_path: Path, -) -> tuple[dict[str, Any], Path]: - full_audio_path = temp_dir / "full-audio.f32le" - _write_f32le(full_audio_path, full_audio) - reduced_audio_path: Path | None = None - if reduced_audio is not None: - reduced_audio_path = temp_dir / "reduced-audio.f32le" - _write_f32le(reduced_audio_path, reduced_audio) - payload_path = temp_dir / "statement-embeddings.f32le" - - statement_spans = _spans_from_statements(statements_pre_restore) - diarization_spans = _spans_from_statements(statements_restored) - _ensure_span_parity(statement_spans, diarization_spans) - - request: dict[str, Any] = { - "schema": REQUEST_SCHEMA, - "sample_rate_hz": sample_rate, - "full_audio_f32le_path": str(full_audio_path), - "models": { - "pyannote_segmentation_onnx_path": str(pyannote_model_path), - "wespeaker_onnx_path": str(wespeaker_model_path), - }, - "output_payload_f32le_path": str(payload_path), - "interval_embedding_payload_f32le_path": None, - "statement_embedding": {"spans": statement_spans}, - "diarization": {"spans": diarization_spans}, - } - if reduced_audio_path is not None: - request["reduced_audio_f32le_path"] = str(reduced_audio_path) - return request, payload_path - - -def _write_f32le(path: Path, audio: np.ndarray) -> None: - data = np.asarray(audio, dtype=" list[dict[str, Any]]: - spans: list[dict[str, Any]] = [] - for statement in statements: - spans.append( - { - "statement_id": int(statement["id"]), - "start_s": _optional_float(statement.get("start")), - "end_s": _optional_float(statement.get("end")), - } - ) - return spans - - -def _optional_float(value: object) -> float | None: - if isinstance(value, bool) or value is None: - return None - if not isinstance(value, int | float): - return None - return float(value) - - -def _ensure_span_parity( - statement_spans: list[dict[str, Any]], diarization_spans: list[dict[str, Any]] -) -> None: - if len(statement_spans) != len(diarization_spans): - raise NativePayloadError( - stage="request", - reason="span-parity-length", - message="native speaker request span lists differ in length", - ) - for index, (left, right) in enumerate(zip(statement_spans, diarization_spans)): - if left["statement_id"] != right["statement_id"]: - raise NativePayloadError( - stage="request", - reason="span-parity-statement-id", - message=f"native speaker request span id mismatch at index {index}", - ) - - -def _python_admitted_statement_ids( - audio: np.ndarray, - statements: list[dict[str, Any]], - *, - sample_rate: int, - min_statement_duration: float, -) -> list[int]: - audio_duration = len(audio) / sample_rate - admitted: list[int] = [] - for statement in statements: - start = statement.get("start") - end = statement.get("end") - if start is None or end is None: - continue - if not isinstance(start, int | float) or not isinstance(end, int | float): - continue - start = max(0.0, min(float(start), audio_duration)) - end = max(0.0, min(float(end), audio_duration)) - if end - start < min_statement_duration: - continue - start_sample = int(start * sample_rate) - end_sample = int(end * sample_rate) - if end_sample - start_sample < int(min_statement_duration * sample_rate): - continue - admitted.append(int(statement["id"])) - return admitted - - -def _accepted_result_from_response( - response: object, - *, - payload_path: Path, - statements_restored: list[dict[str, Any]], - expected_statement_ids: list[int], -) -> NativeSpeakerAnalysisResult: - if not isinstance(response, dict): - raise NativePayloadError("parse", "response-not-object") - if response.get("schema") != RESPONSE_SCHEMA: - raise NativePayloadError("parse", "unknown-schema") - - statement_embeddings = _required_object(response, "statement_embeddings") - statement_ids = _required_int_list(statement_embeddings, "statement_ids") - if statement_ids != expected_statement_ids: - raise NativePayloadError("payload", "statement-id-divergence") - durations_s = _required_float_list(statement_embeddings, "durations_s") - rows = len(statement_ids) - if len(durations_s) != rows: - raise NativePayloadError("payload", "duration-count-mismatch") - shape = statement_embeddings.get("shape") - if shape != [rows, WESPEAKER_EMBEDDING_WIDTH]: - raise NativePayloadError("payload", "embedding-shape-mismatch") - payload_bytes = _read_payload_bytes(payload_path, rows) - embeddings_data: dict[str, np.ndarray] | None - if rows > 0: - embeddings = np.frombuffer(payload_bytes, dtype=" dict[str, Any]: - value = container.get(key) - if not isinstance(value, dict): - raise NativePayloadError("payload", f"missing-{key}") - return value - - -def _required_int_list(container: dict[str, Any], key: str) -> list[int]: - value = container.get(key) - if not isinstance(value, list) or any( - isinstance(item, bool) or not isinstance(item, int) for item in value - ): - raise NativePayloadError("payload", f"invalid-{key}") - return [int(item) for item in value] - - -def _required_float_list(container: dict[str, Any], key: str) -> list[float]: - value = container.get(key) - if not isinstance(value, list) or any( - isinstance(item, bool) or not isinstance(item, int | float) for item in value - ): - raise NativePayloadError("payload", f"invalid-{key}") - return [float(item) for item in value] - - -def _required_float(container: dict[str, Any], key: str) -> float: - value = container.get(key) - if isinstance(value, bool) or not isinstance(value, int | float): - raise NativePayloadError("payload", f"invalid-{key}") - return float(value) - - -def _required_str(container: dict[str, Any], key: str) -> str: - value = container.get(key) - if not isinstance(value, str): - raise NativePayloadError("payload", f"invalid-{key}") - return value - - -def _read_payload_bytes(path: Path, rows: int) -> bytes: - payload = path.read_bytes() - expected_bytes = rows * WESPEAKER_EMBEDDING_WIDTH * 4 - if len(payload) != expected_bytes: - raise NativePayloadError("payload", "embedding-payload-size-mismatch") - return payload - - -def _statement_labels(response: dict[str, Any]) -> list[int | None] | None: - diarization = _required_object(response, "diarization") - value = diarization.get("statement_labels") - if value is None: - return None - if not isinstance(value, list): - raise NativePayloadError("payload", "invalid-statement-labels") - labels: list[int | None] = [] - for item in value: - if item is None: - labels.append(None) - elif isinstance(item, bool) or not isinstance(item, int): - raise NativePayloadError("payload", "invalid-statement-labels") - else: - labels.append(int(item)) - return labels - - -def _helper_reason(stderr: str) -> str | None: - for line in stderr.splitlines(): - try: - payload = json.loads(line) - except json.JSONDecodeError: - continue - if ( - isinstance(payload, dict) - and payload.get("schema") == ERROR_SCHEMA - and isinstance(payload.get("reason"), str) - ): - return str(payload["reason"]) - return None - - -def _fallback( - *, - stage: str, - reason: str, - degradation: str = "native_failure", - native_exit_code: int | None = None, - extra: dict[str, Any] | None = None, -) -> NativeSpeakerAnalysisResult: - event_fields: dict[str, Any] = { - "speaker_analysis_path": "native_to_python", - "speaker_analysis_degradation": degradation, - "speaker_analysis_stage": stage, - "speaker_analysis_reason": reason, - } - if native_exit_code is not None: - event_fields["speaker_analysis_native_exit_code"] = native_exit_code - if extra: - event_fields.update(extra) - return NativeSpeakerAnalysisResult(status="fallback", event_fields=event_fields) - - -def _config_error( - message: str, - *, - stage: str, - reason: str, - native_exit_code: int | None = None, -) -> NativeSpeakerAnalysisResult: - event_fields = { - "speaker_analysis_path": "native", - "speaker_analysis_degradation": "configuration_error", - "speaker_analysis_stage": stage, - "speaker_analysis_reason": reason, - } - if native_exit_code is not None: - event_fields["speaker_analysis_native_exit_code"] = native_exit_code - return NativeSpeakerAnalysisResult( - status="config_error", - event_fields=event_fields, - error_message=message, - ) - - -def _safe_temp_part(value: str) -> str: - cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in value) - return cleaned[:80] or "x" - - -class NativePayloadError(RuntimeError): - def __init__(self, stage: str, reason: str, message: str | None = None) -> None: - super().__init__(message or reason) - self.stage = stage - self.reason = reason - - -__all__ = [ - "CONFIG_KEY", - "EXIT_CONFIG", - "INVALID_SPEAKERS_ANALYZE_MESSAGE", - "PRODUCER_ID", - "NativeSpeakerAnalysisResult", - "create_speakers_analyze_temp_dir", - "maybe_run_native_speaker_analysis", - "sweep_stale_speakers_analyze_dirs", -] diff --git a/solstone/talent/journal/contract/bundle.json b/solstone/talent/journal/contract/bundle.json index 2baa64865..8a1f3eba2 100644 --- a/solstone/talent/journal/contract/bundle.json +++ b/solstone/talent/journal/contract/bundle.json @@ -123,7 +123,7 @@ "type": "object" }, "speaker_evidence": { - "description": "Windowed slot evidence decision for local diarization engagement. This records engagement, not clustering outcome: a multi value with no integer speaker labels means diarization engaged and clustering produced nothing or failed.", + "description": "Windowed slot evidence decision from native speaker analysis. This records engagement, not clustering outcome: a multi value with no integer speaker labels means diarization engaged and clustering produced nothing or failed.", "enum": [ "single", "multi", diff --git a/solstone/talent/journal/references/captures.md b/solstone/talent/journal/references/captures.md index 8ef114495..f2d167957 100644 --- a/solstone/talent/journal/references/captures.md +++ b/solstone/talent/journal/references/captures.md @@ -186,7 +186,7 @@ Example transcript file: - `start` – timestamp in HH:MM:SS format (required) - `text` – transcribed text (required) - `source` – audio source: "mic" or "sys" (optional) -- `speaker` – speaker identifier, numeric or string (optional, from local diarization) +- `speaker` – speaker identifier, numeric or string (optional, from native speaker analysis) - `corrected` – legacy LLM-corrected version of text, still present in some existing journals - `description` – legacy tone or delivery description, e.g., "enthusiastic", "questioning", still present in some existing journals diff --git a/solstone/think/doctor.py b/solstone/think/doctor.py index f27633432..9a95d5879 100644 --- a/solstone/think/doctor.py +++ b/solstone/think/doctor.py @@ -132,6 +132,9 @@ DEFAULT_STT_READY_CHECK = Check("default_stt_ready", "advisory", ("linux", "darw PARAKEET_CPP_STT_READY_CHECK = Check( "parakeet_cpp_stt_ready", "advisory", ("linux", "darwin") ) +SPEAKERS_ANALYZE_INSTALLATION_CHECK = Check( + "speakers_analyze_installation", "blocker", ("linux", "darwin") +) _PARAKEET_CPP_INSTALL_FIX = ( "parakeet-cpp artifacts are not installed — fetch them with: " "journal install-provider parakeet" @@ -1228,6 +1231,20 @@ def parakeet_cpp_stt_ready_check(args: Args) -> CheckResult: return _parakeet_cpp_ready_result(check) +def speakers_analyze_installation_check(args: Args) -> CheckResult: + del args + from solstone.think.speakers_analyze_installation import ( + SPEAKERS_ANALYZE_REPAIR_TEXT, + check_speakers_analyze_installation, + ) + + check = SPEAKERS_ANALYZE_INSTALLATION_CHECK + result = check_speakers_analyze_installation() + if result.ok: + return make_result(check, "ok", "speakers-analyze installation ready") + return make_result(check, "fail", result.message, SPEAKERS_ANALYZE_REPAIR_TEXT) + + def _make_feature_check( feat_name: str, ) -> tuple[Check, Runner]: @@ -1285,6 +1302,7 @@ JOURNAL_CHECKS: list[tuple[Check, Runner]] = [ (LAUNCHD_STALE_PLIST_CHECK, launchd_stale_plist_check), (DEFAULT_STT_READY_CHECK, default_stt_ready_check), (PARAKEET_CPP_STT_READY_CHECK, parakeet_cpp_stt_ready_check), + (SPEAKERS_ANALYZE_INSTALLATION_CHECK, speakers_analyze_installation_check), (SKILL_STATE_CHECK, skill_state_check), *FEATURE_CHECKS.values(), ] @@ -1303,6 +1321,7 @@ JOURNAL_READINESS_CHECKS: list[tuple[Check, Runner]] = [ *READINESS_CHECKS, (DEFAULT_STT_READY_CHECK, default_stt_ready_check), (PARAKEET_CPP_STT_READY_CHECK, parakeet_cpp_stt_ready_check), + (SPEAKERS_ANALYZE_INSTALLATION_CHECK, speakers_analyze_installation_check), *FEATURE_CHECKS.values(), ] diff --git a/solstone/think/install_models.py b/solstone/think/install_models.py index ddd175b61..f42a5b6d0 100644 --- a/solstone/think/install_models.py +++ b/solstone/think/install_models.py @@ -16,17 +16,17 @@ from importlib import resources from pathlib import Path from typing import Any -from solstone.observe.model_assets import ( - resolve_pyannote_segmentation_model, - resolve_wespeaker_model, -) -from solstone.observe.transcribe.main import ( - PYANNOTE_OVERLAP_MODEL_SHA256, +from solstone.apps.speakers.encoder_config import ( + OVERLAP_DETECTOR_SHA256, WESPEAKER_MODEL_SHA256, ) from solstone.observe.transcribe.parakeet_hints import PACKAGED_COREML_HINT from solstone.observe.utils import compute_file_sha256 from solstone.think import parakeet_readiness +from solstone.think.model_assets import ( + resolve_pyannote_segmentation_model, + resolve_wespeaker_model, +) from solstone.think.parakeet_readiness import ( BACKEND, MODEL_VERSION, @@ -208,7 +208,7 @@ def _disk_full_message(cache_dir: Path) -> str: def _verify_bundled_assets() -> None: for asset_path, expected_sha256 in ( (resolve_wespeaker_model(), WESPEAKER_MODEL_SHA256), - (resolve_pyannote_segmentation_model(), PYANNOTE_OVERLAP_MODEL_SHA256), + (resolve_pyannote_segmentation_model(), OVERLAP_DETECTOR_SHA256), ): try: actual_sha256 = compute_file_sha256(asset_path) diff --git a/solstone/observe/model_assets.py b/solstone/think/model_assets.py similarity index 100% rename from solstone/observe/model_assets.py rename to solstone/think/model_assets.py diff --git a/solstone/think/speakers_analyze_handshake.py b/solstone/think/speakers_analyze_handshake.py deleted file mode 100644 index 2d70e8ba3..000000000 --- a/solstone/think/speakers_analyze_handshake.py +++ /dev/null @@ -1,151 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Install checks for the solstone-core-speakers-analyze helper binary.""" - -from __future__ import annotations - -import os -import sys -from dataclasses import dataclass -from importlib.metadata import PackageNotFoundError -from importlib.metadata import version as distribution_version -from pathlib import Path -from typing import Callable, Literal - -from packaging import tags - -from solstone.think import probe - -HELPER_DIST_NAME = "solstone-core-speakers-analyze" -HELPER_BINARY_NAME = "solstone-core-speakers-analyze" -ROOT_DIST_NAME = "solstone" - -SpeakersAnalyzeStatus = Literal["ok", "missing", "non-executable", "incompatible"] - - -@dataclass(frozen=True) -class SpeakersAnalyzeHandshakeResult: - status: SpeakersAnalyzeStatus - message: str | None = None - - -def speakers_analyze_path_for_executable(executable: str | Path | None = None) -> Path: - return Path(executable or sys.executable).with_name(HELPER_BINARY_NAME) - - -def _packaging_platform_tags() -> set[str]: - return {tag.platform for tag in tags.sys_tags()} - - -def runtime_has_speakers_analyze_wheel_coverage( - *, - platform_reader: Callable[ - [], probe.CorePlatform - ] = probe.current_solstone_core_platform, - platform_tag_reader: Callable[[], set[str]] = _packaging_platform_tags, -) -> bool: - """Return whether this runtime can install the packaged speakers helper wheel.""" - platform_tuple = platform_reader() - if platform_tuple not in probe.SOLSTONE_CORE_SPEAKERS_ANALYZE_COVERED_PLATFORMS: - return False - expected_platforms = probe.SOLSTONE_CORE_SPEAKERS_ANALYZE_PLATFORM_TAGS.get( - platform_tuple - ) - if expected_platforms is None: - return False - return not set(expected_platforms.split(".")).isdisjoint(platform_tag_reader()) - - -def check_speakers_analyze_handshake( - *, - executable: str | Path | None = None, - version_reader: Callable[[str], str] = distribution_version, - platform_reader: Callable[ - [], probe.CorePlatform - ] = probe.current_solstone_core_platform, - platform_tag_reader: Callable[[], set[str]] = _packaging_platform_tags, - executable_predicate: Callable[[Path], bool] = lambda path: os.access( - path, os.X_OK - ), -) -> SpeakersAnalyzeHandshakeResult: - """Check helper metadata, platform coverage, path presence, and execute bit. - - The helper rejects every argument (core/crates/solstone-core-speakers-analyze/ - src/lib.rs:71-79), so binary-vs-distribution version skew is unprobeable. - The version lockstep check is still sound: scripts/render_packaging.py rewrites - every leaf pyproject from one version source (_speakers_analyze_leaf_path at - :80-81, _rewrite_speakers_analyze_leaf at :118, and the rewrite map at - :374-376). A distribution-version mismatch therefore indicates a partial - upgrade even though the binary itself cannot self-report. - """ - if not runtime_has_speakers_analyze_wheel_coverage( - platform_reader=platform_reader, - platform_tag_reader=platform_tag_reader, - ): - system, machine = platform_reader() - return SpeakersAnalyzeHandshakeResult( - "incompatible", - "solstone-core-speakers-analyze install check failed: " - f"{system}/{machine} is not covered by speakers-analyze wheel markers; " - "set core.speakers_analyze to 'python' to revert.", - ) - - try: - expected_version = version_reader(ROOT_DIST_NAME) - except PackageNotFoundError: - return SpeakersAnalyzeHandshakeResult( - "incompatible", - "solstone-core-speakers-analyze install check failed: missing solstone " - "distribution metadata; reinstall solstone-journal or set " - "core.speakers_analyze to 'python' to revert.", - ) - - try: - helper_version = version_reader(HELPER_DIST_NAME) - except PackageNotFoundError: - return SpeakersAnalyzeHandshakeResult( - "missing", - "solstone-core-speakers-analyze install check failed: missing " - f"{HELPER_DIST_NAME} distribution metadata; reinstall solstone-journal " - "or set core.speakers_analyze to 'python' to revert.", - ) - - if helper_version != expected_version: - return SpeakersAnalyzeHandshakeResult( - "incompatible", - "solstone-core-speakers-analyze install check failed: " - f"{HELPER_DIST_NAME} metadata is {helper_version} but {ROOT_DIST_NAME} " - f"is {expected_version}; reinstall solstone-journal or set " - "core.speakers_analyze to 'python' to revert.", - ) - - helper_path = speakers_analyze_path_for_executable(executable) - if not helper_path.exists(): - return SpeakersAnalyzeHandshakeResult( - "missing", - "solstone-core-speakers-analyze install check failed: missing binary " - f"{helper_path} for {HELPER_DIST_NAME} {helper_version}; reinstall " - "solstone-journal or set core.speakers_analyze to 'python' to revert.", - ) - if not executable_predicate(helper_path): - return SpeakersAnalyzeHandshakeResult( - "non-executable", - "solstone-core-speakers-analyze install check failed: binary " - f"{helper_path} is not executable for {HELPER_DIST_NAME} {helper_version}; " - "reinstall solstone-journal or set core.speakers_analyze to 'python' " - "to revert.", - ) - - return SpeakersAnalyzeHandshakeResult("ok") - - -__all__ = [ - "HELPER_BINARY_NAME", - "HELPER_DIST_NAME", - "ROOT_DIST_NAME", - "SpeakersAnalyzeHandshakeResult", - "check_speakers_analyze_handshake", - "runtime_has_speakers_analyze_wheel_coverage", - "speakers_analyze_path_for_executable", -] diff --git a/solstone/think/speakers_analyze_installation.py b/solstone/think/speakers_analyze_installation.py new file mode 100644 index 000000000..51ef77465 --- /dev/null +++ b/solstone/think/speakers_analyze_installation.py @@ -0,0 +1,460 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Read-only installation invariant for the native speakers-analyze helper.""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys +import uuid +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as distribution_version +from pathlib import Path +from typing import Literal + +from packaging import tags + +from solstone.apps.speakers.encoder_config import ( + OVERLAP_DETECTOR_SHA256, + WESPEAKER_MODEL_SHA256, +) +from solstone.think import probe +from solstone.think.journal_io import MalformedPolicy, atomic_replace, read_json +from solstone.think.journal_io.lease import ( + FileLease, + acquire_file_lease, + probe_file_lease_held, +) +from solstone.think.model_assets import ( + ModelsDistributionUnavailable, + resolve_pyannote_segmentation_model, + resolve_wespeaker_model, +) +from solstone.think.utils import get_journal + +HELPER_DIST_NAME = "solstone-core-speakers-analyze" +MODELS_DIST_NAME = "solstone-journal-models" +HELPER_BINARY_NAME = "solstone-core-speakers-analyze" +ROOT_DIST_NAME = "solstone" +INSTALL_GENERATION_SCHEMA = "solstone.speakers_analyze.install_generation.v1" +PROOF_KEY_SCHEMA = "solstone.speakers_analyze.install_proof_key.v1" +GENERATION_ENV_KEY = "SOL_SPEAKERS_ANALYZE_INSTALL_GENERATION_ID" +GENERATION_MODE = 0o600 + +SPEAKERS_ANALYZE_REPAIR_TEXT = ( + "Repair: reinstall the journal host stack with solstone-journal, or " + "solstone-journal-cuda on NVIDIA hosts, and restart the journal." +) + +SpeakersAnalyzeInstallationStatus = Literal[ + "ok", + "metadata-missing", + "metadata-version-mismatch", + "platform-unsupported", + "helper-missing", + "helper-not-executable", + "asset-missing", + "asset-digest-mismatch", +] + + +@dataclass(frozen=True) +class SpeakersAnalyzeInstallationResult: + status: SpeakersAnalyzeInstallationStatus + detail: str | None = None + + @property + def ok(self) -> bool: + return self.status == "ok" + + @property + def message(self) -> str: + if self.ok: + return "speakers-analyze installation ready" + detail = f": {self.detail}" if self.detail else "" + return ( + f"Speakers-analyze installation is incomplete " + f"({self.status}{detail}). {SPEAKERS_ANALYZE_REPAIR_TEXT}" + ) + + +@dataclass(frozen=True) +class SpeakersAnalyzeGeneration: + generation_id: str + lease: FileLease + + def release(self) -> None: + self.lease.release() + + def __enter__(self) -> SpeakersAnalyzeGeneration: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.release() + + +def speakers_analyze_path_for_executable(executable: str | Path | None = None) -> Path: + return Path(executable or sys.executable).with_name(HELPER_BINARY_NAME) + + +def _packaging_platform_tags() -> set[str]: + return {tag.platform for tag in tags.sys_tags()} + + +def runtime_has_speakers_analyze_wheel_coverage( + *, + platform_reader: Callable[ + [], probe.CorePlatform + ] = probe.current_solstone_core_platform, + platform_tag_reader: Callable[[], set[str]] = _packaging_platform_tags, +) -> bool: + platform_tuple = platform_reader() + if platform_tuple not in probe.SOLSTONE_CORE_SPEAKERS_ANALYZE_COVERED_PLATFORMS: + return False + expected_platforms = probe.SOLSTONE_CORE_SPEAKERS_ANALYZE_PLATFORM_TAGS.get( + platform_tuple + ) + if expected_platforms is None: + return False + return not set(expected_platforms.split(".")).isdisjoint(platform_tag_reader()) + + +def check_speakers_analyze_installation( + *, + journal_path: str | Path | None = None, + executable: str | Path | None = None, + version_reader: Callable[[str], str] = distribution_version, + platform_reader: Callable[ + [], probe.CorePlatform + ] = probe.current_solstone_core_platform, + platform_tag_reader: Callable[[], set[str]] = _packaging_platform_tags, + executable_predicate: Callable[[Path], bool] = lambda path: os.access( + path, os.X_OK + ), + digest: bool = True, + generation_id: str | None = None, +) -> SpeakersAnalyzeInstallationResult: + cheap = _cheap_installation_result( + executable=executable, + version_reader=version_reader, + platform_reader=platform_reader, + platform_tag_reader=platform_tag_reader, + executable_predicate=executable_predicate, + ) + if not cheap.ok or not digest: + return cheap + + proof_key = _installation_proof_key( + executable=executable, + version_reader=version_reader, + platform_reader=platform_reader, + ) + if _generation_proves_digest( + journal_path=journal_path, + generation_id=generation_id or os.getenv(GENERATION_ENV_KEY), + proof_key=proof_key, + ): + return SpeakersAnalyzeInstallationResult("ok") + return _digest_assets(proof_key) + + +def begin_speakers_analyze_generation( + *, + journal_path: str | Path | None = None, + executable: str | Path | None = None, + version_reader: Callable[[str], str] = distribution_version, + platform_reader: Callable[ + [], probe.CorePlatform + ] = probe.current_solstone_core_platform, +) -> SpeakersAnalyzeGeneration: + root = _journal_root(journal_path) + lease = acquire_file_lease( + _generation_lease_path(root), attempts=1, retry_max_seconds=0 + ) + if lease is None: + raise RuntimeError("speakers-analyze generation lease is already held") + cheap = _cheap_installation_result( + executable=executable, + version_reader=version_reader, + platform_reader=platform_reader, + platform_tag_reader=_packaging_platform_tags, + executable_predicate=lambda path: os.access(path, os.X_OK), + ) + if not cheap.ok: + lease.release() + raise RuntimeError(cheap.message) + proof_key = _installation_proof_key( + executable=executable, + version_reader=version_reader, + platform_reader=platform_reader, + ) + result, observed_assets = _validated_asset_digests(proof_key) + if not result.ok: + lease.release() + raise RuntimeError(result.message) + generation_id = uuid.uuid4().hex + record = { + "schema": INSTALL_GENERATION_SCHEMA, + "generation_id": generation_id, + "created_at": _now_iso(), + "verified_at": _now_iso(), + "proof_key": proof_key, + "assets": observed_assets, + "helper": proof_key["helper"], + "packages": proof_key["packages"], + "platform": proof_key["platform"], + } + _write_generation_record(root, record) + os.environ[GENERATION_ENV_KEY] = generation_id + return SpeakersAnalyzeGeneration(generation_id=generation_id, lease=lease) + + +def _cheap_installation_result( + *, + executable: str | Path | None, + version_reader: Callable[[str], str], + platform_reader: Callable[[], probe.CorePlatform], + platform_tag_reader: Callable[[], set[str]], + executable_predicate: Callable[[Path], bool], +) -> SpeakersAnalyzeInstallationResult: + if not runtime_has_speakers_analyze_wheel_coverage( + platform_reader=platform_reader, + platform_tag_reader=platform_tag_reader, + ): + system, machine = platform_reader() + return SpeakersAnalyzeInstallationResult( + "platform-unsupported", f"{system}/{machine} is not covered" + ) + + versions = _read_versions(version_reader) + if isinstance(versions, SpeakersAnalyzeInstallationResult): + return versions + root_version, helper_version, _models_version = versions + if helper_version != root_version: + return SpeakersAnalyzeInstallationResult( + "metadata-version-mismatch", + f"{HELPER_DIST_NAME} is {helper_version} but {ROOT_DIST_NAME} is {root_version}", + ) + + helper_path = speakers_analyze_path_for_executable(executable) + if not helper_path.exists(): + return SpeakersAnalyzeInstallationResult("helper-missing", str(helper_path)) + if not executable_predicate(helper_path): + return SpeakersAnalyzeInstallationResult( + "helper-not-executable", str(helper_path) + ) + + try: + required_assets = _required_assets() + except ModelsDistributionUnavailable as exc: + return SpeakersAnalyzeInstallationResult("asset-missing", str(exc)) + + for role, path, _expected in required_assets: + if not path.exists(): + return SpeakersAnalyzeInstallationResult("asset-missing", f"{role}: {path}") + try: + path.stat() + except OSError as exc: + return SpeakersAnalyzeInstallationResult( + "asset-missing", f"{role}: {path} ({exc})" + ) + return SpeakersAnalyzeInstallationResult("ok") + + +def _read_versions( + version_reader: Callable[[str], str], +) -> tuple[str, str, str] | SpeakersAnalyzeInstallationResult: + try: + root_version = version_reader(ROOT_DIST_NAME) + except PackageNotFoundError: + return SpeakersAnalyzeInstallationResult("metadata-missing", ROOT_DIST_NAME) + try: + helper_version = version_reader(HELPER_DIST_NAME) + except PackageNotFoundError: + return SpeakersAnalyzeInstallationResult("metadata-missing", HELPER_DIST_NAME) + try: + models_version = version_reader(MODELS_DIST_NAME) + except PackageNotFoundError: + return SpeakersAnalyzeInstallationResult("metadata-missing", MODELS_DIST_NAME) + return root_version, helper_version, models_version + + +def _installation_proof_key( + *, + executable: str | Path | None, + version_reader: Callable[[str], str], + platform_reader: Callable[[], probe.CorePlatform], +) -> dict[str, object]: + versions = _read_versions(version_reader) + if isinstance(versions, SpeakersAnalyzeInstallationResult): + raise RuntimeError(versions.message) + root_version, helper_version, models_version = versions + helper_path = speakers_analyze_path_for_executable(executable) + helper_stat = helper_path.stat() + try: + required_assets = _required_assets() + except ModelsDistributionUnavailable as exc: + raise RuntimeError(str(exc)) from exc + + assets = [] + for role, path, expected_sha256 in required_assets: + stat = path.stat() + assets.append( + { + "role": role, + "path": str(path), + "size": int(stat.st_size), + "mtime_ns": int(stat.st_mtime_ns), + "expected_sha256": expected_sha256, + } + ) + system, machine = platform_reader() + return { + "schema": PROOF_KEY_SCHEMA, + "platform": {"system": system, "machine": machine}, + "packages": { + ROOT_DIST_NAME: root_version, + HELPER_DIST_NAME: helper_version, + MODELS_DIST_NAME: models_version, + }, + "helper": { + "path": str(helper_path), + "size": int(helper_stat.st_size), + "mtime_ns": int(helper_stat.st_mtime_ns), + "mode": int(helper_stat.st_mode & 0o777), + "executable": bool(os.access(helper_path, os.X_OK)), + }, + "assets": assets, + } + + +def _digest_assets(proof_key: dict[str, object]) -> SpeakersAnalyzeInstallationResult: + result, _observed_assets = _validated_asset_digests(proof_key) + return result + + +def _validated_asset_digests( + proof_key: dict[str, object], +) -> tuple[SpeakersAnalyzeInstallationResult, list[dict[str, object]]]: + observed_assets: list[dict[str, object]] = [] + for asset in proof_key["assets"]: # type: ignore[index] + assert isinstance(asset, dict) + path = Path(str(asset["path"])) + expected_sha256 = str(asset["expected_sha256"]) + try: + observed_sha256 = _sha256_file(path) + except OSError as exc: + return ( + SpeakersAnalyzeInstallationResult( + "asset-missing", f"{asset['role']}: {path} ({exc})" + ), + observed_assets, + ) + observed = dict(asset) + observed["observed_sha256"] = observed_sha256 + observed["observed_bytes"] = int(path.stat().st_size) + observed_assets.append(observed) + if observed_sha256 != expected_sha256: + return ( + SpeakersAnalyzeInstallationResult( + "asset-digest-mismatch", f"{asset['role']}: {path}" + ), + observed_assets, + ) + return SpeakersAnalyzeInstallationResult("ok"), observed_assets + + +def _generation_proves_digest( + *, + journal_path: str | Path | None, + generation_id: str | None, + proof_key: dict[str, object], +) -> bool: + if not generation_id: + return False + root = _journal_root(journal_path) + if not probe_file_lease_held(_generation_lease_path(root)): + return False + raw = read_json( + _generation_record_path(root), + on_error=MalformedPolicy.WARN_AND_SKIP, + default={}, + ) + if not isinstance(raw, dict): + return False + if raw.get("schema") != INSTALL_GENERATION_SCHEMA: + return False + if raw.get("generation_id") != generation_id: + return False + if raw.get("proof_key") != proof_key: + return False + assets = raw.get("assets") + if not isinstance(assets, list): + return False + for asset in assets: + if not isinstance(asset, dict): + return False + if asset.get("observed_sha256") != asset.get("expected_sha256"): + return False + return True + + +def _required_assets() -> tuple[tuple[str, Path, str], ...]: + return ( + ("wespeaker", resolve_wespeaker_model(), WESPEAKER_MODEL_SHA256), + ("pyannote", resolve_pyannote_segmentation_model(), OVERLAP_DETECTOR_SHA256), + ) + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _journal_root(journal_path: str | Path | None) -> Path: + return Path(journal_path) if journal_path is not None else Path(get_journal()) + + +def _generation_record_path(root: Path) -> Path: + return root / "health" / "speakers-analyze" / "install-generation.json" + + +def _generation_lease_path(root: Path) -> Path: + return root / "health" / "speakers-analyze" / "install-generation.lock" + + +def _write_generation_record(root: Path, record: dict[str, object]) -> None: + atomic_replace( + _generation_record_path(root), + json.dumps(record, indent=2, sort_keys=True) + "\n", + mode=GENERATION_MODE, + ) + + +def _now_iso() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +__all__ = [ + "GENERATION_ENV_KEY", + "HELPER_BINARY_NAME", + "HELPER_DIST_NAME", + "MODELS_DIST_NAME", + "ROOT_DIST_NAME", + "SPEAKERS_ANALYZE_REPAIR_TEXT", + "SpeakersAnalyzeGeneration", + "SpeakersAnalyzeInstallationResult", + "begin_speakers_analyze_generation", + "check_speakers_analyze_installation", + "runtime_has_speakers_analyze_wheel_coverage", + "speakers_analyze_path_for_executable", +] diff --git a/solstone/think/speakers_analyze_runtime.py b/solstone/think/speakers_analyze_runtime.py deleted file mode 100644 index 9c767ccc4..000000000 --- a/solstone/think/speakers_analyze_runtime.py +++ /dev/null @@ -1,160 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Cross-process runtime state for native speaker analysis attempts.""" - -from __future__ import annotations - -import json -from datetime import datetime, timezone -from pathlib import Path -from typing import TypedDict - -from solstone.think.journal_io import ( - MalformedPolicy, - atomic_replace, - hold_lock, - read_json, -) -from solstone.think.utils import get_journal - -SCHEMA_VERSION = 1 -BREAKER_THRESHOLD = 3 -STATE_MODE = 0o600 - - -class SpeakersAnalyzeBreakerRecord(TypedDict): - schema_version: int - consecutive_failures: int - opened_at: str | None - last_failure_at: str | None - last_failure_stage: str | None - last_failure_reason: str | None - last_native_exit_code: int | None - last_success_at: str | None - - -def now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - -def circuit_breaker_path(*, journal_path: str | Path | None = None) -> Path: - root = Path(journal_path) if journal_path is not None else Path(get_journal()) - return root / "health" / "speakers-analyze" / "circuit-breaker.json" - - -def empty_record() -> SpeakersAnalyzeBreakerRecord: - return { - "schema_version": SCHEMA_VERSION, - "consecutive_failures": 0, - "opened_at": None, - "last_failure_at": None, - "last_failure_stage": None, - "last_failure_reason": None, - "last_native_exit_code": None, - "last_success_at": None, - } - - -def _coerce_record(raw: object) -> SpeakersAnalyzeBreakerRecord: - if not isinstance(raw, dict): - return empty_record() - return { - "schema_version": SCHEMA_VERSION, - "consecutive_failures": max(0, int(raw.get("consecutive_failures") or 0)), - "opened_at": _optional_string(raw.get("opened_at")), - "last_failure_at": _optional_string(raw.get("last_failure_at")), - "last_failure_stage": _optional_string(raw.get("last_failure_stage")), - "last_failure_reason": _optional_string(raw.get("last_failure_reason")), - "last_native_exit_code": _optional_int(raw.get("last_native_exit_code")), - "last_success_at": _optional_string(raw.get("last_success_at")), - } - - -def _optional_string(value: object) -> str | None: - return value if isinstance(value, str) and value else None - - -def _optional_int(value: object) -> int | None: - if isinstance(value, bool) or value is None: - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - -def read_record( - *, journal_path: str | Path | None = None -) -> SpeakersAnalyzeBreakerRecord: - path = circuit_breaker_path(journal_path=journal_path) - raw = read_json(path, on_error=MalformedPolicy.WARN_AND_SKIP, default={}) - return _coerce_record(raw) - - -def native_blocked( - *, journal_path: str | Path | None = None -) -> tuple[bool, SpeakersAnalyzeBreakerRecord]: - path = circuit_breaker_path(journal_path=journal_path) - with hold_lock(path, mode=STATE_MODE): - record = read_record(journal_path=journal_path) - return record["consecutive_failures"] >= BREAKER_THRESHOLD, record - - -def record_native_success( - *, journal_path: str | Path | None = None -) -> SpeakersAnalyzeBreakerRecord: - path = circuit_breaker_path(journal_path=journal_path) - with hold_lock(path, mode=STATE_MODE): - record = empty_record() - record["last_success_at"] = now_iso() - _write_record(path, record) - return record - - -def record_native_failure( - *, - stage: str, - reason: str, - native_exit_code: int | None = None, - journal_path: str | Path | None = None, -) -> SpeakersAnalyzeBreakerRecord: - path = circuit_breaker_path(journal_path=journal_path) - with hold_lock(path, mode=STATE_MODE): - current = read_record(journal_path=journal_path) - consecutive = current["consecutive_failures"] + 1 - opened_at = current["opened_at"] - if consecutive >= BREAKER_THRESHOLD and opened_at is None: - opened_at = now_iso() - record: SpeakersAnalyzeBreakerRecord = { - "schema_version": SCHEMA_VERSION, - "consecutive_failures": consecutive, - "opened_at": opened_at, - "last_failure_at": now_iso(), - "last_failure_stage": stage, - "last_failure_reason": reason, - "last_native_exit_code": native_exit_code, - "last_success_at": current["last_success_at"], - } - _write_record(path, record) - return record - - -def _write_record(path: Path, record: SpeakersAnalyzeBreakerRecord) -> None: - atomic_replace( - path, - json.dumps(record, indent=2, sort_keys=True) + "\n", - mode=STATE_MODE, - ) - - -__all__ = [ - "BREAKER_THRESHOLD", - "SpeakersAnalyzeBreakerRecord", - "circuit_breaker_path", - "empty_record", - "native_blocked", - "read_record", - "record_native_failure", - "record_native_success", -] diff --git a/solstone/think/supervisor.py b/solstone/think/supervisor.py index 79ad7f46e..785a86583 100644 --- a/solstone/think/supervisor.py +++ b/solstone/think/supervisor.py @@ -6528,6 +6528,16 @@ def main() -> None: logging.error(core_result.message) sys.exit(core_handshake.EX_CONFIG) + from solstone.think.speakers_analyze_installation import ( + check_speakers_analyze_installation, + ) + + speakers_installation = check_speakers_analyze_installation() + if not speakers_installation.ok: + print(speakers_installation.message, file=sys.stderr) + logging.error(speakers_installation.message) + sys.exit(core_handshake.EX_CONFIG) + if args.verbose or args.debug: console_handler = logging.StreamHandler() console_handler.setLevel(log_level) @@ -6659,7 +6669,7 @@ def main() -> None: logging.exception("Journal archive extract sweep raised; continuing startup") try: - from solstone.observe.transcribe.speakers_analyze_seam import ( + from solstone.observe.transcribe.speakers_analyze_adapter import ( sweep_stale_speakers_analyze_dirs, ) diff --git a/solstone/think/warm.py b/solstone/think/warm.py index 7baf411c4..b0d14045e 100644 --- a/solstone/think/warm.py +++ b/solstone/think/warm.py @@ -10,8 +10,7 @@ import sys # Single source of truth: pyproject.toml [journal-host], plus the CPU/GPU # onnxruntime entries in the solstone-journal / solstone-journal-cuda leaves. # Package-to-import mapping: -# Pillow -> PIL; opencv-python-headless -> cv2; scikit-learn -> sklearn; -# kaldi-native-fbank -> kaldi_native_fbank. +# Pillow -> PIL; opencv-python-headless -> cv2; scikit-learn -> sklearn. # # Excluded intentionally: pyarrow (not a dependency), scipy (transitive through # sklearn), and pure-Python dependencies (no native code-signing surface). @@ -22,7 +21,6 @@ _WARM_ALL = [ "av", "soundfile", "onnxruntime", - "kaldi_native_fbank", "sklearn", ] _WARM_DARWIN_ARM64 = ["mlx", "mlx_vlm"] diff --git a/tests/_speaker_differential_fixtures.py b/tests/_speaker_differential_fixtures.py index dcfdb8b0e..d0af933ff 100644 --- a/tests/_speaker_differential_fixtures.py +++ b/tests/_speaker_differential_fixtures.py @@ -9,9 +9,9 @@ there must not silently change this instrument's inputs. The differential also needs materially different shapes: multi-window log-prob matrices and embeddings that survive real AHC, not the monkeypatched clustering used by older unit tests. -DRY still binds for production constants: thresholds and frame geometry are -imported from their real homes. Only comparator tolerances are declared here, -with enough room for float noise while staying far below branch margins. +The speaker-plane Python algorithm is now a frozen test oracle. Comparator +tolerances live here, with enough room for float noise while staying far below +branch margins. """ from __future__ import annotations @@ -20,15 +20,15 @@ from dataclasses import dataclass import numpy as np -from solstone.observe.transcribe.diarize import ( +from tests.speaker_oracle.diarize import ( FRAMES_PER_WINDOW, MIN_INTERVAL_S, SAMPLE_RATE, SINGLE_SPEAKER_CLASSES, WINDOW_S, ) -from solstone.observe.transcribe.main import EMBEDDER_NAME, MIN_STATEMENT_DURATION -from solstone.observe.transcribe.overlap import OVERLAP_CLASSES, SpeakerWindowStats +from tests.speaker_oracle.embedder import EMBEDDER_NAME, MIN_STATEMENT_DURATION +from tests.speaker_oracle.overlap import OVERLAP_CLASSES, SpeakerWindowStats # Comparator tolerances. These are instrument tolerances, not production # thresholds. They allow small float drift while preserving all branch decisions. diff --git a/tests/conftest.py b/tests/conftest.py index 987b281a5..31501bd13 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -189,6 +189,46 @@ def set_test_journal_path(monkeypatch, _isolate_os_environ): think_utils._journal_path_cache = None +@pytest.fixture(autouse=True) +def _speakers_analyze_startup_invariant_ready( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + """Keep unit tests independent of the installed native helper wheel. + + tests/test_speakers_analyze_installation.py exercises the real invariant + directly; other unit tests patch specific failure modes when needed. + """ + if Path(str(request.node.path)).name == "test_speakers_analyze_installation.py": + return + + from solstone.think import speakers_analyze_installation as installation + + class _NoopLease: + def release(self) -> None: + return None + + def begin_ready_generation( + **_kwargs: object, + ) -> installation.SpeakersAnalyzeGeneration: + generation_id = "test-speakers-analyze-generation" + os.environ[installation.GENERATION_ENV_KEY] = generation_id + return installation.SpeakersAnalyzeGeneration( + generation_id=generation_id, + lease=_NoopLease(), + ) + + monkeypatch.setattr( + installation, + "check_speakers_analyze_installation", + lambda **_kwargs: installation.SpeakersAnalyzeInstallationResult("ok"), + ) + monkeypatch.setattr( + installation, + "begin_speakers_analyze_generation", + begin_ready_generation, + ) + + @pytest.fixture(autouse=True) def _default_local_backend_vulkan(monkeypatch, request): from solstone.think.providers import local_cuda, local_install diff --git a/tests/speaker_oracle/__init__.py b/tests/speaker_oracle/__init__.py new file mode 100644 index 000000000..6fd471461 --- /dev/null +++ b/tests/speaker_oracle/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Frozen Python speaker oracle used only by tests and fixture builders.""" diff --git a/solstone/observe/transcribe/diarize.py b/tests/speaker_oracle/diarize.py similarity index 98% rename from solstone/observe/transcribe/diarize.py rename to tests/speaker_oracle/diarize.py index b41e3e956..70b604533 100644 --- a/solstone/observe/transcribe/diarize.py +++ b/tests/speaker_oracle/diarize.py @@ -15,9 +15,9 @@ sentences list, matching the transcript `speaker` field format. Sentences with no single-speaker interval coverage get None. Public API: - from solstone.observe.transcribe.diarize import diarize, diarize_auto_k + from tests.speaker_oracle.diarize import diarize, diarize_auto_k - labels = diarize(wav_path, sentences) # auto-estimate k + labels = diarize(wav_path, sentences) # auto-estimate k labels = diarize(wav_path, sentences, n_speakers=4) # known k """ @@ -29,7 +29,7 @@ from pathlib import Path import numpy as np -from solstone.observe.model_assets import ( +from solstone.think.model_assets import ( resolve_pyannote_segmentation_model, resolve_wespeaker_model, ) diff --git a/tests/speaker_oracle/embedder.py b/tests/speaker_oracle/embedder.py new file mode 100644 index 000000000..dc86c1d42 --- /dev/null +++ b/tests/speaker_oracle/embedder.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Frozen WeSpeaker statement-embedding oracle for speaker differential tests.""" + +from __future__ import annotations + +import logging +import platform +import time +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from solstone.apps.speakers.encoder_config import ENCODER_ID, WESPEAKER_EMBEDDING_WIDTH +from solstone.observe.utils import SAMPLE_RATE +from solstone.think.model_assets import resolve_wespeaker_model + +if TYPE_CHECKING: + import numpy as np + import onnxruntime as ort + +_embedder_session: ort.InferenceSession | None = None +EMBEDDER_NAME = ENCODER_ID +MIN_STATEMENT_DURATION = 0.3 + + +def _select_onnx_providers() -> list[str]: + if platform.system() == "Darwin": + return ["CoreMLExecutionProvider", "CPUExecutionProvider"] + return ["CPUExecutionProvider"] + + +def _get_embedder_session() -> ort.InferenceSession: + global _embedder_session + + if _embedder_session is None: + import onnxruntime as ort + + wespeaker_model_path = resolve_wespeaker_model() + if not wespeaker_model_path.is_file(): + raise FileNotFoundError( + f"WeSpeaker model asset not found at {wespeaker_model_path}. " + "Run `make install` to verify the bundled asset." + ) + providers = _select_onnx_providers() + start = time.monotonic() + _embedder_session = ort.InferenceSession( + str(wespeaker_model_path), + providers=providers, + ) + elapsed = time.monotonic() - start + logging.info( + "wespeaker oracle session loaded providers=%s elapsed=%.2fs", + _embedder_session.get_providers(), + elapsed, + ) + + return _embedder_session + + +def _compute_wespeaker_features(audio: np.ndarray, sample_rate: int) -> np.ndarray: + import kaldi_native_fbank as knf + import numpy as np + + if sample_rate != SAMPLE_RATE: + raise ValueError( + f"WeSpeaker embedder requires {SAMPLE_RATE} Hz audio, got {sample_rate}" + ) + + opts = knf.FbankOptions() + opts.frame_opts.samp_freq = float(sample_rate) + opts.frame_opts.dither = 0.0 + opts.frame_opts.snip_edges = True + opts.frame_opts.frame_length_ms = 25.0 + opts.frame_opts.frame_shift_ms = 10.0 + opts.mel_opts.num_bins = 80 + opts.energy_floor = 0.0 + opts.use_energy = False + + fbank = knf.OnlineFbank(opts) + scaled = (audio.astype(np.float32) * 32768.0).tolist() + fbank.accept_waveform(float(sample_rate), scaled) + fbank.input_finished() + + frames = [fbank.get_frame(i) for i in range(fbank.num_frames_ready)] + if not frames: + return np.zeros((0, 80), dtype=np.float32) + + feats = np.stack(frames, axis=0).astype(np.float32) + return feats - feats.mean(axis=0, keepdims=True) + + +def _embed_statements( + audio: np.ndarray, + statements: Sequence[dict[str, object]], + sample_rate: int, +) -> dict[str, np.ndarray] | None: + import numpy as np + + if not statements: + return None + if sample_rate != SAMPLE_RATE: + raise ValueError( + f"WeSpeaker embedder requires {SAMPLE_RATE} Hz audio, got {sample_rate}" + ) + + session = _get_embedder_session() + input_name = session.get_inputs()[0].name + embeddings: list[np.ndarray] = [] + statement_ids: list[int] = [] + durations: list[float] = [] + + for statement in statements: + start = float(statement.get("start", 0.0)) + end = float(statement.get("end", start)) + duration = max(0.0, end - start) + if duration < MIN_STATEMENT_DURATION: + continue + start_sample = max(0, int(round(start * sample_rate))) + end_sample = min(len(audio), int(round(end * sample_rate))) + if end_sample <= start_sample: + continue + segment = audio[start_sample:end_sample] + feats = _compute_wespeaker_features(segment, sample_rate) + if feats.shape[0] == 0: + continue + output = session.run(None, {input_name: feats[None, :, :]})[0] + vector = np.asarray(output[0], dtype=np.float32) + if vector.shape != (WESPEAKER_EMBEDDING_WIDTH,): + raise ValueError( + "WeSpeaker embedding shape mismatch: " + f"{vector.shape} != ({WESPEAKER_EMBEDDING_WIDTH},)" + ) + embeddings.append(vector) + statement_ids.append(int(statement["id"])) + durations.append(duration) + + if not embeddings: + return None + + return { + "embeddings": np.stack(embeddings).astype(np.float32), + "statement_ids": np.asarray(statement_ids, dtype=np.int32), + "durations_s": np.asarray(durations, dtype=np.float32), + "encoder": np.array(EMBEDDER_NAME), + } + + +__all__ = [ + "ENCODER_ID", + "EMBEDDER_NAME", + "MIN_STATEMENT_DURATION", + "_compute_wespeaker_features", + "_embed_statements", + "_embedder_session", + "_get_embedder_session", + "_select_onnx_providers", +] diff --git a/solstone/observe/transcribe/overlap.py b/tests/speaker_oracle/overlap.py similarity index 95% rename from solstone/observe/transcribe/overlap.py rename to tests/speaker_oracle/overlap.py index e637a8893..7ddd473f7 100644 --- a/solstone/observe/transcribe/overlap.py +++ b/tests/speaker_oracle/overlap.py @@ -6,16 +6,12 @@ from __future__ import annotations import logging +import platform import time from pathlib import Path from typing import TYPE_CHECKING, NamedTuple, Sequence -from solstone.apps.speakers.encoder_config import ( - DIARIZE_MIN_OVERLAP, - SLOT_ACTIVE_MIN_SHARE, - SPEAKER_EVIDENCE_MULTI_MIN, - SPEAKER_EVIDENCE_SINGLE_MAX, -) +from solstone.apps.speakers.evidence import SpeakerEvidenceDecision from solstone.observe.utils import SAMPLE_RATE if TYPE_CHECKING: @@ -28,6 +24,10 @@ WINDOW_S = 10 STRIDE_S = 5 FRAMES_PER_WINDOW = 589 OVERLAP_CLASSES = (4, 5, 6) +SLOT_ACTIVE_MIN_SHARE = 0.10 +SPEAKER_EVIDENCE_MULTI_MIN = 0.05 +SPEAKER_EVIDENCE_SINGLE_MAX = 0.05 +DIARIZE_MIN_OVERLAP = 0.05 # Tighter stride used when sharing pyannote output with the diarizer _DIARIZE_STRIDE_S = 2 @@ -41,12 +41,6 @@ class SpeakerWindowStats(NamedTuple): overlap_frames: int -class SpeakerEvidenceDecision(NamedTuple): - speaker_evidence: str - multi_window_fraction: float - mean_window_overlap_share: float - - class OverlapInferenceResult(NamedTuple): overlap_fraction: float avg_log_probs: np.ndarray @@ -132,8 +126,7 @@ def _get_overlap_session() -> ort.InferenceSession: import onnxruntime as ort if _overlap_session is None: - from solstone.observe.model_assets import resolve_pyannote_segmentation_model - from solstone.observe.transcribe.main import _select_onnx_providers + from solstone.think.model_assets import resolve_pyannote_segmentation_model pyannote_model_path = resolve_pyannote_segmentation_model() if not pyannote_model_path.is_file(): @@ -158,6 +151,12 @@ def _get_overlap_session() -> ort.InferenceSession: return _overlap_session +def _select_onnx_providers() -> list[str]: + if platform.system() == "Darwin": + return ["CoreMLExecutionProvider", "CPUExecutionProvider"] + return ["CPUExecutionProvider"] + + def compute_overlap_fraction( audio: np.ndarray, sample_rate: int = SAMPLE_RATE ) -> float: diff --git a/tests/test_check_wheel_contents.py b/tests/test_check_wheel_contents.py index 10f3805db..c04a2c26e 100644 --- a/tests/test_check_wheel_contents.py +++ b/tests/test_check_wheel_contents.py @@ -3,6 +3,7 @@ from __future__ import annotations +import ast import os import re import subprocess @@ -29,6 +30,7 @@ from tests.helpers.release_wheel_fixtures import ( ) SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "check_wheel_contents.py" +ROOT = Path(__file__).resolve().parents[1] CPU_TYPE_X86_64 = 0x01000007 SPEAKERS_LIBRARY = b"fixture libonnxruntime.so.1 GLIBC_2.27\n" SPEAKERS_LICENSE = b"fixture license\n" @@ -93,6 +95,34 @@ def test_script_runs_without_site_packages_from_outside_repo(tmp_path: Path) -> assert "usage:" in result.stdout +def test_production_imports_do_not_reach_deleted_speaker_plane_or_oracle() -> None: + forbidden_exact = { + "kaldi_native_fbank", + "solstone.observe.model_assets", + "solstone.observe.transcribe.diarize", + "solstone.observe.transcribe.overlap", + "solstone.observe.transcribe.speakers_analyze_seam", + "solstone.think.speakers_analyze_handshake", + "solstone.think.speakers_analyze_runtime", + } + forbidden_prefixes = ("tests.speaker_oracle",) + violations: list[str] = [] + for path in sorted((ROOT / "solstone").rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + module_names: list[str] = [] + if isinstance(node, ast.Import): + module_names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + module_names = [node.module] + for module_name in module_names: + if module_name in forbidden_exact or module_name.startswith( + forbidden_prefixes + ): + violations.append(f"{path.relative_to(ROOT)} imports {module_name}") + assert violations == [] + + def test_core_wheel_validator_accepts_static_manylinux_wheel(tmp_path: Path) -> None: wheel = write_core_wheel(tmp_path) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index b0de51954..a042ab978 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -500,6 +500,52 @@ class TestParakeetCppSttReady: assert not journal.exists() +class TestSpeakersAnalyzeInstallation: + def test_registered_for_journal_and_journal_readiness_only(self, doctor): + pair = ( + doctor.SPEAKERS_ANALYZE_INSTALLATION_CHECK, + doctor.speakers_analyze_installation_check, + ) + + assert doctor.SPEAKERS_ANALYZE_INSTALLATION_CHECK.name in doctor.CHECK_MAP + assert pair in doctor.JOURNAL_CHECKS + assert pair in doctor.JOURNAL_READINESS_CHECKS + assert pair not in doctor.UNIVERSAL_CHECKS + assert pair not in doctor.READINESS_CHECKS + assert doctor.CHECK_MAP["speakers_analyze_installation"].severity == "blocker" + + def test_ok_when_shared_invariant_is_ok(self, doctor, monkeypatch): + from solstone.think import speakers_analyze_installation as installation + + monkeypatch.setattr( + installation, + "check_speakers_analyze_installation", + lambda: installation.SpeakersAnalyzeInstallationResult("ok"), + ) + + result = doctor.speakers_analyze_installation_check(args(doctor)) + + assert result.status == "ok" + assert result.detail == "speakers-analyze installation ready" + + def test_fails_with_shared_repair_text(self, doctor, monkeypatch): + from solstone.think import speakers_analyze_installation as installation + + monkeypatch.setattr( + installation, + "check_speakers_analyze_installation", + lambda: installation.SpeakersAnalyzeInstallationResult( + "asset-missing", "wespeaker" + ), + ) + + result = doctor.speakers_analyze_installation_check(args(doctor)) + + assert result.status == "fail" + assert "asset-missing" in result.detail + assert result.fix == installation.SPEAKERS_ANALYZE_REPAIR_TEXT + + class TestHostDependencies: def test_client_readiness_battery_is_host_free(self, doctor, monkeypatch): expected = [ @@ -516,6 +562,7 @@ class TestHostDependencies: for name in [ "host_dependencies", "default_stt_ready", + "speakers_analyze_installation", "feature:pdf-import", "feature:pdf-export", ]: @@ -542,6 +589,9 @@ class TestHostDependencies: assert selected[0][0] is doctor.HOST_DEPENDENCIES_CHECK assert selected[0][0].name == "host_dependencies" assert "default_stt_ready" in {check.name for check, _runner in selected} + assert "speakers_analyze_installation" in { + check.name for check, _runner in selected + } assert "feature:pdf-import" in {check.name for check, _runner in selected} assert "feature:pdf-export" in {check.name for check, _runner in selected} diff --git a/tests/test_install_models.py b/tests/test_install_models.py index 8a055a152..c024389be 100644 --- a/tests/test_install_models.py +++ b/tests/test_install_models.py @@ -100,7 +100,7 @@ def test_verify_bundled_assets_returns_when_hashes_match( ) monkeypatch.setattr( install_models, - "PYANNOTE_OVERLAP_MODEL_SHA256", + "OVERLAP_DETECTOR_SHA256", _sha256(b"pyannote"), ) @@ -124,7 +124,7 @@ def test_verify_bundled_assets_reports_mutated_asset( ) monkeypatch.setattr( install_models, - "PYANNOTE_OVERLAP_MODEL_SHA256", + "OVERLAP_DETECTOR_SHA256", _sha256(b"pyannote"), ) diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index 8c61ec298..63a2368f9 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -14,6 +14,9 @@ import pytest from solstone.think import maintenance, maintenance_cli, schedule_config, scheduler from solstone.think.maintenance import MaintenanceDescriptorError, MaintenanceRoutine +from solstone.think.speakers_analyze_installation import ( + SpeakersAnalyzeInstallationResult, +) def _use_journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: @@ -83,6 +86,15 @@ def _run_cli(monkeypatch: pytest.MonkeyPatch, argv: list[str]) -> int: return int(exc_info.value.code) +@pytest.fixture(autouse=True) +def _speakers_analyze_installation_ready(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "solstone.think.speakers_analyze_installation." + "check_speakers_analyze_installation", + lambda: SpeakersAnalyzeInstallationResult("ok"), + ) + + @pytest.fixture(autouse=True) def reset_scheduler_state(): scheduler._entries = {} diff --git a/tests/test_model_assets.py b/tests/test_model_assets.py index 8289888c6..c605413d4 100644 --- a/tests/test_model_assets.py +++ b/tests/test_model_assets.py @@ -8,7 +8,7 @@ import sys import pytest -from solstone.observe.model_assets import ( +from solstone.think.model_assets import ( ModelsDistributionUnavailable, resolve_wespeaker_model, ) diff --git a/tests/test_sense.py b/tests/test_sense.py index ae115708a..7751746c7 100644 --- a/tests/test_sense.py +++ b/tests/test_sense.py @@ -57,6 +57,21 @@ def _default_thinking_engine_selected(monkeypatch): ) +@pytest.fixture(autouse=True) +def _speakers_analyze_generation_ready(monkeypatch): + class Generation: + generation_id = "test-generation" + + def release(self) -> None: + pass + + monkeypatch.setattr( + "solstone.think.speakers_analyze_installation." + "begin_speakers_analyze_generation", + lambda **_kwargs: Generation(), + ) + + class _ClockShim: def __init__( self, diff --git a/tests/test_speaker_differential.py b/tests/test_speaker_differential.py index 3c35bf5ee..f16208df6 100644 --- a/tests/test_speaker_differential.py +++ b/tests/test_speaker_differential.py @@ -28,7 +28,6 @@ from typing import Any import numpy as np import pytest -from solstone.observe.transcribe import diarize, overlap from solstone.observe.vad import AudioReduction, SpeechSegment from tests import verify_speaker_differential as harness from tests._repo_inventory import assert_inventory_unchanged, repository_inventory @@ -43,6 +42,7 @@ from tests._speaker_differential_fixtures import ( model_free_case, real_model_waveform, ) +from tests.speaker_oracle import diarize, embedder, overlap CACHE_MUTATION_TIMEOUT_S = 2.0 CACHE_MUTATION_JOIN_TIMEOUT_S = 1.0 @@ -103,7 +103,7 @@ def _install_model_free_patches( fake_compute_overlap_and_logprobs, ) monkeypatch.setattr( - harness.transcribe_main, + embedder, "_embed_statements", fake_embed_statements, ) @@ -679,10 +679,10 @@ def test_real_model_emitter_wiring_records_provenance_and_reuses_pyannote( # Prep measured this path at about 0.1s warm and 0.25s cold on this host, # well under the 15s global pytest timeout; keep this unmarked by integration. monkeypatch.setattr(overlap, "_overlap_session", None) - monkeypatch.setattr(harness.transcribe_main, "_embedder_session", None) + monkeypatch.setattr(embedder, "_embedder_session", None) monkeypatch.setattr(diarize, "_wespeaker_session", None) overlap_session = overlap._get_overlap_session() - harness.transcribe_main._get_embedder_session() + embedder._get_embedder_session() diarize._get_wespeaker_session() counting_session = _CountingSession(overlap_session) monkeypatch.setattr(overlap, "_overlap_session", counting_session) diff --git a/tests/test_speaker_verdict.py b/tests/test_speaker_verdict.py index dbb46982e..f21f4a0cb 100644 --- a/tests/test_speaker_verdict.py +++ b/tests/test_speaker_verdict.py @@ -14,11 +14,11 @@ import numpy as np import pytest from solstone.apps.speakers import encoder_config -from solstone.observe.transcribe import diarize from tests import verify_speaker_differential as harness from tests import verify_speaker_verdict as verdict from tests._repo_inventory import assert_inventory_unchanged, repository_inventory from tests._speaker_differential_fixtures import EMBEDDING_MAX_ABS_TOLERANCE +from tests.speaker_oracle import diarize from tests.test_speaker_differential import _emit_model_free_bundle diff --git a/tests/test_speakers_analyze_adapter.py b/tests/test_speakers_analyze_adapter.py new file mode 100644 index 000000000..d62050093 --- /dev/null +++ b/tests/test_speakers_analyze_adapter.py @@ -0,0 +1,428 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Tests for the native speakers-analyze adapter.""" + +from __future__ import annotations + +import json +import stat +from pathlib import Path +from typing import Any + +import numpy as np +import pytest + +from solstone.apps.speakers.encoder_config import ENCODER_ID, WESPEAKER_EMBEDDING_WIDTH +from solstone.observe.transcribe.speakers_analyze_adapter import ( + RESPONSE_SCHEMA, + TEMP_PREFIX, + HelperInvocationResult, + analyze_speakers, + sweep_stale_speakers_analyze_dirs, +) +from solstone.observe.transcribe.speakers_analyze_errors import SpeakerAnalyzeError + +_DEFAULT_LABELS = object() + + +def _statements() -> list[dict[str, Any]]: + return [ + {"id": 1, "start": 0.0, "end": 0.5, "text": "one"}, + {"id": 2, "start": 0.6, "end": 1.1, "text": "two"}, + ] + + +def _response( + *, + statement_ids: list[int] | None = None, + durations_s: list[float] | None = None, + labels: object = _DEFAULT_LABELS, + rows: int | None = None, + byte_count: int | None = None, + shape: list[int] | None = None, + speaker_evidence: str = "multi", + overlap_fraction: float = 0.25, + multi_window_fraction: float = 0.5, + mean_window_overlap_share: float = 0.125, +) -> dict[str, Any]: + statement_ids = [1, 2] if statement_ids is None else statement_ids + rows = len(statement_ids) if rows is None else rows + expected_bytes = rows * WESPEAKER_EMBEDDING_WIDTH * 4 + return { + "schema": RESPONSE_SCHEMA, + "sample_rate_hz": 10, + "inputs": { + "statement_embedding": { + "statement_ids": [1, 2], + "spans_s": [[0.0, 0.5], [0.6, 1.1]], + }, + "diarization": { + "statement_ids": [1, 2], + "spans_s": [[0.0, 0.5], [0.6, 1.1]], + }, + }, + "statement_embeddings": { + "audio_buffer": "full", + "encoder": ENCODER_ID, + "payload_format": "f32le", + "payload_path": "__filled_by_test__", + "dtype": "float32", + "statement_ids": statement_ids, + "durations_s": [0.5 for _ in statement_ids] + if durations_s is None + else durations_s, + "shape": [rows, WESPEAKER_EMBEDDING_WIDTH] if shape is None else shape, + "byte_count": expected_bytes if byte_count is None else byte_count, + "admitted_count": rows, + "skipped_count": 2 - rows, + }, + "pyannote": { + "window_stats": [ + {"speech_frames": 100, "active_slot_count": 2, "overlap_frames": 10} + ] + }, + "evidence": { + "overlap_fraction": overlap_fraction, + "speaker_evidence": speaker_evidence, + "multi_window_fraction": multi_window_fraction, + "mean_window_overlap_share": mean_window_overlap_share, + }, + "diarization": { + "intervals": [], + "valid_intervals": None, + "interval_embeddings": None, + "cluster_labels": None, + "statement_labels": [2, None] if labels is _DEFAULT_LABELS else labels, + "silhouette_k": None, + "effective_k": None, + }, + } + + +def _payload(rows: int, *, nonfinite: bool = False) -> bytes: + values = np.arange(rows * WESPEAKER_EMBEDDING_WIDTH, dtype=" tuple[Any, dict[str, Any], Path]: + captured: dict[str, Any] = {} + temp_dirs: list[Path] = [] + + def temp_dir_factory(_raw_path: Path) -> Path: + path = tmp_path / f"adapter-temp-{len(temp_dirs)}" + path.mkdir(mode=0o700) + temp_dirs.append(path) + return path + + def helper_invoker(_argv: list[str], stdin: str, _raw_path: Path): + request = json.loads(stdin) + captured["request"] = request + if returncode != 0: + return HelperInvocationResult(returncode, stdout or "", stderr) + if stdout is not None: + return HelperInvocationResult(returncode, stdout, stderr) + out = response or _response() + out["statement_embeddings"]["payload_path"] = request[ + "output_payload_f32le_path" + ] + rows = len(out["statement_embeddings"]["statement_ids"]) + Path(request["output_payload_f32le_path"]).write_bytes( + _payload(rows) if payload is None else payload + ) + return HelperInvocationResult(0, json.dumps(out), "") + + result = analyze_speakers( + raw_path=tmp_path + / "chronicle" + / "20260101" + / "mic" + / "090000_300" + / "mic_audio.flac", + full_audio=np.arange(20, dtype=np.float32), + statement_audio=np.arange(20, dtype=np.float32), + reduced_audio=np.arange(10, dtype=np.float32), + statements_pre_restore=statements_pre_restore or _statements(), + statements_restored=statements_restored + or [ + {"id": 1, "start": 10.0, "end": 10.5, "text": "one"}, + {"id": 2, "start": 11.0, "end": 11.5, "text": "two"}, + ], + sample_rate=10, + min_statement_duration=0.3, + helper_locator=lambda: tmp_path / "helper", + helper_invoker=helper_invoker, + model_path_resolver=lambda: (tmp_path / "wespeaker.onnx", tmp_path / "p.onnx"), + temp_dir_factory=temp_dir_factory, + ) + return result, captured["request"], temp_dirs[0] + + +def test_success_maps_request_response_payload_and_cleans_temp_dir(tmp_path: Path): + result, request, temp_dir = _run_adapter(tmp_path) + + assert not temp_dir.exists() + assert request["interval_embedding_payload_f32le_path"] is None + assert request["statement_embedding"]["spans"][0]["start_s"] == 0.0 + assert request["diarization"]["spans"][0]["start_s"] == 10.0 + assert result.statements == [ + {"id": 1, "start": 10.0, "end": 10.5, "text": "one", "speaker": 2}, + {"id": 2, "start": 11.0, "end": 11.5, "text": "two"}, + ] + assert result.embeddings_data is not None + assert result.embeddings_data["embeddings"].shape == ( + 2, + WESPEAKER_EMBEDDING_WIDTH, + ) + assert result.speaker_evidence.speaker_evidence == "multi" + assert result.overlap_fraction == 0.25 + + +@pytest.mark.parametrize( + ("mutate", "reason"), + [ + ( + lambda r: r["statement_embeddings"].update(statement_ids=[1, 1]), + "duplicate-statement-id", + ), + ( + lambda r: r["statement_embeddings"].update(statement_ids=[2, 1]), + "statement-id-divergence", + ), + ( + lambda r: r["statement_embeddings"].update(statement_ids=[1, 99]), + "foreign-statement-id", + ), + ( + lambda r: r["statement_embeddings"].update(durations_s=[0.5]), + "duration-count-mismatch", + ), + ( + lambda r: r["statement_embeddings"].update(durations_s=[0.5, float("inf")]), + "nonfinite-duration", + ), + ( + lambda r: r["statement_embeddings"].update(shape=[2, 255]), + "embedding-shape-mismatch", + ), + ( + lambda r: r["statement_embeddings"].update(byte_count=1), + "embedding-byte-count-mismatch", + ), + ( + lambda r: r["statement_embeddings"].update(admitted_count=1), + "embedding-admitted-count-mismatch", + ), + ( + lambda r: r["statement_embeddings"].update(skipped_count=9), + "embedding-skipped-count-mismatch", + ), + ( + lambda r: r["evidence"].update(speaker_evidence="unknown"), + "unknown-speaker-evidence", + ), + ( + lambda r: r["evidence"].update(overlap_fraction=1.1), + "invalid-overlap-fraction", + ), + ( + lambda r: r["evidence"].update(multi_window_fraction=float("nan")), + "invalid-multi-window-fraction", + ), + ( + lambda r: r["diarization"].update(statement_labels=[0, None]), + "invalid-statement-labels", + ), + ( + lambda r: r["diarization"].update(statement_labels=[1]), + "statement-label-count-mismatch", + ), + ], +) +def test_response_validation_rejects_invalid_payload_shapes( + tmp_path: Path, mutate, reason: str +): + response = _response() + mutate(response) + + with pytest.raises(SpeakerAnalyzeError) as exc: + _run_adapter(tmp_path, response=response) + + assert exc.value.stage == "payload" + assert exc.value.reason == reason + + +def test_nonfinite_consumed_embedding_rejected(tmp_path: Path): + with pytest.raises(SpeakerAnalyzeError) as exc: + _run_adapter(tmp_path, payload=_payload(2, nonfinite=True)) + + assert exc.value.stage == "payload" + assert exc.value.reason == "nonfinite-embedding" + + +def test_payload_size_checked_before_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + response = _response() + + def fail_read_bytes(_path: Path) -> bytes: + raise AssertionError("payload bytes were read before stat-size validation") + + monkeypatch.setattr(Path, "read_bytes", fail_read_bytes) + + with pytest.raises(SpeakerAnalyzeError) as exc: + _run_adapter(tmp_path, response=response, payload=b"oversized") + + assert exc.value.stage == "payload" + assert exc.value.reason == "embedding-payload-size-mismatch" + + +def test_gate_decline_null_labels_is_accepted(tmp_path: Path): + result, _request, _temp_dir = _run_adapter( + tmp_path, + response=_response(labels=None, speaker_evidence="single"), + ) + + assert result.statement_labels is None + assert all("speaker" not in statement for statement in result.statements) + + +def test_zero_admitted_rows_are_accepted_without_embeddings(tmp_path: Path): + short_statements = [ + {"id": 1, "start": 0.0, "end": 0.1, "text": "one"}, + {"id": 2, "start": 0.1, "end": 0.2, "text": "two"}, + ] + + result, _request, _temp_dir = _run_adapter( + tmp_path, + response=_response(statement_ids=[], labels=None, speaker_evidence="single"), + payload=b"", + statements_pre_restore=short_statements, + statements_restored=short_statements, + ) + + assert result.embeddings_data is None + assert result.statements == short_statements + assert result.statement_labels is None + + +def test_malformed_json_response_maps_to_parse_failure(tmp_path: Path): + with pytest.raises(SpeakerAnalyzeError) as exc: + _run_adapter(tmp_path, stdout="{not-json") + + assert exc.value.stage == "parse" + assert exc.value.reason == "malformed-response" + + +def test_signal_returncode_maps_to_invoke_failure(tmp_path: Path): + with pytest.raises(SpeakerAnalyzeError) as exc: + _run_adapter(tmp_path, returncode=-9) + + assert exc.value.stage == "invoke" + assert exc.value.reason == "signal-9" + assert exc.value.native_exit_code == -9 + + +def test_helper_error_json_reason_maps_to_invoke_failure(tmp_path: Path): + stderr = json.dumps( + { + "schema": "solstone-speaker-analyze-error-v1", + "reason": "model-missing", + } + ) + + with pytest.raises(SpeakerAnalyzeError) as exc: + _run_adapter(tmp_path, returncode=69, stderr=stderr) + + assert exc.value.stage == "invoke" + assert exc.value.reason == "model-missing" + assert exc.value.native_exit_code == 69 + assert ( + exc.value.event_fields()["speaker_analysis_failure_reason"] == "model-missing" + ) + + +def test_temp_dir_and_files_are_owner_only_and_cleaned( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import solstone.observe.transcribe.speakers_analyze_adapter as adapter + + captured: dict[str, int] = {} + monkeypatch.setattr(adapter, "TEMP_ROOT", tmp_path) + + def helper_invoker(_argv: list[str], stdin: str, _raw_path: Path): + request = json.loads(stdin) + full_path = Path(request["full_audio_f32le_path"]) + reduced_path = Path(request["reduced_audio_f32le_path"]) + captured["dir_mode"] = stat.S_IMODE(full_path.parent.stat().st_mode) + captured["full_file_mode"] = stat.S_IMODE(full_path.stat().st_mode) + captured["reduced_file_mode"] = stat.S_IMODE(reduced_path.stat().st_mode) + response = _response() + response["statement_embeddings"]["payload_path"] = request[ + "output_payload_f32le_path" + ] + Path(request["output_payload_f32le_path"]).write_bytes(_payload(2)) + return HelperInvocationResult(0, json.dumps(response), "") + + analyze_speakers( + raw_path=tmp_path + / "chronicle" + / "20260101" + / "mic" + / "090000_300" + / "mic_audio.flac", + full_audio=np.ones(20, dtype=np.float32), + statement_audio=np.ones(20, dtype=np.float32), + reduced_audio=np.ones(10, dtype=np.float32), + statements_pre_restore=_statements(), + statements_restored=_statements(), + sample_rate=10, + min_statement_duration=0.3, + helper_locator=lambda: tmp_path / "helper", + helper_invoker=helper_invoker, + model_path_resolver=lambda: (tmp_path / "wespeaker.onnx", tmp_path / "p.onnx"), + ) + + assert captured == { + "dir_mode": 0o700, + "full_file_mode": 0o600, + "reduced_file_mode": 0o600, + } + assert list(tmp_path.glob(f"{TEMP_PREFIX}*")) == [] + + +def test_sweep_stale_speakers_analyze_dirs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import solstone.observe.transcribe.speakers_analyze_adapter as adapter + + monkeypatch.setattr(adapter, "TEMP_ROOT", tmp_path) + stale = tmp_path / f"{TEMP_PREFIX}old" + stale.mkdir() + fresh = tmp_path / f"{TEMP_PREFIX}fresh" + fresh.mkdir() + old_time = 1_600_000_000 + stale.touch() + fresh.touch() + monkeypatch.setattr(adapter.time, "time", lambda: old_time + 10_000) + stale.touch() + import os + + os.utime(stale, (old_time, old_time)) + os.utime(fresh, (old_time + 9_999, old_time + 9_999)) + + assert sweep_stale_speakers_analyze_dirs(max_age_seconds=100) == 1 + assert not stale.exists() + assert fresh.exists() diff --git a/tests/test_speakers_analyze_handshake.py b/tests/test_speakers_analyze_handshake.py deleted file mode 100644 index fcb6fb4a2..000000000 --- a/tests/test_speakers_analyze_handshake.py +++ /dev/null @@ -1,149 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for native speakers-analyze helper installation checks.""" - -from __future__ import annotations - -from importlib.metadata import PackageNotFoundError -from pathlib import Path - -from solstone.think import probe -from solstone.think.speakers_analyze_handshake import ( - HELPER_BINARY_NAME, - HELPER_DIST_NAME, - ROOT_DIST_NAME, - check_speakers_analyze_handshake, - runtime_has_speakers_analyze_wheel_coverage, - speakers_analyze_path_for_executable, -) - - -def test_coverage_gate_reads_helper_constants_not_core_constants(monkeypatch): - core_platform = ("coreos", "core64") - helper_platform = ("helperos", "helper64") - monkeypatch.setattr( - probe, - "SOLSTONE_CORE_COVERED_PLATFORMS", - (core_platform,), - ) - monkeypatch.setattr( - probe, - "SOLSTONE_CORE_PLATFORM_TAGS", - {core_platform: "core-tag"}, - ) - monkeypatch.setattr( - probe, - "SOLSTONE_CORE_SPEAKERS_ANALYZE_COVERED_PLATFORMS", - (helper_platform,), - ) - monkeypatch.setattr( - probe, - "SOLSTONE_CORE_SPEAKERS_ANALYZE_PLATFORM_TAGS", - {helper_platform: "helper-tag"}, - ) - - assert runtime_has_speakers_analyze_wheel_coverage( - platform_reader=lambda: helper_platform, - platform_tag_reader=lambda: {"helper-tag"}, - ) - assert not runtime_has_speakers_analyze_wheel_coverage( - platform_reader=lambda: core_platform, - platform_tag_reader=lambda: {"core-tag"}, - ) - - -def test_helper_path_is_sibling_of_python_executable(tmp_path: Path): - executable = tmp_path / "venv" / "bin" / "python" - - assert speakers_analyze_path_for_executable(executable) == ( - executable.with_name(HELPER_BINARY_NAME) - ) - - -def test_handshake_missing_helper_distribution_metadata(tmp_path: Path): - def version_reader(dist_name: str) -> str: - if dist_name == ROOT_DIST_NAME: - return "1.0.18" - raise PackageNotFoundError(dist_name) - - result = check_speakers_analyze_handshake( - executable=tmp_path / "bin" / "python", - version_reader=version_reader, - platform_reader=lambda: ("linux", "x86_64"), - platform_tag_reader=lambda: {"manylinux_2_27_x86_64"}, - ) - - assert result.status == "missing" - assert HELPER_DIST_NAME in str(result.message) - - -def test_handshake_missing_binary_is_distinct_from_missing_metadata(tmp_path: Path): - def version_reader(_dist_name: str) -> str: - return "1.0.18" - - result = check_speakers_analyze_handshake( - executable=tmp_path / "bin" / "python", - version_reader=version_reader, - platform_reader=lambda: ("linux", "x86_64"), - platform_tag_reader=lambda: {"manylinux_2_27_x86_64"}, - ) - - assert result.status == "missing" - assert "missing binary" in str(result.message) - - -def test_handshake_non_executable_binary_is_distinct(tmp_path: Path): - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - executable = bin_dir / "python" - helper = bin_dir / HELPER_BINARY_NAME - helper.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - - result = check_speakers_analyze_handshake( - executable=executable, - version_reader=lambda _dist_name: "1.0.18", - platform_reader=lambda: ("linux", "x86_64"), - platform_tag_reader=lambda: {"manylinux_2_27_x86_64"}, - executable_predicate=lambda _path: False, - ) - - assert result.status == "non-executable" - assert "not executable" in str(result.message) - - -def test_handshake_version_mismatch_is_incompatible(tmp_path: Path): - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - helper = bin_dir / HELPER_BINARY_NAME - helper.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - - def version_reader(dist_name: str) -> str: - if dist_name == ROOT_DIST_NAME: - return "1.0.18" - if dist_name == HELPER_DIST_NAME: - return "1.0.17" - raise PackageNotFoundError(dist_name) - - result = check_speakers_analyze_handshake( - executable=bin_dir / "python", - version_reader=version_reader, - platform_reader=lambda: ("linux", "x86_64"), - platform_tag_reader=lambda: {"manylinux_2_27_x86_64"}, - executable_predicate=lambda _path: True, - ) - - assert result.status == "incompatible" - assert "metadata is 1.0.17" in str(result.message) - - -def test_handshake_uncovered_platform_is_incompatible(tmp_path: Path): - result = check_speakers_analyze_handshake( - executable=tmp_path / "bin" / "python", - version_reader=lambda _dist_name: "1.0.18", - platform_reader=lambda: ("unsupported", "machine"), - platform_tag_reader=lambda: {"unsupported-tag"}, - ) - - assert result.status == "incompatible" - assert "not covered" in str(result.message) diff --git a/tests/test_speakers_analyze_installation.py b/tests/test_speakers_analyze_installation.py new file mode 100644 index 000000000..c936ce6c1 --- /dev/null +++ b/tests/test_speakers_analyze_installation.py @@ -0,0 +1,294 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Tests for the speakers-analyze startup installation invariant.""" + +from __future__ import annotations + +import os +from importlib.metadata import PackageNotFoundError +from pathlib import Path + +import pytest + +from solstone.think import probe +from solstone.think import speakers_analyze_installation as installation + + +def _version_reader(dist_name: str) -> str: + if dist_name in { + installation.ROOT_DIST_NAME, + installation.HELPER_DIST_NAME, + installation.MODELS_DIST_NAME, + }: + return "1.0.18" + raise PackageNotFoundError(dist_name) + + +def _platform_reader() -> probe.CorePlatform: + return ("linux", "x86_64") + + +def _platform_tags() -> set[str]: + return {"manylinux_2_27_x86_64"} + + +def _helper(tmp_path: Path) -> Path: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + executable = bin_dir / "python" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + helper = bin_dir / installation.HELPER_BINARY_NAME + helper.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + helper.chmod(0o755) + return executable + + +def _asset_fixtures(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + wespeaker = tmp_path / "wespeaker.onnx" + pyannote = tmp_path / "pyannote.onnx" + wespeaker.write_bytes(b"wespeaker") + pyannote.write_bytes(b"pyannote") + wespeaker_sha256 = installation._sha256_file(wespeaker) + pyannote_sha256 = installation._sha256_file(pyannote) + monkeypatch.setattr( + installation, + "_required_assets", + lambda: ( + ("wespeaker", wespeaker, wespeaker_sha256), + ("pyannote", pyannote, pyannote_sha256), + ), + ) + + +def test_coverage_gate_reads_helper_constants_not_core_constants(monkeypatch): + core_platform = ("coreos", "core64") + helper_platform = ("helperos", "helper64") + monkeypatch.setattr( + probe, + "SOLSTONE_CORE_COVERED_PLATFORMS", + (core_platform,), + ) + monkeypatch.setattr( + probe, + "SOLSTONE_CORE_PLATFORM_TAGS", + {core_platform: "core-tag"}, + ) + monkeypatch.setattr( + probe, + "SOLSTONE_CORE_SPEAKERS_ANALYZE_COVERED_PLATFORMS", + (helper_platform,), + ) + monkeypatch.setattr( + probe, + "SOLSTONE_CORE_SPEAKERS_ANALYZE_PLATFORM_TAGS", + {helper_platform: "helper-tag"}, + ) + + assert installation.runtime_has_speakers_analyze_wheel_coverage( + platform_reader=lambda: helper_platform, + platform_tag_reader=lambda: {"helper-tag"}, + ) + assert not installation.runtime_has_speakers_analyze_wheel_coverage( + platform_reader=lambda: core_platform, + platform_tag_reader=lambda: {"core-tag"}, + ) + + +def test_helper_path_is_sibling_of_python_executable(tmp_path: Path): + executable = tmp_path / "venv" / "bin" / "python" + + assert installation.speakers_analyze_path_for_executable(executable) == ( + executable.with_name(installation.HELPER_BINARY_NAME) + ) + + +def test_missing_helper_distribution_metadata(tmp_path: Path): + def version_reader(dist_name: str) -> str: + if dist_name == installation.ROOT_DIST_NAME: + return "1.0.18" + raise PackageNotFoundError(dist_name) + + result = installation.check_speakers_analyze_installation( + executable=tmp_path / "bin" / "python", + version_reader=version_reader, + platform_reader=_platform_reader, + platform_tag_reader=_platform_tags, + ) + + assert result.status == "metadata-missing" + assert installation.HELPER_DIST_NAME in result.message + + +def test_missing_binary_is_distinct_from_missing_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + _asset_fixtures(tmp_path, monkeypatch) + + result = installation.check_speakers_analyze_installation( + executable=tmp_path / "bin" / "python", + version_reader=_version_reader, + platform_reader=_platform_reader, + platform_tag_reader=_platform_tags, + ) + + assert result.status == "helper-missing" + assert installation.HELPER_BINARY_NAME in result.message + + +def test_non_executable_binary_is_distinct( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + _asset_fixtures(tmp_path, monkeypatch) + executable = _helper(tmp_path) + helper = executable.with_name(installation.HELPER_BINARY_NAME) + helper.chmod(0o644) + + result = installation.check_speakers_analyze_installation( + executable=executable, + version_reader=_version_reader, + platform_reader=_platform_reader, + platform_tag_reader=_platform_tags, + executable_predicate=lambda _path: False, + ) + + assert result.status == "helper-not-executable" + + +def test_version_mismatch_is_incompatible( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + _asset_fixtures(tmp_path, monkeypatch) + executable = _helper(tmp_path) + + def version_reader(dist_name: str) -> str: + if dist_name == installation.ROOT_DIST_NAME: + return "1.0.18" + if dist_name == installation.HELPER_DIST_NAME: + return "1.0.17" + if dist_name == installation.MODELS_DIST_NAME: + return "1.0.18" + raise PackageNotFoundError(dist_name) + + result = installation.check_speakers_analyze_installation( + executable=executable, + version_reader=version_reader, + platform_reader=_platform_reader, + platform_tag_reader=_platform_tags, + executable_predicate=lambda _path: True, + ) + + assert result.status == "metadata-version-mismatch" + assert "1.0.17" in result.message + + +def test_uncovered_platform_is_unsupported(tmp_path: Path): + result = installation.check_speakers_analyze_installation( + executable=tmp_path / "bin" / "python", + version_reader=_version_reader, + platform_reader=lambda: ("unsupported", "machine"), + platform_tag_reader=lambda: {"unsupported-tag"}, + ) + + assert result.status == "platform-unsupported" + + +def test_asset_digest_mismatch_is_reported( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + executable = _helper(tmp_path) + wespeaker = tmp_path / "wespeaker.onnx" + pyannote = tmp_path / "pyannote.onnx" + wespeaker.write_bytes(b"wespeaker") + pyannote.write_bytes(b"pyannote") + monkeypatch.setattr( + installation, + "_required_assets", + lambda: ( + ("wespeaker", wespeaker, "0" * 64), + ("pyannote", pyannote, installation._sha256_file(pyannote)), + ), + ) + + result = installation.check_speakers_analyze_installation( + executable=executable, + version_reader=_version_reader, + platform_reader=_platform_reader, + platform_tag_reader=_platform_tags, + ) + + assert result.status == "asset-digest-mismatch" + + +def test_live_generation_record_reuses_digest_proof( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + executable = _helper(tmp_path) + _asset_fixtures(tmp_path, monkeypatch) + generation = installation.begin_speakers_analyze_generation( + journal_path=tmp_path, + executable=executable, + version_reader=_version_reader, + platform_reader=_platform_reader, + ) + calls = 0 + + def fail_digest(_path: Path) -> str: + nonlocal calls + calls += 1 + raise AssertionError("digest should be reused while generation lease is live") + + monkeypatch.setattr(installation, "_sha256_file", fail_digest) + + try: + result = installation.check_speakers_analyze_installation( + journal_path=tmp_path, + executable=executable, + version_reader=_version_reader, + platform_reader=_platform_reader, + platform_tag_reader=_platform_tags, + generation_id=generation.generation_id, + ) + finally: + generation.release() + os.environ.pop(installation.GENERATION_ENV_KEY, None) + + assert result.status == "ok" + assert calls == 0 + + +def test_stale_generation_record_degrades_to_full_digest( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + executable = _helper(tmp_path) + _asset_fixtures(tmp_path, monkeypatch) + generation = installation.begin_speakers_analyze_generation( + journal_path=tmp_path, + executable=executable, + version_reader=_version_reader, + platform_reader=_platform_reader, + ) + generation_id = generation.generation_id + generation.release() + os.environ.pop(installation.GENERATION_ENV_KEY, None) + calls = 0 + original_digest = installation._sha256_file + + def counted_digest(path: Path) -> str: + nonlocal calls + calls += 1 + return original_digest(path) + + monkeypatch.setattr(installation, "_sha256_file", counted_digest) + + result = installation.check_speakers_analyze_installation( + journal_path=tmp_path, + executable=executable, + version_reader=_version_reader, + platform_reader=_platform_reader, + platform_tag_reader=_platform_tags, + generation_id=generation_id, + ) + + assert result.status == "ok" + assert calls == 2 diff --git a/tests/test_speakers_analyze_invocation.py b/tests/test_speakers_analyze_invocation.py new file mode 100644 index 000000000..64cfdf066 --- /dev/null +++ b/tests/test_speakers_analyze_invocation.py @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Tests for bounded native speakers-analyze helper invocation.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest + +from solstone.observe.transcribe.speakers_analyze_adapter import ( + SpeakersAnalyzeBudget, + analyze_speakers, + invoke_speakers_analyze_helper, +) +from solstone.observe.transcribe.speakers_analyze_errors import SpeakerAnalyzeError + + +def _budget(**overrides) -> SpeakersAnalyzeBudget: + values = { + "timeout_s": 1.0, + "stdout_limit_bytes": 1024, + "stderr_limit_bytes": 1024, + "terminate_grace_s": 0.05, + "kill_grace_s": 0.5, + } + values.update(overrides) + return SpeakersAnalyzeBudget(**values) + + +def test_invocation_success_returns_captured_streams(tmp_path: Path): + result = invoke_speakers_analyze_helper( + [ + sys.executable, + "-c", + "import sys; print('ok'); print('warn', file=sys.stderr)", + ], + "{}", + tmp_path / "audio.flac", + budget=_budget(), + ) + + assert result.returncode == 0 + assert result.stdout == "ok\n" + assert result.stderr == "warn\n" + + +def test_timeout_terminates_and_reaps_child(tmp_path: Path): + with pytest.raises(SpeakerAnalyzeError) as exc: + invoke_speakers_analyze_helper( + [sys.executable, "-c", "import time; time.sleep(10)"], + "{}", + tmp_path / "audio.flac", + budget=_budget(timeout_s=0.01), + ) + + assert exc.value.stage == "invoke" + assert exc.value.reason == "timeout" + + +@pytest.mark.parametrize( + ("stream", "reason"), + [("stdout", "stdout-too-large"), ("stderr", "stderr-too-large")], +) +def test_stream_bounds_terminate_and_reap_child( + tmp_path: Path, stream: str, reason: str +): + code = ( + "import sys, time; " + f"sys.{stream}.write('x' * 2048); " + f"sys.{stream}.flush(); " + "time.sleep(10)" + ) + + with pytest.raises(SpeakerAnalyzeError) as exc: + invoke_speakers_analyze_helper( + [sys.executable, "-c", code], + "{}", + tmp_path / "audio.flac", + budget=_budget(stdout_limit_bytes=64, stderr_limit_bytes=64), + ) + + assert exc.value.stage == "invoke" + assert exc.value.reason == reason + + +def test_signal_killed_helper_is_typed_failure(tmp_path: Path): + temp_dir = tmp_path / "temp" + temp_dir.mkdir() + + with pytest.raises(SpeakerAnalyzeError) as exc: + analyze_speakers( + raw_path=tmp_path / "audio.flac", + full_audio=np.zeros(20, dtype=np.float32), + statement_audio=np.zeros(20, dtype=np.float32), + reduced_audio=None, + statements_pre_restore=[{"id": 1, "start": 0.0, "end": 0.5, "text": "x"}], + statements_restored=[{"id": 1, "start": 0.0, "end": 0.5, "text": "x"}], + sample_rate=10, + min_statement_duration=0.3, + helper_locator=lambda: tmp_path / "helper", + helper_invoker=lambda _argv, _stdin, _raw_path: type( + "Result", + (), + {"returncode": -9, "stdout": "", "stderr": ""}, + )(), + model_path_resolver=lambda: (tmp_path / "w.onnx", tmp_path / "p.onnx"), + temp_dir_factory=lambda _raw_path: temp_dir, + ) + + assert exc.value.stage == "invoke" + assert exc.value.reason == "signal-9" + assert exc.value.native_exit_code == -9 + + +def test_malformed_json_response_is_typed_failure(tmp_path: Path): + temp_dir = tmp_path / "temp" + temp_dir.mkdir() + + with pytest.raises(SpeakerAnalyzeError) as exc: + analyze_speakers( + raw_path=tmp_path / "audio.flac", + full_audio=np.zeros(20, dtype=np.float32), + statement_audio=np.zeros(20, dtype=np.float32), + reduced_audio=None, + statements_pre_restore=[{"id": 1, "start": 0.0, "end": 0.5, "text": "x"}], + statements_restored=[{"id": 1, "start": 0.0, "end": 0.5, "text": "x"}], + sample_rate=10, + min_statement_duration=0.3, + helper_locator=lambda: tmp_path / "helper", + helper_invoker=lambda _argv, _stdin, _raw_path: type( + "Result", + (), + {"returncode": 0, "stdout": "{", "stderr": ""}, + )(), + model_path_resolver=lambda: (tmp_path / "w.onnx", tmp_path / "p.onnx"), + temp_dir_factory=lambda _raw_path: temp_dir, + ) + + assert exc.value.stage == "parse" + assert exc.value.reason == "malformed-response" diff --git a/tests/test_speakers_analyze_leaf_install_integration.py b/tests/test_speakers_analyze_leaf_install_integration.py index b0a84898d..9ee015e80 100644 --- a/tests/test_speakers_analyze_leaf_install_integration.py +++ b/tests/test_speakers_analyze_leaf_install_integration.py @@ -9,7 +9,7 @@ from pathlib import Path import pytest -from solstone.think.speakers_analyze_handshake import ( +from solstone.think.speakers_analyze_installation import ( runtime_has_speakers_analyze_wheel_coverage, ) @@ -55,15 +55,15 @@ def test_cpu_leaf_install_reaches_speakers_analyze_helper(tmp_path: Path) -> Non ) assert install.returncode == 0, install.stderr or install.stdout - handshake = subprocess.run( + invariant = subprocess.run( [ str(python), "-c", "\n".join( [ - "from solstone.think.speakers_analyze_handshake import check_speakers_analyze_handshake", - "result = check_speakers_analyze_handshake()", - 'print(f"handshake status={result.status!r} message={result.message!r}")', + "from solstone.think.speakers_analyze_installation import check_speakers_analyze_installation", + "result = check_speakers_analyze_installation()", + 'print(f"installation status={result.status!r} message={result.message!r}")', "raise SystemExit(0 if result.status == 'ok' else 1)", ] ), @@ -73,5 +73,5 @@ def test_cpu_leaf_install_reaches_speakers_analyze_helper(tmp_path: Path) -> Non check=False, timeout=120, ) - print(handshake.stdout, end="") - assert handshake.returncode == 0, handshake.stderr or handshake.stdout + print(invariant.stdout, end="") + assert invariant.returncode == 0, invariant.stderr or invariant.stdout diff --git a/tests/test_speakers_analyze_seam.py b/tests/test_speakers_analyze_seam.py deleted file mode 100644 index e83a445e0..000000000 --- a/tests/test_speakers_analyze_seam.py +++ /dev/null @@ -1,664 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for the config-gated native speaker analysis seam.""" - -from __future__ import annotations - -import json -import os -import stat -import subprocess -import time -from pathlib import Path -from typing import Any - -import numpy as np - -from solstone.apps.speakers.encoder_config import WESPEAKER_EMBEDDING_WIDTH -from solstone.observe.transcribe.speakers_analyze_seam import ( - CONFIG_KEY, - EXIT_UNAVAILABLE, - RESPONSE_SCHEMA, - NativeSpeakerAnalysisResult, - create_speakers_analyze_temp_dir, - maybe_run_native_speaker_analysis, - sweep_stale_speakers_analyze_dirs, -) -from solstone.think import speakers_analyze_runtime -from solstone.think.speakers_analyze_handshake import SpeakersAnalyzeHandshakeResult - -CUTOVER_ARTIFACT_WALL_CLOCK_EXCLUSIONS = frozenset( - { - "_solstone_processing.attempted_at", - "source_segments[].added_at", - "merge_events[].merged_at", - "consolidation_summary.last_merge.merged_at", - } -) - - -class RuntimeRecorder: - def __init__(self) -> None: - self.failures: list[dict[str, Any]] = [] - self.successes = 0 - - def success(self, **_kwargs: Any) -> dict[str, Any]: - self.successes += 1 - return {} - - def failure(self, **kwargs: Any) -> dict[str, Any]: - self.failures.append(kwargs) - return {"consecutive_failures": len(self.failures)} - - -def _statements() -> list[dict[str, Any]]: - return [ - {"id": 1, "start": 0.0, "end": 0.5, "text": "one"}, - {"id": 2, "start": 0.6, "end": 1.0, "text": "two"}, - ] - - -def _restored_statements() -> list[dict[str, Any]]: - return [ - {"id": 1, "start": 10.0, "end": 10.5, "text": "one"}, - {"id": 2, "start": 11.0, "end": 11.4, "text": "two"}, - ] - - -def _temp_factory(tmp_path: Path): - paths: list[Path] = [] - - def factory(_raw_path: Path) -> Path: - path = tmp_path / f"speakers-analyze-{len(paths)}" - path.mkdir(mode=0o700) - paths.append(path) - return path - - return factory, paths - - -def _base_response( - *, - statement_ids: list[int] | None = None, - labels: list[int | None] | None = [7, None], - speaker_evidence: str = "multi", -) -> dict[str, Any]: - if statement_ids is None: - statement_ids = [1, 2] - return { - "schema": RESPONSE_SCHEMA, - "statement_embeddings": { - "statement_ids": statement_ids, - "durations_s": [0.5 for _sid in statement_ids], - "shape": [len(statement_ids), WESPEAKER_EMBEDDING_WIDTH], - }, - "evidence": { - "overlap_fraction": 0.125, - "speaker_evidence": speaker_evidence, - "multi_window_fraction": 0.5, - "mean_window_overlap_share": 0.25, - }, - "diarization": {"statement_labels": labels}, - } - - -def _payload_bytes(rows: int, *, truncate: bool = False) -> bytes: - values = np.arange(rows * WESPEAKER_EMBEDDING_WIDTH, dtype=" subprocess.CompletedProcess[str]: - response = response or _base_response() - if returncode == 0: - statement_ids = response["statement_embeddings"]["statement_ids"] - Path(request["output_payload_f32le_path"]).write_bytes( - _payload_bytes(len(statement_ids), truncate=truncate_payload) - ) - stderr = "" - if stderr_reason is not None: - stderr = json.dumps( - { - "schema": "solstone-speaker-analyze-error-v1", - "reason": stderr_reason, - } - ) - return subprocess.CompletedProcess( - args=["solstone-core-speakers-analyze"], - returncode=returncode, - stdout=json.dumps(response), - stderr=stderr, - ) - - -def _run_native( - tmp_path: Path, - *, - config: dict[str, Any] | None = None, - runner=None, - reduced_audio: np.ndarray | None = None, - full_audio: np.ndarray | None = None, - statement_audio: np.ndarray | None = None, - statements_pre_restore: list[dict[str, Any]] | None = None, - statements_restored: list[dict[str, Any]] | None = None, - breaker_blocked=None, - recorder: RuntimeRecorder | None = None, -) -> NativeSpeakerAnalysisResult: - recorder = recorder or RuntimeRecorder() - temp_factory, _paths = _temp_factory(tmp_path) - if runner is None: - - def runner(_argv, **kwargs): - return _completed(json.loads(kwargs["input"])) - - return maybe_run_native_speaker_analysis( - journal=tmp_path, - raw_path=tmp_path - / "chronicle" - / "20260101" - / "mic" - / "090000_300" - / "mic_audio.flac", - full_audio=( - np.asarray(full_audio, dtype=np.float32) - if full_audio is not None - else np.ones(20, dtype=np.float32) - ), - statement_audio=( - np.asarray(statement_audio, dtype=np.float32) - if statement_audio is not None - else np.ones(20, dtype=np.float32) - ), - reduced_audio=reduced_audio, - statements_pre_restore=statements_pre_restore or _statements(), - statements_restored=statements_restored or _statements(), - sample_rate=10, - min_statement_duration=0.3, - config_reader=lambda _journal: ( - config if config is not None else {"core": {"speakers_analyze": "native"}} - ), - handshake_checker=lambda: SpeakersAnalyzeHandshakeResult("ok"), - helper_locator=lambda: tmp_path / "solstone-core-speakers-analyze", - native_runner=runner, - model_path_resolver=lambda: ( - tmp_path / "wespeaker.onnx", - tmp_path / "pyannote.onnx", - ), - temp_dir_factory=temp_factory, - breaker_blocked=breaker_blocked - or (lambda **_kwargs: (False, {"consecutive_failures": 0})), - record_native_success=recorder.success, - record_native_failure=recorder.failure, - ) - - -def test_absent_key_selects_python_without_running_helper(tmp_path: Path): - result = _run_native( - tmp_path, - config={}, - runner=lambda *_args, **_kwargs: (_ for _ in ()).throw( - AssertionError("native runner called") - ), - ) - - assert result.status == "python" - assert result.event_fields == {} - - -def test_explicit_python_selects_python_without_running_helper(tmp_path: Path): - result = _run_native( - tmp_path, - config={"core": {"speakers_analyze": "python"}}, - runner=lambda *_args, **_kwargs: (_ for _ in ()).throw( - AssertionError("native runner called") - ), - ) - - assert result.status == "python" - assert result.event_fields == {} - - -def test_invalid_config_value_fails_loudly(tmp_path: Path): - result = _run_native(tmp_path, config={"core": {"speakers_analyze": "rust"}}) - - assert result.status == "config_error" - assert result.error_message is not None - assert CONFIG_KEY in result.error_message - assert "found 'rust'" in result.error_message - assert "expected 'python' or 'native'" in result.error_message - assert "Set core.speakers_analyze to 'python' to revert" in result.error_message - - -def test_native_success_maps_response_payload(tmp_path: Path): - result = _run_native(tmp_path) - - assert result.status == "accepted" - assert result.statements == [ - {"id": 1, "start": 0.0, "end": 0.5, "text": "one", "speaker": 7}, - {"id": 2, "start": 0.6, "end": 1.0, "text": "two"}, - ] - assert result.embeddings_data is not None - assert result.embeddings_data["embeddings"].shape == ( - 2, - WESPEAKER_EMBEDDING_WIDTH, - ) - assert result.overlap_fraction == 0.125 - assert result.speaker_evidence is not None - assert result.speaker_evidence.speaker_evidence == "multi" - assert result.event_fields == {"speaker_analysis_path": "native"} - - -def test_native_gate_decline_accepts_no_speaker_labels(tmp_path: Path): - def runner(_argv, **kwargs): - request = json.loads(kwargs["input"]) - return _completed( - request, - response=_base_response(labels=None, speaker_evidence="single"), - ) - - result = _run_native(tmp_path, runner=runner) - - assert result.status == "accepted" - assert result.statements == _statements() - assert all("speaker" not in statement for statement in result.statements or []) - assert result.event_fields == { - "speaker_analysis_path": "native", - "speaker_analysis_degradation": "gate_decline", - "speaker_analysis_stage": "evidence_gate", - "speaker_analysis_reason": "single", - } - - -def test_request_contains_full_and_reduced_buffers_and_restored_spans(tmp_path: Path): - captured: dict[str, Any] = {} - full_audio = np.asarray([1.0, 2.0, 3.0, 4.0], dtype=np.float32) - reduced_audio = np.arange(20, dtype=np.float32) + 10.0 - - def runner(_argv, **kwargs): - request = json.loads(kwargs["input"]) - captured["request"] = request - captured["full_audio"] = np.fromfile( - request["full_audio_f32le_path"], dtype="= 3) - assert record["consecutive_failures"] == attempt - - speakers_analyze_runtime.record_native_success(journal_path=tmp_path) - - blocked, record = speakers_analyze_runtime.native_blocked(journal_path=tmp_path) - assert not blocked - assert record["consecutive_failures"] == 0 - - -def test_temp_dir_and_files_are_owner_only(tmp_path: Path, monkeypatch): - import solstone.observe.transcribe.speakers_analyze_seam as seam - - captured: dict[str, int] = {} - monkeypatch.setattr(seam, "TEMP_ROOT", tmp_path) - - def runner(_argv, **kwargs): - request = json.loads(kwargs["input"]) - full_path = Path(request["full_audio_f32le_path"]) - captured["dir_mode"] = stat.S_IMODE(full_path.parent.stat().st_mode) - captured["full_file_mode"] = stat.S_IMODE(full_path.stat().st_mode) - captured["reduced_file_mode"] = stat.S_IMODE( - Path(request["reduced_audio_f32le_path"]).stat().st_mode - ) - return _completed(request) - - result = maybe_run_native_speaker_analysis( - journal=tmp_path, - raw_path=tmp_path - / "chronicle" - / "20260101" - / "mic" - / "090000_300" - / "mic_audio.flac", - full_audio=np.ones(20, dtype=np.float32), - statement_audio=np.ones(10, dtype=np.float32), - reduced_audio=np.ones(10, dtype=np.float32), - statements_pre_restore=_statements(), - statements_restored=_statements(), - sample_rate=10, - min_statement_duration=0.3, - config_reader=lambda _journal: {"core": {"speakers_analyze": "native"}}, - handshake_checker=lambda: SpeakersAnalyzeHandshakeResult("ok"), - helper_locator=lambda: tmp_path / "solstone-core-speakers-analyze", - native_runner=runner, - model_path_resolver=lambda: ( - tmp_path / "wespeaker.onnx", - tmp_path / "pyannote.onnx", - ), - breaker_blocked=lambda **_kwargs: (False, {}), - record_native_success=lambda **_kwargs: {}, - record_native_failure=lambda **_kwargs: {}, - ) - - assert result.status == "accepted" - assert captured == { - "dir_mode": 0o700, - "full_file_mode": 0o600, - "reduced_file_mode": 0o600, - } - assert list(tmp_path.glob("solstone-speakers-analyze-*")) == [] - - -def test_concurrent_temp_dirs_for_same_segment_are_distinct( - tmp_path: Path, monkeypatch -): - import solstone.observe.transcribe.speakers_analyze_seam as seam - - monkeypatch.setattr(seam, "TEMP_ROOT", tmp_path) - raw_path = ( - tmp_path / "chronicle" / "20260101" / "mic" / "090000_300" / "mic_audio.flac" - ) - - first = create_speakers_analyze_temp_dir(raw_path) - second = create_speakers_analyze_temp_dir(raw_path) - - try: - assert first != second - assert first.name.startswith("solstone-speakers-analyze-") - assert second.name.startswith("solstone-speakers-analyze-") - finally: - first.rmdir() - second.rmdir() - - -def test_sweeps_stale_temp_dirs_only(tmp_path: Path, monkeypatch): - import solstone.observe.transcribe.speakers_analyze_seam as seam - - monkeypatch.setattr(seam, "TEMP_ROOT", tmp_path) - stale = tmp_path / "solstone-speakers-analyze-old" - fresh = tmp_path / "solstone-speakers-analyze-fresh" - unrelated = tmp_path / "other" - stale.mkdir() - fresh.mkdir() - unrelated.mkdir() - old = time.time() - 90000 - os.utime(stale, (old, old)) - - swept = sweep_stale_speakers_analyze_dirs(max_age_seconds=86400) - - assert swept == 1 - assert not stale.exists() - assert fresh.exists() - assert unrelated.exists() - - -def test_default_config_keeps_speakers_analyze_key_absent(): - default = json.loads( - Path("solstone/think/journal_default.json").read_text(encoding="utf-8") - ) - - assert "speakers_analyze" not in json.dumps(default) - - -def test_cutover_wall_clock_exclusion_list_is_literal_and_complete(): - assert CUTOVER_ARTIFACT_WALL_CLOCK_EXCLUSIONS == { - "_solstone_processing.attempted_at", - "source_segments[].added_at", - "merge_events[].merged_at", - "consolidation_summary.last_merge.merged_at", - } - assert "last_seen_ts" not in CUTOVER_ARTIFACT_WALL_CLOCK_EXCLUSIONS diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index e003e028b..cfac43899 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -36,6 +36,19 @@ from tests.helpers.module_mocks import ( ) +@pytest.fixture(autouse=True) +def _speakers_analyze_installation_ready(monkeypatch): + from solstone.think.speakers_analyze_installation import ( + SpeakersAnalyzeInstallationResult, + ) + + monkeypatch.setattr( + "solstone.think.speakers_analyze_installation." + "check_speakers_analyze_installation", + lambda: SpeakersAnalyzeInstallationResult("ok"), + ) + + def _mlx_readiness( *, model_installed: bool = True, diff --git a/tests/test_supervisor_sync_gate.py b/tests/test_supervisor_sync_gate.py index e8af28309..c50b892e3 100644 --- a/tests/test_supervisor_sync_gate.py +++ b/tests/test_supervisor_sync_gate.py @@ -12,6 +12,18 @@ from unittest.mock import MagicMock import pytest from solstone.think import sync_check +from solstone.think.speakers_analyze_installation import ( + SpeakersAnalyzeInstallationResult, +) + + +@pytest.fixture(autouse=True) +def _speakers_analyze_installation_ready(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "solstone.think.speakers_analyze_installation." + "check_speakers_analyze_installation", + lambda: SpeakersAnalyzeInstallationResult("ok"), + ) def _set_identity(monkeypatch): diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index e06792ceb..0b59ba2e2 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -6,6 +6,7 @@ import json import shutil import subprocess +import sys import tempfile from datetime import datetime from pathlib import Path @@ -15,6 +16,8 @@ import numpy as np import pytest import soundfile as sf +from solstone.apps.speakers.encoder_config import ENCODER_ID +from solstone.apps.speakers.evidence import SpeakerEvidenceDecision from solstone.observe import utils as observe_utils from solstone.observe.transcribe import ( DEFAULT_MIN_SPEECH_SECONDS, @@ -23,30 +26,37 @@ from solstone.observe.transcribe import ( build_statement, build_statements_from_acoustic, ) -from solstone.observe.transcribe.main import EMBEDDER_NAME, _statements_to_jsonl -from solstone.observe.transcribe.overlap import ( - OverlapInferenceResult, - SpeakerEvidenceDecision, - SpeakerWindowStats, +from solstone.observe.transcribe.main import _statements_to_jsonl +from solstone.observe.transcribe.speakers_analyze_adapter import ( + PRODUCER_ID as SPEAKERS_ANALYZE_PRODUCER_ID, +) +from solstone.observe.transcribe.speakers_analyze_adapter import ( + SpeakerAnalyzeResult, ) from solstone.observe.utils import SAMPLE_RATE, AudioDecodeError, load_audio from solstone.observe.vad import AudioReduction, SpeechSegment, VadResult from solstone.think.journal_io.errors import MalformedDataError from solstone.think.journal_io.npz import load_npz from solstone.think.media import AUDIO_EXTENSIONS - -CLEAN_SINGLE_STATS = (SpeakerWindowStats(589, 1, 0),) -MULTI_STATS = (SpeakerWindowStats(589, 2, 300),) - - -def _overlap_result( - overlap_fraction: float, - avg_log_probs: np.ndarray | None = None, - window_stats: tuple[SpeakerWindowStats, ...] = CLEAN_SINGLE_STATS, -) -> OverlapInferenceResult: - if avg_log_probs is None: - avg_log_probs = np.zeros((589, 7), dtype=np.float32) - return OverlapInferenceResult(overlap_fraction, avg_log_probs, window_stats) +from tests._repo_inventory import assert_inventory_unchanged, repository_inventory + + +def _speaker_result( + statements: list[dict], + *, + embeddings_data: dict[str, np.ndarray] | None = None, + speaker_evidence: SpeakerEvidenceDecision | None = None, + overlap_fraction: float = 0.0, + statement_labels: list[int | None] | None = None, +) -> SpeakerAnalyzeResult: + return SpeakerAnalyzeResult( + statements=[dict(statement) for statement in statements], + embeddings_data=embeddings_data, + speaker_evidence=speaker_evidence + or SpeakerEvidenceDecision("single", 0.0, 0.0), + overlap_fraction=overlap_fraction, + statement_labels=statement_labels, + ) class TestBuildStatementsFromAcoustic: @@ -478,7 +488,7 @@ class TestEmbeddingsFormat: embeddings = np.random.randn(5, 256).astype(np.float32) statement_ids = np.array([1, 2, 3, 4, 5], dtype=np.int32) - encoder = np.array(EMBEDDER_NAME) + encoder = np.array(ENCODER_ID) np.savez_compressed( npz_path, @@ -490,7 +500,7 @@ class TestEmbeddingsFormat: loaded = np.load(npz_path) np.testing.assert_array_almost_equal(loaded["embeddings"], embeddings) np.testing.assert_array_equal(loaded["statement_ids"], statement_ids) - assert loaded["encoder"].item() == EMBEDDER_NAME + assert loaded["encoder"].item() == ENCODER_ID def test_statement_ids_are_unique(self): """Statement IDs should be unique.""" @@ -544,12 +554,8 @@ def test_process_audio_failed_embeddings_write_emits_failed_event(tmp_path): "solstone.observe.transcribe.main.get_backend", return_value=backend_module ), patch( - "solstone.observe.transcribe.main._embed_statements", - return_value=embeddings_data, - ), - patch( - "solstone.observe.transcribe.overlap.compute_overlap_and_logprobs", - return_value=_overlap_result(0.0), + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + return_value=_speaker_result(statements, embeddings_data=embeddings_data), ), patch("solstone.observe.transcribe.main.callosum_send") as mock_send, patch( @@ -613,12 +619,8 @@ def test_process_audio_embeddings_write_round_trips_without_lock(tmp_path): "solstone.observe.transcribe.main.get_backend", return_value=backend_module ), patch( - "solstone.observe.transcribe.main._embed_statements", - return_value=embeddings_data, - ), - patch( - "solstone.observe.transcribe.overlap.compute_overlap_and_logprobs", - return_value=_overlap_result(0.0), + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + return_value=_speaker_result(statements, embeddings_data=embeddings_data), ), patch("solstone.observe.transcribe.main.callosum_send") as mock_send, ): @@ -635,12 +637,26 @@ def test_process_audio_embeddings_write_round_trips_without_lock(tmp_path): assert list(embeddings_path.parent.glob("*.lock")) == [] -def test_process_audio_native_failure_writes_python_identical_artifacts(tmp_path): +def test_process_audio_native_failure_emits_attributed_failure_only(tmp_path): from solstone.observe.transcribe.main import process_audio - from solstone.observe.transcribe.speakers_analyze_seam import ( - NativeSpeakerAnalysisResult, + from solstone.observe.transcribe.speakers_analyze_errors import ( + SPEAKER_ANALYSIS_FAILURE_LABEL, + SPEAKER_ANALYSIS_FAILURE_REASON, + SpeakerAnalyzeError, ) + raw_path = ( + tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.m4a" + ) + raw_path.parent.mkdir(parents=True) + raw_path.write_bytes(b"\x00" * 2048) + audio_buffer = np.zeros(10 * SAMPLE_RATE, dtype=np.float32) + vad_result = VadResult( + duration=10.0, + speech_duration=5.0, + has_speech=True, + speech_segments=[(1.0, 6.0)], + ) statements = [{"id": 0, "start": 0.0, "end": 1.0, "text": "hi"}] backend_module = MagicMock() backend_module.get_model_info.return_value = { @@ -648,104 +664,320 @@ def test_process_audio_native_failure_writes_python_identical_artifacts(tmp_path "device": "cpu", "compute_type": "int8", } - embeddings_data = { - "embeddings": np.zeros((1, 256), dtype=np.float32), - "statement_ids": np.zeros((1,), dtype=np.int32), - "durations_s": np.zeros((1,), dtype=np.float32), - "encoder": np.array("test"), - } - def run_case(case: str, native_result: NativeSpeakerAnalysisResult): - raw_path = ( - tmp_path - / case - / "chronicle" - / "20260416" - / "default" - / "120000_300" - / "audio.m4a" - ) - raw_path.parent.mkdir(parents=True) - raw_path.write_bytes(b"\x00" * 2048) - audio_buffer = np.zeros(10 * SAMPLE_RATE, dtype=np.float32) - vad_result = VadResult( - duration=10.0, - speech_duration=5.0, - has_speech=True, - speech_segments=[(1.0, 6.0)], - ) - with ( - patch( - "solstone.observe.transcribe.main.get_journal", - return_value=str(raw_path.parents[4]), - ), - patch( - "solstone.observe.transcribe.main.get_config", - return_value={"transcribe": {"preserve_all": False}}, - ), - patch( - "solstone.observe.transcribe.main.stt_transcribe", - return_value=statements, - ), - patch( - "solstone.observe.transcribe.main.get_backend", - return_value=backend_module, - ), - patch( - "solstone.observe.transcribe.main._embed_statements", - return_value=embeddings_data, - ), - patch( - "solstone.observe.transcribe.overlap.compute_overlap_and_logprobs", - return_value=_overlap_result(0.0), - ), - patch( - "solstone.observe.processing_record.now_iso_utc", - return_value="2026-06-30T12:00:00Z", + with ( + patch( + "solstone.observe.transcribe.main.get_journal", + return_value=str(raw_path.parents[4]), + ), + patch( + "solstone.observe.transcribe.main.get_config", + return_value={"transcribe": {"preserve_all": False}}, + ), + patch( + "solstone.observe.transcribe.main.stt_transcribe", return_value=statements + ), + patch( + "solstone.observe.transcribe.main.get_backend", return_value=backend_module + ), + patch( + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + side_effect=SpeakerAnalyzeError( + path=raw_path, + stage="invoke", + reason="unavailable", + native_exit_code=75, ), - patch( - "solstone.observe.transcribe.speakers_analyze_seam." - "maybe_run_native_speaker_analysis", - return_value=native_result, + ), + patch("solstone.observe.transcribe.main.callosum_send") as mock_send, + ): + with pytest.raises(SpeakerAnalyzeError): + process_audio(raw_path, audio_buffer, vad_result, {}, backend="parakeet") + + assert raw_path.exists() + assert not raw_path.with_suffix(".jsonl").exists() + assert not raw_path.with_suffix(".npz").exists() + assert mock_send.call_count == 1 + assert mock_send.call_args.args[:2] == ("observe", "transcribed") + kwargs = mock_send.call_args.kwargs + assert kwargs["outcome"] == "failed" + assert kwargs["reason"] == SPEAKER_ANALYSIS_FAILURE_REASON + assert kwargs["error"] == SPEAKER_ANALYSIS_FAILURE_LABEL + assert kwargs["speaker_analysis_failure_path"] == "native" + assert kwargs["speaker_analysis_failure_stage"] == "invoke" + assert kwargs["speaker_analysis_failure_reason"] == "unavailable" + assert kwargs["speaker_analysis_failure_native_exit_code"] == 75 + + +def test_process_audio_native_failure_emit_error_does_not_write_artifacts( + tmp_path, + caplog: pytest.LogCaptureFixture, +): + from solstone.observe.transcribe.main import process_audio + from solstone.observe.transcribe.speakers_analyze_errors import SpeakerAnalyzeError + + raw_path = ( + tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.m4a" + ) + raw_path.parent.mkdir(parents=True) + raw_path.write_bytes(b"\x00" * 2048) + before = repository_inventory(tmp_path) + audio_buffer = np.zeros(10 * SAMPLE_RATE, dtype=np.float32) + vad_result = VadResult( + duration=10.0, + speech_duration=5.0, + has_speech=True, + speech_segments=[(1.0, 6.0)], + ) + statements = [{"id": 0, "start": 0.0, "end": 1.0, "text": "hi"}] + backend_module = MagicMock() + backend_module.get_model_info.return_value = { + "model": "medium.en", + "device": "cpu", + "compute_type": "int8", + } + + with ( + patch( + "solstone.observe.transcribe.main.get_journal", + return_value=str(raw_path.parents[4]), + ), + patch( + "solstone.observe.transcribe.main.get_config", + return_value={"transcribe": {"preserve_all": False}}, + ), + patch( + "solstone.observe.transcribe.main.stt_transcribe", return_value=statements + ), + patch( + "solstone.observe.transcribe.main.get_backend", return_value=backend_module + ), + patch( + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + side_effect=SpeakerAnalyzeError( + path=raw_path, + stage="invoke", + reason="timeout", ), - patch("solstone.observe.transcribe.main.callosum_send"), - ): + ), + patch( + "solstone.observe.transcribe.main.callosum_send", + side_effect=RuntimeError("callosum down"), + ) as mock_send, + caplog.at_level("ERROR"), + ): + with pytest.raises(SpeakerAnalyzeError): process_audio(raw_path, audio_buffer, vad_result, {}, backend="parakeet") - return raw_path.with_suffix(".jsonl").read_bytes(), load_npz( - raw_path.with_suffix(".npz") - ) - fallback_jsonl, fallback_npz = run_case( - "fallback", - NativeSpeakerAnalysisResult( - status="fallback", - event_fields={ - "speaker_analysis_path": "native_to_python", - "speaker_analysis_degradation": "native_failure", - "speaker_analysis_stage": "invoke", - "speaker_analysis_reason": "unavailable", - "speaker_analysis_native_exit_code": 75, - }, + assert mock_send.call_count == 1 + assert "Failed to emit transcription failure event" in caplog.text + assert raw_path.exists() + assert not raw_path.with_suffix(".jsonl").exists() + assert not raw_path.with_suffix(".npz").exists() + assert_inventory_unchanged(before, repository_inventory(tmp_path)) + + +def test_all_batch_typed_speaker_failure_continues_and_preserves_failed_audio( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + from solstone.observe.transcribe.main import main + from solstone.observe.transcribe.speakers_analyze_errors import SpeakerAnalyzeError + from solstone.think.speakers_analyze_installation import ( + SpeakersAnalyzeInstallationResult, + ) + + first = ( + tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.flac" + ) + second = ( + tmp_path / "chronicle" / "20260416" / "default" / "121000_300" / "audio.flac" + ) + first.parent.mkdir(parents=True) + second.parent.mkdir(parents=True) + first.write_bytes(b"first") + second.write_bytes(b"second") + statements = [{"id": 0, "start": 0.0, "end": 1.0, "text": "hi"}] + backend_module = MagicMock() + backend_module.get_model_info.return_value = { + "model": "medium.en", + "device": "cpu", + "compute_type": "int8", + } + + def fake_analyze_speakers(**kwargs): + raw_path = kwargs["raw_path"] + if raw_path == first: + raise SpeakerAnalyzeError( + path=raw_path, + stage="invoke", + reason="unavailable", + native_exit_code=75, + ) + return _speaker_result(statements) + + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + monkeypatch.setattr(sys, "argv", ["journal transcribe", "--all"]) + with ( + patch( + "solstone.think.speakers_analyze_installation." + "check_speakers_analyze_installation", + return_value=SpeakersAnalyzeInstallationResult("ok"), ), + patch( + "solstone.observe.transcribe.main.read_available_bytes", + return_value=8 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.stt_local_floor_bytes", + return_value=4 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.local_stt_backend", + return_value="parakeet", + ), + patch( + "solstone.observe.transcribe.main.load_audio", + return_value=np.zeros(10 * SAMPLE_RATE, dtype=np.float32), + ), + patch( + "solstone.observe.vad.run_vad", + return_value=VadResult( + duration=10.0, + speech_duration=5.0, + has_speech=True, + speech_segments=[(1.0, 6.0)], + ), + ), + patch("solstone.observe.vad.reduce_audio", return_value=(None, None)), + patch("solstone.observe.transcribe.main.tag_audio", return_value=None), + patch( + "solstone.observe.transcribe.main.stt_transcribe", + return_value=statements, + ), + patch( + "solstone.observe.transcribe.main.get_backend", + return_value=backend_module, + ), + patch( + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + side_effect=fake_analyze_speakers, + ), + patch("solstone.observe.transcribe.main.callosum_send") as mock_send, + ): + main() + + captured = capsys.readouterr() + assert "1 processed" in captured.out + assert "1 failed" in captured.out + assert first.exists() + assert not first.with_suffix(".jsonl").exists() + assert not first.with_suffix(".npz").exists() + assert second.with_suffix(".jsonl").exists() + speaker_failure_events = [ + call + for call in mock_send.call_args_list + if call.kwargs.get("outcome") == "failed" + and call.kwargs.get("speaker_analysis_failure_path") == "native" + ] + assert len(speaker_failure_events) == 1 + + +def test_single_file_typed_speaker_failure_emits_once_and_exits_one( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +): + from solstone.observe.transcribe.main import main + from solstone.observe.transcribe.speakers_analyze_errors import SpeakerAnalyzeError + from solstone.think.speakers_analyze_installation import ( + SpeakersAnalyzeInstallationResult, ) - python_jsonl, python_npz = run_case( - "python", - NativeSpeakerAnalysisResult(status="python"), + + raw_path = ( + tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.flac" ) + raw_path.parent.mkdir(parents=True) + raw_path.write_bytes(b"audio") + statements = [{"id": 0, "start": 0.0, "end": 1.0, "text": "hi"}] + backend_module = MagicMock() + backend_module.get_model_info.return_value = { + "model": "medium.en", + "device": "cpu", + "compute_type": "int8", + } - assert fallback_jsonl == python_jsonl - assert fallback_npz is not None - assert python_npz is not None - assert set(fallback_npz) == set(python_npz) - for key in fallback_npz: - np.testing.assert_array_equal(fallback_npz[key], python_npz[key]) + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + monkeypatch.setattr(sys, "argv", ["journal transcribe", str(raw_path)]) + with ( + patch( + "solstone.think.speakers_analyze_installation." + "check_speakers_analyze_installation", + return_value=SpeakersAnalyzeInstallationResult("ok"), + ), + patch( + "solstone.observe.transcribe.main.read_available_bytes", + return_value=8 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.stt_local_floor_bytes", + return_value=4 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.local_stt_backend", + return_value="parakeet", + ), + patch( + "solstone.observe.transcribe.main.load_audio", + return_value=np.zeros(10 * SAMPLE_RATE, dtype=np.float32), + ), + patch( + "solstone.observe.vad.run_vad", + return_value=VadResult( + duration=10.0, + speech_duration=5.0, + has_speech=True, + speech_segments=[(1.0, 6.0)], + ), + ), + patch("solstone.observe.vad.reduce_audio", return_value=(None, None)), + patch("solstone.observe.transcribe.main.tag_audio", return_value=None), + patch( + "solstone.observe.transcribe.main.stt_transcribe", + return_value=statements, + ), + patch( + "solstone.observe.transcribe.main.get_backend", + return_value=backend_module, + ), + patch( + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + side_effect=SpeakerAnalyzeError( + path=raw_path, + stage="invoke", + reason="timeout", + ), + ), + patch("solstone.observe.transcribe.main.callosum_send") as mock_send, + ): + with pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code == 1 + assert raw_path.exists() + assert not raw_path.with_suffix(".jsonl").exists() + assert not raw_path.with_suffix(".npz").exists() + speaker_failure_events = [ + call + for call in mock_send.call_args_list + if call.kwargs.get("outcome") == "failed" + and call.kwargs.get("speaker_analysis_failure_path") == "native" + ] + assert len(speaker_failure_events) == 1 def test_process_audio_zero_row_native_response_writes_no_embedding_archive(tmp_path): from solstone.observe.transcribe.main import process_audio - from solstone.observe.transcribe.speakers_analyze_seam import ( - NativeSpeakerAnalysisResult, - ) raw_path = ( tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.m4a" @@ -759,18 +991,12 @@ def test_process_audio_zero_row_native_response_writes_no_embedding_archive(tmp_ "device": "cpu", "compute_type": "int8", } - native_result = NativeSpeakerAnalysisResult( - status="accepted", + native_result = SpeakerAnalyzeResult( statements=statements, embeddings_data=None, speaker_evidence=SpeakerEvidenceDecision("single", 0.0, 0.0), overlap_fraction=0.0, - event_fields={ - "speaker_analysis_path": "native", - "speaker_analysis_degradation": "gate_decline", - "speaker_analysis_stage": "evidence_gate", - "speaker_analysis_reason": "single", - }, + statement_labels=None, ) with ( @@ -789,12 +1015,7 @@ def test_process_audio_zero_row_native_response_writes_no_embedding_archive(tmp_ "solstone.observe.transcribe.main.get_backend", return_value=backend_module ), patch( - "solstone.observe.transcribe.main._embed_statements", - side_effect=AssertionError("Python embedder should not run"), - ), - patch( - "solstone.observe.transcribe.speakers_analyze_seam." - "maybe_run_native_speaker_analysis", + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", return_value=native_result, ), patch("solstone.observe.transcribe.main.callosum_send"), @@ -816,11 +1037,8 @@ def test_process_audio_zero_row_native_response_writes_no_embedding_archive(tmp_ assert not raw_path.with_suffix(".npz").exists() -def test_process_audio_python_selection_restores_once_after_embedding(tmp_path): +def test_process_audio_native_adapter_restores_once_after_stt(tmp_path): from solstone.observe.transcribe.main import process_audio - from solstone.observe.transcribe.speakers_analyze_seam import ( - NativeSpeakerAnalysisResult, - ) raw_path = ( tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.m4a" @@ -839,18 +1057,15 @@ def test_process_audio_python_selection_restores_once_after_embedding(tmp_path): "device": "cpu", "compute_type": "int8", } - embeddings_data = { - "embeddings": np.zeros((1, 256), dtype=np.float32), - "statement_ids": np.zeros((1,), dtype=np.int32), - "durations_s": np.zeros((1,), dtype=np.float32), - "encoder": np.array("test"), - } - embed_seen: list[list[dict]] = [] + adapter_seen: list[list[dict]] = [] restore_seen: list[list[dict]] = [] - def fake_embed(_audio, seen_statements, _sample_rate): - embed_seen.append([dict(statement) for statement in seen_statements]) - return embeddings_data + def fake_analyze_speakers(**kwargs): + adapter_seen.append( + [dict(statement) for statement in kwargs["statements_pre_restore"]] + ) + restored = kwargs["statements_restored"]() + return _speaker_result(restored) def fake_restore(seen_statements, _reduction): restore_seen.append([dict(statement) for statement in seen_statements]) @@ -879,16 +1094,8 @@ def test_process_audio_python_selection_restores_once_after_embedding(tmp_path): "solstone.observe.transcribe.main.get_backend", return_value=backend_module ), patch( - "solstone.observe.transcribe.main._embed_statements", side_effect=fake_embed - ), - patch( - "solstone.observe.transcribe.overlap.compute_overlap_and_logprobs", - return_value=_overlap_result(0.0), - ), - patch( - "solstone.observe.transcribe.speakers_analyze_seam." - "maybe_run_native_speaker_analysis", - return_value=NativeSpeakerAnalysisResult(status="python"), + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + side_effect=fake_analyze_speakers, ), patch( "solstone.observe.vad.restore_statement_timestamps", @@ -911,7 +1118,7 @@ def test_process_audio_python_selection_restores_once_after_embedding(tmp_path): backend="parakeet", ) - assert embed_seen == [statements] + assert adapter_seen == [statements] assert restore_seen == [statements] @@ -965,12 +1172,8 @@ def test_process_audio_records_analyzed_processing(tmp_path): "solstone.observe.transcribe.main.get_backend", return_value=backend_module ), patch( - "solstone.observe.transcribe.main._embed_statements", - return_value=embeddings_data, - ), - patch( - "solstone.observe.transcribe.overlap.compute_overlap_and_logprobs", - return_value=_overlap_result(0.0), + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + return_value=_speaker_result(statements, embeddings_data=embeddings_data), ), patch( "solstone.observe.processing_record.now_iso_utc", @@ -1048,7 +1251,7 @@ def test_process_audio_silent_filtered_writes_empty_record(tmp_path): assert mock_send.call_args.kwargs["outcome"] == "filtered" -def test_process_audio_diarizer_failure_is_fail_soft(tmp_path): +def test_process_audio_native_gate_decline_is_accepted(tmp_path): from solstone.observe.transcribe.main import process_audio raw_path = ( @@ -1086,14 +1289,13 @@ def test_process_audio_diarizer_failure_is_fail_soft(tmp_path): patch( "solstone.observe.transcribe.main.get_backend", return_value=backend_module ), - patch("solstone.observe.transcribe.main._embed_statements", return_value=None), patch( - "solstone.observe.transcribe.overlap.compute_overlap_and_logprobs", - return_value=_overlap_result(0.5, window_stats=MULTI_STATS), - ), - patch( - "solstone.observe.transcribe.diarize.diarize_auto_k", - side_effect=RuntimeError("boom"), + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + return_value=_speaker_result( + statements, + speaker_evidence=SpeakerEvidenceDecision("single", 0.0, 0.0), + statement_labels=None, + ), ), patch("solstone.observe.transcribe.main.callosum_send") as mock_send, ): @@ -1108,7 +1310,7 @@ def test_process_audio_diarizer_failure_is_fail_soft(tmp_path): assert "speaker" not in json.loads(lines[1]) -def test_process_audio_diarizes_parakeet_cpp_when_overlap_meets_threshold(tmp_path): +def test_process_audio_writes_native_statement_labels(tmp_path): from solstone.observe.transcribe.main import process_audio raw_path = ( @@ -1130,7 +1332,7 @@ def test_process_audio_diarizes_parakeet_cpp_when_overlap_meets_threshold(tmp_pa "device": "cpu", "compute_type": "int8", } - logprobs = np.zeros((589, 7), dtype=np.float32) + labeled_statements = [{**statements[0], "speaker": 2}] with ( patch( @@ -1147,23 +1349,18 @@ def test_process_audio_diarizes_parakeet_cpp_when_overlap_meets_threshold(tmp_pa patch( "solstone.observe.transcribe.main.get_backend", return_value=backend_module ), - patch("solstone.observe.transcribe.main._embed_statements", return_value=None), patch( - "solstone.observe.transcribe.overlap.compute_overlap_and_logprobs", - return_value=_overlap_result(0.5, logprobs, CLEAN_SINGLE_STATS), + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + return_value=_speaker_result( + labeled_statements, + speaker_evidence=SpeakerEvidenceDecision("multi", 1.0, 0.5), + overlap_fraction=0.5, + statement_labels=[2], + ), ), - patch( - "solstone.observe.transcribe.diarize.diarize_auto_k", - return_value=[2], - ) as mock_diarize, patch("solstone.observe.transcribe.main.callosum_send"), ): - process_audio(raw_path, audio_buffer, vad_result, {}, backend="parakeet-cpp") - - mock_diarize.assert_called_once() - kwargs = mock_diarize.call_args.kwargs - assert kwargs["avg_log_probs"] is logprobs - assert kwargs["audio"] is audio_buffer + process_audio(raw_path, audio_buffer, vad_result, {}, backend="parakeet") jsonl_path = raw_path.with_suffix(".jsonl") lines = jsonl_path.read_text(encoding="utf-8").splitlines() @@ -1271,28 +1468,27 @@ class TestJSONLFormat: assert metadata["speaker_evidence_version"] == "windowed-slots-v1" assert "speaker_evidence_mean_window_overlap_share" not in metadata - def test_statements_to_jsonl_speaker_analysis_producer_is_opt_in(self): - python_lines = _statements_to_jsonl( + def test_new_headers_always_include_speaker_analysis_producer(self): + no_helper_lines = _statements_to_jsonl( [{"start": 1.0, "end": 2.0, "text": "Hello"}], "audio.flac", datetime(2026, 5, 22, 9, 0, 0), {"model": "unit", "device": "cpu", "compute_type": "int8"}, ) - native_lines = _statements_to_jsonl( + helper_lines = _statements_to_jsonl( [{"start": 1.0, "end": 2.0, "text": "Hello"}], "audio.flac", datetime(2026, 5, 22, 9, 0, 0), {"model": "unit", "device": "cpu", "compute_type": "int8"}, - speaker_analysis_producer="solstone-core-speakers-analyze-v1", + speaker_analysis_producer=SPEAKERS_ANALYZE_PRODUCER_ID, ) - python_metadata = json.loads(python_lines[0]) - native_metadata = json.loads(native_lines[0]) + no_helper_metadata = json.loads(no_helper_lines[0]) + helper_metadata = json.loads(helper_lines[0]) - assert "speaker_analysis_producer" not in python_metadata + assert "speaker_analysis_producer" not in no_helper_metadata assert ( - native_metadata["speaker_analysis_producer"] - == "solstone-core-speakers-analyze-v1" + helper_metadata["speaker_analysis_producer"] == SPEAKERS_ANALYZE_PRODUCER_ID ) def test_metadata_first_line(self): diff --git a/tests/test_transcribe_cli.py b/tests/test_transcribe_cli.py index 27774444f..50d38e0b3 100644 --- a/tests/test_transcribe_cli.py +++ b/tests/test_transcribe_cli.py @@ -12,10 +12,23 @@ from unittest.mock import MagicMock, patch import pytest +from solstone.observe.transcribe.speakers_analyze_errors import SpeakerAnalyzeError from solstone.observe.vad import VadResult +from solstone.think.speakers_analyze_installation import ( + SpeakersAnalyzeInstallationResult, +) from tests.helpers.journal_config import seed_journal_config +@pytest.fixture(autouse=True) +def _speaker_installation_ready(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "solstone.think.speakers_analyze_installation." + "check_speakers_analyze_installation", + lambda: SpeakersAnalyzeInstallationResult("ok"), + ) + + def _args(backend: str | None = None) -> argparse.Namespace: return argparse.Namespace(backend=backend, cpu=False, model=None, redo=False) @@ -217,6 +230,157 @@ def test_all_batch_processes_unprocessed_skips_transcribed( assert "1 skipped" in captured.out +def test_all_batch_counts_typed_speaker_failure_and_continues( + tmp_path, monkeypatch, capsys +): + journal = _make_batch_journal(tmp_path) + extra = journal / "chronicle" / "20260101" / "default" / "180000_300" / "audio.flac" + extra.touch() + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) + monkeypatch.setattr("sys.argv", ["sol transcribe", "--all"]) + + calls: list[Path] = [] + + def process_one(audio_path, *_args): + calls.append(audio_path) + if len(calls) == 1: + raise SpeakerAnalyzeError( + path=audio_path, + stage="invoke", + reason="unavailable", + native_exit_code=75, + ) + + with ( + patch("solstone.observe.transcribe.main._process_one", side_effect=process_one), + patch( + "solstone.observe.transcribe.main.read_available_bytes", + return_value=8 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.stt_local_floor_bytes", + return_value=4 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.local_stt_backend", + return_value="parakeet", + ), + ): + from solstone.observe.transcribe.main import main + + main() + + assert len(calls) == 2 + captured = capsys.readouterr() + assert "1 processed" in captured.out + assert "1 skipped" in captured.out + assert "1 failed" in captured.out + + +def test_all_batch_generic_exception_aborts(tmp_path, monkeypatch): + journal = _make_batch_journal(tmp_path) + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) + monkeypatch.setattr("sys.argv", ["sol transcribe", "--all"]) + + with ( + patch( + "solstone.observe.transcribe.main._process_one", + side_effect=RuntimeError("boom"), + ), + patch( + "solstone.observe.transcribe.main.read_available_bytes", + return_value=8 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.stt_local_floor_bytes", + return_value=4 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.local_stt_backend", + return_value="parakeet", + ), + ): + from solstone.observe.transcribe.main import main + + with pytest.raises(RuntimeError, match="boom"): + main() + + +def test_all_batch_provider_blocked_is_deferred_not_failed( + tmp_path, monkeypatch, capsys +): + from solstone.observe.exit_codes import EXIT_PROVIDER_BLOCKED + + journal = _make_batch_journal(tmp_path) + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) + monkeypatch.setattr("sys.argv", ["sol transcribe", "--all"]) + + with ( + patch( + "solstone.observe.transcribe.main._process_one", + side_effect=SystemExit(EXIT_PROVIDER_BLOCKED), + ), + patch( + "solstone.observe.transcribe.main.read_available_bytes", + return_value=8 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.stt_local_floor_bytes", + return_value=4 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.local_stt_backend", + return_value="parakeet", + ), + ): + from solstone.observe.transcribe.main import main + + main() + + captured = capsys.readouterr() + assert "1 deferred" in captured.out + assert "failed" not in captured.out + + +def test_single_file_typed_speaker_failure_exits_one(tmp_path, monkeypatch): + journal = _make_batch_journal(tmp_path) + audio_file = ( + journal / "chronicle" / "20260101" / "default" / "090000_300" / "audio.flac" + ) + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) + monkeypatch.setattr("sys.argv", ["sol transcribe", str(audio_file)]) + + with ( + patch( + "solstone.observe.transcribe.main._process_one", + side_effect=SpeakerAnalyzeError( + path=audio_file, + stage="invoke", + reason="unavailable", + native_exit_code=75, + ), + ), + patch( + "solstone.observe.transcribe.main.read_available_bytes", + return_value=8 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.stt_local_floor_bytes", + return_value=4 * 1024**3, + ), + patch( + "solstone.observe.transcribe.main.local_stt_backend", + return_value="parakeet", + ), + ): + from solstone.observe.transcribe.main import main + + with pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code == 1 + + def test_all_redo_reprocesses_transcribed(tmp_path, monkeypatch): """--all --redo reprocesses even segments that already have .jsonl.""" journal = _make_batch_journal(tmp_path) diff --git a/tests/test_transcribe_diarize.py b/tests/test_transcribe_diarize.py index 80cc01570..201d0a7e8 100644 --- a/tests/test_transcribe_diarize.py +++ b/tests/test_transcribe_diarize.py @@ -51,7 +51,7 @@ def _dummy_features(_audio_slice: np.ndarray) -> np.ndarray: def test_diarize_precomputed_logprobs_emits_one_indexed_int(monkeypatch): - from solstone.observe.transcribe import diarize + from tests.speaker_oracle import diarize def fail_pyannote(): raise AssertionError("precomputed logprobs should skip pyannote") @@ -80,7 +80,7 @@ def test_diarize_precomputed_logprobs_emits_one_indexed_int(monkeypatch): def test_find_intervals_filters_to_confident_single_speaker_runs(): - from solstone.observe.transcribe import diarize + from tests.speaker_oracle import diarize classes = np.concatenate( [ @@ -110,7 +110,7 @@ def test_find_intervals_filters_to_confident_single_speaker_runs(): def test_auto_k_clusters_well_separated_embeddings_by_invariant(monkeypatch): - from solstone.observe.transcribe import diarize + from tests.speaker_oracle import diarize embs = np.zeros((6, 256), dtype=np.float32) embs[0, 0] = 1.0 @@ -149,7 +149,7 @@ def test_auto_k_clusters_well_separated_embeddings_by_invariant(monkeypatch): def test_get_pyannote_session_missing_asset_raises_file_not_found( monkeypatch, tmp_path ): - from solstone.observe.transcribe import diarize + from tests.speaker_oracle import diarize monkeypatch.setattr( diarize, diff --git a/tests/test_transcribe_embedder.py b/tests/test_transcribe_embedder.py index 54ae9952a..4ca1b0309 100644 --- a/tests/test_transcribe_embedder.py +++ b/tests/test_transcribe_embedder.py @@ -9,14 +9,15 @@ import platform import numpy as np import pytest -from solstone.observe.transcribe.main import ( - EMBEDDER_NAME, +from tests.speaker_oracle import embedder +from tests.speaker_oracle.embedder import ( + ENCODER_ID, _compute_wespeaker_features, _embed_statements, _select_onnx_providers, ) -transcribe_main = importlib.import_module("solstone.observe.transcribe.main") +oracle_embedder = importlib.import_module("tests.speaker_oracle.embedder") class _Input: @@ -41,14 +42,14 @@ class _WeSpeakerStubSession: def test_embed_synthetic_shape_and_provenance(monkeypatch) -> None: - monkeypatch.setattr(transcribe_main, "_embedder_session", None) + monkeypatch.setattr(embedder, "_embedder_session", None) monkeypatch.setattr( - transcribe_main, + oracle_embedder, "_get_embedder_session", lambda: _WeSpeakerStubSession(), ) monkeypatch.setattr( - transcribe_main, + oracle_embedder, "_compute_wespeaker_features", lambda _audio, _sr: np.zeros((10, 80), dtype=np.float32), ) @@ -63,7 +64,7 @@ def test_embed_synthetic_shape_and_provenance(monkeypatch) -> None: assert result["embeddings"].shape == (1, 256) assert result["embeddings"].dtype == np.float32 assert result["statement_ids"].tolist() == [1] - assert result["encoder"].item() == EMBEDDER_NAME + assert result["encoder"].item() == ENCODER_ID def test_compute_wespeaker_features_applies_cmn() -> None: diff --git a/tests/test_transcribe_empty_result.py b/tests/test_transcribe_empty_result.py index ca05e1cea..0228da02e 100644 --- a/tests/test_transcribe_empty_result.py +++ b/tests/test_transcribe_empty_result.py @@ -12,16 +12,14 @@ from unittest.mock import MagicMock, patch import numpy as np import pytest +from solstone.apps.speakers.evidence import SpeakerEvidenceDecision from solstone.observe.processing_record import ( HANDLER_TRANSCRIBE, REASON_NO_DECODABLE_AUDIO, SCHEMA, STATE_EMPTY, ) -from solstone.observe.transcribe.overlap import ( - OverlapInferenceResult, - SpeakerWindowStats, -) +from solstone.observe.transcribe.speakers_analyze_adapter import SpeakerAnalyzeResult from solstone.observe.utils import SAMPLE_RATE from solstone.observe.vad import VadResult from solstone.think.data_state import ( @@ -39,14 +37,15 @@ SOUND_TAGS = { "windows": 1, "tags": {"Music": 0.201, "Silence": 0.5}, } -CLEAN_SINGLE_STATS = (SpeakerWindowStats(589, 1, 0),) -def _overlap_result() -> OverlapInferenceResult: - return OverlapInferenceResult( - 0.0, - np.zeros((589, 7), dtype=np.float32), - CLEAN_SINGLE_STATS, +def _speaker_result(statements: list[dict]) -> SpeakerAnalyzeResult: + return SpeakerAnalyzeResult( + statements=[dict(statement) for statement in statements], + embeddings_data=None, + speaker_evidence=SpeakerEvidenceDecision("single", 0.0, 0.0), + overlap_fraction=0.0, + statement_labels=None, ) @@ -148,10 +147,9 @@ def test_process_audio_speech_writes_sound_tags_and_keeps_audio( "solstone.observe.transcribe.main.get_backend", return_value=_backend_module(), ), - patch("solstone.observe.transcribe.main._embed_statements", return_value=None), patch( - "solstone.observe.transcribe.overlap.compute_overlap_and_logprobs", - return_value=_overlap_result(), + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + return_value=_speaker_result(statements), ), patch("solstone.observe.transcribe.main.callosum_send") as mock_send, ): @@ -170,7 +168,9 @@ def test_process_audio_speech_writes_sound_tags_and_keeps_audio( assert mock_send.call_args.kwargs["outcome"] == "transcribed" -def test_empty_statements_filter_path(raw_path, audio_buffer, vad_result): +def test_zero_statement_header_omits_speaker_analysis_producer( + raw_path, audio_buffer, vad_result +): from solstone.observe.transcribe.main import process_audio with ( @@ -196,6 +196,7 @@ def test_empty_statements_filter_path(raw_path, audio_buffer, vad_result): assert jsonl_path.exists() header = _read_header(jsonl_path) _assert_empty_record(header) + assert "speaker_analysis_producer" not in header assert "sound_tags" not in header assert mock_send.call_args.args[:2] == ("observe", "transcribed") assert mock_send.call_args.kwargs["outcome"] == "filtered" diff --git a/tests/test_transcribe_overlap.py b/tests/test_transcribe_overlap.py index 29a6dd2d8..513aa5817 100644 --- a/tests/test_transcribe_overlap.py +++ b/tests/test_transcribe_overlap.py @@ -50,7 +50,7 @@ def _dominant_log_probs(classes: np.ndarray) -> np.ndarray: def test_compute_overlap_fraction_silent_audio_returns_zero(monkeypatch): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap monkeypatch.setattr( overlap, @@ -66,7 +66,7 @@ def test_compute_overlap_fraction_silent_audio_returns_zero(monkeypatch): def test_compute_overlap_fraction_short_audio_padded(monkeypatch): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap monkeypatch.setattr( overlap, @@ -83,7 +83,7 @@ def test_compute_overlap_fraction_short_audio_padded(monkeypatch): def test_compute_overlap_fraction_non_aligned_length(monkeypatch): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap monkeypatch.setattr( overlap, @@ -98,15 +98,15 @@ def test_compute_overlap_fraction_non_aligned_length(monkeypatch): def test_compute_overlap_fraction_rejects_wrong_sample_rate(): - from solstone.observe.transcribe.overlap import compute_overlap_fraction + from tests.speaker_oracle.overlap import compute_overlap_fraction with pytest.raises(ValueError, match="requires 16000 Hz audio"): compute_overlap_fraction(np.zeros(16000, dtype=np.float32), sample_rate=8000) def test_get_overlap_session_loads_and_caches(monkeypatch, tmp_path): - from solstone.observe import model_assets - from solstone.observe.transcribe import overlap + from solstone.think import model_assets + from tests.speaker_oracle import overlap model = tmp_path / "seg.onnx" model.write_bytes(b"stub") @@ -137,7 +137,7 @@ def test_get_overlap_session_loads_and_caches(monkeypatch, tmp_path): def test_compute_overlap_fraction_uses_conditioned_formula(monkeypatch): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap classes = np.concatenate( [ @@ -160,7 +160,7 @@ def test_compute_overlap_fraction_uses_conditioned_formula(monkeypatch): def test_compute_overlap_and_logprobs_returns_fraction_and_logprobs(monkeypatch): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap classes = np.concatenate( [ @@ -186,7 +186,7 @@ def test_compute_overlap_and_logprobs_returns_fraction_and_logprobs(monkeypatch) def test_decide_speaker_evidence_solo_one_slot_returns_single(): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap decision = overlap.decide_speaker_evidence( 0.0, @@ -198,7 +198,7 @@ def test_decide_speaker_evidence_solo_one_slot_returns_single(): def test_decide_speaker_evidence_slot_permuted_windows_return_single(monkeypatch): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap monkeypatch.setattr( overlap, @@ -225,7 +225,7 @@ def test_decide_speaker_evidence_slot_permuted_windows_return_single(monkeypatch def test_decide_speaker_evidence_turn_taking_returns_multi(): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap decision = overlap.decide_speaker_evidence( 0.0, @@ -236,7 +236,7 @@ def test_decide_speaker_evidence_turn_taking_returns_multi(): def test_decide_speaker_evidence_overlap_heavy_returns_multi(): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap decision = overlap.decide_speaker_evidence( 0.8, @@ -247,7 +247,7 @@ def test_decide_speaker_evidence_overlap_heavy_returns_multi(): def test_decide_speaker_evidence_all_silence_returns_none(): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap decision = overlap.decide_speaker_evidence( 0.0, @@ -259,7 +259,7 @@ def test_decide_speaker_evidence_all_silence_returns_none(): def test_decide_speaker_evidence_overlap_fraction_term_engages_multi(): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap decision = overlap.decide_speaker_evidence( 0.5, @@ -270,7 +270,7 @@ def test_decide_speaker_evidence_overlap_fraction_term_engages_multi(): def test_decide_speaker_evidence_branch_four_overlap_ambiguity_returns_multi(): - from solstone.observe.transcribe import overlap + from tests.speaker_oracle import overlap decision = overlap.decide_speaker_evidence( 0.0, diff --git a/tests/test_transcribe_parakeet_cpp_retry.py b/tests/test_transcribe_parakeet_cpp_retry.py index 173395a73..7dcc66584 100644 --- a/tests/test_transcribe_parakeet_cpp_retry.py +++ b/tests/test_transcribe_parakeet_cpp_retry.py @@ -15,6 +15,9 @@ from solstone.observe.utils import SAMPLE_RATE from solstone.observe.vad import VadResult from solstone.think.providers.parakeet_install import ParakeetProviderError from solstone.think.providers.parakeet_server import ParakeetServerNotReady +from solstone.think.speakers_analyze_installation import ( + SpeakersAnalyzeInstallationResult, +) @pytest.fixture @@ -40,6 +43,15 @@ def vad_result() -> VadResult: ) +@pytest.fixture(autouse=True) +def _speakers_analyze_installation_ready(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "solstone.think.speakers_analyze_installation." + "check_speakers_analyze_installation", + lambda: SpeakersAnalyzeInstallationResult("ok"), + ) + + def test_process_audio_parakeet_server_not_ready_defers_honestly( raw_path: Path, audio_buffer: np.ndarray, vad_result: VadResult ) -> None: diff --git a/tests/test_transcribe_telemetry.py b/tests/test_transcribe_telemetry.py index b7d008bad..bd3c0f98d 100644 --- a/tests/test_transcribe_telemetry.py +++ b/tests/test_transcribe_telemetry.py @@ -17,10 +17,8 @@ from unittest.mock import MagicMock, patch import numpy as np import pytest -from solstone.observe.transcribe.overlap import ( - OverlapInferenceResult, - SpeakerWindowStats, -) +from solstone.apps.speakers.evidence import SpeakerEvidenceDecision +from solstone.observe.transcribe.speakers_analyze_adapter import SpeakerAnalyzeResult from solstone.observe.utils import SAMPLE_RATE from solstone.observe.vad import VadResult from solstone.think.providers.parakeet_server import ParakeetServerNotReady @@ -29,14 +27,15 @@ from tests.helpers.module_mocks import module_mock # A string that exists nowhere but in the (mocked) transcript. If it shows up in a # serialized event, transcript content leaked into telemetry. TRANSCRIPT_SENTINEL = "zzq-secret-utterance-do-not-leak" -NO_SPEECH_STATS = (SpeakerWindowStats(0, 0, 0),) -def _overlap_result() -> OverlapInferenceResult: - return OverlapInferenceResult( - 0.0, - np.zeros((589, 7), dtype=np.float32), - NO_SPEECH_STATS, +def _speaker_result(statements: list[dict]) -> SpeakerAnalyzeResult: + return SpeakerAnalyzeResult( + statements=[dict(statement) for statement in statements], + embeddings_data=None, + speaker_evidence=SpeakerEvidenceDecision("none", 0.0, 0.0), + overlap_fraction=0.0, + statement_labels=None, ) @@ -102,10 +101,9 @@ def _run_success( "solstone.observe.transcribe.main.get_backend", return_value=backend_module or _backend_module(), ), - patch("solstone.observe.transcribe.main._embed_statements", return_value=None), patch( - "solstone.observe.transcribe.overlap.compute_overlap_and_logprobs", - return_value=_overlap_result(), + "solstone.observe.transcribe.speakers_analyze_adapter.analyze_speakers", + return_value=_speaker_result(statements), ), patch("solstone.observe.transcribe.main.callosum_send") as mock_send, ): @@ -174,9 +172,10 @@ def test_success_event_carries_stage_timings_and_envelope( assert kwargs["outcome"] == "transcribed" timings = kwargs["timings"] # Stages that ran inside process_audio. decode/vad/reduce are measured in - # _process_one, which this test calls past; no speech-bearing evidence - # resolves the speaker decision to none, so diarization is skipped. - assert {"asr_ms", "embed_ms", "overlap_ms", "write_ms"} <= set(timings) + # _process_one, which this test calls past; the speaker decision is native. + assert {"asr_ms", "speakers_analyze_ms", "write_ms"} <= set(timings) + assert "embed_ms" not in timings + assert "overlap_ms" not in timings assert "diarize_ms" not in timings assert all(isinstance(v, int) and v >= 0 for v in timings.values()) diff --git a/tests/verify_speaker_differential.py b/tests/verify_speaker_differential.py index 89da74b4e..bdeece9e0 100644 --- a/tests/verify_speaker_differential.py +++ b/tests/verify_speaker_differential.py @@ -1,13 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Differential harness for the local speaker pipeline. - -The bundle schema says ``statements`` everywhere because -``solstone.observe.transcribe.main`` owns the production pipeline vocabulary. -At the single boundary into ``solstone.observe.transcribe.diarize``, those same -records are passed as that module's local ``sentences`` parameter. -""" +"""Differential harness for the frozen Python speaker oracle.""" from __future__ import annotations @@ -26,7 +20,6 @@ from typing import Any import numpy as np -from solstone.observe.transcribe import diarize, overlap from solstone.observe.vad import AudioReduction, restore_statement_timestamps from solstone.think.utils import get_rev from tests._speaker_differential_fixtures import ( @@ -42,8 +35,7 @@ from tests._speaker_differential_fixtures import ( SAMPLE_RATE, STATEMENT_DURATION_ABS_TOLERANCE, ) - -transcribe_main = importlib.import_module("solstone.observe.transcribe.main") +from tests.speaker_oracle import diarize, embedder, overlap logger = logging.getLogger(__name__) @@ -238,7 +230,7 @@ def _provenance(producer: str) -> dict[str, Any]: }, "onnx_execution_providers": { "statement_encoder": _session_providers( - getattr(transcribe_main, "_embedder_session", None) + getattr(embedder, "_embedder_session", None) ), "interval_encoder": _session_providers( getattr(diarize, "_wespeaker_session", None) @@ -863,7 +855,7 @@ def emit_speaker_bundle( _refresh_provenance(bundle, producer) return bundle - embedding_result = transcribe_main._embed_statements( + embedding_result = embedder._embed_statements( stt_buffer, working_statements, sample_rate, diff --git a/tests/verify_speaker_verdict.py b/tests/verify_speaker_verdict.py index 4cbcb7911..651b854e4 100644 --- a/tests/verify_speaker_verdict.py +++ b/tests/verify_speaker_verdict.py @@ -47,10 +47,10 @@ from scipy.optimize import linear_sum_assignment from solstone.apps.speakers import encoder_config from solstone.apps.speakers.attribution import _passes_acoustic_margin -from solstone.observe.transcribe import diarize from solstone.think.entities.voiceprints import normalize_embedding from solstone.think.utils import get_rev from tests import verify_speaker_differential as differential +from tests.speaker_oracle import diarize logger = logging.getLogger(__name__) @@ -59,7 +59,7 @@ REFERENCE_CENTROIDS_SCHEMA = "solstone-speaker-verdict-centroids" SCHEMA_VERSION = 1 REFERENCE_CENTROIDS_MANIFEST_KEY = "__speaker_verdict_centroids_manifest_json__" -DIARIZE_MODULE = "solstone.observe.transcribe.diarize" +DIARIZE_MODULE = "tests.speaker_oracle.diarize" ENCODER_MODULE = "solstone.apps.speakers.encoder_config" _MISSING = object() diff --git a/uv.lock b/uv.lock index 156e467ad..455517e24 100644 --- a/uv.lock +++ b/uv.lock @@ -4271,7 +4271,6 @@ journal-host = [ { name = "huggingface-hub" }, { name = "icalendar" }, { name = "jsonschema" }, - { name = "kaldi-native-fbank" }, { name = "litellm" }, { name = "markdown" }, { name = "mistune" }, @@ -4321,6 +4320,7 @@ pdf-import = [ [package.dev-dependencies] dev = [ { name = "freezegun" }, + { name = "kaldi-native-fbank" }, { name = "onnx" }, { name = "pypdf" }, { name = "pytest" }, @@ -4352,7 +4352,6 @@ requires-dist = [ { name = "huggingface-hub", marker = "extra == 'journal-host'", specifier = ">=0.24" }, { name = "icalendar", marker = "extra == 'journal-host'" }, { name = "jsonschema", marker = "extra == 'journal-host'", specifier = ">=4.26,<5" }, - { name = "kaldi-native-fbank", marker = "extra == 'journal-host'", specifier = ">=1.22" }, { name = "litellm", marker = "extra == 'journal-host'", specifier = "==1.86.1" }, { name = "markdown", marker = "extra == 'journal-host'" }, { name = "mistune", marker = "extra == 'journal-host'" }, @@ -4406,6 +4405,7 @@ provides-extras = ["pdf-import", "pdf-export", "pdf", "journal-host", "journal", [package.metadata.requires-dev] dev = [ { name = "freezegun" }, + { name = "kaldi-native-fbank", specifier = ">=1.22" }, { name = "onnx" }, { name = "pypdf", specifier = ">=4.0.0" }, { name = "pytest" },