Skip to content

fix(streaming): slew-rate-limit continuous knobs so fast sweeps don't clip#279

Open
seanhanca wants to merge 2 commits into
mainfrom
fix/param-slew-rate-limit
Open

fix(streaming): slew-rate-limit continuous knobs so fast sweeps don't clip#279
seanhanca wants to merge 2 commits into
mainfrom
fix/param-slew-rate-limit

Conversation

@seanhanca

Copy link
Copy Markdown

Summary

Fixes the tracker bug: "Quickly adjusting settings (especially when exploring unfamiliar parameters) can send output into heavy distortion or clipping. No hysteresis or rate-limiting on control changes means a single fast sweep can produce chaotic, unusable audio."

A fast knob sweep was applied to the running stream as a raw discontinuity. This adds an engine-side, contract-first slew/rate-limiter on continuous knobs so a fast sweep ramps instead of jumping.

Root cause analysis

The path a control change takes from client → engine:

  1. Client sends the full knob dict on the params WS channel (~125 Hz). ws_adapterStreamingSession.set_knobs (acestep/streaming/session.py) coerces it (coerce_knob_values) and virtual_knobs.update(clean) writes the raw target into the shared KnobState.
  2. Once per tick the runner calls backend.read_knobs() (acestep/streaming/pipeline_runner.py ~L489). For ACE this returned self.midi_knobs.get_all_values() verbatim — i.e. whatever the operator last set, with no smoothing (acestep/streaming/ace_backend.py read_knobs).
  3. produce()_prepare_tick() turns those values straight into the per-inference-step quantities the diffusion stream consumes (k1/denoise, feedback, shift, channel-group gains, guidance, DCW, hint_strength, x0_target, …) and pushes them onto the in-flight slots via set_shared_curve.

So between two consecutive ticks a knob could jump its entire range in one step; consecutive generations then differed discontinuously → audible distortion/clipping. There was no existing smooth|slew|throttle|hysteresis on this path (verified by search). Two values (hint_strength, x0_target) were additionally read straight off KnobState, bypassing even a future read-time smoothing point.

Fix

  • Where: KnobSlewLimiter, applied in ACEStepBackend.read_knobs() — the single once-per-tick boundary. Behind the GeneratorBackend seam, so it protects every transport (web, VST, MCP, headless) with one implementation, not per-UI tweens.
  • Contract-first: the ceiling is registry metadata — KnobSpec.slew_max_per_s — projected into the /api/knobs catalog and bumped to KNOB_SCHEMA_VERSION = 2 (TS types regenerated).
  • Defaults / no regressions:
    • Range-relative default DEFAULT_SLEW_FRACTION_PER_S = 3.0 → a full-range sweep ramps over ~0.33 s. A slew limiter caps only the rate, so normal/slow adjustments pass through with zero added latency.
    • Only float knobs are slewed. int/enum/bool (seed, steps_override, rcfg_mode, DCW modes, toggles) and non-registry keys (curve specs, the playback clock) pass through verbatim — the rebuild signature and discrete controls are untouched.
    • dt is wall-clock-paced and capped (max_dt_s) so resuming after an idle pause can't release one un-ramped jump.
    • slew_max_per_s = 0 opts a continuous knob out; per-knob overrides honored.
  • hint_strength / x0_target now read from the slewed knob dict instead of KnobState directly, so the whole translation path honors the limit.

Test plan / evidence

New tests/unit/test_knob_slew.py (a large jump is broken into bounded per-tick deltas; discrete + unknown keys pass through; slow changes incur no lag; dt cap; first-sighting snap; runtime-knob limit rebuild; catalog projection).

$ pytest tests/unit/test_knob_slew.py --noconftest -q
15 passed in 0.03s

$ pytest tests/unit/test_wire_contract.py tests/unit/test_client_sdk.py --noconftest -q
18 passed in 1.63s   # incl. test_generated_wire_types_match_contract (KNOB_SCHEMA_VERSION 2)

$ npm run typecheck   # demos/realtime_motion_graph_web/web
tsc --noEmit  -> clean

$ npm run build       # demos/realtime_motion_graph_web/web
✓ Compiled successfully; TypeScript clean

(Local box has no GPU/torch, so torch-dependent unit tests and the GPU golden harness were not run here; the changed translation path stays a verbatim pass-through whenever a knob is steady or moving below its ceiling.)

Made with Cursor


Supersedes #275 (which was opened from the qianghan fork); re-opened from a branch on this repo. The fork PR is being closed in favor of this one.

seanhanca and others added 2 commits June 17, 2026 10:37
…ing an outage doesn't keep playing stale

When the WS drops and the user switches to a different audio source before
the auto-reconnect completes, the recovered session kept playing the
previously-loaded track even though the UI showed the new selection.

Root cause: the reconnect path (useStartSession) rebinds the fixture
snapshotted at session start. The mid-outage track change is dropped
because useFixtureSwap.run() bails while status !== "ready", and nothing
re-applies it once the session recovers — so the recovered backend +
AudioPlayer stay bound to the stale track while the perf store's fixture
shows the new pick.

Fix: track the live session's bound fixture in useSessionStore
(boundFixture), set on initial connect and reconnect. useFixtureSwap now
reconciles on the "reconnecting" -> "ready" edge: if the selected fixture
diverged from the bound one, it re-runs the swap exactly once. No effect
on a fresh Play or a clean reconnect where the selection never changed.

Adds needsFixtureReconcile() + a focused unit test pinning the decision
matrix, and asserts boundFixture clears on session reset.

Co-authored-by: Cursor <cursoragent@cursor.com>
… clip

A fast knob sweep landed as a raw discontinuity on consecutive
generations (the runner reads the knob target once per tick and the
backend turns it straight into per-inference-step values), driving the
audio into distortion/clipping. There was no hysteresis or rate-limiting
on control changes.

Add an engine-side per-knob slew limiter at the once-per-tick knob read
(ACEStepBackend.read_knobs), so a large jump ramps over a few ticks
instead of snapping. Applied behind the GeneratorBackend seam, it
protects every transport (web, VST, MCP, headless) with one
implementation rather than per-UI tweens.

Contract-first: the rate ceiling is registry metadata
(KnobSpec.slew_max_per_s, projected into the /api/knobs catalog and
bumped to KNOB_SCHEMA_VERSION 2). Default is range-relative
(DEFAULT_SLEW_FRACTION_PER_S = 3.0, a full-range sweep ramps over
~0.33s) and applies only to continuous float knobs; int/enum/bool knobs
(seed, step count, modes, toggles) and non-registry keys (curve specs,
the playback clock) pass through verbatim, so the rebuild signature and
discrete controls are unaffected. A slew limiter caps only the rate of
change, so normal/slow adjustments incur zero added latency.

hint_strength and x0_target now read from the slewed knob dict instead
of the backing KnobState directly, so the whole translation path honors
the limit.

Regenerated packages/demon-client/types/wireContract.gen.ts; added
tests/unit/test_knob_slew.py (bounded per-tick deltas on a jump,
discrete/unknown passthrough, no-lag slow changes, dt cap, catalog
projection).

Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an engine-side slew/rate limiter for continuous (float) knobs so fast parameter sweeps ramp instead of stepping, reducing audible distortion/clipping. It also bumps the knob contract schema and updates the web client/types accordingly, plus includes an additional web-demo reconnect/fixture reconciliation fix.

Changes:

  • Add KnobSlewLimiter + per-knob slew_max_per_s metadata (schema v2) and project it into the knob catalog/manifest.
  • Apply slewing at the backend read_knobs() boundary and ensure hint_strength / x0_target consume the slewed dict.
  • Update client contract/types and add unit tests (Python slew limiter tests; web demo fixture reconnect reconciliation tests).

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/unit/test_knob_slew.py New unit tests covering slew limiting behavior and catalog projection.
packages/demon-client/types/wireContract.gen.ts Bump KNOB_SCHEMA_VERSION to 2 for contract drift detection.
packages/demon-client/types/knobs.ts Extend knob manifest entry type with optional slew_max_per_s.
demos/realtime_motion_graph_web/web/tests/unit/sessionStore.test.ts Update reset test expectations to include boundFixture.
demos/realtime_motion_graph_web/web/tests/unit/fixtureReconnectReconcile.test.ts New tests for reconnect-time fixture reconciliation logic.
demos/realtime_motion_graph_web/web/store/useSessionStore.ts Add boundFixture to session state and reset behavior.
demos/realtime_motion_graph_web/web/hooks/useStartSession.ts Record boundFixture on fresh start and reconnect recovery.
demos/realtime_motion_graph_web/web/hooks/useFixtureSwap.ts Add needsFixtureReconcile + reconnect→ready reconciliation subscription; keep boundFixture in sync on swaps.
acestep/streaming/knobs.py Add schema v2, default slew fraction, slew_max_per_s, effective_slew_max_per_s, catalog projection, and KnobSlewLimiter.
acestep/streaming/ace_backend.py Instantiate/apply the slew limiter in read_knobs() and route hint_strength/x0_target through the slewed knob dict.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +561 to +573
def _slew_specs_by_name(self) -> dict:
"""The live ``{name: KnobSpec}`` map the slew limiter reads its
per-knob ceilings from.

Prefers the session's manifest (``_knob_specs_by_name``), which
is reassigned wholesale when the LoRA / steering knob set changes
— so runtime knobs are covered and the limiter rebuilds its
ceilings exactly when the universe shifts. Falls back to this
backend's own static manifest for bare construction (test
fixtures with no session manifest)."""
sbn = getattr(self.session, "_knob_specs_by_name", None)
if sbn:
return sbn
Comment on lines +343 to +365
// Reconnect reconcile: when a recovered session reaches "ready", the
// backend + player are bound to the fixture snapshotted at session
// start. If the user switched tracks while the socket was down, that
// mid-outage change was dropped by run()'s `status !== "ready"`
// bail. Re-apply it here exactly once on the reconnecting → ready
// edge so the recovered session swaps to the live selection instead
// of playing the stale track. `force` re-runs the swap even when the
// name matches lastSwappedTo (which still points at the stale bound
// fixture until the swap below updates it).
const unsubReconnect = useSessionStore.subscribe((s, prev) => {
const selectedFixture = usePerformanceStore.getState().fixture;
if (
!needsFixtureReconcile({
prevStatus: prev.status,
status: s.status,
selectedFixture,
boundFixture: s.boundFixture,
})
) {
return;
}
void run(selectedFixture, true);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants