mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-10 17:16:20 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
614a8ca912 |
@@ -60,8 +60,11 @@ jobs:
|
||||
|
||||
- name: Install Lua
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y lua5.4
|
||||
# Lua needs only Ubuntu sources; unrelated runner repositories can be unavailable.
|
||||
test -f /etc/apt/sources.list.d/ubuntu.sources
|
||||
apt_sources=(-o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources -o Dir::Etc::sourceparts=-)
|
||||
sudo apt-get "${apt_sources[@]}" update
|
||||
sudo apt-get "${apt_sources[@]}" install -y lua5.4
|
||||
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
|
||||
lua -v
|
||||
|
||||
|
||||
@@ -90,10 +90,6 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
||||
<td><b>Jimaku</b></td>
|
||||
<td>Search and download Japanese subtitles</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Local Subtitle Generation</b></td>
|
||||
<td>Generate Japanese subtitles from local audio in a standalone modal (<code>Ctrl+Shift+G</code>), the sidebar button, or launcher, with progress and optional managed model downloads. Requires whisper.cpp and FFmpeg. Optional Silero speech detection prioritizes dialogue in separately timed passages. <a href="https://docs.subminer.moe/main/subtitle-generation">Setup guide</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>TsukiHime</b></td>
|
||||
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
|
||||
|
||||
@@ -34,14 +34,14 @@
|
||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
"app-builder-lib": "26.15.3",
|
||||
"brace-expansion": "5.0.9",
|
||||
"electron-builder-squirrel-windows": "26.15.3",
|
||||
"fast-uri": "3.1.6",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.1",
|
||||
"js-yaml": "4.3.2",
|
||||
"lodash": "4.18.0",
|
||||
"minimatch": "10.2.5",
|
||||
"picomatch": "4.0.4",
|
||||
@@ -226,7 +226,7 @@
|
||||
|
||||
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.4", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.47", "ws": "^8.20.0" } }, "sha512-ff0uEXuibh9wi+l4vOj7xInLUjtlTaQBje/SCyQkeXZ0j2V0y+Zge5PQIQFRHH9TjjGaYJkTofEcQhncM2q7/w=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="],
|
||||
|
||||
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
"js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: startup
|
||||
|
||||
- Fixed first launch exiting on macOS when the SubMiner config directory did not yet exist by creating it before acquiring the startup lock.
|
||||
@@ -1,6 +0,0 @@
|
||||
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.
|
||||
- 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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Keep the subtitle sidebar near playback during gaps when the subtitle file has a cue starting at zero.
|
||||
@@ -6,23 +6,6 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
// Configure an existing GGML model path or explicitly download a SubMiner-managed model.
|
||||
// Hot-reload: settings apply to the next generation or model download.
|
||||
// ==========================================
|
||||
"subtitleGeneration": {
|
||||
"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.
|
||||
"managedModel": "small", // Multilingual whisper.cpp model to use when modelPath is empty. Download it explicitly from the generation modal or launcher. Values: tiny | tiny-q5_1 | tiny-q8_0 | base | base-q5_1 | base-q8_0 | small | small-q5_1 | small-q8_0 | medium | medium-q5_0 | medium-q8_0 | large-v1 | large-v2 | large-v2-q5_0 | large-v2-q8_0 | large-v3 | large-v3-q5_0 | large-v3-turbo | large-v3-turbo-q5_0 | large-v3-turbo-q8_0
|
||||
"threads": 4, // Positive integer CPU thread count for whisper.cpp Japanese transcription.
|
||||
"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.
|
||||
}, // Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
|
||||
// ==========================================
|
||||
// Visible Overlay Auto-Start
|
||||
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||
@@ -223,7 +206,6 @@
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
|
||||
@@ -369,7 +369,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
||||
{ text: 'YouTube', link: '/youtube-integration' },
|
||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||
{ text: 'Subtitle Generation', link: '/subtitle-generation' },
|
||||
{ text: 'TsukiHime', link: '/tsukihime-integration' },
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||
|
||||
@@ -1010,7 +1010,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time, `%T`=time with milliseconds, `<br>`=newline |
|
||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||
| `isSenren` | object | Senren-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }`. Merges duplicates using Senren's scene-switching markup. Mutually exclusive with `isKiku.enabled`. |
|
||||
| `isSenren` | object | Senren-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }`. Merges duplicates using Senren's scene-switching markup. Mutually exclusive with `isKiku.enabled`. |
|
||||
|
||||
### Kiku/Lapis integration
|
||||
|
||||
@@ -1152,14 +1152,6 @@ The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift
|
||||
|
||||
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
|
||||
|
||||
### Japanese subtitle generation
|
||||
|
||||
Open the standalone modal with `Ctrl+Shift+G`, configurable through `shortcuts.openSubtitleGeneration`, or use the subtitle sidebar button. See [shortcuts](/shortcuts) for the shared mpv and overlay keybindings.
|
||||
|
||||
`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 **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
|
||||
|
||||
Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||
|
||||
@@ -16,10 +16,6 @@ Only **mpv** is strictly required. Everything else is optional, though you will
|
||||
|
||||
Some rows below matter only for the `subminer` command-line launcher, which is Linux and macOS only. On Windows you launch playback with the **SubMiner mpv** shortcut, so skip those.
|
||||
|
||||
[Local Japanese subtitle generation](/subtitle-generation) additionally requires whisper.cpp's `whisper-cli`, FFmpeg, and `ffprobe`. Configure their executable paths in Settings if needed. SubMiner can download a speech model explicitly, or use your existing multilingual GGML model.
|
||||
|
||||
Optional [dialogue-focused generation](/subtitle-generation#prioritizing-spoken-dialogue) also uses whisper.cpp's speech segment detector and a separate Silero GGML VAD model.
|
||||
|
||||
| Dependency | Status | Platforms | What it does |
|
||||
| -------------------- | ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| mpv | Required | All | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
||||
|
||||
@@ -159,7 +159,6 @@ subminer stats -b # start background stats daemon
|
||||
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
|
||||
| `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) |
|
||||
| `subminer settings` | Open the SubMiner settings window |
|
||||
| `subminer generate-subs [video]` | Generate [Japanese subtitles](/usage#generate-japanese-subtitles-locally) locally |
|
||||
| `subminer logs -e` | Export a sanitized local-date log ZIP and print its path |
|
||||
| `subminer config path` | Print active config file path |
|
||||
| `subminer config show` | Print active config contents |
|
||||
|
||||
@@ -6,23 +6,6 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
// Configure an existing GGML model path or explicitly download a SubMiner-managed model.
|
||||
// Hot-reload: settings apply to the next generation or model download.
|
||||
// ==========================================
|
||||
"subtitleGeneration": {
|
||||
"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.
|
||||
"managedModel": "small", // Multilingual whisper.cpp model to use when modelPath is empty. Download it explicitly from the generation modal or launcher. Values: tiny | tiny-q5_1 | tiny-q8_0 | base | base-q5_1 | base-q8_0 | small | small-q5_1 | small-q8_0 | medium | medium-q5_0 | medium-q8_0 | large-v1 | large-v2 | large-v2-q5_0 | large-v2-q8_0 | large-v3 | large-v3-q5_0 | large-v3-turbo | large-v3-turbo-q5_0 | large-v3-turbo-q8_0
|
||||
"threads": 4, // Positive integer CPU thread count for whisper.cpp Japanese transcription.
|
||||
"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.
|
||||
}, // Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
|
||||
// ==========================================
|
||||
// Visible Overlay Auto-Start
|
||||
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||
@@ -223,7 +206,6 @@
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
|
||||
@@ -84,7 +84,6 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||
| `Ctrl+Shift+G` | Open Japanese subtitle generation modal | `shortcuts.openSubtitleGeneration` |
|
||||
| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
|
||||
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# Japanese subtitle generation
|
||||
|
||||
Generate Japanese SRT subtitles from a local video's audio using [whisper.cpp](https://github.com/ggml-org/whisper.cpp). The launcher and overlay use the same local generation service. Audio stays on your computer. Model downloads require an internet connection; generation with an installed model does not.
|
||||
|
||||
## Setup
|
||||
|
||||
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**.
|
||||
|
||||
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.
|
||||
- Leave that path empty and choose a model directly in the generation modal. Each option shows its download size; the selected model has speed and accuracy guidance. The modal offers **Download model** when that model is missing. Your choice lasts for the current SubMiner session, including closing and reopening the modal. Set `subtitleGeneration.managedModel` in Settings to change the default for future sessions.
|
||||
|
||||
Managed models are stored in `models/whisper/` beside your SubMiner configuration file. Downloads show progress, verify the expected file size and SHA256, and publish the model only after verification. Cancelling or failing a download removes its temporary files. A configured external path always takes precedence; an unreadable path displays an error instead of silently downloading another model.
|
||||
|
||||
See the [generated configuration example](/config.example.jsonc) for current defaults. Changes apply to the next operation.
|
||||
|
||||
## Prioritizing spoken dialogue
|
||||
|
||||
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. 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.
|
||||
|
||||
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
|
||||
|
||||
Start with **small** for a balance of Japanese recognition quality and CPU time. This is a general starting recommendation, not a benchmark for your hardware. Tiny and base need less memory and usually finish sooner, with more recognition errors. Medium and large models favor accuracy but need more resources. Large-v3-turbo is optimized for speed compared with large-v3, with some accuracy tradeoff; actual performance depends on your CPU, GPU, whisper.cpp build, and audio.
|
||||
|
||||
The picker includes whisper.cpp's official multilingual tiny, base, small, medium, large-v1, large-v2, large-v3, and large-v3-turbo downloads, including their available quantized variants. Quantized models use less disk space and memory, with possible accuracy loss. English-only `.en` models are excluded. See the [upstream model list](https://github.com/ggml-org/whisper.cpp/blob/master/models/download-ggml-model.sh) and [Whisper's model guidance](https://github.com/openai/whisper#available-models-and-languages).
|
||||
|
||||
A configured external Model Path takes precedence and hides the managed model picker. Clear it in Settings to choose a managed model. Changing the picker never downloads automatically, and it cannot change the model during an active download or generation.
|
||||
|
||||
## 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. 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 **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.
|
||||
|
||||
**Escape** or **Close** closes the modal using the same focus and overlay restoration as other SubMiner modals. Change or disable its shortcut with `shortcuts.openSubtitleGeneration` in Settings. Ctrl+G remains assigned to field grouping.
|
||||
|
||||
SubMiner saves `<video>.ja.generated.srt` beside the media, adding a numeric suffix if that name already exists. It selects the generated Japanese subtitle track and resets the subtitle delay when mpv is still playing the same file. If playback changes, the subtitles remain saved and are not attached to the new video. The result includes the saved path even if mpv cannot load it.
|
||||
|
||||
## From the launcher
|
||||
|
||||
```bash
|
||||
subminer generate-subs episode.mkv --download-model
|
||||
subminer generate-subs episode.mkv --model-path /path/to/ggml-small.bin
|
||||
subminer generate-subs
|
||||
```
|
||||
|
||||
With no file argument, the command uses the current local mpv media and its selected audio track. With an explicit file, it prefers an audio stream tagged Japanese, otherwise the first audio stream. Use `--audio-stream` to choose an absolute FFmpeg stream index. `--output` specifies a new destination SRT; existing output files are never overwritten. See [launcher usage](/usage) for all flags. Ctrl+C cancels the operation.
|
||||
|
||||
## Timing and limitations
|
||||
|
||||
The SRT includes whisper.cpp's timestamps, adjusted for the audio stream's position on the media timeline and, when speech detection is configured, each passage's original start time. No alass step is required to load it. This version uses native Whisper timing; it does not run WhisperX or another forced aligner. Recognition can repeat or invent lines, and timing can be imperfect, especially with music or overlapping speech. Review generated text and audio boundaries when mining.
|
||||
|
||||
Generation supports local files and internal audio tracks. Remote URLs, subtitle translation, and transcription of a separately attached mpv audio track are not supported by the modal. Pass a separate local audio file to the launcher if needed. The destination directory needs writable space for subtitles; temporary storage needs enough space for the extracted mono audio.
|
||||
@@ -6,12 +6,9 @@ The sidebar is enabled by default. Set `subtitleSidebar.enabled` to `false` if y
|
||||
|
||||
## How it works
|
||||
|
||||
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:
|
||||
|
||||
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
|
||||
- Between subtitle lines, the sidebar follows playback to the next cue without jumping back to a cue at the start of the file.
|
||||
- Clicking any cue seeks mpv into that line. For overlapping ASS karaoke, SubMiner moves past the previous line's exit animation when the selected cue has enough time remaining.
|
||||
- The sidebar and the overlay share one cue list, so a media change or subtitle source switch updates both at once.
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ subminer https://youtu.be/... # Play a YouTube URL
|
||||
subminer stats # Open the immersion stats dashboard
|
||||
subminer doctor # Check dependencies, config, and the mpv socket
|
||||
subminer settings # Open the SubMiner settings window
|
||||
subminer generate-subs video.mkv # Generate Japanese subtitles from local audio
|
||||
subminer app --setup # Re-open first-run setup
|
||||
subminer -u # Check for updates
|
||||
```
|
||||
@@ -82,40 +81,6 @@ Two flags are worth knowing early:
|
||||
- `-a/--args` passes extra arguments straight to mpv, for example `subminer --args "--ao=alsa --volume=80" video.mkv`.
|
||||
- `--log-level debug` turns on verbose logging when something is not working.
|
||||
|
||||
### Generate Japanese subtitles locally
|
||||
|
||||
`generate-subs` transcribes local audio with whisper.cpp, saves a timed Japanese SRT file,
|
||||
and loads it into mpv if that same media file is still playing, clearing the previous subtitle
|
||||
delay. It also works with no running
|
||||
SubMiner app or mpv instance when you provide a file path. Omit the path to use the current
|
||||
mpv file and selected audio track.
|
||||
|
||||
```bash
|
||||
subminer generate-subs video.mkv --download-model
|
||||
subminer generate-subs video.mkv --model-path ~/models/ggml-medium.bin
|
||||
subminer generate-subs --model medium --download-model
|
||||
subminer generate-subs video.mkv --audio-stream 2 --output ~/Subs/video.ja.srt
|
||||
```
|
||||
|
||||
Install `whisper-cli` from whisper.cpp, `ffmpeg`, and `ffprobe`, or configure their paths in
|
||||
`subtitleGeneration.whisperPath`, `subtitleGeneration.ffmpegPath`, and `subtitleGeneration.ffprobePath`.
|
||||
Set `subtitleGeneration.modelPath` in settings to reuse an existing whisper.cpp model.
|
||||
With no external path, SubMiner uses `subtitleGeneration.managedModel` and stores downloaded
|
||||
models under `models/whisper` beside its config file. `--model` selects an official multilingual model, including available quantized variants, for
|
||||
this invocation and overrides a configured external model path. Run `subminer generate-subs --help`
|
||||
for accepted names. See [model selection](/subtitle-generation#choosing-a-model) for accuracy and speed guidance.
|
||||
|
||||
Downloads only happen when you pass `--download-model` or choose the download action in the
|
||||
generation modal. The launcher reports each stage and percentages when available. Press Ctrl+C
|
||||
to cancel. `--audio-stream` takes an absolute ffprobe stream index. When you provide a file
|
||||
path without that flag, generation uses a Japanese audio track when tagged, falling back
|
||||
to the first audio track. With no file path, mpv must have an identifiable selected audio
|
||||
track, or you must provide `--audio-stream`.
|
||||
|
||||
Generated files include Whisper's native timing. Speech recognition can make mistakes,
|
||||
especially over music or overlapping dialogue, so check the wording before mining. Existing
|
||||
output files are preserved. See [configuration](/configuration) for the generation settings.
|
||||
|
||||
<details>
|
||||
<summary><b>Less common launcher commands</b></summary>
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
|
||||
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
||||
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional Silero detection groups short speech passages before transcription, decodes each independently, and restores original media timing without joining omitted gaps. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||
@@ -41,3 +40,7 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
|
||||
- Composition over monoliths
|
||||
- Pure helpers where possible
|
||||
- Stable user behavior while internals evolve
|
||||
|
||||
Startup resolves and creates the user-data directory in `src/main-entry-runtime.ts`
|
||||
before the entry process requests Electron's single-instance lock. Main-process
|
||||
config bootstrap then writes the default config only when no config file exists.
|
||||
|
||||
@@ -23,6 +23,8 @@ Read when: selecting the right verification lane for a change
|
||||
there instead of copying them into caller workflows.
|
||||
- The reusable gate installs Lua and runs `bun run test:env`, so the shipped mpv
|
||||
plugin tests run for every pull request and tagged release.
|
||||
Lua installation uses only the runner's Ubuntu package sources so unrelated
|
||||
third-party repository failures do not block the gate.
|
||||
|
||||
## Default Handoff Gate
|
||||
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
import { parseArgs } from '../config.js';
|
||||
import {
|
||||
createGenerationProgressReporter,
|
||||
runGenerateSubtitlesCommand,
|
||||
} from './generate-subtitles-command.js';
|
||||
|
||||
type Deps = NonNullable<Parameters<typeof runGenerateSubtitlesCommand>[1]>;
|
||||
|
||||
function fixture(argv: string[] = ['generate-subs', '/media/episode.mkv']) {
|
||||
const output: string[] = [];
|
||||
const commands: unknown[][] = [];
|
||||
const generations: Parameters<NonNullable<Deps['generate']>>[0][] = [];
|
||||
let exitCode: number | undefined;
|
||||
let interrupted: (() => void) | undefined;
|
||||
let detached = false;
|
||||
const context = {
|
||||
args: parseArgs(argv, 'subminer', {}),
|
||||
mpvSocketPath: '/tmp/test-subminer-socket',
|
||||
processAdapter: {
|
||||
writeStdout: (text: string) => {
|
||||
output.push(text);
|
||||
},
|
||||
setExitCode: (code: number) => {
|
||||
exitCode = code;
|
||||
},
|
||||
},
|
||||
};
|
||||
const deps: Deps = {
|
||||
readConfig: () => ({ subtitleGeneration: { modelPath: '/models/external.bin' } }),
|
||||
configPath: () => '/settings/SubMiner/config.jsonc',
|
||||
resolveModel: async () => ({ kind: 'external', path: '/models/external.bin' }),
|
||||
downloadModel: async () => {
|
||||
throw new Error('Unexpected model download');
|
||||
},
|
||||
generate: async (input) => {
|
||||
generations.push(input);
|
||||
input.onProgress?.({
|
||||
stage: 'transcribe',
|
||||
percent: 50,
|
||||
message: 'Transcribing Japanese audio',
|
||||
});
|
||||
return '/media/episode.ja.srt';
|
||||
},
|
||||
mpvCommand: async (_socket, command) => {
|
||||
commands.push(command);
|
||||
if (command[1] === 'path') return '/media/episode.mkv';
|
||||
if (command[1] === 'track-list') return [{ type: 'audio', selected: true, 'ff-index': 2 }];
|
||||
return undefined;
|
||||
},
|
||||
onInterrupt: (handler) => {
|
||||
interrupted = handler;
|
||||
return () => {
|
||||
detached = true;
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
context,
|
||||
deps,
|
||||
output,
|
||||
commands,
|
||||
generations,
|
||||
exitCode: () => exitCode,
|
||||
interrupt: () => interrupted?.(),
|
||||
detached: () => detached,
|
||||
};
|
||||
}
|
||||
|
||||
test('launcher uses the shared core and selected mpv audio then loads the generated file', async () => {
|
||||
const f = fixture(['generate-subs']);
|
||||
assert.equal(await runGenerateSubtitlesCommand(f.context, f.deps), true);
|
||||
assert.equal(f.generations[0]?.mediaPath, '/media/episode.mkv');
|
||||
assert.equal(f.generations[0]?.audioStreamIndex, 2);
|
||||
assert.equal(
|
||||
f.generations[0]?.modelDirectory,
|
||||
path.join('/settings/SubMiner', 'models', 'whisper'),
|
||||
);
|
||||
assert.equal(f.generations[0]?.config.modelPath, '/models/external.bin');
|
||||
assert.deepEqual(f.commands.at(-2), [
|
||||
'sub-add',
|
||||
'/media/episode.ja.srt',
|
||||
'select',
|
||||
'Japanese (generated)',
|
||||
'ja',
|
||||
]);
|
||||
assert.deepEqual(f.commands.at(-1), ['set_property', 'sub-delay', 0]);
|
||||
assert.match(f.output.join(''), /50%/);
|
||||
assert.match(f.output.join(''), /Saved Japanese subtitles/);
|
||||
assert.equal(f.detached(), true);
|
||||
});
|
||||
|
||||
test('launcher never downloads a model without the explicit option', async () => {
|
||||
const f = fixture();
|
||||
f.deps.resolveModel = async () => ({ kind: 'missing', path: '/models/missing.bin' });
|
||||
await assert.rejects(runGenerateSubtitlesCommand(f.context, f.deps), /--download-model/);
|
||||
assert.equal(f.generations.length, 0);
|
||||
assert.equal(f.detached(), true);
|
||||
});
|
||||
|
||||
test('current mpv generation requires an identifiable selected audio track', async () => {
|
||||
for (const tracks of [
|
||||
[],
|
||||
[{ type: 'audio', selected: true }],
|
||||
[{ type: 'audio', selected: true, external: true, 'ff-index': 0 }],
|
||||
]) {
|
||||
const f = fixture(['generate-subs']);
|
||||
f.deps.mpvCommand = async (_socket, command) =>
|
||||
command[1] === 'path' ? '/media/episode.mkv' : tracks;
|
||||
await assert.rejects(runGenerateSubtitlesCommand(f.context, f.deps), /audio track/);
|
||||
assert.equal(f.generations.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit local file leaves Japanese track selection to the shared generator', async () => {
|
||||
const f = fixture();
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.generations[0]?.audioStreamIndex, undefined);
|
||||
assert.equal(
|
||||
f.commands.some((command) => command[1] === 'track-list'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('launcher does not load generated subtitles after mpv switches files', async () => {
|
||||
const f = fixture(['generate-subs']);
|
||||
let pathRequests = 0;
|
||||
f.deps.mpvCommand = async (_socket, command) => {
|
||||
f.commands.push(command);
|
||||
if (command[1] === 'path')
|
||||
return ++pathRequests === 1 ? '/media/episode.mkv' : '/media/next.mkv';
|
||||
return [{ type: 'audio', selected: true, 'ff-index': 2 }];
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(
|
||||
f.commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
);
|
||||
assert.match(f.output.join(''), /Saved Japanese subtitles/);
|
||||
});
|
||||
|
||||
test('explicit managed model overrides external config and downloads before generation', async () => {
|
||||
const f = fixture([
|
||||
'generate-subs',
|
||||
'/media/episode.mkv',
|
||||
'--model',
|
||||
'medium',
|
||||
'--download-model',
|
||||
]);
|
||||
f.deps.resolveModel = async (config) => {
|
||||
assert.equal(config.modelPath, '');
|
||||
assert.equal(config.managedModel, 'medium');
|
||||
return { kind: 'missing', path: '/models/medium.bin' };
|
||||
};
|
||||
let downloaded = false;
|
||||
f.deps.downloadModel = async () => {
|
||||
downloaded = true;
|
||||
return '/models/medium.bin';
|
||||
};
|
||||
const generate = f.deps.generate;
|
||||
f.deps.generate = async (input) => {
|
||||
assert.equal(downloaded, true);
|
||||
if (!generate) throw new Error('Missing fixture generator');
|
||||
return generate(input);
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.generations.length, 1);
|
||||
});
|
||||
|
||||
test('generation can run standalone and never loads subtitles into another video', async () => {
|
||||
for (const playing of [null, '/media/different.mkv']) {
|
||||
const f = fixture();
|
||||
f.deps.mpvCommand = async (_socket, command) => {
|
||||
f.commands.push(command);
|
||||
if (playing === null) throw new Error('mpv is not running');
|
||||
return playing;
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.generations.length, 1);
|
||||
assert.equal(
|
||||
f.commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('launcher preserves the saved path when loading into mpv fails', async () => {
|
||||
const f = fixture();
|
||||
const mpv = f.deps.mpvCommand;
|
||||
f.deps.mpvCommand = async (socket, command, timeout) => {
|
||||
if (command[0] === 'sub-add') throw new Error('load failed');
|
||||
return mpv?.(socket, command, timeout);
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.match(f.output.join(''), /Saved Japanese subtitles: \/media\/episode.ja.srt/);
|
||||
assert.match(f.output.join(''), /mpv could not load them: load failed/);
|
||||
assert.equal(f.exitCode(), 1);
|
||||
});
|
||||
|
||||
test('SIGINT cancels shared generation and unregisters its handler', async () => {
|
||||
const f = fixture();
|
||||
f.deps.generate = async (input) => {
|
||||
f.interrupt();
|
||||
assert.equal(input.signal?.aborted, true);
|
||||
throw new Error('Aborted');
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.exitCode(), 130);
|
||||
assert.equal(f.detached(), true);
|
||||
assert.match(f.output.join(''), /cancelled/);
|
||||
});
|
||||
|
||||
test('cancellation after generation preserves the saved path and skips mpv loading', async () => {
|
||||
const f = fixture();
|
||||
f.deps.generate = async () => {
|
||||
f.interrupt();
|
||||
return '/media/episode.ja.srt';
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(
|
||||
f.commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
);
|
||||
assert.match(f.output.join(''), /Saved Japanese subtitles: \/media\/episode.ja.srt/);
|
||||
assert.equal(f.exitCode(), 130);
|
||||
assert.equal(f.detached(), true);
|
||||
});
|
||||
|
||||
test('progress throttles repeated updates but always reports stage changes and completion', () => {
|
||||
const output: string[] = [];
|
||||
let time = 0;
|
||||
const progress = createGenerationProgressReporter(
|
||||
(text) => output.push(text),
|
||||
() => time,
|
||||
);
|
||||
progress({ stage: 'download', percent: 0, message: 'Downloading' });
|
||||
progress({ stage: 'download', percent: 1, message: 'Downloading' });
|
||||
time = 1000;
|
||||
progress({ stage: 'download', percent: 50, message: 'Downloading' });
|
||||
progress({ stage: 'download', percent: 100, message: 'Downloading' });
|
||||
progress({ stage: 'extract', message: 'Extracting audio' });
|
||||
assert.equal(output.length, 4);
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
downloadSubtitleGenerationModel,
|
||||
generateJapaneseSubtitles,
|
||||
resolveSubtitleGenerationModel,
|
||||
} from '../../src/core/services/subtitle-generation.js';
|
||||
import {
|
||||
resolveSubtitleGenerationConfig,
|
||||
type SubtitleGenerationProgress,
|
||||
} from '../../src/shared/subtitle-generation.js';
|
||||
import {
|
||||
readLauncherMainConfigObject,
|
||||
resolveLauncherMainConfigPath,
|
||||
} from '../config/shared-config-reader.js';
|
||||
import { sendMpvCommandWithResponse } from '../mpv.js';
|
||||
import { resolvePathMaybe } from '../util.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
type GenerationCommandContext = Pick<LauncherCommandContext, 'args' | 'mpvSocketPath'> & {
|
||||
processAdapter: Pick<LauncherCommandContext['processAdapter'], 'writeStdout' | 'setExitCode'>;
|
||||
};
|
||||
|
||||
interface GenerationCommandDeps {
|
||||
readConfig: typeof readLauncherMainConfigObject;
|
||||
configPath: typeof resolveLauncherMainConfigPath;
|
||||
resolveModel: typeof resolveSubtitleGenerationModel;
|
||||
downloadModel: typeof downloadSubtitleGenerationModel;
|
||||
generate: typeof generateJapaneseSubtitles;
|
||||
mpvCommand: typeof sendMpvCommandWithResponse;
|
||||
onInterrupt: (handler: () => void) => () => void;
|
||||
}
|
||||
|
||||
const defaultDeps: GenerationCommandDeps = {
|
||||
readConfig: readLauncherMainConfigObject,
|
||||
configPath: resolveLauncherMainConfigPath,
|
||||
resolveModel: resolveSubtitleGenerationModel,
|
||||
downloadModel: downloadSubtitleGenerationModel,
|
||||
generate: generateJapaneseSubtitles,
|
||||
mpvCommand: sendMpvCommandWithResponse,
|
||||
onInterrupt: (handler) => {
|
||||
process.on('SIGINT', handler);
|
||||
return () => process.off('SIGINT', handler);
|
||||
},
|
||||
};
|
||||
|
||||
function localMediaPath(value: string, workingDirectory = process.cwd()): string {
|
||||
if (value.startsWith('file://')) return fileURLToPath(value);
|
||||
if (/^[a-z][a-z\d+.-]*:\/\//i.test(value)) {
|
||||
throw new Error('Japanese subtitle generation requires a local media file.');
|
||||
}
|
||||
return path.resolve(workingDirectory, resolvePathMaybe(value));
|
||||
}
|
||||
|
||||
async function readMpvMedia(socketPath: string, command: GenerationCommandDeps['mpvCommand']) {
|
||||
const media = await command(socketPath, ['get_property', 'path'], 1000);
|
||||
if (typeof media !== 'string' || !media.trim()) return null;
|
||||
let workingDirectory: string | undefined;
|
||||
if (!path.isAbsolute(media) && !media.startsWith('file://')) {
|
||||
const directory = await command(socketPath, ['get_property', 'working-directory'], 1000);
|
||||
if (typeof directory !== 'string') return null;
|
||||
workingDirectory = directory;
|
||||
}
|
||||
return localMediaPath(media, workingDirectory);
|
||||
}
|
||||
|
||||
async function readMpvAudioStream(
|
||||
socketPath: string,
|
||||
command: GenerationCommandDeps['mpvCommand'],
|
||||
) {
|
||||
const tracks = await command(socketPath, ['get_property', 'track-list'], 1000);
|
||||
for (const track of Array.isArray(tracks) ? tracks : []) {
|
||||
if (
|
||||
typeof track === 'object' &&
|
||||
track !== null &&
|
||||
'type' in track &&
|
||||
track.type === 'audio' &&
|
||||
'selected' in track &&
|
||||
track.selected === true
|
||||
) {
|
||||
if ('external' in track && track.external === true) {
|
||||
throw new Error(
|
||||
'The selected mpv audio track is external. Pass its local file to generate-subs.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
'ff-index' in track &&
|
||||
typeof track['ff-index'] === 'number' &&
|
||||
Number.isSafeInteger(track['ff-index']) &&
|
||||
track['ff-index'] >= 0
|
||||
) {
|
||||
return track['ff-index'];
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
'Select an audio track in mpv, or pass --audio-stream with its absolute stream index.',
|
||||
);
|
||||
}
|
||||
|
||||
function sameFile(left: string, right: string): boolean {
|
||||
try {
|
||||
return fs.realpathSync(left) === fs.realpathSync(right);
|
||||
} catch {
|
||||
return path.resolve(left) === path.resolve(right);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep progress readable in terminals and redirected logs, even for large model downloads. */
|
||||
export function createGenerationProgressReporter(write: (text: string) => void, now = Date.now) {
|
||||
let previousStage: SubtitleGenerationProgress['stage'] | undefined;
|
||||
let previousTime = -Infinity;
|
||||
let previousLine = '';
|
||||
return (progress: SubtitleGenerationProgress): void => {
|
||||
const percent =
|
||||
typeof progress.percent === 'number' && Number.isFinite(progress.percent)
|
||||
? Math.floor(Math.max(0, Math.min(100, progress.percent)))
|
||||
: undefined;
|
||||
const line = `[${progress.stage}] ${percent === undefined ? '' : `${percent}% `}${progress.message}\n`;
|
||||
const timestamp = now();
|
||||
if (
|
||||
line === previousLine ||
|
||||
(progress.stage === previousStage && timestamp - previousTime < 1000 && percent !== 100)
|
||||
)
|
||||
return;
|
||||
write(line);
|
||||
previousStage = progress.stage;
|
||||
previousTime = timestamp;
|
||||
previousLine = line;
|
||||
};
|
||||
}
|
||||
|
||||
export async function runGenerateSubtitlesCommand(
|
||||
context: GenerationCommandContext,
|
||||
overrides: Partial<GenerationCommandDeps> = {},
|
||||
): Promise<boolean> {
|
||||
const options = context.args.generateSubtitles;
|
||||
if (!options) return false;
|
||||
const deps = { ...defaultDeps, ...overrides };
|
||||
const write = (text: string) => context.processAdapter.writeStdout(text);
|
||||
const controller = new AbortController();
|
||||
const removeInterrupt = deps.onInterrupt(() => controller.abort());
|
||||
try {
|
||||
const config = resolveSubtitleGenerationConfig(deps.readConfig()?.subtitleGeneration);
|
||||
if (options.managedModel) {
|
||||
config.managedModel = options.managedModel;
|
||||
config.modelPath = '';
|
||||
}
|
||||
if (options.modelPath !== undefined)
|
||||
config.modelPath = path.resolve(resolvePathMaybe(options.modelPath));
|
||||
const modelDirectory = path.join(path.dirname(deps.configPath()), 'models', 'whisper');
|
||||
const currentMedia = await readMpvMedia(context.mpvSocketPath, deps.mpvCommand).catch(
|
||||
() => null,
|
||||
);
|
||||
const mediaPath = options.mediaPath ? localMediaPath(options.mediaPath) : currentMedia;
|
||||
if (!mediaPath)
|
||||
throw new Error('Pass a local video file or open one in mpv before running generate-subs.');
|
||||
const audioStreamIndex =
|
||||
options.audioStreamIndex ??
|
||||
(!options.mediaPath
|
||||
? await readMpvAudioStream(context.mpvSocketPath, deps.mpvCommand)
|
||||
: undefined);
|
||||
const onProgress = createGenerationProgressReporter(write);
|
||||
const model = await deps.resolveModel(config, modelDirectory);
|
||||
if (model.kind === 'invalid') throw new Error(model.message);
|
||||
if (model.kind === 'missing') {
|
||||
if (!options.downloadModel) {
|
||||
throw new Error(
|
||||
'No Whisper model found. Run again with --download-model, or set subtitleGeneration.modelPath / --model-path.',
|
||||
);
|
||||
}
|
||||
await deps.downloadModel({ config, modelDirectory, onProgress, signal: controller.signal });
|
||||
}
|
||||
const outputPath = await deps.generate({
|
||||
config,
|
||||
modelDirectory,
|
||||
mediaPath,
|
||||
audioStreamIndex,
|
||||
outputPath: options.outputPath
|
||||
? path.resolve(resolvePathMaybe(options.outputPath))
|
||||
: undefined,
|
||||
onProgress,
|
||||
signal: controller.signal,
|
||||
});
|
||||
write(`Saved Japanese subtitles: ${outputPath}\n`);
|
||||
controller.signal.throwIfAborted();
|
||||
const playingMedia = await readMpvMedia(context.mpvSocketPath, deps.mpvCommand).catch(
|
||||
() => null,
|
||||
);
|
||||
controller.signal.throwIfAborted();
|
||||
if (playingMedia && sameFile(playingMedia, mediaPath)) {
|
||||
try {
|
||||
await deps.mpvCommand(context.mpvSocketPath, [
|
||||
'sub-add',
|
||||
outputPath,
|
||||
'select',
|
||||
'Japanese (generated)',
|
||||
'ja',
|
||||
]);
|
||||
await deps.mpvCommand(context.mpvSocketPath, ['set_property', 'sub-delay', 0]);
|
||||
write('Loaded Japanese subtitles into mpv.\n');
|
||||
} catch (error) {
|
||||
write(
|
||||
`Subtitles are saved, but mpv could not load them: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
context.processAdapter.setExitCode(1);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) throw error;
|
||||
write('Subtitle generation cancelled.\n');
|
||||
context.processAdapter.setExitCode(130);
|
||||
return true;
|
||||
} finally {
|
||||
removeInterrupt();
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,6 @@ export function applyRootOptionsToArgs(
|
||||
}
|
||||
|
||||
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||
if (invocations.generateSubtitles) parsed.generateSubtitles = invocations.generateSubtitles;
|
||||
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
||||
if (invocations.dictionaryCandidates) parsed.dictionaryCandidates = true;
|
||||
if (invocations.dictionarySelect) parsed.dictionarySelect = true;
|
||||
|
||||
@@ -1,61 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseCliPrograms, resolveTopLevelCommand } from './cli-parser-builder.js';
|
||||
import { SUBTITLE_GENERATION_MODELS } from '../../src/shared/subtitle-generation-model-catalog.js';
|
||||
|
||||
test('generate-subs accepts all downloadable multilingual model variants', () => {
|
||||
for (const { id } of SUBTITLE_GENERATION_MODELS) {
|
||||
const { invocations } = parseCliPrograms(['generate-subs', '--model', id], 'subminer');
|
||||
assert.equal(invocations.generateSubtitles?.managedModel, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('generate-subs parses local generation options separately from YouTube options', () => {
|
||||
const result = parseCliPrograms(
|
||||
[
|
||||
'generate-subs',
|
||||
'episode.mkv',
|
||||
'--download-model',
|
||||
'--model',
|
||||
'medium',
|
||||
'--output',
|
||||
'episode.ja.srt',
|
||||
'--audio-stream',
|
||||
'2',
|
||||
],
|
||||
'subminer',
|
||||
);
|
||||
assert.deepEqual(result.invocations.generateSubtitles, {
|
||||
mediaPath: 'episode.mkv',
|
||||
downloadModel: true,
|
||||
managedModel: 'medium',
|
||||
modelPath: undefined,
|
||||
outputPath: 'episode.ja.srt',
|
||||
audioStreamIndex: 2,
|
||||
});
|
||||
assert.equal(
|
||||
parseCliPrograms(['generate-subs'], 'subminer').invocations.generateSubtitles?.mediaPath,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
parseCliPrograms(['generate-subs', '--model-path', '/models/ggml.bin'], 'subminer').invocations
|
||||
.generateSubtitles?.modelPath,
|
||||
'/models/ggml.bin',
|
||||
);
|
||||
});
|
||||
|
||||
test('generate-subs rejects conflicting models and malformed audio stream indices', () => {
|
||||
for (const flags of [
|
||||
['--model', 'tiny.en'],
|
||||
['--model', 'small.en-q5_1'],
|
||||
['--model', 'toString'],
|
||||
['--audio-stream', '-1'],
|
||||
['--audio-stream', '1.5'],
|
||||
['--model-path', '/model.bin', '--download-model'],
|
||||
['--model-path', '/model.bin', '--model', 'small'],
|
||||
])
|
||||
assert.throws(() => parseCliPrograms(['generate-subs', ...flags], 'subminer'), /Generation/);
|
||||
});
|
||||
|
||||
test('resolveTopLevelCommand skips root options and finds the first command', () => {
|
||||
assert.deepEqual(resolveTopLevelCommand(['--backend', 'macos', 'config', 'show']), {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { Command } from 'commander';
|
||||
import type { Args } from '../types.js';
|
||||
import {
|
||||
isSubtitleGenerationModelId,
|
||||
SUBTITLE_GENERATION_MODELS,
|
||||
} from '../../src/shared/subtitle-generation-model-catalog.js';
|
||||
|
||||
export interface JellyfinInvocation {
|
||||
action?: string;
|
||||
@@ -25,7 +20,6 @@ export interface CommandActionInvocation {
|
||||
}
|
||||
|
||||
export interface CliInvocations {
|
||||
generateSubtitles?: Args['generateSubtitles'];
|
||||
jellyfinInvocation: JellyfinInvocation | null;
|
||||
configInvocation: CommandActionInvocation | null;
|
||||
settingsInvocation: CommandActionInvocation | null;
|
||||
@@ -124,7 +118,6 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
'mpv',
|
||||
'logs',
|
||||
'dictionary',
|
||||
'generate-subs',
|
||||
'dict',
|
||||
'stats',
|
||||
'sync',
|
||||
@@ -206,7 +199,6 @@ export function parseCliPrograms(
|
||||
let texthookerOpenBrowser = false;
|
||||
let doctorTriggered = false;
|
||||
let texthookerTriggered = false;
|
||||
let generateSubtitles: Args['generateSubtitles'];
|
||||
|
||||
const commandProgram = new Command();
|
||||
commandProgram
|
||||
@@ -231,50 +223,6 @@ export function parseCliPrograms(
|
||||
.argument('[target]', 'file, directory, or URL');
|
||||
applyRootOptions(rootProgram);
|
||||
|
||||
commandProgram
|
||||
.command('generate-subs')
|
||||
.description('Generate Japanese subtitles locally with whisper.cpp')
|
||||
.argument('[video]', 'Local media file, or the current mpv file if omitted')
|
||||
.option('--download-model', 'Download the selected managed model if missing')
|
||||
.option('--model-path <path>', 'Use an existing whisper.cpp model file')
|
||||
.option(
|
||||
'--model <name>',
|
||||
`Managed model: ${SUBTITLE_GENERATION_MODELS.map((model) => model.id).join(', ')}`,
|
||||
)
|
||||
.option('--output <path>', 'Save subtitles to this SRT path')
|
||||
.option('--audio-stream <index>', 'Absolute audio stream index from ffprobe')
|
||||
.action((mediaPath: string | undefined, options: Record<string, unknown>) => {
|
||||
const model = options.model;
|
||||
if (model !== undefined && !isSubtitleGenerationModelId(model)) {
|
||||
throw new Error(
|
||||
`Generation --model must be one of: ${SUBTITLE_GENERATION_MODELS.map((entry) => entry.id).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.modelPath !== undefined &&
|
||||
(model !== undefined || options.downloadModel === true)
|
||||
) {
|
||||
throw new Error(
|
||||
'Generation --model-path cannot be combined with --model or --download-model.',
|
||||
);
|
||||
}
|
||||
let audioStreamIndex: number | undefined;
|
||||
if (typeof options.audioStream === 'string') {
|
||||
audioStreamIndex = Number(options.audioStream);
|
||||
if (!/^\d+$/.test(options.audioStream) || !Number.isSafeInteger(audioStreamIndex)) {
|
||||
throw new Error('Generation --audio-stream must be a non-negative integer stream index.');
|
||||
}
|
||||
}
|
||||
generateSubtitles = {
|
||||
mediaPath,
|
||||
downloadModel: options.downloadModel === true,
|
||||
modelPath: typeof options.modelPath === 'string' ? options.modelPath : undefined,
|
||||
managedModel: model,
|
||||
outputPath: typeof options.output === 'string' ? options.output : undefined,
|
||||
audioStreamIndex,
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('jellyfin')
|
||||
.alias('jf')
|
||||
@@ -559,7 +507,6 @@ export function parseCliPrograms(
|
||||
options: selectedProgram.opts<Record<string, unknown>>(),
|
||||
rootTarget: rootProgram.processedArgs[0],
|
||||
invocations: {
|
||||
generateSubtitles,
|
||||
jellyfinInvocation,
|
||||
configInvocation,
|
||||
settingsInvocation,
|
||||
|
||||
@@ -25,7 +25,6 @@ import { runHistorySession } from './commands/history-command.js';
|
||||
import { runSyncCommand } from './commands/sync-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
import { runUpdateCommand } from './commands/update-command.js';
|
||||
import { runGenerateSubtitlesCommand } from './commands/generate-subtitles-command.js';
|
||||
|
||||
const APP_VERSION =
|
||||
typeof packageJson.version === 'string' && packageJson.version.trim()
|
||||
@@ -113,10 +112,6 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await runGenerateSubtitlesCommand(context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedAppPath = ensureAppPath(context);
|
||||
state.appPath = resolvedAppPath;
|
||||
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
|
||||
import type { SubtitleGenerationConfig } from '../src/shared/subtitle-generation.js';
|
||||
import {
|
||||
resolveDefaultLogFilePath,
|
||||
type LogFileToggles,
|
||||
@@ -89,14 +88,6 @@ export interface LauncherAiConfig {
|
||||
}
|
||||
|
||||
export interface Args {
|
||||
generateSubtitles?: {
|
||||
mediaPath?: string;
|
||||
downloadModel: boolean;
|
||||
modelPath?: string;
|
||||
managedModel?: SubtitleGenerationConfig['managedModel'];
|
||||
outputPath?: string;
|
||||
audioStreamIndex?: number;
|
||||
};
|
||||
backend: Backend;
|
||||
directory: string;
|
||||
recursive: boolean;
|
||||
|
||||
+2
-2
@@ -83,14 +83,14 @@
|
||||
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
"app-builder-lib": "26.15.3",
|
||||
"brace-expansion": "5.0.9",
|
||||
"electron-builder-squirrel-windows": "26.15.3",
|
||||
"fast-uri": "3.1.6",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.1",
|
||||
"js-yaml": "4.3.2",
|
||||
"lodash": "4.18.0",
|
||||
"minimatch": "10.2.5",
|
||||
"picomatch": "4.0.4",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **Pre-Mining Timing Review**:
|
||||
- Optional review step before creating word, sentence, or audio cards, with a speech-focused waveform that filters out steady background noise so dialogue is easy to spot.
|
||||
- The clip end automatically snaps back to where dialogue actually ends, since subtitles often linger after speech stops.
|
||||
- Drag or use the keyboard to adjust clip boundaries, and preview audio with a sweeping playhead that plays to the true end even on high-latency outputs like Bluetooth headphones.
|
||||
- Pull extra previous or next subtitle lines onto the card with `P`/`N` (or the Prev/Next steppers); a live preview shows exactly what the card will contain.
|
||||
- You can cancel and still keep the card without media, and the review can be toggled on or off for the session.
|
||||
- **Senren Note Type Support**:
|
||||
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, combining sentence, furigana, audio, picture, and misc-info fields.
|
||||
- Supports the same auto/manual/disabled grouping modes as Kiku, including the manual merge modal. Senren and Kiku are mutually exclusive, so only one can be enabled at a time.
|
||||
|
||||
### Changed
|
||||
- **Remote Streaming Mining**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream for every step. No action needed; the temporary download is cleaned up automatically after ten minutes of inactivity.
|
||||
- **TsukiHime Release Picker**: The Japanese and secondary-language tabs now filter releases down to ones that actually carry subtitles for that language, and tell you when none do.
|
||||
|
||||
### Fixed
|
||||
- **Broadcast Caption Accuracy**:
|
||||
- Japanese caption tracks split across two positioned lines (e.g. Crunchyroll) now merge into one, so mined sentences, the sidebar, and line-break settings treat them as a single line; lines from different speakers or sound effects still stay separate.
|
||||
- Mining from the overlay no longer picks up a leftover line from the previous caption, so the mined sentence and clip timing match what's actually on screen.
|
||||
- Copying or mining subtitles no longer includes the separate furigana line that some broadcast subtitle files place above kanji.
|
||||
- **Multi-line Copy After Seeking**: Selecting multiple subtitle lines to copy or mine now selects backward in timeline order after a seek, rather than in playback encounter order.
|
||||
- **Overlay Stability**:
|
||||
- On Hyprland, opening a modal (timing review, Jimaku, session help, and others) over fullscreen mpv no longer makes the overlay flicker while the modal loads.
|
||||
- Switching secondary subtitle tracks no longer causes mpv's native secondary subtitles to flash on screen.
|
||||
- **Anki Update Notifications**: Switching notification settings to on-screen display while a card update is still in progress now correctly dismisses the old overlay progress indicator.
|
||||
- **Jellyfin Subtitles**: Subtitle files now load with zero delay in mpv instead of Jellyfin inferring and applying a sync offset.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(anki): add media timing review before card creation by @ksyasuda in #203
|
||||
- fix(jellyfin): stop inferring subtitle delays by @ksyasuda in #227
|
||||
- feat(anki): support Senren scene-switching field grouping by @ksyasuda in #230
|
||||
- fix(mining): copy multi-line subtitles backward from current line by @ksyasuda in #231
|
||||
- fix(subtitles): keep native secondary subtitles hidden by @ksyasuda in #232
|
||||
- fix(subtitles): drop ASS furigana from recorded cues by @ksyasuda in #233
|
||||
- fix(subtitles): merge wrapped positioned caption rows by @ksyasuda in #234
|
||||
- fix(tsukihime): filter releases by subtitle language by @ksyasuda in #235
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -55,7 +54,6 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||
const { stats } = STATS_DEFAULT_CONFIG;
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
|
||||
@@ -99,7 +99,6 @@ 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',
|
||||
|
||||
@@ -628,12 +628,6 @@ 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,46 +1,10 @@
|
||||
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 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.',
|
||||
}[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,15 +1,6 @@
|
||||
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: [
|
||||
|
||||
@@ -237,7 +237,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
'openSubtitleGeneration',
|
||||
'openSessionHelp',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import { ResolveContext } from './context';
|
||||
import { resolveSubtitleGenerationConfig } from '../../shared/subtitle-generation';
|
||||
import {
|
||||
asBoolean,
|
||||
asColor,
|
||||
@@ -47,17 +46,6 @@ 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);
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -448,9 +448,6 @@ 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' };
|
||||
}
|
||||
@@ -623,7 +620,6 @@ function subsectionForPath(path: string): string | undefined {
|
||||
leaf === 'openRuntimeOptions' ||
|
||||
leaf === 'openJimaku' ||
|
||||
leaf === 'openTsukihime' ||
|
||||
leaf === 'openSubtitleGeneration' ||
|
||||
leaf === 'openSessionHelp' ||
|
||||
leaf === 'openControllerSelect' ||
|
||||
leaf === 'openControllerDebug'
|
||||
@@ -732,8 +728,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
pathStartsWith(path, 'notifications') ||
|
||||
path === 'youtube.primarySubLanguages' ||
|
||||
pathStartsWith(path, 'jimaku') ||
|
||||
pathStartsWith(path, 'subsync') ||
|
||||
pathStartsWith(path, 'subtitleGeneration')
|
||||
pathStartsWith(path, 'subsync')
|
||||
) {
|
||||
return 'hot-reload';
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
|
||||
@@ -24,7 +24,6 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
|
||||
@@ -41,7 +41,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
openControllerDebug: () => calls.push('controller-debug'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
openTsukihime: () => calls.push('tsukihime'),
|
||||
openSubtitleGeneration: () => calls.push('subtitle-generation'),
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube');
|
||||
},
|
||||
@@ -86,9 +85,3 @@ test('dispatchSessionAction opens the character dictionary manager', async () =>
|
||||
|
||||
assert.deepEqual(calls, ['character-dictionary-manager']);
|
||||
});
|
||||
|
||||
test('dispatchSessionAction opens subtitle generation without opening the sidebar', async () => {
|
||||
const { calls, deps } = createDeps();
|
||||
await dispatchSessionAction({ actionId: 'openSubtitleGeneration' }, deps);
|
||||
assert.deepEqual(calls, ['subtitle-generation']);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ export interface SessionActionExecutorDeps {
|
||||
openControllerDebug: () => void;
|
||||
openJimaku: () => void;
|
||||
openTsukihime: () => void;
|
||||
openSubtitleGeneration: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||
replayCurrentSubtitle: () => void;
|
||||
@@ -120,9 +119,6 @@ export async function dispatchSessionAction(
|
||||
case 'openTsukihime':
|
||||
deps.openTsukihime();
|
||||
return;
|
||||
case 'openSubtitleGeneration':
|
||||
deps.openSubtitleGeneration();
|
||||
return;
|
||||
case 'openYoutubePicker':
|
||||
await deps.openYoutubeTrackPicker();
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { ConfiguredShortcuts } from '../utils/shortcut-config';
|
||||
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import { resolveConfiguredShortcuts } from '../utils/shortcut-config';
|
||||
import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './session-bindings';
|
||||
import { parseSessionActionDispatchRequest } from '../../shared/ipc/validators';
|
||||
|
||||
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
||||
return {
|
||||
@@ -24,7 +23,6 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
@@ -39,50 +37,6 @@ function createKeybinding(key: string, command: Keybinding['command']): Keybindi
|
||||
return { key, command };
|
||||
}
|
||||
|
||||
test('subtitle generation shortcut compiles for overlay and mpv without conflicting with field grouping', () => {
|
||||
for (const platform of ['linux', 'darwin', 'win32'] as const) {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG),
|
||||
keybindings: DEFAULT_KEYBINDINGS,
|
||||
platform,
|
||||
});
|
||||
const binding = result.bindings.find(
|
||||
(entry) =>
|
||||
entry.actionType === 'session-action' && entry.actionId === 'openSubtitleGeneration',
|
||||
);
|
||||
assert.ok(binding);
|
||||
assert.deepEqual(binding.key, { code: 'KeyG', modifiers: ['ctrl', 'shift'] });
|
||||
assert.equal(
|
||||
result.warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'shortcuts.openSubtitleGeneration' ||
|
||||
warning.conflictingPaths?.includes('shortcuts.openSubtitleGeneration'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.ok(
|
||||
result.bindings.some(
|
||||
(entry) =>
|
||||
entry.actionType === 'session-action' && entry.actionId === 'triggerFieldGrouping',
|
||||
),
|
||||
);
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings: [binding],
|
||||
warnings: [],
|
||||
numericSelectionTimeoutMs: 3000,
|
||||
});
|
||||
const pluginBinding = artifact.bindings[0];
|
||||
assert.ok(pluginBinding?.actionType === 'session-action');
|
||||
assert.deepEqual(pluginBinding.cliArgs, [
|
||||
'--session-action',
|
||||
'{"actionId":"openSubtitleGeneration"}',
|
||||
]);
|
||||
assert.deepEqual(parseSessionActionDispatchRequest({ actionId: 'openSubtitleGeneration' }), {
|
||||
actionId: 'openSubtitleGeneration',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('compileSessionBindings merges shortcuts and keybindings into one canonical list', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({
|
||||
|
||||
@@ -56,7 +56,6 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
||||
{ key: 'openJimaku', actionId: 'openJimaku' },
|
||||
{ key: 'openTsukihime', actionId: 'openTsukihime' },
|
||||
{ key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' },
|
||||
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
||||
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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: 'はい' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('chunk stitching matches repeated text to the greatest overlap without leaving a duplicate', () => {
|
||||
const cues = [
|
||||
{ startTime: 10, endTime: 14, text: 'はい' },
|
||||
{ startTime: 13, endTime: 20, text: 'はい' },
|
||||
];
|
||||
appendSpeechChunkCues(cues, [
|
||||
{ startTime: 12, endTime: 21, text: 'はい' },
|
||||
{ startTime: 22, endTime: 23, text: 'はい' },
|
||||
]);
|
||||
assert.deepEqual(cues, [
|
||||
{ startTime: 10, endTime: 14, text: 'はい' },
|
||||
{ startTime: 12, endTime: 21, text: 'はい' },
|
||||
{ startTime: 22, endTime: 23, text: 'はい' },
|
||||
]);
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
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<SubtitleCue>();
|
||||
for (const cue of incoming) {
|
||||
const text = cue.text.replace(/\s+/g, '');
|
||||
let duplicate: SubtitleCue | undefined;
|
||||
let greatestOverlap = 0;
|
||||
for (const [index, previous] of cues.entries()) {
|
||||
if (index >= previousCount) break;
|
||||
if (matched.has(previous) || previous.text.replace(/\s+/g, '') !== text) continue;
|
||||
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,
|
||||
);
|
||||
if (overlap > greatestOverlap && overlap >= shorterDuration / 2) {
|
||||
duplicate = previous;
|
||||
greatestOverlap = overlap;
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { access, readFile, rm } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
SubtitleGenerationConfig,
|
||||
SubtitleGenerationProgress,
|
||||
} from '../../shared/subtitle-generation';
|
||||
import { expandSubtitleGenerationPath } from './subtitle-generation-files';
|
||||
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
||||
import { formatTimestamp } from './subtitle-generation-srt';
|
||||
import {
|
||||
parseSpeechPassages,
|
||||
speechPassageCues,
|
||||
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;
|
||||
|
||||
export async function transcribeSubtitleDialogue(input: {
|
||||
config: SubtitleGenerationConfig;
|
||||
modelPath: string;
|
||||
wavPath: string;
|
||||
directory: string;
|
||||
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const vadModelPath = expandSubtitleGenerationPath(input.config.vadModelPath);
|
||||
await access(vadModelPath, constants.R_OK);
|
||||
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' });
|
||||
const segmentLines: string[] = [];
|
||||
await runSubtitleGenerationProcess({
|
||||
command: input.config.vadPath.trim() || 'whisper-vad-speech-segments',
|
||||
args: [
|
||||
'-f',
|
||||
input.wavPath,
|
||||
'-vm',
|
||||
vadModelPath,
|
||||
'-t',
|
||||
String(input.config.threads),
|
||||
'-vt',
|
||||
'0.3',
|
||||
'--vad-min-speech-duration-ms',
|
||||
'100',
|
||||
'--vad-min-silence-duration-ms',
|
||||
'500',
|
||||
'-vp',
|
||||
'350',
|
||||
'-vmsd',
|
||||
String(SPEECH_PASSAGE_SECONDS),
|
||||
'-np',
|
||||
],
|
||||
signal: input.signal,
|
||||
// Capture structured result lines separately from the bounded process log.
|
||||
onLine: (line) => {
|
||||
if (line.startsWith('Detected ') || line.startsWith('Speech segment '))
|
||||
segmentLines.push(line);
|
||||
},
|
||||
});
|
||||
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) => ({
|
||||
passage,
|
||||
base: path.join(input.directory, `speech-${offset + index}`),
|
||||
}));
|
||||
input.onProgress?.({
|
||||
stage: 'transcribe',
|
||||
percent: Math.floor((offset / passages.length) * 100),
|
||||
message: `Transcribing dialogue passages ${offset + 1}-${offset + batch.length} of ${passages.length}...`,
|
||||
});
|
||||
for (const { passage, base } of batch) {
|
||||
await runSubtitleGenerationProcess({
|
||||
command: input.config.ffmpegPath.trim() || 'ffmpeg',
|
||||
args: [
|
||||
'-nostdin',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-ss',
|
||||
String(passage.startSeconds),
|
||||
'-i',
|
||||
input.wavPath,
|
||||
'-t',
|
||||
String(passage.endSeconds - passage.startSeconds),
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'-c:a',
|
||||
'pcm_s16le',
|
||||
`${base}.wav`,
|
||||
],
|
||||
signal: input.signal,
|
||||
});
|
||||
}
|
||||
// One model load per batch, with independent text context and timestamps for every passage.
|
||||
await runSubtitleGenerationProcess({
|
||||
command: input.config.whisperPath.trim() || 'whisper-cli',
|
||||
args: [
|
||||
'-m',
|
||||
input.modelPath,
|
||||
'-l',
|
||||
'ja',
|
||||
'-t',
|
||||
String(input.config.threads),
|
||||
'-mc',
|
||||
'0',
|
||||
'-sns',
|
||||
'-osrt',
|
||||
...batch.flatMap(({ base }) => ['-f', `${base}.wav`, '-of', base]),
|
||||
],
|
||||
signal: input.signal,
|
||||
});
|
||||
for (const { passage, base } of batch) {
|
||||
input.signal?.throwIfAborted();
|
||||
appendSpeechChunkCues(
|
||||
cues,
|
||||
speechPassageCues(await readFile(`${base}.srt`, 'utf8'), passage),
|
||||
);
|
||||
await rm(`${base}.wav`);
|
||||
}
|
||||
}
|
||||
if (cues.length === 0) throw new Error('Whisper recognized no dialogue in the detected speech.');
|
||||
return cues
|
||||
.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime)
|
||||
.map(
|
||||
(cue, index) =>
|
||||
`${index + 1}\n${formatTimestamp(cue.startTime * 1000)} --> ${formatTimestamp(cue.endTime * 1000)}\n${cue.text}\n`,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { 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 { downloadSubtitleGenerationArtifact } from './subtitle-generation-download';
|
||||
import {
|
||||
downloadSubtitleGenerationVadModel,
|
||||
resolveSubtitleGenerationVadModel,
|
||||
} from './subtitle-generation-vad-model';
|
||||
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../shared/subtitle-generation';
|
||||
|
||||
test('verified model publication preserves existing files and cleans up failed or cancelled downloads', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-model-download-'));
|
||||
const originalFetch = globalThis.fetch;
|
||||
const bytes = new TextEncoder().encode('fixture model');
|
||||
const input = {
|
||||
url: 'https://example.test/model',
|
||||
size: bytes.length,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
destination: path.join(directory, 'model.bin'),
|
||||
label: 'test model',
|
||||
};
|
||||
try {
|
||||
globalThis.fetch = Object.assign(async () => new Response(bytes), originalFetch);
|
||||
await downloadSubtitleGenerationArtifact(input);
|
||||
assert.equal(await readFile(input.destination, 'utf8'), 'fixture model');
|
||||
await assert.rejects(downloadSubtitleGenerationArtifact(input), /EEXIST/);
|
||||
await assert.rejects(
|
||||
downloadSubtitleGenerationArtifact({
|
||||
...input,
|
||||
destination: path.join(directory, 'bad.bin'),
|
||||
sha256: 'wrong',
|
||||
}),
|
||||
/integrity/,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
await assert.rejects(
|
||||
downloadSubtitleGenerationArtifact({
|
||||
...input,
|
||||
destination: path.join(directory, 'cancelled.bin'),
|
||||
signal: controller.signal,
|
||||
onProgress: ({ percent }) => {
|
||||
if (percent === 99) controller.abort();
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await readdir(directory), ['model.bin']);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('VAD setup recognizes existing paths and never replaces an invalid external model', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-vad-model-'));
|
||||
try {
|
||||
const config = { ...DEFAULT_SUBTITLE_GENERATION_CONFIG };
|
||||
assert.equal((await resolveSubtitleGenerationVadModel(config, directory)).kind, 'missing');
|
||||
config.vadModelPath = path.join(directory, 'external.bin');
|
||||
await assert.rejects(
|
||||
downloadSubtitleGenerationVadModel({ config, modelDirectory: directory }),
|
||||
/Cannot read/,
|
||||
);
|
||||
await writeFile(config.vadModelPath, 'external model');
|
||||
assert.equal((await resolveSubtitleGenerationVadModel(config, directory)).kind, 'external');
|
||||
assert.equal(
|
||||
await downloadSubtitleGenerationVadModel({ config, modelDirectory: directory }),
|
||||
config.vadModelPath,
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, mkdtemp, open, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { publishSubtitleGenerationFile } from './subtitle-generation-files';
|
||||
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
|
||||
|
||||
export async function downloadSubtitleGenerationArtifact(input: {
|
||||
url: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
destination: string;
|
||||
label: string;
|
||||
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
input.signal?.throwIfAborted();
|
||||
await mkdir(path.dirname(input.destination), { recursive: true });
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
path.join(path.dirname(input.destination), '.download-'),
|
||||
);
|
||||
const temporaryPath = path.join(temporaryDirectory, 'model.bin');
|
||||
input.onProgress?.({ stage: 'download', percent: 0, message: `Downloading ${input.label}...` });
|
||||
try {
|
||||
const response = await fetch(input.url, { signal: input.signal });
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Model download failed: HTTP ${response.status}`);
|
||||
}
|
||||
const file = await open(temporaryPath, 'wx');
|
||||
const reader = response.body.getReader();
|
||||
const digest = createHash('sha256');
|
||||
let received = 0;
|
||||
let previousPercent = -1;
|
||||
try {
|
||||
while (true) {
|
||||
input.signal?.throwIfAborted();
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.byteLength;
|
||||
if (received > input.size) throw new Error('Downloaded model exceeds expected size.');
|
||||
digest.update(value);
|
||||
await file.writeFile(value);
|
||||
const percent = Math.min(99, Math.floor((received / input.size) * 100));
|
||||
if (percent !== previousPercent) {
|
||||
previousPercent = percent;
|
||||
input.onProgress?.({
|
||||
stage: 'download',
|
||||
percent,
|
||||
message: `Downloading ${input.label}...`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (received !== input.size || digest.digest('hex') !== input.sha256) {
|
||||
throw new Error('Downloaded model failed integrity verification. Try downloading again.');
|
||||
}
|
||||
await file.sync();
|
||||
} finally {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
await file.close();
|
||||
}
|
||||
input.signal?.throwIfAborted();
|
||||
await publishSubtitleGenerationFile(temporaryPath, input.destination);
|
||||
input.onProgress?.({ stage: 'download', percent: 100, message: `${input.label} is ready.` });
|
||||
return input.destination;
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { copyFile, link } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export function expandSubtitleGenerationPath(value: string): string {
|
||||
if (value === '~') return homedir();
|
||||
if (value.startsWith('~/') || value.startsWith('~\\'))
|
||||
return path.join(homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
// Prefer atomic publication. Filesystems without hard links still get exclusive creation.
|
||||
export async function publishSubtitleGenerationFile(
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await link(source, destination);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!('code' in error) ||
|
||||
(error.code !== 'ENOTSUP' &&
|
||||
error.code !== 'EOPNOTSUPP' &&
|
||||
error.code !== 'EPERM' &&
|
||||
error.code !== 'EXDEV')
|
||||
)
|
||||
throw error;
|
||||
await copyFile(source, destination, constants.COPYFILE_EXCL);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { access, open, stat } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getSubtitleGenerationModel } from '../../shared/subtitle-generation-model-catalog';
|
||||
import { expandSubtitleGenerationPath } from './subtitle-generation-files';
|
||||
import { downloadSubtitleGenerationArtifact } from './subtitle-generation-download';
|
||||
import type {
|
||||
SubtitleGenerationConfig,
|
||||
SubtitleGenerationModelStatus,
|
||||
SubtitleGenerationProgress,
|
||||
} from '../../shared/subtitle-generation';
|
||||
|
||||
export function isMissingFile(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
async function modelCompatibilityError(modelPath: string): Promise<string | undefined> {
|
||||
const file = await open(modelPath, 'r');
|
||||
try {
|
||||
// whisper_model_load reads GGML magic, then n_vocab. is_multilingual uses n_vocab >= 51865.
|
||||
const header = Buffer.alloc(8);
|
||||
const { bytesRead } = await file.read(header, 0, header.length, 0);
|
||||
if (
|
||||
bytesRead !== header.length ||
|
||||
header.readUInt32LE(0) !== 0x67676d6c ||
|
||||
header.readInt32LE(4) <= 0
|
||||
) {
|
||||
return 'Unsupported model format. Choose a whisper.cpp GGML .bin model.';
|
||||
}
|
||||
if (header.readInt32LE(4) < 51865) {
|
||||
return 'This Whisper model is English-only. Japanese subtitle generation requires a multilingual model.';
|
||||
}
|
||||
return undefined;
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSubtitleGenerationModel(
|
||||
config: SubtitleGenerationConfig,
|
||||
modelDirectory: string,
|
||||
): Promise<SubtitleGenerationModelStatus> {
|
||||
const external = config.modelPath.trim();
|
||||
const modelPath = external
|
||||
? path.resolve(expandSubtitleGenerationPath(external))
|
||||
: path.resolve(modelDirectory, `ggml-${config.managedModel}.bin`);
|
||||
try {
|
||||
const info = await stat(modelPath);
|
||||
if (!info.isFile() || info.size === 0) {
|
||||
return { kind: 'invalid', path: modelPath, message: 'Model must be a nonempty file.' };
|
||||
}
|
||||
await access(modelPath, constants.R_OK);
|
||||
if (!external && info.size !== getSubtitleGenerationModel(config.managedModel).size) {
|
||||
return { kind: 'invalid', path: modelPath, message: 'Managed model has an unexpected size.' };
|
||||
}
|
||||
const compatibilityError = await modelCompatibilityError(modelPath);
|
||||
if (compatibilityError)
|
||||
return { kind: 'invalid', path: modelPath, message: compatibilityError };
|
||||
return { kind: external ? 'external' : 'managed', path: modelPath };
|
||||
} catch (error) {
|
||||
if (!external && isMissingFile(error)) return { kind: 'missing', path: modelPath };
|
||||
return {
|
||||
kind: 'invalid',
|
||||
path: modelPath,
|
||||
message: `Cannot read model: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadSubtitleGenerationModel(input: {
|
||||
config: SubtitleGenerationConfig;
|
||||
modelDirectory: string;
|
||||
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
input.signal?.throwIfAborted();
|
||||
const current = await resolveSubtitleGenerationModel(input.config, input.modelDirectory);
|
||||
if (current.kind === 'external' || current.kind === 'managed') return current.path;
|
||||
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/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-${input.config.managedModel}.bin`,
|
||||
size: model.size,
|
||||
sha256: model.sha256,
|
||||
destination: current.path,
|
||||
label: 'Whisper model',
|
||||
onProgress: input.onProgress,
|
||||
signal: input.signal,
|
||||
});
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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<number[]> {
|
||||
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);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { expandSubtitleGenerationPath } from './subtitle-generation-files';
|
||||
|
||||
const OUTPUT_LIMIT = 64 * 1024;
|
||||
|
||||
// Keep partial lines between chunks: ffmpeg and whisper both report progress on stderr.
|
||||
export function runSubtitleGenerationProcess(input: {
|
||||
command: string;
|
||||
args: string[];
|
||||
signal?: AbortSignal;
|
||||
onLine?: (line: string) => void;
|
||||
}): Promise<string> {
|
||||
input.signal?.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(expandSubtitleGenerationPath(input.command), input.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const abort = () => {
|
||||
child.kill('SIGTERM');
|
||||
killTimer = setTimeout(() => child.kill('SIGKILL'), 2000);
|
||||
killTimer.unref();
|
||||
};
|
||||
input.signal?.addEventListener('abort', abort, { once: true });
|
||||
if (input.signal?.aborted) abort();
|
||||
const cleanup = () => {
|
||||
input.signal?.removeEventListener('abort', abort);
|
||||
clearTimeout(killTimer);
|
||||
};
|
||||
for (const [stream, isStdout] of [
|
||||
[child.stdout, true],
|
||||
[child.stderr, false],
|
||||
] as const) {
|
||||
let pending = '';
|
||||
stream.setEncoding('utf8');
|
||||
stream.on('data', (chunk: string) => {
|
||||
if (isStdout) stdout = (stdout + chunk).slice(-OUTPUT_LIMIT);
|
||||
else stderr = (stderr + chunk).slice(-OUTPUT_LIMIT);
|
||||
const lines = (pending + chunk).split(/[\r\n]/);
|
||||
pending = (lines.pop() ?? '').slice(-OUTPUT_LIMIT);
|
||||
for (const line of lines) input.onLine?.(line);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (pending) input.onLine?.(pending);
|
||||
});
|
||||
}
|
||||
child.once('error', (error) => {
|
||||
cleanup();
|
||||
reject(new Error(`Could not run ${input.command}: ${error.message}`));
|
||||
});
|
||||
child.once('close', (code) => {
|
||||
cleanup();
|
||||
if (input.signal?.aborted) reject(new Error('Subtitle generation cancelled.'));
|
||||
else if (code !== 0) {
|
||||
reject(new Error(`${input.command} exited with status ${code}: ${stderr.trim()}`));
|
||||
} else resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseSpeechPassages, speechPassageCues } from './subtitle-generation-speech';
|
||||
|
||||
test('speech passages convert centiseconds, group nearby speech, and retain long gaps', () => {
|
||||
assert.deepEqual(
|
||||
parseSpeechPassages(
|
||||
[
|
||||
'Detected 4 speech segments:',
|
||||
'Speech segment 0: start = 0.00, end = 300.00',
|
||||
'Speech segment 1: start = 350.00, end = 900.00',
|
||||
'Speech segment 2: start = 10000.00, end = 11900.00',
|
||||
'Speech segment 3: start = 11950.00, end = 12500.00',
|
||||
].join('\n'),
|
||||
),
|
||||
[
|
||||
{ startSeconds: 0, endSeconds: 9 },
|
||||
{ startSeconds: 100, endSeconds: 119 },
|
||||
{ startSeconds: 119.5, endSeconds: 125 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
parseSpeechPassages(
|
||||
'Detected 1 speech segments:\nSpeech segment 0: start = 79896.00, end = 82218.00',
|
||||
),
|
||||
[{ startSeconds: 798.96, endSeconds: 822.18 }],
|
||||
);
|
||||
for (const output of [
|
||||
'',
|
||||
'Detected 1 speech segments:',
|
||||
'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 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/);
|
||||
});
|
||||
|
||||
test('passage cue times cannot extend into omitted audio or accumulate offsets', () => {
|
||||
const srt =
|
||||
'1\n00:00:00,000 --> 00:00:01,000\nはい\n\n2\n00:00:01,000 --> 00:01:40,000\nはい\n\n3\n00:01:41,000 --> 00:01:42,000\n幻覚\n';
|
||||
assert.deepEqual(
|
||||
speechPassageCues(srt, { startSeconds: 1200.25, endSeconds: 1203.75 }).map(
|
||||
({ startTime, endTime, text }) => ({ startTime, endTime, text }),
|
||||
),
|
||||
[
|
||||
{ startTime: 1200.25, endTime: 1201.25, text: 'はい' },
|
||||
{ startTime: 1201.25, endTime: 1203.75, text: 'はい' },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(speechPassageCues('', { startSeconds: 0, endSeconds: 1 }), []);
|
||||
assert.throws(
|
||||
() => speechPassageCues('broken SRT', { startSeconds: 0, endSeconds: 1 }),
|
||||
/malformed/,
|
||||
);
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { parseSrtCues, type SubtitleCue } from './subtitle-cue-parser';
|
||||
|
||||
export interface SpeechPassage {
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
}
|
||||
|
||||
export const SPEECH_PASSAGE_SECONDS = 20;
|
||||
|
||||
// The standalone whisper.cpp detector reports centiseconds, unlike its diagnostic logs.
|
||||
export function parseSpeechPassages(output: string): SpeechPassage[] {
|
||||
const count = /^Detected (\d+) speech segments:$/m.exec(output);
|
||||
if (!count) throw new Error('Speech detector did not report its segment count.');
|
||||
const passages: SpeechPassage[] = [];
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
if (!line.startsWith('Speech segment ')) continue;
|
||||
const match = /^Speech segment (\d+): start = (\d+(?:\.\d+)?), end = (\d+(?:\.\d+)?)$/.exec(
|
||||
line,
|
||||
);
|
||||
if (!match) throw new Error('Speech detector returned a malformed segment.');
|
||||
const index = Number(match[1]);
|
||||
const startSeconds = Number(match[2]) / 100;
|
||||
const endSeconds = Number(match[3]) / 100;
|
||||
if (
|
||||
index !== passages.length ||
|
||||
!Number.isFinite(startSeconds) ||
|
||||
!Number.isFinite(endSeconds) ||
|
||||
endSeconds <= startSeconds ||
|
||||
startSeconds < (passages.at(-1)?.endSeconds ?? 0)
|
||||
) {
|
||||
throw new Error('Speech detector returned unordered or invalid segment timing.');
|
||||
}
|
||||
passages.push({ startSeconds, endSeconds });
|
||||
}
|
||||
if (passages.length !== Number(count[1]))
|
||||
throw new Error('Speech detector output is incomplete.');
|
||||
|
||||
const grouped: SpeechPassage[] = [];
|
||||
for (const passage of passages) {
|
||||
const previous = grouped.at(-1);
|
||||
if (
|
||||
previous &&
|
||||
passage.startSeconds - previous.endSeconds <= 1 &&
|
||||
passage.endSeconds - previous.startSeconds <= SPEECH_PASSAGE_SECONDS
|
||||
) {
|
||||
previous.endSeconds = passage.endSeconds;
|
||||
} else grouped.push({ ...passage });
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// Clamp to the audio actually supplied to Whisper. A cue cannot cross an omitted music break.
|
||||
export function speechPassageCues(srt: string, passage: SpeechPassage): SubtitleCue[] {
|
||||
const duration = passage.endSeconds - passage.startSeconds;
|
||||
const cues = parseSrtCues(srt);
|
||||
if (srt.trim() && cues.length === 0)
|
||||
throw new Error('Whisper returned malformed subtitles for a speech passage.');
|
||||
return cues.flatMap((cue) => {
|
||||
const start = Math.max(0, cue.startTime);
|
||||
const end = Math.min(duration, cue.endTime);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return [];
|
||||
return [
|
||||
{ ...cue, startTime: passage.startSeconds + start, endTime: passage.startSeconds + end },
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function formatTimestamp(milliseconds: number): string {
|
||||
const rounded = Math.max(0, Math.round(milliseconds));
|
||||
const hours = Math.floor(rounded / 3600000);
|
||||
const minutes = Math.floor((rounded % 3600000) / 60000);
|
||||
const seconds = Math.floor((rounded % 60000) / 1000);
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')},${String(rounded % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { access, stat } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
SubtitleGenerationConfig,
|
||||
SubtitleGenerationModelStatus,
|
||||
SubtitleGenerationProgress,
|
||||
} from '../../shared/subtitle-generation';
|
||||
import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model';
|
||||
import { expandSubtitleGenerationPath } from './subtitle-generation-files';
|
||||
import { isMissingFile } from './subtitle-generation-models';
|
||||
import { downloadSubtitleGenerationArtifact } from './subtitle-generation-download';
|
||||
|
||||
export async function resolveSubtitleGenerationVadModel(
|
||||
config: SubtitleGenerationConfig,
|
||||
modelDirectory: string,
|
||||
): Promise<SubtitleGenerationModelStatus> {
|
||||
const external = config.vadModelPath.trim();
|
||||
const modelPath = external
|
||||
? path.resolve(expandSubtitleGenerationPath(external))
|
||||
: path.resolve(modelDirectory, SUBTITLE_GENERATION_VAD_MODEL.filename);
|
||||
try {
|
||||
const info = await stat(modelPath);
|
||||
if (
|
||||
!info.isFile() ||
|
||||
info.size === 0 ||
|
||||
(!external && info.size !== SUBTITLE_GENERATION_VAD_MODEL.size)
|
||||
)
|
||||
return {
|
||||
kind: 'invalid',
|
||||
path: modelPath,
|
||||
message: 'Speech detection model has an invalid size.',
|
||||
};
|
||||
await access(modelPath, constants.R_OK);
|
||||
return { kind: external ? 'external' : 'managed', path: modelPath };
|
||||
} catch (error) {
|
||||
if (!external && isMissingFile(error)) return { kind: 'missing', path: modelPath };
|
||||
return {
|
||||
kind: 'invalid',
|
||||
path: modelPath,
|
||||
message: `Cannot read speech detection model: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadSubtitleGenerationVadModel(input: {
|
||||
config: SubtitleGenerationConfig;
|
||||
modelDirectory: string;
|
||||
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
input.signal?.throwIfAborted();
|
||||
const current = await resolveSubtitleGenerationVadModel(input.config, input.modelDirectory);
|
||||
if (current.kind === 'invalid') throw new Error(current.message);
|
||||
if (current.kind !== 'missing') return current.path;
|
||||
return downloadSubtitleGenerationArtifact({
|
||||
...SUBTITLE_GENERATION_VAD_MODEL,
|
||||
destination: current.path,
|
||||
label: 'Silero speech detection model',
|
||||
onProgress: input.onProgress,
|
||||
signal: input.signal,
|
||||
});
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { 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,
|
||||
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}\nrequire('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('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 keeps separate speech passages 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',
|
||||
"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); for(let i=0;i<args.length;i++) if(args[i]==='-of') require('node:fs').writeFileSync(args[i+1]+'.srt', '1\\n00:00:00,000 --> 00:01:39,000\\nはい\\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('-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);
|
||||
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, 2);
|
||||
assert.deepEqual(
|
||||
(await readdir(directory)).filter((file) => file.endsWith('.srt')),
|
||||
[],
|
||||
);
|
||||
}));
|
||||
|
||||
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') },
|
||||
}),
|
||||
/Could not run .*missing-override/,
|
||||
);
|
||||
} finally {
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
}
|
||||
}));
|
||||
|
||||
test('generation rejects remote media and missing models 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(readFile(input.callsPath), /ENOENT/);
|
||||
}));
|
||||
|
||||
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;
|
||||
}
|
||||
}));
|
||||
@@ -1,295 +0,0 @@
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
SubtitleGenerationConfig,
|
||||
SubtitleGenerationProgress,
|
||||
} from '../../shared/subtitle-generation';
|
||||
import { isMissingFile, resolveSubtitleGenerationModel } from './subtitle-generation-models';
|
||||
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
||||
import { publishSubtitleGenerationFile } from './subtitle-generation-files';
|
||||
import { formatTimestamp } from './subtitle-generation-srt';
|
||||
import { transcribeSubtitleDialogue } from './subtitle-generation-dialogue';
|
||||
|
||||
export {
|
||||
downloadSubtitleGenerationModel,
|
||||
resolveSubtitleGenerationModel,
|
||||
} from './subtitle-generation-models';
|
||||
|
||||
function numericTime(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : undefined;
|
||||
}
|
||||
|
||||
function parseAudioProbe(raw: string, selectedIndex: number | undefined) {
|
||||
const value: unknown = JSON.parse(raw);
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!('streams' in value) ||
|
||||
!Array.isArray(value.streams)
|
||||
) {
|
||||
throw new Error('ffprobe did not return media streams.');
|
||||
}
|
||||
const streams = value.streams.flatMap((stream: unknown) => {
|
||||
if (
|
||||
typeof stream !== 'object' ||
|
||||
stream === null ||
|
||||
!('codec_type' in stream) ||
|
||||
stream.codec_type !== 'audio' ||
|
||||
!('index' in stream) ||
|
||||
typeof stream.index !== 'number' ||
|
||||
!Number.isInteger(stream.index) ||
|
||||
stream.index < 0
|
||||
)
|
||||
return [];
|
||||
const tags = 'tags' in stream ? stream.tags : undefined;
|
||||
const language =
|
||||
typeof tags === 'object' && tags !== null && 'language' in tags ? tags.language : undefined;
|
||||
return [
|
||||
{
|
||||
index: stream.index,
|
||||
start: 'start_time' in stream ? numericTime(stream.start_time) : undefined,
|
||||
duration: 'duration' in stream ? numericTime(stream.duration) : undefined,
|
||||
japanese: language === 'ja' || language === 'jpn',
|
||||
},
|
||||
];
|
||||
});
|
||||
const selected =
|
||||
selectedIndex === undefined
|
||||
? (streams.find((stream) => stream.japanese) ?? streams[0])
|
||||
: streams.find((stream) => stream.index === selectedIndex);
|
||||
if (!selected)
|
||||
throw new Error(
|
||||
selectedIndex === undefined
|
||||
? 'No audio track found.'
|
||||
: `Audio stream ${selectedIndex} was not found.`,
|
||||
);
|
||||
const format = 'format' in value ? value.format : undefined;
|
||||
const formatStart =
|
||||
typeof format === 'object' && format !== null && 'start_time' in format
|
||||
? (numericTime(format.start_time) ?? 0)
|
||||
: 0;
|
||||
const duration =
|
||||
selected.duration ??
|
||||
(typeof format === 'object' && format !== null && 'duration' in format
|
||||
? numericTime(format.duration)
|
||||
: undefined);
|
||||
// mpv rebases media timestamps to the container start. Extraction rebases the selected audio.
|
||||
return { index: selected.index, offset: (selected.start ?? formatStart) - formatStart, duration };
|
||||
}
|
||||
|
||||
function shiftSubtitleTimestamps(srt: string, offsetSeconds: number): string {
|
||||
let cueCount = 0;
|
||||
const result = srt.replace(
|
||||
/(\d{2,}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2,}):(\d{2}):(\d{2}),(\d{3})/g,
|
||||
(
|
||||
_match,
|
||||
sh: string,
|
||||
sm: string,
|
||||
ss: string,
|
||||
sms: string,
|
||||
eh: string,
|
||||
em: string,
|
||||
es: string,
|
||||
ems: string,
|
||||
) => {
|
||||
cueCount += 1;
|
||||
const start = Number(sh) * 3600000 + Number(sm) * 60000 + Number(ss) * 1000 + Number(sms);
|
||||
const end = Number(eh) * 3600000 + Number(em) * 60000 + Number(es) * 1000 + Number(ems);
|
||||
return `${formatTimestamp(start + offsetSeconds * 1000)} --> ${formatTimestamp(end + offsetSeconds * 1000)}`;
|
||||
},
|
||||
);
|
||||
if (cueCount === 0)
|
||||
throw new Error(
|
||||
'Whisper produced no subtitle cues. The audio may contain no recognized speech.',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function ensureAvailableOutput(outputPath: string): Promise<void> {
|
||||
try {
|
||||
await stat(outputPath);
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) return;
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Subtitle output already exists: ${outputPath}`);
|
||||
}
|
||||
|
||||
async function writeSubtitles(input: {
|
||||
mediaPath: string;
|
||||
outputPath?: string;
|
||||
contents: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const parsed = path.parse(input.mediaPath);
|
||||
const directory = input.outputPath ? path.dirname(path.resolve(input.outputPath)) : parsed.dir;
|
||||
const temporaryDirectory = await mkdtemp(path.join(directory, '.subminer-subtitles-'));
|
||||
try {
|
||||
const staged = path.join(temporaryDirectory, 'subtitles.srt');
|
||||
await writeFile(staged, input.contents, { flag: 'wx' });
|
||||
for (let suffix = 0; ; suffix += 1) {
|
||||
input.signal?.throwIfAborted();
|
||||
const destination = input.outputPath
|
||||
? path.resolve(input.outputPath)
|
||||
: path.join(directory, `${parsed.name}.ja.generated${suffix ? `.${suffix}` : ''}.srt`);
|
||||
try {
|
||||
await publishSubtitleGenerationFile(staged, destination);
|
||||
return destination;
|
||||
} catch (error) {
|
||||
if (
|
||||
!input.outputPath &&
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'EEXIST'
|
||||
)
|
||||
continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateJapaneseSubtitles(input: {
|
||||
config: SubtitleGenerationConfig;
|
||||
modelDirectory: string;
|
||||
mediaPath: string;
|
||||
audioStreamIndex?: number;
|
||||
outputPath?: string;
|
||||
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
input.signal?.throwIfAborted();
|
||||
if (/^[a-z][a-z\d+.-]*:\/\//i.test(input.mediaPath))
|
||||
throw new Error('Subtitle generation requires a local media file.');
|
||||
const mediaPath = path.resolve(input.mediaPath);
|
||||
if (!(await stat(mediaPath)).isFile())
|
||||
throw new Error('Subtitle generation requires a local media file.');
|
||||
if (input.outputPath) await ensureAvailableOutput(path.resolve(input.outputPath));
|
||||
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);
|
||||
input.onProgress?.({ stage: 'extract', message: 'Inspecting audio tracks...' });
|
||||
const probe = await runSubtitleGenerationProcess({
|
||||
command: input.config.ffprobePath.trim() || 'ffprobe',
|
||||
args: [
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'stream=index,codec_type,start_time,duration:stream_tags=language:format=start_time,duration',
|
||||
'-of',
|
||||
'json',
|
||||
mediaPath,
|
||||
],
|
||||
signal: input.signal,
|
||||
});
|
||||
const audio = parseAudioProbe(probe, input.audioStreamIndex);
|
||||
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'subminer-whisper-'));
|
||||
try {
|
||||
const wavPath = path.join(temporaryDirectory, 'audio.wav');
|
||||
const subtitleBase = path.join(temporaryDirectory, 'subtitles');
|
||||
input.onProgress?.({ stage: 'extract', percent: 0, message: 'Extracting audio...' });
|
||||
await runSubtitleGenerationProcess({
|
||||
command: input.config.ffmpegPath.trim() || 'ffmpeg',
|
||||
args: [
|
||||
'-nostdin',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-i',
|
||||
mediaPath,
|
||||
'-map',
|
||||
`0:${audio.index}`,
|
||||
'-vn',
|
||||
'-af',
|
||||
'asetpts=PTS-STARTPTS',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'-c:a',
|
||||
'pcm_s16le',
|
||||
'-progress',
|
||||
'pipe:1',
|
||||
'-nostats',
|
||||
wavPath,
|
||||
],
|
||||
signal: input.signal,
|
||||
onLine: (line) => {
|
||||
const match = /^out_time_us=(\d+)$/.exec(line);
|
||||
if (match && audio.duration && audio.duration > 0) {
|
||||
input.onProgress?.({
|
||||
stage: 'extract',
|
||||
percent: Math.min(100, Math.floor(Number(match[1]) / 10000 / audio.duration)),
|
||||
message: 'Extracting audio...',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
input.onProgress?.({
|
||||
stage: 'transcribe',
|
||||
percent: 0,
|
||||
message: 'Generating Japanese subtitles...',
|
||||
});
|
||||
let srt: string;
|
||||
if (input.config.vadModelPath.trim()) {
|
||||
srt = await transcribeSubtitleDialogue({
|
||||
config: input.config,
|
||||
modelPath: model.path,
|
||||
wavPath,
|
||||
directory: temporaryDirectory,
|
||||
signal: input.signal,
|
||||
onProgress: input.onProgress,
|
||||
});
|
||||
} else {
|
||||
await runSubtitleGenerationProcess({
|
||||
command: input.config.whisperPath.trim() || 'whisper-cli',
|
||||
args: [
|
||||
'-m',
|
||||
model.path,
|
||||
'-f',
|
||||
wavPath,
|
||||
'-l',
|
||||
'ja',
|
||||
'-t',
|
||||
String(input.config.threads),
|
||||
'-osrt',
|
||||
'-of',
|
||||
subtitleBase,
|
||||
'-pp',
|
||||
],
|
||||
signal: input.signal,
|
||||
onLine: (line) => {
|
||||
const match = /progress\s*=\s*(\d+(?:\.\d+)?)%/.exec(line);
|
||||
if (match)
|
||||
input.onProgress?.({
|
||||
stage: 'transcribe',
|
||||
percent: Math.min(100, Number(match[1])),
|
||||
message: 'Generating Japanese subtitles...',
|
||||
});
|
||||
},
|
||||
});
|
||||
srt = await readFile(`${subtitleBase}.srt`, 'utf8');
|
||||
}
|
||||
input.signal?.throwIfAborted();
|
||||
input.onProgress?.({ stage: 'write', message: 'Saving Japanese subtitles...' });
|
||||
const contents = shiftSubtitleTimestamps(srt, audio.offset);
|
||||
const outputPath = await writeSubtitles({
|
||||
mediaPath,
|
||||
outputPath: input.outputPath,
|
||||
contents,
|
||||
signal: input.signal,
|
||||
});
|
||||
input.onProgress?.({ stage: 'write', percent: 100, message: 'Japanese subtitles are ready.' });
|
||||
return outputPath;
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ export interface ConfiguredShortcuts {
|
||||
openRuntimeOptions: string | null | undefined;
|
||||
openJimaku: string | null | undefined;
|
||||
openTsukihime: string | null | undefined;
|
||||
openSubtitleGeneration: string | null | undefined;
|
||||
openSessionHelp: string | null | undefined;
|
||||
openControllerSelect: string | null | undefined;
|
||||
openControllerDebug: string | null | undefined;
|
||||
@@ -68,7 +67,6 @@ export function resolveConfiguredShortcuts(
|
||||
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
||||
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
||||
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
|
||||
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
|
||||
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
|
||||
|
||||
@@ -585,24 +585,58 @@ test('shouldDetachBackgroundLaunch only for first background invocation', () =>
|
||||
|
||||
test('configureEarlyAppPaths pins userData to canonical SubMiner config dir', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const userDataPath = configureEarlyAppPaths(
|
||||
{
|
||||
setName: (name) => {
|
||||
calls.push(`name:${name}`);
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-paths-'));
|
||||
const configDir = path.posix.join(tempDir, 'SubMiner');
|
||||
try {
|
||||
const userDataPath = configureEarlyAppPaths(
|
||||
{
|
||||
setName: (name) => {
|
||||
calls.push(`name:${name}`);
|
||||
},
|
||||
setPath: (key, value) => {
|
||||
calls.push(`path:${key}:${value}`);
|
||||
},
|
||||
},
|
||||
setPath: (key, value) => {
|
||||
calls.push(`path:${key}:${value}`);
|
||||
{
|
||||
platform: 'linux',
|
||||
homeDir: tempDir,
|
||||
xdgConfigHome: tempDir,
|
||||
existsSync: (candidate) =>
|
||||
candidate === path.posix.join(tempDir, 'subminer', 'config.jsonc'),
|
||||
},
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
xdgConfigHome: '/tmp/xdg',
|
||||
existsSync: (candidate) => candidate === '/tmp/xdg/subminer/config.jsonc',
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
assert.equal(userDataPath, '/tmp/xdg/SubMiner');
|
||||
assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner']);
|
||||
assert.equal(userDataPath, configDir);
|
||||
assert.deepEqual(calls, ['name:SubMiner', `path:userData:${configDir}`]);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('configureEarlyAppPaths creates a fresh macOS config directory before Electron uses it', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-first-launch-'));
|
||||
const configDir = path.posix.join(homeDir, '.config', 'SubMiner');
|
||||
try {
|
||||
const app = {
|
||||
setName: () => {},
|
||||
setPath: (_key: 'userData', value: string) => {
|
||||
assert.equal(value, configDir);
|
||||
assert.equal(fs.statSync(value).isDirectory(), true);
|
||||
},
|
||||
};
|
||||
const options = { platform: 'darwin', homeDir, xdgConfigHome: '' } satisfies Parameters<
|
||||
typeof configureEarlyAppPaths
|
||||
>[1];
|
||||
|
||||
assert.equal(fs.existsSync(path.join(homeDir, '.config')), false);
|
||||
configureEarlyAppPaths(app, options);
|
||||
|
||||
const configPath = path.join(configDir, 'config.jsonc');
|
||||
const existingConfig = '{"logging":{"level":"debug"}}\n';
|
||||
fs.writeFileSync(configPath, existingConfig);
|
||||
configureEarlyAppPaths(app, options);
|
||||
assert.equal(fs.readFileSync(configPath, 'utf8'), existingConfig);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -260,6 +260,8 @@ export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPath
|
||||
existsSync: options?.existsSync ?? fs.existsSync,
|
||||
});
|
||||
|
||||
// The entry process requests its singleton lock before main-process config bootstrap.
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
app.setName(APP_NAME);
|
||||
app.setPath('userData', userDataPath);
|
||||
|
||||
|
||||
-34
@@ -461,9 +461,6 @@ import {
|
||||
} from './main/early-single-instance';
|
||||
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
|
||||
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc';
|
||||
import { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
|
||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
|
||||
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||
@@ -2987,14 +2984,6 @@ function openTsukihimeOverlay(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function openSubtitleGenerationOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openSubtitleGenerationModal,
|
||||
'Subtitle generation overlay unavailable.',
|
||||
'Failed to open subtitle generation overlay.',
|
||||
);
|
||||
}
|
||||
|
||||
function openSessionHelpOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openSessionHelpModalRuntime,
|
||||
@@ -5515,7 +5504,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openTsukihime: () => openTsukihimeOverlay(),
|
||||
openSessionHelp: () => openSessionHelpOverlay(),
|
||||
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
|
||||
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
|
||||
openControllerSelect: () => openControllerSelectOverlay(),
|
||||
openControllerDebug: () => openControllerDebugOverlay(),
|
||||
@@ -6640,25 +6628,3 @@ function setOverlayVisible(visible: boolean): void {
|
||||
}
|
||||
|
||||
registerIpcRuntimeHandlers();
|
||||
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
||||
getConfig: () => configService.getConfig().subtitleGeneration,
|
||||
getModelDirectory: () =>
|
||||
path.join(path.dirname(configService.getConfigPath()), 'models', 'whisper'),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
onProgress: (progress) => {
|
||||
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
||||
if (window && !window.isDestroyed())
|
||||
window.webContents.send(IPC_CHANNELS.event.subtitleGenerationProgress, progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
registerSubtitleGenerationIpc({
|
||||
ipc: ipcMain,
|
||||
isAllowedSender: (sender) =>
|
||||
[overlayManager.getMainWindow(), overlayManager.getModalWindow()].some(
|
||||
(window) => window && !window.isDestroyed() && window.webContents === sender,
|
||||
),
|
||||
runtime: subtitleGenerationRuntime,
|
||||
openModal: () => openSubtitleGenerationModal(createOverlayHostedModalOpenDeps()),
|
||||
});
|
||||
app.on('before-quit', () => subtitleGenerationRuntime.cancel());
|
||||
|
||||
@@ -20,7 +20,6 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
|
||||
@@ -24,7 +24,6 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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']);
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
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' }),
|
||||
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('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.' });
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
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,
|
||||
} 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;
|
||||
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(),
|
||||
),
|
||||
},
|
||||
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)}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -76,10 +76,8 @@ import type {
|
||||
MediaTimingReviewWaveformRequest,
|
||||
} from './types';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
import type { SubtitleGenerationProgress } from './shared/subtitle-generation';
|
||||
|
||||
const overlayLayer = resolveOverlayLayerFromArgv(process.argv);
|
||||
const onSubtitleGenerationOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleGenerationOpen);
|
||||
|
||||
type EmptyListener = () => void;
|
||||
type PayloadedListener<T> = (payload: T) => void;
|
||||
@@ -263,28 +261,6 @@ const onSecondarySubtitleModeEvent = createLatestValueIpcListenerWithPayload<Sec
|
||||
);
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
requestSubtitleGenerationOpen: () =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.requestSubtitleGenerationOpen),
|
||||
onSubtitleGenerationOpen,
|
||||
getSubtitleGenerationStatus: () =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleGenerationStatus),
|
||||
selectSubtitleGenerationModel: (model) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.selectSubtitleGenerationModel, model),
|
||||
startSubtitleGeneration: () => ipcRenderer.invoke(IPC_CHANNELS.request.startSubtitleGeneration),
|
||||
downloadSubtitleGenerationModel: () =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.downloadSubtitleGenerationModel),
|
||||
downloadSubtitleGenerationVadModel: () =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.downloadSubtitleGenerationVadModel),
|
||||
setSubtitleGenerationVadEnabled: (enabled) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.setSubtitleGenerationVadEnabled, enabled),
|
||||
cancelSubtitleGeneration: () => ipcRenderer.invoke(IPC_CHANNELS.request.cancelSubtitleGeneration),
|
||||
onSubtitleGenerationProgress: (callback) => {
|
||||
const listener = (_event: IpcRendererEvent, progress: SubtitleGenerationProgress) =>
|
||||
callback(progress);
|
||||
ipcRenderer.on(IPC_CHANNELS.event.subtitleGenerationProgress, listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.event.subtitleGenerationProgress, listener);
|
||||
},
|
||||
getOverlayLayer: () => overlayLayer,
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
onSubtitle: (callback: (data: SubtitleData) => void) => {
|
||||
|
||||
@@ -24,7 +24,12 @@ test('quality gate checkout does not persist GitHub credentials', () => {
|
||||
|
||||
test('quality gate installs Lua and runs the environment suite before coverage', () => {
|
||||
assert.match(qualityGateWorkflow, /name: Install Lua/);
|
||||
assert.match(qualityGateWorkflow, /apt-get install -y lua5\.4/);
|
||||
assert.match(
|
||||
qualityGateWorkflow,
|
||||
/apt_sources=\(-o Dir::Etc::sourcelist=sources\.list\.d\/ubuntu\.sources -o Dir::Etc::sourceparts=-\)/,
|
||||
);
|
||||
assert.match(qualityGateWorkflow, /apt-get\s+"\$\{apt_sources\[@\]\}"\s+update/);
|
||||
assert.match(qualityGateWorkflow, /apt-get\s+"\$\{apt_sources\[@\]\}"\s+install\s+-y\s+lua5\.4/);
|
||||
assert.match(
|
||||
qualityGateWorkflow,
|
||||
/Test suite \(source\)\n\s*run: bun run test:fast\n\s*\n\s*- name: Environment suite\n\s*run: bun run test:env\n\s*\n\s*- name: Coverage suite \(maintained source lane\)/,
|
||||
|
||||
@@ -5,7 +5,6 @@ type ControllerInteractionModalState = {
|
||||
kikuModalOpen: boolean;
|
||||
runtimeOptionsModalOpen: boolean;
|
||||
subsyncModalOpen: boolean;
|
||||
subtitleGenerationModalOpen?: boolean;
|
||||
youtubePickerModalOpen: boolean;
|
||||
sessionHelpModalOpen: boolean;
|
||||
subtitleSidebarModalOpen: boolean;
|
||||
@@ -19,7 +18,6 @@ export function isControllerInteractionBlocked(state: ControllerInteractionModal
|
||||
state.kikuModalOpen ||
|
||||
state.runtimeOptionsModalOpen ||
|
||||
state.subsyncModalOpen ||
|
||||
Boolean(state.subtitleGenerationModalOpen) ||
|
||||
state.youtubePickerModalOpen ||
|
||||
state.sessionHelpModalOpen
|
||||
);
|
||||
|
||||
@@ -91,7 +91,6 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
@@ -1581,28 +1580,6 @@ test('session binding: Ctrl+Alt+S dispatches subsync action locally', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('session binding: Ctrl+Shift+G dispatches subtitle generation with the sidebar closed', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openSubtitleGeneration',
|
||||
originalKey: 'Ctrl+Shift+G',
|
||||
key: { code: 'KeyG', modifiers: ['ctrl', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleGeneration',
|
||||
},
|
||||
]);
|
||||
testGlobals.dispatchKeydown({ key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true });
|
||||
assert.deepEqual(testGlobals.sessionActions, [
|
||||
{ actionId: 'openSubtitleGeneration', payload: undefined },
|
||||
]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('session binding: Ctrl+Shift+J dispatches jimaku action locally', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export function createKeyboardHandlers(
|
||||
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSubtitleGenerationKeydown?: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
||||
@@ -1083,11 +1082,6 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (ctx.state.subtitleGenerationModalOpen) {
|
||||
options.handleSubtitleGenerationKeydown?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.mediaTimingReviewModalOpen) {
|
||||
options.handleMediaTimingReviewKeydown(e);
|
||||
return;
|
||||
|
||||
@@ -634,126 +634,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="subtitleGenerationModal"
|
||||
class="modal hidden"
|
||||
aria-hidden="true"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="subtitleGenerationTitle"
|
||||
>
|
||||
<div class="modal-content subtitle-generation-content">
|
||||
<div class="modal-header">
|
||||
<div id="subtitleGenerationTitle" class="modal-title">Generate Japanese subtitles</div>
|
||||
<button id="subtitleGenerationClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body subtitle-generation-body">
|
||||
<p class="subtitle-generation-intro">
|
||||
Turn the current audio track into timed Japanese subtitles. Audio stays on this
|
||||
device.
|
||||
</p>
|
||||
<div class="subtitle-generation-detail">
|
||||
<span class="subtitle-generation-label">Current media</span>
|
||||
<div id="subtitleGenerationMedia">Checking current media...</div>
|
||||
</div>
|
||||
<div class="subtitle-generation-detail">
|
||||
<span class="subtitle-generation-label">Speech model</span>
|
||||
<div id="subtitleGenerationModel">Checking local models...</div>
|
||||
<div
|
||||
id="subtitleGenerationModelPicker"
|
||||
class="subtitle-generation-model-picker hidden"
|
||||
>
|
||||
<label for="subtitleGenerationModelSelect">Model</label>
|
||||
<select
|
||||
id="subtitleGenerationModelSelect"
|
||||
aria-describedby="subtitleGenerationModelDescription subtitleGenerationModelRecommendation"
|
||||
></select>
|
||||
<p id="subtitleGenerationModelDescription" class="subtitle-generation-hint"></p>
|
||||
<p id="subtitleGenerationModelRecommendation" class="subtitle-generation-hint">
|
||||
Start with small for a balance of accuracy and CPU time. Larger models need more
|
||||
memory; speed depends on your hardware. Sizes shown are downloads.
|
||||
</p>
|
||||
<p class="subtitle-generation-hint">
|
||||
This choice lasts for this SubMiner session. Set the default model in Settings.
|
||||
</p>
|
||||
</div>
|
||||
<p class="subtitle-generation-hint">
|
||||
Use your own model in Settings → Integrations → Japanese Subtitle Generation → Model
|
||||
Path.
|
||||
</p>
|
||||
<button
|
||||
id="subtitleGenerationDownload"
|
||||
class="kiku-cancel-button hidden"
|
||||
type="button"
|
||||
>
|
||||
Download model
|
||||
</button>
|
||||
</div>
|
||||
<div class="subtitle-generation-detail">
|
||||
<label class="subtitle-generation-vad-toggle" for="subtitleGenerationVadEnabled">
|
||||
<input
|
||||
id="subtitleGenerationVadEnabled"
|
||||
type="checkbox"
|
||||
aria-describedby="subtitleGenerationVadHint"
|
||||
/>
|
||||
Focus on spoken dialogue <span class="subtitle-generation-hint">Optional</span>
|
||||
</label>
|
||||
<div id="subtitleGenerationVadModel" class="subtitle-generation-hint"></div>
|
||||
<p id="subtitleGenerationVadHint" class="subtitle-generation-hint">
|
||||
Uses a small Silero model to transcribe separate speech passages. May skip songs,
|
||||
quiet speech, and dialogue under music. Requires whisper.cpp's speech detector.
|
||||
</p>
|
||||
<p class="subtitle-generation-hint">
|
||||
Applies for this session. Set VAD Model Path in Settings to enable it by default.
|
||||
</p>
|
||||
<button
|
||||
id="subtitleGenerationVadDownload"
|
||||
class="kiku-cancel-button hidden"
|
||||
type="button"
|
||||
>
|
||||
Download speech detection model
|
||||
</button>
|
||||
</div>
|
||||
<div id="subtitleGenerationActivity" class="subtitle-generation-activity hidden">
|
||||
<div class="subtitle-generation-progress-heading">
|
||||
<span id="subtitleGenerationStage">Preparing</span>
|
||||
<span id="subtitleGenerationPercent"></span>
|
||||
</div>
|
||||
<progress
|
||||
id="subtitleGenerationProgress"
|
||||
max="100"
|
||||
aria-label="Subtitle generation progress"
|
||||
></progress>
|
||||
</div>
|
||||
<div
|
||||
id="subtitleGenerationStatus"
|
||||
class="runtime-options-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div class="subtitle-generation-actions">
|
||||
<button id="subtitleGenerationRefresh" class="kiku-cancel-button" type="button">
|
||||
Check again
|
||||
</button>
|
||||
<button id="subtitleGenerationCancel" class="kiku-cancel-button hidden" type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
id="subtitleGenerationStart"
|
||||
class="kiku-confirm-button"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
Generate subtitles
|
||||
</button>
|
||||
</div>
|
||||
<p class="subtitle-generation-hint">
|
||||
The generated SRT will be saved locally and loaded into the player. You can close this
|
||||
window while it runs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content subsync-modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -855,13 +735,6 @@
|
||||
<button id="subtitleSidebarClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body subtitle-sidebar-body">
|
||||
<button
|
||||
id="subtitleGenerationOpen"
|
||||
class="kiku-cancel-button subtitle-generation-open"
|
||||
type="button"
|
||||
>
|
||||
Generate Japanese subtitles
|
||||
</button>
|
||||
<div id="subtitleSidebarStatus" class="runtime-options-status"></div>
|
||||
<ul id="subtitleSidebarList" class="subtitle-sidebar-list"></ul>
|
||||
</div>
|
||||
|
||||
@@ -225,8 +225,6 @@ function describeSessionAction(
|
||||
return 'Open jimaku';
|
||||
case 'openTsukihime':
|
||||
return 'Open TsukiHime';
|
||||
case 'openSubtitleGeneration':
|
||||
return 'Generate Japanese subtitles';
|
||||
case 'openYoutubePicker':
|
||||
return 'Open YouTube subtitle picker';
|
||||
case 'openPlaylistBrowser':
|
||||
@@ -268,7 +266,6 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
||||
case 'openJimaku':
|
||||
case 'openTsukihime':
|
||||
case 'openCharacterDictionaryManager':
|
||||
case 'openSubtitleGeneration':
|
||||
case 'openControllerSelect':
|
||||
case 'openControllerDebug':
|
||||
case 'openYoutubePicker':
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
describeGenerationModel,
|
||||
describeGenerationProgress,
|
||||
describeGenerationVad,
|
||||
} from './subtitle-generation-view';
|
||||
|
||||
test('only a missing managed model offers a download', () => {
|
||||
assert.deepEqual(describeGenerationModel({ kind: 'missing', path: '/models/small.bin' }), {
|
||||
ready: false,
|
||||
download: true,
|
||||
text: 'Download a speech model to get started.',
|
||||
});
|
||||
for (const kind of ['managed', 'external'] as const) {
|
||||
const model = describeGenerationModel({ kind, path: '/models/ggml-small.bin' });
|
||||
assert.equal(model.ready, true);
|
||||
assert.equal(model.download, false);
|
||||
}
|
||||
assert.deepEqual(
|
||||
describeGenerationModel({
|
||||
kind: 'invalid',
|
||||
path: '/missing/model.bin',
|
||||
message: 'Configured model does not exist.',
|
||||
}),
|
||||
{
|
||||
ready: false,
|
||||
download: false,
|
||||
text: 'Configured model does not exist.',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('generation progress distinguishes measured work from indeterminate stages', () => {
|
||||
assert.deepEqual(
|
||||
describeGenerationProgress({ stage: 'transcribe', percent: 42.8, message: 'Transcribing' }),
|
||||
{
|
||||
stage: 'Recognizing Japanese speech',
|
||||
percent: 42.8,
|
||||
label: '42%',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(describeGenerationProgress({ stage: 'extract', message: 'Extracting audio' }), {
|
||||
stage: 'Preparing audio',
|
||||
percent: null,
|
||||
label: 'Working...',
|
||||
});
|
||||
assert.equal(
|
||||
describeGenerationProgress({ stage: 'download', percent: 0, message: '' }).label,
|
||||
'0%',
|
||||
);
|
||||
assert.equal(
|
||||
describeGenerationProgress({ stage: 'download', percent: Number.NaN, message: '' }).percent,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
describeGenerationProgress({ stage: 'write', percent: 120, message: '' }).percent,
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
test('optional speech detection only gates generation when selected', () => {
|
||||
const missing = { kind: 'missing', path: '/vad.bin' } as const;
|
||||
assert.equal(describeGenerationVad({ enabled: false, model: missing }).ready, true);
|
||||
assert.equal(describeGenerationVad({ enabled: false, model: missing }).download, false);
|
||||
assert.equal(describeGenerationVad({ enabled: true, model: missing }).ready, false);
|
||||
assert.equal(describeGenerationVad({ enabled: true, model: missing }).download, true);
|
||||
assert.equal(
|
||||
describeGenerationVad({ enabled: true, model: { kind: 'managed', path: '/vad.bin' } }).ready,
|
||||
true,
|
||||
);
|
||||
const invalid = { kind: 'invalid', path: '/vad.bin', message: 'Cannot read model' } as const;
|
||||
assert.equal(describeGenerationVad({ enabled: true, model: invalid }).download, false);
|
||||
assert.equal(describeGenerationVad({ enabled: false, model: invalid }).ready, true);
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import type {
|
||||
SubtitleGenerationModelStatus,
|
||||
SubtitleGenerationProgress,
|
||||
} from '../../shared/subtitle-generation';
|
||||
import type { SubtitleGenerationStatus } from '../../shared/subtitle-generation-ipc';
|
||||
|
||||
export function describeGenerationVad(vad: SubtitleGenerationStatus['vad']) {
|
||||
const model = describeGenerationModel(vad.model);
|
||||
return {
|
||||
ready: !vad.enabled || model.ready,
|
||||
download: vad.enabled && model.download,
|
||||
text: !vad.enabled
|
||||
? 'Optional. Generate from the full audio when unchecked.'
|
||||
: vad.model.kind === 'missing'
|
||||
? 'Download the speech detection model to focus on spoken dialogue.'
|
||||
: vad.model.kind === 'invalid'
|
||||
? vad.model.message
|
||||
: vad.model.kind === 'external'
|
||||
? `Your speech detection model: ${vad.model.path}`
|
||||
: 'Silero speech detection model installed.',
|
||||
};
|
||||
}
|
||||
|
||||
export function describeGenerationModel(model: SubtitleGenerationModelStatus) {
|
||||
switch (model.kind) {
|
||||
case 'external':
|
||||
return { ready: true, download: false, text: `Your model: ${model.path}` };
|
||||
case 'managed':
|
||||
return { ready: true, download: false, text: `SubMiner model: ${model.path}` };
|
||||
case 'missing':
|
||||
return { ready: false, download: true, text: 'Download a speech model to get started.' };
|
||||
case 'invalid':
|
||||
return { ready: false, download: false, text: model.message };
|
||||
default: {
|
||||
const exhaustive: never = model;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STAGE_LABELS = {
|
||||
download: 'Downloading speech model',
|
||||
extract: 'Preparing audio',
|
||||
transcribe: 'Recognizing Japanese speech',
|
||||
write: 'Saving subtitles',
|
||||
} satisfies Record<SubtitleGenerationProgress['stage'], string>;
|
||||
|
||||
export function describeGenerationProgress(progress: SubtitleGenerationProgress | null) {
|
||||
const raw = progress?.percent;
|
||||
const percent =
|
||||
raw !== undefined && Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : null;
|
||||
return {
|
||||
stage: progress ? STAGE_LABELS[progress.stage] : 'Preparing',
|
||||
percent,
|
||||
label: percent === null ? 'Working...' : `${Math.floor(percent)}%`,
|
||||
};
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
|
||||
import {
|
||||
SUBTITLE_GENERATION_MODELS,
|
||||
RECOMMENDED_SUBTITLE_GENERATION_MODEL,
|
||||
formatSubtitleGenerationModelSize,
|
||||
getSubtitleGenerationModel,
|
||||
isSubtitleGenerationModelId,
|
||||
} from '../../shared/subtitle-generation-model-catalog';
|
||||
import type {
|
||||
SubtitleGenerationResult,
|
||||
SubtitleGenerationStatus,
|
||||
} from '../../shared/subtitle-generation-ipc';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
import {
|
||||
describeGenerationModel,
|
||||
describeGenerationProgress,
|
||||
describeGenerationVad,
|
||||
} from './subtitle-generation-view';
|
||||
import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model';
|
||||
|
||||
function element<T extends HTMLElement>(id: string, constructor: new () => T): T {
|
||||
const node = document.getElementById(id);
|
||||
if (!(node instanceof constructor)) throw new Error(`Missing subtitle generation element: ${id}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createSubtitleGenerationModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
const dom = {
|
||||
modal: element('subtitleGenerationModal', HTMLDivElement),
|
||||
close: element('subtitleGenerationClose', HTMLButtonElement),
|
||||
open: element('subtitleGenerationOpen', HTMLButtonElement),
|
||||
media: element('subtitleGenerationMedia', HTMLDivElement),
|
||||
model: element('subtitleGenerationModel', HTMLDivElement),
|
||||
modelPicker: element('subtitleGenerationModelPicker', HTMLDivElement),
|
||||
modelSelect: element('subtitleGenerationModelSelect', HTMLSelectElement),
|
||||
modelDescription: element('subtitleGenerationModelDescription', HTMLParagraphElement),
|
||||
download: element('subtitleGenerationDownload', HTMLButtonElement),
|
||||
vadEnabled: element('subtitleGenerationVadEnabled', HTMLInputElement),
|
||||
vadModel: element('subtitleGenerationVadModel', HTMLDivElement),
|
||||
vadDownload: element('subtitleGenerationVadDownload', HTMLButtonElement),
|
||||
activity: element('subtitleGenerationActivity', HTMLDivElement),
|
||||
stage: element('subtitleGenerationStage', HTMLSpanElement),
|
||||
percent: element('subtitleGenerationPercent', HTMLSpanElement),
|
||||
progress: element('subtitleGenerationProgress', HTMLProgressElement),
|
||||
status: element('subtitleGenerationStatus', HTMLDivElement),
|
||||
refresh: element('subtitleGenerationRefresh', HTMLButtonElement),
|
||||
cancel: element('subtitleGenerationCancel', HTMLButtonElement),
|
||||
start: element('subtitleGenerationStart', HTMLButtonElement),
|
||||
};
|
||||
let snapshot: SubtitleGenerationStatus | null = null;
|
||||
let progress: SubtitleGenerationProgress | null = null;
|
||||
let result: SubtitleGenerationResult | null = null;
|
||||
let pending = false;
|
||||
let cancelling = false;
|
||||
let checking = false;
|
||||
let error: string | null = null;
|
||||
let priorFocus: Element | null = null;
|
||||
let poll: ReturnType<typeof setTimeout> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
for (const model of SUBTITLE_GENERATION_MODELS) {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.id;
|
||||
const recommended = model.id === RECOMMENDED_SUBTITLE_GENERATION_MODEL ? ' (recommended)' : '';
|
||||
option.textContent = `${model.id}${recommended} · ${formatSubtitleGenerationModelSize(model.size)}`;
|
||||
dom.modelSelect.append(option);
|
||||
}
|
||||
|
||||
const focus = createModalFocusGuard({
|
||||
isOpen: () => ctx.state.subtitleGenerationModalOpen,
|
||||
getModalRoot: () => dom.modal,
|
||||
getPreferredFocusTargets: () => [dom.modelSelect, dom.download, dom.start],
|
||||
getFallbackFocusTarget: () => dom.close,
|
||||
isModalLayer: ctx.platform.isModalLayer,
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
const busy = pending || Boolean(snapshot?.running);
|
||||
const model = snapshot ? describeGenerationModel(snapshot.model) : null;
|
||||
const vad = snapshot ? describeGenerationVad(snapshot.vad) : 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.';
|
||||
dom.media.textContent = snapshot?.mediaPath ?? 'Open a local media file in the player first.';
|
||||
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);
|
||||
if (snapshot) {
|
||||
dom.modelSelect.value = snapshot.managedModel;
|
||||
dom.modelDescription.textContent = getSubtitleGenerationModel(
|
||||
snapshot.managedModel,
|
||||
).description;
|
||||
}
|
||||
dom.download.classList.toggle('hidden', !model?.download);
|
||||
dom.download.textContent = snapshot
|
||||
? `Download ${snapshot.managedModel} model`
|
||||
: 'Download model';
|
||||
dom.download.disabled = busy || checking;
|
||||
if (!checking) dom.vadEnabled.checked = snapshot?.vad.enabled ?? false;
|
||||
dom.vadEnabled.disabled = busy || checking || !snapshot;
|
||||
dom.vadModel.textContent = vad?.text ?? 'Checking speech detection...';
|
||||
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.refresh.disabled = busy || checking;
|
||||
dom.cancel.classList.toggle('hidden', !busy);
|
||||
dom.cancel.disabled = cancelling;
|
||||
dom.cancel.textContent = cancelling ? 'Cancelling...' : 'Cancel';
|
||||
dom.activity.classList.toggle('hidden', !busy);
|
||||
const activity = describeGenerationProgress(progress);
|
||||
dom.stage.textContent = activity.stage;
|
||||
dom.percent.textContent = activity.label;
|
||||
if (activity.percent === null) dom.progress.removeAttribute('value');
|
||||
else dom.progress.value = activity.percent;
|
||||
dom.status.classList.toggle('error', Boolean(error || (!busy && result && !result.ok)));
|
||||
dom.status.textContent =
|
||||
error ??
|
||||
(busy
|
||||
? cancelling
|
||||
? 'Stopping the current operation...'
|
||||
: (progress?.message ?? 'Starting...')
|
||||
: (result?.message ?? (checking ? 'Checking local setup...' : readyMessage)));
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (poll !== null) clearTimeout(poll);
|
||||
poll = null;
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (checking) return;
|
||||
checking = true;
|
||||
error = null;
|
||||
render();
|
||||
try {
|
||||
snapshot = await window.electronAPI.getSubtitleGenerationStatus();
|
||||
if (!pending) {
|
||||
progress = snapshot.progress;
|
||||
result = snapshot.lastResult ?? result;
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not check subtitle generation setup.';
|
||||
} finally {
|
||||
checking = false;
|
||||
render();
|
||||
stopPolling();
|
||||
if (ctx.state.subtitleGenerationModalOpen && snapshot?.running && !pending) {
|
||||
poll = setTimeout(() => void refresh(), 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function run(action: 'download' | 'download-vad' | 'generate'): Promise<void> {
|
||||
if (pending || snapshot?.running || checking || !snapshot) return;
|
||||
const model = describeGenerationModel(snapshot.model);
|
||||
const vad = describeGenerationVad(snapshot.vad);
|
||||
if (
|
||||
action === 'download'
|
||||
? !model.download
|
||||
: action === 'download-vad'
|
||||
? !vad.download
|
||||
: !model.ready || !vad.ready || !snapshot.mediaPath
|
||||
)
|
||||
return;
|
||||
pending = true;
|
||||
result = null;
|
||||
error = null;
|
||||
progress = { stage: action === 'generate' ? 'extract' : 'download', message: 'Starting...' };
|
||||
render();
|
||||
try {
|
||||
result = await (action === 'download'
|
||||
? window.electronAPI.downloadSubtitleGenerationModel()
|
||||
: action === 'download-vad'
|
||||
? window.electronAPI.downloadSubtitleGenerationVadModel()
|
||||
: window.electronAPI.startSubtitleGeneration());
|
||||
} catch (cause) {
|
||||
result = {
|
||||
ok: false,
|
||||
message: cause instanceof Error ? cause.message : 'Subtitle generation failed.',
|
||||
};
|
||||
} finally {
|
||||
pending = false;
|
||||
cancelling = false;
|
||||
// Recheck model availability after downloads and recover controls after errors.
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModel(): Promise<void> {
|
||||
const model = dom.modelSelect.value;
|
||||
if (pending || snapshot?.running || checking || !isSubtitleGenerationModelId(model)) return;
|
||||
checking = true;
|
||||
error = null;
|
||||
render();
|
||||
try {
|
||||
snapshot = await window.electronAPI.selectSubtitleGenerationModel(model);
|
||||
result = snapshot.lastResult;
|
||||
progress = snapshot.progress;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not select the model.';
|
||||
} finally {
|
||||
checking = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVad(): Promise<void> {
|
||||
if (pending || snapshot?.running || checking || !snapshot) return;
|
||||
const enabled = dom.vadEnabled.checked;
|
||||
checking = true;
|
||||
error = null;
|
||||
render();
|
||||
try {
|
||||
snapshot = await window.electronAPI.setSubtitleGenerationVadEnabled(enabled);
|
||||
result = snapshot.lastResult;
|
||||
progress = snapshot.progress;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not change speech detection.';
|
||||
} finally {
|
||||
checking = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(): Promise<void> {
|
||||
if (cancelling) return;
|
||||
cancelling = true;
|
||||
render();
|
||||
try {
|
||||
await window.electronAPI.cancelSubtitleGeneration();
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not cancel the operation.';
|
||||
} finally {
|
||||
cancelling = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function open(): void {
|
||||
if (ctx.state.subtitleGenerationModalOpen) return;
|
||||
priorFocus = document.activeElement;
|
||||
ctx.state.subtitleGenerationModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
dom.modal.classList.remove('hidden');
|
||||
dom.modal.setAttribute('aria-hidden', 'false');
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
focus.attach();
|
||||
focus.focusFallbackTarget();
|
||||
window.electronAPI.notifyOverlayModalOpened('subtitle-generation');
|
||||
void refresh();
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (!ctx.state.subtitleGenerationModalOpen) return;
|
||||
ctx.state.subtitleGenerationModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
dom.modal.classList.add('hidden');
|
||||
dom.modal.setAttribute('aria-hidden', 'true');
|
||||
focus.detach();
|
||||
stopPolling();
|
||||
window.electronAPI.notifyOverlayModalClosed('subtitle-generation');
|
||||
if (priorFocus instanceof HTMLElement && priorFocus.isConnected)
|
||||
priorFocus.focus({ preventScroll: true });
|
||||
if (!options.modalStateReader.isAnyModalOpen()) syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (!ctx.state.subtitleGenerationModalOpen) return false;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
dom.close.addEventListener('click', close);
|
||||
dom.open.addEventListener('click', () => {
|
||||
void window.electronAPI
|
||||
.requestSubtitleGenerationOpen()
|
||||
.then((opened) => {
|
||||
if (!opened)
|
||||
ctx.dom.subtitleSidebarStatus.textContent = 'Could not open subtitle generation.';
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
ctx.dom.subtitleSidebarStatus.textContent =
|
||||
cause instanceof Error ? cause.message : 'Could not open subtitle generation.';
|
||||
});
|
||||
});
|
||||
dom.download.addEventListener('click', () => void run('download'));
|
||||
dom.vadDownload.addEventListener('click', () => void run('download-vad'));
|
||||
dom.vadEnabled.addEventListener('change', () => void selectVad());
|
||||
dom.modelSelect.addEventListener('change', () => void selectModel());
|
||||
dom.start.addEventListener('click', () => void run('generate'));
|
||||
dom.cancel.addEventListener('click', () => void cancel());
|
||||
dom.refresh.addEventListener('click', () => void refresh());
|
||||
unsubscribe = window.electronAPI.onSubtitleGenerationProgress((update) => {
|
||||
progress = update;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
stopPolling();
|
||||
focus.detach();
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
}
|
||||
|
||||
return { open, close, handleKeydown, wireDomEvents, dispose };
|
||||
}
|
||||
@@ -113,20 +113,6 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0);
|
||||
});
|
||||
|
||||
test('findActiveSubtitleCueIndex follows playback through empty subtitle gaps', () => {
|
||||
const cues = [
|
||||
{ startTime: 0, endTime: 2, text: 'first' },
|
||||
{ startTime: 100, endTime: 102, text: 'later' },
|
||||
{ startTime: 105, endTime: 107, text: 'next' },
|
||||
];
|
||||
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'later', startTime: 100 }, 101, 1), 1);
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: '', startTime: 0 }, 103, 1), 2);
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'next', startTime: 105 }, 105, 2), 2);
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: '', startTime: 0 }, 108, 2), -1);
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'first', startTime: 0 }, 0, 2), 0);
|
||||
});
|
||||
|
||||
test('subtitle sidebar mining context resolves selected row cue timing', () => {
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
Element?: unknown;
|
||||
|
||||
@@ -120,12 +120,8 @@ export function findActiveSubtitleCueIndex(
|
||||
return -1;
|
||||
}
|
||||
|
||||
// The mpv client maps cleared sub-start to zero. Empty text has no active cue timing.
|
||||
const hasCurrentTiming =
|
||||
current !== null &&
|
||||
normalizeCueText(current.text).length > 0 &&
|
||||
typeof current.startTime === 'number' &&
|
||||
Number.isFinite(current.startTime);
|
||||
typeof current?.startTime === 'number' && Number.isFinite(current.startTime);
|
||||
|
||||
if (hasCurrentTiming) {
|
||||
const timingMatch = cues.findIndex(
|
||||
|
||||
@@ -11,7 +11,6 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||
state.kikuModalOpen ||
|
||||
state.runtimeOptionsModalOpen ||
|
||||
state.subsyncModalOpen ||
|
||||
state.subtitleGenerationModalOpen ||
|
||||
state.sessionHelpModalOpen,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ import { isControllerInteractionBlocked } from './controller-interaction-blockin
|
||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||
import { createSubsyncModal } from './modals/subsync.js';
|
||||
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
|
||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||
import { createMediaTimingReviewModal } from './modals/media-timing-review.js';
|
||||
import { createPositioningController } from './positioning.js';
|
||||
@@ -81,12 +80,6 @@ const ctx = {
|
||||
};
|
||||
|
||||
const modalDescriptors = [
|
||||
{
|
||||
id: 'subtitle-generation',
|
||||
isOpen: () => ctx.state.subtitleGenerationModalOpen,
|
||||
close: () => subtitleGenerationModal.close(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'controller-select',
|
||||
isOpen: () => ctx.state.controllerSelectModalOpen,
|
||||
@@ -220,10 +213,6 @@ const subsyncModal = createSubsyncModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subtitleGenerationModal = createSubtitleGenerationModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const controllerSelectModal = createControllerSelectModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
@@ -294,7 +283,6 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleSubtitleGenerationKeydown: subtitleGenerationModal.handleKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
||||
@@ -547,9 +535,6 @@ const recovery = createRendererRecoveryController({
|
||||
registerRendererGlobalErrorHandlers(window, recovery);
|
||||
|
||||
function registerModalOpenHandlers(): void {
|
||||
window.electronAPI.onSubtitleGenerationOpen(() => {
|
||||
runGuarded('subtitle-generation:open', () => subtitleGenerationModal.open());
|
||||
});
|
||||
window.electronAPI.onOpenRuntimeOptions(() => {
|
||||
runGuarded('runtime-options:open', () => {
|
||||
runtimeOptionsModal.openRuntimeOptionsModal();
|
||||
@@ -850,7 +835,6 @@ async function init(): Promise<void> {
|
||||
kikuModal.wireDomEvents();
|
||||
runtimeOptionsModal.wireDomEvents();
|
||||
subsyncModal.wireDomEvents();
|
||||
subtitleGenerationModal.wireDomEvents();
|
||||
controllerSelectModal.wireDomEvents();
|
||||
controllerDebugModal.wireDomEvents();
|
||||
sessionHelpModal.wireDomEvents();
|
||||
@@ -858,7 +842,6 @@ async function init(): Promise<void> {
|
||||
subtitleSidebarModal.wireDomEvents();
|
||||
characterDictionaryModal.wireDomEvents();
|
||||
window.addEventListener('beforeunload', () => {
|
||||
subtitleGenerationModal.dispose();
|
||||
subtitleSidebarModal.disposeDomEvents();
|
||||
});
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ export type RendererState = {
|
||||
characterDictionaryStatus: string;
|
||||
|
||||
subsyncModalOpen: boolean;
|
||||
subtitleGenerationModalOpen: boolean;
|
||||
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
|
||||
subsyncSubmitting: boolean;
|
||||
|
||||
@@ -220,7 +219,6 @@ export function createRendererState(): RendererState {
|
||||
characterDictionaryStatus: '',
|
||||
|
||||
subsyncModalOpen: false,
|
||||
subtitleGenerationModalOpen: false,
|
||||
subsyncSubtitleTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
|
||||
|
||||
@@ -2976,142 +2976,6 @@ iframe[id^='yomitan-popup'],
|
||||
width: min(560px, 92%);
|
||||
}
|
||||
|
||||
.subtitle-generation-content {
|
||||
width: min(580px, 92%);
|
||||
max-height: 92%;
|
||||
border-top: 3px solid var(--ctp-green);
|
||||
}
|
||||
|
||||
.subtitle-generation-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--ctp-surface1) transparent;
|
||||
}
|
||||
|
||||
.subtitle-generation-body > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subtitle-generation-intro {
|
||||
margin: 0;
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.subtitle-generation-detail {
|
||||
padding: 12px 14px;
|
||||
border-left: 2px solid var(--ctp-surface1);
|
||||
background: rgba(24, 25, 38, 0.35);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.subtitle-generation-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--ctp-green);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subtitle-generation-hint {
|
||||
margin: 8px 0 0;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.subtitle-generation-model-picker {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.subtitle-generation-model-picker label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subtitle-generation-model-picker select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 6px;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.subtitle-generation-model-picker select:focus-visible {
|
||||
outline: 2px solid var(--ctp-green);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.subtitle-generation-model-picker select:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.subtitle-generation-detail button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.subtitle-generation-vad-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subtitle-generation-vad-toggle input {
|
||||
accent-color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.subtitle-generation-vad-toggle .subtitle-generation-hint {
|
||||
margin: 0 0 0 auto;
|
||||
}
|
||||
|
||||
.subtitle-generation-progress-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.subtitle-generation-activity progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
accent-color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.subtitle-generation-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.subtitle-generation-actions button:disabled,
|
||||
.subtitle-generation-detail button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.subtitle-generation-open {
|
||||
align-self: flex-start;
|
||||
margin: 0 16px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-body:has(.subtitle-sidebar-item) .subtitle-generation-open {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subsync-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
|
||||
export const OVERLAY_HOSTED_MODALS = [
|
||||
'runtime-options',
|
||||
'subsync',
|
||||
'subtitle-generation',
|
||||
'jimaku',
|
||||
'tsukihime',
|
||||
'youtube-track-picker',
|
||||
@@ -51,14 +50,6 @@ export const IPC_CHANNELS = {
|
||||
dispatchSessionAction: 'session-action:dispatch',
|
||||
},
|
||||
request: {
|
||||
requestSubtitleGenerationOpen: 'subtitle-generation:open',
|
||||
getSubtitleGenerationStatus: 'subtitle-generation:status',
|
||||
startSubtitleGeneration: 'subtitle-generation:start',
|
||||
selectSubtitleGenerationModel: 'subtitle-generation:select-model',
|
||||
downloadSubtitleGenerationModel: 'subtitle-generation:download',
|
||||
downloadSubtitleGenerationVadModel: 'subtitle-generation:download-vad',
|
||||
setSubtitleGenerationVadEnabled: 'subtitle-generation:set-vad-enabled',
|
||||
cancelSubtitleGeneration: 'subtitle-generation:cancel',
|
||||
getVisibleOverlayVisibility: 'get-visible-overlay-visibility',
|
||||
getCurrentSubtitle: 'get-current-subtitle',
|
||||
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
|
||||
@@ -141,8 +132,6 @@ export const IPC_CHANNELS = {
|
||||
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
||||
},
|
||||
event: {
|
||||
subtitleGenerationOpen: 'subtitle-generation:opened',
|
||||
subtitleGenerationProgress: 'subtitle-generation:progress',
|
||||
subtitleSet: 'subtitle:set',
|
||||
overlayPointerRecoveryRequest: 'overlay:pointer-recovery-request',
|
||||
subtitleVisibility: 'mpv:subVisibility',
|
||||
|
||||
@@ -44,7 +44,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'openControllerDebug',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
'openSubtitleGeneration',
|
||||
'openYoutubePicker',
|
||||
'openPlaylistBrowser',
|
||||
'replayCurrentSubtitle',
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type {
|
||||
SubtitleGenerationConfig,
|
||||
SubtitleGenerationModelStatus,
|
||||
SubtitleGenerationProgress,
|
||||
} from './subtitle-generation';
|
||||
|
||||
export type SubtitleGenerationResult =
|
||||
| { ok: true; message: string; outputPath?: string }
|
||||
| { ok: false; message: string };
|
||||
|
||||
export interface SubtitleGenerationStatus {
|
||||
model: SubtitleGenerationModelStatus;
|
||||
vad: { enabled: boolean; model: SubtitleGenerationModelStatus };
|
||||
managedModel: SubtitleGenerationConfig['managedModel'];
|
||||
externalModelPath: string | null;
|
||||
mediaPath: string | null;
|
||||
running: boolean;
|
||||
progress: SubtitleGenerationProgress | null;
|
||||
lastResult: SubtitleGenerationResult | null;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
// Official multilingual models from whisper.cpp/models/download-ggml-model.sh.
|
||||
// Byte sizes and SHA256: https://huggingface.co/api/models/ggerganov/whisper.cpp/tree/main.
|
||||
const MODEL_CATALOG = {
|
||||
tiny: {
|
||||
id: 'tiny',
|
||||
size: 77691713,
|
||||
sha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21',
|
||||
description: 'Fastest and lightest; more transcription errors.',
|
||||
},
|
||||
'tiny-q5_1': {
|
||||
id: 'tiny-q5_1',
|
||||
size: 32152673,
|
||||
sha256: '818710568da3ca15689e31a743197b520007872ff9576237bda97bd1b469c3d7',
|
||||
description:
|
||||
'tiny with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'tiny-q8_0': {
|
||||
id: 'tiny-q8_0',
|
||||
size: 43537433,
|
||||
sha256: 'c2085835d3f50733e2ff6e4b41ae8a2b8d8110461e18821b09a15c40c42d1cca',
|
||||
description:
|
||||
'tiny with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
base: {
|
||||
id: 'base',
|
||||
size: 147951465,
|
||||
sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe',
|
||||
description: 'Fast and lightweight; less accurate than small.',
|
||||
},
|
||||
'base-q5_1': {
|
||||
id: 'base-q5_1',
|
||||
size: 59707625,
|
||||
sha256: '422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898',
|
||||
description:
|
||||
'base with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'base-q8_0': {
|
||||
id: 'base-q8_0',
|
||||
size: 81768585,
|
||||
sha256: 'c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567cbb7d9',
|
||||
description:
|
||||
'base with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
small: {
|
||||
id: 'small',
|
||||
size: 487601967,
|
||||
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b',
|
||||
description: 'Recommended starting point for accuracy and processing time.',
|
||||
},
|
||||
'small-q5_1': {
|
||||
id: 'small-q5_1',
|
||||
size: 190085487,
|
||||
sha256: 'ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb',
|
||||
description:
|
||||
'small with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'small-q8_0': {
|
||||
id: 'small-q8_0',
|
||||
size: 264464607,
|
||||
sha256: '49c8fb02b65e6049d5fa6c04f81f53b867b5ec9540406812c643f177317f779f',
|
||||
description:
|
||||
'small with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
medium: {
|
||||
id: 'medium',
|
||||
size: 1533763059,
|
||||
sha256: '6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208',
|
||||
description: 'Prioritizes accuracy over small; takes longer to process.',
|
||||
},
|
||||
'medium-q5_0': {
|
||||
id: 'medium-q5_0',
|
||||
size: 539212467,
|
||||
sha256: '19fea4b380c3a618ec4723c3eef2eb785ffba0d0538cf43f8f235e7b3b34220f',
|
||||
description:
|
||||
'medium with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'medium-q8_0': {
|
||||
id: 'medium-q8_0',
|
||||
size: 823369779,
|
||||
sha256: '42a1ffcbe4167d224232443396968db4d02d4e8e87e213d3ee2e03095dea6502',
|
||||
description:
|
||||
'medium with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'large-v1': {
|
||||
id: 'large-v1',
|
||||
size: 3094623691,
|
||||
sha256: '7d99f41a10525d0206bddadd86760181fa920438b6b33237e3118ff6c83bb53d',
|
||||
description: 'Older large model; high memory use and longer processing.',
|
||||
},
|
||||
'large-v2': {
|
||||
id: 'large-v2',
|
||||
size: 3094623691,
|
||||
sha256: '9a423fe4d40c82774b6af34115b8b935f34152246eb19e80e376071d3f999487',
|
||||
description: 'Older large model; high memory use and longer processing.',
|
||||
},
|
||||
'large-v2-q5_0': {
|
||||
id: 'large-v2-q5_0',
|
||||
size: 1080732091,
|
||||
sha256: '3a214837221e4530dbc1fe8d734f302af393eb30bd0ed046042ebf4baf70f6f2',
|
||||
description:
|
||||
'large-v2 with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'large-v2-q8_0': {
|
||||
id: 'large-v2-q8_0',
|
||||
size: 1656129691,
|
||||
sha256: 'fef54e6d898246a65c8285bfa83bd1807e27fadf54d5d4e81754c47634737e8c',
|
||||
description:
|
||||
'large-v2 with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'large-v3': {
|
||||
id: 'large-v3',
|
||||
size: 3095033483,
|
||||
sha256: '64d182b440b98d5203c4f9bd541544d84c605196c4f7b845dfa11fb23594d1e2',
|
||||
description: 'Prioritizes accuracy; high memory use and longer processing.',
|
||||
},
|
||||
'large-v3-q5_0': {
|
||||
id: 'large-v3-q5_0',
|
||||
size: 1081140203,
|
||||
sha256: 'd75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1',
|
||||
description:
|
||||
'large-v3 with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'large-v3-turbo': {
|
||||
id: 'large-v3-turbo',
|
||||
size: 1624555275,
|
||||
sha256: '1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69',
|
||||
description: 'Faster large-v3 variant with an accuracy tradeoff; uses more memory than small.',
|
||||
},
|
||||
'large-v3-turbo-q5_0': {
|
||||
id: 'large-v3-turbo-q5_0',
|
||||
size: 574041195,
|
||||
sha256: '394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2',
|
||||
description:
|
||||
'large-v3-turbo with 5-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
'large-v3-turbo-q8_0': {
|
||||
id: 'large-v3-turbo-q8_0',
|
||||
size: 874188075,
|
||||
sha256: '317eb69c11673c9de1e1f0d459b253999804ec71ac4c23c17ecf5fbe24e259a1',
|
||||
description:
|
||||
'large-v3-turbo with 8-bit quantization: lower memory use, with a possible accuracy tradeoff.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type SubtitleGenerationModelId = keyof typeof MODEL_CATALOG;
|
||||
|
||||
export const SUBTITLE_GENERATION_MODELS = Object.values(MODEL_CATALOG);
|
||||
|
||||
export const RECOMMENDED_SUBTITLE_GENERATION_MODEL = 'small' satisfies SubtitleGenerationModelId;
|
||||
|
||||
export function isSubtitleGenerationModelId(value: unknown): value is SubtitleGenerationModelId {
|
||||
return typeof value === 'string' && Object.hasOwn(MODEL_CATALOG, value);
|
||||
}
|
||||
|
||||
export function getSubtitleGenerationModel(id: SubtitleGenerationModelId) {
|
||||
return MODEL_CATALOG[id];
|
||||
}
|
||||
|
||||
export function formatSubtitleGenerationModelSize(size: number): string {
|
||||
if (size < 1024 ** 2) return `${Math.ceil(size / 1024)} KiB`;
|
||||
return size >= 1024 ** 3
|
||||
? `${(size / 1024 ** 3).toFixed(1)} GiB`
|
||||
: `${Math.round(size / 1024 ** 2)} MiB`;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export const SUBTITLE_GENERATION_VAD_MODEL = {
|
||||
filename: 'ggml-silero-v6.2.0.bin',
|
||||
size: 885098,
|
||||
sha256: '2aa269b785eeb53a82983a20501ddf7c1d9c48e33ab63a41391ac6c9f7fb6987',
|
||||
url: 'https://huggingface.co/ggml-org/whisper-vad/resolve/9ffd54a1e1ee413ddf265af9913beaf518d1639b/ggml-silero-v6.2.0.bin',
|
||||
} as const;
|
||||
@@ -1,86 +0,0 @@
|
||||
import {
|
||||
isSubtitleGenerationModelId,
|
||||
RECOMMENDED_SUBTITLE_GENERATION_MODEL,
|
||||
type SubtitleGenerationModelId,
|
||||
} from './subtitle-generation-model-catalog';
|
||||
|
||||
export interface SubtitleGenerationConfig {
|
||||
whisperPath: string;
|
||||
modelPath: string;
|
||||
managedModel: SubtitleGenerationModelId;
|
||||
threads: number;
|
||||
ffmpegPath: string;
|
||||
ffprobePath: string;
|
||||
vadModelPath: string;
|
||||
vadPath: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SUBTITLE_GENERATION_CONFIG: SubtitleGenerationConfig = {
|
||||
whisperPath: '',
|
||||
modelPath: '',
|
||||
managedModel: RECOMMENDED_SUBTITLE_GENERATION_MODEL,
|
||||
threads: 4,
|
||||
ffmpegPath: '',
|
||||
ffprobePath: '',
|
||||
vadModelPath: '',
|
||||
vadPath: '',
|
||||
};
|
||||
|
||||
export interface SubtitleGenerationProgress {
|
||||
stage: 'download' | 'extract' | 'transcribe' | 'write';
|
||||
percent?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type SubtitleGenerationModelStatus =
|
||||
| { kind: 'external' | 'managed'; path: string }
|
||||
| { kind: 'missing'; path: string }
|
||||
| { kind: 'invalid'; path: string; message: string };
|
||||
|
||||
export function resolveSubtitleGenerationConfig(
|
||||
value: unknown,
|
||||
onWarning?: (key: string, value: unknown, message: string) => void,
|
||||
): SubtitleGenerationConfig {
|
||||
const result = { ...DEFAULT_SUBTITLE_GENERATION_CONFIG };
|
||||
if (value === undefined) return result;
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
onWarning?.('subtitleGeneration', value, 'Expected an object.');
|
||||
return result;
|
||||
}
|
||||
for (const [key, rawValue] of Object.entries(value)) {
|
||||
if (
|
||||
key !== 'whisperPath' &&
|
||||
key !== 'modelPath' &&
|
||||
key !== 'ffmpegPath' &&
|
||||
key !== 'ffprobePath' &&
|
||||
key !== 'vadModelPath' &&
|
||||
key !== 'vadPath'
|
||||
)
|
||||
continue;
|
||||
const candidate: unknown = rawValue;
|
||||
if (typeof candidate === 'string') {
|
||||
result[key] = candidate.trim();
|
||||
} else onWarning?.(key, candidate, 'Expected a string.');
|
||||
}
|
||||
if ('managedModel' in value) {
|
||||
if (isSubtitleGenerationModelId(value.managedModel)) {
|
||||
result.managedModel = value.managedModel;
|
||||
} else
|
||||
onWarning?.(
|
||||
'managedModel',
|
||||
value.managedModel,
|
||||
'Expected a supported multilingual Whisper model.',
|
||||
);
|
||||
}
|
||||
if ('threads' in value) {
|
||||
if (
|
||||
typeof value.threads === 'number' &&
|
||||
Number.isInteger(value.threads) &&
|
||||
value.threads >= 1 &&
|
||||
value.threads <= 256
|
||||
) {
|
||||
result.threads = value.threads;
|
||||
} else onWarning?.('threads', value.threads, 'Expected an integer between 1 and 256.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AnkiConnectConfig, WordCardKind } from './anki';
|
||||
import type { SubtitleGenerationConfig } from '../shared/subtitle-generation';
|
||||
import type {
|
||||
AiConfig,
|
||||
AiFeatureConfig,
|
||||
@@ -125,7 +124,6 @@ export interface ShortcutsConfig {
|
||||
openRuntimeOptions?: string | null;
|
||||
openJimaku?: string | null;
|
||||
openTsukihime?: string | null;
|
||||
openSubtitleGeneration?: string | null;
|
||||
openSessionHelp?: string | null;
|
||||
openControllerSelect?: string | null;
|
||||
openControllerDebug?: string | null;
|
||||
@@ -151,7 +149,6 @@ export interface Config {
|
||||
shortcuts?: RawShortcutsConfig;
|
||||
secondarySub?: SecondarySubConfig;
|
||||
subsync?: SubsyncConfig;
|
||||
subtitleGeneration?: Partial<SubtitleGenerationConfig>;
|
||||
startupWarmups?: StartupWarmupsConfig;
|
||||
subtitleStyle?: SubtitleStyleConfig;
|
||||
subtitleSidebar?: SubtitleSidebarConfig;
|
||||
@@ -301,7 +298,6 @@ export interface ResolvedConfig {
|
||||
shortcuts: Required<ShortcutsConfig>;
|
||||
secondarySub: Required<SecondarySubConfig>;
|
||||
subsync: Required<SubsyncConfig>;
|
||||
subtitleGeneration: SubtitleGenerationConfig;
|
||||
startupWarmups: {
|
||||
lowPowerMode: boolean;
|
||||
mecab: boolean;
|
||||
|
||||
@@ -11,12 +11,6 @@ import type {
|
||||
MediaTimingReviewWaveformResult,
|
||||
} from './anki';
|
||||
import type { ChangelogSnapshot } from './changelog';
|
||||
import type { SubtitleGenerationProgress } from '../shared/subtitle-generation';
|
||||
import type { SubtitleGenerationModelId } from '../shared/subtitle-generation-model-catalog';
|
||||
import type {
|
||||
SubtitleGenerationResult,
|
||||
SubtitleGenerationStatus,
|
||||
} from '../shared/subtitle-generation-ipc';
|
||||
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
||||
import type {
|
||||
CompiledSessionBinding,
|
||||
@@ -436,20 +430,6 @@ export interface SessionNumericSelectionStartPayload {
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
requestSubtitleGenerationOpen: () => Promise<boolean>;
|
||||
onSubtitleGenerationOpen: (callback: () => void) => void;
|
||||
getSubtitleGenerationStatus: () => Promise<SubtitleGenerationStatus>;
|
||||
selectSubtitleGenerationModel: (
|
||||
model: SubtitleGenerationModelId,
|
||||
) => Promise<SubtitleGenerationStatus>;
|
||||
startSubtitleGeneration: () => Promise<SubtitleGenerationResult>;
|
||||
downloadSubtitleGenerationModel: () => Promise<SubtitleGenerationResult>;
|
||||
downloadSubtitleGenerationVadModel: () => Promise<SubtitleGenerationResult>;
|
||||
setSubtitleGenerationVadEnabled: (enabled: boolean) => Promise<SubtitleGenerationStatus>;
|
||||
cancelSubtitleGeneration: () => Promise<void>;
|
||||
onSubtitleGenerationProgress: (
|
||||
callback: (progress: SubtitleGenerationProgress) => void,
|
||||
) => () => void;
|
||||
getOverlayLayer: () => 'visible' | 'modal' | null;
|
||||
getPathForFile: (file: File) => string;
|
||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||
@@ -597,7 +577,6 @@ export interface ElectronAPI {
|
||||
modal:
|
||||
| 'runtime-options'
|
||||
| 'subsync'
|
||||
| 'subtitle-generation'
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
| 'youtube-track-picker'
|
||||
@@ -615,7 +594,6 @@ export interface ElectronAPI {
|
||||
modal:
|
||||
| 'runtime-options'
|
||||
| 'subsync'
|
||||
| 'subtitle-generation'
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
| 'youtube-track-picker'
|
||||
|
||||
@@ -23,7 +23,6 @@ export type SessionActionId =
|
||||
| 'openControllerDebug'
|
||||
| 'openJimaku'
|
||||
| 'openTsukihime'
|
||||
| 'openSubtitleGeneration'
|
||||
| 'openYoutubePicker'
|
||||
| 'openPlaylistBrowser'
|
||||
| 'replayCurrentSubtitle'
|
||||
|
||||
Reference in New Issue
Block a user