diff --git a/docs/pink-trombone-dsl-parity-plan.md b/docs/pink-trombone-dsl-parity-plan.md index aa8adb5..7e2560a 100644 --- a/docs/pink-trombone-dsl-parity-plan.md +++ b/docs/pink-trombone-dsl-parity-plan.md @@ -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 diff --git a/docs/pink-trombone-parity.md b/docs/pink-trombone-parity.md index 51ba162..09a6eaa 100644 --- a/docs/pink-trombone-parity.md +++ b/docs/pink-trombone-parity.md @@ -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 diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index 492be53..6b379c8 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -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++) diff --git a/src/AquaSynth.Core/PinkTromboneReference.cs b/src/AquaSynth.Core/PinkTromboneReference.cs index e7f461a..584b27e 100644 --- a/src/AquaSynth.Core/PinkTromboneReference.cs +++ b/src/AquaSynth.Core/PinkTromboneReference.cs @@ -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."); diff --git a/state/evidence.jsonl b/state/evidence.jsonl index fadf388..b67ca19 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/spine.yaml b/state/spine.yaml index ee5406f..0fca7a3 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -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. diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index e738586..3f150a9 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -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);