Make aquarium audio bursts transient

This commit is contained in:
Meta Vens 2026-05-05 10:56:19 +01:00
commit 02ba15d14e
3 changed files with 112 additions and 29 deletions

View file

@ -143,7 +143,9 @@ async function smokeViewport(browser, viewport, screenshotPath, exerciseFluidPan
audio.vocalAgentCount >= 7 &&
audio.lastBurstChirpDrivers >= 6 &&
audio.spectral?.chirpDrivers === 6 &&
audio.spectral?.lastBurstChoirVoices >= 3 &&
audio.spectral?.reactiveFlushes >= 1 &&
audio.spectral?.transientBins >= 24 &&
audio.spectral?.vocalAgents >= 7 &&
audio.spectral?.queuedFrames >= 2048 &&
audio.lastBurst;

View file

@ -1752,7 +1752,21 @@ uniform float uSampleRate;
uniform float uStartSample;
uniform int uBinCount;
uniform vec4 uBins[${audioSpectrumBins}];
uniform vec4 uShapes[${audioSpectrumBins}];
const float TAU = 6.283185307179586;
float binEnvelope(float sampleIndex, vec4 shape) {
if (shape.x < 0.0) {
return 1.0;
}
float local = (sampleIndex - shape.x) / max(shape.y, 1.0);
if (local < 0.0 || local > 1.0) {
return 0.0;
}
float attack = smoothstep(0.0, max(shape.z, 0.001), local);
float body = pow(max(sin(local * 3.141592653589793), 0.0), 0.28);
float tail = exp(-local * shape.w);
return attack * body * tail;
}
float renderSample(float sampleIndex) {
float t = sampleIndex / uSampleRate;
float value = 0.0;
@ -1761,8 +1775,10 @@ float renderSample(float sampleIndex) {
break;
}
vec4 bin = uBins[index];
float phase = bin.z + TAU * (bin.x * t + bin.w * t * t);
value += sin(phase) * bin.y;
vec4 shape = uShapes[index];
float localT = shape.x < 0.0 ? (sampleIndex - uStartSample) / uSampleRate : max(0.0, (sampleIndex - shape.x) / uSampleRate);
float phase = bin.z + TAU * (bin.x * t + bin.w * localT * localT);
value += sin(phase) * bin.y * binEnvelope(sampleIndex, shape);
}
return tanh(value * 0.82);
}
@ -1989,10 +2005,18 @@ type SpectralBurst = {
action: AgentSoundAction;
agent: AquariumAgentFrame;
durationSamples: number;
gain: number;
seed: number;
startSample: number;
};
type SpectralBinShape = {
attackPortion: number;
decay: number;
durationSamples: number;
startSample: number;
};
class BufferedGpuSpectrumOutput {
private bins = new Float32Array(audioSpectrumBins * 4);
private binCount = 0;
@ -2001,6 +2025,8 @@ class BufferedGpuSpectrumOutput {
private fbo: WebGLFramebuffer | null = null;
private gl: WebGL2RenderingContext | null = null;
private lastAgents: ProjectedAgent[] = [];
private lastBurstChoirVoices = 0;
private lastTransientGain = 0;
private mode: "gpu" | "cpu" = "cpu";
private pixelBuffer = new Float32Array(audioChunkFrames);
private processor: ScriptProcessorNode;
@ -2008,8 +2034,10 @@ class BufferedGpuSpectrumOutput {
private queue: Float32Array[] = [];
private queueOffset = 0;
private reactiveFlushes = 0;
private shapes = new Float32Array(audioSpectrumBins * 4);
private sampleCursor = 0;
private texture: WebGLTexture | null = null;
private transientBinCount = 0;
constructor(private context: AudioContext, destination: AudioNode) {
this.processor = context.createScriptProcessor(audioChunkFrames, 0, 1);
@ -2033,7 +2061,10 @@ class BufferedGpuSpectrumOutput {
queuedFrames: this.queue.reduce((total, chunk) => total + chunk.length, -this.queueOffset),
bursts: this.burstEvents.length,
chirpDrivers: 6,
lastBurstChoirVoices: this.lastBurstChoirVoices,
lastTransientGain: this.lastTransientGain,
reactiveFlushes: this.reactiveFlushes,
transientBins: this.transientBinCount,
vocalAgents: this.lastAgents.length,
};
}
@ -2042,16 +2073,28 @@ class BufferedGpuSpectrumOutput {
this.queue = [];
this.queueOffset = 0;
this.reactiveFlushes += 1;
this.burstEvents.push({
const choir = this.choirFor(agent);
this.lastBurstChoirVoices = choir.length;
this.lastTransientGain = action === "notification" ? 1.55 : action === "selected" ? 1.35 : 1.2;
const durationSeconds = action === "notification" ? 0.78 : action === "selected" ? 0.54 : 0.38;
choir.forEach((voice, index) => this.burstEvents.push({
action,
agent,
durationSamples: Math.floor(this.context.sampleRate * (action === "notification" ? 1.4 : action === "selected" ? 1.05 : 0.82)),
seed: hashString(`${agent.id}:${action}:${agent.status}:${this.sampleCursor}`),
startSample: this.sampleCursor,
});
agent: voice,
durationSamples: Math.floor(this.context.sampleRate * durationSeconds),
gain: this.lastTransientGain * (index === 0 ? 1 : 0.62 / Math.sqrt(index + 1)),
seed: hashString(`${voice.id}:${agent.id}:${action}:${voice.status}:${this.sampleCursor}:${index}`),
startSample: this.sampleCursor + Math.floor(this.context.sampleRate * (index === 0 ? 0 : 0.018 + index * 0.017)),
}));
this.fillQueue(audioQueueTargetChunks);
}
private choirFor(agent: AquariumAgentFrame) {
const agents = this.lastAgents.length ? this.lastAgents : [agent as ProjectedAgent];
const target = agents.find((candidate) => candidate.id === agent.id) ?? agent;
const others = agents.filter((candidate) => candidate.id !== agent.id);
return [target, ...others].slice(0, 7);
}
update(agents: ProjectedAgent[], _time: number) {
this.lastAgents = agents;
this.fillQueue(2);
@ -2134,6 +2177,7 @@ class BufferedGpuSpectrumOutput {
gl.uniform1f(gl.getUniformLocation(this.program, "uStartSample"), this.sampleCursor);
gl.uniform1i(gl.getUniformLocation(this.program, "uBinCount"), this.currentBinCount());
gl.uniform4fv(gl.getUniformLocation(this.program, "uBins"), this.bins);
gl.uniform4fv(gl.getUniformLocation(this.program, "uShapes"), this.shapes);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.readPixels(0, 0, audioChunkFrames / 4, 1, gl.RGBA, gl.FLOAT, this.pixelBuffer);
return new Float32Array(this.pixelBuffer);
@ -2149,7 +2193,9 @@ class BufferedGpuSpectrumOutput {
let value = 0;
for (let bin = 0; bin < binCount; bin += 1) {
const offset = bin * 4;
value += Math.sin(this.bins[offset + 2] + Math.PI * 2 * (this.bins[offset] * t + this.bins[offset + 3] * t * t)) * this.bins[offset + 1];
const shape = this.binEnvelope(sampleIndex, offset);
const localT = this.shapes[offset] < 0 ? (sampleIndex - this.sampleCursor) / sampleRate : Math.max(0, (sampleIndex - this.shapes[offset]) / sampleRate);
value += Math.sin(this.bins[offset + 2] + mathTau * (this.bins[offset] * t + this.bins[offset + 3] * localT * localT)) * this.bins[offset + 1] * shape;
}
chunk[frame] = Math.tanh(value * 0.82);
}
@ -2159,13 +2205,16 @@ class BufferedGpuSpectrumOutput {
private writeBins(startSample: number) {
this.bins.fill(0);
this.shapes.fill(-1);
let bin = 0;
for (const agent of this.lastAgents) {
bin = this.writeVocalAgentBins(bin, agent, startSample);
}
const transientStartBin = bin;
for (const event of this.burstEvents) {
bin = this.writeVocalExcitationBins(bin, event, startSample);
}
this.transientBinCount = bin - transientStartBin;
for (const agent of this.lastAgents) {
bin = this.writeVocalAgentBins(bin, agent, startSample);
}
this.binCount = bin;
}
@ -2176,8 +2225,9 @@ class BufferedGpuSpectrumOutput {
const basePitch = profile.f0 * (1 + controls.glottisPitch * 0.035 + controls.vibrato * 0.018 + agent.chirps.acknowledgement * 0.018);
const pulseEnergy = Math.max(Math.abs(controls.glottisPitch), Math.abs(controls.tongue), Math.abs(controls.lips), Math.abs(controls.vibrato), controls.string);
const hoverQuiet = lerp(1, 0.18, agent.hover);
const intensity = (0.00025 + controls.breath * 0.0012 + pulseEnergy * 0.0036 + agent.chirps.acknowledgement * 0.0048 + agent.chirps.panic * 0.004) * hoverQuiet;
for (let harmonic = 1; harmonic <= profile.harmonics && bin < audioSpectrumBins; harmonic += 1) {
const intensity = (0.00003 + controls.breath * 0.00018 + pulseEnergy * 0.00048 + agent.chirps.acknowledgement * 0.00075 + agent.chirps.panic * 0.0008) * hoverQuiet;
const backgroundHarmonics = Math.min(profile.harmonics, 5);
for (let harmonic = 1; harmonic <= backgroundHarmonics && bin < audioSpectrumBins; harmonic += 1) {
const frequency = basePitch * harmonic * (1 + controls.throat * 0.002 * harmonic);
if (frequency > 14000) break;
const sourceTilt = 1 / Math.pow(harmonic, 1.08 + controls.tenseness * 0.22 + profile.softness * 0.18);
@ -2188,9 +2238,9 @@ class BufferedGpuSpectrumOutput {
this.writeBin(bin, frequency, amplitude, profile.phase + agent.phase * harmonic + startSample * 0.0000008 * harmonic, chirp);
bin += 1;
}
for (let index = 0; index < formants.length && bin < audioSpectrumBins; index += 1) {
for (let index = 0; index < Math.min(formants.length, 2) && bin < audioSpectrumBins; index += 1) {
const formant = formants[index];
const breathAmplitude = (0.0009 + controls.breath * 0.003 + agent.chirps.panic * 0.005) * formant.gain;
const breathAmplitude = (0.00005 + controls.breath * 0.00018 + agent.chirps.panic * 0.00045) * formant.gain * hoverQuiet;
this.writeBin(bin, formant.frequency, breathAmplitude, profile.phase * (index + 2.1), controls.tongue * 0.012 + controls.lips * 0.01);
bin += 1;
}
@ -2198,19 +2248,21 @@ class BufferedGpuSpectrumOutput {
}
private writeVocalExcitationBins(bin: number, event: SpectralBurst, startSample: number) {
const localSeconds = (startSample + audioChunkFrames * 0.5 - event.startSample) / this.context.sampleRate;
const chunkEndSample = startSample + audioChunkFrames;
const eventEndSample = event.startSample + event.durationSamples;
if (chunkEndSample < event.startSample || startSample > eventEndSample) return bin;
const localSeconds = clamp((startSample + audioChunkFrames * 0.5 - event.startSample) / this.context.sampleRate, 0, event.durationSamples / this.context.sampleRate);
const durationSeconds = event.durationSamples / this.context.sampleRate;
const envelope = vocalEventEnvelope(localSeconds, durationSeconds, event.action);
if (envelope <= 0.0008) return bin;
const profile = vocalProfileFor(event.agent.id);
const random = mulberry32(event.seed);
const driverSeconds = durationSeconds * 0.62;
const glottis = vocalOneShotChirplet(localSeconds, driverSeconds * 0.16, driverSeconds * 0.08, random() * mathTau, 7.5, 22);
const tongue = vocalOneShotChirplet(localSeconds, driverSeconds * 0.24, driverSeconds * 0.1, random() * mathTau, 5.6, -18);
const lips = vocalOneShotChirplet(localSeconds, driverSeconds * 0.34, driverSeconds * 0.11, random() * mathTau, 4.4, 13);
const throat = vocalOneShotChirplet(localSeconds, driverSeconds * 0.45, driverSeconds * 0.12, random() * mathTau, 3.2, -10);
const vibrato = vocalOneShotChirplet(localSeconds, driverSeconds * 0.58, driverSeconds * 0.13, random() * mathTau, 9.4, 28);
const string = vocalOneShotChirplet(localSeconds, driverSeconds * 0.72, driverSeconds * 0.14, random() * mathTau, 6.8, -24);
const driverSeconds = durationSeconds * 0.54;
const glottis = vocalOneShotChirplet(localSeconds, driverSeconds * 0.1, driverSeconds * 0.052, random() * mathTau, 10.5, 54);
const tongue = vocalOneShotChirplet(localSeconds, driverSeconds * 0.18, driverSeconds * 0.064, random() * mathTau, 7.6, -38);
const lips = vocalOneShotChirplet(localSeconds, driverSeconds * 0.28, driverSeconds * 0.072, random() * mathTau, 6.2, 30);
const throat = vocalOneShotChirplet(localSeconds, driverSeconds * 0.4, driverSeconds * 0.078, random() * mathTau, 4.6, -22);
const vibrato = vocalOneShotChirplet(localSeconds, driverSeconds * 0.54, driverSeconds * 0.086, random() * mathTau, 13.2, 62);
const string = vocalOneShotChirplet(localSeconds, driverSeconds * 0.7, driverSeconds * 0.095, random() * mathTau, 9.8, -48);
const controls = {
glottisPitch: glottis * (event.action === "notification" ? 1.1 : 0.72),
tenseness: clamp(0.34 + envelope * (event.action === "notification" ? 0.48 : 0.3), 0.08, 1),
@ -2223,14 +2275,20 @@ class BufferedGpuSpectrumOutput {
};
const formants = morphFormants(profile.formants, controls);
const basePitch = profile.f0 * (1 + controls.glottisPitch * 0.09 + controls.vibrato * 0.035);
const drive = envelope * (event.action === "notification" ? 0.08 : event.action === "selected" ? 0.064 : 0.05);
const harmonics = Math.min(profile.harmonics + (event.action === "notification" ? 8 : 4), 24);
const drive = event.gain * envelope * (event.action === "notification" ? 0.34 : event.action === "selected" ? 0.28 : 0.24);
const harmonics = event.action === "notification" ? 14 : event.action === "selected" ? 12 : 10;
const shape = {
attackPortion: event.action === "notification" ? 0.018 : 0.022,
decay: event.action === "notification" ? 12 : event.action === "selected" ? 15 : 18,
durationSamples: event.durationSamples,
startSample: event.startSample,
};
for (let harmonic = 1; harmonic <= harmonics && bin < audioSpectrumBins; harmonic += 1) {
const frequency = clamp(basePitch * harmonic * (1 + controls.throat * 0.002 * harmonic), 50, 15000);
const tract = vocalFormantEnvelope(frequency, formants);
const sourceTilt = 1 / Math.pow(harmonic, 0.94 + controls.tenseness * 0.32);
const amplitude = drive * tract * sourceTilt * (0.72 + controls.string * 0.38);
this.writeBin(bin, frequency, amplitude, random() * mathTau, controls.vibrato * 0.04 + controls.glottisPitch * 0.018);
this.writeBin(bin, frequency, amplitude, random() * mathTau, controls.vibrato * 0.22 + controls.glottisPitch * 0.08, shape);
bin += 1;
}
return bin;
@ -2240,12 +2298,29 @@ class BufferedGpuSpectrumOutput {
return Math.min(audioSpectrumBins, Math.max(0, this.binCount));
}
private writeBin(index: number, frequency: number, amplitude: number, phase: number, chirp: number) {
private binEnvelope(sampleIndex: number, offset: number) {
const shapeStart = this.shapes[offset];
if (shapeStart < 0) return 1;
const local = (sampleIndex - shapeStart) / Math.max(this.shapes[offset + 1], 1);
if (local < 0 || local > 1) return 0;
const attack = smoothstep(0, Math.max(this.shapes[offset + 2], 0.001), local);
const body = Math.max(Math.sin(Math.PI * local), 0) ** 0.28;
const tail = Math.exp(-local * this.shapes[offset + 3]);
return attack * body * tail;
}
private writeBin(index: number, frequency: number, amplitude: number, phase: number, chirp: number, shape?: SpectralBinShape) {
const offset = index * 4;
this.bins[offset] = clamp(frequency, 18, 16000);
this.bins[offset + 1] = amplitude;
this.bins[offset + 2] = phase;
this.bins[offset + 3] = chirp;
if (shape) {
this.shapes[offset] = shape.startSample;
this.shapes[offset + 1] = shape.durationSamples;
this.shapes[offset + 2] = shape.attackPortion;
this.shapes[offset + 3] = shape.decay;
}
}
}
@ -2697,6 +2772,11 @@ function lerp(from: number, to: number, t: number) {
return from + (to - from) * clamp(t, 0, 1);
}
function smoothstep(edge0: number, edge1: number, value: number) {
const t = clamp((value - edge0) / Math.max(edge1 - edge0, 0.000001), 0, 1);
return t * t * (3 - 2 * t);
}
function hoverInfluence(x: number, y: number, pointerX: number, pointerY: number, radius: number) {
return 1 - clamp(distance(x, y, pointerX, pointerY) / Math.max(radius, 1), 0, 1);
}

View file

@ -70,3 +70,4 @@
{"ts": "2026-05-04T22:08:32+00:00", "type": "implementation", "status": "ok", "note": "Role-specific prompt salience pass landed: Imagination, Body/modeling, Eyes/research, Hands/implementation, Soul/verification, Life/reorientation, and Self/coordinator now each express self-improvement in their own role language. Prompt TOML smoke, epiphany-core doctrine render test, and app-server prompt config test passed."}
{"ts": "2026-05-04T22:47:52+00:00", "type": "implementation", "status": "ok", "note": "Specialist self-memory persistence landed as Ghostlight-shaped role dossiers under state/agents plus a bounded selfPatch review surface in roleResult/roleAccept. Good mutations are role-matched, bounded, evidence-grounded lane memory/goal/value/private-note updates; project truth, authority, code edits, graphs, checkpoints, planning, scratch, jobs, and raw transcripts are refused with reasons. GUI/coordinator accept paths apply accepted selfPatch requests through tools/epiphany_agent_memory.py."}
{"ts": "2026-05-05T09:38:42+00:00", "type": "implementation", "status": "ok", "note": "Perfect Machine GUI audio pass cut the muted legacy WebAudio voice layer from the aquarium dashboard. Agent sound now flows through the buffered spectral vocal path only: projected agents update vocal bins, bounded status/touch events enqueue chirplet-driven vocal bursts, and the visual smoke probe checks vocalAgentCount plus reactiveFlushes instead of fake partial/hum counters."}
{"ts": "2026-05-05T09:56:04+00:00", "type": "implementation", "status": "ok", "note": "Aquarium audio transient pass landed: interaction bursts now take priority over idle bins, carry per-sample GPU/CPU envelope shapes, fan out across a staggered 7-agent choir, and reduce idle vocal bins to a quiet residual. Visual smoke now requires lastBurstChoirVoices and transientBins so future changes cannot silently regress back to a constant drone."}