feat(subtitles): add local Japanese subtitle generation (#240)

This commit is contained in:
2026-09-15 21:43:11 -07:00
committed by GitHub
parent 6841a37a22
commit 2831e05124
90 changed files with 4934 additions and 57 deletions
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -0,0 +1,43 @@
import type { IpcMain, WebContents } from 'electron';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { isSubtitleGenerationModelId } from '../../shared/subtitle-generation-model-catalog';
import type { createSubtitleGenerationRuntime } from './subtitle-generation-runtime';
export function registerSubtitleGenerationIpc(deps: {
ipc: Pick<IpcMain, 'handle'>;
isAllowedSender: (sender: WebContents) => boolean;
openModal: () => Promise<boolean>;
runtime: ReturnType<typeof createSubtitleGenerationRuntime>;
}): void {
const handlers = [
[IPC_CHANNELS.request.requestSubtitleGenerationOpen, () => deps.openModal()],
[IPC_CHANNELS.request.getSubtitleGenerationStatus, () => deps.runtime.getStatus()],
[
IPC_CHANNELS.request.selectSubtitleGenerationModel,
(model: unknown) => {
if (!isSubtitleGenerationModelId(model))
throw new Error('Unknown subtitle generation model.');
return deps.runtime.selectModel(model);
},
],
[IPC_CHANNELS.request.startSubtitleGeneration, () => deps.runtime.start()],
[IPC_CHANNELS.request.downloadSubtitleGenerationModel, () => deps.runtime.download()],
[IPC_CHANNELS.request.downloadSubtitleGenerationVadModel, () => deps.runtime.downloadVad()],
[
IPC_CHANNELS.request.setSubtitleGenerationVadEnabled,
(enabled: unknown) => {
if (typeof enabled !== 'boolean')
throw new Error('Speech detection selection must be a boolean.');
return deps.runtime.setVadEnabled(enabled);
},
],
[IPC_CHANNELS.request.cancelSubtitleGeneration, () => deps.runtime.cancel()],
] as const;
for (const [channel, handler] of handlers) {
deps.ipc.handle(channel, (event, payload: unknown) => {
if (!deps.isAllowedSender(event.sender))
throw new Error('Subtitle generation is only available from the overlay.');
return handler(payload);
});
}
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openSubtitleGenerationModal } from './subtitle-generation-open';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
test('subtitle generation opens in the dedicated modal window with normal close restoration', async () => {
const calls: string[] = [];
const opened = await openSubtitleGenerationModal({
ensureOverlayStartupPrereqs: () => {
calls.push('startup');
},
ensureOverlayWindowsReadyForVisibilityActions: () => {
calls.push('windows');
},
sendToActiveOverlayWindow: (channel, payload, options) => {
assert.deepEqual(calls, ['startup', 'windows']);
assert.equal(channel, IPC_CHANNELS.event.subtitleGenerationOpen);
assert.equal(payload, undefined);
assert.deepEqual(options, {
restoreOnModalClose: 'subtitle-generation',
preferModalWindow: true,
});
calls.push('open');
return true;
},
waitForModalOpen: async (modal) => {
assert.equal(modal, 'subtitle-generation');
return true;
},
logWarn: () => {
assert.fail('opening should not require a retry');
},
});
assert.equal(opened, true);
assert.deepEqual(calls, ['startup', 'windows', 'open']);
});
@@ -0,0 +1,18 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
export function openSubtitleGenerationModal(
deps: Parameters<typeof openOverlayHostedModal>[0] & Parameters<typeof retryOverlayModalOpen>[0],
): Promise<boolean> {
return retryOverlayModalOpen(deps, {
modal: 'subtitle-generation',
timeoutMs: 1500,
retryWarning: 'Subtitle generation modal did not acknowledge opening; retrying.',
sendOpen: () =>
openOverlayHostedModal(deps, {
channel: IPC_CHANNELS.event.subtitleGenerationOpen,
modal: 'subtitle-generation',
preferModalWindow: true,
}),
});
}
@@ -0,0 +1,259 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../shared/subtitle-generation';
import {
createSubtitleGenerationRuntime,
type SubtitleGenerationRuntimeDeps,
} from './subtitle-generation-runtime';
function fixture(overrides: Partial<SubtitleGenerationRuntimeDeps> = {}) {
let mediaPath = '/video/episode.mkv';
const commands: unknown[][] = [];
const client = {
connected: true,
requestProperty: async (name: string): Promise<unknown> =>
name === 'path' ? mediaPath : [{ type: 'audio', selected: true, 'ff-index': 3 }],
request: async (command: unknown[]) => {
commands.push(command);
return { error: 'success' };
},
};
const runtime = createSubtitleGenerationRuntime({
getConfig: () => DEFAULT_SUBTITLE_GENERATION_CONFIG,
getModelDirectory: () => '/models',
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,
});
return {
runtime,
client,
commands,
changeMedia: () => {
mediaPath = '/video/next.mkv';
},
};
}
test('generation uses the selected audio track and loads the timed SRT with zero delay', async () => {
const { runtime, commands } = fixture({
generate: async (input) => {
assert.equal(input.mediaPath, '/video/episode.mkv');
assert.equal(input.audioStreamIndex, 3);
return '/video/generated.srt';
},
});
assert.equal((await runtime.start()).ok, true);
assert.deepEqual(commands, [
['sub-add', '/video/generated.srt', 'select', 'Generated Japanese', 'ja'],
['set_property', 'sub-delay', 0],
]);
});
test('generation preserves the output without attaching it to a different video', async () => {
const subject = fixture({
generate: async () => {
subject.changeMedia();
return '/video/generated.srt';
},
});
const result = await subject.runtime.start();
assert.equal(result.ok, true);
assert.match(result.message, /Playback changed/);
assert.deepEqual(subject.commands, []);
});
test('mpv load failure still reports where the generated subtitles were saved', async () => {
const subject = fixture();
subject.client.request = async () => ({ error: 'loading failed' });
const result = await subject.runtime.start();
assert.equal(result.ok, true);
assert.match(result.message, /Subtitles saved:.*Could not finish loading/);
});
test('cancellation during the final media check keeps the saved file without loading it', async () => {
const subject = fixture();
const requestProperty = subject.client.requestProperty;
let mediaChecks = 0;
subject.client.requestProperty = async (name) => {
if (name === 'path' && ++mediaChecks === 3) subject.runtime.cancel();
return requestProperty(name);
};
const result = await subject.runtime.start();
assert.equal(result.ok, true);
assert.match(result.message, /Cancelled after saving/);
assert.deepEqual(subject.commands, []);
});
test('only one job runs, cancellation reaches the worker, and status retains its result', async () => {
let signal: AbortSignal | undefined;
let entered = () => {};
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const { runtime } = fixture({
generate: async (input) => {
signal = input.signal;
input.onProgress?.({ stage: 'transcribe', percent: 25, message: 'Working' });
entered();
return new Promise((_, reject) =>
input.signal?.addEventListener('abort', () => reject(new Error('Aborted')), { once: true }),
);
},
});
const first = runtime.start();
await started;
await assert.rejects(runtime.selectModel('medium'), /current operation/);
assert.equal((await runtime.start()).ok, false);
assert.equal((await runtime.download()).ok, false);
const active = await runtime.getStatus();
assert.equal(active.running, true);
assert.equal(active.progress?.percent, 25);
runtime.cancel();
assert.equal(signal?.aborted, true);
assert.deepEqual(await first, { ok: false, message: 'Cancelled.' });
const completed = await runtime.getStatus();
assert.equal(completed.running, false);
assert.deepEqual(completed.lastResult, { ok: false, message: 'Cancelled.' });
});
test('external audio cannot silently generate from a different internal track', async () => {
const subject = fixture({
generate: async () => {
assert.fail('must not transcribe');
},
});
subject.client.requestProperty = async (name) =>
name === 'path'
? '/video/episode.mkv'
: [{ type: 'audio', selected: true, external: true, 'ff-index': 0 }];
const result = await subject.runtime.start();
assert.equal(result.ok, false);
assert.match(result.message, /audio track inside/);
});
test('model selection is retained and used for status, download, and generation', async () => {
const seen: string[] = [];
const { runtime } = fixture({
resolveModel: async (config) => ({
kind: 'missing',
path: `/models/${config.managedModel}.bin`,
}),
download: async ({ config }) => {
seen.push(`download:${config.managedModel}`);
return '/models/downloaded.bin';
},
generate: async ({ config }) => {
seen.push(`generate:${config.managedModel}`);
return '/video/generated.srt';
},
});
const selected = await runtime.selectModel('medium');
assert.equal(selected.managedModel, 'medium');
assert.equal(selected.model.path, '/models/medium.bin');
assert.equal((await runtime.getStatus()).managedModel, 'medium');
assert.equal((await runtime.download()).ok, true);
assert.equal((await runtime.start()).ok, true);
assert.deepEqual(seen, ['download:medium', 'generate:medium']);
assert.equal(DEFAULT_SUBTITLE_GENERATION_CONFIG.managedModel, 'small');
});
test('external model paths prevent managed selection, including unreadable overrides', async () => {
const { runtime } = fixture({
getConfig: () => ({
...DEFAULT_SUBTITLE_GENERATION_CONFIG,
modelPath: '/missing/external.bin',
}),
resolveModel: async () => ({
kind: 'invalid',
path: '/missing/external.bin',
message: 'Missing model',
}),
});
assert.equal((await runtime.getStatus()).externalModelPath, '/missing/external.bin');
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[] = [];
const { runtime } = fixture({
resolveVadModel: async () => ({
kind: installed ? 'managed' : 'missing',
path: '/models/ggml-silero-v6.2.0.bin',
}),
downloadVad: async () => {
installed = true;
return '/models/ggml-silero-v6.2.0.bin';
},
generate: async ({ config }) => {
paths.push(config.vadModelPath);
return '/video/output.srt';
},
});
assert.equal((await runtime.getStatus()).vad.enabled, false);
assert.equal((await runtime.start()).ok, true);
await runtime.setVadEnabled(true);
assert.equal((await runtime.start()).ok, false);
await runtime.setVadEnabled(false);
assert.equal((await runtime.downloadVad()).ok, true);
assert.equal((await runtime.getStatus()).vad.enabled, false);
await runtime.setVadEnabled(true);
assert.equal((await runtime.start()).ok, true);
await runtime.setVadEnabled(false);
assert.equal((await runtime.start()).ok, true);
assert.deepEqual(paths, ['', '/models/ggml-silero-v6.2.0.bin', '']);
});
test('existing external speech model remains the default and survives session toggles', async () => {
const { runtime } = fixture({
getConfig: () => ({ ...DEFAULT_SUBTITLE_GENERATION_CONFIG, vadModelPath: '/external/vad.bin' }),
resolveVadModel: async (config) => ({ kind: 'external', path: config.vadModelPath }),
generate: async ({ config }) => {
assert.equal(config.vadModelPath, '/external/vad.bin');
return '/video/output.srt';
},
});
assert.equal((await runtime.getStatus()).vad.enabled, true);
await runtime.setVadEnabled(false);
await runtime.setVadEnabled(true);
assert.equal((await runtime.start()).ok, true);
});
test('speech model downloads share the job lock and cancellation', async () => {
let enter = () => {};
const started = new Promise<void>((resolve) => {
enter = resolve;
});
const { runtime } = fixture({
downloadVad: async ({ signal }) => {
enter();
return new Promise((_, reject) =>
signal?.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }),
);
},
});
const download = runtime.downloadVad();
await started;
assert.equal((await runtime.start()).ok, false);
await assert.rejects(runtime.setVadEnabled(true), /current operation/);
runtime.cancel();
assert.deepEqual(await download, { ok: false, message: 'Cancelled.' });
});
@@ -0,0 +1,264 @@
import path from 'node:path';
import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model';
import {
downloadSubtitleGenerationVadModel,
resolveSubtitleGenerationVadModel,
} from '../../core/services/subtitle-generation-vad-model';
import type { SubtitleGenerationModelId } from '../../shared/subtitle-generation-model-catalog';
import {
downloadSubtitleGenerationModel,
generateJapaneseSubtitles,
resolveSubtitleGenerationModel,
resolveSubtitleGenerationTools,
} from '../../core/services/subtitle-generation';
import type {
SubtitleGenerationConfig,
SubtitleGenerationProgress,
} from '../../shared/subtitle-generation';
import type {
SubtitleGenerationResult,
SubtitleGenerationStatus,
} from '../../shared/subtitle-generation-ipc';
interface GenerationMpvClient {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
request: (command: unknown[]) => Promise<{ error?: string }>;
}
export interface SubtitleGenerationRuntimeDeps {
getConfig: () => SubtitleGenerationConfig;
getModelDirectory: () => string;
getMpvClient: () => GenerationMpvClient | null;
onProgress: (progress: SubtitleGenerationProgress) => void;
generate?: typeof generateJapaneseSubtitles;
download?: typeof downloadSubtitleGenerationModel;
resolveModel?: typeof resolveSubtitleGenerationModel;
resolveTools?: typeof resolveSubtitleGenerationTools;
downloadVad?: typeof downloadSubtitleGenerationVadModel;
resolveVadModel?: typeof resolveSubtitleGenerationVadModel;
}
async function currentLocalMedia(client: GenerationMpvClient | null): Promise<string | null> {
if (!client?.connected) return null;
const media = await client.requestProperty('path');
if (typeof media !== 'string' || !media || /^[a-z][a-z\d+.-]*:\/\//i.test(media)) return null;
if (path.isAbsolute(media)) return path.normalize(media);
const directory = await client.requestProperty('working-directory');
return typeof directory === 'string' ? path.resolve(directory, media) : null;
}
function selectedAudioIndex(tracks: unknown): number {
if (!Array.isArray(tracks)) throw new Error('Unable to inspect the selected audio track.');
for (const track of tracks) {
if (
!track ||
typeof track !== 'object' ||
!('type' in track) ||
track.type !== 'audio' ||
!('selected' in track) ||
track.selected !== true
)
continue;
if ('external' in track && track.external === true)
throw new Error('Select an audio track inside the local video before generating subtitles.');
if (
'ff-index' in track &&
typeof track['ff-index'] === 'number' &&
Number.isInteger(track['ff-index']) &&
track['ff-index'] >= 0
)
return track['ff-index'];
throw new Error('The selected audio track has no FFmpeg stream index.');
}
throw new Error('Select an audio track in mpv before generating subtitles.');
}
export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeDeps) {
let controller: AbortController | null = null;
let progress: SubtitleGenerationProgress | null = null;
let lastResult: SubtitleGenerationResult | null = null;
let selectedModel: SubtitleGenerationModelId | null = null;
let vadEnabled: boolean | null = null;
function getConfig(): SubtitleGenerationConfig {
const config = deps.getConfig();
return {
...config,
managedModel: selectedModel ?? config.managedModel,
vadModelPath:
vadEnabled === null
? config.vadModelPath
: vadEnabled
? config.vadModelPath.trim() ||
path.resolve(deps.getModelDirectory(), SUBTITLE_GENERATION_VAD_MODEL.filename)
: '',
};
}
const report = (update: SubtitleGenerationProgress) => {
progress = update;
deps.onProgress(update);
};
async function run(
operation: (signal: AbortSignal) => Promise<SubtitleGenerationResult>,
): Promise<SubtitleGenerationResult> {
if (controller)
return { ok: false, message: 'A subtitle generation or model download is already running.' };
const active = new AbortController();
controller = active;
progress = null;
lastResult = null;
try {
lastResult = await operation(active.signal);
} catch (error) {
lastResult = {
ok: false,
message: active.signal.aborted
? 'Cancelled.'
: error instanceof Error
? error.message
: String(error),
};
} finally {
controller = null;
}
return lastResult;
}
async function getStatus(): Promise<SubtitleGenerationStatus> {
const config = getConfig();
const model = await (deps.resolveModel ?? resolveSubtitleGenerationModel)(
config,
deps.getModelDirectory(),
);
const mediaPath = await currentLocalMedia(deps.getMpvClient()).catch(() => null);
return {
model,
vad: {
enabled: Boolean(config.vadModelPath.trim()),
model: await (deps.resolveVadModel ?? resolveSubtitleGenerationVadModel)(
deps.getConfig(),
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,
running: controller !== null,
progress,
lastResult,
};
}
return {
getStatus,
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
if (controller)
throw new Error('Wait for the current operation before changing speech detection.');
vadEnabled = enabled;
lastResult = null;
progress = null;
return getStatus();
},
downloadVad(): Promise<SubtitleGenerationResult> {
return run(async (signal) => {
await (deps.downloadVad ?? downloadSubtitleGenerationVadModel)({
config: deps.getConfig(),
modelDirectory: deps.getModelDirectory(),
onProgress: report,
signal,
});
return { ok: true, message: 'Speech detection model is ready.' };
});
},
async selectModel(model: SubtitleGenerationModelId): Promise<SubtitleGenerationStatus> {
if (controller) throw new Error('Wait for the current operation before changing models.');
if (deps.getConfig().modelPath.trim())
throw new Error('Clear Model Path in Settings before choosing a managed model.');
selectedModel = model;
lastResult = null;
progress = null;
return getStatus();
},
cancel(): void {
controller?.abort();
},
download(): Promise<SubtitleGenerationResult> {
return run(async (signal) => {
await (deps.download ?? downloadSubtitleGenerationModel)({
config: getConfig(),
modelDirectory: deps.getModelDirectory(),
onProgress: report,
signal,
});
return { ok: true, message: 'Model downloaded. Ready to generate Japanese subtitles.' };
});
},
start(): Promise<SubtitleGenerationResult> {
return run(async (signal) => {
const config = getConfig();
if (config.vadModelPath.trim()) {
const vad = await (deps.resolveVadModel ?? resolveSubtitleGenerationVadModel)(
deps.getConfig(),
deps.getModelDirectory(),
);
if (vad.kind === 'missing')
throw new Error(
'Download the optional speech detection model or turn off Focus on spoken dialogue.',
);
if (vad.kind === 'invalid') throw new Error(vad.message);
}
const client = deps.getMpvClient();
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'));
if ((await currentLocalMedia(client)) !== mediaPath)
throw new Error('The current media changed. Start generation again.');
signal.throwIfAborted();
const outputPath = await (deps.generate ?? generateJapaneseSubtitles)({
config,
modelDirectory: deps.getModelDirectory(),
mediaPath,
audioStreamIndex,
onProgress: report,
signal,
});
// Saving succeeds even if playback changes or disconnects during the job.
try {
const playingMedia = await currentLocalMedia(client);
if (!signal.aborted && deps.getMpvClient() === client && playingMedia === mediaPath) {
const loaded = await client.request([
'sub-add',
outputPath,
'select',
'Generated Japanese',
'ja',
]);
if (loaded.error && loaded.error !== 'success') throw new Error(loaded.error);
const delay = await client.request(['set_property', 'sub-delay', 0]);
if (delay.error && delay.error !== 'success') throw new Error(delay.error);
return {
ok: true,
outputPath,
message: `Japanese subtitles saved and loaded: ${outputPath}`,
};
}
return {
ok: true,
outputPath,
message: `Subtitles saved: ${outputPath}. ${signal.aborted ? 'Cancelled after saving; the file was not loaded.' : 'Playback changed, so the file was not loaded.'}`,
};
} catch (error) {
return {
ok: true,
outputPath,
message: `Subtitles saved: ${outputPath}. Could not finish loading into mpv: ${error instanceof Error ? error.message : String(error)}`,
};
}
});
},
};
}