Add vocal tract control playground

This commit is contained in:
Meta Vens 2026-05-25 08:13:52 +01:00
commit da117431db
8 changed files with 770 additions and 0 deletions

View file

@ -127,3 +127,4 @@
{"date":"2026-05-20","topic":"weksa-v01-training-artifact","lesson":"The Weksa blocker has moved from schema definition to artifact consumption. Weksa now emits a machine-readable `weksa.utterance_embedding_handoff.v0.1` tiny PanPhon seed batch for `a`, `pa`, `ta`, `ka`, `sa`, and `ma`; AquaSynth's next serious training pass should load that artifact rather than local toy vectors.","evidence":"Added Weksa emitter `E:/Projects/weksa/tools/emit_tiny_panphon_handoffs.py` and generated `E:/Projects/weksa/examples/speech-training/tiny-panphon-v0.1/batch.json` with fixed 1024/32/64 input widths, inline PanPhon 22-feature frames, and pending AquaSynth-owned 256/64 learned outputs."}
{"date":"2026-05-24","topic":"cultmesh-speech-worker-authority","lesson":"CultMesh should distribute speech render/scoring work, not optimizer authority. AquaSynth emits typed SpeechRenderRequest documents; CultMesh delivers compiled Faust renderers, scoring code, small model/encoder assemblies, and batch/artifact refs to compatible leased workers; workers return SpeechRenderResult documents with loss, gradients, artifacts, and timing receipts. The authority trainer or shard-primary applies gradients through SpeechBackpropagationPipeline and commits SpeechTrainingCheckpoint documents through normal CultCache/CultMesh shard authority.","evidence":"Recorded the authority map in docs/ipa-vocal-tract-roadmap.md, state/spine.yaml, state/map.yaml, state/memory.json, and state/scratch.md. The next implementation layer is transport, admission, worker leases, payload delivery, and result collection; federated averaging waits until the simple authority path is boring."}
{"date":"2026-05-24","topic":"cultmesh-speech-training-step","lesson":"AquaSynth can now run the first honest CultMesh-distributed speech training step: an authority trainer emits SpeechRenderRequest documents and a SpeechWorkerPayloadManifest, admits only CultMesh peers with valid speech-render-worker leases, records SpeechWorkerAdmissionReceipt documents for admitted/rejected peers, lets admitted workers produce SpeechRenderResult gradients, and applies those gradients centrally through SpeechBackpropagationPipeline. Workers still do not write checkpoints.","evidence":"Added GameCult.Mesh to AquaSynth.Core, added payload/admission/assignment/training-step types and store helpers in SpeechDistributedTraining.cs, added RunCultMeshTrainingStep, and added a test with two leased workers plus rejected unleased/read-replica peers. Focused verification: `dotnet test tests/AquaSynth.Dsl.Tests/AquaSynth.Dsl.Tests.csproj --filter \"SpeechDistributedTrainingTests\" -v minimal`: 3 passed. Remaining infrastructure gap: real remote artifact chunk transfer and process supervision belong to CultMesh, not an AquaSynth-local workaround."}
{"date":"2026-05-25","topic":"vocal-tract-control-playground","lesson":"The current AquaSynth vocal-tract lane is touchable as a control surface before it is a Pink Trombone-class tract simulation. A useful playground should expose the real VocalTractControlTarget knobs directly, make the tract-ish shape visible, and stay honest that the WebAudio renderer is a lab proxy rather than full waveguide anatomy.","evidence":"Added tools/vocal-tract-playground with static HTML/CSS/JS, a Node static server, WebAudio source/filter synthesis, tract canvas visualization, presets, and sliders for the 14 base controls plus six mel bands. Verified with `node --check` on app.js/serve.mjs, curl against http://localhost:5125/, and browser interaction: Play toggled audio, Ee preset updated controls and canvas, console errors were empty."}

View file

@ -26,6 +26,7 @@ surfaces:
zyn_reference_build_script: tools/zyn-reference/build-zyn-reference.ps1
zyn_pad_reference_renderer: tools/zyn-reference/ZynPadReference.cpp
zyn_voice_reference_renderer: tools/zyn-reference/ZynVoiceReference.cpp
vocal_tract_playground: tools/vocal-tract-playground/index.html
zyn_reference_build_patches: tools/zyn-reference/patches
zyn_project_fixtures: tests/AquaSynth.Dsl.Tests/Fixtures/ZynAddSubFX/ProjectAuthored
core_package: AquaSynth.Core 0.1.0-alpha.3
@ -58,6 +59,7 @@ modules:
speech_backpropagation_pipeline_tests: tests/AquaSynth.Dsl.Tests/SpeechBackpropagationPipelineTests.cs
speech_distributed_training_tests: tests/AquaSynth.Dsl.Tests/SpeechDistributedTrainingTests.cs
vocal_tract_learning_tests: tests/AquaSynth.Dsl.Tests/VocalTractLearningTests.cs
vocal_tract_playground: tools/vocal-tract-playground
sfxr: src/AquaSynth.Core/SfxrParams.cs
analysis: src/AquaSynth.Core/AudioAnalysis.cs
mel_cepstral_warp_effect: src/AquaSynth.Core/MelCepstralWarpEffect.cs

View file

@ -2,6 +2,20 @@
Current slice:
- Added `tools/vocal-tract-playground`, a static WebAudio playground for the
current AquaSynth `VocalTractControlTarget` surface. It exposes the 14 base
speech controls plus six mel-envelope bands, draws a tract-ish canvas, and
includes vowel/noise/nasal presets.
- This is intentionally a touchable control playground, not a claim of
Pink Trombone-class tract physics. The canvas and audio are tied to the real
AquaSynth control names so future renderer work has an inspectable handle.
- Verification:
`node --check tools\vocal-tract-playground\app.js`
and `node --check tools\vocal-tract-playground\serve.mjs`: passed.
Local server `node tools\vocal-tract-playground\serve.mjs` served
`http://localhost:5125/`; browser check rendered the UI, Play toggled audio,
the `Ee` preset moved sliders/visuals, and console errors were empty.
- Added `MelCepstralWarpEffect` as a real AquaSynth offline/block audio effect:
STFT -> log-mel -> DCT cepstra -> Simplex domain warp -> separable 5-tap
Gaussian blur -> inverse cepstra/log-mel -> original-phase overlap-add

View file

@ -0,0 +1,20 @@
# AquaSynth Vocal Tract Playground
Touchable WebAudio playground for the current `VocalTractControlTarget` surface.
Run it from this directory:
```powershell
node .\serve.mjs
```
Then open:
```text
http://localhost:5125/
```
This is a control-surface witness, not a Pink Trombone-class tract simulation.
It exposes the real AquaSynth speech-driver controls, draws a tract-shaped
visualization, and uses a small WebAudio source/filter proxy so the knobs are
audible while the proper tract renderer continues to mature.

View file

@ -0,0 +1,412 @@
"use strict";
const controlSpecs = [
["Tongue body", "tongueBody", "tract", 0.56],
["Tongue tip", "tongueTip", "tract", 0.42],
["Lip aperture", "lipAperture", "tract", 0.72],
["Lip rounding", "lipRounding", "tract", 0.18],
["Velum", "velum", "tract", 0.18],
["Glottal tenseness", "glottalTenseness", "source", 0.48],
["Turbulence", "turbulence", "source", 0.14],
["Pressure", "pressure", "source", 0.68],
["AM depth", "amDepth", "source", 0.08],
["FM depth", "fmDepth", "source", 0.05],
["LFO rate", "lfoRate", "source", 0.22],
["LFO depth", "lfoDepth", "source", 0.06],
["Filter cutoff", "filterCutoff", "spectral", 0.58],
["Filter resonance", "filterResonance", "spectral", 0.22],
["Mel low", "mel0", "spectral", 0.62],
["Mel low-mid", "mel1", "spectral", 0.55],
["Mel mid", "mel2", "spectral", 0.48],
["Mel high-mid", "mel3", "spectral", 0.42],
["Mel high", "mel4", "spectral", 0.36],
["Mel air", "mel5", "spectral", 0.30]
];
const presets = {
open: {
tongueBody: 0.42, tongueTip: 0.30, lipAperture: 0.92, lipRounding: 0.05,
velum: 0.12, glottalTenseness: 0.42, turbulence: 0.04, pressure: 0.76,
amDepth: 0.04, fmDepth: 0.04, lfoRate: 0.18, lfoDepth: 0.03,
filterCutoff: 0.68, filterResonance: 0.20, mel0: 0.78, mel1: 0.68,
mel2: 0.54, mel3: 0.40, mel4: 0.30, mel5: 0.22
},
ee: {
tongueBody: 0.82, tongueTip: 0.72, lipAperture: 0.46, lipRounding: 0.02,
velum: 0.10, glottalTenseness: 0.58, turbulence: 0.05, pressure: 0.68,
amDepth: 0.03, fmDepth: 0.08, lfoRate: 0.26, lfoDepth: 0.04,
filterCutoff: 0.78, filterResonance: 0.32, mel0: 0.35, mel1: 0.52,
mel2: 0.82, mel3: 0.72, mel4: 0.58, mel5: 0.36
},
oo: {
tongueBody: 0.30, tongueTip: 0.24, lipAperture: 0.36, lipRounding: 0.88,
velum: 0.11, glottalTenseness: 0.44, turbulence: 0.03, pressure: 0.70,
amDepth: 0.03, fmDepth: 0.03, lfoRate: 0.16, lfoDepth: 0.03,
filterCutoff: 0.36, filterResonance: 0.38, mel0: 0.82, mel1: 0.70,
mel2: 0.38, mel3: 0.22, mel4: 0.18, mel5: 0.12
},
ss: {
tongueBody: 0.68, tongueTip: 0.86, lipAperture: 0.32, lipRounding: 0.02,
velum: 0.08, glottalTenseness: 0.10, turbulence: 0.92, pressure: 0.78,
amDepth: 0.18, fmDepth: 0.12, lfoRate: 0.36, lfoDepth: 0.05,
filterCutoff: 0.88, filterResonance: 0.62, mel0: 0.12, mel1: 0.18,
mel2: 0.36, mel3: 0.76, mel4: 0.92, mel5: 0.86
},
ma: {
tongueBody: 0.48, tongueTip: 0.38, lipAperture: 0.28, lipRounding: 0.18,
velum: 0.78, glottalTenseness: 0.40, turbulence: 0.04, pressure: 0.64,
amDepth: 0.08, fmDepth: 0.04, lfoRate: 0.20, lfoDepth: 0.05,
filterCutoff: 0.42, filterResonance: 0.28, mel0: 0.74, mel1: 0.72,
mel2: 0.58, mel3: 0.40, mel4: 0.30, mel5: 0.22
}
};
const state = Object.fromEntries(controlSpecs.map(([, id, , value]) => [id, value]));
const controls = new Map();
let audio = null;
let animationFrame = 0;
let phase = 0;
const canvas = document.getElementById("tractCanvas");
const ctx = canvas.getContext("2d");
const controlsRoot = document.getElementById("controls");
const playButton = document.getElementById("playButton");
const panicButton = document.getElementById("panicButton");
const pressureMeter = document.getElementById("pressureMeter");
const noiseMeter = document.getElementById("noiseMeter");
const nasalMeter = document.getElementById("nasalMeter");
function makeControls() {
for (const [label, id, family, value] of controlSpecs) {
const row = document.createElement("div");
row.className = "control";
row.dataset.family = family;
const labelEl = document.createElement("label");
labelEl.htmlFor = id;
labelEl.textContent = label;
const valueEl = document.createElement("output");
valueEl.className = "value";
valueEl.htmlFor = id;
const input = document.createElement("input");
input.id = id;
input.type = "range";
input.min = "0";
input.max = "1";
input.step = "0.001";
input.value = value.toString();
input.addEventListener("input", () => {
state[id] = Number(input.value);
valueEl.value = state[id].toFixed(3);
updateAudio();
});
row.append(labelEl, valueEl, input);
controlsRoot.append(row);
controls.set(id, { input, valueEl });
}
syncControls();
}
function syncControls() {
for (const [id, pair] of controls) {
pair.input.value = state[id].toString();
pair.valueEl.value = state[id].toFixed(3);
}
updateMeters();
draw();
}
function applyPreset(name) {
Object.assign(state, presets[name]);
syncControls();
updateAudio();
}
function createNoiseBuffer(context) {
const buffer = context.createBuffer(1, context.sampleRate * 2, context.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}
async function startAudio() {
if (audio) {
return;
}
const context = new AudioContext();
const master = context.createGain();
const voice = context.createOscillator();
const voiceGain = context.createGain();
const noise = context.createBufferSource();
const noiseGain = context.createGain();
const nasal = context.createBiquadFilter();
const low = context.createBiquadFilter();
const mid = context.createBiquadFilter();
const high = context.createBiquadFilter();
const air = context.createBiquadFilter();
voice.type = "sawtooth";
noise.buffer = createNoiseBuffer(context);
noise.loop = true;
nasal.type = "bandpass";
low.type = "peaking";
mid.type = "peaking";
high.type = "peaking";
air.type = "highpass";
voice.connect(voiceGain).connect(nasal).connect(low).connect(mid).connect(high).connect(air).connect(master);
noise.connect(noiseGain).connect(high);
master.connect(context.destination);
master.gain.value = 0.0;
voice.start();
noise.start();
audio = { context, master, voice, voiceGain, noiseGain, nasal, low, mid, high, air };
updateAudio();
playButton.textContent = "Stop";
playButton.setAttribute("aria-pressed", "true");
}
async function stopAudio() {
if (!audio) {
return;
}
const old = audio;
audio = null;
old.master.gain.setTargetAtTime(0, old.context.currentTime, 0.02);
await new Promise(resolve => setTimeout(resolve, 80));
await old.context.close();
playButton.textContent = "Play";
playButton.setAttribute("aria-pressed", "false");
}
function updateAudio() {
updateMeters();
if (!audio) {
return;
}
const t = audio.context.currentTime;
const pitch = 82 + state.pressure * 120 + state.glottalTenseness * 80;
const wobble = 1 + Math.sin(t * (0.5 + state.lfoRate * 8)) * state.lfoDepth * 0.08;
const rounded = 1 - state.lipRounding * 0.38;
const aperture = 0.25 + state.lipAperture * 0.85;
audio.voice.frequency.setTargetAtTime(pitch * wobble * (1 + state.fmDepth * 0.12), t, 0.015);
audio.voiceGain.gain.setTargetAtTime((1 - state.turbulence * 0.75) * state.pressure * aperture * 0.24, t, 0.025);
audio.noiseGain.gain.setTargetAtTime(state.turbulence * state.pressure * (0.05 + state.tongueTip * 0.22), t, 0.02);
audio.master.gain.setTargetAtTime(0.72, t, 0.03);
audio.nasal.frequency.setTargetAtTime(220 + state.velum * 260, t, 0.02);
audio.nasal.Q.setTargetAtTime(0.5 + state.velum * 5, t, 0.02);
audio.nasal.gain?.setTargetAtTime?.(state.velum * 8, t, 0.02);
audio.low.frequency.setTargetAtTime((280 + state.tongueBody * 620) * rounded, t, 0.02);
audio.low.Q.setTargetAtTime(0.7 + state.filterResonance * 8, t, 0.02);
audio.low.gain.setTargetAtTime((state.mel0 - 0.5) * 18, t, 0.02);
audio.mid.frequency.setTargetAtTime(780 + state.tongueTip * 1450 + state.lipAperture * 300, t, 0.02);
audio.mid.Q.setTargetAtTime(0.8 + state.filterResonance * 7, t, 0.02);
audio.mid.gain.setTargetAtTime(((state.mel1 + state.mel2) * 0.5 - 0.5) * 18, t, 0.02);
audio.high.frequency.setTargetAtTime(1900 + state.tongueTip * 2600 + state.filterCutoff * 1800, t, 0.02);
audio.high.Q.setTargetAtTime(0.7 + state.filterResonance * 10, t, 0.02);
audio.high.gain.setTargetAtTime(((state.mel3 + state.mel4) * 0.5 - 0.5) * 22, t, 0.02);
audio.air.frequency.setTargetAtTime(3200 + state.filterCutoff * 6500, t, 0.02);
audio.air.Q.setTargetAtTime(0.7, t, 0.02);
}
function updateMeters() {
pressureMeter.value = state.pressure;
noiseMeter.value = state.turbulence;
nasalMeter.value = state.velum;
}
function draw() {
const width = canvas.width;
const height = canvas.height;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#111714";
ctx.fillRect(0, 0, width, height);
const cx = width * 0.5;
const cy = height * 0.54;
const time = performance.now() * 0.001;
phase += 0.018 + state.pressure * 0.02;
drawGrid(width, height);
drawTract(cx, cy, width * 0.76, height * 0.42, time);
drawSpectralBars(width, height);
drawReadout(width, height);
animationFrame = requestAnimationFrame(draw);
}
function drawGrid(width, height) {
ctx.strokeStyle = "rgba(168, 179, 163, 0.11)";
ctx.lineWidth = 1;
for (let x = 70; x < width; x += 70) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 60; y < height; y += 60) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
}
function drawTract(cx, cy, length, scale, time) {
const pointsTop = [];
const pointsBottom = [];
const samples = 72;
const lipPinch = 1 - state.lipAperture * 0.72;
const rounding = state.lipRounding * 0.18;
const body = state.tongueBody;
const tip = state.tongueTip;
const velum = state.velum;
for (let i = 0; i <= samples; i++) {
const u = i / samples;
const x = cx - length / 2 + u * length;
const tongueBody = Math.exp(-Math.pow((u - 0.48) / 0.22, 2)) * body * 0.52;
const tongueTip = Math.exp(-Math.pow((u - 0.76) / 0.12, 2)) * tip * 0.50;
const lips = Math.exp(-Math.pow((u - 0.97) / 0.08, 2)) * lipPinch;
const throat = Math.exp(-Math.pow((u - 0.10) / 0.16, 2)) * (0.12 + state.glottalTenseness * 0.18);
const pulse = Math.sin(time * (2 + state.lfoRate * 8) + u * 7) * state.amDepth * 0.08;
const radius = Math.max(0.10, 0.62 - tongueBody - tongueTip - lips - throat + rounding + pulse);
const yTop = cy - radius * scale * 0.5;
const yBottom = cy + radius * scale * 0.5;
pointsTop.push([x, yTop]);
pointsBottom.push([x, yBottom]);
}
const grad = ctx.createLinearGradient(cx - length / 2, cy, cx + length / 2, cy);
grad.addColorStop(0, "#f47f67");
grad.addColorStop(0.46, "#f2b84b");
grad.addColorStop(1, "#7fd18b");
ctx.beginPath();
for (let i = 0; i < pointsTop.length; i++) {
const [x, y] = pointsTop[i];
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
for (let i = pointsBottom.length - 1; i >= 0; i--) {
const [x, y] = pointsBottom[i];
ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fillStyle = "rgba(127, 209, 139, 0.17)";
ctx.fill();
ctx.strokeStyle = grad;
ctx.lineWidth = 4;
ctx.stroke();
const glottisX = cx - length * 0.52;
const lipX = cx + length * 0.52;
drawSource(glottisX, cy, scale);
drawLips(lipX, cy, scale);
drawNasalBranch(cx + length * 0.20, cy, scale, velum);
drawWave(cx, cy, length, scale);
}
function drawSource(x, y, scale) {
const height = scale * (0.24 + state.glottalTenseness * 0.22);
ctx.fillStyle = "#f47f67";
ctx.fillRect(x - 18, y - height / 2, 10, height);
ctx.fillRect(x + 8, y - height / 2, 10, height);
ctx.fillStyle = "rgba(244, 127, 103, 0.25)";
ctx.beginPath();
ctx.arc(x, y, 28 + state.pressure * 24, 0, Math.PI * 2);
ctx.fill();
}
function drawLips(x, y, scale) {
const aperture = 8 + state.lipAperture * 44;
const round = 18 + state.lipRounding * 36;
ctx.strokeStyle = "#7fd18b";
ctx.lineWidth = 5;
ctx.beginPath();
ctx.ellipse(x, y, round, aperture, 0, 0, Math.PI * 2);
ctx.stroke();
}
function drawNasalBranch(x, y, scale, velum) {
ctx.strokeStyle = `rgba(118, 199, 212, ${0.2 + velum * 0.75})`;
ctx.lineWidth = 8 + velum * 10;
ctx.beginPath();
ctx.moveTo(x, y - scale * 0.16);
ctx.bezierCurveTo(x + scale * 0.12, y - scale * 0.55, x + scale * 0.40, y - scale * 0.62, x + scale * 0.60, y - scale * 0.38);
ctx.stroke();
}
function drawWave(cx, cy, length, scale) {
ctx.strokeStyle = "rgba(240, 244, 237, 0.45)";
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i <= 160; i++) {
const u = i / 160;
const x = cx - length / 2 + u * length;
const amp = (0.12 + state.pressure * 0.22) * scale * (1 - state.turbulence * 0.35);
const y = cy + Math.sin(u * 18 + phase) * amp * Math.sin(Math.PI * u);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
function drawSpectralBars(width, height) {
const values = [state.mel0, state.mel1, state.mel2, state.mel3, state.mel4, state.mel5];
const x0 = width * 0.08;
const y0 = height * 0.91;
const barW = Math.max(16, width * 0.035);
const gap = barW * 0.45;
for (let i = 0; i < values.length; i++) {
const h = values[i] * height * 0.20;
ctx.fillStyle = i < 2 ? "#f2b84b" : i < 4 ? "#7fd18b" : "#76c7d4";
ctx.fillRect(x0 + i * (barW + gap), y0 - h, barW, h);
}
}
function drawReadout(width, height) {
ctx.fillStyle = "#f0f4ed";
ctx.font = "700 22px system-ui, sans-serif";
ctx.fillText(`vector[20] pressure=${state.pressure.toFixed(2)} noise=${state.turbulence.toFixed(2)} velum=${state.velum.toFixed(2)}`, 28, 42);
ctx.fillStyle = "#a8b3a3";
ctx.font = "15px system-ui, sans-serif";
ctx.fillText("AquaSynth control target, not a claimed full tract simulation", 28, height - 24);
}
document.querySelectorAll("[data-preset]").forEach(button => {
button.addEventListener("click", () => applyPreset(button.dataset.preset));
});
playButton.addEventListener("click", async () => {
if (audio) {
await stopAudio();
} else {
await startAudio();
}
});
panicButton.addEventListener("click", stopAudio);
makeControls();
cancelAnimationFrame(animationFrame);
draw();

View file

@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AquaSynth Vocal Tract Playground</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<main class="app">
<section class="stage">
<header class="topbar">
<div>
<p class="eyebrow">AquaSynth lab</p>
<h1>Vocal Tract Playground</h1>
</div>
<div class="transport" role="group" aria-label="Transport">
<button id="playButton" type="button" aria-pressed="false" title="Start or stop audio">Play</button>
<button id="panicButton" type="button" title="Silence immediately">Panic</button>
</div>
</header>
<div class="visual-shell">
<canvas id="tractCanvas" width="1100" height="520" aria-label="Vocal tract control visualization"></canvas>
<div class="meter-row" aria-label="Output meters">
<meter id="pressureMeter" min="0" max="1" value="0"></meter>
<meter id="noiseMeter" min="0" max="1" value="0"></meter>
<meter id="nasalMeter" min="0" max="1" value="0"></meter>
</div>
</div>
</section>
<aside class="panel" aria-label="Controls">
<section class="preset-strip" aria-label="Presets">
<button type="button" data-preset="open">Open</button>
<button type="button" data-preset="ee">Ee</button>
<button type="button" data-preset="oo">Oo</button>
<button type="button" data-preset="ss">Ss</button>
<button type="button" data-preset="ma">Ma</button>
</section>
<section class="knob-grid" id="controls"></section>
</aside>
</main>
<script src="./app.js"></script>
</body>
</html>

View file

@ -0,0 +1,37 @@
import { createReadStream, existsSync, statSync } from "node:fs";
import { createServer } from "node:http";
import { extname, join, normalize, resolve, sep } from "node:path";
const root = resolve(import.meta.dirname);
const port = Number.parseInt(process.env.PORT ?? "5125", 10);
const host = process.env.HOST ?? "127.0.0.1";
const types = new Map([
[".html", "text/html; charset=utf-8"],
[".css", "text/css; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"]
]);
function fileFor(url) {
const requestPath = decodeURIComponent(new URL(url, `http://${host}:${port}`).pathname);
const normalized = normalize(requestPath === "/" ? "/index.html" : requestPath);
const file = resolve(join(root, normalized));
return file === root || file.startsWith(root + sep) ? file : "";
}
createServer((request, response) => {
const file = fileFor(request.url ?? "/");
if (!file || !existsSync(file) || !statSync(file).isFile()) {
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
response.end("not found");
return;
}
response.writeHead(200, {
"cache-control": "no-store",
"content-type": types.get(extname(file)) ?? "application/octet-stream"
});
createReadStream(file).pipe(response);
}).listen(port, host, () => {
console.log(`AquaSynth vocal tract playground: http://${host}:${port}/`);
});

View file

@ -0,0 +1,236 @@
:root {
color-scheme: dark;
--bg: #111412;
--ink: #f0f4ed;
--muted: #a8b3a3;
--line: #344035;
--panel: #1a211c;
--panel-2: #202822;
--accent: #7fd18b;
--accent-2: #f2b84b;
--accent-3: #f47f67;
--water: #76c7d4;
--shadow: rgba(0, 0, 0, 0.32);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background:
linear-gradient(180deg, rgba(127, 209, 139, 0.08), transparent 34rem),
var(--bg);
color: var(--ink);
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
button,
input {
font: inherit;
}
button {
min-height: 2.25rem;
border: 1px solid var(--line);
border-radius: 6px;
background: #263229;
color: var(--ink);
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
button:focus-visible,
input:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.app {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(20rem, 25rem);
gap: 1rem;
min-height: 100vh;
padding: 1rem;
}
.stage,
.panel {
min-width: 0;
}
.stage {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 1rem;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 4rem;
}
.eyebrow {
margin: 0 0 0.1rem;
color: var(--accent);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(1.4rem, 2.6vw, 2.6rem);
line-height: 1.05;
}
.transport {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.transport button {
width: 5.75rem;
}
#playButton[aria-pressed="true"] {
background: var(--accent);
color: #0c140e;
border-color: var(--accent);
}
.visual-shell {
display: grid;
grid-template-rows: minmax(18rem, 1fr) auto;
gap: 0.75rem;
min-height: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: #151a17;
box-shadow: 0 18px 40px var(--shadow);
overflow: hidden;
}
canvas {
width: 100%;
height: 100%;
min-height: 18rem;
display: block;
}
.meter-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
padding: 0 0.75rem 0.75rem;
}
meter {
width: 100%;
height: 0.9rem;
}
.panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.75rem;
max-height: calc(100vh - 2rem);
}
.preset-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.45rem;
}
.preset-strip button {
min-width: 0;
}
.knob-grid {
display: grid;
gap: 0.55rem;
overflow: auto;
padding-right: 0.15rem;
}
.control {
display: grid;
grid-template-columns: minmax(7rem, 1fr) 4.2rem;
gap: 0.45rem 0.65rem;
align-items: center;
padding: 0.65rem;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.control label {
min-width: 0;
font-weight: 650;
}
.value {
text-align: right;
color: var(--accent-2);
font-variant-numeric: tabular-nums;
}
.control input[type="range"] {
grid-column: 1 / -1;
width: 100%;
accent-color: var(--accent);
}
.control[data-family="source"] input[type="range"] {
accent-color: var(--accent-3);
}
.control[data-family="spectral"] input[type="range"] {
accent-color: var(--water);
}
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
}
.panel {
max-height: none;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 520px) {
.app {
padding: 0.75rem;
}
.preset-strip {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.control {
grid-template-columns: minmax(0, 1fr) 3.5rem;
}
}