mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
feat(anki): mirror mpv software volume into generated sentence audio (#155)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_<timestamp>.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,
|
||||
},
|
||||
|
||||
@@ -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"`) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface AnkiConnectConfig {
|
||||
animatedCrf?: number;
|
||||
syncAnimatedImageToWordAudio?: boolean;
|
||||
normalizeAudio?: boolean;
|
||||
mirrorMpvVolume?: boolean;
|
||||
audioPadding?: number;
|
||||
fallbackDuration?: number;
|
||||
maxMediaDuration?: number;
|
||||
|
||||
@@ -236,6 +236,7 @@ export interface ResolvedConfig {
|
||||
animatedCrf: number;
|
||||
syncAnimatedImageToWordAudio: boolean;
|
||||
normalizeAudio: boolean;
|
||||
mirrorMpvVolume: boolean;
|
||||
audioPadding: number;
|
||||
fallbackDuration: number;
|
||||
maxMediaDuration: number;
|
||||
|
||||
Reference in New Issue
Block a user