mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
feat(subtitles): use loaded subtitles to guide generation timing (#249)
This commit is contained in:
@@ -2,3 +2,4 @@ type: docs
|
||||
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.
|
||||
- Document automatic loaded subtitle references, English dialogue preference, signs/songs filtering, fallback behavior, and the limits of reference-guided timing.
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
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).
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
subminer generate-subs video.mkv --download-model
|
||||
subminer generate-subs video.mkv --model-path ~/models/ggml-medium.bin
|
||||
|
||||
@@ -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/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.
|
||||
- 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/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.
|
||||
|
||||
@@ -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();
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.generations[0]?.audioStreamIndex, undefined);
|
||||
assert.equal(
|
||||
f.commands.some((command) => command[1] === 'track-list'),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('launcher does not load generated subtitles after mpv switches files', async () => {
|
||||
const f = fixture(['generate-subs']);
|
||||
let pathRequests = 0;
|
||||
let changed = false;
|
||||
f.deps.mpvCommand = async (_socket, command) => {
|
||||
f.commands.push(command);
|
||||
if (command[1] === 'path')
|
||||
return ++pathRequests === 1 ? '/media/episode.mkv' : '/media/next.mkv';
|
||||
if (command[1] === 'path') return changed ? '/media/next.mkv' : '/media/episode.mkv';
|
||||
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);
|
||||
assert.equal(
|
||||
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/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const f = fixture([
|
||||
'generate-subs',
|
||||
@@ -191,6 +274,69 @@ test('explicit managed model overrides external config and downloads before gene
|
||||
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 () => {
|
||||
for (const playing of [null, '/media/different.mkv']) {
|
||||
const f = fixture();
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
resolveSubtitleGenerationTools,
|
||||
} from '../../src/core/services/subtitle-generation.js';
|
||||
import { requireSubtitleGenerationTools } from '../../src/core/services/subtitle-generation-tools.js';
|
||||
import {
|
||||
readSubtitleGenerationReferences,
|
||||
type SubtitleGenerationReference,
|
||||
} from '../../src/core/services/subtitle-generation-reference.js';
|
||||
import {
|
||||
resolveSubtitleGenerationConfig,
|
||||
type SubtitleGenerationProgress,
|
||||
@@ -69,12 +73,9 @@ async function readMpvMedia(socketPath: string, command: GenerationCommandDeps['
|
||||
return localMediaPath(media, workingDirectory);
|
||||
}
|
||||
|
||||
async function readMpvAudioStream(
|
||||
socketPath: string,
|
||||
command: GenerationCommandDeps['mpvCommand'],
|
||||
) {
|
||||
const tracks = await command(socketPath, ['get_property', 'track-list'], 1000);
|
||||
for (const track of Array.isArray(tracks) ? tracks : []) {
|
||||
function selectedMpvAudioStream(value: unknown) {
|
||||
const tracks: unknown[] = Array.isArray(value) ? value : [];
|
||||
for (const track of tracks) {
|
||||
if (
|
||||
typeof track === 'object' &&
|
||||
track !== null &&
|
||||
@@ -160,11 +161,29 @@ export async function runGenerateSubtitlesCommand(
|
||||
const mediaPath = options.mediaPath ? localMediaPath(options.mediaPath) : currentMedia;
|
||||
if (!mediaPath)
|
||||
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 =
|
||||
options.audioStreamIndex ??
|
||||
(!options.mediaPath
|
||||
? await readMpvAudioStream(context.mpvSocketPath, deps.mpvCommand)
|
||||
: undefined);
|
||||
options.audioStreamIndex ?? (!options.mediaPath ? selectedMpvAudioStream(tracks) : undefined);
|
||||
const onProgress = createGenerationProgressReporter(write);
|
||||
// Missing executables fail here, before any model download starts.
|
||||
requireSubtitleGenerationTools(await deps.resolveTools(config));
|
||||
@@ -183,6 +202,7 @@ export async function runGenerateSubtitlesCommand(
|
||||
modelDirectory,
|
||||
mediaPath,
|
||||
audioStreamIndex,
|
||||
references,
|
||||
outputPath: options.outputPath
|
||||
? path.resolve(resolvePathMaybe(options.outputPath))
|
||||
: 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 { 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', () => {
|
||||
const chunks = splitSpeechPassages(
|
||||
[{ startSeconds: 544.418, endSeconds: 581.581 }],
|
||||
|
||||
@@ -5,11 +5,12 @@ const CHUNK_CONTEXT_SECONDS = 0.25;
|
||||
const WHISPER_WINDOW_SECONDS = 30;
|
||||
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(
|
||||
passages: readonly SpeechPassage[],
|
||||
pauses: readonly number[] = [],
|
||||
speechStarts: readonly number[] = [],
|
||||
referenceStarts: readonly number[] = [],
|
||||
): SpeechPassage[] {
|
||||
return passages.flatMap((passage) => {
|
||||
if (passage.endSeconds - passage.startSeconds <= WHISPER_WINDOW_SECONDS)
|
||||
@@ -22,17 +23,19 @@ export function splitSpeechPassages(
|
||||
if (target < passage.endSeconds) {
|
||||
// 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.
|
||||
let nearestSpeechStart: number | undefined;
|
||||
for (const time of speechStarts) {
|
||||
if (
|
||||
time >= target - PAUSE_SEARCH_SECONDS &&
|
||||
time <= target + PAUSE_SEARCH_SECONDS &&
|
||||
time < passage.endSeconds &&
|
||||
(nearestSpeechStart === undefined ||
|
||||
Math.abs(time - target) < Math.abs(nearestSpeechStart - target))
|
||||
)
|
||||
nearestSpeechStart = time;
|
||||
}
|
||||
const nearestStart = (starts: readonly number[]): number | undefined => {
|
||||
let nearest: number | undefined;
|
||||
for (const time of starts) {
|
||||
if (
|
||||
time >= target - PAUSE_SEARCH_SECONDS &&
|
||||
time <= target + PAUSE_SEARCH_SECONDS &&
|
||||
time < passage.endSeconds &&
|
||||
(nearest === undefined || Math.abs(time - target) < Math.abs(nearest - target))
|
||||
)
|
||||
nearest = time;
|
||||
}
|
||||
return nearest;
|
||||
};
|
||||
let latestPause: number | undefined;
|
||||
for (const time of pauses) {
|
||||
if (
|
||||
@@ -42,7 +45,7 @@ export function splitSpeechPassages(
|
||||
)
|
||||
latestPause = time;
|
||||
}
|
||||
end = nearestSpeechStart ?? latestPause ?? end;
|
||||
end = nearestStart(referenceStarts) ?? nearestStart(speechStarts) ?? latestPause ?? end;
|
||||
}
|
||||
chunks.push({
|
||||
startSeconds: Math.max(passage.startSeconds, boundary - CHUNK_CONTEXT_SECONDS),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
parseSpeechPassages,
|
||||
speechPassageCues,
|
||||
SPEECH_PASSAGE_SECONDS,
|
||||
type SpeechPassage,
|
||||
} from './subtitle-generation-speech';
|
||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||
import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks';
|
||||
@@ -21,46 +22,50 @@ import { findAudiblePassages, mergeSpeechPassages } from './subtitle-generation-
|
||||
|
||||
export async function transcribeSubtitleDialogue(input: {
|
||||
config: SubtitleGenerationConfig;
|
||||
tools: SubtitleGenerationToolPaths & { vad: string };
|
||||
tools: SubtitleGenerationToolPaths;
|
||||
referenceStarts?: readonly number[];
|
||||
modelPath: string;
|
||||
wavPath: string;
|
||||
directory: string;
|
||||
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const vadModelPath = expandSubtitleGenerationPath(input.config.vadModelPath);
|
||||
await access(vadModelPath, constants.R_OK);
|
||||
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' });
|
||||
const segmentLines: string[] = [];
|
||||
await runSubtitleGenerationProcess({
|
||||
command: input.tools.vad,
|
||||
args: [
|
||||
'-f',
|
||||
input.wavPath,
|
||||
'-vm',
|
||||
vadModelPath,
|
||||
'-t',
|
||||
String(input.config.threads),
|
||||
'-vt',
|
||||
'0.3',
|
||||
'--vad-min-speech-duration-ms',
|
||||
'100',
|
||||
'--vad-min-silence-duration-ms',
|
||||
'500',
|
||||
'-vp',
|
||||
'350',
|
||||
'-vmsd',
|
||||
String(SPEECH_PASSAGE_SECONDS),
|
||||
'-np',
|
||||
],
|
||||
signal: input.signal,
|
||||
// Capture structured result lines separately from the bounded process log.
|
||||
onLine: (line) => {
|
||||
if (line.startsWith('Detected ') || line.startsWith('Speech segment '))
|
||||
segmentLines.push(line);
|
||||
},
|
||||
});
|
||||
const speech = parseSpeechPassages(segmentLines.join('\n'));
|
||||
let speech: SpeechPassage[] = [];
|
||||
if (input.tools.vad !== null) {
|
||||
const vadModelPath = expandSubtitleGenerationPath(input.config.vadModelPath);
|
||||
await access(vadModelPath, constants.R_OK);
|
||||
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' });
|
||||
const segmentLines: string[] = [];
|
||||
await runSubtitleGenerationProcess({
|
||||
command: input.tools.vad,
|
||||
args: [
|
||||
'-f',
|
||||
input.wavPath,
|
||||
'-vm',
|
||||
vadModelPath,
|
||||
'-t',
|
||||
String(input.config.threads),
|
||||
'-vt',
|
||||
'0.3',
|
||||
'--vad-min-speech-duration-ms',
|
||||
'100',
|
||||
'--vad-min-silence-duration-ms',
|
||||
'500',
|
||||
'-vp',
|
||||
'350',
|
||||
'-vmsd',
|
||||
String(SPEECH_PASSAGE_SECONDS),
|
||||
'-np',
|
||||
],
|
||||
signal: input.signal,
|
||||
// Capture structured result lines separately from the bounded process log.
|
||||
onLine: (line) => {
|
||||
if (line.startsWith('Detected ') || line.startsWith('Speech segment '))
|
||||
segmentLines.push(line);
|
||||
},
|
||||
});
|
||||
speech = parseSpeechPassages(segmentLines.join('\n'));
|
||||
}
|
||||
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Checking audio coverage...' });
|
||||
const audible = await findAudiblePassages({
|
||||
ffmpegPath: input.tools.ffmpeg,
|
||||
@@ -82,6 +87,7 @@ export async function transcribeSubtitleDialogue(input: {
|
||||
detected,
|
||||
pauses,
|
||||
speech.map((passage) => passage.startSeconds),
|
||||
input.referenceStarts,
|
||||
);
|
||||
const cues: SubtitleCue[] = [];
|
||||
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']);
|
||||
});
|
||||
|
||||
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', () =>
|
||||
fixture(async (directory) => {
|
||||
const input = await generationFixture(directory);
|
||||
|
||||
@@ -11,6 +11,10 @@ import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
||||
import { publishSubtitleGenerationFile } from './subtitle-generation-files';
|
||||
import { formatTimestamp } from './subtitle-generation-srt';
|
||||
import { transcribeSubtitleDialogue } from './subtitle-generation-dialogue';
|
||||
import {
|
||||
loadSubtitleGenerationReference,
|
||||
type SubtitleGenerationReference,
|
||||
} from './subtitle-generation-reference';
|
||||
import {
|
||||
requireSubtitleGenerationTools,
|
||||
resolveSubtitleGenerationTools,
|
||||
@@ -174,6 +178,7 @@ export async function generateJapaneseSubtitles(input: {
|
||||
modelDirectory: string;
|
||||
mediaPath: string;
|
||||
audioStreamIndex?: number;
|
||||
references?: readonly SubtitleGenerationReference[];
|
||||
outputPath?: string;
|
||||
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
@@ -257,11 +262,21 @@ export async function generateJapaneseSubtitles(input: {
|
||||
percent: 0,
|
||||
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;
|
||||
if (tools.vad !== null) {
|
||||
if (tools.vad !== null || referenceStarts.length > 0) {
|
||||
srt = await transcribeSubtitleDialogue({
|
||||
config: input.config,
|
||||
tools: { ...tools, vad: tools.vad },
|
||||
tools,
|
||||
referenceStarts,
|
||||
modelPath: model.path,
|
||||
wavPath,
|
||||
directory: temporaryDirectory,
|
||||
|
||||
@@ -23,6 +23,7 @@ function fixture(overrides: Partial<SubtitleGenerationRuntimeDeps> = {}) {
|
||||
getModelDirectory: () => '/models',
|
||||
getMpvClient: () => client,
|
||||
onProgress: () => {},
|
||||
detectAcceleration: async () => ({ kind: 'unavailable' }),
|
||||
resolveModel: async () => ({ kind: 'external', path: '/models/local.bin' }),
|
||||
resolveTools: async (config) => ({
|
||||
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, []);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const subject = fixture();
|
||||
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/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { runtime } = fixture({
|
||||
resolveVadModel: async () => ({ kind: 'managed', path: '/models/ggml-silero-v6.2.0.bin' }),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 {
|
||||
downloadSubtitleGenerationVadModel,
|
||||
@@ -35,6 +37,7 @@ export interface SubtitleGenerationRuntimeDeps {
|
||||
download?: typeof downloadSubtitleGenerationModel;
|
||||
resolveModel?: typeof resolveSubtitleGenerationModel;
|
||||
resolveTools?: typeof resolveSubtitleGenerationTools;
|
||||
detectAcceleration?: typeof detectSubtitleGenerationAcceleration;
|
||||
downloadVad?: typeof downloadSubtitleGenerationVadModel;
|
||||
resolveVadModel?: typeof resolveSubtitleGenerationVadModel;
|
||||
}
|
||||
@@ -80,6 +83,13 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
let lastResult: SubtitleGenerationResult | null = null;
|
||||
let selectedModel: SubtitleGenerationModelId | null = null;
|
||||
let vadEnabled: boolean | null = null;
|
||||
let accelerationCheck:
|
||||
| {
|
||||
path: string;
|
||||
expires: number;
|
||||
result: ReturnType<typeof detectSubtitleGenerationAcceleration>;
|
||||
}
|
||||
| undefined;
|
||||
function getConfig(): SubtitleGenerationConfig {
|
||||
const config = deps.getConfig();
|
||||
return {
|
||||
@@ -127,6 +137,20 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
|
||||
async function getStatus(): Promise<SubtitleGenerationStatus> {
|
||||
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)(
|
||||
config,
|
||||
deps.getModelDirectory(),
|
||||
@@ -142,7 +166,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
),
|
||||
},
|
||||
// Session toggles decide whether the speech detector executable is required.
|
||||
tools: await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config),
|
||||
tools,
|
||||
acceleration,
|
||||
managedModel: config.managedModel,
|
||||
externalModelPath: config.modelPath.trim() || null,
|
||||
mediaPath,
|
||||
@@ -214,7 +239,11 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
const mediaPath = await currentLocalMedia(client);
|
||||
if (!client || !mediaPath)
|
||||
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)
|
||||
throw new Error('The current media changed. Start generation again.');
|
||||
signal.throwIfAborted();
|
||||
@@ -223,6 +252,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
modelDirectory: deps.getModelDirectory(),
|
||||
mediaPath,
|
||||
audioStreamIndex,
|
||||
references,
|
||||
onProgress: report,
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -3,10 +3,25 @@ import test from 'node:test';
|
||||
import {
|
||||
describeGenerationModel,
|
||||
describeGenerationProgress,
|
||||
describeGenerationRecommendation,
|
||||
describeGenerationTools,
|
||||
describeGenerationVad,
|
||||
} 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', () => {
|
||||
const found = { kind: 'found', path: '/usr/bin/tool' } as const;
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import {
|
||||
missingSubtitleGenerationTools,
|
||||
recommendedSubtitleGenerationModel,
|
||||
type SubtitleGenerationAcceleration,
|
||||
type SubtitleGenerationModelStatus,
|
||||
type SubtitleGenerationProgress,
|
||||
type SubtitleGenerationTools,
|
||||
} from '../../shared/subtitle-generation';
|
||||
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) {
|
||||
const missing = missingSubtitleGenerationTools(tools);
|
||||
if (missing.length > 0) return { ready: false, text: missing.join(' ') };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
|
||||
import {
|
||||
SUBTITLE_GENERATION_MODELS,
|
||||
RECOMMENDED_SUBTITLE_GENERATION_MODEL,
|
||||
formatSubtitleGenerationModelSize,
|
||||
getSubtitleGenerationModel,
|
||||
isSubtitleGenerationModelId,
|
||||
@@ -16,6 +15,7 @@ import { createModalFocusGuard } from './modal-focus-guard';
|
||||
import {
|
||||
describeGenerationModel,
|
||||
describeGenerationProgress,
|
||||
describeGenerationRecommendation,
|
||||
describeGenerationTools,
|
||||
describeGenerationVad,
|
||||
} from './subtitle-generation-view';
|
||||
@@ -70,8 +70,7 @@ export function createSubtitleGenerationModal(
|
||||
for (const model of SUBTITLE_GENERATION_MODELS) {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.id;
|
||||
const recommended = model.id === RECOMMENDED_SUBTITLE_GENERATION_MODEL ? ' (recommended)' : '';
|
||||
option.textContent = `${model.id}${recommended} · ${formatSubtitleGenerationModelSize(model.size)}`;
|
||||
option.textContent = `${model.id} · ${formatSubtitleGenerationModelSize(model.size)}`;
|
||||
dom.modelSelect.append(option);
|
||||
}
|
||||
|
||||
@@ -103,10 +102,15 @@ export function createSubtitleGenerationModal(
|
||||
dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath));
|
||||
dom.modelSelect.disabled = busy || checking || !snapshot || Boolean(snapshot.externalModelPath);
|
||||
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.modelDescription.textContent = getSubtitleGenerationModel(
|
||||
snapshot.managedModel,
|
||||
).description;
|
||||
dom.modelDescription.textContent = `${getSubtitleGenerationModel(snapshot.managedModel).description} ${recommendation.text}`;
|
||||
}
|
||||
dom.download.classList.toggle('hidden', !model?.download);
|
||||
dom.download.textContent = snapshot
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
SubtitleGenerationAcceleration,
|
||||
SubtitleGenerationConfig,
|
||||
SubtitleGenerationModelStatus,
|
||||
SubtitleGenerationProgress,
|
||||
@@ -13,6 +14,7 @@ export interface SubtitleGenerationStatus {
|
||||
model: SubtitleGenerationModelStatus;
|
||||
vad: { enabled: boolean; model: SubtitleGenerationModelStatus };
|
||||
tools: SubtitleGenerationTools;
|
||||
acceleration: SubtitleGenerationAcceleration;
|
||||
managedModel: SubtitleGenerationConfig['managedModel'];
|
||||
externalModelPath: string | null;
|
||||
mediaPath: string | null;
|
||||
|
||||
@@ -45,7 +45,7 @@ const MODEL_CATALOG = {
|
||||
id: 'small',
|
||||
size: 487601967,
|
||||
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b',
|
||||
description: 'Recommended starting point for accuracy and processing time.',
|
||||
description: 'Balances accuracy and processing time with modest memory requirements.',
|
||||
},
|
||||
'small-q5_1': {
|
||||
id: 'small-q5_1',
|
||||
|
||||
@@ -41,6 +41,16 @@ export type SubtitleGenerationToolStatus =
|
||||
| { kind: 'found'; path: 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. */
|
||||
export interface SubtitleGenerationTools {
|
||||
ffmpeg: SubtitleGenerationToolStatus;
|
||||
|
||||
Reference in New Issue
Block a user