From 773664db784465abfda796f72053921d266b9dfe Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 7 Sep 2026 23:28:32 -0700 Subject: [PATCH] feat(subtitles): refine dialogue-focused subtitle generation - Split long speech at quiet pauses with overlapping context - Stitch overlapping cues without merging repeated dialogue - Clarify dialogue controls and hide the sidebar button when cues load --- changes/japanese-subtitle-generation.md | 4 +- docs-site/configuration.md | 2 +- docs-site/subtitle-generation.md | 12 ++-- docs-site/subtitle-sidebar.md | 2 +- .../subtitle-generation-chunks.test.ts | 38 +++++++++++ .../services/subtitle-generation-chunks.ts | 65 +++++++++++++++++++ .../services/subtitle-generation-dialogue.ts | 27 ++++++-- .../services/subtitle-generation-models.ts | 2 +- .../services/subtitle-generation-pauses.ts | 35 ++++++++++ .../subtitle-generation-speech.test.ts | 19 +++++- .../services/subtitle-generation-speech.ts | 4 +- src/core/services/subtitle-generation.test.ts | 52 ++++++++++++++- .../runtime/subtitle-generation-runtime.ts | 2 +- src/renderer/index.html | 4 +- .../modals/subtitle-generation-view.ts | 2 +- src/renderer/modals/subtitle-generation.ts | 4 +- src/renderer/style.css | 4 ++ 17 files changed, 251 insertions(+), 27 deletions(-) create mode 100644 src/core/services/subtitle-generation-chunks.test.ts create mode 100644 src/core/services/subtitle-generation-chunks.ts create mode 100644 src/core/services/subtitle-generation-pauses.ts diff --git a/changes/japanese-subtitle-generation.md b/changes/japanese-subtitle-generation.md index 8f9da780..e93f70e2 100644 --- a/changes/japanese-subtitle-generation.md +++ b/changes/japanese-subtitle-generation.md @@ -1,6 +1,6 @@ type: added area: subtitles -- Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the subtitle sidebar button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. +- 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. -- Optionally select Prioritize dialogue in the modal and download the separate Silero speech detection model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. With the detector executable installed, transcribe separate speech passages at their original positions, reset text context between passages, and keep subtitle cues within those passages instead of spanning music breaks. +- 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 overlap cues. Keep original media timing and separate repeated dialogue without spanning omitted music breaks. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 684c10f8..4f827ea0 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1205,7 +1205,7 @@ Open the standalone modal with `Ctrl+Shift+G`, configurable through `shortcuts.o `subtitleGeneration` configures local Japanese transcription for both the launcher and overlay. In **Settings → Integrations → Japanese Subtitle Generation**, set `modelPath` to an existing multilingual whisper.cpp GGML model, or leave it empty and choose a `managedModel` as the default. The generation modal lets you select another model for the current session, with download sizes and accuracy versus speed guidance. Downloads are explicit. Leave `whisperPath`, `ffmpegPath`, and `ffprobePath` empty to find the executables on `PATH`, or set them to override the executable paths. `threads` controls the CPU thread count. Settings apply to the next operation. See [subtitle generation](/subtitle-generation) for setup and behavior, and the [generated configuration example](/config.example.jsonc) for defaults. -The generation modal offers an optional **Prioritize dialogue** checkbox and a separate Silero model download. Set `subtitleGeneration.vadModelPath` to a Silero GGML VAD model to make dialogue mode the default. `vadPath` overrides the speech detector executable. See [dialogue generation setup](/subtitle-generation#prioritizing-spoken-dialogue) for session behavior, the additional tool, and limitations. +The generation modal offers an optional **Focus on spoken dialogue** checkbox and a separate Silero model download. Set `subtitleGeneration.vadModelPath` to a Silero GGML VAD model to make dialogue mode the default. `vadPath` overrides the speech detector executable. See [dialogue generation setup](/subtitle-generation#prioritizing-spoken-dialogue) for session behavior, the additional tool, and limitations. ### Subtitle Sync diff --git a/docs-site/subtitle-generation.md b/docs-site/subtitle-generation.md index 6a985166..7141e211 100644 --- a/docs-site/subtitle-generation.md +++ b/docs-site/subtitle-generation.md @@ -17,15 +17,17 @@ See the [generated configuration example](/config.example.jsonc) for current def ## Prioritizing spoken dialogue -To focus on dialogue, check the optional **Prioritize dialogue** box in the generation modal. If the speech detection model is missing, click **Download Silero** 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. +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. 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. -With speech detection configured, SubMiner transcribes short speech passages separately and restores each passage's position on the original audio timeline. It resets transcription context between passages and limits subtitle cues to the passage that produced them. A line cannot stretch across an omitted music break, and repeated dialogue in separate passages remains separate. Progress reports completed batches of dialogue passages. +With speech detection configured, SubMiner transcribes short speech passages separately and restores each passage's position on the original audio timeline. Detection retains brief utterances and includes extra audio around speech to reduce clipped syllables. If the detector returns a long passage, SubMiner looks for quiet pauses near chunk boundaries. Adjacent chunks overlap slightly to provide context when speech continues through a cut. Matching subtitle cues in that overlap are combined; repeated dialogue at separate times remains separate. -This mode prioritizes spoken dialogue over songs and background sounds. It can miss quiet speech or speech mixed with loud music, and recognition errors are still possible. Uncheck **Prioritize dialogue** to return to full-audio transcription for the session, or clear `vadModelPath` to change the default. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved. +SubMiner resets transcription context between passages and limits subtitle cues to the audio supplied for each chunk. A line cannot stretch across an omitted music break. Progress reports completed batches of dialogue passages. These adjustments do not replace Whisper's timestamp estimates or guarantee that every spoken line is recognized. + +This mode prioritizes spoken dialogue over songs and background sounds. It can miss quiet speech or speech mixed with loud music, and recognition errors are still possible. Uncheck **Focus on spoken dialogue** to return to full-audio transcription for the session, or clear `vadModelPath` to change the default. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved. ## Choosing a model @@ -38,9 +40,9 @@ A configured external Model Path takes precedence and hides the managed model pi ## From the overlay 1. Open a local video in mpv and select its Japanese audio track. -2. Press **Ctrl+Shift+G** to open the standalone generation modal. You can also click **Generate Japanese subtitles** in the subtitle sidebar. Neither an open sidebar nor an existing subtitle track is required. +2. Press **Ctrl+Shift+G** to open the standalone generation modal. When the subtitle sidebar has no subtitle lines loaded, it also offers a **Generate Japanese subtitles** button. Neither an open sidebar nor an existing subtitle track is required for the shortcut. 3. Choose a model and download it if prompted, or configure your existing model path in Settings and click **Check again**. -4. Optionally check **Prioritize dialogue** and download Silero if prompted. +4. Optionally check **Focus on spoken dialogue** and click **Download speech detection model** if prompted. 5. Click **Generate subtitles**. The modal shows audio preparation, transcription, and saving progress. Percentages appear when the underlying tool reports them. **Cancel** stops the current operation. Closing the modal lets the job continue; reopening it shows the current progress or result. diff --git a/docs-site/subtitle-sidebar.md b/docs-site/subtitle-sidebar.md index 8bc13624..27d157f5 100644 --- a/docs-site/subtitle-sidebar.md +++ b/docs-site/subtitle-sidebar.md @@ -6,7 +6,7 @@ The sidebar is enabled by default. Set `subtitleSidebar.enabled` to `false` if y ## How It Works -The **Generate Japanese subtitles** button opens [local subtitle generation](/subtitle-generation). You can use it even when the current video has no subtitle track. +When the sidebar has no subtitle lines loaded, the **Generate Japanese subtitles** button opens [local subtitle generation](/subtitle-generation). The button hides once subtitle lines are loaded and stays hidden between lines. Press **Ctrl+Shift+G** to open generation at any time. When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open: diff --git a/src/core/services/subtitle-generation-chunks.test.ts b/src/core/services/subtitle-generation-chunks.test.ts new file mode 100644 index 00000000..09e002a9 --- /dev/null +++ b/src/core/services/subtitle-generation-chunks.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks'; + +test('long speech splits near a pause with context on both sides and no lost audio', () => { + assert.deepEqual(splitSpeechPassages([{ startSeconds: 100, endSeconds: 145 }], [105, 118, 137]), [ + { startSeconds: 100, endSeconds: 118.25 }, + { startSeconds: 117.75, endSeconds: 137.25 }, + { startSeconds: 136.75, endSeconds: 145 }, + ]); +}); + +test('uninterrupted speech retains overlapping context without crossing omitted gaps', () => { + const chunks = splitSpeechPassages([ + { startSeconds: 0, endSeconds: 60 }, + { startSeconds: 100, endSeconds: 100.15 }, + ]); + assert.deepEqual(chunks, [ + { startSeconds: 0, endSeconds: 20.25 }, + { startSeconds: 19.75, endSeconds: 40.25 }, + { startSeconds: 39.75, endSeconds: 60 }, + { startSeconds: 100, endSeconds: 100.15 }, + ]); +}); + +test('chunk stitching removes matching overlap cues but retains repeated dialogue', () => { + const cues = [{ startTime: 19.7, endTime: 20.2, text: 'はい' }]; + appendSpeechChunkCues(cues, [ + { startTime: 19.8, endTime: 20.3, text: 'はい' }, + { startTime: 21, endTime: 21.5, text: 'はい' }, + { startTime: 21.4, endTime: 22, text: 'はい' }, + ]); + assert.deepEqual(cues, [ + { startTime: 19.7, endTime: 20.3, text: 'はい' }, + { startTime: 21, endTime: 21.5, text: 'はい' }, + { startTime: 21.4, endTime: 22, text: 'はい' }, + ]); +}); diff --git a/src/core/services/subtitle-generation-chunks.ts b/src/core/services/subtitle-generation-chunks.ts new file mode 100644 index 00000000..e51b47dc --- /dev/null +++ b/src/core/services/subtitle-generation-chunks.ts @@ -0,0 +1,65 @@ +import type { SubtitleCue } from './subtitle-cue-parser'; +import { SPEECH_PASSAGE_SECONDS, type SpeechPassage } from './subtitle-generation-speech'; + +const CHUNK_CONTEXT_SECONDS = 0.25; +const PAUSE_SEARCH_SECONDS = 5; + +// Prefer a quiet pause near the end of each chunk. Context stays inside detected speech. +export function splitSpeechPassages( + passages: readonly SpeechPassage[], + pauses: readonly number[] = [], +): SpeechPassage[] { + return passages.flatMap((passage) => { + const chunks: SpeechPassage[] = []; + let boundary = passage.startSeconds; + while (boundary < passage.endSeconds) { + const target = boundary + SPEECH_PASSAGE_SECONDS; + let end = Math.min(target, passage.endSeconds); + if (target < passage.endSeconds) { + let latestPause: number | undefined; + for (const time of pauses) { + if ( + time >= target - PAUSE_SEARCH_SECONDS && + time <= target && + (latestPause === undefined || time > latestPause) + ) + latestPause = time; + } + if (latestPause !== undefined) end = latestPause; + } + chunks.push({ + startSeconds: Math.max(passage.startSeconds, boundary - CHUNK_CONTEXT_SECONDS), + endSeconds: Math.min(passage.endSeconds, end + CHUNK_CONTEXT_SECONDS), + }); + boundary = end; + } + return chunks; + }); +} + +// Deduplicate only matching text substantially overlapping cues from earlier chunks. +// Repeated words within the current chunk or at separate times remain separate. +export function appendSpeechChunkCues(cues: SubtitleCue[], incoming: readonly SubtitleCue[]): void { + const previousCount = cues.length; + const matched = new Set(); + for (const cue of incoming) { + const text = cue.text.replace(/\s+/g, ''); + const duplicate = cues.find((previous, index) => { + if (index >= previousCount || matched.has(previous)) return false; + const overlap = + Math.min(previous.endTime, cue.endTime) - Math.max(previous.startTime, cue.startTime); + const shorterDuration = Math.min( + previous.endTime - previous.startTime, + cue.endTime - cue.startTime, + ); + return ( + overlap > 0 && overlap >= shorterDuration / 2 && previous.text.replace(/\s+/g, '') === text + ); + }); + if (duplicate) { + duplicate.startTime = Math.min(duplicate.startTime, cue.startTime); + duplicate.endTime = Math.max(duplicate.endTime, cue.endTime); + matched.add(duplicate); + } else cues.push({ ...cue }); + } +} diff --git a/src/core/services/subtitle-generation-dialogue.ts b/src/core/services/subtitle-generation-dialogue.ts index 667f600d..70b0bb44 100644 --- a/src/core/services/subtitle-generation-dialogue.ts +++ b/src/core/services/subtitle-generation-dialogue.ts @@ -14,6 +14,8 @@ import { SPEECH_PASSAGE_SECONDS, } from './subtitle-generation-speech'; import type { SubtitleCue } from './subtitle-cue-parser'; +import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks'; +import { findSpeechPauses } from './subtitle-generation-pauses'; const PASSAGES_PER_BATCH = 16; @@ -40,10 +42,12 @@ export async function transcribeSubtitleDialogue(input: { String(input.config.threads), '-vt', '0.3', - '-vsd', + '--vad-min-speech-duration-ms', + '100', + '--vad-min-silence-duration-ms', '500', '-vp', - '200', + '350', '-vmsd', String(SPEECH_PASSAGE_SECONDS), '-np', @@ -55,8 +59,18 @@ export async function transcribeSubtitleDialogue(input: { segmentLines.push(line); }, }); - const passages = parseSpeechPassages(segmentLines.join('\n')); - if (passages.length === 0) throw new Error('No spoken dialogue detected.'); + const detected = parseSpeechPassages(segmentLines.join('\n')); + if (detected.length === 0) throw new Error('No spoken dialogue detected.'); + const pauses = detected.some( + (passage) => passage.endSeconds - passage.startSeconds > SPEECH_PASSAGE_SECONDS, + ) + ? await findSpeechPauses({ + ffmpegPath: input.config.ffmpegPath, + wavPath: input.wavPath, + signal: input.signal, + }) + : []; + const passages = splitSpeechPassages(detected, pauses); const cues: SubtitleCue[] = []; for (let offset = 0; offset < passages.length; offset += PASSAGES_PER_BATCH) { const batch = passages.slice(offset, offset + PASSAGES_PER_BATCH).map((passage, index) => ({ @@ -113,7 +127,10 @@ export async function transcribeSubtitleDialogue(input: { }); for (const { passage, base } of batch) { input.signal?.throwIfAborted(); - cues.push(...speechPassageCues(await readFile(`${base}.srt`, 'utf8'), passage)); + appendSpeechChunkCues( + cues, + speechPassageCues(await readFile(`${base}.srt`, 'utf8'), passage), + ); await rm(`${base}.wav`); } } diff --git a/src/core/services/subtitle-generation-models.ts b/src/core/services/subtitle-generation-models.ts index 6daf99a9..9cc10b5e 100644 --- a/src/core/services/subtitle-generation-models.ts +++ b/src/core/services/subtitle-generation-models.ts @@ -79,7 +79,7 @@ export async function downloadSubtitleGenerationModel(input: { if (current.kind === 'invalid') throw new Error(current.message); const model = getSubtitleGenerationModel(input.config.managedModel); return downloadSubtitleGenerationArtifact({ - url: `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${input.config.managedModel}.bin`, + url: `https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-${input.config.managedModel}.bin`, size: model.size, sha256: model.sha256, destination: current.path, diff --git a/src/core/services/subtitle-generation-pauses.ts b/src/core/services/subtitle-generation-pauses.ts new file mode 100644 index 00000000..60045831 --- /dev/null +++ b/src/core/services/subtitle-generation-pauses.ts @@ -0,0 +1,35 @@ +import { runSubtitleGenerationProcess } from './subtitle-generation-process'; + +// Each completed silencedetect line contains both the end and duration of a quiet interval. +export async function findSpeechPauses(input: { + ffmpegPath: string; + wavPath: string; + signal?: AbortSignal; +}): Promise { + const pauses: number[] = []; + await runSubtitleGenerationProcess({ + command: input.ffmpegPath.trim() || 'ffmpeg', + args: [ + '-nostdin', + '-hide_banner', + '-nostats', + '-i', + input.wavPath, + '-af', + 'silencedetect=noise=-35dB:d=0.12', + '-f', + 'null', + '-', + ], + signal: input.signal, + onLine: (line) => { + const match = /silence_end: (\S+) \| silence_duration: (\S+)/.exec(line); + if (!match) return; + const end = Number(match[1]); + const duration = Number(match[2]); + if (Number.isFinite(end) && Number.isFinite(duration) && duration > 0 && end >= duration) + pauses.push(end - duration / 2); + }, + }); + return pauses.sort((a, b) => a - b); +} diff --git a/src/core/services/subtitle-generation-speech.test.ts b/src/core/services/subtitle-generation-speech.test.ts index 2f8bc303..c1f3c351 100644 --- a/src/core/services/subtitle-generation-speech.test.ts +++ b/src/core/services/subtitle-generation-speech.test.ts @@ -21,6 +21,24 @@ test('speech passages convert centiseconds, group nearby speech, and retain long ); }); +test('speech passages retain long merged detector segments for pause-aware splitting', () => { + assert.deepEqual( + parseSpeechPassages( + [ + 'Detected 3 speech segments:', + 'Speech segment 0: start = 33714.00, end = 36756.00', + 'Speech segment 1: start = 40000.00, end = 46000.00', + 'Speech segment 2: start = 50000.00, end = 50100.00', + ].join('\n'), + ), + [ + { startSeconds: 337.14, endSeconds: 367.56 }, + { startSeconds: 400, endSeconds: 460 }, + { startSeconds: 500, endSeconds: 501 }, + ], + ); +}); + test('speech detector distinguishes no speech from missing, malformed, or truncated output', () => { assert.deepEqual(parseSpeechPassages('Detected 0 speech segments:'), []); assert.deepEqual( @@ -35,7 +53,6 @@ test('speech detector distinguishes no speech from missing, malformed, or trunca 'Detected 1 speech segments:\nSpeech segment 1: start = 100.00, end = 200.00', 'Detected 1 speech segments:\nSpeech segment 0: start = 200.00, end = 100.00', 'Detected 1 speech segments:\nSpeech segment 0: start = NaN, end = 100.00', - 'Detected 1 speech segments:\nSpeech segment 0: start = 0.00, end = 10000.00', 'Detected 2 speech segments:\nSpeech segment 0: start = 0.00, end = 200.00\nSpeech segment 1: start = 100.00, end = 300.00', ]) assert.throws(() => parseSpeechPassages(output), /Speech detector/); diff --git a/src/core/services/subtitle-generation-speech.ts b/src/core/services/subtitle-generation-speech.ts index f1977956..39377edf 100644 --- a/src/core/services/subtitle-generation-speech.ts +++ b/src/core/services/subtitle-generation-speech.ts @@ -26,9 +26,7 @@ export function parseSpeechPassages(output: string): SpeechPassage[] { !Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || endSeconds <= startSeconds || - startSeconds < (passages.at(-1)?.endSeconds ?? 0) || - // VAD can pass the requested split point while looking for a pause. - endSeconds - startSeconds > 30 + startSeconds < (passages.at(-1)?.endSeconds ?? 0) ) { throw new Error('Speech detector returned unordered or invalid segment timing.'); } diff --git a/src/core/services/subtitle-generation.test.ts b/src/core/services/subtitle-generation.test.ts index 68454a47..0de06796 100644 --- a/src/core/services/subtitle-generation.test.ts +++ b/src/core/services/subtitle-generation.test.ts @@ -197,6 +197,48 @@ test('dialogue generation keeps separate speech passages on the media timeline', ); })); +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('-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('no detected speech stops generation without transcribing the full audio', () => fixture(async (directory) => { const input = await generationFixture(directory); @@ -288,10 +330,16 @@ test('process cancellation terminates work and bounds diagnostic output', () => ); })); -test('download fails integrity verification and removes partial files', () => +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 () => new Response('not a model'), originalFetch); + 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({ diff --git a/src/main/runtime/subtitle-generation-runtime.ts b/src/main/runtime/subtitle-generation-runtime.ts index 2086f9af..2da81147 100644 --- a/src/main/runtime/subtitle-generation-runtime.ts +++ b/src/main/runtime/subtitle-generation-runtime.ts @@ -202,7 +202,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD ); if (vad.kind === 'missing') throw new Error( - 'Download the optional speech detection model or turn off Prioritize dialogue.', + 'Download the optional speech detection model or turn off Focus on spoken dialogue.', ); if (vad.kind === 'invalid') throw new Error(vad.message); } diff --git a/src/renderer/index.html b/src/renderer/index.html index a66e7d15..e9dc308e 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -696,7 +696,7 @@ type="checkbox" aria-describedby="subtitleGenerationVadHint" /> - Prioritize dialogue Optional + Focus on spoken dialogue Optional

@@ -711,7 +711,7 @@ class="kiku-cancel-button hidden" type="button" > - Download Silero + Download speech detection model