From 7b1a005a659efae77e3720c6018cd93b7976b04d Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 10 Jul 2026 00:44:23 -0700 Subject: [PATCH] feat(anki): mirror mpv software volume into generated sentence audio (#155) --- changes/audio-normalization.md | 2 +- config.example.jsonc | 5 +- docs-site/anki-integration.md | 6 +- docs-site/configuration.md | 4 +- docs-site/public/config.example.jsonc | 5 +- src/anki-integration.test.ts | 64 +++++++++++++++++-- src/anki-integration.ts | 33 ++++++++-- .../card-creation-sentence-media.test.ts | 22 ++++++- src/anki-integration/card-creation.test.ts | 7 +- src/anki-integration/card-creation.ts | 13 ++++ src/anki-integration/mpv-volume.test.ts | 56 ++++++++++++++++ src/anki-integration/mpv-volume.ts | 26 ++++++++ .../pending-youtube-media-queue.test.ts | 1 + .../pending-youtube-media-queue.ts | 6 ++ src/anki-integration/pending-youtube-media.ts | 1 + src/config/config.test.ts | 26 ++++++++ .../definitions/defaults-integrations.ts | 1 + .../definitions/domain-registry.test.ts | 1 + .../definitions/options-integrations.ts | 10 ++- src/config/definitions/template-sections.ts | 2 +- src/config/resolve/anki-connect.ts | 16 +++++ src/config/settings/registry.test.ts | 11 ++++ src/config/settings/registry.ts | 3 + src/core/services/config-hot-reload.test.ts | 4 ++ src/core/services/config-hot-reload.ts | 2 + .../config-hot-reload-handlers.test.ts | 36 +++++++++++ .../runtime/config-hot-reload-handlers.ts | 10 +++ src/media-generator.test.ts | 48 ++++++++++++++ src/media-generator.ts | 19 +++++- src/types/anki.ts | 1 + src/types/config.ts | 1 + 31 files changed, 419 insertions(+), 23 deletions(-) create mode 100644 src/anki-integration/mpv-volume.test.ts create mode 100644 src/anki-integration/mpv-volume.ts diff --git a/changes/audio-normalization.md b/changes/audio-normalization.md index eb1c59a8..0e5b2c28 100644 --- a/changes/audio-normalization.md +++ b/changes/audio-normalization.md @@ -1,4 +1,4 @@ type: added area: mining -- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed. +- Normalized generated card audio and mirrored mpv's cubic software-volume curve by default during media extraction, with a `-1 dBFS` limiter for amplified clips and live `ankiConnect.media.normalizeAudio` and `ankiConnect.media.mirrorMpvVolume` toggles to control both behaviors independently. diff --git a/config.example.jsonc b/config.example.jsonc index 95ebe7ae..e739e85f 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -515,7 +515,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // 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. // ========================================== @@ -559,7 +559,8 @@ "animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio. "animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files. "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false - "normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false + "normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false + "mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 2f92fb52..418ec9d3 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -162,13 +162,16 @@ Audio is extracted from the video file using the subtitle's start and end timest "media": { "generateAudio": true, "normalizeAudio": true, // normalize generated clip loudness + "mirrorMpvVolume": true, // apply the current mpv volume level "audioPadding": 0, // optional seconds before and after subtitle timing "maxMediaDuration": 30 // cap total duration in seconds } } ``` -Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness. +Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness. Changing this setting applies to the next extraction without restarting SubMiner. + +`mirrorMpvVolume` is also enabled by default. Immediately before extracting each playback-overlay card's audio, SubMiner reads mpv's numeric `volume` and applies mpv's cubic software-volume curve after loudness normalization. For example, mpv volume `50` produces `0.5³ = 0.125` gain. Amplified output above mpv volume `100` is limited to a `-1 dBFS` ceiling before MP3 encoding to prevent clipping. It ignores mpv's separate `mute` state. If the volume property is missing, invalid, or unavailable, extraction continues with unity scaling; disabling this option skips the query and volume filter. Changing this setting applies to the next extraction without restarting SubMiner. YouTube cards queued for a background media-cache download retain the volume captured when the card was mined. Stats-dashboard mining does not currently have access to the active mpv property client, so it does not apply mpv volume scaling. The audio is uploaded to Anki's media folder and inserted as `[sound:audio_.mp3]`. @@ -349,6 +352,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead "imageFormat": "jpg", "imageQuality": 92, "normalizeAudio": true, + "mirrorMpvVolume": true, "audioPadding": 0, "maxMediaDuration": 30, }, diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 5fe72a57..fd5f49c6 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -952,6 +952,7 @@ Enable automatic Anki card creation and updates with media generation: "animatedMaxHeight": 0, "animatedCrf": 35, "normalizeAudio": true, + "mirrorMpvVolume": true, "audioPadding": 0, "fallbackDuration": 3, "maxMediaDuration": 30 @@ -1002,7 +1003,8 @@ This example is intentionally compact. The option table below documents availabl | `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. | | `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | | `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | -| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. | +| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. | +| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. | | `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | | `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | | `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 95ebe7ae..e739e85f 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -515,7 +515,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // 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. // ========================================== @@ -559,7 +559,8 @@ "animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio. "animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files. "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false - "normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false + "normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false + "mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 6a1a1105..2cff3587 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -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; generateScreenshot: (path: MediaInput) => Promise; }; @@ -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 }> = []; const storedMedia: string[] = []; + const audioVolumeScales: Array = []; + 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; }; mediaGenerator: { - generateAudio: () => Promise; + generateAudio: ( + path: MediaInput, + startTime: number, + endTime: number, + audioPadding?: number, + audioStreamIndex?: number, + normalizeAudio?: boolean, + volumeScale?: number, + ) => Promise; generateScreenshot: () => Promise; }; 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 ?? '', /^ warning.path === 'ankiConnect.media.mirrorMpvVolume'), + ); +}); + test('parses updates config and warns on invalid values', () => { const validDir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 0a405532..adcc74de 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -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, diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index db4c8958..1c9801d6 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -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', diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 2ec03b4a..04b8ef60 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -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', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 943d543b..8744d350 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -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.', ], diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index e1e55b5d..a668e4f5 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -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) { diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index 7ed51405..a806d993 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -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', diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 7aaed909..2b821523 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -236,6 +236,7 @@ const LABEL_OVERRIDES: Record = { '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' || diff --git a/src/core/services/config-hot-reload.test.ts b/src/core/services/config-hot-reload.test.ts index 7f5de50c..38fcfcb6 100644 --- a/src/core/services/config-hot-reload.test.ts +++ b/src/core/services/config-hot-reload.test.ts @@ -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', diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts index 2f747dae..66a9da0c 100644 --- a/src/core/services/config-hot-reload.ts +++ b/src/core/services/config-hot-reload.ts @@ -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', diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index a1cc6247..112dbadb 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -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'; diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 3b4993c6..438826fc 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -93,6 +93,16 @@ function buildAnkiRuntimeConfigPatch( if (diff.hotReloadFields.includes('ankiConnect.deck')) { patch.deck = config.ankiConnect.deck; } + const mediaPatch: NonNullable = {}; + 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; } diff --git a/src/media-generator.test.ts b/src/media-generator.test.ts index f7fcb876..752eb725 100644 --- a/src/media-generator.test.ts +++ b/src/media-generator.test.ts @@ -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); diff --git a/src/media-generator.ts b/src/media-generator.ts index 09e0e267..79787b5b 100644 --- a/src/media-generator.ts +++ b/src/media-generator.ts @@ -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 { 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); diff --git a/src/types/anki.ts b/src/types/anki.ts index daf4c089..8ebd604a 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -75,6 +75,7 @@ export interface AnkiConnectConfig { animatedCrf?: number; syncAnimatedImageToWordAudio?: boolean; normalizeAudio?: boolean; + mirrorMpvVolume?: boolean; audioPadding?: number; fallbackDuration?: number; maxMediaDuration?: number; diff --git a/src/types/config.ts b/src/types/config.ts index 1c278c30..1866fd33 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -236,6 +236,7 @@ export interface ResolvedConfig { animatedCrf: number; syncAnimatedImageToWordAudio: boolean; normalizeAudio: boolean; + mirrorMpvVolume: boolean; audioPadding: number; fallbackDuration: number; maxMediaDuration: number;