Compare graph tract parity fixtures

This commit is contained in:
Meta Vens 2026-05-26 19:23:14 +01:00
commit c8086f7c74
8 changed files with 85 additions and 14 deletions

View file

@ -213,6 +213,15 @@ semantics and a `tract propagation=graph` entry point; the remaining parity
question is whether graph-authored PT compatibility can beat the current weak
`Voice.Tract` baseline.
The parity report now renders both lanes. Current graph evidence is mixed:
nasal, sibilant, and closure-release scores improved in some proxy metrics, but
open/front vowel log-mel cosine remains far below the legacy waveguide lane and
the graph lane needed explicit radiation gain to escape underpowered output.
Baking static tongue/constriction/lip shape into the generated graph path did
not materially move the report, so the next useful pressure is lower-level
source, scattering, radiation, and transient behavior rather than more contour
paperwork.
## Cut Line
The current `Voice.Tract` scalar proxy may survive only as an adapter over real

View file

@ -653,6 +653,7 @@ public static class FaustEmitter
source.AppendLine($"{name}_graph_connection_coupling_{SafeIdentifier(connection.Name)} = clip01({parameters.Expression(OwnerField(connectionPath, "coupling"), connection.Coupling)});");
source.AppendLine($"{name}_graph_connection_loss_{SafeIdentifier(connection.Name)} = clip01({parameters.Expression(OwnerField(connectionPath, "loss"), connection.Loss)});");
}
source.AppendLine($"{name}_graph_radiation_gain = 24.0;");
foreach (var sourceName in network.SourcePorts)
{
@ -759,7 +760,7 @@ public static class FaustEmitter
}
nextStates.AddRange(segments.Select(segment => $"{name}_graph_next_l{segment.Index}"));
source.Replace(string.Join(", ", NextStatesPlaceholder(stateCount)), string.Join(", ", nextStates));
source.AppendLine($" {name}_graph_out = {(radiated.Count == 0 ? "0.0" : string.Join(" + ", radiated))};");
source.AppendLine($" {name}_graph_out = ({(radiated.Count == 0 ? "0.0" : string.Join(" + ", radiated))}) * {name}_graph_radiation_gain;");
source.AppendLine(" };");
source.AppendLine("};");
source.AppendLine($"{name}_acoustic_graph_radiated = {name}_graph({name}_graph_drive);");

View file

@ -1215,7 +1215,10 @@ public static class PatchScript
{
var prefix = ownerPath.Replace("/", "_", StringComparison.Ordinal).Trim('_');
var primaryPathName = $"{prefix}_oral";
var oralArea = tract.AreaFunction ?? new TractAreaFunction([0.6f, 0.8f, 1.2f, 1.5f, 1.5f, 1.2f, 0.8f, 0.6f], 17);
var baseOralArea = tract.AreaFunction ?? new TractAreaFunction([0.6f, 0.8f, 1.2f, 1.5f, 1.5f, 1.2f, 0.8f, 0.6f], 17);
var oralArea = tract.Propagation == TractPropagationMode.Graph
? TractGraphAreaFunction(tract, baseOralArea)
: baseOralArea;
AddAcousticPathRecord(new AcousticPath(primaryPathName, oralArea, 343, tract.WaveguideLoss));
var sourceNames = new List<string>();
@ -1233,7 +1236,14 @@ public static class PatchScript
tract.Glottis?.Skew ?? 0.42f,
tract.Glottis?.Aspiration ?? 0.08f);
AddAcousticSourcePortRecord(source);
AddAcousticTerminalRecord(new AcousticTerminal(source.Name, source.Path, source.Position, AcousticTerminalKind.Source, source.Name));
AddAcousticTerminalRecord(new AcousticTerminal(
source.Name,
source.Path,
source.Position,
AcousticTerminalKind.Source,
source.Name,
1,
tract.GlottalReflection));
sourceNames.Add(sourceName);
if (tract.Injection is { } injection)
@ -1352,6 +1362,33 @@ public static class PatchScript
private static float NormalizeTractIndex(float index, int sections) =>
sections <= 0 ? 0 : Math.Clamp(index / Math.Max(1, sections - 1), 0, 1);
private static TractAreaFunction TractGraphAreaFunction(VocalTract tract, TractAreaFunction areaFunction)
{
var sections = areaFunction.Sections;
var diameters = new float[sections];
for (var i = 0; i < sections; i++)
{
var diameter = areaFunction.Diameters[i];
if (i == sections - 1)
{
diameter = tract.LipOpening;
}
else
{
var tongueWidth = Math.Max(1, sections * 0.18f);
var tongueWeight = MathF.Exp(0 - MathF.Pow((i - tract.TongueIndex) / tongueWidth, 2));
diameter += (tract.TongueDiameter - diameter) * tongueWeight;
}
var constrictionWidth = Math.Max(1, sections * 0.09f);
var constrictionWeight = MathF.Exp(0 - MathF.Pow((i - tract.ConstrictionIndex) / constrictionWidth, 2));
diameter = Math.Min(diameter, tract.ConstrictionDiameter + Math.Max(0, diameter - tract.ConstrictionDiameter) * (1 - constrictionWeight));
diameters[i] = Math.Max(0, diameter);
}
return new TractAreaFunction(diameters, areaFunction.LengthCentimeters);
}
private void AddModBus(IReadOnlyDictionary<string, string> fields, int line)
{
var name = GetAny(fields, ["name", "n"], "mod");

View file

@ -5,6 +5,7 @@ public sealed record PinkTromboneParityFixture(
string Description,
PinkTromboneFixtureControls Controls,
string AquaScript,
string GraphAquaScript,
IReadOnlyList<string> ReferenceFeatures);
public sealed record PinkTromboneFixtureControls(
@ -98,9 +99,9 @@ public static class PinkTromboneParityFixtures
string description,
PinkTromboneFixtureControls controls,
IReadOnlyList<string> referenceFeatures) =>
new(id, description, controls, Script(controls), referenceFeatures);
new(id, description, controls, Script(controls, "waveguide"), Script(controls, "graph"), referenceFeatures);
private static string Script(PinkTromboneFixtureControls controls) =>
private static string Script(PinkTromboneFixtureControls controls, string propagation) =>
$$"""
patch gain=0.82 soft_clip=true
@ -113,7 +114,7 @@ public static class PinkTromboneParityFixtures
nasal_branch name=nose junction=17 velum=.01 reflection=-.85 loss=.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=motion diameter_slew=18 shape_return=8 constriction_slew=24 velum_slew=16 obstruction_threshold=.05
tract shape=human glottis=modal injection=inj nasal_branch=nose motion=motion propagation=waveguide waveguide_loss=.999 freq={{F(controls.Frequency)}} gain={{F(controls.Gain)}} sustain=.45 decay=.12 tongue_index={{F(controls.TongueIndex)}} tongue_diameter={{F(controls.TongueDiameter)}} constriction_index={{F(controls.ConstrictionIndex)}} constriction_diameter={{F(controls.ConstrictionDiameter)}} turbulence={{F(controls.Turbulence)}} velum={{F(controls.Velum)}} lip={{F(controls.LipOpening)}} burst={{F(controls.Burst)}} glottal_reflection={{F(controls.GlottalReflection)}} lip_reflection={{F(controls.LipReflection)}}
tract shape=human glottis=modal injection=inj nasal_branch=nose motion=motion propagation={{propagation}} waveguide_loss=.999 freq={{F(controls.Frequency)}} gain={{F(controls.Gain)}} sustain=.45 decay=.12 tongue_index={{F(controls.TongueIndex)}} tongue_diameter={{F(controls.TongueDiameter)}} constriction_index={{F(controls.ConstrictionIndex)}} constriction_diameter={{F(controls.ConstrictionDiameter)}} turbulence={{F(controls.Turbulence)}} velum={{F(controls.Velum)}} lip={{F(controls.LipOpening)}} burst={{F(controls.Burst)}} glottal_reflection={{F(controls.GlottalReflection)}} lip_reflection={{F(controls.LipReflection)}}
""";
private static string F(float value) =>

View file

@ -151,3 +151,4 @@
{"timestamp":"2026-05-26T18:35:00+01:00","topic":"physical-voice-typed-graph-faust-lowering","claim":"Typed acoustic path graphs now compile to Faust bidirectional segment state when terminals are present. The lowering splits paths at terminals, creates right/left segment state, scatters connected terminals by area-weighted pressure continuity, injects source terminals, and sums radiation terminals. Remaining known limits are integer sample segment delays, fallback for co-located same-path terminals, and only first area-scattering law lowering.","evidence":"Added `AcousticGraphExpression` in `FaustExport`; focused tests `ParserSupportsReusableAcousticPortNetworkPrimitives`, `ParserSupportsTypedAcousticPathGraph`, `FaustCompilerValidatesAcousticPathGraphWhenInstalled`, `ParserSupportsPinkTromboneStyleTractVoice`, and `WaveguideTractDefaultsToAcousticUnitDelayGrid` passed; `dotnet build src/AquaSynth.Core/AquaSynth.Core.csproj --no-restore --disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false` passed."}
{"timestamp":"2026-05-26T19:10:00+01:00","topic":"physical-voice-graph-wave-clock-and-node-aggregation","claim":"Acoustic graph lowering now honors `wave_clock` delay strategy and aggregates co-located terminals into shared graph nodes. Sources can inject at connected nodes, and graph delay lowers through unit-grid, half-sample, linear fractional, Lagrange, or Thiran/allpass Faust primitives from physical segment length and `ma.SR`.","evidence":"Updated `AcousticGraphExpression` with wave-clock delay selection, same-position terminal node aggregation, connected-node source injection, and basic connection-law expression routing; focused tests `ParserSupportsReusableAcousticPortNetworkPrimitives`, `ParserSupportsTypedAcousticPathGraph`, `FaustCompilerValidatesAcousticPathGraphWhenInstalled`, `ParserSupportsPinkTromboneStyleTractVoice`, and `WaveguideTractDefaultsToAcousticUnitDelayGrid` passed; core build passed."}
{"timestamp":"2026-05-26T19:30:00+01:00","topic":"physical-voice-tract-graph-propagation","claim":"PT-shaped `tract` authoring can now opt into typed acoustic graph audio with `propagation=graph`. Generated tract acoustic networks now include typed terminals/connections, normalized tract-index positions, and a Thiran wave-clock default for graph propagation; legacy `propagation=waveguide` remains available as the old baseline.","evidence":"Added `TractPropagationMode.Graph`, parser alias `propagation=graph`, graph-first Faust routing for tract voices in graph mode, generated tract terminal/connection records, and focused test `TractVoiceCanLowerThroughAcousticGraph`; focused tests and core build passed."}
{"timestamp":"2026-05-26T19:55:00+01:00","topic":"pink-trombone-graph-logmel-evidence","claim":"Pink Trombone parity reporting now renders both legacy waveguide and graph-propagation candidates. Graph output is Faust-valid and audible, but open/front vowel log-mel cosine remains below legacy; nasal graph is near legacy, sibilant score improves while cosine remains low, and closure-release graph cosine becomes positive. Static tongue/constriction/lip baking into graph path did not materially move the latest report, pointing next pressure at source/scattering/radiation/transient behavior.","evidence":"Updated `PinkTromboneParityFixture` with `GraphAquaScript`, parity artifacts now include `candidate-graph.*` and per-lane reports. Latest run under `artifacts/parity/pink-trombone-logmel/20260526T182050731`: open graph cosine 0.3736 vs legacy 0.6143, front 0.3889 vs 0.6349, nasal 0.5318 vs 0.5425, sibilant 0.2102 vs 0.3744, closure 0.1168 vs -0.0820."}

View file

@ -118,6 +118,7 @@ live_invariants:
- The physical voice research packet now includes a practical implementation/researcher map (`research/physical-voice-modeling/PEOPLE.md`) and talk/transcript ledger (`research/physical-voice-modeling/talks/TALKS.md`). Birkholz/VocalTractLab pressures multi-level articulatory control and gestures; Story/TubeTalker pressures continuous parametric area functions and length/coupling; Smith pressures bidirectional delay-line scattering and fractional-delay waveguides; Fels/ArtiSynth pressures separation between biomechanics, acoustics, timeline controls, and diagnostics. These are reusable DSL semantics, not modules to clone.
- Physical voice implementation has started moving PT pressure into neutral acoustic owners: `AcousticPath`, `AcousticTerminal`, `AcousticConnection`, `AcousticSourcePort`, `AcousticBranch`, `AcousticRadiationPort`, `WaveClockPolicy`, and `AcousticPortNetwork`. Parser commands `path`, `terminal`, `connect`, `source_port`, `branch`, `radiation_port`, `wave_clock`, and `acoustic_network` now exist. Existing `tract_shape`, `glottis`, `tract_injection`, `nasal_branch`, and `tract` commands populate acoustic records as compatibility aliases while the current `Voice.Tract` Faust renderer still owns audio.
- `acoustic` / `acoustic_voice` can now render an `AcousticPortNetwork` through a compact Faust response proxy or, when typed terminals are present, a compiled bidirectional Faust segment graph. Graph lowering splits paths at terminal positions, aggregates co-located terminals into graph nodes, orders connection ports deterministically, scatters area-weighted/pressure/bypass connection groups, injects source terminals even at connected nodes, and sums radiation terminals. Segment delay follows physical segment length and the network `wave_clock` strategy through unit-grid, half-sample, linear fractional, Lagrange, or Thiran/allpass Faust delay primitives. Source, branch, radiation, terminal, and connection controls can bind through ordinary Aqua parameters into emitted Faust. PT-shaped `tract` authoring can opt into graph audio with `propagation=graph`; legacy `propagation=waveguide` remains the comparison baseline. Remaining pressure: deepen connection-law semantics and run log-mel parity against the graph renderer. Do not expand the proxy into a second fake synthesizer.
- Pink Trombone log-mel parity now writes both legacy waveguide and graph candidates. Latest graph evidence after radiation gain: open/front graph cosine stays around 0.37/0.39 versus legacy 0.61/0.63; nasal graph cosine is close to legacy around 0.53; sibilant graph score improves but cosine remains low; closure-release graph cosine becomes positive but still weak. Static tongue/constriction/lip baking into graph path did not materially move these numbers, so the next graph pressure is source/scattering/radiation/transient behavior, not more static shape paperwork.
- 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. Waveguide tracts now default to an acoustic unit-delay grid from physical length at the current 44.1 kHz target, so a 17 cm oral tract emits about 22 sections instead of treating PT's 44 half-sample cells as anatomy; `VocalTract.IndexScale` maps runtime PT-index controls onto that emitted grid. 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.61 open vowel, 0.63 front vowel, 0.54 nasal, 0.37 sibilant, and -0.08 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; closure/release timing is still badly wrong.
- 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.

View file

@ -629,6 +629,13 @@ public sealed class PatchScriptTests
Assert.Contains("wg_diameter_target_", export.Source);
Assert.Contains("wg_substep_drive", export.Source);
Assert.Contains("process =", export.Source);
var graphPatch = PatchScript.Parse(fixture.GraphAquaScript);
var graphVoice = Assert.Single(graphPatch.Voices);
var graphExport = FaustEmitter.Emit(graphPatch, new FaustExportOptions($"pt_graph_{fixture.Id.Replace('-', '_')}"));
Assert.Equal(TractPropagationMode.Graph, graphVoice.Tract?.Propagation);
Assert.Contains("acoustic_graph_radiated", graphExport.Source);
Assert.DoesNotContain("wg_diameter_target_", graphExport.Source);
Assert.NotEmpty(fixture.ReferenceFeatures);
}

View file

@ -57,26 +57,40 @@ public sealed class PinkTromboneLogMelParityTests
var comparison = new AudioAnalyzer(new AudioAnalysisConfig(SampleRate: reference.SampleRate))
.Compare(reference.Samples, candidate.Samples);
reports.Add(Report(fixture, comparison));
WriteFixtureArtifacts(artifactDir, fixture, reference.Samples, candidate.Samples, reference.SampleRate, comparison, candidateSource.Source);
reports.Add(Report(fixture, comparison, "waveguide"));
WriteFixtureArtifacts(artifactDir, fixture, "waveguide", reference.Samples, candidate.Samples, reference.SampleRate, comparison, candidateSource.Source);
var graphSource = FaustEmitter.EmitScript(fixture.GraphAquaScript, new FaustExportOptions($"pt_graph_{fixture.Id.Replace('-', '_')}"));
File.WriteAllText(Path.Combine(fixtureDir, "candidate-graph.dsp"), graphSource.Source);
var graphCandidate = await FaustCompiler.RenderAsync(
graphSource.Source,
new FaustRenderOptions(reference.SampleRate, reference.Samples.Length / (float)reference.SampleRate));
Assert.NotNull(graphCandidate);
Assert.True(graphCandidate.Samples.Length > 0, $"{graphCandidate.Stderr}{Environment.NewLine}artifacts: {fixtureDir}");
Assert.True(graphCandidate.Samples.Max(MathF.Abs) > 0.00001f, graphCandidate.Stderr);
var graphComparison = new AudioAnalyzer(new AudioAnalysisConfig(SampleRate: reference.SampleRate))
.Compare(reference.Samples, graphCandidate.Samples);
reports.Add(Report(fixture, graphComparison, "graph"));
WriteFixtureArtifacts(artifactDir, fixture, "graph", reference.Samples, graphCandidate.Samples, reference.SampleRate, graphComparison, graphSource.Source);
Assert.True(
comparison.LogMelCosineSimilarity >= FixtureSmokeCosineFloors.GetValueOrDefault(fixture.Id, SmokeCosineFloor),
$"{Report(fixture, comparison)}{Environment.NewLine}artifacts: {artifactDir}");
$"{Report(fixture, comparison, "waveguide")}{Environment.NewLine}artifacts: {artifactDir}");
}
Directory.CreateDirectory(artifactDir);
File.WriteAllText(Path.Combine(artifactDir, "summary.txt"), string.Join(Environment.NewLine + Environment.NewLine, reports));
}
private static string Report(PinkTromboneParityFixture fixture, AudioComparison comparison) =>
private static string Report(PinkTromboneParityFixture fixture, AudioComparison comparison, string candidate) =>
string.Create(
CultureInfo.InvariantCulture,
$"{fixture.Id}: cosine={comparison.LogMelCosineSimilarity:0.0000} logMelDistance={comparison.LogMelDistance:0.0000} score={comparison.Score:0.0000} rmsRatio={comparison.RmsRatio:0.0000} centroidRatio={comparison.CentroidRatio:0.0000}");
$"{fixture.Id}/{candidate}: cosine={comparison.LogMelCosineSimilarity:0.0000} logMelDistance={comparison.LogMelDistance:0.0000} score={comparison.Score:0.0000} rmsRatio={comparison.RmsRatio:0.0000} centroidRatio={comparison.CentroidRatio:0.0000}");
private static void WriteFixtureArtifacts(
string artifactDir,
PinkTromboneParityFixture fixture,
string candidateName,
IReadOnlyList<float> reference,
IReadOnlyList<float> candidate,
int sampleRate,
@ -86,9 +100,9 @@ public sealed class PinkTromboneLogMelParityTests
var fixtureDir = Path.Combine(artifactDir, fixture.Id);
Directory.CreateDirectory(fixtureDir);
WriteWav(Path.Combine(fixtureDir, "reference-pink-trombone.wav"), reference, sampleRate);
WriteWav(Path.Combine(fixtureDir, "candidate-aquasynth.wav"), candidate, sampleRate);
File.WriteAllText(Path.Combine(fixtureDir, "candidate.dsp"), candidateSource);
File.WriteAllText(Path.Combine(fixtureDir, "report.txt"), Report(fixture, comparison));
WriteWav(Path.Combine(fixtureDir, $"candidate-{candidateName}.wav"), candidate, sampleRate);
File.WriteAllText(Path.Combine(fixtureDir, $"candidate-{candidateName}.dsp"), candidateSource);
File.WriteAllText(Path.Combine(fixtureDir, $"report-{candidateName}.txt"), Report(fixture, comparison, candidateName));
}
private static string ArtifactPath(params string[] parts)