from __future__ import annotations import re from dataclasses import dataclass from typing import Callable VERSAO_ESQUEMA_METRICAS = "music-token-metrics-v2" def gerar_variantes_mojibake(texto: str, rodadas: int = 3) -> set[str]: variantes: set[str] = set() atual = texto for _ in range(rodadas): try: atual = atual.encode("utf-8").decode("cp1252") except UnicodeError: break variantes.add(atual) return variantes APELIDOS_DE_NOTAS = { "DO": "C", "DO#": "C#", "REB": "C#", "RE": "D", "RE#": "D#", "MIB": "D#", "MI": "E", "FA": "F", "FA#": "F#", "SOLB": "F#", "SOL": "G", "SOL#": "G#", "LAB": "G#", "LA": "A", "LA#": "A#", "SIB": "A#", "SI": "B", } NOTAS_POR_PC = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] PC_POR_NOTA = { "C": 0, "C#": 1, "DB": 1, "D": 2, "D#": 3, "EB": 3, "E": 4, "F": 5, "F#": 6, "GB": 6, "G": 7, "G#": 8, "AB": 8, "A": 9, "A#": 10, "BB": 10, "B": 11, "CB": 11, } SUBSTITUICOES_TOKEN = { **{valor: "#" for valor in ("\u266f", *gerar_variantes_mojibake("\u266f"))}, **{valor: "B" for valor in ("\uff22", *gerar_variantes_mojibake("\uff22"))}, } TOKENS_SEM_ACORDE = { "N", "NC", "NONE", "NOCHORD", "SEMACORDE", "SILENCE", "SILENCIO", } @dataclass(frozen=True) class MatrizSemAcorde: verdadeiros_positivos: int = 0 falsos_positivos: int = 0 falsos_negativos: int = 0 verdadeiros_negativos: int = 0 @property def total(self) -> int: return ( self.verdadeiros_positivos + self.falsos_positivos + self.falsos_negativos + self.verdadeiros_negativos ) @property def suporte(self) -> int: return self.verdadeiros_positivos + self.falsos_negativos @property def acuracia(self) -> float: return ( (self.verdadeiros_positivos + self.verdadeiros_negativos) / self.total if self.total else 1.0 ) @property def precisao(self) -> float: denominador = self.verdadeiros_positivos + self.falsos_positivos return self.verdadeiros_positivos / denominador if denominador else 0.0 @property def revocacao(self) -> float: return self.verdadeiros_positivos / self.suporte if self.suporte else 0.0 @property def f1(self) -> float: denominador = self.precisao + self.revocacao return (2.0 * self.precisao * self.revocacao / denominador) if denominador else 0.0 def somar(self, outra: "MatrizSemAcorde") -> "MatrizSemAcorde": return MatrizSemAcorde( verdadeiros_positivos=self.verdadeiros_positivos + outra.verdadeiros_positivos, falsos_positivos=self.falsos_positivos + outra.falsos_positivos, falsos_negativos=self.falsos_negativos + outra.falsos_negativos, verdadeiros_negativos=self.verdadeiros_negativos + outra.verdadeiros_negativos, ) @dataclass(frozen=True) class DistanciaEdicao: distancia: int = 0 substituicoes: int = 0 insercoes: int = 0 remocoes: int = 0 def tokenizar(valor: str) -> list[str]: tokens_brutos = [token.strip() for token in re.split(r"\s+", valor.strip()) if token.strip()] return [normalizar_token(token) for token in tokens_brutos] def normalizar_token(token: str) -> str: texto = token.strip() for origem, destino in SUBSTITUICOES_TOKEN.items(): texto = texto.replace(origem, destino) if "/" in texto: principal, baixo = texto.split("/", 1) principal_norm = normalizar_parte_token(principal) baixo_norm = normalizar_parte_token(baixo) return f"{principal_norm}/{baixo_norm}" if principal_norm and baixo_norm else principal_norm return normalizar_parte_token(texto) def normalizar_parte_token(token: str) -> str: texto = token.strip().replace("♯", "#").replace("♭", "b") texto = re.sub(r"^([A-Ga-g](?:#|b)?)M(?=\d)", r"\1maj", texto) texto = re.sub(r"^([A-Ga-g](?:#|b)?)[-−](?=\d|$)", r"\1m", texto) texto = re.sub(r"^([A-Ga-g](?:#|b)?)\+(?=\d|$)", r"\1aug", texto) texto = texto.replace("Δ", "maj") texto = re.sub(r"[øØ](?:7)?", "m7b5", texto) texto = re.sub(r"[°º](7)?", lambda match: "dim7" if match.group(1) else "dim", texto) limpo = re.sub(r"[^A-Za-z0-9#bB]+", "", texto) maiusculo = limpo.upper() if maiusculo in TOKENS_SEM_ACORDE: return "N" return APELIDOS_DE_NOTAS.get(maiusculo, maiusculo if maiusculo else token.strip()) def remover_baixo_alternativo(token: str) -> str: return str(token or "").split("/", 1)[0].strip() def raiz_token(token: str) -> str: principal = remover_baixo_alternativo(normalizar_token(token)) if eh_sem_acorde(principal): return "" match = re.match(r"^([A-G])(#|B)?", principal.upper()) if not match: return "" nota = f"{match.group(1)}{match.group(2) or ''}" pc = PC_POR_NOTA.get(nota) return NOTAS_POR_PC[pc] if pc is not None else nota def sufixo_token(token: str) -> str: principal = remover_baixo_alternativo(normalizar_token(token)).upper() match = re.match(r"^[A-G](?:#|B)?(.*)$", principal) return match.group(1) if match else "" def qualidade_token(token: str) -> str: if eh_sem_acorde(token): return "no_chord" if not raiz_token(token): return "unknown" sufixo = sufixo_token(token) if sufixo.startswith("SUS"): return "sus" if sufixo.startswith("DIM"): return "dim" if sufixo.startswith("AUG"): return "aug" if sufixo.startswith("M") and not sufixo.startswith("MAJ"): return "minor" return "major" def qualidade_completa_token(token: str) -> str: """Retorna a qualidade sem colapsar extensoes harmonicamente distintas.""" if eh_sem_acorde(token): return "no_chord" if not raiz_token(token): return "unknown" sufixo = sufixo_token(token) aliases = { "": "major", "MAJ": "major", "M": "minor", "MIN": "minor", "-": "minor", "7": "dominant7", "MAJ7": "major7", "MAJOR7": "major7", "7M": "major7", "M7": "minor7", "MIN7": "minor7", "6": "major6", "MAJ6": "major6", "M6": "minor6", "MIN6": "minor6", "9": "dominant9", "MAJ9": "major9", "M9": "minor9", "MIN9": "minor9", "ADD9": "add9", "SUS": "sus4", "SUS2": "sus2", "SUS4": "sus4", "DIM": "diminished", "DIM7": "diminished7", "M7B5": "half_diminished7", "MIN7B5": "half_diminished7", "AUG": "augmented", "+": "augmented", "5": "power5", } return aliases.get(sufixo, f"other:{sufixo}" if sufixo else "major") def eh_sem_acorde(token: str) -> bool: principal = remover_baixo_alternativo(str(token or "")) limpo = re.sub(r"[^A-Za-z0-9]+", "", principal).upper() return limpo in TOKENS_SEM_ACORDE def simplificar_token_musical(token: str) -> str: if eh_sem_acorde(token): return "N" raiz = raiz_token(token) if not raiz: return "" sufixo = { "minor": "M", "sus": "SUS", "dim": "DIM", "aug": "AUG", "major": "", }.get(qualidade_token(token), "") return f"{raiz}{sufixo}" def acuracia_posicional(esperado: list[str], obtido: list[str]) -> tuple[int, float]: acertos = sum(1 for left, right in zip(esperado, obtido) if left == right) total = max(len(esperado), len(obtido), 1) return acertos, acertos / total def avaliar_sem_acorde(esperado: list[str], obtido: list[str]) -> MatrizSemAcorde: """Calcula a matriz binaria de N, contando ausencias como erros de segmentacao.""" verdadeiros_positivos = 0 falsos_positivos = 0 falsos_negativos = 0 verdadeiros_negativos = 0 total = max(len(esperado), len(obtido)) for indice in range(total): esperado_n = indice < len(esperado) and eh_sem_acorde(esperado[indice]) obtido_n = indice < len(obtido) and eh_sem_acorde(obtido[indice]) if esperado_n and obtido_n: verdadeiros_positivos += 1 elif obtido_n: falsos_positivos += 1 elif esperado_n: falsos_negativos += 1 else: verdadeiros_negativos += 1 return MatrizSemAcorde( verdadeiros_positivos=verdadeiros_positivos, falsos_positivos=falsos_positivos, falsos_negativos=falsos_negativos, verdadeiros_negativos=verdadeiros_negativos, ) def distancia_edicao_tokens( esperado: list[str], obtido: list[str], transformador: Callable[[str], str] = lambda valor: valor, ) -> DistanciaEdicao: """Levenshtein deterministico com decomposicao em substituicoes/insercoes/remocoes.""" esperado_norm = [transformador(token) for token in esperado] obtido_norm = [transformador(token) for token in obtido] linhas = len(esperado_norm) + 1 colunas = len(obtido_norm) + 1 tabela: list[list[DistanciaEdicao]] = [ [DistanciaEdicao() for _ in range(colunas)] for _ in range(linhas) ] for indice in range(1, linhas): tabela[indice][0] = DistanciaEdicao( distancia=indice, remocoes=indice, ) for indice in range(1, colunas): tabela[0][indice] = DistanciaEdicao( distancia=indice, insercoes=indice, ) for linha in range(1, linhas): for coluna in range(1, colunas): anterior = tabela[linha - 1][coluna - 1] if esperado_norm[linha - 1] == obtido_norm[coluna - 1]: tabela[linha][coluna] = anterior continue candidatos = ( DistanciaEdicao( distancia=anterior.distancia + 1, substituicoes=anterior.substituicoes + 1, insercoes=anterior.insercoes, remocoes=anterior.remocoes, ), DistanciaEdicao( distancia=tabela[linha][coluna - 1].distancia + 1, substituicoes=tabela[linha][coluna - 1].substituicoes, insercoes=tabela[linha][coluna - 1].insercoes + 1, remocoes=tabela[linha][coluna - 1].remocoes, ), DistanciaEdicao( distancia=tabela[linha - 1][coluna].distancia + 1, substituicoes=tabela[linha - 1][coluna].substituicoes, insercoes=tabela[linha - 1][coluna].insercoes, remocoes=tabela[linha - 1][coluna].remocoes + 1, ), ) tabela[linha][coluna] = min( candidatos, key=lambda item: ( item.distancia, item.insercoes + item.remocoes, item.substituicoes, item.insercoes, ), ) return tabela[-1][-1] def melhor_rotacao( esperado: list[str], obtido: list[str], transformador: Callable[[str], str], ) -> tuple[bool, float, int]: if not esperado or len(esperado) != len(obtido): return False, 0.0, 0 esperado_norm = [transformador(token) for token in esperado] melhor_acuracia = 0.0 melhor_shift = 0 for shift in range(len(obtido)): rotacionado = obtido[shift:] + obtido[:shift] obtido_norm = [transformador(token) for token in rotacionado] _, acuracia = acuracia_posicional(esperado_norm, obtido_norm) if acuracia > melhor_acuracia: melhor_acuracia = acuracia melhor_shift = shift return melhor_acuracia >= 1.0, melhor_acuracia, melhor_shift