diff --git a/docs/pink-trombone-dsl-parity-plan.md b/docs/pink-trombone-dsl-parity-plan.md new file mode 100644 index 0000000..32214c1 --- /dev/null +++ b/docs/pink-trombone-dsl-parity-plan.md @@ -0,0 +1,85 @@ +# Pink Trombone DSL Parity Plan + +## Objective + +Cover Pink Trombone's useful pipeline with AquaSynth language constructs, then +use Pink Trombone as an audio parity target. Do not build a PT clone with Aqua +paint on it. The primitives must compose with ordinary Aqua voices, envelopes, +LFOs, parameters, layers, learned speech controls, filters, and future +morphologies. + +## Current Mechanism + +`tract` currently lowers into `Voice.Tract`: a voice-local scalar control bundle +with a Faust source/filter proxy. It is useful as a playground surface and as a +named failure against Pink Trombone, but it does not own the anatomy: + +- no reusable tract area function; +- no derived area/reflection coefficient primitive; +- no traveling-wave state owner; +- no nasal junction primitive; +- no positioned excitation/injection events; +- no substep/rate semantics; +- no parity renderer/score loop against PT output. + +## Invariants + +- PT pressure must become Aqua DSL vocabulary, not a bespoke PT mode. +- A tract voice remains a voice. Tract primitives are treatments, sources, + curves, junctions, and event lanes that can be combined with the rest of the + patch graph. +- Diameter/area functions own static or slowly moving tract shape. Tongue and + constriction gestures deform that shape; they do not replace the owner. +- Reflection coefficients derive from adjacent areas. Caches and summaries may + observe that derivation, but must not become independent truth. +- Waveguide state owns delay-line propagation once it exists. Formant filters + may remain cheap approximations, but they must be named as approximations. +- Expressive parity comes before audio golf. Audio parity claims require + rendered PT fixtures, Aqua renders, and log-mel cosine similarity. + +## Primitive Decomposition + +1. `tract_shape`: reusable section diameter/area function. + - Owns sampled diameters or areas over normalized tract position. + - Emits diameters, areas, reflection coefficients, and shape summaries. + - Feeds `tract` voices and later morphology/gesture planning. + +2. `glottis`: reusable excitation primitive. + - Owns frequency, intensity, tenseness, aspiration, and reflection intent. + - Can feed tract voices or other voice treatments. + +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. + +4. `nasal_junction`: velum-controlled branch primitive. + - Owns nasal opening and branch shape. + - Derives the three-way junction coefficients from local areas. + +5. `waveguide_tract`: propagation primitive. + - Owns right/left traveling-wave state, substep rate, loss, and radiation. + - Consumes area/reflection fields and source/injection events. + +6. PT parity harness. + - Renders fixed PT references for vowels, nasals, fricatives, closures, and + moving tongue/constriction gestures. + - Renders Aqua DSL reconstructions. + - Scores log-mel cosine similarity plus targeted feature probes. + +## 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. + +## Cut Line + +The current `Voice.Tract` scalar proxy may survive only as an adapter over real +tract primitives. If a future change adds PT behavior that cannot be described +as a reusable Aqua primitive, cut it or keep it in the reference harness instead +of promoting it to the public DSL. diff --git a/docs/pink-trombone-parity.md b/docs/pink-trombone-parity.md index b682d82..1d7935a 100644 --- a/docs/pink-trombone-parity.md +++ b/docs/pink-trombone-parity.md @@ -51,6 +51,8 @@ and emits a transient when a closure opens. 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; - 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 @@ -71,7 +73,8 @@ Current AquaSynth cannot exactly express: - a 28-cell nasal waveguide branch; - a three-way velum/nose junction; - per-section diameter targets as synthesis authority; -- diameter-squared area reflection coefficients; +- 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; @@ -88,6 +91,9 @@ Trombone anatomy: AquaSynth tract-voice proxy as a deliberately non-passing rebuild. - The `tract` DSL command parses into an ordinary `Voice` with `Voice.Tract`, so this remains a voice with a very expressive tract treatment. +- `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. - 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 7e95f55..62e4b9c 100644 --- a/src/AquaSynth.Core/BuiltInScripts.cs +++ b/src/AquaSynth.Core/BuiltInScripts.cs @@ -626,7 +626,12 @@ public static class BuiltInScripts param name=glottal_reflection path=/pink/glottal/reflection default=0.75 min=-0.95 max=0.95 step=0.001 param name=lip_reflection path=/pink/lip/reflection default=-0.85 min=-0.98 max=0.1 step=0.001 + tract_shape + 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 + tract + shape=pt_human freq=@/pink/frequency gain=0.7 attack=0.004 diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index e73dc6d..edfdb97 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -404,6 +404,12 @@ public static class FaustEmitter var lipOpening = parameters.Expression(OwnerField(tractPath, "lip_opening"), tract.LipOpening); 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 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))); source.AppendLine($"{name}_tract_phase = os.phasor(1.0, {frequency});"); source.AppendLine($"{name}_tract_tongue_pos = clip01({tongueIndex} / {F(sections)});"); @@ -411,10 +417,15 @@ public static class FaustEmitter 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_lip = clip01({lipOpening} / 2.5);"); - source.AppendLine($"{name}_tract_q = 2.0 + {tenseness} * 10.0 + {name}_tract_constriction_close * 8.0;"); - source.AppendLine($"{name}_tract_f1 = max(90.0, 260.0 + {name}_tract_lip * 720.0 - {name}_tract_tongue_close * 260.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 - (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);"); + source.AppendLine($"{name}_tract_shape_back = {shapeBack};"); + source.AppendLine($"{name}_tract_shape_middle = {shapeMiddle};"); + source.AppendLine($"{name}_tract_shape_front = {shapeFront};"); + source.AppendLine($"{name}_tract_shape_min = {shapeMinimum};"); + source.AppendLine($"{name}_tract_reflection_energy = {reflectionEnergy};"); + source.AppendLine($"{name}_tract_q = 2.0 + {tenseness} * 10.0 + {name}_tract_constriction_close * 8.0 + {name}_tract_reflection_energy * 4.0;"); + 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});"); diff --git a/src/AquaSynth.Core/Model.cs b/src/AquaSynth.Core/Model.cs index d370d95..9957fa5 100644 --- a/src/AquaSynth.Core/Model.cs +++ b/src/AquaSynth.Core/Model.cs @@ -138,6 +138,51 @@ public sealed record Formant(float FrequencyHz, float BandwidthHz, float Gain); public sealed record FormantFrame(IReadOnlyList Formants); +public sealed record TractAreaFunction(IReadOnlyList Diameters) +{ + public int Sections => Diameters.Count; + + public IReadOnlyList Areas => Diameters.Select(diameter => MathF.Max(0, diameter) * MathF.Max(0, diameter)).ToArray(); + + public IReadOnlyList ReflectionCoefficients + { + get + { + if (Diameters.Count < 2) return Array.Empty(); + var reflections = new float[Diameters.Count - 1]; + for (var i = 1; i < Diameters.Count; i++) + { + var previousArea = MathF.Max(0.000001f, Diameters[i - 1] * Diameters[i - 1]); + var area = MathF.Max(0.000001f, Diameters[i] * Diameters[i]); + reflections[i - 1] = (previousArea - area) / (previousArea + area); + } + + return reflections; + } + } + + public float AverageDiameter(float startFraction, float endFraction) + { + if (Diameters.Count == 0) return 0; + var start = Math.Clamp((int)MathF.Floor(startFraction * Diameters.Count), 0, Diameters.Count - 1); + var end = Math.Clamp((int)MathF.Ceiling(endFraction * Diameters.Count), start + 1, Diameters.Count); + var sum = 0f; + for (var i = start; i < end; i++) + { + sum += Diameters[i]; + } + + return sum / Math.Max(1, end - start); + } + + public float MinimumDiameter => Diameters.Count == 0 ? 0 : Diameters.Min(); + + public static TractAreaFunction FromAreas(IReadOnlyList areas) => + new(areas.Select(area => MathF.Sqrt(MathF.Max(0, area))).ToArray()); +} + +public sealed record TractShape(string Name, TractAreaFunction AreaFunction); + public sealed record Modulator( ModTarget Target, ModWaveform Waveform = ModWaveform.Sine, @@ -191,7 +236,8 @@ public sealed record VocalTract( float Turbulence = 0, float LipOpening = 1.5f, float GlottalReflection = 0.75f, - float LipReflection = -0.85f); + float LipReflection = -0.85f, + TractAreaFunction? AreaFunction = null); public sealed record PatchParameter( string Path, @@ -364,6 +410,7 @@ public sealed record SynthPatch public IReadOnlyList Layers { get; init; } = Array.Empty(); public IReadOnlyList HarmonicBanks { get; init; } = Array.Empty(); public IReadOnlyList SpectralBanks { get; init; } = Array.Empty(); + public IReadOnlyList TractShapes { 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 f3340a1..78d94b0 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -36,6 +36,8 @@ public static class PatchScript private readonly List _layers = []; private readonly List _harmonicBanks = []; private readonly List _spectralBanks = []; + private readonly List _tractShapes = []; + private readonly Dictionary _tractShapesByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _defaults = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _layerDefaults = new(StringComparer.OrdinalIgnoreCase); private PendingOperatorGraph? _pendingOperatorGraph; @@ -57,6 +59,7 @@ public static class PatchScript Layers = _layers, HarmonicBanks = _harmonicBanks, SpectralBanks = _spectralBanks, + TractShapes = _tractShapes, OperatorGraphs = _operatorGraphs, Controls = _controls, Parameters = _parameters, @@ -108,6 +111,10 @@ public static class PatchScript FlushPendingOperatorGraph(); AddSpectralBank(fields, line); break; + case "tract_shape": + FlushPendingOperatorGraph(); + AddTractShape(fields, line); + break; case "voice": FlushPendingOperatorGraph(); Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line)); @@ -265,6 +272,20 @@ public static class PatchScript private static string SpectralPath(int spectralIndex) => $"/spectral/{spectralIndex}"; + private void AddTractShape(IReadOnlyDictionary fields, int line) + { + var name = Required(fields, "name", line); + if (_tractShapesByName.ContainsKey(name)) + { + throw new PatchScriptException(line, $"duplicate tract shape `{name}`"); + } + + var areaFunction = ParseTractAreaFunction(fields, line); + var shape = new TractShape(name, areaFunction); + _tractShapes.Add(shape); + _tractShapesByName[name] = shape; + } + private Dictionary ExpandVoiceFields(Dictionary fields, int line) { var expanded = new Dictionary(_defaults, StringComparer.OrdinalIgnoreCase); @@ -505,11 +526,16 @@ public static class PatchScript } var voice = ParseVoice(voiceFields, ownerPath, line); - var sections = GetBoundInt(fields, line, 44, OwnerField(ownerPath, "tract/sections"), "sections", "cells"); + var areaFunction = ParseTractAreaFunctionReference(fields, line); + var sections = GetBoundInt(fields, line, areaFunction?.Sections ?? 44, OwnerField(ownerPath, "tract/sections"), "sections", "cells"); if (sections < 4) { throw new PatchScriptException(line, "tract sections must be at least 4"); } + if (areaFunction is not null && areaFunction.Sections != sections) + { + 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"); if (noseSections < 0) { @@ -531,7 +557,8 @@ public static class PatchScript GetBoundFloat(fields, line, 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, -0.85f, OwnerField(ownerPath, "tract/lip_reflection"), "lip_reflection", "lr")) + GetBoundFloat(fields, line, -0.85f, OwnerField(ownerPath, "tract/lip_reflection"), "lip_reflection", "lr"), + areaFunction) }; } @@ -916,6 +943,54 @@ public static class PatchScript .ToArray(); } + private TractAreaFunction? ParseTractAreaFunctionReference(IReadOnlyDictionary fields, int line) + { + var hasInlineShape = HasAny(fields, "diameters", "diameter", "areas", "area"); + var hasNamedShape = TryGetAny(fields, ["shape", "tract_shape", "area_function"], out var shapeName); + if (hasInlineShape && hasNamedShape) + { + throw new PatchScriptException(line, "tract cannot use both a named shape and inline diameters/areas"); + } + if (hasInlineShape) + { + return ParseTractAreaFunction(fields, line); + } + if (!hasNamedShape) + { + return null; + } + if (!_tractShapesByName.TryGetValue(shapeName, out var shape)) + { + throw new PatchScriptException(line, $"unknown tract shape `{shapeName}`"); + } + + return shape.AreaFunction; + } + + private static TractAreaFunction ParseTractAreaFunction(IReadOnlyDictionary fields, int line) + { + var hasDiameters = TryGetAny(fields, ["diameters", "diameter"], out var diametersText); + var hasAreas = TryGetAny(fields, ["areas", "area"], out var areasText); + if (hasDiameters == hasAreas) + { + throw new PatchScriptException(line, "tract shape needs exactly one of diameters or areas"); + } + + var samples = ParseFloatList(hasDiameters ? diametersText! : areasText!, line, hasDiameters ? "diameters" : "areas"); + if (samples.Count < 4) + { + throw new PatchScriptException(line, "tract shape needs at least four samples"); + } + if (samples.Any(sample => sample < 0)) + { + throw new PatchScriptException(line, "tract shape samples must be zero or greater"); + } + + return hasDiameters + ? new TractAreaFunction(samples.ToArray()) + : TractAreaFunction.FromAreas(samples.ToArray()); + } + private static PadSpectrumProfile ParsePadSpectrumProfile(IReadOnlyDictionary fields, int line) { var hasPadProfileFields = @@ -1328,6 +1403,7 @@ public static class PatchScript "layer" or "kit" => "layer", "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", "v" or "voice" => "voice", "tract" or "vt" or "tractvoice" or "tract_voice" => "tract", "opgraph" or "ops" or "operators" => "opgraph", diff --git a/src/AquaSynth.Core/PinkTromboneReference.cs b/src/AquaSynth.Core/PinkTromboneReference.cs index cb0c7a4..e6f1708 100644 --- a/src/AquaSynth.Core/PinkTromboneReference.cs +++ b/src/AquaSynth.Core/PinkTromboneReference.cs @@ -61,6 +61,8 @@ public static class PinkTromboneReference BuiltInScripts.PinkTromboneProxy, [ 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("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."), @@ -69,7 +71,7 @@ public static class PinkTromboneReference [ 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("diameter_to_reflection_coefficients", "per-block area reflection update", "Formant filters do not preserve the section-area ownership or reflection math."), + 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."), diff --git a/state/evidence.jsonl b/state/evidence.jsonl index d84e39d..ac85895 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -131,3 +131,4 @@ {"date":"2026-05-25","topic":"speech-loss-control-authority","lesson":"Exporting `/speech/output/N` controls is not proof that the speech training surface is usable. Every base VocalTractControlTarget output must route into audible DSP before gradient descent is trusted; epsilon keep-alive references are not control authority.","evidence":"The compiled Faust speech-loss surface previously used tongue body, tongue tip, lip aperture, lip rounding, and velum only in the final keep-controls epsilon. Routed tract controls into small resonant source-filter proxies, velum into a nasal tone, LFO depth into tremolo depth, and added tests that fail when base controls are only declared/kept alive. Focused verification: `dotnet test AquaSynth.sln --no-restore --filter FaustRenderedSpeechLossSurfaceTests` passed. Full `dotnet test AquaSynth.sln --no-restore` timed out twice at 120s and 300s without a result; orphaned dotnet workers were stopped."} {"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."} diff --git a/state/map.yaml b/state/map.yaml index c2d04af..4505bfa 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -19,6 +19,7 @@ surfaces: ipa_vocal_tract_roadmap: docs/ipa-vocal-tract-roadmap.md utterance_parity_research: docs/utterance-parity-research.md pink_trombone_parity: docs/pink-trombone-parity.md + pink_trombone_dsl_parity_plan: docs/pink-trombone-dsl-parity-plan.md wet_voice_witness_fixture: tests/AquaSynth.Dsl.Tests/Fixtures/Speech/WetVoice01 faust_toolchain_boundary: docs/faust-toolchain-boundary.md dx7_public_domain_fixtures: tests/AquaSynth.Dsl.Tests/Fixtures/Dx7/PublicDomain @@ -43,6 +44,7 @@ modules: speech_distributed_training: src/AquaSynth.Core/SpeechDistributedTraining.cs vocal_tract_learning: src/AquaSynth.Core/VocalTractLearning.cs tract_voice_model: src/AquaSynth.Core/Model.cs + tract_shape_model: src/AquaSynth.Core/Model.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 35b09ed..8d1c291 100644 --- a/state/memory.json +++ b/state/memory.json @@ -51,6 +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", "utterance parity curriculum research and docs", "patch script parser", "SFXR atoms and parameter mapping", @@ -76,6 +77,7 @@ "docs/reference-synth-roadmap.md", "docs/ipa-vocal-tract-roadmap.md", "docs/utterance-parity-research.md", + "docs/pink-trombone-dsl-parity-plan.md", "state/spine.yaml" ], "reference_strategy": { @@ -88,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." + "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." }, "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 cef7815..f933fa5 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -1,6 +1,6 @@ project: AquaSynth spine_version: 1 -updated: 2026-05-25T23:20:00+01:00 +updated: 2026-05-25T23:55:00+01:00 prime_directive: > Build coherent synth machinery. References exist to reveal missing structure, @@ -43,6 +43,7 @@ current_mechanism: roadmap: docs/reference-synth-roadmap.md utterance_parity_research: docs/utterance-parity-research.md pink_trombone_parity: docs/pink-trombone-parity.md + pink_trombone_dsl_parity_plan: docs/pink-trombone-dsl-parity-plan.md parameter_contracts: src/AquaSynth.Core/Model.cs live_invariants: @@ -111,6 +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. - 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 9224993..18bc151 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -86,21 +86,31 @@ public sealed class PatchScriptTests { var patch = PatchScript.Parse(""" param path=/pink/tenseness default=.6 min=0 max=1 step=.001 - tract freq=140 intensity=.72 tenseness=@/pink/tenseness tongue_index=18 tongue_diameter=1.4 velum=.2 constriction_index=32 constriction_diameter=.45 turbulence=.8 + 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 """); + var shape = Assert.Single(patch.TractShapes); + Assert.Equal("human", shape.Name); + 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 voice = Assert.Single(patch.Voices); Assert.NotNull(voice.Tract); - Assert.Equal(44, voice.Tract.Sections); + Assert.Equal(8, voice.Tract.Sections); Assert.Equal(28, voice.Tract.NoseSections); - Assert.Equal(18, voice.Tract.TongueIndex); + 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"); 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); } @@ -327,9 +337,12 @@ public sealed class PatchScriptTests Assert.Contains(reference.Parameters, parameter => parameter.Path == "/pink/tongue/index"); Assert.Contains(reference.Parameters, parameter => parameter.Path == "/pink/constriction/diameter"); 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 == "diameter_to_reflection_coefficients"); + Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "reflection_coefficients_applied_to_waveguide"); Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "nose_waveguide_cells"); + Assert.NotEmpty(patch.TractShapes); Assert.NotEmpty(patch.Parameters); Assert.Contains("process =", export.Source); }