feat: rewrite audio scan with MFCC+delta+spectral contrast pipeline

Root cause of poor discrimination: MFCC[0] (energy) dominated the
feature vector, making cosine similarity see all audio as similar.

Changes:
- Skip MFCC[0], use 12 coefficients instead of 20
- Add delta MFCCs for temporal dynamics
- Add 7-band spectral contrast for tonal vs noise quality
- Switch from cosine similarity to euclidean-distance-based score
- Pre-compute STFT once for whole file (10-20x faster)
- Vectorized sliding window via cumulative sums (no Python loop)
- Lower sample rate 22050→16000 Hz (faster, no quality loss)
- 62-dim feature vector (was 40-dim mean+std of raw MFCCs)
- Default threshold 0.05 (new similarity scale)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 15:28:44 +02:00
parent 8ab5bdba77
commit f2c38aee79
3 changed files with 159 additions and 71 deletions
+51 -20
View File
@@ -1,22 +1,22 @@
import tempfile, os
import numpy as np
from core.audio_scan import build_profile, _extract_mfcc, scan_video
from core.audio_scan import build_profile, _extract_features, scan_video, _similarity
def _make_wav(path: str, duration: float = 8.0, sr: int = 22050):
def _make_wav(path: str, duration: float = 8.0, sr: int = 16000, freq: float = 440.0):
"""Create a short sine-wave WAV file for testing."""
import soundfile as sf
t = np.linspace(0, duration, int(sr * duration), endpoint=False)
audio = 0.5 * np.sin(2 * np.pi * 440 * t)
audio = 0.5 * np.sin(2 * np.pi * freq * t)
sf.write(path, audio, sr)
def test_extract_mfcc_returns_1d_vector():
def test_extract_features_returns_62d_vector():
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
_make_wav(f.name)
try:
vec = _extract_mfcc(f.name)
assert vec.shape == (40,)
vec = _extract_features(f.name)
assert vec.shape == (62,)
assert not np.isnan(vec).any()
finally:
os.unlink(f.name)
@@ -29,7 +29,7 @@ def test_build_profile_single_clip():
profile = build_profile([f.name])
assert "mean_vector" in profile
assert "clip_vectors" in profile
assert profile["mean_vector"].shape == (40,)
assert profile["mean_vector"].shape == (62,)
assert len(profile["clip_vectors"]) == 1
finally:
os.unlink(f.name)
@@ -40,16 +40,13 @@ def test_build_profile_multiple_clips():
try:
for i in range(3):
f = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
freq = 440 + i * 200
import soundfile as sf
t = np.linspace(0, 8.0, 22050 * 8, endpoint=False)
sf.write(f.name, 0.5 * np.sin(2 * np.pi * freq * t), 22050)
_make_wav(f.name, freq=440 + i * 200)
paths.append(f.name)
f.close()
profile = build_profile(paths)
assert len(profile["clip_vectors"]) == 3
assert profile["mean_vector"].shape == (40,)
assert profile["mean_vector"].shape == (62,)
finally:
for p in paths:
os.unlink(p)
@@ -70,6 +67,17 @@ def test_build_profile_empty_returns_none():
assert result is None
def test_similarity_identical_is_one():
a = np.array([1.0, 2.0, 3.0])
assert abs(_similarity(a, a) - 1.0) < 1e-9
def test_similarity_distant_is_low():
a = np.zeros(62)
b = np.ones(62) * 100
assert _similarity(a, b) < 0.01
def test_scan_video_finds_matching_region():
"""A video made of the same sine wave as the reference should match."""
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as ref:
@@ -78,11 +86,11 @@ def test_scan_video_finds_matching_region():
_make_wav(vid.name, duration=20.0)
try:
profile = build_profile([ref.name])
regions = scan_video(vid.name, profile, mode="average", threshold=0.5, hop=1.0)
regions = scan_video(vid.name, profile, mode="average", threshold=0.01, hop=1.0)
assert len(regions) > 0
for start, end, score in regions:
assert abs((end - start) - 8.0) < 1e-9
assert score >= 0.5
assert abs((end - start) - 8.0) < 0.1
assert score >= 0.01
finally:
os.unlink(ref.name)
os.unlink(vid.name)
@@ -95,7 +103,7 @@ def test_scan_video_nearest_mode():
_make_wav(vid.name, duration=20.0)
try:
profile = build_profile([ref.name])
regions = scan_video(vid.name, profile, mode="nearest", threshold=0.5, hop=1.0)
regions = scan_video(vid.name, profile, mode="nearest", threshold=0.01, hop=1.0)
assert len(regions) > 0
finally:
os.unlink(ref.name)
@@ -106,20 +114,43 @@ def test_scan_video_high_threshold_no_match():
"""Different frequencies with very high threshold should not match."""
import soundfile as sf
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as ref:
t = np.linspace(0, 8.0, 22050 * 8, endpoint=False)
sf.write(ref.name, 0.5 * np.sin(2 * np.pi * 440 * t), 22050)
_make_wav(ref.name, duration=8.0, freq=440)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as vid:
# White noise — very different from sine wave
sf.write(vid.name, np.random.randn(22050 * 20).astype(np.float32) * 0.1, 22050)
sf.write(vid.name, np.random.randn(16000 * 20).astype(np.float32) * 0.1, 16000)
try:
profile = build_profile([ref.name])
regions = scan_video(vid.name, profile, mode="average", threshold=0.99, hop=1.0)
regions = scan_video(vid.name, profile, mode="average", threshold=0.5, hop=1.0)
assert len(regions) == 0
finally:
os.unlink(ref.name)
os.unlink(vid.name)
def test_scan_video_same_vs_different_discrimination():
"""Same-frequency match should score higher than cross-frequency."""
import soundfile as sf
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as ref:
_make_wav(ref.name, duration=8.0, freq=440)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as same:
_make_wav(same.name, duration=10.0, freq=440)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as diff:
# White noise
sf.write(diff.name, np.random.randn(16000 * 10).astype(np.float32) * 0.1, 16000)
try:
profile = build_profile([ref.name])
same_regions = scan_video(same.name, profile, mode="average", threshold=0.0, hop=1.0)
diff_regions = scan_video(diff.name, profile, mode="average", threshold=0.0, hop=1.0)
# Same-audio scores should be higher than noise scores
best_same = max(r[2] for r in same_regions)
best_diff = max(r[2] for r in diff_regions)
assert best_same > best_diff
finally:
os.unlink(ref.name)
os.unlink(same.name)
os.unlink(diff.name)
def test_db_get_all_export_paths():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name