From c236acf6e583bc5959c993404cd121123713a949 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Wed, 27 May 2026 10:58:01 -0300 Subject: [PATCH 01/14] feat: implement smart spectrum sync for audio merging --- src/commands/merge.ts | 64 ++++++++++++++++++++++++++++---- src/locales/en-US.ts | 8 ++++ src/locales/pt-BR.ts | 8 ++++ src/services/spectrumAnalyzer.ts | 60 ++++++++++++++++++++++++++++++ src/utils/ffmpeg.ts | 34 +++++++++++++++++ src/utils/formatters.ts | 14 +++++++ src/views/mergeView.ts | 4 +- 7 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 src/services/spectrumAnalyzer.ts diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 26e4cb6..f252651 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { confirm, groupMultiselect, log, note, select, text } from '@clack/prompts'; +import { confirm, groupMultiselect, log, note, select, text, spinner } from '@clack/prompts'; import pc from 'picocolors'; import { getPreferredVideoSource, getPrimaryVideoStream } from '../services/analyzer.ts'; @@ -11,11 +11,13 @@ import type { FFprobeData, SelectedStream } from '../types/media'; import { buildMergeCommand } from '../utils/builder.ts'; import { ValidationError } from '../utils/errors.ts'; import { getMediaInfo } from '../utils/ffprobe.ts'; -import { calculateTotalFrames } from '../utils/formatters.ts'; +import { calculateTotalFrames, parseTimestampToSeconds, formatSecondsToTimestamp } from '../utils/formatters.ts'; import { t } from '../utils/i18n.ts'; import { editTagsMenu, handleExecutionMenu, onCancel, sanitizePath } from '../utils/ui.ts'; import { buildSyncOptions, renderComparison } from '../views/mergeView.ts'; import { buildGroupedOptions } from '../views/streamOptions.ts'; +import { calculateSpectrumDelay } from '../services/spectrumAnalyzer.ts'; +import { extractRawAudio } from '../utils/ffmpeg.ts'; const fallbackRules = fallbackRulesData as FallbackRules; @@ -72,7 +74,7 @@ export async function mergeCommand(_args: string[]) { if (Math.abs(media.durationDiffMs) > 1000) { note(pc.yellow(t('mergeDurationAlert')), t('durationAlertTitle')); - applySyncState(state, await promptSyncAdjustment(media.durationDiffMs, state)); + applySyncState(state, await promptSyncAdjustment(media, state)); } while (true) { @@ -110,7 +112,7 @@ export async function mergeCommand(_args: string[]) { } if (result.action === 'adjust_sync') { - applySyncState(state, await promptSyncAdjustment(media.durationDiffMs, state)); + applySyncState(state, await promptSyncAdjustment(media, state)); continue; } @@ -223,10 +225,10 @@ function buildStreamOptionSources(media: MergeMediaContext) { } async function promptSyncAdjustment( - durationDiffMs: number, + media: MergeMediaContext, currentState: MergeSyncState ): Promise { - const options = buildSyncOptions(durationDiffMs); + const options = buildSyncOptions(media.durationDiffMs); const chosenSyncAction = onCancel(await select({ message: t('mergeHowToSync'), @@ -236,8 +238,54 @@ async function promptSyncAdjustment( let nextDelayMs = currentState.currentDelayMs; let nextApplyShortest = currentState.applyShortest; - if (chosenSyncAction === 'auto') { - nextDelayMs = durationDiffMs; + if (chosenSyncAction === 'spectrum') { + const tsStr = onCancel(await text({ + message: t('mergeAskTimestamp'), + placeholder: '00:15:30', + validate(value) { + if (!value || !value.includes(':')) return t('validNumber'); + } + })); + + const windowStr = onCancel(await text({ + message: t('mergeAskWindow'), + initialValue: '30', + validate(value) { + if (value && isNaN(Number.parseInt(value, 10))) return t('validNumber'); + } + })); + + const durB = Number.parseInt(windowStr, 10) || 30; + const durA = 10; + + const tsSecs = parseTimestampToSeconds(tsStr); + const tsBSecs = Math.max(0, tsSecs - 10); // Starts 10s before the timestamp + const startTsB = formatSecondsToTimestamp(tsBSecs); + + const s = spinner(); + s.start(t('mergeSpectrumExtracting')); + + try { + // Extração simultânea bloqueante (mas performática) + const bufferA = await extractRawAudio(media.sourcePathA, tsStr, durA); + const bufferB = await extractRawAudio(media.sourcePathB, startTsB, durB); + + s.message(t('mergeSpectrumCalculating')); + + const rawOffsetMs = calculateSpectrumDelay(bufferA, bufferB); + const diffSecs = tsSecs - tsBSecs; + const realDelayMs = rawOffsetMs - (diffSecs * 1000); + nextDelayMs = Math.round(realDelayMs * -1); + + s.stop(pc.green(t('successOp'))); + } catch (err) { + s.stop(pc.red(t('mergeSpectrumFailed'))); + if (err instanceof Error) { + log.error(err.message); + } + } + } else if (chosenSyncAction === 'auto') { + nextDelayMs = media.durationDiffMs; } else if (chosenSyncAction === 'manual') { const delayStr = onCancel(await text({ message: t('mergeAskDelay'), diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 3d4b283..50f8d21 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -88,6 +88,14 @@ export default { mergeModifyStreams: 'Modify the streams you want to keep:', mergeCmdSuggested: 'Suggested FFmpeg Command (Merge)', + // Command: Merge (Spectrum Sync) + mergeSpectrumSync: '🎶 Smart Spectrum Sync (Auto-align by audio)', + mergeAskTimestamp: 'Enter the timestamp of File A with distinct sounds (e.g., 00:15:30):', + mergeAskWindow: 'Search window size in File B in seconds (Default: 30):', + mergeSpectrumExtracting: 'Extracting audio frequencies for comparison...', + mergeSpectrumCalculating: 'Calculating cross-correlation matrix...', + mergeSpectrumFailed: 'Failed to analyze audio spectrum.', + // Scanners (FFprobe / FFmpeg) scanQuickStart: 'Running integrity Quick Scan...', scanQuickPass: '✔ Quick Scan passed: Container structure is intact.', diff --git a/src/locales/pt-BR.ts b/src/locales/pt-BR.ts index c98a5ba..55bf9f8 100644 --- a/src/locales/pt-BR.ts +++ b/src/locales/pt-BR.ts @@ -88,6 +88,14 @@ export default { mergeModifyStreams: 'Modifique as faixas que deseja manter:', mergeCmdSuggested: 'Comando FFmpeg Sugerido (Merge)', + // Command: Merge (Spectrum Sync) + mergeSpectrumSync: '🎶 Sincronizar por Espectro de Áudio (Automático)', + mergeAskTimestamp: 'Digite o momento do filme A sem falas (ex: 00:15:30):', + mergeAskWindow: 'Tamanho da janela de busca no filme B em segundos (Padrão: 30):', + mergeSpectrumExtracting: 'Extraindo frequências de áudio para comparação...', + mergeSpectrumCalculating: 'Calculando matriz de correlação cruzada...', + mergeSpectrumFailed: 'Falha ao analisar espectro de áudio.', + // Scanners (FFprobe / FFmpeg) scanQuickStart: 'Executando Quick Scan de integridade...', scanQuickPass: '✔ Quick Scan aprovado: Estrutura do container intacta.', diff --git a/src/services/spectrumAnalyzer.ts b/src/services/spectrumAnalyzer.ts new file mode 100644 index 0000000..a225492 --- /dev/null +++ b/src/services/spectrumAnalyzer.ts @@ -0,0 +1,60 @@ +/** + * Calculates the exact delay between two audio buffers using the Pearson Correlation Coefficient (PCC). + * Extracted arrays must be sampled at 1000Hz for a 1:1 millisecond mapping. + * Time Complexity: O(N * (M - N)) + */ +export const calculateSpectrumDelay = (audioA: Float32Array, audioB: Float32Array): number => { + const lenA = audioA.length; + const lenB = audioB.length; + const slideRange = lenB - lenA; + + if (slideRange < 0) { + throw new Error('Search window in File B must be larger than File A snippet.'); + } + + let sumA = 0; + for (let i = 0; i < lenA; i++) sumA += audioA[i]!; + const meanA = sumA / lenA; + + let varA = 0; + for (let i = 0; i < lenA; i++) { + const diff = audioA[i]! - meanA; + varA += diff * diff; + } + + if (varA === 0) return 0; + + let maxPCC = -Infinity; + let bestOffset = 0; + + let sumB = 0; + for (let i = 0; i < lenA; i++) sumB += audioB[i]!; + + for (let i = 0; i <= slideRange; i++) { + if (i > 0) { + sumB = sumB - audioB[i - 1]! + audioB[i + lenA - 1]!; + } + const meanB = sumB / lenA; + + let covariance = 0; + let varB = 0; + + for (let j = 0; j < lenA; j++) { + const valB_diff = audioB[i + j]! - meanB; + covariance += (audioA[j]! - meanA) * valB_diff; + varB += valB_diff * valB_diff; + } + + if (varB > 0) { + const pcc = covariance / Math.sqrt(varA * varB); + const absPCC = Math.abs(pcc); + + if (absPCC > maxPCC) { + maxPCC = absPCC; + bestOffset = i; + } + } + } + + return bestOffset; +}; \ No newline at end of file diff --git a/src/utils/ffmpeg.ts b/src/utils/ffmpeg.ts index 6de1c38..904a4a0 100644 --- a/src/utils/ffmpeg.ts +++ b/src/utils/ffmpeg.ts @@ -193,3 +193,37 @@ export async function runConversion(ffmpegCmd: string, totalDurationSec: number, }); }); } + +export async function extractRawAudio(filePath: string, startTs: string, durationSec: number): Promise { + return new Promise((resolve, reject) => { + const ff = spawn('ffmpeg', [ + '-v', 'error', + '-ss', startTs, + '-t', durationSec.toString(), + '-i', filePath, + '-ac', '1', + '-ar', '1000', + '-f', 'f32le', + 'pipe:1' + ]); + + const chunks: Buffer[] = []; + let errorLog = ''; + + ff.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)); + ff.stderr.on('data', (data: Buffer) => errorLog += data.toString()); + + ff.on('close', (code) => { + if (code !== 0) { + reject(new JellyError(`${t('mergeSpectrumFailed')} ${errorLog.trim()}`, 'FFMPEG_EXTRACTION_FAILED')); + return; + } + const fullBuffer = Buffer.concat(chunks); + resolve(new Float32Array(fullBuffer.buffer, fullBuffer.byteOffset, fullBuffer.byteLength / 4)); + }); + + ff.on('error', (err) => { + reject(new JellyError(`${t('mergeSpectrumFailed')} ${err.message}`, 'FFMPEG_START_FAILED')); + }); + }); +} \ No newline at end of file diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index df9ec07..1385a24 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -89,3 +89,17 @@ export const calculateTotalFrames = ( } return 0; }; + +export const parseTimestampToSeconds = (ts: string): number => { + const parts = ts.split(':').map(Number); + if (parts.length === 3) return (parts[0]! * 3600) + (parts[1]! * 60) + parts[2]!; + if (parts.length === 2) return (parts[0]! * 60) + parts[1]!; + return Number(ts) || 0; +}; + +export const formatSecondsToTimestamp = (totalSeconds: number): string => { + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = Math.floor(totalSeconds % 60); + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; +}; \ No newline at end of file diff --git a/src/views/mergeView.ts b/src/views/mergeView.ts index 0b1a030..dd67ae1 100644 --- a/src/views/mergeView.ts +++ b/src/views/mergeView.ts @@ -6,7 +6,7 @@ import type { FFprobeData } from '../types/media'; export type SyncMenuOption = { label: string; - value: 'auto' | 'manual' | 'none'; + value: 'auto' | 'manual' | 'none' | 'spectrum'; }; export function buildSyncOptions(exactDiffMs: number): SyncMenuOption[] { @@ -19,12 +19,12 @@ export function buildSyncOptions(exactDiffMs: number): SyncMenuOption[] { }); } + options.push({ label: t('mergeSpectrumSync'), value: 'spectrum' }); options.push({ label: t('mergeManualSync'), value: 'manual' }); options.push({ label: t('mergeNoSync'), value: 'none' }); return options; } - const buildFileSummary = (info: FFprobeData) => { const duration = info.format?.duration ? formatDuration(Number.parseFloat(info.format.duration)) : 'N/A'; const size = info.format?.size ? formatSize(Number.parseInt(info.format.size, 10)) : 'N/A'; From e26b277a4aa372362c308681dafc78fa129c7bda Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Wed, 27 May 2026 11:02:04 -0300 Subject: [PATCH 02/14] feat: enhance spectrum sync messages --- src/commands/merge.ts | 17 +++++++---------- src/locales/en-US.ts | 9 +++++---- src/locales/pt-BR.ts | 9 +++++---- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/commands/merge.ts b/src/commands/merge.ts index f252651..19b09c4 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -239,6 +239,8 @@ async function promptSyncAdjustment( let nextApplyShortest = currentState.applyShortest; if (chosenSyncAction === 'spectrum') { + log.info(pc.cyan(t('mergeSpectrumHint'))); + const tsStr = onCancel(await text({ message: t('mergeAskTimestamp'), placeholder: '00:15:30', @@ -247,16 +249,9 @@ async function promptSyncAdjustment( } })); - const windowStr = onCancel(await text({ - message: t('mergeAskWindow'), - initialValue: '30', - validate(value) { - if (value && isNaN(Number.parseInt(value, 10))) return t('validNumber'); - } - })); - - const durB = Number.parseInt(windowStr, 10) || 30; + // 10s of sample window to 30s search window const durA = 10; + const durB = 30; const tsSecs = parseTimestampToSeconds(tsStr); const tsBSecs = Math.max(0, tsSecs - 10); // Starts 10s before the timestamp @@ -266,7 +261,6 @@ async function promptSyncAdjustment( s.start(t('mergeSpectrumExtracting')); try { - // Extração simultânea bloqueante (mas performática) const bufferA = await extractRawAudio(media.sourcePathA, tsStr, durA); const bufferB = await extractRawAudio(media.sourcePathB, startTsB, durB); @@ -274,10 +268,13 @@ async function promptSyncAdjustment( const rawOffsetMs = calculateSpectrumDelay(bufferA, bufferB); const diffSecs = tsSecs - tsBSecs; + const realDelayMs = rawOffsetMs - (diffSecs * 1000); nextDelayMs = Math.round(realDelayMs * -1); s.stop(pc.green(t('successOp'))); + log.success(pc.green(t('mergeSpectrumResult', nextDelayMs))); + } catch (err) { s.stop(pc.red(t('mergeSpectrumFailed'))); if (err instanceof Error) { diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 50f8d21..3501d6a 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -79,7 +79,7 @@ export default { mergeNone: 'None', mergeSelectStreams: 'Select the streams you want to keep', mergeHowToSync: 'How do you want to adjust the sync for File B?', - mergeAutoSync: '⏭️ Auto-align ({0} to match File A)', + mergeAutoSync: '⏭️ Match Durations ({0} offset to match File A)', mergeManualSync: '✏️ Enter value manually', mergeNoSync: '❌ Do not adjust (0ms)', mergeAskDelay: 'Enter the delay for File B in milliseconds (e.g., 2000 to delay 2s, -500 to advance 0.5s):', @@ -89,9 +89,10 @@ export default { mergeCmdSuggested: 'Suggested FFmpeg Command (Merge)', // Command: Merge (Spectrum Sync) - mergeSpectrumSync: '🎶 Smart Spectrum Sync (Auto-align by audio)', - mergeAskTimestamp: 'Enter the timestamp of File A with distinct sounds (e.g., 00:15:30):', - mergeAskWindow: 'Search window size in File B in seconds (Default: 30):', + mergeSpectrumSync: '🎶 Smart Spectrum Sync (Audio-based auto-align)', + mergeAskTimestamp: 'Enter the timestamp of File A (Reference) for analysis:', + mergeSpectrumHint: '💡 Tip: Choose a timestamp without dialogue, preferably with music or strong ambient sounds.', + mergeSpectrumResult: 'Sync complete! Calculated compensation: {0}ms', mergeSpectrumExtracting: 'Extracting audio frequencies for comparison...', mergeSpectrumCalculating: 'Calculating cross-correlation matrix...', mergeSpectrumFailed: 'Failed to analyze audio spectrum.', diff --git a/src/locales/pt-BR.ts b/src/locales/pt-BR.ts index 55bf9f8..42c9f22 100644 --- a/src/locales/pt-BR.ts +++ b/src/locales/pt-BR.ts @@ -79,7 +79,7 @@ export default { mergeNone: 'Nenhuma', mergeSelectStreams: 'Selecione as faixas que deseja manter', mergeHowToSync: 'Como deseja ajustar a sincronia do Arquivo B?', - mergeAutoSync: '⏭️ Auto-alinhar ({0} para igualar ao Arquivo A)', + mergeAutoSync: '⏭️ Igualar Durações (Aplicar {0} para igualar ao Arquivo A)', mergeManualSync: '✏️ Digitar valor manualmente', mergeNoSync: '❌ Não ajustar (0ms)', mergeAskDelay: 'Informe o atraso do Arquivo B em milissegundos (ex: 2000 para atrasar 2s, -500 para adiantar 0.5s):', @@ -89,9 +89,10 @@ export default { mergeCmdSuggested: 'Comando FFmpeg Sugerido (Merge)', // Command: Merge (Spectrum Sync) - mergeSpectrumSync: '🎶 Sincronizar por Espectro de Áudio (Automático)', - mergeAskTimestamp: 'Digite o momento do filme A sem falas (ex: 00:15:30):', - mergeAskWindow: 'Tamanho da janela de busca no filme B em segundos (Padrão: 30):', + mergeSpectrumSync: '🎶 Smart Spectrum Sync (Auto-alinhamento por áudio)', + mergeAskTimestamp: 'Digite o momento do Arquivo A (Referência) para análise:', + mergeSpectrumHint: '💡 Dica: Escolha um momento sem falas/dublagem, de preferência com música ou sons ambientes marcantes.', + mergeSpectrumResult: 'Sincronia concluída! Compensação calculada: {0}ms', mergeSpectrumExtracting: 'Extraindo frequências de áudio para comparação...', mergeSpectrumCalculating: 'Calculando matriz de correlação cruzada...', mergeSpectrumFailed: 'Falha ao analisar espectro de áudio.', From 51430993120113732de4a181eb2127cda26c2522 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Wed, 27 May 2026 11:11:33 -0300 Subject: [PATCH 03/14] fix: reposition spectrum sync option in sync menu --- src/views/mergeView.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/views/mergeView.ts b/src/views/mergeView.ts index dd67ae1..1a7f2bf 100644 --- a/src/views/mergeView.ts +++ b/src/views/mergeView.ts @@ -12,6 +12,8 @@ export type SyncMenuOption = { export function buildSyncOptions(exactDiffMs: number): SyncMenuOption[] { const options: SyncMenuOption[] = []; + options.push({ label: t('mergeSpectrumSync'), value: 'spectrum' }); + if (Math.abs(exactDiffMs) > 1000) { options.push({ label: t('mergeAutoSync', exactDiffMs > 0 ? t('delayBehind', Math.abs(exactDiffMs)) : t('delayAhead', Math.abs(exactDiffMs))), @@ -19,7 +21,6 @@ export function buildSyncOptions(exactDiffMs: number): SyncMenuOption[] { }); } - options.push({ label: t('mergeSpectrumSync'), value: 'spectrum' }); options.push({ label: t('mergeManualSync'), value: 'manual' }); options.push({ label: t('mergeNoSync'), value: 'none' }); From 79f055688b8bac878b237036a03e96708d0c7357 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Wed, 27 May 2026 11:54:27 -0300 Subject: [PATCH 04/14] test: create spectrum delay calculation and audio extraction tests --- src/services/spectrumAnalyzer.test.ts | 40 ++++++++++++++ src/utils/ffmpeg.test.ts | 75 ++++++++++++++++++++++++++- src/utils/formatters.test.ts | 30 ++++++++++- tests/e2e/merge.test.ts | 46 ++++++++++++++-- 4 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 src/services/spectrumAnalyzer.test.ts diff --git a/src/services/spectrumAnalyzer.test.ts b/src/services/spectrumAnalyzer.test.ts new file mode 100644 index 0000000..609d518 --- /dev/null +++ b/src/services/spectrumAnalyzer.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { calculateSpectrumDelay } from './spectrumAnalyzer.ts'; + +describe('services/spectrumAnalyzer.ts', () => { + test('Should return 0 for perfect alignment (identical arrays)', () => { + const audioA = new Float32Array([0.1, -0.2, 0.5, 0.3]); + const audioB = new Float32Array([0.1, -0.2, 0.5, 0.3]); + + expect(calculateSpectrumDelay(audioA, audioB)).toBe(0); + }); + + test('Should accurately find positive displacement', () => { + const audioA = new Float32Array([0.5, 0.8, -0.2]); + const audioB = new Float32Array([0, 0, 0.5, 0.8, -0.2, 0.1]); + + expect(calculateSpectrumDelay(audioA, audioB)).toBe(2); + }); + + test('Should align correctly even with inverted phase (phase shift)', () => { + const audioA = new Float32Array([0.5, 0.8, -0.2]); + const audioB = new Float32Array([0, 0, -0.5, -0.8, 0.2, 0]); + + expect(calculateSpectrumDelay(audioA, audioB)).toBe(2); + }); + + test('Should return 0 if snippet has absolute silence (zero variance)', () => { + const audioA = new Float32Array([0, 0, 0]); + const audioB = new Float32Array([0, 0, 0, 0, 0]); + + expect(calculateSpectrumDelay(audioA, audioB)).toBe(0); + }); + + test('Should throw error if search window is smaller than snippet', () => { + const audioA = new Float32Array([1, 2, 3]); + const audioB = new Float32Array([1, 2]); + + expect(() => calculateSpectrumDelay(audioA, audioB)) + .toThrow('Search window in File B must be larger than File A snippet.'); + }); +}); \ No newline at end of file diff --git a/src/utils/ffmpeg.test.ts b/src/utils/ffmpeg.test.ts index cee3c90..76f0cb1 100644 --- a/src/utils/ffmpeg.test.ts +++ b/src/utils/ffmpeg.test.ts @@ -6,7 +6,8 @@ import { getDynamicVideoEncoder, getDynamicAudioEncoder, runDeepScan, - runConversion + runConversion, + extractRawAudio } from './ffmpeg.ts'; import { JellyError } from './errors.ts'; @@ -164,4 +165,74 @@ describe('utils/ffmpeg.ts', () => { errCode: 'FFMPEG_START_FAILED' }); }); -}); + + test('extractRawAudio should resolve with Float32Array on success', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const extractPromise = extractRawAudio('fake.mkv', '00:00:00', 2); + + const fakeData = Buffer.alloc(16); + fakeData.writeFloatLE(0.1, 0); + fakeData.writeFloatLE(0.2, 4); + fakeData.writeFloatLE(0.3, 8); + fakeData.writeFloatLE(0.4, 12); + + mockProcess.stdout.emit('data', fakeData); + mockProcess.emit('close', 0); + + const result = await extractPromise; + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(4); + expect(result[0]).toBeCloseTo(0.1); + }); + + test('extractRawAudio should reject with JellyError on non-zero exit', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const extractPromise = extractRawAudio('fake.mkv', '00:00:00', 2); + + mockProcess.stderr.emit('data', Buffer.from('Extraction failed\n')); + mockProcess.emit('close', 1); + + let caught: any; + try { await extractPromise; } catch (e) { caught = e; } + + expect({ + isJelly: caught instanceof JellyError, + code: caught?.code, + message: caught?.message + }).toMatchObject({ + isJelly: true, + code: 'FFMPEG_EXTRACTION_FAILED', + message: expect.stringContaining('Extraction failed') + }); + }); + + test('extractRawAudio should throw JellyError if spawn fails', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const extractPromise = extractRawAudio('fake.mkv', '00:00:00', 2); + + mockProcess.emit('error', new Error('ENOENT')); + + let caught: any; + try { await extractPromise; } catch (e) { caught = e; } + + expect({ + isJelly: caught instanceof JellyError, + code: caught?.code + }).toMatchObject({ + isJelly: true, + code: 'FFMPEG_START_FAILED' + }); + }); +}); \ No newline at end of file diff --git a/src/utils/formatters.test.ts b/src/utils/formatters.test.ts index f15e044..3104db2 100644 --- a/src/utils/formatters.test.ts +++ b/src/utils/formatters.test.ts @@ -9,7 +9,9 @@ import { formatSize, padLabel, formatSubtitleCodec, - calculateTotalFrames + calculateTotalFrames, + parseTimestampToSeconds, + formatSecondsToTimestamp } from './formatters.ts'; import { t } from './i18n.ts'; import type { MediaStream } from '../types/media.d.ts'; @@ -140,4 +142,30 @@ describe('utils/formatters.ts', () => { missing: 0 }); }); + + test('parseTimestampToSeconds should correctly parse HH:MM:SS, MM:SS and SS', () => { + expect({ + full: parseTimestampToSeconds('00:15:30'), + partial: parseTimestampToSeconds('15:30'), + secondsOnly: parseTimestampToSeconds('45'), + invalid: parseTimestampToSeconds('invalid') + }).toMatchObject({ + full: 930, + partial: 930, + secondsOnly: 45, + invalid: 0 + }); + }); + + test('formatSecondsToTimestamp should format seconds to HH:MM:SS padding zeros', () => { + expect({ + full: formatSecondsToTimestamp(930), + short: formatSecondsToTimestamp(1), + hours: formatSecondsToTimestamp(3665) + }).toMatchObject({ + full: '00:15:30', + short: '00:00:01', + hours: '01:01:05' + }); + }); }); \ No newline at end of file diff --git a/tests/e2e/merge.test.ts b/tests/e2e/merge.test.ts index db5963f..c5e39b2 100644 --- a/tests/e2e/merge.test.ts +++ b/tests/e2e/merge.test.ts @@ -150,8 +150,6 @@ describe('E2E: JellyCC Merge Menu', () => { cli.write(Keys.Space); cli.write(Keys.Enter); - // await finishTagPromptWithoutChanges(); - await cli.waitForText('Suggested FFmpeg Command (Merge)'); await openExecutionMenu(); exitFromExecutionMenu(); @@ -166,7 +164,7 @@ describe('E2E: JellyCC Merge Menu', () => { expect(finalOutput).toContain('Clean command generated:'); expect(finalOutput).toContain('Operation finished. 🚀'); }); - + test('Should allow manual sync adjustment and apply strict cut (-shortest)', async () => { cli = new CliTester(['bun', 'run', 'src/index.ts', 'merge'], process.cwd()); @@ -176,7 +174,7 @@ describe('E2E: JellyCC Merge Menu', () => { await cli.waitForText('Duration Alert'); await cli.waitForText('How do you want to adjust the sync for File B?'); - cli.press(Keys.Down, 1); + cli.press(Keys.Down, 2); cli.write(Keys.Enter); await cli.waitForText('Enter the delay for File B in milliseconds'); @@ -243,4 +241,42 @@ describe('E2E: JellyCC Merge Menu', () => { expect(finalOutput).toContain('Clean command generated:'); expect(finalOutput).toContain('Operation finished. 🚀'); }); -}); + + test('Should execute Spectrum Sync successfully and calculate compensation', async () => { + cli = new CliTester(['bun', 'run', 'src/index.ts', 'merge'], process.cwd()); + + await openMergePaths(longMergeFixture, longMergeFixture); + await acceptDefaultStreamSelection(); + + await cli.waitForText('Suggested FFmpeg Command (Merge)'); + await openExecutionMenu(); + cli.clearOutput(); + + cli.press(Keys.Down, 5); + cli.write(Keys.Enter); + + await cli.waitForText('How do you want to adjust the sync for File B?'); + + cli.write(Keys.Enter); + + await cli.waitForText('Enter the timestamp of File A (Reference) for analysis:'); + cli.write('00:00:00'); + cli.write(Keys.Enter); + + await cli.waitForText('Sync complete! Calculated compensation: 0ms'); + + await cli.waitForText('Do you want to use Strict Mode'); + cli.write(Keys.Enter); + + await cli.waitForText('Suggested FFmpeg Command (Merge)'); + await openExecutionMenu(); + exitFromExecutionMenu(); + + const exitCode = await cli.waitForExit(); + const finalOutput = cli.getOutput(); + + expect(exitCode).toBe(0); + expect(finalOutput).toContain('Sync complete! Calculated compensation: 0ms'); + expect(finalOutput).toContain('Clean command generated:'); + }); +}); \ No newline at end of file From 02dd3003b1b63d0c952c6259ede4eebec7c3b109 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Wed, 27 May 2026 12:18:49 -0300 Subject: [PATCH 05/14] docs: add spectrum sync on README --- README.md | 1 + README.pt.md | 1 + src/locales/pt-BR.ts | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4ca11c8..1842a9d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - 🔬 **Quick Scan + Deep Scan** — Checks container integrity and analyzes frame by frame looking for artifacts and errors. - 🔬 **Myopic Scan** — Deep Scan restricted to selected tracks. - 🎛️ **Track Selection** — Choose which video, audio, and subtitle streams to keep in the final file. +- 🎶 **Smart Spectrum Sync** — Automatically aligns audio tracks from different sources, using advanced waveform cross-correlation. - ⏱️ **Sync Adjustment / End Cut** — Defines time offset and end cut to avoid lip-sync issues. - 🔀 **File Merging** — Merges tracks from two files into a single MKV, with automatic/manual sync and Strict Mode. - 🏷️ **Tag Editing** — Edits language (e.g., `por`, `eng`, `jpn`) and title for each track. diff --git a/README.pt.md b/README.pt.md index 93271a4..3214b73 100644 --- a/README.pt.md +++ b/README.pt.md @@ -30,6 +30,7 @@ - 🔬 **Quick Scan + Deep Scan** — Verifica integridade do container e analisa quadro a quadro em busca de artefatos e erros. - 🔬 **Myopic Scan** — Deep Scan restrito às faixas selecionadas. - 🎛️ **Seleção de Faixas** — Escolha quais streams de vídeo, áudio e legenda manter no arquivo final. +- 🎶 **Smart Spectrum Sync** — Alinha automaticamente faixas de áudio de origens diferentes, usando correlação matemática de ondas sonoras. - ⏱️ **Ajuste de Sincronia / Corte Final** — Define offset temporal e corte final para evitar problemas de lip-sync. - 🔀 **Mesclagem de Arquivos** — Une faixas de dois arquivos em um único MKV, com sync automático/manual e Modo Estrito. - 🏷️ **Edição de Tags** — Edita idioma (ex: `por`, `eng`, `jpn`) e título de cada faixa. diff --git a/src/locales/pt-BR.ts b/src/locales/pt-BR.ts index 42c9f22..b983ca6 100644 --- a/src/locales/pt-BR.ts +++ b/src/locales/pt-BR.ts @@ -79,7 +79,7 @@ export default { mergeNone: 'Nenhuma', mergeSelectStreams: 'Selecione as faixas que deseja manter', mergeHowToSync: 'Como deseja ajustar a sincronia do Arquivo B?', - mergeAutoSync: '⏭️ Igualar Durações (Aplicar {0} para igualar ao Arquivo A)', + mergeAutoSync: '⏭️ Igualar Durações ({0} para igualar ao Arquivo A)', mergeManualSync: '✏️ Digitar valor manualmente', mergeNoSync: '❌ Não ajustar (0ms)', mergeAskDelay: 'Informe o atraso do Arquivo B em milissegundos (ex: 2000 para atrasar 2s, -500 para adiantar 0.5s):', From 4658675a4c5671362602275677298e30ae0b0c71 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Wed, 27 May 2026 16:21:14 -0300 Subject: [PATCH 06/14] feat: implement Silence Scan for audio drop detection --- src/commands/check.ts | 8 ++++- src/commands/merge.ts | 15 +++++++++- src/locales/en-US.ts | 8 +++++ src/locales/pt-BR.ts | 8 +++++ src/utils/ffmpeg.ts | 69 ++++++++++++++++++++++++++++++++++++++++++- src/utils/ui.ts | 47 +++++++++++++++++++++++++---- 6 files changed, 146 insertions(+), 9 deletions(-) diff --git a/src/commands/check.ts b/src/commands/check.ts index 52481df..17d6239 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -35,8 +35,10 @@ interface CheckLoopContext { totalFrames: number; fullScanInputs: string[]; fullScanMaps: string[]; + fullAudioScanMaps: string[]; selectedScanInputs: string[]; selectedScanMaps: string[]; + selectedAudioScanMaps: string[]; } export async function checkCommand(args: string[]) { @@ -69,8 +71,10 @@ export async function checkCommand(args: string[]) { ffmpegRepairCmd: context.ffmpegRepairCmd, fullScanInputs: context.fullScanInputs, fullScanMaps: context.fullScanMaps, + fullAudioScanMaps: context.fullAudioScanMaps, selectedScanInputs: context.selectedScanInputs, selectedScanMaps: context.selectedScanMaps, + selectedAudioScanMaps: context.selectedAudioScanMaps, outputPath, totalDuration: context.totalDuration, totalFrames: context.totalFrames, @@ -210,8 +214,10 @@ function buildLoopContext( totalFrames: diagnostic.metadata.totalFrames, fullScanInputs: [targetFile], fullScanMaps: ['0'], + fullAudioScanMaps: probeData.streams.filter(s => s.codec_type === 'audio').map(s => `0:${s.index}`), selectedScanInputs: [targetFile], - selectedScanMaps: selectedStreams.map((stream) => `0:${stream.streamIndex}`) + selectedScanMaps: selectedStreams.map((stream) => `0:${stream.streamIndex}`), + selectedAudioScanMaps: selectedStreams.filter(s => s.type === 'audio').map(s => `0:${s.streamIndex}`) }; } diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 19b09c4..da73fec 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -56,6 +56,8 @@ interface MergeLoopContext { totalFrames: number; fullScanInputs: string[]; fullScanMaps: string[]; + fullAudioScanMaps: string[]; + selectedAudioScanMaps: string[]; } export async function mergeCommand(_args: string[]) { @@ -87,6 +89,8 @@ export async function mergeCommand(_args: string[]) { ffmpegRepairCmd: context.ffmpegRepairCmd, fullScanInputs: context.fullScanInputs, fullScanMaps: context.fullScanMaps, + fullAudioScanMaps: context.fullAudioScanMaps, + selectedAudioScanMaps: context.selectedAudioScanMaps, outputPath: media.outputPath, totalDuration: context.totalDuration, totalFrames: context.totalFrames, @@ -342,7 +346,9 @@ function buildLoopContext(media: MergeMediaContext, state: MergeSessionState): M totalDuration: media.totalDuration, totalFrames: media.totalFrames, fullScanInputs: [media.sourcePathA, media.sourcePathB], - fullScanMaps: buildFullScanMaps(media) + fullScanMaps: buildFullScanMaps(media), + fullAudioScanMaps: buildFullAudioScanMaps(media), + selectedAudioScanMaps: state.selectedStreams.filter(s => s.type === 'audio').map(s => `${s.fileIndex}:${s.streamIndex}`) }; } @@ -353,6 +359,13 @@ function buildFullScanMaps(media: MergeMediaContext) { ]; } +function buildFullAudioScanMaps(media: MergeMediaContext) { + return [ + ...media.infoA.streams.filter((stream) => stream.codec_type === 'audio').map((stream) => `0:${stream.index}`), + ...media.infoB.streams.filter((stream) => stream.codec_type === 'audio').map((stream) => `1:${stream.index}`) + ]; +} + function buildSourceScanMaps(probeData: FFprobeData, fileIndex: number) { return probeData.streams .filter((stream) => stream.codec_type === 'video' || stream.codec_type === 'audio') diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 3501d6a..31f9941 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -17,6 +17,7 @@ export default { menuAdjustSync: '⏱️ Adjust Sync / Final Cut', menuDeepScanFull: '🔍 Deep Scan (All tracks from original file)', menuDeepScanSelected: '🔬 Myopic Scan (Selected tracks only)', + menuSilenceScan: '🔊 Silence Scan (Check for audio drops / Mute)', menuRunRepairScan: '🔧 Execute Forced Repair + 🔍 Deep Scan', menuRunRepairOnly: '🔧 Execute Forced Repair only', menuEditTags: '🏷️ Edit Track Tags (Language/Title)', @@ -116,6 +117,13 @@ export default { convFail: '✖ Error during conversion (Code {0}).', convStartFail: '✖ Failed to start FFmpeg process: {0}', + // Silence Scan + scanSilenceStart: 'Searching for audio drops (Silence Scan)...', + scanSilencePass: '✔ Audio intact: No sudden drops detected.', + scanSilenceWarn: '⚠ Warning: Audio drops detected!', + scanSilenceDetail: 'Silence detected at {0} (duration: {1}s)', + scanSilenceFail: '✖ Failed to execute Silence Scan.', + // Command: Lang langSelect: '🌐 Select your preferred language:', langChanged: '✔ Language changed successfully!', diff --git a/src/locales/pt-BR.ts b/src/locales/pt-BR.ts index b983ca6..f573779 100644 --- a/src/locales/pt-BR.ts +++ b/src/locales/pt-BR.ts @@ -17,6 +17,7 @@ export default { menuAdjustSync: '⏱️ Ajustar Sincronia / Corte Final', menuDeepScanFull: '🔍 Deep Scan (Todas as faixas do arquivo original)', menuDeepScanSelected: '🔬 Myopic Scan (Apenas faixas selecionadas)', + menuSilenceScan: '🔊 Silence Scan (Verificar quedas de áudio / Mudo)', menuRunRepairScan: '🔧 Executar Reparo Forçado + 🔍 Deep Scan', menuRunRepairOnly: '🔧 Executar Reparo Forçado apenas', menuEditTags: '🏷️ Editar Tags das Faixas (Idioma/Título)', @@ -116,6 +117,13 @@ export default { convFail: '✖ Erro durante a conversão (Código {0}).', convStartFail: '✖ Falha ao tentar iniciar o processo do FFmpeg: {0}', + // Silence Scan + scanSilenceStart: 'Procurando por quedas de áudio (Silence Scan)...', + scanSilencePass: '✔ Áudio íntegro: Nenhuma queda brusca detectada.', + scanSilenceWarn: '⚠ Atenção: Quedas de áudio detectadas!', + scanSilenceDetail: 'Silêncio detectado em {0} (duração: {1}s)', + scanSilenceFail: '✖ Falha ao executar o Silence Scan.', + // Comando: Lang langSelect: '🌐 Selecione o idioma de sua preferência / Select your preferred language:', langChanged: '✔ Idioma alterado com sucesso!', diff --git a/src/utils/ffmpeg.ts b/src/utils/ffmpeg.ts index 904a4a0..2ce279f 100644 --- a/src/utils/ffmpeg.ts +++ b/src/utils/ffmpeg.ts @@ -1,9 +1,10 @@ import { spawn } from 'child_process'; -import { spinner } from '@clack/prompts'; +import { log, spinner } from '@clack/prompts'; import pc from 'picocolors'; import { t } from './i18n.ts'; import { JellyError } from './errors.ts'; import type { MediaStream } from '../types/media'; +import { formatSecondsToTimestamp } from './formatters.ts'; export function parseFfmpegTime(timeStr: string) { const parts = timeStr.split(':'); @@ -194,6 +195,72 @@ export async function runConversion(ffmpegCmd: string, totalDurationSec: number, }); } +/** + * Executes a Silence Scan to detect accidentally muted tracks or dropped audio. + * Uses a default threshold of -50dB and 2 seconds of minimum duration. + */ +export async function runSilenceScan(inputs: string[], maps: string[]): Promise { + console.log(''); + const scanSpinner = spinner(); + scanSpinner.start(t('scanSilenceStart')); + + return new Promise((resolve) => { + const ffmpegArgs = ['-v', 'info']; + inputs.forEach(inp => { ffmpegArgs.push('-i', inp); }); + maps.forEach(m => { ffmpegArgs.push('-map', m); }); + + ffmpegArgs.push('-vn', '-af', 'silencedetect=noise=-50dB:d=2', '-f', 'null', '-'); + + const ff = spawn('ffmpeg', ffmpegArgs); + let stderrBuffer = ''; + const results: Array<{ start: number, duration: number }> = []; + let currentStart: number | null = null; + + ff.stderr.on('data', (data) => { + stderrBuffer += data.toString(); + const lines = stderrBuffer.split(/[\r\n]+/); + stderrBuffer = lines.pop() || ''; + + for (const line of lines) { + const startMatch = line.match(/silence_start:\s*([\d.]+)/); + if (startMatch) { + currentStart = Number.parseFloat(startMatch[1]!); + } + + const durationMatch = line.match(/silence_duration:\s*([\d.]+)/); + if (durationMatch && currentStart !== null) { + results.push({ start: currentStart, duration: Number.parseFloat(durationMatch[1]!) }); + currentStart = null; + } + } + }); + + ff.on('close', (code) => { + if (code !== 0) { + scanSpinner.stop(pc.yellow(t('scanSilenceFail'))); + return resolve(true); + } + + if (results.length > 0) { + scanSpinner.stop(pc.yellow(t('scanSilenceWarn'))); + results.forEach(res => { + const timestamp = formatSecondsToTimestamp(res.start); + log.warn(pc.yellow(t('scanSilenceDetail', timestamp, res.duration.toFixed(2)))); + }); + resolve(true); + } else { + scanSpinner.stop(pc.green(t('scanSilencePass'))); + resolve(false); + } + }); + + ff.on('error', () => { + scanSpinner.stop(pc.red(t('scanSilenceFail'))); + resolve(true); + }); + }); +} + export async function extractRawAudio(filePath: string, startTs: string, durationSec: number): Promise { return new Promise((resolve, reject) => { const ff = spawn('ffmpeg', [ diff --git a/src/utils/ui.ts b/src/utils/ui.ts index e1e5997..8f46563 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -1,6 +1,6 @@ -import { isCancel, select, outro, text, confirm } from '@clack/prompts'; +import { isCancel, select, outro, text, confirm, spinner } from '@clack/prompts'; import pc from 'picocolors'; -import { runConversion, runDeepScan } from './ffmpeg.ts'; +import { runConversion, runDeepScan, runSilenceScan } from './ffmpeg.ts'; import { getRepairOutputPath } from '../services/repair.ts'; import { t } from './i18n.ts'; import { UserCancelError } from './errors.ts'; @@ -26,8 +26,10 @@ export async function handleExecutionMenu(options: { ffmpegRepairCmd?: string; fullScanInputs: string[]; fullScanMaps: string[]; + fullAudioScanMaps?: string[]; selectedScanInputs?: string[]; selectedScanMaps?: string[]; + selectedAudioScanMaps?: string[]; outputPath: string; totalDuration: number; totalFrames: number; @@ -80,7 +82,8 @@ export async function handleExecutionMenu(options: { menuOptions.push({ label: t('menuDeepScanFull'), value: 'deep_scan_full' }); } } - + + menuOptions.push({ label: t('menuSilenceScan'), value: 'silence_scan' }); menuOptions.push({ label: t('exit'), value: 'exit' }); const action = onCancel(await select({ @@ -89,17 +92,42 @@ export async function handleExecutionMenu(options: { })); if (action === 'deep_scan_selected') { - fileHasErrors = await runDeepScan(options.selectedScanInputs!, options.selectedScanMaps!, options.totalDuration); + const dsErrors = await runDeepScan(options.selectedScanInputs!, options.selectedScanMaps!, options.totalDuration); + let silenceErrors = false; + if (options.selectedAudioScanMaps && options.selectedAudioScanMaps.length > 0) { + silenceErrors = await runSilenceScan(options.selectedScanInputs!, options.selectedAudioScanMaps); + } + fileHasErrors = fileHasErrors || dsErrors || silenceErrors; dsCompleted = true; continue; } if (action === 'deep_scan_full') { - fileHasErrors = await runDeepScan(options.fullScanInputs, options.fullScanMaps, options.totalDuration); + const dsErrors = await runDeepScan(options.fullScanInputs, options.fullScanMaps, options.totalDuration); + let silenceErrors = false; + if (options.fullAudioScanMaps && options.fullAudioScanMaps.length > 0) { + silenceErrors = await runSilenceScan(options.fullScanInputs, options.fullAudioScanMaps); + } + fileHasErrors = fileHasErrors || dsErrors || silenceErrors; dsCompleted = true; continue; } + if (action === 'silence_scan') { + const inputs = options.selectedScanInputs || options.fullScanInputs; + const audioMaps = options.selectedAudioScanMaps || options.fullAudioScanMaps || []; + + if (audioMaps.length > 0) { + const silenceErrors = await runSilenceScan(inputs, audioMaps); + fileHasErrors = fileHasErrors || silenceErrors; + } else { + const scanSpinner = spinner(); + scanSpinner.start(t('scanSilenceStart')); + scanSpinner.stop(pc.green(t('scanSilencePass'))); + } + continue; + } + if (action === 'select_streams' || action === 'adjust_sync' || action === 'edit_tags') { return { action, deepScanCompleted: dsCompleted, hasErrors: fileHasErrors }; } @@ -118,7 +146,14 @@ export async function handleExecutionMenu(options: { await runConversion(cmdToRun, options.totalDuration, options.totalFrames); if (action === 'run_and_scan' || action === 'run_repair_and_scan') { - await runDeepScan([actualOutputPath], ['0'], options.totalDuration); + const dsErrors = await runDeepScan([actualOutputPath], ['0'], options.totalDuration); + let silenceErrors = false; + + if (options.selectedAudioScanMaps && options.selectedAudioScanMaps.length > 0) { + silenceErrors = await runSilenceScan([actualOutputPath], ['0:a?']); + } + + fileHasErrors = fileHasErrors || dsErrors || silenceErrors; } const successMsg = options.isMerge ? t('successMerge') : t('successOp'); From 6b0fa93794cfb13e63b528fcc96ad7e1ee69fc24 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Wed, 27 May 2026 16:40:08 -0300 Subject: [PATCH 07/14] feat: add progress and track message on Silence Scan --- src/locales/en-US.ts | 2 ++ src/locales/pt-BR.ts | 2 ++ src/utils/ffmpeg.ts | 69 ++++++++++++++++++++++++++++++++++---------- src/utils/ui.ts | 8 ++--- 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 31f9941..63b1fc8 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -123,6 +123,8 @@ export default { scanSilenceWarn: '⚠ Warning: Audio drops detected!', scanSilenceDetail: 'Silence detected at {0} (duration: {1}s)', scanSilenceFail: '✖ Failed to execute Silence Scan.', + scanSilenceProgress: 'Silence Scan in progress: {0}%', + scanSilenceItem: '(duration: {0}s)', // Command: Lang langSelect: '🌐 Select your preferred language:', diff --git a/src/locales/pt-BR.ts b/src/locales/pt-BR.ts index f573779..e089ff2 100644 --- a/src/locales/pt-BR.ts +++ b/src/locales/pt-BR.ts @@ -123,6 +123,8 @@ export default { scanSilenceWarn: '⚠ Atenção: Quedas de áudio detectadas!', scanSilenceDetail: 'Silêncio detectado em {0} (duração: {1}s)', scanSilenceFail: '✖ Falha ao executar o Silence Scan.', + scanSilenceProgress: 'Silence Scan em andamento: {0}%', + scanSilenceItem: '(duração: {0}s)', // Comando: Lang langSelect: '🌐 Selecione o idioma de sua preferência / Select your preferred language:', diff --git a/src/utils/ffmpeg.ts b/src/utils/ffmpeg.ts index 2ce279f..1c13441 100644 --- a/src/utils/ffmpeg.ts +++ b/src/utils/ffmpeg.ts @@ -1,5 +1,5 @@ import { spawn } from 'child_process'; -import { log, spinner } from '@clack/prompts'; +import { spinner, note } from '@clack/prompts'; import pc from 'picocolors'; import { t } from './i18n.ts'; import { JellyError } from './errors.ts'; @@ -199,13 +199,13 @@ export async function runConversion(ffmpegCmd: string, totalDurationSec: number, * Executes a Silence Scan to detect accidentally muted tracks or dropped audio. * Uses a default threshold of -50dB and 2 seconds of minimum duration. */ -export async function runSilenceScan(inputs: string[], maps: string[]): Promise { +export async function runSilenceScan(inputs: string[], maps: string[], totalDurationSec: number): Promise { console.log(''); const scanSpinner = spinner(); scanSpinner.start(t('scanSilenceStart')); return new Promise((resolve) => { - const ffmpegArgs = ['-v', 'info']; + const ffmpegArgs = ['-v', 'info', '-stats']; inputs.forEach(inp => { ffmpegArgs.push('-i', inp); }); maps.forEach(m => { ffmpegArgs.push('-map', m); }); @@ -213,40 +213,77 @@ export async function runSilenceScan(inputs: string[], maps: string[]): Promise< const ff = spawn('ffmpeg', ffmpegArgs); let stderrBuffer = ''; - const results: Array<{ start: number, duration: number }> = []; - let currentStart: number | null = null; + + const results: Array<{ trackIdx: number, start: number, duration: number }> = []; + const currentStarts = new Map(); ff.stderr.on('data', (data) => { stderrBuffer += data.toString(); + + const timeMatch = stderrBuffer.match(/time=(\d{2}:\d{2}:\d{2}\.\d{2})/); + const matchTime = timeMatch?.[1]; + if (matchTime && totalDurationSec > 0) { + const currentTime = parseFfmpegTime(matchTime); + let percent = Math.round((currentTime / totalDurationSec) * 100); + if (percent > 100) percent = 100; + + const barLength = 25; + const filled = Math.round((percent / 100) * barLength); + const empty = barLength - filled; + const bar = '█'.repeat(filled) + '░'.repeat(empty); + + scanSpinner.message(`${t('scanSilenceProgress', percent)} [${pc.cyan(bar)}]`); + } + const lines = stderrBuffer.split(/[\r\n]+/); stderrBuffer = lines.pop() || ''; for (const line of lines) { - const startMatch = line.match(/silence_start:\s*([\d.]+)/); + const startMatch = line.match(/Parsed_silencedetect_(\d+).*silence_start:\s*([\d.]+)/); if (startMatch) { - currentStart = Number.parseFloat(startMatch[1]!); + const trackIdx = parseInt(startMatch[1]!, 10); + currentStarts.set(trackIdx, Number.parseFloat(startMatch[2]!)); } - const durationMatch = line.match(/silence_duration:\s*([\d.]+)/); - if (durationMatch && currentStart !== null) { - results.push({ start: currentStart, duration: Number.parseFloat(durationMatch[1]!) }); - currentStart = null; + const durationMatch = line.match(/Parsed_silencedetect_(\d+).*silence_duration:\s*([\d.]+)/); + if (durationMatch) { + const trackIdx = parseInt(durationMatch[1]!, 10); + const cStart = currentStarts.get(trackIdx); + if (cStart !== undefined) { + results.push({ trackIdx, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); + currentStarts.delete(trackIdx); + } } } }); ff.on('close', (code) => { if (code !== 0) { - scanSpinner.stop(pc.yellow(t('scanSilenceFail'))); + scanSpinner.stop(pc.red(t('scanSilenceFail'))); return resolve(true); } if (results.length > 0) { scanSpinner.stop(pc.yellow(t('scanSilenceWarn'))); - results.forEach(res => { - const timestamp = formatSecondsToTimestamp(res.start); - log.warn(pc.yellow(t('scanSilenceDetail', timestamp, res.duration.toFixed(2)))); - }); + + const grouped = new Map>(); + for (const r of results) { + if (!grouped.has(r.trackIdx)) grouped.set(r.trackIdx, []); + grouped.get(r.trackIdx)!.push(r); + } + + let msg = ''; + for (const [tIdx, items] of grouped.entries()) { + const mapName = maps[tIdx] || `Audio ${tIdx + 1}`; + + msg += `${msg ? '\n\n' : ''}${pc.bold(`🎧 ${t('trackNum', tIdx + 1)} (Map ${mapName})`)}`; + items.forEach(item => { + const durText = t('scanSilenceItem', item.duration.toFixed(2)); + msg += `\n • ${formatSecondsToTimestamp(item.start)} ${pc.dim(durText)}`; + }); + } + + note(msg); resolve(true); } else { scanSpinner.stop(pc.green(t('scanSilencePass'))); diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 8f46563..c8c0de7 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -95,7 +95,7 @@ export async function handleExecutionMenu(options: { const dsErrors = await runDeepScan(options.selectedScanInputs!, options.selectedScanMaps!, options.totalDuration); let silenceErrors = false; if (options.selectedAudioScanMaps && options.selectedAudioScanMaps.length > 0) { - silenceErrors = await runSilenceScan(options.selectedScanInputs!, options.selectedAudioScanMaps); + silenceErrors = await runSilenceScan(options.selectedScanInputs!, options.selectedAudioScanMaps, options.totalDuration); } fileHasErrors = fileHasErrors || dsErrors || silenceErrors; dsCompleted = true; @@ -106,7 +106,7 @@ export async function handleExecutionMenu(options: { const dsErrors = await runDeepScan(options.fullScanInputs, options.fullScanMaps, options.totalDuration); let silenceErrors = false; if (options.fullAudioScanMaps && options.fullAudioScanMaps.length > 0) { - silenceErrors = await runSilenceScan(options.fullScanInputs, options.fullAudioScanMaps); + silenceErrors = await runSilenceScan(options.fullScanInputs, options.fullAudioScanMaps, options.totalDuration); } fileHasErrors = fileHasErrors || dsErrors || silenceErrors; dsCompleted = true; @@ -118,7 +118,7 @@ export async function handleExecutionMenu(options: { const audioMaps = options.selectedAudioScanMaps || options.fullAudioScanMaps || []; if (audioMaps.length > 0) { - const silenceErrors = await runSilenceScan(inputs, audioMaps); + const silenceErrors = await runSilenceScan(inputs, audioMaps, options.totalDuration); fileHasErrors = fileHasErrors || silenceErrors; } else { const scanSpinner = spinner(); @@ -150,7 +150,7 @@ export async function handleExecutionMenu(options: { let silenceErrors = false; if (options.selectedAudioScanMaps && options.selectedAudioScanMaps.length > 0) { - silenceErrors = await runSilenceScan([actualOutputPath], ['0:a?']); + silenceErrors = await runSilenceScan([actualOutputPath], ['0:a?'], options.totalDuration); } fileHasErrors = fileHasErrors || dsErrors || silenceErrors; From 9a1452f603aeffb19532f83c6b4e0ab48660982b Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Wed, 27 May 2026 17:31:25 -0300 Subject: [PATCH 08/14] fix: use address-based tracking for silence detection --- src/utils/ffmpeg.ts | 59 ++++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/src/utils/ffmpeg.ts b/src/utils/ffmpeg.ts index 1c13441..3097287 100644 --- a/src/utils/ffmpeg.ts +++ b/src/utils/ffmpeg.ts @@ -52,12 +52,12 @@ export async function runDeepScan(inputs: string[], maps: string[], totalDuratio let hasErrors = false; return new Promise((resolve, reject) => { - // Montagem dinâmica dos argumentos para ler apenas o que importa + const results: Array<{ addr: string, start: number, duration: number }> = []; + const currentStarts = new Map(); const ffmpegArgs = ['-v', 'warning', '-stats']; inputs.forEach(inp => { ffmpegArgs.push('-i', inp); }); maps.forEach(m => { ffmpegArgs.push('-map', m); }); - - // O ESCUDO DEFINITIVO: Ignora qualquer legenda (-sn) e qualquer dado/fonte (-dn) + ffmpegArgs.push('-sn', '-dn', '-f', 'null', '-'); const ff = spawn('ffmpeg', ffmpegArgs); @@ -86,10 +86,19 @@ export async function runDeepScan(inputs: string[], maps: string[], totalDuratio stderrBuffer = lines.pop() || ''; for (const line of lines) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith('frame=') && !trimmed.startsWith('size=')) { - errorOutput += trimmed + '\n'; - hasErrors = true; + const startMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_start:\s*([\d.]+)/); + if (startMatch) { + const addr = startMatch[1]!; + currentStarts.set(addr, Number.parseFloat(startMatch[2]!)); + } + const durationMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_duration:\s*([\d.]+)/); + if (durationMatch) { + const addr = durationMatch[1]!; + const cStart = currentStarts.get(addr); + if (cStart !== undefined) { + results.push({ addr, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); + currentStarts.delete(addr); + } } } }); @@ -205,7 +214,7 @@ export async function runSilenceScan(inputs: string[], maps: string[], totalDura scanSpinner.start(t('scanSilenceStart')); return new Promise((resolve) => { - const ffmpegArgs = ['-v', 'info', '-stats']; + const ffmpegArgs = ['-v', 'info', '-stats']; inputs.forEach(inp => { ffmpegArgs.push('-i', inp); }); maps.forEach(m => { ffmpegArgs.push('-map', m); }); @@ -214,8 +223,8 @@ export async function runSilenceScan(inputs: string[], maps: string[], totalDura const ff = spawn('ffmpeg', ffmpegArgs); let stderrBuffer = ''; - const results: Array<{ trackIdx: number, start: number, duration: number }> = []; - const currentStarts = new Map(); + const results: Array<{ addr: string, start: number, duration: number }> = []; + const currentStarts = new Map(); ff.stderr.on('data', (data) => { stderrBuffer += data.toString(); @@ -239,19 +248,19 @@ export async function runSilenceScan(inputs: string[], maps: string[], totalDura stderrBuffer = lines.pop() || ''; for (const line of lines) { - const startMatch = line.match(/Parsed_silencedetect_(\d+).*silence_start:\s*([\d.]+)/); + const startMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_start:\s*([\d.]+)/); if (startMatch) { - const trackIdx = parseInt(startMatch[1]!, 10); - currentStarts.set(trackIdx, Number.parseFloat(startMatch[2]!)); + const addr = startMatch[1]!; + currentStarts.set(addr, Number.parseFloat(startMatch[2]!)); } - const durationMatch = line.match(/Parsed_silencedetect_(\d+).*silence_duration:\s*([\d.]+)/); + const durationMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_duration:\s*([\d.]+)/); if (durationMatch) { - const trackIdx = parseInt(durationMatch[1]!, 10); - const cStart = currentStarts.get(trackIdx); + const addr = durationMatch[1]!; + const cStart = currentStarts.get(addr); if (cStart !== undefined) { - results.push({ trackIdx, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); - currentStarts.delete(trackIdx); + results.push({ addr, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); + currentStarts.delete(addr); } } } @@ -266,21 +275,21 @@ export async function runSilenceScan(inputs: string[], maps: string[], totalDura if (results.length > 0) { scanSpinner.stop(pc.yellow(t('scanSilenceWarn'))); - const grouped = new Map>(); + const grouped = new Map>(); for (const r of results) { - if (!grouped.has(r.trackIdx)) grouped.set(r.trackIdx, []); - grouped.get(r.trackIdx)!.push(r); + if (!grouped.has(r.addr)) grouped.set(r.addr, []); + grouped.get(r.addr)!.push(r); } let msg = ''; - for (const [tIdx, items] of grouped.entries()) { - const mapName = maps[tIdx] || `Audio ${tIdx + 1}`; - - msg += `${msg ? '\n\n' : ''}${pc.bold(`🎧 ${t('trackNum', tIdx + 1)} (Map ${mapName})`)}`; + let trackCounter = 1; + for (const [addr, items] of grouped.entries()) { + msg += `${msg ? '\n\n' : ''}${pc.bold(`🎧 Trilha Afetada #${trackCounter}`)}`; items.forEach(item => { const durText = t('scanSilenceItem', item.duration.toFixed(2)); msg += `\n • ${formatSecondsToTimestamp(item.start)} ${pc.dim(durText)}`; }); + trackCounter++; } note(msg); From 7c81e9302b9b5852231f54e54e9d30d9545b0808 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Wed, 27 May 2026 17:37:45 -0300 Subject: [PATCH 09/14] feat: add audio track labels to silence scan --- src/commands/check.ts | 8 +++++++- src/commands/merge.ts | 15 ++++++++++++++- src/utils/ffmpeg.ts | 39 ++++++++++++++++++++++++++++----------- src/utils/ui.ts | 13 ++++++++----- 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/commands/check.ts b/src/commands/check.ts index 17d6239..518beae 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -36,9 +36,11 @@ interface CheckLoopContext { fullScanInputs: string[]; fullScanMaps: string[]; fullAudioScanMaps: string[]; + fullAudioScanLabels: string[]; selectedScanInputs: string[]; selectedScanMaps: string[]; selectedAudioScanMaps: string[]; + selectedAudioScanLabels: string[]; } export async function checkCommand(args: string[]) { @@ -72,9 +74,11 @@ export async function checkCommand(args: string[]) { fullScanInputs: context.fullScanInputs, fullScanMaps: context.fullScanMaps, fullAudioScanMaps: context.fullAudioScanMaps, + fullAudioScanLabels: context.fullAudioScanLabels, selectedScanInputs: context.selectedScanInputs, selectedScanMaps: context.selectedScanMaps, selectedAudioScanMaps: context.selectedAudioScanMaps, + selectedAudioScanLabels: context.selectedAudioScanLabels, outputPath, totalDuration: context.totalDuration, totalFrames: context.totalFrames, @@ -215,9 +219,11 @@ function buildLoopContext( fullScanInputs: [targetFile], fullScanMaps: ['0'], fullAudioScanMaps: probeData.streams.filter(s => s.codec_type === 'audio').map(s => `0:${s.index}`), + fullAudioScanLabels: probeData.streams.filter(s => s.codec_type === 'audio').map(s => (s.tags?.language || 'und').toUpperCase()), selectedScanInputs: [targetFile], selectedScanMaps: selectedStreams.map((stream) => `0:${stream.streamIndex}`), - selectedAudioScanMaps: selectedStreams.filter(s => s.type === 'audio').map(s => `0:${s.streamIndex}`) + selectedAudioScanMaps: selectedStreams.filter(s => s.type === 'audio').map(s => `0:${s.streamIndex}`), + selectedAudioScanLabels: selectedStreams.filter(s => s.type === 'audio').map(s => (s.language || 'und').toUpperCase()) }; } diff --git a/src/commands/merge.ts b/src/commands/merge.ts index da73fec..1f24b08 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -57,7 +57,9 @@ interface MergeLoopContext { fullScanInputs: string[]; fullScanMaps: string[]; fullAudioScanMaps: string[]; + fullAudioScanLabels: string[]; selectedAudioScanMaps: string[]; + selectedAudioScanLabels: string[]; } export async function mergeCommand(_args: string[]) { @@ -90,7 +92,9 @@ export async function mergeCommand(_args: string[]) { fullScanInputs: context.fullScanInputs, fullScanMaps: context.fullScanMaps, fullAudioScanMaps: context.fullAudioScanMaps, + fullAudioScanLabels: context.fullAudioScanLabels, selectedAudioScanMaps: context.selectedAudioScanMaps, + selectedAudioScanLabels: context.selectedAudioScanLabels, outputPath: media.outputPath, totalDuration: context.totalDuration, totalFrames: context.totalFrames, @@ -348,7 +352,9 @@ function buildLoopContext(media: MergeMediaContext, state: MergeSessionState): M fullScanInputs: [media.sourcePathA, media.sourcePathB], fullScanMaps: buildFullScanMaps(media), fullAudioScanMaps: buildFullAudioScanMaps(media), - selectedAudioScanMaps: state.selectedStreams.filter(s => s.type === 'audio').map(s => `${s.fileIndex}:${s.streamIndex}`) + fullAudioScanLabels: buildFullAudioScanLabels(media), + selectedAudioScanMaps: state.selectedStreams.filter(s => s.type === 'audio').map(s => `${s.fileIndex}:${s.streamIndex}`), + selectedAudioScanLabels: state.selectedStreams.filter(s => s.type === 'audio').map(s => (s.language || 'und').toUpperCase()) }; } @@ -366,6 +372,13 @@ function buildFullAudioScanMaps(media: MergeMediaContext) { ]; } +function buildFullAudioScanLabels(media: MergeMediaContext) { + return [ + ...media.infoA.streams.filter((stream) => stream.codec_type === 'audio').map((stream) => (stream.tags?.language || 'und').toUpperCase()), + ...media.infoB.streams.filter((stream) => stream.codec_type === 'audio').map((stream) => (stream.tags?.language || 'und').toUpperCase()) + ]; +} + function buildSourceScanMaps(probeData: FFprobeData, fileIndex: number) { return probeData.streams .filter((stream) => stream.codec_type === 'video' || stream.codec_type === 'audio') diff --git a/src/utils/ffmpeg.ts b/src/utils/ffmpeg.ts index 3097287..2c97525 100644 --- a/src/utils/ffmpeg.ts +++ b/src/utils/ffmpeg.ts @@ -208,7 +208,12 @@ export async function runConversion(ffmpegCmd: string, totalDurationSec: number, * Executes a Silence Scan to detect accidentally muted tracks or dropped audio. * Uses a default threshold of -50dB and 2 seconds of minimum duration. */ -export async function runSilenceScan(inputs: string[], maps: string[], totalDurationSec: number): Promise { +export async function runSilenceScan( + inputs: string[], + maps: string[], + totalDurationSec: number, + trackLabels?: string[] +): Promise { console.log(''); const scanSpinner = spinner(); scanSpinner.start(t('scanSilenceStart')); @@ -222,8 +227,11 @@ export async function runSilenceScan(inputs: string[], maps: string[], totalDura const ff = spawn('ffmpeg', ffmpegArgs); let stderrBuffer = ''; + + const results: Array<{ trackIdx: number, start: number, duration: number }> = []; - const results: Array<{ addr: string, start: number, duration: number }> = []; + let nextTrackIdx = 0; + const addressToTrackIdx = new Map(); const currentStarts = new Map(); ff.stderr.on('data', (data) => { @@ -251,6 +259,11 @@ export async function runSilenceScan(inputs: string[], maps: string[], totalDura const startMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_start:\s*([\d.]+)/); if (startMatch) { const addr = startMatch[1]!; + + if (!addressToTrackIdx.has(addr)) { + addressToTrackIdx.set(addr, nextTrackIdx++); + } + currentStarts.set(addr, Number.parseFloat(startMatch[2]!)); } @@ -258,8 +271,10 @@ export async function runSilenceScan(inputs: string[], maps: string[], totalDura if (durationMatch) { const addr = durationMatch[1]!; const cStart = currentStarts.get(addr); - if (cStart !== undefined) { - results.push({ addr, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); + const trackIdx = addressToTrackIdx.get(addr); + + if (cStart !== undefined && trackIdx !== undefined) { + results.push({ trackIdx, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); currentStarts.delete(addr); } } @@ -275,21 +290,23 @@ export async function runSilenceScan(inputs: string[], maps: string[], totalDura if (results.length > 0) { scanSpinner.stop(pc.yellow(t('scanSilenceWarn'))); - const grouped = new Map>(); + const grouped = new Map>(); for (const r of results) { - if (!grouped.has(r.addr)) grouped.set(r.addr, []); - grouped.get(r.addr)!.push(r); + if (!grouped.has(r.trackIdx)) grouped.set(r.trackIdx, []); + grouped.get(r.trackIdx)!.push(r); } let msg = ''; - let trackCounter = 1; - for (const [addr, items] of grouped.entries()) { - msg += `${msg ? '\n\n' : ''}${pc.bold(`🎧 Trilha Afetada #${trackCounter}`)}`; + const sortedGroups = Array.from(grouped.entries()).sort((a, b) => a[0] - b[0]); + + for (const [tIdx, items] of sortedGroups) { + const langLabel = trackLabels && trackLabels[tIdx] ? ` [${trackLabels[tIdx]}]` : ''; + msg += `${msg ? '\n\n' : ''}${pc.bold(`🎧 Trilha Afetada #${tIdx + 1}${langLabel}`)}`; + items.forEach(item => { const durText = t('scanSilenceItem', item.duration.toFixed(2)); msg += `\n • ${formatSecondsToTimestamp(item.start)} ${pc.dim(durText)}`; }); - trackCounter++; } note(msg); diff --git a/src/utils/ui.ts b/src/utils/ui.ts index c8c0de7..fc269c3 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -27,9 +27,11 @@ export async function handleExecutionMenu(options: { fullScanInputs: string[]; fullScanMaps: string[]; fullAudioScanMaps?: string[]; + fullAudioScanLabels?: string[]; selectedScanInputs?: string[]; selectedScanMaps?: string[]; selectedAudioScanMaps?: string[]; + selectedAudioScanLabels?: string[]; outputPath: string; totalDuration: number; totalFrames: number; @@ -95,7 +97,7 @@ export async function handleExecutionMenu(options: { const dsErrors = await runDeepScan(options.selectedScanInputs!, options.selectedScanMaps!, options.totalDuration); let silenceErrors = false; if (options.selectedAudioScanMaps && options.selectedAudioScanMaps.length > 0) { - silenceErrors = await runSilenceScan(options.selectedScanInputs!, options.selectedAudioScanMaps, options.totalDuration); + silenceErrors = await runSilenceScan(options.selectedScanInputs!, options.selectedAudioScanMaps, options.totalDuration, options.selectedAudioScanLabels); } fileHasErrors = fileHasErrors || dsErrors || silenceErrors; dsCompleted = true; @@ -106,7 +108,7 @@ export async function handleExecutionMenu(options: { const dsErrors = await runDeepScan(options.fullScanInputs, options.fullScanMaps, options.totalDuration); let silenceErrors = false; if (options.fullAudioScanMaps && options.fullAudioScanMaps.length > 0) { - silenceErrors = await runSilenceScan(options.fullScanInputs, options.fullAudioScanMaps, options.totalDuration); + silenceErrors = await runSilenceScan(options.fullScanInputs, options.fullAudioScanMaps, options.totalDuration, options.fullAudioScanLabels); } fileHasErrors = fileHasErrors || dsErrors || silenceErrors; dsCompleted = true; @@ -116,9 +118,10 @@ export async function handleExecutionMenu(options: { if (action === 'silence_scan') { const inputs = options.selectedScanInputs || options.fullScanInputs; const audioMaps = options.selectedAudioScanMaps || options.fullAudioScanMaps || []; - + const audioLabels = options.selectedAudioScanLabels || options.fullAudioScanLabels || []; + if (audioMaps.length > 0) { - const silenceErrors = await runSilenceScan(inputs, audioMaps, options.totalDuration); + const silenceErrors = await runSilenceScan(inputs, audioMaps, options.totalDuration, audioLabels); fileHasErrors = fileHasErrors || silenceErrors; } else { const scanSpinner = spinner(); @@ -150,7 +153,7 @@ export async function handleExecutionMenu(options: { let silenceErrors = false; if (options.selectedAudioScanMaps && options.selectedAudioScanMaps.length > 0) { - silenceErrors = await runSilenceScan([actualOutputPath], ['0:a?'], options.totalDuration); + silenceErrors = await runSilenceScan([actualOutputPath], ['0:a?'], options.totalDuration, options.selectedAudioScanLabels); } fileHasErrors = fileHasErrors || dsErrors || silenceErrors; From 3f4b4b60c90e8e305c7d6bfab2092f1b8f1bf55d Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 8 Jun 2026 21:42:44 -0300 Subject: [PATCH 10/14] refactor: remove silence scan from deepscan --- src/utils/ui.ts | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/utils/ui.ts b/src/utils/ui.ts index fc269c3..9a8f307 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -95,22 +95,14 @@ export async function handleExecutionMenu(options: { if (action === 'deep_scan_selected') { const dsErrors = await runDeepScan(options.selectedScanInputs!, options.selectedScanMaps!, options.totalDuration); - let silenceErrors = false; - if (options.selectedAudioScanMaps && options.selectedAudioScanMaps.length > 0) { - silenceErrors = await runSilenceScan(options.selectedScanInputs!, options.selectedAudioScanMaps, options.totalDuration, options.selectedAudioScanLabels); - } - fileHasErrors = fileHasErrors || dsErrors || silenceErrors; + fileHasErrors = fileHasErrors || dsErrors; dsCompleted = true; continue; - } + } if (action === 'deep_scan_full') { const dsErrors = await runDeepScan(options.fullScanInputs, options.fullScanMaps, options.totalDuration); - let silenceErrors = false; - if (options.fullAudioScanMaps && options.fullAudioScanMaps.length > 0) { - silenceErrors = await runSilenceScan(options.fullScanInputs, options.fullAudioScanMaps, options.totalDuration, options.fullAudioScanLabels); - } - fileHasErrors = fileHasErrors || dsErrors || silenceErrors; + fileHasErrors = fileHasErrors || dsErrors; dsCompleted = true; continue; } @@ -150,13 +142,7 @@ export async function handleExecutionMenu(options: { if (action === 'run_and_scan' || action === 'run_repair_and_scan') { const dsErrors = await runDeepScan([actualOutputPath], ['0'], options.totalDuration); - let silenceErrors = false; - - if (options.selectedAudioScanMaps && options.selectedAudioScanMaps.length > 0) { - silenceErrors = await runSilenceScan([actualOutputPath], ['0:a?'], options.totalDuration, options.selectedAudioScanLabels); - } - - fileHasErrors = fileHasErrors || dsErrors || silenceErrors; + fileHasErrors = fileHasErrors || dsErrors; } const successMsg = options.isMerge ? t('successMerge') : t('successOp'); From 5456c99b1f9fffbece0114e5ba5f493cf704d35d Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 8 Jun 2026 22:39:32 -0300 Subject: [PATCH 11/14] refactor(utils): centralize ffmpeg parsing and audio stream mapping --- src/commands/check.ts | 10 +- src/commands/merge.ts | 22 ++-- src/utils/ffmpeg.ts | 250 +++++++++++++++++++--------------------- src/utils/mediaUtils.ts | 14 ++- 4 files changed, 148 insertions(+), 148 deletions(-) diff --git a/src/commands/check.ts b/src/commands/check.ts index 518beae..02b9617 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -13,7 +13,7 @@ import { buildCheckCommand } from '../utils/builder.ts'; import { ValidationError } from '../utils/errors.ts'; import { getMediaInfo, runQuickScan } from '../utils/ffprobe.ts'; import { t } from '../utils/i18n.ts'; -import { filterGarbageStreams } from '../utils/mediaUtils.ts'; +import { filterGarbageStreams, buildAudioMaps, buildAudioLabels, buildSelectedAudioMaps, buildSelectedAudioLabels } from '../utils/mediaUtils.ts'; import { editTagsMenu, handleExecutionMenu, onCancel, sanitizePath } from '../utils/ui.ts'; import { renderActionPlan, renderMatrix } from '../views/checkView.ts'; import { buildGroupedOptions } from '../views/streamOptions.ts'; @@ -218,12 +218,12 @@ function buildLoopContext( totalFrames: diagnostic.metadata.totalFrames, fullScanInputs: [targetFile], fullScanMaps: ['0'], - fullAudioScanMaps: probeData.streams.filter(s => s.codec_type === 'audio').map(s => `0:${s.index}`), - fullAudioScanLabels: probeData.streams.filter(s => s.codec_type === 'audio').map(s => (s.tags?.language || 'und').toUpperCase()), + fullAudioScanMaps: buildAudioMaps(probeData.streams, 0), + fullAudioScanLabels: buildAudioLabels(probeData.streams), selectedScanInputs: [targetFile], selectedScanMaps: selectedStreams.map((stream) => `0:${stream.streamIndex}`), - selectedAudioScanMaps: selectedStreams.filter(s => s.type === 'audio').map(s => `0:${s.streamIndex}`), - selectedAudioScanLabels: selectedStreams.filter(s => s.type === 'audio').map(s => (s.language || 'und').toUpperCase()) + selectedAudioScanMaps: buildSelectedAudioMaps(selectedStreams), + selectedAudioScanLabels: buildSelectedAudioLabels(selectedStreams) }; } diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 1f24b08..70fda13 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -18,8 +18,11 @@ import { buildSyncOptions, renderComparison } from '../views/mergeView.ts'; import { buildGroupedOptions } from '../views/streamOptions.ts'; import { calculateSpectrumDelay } from '../services/spectrumAnalyzer.ts'; import { extractRawAudio } from '../utils/ffmpeg.ts'; +import { buildAudioMaps, buildAudioLabels, buildSelectedAudioMaps, buildSelectedAudioLabels } from '../utils/mediaUtils.ts'; const fallbackRules = fallbackRulesData as FallbackRules; +const SPECTRUM_SAMPLE_WINDOW_SEC = 10; +const SPECTRUM_SEARCH_WINDOW_SEC = 30; interface MergeSourcePaths { sourcePathA: string; @@ -257,12 +260,11 @@ async function promptSyncAdjustment( } })); - // 10s of sample window to 30s search window - const durA = 10; - const durB = 30; + const durA = SPECTRUM_SAMPLE_WINDOW_SEC; + const durB = SPECTRUM_SEARCH_WINDOW_SEC; const tsSecs = parseTimestampToSeconds(tsStr); - const tsBSecs = Math.max(0, tsSecs - 10); // Starts 10s before the timestamp + const tsBSecs = Math.max(0, tsSecs - SPECTRUM_SAMPLE_WINDOW_SEC); // Starts before the timestamp const startTsB = formatSecondsToTimestamp(tsBSecs); const s = spinner(); @@ -353,8 +355,8 @@ function buildLoopContext(media: MergeMediaContext, state: MergeSessionState): M fullScanMaps: buildFullScanMaps(media), fullAudioScanMaps: buildFullAudioScanMaps(media), fullAudioScanLabels: buildFullAudioScanLabels(media), - selectedAudioScanMaps: state.selectedStreams.filter(s => s.type === 'audio').map(s => `${s.fileIndex}:${s.streamIndex}`), - selectedAudioScanLabels: state.selectedStreams.filter(s => s.type === 'audio').map(s => (s.language || 'und').toUpperCase()) + selectedAudioScanMaps: buildSelectedAudioMaps(state.selectedStreams), + selectedAudioScanLabels: buildSelectedAudioLabels(state.selectedStreams) }; } @@ -367,15 +369,15 @@ function buildFullScanMaps(media: MergeMediaContext) { function buildFullAudioScanMaps(media: MergeMediaContext) { return [ - ...media.infoA.streams.filter((stream) => stream.codec_type === 'audio').map((stream) => `0:${stream.index}`), - ...media.infoB.streams.filter((stream) => stream.codec_type === 'audio').map((stream) => `1:${stream.index}`) + ...buildAudioMaps(media.infoA.streams, 0), + ...buildAudioMaps(media.infoB.streams, 1) ]; } function buildFullAudioScanLabels(media: MergeMediaContext) { return [ - ...media.infoA.streams.filter((stream) => stream.codec_type === 'audio').map((stream) => (stream.tags?.language || 'und').toUpperCase()), - ...media.infoB.streams.filter((stream) => stream.codec_type === 'audio').map((stream) => (stream.tags?.language || 'und').toUpperCase()) + ...buildAudioLabels(media.infoA.streams), + ...buildAudioLabels(media.infoB.streams) ]; } diff --git a/src/utils/ffmpeg.ts b/src/utils/ffmpeg.ts index 2c97525..4608ccd 100644 --- a/src/utils/ffmpeg.ts +++ b/src/utils/ffmpeg.ts @@ -6,6 +6,21 @@ import { JellyError } from './errors.ts'; import type { MediaStream } from '../types/media'; import { formatSecondsToTimestamp } from './formatters.ts'; +export const DEFAULT_SILENCE_NOISE_DB = '-50dB'; +export const DEFAULT_SILENCE_DURATION = 2; + +export interface SilenceScanResult { + trackIdx: number; + start: number; + duration: number; +} + +export interface DeepScanResult { + addr: string; + start: number; + duration: number; +} + export function parseFfmpegTime(timeStr: string) { const parts = timeStr.split(':'); if (parts.length !== 3) return 0; @@ -44,6 +59,48 @@ export function getDynamicAudioEncoder( return `-c:a:${outputIndex} ${targetCodec} -b:a:${outputIndex} ${targetBitrate}k`; } +// Helpers centralizados de parse e barra de progresso (DRY) +function generateProgressBar(percent: number, length: number = 25): string { + const filled = Math.round((percent / 100) * length); + const empty = length - filled; + return '█'.repeat(filled) + '░'.repeat(empty); +} + +function createStderrParser( + totalDurationSec: number, + totalFrames: number, + onProgress: (percent: number) => void, + onLine: (line: string) => void +) { + const state = { buffer: '' }; + + const parse = (data: Buffer | string) => { + state.buffer += data.toString(); + + const timeMatch = state.buffer.match(/time=\s*(\d{2}:\d{2}:\d{2}[\.\d]*)/); + if (timeMatch?.[1] && totalDurationSec > 0) { + const currentTime = parseFfmpegTime(timeMatch[1]); + onProgress(Math.min(100, Math.round((currentTime / totalDurationSec) * 100))); + } else if (totalFrames > 0) { + const frameMatch = state.buffer.match(/frame=\s*(\d+)/); + if (frameMatch?.[1]) { + const currentFrame = Number.parseInt(frameMatch[1], 10); + onProgress(Math.min(100, Math.round((currentFrame / totalFrames) * 100))); + } + } + + const lines = state.buffer.split(/[\r\n]+/); + state.buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) onLine(trimmed); + } + }; + + return { parse, state }; +} + export async function runDeepScan(inputs: string[], maps: string[], totalDurationSec: number): Promise { console.log(''); const dsSpinner = spinner(); @@ -52,64 +109,46 @@ export async function runDeepScan(inputs: string[], maps: string[], totalDuratio let hasErrors = false; return new Promise((resolve, reject) => { - const results: Array<{ addr: string, start: number, duration: number }> = []; + const results: DeepScanResult[] = []; const currentStarts = new Map(); const ffmpegArgs = ['-v', 'warning', '-stats']; + inputs.forEach(inp => { ffmpegArgs.push('-i', inp); }); maps.forEach(m => { ffmpegArgs.push('-map', m); }); - ffmpegArgs.push('-sn', '-dn', '-f', 'null', '-'); const ff = spawn('ffmpeg', ffmpegArgs); let errorOutput = ''; - let stderrBuffer = ''; - - ff.stderr.on('data', (data) => { - stderrBuffer += data.toString(); - const timeMatch = stderrBuffer.match(/time=(\d{2}:\d{2}:\d{2}\.\d{2})/); - const matchTime = timeMatch?.[1]; - if (matchTime && totalDurationSec > 0) { - const currentTime = parseFfmpegTime(matchTime); - let percent = Math.round((currentTime / totalDurationSec) * 100); - if (percent > 100) percent = 100; - - const barLength = 25; - const filled = Math.round((percent / 100) * barLength); - const empty = barLength - filled; - const bar = '█'.repeat(filled) + '░'.repeat(empty); + const onProgress = (percent: number) => { + dsSpinner.message(`${t('scanDeepProgress', percent)} [${pc.cyan(generateProgressBar(percent))}]`); + }; - dsSpinner.message(`${t('scanDeepProgress', percent)} [${pc.cyan(bar)}]`); + const onLine = (line: string) => { + const startMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_start:\s*([\d.]+)/); + if (startMatch) { + currentStarts.set(startMatch[1]!, Number.parseFloat(startMatch[2]!)); } - - const lines = stderrBuffer.split(/[\r\n]+/); - stderrBuffer = lines.pop() || ''; - - for (const line of lines) { - const startMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_start:\s*([\d.]+)/); - if (startMatch) { - const addr = startMatch[1]!; - currentStarts.set(addr, Number.parseFloat(startMatch[2]!)); - } - const durationMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_duration:\s*([\d.]+)/); - if (durationMatch) { - const addr = durationMatch[1]!; - const cStart = currentStarts.get(addr); - if (cStart !== undefined) { - results.push({ addr, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); - currentStarts.delete(addr); - } + + const durationMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_duration:\s*([\d.]+)/); + if (durationMatch) { + const addr = durationMatch[1]!; + const cStart = currentStarts.get(addr); + if (cStart !== undefined) { + results.push({ addr, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); + currentStarts.delete(addr); } } - }); + }; + + const { parse, state } = createStderrParser(totalDurationSec, 0, onProgress, onLine); + ff.stderr.on('data', parse); ff.on('close', (code) => { - if (stderrBuffer.trim()) { - const trimmed = stderrBuffer.trim(); - if (!trimmed.startsWith('frame=') && !trimmed.startsWith('size=')) { - errorOutput += trimmed + '\n'; - hasErrors = true; - } + const remainingBuffer = state.buffer.trim(); + if (remainingBuffer && !remainingBuffer.startsWith('frame=') && !remainingBuffer.startsWith('size=')) { + errorOutput += remainingBuffer + '\n'; + hasErrors = true; } if (errorOutput.trim()) { @@ -143,49 +182,19 @@ export async function runConversion(ffmpegCmd: string, totalDurationSec: number, let tailLog: string[] = []; let lastBar = '[░░░░░░░░░░░░░░░░░░░░░░░░░] 0%'; - let stderrBuffer = ''; - - ff.stderr.on('data', (data) => { - stderrBuffer += data.toString(); - const lines = stderrBuffer.split(/[\r\n]+/); - stderrBuffer = lines.pop() || ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - tailLog.push(trimmed); - if (tailLog.length > 10) { - tailLog.shift(); - } - const timeMatch = trimmed.match(/time=\s*(\d{2}:\d{2}:\d{2}[\.\d]*)/); - const frameMatch = trimmed.match(/frame=\s*(\d+)/); + const onProgress = (percent: number) => { + lastBar = `[${pc.cyan(generateProgressBar(percent))}] ${percent}%`; + }; - let percent = -1; - - const matchTime = timeMatch?.[1]; - const matchFrame = frameMatch?.[1]; - - if (matchTime && totalDurationSec > 0) { - const currentTime = parseFfmpegTime(matchTime); - percent = Math.round((currentTime / totalDurationSec) * 100); - } else if (matchFrame && totalFrames > 0) { - const currentFrame = Number.parseInt(matchFrame, 10); - percent = Math.round((currentFrame / totalFrames) * 100); - } - - if (percent >= 0) { - if (percent > 100) percent = 100; - const barLength = 25; - const filled = Math.round((percent / 100) * barLength); - const empty = barLength - filled; - lastBar = `[${pc.cyan('█'.repeat(filled) + '░'.repeat(empty))}] ${percent}%`; - } + const onLine = (line: string) => { + tailLog.push(line); + if (tailLog.length > 10) tailLog.shift(); + convSpinner.message(`${t('convProgress')}\n${lastBar}\n\n${pc.dim(tailLog.join('\n'))}`); + }; - convSpinner.message(`${t('convProgress')}\n${lastBar}\n\n${pc.dim(tailLog.join('\n'))}`); - } - }); + const { parse } = createStderrParser(totalDurationSec, totalFrames, onProgress, onLine); + ff.stderr.on('data', parse); ff.on('close', (code) => { if (code === 0) { @@ -204,10 +213,6 @@ export async function runConversion(ffmpegCmd: string, totalDurationSec: number, }); } -/** - * Executes a Silence Scan to detect accidentally muted tracks or dropped audio. - * Uses a default threshold of -50dB and 2 seconds of minimum duration. - */ export async function runSilenceScan( inputs: string[], maps: string[], @@ -223,63 +228,44 @@ export async function runSilenceScan( inputs.forEach(inp => { ffmpegArgs.push('-i', inp); }); maps.forEach(m => { ffmpegArgs.push('-map', m); }); - ffmpegArgs.push('-vn', '-af', 'silencedetect=noise=-50dB:d=2', '-f', 'null', '-'); + ffmpegArgs.push('-vn', '-af', `silencedetect=noise=${DEFAULT_SILENCE_NOISE_DB}:d=${DEFAULT_SILENCE_DURATION}`, '-f', 'null', '-'); const ff = spawn('ffmpeg', ffmpegArgs); - let stderrBuffer = ''; - - const results: Array<{ trackIdx: number, start: number, duration: number }> = []; + const results: SilenceScanResult[] = []; let nextTrackIdx = 0; const addressToTrackIdx = new Map(); const currentStarts = new Map(); - ff.stderr.on('data', (data) => { - stderrBuffer += data.toString(); + const onProgress = (percent: number) => { + scanSpinner.message(`${t('scanSilenceProgress', percent)} [${pc.cyan(generateProgressBar(percent))}]`); + }; - const timeMatch = stderrBuffer.match(/time=(\d{2}:\d{2}:\d{2}\.\d{2})/); - const matchTime = timeMatch?.[1]; - if (matchTime && totalDurationSec > 0) { - const currentTime = parseFfmpegTime(matchTime); - let percent = Math.round((currentTime / totalDurationSec) * 100); - if (percent > 100) percent = 100; - - const barLength = 25; - const filled = Math.round((percent / 100) * barLength); - const empty = barLength - filled; - const bar = '█'.repeat(filled) + '░'.repeat(empty); - - scanSpinner.message(`${t('scanSilenceProgress', percent)} [${pc.cyan(bar)}]`); - } - - const lines = stderrBuffer.split(/[\r\n]+/); - stderrBuffer = lines.pop() || ''; - - for (const line of lines) { - const startMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_start:\s*([\d.]+)/); - if (startMatch) { - const addr = startMatch[1]!; - - if (!addressToTrackIdx.has(addr)) { - addressToTrackIdx.set(addr, nextTrackIdx++); - } - - currentStarts.set(addr, Number.parseFloat(startMatch[2]!)); + const onLine = (line: string) => { + const startMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_start:\s*([\d.]+)/); + if (startMatch) { + const addr = startMatch[1]!; + if (!addressToTrackIdx.has(addr)) { + addressToTrackIdx.set(addr, nextTrackIdx++); } + currentStarts.set(addr, Number.parseFloat(startMatch[2]!)); + } - const durationMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_duration:\s*([\d.]+)/); - if (durationMatch) { - const addr = durationMatch[1]!; - const cStart = currentStarts.get(addr); - const trackIdx = addressToTrackIdx.get(addr); - - if (cStart !== undefined && trackIdx !== undefined) { - results.push({ trackIdx, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); - currentStarts.delete(addr); - } + const durationMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_duration:\s*([\d.]+)/); + if (durationMatch) { + const addr = durationMatch[1]!; + const cStart = currentStarts.get(addr); + const trackIdx = addressToTrackIdx.get(addr); + + if (cStart !== undefined && trackIdx !== undefined) { + results.push({ trackIdx, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); + currentStarts.delete(addr); } } - }); + }; + + const { parse } = createStderrParser(totalDurationSec, 0, onProgress, onLine); + ff.stderr.on('data', parse); ff.on('close', (code) => { if (code !== 0) { diff --git a/src/utils/mediaUtils.ts b/src/utils/mediaUtils.ts index 1813010..2052e71 100644 --- a/src/utils/mediaUtils.ts +++ b/src/utils/mediaUtils.ts @@ -1,4 +1,4 @@ -import type { MediaStream } from '../types/media'; +import type { MediaStream, SelectedStream } from '../types/media'; const IMAGE_SUBTITLE_CODECS = new Set(['hdmv_pgs_subtitle', 'pgs', 'dvd_subtitle', 'vobsub']); const ATTACHED_PIC_CODECS = new Set(['mjpeg', 'png', 'bmp']); @@ -23,3 +23,15 @@ export const hasEmbeddedGarbage = (streams: MediaStream[]): boolean => streams.s export const filterGarbageStreams = (streams: MediaStream[]): MediaStream[] => streams.filter((stream) => !isGarbageStream(stream)); + +export const buildAudioMaps = (streams: MediaStream[], fileIndex: number = 0) => + streams.filter(s => s.codec_type === 'audio').map(s => `${fileIndex}:${s.index}`); + +export const buildAudioLabels = (streams: MediaStream[]) => + streams.filter(s => s.codec_type === 'audio').map(s => (s.tags?.language || 'und').toUpperCase()); + +export const buildSelectedAudioMaps = (streams: SelectedStream[]) => + streams.filter(s => s.type === 'audio').map(s => `${s.fileIndex ?? 0}:${s.streamIndex}`); + +export const buildSelectedAudioLabels = (streams: SelectedStream[]) => + streams.filter(s => s.type === 'audio').map(s => (s.language || 'und').toUpperCase()); \ No newline at end of file From ce8eb8bbc777186100944e8c96bf2795220a54af Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 15 Jun 2026 16:16:16 -0300 Subject: [PATCH 12/14] test: add silence scan coverage --- src/utils/ffmpeg.test.ts | 163 +++++++++++++++++++++++++++++++- src/utils/mediaUtils.test.ts | 31 +++++- src/utils/ui.test.ts | 44 ++++++++- tests/e2e/check.test.ts | 92 +++++++++--------- tests/helpers/setup-fixtures.ts | 15 +++ 5 files changed, 293 insertions(+), 52 deletions(-) diff --git a/src/utils/ffmpeg.test.ts b/src/utils/ffmpeg.test.ts index 76f0cb1..f05f3b6 100644 --- a/src/utils/ffmpeg.test.ts +++ b/src/utils/ffmpeg.test.ts @@ -7,7 +7,8 @@ import { getDynamicAudioEncoder, runDeepScan, runConversion, - extractRawAudio + extractRawAudio, + runSilenceScan, } from './ffmpeg.ts'; import { JellyError } from './errors.ts'; @@ -15,8 +16,9 @@ mock.module('@clack/prompts', () => ({ spinner: () => ({ start: mock(), stop: mock(), - message: mock() - }) + message: mock(), + }), + note: mock() })); describe('utils/ffmpeg.ts', () => { @@ -26,6 +28,7 @@ describe('utils/ffmpeg.ts', () => { spawnSpy.mockClear(); }); + // Utility Functions test('parseFfmpegTime should convert time strings correctly', () => { expect({ standard: parseFfmpegTime('01:30:15.50'), @@ -72,6 +75,7 @@ describe('utils/ffmpeg.ts', () => { }); }); + // Deep Scan test('runDeepScan should process streams and resolve correctly', async () => { const mockProcess = new EventEmitter() as any; mockProcess.stderr = new EventEmitter(); @@ -122,6 +126,35 @@ describe('utils/ffmpeg.ts', () => { }); }); + test('runDeepScan should flag errors if remaining buffer contains actual errors (not frame/size) even on exit 0', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runDeepScan(['in.mkv'], ['0:v'], 100); + + mockProcess.stderr.emit('data', Buffer.from('Some critical decoding error without newline')); + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(true); + }); + + test('runDeepScan should ignore remaining buffer if it starts with frame= or size=', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runDeepScan(['in.mkv'], ['0:v'], 100); + + mockProcess.stderr.emit('data', Buffer.from('size=2048kB time=00:02:00.00')); + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(false); + }); + + // Conversion test('runConversion should track progress and resolve on success', async () => { const mockProcess = new EventEmitter() as any; mockProcess.stderr = new EventEmitter(); @@ -136,6 +169,37 @@ describe('utils/ffmpeg.ts', () => { await expect(convPromise).resolves.toBeUndefined(); }); + test('runConversion should handle commands with "-y" and truncate long logs (>10 lines)', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const convPromise = runConversion('ffmpeg -y -i test.mkv', 100); + + for (let i = 1; i <= 12; i++) { + mockProcess.stderr.emit('data', Buffer.from(`Log line ${i}\n`)); + } + + mockProcess.emit('close', 0); + await expect(convPromise).resolves.toBeUndefined(); + }); + + test('runConversion should track progress using totalFrames if totalDurationSec is 0 or missing', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const convPromise = runConversion('ffmpeg -i test.mkv', 0, 100); + + mockProcess.stderr.emit('data', Buffer.from('frame= ')); + mockProcess.stderr.emit('data', Buffer.from('50 fps=30\n')); + mockProcess.stderr.emit('data', Buffer.from('\r\n\r\n \n')); + + mockProcess.emit('close', 0); + + await expect(convPromise).resolves.toBeUndefined(); + }); + test('runConversion should reject with JellyError on non-zero exit or error', async () => { const mockProcess1 = new EventEmitter() as any; mockProcess1.stderr = new EventEmitter(); @@ -166,6 +230,7 @@ describe('utils/ffmpeg.ts', () => { }); }); + // Audio Extraction test('extractRawAudio should resolve with Float32Array on success', async () => { const mockProcess = new EventEmitter() as any; mockProcess.stdout = new EventEmitter(); @@ -235,4 +300,96 @@ describe('utils/ffmpeg.ts', () => { code: 'FFMPEG_START_FAILED' }); }); + + // Silence Scan + test('runSilenceScan should resolve false when no silence is found', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0'], 100); + + mockProcess.stderr.emit('data', Buffer.from('size=256kB time=00:00:25.00 bitrate=100kbits/s\n')); + mockProcess.stderr.emit('data', Buffer.from('some random ffmpeg log without silence\n')); + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(false); + }); + + test('runSilenceScan should capture and group multiple silence durations via memory address', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0', '0:a:1'], 100, ['ENG', 'POR']); + + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xABC] silence_start: 10.5\n')); + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xDEF] silence_start: 20.0\n')); + + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xABC] silence_end: 15.5 | silence_duration: 5.0\n')); + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xDEF] silence_end: 22.0 | silence_duration: 2.0\n')); + + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xABC] silence_start: 30.0\n')); + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xABC] silence_end: 33.0 | silence_duration: 3.0\n')); + + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(true); + }); + + test('runSilenceScan should handle silence_duration without a preceding silence_start safely', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0'], 100); + + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0x999] silence_end: 10.0 | silence_duration: 5.0\n')); + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(false); + }); + + test('runSilenceScan should format warnings correctly even if trackLabels are missing', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0'], 100); + + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0x111] silence_start: 1.0\n')); + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0x111] silence_duration: 2.0\n')); + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(true); + }); + + test('runSilenceScan should return true on failure (non-zero exit) without throwing', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0'], 100); + mockProcess.emit('close', 1); + + const result = await scanPromise; + expect(result).toBe(true); + }); + + test('runSilenceScan should return true and stop spinner on process error (e.g. spawn failed)', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0'], 100); + + mockProcess.emit('error', new Error('spawn ENOENT')); + + const result = await scanPromise; + expect(result).toBe(true); + }); }); \ No newline at end of file diff --git a/src/utils/mediaUtils.test.ts b/src/utils/mediaUtils.test.ts index 9905b79..cbd6b91 100644 --- a/src/utils/mediaUtils.test.ts +++ b/src/utils/mediaUtils.test.ts @@ -4,7 +4,11 @@ import { isAttachedPic, isGarbageStream, hasEmbeddedGarbage, - filterGarbageStreams + filterGarbageStreams, + buildAudioMaps, + buildAudioLabels, + buildSelectedAudioMaps, + buildSelectedAudioLabels } from './mediaUtils.ts'; import type { MediaStream } from '../types/media.d.ts'; @@ -87,3 +91,28 @@ describe('utils/mediaUtils.ts', () => { expect(filtered).not.toContain(coverArtAttached); }); }); + +describe('Audio Metadata Extractors', () => { + const sampleStreams: any[] = [ + { index: 0, codec_type: 'video', tags: { language: 'eng' } }, + { index: 1, codec_type: 'audio', tags: { language: 'por' } }, + { index: 2, codec_type: 'audio' } // language: 'und' + ]; + + const sampleSelected: any[] = [ + { streamIndex: 0, type: 'video', language: 'eng', fileIndex: 0 }, + { streamIndex: 1, type: 'audio', language: 'por', fileIndex: 1 }, + { streamIndex: 2, type: 'audio' } // language: 'und', fileIndex: 0 + ]; + + test('buildAudioMaps & buildAudioLabels should extract audio properties from FFprobeData streams', () => { + expect(buildAudioMaps(sampleStreams, 0)).toEqual(['0:1', '0:2']); + expect(buildAudioMaps(sampleStreams, 1)).toEqual(['1:1', '1:2']); + expect(buildAudioLabels(sampleStreams)).toEqual(['POR', 'UND']); + }); + + test('buildSelectedAudioMaps & buildSelectedAudioLabels should extract audio properties from SelectedStream', () => { + expect(buildSelectedAudioMaps(sampleSelected)).toEqual(['1:1', '0:2']); + expect(buildSelectedAudioLabels(sampleSelected)).toEqual(['POR', 'UND']); + }); +}); \ No newline at end of file diff --git a/src/utils/ui.test.ts b/src/utils/ui.test.ts index dbf59d6..e74b79c 100644 --- a/src/utils/ui.test.ts +++ b/src/utils/ui.test.ts @@ -139,7 +139,6 @@ describe('utils/ui.ts', () => { expect(result.action).toBe('done'); }); - // --- editTagsMenu --- test('editTagsMenu should initialize missing tags from ffprobe data and allow early exit', async () => { (clack.select as any).mockResolvedValueOnce(-1); @@ -194,4 +193,47 @@ describe('utils/ui.ts', () => { expect(result[0]).toMatchObject({ language: 'jpn', title: 'Original Mix' }); }); + + test('handleExecutionMenu should execute silence scan, update error state, and loop back', async () => { + (clack.select as any) + .mockResolvedValueOnce('silence_scan') + .mockResolvedValueOnce('exit'); + + const runSilenceSpy = spyOn(ffmpeg, 'runSilenceScan').mockResolvedValueOnce(true as any); + + const result = await handleExecutionMenu({ + ffmpegCmd: 'cmd', + fullScanInputs: ['full.mkv'], + fullScanMaps: ['0'], + fullAudioScanMaps: ['0:1'], + outputPath: 'out.mkv', + totalDuration: 100, + totalFrames: 2400 + }); + + expect(runSilenceSpy).toHaveBeenCalledWith(['full.mkv'], ['0:1'], 100, []); + expect(result.action).toBe('exit'); + expect(result.hasErrors).toBe(true); + }); + + test('handleExecutionMenu should bypass silence scan (no spawn) if no audio tracks are mapped', async () => { + (clack.select as any) + .mockResolvedValueOnce('silence_scan') + .mockResolvedValueOnce('exit'); + + const runSilenceSpy = spyOn(ffmpeg, 'runSilenceScan'); + + const result = await handleExecutionMenu({ + ffmpegCmd: 'cmd', + fullScanInputs: ['full.mkv'], + fullScanMaps: ['0'], + fullAudioScanMaps: [], + outputPath: 'out.mkv', + totalDuration: 100, + totalFrames: 2400 + }); + + expect(runSilenceSpy).not.toHaveBeenCalled(); + expect(result.action).toBe('exit'); + }); }); \ No newline at end of file diff --git a/tests/e2e/check.test.ts b/tests/e2e/check.test.ts index fcecfa3..73c12ef 100644 --- a/tests/e2e/check.test.ts +++ b/tests/e2e/check.test.ts @@ -44,7 +44,7 @@ describe('E2E: JellyCC Check Menu', () => { await cli.waitForText('What do you want to do?'); }; - // Entrance + // Entrance & Edge Cases test('Should show warn if provided file path does not exist', async () => { cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', 'invalid-file.mkv'], process.cwd()); @@ -57,9 +57,7 @@ describe('E2E: JellyCC Check Menu', () => { test('Should prompt for video path if executed without arguments', async () => { cli = new CliTester(['bun', 'run', 'src/index.ts', 'check'], process.cwd()); - await cli.waitForText('What is the video file path?'); - expect(cli.getOutput()).toContain('What is the video file path?'); }); @@ -73,7 +71,7 @@ describe('E2E: JellyCC Check Menu', () => { expect(finalOutput).toContain('Corrupted media. Aborting analysis to prevent server crashes.'); }); - // Analyzer + // Analyzer & Action Plans test('Should show "Perfect" message and exit immediately if file perfectly matches rules', async () => { cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('perfect.mkv')], process.cwd()); @@ -111,7 +109,6 @@ describe('E2E: JellyCC Check Menu', () => { expect(exitCode).toBe(0); expect(finalOutput).toContain('The file only requires cleanup (Remux). You discarded 1 stream(s).'); expect(finalOutput).toContain('Suggested Cleanup Command'); - expect(finalOutput).toContain('Operation finished. 🚀'); }); test('Should show Transcode action plan if video codec is incompatible', async () => { @@ -130,18 +127,9 @@ describe('E2E: JellyCC Check Menu', () => { expect(exitCode).toBe(0); expect(finalOutput).toContain('Suggested FFmpeg Command (Transcode + Cleanup)'); expect(finalOutput).toContain('hevc_10bit'); - expect(finalOutput).toContain('Operation finished. 🚀'); - }); - - // // Edge Cases - test('Should prompt to remove embedded garbage if cover art or PGS subtitles are detected', async () => { - cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('with_garbage.mkv')], process.cwd()); - - await cli.waitForText('Embedded garbage detected'); - - expect(cli.getOutput()).toContain('Embedded garbage detected'); }); + // Interactive Menus test('Should open stream selection menu, drop a track, and update action plan to Remux', async () => { cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('perfect.mkv')], process.cwd()); @@ -164,12 +152,8 @@ describe('E2E: JellyCC Check Menu', () => { cli.write(Keys.Enter); const exitCode = await cli.waitForExit(); - const finalOutput = cli.getOutput(); - expect(exitCode).toBe(0); - expect(finalOutput).toContain('The file only requires cleanup (Remux). You discarded 1 stream(s).'); - expect(finalOutput).toContain('Suggested Cleanup Command'); - expect(finalOutput).toContain('Operation finished. 🚀'); + expect(cli.getOutput()).toContain('The file only requires cleanup (Remux). You discarded 1 stream(s).'); }); test('Should open tags editor, change language, and update the suggested command', async () => { @@ -178,8 +162,7 @@ describe('E2E: JellyCC Check Menu', () => { await acceptInitialTagEditing(); await openExecutionMenu(); - cli.write(Keys.Down); - cli.write(Keys.Down); + cli.press(Keys.Down, 2); cli.write(Keys.Enter); await cli.waitForText('Select the track you want to edit:'); @@ -187,7 +170,6 @@ describe('E2E: JellyCC Check Menu', () => { cli.write(Keys.Enter); await cli.waitForText('Language (3-letter code. Ex: eng, jpn, und):'); - cli.press(Keys.Backspace, 3); cli.write('jpn'); cli.write(Keys.Enter); @@ -206,29 +188,8 @@ describe('E2E: JellyCC Check Menu', () => { cli.write(Keys.Enter); const exitCode = await cli.waitForExit(); - const finalOutput = cli.getOutput(); - - expect(exitCode).toBe(0); - expect(finalOutput).toContain('language="jpn"'); - expect(finalOutput).toContain('The file only requires cleanup (Remux). You discarded 0 stream(s).'); - expect(finalOutput).toContain('Operation finished. 🚀'); - }); - - test('Should exit gracefully and print the generated command when selecting Exit', async () => { - cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('needs_transcode.mkv')], process.cwd()); - - await acceptInitialTagEditing(); - await openExecutionMenu(); - - cli.write(Keys.Up); - cli.write(Keys.Enter); - - const exitCode = await cli.waitForExit(); - const finalOutput = cli.getOutput(); - expect(exitCode).toBe(0); - expect(finalOutput).toContain('Clean command generated:'); - expect(finalOutput).toContain('Operation finished. 🚀'); + expect(cli.getOutput()).toContain('language="jpn"'); }); // Deep Scan @@ -239,7 +200,6 @@ describe('E2E: JellyCC Check Menu', () => { await openExecutionMenu(); const currentOutput = cli.getOutput(); - expect(currentOutput).not.toContain('Deep Scan (All tracks'); expect(currentOutput).not.toContain('Myopic Scan'); @@ -266,4 +226,42 @@ describe('E2E: JellyCC Check Menu', () => { expect(exitCode).toBe(0); }); -}); + + // Silence Scan + test('Should bypass Silence Scan execution if media has no audio tracks', async () => { + cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('no_audio.mkv')], process.cwd()); + + await openExecutionMenu(); + + cli.press(Keys.Down, 5); + cli.write(Keys.Enter); + + await cli.waitForText('Audio intact: No sudden drops detected.'); + await openExecutionMenu(); + + cli.write(Keys.Up); + cli.write(Keys.Enter); + + const exitCode = await cli.waitForExit(); + expect(exitCode).toBe(0); + }); + + test('Should execute Silence Scan successfully on a clean video and return to menu', async () => { + cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('silence_clean.mkv')], process.cwd()); + + await acceptInitialTagEditing(); + await openExecutionMenu(); + + cli.press(Keys.Down, 5); + cli.write(Keys.Enter); + + await cli.waitForText('Audio intact: No sudden drops detected.'); + await openExecutionMenu(); + + cli.write(Keys.Up); + cli.write(Keys.Enter); + + const exitCode = await cli.waitForExit(); + expect(exitCode).toBe(0); + }); +}); \ No newline at end of file diff --git a/tests/helpers/setup-fixtures.ts b/tests/helpers/setup-fixtures.ts index afaf333..8f8cf1e 100644 --- a/tests/helpers/setup-fixtures.ts +++ b/tests/helpers/setup-fixtures.ts @@ -39,6 +39,21 @@ try { path.join(FIXTURES_DIR, 'needs_transcode.mkv') ]); + // No Audio + runFfmpeg('no_audio.mkv', [ + '-f', 'lavfi', '-i', 'testsrc=duration=2:size=640x360:rate=24', + '-c:v', 'libx264', + path.join(FIXTURES_DIR, 'no_audio.mkv') + ]); + + // Silence Clean (3 seconds of noise) + runFfmpeg('silence_clean.mkv', [ + '-f', 'lavfi', '-i', 'testsrc=duration=3:size=640x360:rate=24', + '-f', 'lavfi', '-i', 'sine=frequency=1000:duration=3', + '-c:v', 'libx264', '-c:a', 'aac', + path.join(FIXTURES_DIR, 'silence_clean.mkv') + ]); + // Garbage File (Video + Audio + Cover Art) const coverPath = path.join(FIXTURES_DIR, 'cover_temp.jpg'); runFfmpeg('cover_temp.jpg', [ From 316740bb2f6cb3e89c78eb4ea02757d03e761f5e Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 15 Jun 2026 16:31:35 -0300 Subject: [PATCH 13/14] docs: add silence scan on README --- README.md | 2 ++ README.pt.md | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1842a9d..6a5a189 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ - 🔧 **Forced Repair** — Fixes files with corrupted timestamps via an intermediate pipeline (`.w64`/`.mp4`). - 🔬 **Quick Scan + Deep Scan** — Checks container integrity and analyzes frame by frame looking for artifacts and errors. - 🔬 **Myopic Scan** — Deep Scan restricted to selected tracks. +- 🔊 **Silence Scan** — Analyze and identify extended periods of silence within audio tracks. - 🎛️ **Track Selection** — Choose which video, audio, and subtitle streams to keep in the final file. - 🎶 **Smart Spectrum Sync** — Automatically aligns audio tracks from different sources, using advanced waveform cross-correlation. - ⏱️ **Sync Adjustment / End Cut** — Defines time offset and end cut to avoid lip-sync issues. @@ -102,6 +103,7 @@ After analyzing a file, an interactive menu is displayed with the following opti - ⏱️ **Adjust Sync / End Cut** — Defines time offset and end cut. - 🔍 **Deep Scan** — Frame-by-frame analysis of all tracks. - 🔬 **Myopic Scan** — Deep Scan only on selected tracks. +- 🔊 **Silence Scan** — Detects long periods of silence in audio tracks. - 🏷️ **Edit Tags** — Edits language and title of each track. ## ⚙️ Configuration diff --git a/README.pt.md b/README.pt.md index 3214b73..e54e3a5 100644 --- a/README.pt.md +++ b/README.pt.md @@ -28,7 +28,8 @@ - 🔄 **Conversão (Transcode)** — Converte para codecs Direct Play (H.264 8-bit / AAC, EAC3 ou FLAC) com regras de fallback configuráveis. - 🔧 **Reparo Forçado** — Corrige arquivos com timestamps corrompidos via pipeline intermediário (`.w64`/`.mp4`). - 🔬 **Quick Scan + Deep Scan** — Verifica integridade do container e analisa quadro a quadro em busca de artefatos e erros. -- 🔬 **Myopic Scan** — Deep Scan restrito às faixas selecionadas. +- 🔬 **Myopic Scan** — Deep Scan restrito apenas às faixas selecionadas. +- 🔊 **Silence Scan** — Analisar e identificar longos períodos de silêncio nas faixas de áudio. - 🎛️ **Seleção de Faixas** — Escolha quais streams de vídeo, áudio e legenda manter no arquivo final. - 🎶 **Smart Spectrum Sync** — Alinha automaticamente faixas de áudio de origens diferentes, usando correlação matemática de ondas sonoras. - ⏱️ **Ajuste de Sincronia / Corte Final** — Define offset temporal e corte final para evitar problemas de lip-sync. @@ -102,7 +103,8 @@ Após a análise de um arquivo, um menu interativo é exibido com as seguintes o - ⏱️ **Ajustar Sincronia / Corte Final** — Define offset temporal e corte final. - 🔍 **Deep Scan** — Análise quadro a quadro de todas as faixas. - 🔬 **Myopic Scan** — Deep Scan apenas nas faixas selecionadas. -- 🏷️ **Editar Tags** — Edita idioma e título de cada faixa. +- 🔊 **Silence Scan** — Detecta longos períodos de silêncio nas faixas de áudio. +- 🏷️ **Editar Tags** — Edita o idioma e título de cada faixa. ## ⚙️ Configuração From 300b7a34305eae8034c20f1302bbef079d4b14f7 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 15 Jun 2026 16:45:31 -0300 Subject: [PATCH 14/14] refactor: remove silence scan dead code from deep scan --- src/utils/ffmpeg.ts | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/src/utils/ffmpeg.ts b/src/utils/ffmpeg.ts index 4608ccd..4479fed 100644 --- a/src/utils/ffmpeg.ts +++ b/src/utils/ffmpeg.ts @@ -15,12 +15,6 @@ export interface SilenceScanResult { duration: number; } -export interface DeepScanResult { - addr: string; - start: number; - duration: number; -} - export function parseFfmpegTime(timeStr: string) { const parts = timeStr.split(':'); if (parts.length !== 3) return 0; @@ -59,7 +53,6 @@ export function getDynamicAudioEncoder( return `-c:a:${outputIndex} ${targetCodec} -b:a:${outputIndex} ${targetBitrate}k`; } -// Helpers centralizados de parse e barra de progresso (DRY) function generateProgressBar(percent: number, length: number = 25): string { const filled = Math.round((percent / 100) * length); const empty = length - filled; @@ -109,8 +102,6 @@ export async function runDeepScan(inputs: string[], maps: string[], totalDuratio let hasErrors = false; return new Promise((resolve, reject) => { - const results: DeepScanResult[] = []; - const currentStarts = new Map(); const ffmpegArgs = ['-v', 'warning', '-stats']; inputs.forEach(inp => { ffmpegArgs.push('-i', inp); }); @@ -125,19 +116,8 @@ export async function runDeepScan(inputs: string[], maps: string[], totalDuratio }; const onLine = (line: string) => { - const startMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_start:\s*([\d.]+)/); - if (startMatch) { - currentStarts.set(startMatch[1]!, Number.parseFloat(startMatch[2]!)); - } - - const durationMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_duration:\s*([\d.]+)/); - if (durationMatch) { - const addr = durationMatch[1]!; - const cStart = currentStarts.get(addr); - if (cStart !== undefined) { - results.push({ addr, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); - currentStarts.delete(addr); - } + if (!line.startsWith('frame=') && !line.startsWith('size=')) { + errorOutput += line + '\n'; } }; @@ -148,10 +128,10 @@ export async function runDeepScan(inputs: string[], maps: string[], totalDuratio const remainingBuffer = state.buffer.trim(); if (remainingBuffer && !remainingBuffer.startsWith('frame=') && !remainingBuffer.startsWith('size=')) { errorOutput += remainingBuffer + '\n'; - hasErrors = true; } if (errorOutput.trim()) { + hasErrors = true; dsSpinner.stop(pc.yellow(t('scanDeepWarn'))); console.log(pc.dim(errorOutput.trim())); } else if (code === 0) {