Spaces:
Sleeping
Sleeping
| 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() | |