From 830da882cca586c71a6d64e88907a88099440377 Mon Sep 17 00:00:00 2001 From: Aqua Date: Wed, 11 Nov 2026 12:00:04 +0000 Subject: [PATCH] PackageBoundaryTests: stop the pack hang (build servers hold the output pipes), bound every wait; trigger+repeat composition tests Co-Authored-By: Claude Sonnet 5.5 --- .../PackageBoundaryTests.cs | 33 +++++- .../TriggerRepeatCompositionTests.cs | 106 ++++++++++++++++++ 2 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 tests/AquaSynth.Dsl.Tests/TriggerRepeatCompositionTests.cs diff --git a/tests/AquaSynth.Dsl.Tests/PackageBoundaryTests.cs b/tests/AquaSynth.Dsl.Tests/PackageBoundaryTests.cs index ae12828..387cd94 100644 --- a/tests/AquaSynth.Dsl.Tests/PackageBoundaryTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PackageBoundaryTests.cs @@ -15,7 +15,7 @@ public sealed class PackageBoundaryTests { var result = await RunAsync( "dotnet", - ["pack", Path.Combine(root, "src", "AquaSynth.Core", "AquaSynth.Core.csproj"), "-c", "Release", "--no-restore", "-o", outputDir]); + ["pack", Path.Combine(root, "src", "AquaSynth.Core", "AquaSynth.Core.csproj"), "-c", "Release", "--no-restore", "-o", outputDir, .. QuietBuildServers]); Assert.Equal(0, result.ExitCode); var package = Assert.Single(Directory.GetFiles(outputDir, "AquaSynth.Core.*.nupkg")); @@ -44,7 +44,7 @@ public sealed class PackageBoundaryTests { var result = await RunAsync( "dotnet", - ["pack", Path.Combine(root, "src", "AquaSynth.Faust", "AquaSynth.Faust.csproj"), "-c", "Release", "--no-restore", "-o", outputDir]); + ["pack", Path.Combine(root, "src", "AquaSynth.Faust", "AquaSynth.Faust.csproj"), "-c", "Release", "--no-restore", "-o", outputDir, .. QuietBuildServers]); Assert.Equal(0, result.ExitCode); var package = Assert.Single(Directory.GetFiles(outputDir, "AquaSynth.Faust.*.nupkg")); @@ -97,6 +97,13 @@ public sealed class PackageBoundaryTests return directory?.FullName ?? throw new InvalidOperationException("could not find repository root"); } + // `dotnet pack` leaves VBCSCompiler and MSBuild node-reuse processes running. They inherit our redirected stdout/stderr pipes, so + // ReadToEndAsync never sees EOF and the test hangs after pack has already written the nupkg (the 2026-11-11 hang). Pack without + // them, and bound every wait so a regression fails with a message instead of hanging. + private static readonly string[] QuietBuildServers = ["-p:UseSharedCompilation=false", "-nodeReuse:false", "--disable-build-servers"]; + private static readonly TimeSpan ProcessTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan PipeDrainGrace = TimeSpan.FromSeconds(10); + private static async Task<(int ExitCode, string Stdout, string Stderr)> RunAsync( string fileName, IReadOnlyList arguments) @@ -108,12 +115,30 @@ public sealed class PackageBoundaryTests UseShellExecute = false }; foreach (var argument in arguments) start.ArgumentList.Add(argument); + start.Environment["MSBUILDDISABLENODEREUSE"] = "1"; + start.Environment["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0"; using var process = Process.Start(start) ?? throw new InvalidOperationException($"failed to start `{fileName}`"); var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync(); - await process.WaitForExitAsync(); + using var timeout = new CancellationTokenSource(ProcessTimeout); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + throw new TimeoutException($"`{fileName} {string.Join(' ', arguments)}` did not exit within {ProcessTimeout}."); + } + + var drained = Task.WhenAll(stdout, stderr); + if (await Task.WhenAny(drained, Task.Delay(PipeDrainGrace)) != drained) + { + throw new TimeoutException( + $"`{fileName}` exited but its output pipes stayed open for {PipeDrainGrace}: a child it spawned (build server?) inherited them."); + } + return (process.ExitCode, await stdout, await stderr); } } - diff --git a/tests/AquaSynth.Dsl.Tests/TriggerRepeatCompositionTests.cs b/tests/AquaSynth.Dsl.Tests/TriggerRepeatCompositionTests.cs new file mode 100644 index 0000000..27d1384 --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/TriggerRepeatCompositionTests.cs @@ -0,0 +1,106 @@ +using AquaSynth.Dsl; +using AquaSynth.Faust; + +namespace AquaSynth.Dsl.Tests; + +// A consumer writes `trigger` on a voice (envelope restarts on a grid) and `patch repeat` (sfxr pitch/arpeggio restart). These render +// the composition and assert what a listener-side measurement sees: the trigger restarts the envelope, the repeat restarts the pitch +// slide inside each trigger window and never cuts the envelope. Sound is not judged here; only the structure of the render. +[Collection("NativeFaust")] +public sealed class TriggerRepeatCompositionTests +{ + private const int Rate = 48000; + + private const string Voice = """ + voice + wave=sine + freq=200 + trigger=0.5 + attack=0.001 + sustain=0.12 + decay=0.1 + pitch_ramp=2.0 + """; + + private static float[]? Render(string script, float seconds) + { + var source = FaustEmitter.Emit(PatchScript.Parse(script), new FaustExportOptions("trigger_repeat", SampleRate: Rate)).Source; + using var compiler = new AquaSynthPatchCompiler(new AquaSynthNativeOptions(SampleRate: Rate)); + if (!compiler.TryCompileSource(new AquaSynthCompileIdentity("trigger_repeat", "trigger_repeat", ""), source, seconds, out var patch, out var error)) + { + if (error?.Contains("Faust toolchain not found", StringComparison.OrdinalIgnoreCase) == true || + error?.Contains("Faust DLL not found", StringComparison.OrdinalIgnoreCase) == true) + { + return null; + } + + Assert.Fail($"compile failed: {error}"); + } + + using (patch) return patch!.Render(seconds); + } + + private static double Rms(float[] x, double from, double to) + { + var a = (int)(from * Rate); + var b = Math.Min(x.Length, (int)(to * Rate)); + double sum = 0; + for (var i = a; i < b; i++) sum += (double)x[i] * x[i]; + return Math.Sqrt(sum / Math.Max(1, b - a)); + } + + // Zero-crossing rate over a window, in Hz: a pitch measure that needs no search range. + private static double CrossingHz(float[] x, double from, double to) + { + var a = (int)(from * Rate); + var b = Math.Min(x.Length, (int)(to * Rate)); + var crossings = 0; + for (var i = a + 1; i < b; i++) if ((x[i - 1] < 0) != (x[i] < 0)) crossings++; + return crossings / 2.0 / ((b - a) / (double)Rate); + } + + [Fact] + public void TriggerRestartsTheEnvelopeWhileRepeatInsideTheWindowDoesNot() + { + // repeat 0.05 is shorter than sustain + decay (0.22): the envelope must still run to its release inside each trigger window. + var x = Render("patch repeat=0.05\n" + Voice, 1.1f); + if (x is null) return; + + Assert.True(Rms(x, 0.00, 0.10) > 0.1, "first hit sounds"); + Assert.True(Rms(x, 0.35, 0.48) < 0.01, "envelope released before the next trigger; repeat did not hold it open"); + Assert.True(Rms(x, 0.50, 0.60) > 0.1, "trigger restarts the envelope"); + Assert.True(Rms(x, 0.85, 0.98) < 0.01, "second window releases too"); + Assert.True(Rms(x, 1.00, 1.10) > 0.1, "third trigger"); + } + + [Fact] + public void RepeatRestartsThePitchSlideInsideEachTriggerWindowAndTriggerRestartsItToo() + { + var withRepeat = Render("patch repeat=0.05\n" + Voice, 0.6f); + var without = Render(Voice, 0.6f); + if (withRepeat is null || without is null) return; + + // Without repeat the slide keeps climbing across the window; with repeat the pitch falls back at each 0.05 s boundary. + var early = CrossingHz(without, 0.00, 0.04); + var late = CrossingHz(without, 0.12, 0.16); + Assert.True(late > early * 1.15, $"control: slide climbs without repeat ({early:F0} -> {late:F0} Hz)"); + + var repeatedEarly = CrossingHz(withRepeat, 0.00, 0.04); + var repeatedLate = CrossingHz(withRepeat, 0.10, 0.14); + Assert.InRange(repeatedLate / repeatedEarly, 0.9, 1.1); + + // The trigger at 0.5 restarts age; the repeat grid inside the new window starts from the trigger, not from absolute time. + var afterTrigger = CrossingHz(withRepeat, 0.50, 0.54); + Assert.InRange(afterTrigger / repeatedEarly, 0.9, 1.1); + } + + [Fact] + public void TriggerAndRepeatLowerToDistinctAges() + { + var source = FaustEmitter.EmitScript("patch repeat=0.05\n" + Voice).Source; + + Assert.Contains("voice_0_age = wrap01(age / 0.5) * 0.5;", source); + Assert.Contains("voice_0_pitch_age = voice_0_age - floor(voice_0_age / repeat_interval) * repeat_interval;", source); + Assert.Contains("oneshot_adsr_at(voice_0_age,", source); + } +} -- 2.47.3