Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
- 🔧 **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.
- 🔀 **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.
Expand Down Expand Up @@ -101,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
Expand Down
7 changes: 5 additions & 2 deletions README.pt.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
- 🔄 **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.
- 🔀 **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.
Expand Down Expand Up @@ -101,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

Expand Down
16 changes: 14 additions & 2 deletions src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -35,8 +35,12 @@ interface CheckLoopContext {
totalFrames: number;
fullScanInputs: string[];
fullScanMaps: string[];
fullAudioScanMaps: string[];
fullAudioScanLabels: string[];
selectedScanInputs: string[];
selectedScanMaps: string[];
selectedAudioScanMaps: string[];
selectedAudioScanLabels: string[];
}

export async function checkCommand(args: string[]) {
Expand Down Expand Up @@ -69,8 +73,12 @@ export async function checkCommand(args: string[]) {
ffmpegRepairCmd: context.ffmpegRepairCmd,
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,
Expand Down Expand Up @@ -210,8 +218,12 @@ function buildLoopContext(
totalFrames: diagnostic.metadata.totalFrames,
fullScanInputs: [targetFile],
fullScanMaps: ['0'],
fullAudioScanMaps: buildAudioMaps(probeData.streams, 0),
fullAudioScanLabels: buildAudioLabels(probeData.streams),
selectedScanInputs: [targetFile],
selectedScanMaps: selectedStreams.map((stream) => `0:${stream.streamIndex}`)
selectedScanMaps: selectedStreams.map((stream) => `0:${stream.streamIndex}`),
selectedAudioScanMaps: buildSelectedAudioMaps(selectedStreams),
selectedAudioScanLabels: buildSelectedAudioLabels(selectedStreams)
};
}

Expand Down
91 changes: 82 additions & 9 deletions src/commands/merge.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,13 +11,18 @@ 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';
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;
Expand Down Expand Up @@ -54,6 +59,10 @@ interface MergeLoopContext {
totalFrames: number;
fullScanInputs: string[];
fullScanMaps: string[];
fullAudioScanMaps: string[];
fullAudioScanLabels: string[];
selectedAudioScanMaps: string[];
selectedAudioScanLabels: string[];
}

export async function mergeCommand(_args: string[]) {
Expand All @@ -72,7 +81,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) {
Expand All @@ -85,6 +94,10 @@ export async function mergeCommand(_args: string[]) {
ffmpegRepairCmd: context.ffmpegRepairCmd,
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,
Expand All @@ -110,7 +123,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;
}

Expand Down Expand Up @@ -223,10 +236,10 @@ function buildStreamOptionSources(media: MergeMediaContext) {
}

async function promptSyncAdjustment(
durationDiffMs: number,
media: MergeMediaContext,
currentState: MergeSyncState
): Promise<MergeSyncState> {
const options = buildSyncOptions(durationDiffMs);
const options = buildSyncOptions(media.durationDiffMs);

const chosenSyncAction = onCancel(await select({
message: t('mergeHowToSync'),
Expand All @@ -236,8 +249,50 @@ async function promptSyncAdjustment(
let nextDelayMs = currentState.currentDelayMs;
let nextApplyShortest = currentState.applyShortest;

if (chosenSyncAction === 'auto') {
nextDelayMs = durationDiffMs;
if (chosenSyncAction === 'spectrum') {
log.info(pc.cyan(t('mergeSpectrumHint')));

const tsStr = onCancel(await text({
message: t('mergeAskTimestamp'),
placeholder: '00:15:30',
validate(value) {
if (!value || !value.includes(':')) return t('validNumber');
}
}));

const durA = SPECTRUM_SAMPLE_WINDOW_SEC;
const durB = SPECTRUM_SEARCH_WINDOW_SEC;

const tsSecs = parseTimestampToSeconds(tsStr);
const tsBSecs = Math.max(0, tsSecs - SPECTRUM_SAMPLE_WINDOW_SEC); // Starts before the timestamp
const startTsB = formatSecondsToTimestamp(tsBSecs);

const s = spinner();
s.start(t('mergeSpectrumExtracting'));

try {
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')));
log.success(pc.green(t('mergeSpectrumResult', nextDelayMs)));

} 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'),
Expand Down Expand Up @@ -297,7 +352,11 @@ 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),
fullAudioScanLabels: buildFullAudioScanLabels(media),
selectedAudioScanMaps: buildSelectedAudioMaps(state.selectedStreams),
selectedAudioScanLabels: buildSelectedAudioLabels(state.selectedStreams)
};
}

Expand All @@ -308,6 +367,20 @@ function buildFullScanMaps(media: MergeMediaContext) {
];
}

function buildFullAudioScanMaps(media: MergeMediaContext) {
return [
...buildAudioMaps(media.infoA.streams, 0),
...buildAudioMaps(media.infoB.streams, 1)
];
}

function buildFullAudioScanLabels(media: MergeMediaContext) {
return [
...buildAudioLabels(media.infoA.streams),
...buildAudioLabels(media.infoB.streams)
];
}

function buildSourceScanMaps(probeData: FFprobeData, fileIndex: number) {
return probeData.streams
.filter((stream) => stream.codec_type === 'video' || stream.codec_type === 'audio')
Expand Down
21 changes: 20 additions & 1 deletion src/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -79,7 +80,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):',
Expand All @@ -88,6 +89,15 @@ export default {
mergeModifyStreams: 'Modify the streams you want to keep:',
mergeCmdSuggested: 'Suggested FFmpeg Command (Merge)',

// Command: Merge (Spectrum Sync)
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.',

// Scanners (FFprobe / FFmpeg)
scanQuickStart: 'Running integrity Quick Scan...',
scanQuickPass: '✔ Quick Scan passed: Container structure is intact.',
Expand All @@ -107,6 +117,15 @@ 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.',
scanSilenceProgress: 'Silence Scan in progress: {0}%',
scanSilenceItem: '(duration: {0}s)',

// Command: Lang
langSelect: '🌐 Select your preferred language:',
langChanged: '✔ Language changed successfully!',
Expand Down
21 changes: 20 additions & 1 deletion src/locales/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -79,7 +80,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 ({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):',
Expand All @@ -88,6 +89,15 @@ export default {
mergeModifyStreams: 'Modifique as faixas que deseja manter:',
mergeCmdSuggested: 'Comando FFmpeg Sugerido (Merge)',

// Command: Merge (Spectrum Sync)
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.',

// Scanners (FFprobe / FFmpeg)
scanQuickStart: 'Executando Quick Scan de integridade...',
scanQuickPass: '✔ Quick Scan aprovado: Estrutura do container intacta.',
Expand All @@ -107,6 +117,15 @@ 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.',
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:',
langChanged: '✔ Idioma alterado com sucesso!',
Expand Down
40 changes: 40 additions & 0 deletions src/services/spectrumAnalyzer.test.ts
Original file line number Diff line number Diff line change
@@ -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.');
});
});
Loading
Loading