diff --git a/docs/acoustic-graph-proprioception.md b/docs/acoustic-graph-proprioception.md index 9988f26..6930a20 100644 --- a/docs/acoustic-graph-proprioception.md +++ b/docs/acoustic-graph-proprioception.md @@ -236,11 +236,14 @@ PT/SndKit comparison report: - `PrimitiveReferenceReport.ComparePinkTrombone` compares primitive geometry, delay, branch admittance, contact, and radiation fields against local PT/SndKit fixture controls without adding a native reference dependency to CI. +- `PinkTromboneReferenceRenderer.RenderTimeline` exposes PT internal oral/nasal + path, branch, contact, and radiation timeline fields from its own arrays, and + `PrimitiveReferenceReport.ComparePinkTromboneTimeline` role-normalizes those + fields against Aqua primitive timelines. The next implementation pass should harden the primitives further: -- compare per-block Aqua primitive timelines against PT/SndKit internals for one - compact tract render, not just static fixture controls; +- turn the timeline comparison into artifact reports for the PT fixture suite; - add optional VTL/ArtiSynth adapters only as artifact comparators, not default CI dependencies; - keep `mama`, `papa`, and `thrombosis` as downstream witnesses until primitive diff --git a/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs b/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs index 3b510db..c00136e 100644 --- a/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs +++ b/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs @@ -11,6 +11,12 @@ public sealed record PinkTromboneUtteranceRender( string FixtureId, IReadOnlyList ControlPoints); +public sealed record PinkTromboneReferenceTimelineSample( + int Block, + string Primitive, + string Signal, + float Value); + public sealed record PinkTromboneControlPoint( float TimeSeconds, PinkTromboneFixtureControls Controls, @@ -29,6 +35,17 @@ public sealed class PinkTromboneReferenceRenderer(int sampleRate = 44100) return new PinkTromboneReferenceRender(samples, sampleRate, controls); } + public IReadOnlyList RenderTimeline( + PinkTromboneFixtureControls controls, + float durationSeconds = 0.12f, + int blockSize = 512) + { + var frames = Math.Max(1, (int)MathF.Round(sampleRate * Math.Max(durationSeconds, 1f / sampleRate))); + var synth = new Synthesizer(sampleRate); + var samples = new float[frames]; + return synth.SynthesizeTimeline(samples, _ => controls, Math.Clamp(blockSize, 1, 4096)); + } + public PinkTromboneUtteranceRender RenderUtterance( string fixtureId, IReadOnlyList controlPoints, @@ -118,6 +135,26 @@ public sealed class PinkTromboneReferenceRenderer(int sampleRate = 44100) } } + public IReadOnlyList SynthesizeTimeline( + float[] buffer, + Func controlsAtFrame, + int blockSize) + { + var p = 0; + var block = 0; + var timeline = new List(); + while (p < buffer.Length) + { + var blockLength = Math.Min(Math.Min(MaxBlockLength, blockSize), buffer.Length - p); + SynthesizeBlock(buffer, p, blockLength, controlsAtFrame(p)); + tract.Snapshot(block, timeline); + p += blockLength; + block++; + } + + return timeline; + } + private void SynthesizeBlock(float[] buffer, int offset, int count, PinkTromboneFixtureControls controls) { var deltaTime = count / (float)sampleRate; @@ -442,11 +479,61 @@ public sealed class PinkTromboneReferenceRenderer(int sampleRate = 44100) return lipOutput + noseOutput; } + public void Snapshot(int block, List timeline) + { + var oralArea = Diameter.Select(diameter => diameter * diameter).DefaultIfEmpty(0).Average(); + var nasalArea = NoseDiameter.Select(diameter => diameter * diameter).DefaultIfEmpty(0).Average(); + var oralIncoming = right.Select(MathF.Abs).DefaultIfEmpty(0).Average() + left.Select(MathF.Abs).DefaultIfEmpty(0).Average(); + var oralOutgoing = junctionOutputRight.Select(MathF.Abs).DefaultIfEmpty(0).Average() + junctionOutputLeft.Take(N).Select(MathF.Abs).DefaultIfEmpty(0).Average(); + var oralEnergyIn = WaveEnergy(Diameter, right, left); + var oralEnergyOut = WaveEnergy(Diameter, junctionOutputRight, junctionOutputLeft); + var nasalAdmittance = Math.Clamp(NoseDiameter[0] * NoseDiameter[0], 0, 1); + var obstruction = Diameter.Select((diameter, index) => (diameter, index)).MinBy(item => item.diameter); + var lipFlow = right[N - 1]; + var noseFlow = noseRight[NoseLength - 1]; + + Add(timeline, block, "path:pt_oral", "area", oralArea); + Add(timeline, block, "path:pt_oral", "incoming_wave", oralIncoming); + Add(timeline, block, "path:pt_oral", "outgoing_wave", oralOutgoing); + Add(timeline, block, "path:pt_oral", "energy_in", oralEnergyIn); + Add(timeline, block, "path:pt_oral", "energy_out", oralEnergyOut); + Add(timeline, block, "path:pt_oral", "passivity_ratio", oralEnergyIn <= 0.000001f ? 1 : oralEnergyOut / oralEnergyIn); + Add(timeline, block, "path:pt_nasal", "area", nasalArea); + Add(timeline, block, "branch:pt_velopharynx", "admittance", nasalAdmittance); + Add(timeline, block, "branch:pt_velopharynx", "reflection_left", newReflectionLeft); + Add(timeline, block, "branch:pt_velopharynx", "reflection_right", newReflectionRight); + Add(timeline, block, "branch:pt_velopharynx", "reflection_nose", newReflectionNose); + Add(timeline, block, "contact:pt_obstruction", "position", obstruction.index / (float)Math.Max(1, N - 1)); + Add(timeline, block, "contact:pt_obstruction", "opening", Math.Clamp(obstruction.diameter / 2.5f, 0, 1)); + Add(timeline, block, "contact:pt_obstruction", "reservoir", transients.Count); + Add(timeline, block, "contact:pt_obstruction", "released_flow", transients.Sum(transient => transient.Strength)); + Add(timeline, block, "radiation:pt_lip", "reflection", LipReflection); + Add(timeline, block, "radiation:pt_lip", "boundary_flow", lipFlow); + Add(timeline, block, "radiation:pt_lip", "flow", lipFlow); + Add(timeline, block, "radiation:pt_lip", "output", lipFlow + noseFlow); + } + public void AddTransient(int position, float strength) { transients.Add(new Transient(position, Time, 0.2f, 0.3f * strength, 200)); } + private static float WaveEnergy(float[] diameters, float[] rightWave, float[] leftWave) + { + var count = Math.Min(diameters.Length, Math.Min(rightWave.Length, leftWave.Length)); + var energy = 0f; + for (var i = 0; i < count; i++) + { + var area = Math.Max(0.000001f, diameters[i] * diameters[i]); + energy += area * (rightWave[i] * rightWave[i] + leftWave[i] * leftWave[i]); + } + + return energy; + } + + private static void Add(List timeline, int block, string primitive, string signal, float value) => + timeline.Add(new PinkTromboneReferenceTimelineSample(block, primitive, signal, value)); + private void CalculateMainTractReflections() { Span area = stackalloc float[N]; diff --git a/src/AquaSynth.Core/PrimitiveReferenceReport.cs b/src/AquaSynth.Core/PrimitiveReferenceReport.cs index 6f4a538..3267ce3 100644 --- a/src/AquaSynth.Core/PrimitiveReferenceReport.cs +++ b/src/AquaSynth.Core/PrimitiveReferenceReport.cs @@ -65,6 +65,34 @@ public static class PrimitiveReferenceReport return rows; } + public static IReadOnlyList ComparePinkTromboneTimeline( + IReadOnlyList candidate, + IReadOnlyList reference) + { + var referenceByKey = reference + .GroupBy(sample => (sample.Block, PrimitiveRole(sample.Primitive), sample.Signal)) + .ToDictionary(group => group.Key, group => group.First().Value); + var rows = new List(); + + foreach (var sample in candidate) + { + var role = PrimitiveRole(sample.Primitive); + if (role.Length == 0) + { + continue; + } + + if (!referenceByKey.TryGetValue((sample.Block, role, sample.Signal), out var expected)) + { + continue; + } + + Add(rows, role, sample.Signal, sample.Value, expected); + } + + return rows; + } + public static string ToCsv(IReadOnlyList samples) { var builder = new StringBuilder(); @@ -90,6 +118,33 @@ public static class PrimitiveReferenceReport private static void Add(List rows, string primitive, string signal, float candidate, float expected) => rows.Add(new PrimitiveReferenceSample(PinkTromboneReference, primitive, signal, candidate, expected, candidate - expected)); + private static string PrimitiveRole(string primitive) + { + if (primitive.StartsWith("path:", StringComparison.OrdinalIgnoreCase)) + { + return primitive.Contains("nasal", StringComparison.OrdinalIgnoreCase) || primitive.Contains("nose", StringComparison.OrdinalIgnoreCase) + ? "path:nasal" + : "path:oral"; + } + + if (primitive.StartsWith("branch:", StringComparison.OrdinalIgnoreCase)) + { + return "branch:velopharynx"; + } + + if (primitive.StartsWith("contact:", StringComparison.OrdinalIgnoreCase)) + { + return "contact:obstruction"; + } + + if (primitive.StartsWith("radiation:", StringComparison.OrdinalIgnoreCase)) + { + return "radiation:lip"; + } + + return ""; + } + private static string Escape(string value) => value.Contains(',') || value.Contains('"') ? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" : value; } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 912296a..bf0cc6c 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -228,3 +228,4 @@ {"date":"2026-05-29","area":"acoustic-graph","summary":"Corrected the proprioception cut line after user pushback: baby words are downstream witnesses, not the architecture owner. The live rebuild direction is primitive collapse plus instrumentation: reduce generated graph-vocal lowering to AreaFunction, WaveguidePath, ScatterJunction, SourcePort, ConstrictionContact, BranchPort, RadiationLoad, and ProbeTimeline; compare primitive/sample-flow behavior against VocalTractLab, ArtiSynth, Story/TubeTalker, Smith/Faust, and PT/SndKit before more utterance golf.","evidence":"docs/acoustic-graph-proprioception.md; docs/acoustic-graph-flow-audit.md; research/physical-voice-modeling/PEOPLE.md; research/physical-voice-modeling/talks/TALKS.md"} {"date":"2026-05-29","area":"primitive-vocal-rebuild","summary":"First teardown cut implemented. PT-shaped tract authoring now adapts into primitive vocal records and no longer emits generated area/contact/injection acoustic banks. The deleted `EnsureTractAcousticNetwork` path makes the old generated graph structurally unable to own default vocal DSP. `VocalNetwork` lowering emits compact primitive Faust locals with delay-library propagation and debug probes, and `ProbeTimelineReport` writes deterministic primitive CSV telemetry before utterance parity. PatchScriptTests pass 84/84, including optional primitive Faust validation when Faust is installed.","evidence":"src/AquaSynth.Core/Model.cs; src/AquaSynth.Core/PatchScript.cs; src/AquaSynth.Core/FaustExport.cs; src/AquaSynth.Core/ProbeTimelineReport.cs; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; docs/acoustic-graph-proprioception.md"} {"date":"2026-05-29","area":"primitive-vocal-telemetry","summary":"Primitive timelines now expose path incoming/outgoing wave variables, area-weighted energy in/out, passivity ratio, and contact resistance. Faust debug probes expose matching path wave/passivity fields. `PrimitiveReferenceReport.ComparePinkTrombone` adds a PT/SndKit-facing static comparison report for area sections/length/reflection count, delay estimate, branch admittance, contact state, and radiation fields. PatchScriptTests pass 86/86.","evidence":"src/AquaSynth.Core/FaustExport.cs; src/AquaSynth.Core/ProbeTimelineReport.cs; src/AquaSynth.Core/PrimitiveReferenceReport.cs; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; docs/acoustic-graph-proprioception.md"} +{"date":"2026-05-29","area":"primitive-vocal-reference-timeline","summary":"PT/SndKit local reference internals are now observable through `PinkTromboneReferenceRenderer.RenderTimeline`, exposing oral/nasal path waves and energy, velopharynx branch reflections/admittance, obstruction contact state, and lip radiation flow/output from the renderer's own arrays. `PrimitiveReferenceReport.ComparePinkTromboneTimeline` role-normalizes Aqua primitive timeline samples against PT timeline samples. PatchScriptTests pass 88/88.","evidence":"src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs; src/AquaSynth.Core/PrimitiveReferenceReport.cs; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; docs/acoustic-graph-proprioception.md"} diff --git a/state/memory.json b/state/memory.json index 7fc263e..0016401 100644 --- a/state/memory.json +++ b/state/memory.json @@ -121,7 +121,7 @@ }, "last_verified": { "command": "dotnet test tests\\AquaSynth.Dsl.Tests\\AquaSynth.Dsl.Tests.csproj --no-restore --disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false --filter \"FullyQualifiedName~PatchScriptTests\" -v minimal", - "result": "Primitive vocal telemetry/reference cut passes PatchScriptTests 86/86. Path timelines now include wave/passivity fields, and PT/SndKit static primitive comparison is covered. Earlier full unfiltered run hit the five-minute wall after leaving orphan dotnet workers; focused suites are the current reliable verification path.", + "result": "Primitive vocal telemetry/reference cut passes PatchScriptTests 88/88. Path timelines now include wave/passivity fields, PT/SndKit static primitive comparison is covered, and the local PT renderer exposes internal primitive timeline samples for role-normalized comparison. Earlier full unfiltered run hit the five-minute wall after leaving orphan dotnet workers; focused suites are the current reliable verification path.", "commit": "pending primitive vocal teardown" } } diff --git a/state/scratch.md b/state/scratch.md index c88b52d..3be2b31 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -34,6 +34,12 @@ Current slice: primitive geometry/delay/branch/contact/radiation fields against PT/SndKit fixture controls without requiring native reference binaries. `PatchScript` tests pass 86/86 after this cut. +- Third primitive evidence cut in progress: `PinkTromboneReferenceRenderer` now + has `RenderTimeline`, exposing PT internal timeline samples from the renderer's + own diameter/wave/reflection/transient arrays. `PrimitiveReferenceReport` + can role-normalize Aqua primitive timeline samples against PT timeline samples + for oral/nasal path, velopharynx branch, obstruction contact, and lip + radiation signals. `PatchScript` tests pass 88/88. - Proprioception correction after user rejected baby-word golf as the rebuild owner. Diagnosis is now durable in diff --git a/state/spine.yaml b/state/spine.yaml index a9d26f2..327926a 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -136,6 +136,7 @@ live_invariants: - Proprioception correction says baby words are downstream witnesses, not the rebuild owner. The live cut is to collapse generated graph-vocal lowering into a small set of instrumented physical primitives, compare primitive/sample-flow behavior against VTL, ArtiSynth, Story/TubeTalker, Smith/Faust, and PT/SndKit, then return to utterances once the tract body is observable. `docs/acoustic-graph-proprioception.md` owns this cut line. - First primitive vocal teardown cut is live. `tract`, `glottis`, `tract_injection`, and `nasal_branch` now adapt PT fixtures into `AreaFunction`, `WaveguidePath`, `SourcePort`, `ConstrictionContact`, `BranchPort`, `RadiationLoad`, `ProbeTimeline`, and `VocalNetwork` records instead of emitting generated `voices_0_area_*`, `voices_0_contact_*`, or `voices_0_inj_*` acoustic banks. The obsolete `EnsureTractAcousticNetwork` generator is deleted. Faust lowering for `Voice.VocalNetwork` emits compact primitive path/source/contact/branch/radiation locals using `de.fdelay*` delay families and debug probes under `/debug/...`; `ProbeTimelineReport` writes deterministic primitive CSV samples. Next pressure is passivity/energy timeline samples plus PT/SndKit timeline comparison, not intelligibility tuning. - Primitive path telemetry now reports `incoming_wave`, `outgoing_wave`, `energy_in`, `energy_out`, and `passivity_ratio` in both Faust debug probes and `ProbeTimelineReport`; contact telemetry includes resistance. `PrimitiveReferenceReport.ComparePinkTrombone` compares static primitive area sections, length, reflection count, delay estimate, branch admittance, contact state, and radiation fields against local PT/SndKit fixture controls without adding a native reference dependency. Next pressure is per-block comparison against PT/SndKit internals, not static control matching alone. + - `PinkTromboneReferenceRenderer.RenderTimeline` now exposes PT internal oral/nasal path, branch, contact, and radiation samples from the renderer's own diameter/wave/reflection/transient arrays. `PrimitiveReferenceReport.ComparePinkTromboneTimeline` role-normalizes Aqua primitive samples against PT timeline samples for path/branch/contact/radiation signals. This is the first local reference-internal comparison surface for the primitive rebuild; next pressure is fixture-suite artifact reporting and then tightening laws where the timeline drift points. - 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. - `FaustExportOptions.DebugProbeUi` wraps graph probe signals in Faust `vbargraph` UI controls under `/debug/...` without changing audio output arity. Native streaming Faust patches now separate writable controls from read-only probe zones and can read/snapshot probe values after `ProcessBlock`; use this for acoustic graph passivity/radiation inspection, leave it off for normal parity renders. diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index c230ac8..4646f1c 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -417,6 +417,52 @@ public sealed class PatchScriptTests Assert.Contains("pt-sndkit,area:voices_0_morphology,sections,44,44,0", csv); } + [Fact] + public void PinkTromboneReferenceRendererExposesPrimitiveTimelineFields() + { + var renderer = new PinkTromboneReferenceRenderer(); + var fixture = PinkTromboneParityFixtures.ById("nasal-vowel"); + + var timeline = renderer.RenderTimeline(fixture.Controls, durationSeconds: .04f, blockSize: 128); + + Assert.Contains(timeline, sample => sample.Primitive == "path:pt_oral" && sample.Signal == "incoming_wave"); + Assert.Contains(timeline, sample => sample.Primitive == "path:pt_oral" && sample.Signal == "outgoing_wave"); + Assert.Contains(timeline, sample => sample.Primitive == "path:pt_oral" && sample.Signal == "energy_in"); + Assert.Contains(timeline, sample => sample.Primitive == "path:pt_oral" && sample.Signal == "energy_out"); + Assert.Contains(timeline, sample => sample.Primitive == "path:pt_oral" && sample.Signal == "passivity_ratio"); + Assert.Contains(timeline, sample => sample.Primitive == "path:pt_nasal" && sample.Signal == "area"); + Assert.Contains(timeline, sample => sample.Primitive == "branch:pt_velopharynx" && sample.Signal == "admittance"); + Assert.Contains(timeline, sample => sample.Primitive == "contact:pt_obstruction" && sample.Signal == "opening"); + Assert.Contains(timeline, sample => sample.Primitive == "radiation:pt_lip" && sample.Signal == "flow"); + + var passivity = timeline + .Where(sample => sample.Primitive == "path:pt_oral" && sample.Signal == "passivity_ratio") + .Select(sample => sample.Value) + .ToArray(); + Assert.NotEmpty(passivity); + Assert.All(passivity, value => Assert.True(float.IsFinite(value), "PT timeline emitted non-finite passivity")); + } + + [Fact] + public void PrimitiveTimelineCanCompareAgainstPinkTromboneInternalTimeline() + { + var fixture = PinkTromboneParityFixtures.ById("nasal-vowel"); + var patch = PatchScript.Parse(fixture.AquaScript); + var aqua = ProbeTimelineReport.Build(patch, "voices_0_network", blocks: 1); + var pt = new PinkTromboneReferenceRenderer().RenderTimeline(fixture.Controls, durationSeconds: .02f, blockSize: 128); + + var comparison = PrimitiveReferenceReport.ComparePinkTromboneTimeline(aqua, pt); + + Assert.Contains(comparison, row => row.Primitive == "path:oral" && row.Signal == "area"); + Assert.Contains(comparison, row => row.Primitive == "path:oral" && row.Signal == "incoming_wave"); + Assert.Contains(comparison, row => row.Primitive == "path:oral" && row.Signal == "energy_in"); + Assert.Contains(comparison, row => row.Primitive == "path:nasal" && row.Signal == "area"); + Assert.Contains(comparison, row => row.Primitive == "branch:velopharynx" && row.Signal == "admittance"); + Assert.Contains(comparison, row => row.Primitive == "contact:obstruction" && row.Signal == "opening"); + Assert.Contains(comparison, row => row.Primitive == "radiation:lip" && row.Signal == "flow"); + Assert.All(comparison, row => Assert.True(float.IsFinite(row.Candidate) && float.IsFinite(row.Expected) && float.IsFinite(row.Error))); + } + [Fact] public void SyrinxVoiceUsesPairedLoadedLabialSourcesThroughOneGraph() {