feat(anki): mirror mpv software volume into generated sentence audio (#155)

This commit is contained in:
2026-07-10 00:44:23 -07:00
committed by GitHub
parent 84c75f50aa
commit 7b1a005a65
31 changed files with 419 additions and 23 deletions
+57 -7
View File
@@ -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
View File
@@ -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);
+6 -1
View File
@@ -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, []);
+13
View File
@@ -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,
),
);
}
+56
View File
@@ -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);
});
+26
View File
@@ -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 {
+26
View File
@@ -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',
+1 -1
View File
@@ -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.',
],
+16
View File
@@ -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) {
+11
View File
@@ -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',
+3
View File
@@ -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',
+2
View File
@@ -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',
@@ -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;
}
+48
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -75,6 +75,7 @@ export interface AnkiConnectConfig {
animatedCrf?: number;
syncAnimatedImageToWordAudio?: boolean;
normalizeAudio?: boolean;
mirrorMpvVolume?: boolean;
audioPadding?: number;
fallbackDuration?: number;
maxMediaDuration?: number;
+1
View File
@@ -236,6 +236,7 @@ export interface ResolvedConfig {
animatedCrf: number;
syncAnimatedImageToWordAudio: boolean;
normalizeAudio: boolean;
mirrorMpvVolume: boolean;
audioPadding: number;
fallbackDuration: number;
maxMediaDuration: number;