mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 04:49:49 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
beeab564b4
|
|||
|
cab7975a2b
|
|||
|
846292809c
|
|||
| 8712780d08 | |||
| 7b1a005a65 | |||
|
84c75f50aa
|
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## v0.18.0 (2026-07-10)
|
||||
|
||||
### Added
|
||||
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
|
||||
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
|
||||
|
||||
### Changed
|
||||
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
|
||||
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
|
||||
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
|
||||
- App Icon: Replaced the app icon with new pixel-art submarine artwork contributed by the community, used across the app icon, tray, notifications, README, docs site, and Stats page.
|
||||
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
|
||||
|
||||
### Fixed
|
||||
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
|
||||
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
|
||||
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
|
||||
- Cover Art Fetching: Stats now fetches AniList cover art as soon as a new series starts playing, and backfills missing art for existing series on the next Stats visit.
|
||||
- Kiku Field Grouping: The manual field-grouping dialog now stays above fullscreen mpv, remains usable across repeated attempts, closes abandoned windows after timeouts, and reports a clear error when the original card can no longer be loaded.
|
||||
- Karaoke Subtitle Collapse: Secondary subtitles no longer stack dozens of one-syllable lines during openings and endings; repeated events collapse into one line, capped to a strip at the top.
|
||||
- Unparsed Token Hover: Subtitle text Yomitan can't parse (truncated inflections, elongation runs) remains hoverable, while staying excluded from highlighting, N+1 candidate math, and vocabulary stats.
|
||||
- YouTube Streaming: Fixed direct YouTube stream extraction that could corrupt signed URLs and cause ffmpeg 403 errors.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.17.2 (2026-06-28)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: mining
|
||||
|
||||
- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- `subminer app` background launches now start the stats server automatically when `stats.autoStartServer` is enabled, and skip startup when a background stats server is already running.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed character-dictionary surname/given-name splitting for unspaced AniList native names where romanized reading length misled the boundary guess (東紫乃 split as 東紫+乃, 渡辺真奈美 as 渡辺真+奈美), so surnames like 東 and 渡辺 were never highlighted with character portraits and unmatched leading kanji (渡) lost hover lookup entirely. Name parts are now resolved with MeCab person-name POS tags and reading validation when MeCab is available; without MeCab, terms are generated for both plausible boundaries so the real surname still matches. Existing snapshots regenerate automatically (format version bump), and snapshots built without MeCab are upgraded to exact MeCab splits once MeCab is detected.
|
||||
- Fixed subtitle tokenization dropping a character entirely when Yomitan's greedy scan consumed trailing punctuation/whitespace past the term (e.g. 平 in 「(東)平 (平)え?」 matched across the paren with no exact-source headword). The scanner now retries shorter windows so the prefix term still tokenizes with its name match and portrait.
|
||||
- Fixed character-name highlights being stripped when MeCab's context-dependent POS tag put the token in the annotation noise filter (e.g. 平 tagged as prefix 接頭詞 in 「あっ 平 これ…」 lost its highlight and portrait even though hover lookup showed the character). Confirmed name matches now survive the POS exclusion filter, and name matching alone keeps the annotation stage active when all other annotations are disabled.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Removed 確かに and やはり from the annotation stop-word list so they get frequency/JLPT highlighting again and count toward vocabulary stats; the list now only covers interjections, pronouns, and grammar fragments.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Cover art is now fetched eagerly when a new series starts playing, instead of waiting for the first visit to its series detail page, so the stats timeline shows the best-guess AniList image right away. The stats covers endpoint also backfills missing series art in the background, so existing series without an image pick one up on the next stats page load.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: launcher
|
||||
|
||||
- Moved fzf previews below launcher menus so long titles and metadata have more horizontal room.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed character names being swallowed by longer generic dictionary matches during subtitle tokenization (e.g. ヨータ in 美姫とヨータ was lost because とヨー normalizes to とよう and matched 渡洋), so the name never tokenized and got no highlight, portrait, or hover lookup. Character-dictionary matches now claim their spans in a greedy pre-pass before the left-to-right scan, and re-segmented name spans override the parse tokenization on merge. A name only claims its span when no strictly longer generic word starts at the same position, so a character named 空 no longer splits 空気 (ties still go to the name). The pre-pass runs only when a SubMiner character dictionary is enabled in the active Yomitan profile.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Show cover art icons in the rofi watch-history picker, reusing AniList covers already stored in the stats database (extracted to `~/.cache/subminer/covers`).
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Kanji-bearing nouns that MeCab tags as non-independent (非自立) — e.g. 日 in いい日だったな, 点, 以外 — now keep frequency/JLPT highlighting and count toward vocabulary stats. Yomitan segments them as standalone vocabulary tokens, so the MeCab POS filter only suppresses kana grammar nouns (こと, もの, とき) it was meant for.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed cancelling the Kiku field grouping dialog showing two "Field grouping cancelled" notifications when grouping was started via the trigger shortcut: the manual workflow already notifies about its outcome (cancelled, UI unavailable, failed), and the trigger path re-notified on top of it. The workflow now owns all outcome notifications, and a previously silent failure (the original card no longer loadable) gets its own message.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed Kiku manual field grouping freezing the overlay after adding a duplicate card: the field grouping modal now reliably appears above fullscreen mpv on Hyprland/Wayland by re-asserting window placement until the compositor maps the modal window, instead of a single post-show attempt that raced the async map and left the dialog invisible.
|
||||
- Fixed manual field grouping staying broken after the first attempt: the request resolver is now always cleared once a choice is made or the request is abandoned, so later grouping attempts no longer short-circuit to an instant "Field grouping cancelled".
|
||||
- Fixed a timed-out or failed field grouping request leaving an orphaned, invisible modal window covering mpv: abandoned requests now tear down the modal window and close the dialog so the overlay recovers immediately.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- New reading-aware subtitle parsing and known-word matching: the known-word cache now stores each Anki card's word together with its reading (cache format v3), and a token only gets the known-word highlight when its parsed reading agrees with the card. The cache and the stats server upgrade automatically.
|
||||
- Fixes words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" no longer matches a known 床/ゆか "floor" card). Cards without a reading field keep matching in any reading as before.
|
||||
- Single-kana grammar tokens (よ in 全然いいよ, standalone え) no longer borrow the reading of an unrelated card (such as 夜 or 絵) and get painted as known: reading-only matching requires at least two kana, while single-kana cards still match by their word field.
|
||||
- Kanji words no longer count as known just because a different mined kanji word shares their reading (渓谷/けいこく no longer matches a known 警告/けいこく card). Words mined in kana form still highlight their kanji spelling in subtitles, and kana subtitle text still matches cards mined in kanji form.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Added `subminer -H` / `--history` to browse local watch history, replay the last watched episode, continue to the next episode, or browse episodes with fzf/rofi.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed character-name annotations dropping for an entire subtitle line when it contained any chunk the dictionary scanner could not match (e.g. an interjection like やほっ before a name): scanner metadata is now merged per token into the parseText segmentation instead of being discarded on any span mismatch.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- Standalone suffix tokens (MeCab pos2 接尾, e.g. さん, れる) are now excluded from JLPT/frequency/N+1 annotations by default, matching how particles and interjections are treated. Cache-backed known-word highlighting still applies; override via the pos2 exclusion config if you want them annotated.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: branding
|
||||
|
||||
- Replaced the SubMiner app icon with new pixel-art submarine artwork contributed by an anonymous community member, used across the app icon, tray, notifications, README, docs site, and stats page.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed secondary subtitles stacking dozens of one-syllable lines down the screen during karaoke-typeset openings/endings, which made the hover-pause band cover the whole video: karaoke-like event spam is now collapsed into a single deduped line, and the secondary subtitle area is height-capped so it always stays a strip at the top.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Show all trend chart titles by default, persist hidden-title choices, and add a per-chart top-title limit selector.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: internal
|
||||
area: testing
|
||||
|
||||
- Test lanes are now defined once in `scripts/test-lanes.ts` and discovered by directory instead of hand-maintained file lists in `package.json`; the unused `test:core:*`/`test:config:dist`/`test:full` scripts were removed.
|
||||
- `scripts/run-test-lane.mjs` runs each test file in an isolated `bun test` process with a wall timeout, so a hanging test or leaked global can no longer cascade failures across the lane.
|
||||
- Previously orphaned suites now run in CI: the stats dashboard tests (`bun run test:stats`), the `scripts/**` tests (`bun run test:scripts`, including the change-verification skill tests), the `test-plugin-process-start-retries.lua` plugin test, and the runtime-compat dist slice (now part of `bun run test:fast`).
|
||||
- The change-verification skill gained a `stats` lane for `stats/` edits.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed subtitle text that Yomitan's parser cannot match (truncated inflections like the volitional in とこ戻ろ…, elongation runs like ぅ~/ぉ〜) being rendered as plain, non-interactive text with no hover lookup. Unparsed runs are now kept as hoverable tokens matching Yomitan's own segmentation, while being fully ignored by frequency/JLPT highlighting, the N+1 candidate math, and vocabulary stats. Bracketed SFX/speaker captions and punctuation-only runs are still skipped.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- Fixed direct YouTube stream media extraction by parsing mpv EDL stream URLs with their byte-length guards, preventing trailing EDL segment options from corrupting signed googlevideo URLs and causing ffmpeg 403 errors.
|
||||
@@ -515,7 +515,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||
// Most other AnkiConnect settings still require restart.
|
||||
// ==========================================
|
||||
@@ -559,7 +559,8 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
|
||||
@@ -162,13 +162,16 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"normalizeAudio": true, // normalize generated clip loudness
|
||||
"mirrorMpvVolume": true, // apply the current mpv volume level
|
||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||
"maxMediaDuration": 30 // cap total duration in seconds
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness.
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness. Changing this setting applies to the next extraction without restarting SubMiner.
|
||||
|
||||
`mirrorMpvVolume` is also enabled by default. Immediately before extracting each playback-overlay card's audio, SubMiner reads mpv's numeric `volume` and applies mpv's cubic software-volume curve after loudness normalization. For example, mpv volume `50` produces `0.5³ = 0.125` gain. Amplified output above mpv volume `100` is limited to a `-1 dBFS` ceiling before MP3 encoding to prevent clipping. It ignores mpv's separate `mute` state. If the volume property is missing, invalid, or unavailable, extraction continues with unity scaling; disabling this option skips the query and volume filter. Changing this setting applies to the next extraction without restarting SubMiner. YouTube cards queued for a background media-cache download retain the volume captured when the card was mined. Stats-dashboard mining does not currently have access to the active mpv property client, so it does not apply mpv volume scaling.
|
||||
|
||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||
|
||||
@@ -349,6 +352,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"imageFormat": "jpg",
|
||||
"imageQuality": 92,
|
||||
"normalizeAudio": true,
|
||||
"mirrorMpvVolume": true,
|
||||
"audioPadding": 0,
|
||||
"maxMediaDuration": 30,
|
||||
},
|
||||
|
||||
+40
-4
@@ -1,12 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
## v0.17.2 (2026-06-28)
|
||||
## v0.18.0 (2026-07-10)
|
||||
|
||||
**Added**
|
||||
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
|
||||
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
|
||||
|
||||
**Changed**
|
||||
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
|
||||
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
|
||||
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
|
||||
- App Icon: Replaced the app icon with new pixel-art submarine artwork contributed by the community, used across the app icon, tray, notifications, README, docs site, and Stats page.
|
||||
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
|
||||
|
||||
**Fixed**
|
||||
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
|
||||
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
|
||||
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
|
||||
- Cover Art Fetching: Stats now fetches AniList cover art as soon as a new series starts playing, and backfills missing art for existing series on the next Stats visit.
|
||||
- Kiku Field Grouping: The manual field-grouping dialog now stays above fullscreen mpv, remains usable across repeated attempts, closes abandoned windows after timeouts, and reports a clear error when the original card can no longer be loaded.
|
||||
- Karaoke Subtitle Collapse: Secondary subtitles no longer stack dozens of one-syllable lines during openings and endings; repeated events collapse into one line, capped to a strip at the top.
|
||||
- Unparsed Token Hover: Subtitle text Yomitan can't parse (truncated inflections, elongation runs) remains hoverable, while staying excluded from highlighting, N+1 candidate math, and vocabulary stats.
|
||||
- YouTube Streaming: Fixed direct YouTube stream extraction that could corrupt signed URLs and cause ffmpeg 403 errors.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
|
||||
<details>
|
||||
<summary>v0.17.x</summary>
|
||||
|
||||
<h2>v0.17.2 (2026-06-28)</h2>
|
||||
|
||||
**Fixed**
|
||||
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
|
||||
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
|
||||
|
||||
## v0.17.1 (2026-06-27)
|
||||
<h2>v0.17.1 (2026-06-27)</h2>
|
||||
|
||||
**Added**
|
||||
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
|
||||
@@ -15,7 +51,7 @@
|
||||
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
|
||||
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
|
||||
|
||||
## v0.17.0 (2026-06-15)
|
||||
<h2>v0.17.0 (2026-06-15)</h2>
|
||||
|
||||
**Changed**
|
||||
|
||||
@@ -50,7 +86,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.16.x</summary>
|
||||
|
||||
@@ -952,6 +952,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"animatedMaxHeight": 0,
|
||||
"animatedCrf": 35,
|
||||
"normalizeAudio": true,
|
||||
"mirrorMpvVolume": true,
|
||||
"audioPadding": 0,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30
|
||||
@@ -1002,7 +1003,8 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. |
|
||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
|
||||
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
|
||||
@@ -515,7 +515,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||
// Most other AnkiConnect settings still require restart.
|
||||
// ==========================================
|
||||
@@ -559,7 +559,8 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
|
||||
@@ -90,7 +90,7 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
|
||||
When `sourcePath` is omitted, SubMiner searches default install/runtime locations for `frequency-dictionary` directories automatically.
|
||||
|
||||
::: info
|
||||
Frequency highlighting skips tokens that look like non-lexical noise (kana reduplication, short kana endings like `っ`), even when dictionary ranks exist.
|
||||
Frequency highlighting skips tokens that look like non-lexical noise (kana reduplication, short kana endings like `っ`), even when dictionary ranks exist. For merged kana tokens, SubMiner keeps a rank when the dictionary headword reading covers the full token (for example, `かと言って` / `かといって`), while grammar wrapped around a shorter lemma remains unannotated.
|
||||
:::
|
||||
|
||||
::: info
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<!-- read_when: changing managed mpv startup, pause-until-ready, or visible overlay boot ordering -->
|
||||
|
||||
# Early Managed Overlay Startup Design
|
||||
|
||||
Status: approved
|
||||
Date: 2026-06-06
|
||||
|
||||
## Problem
|
||||
|
||||
Managed mpv startup can pause playback immediately, then leave SubMiner's tray and visible overlay
|
||||
unavailable until Yomitan/tokenization warmups finish. Startup notifications therefore miss the
|
||||
overlay surface and fall back to non-overlay status paths.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
For cold `--start --background --managed-playback` launches, handle initial args before waiting for
|
||||
the deferred overlay warmup. That lets the tray and visible overlay shell initialize immediately
|
||||
while the existing tokenization warmups continue in the background.
|
||||
|
||||
The mpv plugin pause gate stays armed. Playback release still waits for SubMiner's autoplay-ready
|
||||
signal, which is emitted only after tokenization warmup and visible-overlay readiness. Existing
|
||||
second-instance attach behavior remains unchanged: when the launcher finds an already-running
|
||||
background app, it sends the same control command to that process and reuses its warmups/tokenizer.
|
||||
|
||||
## Checks
|
||||
|
||||
- Add a startup ordering regression test for managed background playback.
|
||||
- Keep the existing deferred startup ordering for non-managed launches.
|
||||
- Run the startup/runtime test slice plus SubMiner verification lane.
|
||||
@@ -1,27 +0,0 @@
|
||||
<!-- read_when: changing overlay notification hover, macOS mouse passthrough, or notification actions -->
|
||||
|
||||
# macOS Notification Hover Stability Design
|
||||
|
||||
Status: approved
|
||||
Date: 2026-06-09
|
||||
|
||||
## Problem
|
||||
|
||||
On macOS, hovering a character dictionary build notification can make the card flicker and slide as
|
||||
if it is hiding, then snap back. The likely trigger is the notification stack changing the overlay
|
||||
window's mouse-passthrough state for a progress card that has no user action.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Keep non-action overlay notifications visually stable and click-through on hover. Only notifications
|
||||
with explicit actions should request interactive overlay input. The notification history panel keeps
|
||||
its existing interactive behavior.
|
||||
|
||||
This avoids a macOS mouseenter/mouseleave passthrough loop for passive progress cards while
|
||||
preserving clickable notification actions.
|
||||
|
||||
## Checks
|
||||
|
||||
- Add a renderer regression test for passive notification hover.
|
||||
- Keep action-bearing notification cards interactive.
|
||||
- Run the targeted overlay notification and mouse-ignore tests.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.18.0-beta.3",
|
||||
"version": "0.18.0",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
- **Sentence Audio Normalization**
|
||||
- Generated sentence audio is now normalized to -23 LUFS by default, giving mined clips consistent volume across shows.
|
||||
- Clips captured from playback can also mirror mpv's software volume curve, with a limiter to prevent clipping when boosted.
|
||||
- Both behaviors are controlled independently and can be turned off in the Anki Connect media settings.
|
||||
|
||||
- **Watch History Browser**
|
||||
- Added `subminer -H` / `--history` to browse local watch history, replay the last episode, continue to the next one, or jump to any past episode.
|
||||
- Works with fzf or rofi; the rofi picker shows AniList cover art already stored in the stats database.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Known-Word Highlighting Accuracy**
|
||||
- Highlighting now compares subtitle and Anki-card readings, so it no longer confuses homographs like 床/とこ vs 床/ゆか or unrelated kanji that happen to share a reading.
|
||||
- Standalone suffix words such as さん or れる are now excluded from JLPT/frequency/N+1 annotations by default, matching how particles and interjections are already treated (configurable if you'd rather keep them annotated).
|
||||
- Cards without readings still fall back to word-only matching; the highlighting cache upgrades automatically with no action needed.
|
||||
|
||||
- **Stats Trend Charts**
|
||||
- Overhauled trend charts with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and sortable multi-column tooltips.
|
||||
|
||||
- **New App Icon**
|
||||
- Replaced the SubMiner icon with new pixel-art submarine artwork contributed by an anonymous community member, now used across the app icon, tray, notifications, README, docs site, and stats page.
|
||||
|
||||
- **Launcher Preview Layout**
|
||||
- fzf previews now sit below the launcher menu instead of beside it, giving long titles and metadata more horizontal room.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Character Name Recognition**
|
||||
- Character dictionaries now correctly split unspaced AniList native names and validate readings, so overlay portraits, highlights, and hover lookups work reliably even without MeCab installed.
|
||||
- Name matches survive punctuation and unmatched text and no longer get overridden by generic dictionary matches or wrongly split from longer words like 空気.
|
||||
- Existing installs regenerate automatically and upgrade to exact splits once MeCab is available — no action needed.
|
||||
|
||||
- **Frequency & JLPT Highlighting Coverage**
|
||||
- Content adverbs (確かに, やはり) and kanji nouns MeCab tags as non-independent (日, 点, 以外) are now correctly included in frequency/JLPT highlighting and vocabulary stats.
|
||||
- Lexicalized kana expressions like かといって keep their frequency annotations, while interjections, pronouns, and pure grammar fragments are still filtered out as noise.
|
||||
|
||||
- **Unparsed Text Hover Lookup**
|
||||
- Subtitle text Yomitan can't fully parse — truncated inflections like とこ戻ろ… or elongation runs like ぅ~ — is hoverable again for dictionary lookup.
|
||||
- These runs stay excluded from frequency/JLPT highlighting and vocabulary stats, same as bracketed captions and punctuation-only text.
|
||||
|
||||
- **Karaoke-Style Secondary Subtitles**
|
||||
- Secondary subtitles no longer flood the screen with dozens of one-syllable lines during karaoke-style openings and endings.
|
||||
- Repeated events now collapse into a single line, and the secondary subtitle area stays capped to a strip at the top.
|
||||
|
||||
- **Kiku Field Grouping Reliability**
|
||||
- The manual field-grouping dialog now stays above fullscreen mpv on Hyprland/Wayland and keeps working across repeated attempts.
|
||||
- Abandoned grouping windows close automatically after a timeout or failure, and each attempt now reports a clear success or error, including when the original card can no longer be loaded.
|
||||
|
||||
- **Background Stats Server Startup**
|
||||
- Background `subminer app` launches now start the stats server automatically when enabled, and skip startup if one is already running.
|
||||
|
||||
- **AniList Cover Art Timing**
|
||||
- Stats now fetches the best-match AniList cover as soon as a new series starts playing, so artwork appears in the timeline immediately instead of only after visiting the series page.
|
||||
- Existing series missing art get backfilled automatically on the next Stats page visit.
|
||||
|
||||
- **YouTube Direct Stream Playback**
|
||||
- Fixed direct YouTube stream extraction so mpv's EDL stream URLs are parsed correctly, preventing corrupted signed video URLs and the resulting ffmpeg 403 errors.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(youtube): parse mpv EDL stream URLs with byte-length guards by @ksyasuda in #134
|
||||
- Normalize generated Anki audio by default by @ksyasuda in #135
|
||||
- feat(launcher): add -H/--history command to browse local watch history by @ksyasuda in #136
|
||||
- fix(overlay): prevent field grouping modal from freezing overlay on Hyprland by @ksyasuda in #138
|
||||
- fix(overlay): collapse karaoke syllable spam in secondary subtitles by @ksyasuda in #139
|
||||
- feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips by @ksyasuda in #140
|
||||
- feat(branding): replace app icon with contributed pixel-art set by @ksyasuda in #141
|
||||
- feat(anki): reading-aware known-word matching (cache v3) by @ksyasuda in #142
|
||||
- fix(stats): start stats server on background app launch by @ksyasuda in #144
|
||||
- fix(tokenizer): keep unparsed Yomitan tokens hoverable by @ksyasuda in #145
|
||||
- fix(overlay): resolve unspaced Japanese name splits and scan recovery by @ksyasuda in #146
|
||||
- fix(tokenizer): prevent grammar tokens from borrowing known-word highlight via unrelated readings by @ksyasuda in #147
|
||||
- fix(stats): fetch cover art eagerly at session start instead of on series page visit by @ksyasuda in #148
|
||||
|
||||
## 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`.
|
||||
@@ -1213,6 +1213,102 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
|
||||
}
|
||||
});
|
||||
|
||||
test('writeChangelogArtifacts skips contributor attribution in GitHub Actions without a token', async () => {
|
||||
const { writeChangelogArtifacts } = await loadModule();
|
||||
const workspace = createWorkspace('release-notes-actions-no-token');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
const originalActions = process.env.GITHUB_ACTIONS;
|
||||
const originalGhToken = process.env.GH_TOKEN;
|
||||
const originalGithubToken = process.env.GITHUB_TOKEN;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalWarn = console.warn;
|
||||
const warnings: string[] = [];
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '001.md'),
|
||||
['type: added', 'area: release', '', '- Added a feature.'].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.GITHUB_ACTIONS = 'true';
|
||||
delete process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
process.env.PATH = workspace;
|
||||
console.warn = (message?: unknown) => {
|
||||
warnings.push(String(message));
|
||||
};
|
||||
|
||||
writeChangelogArtifacts({
|
||||
cwd: projectRoot,
|
||||
version: '0.6.0',
|
||||
date: '2026-05-06',
|
||||
deps: { runClaude: defaultStubClaude().runClaude },
|
||||
});
|
||||
|
||||
assert.deepEqual(warnings, []);
|
||||
const releaseNotes = fs.readFileSync(
|
||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(releaseNotes, /## What's Changed/);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
if (originalActions === undefined) {
|
||||
delete process.env.GITHUB_ACTIONS;
|
||||
} else {
|
||||
process.env.GITHUB_ACTIONS = originalActions;
|
||||
}
|
||||
if (originalGhToken === undefined) {
|
||||
delete process.env.GH_TOKEN;
|
||||
} else {
|
||||
process.env.GH_TOKEN = originalGhToken;
|
||||
}
|
||||
if (originalGithubToken === undefined) {
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.GITHUB_TOKEN = originalGithubToken;
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('shouldSkipDefaultContributionLookup skips GitHub Actions without a gh token', async () => {
|
||||
const { shouldSkipDefaultContributionLookup } = await loadModule();
|
||||
|
||||
assert.equal(
|
||||
shouldSkipDefaultContributionLookup({
|
||||
GITHUB_ACTIONS: 'true',
|
||||
GH_TOKEN: undefined,
|
||||
GITHUB_TOKEN: undefined,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSkipDefaultContributionLookup({
|
||||
GITHUB_ACTIONS: 'true',
|
||||
GH_TOKEN: 'ghs_test',
|
||||
GITHUB_TOKEN: undefined,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSkipDefaultContributionLookup({
|
||||
GITHUB_ACTIONS: undefined,
|
||||
GH_TOKEN: undefined,
|
||||
GITHUB_TOKEN: undefined,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('writeReleaseNotesForVersion preserves committed contributor attribution before installation', async () => {
|
||||
const { writeReleaseNotesForVersion } = await loadModule();
|
||||
const workspace = createWorkspace('release-notes-preserve-attribution');
|
||||
|
||||
@@ -345,6 +345,12 @@ function resolveFragmentRelativePath(fragmentPath: string, cwd: string): string
|
||||
return path.relative(cwd, fragmentPath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
export function shouldSkipDefaultContributionLookup(
|
||||
env: Partial<Record<'GITHUB_ACTIONS' | 'GH_TOKEN' | 'GITHUB_TOKEN', string>> = process.env,
|
||||
): boolean {
|
||||
return env.GITHUB_ACTIONS === 'true' && !env.GH_TOKEN && !env.GITHUB_TOKEN;
|
||||
}
|
||||
|
||||
// Walks git history + the GitHub API to attribute each released fragment to the
|
||||
// PR (and author) that introduced it. One git call and one gh call per fragment,
|
||||
// plus one gh call per unique author for the first-contribution check. Best
|
||||
@@ -354,6 +360,9 @@ function defaultResolveContributions(fragmentPaths: string[], cwd: string): Cont
|
||||
if (fragmentPaths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (shouldSkipDefaultContributionLookup()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const slug = execFileSync(
|
||||
|
||||
@@ -573,6 +573,8 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
volumeScale?: number,
|
||||
) => Promise<Buffer>;
|
||||
generateScreenshot: (path: MediaInput) => Promise<Buffer>;
|
||||
};
|
||||
@@ -587,6 +589,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
||||
imageFieldName?: string;
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
volumeScale?: number;
|
||||
}) => void;
|
||||
};
|
||||
internals.client = {
|
||||
@@ -606,9 +609,17 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
||||
},
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateAudio: async (mediaPath, _startTime, _endTime, _audioPadding, audioStreamIndex) => {
|
||||
generateAudio: async (
|
||||
mediaPath,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
mediaInputs.push(
|
||||
`audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}`,
|
||||
`audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}:${volumeScale}`,
|
||||
);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
@@ -628,6 +639,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
||||
imageFieldName: 'Picture',
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
volumeScale: 0.25,
|
||||
});
|
||||
internals.queuePendingYoutubeMediaUpdate({
|
||||
sourceUrl: 'https://youtu.be/abc123',
|
||||
@@ -640,14 +652,15 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
||||
imageFieldName: 'Picture',
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
volumeScale: 0.8,
|
||||
});
|
||||
|
||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.deepEqual(mediaInputs, [
|
||||
'audio:/tmp/media.mkv:youtube-cache:auto',
|
||||
'audio:/tmp/media.mkv:youtube-cache:auto:0.25',
|
||||
'image:/tmp/media.mkv:youtube-cache',
|
||||
'audio:/tmp/media.mkv:youtube-cache:auto',
|
||||
'audio:/tmp/media.mkv:youtube-cache:auto:0.8',
|
||||
'image:/tmp/media.mkv:youtube-cache',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
@@ -771,6 +784,8 @@ test('AnkiIntegration reports partial queued YouTube media updates separately fr
|
||||
test('AnkiIntegration queues YouTube media updates against recovered source URLs', async () => {
|
||||
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const storedMedia: string[] = [];
|
||||
const audioVolumeScales: Array<number | undefined> = [];
|
||||
let mpvVolume = 30;
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
@@ -787,6 +802,10 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
requestProperty: async (name: string) => {
|
||||
assert.equal(name, 'volume');
|
||||
return mpvVolume;
|
||||
},
|
||||
} as never,
|
||||
() => undefined,
|
||||
undefined,
|
||||
@@ -807,7 +826,15 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
||||
storeMediaFile: (filename: string) => Promise<void>;
|
||||
};
|
||||
mediaGenerator: {
|
||||
generateAudio: () => Promise<Buffer>;
|
||||
generateAudio: (
|
||||
path: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
volumeScale?: number,
|
||||
) => Promise<Buffer>;
|
||||
generateScreenshot: () => Promise<Buffer>;
|
||||
};
|
||||
queuePendingYoutubeMediaUpdateForNote: (job: {
|
||||
@@ -834,7 +861,18 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
||||
},
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateAudio: async (
|
||||
_path,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
_audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioVolumeScales.push(volumeScale);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => Buffer.from('image'),
|
||||
};
|
||||
internals.showNotification = async () => undefined;
|
||||
@@ -850,6 +888,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
||||
},
|
||||
label: 'resolved source',
|
||||
});
|
||||
mpvVolume = 90;
|
||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.equal(queued, true);
|
||||
@@ -858,6 +897,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
||||
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
|
||||
});
|
||||
|
||||
test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
|
||||
@@ -865,7 +905,9 @@ test('AnkiIntegration passes audio normalization config for ready cached YouTube
|
||||
path: string;
|
||||
audioStreamIndex?: number;
|
||||
normalizeAudio?: boolean;
|
||||
volumeScale?: number;
|
||||
}> = [];
|
||||
const requestedProperties: string[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
@@ -881,6 +923,10 @@ test('AnkiIntegration passes audio normalization config for ready cached YouTube
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
requestProperty: async (name: string) => {
|
||||
requestedProperties.push(name);
|
||||
return 55;
|
||||
},
|
||||
} as never,
|
||||
() => undefined,
|
||||
undefined,
|
||||
@@ -902,6 +948,7 @@ test('AnkiIntegration passes audio normalization config for ready cached YouTube
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
volumeScale?: number,
|
||||
) => Promise<Buffer>;
|
||||
};
|
||||
generateAudio: () => Promise<Buffer | null>;
|
||||
@@ -914,19 +961,22 @@ test('AnkiIntegration passes audio normalization config for ready cached YouTube
|
||||
_audioPadding,
|
||||
audioStreamIndex,
|
||||
normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio });
|
||||
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio, volumeScale });
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
};
|
||||
|
||||
await internals.generateAudio();
|
||||
|
||||
assert.deepEqual(requestedProperties, ['volume']);
|
||||
assert.deepEqual(audioCalls, [
|
||||
{
|
||||
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
audioStreamIndex: undefined,
|
||||
normalizeAudio: false,
|
||||
volumeScale: 0.55 ** 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
+29
-4
@@ -73,6 +73,7 @@ import type {
|
||||
PendingYoutubeMediaQueueFailedOptions,
|
||||
PendingYoutubeMediaQueueReadyOptions,
|
||||
} from './anki-integration/pending-youtube-media-queue';
|
||||
import { resolveMpvVolumeScale } from './anki-integration/mpv-volume';
|
||||
|
||||
const log = createLogger('anki').child('integration');
|
||||
|
||||
@@ -341,14 +342,23 @@ export class AnkiIntegration {
|
||||
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) =>
|
||||
generateAudio: (
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
normalizeAudio,
|
||||
volumeScale,
|
||||
) =>
|
||||
this.mediaGenerator.generateAudio(
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
normalizeAudio,
|
||||
volumeScale,
|
||||
),
|
||||
generateScreenshot: (videoPath, timestamp, options) =>
|
||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||
@@ -373,6 +383,7 @@ export class AnkiIntegration {
|
||||
mergeFieldValue: (existing, newValue, overwrite) =>
|
||||
this.mergeFieldValue(existing, newValue, overwrite),
|
||||
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
|
||||
getMpvVolumeScale: () => this.getMpvVolumeScale(),
|
||||
generateAudioFilename: () => this.generateAudioFilename(),
|
||||
generateImageFilename: () => this.generateImageFilename(),
|
||||
formatMiscInfoPatternForMediaPath: (
|
||||
@@ -496,14 +507,23 @@ export class AnkiIntegration {
|
||||
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) =>
|
||||
generateAudio: (
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
normalizeAudio,
|
||||
volumeScale,
|
||||
) =>
|
||||
this.mediaGenerator.generateAudio(
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
normalizeAudio,
|
||||
volumeScale,
|
||||
),
|
||||
generateScreenshot: (videoPath, timestamp, options) =>
|
||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||
@@ -965,6 +985,10 @@ export class AnkiIntegration {
|
||||
);
|
||||
}
|
||||
|
||||
private async getMpvVolumeScale(): Promise<number | undefined> {
|
||||
return resolveMpvVolumeScale(this.mpvClient, this.config.media?.mirrorMpvVolume !== false);
|
||||
}
|
||||
|
||||
async handleYoutubeMediaCacheReady(
|
||||
sourceUrl: string,
|
||||
cachedPath: string,
|
||||
@@ -1003,6 +1027,7 @@ export class AnkiIntegration {
|
||||
this.config.media?.audioPadding,
|
||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
await this.getMpvVolumeScale(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
const addedFields: Record<string, string>[] = [];
|
||||
const updatedFields: Record<string, string>[] = [];
|
||||
const storedMedia: string[] = [];
|
||||
const requestedProperties: string[] = [];
|
||||
const audioVolumeScales: Array<number | undefined> = [];
|
||||
|
||||
const deps: CardCreationDeps = {
|
||||
getConfig: () =>
|
||||
@@ -24,6 +26,7 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
mirrorMpvVolume: true,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {},
|
||||
@@ -39,6 +42,10 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
currentSubEnd: 14,
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
requestProperty: async (name: string) => {
|
||||
requestedProperties.push(name);
|
||||
return 40;
|
||||
},
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async (_deck, _modelName, fields) => {
|
||||
@@ -68,7 +75,18 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateAudio: async (
|
||||
_path,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
_audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioVolumeScales.push(volumeScale);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
@@ -124,6 +142,8 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
Expression: '字幕',
|
||||
});
|
||||
assert.equal(storedMedia.length, 1);
|
||||
assert.deepEqual(requestedProperties, ['volume']);
|
||||
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
|
||||
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||
|
||||
@@ -525,6 +525,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
||||
miscInfoFieldName?: string;
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
volumeScale?: number;
|
||||
}> = [];
|
||||
let streamRequests = 0;
|
||||
|
||||
@@ -556,7 +557,10 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
currentAudioStreamIndex: 2,
|
||||
requestProperty: async () => {
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'volume') {
|
||||
return 35;
|
||||
}
|
||||
streamRequests += 1;
|
||||
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
|
||||
},
|
||||
@@ -652,6 +656,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
||||
miscInfoFieldName: 'MiscInfo',
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
volumeScale: 0.35 ** 3,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(updates, []);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './media-source';
|
||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||
|
||||
const log = createLogger('anki').child('integration.card-creation');
|
||||
|
||||
@@ -66,6 +67,7 @@ interface CardCreationMediaGenerator {
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
volumeScale?: number,
|
||||
): Promise<Buffer | null>;
|
||||
generateScreenshot(
|
||||
path: MediaInput,
|
||||
@@ -714,6 +716,12 @@ export class CardCreationService {
|
||||
|
||||
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
||||
if (shouldQueuePendingYoutubeMedia) {
|
||||
const volumeScale = generateAudio
|
||||
? await resolveMpvVolumeScale(
|
||||
mpvClient,
|
||||
this.deps.getConfig().media?.mirrorMpvVolume !== false,
|
||||
)
|
||||
: undefined;
|
||||
this.deps.queuePendingYoutubeMediaUpdate?.({
|
||||
sourceUrl:
|
||||
trimToNonEmptyString(await this.deps.getYoutubeMediaSourceUrl?.()) ??
|
||||
@@ -727,6 +735,7 @@ export class CardCreationService {
|
||||
miscInfoFieldName: resolvedMiscInfoField ?? undefined,
|
||||
generateAudio,
|
||||
generateImage,
|
||||
volumeScale,
|
||||
});
|
||||
await this.deps.showNotification(noteId, label, 'media queued');
|
||||
return true;
|
||||
@@ -844,6 +853,10 @@ export class CardCreationService {
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
),
|
||||
this.deps.getConfig().media?.normalizeAudio !== false,
|
||||
await resolveMpvVolumeScale(
|
||||
mpvClient,
|
||||
this.deps.getConfig().media?.mirrorMpvVolume !== false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||
|
||||
test('resolveMpvVolumeScale converts numeric mpv volume with mpv software volume curve', async () => {
|
||||
const requested: string[] = [];
|
||||
|
||||
const scale = await resolveMpvVolumeScale(
|
||||
{
|
||||
requestProperty: async (name) => {
|
||||
requested.push(name);
|
||||
return 75;
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(scale, 0.421875);
|
||||
assert.deepEqual(requested, ['volume']);
|
||||
});
|
||||
|
||||
test('resolveMpvVolumeScale skips mpv when mirroring is disabled', async () => {
|
||||
let requested = false;
|
||||
|
||||
const scale = await resolveMpvVolumeScale(
|
||||
{
|
||||
requestProperty: async () => {
|
||||
requested = true;
|
||||
return 50;
|
||||
},
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(scale, undefined);
|
||||
assert.equal(requested, false);
|
||||
});
|
||||
|
||||
test('resolveMpvVolumeScale falls back to unity for missing, failed, or invalid values', async () => {
|
||||
assert.equal(await resolveMpvVolumeScale({}, true), 1);
|
||||
assert.equal(
|
||||
await resolveMpvVolumeScale(
|
||||
{
|
||||
requestProperty: async () => {
|
||||
throw new Error('disconnected');
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
1,
|
||||
);
|
||||
assert.equal(await resolveMpvVolumeScale({ requestProperty: async () => '50' }, true), 1);
|
||||
assert.equal(await resolveMpvVolumeScale({ requestProperty: async () => Number.NaN }, true), 1);
|
||||
assert.equal(await resolveMpvVolumeScale({ requestProperty: async () => -1 }, true), 1);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface MpvVolumePropertySource {
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function resolveMpvVolumeScale(
|
||||
mpvClient: MpvVolumePropertySource,
|
||||
enabled: boolean,
|
||||
): Promise<number | undefined> {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!mpvClient.requestProperty) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const volume = await mpvClient.requestProperty('volume');
|
||||
if (typeof volume !== 'number' || !Number.isFinite(volume) || volume < 0) {
|
||||
return 1;
|
||||
}
|
||||
return (volume / 100) ** 3;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ function createDeps(
|
||||
resolveConfiguredFieldName: () => 'Picture',
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
getMpvVolumeScale: async () => 1,
|
||||
generateAudioFilename: () => 'audio.mp3',
|
||||
generateImageFilename: () => 'image.webp',
|
||||
formatMiscInfoPatternForMediaPath: () => '',
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface PendingYoutubeMediaQueueDeps {
|
||||
) => string | null;
|
||||
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
|
||||
getAnimatedImageLeadInSeconds: (noteInfo: PendingYoutubeMediaNoteInfo) => Promise<number>;
|
||||
getMpvVolumeScale: () => Promise<number | undefined>;
|
||||
generateAudioFilename: () => string;
|
||||
generateImageFilename: () => string;
|
||||
formatMiscInfoPatternForMediaPath: (
|
||||
@@ -126,6 +127,9 @@ export class PendingYoutubeMediaQueue {
|
||||
|
||||
const config = this.deps.getConfig();
|
||||
const mediaRange = this.deps.getSubtitleMediaRange(job.context);
|
||||
const volumeScale = shouldGenerateAudio(config)
|
||||
? await this.deps.getMpvVolumeScale()
|
||||
: undefined;
|
||||
this.enqueue({
|
||||
sourceUrl,
|
||||
noteId: job.noteId,
|
||||
@@ -143,6 +147,7 @@ export class PendingYoutubeMediaQueue {
|
||||
this.deps.resolveConfiguredFieldName(job.noteInfo, config.fields?.miscInfo) ?? undefined,
|
||||
generateAudio: shouldGenerateAudio(config),
|
||||
generateImage: shouldGenerateImage(config),
|
||||
volumeScale,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -273,6 +278,7 @@ export class PendingYoutubeMediaQueue {
|
||||
config.media?.audioPadding,
|
||||
undefined,
|
||||
config.media?.normalizeAudio !== false,
|
||||
job.volumeScale,
|
||||
);
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface PendingYoutubeMediaUpdate {
|
||||
miscInfoFieldName?: string;
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
volumeScale?: number;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
|
||||
@@ -93,6 +93,7 @@ test('loads defaults when config is missing', () => {
|
||||
systemPrompt: '',
|
||||
});
|
||||
assert.equal(config.ankiConnect.media.normalizeAudio, true);
|
||||
assert.equal(config.ankiConnect.media.mirrorMpvVolume, true);
|
||||
assert.equal(config.startupWarmups.lowPowerMode, false);
|
||||
assert.equal(config.startupWarmups.mecab, true);
|
||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||
@@ -168,6 +169,31 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
|
||||
});
|
||||
|
||||
test('rejects invalid mpv volume mirroring values', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"mirrorMpvVolume": "false"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
|
||||
assert.equal(
|
||||
service.getConfig().ankiConnect.media.mirrorMpvVolume,
|
||||
DEFAULT_CONFIG.ankiConnect.media.mirrorMpvVolume,
|
||||
);
|
||||
assert.ok(
|
||||
service.getWarnings().some((warning) => warning.path === 'ankiConnect.media.mirrorMpvVolume'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses updates config and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -52,6 +52,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
animatedCrf: 35,
|
||||
syncAnimatedImageToWordAudio: true,
|
||||
normalizeAudio: true,
|
||||
mirrorMpvVolume: true,
|
||||
audioPadding: 0,
|
||||
fallbackDuration: 3.0,
|
||||
maxMediaDuration: 30,
|
||||
|
||||
@@ -111,6 +111,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
'ankiConnect.enabled',
|
||||
'subtitleStyle.nameMatchEnabled',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'mpv.executablePath',
|
||||
'mpv.launchMode',
|
||||
|
||||
@@ -185,7 +185,15 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
path: 'ankiConnect.media.normalizeAudio',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.normalizeAudio,
|
||||
description: 'Normalize generated sentence audio loudness during media extraction.',
|
||||
description:
|
||||
'Normalize generated sentence audio loudness during media extraction. Changes apply live.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.mirrorMpvVolume',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.mirrorMpvVolume,
|
||||
description:
|
||||
"Apply mpv's current software volume curve to generated sentence audio. Changes apply live.",
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateImage',
|
||||
|
||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'AnkiConnect Integration',
|
||||
description: ['Automatic Anki updates and media generation options.'],
|
||||
notes: [
|
||||
'Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
|
||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
|
||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||
'Most other AnkiConnect settings still require restart.',
|
||||
],
|
||||
|
||||
@@ -106,6 +106,22 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
},
|
||||
};
|
||||
|
||||
if (hasOwn(media, 'mirrorMpvVolume')) {
|
||||
const parsed = asBoolean(media.mirrorMpvVolume);
|
||||
if (parsed === undefined) {
|
||||
context.resolved.ankiConnect.media.mirrorMpvVolume =
|
||||
DEFAULT_CONFIG.ankiConnect.media.mirrorMpvVolume;
|
||||
context.warn(
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
media.mirrorMpvVolume,
|
||||
context.resolved.ankiConnect.media.mirrorMpvVolume,
|
||||
'Expected boolean.',
|
||||
);
|
||||
} else {
|
||||
context.resolved.ankiConnect.media.mirrorMpvVolume = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOwn(behavior, 'notificationType')) {
|
||||
const parsed = asNotificationType(behavior.notificationType);
|
||||
if (parsed === undefined) {
|
||||
|
||||
@@ -165,6 +165,15 @@ test('settings registry exposes specialized controls for config-assisted inputs'
|
||||
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
||||
});
|
||||
|
||||
test('settings registry exposes mpv volume mirroring as a mining toggle', () => {
|
||||
const volumeField = field('ankiConnect.media.mirrorMpvVolume');
|
||||
|
||||
assert.equal(volumeField.category, 'mining-anki');
|
||||
assert.equal(volumeField.section, 'Media Capture');
|
||||
assert.equal(volumeField.control, 'boolean');
|
||||
assert.equal(volumeField.defaultValue, true);
|
||||
});
|
||||
|
||||
test('settings registry exposes YouTube media cache mode as a labeled select', () => {
|
||||
const mediaCacheMode = field('youtube.mediaCache.mode');
|
||||
const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight');
|
||||
@@ -313,6 +322,8 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
|
||||
'subsync.replace',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||
|
||||
@@ -236,6 +236,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||
};
|
||||
|
||||
@@ -671,6 +672,8 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'secondarySub.defaultMode' ||
|
||||
path === 'ankiConnect.deck' ||
|
||||
path === 'ankiConnect.ai.enabled' ||
|
||||
path === 'ankiConnect.media.normalizeAudio' ||
|
||||
path === 'ankiConnect.media.mirrorMpvVolume' ||
|
||||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
||||
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
||||
path === 'ankiConnect.knownWords.refreshMinutes' ||
|
||||
|
||||
@@ -31,6 +31,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
||||
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
|
||||
next.subsync.replace = !prev.subsync.replace;
|
||||
next.ankiConnect.deck = 'Mining';
|
||||
next.ankiConnect.media.normalizeAudio = !prev.ankiConnect.media.normalizeAudio;
|
||||
next.ankiConnect.media.mirrorMpvVolume = !prev.ankiConnect.media.mirrorMpvVolume;
|
||||
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
|
||||
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
|
||||
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
|
||||
@@ -65,6 +67,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
||||
'jimaku.maxEntryResults',
|
||||
'subsync.replace',
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
|
||||
@@ -68,6 +68,8 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'jimaku',
|
||||
'subsync',
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
|
||||
@@ -1713,7 +1713,9 @@ test('annotateTokens excludes kana-only composite function/content tokens from f
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'になれば',
|
||||
reading: 'になれば',
|
||||
headword: 'なる',
|
||||
headwordReading: 'なる',
|
||||
pos1: '助詞|動詞',
|
||||
pos2: '格助詞|自立|接続助詞',
|
||||
startPos: 0,
|
||||
@@ -1730,6 +1732,28 @@ test('annotateTokens excludes kana-only composite function/content tokens from f
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens keeps frequency for mixed kana tokens whose headword reading covers the token', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'かといって',
|
||||
reading: 'カトイッテ',
|
||||
headword: 'かと言って',
|
||||
headwordReading: 'かといって',
|
||||
pos1: '助詞|助詞|動詞|助詞',
|
||||
pos2: '副助詞|格助詞|自立|接続助詞',
|
||||
startPos: 0,
|
||||
endPos: 5,
|
||||
frequencyRank: 4898,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps(), {
|
||||
minSentenceWordsForNPlusOne: 1,
|
||||
});
|
||||
|
||||
assert.equal(result[0]?.frequencyRank, 4898);
|
||||
});
|
||||
|
||||
test('annotateTokens excludes composite tokens when all component pos tags are excluded', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
|
||||
@@ -550,10 +550,20 @@ function isKanaOnlyMixedFunctionContentToken(
|
||||
}
|
||||
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1));
|
||||
return (
|
||||
const hasMixedFunctionContentParts =
|
||||
pos1Parts.length >= 2 &&
|
||||
pos1Parts.some((part) => pos1Exclusions.has(part)) &&
|
||||
pos1Parts.some((part) => !pos1Exclusions.has(part))
|
||||
pos1Parts.some((part) => !pos1Exclusions.has(part));
|
||||
if (!hasMixedFunctionContentParts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedReading = normalizeJlptTextForExclusion(token.reading);
|
||||
const normalizedHeadwordReading = normalizeJlptTextForExclusion(token.headwordReading ?? '');
|
||||
return (
|
||||
!normalizedReading ||
|
||||
!normalizedHeadwordReading ||
|
||||
normalizedReading !== normalizedHeadwordReading
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,42 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
||||
assert.ok(calls.includes('broadcast:config:hot-reload'));
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler applies only changed Anki media options', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.ankiConnect.media.normalizeAudio = false;
|
||||
config.ankiConnect.media.mirrorMpvVolume = false;
|
||||
const ankiPatches: unknown[] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => undefined,
|
||||
setSessionBindings: () => undefined,
|
||||
refreshGlobalAndOverlayShortcuts: () => undefined,
|
||||
setSecondarySubMode: () => undefined,
|
||||
broadcastToOverlayWindows: () => undefined,
|
||||
applyAnkiRuntimeConfigPatch: (patch) => ankiPatches.push(patch),
|
||||
});
|
||||
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: ['ankiConnect.media.normalizeAudio'],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: ['ankiConnect.media.mirrorMpvVolume'],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(ankiPatches, [
|
||||
{ media: { normalizeAudio: false } },
|
||||
{ media: { mirrorMpvVolume: false } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildConfigHotReloadPayload includes independent primary subtitle mode', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.subtitleStyle.primaryDefaultMode = 'hover';
|
||||
|
||||
@@ -93,6 +93,16 @@ function buildAnkiRuntimeConfigPatch(
|
||||
if (diff.hotReloadFields.includes('ankiConnect.deck')) {
|
||||
patch.deck = config.ankiConnect.deck;
|
||||
}
|
||||
const mediaPatch: NonNullable<AnkiConnectConfig['media']> = {};
|
||||
if (diff.hotReloadFields.includes('ankiConnect.media.normalizeAudio')) {
|
||||
mediaPatch.normalizeAudio = config.ankiConnect.media.normalizeAudio;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.media.mirrorMpvVolume')) {
|
||||
mediaPatch.mirrorMpvVolume = config.ankiConnect.media.mirrorMpvVolume;
|
||||
}
|
||||
if (Object.keys(mediaPatch).length > 0) {
|
||||
patch.media = mediaPatch;
|
||||
}
|
||||
if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) {
|
||||
patch.knownWords = config.ankiConnect.knownWords;
|
||||
}
|
||||
|
||||
@@ -181,6 +181,54 @@ test('generateAudio can preserve raw sentence audio loudness', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio applies mpv volume after loudness normalization', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12, 0, null, true, 0.42);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-af') + 1], 'loudnorm=I=-23:TP=-2:LRA=11,volume=0.42');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio limits amplified mpv volume after applying gain', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12, 0, null, true, 2);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(
|
||||
args[args.indexOf('-af') + 1],
|
||||
'loudnorm=I=-23:TP=-2:LRA=11,volume=2,alimiter=limit=0.891251:level=false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio applies mpv volume without loudness normalization', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12, 0, null, false, 0.75);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-af') + 1], 'volume=0.75');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio omits no-op mpv volume filters', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12, 0, null, false, 1);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args.includes('-af'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio preserves a zero numeric mpv volume', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12, 0, null, false, 0);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-af') + 1], 'volume=0');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio clips leading padding without adding it to trailing duration', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5);
|
||||
|
||||
+18
-1
@@ -25,6 +25,7 @@ import { normalizeMediaInput, type MediaInput } from './media-input';
|
||||
|
||||
const log = createLogger('media');
|
||||
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-23:TP=-2:LRA=11';
|
||||
const AUDIO_AMPLIFICATION_LIMITER_FILTER = 'alimiter=limit=0.891251:level=false';
|
||||
|
||||
export type { MediaInput, MediaInputOptions } from './media-input';
|
||||
|
||||
@@ -266,6 +267,7 @@ export class MediaGenerator {
|
||||
padding: number = 0,
|
||||
audioStreamIndex: number | null = null,
|
||||
normalizeAudio = true,
|
||||
volumeScale?: number,
|
||||
): Promise<Buffer> {
|
||||
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||
const start = Math.max(0, startTime - safePadding);
|
||||
@@ -296,8 +298,23 @@ export class MediaGenerator {
|
||||
}
|
||||
|
||||
args.push('-vn');
|
||||
const audioFilters: string[] = [];
|
||||
if (normalizeAudio) {
|
||||
args.push('-af', AUDIO_NORMALIZATION_FILTER);
|
||||
audioFilters.push(AUDIO_NORMALIZATION_FILTER);
|
||||
}
|
||||
if (
|
||||
typeof volumeScale === 'number' &&
|
||||
Number.isFinite(volumeScale) &&
|
||||
volumeScale >= 0 &&
|
||||
volumeScale !== 1
|
||||
) {
|
||||
audioFilters.push(`volume=${volumeScale}`);
|
||||
if (volumeScale > 1) {
|
||||
audioFilters.push(AUDIO_AMPLIFICATION_LIMITER_FILTER);
|
||||
}
|
||||
}
|
||||
if (audioFilters.length > 0) {
|
||||
args.push('-af', audioFilters.join(','));
|
||||
}
|
||||
args.push('-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface AnkiConnectConfig {
|
||||
animatedCrf?: number;
|
||||
syncAnimatedImageToWordAudio?: boolean;
|
||||
normalizeAudio?: boolean;
|
||||
mirrorMpvVolume?: boolean;
|
||||
audioPadding?: number;
|
||||
fallbackDuration?: number;
|
||||
maxMediaDuration?: number;
|
||||
|
||||
@@ -236,6 +236,7 @@ export interface ResolvedConfig {
|
||||
animatedCrf: number;
|
||||
syncAnimatedImageToWordAudio: boolean;
|
||||
normalizeAudio: boolean;
|
||||
mirrorMpvVolume: boolean;
|
||||
audioPadding: number;
|
||||
fallbackDuration: number;
|
||||
maxMediaDuration: number;
|
||||
|
||||
Reference in New Issue
Block a user