Skip to content
Moku
@moku-labs/ai pages

voiceover

Task plugin for text-to-speech. Owns the voiceover contract and the one-off facade app.voiceover.*.

On this page

What it does

voiceover is the task plugin for text-to-speech. It owns the contract every voiceover provider implements: VoiceoverRequest, VoiceoverResult and VoiceoverHandler. It exposes a small typed facade, app.voiceover.*, for one-off generation, cost estimates and provider discovery.

The plugin talks to no AI service. Provider plugins register a handler with registry under the task "voiceover". The facade resolves the handler by name and calls it.

Providers that serve this task today:

ProviderPlugin
elevenlabselevenlabs
openaiopenai

How it works

The registry carries handlers as unknown. This plugin checks the shape at run time and casts once, at its own resolve site. A malformed registration fails with a clear error, never a crash.

Before dispatch the facade resolves the request. format defaults to config.defaultFormat. The narration pack's per-provider defaults are merged under the request's own params. The request wins on a key conflict.

The plugin is stateless. No lifecycle hooks, no events.

Config

pluginConfigs.voiceover.

OptionTypeDefaultDescription
defaultProviderstring"elevenlabs"Provider the facade uses when a call names none.
defaultFormat"mp3" | "wav" | "ogg""mp3"Output format when a request omits format.
import { createApp } from "@moku-labs/ai";

const app = createApp({
  pluginConfigs: {
    voiceover: { defaultProvider: "openai", defaultFormat: "wav" }
  }
});

API

Mounted at app.voiceover, reachable with ctx.require(voiceoverPlugin).

MethodSignatureDescription
generate(request: VoiceoverRequest, opts?: { signal?: AbortSignal; provider?: string }) => Promise<VoiceoverResult>One-off generation. Direct, not journaled.
estimate(request: VoiceoverRequest, opts?: { provider?: string }) => { usd: number }Cost estimate without a call. Synchronous. Same handler estimate() the runner's budget gate uses.
providers() => string[]Registered voiceover providers, in registration order.

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

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

VoiceoverRequest

FieldTypeRequiredDescription
textstringyesThe text to speak.
voicestringyesProvider-scoped voice id or name.
languagestringnoBCP-47 tag, e.g. "en-US", for providers that need a hint.
modelstringnoProvider-scoped model id.
format"mp3" | "wav" | "ogg"noOutput container. Defaults to config.defaultFormat.
paramsRecord<string, unknown>noProvider params. Merged over the pack defaults.

VoiceoverResult

FieldTypeDescription
audioUint8ArrayThe audio bytes.
mimeTypestringaudio/mpeg, audio/wav or audio/ogg.
costUsdnumberActual cost, USD.
metaRecord<string, unknown>?Metadata only, e.g. durationMs, characters, model.

Example

The durable path is a build file item. The runner journals it, so a crash resumes and a second run reuses the artifact:

lines.moku.yaml
version: 1
name: lines
items:
  - id: intro
    task: voiceover
    input:
      text: "Welcome to the show."
      voice: "21m00Tcm4TlvDq8ikWAM"
      language: en-US
  - id: intro-wav
    task: voiceover
    provider: openai
    input:
      text: "Welcome to the show."
      voice: alloy
      format: wav

The facade is the direct path. Nothing is journaled, so there is no resume and no cost ledger entry. Use it for scripts, tests and interactive one-offs:

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

await app.start();

const { usd } = app.voiceover.estimate({ text: "Welcome to the show.", voice: "alloy" }, { provider: "openai" });
if (usd > 0.01) throw new Error("too expensive for a smoke test");

const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);

const result = await app.voiceover.generate(
  { text: "Welcome to the show.", voice: "alloy", format: "wav" },
  { provider: "openai", signal: controller.signal }
);
await Bun.write("intro.wav", result.audio);
console.log(result.mimeType, result.costUsd);

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

A custom provider registers a VoiceoverHandler under "voiceover" in its onInit:

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

declare function synthesize(text: string, voice: string): Promise<Uint8Array>;

const handler: Voiceover.VoiceoverHandler = {
  estimate: request => ({ usd: request.text.length * 0.00003 }),
  execute: async request => ({
    audio: await synthesize(request.text, request.voice),
    mimeType: "audio/mpeg",
    costUsd: 0.001
  })
};

export const acmeTtsPlugin = createPlugin("acmeTts", {
  depends: [registryPlugin],
  onInit: ctx => {
    ctx.require(registryPlugin).register("voiceover", "acme", handler);
  }
});
  • generate is not journaled. No resume, no cost ledger entry. Build files and anything worth resuming go through runner.
  • Request params win. The narration pack supplies per-provider defaults. A key in request.params replaces the pack's value.
  • Providers list in registration order. config.defaultProvider does not change the order of providers().

Events

Emits

None.

Listens to

None.

Depends on

  • registry: resolve("voiceover", provider) and providers("voiceover").