From 22ddc788899df7fbab5673298d208e0d7a6287ab Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 26 May 2026 21:53:24 +0100 Subject: [PATCH] Add Pink Trombone utterance reference fixtures --- .../PinkTromboneReferenceRenderer.cs | 86 +++++- .../PinkTromboneUtteranceFixtures.cs | 262 ++++++++++++++++++ state/evidence.jsonl | 1 + state/map.yaml | 2 + state/scratch.md | 22 ++ .../PinkTromboneUtteranceReferenceTests.cs | 137 +++++++++ 6 files changed, 506 insertions(+), 4 deletions(-) create mode 100644 src/AquaSynth.Core/PinkTromboneUtteranceFixtures.cs create mode 100644 tests/AquaSynth.Dsl.Tests/PinkTromboneUtteranceReferenceTests.cs diff --git a/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs b/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs index 9620421..0502680 100644 --- a/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs +++ b/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs @@ -5,6 +5,17 @@ public sealed record PinkTromboneReferenceRender( int SampleRate, PinkTromboneFixtureControls Controls); +public sealed record PinkTromboneUtteranceRender( + float[] Samples, + int SampleRate, + string FixtureId, + IReadOnlyList 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 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 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 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; diff --git a/src/AquaSynth.Core/PinkTromboneUtteranceFixtures.cs b/src/AquaSynth.Core/PinkTromboneUtteranceFixtures.cs new file mode 100644 index 0000000..3afa588 --- /dev/null +++ b/src/AquaSynth.Core/PinkTromboneUtteranceFixtures.cs @@ -0,0 +1,262 @@ +namespace AquaSynth.Dsl; + +public sealed record PinkTromboneUtteranceFixture( + string Id, + string Text, + float DurationSeconds, + IReadOnlyList ControlPoints, + IReadOnlyList 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 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 Points( + params (float time, PinkTromboneFixtureControls controls, string label)[] points) => + points.Select(point => new PinkTromboneControlPoint(point.time, point.controls, point.label)).ToArray(); +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index a2ac062..b0c8fad 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/map.yaml b/state/map.yaml index 8aad11b..e8ae358 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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 diff --git a/state/scratch.md b/state/scratch.md index 6bbcb9e..83f3884 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -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///`. +- 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 diff --git a/tests/AquaSynth.Dsl.Tests/PinkTromboneUtteranceReferenceTests.cs b/tests/AquaSynth.Dsl.Tests/PinkTromboneUtteranceReferenceTests.cs new file mode 100644 index 0000000..3aa8607 --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/PinkTromboneUtteranceReferenceTests.cs @@ -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 + { + "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 + { + $"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 + { + "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 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)); + } + } +}