import subprocess from unittest.mock import MagicMock, patch import numpy as np import pytest from src.atmos.alignment_check import ( CONTENT_MISMATCH_WARN_R, SYNC_MISMATCH_WARN_MS, AlignmentMeasurement, _check_content_match, _check_sync, _cross_correlate, _decode_head_mono_8k, _resample_to_8k, _to_alignment_measurement, apply_alignment_measurement, ) from src.atmos.models import ( AtmosValidationBuilder, AtmosValidationFindingCode, AtmosValidationMetadata, ) from src.atmos.render_errors import FfmpegError, FfmpegUnavailable _SYNC_KEY = AtmosValidationFindingCode.SYNC_MISMATCH.metadata_key _CONTENT_KEY = AtmosValidationFindingCode.CONTENT_MISMATCH.metadata_key _ANALYSIS_SR = 8_000 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 TestThresholds: def test_sync_mismatch_window_matches_sony_qc(self) -> None: # Sony QC parity (PASS_FAIL_WINDOW_MS_DEFAULT): warn above 50 ms of lag. assert SYNC_MISMATCH_WARN_MS == 50.0 def test_content_mismatch_floor(self) -> None: # Below |r| = 0.50 the content doesn't match well enough to trust the lag. assert CONTENT_MISMATCH_WARN_R == 0.50 class TestCrossCorrelate: def test_recovers_known_positive_lag(self) -> None: # test = reference delayed by lag_samples (Atmos lags the reference) -> positive lag. lag_samples = 400 # 50 ms at 8 kHz rng = np.random.default_rng(0) reference = rng.standard_normal(_ANALYSIS_SR * 5) test = np.roll(reference, lag_samples) lag, r = _cross_correlate(reference, test) assert lag == lag_samples assert r >= 0.9 def test_recovers_known_negative_lag(self) -> None: # test = reference advanced (Atmos leads the reference) -> negative lag; guards the signed # "positive = Atmos lags" convention against an abs()/sign-drop regression the positive case # alone can't catch. lag_samples = -400 rng = np.random.default_rng(0) reference = rng.standard_normal(_ANALYSIS_SR * 5) test = np.roll(reference, lag_samples) lag, r = _cross_correlate(reference, test) assert lag == lag_samples assert r >= 0.9 def test_recovers_polarity_flip(self) -> None: t = np.arange(_ANALYSIS_SR * 5) / _ANALYSIS_SR tone = np.sin(2 * np.pi * 440 * t) lag, r = _cross_correlate(tone, -tone) assert lag == 0 assert r <= -0.99 def test_identical_signals_lag_zero(self) -> None: rng = np.random.default_rng(7) signal = rng.standard_normal(_ANALYSIS_SR * 5) lag, r = _cross_correlate(signal, signal) assert lag == 0 assert r >= 0.99 def test_silence_yields_near_zero_correlation(self) -> None: silence = np.zeros(_ANALYSIS_SR * 5) _, r = _cross_correlate(silence, silence) assert abs(r) < 0.10 def test_unrelated_content_low_correlation(self) -> None: rng = np.random.default_rng(42) reference = np.sin(2 * np.pi * 1000 * np.arange(_ANALYSIS_SR * 5) / _ANALYSIS_SR) test = rng.standard_normal(_ANALYSIS_SR * 5) _, r = _cross_correlate(reference, test) assert abs(r) < 0.10 def test_length_mismatch_uses_overlap(self) -> None: rng = np.random.default_rng(1) reference = rng.standard_normal(_ANALYSIS_SR * 5) test = reference[: _ANALYSIS_SR * 3] lag, r = _cross_correlate(reference, test) assert lag == 0 assert r >= 0.99 class TestResampleTo8k: def test_resamples_48k_mono_to_8k(self) -> None: one_second_at_48k = np.ones(48000, dtype=np.float64) result = _resample_to_8k(one_second_at_48k, source_sample_rate_hz=48000) assert result.dtype == np.float64 # 48 kHz -> 8 kHz is a 6:1 downsample, so 1 s of input yields ~1 s at 8 kHz. assert len(result) == _ANALYSIS_SR def test_resamples_96k_mono_to_8k(self) -> None: one_second_at_96k = np.ones(96000, dtype=np.float64) result = _resample_to_8k(one_second_at_96k, source_sample_rate_hz=96000) assert len(result) == _ANALYSIS_SR class TestToAlignmentMeasurement: def test_strong_match_reports_lag_in_ms(self) -> None: # 80 samples at 8 kHz = 10 ms; a strong r keeps the offset. alignment_measurement = _to_alignment_measurement(80, 0.95) assert alignment_measurement.content_match_r == 0.95 assert alignment_measurement.alignment_offset_ms == 10.0 def test_strong_negative_match_reports_lag_in_ms(self) -> None: # |r| is what matters, so a polarity-flipped strong match still yields the offset. alignment_measurement = _to_alignment_measurement(-400, -0.95) assert alignment_measurement.content_match_r == -0.95 assert alignment_measurement.alignment_offset_ms == -50.0 def test_weak_match_suppresses_lag(self) -> None: alignment_measurement = _to_alignment_measurement(80, 0.10) assert alignment_measurement.content_match_r == 0.10 assert alignment_measurement.alignment_offset_ms is None def test_at_floor_keeps_lag(self) -> None: # Boundary: the gate is |r| < floor (strict), so exactly at the floor keeps the lag. alignment_measurement = _to_alignment_measurement(80, CONTENT_MISMATCH_WARN_R) assert alignment_measurement.alignment_offset_ms == 10.0 class TestDecodeHeadMono8k: def test_ffmpeg_unavailable_raises(self) -> None: with ( patch("src.atmos.alignment_check.subprocess.run", side_effect=OSError("not found")), pytest.raises(FfmpegUnavailable), ): _decode_head_mono_8k("https://example.com/stereo.flac") def test_nonzero_exit_raises_ffmpeg_error(self) -> None: process = MagicMock(returncode=1, stderr=b"boom", stdout=b"") with ( patch("src.atmos.alignment_check.subprocess.run", return_value=process), pytest.raises(FfmpegError, match="exited with code 1"), ): _decode_head_mono_8k("https://example.com/stereo.flac") def test_timeout_raises_ffmpeg_error(self) -> None: with ( patch( "src.atmos.alignment_check.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="ffmpeg", timeout=300), ), pytest.raises(FfmpegError, match="timed out"), ): _decode_head_mono_8k("https://example.com/stereo.flac") def test_success_returns_float64(self) -> None: samples = np.ones(_ANALYSIS_SR, dtype=np.float32) # 1 s at 8 kHz process = MagicMock(returncode=0, stdout=samples.tobytes(), stderr=b"") with patch("src.atmos.alignment_check.subprocess.run", return_value=process): result = _decode_head_mono_8k("https://example.com/stereo.flac") assert result.dtype == np.float64 assert len(result) == _ANALYSIS_SR def test_non_finite_samples_raise_ffmpeg_error(self) -> None: # A NaN would poison the correlation and silently defeat every downstream gate. samples = np.array([0.0, np.nan, 1.0], dtype=np.float32) process = MagicMock(returncode=0, stdout=samples.tobytes(), stderr=b"") with ( patch("src.atmos.alignment_check.subprocess.run", return_value=process), pytest.raises(FfmpegError, match="non-finite"), ): _decode_head_mono_8k("https://example.com/stereo.flac") def test_malformed_stream_raises_ffmpeg_error(self) -> None: # A byte count not divisible by 4 cannot be a valid f32le stream. process = MagicMock(returncode=0, stdout=b"\x00\x00\x00", stderr=b"") with ( patch("src.atmos.alignment_check.subprocess.run", return_value=process), pytest.raises(FfmpegError, match="malformed"), ): _decode_head_mono_8k("https://example.com/stereo.flac") def test_empty_stream_raises_ffmpeg_error(self) -> None: # ffmpeg exits 0 but decodes nothing (header-only or unreadable head): an empty array would # otherwise reach _cross_correlate as a bare IndexError; categorize it as a decode failure. process = MagicMock(returncode=0, stdout=b"", stderr=b"") with ( patch("src.atmos.alignment_check.subprocess.run", return_value=process), pytest.raises(FfmpegError, match="empty"), ): _decode_head_mono_8k("https://example.com/stereo.flac") class TestCheckContentMatch: def test_below_floor_warns(self) -> None: atmos_validation_builder = AtmosValidationBuilder() _check_content_match( AlignmentMeasurement(alignment_offset_ms=None, content_match_r=0.49), atmos_validation_builder, ) assert _CONTENT_KEY in atmos_validation_builder.warnings def test_at_floor_no_warning(self) -> None: # Boundary: exactly 0.50 passes (strict <). atmos_validation_builder = AtmosValidationBuilder() _check_content_match( AlignmentMeasurement(alignment_offset_ms=0.0, content_match_r=0.50), atmos_validation_builder, ) assert _CONTENT_KEY not in atmos_validation_builder.warnings def test_strong_negative_polarity_is_not_a_content_mismatch(self) -> None: # |r| is what matters: a polarity-flipped but strong match is the same program. atmos_validation_builder = AtmosValidationBuilder() _check_content_match( AlignmentMeasurement(alignment_offset_ms=0.0, content_match_r=-0.95), atmos_validation_builder, ) assert _CONTENT_KEY not in atmos_validation_builder.warnings class TestCheckSync: def test_above_window_warns(self) -> None: atmos_validation_builder = AtmosValidationBuilder() _check_sync( AlignmentMeasurement(alignment_offset_ms=50.1, content_match_r=0.95), atmos_validation_builder, ) assert _SYNC_KEY in atmos_validation_builder.warnings def test_at_window_no_warning(self) -> None: # Boundary: exactly 50 ms passes (strict >). atmos_validation_builder = AtmosValidationBuilder() _check_sync( AlignmentMeasurement(alignment_offset_ms=50.0, content_match_r=0.95), atmos_validation_builder, ) assert _SYNC_KEY not in atmos_validation_builder.warnings def test_negative_lag_at_window_no_warning(self) -> None: atmos_validation_builder = AtmosValidationBuilder() _check_sync( AlignmentMeasurement(alignment_offset_ms=-50.0, content_match_r=0.95), atmos_validation_builder, ) assert _SYNC_KEY not in atmos_validation_builder.warnings def test_suppressed_lag_skips_sync_check(self) -> None: # When the content doesn't match, the lag is None and no sync warning is emitted. atmos_validation_builder = AtmosValidationBuilder() _check_sync( AlignmentMeasurement(alignment_offset_ms=None, content_match_r=0.10), atmos_validation_builder, ) assert _SYNC_KEY not in atmos_validation_builder.warnings class TestApplyAlignmentMeasurement: def test_stores_measurement_and_emits_sync_warning(self) -> None: atmos_validation_builder = AtmosValidationBuilder() atmos_validation_builder.metadata = _atmos_metadata() apply_alignment_measurement( AlignmentMeasurement(alignment_offset_ms=150.0, content_match_r=0.90), atmos_validation_builder, ) assert atmos_validation_builder.metadata is not None assert atmos_validation_builder.metadata.stereo_reference_alignment_offset_ms == 150.0 assert atmos_validation_builder.metadata.stereo_reference_content_match_r == 0.90 assert _SYNC_KEY in atmos_validation_builder.warnings assert _CONTENT_KEY not in atmos_validation_builder.warnings def test_content_mismatch_suppresses_lag_and_warns(self) -> None: atmos_validation_builder = AtmosValidationBuilder() atmos_validation_builder.metadata = _atmos_metadata() apply_alignment_measurement( AlignmentMeasurement(alignment_offset_ms=None, content_match_r=0.10), atmos_validation_builder, ) assert atmos_validation_builder.metadata is not None assert atmos_validation_builder.metadata.stereo_reference_alignment_offset_ms is None assert atmos_validation_builder.metadata.stereo_reference_content_match_r == 0.10 assert _CONTENT_KEY in atmos_validation_builder.warnings assert _SYNC_KEY not in atmos_validation_builder.warnings def test_in_spec_measurement_emits_no_warnings(self) -> None: atmos_validation_builder = AtmosValidationBuilder() atmos_validation_builder.metadata = _atmos_metadata() apply_alignment_measurement( AlignmentMeasurement(alignment_offset_ms=0.0, content_match_r=0.95), atmos_validation_builder, ) 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_alignment_measurement( AlignmentMeasurement(alignment_offset_ms=200.0, content_match_r=0.95), atmos_validation_builder, ) assert _SYNC_KEY in atmos_validation_builder.warnings