mulmocast 2.18.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15465,6 +15465,106 @@
15465
15465
  "soundEffectPrompt": {
15466
15466
  "type": "string"
15467
15467
  },
15468
+ "soundEffects": {
15469
+ "description": "Sound effects mixed into the audio track during this beat. Multiple effects may overlap.",
15470
+ "type": "array",
15471
+ "items": {
15472
+ "type": "object",
15473
+ "properties": {
15474
+ "source": {
15475
+ "oneOf": [
15476
+ {
15477
+ "type": "object",
15478
+ "properties": {
15479
+ "kind": {
15480
+ "type": "string",
15481
+ "const": "url"
15482
+ },
15483
+ "url": {
15484
+ "type": "string",
15485
+ "format": "uri"
15486
+ }
15487
+ },
15488
+ "required": [
15489
+ "kind",
15490
+ "url"
15491
+ ],
15492
+ "additionalProperties": false
15493
+ },
15494
+ {
15495
+ "type": "object",
15496
+ "properties": {
15497
+ "kind": {
15498
+ "type": "string",
15499
+ "const": "path"
15500
+ },
15501
+ "path": {
15502
+ "type": "string",
15503
+ "minLength": 1
15504
+ }
15505
+ },
15506
+ "required": [
15507
+ "kind",
15508
+ "path"
15509
+ ],
15510
+ "additionalProperties": false
15511
+ },
15512
+ {
15513
+ "type": "object",
15514
+ "properties": {
15515
+ "kind": {
15516
+ "type": "string",
15517
+ "const": "jinglescript"
15518
+ },
15519
+ "score": {
15520
+ "type": "object",
15521
+ "propertyNames": {
15522
+ "type": "string"
15523
+ },
15524
+ "additionalProperties": {},
15525
+ "description": "JingleScript score (format jinglescript/1), rendered to audio when the audio is generated"
15526
+ }
15527
+ },
15528
+ "required": [
15529
+ "kind",
15530
+ "score"
15531
+ ],
15532
+ "additionalProperties": false
15533
+ }
15534
+ ],
15535
+ "description": "Audio file of the sound effect (url or path), or a JingleScript score to synthesize"
15536
+ },
15537
+ "startAt": {
15538
+ "default": 0,
15539
+ "description": "Seconds from when the beat appears on screen (the same clock as its html animation) at which the sound effect plays. The first beat's narration starts audioParams.introPadding later",
15540
+ "type": "number",
15541
+ "minimum": 0
15542
+ },
15543
+ "volume": {
15544
+ "default": 1,
15545
+ "description": "Volume of the sound effect. 1.0 is the gain of the narration at its default volume; audioParams.audioVolume and ttsVolume do not affect sound effects",
15546
+ "type": "number",
15547
+ "minimum": 0,
15548
+ "maximum": 4
15549
+ },
15550
+ "duration": {
15551
+ "description": "Maximum play length in seconds; the effect is cut (with a short fade-out) after it. Default: the whole file, even past the end of the beat",
15552
+ "type": "number",
15553
+ "exclusiveMinimum": 0
15554
+ },
15555
+ "loop": {
15556
+ "description": "Repeat the effect until duration, or until the beat leaves the screen when duration is not set (e.g. a ticking clock)",
15557
+ "type": "boolean"
15558
+ }
15559
+ },
15560
+ "required": [
15561
+ "source",
15562
+ "startAt",
15563
+ "volume"
15564
+ ],
15565
+ "additionalProperties": false
15566
+ }
15567
+ },
15468
15568
  "htmlPrompt": {
15469
15569
  "type": "object",
15470
15570
  "properties": {
@@ -5,6 +5,7 @@ import { fileWriteAgent } from "@graphai/vanilla_node_agents";
5
5
  import { ttsOpenaiAgent, ttsGoogleAgent, ttsGeminiAgent, ttsElevenlabsAgent, ttsKotodamaAgent, addBGMAgent, combineAudioFilesAgent, mediaMockAgent, } from "../agents/index.js";
6
6
  import { audioGraphOption } from "./graph_option.js";
7
7
  import { createUsageCallback } from "../utils/usage_callback.js";
8
+ import { renderJingleScores } from "../utils/jinglescript.js";
8
9
  import { getAudioArtifactFilePath, getAudioFilePath, getGroupedAudioFilePath, getOutputStudioFilePath, resolveDirPath, defaultBGMPath, mkdir, writingMessage, } from "../utils/file.js";
9
10
  import { localizedText } from "../utils/utils.js";
10
11
  import { text2hash } from "../utils/utils_node.js";
@@ -202,7 +203,7 @@ export const audio_graph_data = {
202
203
  wait: ":combineFiles",
203
204
  voiceFile: ":audioCombinedFilePath",
204
205
  outputFile: ":audioArtifactFilePath",
205
- context: ":context",
206
+ context: ":combineFiles", // has studio.beats[].startAt, needed to place sound effects
206
207
  params: {
207
208
  musicFile: ":musicFile",
208
209
  },
@@ -268,6 +269,8 @@ export const audio = async (context, args) => {
268
269
  const outputStudioFilePath = getOutputStudioFilePath(outDirPath, fileName);
269
270
  mkdir(outDirPath);
270
271
  mkdir(audioSegmentDirPath);
272
+ // Before TTS, so an invalid score fails fast.
273
+ await renderJingleScores(context);
271
274
  const graph = new GraphAI(audio_graph_data, audioAgents, await audioGraphOption(context, settings));
272
275
  callbacks?.forEach((callback) => graph.registerCallback(callback));
273
276
  graph.registerCallback(createUsageCallback(context));
@@ -168,7 +168,10 @@ const renderAvatarTrack = async (plan, context, getAvatarScript) => {
168
168
  // rendered once, at the largest size it is shown; smaller placements scale it down
169
169
  const height = even(Math.max(...plan.placements.map((p) => percent(p.position.scale, canvas.height))));
170
170
  const segments = plan.segments.map((segment) => ({ ...segment, audioStamp: fileStamp(segment.audio) }));
171
- const identity = { version: 4, segments, source: plan.source, sourceStamp: await sourceStamp(plan.source), duration: plan.duration, height, padTop: PAD_TOP };
171
+ // bump `version` when the track would come out differently, including an avatarscript upgrade that
172
+ // changes its output (5: avatarscript 0.4.0 holds a phrase's last sound): a reused track needs no
173
+ // avatarscript, so its version cannot be part of this
174
+ const identity = { version: 5, segments, source: plan.source, sourceStamp: await sourceStamp(plan.source), duration: plan.duration, height, padTop: PAD_TOP };
172
175
  const hash = createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 12);
173
176
  const dir = MulmoStudioContextMethods.getImageProjectDirPath(context);
174
177
  const name = `avatar_${plan.speakerId.replace(/[^\w-]/g, "_")}_${hash}`;
@@ -1,13 +1,25 @@
1
1
  import type { AgentFunctionInfo } from "graphai";
2
2
  import { MulmoStudioContext } from "../types/index.js";
3
+ import { FfmpegContext } from "../utils/ffmpeg_utils.js";
3
4
  export declare const resolveAddBgmMixParams: (audioParams: MulmoStudioContext["presentationStyle"]["audioParams"]) => {
4
5
  useExplicitMix: boolean;
5
6
  voiceVolume: number;
6
7
  };
7
- export declare const resolveAddBgmFilterConfig: (useExplicitMix: boolean) => {
8
+ export declare const resolveAddBgmFilterConfig: (useExplicitMix: boolean, mixedInputId?: string) => {
8
9
  amixNormalize: string;
9
10
  mixedOutputId: string;
10
11
  limiterFilter: string | undefined;
11
12
  };
13
+ export type SoundEffectPlacement = {
14
+ file: string;
15
+ startAt: number;
16
+ volume: number;
17
+ duration?: number;
18
+ loop: boolean;
19
+ };
20
+ export declare const getSoundEffectPlacements: (context: MulmoStudioContext) => SoundEffectPlacement[];
21
+ export declare const soundEffectGain: (useExplicitMix: boolean) => 0.5 | 1;
22
+ export declare const buildSoundEffectFilters: (inputIds: number[], placements: SoundEffectPlacement[], gain: number, outputId: string) => string[];
23
+ export declare const addSoundEffects: (ffmpegContext: FfmpegContext, context: MulmoStudioContext) => "mixed" | "mixed_sfx";
12
24
  declare const addBGMAgentInfo: AgentFunctionInfo;
13
25
  export default addBGMAgentInfo;
@@ -1,7 +1,10 @@
1
1
  import { GraphAILogger } from "graphai";
2
2
  import { FfmpegContextAddInput, FfmpegContextInit, FfmpegContextGenerateOutput, ffmpegGetMediaDuration } from "../utils/ffmpeg_utils.js";
3
3
  import { MulmoStudioContextMethods } from "../methods/mulmo_studio_context.js";
4
+ import { MulmoMediaSourceMethods } from "../methods/mulmo_media_source.js";
4
5
  import { isFile } from "../utils/file.js";
6
+ import { userAssert } from "../utils/utils.js";
7
+ import { getJingleFilePath } from "../utils/jinglescript.js";
5
8
  import { agentGenerationError, agentFileNotExistError, audioAction, audioFileTarget } from "../utils/error_cause.js";
6
9
  export const resolveAddBgmMixParams = (audioParams) => {
7
10
  const useExplicitMix = audioParams.ttsVolume !== undefined;
@@ -11,14 +14,82 @@ export const resolveAddBgmMixParams = (audioParams) => {
11
14
  voiceVolume: audioParams.audioVolume * ttsVolume,
12
15
  };
13
16
  };
14
- export const resolveAddBgmFilterConfig = (useExplicitMix) => {
17
+ export const resolveAddBgmFilterConfig = (useExplicitMix, mixedInputId = "mixed") => {
15
18
  const amixNormalize = useExplicitMix ? ":normalize=0" : "";
16
19
  return {
17
20
  amixNormalize,
18
- mixedOutputId: useExplicitMix ? "mixed_limited" : "mixed",
19
- limiterFilter: useExplicitMix ? "[mixed]alimiter=limit=0.95:attack=5:release=50[mixed_limited]" : undefined,
21
+ mixedOutputId: useExplicitMix ? "mixed_limited" : mixedInputId,
22
+ limiterFilter: useExplicitMix ? `[${mixedInputId}]alimiter=limit=0.95:attack=5:release=50[mixed_limited]` : undefined,
20
23
  };
21
24
  };
25
+ // Absolute placement of every beat's sound effects on the final audio timeline. startAt counts from
26
+ // when the beat appears on screen, the same clock as its html animation (for the first beat, that is
27
+ // introPadding before its narration starts).
28
+ // The context must come from combineAudioFilesAgent, which sets studio.beats[].startAt.
29
+ export const getSoundEffectPlacements = (context) => {
30
+ return context.studio.script.beats.flatMap((beat, index) => (beat.soundEffects ?? []).map((soundEffect, seIndex) => {
31
+ const beatStartAt = context.studio.beats[index]?.startAt;
32
+ userAssert(beatStartAt !== undefined, `soundEffects: startAt of beat ${index} is not computed yet`);
33
+ const { source } = soundEffect;
34
+ const file = source.kind === "jinglescript" ? getJingleFilePath(source.score, context) : MulmoMediaSourceMethods.resolve(source, context);
35
+ userAssert(!!file, `soundEffects: unsupported source at beat ${index}, effect ${seIndex} (use url or path)`);
36
+ const startAt = soundEffect.startAt ?? 0;
37
+ const loop = soundEffect.loop ?? false;
38
+ // A looping effect without duration plays until the beat leaves the screen.
39
+ const duration = soundEffect.duration ?? (loop ? MulmoStudioContextMethods.getBeatScreenDuration(context, index) - startAt : undefined);
40
+ userAssert(duration === undefined || duration > 0, `soundEffects: beat ${index}, effect ${seIndex} starts after the end of the beat`);
41
+ return {
42
+ file,
43
+ startAt: MulmoStudioContextMethods.getBeatScreenStartAt(context, index) + startAt,
44
+ volume: soundEffect.volume ?? 1.0,
45
+ ...(duration !== undefined ? { duration } : {}),
46
+ loop,
47
+ };
48
+ }));
49
+ };
50
+ // In legacy mode, amix (normalize=1) halves the voice and the music. Sound effects are mixed in
51
+ // afterwards with normalize=0, so apply the same factor to keep volume 1.0 at the gain of the narration at its
52
+ // default volume. audioVolume / ttsVolume deliberately do not scale sound effects.
53
+ export const soundEffectGain = (useExplicitMix) => (useExplicitMix ? 1.0 : 0.5);
54
+ const soundEffectFadeOut = 0.05; // seconds, avoids a click where the effect is cut
55
+ const getSoundEffectTrim = (duration) => {
56
+ const fade = Math.min(soundEffectFadeOut, duration);
57
+ return `atrim=duration=${duration}, afade=t=out:st=${duration - fade}:d=${fade}, `;
58
+ };
59
+ export const buildSoundEffectFilters = (inputIds, placements, gain, outputId) => {
60
+ const ids = placements.map((_, index) => `se_${index}`);
61
+ const filters = placements.map((placement, index) => {
62
+ const delayMs = Math.round(placement.startAt * 1000);
63
+ const trim = placement.duration !== undefined ? getSoundEffectTrim(placement.duration) : "";
64
+ return `[${inputIds[index]}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo, ${trim}volume=${placement.volume * gain}, adelay=${delayMs}|${delayMs}[${ids[index]}]`;
65
+ });
66
+ if (ids.length === 1) {
67
+ return [...filters, `[${ids[0]}]anull[${outputId}]`];
68
+ }
69
+ const inputs = ids.map((id) => "[" + id + "]").join("");
70
+ return [...filters, `${inputs}amix=inputs=${ids.length}:duration=longest:normalize=0[${outputId}]`];
71
+ };
72
+ // Adds the beats' sound effects to ffmpegContext and mixes them onto [mixed].
73
+ // Returns the id of the resulting stream ("mixed" when there are none).
74
+ export const addSoundEffects = (ffmpegContext, context) => {
75
+ const { useExplicitMix } = resolveAddBgmMixParams(context.presentationStyle.audioParams);
76
+ const soundEffects = getSoundEffectPlacements(context);
77
+ if (soundEffects.length === 0) {
78
+ return "mixed";
79
+ }
80
+ soundEffects.forEach(({ file }) => {
81
+ if (!/^https?:\/\//.test(file) && !isFile(file)) {
82
+ throw new Error(`AddBGMAgent soundEffect file not exist: ${file}`, {
83
+ cause: agentFileNotExistError("addBGMAgent", audioAction, audioFileTarget, file),
84
+ });
85
+ }
86
+ });
87
+ GraphAILogger.log("soundEffects:", soundEffects);
88
+ const inputIds = soundEffects.map(({ file, loop }) => FfmpegContextAddInput(ffmpegContext, file, loop ? ["-stream_loop", "-1"] : undefined));
89
+ ffmpegContext.filterComplex.push(...buildSoundEffectFilters(inputIds, soundEffects, soundEffectGain(useExplicitMix), "sfx"));
90
+ ffmpegContext.filterComplex.push("[mixed][sfx]amix=inputs=2:duration=first:normalize=0[mixed_sfx]");
91
+ return "mixed_sfx";
92
+ };
22
93
  const addBGMAgent = async ({ namedInputs, params, }) => {
23
94
  const { voiceFile, outputFile, context } = namedInputs;
24
95
  const { musicFile } = params;
@@ -44,8 +115,10 @@ const addBGMAgent = async ({ namedInputs, params, }) => {
44
115
  const { useExplicitMix, voiceVolume } = resolveAddBgmMixParams(audioParams);
45
116
  ffmpegContext.filterComplex.push(`[${musicInputIndex}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo, volume=${audioParams.bgmVolume}[music]`);
46
117
  ffmpegContext.filterComplex.push(`[${voiceInputIndex}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo, volume=${voiceVolume}, adelay=${introPadding * 1000}|${introPadding * 1000}[voice]`);
47
- const { amixNormalize, mixedOutputId, limiterFilter } = resolveAddBgmFilterConfig(useExplicitMix);
118
+ const { amixNormalize } = resolveAddBgmFilterConfig(useExplicitMix);
48
119
  ffmpegContext.filterComplex.push(`[music][voice]amix=inputs=2:duration=longest${amixNormalize}[mixed]`);
120
+ const mixedInputId = addSoundEffects(ffmpegContext, context);
121
+ const { mixedOutputId, limiterFilter } = resolveAddBgmFilterConfig(useExplicitMix, mixedInputId);
49
122
  if (limiterFilter) {
50
123
  ffmpegContext.filterComplex.push(limiterFilter);
51
124
  }
@@ -23,6 +23,8 @@ export declare const MulmoStudioContextMethods: {
23
23
  needTranslate(context: MulmoStudioContext, includeCaption?: boolean): boolean | "" | undefined;
24
24
  getIntroPadding(context: MulmoStudioContext): number;
25
25
  getExtraPadding(context: MulmoStudioContext, index: number): number;
26
+ getBeatScreenStartAt(context: MulmoStudioContext, index: number): number;
27
+ getBeatScreenDuration(context: MulmoStudioContext, index: number): number;
26
28
  getBeatDuration(context: MulmoStudioContext, index: number): number;
27
29
  getVoiceOverGroupDuration(context: MulmoStudioContext, index: number): number;
28
30
  isMovieBeat(context: MulmoStudioContext, studioBeat: MulmoStudioBeat, beat: MulmoBeat): boolean;
@@ -110,6 +110,25 @@ export const MulmoStudioContextMethods = {
110
110
  }
111
111
  return 0;
112
112
  },
113
+ // When this beat appears on screen, in seconds from the start of the movie. The first beat's segment
114
+ // starts at 0 and absorbs the intro padding; every later beat starts when its narration does.
115
+ getBeatScreenStartAt(context, index) {
116
+ if (index === 0) {
117
+ return 0;
118
+ }
119
+ return MulmoStudioContextMethods.getIntroPadding(context) + (context.studio.beats[index]?.startAt ?? 0);
120
+ },
121
+ // How long this beat stays on screen from getBeatScreenStartAt: its whole segment, including the
122
+ // voice_over beats that share its shot. A voice_over beat itself is counted for its own slice only.
123
+ // The audio ends outroPadding after the last beat; getExtraPadding gives a single-beat script only
124
+ // the intro padding, so its outro is added here.
125
+ getBeatScreenDuration(context, index) {
126
+ if (MulmoBeatMethods.isVoiceOver(context.studio.script.beats[index])) {
127
+ return MulmoStudioContextMethods.getBeatDuration(context, index);
128
+ }
129
+ const singleBeatOutro = context.studio.beats.length === 1 ? context.presentationStyle.audioParams.outroPadding : 0;
130
+ return MulmoStudioContextMethods.getSegmentDuration(context, index) + singleBeatOutro;
131
+ },
113
132
  // The duration this beat occupies on the audio timeline.
114
133
  getBeatDuration(context, index) {
115
134
  return (context.studio.beats[index]?.duration ?? 0) + MulmoStudioContextMethods.getExtraPadding(context, index);
@@ -4985,6 +4985,22 @@ export declare const mulmoMovieParamsSchema: z.ZodObject<{
4985
4985
  concurrency: z.ZodOptional<z.ZodNumber>;
4986
4986
  generateAudio: z.ZodOptional<z.ZodBoolean>;
4987
4987
  }, z.core.$strip>;
4988
+ export declare const mulmoBeatSoundEffectSchema: z.ZodObject<{
4989
+ source: z.ZodDiscriminatedUnion<[z.ZodObject<{
4990
+ kind: z.ZodLiteral<"url">;
4991
+ url: z.ZodURL;
4992
+ }, z.core.$strict>, z.ZodObject<{
4993
+ kind: z.ZodLiteral<"path">;
4994
+ path: z.ZodString;
4995
+ }, z.core.$strict>, z.ZodObject<{
4996
+ kind: z.ZodLiteral<"jinglescript">;
4997
+ score: z.ZodRecord<z.ZodString, z.ZodUnknown>;
4998
+ }, z.core.$strict>], "kind">;
4999
+ startAt: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
5000
+ volume: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
5001
+ duration: z.ZodOptional<z.ZodNumber>;
5002
+ loop: z.ZodOptional<z.ZodBoolean>;
5003
+ }, z.core.$strict>;
4988
5004
  export declare const mulmoBeatSchema: z.ZodObject<{
4989
5005
  speaker: z.ZodOptional<z.ZodString>;
4990
5006
  text: z.ZodDefault<z.ZodOptional<z.ZodString>>;
@@ -9086,6 +9102,22 @@ export declare const mulmoBeatSchema: z.ZodObject<{
9086
9102
  imagePrompt: z.ZodOptional<z.ZodString>;
9087
9103
  moviePrompt: z.ZodOptional<z.ZodString>;
9088
9104
  soundEffectPrompt: z.ZodOptional<z.ZodString>;
9105
+ soundEffects: z.ZodOptional<z.ZodArray<z.ZodObject<{
9106
+ source: z.ZodDiscriminatedUnion<[z.ZodObject<{
9107
+ kind: z.ZodLiteral<"url">;
9108
+ url: z.ZodURL;
9109
+ }, z.core.$strict>, z.ZodObject<{
9110
+ kind: z.ZodLiteral<"path">;
9111
+ path: z.ZodString;
9112
+ }, z.core.$strict>, z.ZodObject<{
9113
+ kind: z.ZodLiteral<"jinglescript">;
9114
+ score: z.ZodRecord<z.ZodString, z.ZodUnknown>;
9115
+ }, z.core.$strict>], "kind">;
9116
+ startAt: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
9117
+ volume: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
9118
+ duration: z.ZodOptional<z.ZodNumber>;
9119
+ loop: z.ZodOptional<z.ZodBoolean>;
9120
+ }, z.core.$strict>>>;
9089
9121
  htmlPrompt: z.ZodOptional<z.ZodObject<{
9090
9122
  systemPrompt: z.ZodDefault<z.ZodOptional<z.ZodString>>;
9091
9123
  prompt: z.ZodString;
@@ -14353,6 +14385,22 @@ export declare const mulmoScriptSchema: z.ZodObject<{
14353
14385
  imagePrompt: z.ZodOptional<z.ZodString>;
14354
14386
  moviePrompt: z.ZodOptional<z.ZodString>;
14355
14387
  soundEffectPrompt: z.ZodOptional<z.ZodString>;
14388
+ soundEffects: z.ZodOptional<z.ZodArray<z.ZodObject<{
14389
+ source: z.ZodDiscriminatedUnion<[z.ZodObject<{
14390
+ kind: z.ZodLiteral<"url">;
14391
+ url: z.ZodURL;
14392
+ }, z.core.$strict>, z.ZodObject<{
14393
+ kind: z.ZodLiteral<"path">;
14394
+ path: z.ZodString;
14395
+ }, z.core.$strict>, z.ZodObject<{
14396
+ kind: z.ZodLiteral<"jinglescript">;
14397
+ score: z.ZodRecord<z.ZodString, z.ZodUnknown>;
14398
+ }, z.core.$strict>], "kind">;
14399
+ startAt: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
14400
+ volume: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
14401
+ duration: z.ZodOptional<z.ZodNumber>;
14402
+ loop: z.ZodOptional<z.ZodBoolean>;
14403
+ }, z.core.$strict>>>;
14356
14404
  htmlPrompt: z.ZodOptional<z.ZodObject<{
14357
14405
  systemPrompt: z.ZodDefault<z.ZodOptional<z.ZodString>>;
14358
14406
  prompt: z.ZodString;
@@ -19144,6 +19192,22 @@ export declare const mulmoStudioSchema: z.ZodObject<{
19144
19192
  imagePrompt: z.ZodOptional<z.ZodString>;
19145
19193
  moviePrompt: z.ZodOptional<z.ZodString>;
19146
19194
  soundEffectPrompt: z.ZodOptional<z.ZodString>;
19195
+ soundEffects: z.ZodOptional<z.ZodArray<z.ZodObject<{
19196
+ source: z.ZodDiscriminatedUnion<[z.ZodObject<{
19197
+ kind: z.ZodLiteral<"url">;
19198
+ url: z.ZodURL;
19199
+ }, z.core.$strict>, z.ZodObject<{
19200
+ kind: z.ZodLiteral<"path">;
19201
+ path: z.ZodString;
19202
+ }, z.core.$strict>, z.ZodObject<{
19203
+ kind: z.ZodLiteral<"jinglescript">;
19204
+ score: z.ZodRecord<z.ZodString, z.ZodUnknown>;
19205
+ }, z.core.$strict>], "kind">;
19206
+ startAt: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
19207
+ volume: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
19208
+ duration: z.ZodOptional<z.ZodNumber>;
19209
+ loop: z.ZodOptional<z.ZodBoolean>;
19210
+ }, z.core.$strict>>>;
19147
19211
  htmlPrompt: z.ZodOptional<z.ZodObject<{
19148
19212
  systemPrompt: z.ZodDefault<z.ZodOptional<z.ZodString>>;
19149
19213
  prompt: z.ZodString;
@@ -612,6 +612,44 @@ export const mulmoMovieParamsSchema = z.object({
612
612
  concurrency: z.number().int().positive().optional().describe("Max concurrent movie generation requests"),
613
613
  generateAudio: z.boolean().optional().describe("Request audio generation in the video (model-dependent)"),
614
614
  });
615
+ export const mulmoBeatSoundEffectSchema = z
616
+ .object({
617
+ source: z
618
+ .discriminatedUnion("kind", [
619
+ z.object({ kind: z.literal("url"), url: URLStringSchema }).strict(),
620
+ z.object({ kind: z.literal("path"), path: z.string().min(1) }).strict(),
621
+ z
622
+ .object({
623
+ kind: z.literal("jinglescript"),
624
+ score: z.record(z.string(), z.unknown()).describe("JingleScript score (format jinglescript/1), rendered to audio when the audio is generated"),
625
+ })
626
+ .strict(),
627
+ ])
628
+ .describe("Audio file of the sound effect (url or path), or a JingleScript score to synthesize"),
629
+ startAt: z
630
+ .number()
631
+ .min(0)
632
+ .optional()
633
+ .default(0)
634
+ .describe("Seconds from when the beat appears on screen (the same clock as its html animation) at which the sound effect plays. The first beat's narration starts audioParams.introPadding later"),
635
+ volume: z
636
+ .number()
637
+ .min(0)
638
+ .max(4)
639
+ .optional()
640
+ .default(1.0)
641
+ .describe("Volume of the sound effect. 1.0 is the gain of the narration at its default volume; audioParams.audioVolume and ttsVolume do not affect sound effects"),
642
+ duration: z
643
+ .number()
644
+ .positive()
645
+ .optional()
646
+ .describe("Maximum play length in seconds; the effect is cut (with a short fade-out) after it. Default: the whole file, even past the end of the beat"),
647
+ loop: z
648
+ .boolean()
649
+ .optional()
650
+ .describe("Repeat the effect until duration, or until the beat leaves the screen when duration is not set (e.g. a ticking clock)"),
651
+ })
652
+ .strict();
615
653
  export const mulmoBeatSchema = z
616
654
  .object({
617
655
  speaker: speakerIdSchema.optional(),
@@ -643,6 +681,10 @@ export const mulmoBeatSchema = z
643
681
  imagePrompt: z.string().optional(),
644
682
  moviePrompt: z.string().optional(),
645
683
  soundEffectPrompt: z.string().optional(),
684
+ soundEffects: z
685
+ .array(mulmoBeatSoundEffectSchema)
686
+ .optional()
687
+ .describe("Sound effects mixed into the audio track during this beat. Multiple effects may overlap."),
646
688
  htmlPrompt: htmlPromptParamsSchema.optional(),
647
689
  enableLipSync: z.boolean().optional().describe("Enable lip sync generation for this beat"),
648
690
  hidden: z.boolean().optional().describe("Hide this beat from the presentation"),
@@ -1,6 +1,6 @@
1
1
  import { type CallbackFunction } from "graphai";
2
2
  import type { UsageCollectorAPI } from "./usage.js";
3
- import { langSchema, localizedTextSchema, mulmoBeatSchema, mulmoScriptSchema, mulmoStudioSchema, mulmoStudioBeatSchema, mulmoStoryboardSchema, mulmoStoryboardSceneSchema, mulmoStudioMultiLingualSchema, mulmoStudioMultiLingualArraySchema, mulmoStudioMultiLingualDataSchema, mulmoStudioMultiLingualFileSchema, speakerDictionarySchema, speakerSchema, mulmoSpeechParamsSchema, mulmoImageParamsSchema, mulmoImageParamsImagesValueSchema, mulmoImageParamsImagesSchema, mulmoFillOptionSchema, mulmoTransitionSchema, mulmoVideoFilterSchema, mulmoMovieParamsSchema, mulmoSoundEffectParamsSchema, mulmoLipSyncParamsSchema, textSlideParamsSchema, speechOptionsSchema, speakerDataSchema, mulmoCanvasDimensionSchema, mulmoPromptTemplateSchema, mulmoPromptTemplateFileSchema, text2ImageProviderSchema, text2HtmlImageProviderSchema, text2MovieProviderSchema, text2SpeechProviderSchema, mulmoPresentationStyleSchema, multiLingualTextsSchema, mulmoImageAssetSchema, mulmoMermaidMediaSchema, mulmoRemotionMediaSchema, remotionCodeSourceSchema, mulmoTextSlideMediaSchema, mulmoMarkdownMediaSchema, mulmoImageMediaSchema, mulmoChartMediaSchema, mediaSourceSchema, mediaSourceMermaidSchema, backgroundImageSchema, backgroundImageSourceSchema, mulmoSessionStateSchema, mulmoOpenAIImageModelSchema, mulmoGoogleImageModelSchema, mulmoGoogleMovieModelSchema, mulmoReplicateMovieModelSchema, mulmoImagePromptMediaSchema, mulmoImageReferenceSchema, mulmoMovieMediaSchema, mulmoMoviePromptMediaSchema, markdownLayoutSchema, row2Schema, grid2x2Schema, mulmoAvatarParamsSchema, avatarPositionSchema, speakerAvatarSchema, mulmoAvatarTrackSchema } from "./schema.js";
3
+ import { langSchema, localizedTextSchema, mulmoBeatSchema, mulmoScriptSchema, mulmoStudioSchema, mulmoStudioBeatSchema, mulmoStoryboardSchema, mulmoStoryboardSceneSchema, mulmoStudioMultiLingualSchema, mulmoStudioMultiLingualArraySchema, mulmoStudioMultiLingualDataSchema, mulmoStudioMultiLingualFileSchema, speakerDictionarySchema, speakerSchema, mulmoSpeechParamsSchema, mulmoImageParamsSchema, mulmoImageParamsImagesValueSchema, mulmoImageParamsImagesSchema, mulmoFillOptionSchema, mulmoTransitionSchema, mulmoVideoFilterSchema, mulmoMovieParamsSchema, mulmoSoundEffectParamsSchema, mulmoBeatSoundEffectSchema, mulmoLipSyncParamsSchema, textSlideParamsSchema, speechOptionsSchema, speakerDataSchema, mulmoCanvasDimensionSchema, mulmoPromptTemplateSchema, mulmoPromptTemplateFileSchema, text2ImageProviderSchema, text2HtmlImageProviderSchema, text2MovieProviderSchema, text2SpeechProviderSchema, mulmoPresentationStyleSchema, multiLingualTextsSchema, mulmoImageAssetSchema, mulmoMermaidMediaSchema, mulmoRemotionMediaSchema, remotionCodeSourceSchema, mulmoTextSlideMediaSchema, mulmoMarkdownMediaSchema, mulmoImageMediaSchema, mulmoChartMediaSchema, mediaSourceSchema, mediaSourceMermaidSchema, backgroundImageSchema, backgroundImageSourceSchema, mulmoSessionStateSchema, mulmoOpenAIImageModelSchema, mulmoGoogleImageModelSchema, mulmoGoogleMovieModelSchema, mulmoReplicateMovieModelSchema, mulmoImagePromptMediaSchema, mulmoImageReferenceSchema, mulmoMovieMediaSchema, mulmoMoviePromptMediaSchema, markdownLayoutSchema, row2Schema, grid2x2Schema, mulmoAvatarParamsSchema, avatarPositionSchema, speakerAvatarSchema, mulmoAvatarTrackSchema } from "./schema.js";
4
4
  import { pdf_modes, pdf_sizes, storyToScriptGenerateMode } from "./const.js";
5
5
  import type { LLM } from "./provider2agent.js";
6
6
  import { z } from "zod";
@@ -43,6 +43,7 @@ export type MulmoStudioMultiLingualFile = z.infer<typeof mulmoStudioMultiLingual
43
43
  export type MultiLingualTexts = z.infer<typeof multiLingualTextsSchema>;
44
44
  export type MulmoMovieParams = z.infer<typeof mulmoMovieParamsSchema>;
45
45
  export type MulmoSoundEffectParams = z.infer<typeof mulmoSoundEffectParamsSchema>;
46
+ export type MulmoBeatSoundEffect = z.infer<typeof mulmoBeatSoundEffectSchema>;
46
47
  export type MulmoLipSyncParams = z.infer<typeof mulmoLipSyncParamsSchema>;
47
48
  export type MulmoAvatarParams = z.infer<typeof mulmoAvatarParamsSchema>;
48
49
  export type MulmoAvatarPosition = z.infer<typeof avatarPositionSchema>;
@@ -2504,6 +2504,22 @@ export declare const createStudioData: (_mulmoScript: MulmoScript, fileName: str
2504
2504
  imagePrompt?: string | undefined;
2505
2505
  moviePrompt?: string | undefined;
2506
2506
  soundEffectPrompt?: string | undefined;
2507
+ soundEffects?: {
2508
+ source: {
2509
+ kind: "url";
2510
+ url: string;
2511
+ } | {
2512
+ kind: "path";
2513
+ path: string;
2514
+ } | {
2515
+ kind: "jinglescript";
2516
+ score: Record<string, unknown>;
2517
+ };
2518
+ startAt: number;
2519
+ volume: number;
2520
+ duration?: number | undefined;
2521
+ loop?: boolean | undefined;
2522
+ }[] | undefined;
2507
2523
  htmlPrompt?: {
2508
2524
  systemPrompt: string;
2509
2525
  prompt: string;
@@ -5143,6 +5159,22 @@ export declare const initializeContextFromFiles: (files: FileObject, raiseError:
5143
5159
  imagePrompt?: string | undefined;
5144
5160
  moviePrompt?: string | undefined;
5145
5161
  soundEffectPrompt?: string | undefined;
5162
+ soundEffects?: {
5163
+ source: {
5164
+ kind: "url";
5165
+ url: string;
5166
+ } | {
5167
+ kind: "path";
5168
+ path: string;
5169
+ } | {
5170
+ kind: "jinglescript";
5171
+ score: Record<string, unknown>;
5172
+ };
5173
+ startAt: number;
5174
+ volume: number;
5175
+ duration?: number | undefined;
5176
+ loop?: boolean | undefined;
5177
+ }[] | undefined;
5146
5178
  htmlPrompt?: {
5147
5179
  systemPrompt: string;
5148
5180
  prompt: string;
@@ -0,0 +1,12 @@
1
+ import type { MulmoStudioContext } from "../types/index.js";
2
+ type JingleScore = Record<string, unknown>;
3
+ export declare const getJingleFilePath: (score: JingleScore, context: MulmoStudioContext) => string;
4
+ export declare const getJingleScores: (context: MulmoStudioContext) => {
5
+ score: Record<string, unknown>;
6
+ beatIndex: number;
7
+ effectIndex: number;
8
+ }[];
9
+ export declare const validateJingleScores: (context: MulmoStudioContext) => void;
10
+ export declare const renderJingleScores: (context: MulmoStudioContext) => Promise<void>;
11
+ export declare const writeFileAtomically: (filePath: string, data: Uint8Array) => void;
12
+ export {};
@@ -0,0 +1,63 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { GraphAILogger } from "graphai";
4
+ import { checkScore, formatProblem, loadSamples, parseScore, render, toWav } from "jinglescript";
5
+ import { MulmoStudioContextMethods } from "../methods/mulmo_studio_context.js";
6
+ import { hashSHA256, mkdir } from "./file.js";
7
+ import { userAssert } from "./utils.js";
8
+ // The audio mix (addBGMAgent) runs at 44.1 kHz.
9
+ const JINGLE_SAMPLE_RATE = 44100;
10
+ // Per download of a recorded sample (grandpiano: about 1.2 MB per note).
11
+ const SAMPLE_FETCH_TIMEOUT_MS = 60000;
12
+ // Rendered jingles are cached by the hash of their score, so identical scores share one file.
13
+ export const getJingleFilePath = (score, context) => {
14
+ return path.join(MulmoStudioContextMethods.getAudioDirPath(context), `jingle_${hashSHA256(JSON.stringify(score))}.wav`);
15
+ };
16
+ export const getJingleScores = (context) => {
17
+ return context.studio.script.beats.flatMap((beat, beatIndex) => (beat.soundEffects ?? []).flatMap((soundEffect, effectIndex) => soundEffect.source.kind === "jinglescript" ? [{ score: soundEffect.source.score, beatIndex, effectIndex }] : []));
18
+ };
19
+ // Checks every JingleScript score in the script; throws listing every problem with its location.
20
+ export const validateJingleScores = (context) => {
21
+ const problems = getJingleScores(context).flatMap(({ score, beatIndex, effectIndex }) => checkScore(score).errors.map((problem) => `beats[${beatIndex}].soundEffects[${effectIndex}].source.score: ${formatProblem(problem).trim()}`));
22
+ userAssert(problems.length === 0, `Invalid JingleScript score:\n${problems.join("\n")}`);
23
+ };
24
+ const fetchWithTimeout = (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(SAMPLE_FETCH_TIMEOUT_MS) });
25
+ // Sampled instruments (grandpiano) play recordings that jinglescript downloads on first use and
26
+ // caches; a score without them loads nothing.
27
+ const loadJingleSamples = async (score, location) => {
28
+ try {
29
+ return await loadSamples(score, { fetch: fetchWithTimeout });
30
+ }
31
+ catch (error) {
32
+ throw new Error(`${location}: failed to load JingleScript samples: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
33
+ }
34
+ };
35
+ // Renders the JingleScript scores of the script to WAV files (skipping cached ones unless forced).
36
+ export const renderJingleScores = async (context) => {
37
+ validateJingleScores(context);
38
+ for (const { score, beatIndex, effectIndex } of getJingleScores(context)) {
39
+ const filePath = getJingleFilePath(score, context);
40
+ if (!context.force && fs.existsSync(filePath)) {
41
+ continue;
42
+ }
43
+ const parsed = parseScore(score);
44
+ const samples = await loadJingleSamples(parsed, `beats[${beatIndex}].soundEffects[${effectIndex}].source.score`);
45
+ const { audio, sampleRate } = render(parsed, { sampleRate: JINGLE_SAMPLE_RATE, samples });
46
+ mkdir(path.dirname(filePath));
47
+ writeFileAtomically(filePath, toWav(audio, sampleRate));
48
+ GraphAILogger.info(`jinglescript: rendered ${filePath}`);
49
+ }
50
+ };
51
+ // Writes to a temporary file next to the target, then renames it, so an interrupted write never
52
+ // leaves a partial WAV that a later run would take for a cached one.
53
+ export const writeFileAtomically = (filePath, data) => {
54
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
55
+ try {
56
+ fs.writeFileSync(tmpPath, data);
57
+ fs.renameSync(tmpPath, filePath);
58
+ }
59
+ catch (error) {
60
+ fs.rmSync(tmpPath, { force: true });
61
+ throw error;
62
+ }
63
+ };