Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add fart synthesizer & fartflower piece

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 <noreply@anthropic.com>

+492 -5
+98
system/public/aesthetic.computer/disks/fartflower.mjs
··· 1 + // 🌸 Fartflower 2025.04.14 2 + // One button. Click it. Flower blooms. Fart sound plays. 3 + 4 + let bloomProgress = 0; 5 + let activeFart = null; 6 + let fartButton; 7 + 8 + // 🥾 Boot 9 + function boot({ ui: { Button }, screen }) { 10 + const centerX = screen.width / 2; 11 + const centerY = screen.height / 2; 12 + const buttonSize = 60; 13 + 14 + // Single centered button 15 + fartButton = new Button("💨", { 16 + box: [ 17 + centerX - buttonSize / 2, 18 + centerY - buttonSize / 2, 19 + buttonSize, 20 + buttonSize, 21 + ], 22 + fontSize: 32, 23 + }); 24 + } 25 + 26 + // 🧮 Sim 27 + function sim({ num }) { 28 + // Animate the bloom 29 + if (activeFart && bloomProgress < 1) { 30 + bloomProgress = num.clamp(bloomProgress + 0.08, 0, 1); 31 + } else if (!activeFart && bloomProgress > 0) { 32 + bloomProgress = num.clamp(bloomProgress - 0.06, 0, 1); 33 + } 34 + } 35 + 36 + // 🎨 Paint 37 + function paint({ wipe, ink, circle, line, screen, num }) { 38 + wipe(240); 39 + 40 + const centerX = screen.width / 2; 41 + const centerY = screen.height / 2; 42 + const petalCount = 6; 43 + const maxPetalRadius = 80; 44 + const petalRadius = num.lerp(10, maxPetalRadius, bloomProgress); 45 + 46 + // Draw petals in a circle 47 + for (let i = 0; i < petalCount; i++) { 48 + const angle = (i / petalCount) * Math.PI * 2; 49 + const x = centerX + Math.cos(angle) * (50 + petalRadius * 0.5); 50 + const y = centerY + Math.sin(angle) * (50 + petalRadius * 0.5); 51 + 52 + // Petals fade in/out with bloom 53 + const petalAlpha = Math.floor(bloomProgress * 200); 54 + ink(255, 100, 150, petalAlpha).circle(x, y, petalRadius); 55 + } 56 + 57 + // Center stem 58 + ink(100, 150, 80).line(centerX, centerY, centerX, centerY + 100); 59 + 60 + // Center of flower (grows with bloom) 61 + const centerRadius = num.lerp(5, 20, bloomProgress); 62 + ink(255, 200, 0).circle(centerX, centerY, centerRadius); 63 + 64 + // Draw button 65 + fartButton.paint({ ink }); 66 + } 67 + 68 + // ✒ Act 69 + function act({ event: e, sound: { fart } }) { 70 + if (fartButton.trigger(e)) { 71 + // Kill previous fart if still active 72 + if (activeFart) { 73 + activeFart.kill(0.1); 74 + } 75 + 76 + // Trigger new fart sound with physical modeling parameters 77 + activeFart = fart({ 78 + pressure: 0.8, 79 + pitch: 60 + Math.random() * 30, 80 + rasp: 0.6, 81 + volume: 0.7, 82 + pan: 0, 83 + }); 84 + 85 + // Enable sustain for the bloom animation duration 86 + activeFart.enableSustain(); 87 + 88 + // Disable sustain after bloom completes 89 + setTimeout(() => { 90 + if (activeFart) { 91 + activeFart.disableSustain(); 92 + activeFart = null; 93 + } 94 + }, 500); 95 + } 96 + } 97 + 98 + export { boot, sim, paint, act };
+36
system/public/aesthetic.computer/lib/disk.mjs
··· 7321 7321 bpm: undefined, 7322 7322 sounds: [], 7323 7323 bubbles: [], 7324 + farts: [], 7324 7325 kills: [], 7325 7326 }; 7326 7327 ··· 11458 11459 // soundClear?.(); 11459 11460 sound.sounds.length = 0; // Empty the sound command buffer. 11460 11461 sound.bubbles.length = 0; 11462 + sound.farts.length = 0; 11461 11463 sound.kills.length = 0; 11462 11464 return; 11463 11465 } ··· 12437 12439 }; 12438 12440 }; 12439 12441 12442 + $sound.fart = function ({ pressure = 1, pitch = 60, rasp = 0.5, volume = 1, pan = 0 } = {}) { 12443 + const id = soundId; 12444 + sound.farts = sound.farts || []; 12445 + sound.farts.push({ id, pressure, pitch, rasp, volume, pan }); 12446 + soundId += 1n; 12447 + 12448 + return { 12449 + startedAt: soundTime, 12450 + id, 12451 + kill: function (fade) { 12452 + sound.kills.push({ id, fade }); 12453 + }, 12454 + update: function (properties) { 12455 + send({ 12456 + type: "fart:update", 12457 + content: { id, properties }, 12458 + }); 12459 + }, 12460 + enableSustain: function () { 12461 + send({ 12462 + type: "fart:update", 12463 + content: { id, properties: { sustain: true } }, 12464 + }); 12465 + }, 12466 + disableSustain: function () { 12467 + send({ 12468 + type: "fart:update", 12469 + content: { id, properties: { sustain: false } }, 12470 + }); 12471 + }, 12472 + }; 12473 + }; 12474 + 12440 12475 $sound.kill = function (id, fade) { 12441 12476 sound.kills.push({ id, fade }); 12442 12477 }; ··· 16077 16112 16078 16113 sound.sounds.length = 0; // Empty the sound command buffer. 16079 16114 sound.bubbles.length = 0; 16115 + sound.farts.length = 0; 16080 16116 sound.kills.length = 0; 16081 16117 16082 16118 twoDCommands.length = 0; // Empty the 2D GPU command buffer.
+295
system/public/aesthetic.computer/lib/sound/fart.mjs
··· 1 + // 💨 Fart 2025.04.14 2 + // Physical modeling of a fart sound using pressure, pitch, and rasp parameters. 3 + // Based on procedural audio synthesis principles from CompuFart. 4 + 5 + export default class Fart { 6 + // Generic for all instruments. 7 + playing = true; 8 + fading = false; // If we are fading and then stopping playback. 9 + fadeProgress; 10 + fadeDuration; 11 + 12 + #volume = 1; // 0 to 1 13 + #pan = 0; // -1 to 1 14 + 15 + #pressure; // 0 to 1 - how hard you squeeze 16 + #pitch; // Hz - fundamental frequency 17 + #rasp; // 0 to 1 - noise component (0 = pure tone, 1 = mostly noise) 18 + 19 + #amp; 20 + #decay; 21 + #gain; 22 + #phase; 23 + #lastOut; 24 + #timestep; 25 + 26 + #out = 0; 27 + #maxOut = 1; 28 + 29 + #progress = 0; 30 + 31 + #QUIET = 0.000001; 32 + 33 + // Noise generation state 34 + #noiseState = 0; 35 + 36 + // Parameter update properties for smooth transitions 37 + #futurePressure; 38 + #futurePitch; 39 + #futureRasp; 40 + #futureVolume; 41 + #futurePan; 42 + 43 + #pressureUpdatesTotal; 44 + #pressureUpdatesLeft; 45 + #pressureUpdateSlice; 46 + 47 + #pitchUpdatesTotal; 48 + #pitchUpdatesLeft; 49 + #pitchUpdateSlice; 50 + 51 + #raspUpdatesTotal; 52 + #raspUpdatesLeft; 53 + #raspUpdateSlice; 54 + 55 + #volumeUpdatesTotal; 56 + #volumeUpdatesLeft; 57 + #volumeUpdateSlice; 58 + 59 + #panUpdatesTotal; 60 + #panUpdatesLeft; 61 + #panUpdateSlice; 62 + 63 + #sustain = false; 64 + 65 + constructor(pressure, pitch, rasp, volume, pan, id) { 66 + this.id = id; // Store the ID for tracking 67 + this.start(pressure, pitch, rasp, volume, pan); 68 + } 69 + 70 + start( 71 + pressure = this.#pressure, 72 + pitch = this.#pitch, 73 + rasp = this.#rasp, 74 + volume = this.#volume, 75 + pan = this.#pan 76 + ) { 77 + this.#pan = pan; 78 + this.#volume = volume; 79 + this.#pressure = Math.max(0.01, Math.min(1, pressure)); // Clamp 0.01-1 80 + this.#pitch = Math.max(20, Math.min(8000, pitch)); // Clamp pitch to audible range 81 + this.#rasp = Math.max(0, Math.min(1, rasp)); // Clamp 0-1 82 + 83 + // Initialize future values for parameter updates 84 + this.#futurePressure = this.#pressure; 85 + this.#futurePitch = this.#pitch; 86 + this.#futureRasp = this.#rasp; 87 + this.#futureVolume = this.#volume; 88 + this.#futurePan = this.#pan; 89 + 90 + this.#timestep = 1 / sampleRate; 91 + this.#lastOut = this.#out; 92 + 93 + // Amplitude envelope: pressure controls initial amplitude 94 + this.#amp = 0.3 * this.#pressure; 95 + 96 + // Decay rate: lower pitch = longer sustain 97 + this.#decay = 0.8 + (this.#pitch / 8000) * 0.2; // Faster decay for higher pitches 98 + this.#gain = Math.exp(-this.#decay * this.#timestep); 99 + 100 + this.#phase = 0; 101 + } 102 + 103 + update({ pressure, pitch, rasp, volume, pan, sustain, duration = 0.1 }) { 104 + // Sustain updates (immediate change, no interpolation needed) 105 + if (typeof sustain === "boolean") { 106 + this.#sustain = sustain; 107 + console.log(`💨 UPDATE: Sustain set to ${sustain} for fart ${this.id || 'unknown'}`); 108 + } 109 + 110 + // Pressure updates (affects amplitude and energy) 111 + if (typeof pressure === "number" && pressure >= 0) { 112 + this.#futurePressure = Math.max(0.01, Math.min(1, pressure)); 113 + this.#pressureUpdatesTotal = duration * sampleRate; 114 + this.#pressureUpdatesLeft = this.#pressureUpdatesTotal; 115 + this.#pressureUpdateSlice = 116 + (this.#futurePressure - this.#pressure) / this.#pressureUpdatesTotal; 117 + } 118 + 119 + // Pitch updates (affects frequency) 120 + if (typeof pitch === "number" && pitch > 0) { 121 + this.#futurePitch = Math.max(20, Math.min(8000, pitch)); 122 + this.#pitchUpdatesTotal = duration * sampleRate; 123 + this.#pitchUpdatesLeft = this.#pitchUpdatesTotal; 124 + this.#pitchUpdateSlice = 125 + (this.#futurePitch - this.#pitch) / this.#pitchUpdatesTotal; 126 + } 127 + 128 + // Rasp updates (affects noise/tone balance) 129 + if (typeof rasp === "number" && rasp >= 0) { 130 + this.#futureRasp = Math.max(0, Math.min(1, rasp)); 131 + this.#raspUpdatesTotal = duration * sampleRate; 132 + this.#raspUpdatesLeft = this.#raspUpdatesTotal; 133 + this.#raspUpdateSlice = 134 + (this.#futureRasp - this.#rasp) / this.#raspUpdatesTotal; 135 + } 136 + 137 + // Volume updates 138 + if (typeof volume === "number") { 139 + this.#futureVolume = volume; 140 + this.#volumeUpdatesTotal = duration * sampleRate; 141 + this.#volumeUpdatesLeft = this.#volumeUpdatesTotal; 142 + this.#volumeUpdateSlice = 143 + (this.#futureVolume - this.#volume) / this.#volumeUpdatesTotal; 144 + } 145 + 146 + // Pan updates 147 + if (typeof pan === "number") { 148 + this.#futurePan = pan; 149 + this.#panUpdatesTotal = duration * sampleRate; 150 + this.#panUpdatesLeft = this.#panUpdatesTotal; 151 + this.#panUpdateSlice = 152 + (this.#futurePan - this.#pan) / this.#panUpdatesTotal; 153 + } 154 + } 155 + 156 + // Sustain control methods 157 + setSustain(sustain) { 158 + this.#sustain = sustain; 159 + console.log(`💨 setSustain(${sustain}) for fart ${this.id || 'unknown'}`); 160 + } 161 + 162 + enableSustain() { 163 + this.#sustain = true; 164 + console.log(`💨 enableSustain() for fart ${this.id || 'unknown'}`); 165 + } 166 + 167 + disableSustain() { 168 + this.#sustain = false; 169 + console.log(`💨 disableSustain() for fart ${this.id || 'unknown'}`); 170 + } 171 + 172 + // Linear congruential generator for pseudo-random noise 173 + _noise() { 174 + this.#noiseState = (this.#noiseState * 1103515245 + 12345) & 0x7fffffff; 175 + return (this.#noiseState / 0x7fffffff) * 2 - 1; // Range: -1 to 1 176 + } 177 + 178 + next() { 179 + // Interpolated parameter updates 180 + 181 + // Pressure updates (affects amplitude) 182 + if (this.#pressureUpdatesLeft > 0) { 183 + this.#pressure += this.#pressureUpdateSlice; 184 + this.#pressureUpdatesLeft -= 1; 185 + } 186 + 187 + // Pitch updates (affects frequency for next cycle) 188 + if (this.#pitchUpdatesLeft > 0) { 189 + this.#pitch += this.#pitchUpdateSlice; 190 + this.#pitchUpdatesLeft -= 1; 191 + } 192 + 193 + // Rasp updates (affects noise/tone balance) 194 + if (this.#raspUpdatesLeft > 0) { 195 + this.#rasp += this.#raspUpdateSlice; 196 + this.#raspUpdatesLeft -= 1; 197 + } 198 + 199 + // Volume updates 200 + if (this.#volumeUpdatesLeft > 0) { 201 + this.#volume += this.#volumeUpdateSlice; 202 + this.#volumeUpdatesLeft -= 1; 203 + } 204 + 205 + // Pan updates 206 + if (this.#panUpdatesLeft > 0) { 207 + this.#pan += this.#panUpdateSlice; 208 + this.#panUpdatesLeft -= 1; 209 + } 210 + 211 + // Stop if amplitude is quiet and not sustaining 212 + if (!this.#sustain && this.#amp < this.#QUIET) { 213 + this.playing = false; 214 + return 0; 215 + } 216 + 217 + // Calculate phase step from current pitch 218 + const phaseStep = (this.#pitch / sampleRate) * Math.PI * 2; 219 + 220 + // Generate tone component (sine wave) 221 + const tone = Math.sin(this.#phase) * this.#pressure; 222 + 223 + // Generate noise component 224 + const noise = this._noise() * this.#rasp; 225 + 226 + // Mix tone and noise 227 + const mixed = tone * (1 - this.#rasp) + noise; 228 + 229 + // Apply amplitude envelope with smoothing 230 + this.#out = this.#lastOut * 0.3 + mixed * this.#amp * 0.7; 231 + this.#lastOut = this.#out; 232 + 233 + // Advance phase 234 + this.#phase += phaseStep; 235 + if (this.#phase > Math.PI * 2) { 236 + this.#phase -= Math.PI * 2; 237 + } 238 + 239 + // Only apply amplitude decay if not in sustain mode 240 + if (!this.#sustain) { 241 + this.#amp *= this.#gain; 242 + } 243 + 244 + this.#progress += 1; 245 + 246 + // Normalization to a max of 1 / -1 247 + let out = this.#out * this.#volume; 248 + if (Math.abs(out) > this.#maxOut) this.#maxOut = Math.abs(out); 249 + 250 + out = out / this.#maxOut; 251 + 252 + // Apply fading if necessary 253 + if (this.fading) { 254 + if (this.fadeProgress < this.fadeDuration) { 255 + this.fadeProgress += 1; 256 + // Apply the fade envelope to the output. 257 + out *= 1 - this.fadeProgress / this.fadeDuration; 258 + } else { 259 + this.fading = false; 260 + this.playing = false; 261 + return 0; 262 + } 263 + } 264 + 265 + return out; 266 + } 267 + 268 + // Stereo panning 269 + pan(channel, frame) { 270 + if (channel === 0) { 271 + // Left Channel 272 + if (this.#pan > 0) { 273 + frame *= 1 - this.#pan; 274 + } 275 + } else if (channel === 1) { 276 + // Right Channel 277 + if (this.#pan < 0) { 278 + frame *= 1 - Math.abs(this.#pan); 279 + } 280 + } 281 + return frame; 282 + } 283 + 284 + // Use a 25ms fade by default. 285 + kill(fade = 0.025) { 286 + if (!fade) { 287 + this.playing = false; 288 + } else { 289 + // Fade over 'fade' seconds, before stopping playback. 290 + this.fading = true; 291 + this.fadeProgress = 0; 292 + this.fadeDuration = fade * sampleRate; // Convert seconds to samples. 293 + } 294 + } 295 + }
+63 -5
system/public/aesthetic.computer/lib/speaker.mjs
··· 6 6 // Cache bust: Feb 4, 2026 - fixed _fillCustomBuffer for AudioWorklet compatibility 7 7 import Synth from "./sound/synth.mjs?v=20260204"; 8 8 import Bubble from "./sound/bubble.mjs"; 9 + import Fart from "./sound/fart.mjs"; 9 10 import { lerp, within, clamp } from "./num.mjs"; 10 11 11 12 const { abs, round, floor } = Math; ··· 232 233 bubbleData.pan, 233 234 bubbleData.id, 234 235 ); 235 - 236 + 236 237 // Track bubble by ID if provided 237 238 if (bubbleData.id !== undefined) { 238 239 this.#running[bubbleData.id] = bubble; 239 240 } 240 - 241 + 241 242 this.#queue.push(bubble); 242 243 }); 243 244 } 244 - 245 + 246 + // Process farts array 247 + if (soundData.farts) { 248 + soundData.farts.forEach(fartData => { 249 + const fart = new Fart( 250 + fartData.pressure, 251 + fartData.pitch, 252 + fartData.rasp, 253 + fartData.volume, 254 + fartData.pan, 255 + fartData.id, 256 + ); 257 + 258 + // Track fart by ID if provided 259 + if (fartData.id !== undefined) { 260 + this.#running[fartData.id] = fart; 261 + } 262 + 263 + this.#queue.push(fart); 264 + }); 265 + } 266 + 245 267 // Process sounds array (existing logic should handle this via other messages) 246 268 247 269 // Process kills array ··· 297 319 return; 298 320 } 299 321 322 + // 🫧 Bubble-specific update handler 323 + if (msg.type === "bubble:update") { 324 + const soundInstance = this.#running[msg.content.id]; 325 + // console.log(`📻 SPEAKER bubble:update: id=${msg.content.id}, found=${!!soundInstance}, props=${JSON.stringify(msg.content.properties)}`); 326 + soundInstance?.update(msg.content.properties); 327 + return; 328 + } 329 + 330 + // 💨 Fart-specific update handler 331 + if (msg.type === "fart:update") { 332 + const soundInstance = this.#running[msg.content.id]; 333 + // console.log(`📻 SPEAKER fart:update: id=${msg.content.id}, found=${!!soundInstance}, props=${JSON.stringify(msg.content.properties)}`); 334 + soundInstance?.update(msg.content.properties); 335 + return; 336 + } 337 + 300 338 // 🔄 Update sample buffer for a labeled sample and any running sounds using it 301 339 if (msg.type === "sample:update") { 302 340 const { label, buffer } = msg.data; ··· 566 604 msg.data.pan, 567 605 msg.data.id, 568 606 ); 569 - 607 + 570 608 // Track bubble by ID if provided 571 609 if (msg.data.id !== undefined) { 572 610 this.#running[msg.data.id] = bubble; 573 611 } 574 - 612 + 575 613 this.#queue.push(bubble); 614 + return; 615 + } 616 + 617 + // Fart works similarly to Bubble - physical modeling synthesis 618 + if (msg.type === "fart") { 619 + const fart = new Fart( 620 + msg.data.pressure, 621 + msg.data.pitch, 622 + msg.data.rasp, 623 + msg.data.volume, 624 + msg.data.pan, 625 + msg.data.id, 626 + ); 627 + 628 + // Track fart by ID if provided 629 + if (msg.data.id !== undefined) { 630 + this.#running[msg.data.id] = fart; 631 + } 632 + 633 + this.#queue.push(fart); 576 634 return; 577 635 } 578 636 };