import numpy as np import pytest from src.atmos.models import ( AtmosValidationBuilder, AtmosValidationFindingCode, AtmosValidationMetadata, ) from src.atmos.silent_object_check import ( SILENT_OBJECT_WARN_DBFS, SilentObjectMeasurement, apply_silent_object_measurement, to_silent_object_measurement, ) _SILENT_OBJECT_KEY = AtmosValidationFindingCode.SILENT_OBJECT.metadata_key _SILENT_HEIGHT_KEY = AtmosValidationFindingCode.SILENT_HEIGHT.metadata_key def _atmos_metadata() -> AtmosValidationMetadata: return AtmosValidationMetadata( container="Wave", codec="PCM", codec_id="1", channels=12, sample_rate_hz=48000, bits_per_sample=24, is_truncated=False, adm_profile="Dolby Atmos Master", duration_ms=180500, ) class TestThreshold: def test_sony_qc_parity_value(self) -> None: # Locks the gate to Sony QC's blank-object scanner (SILENCE_THRESH_DB_DEFAULT). Unlike the # strict-> loudness gates, Sony's comparison is inclusive: a peak exactly at the threshold # IS silent. assert SILENT_OBJECT_WARN_DBFS == -120.0 class TestToSilentObjectMeasurement: @pytest.mark.parametrize( ("object_peak", "expect_silent"), [ # 10^(-120/20) == 1e-6 exactly: at the threshold is silent (inclusive <=). pytest.param(1e-6, True, id="exactly_at_threshold"), pytest.param(10 ** (-119.9 / 20), False, id="just_above_threshold"), pytest.param(0.0, True, id="digital_silence"), pytest.param(0.5, False, id="clearly_active"), ], ) def test_silence_boundary(self, object_peak: float, expect_silent: bool) -> None: channel_peaks = np.array([1.0, object_peak]) silent_object_measurement = to_silent_object_measurement(channel_peaks, (1,), ()) assert (silent_object_measurement.silent_object_track_indices == (1,)) is expect_silent def test_only_object_channels_are_scanned(self) -> None: # Channel 0 (a bed channel) is digitally silent but not an object — it must not be flagged. channel_peaks = np.array([0.0, 0.0, 0.7]) silent_object_measurement = to_silent_object_measurement(channel_peaks, (1, 2), ()) assert silent_object_measurement.silent_object_track_indices == (1,) assert silent_object_measurement.object_track_count == 2 def test_no_objects_yields_empty_measurement(self) -> None: silent_object_measurement = to_silent_object_measurement(np.array([0.0, 0.0]), (), ()) assert silent_object_measurement.object_track_count == 0 assert silent_object_measurement.silent_object_track_indices == () class TestToSilentObjectMeasurementHeights: def test_silent_and_active_heights_are_measured(self) -> None: channel_peaks = np.array([1.0, 0.0, 0.5, 0.0]) silent_object_measurement = to_silent_object_measurement(channel_peaks, (), (1, 2, 3)) assert silent_object_measurement.height_track_count == 3 assert silent_object_measurement.silent_height_track_indices == (1, 3) class TestApplySilentObjectMeasurement: def test_silent_objects_warn_and_store_metadata(self) -> None: atmos_validation_builder = AtmosValidationBuilder() atmos_validation_builder.metadata = _atmos_metadata() apply_silent_object_measurement( SilentObjectMeasurement( object_track_count=5, silent_object_track_indices=(10, 11), height_track_count=0, silent_height_track_indices=(), ), atmos_validation_builder, ) assert atmos_validation_builder.metadata is not None assert atmos_validation_builder.metadata.object_track_count == 5 assert atmos_validation_builder.metadata.silent_object_track_indices == (10, 11) assert _SILENT_OBJECT_KEY in atmos_validation_builder.warnings def test_active_objects_store_metadata_without_warning(self) -> None: atmos_validation_builder = AtmosValidationBuilder() atmos_validation_builder.metadata = _atmos_metadata() apply_silent_object_measurement( SilentObjectMeasurement( object_track_count=5, silent_object_track_indices=(), height_track_count=2, silent_height_track_indices=(), ), atmos_validation_builder, ) # The stored count is what distinguishes "objects present, all active" from a master where # classification found no objects at all and this check was vacuous. assert atmos_validation_builder.metadata is not None assert atmos_validation_builder.metadata.object_track_count == 5 assert atmos_validation_builder.metadata.silent_object_track_indices == () assert atmos_validation_builder.warnings == {} def test_no_metadata_still_emits_warnings(self) -> None: # metadata is None when the atmos mediainfo failed — the check still runs. atmos_validation_builder = AtmosValidationBuilder() apply_silent_object_measurement( SilentObjectMeasurement( object_track_count=1, silent_object_track_indices=(4,), height_track_count=0, silent_height_track_indices=(), ), atmos_validation_builder, ) assert _SILENT_OBJECT_KEY in atmos_validation_builder.warnings class TestSilentHeightGating: # Sony's sub-rule: silent bed height channels warn ONLY when no object is active — with active # objects the height content can legitimately live in the objects instead of the bed. @pytest.mark.parametrize( ("object_track_count", "silent_object_track_indices", "expect_height_warning"), [ pytest.param(3, (), False, id="active_objects_suppress_the_warning"), pytest.param(0, (), True, id="no_objects_at_all_warns"), pytest.param(2, (7, 8), True, id="all_objects_silent_warns"), pytest.param(2, (7,), False, id="one_active_object_suppresses"), ], ) def test_gating_on_active_objects( self, object_track_count: int, silent_object_track_indices: tuple[int, ...], expect_height_warning: bool, ) -> None: atmos_validation_builder = AtmosValidationBuilder() apply_silent_object_measurement( SilentObjectMeasurement( object_track_count=object_track_count, silent_object_track_indices=silent_object_track_indices, height_track_count=2, silent_height_track_indices=(8, 9), ), atmos_validation_builder, ) assert (_SILENT_HEIGHT_KEY in atmos_validation_builder.warnings) is expect_height_warning def test_active_heights_never_warn(self) -> None: atmos_validation_builder = AtmosValidationBuilder() apply_silent_object_measurement( SilentObjectMeasurement( object_track_count=0, silent_object_track_indices=(), height_track_count=2, silent_height_track_indices=(), ), atmos_validation_builder, ) assert atmos_validation_builder.warnings == {} def test_heights_are_persisted_to_metadata_even_when_suppressed(self) -> None: # The metadata stays raw (what was measured); only the WARNING is Sony-gated. atmos_validation_builder = AtmosValidationBuilder() atmos_validation_builder.metadata = _atmos_metadata() apply_silent_object_measurement( SilentObjectMeasurement( object_track_count=3, silent_object_track_indices=(), height_track_count=2, silent_height_track_indices=(8, 9), ), atmos_validation_builder, ) assert atmos_validation_builder.metadata is not None assert atmos_validation_builder.metadata.height_track_count == 2 assert atmos_validation_builder.metadata.silent_height_track_indices == (8, 9) assert atmos_validation_builder.warnings == {}