You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Limiter for normalize_audio: reach target_lufs past the peak ceiling (split from #467) #474
Split from #467. #467 is now a fix for the template and the skill: episodes are matched downward to a series loudness that every episode can reach. This issue is the other half.
Idea
Add a limiter to the audio tasks, either as a new limit_audio task or as a limit: true mode on normalize_audio. It would let target_lufs go past the peak ceiling by squashing a few transient peaks, for example the studio-audience laugh in #467. That laugh has a 22.7 dB crest and holds Rollback at -20.7 LUFS with a -3 dBFS peak.
Why it's an idea, not a fix
Loudness matching doesn't need a limiter: target_lufs can always lower gain (see #467). A limiter only matters if a series should sit louder than its most dynamic episode allows, for example the -14 to -16 LUFS streaming platforms target, compared with the roughly -21 LUFS these episodes reach without one.
What a design would have to settle
True-peak detection with oversampling. normalize_audio and get_gallery_metadata measure sample peak, and AAC encoding can overshoot between samples.
Look-ahead, attack and release, so it doesn't pump on laughter or music.
A dependency or in-house DSP. pyloudnorm measures loudness but doesn't limit.
How it's reported: gain reduction applied, and how many samples it touched.
Whether there's demand. So far one series has hit the ceiling, and matching downward solved it.
Filed by Claude Opus 5.5 (claude-opus-5-5, anthropic) at Don's direction.
Implementation input from an Opus 5.5 field session (local ffmpeg assembly of a musical short, 2026-09-26): ffmpeg's alimiterauto-levels its output up to the limit by default. After an AAC encode on top, that clipped. With level=disabled and a -3 dB ceiling, the final came out at -16.4 LUFS integrated, peak -2.7 dBFS. If the limiter here uses alimiter, set level=disabled, or the limiter itself becomes a gain stage.
Plan v2: a true-peak limit mode for normalize_audio
Designed by Claude Opus 5.5 (claude-opus-5-5, anthropic) against origin/develop0540972. v2 (same model, same provider) re-plans stage 2 after it bounced at C-F135 on #497: the limited mix measures -3.0 dBTP, but the AAC mux of the film comes out at -2.54 dBTP. See Q6.
Status: stage 1 (#496) is verified and stands. Stage 2 (#497) is merged at 2122558. Its template variable and skill lines passed (C-F136). Only the film's true-peak check failed.
Verdict: build smaller
Build it as one limit: true argument on normalize_audio: a look-ahead, true-peak limiter with fixed timings. That lets target_lufs go past the peak_dbfs ceiling. Don't add a limit_audio task and don't expose attack, release or look-ahead knobs. After that, one small stage exposes it on assemble-and-score and in the series-episodes skill.
Correction to the idea: dw already has a limiter.compress_audio(mode="limit") (dw/tasks/audio_utils.py:1573, added in #200/#207) is a sample-peak envelope follower. It has no look-ahead, so with attack_ms > 0 the start of every transient gets through above the threshold, and it reports nothing. No template or skill uses it. It can't do this job because it lets the laugh through, but it bears on Q2.
Value
Who gains: Don's driving sessions that finish a series or a song for playback at platform loudness (-14 to -16 LUFS). Today those land around -21 whenever one transient sets the peak.
Real use: Don's workspaces include several series and music productions (rack-four, shedding-skin-quartet, encanto-uprising, inner-space, acorn-wars, and others). I excluded qa-* and regression-*. Any of them could hit the same ceiling as soon as a louder target is asked for.
How often: occasional, at the finishing step of a piece rather than on every run. Demand is modest but real, and it is growing with the series work.
Build cost
Stage 1 is about $4–6 and stage 2 about $2–3, so about $6–9 in total.
Maintenance: one private DSP routine (about 60 lines of numpy/scipy) whose correctness has to stay pinned by tests, mainly that the output true peak is ≤ the ceiling.
Dependency:scipy is already imported at module level by dw/loudness.py:19, but it is only installed transitively through controlnet-aux. Stage 1 declares it in pyproject.toml. That fixes an existing latent gap as well.
Concepts and security: no new concept (dw already has dynamics tasks), and no security boundary is touched.
Reversibility
The argument defaults to false, so behaviour is unchanged for everyone who doesn't set it. Removing it later would break only workflows that set it: stage 2's template and anything an agent saved. That is small, but it would still be a breaking-change.
Document compress_audio(mode="limit") before normalize_audio(target_lufs). This costs nothing to build, but it doesn't work: without look-ahead the transient gets through, so the true peak stays over the ceiling and normalize_audio still caps. The agent also has to pick a threshold by hand.
Shell out to ffmpeg alimiter. That would be the first ffmpeg subprocess in the engine (none exists today; imageio-ffmpeg bundles the binary). It auto-levels unless level=disabled, and it adds a process boundary and argument sanitising. All of that buys less than about 60 lines of numpy.
A standalone limit_audio task. It gets the same result with more surface: a new task, docs and domains, plus a step_cache.NORMALIZING_COMMANDS entry (dw/step_cache.py:115). The agent also has to order the two steps correctly, gain first and then limit. As a mode on normalize_audio, the one step that sets the level also holds the ceiling.
Proposed follow-ups (not in this plan; Don decides whether each becomes an issue)
compress_audio(mode="limit") earns nothing outside its own tests. See Q2.
warn_if_written_above_full_scale (dw/result.py:223) judges the encoded file on sample peak, although probe_media already measures true_peak_dbfs. That is a separate question from this feature.
Problem and non-goals
Problem.normalize_audio(target_lufs=T, peak_dbfs=C) applies min(T - measured, C - sample_peak) as one static gain (audio_utils.py:1458). When one transient sets the peak, the track can't reach T, and the step warns target_lufs_capped.
Non-goals:
a general mastering chain (multiband, stereo processing, compression below the limit);
user-tunable limiter timings;
changing any default;
changing compress_audio;
changing how the encoded file is checked.
The design
Engine: normalize_audio gets limit=False
The signature becomes normalize_audio(audio, peak_dbfs=-1.0, target_lufs=None, limit=False, sample_rate=None). Positional use is unaffected in practice, because templates pass keywords.
With limit=True:
What peak_dbfs means. It becomes a true-peak ceiling: dBTP, measured with 4x oversampling (BS.1770, dw/loudness.py). It is no longer a sample-peak ceiling.
Gain. The gain is T - measured_lufs when target_lufs is given. Without target_lufs, it is the peak-normalising gain as today. The gain is applied without the ceiling cap. The makeup gain comes only from target_lufs, so the limiter never becomes a gain stage. That is the alimiter auto-level trap from the comment above, avoided by construction.
The limiter (a private routine in audio_utils.py):
Detection: linked across channels (the loudest channel drives everything, so the stereo image doesn't shift). It reads the per-sample true peak from a 4x resample_poly oversample.
Required gain: per sample, min(1, ceiling / peak).
Look-ahead: a sliding minimum over the look-ahead window, followed by a boxcar of the same length. The gain therefore ramps down before the transient and is ≤ what's required at every sample.
Release: a hold plus a smoothed release.
Timings (fixed): about 5 ms look-ahead and about 150 ms release. They are long enough not to pump on a laugh and short enough not to duck the line after it. The builder may tune them within that intent and pins the chosen numbers in tests.
Speed: fully vectorised (scipy.ndimage / numpy). There is no per-sample Python loop; the 2026-09-22 quality review flagged _follow_envelope's loop.
Delay: the output isn't delayed. The look-ahead is applied to the gain curve, never to the signal, so sync with picture is untouched.
Check pass. Re-measure the output's true peak. If reconstruction still leaves it above the ceiling, apply one static trim to meet the ceiling. Then re-measure LUFS. Limiting lowers loudness a little, so make one correction pass: add the shortfall to the gain and limit again.
A cap on how hard it squashes. Gain reduction never exceeds 12 dB. If reaching T would need more, the gain stops at what 12 dB of reduction allows. The existing target_lufs_capped warning then fires with limited: true and shortfall_lu, so a request like "-10 LUFS on a dialogue track" doesn't silently destroy it.
When the ceiling isn't in the way, the limiter touches nothing (limited_fraction 0). The output then equals the limit=False output, apart from the true-peak vs sample-peak difference when target_lufs isn't given.
limit=False (the default) is byte-for-byte today's behaviour, including target_lufs_capped and the peak_ceiling constraint.
Other surfaces
MCP, REST, syntax: none.
step_cache.NORMALIZING_COMMANDS: unchanged, since this is the same command.
Schema:get_task's schema picks up limit from the signature (dw/introspection.py:428).
task_domains.py: no entry, because a boolean has no numeric domain.
Stages
Stage 1: normalize_audio(limit=true) (server)
Builds: the engine design above.
Tests (tests/test_audio_utils.py):
the output true peak is ≤ the ceiling (+0.05 dB tolerance) on a synthetic track with a +20 dB crest transient over a steady bed;
LUFS lands within 0.5 LU of the target;
the 12 dB cap produces target_lufs_capped with limited: true;
limiter_heavy fires above 6 dB of reduction;
no reduction happens when the ceiling isn't in the way;
the default path is unchanged (the existing tests at :671–777 still pass untouched);
a silent track is returned unchanged;
the output has the same length as the input (no delay);
timing: a 3-minute stereo 48 kHz track limits in a few seconds.
Other changes:
declare scipy in pyproject.toml;
docs/TASKS.md: add target_lufs and limit rows to the normalize_audio table (the target_lufs row is missing today) and a line under compress_audio saying its limit mode is sample-peak with no look-ahead, and pointing to normalize_audio(limit=true) for a true-peak ceiling;
update the normalize_audio bullets in CLAUDE.md.
Deploy: server.
Estimate: $4–6.
Acceptance intent (over MCP). The tester runs inline workflows on a track whose crest makes target_lufs cap: an uploaded or kept laugh-track episode, or any dialogue cut that warns target_lufs_capped at peak_dbfs: -3.
limit omitted: same result and warnings as today (target_lufs_capped, and the log constraint: "peak_ceiling").
target_lufs: -16, peak_dbfs: -3, limit: true, saved as wav:
get_gallery_metadata gives integrated_lufs within 0.5 LU of -16 and true_peak_dbfs ≤ -3.0 (+0.1);
the job's events carry a log with constraint: "limiter", max_gain_reduction_db > 0 and limited_fraction between 0 and 1;
there is no target_lufs_capped warning.
The same limited track muxed into an mp4 (pair_audio): no audio_clipped warning, and a decoded peak below 0 dBFS.
A target needing more than 12 dB of reduction (e.g. -8 on the same track): target_lufs_capped with limited: true and shortfall_lu > 0, plus limiter_heavy; the output true peak is still ≤ the ceiling.
A target reachable without limiting: limited_fraction: 0, and the same LUFS as limit: false.
limit: true without target_lufs: true_peak_dbfs ≤ peak_dbfs.
A silent track: unchanged, with the existing silent warning.
The output duration equals the input's (get_gallery_metadata), and speech in the limited file stays in sync with the picture after muxing.
get_task("normalize_audio") lists limit with its description.
Stage 2: expose it on assemble-and-score and in the series-episodes skill (server and plugin)
one line in minimax-music3 saying the same for a song master;
(v2) the template description says where the ceiling holds. With limit, -3 dBTP is held on the mix, and the AAC mux of the film can land up to about 1 dB above it on limited material: -2.54 on the first laugh-track episode. It stops promising -3 dBTP on the film. Per Q6's default, this is the whole v2 change; the rest of stage 2 is already built.
Tests: the template test that pins assemble-and-score's variables, and one test that limit reaches the balanced step. tests/test_plugin_skills.py if it pins numbers in the skill text.
Deploy: server (the template) and plugin (the skills).
Estimate: $2–3 (spent). v2 adds about $0.5 for the description and its acceptance run.
Acceptance intent:
Over MCP: list_workflows shows limit on assemble-and-score.
The defaults give the same result as before.
target_lufs: -16, limit: true on a laugh-track episode: the film lands within 0.5 LU of -16. (v2) The balanced log has output_true_peak_dbfs ≤ -3.0. The film's true_peak_dbfs is ≤ -2.0, which is the mix ceiling plus a 1 dB encode allowance and still under the -1 dBTP streaming ceiling. There is no audio_clipped. The template description no longer claims -3 dBTP on the film.
The skill text is verified by the tester reading the file at develop, since the plugin has no MCP surface. Don approves that line.
Artefacts. A fixed-timing limiter can still be audible on some material (sustained music peaks). The 6 dB limiter_heavy warning and the 12 dB cap bound it. Exposed timings are the thing a field report of pumping would bring back.
True-peak meter vs AAC. A true-peak ceiling of -3 is sound practice for AAC (the idea: musical join task - spoken scene into song with the song entering under the last line, as a run output with provenance #486 session measured -2.7 decoded). An encoder can still overshoot; the existing audio_clipped post-encode check stays as the backstop. (v2) Confirmed and measured: the AAC mux adds about 0.46 dB on a limited laugh-track mix (16% of samples at the ceiling) and about -0.1 dB on an unlimited one. The overshoot depends on the material (CLAUDE.md already records 1.9 dB on an unnormalized song), so no fixed margin can be known to be enough.
Speed. The oversampled detection costs about 4x the samples. It has to stay vectorised; the tests pin the timing.
Test sensitivity. A synthetic crest test can pass while real laughter pumps. The acceptance cases use a real episode for that reason.
Questions
Q1. Should this be a mode on normalize_audio or a new limit_audio task? Default: the mode. A task would add a task, docs, domain entries and a NORMALIZING_COMMANDS entry, and agents would have to order two steps correctly. Stage 1 would rise about $1.
Q2. What happens to compress_audio(mode="limit")? Default: leave it, and document the difference. One option is to route it through the new limiter: a behaviour change with no consumers, about +$1 in stage 1. Another is to remove the mode, which is a breaking-change and would be its own issue.
Q3. Fixed timings and fixed thresholds (12 dB cap, 6 dB warning)? Default: fixed. Exposing lookahead_ms/release_ms/max_reduction_db adds three arguments, three domain entries and the tests for them, with no demand yet.
Q4. With limit: true, is peak_dbfs a true-peak ceiling? Default: yes. The alternative, keeping it sample-peak, leaves the inter-sample overs that AAC turns into clipping, which is what the limiter is for.
Q5. Should stage 2 expose limit beyond assemble-and-score? Default: assemble-and-score only (the series recipe). Adding dissolve-between-shots and minimax/music-video as well costs about +$1 and needs the same target_lufs exposure there first.
Q6 (v2). How far should the -3 dBTP ceiling reach under limit: true: the mix or the muxed film? Default: the mix. Document that the film can land up to about 1 dB above it, and gate the acceptance at ≤ -2.0 dBTP with no audio_clipped (above). That costs about $0.5, the plan's non-goals stand (the encoded file's check is unchanged), and -2.5 is still well inside the -1 dBTP delivery ceilings the streaming target is for. #486's hand-mastered result was -2.7. Alternatives:
(b) A fixed encode margin.normalize_audio(limit=true) holds peak_dbfs minus about 1 dB internally. That is a behaviour change to verified stage 1, the log would report a ceiling that isn't the one asked for, and no fixed margin can be known to be enough. About +$1.5 and a re-verify of stage 1.
(c) Trim after the encode. The lossy mux in Result.save re-probes the written file's true peak and, over a ceiling, trims the audio and re-encodes once. This is the only option that guarantees the film, but it lifts a non-goal ("changing how the encoded file is checked") and needs a ceiling carried to pair_audio or the result. It would be a new stage 3 of about $4–6, touching dw/result.py, and it should also answer the follow-up about warn_if_written_above_full_scale reading sample peak.
Reply in comments and hand back with owner:lead. Approve by adding status:plan-approved yourself. No agent may set it.
the balanced log has constraint: "limiter" and output_true_peak_dbfs ≤ −3.0 (+0.05);
the film lands within 0.5 LU of −16, with true_peak_dbfs ≤ −2.0 and no audio_clipped or target_lufs_capped;
the template description says the ceiling holds on the mix, that the AAC film can land up to about 1 dB above it, and no longer promises −3 dBTP on the film.
It also carries C-F135's checks unchanged: the limit variable with default false, omitted and false giving the same capped result (peak_ceiling), and the three films having the same shape. It records the encode overshoot (film true peak minus the mix's) in the report.
C-F136 (the skill text) is unaffected and stands.
C-F135 still asserts the v1 film gate (≤ −2.9 dBTP), so it would fail against the approved v2 behaviour. I haven't edited it. Retiring it in favour of C-F150 is requested in dkackman/harnest#25 (suite + status:needs-approval). Until that is ruled on, a verify of #497 should judge it by C-F150 and C-F136.
Before writing these, I pinned the current template shape with a read-only get_workflow("templates/assemble-and-score", variables_only=true): limit: false and target_lufs: null are present.
Specced by Claude Opus 5.5 (claude-opus-5-5, anthropic). No GPU calls.
Close-out by Claude Opus 5.5 (claude-opus-5-5, anthropic).
Closed: stage 1 #496 (normalize_audio(limit=true), verified at 6a5cbd5) and stage 2 #497 (limit on assemble-and-score and in the series-episodes / minimax-music3 skills, verified under plan v2 at b8afddf). Each bounced once; the record says why.
Design record:docs/proposals/complete/normalize-audio-limiter-complete.md, with a todo.md entry, in 3b84b37 and merged to develop as d4e55c7. Docs only, so there is no deploy. It lists what was deferred: the ceiling on the encoded film (Q6 b/c), true-peak in warn_if_written_above_full_scale, compress_audio's limit mode, limit on other templates, and tunable timings.
verify: run every case whose source: names one of this feature's stages (#496, #497): C-F132, C-F133, C-F134, C-F150 and C-F136. Close the parent if they all pass. C-F135 carries the v1 film gate, and dkackman/harnest#25 asks to retire it, so judge #497 by C-F150.
Verified. Tester run on Claude Opus 5.5 (claude-opus-5-5, provider anthropic), against lem at develop @ cdfd02f, workspace regression-complete. I made every check below with an MCP call this session. The feature parent is judged by its stages' cases: C-F132, C-F133 and C-F134 for #496, and C-F150 and C-F136 for #497. #497 is judged by C-F150, not C-F135, per dkackman/harnest#25.
get_task("normalize_audio") lists limit (default false). Its description names the true-peak ceiling (4x oversampled) and the 12 dB cap.
C-F132: pass (job c2f3188e19b4)
capped and capped_false: both warn target_lufs_capped ("+1.4 dB … caps the gain at -2.0 dB"). Both logs read constraint: peak_ceiling with no limiter fields. The two files are identical: −19.39 LUFS, peak −3.000.
limited file: −15.96 LUFS, true peak −2.999. No capped or heavy warning names it.
limited_mux: 472 frames, 24 fps, 960×544, 19.667 s, true peak −2.85, no audio_clipped. Its envelope bins line up with the source's: the loudest bin and the first bin above −40 fall at the same index.
C-F133: pass (job 31de74e78fd7)
heavy: limiter_heavy (8.5 dB) and no capped warning. Reduction 8.51, fraction 0.405, −14.47 LUFS, true peak −2.999.
squashed:
limiter_heavy at 12.0.
target_lufs_capped with limited: true and shortfall_lu 8.67. Both fields sit on the warning event, not the log.
The file reads −13.67 LUFS, true peak −2.999, louder than heavy.
full_scale: reduction 6.86 with limiter_heavy, true peak +0.0006 (≤ 0.1), no capped warning. It also carries audio_no_headroom (peak −0.01), which is expected at a 0 dBTP ceiling; the case doesn't cover it.
C-F134: pass (job 1ca9f19d8dab)
quiet_true: limiter with 0 reduction and fraction 0. Its file is identical to quiet_false's (−22.00 LUFS, −5.53 dBTP).
peak_true: true peak −3.000 and fraction 0.
A one-track mix_audio with gains [0] works.
silent_true and silent_false both succeed at −120 dBFS with only the near-silent warning. Neither has a limiter log or limiter_heavy.
C-F150: pass
list_workflows(shape="sequence"): variable_names includes limit and target_lufs, and get_workflow shows limit: false.
The description says the ceiling "holds on the mix 'balanced' writes, not on the film". It says the AAC mux can land "up to about 1 dB above it", and it promises no −3 dBTP on the film.
(a) no limit (job bb48dc3131ed) and (b) limit:false (job 729d7beeca7d) behave the same:
target_lufs_capped names balanced, and the log reads constraint: peak_ceiling, gain −2.0.
The two films are identical: −19.504 LUFS, true peak −3.103.
(c) limit:true (job 42a7c5237b43): validated first, valid with no warnings.
Film: −16.01 LUFS, true peak −2.537 (≤ −2.0). That is an overshoot of +0.46 dB over the log's −3.0. There are no warnings at all: no audio_clipped and no target_lufs_capped.
All three films: 472 frames, 24 fps, 960×544, 19.666667 s.
Only (c) was validated first, as the summary on this issue says. (a) and (b) ran with acknowledged_cost: true and without a separate validate.
C-F136: pass
dw:series-episodes says to match downward by default. It says to pass limit: true only when the series should sit louder than its most dynamic episode allows (e.g. −16), and that limiter_heavy means lower the series target_lufs.
dw:minimax-music3 says the same in one line for a master.
Neither skill turns limit on by default, and neither names a variable or warning that doesn't exist.
Cleanup: every run's output has been deleted with delete_output(job_id).
Activity
dkackman commentedon Sep 26, 2026
Implementation input from an Opus 5.5 field session (local ffmpeg assembly of a musical short, 2026-09-26): ffmpeg's
alimiterauto-levels its output up to the limit by default. After an AAC encode on top, that clipped. Withlevel=disabledand a -3 dB ceiling, the final came out at -16.4 LUFS integrated, peak -2.7 dBFS. If the limiter here usesalimiter, setlevel=disabled, or the limiter itself becomes a gain stage.fix(templates,plugin): #467 - expose target_lufs on assemble-and-scor…
dkackman commentedon Sep 26, 2026
Plan v2: a true-peak limit mode for
normalize_audioDesigned by Claude Opus 5.5 (
claude-opus-5-5, anthropic) againstorigin/develop0540972. v2 (same model, same provider) re-plans stage 2 after it bounced at C-F135 on #497: the limited mix measures -3.0 dBTP, but the AAC mux of the film comes out at -2.54 dBTP. See Q6.Status: stage 1 (#496) is verified and stands. Stage 2 (#497) is merged at
2122558. Its template variable and skill lines passed (C-F136). Only the film's true-peak check failed.Verdict: build smaller
Build it as one
limit: trueargument onnormalize_audio: a look-ahead, true-peak limiter with fixed timings. That letstarget_lufsgo past thepeak_dbfsceiling. Don't add alimit_audiotask and don't expose attack, release or look-ahead knobs. After that, one small stage exposes it onassemble-and-scoreand in the series-episodes skill.Correction to the idea: dw already has a limiter.
compress_audio(mode="limit")(dw/tasks/audio_utils.py:1573, added in #200/#207) is a sample-peak envelope follower. It has no look-ahead, so withattack_ms > 0the start of every transient gets through above the threshold, and it reports nothing. No template or skill uses it. It can't do this job because it lets the laugh through, but it bears on Q2.Value
alimiterand reached -16.4 LUFS with a -2.7 dBFS peak (the comment on this issue). That is the same need, met outside dw, with no provenance.rack-four,shedding-skin-quartet,encanto-uprising,inner-space,acorn-wars, and others). I excludedqa-*andregression-*. Any of them could hit the same ceiling as soon as a louder target is asked for.Build cost
Stage 1 is about $4–6 and stage 2 about $2–3, so about $6–9 in total.
Inertia
constraintvalue (limiter) and new fields on normalize_audio's existing normalize_audio (target_lufs), grade, loop_audio log nothing about what they applied — a save:false step's effect is unobservable #392 log event. There is no new task, MCP tool or syntax. The task list isn't in the MCP surface budget (tests/test_mcp_server.py:1502), so the budget is unaffected.scipyis already imported at module level bydw/loudness.py:19, but it is only installed transitively through controlnet-aux. Stage 1 declares it inpyproject.toml. That fixes an existing latent gap as well.Reversibility
The argument defaults to
false, so behaviour is unchanged for everyone who doesn't set it. Removing it later would break only workflows that set it: stage 2's template and anything an agent saved. That is small, but it would still be abreaking-change.Cheaper alternatives
compress_audio(mode="limit")beforenormalize_audio(target_lufs). This costs nothing to build, but it doesn't work: without look-ahead the transient gets through, so the true peak stays over the ceiling andnormalize_audiostill caps. The agent also has to pick a threshold by hand.alimiter. That would be the first ffmpeg subprocess in the engine (none exists today;imageio-ffmpegbundles the binary). It auto-levels unlesslevel=disabled, and it adds a process boundary and argument sanitising. All of that buys less than about 60 lines of numpy.limit_audiotask. It gets the same result with more surface: a new task, docs and domains, plus astep_cache.NORMALIZING_COMMANDSentry (dw/step_cache.py:115). The agent also has to order the two steps correctly, gain first and then limit. As a mode onnormalize_audio, the one step that sets the level also holds the ceiling.Overlap
idea): its join would want this limiter on the final mix. This plan doesn't depend on idea: musical join task - spoken scene into song with the song entering under the last line, as a run output with provenance #486 and doesn't fold any of it in; idea: musical join task - spoken scene into song with the song entering under the last line, as a run output with provenance #486 can call the same private routine once it exists.target_lufstoassemble-and-score. Stage 2 is blocked by it (see Order).Proposed follow-ups (not in this plan; Don decides whether each becomes an issue)
compress_audio(mode="limit")earns nothing outside its own tests. See Q2.warn_if_written_above_full_scale(dw/result.py:223) judges the encoded file on sample peak, althoughprobe_mediaalready measurestrue_peak_dbfs. That is a separate question from this feature.Problem and non-goals
Problem.
normalize_audio(target_lufs=T, peak_dbfs=C)appliesmin(T - measured, C - sample_peak)as one static gain (audio_utils.py:1458). When one transient sets the peak, the track can't reach T, and the step warnstarget_lufs_capped.Non-goals:
compress_audio;The design
Engine:
normalize_audiogetslimit=FalseThe signature becomes
normalize_audio(audio, peak_dbfs=-1.0, target_lufs=None, limit=False, sample_rate=None). Positional use is unaffected in practice, because templates pass keywords.With
limit=True:peak_dbfsmeans. It becomes a true-peak ceiling: dBTP, measured with 4x oversampling (BS.1770,dw/loudness.py). It is no longer a sample-peak ceiling.T - measured_lufswhentarget_lufsis given. Withouttarget_lufs, it is the peak-normalising gain as today. The gain is applied without the ceiling cap. The makeup gain comes only fromtarget_lufs, so the limiter never becomes a gain stage. That is thealimiterauto-level trap from the comment above, avoided by construction.audio_utils.py):resample_polyoversample.min(1, ceiling / peak).scipy.ndimage/numpy). There is no per-sample Python loop; the 2026-09-22 quality review flagged_follow_envelope's loop.target_lufs_cappedwarning then fires withlimited: trueandshortfall_lu, so a request like "-10 LUFS on a dialogue track" doesn't silently destroy it.emit_log:constraint: "limiter",gain_db,max_gain_reduction_db,limited_fraction(share of samples reduced),output_true_peak_dbfsandoutput_lufs. A new warning kind,limiter_heavy, fires whenmax_gain_reduction_db > 6, because pumping becomes audible around there.limited_fraction0). The output then equals thelimit=Falseoutput, apart from the true-peak vs sample-peak difference whentarget_lufsisn't given.limit=False(the default) is byte-for-byte today's behaviour, includingtarget_lufs_cappedand thepeak_ceilingconstraint.Other surfaces
step_cache.NORMALIZING_COMMANDS: unchanged, since this is the same command.get_task's schema picks uplimitfrom the signature (dw/introspection.py:428).task_domains.py: no entry, because a boolean has no numeric domain.Stages
Stage 1:
normalize_audio(limit=true)(server)tests/test_audio_utils.py):target_lufs_cappedwithlimited: true;limiter_heavyfires above 6 dB of reduction;scipyinpyproject.toml;target_lufsandlimitrows to thenormalize_audiotable (thetarget_lufsrow is missing today) and a line undercompress_audiosaying itslimitmode is sample-peak with no look-ahead, and pointing tonormalize_audio(limit=true)for a true-peak ceiling;normalize_audiobullets in CLAUDE.md.target_lufscap: an uploaded or kept laugh-track episode, or any dialogue cut that warnstarget_lufs_cappedatpeak_dbfs: -3.limitomitted: same result and warnings as today (target_lufs_capped, and the logconstraint: "peak_ceiling").target_lufs: -16, peak_dbfs: -3, limit: true, saved as wav:get_gallery_metadatagivesintegrated_lufswithin 0.5 LU of -16 andtrue_peak_dbfs≤ -3.0 (+0.1);constraint: "limiter",max_gain_reduction_db> 0 andlimited_fractionbetween 0 and 1;target_lufs_cappedwarning.pair_audio): noaudio_clippedwarning, and a decoded peak below 0 dBFS.target_lufs_cappedwithlimited: trueandshortfall_lu> 0, pluslimiter_heavy; the output true peak is still ≤ the ceiling.limited_fraction: 0, and the same LUFS aslimit: false.limit: truewithouttarget_lufs:true_peak_dbfs≤peak_dbfs.get_gallery_metadata), and speech in the limited file stays in sync with the picture after muxing.get_task("normalize_audio")listslimitwith its description.Stage 2: expose it on
assemble-and-scoreand in the series-episodes skill (server and plugin)limitvariable (defaultfalse) onworkflows/templates/assemble-and-score.json, passed to thebalancedstep beside Episodes can't be loudness-matched: assemble-and-score doesn't expose target_lufs; skill should match downward #467'starget_lufs;plugins/dw/skills/series-episodes/SKILL.md(the normalize bullet and the loudness paragraph): match downward by default, as Episodes can't be loudness-matched: assemble-and-score doesn't expose target_lufs; skill should match downward #467 wrote it. Passlimit: trueonly when the series should sit louder than its most dynamic episode allows (e.g. -16 for streaming). Alimiter_heavywarning means lowering the series target;minimax-music3saying the same for a song master;limit, -3 dBTP is held on the mix, and the AAC mux of the film can land up to about 1 dB above it on limited material: -2.54 on the first laugh-track episode. It stops promising -3 dBTP on the film. Per Q6's default, this is the whole v2 change; the rest of stage 2 is already built.assemble-and-score's variables, and one test thatlimitreaches thebalancedstep.tests/test_plugin_skills.pyif it pins numbers in the skill text.list_workflowsshowslimitonassemble-and-score.target_lufs: -16, limit: trueon a laugh-track episode: the film lands within 0.5 LU of -16. (v2) Thebalancedlog hasoutput_true_peak_dbfs≤ -3.0. The film'strue_peak_dbfsis ≤ -2.0, which is the mix ceiling plus a 1 dB encode allowance and still under the -1 dBTP streaming ceiling. There is noaudio_clipped. The template description no longer claims -3 dBTP on the film.develop, since the plugin has no MCP surface. Don approves that line.Order and dependencies
target_lufstoassemble-and-scoreand writes the downward-matching rule that stage 2 extends. If Episodes can't be loudness-matched: assemble-and-score doesn't expose target_lufs; skill should match downward #467 is declined, stage 2 addstarget_lufsitself, and the estimate goes up about $1.Risks
limiter_heavywarning and the 12 dB cap bound it. Exposed timings are the thing a field report of pumping would bring back.audio_clippedpost-encode check stays as the backstop. (v2) Confirmed and measured: the AAC mux adds about 0.46 dB on a limited laugh-track mix (16% of samples at the ceiling) and about -0.1 dB on an unlimited one. The overshoot depends on the material (CLAUDE.md already records 1.9 dB on an unnormalized song), so no fixed margin can be known to be enough.Questions
Q1. Should this be a mode on
normalize_audioor a newlimit_audiotask? Default: the mode. A task would add a task, docs, domain entries and aNORMALIZING_COMMANDSentry, and agents would have to order two steps correctly. Stage 1 would rise about $1.Q2. What happens to
compress_audio(mode="limit")? Default: leave it, and document the difference. One option is to route it through the new limiter: a behaviour change with no consumers, about +$1 in stage 1. Another is to remove the mode, which is abreaking-changeand would be its own issue.Q3. Fixed timings and fixed thresholds (12 dB cap, 6 dB warning)? Default: fixed. Exposing
lookahead_ms/release_ms/max_reduction_dbadds three arguments, three domain entries and the tests for them, with no demand yet.Q4. With
limit: true, ispeak_dbfsa true-peak ceiling? Default: yes. The alternative, keeping it sample-peak, leaves the inter-sample overs that AAC turns into clipping, which is what the limiter is for.Q5. Should stage 2 expose
limitbeyondassemble-and-score? Default:assemble-and-scoreonly (the series recipe). Addingdissolve-between-shotsandminimax/music-videoas well costs about +$1 and needs the sametarget_lufsexposure there first.Q6 (v2). How far should the -3 dBTP ceiling reach under
limit: true: the mix or the muxed film? Default: the mix. Document that the film can land up to about 1 dB above it, and gate the acceptance at ≤ -2.0 dBTP with noaudio_clipped(above). That costs about $0.5, the plan's non-goals stand (the encoded file's check is unchanged), and -2.5 is still well inside the -1 dBTP delivery ceilings the streaming target is for. #486's hand-mastered result was -2.7. Alternatives:normalize_audio(limit=true)holdspeak_dbfsminus about 1 dB internally. That is a behaviour change to verified stage 1, the log would report a ceiling that isn't the one asked for, and no fixed margin can be known to be enough. About +$1.5 and a re-verify of stage 1.Result.savere-probes the written file's true peak and, over a ceiling, trims the audio and re-encodes once. This is the only option that guarantees the film, but it lifts a non-goal ("changing how the encoded file is checked") and needs a ceiling carried topair_audioor the result. It would be a new stage 3 of about $4–6, touchingdw/result.py, and it should also answer the follow-up aboutwarn_if_written_above_full_scalereading sample peak.Reply in comments and hand back with
owner:lead. Approve by addingstatus:plan-approvedyourself. No agent may set it.dkackman commentedon Sep 27, 2026
approve build smaller
22 remaining items
dkackman commentedon Sep 27, 2026
Acceptance cases for plan v2, in
regression-suite-complete.md(harnest):C-F150 (new,
pending: #497) covers the v2 gates:balancedlog hasconstraint: "limiter"andoutput_true_peak_dbfs≤ −3.0 (+0.05);true_peak_dbfs≤ −2.0 and noaudio_clippedortarget_lufs_capped;It also carries C-F135's checks unchanged: the
limitvariable with defaultfalse, omitted andfalsegiving the same capped result (peak_ceiling), and the three films having the same shape. It records the encode overshoot (film true peak minus the mix's) in the report.C-F136 (the skill text) is unaffected and stands.
C-F135 still asserts the v1 film gate (≤ −2.9 dBTP), so it would fail against the approved v2 behaviour. I haven't edited it. Retiring it in favour of C-F150 is requested in dkackman/harnest#25 (
suite+status:needs-approval). Until that is ruled on, a verify of #497 should judge it by C-F150 and C-F136.Before writing these, I pinned the current template shape with a read-only
get_workflow("templates/assemble-and-score", variables_only=true):limit: falseandtarget_lufs: nullare present.Specced by Claude Opus 5.5 (
claude-opus-5-5, anthropic). No GPU calls.dkackman commentedon Sep 27, 2026
Close-out by Claude Opus 5.5 (
claude-opus-5-5, anthropic).Closed: stage 1 #496 (
normalize_audio(limit=true), verified at6a5cbd5) and stage 2 #497 (limitonassemble-and-scoreand in the series-episodes / minimax-music3 skills, verified under plan v2 atb8afddf). Each bounced once; the record says why.Design record:
docs/proposals/complete/normalize-audio-limiter-complete.md, with atodo.mdentry, in3b84b37and merged todevelopasd4e55c7. Docs only, so there is no deploy. It lists what was deferred: the ceiling on the encoded film (Q6 b/c), true-peak inwarn_if_written_above_full_scale,compress_audio's limit mode,limiton other templates, and tunable timings.verify: run every case whose
source:names one of this feature's stages (#496, #497): C-F132, C-F133, C-F134, C-F150 and C-F136. Close the parent if they all pass. C-F135 carries the v1 film gate, and dkackman/harnest#25 asks to retire it, so judge #497 by C-F150.dkackman commentedon Sep 28, 2026
Verified. Tester run on Claude Opus 5.5 (
claude-opus-5-5, provideranthropic), against lem at develop @ cdfd02f, workspaceregression-complete. I made every check below with an MCP call this session. The feature parent is judged by its stages' cases: C-F132, C-F133 and C-F134 for #496, and C-F150 and C-F136 for #497. #497 is judged by C-F150, not C-F135, per dkackman/harnest#25.get_task("normalize_audio")listslimit(default false). Its description names the true-peak ceiling (4x oversampled) and the 12 dB cap.C-F132: pass (job c2f3188e19b4)
cappedandcapped_false: both warntarget_lufs_capped("+1.4 dB … caps the gain at -2.0 dB"). Both logs readconstraint: peak_ceilingwith no limiter fields. The two files are identical: −19.39 LUFS, peak −3.000.limitedlog:constraint: limiter, gain +2.4max_gain_reduction_db4.46,limited_fraction0.162output_true_peak_dbfs−3.0,output_lufs−16.0limitedfile: −15.96 LUFS, true peak −2.999. No capped or heavy warning names it.limited_mux: 472 frames, 24 fps, 960×544, 19.667 s, true peak −2.85, noaudio_clipped. Its envelope bins line up with the source's: the loudest bin and the first bin above −40 fall at the same index.C-F133: pass (job 31de74e78fd7)
heavy:limiter_heavy(8.5 dB) and no capped warning. Reduction 8.51, fraction 0.405, −14.47 LUFS, true peak −2.999.squashed:limiter_heavyat 12.0.target_lufs_cappedwithlimited: trueandshortfall_lu8.67. Both fields sit on the warning event, not the log.heavy.full_scale: reduction 6.86 withlimiter_heavy, true peak +0.0006 (≤ 0.1), no capped warning. It also carriesaudio_no_headroom(peak −0.01), which is expected at a 0 dBTP ceiling; the case doesn't cover it.C-F134: pass (job 1ca9f19d8dab)
quiet_true: limiter with 0 reduction and fraction 0. Its file is identical toquiet_false's (−22.00 LUFS, −5.53 dBTP).peak_true: true peak −3.000 and fraction 0.mix_audiowith gains [0] works.silent_trueandsilent_falseboth succeed at −120 dBFS with only the near-silent warning. Neither has a limiter log orlimiter_heavy.C-F150: pass
list_workflows(shape="sequence"):variable_namesincludeslimitandtarget_lufs, andget_workflowshowslimit: false.limit(job bb48dc3131ed) and (b)limit:false(job 729d7beeca7d) behave the same:target_lufs_cappednamesbalanced, and the log readsconstraint: peak_ceiling, gain −2.0.limit:true(job 42a7c5237b43): validated first, valid with no warnings.balancedlog:constraint: limiter,output_true_peak_dbfs−3.0,max_gain_reduction_db4.46.audio_clippedand notarget_lufs_capped.acknowledged_cost: trueand without a separate validate.C-F136: pass
dw:series-episodessays to match downward by default. It says to passlimit: trueonly when the series should sit louder than its most dynamic episode allows (e.g. −16), and thatlimiter_heavymeans lower the seriestarget_lufs.dw:minimax-music3says the same in one line for a master.limiton by default, and neither names a variable or warning that doesn't exist.Cleanup: every run's output has been deleted with
delete_output(job_id).docs(proposals): dkackman#474 - design record for normalize_audio(lim…