Generalize tract morphology geometry

This commit is contained in:
Meta Vens 2026-05-26 11:21:21 +01:00
commit b928416d63
7 changed files with 178 additions and 51 deletions

View file

@ -11,16 +11,18 @@ 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:
with a Faust source/filter proxy and an early waveguide backend. It is useful as
a playground surface and as a named failure against Pink Trombone, but the
remaining anatomy ownership is still too discrete:
- no reusable tract area function;
- no derived area/reflection coefficient primitive;
- no traveling-wave state owner;
- no nasal junction primitive;
- no waveguide-applied positioned excitation/injection events;
- no substep/rate semantics;
- no parity renderer/score loop against PT output.
- authored tract shapes now own physical length and continuous interpolation,
and a tract voice may resample them to a chosen compiled grid;
- `sections`, `nose_sections`, and integer junction indices still leak lowering
grid choices into the script surface;
- `substeps` still exists as compatibility clock intent, but it should be
demoted in favor of acoustic length, propagation speed, and fractional delay;
- topology changes can recompile, but morphology motion must remain runtime
control data.
## Invariants
@ -28,10 +30,17 @@ named failure against Pink Trombone, but it does not own the anatomy:
- 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.
- Diameter/area functions own continuous tract shape over normalized position,
physical acoustic length, derived areas, reflection coefficients, and
resampling. Tongue and constriction gestures deform that shape; they do not
replace the owner.
- PT's 44 oral cells and 28 nasal cells are a reference discretization, not an
Aqua morphology model.
- Reflection coefficients derive from adjacent areas. Caches and summaries may
observe that derivation, but must not become independent truth.
- Waveguide clock derives from acoustic length, propagation speed, sample rate,
and delay approximation. A user-facing `substeps` count is only legacy
pressure until the fractional-delay waveguide owns this cleanly.
- 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
@ -40,8 +49,10 @@ named failure against Pink Trombone, but it does not own the anatomy:
## 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.
- Owns sampled diameters or areas over normalized continuous tract position.
- Owns physical acoustic length through `length_cm`.
- Emits interpolated diameters, resampled grids, areas, reflection
coefficients, per-cell acoustic delay, and shape summaries.
- Feeds `tract` voices and later morphology/gesture planning.
2. `glottis`: reusable excitation primitive.
@ -64,15 +75,16 @@ named failure against Pink Trombone, but it does not own the anatomy:
when a waveguide tract references the branch.
5. `waveguide_tract`: propagation primitive.
- Owns right/left traveling-wave state, substep rate, loss, and radiation.
- Owns right/left traveling-wave state, acoustic wave clock, fractional
delay/loss approximation, and radiation.
- Consumes area/reflection fields and source/injection events.
- First oral-tube lowering exists: generated right/left section equations
consume `tract_shape` reflection coefficients and boundary reflections.
- Waveguide lowering now derives live per-section diameter targets, areas,
and reflection coefficients from the tract shape plus tongue, constriction,
and lip controls before scattering.
Substep clock intent is represented and lowering consumes it for drive/loss
scaling, but exact intra-sample recursive state updates remain missing.
Legacy substep clock intent is represented and lowering consumes it for
drive/loss scaling, but exact fractional-delay propagation remains missing.
6. `tract_motion`: control-rate slew and obstruction history.
- Owns diameter/constriction/velum slew rates and obstruction threshold.
@ -117,10 +129,20 @@ Faust. Current waveguide baseline: open vowel 0.5578, front vowel 0.5368,
nasal 0.4299, sibilant 0.2852, closure-release 0.1147. Those are pressure
readings, not parity, but closure-release is no longer anti-correlated.
Exact Pink Trombone timing still needs pressure: the loop currently performs
one feedback update per output sample while carrying `substeps` as drive/loss
intent. The next backend cut is true twice-per-sample state update inside the
Faust-friendly loop shape, not a return to named equation sprawl.
The continuous morphology cut has started. `tract_shape length_cm=...` is now
physical geometry, not just a list length. `TractAreaFunction` can interpolate
diameter over normalized position, resample to an arbitrary grid, and derive
per-cell acoustic delay. A `tract` voice may choose a different `sections=...`
lowering grid without requiring the named shape's authoring samples to match.
For a human-length 17 cm tract sampled at PT's 44 oral cells at 44.1 kHz, that
delay is about 0.5 samples per cell. That makes PT's two tract updates per
output sample look like a discretization strategy for a half-sample cell grid,
not a reusable Aqua language concept.
Exact Pink Trombone timing still needs pressure, but the next backend cut is no
longer "add more substeps." It is a fractional-delay waveguide lowering whose
clock comes from physical length, propagation speed, and sample rate while
morphology moves through runtime controls without recompiling the patch.
## Cut Line

View file

@ -46,12 +46,21 @@ not static formants: the shaper slews section diameters toward target diameters,
updates reflections at block boundaries, injects turbulence at constrictions,
and emits a transient when a closure opens.
The 44 oral and 28 nasal counts are Pink Trombone's implementation grid, not
the AquaSynth abstraction. At 44.1 kHz, a 17 cm tract split into 44 cells has an
acoustic travel time of roughly 0.5 samples per cell, so the twice-per-sample
update is better understood as a discretization/wave-clock choice. AquaSynth's
live target is continuous morphology plus fractional-delay waveguide lowering,
with the grid chosen by the backend.
## AquaSynth Capability Map
Current AquaSynth can express:
- runtime parameters with stable paths;
- reusable `tract_shape` declarations with authored diameter or area samples;
- reusable `tract_shape` declarations with authored diameter or area samples,
physical length, normalized interpolation, resampling, and per-cell acoustic
delay derivation;
- derived tract areas and adjacent-section reflection coefficients;
- reusable `glottis` declarations for tract excitation quality;
- reusable `tract_injection` declarations for positioned frication/burst
@ -75,7 +84,8 @@ Current AquaSynth can express:
Current AquaSynth cannot exactly express:
- exact two recursive tract state updates inside one output sample;
- fractional-delay tract propagation whose clock derives from physical length,
propagation speed, and sample rate;
- exact Pink Trombone LF glottal coefficient updates.
## Current Parity Rung
@ -89,9 +99,10 @@ 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.
- `tract_shape` owns reusable continuous diameter/area functions. The current
proxy consumes its shape summaries and derived reflection energy; waveguide
lowering consumes a compiled grid sampled from that continuous owner, so
authoring sample count and backend section count can differ.
- `glottis` and `tract_injection` own excitation and positioned noise/burst
controls consumed by tract voices.
- `propagation=waveguide` emits an oral right/left tube from the derived
@ -104,9 +115,9 @@ Trombone anatomy:
pressure from obstruction history.
- waveguide lowering derives live per-section diameter targets, areas, and
reflection coefficients from shape and gesture controls.
- `substeps` expresses waveguide clock intent; current Faust lowering consumes
it through drive/loss scaling but does not yet prove exact intra-sample state
updates.
- `substeps` is now legacy waveguide clock pressure. Current Faust lowering
consumes it through drive/loss scaling, but the coherent target is
fractional-delay propagation from physical tract length and wave speed.
- `PinkTromboneParityFixtures` defines fixed Aqua DSL workouts for open,
front, nasal, sibilant, and closure cases using the reusable low-level
primitives.
@ -140,8 +151,10 @@ of these:
DSP model in AquaSynth.Faust/Core: typed tract sections, diameter curves,
reflection calculation, glottal source, nasal branch, and a Faust emitter for
that structure.
2. Add true twice-per-output-sample state update inside the Faust-friendly
feedback-loop component.
2. Replace `substeps` as a musical surface with fractional-delay waveguide
lowering. Physical length, propagation speed, and sample rate decide delay;
runtime morphology controls move diameters and junction openings without
recompiling unless topology changes.
3. Use the log-mel artifact loop to golf fixture-by-fixture now that the real
tract path renders.

View file

@ -138,10 +138,19 @@ public sealed record Formant(float FrequencyHz, float BandwidthHz, float Gain);
public sealed record FormantFrame(IReadOnlyList<Formant> Formants);
public sealed record TractAreaFunction(IReadOnlyList<float> Diameters)
public sealed record TractAreaFunction(IReadOnlyList<float> Diameters, float LengthCentimeters = 17)
{
public int Sections => Diameters.Count;
public float LengthMeters => MathF.Max(0, LengthCentimeters) / 100;
public float SectionLengthMeters => Sections == 0 ? 0 : LengthMeters / Sections;
public float CellDelaySamples(float sampleRate, float propagationSpeedMetersPerSecond = 343) =>
propagationSpeedMetersPerSecond <= 0
? 0
: SectionLengthMeters / propagationSpeedMetersPerSecond * MathF.Max(0, sampleRate);
public IReadOnlyList<float> Areas => Diameters.Select(diameter => MathF.Max(0, diameter) * MathF.Max(0, diameter)).ToArray();
public IReadOnlyList<float> ReflectionCoefficients
@ -164,8 +173,8 @@ public sealed record TractAreaFunction(IReadOnlyList<float> Diameters)
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 start = Math.Clamp((int)MathF.Floor(Math.Clamp(startFraction, 0, 1) * Diameters.Count), 0, Diameters.Count - 1);
var end = Math.Clamp((int)MathF.Ceiling(Math.Clamp(endFraction, 0, 1) * Diameters.Count), start + 1, Diameters.Count);
var sum = 0f;
for (var i = start; i < end; i++)
{
@ -175,10 +184,31 @@ public sealed record TractAreaFunction(IReadOnlyList<float> Diameters)
return sum / Math.Max(1, end - start);
}
public float DiameterAt(float normalizedPosition)
{
if (Diameters.Count == 0) return 0;
if (Diameters.Count == 1) return Diameters[0];
var position = Math.Clamp(normalizedPosition, 0, 1) * (Diameters.Count - 1);
var left = (int)MathF.Floor(position);
var right = Math.Min(Diameters.Count - 1, left + 1);
var mix = position - left;
return Diameters[left] + (Diameters[right] - Diameters[left]) * mix;
}
public TractAreaFunction Resample(int sections)
{
if (sections < 1) throw new ArgumentOutOfRangeException(nameof(sections), "section count must be positive");
if (sections == 1) return new TractAreaFunction([DiameterAt(0)], LengthCentimeters);
var diameters = Enumerable.Range(0, sections)
.Select(i => DiameterAt((float)i / (sections - 1)))
.ToArray();
return new TractAreaFunction(diameters, LengthCentimeters);
}
public float MinimumDiameter => Diameters.Count == 0 ? 0 : Diameters.Min();
public static TractAreaFunction FromAreas(IReadOnlyList<float> areas) =>
new(areas.Select(area => MathF.Sqrt(MathF.Max(0, area))).ToArray());
public static TractAreaFunction FromAreas(IReadOnlyList<float> areas, float lengthCentimeters = 17) =>
new(areas.Select(area => MathF.Sqrt(MathF.Max(0, area))).ToArray(), lengthCentimeters);
}
public sealed record TractShape(string Name, TractAreaFunction AreaFunction);

View file

@ -308,7 +308,7 @@ public static class PatchScript
throw new PatchScriptException(line, $"duplicate tract shape `{name}`");
}
var areaFunction = ParseTractAreaFunction(fields, line);
var areaFunction = ParseTractAreaFunction(fields, line, 17);
var shape = new TractShape(name, areaFunction);
_tractShapes.Add(shape);
_tractShapesByName[name] = shape;
@ -362,7 +362,7 @@ public static class PatchScript
var branch = new NasalBranch(
name,
ParseTractAreaFunction(fields, line),
ParseTractAreaFunction(fields, line, 12),
GetBoundInt(fields, line, 17, $"/nasal_branches/{_nasalBranches.Count}/junction", "junction", "junction_index", "at"),
GetBoundFloat(fields, line, 0.01f, $"/nasal_branches/{_nasalBranches.Count}/velum", "velum", "opening"),
GetBoundFloat(fields, line, -0.85f, $"/nasal_branches/{_nasalBranches.Count}/reflection", "reflection", "lip_reflection"),
@ -640,10 +640,9 @@ public static class PatchScript
{
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");
}
areaFunction = areaFunction is not null && areaFunction.Sections != sections
? areaFunction.Resample(sections)
: areaFunction;
var noseSections = GetBoundInt(fields, line, nasal?.AreaFunction?.Sections ?? 28, OwnerField(ownerPath, "tract/nose_sections"), "nose_sections", "nose_cells");
if (noseSections < 0)
{
@ -1068,7 +1067,7 @@ public static class PatchScript
}
if (hasInlineShape)
{
return ParseTractAreaFunction(fields, line);
return ParseTractAreaFunction(fields, line, 17);
}
if (!hasNamedShape)
{
@ -1144,15 +1143,16 @@ public static class PatchScript
throw new PatchScriptException(line, $"unknown nasal branch `{nasalName}`");
}
var fallback = hasNamedNasal ? namedNasal! : new NasalBranch(AreaFunction: new TractAreaFunction([0.01f, 0.6f, 1.2f, 1.6f]));
var fallback = hasNamedNasal ? namedNasal! : new NasalBranch(AreaFunction: new TractAreaFunction([0.01f, 0.6f, 1.2f, 1.6f], 12));
var areaFunction = fallback.AreaFunction;
var lengthCentimeters = GetFloat(fields, line, areaFunction?.LengthCentimeters ?? 12, "length_cm", "length_centimeters");
if (TryGetAny(fields, ["nose_diameters"], out var diameters))
{
areaFunction = new TractAreaFunction(ParseFloatList(diameters, line, "nose_diameters").ToArray());
areaFunction = new TractAreaFunction(ParseFloatList(diameters, line, "nose_diameters").ToArray(), lengthCentimeters);
}
if (TryGetAny(fields, ["nose_areas"], out var areas))
{
areaFunction = TractAreaFunction.FromAreas(ParseFloatList(areas, line, "nose_areas").ToArray());
areaFunction = TractAreaFunction.FromAreas(ParseFloatList(areas, line, "nose_areas").ToArray(), lengthCentimeters);
}
return new NasalBranch(
@ -1188,7 +1188,7 @@ public static class PatchScript
GetBoundFloat(fields, line, fallback.ObstructionThreshold, OwnerField(ownerPath, "tract/motion/obstruction_threshold"), "obstruction_threshold", "closure"));
}
private static TractAreaFunction ParseTractAreaFunction(IReadOnlyDictionary<string, string> fields, int line)
private static TractAreaFunction ParseTractAreaFunction(IReadOnlyDictionary<string, string> fields, int line, float defaultLengthCentimeters)
{
var hasDiameters = TryGetAny(fields, ["diameters", "diameter"], out var diametersText);
var hasAreas = TryGetAny(fields, ["areas", "area"], out var areasText);
@ -1206,10 +1206,15 @@ public static class PatchScript
{
throw new PatchScriptException(line, "tract shape samples must be zero or greater");
}
var lengthCentimeters = GetFloat(fields, line, defaultLengthCentimeters, "length_cm", "length_centimeters");
if (lengthCentimeters <= 0)
{
throw new PatchScriptException(line, "tract shape length_cm must be greater than zero");
}
return hasDiameters
? new TractAreaFunction(samples.ToArray())
: TractAreaFunction.FromAreas(samples.ToArray());
? new TractAreaFunction(samples.ToArray(), lengthCentimeters)
: TractAreaFunction.FromAreas(samples.ToArray(), lengthCentimeters);
}
private static PadSpectrumProfile ParsePadSpectrumProfile(IReadOnlyDictionary<string, string> fields, int line)

View file

@ -140,3 +140,4 @@
{"date":"2026-05-26","topic":"pink-trombone-parity-fixtures","lesson":"Expressive parity needs fixed workouts before audio golf. AquaSynth now has `PinkTromboneParityFixtures` for open vowel, front vowel, nasal vowel, sibilant, and closure-release scripts built from reusable low-level tract primitives, plus log-mel cosine similarity in `AudioComparison`. The remaining proof is to generate/capture PT reference audio and set honest thresholds.","evidence":"Added `PinkTromboneParityFixtures`, `AudioComparison.LogMelCosineSimilarity`, `AudioAnalyzer.CosineSimilarity`, and tests that every PT fixture parses, emits Faust, uses tract_shape/glottis/tract_injection/nasal_branch/tract_motion, and exports waveguide lowering. Focused build/test passed 4 tests."}
{"timestamp":"2026-05-25T21:34:00+01:00","topic":"pink-trombone-logmel-parity","claim":"PinkTromboneReferenceRenderer now gives deterministic PT-style fixture audio and PinkTromboneLogMelParityTests writes log-mel/listening artifacts. The compileable Aqua resonator proxy baseline is measurable but weak: cosine 0.5129 open vowel, 0.5405 front vowel, 0.3882 nasal, 0.1156 sibilant, -0.1830 closure-release. Faust waveguide named-state lowering caused compiler churn and remains backend debt rather than a parity claim.","evidence":"dotnet test tests/AquaSynth.Dsl.Tests/AquaSynth.Dsl.Tests.csproj --no-build --filter PinkTromboneFixturesReportLogMelParityWhenFaustIsInstalled -v minimal passed; artifacts/parity/pink-trombone-logmel/20260525T212806301/summary.txt"}
{"timestamp":"2026-05-26T09:37:00+01:00","topic":"pink-trombone-waveguide-faust-loop","claim":"The Pink Trombone waveguide lowering now uses one Faust feedback component (`wg_loop ~ si.bus(...)`) instead of scalar named recursive equations. Full 44-section oral and 28-section nasal waveguide candidates compile/render through Faust. Current log-mel cosine baseline: open vowel 0.5578, front vowel 0.5368, nasal 0.4299, sibilant 0.2852, closure-release 0.1147.","evidence":"dotnet test tests/AquaSynth.Dsl.Tests/AquaSynth.Dsl.Tests.csproj --no-build --filter PinkTromboneFixturesReportLogMelParityWhenFaustIsInstalled -v minimal passed; artifacts/parity/pink-trombone-logmel/20260526T093628426/summary.txt"}
{"timestamp":"2026-05-26T10:10:00+01:00","topic":"continuous-tract-morphology","claim":"PT's 44 oral cells are a reference grid, not anatomy. A 17 cm tract split into 44 cells at 44.1 kHz has about 0.5 samples of acoustic delay per cell, so PT-style substeps are pressure for fractional-delay waveguide lowering rather than a durable Aqua DSL primitive. `TractAreaFunction` now owns physical length, normalized interpolation, resampling, and per-cell delay derivation; `tract sections=...` can choose a compiled lowering grid without forcing the named morphology's authoring sample count to match.","evidence":"Added `length_cm` parsing for `tract_shape`/`nasal_branch`, `TractAreaFunction.DiameterAt`, `Resample`, and `CellDelaySamples`; focused tests cover continuous interpolation, authoring-grid/lowering-grid separation, and the 44-cell half-sample delay fact."}

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; `tract_motion` owns control slew and obstruction threshold; `propagation=waveguide` lowers tract voices into a Faust-friendly `wg_loop ~ si.bus(...)` feedback component with full 44-section oral and 28-section nasal right/left traveling-wave state, live per-section diameter/area/reflection fields, obstruction-history burst pressure, and substep clock intent. `PinkTromboneParityFixtures` defines fixed Aqua DSL workouts, `PinkTromboneReferenceRenderer` renders deterministic PT-style fixture references, and `AudioComparison` includes log-mel cosine similarity. Current waveguide baseline is weak but measured: cosine about 0.56 open vowel, 0.54 front vowel, 0.43 nasal, 0.29 sibilant, and 0.11 closure-release. Remaining low-level PT gaps are exact twice-per-output-sample state updates and real threshold golf.
- PT expressive parity should decompose into reusable Aqua primitives before audio golf: `tract_shape` owns continuous diameter/area geometry over normalized position, physical length (`length_cm`), interpolation/resampling, derived reflection coefficients, and per-cell acoustic delay; `tract` may resample that geometry to a chosen compiled `sections` grid without making authoring sample count anatomy; `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 a Faust-friendly `wg_loop ~ si.bus(...)` feedback component with compiled right/left traveling-wave state, live per-section diameter/area/reflection fields, obstruction-history burst pressure, and legacy substep clock intent. PT's 44 oral and 28 nasal cells are reference discretization, not Aqua morphology authority. `PinkTromboneParityFixtures` defines fixed Aqua DSL workouts, `PinkTromboneReferenceRenderer` renders deterministic PT-style fixture references, and `AudioComparison` includes log-mel cosine similarity. Current waveguide baseline is weak but measured: cosine about 0.56 open vowel, 0.54 front vowel, 0.43 nasal, 0.29 sibilant, and 0.11 closure-release. Remaining low-level gap is fractional-delay waveguide lowering whose clock derives from physical length, propagation speed, and sample rate so runtime morphology motion does not require recompilation.
- 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

@ -86,7 +86,7 @@ public sealed class PatchScriptTests
{
var patch = PatchScript.Parse("""
param path=/pink/tenseness default=.6 min=0 max=1 step=.001
tract_shape name=human diameters=.6,.7,.9,1.1,1.3,1.5,1.4,1.2
tract_shape name=human length_cm=17 diameters=.6,.7,.9,1.1,1.3,1.5,1.4,1.2
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
@ -97,6 +97,7 @@ public sealed class PatchScriptTests
var shape = Assert.Single(patch.TractShapes);
Assert.Equal("human", shape.Name);
Assert.Equal(8, shape.AreaFunction.Sections);
Assert.Equal(17, shape.AreaFunction.LengthCentimeters);
Assert.Equal(7, shape.AreaFunction.ReflectionCoefficients.Count);
Assert.Contains(shape.AreaFunction.ReflectionCoefficients, coefficient => MathF.Abs(coefficient) > 0.01f);
var glottis = Assert.Single(patch.GlottalSources);
@ -151,6 +152,61 @@ public sealed class PatchScriptTests
Assert.Contains("tract_oral_waveguide", faust);
}
[Fact]
public void TractAreaFunctionOwnsContinuousMorphology()
{
var shape = new TractAreaFunction([1, 3, 1], LengthCentimeters: 18);
Assert.Equal(0.18f, shape.LengthMeters, 5);
Assert.Equal(2, shape.DiameterAt(.25f), 5);
Assert.Equal(3, shape.DiameterAt(.5f), 5);
var resampled = shape.Resample(5);
Assert.Equal(5, resampled.Sections);
Assert.Equal(18, resampled.LengthCentimeters);
Assert.Equal([1, 2, 3, 2, 1], resampled.Diameters.Select(value => MathF.Round(value, 5)).ToArray());
var pinkTromboneCellDelay = new TractAreaFunction(Enumerable.Repeat(1f, 44).ToArray(), LengthCentimeters: 17)
.CellDelaySamples(44100);
Assert.InRange(pinkTromboneCellDelay, .49f, .51f);
}
[Fact]
public void ParserReadsTractShapeLengthAsPhysicalGeometry()
{
var patch = PatchScript.Parse("""
tract_shape name=longform length_cm=24 diameters=.5,1,2,1,.5
nasal_branch name=side length_cm=9 diameters=.01,.4,.8,.4
tract shape=longform nasal_branch=side propagation=waveguide
""");
var shape = Assert.Single(patch.TractShapes);
Assert.Equal(24, shape.AreaFunction.LengthCentimeters);
Assert.Equal(1.5f, shape.AreaFunction.DiameterAt(.375f), 5);
var branch = Assert.Single(patch.NasalBranches);
Assert.Equal(9, branch.AreaFunction?.LengthCentimeters);
Assert.Same(shape.AreaFunction, Assert.Single(patch.Voices).Tract?.AreaFunction);
}
[Fact]
public void TractVoiceCanChooseLoweringGridWithoutChangingMorphology()
{
var patch = PatchScript.Parse("""
tract_shape name=curve length_cm=17 diameters=.5,1,2,1,.5
tract shape=curve sections=9 propagation=waveguide
""");
var shape = Assert.Single(patch.TractShapes);
var tract = Assert.Single(patch.Voices).Tract;
Assert.NotNull(tract?.AreaFunction);
Assert.Equal(5, shape.AreaFunction.Sections);
Assert.Equal(9, tract.AreaFunction.Sections);
Assert.Equal(17, tract.AreaFunction.LengthCentimeters);
Assert.Equal(shape.AreaFunction.DiameterAt(.375f), tract.AreaFunction.DiameterAt(.375f), 5);
}
[Fact]
public void ParserPreservesDeclaredPatchParameters()
{