feat: carregamento automático da análise do Scanner ao abrir a aba
- carregamento automático da análise do Scanner ao abrir a aba Refinar - simplificada a aba Editar vídeo para exibir análises disponíveis sem carregamento manual - movida a visualização do tipo de vídeo para a guia Projeto com leitura do banco Resumo: - 10 arquivos alterados - 2 novos - 8 modificados - 0 removidos 8 files changed, 114 insertions(+), 11 deletions(-) Arquivos: - .jhonny/analises.db - code/cep-plugin/abas/projeto/projeto.js - code/cep-plugin/index.html - code/cep-plugin/main.js - code/engine/integracoes/audio/__init__.py - code/engine/integracoes/audio/modelos_de_analise_musical.py - code/engine/integracoes/audio/provider_de_analise_musical_essentia.py - code/engine/testes/test_analisador_de_musica_essentia.py - code/engine/consultar_edicao_concluida.py - code/engine/integracoes/audio/classificador_de_musica_essentia.py
This commit is contained in:
Binary file not shown.
@@ -71,9 +71,51 @@ function projetoSelecionarSequencia() {
|
|||||||
});
|
});
|
||||||
document.getElementById("btnProjetoContinuar").disabled = false;
|
document.getElementById("btnProjetoContinuar").disabled = false;
|
||||||
document.getElementById("btnProjetoSalvar").disabled = false;
|
document.getElementById("btnProjetoSalvar").disabled = false;
|
||||||
|
projetoCarregarTipoDeVideo();
|
||||||
document.getElementById("projetoInfo").textContent = "A sequência foi carregada. Selecione as faixas e clipes que participarão do plano.";
|
document.getElementById("projetoInfo").textContent = "A sequência foi carregada. Selecione as faixas e clipes que participarão do plano.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projetoCarregarTipoDeVideo() {
|
||||||
|
var select = document.getElementById("projetoTipoVideoSelect");
|
||||||
|
var info = document.getElementById("projetoTipoVideoInfo");
|
||||||
|
select.disabled = true;
|
||||||
|
runStreamed(SCANNER_PYTHON_BIN, [CONSULTAR_EDICAO_SCRIPT, projetoAtual.id, "--banco", ANALISES_DB_PATH],
|
||||||
|
function () {},
|
||||||
|
function (codigo, ultimoErro, ultimaSaida) {
|
||||||
|
if (codigo !== 0) { info.textContent = ultimoErro || "Nenhum tipo de vídeo salvo para esta sequência."; return; }
|
||||||
|
try {
|
||||||
|
var edicao = JSON.parse(ultimaSaida).edicao;
|
||||||
|
var tipos = tiposVideoLer().filter(function (item) { return item.ativo !== false; });
|
||||||
|
var tipo = tipos.filter(function (item) { return item.id === edicao.tipo_de_video; })[0];
|
||||||
|
select.innerHTML = "";
|
||||||
|
tipos.forEach(function (item) {
|
||||||
|
var opcao = document.createElement("option");
|
||||||
|
opcao.value = item.id;
|
||||||
|
opcao.textContent = item.nome;
|
||||||
|
select.appendChild(opcao);
|
||||||
|
});
|
||||||
|
if (!tipo) {
|
||||||
|
var tipoDesconhecido = document.createElement("option");
|
||||||
|
tipoDesconhecido.value = edicao.tipo_de_video;
|
||||||
|
tipoDesconhecido.textContent = edicao.tipo_de_video;
|
||||||
|
select.appendChild(tipoDesconhecido);
|
||||||
|
}
|
||||||
|
select.value = edicao.tipo_de_video;
|
||||||
|
select.disabled = false;
|
||||||
|
silenceState.tipoVideoId = edicao.tipo_de_video;
|
||||||
|
silenceState.tipoVideo = tipo || null;
|
||||||
|
info.textContent = "Tipo carregado da edição concluída mais recente.";
|
||||||
|
} catch (erro) { info.textContent = "Não foi possível interpretar o tipo salvo: " + erro.message; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function projetoSelecionarTipoVideo() {
|
||||||
|
var tipo = tiposVideoLer().filter(function (item) { return item.id === document.getElementById("projetoTipoVideoSelect").value; })[0] || null;
|
||||||
|
silenceState.tipoVideoId = tipo ? tipo.id : null;
|
||||||
|
silenceState.tipoVideo = tipo;
|
||||||
|
document.getElementById("projetoTipoVideoInfo").textContent = tipo ? tipo.nome + " · disponível para a edição." : "Nenhum tipo selecionado.";
|
||||||
|
}
|
||||||
|
|
||||||
function projetoSalvar() {
|
function projetoSalvar() {
|
||||||
if (!projetoAtual) return;
|
if (!projetoAtual) return;
|
||||||
var faixas = Array.prototype.slice.call(document.querySelectorAll("[data-projeto-faixa]:checked")).map(function (item) { return item.getAttribute("data-projeto-faixa"); });
|
var faixas = Array.prototype.slice.call(document.querySelectorAll("[data-projeto-faixa]:checked")).map(function (item) { return item.getAttribute("data-projeto-faixa"); });
|
||||||
|
|||||||
@@ -111,6 +111,11 @@
|
|||||||
<div><dt>Faixas</dt><dd id="projetoTotalFaixas">—</dd></div>
|
<div><dt>Faixas</dt><dd id="projetoTotalFaixas">—</dd></div>
|
||||||
<div><dt>Clipes</dt><dd id="projetoTotalClipes">—</dd></div>
|
<div><dt>Clipes</dt><dd id="projetoTotalClipes">—</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
<label class="field-label" for="projetoTipoVideoSelect">Tipo de vídeo</label>
|
||||||
|
<select id="projetoTipoVideoSelect" class="select-field" onchange="projetoSelecionarTipoVideo()" disabled>
|
||||||
|
<option value="">Carregando tipo de vídeo…</option>
|
||||||
|
</select>
|
||||||
|
<p class="field-help" id="projetoTipoVideoInfo">O tipo definido na edição concluída será carregado do banco.</p>
|
||||||
<h3>Faixas e clipes utilizados</h3>
|
<h3>Faixas e clipes utilizados</h3>
|
||||||
<div id="projetoFaixas" class="scanner-track-list"></div>
|
<div id="projetoFaixas" class="scanner-track-list"></div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
@@ -123,20 +128,19 @@
|
|||||||
<!-- ============================= EDITAR VÍDEO ============================= -->
|
<!-- ============================= EDITAR VÍDEO ============================= -->
|
||||||
<div class="tab-panel" id="tabSilence" role="tabpanel" aria-labelledby="tabBtnSilence" hidden>
|
<div class="tab-panel" id="tabSilence" role="tabpanel" aria-labelledby="tabBtnSilence" hidden>
|
||||||
|
|
||||||
<p class="tab-intro">A edição usa a análise já concluída pelo Scanner. Selecione o tipo de vídeo e conclua para salvar a configuração no banco.</p>
|
<p class="tab-intro">Os dados analisados e o tipo de vídeo definidos no Projeto já estão disponíveis para esta edição.</p>
|
||||||
|
|
||||||
<section class="card scanner-analysis-card" id="scannerAnalysisCard">
|
<section class="card scanner-analysis-card" id="scannerAnalysisCard">
|
||||||
<div class="card-head"><h2>Análise do Scanner</h2><span class="step-chip" id="scannerAnalysisStatus">Não carregada</span></div>
|
<div class="card-head"><h2>Análise do Scanner</h2><span class="step-chip" id="scannerAnalysisStatus">Não carregada</span></div>
|
||||||
<p class="field-help" id="scannerAnalysisInfo">Carregue a análise mais recente do Scanner antes de concluir a edição.</p>
|
<p class="field-help" id="scannerAnalysisInfo">A análise mais recente da sequência será carregada automaticamente.</p>
|
||||||
<ul class="scanner-analysis-checks" id="scannerAnalysisChecks" aria-label="Componentes carregados da análise">
|
<ul class="scanner-analysis-checks" id="scannerAnalysisChecks" aria-label="Componentes carregados da análise">
|
||||||
<li id="scannerCheckTranscription" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Transcrição</span><em>Aguardando</em></li>
|
<li id="scannerCheckTranscription" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Transcrição</span><em>Aguardando</em></li>
|
||||||
<li id="scannerCheckDiarization" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Detecção de falantes</span><em>Aguardando</em></li>
|
<li id="scannerCheckDiarization" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Detecção de falantes</span><em>Aguardando</em></li>
|
||||||
<li id="scannerCheckVoiceFeatures" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Métrica de voz</span><em>Aguardando</em></li>
|
<li id="scannerCheckVoiceFeatures" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Métrica de voz</span><em>Aguardando</em></li>
|
||||||
</ul>
|
</ul>
|
||||||
<button id="btnLoadScannerAnalysis" class="button button-primary" onclick="silenceLoadScannerAnalysis()" type="button">Carregar análise do Scanner</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="step" id="step1" data-state="locked">
|
<section class="step" id="step1" data-state="locked" hidden>
|
||||||
<div class="step-head">
|
<div class="step-head">
|
||||||
<span class="step-number" aria-hidden="true">1</span>
|
<span class="step-number" aria-hidden="true">1</span>
|
||||||
<div class="step-title">
|
<div class="step-title">
|
||||||
@@ -297,7 +301,7 @@
|
|||||||
<div class="card-head"><h2>Falantes da análise</h2><span class="step-chip" id="refinarStatus">Aguardando análise</span></div>
|
<div class="card-head"><h2>Falantes da análise</h2><span class="step-chip" id="refinarStatus">Aguardando análise</span></div>
|
||||||
<p class="field-help" id="refinarInfo">Carregue a análise mais recente do Scanner para consultar os falantes detectados.</p>
|
<p class="field-help" id="refinarInfo">Carregue a análise mais recente do Scanner para consultar os falantes detectados.</p>
|
||||||
<div class="refinar-actions">
|
<div class="refinar-actions">
|
||||||
<button id="btnRefinarLoad" class="button button-primary" onclick="refinarCarregarAnalise()" type="button">Carregar análise do Scanner</button>
|
<button id="btnRefinarLoad" class="button button-ghost" onclick="refinarCarregarAnalise()" type="button">Recarregar análise do Scanner</button>
|
||||||
<button id="btnRefinarStopAudio" class="button" onclick="stopSpeakerAudio()" type="button">Parar preview</button>
|
<button id="btnRefinarStopAudio" class="button" onclick="stopSpeakerAudio()" type="button">Parar preview</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -950,6 +950,7 @@ var ANALISE_PREVIEW_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/c
|
|||||||
var REGISTRAR_EDICAO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/registrar_edicao.py";
|
var REGISTRAR_EDICAO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/registrar_edicao.py";
|
||||||
var CARREGAR_PLANO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/carregar_plano_de_edicao.py";
|
var CARREGAR_PLANO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/carregar_plano_de_edicao.py";
|
||||||
var REGISTRAR_PROJETO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/registrar_projeto_de_edicao.py";
|
var REGISTRAR_PROJETO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/registrar_projeto_de_edicao.py";
|
||||||
|
var CONSULTAR_EDICAO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/consultar_edicao_concluida.py";
|
||||||
var planoEditorialIdCarregado = null;
|
var planoEditorialIdCarregado = null;
|
||||||
var retakesCasoAtual = null;
|
var retakesCasoAtual = null;
|
||||||
var retakesGrupos = [];
|
var retakesGrupos = [];
|
||||||
@@ -2415,10 +2416,16 @@ function silenceLoadScannerAnalysis() {
|
|||||||
|
|
||||||
function refinarAoAbrir() {
|
function refinarAoAbrir() {
|
||||||
var info = document.getElementById("refinarInfo");
|
var info = document.getElementById("refinarInfo");
|
||||||
if (!info || !silenceState.sequenceId) return;
|
if (!info) return;
|
||||||
|
if (!silenceState.sequenceId || !silenceState.transcriptPath) {
|
||||||
|
silenceLoadScannerAnalysis();
|
||||||
|
return;
|
||||||
|
}
|
||||||
info.textContent = silenceState.sequenceName + " · " + (silenceState.speakers.length
|
info.textContent = silenceState.sequenceName + " · " + (silenceState.speakers.length
|
||||||
? silenceState.speakers.length + " falante(s) carregado(s)."
|
? silenceState.speakers.length + " falante(s) carregado(s)."
|
||||||
: "Nenhum falante carregado.");
|
: "Nenhum falante carregado.");
|
||||||
|
var botao = document.getElementById("btnRefinarLoad");
|
||||||
|
if (botao) botao.textContent = "Recarregar análise do Scanner";
|
||||||
}
|
}
|
||||||
|
|
||||||
function refinarCarregarAnalise() {
|
function refinarCarregarAnalise() {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Consulta a configuração editorial concluída mais recente de um vídeo."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
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.persistencia.conexao import abrir_banco
|
||||||
|
|
||||||
|
|
||||||
|
def consultar(caminho_do_banco: Path, video_id: str) -> dict[str, object] | None:
|
||||||
|
"""Retorna a edição mais recente sem alterar o banco."""
|
||||||
|
conexao = abrir_banco(caminho_do_banco)
|
||||||
|
try:
|
||||||
|
linha = conexao.execute(
|
||||||
|
"""SELECT id, video_id, sequencia, tipo_de_video, configuracao, concluida_em
|
||||||
|
FROM edicoes_de_video WHERE video_id = ?
|
||||||
|
ORDER BY concluida_em DESC, id DESC LIMIT 1""", (video_id,),
|
||||||
|
).fetchone()
|
||||||
|
if linha is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": linha["id"], "video_id": linha["video_id"],
|
||||||
|
"sequencia": linha["sequencia"], "tipo_de_video": linha["tipo_de_video"],
|
||||||
|
"configuracao": json.loads(linha["configuracao"]),
|
||||||
|
"concluida_em": linha["concluida_em"],
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conexao.close()
|
||||||
|
|
||||||
|
|
||||||
|
def principal() -> None:
|
||||||
|
"""Executa a consulta pela linha de comando."""
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("video_id")
|
||||||
|
parser.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
|
||||||
|
opcoes = parser.parse_args()
|
||||||
|
try:
|
||||||
|
edicao = consultar(opcoes.banco, opcoes.video_id)
|
||||||
|
if edicao is None:
|
||||||
|
raise ValueError("Nenhuma edição concluída encontrada para este vídeo.")
|
||||||
|
print(json.dumps({"ok": True, "edicao": edicao}, ensure_ascii=False))
|
||||||
|
except (OSError, ValueError, sqlite3.Error, json.JSONDecodeError) as erro:
|
||||||
|
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
|
||||||
|
raise SystemExit(1) from erro
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
principal()
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
"""Integrações locais para análise de áudio e música."""
|
"""Integrações locais para análise de áudio e música."""
|
||||||
|
|
||||||
from .analisador_de_metricas_de_voz import AnalisadorDeMetricasDeVoz
|
from .analisador_de_metricas_de_voz import AnalisadorDeMetricasDeVoz
|
||||||
from .modelos_de_analise_musical import ResultadoDaAnaliseMusical
|
from .classificador_de_musica_essentia import ClassificadorDeMusicaEssentia
|
||||||
|
from .modelos_de_analise_musical import PontuacaoDeEtiqueta, ResultadoDaAnaliseMusical
|
||||||
from .provider_de_analise_musical_essentia import AnalisadorDeMusicaInstrumentalEssentia
|
from .provider_de_analise_musical_essentia import AnalisadorDeMusicaInstrumentalEssentia
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AnalisadorDeMetricasDeVoz",
|
"AnalisadorDeMetricasDeVoz",
|
||||||
|
"ClassificadorDeMusicaEssentia",
|
||||||
|
"PontuacaoDeEtiqueta",
|
||||||
"AnalisadorDeMusicaInstrumentalEssentia",
|
"AnalisadorDeMusicaInstrumentalEssentia",
|
||||||
"ResultadoDaAnaliseMusical",
|
"ResultadoDaAnaliseMusical",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
Classificação semântica de música por modelos oficiais do Essentia.
|
||||||
|
|
||||||
|
Este módulo carrega os pesos de gênero e humor, extrai um embedding de áudio
|
||||||
|
uma única vez e aplica os classificadores sobre esse embedding. Os arquivos
|
||||||
|
dos modelos ficam fora do repositório e são informados por configuração.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .modelos_de_analise_musical import PontuacaoDeEtiqueta
|
||||||
|
|
||||||
|
|
||||||
|
class ClassificadorDeMusicaEssentia:
|
||||||
|
"""
|
||||||
|
Classifica gênero e humor diretamente do sinal de áudio.
|
||||||
|
|
||||||
|
A classe não lê tags do arquivo, não produz texto narrativo e não
|
||||||
|
persiste resultados. Ela depende dos pesos ``.pb`` e metadados ``.json``
|
||||||
|
oficiais do Essentia, recebidos por caminho.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NOME_DO_MODELO_DE_EMBEDDING = "discogs-effnet-bs64-1.pb"
|
||||||
|
NOME_DO_MODELO_DE_GENERO = "genre_discogs400-discogs-effnet-1.pb"
|
||||||
|
NOME_DOS_METADADOS_DE_GENERO = "genre_discogs400-discogs-effnet-1.json"
|
||||||
|
NOME_DO_MODELO_DE_HUMOR = "mtg_jamendo_moodtheme-discogs-effnet-1.pb"
|
||||||
|
NOME_DOS_METADADOS_DE_HUMOR = "mtg_jamendo_moodtheme-discogs-effnet-1.json"
|
||||||
|
|
||||||
|
def __init__(self, diretorio_dos_modelos: str | Path) -> None:
|
||||||
|
"""Inicializa o classificador com o diretório dos pesos locais."""
|
||||||
|
self._diretorio_dos_modelos = Path(diretorio_dos_modelos).expanduser().resolve()
|
||||||
|
self._modelos_carregados: tuple[Any, Any, Any] | None = None
|
||||||
|
self._classes_por_tarefa: dict[str, tuple[str, ...]] | None = None
|
||||||
|
|
||||||
|
def classificar(
|
||||||
|
self, arquivo: str | Path,
|
||||||
|
) -> tuple[tuple[PontuacaoDeEtiqueta, ...], tuple[PontuacaoDeEtiqueta, ...]]:
|
||||||
|
"""
|
||||||
|
Classifica o gênero e o humor diretamente do conteúdo do arquivo.
|
||||||
|
|
||||||
|
Parâmetros:
|
||||||
|
arquivo: Caminho de áudio que será lido pelo Essentia.
|
||||||
|
|
||||||
|
Retorna:
|
||||||
|
Tupla com pontuações de gênero e de humor, em ordem decrescente.
|
||||||
|
|
||||||
|
Pode gerar:
|
||||||
|
FileNotFoundError: quando o arquivo não existir.
|
||||||
|
RuntimeError: quando Essentia ou algum modelo não estiver disponível.
|
||||||
|
"""
|
||||||
|
caminho = Path(arquivo).expanduser().resolve(strict=False)
|
||||||
|
if not caminho.is_file():
|
||||||
|
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {caminho}")
|
||||||
|
try:
|
||||||
|
from essentia.standard import (
|
||||||
|
MonoLoader,
|
||||||
|
TensorflowPredict2D,
|
||||||
|
TensorflowPredictEffnetDiscogs,
|
||||||
|
)
|
||||||
|
except ImportError as erro:
|
||||||
|
raise RuntimeError(
|
||||||
|
"A classificação musical precisa do suporte TensorFlow do Essentia."
|
||||||
|
) from erro
|
||||||
|
|
||||||
|
modelo_de_embedding, modelo_de_genero, modelo_de_humor = self._obter_modelos(
|
||||||
|
TensorflowPredictEffnetDiscogs, TensorflowPredict2D,
|
||||||
|
)
|
||||||
|
audio = MonoLoader(filename=str(caminho), sampleRate=16000, resampleQuality=4)()
|
||||||
|
embedding = modelo_de_embedding(audio)
|
||||||
|
previsao_de_genero = modelo_de_genero(embedding)
|
||||||
|
previsao_de_humor = modelo_de_humor(embedding)
|
||||||
|
return (
|
||||||
|
self._converter_pontuacoes(previsao_de_genero, "genero"),
|
||||||
|
self._converter_pontuacoes(previsao_de_humor, "humor"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _obter_modelos(self, classe_de_embedding: Any, classe_de_classificacao: Any):
|
||||||
|
if self._modelos_carregados is not None:
|
||||||
|
return self._modelos_carregados
|
||||||
|
caminhos = {
|
||||||
|
"embedding": self._diretorio_dos_modelos / self.NOME_DO_MODELO_DE_EMBEDDING,
|
||||||
|
"genero": self._diretorio_dos_modelos / self.NOME_DO_MODELO_DE_GENERO,
|
||||||
|
"humor": self._diretorio_dos_modelos / self.NOME_DO_MODELO_DE_HUMOR,
|
||||||
|
}
|
||||||
|
ausentes = [str(caminho) for caminho in caminhos.values() if not caminho.is_file()]
|
||||||
|
if ausentes:
|
||||||
|
raise RuntimeError("Modelos Essentia ausentes: " + ", ".join(ausentes))
|
||||||
|
self._modelos_carregados = (
|
||||||
|
classe_de_embedding(
|
||||||
|
graphFilename=str(caminhos["embedding"]), output="PartitionedCall:1",
|
||||||
|
),
|
||||||
|
classe_de_classificacao(
|
||||||
|
graphFilename=str(caminhos["genero"]),
|
||||||
|
input="serving_default_model_Placeholder", output="PartitionedCall:0",
|
||||||
|
),
|
||||||
|
classe_de_classificacao(graphFilename=str(caminhos["humor"])),
|
||||||
|
)
|
||||||
|
self._classes_por_tarefa = {
|
||||||
|
"genero": self._carregar_classes(self.NOME_DOS_METADADOS_DE_GENERO),
|
||||||
|
"humor": self._carregar_classes(self.NOME_DOS_METADADOS_DE_HUMOR),
|
||||||
|
}
|
||||||
|
return self._modelos_carregados
|
||||||
|
|
||||||
|
def _carregar_classes(self, nome_do_arquivo: str) -> tuple[str, ...]:
|
||||||
|
caminho = self._diretorio_dos_modelos / nome_do_arquivo
|
||||||
|
if not caminho.is_file():
|
||||||
|
raise RuntimeError(f"Metadados Essentia ausentes: {caminho}")
|
||||||
|
try:
|
||||||
|
dados = json.loads(caminho.read_text(encoding="utf-8"))
|
||||||
|
classes = dados["classes"]
|
||||||
|
except (OSError, json.JSONDecodeError, KeyError, TypeError) as erro:
|
||||||
|
raise RuntimeError(f"Metadados Essentia inválidos: {caminho}") from erro
|
||||||
|
if not isinstance(classes, list) or not all(isinstance(item, str) for item in classes):
|
||||||
|
raise RuntimeError(f"Lista de classes Essentia inválida: {caminho}")
|
||||||
|
return tuple(classes)
|
||||||
|
|
||||||
|
def _converter_pontuacoes(self, previsoes: Any, tarefa: str) -> tuple[PontuacaoDeEtiqueta, ...]:
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
if self._classes_por_tarefa is None:
|
||||||
|
raise RuntimeError("As classes dos modelos ainda não foram carregadas.")
|
||||||
|
valores = np.asarray(previsoes).mean(axis=0).tolist()
|
||||||
|
classes = self._classes_por_tarefa[tarefa]
|
||||||
|
if len(valores) != len(classes):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Quantidade de previsões incompatível para {tarefa}: "
|
||||||
|
f"{len(valores)} valores e {len(classes)} classes."
|
||||||
|
)
|
||||||
|
pontuacoes = (
|
||||||
|
PontuacaoDeEtiqueta(etiqueta=etiqueta, confianca=float(valor))
|
||||||
|
for etiqueta, valor in zip(classes, valores)
|
||||||
|
)
|
||||||
|
return tuple(sorted(pontuacoes, key=lambda item: item.confianca, reverse=True))
|
||||||
@@ -10,6 +10,14 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PontuacaoDeEtiqueta:
|
||||||
|
"""Representa uma etiqueta musical e a confiança calculada para ela."""
|
||||||
|
|
||||||
|
etiqueta: str
|
||||||
|
confianca: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ResultadoDaAnaliseMusical:
|
class ResultadoDaAnaliseMusical:
|
||||||
"""
|
"""
|
||||||
@@ -31,5 +39,7 @@ class ResultadoDaAnaliseMusical:
|
|||||||
humores: tuple[str, ...] = field(default_factory=tuple)
|
humores: tuple[str, ...] = field(default_factory=tuple)
|
||||||
instrumentos: tuple[str, ...] = field(default_factory=tuple)
|
instrumentos: tuple[str, ...] = field(default_factory=tuple)
|
||||||
etiquetas: tuple[str, ...] = field(default_factory=tuple)
|
etiquetas: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
pontuacoes_de_genero: tuple[PontuacaoDeEtiqueta, ...] = field(default_factory=tuple)
|
||||||
|
pontuacoes_de_humor: tuple[PontuacaoDeEtiqueta, ...] = field(default_factory=tuple)
|
||||||
provedor: str = "Essentia"
|
provedor: str = "Essentia"
|
||||||
modelo: str | None = None
|
modelo: str | None = None
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ class AnalisadorDeMusicaInstrumentalEssentia:
|
|||||||
self,
|
self,
|
||||||
extrator: Callable[[str], tuple[Any, Any]] | None = None,
|
extrator: Callable[[str], tuple[Any, Any]] | None = None,
|
||||||
nome_do_modelo: str | None = None,
|
nome_do_modelo: str | None = None,
|
||||||
|
classificador: Any | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Inicializa o adaptador sem carregar a dependência externa."""
|
"""Inicializa o adaptador sem carregar a dependência externa."""
|
||||||
self._extrator = extrator
|
self._extrator = extrator
|
||||||
self._nome_do_modelo = nome_do_modelo
|
self._nome_do_modelo = nome_do_modelo
|
||||||
|
self._classificador = classificador
|
||||||
|
|
||||||
def analisar(self, arquivo: str | Path) -> ResultadoDaAnaliseMusical:
|
def analisar(self, arquivo: str | Path) -> ResultadoDaAnaliseMusical:
|
||||||
"""
|
"""
|
||||||
@@ -53,7 +55,13 @@ class AnalisadorDeMusicaInstrumentalEssentia:
|
|||||||
dados, _ = self._obter_extrator()(str(caminho))
|
dados, _ = self._obter_extrator()(str(caminho))
|
||||||
if dados is None:
|
if dados is None:
|
||||||
raise ValueError("O Essentia devolveu uma análise vazia.")
|
raise ValueError("O Essentia devolveu uma análise vazia.")
|
||||||
return self._converter_resultado(caminho, dados)
|
pontuacoes_de_genero: tuple[Any, ...] = ()
|
||||||
|
pontuacoes_de_humor: tuple[Any, ...] = ()
|
||||||
|
if self._classificador is not None:
|
||||||
|
pontuacoes_de_genero, pontuacoes_de_humor = self._classificador.classificar(caminho)
|
||||||
|
return self._converter_resultado(
|
||||||
|
caminho, dados, pontuacoes_de_genero, pontuacoes_de_humor,
|
||||||
|
)
|
||||||
|
|
||||||
def _obter_extrator(self) -> Callable[[str], tuple[Any, Any]]:
|
def _obter_extrator(self) -> Callable[[str], tuple[Any, Any]]:
|
||||||
if self._extrator is not None:
|
if self._extrator is not None:
|
||||||
@@ -69,7 +77,11 @@ class AnalisadorDeMusicaInstrumentalEssentia:
|
|||||||
return self._extrator
|
return self._extrator
|
||||||
|
|
||||||
def _converter_resultado(
|
def _converter_resultado(
|
||||||
self, caminho: Path, dados: Any,
|
self,
|
||||||
|
caminho: Path,
|
||||||
|
dados: Any,
|
||||||
|
pontuacoes_de_genero: tuple[Any, ...] = (),
|
||||||
|
pontuacoes_de_humor: tuple[Any, ...] = (),
|
||||||
) -> ResultadoDaAnaliseMusical:
|
) -> ResultadoDaAnaliseMusical:
|
||||||
return ResultadoDaAnaliseMusical(
|
return ResultadoDaAnaliseMusical(
|
||||||
caminho_do_arquivo=caminho,
|
caminho_do_arquivo=caminho,
|
||||||
@@ -79,10 +91,12 @@ class AnalisadorDeMusicaInstrumentalEssentia:
|
|||||||
modo=self._texto(self._buscar(dados, "tonal.key_edma.scale")),
|
modo=self._texto(self._buscar(dados, "tonal.key_edma.scale")),
|
||||||
intensidade=self._numero(self._buscar(dados, "lowlevel.average_loudness")),
|
intensidade=self._numero(self._buscar(dados, "lowlevel.average_loudness")),
|
||||||
dancabilidade=self._numero(self._buscar(dados, "rhythm.danceability")),
|
dancabilidade=self._numero(self._buscar(dados, "rhythm.danceability")),
|
||||||
generos=self._etiquetas(dados, ("genre", "genres")),
|
generos=tuple(item.etiqueta for item in pontuacoes_de_genero),
|
||||||
humores=self._etiquetas(dados, ("mood", "moods")),
|
humores=tuple(item.etiqueta for item in pontuacoes_de_humor),
|
||||||
instrumentos=self._etiquetas(dados, ("instrument", "instruments")),
|
instrumentos=self._etiquetas(dados, ("instrument", "instruments")),
|
||||||
etiquetas=self._etiquetas(dados, ("tags", "semantic")),
|
etiquetas=self._etiquetas(dados, ("tags", "semantic")),
|
||||||
|
pontuacoes_de_genero=pontuacoes_de_genero,
|
||||||
|
pontuacoes_de_humor=pontuacoes_de_humor,
|
||||||
modelo=self._nome_do_modelo,
|
modelo=self._nome_do_modelo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from engine.integracoes.audio import AnalisadorDeMusicaInstrumentalEssentia
|
from engine.integracoes.audio import AnalisadorDeMusicaInstrumentalEssentia
|
||||||
|
from engine.integracoes.audio import PontuacaoDeEtiqueta
|
||||||
|
|
||||||
|
|
||||||
class TesteAnalisadorDeMusicaInstrumentalEssentia(unittest.TestCase):
|
class TesteAnalisadorDeMusicaInstrumentalEssentia(unittest.TestCase):
|
||||||
@@ -54,3 +55,25 @@ class TesteAnalisadorDeMusicaInstrumentalEssentia(unittest.TestCase):
|
|||||||
self.assertEqual(resultado.duracao_em_segundos, 10.0)
|
self.assertEqual(resultado.duracao_em_segundos, 10.0)
|
||||||
self.assertEqual(resultado.batidas_por_minuto, 110.0)
|
self.assertEqual(resultado.batidas_por_minuto, 110.0)
|
||||||
self.assertEqual(resultado.tonalidade, "D")
|
self.assertEqual(resultado.tonalidade, "D")
|
||||||
|
|
||||||
|
def test_incorpora_classificacoes_do_audio_ao_resultado(self):
|
||||||
|
classificador = type("Classificador", (), {
|
||||||
|
"classificar": lambda self, _arquivo: (
|
||||||
|
(PontuacaoDeEtiqueta("Jazz-Funk", 0.8),),
|
||||||
|
(PontuacaoDeEtiqueta("energética", 0.7),),
|
||||||
|
),
|
||||||
|
})()
|
||||||
|
|
||||||
|
def extrator(_caminho: str):
|
||||||
|
return {"rhythm.bpm": 100.0}, {}
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as pasta:
|
||||||
|
arquivo = Path(pasta) / "instrumental.wav"
|
||||||
|
arquivo.write_bytes(b"audio")
|
||||||
|
resultado = AnalisadorDeMusicaInstrumentalEssentia(
|
||||||
|
extrator, classificador=classificador,
|
||||||
|
).analisar(arquivo)
|
||||||
|
|
||||||
|
self.assertEqual(resultado.generos, ("Jazz-Funk",))
|
||||||
|
self.assertEqual(resultado.humores, ("energética",))
|
||||||
|
self.assertEqual(resultado.pontuacoes_de_genero[0].confianca, 0.8)
|
||||||
|
|||||||
Reference in New Issue
Block a user