Skip to content

Produce a Spoken-Word Show ​

This page combines a host track, a guest track, and a music bed. It renders them with commentaryDucking, masters the result with speech, and checks the report.

Which CLI

This page uses the Python CLI (pip install libsonare, command sonare). mix is not in the native sonare-cli; see Which CLI Are You Using?. In code, Mixer.fromSceneJson loads the same scene (see Mixing Engine).

What you need

  • Install: pip install libsonare (Python 3.11 or later). The Node tab needs npm install @libraz/libsonare (Node.js 22.12 or later).
  • Three WAV files of the same length and sample rate: host.wav, guest.wav and music-bed.wav, mono or stereo. 48 kHz is the rate used here.
  • Test material: the numbers below come from the 8-second synthetic tracks the script below writes (vowel-like syllables for the voices, a sustained chord for the bed). With tracks of your own the commands are the same and the numbers will be yours.
Words used on this page
  • Duck: turn the music down automatically while someone speaks, and let it come back in the gaps.
  • Sidechain and key: a compressor normally measures the signal it is turning down. A sidechain makes it measure a different track, the key. Here the host is the key and the music bed is what gets turned down, so the music drops whenever the host speaks. Sidechain and Ducking has the arithmetic.
  • Pumping: the bed surging back up in every gap between words because the release is too short.
  • De-esser: a compressor that acts only on the 5 to 8 kHz band where "s" and "sh" sounds (sibilance) concentrate.
  • VCA group: one fader that moves several strips' levels together without mixing them first; see VCA groups vs submixes.
A script that writes test voices and a music bed

Save as make_show.py and run python make_show.py (NumPy comes with pip install libsonare). The host talks for the first 3 seconds and the last 3; the guest talks in between.

python
"""Write host.wav, guest.wav and music-bed.wav: 8 s, 48 kHz mono test material."""
import wave

import numpy as np

SR = 48000
t = np.arange(8 * SR) / SR
rng = np.random.default_rng(5)


def save(path, x):
    with wave.open(path, "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(SR)
        w.writeframes((np.clip(x, -1.0, 1.0) * 32767).astype("<i2").tobytes())


def voice(f0, talking):
    """Vowel-like syllables at about 4 per second, plus a little breath noise."""
    phase = 2 * np.pi * np.cumsum(f0 * (1 + 0.05 * np.sin(2 * np.pi * 0.7 * t))) / SR
    tone = sum(np.sin(k * phase) / k for k in range(1, 30))
    syllables = np.clip(np.sin(2 * np.pi * 4.0 * t), 0.0, None) ** 0.5
    x = (0.25 * tone + 0.02 * rng.standard_normal(len(t))) * syllables
    return x * talking


host_talks = ((t < 3.0) | (t >= 5.0)).astype(float)
save("host.wav", voice(120.0, host_talks))
save("guest.wav", voice(200.0, 1.0 - host_talks))
save("music-bed.wav", 0.3 * sum(np.sin(2 * np.pi * f * t) for f in (220.0, 277.2, 329.6)) / 3)

What You Will Learn ​

By the end of this page you should be able to:

  • render host, guest, and a music bed through the built-in commentaryDucking mixer preset and read exactly what routes the duck;
  • edit the duck depth in the scene and tell a real change from a no-op;
  • master an episode with the speech preset and see how the assistant explains that named choice;
  • read the loudness number that decides whether the episode is ready for a podcast platform.

The whole job ​

bash
#!/usr/bin/env bash
set -euo pipefail

# 1. Fetch the built-in ducking scene and inspect it before rendering.
sonare mixing-preset --preset commentaryDucking --json > scene.json

# 2. Deepen the duck: lower the threshold so the host's speech crosses it on every
#    phrase, and hold the bed down through each sentence with a slower release.
python3 -c '
import json
scene = json.load(open("scene.json"))
for strip in scene["strips"]:
    if strip["id"] == "music-bed":
        for insert in strip["inserts"]:
            if insert["processor"] == "dynamics.sidechainRouter":
                insert["params"].update({"thresholdDb": -36, "ratio": 4, "attackMs": 10, "releaseMs": 400})
json.dump(scene, open("scene.json", "w"))
'

# 3. Render host, guest, and the bed through the edited scene.
sonare mix --scene scene.json \
  --input host=host.wav \
  --input guest=guest.wav \
  --input music-bed=music-bed.wav \
  -o show.wav --json

# 4. Master for the platform with the explicit speech preset.
sonare mastering show.wav \
  --preset speech \
  -o show-master.wav --json

# 5. Confirm the loudness the episode actually landed at (a second render is
#    unavoidable here: --report only comes from a run that also writes audio).
sonare mastering show.wav \
  --assistant --preset speech --explain --target-platform podcast \
  -o show-assistant.wav --report show-report.json --json
Host, guest, and a bed to a ducked episode
sidechainKeyhost.wavguest.wavmusic-bed.wavhost: de-ess+ compressguest: compressmusic-bed:sidechainRoutermaster busshow.wavmastering--preset speechshow-master.wav
Only the host strip drives the duck. Guest speech does not lower the music bed.

Step 1 — Fetch the preset and read what it wires ​

sonare mixing-presets --json lists three built-in scenes: vocalReverbSend, drumBusSubgroup, and commentaryDucking. The third is this job:

bash
sonare mixing-preset --preset commentaryDucking --json
typescript
import { init, mixingScenePresetJson } from '@libraz/libsonare';

await init();
const scene = JSON.parse(mixingScenePresetJson('commentaryDucking'));
python
import json
import libsonare as sonare

scene = json.loads(sonare.mixing_scene_preset_json("commentaryDucking"))

Read the scene before rendering anything — the preset is a starting point, not a black box, and its strips say exactly what it does:

StripfaderDbInsertsNotes
host-3dynamics.deesser (6000 Hz, inside the 5 to 8 kHz sibilance band; threshold -24 dB), then dynamics.compressor (threshold -20 dB, ratio 3)pan: 0, centered
guest-4dynamics.compressor (threshold -22 dB, ratio 2.5)pan: 0.1, nudged slightly right so the two voices don't sit on top of each other
music-bed-18dynamics.sidechainRouter (rangeDb: 18, sidechainKey: "host", slot: "post")its fader sits 15 dB below the host's before the duck even engages; the actual level gap depends on the files

All three strips connect straight to the master bus — there is no aux return to manage, unlike the reverb-and-delay returns in Mix and Master a Song in the CLI. A voices VCA group holds host and guest together at gainDb: 0, so a single fader move can bring both speakers down for, say, a phone-in segment without touching the bed.

The duck itself is one insert: dynamics.sidechainRouter sits on the music-bed strip's post-fader slot, keyed to sidechainKey: "host". That key is the whole mechanism — it is not a stereo bus both host and guest feed into. Only the host track ducks the bed. If your guest talks over music and the host is silent, this preset leaves the bed at full level. That is a defensible default for an interview show where one person carries the narration, but it means a two-host chat show needs a second sidechain insert keyed to guest, or a bus that sums both voices before the sidechain reads it — either is a manual edit to the fetched scene, not a flag.

Read the scene before you render

mixing-preset --preset <name> --json is just a JSON dump — nothing here touches audio. Piping it straight into mix --scene - would work, but printing it to a file first, as the script above does, is what lets you catch a one-sided sidechain like this before you've rendered anything.

Step 2 — Set the duck depth ​

The preset as fetched barely ducks. Its threshold (-24 dB) sits above most of the host's speech, so the router asks for little reduction: on this material the bed drops 3.2 dB while the host talks, a change most listeners will not notice. A bed that stays out of the way of speech sits 6 to 12 dB lower, so the script at the top of this page changes four of the router's controls. The preset sends only rangeDb, so the rest run at their defaults until you override them:

ParameterDefaultStarting point for music under speechWhy
thresholdDb-24about 10 dB below the host's typical speaking levelthe host must cross it on every phrase, or nothing ducks
ratio44together with how far the host crosses the threshold, this sets the depth; aim for the bed 6 to 12 dB lower while the host talks
attackMs55 to 20fast enough that the first syllable is not masked, slow enough not to click
releaseMs100300 to 500100 ms lets the bed swell back in every gap between words, which is heard as pumping; a slower release holds it down through a sentence
rangeDb18 (the preset's)12 to 18a cap, so a shout cannot bury the bed entirely

These are starting points, not answers: speaking levels and beds differ, so set them, render, and measure. The script edits them by merging into the insert's params:

python
insert["params"].update({"thresholdDb": -36, "ratio": 4, "attackMs": 10, "releaseMs": 400})
MIXER · SIDECHAIN DUCKIDLE
Ducking: a voice pulls the music bed down

A voice (top) keys dynamics.sidechainRouter on a music bed, as in the commentaryDucking scene. The middle lane is the bed before (grey) and after (teal) the duck; the bottom lane is the gain reduction between them, capped at 18 dB. At the defaults the bed sits about 10 dB down under the voice, but the 100 ms release lets it swell back to about −2 dB in each 150 ms gap between phrases: that swing is the pumping. Raise the release to 500 ms and the bed stays near −11 dB through the sentence, coming back only in the long pause. Press play to hear the mix.

Release
100 ms
Threshold
-30 dB
Ratio
4

A whole-file number cannot tell you how deep the duck is. Measure the bed only while the host is talking, and compare it with the same stretch of bed without the duck. The script below does that: it renders the scene three times (as is, with the duck removed, and with only the bed), recovers the ducked bed by subtraction, and averages both versions of the bed over the 10 ms blocks where host.wav is louder than -40 dBFS.

A script that measures the duck under speech

Save as duck_depth.py next to the three WAV files and run python duck_depth.py scene.json.

python
"""Print how far the duck pulls the music bed down while the host is talking."""
import copy
import json
import subprocess
import sys

import numpy as np
from libsonare import Audio

BLOCK = 480  # 10 ms at 48 kHz


def render(scene, path):
    with open("duck-scene.json", "w") as f:
        json.dump(scene, f)
    subprocess.run(
        ["sonare", "mix", "--scene", "duck-scene.json", "--input", "host=host.wav",
         "--input", "guest=guest.wav", "--input", "music-bed=music-bed.wav", "-o", path],
        check=True, capture_output=True,
    )
    with Audio.from_file_channel(path, 0) as audio:
        return audio.data


def variant(scene, duck=True, voices=True):
    s = copy.deepcopy(scene)
    for strip in s["strips"]:
        if strip["id"] == "music-bed" and not duck:
            strip["inserts"] = []
        if strip["id"] in ("host", "guest") and not voices:
            strip["muted"] = True
    return s


def block_db(x):
    x = x[: len(x) // BLOCK * BLOCK].reshape(-1, BLOCK)
    return 10 * np.log10(np.mean(x**2, axis=1) + 1e-12)


def level_db(x):
    blocks = block_db(x)
    n = min(len(blocks), len(talking))
    return 10 * np.log10(np.mean(10 ** (blocks[:n][talking[:n]] / 10)))


scene = json.load(open(sys.argv[1]))
bed_alone = render(variant(scene, duck=False, voices=False), "duck-c.wav")
# The master bus is a plain sum, so (with duck) - (without duck) + (bed alone)
# leaves the ducked bed by itself.
ducked = (render(variant(scene), "duck-a.wav") - render(variant(scene, duck=False), "duck-b.wav")
          + bed_alone)
with Audio.from_file("host.wav") as audio:
    talking = block_db(audio.data) > -40.0  # 10 ms blocks where the host is speaking

before, after = level_db(bed_alone), level_db(ducked)
print(f"bed while the host talks: {before:.1f} dB unducked, {after:.1f} dB ducked, "
      f"duck depth {before - after:.1f} dB")

On the test material from the script at the top of this page, the unedited preset ducks the bed by 3.2 dB while the host talks, and the update line above (with rangeDb left at the preset's 18) by 8.6 dB. Measure the scene you will actually render.

The sidechain router is a compressor whose detector listens to the host instead of the bed, so the reduction it asks for is the shaded gain-reduction gap below: how far the key rises above the threshold, scaled by the ratio, and nothing more. Raise the threshold until the program no longer crosses it and the gap disappears — that is the state the warning below describes, and no rangeDb brings it back.

DYNAMICS · COMPRESSORIDLE
Compression — threshold, ratio, knee, attack, release

The left panel is the transfer curve — input level in, output level out — with the threshold and the soft knee marked; raise the ratio and it bends harder past the threshold. The right panel runs a fixed program (a steady bed with transient hits) through the compressor: the shaded gap is the gain reduction, and attack and release decide how fast it clamps down and lets go. Press play to hear the same program — the pumping you see is the pumping you hear.

Threshold
-18 dB
Ratio
4 :1
Knee
6 dB
Attack
15 ms
Release
160 ms

rangeDb is a ceiling, and most edits to it do nothing ​

rangeDb is the most the bed is allowed to be pulled down. The router only pulls it that far if the host's level calls for it, so a cap above what the host asks for changes nothing. On the shipped scene, cmp shows it:

bash
# rangeDb 30 instead of the scene's 18
cmp show.wav show-r30.wav   # identical, no output

The host never calls for more than about 14.3 dB of reduction (a ceiling of 14.2 still changes the render, 14.3 does not), so any ceiling at or above that behaves the same as no ceiling. Lower it below that point and the edit is real. sonare dynamics on the whole-file mixdown shows that something changed, but averaged over 8 seconds that include long stretches where the host is silent, the change looks tiny:

rangeDbrms_db (whole file)peak_dbRender vs 18
none (insert removed entirely)-24.39-10.63differs
1-24.44-10.63differs
5-24.56-10.63differs
18 (the scene's value)-24.62-10.63none
30-24.62-10.63identical bytes

The duck-depth script reads the same thing directly: with the cap at 5 the bed drops 4.2 dB while the host talks, at 12 it drops 8.1 dB, and at 18 it drops 8.6 dB.

A cap you never hit is not a cap you tuned

If lowering rangeDb does nothing to your render either, the problem isn't the parameter. Your host track probably sits quieter than the threshold the router reacts to, so no ducking is being requested at all. Confirm the duck is engaging at the scene's default before spending time tuning how deep it goes.

Step 3 — Render ​

bash
sonare mix --scene scene.json \
  --input host=host.wav \
  --input guest=guest.wav \
  --input music-bed=music-bed.wav \
  -o show.wav --json
json
{"strip_count": 3, "sample_rate": 48000, "block_size": 512, "rendered_samples": 384124, "output": "show.wav"}

strip_count is 3 because this scene has no aux returns — every --input names a real strip, so there is no silent return to account for the way there is in the four-stem song scene. Each --input id must match a strip id in the scene; a strip nothing names is fed silence, which is the right behavior for a bed you deliberately withhold on some episodes but a silent surprise if you typo music-bed as musicbed.

Step 4 — Master with the speech preset ​

bash
sonare mastering show.wav --preset speech -o show-master.wav --json
json
{"mode": "preset", "input_lufs": -22.48, "output_lufs": -16.00, "applied_gain_db": 7.00, "output": "show-master.wav", "preset": "speech", "stages": ["repair.denoise", "eq.tilt", "dynamics.deesser", "dynamics.compressor", "loudness.optimize"]}

The assistant can start from the same named preset and add its explanation to the report:

bash
sonare mastering show.wav --assistant --preset speech --explain --target-platform podcast -o show-assistant.wav --report show-report.json --json
json
{"mode": "assistant", "input_lufs": -22.48, "output_lufs": -16.00, "applied_gain_db": 7.01, "stages": ["repair.denoise", "eq.tilt", "dynamics.deesser", "dynamics.compressor", "stereo.monoMaker", "loudness.optimize"], "explanation": ["base preset: speech", "target loudness and ceiling applied from AssistantConfig", "speech preset enables de-esser and mono compatibility"]}

The standalone speech preset targets its built-in -16 LUFS. The assistant run starts from the same chain and gets -16 LUFS from --target-platform podcast; it also adds the speech preset's low-frequency mono-maker stage and includes the reasons in explanation. The assistant never infers a genre from the audio, so the --preset speech choice stays visible and reproducible.

Clean the voices before the mix, not the finished show

Both runs start with repair.denoise, and at this point it runs over the whole show, music bed included. Noise reduction on music smears its sustain and leaves watery artifacts, and the bed needs none. With real recordings, clean each voice before Step 3 and switch the master's denoise off:

bash
sonare repair host.wav -o host-clean.wav --explain --json
sonare repair guest.wav -o guest-clean.wav --explain --json
# Mix host-clean.wav and guest-clean.wav in Step 3, then:
sonare mastering show.wav --preset speech --params repair.denoise.enabled=0 \
  -o show-master.wav --json

With repair.denoise.enabled=0 the stage list starts at eq.tilt; the same --params works on the --assistant run. repair measures each voice and runs only the stages its evidence calls for (see Clean Up a Batch of Recordings); it scales a voice whose repaired peak went past full scale back to full scale, so check the voice levels again before you mix. The host strip in this scene also carries its own de-esser, so the master's dynamics.deesser is a second pass on the host: if the host's "s" sounds start to lisp, lower one of the two.

--target-platform requires --assistant

bash
sonare mastering show.wav --preset speech --target-platform podcast -o x.wav --json
# Error: --target-platform requires --assistant
# exit code 3

The platform target is an assistant input, not a global option. A standalone --preset run cannot combine it, so the flag is rejected. Use --assistant --preset speech --target-platform podcast when you need both the named speech chain and the platform target.

--target-platform accepts eight names, but only four of them change anything:

--target-platformLoudness targetTrue-peak ceiling
broadcast-23 LUFS-1 dBTP
podcast-16 LUFS-1 dBTP
club, cd-9 LUFS-0.3 dBTP (no published source for either number)
streaming, youtube, audiobook, cinemawhatever --target-lufs says (default -14 LUFS)whatever --ceiling-db says (default -1 dBTP)

Broadcast (EBU R128) and podcast are the two rows with an established basis. club and cd are the library's own loud-master settings: no published standard backs those numbers, and CD has no loudness standard at all, so treat them as a convenience rather than a delivery spec. The last row applies nothing of its own. In particular, --target-platform audiobook on its own masters to -14 LUFS and -1 dBTP, which is louder and hotter than audiobook distributors usually accept (commonly RMS between -23 and -18 dBFS, with peaks at or below -3 dBFS, and a noise floor around -60 dBFS or lower). Those specs are RMS and sample peak, while --target-lufs and --ceiling-db are LUFS and true peak; for speech the two agree only roughly, so measure the RMS of the result yourself as well. For an audiobook, pass the numbers yourself and check them against the distributor's spec:

bash
sonare mastering chapter.wav --assistant --preset speech \
  --target-lufs -20 --ceiling-db -3 -o chapter-master.wav --report chapter-report.json --json

An explicit --target-lufs or --ceiling-db also wins over the four targets that do supply values. Full option list: sonare mastering --help. What each target actually asks for is in Delivery Targets.

Step 5 — Check whether your voices are safe in mono ​

bash
sonare mastering show.wav --assistant --preset speech --target-platform podcast --speech-mono-amount 0 -o show-mono0.wav --json
sonare mastering show.wav --assistant --preset speech --target-platform podcast --speech-mono-amount 1 -o show-mono1.wav --json
if cmp -s show-mono0.wav show-mono1.wav; then
  echo "mono amount did not change these files"
else
  echo "mono amount changed the render"
fi

--speech-mono-amount (0–1, default 1) controls how much of the speech preset's side signal below its 120 Hz mono-maker crossover is collapsed toward mono. The assistant applies it because --preset speech selects that preset; it does not depend on audio genre classification. A value of 0 leaves that low-frequency side signal unchanged, while 1 removes that side component through the crossover; higher frequencies retain their side signal. The audible and byte-level difference depends on how much low-frequency side information the episode carries. On this episode the script prints mono amount changed the render.

The mono-maker keeps bass energy centred, so the level and headroom of the low end stay consistent across playback systems. It does not make the whole file mono-safe: a phone speaker, a Bluetooth earbud in mono-call mode, and a podcast app's "boost voice" setting all sum left and right across the full band, and that is a separate check (see Mono Compatibility and the mono-fold demo there). host's pan: 0 and guest's pan: 0.1 in this preset are already close to center, so the amount matters most when the recordings carry wider low-frequency side information, such as a stereo room mic on the guest.

Verify the preset before trusting a speech-only flag

Run with --explain and check for speech preset enables de-esser and mono compatibility in explanation. That confirms the assistant started from the speech preset and applied the speech-specific stage.

Step 6 — Read the loudness that matters ​

--report show-report.json from the podcast-targeted assistant run holds the number that decides whether the episode is ready:

json
{
  "before": { "integrated_lufs": -22.48, "true_peak_dbtp": -10.58 },
  "after":  { "integrated_lufs": -16.00, "true_peak_dbtp": -4.07 },
  "applied_gain_db": 7.01,
  "max_gain_reduction_db": -0.50,
  "loudness_target_limited": false
}

-16 LUFS is what --target-platform podcast asked for, and after.integrated_lufs is the measured result. loudness_target_limited: false means the requested target was reached within the configured constraints; it does not mean that the limiter was inactive or that the master is finished. max_gain_reduction_db reports the actual maximum gain reduction. Check the report's loudness and true-peak values, then audition the result. See LUFS for what the number means and Delivery Targets for what other platforms expect instead of -16.

As with the four-stem case in Mix and Master a Song in the CLI, a separate sonare lufs show-master.wav call would disagree with this figure — it downmixes to mono first and warns on stderr before doing so. For a stereo deliverable, the mastering report's number is the one to believe.

Where to go next ​