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
+2
View File
@@ -1,4 +1,5 @@
import { RawConfig, ResolvedConfig } from '../types/config';
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../shared/subtitle-generation';
import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core';
import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion';
import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations';
@@ -54,6 +55,7 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
subtitlePosition,
keybindings,
websocket,
+1
View File
@@ -99,6 +99,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openTsukihime: 'Ctrl+Shift+T',
openSubtitleGeneration: 'Ctrl+Shift+G',
openSessionHelp: 'CommandOrControl+Slash',
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
+6
View File
@@ -628,6 +628,12 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.openSessionHelp,
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
},
{
path: 'shortcuts.openSubtitleGeneration',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openSubtitleGeneration,
description: 'Accelerator that opens the standalone Japanese subtitle generation modal.',
},
{
path: 'shortcuts.openControllerSelect',
kind: 'string',
@@ -1,10 +1,46 @@
import { ResolvedConfig } from '../../types/config';
import { ConfigOptionRegistryEntry } from './shared';
import { SUBTITLE_GENERATION_MODELS } from '../../shared/subtitle-generation-model-catalog';
export function buildSubtitleConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
return [
...(
['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const
).map((key) => ({
path: `subtitleGeneration.${key}`,
kind: 'string' as const,
defaultValue: defaultConfig.subtitleGeneration[key],
description: {
whisperPath:
'Optional path override for whisper.cpp. Leave empty to find whisper-cli on PATH.',
modelPath:
'Path to an existing multilingual whisper.cpp GGML model. Leave empty to use a SubMiner-managed model. A configured path always takes precedence.',
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 while retaining uncertain audible sections, which may include songs. Leave empty to transcribe the full audio.',
vadPath:
'Optional speech detector executable override. With vadModelPath configured, leave empty to find whisper-vad-speech-segments or vad-speech-segments on PATH.',
}[key],
})),
{
path: 'subtitleGeneration.managedModel',
kind: 'enum',
enumValues: SUBTITLE_GENERATION_MODELS.map((model) => model.id),
defaultValue: defaultConfig.subtitleGeneration.managedModel,
description:
'Multilingual whisper.cpp model to use when modelPath is empty. Download it explicitly from the generation modal or launcher.',
},
{
path: 'subtitleGeneration.threads',
kind: 'number',
defaultValue: defaultConfig.subtitleGeneration.threads,
description: 'Positive integer CPU thread count for whisper.cpp Japanese transcription.',
},
{
path: 'subtitleStyle.primaryDefaultMode',
kind: 'enum',
@@ -1,6 +1,15 @@
import { ConfigTemplateSection } from './shared';
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Japanese Subtitle Generation',
description: [
'Generate timed Japanese subtitles from local audio using whisper.cpp.',
'Configure an existing GGML model path or explicitly download a SubMiner-managed model.',
],
notes: ['Hot-reload: settings apply to the next generation or model download.'],
key: 'subtitleGeneration',
},
{
title: 'Visible Overlay Auto-Start',
description: [
+1
View File
@@ -237,6 +237,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'openRuntimeOptions',
'openJimaku',
'openTsukihime',
'openSubtitleGeneration',
'openSessionHelp',
'openControllerSelect',
'openControllerDebug',
+12
View File
@@ -1,5 +1,6 @@
import { ResolvedConfig } from '../../types/config';
import { ResolveContext } from './context';
import { resolveSubtitleGenerationConfig } from '../../shared/subtitle-generation';
import {
asBoolean,
asColor,
@@ -46,6 +47,17 @@ function applySubtitleHoverTokenCssCompatibility(
export function applySubtitleDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
resolved.subtitleGeneration = resolveSubtitleGenerationConfig(
src.subtitleGeneration,
(key, value, message) => {
const configPath = key === 'subtitleGeneration' ? key : `subtitleGeneration.${key}`;
const fallback =
key === 'subtitleGeneration'
? resolved.subtitleGeneration
: Object.entries(resolved.subtitleGeneration).find(([name]) => name === key)?.[1];
warn(configPath, value, fallback, message);
},
);
if (isObject(src.jimaku)) {
const apiKey = asString(src.jimaku.apiKey);
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveConfig } from '../resolve';
import { buildConfigSettingsRegistry } from '../settings/registry';
import { SUBTITLE_GENERATION_MODELS } from '../../shared/subtitle-generation-model-catalog';
import { resolveSubtitleGenerationConfig } from '../../shared/subtitle-generation';
test('every downloadable multilingual model is accepted by config and offered in settings', () => {
const settings = buildConfigSettingsRegistry(resolveConfig({}).resolved).find(
(entry) => entry.configPath === 'subtitleGeneration.managedModel',
);
assert.deepEqual(
settings?.enumValues,
SUBTITLE_GENERATION_MODELS.map((model) => model.id),
);
for (const { id } of SUBTITLE_GENERATION_MODELS) {
const { resolved, warnings } = resolveConfig({ subtitleGeneration: { managedModel: id } });
assert.equal(resolved.subtitleGeneration.managedModel, id);
assert.equal(warnings.length, 0);
}
});
test('generation config rejects English-only, unknown, and prototype model names', () => {
for (const managedModel of ['tiny.en', 'small.en-q5_1', 'unknown', 'toString', '__proto__']) {
const warnings: string[] = [];
const resolved = resolveSubtitleGenerationConfig({ managedModel }, (key) => warnings.push(key));
assert.equal(resolved.managedModel, 'small');
assert.equal(warnings.length, 1);
}
});
test('generation config is resolved and its external model path is editable without restart', () => {
const { resolved, warnings } = resolveConfig({
subtitleGeneration: { modelPath: '/models/japanese.bin', managedModel: 'medium', threads: 8 },
});
assert.equal(resolved.subtitleGeneration.modelPath, '/models/japanese.bin');
assert.equal(resolved.subtitleGeneration.managedModel, 'medium');
assert.equal(warnings.length, 0);
const field = buildConfigSettingsRegistry(resolved).find(
(entry) => entry.configPath === 'subtitleGeneration.modelPath',
);
assert.equal(field?.category, 'integrations');
assert.equal(field?.restartBehavior, 'hot-reload');
});
test('generation executable overrides default to empty and accept blank values without warnings', () => {
for (const subtitleGeneration of [
{},
{ whisperPath: '', ffmpegPath: '', ffprobePath: '' },
{ whisperPath: ' ', ffmpegPath: ' ', ffprobePath: ' ' },
]) {
const { resolved, warnings } = resolveConfig({ subtitleGeneration });
assert.equal(resolved.subtitleGeneration.whisperPath, '');
assert.equal(resolved.subtitleGeneration.ffmpegPath, '');
assert.equal(resolved.subtitleGeneration.ffprobePath, '');
assert.equal(warnings.length, 0);
}
});
test('subtitle generation shortcut can be customized or disabled', () => {
assert.equal(resolveConfig({}).resolved.shortcuts.openSubtitleGeneration, 'Ctrl+Shift+G');
assert.equal(
resolveConfig({ shortcuts: { openSubtitleGeneration: 'Ctrl+Alt+G' } }).resolved.shortcuts
.openSubtitleGeneration,
'Ctrl+Alt+G',
);
assert.equal(
resolveConfig({ shortcuts: { openSubtitleGeneration: null } }).resolved.shortcuts
.openSubtitleGeneration,
null,
);
});
test('dialogue detection paths are optional, validated, and editable in Settings', () => {
const { resolved, warnings } = resolveConfig({
subtitleGeneration: { vadModelPath: ' /models/silero.bin ', vadPath: ' /bin/vad ' },
});
assert.equal(resolved.subtitleGeneration.vadModelPath, '/models/silero.bin');
assert.equal(resolved.subtitleGeneration.vadPath, '/bin/vad');
assert.equal(warnings.length, 0);
for (const key of ['vadModelPath', 'vadPath'] as const) {
const field = buildConfigSettingsRegistry(resolved).find(
(entry) => entry.configPath === `subtitleGeneration.${key}`,
);
assert.equal(field?.category, 'integrations');
assert.equal(field?.restartBehavior, 'hot-reload');
assert.equal(resolveConfig({}).resolved.subtitleGeneration[key], '');
assert.equal(resolveConfig({ subtitleGeneration: { [key]: false } }).warnings.length, 1);
}
});
+6 -1
View File
@@ -448,6 +448,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('subsync.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('subtitleGeneration.')) {
return { category: 'integrations', section: 'Japanese Subtitle Generation' };
}
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
@@ -620,6 +623,7 @@ function subsectionForPath(path: string): string | undefined {
leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' ||
leaf === 'openTsukihime' ||
leaf === 'openSubtitleGeneration' ||
leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' ||
leaf === 'openControllerDebug'
@@ -728,7 +732,8 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
pathStartsWith(path, 'notifications') ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync')
pathStartsWith(path, 'subsync') ||
pathStartsWith(path, 'subtitleGeneration')
) {
return 'hot-reload';
}