""" Unit tests cho DSP Engine - SonicForge Studio. Kiểm nghiệm thuật toán Zero-Crossing, Waveform, Cut/Loop/Fade, Multitrack Mixdown. """ import os import sys import tempfile import numpy as np import soundfile as sf import pytest # Thêm project root vào path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from app.core.dsp_utils import ( find_zero_crossing, find_nearest_zero_crossing_file, apply_micro_fade, generate_peak_waveform, generate_rms_waveform, ) from app.core.audio_editor import ( cut_and_loop_segment, mix_multitrack_session, export_audio, ) from app.core.analyzer import ( analyze_audio, analyze_audio_advanced, _estimate_structure_heuristic, ) # ── Fixtures ───────────────────────────────────────────────── def _create_test_wav(duration_sec: float = 2.0, sr: int = 44100, freq: float = 440.0) -> str: """Tạo file WAV dạng sine wave cho testing.""" t = np.linspace(0, duration_sec, int(sr * duration_sec), endpoint=False) y = np.sin(2 * np.pi * freq * t).astype(np.float32) tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) sf.write(tmp.name, y, sr) tmp.close() return tmp.name def _create_test_wav_stereo(duration_sec: float = 2.0, sr: int = 44100) -> str: """Tạo file WAV stereo cho testing.""" t = np.linspace(0, duration_sec, int(sr * duration_sec), endpoint=False) left = np.sin(2 * np.pi * 440.0 * t).astype(np.float32) right = np.sin(2 * np.pi * 880.0 * t).astype(np.float32) stereo = np.column_stack([left, right]) tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) sf.write(tmp.name, stereo, sr) tmp.close() return tmp.name # ── Test Zero-Crossing (Week 2) ────────────────────────────── class TestZeroCrossing: """Kiểm nghiệm thuật toán Zero-Crossing (triệt tiêu click/pop).""" def test_find_zero_crossing_known_sine(self): """ Sine wave 440Hz có zero-crossing tại bội số của 1/(2*440). Kiểm tra điểm tìm được phải nằm rất gần điểm đổi dấu thật. """ sr = 44100 duration = 1.0 freq = 440.0 t = np.linspace(0, duration, int(sr * duration), endpoint=False) y = np.sin(2 * np.pi * freq * t).astype(np.float32) # Target tại 0.5s result = find_zero_crossing(y, sr, target_time=0.5, window_seconds=0.04) # Kết quả phải nằm trong khoảng ±40ms từ target assert abs(result - 0.5) <= 0.04 # Xác minh biên độ tại điểm zero-crossing rất nhỏ result_sample = int(result * sr) if result_sample < len(y) - 1: # Kiểm tra đổi dấu assert y[result_sample] * y[result_sample + 1] <= 0 or abs(y[result_sample]) < 0.01 def test_find_zero_crossing_returns_target_when_no_crossing(self): """Với tín hiệu DC (không có zero-crossing), trả về vị trí gốc.""" sr = 44100 y = np.ones(sr, dtype=np.float32) # DC signal, no crossing result = find_zero_crossing(y, sr, target_time=0.5) assert result == 0.5 def test_find_zero_crossing_edge_start(self): """Zero-crossing gần đầu mảng.""" sr = 44100 y = np.sin(2 * np.pi * 440.0 * np.linspace(0, 1, sr, endpoint=False)).astype(np.float32) result = find_zero_crossing(y, sr, target_time=0.0) assert result >= 0.0 assert result <= 0.04 # Phải nằm trong window def test_find_zero_crossing_edge_end(self): """Zero-crossing gần cuối mảng.""" sr = 44100 y = np.sin(2 * np.pi * 440.0 * np.linspace(0, 1, sr, endpoint=False)).astype(np.float32) result = find_zero_crossing(y, sr, target_time=0.99) assert result >= 0.95 assert result <= 1.0 def test_find_zero_crossing_negative_time(self): """Handle target_time âm.""" sr = 44100 y = np.sin(2 * np.pi * 440.0 * np.linspace(0, 1, sr, endpoint=False)).astype(np.float32) result = find_zero_crossing(y, sr, target_time=-1.0) assert result == -1.0 def test_find_zero_crossing_file_wrapper(self): """Test find_nearest_zero_crossing_file wrapper.""" wav_path = _create_test_wav(duration_sec=1.0) try: result = find_nearest_zero_crossing_file(wav_path, 0.5) assert isinstance(result, float) assert abs(result - 0.5) <= 0.05 finally: os.unlink(wav_path) def test_zero_crossing_precision_sync(self): """ Sai số định vị mẫu âm học phải tiến về 0. Kiểm tra sai số <= 1 sample. """ sr = 44100 freq = 440.0 t = np.linspace(0, 1.0, sr, endpoint=False) y = np.sin(2 * np.pi * freq * t).astype(np.float32) # Tìm zero-crossing đầu tiên thực tế signs = np.sign(y) true_crossings = np.where(np.diff(signs) != 0)[0] if len(true_crossings) > 5: # Nhắm vào zero-crossing thứ 5 true_time = float(true_crossings[5]) / sr found_time = find_zero_crossing(y, sr, true_time, window_seconds=0.04) # Sai số tối đa: 1 sample = 1/44100 ≈ 0.0000227s sample_error = abs(found_time * sr - true_crossings[5]) assert sample_error <= 1.0, f"Sai số {sample_error} samples vượt ngưỡng 1 sample" # ── Test Peak Waveform (Week 2) ────────────────────────────── class TestPeakWaveform: """Kiểm nghiệm Peak Waveform generation.""" def test_generate_peak_waveform(self): wav_path = _create_test_wav(duration_sec=1.0) try: result = generate_peak_waveform(wav_path, num_peaks=100) assert "peaks" in result assert "duration" in result assert "sample_rate" in result assert len(result["peaks"]) == 100 assert result["duration"] > 0.9 assert all(0 <= p <= 1.0 for p in result["peaks"]) finally: os.unlink(wav_path) def test_generate_rms_waveform(self): wav_path = _create_test_wav(duration_sec=1.0) try: result = generate_rms_waveform(wav_path, num_points=50) assert "rms" in result assert len(result["rms"]) == 50 assert all(v >= 0 for v in result["rms"]) finally: os.unlink(wav_path) def test_peak_waveform_empty_audio(self): """Test với file WAV rất ngắn.""" sr = 44100 y = np.zeros(100, dtype=np.float32) tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) sf.write(tmp.name, y, sr) tmp.close() try: result = generate_peak_waveform(tmp.name, num_peaks=10) assert "peaks" in result # Với tín hiệu zero, tất cả peaks phải = 0 assert all(p == 0 for p in result["peaks"]) finally: os.unlink(tmp.name) # ── Test Micro-Fade ────────────────────────────────────────── class TestMicroFade: def test_apply_micro_fade(self): from pydub import AudioSegment from pydub.generators import Sine # Tạo 1-second sine tone tone = Sine(440).to_audio_segment(duration=1000) faded = apply_micro_fade(tone, fade_duration_ms=50) # Độ dài không đổi assert len(faded) == len(tone) def test_apply_micro_fade_short_segment(self): from pydub import AudioSegment from pydub.generators import Sine # Segment ngắn hơn 2x fade duration tone = Sine(440).to_audio_segment(duration=80) faded = apply_micro_fade(tone, fade_duration_ms=50) assert len(faded) == len(tone) # ── Test Cut & Loop (Week 2-3) ─────────────────────────────── class TestCutAndLoop: def test_cut_segment(self): wav_path = _create_test_wav(duration_sec=5.0) try: result = cut_and_loop_segment( file_path=wav_path, start_sec=1.0, end_sec=3.0, loop_count=1, fade_in_ms=50, fade_out_ms=50 ) # 2 giây = 2000ms (±tolerance cho fade) assert abs(len(result) - 2000) < 50 finally: os.unlink(wav_path) def test_cut_and_loop(self): wav_path = _create_test_wav(duration_sec=5.0) try: result = cut_and_loop_segment( file_path=wav_path, start_sec=1.0, end_sec=2.0, loop_count=3, fade_in_ms=0, fade_out_ms=0 ) # 1 giây * 3 lần = 3000ms assert abs(len(result) - 3000) < 50 finally: os.unlink(wav_path) def test_cut_with_volume_change(self): wav_path = _create_test_wav(duration_sec=2.0) try: result = cut_and_loop_segment( file_path=wav_path, start_sec=0.0, end_sec=1.0, volume_db_change=-6.0 ) assert len(result) > 0 finally: os.unlink(wav_path) # ── Test Multitrack Mixdown (Week 3) ───────────────────────── class TestMultitrackMixdown: def test_mix_two_tracks(self): wav1 = _create_test_wav(duration_sec=2.0, freq=440.0) wav2 = _create_test_wav(duration_sec=2.0, freq=880.0) tmp_output = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp_output.close() try: result = mix_multitrack_session( tracks_meta=[ {"file_path": wav1, "volume": 0.8, "muted": False}, {"file_path": wav2, "volume": 0.6, "muted": False}, ], output_path=tmp_output.name, sample_rate=44100, bit_depth=16 ) assert result["success"] is True assert result["tracks_processed"] == 2 assert os.path.exists(tmp_output.name) # Verify output is valid WAV y, sr = sf.read(tmp_output.name) assert sr == 44100 assert len(y) > 0 finally: os.unlink(wav1) os.unlink(wav2) if os.path.exists(tmp_output.name): os.unlink(tmp_output.name) def test_mix_with_muted_track(self): wav1 = _create_test_wav(duration_sec=1.0, freq=440.0) wav2 = _create_test_wav(duration_sec=1.0, freq=880.0) tmp_output = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp_output.close() try: result = mix_multitrack_session( tracks_meta=[ {"file_path": wav1, "volume": 1.0, "muted": False}, {"file_path": wav2, "volume": 1.0, "muted": True}, ], output_path=tmp_output.name ) assert result["success"] is True assert result["tracks_processed"] == 1 finally: os.unlink(wav1) os.unlink(wav2) if os.path.exists(tmp_output.name): os.unlink(tmp_output.name) def test_mix_all_muted(self): """Khi tất cả tracks đều muted, trả về lỗi.""" result = mix_multitrack_session( tracks_meta=[ {"file_path": "/dummy", "volume": 1.0, "muted": True}, ], output_path="/tmp/kilo/test_output.wav" ) assert result["success"] is False def test_mix_gain_no_clipping(self): """ Đảm bảo tăng giảm âm lượng không gây méo tiếng (Clipping distortion). Volume 0.5 => gain_db ≈ -6.02 dB """ wav_path = _create_test_wav(duration_sec=1.0, freq=440.0) tmp_output = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp_output.close() try: result = mix_multitrack_session( tracks_meta=[ {"file_path": wav_path, "volume": 0.5, "muted": False}, ], output_path=tmp_output.name, sample_rate=44100, bit_depth=16 ) assert result["success"] is True # Kiểm tra output: max amplitude phải < 1.0 (no clipping) y, sr = sf.read(tmp_output.name) max_amp = np.max(np.abs(y)) assert max_amp <= 1.0, f"Clipping detected: max amplitude = {max_amp}" finally: os.unlink(wav_path) if os.path.exists(tmp_output.name): os.unlink(tmp_output.name) # ── Test Multi-Format Export (Week 1 / 5) ──────────────────── class TestExport: def test_export_wav_16bit(self): wav_path = _create_test_wav(duration_sec=1.0) tmp_output = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp_output.close() try: result = export_audio(wav_path, tmp_output.name, format="wav", sample_rate=44100, bit_depth=16) assert result["success"] is True assert os.path.exists(tmp_output.name) info = sf.info(tmp_output.name) assert info.subtype == "PCM_16" finally: os.unlink(wav_path) if os.path.exists(tmp_output.name): os.unlink(tmp_output.name) def test_export_wav_24bit(self): wav_path = _create_test_wav(duration_sec=1.0) tmp_output = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp_output.close() try: result = export_audio(wav_path, tmp_output.name, format="wav", sample_rate=44100, bit_depth=24) assert result["success"] is True info = sf.info(tmp_output.name) assert info.subtype == "PCM_24" finally: os.unlink(wav_path) if os.path.exists(tmp_output.name): os.unlink(tmp_output.name) # ── Test Audio Analysis (Week 4) ───────────────────────────── class TestAnalyzer: def test_analyze_audio_basic(self): wav_path = _create_test_wav(duration_sec=5.0) try: result = analyze_audio(wav_path) assert "bpm" in result assert "beats" in result assert "bars" in result assert "duration" in result assert result["duration"] > 4.5 assert isinstance(result["bpm"], float) finally: os.unlink(wav_path) def test_analyze_audio_advanced(self): wav_path = _create_test_wav(duration_sec=5.0) try: result = analyze_audio_advanced(wav_path) assert "spectral_centroid_avg" in result assert "rms_energy_avg" in result assert "zero_crossing_rate_avg" in result assert "sample_rate" in result finally: os.unlink(wav_path) def test_structure_heuristic(self): """Test heuristic structure estimation.""" analysis = { "duration": 120.0, "bars": [0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 30.0, 32.0, 34.0, 36.0, 38.0, 40.0, 42.0, 44.0, 46.0, 48.0, 50.0] } result = _estimate_structure_heuristic(analysis) assert "sections" in result assert len(result["sections"]) > 0 # Kiểm tra có Intro section_names = [s["name"] for s in result["sections"]] assert "Intro" in section_names def test_structure_heuristic_short(self): """Test heuristic với audio quá ngắn.""" analysis = {"duration": 5.0, "bars": []} result = _estimate_structure_heuristic(analysis) assert len(result["sections"]) == 1 assert result["sections"][0]["name"] == "Full" if __name__ == "__main__": pytest.main([__file__, "-v"])