diff --git a/solstone/observe/transcribe/failure-and-telemetry.md b/solstone/observe/transcribe/failure-and-telemetry.md index 417e6a116..681f4ee76 100644 --- a/solstone/observe/transcribe/failure-and-telemetry.md +++ b/solstone/observe/transcribe/failure-and-telemetry.md @@ -14,7 +14,7 @@ event carries. Two rules govern everything here: | Exit | Meaning | Input file | Output | |------|---------|-----------|--------| -| `0` | Work is done. Either a transcript was written, or the clip was silence-filtered / preserved by policy. | Consumed or preserved per policy | `.jsonl` (+ `.npz`) written, or deliberately not written for filtered silence | +| `0` | Work is done. Either a transcript was written, or the clip was silence-filtered / preserved by policy. | Consumed or preserved per policy | Transcript `.jsonl` (+ `.npz`) written, or a header-only terminal `.jsonl` record written for filtered silence before raw removal | | `69` (`EXIT_PROVIDER_BLOCKED`) | **Honest deferral.** The STT provider could not do the work. Nothing was attempted downstream, nothing was written. | **Preserved on disk** | None | | `1` | Hard failure. Something broke that a retry will not fix on its own. | Preserved on disk | None | diff --git a/solstone/observe/transcribe/main.py b/solstone/observe/transcribe/main.py index 3708d2e0d..3e1144bf5 100644 --- a/solstone/observe/transcribe/main.py +++ b/solstone/observe/transcribe/main.py @@ -103,7 +103,7 @@ from solstone.observe.transcribe.resource import ( resolve_stt_backend_choice, stt_local_floor_bytes, ) -from solstone.observe.transcribe.sound_tags import is_salient, tag_audio +from solstone.observe.transcribe.sound_tags import tag_audio from solstone.observe.utils import ( SAMPLE_RATE, AudioDecodeError, @@ -987,44 +987,33 @@ def process_audio( vad_result.speech_duration, vad_result.duration, ) + _write_empty_processing_jsonl( + raw_path, + jsonl_path, + model_info=model_info, + observer=observer, + vad_result=vad_result, + segment_meta=segment_meta, + backend=resolved_backend, + sound_tags=sound_tags, + ) if preserve_all: outcome = "preserved" - _write_empty_processing_jsonl( - raw_path, - jsonl_path, - model_info=model_info, - observer=observer, - vad_result=vad_result, - segment_meta=segment_meta, - backend=resolved_backend, - sound_tags=sound_tags, - ) logging.info( f"No speech detected in {raw_path}, preserving file " f"(preserve_all=true, VAD: {vad_result.speech_duration:.1f}s " f"of {vad_result.duration:.1f}s)" ) - elif sound_tags is not None and is_salient(sound_tags["tags"]): + else: outcome = "filtered" - _write_empty_processing_jsonl( - raw_path, - jsonl_path, - model_info=model_info, - observer=observer, - vad_result=vad_result, - segment_meta=segment_meta, - backend=resolved_backend, - sound_tags=sound_tags, - ) logging.info( - "No speech detected in %s, wrote salient sound metadata before removing file", + "No speech detected in %s, wrote terminal empty marker before " + "removing file (VAD: %.1fs speech of %.1fs)", raw_path, + vad_result.speech_duration, + vad_result.duration, ) raw_path.unlink() - else: - outcome = "filtered" - logging.info(f"No speech detected in {raw_path}, removing file") - raw_path.unlink() _emit_transcribed( event, @@ -1385,46 +1374,32 @@ def _process_one( segment = get_segment_key(audio_path) event = _build_base_event(audio_path, vad_result, segment, observer) + _write_empty_processing_jsonl( + audio_path, + _get_jsonl_path(audio_path), + model_info={}, + observer=observer, + vad_result=vad_result, + segment_meta=None, + backend=None, + sound_tags=sound_tags, + ) if preserve_all: outcome = "preserved" - _write_empty_processing_jsonl( - audio_path, - _get_jsonl_path(audio_path), - model_info={}, - observer=observer, - vad_result=vad_result, - segment_meta=None, - backend=None, - sound_tags=sound_tags, - ) logging.info( f"Insufficient speech in {audio_path}, preserving file " f"(preserve_all=true, VAD: {vad_result.speech_duration:.1f}s " f"of {vad_result.duration:.1f}s, threshold: {min_speech_seconds:.1f}s)" ) - elif sound_tags is not None and is_salient(sound_tags["tags"]): - outcome = "filtered" - _write_empty_processing_jsonl( - audio_path, - _get_jsonl_path(audio_path), - model_info={}, - observer=observer, - vad_result=vad_result, - segment_meta=None, - backend=None, - sound_tags=sound_tags, - ) - logging.info( - "Insufficient speech in %s, wrote salient sound metadata before removing file", - audio_path, - ) - audio_path.unlink() else: outcome = "filtered" logging.info( - f"Insufficient speech in {audio_path}, removing file " - f"(VAD: {vad_result.speech_duration:.1f}s of {vad_result.duration:.1f}s, " - f"threshold: {min_speech_seconds:.1f}s)" + "Insufficient speech in %s, wrote terminal empty marker before " + "removing file (VAD: %.1fs of %.1fs, threshold: %.1fs)", + audio_path, + vad_result.speech_duration, + vad_result.duration, + min_speech_seconds, ) audio_path.unlink() diff --git a/tests/test_bad_media_corpus.py b/tests/test_bad_media_corpus.py index a3a945dac..609c20db6 100644 --- a/tests/test_bad_media_corpus.py +++ b/tests/test_bad_media_corpus.py @@ -520,10 +520,17 @@ def test_ac3_silent_audio_records_empty_no_stt(segment_journal, monkeypatch): preserve_all=False, ) - assert filtered_record is None + assert filtered_record is not None + _assert_processing_record( + filtered_record, + state=STATE_EMPTY, + reason_code=REASON_NO_DECODABLE_AUDIO, + handler=HANDLER_TRANSCRIBE, + ) assert filtered_stt.call_count == 0 assert not filter_audio.exists() - assert not filtered_jsonl.exists() + assert filtered_jsonl.exists() + assert len(filtered_jsonl.read_text(encoding="utf-8").splitlines()) == 1 def test_corrupt_audio_decode_records_failed_without_vad_or_stt( diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 6ad17ef40..67ddf4dc4 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -720,7 +720,12 @@ def test_process_audio_records_analyzed_processing(tmp_path): assert len(jsonl_path.read_text().splitlines()) >= 2 -def test_process_audio_silent_filtered_writes_no_processing_record(tmp_path): +def test_process_audio_silent_filtered_writes_empty_record(tmp_path): + from solstone.observe.processing_record import ( + HANDLER_TRANSCRIBE, + REASON_NO_DECODABLE_AUDIO, + STATE_EMPTY, + ) from solstone.observe.transcribe.main import process_audio raw_path = ( @@ -758,7 +763,14 @@ def test_process_audio_silent_filtered_writes_no_processing_record(tmp_path): ): process_audio(raw_path, audio_buffer, vad_result, {}, backend="parakeet") - assert not raw_path.with_suffix(".jsonl").exists() + jsonl_path = raw_path.with_suffix(".jsonl") + assert jsonl_path.exists() + header = json.loads(jsonl_path.read_text(encoding="utf-8").splitlines()[0]) + record = header["_solstone_processing"] + assert record["state"] == STATE_EMPTY + assert record["reason_code"] == REASON_NO_DECODABLE_AUDIO + assert record["handler"] == HANDLER_TRANSCRIBE + assert record["input_size"] == 1024 assert not raw_path.exists() assert mock_send.call_args.kwargs["outcome"] == "filtered" diff --git a/tests/test_transcribe_cli.py b/tests/test_transcribe_cli.py index 05c7322f8..58fa8e045 100644 --- a/tests/test_transcribe_cli.py +++ b/tests/test_transcribe_cli.py @@ -12,6 +12,8 @@ from unittest.mock import MagicMock, patch import pytest +from solstone.observe.vad import VadResult + def _args(backend: str | None = None) -> argparse.Namespace: return argparse.Namespace(backend=backend, cpu=False, model=None, redo=False) @@ -30,15 +32,18 @@ def test_main_accepts_journal_relative_path(tmp_path, monkeypatch): ) mock_load = MagicMock(return_value=MagicMock()) - mock_vad_result = MagicMock() - mock_vad_result.has_speech = False - mock_vad_result.speech_duration = 0.0 - mock_vad_result.duration = 5.0 + mock_vad_result = VadResult( + duration=5.0, + speech_duration=0.0, + has_speech=False, + speech_segments=[], + ) mock_vad = MagicMock(return_value=mock_vad_result) with ( patch("solstone.observe.transcribe.main.load_audio", mock_load), patch("solstone.observe.vad.run_vad", mock_vad), + patch("solstone.observe.transcribe.main.tag_audio", return_value=None), patch("solstone.observe.transcribe.main.callosum_send"), patch( "solstone.observe.transcribe.main.get_segment_key", diff --git a/tests/test_transcribe_empty_result.py b/tests/test_transcribe_empty_result.py index 791a0f4b9..701ba1b99 100644 --- a/tests/test_transcribe_empty_result.py +++ b/tests/test_transcribe_empty_result.py @@ -4,6 +4,7 @@ """Tests for empty-result handling in process_audio.""" import argparse +import datetime import json import logging from unittest.mock import MagicMock, patch @@ -11,9 +12,19 @@ from unittest.mock import MagicMock, patch import numpy as np import pytest -from solstone.observe.processing_record import REASON_NO_DECODABLE_AUDIO, STATE_EMPTY +from solstone.observe.processing_record import ( + HANDLER_TRANSCRIBE, + REASON_NO_DECODABLE_AUDIO, + SCHEMA, + STATE_EMPTY, +) from solstone.observe.utils import SAMPLE_RATE from solstone.observe.vad import VadResult +from solstone.think.data_state import ( + DataState, + derive_modality_state, + read_processing_record, +) SOUND_TAGS = { "engine": "ced.cpp v0.1.0", @@ -83,6 +94,21 @@ def _read_header(jsonl_path): return json.loads(jsonl_path.read_text(encoding="utf-8").splitlines()[0]) +def _read_jsonl_lines(jsonl_path): + return jsonl_path.read_text(encoding="utf-8").splitlines() + + +def _assert_empty_record(header, *, input_size=None): + record = header["_solstone_processing"] + assert record["schema"] == SCHEMA + assert record["state"] == STATE_EMPTY + assert record["reason_code"] == REASON_NO_DECODABLE_AUDIO + assert record["handler"] == HANDLER_TRANSCRIBE + if input_size is not None: + assert record["input_size"] == input_size + return record + + def test_process_audio_speech_writes_sound_tags_and_keeps_audio( raw_path, audio_buffer, @@ -152,7 +178,11 @@ def test_empty_statements_filter_path(raw_path, audio_buffer, vad_result): process_audio(raw_path, audio_buffer, vad_result, {}, backend="parakeet") assert not raw_path.exists() - assert not raw_path.with_suffix(".jsonl").exists() + jsonl_path = raw_path.with_suffix(".jsonl") + assert jsonl_path.exists() + header = _read_header(jsonl_path) + _assert_empty_record(header) + assert "sound_tags" not in header assert mock_send.call_args.args[:2] == ("observe", "transcribed") assert mock_send.call_args.kwargs["outcome"] == "filtered" @@ -198,7 +228,7 @@ def test_empty_statements_preserve_path(raw_path, audio_buffer, vad_result): assert mock_send.call_args.kwargs["outcome"] == "preserved" -def test_empty_statements_salient_writes_empty_jsonl_then_deletes_audio( +def test_empty_statements_with_tags_writes_empty_jsonl_then_deletes_audio( raw_path, audio_buffer, vad_result, @@ -237,7 +267,7 @@ def test_empty_statements_salient_writes_empty_jsonl_then_deletes_audio( assert mock_send.call_args.kwargs["outcome"] == "filtered" -def test_empty_statements_non_salient_deletes_without_jsonl( +def test_empty_statements_non_salient_writes_empty_record_then_deletes_audio( raw_path, audio_buffer, vad_result, @@ -270,7 +300,11 @@ def test_empty_statements_non_salient_deletes_without_jsonl( ) assert not raw_path.exists() - assert not raw_path.with_suffix(".jsonl").exists() + jsonl_path = raw_path.with_suffix(".jsonl") + assert jsonl_path.exists() + header = _read_header(jsonl_path) + _assert_empty_record(header) + assert header["sound_tags"] == SILENCE_SOUND_TAGS assert mock_send.call_args.kwargs["outcome"] == "filtered" @@ -355,7 +389,7 @@ def test_vad_no_speech_preserve_path_writes_empty_record( assert mock_send.call_args.kwargs["outcome"] == "preserved" -def test_vad_no_speech_salient_writes_empty_jsonl_then_deletes_audio( +def test_vad_no_speech_with_tags_writes_empty_jsonl_then_deletes_audio( raw_path, no_speech_vad_result, ): @@ -387,7 +421,7 @@ def test_vad_no_speech_salient_writes_empty_jsonl_then_deletes_audio( assert mock_send.call_args.kwargs["outcome"] == "filtered" -def test_vad_no_speech_non_salient_deletes_without_jsonl( +def test_vad_no_speech_non_salient_writes_empty_record_then_deletes_audio( raw_path, no_speech_vad_result, ): @@ -416,7 +450,11 @@ def test_vad_no_speech_non_salient_deletes_without_jsonl( ) assert not raw_path.exists() - assert not raw_path.with_suffix(".jsonl").exists() + jsonl_path = raw_path.with_suffix(".jsonl") + assert jsonl_path.exists() + header = _read_header(jsonl_path) + _assert_empty_record(header) + assert header["sound_tags"] == SILENCE_SOUND_TAGS assert mock_send.call_args.kwargs["outcome"] == "filtered" @@ -454,7 +492,7 @@ def test_vad_no_speech_write_failure_preserves_audio( assert not raw_path.with_suffix(".jsonl").exists() -def test_vad_no_speech_tagger_raise_degrades_to_existing_filter_path( +def test_vad_no_speech_tagger_raise_writes_empty_record_without_tags( raw_path, no_speech_vad_result, caplog: pytest.LogCaptureFixture, @@ -485,7 +523,11 @@ def test_vad_no_speech_tagger_raise_degrades_to_existing_filter_path( ) assert not raw_path.exists() - assert not raw_path.with_suffix(".jsonl").exists() + jsonl_path = raw_path.with_suffix(".jsonl") + assert jsonl_path.exists() + header = _read_header(jsonl_path) + _assert_empty_record(header) + assert "sound_tags" not in header assert mock_send.call_args.kwargs["outcome"] == "filtered" warnings = [ record @@ -496,6 +538,114 @@ def test_vad_no_speech_tagger_raise_degrades_to_existing_filter_path( assert len(warnings) == 1 +@pytest.mark.parametrize( + ("branch", "sound_tags"), + [ + pytest.param("empty_statements", SOUND_TAGS, id="empty-statements-tags"), + pytest.param("empty_statements", None, id="empty-statements-no-tags"), + pytest.param("vad_no_speech", SOUND_TAGS, id="vad-no-speech-tags"), + pytest.param("vad_no_speech", None, id="vad-no-speech-no-tags"), + ], +) +def test_filtered_raw_deletion_leaves_terminal_processing_record( + raw_path, + audio_buffer, + vad_result, + no_speech_vad_result, + branch, + sound_tags, +): + raw_path.write_bytes(b"raw audio bytes") + input_size = raw_path.stat().st_size + + if branch == "empty_statements": + from solstone.observe.transcribe.main import process_audio + + with ( + patch( + "solstone.observe.transcribe.main.get_config", + return_value={"transcribe": {"preserve_all": False}}, + ), + patch( + "solstone.observe.transcribe.main.get_journal", + return_value=str(raw_path.parents[4]), + ), + patch("solstone.observe.transcribe.main.stt_transcribe", return_value=[]), + patch( + "solstone.observe.transcribe.main.get_backend", + return_value=_backend_module(), + ), + patch("solstone.observe.transcribe.main.callosum_send"), + ): + process_audio( + raw_path, + audio_buffer, + vad_result, + {}, + backend="parakeet", + sound_tags=sound_tags, + ) + else: + from solstone.observe.transcribe.main import _process_one + + args = argparse.Namespace(backend=None, cpu=False, model=None, redo=False) + with ( + 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=no_speech_vad_result), + patch( + "solstone.observe.transcribe.main.tag_audio", return_value=sound_tags + ), + patch("solstone.observe.transcribe.main.callosum_send"), + ): + _process_one( + raw_path, + args, + {"preserve_all": False}, + "parakeet", + [], + ) + + jsonl_path = raw_path.with_suffix(".jsonl") + assert not raw_path.exists() + assert jsonl_path.exists() + lines = _read_jsonl_lines(jsonl_path) + assert len(lines) == 1 + + header = json.loads(lines[0]) + record = _assert_empty_record(header, input_size=input_size) + attempted_at = record["attempted_at"] + assert isinstance(attempted_at, str) + datetime.datetime.strptime(attempted_at, "%Y-%m-%dT%H:%M:%SZ") + assert record == { + "schema": SCHEMA, + "state": STATE_EMPTY, + "reason_code": REASON_NO_DECODABLE_AUDIO, + "handler": HANDLER_TRANSCRIBE, + "attempted_at": attempted_at, + "input_size": input_size, + } + if sound_tags is None: + assert "sound_tags" not in header + else: + assert header["sound_tags"] == sound_tags + + assert read_processing_record([header]) == record + assert ( + derive_modality_state( + raw_path.parent, + "audio", + has_chunks=False, + has_jsonl=True, + has_raw=False, + record=record, + ) + == DataState.EMPTY.value + ) + + def test_backend_raise_propagates(raw_path, audio_buffer, vad_result): from solstone.observe.transcribe.main import process_audio