@molecule/api-audio-render

Utility · audio-render · API (Node) · v2.0.1 · Apache-2.0

Offline multi-track audio mixdown — renders an AudioSession to WAV/MP3/FLAC via FFmpeg, queue-driven.

npm install @molecule/api-audio-render

npm · Source on GitHub

How it works

@molecule/api-audio-render is a utility package for the API (Node) side (audio-render).

import { renderAudio, getRenderStatus } from '@molecule/api-audio-render'

const job = await renderAudio(
  {
    duration: 8,
    channels: [
      {
        id: 'drums',
        clips: [{ audioUrl: '/tmp/loop.wav', startTime: 0, duration: 8 }],
        volume: 0.9,
      },
      {
        id: 'lead',
        clips: [{ audioUrl: '/tmp/lead.wav', startTime: 2, duration: 4 }],
        pan: 0.3,
      },
    ],
  },
  { format: 'mp3', bitrate: '256k' },
)

// …later…
const status = getRenderStatus(job.id)

Works with: @molecule/api-queue

Reference

Offline multi-track audio mixdown for molecule.dev — flagship use case is the music-daw app.

Same shape as @molecule/api-video-render: take a multi-track AudioSession (channels with clips, per-channel volume / pan / effects), enqueue a render job via the bonded queue provider, and have a worker spawn ffmpeg to produce a single mixed-down audio file (WAV, MP3, or FLAC).

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

import { renderAudio, getRenderStatus } from '@molecule/api-audio-render'

const job = await renderAudio(
  {
    duration: 8,
    channels: [
      {
        id: 'drums',
        clips: [{ audioUrl: '/tmp/loop.wav', startTime: 0, duration: 8 }],
        volume: 0.9,
      },
      {
        id: 'lead',
        clips: [{ audioUrl: '/tmp/lead.wav', startTime: 2, duration: 4 }],
        pan: 0.3,
      },
    ],
  },
  { format: 'mp3', bitrate: '256k' },
)

// …later…
const status = getRenderStatus(job.id)
// Worker side — typically a long-running process.
import { startAudioRenderWorker } from '@molecule/api-audio-render'

const stop = startAudioRenderWorker({
  ffmpegPath: '/usr/local/bin/ffmpeg',
  onJobComplete: (result) => console.log('rendered', result.jobId),
})

Type

utility

Installation

npm install @molecule/api-audio-render @molecule/api-queue

API

Interfaces

FfmpegCommand

Result of buildFfmpegArgs — the exact argv to spawn plus the resolved filter graph (handy for diagnostics and tests).

interface FfmpegCommand {
  /** Argument vector — element 0 is the first arg, NOT the binary name. */
  args: string[]
  /** The fully assembled `-filter_complex` script. */
  filterGraph: string
  /** Final output stream label fed into `-map`. */
  outputStreamLabel: string
}

AudioRenderRequest

Minimal request shape consumed by the handlers.

interface AudioRenderRequest {
  /** Parsed JSON body (POST). */
  body?: unknown
  /** Path parameters (`:id`). */
  params?: Record<string, string | undefined>
}

AudioRenderResponse

Minimal response shape consumed by the handlers.

interface AudioRenderResponse {
  setStatus: (status: number) => void
  sendJson: (body: unknown) => void
}

AudioRenderRoutes

Bundle of route handlers returned by createAudioRenderRoutes.

interface AudioRenderRoutes {
  /** `POST /render/audio` — enqueue a session. */
  enqueue: (req: AudioRenderRequest, res: AudioRenderResponse) => Promise<void>
  /** `GET /render/jobs/:id` — fetch a job's status. */
  status: (req: AudioRenderRequest, res: AudioRenderResponse) => Promise<void>
  /** `DELETE /render/jobs/:id` — request cancellation. */
  cancel: (req: AudioRenderRequest, res: AudioRenderResponse) => Promise<void>
}

CreateAudioRenderRoutesOptions

Options for createAudioRenderRoutes.

interface CreateAudioRenderRoutesOptions {
  /**
   * Directory every clip `audioUrl` must live under. Relative paths resolve
   * against it; URLs, `..` segments and paths outside it are rejected.
   */
  mediaRoot: string
  /** Directory rendered files are written to, as `<uuid>.<format>`. */
  outputDir: string
  /**
   * Optional pre-flight validator. Throw to reject the enqueue request;
   * the thrown error's `.message` becomes the JSON `error` field with
   * HTTP 400.
   */
  validate?: (session: AudioSession, options: AudioRenderOptions) => void | Promise<void>
}

AudioJobStore

Backend contract for render-job status. All operations are synchronous to keep getRenderStatus / cancelRender synchronous (their public shape). A backend shared across processes (to fix the split-process topology) must therefore be a synchronous façade over the shared storage.

interface AudioJobStore {
  /** Register a freshly-enqueued job. */
  register(job: RenderJob): void
  /** Look up a job by id, or `undefined` if unknown to this store. */
  get(jobId: string): RenderJob | undefined
  /** Merge a partial patch into an existing job. No-op (returns `undefined`) if unknown. */
  update(jobId: string, patch: Partial<RenderJob>): RenderJob | undefined
  /** Drop all jobs — primarily for tests. */
  reset(): void
  /** Snapshot of all registered job ids — for diagnostics and tests. */
  ids(): string[]
}

AudioClip

A single audio clip placed on a AudioChannel timeline.

Time fields are seconds in the session's master timeline. The renderer pads silence before the clip's startTime and trims to duration so timeline placement is preserved exactly.

interface AudioClip {
  /** Stable clip id — appears in error messages and progress events. */
  id?: string
  /**
   * Source audio: a local filesystem path. An `http(s):` URL is only opened
   * when the trusted caller sets `allowRemoteSources`; the HTTP routes accept
   * only paths inside their configured `mediaRoot`. Strings containing
   * control characters, a leading `-` or `..` segments are rejected.
   */
  audioUrl: string
  /** Seconds from session t=0 at which this clip should begin playing. */
  startTime: number
  /** Length of the clip's contribution to the mix, in seconds. */
  duration: number
  /**
   * Optional offset into the source file (seconds). Defaults to 0 — the
   * clip plays from the start of `audioUrl`.
   */
  sourceOffset?: number
}

AudioEffect

A simple audio effect applied to a channel before mixdown.

The shape is intentionally minimal — providers translate kind into the right ffmpeg -af filter. Unknown kinds are ignored at render time (warned, not failed) so future effect types are forward-compatible.

interface AudioEffect {
  /** Effect kind — `gain`, `lowpass`, `highpass`, `reverb`, etc. */
  kind: string
  /** Effect-specific parameters (numeric or string). */
  params?: Record<string, number | string>
}

AudioChannel

A single mixer channel: an ordered list of clips plus per-channel volume / pan / effects.

interface AudioChannel {
  /** Stable channel id — required because clips reference timing in seconds, not bars. */
  id: string
  /** Display label (caller-supplied, never localized by this package). */
  label?: string
  /** Clips placed on this channel's timeline. */
  clips: AudioClip[]
  /**
   * Linear volume gain. `1` = unity, `0` = silent, `2` = +6 dB.
   * Defaults to 1 when omitted.
   */
  volume?: number
  /**
   * Stereo pan from `-1` (hard left) to `1` (hard right). `0` = center.
   * Defaults to 0 when omitted.
   */
  pan?: number
  /** Whether this channel is muted in the final mix. Defaults to false. */
  muted?: boolean
  /** Optional per-channel effects, applied in array order before summing. */
  effects?: AudioEffect[]
}

AudioSession

A complete multi-track session ready for mixdown.

The session is the single argument to renderAudio; it must be fully self-describing — no implicit project state, no global mixer.

interface AudioSession {
  /** Mixer channels. Empty arrays produce a silent track of `duration` seconds. */
  channels: AudioChannel[]
  /** Total session length in seconds (master timeline). */
  duration: number
  /** Optional master gain applied after summing the channels. Defaults to 1. */
  masterVolume?: number
}

AudioRenderOptions

Options accepted by renderAudio.

interface AudioRenderOptions {
  /** Output format. Defaults to `'mp3'`. */
  format?: AudioRenderFormat
  /** Output sample rate in Hz. Defaults to `44100`. */
  sampleRate?: number
  /** Output channel count: 1 = mono, 2 = stereo. Defaults to `2`. */
  channels?: number
  /**
   * Output bitrate (e.g. `'192k'`). Honoured for `mp3`; ignored for
   * uncompressed `wav` and lossless `flac`. Defaults to `'192k'` for mp3.
   */
  bitrate?: string
  /**
   * Override the destination path. Defaults to a unique tmp path under the
   * caller's `os.tmpdir()` with the format-appropriate extension. Trusted
   * server code only — the HTTP routes ignore any client value and mint a
   * path inside their configured `outputDir`.
   */
  outputPath?: string
  /**
   * Override the queue name jobs are dispatched to. Defaults to
   * `'audio-render'`. Trusted server code only — ignored by the HTTP routes.
   */
  queueName?: string
  /**
   * Let ffmpeg open `http:`/`https:` clip sources. Defaults to `false`,
   * which passes `-protocol_whitelist file` so only local files can be read.
   * Trusted server code only — the HTTP routes never set it.
   */
  allowRemoteSources?: boolean
}

RenderJob

The job descriptor returned by renderAudio and inspected by getRenderStatus. Mutated in-place by the worker as state advances; consumers should treat reads as a snapshot.

interface RenderJob {
  /** Stable job id — opaque, generated by the queue provider. */
  id: string
  /** Current lifecycle state. */
  status: AudioRenderJobStatus
  /** Queue name the job was sent to. */
  queueName: string
  /** Resolved output path. */
  outputPath: string
  /** Resolved output format. */
  format: AudioRenderFormat
  /** Echo of the originating session — useful for re-rendering / diagnostics. */
  session: AudioSession
  /** Echo of the resolved options. */
  options: Required<
    Omit<AudioRenderOptions, 'outputPath' | 'queueName' | 'bitrate' | 'allowRemoteSources'>
  > & {
    bitrate?: string
  }
  /** When the job was enqueued. */
  enqueuedAt: Date
  /** Set when the worker begins processing. */
  startedAt?: Date
  /** Set when the worker finishes (success, failure, or cancellation). */
  finishedAt?: Date
  /** Populated when `status === 'failed'`. */
  error?: string
}

AudioRenderJobPayload

Payload placed on the queue for the worker to consume.

interface AudioRenderJobPayload {
  jobId: string
  session: AudioSession
  options: AudioRenderOptions
  outputPath: string
  format: AudioRenderFormat
}

ProcessAudioRenderJobOptions

Options for processAudioRenderJob.

interface ProcessAudioRenderJobOptions {
  /**
   * ffmpeg binary path. When omitted, resolves the `FFMPEG_PATH` environment
   * variable, then falls back to `'ffmpeg'` (resolved on `$PATH`). See
   * `resolveFfmpegPath`.
   */
  ffmpegPath?: string
  /** Override `child_process.spawn` for tests. */
  spawn?: SpawnFunction
  /** Maximum time (ms) to allow ffmpeg before killing it. Defaults to 600_000 (10 min). */
  timeoutMs?: number
}

ProcessAudioRenderJobResult

Result of a single render attempt.

interface ProcessAudioRenderJobResult {
  jobId: string
  status: RenderJob['status']
  outputPath?: string
  exitCode?: number | null
  error?: string
  /** The exact argv passed to ffmpeg — useful for diagnostics in tests. */
  ffmpegArgs: string[]
}

StartAudioRenderWorkerOptions

Options for startAudioRenderWorker.

interface StartAudioRenderWorkerOptions extends ProcessAudioRenderJobOptions {
  /** Queue name to subscribe to. Defaults to `'audio-render'`. */
  queueName?: string
  /** Called whenever a job completes (success, failure, or cancellation). */
  onJobComplete?: (result: ProcessAudioRenderJobResult) => void
}

Types

AudioRenderFormat

Supported output container/codec combinations.

  • wav — uncompressed PCM, default 16-bit signed (pcm_s16le).
  • mp3 — lossy MPEG-1 Layer III via libmp3lame.
  • flac — lossless FLAC.
type AudioRenderFormat = 'wav' | 'mp3' | 'flac'

AudioRenderJobStatus

Lifecycle states a render job moves through.

queued → processing → (completed | failed | cancelled). Cancellation requests on queued jobs short-circuit straight to cancelled without ever entering processing.

type AudioRenderJobStatus = 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled'

SpawnFunction

Function signature compatible with child_process.spawn. Exposed as the injection seam for tests.

type SpawnFunction = (
  command: string,
  args: readonly string[],
  options?: SpawnOptions,
) => ChildProcess

Functions

sanitizeAudioPath(value, label)

Reject paths/URLs containing NUL, newline, or any other control character, a leading - (ffmpeg would read it as an option), or a .. path segment. Control characters can't appear in legitimate filesystem paths or URLs; if one shows up it's almost certainly an injection attempt or a bug upstream, and ffmpeg's filter-graph parser would mis-tokenize it.

function sanitizeAudioPath(value: unknown, label: string): string
  • value — The caller-supplied path or URL.
  • label — Diagnostic label used in the thrown error.

Returns: The validated value, unchanged.

assertSafeChannelId(value)

Validate a channel id. Ids are embedded in filter-graph link labels, so anything outside [A-Za-z0-9_-] could splice extra filters into the graph.

function assertSafeChannelId(value: unknown): string
  • value — The caller-supplied channel id.

Returns: The validated id, unchanged.

buildFfmpegArgs(session, options, outputPath)

Construct the full ffmpeg argv for an AudioSession.

function buildFfmpegArgs(
  session: AudioSession,
  options: Required<Pick<AudioRenderOptions, 'format' | 'sampleRate' | 'channels'>> & {
    bitrate?: string
    allowRemoteSources?: boolean
  },
  outputPath: string,
): FfmpegCommand
  • session — The multi-track session to render.
  • options — Resolved render options (caller has already merged defaults).
  • outputPath — Destination file path.

Returns: The argv plus the filter-graph string for diagnostics.

createAudioRenderRoutes(routeOptions)

Build the audio-render HTTP route handlers.

function createAudioRenderRoutes(routeOptions: CreateAudioRenderRoutesOptions): AudioRenderRoutes
  • routeOptions — Media root, output directory and optional validator.

Returns: A bundle of three async handlers.

createInMemoryAudioJobStore()

The default in-memory AudioJobStore, backed by a Map. Process-local — a restart loses state.

function createInMemoryAudioJobStore(): AudioJobStore

Returns: A fresh in-memory store.

getAudioJobStore()

Return the active job store. Defaults to the in-memory store.

function getAudioJobStore(): AudioJobStore

Returns: The active AudioJobStore.

setAudioJobStore(store)

Replace the active job store. Wire a shared, cross-process backend here (in BOTH the enqueuing process and the worker process) so getRenderStatus observes the worker's transitions in a split-process queue topology. Pass undefined to reset to a fresh in-memory store — primarily for tests.

function setAudioJobStore(store: AudioJobStore | undefined): void
  • store — The store to use, or undefined to reset to in-memory.

registerJob(job)

Register a freshly-enqueued job in the active store.

function registerJob(job: RenderJob): void
  • job — The job descriptor returned by renderAudio.

getJob(jobId)

Look up a job by id in the active store.

function getJob(jobId: string): RenderJob | undefined
  • jobId — The job id to retrieve.

Returns: The job, or undefined if no such id has been registered.

updateJob(jobId, patch)

Apply an in-place patch to a registered job. No-op if the id is unknown.

function updateJob(jobId: string, patch: Partial<RenderJob>): RenderJob | undefined
  • jobId — The job id to update.
  • patch — Partial fields to merge into the job descriptor.

Returns: The updated job, or undefined if no such id was registered.

resetJobStore()

Drop all registered jobs — for tests.

function resetJobStore(): void

listJobIds()

Snapshot of all currently-registered job ids — for diagnostics and tests.

function listJobIds(): string[]

hasTraversalSegment(value)

Whether a path string contains a .. segment (with / or \ separators).

function hasTraversalSegment(value: string): boolean
  • value — The path to inspect.

Returns: true when any segment is exactly ...

resolveMediaSource(source, mediaRoot, label)

Resolve a client-supplied media source to an absolute path inside mediaRoot. Relative paths are resolved against the root; absolute paths are accepted only when they already sit inside it.

function resolveMediaSource(source: unknown, mediaRoot: string, label: string): string
  • source — The untrusted source string from the request.
  • mediaRoot — The directory every source must live under.
  • label — Field name used in error messages.

Returns: The absolute, confined path.

createOutputPath(outputDir, extension)

Mint a fresh output path inside outputDir from a random UUID.

function createOutputPath(outputDir: string, extension: string): string
  • outputDir — The directory rendered files are written to.
  • extension — File extension without the dot (e.g. mp4).

Returns: An absolute output path no client input contributed to.

assertAllowedAudioFormat(value)

Validate an output format against the supported set.

function assertAllowedAudioFormat(value: unknown): AudioRenderFormat
  • value — The candidate format.

Returns: The format, typed.

renderAudio(session, options)

Enqueue an AudioSession for offline mixdown.

Does NOT block on rendering. Returns immediately with a RenderJob whose status starts at 'queued'; poll getRenderStatus or subscribe to the queue from a worker process to track progress.

function renderAudio(session: AudioSession, options?: AudioRenderOptions): Promise<RenderJob>
  • session — The multi-track session to render.
  • options — Output format / sample rate / channel count / bitrate overrides. All fields optional — sensible mp3 defaults.

Returns: The freshly-enqueued render job.

getRenderStatus(jobId)

Look up a previously-enqueued render job by id.

Process-local. This reads the active job store (an in-memory Map by default), which only reflects the worker's transitions when the worker runs in the SAME process (true with the @molecule/api-queue-memory bond). With a distributed queue bond and a separate worker process this returns the stale queued snapshot forever — the worker's processing/completed/ failed updates land in the worker's own store. To make status observable across processes, inject a shared backend via setAudioJobStore() in BOTH processes, or persist durable status from the worker's onJobComplete callback and read it from your own storage.

function getRenderStatus(jobId: string): RenderJob | undefined
  • jobId — The id returned in RenderJob.id from renderAudio.

Returns: The job as known to THIS process's store, or undefined.

cancelRender(jobId)

Mark a render job as cancelled.

Cancellation is cooperative: a job already in processing will be marked but the worker decides whether to honour the flag (the bundled worker checks before spawning ffmpeg and again on completion). Jobs in a terminal state (completed, failed, cancelled) are not changed.

Process-local, same caveat as getRenderStatus: the flag is written to this process's job store, so a worker in a separate process only observes it when a shared store is injected via setAudioJobStore() in both processes.

function cancelRender(jobId: string): boolean
  • jobId — The id returned in RenderJob.id from renderAudio.

Returns: true if the job was found and a cancellation flag applied, false if the id is unknown or the job is already terminal.

resolveFfmpegPath(explicit)

Resolve the ffmpeg binary path to spawn. Precedence: an explicit ffmpegPath → the FFMPEG_PATH environment variable → 'ffmpeg' (resolved on $PATH).

function resolveFfmpegPath(explicit?: string): string
  • explicit — An explicit path from ProcessAudioRenderJobOptions.

Returns: The binary path/name to spawn.

describeSpawnFailure(error, ffmpegPath)

Turn a spawn failure into a human-actionable message. A raw spawn ffmpeg ENOENT gives no hint at the fix; for ENOENT this names the resolved binary path and how to point at a real one. Any other error is returned by its own .message.

function describeSpawnFailure(error: unknown, ffmpegPath: string): string
  • error — The error thrown/emitted by child_process.spawn.
  • ffmpegPath — The binary path that failed to spawn.

Returns: A clear, actionable error message string.

processAudioRenderJob(payload, processOptions)

Process a single render-job payload: build the ffmpeg argv, spawn ffmpeg, await exit, and update the job-store entry to match.

Intended to be wired into a queue subscription via startAudioRenderWorker, but exported on its own so callers with custom queue topologies can drive the work directly.

function processAudioRenderJob(
  payload: AudioRenderJobPayload,
  processOptions?: ProcessAudioRenderJobOptions,
): Promise<ProcessAudioRenderJobResult>
  • payload — The job payload received from the queue.
  • processOptions — Optional spawn / timeout overrides.

Returns: A summary of the attempt suitable for logging.

startAudioRenderWorker(options)

Subscribe to the render queue and process every incoming job.

function startAudioRenderWorker(options?: StartAudioRenderWorkerOptions): () => void
  • options — Queue name + spawn / timeout overrides.

Returns: The unsubscribe function returned by subscribe().

Constants

DEFAULT_AUDIO_RENDER_QUEUE

Default queue name jobs are dispatched to.

const DEFAULT_AUDIO_RENDER_QUEUE: 'audio-render'

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-queue ^1.0.1

Runtime Dependencies

  • @molecule/api-queue

Security: Every caller-controlled string (clip audioUrl, output path) is checked against sanitizeAudioPath (no control characters, no leading -, no .. segments) and every channel id against assertSafeChannelId before it reaches ffmpeg's command line. ffmpeg itself is invoked via child_process.spawn with an argv array — never via a shell — with -nostdin, and every input is opened with -protocol_whitelist file: a clip can only make ffmpeg fetch a URL when trusted server code passes allowRemoteSources: true.

HTTP routes: untrusted input, authentication required. createAudioRenderRoutes({ mediaRoot, outputDir }) requires both directories and must be mounted behind authentication — its routes enqueue, read and cancel any job by id. The client's outputPath, queueName and allowRemoteSources are ignored, the output path is minted inside outputDir from a random UUID, and every clip audioUrl must be a path that resolves inside mediaRoot (URLs, .. segments and absolute paths outside it are rejected with HTTP 400). outputPath, queueName and allowRemoteSources on renderAudio are for trusted server code only — never copy them from a request.

Resource intensity: ffmpeg can saturate CPU and IO for large sessions. The package is queue-driven on purpose so flagship apps can gate concurrency at the queue tier rather than in the request path.

ffmpeg is a runtime prerequisite, not a dependency. The worker spawns whatever ffmpegPath points at — resolved from the ffmpegPath option, then the FFMPEG_PATH env var, then ffmpeg on $PATH. Install it in the runtime image (e.g. apt-get install -y ffmpeg) — it is NOT present in minimal containers including the molecule.dev sandbox base image. When it's missing, the job fails with a clear, actionable error naming the path and the fix (install ffmpeg or set FFMPEG_PATH) on the job's error field — not a raw spawn ffmpeg ENOENT.

Queue wiring: renderAudio dispatches through the abstract @molecule/api-queue core, so a queue bond must be wired at startup — bond('queue', provider) with @molecule/api-queue-memory (same-process dev default), -redis, -rabbitmq, or -sqs — and startAudioRenderWorker() must be running to consume jobs. Both throw "Queue provider not configured. Call setProvider() first." otherwise.

Job status is process-local by default. getRenderStatus / cancelRender read the active job store — an in-memory map by default — that is shared between renderAudio and the worker ONLY when both run in the same process (true with the memory queue bond). With a distributed queue bond and a separate worker process, the enqueuing process never sees status transitions. Fix it either way: inject a shared, cross-process backend via setAudioJobStore() in BOTH processes (the store contract is synchronous, so it must be a sync façade over the shared storage), OR persist durable status from the worker's onJobComplete callback and read it from your own storage. For HTTP exposure of enqueue/status/cancel, see createAudioRenderRoutes().

No locale bond: This package's surface is programmatic — no user-visible strings are generated, so there's no companion @molecule/api-locales-audio-render.