from __future__ import annotations from dataclasses import dataclass import os import time from typing import Any, Optional import librosa import numpy as np from scipy import signal as scipy_signal NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] MAJOR_PROFILE = np.array( [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88], dtype=np.float32, ) MINOR_PROFILE = np.array( [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17], dtype=np.float32, ) FIFTHS_ORDER = ["C", "G", "D", "A", "E", "B", "F#", "C#", "G#", "D#", "A#", "F"] FIFTHS_INDEX = {name: index for index, name in enumerate(FIFTHS_ORDER)} MAX_PITCH_EVIDENCE_SECONDS = 12.0 PITCH_EVIDENCE_TARGET_SR = 11025 @dataclass class TonalPitchEvidence: histogram: np.ndarray dominant_pc: Optional[int] dominant_ratio: float onset_histogram: np.ndarray @dataclass class TonalValidationCandidate: tonic: str mode: str score: float confidence: float acoustic_correlation: float root_support: float tonic_pitch_support: float dominant_pitch_support: float diatonic_coverage: float circle_coherence: float def extract_tonal_pitch_evidence( audio: np.ndarray, sr: int, faixa: tuple[float, float], hop_length: int = 512, base_histogram: Optional[np.ndarray] = None, onset_histogram_hint: Optional[np.ndarray] = None, prefer_fast_mode: bool = False, ) -> TonalPitchEvidence: stage_timing_enabled = os.getenv("AUDIO_REPORT_STAGE_TIMING", "").strip().lower() in {"1", "true", "yes", "on"} def mark_stage(label: str, started_at: float) -> float: if not stage_timing_enabled: return time.perf_counter() now = time.perf_counter() print(f"[tonal_timing] {label} ({now - started_at:.2f}s)", flush=True) return now data = np.asarray(audio, dtype=np.float32) if data.size == 0: return TonalPitchEvidence( histogram=np.zeros(12, dtype=np.float32), dominant_pc=None, dominant_ratio=0.0, onset_histogram=np.zeros(12, dtype=np.float32), ) if prefer_fast_mode and base_histogram is not None: return build_histogram_pitch_evidence( base_histogram=base_histogram, onset_histogram_hint=onset_histogram_hint, ) data, sr = compact_pitch_evidence_audio(data, sr) stage_started_at = mark_stage("compact_audio", time.perf_counter()) n_fft = 2048 try: stft = np.abs( librosa.stft( y=data, n_fft=n_fft, hop_length=hop_length, center=True, ), ).astype(np.float32) except Exception: return TonalPitchEvidence( histogram=np.zeros(12, dtype=np.float32), dominant_pc=None, dominant_ratio=0.0, onset_histogram=np.zeros(12, dtype=np.float32), ) stage_started_at = mark_stage("spectral_tracking", stage_started_at) freqs = librosa.fft_frequencies(sr=sr, n_fft=n_fft).astype(np.float32) mask = ( (freqs >= max(40.0, float(faixa[0]) * 0.75)) & (freqs <= min(float(sr) / 2.0 - 1.0, float(faixa[1]) * 1.35)) ) if not np.any(mask): return TonalPitchEvidence( histogram=np.zeros(12, dtype=np.float32), dominant_pc=None, dominant_ratio=0.0, onset_histogram=np.zeros(12, dtype=np.float32), ) masked_freqs = freqs[mask] masked_stft = stft[mask, :] histogram = np.zeros(12, dtype=np.float32) onset_histogram = np.zeros(12, dtype=np.float32) n_frames = masked_stft.shape[1] if masked_stft.ndim == 2 else 0 for frame_index in range(n_frames): frame_magnitudes = masked_stft[:, frame_index] if frame_magnitudes.size == 0: continue peak = float(np.max(frame_magnitudes)) if peak <= 1e-8: continue top_indices = np.argpartition(frame_magnitudes, -4)[-4:] frame_total = 0.0 strongest_pc: Optional[int] = None strongest_weight = 0.0 for idx in top_indices: freq = float(masked_freqs[idx]) mag = float(frame_magnitudes[idx]) if not np.isfinite(freq) or not np.isfinite(mag) or mag <= peak * 0.22: continue if freq < max(40.0, float(faixa[0]) * 0.72) or freq > min(float(sr) / 2.0 - 1.0, float(faixa[1]) * 1.4): continue midi = 69.0 + 12.0 * np.log2(freq / 440.0) pc = int(round(midi)) % 12 clarity = max(0.0, mag / peak) weight = mag * (0.55 + clarity) histogram[pc] += weight frame_total += weight if weight > strongest_weight: strongest_weight = weight strongest_pc = pc if strongest_pc is not None and frame_total > 0: onset_histogram[strongest_pc] += strongest_weight / frame_total stage_started_at = mark_stage("histogram_accumulation", stage_started_at) histogram = normalize_vector(histogram) onset_histogram = normalize_vector(onset_histogram) if float(histogram.sum()) <= 0: return TonalPitchEvidence( histogram=histogram, dominant_pc=None, dominant_ratio=0.0, onset_histogram=onset_histogram, ) dominant_pc = int(np.argmax(histogram)) dominant_ratio = float(histogram[dominant_pc]) return TonalPitchEvidence( histogram=histogram, dominant_pc=dominant_pc, dominant_ratio=dominant_ratio, onset_histogram=onset_histogram, ) def build_histogram_pitch_evidence( base_histogram: Optional[np.ndarray], onset_histogram_hint: Optional[np.ndarray] = None, ) -> TonalPitchEvidence: histogram = normalize_vector(np.asarray(base_histogram if base_histogram is not None else np.zeros(12), dtype=np.float32)) onset_histogram = normalize_vector( np.asarray(onset_histogram_hint if onset_histogram_hint is not None else histogram, dtype=np.float32), ) if float(histogram.sum()) <= 0: return TonalPitchEvidence( histogram=np.zeros(12, dtype=np.float32), dominant_pc=None, dominant_ratio=0.0, onset_histogram=np.zeros(12, dtype=np.float32), ) dominant_pc = int(np.argmax(histogram)) dominant_ratio = float(histogram[dominant_pc]) return TonalPitchEvidence( histogram=histogram, dominant_pc=dominant_pc, dominant_ratio=dominant_ratio, onset_histogram=onset_histogram, ) def compact_pitch_evidence_audio(audio: np.ndarray, sr: int) -> tuple[np.ndarray, int]: data = np.asarray(audio, dtype=np.float32) if data.size == 0: return data, sr if sr > PITCH_EVIDENCE_TARGET_SR: data = resample_audio(data, sr, PITCH_EVIDENCE_TARGET_SR) sr = PITCH_EVIDENCE_TARGET_SR max_samples = int(MAX_PITCH_EVIDENCE_SECONDS * sr) if data.size <= max_samples: return data, sr frame = max(1024, int(sr * 0.5)) hop = max(512, int(sr * 0.25)) energies: list[tuple[float, int, int]] = [] for start in range(0, max(1, data.size - frame), hop): end = min(data.size, start + frame) chunk = data[start:end] if chunk.size < frame // 2: continue rms = float(np.sqrt(np.mean(np.square(chunk)) + 1e-10)) energies.append((rms, start, end)) if not energies: return data[:max_samples], sr energies.sort(key=lambda item: item[0], reverse=True) selected: list[tuple[int, int]] = [] target_windows = max(2, min(3, max_samples // max(frame, 1))) for _rms, start, end in energies: if any(abs(start - existing_start) < int(sr * 2.5) for existing_start, _existing_end in selected): continue selected.append((start, end)) if len(selected) >= target_windows: break if not selected: return data[:max_samples], sr selected.sort(key=lambda item: item[0]) snippets = [data[start:end] for start, end in selected] compacted = np.concatenate(snippets).astype(np.float32) if compacted.size > max_samples: compacted = compacted[:max_samples] return compacted, sr def resample_audio(audio: np.ndarray, orig_sr: int, target_sr: int) -> np.ndarray: if orig_sr == target_sr or audio.size == 0: return np.asarray(audio, dtype=np.float32) gcd = np.gcd(int(orig_sr), int(target_sr)) up = int(target_sr // gcd) down = int(orig_sr // gcd) return scipy_signal.resample_poly(audio, up, down).astype(np.float32) def validate_key_candidates( base_candidates: list[Any], chroma_mean: np.ndarray, root_histogram: np.ndarray, acoustic_events: list[dict[str, Any]], pitch_evidence: TonalPitchEvidence, note_to_pitch_class, correlation_pearson, logistic, ) -> list[TonalValidationCandidate]: validated: list[TonalValidationCandidate] = [] first_root = note_to_pitch_class(str(acoustic_events[0].get("nome", ""))) if acoustic_events else None last_root = note_to_pitch_class(str(acoustic_events[-1].get("nome", ""))) if acoustic_events else None for candidate in base_candidates: tonic = str(candidate.tonic) mode = str(candidate.mode) tonic_pc = NOTE_NAMES.index(tonic) profile = np.roll(MAJOR_PROFILE if mode == "maior" else MINOR_PROFILE, tonic_pc) acoustic_correlation = float(correlation_pearson(chroma_mean, profile)) root_support = float(root_histogram[tonic_pc]) if root_histogram.size == 12 else 0.0 dominant_pc = (tonic_pc + 7) % 12 subdominant_pc = (tonic_pc + 5) % 12 relative_pc = (tonic_pc + (9 if mode == "maior" else 3)) % 12 dominant_root_support = float(root_histogram[dominant_pc]) if root_histogram.size == 12 else 0.0 subdominant_root_support = float(root_histogram[subdominant_pc]) if root_histogram.size == 12 else 0.0 tonic_pitch_support = float(pitch_evidence.histogram[tonic_pc]) if pitch_evidence.histogram.size == 12 else 0.0 dominant_pitch_support = float(pitch_evidence.histogram[dominant_pc]) if pitch_evidence.histogram.size == 12 else 0.0 relative_pitch_support = float(pitch_evidence.histogram[relative_pc]) if pitch_evidence.histogram.size == 12 else 0.0 onset_tonic_support = float(pitch_evidence.onset_histogram[tonic_pc]) if pitch_evidence.onset_histogram.size == 12 else 0.0 diatonic_coverage = weighted_diatonic_coverage( acoustic_events, tonic_pc=tonic_pc, mode=mode, note_to_pitch_class=note_to_pitch_class, ) circle_coherence = weighted_circle_of_fifths_coherence( acoustic_events, tonic_pc=tonic_pc, note_to_pitch_class=note_to_pitch_class, ) edge_bonus = 0.0 if first_root == tonic_pc: edge_bonus += 0.3 if last_root == tonic_pc: edge_bonus += 0.52 elif last_root == dominant_pc: edge_bonus += 0.18 dominant_hint = 0.0 if pitch_evidence.dominant_pc is not None: if pitch_evidence.dominant_pc == tonic_pc: dominant_hint += 0.32 elif pitch_evidence.dominant_pc == dominant_pc: dominant_hint += 0.22 elif pitch_evidence.dominant_pc == relative_pc: dominant_hint += 0.08 final_score = ( float(candidate.score) * 0.18 + acoustic_correlation * 3.25 + root_support * 4.9 + dominant_root_support * 2.15 + subdominant_root_support * 0.95 + tonic_pitch_support * 4.15 + dominant_pitch_support * 1.9 + onset_tonic_support * 1.35 + relative_pitch_support * 0.4 + diatonic_coverage * 2.55 + circle_coherence * 1.45 + edge_bonus + dominant_hint ) confidence = float(logistic(final_score / 7.4)) validated.append( TonalValidationCandidate( tonic=tonic, mode=mode, score=float(final_score), confidence=confidence, acoustic_correlation=acoustic_correlation, root_support=root_support, tonic_pitch_support=tonic_pitch_support, dominant_pitch_support=dominant_pitch_support, diatonic_coverage=diatonic_coverage, circle_coherence=circle_coherence, ) ) validated.sort(key=lambda item: item.score, reverse=True) return validated[:6] def weighted_diatonic_coverage( events: list[dict[str, Any]], tonic_pc: int, mode: str, note_to_pitch_class, ) -> float: if not events: return 0.0 diatonic = diatonic_pitch_classes(tonic_pc, mode) supported = 0.0 total = 0.0 for event in events: root = note_to_pitch_class(str(event.get("nome", ""))) if root is None: continue weight = max( 0.18, (float(event.get("fim", 0.0)) - float(event.get("inicio", 0.0))) * max(0.15, float(event.get("confianca", 0.0))), ) total += weight if root in diatonic: supported += weight if total <= 0: return 0.0 return supported / total def weighted_circle_of_fifths_coherence( events: list[dict[str, Any]], tonic_pc: int, note_to_pitch_class, ) -> float: if not events: return 0.0 anchors = { tonic_pc, (tonic_pc + 7) % 12, (tonic_pc + 5) % 12, (tonic_pc + 2) % 12, (tonic_pc + 10) % 12, } total = 0.0 score = 0.0 for event in events: root = note_to_pitch_class(str(event.get("nome", ""))) if root is None: continue weight = max( 0.18, (float(event.get("fim", 0.0)) - float(event.get("inicio", 0.0))) * max(0.15, float(event.get("confianca", 0.0))), ) total += weight best_distance = min(circle_distance(root, anchor) for anchor in anchors) score += weight * max(0.0, 1.0 - (best_distance / 4.0)) if total <= 0: return 0.0 return score / total def diatonic_pitch_classes(tonic_pc: int, mode: str) -> set[int]: intervals = [0, 2, 3, 5, 7, 8, 10] if mode == "menor" else [0, 2, 4, 5, 7, 9, 11] return {int((tonic_pc + interval) % 12) for interval in intervals} def circle_distance(left_pc: int, right_pc: int) -> int: left_name = NOTE_NAMES[int(left_pc) % 12] right_name = NOTE_NAMES[int(right_pc) % 12] left_index = FIFTHS_INDEX[left_name] right_index = FIFTHS_INDEX[right_name] diff = abs(left_index - right_index) return min(diff, 12 - diff) def normalize_vector(values: np.ndarray) -> np.ndarray: vector = np.asarray(values, dtype=np.float32) total = float(vector.sum()) if total <= 1e-8: return np.zeros_like(vector) return vector / total