Skip to content

Integration - play a .soundef in your engine

Soundef's promise is author behavior, portable across engines. A .soundef file is engine-agnostic. The schema defines the format; Forge is one interpretation that turns a file + a seed into a PlaybackPlan, and your runtime turns that plan into sound. Forge can also bake to plain wav, so a custom runtime is never mandatory - but a runtime gives you live variation and per-trigger randomness.

This page is for runtime authors (Godot / Unity / Unreal / Bevy / web). Forge's JS is shown only as a reference for the behavior - not as a library to install.

Pipeline

.soundef text (YAML 1.2 - JSON is valid)
  → validate against /schemas/soundef.v0.json
  → resolve(seed)  - pick sources, lerp ranges, roll probability
  → PlaybackPlan JSON
  → your audio engine

Validate with any JSON Schema validator against the canonical schema at /schemas/soundef.v0.json. After validation, resolve the plan per the rules below. Or pre-resolve at build time: in Forge check includes json plan and Bake to write baked/<name>_<seed>.plan.json (validated at /schemas/playback-plan.v0.json) and ship that JSON. At runtime just play it without reimplementing resolve().

The PlaybackPlan (JSON shape)

This JSON is the file Forge writes when you check includes json plan and Bake. Validate it against /schemas/playback-plan.v0.json. resolve() has already done all the random rolls: it picked one source per layer, lerped every randomRange, and rolled probability. Your runtime plays what the plan says.

json
{
  "name": "sword_hit",
  "seed": "tavern-hit",
  "layers": [
    {
      "name": "body",
      "source": "swords/body_02.wav",
      "offset": 0.0,
      "gain": -1.2,
      "pitch": 0.83,
      "probabilityRoll": 0.42
    },
    {
      "name": "sparkle",
      "source": "swords/sparkle.wav",
      "offset": 61.0,
      "gain": -6.1,
      "pitch": 0.0,
      "skipped": true,
      "probabilityRoll": 0.97
    }
  ],
  "effects": {
    "reverbAmount": 0.2,
    "reverbDecay": 1.5,
    "reverbDamping": 0.25
  }
}
FieldTypeNotes
namestringFrom definition.name.
seedstring | numberThe seed you resolved with.
layersResolvedLayer[]One entry per layers key, in declaration order.
layers[].sourcestringConcrete file - globs already expanded. Resolve against sounds/ (see Sources).
layers[].offsetnumberms - delay after trigger.
layers[].gainnumberdB - 0 is unity.
layers[].pitchnumberst - semitones, 12 st is one octave.
layers[].skippedboolean?true if the probability roll failed - do not play (keep for timeline ghost).
layers[].probabilityRollnumber?The roll in [0,1) when probability was set.
effectsobject?Only when effects was declared. See Effects.

Mapping a plan to audio

For each layer in plan.layers, skip it if layer.skipped is true, otherwise:

Plan fieldUnitConvert toFormula
sourcepatha decoded bufferresolve against sounds/ (see Sources)
offsetmsstart delayoffset / 1000 seconds after trigger
gaindBlinear gain10^(gain / 20)
pitchstplayback rate2^(pitch / 12) (also scales duration)

Web Audio example (reference, not a library):

ts
const now = ctx.currentTime;
for (const layer of plan.layers) {
  if (layer.skipped) continue;

  const src = ctx.createBufferSource();
  src.buffer = buffers.get(layer.source);
  src.playbackRate.value = Math.pow(2, layer.pitch / 12);

  const g = ctx.createGain();
  g.gain.value = Math.pow(10, layer.gain / 20);

  src.connect(g).connect(ctx.destination);
  src.start(now + layer.offset / 1000);
}

The same three converts apply in any engine - for example in Godot set AudioStreamPlayer.volume_db = layer.gain directly (Godot already takes dB), pitch_scale = pow(2, layer.pitch / 12.0), and schedule the start layer.offset ms later.

skipped layers

A layer whose probability roll failed is still present in the plan with skipped: true. Playback and bake ignore it; Forge's timeline draws it as a dashed ghost. Keep the entry if you render a timeline; drop it if you only play audio.

Effects (optional)

plan.effects is present only when the definition declares effects. Effects are Forge-only and ignorable live - a runtime may skip them and still be correct. If you implement them, the values are already resolved numbers:

  • reverbAmount (0..1 wet), reverbDecay (seconds), reverbDamping (0..1)
  • delayTime (ms), delayFeedback (0..1), delayMix (0..1)
  • limiterThreshold (dB), limiterCeiling (dB)

Forge's chain order is layers sum → delay → reverb → limiter → output in both live and baked render. See Effects for the render detail Forge uses.

Determinism contract

definition + seed → the same PlaybackPlan, every time, on every platform.

To reproduce Forge's exact output in another engine, match the RNG. Forge uses a tiny portable pair - mulberry32 seeded through xmur3 - re-shown here for reference:

ts
function xmur3(str: string): () => number {
  let h = 1779033703 ^ str.length;
  for (let i = 0; i < str.length; i++) {
    h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
    h = (h << 13) | (h >>> 19);
  }
  return () => {
    h = Math.imul(h ^ (h >>> 16), 2246822507);
    h = Math.imul(h ^ (h >>> 13), 3266489909);
    h ^= h >>> 16;
    return h >>> 0;
  };
}

function mulberry32(a: number): () => number {
  return () => {
    let t = (a += 0x6d2b79f5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// createRng(seed): mulberry32(xmur3(String(seed))())

Rules you must follow to match:

  • Isolated streams per layer. Each layer uses mulberry32(xmur3(String(seed + "#" + layerName))). Dragging one layer's offset never shifts another layer's rolls, and renaming a layer re-rolls it.
  • Per-layer roll order inside a layer: probability roll first (if probability is set), then source pick (random mode; sequence is deterministic round-robin and uses no RNG), then offset, gain, pitch in that order - each randomRange uses one draw via lerp(min, max, rng()).
  • avoidRepeat retries use RNG. A rejected repeat draw still advances the stream - account for retries.
  • Effects use their own streams: seed + "#reverb", seed + "#delay", seed + "#limiter", isolated from layers.

If your RNG yields the same float sequence as mulberry32(xmur3(...)), your resolved plan matches Forge byte-for-byte. See Determinism for the canonical wording.

Handling diagnostics

A validator should emit diagnostics with { severity: "error" | "warning", message, code?, range? }. Treat error as fatal (do not play); warning still plays. Forge maps the same codes to editor squiggles. See the full Diagnostics table and Troubleshooting.

See also

Soundef — declarative SFX behavior