Route tract playground through graph renderer
This commit is contained in:
parent
3373205bd9
commit
d1e55c5872
10 changed files with 353 additions and 182 deletions
|
|
@ -156,3 +156,4 @@
|
|||
{"timestamp":"2026-05-26T20:55:00+01:00","topic":"physical-voice-side-branch-scattering-blocker","claim":"Nasal graph parity is blocked by the side-branch port law, not by a missing scalar gain. Terminal-specific admittance in connection pressure is structurally cleaner but does not move PT log-mel metrics. Per-port outgoing coupling and squared generated velum opening both suppress nasal leakage only by making oral fixtures nearly silent, showing that the current pressure-wave normalization lets a weak side branch become an accidental loudness path. The next architecture needs power/flow-consistent branch-port scattering that separates junction admittance from outgoing wave normalization.","evidence":"Focused optional PT log-mel runs after per-port outgoing coupling produced open/front graph RMS ratios near 0.0017 with nasal cosine 0.5338; squared generated velum opening produced open/front RMS ratios near 0.0038/0.0039 and nasal RMS 1.5729. Both were cut. The retained terminal-admittance pressure solve preserves the previous source/radiation metrics."}
|
||||
{"timestamp":"2026-05-26T21:20:00+01:00","topic":"physical-voice-side-branch-admittance-progress","claim":"Side-branch graph scattering now uses terminal-specific admittance plus a floored area-ratio pressure normalization for outgoing branch waves. This is not final power parity, but it improves nasal cosine/RMS without erasing the oral vowel cosine win. Raw per-port coupling, squared velum mapping, and lip/beak radiation gain were tested and cut.","evidence":"Latest optional PT log-mel run: graph open 0.6202 RMS 0.1703, graph front 0.6417 RMS 0.1752, graph nasal 0.5159 RMS 1.4815, graph sibilant 0.2268 RMS 1.0643, graph closure 0.1117 RMS 1.8085. Previous source/radiation baseline was open 0.6393 RMS 0.3559, front 0.6596 RMS 0.3661, nasal 0.4607 RMS 1.6914, sibilant 0.2320 RMS 0.9158, closure 0.1170 RMS 1.6650."}
|
||||
{"timestamp":"2026-05-26T21:35:00+01:00","topic":"physical-voice-frication-source-pressure","claim":"Generated graph `TurbulenceJet` ports now treat sustained turbulence and burst as separate pressure authorities: continuous frication uses max(turbulence, burst) for source pressure instead of being throttled by burst alone, while turbulence still gates noise amount. Fully ungated noise was tested and cut because it hurt closure-release.","evidence":"Optional PT log-mel after pressure fix: sibilant graph cosine 0.2302 / RMS 1.1387 and closure graph cosine 0.1145 / RMS 1.6627. Setting noise to 1 barely changed sibilant (0.2303) and worsened closure (0.1113 / RMS 1.8777), so that part was reverted."}
|
||||
{"timestamp":"2026-05-26T21:07:36+01:00","topic":"vocal-tract-playground-actual-graph-audition","claim":"The vocal tract playground no longer owns a browser-native source/filter audio witness. Audition audio is now rendered from the actual Aqua DSL tract graph path by posting controls to a .NET renderer that emits tract propagation=graph, lowers through FaustEmitter, renders with FaustCompiler, writes a WAV, and serves it back to the browser.","evidence":"Built tools/TractGraphRenderer successfully with disabled build servers. Direct renderer smoke wrote artifacts/vocal-tract-playground/smoke.wav with peak 0.15414488, RMS 0.066947475, and no warnings. Live POST http://127.0.0.1:5126/render returned a rendered WAV and /renders/... served HTTP 200. node --check passed for playground app.js and serve.mjs."}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ surfaces:
|
|||
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
|
||||
tract_graph_renderer: tools/TractGraphRenderer/TractGraphRenderer.csproj
|
||||
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
|
||||
|
|
@ -69,6 +70,7 @@ modules:
|
|||
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
|
||||
tract_graph_renderer: tools/TractGraphRenderer
|
||||
sfxr: src/AquaSynth.Core/SfxrParams.cs
|
||||
analysis: src/AquaSynth.Core/AudioAnalysis.cs
|
||||
mel_cepstral_warp_effect: src/AquaSynth.Core/MelCepstralWarpEffect.cs
|
||||
|
|
|
|||
|
|
@ -2,6 +2,23 @@
|
|||
|
||||
Current slice:
|
||||
|
||||
- Replaced the vocal tract playground's browser-native source/filter witness
|
||||
with actual Aqua DSL graph rendering. The page now posts the visible controls
|
||||
to `/render`; `tools/TractGraphRenderer` emits `tract propagation=graph`,
|
||||
lowers through `FaustEmitter`, renders through `FaustCompiler.RenderAsync`,
|
||||
writes a WAV under `artifacts/vocal-tract-playground`, and the browser plays
|
||||
that returned audio.
|
||||
- The fake JS `createTractSynth` path was deleted so the playground has one
|
||||
audio authority: Aqua DSL -> Faust graph lowering. The UI is render-on-demand,
|
||||
not realtime, because recompiling/rendering Faust per audition is the honest
|
||||
current machine.
|
||||
- Verification:
|
||||
`dotnet build tools\TractGraphRenderer\TractGraphRenderer.csproj --disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false`
|
||||
passed; direct renderer smoke wrote `artifacts\vocal-tract-playground\smoke.wav`
|
||||
with peak `0.15414488`, RMS `0.066947475`, and no warnings; live server
|
||||
`POST http://127.0.0.1:5126/render` returned a WAV and `/renders/...wav`
|
||||
served HTTP 200; `node --check` passed for `app.js` and `serve.mjs`.
|
||||
|
||||
- Added `tools/vocal-tract-playground`, a static WebAudio playground for the
|
||||
current AquaSynth `VocalTractControlTarget` surface. It exposes the 14 base
|
||||
voice-patch target controls plus six mel-envelope bands: these are the knobs
|
||||
|
|
|
|||
112
tools/TractGraphRenderer/Program.cs
Normal file
112
tools/TractGraphRenderer/Program.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using AquaSynth.Dsl;
|
||||
using AquaSynth.Faust;
|
||||
|
||||
var input = (await Console.In.ReadToEndAsync()).TrimStart('\uFEFF', '\u200B', ' ', '\t', '\r', '\n');
|
||||
var request = JsonSerializer.Deserialize<RenderRequest>(
|
||||
string.IsNullOrWhiteSpace(input) ? "{}" : input,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RenderRequest();
|
||||
|
||||
var outputPath = string.IsNullOrWhiteSpace(request.OutputPath)
|
||||
? Path.Combine(Path.GetTempPath(), $"aquasynth-tract-{Guid.NewGuid():N}.wav")
|
||||
: Path.GetFullPath(request.OutputPath);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
|
||||
if (FaustCompiler.FindFaust() is null)
|
||||
{
|
||||
Console.Error.WriteLine("Faust was not found on PATH or FAUST_HOME; cannot render actual Aqua graph audio.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var script = Script(request);
|
||||
var export = FaustEmitter.EmitScript(script, new FaustExportOptions("aqua_tract_graph_audition"));
|
||||
var render = await FaustCompiler.RenderAsync(
|
||||
export.Source,
|
||||
new FaustRenderOptions(request.SampleRate, request.DurationSeconds));
|
||||
|
||||
if (render is null || render.Samples.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine(render?.Stderr.Length > 0 ? render.Stderr : "Faust render produced no samples.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
WriteWav(outputPath, render.Samples, render.SampleRate, 0.92f);
|
||||
var peak = render.Samples.Select(MathF.Abs).DefaultIfEmpty(0).Max();
|
||||
var rms = MathF.Sqrt(render.Samples.Select(sample => sample * sample).DefaultIfEmpty(0).Average());
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
outputPath,
|
||||
render.SampleRate,
|
||||
samples = render.Samples.Length,
|
||||
peak,
|
||||
rms,
|
||||
warnings = export.Warnings
|
||||
}));
|
||||
|
||||
return 0;
|
||||
|
||||
static string Script(RenderRequest controls) =>
|
||||
$$"""
|
||||
patch gain=0.82 soft_clip=true
|
||||
|
||||
tract_shape
|
||||
name=human
|
||||
diameters=0.6,0.6,0.6,0.6,0.6,0.7,0.8,1.0,1.1,1.1,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.4,1.3,1.2,1.15,1.5
|
||||
|
||||
glottis name=modal intensity={{F(controls.Intensity)}} tenseness={{F(controls.Tenseness)}} aspiration=.12 reflection={{F(controls.GlottalReflection)}} skew=.42
|
||||
tract_injection name=inj position=32 diameter=1 turbulence=.1 burst=.25 width=1
|
||||
nasal_branch name=nose junction=17 velum=.01 reflection=-.85 loss=.999 diameters=0.01,0.35,0.5,0.65,0.8,0.95,1.1,1.25,1.4,1.55,1.7,1.8,1.9,1.9,1.85,1.75,1.65,1.55,1.45,1.35,1.25,1.15,1.05,0.95,0.85,0.75,0.65,0.55
|
||||
tract_motion name=motion diameter_slew=18 shape_return=8 constriction_slew=24 velum_slew=16 obstruction_threshold=.05
|
||||
|
||||
tract shape=human glottis=modal injection=inj nasal_branch=nose motion=motion propagation=graph waveguide_loss=.999 freq={{F(controls.Frequency)}} gain={{F(controls.Gain)}} intensity={{F(controls.Intensity)}} tenseness={{F(controls.Tenseness)}} sustain={{F(Math.Max(0.05f, controls.DurationSeconds - 0.12f))}} decay=.12 tongue_index={{F(controls.TongueIndex)}} tongue_diameter={{F(controls.TongueDiameter)}} constriction_index={{F(controls.ConstrictionIndex)}} constriction_diameter={{F(controls.ConstrictionDiameter)}} turbulence={{F(controls.Turbulence)}} velum={{F(controls.Velum)}} lip={{F(controls.LipOpening)}} burst={{F(controls.Burst)}} glottal_reflection={{F(controls.GlottalReflection)}} lip_reflection={{F(controls.LipReflection)}}
|
||||
""";
|
||||
|
||||
static string F(float value) =>
|
||||
value.ToString("0.######", CultureInfo.InvariantCulture);
|
||||
|
||||
static void WriteWav(string path, IReadOnlyList<float> samples, int sampleRate, float normalizePeak)
|
||||
{
|
||||
var peak = samples.Select(MathF.Abs).DefaultIfEmpty(0).Max();
|
||||
var gain = peak > 0 ? Math.Min(1, normalizePeak / peak) : 1;
|
||||
using var stream = File.Create(path);
|
||||
using var writer = new BinaryWriter(stream);
|
||||
var dataLength = samples.Count * sizeof(short);
|
||||
writer.Write("RIFF"u8);
|
||||
writer.Write(36 + dataLength);
|
||||
writer.Write("WAVE"u8);
|
||||
writer.Write("fmt "u8);
|
||||
writer.Write(16);
|
||||
writer.Write((short)1);
|
||||
writer.Write((short)1);
|
||||
writer.Write(sampleRate);
|
||||
writer.Write(sampleRate * sizeof(short));
|
||||
writer.Write((short)sizeof(short));
|
||||
writer.Write((short)16);
|
||||
writer.Write("data"u8);
|
||||
writer.Write(dataLength);
|
||||
foreach (var sample in samples)
|
||||
{
|
||||
writer.Write((short)Math.Clamp(sample * gain * short.MaxValue, short.MinValue, short.MaxValue));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RenderRequest(
|
||||
string OutputPath = "",
|
||||
int SampleRate = 44100,
|
||||
float DurationSeconds = 0.57f,
|
||||
float Frequency = 140,
|
||||
float Intensity = 0.72f,
|
||||
float Tenseness = 0.6f,
|
||||
float TongueIndex = 13,
|
||||
float TongueDiameter = 2.7f,
|
||||
float ConstrictionIndex = 32,
|
||||
float ConstrictionDiameter = 1,
|
||||
float Turbulence = 0.1f,
|
||||
float Velum = 0.01f,
|
||||
float LipOpening = 1.5f,
|
||||
float GlottalReflection = 0.75f,
|
||||
float LipReflection = -0.85f,
|
||||
float Gain = 0.7f,
|
||||
float Burst = 0.25f);
|
||||
16
tools/TractGraphRenderer/TractGraphRenderer.csproj
Normal file
16
tools/TractGraphRenderer/TractGraphRenderer.csproj
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AquaSynth.Core\AquaSynth.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\AquaSynth.Faust\AquaSynth.Faust.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# AquaSynth Tract Voice Playground
|
||||
|
||||
Touchable WebAudio playground for the Aqua DSL `tract` voice surface.
|
||||
Touchable audition playground for the Aqua DSL `tract` voice surface.
|
||||
|
||||
Run it from this directory:
|
||||
|
||||
|
|
@ -14,13 +14,14 @@ Then open:
|
|||
http://localhost:5125/
|
||||
```
|
||||
|
||||
This is a tract-voice control witness. It exposes the same Pink
|
||||
Trombone-shaped controls that Aqua DSL now lowers through `tract`: frequency,
|
||||
intensity, tenseness, tongue index/diameter, velum, constriction
|
||||
index/diameter, turbulence, burst, lip opening, and radiation reflection
|
||||
controls.
|
||||
This is an actual-machine audition surface. The browser sends the visible
|
||||
controls to `tools/TractGraphRenderer`, which emits an Aqua DSL patch with
|
||||
`tract propagation=graph`, lowers it through the normal Faust emitter, renders
|
||||
the compiled DSP, and returns a WAV for playback.
|
||||
|
||||
The browser synth runs a stable source/filter witness so the knobs are
|
||||
touchable while the Faust graph lowering keeps maturing toward Pink Trombone
|
||||
parity. It is a control playground, not the canonical DSP owner; the canonical
|
||||
audio path is still the Aqua DSL -> Faust graph lowering.
|
||||
That makes rendering slower than a browser-native oscillator, but it removes
|
||||
the fake witness from the feedback loop. The sound you hear is the Aqua DSL ->
|
||||
Faust graph path that the compiler owns.
|
||||
|
||||
Faust must be available through `PATH` or `FAUST_HOME`, and the .NET SDK must be
|
||||
available to run the renderer.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const presets = {
|
|||
const state = Object.fromEntries(controlSpecs.map(([, id, , value]) => [id, value]));
|
||||
const controls = new Map();
|
||||
let audio = null;
|
||||
let renderAbort = null;
|
||||
let animationFrame = 0;
|
||||
let phase = 0;
|
||||
|
||||
|
|
@ -61,6 +62,7 @@ const ctx = canvas.getContext("2d");
|
|||
const controlsRoot = document.getElementById("controls");
|
||||
const playButton = document.getElementById("playButton");
|
||||
const panicButton = document.getElementById("panicButton");
|
||||
const renderStatus = document.getElementById("renderStatus");
|
||||
const pressureMeter = document.getElementById("pressureMeter");
|
||||
const noiseMeter = document.getElementById("noiseMeter");
|
||||
const nasalMeter = document.getElementById("nasalMeter");
|
||||
|
|
@ -124,35 +126,58 @@ function applyPreset(name) {
|
|||
async function startAudio() {
|
||||
if (audio) return;
|
||||
|
||||
playButton.disabled = true;
|
||||
playButton.textContent = "Rendering...";
|
||||
setStatus("Rendering Aqua DSL graph through Faust...");
|
||||
const context = new AudioContext();
|
||||
const master = context.createGain();
|
||||
const processor = context.createScriptProcessor(1024, 0, 1);
|
||||
const synth = createTractSynth(context.sampleRate);
|
||||
master.gain.value = state.gain;
|
||||
master.connect(context.destination);
|
||||
|
||||
processor.onaudioprocess = event => {
|
||||
const output = event.outputBuffer.getChannelData(0);
|
||||
synth.render(output, state);
|
||||
};
|
||||
|
||||
master.gain.value = 0.0;
|
||||
processor.connect(master).connect(context.destination);
|
||||
audio = { context, master, processor, synth };
|
||||
updateAudio();
|
||||
playButton.textContent = "Stop";
|
||||
playButton.setAttribute("aria-pressed", "true");
|
||||
try {
|
||||
const { buffer, summary } = await renderCurrentPatch(context);
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(master);
|
||||
source.onended = () => {
|
||||
if (audio?.source === source) {
|
||||
void stopAudio();
|
||||
}
|
||||
};
|
||||
audio = { context, master, source };
|
||||
source.start();
|
||||
updateAudio();
|
||||
setStatus(`Rendered ${summary.samples} samples, peak ${summary.peak.toFixed(3)}, rms ${summary.rms.toFixed(3)}`);
|
||||
playButton.textContent = "Stop";
|
||||
playButton.setAttribute("aria-pressed", "true");
|
||||
} catch (error) {
|
||||
await context.close();
|
||||
setStatus(error.message);
|
||||
playButton.textContent = "Render";
|
||||
playButton.setAttribute("aria-pressed", "false");
|
||||
} finally {
|
||||
playButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAudio() {
|
||||
if (!audio) return;
|
||||
|
||||
const old = audio;
|
||||
audio = null;
|
||||
old.master.gain.setTargetAtTime(0, old.context.currentTime, 0.02);
|
||||
old.processor.disconnect();
|
||||
await new Promise(resolve => setTimeout(resolve, 80));
|
||||
await old.context.close();
|
||||
playButton.textContent = "Play";
|
||||
renderAbort?.abort();
|
||||
renderAbort = null;
|
||||
if (old) {
|
||||
old.master.gain.setTargetAtTime(0, old.context.currentTime, 0.02);
|
||||
try {
|
||||
old.source.stop();
|
||||
} catch {
|
||||
// The source may already have ended.
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 80));
|
||||
await old.context.close();
|
||||
}
|
||||
playButton.textContent = "Render";
|
||||
playButton.setAttribute("aria-pressed", "false");
|
||||
playButton.disabled = false;
|
||||
}
|
||||
|
||||
function updateAudio() {
|
||||
|
|
@ -162,155 +187,46 @@ function updateAudio() {
|
|||
audio.master.gain.setTargetAtTime(state.gain, audio.context.currentTime, 0.03);
|
||||
}
|
||||
|
||||
async function renderCurrentPatch(context) {
|
||||
renderAbort = new AbortController();
|
||||
const response = await fetch("/render", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...state,
|
||||
durationSeconds: 0.72,
|
||||
sampleRate: Math.round(context.sampleRate)
|
||||
}),
|
||||
signal: renderAbort.signal
|
||||
});
|
||||
renderAbort = null;
|
||||
|
||||
const summary = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(summary.error ?? "Graph render failed");
|
||||
}
|
||||
|
||||
const wav = await fetch(summary.audioUrl, { cache: "no-store" });
|
||||
if (!wav.ok) {
|
||||
throw new Error(`Rendered WAV was not available: HTTP ${wav.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
buffer: await context.decodeAudioData(await wav.arrayBuffer())
|
||||
};
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
if (renderStatus) renderStatus.textContent = message;
|
||||
}
|
||||
|
||||
function updateMeters() {
|
||||
pressureMeter.value = state.intensity;
|
||||
noiseMeter.value = state.turbulence;
|
||||
nasalMeter.value = (state.velum - 0.01) / 0.39;
|
||||
}
|
||||
|
||||
function createTractSynth(sampleRate) {
|
||||
let glottalPhase = 0;
|
||||
let noise = 0.1234567;
|
||||
let lastConstrictionDiameter = 1.0;
|
||||
let transient = 0;
|
||||
let dcBlockX = 0;
|
||||
let dcBlockY = 0;
|
||||
const breathMemory = { x: 0, y: 0 };
|
||||
const fricMemory = { x: 0, y: 0 };
|
||||
const f1 = makeBandpass();
|
||||
const f2 = makeBandpass();
|
||||
const f3 = makeBandpass();
|
||||
const nasal = makeBandpass();
|
||||
const fric = makeBandpass();
|
||||
|
||||
function rand() {
|
||||
noise = (noise * 16807) % 2147483647;
|
||||
return noise / 1073741823.5 - 1;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function makeBandpass() {
|
||||
let b0 = 0;
|
||||
let b1 = 0;
|
||||
let b2 = 0;
|
||||
let a1 = 0;
|
||||
let a2 = 0;
|
||||
let x1 = 0;
|
||||
let x2 = 0;
|
||||
let y1 = 0;
|
||||
let y2 = 0;
|
||||
return {
|
||||
set(hz, q) {
|
||||
const omega = 2 * Math.PI * clamp(hz, 40, sampleRate * 0.45) / sampleRate;
|
||||
const sin = Math.sin(omega);
|
||||
const alpha = sin / (2 * clamp(q, 0.2, 40));
|
||||
const cos = Math.cos(omega);
|
||||
const a0 = 1 + alpha;
|
||||
b0 = alpha / a0;
|
||||
b1 = 0;
|
||||
b2 = -alpha / a0;
|
||||
a1 = (-2 * cos) / a0;
|
||||
a2 = (1 - alpha) / a0;
|
||||
},
|
||||
process(x) {
|
||||
const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
|
||||
x2 = x1;
|
||||
x1 = x;
|
||||
y2 = y1;
|
||||
y1 = y;
|
||||
return y;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function highpass(input, hz, memory) {
|
||||
const alpha = 1 / (1 + 2 * Math.PI * hz / sampleRate);
|
||||
const y = alpha * (memory.y + input - memory.x);
|
||||
memory.x = input;
|
||||
memory.y = y;
|
||||
return y;
|
||||
}
|
||||
|
||||
function glottis(s) {
|
||||
glottalPhase += s.frequency / sampleRate;
|
||||
glottalPhase -= Math.floor(glottalPhase);
|
||||
const phase = glottalPhase;
|
||||
const tenseness = clamp(s.tenseness, 0, 1);
|
||||
const openPhase = 0.42 + (1 - tenseness) * 0.36;
|
||||
const glottalBoundary = clamp((s.glottalReflection + 0.95) / 1.9, 0, 1);
|
||||
const pulse = phase < openPhase
|
||||
? Math.sin(Math.PI * phase / openPhase)
|
||||
: -0.22 * Math.sin(Math.PI * (phase - openPhase) / Math.max(0.001, 1 - openPhase));
|
||||
const bite = Math.sin(4 * Math.PI * phase) * (0.04 + tenseness * 0.20 + glottalBoundary * 0.10);
|
||||
return (pulse * (0.72 + glottalBoundary * 0.42) - bite) * s.intensity * (0.13 + tenseness * 0.25);
|
||||
}
|
||||
|
||||
function updateTransient(s) {
|
||||
const opening = s.constrictionDiameter - lastConstrictionDiameter;
|
||||
if (opening > 0.18 && lastConstrictionDiameter < 0.28) {
|
||||
transient += opening * s.intensity * (0.18 + s.turbulence * 0.42) * s.burst;
|
||||
}
|
||||
lastConstrictionDiameter = s.constrictionDiameter;
|
||||
}
|
||||
|
||||
function formants(s) {
|
||||
const tongue = clamp((s.tongueIndex - 8) / 26, 0, 1);
|
||||
const tongueOpen = clamp((s.tongueDiameter - 0.45) / 3.15, 0, 1);
|
||||
const lip = clamp((s.lipOpening - 0.35) / 2.15, 0, 1);
|
||||
const lipReflection = clamp((-s.lipReflection - 0.1) / 0.88, 0, 1);
|
||||
const constrict = clamp((1.15 - s.constrictionDiameter) / 1.15, 0, 1);
|
||||
return {
|
||||
f1: 240 + tongueOpen * 620 + lip * 80 - constrict * 130,
|
||||
f2: 650 + tongue * 1850 + (1 - tongueOpen) * 320 - (1 - lip) * 320 - lipReflection * 120,
|
||||
f3: 1850 + tongue * 900 + constrict * 1900 - lipReflection * 260,
|
||||
nasal: 240 + clamp(s.velum / 0.4, 0, 1) * 420,
|
||||
fric: 1400 + clamp(s.constrictionIndex / 44, 0, 1) * 5200,
|
||||
radiation: 0.72 + (1 - lipReflection) * 0.46
|
||||
};
|
||||
}
|
||||
|
||||
function condition(output) {
|
||||
const blocked = output - dcBlockX + 0.995 * dcBlockY;
|
||||
dcBlockX = output;
|
||||
dcBlockY = blocked;
|
||||
return Math.tanh(blocked * 1.15) * 0.72;
|
||||
}
|
||||
|
||||
return {
|
||||
render(output, s) {
|
||||
updateTransient(s);
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
transient *= 0.995;
|
||||
const shape = formants(s);
|
||||
const velum = clamp((s.velum - 0.01) / 0.39, 0, 1);
|
||||
const constrict = clamp((1.15 - s.constrictionDiameter) / 1.15, 0, 1);
|
||||
const openness = clamp((s.constrictionDiameter - 0.18) / 0.9, 0, 1);
|
||||
const source = glottis(s);
|
||||
const white = rand();
|
||||
const breath = highpass(white, 900, breathMemory);
|
||||
const fricNoise = highpass(white, 2200, fricMemory);
|
||||
const frication = fricNoise * s.turbulence * constrict * openness * (0.35 + s.intensity * 0.65);
|
||||
const burst = rand() * transient;
|
||||
|
||||
f1.set(shape.f1, 8 + s.tenseness * 8);
|
||||
f2.set(shape.f2, 6 + s.tenseness * 6);
|
||||
f3.set(shape.f3, 5 + constrict * 8);
|
||||
nasal.set(shape.nasal, 5 + velum * 8);
|
||||
fric.set(shape.fric, 2.5 + constrict * 8);
|
||||
|
||||
const vowel = f1.process(source) * 1.8 + f2.process(source) * 1.25 + f3.process(source) * 0.65 * shape.radiation;
|
||||
const air = breath * (1 - Math.sqrt(clamp(s.tenseness, 0, 1))) * s.intensity * 0.16;
|
||||
const hiss = fric.process(frication + burst * 0.6) * 2.3;
|
||||
const nose = nasal.process(source + air) * velum * 0.9;
|
||||
output[i] = condition((vowel * (1 - velum * 0.28) + nose + hiss * shape.radiation + air) * 0.42);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
|
@ -460,10 +376,10 @@ function drawWave(cx, cy, length, scale) {
|
|||
function drawReadout(width, height) {
|
||||
ctx.fillStyle = "#f0f4ed";
|
||||
ctx.font = "700 22px system-ui, sans-serif";
|
||||
ctx.fillText(`source/filter witness freq=${state.frequency.toFixed(1)}Hz tense=${state.tenseness.toFixed(2)} velum=${state.velum.toFixed(2)} burst=${state.burst.toFixed(2)}`, 28, 42);
|
||||
ctx.fillText(`Aqua graph render freq=${state.frequency.toFixed(1)}Hz tense=${state.tenseness.toFixed(2)} velum=${state.velum.toFixed(2)} burst=${state.burst.toFixed(2)}`, 28, 42);
|
||||
ctx.fillStyle = "#a8b3a3";
|
||||
ctx.font = "15px system-ui, sans-serif";
|
||||
ctx.fillText("Aqua DSL tract voice controls: source, tongue, velum, constriction, turbulence, radiation", 28, height - 24);
|
||||
ctx.fillText("Render-on-demand through Aqua DSL -> Faust graph lowering", 28, height - 24);
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-preset]").forEach(button => {
|
||||
|
|
@ -483,6 +399,5 @@ draw();
|
|||
|
||||
window.AquaTractPlayground = {
|
||||
state,
|
||||
presets,
|
||||
createTractSynth
|
||||
presets
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@
|
|||
<h1>Tract Voice 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>
|
||||
<button id="playButton" type="button" aria-pressed="false" title="Render and play actual Aqua graph audio">Render</button>
|
||||
<button id="panicButton" type="button" title="Stop playback or render">Stop</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
<meter id="noiseMeter" min="0" max="1" value="0"></meter>
|
||||
<meter id="nasalMeter" min="0" max="1" value="0"></meter>
|
||||
</div>
|
||||
<p id="renderStatus" class="render-status" role="status">Ready to render the Aqua DSL graph.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,123 @@
|
|||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { createReadStream, existsSync, mkdirSync, statSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { basename, extname, join, normalize, resolve, sep } from "node:path";
|
||||
|
||||
const root = resolve(import.meta.dirname);
|
||||
const repoRoot = resolve(root, "..", "..");
|
||||
const renderRoot = resolve(repoRoot, "artifacts", "vocal-tract-playground");
|
||||
const rendererProject = resolve(repoRoot, "tools", "TractGraphRenderer", "TractGraphRenderer.csproj");
|
||||
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"]
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".wav", "audio/wav"]
|
||||
]);
|
||||
|
||||
mkdirSync(renderRoot, { recursive: true });
|
||||
|
||||
function fileFor(url) {
|
||||
const requestPath = decodeURIComponent(new URL(url, `http://${host}:${port}`).pathname);
|
||||
if (requestPath.startsWith("/renders/")) {
|
||||
const file = resolve(join(renderRoot, basename(requestPath)));
|
||||
return file === renderRoot || file.startsWith(renderRoot + sep) ? file : "";
|
||||
}
|
||||
|
||||
const normalized = normalize(requestPath === "/" ? "/index.html" : requestPath);
|
||||
const file = resolve(join(root, normalized));
|
||||
return file === root || file.startsWith(root + sep) ? file : "";
|
||||
}
|
||||
|
||||
function readBody(request) {
|
||||
return new Promise((resolveBody, reject) => {
|
||||
let body = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", chunk => {
|
||||
body += chunk;
|
||||
if (body.length > 24_000) {
|
||||
reject(new Error("request body too large"));
|
||||
request.destroy();
|
||||
}
|
||||
});
|
||||
request.on("end", () => resolveBody(body));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function renderGraph(body) {
|
||||
return new Promise((resolveRender, reject) => {
|
||||
const child = spawn("dotnet", [
|
||||
"run",
|
||||
"--project",
|
||||
rendererProject,
|
||||
"-p:UseSharedCompilation=false",
|
||||
"-p:BuildInParallel=false"
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", chunk => { stdout += chunk; });
|
||||
child.stderr.on("data", chunk => { stderr += chunk; });
|
||||
child.on("error", reject);
|
||||
child.on("close", code => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr || stdout || `renderer exited ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolveRender(JSON.parse(stdout));
|
||||
} catch (error) {
|
||||
reject(new Error(`renderer returned invalid JSON: ${error.message}\n${stdout}`));
|
||||
}
|
||||
});
|
||||
child.stdin.end(JSON.stringify(body));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRender(request, response) {
|
||||
try {
|
||||
const controls = JSON.parse(await readBody(request));
|
||||
const fileName = `tract-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`;
|
||||
const outputPath = resolve(join(renderRoot, fileName));
|
||||
const summary = await renderGraph({
|
||||
...controls,
|
||||
outputPath,
|
||||
sampleRate: controls.sampleRate ?? 44100,
|
||||
durationSeconds: controls.durationSeconds ?? 0.57
|
||||
});
|
||||
|
||||
response.writeHead(200, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "application/json; charset=utf-8"
|
||||
});
|
||||
response.end(JSON.stringify({
|
||||
...summary,
|
||||
audioUrl: `/renders/${fileName}`
|
||||
}));
|
||||
} catch (error) {
|
||||
response.writeHead(500, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "application/json; charset=utf-8"
|
||||
});
|
||||
response.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
createServer((request, response) => {
|
||||
if (request.method === "POST" && new URL(request.url ?? "/", `http://${host}:${port}`).pathname === "/render") {
|
||||
void handleRender(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileFor(request.url ?? "/");
|
||||
if (!file || !existsSync(file) || !statSync(file).isFile()) {
|
||||
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ h1 {
|
|||
|
||||
.visual-shell {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(18rem, 1fr) auto;
|
||||
grid-template-rows: minmax(18rem, 1fr) auto auto;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--line);
|
||||
|
|
@ -146,6 +146,14 @@ meter {
|
|||
height: 0.9rem;
|
||||
}
|
||||
|
||||
.render-status {
|
||||
min-height: 1.35rem;
|
||||
margin: -0.35rem 0 0;
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
|
|
|
|||
Loading…
Reference in a new issue