Add tract motion and obstruction history

This commit is contained in:
Meta Vens 2026-05-25 21:18:49 +01:00
commit ae57fa96be
10 changed files with 130 additions and 19 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;");

View file

@ -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<GlottalSource> GlottalSources { get; init; } = Array.Empty<GlottalSource>();
public IReadOnlyList<TractInjection> TractInjections { get; init; } = Array.Empty<TractInjection>();
public IReadOnlyList<NasalBranch> NasalBranches { get; init; } = Array.Empty<NasalBranch>();
public IReadOnlyList<TractMotion> TractMotions { get; init; } = Array.Empty<TractMotion>();
public IReadOnlyList<OperatorGraph> OperatorGraphs { get; init; } = Array.Empty<OperatorGraph>();
public IReadOnlyList<ControlLane> Controls { get; init; } = Array.Empty<ControlLane>();
public IReadOnlyList<PatchParameter> Parameters { get; init; } = Array.Empty<PatchParameter>();

View file

@ -44,6 +44,8 @@ public static class PatchScript
private readonly Dictionary<string, TractInjection> _tractInjectionsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<NasalBranch> _nasalBranches = [];
private readonly Dictionary<string, NasalBranch> _nasalBranchesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<TractMotion> _tractMotions = [];
private readonly Dictionary<string, TractMotion> _tractMotionsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _defaults = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Dictionary<string, string>> _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<string, string> 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<string, string> ExpandVoiceFields(Dictionary<string, string> fields, int line)
{
var expanded = new Dictionary<string, string>(_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<string, string> 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<string, string> 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",

View file

@ -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.");

View file

@ -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."}

View file

@ -112,7 +112,7 @@ live_invariants:
- The speech lane is the active center: IPA parity, utterance embeddings, synth-driver automation, rendered loss, and compiled Faust control sweeps take priority over new DX7/Zyn digestion.
- Every learned speech arrow must stay inspectable: utterance metadata -> embedding, embedding plus phonetic features -> automation, automation -> rendered audio, rendered audio -> loss/gradient.
- Pink Trombone parity is structural waveguide pressure, not permission to add more formant proxy knobs. AquaSynth now has a `tract` DSL command that lowers into `Voice.Tract` with PT-shaped runtime controls and a Faust tract-voice proxy; it is still an ordinary voice treatment, not a separate graph owner and not an exact 44-cell/28-cell bidirectional waveguide.
- PT expressive parity should decompose into reusable Aqua primitives before audio golf: `tract_shape` owns section diameters/areas and derived reflection coefficients; `glottis` owns excitation controls; `tract_injection` owns positioned frication/burst controls and now distributes frication pressure into waveguide section updates; `nasal_branch` owns a second diameter/area tube; `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.

View file

@ -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);
}