From 05425cc5db4bdfc38b38b25041ef43fd1b2f2e16 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 20 Sep 2026 22:07:30 -0700 Subject: [PATCH] fix(anki): separate word audio mapping for animation sync (#256) --- changes/fix-animated-word-audio-sync.md | 4 +++ changes/word-audio-mapping-docs.md | 4 +++ config.example.jsonc | 3 +- docs-site/anki-integration.md | 5 ++- docs-site/configuration.md | 3 +- docs-site/public/config.example.jsonc | 3 +- .../animated-image-sync.test.ts | 35 +++++++++++++++++-- src/anki-integration/animated-image-sync.ts | 4 +-- .../definitions/defaults-integrations.ts | 1 + .../definitions/options-integrations.ts | 7 ++++ src/config/definitions/template-sections.ts | 2 +- src/config/hot-reload.ts | 1 + src/config/resolve/anki-connect.test.ts | 22 ++++++++++++ .../resolve/anki-connect/modern-fields.ts | 10 +++++- src/config/settings/registry.test.ts | 1 + src/types/anki.ts | 1 + src/types/config.ts | 1 + 17 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 changes/fix-animated-word-audio-sync.md create mode 100644 changes/word-audio-mapping-docs.md diff --git a/changes/fix-animated-word-audio-sync.md b/changes/fix-animated-word-audio-sync.md new file mode 100644 index 00000000..f4508cc4 --- /dev/null +++ b/changes/fix-animated-word-audio-sync.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Added `ankiConnect.fields.wordAudio` to read word audio separately from the generated sentence-audio destination, fixing animated images that start moving immediately when `fields.audio` points to `SentenceAudio`. diff --git a/changes/word-audio-mapping-docs.md b/changes/word-audio-mapping-docs.md new file mode 100644 index 00000000..cea4e453 --- /dev/null +++ b/changes/word-audio-mapping-docs.md @@ -0,0 +1,4 @@ +type: docs +area: anki + +- Documented the separate word-audio mapping for animated-image synchronization and that existing images need regeneration to pick up the corrected freeze. diff --git a/config.example.jsonc b/config.example.jsonc index 1119873c..6eed9b71 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -541,7 +541,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. + // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind 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. // ========================================== @@ -562,6 +562,7 @@ "fields": { "word": "Expression", // Card field for the mined word or expression text. "audio": "ExpressionAudio", // Card field that receives generated sentence audio. + "wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization. "image": "Picture", // Card field that receives the captured screenshot or animated image. "sentence": "Sentence", // Card field that receives the source sentence text. "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 235dd2d6..113bb314 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -125,6 +125,7 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn "fields": { "word": "Expression", // mined word / expression text "audio": "SentenceAudio", // sentence audio clip cut from the video + "wordAudio": "ExpressionAudio", // existing Yomitan word audio, read for animation sync "image": "Picture", // screenshot or animated clip "sentence": "Sentence", // subtitle text "miscInfo": "MiscInfo" // metadata (filename, timestamp) @@ -136,6 +137,8 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error. +`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults. + These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names. Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `
` newline). @@ -241,7 +244,7 @@ SubMiner can produce an animated AVIF spanning the subtitle duration instead of } ``` -Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing word-audio duration, so the motion starts together with the sentence audio. +Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing audio duration in `fields.wordAudio`, so the motion starts together with the sentence audio. The freeze is baked into the image when mined; changing the mapping does not repair previously generated images. ### Behavior options diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 879d3f77..12321710 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -975,7 +975,8 @@ This example is intentionally compact. The option table below documents availabl | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. | | `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | -| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. | +| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. | +| `fields.wordAudio` | string | Existing word-audio field read for the animated image's opening freeze. Independent of the sentence-audio destination in `fields.audio`; this mapping does not write audio. See [config.example.jsonc](/config.example.jsonc) for defaults. | | `fields.image` | string | Card field for images (default: `Picture`) | | `fields.sentence` | string | Card field for sentences (default: `Sentence`) | | `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 1119873c..6eed9b71 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -541,7 +541,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. + // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind 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. // ========================================== @@ -562,6 +562,7 @@ "fields": { "word": "Expression", // Card field for the mined word or expression text. "audio": "ExpressionAudio", // Card field that receives generated sentence audio. + "wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization. "image": "Picture", // Card field that receives the captured screenshot or animated image. "sentence": "Sentence", // Card field that receives the source sentence text. "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). diff --git a/src/anki-integration/animated-image-sync.test.ts b/src/anki-integration/animated-image-sync.test.ts index 6f18cba5..2bfe309a 100644 --- a/src/anki-integration/animated-image-sync.test.ts +++ b/src/anki-integration/animated-image-sync.test.ts @@ -14,7 +14,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for const leadInSeconds = await resolveAnimatedImageLeadInSeconds({ config: { fields: { - audio: 'ExpressionAudio', + audio: 'SentenceAudio', + wordAudio: 'Pronunciation', }, media: { imageType: 'avif', @@ -25,7 +26,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for noteInfo: { noteId: 42, fields: { - ExpressionAudio: { + SentenceAudio: { value: '[sound:sentence.mp3]' }, + Pronunciation: { value: '[sound:word.mp3][sound:alt.ogg]', }, }, @@ -121,3 +123,32 @@ test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled assert.equal(leadInSeconds, 0); }); + +for (const sentenceAudio of ['', '[sound:sentence.mp3]']) { + test(`word audio defaults independently of sentence audio (${sentenceAudio ? 'existing' : 'new'} note)`, async () => { + const retrieved: string[] = []; + const leadInSeconds = await resolveAnimatedImageLeadInSeconds({ + config: { + fields: { audio: 'SentenceAudio' }, + media: { imageType: 'avif' }, + }, + noteInfo: { + noteId: 42, + fields: { + ExpressionAudio: { value: '[sound:word.mp3]' }, + SentenceAudio: { value: sentenceAudio }, + }, + }, + resolveConfiguredFieldName: (noteInfo, ...preferredNames) => + preferredNames.find((name) => name !== undefined && name in noteInfo.fields) ?? null, + retrieveMediaFileBase64: async (filename) => { + retrieved.push(filename); + return 'd29yZA=='; + }, + probeAudioDurationSeconds: async (_buffer, filename) => (filename === 'word.mp3' ? 0.6 : 4), + }); + + assert.equal(leadInSeconds, 0.6); + assert.deepEqual(retrieved, ['word.mp3']); + }); +} diff --git a/src/anki-integration/animated-image-sync.ts b/src/anki-integration/animated-image-sync.ts index 25282873..96d81809 100644 --- a/src/anki-integration/animated-image-sync.ts +++ b/src/anki-integration/animated-image-sync.ts @@ -97,8 +97,8 @@ export async function resolveAnimatedImageLeadInSeconds { ); }); +test('word audio mapping defaults and validates independently of sentence audio', () => { + for (const wordAudio of [undefined, 'Pronunciation', 7]) { + const { context, warnings } = makeContext({ + fields: { + audio: 'SentenceAudio', + ...(wordAudio !== undefined ? { wordAudio } : {}), + }, + }); + applyAnkiConnectResolution(context); + + assert.equal(context.resolved.ankiConnect.fields.audio, 'SentenceAudio'); + assert.equal( + context.resolved.ankiConnect.fields.wordAudio, + typeof wordAudio === 'string' ? wordAudio : DEFAULT_CONFIG.ankiConnect.fields.wordAudio, + ); + assert.deepEqual( + warnings.map((warning) => warning.path), + typeof wordAudio === 'number' ? ['ankiConnect.fields.wordAudio'] : [], + ); + } +}); + test('invalid modern Anki subtrees warn and keep resolved defaults', () => { const { context, warnings } = makeContext({ fields: { word: 7 }, diff --git a/src/config/resolve/anki-connect/modern-fields.ts b/src/config/resolve/anki-connect/modern-fields.ts index c8d30ad1..228d8615 100644 --- a/src/config/resolve/anki-connect/modern-fields.ts +++ b/src/config/resolve/anki-connect/modern-fields.ts @@ -7,7 +7,15 @@ export function applyModernFieldsResolution( context: ResolveContext, fields: Record, ): void { - for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) { + for (const key of [ + 'word', + 'audio', + 'wordAudio', + 'image', + 'sentence', + 'miscInfo', + 'translation', + ] as const) { applyModernValue( context, fields, diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index cc33f30a..023bc1d5 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -364,6 +364,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => { 'ankiConnect.nPlusOne.minSentenceWords', 'ankiConnect.fields.word', 'ankiConnect.fields.audio', + 'ankiConnect.fields.wordAudio', 'ankiConnect.fields.image', 'ankiConnect.fields.sentence', 'ankiConnect.fields.miscInfo', diff --git a/src/types/anki.ts b/src/types/anki.ts index 2874b1a4..1ddcc732 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -157,6 +157,7 @@ export interface AnkiConnectConfig { fields?: { word?: string; audio?: string; + wordAudio?: string; image?: string; sentence?: string; miscInfo?: string; diff --git a/src/types/config.ts b/src/types/config.ts index 628c6d04..b6607be2 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -228,6 +228,7 @@ export interface ResolvedConfig { fields: { word: string; audio: string; + wordAudio: string; image: string; sentence: string; miscInfo: string;