Week of 2026-05-18 — AETHER Director's Track DSL: scene-as-code for multi-zone orchestration

Started 2026-05-18 · shipped

The household has ten Sonos zones, an Almanac display, and a Membrane audio pipeline that produces monthly Lineage programs. Until this week each of those was reachable only through its own surface — the Sonos zone dashboard at /, the Music PWA at /music, the Audio Pipeline LiveView at /audio. None of them composed. "Friday at 18:00 fade the social zones to 25%, kick off the January Almanac on the dining room, switch the display to high-contrast" required driving four surfaces manually, in sequence, by hand.

This workshop entry documents the substrate that closes that gap: a compile-time-validated Elixir DSL for declaring multi-zone scenes, and a small GenServer-based runtime that schedules them on the system clock and dispatches their steps through the existing NATS subject graph.

The DSL

The artifact is shaped to read like the household intent it encodes:

defmodule HomeScenes do
  use Aether.Director.Scene

  scene :friday_evening do
    description "Dining + family + bedroom dim for evening reading."
    at "18:00"
    on ~w(Mon Tue Wed Thu Fri)

    parallel do
      fade [:dining_room, :family_room, :master_bedroom],
        to: 25, over: {30, :seconds}
      play_program "almanac-january-rockefeller", zone: :dining_room
      display :almanac, mode: :high_contrast
    end
  end
end

Every line of that block validates at compile time:

config :aether, :zones. Typo :diningroomCompileError with the configured zone list in the message.

"Funday"CompileError.

The macros expand to a flat list of %Aether.Director.Scene.Step{} structs at compile time, accumulated onto a @__scenes__ module attribute, and exposed via a generated __scenes__/0 function. Scenes are pure data: introspectable, serializable, testable without standing up a runtime.

Design tradeoff: macros vs runtime DSL

The obvious alternative was a runtime DSL — scenes as %{name: …, steps: […]} maps loaded from a TOML/YAML file. Easier to reload, no compile step, no macro hygiene to worry about.

I rejected that path because scene-as-code is config that gets audited as carefully as the rest of the substrate. If :diningroom is typo'd, the failure mode under a runtime DSL is "Friday at 18:00 the kitchen fade silently does nothing because the bus rejects the unknown subject" — a household-orchestration substrate cannot have that failure mode. Compile-time validation means the typo fails the build the same hour it lands.

The cost is that adding/removing scenes requires a recompile in iex -S mix (or a full process restart). That's the right cost for this surface: scenes change rarely, and when they do they should get the same scrutiny as a configuration commit.

This is the same tradeoff Phoenix made for routes (compile-time verified routes vs runtime route table), Ecto made for schemas (compile-time field validation vs Map-backed records), and ExUnit made for tests (compile-time describe/test macros vs runtime test registration). The decision falls the same way each time the cost of late failure is high.

Integration shape: reusing NATS subjects, not bypassing them

The Director runtime does not call Aether.Zone.set_volume/2 directly. It does not poke the Membrane pipeline through an Elixir function call. It publishes NATS messages on the same subject graph the existing surfaces use:

| DSL step | NATS subject | Op | |-—|-—|-—| | fade [...] | lab.sonos.zone.<name>.cmd | volume (one cmd per ~1.2s step per zone) | | play_program … | lab.audio.program.cmd | play | | display … | lab.display.cmd | set_mode |

The reuse is the point. The audit stream (HOMELAB_AUDIT on JetStream) picks up every Director-emitted cmd alongside cmds from the dashboard, the PWA music client, or the Python sonos-cli. The substrate stays one substrate. The Director is a publisher; it participates in the existing protocol rather than replacing it.

fade is implemented as a sequence of timed volume cmds — a 30-second fade from 50% to 25% becomes 25 cmds at ~1.2s intervals, walking linearly from start to end. One unlinked Task per zone walks its plan in real time; the publish behaviour means tests substitute an Agent that captures the calls for assertion.

What's gated to v0.2

Needs an inheritance semantics decision (full-override vs step-prepend vs step-append) before code.

overlap (first-wins, newest-wins, union, conflict-error). A doctrine question, not a code question.

pre-roll synthesized through the Piper shim before the program kicks off. Requires the audio pipeline to support short-form one-shot synthesis; deferred until that's a clean entry point.

captured to a durable stream for offline replay + regression testing of scene semantics. The publish subjects already pass through JetStream; the missing piece is a Director-specific replay surface.

(door sensor, presence detection, Tailscale device-online). The runtime is already shaped to accept :fire_scene messages from any source; the missing piece is the trigger-source modules.

What landed

GenServer (~330 LOC)

shim with a Behaviour for test mocking (~25 LOC)

publisher + Behaviour for the runtime to call against (~60 LOC)

dashboard (~220 LOC)

+ validation tests (~250 LOC, 18 tests)

dispatch + scheduling-math tests (~310 LOC, 16 tests)

capture publisher + manual-set fake clock (~85 LOC)

Total: ~1,830 LOC of substrate + tests. 34/34 director tests pass. mix compile --warnings-as-errors clean. Full suite: 38/39 pass (one pre-existing failure in page_controller_test.exs unrelated to this work — it asserts on Phoenix's default landing text but the root route is DashboardLive).

Cross-references

the Director's scheduled-control surface. Same substrate, two control modes. See Week of 2026-05-15 — PWA Music Client.

S2 App Is Bad](/journal/sonos-s2-touchscreen-monoculture): control belongs in declarative substrate (config you can audit), not in a touchscreen UI flow (state you have to drive by hand).

§11 — the runnable-claim contract for software components of the portfolio.

License: AGPL-3.0. Version: v0.1.0 — concept-but-running.