Compare commits

..

3 Commits

27 changed files with 392 additions and 53 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: mining
- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: youtube
- Fixed direct YouTube stream media extraction by parsing mpv EDL stream URLs with their byte-length guards, preventing trailing EDL segment options from corrupting signed googlevideo URLs and causing ffmpeg 403 errors.
+1
View File
@@ -559,6 +559,7 @@
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
+3 -1
View File
@@ -161,13 +161,14 @@ Audio is extracted from the video file using the subtitle's start and end timest
"ankiConnect": {
"media": {
"generateAudio": true,
"normalizeAudio": true, // normalize generated clip loudness
"audioPadding": 0, // optional seconds before and after subtitle timing
"maxMediaDuration": 30 // cap total duration in seconds
}
}
```
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream.
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness.
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
@@ -347,6 +348,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
"imageType": "static",
"imageFormat": "jpg",
"imageQuality": 92,
"normalizeAudio": true,
"audioPadding": 0,
"maxMediaDuration": 30,
},
+2
View File
@@ -951,6 +951,7 @@ Enable automatic Anki card creation and updates with media generation:
"animatedMaxWidth": 640,
"animatedMaxHeight": 0,
"animatedCrf": 35,
"normalizeAudio": true,
"audioPadding": 0,
"fallbackDuration": 3,
"maxMediaDuration": 30
@@ -1001,6 +1002,7 @@ This example is intentionally compact. The option table below documents availabl
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
+1
View File
@@ -559,6 +559,7 @@
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
-28
View File
@@ -1,28 +0,0 @@
## Highlights
### Fixed
- **YouTube Background Cache:** Fixed Windows background media cache startup for YouTube URLs opened directly in mpv.
- Resolved stream URLs are now tracked even when mpv still exposes the original YouTube playlist entry.
- Queued Anki media updates can append audio and images after the cache finishes instead of staying text-only.
- **YouTube Subtitle Picker Notifications:** Manual subtitle picker requests now show immediate status while SubMiner probes tracks and opens the modal.
- Subtitle download progress is replaced with a transient success notification after tracks load.
## What's Changed
- feat(youtube): notify on manual picker open and show success after track load by @ksyasuda in #133
- fix(youtube): recover source URL for background media cache on direct mpv open by @ksyasuda in #132
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+18 -4
View File
@@ -860,13 +860,18 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
assert.equal(storedMedia.length, 2);
});
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => {
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
const audioCalls: Array<{
path: string;
audioStreamIndex?: number;
normalizeAudio?: boolean;
}> = [];
const integration = new AnkiIntegration(
{
media: {
audioPadding: 0,
normalizeAudio: false,
},
},
{} as never,
@@ -896,13 +901,21 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
endTime: number,
audioPadding?: number,
audioStreamIndex?: number,
normalizeAudio?: boolean,
) => Promise<Buffer>;
};
generateAudio: () => Promise<Buffer | null>;
};
internals.mediaGenerator = {
generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => {
audioCalls.push({ path: path.path, audioStreamIndex });
generateAudio: async (
path,
_startTime,
_endTime,
_audioPadding,
audioStreamIndex,
normalizeAudio,
) => {
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio });
return Buffer.from('audio');
},
};
@@ -913,6 +926,7 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
{
path: '/tmp/subminer-youtube-media-cache/media.mkv',
audioStreamIndex: undefined,
normalizeAudio: false,
},
]);
});
+3
View File
@@ -348,6 +348,7 @@ export class AnkiIntegration {
endTime,
audioPadding,
audioStreamIndex,
this.config.media?.normalizeAudio !== false,
),
generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
@@ -502,6 +503,7 @@ export class AnkiIntegration {
endTime,
audioPadding,
audioStreamIndex,
this.config.media?.normalizeAudio !== false,
),
generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
@@ -996,6 +998,7 @@ export class AnkiIntegration {
endTime,
this.config.media?.audioPadding,
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
this.config.media?.normalizeAudio !== false,
);
}
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import { toMpvEdlValue } from './mpv-edl-test-utils';
import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki';
@@ -269,9 +270,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
'!global_tags,title=test',
].join(';');
@@ -354,8 +357,8 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
assert.deepEqual(audioPaths, [audioUrl]);
assert.deepEqual(imagePaths, [videoUrl]);
assert.equal(storedMedia.length, 2);
assert.equal(updatedFields.length, 1);
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
+7 -4
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import { toMpvEdlValue } from './mpv-edl-test-utils';
import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki';
@@ -290,9 +291,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
'!global_tags,title=test',
].join(';');
@@ -397,8 +400,8 @@ test('CardCreationService uses stream-open-filename for remote media generation'
const created = await service.createSentenceCard('テスト', 0, 1);
assert.equal(created, true);
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
assert.deepEqual(audioPaths, [audioUrl]);
assert.deepEqual(imagePaths, [videoUrl]);
});
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
+2
View File
@@ -65,6 +65,7 @@ interface CardCreationMediaGenerator {
endTime: number,
audioPadding?: number,
audioStreamIndex?: number,
normalizeAudio?: boolean,
): Promise<Buffer | null>;
generateScreenshot(
path: MediaInput,
@@ -842,6 +843,7 @@ export class CardCreationService {
videoPath,
mpvClient.currentAudioStreamIndex ?? undefined,
),
this.deps.getConfig().media?.normalizeAudio !== false,
);
}
+56 -7
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as mediaSource from './media-source';
import { toMpvEdlValue } from './mpv-edl-test-utils';
const { resolveMediaGenerationInputPath } = mediaSource;
@@ -53,9 +54,11 @@ test('resolveMediaGenerationInputPath prefers stream-open-filename for remote me
});
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
'!global_tags,title=test',
].join(';');
@@ -74,8 +77,52 @@ test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video
'video',
);
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4');
assert.equal(audioResult, audioUrl);
assert.equal(videoResult, videoUrl);
});
test('resolveMediaGenerationInputPath strips mpv edl segment options from unwrapped streams', async () => {
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const signedVideoUrl =
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
const edlSource = [
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(signedVideoUrl)},title=clip,length=73,timestamps=chapters`,
'!global_tags,title=test',
].join(';');
const result = await resolveMediaGenerationInputPath(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => edlSource,
},
'video',
);
assert.equal(result, signedVideoUrl);
});
test('resolveMediaGenerationInputPath ignores length-guarded URLs in mpv edl headers', async () => {
const initUrl = 'https://init.example/init.mp4';
const audioUrl = 'https://audio.example/stream';
const videoUrl = 'https://video.example/stream';
const edlSource = [
`edl://!mp4_dash,init=${toMpvEdlValue(initUrl)}`,
'!new_stream',
toMpvEdlValue(audioUrl),
'!new_stream',
toMpvEdlValue(videoUrl),
].join(';');
const audioResult = await resolveMediaGenerationInputPath(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => edlSource,
},
'audio',
);
assert.equal(audioResult, audioUrl);
});
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
@@ -97,9 +144,11 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
].join(';');
const result = await resolver!(
@@ -117,7 +166,7 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
'audio',
);
assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
assert.equal(result?.path, audioUrl);
assert.equal(result?.singleResolvedStream, true);
assert.equal(result?.inputOptions?.reconnect, true);
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
+3 -3
View File
@@ -1,6 +1,7 @@
import { isRemoteMediaPath } from '../jimaku/utils';
import type { MediaInput, MediaInputOptions } from '../media-input';
import type { MpvClient } from '../types/runtime';
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
export type MediaGenerationKind = 'audio' | 'video';
export type MediaGenerationInputSource =
@@ -73,9 +74,8 @@ function normalizeHeaderName(value: string): string | null {
}
function extractUrlsFromMpvEdlSource(source: string): string[] {
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
return [...matches]
.map((match) => trimToNonEmptyString(match[1]))
return extractFileUrlsFromMpvEdlSource(source)
.map((value) => trimToNonEmptyString(value))
.filter((value): value is string => value !== null);
}
@@ -0,0 +1,3 @@
export function toMpvEdlValue(value: string): string {
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
}
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
import { toMpvEdlValue } from './mpv-edl-test-utils';
test('extractFileUrlsFromMpvEdlSource honors length-guarded file values', () => {
const url =
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
const source = `edl://!new_stream;${toMpvEdlValue(url)},title=clip,length=73`;
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [url]);
});
test('extractFileUrlsFromMpvEdlSource reads file parameters', () => {
const initUrl = 'https://init.example/init.mp4';
const fileUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const source = `edl://!mp4_dash,init=${toMpvEdlValue(initUrl)};file=${toMpvEdlValue(
fileUrl,
)},length=42`;
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [fileUrl]);
});
test('extractFileUrlsFromMpvEdlSource aggregates file URLs across entries', () => {
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const source = [
'edl://!new_stream',
toMpvEdlValue(audioUrl),
'!new_stream',
`file=${toMpvEdlValue(videoUrl)},length=50`,
'!global_tags,title=test',
].join(';');
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [audioUrl, videoUrl]);
});
+195
View File
@@ -0,0 +1,195 @@
const EDL_URI_PREFIX = 'edl://';
const BYTE_COMMA = ','.charCodeAt(0);
const BYTE_CR = '\r'.charCodeAt(0);
const BYTE_EQUALS = '='.charCodeAt(0);
const BYTE_EXCLAMATION = '!'.charCodeAt(0);
const BYTE_LF = '\n'.charCodeAt(0);
const BYTE_PERCENT = '%'.charCodeAt(0);
const BYTE_SEMICOLON = ';'.charCodeAt(0);
function isDigitByte(value: number | undefined): value is number {
return value !== undefined && value >= 48 && value <= 57;
}
function isEntrySeparator(value: number | undefined): boolean {
return value === BYTE_SEMICOLON || value === BYTE_LF || value === BYTE_CR;
}
function isParamSeparator(value: number | undefined): boolean {
return value === BYTE_COMMA || isEntrySeparator(value);
}
function decodeBytes(buffer: Buffer, start: number, end: number): string {
return buffer.subarray(start, end).toString('utf8');
}
function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}
function toEdlDataBuffer(source: string): Buffer {
const data = source.startsWith(EDL_URI_PREFIX) ? source.slice(EDL_URI_PREFIX.length) : source;
return Buffer.from(data, 'utf8');
}
function parseLengthGuardedValue(
buffer: Buffer,
position: number,
): { value: string; end: number } | null {
if (buffer[position] !== BYTE_PERCENT) {
return null;
}
let cursor = position + 1;
if (!isDigitByte(buffer[cursor])) {
return null;
}
let byteLength = 0;
while (true) {
const digit = buffer[cursor];
if (!isDigitByte(digit)) {
break;
}
byteLength = byteLength * 10 + (digit - 48);
cursor += 1;
}
if (buffer[cursor] !== BYTE_PERCENT) {
return null;
}
const valueStart = cursor + 1;
const valueEnd = valueStart + byteLength;
if (valueEnd > buffer.length) {
return null;
}
return {
value: decodeBytes(buffer, valueStart, valueEnd),
end: valueEnd,
};
}
function skipEntrySeparators(buffer: Buffer, position: number): number {
let cursor = position;
while (cursor < buffer.length && isEntrySeparator(buffer[cursor])) {
cursor += 1;
}
return cursor;
}
function skipEntry(buffer: Buffer, position: number): number {
let cursor = position;
while (cursor < buffer.length) {
const guardedValue = parseLengthGuardedValue(buffer, cursor);
if (guardedValue) {
cursor = guardedValue.end;
continue;
}
if (isEntrySeparator(buffer[cursor])) {
break;
}
cursor += 1;
}
return cursor;
}
function parseRawValue(buffer: Buffer, position: number): { value: string; end: number } {
let cursor = position;
while (
cursor < buffer.length &&
!isParamSeparator(buffer[cursor]) &&
buffer[cursor] !== BYTE_EXCLAMATION
) {
cursor += 1;
}
return {
value: decodeBytes(buffer, position, cursor),
end: cursor,
};
}
function parseParamValue(buffer: Buffer, position: number): { value: string; end: number } {
return parseLengthGuardedValue(buffer, position) ?? parseRawValue(buffer, position);
}
function parseOptionalParamName(
buffer: Buffer,
position: number,
): { name: string | null; valueStart: number } {
let cursor = position;
while (
cursor < buffer.length &&
!isParamSeparator(buffer[cursor]) &&
buffer[cursor] !== BYTE_PERCENT &&
buffer[cursor] !== BYTE_EXCLAMATION
) {
if (buffer[cursor] === BYTE_EQUALS) {
return {
name: decodeBytes(buffer, position, cursor),
valueStart: cursor + 1,
};
}
cursor += 1;
}
return { name: null, valueStart: position };
}
function parseSegmentEntry(buffer: Buffer, position: number): { urls: string[]; end: number } {
const urls: string[] = [];
let cursor = position;
let unnamedParamIndex = 0;
while (cursor < buffer.length && !isEntrySeparator(buffer[cursor])) {
const { name, valueStart } = parseOptionalParamName(buffer, cursor);
const value = parseParamValue(buffer, valueStart);
const lowerName = name?.toLowerCase() ?? null;
const isFileParam = lowerName === 'file' || (lowerName === null && unnamedParamIndex === 0);
if (isFileParam && isHttpUrl(value.value)) {
urls.push(value.value);
}
if (lowerName === null) {
unnamedParamIndex += 1;
}
cursor = value.end;
if (buffer[cursor] === BYTE_COMMA) {
cursor += 1;
continue;
}
if (!isEntrySeparator(buffer[cursor])) {
cursor = skipEntry(buffer, cursor);
}
}
return { urls, end: cursor };
}
export function extractFileUrlsFromMpvEdlSource(source: string): string[] {
const buffer = toEdlDataBuffer(source);
const urls: string[] = [];
let cursor = 0;
while (cursor < buffer.length) {
cursor = skipEntrySeparators(buffer, cursor);
if (cursor >= buffer.length) {
break;
}
if (buffer[cursor] === BYTE_EXCLAMATION) {
cursor = skipEntry(buffer, cursor);
continue;
}
const segment = parseSegmentEntry(buffer, cursor);
urls.push(...segment.urls);
cursor = segment.end;
}
return urls;
}
@@ -272,6 +272,7 @@ export class PendingYoutubeMediaQueue {
job.endTime,
config.media?.audioPadding,
undefined,
config.media?.normalizeAudio !== false,
);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
+1
View File
@@ -92,6 +92,7 @@ test('loads defaults when config is missing', () => {
model: '',
systemPrompt: '',
});
assert.equal(config.ankiConnect.media.normalizeAudio, true);
assert.equal(config.startupWarmups.lowPowerMode, false);
assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true);
@@ -51,6 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
animatedMaxHeight: 0,
animatedCrf: 35,
syncAnimatedImageToWordAudio: true,
normalizeAudio: true,
audioPadding: 0,
fallbackDuration: 3.0,
maxMediaDuration: 30,
@@ -110,6 +110,7 @@ test('config option registry includes critical paths and has unique entries', ()
'subtitleStyle.autoPauseVideoOnYomitanPopup',
'ankiConnect.enabled',
'subtitleStyle.nameMatchEnabled',
'ankiConnect.media.normalizeAudio',
'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath',
'mpv.launchMode',
@@ -181,6 +181,12 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
description: 'Generate sentence audio for mined cards.',
},
{
path: 'ankiConnect.media.normalizeAudio',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.media.normalizeAudio,
description: 'Normalize generated sentence audio loudness during media extraction.',
},
{
path: 'ankiConnect.media.generateImage',
kind: 'boolean',
+9 -1
View File
@@ -1206,6 +1206,7 @@ export function createStatsApp(
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
const normalizeAudio = ankiConfig.media?.normalizeAudio !== false;
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
const startSec = startMs / 1000;
@@ -1228,7 +1229,14 @@ export function createStatsApp(
const audioPromise = generateAudio
? timeMiningPhase(mode, 'generateAudio', () =>
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
mediaGen.generateAudio(
sourcePath,
startSec,
clampedEndSec,
audioPadding,
null,
normalizeAudio,
),
)
: Promise.resolve(null);
+18
View File
@@ -163,6 +163,24 @@ test('generateAudio defaults to unpadded sentence timing', async () => {
});
});
test('generateAudio normalizes sentence audio by default', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAudio('/video.mp4', 10, 12);
const args = readFfmpegArgs(argsPath);
assert.equal(args[args.indexOf('-af') + 1], 'loudnorm=I=-23:TP=-2:LRA=11');
});
});
test('generateAudio can preserve raw sentence audio loudness', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAudio('/video.mp4', 10, 12, 0, null, false);
const args = readFfmpegArgs(argsPath);
assert.equal(args.includes('-af'), false);
});
});
test('generateAudio clips leading padding without adding it to trailing duration', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5);
+7 -1
View File
@@ -24,6 +24,7 @@ import { createLogger } from './logger';
import { normalizeMediaInput, type MediaInput } from './media-input';
const log = createLogger('media');
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-23:TP=-2:LRA=11';
export type { MediaInput, MediaInputOptions } from './media-input';
@@ -264,6 +265,7 @@ export class MediaGenerator {
endTime: number,
padding: number = 0,
audioStreamIndex: number | null = null,
normalizeAudio = true,
): Promise<Buffer> {
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
const start = Math.max(0, startTime - safePadding);
@@ -293,7 +295,11 @@ export class MediaGenerator {
args.push('-map', `0:${audioStreamIndex}`);
}
args.push('-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
args.push('-vn');
if (normalizeAudio) {
args.push('-af', AUDIO_NORMALIZATION_FILTER);
}
args.push('-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
this.logMediaDebug(
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
+1
View File
@@ -74,6 +74,7 @@ export interface AnkiConnectConfig {
animatedMaxHeight?: number;
animatedCrf?: number;
syncAnimatedImageToWordAudio?: boolean;
normalizeAudio?: boolean;
audioPadding?: number;
fallbackDuration?: number;
maxMediaDuration?: number;
+1
View File
@@ -235,6 +235,7 @@ export interface ResolvedConfig {
animatedMaxHeight?: number;
animatedCrf: number;
syncAnimatedImageToWordAudio: boolean;
normalizeAudio: boolean;
audioPadding: number;
fallbackDuration: number;
maxMediaDuration: number;