From c8086f7c74d5bf7347f4e6bc0b3ea30266d85ba5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 26 May 2026 19:23:14 +0100 Subject: [PATCH] Compare graph tract parity fixtures --- docs/pink-trombone-dsl-parity-plan.md | 9 ++++ src/AquaSynth.Core/FaustExport.cs | 3 +- src/AquaSynth.Core/PatchScript.cs | 41 ++++++++++++++++++- .../PinkTromboneParityFixtures.cs | 7 ++-- state/evidence.jsonl | 1 + state/spine.yaml | 1 + tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs | 7 ++++ .../PinkTromboneLogMelParityTests.cs | 30 ++++++++++---- 8 files changed, 85 insertions(+), 14 deletions(-) diff --git a/docs/pink-trombone-dsl-parity-plan.md b/docs/pink-trombone-dsl-parity-plan.md index 1780714..9f09c10 100644 --- a/docs/pink-trombone-dsl-parity-plan.md +++ b/docs/pink-trombone-dsl-parity-plan.md @@ -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 diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index ecb7e9b..eb8a79b 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -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);"); diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index 9e05a11..d5d2e79 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -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(); @@ -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 fields, int line) { var name = GetAny(fields, ["name", "n"], "mod"); diff --git a/src/AquaSynth.Core/PinkTromboneParityFixtures.cs b/src/AquaSynth.Core/PinkTromboneParityFixtures.cs index e3943bb..b5fc8a6 100644 --- a/src/AquaSynth.Core/PinkTromboneParityFixtures.cs +++ b/src/AquaSynth.Core/PinkTromboneParityFixtures.cs @@ -5,6 +5,7 @@ public sealed record PinkTromboneParityFixture( string Description, PinkTromboneFixtureControls Controls, string AquaScript, + string GraphAquaScript, IReadOnlyList ReferenceFeatures); public sealed record PinkTromboneFixtureControls( @@ -98,9 +99,9 @@ public static class PinkTromboneParityFixtures string description, PinkTromboneFixtureControls controls, IReadOnlyList 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) => diff --git a/state/evidence.jsonl b/state/evidence.jsonl index a718a36..097b7cc 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/spine.yaml b/state/spine.yaml index 7b7d673..1171091 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -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. diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index 56bb979..d2a12da 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -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); } diff --git a/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs b/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs index 4c06f91..c7f636b 100644 --- a/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs @@ -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 reference, IReadOnlyList 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)