Normalize generated Anki audio by default (#135)

This commit is contained in:
2026-07-02 00:34:16 -07:00
committed by GitHub
parent 4b10e85053
commit 359cb0a301
18 changed files with 80 additions and 7 deletions
+4
View File
@@ -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.
+1
View File
@@ -559,6 +559,7 @@
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio. "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. "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 "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. "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. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
+3 -1
View File
@@ -161,13 +161,14 @@ Audio is extracted from the video file using the subtitle's start and end timest
"ankiConnect": { "ankiConnect": {
"media": { "media": {
"generateAudio": true, "generateAudio": true,
"normalizeAudio": true, // normalize generated clip loudness
"audioPadding": 0, // optional seconds before and after subtitle timing "audioPadding": 0, // optional seconds before and after subtitle timing
"maxMediaDuration": 30 // cap total duration in seconds "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_<timestamp>.mp3]`. The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
@@ -347,6 +348,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
"imageType": "static", "imageType": "static",
"imageFormat": "jpg", "imageFormat": "jpg",
"imageQuality": 92, "imageQuality": 92,
"normalizeAudio": true,
"audioPadding": 0, "audioPadding": 0,
"maxMediaDuration": 30, "maxMediaDuration": 30,
}, },
+2
View File
@@ -951,6 +951,7 @@ Enable automatic Anki card creation and updates with media generation:
"animatedMaxWidth": 640, "animatedMaxWidth": 640,
"animatedMaxHeight": 0, "animatedMaxHeight": 0,
"animatedCrf": 35, "animatedCrf": 35,
"normalizeAudio": true,
"audioPadding": 0, "audioPadding": 0,
"fallbackDuration": 3, "fallbackDuration": 3,
"maxMediaDuration": 30 "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.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. | | `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.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.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | | `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | | `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
+1
View File
@@ -559,6 +559,7 @@
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio. "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. "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 "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. "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. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
+18 -4
View File
@@ -860,13 +860,18 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
assert.equal(storedMedia.length, 2); assert.equal(storedMedia.length, 2);
}); });
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => { test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = []; const audioCalls: Array<{
path: string;
audioStreamIndex?: number;
normalizeAudio?: boolean;
}> = [];
const integration = new AnkiIntegration( const integration = new AnkiIntegration(
{ {
media: { media: {
audioPadding: 0, audioPadding: 0,
normalizeAudio: false,
}, },
}, },
{} as never, {} as never,
@@ -896,13 +901,21 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
endTime: number, endTime: number,
audioPadding?: number, audioPadding?: number,
audioStreamIndex?: number, audioStreamIndex?: number,
normalizeAudio?: boolean,
) => Promise<Buffer>; ) => Promise<Buffer>;
}; };
generateAudio: () => Promise<Buffer | null>; generateAudio: () => Promise<Buffer | null>;
}; };
internals.mediaGenerator = { internals.mediaGenerator = {
generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => { generateAudio: async (
audioCalls.push({ path: path.path, audioStreamIndex }); path,
_startTime,
_endTime,
_audioPadding,
audioStreamIndex,
normalizeAudio,
) => {
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio });
return Buffer.from('audio'); 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', path: '/tmp/subminer-youtube-media-cache/media.mkv',
audioStreamIndex: undefined, audioStreamIndex: undefined,
normalizeAudio: false,
}, },
]); ]);
}); });
+3
View File
@@ -348,6 +348,7 @@ export class AnkiIntegration {
endTime, endTime,
audioPadding, audioPadding,
audioStreamIndex, audioStreamIndex,
this.config.media?.normalizeAudio !== false,
), ),
generateScreenshot: (videoPath, timestamp, options) => generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options), this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
@@ -502,6 +503,7 @@ export class AnkiIntegration {
endTime, endTime,
audioPadding, audioPadding,
audioStreamIndex, audioStreamIndex,
this.config.media?.normalizeAudio !== false,
), ),
generateScreenshot: (videoPath, timestamp, options) => generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options), this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
@@ -996,6 +998,7 @@ export class AnkiIntegration {
endTime, endTime,
this.config.media?.audioPadding, this.config.media?.audioPadding,
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex), resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
this.config.media?.normalizeAudio !== false,
); );
} }
+2
View File
@@ -65,6 +65,7 @@ interface CardCreationMediaGenerator {
endTime: number, endTime: number,
audioPadding?: number, audioPadding?: number,
audioStreamIndex?: number, audioStreamIndex?: number,
normalizeAudio?: boolean,
): Promise<Buffer | null>; ): Promise<Buffer | null>;
generateScreenshot( generateScreenshot(
path: MediaInput, path: MediaInput,
@@ -842,6 +843,7 @@ export class CardCreationService {
videoPath, videoPath,
mpvClient.currentAudioStreamIndex ?? undefined, mpvClient.currentAudioStreamIndex ?? undefined,
), ),
this.deps.getConfig().media?.normalizeAudio !== false,
); );
} }
@@ -272,6 +272,7 @@ export class PendingYoutubeMediaQueue {
job.endTime, job.endTime,
config.media?.audioPadding, config.media?.audioPadding,
undefined, undefined,
config.media?.normalizeAudio !== false,
); );
if (audioBuffer) { if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer); await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
+1
View File
@@ -92,6 +92,7 @@ test('loads defaults when config is missing', () => {
model: '', model: '',
systemPrompt: '', systemPrompt: '',
}); });
assert.equal(config.ankiConnect.media.normalizeAudio, true);
assert.equal(config.startupWarmups.lowPowerMode, false); assert.equal(config.startupWarmups.lowPowerMode, false);
assert.equal(config.startupWarmups.mecab, true); assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true); assert.equal(config.startupWarmups.yomitanExtension, true);
@@ -51,6 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
animatedMaxHeight: 0, animatedMaxHeight: 0,
animatedCrf: 35, animatedCrf: 35,
syncAnimatedImageToWordAudio: true, syncAnimatedImageToWordAudio: true,
normalizeAudio: true,
audioPadding: 0, audioPadding: 0,
fallbackDuration: 3.0, fallbackDuration: 3.0,
maxMediaDuration: 30, maxMediaDuration: 30,
@@ -110,6 +110,7 @@ test('config option registry includes critical paths and has unique entries', ()
'subtitleStyle.autoPauseVideoOnYomitanPopup', 'subtitleStyle.autoPauseVideoOnYomitanPopup',
'ankiConnect.enabled', 'ankiConnect.enabled',
'subtitleStyle.nameMatchEnabled', 'subtitleStyle.nameMatchEnabled',
'ankiConnect.media.normalizeAudio',
'anilist.characterDictionary.collapsibleSections.description', 'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath', 'mpv.executablePath',
'mpv.launchMode', 'mpv.launchMode',
@@ -181,6 +181,12 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.media.generateAudio, defaultValue: defaultConfig.ankiConnect.media.generateAudio,
description: 'Generate sentence audio for mined cards.', 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', path: 'ankiConnect.media.generateImage',
kind: 'boolean', kind: 'boolean',
+9 -1
View File
@@ -1206,6 +1206,7 @@ export function createStatsApp(
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator(); const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
const audioPadding = ankiConfig.media?.audioPadding ?? 0; const audioPadding = ankiConfig.media?.audioPadding ?? 0;
const normalizeAudio = ankiConfig.media?.normalizeAudio !== false;
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30; const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
const startSec = startMs / 1000; const startSec = startMs / 1000;
@@ -1228,7 +1229,14 @@ export function createStatsApp(
const audioPromise = generateAudio const audioPromise = generateAudio
? timeMiningPhase(mode, 'generateAudio', () => ? timeMiningPhase(mode, 'generateAudio', () =>
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding), mediaGen.generateAudio(
sourcePath,
startSec,
clampedEndSec,
audioPadding,
null,
normalizeAudio,
),
) )
: Promise.resolve(null); : Promise.resolve(null);
+18
View File
@@ -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 () => { test('generateAudio clips leading padding without adding it to trailing duration', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => { await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5); await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5);
+7 -1
View File
@@ -24,6 +24,7 @@ import { createLogger } from './logger';
import { normalizeMediaInput, type MediaInput } from './media-input'; import { normalizeMediaInput, type MediaInput } from './media-input';
const log = createLogger('media'); const log = createLogger('media');
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-18:TP=-1.5:LRA=11';
export type { MediaInput, MediaInputOptions } from './media-input'; export type { MediaInput, MediaInputOptions } from './media-input';
@@ -264,6 +265,7 @@ export class MediaGenerator {
endTime: number, endTime: number,
padding: number = 0, padding: number = 0,
audioStreamIndex: number | null = null, audioStreamIndex: number | null = null,
normalizeAudio = true,
): Promise<Buffer> { ): Promise<Buffer> {
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0; const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
const start = Math.max(0, startTime - safePadding); const start = Math.max(0, startTime - safePadding);
@@ -293,7 +295,11 @@ export class MediaGenerator {
args.push('-map', `0:${audioStreamIndex}`); 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( this.logMediaDebug(
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`, `audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
+1
View File
@@ -74,6 +74,7 @@ export interface AnkiConnectConfig {
animatedMaxHeight?: number; animatedMaxHeight?: number;
animatedCrf?: number; animatedCrf?: number;
syncAnimatedImageToWordAudio?: boolean; syncAnimatedImageToWordAudio?: boolean;
normalizeAudio?: boolean;
audioPadding?: number; audioPadding?: number;
fallbackDuration?: number; fallbackDuration?: number;
maxMediaDuration?: number; maxMediaDuration?: number;
+1
View File
@@ -235,6 +235,7 @@ export interface ResolvedConfig {
animatedMaxHeight?: number; animatedMaxHeight?: number;
animatedCrf: number; animatedCrf: number;
syncAnimatedImageToWordAudio: boolean; syncAnimatedImageToWordAudio: boolean;
normalizeAudio: boolean;
audioPadding: number; audioPadding: number;
fallbackDuration: number; fallbackDuration: number;
maxMediaDuration: number; maxMediaDuration: number;