mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
501 lines
19 KiB
TypeScript
501 lines
19 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { constants } from 'node:fs';
|
|
import { access, 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';
|
|
import {
|
|
DEFAULT_SUBTITLE_GENERATION_CONFIG,
|
|
resolveSubtitleGenerationConfig,
|
|
type SubtitleGenerationProgress,
|
|
} from '../../shared/subtitle-generation';
|
|
import {
|
|
downloadSubtitleGenerationModel,
|
|
ensureWritableDirectory,
|
|
generateJapaneseSubtitles,
|
|
resolveSubtitleGenerationModel,
|
|
} from './subtitle-generation';
|
|
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
|
|
|
async function fixture(run: (directory: string) => Promise<void>) {
|
|
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-generation-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;
|
|
}
|
|
|
|
function modelHeader(vocabularySize = 51865): Buffer {
|
|
const header = Buffer.alloc(8);
|
|
header.writeUInt32LE(0x67676d6c, 0);
|
|
header.writeInt32LE(vocabularySize, 4);
|
|
return header;
|
|
}
|
|
|
|
async function generationFixture(directory: string) {
|
|
const modelPath = path.join(directory, 'external.bin');
|
|
const mediaPath = path.join(directory, 'episode.mkv');
|
|
const callsPath = path.join(directory, 'calls.jsonl');
|
|
await writeFile(modelPath, modelHeader());
|
|
await writeFile(mediaPath, 'local media');
|
|
const record = `require('node:fs').appendFileSync(${JSON.stringify(callsPath)}, JSON.stringify(process.argv.slice(2)) + '\\n');`;
|
|
const ffprobePath = await executable(
|
|
directory,
|
|
'ffprobe',
|
|
`${record}\nprocess.stdout.write(JSON.stringify({streams: [{index:1,codec_type:'audio',start_time:'10',tags:{language:'eng'}},{index:3,codec_type:'audio',start_time:'12.5',duration:'20',tags:{language:'jpn'}}],format:{start_time:'10',duration:'25'}}));`,
|
|
);
|
|
const ffmpegPath = await executable(
|
|
directory,
|
|
'ffmpeg',
|
|
`${record}
|
|
if (process.argv.at(-1) === '-') {
|
|
process.stderr.write('[silencedetect] silence_end: 25 | silence_duration: 25\\n');
|
|
process.stdout.write('out_time_us=25000000\\nprogress=end\\n');
|
|
} else {
|
|
require('node:fs').writeFileSync(process.argv.at(-1), 'wav');
|
|
process.stdout.write('out_time_');
|
|
setTimeout(() => process.stdout.write('us=10000000\\nprogress=end\\n'), 10);
|
|
}`,
|
|
);
|
|
const whisperPath = await executable(
|
|
directory,
|
|
'whisper-cli',
|
|
`${record}\nconst args=process.argv.slice(2); require('node:fs').writeFileSync(args[args.indexOf('-of')+1]+'.srt', '1\\n00:00:01,000 --> 00:00:02,000\\nこんにちは\\n'); process.stderr.write('whisper_print_progress_callback: progress = '); setTimeout(() => process.stderr.write('55%\\n'), 10);`,
|
|
);
|
|
return {
|
|
config: {
|
|
...DEFAULT_SUBTITLE_GENERATION_CONFIG,
|
|
modelPath,
|
|
ffprobePath,
|
|
ffmpegPath,
|
|
whisperPath,
|
|
},
|
|
mediaPath,
|
|
modelDirectory: path.join(directory, 'models'),
|
|
callsPath,
|
|
};
|
|
}
|
|
|
|
test('config parser accepts supported models and rejects unsafe threads and wrong field types', () => {
|
|
const warnings: string[] = [];
|
|
const result = resolveSubtitleGenerationConfig(
|
|
{ modelPath: '/tmp/whisper.bin', threads: 0, whisperPath: 42, managedModel: 'large-v3-turbo' },
|
|
(key) => warnings.push(key),
|
|
);
|
|
assert.equal(result.modelPath, '/tmp/whisper.bin');
|
|
assert.equal(result.managedModel, 'large-v3-turbo');
|
|
assert.equal(result.threads, DEFAULT_SUBTITLE_GENERATION_CONFIG.threads);
|
|
assert.deepEqual(warnings, ['whisperPath', 'threads']);
|
|
});
|
|
|
|
test('generation uses reference cuts with and without VAD while preserving media offsets', () =>
|
|
fixture(async (directory) => {
|
|
const input = await generationFixture(directory);
|
|
const ffmpegPath = await executable(
|
|
directory,
|
|
'reference-ffmpeg',
|
|
`
|
|
const fs = require('node:fs');
|
|
const args = process.argv.slice(2);
|
|
fs.appendFileSync(${JSON.stringify(input.callsPath)}, JSON.stringify(args) + '\\n');
|
|
if (args.includes('-c:s')) {
|
|
fs.writeFileSync(args.at(-1), '1\\n00:00:24,500 --> 00:00:26,500\\nHello\\n\\n2\\n00:00:45,500 --> 00:00:47,500\\nWorld\\n');
|
|
} else if (args.at(-1) === '-') {
|
|
process.stdout.write('out_time_us=70000000\\nprogress=end\\n');
|
|
} else fs.writeFileSync(args.at(-1), 'wav');
|
|
`,
|
|
);
|
|
const vadPath = await executable(
|
|
directory,
|
|
'reference-vad',
|
|
"process.stdout.write('Detected 1 speech segments:\\nSpeech segment 0: start = 0.000, end = 7000.000\\n');",
|
|
);
|
|
const vadModelPath = path.join(directory, 'vad.bin');
|
|
await writeFile(vadModelPath, 'model');
|
|
for (const vad of [false, true]) {
|
|
await writeFile(input.callsPath, '');
|
|
const output = await generateJapaneseSubtitles({
|
|
...input,
|
|
config: { ...input.config, ffmpegPath, vadPath, vadModelPath: vad ? vadModelPath : '' },
|
|
references: [
|
|
{ label: 'English Full', delaySeconds: 0, source: { kind: 'embedded', streamIndex: 5 } },
|
|
],
|
|
});
|
|
const calls: string[][] = (await readFile(input.callsPath, 'utf8'))
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => JSON.parse(line));
|
|
const clips = calls.filter((args) => args.includes('-ss'));
|
|
assert.equal(clips[0]?.[clips[0].indexOf('-t') + 1], '22.25');
|
|
assert.equal(clips[1]?.[clips[1].indexOf('-ss') + 1], '21.75');
|
|
const srt = await readFile(output, 'utf8');
|
|
assert.match(srt, /00:00:03,500 --> 00:00:04,500/);
|
|
assert.match(srt, /00:00:25,250 --> 00:00:26,250/);
|
|
}
|
|
}));
|
|
|
|
test('external model path wins and invalid external models never fall back to download', () =>
|
|
fixture(async (directory) => {
|
|
const input = await generationFixture(directory);
|
|
assert.deepEqual(await resolveSubtitleGenerationModel(input.config, input.modelDirectory), {
|
|
kind: 'external',
|
|
path: input.config.modelPath,
|
|
});
|
|
const invalid = { ...input.config, modelPath: path.join(directory, 'missing.bin') };
|
|
assert.equal(
|
|
(await resolveSubtitleGenerationModel(invalid, input.modelDirectory)).kind,
|
|
'invalid',
|
|
);
|
|
await assert.rejects(
|
|
downloadSubtitleGenerationModel({ ...input, config: invalid }),
|
|
/Cannot read model/,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await resolveSubtitleGenerationModel(
|
|
{ ...input.config, modelPath: '' },
|
|
input.modelDirectory,
|
|
)
|
|
).kind,
|
|
'missing',
|
|
);
|
|
}));
|
|
|
|
test('English-only and incompatible external models are rejected before transcription', () =>
|
|
fixture(async (directory) => {
|
|
const input = await generationFixture(directory);
|
|
await writeFile(input.config.modelPath, modelHeader(51864));
|
|
const englishOnly = await resolveSubtitleGenerationModel(input.config, input.modelDirectory);
|
|
assert.equal(englishOnly.kind, 'invalid');
|
|
assert.ok('message' in englishOnly);
|
|
assert.match(englishOnly.message, /English-only/);
|
|
await assert.rejects(generateJapaneseSubtitles(input), /requires a multilingual model/);
|
|
await writeFile(input.config.modelPath, 'not a GGML model');
|
|
await assert.rejects(generateJapaneseSubtitles(input), /Unsupported model format/);
|
|
await writeFile(input.config.modelPath, modelHeader().subarray(0, 4));
|
|
await assert.rejects(generateJapaneseSubtitles(input), /Unsupported model format/);
|
|
await assert.rejects(readFile(input.callsPath), /ENOENT/);
|
|
}));
|
|
|
|
test('generation picks Japanese audio, restores timeline offsets, reports split progress, and preserves existing output', () =>
|
|
fixture(async (directory) => {
|
|
const input = await generationFixture(directory);
|
|
const existing = path.join(directory, 'episode.ja.generated.srt');
|
|
await writeFile(existing, 'user subtitles');
|
|
const progress: SubtitleGenerationProgress[] = [];
|
|
const result = await generateJapaneseSubtitles({
|
|
...input,
|
|
onProgress: (event) => progress.push(event),
|
|
});
|
|
assert.equal(result, path.join(directory, 'episode.ja.generated.1.srt'));
|
|
assert.equal(await readFile(existing, 'utf8'), 'user subtitles');
|
|
assert.match(await readFile(result, 'utf8'), /00:00:03,500 --> 00:00:04,500\nこんにちは/);
|
|
const calls = (await readFile(input.callsPath, 'utf8'))
|
|
.trim()
|
|
.split('\n')
|
|
.map((line): unknown => JSON.parse(line));
|
|
assert.ok(Array.isArray(calls[1]));
|
|
assert.ok(calls[1].includes('0:3'));
|
|
assert.ok(Array.isArray(calls[2]));
|
|
assert.ok(calls[2].includes('ja'));
|
|
assert.ok(calls[2].includes('-osrt'));
|
|
assert.ok(progress.some((event) => event.stage === 'extract' && event.percent === 50));
|
|
assert.ok(progress.some((event) => event.stage === 'transcribe' && event.percent === 55));
|
|
assert.deepEqual(
|
|
(await readdir(directory)).filter((file) => file.startsWith('.subminer-')),
|
|
[],
|
|
);
|
|
}));
|
|
|
|
test('explicit audio stream and output path are respected without overwriting existing files', () =>
|
|
fixture(async (directory) => {
|
|
const input = await generationFixture(directory);
|
|
const outputPath = path.join(directory, 'chosen.srt');
|
|
const result = await generateJapaneseSubtitles({ ...input, audioStreamIndex: 1, outputPath });
|
|
assert.equal(result, outputPath);
|
|
assert.match(await readFile(result, 'utf8'), /00:00:01,000 --> 00:00:02,000/);
|
|
await assert.rejects(generateJapaneseSubtitles({ ...input, outputPath }), /already exists/);
|
|
await assert.rejects(
|
|
generateJapaneseSubtitles({ ...input, audioStreamIndex: 99 }),
|
|
/stream 99 was not found/,
|
|
);
|
|
}));
|
|
|
|
test('dialogue generation isolates Whisper state between passages and preserves media timing', () =>
|
|
fixture(async (directory) => {
|
|
const input = await generationFixture(directory);
|
|
const vadModelPath = path.join(directory, 'vad.bin');
|
|
await writeFile(vadModelPath, 'speech detector model');
|
|
const vadPath = await executable(
|
|
directory,
|
|
'vad',
|
|
"process.stdout.write('Detected 2 speech segments:\\nSpeech segment 0: start = 1000.00, end = 1100.00\\nSpeech segment 1: start = 10000.00, end = 10100.00\\n');",
|
|
);
|
|
const whisperPath = await executable(
|
|
directory,
|
|
'dialogue-whisper',
|
|
`const args = process.argv.slice(2);
|
|
let files = 0;
|
|
for (let i = 0; i < args.length; i++) if (args[i] === '-of') {
|
|
// Reproduce a decoder that degenerates when reused for another audio file.
|
|
const text = files++ === 0 ? 'はい' : 'お' + 'ぉ'.repeat(40) + 'ぇ'.repeat(178);
|
|
require('node:fs').writeFileSync(args[i + 1] + '.srt', '1\\n00:00:00,000 --> 00:01:39,000\\n' + text + '\\n');
|
|
}`,
|
|
);
|
|
const output = await generateJapaneseSubtitles({
|
|
...input,
|
|
config: { ...input.config, vadModelPath, vadPath, whisperPath },
|
|
});
|
|
assert.equal(
|
|
await readFile(output, 'utf8'),
|
|
'1\n00:00:12,500 --> 00:00:13,500\nはい\n\n2\n00:01:42,500 --> 00:01:43,500\nはい\n',
|
|
);
|
|
}));
|
|
|
|
test('dialogue generation uses quiet pauses and stitches overlapping chunks on the media timeline', () =>
|
|
fixture(async (directory) => {
|
|
const input = await generationFixture(directory);
|
|
const vadModelPath = path.join(directory, 'vad.bin');
|
|
await writeFile(vadModelPath, 'speech detector model');
|
|
const vadPath = await executable(
|
|
directory,
|
|
'vad',
|
|
`const assert = require('node:assert/strict');
|
|
const args = process.argv.slice(2);
|
|
assert.equal(args[args.indexOf('--vad-min-speech-duration-ms') + 1], '100');
|
|
assert.equal(args[args.indexOf('-vp') + 1], '350');
|
|
process.stdout.write('Detected 1 speech segments:\\nSpeech segment 0: start = 1000.00, end = 4500.00\\n');`,
|
|
);
|
|
const ffmpegPath = await executable(
|
|
directory,
|
|
'pause-ffmpeg',
|
|
`const args = process.argv.slice(2);
|
|
if (args.includes('silencedetect=noise=-50dB:d=0.5')) {
|
|
process.stderr.write('[silencedetect] silence_end: 45 | silence_duration: 45\\n');
|
|
process.stdout.write('out_time_us=45000000\\n');
|
|
} else if (args.includes('-af')) {
|
|
process.stderr.write('[silencedetect] silence_end: 28.1 | silence_duration: 0.2\\n');
|
|
} else {
|
|
require('node:fs').writeFileSync(args.at(-1), 'wav');
|
|
}`,
|
|
);
|
|
const whisperPath = await executable(
|
|
directory,
|
|
'overlap-whisper',
|
|
`const args = process.argv.slice(2);
|
|
for (let i = 0; i < args.length; i++) if (args[i] === '-of') {
|
|
const time = args[i + 1].endsWith('speech-0')
|
|
? '00:00:17,800 --> 00:00:18,250'
|
|
: '00:00:00,100 --> 00:00:00,650';
|
|
require('node:fs').writeFileSync(args[i + 1] + '.srt', '1\\n' + time + '\\nはい\\n');
|
|
}`,
|
|
);
|
|
const output = await generateJapaneseSubtitles({
|
|
...input,
|
|
config: { ...input.config, vadModelPath, vadPath, ffmpegPath, whisperPath },
|
|
});
|
|
assert.equal(await readFile(output, 'utf8'), '1\n00:00:30,300 --> 00:00:30,900\nはい\n');
|
|
}));
|
|
|
|
test('silent audio with no detected speech stops generation without transcription', () =>
|
|
fixture(async (directory) => {
|
|
const input = await generationFixture(directory);
|
|
const vadModelPath = path.join(directory, 'vad.bin');
|
|
await writeFile(vadModelPath, 'speech detector model');
|
|
const vadPath = await executable(
|
|
directory,
|
|
'vad',
|
|
"process.stdout.write('Detected 0 speech segments:\\n');",
|
|
);
|
|
await assert.rejects(
|
|
generateJapaneseSubtitles({ ...input, config: { ...input.config, vadModelPath, vadPath } }),
|
|
/No spoken dialogue detected/,
|
|
);
|
|
assert.equal((await readFile(input.callsPath, 'utf8')).trim().split('\n').length, 3);
|
|
assert.deepEqual(
|
|
(await readdir(directory)).filter((file) => file.endsWith('.srt')),
|
|
[],
|
|
);
|
|
}));
|
|
|
|
test('dialogue generation retains audible audio rejected by VAD', () =>
|
|
fixture(async (directory) => {
|
|
const input = await generationFixture(directory);
|
|
const vadModelPath = path.join(directory, 'vad.bin');
|
|
await writeFile(vadModelPath, 'speech detector model');
|
|
const vadPath = await executable(
|
|
directory,
|
|
'vad',
|
|
"process.stdout.write('Detected 0 speech segments:\\n');",
|
|
);
|
|
const ffmpegPath = await executable(
|
|
directory,
|
|
'audible-ffmpeg',
|
|
`
|
|
const args = process.argv.slice(2);
|
|
if (args.at(-1) === '-') {
|
|
process.stdout.write('out_time_us=19000000\\nprogress=end\\n');
|
|
} else {
|
|
require('node:fs').writeFileSync(args.at(-1), 'wav');
|
|
}`,
|
|
);
|
|
const output = await generateJapaneseSubtitles({
|
|
...input,
|
|
config: { ...input.config, vadModelPath, vadPath, ffmpegPath },
|
|
});
|
|
assert.match(await readFile(output, 'utf8'), /00:00:03,500 --> 00:00:04,500\nこんにちは/);
|
|
}));
|
|
|
|
test('empty executable paths find tools on PATH and explicit overrides take precedence', () =>
|
|
fixture(async (directory) => {
|
|
const input = await generationFixture(directory);
|
|
const previousPath = process.env.PATH;
|
|
process.env.PATH = directory;
|
|
try {
|
|
const config = {
|
|
...DEFAULT_SUBTITLE_GENERATION_CONFIG,
|
|
modelPath: input.config.modelPath,
|
|
};
|
|
const result = await generateJapaneseSubtitles({ ...input, config });
|
|
assert.match(await readFile(result, 'utf8'), /こんにちは/);
|
|
await assert.rejects(
|
|
generateJapaneseSubtitles({
|
|
...input,
|
|
config: { ...config, ffprobePath: path.join(directory, 'missing-override') },
|
|
}),
|
|
/missing-override \(subtitleGeneration\.ffprobePath\) is not an executable file/,
|
|
);
|
|
} finally {
|
|
if (previousPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = previousPath;
|
|
}
|
|
}));
|
|
|
|
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(
|
|
generateJapaneseSubtitles({ ...input, mediaPath: 'https://example.com/movie.mkv' }),
|
|
/local media file/,
|
|
);
|
|
await assert.rejects(
|
|
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(
|
|
'directory permission preflight rejects a write-only destination',
|
|
{
|
|
skip: process.platform === 'win32' || process.getuid?.() === 0,
|
|
},
|
|
() =>
|
|
fixture(async (directory) => {
|
|
const writeOnly = path.join(directory, 'write-only');
|
|
await mkdir(writeOnly);
|
|
try {
|
|
await chmod(writeOnly, 0o200);
|
|
await access(writeOnly, constants.W_OK);
|
|
await assert.rejects(ensureWritableDirectory(writeOnly), /write-only is not writable/);
|
|
} finally {
|
|
await chmod(writeOnly, 0o755);
|
|
}
|
|
}),
|
|
);
|
|
|
|
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(
|
|
directory,
|
|
'slow',
|
|
"process.stdout.write('ready\\n');setInterval(()=>{},1000);",
|
|
);
|
|
const controller = new AbortController();
|
|
await assert.rejects(
|
|
runSubtitleGenerationProcess({
|
|
command: slow,
|
|
args: [],
|
|
signal: controller.signal,
|
|
onLine: () => controller.abort(),
|
|
}),
|
|
/cancelled/,
|
|
);
|
|
const failed = await executable(
|
|
directory,
|
|
'failed',
|
|
"process.stderr.write('x'.repeat(100000));process.exitCode=7;",
|
|
);
|
|
await assert.rejects(
|
|
runSubtitleGenerationProcess({ command: failed, args: [] }),
|
|
(error: unknown) =>
|
|
error instanceof Error &&
|
|
error.message.length < 66000 &&
|
|
error.message.includes('status 7'),
|
|
);
|
|
}));
|
|
|
|
test('download uses the pinned model revision and removes files that fail integrity', () =>
|
|
fixture(async (directory) => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = Object.assign(async (request: string | URL | Request) => {
|
|
assert.equal(
|
|
request,
|
|
'https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-small.bin',
|
|
);
|
|
return new Response('not a model');
|
|
}, originalFetch);
|
|
try {
|
|
await assert.rejects(
|
|
downloadSubtitleGenerationModel({
|
|
config: DEFAULT_SUBTITLE_GENERATION_CONFIG,
|
|
modelDirectory: directory,
|
|
}),
|
|
/integrity verification/,
|
|
);
|
|
assert.deepEqual(await readdir(directory), []);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
}));
|