Skip to content
Moku
@moku-labs/ai pages

music

Task plugin for music tracks. Owns the music contract, sync or async providers, and the one-off facade app.music.*.

On this page

What it does

music is the task plugin for music tracks. It owns the contract every music provider implements: MusicChunk, MusicRequest, MusicResult, MusicJobPoll and MusicHandler. It exposes a small typed facade, app.music.*, for one-off generation, cost estimates and provider discovery.

Providers that serve this task today:

ProviderPlugin
elevenlabselevenlabs
falfal

How it works

A handler has estimate, plus either execute (one call) or the async pair submit + poll, or both. The runner prefers submit + poll and journals the job id, so an aborted run resumes by polling, never by submitting again. The facade generate prefers execute and runs its own in-memory submit/poll loop when the provider has no execute.

In the facade loop, done returns the result without the state field, failed throws the poll's error as it is, and an abort during the wait rejects at once with the signal's reason.

The registry carries handlers as unknown. This plugin narrows them with the runtime guard isMusicHandler. A malformed registration fails with a clear error, never a crash.

The result shape matches VoiceoverResult. The runner stores audio and maps audio/mpeg to .mp3.

Config

pluginConfigs.music.

OptionTypeDefaultDescription
defaultProviderstring"fal"Provider the facade uses when a call names none.
pollIntervalMsnumber5000Delay between polls in the facade's submit/poll loop, ms.
import { createApp } from "@moku-labs/ai";

const app = createApp({
  pluginConfigs: { music: { defaultProvider: "fal", pollIntervalMs: 2000 } }
});

API

Mounted at app.music, reachable with ctx.require(musicPlugin).

MethodSignatureDescription
generate(request: MusicRequest, opts?: { provider?: string; signal?: AbortSignal }) => Promise<MusicResult>One-off generation. Direct, not journaled. signal cancels the provider call or the poll wait.
estimate(request: MusicRequest, opts?: { provider?: string }) => { usd: number }Cost estimate without a call. The same handler estimate() the runner's budget gate uses.
providers() => string[]Registered music providers, in registration order.

An unknown provider, or a registered value that fails the shape check, throws:

[ai] No music provider named "<name>" is registered.
  Available: <comma list or "none">.

MusicRequest

FieldTypeRequiredDescription
promptstringyesOverall music prompt.
modelstringyesProvider-scoped model alias, e.g. "elevenlabs-music-v2.5". Price, endpoint and output depend on it.
lengthMsnumberyesTrack length, ms.
chunksMusicChunk[]noComposition plan. A model without plans rejects it, never drops it silently.
seednumbernoSeed, when the model takes one.
paramsRecord<string, unknown>noProvider params. Each provider documents the keys it reads.

MusicChunk

FieldTypeRequiredDescription
textstringyesLyrics or section description.
durationMsnumberyesSection length, ms.
stylesstring[]yesPositive style tags.
avoidstring[]noNegative style tags.

MusicResult

FieldTypeDescription
audioUint8ArrayThe audio bytes.
mimeTypestringMIME type of audio, e.g. audio/mpeg.
costUsdnumberActual cost, USD.
metaRecord<string, unknown>?Metadata only.

MusicJobPoll

{ state: "pending" }, { state: "done" } & MusicResult, or { state: "failed"; error: unknown }.

Example

The durable path is a build file item:

score.moku.yaml
version: 1
name: score
items:
  - id: teaser-score
    task: music
    provider: fal
    input:
      model: "elevenlabs-music-v2.5"
      prompt: "Tense synth pulse, rising."
      lengthMs: 30000

The facade is the direct path. Nothing is journaled:

score.ts
import { app } from "./app";

await app.start();

const { usd } = app.music.estimate({ prompt: "tense synth", model: "stable-audio-2.5", lengthMs: 30_000 });
console.log(usd);

const track = await app.music.generate({
  prompt: "tense synth",
  model: "elevenlabs-music-v2.5",
  lengthMs: 60_000,
  chunks: [
    { text: "intro", durationMs: 20_000, styles: ["ambient"] },
    { text: "build", durationMs: 40_000, styles: ["synthwave", "driving"], avoid: ["vocals"] }
  ]
});
await Bun.write("teaser.mp3", track.audio);

console.log(app.music.providers()); // ["elevenlabs", "fal"]
await app.stop();

A custom provider registers a MusicHandler under "music":

acme-music.ts
import { createPlugin, registryPlugin } from "@moku-labs/ai";
import type { Music } from "@moku-labs/ai";

declare function startJob(request: Music.MusicRequest): Promise<string>;
declare function checkJob(jobId: string): Promise<Music.MusicJobPoll>;

const handler: Music.MusicHandler = {
  estimate: request => ({ usd: Math.ceil(request.lengthMs / 60_000) * 0.8 }),
  submit: async request => ({ jobId: await startJob(request) }),
  poll: async jobId => checkJob(jobId)
};

export const acmeMusicPlugin = createPlugin("acmeMusic", {
  depends: [registryPlugin],
  onInit: ctx => {
    ctx.require(registryPlugin).register("music", "acme", handler);
  }
});
  • generate is not journaled. A crash in the facade's poll loop loses the job. Build files go through runner.
  • model is required. The runner hashes the item's input as written. A model defaulted inside a provider would not be part of the artifact key, and a changed default would reuse another model's audio.
  • No automatic fallback. There is no second model and no second provider. Name both on the item.

Events

Emits

None.

Listens to

None.

Depends on

  • registry: resolve("music", provider) and providers("music").
  • sfx: short sound effects, always mp3.
  • runner: the durable path for the same handlers.
  • elevenlabs, fal: the providers.