DesenrolaAi / python-basic-pitch /tests /test_harmonic_pipeline.py
Azure DevOps Pipeline
deploy: Merged PR 26: melhoria no projeto
77bffac
Raw
History Blame Contribute Delete
10.2 kB
import unittest
from pathlib import Path
import sys
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from harmonic_pipeline import (
BassHint,
ChordCandidate,
KeyCandidate,
candidate_to_payload,
empty_harmonic_response,
build_key_candidates,
build_stage_progression,
estimate_beat_metrics,
estimate_meter_hint,
estimate_no_chord_evidence,
merge_consecutive_events,
normalize_vector,
rank_progression_for_key,
HarmonicSegment,
build_segment_chord_candidates,
)
from main import AnalisarRequest
class HarmonicPipelineTests(unittest.TestCase):
def test_no_chord_detects_digital_silence(self):
evidence = estimate_no_chord_evidence(
np.zeros(16000, dtype=np.float32),
sr=16000,
chroma=np.zeros((12, 24), dtype=np.float32),
instrumento="teclado",
)
self.assertTrue(evidence.detected)
self.assertGreaterEqual(evidence.confidence, evidence.threshold)
self.assertIn("digital_silence", evidence.reasons)
def test_no_chord_detects_diffuse_noise(self):
rng = np.random.default_rng(20260810)
noise = rng.normal(0.0, 0.08, 32000).astype(np.float32)
diffuse_chroma = np.full((12, 40), 1.0 / 12.0, dtype=np.float32)
evidence = estimate_no_chord_evidence(
noise,
sr=16000,
chroma=diffuse_chroma,
instrumento="violao",
)
self.assertTrue(evidence.detected)
self.assertIn("weak_chord_template_fit", evidence.reasons)
self.assertIn("high_chroma_entropy", evidence.reasons)
def test_no_chord_detects_speech_like_sparse_unstable_content(self):
time = np.arange(32000, dtype=np.float32) / 16000.0
speech_like = (
0.05 * np.sin(2.0 * np.pi * (145.0 + (38.0 * time)) * time)
+ 0.018 * np.sin(2.0 * np.pi * (290.0 + (71.0 * time)) * time)
).astype(np.float32)
sparse_chroma = np.zeros((12, 48), dtype=np.float32)
for frame in range(sparse_chroma.shape[1]):
sparse_chroma[frame % 12, frame] = 1.0
evidence = estimate_no_chord_evidence(
speech_like,
sr=16000,
chroma=sparse_chroma,
instrumento="teclado",
)
self.assertTrue(evidence.detected)
self.assertIn("sparse_unstable_tonal_content", evidence.reasons)
def test_no_chord_does_not_reject_sparse_melodic_instrument_phrase(self):
time = np.arange(32000, dtype=np.float32) / 16000.0
melodic_audio = (0.05 * np.sin(2.0 * np.pi * 220.0 * time)).astype(np.float32)
melodic_chroma = np.zeros((12, 48), dtype=np.float32)
phrase = [9, 11, 1, 4, 6, 9]
for frame in range(melodic_chroma.shape[1]):
melodic_chroma[phrase[(frame // 8) % len(phrase)], frame] = 1.0
evidence = estimate_no_chord_evidence(
melodic_audio,
sr=16000,
chroma=melodic_chroma,
instrumento="sax_alto",
)
self.assertFalse(evidence.detected)
def test_no_chord_preserves_quiet_tonal_chord(self):
time = np.arange(32000, dtype=np.float32) / 16000.0
quiet_c_major = (
0.00018 * np.sin(2.0 * np.pi * 261.63 * time)
+ 0.00018 * np.sin(2.0 * np.pi * 329.63 * time)
+ 0.00018 * np.sin(2.0 * np.pi * 392.00 * time)
).astype(np.float32)
chord_chroma = np.zeros((12, 40), dtype=np.float32)
chord_chroma[0, :] = 1.0
chord_chroma[4, :] = 0.95
chord_chroma[7, :] = 0.98
evidence = estimate_no_chord_evidence(
quiet_c_major,
sr=16000,
chroma=chord_chroma,
instrumento="teclado",
)
self.assertFalse(evidence.detected)
self.assertGreater(evidence.template_fit, 0.9)
def test_empty_response_exposes_no_chord_without_changing_legacy_shapes(self):
evidence = estimate_no_chord_evidence(
np.zeros(16000, dtype=np.float32),
sr=16000,
chroma=np.zeros((12, 8), dtype=np.float32),
instrumento="ukulele",
)
response = empty_harmonic_response(no_chord_evidence=evidence)
self.assertEqual(response["acorde_atual"], "")
self.assertEqual(response["acordes"], [])
self.assertEqual(response["current_chord_candidates"], [])
diagnostic = response["diagnostico_harmonico"]
self.assertTrue(diagnostic["no_chord_detected"])
self.assertEqual(diagnostic["silence_ratio"], 1.0)
self.assertEqual(diagnostic["score_kind"], "heuristic_evidence_v1")
self.assertIsInstance(diagnostic["no_chord_reasons"], list)
def test_key_candidates_prioritize_expected_major_center(self):
chroma = normalize_vector(
np.array([0.34, 0.02, 0.06, 0.03, 0.16, 0.08, 0.03, 0.18, 0.02, 0.06, 0.01, 0.01], dtype=np.float32)
)
events = [
{"nome": "G", "inicio": 0.0, "fim": 1.0, "confianca": 0.9},
{"nome": "Em", "inicio": 1.0, "fim": 2.0, "confianca": 0.9},
{"nome": "C", "inicio": 2.0, "fim": 3.0, "confianca": 0.85},
{"nome": "D", "inicio": 3.0, "fim": 4.0, "confianca": 0.88},
{"nome": "G", "inicio": 4.0, "fim": 5.0, "confianca": 0.92},
]
root_histogram = normalize_vector(np.array([0.0, 0.0, 0.18, 0.0, 0.12, 0.0, 0.0, 0.46, 0.0, 0.0, 0.0, 0.24], dtype=np.float32))
candidates = build_key_candidates(chroma, root_histogram, events)
self.assertGreater(len(candidates), 0)
self.assertEqual(candidates[0].tonic, "G")
self.assertEqual(candidates[0].mode, "maior")
def test_rank_progression_respects_tonal_sequence(self):
segments = [
HarmonicSegment(index=0, start=0.0, end=1.0, duration=1.0, chroma=normalize_vector(np.array([0.3, 0, 0.02, 0, 0.2, 0, 0, 0.3, 0, 0.02, 0, 0.16], dtype=np.float32)), energy=0.3),
HarmonicSegment(index=1, start=1.0, end=2.0, duration=1.0, chroma=normalize_vector(np.array([0.18, 0, 0.02, 0.2, 0, 0, 0, 0.24, 0, 0.02, 0, 0.34], dtype=np.float32)), energy=0.34),
HarmonicSegment(index=2, start=2.0, end=3.0, duration=1.0, chroma=normalize_vector(np.array([0.28, 0, 0, 0, 0.02, 0.19, 0, 0.18, 0, 0, 0, 0.33], dtype=np.float32)), energy=0.33),
HarmonicSegment(index=3, start=3.0, end=4.0, duration=1.0, chroma=normalize_vector(np.array([0.02, 0, 0.28, 0, 0.02, 0.18, 0, 0.32, 0, 0, 0, 0.18], dtype=np.float32)), energy=0.32),
]
acoustic = [
[ChordCandidate("G", 7, "", 0.91, 0.92), ChordCandidate("C", 0, "", 0.89, 0.89)],
[ChordCandidate("Em", 4, "m", 1.38, 1.42), ChordCandidate("G", 7, "", 0.58, 0.6)],
[ChordCandidate("C", 0, "", 0.9, 0.91), ChordCandidate("Em", 4, "m", 0.83, 0.84)],
[ChordCandidate("D", 2, "", 1.05, 1.08), ChordCandidate("G", 7, "", 0.73, 0.76)],
]
ranked = rank_progression_for_key(segments, acoustic, KeyCandidate(tonic="G", mode="maior", confidence=0.82, score=5.0))
self.assertIsNotNone(ranked)
self.assertEqual([event["nome"] for event in ranked["events"]], ["G", "Em", "C", "D"])
def test_stage_progression_prefers_compact_playable_window(self):
events = merge_consecutive_events(
[
{"nome": "G", "inicio": 0.0, "fim": 1.0, "confianca": 0.92},
{"nome": "Em", "inicio": 1.0, "fim": 2.0, "confianca": 0.87},
{"nome": "C", "inicio": 2.0, "fim": 3.0, "confianca": 0.85},
{"nome": "D", "inicio": 3.0, "fim": 4.0, "confianca": 0.9},
{"nome": "G", "inicio": 4.0, "fim": 5.0, "confianca": 0.88},
],
min_duration=0.3,
)
stage = build_stage_progression(events, "G", "maior")
self.assertEqual(stage["progression"], "G Em C D")
self.assertEqual(stage["auxiliary"], "G Em C D G")
def test_estimate_beat_metrics_returns_stable_bpm(self):
beats = [0.0, 0.5, 1.0, 1.5, 2.0, 2.5]
metrics = estimate_beat_metrics(beats, duration=2.15)
self.assertAlmostEqual(metrics["bpm"], 120.0, places=1)
self.assertGreater(metrics["beat_confidence"], 0.5)
self.assertEqual(metrics["meter_hint"], "4/4")
def test_live_beat_eta_uses_last_beat_reference(self):
beats = [0.2, 0.7, 1.2, 1.7]
metrics = estimate_beat_metrics(beats, duration=2.2, live_mode=True)
self.assertGreater(metrics["next_beat_eta_ms"], 0)
self.assertLessEqual(metrics["next_beat_eta_ms"], metrics["beat_period_ms"])
def test_meter_hint_can_detect_three_four_pattern(self):
intervals = np.array([0.65, 0.45, 0.45, 0.65, 0.45, 0.45, 0.65, 0.45, 0.45], dtype=np.float32)
beat_times = np.concatenate([[0.0], np.cumsum(intervals)]).astype(np.float32)
meter = estimate_meter_hint(beat_times, 0.72)
self.assertEqual(meter, "3/4")
def test_candidate_payload_preserves_ambiguity_gap(self):
am = candidate_to_payload(ChordCandidate("Am", 9, "m", 1.02, 1.08))
dm = candidate_to_payload(ChordCandidate("Dm", 2, "m", 0.99, 1.01))
self.assertEqual(am["nome"], "Am")
self.assertLess(am["confianca"] - dm["confianca"], 0.05)
def test_bass_hint_resolves_am_vs_dm_candidate(self):
chroma = normalize_vector(
np.array([0.27, 0.0, 0.13, 0.0, 0.25, 0.03, 0.0, 0.0, 0.0, 0.32, 0.0, 0.0], dtype=np.float32)
)
candidates = build_segment_chord_candidates(
chroma,
top_k=3,
bass_hint=BassHint(pitch_pc=9, frequency_hz=110.0, confidence=0.82, energy=0.2),
)
self.assertGreaterEqual(len(candidates), 1)
self.assertEqual(candidates[0].name, "Am")
self.assertGreater(candidates[0].bass_score, 0.0)
def test_analisar_request_rejects_invalid_instrument(self):
with self.assertRaises(ValueError):
AnalisarRequest(path="/tmp/audio.wav", instrumento="guitarra")
if __name__ == "__main__":
unittest.main()