mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
Normalize generated Anki audio by default (#135)
This commit is contained in:
@@ -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.
|
||||||
@@ -559,6 +559,7 @@
|
|||||||
"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
|
||||||
"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.
|
||||||
|
|||||||
@@ -161,13 +161,14 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
|||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true,
|
"generateAudio": true,
|
||||||
|
"normalizeAudio": true, // normalize generated clip loudness
|
||||||
"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.
|
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]`.
|
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",
|
"imageType": "static",
|
||||||
"imageFormat": "jpg",
|
"imageFormat": "jpg",
|
||||||
"imageQuality": 92,
|
"imageQuality": 92,
|
||||||
|
"normalizeAudio": true,
|
||||||
"audioPadding": 0,
|
"audioPadding": 0,
|
||||||
"maxMediaDuration": 30,
|
"maxMediaDuration": 30,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -951,6 +951,7 @@ Enable automatic Anki card creation and updates with media generation:
|
|||||||
"animatedMaxWidth": 640,
|
"animatedMaxWidth": 640,
|
||||||
"animatedMaxHeight": 0,
|
"animatedMaxHeight": 0,
|
||||||
"animatedCrf": 35,
|
"animatedCrf": 35,
|
||||||
|
"normalizeAudio": true,
|
||||||
"audioPadding": 0,
|
"audioPadding": 0,
|
||||||
"fallbackDuration": 3,
|
"fallbackDuration": 3,
|
||||||
"maxMediaDuration": 30
|
"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.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.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"`) |
|
||||||
|
|||||||
@@ -559,6 +559,7 @@
|
|||||||
"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
|
||||||
"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.
|
||||||
|
|||||||
@@ -860,13 +860,18 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
|||||||
assert.equal(storedMedia.length, 2);
|
assert.equal(storedMedia.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => {
|
test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
|
||||||
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
|
const audioCalls: Array<{
|
||||||
|
path: string;
|
||||||
|
audioStreamIndex?: number;
|
||||||
|
normalizeAudio?: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
media: {
|
media: {
|
||||||
audioPadding: 0,
|
audioPadding: 0,
|
||||||
|
normalizeAudio: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -896,13 +901,21 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
|
|||||||
endTime: number,
|
endTime: number,
|
||||||
audioPadding?: number,
|
audioPadding?: number,
|
||||||
audioStreamIndex?: number,
|
audioStreamIndex?: number,
|
||||||
|
normalizeAudio?: boolean,
|
||||||
) => Promise<Buffer>;
|
) => Promise<Buffer>;
|
||||||
};
|
};
|
||||||
generateAudio: () => Promise<Buffer | null>;
|
generateAudio: () => Promise<Buffer | null>;
|
||||||
};
|
};
|
||||||
internals.mediaGenerator = {
|
internals.mediaGenerator = {
|
||||||
generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => {
|
generateAudio: async (
|
||||||
audioCalls.push({ path: path.path, audioStreamIndex });
|
path,
|
||||||
|
_startTime,
|
||||||
|
_endTime,
|
||||||
|
_audioPadding,
|
||||||
|
audioStreamIndex,
|
||||||
|
normalizeAudio,
|
||||||
|
) => {
|
||||||
|
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio });
|
||||||
return Buffer.from('audio');
|
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',
|
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||||
audioStreamIndex: undefined,
|
audioStreamIndex: undefined,
|
||||||
|
normalizeAudio: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ export class AnkiIntegration {
|
|||||||
endTime,
|
endTime,
|
||||||
audioPadding,
|
audioPadding,
|
||||||
audioStreamIndex,
|
audioStreamIndex,
|
||||||
|
this.config.media?.normalizeAudio !== false,
|
||||||
),
|
),
|
||||||
generateScreenshot: (videoPath, timestamp, options) =>
|
generateScreenshot: (videoPath, timestamp, options) =>
|
||||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||||
@@ -502,6 +503,7 @@ export class AnkiIntegration {
|
|||||||
endTime,
|
endTime,
|
||||||
audioPadding,
|
audioPadding,
|
||||||
audioStreamIndex,
|
audioStreamIndex,
|
||||||
|
this.config.media?.normalizeAudio !== false,
|
||||||
),
|
),
|
||||||
generateScreenshot: (videoPath, timestamp, options) =>
|
generateScreenshot: (videoPath, timestamp, options) =>
|
||||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||||
@@ -996,6 +998,7 @@ export class AnkiIntegration {
|
|||||||
endTime,
|
endTime,
|
||||||
this.config.media?.audioPadding,
|
this.config.media?.audioPadding,
|
||||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||||
|
this.config.media?.normalizeAudio !== false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ interface CardCreationMediaGenerator {
|
|||||||
endTime: number,
|
endTime: number,
|
||||||
audioPadding?: number,
|
audioPadding?: number,
|
||||||
audioStreamIndex?: number,
|
audioStreamIndex?: number,
|
||||||
|
normalizeAudio?: boolean,
|
||||||
): Promise<Buffer | null>;
|
): Promise<Buffer | null>;
|
||||||
generateScreenshot(
|
generateScreenshot(
|
||||||
path: MediaInput,
|
path: MediaInput,
|
||||||
@@ -842,6 +843,7 @@ export class CardCreationService {
|
|||||||
videoPath,
|
videoPath,
|
||||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||||
),
|
),
|
||||||
|
this.deps.getConfig().media?.normalizeAudio !== false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
job.endTime,
|
job.endTime,
|
||||||
config.media?.audioPadding,
|
config.media?.audioPadding,
|
||||||
undefined,
|
undefined,
|
||||||
|
config.media?.normalizeAudio !== false,
|
||||||
);
|
);
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
model: '',
|
model: '',
|
||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
});
|
});
|
||||||
|
assert.equal(config.ankiConnect.media.normalizeAudio, 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);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
animatedMaxHeight: 0,
|
animatedMaxHeight: 0,
|
||||||
animatedCrf: 35,
|
animatedCrf: 35,
|
||||||
syncAnimatedImageToWordAudio: true,
|
syncAnimatedImageToWordAudio: true,
|
||||||
|
normalizeAudio: true,
|
||||||
audioPadding: 0,
|
audioPadding: 0,
|
||||||
fallbackDuration: 3.0,
|
fallbackDuration: 3.0,
|
||||||
maxMediaDuration: 30,
|
maxMediaDuration: 30,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
'ankiConnect.enabled',
|
'ankiConnect.enabled',
|
||||||
'subtitleStyle.nameMatchEnabled',
|
'subtitleStyle.nameMatchEnabled',
|
||||||
|
'ankiConnect.media.normalizeAudio',
|
||||||
'anilist.characterDictionary.collapsibleSections.description',
|
'anilist.characterDictionary.collapsibleSections.description',
|
||||||
'mpv.executablePath',
|
'mpv.executablePath',
|
||||||
'mpv.launchMode',
|
'mpv.launchMode',
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
|
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
|
||||||
description: 'Generate sentence audio for mined cards.',
|
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',
|
path: 'ankiConnect.media.generateImage',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -1206,6 +1206,7 @@ export function createStatsApp(
|
|||||||
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
||||||
|
|
||||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||||
|
const normalizeAudio = ankiConfig.media?.normalizeAudio !== false;
|
||||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||||
|
|
||||||
const startSec = startMs / 1000;
|
const startSec = startMs / 1000;
|
||||||
@@ -1228,7 +1229,14 @@ export function createStatsApp(
|
|||||||
|
|
||||||
const audioPromise = generateAudio
|
const audioPromise = generateAudio
|
||||||
? timeMiningPhase(mode, 'generateAudio', () =>
|
? timeMiningPhase(mode, 'generateAudio', () =>
|
||||||
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
|
mediaGen.generateAudio(
|
||||||
|
sourcePath,
|
||||||
|
startSec,
|
||||||
|
clampedEndSec,
|
||||||
|
audioPadding,
|
||||||
|
null,
|
||||||
|
normalizeAudio,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
|
|
||||||
|
|||||||
@@ -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=-18:TP=-1.5: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 () => {
|
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);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { createLogger } from './logger';
|
|||||||
import { normalizeMediaInput, type MediaInput } from './media-input';
|
import { normalizeMediaInput, type MediaInput } from './media-input';
|
||||||
|
|
||||||
const log = createLogger('media');
|
const log = createLogger('media');
|
||||||
|
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-18:TP=-1.5:LRA=11';
|
||||||
|
|
||||||
export type { MediaInput, MediaInputOptions } from './media-input';
|
export type { MediaInput, MediaInputOptions } from './media-input';
|
||||||
|
|
||||||
@@ -264,6 +265,7 @@ export class MediaGenerator {
|
|||||||
endTime: number,
|
endTime: number,
|
||||||
padding: number = 0,
|
padding: number = 0,
|
||||||
audioStreamIndex: number | null = null,
|
audioStreamIndex: number | null = null,
|
||||||
|
normalizeAudio = true,
|
||||||
): 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);
|
||||||
@@ -293,7 +295,11 @@ export class MediaGenerator {
|
|||||||
args.push('-map', `0:${audioStreamIndex}`);
|
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(
|
this.logMediaDebug(
|
||||||
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
|
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface AnkiConnectConfig {
|
|||||||
animatedMaxHeight?: number;
|
animatedMaxHeight?: number;
|
||||||
animatedCrf?: number;
|
animatedCrf?: number;
|
||||||
syncAnimatedImageToWordAudio?: boolean;
|
syncAnimatedImageToWordAudio?: boolean;
|
||||||
|
normalizeAudio?: boolean;
|
||||||
audioPadding?: number;
|
audioPadding?: number;
|
||||||
fallbackDuration?: number;
|
fallbackDuration?: number;
|
||||||
maxMediaDuration?: number;
|
maxMediaDuration?: number;
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ export interface ResolvedConfig {
|
|||||||
animatedMaxHeight?: number;
|
animatedMaxHeight?: number;
|
||||||
animatedCrf: number;
|
animatedCrf: number;
|
||||||
syncAnimatedImageToWordAudio: boolean;
|
syncAnimatedImageToWordAudio: boolean;
|
||||||
|
normalizeAudio: boolean;
|
||||||
audioPadding: number;
|
audioPadding: number;
|
||||||
fallbackDuration: number;
|
fallbackDuration: number;
|
||||||
maxMediaDuration: number;
|
maxMediaDuration: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user