Two Months of Aether: Keeping AI From Rotting a Real-Time Audio Engine

2026-04-16

I was bored one afternoon a couple of months ago. I started tinkering with Rust audio. Today I have a modular synth environment with 150+ nodes, a lock-free audio graph, a Rhai scripting layer that can build and drive entire arrangements, custom renderers for oscilloscopes and spectrum analyzers, procedural 3D scenes, a generated 583-line demo patch, and a docs site with per-node thumbnails rendered by the actual editor.

Most of this was written with an AI in the loop.

Some numbers for shape:

If you've tried pairing with an AI on anything non-trivial, you already know the failure mode. The model does what you asked, then next week it forgets, duplicates a constant somewhere, refactors a thing away from a pattern it established three days ago, and by month two the codebase is a divergent series - every individual change looks small and defensible, the partial sums still go to infinity. At 44 million tokens a month, divergence comes fast.

That didn't happen here. Not because the model magically got better, but because of structure around the model.

This post is about that structure. Three pieces: a vault, a macro, and a split. Plus the habits that hold them together.


The vault

There are 897 markdown files in .vault/ at the root of the repo. It's a flat-folder Obsidian zettelkasten. Every note is atomic - one idea per note. Notes link aggressively via [[wikilinks]]. Entry points are Maps of Content (MOC-*.md), which are just curated indexes with one-line descriptions of what each linked atom covers.

The vault has five note types, each with a specific job:

TypePurpose
AtomSingle concept: a pattern, an algorithm, an API detail. Timeless reference.
SpecDesign for an unimplemented feature. Describes what and how, not do this.
TaskWork item. Links to specs. Has status: active or done.
MOCIndex of related atoms/tasks. Zero content of its own, just links.
Node groupMOC for a related set of audio nodes (e.g. drum synthesis).

The distinction between a spec and a task matters. A spec describes what a subsystem is. A task says "implement this" and points at a spec. Atoms describe what exists now. Keeping those separate means the vault doesn't rot the way a single TODO.md would.

Why this matters for AI-assisted work: when I start work on a new subsystem, the AI reads the relevant MOC first, follows links, and has real context without grepping the whole codebase. If I'm touching the audio architecture, it reads MOC-audio-architecture.md. If I'm touching the editor, it reads MOC-editor-architecture.md and MOC-ui-logic-separation.md. The context is scoped, current, and not dependent on the model's training memory of some generic "how to build an audio engine."

The CLAUDE.md at the root tells the model exactly this:

Before investigating an unfamiliar subsystem (Bevy, rendering, ECS, audio pipeline, etc.), read the relevant vault MOC and linked notes. The vault contains hard-won knowledge from previous debugging sessions - patterns, gotchas, confirmed root causes. Start there before searching the web or guessing.

And the inverse:

After modifying code, check whether any vault notes reference the changed files, patterns, or APIs. Update them to reflect the current state.

The vault is a build artifact of my attention. Every time I figure out something non-obvious, it becomes a note. Every time the AI drifts from a pattern, I either update the note or add a new one. Over two months, that compounded into something that now bootstraps context for every session.

There's a dedicated violations-log.md for the inverse: existing code that breaks the current conventions. If the AI encounters it while editing nearby code, it logs the violation and moves on - doesn't silently "fix" it and start a cleanup side-quest. The log later tells me what to fix deliberately, on my schedule.


The macro

Aether has a custom proc macro called graph_node!. One invocation defines a complete audio node. Here's what the Oscillator looks like, abbreviated:

graph_node! {
    /// General-purpose waveform generator with sine, saw, square, triangle,
    /// and pulse shapes. Use as a sound source, LFO, or modulator.
    name: OscillatorNode,

    config: {
        frequency: Frequency = Frequency::A4,
        waveform: Waveform = Waveform::Sine,
        pulse_width: f32 = 0.5,
        phase_reset: bool = false,
    },

    state: {
        phase: Float = 0.0,
        triggered: bool = false,
    },

    inputs: [
        MidiIn       => Single(0) => { name: "MIDI In",       types: Midi },
        Gate         => Single(1) => { name: "Gate",          types: Gate },
        FrequencyMod => Single(2) => { name: "Frequency Mod", types: ControlFreq },
        /* ... PW Mod, Detune, Tuning elided for brevity ... */
    ],

    outputs: [
        AudioOut => Single(0) => { name: "Audio Out", types: Audio },
        Gate     => Single(1) => { name: "Gate",      types: Gate },
        Velocity => Single(2) => { name: "Velocity",  types: ControlNorm },
    ],

    metadata: {
        type_id: KnownNodeType::Oscillator,
        display_name: "Oscillator",
        category: NodeCategory::Source,
        description: "Waveform generator with frequency modulation",
    },

    properties: { /* typed getters/setters, UI hints - elided */ },
}

That single declaration expands to:

  • The port enums and their index traits
  • A Property enum with typed getters and setters
  • Config and State structs with serde serialization
  • The AudioNode trait impl scaffolding (I implement process() manually)
  • A StaticTopology impl describing the node's fixed port layout
  • A registry entry via inventory::submit! so the node self-registers at startup
  • Help text extraction: the /// comments split on the first blank line into help_brief and help_detail, stored on the metadata
  • A collect_sync() method for any sync: fields, used by the editor to show live values on the back face of the card-flip activity monitor
  • Per-node-type Rhai script handles with typed property setter methods - so Oscillator.add("osc").waveform(Sawtooth) works in a script without any manual glue

Effect: adding a new audio node means editing one file. The macro tells the compiler exactly what's needed; when I'm working with AI and I say "add a Phaser node," the shape of the work is unambiguous. The macro also catches structural drift: rename a port, and every process() function that referenced the old name fails to compile; add a property, and the exhaustive match on the Property enum elsewhere breaks until you handle the new variant. There's no "the docstring said it but the code doesn't implement it" problem, because there's no separate docstring to keep in sync - the /// comment becomes the node's help text directly, the port declarations are the single source of truth for what the node does.

This is the highest-leverage tool in the project. The alternative - "to add a node, edit the processor struct and the registry and the property enum and the serialization and the Rhai bindings and the help text table and..." - is exactly the kind of fan-out that defeats AI-assisted work.


The split

The editor is two crates:

  • libs/editor-logic - framework-agnostic UI logic. No egui dependency. No Bevy dependency. Its Cargo.toml has neither, so accidental coupling is a compile error. This crate owns all state machines, input interpretation, action dispatch, layout orchestration, and view model production.
  • libs/platform-bevy - thin Bevy + egui rendering layer. Reads view models produced by editor-logic, paints pixels, translates egui events into framework-agnostic UiEvent values, feeds them back.

The contract between them is view models: plain data structs produced each frame containing everything the renderer needs. Each widget - canvas, sequencer, palette, properties panel - has one. Renderers are pure functions from view model to pixels.

The canvas view model, for example, contains:

  • Every node's position, size, label, port list, interaction state
  • Every connection between nodes
  • The selection set
  • The current interaction (idle, dragging nodes, dragging connection, box selecting)
  • A transform block with zoom, scene rect, canvas screen rect

The renderer never queries live state. It paints what the view model says. If a value should conditionally change, the conditional is in logic, not in paint.

Two immediate benefits:

  1. Headless testing. bins/aether-headless runs the same editor-logic without any rendering backend. State-machine behavior is testable without a window.
  2. Weird reuse. I needed per-node preview images for the docs site. Instead of writing a second renderer, I spawned a minimal Bevy+bevy_egui app, built each node through the exact same view-model pipeline the live editor uses, called the same render_canvas function, screenshotted, and cropped. A PNG per registered node, rendered by the real editor, zero duplicated rendering logic. If I change a port color in the theme, the docs update on next just docs.

The split pays recurring dividends. It also matches how AI-assisted work wants to be structured: logic is testable without a window, view models are plain data the AI can reason about without needing to understand egui's paint loop.


Docs as a build artifact

The documentation site at voidrealm.com is entirely generated:

just docs

That runs three steps:

  1. just scripting-docs - dumps the Rhai scripting guide by walking the node registry and extracting metadata, property tables, port layouts, help text. Writes .vault/export/rhai.md.
  2. just node-thumbnails - spawns the headless Bevy+egui app, renders every registered node through the real editor renderer, crops to the node rect, writes PNGs.
  3. dump-docs-site - reads the node registry and the regenerated rhai.md, writes the node reference HTML, renders the Rhai scripting guide HTML with the same tokenizer the in-editor highlighter uses, and injects the live .aether/scripts/lofi-chill-demo.rhai script (syntax-highlighted) into the landing page.

Every node thumbnail, every scripting-guide TOC entry, every line of the embedded demo script, every token of syntax highlighting - built from the same sources the engine uses. If a node's port metadata changes, the docs change on the next run. Impossible to drift.

The tokenizer is the clearest example of the "never drift" principle. The in-editor Rhai syntax highlighter and the docs-site syntax highlighter both call one shared tokenize(&str) -> Vec<Span> function in editor-logic. If I add a keyword, both paths pick it up.


Getting the sound right

Smooth sound is not free. Real-time audio has a particular set of problems you don't hit anywhere else:

Zipper noise. Any control parameter changing at audio rate produces audible stepping. A gain slider jumping from 0.5 to 0.6 in one buffer produces a click. The fix is a SmoothedValue wrapper that ramps over a window - 64 samples (~1.3ms at 48kHz) by default. Anything that modulates amplitude or spectral content goes through it. There's a written smoothing policy that catalogs which parameters need it, which don't (gates, triggers, MIDI - all discrete events), and which existing nodes still need to be audited for compliance.

Envelope retrigger click. Fast repeated MIDI notes produce audible crackle when the envelope is retriggered while still active. The root cause is subtle: envelopes can be tempted to advance their state in coarse chunks ahead of the per-sample processing pass, which is cheaper - but then gate events arrive per-sample, out of phase with the coarse advance, and the retrigger blend captures the envelope level at the wrong moment. The correct shape is boring and expensive: every envelope evaluates per-sample, so trigger events always land where the signal actually is.

Arbitrary envelope shapes. The envelope isn't a fixed ADSR. It's a keyframe curve with cubic Hermite interpolation, arbitrary number of keyframes, optional hold point at any keyframe, non-zero starting values, tangent-driven overshoots. Retrigger blending must work for all of these, not just the textbook attack-decay-sustain-release case. Most audio engines punt on this; the retrigger has to compose with whatever shape the user drew.

None of these bugs are findable by listening once. The fix is a tooling loop:

just wav-retrigger       # Osc → AmpEnvelope, same-note retrigger
just wav-poly-retrigger  # held C4 with E4 stabs
just wav-polysynth-stack # 1, 3, 5, 7, 9 voice chords
just wav-polysynth-play  # play the WAV
just fuzz-retrigger      # 50-segment fuzz, writes WAV + report
just test-retrigger-click  # asserts a click exists - fails when fixed

Every one of these is a just recipe that runs a #[test] with --ignored --nocapture, generates a WAV, and (if the playback variant is used) pipes it to afplay. The fuzz harness produces 50 randomized retrigger patterns and writes a combined WAV plus a report on which segments contain clicks.

The note I wrote on that retrigger click runs 130+ lines. It documents the architecture, the root cause, every attempted fix, the specific commits that introduced and reverted the regression. It exists because fixing it the first time took days, and I wasn't going to re-derive it six months from now.

The broader pattern here matters more than the specific bug. The WAV recipes, the fuzz harness, the click detector - none of it is there for my ears. It's there so the AI can detect audio discontinuities without me in the loop. Before asking it to fix the retrigger click, I had it write a click detector - a test that passed on the broken version by confirming the click was present. Only once the detector was reliable did I point the AI at the fix. After that the AI had a ground truth signal it could trust: "the click is still here, keep going" or "the click is gone, you're done." I never had to listen to an attempt. The same shape applies to every audio-quality issue that shows up: build the detector, verify it on a known-bad case, then let the AI iterate against it. Correctness doesn't need my ears if the measurement is automated.


Testing the UI without a UI

The other axis: UI behavior.

A typical editor couples UI behavior to the rendering framework, which means testing it requires a running display server, a GPU, and an input event loop. Debugging a state machine means stepping through paint calls. Reproducing a drag-selection bug means actually dragging with a mouse.

The split I described earlier - editor-logic with zero rendering deps - makes this tractable. There's a second binary, bins/aether-headless, that runs the full logic layer without any presentation:

# Run a Rhai script headless, quiet mode, exit when done
just script-fast .aether/scripts/lofi-chill-demo.rhai

# Run headless with full tracing
just script-debug .aether/scripts/my-experiment.rhai

# Open a .aether project file in the TUI browser
just tui my-project.aether

The audio thread runs in the background pumped at ~1ms intervals. The script thread has a real Rhai engine. But there's no window, no egui, no eframe. All UI state machines - canvas interaction, sequencer piano-roll, shortcuts, layout - can be exercised via UiEvent values and asserted against view model output:

#[test]
fn console_tab_completion() {
    let mut console = ConsoleLogic::new(mock_context());
    console.handle_event(UiEvent::TextInput { text: "add_n".into() });
    console.handle_event(UiEvent::KeyPress {
        key: Key::Tab,
        modifiers: Modifiers::NONE,
    });
    let vm = console.view_model();
    assert_eq!(vm.input_text, "add_node");
}

That's the whole test. No window, no framework, no fixtures. 290+ tests like this across the codebase - ConsoleLogic, CanvasLogic, Shortcuts, LayoutOrchestrator, Sequencer, EditorSubsystem routing.

This matters more than it sounds. The AI can build a new widget or interaction state machine, then verify it works by writing and running tests against the view model - before I ever look at it. The feedback loop is compile → test → fix, not compile → open the app → click around → realize it's wrong → context-switch back → fix.

Integration tests use a shared test harness with five builders - test_editor(), test_editor_with_settings(), test_editor_with_project(), test_editor_with_scripts(), test_editor_with_rhai(). All backed by the real AudioThread and GraphEngine. The audio thread is pumped manually via pump_audio(). Property tests verify that processing through editor-logic without a presentation layer produces identical AppAction outputs as with one.

The key insight: if a feature can't be tested headlessly, the split is wrong. Any time I find myself thinking "this logic needs a window to test," that's a signal the logic has leaked into the rendering layer and needs to come back.


Stochastic graph tests

Another tool in the box, not a silver one. Two jobs: shake out crashes in weird node combinations, and build chains deep enough to expose where the engine spends its time.

Each node that participates in stochastic testing registers a StochasticEntry via inventory::submit! - a static table of function pointers the harness walks at runtime:

pub struct StochasticEntry {
    pub label: &'static str,
    pub try_append: fn(&mut StochasticChain) -> Option<ChainRole>,
}

The single function, try_append, is given a mutable reference to the growing StochasticChain and decides how (or whether) to attach. It returns a ChainRole - Source (generator, ignores tail), Insert (splice into the current path), or Parallel (branch off and merge back through a mixer) - or None if the node can't attach to the current tail type.

The chain owns a seeded RNG and a StochasticIntent hint - Light, Heavy, EdgeCase, or Functional - so nodes can dial their parameters toward the kind of run this is (minimum cost, maximum cost, boundary stress, or musically plausible). A Reverb receiving EdgeCase picks feedback 1.0. Receiving Functional, it picks a reasonable room size. There's a small adapter layer that auto-inserts conversion nodes when the chain's tail type doesn't match the next node's input.

The harness processes buffers and asserts the boring invariants: no panic, no NaN or infinity in the output, determinism (same seed → same bytes, modulo floating-point associativity).

Limits, honestly: it's a chain, not a general graph - no branching, no feedback. It only wires up the primary input/output path, so secondary ports (side-chain inputs, gate inputs, modulation ports) go largely unexercised unless a node's try_append specifically opts into them. The invariants it checks are crash-adjacent - panic, NaN, determinism - not musical correctness. "Did the envelope shape come out right" is not a question this harness can answer.

Within those limits, it finds real bugs. A NaN triggered by a specific oscillator-into-filter-into-reverb cascade at EdgeCase parameters is the kind of thing no hand-written test would cover, and every run that surfaces one is a bug I would otherwise have waited for a user to hit.

The other reason it exists is scale - building absurdly long chains to flush out bottlenecks. 100 passthrough nodes in a row tells you the per-node framework overhead. 50 biquad filters in series tells you coefficient-math cost. 500-node chains exercise topology mutation paths that would never happen in normal use but show up clearly in a flame graph. Those deep chains fed the first round of optimization work on pull() recursion and the output cache - you can find your hot spots without this, but it takes a lot longer.

The same seed-stable graph generator feeds the criterion benchmarks in the next section, so stochastic/light/seed_42, stochastic/heavy/seed_42, and stochastic/functional/seed_42 are all benchmarks whose graphs stay byte-stable across runs. You're measuring real work, not construction noise.


Measuring it

The audio thread has a budget. At 48kHz with a 512-sample buffer, that's ~10.6ms per buffer. Miss it and you get an audible dropout. You can't guess at performance here; you have to measure. And measuring has to be cheap enough to do constantly, or you don't do it.

Two things made measuring cheap. They weren't intended as performance tooling - they paid off as a side effect of how the code was structured.

Tracing was already there. Every non-hot-path function has #[tracing::instrument] on it. Hot paths (per-sample DSP, per-frame render) are explicitly marked // repo-util:hot-path at the file top, and the linter forbids #[tracing::instrument] inside those files. When I want to profile, I flip on tracing-chrome, run the app, drop the output into Perfetto, and every function call is a span on the timeline. There's nothing to wire up the day I decide I want a trace - the instrument attributes have been there all along.

Isolation made Criterion trivial. The graph engine has no dependency on audio I/O, no window, no file system, no threads tangled in. A benchmark looks like:

fn framework_passthrough_chain(c: &mut Criterion, n_nodes: usize) {
    let mut engine = GraphEngine::new(dsp_config());
    let sink = wire_passthrough_chain(&mut engine, n_nodes);
    c.bench_function(&format!("framework/passthrough_chain [{n_nodes}]"), |b| {
        b.iter(|| engine.pull(sink));
    });
}

That's it. No mocks, no fixtures. Because the engine is self-contained, benchmarking it is a four-line affair. Current coverage: audio_hot_path.rs, topology.rs, spectrum_analyzer.rs, stochastic.rs, view_models.rs - parameterized at 10 / 50 / 100 / 500 nodes so you can see how things scale, not just a single-number average.

The two tools compose: Perfetto finds bottlenecks in the real application (with real scene, real scripts, real audio thread). Criterion guards those bottlenecks once they're fixed. A real example: early audio-thread profiling surfaced Arc<dyn Any> clones happening per connection per frame in the metadata update path. The fix was to replace the Arc with a Box<dyn CloneableMetadata> and mutate it in place via get_or_insert_default::<T>(), instead of a read/clone/mutate/write cycle that allocated every frame. When the criterion harness landed later, it covered the same general neighborhood - passthrough_chain [N nodes], fan_in [N sources → mixer], command_dispatch [N commands] - so any regression in the hot path shows up in just perf-cmp.

just perf         # run all criterion benchmarks
just perf-save    # save current results as the baseline (git-tracked)
just perf-cmp     # compare against the saved baseline

Baselines live in .bench-baselines/ in the repo. Data files only - criterion's JSON and raw CSVs, not the SVG reports. Everyone who clones gets the same reference point.

None of this required dedicated perf work up front. It fell out of two structural choices made for other reasons: instrumenting everything by default, and keeping the graph engine uncoupled from the rest of the app.


Rust, and a linter of my own

I didn't pick Rust because it's trendy. I picked it because the compiler disagrees with the AI on my behalf.

The three language features doing the most work in this project:

Exhaustive pattern matching. For every enum I own, every match has to list every variant. I forbid wildcard _ => arms as a rule; the linter below enforces it. When the AI adds a new PortType variant, the compiler tells me about every place in the codebase that needs updating. That conversation is impossible in a language that lets you swallow unhandled cases.

Ownership and Send. The audio thread and the UI thread communicate only via lock-free channels. There is no option to "just share a mutex." You physically cannot write the wrong thing because the types don't line up. When the AI tries to sneak a Rc or a raw reference across a thread boundary - and it does try, it's been trained on code that doesn't care - the compiler blocks it.

Newtypes. NodeId, PortId, SampleRate, FrameCount, Frequency, Gain. No bare primitives in public APIs. A signature that takes (u32, u32, f32) is meaningless; a signature that takes (NodeId, PortId, Gain) is self-documenting and type-checked. The convention is written down: wrap a primitive as soon as it has a domain meaning a reader shouldn't have to guess at.

These alone catch most of what I'd call "AI slop." But rustc and clippy stop at the language. The project-specific conventions - no silent fallbacks, no weasel comments, no magic numbers, no 3000-line files - aren't enforceable by rustc. So I built my own.

repo-util

There's a custom linter in repo-util/ in the workspace. It's not a plugin; it's project-owned code, maintained like any other crate. It walks the codebase and enforces rules the Rust compiler can't:

  • unwrap-or-default - flags .unwrap_or_default(), .unwrap_or(0), .unwrap_or_else(..) patterns. Every one is a silent fallback hiding a potential bug. Handle the missing value explicitly or propagate the error.
  • match-fallback - flags wildcard _ => arms on project-owned enums. List every variant.
  • weasel-comment - flags comments containing "TODO", "FIXME", "XXX", "HACK", "for now", "workaround", "temporary". Fix the code so the comment is no longer true, then remove the comment.
  • fn-too-long - functions over 100 lines. Split.
  • file-too-long - files over 800 lines. Split into modules.
  • magic-value - numeric or string literals that should be named constants.
  • qualified-path - types used inline with 3+ path segments. Add a use import.
  • reexport - pub use re-exports outside lib.rs.
  • dead-allow - #[allow(dead_code)] or #[allow(clippy::...)]. If the code is unused, delete it; if the warning is wrong, justify it specifically.
  • println-in-src - println!/eprintln! outside tests. Use tracing.

The golden rule is written down as a first-class principle:

Lint violations identify real problems in the code. The fix is to solve the underlying problem, not to silence the lint. Every violation is a symptom - treat the disease, not the symptom.

And the inverse, equally important: if a rule itself is wrong, fix the rule. repo-util is project-owned, not some third-party plugin you have to put up with. If a warning is a false positive, the fix is to edit repo-util/src/ and submit a commit. I've done this several times. It keeps the tool honest and prevents the "suppress with #[allow]" escape hatch from becoming a habit.

Why this matters specifically with AI: the AI loves to weasel. Left alone, it will write .unwrap_or(0.0) to "make the code more robust." It will add // TODO: handle this edge case later and move on. It will put a magic 0.85 in the middle of a function. Every one of these is a small betrayal of correctness; individually they don't matter; cumulatively they compost into rot. The linter refuses all of them at build time. The AI and I learn the same lesson at the same time: write it properly or don't write it.

Running it is one command, scoped to a package:

cargo run -p repo-util -- lint -p editor-logic

There's also a just tidy <package> recipe that chains the full review pass - repo-util lint, then clippy --fix, then a clean clippy pass, then cargo fmt. Same scope. One shot before I push.

Between rustc, clippy, and repo-util, by the time code compiles cleanly it has already survived three tiers of review. Whatever gets through is what I actually have to look at.


The habits that hold it together

None of this works if the developer-AI pair isn't disciplined. A few habits, accumulated over two months:

Ask before adding unrequested functionality. If I said "fix bug X," the AI fixes X. It doesn't also refactor the surrounding code, rename a helper, or "clean up while it's in there." Scope discipline is a first-class rule.

Stop looping, start asking. If two attempts fail, stop. Explain what failed. Ask for guidance. The AI is really, really prone to trying a third variation silently, and the third variation is almost always worse than the first.

Trust the user's diagnosis. When I say "I think the issue is the pipeline timing," the AI tests that hypothesis first, not whatever it finds more interesting in the surrounding code.

These aren't abstract principles. They're notes in the vault. Every one of them was added after I got burned and told the model "don't do that again, and save a note so you don't forget."


What I actually think about AI coding

The leverage is entirely downstream of structure. The more sharply your domain is decomposed - typed boundaries, enforced conventions, one-file units of work - the more useful the AI becomes. Without that, you end up arguing with it every few hours about a pattern you thought you'd agreed on, and the codebase drifts into a shape nobody designed.

With it, the friction drops. Ambiguous instructions become unambiguous because the compiler and linter take on half the conversation. "Add a node" means editing one file; "fix this bug" means finding where the type system already pointed you. The work I got done in two months is work that would have taken much longer if every request required me to re-specify the rules - which is what happens in codebases without the rules written down and machine-enforced.

The structure I described above - vault + macro + split + linter + habits - is what keeps two months of AI-assisted work from turning into an archaeological site. Each piece does one job:

  • The vault stops the AI from hallucinating context. When it reads MOC-audio-architecture.md, it's grounded in what I actually built, not on its training data's averaged opinion about audio architectures.
  • The macro makes the shape of change unambiguous. There's one file to edit. The compiler tells you what's missing.
  • The split makes the right thing easy and the wrong thing a compile error. No egui types in logic. No business rules in paint.
  • The linter enforces conventions. You can't slip unwrap_or_default past it without an explicit repo-util:allow marker and a reason.
  • The habits are written down and re-read. "Don't duplicate the keyword list" is in the vault, not in my head.

This is not how most AI-coding demos look. Most demos are vibes: one prompt, one artifact, one refactor nobody wants to maintain. That's fine for a crud app. It's catastrophic for a real-time audio engine where one dropped sample is audible.

Aether is my answer to the question "can you actually build something real with this?" Two months in, the answer is yes, but with conditions. The conditions are what I've written above.


Where this goes

The next split is a big one: make the core graph engine no_std, and run it on a Raspberry Pi Pico 2 (RP2350, dual-core Cortex-M33 at 150 MHz) with Embassy as the async runtime. A hardware synth you design in Aether on the desktop, then compile and flash.

Most of the DSP code is already close. Node processors are math on buffers - oscillators, filters, envelopes, delays - nothing std-specific about them. The graph_node! macro generates the same types regardless of platform. What needs to change is everything around it: crossbeam channels (std-only, needs embassy-sync), the project file format (redb is std), the inventory-based node registry (relies on lazy statics and a linker trick that doesn't work on all embedded targets), and the tracing layer (not embedded-friendly). These are all feature-gatable, but every one is a real piece of work.

The goal is a single workflow: design the patch in Aether with full GUI, visualizations, scripting, and the 150-node palette. When you like it, compile a subset of the graph to firmware - just the nodes you used, no editor - and flash it to the Pico. Plug in a MIDI controller and an audio DAC and the instrument exists as hardware. Same node definitions, same macro, same DSP code, different deployment target.

I have an Embassy-based firmware synth project sitting alongside this one. They've been separate experiments so far. The intent is to unify them: Aether is the design environment, the Pico 2 is where the finished instrument lives.

Whether any of this works out depends on whether the methodology that got me here survives the port. The vault doesn't care about the target. The macro doesn't care about the target. The split between logic and presentation doesn't care. The pieces that will resist - inventory, redb, crossbeam, tracing - are the pieces I kept generic on purpose, because I expected this moment. We'll see.


Where it is

  • Project: voidrealm.com - demo video, docs, node reference with live previews
  • Contact: hello@voidrealm.com - if any of the above is interesting, or if you just want to talk shop
  • Tech stack: Rust nightly, Bevy 0.18, egui, cpal, crossbeam-channel, redb, rhai, custom proc macros
  • Status: two months of solo tinkering, no roadmap, no pressure

If you skim the landing page and the 583-line Rhai script on it looks like the kind of thing you want to read, poke at the rest. If you skim and it's not your flavor, no hard feelings.

Either way: the method is reusable. Start a vault, write the macro that makes your domain's base unit one-file, separate logic from paint. See how far you get.

pull()

hello@voidrealm.com

The terminal node of the whole graph. If any of the above resonated, that's where the signal ends up.