diff --git a/code/cep-plugin/main.js b/code/cep-plugin/main.js index e9618aa..cfbbf30 100755 --- a/code/cep-plugin/main.js +++ b/code/cep-plugin/main.js @@ -1326,6 +1326,7 @@ var silenceState = { sequenceId: null, sequenceName: null, mediaPath: null, + audioCachePath: null, fileBaseName: null, outputDir: null, transcriptPath: null, @@ -1333,8 +1334,12 @@ var silenceState = { cachedTranscriptPath: null, jsonOutputPath: null, speakers: [], + speakerTurns: [], }; +var speakerAudioPlayer = null; +var speakerAudioQueue = null; + // ---- Central transcript cache ---- // Evita rodar o Whisper de novo para um vídeo já transcrito antes: indexa // transcrições por caminho absoluto + tamanho + data de modificação do @@ -1572,9 +1577,11 @@ function silenceSelectSequence() { silenceState.mediaPath = result.mediaPath; silenceState.fileBaseName = slugify(result.clipName || result.sequenceName); silenceState.outputDir = path.join(SILENCE_OUTPUT_ROOT, silenceState.fileBaseName); + silenceState.audioCachePath = path.join(silenceState.outputDir, path.parse(result.mediaPath).name + ".wav"); silenceState.transcriptPath = null; silenceState.jsonOutputPath = null; silenceState.speakers = []; + silenceState.speakerTurns = []; var speakerConfig = document.getElementById("silenceSpeakerConfig"); if (speakerConfig) speakerConfig.hidden = true; @@ -1774,6 +1781,7 @@ function silenceTranscribe() { silenceState.transcriptPath = out; silenceState.transcribeModel = model; silenceState.speakers = []; + silenceState.speakerTurns = []; var speakerConfig = document.getElementById("silenceSpeakerConfig"); if (speakerConfig) speakerConfig.hidden = true; var resumo = parsed.segments + " trechos · " + Math.round(parsed.duration) + "s de áudio"; @@ -1844,7 +1852,10 @@ function silenceDiarizeSpeakers(onFinished) { } var parsed = null; try { parsed = JSON.parse(fs.readFileSync(silenceState.transcriptPath, "utf-8")).diarization; } catch (e) {} - if (parsed && Array.isArray(parsed.speakers)) renderSpeakerConfiguration(parsed.speakers); + if (parsed && Array.isArray(parsed.speakers)) { + silenceState.speakerTurns = Array.isArray(parsed.turns) ? parsed.turns : []; + renderSpeakerConfiguration(parsed.speakers, silenceState.speakerTurns); + } var msg = parsed ? "Foram detectados " + parsed.num_speakers + (parsed.num_speakers === 1 ? " falante" : " falantes") + ": " + parsed.speakers.join(", ") + "." : "Falantes detectados."; @@ -1863,11 +1874,12 @@ function speakerNameDefault(speakerId) { return speakerId; } -function renderSpeakerConfiguration(speakerIds) { +function renderSpeakerConfiguration(speakerIds, speakerTurns) { var config = document.getElementById("silenceSpeakerConfig"); var list = document.getElementById("silenceSpeakerList"); var count = document.getElementById("silenceSpeakerCount"); if (!config || !list || !count) return; + silenceState.speakerTurns = Array.isArray(speakerTurns) ? speakerTurns : silenceState.speakerTurns; var previous = {}; silenceState.speakers.forEach(function (speaker) { previous[speaker.id] = speaker; }); silenceState.speakers = speakerIds.map(function (id) { @@ -1887,6 +1899,13 @@ function renderSpeakerConfiguration(speakerIds) { var label = document.createElement("label"); label.className = "field-label"; label.textContent = speaker.id; + var play = document.createElement("button"); + play.type = "button"; + play.className = "button speaker-play-button"; + play.textContent = "▶"; + play.title = "Ouvir trechos de " + speaker.id; + play.setAttribute("aria-label", "Ouvir trechos de " + speaker.id); + play.addEventListener("click", function () { toggleSpeakerAudio(speaker.id, play); }); var input = document.createElement("input"); input.type = "text"; input.className = "select-field speaker-name-input"; @@ -1906,6 +1925,7 @@ function renderSpeakerConfiguration(speakerIds) { activeLabel.appendChild(active); activeLabel.appendChild(activeText); row.appendChild(label); + row.appendChild(play); row.appendChild(input); row.appendChild(activeLabel); list.appendChild(row); @@ -1913,12 +1933,81 @@ function renderSpeakerConfiguration(speakerIds) { config.hidden = silenceState.speakers.length === 0; } +function speakerAudioFileUrl(filePath) { + return encodeURI("file://" + String(filePath || "")); +} + +function stopSpeakerAudio() { + if (speakerAudioPlayer) { + speakerAudioPlayer.pause(); + speakerAudioPlayer.removeAttribute("src"); + speakerAudioPlayer.load(); + } + speakerAudioPlayer = null; + speakerAudioQueue = null; + document.querySelectorAll(".speaker-play-button").forEach(function (button) { + button.textContent = "▶"; + button.classList.remove("is-playing"); + }); +} + +function toggleSpeakerAudio(speakerId, button) { + if (speakerAudioPlayer && speakerAudioQueue && speakerAudioQueue.speakerId === speakerId) { + stopSpeakerAudio(); + return; + } + stopSpeakerAudio(); + var ranges = silenceState.speakerTurns.filter(function (turn) { + return turn.speaker === speakerId && Number(turn.end) > Number(turn.start); + }).sort(function (a, b) { return Number(a.start) - Number(b.start); }); + if (!ranges.length) { + showToast("err", "Não há intervalos de áudio disponíveis para " + speakerId + "."); + return; + } + if (!silenceState.audioCachePath || !fs.existsSync(silenceState.audioCachePath)) { + showToast("err", "Arquivo de áudio do cache não encontrado. Reprocesse a transcrição."); + return; + } + speakerAudioQueue = { speakerId: speakerId, ranges: ranges, index: 0, button: button }; + speakerAudioPlayer = new Audio(speakerAudioFileUrl(silenceState.audioCachePath)); + button.textContent = "■"; + button.classList.add("is-playing"); + speakerAudioPlayer.addEventListener("loadedmetadata", playCurrentSpeakerRange); + speakerAudioPlayer.addEventListener("timeupdate", advanceSpeakerAudioRange); + speakerAudioPlayer.addEventListener("ended", advanceSpeakerAudioRange); + speakerAudioPlayer.addEventListener("error", function () { + stopSpeakerAudio(); + showToast("err", "Não foi possível reproduzir o áudio deste falante."); + }); +} + +function playCurrentSpeakerRange() { + if (!speakerAudioPlayer || !speakerAudioQueue) return; + var range = speakerAudioQueue.ranges[speakerAudioQueue.index]; + if (!range) { stopSpeakerAudio(); return; } + speakerAudioPlayer.currentTime = Math.max(0, Number(range.start) - 0.15); + var playPromise = speakerAudioPlayer.play(); + if (playPromise && playPromise.catch) playPromise.catch(function () { stopSpeakerAudio(); }); +} + +function advanceSpeakerAudioRange() { + if (!speakerAudioPlayer || !speakerAudioQueue) return; + var range = speakerAudioQueue.ranges[speakerAudioQueue.index]; + if (!range || speakerAudioPlayer.currentTime < Number(range.end)) return; + speakerAudioQueue.index += 1; + if (speakerAudioQueue.index >= speakerAudioQueue.ranges.length) { + stopSpeakerAudio(); + return; + } + playCurrentSpeakerRange(); +} + function silenceLoadSpeakerConfiguration() { if (!silenceState.transcriptPath) return; try { var transcript = JSON.parse(fs.readFileSync(silenceState.transcriptPath, "utf-8")); var diarization = transcript.diarization; - if (diarization && Array.isArray(diarization.speakers)) renderSpeakerConfiguration(diarization.speakers); + if (diarization && Array.isArray(diarization.speakers)) renderSpeakerConfiguration(diarization.speakers, diarization.turns); } catch (e) {} } diff --git a/code/cep-plugin/styles.css b/code/cep-plugin/styles.css index 7fc5acc..9efad95 100755 --- a/code/cep-plugin/styles.css +++ b/code/cep-plugin/styles.css @@ -406,8 +406,10 @@ textarea { padding: 8px 10px; font: 10px var(--font-mono); color: var(--text-sec .speaker-config-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 10.5px; } .speaker-config-head span { color: var(--text-muted); font-size: 9.2px; } .speaker-config .field-help { margin: 6px 0 8px; } -.speaker-row { display: grid; grid-template-columns: 92px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); } +.speaker-row { display: grid; grid-template-columns: 92px auto minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); } .speaker-row .field-label { margin: 0; } +.speaker-play-button { min-width: 28px; padding: 4px 7px; } +.speaker-play-button.is-playing { color: var(--accent); } .speaker-name-input { width: 100%; } .speaker-active-field { margin: 0; padding: 6px 8px; white-space: nowrap; }