fix: overwrite manual subtitle audio fields

This commit is contained in:
2026-04-25 19:42:33 -07:00
parent 2c01baafc9
commit 7a08382c23
8 changed files with 291 additions and 62 deletions

View File

@@ -0,0 +1,66 @@
---
id: TASK-299
title: Force audio replacement during manual subtitle mining
status: Done
assignee:
- Codex
created_date: '2026-04-26 00:10'
updated_date: '2026-04-26 02:42'
labels:
- anki
- mining
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Manual subtitle mining via the Ctrl+C/Ctrl+V flow should replace expression and sentence audio fields even when the user has configured media overwrite fields to false. These fields can already contain proxy-inserted SubMiner audio on a new card, and manual update intent is to replace that generated content.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Manual subtitle mining replaces existing expression audio content regardless of configured audio overwrite settings.
- [x] #2 Manual subtitle mining replaces existing sentence audio content regardless of configured audio overwrite settings.
- [x] #3 Non-manual mining/update flows continue to respect configured audio overwrite settings.
- [x] #4 A regression test covers manual audio replacement when overwrite settings are disabled.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Locate the manual subtitle mining Ctrl+C/Ctrl+V flow and the Anki media field overwrite gate.
2. Add a failing regression test showing manual mining overwrites expression and sentence audio when configured audio overwrite is disabled.
3. Implement the smallest path-specific override so only manual subtitle mining forces audio replacement.
4. Run the focused mining test and update task acceptance criteria/final notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented focused manual clipboard update behavior in CardCreationService.updateLastAddedFromClipboard: generated manual audio is written to both resolved sentence audio and expression audio fields with forced overwrite. Other update flows still use existing overwrite config paths.
Verification: focused Anki tests passed; typecheck passed; changelog lint and diff check passed. Full bun run test:fast was attempted but is blocked by unrelated existing tokenizer annotation-stage failures tied to dirty task 298 worktree changes.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Manual clipboard subtitle updates now resolve both sentence audio and expression audio fields and replace both with the newly generated audio regardless of ankiConnect.behavior.overwriteAudio.
- Added a regression test for the Ctrl+C/Ctrl+V manual update path with existing proxy-inserted audio and overwriteAudio disabled.
- Registered the regression test in test:fast, documented the overwrite exception in user docs, and added a changelog fragment.
Verification:
- bun test src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/card-creation.test.ts
- bun run tsc --noEmit
- 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/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts
- bun run changelog:lint
- bun run docs:test
- bun run docs:build
- git diff --check
Blocked gate:
- bun run test:fast currently fails in unrelated src/core/services/tokenizer/annotation-stage.test.ts tests for kana-only non-independent noun helper merges; those files have pre-existing dirty changes outside this task.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,4 @@
type: fixed
area: anki
- Anki: Manual clipboard subtitle updates now replace both expression and sentence audio fields even when configured audio overwrite is disabled.

View File

@@ -213,6 +213,8 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
} }
``` ```
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated audio in both the expression audio field and sentence audio field.
## AI Translation ## AI Translation
SubMiner can auto-translate the mined sentence and fill the translation field. SubMiner can auto-translate the mined sentence and fill the translation field.

View File

@@ -858,7 +858,7 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description | | Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | | `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | | `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
@@ -893,7 +893,7 @@ This example is intentionally compact. The option table below documents availabl
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) | | `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | | `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | | `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode` (default: `true`) | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) | | `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | | `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | | `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |

View File

@@ -100,6 +100,8 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1``9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard. - For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1``9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill. 3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill.
Manual clipboard updates always replace generated audio in both the expression audio field and sentence audio field, even when `ankiConnect.behavior.overwriteAudio` is disabled. The manual flow assumes you are intentionally replacing the proxy-generated clip on the newest card.
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card. This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
| Shortcut | Action | Config key | | Shortcut | Action | Config key |

View File

@@ -70,7 +70,7 @@
"test:launcher": "bun run test:launcher:src", "test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src", "test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle: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/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/build-changelog.test.ts scripts/electron-builder-after-pack.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/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/build-changelog.test.ts scripts/electron-builder-after-pack.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", "generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts", "verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start", "start": "bun run build && electron . --start",

View File

@@ -0,0 +1,143 @@
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<typeof CardCreationService>[0];
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
service: CardCreationService;
updatedFields: Record<string, string>[];
mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }>;
storedMedia: string[];
} {
const updatedFields: Record<string, string>[] = [];
const mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }> = [];
const storedMedia: string[] = [];
const deps: CardCreationDeps = {
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'ExpressionAudio',
},
media: {
generateAudio: true,
generateImage: false,
maxMediaDuration: 30,
},
behavior: {
overwriteAudio: false,
overwriteImage: false,
},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () =>
({
findTiming: (text: string) => (text === '字幕' ? { startTime: 12, endTime: 14 } : null),
}) as never,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentAudioStreamIndex: 0,
}) as never,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
ExpressionAudio: { value: '[sound:auto-expression.mp3]' },
SentenceAudio: { value: '[sound:auto-sentence.mp3]' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async (filename) => {
storedMedia.push(filename);
},
findNotes: async () => [42],
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: (fields) =>
Object.fromEntries(
Object.entries(fields).map(([name, field]) => [name.toLowerCase(), field.value]),
),
processSentence: (sentence) => sentence,
setCardTypeFields: () => undefined,
mergeFieldValue: (existing, newValue, overwrite) => {
mergeCalls.push({ existing, newValue, overwrite });
return overwrite || !existing.trim() ? newValue : existing;
},
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
...overrides,
};
return {
service: new CardCreationService(deps),
updatedFields,
mergeCalls,
storedMedia,
};
}
test('manual clipboard subtitle update replaces expression and sentence audio even when overwriteAudio is disabled', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
await service.updateLastAddedFromClipboard('字幕');
assert.equal(updatedFields.length, 1);
assert.equal(storedMedia.length, 1);
const audioValue = `[sound:${storedMedia[0]}]`;
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
assert.deepEqual(
mergeCalls.map((call) => call.overwrite),
[true, true],
);
});

View File

@@ -219,6 +219,10 @@ export class CardCreationService {
this.deps.getConfig(), this.deps.getConfig(),
); );
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo); const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
const expressionAudioField = this.deps.resolveConfiguredFieldName(
noteInfo,
this.deps.getConfig().fields?.audio || 'ExpressionAudio',
);
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField; const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
const sentence = blocks.join(' '); const sentence = blocks.join(' ');
@@ -248,13 +252,21 @@ export class CardCreationService {
if (audioBuffer) { if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer); await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
if (sentenceAudioField) { if (sentenceAudioField || expressionAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || ''; const audioValue = `[sound:${audioFilename}]`;
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue( const audioFields = new Set(
existingAudio, [sentenceAudioField, expressionAudioField].filter(
`[sound:${audioFilename}]`, (fieldName): fieldName is string => Boolean(fieldName),
this.deps.getConfig().behavior?.overwriteAudio !== false, ),
); );
for (const audioField of audioFields) {
const existingAudio = noteInfo.fields[audioField]?.value || '';
updatedFields[audioField] = this.deps.mergeFieldValue(
existingAudio,
audioValue,
true,
);
}
} }
miscInfoFilename = audioFilename; miscInfoFilename = audioFilename;
updatePerformed = true; updatePerformed = true;