From e2afceb492cbea6c032bfb088c4accf981a3e97e Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 10 Jun 2026 22:51:19 -0700 Subject: [PATCH] fix(anki): write sentence card audio only to sentence audio field (#118) --- changes/sentence-card-audio-field.md | 4 + package.json | 2 +- .../card-creation-sentence-media.test.ts | 130 ++++++++++++++++++ src/anki-integration/card-creation.ts | 11 -- 4 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 changes/sentence-card-audio-field.md create mode 100644 src/anki-integration/card-creation-sentence-media.test.ts diff --git a/changes/sentence-card-audio-field.md b/changes/sentence-card-audio-field.md new file mode 100644 index 00000000..679d619c --- /dev/null +++ b/changes/sentence-card-audio-field.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Sentence-card mining now writes generated audio only to the configured sentence audio field instead of also filling expression audio. diff --git a/package.json b/package.json index ee12fd4a..1e63ddfd 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "test:launcher": "bun run test:launcher:src", "test:core": "bun run test:core:src", "test:subtitle": "bun run test:subtitle:src", - "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/docs-versioning.test.ts scripts/docs-versioned-assets.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/prepare-build-assets.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", + "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/card-creation-sentence-media.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/docs-versioning.test.ts scripts/docs-versioned-assets.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/prepare-build-assets.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", "generate:config-example": "bun run src/generate-config-example.ts", "verify:config-example": "bun run src/verify-config-example.ts", "start": "bun run build && electron . --start", diff --git a/src/anki-integration/card-creation-sentence-media.test.ts b/src/anki-integration/card-creation-sentence-media.test.ts new file mode 100644 index 00000000..c2821f86 --- /dev/null +++ b/src/anki-integration/card-creation-sentence-media.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { CardCreationService } from './card-creation'; +import type { AnkiConnectConfig } from '../types/anki'; + +type CardCreationDeps = ConstructorParameters[0]; + +test('sentence card writes generated audio only to sentence audio field', async () => { + const addedFields: Record[] = []; + const updatedFields: Record[] = []; + const storedMedia: string[] = []; + + const deps: CardCreationDeps = { + getConfig: () => + ({ + deck: 'Mining', + fields: { + word: 'Expression', + sentence: 'Sentence', + audio: 'ExpressionAudio', + translation: 'SelectionText', + }, + media: { + generateAudio: true, + generateImage: false, + maxMediaDuration: 30, + }, + behavior: {}, + ai: false, + }) as AnkiConnectConfig, + getAiConfig: () => ({}), + getTimingTracker: () => ({}) as never, + getMpvClient: () => + ({ + currentVideoPath: '/video.mp4', + currentSubText: '字幕', + currentSubStart: 12, + currentSubEnd: 14, + currentTimePos: 13, + currentAudioStreamIndex: 0, + }) as never, + client: { + addNote: async (_deck, _modelName, fields) => { + addedFields.push(fields); + return 42; + }, + addTags: async () => undefined, + notesInfo: async () => [ + { + noteId: 42, + fields: { + Expression: { value: '字幕' }, + Sentence: { value: '字幕' }, + SelectionText: { value: 'Subtitle' }, + ExpressionAudio: { value: '' }, + SentenceAudio: { value: '' }, + }, + }, + ], + updateNoteFields: async (_noteId, fields) => { + updatedFields.push(fields); + }, + storeMediaFile: async (filename) => { + storedMedia.push(filename); + }, + findNotes: async () => [], + retrieveMediaFile: async () => '', + }, + mediaGenerator: { + generateAudio: async () => Buffer.from('audio'), + generateScreenshot: async () => null, + generateAnimatedImage: async () => null, + }, + showOsdNotification: () => undefined, + showUpdateResult: () => undefined, + showStatusNotification: () => undefined, + showNotification: async () => undefined, + beginUpdateProgress: () => undefined, + endUpdateProgress: () => undefined, + withUpdateProgress: async (_message, action) => action(), + resolveConfiguredFieldName: (noteInfo, ...preferredNames) => { + for (const preferredName of preferredNames) { + if (preferredName && preferredName in noteInfo.fields) return preferredName; + } + return null; + }, + resolveNoteFieldName: (noteInfo, preferredName) => + preferredName && preferredName in noteInfo.fields ? preferredName : null, + getAnimatedImageLeadInSeconds: async () => 0, + extractFields: () => ({}), + processSentence: (sentence) => sentence, + setCardTypeFields: () => undefined, + mergeFieldValue: (_existing, newValue) => newValue, + formatMiscInfoPattern: () => '', + getEffectiveSentenceCardConfig: () => ({ + model: 'Sentence', + sentenceField: 'Sentence', + audioField: 'SentenceAudio', + lapisEnabled: true, + kikuEnabled: false, + kikuFieldGrouping: 'disabled', + kikuDeleteDuplicateInAuto: false, + }), + getFallbackDurationSeconds: () => 10, + appendKnownWordsFromNoteInfo: () => undefined, + isUpdateInProgress: () => false, + setUpdateInProgress: () => undefined, + trackLastAddedNoteId: () => undefined, + }; + + const created = await new CardCreationService(deps).createSentenceCard( + '字幕', + 12, + 14, + 'Subtitle', + ); + + assert.equal(created, true); + assert.deepEqual(addedFields[0], { + Sentence: '字幕', + SelectionText: 'Subtitle', + IsSentenceCard: 'x', + Expression: '字幕', + }); + assert.equal(storedMedia.length, 1); + const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields); + assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`); + assert.equal('ExpressionAudio' in mediaUpdate!, false); +}); diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 5b09e5e1..1077ac0b 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -528,7 +528,6 @@ export class CardCreationService { const translationField = this.deps.getConfig().fields?.translation || 'SelectionText'; let resolvedMiscInfoField: string | null = null; let resolvedSentenceAudioField: string = audioFieldName; - let resolvedExpressionAudioField: string | null = null; fields[sentenceField] = sentence; @@ -626,10 +625,6 @@ export class CardCreationService { this.deps.appendKnownWordsFromNoteInfo(createdNoteInfo); resolvedSentenceAudioField = this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName; - resolvedExpressionAudioField = this.deps.resolveConfiguredFieldName( - createdNoteInfo, - this.deps.getConfig().fields?.audio || 'ExpressionAudio', - ); resolvedMiscInfoField = this.deps.resolveConfiguredFieldName( createdNoteInfo, this.deps.getConfig().fields?.miscInfo, @@ -662,12 +657,6 @@ export class CardCreationService { await this.deps.client.storeMediaFile(audioFilename, audioBuffer); const audioValue = `[sound:${audioFilename}]`; mediaFields[resolvedSentenceAudioField] = audioValue; - if ( - resolvedExpressionAudioField && - resolvedExpressionAudioField !== resolvedSentenceAudioField - ) { - mediaFields[resolvedExpressionAudioField] = audioValue; - } miscInfoFilename = audioFilename; } } catch (error) {