diff --git a/.jhonny/analises.db b/.jhonny/analises.db index 3ab86ab..4217284 100644 Binary files a/.jhonny/analises.db and b/.jhonny/analises.db differ diff --git a/code/engine/analisar_trilhas.py b/code/engine/analisar_trilhas.py new file mode 100644 index 0000000..2a2beaf --- /dev/null +++ b/code/engine/analisar_trilhas.py @@ -0,0 +1,70 @@ +"""Executa a análise em lote de uma pasta de trilhas e emite progresso JSON.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent +if str(CAMINHO_DO_CODIGO) not in sys.path: + sys.path.insert(0, str(CAMINHO_DO_CODIGO)) + +from engine.integracoes.audio import AnalisadorDeMusicaInstrumentalEssentia, ClassificadorDeMusicaEssentia +from engine.persistencia.conexao import abrir_banco +from engine.persistencia.repositorio_de_trilhas import RepositorioDeTrilhasSQLite + +EXTENSOES_DE_AUDIO = {".wav", ".mp3", ".flac", ".m4a", ".aiff", ".ogg"} + + +def emitir(evento: str, **dados: object) -> None: + """Emite uma linha de progresso consumível pelo painel CEP.""" + print("@@PROGRESS " + json.dumps({"evento": evento, **dados}, ensure_ascii=False), flush=True) + + +def executar(pasta: Path, banco: Path, diretorio_dos_modelos: Path | None) -> int: + """Analisa todos os arquivos musicais diretamente encontrados na pasta.""" + arquivos = tuple(sorted( + arquivo for arquivo in pasta.iterdir() + if arquivo.is_file() and arquivo.suffix.lower() in EXTENSOES_DE_AUDIO + )) + conexao = abrir_banco(banco) + try: + repositorio = RepositorioDeTrilhasSQLite(conexao) + trilhas = repositorio.registrar_pasta_e_arquivos(pasta, arquivos) + classificador = ClassificadorDeMusicaEssentia(diretorio_dos_modelos) if diretorio_dos_modelos else None + analisador = AnalisadorDeMusicaInstrumentalEssentia(classificador=classificador) + emitir("lote_iniciado", total=len(trilhas), pasta=str(pasta)) + for ordem, trilha in enumerate(trilhas, start=1): + repositorio.iniciar_analise(trilha.identificador) + emitir("trilha_iniciada", nome=trilha.nome_do_arquivo, indice=ordem, total=len(trilhas), percentual=(ordem - 1) * 100 / max(1, len(trilhas))) + try: + resultado = analisador.analisar(trilha.caminho) + repositorio.registrar_analise(trilha, resultado) + emitir("trilha_concluida", nome=trilha.nome_do_arquivo, indice=ordem, total=len(trilhas), percentual=ordem * 100 / max(1, len(trilhas))) + except (OSError, RuntimeError, ValueError) as erro: + repositorio.registrar_erro(trilha.identificador, str(erro)) + emitir("trilha_com_erro", nome=trilha.nome_do_arquivo, indice=ordem, total=len(trilhas), erro=str(erro), percentual=ordem * 100 / max(1, len(trilhas))) + emitir("lote_concluido", total=len(trilhas), percentual=100) + return 0 + finally: + conexao.close() + + +def principal() -> None: + """Lê argumentos e executa o lote de análise.""" + argumentos = argparse.ArgumentParser() + argumentos.add_argument("pasta", type=Path) + argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db")) + argumentos.add_argument("--modelos", type=Path, default=None) + opcoes = argumentos.parse_args() + try: + raise SystemExit(executar(opcoes.pasta, opcoes.banco, opcoes.modelos)) + except (OSError, ValueError) as erro: + emitir("lote_com_erro", erro=str(erro)) + raise SystemExit(1) from erro + + +if __name__ == "__main__": + principal() diff --git a/code/engine/persistencia/esquema.py b/code/engine/persistencia/esquema.py index b991353..4e8aeb8 100644 --- a/code/engine/persistencia/esquema.py +++ b/code/engine/persistencia/esquema.py @@ -61,6 +61,54 @@ CREATE TABLE IF NOT EXISTS arquivos ( UNIQUE (video_id, caminho) ); +-- Catálogo independente de músicas para uso como trilha de fundo. +CREATE TABLE IF NOT EXISTS pastas_de_trilhas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + caminho TEXT NOT NULL UNIQUE, + criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + atualizada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE IF NOT EXISTS trilhas_musicais ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pasta_id INTEGER NOT NULL REFERENCES pastas_de_trilhas(id) ON DELETE CASCADE, + caminho TEXT NOT NULL UNIQUE, + nome_do_arquivo TEXT NOT NULL, + hash_do_conteudo TEXT, + tamanho_em_bytes INTEGER, + status TEXT NOT NULL DEFAULT 'aguardando', + erro TEXT, + criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + atualizada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_trilhas_musicais_pasta ON trilhas_musicais(pasta_id); + +CREATE TABLE IF NOT EXISTS analises_musicais ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trilha_id INTEGER NOT NULL REFERENCES trilhas_musicais(id) ON DELETE CASCADE, + provedor TEXT NOT NULL, + modelo TEXT, + duracao_em_segundos REAL, + batidas_por_minuto REAL, + tonalidade TEXT, + modo TEXT, + intensidade REAL, + dancabilidade REAL, + resultado_bruto_json TEXT, + criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_analises_musicais_trilha ON analises_musicais(trilha_id); + +CREATE TABLE IF NOT EXISTS etiquetas_musicais ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + analise_id INTEGER NOT NULL REFERENCES analises_musicais(id) ON DELETE CASCADE, + tipo TEXT NOT NULL, + etiqueta TEXT NOT NULL, + confianca REAL NOT NULL, + ordem INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_etiquetas_musicais_analise ON etiquetas_musicais(analise_id); + CREATE TABLE IF NOT EXISTS analises_versao ( video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE, etapa TEXT NOT NULL, diff --git a/code/engine/persistencia/repositorio_de_trilhas.py b/code/engine/persistencia/repositorio_de_trilhas.py new file mode 100644 index 0000000..7e98794 --- /dev/null +++ b/code/engine/persistencia/repositorio_de_trilhas.py @@ -0,0 +1,114 @@ +"""Persistência do catálogo independente de trilhas musicais.""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from ..integracoes.audio.modelos_de_analise_musical import ResultadoDaAnaliseMusical +from .conexao import abrir_banco + + +@dataclass(frozen=True) +class TrilhaMusical: + """Representa um arquivo de música catalogado, sem vínculo com vídeo.""" + + identificador: int + caminho: Path + nome_do_arquivo: str + status: str + erro: str | None = None + + +class RepositorioDeTrilhasSQLite: + """Salva pastas, trilhas e versões das análises musicais no SQLite.""" + + def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None: + """Abre o banco informado e garante que o esquema esteja disponível.""" + self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco) + + def registrar_pasta_e_arquivos(self, pasta: str | Path, arquivos: Iterable[Path]) -> list[TrilhaMusical]: + """Registra a pasta e torna sua lista de arquivos pronta para análise.""" + caminho_da_pasta = Path(pasta).expanduser().resolve(strict=True) + if not caminho_da_pasta.is_dir(): + raise NotADirectoryError(f"A pasta de trilhas não existe: {caminho_da_pasta}") + arquivos_materializados = tuple(Path(arquivo).resolve() for arquivo in arquivos) + with self.conexao: + cursor = self.conexao.execute( + """INSERT INTO pastas_de_trilhas(caminho) VALUES (?) + ON CONFLICT(caminho) DO UPDATE SET atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""", + (str(caminho_da_pasta),), + ) + pasta_id = cursor.lastrowid or self.conexao.execute( + "SELECT id FROM pastas_de_trilhas WHERE caminho = ?", (str(caminho_da_pasta),) + ).fetchone()[0] + resultado: list[TrilhaMusical] = [] + for arquivo in arquivos_materializados: + linha = self.conexao.execute( + """INSERT INTO trilhas_musicais(pasta_id, caminho, nome_do_arquivo, tamanho_em_bytes) + VALUES (?, ?, ?, ?) + ON CONFLICT(caminho) DO UPDATE SET nome_do_arquivo = excluded.nome_do_arquivo, + tamanho_em_bytes = excluded.tamanho_em_bytes, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""", + (pasta_id, str(arquivo), arquivo.name, arquivo.stat().st_size), + ) + identificador = linha.lastrowid or self.conexao.execute( + "SELECT id FROM trilhas_musicais WHERE caminho = ?", (str(arquivo),) + ).fetchone()[0] + registro = self.conexao.execute( + "SELECT id, caminho, nome_do_arquivo, status, erro FROM trilhas_musicais WHERE id = ?", + (identificador,), + ).fetchone() + resultado.append(TrilhaMusical(registro[0], Path(registro[1]), registro[2], registro[3], registro[4])) + return resultado + + def iniciar_analise(self, trilha_id: int) -> None: + """Marca uma trilha como em análise e remove erro anterior.""" + with self.conexao: + self.conexao.execute( + "UPDATE trilhas_musicais SET status = 'analisando', erro = NULL, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + (trilha_id,), + ) + + def registrar_analise(self, trilha: TrilhaMusical, resultado: ResultadoDaAnaliseMusical) -> None: + """Grava descritores e etiquetas produzidos pelo áudio da trilha.""" + bruto = json.dumps({"generos": resultado.generos, "humores": resultado.humores}, ensure_ascii=False) + with self.conexao: + self.conexao.execute( + "UPDATE trilhas_musicais SET hash_do_conteudo = ?, status = 'concluida', erro = NULL, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + (self._calcular_hash(trilha.caminho), trilha.identificador), + ) + cursor = self.conexao.execute( + """INSERT INTO analises_musicais + (trilha_id, provedor, modelo, duracao_em_segundos, batidas_por_minuto, tonalidade, modo, intensidade, dancabilidade, resultado_bruto_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (trilha.identificador, resultado.provedor, resultado.modelo, resultado.duracao_em_segundos, + resultado.batidas_por_minuto, resultado.tonalidade, resultado.modo, resultado.intensidade, + resultado.dancabilidade, bruto), + ) + analise_id = cursor.lastrowid + for tipo, etiquetas in (("genero", resultado.pontuacoes_de_genero), ("humor", resultado.pontuacoes_de_humor)): + self.conexao.executemany( + "INSERT INTO etiquetas_musicais(analise_id, tipo, etiqueta, confianca, ordem) VALUES (?, ?, ?, ?, ?)", + ((analise_id, tipo, item.etiqueta, item.confianca, ordem) for ordem, item in enumerate(etiquetas)), + ) + + def registrar_erro(self, trilha_id: int, erro: str) -> None: + """Marca uma trilha como erro sem interromper o lote inteiro.""" + with self.conexao: + self.conexao.execute( + "UPDATE trilhas_musicais SET status = 'erro', erro = ?, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + (erro[:2000], trilha_id), + ) + + @staticmethod + def _calcular_hash(caminho: Path) -> str: + """Calcula SHA-256 em blocos para não carregar o áudio inteiro na memória.""" + resumo = hashlib.sha256() + with caminho.open("rb") as arquivo: + for bloco in iter(lambda: arquivo.read(1024 * 1024), b""): + resumo.update(bloco) + return resumo.hexdigest()