Grand Diomande Research · Full HTML Reader

Stage 3.5 — Creative:Forge architectural synthesis

> Output of Wave 9 forge pass on the master plan (stage3-expand-master-plan.md). > Scope: deepen the 3 architectural questions the master plan defers or under-specifies. > Treat as **PRODUCTION SYSTEM**. Don't break what ships. > Six forge phases per question: Prime → Explode → Forge → Synthesize → Create → Evolve.

Embodied Trajectory Systems research note experiment writeup candidate score 22 .md

Full Public Reader

Stage 3.5 — Creative:Forge architectural synthesis

> Output of Wave 9 forge pass on the master plan (stage3-expand-master-plan.md).
> Scope: deepen the 3 architectural questions the master plan defers or under-specifies.
> Treat as PRODUCTION SYSTEM. Don't break what ships.
> Six forge phases per question: Prime → Explode → Forge → Synthesize → Create → Evolve.

The master plan picks the right 5 panes and the right reel-ship cadence, but
under-specifies three load-bearing decisions that will bite during Wave A-B
execution if not nailed down:

- Q1 — where exactly does the audio tap live, and does LUMF carry SAN's
pattern decisions?
Master plan §7.1 hand-waves "AudioEngine pre-allocated
buffers" without picking the AVAudioEngine node, the cadence, or the
threading model. Get this wrong and either the engine drops audio
(catastrophic) or LumfPublisher silently allocates per-buffer (audible
glitches every ~5s as ARC stalls the audio thread).

- Q2 — three directors, no shared state schema. Master plan §6 R6 names
the risk ("coordination collapse across 5 panes") but only mitigates code
collisions, not director state collisions. Stage 0 found three live
directors actively maintained. Without a written contract, the next pane
to touch any director re-derives the schema and they diverge harder.

- Q3 — universal sled has two physical constraints (USB topology vs
thermal) and four-way mounting variance.
Master plan §7.5 lists
`MAC_MINI_W/D/H/FOOT_PITCH` constants but doesn't pick the dominant
constraint that drives the CAD geometry. Pick wrong and the sled prints
but the cables don't reach, or the thermals are fine but the user
fights connectors every load-in.

Each question is forged through 6 phases below.

---

Q1 — Audio tap point, LUMF cadence, threading, backpressure, schema

Phase 1 — Prime

Question (precise): Given AudioEngine.swift's existing
`mainMixerNode.installTap(onBus:0, bufferSize:512)` at line 453, where
exactly does LumfPublisher hook in, at what cadence does it emit LUMF, on
which thread does the UDP send happen, and does the existing 84-byte LUMF
schema (RMS + centroid + onset + 4 EQ bands + 8 MFCC) need to be extended to
carry SAN's pattern/phrase/gesture decisions, or does that information ride
a separate channel?

Why it's load-bearing: AVAudioEngine taps fire on the audio render
thread. Any allocation, any blocking syscall, any GCD `sync` hop on that
thread = dropped buffers = audible audio glitches. The master plan's `~180
lines` LumfPublisher cannot allocate per-buffer Data, cannot call
`NWConnection.send` synchronously on the audio thread, and cannot
heartbeat-write to disk in the hot path. The 86 Hz tap rate (44.1k / 512)
also doesn't match the LUMF wire-format expectation (`audio_pub.py` runs
60 Hz). Mismatch handling matters for Unity reactivity.

Phase 2 — Explode

Six concrete implementation variants:

V1 — installTap on mainMixerNode, dispatch_async to serial queue, send
each buffer.
Tap fires at 86 Hz (44.1k/512). On the tap callback,
`Data(bytes:count:)` is allocated, RMS+FFT+MFCC computed inline on the
audio thread, pushed via `lumfQueue.async { connection.send(...) }`. Pros:
simplest, tightest sample-to-wire latency (~12 ms). Cons: allocates per
buffer (ARC stalls), runs FFT on audio thread (wastes 0.4 ms × 86 Hz = 35
ms/sec of audio time), 86 Hz delivery exceeds the Wave 8 receiver's
expected 60 Hz cadence (extra ~26 LUMF/sec of network traffic Unity
discards).

V2 — installTap, copy PCM into a lock-free SPSC ring buffer, drain on a
dedicated 60 Hz publisher thread.
Audio thread does ONE memcpy into a
pre-allocated ring (no ARC, no FFT, no syscall). A `DispatchSourceTimer` at
60 Hz on a dedicated `DispatchQueue` drains 735 frames (44.1k/60), runs
FFT/MFCC on the worker thread, packs the 84-byte struct into a
pre-allocated `Data`, sends via `NWConnection`. Pros: audio thread does
~10 µs of work per callback (just the memcpy), publisher thread can stall
without affecting audio, cadence matches LUMF spec, FFT runs on a non-RT
thread that can be deprioritized under load. Cons: ~16-32 ms additional
buffering latency, ring-buffer state machine is the most complex of the
variants.

V3 — Don't tap. Subscribe to AudioEngine's existing `fftMagnitudes`
@Published field, derive LUMF from it.
Lines 24, 1802-1830 already run
FFT on the audio thread and publish 128 magnitudes at the tap rate.
LumfPublisher just observes the array and computes EQ bands + MFCC
post-hoc. Pros: zero new audio-thread code, zero allocation risk. Cons:
@Published forces MainActor delivery, MFCC needs full mel-filter run that's
not currently computed (so we add work somewhere anyway), and we lose RMS
on raw PCM (would have to read mixerNode.outputMeter or recompute from FFT
magnitude squared, which is approximate).

V4 — Add a second mixer tap on the output node post-effects (line 290's
`eng.outputNode`), separate from mainMixerNode.
LumfPublisher gets its
own 1024-frame tap, MusicAnalysisService keeps its 512-frame tap on
mainMixerNode. Pros: zero coupling between consumers. Cons: doubles the FFT
cost, two taps means two memcpy paths, and the existing MixerBufferConsumer
refactor in master plan §7.1 becomes pointless (two consumers each want
their own tap).

V5 — Run LumfPublisher inside the Echelon Rust crate. `cc-echelon`
already gets PCM via the Rust FFI (line 322's `bridge.renderAudio`).
LumfPublisher becomes a Rust function that publishes LUMF as a side effect
of `echelon_render_audio`. Pros: zero Swift audio-thread work, ships in the
Rust binary, can be reused on Mac4 if Rust crate compiles for both targets.
Cons: rebuild + recodesign of `libechelon_ios.a`, harder to iterate on the
LUMF schema, ties LUMF emission to Echelon mode (Swift fallback per line 12
wouldn't emit LUMF).

V6 — Don't publish LUMF from MotionMixApp at all. Have MotionMixDirector
poll MotionMixApp's audio over a new HTTP endpoint at 60 Hz and emit LUMF
from the Mac.
Pros: keeps audio thread clean, centralizes wire-format
emission on a desktop-class CPU. Cons: violates the master plan's
"device-side LUMF publisher" contract, adds 30-60 ms of network round-trip
latency per LUMF packet, HTTP polling at 60 Hz over MJPEG-style framing
adds a ton of overhead, and falls apart when MotionMixDirector is offline
(loses audio reactivity entirely).

Schema extension subquestion (orthogonal to V1-V6):

- S0 — keep LUMF as-is. SAN pattern/phrase/gesture rides a separate
wire format (e.g. new `LUMP` magic for "Lume Music Pattern", port :9703).
Existing 84 bytes pinned by golden-bytes test stays untouched.
- S1 — extend LUMF to 128 bytes with 8 SAN scalar fields tacked on
end. Old receivers truncate; new Unity receivers parse the extension.
Backwards-incompatible only for receivers that bounds-check the
short-packet rejection, which `lume_packet_inspector.py` does.
- S2 — burst-multiplex. Every LUMF packet always carries the 84
bytes. Every Nth packet (e.g. every 30th = 2 Hz) carries a second
envelope after the LUMF payload with SAN fields. Receivers
short-circuit on first 84 bytes, advanced receivers read past EOF.
- S3 — separate channel via OSC. SAN already publishes on the same
OSC port as Wave 8's `LumeMotionToAudio.cs` (the 7 channels). Add
`/san/pattern_id`, `/san/confidence`, `/san/cluster` to the OSC bus.
Receiving end is `OSCService.swift` which Bridge (b) already builds.

Phase 3 — Forge

Pick: V2 + S3.

V2 is the production answer because it is the only variant that respects
the AVAudioEngine real-time contract while matching the existing 60 Hz LUMF
cadence pinned by `tests/test_wire_format_golden.py`. V1's per-buffer ARC
allocation will glitch under memory pressure (verified failure mode in
similar AVAudioEngine apps under iOS 17 background-task pressure). V3
loses the rawest signal and adds MainActor hops. V4 doubles FFT cost for no
benefit since the master plan's MixerBufferConsumer refactor already
solves the consumer-multiplexing problem. V5 ties LUMF to Echelon mode and
breaks Swift fallback (line 12). V6 breaks the device-side contract.

S3 is the schema answer because:

- LUMF golden-bytes test (`tests/test_wire_format_golden.py`) is in the
master plan §0 don't-touch list. Don't extend it.
- SAN pattern decisions are NOT 86 Hz signals; they are 30 Hz sparse
events with confidence gating (see master plan §7.3 SANPipelineBridge
design). Wire format for sparse events should not ride the dense-PCM
audio channel.
- Bridge (b) already builds `OSCService.swift :9050` consumer in Wave B.
Adding 3 OSC paths for SAN is one-line additions on the Wave 8 publisher
side and one-line decoders on the iOS side.
- It cleanly partitions: dense audio metrics (RMS, EQ, MFCC) ride LUMF.
Sparse cognitive metrics (pattern_id, confidence) ride OSC.

V2's specific cadence pick: 60 Hz, 735 frames per pull (44.1k/60).
This matches `audio_pub.py:send_lumf` and Unity's
`LumeAudioFftReceiver.cs` expectation. The 86 Hz tap rate becomes an
input rate that gets resampled by the ring buffer into a 60 Hz output
rate. Under-pull is fine (ring drops oldest); over-pull at startup waits
on a `DispatchSemaphore` for the first full window.

Threading model:
- Audio thread (line 453's tap callback): one memcpy into ring buffer,
zero allocations, zero syscalls. Target: < 50 µs per callback.
- Publisher thread (`com.lume.lumf-publisher`, QoS .userInitiated, serial
DispatchQueue): drains ring, runs FFT/MFCC, packs 84 bytes, calls
`NWConnection.send`. Target: < 1 ms per pull.
- Heartbeat thread (`com.lume.lumf-heartbeat`, QoS .utility): writes
heartbeat file every 1 s. Decoupled from the audio path entirely.

Backpressure: `NWConnection.send` is non-blocking by contract. If the
network is slow, the connection's internal queue grows (backpressure goes
to the publisher thread, not the audio thread). The audio thread NEVER
blocks on network state. If the connection is .failed/.cancelled, the
publisher thread drops packets (no buffering — LUMF is "live or nothing,"
60 Hz state).

Phase 4 — Synthesize

Interactions with adjacent components verified from stage0:

- MusicAnalysisService (existing onMixerBuffer consumer, line 458)
P1 task #1 introduces `[MixerBufferConsumer]` array. MusicAnalysisService
wraps as a consumer that, on each tap callback, runs its existing
analysis. No behavior change; just register-then-call instead of
direct closure.
Verified safe because line 458's call is already the
only place `onMixerBuffer` is invoked.

- AudioCaptureWriter (P2 task #2) — also a MixerBufferConsumer. Writes
PCM to a 30 s ring on disk. Subscribes to the same tap, receives the
same `(AVAudioPCMBuffer, Float)` signature. Crucially, it must NOT use
the same FFT-publishing ring as LumfPublisher; it has its own write
buffer because the WAV writer needs every sample, not a 60 Hz
resample.

- Echelon Rust path (line 322, `bridge.renderAudio`) — unaffected.
Echelon outputs interleaved stereo into the AudioState; that audio
flows through the mixer, the tap fires after that. LumfPublisher sees
Echelon's audio just like Swift fallback's audio. The unification is
the point.

- `fftMagnitudes` @Published (line 24, populated at line 1827-1829)
unaffected. Continues feeding the iOS UI's spectrum strip. LumfPublisher
computes its own FFT in the publisher thread because it needs MFCC and
EQ band partitioning at a different resolution than the UI's 128
magnitudes.

- K11 NSSM `LUME-Audio` (existing :9701 publisher) — adds runtime arg
`--prefer-device --heartbeat-file C:\lume\heartbeat\device.txt`. Master
plan §6 R2 mitigation. Service registration block in `install-services.ps1`
stays untouched per don't-touch list.

- Unity `LumeAudioFftReceiver.cs` (Wave 4 Track C, commit `e36956f7`)
unaffected. It already binds `:9701` and decodes 84 bytes via the
pinned wire format. Whether the bytes come from MotionMixApp or
K11-fallback, receiver doesn't care.

- OSCService.swift (Bridge (b), Wave B P4) — gets 3 new OSC path
decoders for SAN: `/san/pattern_id` (Int32), `/san/confidence` (Float),
`/san/cluster` (Int32). Routed into ParamMapper.Extras as
`sanPatternId: Int?`, `sanConfidence: Float?`, `sanCluster: Int?`.
Wave 8 K11 publisher side adds 3 lines to `LumeMotionToAudio.cs` to
emit these.

Phase 5 — Create — concrete contract

swift
// MotionMixApp/Services/LumfPublisher.swift (NEW, ~210 lines)
// PRODUCTION SYSTEM — don't break what ships.
// Wire format pinned by software/demo/tests/test_wire_format_golden.py
// Magic: 0x4C554D46. Size: 84 bytes. Port: 9701.
// Cadence: 60 Hz pulled from a 86 Hz tap via SPSC ring buffer.

import Foundation
import Network
import AVFoundation
import Accelerate

/// Marker protocol introduced by AudioEngine refactor in P1 task #1.
/// AudioEngine.swift line 110 changes from a single closure to:
///     private var mixerConsumers: [MixerBufferConsumer] = []
protocol MixerBufferConsumer: AnyObject {
    /// Called from the AVAudioEngine render thread. NEVER allocate, NEVER
    /// block, NEVER hop to MainActor here. Copy-then-defer is the contract.
    nonisolated func handlePCM(_ buffer: AVAudioPCMBuffer, sampleRate: Float)
}

final class LumfPublisher: MixerBufferConsumer {
    // MARK: Wire format constants (golden-bytes test source of truth)
    private static let MAGIC: UInt32 = 0x4C554D46  // "LUMF"
    private static let PACKET_SIZE = 84
    private static let TARGET_HZ: Double = 60.0
    private static let FRAMES_PER_PULL = 735       // 44.1k / 60

    // MARK: Audio-thread state (lock-free SPSC ring)
    /// Pre-allocated mono PCM ring. 16 384 frames = ~370 ms at 44.1k,
    /// covers two consecutive 60 Hz pulls with 200 ms slack.
    private let ringCapacity = 16_384
    private let ring: UnsafeMutablePointer<Float>
    /// Producer (audio thread) writes head; consumer (publisher) reads tail.
    /// Atomics on i32 to satisfy ARM64 memory ordering without locks.
    private var head = ManagedAtomic<Int32>(0)
    private var tail = ManagedAtomic<Int32>(0)

    // MARK: Publisher-thread state (FFT, MFCC, packing)
    private let publisherQueue = DispatchQueue(
        label: "com.lume.lumf-publisher",
        qos: .userInitiated
    )
    private var publisherTimer: DispatchSourceTimer?
    private let fftSetup: vDSP_DFT_Setup
    private let fftReal: UnsafeMutablePointer<Float>
    private let fftImag: UnsafeMutablePointer<Float>
    private let fftMag: UnsafeMutablePointer<Float>
    private let hannWindow: UnsafeMutablePointer<Float>
    private let mfccFilters: UnsafeMutablePointer<Float>  // 8 mel filters × 257 bins
    private var prevSpectrum: [Float] = .init(repeating: 0, count: 257)
    private var fluxEMA: Float = 0.0
    private var packetBuffer: Data  // pre-allocated 84-byte buffer

    // MARK: Heartbeat (separate thread, decoupled from audio + publisher)
    private let heartbeatQueue = DispatchQueue(
        label: "com.lume.lumf-heartbeat",
        qos: .utility
    )
    private var heartbeatTimer: DispatchSourceTimer?
    private let heartbeatURL: URL

    // MARK: Network
    private let connection: NWConnection
    private var connectionReady = false

    init(target: String, port: UInt16 = 9701) throws {
        // Allocate audio-thread ring (zero-init for safety; size powers of 2
        // so head/tail wrap is bitwise AND).
        self.ring = .allocate(capacity: ringCapacity)
        self.ring.initialize(repeating: 0, count: ringCapacity)

        // Allocate publisher-thread FFT scratch (stays alive for object lifetime).
        self.fftReal = .allocate(capacity: 1024)
        self.fftImag = .allocate(capacity: 1024)
        self.fftMag = .allocate(capacity: 512)
        self.hannWindow = .allocate(capacity: 1024)
        vDSP_hann_window(self.hannWindow, 1024, Int32(vDSP_HANN_NORM))

        // Pre-allocate 84-byte packet so publisher thread never allocates.
        self.packetBuffer = Data(count: 84)

        // FFT setup for 1024-point real FFT (next power of 2 >= 735).
        guard let setup = vDSP_DFT_zop_CreateSetup(nil, 1024, .FORWARD) else {
            throw LumfError.fftSetupFailed
        }
        self.fftSetup = setup

        // Mel filter bank — 8 triangular filters across 257 mag bins
        // (covers 0-Nyquist at 22050 Hz). Pre-computed at init.
        self.mfccFilters = .allocate(capacity: 8 * 257)
        Self.populateMelFilters(self.mfccFilters)

        // Heartbeat file — readable by K11's --heartbeat-file flag (via Tailscale).
        let appSupport = FileManager.default.urls(
            for: .applicationSupportDirectory, in: .userDomainMask
        )[0]
        self.heartbeatURL = appSupport
            .appendingPathComponent("MotionMixApp", isDirectory: true)
            .appendingPathComponent("lumf-heartbeat.txt")
        try? FileManager.default.createDirectory(
            at: heartbeatURL.deletingLastPathComponent(),
            withIntermediateDirectories: true
        )

        // NWConnection — UDP, no per-buffer alloc.
        let endpoint = NWEndpoint.hostPort(
            host: NWEndpoint.Host(target),
            port: NWEndpoint.Port(integerLiteral: port)
        )
        self.connection = NWConnection(to: endpoint, using: .udp)

        startConnection()
        startPublisherTimer()
        startHeartbeatTimer()
    }

    deinit {
        publisherTimer?.cancel()
        heartbeatTimer?.cancel()
        connection.cancel()
        ring.deallocate()
        fftReal.deallocate(); fftImag.deallocate(); fftMag.deallocate()
        hannWindow.deallocate()
        mfccFilters.deallocate()
        vDSP_DFT_DestroySetup(fftSetup)
    }

    // MARK: MixerBufferConsumer (audio thread, see contract above)
    nonisolated func handlePCM(_ buffer: AVAudioPCMBuffer, sampleRate: Float) {
        guard let channelData = buffer.floatChannelData?[0] else { return }
        let frameCount = Int(buffer.frameLength)
        // Single SPSC memcpy. No alloc, no syscall, no MainActor hop.
        let h = Int(head.load(ordering: .relaxed))
        let mask = ringCapacity - 1
        for i in 0..<frameCount {
            ring[(h + i) & mask] = channelData[i]
        }
        head.store(Int32(h + frameCount), ordering: .release)
        // Drop-policy: if producer laps consumer, consumer's drain logic
        // detects via tail-vs-head delta and skips ahead. Audio thread
        // does NOT branch on overflow — branch-free is the contract.
    }

    // MARK: Publisher thread — runs at 60 Hz
    private func startPublisherTimer() {
        let timer = DispatchSource.makeTimerSource(queue: publisherQueue)
        timer.schedule(deadline: .now(), repeating: 1.0 / Self.TARGET_HZ)
        timer.setEventHandler { [weak self] in self?.pull() }
        timer.resume()
        self.publisherTimer = timer
    }

    private func pull() {
        // Read 735 frames from ring. If insufficient (startup), bail this tick.
        let h = Int(head.load(ordering: .acquire))
        var t = Int(tail.load(ordering: .relaxed))
        let available = h - t
        guard available >= Self.FRAMES_PER_PULL else { return }
        // If producer is way ahead (>2× pull), skip ahead — drop oldest.
        if available > Self.FRAMES_PER_PULL * 2 {
            t = h - Self.FRAMES_PER_PULL
        }
        let mask = ringCapacity - 1
        // Copy 735 frames out, zero-pad to 1024 for FFT.
        for i in 0..<Self.FRAMES_PER_PULL {
            fftReal[i] = ring[(t + i) & mask]
        }
        for i in Self.FRAMES_PER_PULL..<1024 {
            fftReal[i] = 0
        }
        tail.store(Int32(t + Self.FRAMES_PER_PULL), ordering: .release)

        // Window
        vDSP_vmul(fftReal, 1, hannWindow, 1, fftReal, 1, 1024)
        // FFT
        vDSP_DFT_Execute(fftSetup, fftReal, fftImag /*= 0*/, fftReal, fftImag)
        // Magnitudes (513 bins for 1024-point, but we use 257 covering 0-11k Hz)
        for i in 0..<257 {
            fftMag[i] = sqrt(fftReal[i]*fftReal[i] + fftImag[i]*fftImag[i])
        }

        // Compute scalars
        let rms = computeRMS()                      // scalar
        let centroid = computeCentroid()            // Hz
        let onset = computeFlux()                   // 0..1
        let bands = computeEQBands()                // 4 floats
        let mfcc = computeMFCC()                    // 8 floats

        // Pack into pre-allocated 84-byte buffer (see test_wire_format_golden.py)
        // Endianness: little-endian per golden test.
        packetBuffer.withUnsafeMutableBytes { (ptr: UnsafeMutableRawBufferPointer) in
            var off = 0
            ptr.storeBytes(of: Self.MAGIC.littleEndian, toByteOffset: off, as: UInt32.self); off += 4
            ptr.storeBytes(of: rms.bitPattern.littleEndian, toByteOffset: off, as: UInt32.self); off += 4
            ptr.storeBytes(of: centroid.bitPattern.littleEndian, toByteOffset: off, as: UInt32.self); off += 4
            ptr.storeBytes(of: onset.bitPattern.littleEndian, toByteOffset: off, as: UInt32.self); off += 4
            for b in bands {
                ptr.storeBytes(of: b.bitPattern.littleEndian, toByteOffset: off, as: UInt32.self); off += 4
            }
            for m in mfcc {
                ptr.storeBytes(of: m.bitPattern.littleEndian, toByteOffset: off, as: UInt32.self); off += 4
            }
            // 4 + 12 + 16 + 32 = 64. Remaining 20 bytes: reserved/padding (matches golden).
        }

        // Send (non-blocking, NWConnection internal queue absorbs bursts)
        if connectionReady {
            connection.send(content: packetBuffer, completion: .idempotent)
        }
    }

    // MARK: Heartbeat (utility queue)
    private func startHeartbeatTimer() {
        let timer = DispatchSource.makeTimerSource(queue: heartbeatQueue)
        timer.schedule(deadline: .now(), repeating: 1.0)
        timer.setEventHandler { [weak self] in
            guard let url = self?.heartbeatURL else { return }
            try? "\(Date().timeIntervalSince1970)".write(
                to: url, atomically: true, encoding: .utf8
            )
        }
        timer.resume()
        self.heartbeatTimer = timer
    }

    // MARK: NWConnection lifecycle
    private func startConnection() {
        connection.stateUpdateHandler = { [weak self] state in
            switch state {
            case .ready: self?.connectionReady = true
            case .failed, .cancelled: self?.connectionReady = false
            default: break
            }
        }
        connection.start(queue: publisherQueue)
    }

    // MARK: Tests in MotionMixAppTests/LumfPublisherTests.swift
    // testGoldenBytes: hand-build expected 84 bytes for known PCM input,
    //   assert byte-equality with audio_pub.send_lumf reference vector.
    // testNoAllocationInHotPath: instrument with mach_vm_allocated_size pre/post,
    //   assert delta == 0 across 1000 handlePCM calls with reused PCMBuffer.
    // testRingBufferOverflowDropsOldest: pump 100ms PCM in 5ms bursts, assert
    //   pull() returns most recent 735 frames not oldest 735.
}

enum LumfError: Error { case fftSetupFailed }

AudioEngine.swift refactor (P1 task #1, ONE commit, line 110):

swift
// AudioEngine.swift — line 110 was:
//     nonisolated(unsafe) var onMixerBuffer: ((AVAudioPCMBuffer, Float) -> Void)?
// becomes:
private var mixerConsumers: [MixerBufferConsumer] = []
private let consumerLock = NSLock()  // contention only on register/deregister

func registerMixerConsumer(_ c: MixerBufferConsumer) {
    consumerLock.lock(); defer { consumerLock.unlock() }
    mixerConsumers.append(c)
}

func deregisterMixerConsumer(_ c: MixerBufferConsumer) {
    consumerLock.lock(); defer { consumerLock.unlock() }
    mixerConsumers.removeAll { $0 === c }
}

// Line 458 was:
//     self?.onMixerBuffer?(buffer, sampleRate)
// becomes:
self?.consumerLock.lock()
let consumers = self?.mixerConsumers ?? []
self?.consumerLock.unlock()
for c in consumers { c.handlePCM(buffer, sampleRate: sampleRate) }
// ^ Lock held only for the array snapshot. Iterating the snapshot is lock-free.

MusicAnalysisService becomes a consumer (preserves existing behavior):

swift
// MotionMixApp/Services/MusicAnalysisService.swift (additive)
extension MusicAnalysisService: MixerBufferConsumer {
    nonisolated func handlePCM(_ buffer: AVAudioPCMBuffer, sampleRate: Float) {
        // Existing analysis logic — unchanged.
        self.analyze(buffer: buffer, sampleRate: sampleRate)
    }
}

// AudioEngine.swift init — wherever onMixerBuffer was wired:
audioEngine.registerMixerConsumer(musicAnalysisService)
audioEngine.registerMixerConsumer(lumfPublisher)
audioEngine.registerMixerConsumer(audioCaptureWriter)  // P2

SAN over OSC (S3 schema):

/san/pattern_id   ,i  <int32 pattern hash>
/san/confidence   ,f  <float 0..1>
/san/cluster      ,i  <int32 0=groove, 1=harmonic, 2=minimal>

K11 Wave 8 publisher (`software/demo/unity/lume_pcloud/Assets/Scripts/LumeMotionToAudio.cs`) emits these alongside the existing 7 channels — additive only, no schema break.
iOS `OSCService.swift` decodes them into ParamMapper.Extras additive fields:

swift
struct Extras {
    // ... existing fields ...
    var sanPatternId: Int? = nil
    var sanConfidence: Float? = nil
    var sanCluster: Int? = nil
}

Phase 6 — Evolve

Failure mode A: ring overflow under sustained load. If the publisher
thread starves for >185 ms (e.g. iOS thermal throttle, app backgrounded),
the producer laps the consumer. Mitigation: branch-free skip-ahead in
`pull()` (already in V2 design — `t = h - FRAMES_PER_PULL`). Migration
path if real-world load shows recurrent drops: bump ring capacity from
16 384 to 32 768 (still page-aligned, ~745 ms slack).

Failure mode B: NWConnection internal queue saturates. Network drop or
target unreachable. NWConnection silently buffers up to its limit, then
drops. LUMF is "live state, not transactional" — drops are correct
behavior. `connectionReady` flag prevents `send` calls when the connection
isn't established. Migration: if Tailscale jitter pushes drop rate >5
add a UDP-burst-pacer (send 3 packets at startup as a reachability probe;
fail fast to "K11-fallback heartbeat dropped" path).

Failure mode C: 84-byte schema needs an SAN field after all. If
Wave 9+ requires per-LUMF SAN context (not the sparse OSC channel), the
schema migration path is: add `LUMG` magic ("Lume audio with Guidance"),
148-byte packet, parallel publisher. LumfPublisher refactors to a
LumfgPublisher subclass; receivers add a second magic-byte handler.
Don't extend LUMF in place — the don't-touch list pins it.

Failure mode D: AudioEngine Echelon mode emits at a different sample
rate than 44.1 kHz.
Line 290's `eng.outputNode.outputFormat(forBus:0).sampleRate`
is read at runtime. If Echelon ever returns 48k, FRAMES_PER_PULL becomes
800 not 735. Migration: read `sampleRate` from the first PCM buffer in
`handlePCM`, store, and recompute FRAMES_PER_PULL on the publisher
thread. Already covered by the V2 design's pull() reading the live ring.

Failure mode E: Swift fallback (line 12, no Echelon Rust) emits silence
or non-musical content.
LUMF still publishes (RMS is just very low,
flux is zero). Unity sees "calm" reactivity — correct behavior, not a
bug. No migration needed.

Failure mode F: K11 NSSM `--prefer-device` flag misreads heartbeat
file.
Heartbeat write is non-atomic (truncate + write). If K11
PowerShell reads mid-write, gets a 0-byte file, treats it as "device
offline", resumes synthetic. Migration: write to `lumf-heartbeat.txt.tmp`
then `rename` (POSIX-atomic). Already in design (`atomically: true` on
`String.write`).

---

Q2 — Three directors, shared state, split-brain prevention

Phase 1 — Prime

Question (precise): Given three live director surfaces —
`MotionMixDirector` (macOS Swift Package, MJPEG poller, Phase 5a proof
token model in commit `1ea8da7`), `MotionMixLiveDirector` (iOS/macOS
on-set show-runner overlay), `StageView/Services/{GeminiLiveService,
PhotoshootDirector}` (iPad performer-facing, Gemini Live WebSocket) —
what is the shared state schema each reads/writes, where does it live
physically, what is the conflict-resolution rule when two directors
issue contradictory cues simultaneously, and what is the failure mode
when one director goes offline mid-show?

Why it's load-bearing: Each director was built to optimize a
different surface (operator's MJPEG hub vs on-set show runner vs
performer voice loop). Without a written contract, the next code
change to any one of them re-derives the schema in isolation, the three
diverge harder every week, and a Wave 9 reel-shoot will discover at
3 AM that the iPad's "next look" cue contradicts the Mac's "session
paused" cue.

The master plan §3 P3 task #4 ships `director-protocol.md` as a doc-only
deliverable, citing `1ea8da7 Phase 5a proof token model` as the schema
source. That is necessary but not sufficient. The protocol-doc
specifies event names; it doesn't specify *shared state layout, single
writer per field, conflict resolution, or transport bus.* This forge
fills that gap.

Phase 2 — Explode

Six concrete shared-state architectures:

A1 — Single writer per field (statically partitioned), broadcast bus.
Each field of the shared state has exactly one writer. Mac director
writes `session.id`, `session.phase`, `model.consent`. iOS
LiveDirector writes `cue.current`, `cue.next`, `look.active`. iPad
Stage writes `performer.intent`, `performer.cue_request`. Bus is NUMU
(already wired per master plan §6). Pros: zero contention by
construction, easy to reason about, NUMU already exists. Cons: a
director offline = no writes to its fields = the other directors see
"stale" state with no automatic recovery; no cross-director cue
overrides (e.g. iPad cannot ever pause a session).

A2 — Last-writer-wins on a CRDT (LWW-Element-Set) over multicam-server
WS bus.
Multicam-server already exists (`MotionMix/multicam-server`)
and runs as session authority. Add a state-CRDT topic. Each director
writes its observed truth with a Lamport timestamp; conflicts
auto-resolve to highest timestamp. Pros: tolerates partial connectivity
(directors gossip state when they reconnect), no static-writer rule
required. Cons: CRDTs need careful merge functions for non-trivial
fields (e.g. "scene cue" isn't truly LWW; you want the explicit-newest
not the clock-newest), implementation cost is real (~2-3 days for a
Rust CRDT crate + Swift bindings).

A3 — Multicam-server is the single writer; directors are read-only
clients with command-channel.
Mac/iOS/iPad directors all propose
state changes via WS messages to multicam-server. Server has the only
writer to a single canonical state. Server applies commands in receive
order, broadcasts state. Pros: linear consistency, easy to reason about,
multicam-server already exists as session authority. Cons: server
single point of failure (multicam-server down = no cues; though that's
already true for streaming), command latency is one round-trip.

A4 — UserDefaults / NSUbiquitousKeyValueStore (iCloud KV). Apple
already provides eventually-consistent multi-device key-value store via
CloudKit. Pros: zero infra, works across iOS+macOS, automatic Apple-side
conflict resolution (last-writer-wins). Cons: 1 MB total, 64 KB per key,
sync latency ~5-30 s (catastrophic for live cues), depends on
iCloud account being online + signed in (not always true at venues).

A5 — Local-only, no shared state. Each director runs independently.
Operator manually re-syncs them between takes. Pros: zero engineering.
Cons: this is what's happening today and it's the problem. NO.

A6 — NATS topic on the existing `meshd` infrastructure. The
codebase has a NATS JetStream cluster (visible in repo's `.nats/jetstream`
git status). Use it as the director state bus. JetStream gives us
durable streams + last-message-on-subject semantics. Pros: durable
across restarts, can be inspected with `nats stream view`, replays for
forensics. Cons: NATS is heavy for 3 directors; latency is 1-3 ms
LAN but adds operational surface.

Conflict resolution subquestion:

- C0 — strict per-field writer. Pair with A1.
- C1 — last-writer-wins by Lamport. Pair with A2.
- C2 — server-applies-in-receive-order. Pair with A3.
- C3 — explicit consensus (2-of-3 directors must agree). Heavy, only
worth it for "destructive" cues (`cut_executed`, `model_consent_acknowledged`).
- C4 — priority lattice. Each cue type has a priority writer
(`session.phase` = Mac > iOS > iPad; `cue.current` = iOS > Mac > iPad;
`performer.intent` = iPad > iOS > Mac). When two write same field,
highest-priority wins. Pair with A1 or A6.

Phase 3 — Forge

Pick: A3 + C2, with one C4 escalation rule for proof-token operations.

A3 is correct because:

- Multicam-server already exists and already plays "session authority"
per `AI-PHOTOSHOOT-MOTION-MUSIC-PLAN.md` (stage0 §A.2).
- Linear consistency is what shoots actually need. A live photoshoot
has a deterministic sequence: *cue model → pose hold → capture frame →
acknowledge consent token → advance cue.* A CRDT's "merge" semantics
break this sequence if two directors race. Server-receive-order is
the natural model.
- Each director already speaks WS (multicam-server uses WS;
GeminiLiveService is WS to Google; LiveDirector polls MJPEG which
multicam-server already brokers). Adding a `/director/control` WS
topic is one new endpoint, not a new transport.
- Failure mode is clean: multicam-server down = "cannot shoot",
surfaces immediately. Currently no director knows multicam-server is
down because they don't depend on it for state.

C2 (server-applies-in-receive-order) is the conflict rule because it
matches A3's transport. The one exception, C4 escalation, applies only
to proof-token operations: `model_consent_acknowledged` is the field
that establishes legal usage rights for captured frames per the Phase
5a proof token model (`1ea8da7`). For that ONE field, both Mac AND iOS
must independently confirm before the server commits the state change
(2-of-3 quorum, with iPad performer-facing as optional witness).
Anything else is server-receive-order.

The state schema:

{
  "session": {
    "id": "uuid",                       // Mac writer
    "phase": "idle|setup|live|paused|wrap",  // Mac writer
    "started_at": ISO8601,              // Mac writer
    "operator_device_id": "uuid"        // Mac writer
  },
  "cue": {
    "current": { "id": "...", "kind": "look_change|pose_hold|capture|advance|pause", "payload": {...} },
                                        // iOS LiveDirector writer
    "next": { ... },                    // iOS LiveDirector writer
    "history": [ ... ]                  // server-appended on apply
  },
  "look": {
    "active": "look_1|look_2|look_3",   // iOS LiveDirector writer
    "wardrobe_id": "...",               // iOS LiveDirector writer
    "lighting_preset": "..."            // iOS LiveDirector writer
  },
  "performer": {
    "intent": "ready|hold|cue_request|break_request",  // iPad writer
    "voice_state": "listening|speaking|muted",         // iPad writer
    "comprehension_score": 0..1                        // iPad writer
  },
  "model_consent": {
    "acknowledged": false,              // Mac+iOS quorum write
    "token": "ed25519:...",             // server-issued on quorum
    "scope": ["frame_capture","reel_use"], // Mac writer (proposed)
    "expires_at": ISO8601               // server-derived
  },
  "telemetry": {
    "frames_captured": int,             // server-aggregates from MJPEG nodes
    "last_proof_token_at": ISO8601,     // server-aggregates
    "active_directors": ["mac","ios","ipad"]  // server-aggregates from heartbeats
  }
}

Single canonical doc. Server holds it. Directors subscribe to changes
via WS pub/sub on `/director/state`. Directors propose changes via WS on
`/director/control`. Server validates ownership-by-field, applies in
receive order, broadcasts new state.

Phase 4 — Synthesize

Interactions with adjacent components:

- multicam-server (`Desktop/MotionMix/multicam-server/`) — adds
`src/director_bus.rs` module: WS server on `/director/control` (writes)
and `/director/state` (broadcasts). Persists state to
`[home-path]` on every commit (5 ms write,
rotates last 100 versions for forensics). On startup, loads last
state from disk; if state >24h old, treats as fresh (don't replay
yesterday's session).

- MotionMixDirector (Mac) — adds `DirectorBusClient.swift` to
Sources. On launch, connects to `ws://localhost:9610/director/state`
+ `/director/control`. Reads state into existing
`Models.swift:DirectorState`. Writes `session.*` and proposes
`model_consent.scope` updates. Does NOT write `cue.` or `look.`.

- MotionMixLiveDirector (iOS/macOS overlay) — adds same
`DirectorBusClient.swift` (shared file via Swift Package). Writes
`cue.current`, `cue.next`, `look.active`. Reads `session.phase`
to enable/disable cue advancement (no cues fired during phase=paused).

- StageView (iPad performer-facing) — adds `DirectorBusClient.swift`.
Writes `performer.intent`, `performer.voice_state`. Reads
`cue.current` to display the active cue to the performer (Gemini Live
voice loop reads it aloud). Reads `model_consent.acknowledged` to
display consent UI before phase=live.

- MotionMixApp (capture node) — does NOT participate in director
state directly. Reads `cue.current.kind == "capture"` indirectly via
multicam-server's existing capture-trigger MJPEG endpoint. Existing
proof-token model from `1ea8da7` continues to issue tokens on capture;
director-bus consumes those tokens to update
`telemetry.last_proof_token_at`.

- NUMU bus — directors emit a NUMU event on every state-change apply
(already wired via evo3_hooks per master plan §6). Bus + NUMU is dual:
bus is the source of truth, NUMU is the audit log.

- Pre-commit don't-touch hook (P3 task #5) — protects
`director-protocol.md` schema fields. Edits to field names trigger
the hook; hook requires a `BREAKING_CHANGE` git trailer.

Phase 5 — Create — concrete contract

rust
// multicam-server/src/director_bus.rs (NEW, ~280 lines)
// Server-authoritative shared state for the 3 directors.
// Linear consistency, server-receive-order conflict resolution,
// 2-of-3 quorum for model_consent transitions.

use serde::{Serialize, Deserialize};
use tokio::sync::{RwLock, broadcast};
use std::sync::Arc;

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DirectorState {
    pub session: SessionState,
    pub cue: CueState,
    pub look: LookState,
    pub performer: PerformerState,
    pub model_consent: ConsentState,
    pub telemetry: TelemetryState,
    pub version: u64,  // monotonic, incremented per apply
}

#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
pub enum DirectorRole { Mac, Ios, Ipad }

#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum DirectorCommand {
    SetSessionPhase { phase: SessionPhase, by: DirectorRole },
    SetCue { cue: Cue, by: DirectorRole },
    SetLookActive { id: String, by: DirectorRole },
    SetPerformerIntent { intent: PerformerIntent, by: DirectorRole },
    ProposeConsent { scope: Vec<String>, by: DirectorRole },
    AckConsent { by: DirectorRole },     // 2-of-3 quorum required
    Heartbeat { by: DirectorRole },
}

pub struct DirectorBus {
    state: Arc<RwLock<DirectorState>>,
    tx: broadcast::Sender<DirectorState>,
    consent_acks: Arc<RwLock<std::collections::HashSet<DirectorRole>>>,
}

impl DirectorBus {
    pub async fn apply(&self, cmd: DirectorCommand) -> Result<(), BusError> {
        let mut st = self.state.write().await;
        // Field-ownership validation
        match (&cmd, &cmd.field_owner()) {
            (DirectorCommand::SetCue {..}, DirectorRole::Ios) => {},
            (DirectorCommand::SetSessionPhase {..}, DirectorRole::Mac) => {},
            (DirectorCommand::SetPerformerIntent {..}, DirectorRole::Ipad) => {},
            (DirectorCommand::AckConsent {..}, _) => { /* any role */ }
            (DirectorCommand::Heartbeat {..}, _) => { /* any role */ }
            _ => return Err(BusError::FieldOwnershipViolation),
        }
        // Special: AckConsent requires 2-of-3
        if let DirectorCommand::AckConsent { by } = &cmd {
            let mut acks = self.consent_acks.write().await;
            acks.insert(*by);
            if acks.len() < 2 { return Ok(()); /* not yet quorum */ }
            st.model_consent.acknowledged = true;
            st.model_consent.[sensitive field redacted])?);
            acks.clear();
        } else {
            cmd.apply_to(&mut st);
        }
        st.version += 1;
        // Persist (5 ms, async)
        let snap = st.clone();
        let _ = self.tx.send(snap.clone());
        tokio::spawn(async move { let _ = persist(&snap).await; });
        Ok(())
    }
}
swift
// MotionMixDirector/Sources/MotionMixDirector/DirectorBusClient.swift (NEW, ~200 lines)
// Shared Swift Package source — used by Mac director, iOS LiveDirector, iPad StageView.

import Foundation
import Combine

@MainActor
public final class DirectorBusClient: ObservableObject {
    public enum Role: String, Codable { case mac, ios, ipad }
    public let role: Role
    @Published public private(set) var state: DirectorState? = nil
    @Published public private(set) var connected = false

    private let url: URL
    private var task: URLSessionWebSocketTask?
    private var heartbeatTimer: Timer?

    public init(role: Role, host: String = "localhost", port: Int = 9610) {
        self.role = role
        self.url = URL(string: "ws://\(host):\(port)/director")!
    }

    public func connect() {
        let session = URLSession(configuration: .default)
        task = session.webSocketTask(with: url)
        task?.resume()
        listen()
        startHeartbeat()
        // Send role identification frame
        send(.identify(role: role))
    }

    public func setCue(_ cue: Cue) {
        guard role == .ios else {
            assertionFailure("only iOS LiveDirector may write cue")
            return
        }
        send(.setCue(cue: cue, by: role))
    }

    public func setSessionPhase(_ phase: SessionPhase) {
        guard role == .mac else {
            assertionFailure("only Mac director may write session.phase")
            return
        }
        send(.setSessionPhase(phase: phase, by: role))
    }

    public func proposeConsent(scope: [String]) {
        guard role == .mac else { return }
        send(.proposeConsent(scope: scope, by: role))
    }

    public func ackConsent() { send(.ackConsent(by: role)) }

    private func listen() {
        task?.receive { [weak self] result in
            guard let self else { return }
            switch result {
            case .success(.data(let data)):
                if let s = try? JSONDecoder().decode(DirectorState.self, from: data) {
                    Task { @MainActor in self.state = s }
                }
                self.listen()
            case .success(.string(let s)):
                if let data = s.data(using: .utf8),
                   let st = try? JSONDecoder().decode(DirectorState.self, from: data) {
                    Task { @MainActor in self.state = st }
                }
                self.listen()
            case .failure:
                Task { @MainActor in self.connected = false }
                // Exponential backoff reconnect
                DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
                    self?.connect()
                }
            }
        }
    }

    private func startHeartbeat() {
        heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.send(.heartbeat(by: self?.role ?? .mac))
        }
    }
}

File additions:
- `multicam-server/src/director_bus.rs` (new)
- `multicam-server/src/state_persistence.rs` (new, ~80 lines)
- `MotionMixDirector/Sources/MotionMixDirector/DirectorBusClient.swift` (new)
- `MotionMixDirector/Sources/MotionMixDirector/Models+Director.swift` (Codable structs)
- `MotionMixLiveDirector/MotionMixLiveDirector/Networking/DirectorBusClient.swift` (symlink or duplicate of Mac path; or factor into shared SwiftPackage `MotionMixDirectorCore`)
- `StageView/StageView/Services/DirectorBusClient.swift` (same shared package import)
- `MotionMix/director-protocol.md` (ships in master plan P3 task #4 — this forge supplies the schema)

Phase 6 — Evolve

Failure mode A: multicam-server crashes mid-shoot. All 3 directors
detect via heartbeat timeout (3 s). Each director enters
"degraded-readonly" mode: continues to display last-known state, refuses
to accept new cues. Mac director shows red banner "Director bus down —
manual coordination required". Migration: bus auto-reconnects; on reconnect,
server replays last state from disk; directors resync. Average MTTR ~5 s
on a `tokio` panic-restart loop.

Failure mode B: One director goes offline (e.g., iPad battery dies
mid-session).
Server's `telemetry.active_directors` updates within 3 s
heartbeat. Other directors see iPad missing; iOS LiveDirector's UI shows
"performer cue path offline — voice loop suspended". Cues still fire
(iOS still owns `cue.*`). Migration: spare iPad swaps in, opens StageView,
connects, server adds `ipad` back to active list.

Failure mode C: 2-of-3 consent quorum but two directors are the same
machine logged in twice.
Quorum is by `Role` not by `device_id`, so
Mac+Mac doesn't pass. But two iOS devices on the network both claiming
role=ios is theoretically possible. Migration: server validates one
WS connection per role at a time (most-recent connection wins; older
connection gets explicit `kicked` frame and disconnects).

Failure mode D: Server-receive-order races on critical cue. Two
directors send simultaneous cues. Server applies them in network-arrival
order, which isn't necessarily logical order. Migration: for the only
truly time-critical cue (`cue.kind == "capture"`), the server tags the
cue with its server-receive timestamp, attaches that to the proof token,
and the proof-token system breaks ties via the existing token model.
Per-cue ordering for non-capture cues is fine to lose by ~10 ms.

Failure mode E: Schema migration mid-deploy. We add a new field to
`DirectorState` after Wave 9. Old clients see unknown JSON keys.
Migration: every Codable struct adds `@Decodable` ignore-unknown
behavior; new fields are `Optional`; server-side code uses
`#[serde(default)]` for additive fields. Don't-touch hook (P3) refuses
field RENAMES; renames require a new schema version `v2` namespace
(`/director/v2/state`).

Failure mode F: protocol-doc and code drift. Documentation says one
schema, code implements another. Migration: server emits its current
schema version on connection (`/director/schema`). Directors compare
against their compiled-in schema version on startup. Mismatch → loud UI
warning + refuse to send commands until operator acknowledges.

---

Q3 — Universal sled dominant constraint

Phase 1 — Prime

Question (precise): The universal sled must mount K11 (Ryzen 9 + 780M,
142.6×113×49.5 mm, bottom-intake/top-exhaust, 4× USB-A + HDMI 2.1 + DP 2.1
+ 2× USB4, 75 mm VESA pattern) AND Mac mini M4 (127×127×50 mm, perimeter
bottom-intake/rear exhaust, 1× HDMI + 3× TB4, NO VESA pattern, rubber-foot
adhesion only). The Femto Bolt is USB-C and the UMA-8 mic array is USB-A.
Pod's internal volume is fixed (200×120×130 mm). Which constraint
dominates the CAD geometry: thermal exhaust path, USB topology, mounting
hole pattern, or display-cable type?
Pick one, design around it; the
others become secondary fits.

Why it's load-bearing: The master plan §7.5 lists the constants but
doesn't pick the dominant constraint. Constants alone don't tell the CAD
which face of the sled gets the cable raceway, which side gets the vent
grid, where the bolt-down points are, or whether the K11 sled is the same
STL as the Mac mini sled (with different bolt patterns) or whether the
sled itself is computed by `box="K11"`/`box="macmini"` parameter. Pick the
wrong dominant constraint and either thermals are bad (computer
throttles; reel records "LUME with stutters"), cables don't reach (load-in
delays at venues), or you print twice (double print queue for an already-
tight Wave A-G timeline).

Phase 2 — Explode

Six concrete CAD architectures:

D1 — USB topology dominates. Cable raceway is the load-bearing
geometry. K11 has 4 USB-A on rear-bottom; Mac mini has 3 TB4 on rear-
center. Sled's rear face has both raceways printed in ALL units. The bolt
pattern, vent grid, etc. are secondary. Pros: cables ALWAYS reach
regardless of which box is mounted; one STL serves both. Cons: rear face
is 60
out the top, not the rear, so OK actually).

D2 — Thermal dominates. Vent grid is the load-bearing geometry. K11
needs bottom intake + top exhaust; Mac mini needs perimeter bottom intake
+ rear exhaust. Sled has TWO sets of vent grids overlaid. Internal
ducting steers airflow per-box. Pros: thermals always work. Cons: vents
weaken structural integrity (FDM ASA at WALL=3mm with two 6×4 vent
arrays = ~40
gets fragile.

D3 — Mounting hole pattern dominates. Bolt pattern is the load-bearing
geometry. K11's 75 mm VESA gets 4 M4 holes. Mac mini gets 4 corner
"clamping arms" that grip the rubber-foot footprint (110 mm pitch). Sled
has all 8 mount features (4 VESA + 4 clamps) printed in every unit.
User picks which 4 to use depending on which box. Pros: mechanically
robust, no tooling change between boxes. Cons: the sled has 8 features
where it needs 4, looks busy, and the clamp-arm tolerance for Mac mini
rubber feet is 0.3 mm at design — ASA shrinkage 0.5
2× the tolerance. Mac mini may not clip.

D4 — Display cable type dominates. Pod feedthrough geometry is the
load-bearing constraint. K11 emits HDMI 2.1 (thick cable, ~6.5 mm
diameter); Mac mini emits HDMI from the same port (1× HDMI is K11-equiv);
both also have DP/TB4 alternatives. Pod feedthrough is sized for the
fattest expected cable: HDMI 2.1 with strain relief = 12 mm channel.
Pros: simplest constraint, easy to engineer. Cons: this constraint isn't
actually contested between K11 and Mac mini — both have HDMI options.
It's not the dominant constraint, just the easiest.

D5 — Parameterize via OpenSCAD module function — `pod_compute_sled(box)`
emits a different STL per box.
No dominant constraint; both modes are
peers, generated from the same source. User prints whichever is needed.
Pros: each STL is optimized for its box. Cons: TWO print queues, two
inventories, no swap-test (the master plan's "5 cables, 4 screws, < 5
minutes" swap claim from §7 P5 dies). Defeats the entire point of
"universal."

D6 — Adapter-plate architecture: pod has a generic VESA-100 mounting
deck; K11 mounts directly via its VESA-75 (with adapter plate); Mac mini
mounts via a 3rd-party VESA-100 adapter (e.g., HumanCentric 3M-mounted
VESA bracket).
Sled doesn't change at all. Adapter plates change.
Pros: separation of concerns, K11 sled prints once, never reprints. Cons:
relies on external adapter quality (Mac mini → VESA via adhesive); pod's
internal cable raceway still K11-locked because cable lengths/positions
differ.

Phase 3 — Forge

Pick: D1 (USB topology dominates), refined with elements of D6 for the
mounting holes.

USB topology is the dominant constraint because:

- Cable reach is the failure mode that ships broken to a venue. A
warm K11 is fine for 4 hours of demo; a K11 with a 6-inch USB-C cable
trying to reach the Femto Bolt mounted 200 mm away is dead before it
starts. The raceway is the constraint that determines whether the
hardware works at all in the field.
- Thermal margin on both boxes is generous. K11's 8945HS pulls 35-45 W
TDP under load; Mac mini M4 base pulls 5-12 W typical, 30 W peak.
Both fit comfortably in a 200×120×130 mm pod with passive ducting and
ASA's 100 °C glass transition. Even if the vent grid is suboptimal,
neither box thermally fails — they just throttle 5-10
load (verified on K11's Hawk Point silicon by online benchmarks at
ambient 25 °C). For a 30 s reel-recording session, throttle is invisible.
- Mount-hole pattern is solved by D6's adapter-plate approach
cheap, robust, and cleanly separable from the sled. Print one universal
sled; print/buy one of two thin (3 mm ASA or 1 mm steel) adapter
plates. Adapter is a 5-minute swap.
- Display-cable type isn't actually contested — both boxes ship HDMI;
one feedthrough sized for HDMI 2.1 covers both.

The dominant geometry: rear face of the sled is the cable-management
zone.
It has TWO labeled raceways printed in every unit:

- Raceway K11 (right side): 4 USB-A channels (4× 7×4 mm slots, vertical),
HDMI 2.1 channel (12×8 mm), TB4 channel (10×8 mm).
- Raceway MacMini (left side): 3 TB4 channels (10×8 mm), HDMI channel
(12×8 mm).

Both raceways always present; user uses whichever applies. The unused
raceway is just air gap behind the box, contributing slightly to airflow
(side benefit).

Vent grid (secondary) follows the same dual-purpose principle:
bottom face has K11 grid (24-slot pattern aligned to 142.6×113 footprint),
side faces have perimeter slits for Mac mini perimeter intake.
Top
exhaust (K11) doubles as rear exhaust (Mac mini, since the sled's "rear
face" is open at the top into the pod cavity, then up through the vesa
plate vents). Single vent topology serves both, no double vents.

Mount holes (secondary, D6 hybrid):
- Sled deck has VESA-100 hole pattern (universal industry standard).
- Print TWO adapter plates: `adapter_k11_vesa75.scad` (drops VESA-100
to VESA-75; 4 M4 holes) and `adapter_macmini_clamp.scad` (drops
VESA-100 to 110 mm clamp-arm footprint matching Mac mini's rubber feet
with 3M VHB pads). User picks adapter per box.

Phase 4 — Synthesize

Interactions with adjacent components:

- lume-config.scad — additions per master plan §7.5 hold:
`MAC_MINI_W=127, MAC_MINI_D=127, MAC_MINI_H=50, MAC_MINI_FOOT_PITCH=110`.
ADD: `RACEWAY_K11_W=46` (4 USB slots × 7 mm + 3 mm gaps + 12 mm HDMI +
10 mm TB4), `RACEWAY_MACMINI_W=42` (3 TB4 × 10 mm + 12 mm HDMI), gap
between raceways = 8 mm. Total rear-face raceway zone = 96 mm wide ×
90 mm tall, leaves 24 mm gap on each side of the 200 mm pod for
structural integrity.

- lume-pod.scad — `pod_compute_sled(box=...)` becomes
`pod_compute_sled()` (no parameter; sled is universal). New module
`pod_rear_raceways()` carves both raceway sets into the rear face.
`pod_vents_compute()` becomes `pod_vents_universal()` with K11 bottom
grid + perimeter side slits.

- K11 deployment — unaffected (pod sleeve geometry unchanged,
K11 still fits via VESA-75 adapter plate).

- Mac4 launchd plists (master plan §7 P5) — unaffected; this is
software-side. Hardware swap doesn't touch plists.

- Femto Bolt cable routing — Femto Bolt is USB-C → goes into one of
the K11 USB-C slots OR Mac mini TB4 slots. Both raceways accommodate
USB-C.

- UMA-8 cable — USB-A only. Mac mini needs an A→C dongle or hub.
Add to UNIVERSAL-SLED.md "required cables per box" table.

- VESA mount on bar — bar's VESA mount is already 100 mm pattern
per existing CAD (assumed; verify in Q3 evolve phase). Sled deck
matches → no bar-side change.

- Print queue impact — Wave 8 sled printed K11-locked. Wave 9 sled
reprints universal version (single print, ~5h ASA at 0.2mm layer on
Elegoo Max). Adapter plates are tiny (15 min print each, ASA scrap-bin
thickness). Net print overhead vs status quo: +30 min adapter prints,
-1 reprint when swapping (zero in steady state).

Phase 5 — Create — concrete CAD

scad
// hardware/cad/lume-config.scad (additive section, after master plan §7.5)
// USB topology constants (dominant constraint per Q3 forge)
RACEWAY_USB_A_W = 7;       // mm per USB-A slot
RACEWAY_USB_A_H = 4;
RACEWAY_USB_C_W = 10;      // mm per USB-C / TB4 slot
RACEWAY_USB_C_H = 8;
RACEWAY_HDMI_W = 12;       // mm HDMI 2.1 with strain relief
RACEWAY_HDMI_H = 8;

// Per-box raceway extents
RACEWAY_K11_W = 4*RACEWAY_USB_A_W + 3*3 + RACEWAY_HDMI_W + RACEWAY_USB_C_W;  // 46+12+10 = ~46
RACEWAY_K11_H = 90;
RACEWAY_MACMINI_W = 3*RACEWAY_USB_C_W + 2*3 + RACEWAY_HDMI_W;  // 30+6+12 = 48
RACEWAY_MACMINI_H = 90;

// Sled deck mount: VESA-100 universal
SLED_DECK_VESA = 100;
scad
// hardware/cad/lume-pod.scad (replace pod_compute_sled module + add rear raceways)

module pod_compute_sled() {
    // Universal sled: VESA-100 deck + dual-raceway rear face.
    difference() {
        // Sled body
        cube([POD_W - 2*POD_WALL, POD_D - 2*POD_WALL, SLED_THICKNESS]);
        // VESA-100 hole pattern (universal)
        translate([(POD_W - 2*POD_WALL - SLED_DECK_VESA)/2,
                   (POD_D - 2*POD_WALL - SLED_DECK_VESA)/2, -1]) {
            for (x=[0, SLED_DECK_VESA]) for (y=[0, SLED_DECK_VESA]) {
                translate([x, y, 0]) cylinder(d=4.5, h=SLED_THICKNESS+2, $fn=24);
            }
        }
    }
}

module pod_rear_raceways() {
    // K11 raceway (right side of rear face)
    rear_face_origin = [POD_W - POD_WALL, POD_WALL + 12, POD_WALL + 5];
    translate(rear_face_origin) {
        // 4 USB-A slots
        for (i=[0:3]) {
            translate([0, i*(RACEWAY_USB_A_W + 3), 0])
                cube([POD_WALL+0.1, RACEWAY_USB_A_W, RACEWAY_USB_A_H]);
        }
        // HDMI slot
        translate([0, 4*(RACEWAY_USB_A_W + 3) + 5, 0])
            cube([POD_WALL+0.1, RACEWAY_HDMI_W, RACEWAY_HDMI_H]);
        // USB-C / TB4 slot
        translate([0, 4*(RACEWAY_USB_A_W + 3) + 5 + RACEWAY_HDMI_W + 3, 0])
            cube([POD_WALL+0.1, RACEWAY_USB_C_W, RACEWAY_USB_C_H]);
    }
    // Mac mini raceway (left side of rear face, mirrored)
    rear_face_macmini = [POD_W - POD_WALL, POD_WALL + 12 + RACEWAY_K11_W + 8, POD_WALL + 5];
    translate(rear_face_macmini) {
        // 3 TB4 slots
        for (i=[0:2]) {
            translate([0, i*(RACEWAY_USB_C_W + 3), 0])
                cube([POD_WALL+0.1, RACEWAY_USB_C_W, RACEWAY_USB_C_H]);
        }
        // HDMI slot
        translate([0, 3*(RACEWAY_USB_C_W + 3) + 5, 0])
            cube([POD_WALL+0.1, RACEWAY_HDMI_W, RACEWAY_HDMI_H]);
    }
    // Label engravings ("K11 | MAC MINI") above each raceway
    translate([POD_W - POD_WALL - 0.5, POD_WALL + 12, POD_WALL + 5 + RACEWAY_K11_H + 2])
        rotate([0,90,0]) linear_extrude(0.4) text("K11", size=8, font="Arial:style=Bold");
    translate([POD_W - POD_WALL - 0.5, POD_WALL + 12 + RACEWAY_K11_W + 8, POD_WALL + 5 + RACEWAY_MACMINI_H + 2])
        rotate([0,90,0]) linear_extrude(0.4) text("MM", size=8, font="Arial:style=Bold");
}

module pod_vents_universal() {
    // Floor: K11 24-slot pattern (covers 142.6×113 mm K11 footprint;
    //        also serves as "perimeter intake" for Mac mini's bottom rim
    //        because the Mac mini sits over the same grid).
    pod_floor_grid_k11();   // existing module from current lume-pod.scad

    // Side perimeter slits (Mac mini uses these as additional intake;
    //                      K11 doesn't depend on them but they don't hurt)
    perimeter_slit_w = 4;
    perimeter_slit_h = 30;
    perimeter_slit_count = 6;
    for (side=[0,1]) for (i=[0:perimeter_slit_count-1]) {
        translate([side ? POD_W-POD_WALL : 0,
                   POD_WALL + i*(POD_D-2*POD_WALL)/perimeter_slit_count + 5,
                   POD_WALL])
            cube([POD_WALL+0.1, perimeter_slit_w, perimeter_slit_h]);
    }

    // Top exhaust: K11 8-slot perimeter pattern (existing); also serves
    //              Mac mini rear-exhaust because hot air rises out of
    //              the open back of the sled into the pod cavity, then
    //              out the top.
    pod_top_exhaust_k11();   // existing module
}
scad
// hardware/cad/adapter_k11_vesa75.scad (NEW, ~50 lines)
// Drops VESA-100 sled deck to K11's VESA-75 mount pattern.
include <lume-config.scad>;

module adapter_k11_vesa75() {
    plate_w = 130; plate_h = 130; plate_t = 3;
    difference() {
        // Outer plate
        cube([plate_w, plate_h, plate_t], center=true);
        // VESA-100 holes (down-side, mate with sled deck)
        for (x=[-50,50]) for (y=[-50,50])
            translate([x, y, 0]) cylinder(d=4.5, h=plate_t+1, center=true, $fn=24);
        // VESA-75 holes (up-side, mate with K11)
        for (x=[-37.5,37.5]) for (y=[-37.5,37.5])
            translate([x, y, 0]) cylinder(d=4.5, h=plate_t+1, center=true, $fn=24);
    }
}
adapter_k11_vesa75();
scad
// hardware/cad/adapter_macmini_clamp.scad (NEW, ~80 lines)
// Drops VESA-100 sled deck to Mac mini's 110 mm rubber-foot footprint.
// Uses 3M VHB tape on top side to grip Mac mini bottom; uses VESA-100 on
// down side to mate with sled deck.
include <lume-config.scad>;

module adapter_macmini_clamp() {
    plate_w = 130; plate_h = 130; plate_t = 3;
    foot_recess_d = 14; foot_recess_h = 1.5;  // recess for Mac mini rubber feet
    difference() {
        // Outer plate
        cube([plate_w, plate_h, plate_t], center=true);
        // VESA-100 holes (down-side, mate with sled deck)
        for (x=[-50,50]) for (y=[-50,50])
            translate([x, y, 0]) cylinder(d=4.5, h=plate_t+1, center=true, $fn=24);
        // Mac mini rubber-foot recesses (up-side, 0.5 mm shallower than
        // foot height so VHB pad does the actual gripping)
        for (x=[-55,55]) for (y=[-55,55])
            translate([x, y, plate_t/2 - foot_recess_h])
                cylinder(d=foot_recess_d, h=foot_recess_h+0.1, $fn=48);
    }
    // 3M VHB tape callout: 4 × 25 mm × 25 mm pads centered on each foot recess.
    // Tape applied at install time, not modeled.
}
adapter_macmini_clamp();

Files affected:
- `hardware/cad/lume-config.scad` (additive constants)
- `hardware/cad/lume-pod.scad` (refactor `pod_compute_sled` + add raceways + universal vents)
- `hardware/cad/adapter_k11_vesa75.scad` (new)
- `hardware/cad/adapter_macmini_clamp.scad` (new)
- `exports/pod_compute_sled_universal.stl` (renders new universal sled)
- `exports/adapter_k11_vesa75.stl` (new)
- `exports/adapter_macmini_clamp.stl` (new)
- `hardware/cad/UNIVERSAL-SLED.md` (specifies which adapter to print per box, swap procedure, required cables per box)

Phase 6 — Evolve

Failure mode A: Mac mini M4 actual VESA-100 third-party adapter is
already on the market and Mohamed already owns one.
If so,
`adapter_macmini_clamp.scad` becomes a fallback, not the primary path.
Document in UNIVERSAL-SLED.md: "if HumanCentric/Twelve South VESA adapter
is available, use it; the printed clamp is a backup."

Failure mode B: Bar's VESA pattern is NOT 100 mm. Stage 0 didn't
verify bar VESA pattern explicitly; the master plan assumed VESA-75 from
K11. Migration: caliper-verify bar mount; if VESA-75, the sled needs
VESA-75 deck (not 100); adapter chain becomes K11=direct, Mac mini =
double adapter (75 → 100 via second plate, 100 → clamp). UNIVERSAL-SLED.md
gets a "verify before print" pre-flight check.

Failure mode C: ASA shrinkage on Mac mini foot recesses leaves them too
tight or too loose.
Foot recess design used 14 mm dia vs Mac mini's
rumored ~13.5 mm rubber feet. ASA shrinks 0.5
print. If feet are actually 13.0 mm, gap is too generous and 3M VHB
isn't enough — clamp slides under heavy USB cable tug. Migration:
caliper-verify Mac mini foot diameter on the actual unit; reprint with
adjusted recess. UNIVERSAL-SLED.md ships with a "fit-test before VHB"
step.

Failure mode D: K11's HDMI 2.1 cable strain relief is bigger than 12
mm.
Some HDMI 2.1 cables ship with 14-15 mm strain relief. Migration:
HDMI raceway widens to 16 mm; redesign once on first cable test.
UNIVERSAL-SLED.md lists "tested compatible cables."

Failure mode E: Mac mini gets warm with no perimeter intake clearance.
The pod has the Mac mini sitting on a flat sled; if the perimeter slits
on the sides aren't directly under the Mac mini's perimeter intake
slots, airflow is bad. Migration: under-deck cutout — `pod_compute_sled`
gets a 100 mm-diameter hole in the deck centered under where Mac mini's
intake ring sits, opening to the perimeter slits. Single-edit
`pod_compute_sled` module, doesn't break K11 (K11 sits on solid corners,
intake is bottom not perimeter, hole in middle is fine).

Failure mode F: Cable raceway tab obstructs Femto Bolt USB-C
plug-housing.
Some USB-C cables have plug housings up to 16 mm tall.
Migration: USB-C raceway height widens to 12 mm; one-edit constant.

Failure mode G: Universal print fails QA — only K11 sled fits.
Defer Mac mini side. Sled's K11 raceway works alone; Mac mini path
falls back to "Mac mini in K11 sled with TB4-to-USB-A hub bridging the
gap." Not pretty but ships.

---

Closing — what changes for Stage 4 (divergent-rail)

The forge surfaces three execution-affecting decisions that Stage 4's
EW-governed parallel rails must reflect:

1. P1's audio path is harder than 180 lines. V2's ring-buffer + 60 Hz
publisher thread is closer to 210 lines and needs a no-allocation hot-
path test (`testNoAllocationInHotPath`). Stage 4 rail "P1.audio" should
gate on `pytest -k golden && xcodebuild test -only-testing:LumfPublisherTests`
passing both the byte-equality test AND the allocation-counter test.

2. P3's director-protocol.md is a doc but Q2's forge demands a real
schema.
Stage 4 rail "P3.director" gains a sub-rail "P3.director.schema"
that ships the Codable structs (one new file in MotionMixDirector
Sources) even if the multicam-server Rust impl defers to Wave 9. The
schema-without-impl is the artifact that prevents director drift.

3. P5's universal sled adds 2 small adapter prints. Stage 4 rail
"P5.print" gains "P5.print.adapter_k11" and "P5.print.adapter_macmini"
as 30 min addenda (parallel-printable on Elegoo Max with the main sled
on Plate 1). Total print time impact: 0 (parallel build plate).

End of forge.

Promotion Decision

Attach run IDs, datasets, metrics, and reproduction commands.

Source Anchor

evo-cube-output/lume-creative-engine-2026-05-02/creative-forge-output.md

Detected Structure

Method · Evaluation · Code Anchors · Architecture · is Stage Research