DesenrolaAi / python-basic-pitch /report_audio_accuracy.py
Azure DevOps Pipeline
deploy: Merged PR 29: melhoria no projeto, refatoracao e desempenho para 100 pessoas logadas
07e5a95
Raw
History Blame Contribute Delete
23.5 kB
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Iterable
sys.path.insert(0, str(Path(__file__).resolve().parent))
from music_token_metrics import (
MatrizSemAcorde,
VERSAO_ESQUEMA_METRICAS,
acuracia_posicional,
avaliar_sem_acorde,
distancia_edicao_tokens,
melhor_rotacao,
normalizar_token,
qualidade_completa_token,
qualidade_token,
raiz_token,
simplificar_token_musical,
tokenizar,
)
RAIZ_PROJETO = Path(__file__).resolve().parents[1]
CAMINHO_DESCRICAO_PADRAO = RAIZ_PROJETO / "audios-testes" / "descricao.md"
FAIXAS_POR_INSTRUMENTO = {
"violao": (80.0, 1200.0),
"teclado": (27.5, 4200.0),
"sax_alto": (130.0, 900.0),
"violino": (196.0, 3500.0),
}
@dataclass(frozen=True)
class CasoAudio:
nome_arquivo: str
esperado: str
instrumento: str
caminho: Path
@dataclass(frozen=True)
class ResultadoAudio:
filename: str
instrument: str
expected: str
predicted: str
exact_match: bool
slash_tolerated_match: bool
rotated_exact_match: bool
rotated_simplified_match: bool
rotated_root_match: bool
musical_match: bool
token_accuracy: float
simplified_token_accuracy: float
root_accuracy: float
quality_accuracy: float
full_quality_accuracy: float
edit_distance: int
substitutions: int
insertions: int
deletions: int
token_error_rate: float
no_chord_true_positives: int
no_chord_false_positives: int
no_chord_false_negatives: int
no_chord_true_negatives: int
best_rotation_shift: int
expected_tokens: int
predicted_tokens: int
matched_tokens: int
elapsed_seconds: float
diagnostics: dict[str, Any]
def ler_argumentos() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Relatorio de assertividade com audios reais.")
parser.add_argument("--spec", type=Path, default=CAMINHO_DESCRICAO_PADRAO, help="Arquivo descricao.md com saidas esperadas.")
parser.add_argument("--json", action="store_true", help="Imprime o relatorio em JSON.")
parser.add_argument(
"--instrument",
action="append",
default=[],
help="Filtra por instrumento (ex.: --instrument violao --instrument teclado).",
)
parser.add_argument(
"--no-fixtures",
action="store_true",
help="Desliga as fixtures de regressao para medir somente a saida real do algoritmo.",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Imprime progresso e duracao por caso conforme a analise acontece.",
)
parser.add_argument(
"--case",
action="append",
default=[],
help="Filtra por trecho do nome do arquivo (ex.: --case bia --case teclado).",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Limita a quantidade de casos processados apos os filtros.",
)
parser.add_argument(
"--validate-corpus",
action="store_true",
help="Valida descricao e arquivos sem executar o pipeline DSP.",
)
return parser.parse_args()
def carregar_casos(caminho_descricao: Path) -> list[CasoAudio]:
conteudo = ler_texto_com_fallback(caminho_descricao)
padrao = re.compile(
"^Audio:\\s*(.+?)\\r?\\n"
"^Sa(?:ida|\u00edda|\u00c3\u00adda) esperada:\\s*(.+?)(?:\\r?\\n\\s*=+|\\Z)",
re.IGNORECASE | re.DOTALL | re.MULTILINE,
)
casos: list[CasoAudio] = []
for nome_arquivo, esperado in padrao.findall(conteudo):
nome_normalizado = nome_arquivo.strip()
caminho_audio = resolver_caminho_audio(caminho_descricao.parent, nome_normalizado)
casos.append(
CasoAudio(
nome_arquivo=nome_normalizado,
esperado=normalizar_saida_esperada(esperado),
instrumento=inferir_instrumento(nome_normalizado),
caminho=caminho_audio,
)
)
return casos
def ler_texto_com_fallback(caminho: Path) -> str:
for encoding in ("utf-8", "utf-8-sig", "cp1252", "latin-1"):
try:
return caminho.read_text(encoding=encoding)
except UnicodeDecodeError:
continue
return caminho.read_text(encoding="utf-8", errors="replace")
def resolver_caminho_audio(pasta: Path, nome_arquivo: str) -> Path:
caminho_direto = pasta / nome_arquivo
if caminho_direto.exists():
return caminho_direto
nome_base_esperado = Path(nome_arquivo).stem.casefold()
encontrados = [
caminho
for caminho in pasta.iterdir()
if caminho.is_file() and caminho.stem.casefold() == nome_base_esperado
]
if len(encontrados) == 1:
return encontrados[0]
raise FileNotFoundError(f"Arquivo nao encontrado para '{nome_arquivo}' em {pasta}")
def inferir_instrumento(nome_arquivo: str) -> str:
texto = nome_arquivo.casefold()
if "sax" in texto:
return "sax_alto"
if "violino" in texto:
return "violino"
if "teclado" in texto:
return "teclado"
return "violao"
def normalizar_saida_esperada(valor: str) -> str:
return " ".join(tokenizar(valor))
def resumo_diagnostico(resultado: dict[str, Any]) -> dict[str, Any]:
diagnostico_harmonico = resultado.get("diagnostico_harmonico") or {}
return {
"tom": resultado.get("tom"),
"modo": resultado.get("modo"),
"confianca_tom": resultado.get("confianca_tom"),
"perfil_fonte_audio": resultado.get("perfil_fonte_audio"),
"base_harmonica_auxiliar": resultado.get("base_harmonica_auxiliar"),
"tonalidades_candidatas": resultado.get("tonalidades_candidatas", [])[:3],
"candidatas_progressao": resultado.get("candidatas_progressao", [])[:3],
"chord_candidates": resultado.get("chord_candidates", [])[:4],
"no_chord_detected": diagnostico_harmonico.get("no_chord_detected", False),
"no_chord_probability": diagnostico_harmonico.get("no_chord_probability"),
"no_chord_threshold": diagnostico_harmonico.get("no_chord_threshold"),
"no_chord_reasons": diagnostico_harmonico.get("no_chord_reasons", []),
"score_kind": diagnostico_harmonico.get("score_kind"),
"timings": resultado.get("timings", {}),
}
def analisar_caso(caso: CasoAudio) -> ResultadoAudio:
inicio = time.perf_counter()
diagnostics: dict[str, Any] = {}
if caso.instrumento in {"sax_alto", "violino"}:
import main as pitch_main
resultado = pitch_main.analisar_melodico(caso.caminho, caso.instrumento)
tokens_obtidos = [normalizar_token(token) for token in resultado.get("notas_resumo", [])]
else:
from harmonic_pipeline import analyze_harmonic_audio
resultado = analyze_harmonic_audio(
str(caso.caminho),
caso.instrumento,
FAIXAS_POR_INSTRUMENTO[caso.instrumento],
)
tokens_obtidos = tokenizar(resultado.get("cifra_palco", ""))
diagnostico_harmonico = resultado.get("diagnostico_harmonico") or {}
if diagnostico_harmonico.get("no_chord_detected") is True:
# N e um estado acustico, nao uma cifra. Convertemos para token
# apenas na fronteira do benchmark para medir TP/FP/FN corretamente.
tokens_obtidos = ["N"]
diagnostics = resumo_diagnostico(resultado)
tokens_esperados = tokenizar(caso.esperado)
tokens_corretos, token_accuracy = acuracia_posicional(tokens_esperados, tokens_obtidos)
simplificados_esperados = [simplificar_token_musical(token) for token in tokens_esperados]
simplificados_obtidos = [simplificar_token_musical(token) for token in tokens_obtidos]
_, simplified_token_accuracy = acuracia_posicional(simplificados_esperados, simplificados_obtidos)
raizes_esperadas = [raiz_token(token) for token in tokens_esperados]
raizes_obtidas = [raiz_token(token) for token in tokens_obtidos]
_, root_accuracy = acuracia_posicional(raizes_esperadas, raizes_obtidas)
qualidades_esperadas = [qualidade_token(token) for token in tokens_esperados]
qualidades_obtidas = [qualidade_token(token) for token in tokens_obtidos]
_, quality_accuracy = acuracia_posicional(qualidades_esperadas, qualidades_obtidas)
qualidades_completas_esperadas = [qualidade_completa_token(token) for token in tokens_esperados]
qualidades_completas_obtidas = [qualidade_completa_token(token) for token in tokens_obtidos]
_, full_quality_accuracy = acuracia_posicional(
qualidades_completas_esperadas,
qualidades_completas_obtidas,
)
edicao = distancia_edicao_tokens(tokens_esperados, tokens_obtidos)
matriz_sem_acorde = avaliar_sem_acorde(tokens_esperados, tokens_obtidos)
exact_match = tokens_esperados == tokens_obtidos
slash_tolerated_match = simplificados_esperados == simplificados_obtidos
rotated_exact_match, _, exact_shift = melhor_rotacao(tokens_esperados, tokens_obtidos, lambda valor: valor)
rotated_simplified_match, _, simplified_shift = melhor_rotacao(
tokens_esperados,
tokens_obtidos,
simplificar_token_musical,
)
rotated_root_match, _, root_shift = melhor_rotacao(tokens_esperados, tokens_obtidos, raiz_token)
musical_match = exact_match or slash_tolerated_match or rotated_simplified_match
return ResultadoAudio(
filename=caso.caminho.name,
instrument=caso.instrumento,
expected=" ".join(tokens_esperados),
predicted=" ".join(tokens_obtidos),
exact_match=exact_match,
slash_tolerated_match=slash_tolerated_match,
rotated_exact_match=rotated_exact_match,
rotated_simplified_match=rotated_simplified_match,
rotated_root_match=rotated_root_match,
musical_match=musical_match,
token_accuracy=token_accuracy,
simplified_token_accuracy=simplified_token_accuracy,
root_accuracy=root_accuracy,
quality_accuracy=quality_accuracy,
full_quality_accuracy=full_quality_accuracy,
edit_distance=edicao.distancia,
substitutions=edicao.substituicoes,
insertions=edicao.insercoes,
deletions=edicao.remocoes,
token_error_rate=edicao.distancia / max(len(tokens_esperados), 1),
no_chord_true_positives=matriz_sem_acorde.verdadeiros_positivos,
no_chord_false_positives=matriz_sem_acorde.falsos_positivos,
no_chord_false_negatives=matriz_sem_acorde.falsos_negativos,
no_chord_true_negatives=matriz_sem_acorde.verdadeiros_negativos,
best_rotation_shift=exact_shift or simplified_shift or root_shift,
expected_tokens=len(tokens_esperados),
predicted_tokens=len(tokens_obtidos),
matched_tokens=tokens_corretos,
elapsed_seconds=time.perf_counter() - inicio,
diagnostics=diagnostics,
)
def resumir_resultados(resultados: Iterable[ResultadoAudio]) -> dict[str, Any]:
lista = list(resultados)
total = len(lista)
acertos_exatos = sum(1 for item in lista if item.exact_match)
acertos_musicais = sum(1 for item in lista if item.musical_match)
por_instrumento: dict[str, dict[str, Any]] = {}
for item in lista:
resumo = por_instrumento.setdefault(
item.instrument,
{
"cases": 0,
"exact_matches": 0,
"musical_matches": 0,
"mean_token_accuracy": 0.0,
"mean_simplified_accuracy": 0.0,
"mean_root_accuracy": 0.0,
"mean_quality_accuracy": 0.0,
"mean_full_quality_accuracy": 0.0,
"mean_elapsed_seconds": 0.0,
"edit_distance": 0,
"substitutions": 0,
"insertions": 0,
"deletions": 0,
"expected_tokens": 0,
"no_chord_true_positives": 0,
"no_chord_false_positives": 0,
"no_chord_false_negatives": 0,
"no_chord_true_negatives": 0,
},
)
resumo["cases"] += 1
resumo["exact_matches"] += 1 if item.exact_match else 0
resumo["musical_matches"] += 1 if item.musical_match else 0
resumo["mean_token_accuracy"] += item.token_accuracy
resumo["mean_simplified_accuracy"] += item.simplified_token_accuracy
resumo["mean_root_accuracy"] += item.root_accuracy
resumo["mean_quality_accuracy"] += item.quality_accuracy
resumo["mean_full_quality_accuracy"] += item.full_quality_accuracy
resumo["mean_elapsed_seconds"] += item.elapsed_seconds
resumo["edit_distance"] += item.edit_distance
resumo["substitutions"] += item.substitutions
resumo["insertions"] += item.insertions
resumo["deletions"] += item.deletions
resumo["expected_tokens"] += item.expected_tokens
resumo["no_chord_true_positives"] += item.no_chord_true_positives
resumo["no_chord_false_positives"] += item.no_chord_false_positives
resumo["no_chord_false_negatives"] += item.no_chord_false_negatives
resumo["no_chord_true_negatives"] += item.no_chord_true_negatives
for resumo in por_instrumento.values():
quantidade = max(int(resumo["cases"]), 1)
resumo["mean_token_accuracy"] = resumo["mean_token_accuracy"] / quantidade
resumo["mean_simplified_accuracy"] = resumo["mean_simplified_accuracy"] / quantidade
resumo["mean_root_accuracy"] = resumo["mean_root_accuracy"] / quantidade
resumo["mean_quality_accuracy"] = resumo["mean_quality_accuracy"] / quantidade
resumo["mean_full_quality_accuracy"] = resumo["mean_full_quality_accuracy"] / quantidade
resumo["mean_elapsed_seconds"] = resumo["mean_elapsed_seconds"] / quantidade
resumo["token_error_rate"] = resumo["edit_distance"] / max(resumo["expected_tokens"], 1)
resumo["no_chord"] = resumir_matriz_sem_acorde(
MatrizSemAcorde(
verdadeiros_positivos=resumo.pop("no_chord_true_positives"),
falsos_positivos=resumo.pop("no_chord_false_positives"),
falsos_negativos=resumo.pop("no_chord_false_negatives"),
verdadeiros_negativos=resumo.pop("no_chord_true_negatives"),
)
)
matriz_sem_acorde_global = MatrizSemAcorde()
for item in lista:
matriz_sem_acorde_global = matriz_sem_acorde_global.somar(
MatrizSemAcorde(
verdadeiros_positivos=item.no_chord_true_positives,
falsos_positivos=item.no_chord_false_positives,
falsos_negativos=item.no_chord_false_negatives,
verdadeiros_negativos=item.no_chord_true_negatives,
)
)
total_tokens_esperados = sum(item.expected_tokens for item in lista)
distancia_edicao_total = sum(item.edit_distance for item in lista)
return {
"metrics_schema_version": VERSAO_ESQUEMA_METRICAS,
"fixtures_enabled": fixtures_habilitadas(),
"total_cases": total,
"exact_matches": acertos_exatos,
"exact_match_rate": (acertos_exatos / total) if total else 0.0,
"musical_matches": acertos_musicais,
"musical_match_rate": (acertos_musicais / total) if total else 0.0,
"mean_token_accuracy": (sum(item.token_accuracy for item in lista) / total) if total else 0.0,
"mean_simplified_accuracy": (sum(item.simplified_token_accuracy for item in lista) / total) if total else 0.0,
"mean_root_accuracy": (sum(item.root_accuracy for item in lista) / total) if total else 0.0,
"mean_quality_accuracy": (sum(item.quality_accuracy for item in lista) / total) if total else 0.0,
"mean_full_quality_accuracy": (
sum(item.full_quality_accuracy for item in lista) / total
) if total else 0.0,
"token_error_rate": distancia_edicao_total / max(total_tokens_esperados, 1),
"edit_operations": {
"distance": distancia_edicao_total,
"substitutions": sum(item.substitutions for item in lista),
"insertions": sum(item.insertions for item in lista),
"deletions": sum(item.deletions for item in lista),
"expected_tokens": total_tokens_esperados,
},
"no_chord": resumir_matriz_sem_acorde(matriz_sem_acorde_global),
"mean_elapsed_seconds": (sum(item.elapsed_seconds for item in lista) / total) if total else 0.0,
"by_instrument": por_instrumento,
"results": [asdict(item) for item in lista],
}
def resumir_matriz_sem_acorde(matriz: MatrizSemAcorde) -> dict[str, Any]:
return {
"support": matriz.suporte,
"true_positives": matriz.verdadeiros_positivos,
"false_positives": matriz.falsos_positivos,
"false_negatives": matriz.falsos_negativos,
"true_negatives": matriz.verdadeiros_negativos,
"accuracy": matriz.acuracia,
"precision": matriz.precisao,
"recall": matriz.revocacao,
"f1": matriz.f1,
"zero_division": 0,
}
def imprimir_relatorio_humano(resumo: dict[str, Any]) -> None:
modo = "com fixtures de regressao" if resumo["fixtures_enabled"] else "sem fixtures"
print("Relatorio de Assertividade")
print(f"Esquema de metricas: {resumo['metrics_schema_version']}")
print(f"Modo: {modo}")
print(f"Casos: {resumo['total_cases']}")
print(f"Acerto exato: {resumo['exact_matches']}/{resumo['total_cases']} ({resumo['exact_match_rate']:.1%})")
print(f"Acerto musical: {resumo['musical_matches']}/{resumo['total_cases']} ({resumo['musical_match_rate']:.1%})")
print(f"Media por token: {resumo['mean_token_accuracy']:.1%}")
print(f"Media sem baixo/ornamento: {resumo['mean_simplified_accuracy']:.1%}")
print(f"Media de raiz: {resumo['mean_root_accuracy']:.1%}")
print(f"Media de qualidade: {resumo['mean_quality_accuracy']:.1%}")
print(f"Media de qualidade completa: {resumo['mean_full_quality_accuracy']:.1%}")
operacoes = resumo["edit_operations"]
print(
f"Taxa de erro sequencial: {resumo['token_error_rate']:.1%} "
f"(S={operacoes['substitutions']}, I={operacoes['insertions']}, D={operacoes['deletions']})"
)
no_chord = resumo["no_chord"]
print(
f"N/no-chord: suporte={no_chord['support']}, FP={no_chord['false_positives']}, "
f"FN={no_chord['false_negatives']}, precisao={no_chord['precision']:.1%}, "
f"revocacao={no_chord['recall']:.1%}, F1={no_chord['f1']:.1%}"
)
print(f"Tempo medio por caso: {resumo['mean_elapsed_seconds']:.2f}s")
print("")
print("Por instrumento:")
for instrumento, dados in sorted(resumo["by_instrument"].items()):
print(
f"- {instrumento}: {int(dados['exact_matches'])}/{int(dados['cases'])} exatos "
f"({(dados['exact_matches'] / max(dados['cases'], 1)):.1%}), "
f"musical {int(dados['musical_matches'])}/{int(dados['cases'])} "
f"({(dados['musical_matches'] / max(dados['cases'], 1)):.1%}), "
f"raiz {(dados['mean_root_accuracy']):.1%}, "
f"qualidade completa {(dados['mean_full_quality_accuracy']):.1%}, "
f"TER {(dados['token_error_rate']):.1%}"
)
print("")
print("Casos:")
for item in resumo["results"]:
status = "OK" if item["exact_match"] else ("MUSICAL" if item["musical_match"] else "FALHOU")
total_tokens = max(item["expected_tokens"], item["predicted_tokens"], 1)
print(f"- {status} [{item['instrument']}] {item['filename']}")
print(f" esperado: {item['expected']}")
print(f" obtido: {item['predicted']}")
print(f" tokens: {item['matched_tokens']}/{total_tokens} ({item['token_accuracy']:.1%})")
print(
f" musical: sem-baixo {str(item['slash_tolerated_match']).lower()}, "
f"rotacao {str(item['rotated_simplified_match']).lower()}, "
f"raiz {item['root_accuracy']:.1%}, qualidade {item['quality_accuracy']:.1%}, "
f"qualidade completa {item['full_quality_accuracy']:.1%}"
)
print(
f" edicao: TER {item['token_error_rate']:.1%}, "
f"S={item['substitutions']}, I={item['insertions']}, D={item['deletions']}"
)
print(f" tempo: {item['elapsed_seconds']:.2f}s")
if not item["exact_match"] and item.get("diagnostics"):
diagnostico = item["diagnostics"]
print(
f" diag: tom {diagnostico.get('tom') or '-'} {diagnostico.get('modo') or ''} "
f"conf {diagnostico.get('confianca_tom') or '-'}"
)
candidatos = diagnostico.get("candidatas_progressao") or []
if candidatos:
resumo_candidatos = [
" ".join(candidato.get("acordes", [])[:6])
for candidato in candidatos[:2]
]
print(f" cand: {' | '.join(resumo_candidatos)}")
def analisar_casos(casos: list[CasoAudio], verbose: bool = False) -> list[ResultadoAudio]:
resultados: list[ResultadoAudio] = []
total = len(casos)
for indice, caso in enumerate(casos, start=1):
if verbose:
print(f"[{indice}/{total}] analisando [{caso.instrumento}] {caso.caminho.name}...", flush=True)
resultado = analisar_caso(caso)
resultados.append(resultado)
if verbose:
status = "OK" if resultado.exact_match else ("MUSICAL" if resultado.musical_match else "FALHOU")
print(
f"[{indice}/{total}] {status} em {resultado.elapsed_seconds:.2f}s | "
f"{resultado.filename} | token {resultado.token_accuracy:.1%}",
flush=True,
)
return resultados
def filtrar_casos(casos: list[CasoAudio], args: argparse.Namespace) -> list[CasoAudio]:
filtrados = list(casos)
if args.instrument:
instrumentos = {valor.strip().lower() for valor in args.instrument if valor.strip()}
filtrados = [caso for caso in filtrados if caso.instrumento.lower() in instrumentos]
if args.case:
termos = [valor.strip().casefold() for valor in args.case if valor.strip()]
filtrados = [
caso
for caso in filtrados
if any(termo in caso.nome_arquivo.casefold() for termo in termos)
]
if args.limit > 0:
filtrados = filtrados[: args.limit]
return filtrados
def fixtures_habilitadas() -> bool:
valor = str(os.getenv("AUDIO_REGRESSION_FIXTURES", "0")).strip().lower()
return valor in {"1", "true", "on", "yes"}
def main() -> int:
args = ler_argumentos()
if args.no_fixtures:
os.environ["AUDIO_REGRESSION_FIXTURES"] = "0"
casos = filtrar_casos(carregar_casos(args.spec), args)
if not casos:
raise RuntimeError(f"Nenhum caso de audio valido encontrado em {args.spec}")
if args.validate_corpus:
print(f"Corpus valido: {len(casos)} casos em {args.spec}")
return 0
resultados = analisar_casos(casos, verbose=args.verbose)
resumo = resumir_resultados(resultados)
if args.json:
print(json.dumps(resumo, ensure_ascii=False, indent=2))
else:
imprimir_relatorio_humano(resumo)
return 0
if __name__ == "__main__":
raise SystemExit(main())