File size: 10,214 Bytes
21f2aa3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 | #!/usr/bin/env python3
"""
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
# Add src to path
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 # 500ms
t = np.linspace(0, duration, int(sr * duration))
# Create dip-spike pattern typical of qalqalah
envelope = np.ones_like(t)
# Dip at 30-40%
envelope[int(0.3*len(t)):int(0.4*len(t))] = 0.1
# Spike at 40-50%
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)
# Result type is QalqalahResult which inherits from PhysicsResult
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)
# Should at least have a score
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) # ~4.5ms at 22050
result = validator.validate_qalqalah(short_audio, 0.0, 0.005)
# Should not crash, status can be FAIL or SKIPPED
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 # 400ms (should be ~2 counts)
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)
# Natural madd is 2 counts
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))
# Low frequency nasal resonance
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))
# Lower frequency components for "heavy" sound
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) # Fading
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)
# First letter
signal[:len(t)//2] = np.sin(2 * np.pi * 200 * t[:len(t)//2])
# Gap (silence)
# Second letter
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"""
# QalqalahResult inherits from PhysicsResult and has extra 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"])
|