| | |
| | """ |
| | TajweedSST - Physics Validator Unit Tests |
| | |
| | Tests all Tajweed acoustic validation rules: |
| | - Qalqalah (bounce) |
| | - Madd (elongation) |
| | - Ghunnah (nasalization) |
| | - Tafkheem (heavy letters) |
| | - Idgham (assimilation) |
| | - Ikhfa (concealment) |
| | - Iqlab (conversion) |
| | - Izhar (clarity) |
| | """ |
| |
|
| | import pytest |
| | import numpy as np |
| | import os |
| | import sys |
| |
|
| | |
| | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) |
| |
|
| | from physics_validator import ( |
| | PhysicsValidator, |
| | ValidationStatus, |
| | PhysicsResult, |
| | QalqalahResult, |
| | MaddResult |
| | ) |
| |
|
| |
|
| | class TestPhysicsValidatorInit: |
| | """Test initialization and configuration""" |
| | |
| | def test_default_init(self): |
| | """Validator initializes with default sample rate""" |
| | pv = PhysicsValidator() |
| | assert pv.sample_rate == 22050 |
| | assert pv._average_vowel_duration > 0 |
| | |
| | def test_custom_sample_rate(self): |
| | """Validator accepts custom sample rate""" |
| | pv = PhysicsValidator(sample_rate=16000) |
| | assert pv.sample_rate == 16000 |
| | |
| | def test_thresholds_exist(self): |
| | """All Tajweed thresholds are defined""" |
| | pv = PhysicsValidator() |
| | assert hasattr(pv, 'QALQALAH_DIP_THRESHOLD') |
| | assert hasattr(pv, 'MADD_RATIO_ASLI') |
| | assert hasattr(pv, 'MADD_RATIO_WAJIB') |
| | assert hasattr(pv, 'MADD_RATIO_LAZIM') |
| |
|
| |
|
| | class TestQalqalahValidation: |
| | """Test Qalqalah (echo/bounce) detection""" |
| | |
| | @pytest.fixture |
| | def validator(self): |
| | return PhysicsValidator() |
| | |
| | @pytest.fixture |
| | def sample_audio(self): |
| | """Generate test audio: silence -> speech -> silence (qalqalah pattern)""" |
| | sr = 22050 |
| | duration = 0.5 |
| | t = np.linspace(0, duration, int(sr * duration)) |
| | |
| | |
| | envelope = np.ones_like(t) |
| | |
| | envelope[int(0.3*len(t)):int(0.4*len(t))] = 0.1 |
| | |
| | envelope[int(0.4*len(t)):int(0.5*len(t))] = 1.5 |
| | |
| | signal = envelope * np.sin(2 * np.pi * 200 * t) |
| | return signal.astype(np.float32) |
| | |
| | def test_qalqalah_returns_physics_result(self, validator, sample_audio): |
| | """Qalqalah validation returns PhysicsResult""" |
| | result = validator.validate_qalqalah(sample_audio, 0.0, 0.5) |
| | |
| | assert hasattr(result, 'status') |
| | assert hasattr(result, 'metric_name') |
| | |
| | def test_qalqalah_detects_dip_spike(self, validator, sample_audio): |
| | """Qalqalah validator detects dip-spike pattern""" |
| | result = validator.validate_qalqalah(sample_audio, 0.0, 0.5) |
| | |
| | assert result.score >= 0 |
| | |
| | def test_qalqalah_short_segment_handles_gracefully(self, validator): |
| | """Very short segments should be handled gracefully""" |
| | short_audio = np.zeros(100, dtype=np.float32) |
| | result = validator.validate_qalqalah(short_audio, 0.0, 0.005) |
| | |
| | assert result.status in [ValidationStatus.SKIPPED, ValidationStatus.FAIL] |
| |
|
| |
|
| | class TestMaddValidation: |
| | """Test Madd (elongation) detection""" |
| | |
| | @pytest.fixture |
| | def validator(self): |
| | return PhysicsValidator() |
| | |
| | @pytest.fixture |
| | def vowel_audio(self): |
| | """Generate sustained vowel-like audio""" |
| | sr = 22050 |
| | duration = 0.4 |
| | t = np.linspace(0, duration, int(sr * duration)) |
| | signal = np.sin(2 * np.pi * 200 * t) |
| | return signal.astype(np.float32) |
| | |
| | def test_madd_returns_physics_result(self, validator, vowel_audio): |
| | """Madd validation returns PhysicsResult""" |
| | result = validator.validate_madd(vowel_audio, 0.0, 0.4, expected_count=2) |
| | assert hasattr(result, 'status') |
| | assert hasattr(result, 'score') |
| | |
| | def test_madd_asli_duration(self, validator, vowel_audio): |
| | """Madd Asli (2 counts) should pass for ~400ms vowel""" |
| | result = validator.validate_madd(vowel_audio, 0.0, 0.4, expected_count=2) |
| | |
| | assert result.score >= 0 |
| |
|
| |
|
| | class TestGhunnahValidation: |
| | """Test Ghunnah (nasalization) detection""" |
| | |
| | @pytest.fixture |
| | def validator(self): |
| | return PhysicsValidator() |
| | |
| | @pytest.fixture |
| | def nasal_audio(self): |
| | """Generate nasal-like audio with limited bandwidth""" |
| | sr = 22050 |
| | duration = 0.3 |
| | t = np.linspace(0, duration, int(sr * duration)) |
| | |
| | signal = np.sin(2 * np.pi * 300 * t) + 0.5 * np.sin(2 * np.pi * 500 * t) |
| | return signal.astype(np.float32) |
| | |
| | def test_ghunnah_returns_physics_result(self, validator, nasal_audio): |
| | """Ghunnah validation returns PhysicsResult""" |
| | result = validator.validate_ghunnah(nasal_audio, 0.0, 0.3) |
| | assert hasattr(result, 'status') |
| | assert hasattr(result, 'score') |
| |
|
| |
|
| | class TestTafkheemValidation: |
| | """Test Tafkheem (heavy letter) detection via F2 formant""" |
| | |
| | @pytest.fixture |
| | def validator(self): |
| | return PhysicsValidator() |
| | |
| | @pytest.fixture |
| | def heavy_audio(self): |
| | """Generate audio with low F2 characteristic""" |
| | sr = 22050 |
| | duration = 0.2 |
| | t = np.linspace(0, duration, int(sr * duration)) |
| | |
| | signal = np.sin(2 * np.pi * 150 * t) + 0.3 * np.sin(2 * np.pi * 1000 * t) |
| | return signal.astype(np.float32) |
| | |
| | def test_tafkheem_returns_physics_result(self, validator, heavy_audio): |
| | """Tafkheem validation returns PhysicsResult""" |
| | result = validator.validate_tafkheem(heavy_audio, 0.0, 0.2) |
| | assert hasattr(result, 'status') |
| | assert hasattr(result, 'score') |
| |
|
| |
|
| | class TestIdghamValidation: |
| | """Test Idgham (assimilation) detection""" |
| | |
| | @pytest.fixture |
| | def validator(self): |
| | return PhysicsValidator() |
| | |
| | @pytest.fixture |
| | def merged_audio(self): |
| | """Generate smoothly merged audio (no boundary)""" |
| | sr = 22050 |
| | duration = 0.4 |
| | t = np.linspace(0, duration, int(sr * duration)) |
| | signal = np.sin(2 * np.pi * 200 * t) |
| | return signal.astype(np.float32) |
| | |
| | def test_idgham_returns_physics_result(self, validator, merged_audio): |
| | """Idgham validation returns PhysicsResult""" |
| | result = validator.validate_idgham(merged_audio, 0.0, 0.2, 0.4, has_ghunnah=True) |
| | assert hasattr(result, 'status') |
| | assert hasattr(result, 'score') |
| |
|
| |
|
| | class TestIkhfaValidation: |
| | """Test Ikhfa (concealment) detection""" |
| | |
| | @pytest.fixture |
| | def validator(self): |
| | return PhysicsValidator() |
| | |
| | @pytest.fixture |
| | def concealed_audio(self): |
| | """Generate gradually fading nasal audio""" |
| | sr = 22050 |
| | duration = 0.3 |
| | t = np.linspace(0, duration, int(sr * duration)) |
| | envelope = np.exp(-3 * t / duration) |
| | signal = envelope * np.sin(2 * np.pi * 300 * t) |
| | return signal.astype(np.float32) |
| | |
| | def test_ikhfa_returns_physics_result(self, validator, concealed_audio): |
| | """Ikhfa validation returns PhysicsResult""" |
| | result = validator.validate_ikhfa(concealed_audio, 0.0, 0.3) |
| | assert hasattr(result, 'status') |
| | assert hasattr(result, 'score') |
| |
|
| |
|
| | class TestIzharValidation: |
| | """Test Izhar (clear pronunciation) detection""" |
| | |
| | @pytest.fixture |
| | def validator(self): |
| | return PhysicsValidator() |
| | |
| | @pytest.fixture |
| | def clear_audio(self): |
| | """Generate audio with clear boundary between sounds""" |
| | sr = 22050 |
| | duration = 0.4 |
| | t = np.linspace(0, duration, int(sr * duration)) |
| | signal = np.zeros_like(t) |
| | |
| | signal[:len(t)//2] = np.sin(2 * np.pi * 200 * t[:len(t)//2]) |
| | |
| | |
| | signal[int(0.55*len(t)):] = np.sin(2 * np.pi * 300 * t[int(0.55*len(t)):]) |
| | return signal.astype(np.float32) |
| | |
| | def test_izhar_returns_physics_result(self, validator, clear_audio): |
| | """Izhar validation returns PhysicsResult""" |
| | result = validator.validate_izhar(clear_audio, 0.0, 0.2, 0.22) |
| | assert hasattr(result, 'status') |
| | assert hasattr(result, 'score') |
| |
|
| |
|
| | class TestValidationResults: |
| | """Test result dataclasses""" |
| | |
| | def test_physics_result_fields(self): |
| | """PhysicsResult has all required fields""" |
| | result = PhysicsResult( |
| | status=ValidationStatus.PASS, |
| | metric_name="test", |
| | expected_pattern="dip-spike", |
| | observed_pattern="dip-spike", |
| | score=0.95 |
| | ) |
| | assert result.status == ValidationStatus.PASS |
| | assert result.score == 0.95 |
| | |
| | def test_qalqalah_result_fields(self): |
| | """QalqalahResult has specific fields""" |
| | |
| | from physics_validator import QalqalahResult, ValidationStatus |
| | result = QalqalahResult( |
| | status=ValidationStatus.PASS, |
| | metric_name="RMS Energy", |
| | expected_pattern="dip_then_spike", |
| | observed_pattern="dip_then_spike", |
| | score=0.8, |
| | rms_profile="dip-spike", |
| | dip_depth=0.3, |
| | spike_height=1.5, |
| | closure_duration_ms=50 |
| | ) |
| | assert result.dip_depth == 0.3 |
| | assert result.spike_height == 1.5 |
| | |
| | def test_madd_result_fields(self): |
| | """MaddResult has duration fields""" |
| | from physics_validator import MaddResult, ValidationStatus |
| | result = MaddResult( |
| | status=ValidationStatus.PASS, |
| | metric_name="Duration Ratio", |
| | expected_pattern="extended", |
| | observed_pattern="extended", |
| | score=1.0, |
| | actual_duration_ms=400, |
| | expected_duration_ms=400, |
| | ratio=1.0 |
| | ) |
| | assert result.ratio == 1.0 |
| |
|
| |
|
| | if __name__ == "__main__": |
| | pytest.main([__file__, "-v"]) |
| |
|