feat(anki): mirror mpv software volume into generated sentence audio (#155)

This commit is contained in:
2026-07-10 00:44:23 -07:00
committed by GitHub
parent 84c75f50aa
commit 7b1a005a65
31 changed files with 419 additions and 23 deletions
@@ -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);
+6 -1
View File
@@ -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, []);
+13
View File
@@ -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,
),
);
}
+56
View File
@@ -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);
});
+26
View File
@@ -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 {