tiny-turn-detector / tests /test_features_and_baselines.py
Yash-V1002's picture
Deploy Tiny Turn Detector
875e4af verified
Raw
History Blame Contribute Delete
14.4 kB
"""
Unit tests for feature extraction and baselines.
IMPORTANT: every audio signal in this file is SYNTHETIC (generated sine
waves / white noise / silence), constructed purely to validate that the
feature-math and baseline logic run correctly and behave sensibly on known
signal shapes. This is standard unit-testing practice for signal-processing
code. It is explicitly NOT a substitute for running against the real
pipecat-ai/smart-turn-data-v3.2-train dataset, and no result here is ever
reported as a dataset metric. See experiments/EXPERIMENTS.md, where every
real experiment row remains marked NOT RUN until it is run against real
data.
"""
import numpy as np
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from turn_detector.features import (
frame_signal, rms_per_frame, zcr_per_frame, spectral_centroid,
spectral_bandwidth, spectral_rolloff, mfcc, trailing_silence_duration,
energy_slope, extract_features, FeatureConfig,
)
from turn_detector.audio_io import pad_or_trim, resample_linear
from turn_detector.baseline_energy import EnergySilenceBaseline, EnergySilenceConfig, tune_threshold
from turn_detector.baseline_classifier import ClassifierBaseline, build_feature_matrix
from turn_detector.splits import split_random, split_source_aware, source_overlap
from turn_detector.evaluation import classification_metrics, slice_metrics
from turn_detector.data import stratified_reservoir_sample, duration_bucket
SR = 16_000
def make_tone(freq_hz, duration_sec, sr=SR, amplitude=0.5):
t = np.arange(int(sr * duration_sec)) / sr
return (amplitude * np.sin(2 * np.pi * freq_hz * t)).astype(np.float32)
def make_silence(duration_sec, sr=SR):
return np.zeros(int(sr * duration_sec), dtype=np.float32)
def make_speech_then_silence(speech_sec=1.5, silence_sec=0.5, sr=SR):
speech = make_tone(180, speech_sec, sr, amplitude=0.3) + 0.02 * np.random.default_rng(0).standard_normal(int(sr * speech_sec)).astype(np.float32)
silence = make_silence(silence_sec, sr)
return np.concatenate([speech, silence]).astype(np.float32)
# ---------------------------------------------------------------------------
# frame_signal / rms / zcr
# ---------------------------------------------------------------------------
def test_frame_signal_shape():
audio = np.arange(1000, dtype=np.float32)
frames = frame_signal(audio, frame_len=100, hop_len=50)
assert frames.shape[1] == 100
assert frames.shape[0] == 1 + (1000 - 100) // 50
def test_rms_silence_near_zero():
silence = make_silence(0.5)
frames = frame_signal(silence, 400, 160)
rms = rms_per_frame(frames)
assert np.all(rms < 1e-4)
def test_rms_tone_greater_than_silence():
tone = make_tone(220, 0.5)
silence = make_silence(0.5)
rms_tone = rms_per_frame(frame_signal(tone, 400, 160)).mean()
rms_sil = rms_per_frame(frame_signal(silence, 400, 160)).mean()
assert rms_tone > rms_sil
def test_zcr_high_freq_greater_than_low_freq():
low = make_tone(100, 0.5)
high = make_tone(3000, 0.5)
zcr_low = zcr_per_frame(frame_signal(low, 400, 160)).mean()
zcr_high = zcr_per_frame(frame_signal(high, 400, 160)).mean()
assert zcr_high > zcr_low
# ---------------------------------------------------------------------------
# spectral features
# ---------------------------------------------------------------------------
def test_spectral_centroid_tracks_frequency():
low = make_tone(200, 0.5)
high = make_tone(4000, 0.5)
c_low = spectral_centroid(frame_signal(low, 800, 400), SR).mean()
c_high = spectral_centroid(frame_signal(high, 800, 400), SR).mean()
assert c_high > c_low
# centroid should be in the right ballpark (within an octave) of the tone freq
assert 100 < c_low < 800
assert 2000 < c_high < 8000
def test_spectral_rolloff_below_nyquist():
tone = make_tone(1000, 0.5)
rolloff = spectral_rolloff(frame_signal(tone, 800, 400), SR)
assert np.all(rolloff <= SR / 2)
assert np.all(rolloff >= 0)
def test_mfcc_shape():
tone = make_tone(220, 0.5)
frames = frame_signal(tone, 400, 160)
coeffs = mfcc(frames, SR, n_mfcc=13, n_mels=26)
assert coeffs.shape == (frames.shape[0], 13)
assert np.all(np.isfinite(coeffs))
# ---------------------------------------------------------------------------
# silence duration / energy slope
# ---------------------------------------------------------------------------
def test_trailing_silence_duration_detects_appended_silence():
audio = make_speech_then_silence(speech_sec=1.0, silence_sec=0.6)
dur = trailing_silence_duration(audio, SR, silence_rms_threshold=0.05)
# should detect roughly the appended silence duration (allow tolerance
# for frame/hop quantization)
assert 0.3 < dur <= 0.7
def test_trailing_silence_zero_when_ends_in_speech():
audio = make_tone(180, 0.8, amplitude=0.4)
dur = trailing_silence_duration(audio, SR, silence_rms_threshold=0.01)
assert dur == 0.0
def test_energy_slope_sign_on_decaying_energy():
rms = np.array([0.5, 0.4, 0.3, 0.2, 0.1])
assert energy_slope(rms) < 0
def test_energy_slope_sign_on_rising_energy():
rms = np.array([0.1, 0.2, 0.3, 0.4, 0.5])
assert energy_slope(rms) > 0
# ---------------------------------------------------------------------------
# audio_io
# ---------------------------------------------------------------------------
def test_pad_or_trim_left_pads_short_clip():
audio = np.ones(100, dtype=np.float32)
out = pad_or_trim(audio, 200, pad_side="left")
assert len(out) == 200
assert np.all(out[:100] == 0)
assert np.all(out[100:] == 1)
def test_pad_or_trim_trims_long_clip_keeps_tail():
audio = np.arange(200, dtype=np.float32)
out = pad_or_trim(audio, 100, pad_side="left")
assert len(out) == 100
np.testing.assert_array_equal(out, audio[-100:])
def test_resample_linear_changes_length_correctly():
audio = make_tone(200, 1.0, sr=8000)
out = resample_linear(audio, sr_in=8000, sr_out=16000)
assert abs(len(out) - 16000) <= 2
# ---------------------------------------------------------------------------
# full feature extraction
# ---------------------------------------------------------------------------
def test_extract_features_end_to_end_no_nan_on_normal_clip():
audio = make_speech_then_silence(1.0, 0.5)
feats = extract_features(audio, FeatureConfig(sr=SR))
assert feats["too_short_for_framing"] is False
for k, v in feats.items():
if isinstance(v, float) and "tail" not in k:
assert np.isfinite(v), f"{k} is not finite: {v}"
def test_extract_features_handles_very_short_clip_gracefully():
audio = make_tone(200, 0.01) # 10ms — shorter than default frame length
feats = extract_features(audio, FeatureConfig(sr=SR))
assert "duration_sec" in feats
# Should not raise, and should flag itself as too short for framing.
assert feats["too_short_for_framing"] is True
def test_recent_window_features_flag_unavailable_when_clip_too_short():
audio = make_tone(200, 0.05) # 50ms
feats = extract_features(audio, FeatureConfig(sr=SR, recent_windows_ms=(100, 250)))
assert feats["tail100ms_available"] is False
assert feats["tail250ms_available"] is False
assert np.isnan(feats["tail100ms_rms_mean"])
def test_recent_window_features_available_on_long_clip():
audio = make_speech_then_silence(1.0, 0.5)
feats = extract_features(audio, FeatureConfig(sr=SR, recent_windows_ms=(100, 250, 500)))
assert feats["tail100ms_available"] is True
assert feats["tail500ms_available"] is True
# ---------------------------------------------------------------------------
# baseline_energy
# ---------------------------------------------------------------------------
def test_energy_baseline_predicts_end_on_long_trailing_silence():
audio = make_speech_then_silence(speech_sec=1.0, silence_sec=0.8)
baseline = EnergySilenceBaseline(EnergySilenceConfig(silence_rms_threshold=0.05, silence_duration_threshold_sec=0.3))
assert baseline.predict(audio) is True
def test_energy_baseline_predicts_continue_on_no_trailing_silence():
audio = make_tone(180, 1.0, amplitude=0.4)
baseline = EnergySilenceBaseline(EnergySilenceConfig(silence_rms_threshold=0.01, silence_duration_threshold_sec=0.3))
assert baseline.predict(audio) is False
def test_tune_threshold_selects_reasonable_threshold_on_synthetic_data():
rng = np.random.default_rng(1)
data = []
for _ in range(20):
# END examples: long trailing silence
sil = 0.4 + rng.uniform(0, 0.3)
data.append((make_speech_then_silence(1.0, sil), True))
# CONTINUE examples: short/no trailing silence
sil_short = rng.uniform(0, 0.05)
data.append((make_speech_then_silence(1.0, sil_short), False))
cfg = EnergySilenceConfig(silence_rms_threshold=0.05)
best_t, results = tune_threshold(data, cfg, candidate_thresholds_sec=np.arange(0.05, 0.5, 0.05))
assert 0.05 <= best_t <= 0.5
assert results[best_t]["accuracy"] > 0.8 # synthetic data is cleanly separable
# ---------------------------------------------------------------------------
# baseline_classifier
# ---------------------------------------------------------------------------
def test_build_feature_matrix_consistent_shape():
feats = [{"a": 1.0, "b": 2.0}, {"a": 3.0, "c": 4.0}]
X, order = build_feature_matrix(feats)
assert X.shape == (2, 3)
assert order == ["a", "b", "c"]
assert np.isnan(X[0, order.index("c")])
assert np.isnan(X[1, order.index("b")])
def test_classifier_baseline_fits_and_predicts_on_synthetic_data():
rng = np.random.default_rng(2)
audios, labels = [], []
for _ in range(15):
sil = 0.4 + rng.uniform(0, 0.3)
audios.append(make_speech_then_silence(1.0, sil)); labels.append(True)
sil_short = rng.uniform(0, 0.05)
audios.append(make_speech_then_silence(1.0, sil_short)); labels.append(False)
clf = ClassifierBaseline()
clf.fit(audios, labels)
preds = clf.predict(audios)
acc = float(np.mean(preds == np.array(labels)))
assert acc > 0.7 # synthetic, cleanly separable data
assert clf.param_count() > 0
assert clf.model_size_bytes() > 0
top = clf.top_features(5)
assert len(top) == 5
# ---------------------------------------------------------------------------
# splits
# ---------------------------------------------------------------------------
def test_split_source_aware_has_no_source_overlap():
records = [{"dataset": f"src{i % 4}", "id": i} for i in range(40)]
train, val = split_source_aware(records, val_frac=0.25, seed=0)
assert len(source_overlap(train, val)) == 0
assert len(train) + len(val) == 40
def test_split_random_can_have_source_overlap():
records = [{"dataset": f"src{i % 2}", "id": i} for i in range(40)]
train, val = split_random(records, val_frac=0.5, seed=0)
# With only 2 sources and a random split, overlap is expected (not
# guaranteed on every seed, but true for this fixed seed/setup).
assert len(train) + len(val) == 40
def test_duration_bucket_boundaries():
assert duration_bucket(0.5) == "<1s"
assert duration_bucket(1.5) == "1-2s"
assert duration_bucket(3.0) == "2-4s"
assert duration_bucket(6.0) == "4-8s"
assert duration_bucket(10.0) == ">=8s"
# ---------------------------------------------------------------------------
# evaluation
# ---------------------------------------------------------------------------
def test_classification_metrics_perfect_predictions():
y_true = np.array([True, False, True, False])
y_pred = np.array([True, False, True, False])
m = classification_metrics(y_true, y_pred)
assert m["accuracy"] == 1.0
assert m["false_end_rate"] == 0.0
assert m["false_continue_rate"] == 0.0
assert m["endpoint_latency_ms"] is None
def test_classification_metrics_false_end_rate():
y_true = np.array([False, False, True, True])
y_pred = np.array([True, False, True, True]) # one false END
m = classification_metrics(y_true, y_pred)
assert m["confusion_matrix"]["fp"] == 1
assert m["false_end_rate"] == 0.5 # 1 of 2 actual-CONTINUE misclassified
def test_slice_metrics_excludes_small_slices():
y_true = np.array([True] * 25 + [False] * 5)
y_pred = np.array([True] * 25 + [True] * 5)
slices = np.array(["big"] * 25 + ["small"] * 5)
result = slice_metrics(y_true, y_pred, slices, min_samples=10)
assert "big" in result["slices"]
assert "small" not in result["slices"]
assert result["excluded_insufficient_n"][0]["slice"] == "small"
# ---------------------------------------------------------------------------
# data.py stratified sampling (logic-only test, synthetic fake records)
# ---------------------------------------------------------------------------
def test_stratified_reservoir_sample_respects_target_size():
rng = np.random.default_rng(3)
fake_records = []
for i in range(500):
fake_records.append({
"endpoint_bool": bool(rng.integers(0, 2)),
"language": rng.choice(["eng", "hin", "spa"]),
"dataset": rng.choice(["src_a", "src_b"]),
"synthetic": bool(rng.integers(0, 2)),
"midfiller": bool(rng.integers(0, 2)),
"endfiller": bool(rng.integers(0, 2)),
"duration_sec": rng.uniform(0.5, 5.0),
})
sample = stratified_reservoir_sample(fake_records, target_n=100, seed=42)
assert 0 < len(sample) <= 120 # allow small overshoot from rounding across strata
def test_stratified_reservoir_sample_deterministic_given_seed():
rng = np.random.default_rng(4)
fake_records = [
{"endpoint_bool": bool(rng.integers(0, 2)), "language": "eng",
"dataset": "src_a", "synthetic": True, "midfiller": None,
"endfiller": None, "duration_sec": 1.0}
for _ in range(100)
]
s1 = stratified_reservoir_sample(fake_records, target_n=20, seed=7)
s2 = stratified_reservoir_sample(fake_records, target_n=20, seed=7)
ids1 = [r.get("duration_sec") for r in s1]
ids2 = [r.get("duration_sec") for r in s2]
assert ids1 == ids2
if __name__ == "__main__":
import pytest
raise SystemExit(pytest.main([__file__, "-v"]))