diff --git a/changes/japanese-subtitle-generation.md b/changes/japanese-subtitle-generation.md index 76bbf514..4cfe9891 100644 --- a/changes/japanese-subtitle-generation.md +++ b/changes/japanese-subtitle-generation.md @@ -2,5 +2,5 @@ type: added 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. +- 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 is writable 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. With the detector executable installed, retain brief utterances and extra audio around speech, split long passages near quiet pauses with overlapping context, and combine duplicate cues by greatest timing overlap. Keep original media timing and separate repeated dialogue without spanning omitted music breaks. diff --git a/config.example.jsonc b/config.example.jsonc index bdfc9978..2acdc535 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -20,7 +20,7 @@ "ffmpegPath": "", // Optional FFmpeg path override for audio extraction. Leave empty to find ffmpeg on PATH. "ffprobePath": "", // Optional FFprobe path override for audio tracks and timing. Leave empty to find ffprobe on PATH. "vadModelPath": "", // Path to a whisper.cpp Silero VAD model. Enables dialogue-focused generation from separate speech passages. Leave empty to transcribe the full audio, including songs. - "vadPath": "" // Optional speech detector executable override. With vadModelPath configured, leave empty to find whisper-vad-speech-segments on PATH. + "vadPath": "" // Optional speech detector executable override. With vadModelPath configured, leave empty to find whisper-vad-speech-segments or vad-speech-segments on PATH. }, // Generate timed Japanese subtitles from local audio using whisper.cpp. // ========================================== diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index bdfc9978..2acdc535 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -20,7 +20,7 @@ "ffmpegPath": "", // Optional FFmpeg path override for audio extraction. Leave empty to find ffmpeg on PATH. "ffprobePath": "", // Optional FFprobe path override for audio tracks and timing. Leave empty to find ffprobe on PATH. "vadModelPath": "", // Path to a whisper.cpp Silero VAD model. Enables dialogue-focused generation from separate speech passages. Leave empty to transcribe the full audio, including songs. - "vadPath": "" // Optional speech detector executable override. With vadModelPath configured, leave empty to find whisper-vad-speech-segments on PATH. + "vadPath": "" // Optional speech detector executable override. With vadModelPath configured, leave empty to find whisper-vad-speech-segments or vad-speech-segments on PATH. }, // Generate timed Japanese subtitles from local audio using whisper.cpp. // ========================================== diff --git a/docs-site/subtitle-generation.md b/docs-site/subtitle-generation.md index 7141e211..f2b2f139 100644 --- a/docs-site/subtitle-generation.md +++ b/docs-site/subtitle-generation.md @@ -6,6 +6,8 @@ Generate Japanese SRT subtitles from a local video's audio using [whisper.cpp](h Install whisper.cpp's `whisper-cli` executable and FFmpeg, including `ffprobe`. SubMiner downloads models, not these executables. Leave `whisperPath`, `ffmpegPath`, and `ffprobePath` empty to find the executables on `PATH`. To use a specific installation, set a path override under **Settings → Integrations → Japanese Subtitle Generation**. +The generation modal checks for these executables under **Local tools** and keeps **Generate subtitles** disabled until every required one is found, naming the missing executable and its setting. Model downloads stay available in the meantime. After installing a tool or changing a path, click **Check again**. The launcher runs the same check before any model download. Generation also confirms the destination directory is writable before extracting audio. + Choose one model source: - Set `subtitleGeneration.modelPath` to an existing **multilingual whisper.cpp GGML `.bin` model**. Python Whisper checkpoints and English-only models are not suitable for Japanese transcription. @@ -19,7 +21,7 @@ See the [generated configuration example](/config.example.jsonc) for current def To focus on dialogue, check the optional **Focus on spoken dialogue** box in the generation modal. If the speech detection model is missing, click **Download speech detection model** to install it. This separate download uses the same progress, cancellation, and integrity checks as Whisper downloads. Checking the box never downloads automatically, and leaving it unchecked lets you generate without the Silero model. -You also need whisper.cpp's [speech segment detector](https://github.com/ggml-org/whisper.cpp/tree/master/examples/vad-speech-segments). SubMiner downloads the model, not this executable. The detector is found as `whisper-vad-speech-segments` on `PATH`. Builds from the upstream source may name it `vad-speech-segments`; set `vadPath` in **Settings → Integrations → Japanese Subtitle Generation** when needed. +You also need whisper.cpp's [speech segment detector](https://github.com/ggml-org/whisper.cpp/tree/master/examples/vad-speech-segments). SubMiner downloads the model, not this executable. The detector is found as `whisper-vad-speech-segments` or, for builds from the upstream source, `vad-speech-segments` on `PATH`. Set `vadPath` in **Settings → Integrations → Japanese Subtitle Generation** for any other location. With **Focus on spoken dialogue** checked, the modal's **Local tools** check requires the detector too. The checkbox choice lasts for the current SubMiner session, including closing and reopening the modal. To make dialogue mode your default, set `vadModelPath` in Settings to a [Silero GGML VAD model](https://huggingface.co/ggml-org/whisper-vad/tree/main). The modal downloads `ggml-silero-v6.2.0.bin` into the same `models/whisper/` directory as managed Whisper models. An existing configured VAD path takes precedence and checks the box initially. Unchecking it temporarily disables dialogue mode without changing that path. Downloading the model alone does not enable dialogue mode. diff --git a/launcher/commands/generate-subtitles-command.test.ts b/launcher/commands/generate-subtitles-command.test.ts index a3dc4f48..9f47632d 100644 --- a/launcher/commands/generate-subtitles-command.test.ts +++ b/launcher/commands/generate-subtitles-command.test.ts @@ -32,6 +32,12 @@ function fixture(argv: string[] = ['generate-subs', '/media/episode.mkv']) { readConfig: () => ({ subtitleGeneration: { modelPath: '/models/external.bin' } }), configPath: () => '/settings/SubMiner/config.jsonc', resolveModel: async () => ({ kind: 'external', path: '/models/external.bin' }), + resolveTools: async () => ({ + ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' }, + ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' }, + whisper: { kind: 'found', path: '/usr/bin/whisper-cli' }, + vad: null, + }), downloadModel: async () => { throw new Error('Unexpected model download'); }, @@ -92,6 +98,22 @@ test('launcher uses the shared core and selected mpv audio then loads the genera assert.equal(f.detached(), true); }); +test('launcher reports a missing executable before downloading a model', async () => { + const f = fixture(['generate-subs', '/media/episode.mkv', '--download-model']); + f.deps.resolveModel = async () => ({ kind: 'missing', path: '/models/missing.bin' }); + f.deps.resolveTools = async () => ({ + ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' }, + ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' }, + whisper: { kind: 'missing', message: 'whisper-cli was not found on PATH.' }, + vad: null, + }); + await assert.rejects( + runGenerateSubtitlesCommand(f.context, f.deps), + /whisper-cli was not found on PATH/, + ); + assert.equal(f.generations.length, 0); +}); + test('launcher never downloads a model without the explicit option', async () => { const f = fixture(); f.deps.resolveModel = async () => ({ kind: 'missing', path: '/models/missing.bin' }); diff --git a/launcher/commands/generate-subtitles-command.ts b/launcher/commands/generate-subtitles-command.ts index b58aaa2f..a98d9bff 100644 --- a/launcher/commands/generate-subtitles-command.ts +++ b/launcher/commands/generate-subtitles-command.ts @@ -5,7 +5,9 @@ import { downloadSubtitleGenerationModel, generateJapaneseSubtitles, resolveSubtitleGenerationModel, + resolveSubtitleGenerationTools, } from '../../src/core/services/subtitle-generation.js'; +import { requireSubtitleGenerationTools } from '../../src/core/services/subtitle-generation-tools.js'; import { resolveSubtitleGenerationConfig, type SubtitleGenerationProgress, @@ -26,6 +28,7 @@ interface GenerationCommandDeps { readConfig: typeof readLauncherMainConfigObject; configPath: typeof resolveLauncherMainConfigPath; resolveModel: typeof resolveSubtitleGenerationModel; + resolveTools: typeof resolveSubtitleGenerationTools; downloadModel: typeof downloadSubtitleGenerationModel; generate: typeof generateJapaneseSubtitles; mpvCommand: typeof sendMpvCommandWithResponse; @@ -36,6 +39,7 @@ const defaultDeps: GenerationCommandDeps = { readConfig: readLauncherMainConfigObject, configPath: resolveLauncherMainConfigPath, resolveModel: resolveSubtitleGenerationModel, + resolveTools: resolveSubtitleGenerationTools, downloadModel: downloadSubtitleGenerationModel, generate: generateJapaneseSubtitles, mpvCommand: sendMpvCommandWithResponse, @@ -162,6 +166,8 @@ export async function runGenerateSubtitlesCommand( ? await readMpvAudioStream(context.mpvSocketPath, deps.mpvCommand) : undefined); const onProgress = createGenerationProgressReporter(write); + // Missing executables fail here, before any model download starts. + requireSubtitleGenerationTools(await deps.resolveTools(config)); const model = await deps.resolveModel(config, modelDirectory); if (model.kind === 'invalid') throw new Error(model.message); if (model.kind === 'missing') { diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts index 0377993d..a6280a07 100644 --- a/src/config/definitions/options-subtitle.ts +++ b/src/config/definitions/options-subtitle.ts @@ -24,7 +24,7 @@ export function buildSubtitleConfigOptionRegistry( vadModelPath: 'Path to a whisper.cpp Silero VAD model. Enables dialogue-focused generation from separate speech passages. Leave empty to transcribe the full audio, including songs.', vadPath: - 'Optional speech detector executable override. With vadModelPath configured, leave empty to find whisper-vad-speech-segments on PATH.', + 'Optional speech detector executable override. With vadModelPath configured, leave empty to find whisper-vad-speech-segments or vad-speech-segments on PATH.', }[key], })), { diff --git a/src/core/services/subtitle-generation-dialogue.ts b/src/core/services/subtitle-generation-dialogue.ts index 70b0bb44..76b0eee9 100644 --- a/src/core/services/subtitle-generation-dialogue.ts +++ b/src/core/services/subtitle-generation-dialogue.ts @@ -7,6 +7,7 @@ import type { } from '../../shared/subtitle-generation'; import { expandSubtitleGenerationPath } from './subtitle-generation-files'; import { runSubtitleGenerationProcess } from './subtitle-generation-process'; +import type { SubtitleGenerationToolPaths } from './subtitle-generation-tools'; import { formatTimestamp } from './subtitle-generation-srt'; import { parseSpeechPassages, @@ -21,6 +22,7 @@ const PASSAGES_PER_BATCH = 16; export async function transcribeSubtitleDialogue(input: { config: SubtitleGenerationConfig; + tools: SubtitleGenerationToolPaths & { vad: string }; modelPath: string; wavPath: string; directory: string; @@ -32,7 +34,7 @@ export async function transcribeSubtitleDialogue(input: { input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' }); const segmentLines: string[] = []; await runSubtitleGenerationProcess({ - command: input.config.vadPath.trim() || 'whisper-vad-speech-segments', + command: input.tools.vad, args: [ '-f', input.wavPath, @@ -65,7 +67,7 @@ export async function transcribeSubtitleDialogue(input: { (passage) => passage.endSeconds - passage.startSeconds > SPEECH_PASSAGE_SECONDS, ) ? await findSpeechPauses({ - ffmpegPath: input.config.ffmpegPath, + ffmpegPath: input.tools.ffmpeg, wavPath: input.wavPath, signal: input.signal, }) @@ -84,7 +86,7 @@ export async function transcribeSubtitleDialogue(input: { }); for (const { passage, base } of batch) { await runSubtitleGenerationProcess({ - command: input.config.ffmpegPath.trim() || 'ffmpeg', + command: input.tools.ffmpeg, args: [ '-nostdin', '-hide_banner', @@ -109,7 +111,7 @@ export async function transcribeSubtitleDialogue(input: { } // One model load per batch, with independent text context and timestamps for every passage. await runSubtitleGenerationProcess({ - command: input.config.whisperPath.trim() || 'whisper-cli', + command: input.tools.whisper, args: [ '-m', input.modelPath, diff --git a/src/core/services/subtitle-generation-pauses.ts b/src/core/services/subtitle-generation-pauses.ts index 60045831..f0fdbda1 100644 --- a/src/core/services/subtitle-generation-pauses.ts +++ b/src/core/services/subtitle-generation-pauses.ts @@ -8,7 +8,7 @@ export async function findSpeechPauses(input: { }): Promise { const pauses: number[] = []; await runSubtitleGenerationProcess({ - command: input.ffmpegPath.trim() || 'ffmpeg', + command: input.ffmpegPath, args: [ '-nostdin', '-hide_banner', diff --git a/src/core/services/subtitle-generation-process.ts b/src/core/services/subtitle-generation-process.ts index da719e30..3905f7c7 100644 --- a/src/core/services/subtitle-generation-process.ts +++ b/src/core/services/subtitle-generation-process.ts @@ -48,7 +48,13 @@ export function runSubtitleGenerationProcess(input: { } child.once('error', (error) => { cleanup(); - reject(new Error(`Could not run ${input.command}: ${error.message}`)); + reject( + new Error( + 'code' in error && error.code === 'ENOENT' + ? `${input.command} was not found. Install it or set its path under subtitleGeneration in Settings.` + : `Could not run ${input.command}: ${error.message}`, + ), + ); }); child.once('close', (code) => { cleanup(); diff --git a/src/core/services/subtitle-generation-tools.test.ts b/src/core/services/subtitle-generation-tools.test.ts new file mode 100644 index 00000000..7b057735 --- /dev/null +++ b/src/core/services/subtitle-generation-tools.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../shared/subtitle-generation'; +import { + requireSubtitleGenerationTools, + resolveSubtitleGenerationTools, +} from './subtitle-generation-tools'; + +async function fixture(run: (directory: string) => Promise) { + const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-generation-tools-')); + try { + await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +async function executable(directory: string, name: string): Promise { + const file = path.join(directory, name); + await writeFile(file, '#!/bin/sh\n', { mode: 0o755 }); + return file; +} + +test('tools resolve from PATH, honor overrides, and only require the detector in dialogue mode', () => + fixture(async (directory) => { + const bin = path.join(directory, 'bin'); + await mkdir(bin); + for (const name of ['ffmpeg', 'ffprobe', 'whisper-cli']) await executable(bin, name); + const detector = await executable(bin, 'vad-speech-segments'); + const customWhisper = await executable(directory, 'my-whisper'); + await writeFile(path.join(directory, 'not-executable'), '', { mode: 0o644 }); + const env = { PATH: bin }; + + const found = await resolveSubtitleGenerationTools(DEFAULT_SUBTITLE_GENERATION_CONFIG, env); + assert.deepEqual(found, { + ffmpeg: { kind: 'found', path: path.join(bin, 'ffmpeg') }, + ffprobe: { kind: 'found', path: path.join(bin, 'ffprobe') }, + whisper: { kind: 'found', path: path.join(bin, 'whisper-cli') }, + vad: null, + }); + assert.equal(requireSubtitleGenerationTools(found).vad, null); + + const dialogue = await resolveSubtitleGenerationTools( + { ...DEFAULT_SUBTITLE_GENERATION_CONFIG, vadModelPath: '/models/vad.bin' }, + env, + ); + assert.deepEqual(dialogue.vad, { kind: 'found', path: detector }); + + const overridden = await resolveSubtitleGenerationTools( + { + ...DEFAULT_SUBTITLE_GENERATION_CONFIG, + whisperPath: customWhisper, + ffmpegPath: path.join(directory, 'not-executable'), + }, + env, + ); + assert.deepEqual(overridden.whisper, { kind: 'found', path: customWhisper }); + assert.equal(overridden.ffmpeg.kind, 'missing'); + assert.throws( + () => requireSubtitleGenerationTools(overridden), + /not-executable \(subtitleGeneration\.ffmpegPath\) is not an executable file/, + ); + })); + +test('missing tools name the executable, the installer, and the setting', () => + fixture(async (directory) => { + const tools = await resolveSubtitleGenerationTools( + { ...DEFAULT_SUBTITLE_GENERATION_CONFIG, vadModelPath: '/models/vad.bin' }, + { PATH: directory }, + ); + assert.deepEqual(tools.whisper, { + kind: 'missing', + message: + 'whisper-cli was not found on PATH. Install whisper.cpp or set subtitleGeneration.whisperPath in Settings.', + }); + assert.deepEqual(tools.vad, { + kind: 'missing', + message: + "whisper-vad-speech-segments was not found on PATH. Install whisper.cpp's speech segment detector or set subtitleGeneration.vadPath in Settings.", + }); + assert.throws(() => requireSubtitleGenerationTools(tools), /ffmpeg was not found on PATH/); + })); diff --git a/src/core/services/subtitle-generation-tools.ts b/src/core/services/subtitle-generation-tools.ts new file mode 100644 index 00000000..0477bb1b --- /dev/null +++ b/src/core/services/subtitle-generation-tools.ts @@ -0,0 +1,125 @@ +import { access, stat } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import path from 'node:path'; +import type { + SubtitleGenerationConfig, + SubtitleGenerationToolStatus, + SubtitleGenerationTools, +} from '../../shared/subtitle-generation'; +import { expandSubtitleGenerationPath } from './subtitle-generation-files'; + +/** Executable paths ready to spawn. `vad` is null when dialogue mode is off. */ +export interface SubtitleGenerationToolPaths { + ffmpeg: string; + ffprobe: string; + whisper: string; + vad: string | null; +} + +const TOOL_LOOKUPS = { + ffmpeg: { setting: 'ffmpegPath', names: ['ffmpeg'], install: 'Install FFmpeg' }, + ffprobe: { setting: 'ffprobePath', names: ['ffprobe'], install: 'Install FFmpeg' }, + whisper: { setting: 'whisperPath', names: ['whisper-cli'], install: 'Install whisper.cpp' }, + vad: { + setting: 'vadPath', + names: ['whisper-vad-speech-segments', 'vad-speech-segments'], + install: "Install whisper.cpp's speech segment detector", + }, +} as const; + +async function isExecutableFile(filePath: string): Promise { + try { + if (!(await stat(filePath)).isFile()) return false; + await access(filePath, constants.X_OK); + return true; + } catch { + return false; + } +} + +function executableNames(name: string, env: NodeJS.ProcessEnv): string[] { + if (process.platform !== 'win32' || path.extname(name)) return [name]; + const extensions = (env.PATHEXT ?? '.EXE;.CMD;.BAT') + .split(';') + .map((entry) => entry.trim()) + .filter(Boolean); + return [name, ...extensions.map((extension) => `${name}${extension}`)]; +} + +async function findOnPath(names: readonly string[], env: NodeJS.ProcessEnv): Promise { + const directories = (env.PATH ?? '') + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); + for (const directory of directories) { + for (const name of names) { + for (const candidate of executableNames(name, env)) { + const filePath = path.join(directory, candidate); + if (await isExecutableFile(filePath)) return filePath; + } + } + } + return ''; +} + +async function resolveTool( + tool: keyof typeof TOOL_LOOKUPS, + config: SubtitleGenerationConfig, + env: NodeJS.ProcessEnv, +): Promise { + const lookup = TOOL_LOOKUPS[tool]; + const override = config[lookup.setting].trim(); + if (override) { + const expanded = expandSubtitleGenerationPath(override); + const found = + path.dirname(expanded) === '.' + ? await findOnPath([expanded], env) + : (await isExecutableFile(expanded)) + ? path.resolve(expanded) + : ''; + return found + ? { kind: 'found', path: found } + : { + kind: 'missing', + message: `${override} (subtitleGeneration.${lookup.setting}) is not an executable file.`, + }; + } + const found = await findOnPath(lookup.names, env); + return found + ? { kind: 'found', path: found } + : { + kind: 'missing', + message: `${lookup.names[0]} was not found on PATH. ${lookup.install} or set subtitleGeneration.${lookup.setting} in Settings.`, + }; +} + +/** Locate every executable a generation run needs, before any model download or audio work. */ +export async function resolveSubtitleGenerationTools( + config: SubtitleGenerationConfig, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const [ffmpeg, ffprobe, whisper, vad] = await Promise.all([ + resolveTool('ffmpeg', config, env), + resolveTool('ffprobe', config, env), + resolveTool('whisper', config, env), + config.vadModelPath.trim() ? resolveTool('vad', config, env) : null, + ]); + return { ffmpeg, ffprobe, whisper, vad }; +} + +function foundPath(tool: SubtitleGenerationToolStatus): string { + if (tool.kind === 'missing') throw new Error(tool.message); + return tool.path; +} + +/** Throw the first missing tool's message, otherwise narrow to spawnable paths. */ +export function requireSubtitleGenerationTools( + tools: SubtitleGenerationTools, +): SubtitleGenerationToolPaths { + return { + ffmpeg: foundPath(tools.ffmpeg), + ffprobe: foundPath(tools.ffprobe), + whisper: foundPath(tools.whisper), + vad: tools.vad ? foundPath(tools.vad) : null, + }; +} diff --git a/src/core/services/subtitle-generation.test.ts b/src/core/services/subtitle-generation.test.ts index 0de06796..b9c499d9 100644 --- a/src/core/services/subtitle-generation.test.ts +++ b/src/core/services/subtitle-generation.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -277,7 +277,7 @@ test('empty executable paths find tools on PATH and explicit overrides take prec ...input, config: { ...config, ffprobePath: path.join(directory, 'missing-override') }, }), - /Could not run .*missing-override/, + /missing-override \(subtitleGeneration\.ffprobePath\) is not an executable file/, ); } finally { if (previousPath === undefined) delete process.env.PATH; @@ -285,7 +285,7 @@ test('empty executable paths find tools on PATH and explicit overrides take prec } })); -test('generation rejects remote media and missing models before starting a subprocess', () => +test('generation rejects remote media, missing models, and missing tools before starting a subprocess', () => fixture(async (directory) => { const input = await generationFixture(directory); await assert.rejects( @@ -296,9 +296,42 @@ test('generation rejects remote media and missing models before starting a subpr generateJapaneseSubtitles({ ...input, config: { ...input.config, modelPath: '' } }), /No Whisper model found/, ); + await assert.rejects( + generateJapaneseSubtitles({ + ...input, + config: { + ...input.config, + vadModelPath: path.join(directory, 'vad.bin'), + vadPath: path.join(directory, 'missing-detector'), + }, + }), + /missing-detector \(subtitleGeneration\.vadPath\) is not an executable file/, + ); await assert.rejects(readFile(input.callsPath), /ENOENT/); })); +test( + 'generation rejects an unwritable destination before extracting audio', + { + skip: process.platform === 'win32' || process.getuid?.() === 0, + }, + () => + fixture(async (directory) => { + const input = await generationFixture(directory); + const readOnly = path.join(directory, 'read-only'); + await mkdir(readOnly, { mode: 0o555 }); + try { + await assert.rejects( + generateJapaneseSubtitles({ ...input, outputPath: path.join(readOnly, 'out.srt') }), + /read-only is not writable/, + ); + await assert.rejects(readFile(input.callsPath), /ENOENT/); + } finally { + await chmod(readOnly, 0o755); + } + }), +); + test('process cancellation terminates work and bounds diagnostic output', () => fixture(async (directory) => { const slow = await executable( diff --git a/src/core/services/subtitle-generation.ts b/src/core/services/subtitle-generation.ts index 21902043..ad26083a 100644 --- a/src/core/services/subtitle-generation.ts +++ b/src/core/services/subtitle-generation.ts @@ -1,4 +1,5 @@ -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { access, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import type { @@ -10,11 +11,16 @@ 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 { + requireSubtitleGenerationTools, + resolveSubtitleGenerationTools, +} from './subtitle-generation-tools'; export { downloadSubtitleGenerationModel, resolveSubtitleGenerationModel, } from './subtitle-generation-models'; +export { resolveSubtitleGenerationTools } from './subtitle-generation-tools'; function numericTime(value: unknown): number | undefined { if (typeof value !== 'number' && typeof value !== 'string') return undefined; @@ -118,6 +124,15 @@ async function ensureAvailableOutput(outputPath: string): Promise { throw new Error(`Subtitle output already exists: ${outputPath}`); } +// Fail before extraction and transcription when the destination cannot take the file. +async function ensureWritableDirectory(directory: string): Promise { + try { + await access(directory, constants.W_OK); + } catch { + throw new Error(`Cannot save subtitles: ${directory} is not writable.`); + } +} + async function writeSubtitles(input: { mediaPath: string; outputPath?: string; @@ -170,15 +185,19 @@ export async function generateJapaneseSubtitles(input: { if (!(await stat(mediaPath)).isFile()) throw new Error('Subtitle generation requires a local media file.'); if (input.outputPath) await ensureAvailableOutput(path.resolve(input.outputPath)); + await ensureWritableDirectory( + input.outputPath ? path.dirname(path.resolve(input.outputPath)) : path.dirname(mediaPath), + ); const model = await resolveSubtitleGenerationModel(input.config, input.modelDirectory); if (model.kind === 'missing') throw new Error( 'No Whisper model found. Download a model or configure an existing model path.', ); if (model.kind === 'invalid') throw new Error(model.message); + const tools = requireSubtitleGenerationTools(await resolveSubtitleGenerationTools(input.config)); input.onProgress?.({ stage: 'extract', message: 'Inspecting audio tracks...' }); const probe = await runSubtitleGenerationProcess({ - command: input.config.ffprobePath.trim() || 'ffprobe', + command: tools.ffprobe, args: [ '-v', 'error', @@ -197,7 +216,7 @@ export async function generateJapaneseSubtitles(input: { const subtitleBase = path.join(temporaryDirectory, 'subtitles'); input.onProgress?.({ stage: 'extract', percent: 0, message: 'Extracting audio...' }); await runSubtitleGenerationProcess({ - command: input.config.ffmpegPath.trim() || 'ffmpeg', + command: tools.ffmpeg, args: [ '-nostdin', '-hide_banner', @@ -239,9 +258,10 @@ export async function generateJapaneseSubtitles(input: { message: 'Generating Japanese subtitles...', }); let srt: string; - if (input.config.vadModelPath.trim()) { + if (tools.vad !== null) { srt = await transcribeSubtitleDialogue({ config: input.config, + tools: { ...tools, vad: tools.vad }, modelPath: model.path, wavPath, directory: temporaryDirectory, @@ -250,7 +270,7 @@ export async function generateJapaneseSubtitles(input: { }); } else { await runSubtitleGenerationProcess({ - command: input.config.whisperPath.trim() || 'whisper-cli', + command: tools.whisper, args: [ '-m', model.path, diff --git a/src/main/runtime/subtitle-generation-runtime.test.ts b/src/main/runtime/subtitle-generation-runtime.test.ts index 9f2d5643..60a74264 100644 --- a/src/main/runtime/subtitle-generation-runtime.test.ts +++ b/src/main/runtime/subtitle-generation-runtime.test.ts @@ -24,6 +24,12 @@ function fixture(overrides: Partial = {}) { getMpvClient: () => client, onProgress: () => {}, resolveModel: async () => ({ kind: 'external', path: '/models/local.bin' }), + resolveTools: async (config) => ({ + ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' }, + ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' }, + whisper: { kind: 'found', path: '/usr/bin/whisper-cli' }, + vad: config.vadModelPath ? { kind: 'found', path: '/usr/bin/vad' } : null, + }), generate: async () => '/video/episode.ja.generated.srt', ...overrides, }); @@ -176,6 +182,15 @@ test('external model paths prevent managed selection, including unreadable overr await assert.rejects(runtime.selectModel('medium'), /Clear Model Path/); }); +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' }), + }); + assert.equal((await runtime.getStatus()).tools.vad, null); + await runtime.setVadEnabled(true); + assert.deepEqual((await runtime.getStatus()).tools.vad, { kind: 'found', path: '/usr/bin/vad' }); +}); + test('speech detection is optional and downloading alone does not enable it', async () => { let installed = false; const paths: string[] = []; diff --git a/src/main/runtime/subtitle-generation-runtime.ts b/src/main/runtime/subtitle-generation-runtime.ts index 2da81147..3d521827 100644 --- a/src/main/runtime/subtitle-generation-runtime.ts +++ b/src/main/runtime/subtitle-generation-runtime.ts @@ -9,6 +9,7 @@ import { downloadSubtitleGenerationModel, generateJapaneseSubtitles, resolveSubtitleGenerationModel, + resolveSubtitleGenerationTools, } from '../../core/services/subtitle-generation'; import type { SubtitleGenerationConfig, @@ -33,6 +34,7 @@ export interface SubtitleGenerationRuntimeDeps { generate?: typeof generateJapaneseSubtitles; download?: typeof downloadSubtitleGenerationModel; resolveModel?: typeof resolveSubtitleGenerationModel; + resolveTools?: typeof resolveSubtitleGenerationTools; downloadVad?: typeof downloadSubtitleGenerationVadModel; resolveVadModel?: typeof resolveSubtitleGenerationVadModel; } @@ -139,6 +141,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD deps.getModelDirectory(), ), }, + // Session toggles decide whether the speech detector executable is required. + tools: await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config), managedModel: config.managedModel, externalModelPath: config.modelPath.trim() || null, mediaPath, diff --git a/src/renderer/index.html b/src/renderer/index.html index d0f017a1..7c321dff 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -656,6 +656,14 @@ Current media
Checking current media...
+
+ Local tools +
Checking local tools...
+

+ SubMiner downloads models, not whisper.cpp or FFmpeg. Install them, or set their + paths in Settings → Integrations → Japanese Subtitle Generation. +

+
Speech model
Checking local models...
diff --git a/src/renderer/modals/subtitle-generation-view.test.ts b/src/renderer/modals/subtitle-generation-view.test.ts index 85a03073..fa06f034 100644 --- a/src/renderer/modals/subtitle-generation-view.test.ts +++ b/src/renderer/modals/subtitle-generation-view.test.ts @@ -3,9 +3,33 @@ import test from 'node:test'; import { describeGenerationModel, describeGenerationProgress, + describeGenerationTools, describeGenerationVad, } from './subtitle-generation-view'; +test('missing tools block generation and list every install instruction', () => { + const found = { kind: 'found', path: '/usr/bin/tool' } as const; + assert.deepEqual( + describeGenerationTools({ ffmpeg: found, ffprobe: found, whisper: found, vad: null }), + { + ready: true, + text: 'whisper.cpp and FFmpeg are installed.', + }, + ); + assert.deepEqual( + describeGenerationTools({ + ffmpeg: { kind: 'missing', message: 'ffmpeg was not found on PATH.' }, + ffprobe: found, + whisper: found, + vad: { kind: 'missing', message: 'whisper-vad-speech-segments was not found on PATH.' }, + }), + { + ready: false, + text: 'ffmpeg was not found on PATH. whisper-vad-speech-segments was not found on PATH.', + }, + ); +}); + test('only a missing managed model offers a download', () => { assert.deepEqual(describeGenerationModel({ kind: 'missing', path: '/models/small.bin' }), { ready: false, diff --git a/src/renderer/modals/subtitle-generation-view.ts b/src/renderer/modals/subtitle-generation-view.ts index 7bd63601..1a5fd725 100644 --- a/src/renderer/modals/subtitle-generation-view.ts +++ b/src/renderer/modals/subtitle-generation-view.ts @@ -1,9 +1,22 @@ -import type { - SubtitleGenerationModelStatus, - SubtitleGenerationProgress, +import { + missingSubtitleGenerationTools, + type SubtitleGenerationModelStatus, + type SubtitleGenerationProgress, + type SubtitleGenerationTools, } from '../../shared/subtitle-generation'; import type { SubtitleGenerationStatus } from '../../shared/subtitle-generation-ipc'; +export function describeGenerationTools(tools: SubtitleGenerationTools) { + const missing = missingSubtitleGenerationTools(tools); + if (missing.length > 0) return { ready: false, text: missing.join(' ') }; + return { + ready: true, + text: tools.vad + ? 'whisper.cpp, its speech detector, and FFmpeg are installed.' + : 'whisper.cpp and FFmpeg are installed.', + }; +} + export function describeGenerationVad(vad: SubtitleGenerationStatus['vad']) { const model = describeGenerationModel(vad.model); return { diff --git a/src/renderer/modals/subtitle-generation.ts b/src/renderer/modals/subtitle-generation.ts index 3d519736..42f8d11c 100644 --- a/src/renderer/modals/subtitle-generation.ts +++ b/src/renderer/modals/subtitle-generation.ts @@ -16,6 +16,7 @@ import { createModalFocusGuard } from './modal-focus-guard'; import { describeGenerationModel, describeGenerationProgress, + describeGenerationTools, describeGenerationVad, } from './subtitle-generation-view'; import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model'; @@ -38,6 +39,7 @@ export function createSubtitleGenerationModal( close: element('subtitleGenerationClose', HTMLButtonElement), open: element('subtitleGenerationOpen', HTMLButtonElement), media: element('subtitleGenerationMedia', HTMLDivElement), + tools: element('subtitleGenerationTools', HTMLDivElement), model: element('subtitleGenerationModel', HTMLDivElement), modelPicker: element('subtitleGenerationModelPicker', HTMLDivElement), modelSelect: element('subtitleGenerationModelSelect', HTMLSelectElement), @@ -85,14 +87,18 @@ export function createSubtitleGenerationModal( const busy = pending || Boolean(snapshot?.running); const model = snapshot ? describeGenerationModel(snapshot.model) : null; const vad = snapshot ? describeGenerationVad(snapshot.vad) : null; + const tools = snapshot ? describeGenerationTools(snapshot.tools) : null; const readyMessage = !snapshot?.mediaPath ? 'Open local media to generate subtitles.' - : !model?.ready - ? 'Set up a speech model to continue.' - : !vad?.ready - ? 'Download the speech detection model or uncheck Focus on spoken dialogue.' - : 'Ready when you are.'; + : !tools?.ready + ? 'Install the missing tools or set their paths in Settings, then click Check again.' + : !model?.ready + ? 'Set up a speech model to continue.' + : !vad?.ready + ? 'Download the speech detection model or uncheck Focus on spoken dialogue.' + : 'Ready when you are.'; dom.media.textContent = snapshot?.mediaPath ?? 'Open a local media file in the player first.'; + dom.tools.textContent = tools?.text ?? 'Checking local tools...'; dom.model.textContent = model?.text ?? 'Checking local models...'; dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath)); dom.modelSelect.disabled = busy || checking || !snapshot || Boolean(snapshot.externalModelPath); @@ -113,7 +119,8 @@ export function createSubtitleGenerationModal( dom.vadDownload.classList.toggle('hidden', !vad?.download); dom.vadDownload.textContent = `Download speech detection model · ${formatSubtitleGenerationModelSize(SUBTITLE_GENERATION_VAD_MODEL.size)}`; dom.vadDownload.disabled = busy || checking; - dom.start.disabled = busy || checking || !model?.ready || !vad?.ready || !snapshot?.mediaPath; + dom.start.disabled = + busy || checking || !tools?.ready || !model?.ready || !vad?.ready || !snapshot?.mediaPath; dom.refresh.disabled = busy || checking; dom.cancel.classList.toggle('hidden', !busy); dom.cancel.disabled = cancelling; @@ -166,12 +173,13 @@ export function createSubtitleGenerationModal( if (pending || snapshot?.running || checking || !snapshot) return; const model = describeGenerationModel(snapshot.model); const vad = describeGenerationVad(snapshot.vad); + const tools = describeGenerationTools(snapshot.tools); if ( action === 'download' ? !model.download : action === 'download-vad' ? !vad.download - : !model.ready || !vad.ready || !snapshot.mediaPath + : !tools.ready || !model.ready || !vad.ready || !snapshot.mediaPath ) return; pending = true; diff --git a/src/shared/subtitle-generation-ipc.ts b/src/shared/subtitle-generation-ipc.ts index df1fe6d0..ebf30d1d 100644 --- a/src/shared/subtitle-generation-ipc.ts +++ b/src/shared/subtitle-generation-ipc.ts @@ -2,6 +2,7 @@ import type { SubtitleGenerationConfig, SubtitleGenerationModelStatus, SubtitleGenerationProgress, + SubtitleGenerationTools, } from './subtitle-generation'; export type SubtitleGenerationResult = @@ -11,6 +12,7 @@ export type SubtitleGenerationResult = export interface SubtitleGenerationStatus { model: SubtitleGenerationModelStatus; vad: { enabled: boolean; model: SubtitleGenerationModelStatus }; + tools: SubtitleGenerationTools; managedModel: SubtitleGenerationConfig['managedModel']; externalModelPath: string | null; mediaPath: string | null; diff --git a/src/shared/subtitle-generation.ts b/src/shared/subtitle-generation.ts index 0578aef6..55609910 100644 --- a/src/shared/subtitle-generation.ts +++ b/src/shared/subtitle-generation.ts @@ -37,6 +37,24 @@ export type SubtitleGenerationModelStatus = | { kind: 'missing'; path: string } | { kind: 'invalid'; path: string; message: string }; +export type SubtitleGenerationToolStatus = + | { kind: 'found'; path: string } + | { kind: 'missing'; message: string }; + +/** Executables generation depends on. `vad` is null unless dialogue mode is on. */ +export interface SubtitleGenerationTools { + ffmpeg: SubtitleGenerationToolStatus; + ffprobe: SubtitleGenerationToolStatus; + whisper: SubtitleGenerationToolStatus; + vad: SubtitleGenerationToolStatus | null; +} + +export function missingSubtitleGenerationTools(tools: SubtitleGenerationTools): string[] { + return [tools.ffmpeg, tools.ffprobe, tools.whisper, tools.vad].flatMap((tool) => + tool?.kind === 'missing' ? [tool.message] : [], + ); +} + export function resolveSubtitleGenerationConfig( value: unknown, onWarning?: (key: string, value: unknown, message: string) => void,