From 44c4e4feea1257a28d8c592dcfa64d12dccb5fa6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 25 May 2026 20:41:59 +0100 Subject: [PATCH] Add low-level tract waveguide primitives --- docs/pink-trombone-dsl-parity-plan.md | 30 ++- docs/pink-trombone-parity.md | 22 +- src/AquaSynth.Core/BuiltInScripts.cs | 30 +++ src/AquaSynth.Core/FaustExport.cs | 97 ++++++++- src/AquaSynth.Core/Model.cs | 41 +++- src/AquaSynth.Core/PatchScript.cs | 195 +++++++++++++++++- src/AquaSynth.Core/PinkTromboneReference.cs | 13 +- state/evidence.jsonl | 1 + state/map.yaml | 1 + state/memory.json | 4 +- state/spine.yaml | 4 +- tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs | 45 +++- 12 files changed, 438 insertions(+), 45 deletions(-) diff --git a/docs/pink-trombone-dsl-parity-plan.md b/docs/pink-trombone-dsl-parity-plan.md index 32214c1..dd02d62 100644 --- a/docs/pink-trombone-dsl-parity-plan.md +++ b/docs/pink-trombone-dsl-parity-plan.md @@ -18,7 +18,7 @@ named failure against Pink Trombone, but it does not own the anatomy: - no derived area/reflection coefficient primitive; - no traveling-wave state owner; - no nasal junction primitive; -- no positioned excitation/injection events; +- no waveguide-applied positioned excitation/injection events; - no substep/rate semantics; - no parity renderer/score loop against PT output. @@ -47,20 +47,28 @@ named failure against Pink Trombone, but it does not own the anatomy: 2. `glottis`: reusable excitation primitive. - Owns frequency, intensity, tenseness, aspiration, and reflection intent. - Can feed tract voices or other voice treatments. + - Implemented as a named source primitive consumed by `tract` voices. 3. `tract_injection`: positioned noise/burst excitation. - Owns constriction position, diameter/opening, turbulence, and release transient behavior. - Can be driven by envelopes, LFOs, learned speech controls, or consonant gestures. + - Implemented as a named injection primitive consumed by `tract` voices; it + is not yet injected into waveguide cell state. 4. `nasal_junction`: velum-controlled branch primitive. - Owns nasal opening and branch shape. - Derives the three-way junction coefficients from local areas. + - Implemented as `nasal_branch` plus generated three-way junction equations + when a waveguide tract references the branch. 5. `waveguide_tract`: propagation primitive. - Owns right/left traveling-wave state, substep rate, loss, and radiation. - Consumes area/reflection fields and source/injection events. + - First oral-tube lowering exists: generated right/left section equations + consume `tract_shape` reflection coefficients and boundary reflections. + True substep execution and cell-local injection remain missing. 6. PT parity harness. - Renders fixed PT references for vowels, nasals, fricatives, closures, and @@ -70,12 +78,20 @@ named failure against Pink Trombone, but it does not own the anatomy: ## First Cut -First owner implemented: `tract_shape`. It is the smallest useful owner: -reflection, waveguide, constriction, nasal junction, and morphology all depend -on a section diameter/area field. The current Faust proxy now consumes this -primitive through shape summaries and derived reflection energy instead of -silently inventing one universal tube. The next cut is to make a propagation -primitive consume the full reflection coefficient field. +Implemented owners: `tract_shape`, `glottis`, `tract_injection`, +`nasal_branch`, and first-pass `propagation=waveguide`. +`tract_shape` owns section diameter/area fields and reflection derivation. +`glottis` owns excitation quality. `tract_injection` owns positioned +frication/burst pressure. The current Faust proxy consumes these primitives +through shape summaries, reflection energy, glottal shaping, and injection +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 cell-local events and timing: the oral/nasal tube now consumes +the full reflection coefficient fields, but PT's two tract steps per output +sample, cell-positioned turbulence injection, and obstruction-state transients +still need low-level owners. ## Cut Line diff --git a/docs/pink-trombone-parity.md b/docs/pink-trombone-parity.md index 1d7935a..218603e 100644 --- a/docs/pink-trombone-parity.md +++ b/docs/pink-trombone-parity.md @@ -53,12 +53,18 @@ Current AquaSynth can express: - runtime parameters with stable paths; - reusable `tract_shape` declarations with authored diameter or area samples; - derived tract areas and adjacent-section reflection coefficients; +- reusable `glottis` declarations for tract excitation quality; +- reusable `tract_injection` declarations for positioned frication/burst + pressure; +- reusable `nasal_branch` declarations with velum-controlled junctions; - a `tract` voice command owned by `Voice.Tract`; - Pink Trombone-shaped controls for intensity, tenseness, tongue body, constriction, velum/nasal opening, turbulence, lip opening, and end reflections; - a Faust-lowered tract voice proxy with LF-style excitation, aspiration, frication, oral resonators, and a velum-controlled nasal lane; +- generated oral bidirectional waveguide equations over a `tract_shape` section + field when `propagation=waveguide`; - oscillator/noise sources; - ADSR and staged rate/level envelopes; - low/high/band/notch filters; @@ -68,16 +74,10 @@ Current AquaSynth can express: Current AquaSynth cannot exactly express: -- a 44-cell bidirectional tract waveguide; -- independent right/left traveling-wave state arrays; -- a 28-cell nasal waveguide branch; -- a three-way velum/nose junction; - per-section diameter targets as synthesis authority; -- application of diameter-squared reflection coefficients to bidirectional - waveguide propagation; - a subgraph that steps twice per output sample; -- turbulence injected at a tract position into neighboring cells; -- obstruction-state release transients; +- turbulence applied to bidirectional waveguide cells; +- obstruction-state release transients derived from prior closure state; - exact Pink Trombone LF glottal coefficient updates. ## Current Parity Rung @@ -94,6 +94,12 @@ Trombone anatomy: - `tract_shape` owns reusable section diameter/area functions. The current proxy consumes its shape summaries and derived reflection energy; future waveguide lowering should consume the full coefficients. +- `glottis` and `tract_injection` own excitation and positioned noise/burst + controls consumed by tract voices. +- `propagation=waveguide` emits an oral right/left tube from the derived + reflection field. It is the first low-level propagation path, not full PT. +- `nasal_branch` adds a second diameter/area tube and generated three-way + oral/nasal scattering junction when used by a waveguide tract. - 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/BuiltInScripts.cs b/src/AquaSynth.Core/BuiltInScripts.cs index 62e4b9c..25d83fe 100644 --- a/src/AquaSynth.Core/BuiltInScripts.cs +++ b/src/AquaSynth.Core/BuiltInScripts.cs @@ -630,8 +630,38 @@ public static class BuiltInScripts name=pt_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=pt_glottis + intensity=@/pink/intensity + tenseness=@/pink/tenseness + aspiration=0.12 + reflection=@/pink/glottal/reflection + skew=0.42 + + tract_injection + name=pt_constriction + position=@/pink/constriction/index + diameter=@/pink/constriction/diameter + turbulence=@/pink/turbulence + burst=0.28 + width=1 + + nasal_branch + name=pt_nose + junction=17 + velum=@/pink/velum + reflection=@/pink/lip/reflection + loss=0.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 shape=pt_human + glottis=pt_glottis + injection=pt_constriction + nasal_branch=pt_nose + propagation=waveguide + waveguide_loss=0.999 + substeps=2 freq=@/pink/frequency gain=0.7 attack=0.004 diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index edfdb97..8e7c289 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -405,17 +405,28 @@ public static class FaustEmitter var glottalReflection = parameters.Expression(OwnerField(tractPath, "glottal_reflection"), tract.GlottalReflection); var lipReflection = parameters.Expression(OwnerField(tractPath, "lip_reflection"), tract.LipReflection); var areaFunction = tract.AreaFunction; + var glottis = tract.Glottis; + var injection = tract.Injection; var shapeBack = F(areaFunction?.AverageDiameter(0.18f, 0.38f) ?? 1.35f); var shapeMiddle = F(areaFunction?.AverageDiameter(0.38f, 0.68f) ?? 1.5f); var shapeFront = F(areaFunction?.AverageDiameter(0.68f, 0.96f) ?? 1.5f); var shapeMinimum = F(areaFunction?.MinimumDiameter ?? 0.6f); var reflectionEnergy = F(areaFunction is null ? 0.12f : MathF.Min(1, areaFunction.ReflectionCoefficients.Sum(reflection => MathF.Abs(reflection)) / Math.Max(1, areaFunction.ReflectionCoefficients.Count))); + var aspiration = $"clip01({parameters.Expression(OwnerField(tractPath, "glottis/aspiration"), glottis?.Aspiration ?? 0.08f)})"; + var glottalSkew = $"clip01({parameters.Expression(OwnerField(tractPath, "glottis/skew"), glottis?.Skew ?? 0.42f)})"; + var injectionPosition = parameters.Expression(OwnerField(tractPath, "injection/position"), injection?.Position ?? tract.ConstrictionIndex); + var injectionDiameter = parameters.Expression(OwnerField(tractPath, "injection/diameter"), injection?.Diameter ?? tract.ConstrictionDiameter); + var injectionTurbulence = $"clip01({parameters.Expression(OwnerField(tractPath, "injection/turbulence"), injection?.Turbulence ?? tract.Turbulence)})"; + var injectionBurst = $"clip01({parameters.Expression(OwnerField(tractPath, "injection/burst"), injection?.Burst ?? 0)})"; + var injectionWidth = $"max(0.05, {parameters.Expression(OwnerField(tractPath, "injection/width"), injection?.Width ?? 1)})"; source.AppendLine($"{name}_tract_phase = os.phasor(1.0, {frequency});"); source.AppendLine($"{name}_tract_tongue_pos = clip01({tongueIndex} / {F(sections)});"); source.AppendLine($"{name}_tract_constriction_pos = clip01({constrictionIndex} / {F(sections)});"); + source.AppendLine($"{name}_tract_injection_pos = clip01({injectionPosition} / {F(sections)});"); source.AppendLine($"{name}_tract_tongue_close = clip01((3.5 - {tongueDiameter}) / 3.5);"); source.AppendLine($"{name}_tract_constriction_close = clip01((1.15 - {constrictionDiameter}) / 1.15);"); + source.AppendLine($"{name}_tract_injection_close = clip01((1.15 - {injectionDiameter}) / max(0.05, 1.15 * {injectionWidth}));"); source.AppendLine($"{name}_tract_lip = clip01({lipOpening} / 2.5);"); source.AppendLine($"{name}_tract_shape_back = {shapeBack};"); source.AppendLine($"{name}_tract_shape_middle = {shapeMiddle};"); @@ -426,11 +437,17 @@ public static class FaustEmitter source.AppendLine($"{name}_tract_f1 = max(90.0, 260.0 + {name}_tract_lip * 720.0 - {name}_tract_tongue_close * 260.0 + ({name}_tract_shape_back - 1.35) * 210.0 - (1.0 - {name}_tract_shape_min) * 120.0 + {velum} * 120.0);"); source.AppendLine($"{name}_tract_f2 = max(180.0, 820.0 + {name}_tract_tongue_pos * 1850.0 - {name}_tract_tongue_close * 640.0 + ({name}_tract_shape_middle - 1.5) * 360.0 - (1.0 - {name}_tract_lip) * 260.0);"); source.AppendLine($"{name}_tract_f3 = max(500.0, 1900.0 + {name}_tract_constriction_pos * 2600.0 + {name}_tract_tongue_close * 700.0 + ({name}_tract_shape_front - 1.5) * 520.0);"); - source.AppendLine($"{name}_tract_lf = (sin(2.0 * ma.PI * {name}_tract_phase) - (0.15 + {tenseness} * 0.45) * sin(4.0 * ma.PI * {name}_tract_phase)) * {intensity} * (0.45 + 0.55 * pow(max(0.0, {tenseness}), 0.25));"); - source.AppendLine($"{name}_tract_aspiration = no.noise * {intensity} * (1.0 - sqrt(max(0.0, {tenseness}))) * (0.02 + 0.16 * {name}_tract_constriction_close);"); - source.AppendLine($"{name}_tract_frication = no.noise * {turbulence} * {name}_tract_constriction_close * {intensity} * (0.2 + 0.8 * {tenseness});"); - source.AppendLine($"{name}_tract_burst = no.noise * {name}_tract_constriction_close * exp(-age * (45.0 + {name}_tract_constriction_close * 120.0)) * 0.18;"); + source.AppendLine($"{name}_tract_lf_open = select2({name}_tract_phase < (0.42 + {glottalSkew} * 0.36), -0.28 * sin(ma.PI * ({name}_tract_phase - (0.42 + {glottalSkew} * 0.36)) / max(0.001, 1.0 - (0.42 + {glottalSkew} * 0.36))), sin(ma.PI * {name}_tract_phase / max(0.001, 0.42 + {glottalSkew} * 0.36)));"); + source.AppendLine($"{name}_tract_lf = ({name}_tract_lf_open - (0.12 + {tenseness} * 0.62) * sin(4.0 * ma.PI * {name}_tract_phase)) * {intensity} * (0.45 + 0.75 * pow(max(0.0, {tenseness}), 0.35));"); + source.AppendLine($"{name}_tract_aspiration = no.noise * {intensity} * {aspiration} * (1.0 - sqrt(max(0.0, {tenseness}))) * (0.08 + 0.22 * {name}_tract_constriction_close);"); + source.AppendLine($"{name}_tract_frication = no.noise * max({turbulence} * {name}_tract_constriction_close, {injectionTurbulence} * {name}_tract_injection_close) * {intensity} * (0.25 + 0.75 * {name}_tract_injection_pos) * (0.2 + 0.8 * {tenseness});"); + source.AppendLine($"{name}_tract_burst = no.noise * max({name}_tract_constriction_close * 0.18, {name}_tract_injection_close * {injectionBurst}) * exp(-age * (45.0 + max({name}_tract_constriction_close, {name}_tract_injection_close) * 120.0));"); source.AppendLine($"{name}_tract_raw = ({name}_tract_lf + {name}_tract_aspiration + {name}_tract_frication + {name}_tract_burst) * (0.55 + 0.45 * abs({glottalReflection}));"); + if (tract.Propagation == TractPropagationMode.Waveguide && areaFunction is not null) + { + return TractWaveguideExpression(source, tract, areaFunction, name, glottalReflection, lipReflection, velum); + } + source.AppendLine($"{name}_tract_oral = {name}_tract_raw * 0.18 + ({name}_tract_raw : fi.resonbp({name}_tract_f1, {name}_tract_q, 1.0)) * (0.75 + {name}_tract_lip * 0.65) + ({name}_tract_raw : fi.resonbp({name}_tract_f2, {name}_tract_q, 1.0)) * (0.85 + {name}_tract_tongue_close) + ({name}_tract_raw : fi.resonbp({name}_tract_f3, {name}_tract_q, 1.0)) * (0.35 + {name}_tract_constriction_close);"); if (hasNose) { @@ -444,6 +461,78 @@ public static class FaustEmitter return $"{name}_tract_radiated"; } + private static string TractWaveguideExpression( + StringBuilder source, + VocalTract tract, + TractAreaFunction areaFunction, + string name, + string glottalReflection, + string lipReflection, + string velum) + { + var sections = areaFunction.Sections; + var reflections = areaFunction.ReflectionCoefficients; + var loss = F(Math.Clamp(tract.WaveguideLoss, 0, 1)); + source.AppendLine($"{name}_wg_loss = {loss};"); + source.AppendLine($"{name}_wg_substeps = {Math.Max(1, tract.Substeps)};"); + for (var i = 0; i < reflections.Count; i++) + { + source.AppendLine($"{name}_wg_k_{i + 1} = {F(reflections[i])};"); + } + + 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_raw + {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++) + { + if (i == junction) + { + 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_loss) : mem;"); + source.AppendLine($"{name}_wg_l_{i - 1} = (({name}_wg_l_{i} + {name}_wg_scatter_{i}) * {name}_wg_loss) : mem;"); + } + + var noseOutput = "0.0"; + if (nasal is not null && nasal.AreaFunction is { } nasalFunction) + { + var noseSections = nasalFunction.Sections; + var noseReflections = nasalFunction.ReflectionCoefficients; + var noseLoss = F(Math.Clamp(nasal.Loss, 0, 1)); + var noseReflection = F(nasal.Reflection); + var leftArea = F(areaFunction.Areas[Math.Clamp(junction - 1, 0, areaFunction.Areas.Count - 1)]); + var rightArea = F(areaFunction.Areas[Math.Clamp(junction, 0, areaFunction.Areas.Count - 1)]); + source.AppendLine($"{name}_nose_loss = {noseLoss};"); + for (var i = 0; i < noseReflections.Count; i++) + { + source.AppendLine($"{name}_nose_k_{i + 1} = {F(noseReflections[i])};"); + } + source.AppendLine($"{name}_nose_area = max(0.000001, (0.01 + {velum} * 0.39) * (0.01 + {velum} * 0.39));"); + source.AppendLine($"{name}_nose_sum = {leftArea} + {rightArea} + {name}_nose_area;"); + source.AppendLine($"{name}_nose_reflect_left = (2.0 * {leftArea} - {name}_nose_sum) / {name}_nose_sum;"); + source.AppendLine($"{name}_nose_reflect_right = (2.0 * {rightArea} - {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_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_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++) + { + source.AppendLine($"{name}_nose_scatter_{i} = {name}_nose_k_{i} * ({name}_nose_r_{i - 1} + {name}_nose_l_{i});"); + source.AppendLine($"{name}_nose_r_{i} = (({name}_nose_r_{i - 1} - {name}_nose_scatter_{i}) * {name}_nose_loss) : mem;"); + source.AppendLine($"{name}_nose_l_{i - 1} = (({name}_nose_l_{i} + {name}_nose_scatter_{i}) * {name}_nose_loss) : mem;"); + } + source.AppendLine($"{name}_tract_nose_waveguide = {name}_nose_r_{noseSections - 1};"); + noseOutput = $"{name}_tract_nose_waveguide"; + } + + source.AppendLine($"{name}_tract_oral_waveguide = {name}_wg_r_{sections - 1} + {name}_wg_l_{sections - 1} * 0.05;"); + source.AppendLine($"{name}_tract_radiated = {name}_tract_oral_waveguide * (0.65 + 0.35 * abs({lipReflection})) + {noseOutput};"); + return $"{name}_tract_radiated"; + } + private static void EmitOperatorGraph(StringBuilder source, Playback playback, OperatorGraph graph, string name, ParameterMap parameters, List warnings) { if (graph.Operators.Count == 0) diff --git a/src/AquaSynth.Core/Model.cs b/src/AquaSynth.Core/Model.cs index 9957fa5..78455da 100644 --- a/src/AquaSynth.Core/Model.cs +++ b/src/AquaSynth.Core/Model.cs @@ -183,6 +183,36 @@ public sealed record TractAreaFunction(IReadOnlyList Diameters) public sealed record TractShape(string Name, TractAreaFunction AreaFunction); +public sealed record GlottalSource( + string Name = "", + float Intensity = 0.72f, + float Tenseness = 0.6f, + float Aspiration = 0.08f, + float Reflection = 0.75f, + float Skew = 0.42f); + +public sealed record TractInjection( + string Name = "", + float Position = 32, + float Diameter = 1, + float Turbulence = 0, + float Burst = 0, + float Width = 1); + +public enum TractPropagationMode +{ + Resonator, + Waveguide +} + +public sealed record NasalBranch( + string Name = "", + TractAreaFunction? AreaFunction = null, + int JunctionIndex = 17, + float Velum = 0.01f, + float Reflection = -0.85f, + float Loss = 0.999f); + public sealed record Modulator( ModTarget Target, ModWaveform Waveform = ModWaveform.Sine, @@ -237,7 +267,13 @@ public sealed record VocalTract( float LipOpening = 1.5f, float GlottalReflection = 0.75f, float LipReflection = -0.85f, - TractAreaFunction? AreaFunction = null); + TractAreaFunction? AreaFunction = null, + GlottalSource? Glottis = null, + TractInjection? Injection = null, + NasalBranch? Nasal = null, + TractPropagationMode Propagation = TractPropagationMode.Resonator, + float WaveguideLoss = 0.999f, + int Substeps = 1); public sealed record PatchParameter( string Path, @@ -411,6 +447,9 @@ public sealed record SynthPatch public IReadOnlyList HarmonicBanks { get; init; } = Array.Empty(); public IReadOnlyList SpectralBanks { get; init; } = Array.Empty(); public IReadOnlyList TractShapes { get; init; } = Array.Empty(); + public IReadOnlyList GlottalSources { get; init; } = Array.Empty(); + public IReadOnlyList TractInjections { get; init; } = Array.Empty(); + public IReadOnlyList NasalBranches { get; init; } = Array.Empty(); public IReadOnlyList OperatorGraphs { get; init; } = Array.Empty(); public IReadOnlyList Controls { get; init; } = Array.Empty(); public IReadOnlyList Parameters { get; init; } = Array.Empty(); diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index 78d94b0..134b89d 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -38,6 +38,12 @@ public static class PatchScript private readonly List _spectralBanks = []; private readonly List _tractShapes = []; private readonly Dictionary _tractShapesByName = new(StringComparer.OrdinalIgnoreCase); + private readonly List _glottalSources = []; + private readonly Dictionary _glottalSourcesByName = new(StringComparer.OrdinalIgnoreCase); + private readonly List _tractInjections = []; + private readonly Dictionary _tractInjectionsByName = new(StringComparer.OrdinalIgnoreCase); + private readonly List _nasalBranches = []; + private readonly Dictionary _nasalBranchesByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _defaults = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _layerDefaults = new(StringComparer.OrdinalIgnoreCase); private PendingOperatorGraph? _pendingOperatorGraph; @@ -60,6 +66,9 @@ public static class PatchScript HarmonicBanks = _harmonicBanks, SpectralBanks = _spectralBanks, TractShapes = _tractShapes, + GlottalSources = _glottalSources, + TractInjections = _tractInjections, + NasalBranches = _nasalBranches, OperatorGraphs = _operatorGraphs, Controls = _controls, Parameters = _parameters, @@ -115,6 +124,18 @@ public static class PatchScript FlushPendingOperatorGraph(); AddTractShape(fields, line); break; + case "glottis": + FlushPendingOperatorGraph(); + AddGlottalSource(fields, line); + break; + case "tract_injection": + FlushPendingOperatorGraph(); + AddTractInjection(fields, line); + break; + case "nasal_branch": + FlushPendingOperatorGraph(); + AddNasalBranch(fields, line); + break; case "voice": FlushPendingOperatorGraph(); Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line)); @@ -286,6 +307,63 @@ public static class PatchScript _tractShapesByName[name] = shape; } + private void AddGlottalSource(IReadOnlyDictionary fields, int line) + { + var name = Required(fields, "name", line); + if (_glottalSourcesByName.ContainsKey(name)) + { + throw new PatchScriptException(line, $"duplicate glottis `{name}`"); + } + + var glottis = new GlottalSource( + name, + GetBoundFloat(fields, line, 0.72f, $"/glottis/{_glottalSources.Count}/intensity", "intensity", "pressure"), + GetBoundFloat(fields, line, 0.6f, $"/glottis/{_glottalSources.Count}/tenseness", "tenseness", "tense"), + GetBoundFloat(fields, line, 0.08f, $"/glottis/{_glottalSources.Count}/aspiration", "aspiration", "breath"), + GetBoundFloat(fields, line, 0.75f, $"/glottis/{_glottalSources.Count}/reflection", "reflection", "glottal_reflection", "gr"), + GetBoundFloat(fields, line, 0.42f, $"/glottis/{_glottalSources.Count}/skew", "skew", "open_phase")); + _glottalSources.Add(glottis); + _glottalSourcesByName[name] = glottis; + } + + private void AddTractInjection(IReadOnlyDictionary fields, int line) + { + var name = Required(fields, "name", line); + if (_tractInjectionsByName.ContainsKey(name)) + { + throw new PatchScriptException(line, $"duplicate tract injection `{name}`"); + } + + var injection = new TractInjection( + name, + GetBoundFloat(fields, line, 32, $"/tract_injections/{_tractInjections.Count}/position", "position", "index", "constriction_index", "ci"), + GetBoundFloat(fields, line, 1, $"/tract_injections/{_tractInjections.Count}/diameter", "diameter", "opening", "constriction_diameter", "cd"), + GetBoundFloat(fields, line, 0, $"/tract_injections/{_tractInjections.Count}/turbulence", "turbulence", "frication"), + GetBoundFloat(fields, line, 0, $"/tract_injections/{_tractInjections.Count}/burst", "burst", "transient"), + GetBoundFloat(fields, line, 1, $"/tract_injections/{_tractInjections.Count}/width", "width")); + _tractInjections.Add(injection); + _tractInjectionsByName[name] = injection; + } + + private void AddNasalBranch(IReadOnlyDictionary fields, int line) + { + var name = Required(fields, "name", line); + if (_nasalBranchesByName.ContainsKey(name)) + { + throw new PatchScriptException(line, $"duplicate nasal branch `{name}`"); + } + + var branch = new NasalBranch( + name, + ParseTractAreaFunction(fields, line), + GetBoundInt(fields, line, 17, $"/nasal_branches/{_nasalBranches.Count}/junction", "junction", "junction_index", "at"), + GetBoundFloat(fields, line, 0.01f, $"/nasal_branches/{_nasalBranches.Count}/velum", "velum", "opening"), + GetBoundFloat(fields, line, -0.85f, $"/nasal_branches/{_nasalBranches.Count}/reflection", "reflection", "lip_reflection"), + GetBoundFloat(fields, line, 0.999f, $"/nasal_branches/{_nasalBranches.Count}/loss", "loss")); + _nasalBranches.Add(branch); + _nasalBranchesByName[name] = branch; + } + private Dictionary ExpandVoiceFields(Dictionary fields, int line) { var expanded = new Dictionary(_defaults, StringComparer.OrdinalIgnoreCase); @@ -527,6 +605,9 @@ public static class PatchScript var voice = ParseVoice(voiceFields, ownerPath, line); var areaFunction = ParseTractAreaFunctionReference(fields, line); + var glottis = ParseGlottalSourceReference(fields, ownerPath, line); + var injection = ParseTractInjectionReference(fields, ownerPath, line); + var nasal = ParseNasalBranchReference(fields, ownerPath, line); var sections = GetBoundInt(fields, line, areaFunction?.Sections ?? 44, OwnerField(ownerPath, "tract/sections"), "sections", "cells"); if (sections < 4) { @@ -536,7 +617,7 @@ public static class PatchScript { throw new PatchScriptException(line, "tract shape section count must match tract sections"); } - var noseSections = GetBoundInt(fields, line, 28, OwnerField(ownerPath, "tract/nose_sections"), "nose_sections", "nose_cells"); + var noseSections = GetBoundInt(fields, line, nasal?.AreaFunction?.Sections ?? 28, OwnerField(ownerPath, "tract/nose_sections"), "nose_sections", "nose_cells"); if (noseSections < 0) { throw new PatchScriptException(line, "tract nose_sections must be non-negative"); @@ -547,18 +628,24 @@ public static class PatchScript Tract = new VocalTract( sections, noseSections, - GetBoundFloat(fields, line, 0.72f, OwnerField(ownerPath, "tract/intensity"), "intensity", "pressure"), - GetBoundFloat(fields, line, 0.6f, OwnerField(ownerPath, "tract/tenseness"), "tenseness", "tense"), + GetBoundFloat(fields, line, glottis?.Intensity ?? 0.72f, OwnerField(ownerPath, "tract/intensity"), "intensity", "pressure"), + GetBoundFloat(fields, line, glottis?.Tenseness ?? 0.6f, OwnerField(ownerPath, "tract/tenseness"), "tenseness", "tense"), GetBoundFloat(fields, line, 12.9f, OwnerField(ownerPath, "tract/tongue_index"), "tongue_index", "tongue", "ti"), GetBoundFloat(fields, line, 2.43f, OwnerField(ownerPath, "tract/tongue_diameter"), "tongue_diameter", "td"), GetBoundFloat(fields, line, 0.01f, OwnerField(ownerPath, "tract/velum"), "velum", "nose", "nasal"), - GetBoundFloat(fields, line, 32, OwnerField(ownerPath, "tract/constriction_index"), "constriction_index", "ci"), - GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "tract/constriction_diameter"), "constriction_diameter", "cd"), - GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "tract/turbulence"), "turbulence", "frication"), + GetBoundFloat(fields, line, injection?.Position ?? 32, OwnerField(ownerPath, "tract/constriction_index"), "constriction_index", "ci"), + GetBoundFloat(fields, line, injection?.Diameter ?? 1, OwnerField(ownerPath, "tract/constriction_diameter"), "constriction_diameter", "cd"), + GetBoundFloat(fields, line, injection?.Turbulence ?? 0, OwnerField(ownerPath, "tract/turbulence"), "turbulence", "frication"), GetBoundFloat(fields, line, 1.5f, OwnerField(ownerPath, "tract/lip_opening"), "lip", "lip_opening", "mouth"), - GetBoundFloat(fields, line, 0.75f, OwnerField(ownerPath, "tract/glottal_reflection"), "glottal_reflection", "gr"), + GetBoundFloat(fields, line, glottis?.Reflection ?? 0.75f, OwnerField(ownerPath, "tract/glottal_reflection"), "glottal_reflection", "gr"), GetBoundFloat(fields, line, -0.85f, OwnerField(ownerPath, "tract/lip_reflection"), "lip_reflection", "lr"), - areaFunction) + areaFunction, + glottis, + injection, + nasal, + ParseTractPropagationMode(GetAny(fields, ["propagation", "model"], "resonator"), line), + GetBoundFloat(fields, line, 0.999f, OwnerField(ownerPath, "tract/waveguide_loss"), "waveguide_loss", "loss"), + GetBoundInt(fields, line, 1, OwnerField(ownerPath, "tract/substeps"), "substeps", "steps")) }; } @@ -967,6 +1054,88 @@ public static class PatchScript return shape.AreaFunction; } + private GlottalSource? ParseGlottalSourceReference(IReadOnlyDictionary fields, string ownerPath, int line) + { + var hasInlineGlottis = HasAny(fields, "aspiration", "breath", "skew", "open_phase"); + var hasNamedGlottis = TryGetAny(fields, ["glottis", "source"], out var glottisName); + if (!hasNamedGlottis && !hasInlineGlottis) + { + return null; + } + GlottalSource? namedGlottis = null; + if (hasNamedGlottis && !_glottalSourcesByName.TryGetValue(glottisName, out namedGlottis)) + { + throw new PatchScriptException(line, $"unknown glottis `{glottisName}`"); + } + + var fallback = hasNamedGlottis ? namedGlottis! : new GlottalSource(); + return new GlottalSource( + hasNamedGlottis ? glottisName : "", + GetBoundFloat(fields, line, fallback.Intensity, OwnerField(ownerPath, "tract/glottis/intensity"), "intensity", "pressure"), + GetBoundFloat(fields, line, fallback.Tenseness, OwnerField(ownerPath, "tract/glottis/tenseness"), "tenseness", "tense"), + GetBoundFloat(fields, line, fallback.Aspiration, OwnerField(ownerPath, "tract/glottis/aspiration"), "aspiration", "breath"), + GetBoundFloat(fields, line, fallback.Reflection, OwnerField(ownerPath, "tract/glottis/reflection"), "glottal_reflection", "gr"), + GetBoundFloat(fields, line, fallback.Skew, OwnerField(ownerPath, "tract/glottis/skew"), "skew", "open_phase")); + } + + private TractInjection? ParseTractInjectionReference(IReadOnlyDictionary fields, string ownerPath, int line) + { + var hasInlineInjection = HasAny(fields, "burst", "transient", "width"); + var hasNamedInjection = TryGetAny(fields, ["injection", "tract_injection"], out var injectionName); + if (!hasNamedInjection && !hasInlineInjection) + { + return null; + } + TractInjection? namedInjection = null; + if (hasNamedInjection && !_tractInjectionsByName.TryGetValue(injectionName, out namedInjection)) + { + throw new PatchScriptException(line, $"unknown tract injection `{injectionName}`"); + } + + var fallback = hasNamedInjection ? namedInjection! : new TractInjection(); + return new TractInjection( + hasNamedInjection ? injectionName : "", + GetBoundFloat(fields, line, fallback.Position, OwnerField(ownerPath, "tract/injection/position"), "position", "constriction_index", "ci"), + GetBoundFloat(fields, line, fallback.Diameter, OwnerField(ownerPath, "tract/injection/diameter"), "diameter", "constriction_diameter", "cd"), + GetBoundFloat(fields, line, fallback.Turbulence, OwnerField(ownerPath, "tract/injection/turbulence"), "turbulence", "frication"), + GetBoundFloat(fields, line, fallback.Burst, OwnerField(ownerPath, "tract/injection/burst"), "burst", "transient"), + GetBoundFloat(fields, line, fallback.Width, OwnerField(ownerPath, "tract/injection/width"), "width")); + } + + private NasalBranch? ParseNasalBranchReference(IReadOnlyDictionary fields, string ownerPath, int line) + { + var hasInlineNasal = HasAny(fields, "nose_diameters", "nose_areas", "nose_junction"); + var hasNamedNasal = TryGetAny(fields, ["nasal_branch", "nasal_shape"], out var nasalName); + if (!hasNamedNasal && !hasInlineNasal) + { + return null; + } + NasalBranch? namedNasal = null; + if (hasNamedNasal && !_nasalBranchesByName.TryGetValue(nasalName, out namedNasal)) + { + throw new PatchScriptException(line, $"unknown nasal branch `{nasalName}`"); + } + + var fallback = hasNamedNasal ? namedNasal! : new NasalBranch(AreaFunction: new TractAreaFunction([0.01f, 0.6f, 1.2f, 1.6f])); + var areaFunction = fallback.AreaFunction; + if (TryGetAny(fields, ["nose_diameters"], out var diameters)) + { + areaFunction = new TractAreaFunction(ParseFloatList(diameters, line, "nose_diameters").ToArray()); + } + if (TryGetAny(fields, ["nose_areas"], out var areas)) + { + areaFunction = TractAreaFunction.FromAreas(ParseFloatList(areas, line, "nose_areas").ToArray()); + } + + return new NasalBranch( + hasNamedNasal ? nasalName : "", + areaFunction, + GetBoundInt(fields, line, fallback.JunctionIndex, OwnerField(ownerPath, "tract/nasal/junction"), "nose_junction", "junction", "junction_index"), + GetBoundFloat(fields, line, fallback.Velum, OwnerField(ownerPath, "tract/nasal/velum"), "velum", "nose", "nasal"), + GetBoundFloat(fields, line, fallback.Reflection, OwnerField(ownerPath, "tract/nasal/reflection"), "nose_reflection"), + GetBoundFloat(fields, line, fallback.Loss, OwnerField(ownerPath, "tract/nasal/loss"), "nose_loss")); + } + private static TractAreaFunction ParseTractAreaFunction(IReadOnlyDictionary fields, int line) { var hasDiameters = TryGetAny(fields, ["diameters", "diameter"], out var diametersText); @@ -1404,6 +1573,9 @@ public static class PatchScript "harmonics" or "partials" or "drawbars" => "harmonics", "spectrum" or "spectral" or "padsource" or "pad_source" => "spectrum", "tractshape" or "tract_shape" or "area_function" or "tract_area" => "tract_shape", + "glottis" or "glottal" or "excitation" => "glottis", + "tract_injection" or "injection" or "frication" or "burst" => "tract_injection", + "nasal_branch" or "nose_branch" or "nasal" => "nasal_branch", "v" or "voice" => "voice", "tract" or "vt" or "tractvoice" or "tract_voice" => "tract", "opgraph" or "ops" or "operators" => "opgraph", @@ -1495,6 +1667,13 @@ public static class PatchScript _ => target.ToString().ToLowerInvariant() }; + private static TractPropagationMode ParseTractPropagationMode(string value, int line) => value.ToLowerInvariant() switch + { + "resonator" or "proxy" or "formant" => TractPropagationMode.Resonator, + "waveguide" or "kl" or "kelly-lochbaum" or "kelly_lochbaum" or "tube" => TractPropagationMode.Waveguide, + _ => throw new PatchScriptException(line, $"unknown tract propagation mode `{value}`") + }; + private static string Required(IReadOnlyDictionary fields, string key, int line) => fields.TryGetValue(key, out var value) ? value : throw new PatchScriptException(line, $"missing `{key}`"); diff --git a/src/AquaSynth.Core/PinkTromboneReference.cs b/src/AquaSynth.Core/PinkTromboneReference.cs index e6f1708..76da783 100644 --- a/src/AquaSynth.Core/PinkTromboneReference.cs +++ b/src/AquaSynth.Core/PinkTromboneReference.cs @@ -63,18 +63,21 @@ public static class PinkTromboneReference new("tract_voice_authority", "voice.Tract", "Pink Trombone pressure now lowers through a tract treatment on an ordinary AquaSynth voice."), new("tract_area_function", "tract_shape diameters", "AquaSynth can now author reusable section diameter/area functions for tract voices."), new("diameter_to_reflection_coefficients", "TractAreaFunction.ReflectionCoefficients", "AquaSynth derives adjacent-section reflection coefficients from squared diameters as a reusable primitive."), + new("glottal_source_primitive", "glottis", "AquaSynth can author reusable glottal excitation controls for tract voices."), + new("positioned_injection_primitive", "tract_injection", "AquaSynth can author reusable constriction-position noise/burst injection controls."), + new("main_tract_waveguide_cells", "waveguide propagation over tract_shape sections", "AquaSynth can emit an oral bidirectional tube from derived section reflection coefficients."), + new("reflection_coefficients_applied_to_waveguide", "generated scattering junctions", "Derived reflection coefficients feed generated right/left wave scattering equations."), + new("nose_waveguide_cells", "nasal_branch", "AquaSynth can author a nasal branch as its own diameter/area tube."), + new("nose_junction", "generated three-way junction", "Waveguide lowering can emit a velum-controlled oral/nasal junction from local areas."), 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("main_tract_waveguide_cells", "44 bidirectional sections", "No AquaSynth DSL authority owns arrays of right/left traveling wave state per tract section."), - new("nose_waveguide_cells", "28 bidirectional sections", "The current DSL has no nasal branch delay-line graph or velum-controlled three-way junction."), - new("reflection_coefficients_applied_to_waveguide", "per-block area reflection update", "AquaSynth can derive reflection coefficients, but the current Faust proxy does not apply them to bidirectional waveguide propagation."), 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("positioned_turbulence_injection", "noise injected into neighboring cells", "The current noise field is source/color pressure, not a positioned tract injection event."), - new("closure_release_transients", "obstruction release impulse", "AquaSynth envelopes can shape amplitude, but they do not observe obstruction state and emit release transients."), + new("positioned_turbulence_applied_to_waveguide_cells", "noise injected into neighboring cells", "AquaSynth can author positioned injection controls, but the current Faust proxy does not inject into bidirectional waveguide cell state."), + new("stateful_closure_release_transients", "obstruction release impulse", "AquaSynth can author burst pressure, but it does not yet observe prior obstruction state and emit PT-style release transients."), new("lf_glottal_exactness", "Pink Trombone LF-style coefficients", "The proxy uses ordinary oscillators/noise, not the LF coefficient path from tenseness.") ], "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 ac85895..1b9649a 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -132,3 +132,4 @@ {"date":"2026-05-25","topic":"pink-trombone-parity-boundary","lesson":"Pink Trombone parity must pressure AquaSynth at the tract-DSP authority, not through more source/filter proxy controls. The current DSL can expose parameters and formant/noise proxies, but it cannot exactly represent Pink Trombone's moving bidirectional waveguide graph: 44 main tract cells, 28 nasal cells, diameter-to-area reflection coefficients, velum junction, twice-rate tract stepping, positioned turbulence injection, closure transients, or exact LF glottal coefficient updates.","evidence":"Inspected `chdh/pink-trombone-mod` source (`Synthesizer.ts`, `Glottis.ts`, `Tract.ts`, `TractShaper.ts`) and added `docs/pink-trombone-parity.md`, `PinkTromboneReference`, a current non-passing AquaSynth proxy rebuild, and `PinkTromboneReferenceDeclaresMissingWaveguideAuthority` so the proxy cannot be mistaken for actual tract parity."} {"date":"2026-05-25","topic":"tract-voice-dsl","lesson":"Pink Trombone pressure belongs on the voice as an expressive tract treatment before it becomes exact anatomy. The `tract` DSL command now lowers into `Voice.Tract`, exposes PT-shaped runtime controls, and emits a Faust tract-voice proxy; it must not be treated as a separate patch graph owner or as exact 44-cell/28-cell waveguide parity.","evidence":"Added `VocalTract` on `Voice`, `tract` parsing and field-site bindings, Faust tract proxy lowering, an updated built-in Pink Trombone proxy, parity docs/reference metadata, and a WebAudio tract playground with matching knobs. Focused tests covered parse -> model -> Faust and Pink Trombone reference declarations."} {"date":"2026-05-25","topic":"pink-trombone-expressive-parity","lesson":"The Pink Trombone path is expressive-parity first, audio-parity second. Do not half-consume PT into one helper. Decompose its pipeline into reusable Aqua DSL primitives, then golf rendered PT fixtures with log-mel cosine similarity once the language can express the pipeline honestly.","evidence":"Added `docs/pink-trombone-dsl-parity-plan.md`, oriented parity docs/spine/map, and implemented the first primitive: `tract_shape` declarations parse into reusable `TractAreaFunction` objects that derive areas/reflection coefficients and feed `Voice.Tract` lowering. Focused tests assert shape ownership, coefficient derivation, and reference matched/missing features."} +{"date":"2026-05-26","topic":"pink-trombone-low-level-dsp-primitives","lesson":"PT parity must be targeted at the low-level DSP loop: right/left traveling-wave state, reflection coefficients, scattering junctions, nasal branch junctions, and injected excitation. AquaSynth now has reusable primitives for tract shape, glottal source, tract injection, nasal branch, and generated oral/nasal waveguide lowering; remaining gaps are true twice-rate stepping, cell-local turbulence/transient injection, target diameter slew, and rendered PT fixture scoring.","evidence":"Added `GlottalSource`, `TractInjection`, `NasalBranch`, `TractPropagationMode.Waveguide`, parser commands `glottis`, `tract_injection`, `nasal_branch`, and Faust generation for oral right/left waveguide sections plus a velum-controlled three-way nose junction. Focused verification: `dotnet build tests\\AquaSynth.Dsl.Tests\\AquaSynth.Dsl.Tests.csproj --no-restore --disable-build-servers -v minimal` and focused `dotnet test --no-build` passed."} diff --git a/state/map.yaml b/state/map.yaml index 4505bfa..2004bda 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -45,6 +45,7 @@ modules: vocal_tract_learning: src/AquaSynth.Core/VocalTractLearning.cs tract_voice_model: src/AquaSynth.Core/Model.cs tract_shape_model: src/AquaSynth.Core/Model.cs + tract_waveguide_lowering: src/AquaSynth.Core/FaustExport.cs faust_emitter: src/AquaSynth.Core/FaustExport.cs faust_compiler: src/AquaSynth.Faust/FaustCompiler.cs native_faust_runtime: src/AquaSynth.Faust/NativeFaustRuntime.cs diff --git a/state/memory.json b/state/memory.json index 8d1c291..7493d58 100644 --- a/state/memory.json +++ b/state/memory.json @@ -51,7 +51,7 @@ "generated DSP artifact manifests for engine hosting", "live patch compilation, native Faust factory lifetime, and offline sample-buffer render proof for engine hosts", "phonetic intent, articulatory plan, morphology capability, and constraint-report contracts for vocal tract synthesis", - "Pink Trombone expressive-parity path through reusable tract DSL primitives, starting with tract_shape diameter/area functions", + "Pink Trombone expressive-parity path through reusable tract DSL primitives: tract_shape, glottis, tract_injection, nasal_branch, and generated oral/nasal waveguide lowering", "utterance parity curriculum research and docs", "patch script parser", "SFXR atoms and parameter mapping", @@ -90,7 +90,7 @@ "Surge XT for modern hybrid synth modulation", "Cardinal/VCV-style patches for modular cable/rate semantics" ], - "rule": "For speech, add schema fields and learned-control lanes only when the parity harness has a loss surface that can exercise them. For reference synths, add DSL abstractions only when rebuilds prove the missing invariant. Parsing alone is inventory, not evidence of expressivity. Pink Trombone should be decomposed into reusable Aqua primitives before audio golf, not copied as a bespoke clone." + "rule": "For speech, add schema fields and learned-control lanes only when the parity harness has a loss surface that can exercise them. For reference synths, add DSL abstractions only when rebuilds prove the missing invariant. Parsing alone is inventory, not evidence of expressivity. Pink Trombone should be decomposed into reusable low-level Aqua primitives before audio golf, not copied as a bespoke clone or inflated into PT-shaped modules." }, "ipa_vocal_tract_path": { "status": "This is now the active project center. Roadmap and research notes align the curriculum around structured utterance metadata -> learned utterance embedding -> learned synth automation -> vocal tract/envelope/LFO/filter/spectral controls -> rendered audio loss. The first Core contracts still defend the boundary: PhoneticIntent is the Weksa/language-owned handoff, ArticulatoryPlan is the AquaSynth realization layer, VocalTractMorphology declares anatomy capabilities, ArticulatoryConstraintReport is the host-visible veto surface, and VocalTractPlanResult is the tiny end-to-end proof artifact for host branching. IPA is an authoring input only: tokenization and feature mapping lower into language phonology profiles, timed articulatory gestures, morphology-specific tract/excitation curves, and Faust DSP. The staged support plan starts with tokenization and vowels, then core pulmonic consonants, expanded human IPA, airstream/phonation, and Weksa/alien morphologies. Optional eSpeak NG tests provide the first generated speech workout when ESPEAK_NG or espeak-ng is installed; eSpeak is intelligibility/timing/log-mel pressure, not anatomy truth. AquaSynth owns the learned utterance embedding encoder and synth driver: speech text embeddings plus prosody/emphasis hints plus Ghostlight/Epiphany-shaped character state vectors compress into utterance embeddings, and phonetic features plus that embedding feed a packed-buffer native C# backprop/Adam mapper that predicts tract, AM/FM, LFO, filter, and mel spectral-envelope controls. SpeechBackpropagationPipeline chains the two models: synth-driver target loss returns gradients with respect to its input, slices the utterance-embedding gradient, and updates the utterance encoder from downstream speech loss. The generated ground-truth training fixture renders eSpeak syllables, extracts log-mel evidence, and trains that chained pipeline toward eSpeak-seeded mel/control targets. CultMesh speech training now has payload manifests, peer/lease admission receipts, worker assignment, result collection, and central checkpoint application; workers do not own model updates. Real remote artifact chunk transfer and process supervision remain CultMesh infrastructure surfaces, not AquaSynth workaround territory.", diff --git a/state/spine.yaml b/state/spine.yaml index f933fa5..25ba9e3 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -1,6 +1,6 @@ project: AquaSynth spine_version: 1 -updated: 2026-05-25T23:55:00+01:00 +updated: 2026-05-26T00:20:00+01:00 prime_directive: > Build coherent synth machinery. References exist to reveal missing structure, @@ -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; future glottis, positioned injection, nasal junction, waveguide propagation, and substep primitives should compose with normal voices, parameters, envelopes, LFOs, layers, and learned speech controls. + - 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; `nasal_branch` owns a second diameter/area tube; `propagation=waveguide` lowers tract voices into generated oral/nasal right-left waveguide equations. Remaining low-level PT gaps are true two-steps-per-sample execution, cell-local turbulence/transient injection, target diameter slew, and parity fixtures. - 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. - Speech training receipts must keep time as a witness: every request, render result, and checkpoint should record stage decision time, latency spent, latency budget, confidence, and artifact/model identifiers. diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index 18bc151..35da481 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -87,7 +87,10 @@ public sealed class PatchScriptTests var patch = PatchScript.Parse(""" param path=/pink/tenseness default=.6 min=0 max=1 step=.001 tract_shape name=human diameters=.6,.7,.9,1.1,1.3,1.5,1.4,1.2 - tract shape=human freq=140 intensity=.72 tenseness=@/pink/tenseness tongue_index=4 tongue_diameter=1.4 velum=.2 constriction_index=6 constriction_diameter=.45 turbulence=.8 + glottis name=modal intensity=.72 tenseness=@/pink/tenseness aspiration=.14 reflection=.72 skew=.5 + tract_injection name=sibilant position=6 diameter=.45 turbulence=.8 burst=.3 width=.8 + nasal_branch name=nose junction=3 velum=.2 diameters=.01,.6,1.2,1.4 + tract shape=human glottis=modal injection=sibilant nasal_branch=nose propagation=waveguide waveguide_loss=.998 substeps=2 freq=140 tongue_index=4 tongue_diameter=1.4 velum=.2 """); var shape = Assert.Single(patch.TractShapes); @@ -95,23 +98,42 @@ public sealed class PatchScriptTests Assert.Equal(8, shape.AreaFunction.Sections); Assert.Equal(7, shape.AreaFunction.ReflectionCoefficients.Count); Assert.Contains(shape.AreaFunction.ReflectionCoefficients, coefficient => MathF.Abs(coefficient) > 0.01f); + var glottis = Assert.Single(patch.GlottalSources); + Assert.Equal("modal", glottis.Name); + Assert.Equal(.14f, glottis.Aspiration, 5); + var injection = Assert.Single(patch.TractInjections); + Assert.Equal("sibilant", injection.Name); + Assert.Equal(.3f, injection.Burst, 5); + var nasal = Assert.Single(patch.NasalBranches); + Assert.Equal("nose", nasal.Name); + Assert.Equal(4, nasal.AreaFunction?.Sections); var voice = Assert.Single(patch.Voices); Assert.NotNull(voice.Tract); Assert.Equal(8, voice.Tract.Sections); - Assert.Equal(28, voice.Tract.NoseSections); + Assert.Equal(4, voice.Tract.NoseSections); Assert.Equal(4, voice.Tract.TongueIndex); Assert.Equal(.45f, voice.Tract.ConstrictionDiameter, 5); Assert.Same(shape.AreaFunction, voice.Tract.AreaFunction); - Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/voices/0/tract/tenseness"); + Assert.Equal(glottis, voice.Tract.Glottis); + Assert.Equal(injection, voice.Tract.Injection); + Assert.Equal(nasal, voice.Tract.Nasal); + Assert.Equal(TractPropagationMode.Waveguide, voice.Tract.Propagation); + Assert.Equal(.998f, voice.Tract.WaveguideLoss, 5); + Assert.Equal(2, voice.Tract.Substeps); + Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/glottis/0/tenseness"); var faust = FaustEmitter.Emit(patch, new FaustExportOptions("tract_voice")).Source; Assert.Contains("tract_lf", faust); Assert.Contains("tract_frication", faust); - Assert.Contains("tract_nose", faust); Assert.Contains("tract_shape_middle", faust); Assert.Contains("tract_reflection_energy", faust); - Assert.Contains("fi.resonbp", faust); + Assert.Contains("tract_lf_open", faust); + Assert.Contains("tract_injection_close", faust); + Assert.Contains("wg_scatter_1", faust); + Assert.Contains("nose_reflect_nose", faust); + Assert.Contains("tract_nose_waveguide", faust); + Assert.Contains("tract_oral_waveguide", faust); } [Fact] @@ -339,10 +361,17 @@ public sealed class PatchScriptTests Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "tract_voice_authority"); Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "tract_area_function"); Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "diameter_to_reflection_coefficients"); - Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "main_tract_waveguide_cells"); - Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "reflection_coefficients_applied_to_waveguide"); - Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "nose_waveguide_cells"); + Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "glottal_source_primitive"); + Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "positioned_injection_primitive"); + Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "main_tract_waveguide_cells"); + Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "reflection_coefficients_applied_to_waveguide"); + Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "nose_waveguide_cells"); + Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "nose_junction"); + Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "positioned_turbulence_applied_to_waveguide_cells"); Assert.NotEmpty(patch.TractShapes); + Assert.NotEmpty(patch.GlottalSources); + Assert.NotEmpty(patch.TractInjections); + Assert.NotEmpty(patch.NasalBranches); Assert.NotEmpty(patch.Parameters); Assert.Contains("process =", export.Source); }