Engine add-ons¶
eden/engine/ holds Genesis components that stock Genesis does not ship: the audio sensors and the MochiSolver.
They are Genesis code (they subclass Genesis engine classes and run inside scene.step()), but they live in Eden so
that Eden runs on the released Genesis pin. This page is for two readers: users of the audio sensors, and
contributors adding a sensor or a solver. It also says which components cannot live here and where they go instead.
eden/engine/ is a plain package, not a framework: there is no registry, and import eden never imports it. A
sensor is installed by importing its module; a solver is installed on one scene at a time.
Why not a Genesis fork¶
A forked Genesis is a second pin to keep in sync and a rebase every time upstream moves. Worse, it makes configs
unreadable without the fork: Eden saves an options class by its module path (_option_module_ /
_option_class_), so an options class that lives in a Genesis fork makes every saved config that uses it unopenable
on stock Genesis. An add-on in eden/engine/ is importable by anyone who has Eden, and a saved config re-imports its
module by itself through that same path.
Genesis is pinned to an exact release in pyproject.toml, so even an add-on that reaches into Genesis internals
reaches into a surface that only moves when someone bumps the pin deliberately, and the checks below catch it then.
What can live here¶
Whether a component fits is decided by whether Genesis already has a seam where it belongs:
| Component | Genesis change needed | Where it goes |
|---|---|---|
| Sensor | none: SensorManager.SENSOR_TYPES_MAP is an open registry |
eden/engine/sensors/ |
| Recorder, viewer plugin | none: @register_recording, Viewer.add_plugin |
eden/engine/, once the first one arrives |
| A new solver with its own materials, alone in its scene | Simulator.__init__ builds a fixed list of solvers |
eden/engine/solvers/, installed per scene |
| A constraint-solver flavor | RigidSolver hardcodes its constraint solver |
a Genesis fork pin |
| A coupler | Simulator.__init__ picks it from a closed isinstance chain |
a Genesis fork pin |
Genesis has no entry-point or plugin discovery; the open registries above are its whole extension surface, and they
are what Genesis' own out-of-tree Nyx plugin (gs_nyx_plugin) uses.
What stays out is a component that must change a solver that other scenes use, or run beside one. A
constraint-solver flavor or a coupler replaces a Genesis attribute for every scene in the process. For those, pin a
Genesis fork that carries the component and keep only the Genesis-side code there: the options classes, the entity
wrappers and the validation stay in eden/options and eden/entities, so a saved config still names Eden modules.
Check what such a component gives up before adopting it: a faster constraint solver without static friction, for
example, silently breaks every grasp.
Hosting a solver: the MochiSolver¶
A solver can be hosted one scene at a time. The simulator steps, resets and snapshots whatever is in its solver list,
so a solver appended to one scene's list after gs.Scene() runs. What the list does not carry is bound on that
scene's instances, never on a class, so a scene without the solver runs exactly the upstream code. The MochiSolver
(eden/engine/solvers/mochi/hosting.py) is the worked example. Users configure it as described in
Physics, and EnvBase installs it on the scene's first Mochi
entity.
install_mochi_solver(scene, options) must run before the scene is built and before any Mochi entity is added. Its
seams are the ones any hosted solver meets:
| Upstream assumes | Hosted as |
|---|---|
SceneOptions declares every solver's options and fills their unset dt / gravity from SimOptions |
the options class (MochiOptions) lives in Eden; install_mochi_solver copies the unset values from scene.options.sim |
Scene.add_entity picks a surface's defaults from a closed isinstance chain on the material, and Simulator._add_entity gives a material to the solver declaring its most derived class |
the scene's add_entity is bound to route Mochi materials to add_mochi_entity, which sets the defaults and calls the solver's add_entity |
| the rasterizer draws the solvers it names, and the visualizer refreshes their visual geoms | those methods are bound on the scene's rasterizer context and visualizer |
Simulator.destroy frees the arrays of the solvers it can list |
the scene's sim.destroy is bound to free the solver's arrays after it |
Simulator.reset restores a solver only while it holds kinematic entities |
the scene's sim.reset is bound to restore a solver of deformable bodies alone |
the scene serializer names a material class by its bare __name__ |
a material extending an upstream one takes a name of its own (MochiRigid, not Rigid), or every rigid scene of the process would load back with it; scene.export is bound to raise, since gs.Scene.load rebuilds a scene without the hosted solver |
The ray tracer and the batch renderer have no such seam (their per-solver code is inline), so
install_mochi_solver refuses a scene that renders with either. Each binding is a seam upstream could open for any
third-party solver: a solver registry, material dispatch by method resolution order, surface defaults owned by the
material, renderers that iterate the active solvers, and a serializer that tells namesakes apart.
The Genesis internals a hosted solver subclasses or binds are declared with eden.engine._surface.require_members,
and their behaviour is pinned by tests/engine/solvers/mochi/test_hosting.py, so a Genesis pin bump fails there.
Writing a sensor add-on¶
Sensor.__init_subclass__ writes every subclass into SensorManager.SENSOR_TYPES_MAP, keyed on the options class
it reads off the generic parameterization, with no check on which package defined it. scene.add_sensor(options)
resolves through that map, so an Eden sensor class is reachable from Eden's own SensorsOptions as soon as its
module is imported:
from genesis.engine.sensors.sensor_manager import SensorManager
from eden.engine.sensors import SpatialAudio, SpatialAudioSensor
assert SensorManager.SENSOR_TYPES_MAP[SpatialAudio] is SpatialAudioSensor # no Genesis edit
scene.read_sensors() raises AttributeError in a scene with an out-of-tree sensor
The gs.sensors.types.<Name> tag namespace resolves names out of genesis.options.sensors, so it cannot see an
Eden-side options class, and scene.read_sensors() / entity.read_sensors() label their results through it.
They therefore raise AttributeError in any scene that holds an out-of-tree sensor. Nothing in Eden uses that
path: env.sensors[name].read() and the SensorRead observation term go through sensor.read(), which works.
tests/engine/sensors/test_spatial_audio.py pins the limitation, so a Genesis fix is noticed at the pin bump.
A sensor subclasses SimpleSensor, declaring its options, shared-context and metadata classes as type parameters.
Genesis reads them off __orig_bases__, so the parameterization is the registration:
class MySensor(SimpleSensor[MyOptions, MyContext, MyMetadata]):
def build(self): ... # resolve handles, grow per-class metadata
def _get_return_format(self): ... # read() shape; runs at construction
@classmethod
def _update_raw_data(cls, ctx, meta, raw_data_T): ... # per step, batched over all instances
Five things to know before you write one:
_get_return_format()runs during__init__, notbuild(). The read cache is sized from it, so anything the shape depends on has to be resolvable at construction. Eden creates entities before sensors, so resolving names against the scene is fine (seeActuationAudioSensor._resolve_dofs).- The per-step hooks are classmethods. One call updates every instance of the class, which is why per-instance
state goes in the shared metadata (grown one column per sensor in
build()) and not onself. entity_idxis not final at construction. Eden injects it ineden.entities.sensor.Sensor.pre_build, fromSensorOptions.attach_entity_name. Amodel_post_initvalidator that requiresentity_idx >= 0would reject every declarative config. Validate it in the options'validate_scene(scene), whichscene.add_sensorcalls after Eden's injection and before constructing the sensor, and nameattach_entity_namein the error: that is the knob the caller set. Genesis'RigidEntitySensorOptionsMixinalready does this for a sensor bound to a whole entity.- A link-attached sensor must use Genesis' attachment mixins. Subclass
KinematicSensorMixin(orRigidSensorMixin) and mixKinematicSensorMetadataMixininto the metadata, asSpatialAudioSensordoes. Eden'sSensor.get_pos()/get_quat()/set_pos_offset()read theoffsets_pos/offsets_quatthose mixins maintain, so a sensor that resolves its own link breaks all three, and getspos_offsetwrong unless it rotates it into the link's frame. Conversely, an options base brings fields with it: if the sensor has no use for an inherited one (ActuationAudioignoreslink_idx_local), refuse a non-default value invalidate_scenerather than drop it. - Use a
SharedSensorContextfor anything scene-level, as the next section explains.
SharedSensorContext: the seam that avoids a fork¶
A SharedSensorContext is a resource shared across sensor types, and SensorManager gives it a full lifecycle:
constructed on first use, update()d once per simulation step before the per-type update loop, reset() before
the per-type reset, and destroy()ed on teardown.
That is the lifecycle a scene-level subsystem needs. The fork Eden's audio sensors come from keeps its audio
registry as a scene-level AudioManager, which takes three Genesis edits; hosted on a context, the same subsystem
takes none:
| The fork needs | A context gives you |
|---|---|
Simulator.__init__ constructs the manager |
the sensor manager constructs the context on first use |
Scene.step pumps it |
update() once per step, before any sensor reads |
Scene.reset clears it |
reset(envs_idx) before the per-type reset |
Scene.add_audio_source fills it |
a producing sensor registers itself in build() |
The ordering is the valuable part: sources synthesize before microphones render, whatever order the sensors were declared in. Synthesizing inside a sensor's own update hook would make correctness depend on declaration order.
Anything that must advance every simulation step belongs on the context, not on a sensor. Eden skips the per-type
update loop on the intermediate scene steps of a control step (EnvOptions.update_sensors_every_substep=False, the
default) but still calls context.update(). State advanced from a sensor hook would see one step in decimation;
for the audio delay line that would have silently shortened every propagation delay by the decimation factor.
tests/engine/sensors/test_audio.py pins this.
Provenance, and what happens when the pin moves¶
State provenance in the module docstring: the upstream source and commit the component was ported from, every deviation, and the limitation that kept it out of Genesis. A component with no traceable origin cannot be re-synced when upstream moves, and one ported from a closed upstream pull request may have been closed for cause; that reason has to reach whoever decides whether to use it.
Each module then declares the Genesis class members it overrides, calls or reads beyond what its imports already guarantee:
require_members(__name__, {SimpleSensor: ("_update_raw_data", "_get_return_format"), RigidJoint: ("dof_start",)})
eden.engine._surface.require_members raises at import, naming the module, every missing member and the installed
Genesis version. It checks structure, which catches a rename or a removal. It cannot check behaviour, such as
SensorManager.step still calling context.update() before the per-type loop; tests in
tests/engine/sensors/test_audio.py pin those contracts so that a pin bump that breaks one fails loudly instead of
silencing a scene. When you add a component that leans on a behavioural guarantee, add the test that would notice it
change.
The audio sensors¶
Three stages, installed by import eden.engine.sensors. All but PianoAudio, which is Eden's own, are ported from
the neuroagents-lab/tactile-genesis fork (commit 3023bcd):
| Stage | Component | What it does |
|---|---|---|
| Synthesis | ActuationAudio |
Per-DOF motor noise from joint torque and speed. |
| Synthesis | ContactAudio |
Taps, ring-down and scraping of one link's contacts, voiced by the material of what it touches. |
| Synthesis | PianoAudio |
The struck strings of a keyboard entity, voiced from the motion of its key joints. |
| Propagation | AudioContext |
The source registry and the delay line carrying their sound. |
| Rendering | SpatialAudio |
A microphone: distance attenuation, propagation delay, Doppler. |
The sensors are declared like any other (see Sensors and rendering), and every microphone hears every registered source:
from eden.engine.sensors import ActuationAudio, ActuationAudioProperties, SpatialAudio
from eden.options.sensors import SensorOptions, SensorsOptions
AUDIO_SUBSTEPS = 20 # samples per sim step: with sim_dt=0.005 that is a 4 kHz audio rate
sensors_options = SensorsOptions(
motors=SensorOptions(
sensor=ActuationAudio(
audio_substeps=AUDIO_SUBSTEPS,
default_properties=ActuationAudioProperties(pitch_slope=180.0, power_gain=0.05),
properties={"joint1": ActuationAudioProperties(reversal_click_gain=0.3)},
),
attach_entity_name="robot",
),
mic_onboard=SensorOptions(
sensor=SpatialAudio(audio_substeps=AUDIO_SUBSTEPS),
attach_entity_name="robot",
attach_link_name="hand",
),
mic_room=SensorOptions(sensor=SpatialAudio(audio_substeps=AUDIO_SUBSTEPS, pos_offset=(2.0, 0.0, 0.5))),
)
ActuationAudio.read() returns (B, n_dofs, audio_substeps), the per-joint signal, usable as an observation.
SpatialAudio.read() returns (B, audio_substeps), the mixed signal at that microphone.
Contact sounds¶
ContactAudio sits on the link that does the touching and takes its timbre from what is touched. Materials are keyed
by Eden scene name, "bowl" for every link of that entity or "robot/hand" for one, and merge across every
ContactAudio sensor of the scene:
from eden.engine.sensors import ContactAudio, ContactAudioProperties
from eden.options.sensors import SensorOptions
steel = ContactAudioProperties(modal_freqs=(1180.0, 2050.0), modal_decays=(5.0, 8.0), modal_gains=(1.0, 0.7))
wood = ContactAudioProperties(
modal_freqs=(310.0, 640.0),
modal_decays=(90.0, 140.0),
modal_gains=(1.0, 0.5),
roughness_gain=0.3,
roughness_spatial_freq=400.0,
)
fingertip = SensorOptions(
sensor=ContactAudio(audio_substeps=80, properties={"bowl": steel, "table": wood}),
attach_entity_name="robot",
attach_link_name="left_finger",
)
ContactAudio.read() returns (B, audio_substeps), the structure-borne vibration at that link.
The piano¶
PianoAudio voices a keyboard entity, by default eden.options.objects.piano.Piano: 88 spring-loaded hinged keys
with the dimensions of RoboPianist's piano. The sensor reads only the
key joints. A key that travels past strike_fraction (default 0.87) of its stroke throws a hammer, as loud as the
key was fast; the note rings while the key stays down and is damped once it comes back up past release_fraction
(default 0.5), unless the sustain pedal is down. Whatever presses the keys, a robot hand or a joint torque, sounds
the same:
from eden.engine.sensors import PianoAudio
from eden.options import SceneOptions
from eden.options.entities import PlaneOptions
from eden.options.objects.piano import Piano
from eden.options.sensors import SensorOptions
scene_options = SceneOptions(floor=PlaneOptions(), piano=Piano(default_root_pos=(0.0, 0.0, 0.7)))
strings = SensorOptions(sensor=PianoAudio(), attach_entity_name="piano")
# after env.build():
env.sensors["strings"].sensor.set_sustain(True) # pedal down; pass envs_idx= for some environments
env.sensors["strings"].sensor.keys_down # (B, 88) bool copy, the same test that throws the hammers
To voice only some keys, name each with the note it plays; a subset is never renumbered:
from eden.options.objects.piano import LOWEST_MIDI_NOTE, key_joint_name
PianoAudio(key_notes={key_joint_name(key): LOWEST_MIDI_NOTE + key for key in (39, 43, 46)}) # C4 E4 G4
PianoAudio.read() returns (B, audio_substeps), the whole instrument mixed, which also radiates from the
keyboard's base link. A pressed key dips 10 mm below the keyboard's underside, so the keyboard cannot rest on a floor:
Piano defaults to 2 cm of clearance, and any other placement must leave that gap too.
The piano task (eden/tasks/piano/config.py, eden train --task piano --song twinkle_twinkle) reads the key
joints directly and does not attach PianoAudio, since a reward needs only which keys are down. To hear a policy,
attach the sensor at evaluation time and record it. A control step spans decimation simulation steps and read()
returns only the last one's block, so sensor.start_recording() keeps every block and sensor.take_recording() hands
over what has accumulated; call it once per env.step(). examples/projects/manipulation/piano/main.py play does
exactly that.
Drain a piano recording every step
Unlike Camera.start_recording, which streams to a file, PianoAudio holds its blocks in memory for every
environment until take_recording() empties them, so an undrained recording grows without bound. Starting a
second recording while one is open raises rather than dropping what it holds, and so does stopping one that was
never started.
Things to get right¶
- One sample rate per scene. Every source and microphone must share
audio_substeps; a disagreement raises at build, naming both values. max_delaysizes the delay line. A source farther thanmax_delay * speed_of_soundis heard at the clamped delay rather than its true one. The defaults (max_delay=0.03s,speed_of_sound=343.0m/s) reach about 10 m.- Continuous audio needs one render per block. At Eden's default
decimation=4, reading a microphone once perenv.step()samples one block in four: fine as an observation, gapped as a recording. Usedecimation=1(orupdate_sensors_every_substep=Trueplus a reader at the simulation rate) when writing to a file; the sensors warn once when they notice. Propagation delay, attenuation and Doppler are unaffected either way. ActuationAudiocovers a whole entity. Each DOF radiates from its own joint's child link, soattach_link_name(and the inherited offsets) raise rather than being ignored; usejointsto voice a subset.- Every partial stays below Nyquist. A whine or hum fundamental saturates where its highest partial with a
non-zero gain meets the audio rate's ceiling (0.45 x Nyquist: 900 Hz at the default
sim_dt=0.005andaudio_substeps=20), so a bank with fewer sounding partials reaches a higher pitch. Afriction_freq,click_freqoridle_freqthe rate cannot carry is clamped, with a build-time warning when its gain makes it audible. The defaults fit the default rate; raiseaudio_substepsfor more headroom. - Register sources at build. The delay line is sized on the first registry update that finds a radiating source,
and
env.build()already runs one, so a source declared throughSensorsOptionsis always in time. A hand-registeredAudioSourceis in time only when no declared source exists yet; a lateAudioContext.registerraises rather than being dropped. - A still joint is silent. The whine fades out below 20 Hz: otherwise a joint holding a load has torque but no
pitch, and
load_gain * |tau|times its frozen oscillator is a DC offset. Useidle_gainfor the hum of an energized motor at rest. - Contact materials need audio headroom. Real objects ring at kilohertz, and a mode above the ceiling is silenced
(with a warning) rather than aliased: at the default 4 kHz rate nothing above 900 Hz sounds. The contact recipe runs
audio_substeps=80for a 16 kHz rate. - A tap lasts one simulation step, which is why
ContactAudiosynthesizes from the registry's per-step update and not from its own sensor hook: atdecimation=4the hook would miss three taps in four. - A scene where no
ContactAudiodeclares a material raises at build, since nothing could ever sound. A link with no material (and nodefault_properties) is silent by design. - A piano needs a 16 kHz rate, which is why
PianoAudiodefaults toaudio_substeps=80where the other sensors default to 20: the keyboard's fundamentals run to 4186 Hz. Its strings are exact sinusoids, so they run up to 0.9 x Nyquist rather than the filters' 0.45; a partial above that is dropped, and the keys whose fundamental is dropped are reported at build. Put it in a scene with other audio sensors and they must useaudio_substeps=80too. - How hard a key is hit is how fast it was moving over the simulation step that carried it past the strike
point, or the step before if that was faster, since a fast key meets its stop inside that step. A key eased down
makes no sound.
velocity_ref(default6.0) is the speed of a full-scale note. - Synthesis is block-vectorized.
ActuationVoice.emitrenders a whole block per call, with the two-pole filters in closed form (two_pole_block_kernels), so the kernel count does not grow withaudio_substeps. A piano block is one matrix product: the block is linear in the strings' phasors, soPianoVoice.emitmultiplies them into precomputed envelope-times-sine tables. On CUDA the product is split so that TF32, whicheden.initenables by default, costs no precision; unsplit it would put a -70 dB error on every sample. - The piano is not a sampled instrument. It has the right pitch, dynamics and damping, and is recognizably a struck string rather than a grand piano; amplitudes are in arbitrary units. RoboPianist itself synthesizes nothing: it sends MIDI to FluidSynth.
- Not ported from the fork's contact sensor:
from_meshmodal analysis with strike-location timbre, and the active-acoustic excitation emitter. - Free-field only. No occlusion, reflections or room response: a microphone behind a wall hears the source as if the wall were not there.
Hearing it live¶
eden.engine.sensors.playback.LiveAudioPlayer streams a sensor's blocks to the sound card while the simulation runs.
It needs no Python audio package: it pipes raw PCM into aplay or ffplay, whichever the machine has, and paces the
loop to real time so the sound stays with the viewer's picture:
from eden.engine.sensors.playback import LiveAudioPlayer
with LiveAudioPlayer(sample_rate=int(AUDIO_SUBSTEPS / SIM_DT), gain=0.1) as speaker:
for _ in range(steps):
env.step()
speaker.write(env.sensors["mic"].read()[0])
The sound trails the simulation by the lead cushion (default 0.06 s) plus the sound server's own latency;
close() reports how far it trailed. The players are told not to buffer ahead (a stock aplay would add about
0.7 s and ffplay 2 s), so pass a custom command only if it is equally well behaved.
A live stream cannot be peak-normalized, so gain is fixed up front and the signal is soft-limited. A simulation
slower than real time cannot play in sync: each time the sound card runs dry the player counts a dropout and rebuilds
its cushion, and it reports the count on close. Eden's viewer already paces env.step() to sim_dt, so a scene that
holds that rate plays cleanly with the viewer open. The player needs one read() per simulation step
(decimation=1).
Every audio recipe takes --play (live, best with --vis) and --wav <file>:
examples/recipes/sensors/engine_addon_spatial_audio.pyruns a Franka with three microphones.examples/recipes/sensors/engine_addon_contact_audio.pydrops a ball on steel and another on wood, and slides a box down a ramp.examples/recipes/sensors/engine_addon_piano.pyplays Ode to Joy by pushing the keys of a simulated piano.
See also¶
- Physics: using the Mochi solver.
- Sensors and rendering: declaring sensors, reading them, and when they update.
- Engine API: every field of the audio sensors and the Mochi solver.