From 0970ebacd5eb3fc4288a72f3b71b68e18a13f040 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 28 May 2026 00:07:13 +0100 Subject: [PATCH] Separate graph source injection from topology --- docs/acoustic-graph-flow-audit.md | 19 ++++++++ src/AquaSynth.Core/FaustExport.cs | 29 +++++++++-- state/evidence.jsonl | 1 + state/spine.yaml | 3 +- .../PinkTromboneLogMelParityTests.cs | 48 +++++++++++++++++-- 5 files changed, 93 insertions(+), 7 deletions(-) diff --git a/docs/acoustic-graph-flow-audit.md b/docs/acoustic-graph-flow-audit.md index 117c391..66e31be 100644 --- a/docs/acoustic-graph-flow-audit.md +++ b/docs/acoustic-graph-flow-audit.md @@ -232,6 +232,25 @@ wave-convention fix, `papa` rose to cosine `0.8828` with near-unity RMS, while `thrombosis` still fails because it goes too silent even though speech-band ratio is finally present. +The user-listening report after that pass overruled the flattering metric: +`mama` and `papa` are still nearly the same vibrating-phone sound. The harness +now writes utterance separability evidence and fails when candidate utterances +are more mutually similar than the PT references by a wide log-mel margin. +Latest collapse artifact: +`artifacts/parity/pink-trombone-utterance-logmel/20260527T225833392`. +It reports `mama/papa` reference log-mel similarity `0.3791` versus candidate +similarity `0.6069`, with candidate envelope similarity `0.8885`. That is not +speech parity; it is spectral collapse with a nice report. + +Source terminals are also no longer topology owners unless they sit on a path +boundary. Interior source ports now attach to the nearest existing graph node +and inject energy there, while non-source terminals own node area/reflection. +This is the right ownership cut: geometry creates junctions; source ports add +energy to traveling waves. The change worsened some output levels because it +removed fake source-created impedance slices, but that is a downstream source, +closure, and radiation problem, not grounds to let source placement mutate the +tract. + The next coherent implementation target is source/boundary coupling, not more global gain: diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index 5be8bac..f05a7df 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -557,7 +557,12 @@ public static class FaustEmitter continue; } - var ordered = group + var groupTerminals = group.ToList(); + var topologyTerminals = groupTerminals + .Where(terminal => terminal.Kind != AcousticTerminalKind.Source || terminal.Position <= 0.0001f || terminal.Position >= 0.9999f) + .ToList(); + + var ordered = topologyTerminals .GroupBy(terminal => MathF.Round(Math.Clamp(terminal.Position, 0, 1), 4)) .OrderBy(positionGroup => positionGroup.Key) .ThenBy(positionGroup => positionGroup.Select(terminal => terminal.Name).Order(StringComparer.OrdinalIgnoreCase).First()) @@ -580,6 +585,19 @@ public static class FaustEmitter }) .ToList(); + foreach (var terminal in groupTerminals.Except(topologyTerminals)) + { + var nearest = ordered + .OrderBy(node => MathF.Abs(node.Position - Math.Clamp(terminal.Position, 0, 1))) + .ThenBy(node => node.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + if (nearest is not null) + { + ((List)nearest.Terminals).Add(terminal); + terminalNodes[terminal.Name] = nearest; + } + } + for (var i = 0; i < ordered.Count - 1; i++) { if (Math.Abs(ordered[i + 1].Position - ordered[i].Position) < 0.0001f) @@ -649,8 +667,13 @@ public static class FaustEmitter } foreach (var node in graphNodes) { - source.AppendLine($"{name}_graph_node_area_{node.Name} = max(0.000001, {string.Join(" + ", node.Terminals.Select(terminal => $"{name}_graph_terminal_area_{SafeIdentifier(terminal.Name)}"))});"); - source.AppendLine($"{name}_graph_node_reflection_{node.Name} = ({string.Join(" + ", node.Terminals.Select(terminal => $"{name}_graph_terminal_reflection_{SafeIdentifier(terminal.Name)}"))}) / {F(Math.Max(1, node.Terminals.Count))};"); + var areaTerminals = node.Terminals.Where(terminal => terminal.Kind != AcousticTerminalKind.Source).ToList(); + if (areaTerminals.Count == 0) + { + areaTerminals = node.Terminals.ToList(); + } + source.AppendLine($"{name}_graph_node_area_{node.Name} = max(0.000001, {string.Join(" + ", areaTerminals.Select(terminal => $"{name}_graph_terminal_area_{SafeIdentifier(terminal.Name)}"))});"); + source.AppendLine($"{name}_graph_node_reflection_{node.Name} = ({string.Join(" + ", areaTerminals.Select(terminal => $"{name}_graph_terminal_reflection_{SafeIdentifier(terminal.Name)}"))}) / {F(Math.Max(1, areaTerminals.Count))};"); } foreach (var segment in segments) { diff --git a/state/evidence.jsonl b/state/evidence.jsonl index c155ea7..9e17841 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -192,3 +192,4 @@ {"evidence":"Replaced same-path two-port area scatter with an admittance-weighted pressure-continuity law (`junction pressure`, then `pressure - incoming`) and switched generated graph tracts from half-sample grid to continuous fractional delay so added source terminals do not lengthen the tract. Narrow-open turbulence now starts above diameter 0.04 instead of 0.3. Focused graph/Faust checks passed; static PT graph artifact `artifacts/parity/pink-trombone-logmel/20260527T204022933` passed smoke floors. Utterance artifact `artifacts/parity/pink-trombone-utterance-logmel/20260527T203950790` still fails at papa with cosine 0.7314, articulation 0.1140, and speechBandRatio 0.0151. A per-segment Faust low-pass loss experiment and a softer radiation blend were tested and cut because they added cost or worsened papa without fixing articulation.","claim":"Passivity probes were not enough: the old two-port scatter could conserve local energy while using the wrong pressure relation for unequal areas. Correct pressure scattering and fractional graph delay are structural fixes even when PT metrics expose new failures. The next coherent target is graph-native closure reservoir/source impedance, not global gain or radiation-color compensation.","timestamp":"2026-05-27T20:45:00+01:00","topic":"acoustic-graph-pressure-law-fractional-delay"} {"evidence":"Added debug-only graph node incident-pressure/source probes and a papa source-probe artifact. `artifacts/parity/pink-trombone-graph-source-probes/20260527T204750891` showed lip-adjacent plosive source peaks around 0.094 versus glottal source around 3.7. Raising turbulence release-reservoir coupling moved the plosive source peak to about 0.432 in `artifacts/parity/pink-trombone-graph-source-probes/20260527T204918624`; focused source-probe and static PT graph tests passed. The utterance artifact `artifacts/parity/pink-trombone-utterance-logmel/20260527T205009225` still fails papa with cosine 0.7242 and articulation 0.1158.","claim":"Papa's plosive failure is now visible at the source layer: closure release was under-coupled relative to the glottal source. Increasing the graph source release reservoir is physically justified, but raw release amplitude alone does not restore speech-band articulation. Next work should shape that release through source impedance/constriction filtering and radiation, not boost the whole voice.","timestamp":"2026-05-27T20:55:00+01:00","topic":"acoustic-graph-source-probes-release-coupling"} {"evidence":"Re-read the PinkTromboneReferenceRenderer and found the graph lowering had mixed wave conventions: two-port same-path scatter had been rewritten as pressure-continuity over area-weighted pressure variables, while PT/Kelly-Lochbaum traveling waves use area reflection coefficients. Replaced two-port same-path scatter with `r=(A_left-A_right)/(A_left+A_right)`, `right_out=left_in-r*(left_in+right_in)`, `left_out=right_in+r*(left_in+right_in)`, and converted the three-port branch law to the matching PT nasal-junction form `r_p=(2*A_p-sumA)/sumA`, `out_p=r_p*in_p+(1+r_p)*sum(other inputs)`. The raw release-reservoir boost was cut after this wave-convention fix made papa overdrive. Static graph artifact `artifacts/parity/pink-trombone-logmel/20260527T223816035` passed; utterance artifact `artifacts/parity/pink-trombone-utterance-logmel/20260527T223931573` improved papa to cosine 0.8828, rmsRatio 1.0186, silenceMismatch 0.0455, while thrombosis still fails from silence/articulation.","claim":"The phone-vibration failure was partly caused by using the wrong wave-variable convention, not by insufficient source gain. Keep the KL/PT traveling-wave convention consistent across two-port and branch junctions. Do not restore the pressure-continuity rewrite unless the graph switches to explicitly power-normalized wave variables everywhere.","timestamp":"2026-05-27T22:45:00+01:00","topic":"acoustic-graph-kelly-lochbaum-wave-convention"} +{"evidence":"Added utterance separability diagnostics after user listening reported that mama and papa still sound like nearly identical phone vibrations despite flattering individual log-mel scores. The utterance harness now renders all accepted fixtures, writes `separability.txt`, and fails when candidate pairs are more mutually similar than reference pairs by more than 0.20 log-mel similarity. Latest artifact `artifacts/parity/pink-trombone-utterance-logmel/20260527T225833392` catches mama/papa collapse: referenceLogMel 0.3791, candidateLogMel 0.6069, referenceEnvelope 0.8670, candidateEnvelope 0.8885. Interior source terminals also stopped creating topology nodes or contributing to node area/reflection; they now attach to existing graph nodes and inject energy while geometry terminals own impedance.","claim":"Log-mel cosine can lie by rewarding shared filtered buzz. The parity harness must reject cross-utterance collapse, and graph source ports must inject into wave nodes without mutating the tract topology they are meant to excite.","timestamp":"2026-05-27T23:05:00+01:00","topic":"acoustic-graph-utterance-collapse-source-topology"} diff --git a/state/spine.yaml b/state/spine.yaml index 7e9e216..89a5825 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -1,6 +1,6 @@ project: AquaSynth spine_version: 1 -updated: 2026-05-27T22:45:00+01:00 +updated: 2026-05-27T23:05:00+01:00 prime_directive: > Build coherent synth machinery. References exist to reveal missing structure, @@ -121,6 +121,7 @@ live_invariants: - `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 now routes accepted parity through `propagation=graph`; the old waveguide parity lane is not a comparison baseline or historical pressure lane. - Pink Trombone log-mel parity now writes graph candidates only. Generated tract graphs mirror live `/pink/...` tract bindings into acoustic source, terminal, radiation, and path-area fields. Graph generation stopped baking first-frame tongue/constriction/lip morphology into the path; `AcousticPath.AreaControl` now turns tongue/constriction/lip controls into live area expressions at graph sample points. Velum owns nasal branch entrance diameter, which lowers through a squared parameter-binding transform into side-port admittance; generated nasal connections keep full connection coupling, and the nasal terminal carries the side-port admittance limit. The first nasal tube sample is no longer allowed to decide the velum opening. Lip opening owns path area and radiation aperture, not terminal area scaling. Graph radiation now uses aperture as a boundary impedance owner: lip/beak opening blends boundary reflection from near-closed termination to declared open-end reflection, names per-port boundary flow (`incoming - outgoing`), and weights output by bounded local terminal admittance rather than a fixed 4x output scalar. Latest static graph evidence after radiation admittance normalization is under `artifacts/parity/pink-trombone-logmel/20260527T170548495`: open/front/nasal/ma/sibilant/closure cosine 0.6675/0.7216/0.7251/0.7098/0.4862/0.6513; latest utterance evidence under `artifacts/parity/pink-trombone-utterance-logmel/20260527T170618466`: mama/papa/thrombosis cosine 0.7428/0.7830/0.5442 with rmsRatio 2.7955/2.4594/2.7397 and articulation 0.1288/0.1226/0.1151. The output is less absurdly loud, but articulation and speech-band motion are still the live failure. - `docs/acoustic-graph-flow-audit.md` compares Aqua's graph to Birkholz/VTL, Story/TubeTalker, Smith waveguides, ArtiSynth, and PT/SndKit. The graph records can name the research-backed architecture, but current Faust lowering still lacks fully shaped graph-native closure pressure storage. The kept physical fixes move nasal coupling from global connection damping into transformed side-port admittance and use one Kelly-Lochbaum/PT-style traveling-wave convention for ordinary two-port area discontinuities and three-port branch junctions. The previous pressure-continuity rewrite was wrong for the graph's wave variables and produced the phone-vibration failure. Radiation now has named flow/admittance probes and local terminal-admittance weighting, reducing over-radiation without restoring the old damper. The graph clock for generated tract graphs is continuous fractional delay so adding terminals does not silently lengthen the morphology. Node source/incident-pressure debug probes expose source-layer failures; a raw release-reservoir boost was tested and cut after the KL fix made it overdrive papa. Latest utterance artifact `artifacts/parity/pink-trombone-utterance-logmel/20260527T223931573` has papa at cosine 0.8828/RMS 1.0186 while thrombosis still fails from silence/articulation. Next cut is source impedance/constriction filtering for release energy, not global gain or PT-shaped modules. + - PT utterance metrics now include cross-utterance separability because log-mel parity alone hid the user's actual listening failure. Artifact `artifacts/parity/pink-trombone-utterance-logmel/20260527T225833392` shows `mama/papa` candidate collapse: reference log-mel similarity 0.3791 versus candidate 0.6069, with candidate envelope similarity 0.8885. Interior source terminals no longer decide topology or node area/reflection; they attach to existing graph nodes and inject energy, while non-source terminals own geometry. Keep this source/topology cut even if other broken layers get louder. - Graph source lowering now names per-node incident pressure and node-local source sums inside the recursive graph loop. Generated tract injection source banks now use cell-centered positions and `1 / sections` index scaling, so turbulence/release energy is born inside tube cells rather than directly on graph/radiation nodes. Latest source-position artifact under `artifacts/parity/pink-trombone-utterance-logmel/20260527T172949293`: mama/papa/thrombosis cosine 0.6654/0.7746/0.5522, rmsRatio 3.0010/2.5712/2.4943, articulation 0.1444/0.1177/0.1193. Static closure-release RMS improved to 1.0078 under `artifacts/parity/pink-trombone-logmel/20260527T172843945`, but papa articulation worsened; keep the cell-centered source placement because it is the correct graph-level source geometry, not because metrics liked it. - TurbulenceJet now includes a release-timed burst-noise component in addition to the deterministic pressure pulse, but a stronger trial barely moved papa; the source is not the main remaining articulation bottleneck. Graph radiation now mixes 30% raw boundary flow with 70% high-passed flow after local admittance weighting, down from the over-bright 15/85 blend. Latest radiation/source artifact under `artifacts/parity/pink-trombone-utterance-logmel/20260527T174236049`: mama/papa/thrombosis cosine 0.6578/0.7692/0.5585, rmsRatio 3.0006/2.6153/2.3488, articulation 0.1437/0.1173/0.1191. Static `closure-release` under `artifacts/parity/pink-trombone-logmel/20260527T174139661` has rmsRatio 0.9900 and speechBandRatio 0.0842. This is still not speaking; next pressure is true tract resonance/passive scattering and pressure storage, not more burst gain. - Same-path two-port area discontinuities now emit `graph_area_energy_in_*` and `graph_area_energy_out_*` probes alongside the three-port branch `graph_connection_energy_*` probes. Use these to inspect passivity and formant-body failures at the scatter sites rather than judging only final output loudness. diff --git a/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs b/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs index 0b082cf..a44d0fc 100644 --- a/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs @@ -96,6 +96,8 @@ public sealed class PinkTromboneLogMelParityTests var artifactDir = ArtifactPath("parity", "pink-trombone-utterance-logmel", DateTimeOffset.UtcNow.ToString("yyyyMMddTHHmmssfff", CultureInfo.InvariantCulture)); var analyzer = new AudioAnalyzer(new AudioAnalysisConfig(SampleRate: 44100)); var reports = new List(); + var rendered = new List(); + var smokeFailures = new List(); foreach (var fixture in PinkTromboneUtteranceFixtures.All.Where(fixture => UtteranceParityIds.Contains(fixture.Id))) { @@ -110,6 +112,7 @@ public sealed class PinkTromboneLogMelParityTests Assert.True(candidate.Samples.Max(MathF.Abs) > 0.00001f, candidate.Stderr); var comparison = analyzer.Compare(reference.Samples, candidate.Samples); + rendered.Add(new RenderedUtterance(fixture.Id, reference.Samples, candidate.Samples)); var report = UtteranceReport(fixture, comparison); reports.Add(report); @@ -121,13 +124,22 @@ public sealed class PinkTromboneLogMelParityTests File.WriteAllText(Path.Combine(fixtureDir, "report.txt"), report); File.WriteAllText(Path.Combine(fixtureDir, "controls.csv"), UtteranceControlCsv(fixture)); - Assert.True( - comparison.LogMelCosineSimilarity >= UtteranceGraphSmokeCosineFloors.GetValueOrDefault(fixture.Id, 0.1f), - $"{report}{Environment.NewLine}artifacts: {artifactDir}"); + if (comparison.LogMelCosineSimilarity < UtteranceGraphSmokeCosineFloors.GetValueOrDefault(fixture.Id, 0.1f)) + { + smokeFailures.Add(report); + } } Directory.CreateDirectory(artifactDir); File.WriteAllText(Path.Combine(artifactDir, "summary.txt"), string.Join(Environment.NewLine + Environment.NewLine, reports)); + var separability = UtteranceSeparability(analyzer, rendered); + File.WriteAllLines(Path.Combine(artifactDir, "separability.txt"), separability.Select(item => item.Report)); + var collapseFailures = separability + .Where(item => item.CandidateLogMelSimilarity > item.ReferenceLogMelSimilarity + 0.20f) + .Select(item => $"candidate collapse: {item.Report}") + .ToArray(); + var failures = smokeFailures.Concat(collapseFailures).ToArray(); + Assert.True(failures.Length == 0, $"{string.Join(Environment.NewLine, failures)}{Environment.NewLine}artifacts: {artifactDir}"); } [Fact] @@ -294,6 +306,36 @@ public sealed class PinkTromboneLogMelParityTests $"{fixture.Id}/graph-utterance: verdict={verdict} 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} articulation={comparison.Articulation.ArticulationScore:0.0000} envCos={comparison.Articulation.EnvelopeCosineSimilarity:0.0000} activeRatio={comparison.Articulation.ActiveFrameRatio:0.0000} silenceMismatch={comparison.Articulation.SilenceMismatch:0.0000} envelopeFluxRatio={comparison.Articulation.EnvelopeFluxRatio:0.0000} spectralFluxRatio={comparison.Articulation.SpectralFluxRatio:0.0000} motorBandRatio={comparison.Articulation.MotorBandRatio:0.0000} speechBandRatio={comparison.Articulation.SpeechBandRatio:0.0000}"); } + private static IReadOnlyList UtteranceSeparability(AudioAnalyzer analyzer, IReadOnlyList utterances) + { + var rows = new List(); + for (var i = 0; i < utterances.Count; i++) + { + for (var j = i + 1; j < utterances.Count; j++) + { + var left = utterances[i]; + var right = utterances[j]; + var reference = analyzer.Compare(left.ReferenceSamples, right.ReferenceSamples); + var candidate = analyzer.Compare(left.CandidateSamples, right.CandidateSamples); + var report = string.Create( + CultureInfo.InvariantCulture, + $"{left.Id}/{right.Id}: referenceLogMel={reference.LogMelCosineSimilarity:0.0000} candidateLogMel={candidate.LogMelCosineSimilarity:0.0000} referenceEnvelope={reference.Articulation.EnvelopeCosineSimilarity:0.0000} candidateEnvelope={candidate.Articulation.EnvelopeCosineSimilarity:0.0000} referenceArticulation={reference.Articulation.ArticulationScore:0.0000} candidateArticulation={candidate.Articulation.ArticulationScore:0.0000}"); + rows.Add(new UtteranceSeparabilityRow( + left.Id, + right.Id, + reference.LogMelCosineSimilarity, + candidate.LogMelCosineSimilarity, + report)); + } + } + + return rows; + } + + private sealed record UtteranceSeparabilityRow(string LeftId, string RightId, float ReferenceLogMelSimilarity, float CandidateLogMelSimilarity, string Report); + + private sealed record RenderedUtterance(string Id, float[] ReferenceSamples, float[] CandidateSamples); + private static string ArticulationVerdict(AudioComparison comparison) { var articulation = comparison.Articulation;