From ae57fa96be2218442004720240c049e199bd6f2e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 25 May 2026 21:18:49 +0100 Subject: [PATCH] Add tract motion and obstruction history --- docs/pink-trombone-dsl-parity-plan.md | 18 ++++--- docs/pink-trombone-parity.md | 6 ++- src/AquaSynth.Core/BuiltInScripts.cs | 9 ++++ src/AquaSynth.Core/FaustExport.cs | 34 +++++++++--- src/AquaSynth.Core/Model.cs | 10 ++++ src/AquaSynth.Core/PatchScript.cs | 53 +++++++++++++++++++ src/AquaSynth.Core/PinkTromboneReference.cs | 3 +- state/evidence.jsonl | 1 + state/spine.yaml | 2 +- tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs | 13 ++++- 10 files changed, 130 insertions(+), 19 deletions(-) diff --git a/docs/pink-trombone-dsl-parity-plan.md b/docs/pink-trombone-dsl-parity-plan.md index 0bec439..7b907bb 100644 --- a/docs/pink-trombone-dsl-parity-plan.md +++ b/docs/pink-trombone-dsl-parity-plan.md @@ -70,7 +70,13 @@ named failure against Pink Trombone, but it does not own the anatomy: consume `tract_shape` reflection coefficients and boundary reflections. True substep execution remains missing. -6. PT parity harness. +6. `tract_motion`: control-rate slew and obstruction history. + - Owns diameter/constriction/velum slew rates and obstruction threshold. + - Feeds smoothed target controls and release-transient detection. + - Implemented as `tract_motion`; waveguide lowering uses it for control + slew and stateful obstruction-opening burst pressure. + +7. PT parity harness. - Renders fixed PT references for vowels, nasals, fricatives, closures, and moving tongue/constriction gestures. - Renders Aqua DSL reconstructions. @@ -79,7 +85,7 @@ named failure against Pink Trombone, but it does not own the anatomy: ## First Cut Implemented owners: `tract_shape`, `glottis`, `tract_injection`, -`nasal_branch`, and first-pass `propagation=waveguide`. +`nasal_branch`, `tract_motion`, 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 @@ -88,10 +94,10 @@ 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 and distributes frication into section -updates, but PT's two tract steps per output sample, target diameter slew, and -obstruction-state transients still need low-level owners. +The next cut is timing and proof: the oral/nasal tube now consumes the full +reflection coefficient 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. ## Cut Line diff --git a/docs/pink-trombone-parity.md b/docs/pink-trombone-parity.md index c6ae8ef..eea249d 100644 --- a/docs/pink-trombone-parity.md +++ b/docs/pink-trombone-parity.md @@ -57,6 +57,7 @@ Current AquaSynth can express: - reusable `tract_injection` declarations for positioned frication/burst pressure; - reusable `nasal_branch` declarations with velum-controlled junctions; +- reusable `tract_motion` declarations for slew and obstruction thresholds; - 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 @@ -74,9 +75,8 @@ Current AquaSynth can express: Current AquaSynth cannot exactly express: -- per-section diameter targets as synthesis authority; +- per-section dynamic diameter target arrays; - a subgraph that steps twice per output sample; -- obstruction-state release transients derived from prior closure state; - exact Pink Trombone LF glottal coefficient updates. ## Current Parity Rung @@ -101,6 +101,8 @@ Trombone anatomy: oral/nasal scattering junction when used by a waveguide tract. - `tract_injection` pressure is distributed into waveguide section updates by position and width when waveguide propagation is active. +- `tract_motion` smooths tract controls and lets the waveguide derive burst + pressure from obstruction history. - 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 25d83fe..979eb5e 100644 --- a/src/AquaSynth.Core/BuiltInScripts.cs +++ b/src/AquaSynth.Core/BuiltInScripts.cs @@ -654,11 +654,20 @@ public static class BuiltInScripts 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_motion + name=pt_motion + diameter_slew=18 + shape_return=8 + constriction_slew=24 + velum_slew=16 + obstruction_threshold=0.05 + tract shape=pt_human glottis=pt_glottis injection=pt_constriction nasal_branch=pt_nose + motion=pt_motion propagation=waveguide waveguide_loss=0.999 substeps=2 diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index d87874f..92e9cab 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -37,6 +37,7 @@ public static class FaustEmitter } source.AppendLine("clip01(x) = min(1.0, max(0.0, x));"); source.AppendLine("wrap01(x) = x - floor(x);"); + source.AppendLine("slew(rate,x) = x : *(1.0 - exp(-max(0.0001, rate) / ma.SR)) : + ~ *(exp(-max(0.0001, rate) / ma.SR));"); source.AppendLine("softclip(x) = ma.tanh(x * 1.35);"); source.AppendLine("fold(x) = 2.0 * abs(2.0 * (x / 4.0 - floor(x / 4.0)) - 1.0) - 1.0;"); source.AppendLine("release_start(a,d,g) = max(g, a + d);"); @@ -395,11 +396,21 @@ public static class FaustEmitter var hasNose = tract.NoseSections > 0; var intensity = $"clip01({parameters.Expression(OwnerField(tractPath, "intensity"), tract.Intensity)})"; var tenseness = $"clip01({parameters.Expression(OwnerField(tractPath, "tenseness"), tract.Tenseness)})"; - var tongueIndex = parameters.Expression(OwnerField(tractPath, "tongue_index"), tract.TongueIndex); - var tongueDiameter = parameters.Expression(OwnerField(tractPath, "tongue_diameter"), tract.TongueDiameter); - var velum = $"clip01(({parameters.Expression(OwnerField(tractPath, "velum"), tract.Velum)} - 0.01) / 0.39)"; - var constrictionIndex = parameters.Expression(OwnerField(tractPath, "constriction_index"), tract.ConstrictionIndex); - var constrictionDiameter = parameters.Expression(OwnerField(tractPath, "constriction_diameter"), tract.ConstrictionDiameter); + var motion = tract.Motion; + var diameterSlew = parameters.Expression(OwnerField(tractPath, "motion/diameter_slew"), motion?.DiameterSlewPerSecond ?? 18); + var constrictionSlew = parameters.Expression(OwnerField(tractPath, "motion/constriction_slew"), motion?.ConstrictionSlewPerSecond ?? 24); + var velumSlew = parameters.Expression(OwnerField(tractPath, "motion/velum_slew"), motion?.VelumSlewPerSecond ?? 16); + var obstructionThreshold = parameters.Expression(OwnerField(tractPath, "motion/obstruction_threshold"), motion?.ObstructionThreshold ?? 0.05f); + var tongueIndexRaw = parameters.Expression(OwnerField(tractPath, "tongue_index"), tract.TongueIndex); + var tongueDiameterRaw = parameters.Expression(OwnerField(tractPath, "tongue_diameter"), tract.TongueDiameter); + var velumRaw = $"clip01(({parameters.Expression(OwnerField(tractPath, "velum"), tract.Velum)} - 0.01) / 0.39)"; + var constrictionIndexRaw = parameters.Expression(OwnerField(tractPath, "constriction_index"), tract.ConstrictionIndex); + var constrictionDiameterRaw = parameters.Expression(OwnerField(tractPath, "constriction_diameter"), tract.ConstrictionDiameter); + var tongueIndex = motion is null ? tongueIndexRaw : $"slew({diameterSlew}, {tongueIndexRaw})"; + var tongueDiameter = motion is null ? tongueDiameterRaw : $"slew({diameterSlew}, {tongueDiameterRaw})"; + var velum = motion is null ? velumRaw : $"slew({velumSlew}, {velumRaw})"; + var constrictionIndex = motion is null ? constrictionIndexRaw : $"slew({constrictionSlew}, {constrictionIndexRaw})"; + var constrictionDiameter = motion is null ? constrictionDiameterRaw : $"slew({constrictionSlew}, {constrictionDiameterRaw})"; var turbulence = $"clip01({parameters.Expression(OwnerField(tractPath, "turbulence"), tract.Turbulence)})"; var lipOpening = parameters.Expression(OwnerField(tractPath, "lip_opening"), tract.LipOpening); var glottalReflection = parameters.Expression(OwnerField(tractPath, "glottal_reflection"), tract.GlottalReflection); @@ -414,8 +425,10 @@ public static class FaustEmitter 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 injectionPositionRaw = parameters.Expression(OwnerField(tractPath, "injection/position"), injection?.Position ?? tract.ConstrictionIndex); + var injectionDiameterRaw = parameters.Expression(OwnerField(tractPath, "injection/diameter"), injection?.Diameter ?? tract.ConstrictionDiameter); + var injectionPosition = motion is null ? injectionPositionRaw : $"slew({constrictionSlew}, {injectionPositionRaw})"; + var injectionDiameter = motion is null ? injectionDiameterRaw : $"slew({constrictionSlew}, {injectionDiameterRaw})"; 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)})"; @@ -441,7 +454,12 @@ public static class FaustEmitter 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_prev_injection_close = {name}_tract_injection_close : mem;"); + source.AppendLine($"{name}_tract_obstructed = select2({injectionDiameter} < {obstructionThreshold}, 0.0, 1.0);"); + source.AppendLine($"{name}_tract_prev_obstructed = {name}_tract_obstructed : mem;"); + source.AppendLine($"{name}_tract_release_opening = max(0.0, {name}_tract_prev_injection_close - {name}_tract_injection_close) * {name}_tract_prev_obstructed;"); + source.AppendLine($"{name}_tract_release_memory = {name}_tract_release_opening : + ~ *(0.935);"); + source.AppendLine($"{name}_tract_burst = no.noise * {name}_tract_release_memory * {injectionBurst} * {intensity};"); source.AppendLine($"{name}_tract_excitation = ({name}_tract_lf + {name}_tract_aspiration + {name}_tract_burst) * (0.55 + 0.45 * abs({glottalReflection}));"); source.AppendLine($"{name}_tract_injection_pressure = {name}_tract_frication * (0.55 + 0.45 * abs({glottalReflection}));"); source.AppendLine($"{name}_tract_raw = {name}_tract_excitation + {name}_tract_injection_pressure;"); diff --git a/src/AquaSynth.Core/Model.cs b/src/AquaSynth.Core/Model.cs index 78455da..a91d2bf 100644 --- a/src/AquaSynth.Core/Model.cs +++ b/src/AquaSynth.Core/Model.cs @@ -213,6 +213,14 @@ public sealed record NasalBranch( float Reflection = -0.85f, float Loss = 0.999f); +public sealed record TractMotion( + string Name = "", + float DiameterSlewPerSecond = 18, + float ShapeReturnPerSecond = 8, + float ConstrictionSlewPerSecond = 24, + float VelumSlewPerSecond = 16, + float ObstructionThreshold = 0.05f); + public sealed record Modulator( ModTarget Target, ModWaveform Waveform = ModWaveform.Sine, @@ -271,6 +279,7 @@ public sealed record VocalTract( GlottalSource? Glottis = null, TractInjection? Injection = null, NasalBranch? Nasal = null, + TractMotion? Motion = null, TractPropagationMode Propagation = TractPropagationMode.Resonator, float WaveguideLoss = 0.999f, int Substeps = 1); @@ -450,6 +459,7 @@ public sealed record SynthPatch public IReadOnlyList GlottalSources { get; init; } = Array.Empty(); public IReadOnlyList TractInjections { get; init; } = Array.Empty(); public IReadOnlyList NasalBranches { get; init; } = Array.Empty(); + public IReadOnlyList TractMotions { 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 134b89d..1931f1f 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -44,6 +44,8 @@ public static class PatchScript private readonly Dictionary _tractInjectionsByName = new(StringComparer.OrdinalIgnoreCase); private readonly List _nasalBranches = []; private readonly Dictionary _nasalBranchesByName = new(StringComparer.OrdinalIgnoreCase); + private readonly List _tractMotions = []; + private readonly Dictionary _tractMotionsByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _defaults = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _layerDefaults = new(StringComparer.OrdinalIgnoreCase); private PendingOperatorGraph? _pendingOperatorGraph; @@ -69,6 +71,7 @@ public static class PatchScript GlottalSources = _glottalSources, TractInjections = _tractInjections, NasalBranches = _nasalBranches, + TractMotions = _tractMotions, OperatorGraphs = _operatorGraphs, Controls = _controls, Parameters = _parameters, @@ -136,6 +139,10 @@ public static class PatchScript FlushPendingOperatorGraph(); AddNasalBranch(fields, line); break; + case "tract_motion": + FlushPendingOperatorGraph(); + AddTractMotion(fields, line); + break; case "voice": FlushPendingOperatorGraph(); Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line)); @@ -364,6 +371,25 @@ public static class PatchScript _nasalBranchesByName[name] = branch; } + private void AddTractMotion(IReadOnlyDictionary fields, int line) + { + var name = Required(fields, "name", line); + if (_tractMotionsByName.ContainsKey(name)) + { + throw new PatchScriptException(line, $"duplicate tract motion `{name}`"); + } + + var motion = new TractMotion( + name, + GetBoundFloat(fields, line, 18, $"/tract_motions/{_tractMotions.Count}/diameter_slew", "diameter_slew", "slew"), + GetBoundFloat(fields, line, 8, $"/tract_motions/{_tractMotions.Count}/shape_return", "shape_return", "return_slew"), + GetBoundFloat(fields, line, 24, $"/tract_motions/{_tractMotions.Count}/constriction_slew", "constriction_slew", "constriction"), + GetBoundFloat(fields, line, 16, $"/tract_motions/{_tractMotions.Count}/velum_slew", "velum_slew", "velum"), + GetBoundFloat(fields, line, 0.05f, $"/tract_motions/{_tractMotions.Count}/obstruction_threshold", "obstruction_threshold", "closure")); + _tractMotions.Add(motion); + _tractMotionsByName[name] = motion; + } + private Dictionary ExpandVoiceFields(Dictionary fields, int line) { var expanded = new Dictionary(_defaults, StringComparer.OrdinalIgnoreCase); @@ -608,6 +634,7 @@ public static class PatchScript var glottis = ParseGlottalSourceReference(fields, ownerPath, line); var injection = ParseTractInjectionReference(fields, ownerPath, line); var nasal = ParseNasalBranchReference(fields, ownerPath, line); + var motion = ParseTractMotionReference(fields, ownerPath, line); var sections = GetBoundInt(fields, line, areaFunction?.Sections ?? 44, OwnerField(ownerPath, "tract/sections"), "sections", "cells"); if (sections < 4) { @@ -643,6 +670,7 @@ public static class PatchScript glottis, injection, nasal, + motion, 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")) @@ -1136,6 +1164,30 @@ public static class PatchScript GetBoundFloat(fields, line, fallback.Loss, OwnerField(ownerPath, "tract/nasal/loss"), "nose_loss")); } + private TractMotion? ParseTractMotionReference(IReadOnlyDictionary fields, string ownerPath, int line) + { + var hasInlineMotion = HasAny(fields, "diameter_slew", "shape_return", "constriction_slew", "velum_slew", "obstruction_threshold"); + var hasNamedMotion = TryGetAny(fields, ["motion", "tract_motion"], out var motionName); + if (!hasNamedMotion && !hasInlineMotion) + { + return null; + } + TractMotion? namedMotion = null; + if (hasNamedMotion && !_tractMotionsByName.TryGetValue(motionName, out namedMotion)) + { + throw new PatchScriptException(line, $"unknown tract motion `{motionName}`"); + } + + var fallback = hasNamedMotion ? namedMotion! : new TractMotion(); + return new TractMotion( + hasNamedMotion ? motionName : "", + GetBoundFloat(fields, line, fallback.DiameterSlewPerSecond, OwnerField(ownerPath, "tract/motion/diameter_slew"), "diameter_slew", "slew"), + GetBoundFloat(fields, line, fallback.ShapeReturnPerSecond, OwnerField(ownerPath, "tract/motion/shape_return"), "shape_return", "return_slew"), + GetBoundFloat(fields, line, fallback.ConstrictionSlewPerSecond, OwnerField(ownerPath, "tract/motion/constriction_slew"), "constriction_slew"), + GetBoundFloat(fields, line, fallback.VelumSlewPerSecond, OwnerField(ownerPath, "tract/motion/velum_slew"), "velum_slew"), + GetBoundFloat(fields, line, fallback.ObstructionThreshold, OwnerField(ownerPath, "tract/motion/obstruction_threshold"), "obstruction_threshold", "closure")); + } + private static TractAreaFunction ParseTractAreaFunction(IReadOnlyDictionary fields, int line) { var hasDiameters = TryGetAny(fields, ["diameters", "diameter"], out var diametersText); @@ -1576,6 +1628,7 @@ public static class PatchScript "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", + "tract_motion" or "shape_motion" or "slew" => "tract_motion", "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 7c04ee7..ecf5ca7 100644 --- a/src/AquaSynth.Core/PinkTromboneReference.cs +++ b/src/AquaSynth.Core/PinkTromboneReference.cs @@ -70,6 +70,8 @@ public static class PinkTromboneReference 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("positioned_turbulence_applied_to_waveguide_cells", "position-weighted waveguide injection", "Frication pressure can be distributed into oral waveguide section updates by injection position and width."), + new("tract_shape_motion", "tract_motion slew semantics", "AquaSynth can author tract control slew and obstruction thresholds for moving diameter targets."), + new("closure_transients", "obstruction-history burst", "Waveguide lowering derives burst pressure from prior obstruction state and current opening."), 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."), @@ -77,7 +79,6 @@ public static class PinkTromboneReference ], [ 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("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 3b11161..393bc73 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -134,3 +134,4 @@ {"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."} {"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."} diff --git a/state/spine.yaml b/state/spine.yaml index 79f6bbd..dbd2b21 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; `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, obstruction-history transients, target diameter slew, and 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 obstruction-history burst pressure. Remaining low-level PT gaps are true two-steps-per-sample execution, dynamic per-section target diameter arrays, and 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 49c8d85..67b2f27 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -90,7 +90,8 @@ public sealed class PatchScriptTests 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 + tract_motion name=quick diameter_slew=20 constriction_slew=30 velum_slew=12 obstruction_threshold=.08 + tract shape=human glottis=modal injection=sibilant nasal_branch=nose motion=quick propagation=waveguide waveguide_loss=.998 substeps=2 freq=140 tongue_index=4 tongue_diameter=1.4 velum=.2 """); var shape = Assert.Single(patch.TractShapes); @@ -107,6 +108,9 @@ public sealed class PatchScriptTests var nasal = Assert.Single(patch.NasalBranches); Assert.Equal("nose", nasal.Name); Assert.Equal(4, nasal.AreaFunction?.Sections); + var motion = Assert.Single(patch.TractMotions); + Assert.Equal("quick", motion.Name); + Assert.Equal(.08f, motion.ObstructionThreshold, 5); var voice = Assert.Single(patch.Voices); Assert.NotNull(voice.Tract); @@ -118,6 +122,7 @@ public sealed class PatchScriptTests Assert.Equal(glottis, voice.Tract.Glottis); Assert.Equal(injection, voice.Tract.Injection); Assert.Equal(nasal, voice.Tract.Nasal); + Assert.Equal(motion, voice.Tract.Motion); Assert.Equal(TractPropagationMode.Waveguide, voice.Tract.Propagation); Assert.Equal(.998f, voice.Tract.WaveguideLoss, 5); Assert.Equal(2, voice.Tract.Substeps); @@ -132,6 +137,9 @@ public sealed class PatchScriptTests Assert.Contains("tract_injection_close", faust); Assert.Contains("wg_injection_cell", faust); Assert.Contains("wg_inject_6", faust); + Assert.Contains("tract_release_memory", faust); + Assert.Contains("tract_obstructed", faust); + Assert.Contains("slew(", faust); Assert.Contains("wg_scatter_1", faust); Assert.Contains("nose_reflect_nose", faust); Assert.Contains("tract_nose_waveguide", faust); @@ -370,10 +378,13 @@ public sealed class PatchScriptTests Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "nose_waveguide_cells"); Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "nose_junction"); Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "positioned_turbulence_applied_to_waveguide_cells"); + Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "tract_shape_motion"); + Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "closure_transients"); Assert.NotEmpty(patch.TractShapes); Assert.NotEmpty(patch.GlottalSources); Assert.NotEmpty(patch.TractInjections); Assert.NotEmpty(patch.NasalBranches); + Assert.NotEmpty(patch.TractMotions); Assert.NotEmpty(patch.Parameters); Assert.Contains("process =", export.Source); }