feat(subtitles): use loaded subtitles to guide generation timing (#249)

This commit is contained in:
2026-09-18 00:24:42 -07:00
committed by GitHub
parent 186d4a0640
commit a8c16147ad
24 changed files with 965 additions and 75 deletions
@@ -2,3 +2,4 @@ type: docs
area: subtitles area: subtitles
- Explain how dialogue generation retains uncertain audible sections, why songs may also be transcribed, how detected speech starts guide long-passage cuts to reduce early subtitles, and why each passage uses a fresh Whisper process. - Explain how dialogue generation retains uncertain audible sections, why songs may also be transcribed, how detected speech starts guide long-passage cuts to reduce early subtitles, and why each passage uses a fresh Whisper process.
- Document automatic loaded subtitle references, English dialogue preference, signs/songs filtering, fallback behavior, and the limits of reference-guided timing.
+2
View File
@@ -4,3 +4,5 @@ area: subtitles
- Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded. - Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded.
- Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH. The modal's Local tools check and the launcher name any missing executable and its setting before downloading a model or extracting audio, and generation confirms the destination directory grants write and search permissions up front. - Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH. The modal's Local tools check and the launcher name any missing executable and its setting before downloading a model or extracting audio, and generation confirms the destination directory grants write and search permissions up front.
- Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. Retain uncertain audible sections so VAD rejection does not discard dialogue under music, accepting that songs may also be transcribed. Keep passages intact within Whisper's audio window, split longer passages near detected speech starts or quiet pauses with overlapping context to reduce early subtitle timing, and combine overlapping duplicate cues even when punctuation differs. Run each passage in a fresh Whisper process to prevent repeated-character output caused by state carried between files, at the cost of reloading the model per passage. Preserve original media timing and separate repeated dialogue. - Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. Retain uncertain audible sections so VAD rejection does not discard dialogue under music, accepting that songs may also be transcribed. Keep passages intact within Whisper's audio window, split longer passages near detected speech starts or quiet pauses with overlapping context to reduce early subtitle timing, and combine overlapping duplicate cues even when punctuation differs. Run each passage in a fresh Whisper process to prevent repeated-character output caused by state carried between files, at the cost of reloading the model per passage. Preserve original media timing and separate repeated dialogue.
- Recommend large-v3-turbo in the model picker when NVIDIA hardware and CUDA device support in the selected Whisper executable are confirmed; recommend small otherwise. Preserve configured and session model selections.
- Automatically use an eligible embedded or external subtitle track loaded in mpv to guide long-passage cuts, preferring English dialogue and excluding tracks marked signs, songs, forced, or generated. Honor active subtitle delays, retain unreferenced audio, and fall back to audio timing when references cannot be read. Available in the overlay and launcher, with or without VAD. The launcher captures audio selection, reference tracks, and delays before model setup so playback changes during a download do not mix inputs from different times, and rejects an unverified media snapshot before using mpv's selected audio stream.
+12 -2
View File
@@ -29,11 +29,21 @@ With speech detection configured, SubMiner keeps detected speech and other audib
Passages that fit within Whisper's 30-second audio window stay intact. Longer passages prefer nearby detected speech starts when choosing cuts, falling back to quiet pauses, with a small overlap to provide context. This reduces early subtitles caused by starting a clip well before its dialogue, while keeping all retained audio covered. Matching overlapping cues are combined even when punctuation differs; repeated dialogue at separate times remains separate. SubMiner runs each passage in a fresh Whisper process so decoder state from earlier audio cannot affect later passages. This reloads the model for each passage and can increase generation time. Subtitle cues stay within the supplied audio and retain each passage's position on the original timeline. Progress reports the current passage. Passages that fit within Whisper's 30-second audio window stay intact. Longer passages prefer nearby detected speech starts when choosing cuts, falling back to quiet pauses, with a small overlap to provide context. This reduces early subtitles caused by starting a clip well before its dialogue, while keeping all retained audio covered. Matching overlapping cues are combined even when punctuation differs; repeated dialogue at separate times remains separate. SubMiner runs each passage in a fresh Whisper process so decoder state from earlier audio cannot affect later passages. This reloads the model for each passage and can increase generation time. Subtitle cues stay within the supplied audio and retain each passage's position on the original timeline. Progress reports the current passage.
This mode favors retaining dialogue over excluding music, so songs and background sounds may also produce subtitles. It can take longer than transcribing only VAD-approved speech. Whisper can still miss or misrecognize dialogue, and its timestamps remain estimates. Uncheck **Focus on spoken dialogue** to return to full-audio transcription for the session, or clear `vadModelPath` to change the default. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved. This mode favors retaining dialogue over excluding music, so songs and background sounds may also produce subtitles. It can take longer than transcribing only VAD-approved speech. Whisper can still miss or misrecognize dialogue, and its timestamps remain estimates. Uncheck **Focus on spoken dialogue** to disable VAD for the session, or clear `vadModelPath` to change the default. Without a usable subtitle reference, disabling VAD returns to full-audio transcription. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved.
## Using loaded subtitles as timing references
When generating for the video currently open in mpv, SubMiner automatically looks for a dialogue subtitle track among its embedded subtitles and loaded external SRT, ASS/SSA, or WebVTT files. It prefers English, then tracks labeled full or dialogue. Forced tracks, image subtitles, generated subtitles, and tracks whose titles or filenames identify signs, songs, lyrics, karaoke, or opening/ending subtitles are skipped. These checks rely on metadata; an unlabeled signs-only file cannot always be identified.
The selected reference appears in generation progress. SubMiner reads its timestamps, including the active primary or secondary track's subtitle delay, and uses nearby cue starts to guide cuts in long audio passages. Short passages stay intact. The reference works with or without **Focus on spoken dialogue**. With that option enabled, reference starts take priority over VAD starts when choosing a nearby cut; VAD still helps identify speech. Audio outside reference cues remains eligible for transcription, and Whisper still supplies the Japanese text and final timestamps. The reference is assumed to be timed for the playing video; this does not automatically sync a mistimed reference.
Unreadable or empty references are skipped in favor of another eligible loaded track. If none can be read, generation uses its normal audio timing. The launcher uses loaded references only when its input matches the video currently open in mpv; standalone generation keeps its existing behavior. It captures reference tracks and delays together with the initial audio selection, before checking or downloading a model. When relying on mpv's selected audio, it stops and asks you to retry if the media changes or cannot be verified during capture.
## Choosing a model ## Choosing a model
Start with **small** for a balance of Japanese recognition quality and CPU time. This is a general starting recommendation, not a benchmark for your hardware. Tiny and base need less memory and usually finish sooner, with more recognition errors. Medium and large models favor accuracy but need more resources. Large-v3-turbo is optimized for speed compared with large-v3, with some accuracy tradeoff; actual performance depends on your CPU, GPU, whisper.cpp build, and audio. The modal recommends **large-v3-turbo** when it detects an NVIDIA GPU through `nvidia-smi` and the selected `whisper-cli` discovers an available CUDA device. Otherwise it recommends **small** for a balance of Japanese recognition quality and CPU time. The check works before downloading a model and falls back to small if a tool is missing, fails, times out, or reports an unrecognized result. Vulkan, AMD, and Apple GPU support do not qualify for the turbo recommendation. Checks are cached for up to 30 seconds; changing the Whisper executable path triggers a new check.
The recommendation labels the model picker and explains the detected support. It does not change your configured model, current selection, external model path, or launcher's model choice. It also does not force a GPU backend during transcription. These are starting recommendations, not hardware benchmarks or guarantees that every model fits in available GPU memory. Tiny and base need less memory and usually finish sooner, with more recognition errors. Medium and large models favor accuracy but need more resources. Large-v3-turbo is optimized for speed compared with large-v3, with some accuracy tradeoff; actual performance depends on your CPU, GPU, whisper.cpp build, and audio.
The picker includes whisper.cpp's official multilingual tiny, base, small, medium, large-v1, large-v2, large-v3, and large-v3-turbo downloads, including their available quantized variants. Quantized models use less disk space and memory, with possible accuracy loss. English-only `.en` models are excluded. See the [upstream model list](https://github.com/ggml-org/whisper.cpp/blob/master/models/download-ggml-model.sh) and [Whisper's model guidance](https://github.com/openai/whisper#available-models-and-languages). The picker includes whisper.cpp's official multilingual tiny, base, small, medium, large-v1, large-v2, large-v3, and large-v3-turbo downloads, including their available quantized variants. Quantized models use less disk space and memory, with possible accuracy loss. English-only `.en` models are excluded. See the [upstream model list](https://github.com/ggml-org/whisper.cpp/blob/master/models/download-ggml-model.sh) and [Whisper's model guidance](https://github.com/openai/whisper#available-models-and-languages).
+4
View File
@@ -92,6 +92,10 @@ delay. It also works with no running
SubMiner app or mpv instance when you provide a file path. Omit the path to use the current SubMiner app or mpv instance when you provide a file path. Omit the path to use the current
mpv file and selected audio track. mpv file and selected audio track.
When the input matches the current mpv file, generation automatically uses an eligible embedded
or loaded external subtitle track as a timing reference, preferring English dialogue and skipping
tracks marked as signs, songs, or forced. See [timing references](/subtitle-generation#using-loaded-subtitles-as-timing-references).
```bash ```bash
subminer generate-subs video.mkv --download-model subminer generate-subs video.mkv --download-model
subminer generate-subs video.mkv --model-path ~/models/ggml-medium.bin subminer generate-subs video.mkv --model-path ~/models/ggml-medium.bin
+2
View File
@@ -32,6 +32,8 @@ Update checks and startup launcher migration share a serialized update-state sto
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection. - `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic. - `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative. - `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
- `src/renderer/` owns overlay rendering and input behavior. - `src/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution. - `src/config/` owns config definitions, defaults, loading, and resolution.
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel. - `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
@@ -136,25 +136,28 @@ test('current mpv generation requires an identifiable selected audio track', asy
} }
}); });
test('explicit local file leaves Japanese track selection to the shared generator', async () => { test('explicit playing file leaves audio selection to the generator but inspects subtitle references', async () => {
const f = fixture(); const f = fixture();
await runGenerateSubtitlesCommand(f.context, f.deps); await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(f.generations[0]?.audioStreamIndex, undefined); assert.equal(f.generations[0]?.audioStreamIndex, undefined);
assert.equal( assert.equal(
f.commands.some((command) => command[1] === 'track-list'), f.commands.some((command) => command[1] === 'track-list'),
false, true,
); );
}); });
test('launcher does not load generated subtitles after mpv switches files', async () => { test('launcher does not load generated subtitles after mpv switches files', async () => {
const f = fixture(['generate-subs']); const f = fixture(['generate-subs']);
let pathRequests = 0; let changed = false;
f.deps.mpvCommand = async (_socket, command) => { f.deps.mpvCommand = async (_socket, command) => {
f.commands.push(command); f.commands.push(command);
if (command[1] === 'path') if (command[1] === 'path') return changed ? '/media/next.mkv' : '/media/episode.mkv';
return ++pathRequests === 1 ? '/media/episode.mkv' : '/media/next.mkv';
return [{ type: 'audio', selected: true, 'ff-index': 2 }]; return [{ type: 'audio', selected: true, 'ff-index': 2 }];
}; };
f.deps.generate = async () => {
changed = true;
return '/media/episode.ja.srt';
};
await runGenerateSubtitlesCommand(f.context, f.deps); await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal( assert.equal(
f.commands.some((command) => command[0] === 'sub-add'), f.commands.some((command) => command[0] === 'sub-add'),
@@ -163,6 +166,86 @@ test('launcher does not load generated subtitles after mpv switches files', asyn
assert.match(f.output.join(''), /Saved Japanese subtitles/); assert.match(f.output.join(''), /Saved Japanese subtitles/);
}); });
test('current mpv generation rejects tracks when playback changes during capture', async () => {
for (const nextMedia of ['/media/next.mkv', null]) {
const f = fixture(['generate-subs']);
let media: string | null = '/media/episode.mkv';
let modelChecks = 0;
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return media;
if (command[1] === 'track-list') {
media = nextMedia;
return [{ type: 'audio', selected: true, 'ff-index': 99 }];
}
return undefined;
};
f.deps.resolveModel = async () => {
modelChecks += 1;
return { kind: 'external', path: '/models/model.bin' };
};
await assert.rejects(
runGenerateSubtitlesCommand(f.context, f.deps),
/mpv media.*Run generate-subs again/,
);
assert.equal(f.generations.length, 0);
assert.equal(modelChecks, 0);
}
});
test('explicit media or audio stream remains usable when the mpv snapshot changes', async () => {
for (const options of [['/media/episode.mkv'], ['--audio-stream', '7']]) {
const f = fixture(['generate-subs', ...options]);
let media = '/media/episode.mkv';
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return media;
if (command[1] === 'track-list') {
media = '/media/next.mkv';
return [
{ type: 'audio', selected: true, 'ff-index': 99 },
{ type: 'sub', lang: 'eng', 'ff-index': 100 },
];
}
return undefined;
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(f.generations[0]?.mediaPath, '/media/episode.mkv');
assert.equal(
f.generations[0]?.audioStreamIndex,
options[0] === '--audio-stream' ? 7 : undefined,
);
assert.deepEqual(f.generations[0]?.references, []);
}
});
test('launcher captures loaded external references only for the matching media', async () => {
for (const matching of [true, false]) {
const f = fixture();
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return matching ? '/media/episode.mkv' : '/media/other.mkv';
if (command[1] === 'working-directory') return '/mpv';
if (command[1] === 'track-list')
return [
{ type: 'sub', external: true, 'external-filename': 'episode.en.signs.ass' },
{ type: 'sub', external: true, 'external-filename': 'episode.en.srt' },
];
return undefined;
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.deepEqual(
f.generations[0]?.references,
matching
? [
{
label: 'episode.en.srt',
delaySeconds: 0,
source: { kind: 'external', path: '/mpv/episode.en.srt' },
},
]
: [],
);
}
});
test('explicit managed model overrides external config and downloads before generation', async () => { test('explicit managed model overrides external config and downloads before generation', async () => {
const f = fixture([ const f = fixture([
'generate-subs', 'generate-subs',
@@ -191,6 +274,69 @@ test('explicit managed model overrides external config and downloads before gene
assert.equal(f.generations.length, 1); assert.equal(f.generations.length, 1);
}); });
test('audio and subtitle timing use the initial mpv snapshot across model setup', async () => {
for (const changeDuring of ['resolve', 'download']) {
const f = fixture(['generate-subs', '--download-model']);
let changed = false;
let trackReads = 0;
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return '/media/episode.mkv';
if (command[1] === 'track-list') {
trackReads += 1;
return [
{ type: 'audio', selected: true, 'ff-index': changed ? 3 : 2 },
{ type: 'sub', id: 1, title: 'English Full', lang: 'eng', 'ff-index': changed ? 5 : 4 },
];
}
if (command[1] === 'sid') return 1;
if (command[1] === 'sub-delay') return changed ? 9 : 1.5;
return undefined;
};
f.deps.resolveModel = async () => {
if (changeDuring === 'resolve') changed = true;
return { kind: 'missing', path: '/models/model.bin' };
};
f.deps.downloadModel = async () => {
changed = true;
return '/models/model.bin';
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(f.generations[0]?.audioStreamIndex, 2);
assert.deepEqual(f.generations[0]?.references, [
{
label: 'English Full',
delaySeconds: 1.5,
source: { kind: 'embedded', streamIndex: 4 },
},
]);
assert.equal(trackReads, 1);
}
});
test('explicit audio stream bypasses mpv audio selection while retaining subtitle references', async () => {
const f = fixture(['generate-subs', '--audio-stream', '7']);
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return '/media/episode.mkv';
if (command[1] === 'track-list')
return [
{ type: 'audio', selected: true, external: true, 'ff-index': 0 },
{ type: 'sub', id: 2, title: 'English Full', lang: 'eng', 'ff-index': 4 },
];
if (command[1] === 'secondary-sid') return 2;
if (command[1] === 'secondary-sub-delay') return -0.5;
return undefined;
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(f.generations[0]?.audioStreamIndex, 7);
assert.deepEqual(f.generations[0]?.references, [
{
label: 'English Full',
delaySeconds: -0.5,
source: { kind: 'embedded', streamIndex: 4 },
},
]);
});
test('generation can run standalone and never loads subtitles into another video', async () => { test('generation can run standalone and never loads subtitles into another video', async () => {
for (const playing of [null, '/media/different.mkv']) { for (const playing of [null, '/media/different.mkv']) {
const f = fixture(); const f = fixture();
+30 -10
View File
@@ -8,6 +8,10 @@ import {
resolveSubtitleGenerationTools, resolveSubtitleGenerationTools,
} from '../../src/core/services/subtitle-generation.js'; } from '../../src/core/services/subtitle-generation.js';
import { requireSubtitleGenerationTools } from '../../src/core/services/subtitle-generation-tools.js'; import { requireSubtitleGenerationTools } from '../../src/core/services/subtitle-generation-tools.js';
import {
readSubtitleGenerationReferences,
type SubtitleGenerationReference,
} from '../../src/core/services/subtitle-generation-reference.js';
import { import {
resolveSubtitleGenerationConfig, resolveSubtitleGenerationConfig,
type SubtitleGenerationProgress, type SubtitleGenerationProgress,
@@ -69,12 +73,9 @@ async function readMpvMedia(socketPath: string, command: GenerationCommandDeps['
return localMediaPath(media, workingDirectory); return localMediaPath(media, workingDirectory);
} }
async function readMpvAudioStream( function selectedMpvAudioStream(value: unknown) {
socketPath: string, const tracks: unknown[] = Array.isArray(value) ? value : [];
command: GenerationCommandDeps['mpvCommand'], for (const track of tracks) {
) {
const tracks = await command(socketPath, ['get_property', 'track-list'], 1000);
for (const track of Array.isArray(tracks) ? tracks : []) {
if ( if (
typeof track === 'object' && typeof track === 'object' &&
track !== null && track !== null &&
@@ -160,11 +161,29 @@ export async function runGenerateSubtitlesCommand(
const mediaPath = options.mediaPath ? localMediaPath(options.mediaPath) : currentMedia; const mediaPath = options.mediaPath ? localMediaPath(options.mediaPath) : currentMedia;
if (!mediaPath) if (!mediaPath)
throw new Error('Pass a local video file or open one in mpv before running generate-subs.'); throw new Error('Pass a local video file or open one in mpv before running generate-subs.');
// Capture audio and subtitle timing together before model setup can yield to playback changes.
const matchesCurrentMedia = currentMedia !== null && sameFile(currentMedia, mediaPath);
const tracks = matchesCurrentMedia
? await deps
.mpvCommand(context.mpvSocketPath, ['get_property', 'track-list'], 1000)
.catch(() => null)
: null;
let references: SubtitleGenerationReference[] = [];
if (matchesCurrentMedia) {
const candidates = await readSubtitleGenerationReferences(tracks, (name) =>
deps.mpvCommand(context.mpvSocketPath, ['get_property', name], 1000),
);
const stillPlaying = await readMpvMedia(context.mpvSocketPath, deps.mpvCommand).catch(
() => null,
);
if (stillPlaying && sameFile(stillPlaying, mediaPath)) references = candidates;
else if (!options.mediaPath && options.audioStreamIndex === undefined)
throw new Error(
'The current mpv media changed or could not be verified while reading tracks. Run generate-subs again.',
);
}
const audioStreamIndex = const audioStreamIndex =
options.audioStreamIndex ?? options.audioStreamIndex ?? (!options.mediaPath ? selectedMpvAudioStream(tracks) : undefined);
(!options.mediaPath
? await readMpvAudioStream(context.mpvSocketPath, deps.mpvCommand)
: undefined);
const onProgress = createGenerationProgressReporter(write); const onProgress = createGenerationProgressReporter(write);
// Missing executables fail here, before any model download starts. // Missing executables fail here, before any model download starts.
requireSubtitleGenerationTools(await deps.resolveTools(config)); requireSubtitleGenerationTools(await deps.resolveTools(config));
@@ -183,6 +202,7 @@ export async function runGenerateSubtitlesCommand(
modelDirectory, modelDirectory,
mediaPath, mediaPath,
audioStreamIndex, audioStreamIndex,
references,
outputPath: options.outputPath outputPath: options.outputPath
? path.resolve(resolvePathMaybe(options.outputPath)) ? path.resolve(resolvePathMaybe(options.outputPath))
: undefined, : undefined,
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { detectSubtitleGenerationAcceleration } from './subtitle-generation-acceleration';
async function fixture(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-acceleration-test-'));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
async function executable(directory: string, name: string, body: string) {
const file = path.join(directory, name);
await writeFile(file, `#!${process.execPath}\n${body}`, { mode: 0o755 });
return file;
}
const cudaOutput =
'ggml_cuda_init: found 1 CUDA devices:\nwhisper_model_load: invalid model data (bad magic)\n';
test('NVIDIA and CUDA discovery work without downloading or loading a model', () =>
fixture(async (directory) => {
await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");');
const whisper = await executable(
directory,
'whisper-cli',
`const fs = require('node:fs');
const model = process.argv[process.argv.indexOf('-m') + 1];
if (fs.readFileSync(model).length !== 4) process.exit(1);
fs.writeFileSync(${JSON.stringify(path.join(directory, 'probe-path'))}, model);
process.stderr.write(${JSON.stringify(cudaOutput)}); process.exit(3);`,
);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' },
);
const model = await readFile(path.join(directory, 'probe-path'), 'utf8');
await assert.rejects(readdir(path.dirname(model)), { code: 'ENOENT' });
}));
test('CPU-only, Vulkan-only, hidden CUDA devices and incomplete probes fall back safely', () =>
fixture(async (directory) => {
await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");');
for (const output of [
'usage: --no-gpu disable GPU\ninvalid model data (bad magic)',
'ggml_vulkan: Found 1 Vulkan devices\ninvalid model data (bad magic)',
'ggml_cuda_init: found 0 CUDA devices\ninvalid model data (bad magic)',
'ggml_cuda_init: found 1 CUDA devices\nCUDA error: driver initialization failed',
]) {
const whisper = await executable(
directory,
'whisper-cli',
`process.stderr.write(${JSON.stringify(output)}); process.exit(3);`,
);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
}
}));
test('missing tools, missing NVIDIA devices and driver errors do not recommend turbo', () =>
fixture(async (directory) => {
const whisper = await executable(
directory,
'whisper-cli',
`process.stderr.write(${JSON.stringify(cudaOutput)}); process.exit(3);`,
);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'missing', message: 'Not installed' },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
for (const driver of [
null,
'process.exit(0);',
'console.log("NVIDIA GPU"); process.exit(1);',
]) {
if (driver !== null) await executable(directory, 'nvidia-smi', driver);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
}
}));
test('a hung Whisper probe times out even if it printed a CUDA device', () =>
fixture(async (directory) => {
await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");');
const whisper = await executable(
directory,
'whisper-cli',
`process.stderr.write(${JSON.stringify(cudaOutput)}); setInterval(() => {}, 1000);`,
);
const started = Date.now();
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
assert.ok(Date.now() - started < 6000);
}));
@@ -0,0 +1,62 @@
import { execFile } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type {
SubtitleGenerationAcceleration,
SubtitleGenerationToolStatus,
} from '../../shared/subtitle-generation';
function probe(command: string, args: string[], env: NodeJS.ProcessEnv) {
return new Promise<{ code: number; stdout: string; stderr: string } | null>((resolve) => {
execFile(
command,
args,
{ env, timeout: 3000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024, windowsHide: true },
(error, stdout, stderr) => {
const code = error?.code ?? 0;
if (typeof code !== 'number' || error?.killed || error?.signal) {
resolve(null);
return;
}
resolve({ code, stdout, stderr });
},
);
});
}
/** Require both a working NVIDIA driver and CUDA device discovery in the selected Whisper binary. */
export async function detectSubtitleGenerationAcceleration(
whisper: SubtitleGenerationToolStatus,
env: NodeJS.ProcessEnv = process.env,
): Promise<SubtitleGenerationAcceleration> {
const unavailable: SubtitleGenerationAcceleration = { kind: 'unavailable' };
if (whisper.kind === 'missing') return unavailable;
let directory: string | undefined;
try {
const nvidia = await probe('nvidia-smi', ['--query-gpu=name', '--format=csv,noheader'], env);
const gpuName = nvidia?.stdout.trim().split(/\r?\n/)[0]?.trim();
if (nvidia?.code !== 0 || !gpuName) return unavailable;
directory = await mkdtemp(path.join(tmpdir(), 'subminer-cuda-check-'));
const model = path.join(directory, 'probe.bin');
await writeFile(model, Buffer.alloc(4));
// whisper.cpp discovers backends before checking model magic. This deliberately invalid
// local file stops before allocating a model or decoding audio, even on a fresh install.
const result = await probe(whisper.path, ['-m', model, '-f', model], env);
if (
result &&
result.code !== 0 &&
/ggml_cuda_init:\s+found\s+[1-9]\d*\s+CUDA devices?\b/i.test(result.stderr) &&
/invalid model data \(bad magic\)/i.test(result.stderr)
) {
return { kind: 'nvidia-cuda', gpuName };
}
return unavailable;
} catch {
// Detection is advisory; unsupported builds and driver failures must not block generation.
return unavailable;
} finally {
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {});
}
}
@@ -2,6 +2,24 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks'; import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks';
test('reference starts guide long cuts ahead of VAD without dropping unreferenced audio', () => {
const chunks = splitSpeechPassages(
[{ startSeconds: 0, endSeconds: 70 }],
[18],
[19],
[22, 43, 200],
);
assert.deepEqual(chunks, [
{ startSeconds: 0, endSeconds: 22.25 },
{ startSeconds: 21.75, endSeconds: 43.25 },
{ startSeconds: 42.75, endSeconds: 63.25 },
{ startSeconds: 62.75, endSeconds: 70 },
]);
assert.deepEqual(splitSpeechPassages([{ startSeconds: 0, endSeconds: 25 }], [], [], [10, 20]), [
{ startSeconds: 0, endSeconds: 25 },
]);
});
test('long coverage cuts at nearby speech starts instead of leaving a quiet lead-in', () => { test('long coverage cuts at nearby speech starts instead of leaving a quiet lead-in', () => {
const chunks = splitSpeechPassages( const chunks = splitSpeechPassages(
[{ startSeconds: 544.418, endSeconds: 581.581 }], [{ startSeconds: 544.418, endSeconds: 581.581 }],
+16 -13
View File
@@ -5,11 +5,12 @@ const CHUNK_CONTEXT_SECONDS = 0.25;
const WHISPER_WINDOW_SECONDS = 30; const WHISPER_WINDOW_SECONDS = 30;
const PAUSE_SEARCH_SECONDS = 5; const PAUSE_SEARCH_SECONDS = 5;
// Prefer detected speech starts, then quiet pauses. Context stays inside retained audio. // Prefer subtitle timing hints, then detected speech starts and quiet pauses.
export function splitSpeechPassages( export function splitSpeechPassages(
passages: readonly SpeechPassage[], passages: readonly SpeechPassage[],
pauses: readonly number[] = [], pauses: readonly number[] = [],
speechStarts: readonly number[] = [], speechStarts: readonly number[] = [],
referenceStarts: readonly number[] = [],
): SpeechPassage[] { ): SpeechPassage[] {
return passages.flatMap((passage) => { return passages.flatMap((passage) => {
if (passage.endSeconds - passage.startSeconds <= WHISPER_WINDOW_SECONDS) if (passage.endSeconds - passage.startSeconds <= WHISPER_WINDOW_SECONDS)
@@ -22,17 +23,19 @@ export function splitSpeechPassages(
if (target < passage.endSeconds) { if (target < passage.endSeconds) {
// Starting in a long quiet lead-in can make Whisper place the next line // Starting in a long quiet lead-in can make Whisper place the next line
// several seconds early. A nearby VAD start gives the next chunk an anchor. // several seconds early. A nearby VAD start gives the next chunk an anchor.
let nearestSpeechStart: number | undefined; const nearestStart = (starts: readonly number[]): number | undefined => {
for (const time of speechStarts) { let nearest: number | undefined;
if ( for (const time of starts) {
time >= target - PAUSE_SEARCH_SECONDS && if (
time <= target + PAUSE_SEARCH_SECONDS && time >= target - PAUSE_SEARCH_SECONDS &&
time < passage.endSeconds && time <= target + PAUSE_SEARCH_SECONDS &&
(nearestSpeechStart === undefined || time < passage.endSeconds &&
Math.abs(time - target) < Math.abs(nearestSpeechStart - target)) (nearest === undefined || Math.abs(time - target) < Math.abs(nearest - target))
) )
nearestSpeechStart = time; nearest = time;
} }
return nearest;
};
let latestPause: number | undefined; let latestPause: number | undefined;
for (const time of pauses) { for (const time of pauses) {
if ( if (
@@ -42,7 +45,7 @@ export function splitSpeechPassages(
) )
latestPause = time; latestPause = time;
} }
end = nearestSpeechStart ?? latestPause ?? end; end = nearestStart(referenceStarts) ?? nearestStart(speechStarts) ?? latestPause ?? end;
} }
chunks.push({ chunks.push({
startSeconds: Math.max(passage.startSeconds, boundary - CHUNK_CONTEXT_SECONDS), startSeconds: Math.max(passage.startSeconds, boundary - CHUNK_CONTEXT_SECONDS),
@@ -13,6 +13,7 @@ import {
parseSpeechPassages, parseSpeechPassages,
speechPassageCues, speechPassageCues,
SPEECH_PASSAGE_SECONDS, SPEECH_PASSAGE_SECONDS,
type SpeechPassage,
} from './subtitle-generation-speech'; } from './subtitle-generation-speech';
import type { SubtitleCue } from './subtitle-cue-parser'; import type { SubtitleCue } from './subtitle-cue-parser';
import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks'; import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks';
@@ -21,46 +22,50 @@ import { findAudiblePassages, mergeSpeechPassages } from './subtitle-generation-
export async function transcribeSubtitleDialogue(input: { export async function transcribeSubtitleDialogue(input: {
config: SubtitleGenerationConfig; config: SubtitleGenerationConfig;
tools: SubtitleGenerationToolPaths & { vad: string }; tools: SubtitleGenerationToolPaths;
referenceStarts?: readonly number[];
modelPath: string; modelPath: string;
wavPath: string; wavPath: string;
directory: string; directory: string;
onProgress?: (progress: SubtitleGenerationProgress) => void; onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<string> { }): Promise<string> {
const vadModelPath = expandSubtitleGenerationPath(input.config.vadModelPath); let speech: SpeechPassage[] = [];
await access(vadModelPath, constants.R_OK); if (input.tools.vad !== null) {
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' }); const vadModelPath = expandSubtitleGenerationPath(input.config.vadModelPath);
const segmentLines: string[] = []; await access(vadModelPath, constants.R_OK);
await runSubtitleGenerationProcess({ input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' });
command: input.tools.vad, const segmentLines: string[] = [];
args: [ await runSubtitleGenerationProcess({
'-f', command: input.tools.vad,
input.wavPath, args: [
'-vm', '-f',
vadModelPath, input.wavPath,
'-t', '-vm',
String(input.config.threads), vadModelPath,
'-vt', '-t',
'0.3', String(input.config.threads),
'--vad-min-speech-duration-ms', '-vt',
'100', '0.3',
'--vad-min-silence-duration-ms', '--vad-min-speech-duration-ms',
'500', '100',
'-vp', '--vad-min-silence-duration-ms',
'350', '500',
'-vmsd', '-vp',
String(SPEECH_PASSAGE_SECONDS), '350',
'-np', '-vmsd',
], String(SPEECH_PASSAGE_SECONDS),
signal: input.signal, '-np',
// Capture structured result lines separately from the bounded process log. ],
onLine: (line) => { signal: input.signal,
if (line.startsWith('Detected ') || line.startsWith('Speech segment ')) // Capture structured result lines separately from the bounded process log.
segmentLines.push(line); onLine: (line) => {
}, if (line.startsWith('Detected ') || line.startsWith('Speech segment '))
}); segmentLines.push(line);
const speech = parseSpeechPassages(segmentLines.join('\n')); },
});
speech = parseSpeechPassages(segmentLines.join('\n'));
}
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Checking audio coverage...' }); input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Checking audio coverage...' });
const audible = await findAudiblePassages({ const audible = await findAudiblePassages({
ffmpegPath: input.tools.ffmpeg, ffmpegPath: input.tools.ffmpeg,
@@ -82,6 +87,7 @@ export async function transcribeSubtitleDialogue(input: {
detected, detected,
pauses, pauses,
speech.map((passage) => passage.startSeconds), speech.map((passage) => passage.startSeconds),
input.referenceStarts,
); );
const cues: SubtitleCue[] = []; const cues: SubtitleCue[] = [];
for (const [index, passage] of passages.entries()) { for (const [index, passage] of passages.entries()) {
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
loadSubtitleGenerationReference,
readSubtitleGenerationReferences,
subtitleGenerationReferences,
} from './subtitle-generation-reference';
const embedded = { type: 'sub', codec: 'ass', 'ff-index': 2, lang: 'eng' };
test('references exclude signs, songs, forced, bitmap and generated tracks even when selected', () => {
const excluded = [
'Signs & Songs',
'SignsSongs',
'Signs/Songs',
'S&S',
'S+S',
'Forced',
'Karaoke',
'OP',
'ED',
'English lyrics',
'Generated Japanese',
];
assert.deepEqual(
subtitleGenerationReferences([
...excluded.map((title) => ({ ...embedded, title, selected: true })),
{ ...embedded, forced: true },
{ ...embedded, codec: 'hdmv_pgs_subtitle' },
{
...embedded,
title: 'English',
external: true,
'external-filename': '/subs/show.en.signs.ass',
},
{ ...embedded, title: 'English Full' },
]).map((reference) => reference.label),
['English Full'],
);
});
test('references rank English dialogue first and resolve loaded external files against mpv cwd', () => {
const refs = subtitleGenerationReferences(
[
{ ...embedded, title: 'French Full', lang: 'fra', selected: true },
{ ...embedded, title: 'English' },
{ type: 'sub', external: true, 'external-filename': 'subs/show.en.full.srt' },
{ type: 'sub', external: true, 'external-filename': 'https://example.com/en.srt' },
{ type: 'sub', 'ff-index': -1 },
null,
],
'/mpv',
);
assert.deepEqual(
refs.map((ref) => ref.label),
['show.en.full.srt', 'English', 'French Full'],
);
assert.deepEqual(refs[0]?.source, { kind: 'external', path: '/mpv/subs/show.en.full.srt' });
assert.deepEqual(
subtitleGenerationReferences([
{ type: 'sub', external: true, 'external-filename': 'relative.en.srt' },
]),
[],
);
});
test('reference discovery captures primary and secondary subtitle delays', async () => {
const properties: Record<string, unknown> = {
'working-directory': '/mpv',
sid: 1,
'secondary-sid': 2,
'sub-delay': 1.5,
'secondary-sub-delay': -2,
};
const refs = await readSubtitleGenerationReferences(
[
{ ...embedded, id: 1 },
{ ...embedded, id: 2 },
{ ...embedded, id: 3 },
],
async (name) => properties[name],
);
assert.deepEqual(
refs.map((ref) => ref.delaySeconds),
[1.5, -2, 0],
);
});
test('reference extraction retries unreadable tracks, restores audio offset and ignores marked lyrics', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'generation-reference-'));
try {
const ffmpegPath = path.join(directory, 'ffmpeg');
await writeFile(
ffmpegPath,
`#!${process.execPath}
const args = process.argv.slice(2);
if (args[args.indexOf('-map') + 1] === '0:2') process.exit(1);
require('node:fs').writeFileSync(args.at(-1), '1\\n00:00:10,000 --> 00:00:12,000\\nHello\\n\\n2\\n00:00:20,000 --> 00:00:22,000\\n♪ Song ♪\\n\\n3\\n00:00:30,000 --> 00:00:32,000\\nWorld\\n');
`,
{ mode: 0o755 },
);
const refs = subtitleGenerationReferences([{ ...embedded }, { ...embedded, 'ff-index': 3 }]);
const hints = await loadSubtitleGenerationReference({
references: refs,
mediaPath: '/video.mkv',
ffmpegPath,
directory,
audioOffset: 2.5,
});
assert.deepEqual(hints, [7.5, 27.5]);
assert.deepEqual(
await loadSubtitleGenerationReference({
references: refs.slice(0, 1),
mediaPath: '/video.mkv',
ffmpegPath,
directory,
audioOffset: 0,
}),
[],
);
await assert.rejects(
loadSubtitleGenerationReference({
references: refs,
mediaPath: '/video.mkv',
ffmpegPath,
directory,
audioOffset: 0,
signal: AbortSignal.abort(),
}),
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,169 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
import { parseSrtCues } from './subtitle-cue-parser';
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
export type SubtitleGenerationReference = {
label: string;
delaySeconds: number;
source: { kind: 'embedded'; streamIndex: number } | { kind: 'external'; path: string };
};
// Check both titles and filenames: releases often tag only one of them.
const EXCLUDED =
/(?:^|[^\p{L}\p{N}])(?:signs?(?:songs?)?|songs?|lyrics?|karaoke|forced|s[\s&+_-]*s|op|ed|opening|ending|generated)(?=$|[^\p{L}\p{N}])||/iu;
const TEXT_CODECS = new Set(['ass', 'ssa', 'subrip', 'srt', 'webvtt', 'mov_text', 'text']);
/** Rank loaded dialogue tracks, preferring English, then explicitly full tracks. */
export function subtitleGenerationReferences(
value: unknown,
workingDirectory?: string,
delays: ReadonlyMap<number, number> = new Map(),
): SubtitleGenerationReference[] {
if (!Array.isArray(value)) return [];
const tracks: unknown[] = value;
return tracks
.flatMap((track) => {
if (typeof track !== 'object' || track === null || !('type' in track) || track.type !== 'sub')
return [];
const title = 'title' in track && typeof track.title === 'string' ? track.title : '';
const filename =
'external-filename' in track && typeof track['external-filename'] === 'string'
? track['external-filename']
: '';
const name = `${title} ${path.basename(filename)}`;
if (('forced' in track && track.forced === true) || EXCLUDED.test(name)) return [];
if ('codec' in track && typeof track.codec === 'string' && !TEXT_CODECS.has(track.codec))
return [];
let source: SubtitleGenerationReference['source'];
if ('external' in track && track.external === true) {
let local = filename;
if (local.startsWith('file://')) {
try {
local = fileURLToPath(local);
} catch {
return [];
}
} else if (/^[a-z][a-z\d+.-]*:\/\//i.test(local)) return [];
if (!local || (!path.isAbsolute(local) && !workingDirectory)) return [];
if (!/\.(?:srt|ass|ssa|vtt)$/i.test(local)) return [];
source = { kind: 'external', path: path.resolve(workingDirectory ?? '.', local) };
} else {
if (
!('ff-index' in track) ||
typeof track['ff-index'] !== 'number' ||
!Number.isSafeInteger(track['ff-index']) ||
track['ff-index'] < 0
)
return [];
source = { kind: 'embedded', streamIndex: track['ff-index'] };
}
const language = 'lang' in track && typeof track.lang === 'string' ? track.lang : '';
const english =
/^(?:en|eng|english)(?:[-_]|$)/i.test(language) ||
/(?:^|[\s.\[(_-])(?:en|eng|english)(?=$|[\s.\])_-])/i.test(name);
const full = /\b(?:full|dialogue|dialog)\b/i.test(name);
const selected = 'selected' in track && track.selected === true;
const preferred = 'default' in track && track.default === true;
return [
{
reference: {
label:
title ||
path.basename(filename) ||
`${language || 'Subtitle'} stream ${source.kind === 'embedded' ? source.streamIndex : ''}`,
source,
delaySeconds:
'id' in track && typeof track.id === 'number' ? (delays.get(track.id) ?? 0) : 0,
},
score:
Number(english) * 100 + Number(full) * 20 + Number(selected) * 2 + Number(preferred),
},
];
})
.sort((a, b) => b.score - a.score)
.map(({ reference }) => reference);
}
/** Read mpv's path base and active subtitle delays while capturing timing references. */
export async function readSubtitleGenerationReferences(
tracks: unknown,
requestProperty: (name: string) => Promise<unknown>,
): Promise<SubtitleGenerationReference[]> {
const [directory, primary, secondary, primaryDelay, secondaryDelay] = await Promise.all(
['working-directory', 'sid', 'secondary-sid', 'sub-delay', 'secondary-sub-delay'].map((name) =>
requestProperty(name).catch(() => null),
),
);
const delays = new Map<number, number>();
for (const [id, delay] of [
[primary, primaryDelay],
[secondary, secondaryDelay],
]) {
if (typeof id === 'number' && typeof delay === 'number' && Number.isFinite(delay))
delays.set(id, delay);
}
return subtitleGenerationReferences(
tracks,
typeof directory === 'string' ? directory : undefined,
delays,
);
}
/** Reference timestamps are hints on the extracted audio timeline, never a coverage mask. */
export async function loadSubtitleGenerationReference(input: {
references: readonly SubtitleGenerationReference[];
mediaPath: string;
ffmpegPath: string;
directory: string;
audioOffset: number;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<number[]> {
for (const [index, reference] of input.references.entries()) {
input.signal?.throwIfAborted();
try {
const output = path.join(input.directory, `reference-${index}.srt`);
const embedded = reference.source.kind === 'embedded';
await runSubtitleGenerationProcess({
command: input.ffmpegPath,
args: [
'-nostdin',
'-hide_banner',
'-loglevel',
'error',
...(embedded ? ['-copyts', '-start_at_zero'] : []),
'-i',
reference.source.kind === 'external' ? reference.source.path : input.mediaPath,
'-map',
reference.source.kind === 'embedded' ? `0:${reference.source.streamIndex}` : '0:s:0',
'-c:s',
'srt',
output,
],
signal: input.signal,
});
const starts = parseSrtCues(await readFile(output, 'utf8'))
.filter((cue) => cue.text.trim() && !/[]/u.test(cue.text) && cue.endTime > cue.startTime)
.map((cue) => cue.startTime + reference.delaySeconds - input.audioOffset)
.filter((time) => Number.isFinite(time) && time >= 0);
if (starts.length === 0) continue;
input.onProgress?.({
stage: 'extract',
message: `Using subtitle timing reference: ${reference.label}`,
});
return [...new Set(starts)].sort((a, b) => a - b);
} catch {
input.signal?.throwIfAborted();
// An optional reference must not prevent transcription. Try the next loaded track.
}
}
if (input.references.length)
input.onProgress?.({
stage: 'extract',
message: 'No readable subtitle timing reference. Using audio timing.',
});
return [];
}
@@ -95,6 +95,52 @@ test('config parser accepts supported models and rejects unsafe threads and wron
assert.deepEqual(warnings, ['whisperPath', 'threads']); assert.deepEqual(warnings, ['whisperPath', 'threads']);
}); });
test('generation uses reference cuts with and without VAD while preserving media offsets', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const ffmpegPath = await executable(
directory,
'reference-ffmpeg',
`
const fs = require('node:fs');
const args = process.argv.slice(2);
fs.appendFileSync(${JSON.stringify(input.callsPath)}, JSON.stringify(args) + '\\n');
if (args.includes('-c:s')) {
fs.writeFileSync(args.at(-1), '1\\n00:00:24,500 --> 00:00:26,500\\nHello\\n\\n2\\n00:00:45,500 --> 00:00:47,500\\nWorld\\n');
} else if (args.at(-1) === '-') {
process.stdout.write('out_time_us=70000000\\nprogress=end\\n');
} else fs.writeFileSync(args.at(-1), 'wav');
`,
);
const vadPath = await executable(
directory,
'reference-vad',
"process.stdout.write('Detected 1 speech segments:\\nSpeech segment 0: start = 0.000, end = 7000.000\\n');",
);
const vadModelPath = path.join(directory, 'vad.bin');
await writeFile(vadModelPath, 'model');
for (const vad of [false, true]) {
await writeFile(input.callsPath, '');
const output = await generateJapaneseSubtitles({
...input,
config: { ...input.config, ffmpegPath, vadPath, vadModelPath: vad ? vadModelPath : '' },
references: [
{ label: 'English Full', delaySeconds: 0, source: { kind: 'embedded', streamIndex: 5 } },
],
});
const calls: string[][] = (await readFile(input.callsPath, 'utf8'))
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const clips = calls.filter((args) => args.includes('-ss'));
assert.equal(clips[0]?.[clips[0].indexOf('-t') + 1], '22.25');
assert.equal(clips[1]?.[clips[1].indexOf('-ss') + 1], '21.75');
const srt = await readFile(output, 'utf8');
assert.match(srt, /00:00:03,500 --> 00:00:04,500/);
assert.match(srt, /00:00:25,250 --> 00:00:26,250/);
}
}));
test('external model path wins and invalid external models never fall back to download', () => test('external model path wins and invalid external models never fall back to download', () =>
fixture(async (directory) => { fixture(async (directory) => {
const input = await generationFixture(directory); const input = await generationFixture(directory);
+17 -2
View File
@@ -11,6 +11,10 @@ import { runSubtitleGenerationProcess } from './subtitle-generation-process';
import { publishSubtitleGenerationFile } from './subtitle-generation-files'; import { publishSubtitleGenerationFile } from './subtitle-generation-files';
import { formatTimestamp } from './subtitle-generation-srt'; import { formatTimestamp } from './subtitle-generation-srt';
import { transcribeSubtitleDialogue } from './subtitle-generation-dialogue'; import { transcribeSubtitleDialogue } from './subtitle-generation-dialogue';
import {
loadSubtitleGenerationReference,
type SubtitleGenerationReference,
} from './subtitle-generation-reference';
import { import {
requireSubtitleGenerationTools, requireSubtitleGenerationTools,
resolveSubtitleGenerationTools, resolveSubtitleGenerationTools,
@@ -174,6 +178,7 @@ export async function generateJapaneseSubtitles(input: {
modelDirectory: string; modelDirectory: string;
mediaPath: string; mediaPath: string;
audioStreamIndex?: number; audioStreamIndex?: number;
references?: readonly SubtitleGenerationReference[];
outputPath?: string; outputPath?: string;
onProgress?: (progress: SubtitleGenerationProgress) => void; onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal; signal?: AbortSignal;
@@ -257,11 +262,21 @@ export async function generateJapaneseSubtitles(input: {
percent: 0, percent: 0,
message: 'Generating Japanese subtitles...', message: 'Generating Japanese subtitles...',
}); });
const referenceStarts = await loadSubtitleGenerationReference({
references: input.references ?? [],
mediaPath,
ffmpegPath: tools.ffmpeg,
directory: temporaryDirectory,
audioOffset: audio.offset,
onProgress: input.onProgress,
signal: input.signal,
});
let srt: string; let srt: string;
if (tools.vad !== null) { if (tools.vad !== null || referenceStarts.length > 0) {
srt = await transcribeSubtitleDialogue({ srt = await transcribeSubtitleDialogue({
config: input.config, config: input.config,
tools: { ...tools, vad: tools.vad }, tools,
referenceStarts,
modelPath: model.path, modelPath: model.path,
wavPath, wavPath,
directory: temporaryDirectory, directory: temporaryDirectory,
@@ -23,6 +23,7 @@ function fixture(overrides: Partial<SubtitleGenerationRuntimeDeps> = {}) {
getModelDirectory: () => '/models', getModelDirectory: () => '/models',
getMpvClient: () => client, getMpvClient: () => client,
onProgress: () => {}, onProgress: () => {},
detectAcceleration: async () => ({ kind: 'unavailable' }),
resolveModel: async () => ({ kind: 'external', path: '/models/local.bin' }), resolveModel: async () => ({ kind: 'external', path: '/models/local.bin' }),
resolveTools: async (config) => ({ resolveTools: async (config) => ({
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' }, ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
@@ -71,6 +72,27 @@ test('generation preserves the output without attaching it to a different video'
assert.deepEqual(subject.commands, []); assert.deepEqual(subject.commands, []);
}); });
test('generation selects loaded dialogue references and excludes the signs track', async () => {
const subject = fixture({
generate: async (input) => {
assert.deepEqual(input.references, [
{ label: 'English Full', delaySeconds: 0, source: { kind: 'embedded', streamIndex: 5 } },
]);
return '/video/generated.srt';
},
});
const request = subject.client.requestProperty;
subject.client.requestProperty = async (name) =>
name === 'track-list'
? [
{ type: 'audio', selected: true, 'ff-index': 3 },
{ type: 'sub', lang: 'eng', title: 'Signs & Songs', 'ff-index': 4 },
{ type: 'sub', lang: 'eng', title: 'English Full', 'ff-index': 5 },
]
: request(name);
assert.equal((await subject.runtime.start()).ok, true);
});
test('mpv load failure still reports where the generated subtitles were saved', async () => { test('mpv load failure still reports where the generated subtitles were saved', async () => {
const subject = fixture(); const subject = fixture();
subject.client.request = async () => ({ error: 'loading failed' }); subject.client.request = async () => ({ error: 'loading failed' });
@@ -182,6 +204,40 @@ test('external model paths prevent managed selection, including unreadable overr
await assert.rejects(runtime.selectModel('medium'), /Clear Model Path/); await assert.rejects(runtime.selectModel('medium'), /Clear Model Path/);
}); });
test('CUDA recommendations preserve selected and configured models and follow the Whisper path', async () => {
let whisperPath = '/cuda/whisper-cli';
const checked: string[] = [];
const subject = fixture({
getConfig: () => ({ ...DEFAULT_SUBTITLE_GENERATION_CONFIG, managedModel: 'medium' }),
resolveTools: async () => ({
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' },
whisper: { kind: 'found', path: whisperPath },
vad: null,
}),
detectAcceleration: async (whisper) => {
assert.equal(whisper.kind, 'found');
if (whisper.kind !== 'found') throw new Error('Expected a Whisper executable');
checked.push(whisper.path);
return whisper.path.startsWith('/cuda/')
? { kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' }
: { kind: 'unavailable' };
},
});
const initial = await subject.runtime.getStatus();
assert.deepEqual(initial.acceleration, { kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' });
assert.equal(initial.managedModel, 'medium');
const selected = await subject.runtime.selectModel('small');
assert.equal(selected.managedModel, 'small');
assert.equal(selected.acceleration.kind, 'nvidia-cuda');
assert.deepEqual(checked, ['/cuda/whisper-cli']);
whisperPath = '/cpu/whisper-cli';
const changed = await subject.runtime.getStatus();
assert.equal(changed.acceleration.kind, 'unavailable');
assert.equal(changed.managedModel, 'small');
assert.deepEqual(checked, ['/cuda/whisper-cli', '/cpu/whisper-cli']);
});
test('status reports the speech detector only while dialogue mode is on', async () => { test('status reports the speech detector only while dialogue mode is on', async () => {
const { runtime } = fixture({ const { runtime } = fixture({
resolveVadModel: async () => ({ kind: 'managed', path: '/models/ggml-silero-v6.2.0.bin' }), resolveVadModel: async () => ({ kind: 'managed', path: '/models/ggml-silero-v6.2.0.bin' }),
@@ -1,4 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import { readSubtitleGenerationReferences } from '../../core/services/subtitle-generation-reference';
import { detectSubtitleGenerationAcceleration } from '../../core/services/subtitle-generation-acceleration';
import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model'; import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model';
import { import {
downloadSubtitleGenerationVadModel, downloadSubtitleGenerationVadModel,
@@ -35,6 +37,7 @@ export interface SubtitleGenerationRuntimeDeps {
download?: typeof downloadSubtitleGenerationModel; download?: typeof downloadSubtitleGenerationModel;
resolveModel?: typeof resolveSubtitleGenerationModel; resolveModel?: typeof resolveSubtitleGenerationModel;
resolveTools?: typeof resolveSubtitleGenerationTools; resolveTools?: typeof resolveSubtitleGenerationTools;
detectAcceleration?: typeof detectSubtitleGenerationAcceleration;
downloadVad?: typeof downloadSubtitleGenerationVadModel; downloadVad?: typeof downloadSubtitleGenerationVadModel;
resolveVadModel?: typeof resolveSubtitleGenerationVadModel; resolveVadModel?: typeof resolveSubtitleGenerationVadModel;
} }
@@ -80,6 +83,13 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
let lastResult: SubtitleGenerationResult | null = null; let lastResult: SubtitleGenerationResult | null = null;
let selectedModel: SubtitleGenerationModelId | null = null; let selectedModel: SubtitleGenerationModelId | null = null;
let vadEnabled: boolean | null = null; let vadEnabled: boolean | null = null;
let accelerationCheck:
| {
path: string;
expires: number;
result: ReturnType<typeof detectSubtitleGenerationAcceleration>;
}
| undefined;
function getConfig(): SubtitleGenerationConfig { function getConfig(): SubtitleGenerationConfig {
const config = deps.getConfig(); const config = deps.getConfig();
return { return {
@@ -127,6 +137,20 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
async function getStatus(): Promise<SubtitleGenerationStatus> { async function getStatus(): Promise<SubtitleGenerationStatus> {
const config = getConfig(); const config = getConfig();
const tools = await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config);
const whisperPath = tools.whisper.kind === 'found' ? tools.whisper.path : '';
if (
!accelerationCheck ||
accelerationCheck.path !== whisperPath ||
(!controller && Date.now() >= accelerationCheck.expires)
) {
accelerationCheck = {
path: whisperPath,
expires: Date.now() + 30_000,
result: (deps.detectAcceleration ?? detectSubtitleGenerationAcceleration)(tools.whisper),
};
}
const acceleration = await accelerationCheck.result;
const model = await (deps.resolveModel ?? resolveSubtitleGenerationModel)( const model = await (deps.resolveModel ?? resolveSubtitleGenerationModel)(
config, config,
deps.getModelDirectory(), deps.getModelDirectory(),
@@ -142,7 +166,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
), ),
}, },
// Session toggles decide whether the speech detector executable is required. // Session toggles decide whether the speech detector executable is required.
tools: await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config), tools,
acceleration,
managedModel: config.managedModel, managedModel: config.managedModel,
externalModelPath: config.modelPath.trim() || null, externalModelPath: config.modelPath.trim() || null,
mediaPath, mediaPath,
@@ -214,7 +239,11 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
const mediaPath = await currentLocalMedia(client); const mediaPath = await currentLocalMedia(client);
if (!client || !mediaPath) if (!client || !mediaPath)
throw new Error('Open a local video or audio file in mpv first.'); throw new Error('Open a local video or audio file in mpv first.');
const audioStreamIndex = selectedAudioIndex(await client.requestProperty('track-list')); const tracks = await client.requestProperty('track-list');
const audioStreamIndex = selectedAudioIndex(tracks);
const references = await readSubtitleGenerationReferences(tracks, (name) =>
client.requestProperty(name),
);
if ((await currentLocalMedia(client)) !== mediaPath) if ((await currentLocalMedia(client)) !== mediaPath)
throw new Error('The current media changed. Start generation again.'); throw new Error('The current media changed. Start generation again.');
signal.throwIfAborted(); signal.throwIfAborted();
@@ -223,6 +252,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
modelDirectory: deps.getModelDirectory(), modelDirectory: deps.getModelDirectory(),
mediaPath, mediaPath,
audioStreamIndex, audioStreamIndex,
references,
onProgress: report, onProgress: report,
signal, signal,
}); });
@@ -3,10 +3,25 @@ import test from 'node:test';
import { import {
describeGenerationModel, describeGenerationModel,
describeGenerationProgress, describeGenerationProgress,
describeGenerationRecommendation,
describeGenerationTools, describeGenerationTools,
describeGenerationVad, describeGenerationVad,
} from './subtitle-generation-view'; } from './subtitle-generation-view';
test('only confirmed NVIDIA CUDA support recommends turbo', () => {
assert.deepEqual(describeGenerationRecommendation({ kind: 'unavailable' }), {
model: 'small',
text: 'NVIDIA CUDA acceleration was not confirmed. small is recommended.',
});
assert.deepEqual(
describeGenerationRecommendation({ kind: 'nvidia-cuda', gpuName: 'NVIDIA RTX 5070 Ti' }),
{
model: 'large-v3-turbo',
text: 'NVIDIA CUDA is available with NVIDIA RTX 5070 Ti. large-v3-turbo is recommended.',
},
);
});
test('missing tools block generation and list every install instruction', () => { test('missing tools block generation and list every install instruction', () => {
const found = { kind: 'found', path: '/usr/bin/tool' } as const; const found = { kind: 'found', path: '/usr/bin/tool' } as const;
assert.deepEqual( assert.deepEqual(
@@ -1,11 +1,23 @@
import { import {
missingSubtitleGenerationTools, missingSubtitleGenerationTools,
recommendedSubtitleGenerationModel,
type SubtitleGenerationAcceleration,
type SubtitleGenerationModelStatus, type SubtitleGenerationModelStatus,
type SubtitleGenerationProgress, type SubtitleGenerationProgress,
type SubtitleGenerationTools, type SubtitleGenerationTools,
} from '../../shared/subtitle-generation'; } from '../../shared/subtitle-generation';
import type { SubtitleGenerationStatus } from '../../shared/subtitle-generation-ipc'; import type { SubtitleGenerationStatus } from '../../shared/subtitle-generation-ipc';
export function describeGenerationRecommendation(acceleration: SubtitleGenerationAcceleration) {
return {
model: recommendedSubtitleGenerationModel(acceleration),
text:
acceleration.kind === 'nvidia-cuda'
? `NVIDIA CUDA is available with ${acceleration.gpuName}. large-v3-turbo is recommended.`
: 'NVIDIA CUDA acceleration was not confirmed. small is recommended.',
};
}
export function describeGenerationTools(tools: SubtitleGenerationTools) { export function describeGenerationTools(tools: SubtitleGenerationTools) {
const missing = missingSubtitleGenerationTools(tools); const missing = missingSubtitleGenerationTools(tools);
if (missing.length > 0) return { ready: false, text: missing.join(' ') }; if (missing.length > 0) return { ready: false, text: missing.join(' ') };
+10 -6
View File
@@ -1,7 +1,6 @@
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation'; import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
import { import {
SUBTITLE_GENERATION_MODELS, SUBTITLE_GENERATION_MODELS,
RECOMMENDED_SUBTITLE_GENERATION_MODEL,
formatSubtitleGenerationModelSize, formatSubtitleGenerationModelSize,
getSubtitleGenerationModel, getSubtitleGenerationModel,
isSubtitleGenerationModelId, isSubtitleGenerationModelId,
@@ -16,6 +15,7 @@ import { createModalFocusGuard } from './modal-focus-guard';
import { import {
describeGenerationModel, describeGenerationModel,
describeGenerationProgress, describeGenerationProgress,
describeGenerationRecommendation,
describeGenerationTools, describeGenerationTools,
describeGenerationVad, describeGenerationVad,
} from './subtitle-generation-view'; } from './subtitle-generation-view';
@@ -70,8 +70,7 @@ export function createSubtitleGenerationModal(
for (const model of SUBTITLE_GENERATION_MODELS) { for (const model of SUBTITLE_GENERATION_MODELS) {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = model.id; option.value = model.id;
const recommended = model.id === RECOMMENDED_SUBTITLE_GENERATION_MODEL ? ' (recommended)' : ''; option.textContent = `${model.id} · ${formatSubtitleGenerationModelSize(model.size)}`;
option.textContent = `${model.id}${recommended} · ${formatSubtitleGenerationModelSize(model.size)}`;
dom.modelSelect.append(option); dom.modelSelect.append(option);
} }
@@ -103,10 +102,15 @@ export function createSubtitleGenerationModal(
dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath)); dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath));
dom.modelSelect.disabled = busy || checking || !snapshot || Boolean(snapshot.externalModelPath); dom.modelSelect.disabled = busy || checking || !snapshot || Boolean(snapshot.externalModelPath);
if (snapshot) { if (snapshot) {
const recommendation = describeGenerationRecommendation(snapshot.acceleration);
for (const option of dom.modelSelect.options) {
if (!isSubtitleGenerationModelId(option.value)) continue;
const model = getSubtitleGenerationModel(option.value);
const recommended = model.id === recommendation.model ? ' (recommended)' : '';
option.textContent = `${model.id}${recommended} · ${formatSubtitleGenerationModelSize(model.size)}`;
}
dom.modelSelect.value = snapshot.managedModel; dom.modelSelect.value = snapshot.managedModel;
dom.modelDescription.textContent = getSubtitleGenerationModel( dom.modelDescription.textContent = `${getSubtitleGenerationModel(snapshot.managedModel).description} ${recommendation.text}`;
snapshot.managedModel,
).description;
} }
dom.download.classList.toggle('hidden', !model?.download); dom.download.classList.toggle('hidden', !model?.download);
dom.download.textContent = snapshot dom.download.textContent = snapshot
+2
View File
@@ -1,4 +1,5 @@
import type { import type {
SubtitleGenerationAcceleration,
SubtitleGenerationConfig, SubtitleGenerationConfig,
SubtitleGenerationModelStatus, SubtitleGenerationModelStatus,
SubtitleGenerationProgress, SubtitleGenerationProgress,
@@ -13,6 +14,7 @@ export interface SubtitleGenerationStatus {
model: SubtitleGenerationModelStatus; model: SubtitleGenerationModelStatus;
vad: { enabled: boolean; model: SubtitleGenerationModelStatus }; vad: { enabled: boolean; model: SubtitleGenerationModelStatus };
tools: SubtitleGenerationTools; tools: SubtitleGenerationTools;
acceleration: SubtitleGenerationAcceleration;
managedModel: SubtitleGenerationConfig['managedModel']; managedModel: SubtitleGenerationConfig['managedModel'];
externalModelPath: string | null; externalModelPath: string | null;
mediaPath: string | null; mediaPath: string | null;
@@ -45,7 +45,7 @@ const MODEL_CATALOG = {
id: 'small', id: 'small',
size: 487601967, size: 487601967,
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b', sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b',
description: 'Recommended starting point for accuracy and processing time.', description: 'Balances accuracy and processing time with modest memory requirements.',
}, },
'small-q5_1': { 'small-q5_1': {
id: 'small-q5_1', id: 'small-q5_1',
+10
View File
@@ -41,6 +41,16 @@ export type SubtitleGenerationToolStatus =
| { kind: 'found'; path: string } | { kind: 'found'; path: string }
| { kind: 'missing'; message: string }; | { kind: 'missing'; message: string };
export type SubtitleGenerationAcceleration =
| { kind: 'nvidia-cuda'; gpuName: string }
| { kind: 'unavailable' };
export function recommendedSubtitleGenerationModel(acceleration: SubtitleGenerationAcceleration) {
return acceleration.kind === 'nvidia-cuda'
? 'large-v3-turbo'
: RECOMMENDED_SUBTITLE_GENERATION_MODEL;
}
/** Executables generation depends on. `vad` is null unless dialogue mode is on. */ /** Executables generation depends on. `vad` is null unless dialogue mode is on. */
export interface SubtitleGenerationTools { export interface SubtitleGenerationTools {
ffmpeg: SubtitleGenerationToolStatus; ffmpeg: SubtitleGenerationToolStatus;