mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
fix(anki): separate word audio mapping for animation sync (#256)
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,8 +97,8 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
|
||||
|
||||
const wordAudioFieldName = resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
config.fields?.audio,
|
||||
DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
|
||||
config.fields?.wordAudio,
|
||||
DEFAULT_ANKI_CONNECT_CONFIG.fields.wordAudio,
|
||||
);
|
||||
if (!wordAudioFieldName) {
|
||||
return 0;
|
||||
|
||||
@@ -29,6 +29,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
audio: 'ExpressionAudio',
|
||||
wordAudio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
sentence: 'Sentence',
|
||||
miscInfo: 'MiscInfo',
|
||||
|
||||
@@ -82,6 +82,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.fields.audio,
|
||||
description: 'Card field that receives generated sentence audio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.wordAudio',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.wordAudio,
|
||||
description:
|
||||
'Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.image',
|
||||
kind: 'string',
|
||||
|
||||
@@ -144,7 +144,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'AnkiConnect Integration',
|
||||
description: ['Automatic Anki updates and media generation options.'],
|
||||
notes: [
|
||||
'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.',
|
||||
],
|
||||
|
||||
@@ -29,6 +29,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'ankiConnect.nPlusOne.minSentenceWords',
|
||||
'ankiConnect.fields.word',
|
||||
'ankiConnect.fields.audio',
|
||||
'ankiConnect.fields.wordAudio',
|
||||
'ankiConnect.fields.image',
|
||||
'ankiConnect.fields.sentence',
|
||||
'ankiConnect.fields.miscInfo',
|
||||
|
||||
@@ -290,6 +290,28 @@ test('accepts ankiConnect.media.syncAnimatedImageToWordAudio override', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 },
|
||||
|
||||
@@ -7,7 +7,15 @@ export function applyModernFieldsResolution(
|
||||
context: ResolveContext,
|
||||
fields: Record<string, unknown>,
|
||||
): 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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -157,6 +157,7 @@ export interface AnkiConnectConfig {
|
||||
fields?: {
|
||||
word?: string;
|
||||
audio?: string;
|
||||
wordAudio?: string;
|
||||
image?: string;
|
||||
sentence?: string;
|
||||
miscInfo?: string;
|
||||
|
||||
@@ -228,6 +228,7 @@ export interface ResolvedConfig {
|
||||
fields: {
|
||||
word: string;
|
||||
audio: string;
|
||||
wordAudio: string;
|
||||
image: string;
|
||||
sentence: string;
|
||||
miscInfo: string;
|
||||
|
||||
Reference in New Issue
Block a user