diff --git a/slab/menuband/Sources/CFluoddity/fluoddity_voice.c b/slab/menuband/Sources/CFluoddity/fluoddity_voice.c index bc746e83b..ec6feebde 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 5ff217e8d..d39b980fc 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 ac9705ded..7e3efefb6 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 2b103e509..7cc21ff33 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 0ce117d92..3f3447c80 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..