Files
SonicForgeStudio/tests/test_dsp_engine.py
T
2026-07-18 15:09:05 +07:00

479 lines
17 KiB
Python

"""
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"])