Represent tract waveguide substep clock

This commit is contained in:
Meta Vens 2026-05-25 21:30:00 +01:00
commit da4be187a2
7 changed files with 28 additions and 15 deletions

View file

@ -71,7 +71,8 @@ named failure against Pink Trombone, but it does not own the anatomy:
- Waveguide lowering now derives live per-section diameter targets, areas,
and reflection coefficients from the tract shape plus tongue, constriction,
and lip controls before scattering.
True substep execution remains missing.
Substep clock intent is represented and lowering consumes it for drive/loss
scaling, but exact intra-sample recursive state updates remain missing.
6. `tract_motion`: control-rate slew and obstruction history.
- Owns diameter/constriction/velum slew rates and obstruction threshold.
@ -97,11 +98,11 @@ pressure instead of silently inventing all of them inside one helper. The
waveguide lowering consumes the derived oral reflection field as right/left
section state equations.
The next cut is timing and proof: the oral/nasal tube now derives moving
The next cut is timing exactness and proof: the oral/nasal tube now derives moving
per-section diameter/area/reflection fields, distributes frication into section
updates, and derives release bursts from obstruction history. PT's two tract
steps per output sample and rendered parity fixtures still need low-level
owners.
updates, derives release bursts from obstruction history, and carries substep
clock intent. Exact two recursive tract state updates per output sample and
rendered parity fixtures still need proof.
## Cut Line

View file

@ -75,7 +75,7 @@ Current AquaSynth can express:
Current AquaSynth cannot exactly express:
- a subgraph that steps twice per output sample;
- exact two recursive tract state updates inside one output sample;
- exact Pink Trombone LF glottal coefficient updates.
## Current Parity Rung
@ -104,6 +104,9 @@ Trombone anatomy:
pressure from obstruction history.
- waveguide lowering derives live per-section diameter targets, areas, and
reflection coefficients from shape and gesture controls.
- `substeps` expresses waveguide clock intent; current Faust lowering consumes
it through drive/loss scaling but does not yet prove exact intra-sample state
updates.
- The proxy script parses and emits Faust with live controls, but its missing
features explicitly name the waveguide authorities AquaSynth does not own.
- `tools/vocal-tract-playground` exposes the same control surface for fast

View file

@ -514,8 +514,11 @@ public static class FaustEmitter
{
var sections = areaFunction.Sections;
var loss = F(Math.Clamp(tract.WaveguideLoss, 0, 1));
var substeps = Math.Max(1, tract.Substeps);
source.AppendLine($"{name}_wg_loss = {loss};");
source.AppendLine($"{name}_wg_substeps = {Math.Max(1, tract.Substeps)};");
source.AppendLine($"{name}_wg_substeps = {substeps};");
source.AppendLine($"{name}_wg_substep_drive = 1.0 / {name}_wg_substeps;");
source.AppendLine($"{name}_wg_substep_loss = pow({name}_wg_loss, {name}_wg_substeps);");
source.AppendLine($"{name}_wg_injection_cell = {name}_tract_injection_pos * {F(sections)};");
for (var i = 0; i < sections; i++)
{
@ -529,7 +532,7 @@ public static class FaustEmitter
source.AppendLine($"{name}_wg_diameter_target_{i} = max(0.0, {target});");
source.AppendLine($"{name}_wg_diameter_{i} = slew({shapeReturn}, {name}_wg_diameter_target_{i});");
source.AppendLine($"{name}_wg_area_{i} = max(0.000001, {name}_wg_diameter_{i} * {name}_wg_diameter_{i});");
source.AppendLine($"{name}_wg_inject_{i} = {name}_tract_injection_pressure * clip01(1.0 - abs({name}_wg_injection_cell - {F(i)}) / {injectionWidth});");
source.AppendLine($"{name}_wg_inject_{i} = {name}_tract_injection_pressure * {name}_wg_substep_drive * clip01(1.0 - abs({name}_wg_injection_cell - {F(i)}) / {injectionWidth});");
}
for (var i = 1; i < sections; i++)
{
@ -538,7 +541,7 @@ public static class FaustEmitter
var nasal = tract.Nasal is { AreaFunction: { } nasalShape } ? tract.Nasal : null;
var junction = nasal is null ? -1 : Math.Clamp(nasal.JunctionIndex, 1, sections - 2);
source.AppendLine($"{name}_wg_r_0 = ({name}_tract_excitation + {name}_wg_inject_0 * 0.5 + {name}_wg_l_0 * {glottalReflection}) : mem;");
source.AppendLine($"{name}_wg_r_0 = ({name}_tract_excitation * {name}_wg_substep_drive + {name}_wg_inject_0 * 0.5 + {name}_wg_l_0 * {glottalReflection}) : mem;");
source.AppendLine($"{name}_wg_l_{sections - 1} = ({name}_wg_r_{sections - 1} * {lipReflection}) : mem;");
for (var i = 1; i < sections; i++)
{
@ -547,8 +550,8 @@ public static class FaustEmitter
continue;
}
source.AppendLine($"{name}_wg_scatter_{i} = {name}_wg_k_{i} * ({name}_wg_r_{i - 1} + {name}_wg_l_{i});");
source.AppendLine($"{name}_wg_r_{i} = (({name}_wg_r_{i - 1} - {name}_wg_scatter_{i} + {name}_wg_inject_{i} * 0.5) * {name}_wg_loss) : mem;");
source.AppendLine($"{name}_wg_l_{i - 1} = (({name}_wg_l_{i} + {name}_wg_scatter_{i} + {name}_wg_inject_{i - 1} * 0.5) * {name}_wg_loss) : mem;");
source.AppendLine($"{name}_wg_r_{i} = (({name}_wg_r_{i - 1} - {name}_wg_scatter_{i} + {name}_wg_inject_{i} * 0.5) * {name}_wg_substep_loss) : mem;");
source.AppendLine($"{name}_wg_l_{i - 1} = (({name}_wg_l_{i} + {name}_wg_scatter_{i} + {name}_wg_inject_{i - 1} * 0.5) * {name}_wg_substep_loss) : mem;");
}
var noseOutput = "0.0";
@ -568,8 +571,8 @@ public static class FaustEmitter
source.AppendLine($"{name}_nose_reflect_left = (2.0 * {name}_wg_area_{junction - 1} - {name}_nose_sum) / {name}_nose_sum;");
source.AppendLine($"{name}_nose_reflect_right = (2.0 * {name}_wg_area_{junction} - {name}_nose_sum) / {name}_nose_sum;");
source.AppendLine($"{name}_nose_reflect_nose = (2.0 * {name}_nose_area - {name}_nose_sum) / {name}_nose_sum;");
source.AppendLine($"{name}_wg_l_{junction - 1} = ((({name}_nose_reflect_left * {name}_wg_r_{junction - 1}) + (1.0 + {name}_nose_reflect_left) * ({name}_wg_l_{junction} + {name}_nose_l_0) + {name}_wg_inject_{junction - 1} * 0.5) * {name}_wg_loss) : mem;");
source.AppendLine($"{name}_wg_r_{junction} = ((({name}_nose_reflect_right * {name}_wg_l_{junction}) + (1.0 + {name}_nose_reflect_right) * ({name}_wg_r_{junction - 1} + {name}_nose_l_0) + {name}_wg_inject_{junction} * 0.5) * {name}_wg_loss) : mem;");
source.AppendLine($"{name}_wg_l_{junction - 1} = ((({name}_nose_reflect_left * {name}_wg_r_{junction - 1}) + (1.0 + {name}_nose_reflect_left) * ({name}_wg_l_{junction} + {name}_nose_l_0) + {name}_wg_inject_{junction - 1} * 0.5) * {name}_wg_substep_loss) : mem;");
source.AppendLine($"{name}_wg_r_{junction} = ((({name}_nose_reflect_right * {name}_wg_l_{junction}) + (1.0 + {name}_nose_reflect_right) * ({name}_wg_r_{junction - 1} + {name}_nose_l_0) + {name}_wg_inject_{junction} * 0.5) * {name}_wg_substep_loss) : mem;");
source.AppendLine($"{name}_nose_r_0 = (({name}_nose_reflect_nose * {name}_nose_l_0) + (1.0 + {name}_nose_reflect_nose) * ({name}_wg_l_{junction} + {name}_wg_r_{junction - 1})) * {name}_nose_loss : mem;");
source.AppendLine($"{name}_nose_l_{noseSections - 1} = ({name}_nose_r_{noseSections - 1} * {noseReflection}) : mem;");
for (var i = 1; i < noseSections; i++)

View file

@ -73,13 +73,14 @@ public static class PinkTromboneReference
new("tract_shape_motion", "tract_motion slew semantics", "AquaSynth can author tract control slew and obstruction thresholds for moving diameter targets."),
new("diameter_authority", "dynamic waveguide section diameters", "Waveguide lowering derives live per-section diameter targets before recalculating section areas and reflection coefficients."),
new("closure_transients", "obstruction-history burst", "Waveguide lowering derives burst pressure from prior obstruction state and current opening."),
new("tract_sample_rate", "substeps field", "AquaSynth can author waveguide substep count and lowering scales drive/loss by that clock intent."),
new("runtime_controls", "/pink/frequency,/pink/intensity,/pink/tenseness,/pink/tongue/index,/pink/tongue/diameter,/pink/velum,/pink/turbulence", "AquaSynth can expose stable runtime parameter paths."),
new("glottal_pitch_pressure_proxy", "tract LF-style source", "AquaSynth can drive pitch/loudness-like controls without recompiling."),
new("turbulence_proxy", "position-aware frication controls", "The tract voice has constriction index/diameter and turbulence controls."),
new("nasal_proxy", "velum-controlled nasal resonator", "The tract voice has a velum/nasal output lane.")
],
[
new("twice_rate_tract_stepping", "two tract steps per audio sample", "AquaSynth voice graphs do not express a subgraph with its own stepping rate."),
new("exact_twice_rate_state_update", "two recursive tract state updates per audio sample", "AquaSynth records and uses substep intent, but Faust lowering still approximates intra-sample state updates with drive/loss scaling."),
new("lf_glottal_exactness", "Pink Trombone LF-style coefficients", "The proxy uses an LF-like glottal source but does not yet match Pink Trombone coefficient updates exactly.")
],
"This is a deliberately non-passing rebuild: it keeps the current source-filter proxy parseable while naming the tract-DSP authority AquaSynth lacks.");

View file

@ -136,3 +136,4 @@
{"date":"2026-05-26","topic":"waveguide-cell-injection","lesson":"A positioned injection primitive is not enough; frication has to enter the traveling-wave section updates. AquaSynth waveguide lowering now computes a section-space injection cell and distributes injection pressure across oral waveguide right/left updates by position and width. Remaining PT DSP gaps are true substep execution, obstruction-history transients, diameter target slew, and rendered parity fixtures.","evidence":"Changed Faust waveguide lowering to split glottal excitation from tract injection pressure, emit `wg_injection_cell` and `wg_inject_N` terms, and add those terms to oral waveguide section and nasal-junction updates. Verification required disabling build servers/shared compilation/parallel build after normal dotnet builds hung: `dotnet build tests\\AquaSynth.Dsl.Tests\\AquaSynth.Dsl.Tests.csproj --no-restore --disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false -v minimal`; focused `dotnet test --no-build` passed 3 tests."}
{"date":"2026-05-26","topic":"tract-motion-obstruction-history","lesson":"PT-style movement and release bursts are temporal DSP semantics, not preset flavor. AquaSynth now has `tract_motion` to own slew rates and obstruction threshold; waveguide lowering smooths tract controls and derives burst pressure from previous obstruction state opening into current non-obstruction. Remaining PT expression gaps are true two-steps-per-sample execution, dynamic per-section target diameter arrays, and rendered parity fixtures.","evidence":"Added `TractMotion`, parser command `tract_motion`, voice references/inline overrides, Faust `slew(rate,x)`, smoothed tongue/constriction/velum controls, `tract_prev_injection_close`, `tract_obstructed`, and `tract_release_memory` burst generation. Verification: no-hang build/test command with `--disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false`; focused tests passed 3."}
{"date":"2026-05-26","topic":"dynamic-tract-reflection-field","lesson":"A PT-class waveguide cannot keep reflection coefficients as compile-time constants. AquaSynth waveguide lowering now derives live per-section diameter targets from `tract_shape`, tongue, constriction, and lip controls, slews them through `tract_motion`, squares them into section areas, and recomputes reflection coefficients and nasal junction areas as DSP expressions.","evidence":"Replaced static `wg_k_N` constants in `FaustExport.TractWaveguideExpression` with generated `wg_diameter_target_N`, `wg_diameter_N`, `wg_area_N`, and dynamic `wg_k_N` expressions. Focused no-hang build/test command passed 3 tests."}
{"date":"2026-05-26","topic":"waveguide-substep-clock","lesson":"PT's twice-rate tract clock must be represented explicitly. AquaSynth already had `substeps`; lowering now consumes it through `wg_substep_drive` and `wg_substep_loss` so the field is not decorative. Exact intra-sample recursive state updates remain named debt until the Faust lowering or another backend can prove them.","evidence":"Added substep drive/loss scaling in `TractWaveguideExpression`, updated parity docs/reference metadata, and kept `exact_twice_rate_state_update` as missing. Focused no-hang build/test command passed 3 tests."}

View file

@ -112,7 +112,7 @@ live_invariants:
- The speech lane is the active center: IPA parity, utterance embeddings, synth-driver automation, rendered loss, and compiled Faust control sweeps take priority over new DX7/Zyn digestion.
- Every learned speech arrow must stay inspectable: utterance metadata -> embedding, embedding plus phonetic features -> automation, automation -> rendered audio, rendered audio -> loss/gradient.
- Pink Trombone parity is structural waveguide pressure, not permission to add more formant proxy knobs. AquaSynth now has a `tract` DSL command that lowers into `Voice.Tract` with PT-shaped runtime controls and a Faust tract-voice proxy; it is still an ordinary voice treatment, not a separate graph owner and not an exact 44-cell/28-cell bidirectional waveguide.
- PT expressive parity should decompose into reusable Aqua primitives before audio golf: `tract_shape` owns section diameters/areas and derived reflection coefficients; `glottis` owns excitation controls; `tract_injection` owns positioned frication/burst controls and now distributes frication pressure into waveguide section updates; `nasal_branch` owns a second diameter/area tube; `tract_motion` owns control slew and obstruction threshold; `propagation=waveguide` lowers tract voices into generated oral/nasal right-left waveguide equations with live per-section diameter/area/reflection fields and obstruction-history burst pressure. Remaining low-level PT gaps are true two-steps-per-sample execution and rendered parity fixtures.
- PT expressive parity should decompose into reusable Aqua primitives before audio golf: `tract_shape` owns section diameters/areas and derived reflection coefficients; `glottis` owns excitation controls; `tract_injection` owns positioned frication/burst controls and now distributes frication pressure into waveguide section updates; `nasal_branch` owns a second diameter/area tube; `tract_motion` owns control slew and obstruction threshold; `propagation=waveguide` lowers tract voices into generated oral/nasal right-left waveguide equations with live per-section diameter/area/reflection fields, obstruction-history burst pressure, and substep clock intent. Remaining low-level PT gaps are exact intra-sample recursive substep state updates and rendered parity fixtures.
- If dotnet builds hang in this workspace, stop orphan dotnet workers and retry with `--disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false`; plain parallel/shared builds can stall without useful diagnostics.
- eSpeak NG is an early parity reference for timing, IPA coverage, and log-mel pressure, not an anatomy oracle or final target.
- Do not expand into prosody, emotional context, personality, or broad Weksa expressivity until the tiny IPA utterance harness can show useful loss movement.

View file

@ -139,6 +139,8 @@ public sealed class PatchScriptTests
Assert.Contains("wg_inject_6", faust);
Assert.Contains("wg_diameter_target_3", faust);
Assert.Contains("wg_area_3", faust);
Assert.Contains("wg_substep_drive", faust);
Assert.Contains("wg_substep_loss", faust);
Assert.Contains("tract_release_memory", faust);
Assert.Contains("tract_obstructed", faust);
Assert.Contains("slew(", faust);
@ -383,6 +385,8 @@ public sealed class PatchScriptTests
Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "tract_shape_motion");
Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "diameter_authority");
Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "closure_transients");
Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "tract_sample_rate");
Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "exact_twice_rate_state_update");
Assert.NotEmpty(patch.TractShapes);
Assert.NotEmpty(patch.GlottalSources);
Assert.NotEmpty(patch.TractInjections);