Render aquarium voices from tempo classes
This commit is contained in:
parent
1efd384cd9
commit
8f6b56086c
2 changed files with 244 additions and 60 deletions
|
|
@ -112,7 +112,11 @@ async function smokeViewport(browser, viewport, screenshotPath, exerciseFluidPan
|
|||
throw new Error(`DOM agent projection was not synchronized: ${projectionProbe.reason}`);
|
||||
}
|
||||
|
||||
await page.locator('[data-agent-node="research"]').hover();
|
||||
const hoverBox = await page.locator('[data-agent-node="research"]').boundingBox();
|
||||
if (!hoverBox) {
|
||||
throw new Error("research DOM node has no hover bounds");
|
||||
}
|
||||
await page.mouse.move(hoverBox.x + hoverBox.width / 2, hoverBox.y + hoverBox.height / 2);
|
||||
await page.waitForTimeout(180);
|
||||
const hoverProbe = await page.evaluate(() => {
|
||||
const node = document.querySelector('[data-agent-node="research"]');
|
||||
|
|
|
|||
|
|
@ -1758,10 +1758,12 @@ const agentRegisters: Record<string, number> = {
|
|||
verification: 440.0,
|
||||
};
|
||||
|
||||
const partialRatios = buildPartialRatios(10);
|
||||
const partialRatios = buildPartialRatios(0);
|
||||
const audioChunkFrames = 2048;
|
||||
const audioQueueTargetChunks = 8;
|
||||
const audioSpectrumBins = 96;
|
||||
const cyclicTempoClasses = 12;
|
||||
const metricalTempoFactors = [4, 8 / 3, 3, 2, 4 / 3, 3 / 2, 1, 2 / 3, 3 / 4, 1 / 2, 1 / 3];
|
||||
|
||||
const audioSpectrumShader = `#version 300 es
|
||||
precision highp float;
|
||||
|
|
@ -1890,6 +1892,170 @@ function makeNoiseLoopBuffer(context: AudioContext, agent: AquariumAgentFrame) {
|
|||
return buffer;
|
||||
}
|
||||
|
||||
type VocalFormant = {
|
||||
frequency: number;
|
||||
bandwidth: number;
|
||||
gain: number;
|
||||
};
|
||||
|
||||
type VocalProfile = {
|
||||
f0: number;
|
||||
formants: VocalFormant[];
|
||||
phase: number;
|
||||
softness: number;
|
||||
};
|
||||
|
||||
const vocalProfiles: Record<string, VocalProfile> = {
|
||||
coordinator: {
|
||||
f0: 174.61,
|
||||
phase: 0.2,
|
||||
softness: 0.62,
|
||||
formants: [
|
||||
{ frequency: 620, bandwidth: 95, gain: 1.05 },
|
||||
{ frequency: 1040, bandwidth: 150, gain: 0.82 },
|
||||
{ frequency: 2460, bandwidth: 260, gain: 0.46 },
|
||||
{ frequency: 3650, bandwidth: 420, gain: 0.24 },
|
||||
],
|
||||
},
|
||||
imagination: {
|
||||
f0: 523.25,
|
||||
phase: 1.8,
|
||||
softness: 0.44,
|
||||
formants: [
|
||||
{ frequency: 340, bandwidth: 70, gain: 0.78 },
|
||||
{ frequency: 2300, bandwidth: 230, gain: 1.0 },
|
||||
{ frequency: 3050, bandwidth: 330, gain: 0.5 },
|
||||
{ frequency: 4300, bandwidth: 520, gain: 0.26 },
|
||||
],
|
||||
},
|
||||
research: {
|
||||
f0: 659.25,
|
||||
phase: 2.6,
|
||||
softness: 0.5,
|
||||
formants: [
|
||||
{ frequency: 300, bandwidth: 75, gain: 0.72 },
|
||||
{ frequency: 870, bandwidth: 130, gain: 0.9 },
|
||||
{ frequency: 2240, bandwidth: 250, gain: 0.54 },
|
||||
{ frequency: 3500, bandwidth: 430, gain: 0.24 },
|
||||
],
|
||||
},
|
||||
reorientation: {
|
||||
f0: 392,
|
||||
phase: 3.4,
|
||||
softness: 0.58,
|
||||
formants: [
|
||||
{ frequency: 430, bandwidth: 85, gain: 0.92 },
|
||||
{ frequency: 1200, bandwidth: 170, gain: 0.84 },
|
||||
{ frequency: 2600, bandwidth: 280, gain: 0.45 },
|
||||
{ frequency: 3900, bandwidth: 480, gain: 0.22 },
|
||||
],
|
||||
},
|
||||
modeling: {
|
||||
f0: 246.94,
|
||||
phase: 4.2,
|
||||
softness: 0.72,
|
||||
formants: [
|
||||
{ frequency: 720, bandwidth: 120, gain: 1.0 },
|
||||
{ frequency: 1180, bandwidth: 160, gain: 0.76 },
|
||||
{ frequency: 2500, bandwidth: 250, gain: 0.42 },
|
||||
{ frequency: 3600, bandwidth: 420, gain: 0.2 },
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
f0: 329.63,
|
||||
phase: 5.0,
|
||||
softness: 0.46,
|
||||
formants: [
|
||||
{ frequency: 520, bandwidth: 90, gain: 0.86 },
|
||||
{ frequency: 1500, bandwidth: 210, gain: 0.9 },
|
||||
{ frequency: 2700, bandwidth: 280, gain: 0.46 },
|
||||
{ frequency: 4100, bandwidth: 520, gain: 0.22 },
|
||||
],
|
||||
},
|
||||
verification: {
|
||||
f0: 440,
|
||||
phase: 5.8,
|
||||
softness: 0.66,
|
||||
formants: [
|
||||
{ frequency: 270, bandwidth: 65, gain: 0.82 },
|
||||
{ frequency: 720, bandwidth: 120, gain: 0.92 },
|
||||
{ frequency: 2200, bandwidth: 250, gain: 0.5 },
|
||||
{ frequency: 3400, bandwidth: 430, gain: 0.24 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function vocalProfileFor(id: string) {
|
||||
return vocalProfiles[id] ?? vocalProfiles.coordinator;
|
||||
}
|
||||
|
||||
function reverseCyclicTempogram(agent: Pick<ProjectedAgent, "activity" | "phase" | "chirps">, activity: number, panic: number, acknowledgement: number) {
|
||||
const bins = new Float32Array(cyclicTempoClasses);
|
||||
const baseTempo = 44 + activity * 96 + panic * 132 + acknowledgement * 48;
|
||||
const width = 0.86 - panic * 0.32 + acknowledgement * 0.12;
|
||||
for (const factor of metricalTempoFactors) {
|
||||
const tempo = baseTempo * factor;
|
||||
const classPosition = cyclicTempoClass(tempo);
|
||||
const factorWeight = 1 / Math.sqrt(1 + Math.abs(Math.log2(factor)));
|
||||
for (let index = 0; index < bins.length; index += 1) {
|
||||
const distance = cyclicClassDistance(index, classPosition, cyclicTempoClasses);
|
||||
bins[index] += Math.exp(-(distance * distance) / (2 * width * width)) * factorWeight;
|
||||
}
|
||||
}
|
||||
const bodyPulse = 0.35 + Math.abs(agent.chirps.radial ?? 0) * 0.22 + Math.abs(agent.chirps.tangential ?? 0) * 0.18;
|
||||
for (let index = 0; index < bins.length; index += 1) {
|
||||
bins[index] *= bodyPulse * (0.72 + Math.sin(agent.phase + index * 0.7) * 0.08);
|
||||
}
|
||||
let peak = 0;
|
||||
for (const value of bins) peak = Math.max(peak, value);
|
||||
if (peak > 0) {
|
||||
for (let index = 0; index < bins.length; index += 1) {
|
||||
bins[index] = bins[index] / peak;
|
||||
}
|
||||
}
|
||||
return bins;
|
||||
}
|
||||
|
||||
function cyclicTempoClass(tempoBpm: number) {
|
||||
const reference = 60;
|
||||
const cycles = ((Math.log2(Math.max(tempoBpm, 1) / reference) % 1) + 1) % 1;
|
||||
return cycles * cyclicTempoClasses;
|
||||
}
|
||||
|
||||
function cyclicClassDistance(index: number, position: number, modulo: number) {
|
||||
const direct = Math.abs(index - position);
|
||||
return Math.min(direct, modulo - direct);
|
||||
}
|
||||
|
||||
function tempoClassToHz(tempogram: Float32Array, offset: number) {
|
||||
let weighted = 0;
|
||||
let total = 0;
|
||||
for (let index = 0; index < tempogram.length; index += 1) {
|
||||
const weight = tempogram[index];
|
||||
const classTempo = 60 * 2 ** (index / tempogram.length);
|
||||
weighted += classTempo * weight * (1 + ((index + offset) % 3) * 0.08);
|
||||
total += weight;
|
||||
}
|
||||
return (total > 0 ? weighted / total : 90) / 60;
|
||||
}
|
||||
|
||||
function morphFormants(formants: VocalFormant[], shift: number) {
|
||||
return formants.map((formant, index) => ({
|
||||
frequency: formant.frequency * (1 + shift * (0.08 + index * 0.035)),
|
||||
bandwidth: formant.bandwidth * (1 + Math.abs(shift) * 0.28),
|
||||
gain: formant.gain * (1 + shift * (index === 1 ? 0.18 : 0.06)),
|
||||
}));
|
||||
}
|
||||
|
||||
function formantEnvelope(frequency: number, formants: VocalFormant[]) {
|
||||
let gain = 0.035;
|
||||
for (const formant of formants) {
|
||||
const distance = (frequency - formant.frequency) / Math.max(formant.bandwidth, 1);
|
||||
gain += Math.exp(-0.5 * distance * distance) * formant.gain;
|
||||
}
|
||||
return gain;
|
||||
}
|
||||
|
||||
function hashString(value: string) {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
|
|
@ -1956,6 +2122,8 @@ class BufferedGpuSpectrumOutput {
|
|||
mode: this.mode,
|
||||
queuedFrames: this.queue.reduce((total, chunk) => total + chunk.length, -this.queueOffset),
|
||||
bursts: this.burstEvents.length,
|
||||
tempoClasses: cyclicTempoClasses,
|
||||
vocalAgents: this.lastAgents.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2079,33 +2247,79 @@ class BufferedGpuSpectrumOutput {
|
|||
this.bins.fill(0);
|
||||
let bin = 0;
|
||||
for (const agent of this.lastAgents) {
|
||||
const register = agentRegisters[agent.id] ?? 261.63;
|
||||
const energy = 0.0025 + agent.activity * 0.003 + agent.chirps.expression * 0.003 + agent.chirps.panic * 0.018;
|
||||
const ratios = [0.5, 1, 1.5, 2, 2.75, 4, 6, 9];
|
||||
for (let index = 0; index < ratios.length && bin < audioSpectrumBins; index += 1) {
|
||||
const ratio = ratios[index] * (1 + Math.sin(agent.phase + index) * 0.006);
|
||||
this.writeBin(bin, register * ratio, energy / (1 + index * 0.32), agent.phase * (index + 1.7), agent.chirps.radial * 0.012 + agent.chirps.panic * 0.09);
|
||||
bin += 1;
|
||||
}
|
||||
bin = this.writeVocalAgentBins(bin, agent, startSample);
|
||||
}
|
||||
for (const event of this.burstEvents) {
|
||||
const local = (startSample - event.startSample) / Math.max(1, event.durationSamples);
|
||||
if (local < -0.2 || local > 1.1) continue;
|
||||
const register = agentRegisters[event.agent.id] ?? 261.63;
|
||||
const envelope = Math.max(0, Math.sin(Math.PI * clamp(local, 0, 1)));
|
||||
const random = mulberry32(event.seed);
|
||||
const count = event.action === "notification" ? 24 : event.action === "selected" ? 18 : 14;
|
||||
for (let index = 0; index < count && bin < audioSpectrumBins; index += 1) {
|
||||
const harmonic = 1 + index * (event.action === "selected" ? 0.62 : 0.9) + random() * 0.18;
|
||||
const amp = envelope * (event.action === "notification" ? 0.045 : 0.032) / Math.sqrt(index + 1);
|
||||
const chirp = (random() - 0.5) * (event.action === "notification" ? 0.7 : 0.38);
|
||||
this.writeBin(bin, clamp(register * harmonic, 42, 15000), amp, random() * Math.PI * 2, chirp);
|
||||
bin += 1;
|
||||
}
|
||||
bin = this.writeEventVocalExcitation(bin, event, startSample);
|
||||
}
|
||||
this.binCount = bin;
|
||||
}
|
||||
|
||||
private writeVocalAgentBins(bin: number, agent: ProjectedAgent, startSample: number) {
|
||||
const profile = vocalProfileFor(agent.id);
|
||||
const tempogram = reverseCyclicTempogram(agent, agent.activity, agent.chirps.panic, agent.chirps.acknowledgement);
|
||||
const tempEnergy = tempogram.reduce((total, value) => total + value, 0) / tempogram.length;
|
||||
const vowelShift = clamp(agent.chirps.radial * 0.18 + agent.chirps.tangential * 0.12 + agent.chirps.panic * 0.34 + agent.chirps.acknowledgement * 0.28, -0.45, 0.65);
|
||||
const formants = morphFormants(profile.formants, vowelShift);
|
||||
const base = profile.f0 * (1 + agent.chirps.tangential * 0.006 + agent.chirps.acknowledgement * 0.025);
|
||||
const breath = 0.0018 + agent.activity * 0.0018 + agent.chirps.panic * 0.009;
|
||||
const harmonics = 13;
|
||||
for (let harmonic = 1; harmonic <= harmonics && bin < audioSpectrumBins; harmonic += 1) {
|
||||
const frequency = base * harmonic;
|
||||
if (frequency > 13500) break;
|
||||
const tempoClass = tempogram[harmonic % tempogram.length];
|
||||
const formant = formantEnvelope(frequency, formants);
|
||||
const glottalTilt = 1 / Math.pow(harmonic, 1.18 + profile.softness * 0.38);
|
||||
const amplitude = (0.004 + tempEnergy * 0.012 + breath) * glottalTilt * formant * (0.56 + tempoClass * 1.4);
|
||||
const tempoHz = tempoClassToHz(tempogram, harmonic + Math.floor(agent.phase));
|
||||
const chirp = tempoHz * (0.0018 + agent.chirps.panic * 0.006 + agent.chirps.acknowledgement * 0.004);
|
||||
this.writeBin(bin, frequency, amplitude, profile.phase + agent.phase * harmonic + startSample * 0.000001 * harmonic, chirp);
|
||||
bin += 1;
|
||||
}
|
||||
const breathBands = [0.65, 1.2, 2.1, 3.7];
|
||||
for (let index = 0; index < breathBands.length && bin < audioSpectrumBins; index += 1) {
|
||||
const center = formants[Math.min(index, formants.length - 1)].frequency * breathBands[index];
|
||||
const tempoClass = tempogram[(index * 3 + Math.floor(agent.phase)) % tempogram.length];
|
||||
this.writeBin(bin, clamp(center, 120, 12000), breath * (0.18 + tempoClass * 0.6), profile.phase * (index + 2.3), tempoClass * 0.012);
|
||||
bin += 1;
|
||||
}
|
||||
return bin;
|
||||
}
|
||||
|
||||
private writeEventVocalExcitation(bin: number, event: SpectralBurst, startSample: number) {
|
||||
const local = (startSample - event.startSample) / Math.max(1, event.durationSamples);
|
||||
if (local < -0.08 || local > 1.1) return bin;
|
||||
const profile = vocalProfileFor(event.agent.id);
|
||||
const envelope = Math.exp(-Math.max(0, local) * (event.action === "notification" ? 3.2 : event.action === "selected" ? 4.8 : 6.4)) *
|
||||
Math.sin(Math.PI * clamp(local + 0.04, 0, 1));
|
||||
const random = mulberry32(event.seed);
|
||||
const fakeAgent = {
|
||||
...event.agent,
|
||||
chirps: {
|
||||
acknowledgement: event.action === "touch" ? envelope : 0.4 * envelope,
|
||||
panic: event.action === "notification" ? 0.9 : 0.25,
|
||||
radial: envelope * 0.8,
|
||||
tangential: envelope * 0.5,
|
||||
},
|
||||
activity: event.action === "notification" ? 0.9 : 0.55,
|
||||
} as ProjectedAgent;
|
||||
const tempogram = reverseCyclicTempogram(fakeAgent, fakeAgent.activity, fakeAgent.chirps.panic, fakeAgent.chirps.acknowledgement);
|
||||
const formants = morphFormants(profile.formants, event.action === "notification" ? 0.55 : event.action === "selected" ? 0.16 : -0.22);
|
||||
const count = event.action === "notification" ? 30 : event.action === "selected" ? 22 : 16;
|
||||
for (let index = 0; index < count && bin < audioSpectrumBins; index += 1) {
|
||||
const harmonic = 1 + index * (event.action === "selected" ? 0.55 : 0.82) + random() * 0.16;
|
||||
const base = profile.f0 * harmonic * (1 + random() * 0.018);
|
||||
const tempoClass = tempogram[index % tempogram.length];
|
||||
const formant = formantEnvelope(base, formants);
|
||||
const stringDamping = Math.exp(-index * 0.055) * envelope;
|
||||
const amplitude = stringDamping * (event.action === "notification" ? 0.052 : 0.036) * formant * (0.32 + tempoClass);
|
||||
const chirp = (random() - 0.5) * (event.action === "notification" ? 0.34 : 0.18) + tempoClass * 0.03;
|
||||
this.writeBin(bin, clamp(base, 60, 15000), amplitude, random() * Math.PI * 2, chirp);
|
||||
bin += 1;
|
||||
}
|
||||
return bin;
|
||||
}
|
||||
|
||||
private currentBinCount() {
|
||||
return Math.min(audioSpectrumBins, Math.max(0, this.binCount));
|
||||
}
|
||||
|
|
@ -2202,8 +2416,8 @@ class AquariumSoundscape {
|
|||
0,
|
||||
1,
|
||||
);
|
||||
voice.gain.gain.setTargetAtTime(0.16 + energy * 0.42, now, 0.08);
|
||||
voice.noiseGain.gain.setTargetAtTime(0.018 + energy * 0.046 + agent.chirps.panic * 0.08, now, 0.12);
|
||||
voice.gain.gain.setTargetAtTime(0, now, 0.08);
|
||||
voice.noiseGain.gain.setTargetAtTime(0, now, 0.12);
|
||||
voice.filter.frequency.setTargetAtTime(clamp(register * (4.6 + agent.chirps.glowPulse * 1.2 + agent.chirps.panic * 4.2), 60, 18000), now, 0.12);
|
||||
voice.filter.Q.setTargetAtTime(0.22 + agent.chirps.panic * 1.8 + agent.hover * 0.32, now, 0.1);
|
||||
voice.panner.pan.setTargetAtTime(clamp((agent.baseX / 100) * 2 - 1, -0.86, 0.86), now, 0.18);
|
||||
|
|
@ -2234,42 +2448,8 @@ class AquariumSoundscape {
|
|||
this.resume();
|
||||
return;
|
||||
}
|
||||
const cacheKey = `${agent.id}:${action}`;
|
||||
let buffer = this.burstCache.get(cacheKey);
|
||||
if (!buffer) {
|
||||
const generated = makeChirpBurstBuffer(this.context, agent, action);
|
||||
buffer = generated.buffer;
|
||||
this.burstCache.set(cacheKey, buffer);
|
||||
this.lastBurstChirps = generated.chirpCount;
|
||||
} else {
|
||||
this.lastBurstChirps = chirpCountFor(action);
|
||||
}
|
||||
this.lastBurstChirps = chirpCountFor(action);
|
||||
this.spectralOutput.triggerBurst(agent, action);
|
||||
const source = this.context.createBufferSource();
|
||||
const gain = this.context.createGain();
|
||||
const filter = this.context.createBiquadFilter();
|
||||
const panner = this.context.createStereoPanner();
|
||||
const now = this.context.currentTime;
|
||||
source.buffer = buffer;
|
||||
filter.type = action === "notification" ? "highpass" : action === "selected" ? "peaking" : "bandpass";
|
||||
filter.frequency.value = (agentRegisters[agent.id] ?? 261.63) * (action === "notification" ? 2.6 : action === "selected" ? 3.4 : 4.6);
|
||||
filter.Q.value = action === "notification" ? 0.78 : action === "selected" ? 1.4 : 1.1;
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(action === "notification" ? 0.22 : action === "selected" ? 0.2 : 0.18, now + 0.035);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + buffer.duration);
|
||||
panner.pan.value = clamp((agent.baseX / 100) * 2 - 1, -0.88, 0.88);
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(panner);
|
||||
panner.connect(this.master);
|
||||
source.start(now);
|
||||
source.stop(now + buffer.duration + 0.02);
|
||||
source.addEventListener("ended", () => {
|
||||
source.disconnect();
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
panner.disconnect();
|
||||
}, { once: true });
|
||||
this.publishDebugState(action);
|
||||
}
|
||||
|
||||
|
|
|
|||
Reference in a new issue