From 8c19de5ae87473af048944619592131d1eade60a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 14 Apr 2026 16:17:33 -0700 Subject: [PATCH] feat: add fart synthesizer & fartflower piece MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add procedural fart sound synthesis with physical modeling parameters: - sound.fart() API following bubble.mjs pattern - Fart class with pressure, pitch, rasp parameters - enableSustain/disableSustain for sustained sounds - fartflower.mjs piece: click button → flower blooms + fart sound Reusable across all pieces like bubble. Co-Authored-By: Claude Haiku 4.5 --- .../aesthetic.computer/disks/fartflower.mjs | 98 ++++++ system/public/aesthetic.computer/lib/disk.mjs | 36 +++ .../aesthetic.computer/lib/sound/fart.mjs | 295 ++++++++++++++++++ .../public/aesthetic.computer/lib/speaker.mjs | 68 +++- 4 files changed, 492 insertions(+), 5 deletions(-) create mode 100644 system/public/aesthetic.computer/disks/fartflower.mjs create mode 100644 system/public/aesthetic.computer/lib/sound/fart.mjs diff --git a/system/public/aesthetic.computer/disks/fartflower.mjs b/system/public/aesthetic.computer/disks/fartflower.mjs new file mode 100644 index 000000000..32d765a2b --- /dev/null +++ b/system/public/aesthetic.computer/disks/fartflower.mjs @@ -0,0 +1,98 @@ +// 🌸 Fartflower 2025.04.14 +// One button. Click it. Flower blooms. Fart sound plays. + +let bloomProgress = 0; +let activeFart = null; +let fartButton; + +// 🥾 Boot +function boot({ ui: { Button }, screen }) { + const centerX = screen.width / 2; + const centerY = screen.height / 2; + const buttonSize = 60; + + // Single centered button + fartButton = new Button("💨", { + box: [ + centerX - buttonSize / 2, + centerY - buttonSize / 2, + buttonSize, + buttonSize, + ], + fontSize: 32, + }); +} + +// 🧮 Sim +function sim({ num }) { + // Animate the bloom + if (activeFart && bloomProgress < 1) { + bloomProgress = num.clamp(bloomProgress + 0.08, 0, 1); + } else if (!activeFart && bloomProgress > 0) { + bloomProgress = num.clamp(bloomProgress - 0.06, 0, 1); + } +} + +// 🎨 Paint +function paint({ wipe, ink, circle, line, screen, num }) { + wipe(240); + + const centerX = screen.width / 2; + const centerY = screen.height / 2; + const petalCount = 6; + const maxPetalRadius = 80; + const petalRadius = num.lerp(10, maxPetalRadius, bloomProgress); + + // Draw petals in a circle + for (let i = 0; i < petalCount; i++) { + const angle = (i / petalCount) * Math.PI * 2; + const x = centerX + Math.cos(angle) * (50 + petalRadius * 0.5); + const y = centerY + Math.sin(angle) * (50 + petalRadius * 0.5); + + // Petals fade in/out with bloom + const petalAlpha = Math.floor(bloomProgress * 200); + ink(255, 100, 150, petalAlpha).circle(x, y, petalRadius); + } + + // Center stem + ink(100, 150, 80).line(centerX, centerY, centerX, centerY + 100); + + // Center of flower (grows with bloom) + const centerRadius = num.lerp(5, 20, bloomProgress); + ink(255, 200, 0).circle(centerX, centerY, centerRadius); + + // Draw button + fartButton.paint({ ink }); +} + +// ✒ Act +function act({ event: e, sound: { fart } }) { + if (fartButton.trigger(e)) { + // Kill previous fart if still active + if (activeFart) { + activeFart.kill(0.1); + } + + // Trigger new fart sound with physical modeling parameters + activeFart = fart({ + pressure: 0.8, + pitch: 60 + Math.random() * 30, + rasp: 0.6, + volume: 0.7, + pan: 0, + }); + + // Enable sustain for the bloom animation duration + activeFart.enableSustain(); + + // Disable sustain after bloom completes + setTimeout(() => { + if (activeFart) { + activeFart.disableSustain(); + activeFart = null; + } + }, 500); + } +} + +export { boot, sim, paint, act }; diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index d0ca22345..3e8dc92a7 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -7321,6 +7321,7 @@ sound = { bpm: undefined, sounds: [], bubbles: [], + farts: [], kills: [], }; @@ -11458,6 +11459,7 @@ async function makeFrame({ data: { type, content } }) { // soundClear?.(); sound.sounds.length = 0; // Empty the sound command buffer. sound.bubbles.length = 0; + sound.farts.length = 0; sound.kills.length = 0; return; } @@ -12437,6 +12439,39 @@ async function makeFrame({ data: { type, content } }) { }; }; + $sound.fart = function ({ pressure = 1, pitch = 60, rasp = 0.5, volume = 1, pan = 0 } = {}) { + const id = soundId; + sound.farts = sound.farts || []; + sound.farts.push({ id, pressure, pitch, rasp, volume, pan }); + soundId += 1n; + + return { + startedAt: soundTime, + id, + kill: function (fade) { + sound.kills.push({ id, fade }); + }, + update: function (properties) { + send({ + type: "fart:update", + content: { id, properties }, + }); + }, + enableSustain: function () { + send({ + type: "fart:update", + content: { id, properties: { sustain: true } }, + }); + }, + disableSustain: function () { + send({ + type: "fart:update", + content: { id, properties: { sustain: false } }, + }); + }, + }; + }; + $sound.kill = function (id, fade) { sound.kills.push({ id, fade }); }; @@ -16077,6 +16112,7 @@ async function makeFrame({ data: { type, content } }) { sound.sounds.length = 0; // Empty the sound command buffer. sound.bubbles.length = 0; + sound.farts.length = 0; sound.kills.length = 0; twoDCommands.length = 0; // Empty the 2D GPU command buffer. diff --git a/system/public/aesthetic.computer/lib/sound/fart.mjs b/system/public/aesthetic.computer/lib/sound/fart.mjs new file mode 100644 index 000000000..7554a5392 --- /dev/null +++ b/system/public/aesthetic.computer/lib/sound/fart.mjs @@ -0,0 +1,295 @@ +// 💨 Fart 2025.04.14 +// Physical modeling of a fart sound using pressure, pitch, and rasp parameters. +// Based on procedural audio synthesis principles from CompuFart. + +export default class Fart { + // Generic for all instruments. + playing = true; + fading = false; // If we are fading and then stopping playback. + fadeProgress; + fadeDuration; + + #volume = 1; // 0 to 1 + #pan = 0; // -1 to 1 + + #pressure; // 0 to 1 - how hard you squeeze + #pitch; // Hz - fundamental frequency + #rasp; // 0 to 1 - noise component (0 = pure tone, 1 = mostly noise) + + #amp; + #decay; + #gain; + #phase; + #lastOut; + #timestep; + + #out = 0; + #maxOut = 1; + + #progress = 0; + + #QUIET = 0.000001; + + // Noise generation state + #noiseState = 0; + + // Parameter update properties for smooth transitions + #futurePressure; + #futurePitch; + #futureRasp; + #futureVolume; + #futurePan; + + #pressureUpdatesTotal; + #pressureUpdatesLeft; + #pressureUpdateSlice; + + #pitchUpdatesTotal; + #pitchUpdatesLeft; + #pitchUpdateSlice; + + #raspUpdatesTotal; + #raspUpdatesLeft; + #raspUpdateSlice; + + #volumeUpdatesTotal; + #volumeUpdatesLeft; + #volumeUpdateSlice; + + #panUpdatesTotal; + #panUpdatesLeft; + #panUpdateSlice; + + #sustain = false; + + constructor(pressure, pitch, rasp, volume, pan, id) { + this.id = id; // Store the ID for tracking + this.start(pressure, pitch, rasp, volume, pan); + } + + start( + pressure = this.#pressure, + pitch = this.#pitch, + rasp = this.#rasp, + volume = this.#volume, + pan = this.#pan + ) { + this.#pan = pan; + this.#volume = volume; + this.#pressure = Math.max(0.01, Math.min(1, pressure)); // Clamp 0.01-1 + this.#pitch = Math.max(20, Math.min(8000, pitch)); // Clamp pitch to audible range + this.#rasp = Math.max(0, Math.min(1, rasp)); // Clamp 0-1 + + // Initialize future values for parameter updates + this.#futurePressure = this.#pressure; + this.#futurePitch = this.#pitch; + this.#futureRasp = this.#rasp; + this.#futureVolume = this.#volume; + this.#futurePan = this.#pan; + + this.#timestep = 1 / sampleRate; + this.#lastOut = this.#out; + + // Amplitude envelope: pressure controls initial amplitude + this.#amp = 0.3 * this.#pressure; + + // Decay rate: lower pitch = longer sustain + this.#decay = 0.8 + (this.#pitch / 8000) * 0.2; // Faster decay for higher pitches + this.#gain = Math.exp(-this.#decay * this.#timestep); + + this.#phase = 0; + } + + update({ pressure, pitch, rasp, volume, pan, sustain, duration = 0.1 }) { + // Sustain updates (immediate change, no interpolation needed) + if (typeof sustain === "boolean") { + this.#sustain = sustain; + console.log(`💨 UPDATE: Sustain set to ${sustain} for fart ${this.id || 'unknown'}`); + } + + // Pressure updates (affects amplitude and energy) + if (typeof pressure === "number" && pressure >= 0) { + this.#futurePressure = Math.max(0.01, Math.min(1, pressure)); + this.#pressureUpdatesTotal = duration * sampleRate; + this.#pressureUpdatesLeft = this.#pressureUpdatesTotal; + this.#pressureUpdateSlice = + (this.#futurePressure - this.#pressure) / this.#pressureUpdatesTotal; + } + + // Pitch updates (affects frequency) + if (typeof pitch === "number" && pitch > 0) { + this.#futurePitch = Math.max(20, Math.min(8000, pitch)); + this.#pitchUpdatesTotal = duration * sampleRate; + this.#pitchUpdatesLeft = this.#pitchUpdatesTotal; + this.#pitchUpdateSlice = + (this.#futurePitch - this.#pitch) / this.#pitchUpdatesTotal; + } + + // Rasp updates (affects noise/tone balance) + if (typeof rasp === "number" && rasp >= 0) { + this.#futureRasp = Math.max(0, Math.min(1, rasp)); + this.#raspUpdatesTotal = duration * sampleRate; + this.#raspUpdatesLeft = this.#raspUpdatesTotal; + this.#raspUpdateSlice = + (this.#futureRasp - this.#rasp) / this.#raspUpdatesTotal; + } + + // Volume updates + if (typeof volume === "number") { + this.#futureVolume = volume; + this.#volumeUpdatesTotal = duration * sampleRate; + this.#volumeUpdatesLeft = this.#volumeUpdatesTotal; + this.#volumeUpdateSlice = + (this.#futureVolume - this.#volume) / this.#volumeUpdatesTotal; + } + + // Pan updates + if (typeof pan === "number") { + this.#futurePan = pan; + this.#panUpdatesTotal = duration * sampleRate; + this.#panUpdatesLeft = this.#panUpdatesTotal; + this.#panUpdateSlice = + (this.#futurePan - this.#pan) / this.#panUpdatesTotal; + } + } + + // Sustain control methods + setSustain(sustain) { + this.#sustain = sustain; + console.log(`💨 setSustain(${sustain}) for fart ${this.id || 'unknown'}`); + } + + enableSustain() { + this.#sustain = true; + console.log(`💨 enableSustain() for fart ${this.id || 'unknown'}`); + } + + disableSustain() { + this.#sustain = false; + console.log(`💨 disableSustain() for fart ${this.id || 'unknown'}`); + } + + // Linear congruential generator for pseudo-random noise + _noise() { + this.#noiseState = (this.#noiseState * 1103515245 + 12345) & 0x7fffffff; + return (this.#noiseState / 0x7fffffff) * 2 - 1; // Range: -1 to 1 + } + + next() { + // Interpolated parameter updates + + // Pressure updates (affects amplitude) + if (this.#pressureUpdatesLeft > 0) { + this.#pressure += this.#pressureUpdateSlice; + this.#pressureUpdatesLeft -= 1; + } + + // Pitch updates (affects frequency for next cycle) + if (this.#pitchUpdatesLeft > 0) { + this.#pitch += this.#pitchUpdateSlice; + this.#pitchUpdatesLeft -= 1; + } + + // Rasp updates (affects noise/tone balance) + if (this.#raspUpdatesLeft > 0) { + this.#rasp += this.#raspUpdateSlice; + this.#raspUpdatesLeft -= 1; + } + + // Volume updates + if (this.#volumeUpdatesLeft > 0) { + this.#volume += this.#volumeUpdateSlice; + this.#volumeUpdatesLeft -= 1; + } + + // Pan updates + if (this.#panUpdatesLeft > 0) { + this.#pan += this.#panUpdateSlice; + this.#panUpdatesLeft -= 1; + } + + // Stop if amplitude is quiet and not sustaining + if (!this.#sustain && this.#amp < this.#QUIET) { + this.playing = false; + return 0; + } + + // Calculate phase step from current pitch + const phaseStep = (this.#pitch / sampleRate) * Math.PI * 2; + + // Generate tone component (sine wave) + const tone = Math.sin(this.#phase) * this.#pressure; + + // Generate noise component + const noise = this._noise() * this.#rasp; + + // Mix tone and noise + const mixed = tone * (1 - this.#rasp) + noise; + + // Apply amplitude envelope with smoothing + this.#out = this.#lastOut * 0.3 + mixed * this.#amp * 0.7; + this.#lastOut = this.#out; + + // Advance phase + this.#phase += phaseStep; + if (this.#phase > Math.PI * 2) { + this.#phase -= Math.PI * 2; + } + + // Only apply amplitude decay if not in sustain mode + if (!this.#sustain) { + this.#amp *= this.#gain; + } + + this.#progress += 1; + + // Normalization to a max of 1 / -1 + let out = this.#out * this.#volume; + if (Math.abs(out) > this.#maxOut) this.#maxOut = Math.abs(out); + + out = out / this.#maxOut; + + // Apply fading if necessary + if (this.fading) { + if (this.fadeProgress < this.fadeDuration) { + this.fadeProgress += 1; + // Apply the fade envelope to the output. + out *= 1 - this.fadeProgress / this.fadeDuration; + } else { + this.fading = false; + this.playing = false; + return 0; + } + } + + return out; + } + + // Stereo panning + pan(channel, frame) { + if (channel === 0) { + // Left Channel + if (this.#pan > 0) { + frame *= 1 - this.#pan; + } + } else if (channel === 1) { + // Right Channel + if (this.#pan < 0) { + frame *= 1 - Math.abs(this.#pan); + } + } + return frame; + } + + // Use a 25ms fade by default. + kill(fade = 0.025) { + if (!fade) { + this.playing = false; + } else { + // Fade over 'fade' seconds, before stopping playback. + this.fading = true; + this.fadeProgress = 0; + this.fadeDuration = fade * sampleRate; // Convert seconds to samples. + } + } +} diff --git a/system/public/aesthetic.computer/lib/speaker.mjs b/system/public/aesthetic.computer/lib/speaker.mjs index a434c4d4f..82fabfbaa 100644 --- a/system/public/aesthetic.computer/lib/speaker.mjs +++ b/system/public/aesthetic.computer/lib/speaker.mjs @@ -6,6 +6,7 @@ import { checkPackMode } from "./pack-mode.mjs"; // Cache bust: Feb 4, 2026 - fixed _fillCustomBuffer for AudioWorklet compatibility import Synth from "./sound/synth.mjs?v=20260204"; import Bubble from "./sound/bubble.mjs"; +import Fart from "./sound/fart.mjs"; import { lerp, within, clamp } from "./num.mjs"; const { abs, round, floor } = Math; @@ -232,16 +233,37 @@ class SpeakerProcessor extends AudioWorkletProcessor { bubbleData.pan, bubbleData.id, ); - + // Track bubble by ID if provided if (bubbleData.id !== undefined) { this.#running[bubbleData.id] = bubble; } - + this.#queue.push(bubble); }); } - + + // Process farts array + if (soundData.farts) { + soundData.farts.forEach(fartData => { + const fart = new Fart( + fartData.pressure, + fartData.pitch, + fartData.rasp, + fartData.volume, + fartData.pan, + fartData.id, + ); + + // Track fart by ID if provided + if (fartData.id !== undefined) { + this.#running[fartData.id] = fart; + } + + this.#queue.push(fart); + }); + } + // Process sounds array (existing logic should handle this via other messages) // Process kills array @@ -297,6 +319,22 @@ class SpeakerProcessor extends AudioWorkletProcessor { return; } + // 🫧 Bubble-specific update handler + if (msg.type === "bubble:update") { + const soundInstance = this.#running[msg.content.id]; + // console.log(`📻 SPEAKER bubble:update: id=${msg.content.id}, found=${!!soundInstance}, props=${JSON.stringify(msg.content.properties)}`); + soundInstance?.update(msg.content.properties); + return; + } + + // 💨 Fart-specific update handler + if (msg.type === "fart:update") { + const soundInstance = this.#running[msg.content.id]; + // console.log(`📻 SPEAKER fart:update: id=${msg.content.id}, found=${!!soundInstance}, props=${JSON.stringify(msg.content.properties)}`); + soundInstance?.update(msg.content.properties); + return; + } + // 🔄 Update sample buffer for a labeled sample and any running sounds using it if (msg.type === "sample:update") { const { label, buffer } = msg.data; @@ -566,15 +604,35 @@ class SpeakerProcessor extends AudioWorkletProcessor { msg.data.pan, msg.data.id, ); - + // Track bubble by ID if provided if (msg.data.id !== undefined) { this.#running[msg.data.id] = bubble; } - + this.#queue.push(bubble); return; } + + // Fart works similarly to Bubble - physical modeling synthesis + if (msg.type === "fart") { + const fart = new Fart( + msg.data.pressure, + msg.data.pitch, + msg.data.rasp, + msg.data.volume, + msg.data.pan, + msg.data.id, + ); + + // Track fart by ID if provided + if (msg.data.id !== undefined) { + this.#running[msg.data.id] = fart; + } + + this.#queue.push(fart); + return; + } }; } -- 2.51.2