From 7ad8c8f31e1a97cfff09ee7df2fb367d889879b6 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 16 Aug 2026 10:51:26 -0400 Subject: [PATCH] =?UTF-8?q?The=20Fluoddity=20row=20shows=20the=20swarm=20i?= =?UTF-8?q?nstead=20of=20asking=20you=20to=20breed=20it:=20the=20?= =?UTF-8?q?=F0=9F=8E=B2/=F0=9F=A7=AC=20cells=20are=20gone=20(they=20read?= =?UTF-8?q?=20as=20mystery=20buttons=20=E2=80=94=20breeding=20and=20mutati?= =?UTF-8?q?on=20stay=20reachable=20through=20the=20.fluoddity=20distribute?= =?UTF-8?q?d=20notification),=20the=20indigo=20band=20runs=20full=20width,?= =?UTF-8?q?=20and=20while=20the=20backend=20is=20active=20the=20cell=20bec?= =?UTF-8?q?omes=20a=20live=20128=C3=9716=20picture=20of=20the=20most=20rec?= =?UTF-8?q?ent=20note's=20trail=20field=20=E2=80=94=20fluod=5Fvoice=5Ffiel?= =?UTF-8?q?d=5Fptr=20exposes=20the=20C=20field,=20the=20voice=20tracks=20i?= =?UTF-8?q?ts=20latest=20slot=20and=20hands=20the=20UI=20a=20tear-tolerant?= =?UTF-8?q?=20snapshot,=20and=20a=2012=20fps=20timer=20that=20only=20runs?= =?UTF-8?q?=20while=20active=20repaints=20the=20strip=20through=20a=20dark?= =?UTF-8?q?-indigo-to-lavender=20magnitude=20ramp=20with=20the=20label=20s?= =?UTF-8?q?hadowed=20on=20top?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CFluoddity/include/fluoddity_voice.h | 8 + .../Sources/MenuBand/InstrumentMapView.swift | 160 ++++++++++++------ .../Sources/MenuBand/MenuBandController.swift | 3 + .../MenuBand/MenuBandFluoddityVoice.swift | 22 +++ .../CollapsedPianoWaveformView.swift | 20 +-- 5 files changed, 147 insertions(+), 66 deletions(-) diff --git a/slab/menuband/Sources/CFluoddity/include/fluoddity_voice.h b/slab/menuband/Sources/CFluoddity/include/fluoddity_voice.h index 5aa687ce1d..49ee98d7f5 100644 --- a/slab/menuband/Sources/CFluoddity/include/fluoddity_voice.h +++ b/slab/menuband/Sources/CFluoddity/include/fluoddity_voice.h @@ -123,6 +123,14 @@ int fluod_voice_init(FluodVoice *v, uint32_t seed, double freq, // swarm scatter and cohort mutations. int fluod_voice_init_rule(FluodVoice *v, const FluodRule *genome, uint32_t seed, double freq, double sample_rate); +// Raw pointer to the HΓ—WΓ—2 flow field, for UI visualization. The render +// thread writes the field during ticks; a visual reader tolerates torn +// frames, so no synchronization is required (voice storage is preallocated +// and never freed while the node lives). +static inline const float *fluod_voice_field_ptr(const FluodVoice *v) { + return &v->field[0][0][0]; +} + // One output sample. `env` is the host's amplitude envelope (0..1); // `frequency` may glide per sample like gm_voice_render's. Output is // AGC-normalized and soft-clipped; NaN/Inf is trapped internally (the diff --git a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift index c1f701c446..14ba98d7d1 100644 --- a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift +++ b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift @@ -56,8 +56,6 @@ final class InstrumentListView: NSView { case radio(Int) case spotify case fluoddity - case fluodDice - case fluodMutate } private var hoveredTarget: HoverTarget? { didSet { @@ -119,14 +117,20 @@ final class InstrumentListView: NSView { var onSpotifyCommit: (() -> Void)? /// True while the Fluoddity ecosystem backend owns melodic notes β€” - /// fills the FLUODDITY cell the way `sampleBackendActive` fills SAMPLE. - var fluoddityActive: Bool = false { didSet { needsDisplay = true } } + /// fills the FLUODDITY cell the way `sampleBackendActive` fills SAMPLE, + /// and starts the live trail-field strip animating inside the cell. + var fluoddityActive: Bool = false { + didSet { needsDisplay = true; updateFluodAnimTimer() } + } /// Fires when the FLUODDITY cell is clicked (host toggles the backend). var onFluoddityCommit: (() -> Void)? - /// Fires when the 🎲 cell is clicked β€” breed a fresh species (new seed). - var onFluoddityReseed: (() -> Void)? - /// Fires when the 🧬 cell is clicked β€” mutate the current species. - var onFluoddityMutate: (() -> Void)? + /// Supplies the most recent ecosystem's flow field for the live strip: + /// FLUOD_FIELD_H rows Γ— FLUOD_FIELD_W cols Γ— 2 floats, row-major, or + /// nil before any Fluoddity note has sounded. + var fluodFieldSource: (() -> [Float]?)? + /// Repaints the FLUODDITY cell while the backend is active so the + /// swarm's trail field reads as a living picture, not a still. + private var fluodAnimTimer: Timer? private var trackingArea: NSTrackingArea? @@ -183,12 +187,6 @@ final class InstrumentListView: NSView { if fluodMainRect.contains(point) { return "Fluoddity β€” every note is a particle ecosystem scanned as a wavetable; timbre drifts while you hold" } - if fluodDiceRect.contains(point) { - return "Fluoddity Β· new species β€” reroll the 80-parameter rule genome" - } - if fluodMutateRect.contains(point) { - return "Fluoddity Β· mutate β€” evolve the current species a step" - } if isMidiOutHit(point) { return "0 MIDI OUT - route notes to the virtual MIDI port; local synth is muted" } @@ -259,19 +257,76 @@ final class InstrumentListView: NSView { private static var fluodRowY: CGFloat { stationRowY + stationRowH + midiOutGap } - private var fluodSmallW: CGFloat { Self.cellW } - private var fluodMainRect: NSRect { NSRect(x: 0, y: Self.fluodRowY, - width: bounds.width - fluodSmallW * 2, height: Self.fluodRowH) - } - private var fluodDiceRect: NSRect { - NSRect(x: bounds.width - fluodSmallW * 2, y: Self.fluodRowY, - width: fluodSmallW, height: Self.fluodRowH) + width: bounds.width, height: Self.fluodRowH) + } + + /// Render the live trail field into a tiny CGImage (one pixel per field + /// cell, 128Γ—16). Flow magnitude maps through a dark-indigoβ†’white ramp; + /// each frame auto-normalizes to its own peak so quiet ecosystems still + /// show their form instead of a black bar. + private func fluodFieldImage() -> CGImage? { + guard let field = fluodFieldSource?(), field.count >= 128 * 16 * 2 + else { return nil } + let w = 128, h = 16 + var mags = [Float](repeating: 0, count: w * h) + var peak: Float = 1e-6 + for i in 0..<(w * h) { + let fx = field[i * 2], fy = field[i * 2 + 1] + let m = (fx * fx + fy * fy).squareRoot() + mags[i] = m + if m > peak { peak = m } + } + var rgba = [UInt8](repeating: 0, count: w * h * 4) + for i in 0..<(w * h) { + let t = (mags[i] / peak).squareRoot() // lift faint trails + let r: Float, g: Float, b: Float + if t < 0.6 { // dark indigo β†’ indigo + let u = t / 0.6 + r = 24 + u * 70; g = 22 + u * 70; b = 64 + u * 166 + } else { // indigo β†’ lavender-white + let u = (t - 0.6) / 0.4 + r = 94 + u * 146; g = 92 + u * 146; b = 230 + u * 25 + } + rgba[i * 4 + 0] = UInt8(min(255, r)) + rgba[i * 4 + 1] = UInt8(min(255, g)) + rgba[i * 4 + 2] = UInt8(min(255, b)) + rgba[i * 4 + 3] = 255 + } + let cs = CGColorSpaceCreateDeviceRGB() + return rgba.withUnsafeMutableBytes { buf -> CGImage? in + guard let ctx = CGContext( + data: buf.baseAddress, width: w, height: h, + bitsPerComponent: 8, bytesPerRow: w * 4, space: cs, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + else { return nil } + return ctx.makeImage() + } + } + + /// Run a ~12 fps repaint of the FLUODDITY cell only while the backend + /// is active and the view is in a window; idle costs nothing. + private func updateFluodAnimTimer() { + let want = fluoddityActive && window != nil + if want, fluodAnimTimer == nil { + let t = Timer.scheduledTimer(withTimeInterval: 1.0 / 12.0, + repeats: true) { [weak self] _ in + guard let self else { return } + self.setNeedsDisplay(self.fluodMainRect) + } + t.tolerance = 0.03 + RunLoop.current.add(t, forMode: .eventTracking) + fluodAnimTimer = t + } else if !want { + fluodAnimTimer?.invalidate() + fluodAnimTimer = nil + } } - private var fluodMutateRect: NSRect { - NSRect(x: bounds.width - fluodSmallW, y: Self.fluodRowY, - width: fluodSmallW, height: Self.fluodRowH) + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateFluodAnimTimer() } /// "0 MIDI OUT" cell β€” a full-width row at the TOP of the board, above @@ -332,8 +387,6 @@ final class InstrumentListView: NSView { if let station = radioStationIndex(at: point) { return .radio(station) } if isSpotifyHit(point) { return .spotify } if fluodMainRect.contains(point) { return .fluoddity } - if fluodDiceRect.contains(point) { return .fluodDice } - if fluodMutateRect.contains(point) { return .fluodMutate } return nil } @@ -558,35 +611,46 @@ final class InstrumentListView: NSView { y: rr.midY - size.height / 2)) } - // Fluoddity row β€” full-width band under the radio strip. Indigo so - // the ecosystem voice reads distinctly from every other special - // cell; main cell fills solid while the backend is active, and the - // 🎲/🧬 cells at the right end stay clickable in either state. - let fluodTint = NSColor.systemIndigo - let fluodCells: [(NSRect, HoverTarget, String, CGFloat)] = [ - (fluodMainRect, .fluoddity, "FLUODDITY", 10.5), - (fluodDiceRect, .fluodDice, "🎲", 11), - (fluodMutateRect, .fluodMutate, "🧬", 11), - ] - for (rr, target, label, fontSize) in fluodCells { - guard rr.intersects(dirtyRect) else { continue } - let hovered = hoveredTarget == target + // Fluoddity row β€” one full-width indigo band under the radio strip. + // Idle it reads like SAMPLE/SPOTIFY; active, the cell becomes a live + // picture of the most recent note's trail field β€” the same swarm the + // ear is hearing, one pixel per field cell. + if fluodMainRect.intersects(dirtyRect) { + let rr = fluodMainRect + let fluodTint = NSColor.systemIndigo + let hovered = hoveredTarget == .fluoddity let cap = NSBezierPath(roundedRect: rr.insetBy(dx: 1.75, dy: 1.5), xRadius: 3, yRadius: 3) + var fieldDrawn = false if fluoddityActive { fluodTint.withAlphaComponent(hovered ? 0.98 : 0.85).setFill(); cap.fill() + if let img = fluodFieldImage(), + let cg = NSGraphicsContext.current?.cgContext { + NSGraphicsContext.current?.saveGraphicsState() + cap.addClip() + cg.interpolationQuality = .none // crisp field cells + cg.draw(img, in: rr.insetBy(dx: 1.75, dy: 1.5)) + NSGraphicsContext.current?.restoreGraphicsState() + fieldDrawn = true + } fluodTint.setStroke(); cap.lineWidth = 1.4; cap.stroke() } else { fluodTint.withAlphaComponent(hovered ? 0.48 : 0.30).setFill(); cap.fill() fluodTint.withAlphaComponent(hovered ? 1.0 : 0.85).setStroke() cap.lineWidth = hovered ? 1.4 : 1.0; cap.stroke() } - let labelColor: NSColor = fluoddityActive ? .white : .labelColor - let str = NSAttributedString(string: label, attributes: [ - .font: NSFont.systemFont(ofSize: fontSize, weight: .semibold), - .foregroundColor: labelColor, + var attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 10.5, weight: .semibold), + .foregroundColor: fluoddityActive ? NSColor.white : .labelColor, .kern: 0.4, - ]) + ] + if fieldDrawn { // keep the label legible over the living field + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.65) + shadow.shadowBlurRadius = 2 + attrs[.shadow] = shadow + } + let str = NSAttributedString(string: "FLUODDITY", attributes: attrs) let size = str.size() str.draw(at: NSPoint(x: rr.midX - size.width / 2, y: rr.midY - size.height / 2)) @@ -764,14 +828,6 @@ final class InstrumentListView: NSView { onFluoddityCommit?() return } - if fluodDiceRect.contains(pt) { - onFluoddityReseed?() - return - } - if fluodMutateRect.contains(pt) { - onFluoddityMutate?() - return - } dragging = true if let p = program(at: pt) { // Treat the press as a hover-into-this-cell so the preview note diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 6a478f9284..49ad95401f 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -539,6 +539,9 @@ final class MenuBandController { /// distributed-notification hook. Same privacy rationale as above. func setFluodditySeed(_ seed: UInt32) { synth.fluodVoice.setSeed(seed) } func mutateFluoddity(amount: Float) { synth.fluodVoice.mutate(amount: amount) } + /// Live trail field of the most recent Fluoddity note, for the popover's + /// FLUODDITY cell visualization. nil before any note has sounded. + func fluoddityFieldSnapshot() -> [Float]? { synth.fluodVoice.fieldSnapshot() } /// Breed a fresh species: reroll the rule genome from a random seed. /// Affects future note-ons; sounding notes keep their birth genome. func reseedFluoddity() { diff --git a/slab/menuband/Sources/MenuBand/MenuBandFluoddityVoice.swift b/slab/menuband/Sources/MenuBand/MenuBandFluoddityVoice.swift index 952ee711eb..06a8295605 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandFluoddityVoice.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandFluoddityVoice.swift @@ -63,6 +63,11 @@ final class MenuBandFluoddityVoice { /// (same genome β€” the instrument stays the instrument). private var seedCounter: UInt32 = 0x517C0FFE + /// Slot of the most recently triggered note, written by the render + /// thread, read by the UI's live field strip. -1 until the first note. + /// A torn read is harmless (visual only), so no lock. + private var latestSlot: Int32 = -1 + /// Trackpad bend target + render-thread glide, same scheme (and time /// constant) as MenuBandGMSynth. private var pitchScale: Double = 1.0 @@ -142,6 +147,22 @@ final class MenuBandFluoddityVoice { pitchScale = pow(2.0, Double(amount)) } + /// Copy the most recent ecosystem's flow field (FLUOD_FIELD_H rows Γ— + /// FLUOD_FIELD_W cols Γ— 2 components, row-major) for visualization. + /// nil before any note has sounded. The copy may tear against the + /// render thread mid-tick β€” fine for a picture, never used for audio. + func fieldSnapshot() -> [Float]? { + let slot = Int(latestSlot) + guard slot >= 0 && slot < maxVoices else { return nil } + let count = Int(FLUOD_FIELD_W) * Int(FLUOD_FIELD_H) * 2 + var out = [Float](repeating: 0, count: count) + let src = fluod_voice_field_ptr(cores + slot)! + out.withUnsafeMutableBufferPointer { dst in + dst.baseAddress!.update(from: src, count: count) + } + return out + } + func noteOn(_ midi: UInt8, velocity: UInt8, channel: UInt8) { lock.lock() seedCounter = seedCounter &* 1_664_525 &+ 1_013_904_223 @@ -208,6 +229,7 @@ final class MenuBandFluoddityVoice { voices[slot].env = 0 voices[slot].releasing = false voices[slot].active = true + latestSlot = Int32(slot) case let .noteOff(midi, channel): for i in 0..