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:
@@ -10,6 +10,8 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
const addedFields: Record<string, string>[] = [];
|
||||
const updatedFields: Record<string, string>[] = [];
|
||||
const storedMedia: string[] = [];
|
||||
const requestedProperties: string[] = [];
|
||||
const audioVolumeScales: Array<number | undefined> = [];
|
||||
|
||||
const deps: CardCreationDeps = {
|
||||
getConfig: () =>
|
||||
@@ -24,6 +26,7 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
mirrorMpvVolume: true,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {},
|
||||
@@ -39,6 +42,10 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
currentSubEnd: 14,
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
requestProperty: async (name: string) => {
|
||||
requestedProperties.push(name);
|
||||
return 40;
|
||||
},
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async (_deck, _modelName, fields) => {
|
||||
@@ -68,7 +75,18 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateAudio: async (
|
||||
_path,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
_audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioVolumeScales.push(volumeScale);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
@@ -124,6 +142,8 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
Expression: '字幕',
|
||||
});
|
||||
assert.equal(storedMedia.length, 1);
|
||||
assert.deepEqual(requestedProperties, ['volume']);
|
||||
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
|
||||
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||
|
||||
@@ -525,6 +525,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
||||
miscInfoFieldName?: string;
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
volumeScale?: number;
|
||||
}> = [];
|
||||
let streamRequests = 0;
|
||||
|
||||
@@ -556,7 +557,10 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
currentAudioStreamIndex: 2,
|
||||
requestProperty: async () => {
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'volume') {
|
||||
return 35;
|
||||
}
|
||||
streamRequests += 1;
|
||||
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
|
||||
},
|
||||
@@ -652,6 +656,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
||||
miscInfoFieldName: 'MiscInfo',
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
volumeScale: 0.35 ** 3,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(updates, []);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './media-source';
|
||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||
|
||||
const log = createLogger('anki').child('integration.card-creation');
|
||||
|
||||
@@ -66,6 +67,7 @@ interface CardCreationMediaGenerator {
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
volumeScale?: number,
|
||||
): Promise<Buffer | null>;
|
||||
generateScreenshot(
|
||||
path: MediaInput,
|
||||
@@ -714,6 +716,12 @@ export class CardCreationService {
|
||||
|
||||
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
||||
if (shouldQueuePendingYoutubeMedia) {
|
||||
const volumeScale = generateAudio
|
||||
? await resolveMpvVolumeScale(
|
||||
mpvClient,
|
||||
this.deps.getConfig().media?.mirrorMpvVolume !== false,
|
||||
)
|
||||
: undefined;
|
||||
this.deps.queuePendingYoutubeMediaUpdate?.({
|
||||
sourceUrl:
|
||||
trimToNonEmptyString(await this.deps.getYoutubeMediaSourceUrl?.()) ??
|
||||
@@ -727,6 +735,7 @@ export class CardCreationService {
|
||||
miscInfoFieldName: resolvedMiscInfoField ?? undefined,
|
||||
generateAudio,
|
||||
generateImage,
|
||||
volumeScale,
|
||||
});
|
||||
await this.deps.showNotification(noteId, label, 'media queued');
|
||||
return true;
|
||||
@@ -844,6 +853,10 @@ export class CardCreationService {
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
),
|
||||
this.deps.getConfig().media?.normalizeAudio !== false,
|
||||
await resolveMpvVolumeScale(
|
||||
mpvClient,
|
||||
this.deps.getConfig().media?.mirrorMpvVolume !== false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||
|
||||
test('resolveMpvVolumeScale converts numeric mpv volume with mpv software volume curve', async () => {
|
||||
const requested: string[] = [];
|
||||
|
||||
const scale = await resolveMpvVolumeScale(
|
||||
{
|
||||
requestProperty: async (name) => {
|
||||
requested.push(name);
|
||||
return 75;
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(scale, 0.421875);
|
||||
assert.deepEqual(requested, ['volume']);
|
||||
});
|
||||
|
||||
test('resolveMpvVolumeScale skips mpv when mirroring is disabled', async () => {
|
||||
let requested = false;
|
||||
|
||||
const scale = await resolveMpvVolumeScale(
|
||||
{
|
||||
requestProperty: async () => {
|
||||
requested = true;
|
||||
return 50;
|
||||
},
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(scale, undefined);
|
||||
assert.equal(requested, false);
|
||||
});
|
||||
|
||||
test('resolveMpvVolumeScale falls back to unity for missing, failed, or invalid values', async () => {
|
||||
assert.equal(await resolveMpvVolumeScale({}, true), 1);
|
||||
assert.equal(
|
||||
await resolveMpvVolumeScale(
|
||||
{
|
||||
requestProperty: async () => {
|
||||
throw new Error('disconnected');
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
1,
|
||||
);
|
||||
assert.equal(await resolveMpvVolumeScale({ requestProperty: async () => '50' }, true), 1);
|
||||
assert.equal(await resolveMpvVolumeScale({ requestProperty: async () => Number.NaN }, true), 1);
|
||||
assert.equal(await resolveMpvVolumeScale({ requestProperty: async () => -1 }, true), 1);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface MpvVolumePropertySource {
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function resolveMpvVolumeScale(
|
||||
mpvClient: MpvVolumePropertySource,
|
||||
enabled: boolean,
|
||||
): Promise<number | undefined> {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!mpvClient.requestProperty) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const volume = await mpvClient.requestProperty('volume');
|
||||
if (typeof volume !== 'number' || !Number.isFinite(volume) || volume < 0) {
|
||||
return 1;
|
||||
}
|
||||
return (volume / 100) ** 3;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ function createDeps(
|
||||
resolveConfiguredFieldName: () => 'Picture',
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
getMpvVolumeScale: async () => 1,
|
||||
generateAudioFilename: () => 'audio.mp3',
|
||||
generateImageFilename: () => 'image.webp',
|
||||
formatMiscInfoPatternForMediaPath: () => '',
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface PendingYoutubeMediaQueueDeps {
|
||||
) => string | null;
|
||||
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
|
||||
getAnimatedImageLeadInSeconds: (noteInfo: PendingYoutubeMediaNoteInfo) => Promise<number>;
|
||||
getMpvVolumeScale: () => Promise<number | undefined>;
|
||||
generateAudioFilename: () => string;
|
||||
generateImageFilename: () => string;
|
||||
formatMiscInfoPatternForMediaPath: (
|
||||
@@ -126,6 +127,9 @@ export class PendingYoutubeMediaQueue {
|
||||
|
||||
const config = this.deps.getConfig();
|
||||
const mediaRange = this.deps.getSubtitleMediaRange(job.context);
|
||||
const volumeScale = shouldGenerateAudio(config)
|
||||
? await this.deps.getMpvVolumeScale()
|
||||
: undefined;
|
||||
this.enqueue({
|
||||
sourceUrl,
|
||||
noteId: job.noteId,
|
||||
@@ -143,6 +147,7 @@ export class PendingYoutubeMediaQueue {
|
||||
this.deps.resolveConfiguredFieldName(job.noteInfo, config.fields?.miscInfo) ?? undefined,
|
||||
generateAudio: shouldGenerateAudio(config),
|
||||
generateImage: shouldGenerateImage(config),
|
||||
volumeScale,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -273,6 +278,7 @@ export class PendingYoutubeMediaQueue {
|
||||
config.media?.audioPadding,
|
||||
undefined,
|
||||
config.media?.normalizeAudio !== false,
|
||||
job.volumeScale,
|
||||
);
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface PendingYoutubeMediaUpdate {
|
||||
miscInfoFieldName?: string;
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
volumeScale?: number;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
|
||||
Reference in New Issue
Block a user