From df70a4f2f3d3e488811cb1ee4d6d4b98ad0bb1cb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 26 May 2026 22:20:42 +0100 Subject: [PATCH] Port Pink Trombone reference renderer --- .gitmodules | 3 + docs/pink-trombone-parity.md | 26 +- external/pink-trombone-mod | 1 + .../PinkTromboneReferenceRenderer.cs | 1038 +++++++++++++---- state/evidence.jsonl | 1 + state/map.yaml | 2 + state/spine.yaml | 5 +- 7 files changed, 835 insertions(+), 241 deletions(-) create mode 160000 external/pink-trombone-mod diff --git a/.gitmodules b/.gitmodules index 5ec6a0b..2bd0ec4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "external/zynaddsubfx"] path = external/zynaddsubfx url = https://github.com/zynaddsubfx/zynaddsubfx +[submodule "external/pink-trombone-mod"] + path = external/pink-trombone-mod + url = https://github.com/chdh/pink-trombone-mod diff --git a/docs/pink-trombone-parity.md b/docs/pink-trombone-parity.md index 2b5e33f..c1ab166 100644 --- a/docs/pink-trombone-parity.md +++ b/docs/pink-trombone-parity.md @@ -16,6 +16,8 @@ Primary source for this packet: - `chdh/pink-trombone-mod`: MIT TypeScript modularization of Neil Thapen's Pink Trombone, +- Pinned local source: `external/pink-trombone-mod` at + `359c2d3b42b10280404c1650dc601902112b4c90` - Source files inspected: - `src/Synthesizer.ts` - `src/Glottis.ts` @@ -86,7 +88,29 @@ Current AquaSynth cannot exactly express: - fractional-delay tract propagation whose clock derives from physical length, propagation speed, and sample rate; -- exact Pink Trombone LF glottal coefficient updates. +- exact Pink Trombone block timing and source/noise/transient behavior inside + the Faust-lowered Aqua graph. + +## Reference Renderer Authority + +`PinkTromboneReferenceRenderer` is a source-port of the pinned MIT TypeScript +implementation, not a Pink-Trombone-flavored proxy. Its inner graph mirrors the +upstream DSP authorities: + +- `Synthesizer`: 512-sample blocks, tract stepping twice per output sample; +- `Glottis`: LF waveform coefficient solve, aspiration noise, vibrato/wobble, + and intensity/tenseness/frequency smoothing; +- `Tract`: 44 oral cells, 28 nasal cells, area-derived reflections, three-way + nasal junction, lip/nose radiation sum, filtered frication, and closure + transients; +- `TractShaper`: tongue/rest diameter targets, touch-style constriction + reduction, wall slew, velum slew, and obstruction release detection. + +The renderer keeps AquaSynth's fixture control record as the authoring surface, +but those controls are now translated into the source graph rather than into a +hand-rolled approximation. Reference WAVs written before +`20260526T211902375` were produced by the old approximation and are invalid for +parity golf. ## Current Parity Rung diff --git a/external/pink-trombone-mod b/external/pink-trombone-mod new file mode 160000 index 0000000..359c2d3 --- /dev/null +++ b/external/pink-trombone-mod @@ -0,0 +1 @@ +Subproject commit 359c2d3b42b10280404c1650dc601902112b4c90 diff --git a/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs b/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs index 0502680..b32422c 100644 --- a/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs +++ b/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs @@ -18,17 +18,14 @@ public sealed record PinkTromboneControlPoint( public sealed class PinkTromboneReferenceRenderer(int sampleRate = 44100) { - private const int Sections = 44; - private const int NoseSections = 28; - private const float Loss = 0.999f; private readonly int sampleRate = Math.Max(1, sampleRate); public PinkTromboneReferenceRender Render(PinkTromboneFixtureControls controls, float durationSeconds = 0.57f) { var frames = Math.Max(1, (int)MathF.Round(sampleRate * Math.Max(durationSeconds, 1f / sampleRate))); - var synth = new State(sampleRate); + var synth = new Synthesizer(sampleRate); var samples = new float[frames]; - synth.Render(samples, controls); + synth.Synthesize(samples, _ => controls); return new PinkTromboneReferenceRender(samples, sampleRate, controls); } @@ -42,13 +39,11 @@ public sealed class PinkTromboneReferenceRenderer(int sampleRate = 44100) throw new ArgumentException("Utterance render needs at least one control point.", nameof(controlPoints)); } - var ordered = controlPoints - .OrderBy(point => point.TimeSeconds) - .ToArray(); + var ordered = controlPoints.OrderBy(point => point.TimeSeconds).ToArray(); var frames = Math.Max(1, (int)MathF.Round(sampleRate * Math.Max(durationSeconds, 1f / sampleRate))); - var synth = new State(sampleRate); + var synth = new Synthesizer(sampleRate); var samples = new float[frames]; - synth.Render(samples, frame => InterpolateControls(ordered, frame / (float)sampleRate)); + synth.Synthesize(samples, frame => InterpolateControls(ordered, frame / (float)sampleRate)); return new PinkTromboneUtteranceRender(samples, sampleRate, fixtureId, ordered); } @@ -93,253 +88,60 @@ public sealed class PinkTromboneReferenceRenderer(int sampleRate = 44100) private static float Mix(float a, float b, float t) => a + (b - a) * t; - private sealed class State + private sealed class Synthesizer { - private readonly float[] right = new float[Sections]; - private readonly float[] left = new float[Sections]; - private readonly float[] junctionRight = new float[Sections]; - private readonly float[] junctionLeft = new float[Sections + 1]; - private readonly float[] reflection = new float[Sections]; - private readonly float[] diameter = new float[Sections]; - private readonly float[] targetDiameter = new float[Sections]; - private readonly float[] noseRight = new float[NoseSections]; - private readonly float[] noseLeft = new float[NoseSections]; - private readonly float[] noseJunctionRight = new float[NoseSections]; - private readonly float[] noseJunctionLeft = new float[NoseSections + 1]; - private readonly float[] noseReflection = new float[NoseSections]; - private readonly float[] noseDiameter = new float[NoseSections]; + private const int MaxBlockLength = 512; private readonly int sampleRate; - private float glottalPhase; - private float noise = 0.1234567f; - private float lastConstrictionDiameter = 1; - private float transient; + private readonly Glottis glottis; + private readonly Tract tract; + private readonly TractShaper tractShaper; private float dcBlockX; private float dcBlockY; - public State(int sampleRate) + public Synthesizer(int sampleRate) { this.sampleRate = sampleRate; - InitRest(); - UpdateNose(0.01f); + glottis = new Glottis(sampleRate); + tract = new Tract(glottis, sampleRate * 2); + tractShaper = new TractShaper(tract); + CalculateNewBlockParameters(0, new PinkTromboneFixtureControls()); } - public void Render(float[] output, PinkTromboneFixtureControls controls) + public void Synthesize(float[] buffer, Func controlsAtFrame) { - Render(output, _ => controls); - } - - public void Render(float[] output, Func controlsAtFrame) - { - for (var i = 0; i < output.Length; i++) + var p = 0; + while (p < buffer.Length) { - var controls = controlsAtFrame(i); - UpdateTargets(controls); - UpdateNose(controls.Velum); - UpdateReflection(); - UpdateTransient(controls); - SlewDiameters(); - transient *= 0.995f; - var glottal = Glottis(controls) + Rand() * transient; - var first = Step(glottal, controls); - var second = Step(glottal, controls); - output[i] = Condition((first + second) * 0.72f) * controls.Gain; + var blockLength = Math.Min(MaxBlockLength, buffer.Length - p); + SynthesizeBlock(buffer, p, blockLength, controlsAtFrame(p)); + p += blockLength; } } - private float Rand() + private void SynthesizeBlock(float[] buffer, int offset, int count, PinkTromboneFixtureControls controls) { - noise = (noise * 16807) % 2147483647; - return noise / 1073741823.5f - 1; - } - - private void InitRest() - { - for (var i = 0; i < Sections; i++) + var deltaTime = count / (float)sampleRate; + CalculateNewBlockParameters(deltaTime, controls); + for (var i = 0; i < count; i++) { - var value = RestDiameter(i); - diameter[i] = value; - targetDiameter[i] = value; + var lambda1 = i / (float)count; + var lambda2 = (i + 0.5f) / count; + var glottalOutput = glottis.Step(lambda1); + var vocalOutput1 = tract.Step(glottalOutput, lambda1, controls); + var vocalOutput2 = tract.Step(glottalOutput, lambda2, controls); + buffer[offset + i] = Condition((vocalOutput1 + vocalOutput2) * 0.125f * controls.Gain); } } - private static float RestDiameter(int index) => - index < 7 ? 0.6f : index < 10 ? 1.1f : 1.5f; - - private void UpdateTargets(PinkTromboneFixtureControls controls) + private void CalculateNewBlockParameters(float deltaTime, PinkTromboneFixtureControls controls) { - for (var i = 0; i < Sections; i++) - { - var value = RestDiameter(i); - if (i > 10 && i < 39) - { - var angle = 1.1f * MathF.PI * (controls.TongueIndex - i) / 22; - var fixedTongueDiameter = 2 + (controls.TongueDiameter - 2) / 1.5f; - var curve = (1.5f - fixedTongueDiameter + 1.7f) * MathF.Cos(angle); - if (i is 8 or 38) curve *= 0.8f; - if (i is 10 or 37) curve *= 0.94f; - value = 1.5f - curve; - } - - targetDiameter[i] = Math.Max(0, value); - } - - ApplyConstriction(controls.ConstrictionIndex, controls.ConstrictionDiameter); - targetDiameter[Sections - 1] = Math.Max(0.05f, controls.LipOpening); - } - - private void ApplyConstriction(float position, float constrictionDiameter) - { - var newDiameter = Math.Max(0, constrictionDiameter - 0.3f); - var range = position < 25 ? 10 : 5; - var lower = Math.Max(0, (int)MathF.Floor(position - range - 1)); - var upper = Math.Min(Sections - 1, (int)MathF.Ceiling(position + range + 1)); - for (var i = lower; i <= upper; i++) - { - var offset = MathF.Abs(i - position) - 0.5f; - float scale; - if (offset <= 0) scale = 0; - else if (offset > range) scale = 1; - else scale = 0.5f * (1 - MathF.Cos(MathF.PI * offset / range)); - - var difference = targetDiameter[i] - newDiameter; - if (difference > 0) targetDiameter[i] = newDiameter + difference * scale; - } - } - - private void SlewDiameters() - { - for (var i = 0; i < Sections; i++) - { - var speed = i < 17 ? 0.00035f : i < 32 ? 0.00045f : 0.0007f; - diameter[i] += Math.Clamp(targetDiameter[i] - diameter[i], -speed, speed * 2); - } - } - - private void UpdateNose(float velum) - { - for (var i = 0; i < NoseSections; i++) - { - var d = 2f * i / NoseSections; - var value = i == 0 ? velum : d < 1 ? 0.4f + 1.6f * d : 0.5f + 1.5f * (2 - d); - noseDiameter[i] = Math.Min(value, 1.9f); - } - - for (var i = 1; i < NoseSections; i++) - { - noseReflection[i] = Reflection(noseDiameter[i - 1], noseDiameter[i]); - } - } - - private void UpdateReflection() - { - for (var i = 1; i < Sections; i++) - { - reflection[i] = Reflection(diameter[i - 1], diameter[i]); - } - } - - private static float Reflection(float previousDiameter, float nextDiameter) - { - var previousArea = Math.Max(1e-6f, previousDiameter * previousDiameter); - var nextArea = Math.Max(1e-6f, nextDiameter * nextDiameter); - return (previousArea - nextArea) / (previousArea + nextArea); - } - - private float Glottis(PinkTromboneFixtureControls controls) - { - glottalPhase += controls.Frequency / sampleRate; - glottalPhase -= MathF.Floor(glottalPhase); - var phase = glottalPhase; - var tenseness = Math.Clamp(controls.Tenseness, 0, 1); - var openPhase = 0.55f + 0.32f * (1 - tenseness); - var pulse = phase < openPhase - ? MathF.Sin(MathF.PI * phase / openPhase) - : -0.28f * MathF.Sin(MathF.PI * (phase - openPhase) / (1 - openPhase)); - var harmonicBite = (0.12f + tenseness * 0.62f) * MathF.Sin(4 * MathF.PI * phase); - var aspiration = Rand() * controls.Intensity * (1 - MathF.Sqrt(tenseness)) * 0.18f; - return (pulse - harmonicBite + aspiration) * controls.Intensity * (0.45f + 0.75f * MathF.Pow(tenseness, 0.35f)); - } - - private void InjectTurbulence(PinkTromboneFixtureControls controls) - { - var thinness = Math.Clamp(8 * (0.7f - controls.ConstrictionDiameter), 0, 1); - var openness = Math.Clamp(30 * (controls.ConstrictionDiameter - 0.3f), 0, 1); - var frontLift = 0.35f + 0.65f * Math.Clamp(controls.ConstrictionIndex / Sections, 0, 1); - var amount = Rand() * controls.Turbulence * thinness * openness * controls.Intensity * frontLift * 1.8f; - var index = (int)MathF.Floor(controls.ConstrictionIndex); - var delta = controls.ConstrictionIndex - index; - if (index + 1 < Sections) - { - right[index + 1] += amount * (1 - delta) * 0.5f; - left[index + 1] += amount * (1 - delta) * 0.5f; - } - - if (index + 2 < Sections) - { - right[index + 2] += amount * delta * 0.5f; - left[index + 2] += amount * delta * 0.5f; - } - } - - private float Step(float input, PinkTromboneFixtureControls controls) - { - InjectTurbulence(controls); - junctionRight[0] = left[0] * controls.GlottalReflection + input; - junctionLeft[Sections] = right[Sections - 1] * controls.LipReflection; - - for (var i = 1; i < Sections; i++) - { - var wave = reflection[i] * (right[i - 1] + left[i]); - junctionRight[i] = right[i - 1] - wave; - junctionLeft[i] = left[i] + wave; - } - - var noseStart = Sections - NoseSections + 1; - var velumArea = Math.Max(1e-6f, controls.Velum * controls.Velum); - var leftArea = Math.Max(1e-6f, diameter[noseStart] * diameter[noseStart]); - var rightArea = Math.Max(1e-6f, diameter[noseStart + 1] * diameter[noseStart + 1]); - var sum = leftArea + rightArea + velumArea; - var reflectLeft = (2 * leftArea - sum) / sum; - var reflectRight = (2 * rightArea - sum) / sum; - var reflectNose = (2 * velumArea - sum) / sum; - junctionLeft[noseStart] = reflectLeft * right[noseStart - 1] + (1 + reflectLeft) * (noseLeft[0] + left[noseStart]); - junctionRight[noseStart] = reflectRight * left[noseStart] + (1 + reflectRight) * (right[noseStart - 1] + noseLeft[0]); - noseJunctionRight[0] = reflectNose * noseLeft[0] + (1 + reflectNose) * (left[noseStart] + right[noseStart - 1]); - - for (var i = 0; i < Sections; i++) - { - right[i] = junctionRight[i] * Loss; - left[i] = junctionLeft[i + 1] * Loss; - } - - var lipOutput = right[Sections - 1]; - noseJunctionLeft[NoseSections] = noseRight[NoseSections - 1] * controls.LipReflection; - for (var i = 1; i < NoseSections; i++) - { - var wave = noseReflection[i] * (noseRight[i - 1] + noseLeft[i]); - noseJunctionRight[i] = noseRight[i - 1] - wave; - noseJunctionLeft[i] = noseLeft[i] + wave; - } - - for (var i = 0; i < NoseSections; i++) - { - noseRight[i] = noseJunctionRight[i] * Loss; - noseLeft[i] = noseJunctionLeft[i + 1] * Loss; - } - - return lipOutput * (0.85f + controls.LipOpening * 0.28f) + - noseRight[NoseSections - 1] * Math.Clamp(controls.Velum / 0.4f, 0, 1); - } - - private void UpdateTransient(PinkTromboneFixtureControls controls) - { - var opening = controls.ConstrictionDiameter - lastConstrictionDiameter; - if (opening > 0.18f && lastConstrictionDiameter < 0.28f) - { - transient += opening * controls.Intensity * (0.18f + controls.Turbulence * 0.42f) * controls.Burst; - } - - lastConstrictionDiameter = controls.ConstrictionDiameter; + glottis.TargetFrequency = controls.Frequency; + glottis.TargetTenseness = controls.Tenseness; + glottis.TargetIntensity = controls.Intensity; + glottis.AdjustParameters(deltaTime); + tractShaper.SetTargets(controls); + tractShaper.AdjustTractShape(deltaTime); + tract.CalculateNewBlockParameters(); } private float Condition(float output) @@ -347,7 +149,767 @@ public sealed class PinkTromboneReferenceRenderer(int sampleRate = 44100) var blocked = output - dcBlockX + 0.995f * dcBlockY; dcBlockX = output; dcBlockY = blocked; - return MathF.Tanh(blocked * 1.9f); + return MathF.Tanh(blocked * 1.25f); + } + } + + private sealed class Glottis + { + private readonly int sampleRate; + private readonly FilteredNoiseSource aspirationNoiseSource; + private int sampleCount; + private float intensity; + private float loudness = 1; + private float smoothFrequency = 140; + private float timeInWaveform; + private float newTenseness = 0.6f; + private float oldTenseness = 0.6f; + private float newFrequency = 140; + private float oldFrequency = 140; + private float waveformLength; + private float alpha; + private float e0; + private float epsilon; + private float shift; + private float delta; + private float te; + private float omega; + + public float TargetTenseness { get; set; } = 0.6f; + public float TargetFrequency { get; set; } = 140; + public float TargetIntensity { get; set; } = 0.72f; + + public Glottis(int sampleRate) + { + this.sampleRate = sampleRate; + aspirationNoiseSource = new FilteredNoiseSource(500, 0.5f, sampleRate, 0x8000, 0x5EED1234u); + SetupWaveform(0); + } + + public float Step(float lambda) + { + var time = sampleCount / (float)sampleRate; + if (timeInWaveform > waveformLength) + { + timeInWaveform -= waveformLength; + SetupWaveform(lambda); + } + + var out1 = NormalizedLfWaveform(timeInWaveform / waveformLength); + var aspirationNoise = aspirationNoiseSource.Next(); + var aspiration1 = intensity * (1 - MathF.Sqrt(Math.Clamp(TargetTenseness, 0, 1))) * GetNoiseModulator() * aspirationNoise; + var aspiration2 = aspiration1 * (0.2f + 0.02f * SimplexNoise.Simplex1(time * 1.99f)); + sampleCount++; + timeInWaveform += 1f / sampleRate; + return out1 + aspiration2; + } + + public float GetNoiseModulator() + { + var voiced = 0.1f + 0.2f * Math.Max(0, MathF.Sin(MathF.PI * 2 * timeInWaveform / waveformLength)); + var amount = Math.Clamp(TargetTenseness, 0, 1) * intensity; + return amount * voiced + (1 - amount) * 0.3f; + } + + public void AdjustParameters(float deltaTime) + { + var delta = deltaTime * sampleRate / 512f; + var oldTime = sampleCount / (float)sampleRate; + var newTime = oldTime + deltaTime; + AdjustIntensity(delta); + CalculateNewFrequency(newTime, delta); + CalculateNewTenseness(newTime); + } + + private void CalculateNewFrequency(float time, float delta) + { + if (intensity == 0) + { + smoothFrequency = TargetFrequency; + } + else if (TargetFrequency > smoothFrequency) + { + smoothFrequency = Math.Min(smoothFrequency * (1 + 0.1f * delta), TargetFrequency); + } + else if (TargetFrequency < smoothFrequency) + { + smoothFrequency = Math.Max(smoothFrequency / (1 + 0.1f * delta), TargetFrequency); + } + + oldFrequency = newFrequency; + newFrequency = Math.Max(10, smoothFrequency * (1 + CalculateVibrato(time))); + } + + private void CalculateNewTenseness(float time) + { + oldTenseness = newTenseness; + newTenseness = Math.Max( + 0, + TargetTenseness + 0.1f * SimplexNoise.Simplex1(time * 0.46f) + 0.05f * SimplexNoise.Simplex1(time * 0.36f)); + if (TargetIntensity > 0) + { + newTenseness += (3 - TargetTenseness) * Math.Max(0, 1 - intensity); + } + } + + private void AdjustIntensity(float delta) + { + var target = Math.Clamp(TargetIntensity, 0, 1); + if (intensity < target) + { + intensity = Math.Min(intensity + 0.13f * delta, target); + } + else + { + intensity = Math.Max(intensity - 0.05f * delta, target); + } + } + + private static float CalculateVibrato(float time) + { + var vibrato = 0.005f * MathF.Sin(2 * MathF.PI * time * 6); + vibrato += 0.02f * SimplexNoise.Simplex1(time * 4.07f); + vibrato += 0.04f * SimplexNoise.Simplex1(time * 2.15f); + vibrato += 0.2f * SimplexNoise.Simplex1(time * 0.98f); + vibrato += 0.4f * SimplexNoise.Simplex1(time * 0.5f); + return vibrato; + } + + private void SetupWaveform(float lambda) + { + var frequency = Interpolate(oldFrequency, newFrequency, lambda); + var tenseness = Interpolate(oldTenseness, newTenseness, lambda); + waveformLength = 1 / frequency; + loudness = MathF.Pow(Math.Max(0, tenseness), 0.25f); + + var rd = Math.Clamp(3 * (1 - tenseness), 0.5f, 2.7f); + var ra = -0.01f + 0.048f * rd; + var rk = 0.224f + 0.118f * rd; + var rg = (rk / 4) * (0.5f + 1.2f * rk) / (0.11f * rd - ra * (0.5f + 1.2f * rk)); + var ta = ra; + var tp = 1 / (2 * rg); + te = tp + tp * rk; + epsilon = 1 / ta; + shift = MathF.Exp(-epsilon * (1 - te)); + delta = 1 - shift; + + var rhsIntegral = ((1 / epsilon) * (shift - 1) + (1 - te) * shift) / delta; + var totalLowerIntegral = rhsIntegral - (te - tp) / 2; + var totalUpperIntegral = -totalLowerIntegral; + omega = MathF.PI / tp; + var s = MathF.Sin(omega * te); + var y = -MathF.PI * s * totalUpperIntegral / (tp * 2); + var z = MathF.Log(y); + alpha = z / (tp / 2 - te); + e0 = -1 / (s * MathF.Exp(alpha * te)); + } + + private float NormalizedLfWaveform(float t) + { + float output; + if (t > te) + { + output = (-MathF.Exp(-epsilon * (t - te)) + shift) / delta; + } + else + { + output = e0 * MathF.Exp(alpha * t) * MathF.Sin(omega * t); + } + + return output * intensity * loudness; + } + } + + private sealed class Tract + { + public const int N = 44; + public const int BladeStart = 10; + public const int TipStart = 32; + public const int LipStart = 39; + public const int NoseLength = 28; + public const int NoseStart = N - NoseLength + 1; + + private const float Loss = 0.999f; + private const float GlottalReflection = 0.75f; + private const float LipReflection = -0.85f; + private readonly Glottis glottis; + private readonly int tractSampleRate; + private readonly FilteredNoiseSource fricationNoiseSource; + private int sampleCount; + private float reflectionLeft; + private float newReflectionLeft; + private float reflectionRight; + private float newReflectionRight; + private float reflectionNose; + private float newReflectionNose; + + public readonly float[] Diameter = new float[N]; + public readonly float[] NoseDiameter = new float[NoseLength]; + private readonly float[] right = new float[N]; + private readonly float[] left = new float[N]; + private readonly float[] reflection = new float[N]; + private readonly float[] newReflection = new float[N]; + private readonly float[] junctionOutputRight = new float[N]; + private readonly float[] junctionOutputLeft = new float[N + 1]; + private readonly float[] noseRight = new float[NoseLength]; + private readonly float[] noseLeft = new float[NoseLength]; + private readonly float[] noseJunctionOutputRight = new float[NoseLength]; + private readonly float[] noseJunctionOutputLeft = new float[NoseLength + 1]; + private readonly float[] noseReflection = new float[NoseLength]; + private readonly List transients = []; + + public float Time { get; private set; } + public TurbulencePoint TurbulencePoint { get; set; } + + public Tract(Glottis glottis, int tractSampleRate) + { + this.glottis = glottis; + this.tractSampleRate = tractSampleRate; + fricationNoiseSource = new FilteredNoiseSource(1000, 0.5f, tractSampleRate, 0x8000, 0xA11CEu); + } + + public void CalculateNoseReflections() + { + Span area = stackalloc float[NoseLength]; + for (var i = 0; i < NoseLength; i++) + { + area[i] = Math.Max(1e-6f, NoseDiameter[i] * NoseDiameter[i]); + } + + for (var i = 1; i < NoseLength; i++) + { + noseReflection[i] = (area[i - 1] - area[i]) / (area[i - 1] + area[i]); + } + } + + public void CalculateNewBlockParameters() + { + CalculateMainTractReflections(); + CalculateNoseJunctionReflections(); + } + + public float Step(float glottalOutput, float lambda, PinkTromboneFixtureControls controls) + { + ProcessTransients(); + AddTurbulenceNoise(controls); + + junctionOutputRight[0] = left[0] * GlottalReflection + glottalOutput; + junctionOutputLeft[N] = right[N - 1] * LipReflection; + + for (var i = 1; i < N; i++) + { + var r = Interpolate(reflection[i], newReflection[i], lambda); + var w = r * (right[i - 1] + left[i]); + junctionOutputRight[i] = right[i - 1] - w; + junctionOutputLeft[i] = left[i] + w; + } + + { + const int i = NoseStart; + var r = Interpolate(reflectionLeft, newReflectionLeft, lambda); + junctionOutputLeft[i] = r * right[i - 1] + (1 + r) * (noseLeft[0] + left[i]); + r = Interpolate(reflectionRight, newReflectionRight, lambda); + junctionOutputRight[i] = r * left[i] + (1 + r) * (right[i - 1] + noseLeft[0]); + r = Interpolate(reflectionNose, newReflectionNose, lambda); + noseJunctionOutputRight[0] = r * noseLeft[0] + (1 + r) * (left[i] + right[i - 1]); + } + + for (var i = 0; i < N; i++) + { + right[i] = junctionOutputRight[i] * Loss; + left[i] = junctionOutputLeft[i + 1] * Loss; + } + + var lipOutput = right[N - 1]; + noseJunctionOutputLeft[NoseLength] = noseRight[NoseLength - 1] * LipReflection; + + for (var i = 1; i < NoseLength; i++) + { + var w = noseReflection[i] * (noseRight[i - 1] + noseLeft[i]); + noseJunctionOutputRight[i] = noseRight[i - 1] - w; + noseJunctionOutputLeft[i] = noseLeft[i] + w; + } + + for (var i = 0; i < NoseLength; i++) + { + noseRight[i] = noseJunctionOutputRight[i]; + noseLeft[i] = noseJunctionOutputLeft[i + 1]; + } + + var noseOutput = noseRight[NoseLength - 1]; + sampleCount++; + Time = sampleCount / (float)tractSampleRate; + return lipOutput + noseOutput; + } + + public void AddTransient(int position, float strength) + { + transients.Add(new Transient(position, Time, 0.2f, 0.3f * strength, 200)); + } + + private void CalculateMainTractReflections() + { + Span area = stackalloc float[N]; + for (var i = 0; i < N; i++) + { + area[i] = Diameter[i] * Diameter[i]; + } + + for (var i = 1; i < N; i++) + { + reflection[i] = newReflection[i]; + var sum = area[i - 1] + area[i]; + newReflection[i] = Math.Abs(sum) > 1e-6f ? (area[i - 1] - area[i]) / sum : 1; + } + } + + private void CalculateNoseJunctionReflections() + { + reflectionLeft = newReflectionLeft; + reflectionRight = newReflectionRight; + reflectionNose = newReflectionNose; + var velumA = NoseDiameter[0] * NoseDiameter[0]; + var an0 = Diameter[NoseStart] * Diameter[NoseStart]; + var an1 = Diameter[NoseStart + 1] * Diameter[NoseStart + 1]; + var sum = an0 + an1 + velumA; + newReflectionLeft = Math.Abs(sum) > 1e-6f ? (2 * an0 - sum) / sum : 1; + newReflectionRight = Math.Abs(sum) > 1e-6f ? (2 * an1 - sum) / sum : 1; + newReflectionNose = Math.Abs(sum) > 1e-6f ? (2 * velumA - sum) / sum : 1; + } + + private void ProcessTransients() + { + for (var i = transients.Count - 1; i >= 0; i--) + { + var transient = transients[i]; + var timeAlive = Time - transient.StartTime; + if (timeAlive > transient.LifeTime) + { + transients.RemoveAt(i); + continue; + } + + var amplitude = transient.Strength * MathF.Pow(2, -transient.Exponent * timeAlive); + right[transient.Position] += amplitude / 2; + left[transient.Position] += amplitude / 2; + } + } + + private void AddTurbulenceNoise(PinkTromboneFixtureControls controls) + { + var point = TurbulencePoint; + if (point.Strength <= 0 || point.Position < 2 || point.Position > N || point.Diameter <= 0) + { + return; + } + + var intensity = Math.Clamp((Time - point.StartTime) / 0.1f, 0, 1); + if (intensity <= 0) + { + return; + } + + var turbulenceNoise = 0.66f * fricationNoiseSource.Next() * intensity * point.Strength * glottis.GetNoiseModulator(); + AddTurbulenceNoiseAtPosition(turbulenceNoise, point.Position, point.Diameter, controls); + } + + private void AddTurbulenceNoiseAtPosition( + float turbulenceNoise, + float position, + float diameter, + PinkTromboneFixtureControls controls) + { + var i = (int)MathF.Floor(position); + var delta = position - i; + var thinness0 = Math.Clamp(8 * (0.7f - diameter), 0, 1); + var openness = Math.Clamp(30 * (diameter - 0.3f), 0, 1); + var noise0 = turbulenceNoise * (1 - delta) * thinness0 * openness * controls.Turbulence; + var noise1 = turbulenceNoise * delta * thinness0 * openness * controls.Turbulence; + if (i + 1 < N) + { + right[i + 1] += noise0 / 2; + left[i + 1] += noise0 / 2; + } + + if (i + 2 < N) + { + right[i + 2] += noise1 / 2; + left[i + 2] += noise1 / 2; + } + } + } + + private sealed class TractShaper + { + private const float GridOffset = 1.7f; + private const float MovementSpeed = 15; + private const float VelumOpenTarget = 0.4f; + private const float VelumClosedTarget = 0.01f; + private readonly Tract tract; + private readonly float[] targetDiameter = new float[Tract.N]; + private int lastObstruction = -1; + private float velumTarget; + private float lastTurbulencePosition = float.NaN; + + public TractShaper(Tract tract) + { + this.tract = tract; + velumTarget = VelumOpenTarget; + ShapeNose(); + tract.CalculateNoseReflections(); + velumTarget = VelumClosedTarget; + ShapeNose(); + ShapeMainTract(); + } + + public void SetTargets(PinkTromboneFixtureControls controls) + { + for (var i = 0; i < Tract.N; i++) + { + targetDiameter[i] = GetRestDiameter(i, controls.TongueIndex, controls.TongueDiameter); + } + + ReduceTargetDiametersByTouch(controls.ConstrictionIndex, Math.Max(0, controls.ConstrictionDiameter - 0.3f)); + targetDiameter[Tract.N - 1] = Math.Min(targetDiameter[Tract.N - 1], Math.Max(0, controls.LipOpening)); + velumTarget = controls.Velum > 0.08f ? Math.Clamp(controls.Velum, VelumClosedTarget, VelumOpenTarget) : VelumClosedTarget; + if (controls.Turbulence > 0.001f) + { + if (float.IsNaN(lastTurbulencePosition) || MathF.Abs(lastTurbulencePosition - controls.ConstrictionIndex) > 0.5f) + { + lastTurbulencePosition = controls.ConstrictionIndex; + tract.TurbulencePoint = new TurbulencePoint(controls.ConstrictionIndex, controls.ConstrictionDiameter, tract.Time, controls.Turbulence); + } + else + { + var current = tract.TurbulencePoint; + tract.TurbulencePoint = current with + { + Position = controls.ConstrictionIndex, + Diameter = controls.ConstrictionDiameter, + Strength = controls.Turbulence + }; + } + } + else + { + tract.TurbulencePoint = default; + lastTurbulencePosition = float.NaN; + } + } + + public void AdjustTractShape(float deltaTime) + { + var amount = deltaTime * MovementSpeed; + var newLastObstruction = -1; + for (var i = 0; i < Tract.N; i++) + { + var diameter = tract.Diameter[i]; + if (diameter <= 0) + { + newLastObstruction = i; + } + + float slowReturn; + if (i < Tract.NoseStart) + { + slowReturn = 0.6f; + } + else if (i >= Tract.TipStart) + { + slowReturn = 1; + } + else + { + slowReturn = 0.6f + 0.4f * (i - Tract.NoseStart) / (Tract.TipStart - Tract.NoseStart); + } + + tract.Diameter[i] = MoveTowards(diameter, targetDiameter[i], slowReturn * amount, 2 * amount); + } + + if (lastObstruction > -1 && newLastObstruction == -1 && tract.NoseDiameter[0] < 0.223f) + { + tract.AddTransient(lastObstruction, 1); + } + + lastObstruction = newLastObstruction; + tract.NoseDiameter[0] = MoveTowards(tract.NoseDiameter[0], velumTarget, amount * 0.25f, amount * 0.1f); + } + + private void ShapeMainTract() + { + for (var i = 0; i < Tract.N; i++) + { + var d = GetRestDiameter(i, 12.9f, 2.43f); + tract.Diameter[i] = d; + targetDiameter[i] = d; + } + } + + private static float GetRestDiameter(int i, float tongueIndex, float tongueDiameter) + { + if (i < 7) return 0.6f; + if (i < Tract.BladeStart) return 1.1f; + if (i >= Tract.LipStart) return 1.5f; + var t = 1.1f * MathF.PI * (tongueIndex - i) / (Tract.TipStart - Tract.BladeStart); + var fixedTongueDiameter = 2 + (tongueDiameter - 2) / 1.5f; + var curve = (1.5f - fixedTongueDiameter + GridOffset) * MathF.Cos(t); + if (i == Tract.BladeStart - 2 || i == Tract.LipStart - 1) curve *= 0.8f; + if (i == Tract.BladeStart || i == Tract.LipStart - 2) curve *= 0.94f; + return 1.5f - curve; + } + + private void ReduceTargetDiametersByTouch(float index, float diameter) + { + if (index < 2 || index >= Tract.N || diameter >= 3) + { + return; + } + + float width; + if (index < 25) + { + width = 10; + } + else if (index >= Tract.TipStart) + { + width = 5; + } + else + { + width = 10 - 5 * (index - 25) / (Tract.TipStart - 25); + } + + for (var offset = -(int)MathF.Ceiling(width) - 1; offset < width + 1; offset++) + { + var p = (int)MathF.Round(index) + offset; + if (p < 0 || p >= Tract.N) continue; + var relpos = MathF.Abs(p - index) - 0.5f; + float shrink; + if (relpos <= 0) shrink = 0; + else if (relpos > width) shrink = 1; + else shrink = 0.5f * (1 - MathF.Cos(MathF.PI * relpos / width)); + if (diameter < targetDiameter[p]) + { + targetDiameter[p] = diameter + (targetDiameter[p] - diameter) * shrink; + } + } + } + + private void ShapeNose() + { + for (var i = 0; i < Tract.NoseLength; i++) + { + float diameter; + var d = 2f * i / Tract.NoseLength; + if (i == 0) diameter = velumTarget; + else if (d < 1) diameter = 0.4f + 1.6f * d; + else diameter = 0.5f + 1.5f * (2 - d); + tract.NoseDiameter[i] = Math.Min(diameter, 1.9f); + } + } + } + + private sealed class FilteredNoiseSource + { + private readonly float[] buffer; + private readonly Biquad filter; + private int index; + + public FilteredNoiseSource(float f0, float q, int sampleRate, int bufferSize, uint seed) + { + buffer = new float[bufferSize]; + var rng = new Lcg(seed); + for (var i = 0; i < buffer.Length; i++) + { + buffer[i] = 2 * rng.NextFloat() - 1; + } + + filter = Biquad.BandPass(f0, q, sampleRate); + } + + public float Next() + { + if (index >= buffer.Length) + { + index = 0; + } + + return filter.Process(buffer[index++]); + } + } + + private sealed class Biquad + { + private readonly float nb0; + private readonly float nb1; + private readonly float nb2; + private readonly float na1; + private readonly float na2; + private float x1; + private float x2; + private float y1; + private float y2; + + private Biquad(float b0, float b1, float b2, float a0, float a1, float a2) + { + nb0 = b0 / a0; + nb1 = b1 / a0; + nb2 = b2 / a0; + na1 = a1 / a0; + na2 = a2 / a0; + } + + public static Biquad BandPass(float f0, float q, int sampleRate) + { + var w0 = 2 * MathF.PI * f0 / sampleRate; + var alpha = MathF.Sin(w0) / (2 * q); + return new Biquad(alpha, 0, -alpha, 1 + alpha, -2 * MathF.Cos(w0), 1 - alpha); + } + + public float Process(float x) + { + var y = nb0 * x + nb1 * x1 + nb2 * x2 - na1 * y1 - na2 * y2; + x2 = x1; + x1 = x; + y2 = y1; + y1 = y; + return y; + } + } + + private struct Lcg(uint state) + { + private uint state = state == 0 ? 1u : state; + + public float NextFloat() + { + state = unchecked(state * 1664525u + 1013904223u); + return (state >> 8) / 16777216f; + } + } + + private readonly record struct Transient(int Position, float StartTime, float LifeTime, float Strength, float Exponent); + + private readonly record struct TurbulencePoint(float Position, float Diameter, float StartTime, float Strength); + + private static float Interpolate(float v1, float v2, float position) => v1 * (1 - position) + v2 * position; + + private static float MoveTowards(float current, float target, float amountUp, float amountDown) => + current < target ? Math.Min(current + amountUp, target) : Math.Max(current - amountDown, target); + + private static class SimplexNoise + { + private sealed record Grad(float X, float Y, float Z) + { + public float Dot2(float x, float y) => X * x + Y * y; + } + + private static readonly Grad[] Grad3 = + [ + new(1, 1, 0), new(-1, 1, 0), new(1, -1, 0), new(-1, -1, 0), + new(1, 0, 1), new(-1, 0, 1), new(1, 0, -1), new(-1, 0, -1), + new(0, 1, 1), new(0, -1, 1), new(0, 1, -1), new(0, -1, -1) + ]; + + private static readonly int[] P = + [ + 151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142, + 8,99,37,240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117, + 35,11,32,57,177,33,88,237,149,56,87,174,20,125,136,171,168,68,175,74,165,71, + 134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41, + 55,46,245,40,244,102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89, + 18,169,200,196,135,130,116,188,159,86,164,100,109,198,173,186,3,64,52,217,226, + 250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182, + 189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,167,43, + 172,9,129,22,39,253,19,98,108,110,79,113,224,232,178,185,112,104,218,246,97, + 228,251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,14,239, + 107,49,192,214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,150,254, + 138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180 + ]; + + private static readonly int[] Perm = new int[512]; + private static readonly Grad[] GradP = new Grad[512]; + + static SimplexNoise() + { + SetSeed(0); + } + + public static float Simplex1(float x) => Simplex2(x * 1.2f, -x * 0.7f); + + private static void SetSeed(int seed0) + { + var seed = seed0; + if (seed < 256) seed |= seed << 8; + for (var i = 0; i < 256; i++) + { + var v = (i & 1) != 0 ? P[i] ^ (seed & 255) : P[i] ^ ((seed >> 8) & 255); + Perm[i] = Perm[i + 256] = v; + GradP[i] = GradP[i + 256] = Grad3[v % 12]; + } + } + + private static float Simplex2(float xin, float yin) + { + const float f2 = 0.3660254037844386f; + const float g2 = 0.21132486540518713f; + var s = (xin + yin) * f2; + var i = (int)MathF.Floor(xin + s); + var j = (int)MathF.Floor(yin + s); + var t = (i + j) * g2; + var x0 = xin - i + t; + var y0 = yin - j + t; + int i1; + int j1; + if (x0 > y0) + { + i1 = 1; + j1 = 0; + } + else + { + i1 = 0; + j1 = 1; + } + + var x1 = x0 - i1 + g2; + var y1 = y0 - j1 + g2; + var x2 = x0 - 1 + 2 * g2; + var y2 = y0 - 1 + 2 * g2; + i &= 255; + j &= 255; + var gi0 = GradP[i + Perm[j]]; + var gi1 = GradP[i + i1 + Perm[j + j1]]; + var gi2 = GradP[i + 1 + Perm[j + 1]]; + + float n0; + float n1; + float n2; + var t0 = 0.5f - x0 * x0 - y0 * y0; + if (t0 < 0) n0 = 0; + else + { + t0 *= t0; + n0 = t0 * t0 * gi0.Dot2(x0, y0); + } + + var t1 = 0.5f - x1 * x1 - y1 * y1; + if (t1 < 0) n1 = 0; + else + { + t1 *= t1; + n1 = t1 * t1 * gi1.Dot2(x1, y1); + } + + var t2 = 0.5f - x2 * x2 - y2 * y2; + if (t2 < 0) n2 = 0; + else + { + t2 *= t2; + n2 = t2 * t2 * gi2.Dot2(x2, y2); + } + + return 70 * (n0 + n1 + n2); } } } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index b0c8fad..5780b92 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -159,3 +159,4 @@ {"timestamp":"2026-05-26T21:07:36+01:00","topic":"vocal-tract-playground-actual-graph-audition","claim":"The vocal tract playground no longer owns a browser-native source/filter audio witness. Audition audio is now rendered from the actual Aqua DSL tract graph path by posting controls to a .NET renderer that emits tract propagation=graph, lowers through FaustEmitter, renders with FaustCompiler, writes a WAV, and serves it back to the browser.","evidence":"Built tools/TractGraphRenderer successfully with disabled build servers. Direct renderer smoke wrote artifacts/vocal-tract-playground/smoke.wav with peak 0.15414488, RMS 0.066947475, and no warnings. Live POST http://127.0.0.1:5126/render returned a rendered WAV and /renders/... served HTTP 200. node --check passed for playground app.js and serve.mjs."} {"timestamp":"2026-05-26T21:38:50+01:00","topic":"pink-trombone-graph-nasal-level-balance","claim":"The graph tract nasal path no longer reuses velum as both branch valve and nostril aperture. Generated tract graphs now square velum at the nasal branch entrance, keep nostril radiation independent, and use a lower graph radiation gain so corrected topology does not drive the patch into soft clipping. Playground Ma is now level-matched with Open instead of overdriven.","evidence":"PT log-mel graph run passed with open cosine 0.6219 RMS 0.9743, front 0.6407 RMS 1.0024, nasal 0.5375 RMS 1.1476, new bilabial-nasal-ma 0.4743 RMS 1.2206, sibilant 0.3233 RMS 1.0008, closure 0.0817 RMS 1.5534. Live renderer smoke after lowering patch gain reported Open peak/RMS 0.498/0.257 and Ma 0.480/0.243."} {"timestamp":"2026-05-26T21:52:30+01:00","topic":"pink-trombone-time-varying-utterance-reference-fixtures","claim":"AquaSynth now has explicit time-varying Pink Trombone reference utterance fixtures for mama, papa, lulek, and thrombosis. The PT renderer can interpolate timestamped control points sample-by-sample and write reference WAVs plus control CSVs before Aqua parity golf begins.","evidence":"Focused test PinkTromboneUtteranceFixturesWriteReferenceWavs passed and wrote artifacts/parity/pink-trombone-utterances/20260526T205230009. PT reference summaries: mama peak/RMS 0.7033/0.4213, papa 0.7012/0.3618, lulek 0.7200/0.4087, thrombosis 0.7600/0.4658."} +{"evidence":"Pinned external/pink-trombone-mod at 359c2d3b42b10280404c1650dc601902112b4c90; focused dotnet test passed and wrote artifacts/parity/pink-trombone-utterances/20260526T211902375 with mama/papa/lulek/thrombosis reference WAVs.","claim":"The previous Pink Trombone reference renderer was invalid as a parity oracle because it hand-rolled PT-shaped DSP instead of porting the source graph. The renderer now ports the pinned MIT chdh/pink-trombone-mod DSP source: LF glottis, filtered noise, 512-sample block updates, 2x tract stepping, 44 oral/28 nasal waveguide cells, nasal junction, target-diameter slew, touch-style constriction, frication, and obstruction release transients. Earlier utterance WAVs are invalid for parity golf.","timestamp":"2026-05-26T22:20:18+01:00","topic":"pink-trombone-reference-renderer-authority"} diff --git a/state/map.yaml b/state/map.yaml index e8ae358..3bda345 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -25,6 +25,7 @@ surfaces: dx7_public_domain_fixtures: tests/AquaSynth.Dsl.Tests/Fixtures/Dx7/PublicDomain zyn_reference_source: external/zynaddsubfx zyn_upstream_instrument_bank: external/zynaddsubfx/instruments + pink_trombone_reference_source: external/pink-trombone-mod zyn_reference_build_script: tools/zyn-reference/build-zyn-reference.ps1 zyn_pad_reference_renderer: tools/zyn-reference/ZynPadReference.cpp zyn_voice_reference_renderer: tools/zyn-reference/ZynVoiceReference.cpp @@ -121,6 +122,7 @@ checks: faust_validation: automatic in tests when Faust is installed dexed_py_reference: optional in tests when AQUASYNTH_DX7_PYTHON or a Python with dexed-py is available zyn_reference_source: pinned submodule used only for parity tests; never package content + pink_trombone_reference_source: pinned MIT submodule used only for parity renderer source audits and port verification; never package content espeak_ng_reference: optional command-line development reference, found through ESPEAK_NG or PATH; never package content zyn_upstream_instrument_bank: nested GPL submodule used only for test/development pressure surveys and oracle renders; never package content diff --git a/state/spine.yaml b/state/spine.yaml index f0330a1..16fdb8b 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -1,6 +1,6 @@ project: AquaSynth spine_version: 1 -updated: 2026-05-26T19:10:00+01:00 +updated: 2026-05-26T22:20:00+01:00 prime_directive: > Build coherent synth machinery. References exist to reveal missing structure, @@ -30,6 +30,7 @@ current_mechanism: dx7_reference_fixtures: tests/AquaSynth.Dsl.Tests/Fixtures/Dx7/PublicDomain dx7_reference_renderer_tests: tests/AquaSynth.Dsl.Tests/Dx7ReferenceParityTests.cs zyn_reference_source: external/zynaddsubfx + pink_trombone_reference_source: external/pink-trombone-mod zyn_upstream_instrument_bank: external/zynaddsubfx/instruments zyn_reference_build_script: tools/zyn-reference/build-zyn-reference.ps1 zyn_pad_reference_renderer: tools/zyn-reference/ZynPadReference.cpp @@ -119,7 +120,7 @@ live_invariants: - 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 source/radiation/side-branch/frication cuts: graph open/front cosine is 0.6202/0.6417, still slightly above the legacy waveguide lane at 0.6143/0.6349; nasal graph cosine improved to 0.5159 and RMS ratio dropped from 1.6914 to 1.4815; sibilant graph cosine remains weak at 0.2302; closure-release graph cosine stays positive at 0.1145 and beats the old negative waveguide baseline. The useful changes were LF-style glottal shaping with a modest harmonic pressure blend, graph radiation high-pass leakage by radiation kind, aperture-weighted high-passed `TurbulenceJet` source semantics, terminal-specific admittance in connection pressure, floored area-ratio pressure normalization for branch outputs, and generated frication ports using max(turbulence, burst) as pressure instead of burst alone. Raw per-port outgoing coupling, squared generated velum opening, lip/beak radiation gain, and un-gated turbulence noise were cut as compensators. Remaining pressure is oral RMS/energy normalization and better frication/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. + - 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` now ports the pinned MIT `chdh/pink-trombone-mod` DSP source (`external/pink-trombone-mod` at 359c2d3b42b10280404c1650dc601902112b4c90), and `AudioComparison` includes log-mel cosine similarity. Reference WAVs before `artifacts/parity/pink-trombone-utterances/20260526T211902375` came from the old fake renderer and are invalid. Do not resume Aqua log-mel golf until the source-ported PT utterance WAVs have been listened to and accepted as a sane oracle. Remaining low-level Aqua 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. - Do not expand into prosody, emotional context, personality, or broad Weksa expressivity until the tiny IPA utterance harness can show useful loss movement.