Add Pink Trombone utterance reference fixtures

This commit is contained in:
Meta Vens 2026-05-26 21:53:24 +01:00
commit 22ddc78889
6 changed files with 506 additions and 4 deletions

View file

@ -5,6 +5,17 @@ public sealed record PinkTromboneReferenceRender(
int SampleRate,
PinkTromboneFixtureControls Controls);
public sealed record PinkTromboneUtteranceRender(
float[] Samples,
int SampleRate,
string FixtureId,
IReadOnlyList<PinkTromboneControlPoint> ControlPoints);
public sealed record PinkTromboneControlPoint(
float TimeSeconds,
PinkTromboneFixtureControls Controls,
string Label = "");
public sealed class PinkTromboneReferenceRenderer(int sampleRate = 44100)
{
private const int Sections = 44;
@ -21,6 +32,67 @@ public sealed class PinkTromboneReferenceRenderer(int sampleRate = 44100)
return new PinkTromboneReferenceRender(samples, sampleRate, controls);
}
public PinkTromboneUtteranceRender RenderUtterance(
string fixtureId,
IReadOnlyList<PinkTromboneControlPoint> controlPoints,
float durationSeconds)
{
if (controlPoints.Count == 0)
{
throw new ArgumentException("Utterance render needs at least one control point.", nameof(controlPoints));
}
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 samples = new float[frames];
synth.Render(samples, frame => InterpolateControls(ordered, frame / (float)sampleRate));
return new PinkTromboneUtteranceRender(samples, sampleRate, fixtureId, ordered);
}
private static PinkTromboneFixtureControls InterpolateControls(IReadOnlyList<PinkTromboneControlPoint> points, float timeSeconds)
{
if (timeSeconds <= points[0].TimeSeconds) return points[0].Controls;
for (var i = 0; i < points.Count - 1; i++)
{
var current = points[i];
var next = points[i + 1];
if (timeSeconds > next.TimeSeconds) continue;
var span = Math.Max(0.0001f, next.TimeSeconds - current.TimeSeconds);
var t = Smooth01((timeSeconds - current.TimeSeconds) / span);
return Lerp(current.Controls, next.Controls, t);
}
return points[^1].Controls;
}
private static float Smooth01(float value)
{
var t = Math.Clamp(value, 0, 1);
return t * t * (3 - 2 * t);
}
private static PinkTromboneFixtureControls Lerp(PinkTromboneFixtureControls a, PinkTromboneFixtureControls b, float t) =>
new(
Frequency: Mix(a.Frequency, b.Frequency, t),
Intensity: Mix(a.Intensity, b.Intensity, t),
Tenseness: Mix(a.Tenseness, b.Tenseness, t),
TongueIndex: Mix(a.TongueIndex, b.TongueIndex, t),
TongueDiameter: Mix(a.TongueDiameter, b.TongueDiameter, t),
ConstrictionIndex: Mix(a.ConstrictionIndex, b.ConstrictionIndex, t),
ConstrictionDiameter: Mix(a.ConstrictionDiameter, b.ConstrictionDiameter, t),
Turbulence: Mix(a.Turbulence, b.Turbulence, t),
Velum: Mix(a.Velum, b.Velum, t),
LipOpening: Mix(a.LipOpening, b.LipOpening, t),
GlottalReflection: Mix(a.GlottalReflection, b.GlottalReflection, t),
LipReflection: Mix(a.LipReflection, b.LipReflection, t),
Gain: Mix(a.Gain, b.Gain, t),
Burst: Mix(a.Burst, b.Burst, t));
private static float Mix(float a, float b, float t) => a + (b - a) * t;
private sealed class State
{
private readonly float[] right = new float[Sections];
@ -53,12 +125,18 @@ public sealed class PinkTromboneReferenceRenderer(int sampleRate = 44100)
public void Render(float[] output, PinkTromboneFixtureControls controls)
{
UpdateTargets(controls);
UpdateNose(controls.Velum);
UpdateReflection();
UpdateTransient(controls);
Render(output, _ => controls);
}
public void Render(float[] output, Func<int, PinkTromboneFixtureControls> controlsAtFrame)
{
for (var i = 0; i < output.Length; i++)
{
var controls = controlsAtFrame(i);
UpdateTargets(controls);
UpdateNose(controls.Velum);
UpdateReflection();
UpdateTransient(controls);
SlewDiameters();
transient *= 0.995f;
var glottal = Glottis(controls) + Rand() * transient;

View file

@ -0,0 +1,262 @@
namespace AquaSynth.Dsl;
public sealed record PinkTromboneUtteranceFixture(
string Id,
string Text,
float DurationSeconds,
IReadOnlyList<PinkTromboneControlPoint> ControlPoints,
IReadOnlyList<string> IntendedPhones);
public static class PinkTromboneUtteranceFixtures
{
private static readonly PinkTromboneFixtureControls A = new(
Frequency: 138,
Intensity: 0.72f,
Tenseness: 0.56f,
TongueIndex: 13,
TongueDiameter: 2.7f,
ConstrictionIndex: 32,
ConstrictionDiameter: 1.45f,
Turbulence: 0.03f,
Velum: 0.01f,
LipOpening: 1.7f,
Gain: 0.72f);
private static readonly PinkTromboneFixtureControls U = A with
{
Frequency = 128,
TongueIndex = 12,
TongueDiameter = 2.9f,
ConstrictionIndex = 38,
ConstrictionDiameter = 1.45f,
LipOpening = 0.55f,
LipReflection = -0.9f
};
private static readonly PinkTromboneFixtureControls E = A with
{
Frequency = 150,
Tenseness = 0.64f,
TongueIndex = 27,
TongueDiameter = 1.05f,
ConstrictionIndex = 34,
ConstrictionDiameter = 1.18f,
LipOpening = 1.08f
};
private static readonly PinkTromboneFixtureControls I = E with
{
Frequency = 156,
TongueIndex = 29,
TongueDiameter = 0.88f,
LipOpening = 0.95f
};
private static readonly PinkTromboneFixtureControls O = A with
{
Frequency = 132,
TongueIndex = 12.5f,
TongueDiameter = 2.65f,
ConstrictionIndex = 37,
ConstrictionDiameter = 1.35f,
LipOpening = 0.72f,
LipReflection = -0.9f
};
private static readonly PinkTromboneFixtureControls M = A with
{
Frequency = 132,
Intensity = 0.66f,
Tenseness = 0.52f,
TongueIndex = 14,
TongueDiameter = 2.2f,
ConstrictionIndex = 41,
ConstrictionDiameter = 0.04f,
Turbulence = 0.03f,
Velum = 0.34f,
LipOpening = 0.35f,
GlottalReflection = 0.78f,
LipReflection = -0.84f
};
private static readonly PinkTromboneFixtureControls PClosure = A with
{
Frequency = 126,
Intensity = 0.04f,
Tenseness = 0.22f,
ConstrictionIndex = 41,
ConstrictionDiameter = 0.02f,
Turbulence = 0.08f,
Velum = 0.01f,
LipOpening = 0.35f,
Burst = 0.9f,
Gain = 0.7f
};
private static readonly PinkTromboneFixtureControls PRelease = PClosure with
{
Intensity = 0.28f,
Tenseness = 0.36f,
ConstrictionDiameter = 0.52f,
Turbulence = 0.75f,
Burst = 1.0f
};
private static readonly PinkTromboneFixtureControls L = E with
{
Frequency = 144,
TongueIndex = 28,
TongueDiameter = 1.2f,
ConstrictionIndex = 30,
ConstrictionDiameter = 0.85f,
Turbulence = 0.02f,
LipOpening = 1.25f
};
private static readonly PinkTromboneFixtureControls KClosure = A with
{
Frequency = 130,
Intensity = 0.08f,
Tenseness = 0.26f,
TongueIndex = 19,
TongueDiameter = 1.1f,
ConstrictionIndex = 18,
ConstrictionDiameter = 0.02f,
Turbulence = 0.1f,
Burst = 0.9f
};
private static readonly PinkTromboneFixtureControls KRelease = KClosure with
{
Intensity = 0.26f,
ConstrictionDiameter = 0.62f,
Turbulence = 0.58f,
Burst = 1.0f
};
private static readonly PinkTromboneFixtureControls Th = A with
{
Frequency = 118,
Intensity = 0.18f,
Tenseness = 0.2f,
TongueIndex = 31,
TongueDiameter = 0.72f,
ConstrictionIndex = 32,
ConstrictionDiameter = 0.32f,
Turbulence = 0.86f,
LipOpening = 1.05f
};
private static readonly PinkTromboneFixtureControls R = O with
{
Frequency = 136,
TongueIndex = 22,
TongueDiameter = 1.0f,
ConstrictionIndex = 24,
ConstrictionDiameter = 0.95f,
Turbulence = 0.02f,
LipOpening = 0.9f
};
private static readonly PinkTromboneFixtureControls BClosure = M with
{
Velum = 0.01f,
Intensity = 0.2f,
Burst = 0.85f
};
private static readonly PinkTromboneFixtureControls BRelease = BClosure with
{
Intensity = 0.58f,
ConstrictionDiameter = 0.62f,
Turbulence = 0.35f,
Burst = 0.9f
};
private static readonly PinkTromboneFixtureControls S = I with
{
Frequency = 130,
Intensity = 0.42f,
Tenseness = 0.22f,
ConstrictionIndex = 34,
ConstrictionDiameter = 0.32f,
Turbulence = 0.98f,
LipOpening = 1.15f,
Gain = 0.76f
};
public static IReadOnlyList<PinkTromboneUtteranceFixture> All { get; } =
[
new(
"mama",
"mama",
1.05f,
Points(
(0.00f, M, "m"),
(0.15f, M, "m hold"),
(0.24f, A, "a"),
(0.46f, A, "a hold"),
(0.54f, M, "m"),
(0.68f, M, "m hold"),
(0.77f, A with { Frequency = 142 }, "a"),
(1.05f, A with { Intensity = 0.48f }, "fade")),
["m", "a", "m", "a"]),
new(
"papa",
"papa",
1.02f,
Points(
(0.00f, PClosure, "p closure"),
(0.12f, PClosure, "p pressure"),
(0.16f, PRelease, "p release"),
(0.25f, A, "a"),
(0.45f, A, "a hold"),
(0.52f, PClosure, "p closure"),
(0.64f, PRelease, "p release"),
(0.73f, A with { Frequency = 142 }, "a"),
(1.02f, A with { Intensity = 0.46f }, "fade")),
["p", "a", "p", "a"]),
new(
"lulek",
"lulek",
1.22f,
Points(
(0.00f, L, "l"),
(0.13f, U, "u"),
(0.39f, U, "u hold"),
(0.48f, L with { Frequency = 148 }, "l"),
(0.61f, E, "e"),
(0.86f, E, "e hold"),
(0.95f, KClosure, "k closure"),
(1.07f, KRelease, "k release"),
(1.22f, KRelease with { Intensity = 0.02f, Turbulence = 0.12f }, "stop")),
["l", "u", "l", "e", "k"]),
new(
"thrombosis",
"thrombosis",
1.72f,
Points(
(0.00f, Th, "th"),
(0.17f, Th, "th hold"),
(0.25f, R, "r"),
(0.39f, O, "o"),
(0.59f, O, "o hold"),
(0.68f, M, "m"),
(0.82f, M, "m hold"),
(0.90f, BClosure, "b closure"),
(0.98f, BRelease, "b release"),
(1.07f, O with { Frequency = 140 }, "o"),
(1.25f, S, "s"),
(1.42f, I, "i"),
(1.55f, S with { Intensity = 0.32f }, "s"),
(1.72f, S with { Intensity = 0.04f, Turbulence = 0.2f }, "fade")),
["th", "r", "o", "m", "b", "o", "s", "i", "s"])
];
public static PinkTromboneUtteranceFixture ById(string id) =>
All.First(fixture => fixture.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
private static IReadOnlyList<PinkTromboneControlPoint> Points(
params (float time, PinkTromboneFixtureControls controls, string label)[] points) =>
points.Select(point => new PinkTromboneControlPoint(point.time, point.controls, point.label)).ToArray();
}

View file

@ -158,3 +158,4 @@
{"timestamp":"2026-05-26T21:35:00+01:00","topic":"physical-voice-frication-source-pressure","claim":"Generated graph `TurbulenceJet` ports now treat sustained turbulence and burst as separate pressure authorities: continuous frication uses max(turbulence, burst) for source pressure instead of being throttled by burst alone, while turbulence still gates noise amount. Fully ungated noise was tested and cut because it hurt closure-release.","evidence":"Optional PT log-mel after pressure fix: sibilant graph cosine 0.2302 / RMS 1.1387 and closure graph cosine 0.1145 / RMS 1.6627. Setting noise to 1 barely changed sibilant (0.2303) and worsened closure (0.1113 / RMS 1.8777), so that part was reverted."}
{"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."}

View file

@ -49,7 +49,9 @@ modules:
tract_shape_model: src/AquaSynth.Core/Model.cs
tract_waveguide_lowering: src/AquaSynth.Core/FaustExport.cs
pink_trombone_parity_fixtures: src/AquaSynth.Core/PinkTromboneParityFixtures.cs
pink_trombone_utterance_fixtures: src/AquaSynth.Core/PinkTromboneUtteranceFixtures.cs
pink_trombone_logmel_parity_tests: tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs
pink_trombone_utterance_reference_tests: tests/AquaSynth.Dsl.Tests/PinkTromboneUtteranceReferenceTests.cs
faust_emitter: src/AquaSynth.Core/FaustExport.cs
faust_compiler: src/AquaSynth.Faust/FaustCompiler.cs
native_faust_runtime: src/AquaSynth.Faust/NativeFaustRuntime.cs

View file

@ -2,6 +2,28 @@
Current slice:
- Added time-varying Pink Trombone utterance reference fixtures before any Aqua
utterance metric golf. `PinkTromboneReferenceRenderer.RenderUtterance`
interpolates timestamped `PinkTromboneControlPoint` curves sample by sample,
preserving the existing PT-style waveguide state.
- New fixture set: `mama`, `papa`, `lulek`, and `thrombosis`. Each fixture owns
explicit control points for PT frequency, intensity, tenseness, tongue
index/diameter, constriction index/diameter, turbulence, velum, lip opening,
reflections, gain, and burst. These are first-pass pronunciation sketches,
not accepted references until user listening approves them.
- `PinkTromboneUtteranceReferenceTests.PinkTromboneUtteranceFixturesWriteReferenceWavs`
writes PT reference WAVs, `controls.csv`, and `summary.txt` under
`artifacts/parity/pink-trombone-utterances/<timestamp>/<word>/`.
- Latest artifact set:
`artifacts/parity/pink-trombone-utterances/20260526T205230009`.
Reported PT reference peaks/RMS:
`mama` `0.7033/0.4213`;
`papa` `0.7012/0.3618`;
`lulek` `0.7200/0.4087`;
`thrombosis` `0.7600/0.4658`.
Do not golf Aqua against these until the user has judged whether the PT
reference words are recognizable enough to serve as targets.
- Golfed the actual graph tract path against the PT log-mel harness after the
playground `Ma` preset sounded like amplified buzzing. The failure was not a
playground-only gain problem: generated graph topology reused velum as both

View file

@ -0,0 +1,137 @@
using System.Globalization;
using System.Text;
namespace AquaSynth.Dsl.Tests;
public sealed class PinkTromboneUtteranceReferenceTests
{
[Fact]
public void PinkTromboneUtteranceFixturesWriteReferenceWavs()
{
var renderer = new PinkTromboneReferenceRenderer();
var artifactDir = ArtifactPath("parity", "pink-trombone-utterances", DateTimeOffset.UtcNow.ToString("yyyyMMddTHHmmssfff", CultureInfo.InvariantCulture));
Directory.CreateDirectory(artifactDir);
var summary = new List<string>
{
"Pink Trombone utterance reference fixtures",
"These are PT-rendered control-curve sketches for listening before Aqua parity golf.",
""
};
foreach (var fixture in PinkTromboneUtteranceFixtures.All)
{
var render = renderer.RenderUtterance(fixture.Id, fixture.ControlPoints, fixture.DurationSeconds);
var fixtureDir = Path.Combine(artifactDir, fixture.Id);
Directory.CreateDirectory(fixtureDir);
WriteWav(Path.Combine(fixtureDir, "reference-pink-trombone.wav"), render.Samples, render.SampleRate);
File.WriteAllText(Path.Combine(fixtureDir, "controls.csv"), ControlCsv(fixture));
File.WriteAllText(Path.Combine(fixtureDir, "summary.txt"), FixtureSummary(fixture, render));
var analysis = AudioAnalyzer.AnalyzeAudio(render.Samples, new AudioAnalysisConfig(SampleRate: render.SampleRate));
Assert.True(render.Samples.Length > render.SampleRate * 0.5f, fixture.Id);
Assert.True(analysis.Features.Peak > 0.001f, fixture.Id);
Assert.True(analysis.Features.Rms > 0.0001f, fixture.Id);
summary.Add($"{fixture.Id}: `{fixture.Text}` duration={fixture.DurationSeconds:0.000}s peak={analysis.Features.Peak:0.0000} rms={analysis.Features.Rms:0.0000}");
}
File.WriteAllText(Path.Combine(artifactDir, "summary.txt"), string.Join(Environment.NewLine, summary));
}
private static string FixtureSummary(PinkTromboneUtteranceFixture fixture, PinkTromboneUtteranceRender render)
{
var lines = new List<string>
{
$"id: {fixture.Id}",
$"text: {fixture.Text}",
$"duration_seconds: {fixture.DurationSeconds.ToString("0.000", CultureInfo.InvariantCulture)}",
$"sample_rate: {render.SampleRate}",
$"samples: {render.Samples.Length}",
$"intended_phones: {string.Join(" ", fixture.IntendedPhones)}",
"",
"control_points:",
};
lines.AddRange(fixture.ControlPoints.Select(point => $" {point.TimeSeconds:0.000}s {point.Label}: {ControlLine(point.Controls)}"));
return string.Join(Environment.NewLine, lines);
}
private static string ControlCsv(PinkTromboneUtteranceFixture fixture)
{
var lines = new List<string>
{
"time,label,frequency,intensity,tenseness,tongue_index,tongue_diameter,constriction_index,constriction_diameter,turbulence,velum,lip_opening,glottal_reflection,lip_reflection,gain,burst"
};
lines.AddRange(fixture.ControlPoints.Select(point =>
string.Join(",",
F(point.TimeSeconds),
Escape(point.Label),
F(point.Controls.Frequency),
F(point.Controls.Intensity),
F(point.Controls.Tenseness),
F(point.Controls.TongueIndex),
F(point.Controls.TongueDiameter),
F(point.Controls.ConstrictionIndex),
F(point.Controls.ConstrictionDiameter),
F(point.Controls.Turbulence),
F(point.Controls.Velum),
F(point.Controls.LipOpening),
F(point.Controls.GlottalReflection),
F(point.Controls.LipReflection),
F(point.Controls.Gain),
F(point.Controls.Burst))));
return string.Join(Environment.NewLine, lines);
}
private static string ControlLine(PinkTromboneFixtureControls controls) =>
$"freq={F(controls.Frequency)} intensity={F(controls.Intensity)} tense={F(controls.Tenseness)} tongue={F(controls.TongueIndex)}/{F(controls.TongueDiameter)} constriction={F(controls.ConstrictionIndex)}/{F(controls.ConstrictionDiameter)} turbulence={F(controls.Turbulence)} velum={F(controls.Velum)} lip={F(controls.LipOpening)}";
private static string Escape(string value) =>
value.Contains(',') || value.Contains('"') ? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" : value;
private static string F(float value) =>
value.ToString("0.######", CultureInfo.InvariantCulture);
private static string ArtifactPath(params string[] parts)
{
var root = RepositoryRoot();
return Path.Combine([root, "artifacts", .. parts]);
}
private static string RepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AquaSynth.sln")))
{
directory = directory.Parent;
}
return directory?.FullName ?? AppContext.BaseDirectory;
}
private static void WriteWav(string path, IReadOnlyList<float> samples, int sampleRate)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var stream = File.Create(path);
using var writer = new BinaryWriter(stream, Encoding.ASCII);
var dataSize = samples.Count * sizeof(short);
writer.Write("RIFF"u8);
writer.Write(36 + dataSize);
writer.Write("WAVE"u8);
writer.Write("fmt "u8);
writer.Write(16);
writer.Write((short)1);
writer.Write((short)1);
writer.Write(sampleRate);
writer.Write(sampleRate * sizeof(short));
writer.Write((short)sizeof(short));
writer.Write((short)16);
writer.Write("data"u8);
writer.Write(dataSize);
foreach (var sample in samples)
{
writer.Write((short)Math.Clamp(MathF.Round(sample * short.MaxValue), short.MinValue, short.MaxValue));
}
}
}