From 359cb0a301436405b94fbd5c7bd6c66d2679a79d Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 2 Jul 2026 00:34:16 -0700 Subject: [PATCH] Normalize generated Anki audio by default (#135) --- changes/audio-normalization.md | 4 ++++ config.example.jsonc | 1 + docs-site/anki-integration.md | 4 +++- docs-site/configuration.md | 2 ++ docs-site/public/config.example.jsonc | 1 + src/anki-integration.test.ts | 22 +++++++++++++++---- src/anki-integration.ts | 3 +++ src/anki-integration/card-creation.ts | 2 ++ .../pending-youtube-media-queue.ts | 1 + src/config/config.test.ts | 1 + .../definitions/defaults-integrations.ts | 1 + .../definitions/domain-registry.test.ts | 1 + .../definitions/options-integrations.ts | 6 +++++ src/core/services/stats-server.ts | 10 ++++++++- src/media-generator.test.ts | 18 +++++++++++++++ src/media-generator.ts | 8 ++++++- src/types/anki.ts | 1 + src/types/config.ts | 1 + 18 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 changes/audio-normalization.md diff --git a/changes/audio-normalization.md b/changes/audio-normalization.md new file mode 100644 index 00000000..2489bc7b --- /dev/null +++ b/changes/audio-normalization.md @@ -0,0 +1,4 @@ +type: fixed +area: mining + +- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed. diff --git a/config.example.jsonc b/config.example.jsonc index 55ce4c1b..cfd0d18e 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -559,6 +559,7 @@ "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 "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 18f6bc32..2f92fb52 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -161,13 +161,14 @@ Audio is extracted from the video file using the subtitle's start and end timest "ankiConnect": { "media": { "generateAudio": true, + "normalizeAudio": true, // normalize generated clip loudness "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. +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. The audio is uploaded to Anki's media folder and inserted as `[sound:audio_.mp3]`. @@ -347,6 +348,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead "imageType": "static", "imageFormat": "jpg", "imageQuality": 92, + "normalizeAudio": true, "audioPadding": 0, "maxMediaDuration": 30, }, diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 910b54ef..5fe72a57 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -951,6 +951,7 @@ Enable automatic Anki card creation and updates with media generation: "animatedMaxWidth": 640, "animatedMaxHeight": 0, "animatedCrf": 35, + "normalizeAudio": true, "audioPadding": 0, "fallbackDuration": 3, "maxMediaDuration": 30 @@ -1001,6 +1002,7 @@ 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.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 55ce4c1b..cfd0d18e 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -559,6 +559,7 @@ "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 "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 927c32f4..6a1a1105 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -860,13 +860,18 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs assert.equal(storedMedia.length, 2); }); -test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => { - const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = []; +test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => { + const audioCalls: Array<{ + path: string; + audioStreamIndex?: number; + normalizeAudio?: boolean; + }> = []; const integration = new AnkiIntegration( { media: { audioPadding: 0, + normalizeAudio: false, }, }, {} as never, @@ -896,13 +901,21 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a endTime: number, audioPadding?: number, audioStreamIndex?: number, + normalizeAudio?: boolean, ) => Promise; }; generateAudio: () => Promise; }; internals.mediaGenerator = { - generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => { - audioCalls.push({ path: path.path, audioStreamIndex }); + generateAudio: async ( + path, + _startTime, + _endTime, + _audioPadding, + audioStreamIndex, + normalizeAudio, + ) => { + audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio }); return Buffer.from('audio'); }, }; @@ -913,6 +926,7 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a { path: '/tmp/subminer-youtube-media-cache/media.mkv', audioStreamIndex: undefined, + normalizeAudio: false, }, ]); }); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index c0524e5b..a8fbf60f 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -348,6 +348,7 @@ export class AnkiIntegration { endTime, audioPadding, audioStreamIndex, + this.config.media?.normalizeAudio !== false, ), generateScreenshot: (videoPath, timestamp, options) => this.mediaGenerator.generateScreenshot(videoPath, timestamp, options), @@ -502,6 +503,7 @@ export class AnkiIntegration { endTime, audioPadding, audioStreamIndex, + this.config.media?.normalizeAudio !== false, ), generateScreenshot: (videoPath, timestamp, options) => this.mediaGenerator.generateScreenshot(videoPath, timestamp, options), @@ -996,6 +998,7 @@ export class AnkiIntegration { endTime, this.config.media?.audioPadding, resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex), + this.config.media?.normalizeAudio !== false, ); } diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 457a877e..22d11df0 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -65,6 +65,7 @@ interface CardCreationMediaGenerator { endTime: number, audioPadding?: number, audioStreamIndex?: number, + normalizeAudio?: boolean, ): Promise; generateScreenshot( path: MediaInput, @@ -842,6 +843,7 @@ export class CardCreationService { videoPath, mpvClient.currentAudioStreamIndex ?? undefined, ), + this.deps.getConfig().media?.normalizeAudio !== false, ); } diff --git a/src/anki-integration/pending-youtube-media-queue.ts b/src/anki-integration/pending-youtube-media-queue.ts index ed899419..f1ba2919 100644 --- a/src/anki-integration/pending-youtube-media-queue.ts +++ b/src/anki-integration/pending-youtube-media-queue.ts @@ -272,6 +272,7 @@ export class PendingYoutubeMediaQueue { job.endTime, config.media?.audioPadding, undefined, + config.media?.normalizeAudio !== false, ); if (audioBuffer) { await this.deps.client.storeMediaFile(audioFilename, audioBuffer); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 79687015..5eaca6de 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -92,6 +92,7 @@ test('loads defaults when config is missing', () => { model: '', systemPrompt: '', }); + assert.equal(config.ankiConnect.media.normalizeAudio, true); assert.equal(config.startupWarmups.lowPowerMode, false); assert.equal(config.startupWarmups.mecab, true); assert.equal(config.startupWarmups.yomitanExtension, true); diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index d81f312e..0a405532 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -51,6 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< animatedMaxHeight: 0, animatedCrf: 35, syncAnimatedImageToWordAudio: true, + normalizeAudio: 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 b1eefc65..db4c8958 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -110,6 +110,7 @@ test('config option registry includes critical paths and has unique entries', () 'subtitleStyle.autoPauseVideoOnYomitanPopup', 'ankiConnect.enabled', 'subtitleStyle.nameMatchEnabled', + 'ankiConnect.media.normalizeAudio', '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 32e89832..571f968e 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -181,6 +181,12 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.ankiConnect.media.generateAudio, description: 'Generate sentence audio for mined cards.', }, + { + path: 'ankiConnect.media.normalizeAudio', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.media.normalizeAudio, + description: 'Normalize generated sentence audio loudness during media extraction.', + }, { path: 'ankiConnect.media.generateImage', kind: 'boolean', diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index c031f7c4..144a176c 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -1206,6 +1206,7 @@ export function createStatsApp( const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator(); const audioPadding = ankiConfig.media?.audioPadding ?? 0; + const normalizeAudio = ankiConfig.media?.normalizeAudio !== false; const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30; const startSec = startMs / 1000; @@ -1228,7 +1229,14 @@ export function createStatsApp( const audioPromise = generateAudio ? timeMiningPhase(mode, 'generateAudio', () => - mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding), + mediaGen.generateAudio( + sourcePath, + startSec, + clampedEndSec, + audioPadding, + null, + normalizeAudio, + ), ) : Promise.resolve(null); diff --git a/src/media-generator.test.ts b/src/media-generator.test.ts index 1bbe9116..90276a8f 100644 --- a/src/media-generator.test.ts +++ b/src/media-generator.test.ts @@ -163,6 +163,24 @@ test('generateAudio defaults to unpadded sentence timing', async () => { }); }); +test('generateAudio normalizes sentence audio by default', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAudio('/video.mp4', 10, 12); + + const args = readFfmpegArgs(argsPath); + assert.equal(args[args.indexOf('-af') + 1], 'loudnorm=I=-18:TP=-1.5:LRA=11'); + }); +}); + +test('generateAudio can preserve raw sentence audio loudness', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAudio('/video.mp4', 10, 12, 0, null, false); + + const args = readFfmpegArgs(argsPath); + assert.equal(args.includes('-af'), false); + }); +}); + 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 6f51b60a..683137b4 100644 --- a/src/media-generator.ts +++ b/src/media-generator.ts @@ -24,6 +24,7 @@ import { createLogger } from './logger'; import { normalizeMediaInput, type MediaInput } from './media-input'; const log = createLogger('media'); +const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-18:TP=-1.5:LRA=11'; export type { MediaInput, MediaInputOptions } from './media-input'; @@ -264,6 +265,7 @@ export class MediaGenerator { endTime: number, padding: number = 0, audioStreamIndex: number | null = null, + normalizeAudio = true, ): Promise { const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0; const start = Math.max(0, startTime - safePadding); @@ -293,7 +295,11 @@ export class MediaGenerator { args.push('-map', `0:${audioStreamIndex}`); } - args.push('-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath); + args.push('-vn'); + if (normalizeAudio) { + args.push('-af', AUDIO_NORMALIZATION_FILTER); + } + args.push('-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath); this.logMediaDebug( `audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`, diff --git a/src/types/anki.ts b/src/types/anki.ts index c8f35b26..daf4c089 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -74,6 +74,7 @@ export interface AnkiConnectConfig { animatedMaxHeight?: number; animatedCrf?: number; syncAnimatedImageToWordAudio?: boolean; + normalizeAudio?: boolean; audioPadding?: number; fallbackDuration?: number; maxMediaDuration?: number; diff --git a/src/types/config.ts b/src/types/config.ts index 435513ff..1c278c30 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -235,6 +235,7 @@ export interface ResolvedConfig { animatedMaxHeight?: number; animatedCrf: number; syncAnimatedImageToWordAudio: boolean; + normalizeAudio: boolean; audioPadding: number; fallbackDuration: number; maxMediaDuration: number;