DesenrolaAi / python-basic-pitch /music_token_metrics.py
Azure DevOps Pipeline
deploy: Merged PR 26: melhoria no projeto
77bffac
Raw
History Blame Contribute Delete
12 kB
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