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()