mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
feat(anki): mirror mpv software volume into generated sentence audio (#155)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
type: added
|
type: added
|
||||||
area: mining
|
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
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// 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.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still 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.
|
"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
|
"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.
|
"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.
|
||||||
|
|||||||
@@ -162,13 +162,16 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
|||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true,
|
"generateAudio": true,
|
||||||
"normalizeAudio": true, // normalize generated clip loudness
|
"normalizeAudio": true, // normalize generated clip loudness
|
||||||
|
"mirrorMpvVolume": true, // apply the current mpv volume level
|
||||||
"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. 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]`.
|
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",
|
"imageFormat": "jpg",
|
||||||
"imageQuality": 92,
|
"imageQuality": 92,
|
||||||
"normalizeAudio": true,
|
"normalizeAudio": true,
|
||||||
|
"mirrorMpvVolume": true,
|
||||||
"audioPadding": 0,
|
"audioPadding": 0,
|
||||||
"maxMediaDuration": 30,
|
"maxMediaDuration": 30,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -952,6 +952,7 @@ Enable automatic Anki card creation and updates with media generation:
|
|||||||
"animatedMaxHeight": 0,
|
"animatedMaxHeight": 0,
|
||||||
"animatedCrf": 35,
|
"animatedCrf": 35,
|
||||||
"normalizeAudio": true,
|
"normalizeAudio": true,
|
||||||
|
"mirrorMpvVolume": true,
|
||||||
"audioPadding": 0,
|
"audioPadding": 0,
|
||||||
"fallbackDuration": 3,
|
"fallbackDuration": 3,
|
||||||
"maxMediaDuration": 30
|
"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.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.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.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"`) |
|
||||||
|
|||||||
@@ -515,7 +515,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// 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.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still 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.
|
"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
|
"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.
|
"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.
|
||||||
|
|||||||
@@ -573,6 +573,8 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
|||||||
endTime: number,
|
endTime: number,
|
||||||
audioPadding?: number,
|
audioPadding?: number,
|
||||||
audioStreamIndex?: number,
|
audioStreamIndex?: number,
|
||||||
|
normalizeAudio?: boolean,
|
||||||
|
volumeScale?: number,
|
||||||
) => Promise<Buffer>;
|
) => Promise<Buffer>;
|
||||||
generateScreenshot: (path: MediaInput) => 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;
|
imageFieldName?: string;
|
||||||
generateAudio: boolean;
|
generateAudio: boolean;
|
||||||
generateImage: boolean;
|
generateImage: boolean;
|
||||||
|
volumeScale?: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
};
|
};
|
||||||
internals.client = {
|
internals.client = {
|
||||||
@@ -606,9 +609,17 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
internals.mediaGenerator = {
|
internals.mediaGenerator = {
|
||||||
generateAudio: async (mediaPath, _startTime, _endTime, _audioPadding, audioStreamIndex) => {
|
generateAudio: async (
|
||||||
|
mediaPath,
|
||||||
|
_startTime,
|
||||||
|
_endTime,
|
||||||
|
_audioPadding,
|
||||||
|
audioStreamIndex,
|
||||||
|
_normalizeAudio,
|
||||||
|
volumeScale,
|
||||||
|
) => {
|
||||||
mediaInputs.push(
|
mediaInputs.push(
|
||||||
`audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}`,
|
`audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}:${volumeScale}`,
|
||||||
);
|
);
|
||||||
return Buffer.from('audio');
|
return Buffer.from('audio');
|
||||||
},
|
},
|
||||||
@@ -628,6 +639,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
|||||||
imageFieldName: 'Picture',
|
imageFieldName: 'Picture',
|
||||||
generateAudio: true,
|
generateAudio: true,
|
||||||
generateImage: true,
|
generateImage: true,
|
||||||
|
volumeScale: 0.25,
|
||||||
});
|
});
|
||||||
internals.queuePendingYoutubeMediaUpdate({
|
internals.queuePendingYoutubeMediaUpdate({
|
||||||
sourceUrl: 'https://youtu.be/abc123',
|
sourceUrl: 'https://youtu.be/abc123',
|
||||||
@@ -640,14 +652,15 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
|||||||
imageFieldName: 'Picture',
|
imageFieldName: 'Picture',
|
||||||
generateAudio: true,
|
generateAudio: true,
|
||||||
generateImage: true,
|
generateImage: true,
|
||||||
|
volumeScale: 0.8,
|
||||||
});
|
});
|
||||||
|
|
||||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||||
|
|
||||||
assert.deepEqual(mediaInputs, [
|
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',
|
'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',
|
'image:/tmp/media.mkv:youtube-cache',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(
|
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 () => {
|
test('AnkiIntegration queues YouTube media updates against recovered source URLs', async () => {
|
||||||
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||||
const storedMedia: string[] = [];
|
const storedMedia: string[] = [];
|
||||||
|
const audioVolumeScales: Array<number | undefined> = [];
|
||||||
|
let mpvVolume = 30;
|
||||||
|
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
@@ -787,6 +802,10 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
|||||||
currentSubStart: 10,
|
currentSubStart: 10,
|
||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
currentTimePos: 11,
|
currentTimePos: 11,
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
assert.equal(name, 'volume');
|
||||||
|
return mpvVolume;
|
||||||
|
},
|
||||||
} as never,
|
} as never,
|
||||||
() => undefined,
|
() => undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -807,7 +826,15 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
|||||||
storeMediaFile: (filename: string) => Promise<void>;
|
storeMediaFile: (filename: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: () => Promise<Buffer>;
|
generateAudio: (
|
||||||
|
path: MediaInput,
|
||||||
|
startTime: number,
|
||||||
|
endTime: number,
|
||||||
|
audioPadding?: number,
|
||||||
|
audioStreamIndex?: number,
|
||||||
|
normalizeAudio?: boolean,
|
||||||
|
volumeScale?: number,
|
||||||
|
) => Promise<Buffer>;
|
||||||
generateScreenshot: () => Promise<Buffer>;
|
generateScreenshot: () => Promise<Buffer>;
|
||||||
};
|
};
|
||||||
queuePendingYoutubeMediaUpdateForNote: (job: {
|
queuePendingYoutubeMediaUpdateForNote: (job: {
|
||||||
@@ -834,7 +861,18 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
internals.mediaGenerator = {
|
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'),
|
generateScreenshot: async () => Buffer.from('image'),
|
||||||
};
|
};
|
||||||
internals.showNotification = async () => undefined;
|
internals.showNotification = async () => undefined;
|
||||||
@@ -850,6 +888,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
|||||||
},
|
},
|
||||||
label: 'resolved source',
|
label: 'resolved source',
|
||||||
});
|
});
|
||||||
|
mpvVolume = 90;
|
||||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||||
|
|
||||||
assert.equal(queued, true);
|
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.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
||||||
assert.equal(storedMedia.length, 2);
|
assert.equal(storedMedia.length, 2);
|
||||||
|
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
|
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;
|
path: string;
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
normalizeAudio?: boolean;
|
normalizeAudio?: boolean;
|
||||||
|
volumeScale?: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
const requestedProperties: string[] = [];
|
||||||
|
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
@@ -881,6 +923,10 @@ test('AnkiIntegration passes audio normalization config for ready cached YouTube
|
|||||||
currentSubStart: 10,
|
currentSubStart: 10,
|
||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
currentTimePos: 11,
|
currentTimePos: 11,
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
requestedProperties.push(name);
|
||||||
|
return 55;
|
||||||
|
},
|
||||||
} as never,
|
} as never,
|
||||||
() => undefined,
|
() => undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -902,6 +948,7 @@ test('AnkiIntegration passes audio normalization config for ready cached YouTube
|
|||||||
audioPadding?: number,
|
audioPadding?: number,
|
||||||
audioStreamIndex?: number,
|
audioStreamIndex?: number,
|
||||||
normalizeAudio?: boolean,
|
normalizeAudio?: boolean,
|
||||||
|
volumeScale?: number,
|
||||||
) => Promise<Buffer>;
|
) => Promise<Buffer>;
|
||||||
};
|
};
|
||||||
generateAudio: () => Promise<Buffer | null>;
|
generateAudio: () => Promise<Buffer | null>;
|
||||||
@@ -914,19 +961,22 @@ test('AnkiIntegration passes audio normalization config for ready cached YouTube
|
|||||||
_audioPadding,
|
_audioPadding,
|
||||||
audioStreamIndex,
|
audioStreamIndex,
|
||||||
normalizeAudio,
|
normalizeAudio,
|
||||||
|
volumeScale,
|
||||||
) => {
|
) => {
|
||||||
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio });
|
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio, volumeScale });
|
||||||
return Buffer.from('audio');
|
return Buffer.from('audio');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await internals.generateAudio();
|
await internals.generateAudio();
|
||||||
|
|
||||||
|
assert.deepEqual(requestedProperties, ['volume']);
|
||||||
assert.deepEqual(audioCalls, [
|
assert.deepEqual(audioCalls, [
|
||||||
{
|
{
|
||||||
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||||
audioStreamIndex: undefined,
|
audioStreamIndex: undefined,
|
||||||
normalizeAudio: false,
|
normalizeAudio: false,
|
||||||
|
volumeScale: 0.55 ** 3,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
+29
-4
@@ -73,6 +73,7 @@ import type {
|
|||||||
PendingYoutubeMediaQueueFailedOptions,
|
PendingYoutubeMediaQueueFailedOptions,
|
||||||
PendingYoutubeMediaQueueReadyOptions,
|
PendingYoutubeMediaQueueReadyOptions,
|
||||||
} from './anki-integration/pending-youtube-media-queue';
|
} from './anki-integration/pending-youtube-media-queue';
|
||||||
|
import { resolveMpvVolumeScale } from './anki-integration/mpv-volume';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration');
|
const log = createLogger('anki').child('integration');
|
||||||
|
|
||||||
@@ -341,14 +342,23 @@ export class AnkiIntegration {
|
|||||||
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) =>
|
generateAudio: (
|
||||||
|
videoPath,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
audioPadding,
|
||||||
|
audioStreamIndex,
|
||||||
|
normalizeAudio,
|
||||||
|
volumeScale,
|
||||||
|
) =>
|
||||||
this.mediaGenerator.generateAudio(
|
this.mediaGenerator.generateAudio(
|
||||||
videoPath,
|
videoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
audioPadding,
|
audioPadding,
|
||||||
audioStreamIndex,
|
audioStreamIndex,
|
||||||
this.config.media?.normalizeAudio !== false,
|
normalizeAudio,
|
||||||
|
volumeScale,
|
||||||
),
|
),
|
||||||
generateScreenshot: (videoPath, timestamp, options) =>
|
generateScreenshot: (videoPath, timestamp, options) =>
|
||||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||||
@@ -373,6 +383,7 @@ export class AnkiIntegration {
|
|||||||
mergeFieldValue: (existing, newValue, overwrite) =>
|
mergeFieldValue: (existing, newValue, overwrite) =>
|
||||||
this.mergeFieldValue(existing, newValue, overwrite),
|
this.mergeFieldValue(existing, newValue, overwrite),
|
||||||
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
|
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
|
||||||
|
getMpvVolumeScale: () => this.getMpvVolumeScale(),
|
||||||
generateAudioFilename: () => this.generateAudioFilename(),
|
generateAudioFilename: () => this.generateAudioFilename(),
|
||||||
generateImageFilename: () => this.generateImageFilename(),
|
generateImageFilename: () => this.generateImageFilename(),
|
||||||
formatMiscInfoPatternForMediaPath: (
|
formatMiscInfoPatternForMediaPath: (
|
||||||
@@ -496,14 +507,23 @@ export class AnkiIntegration {
|
|||||||
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) =>
|
generateAudio: (
|
||||||
|
videoPath,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
audioPadding,
|
||||||
|
audioStreamIndex,
|
||||||
|
normalizeAudio,
|
||||||
|
volumeScale,
|
||||||
|
) =>
|
||||||
this.mediaGenerator.generateAudio(
|
this.mediaGenerator.generateAudio(
|
||||||
videoPath,
|
videoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
audioPadding,
|
audioPadding,
|
||||||
audioStreamIndex,
|
audioStreamIndex,
|
||||||
this.config.media?.normalizeAudio !== false,
|
normalizeAudio,
|
||||||
|
volumeScale,
|
||||||
),
|
),
|
||||||
generateScreenshot: (videoPath, timestamp, options) =>
|
generateScreenshot: (videoPath, timestamp, options) =>
|
||||||
this.mediaGenerator.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(
|
async handleYoutubeMediaCacheReady(
|
||||||
sourceUrl: string,
|
sourceUrl: string,
|
||||||
cachedPath: string,
|
cachedPath: string,
|
||||||
@@ -1003,6 +1027,7 @@ export class AnkiIntegration {
|
|||||||
this.config.media?.audioPadding,
|
this.config.media?.audioPadding,
|
||||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||||
this.config.media?.normalizeAudio !== false,
|
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 addedFields: Record<string, string>[] = [];
|
||||||
const updatedFields: Record<string, string>[] = [];
|
const updatedFields: Record<string, string>[] = [];
|
||||||
const storedMedia: string[] = [];
|
const storedMedia: string[] = [];
|
||||||
|
const requestedProperties: string[] = [];
|
||||||
|
const audioVolumeScales: Array<number | undefined> = [];
|
||||||
|
|
||||||
const deps: CardCreationDeps = {
|
const deps: CardCreationDeps = {
|
||||||
getConfig: () =>
|
getConfig: () =>
|
||||||
@@ -24,6 +26,7 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
media: {
|
media: {
|
||||||
generateAudio: true,
|
generateAudio: true,
|
||||||
generateImage: false,
|
generateImage: false,
|
||||||
|
mirrorMpvVolume: true,
|
||||||
maxMediaDuration: 30,
|
maxMediaDuration: 30,
|
||||||
},
|
},
|
||||||
behavior: {},
|
behavior: {},
|
||||||
@@ -39,6 +42,10 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
currentSubEnd: 14,
|
currentSubEnd: 14,
|
||||||
currentTimePos: 13,
|
currentTimePos: 13,
|
||||||
currentAudioStreamIndex: 0,
|
currentAudioStreamIndex: 0,
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
requestedProperties.push(name);
|
||||||
|
return 40;
|
||||||
|
},
|
||||||
}) as never,
|
}) as never,
|
||||||
client: {
|
client: {
|
||||||
addNote: async (_deck, _modelName, fields) => {
|
addNote: async (_deck, _modelName, fields) => {
|
||||||
@@ -68,7 +75,18 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
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,
|
generateScreenshot: async () => null,
|
||||||
generateAnimatedImage: async () => null,
|
generateAnimatedImage: async () => null,
|
||||||
},
|
},
|
||||||
@@ -124,6 +142,8 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
Expression: '字幕',
|
Expression: '字幕',
|
||||||
});
|
});
|
||||||
assert.equal(storedMedia.length, 1);
|
assert.equal(storedMedia.length, 1);
|
||||||
|
assert.deepEqual(requestedProperties, ['volume']);
|
||||||
|
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
|
||||||
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
||||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||||
|
|||||||
@@ -525,6 +525,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
|||||||
miscInfoFieldName?: string;
|
miscInfoFieldName?: string;
|
||||||
generateAudio: boolean;
|
generateAudio: boolean;
|
||||||
generateImage: boolean;
|
generateImage: boolean;
|
||||||
|
volumeScale?: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
let streamRequests = 0;
|
let streamRequests = 0;
|
||||||
|
|
||||||
@@ -556,7 +557,10 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
|||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
currentTimePos: 11,
|
currentTimePos: 11,
|
||||||
currentAudioStreamIndex: 2,
|
currentAudioStreamIndex: 2,
|
||||||
requestProperty: async () => {
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'volume') {
|
||||||
|
return 35;
|
||||||
|
}
|
||||||
streamRequests += 1;
|
streamRequests += 1;
|
||||||
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
|
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',
|
miscInfoFieldName: 'MiscInfo',
|
||||||
generateAudio: true,
|
generateAudio: true,
|
||||||
generateImage: true,
|
generateImage: true,
|
||||||
|
volumeScale: 0.35 ** 3,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(updates, []);
|
assert.deepEqual(updates, []);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from './media-source';
|
} from './media-source';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||||
|
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration.card-creation');
|
const log = createLogger('anki').child('integration.card-creation');
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ interface CardCreationMediaGenerator {
|
|||||||
audioPadding?: number,
|
audioPadding?: number,
|
||||||
audioStreamIndex?: number,
|
audioStreamIndex?: number,
|
||||||
normalizeAudio?: boolean,
|
normalizeAudio?: boolean,
|
||||||
|
volumeScale?: number,
|
||||||
): Promise<Buffer | null>;
|
): Promise<Buffer | null>;
|
||||||
generateScreenshot(
|
generateScreenshot(
|
||||||
path: MediaInput,
|
path: MediaInput,
|
||||||
@@ -714,6 +716,12 @@ export class CardCreationService {
|
|||||||
|
|
||||||
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
||||||
if (shouldQueuePendingYoutubeMedia) {
|
if (shouldQueuePendingYoutubeMedia) {
|
||||||
|
const volumeScale = generateAudio
|
||||||
|
? await resolveMpvVolumeScale(
|
||||||
|
mpvClient,
|
||||||
|
this.deps.getConfig().media?.mirrorMpvVolume !== false,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
this.deps.queuePendingYoutubeMediaUpdate?.({
|
this.deps.queuePendingYoutubeMediaUpdate?.({
|
||||||
sourceUrl:
|
sourceUrl:
|
||||||
trimToNonEmptyString(await this.deps.getYoutubeMediaSourceUrl?.()) ??
|
trimToNonEmptyString(await this.deps.getYoutubeMediaSourceUrl?.()) ??
|
||||||
@@ -727,6 +735,7 @@ export class CardCreationService {
|
|||||||
miscInfoFieldName: resolvedMiscInfoField ?? undefined,
|
miscInfoFieldName: resolvedMiscInfoField ?? undefined,
|
||||||
generateAudio,
|
generateAudio,
|
||||||
generateImage,
|
generateImage,
|
||||||
|
volumeScale,
|
||||||
});
|
});
|
||||||
await this.deps.showNotification(noteId, label, 'media queued');
|
await this.deps.showNotification(noteId, label, 'media queued');
|
||||||
return true;
|
return true;
|
||||||
@@ -844,6 +853,10 @@ export class CardCreationService {
|
|||||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||||
),
|
),
|
||||||
this.deps.getConfig().media?.normalizeAudio !== false,
|
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',
|
resolveConfiguredFieldName: () => 'Picture',
|
||||||
mergeFieldValue: (_existing, newValue) => newValue,
|
mergeFieldValue: (_existing, newValue) => newValue,
|
||||||
getAnimatedImageLeadInSeconds: async () => 0,
|
getAnimatedImageLeadInSeconds: async () => 0,
|
||||||
|
getMpvVolumeScale: async () => 1,
|
||||||
generateAudioFilename: () => 'audio.mp3',
|
generateAudioFilename: () => 'audio.mp3',
|
||||||
generateImageFilename: () => 'image.webp',
|
generateImageFilename: () => 'image.webp',
|
||||||
formatMiscInfoPatternForMediaPath: () => '',
|
formatMiscInfoPatternForMediaPath: () => '',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface PendingYoutubeMediaQueueDeps {
|
|||||||
) => string | null;
|
) => string | null;
|
||||||
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
|
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
|
||||||
getAnimatedImageLeadInSeconds: (noteInfo: PendingYoutubeMediaNoteInfo) => Promise<number>;
|
getAnimatedImageLeadInSeconds: (noteInfo: PendingYoutubeMediaNoteInfo) => Promise<number>;
|
||||||
|
getMpvVolumeScale: () => Promise<number | undefined>;
|
||||||
generateAudioFilename: () => string;
|
generateAudioFilename: () => string;
|
||||||
generateImageFilename: () => string;
|
generateImageFilename: () => string;
|
||||||
formatMiscInfoPatternForMediaPath: (
|
formatMiscInfoPatternForMediaPath: (
|
||||||
@@ -126,6 +127,9 @@ export class PendingYoutubeMediaQueue {
|
|||||||
|
|
||||||
const config = this.deps.getConfig();
|
const config = this.deps.getConfig();
|
||||||
const mediaRange = this.deps.getSubtitleMediaRange(job.context);
|
const mediaRange = this.deps.getSubtitleMediaRange(job.context);
|
||||||
|
const volumeScale = shouldGenerateAudio(config)
|
||||||
|
? await this.deps.getMpvVolumeScale()
|
||||||
|
: undefined;
|
||||||
this.enqueue({
|
this.enqueue({
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
noteId: job.noteId,
|
noteId: job.noteId,
|
||||||
@@ -143,6 +147,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
this.deps.resolveConfiguredFieldName(job.noteInfo, config.fields?.miscInfo) ?? undefined,
|
this.deps.resolveConfiguredFieldName(job.noteInfo, config.fields?.miscInfo) ?? undefined,
|
||||||
generateAudio: shouldGenerateAudio(config),
|
generateAudio: shouldGenerateAudio(config),
|
||||||
generateImage: shouldGenerateImage(config),
|
generateImage: shouldGenerateImage(config),
|
||||||
|
volumeScale,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -273,6 +278,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
config.media?.audioPadding,
|
config.media?.audioPadding,
|
||||||
undefined,
|
undefined,
|
||||||
config.media?.normalizeAudio !== false,
|
config.media?.normalizeAudio !== false,
|
||||||
|
job.volumeScale,
|
||||||
);
|
);
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface PendingYoutubeMediaUpdate {
|
|||||||
miscInfoFieldName?: string;
|
miscInfoFieldName?: string;
|
||||||
generateAudio: boolean;
|
generateAudio: boolean;
|
||||||
generateImage: boolean;
|
generateImage: boolean;
|
||||||
|
volumeScale?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
});
|
});
|
||||||
assert.equal(config.ankiConnect.media.normalizeAudio, true);
|
assert.equal(config.ankiConnect.media.normalizeAudio, true);
|
||||||
|
assert.equal(config.ankiConnect.media.mirrorMpvVolume, 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);
|
||||||
@@ -168,6 +169,31 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
|
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', () => {
|
test('parses updates config and warns on invalid values', () => {
|
||||||
const validDir = makeTempDir();
|
const validDir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
animatedCrf: 35,
|
animatedCrf: 35,
|
||||||
syncAnimatedImageToWordAudio: true,
|
syncAnimatedImageToWordAudio: true,
|
||||||
normalizeAudio: true,
|
normalizeAudio: true,
|
||||||
|
mirrorMpvVolume: true,
|
||||||
audioPadding: 0,
|
audioPadding: 0,
|
||||||
fallbackDuration: 3.0,
|
fallbackDuration: 3.0,
|
||||||
maxMediaDuration: 30,
|
maxMediaDuration: 30,
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'ankiConnect.enabled',
|
'ankiConnect.enabled',
|
||||||
'subtitleStyle.nameMatchEnabled',
|
'subtitleStyle.nameMatchEnabled',
|
||||||
'ankiConnect.media.normalizeAudio',
|
'ankiConnect.media.normalizeAudio',
|
||||||
|
'ankiConnect.media.mirrorMpvVolume',
|
||||||
'anilist.characterDictionary.collapsibleSections.description',
|
'anilist.characterDictionary.collapsibleSections.description',
|
||||||
'mpv.executablePath',
|
'mpv.executablePath',
|
||||||
'mpv.launchMode',
|
'mpv.launchMode',
|
||||||
|
|||||||
@@ -185,7 +185,15 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
path: 'ankiConnect.media.normalizeAudio',
|
path: 'ankiConnect.media.normalizeAudio',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
defaultValue: defaultConfig.ankiConnect.media.normalizeAudio,
|
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',
|
path: 'ankiConnect.media.generateImage',
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'AnkiConnect Integration',
|
title: 'AnkiConnect Integration',
|
||||||
description: ['Automatic Anki updates and media generation options.'],
|
description: ['Automatic Anki updates and media generation options.'],
|
||||||
notes: [
|
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.',
|
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||||
'Most other AnkiConnect settings still 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')) {
|
if (hasOwn(behavior, 'notificationType')) {
|
||||||
const parsed = asNotificationType(behavior.notificationType);
|
const parsed = asNotificationType(behavior.notificationType);
|
||||||
if (parsed === undefined) {
|
if (parsed === undefined) {
|
||||||
|
|||||||
@@ -165,6 +165,15 @@ test('settings registry exposes specialized controls for config-assisted inputs'
|
|||||||
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
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', () => {
|
test('settings registry exposes YouTube media cache mode as a labeled select', () => {
|
||||||
const mediaCacheMode = field('youtube.mediaCache.mode');
|
const mediaCacheMode = field('youtube.mediaCache.mode');
|
||||||
const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight');
|
const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight');
|
||||||
@@ -313,6 +322,8 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
|
|||||||
'subsync.replace',
|
'subsync.replace',
|
||||||
'ankiConnect.behavior.autoUpdateNewCards',
|
'ankiConnect.behavior.autoUpdateNewCards',
|
||||||
'ankiConnect.deck',
|
'ankiConnect.deck',
|
||||||
|
'ankiConnect.media.normalizeAudio',
|
||||||
|
'ankiConnect.media.mirrorMpvVolume',
|
||||||
'ankiConnect.knownWords.highlightEnabled',
|
'ankiConnect.knownWords.highlightEnabled',
|
||||||
'ankiConnect.knownWords.refreshMinutes',
|
'ankiConnect.knownWords.refreshMinutes',
|
||||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
||||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||||
|
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
||||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -671,6 +672,8 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'secondarySub.defaultMode' ||
|
path === 'secondarySub.defaultMode' ||
|
||||||
path === 'ankiConnect.deck' ||
|
path === 'ankiConnect.deck' ||
|
||||||
path === 'ankiConnect.ai.enabled' ||
|
path === 'ankiConnect.ai.enabled' ||
|
||||||
|
path === 'ankiConnect.media.normalizeAudio' ||
|
||||||
|
path === 'ankiConnect.media.mirrorMpvVolume' ||
|
||||||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
||||||
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
||||||
path === 'ankiConnect.knownWords.refreshMinutes' ||
|
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.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
|
||||||
next.subsync.replace = !prev.subsync.replace;
|
next.subsync.replace = !prev.subsync.replace;
|
||||||
next.ankiConnect.deck = 'Mining';
|
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.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
|
||||||
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
|
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
|
||||||
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
|
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',
|
'jimaku.maxEntryResults',
|
||||||
'subsync.replace',
|
'subsync.replace',
|
||||||
'ankiConnect.deck',
|
'ankiConnect.deck',
|
||||||
|
'ankiConnect.media.normalizeAudio',
|
||||||
|
'ankiConnect.media.mirrorMpvVolume',
|
||||||
'ankiConnect.behavior.autoUpdateNewCards',
|
'ankiConnect.behavior.autoUpdateNewCards',
|
||||||
'ankiConnect.knownWords.highlightEnabled',
|
'ankiConnect.knownWords.highlightEnabled',
|
||||||
'ankiConnect.knownWords.refreshMinutes',
|
'ankiConnect.knownWords.refreshMinutes',
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
|||||||
'jimaku',
|
'jimaku',
|
||||||
'subsync',
|
'subsync',
|
||||||
'ankiConnect.deck',
|
'ankiConnect.deck',
|
||||||
|
'ankiConnect.media.normalizeAudio',
|
||||||
|
'ankiConnect.media.mirrorMpvVolume',
|
||||||
'ankiConnect.behavior.autoUpdateNewCards',
|
'ankiConnect.behavior.autoUpdateNewCards',
|
||||||
'ankiConnect.knownWords.highlightEnabled',
|
'ankiConnect.knownWords.highlightEnabled',
|
||||||
'ankiConnect.knownWords.refreshMinutes',
|
'ankiConnect.knownWords.refreshMinutes',
|
||||||
|
|||||||
@@ -149,6 +149,42 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
|||||||
assert.ok(calls.includes('broadcast:config:hot-reload'));
|
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', () => {
|
test('buildConfigHotReloadPayload includes independent primary subtitle mode', () => {
|
||||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||||
config.subtitleStyle.primaryDefaultMode = 'hover';
|
config.subtitleStyle.primaryDefaultMode = 'hover';
|
||||||
|
|||||||
@@ -93,6 +93,16 @@ function buildAnkiRuntimeConfigPatch(
|
|||||||
if (diff.hotReloadFields.includes('ankiConnect.deck')) {
|
if (diff.hotReloadFields.includes('ankiConnect.deck')) {
|
||||||
patch.deck = config.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'])) {
|
if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) {
|
||||||
patch.knownWords = config.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 () => {
|
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);
|
||||||
|
|||||||
+18
-1
@@ -25,6 +25,7 @@ import { normalizeMediaInput, type MediaInput } from './media-input';
|
|||||||
|
|
||||||
const log = createLogger('media');
|
const log = createLogger('media');
|
||||||
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-23:TP=-2:LRA=11';
|
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';
|
export type { MediaInput, MediaInputOptions } from './media-input';
|
||||||
|
|
||||||
@@ -266,6 +267,7 @@ export class MediaGenerator {
|
|||||||
padding: number = 0,
|
padding: number = 0,
|
||||||
audioStreamIndex: number | null = null,
|
audioStreamIndex: number | null = null,
|
||||||
normalizeAudio = true,
|
normalizeAudio = true,
|
||||||
|
volumeScale?: number,
|
||||||
): 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);
|
||||||
@@ -296,8 +298,23 @@ export class MediaGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
args.push('-vn');
|
args.push('-vn');
|
||||||
|
const audioFilters: string[] = [];
|
||||||
if (normalizeAudio) {
|
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);
|
args.push('-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface AnkiConnectConfig {
|
|||||||
animatedCrf?: number;
|
animatedCrf?: number;
|
||||||
syncAnimatedImageToWordAudio?: boolean;
|
syncAnimatedImageToWordAudio?: boolean;
|
||||||
normalizeAudio?: boolean;
|
normalizeAudio?: boolean;
|
||||||
|
mirrorMpvVolume?: boolean;
|
||||||
audioPadding?: number;
|
audioPadding?: number;
|
||||||
fallbackDuration?: number;
|
fallbackDuration?: number;
|
||||||
maxMediaDuration?: number;
|
maxMediaDuration?: number;
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ export interface ResolvedConfig {
|
|||||||
animatedCrf: number;
|
animatedCrf: number;
|
||||||
syncAnimatedImageToWordAudio: boolean;
|
syncAnimatedImageToWordAudio: boolean;
|
||||||
normalizeAudio: boolean;
|
normalizeAudio: boolean;
|
||||||
|
mirrorMpvVolume: boolean;
|
||||||
audioPadding: number;
|
audioPadding: number;
|
||||||
fallbackDuration: number;
|
fallbackDuration: number;
|
||||||
maxMediaDuration: number;
|
maxMediaDuration: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user