From 0ab0f1863c6f6de6a3c835c8e184dcba3fe91d75 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 16 Aug 2026 11:06:59 -0400 Subject: [PATCH] =?UTF-8?q?Fluoddity=20TV=20v2=20after=20@jeffrey's=20firs?= =?UTF-8?q?t=20viewing=20(mushy=20picture,=20shy=20volume):=20the=20screen?= =?UTF-8?q?=20splits=20into=20an=20honest=20ribbon=20=E2=80=94=20the=2016-?= =?UTF-8?q?row=20trail=20field=20at=20readable=20aspect=20with=20the=20par?= =?UTF-8?q?ticles=20on=20it,=20low=20interpolation=20so=20it=20reads=20as?= =?UTF-8?q?=20smoke=20instead=20of=20bar-mush=20=E2=80=94=20over=20a=20liv?= =?UTF-8?q?e=20oscilloscope=20of=20the=20actual=20scan-table=20wavetable;?= =?UTF-8?q?=20the=20AGC=20target=20rises=200.35=E2=86=920.50=20and=20maste?= =?UTF-8?q?r=200.55=E2=86=920.7=20(~+6=20dB,=20tanh=20still=20catches=20pe?= =?UTF-8?q?aks)=20so=20the=20ecosystem=20sits=20level=20with=20the=20GM=20?= =?UTF-8?q?voices;=20visualLiveliness=20keeps=20the=20latest=20ecosystem?= =?UTF-8?q?=20ticking=20silently=20on=20the=20render=20thread=20while=20th?= =?UTF-8?q?e=20TV=20is=20watching=20so=20the=20picture=20breathes=20betwee?= =?UTF-8?q?n=20notes=20instead=20of=20freezing=20on=20release;=20and=20a?= =?UTF-8?q?=20conducted=20.play=20no=20longer=20yanks=20an=20active=20Fluo?= =?UTF-8?q?ddity=20backend=20back=20to=20GM=20unless=20the=20post=20names?= =?UTF-8?q?=20a=20program=20explicitly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/CFluoddity/fluoddity_voice.c | 2 +- .../CFluoddity/include/fluoddity_voice.h | 6 +++ .../Sources/MenuBand/AppDelegate.swift | 9 +++- .../Sources/MenuBand/FluoddityTV.swift | 53 ++++++++++++++++--- .../Sources/MenuBand/MenuBandController.swift | 2 + .../MenuBand/MenuBandFluoddityVoice.swift | 41 +++++++++++++- 6 files changed, 102 insertions(+), 11 deletions(-) diff --git a/slab/menuband/Sources/CFluoddity/fluoddity_voice.c b/slab/menuband/Sources/CFluoddity/fluoddity_voice.c index bc746e83b4..ec6feebdef 100644 --- a/slab/menuband/Sources/CFluoddity/fluoddity_voice.c +++ b/slab/menuband/Sources/CFluoddity/fluoddity_voice.c @@ -25,7 +25,7 @@ #define FLUOD_SENSE_SCALE 1.0f // field → black-box input gain #define FLUOD_VISUAL_FPS 60.0f // Fluoddity's frame rate, for slider compensation #define FLUOD_WARMUP_TICKS 32 // sim ticks pre-run at note-on -#define FLUOD_OUT_TARGET 0.35f // normalized output RMS target +#define FLUOD_OUT_TARGET 0.50f // normalized output RMS target #define FLUOD_AGC_FLOOR 0.02f // table RMS below this fades toward silence // ── Hashing (PCG, uint-domain cousin of fourier4_4.glsl's) ── diff --git a/slab/menuband/Sources/CFluoddity/include/fluoddity_voice.h b/slab/menuband/Sources/CFluoddity/include/fluoddity_voice.h index 5ff217e8d9..d39b980fcb 100644 --- a/slab/menuband/Sources/CFluoddity/include/fluoddity_voice.h +++ b/slab/menuband/Sources/CFluoddity/include/fluoddity_voice.h @@ -137,6 +137,12 @@ static inline const FluodParticle *fluod_voice_particles_ptr(const FluodVoice *v return v->p; } +// Same contract, for the sound: the current column-summed scan table — the +// FLUOD_FIELD_W-sample wavetable the ear is actually hearing. +static inline const float *fluod_voice_table_ptr(const FluodVoice *v) { + return v->tab_cur; +} + // 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/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index ac9705ded7..7e3efefb6f 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -4464,7 +4464,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { DispatchQueue.main.async { [weak self] in guard let self = self else { return } let gen = self.playGeneration // stop bumps this to cancel onsets - self.menuBand.setMelodicProgram(program) + // A conducted melody should play THROUGH an active Fluoddity + // ecosystem, not silently yank it back to GM — only switch + // programs when the post names one explicitly (or nothing + // special is active). + if info["program"] != nil + || self.menuBand.instrumentBackend != .fluoddity { + self.menuBand.setMelodicProgram(program) + } // Surface the instrument change on the menubar chip immediately — // a conducted part switching patch should read like a user picking // an instrument, not change silently under the hood. diff --git a/slab/menuband/Sources/MenuBand/FluoddityTV.swift b/slab/menuband/Sources/MenuBand/FluoddityTV.swift index 2b103e5094..7cc21ff33f 100644 --- a/slab/menuband/Sources/MenuBand/FluoddityTV.swift +++ b/slab/menuband/Sources/MenuBand/FluoddityTV.swift @@ -31,6 +31,7 @@ final class FluoddityTV { guard let panel, let screen else { return } screen.fieldSource = { [weak menuBand] in menuBand?.fluoddityFieldSnapshot() } screen.particleSource = { [weak menuBand] in menuBand?.fluoddityParticleSnapshot() } + screen.tableSource = { [weak menuBand] in menuBand?.fluoddityTableSnapshot() } if !panel.setFrameUsingName("FluoddityTV") { // First run: tuck under the menu bar at the screen's top right. if let vis = NSScreen.main?.visibleFrame { @@ -39,11 +40,13 @@ final class FluoddityTV { } } panel.orderFront(nil) + menuBand.setFluoddityVisualLiveliness(true) startTimer() } func hide() { timer?.invalidate(); timer = nil + menuBand?.setFluoddityVisualLiveliness(false) panel?.saveFrame(usingName: "FluoddityTV") panel?.orderOut(nil) } @@ -141,6 +144,8 @@ final class FluoddityTV { final class FluodTVScreenView: NSView { var fieldSource: (() -> [Float]?)? var particleSource: (() -> [Float]?)? + /// The scan table — the wavetable the ear is hearing right now. + var tableSource: (() -> [Float]?)? private var flashUntil: TimeInterval = 0 /// Brief white blink acknowledging a breeding action (the audible @@ -202,26 +207,60 @@ final class FluodTVScreenView: NSView { else { return nil } return ctx.makeImage() } + // Two zones: the ecosystem strip on top at an honest-ish aspect + // (16 transverse rows should read as a ribbon, not be smeared to + // fill the screen), and below it the oscilloscope of the scan + // table — the very wavetable those trails are being heard as. + let stripH = (bounds.height * 0.45).rounded() + let strip = NSRect(x: 0, y: bounds.height - stripH, + width: bounds.width, height: stripH) + let scope = NSRect(x: 0, y: 0, width: bounds.width, + height: bounds.height - stripH - 1) + if let image, let cg = NSGraphicsContext.current?.cgContext { cg.saveGState() - cg.interpolationQuality = .none - cg.draw(image, in: bounds) + cg.interpolationQuality = .low // organic smoke, not bar-mush + cg.draw(image, in: strip) cg.restoreGState() } // The swarm itself: px is the scan (x) axis, py transverse. if let particles = particleSource?() { - NSColor.white.withAlphaComponent(0.85).setFill() + NSColor.white.withAlphaComponent(0.9).setFill() var i = 0 while i + 1 < particles.count { - let x = CGFloat(particles[i]) * bounds.width - let y = CGFloat(particles[i + 1]) * bounds.height - NSBezierPath(ovalIn: NSRect(x: x - 1.5, y: y - 1.5, - width: 3, height: 3)).fill() + let x = strip.minX + CGFloat(particles[i]) * strip.width + let y = strip.minY + CGFloat(particles[i + 1]) * strip.height + NSBezierPath(ovalIn: NSRect(x: x - 1.25, y: y - 1.25, + width: 2.5, height: 2.5)).fill() i += 2 } } + // Oscilloscope: one cycle of the instrument, normalized per frame. + if let table = tableSource?(), table.count >= w, scope.height > 8 { + var peakT: Float = 1e-6 + for v in table where abs(v) > peakT { peakT = abs(v) } + NSColor.systemIndigo.withAlphaComponent(0.25).setStroke() + let mid = NSBezierPath() + mid.move(to: NSPoint(x: scope.minX, y: scope.midY)) + mid.line(to: NSPoint(x: scope.maxX, y: scope.midY)) + mid.lineWidth = 1 + mid.stroke() + let trace = NSBezierPath() + let amp = scope.height * 0.44 + for i in 0.. [Float]? { synth.fluodVoice.fieldSnapshot() } func fluoddityParticleSnapshot() -> [Float]? { synth.fluodVoice.particleSnapshot() } + func fluoddityTableSnapshot() -> [Float]? { synth.fluodVoice.tableSnapshot() } + func setFluoddityVisualLiveliness(_ on: Bool) { synth.fluodVoice.visualLiveliness = on } /// 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 0ce117d925..3f3447c80e 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandFluoddityVoice.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandFluoddityVoice.swift @@ -45,8 +45,9 @@ final class MenuBandFluoddityVoice { private var attached = false /// The C core AGC-levels each ecosystem near FLUOD_OUT_TARGET, so this - /// is plain polyphony headroom like the GM node's. - private let masterGain: Double = 0.55 + /// is plain polyphony headroom like the GM node's. Ecosystem RMS sits + /// well under a GM voice's peaks, hence the hotter master. + private let masterGain: Double = 0.7 /// Fixed polyphony. Each voice simulates a whole 40-particle field, so /// the cap is lower than the GM node's 24. @@ -68,6 +69,13 @@ final class MenuBandFluoddityVoice { /// A torn read is harmless (visual only), so no lock. private var latestSlot: Int32 = -1 + /// While the Fluoddity TV is on screen, the render thread keeps the + /// latest ecosystem's simulation ticking silently between notes so the + /// picture stays alive instead of freezing on release. Costs about one + /// voice of sim work, only while someone is watching. Control-thread + /// write, render-thread read; a torn read is harmless. + var visualLiveliness = false + /// Trackpad bend target + render-thread glide, same scheme (and time /// constant) as MenuBandGMSynth. private var pitchScale: Double = 1.0 @@ -179,6 +187,21 @@ final class MenuBandFluoddityVoice { return out } + /// Copy the most recent ecosystem's current scan table — the actual + /// wavetable being heard, FLUOD_FIELD_W samples. Same tear-tolerant + /// visual-only contract as `fieldSnapshot()`. + func tableSnapshot() -> [Float]? { + let slot = Int(latestSlot) + guard slot >= 0 && slot < maxVoices else { return nil } + let n = Int(FLUOD_FIELD_W) + var out = [Float](repeating: 0, count: n) + let src = fluod_voice_table_ptr(cores + slot)! + out.withUnsafeMutableBufferPointer { dst in + dst.baseAddress!.update(from: src, count: n) + } + return out + } + func noteOn(_ midi: UInt8, velocity: UInt8, channel: UInt8) { lock.lock() seedCounter = seedCounter &* 1_664_525 &+ 1_013_904_223 @@ -299,5 +322,19 @@ final class MenuBandFluoddityVoice { var p = pitchStart for _ in 0..= 0 && slot < maxVoices && !voices[slot].active { + let ptr = cores + slot + let f = voices[slot].freq + for _ in 0..