diff --git a/acestep/streaming/ace_backend.py b/acestep/streaming/ace_backend.py
index 1f765f6a..ff454297 100644
--- a/acestep/streaming/ace_backend.py
+++ b/acestep/streaming/ace_backend.py
@@ -38,10 +38,13 @@
Capabilities,
TickContext,
)
+from acestep.steering import SteeringController
from acestep.streaming.knobs import (
CHANNEL_GROUPS,
KEYSTONE_CHANNELS,
knob_specs as registry_knob_specs,
+ manual_slot_specs,
+ steering_axis_spec,
)
# Audio sample rate the ACE-Step v1.5 family is trained on, and the
@@ -121,6 +124,41 @@ def _curve_from_spec(spec, T):
return None
+def steering_knob_specs(steering: "SteeringController") -> list:
+ """Project a SteeringController's live surface into registry specs.
+
+ Empty when no vector bundle is reachable (the knobs would be dead).
+ The spec SHAPES come from the registry factories in
+ ``acestep.streaming.knobs``; only the axis/catalog metadata is
+ filled in here, where the steering policy lives.
+ """
+ if not steering.is_loaded:
+ return []
+ specs: list = []
+ for ax in steering.auto_axes:
+ inject_layer = max(
+ 0, min(steering.MANUAL_MAX_LAYER, ax.probe_layer + ax.layer_offset),
+ )
+ specs.append(steering_axis_spec(
+ ax.name,
+ axis=ax.axis,
+ inject_layer=inject_layer,
+ probe_step=ax.probe_step,
+ probe_n=steering._probe_n,
+ blurb=ax.blurb,
+ ))
+ src_max = max(0, len(steering.catalog) - 1)
+ for slot in steering.active_slots():
+ specs.extend(manual_slot_specs(
+ slot,
+ src_max=src_max,
+ catalog_len=len(steering.catalog),
+ layer_max=steering.MANUAL_MAX_LAYER,
+ step_max=steering.MANUAL_MAX_STEP,
+ ))
+ return specs
+
+
class ACEStepBackend(DiffusionBackend):
"""ACE-Step v1.5 diffusion generation behind the GeneratorBackend seam.
@@ -141,6 +179,7 @@ def __init__(
walk_window=False,
walk_window_s=60.0,
neg_conditioning=None,
+ steering: SteeringController | None = None,
):
# The family codec is the engine Session: its windowed VAE
# decode is what render_window()/render_full() drive. The
@@ -205,6 +244,19 @@ def __init__(
# ``None`` on the first tick just seeds the baseline.
self._last_rebuild_keys = None
+ # Activation steering. The controller is the source of truth for
+ # the slot count and vector catalog; the session mirrors its
+ # slot ops into KnobState / the knob manifest. ``None`` (e.g. a
+ # bare-construction test fixture) degrades to an unloaded
+ # controller so every consumer can read it unconditionally.
+ self.steering = (
+ steering if steering is not None else SteeringController(None)
+ )
+ # (pipeline, snapshot) change-detection key for _sync_steering;
+ # None forces a push on the first tick and after a
+ # steps_override-driven pipeline rebuild.
+ self._last_steering = None
+
# ----- per-tick translation state (the old run() locals) -----
self._last_latent = None
# Previous fresh latent for the full-buffer MSE skip. Tracked
@@ -275,6 +327,7 @@ def capabilities(self) -> Capabilities:
depth=True,
curves=True,
notes_conditioning=False,
+ steering=self.steering.is_loaded,
)
def geometry(self) -> AudioGeometry:
@@ -291,11 +344,13 @@ def geometry(self) -> AudioGeometry:
def knob_specs(self, lora_ids=()) -> list:
"""The ACE-family manifest: the shared registry's spec list,
parameterized by this session's SDE mode and the enabled-LoRA
- set the session passes in (see the protocol docstring)."""
+ set the session passes in (see the protocol docstring), plus
+ the activation-steering surface (auto axes + the live manual
+ slots) when this session's checkpoint has a vector bundle."""
return registry_knob_specs(
self.use_sde,
loras=list(lora_ids) if self.use_lora else [],
- )
+ ) + steering_knob_specs(self.steering)
# ---- public hooks reachable from session ops ---------------------------
@@ -377,6 +432,28 @@ def _sync_channel_guidance(self, raw: dict, last: list) -> list:
self.stream.model.handler._channel_guidance = configs
return ch_gains[:]
+ def _sync_steering(self, raw: dict, last):
+ """Push activation-steering configs when the snapshot changes.
+
+ ``last`` is ``(pipeline, snapshot_tuple)`` or ``None``. Pipeline
+ identity is part of the key because ``steps_override`` rebuilds
+ the StreamPipeline (fresh, empty steering state) without
+ changing ``raw`` — without the identity check the new pipeline
+ would never receive ``set_steering``.
+ """
+ if not self.steering.is_loaded:
+ return last
+ pipe = self.stream.pipeline
+ if pipe is None:
+ return last
+ n = max(1, int(raw.get("steps_override", 8)))
+ snapshot = self.steering.snapshot_key(raw, n)
+ last_pipe, last_snapshot = last if last is not None else (None, None)
+ if pipe is last_pipe and snapshot == last_snapshot:
+ return last
+ pipe.set_steering(self.steering.build_configs(raw, n))
+ return (pipe, snapshot)
+
# ---- GeneratorBackend hot loop -----------------------------------------
def sync_source(self, ctx: TickContext) -> None:
@@ -712,6 +789,9 @@ def _prepare_tick(self, knobs: dict, ctx: TickContext) -> dict:
self._last_channel_gains = self._sync_channel_guidance(
raw, self._last_channel_gains,
)
+ self._last_steering = self._sync_steering(
+ raw, self._last_steering,
+ )
# Route every curve-capable parameter through the shared
# mutable curve system so knob changes take effect on ALL
diff --git a/acestep/streaming/events.py b/acestep/streaming/events.py
index 9e86949b..a5a090d6 100644
--- a/acestep/streaming/events.py
+++ b/acestep/streaming/events.py
@@ -166,6 +166,15 @@ class DepthApplied:
value: int
+@dataclass(frozen=True)
+class ManualSlotCount:
+ """Manual steering slot count after a manual_slot_add / manual_slot_pop
+ (published on success AND refusal so the client's +/- UI resyncs
+ either way). ``count`` is the controller's live slot count."""
+
+ count: int
+
+
@dataclass(frozen=True)
class SwapReady:
"""Source swap completed. Carries enough state for the transport to
diff --git a/acestep/streaming/families.py b/acestep/streaming/families.py
index 701d4d92..4adda768 100644
--- a/acestep/streaming/families.py
+++ b/acestep/streaming/families.py
@@ -20,8 +20,16 @@
def _make_acestep(ss):
+ from acestep.steering import SteeringController, ensure_steering_vectors
from acestep.streaming.ace_backend import ACEStepBackend
+ # SteeringController is the source of truth for slot_count and the
+ # vector catalog; ensure_steering_vectors fetches/caches the
+ # checkpoint's probe bundle (None for checkpoints without one — XL,
+ # fetch failures — which degrades the controller to is_loaded=False
+ # and drops the steering capability/knobs for the session).
+ steering = SteeringController(ensure_steering_vectors(ss.checkpoint))
+
return ACEStepBackend(
ss.session, ss.stream,
state=ss.state,
@@ -34,6 +42,7 @@ def _make_acestep(ss):
walk_window=ss.walk_window,
walk_window_s=ss.walk_window_s,
neg_conditioning=ss.cond_negative,
+ steering=steering,
)
@@ -43,14 +52,48 @@ def _make_acestep(ss):
def _acestep_knob_universe():
- from acestep.streaming.knobs import knob_specs
+ from acestep.steering.policy import (
+ AUTO_AXES,
+ MANUAL_MAX_LAYER,
+ MANUAL_MAX_STEP,
+ PROBE_N,
+ )
+ from acestep.streaming.knobs import (
+ knob_specs,
+ manual_slot_specs,
+ steering_axis_spec,
+ )
# Every spec the family can ever expose: both SDE-mode variants plus
# a representative LoRA-strength knob (the per-id specs all come from
- # lora_strength_spec, so one placeholder id covers the pattern).
+ # lora_strength_spec, so one placeholder id covers the pattern), plus
+ # the steering surface — the four auto axes and one representative
+ # manual slot (per-slot specs all come from manual_slot_specs).
+ # Catalog geometry uses the canonical v15-turbo bundle's 144 cells;
+ # no network fetch happens here (policy tables only).
+ steering = [
+ steering_axis_spec(
+ ax.name,
+ axis=ax.axis,
+ inject_layer=max(
+ 0, min(MANUAL_MAX_LAYER, ax.probe_layer + ax.layer_offset),
+ ),
+ probe_step=ax.probe_step,
+ probe_n=PROBE_N,
+ blurb=ax.blurb,
+ )
+ for ax in AUTO_AXES
+ ] + manual_slot_specs(
+ 1,
+ src_max=143,
+ catalog_len=144,
+ layer_max=MANUAL_MAX_LAYER,
+ step_max=MANUAL_MAX_STEP,
+ )
return (
knob_specs(False, loras=[""])
+ knob_specs(True, loras=[""])
+ + steering
)
diff --git a/acestep/streaming/generator_backend.py b/acestep/streaming/generator_backend.py
index 0a33ac05..e26b3ae6 100644
--- a/acestep/streaming/generator_backend.py
+++ b/acestep/streaming/generator_backend.py
@@ -152,6 +152,11 @@ class Capabilities:
depth: bool = False
curves: bool = False
notes_conditioning: bool = False
+ # Activation steering (per-layer residual shifts driven by the
+ # steer_* / man_*_ knobs and the manual_slot_add/pop commands).
+ # True only when the backend has a steering controller with a
+ # reachable vector bundle for its checkpoint.
+ steering: bool = False
@dataclass(frozen=True)
diff --git a/acestep/streaming/knobs.py b/acestep/streaming/knobs.py
index f3c14bb9..df8c0eff 100644
--- a/acestep/streaming/knobs.py
+++ b/acestep/streaming/knobs.py
@@ -218,6 +218,101 @@ def lora_strength_spec(lora_id: str) -> KnobSpec:
)
+# Activation-steering alpha range. Bipolar so the operator can invert an
+# axis without leaving the surface; useful magnitude is roughly 2..15 by
+# ear, breakage above that.
+STEERING_ALPHA_MAX = 30.0
+
+
+def steering_axis_spec(
+ name: str,
+ *,
+ axis: str = "",
+ inject_layer: int = 0,
+ probe_step: int = 0,
+ probe_n: int = 8,
+ blurb: str = "",
+) -> KnobSpec:
+ """The registry spec for one auto-path activation-steering knob.
+
+ Shaped here (range, group, bank) so every transport projects the
+ same contract; the axis metadata (where the vector injects, what it
+ does) arrives as plain values from the backend that owns the
+ steering policy — this module stays torch-free / acestep-free.
+ """
+ return KnobSpec(
+ name, default=0.0,
+ min_val=-STEERING_ALPHA_MAX, max_val=STEERING_ALPHA_MAX,
+ group="steering",
+ description=(
+ f"Activation-steering ({axis}) injected at DiT layer "
+ f"{inject_layer}, step round({probe_step}/{probe_n} * inject_n) "
+ f"of the current schedule. 0 = off, negative inverts the axis "
+ f"direction. {blurb}."
+ " Useful magnitude roughly 2..15 by ear; breakage above that."
+ ),
+ )
+
+
+def manual_slot_specs(
+ slot_id: int,
+ *,
+ src_max: int,
+ catalog_len: int,
+ layer_max: int,
+ step_max: int,
+) -> list:
+ """The four registry specs for one manual steering slot.
+
+ Like :func:`lora_strength_spec`, factored so the runtime slot
+ add path and the session-start manifest both shape the knobs from
+ the registry. Manual slots bypass the auto path's fractional step
+ mapping, layer offset, and sign correction — the vector lands at
+ the operator's chosen cell with the operator's chosen sign.
+ """
+ return [
+ KnobSpec(
+ f"man_src_{slot_id}", default=0.0, min_val=0.0,
+ max_val=float(src_max), type="int", group="manual",
+ description=(
+ f"Manual slot {slot_id}: vector catalog index. Resolves to "
+ f"a (axis, build_layer, build_step) cell on disk; call "
+ f"list_manual_steering_vectors for the table. Index "
+ f"0..{src_max} ({catalog_len} cells)."
+ ),
+ ),
+ KnobSpec(
+ f"man_layer_{slot_id}", default=9.0, min_val=0.0,
+ max_val=float(layer_max), type="int", group="manual",
+ description=(
+ f"Manual slot {slot_id}: DiT inject layer (0..{layer_max}). "
+ "Passed verbatim to the engine; no automatic offset."
+ ),
+ ),
+ KnobSpec(
+ f"man_step_{slot_id}", default=0.0, min_val=0.0,
+ max_val=float(step_max), type="int", group="manual",
+ description=(
+ f"Manual slot {slot_id}: diffusion inject step "
+ f"(0..{step_max}). No fractional mapping. Values past the "
+ "current steps_override - 1 silently no-op (the engine only "
+ "fires when step equals the active diffusion step)."
+ ),
+ ),
+ KnobSpec(
+ f"man_alpha_{slot_id}", default=0.0,
+ min_val=-STEERING_ALPHA_MAX, max_val=STEERING_ALPHA_MAX,
+ group="manual",
+ description=(
+ f"Manual slot {slot_id}: injection strength. 0 = slot off. "
+ "Bipolar: negative alpha inverts the chosen vector's "
+ "direction at injection (no sign correction is applied). "
+ "Useful magnitude roughly 2..15 by ear; breakage above that."
+ ),
+ ),
+ ]
+
+
def knob_catalog(sde: bool, loras=None) -> dict:
"""Project the full registry into a transport-agnostic catalog:
``name -> {type, default, min?, max, group, options?, description?,
diff --git a/acestep/streaming/session.py b/acestep/streaming/session.py
index a3b87df7..b3acaa06 100644
--- a/acestep/streaming/session.py
+++ b/acestep/streaming/session.py
@@ -73,12 +73,14 @@
from acestep.streaming.commands import CommandOrigin
from acestep.streaming.config import SessionConfig
from acestep.streaming.encode import blend_for_strength, encode_cond_pair
+from acestep.steering import CapacityError, EmptyError
from acestep.streaming.events import (
AudioReady,
CommandFailed,
DepthApplied,
EventBus,
LoraCatalogUpdate,
+ ManualSlotCount,
ParamsEcho,
PromptApplied,
PromptBlendEcho,
@@ -462,11 +464,20 @@ def __init__(
# Cached {name: KnobSpec} map for hot-path validation in set_knobs.
# knob_specs() rebuilds 34 dataclasses, so we never call it per tick;
- # rebuilt only when the LoRA set changes (see _apply_lora_pending).
+ # rebuilt only when the LoRA set changes (see _apply_lora_pending)
+ # or a manual steering slot is added/popped.
# Reassigned wholesale (atomic ref swap), so set_knobs can read it
# without a lock from the dispatch thread.
self._rebuild_knob_specs(self.initial_enable_ids)
+ # The backend's manifest can extend the shared registry set the
+ # KnobState was seeded from (today: the activation-steering
+ # knobs, present only when the checkpoint has a vector bundle).
+ # Seed any such knob so snapshot ``knob_values`` stays complete
+ # from t=0 — add_knob is a no-op for already-seeded names.
+ for spec in self._knob_specs_by_name.values():
+ self.virtual_knobs.add_knob(spec)
+
def _rebuild_knob_specs(self, lora_ids: list) -> None:
# Backend-owned manifest: which specs the LoRA set expands to is
# family knowledge behind the seam; the session only tracks the
@@ -524,6 +535,24 @@ def knob_manifest_payload(self) -> dict:
"knobs": catalog_from_specs(self._knob_specs_by_name.values()),
}
+ def steering_payload(self) -> dict:
+ """Wire-shaped activation-steering block, shared by the ``ready``
+ frame and the snapshot. ``steering_available`` mirrors the
+ backend's ``steering`` capability bit; the count/cap fields drive
+ the client's manual-slot row rendering and +/- enablement."""
+ ctl = getattr(self.backend, "steering", None)
+ if ctl is None:
+ return {
+ "manual_slot_count": 0,
+ "manual_slot_cap": 0,
+ "steering_available": False,
+ }
+ return {
+ "manual_slot_count": ctl.slot_count,
+ "manual_slot_cap": ctl.slot_cap,
+ "steering_available": bool(ctl.is_loaded),
+ }
+
def lora_catalog_payload(self) -> list:
"""Wire-shaped LoRA catalog for the active engine. Empty list
when LoRA isn't available on this backend."""
@@ -589,6 +618,9 @@ def snapshot(self) -> dict:
"geometry": self.geometry_payload(),
"capabilities": self.capabilities_payload(),
"knob_manifest": self.knob_manifest_payload(),
+ # Activation-steering surface (count drives manual-slot row
+ # rendering; available=False hides the steering tiles).
+ **self.steering_payload(),
}
# ---- Runner lifecycle ----------------------------------------------
@@ -1498,6 +1530,67 @@ def disable_lora(
origin.value, lora_id,
)
+ @requires_capability("steering", "manual_slot_add")
+ def manual_slot_add(
+ self,
+ *,
+ origin: CommandOrigin = CommandOrigin.PRIMARY,
+ ) -> None:
+ """Allocate the next manual steering slot (LIFO).
+
+ The controller is the primary write; KnobState and the cached
+ spec map mirror it so the four ``man_*_`` knobs validate and
+ snapshot from the moment the slot exists. No GPU work, so it
+ applies inline (no pending queue). Publishes
+ :class:`ManualSlotCount` on success AND refusal — the client's
+ +/- UI resyncs from the echo either way.
+ """
+ self.state.last_activity_ts = time.monotonic()
+ ctl = self.backend.steering
+ try:
+ new_slot = ctl.add_slot()
+ except CapacityError:
+ logger.info(
+ "manual_slot_add_refused origin={} cap={}",
+ origin.value, ctl.slot_cap,
+ )
+ else:
+ self._rebuild_knob_specs(self._enabled_lora_ids())
+ names = ctl.knob_names(new_slot)
+ for name in (names.src, names.layer, names.step, names.alpha):
+ spec = self._knob_specs_by_name.get(name)
+ if spec is not None:
+ self.virtual_knobs.add_knob(spec)
+ logger.info(
+ "manual_slot_added origin={} slot={}", origin.value, new_slot,
+ )
+ self.bus.publish(ManualSlotCount(count=ctl.slot_count))
+
+ @requires_capability("steering", "manual_slot_pop")
+ def manual_slot_pop(
+ self,
+ *,
+ origin: CommandOrigin = CommandOrigin.PRIMARY,
+ ) -> None:
+ """Remove the highest-numbered manual steering slot (LIFO;
+ interior deletion is not supported). Refusal on an empty
+ registry still publishes :class:`ManualSlotCount`."""
+ self.state.last_activity_ts = time.monotonic()
+ ctl = self.backend.steering
+ try:
+ popped = ctl.pop_slot()
+ except EmptyError:
+ logger.info("manual_slot_pop_refused origin={}", origin.value)
+ else:
+ names = ctl.knob_names(popped)
+ for name in (names.src, names.layer, names.step, names.alpha):
+ self.virtual_knobs.remove_knob(name)
+ self._rebuild_knob_specs(self._enabled_lora_ids())
+ logger.info(
+ "manual_slot_popped origin={} slot={}", origin.value, popped,
+ )
+ self.bus.publish(ManualSlotCount(count=ctl.slot_count))
+
@requires_capability("timbre", "set_timbre_strength")
def set_timbre_strength(
self,
diff --git a/demos/realtime_motion_graph_web/mcp_server.py b/demos/realtime_motion_graph_web/mcp_server.py
index 96726793..56c2bf1f 100644
--- a/demos/realtime_motion_graph_web/mcp_server.py
+++ b/demos/realtime_motion_graph_web/mcp_server.py
@@ -47,14 +47,25 @@
from loguru import logger
from mcp.server.fastmcp import FastMCP
+from acestep.steering import (
+ MANUAL_SLOT_CAP,
+ ensure_steering_vectors,
+ enumerate_catalog,
+)
from acestep.streaming.knobs import (
KNOB_SCHEMA_VERSION,
+ KnobSpec,
coerce_knob_values,
knob_catalog,
knob_specs,
)
from .protocol import coerce_command_payload, wire_contract
+# MCP runs as a single global process, so we pre-fetch the canonical
+# 2B turbo bundle at module init. Fetch failures leave the cache empty;
+# the next streaming session retries.
+_MANUAL_VECTOR_DIR = ensure_steering_vectors("acestep-v15-turbo")
+
# MCP wire protocol owns stdout — every log MUST go to stderr. Lazy
# configure so this module stays importable without a hard dependency on
@@ -239,6 +250,27 @@ def _waveform_to_audio_bytes(waveform: np.ndarray) -> bytes:
return struct.pack(" list[dict]:
+ """Manual steering catalog flattened to wire-stable dicts."""
+ if _MANUAL_VECTOR_DIR is None:
+ return []
+ return [
+ {
+ "index": entry.index,
+ "axis": entry.axis,
+ "build_layer": entry.build_layer,
+ "build_step": entry.build_step,
+ "filename": entry.filename,
+ }
+ for entry in enumerate_catalog(_MANUAL_VECTOR_DIR)
+ ]
+
+
# ---------------------------------------------------------------------------
# Tools — discovery
# ---------------------------------------------------------------------------
@@ -339,6 +371,19 @@ async def list_knobs(session_id: Optional[str] = None) -> dict:
which LoRAs are currently enabled — pulled from the live snapshot.
"""
snap = await session_state(session_id)
+ # Prefer the snapshot's backend-owned manifest (Phase 2): it is the
+ # session's LIVE knob universe, including the backend-specific knobs
+ # the static registry projection can't reproduce (the steering
+ # steer_* axes and the per-slot man_*_ quadruples).
+ manifest = snap.get("knob_manifest") or {}
+ if isinstance(manifest, dict) and manifest.get("knobs"):
+ return {
+ "version": manifest.get("version", KNOB_SCHEMA_VERSION),
+ "knobs": manifest["knobs"],
+ "current": snap.get("knob_values") or {},
+ }
+ # Older server snapshot without a manifest: re-derive from the
+ # shared registry (no steering surface on those servers anyway).
sde, enabled = _session_knob_shape(snap)
return {
"version": KNOB_SCHEMA_VERSION,
@@ -362,6 +407,33 @@ def _session_knob_shape(snap: dict) -> tuple[bool, list]:
return sde, enabled
+def _specs_from_snapshot(snap: dict) -> dict:
+ """``{name: KnobSpec}`` for a session snapshot.
+
+ Reconstructed from the snapshot's backend-owned ``knob_manifest``
+ when present (so validation covers backend-specific knobs like the
+ steering surface); falls back to the shared registry for older
+ servers. The manifest is itself a registry projection
+ (``catalog_from_specs``), so this stays single-source."""
+ manifest = (snap.get("knob_manifest") or {}).get("knobs")
+ if isinstance(manifest, dict) and manifest:
+ return {
+ name: KnobSpec(
+ name=name,
+ default=e.get("default", 0.0),
+ min_val=e.get("min"),
+ max_val=e.get("max", 1.0),
+ type=e.get("type", "float"),
+ options=tuple(e.get("options") or ()),
+ group=e.get("group", "core"),
+ bank=bool(e.get("bank", True)),
+ )
+ for name, e in manifest.items()
+ }
+ sde, enabled = _session_knob_shape(snap)
+ return {s.name: s for s in knob_specs(sde=sde, loras=enabled)}
+
+
async def _validate_against_session(
raw: dict, session_id: Optional[str]
) -> dict:
@@ -370,14 +442,53 @@ async def _validate_against_session(
any value is out of range or not an allowed enum/bool option. Reuses
the same coerce_knob_values the server enforces, so MCP can't drift."""
snap = await session_state(session_id)
- sde, enabled = _session_knob_shape(snap)
- specs = {s.name: s for s in knob_specs(sde=sde, loras=enabled)}
- clean, errors = coerce_knob_values(raw, specs)
+ clean, errors = coerce_knob_values(raw, _specs_from_snapshot(snap))
if errors:
raise ValueError("; ".join(errors))
return clean
+@mcp.tool()
+async def add_manual_slot(session_id: Optional[str] = None) -> dict:
+ """Spawn the next manual steering slot (LIFO; alpha defaults to 0).
+
+ Refused (no-op echo) at MANUAL_SLOT_CAP.
+ """
+ _send_cmd(session_id, {"type": "manual_slot_add"})
+ snap = await session_state(session_id)
+ return {
+ "count": int(snap.get("manual_slot_count") or 0),
+ "cap": MANUAL_SLOT_CAP,
+ }
+
+
+@mcp.tool()
+async def pop_manual_slot(session_id: Optional[str] = None) -> dict:
+ """Remove the highest-numbered manual steering slot.
+
+ LIFO; interior deletion is not supported. Refused (no-op echo) on
+ an empty registry.
+ """
+ _send_cmd(session_id, {"type": "manual_slot_pop"})
+ snap = await session_state(session_id)
+ return {
+ "count": int(snap.get("manual_slot_count") or 0),
+ "cap": MANUAL_SLOT_CAP,
+ }
+
+
+@mcp.tool()
+async def list_manual_steering_vectors() -> dict:
+ """Catalog of pre-built steering vectors for the manual slots.
+
+ Returns ``{"count": N, "vectors": [...]}``. Each entry's ``index``
+ is the value to set on ``man_src_``. Order is stable across
+ sessions (axis-major, then build_layer asc, then build_step asc).
+ """
+ cat = _enumerate_manual_catalog()
+ return {"count": len(cat), "vectors": cat}
+
+
# ---------------------------------------------------------------------------
# Tools — prompt
# ---------------------------------------------------------------------------
diff --git a/demos/realtime_motion_graph_web/protocol.py b/demos/realtime_motion_graph_web/protocol.py
index 57c48c29..ec85cfd9 100644
--- a/demos/realtime_motion_graph_web/protocol.py
+++ b/demos/realtime_motion_graph_web/protocol.py
@@ -292,6 +292,22 @@ class EventSpec:
requires="lora",
description="Disable a LoRA and drop its lora_str_ knob.",
),
+ CommandSpec(
+ "manual_slot_add",
+ requires="steering",
+ description="Allocate the next manual steering slot (LIFO); "
+ "allocates its four man_*_ knobs. Echoed back as "
+ "manual_slot_count on success AND refusal (at "
+ "manual_slot_cap).",
+ ),
+ CommandSpec(
+ "manual_slot_pop",
+ requires="steering",
+ description="Remove the highest-numbered manual steering slot and "
+ "drop its man_*_ knobs (LIFO; interior deletion is "
+ "not supported). Echoed back as manual_slot_count on "
+ "success AND refusal (empty registry).",
+ ),
CommandSpec(
"set_timbre_strength",
fields=(FieldSpec("value", "float", required=True, default=1.0,
@@ -439,6 +455,25 @@ class EventSpec:
"resolved (SDE mode, enabled lora_str_ "
"knobs). /api/knobs remains the static "
"pre-session probe."),
+ # Activation-steering surface. Wire-optional like the
+ # Phase-2 fields above: absent on backends without the
+ # steering capability, and the client hides the steering
+ # tiles when steering_available isn't explicitly true.
+ FieldSpec("manual_slot_count", "int",
+ description="Active manual steering slot count; "
+ "drives the client's man_*_ row "
+ "rendering. Updated live via the "
+ "manual_slot_count event."),
+ FieldSpec("manual_slot_cap", "int",
+ description="Server-imposed ceiling on manual "
+ "steering slots; gates the client's "
+ "+ button."),
+ FieldSpec("steering_available", "bool",
+ description="True when the session's checkpoint has "
+ "a reachable steering-vector bundle; "
+ "false hides the steering surface (the "
+ "steer_*/man_* knobs are absent from "
+ "the manifest too)."),
),
binary_follow=True,
description="First JSON after the upload handshake, followed by the "
@@ -539,6 +574,15 @@ class EventSpec:
description="The clamped applied depth."),),
description="Ack for set_depth.",
),
+ EventSpec(
+ "manual_slot_count",
+ fields=(FieldSpec("count", "int", required=True,
+ description="The live manual steering slot "
+ "count after the command."),),
+ description="Ack for manual_slot_add / manual_slot_pop — emitted on "
+ "success and refusal alike so the client's +/- UI "
+ "resyncs either way.",
+ ),
EventSpec(
"timbre_set",
fields=(FieldSpec("name", "str", required=True),
diff --git a/demos/realtime_motion_graph_web/web/app/globals.css b/demos/realtime_motion_graph_web/web/app/globals.css
index 466a2913..99445f6b 100644
--- a/demos/realtime_motion_graph_web/web/app/globals.css
+++ b/demos/realtime_motion_graph_web/web/app/globals.css
@@ -4193,15 +4193,15 @@ body.curve-open #install-video-area #graph {
margin: 0;
max-width: 70ch;
}
-/* Side-by-side columns for the two voice banks. Internal voices on the
- left (8 wide), tuned morph on the right (6 wide), thin vertical
- divider between them — the inShaper "shaper-1 / shaper-2" two-pane
- layout in miniature. */
+/* Two-column grid shared by every row inside the voice tile (channels
+ on row 1, steering on row 2 when present). Grid (not flex) so the
+ columns auto-size to the widest content across rows — that's what
+ makes "manual steering" line up with "channel groups" above. */
.voice-sections-row {
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- gap: 14px;
+ display: grid;
+ grid-template-columns: auto 1px auto;
+ row-gap: 14px;
+ column-gap: 14px;
align-items: stretch;
}
.voice-section {
@@ -4214,8 +4214,7 @@ body.curve-open #install-video-area #graph {
width: 1px;
align-self: stretch;
background: var(--frame-line);
- margin: 4px 4px;
- flex: 0 0 auto;
+ margin: 4px 0;
}
.voice-section-label {
font-family: var(--font-mono);
@@ -4225,6 +4224,7 @@ body.curve-open #install-video-area #graph {
color: var(--text-dim);
}
+
/* A tile is a labeled card that groups related controls. Tiles are sized
to their content (no fixed widths beyond what their inner channels and
per-tile rules dictate), which lets them wrap densely. */
diff --git a/demos/realtime_motion_graph_web/web/components/Performance/AdvancedDrawer.tsx b/demos/realtime_motion_graph_web/web/components/Performance/AdvancedDrawer.tsx
index 7fc0d068..4ffcdbde 100644
--- a/demos/realtime_motion_graph_web/web/components/Performance/AdvancedDrawer.tsx
+++ b/demos/realtime_motion_graph_web/web/components/Performance/AdvancedDrawer.tsx
@@ -265,7 +265,7 @@ const SPREAD_SECTIONS: Array<{ id: DrawerTab; label: string }> = [
{ id: "core", label: "Core" },
{ id: "styles", label: "Styles" },
{ id: "mod", label: "Mod" },
- { id: "voice", label: "Channels" },
+ { id: "voice", label: "Experimental" },
{ id: "config", label: "Config" },
];
diff --git a/demos/realtime_motion_graph_web/web/components/Performance/DrawerTabs.tsx b/demos/realtime_motion_graph_web/web/components/Performance/DrawerTabs.tsx
index fe6a99bc..2bebeedd 100644
--- a/demos/realtime_motion_graph_web/web/components/Performance/DrawerTabs.tsx
+++ b/demos/realtime_motion_graph_web/web/components/Performance/DrawerTabs.tsx
@@ -27,7 +27,7 @@ export type DrawerTab = (typeof DRAWER_TABS)[number];
const TAB_LABELS: Record = {
core: "Core",
mod: "Mod",
- voice: "Channels",
+ voice: "Experimental",
styles: "Styles",
// Auto-generated control surface, rendered straight from the backend
// /api/knobs manifest. Reference template for a re-skinned UI.
diff --git a/demos/realtime_motion_graph_web/web/components/Performance/MobileFullSheet.tsx b/demos/realtime_motion_graph_web/web/components/Performance/MobileFullSheet.tsx
index 084fc5ba..acc05387 100644
--- a/demos/realtime_motion_graph_web/web/components/Performance/MobileFullSheet.tsx
+++ b/demos/realtime_motion_graph_web/web/components/Performance/MobileFullSheet.tsx
@@ -29,7 +29,7 @@ const TABS: { id: Tab; label: string }[] = [
{ id: "core", label: "Core" },
{ id: "styles", label: "Styles" },
{ id: "mod", label: "Mod" },
- { id: "voice", label: "Channels" },
+ { id: "voice", label: "Experimental" },
{ id: "saved", label: "Saved" },
{ id: "config", label: "Config" },
];
diff --git a/demos/realtime_motion_graph_web/web/components/Performance/ModTile.tsx b/demos/realtime_motion_graph_web/web/components/Performance/ModTile.tsx
index 7f64d10f..ae3d4e97 100644
--- a/demos/realtime_motion_graph_web/web/components/Performance/ModTile.tsx
+++ b/demos/realtime_motion_graph_web/web/components/Performance/ModTile.tsx
@@ -2,7 +2,12 @@
import { usePerformanceStore } from "@/store/usePerformanceStore";
import { useSessionStore } from "@/store/useSessionStore";
-import { DCW_MODES, DCW_WAVELETS, RCFG_MODES, type RcfgMode } from "@/types/engine";
+import {
+ DCW_MODES,
+ DCW_WAVELETS,
+ RCFG_MODES,
+ type RcfgMode,
+} from "@/types/engine";
import { Knob } from "./Knob";
import { defaultLabelFor, kbdHintFor } from "./SliderTile";
diff --git a/demos/realtime_motion_graph_web/web/components/Performance/SliderTile.tsx b/demos/realtime_motion_graph_web/web/components/Performance/SliderTile.tsx
index 6d5c38e4..7aa2a7a1 100644
--- a/demos/realtime_motion_graph_web/web/components/Performance/SliderTile.tsx
+++ b/demos/realtime_motion_graph_web/web/components/Performance/SliderTile.tsx
@@ -63,6 +63,18 @@ const PARAM_TOOLTIPS: Record = {
cfg_rescale:
"After CFG, mix the guided velocity's magnitude back toward what the positive forward produced. 0 keeps raw CFG; 1 fully snaps the magnitude. Pair with high guidance_scale to keep the prompt-push without the harshness that high CFG causes on its own.",
+ // ── Activation steering (auto path) ──
+ // Each tooltip names the underlying probe cell so the operator can
+ // recreate the effect on a manual slot.
+ steer_bright:
+ "Activation-steering: positive alpha shifts spectral centroid up (brighter, more highs). 0 = off; useful range 5-15 by ear. Recreate as a manual slot: vector brightness_l09_t3 at layer = 9, step = round(3/8 x steps_count).",
+ steer_warm:
+ "Activation-steering: positive alpha tilts the spectrum toward bass (warmer). The raw vector points the wrong way for this axis, so this knob folds in a -1 sign. 0 = off; useful range 5-15 by ear. Recreate as a manual slot: vector warmth_l15_t0 at layer = 15, step = 0, then INVERT alpha sign (manual mode is sign-agnostic).",
+ steer_rough:
+ "Activation-steering: positive alpha increases spectral flatness (grittier, noisier). Vector magnitude at this probe cell is small, so effect builds slowly. 0 = off; useful range 5-15 by ear. Recreate as a manual slot: vector roughness_l09_t3 at layer = 9, step = round(3/8 x steps_count).",
+ steer_density:
+ "Activation-steering: positive alpha thins the texture toward sparse/minimal. Inject layer is shifted 3 shallower than the probe layer (Phase-3 transfer finding). 0 = off; useful range 5-15 by ear. Recreate as a manual slot: vector density_l18_t3 at layer = 15 (probe 18 minus 3), step = round(3/8 x steps_count).",
+
// ── DCW ──
dcw_scaler:
"Experimental — adjusts the low-band strength of an internal correction the model applies to itself during generation (DCW). This scaler is active in the early part of the run. The exact audio mapping is still being explored — sweep it to discover what it does for your source. Extreme values can be unpredictable but cool.",
@@ -97,6 +109,19 @@ export function tooltipFor(param: string): string | undefined {
if (param === "lora_blend") {
return "Crossfade between LoRA A and LoRA B. 0 = A only, 1 = B only, 0.5 = both at half strength. Use this to morph between two styles smoothly.";
}
+ // Manual steering tooltips share copy across all slots.
+ if (param.startsWith("man_src_")) {
+ return "Catalog index of the steering vector this slot fires. The catalog enumerates every pre-built (axis, build_layer, build_step) cell on disk in stable axis-major order. Double-click the readout to type an exact index; query the MCP list_manual_steering_vectors tool for the full table. Has no effect until α is non-zero.";
+ }
+ if (param.startsWith("man_layer_")) {
+ return "DiT inject layer (0-23). The vector is added to this layer's post-block residual. Bypasses the auto path's density layer offset — the value lands exactly where you point it.";
+ }
+ if (param.startsWith("man_step_")) {
+ return "Diffusion inject step (0-15). Bypasses the auto path's fractional step mapping; the engine fires the injection only on the step that matches this value. If you pick a step past the current steps count - 1, the slot stays silent until you raise the step count.";
+ }
+ if (param.startsWith("man_alpha_")) {
+ return "Strength of this manual slot's injection. 0 disables the slot. Negative α inverts the vector's direction at injection time (no sign correction is applied; what you set is what the engine receives). Sweep range and breakage point mirror the perceptual steering knobs.";
+ }
return PARAM_TOOLTIPS[param];
}
diff --git a/demos/realtime_motion_graph_web/web/components/Performance/VoiceTile.tsx b/demos/realtime_motion_graph_web/web/components/Performance/VoiceTile.tsx
index 7beeb478..17bf11ea 100644
--- a/demos/realtime_motion_graph_web/web/components/Performance/VoiceTile.tsx
+++ b/demos/realtime_motion_graph_web/web/components/Performance/VoiceTile.tsx
@@ -3,7 +3,9 @@
import { useEffect, useState } from "react";
import { useConfig } from "@/lib/config";
+import { useSessionStore } from "@/store/useSessionStore";
+import { Knob } from "./Knob";
import { SliderGroup } from "./SliderGroup";
import { defaultLabelFor, kbdHintFor } from "./SliderTile";
@@ -31,6 +33,15 @@ const MORPH = ["ch13", "ch14", "ch19", "ch23", "ch29", "ch56"];
export function VoiceTile() {
const ranges = useConfig().channel_ranges;
+ const manualSlotCount = useSessionStore((s) => s.manualSlotCount);
+ const manualSlotCap = useSessionStore((s) => s.manualSlotCap);
+ const steeringAvailable = useSessionStore((s) => s.steeringAvailable);
+ const remote = useSessionStore((s) => s.remote);
+ const slotCount = manualSlotCount ?? 0;
+ const slotCap = manualSlotCap ?? 0;
+ const canAddSlot = remote !== null && slotCap > 0 && slotCount < slotCap;
+ const canPopSlot = remote !== null && slotCount > 0;
+ const showSteering = steeringAvailable === true;
// Experimental-feature notice — dismissable, and the dismissal sticks
// across reloads. Read after mount (not in the useState initializer)
// so a localStorage read can't break SSR hydration.
@@ -69,9 +80,12 @@ export function VoiceTile() {
)}
+ {/* Two-column grid shared by the channel rows and the steering
+ rows so column 1 (highlights / steering) and column 2 (groups
+ / manual steering) line up across rows. */}
-
Highlights
+
channel highlights
{MORPH.map((p) => {
const r = ranges[p];
@@ -92,7 +106,7 @@ export function VoiceTile() {
-
Groups
+
channel groups
{VOICES.map((p) => {
const r = ranges[p];
@@ -111,6 +125,115 @@ export function VoiceTile() {
})}