Clean release snapshot

This commit is contained in:
Byron Gamatos
2026-06-16 18:48:12 +02:00
commit bd603184d5
291 changed files with 47318 additions and 0 deletions
+413
View File
@@ -0,0 +1,413 @@
// audio_channel_midi_test — exercise pushInputBlock / popInputBlock + the
// global midiOverflows counter without spawning a subprocess.
//
// Closes the v2/v3 review-thread concern that the inline-MIDI path had no
// automated coverage (the existing GR6 smoke driver only pushes empty
// MidiBuffers). Both ends of an AudioChannel are opened in the same
// process — createHostSide on one instance, openSandboxSide on a second
// instance using the same Names — so we don't need a real spawn.
//
// Win32-only for the same reason AudioChannel.cpp is.
#include <juce_audio_basics/juce_audio_basics.h>
#include <juce_core/juce_core.h>
#include "../../src/audio/Sandbox/Protocol.h"
#include "../../src/audio/Sandbox/AudioChannel.h"
#include <atomic>
#include <cstdio>
#include <thread>
using namespace slopsmith::sandbox;
namespace {
int g_failed = 0;
int g_passed = 0;
void check(bool cond, const char* what, const char* file, int line)
{
if (cond) { ++g_passed; return; }
++g_failed;
std::fprintf(stderr, " FAIL: %s (%s:%d)\n", what, file, line);
}
#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__)
// REQUIRE = fatal CHECK: bails the current test on failure so a busted
// setup precondition (e.g., HeaderPeek failing to open the mapping) doesn't
// cascade into a NULL deref + a barrage of misleading follow-on failures.
// Use for everything that subsequent test lines dereference / depend on.
#define REQUIRE(cond) \
do { if (!(cond)) { check(false, #cond, __FILE__, __LINE__); return; } } while (0)
// Helper: open a fresh host+sandbox AudioChannel pair with a given dims, run
// a callback against both ends, then tear down. The pair is unique per call
// (suffix-randomised mapping name) so concurrent test runs don't collide.
struct ChannelPair
{
AudioChannel host;
AudioChannel sandbox;
AudioChannel::Names names;
AudioDimensions dims;
juce::String err;
bool ok = false;
explicit ChannelPair(const AudioDimensions& d) : dims(d)
{
ok = host.createHostSide(dims, names, err);
if (!ok)
{
std::fprintf(stderr, " ChannelPair: createHostSide failed: %s\n",
err.toRawUTF8());
return;
}
ok = sandbox.openSandboxSide(names, err);
if (!ok)
{
std::fprintf(stderr, " ChannelPair: openSandboxSide failed: %s\n",
err.toRawUTF8());
// host's named mapping + events are released by AudioChannel's
// destructor when this ChannelPair goes out of scope (sandbox
// first, then host, per reverse-declaration-order rules).
// Names are randomised per ChannelPair so an aborted construct
// doesn't leak into a subsequent test in the same run.
}
}
};
void testRoundtripSmallBuffer()
{
std::printf("test: roundtrip small MidiBuffer (count, frames, bytes)\n");
AudioDimensions dims; // defaults: 4 blocks × 1024 samples × 2 ch
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
// 3 events at distinct frames — Note On, CC, Note Off.
midi.addEvent(juce::MidiMessage::noteOn(1, 60, (juce::uint8)100), 0);
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, 64), 64);
midi.addEvent(juce::MidiMessage::noteOff(1, 60), 200);
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, /*timeoutMs*/ 1000));
int n = 0;
int frames[3] = {-1, -1, -1};
juce::uint8 firstByte[3] = {0, 0, 0};
for (const auto& meta : drained)
{
if (n < 3) { frames[n] = meta.samplePosition;
firstByte[n] = meta.getMessage().getRawData()[0]; }
++n;
}
CHECK(n == 3);
CHECK(frames[0] == 0);
CHECK(frames[1] == 64);
CHECK(frames[2] == 200);
// Note On status nibble = 0x90, CC = 0xB0, Note Off = 0x80.
CHECK((firstByte[0] & 0xF0) == 0x90);
CHECK((firstByte[1] & 0xF0) == 0xB0);
CHECK((firstByte[2] & 0xF0) == 0x80);
// No overflows expected on the happy path.
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore);
}
void testSysExBumpsOverflow()
{
std::printf("test: SysEx-sized event drops + bumps midiOverflows\n");
AudioDimensions dims;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
// SysEx — JUCE wraps the payload with F0/F7 framing, so a 3-byte
// payload becomes a 5-byte raw message (> kMidiEventMaxBytes = 4),
// which pushInputBlock should drop and bump midiOverflows.
const juce::uint8 sysexPayload[] = { 0x7E, 0x7F, 0x06 };
midi.addEvent(juce::MidiMessage::createSysExMessage(sysexPayload, 3), 32);
// Plus a normal CC event at frame 100 — should round-trip.
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, 64), 100);
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, 1000));
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
CHECK(n == 1); // SysEx dropped, CC survives.
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore + 1);
}
void testOverCapBumpsOverflow()
{
std::printf("test: events past kMidiEventsPerSlot drop + bump overflows\n");
AudioDimensions dims;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
// Push kMidiEventsPerSlot + 8 events — the trailing 8 should be dropped.
constexpr int kExtra = 8;
const int total = (int)kMidiEventsPerSlot + kExtra;
for (int i = 0; i < total; ++i)
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, i & 0x7F), i % 256);
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, 1000));
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
CHECK(n == (int)kMidiEventsPerSlot);
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore + (uint64_t)kExtra);
}
void testFramePastSamplesDropped()
{
std::printf("test: events past block samples drop + bump overflows\n");
AudioDimensions dims;
dims.maxBlockSamples = 128;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 128);
srcAudio.clear();
juce::MidiBuffer midi;
// Caller passes numSamples=128 (within cap). Events at frames >= 128
// should DROP rather than clamp into the audible portion (which would
// silently re-time them, the worse failure mode).
midi.addEvent(juce::MidiMessage::noteOn(1, 60, (juce::uint8)100), 50); // in-range
midi.addEvent(juce::MidiMessage::noteOn(1, 61, (juce::uint8)100), 127); // last in-range frame
midi.addEvent(juce::MidiMessage::noteOn(1, 62, (juce::uint8)100), 128); // out-of-range (= samples)
midi.addEvent(juce::MidiMessage::noteOn(1, 63, (juce::uint8)100), 200); // out-of-range
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 128));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 128);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 128, 1000));
int n = 0;
int lastFrame = -1;
for (const auto& meta : drained) { ++n; lastFrame = meta.samplePosition; }
CHECK(n == 2); // events at 50 and 127
CHECK(lastFrame == 127); // 128 and 200 dropped, NOT clamped to 127
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore + 2);
}
void testNumSamplesOverCapRejected()
{
std::printf("test: numSamples > maxSamples rejected up front\n");
AudioDimensions dims;
dims.maxBlockSamples = 128;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
midi.addEvent(juce::MidiMessage::noteOn(1, 60, (juce::uint8)100), 50);
// Caller passes numSamples=256 but spawn cap is 128. Old behavior was
// silently truncate audio + drop MIDI in [128, 256). New behavior:
// return false up front so the misuse is visible to the caller. No
// shm counter is bumped (caller misuse is a distinct class from
// real-dropout / ring-full, and dropouts/xruns are reserved for
// those — see the comment in pushInputBlock).
CHECK(! pair.host.pushInputBlock(srcAudio, midi, 256));
}
void testSlotReuseAcrossWraparound()
{
// Push/pop more blocks than the ring has slots so each slot is used
// multiple times. Catches a regression in the "count is always
// overwritten on push" invariant — if pushInputBlock ever skipped the
// count store on a slot whose prior cycle had MIDI events, the next
// pop would replay those stale events against the fresh audio.
std::printf("test: slot reuse across ring wrap-around (no MIDI leakage)\n");
AudioDimensions dims;
// Pin maxBlocks explicitly: the modulus-coprime reasoning below depends
// on it. If AudioDimensions{}'s default ever changes, this test would
// silently stop exercising the slot-reuse-with-different-counts property.
constexpr uint32_t kRingSize = 4;
dims.maxBlocks = kRingSize;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
// Run enough cycles for every slot to be reused multiple times.
// 3*maxBlocks + 2 = 14 cycles with maxBlocks=4 means each slot is hit
// 3 or 4 times.
const int kCycles = 3 * (int)dims.maxBlocks + 2;
// Vary the MIDI count per block so a leaked stale count from a prior
// cycle on the SAME slot would show up as a wrong-count assertion.
// Modulus must be COPRIME with maxBlocks (4) — using `i % 4` would
// make each slot see the same count on every wrap (defeating the
// test). 5 is coprime with 4: slot 0 across cycles 0/4/8/12 sees
// counts 0/4/3/2, so a stale count from the prior visit would mismatch.
constexpr int kEventCountModulus = 5;
// Real coprimality check (not just oddness — those happen to coincide for
// kRingSize=4 because 4 = 2², but a future bump to e.g. 6 would let
// odd-but-not-coprime values like 9 silently slip through and defeat the
// stale-count detection).
constexpr auto gcd = [](int a, int b)
{
while (b != 0) { a %= b; auto t = a; a = b; b = t; }
return a;
};
static_assert(gcd((int)kRingSize, kEventCountModulus) == 1,
"kEventCountModulus must stay coprime with kRingSize — "
"otherwise each ring slot sees the same MIDI-event count "
"on every wrap and the stale-count regression test "
"becomes trivially-passing.");
for (int i = 0; i < kCycles; ++i)
{
juce::MidiBuffer midi;
const int eventCount = i % kEventCountModulus; // 0, 1, 2, 3, 4, 0, 1, ...
for (int e = 0; e < eventCount; ++e)
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, e * 16),
e * 32);
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, 1000));
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
CHECK(n == eventCount);
}
}
void testThreadedProducerConsumer()
{
// Cross-thread loopback: a producer thread pushes ordered blocks while a
// consumer thread drains them, both blocking on the real doorbell
// (Win32 auto-reset events / POSIX socketpair). This is the case the
// single-threaded tests above can't cover — the producer/consumer
// happens-before edge runs through the shared atomic write index plus the
// doorbell wake, and is what ThreadSanitizer actually inspects. Each block
// carries a unique audio marker + a varying MIDI count so a torn handoff,
// a dropped/duplicated block, or stale-slot MIDI would surface as a
// mismatch rather than passing silently.
std::printf("test: threaded producer/consumer over the doorbell\n");
AudioDimensions dims; // 4 blocks × 1024 samples × 2 ch
ChannelPair pair{dims};
REQUIRE(pair.ok);
constexpr int kBlocks = 4000;
const int samples = 256;
std::atomic<bool> producerOk{true};
std::atomic<int> mismatches{0};
std::thread producer([&]
{
juce::AudioBuffer<float> src((int)dims.maxChannels, samples);
for (int i = 0; i < kBlocks; ++i)
{
// Unique per-block marker in sample 0 of every channel.
src.clear();
for (int ch = 0; ch < (int)dims.maxChannels; ++ch)
src.setSample(ch, 0, (float)i);
juce::MidiBuffer midi;
const int eventCount = i % 7; // 0..6 events, < kMidiEventsPerSlot
for (int e = 0; e < eventCount; ++e)
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, e & 0x7F),
e); // frames 0..5 < samples
// The host audio thread would drop on a full ring (xrun); this
// test wants lossless ordering, so spin-retry until the consumer
// frees a slot. yield() keeps it from starving the consumer.
int spins = 0;
while (!pair.host.pushInputBlock(src, midi, samples))
{
std::this_thread::yield();
if (++spins > 50'000'000) { producerOk.store(false); return; }
}
}
});
juce::AudioBuffer<float> dst((int)dims.maxChannels, samples);
for (int i = 0; i < kBlocks; ++i)
{
juce::MidiBuffer drained;
// popInputBlock returns false on a coalesced / spurious doorbell wake
// (it rechecks the ring index, finds nothing new yet, and returns) —
// that is NOT a lost block, just "try again", exactly as the real
// runAudioThread loops. Retry until the real block arrives; the
// doorbell byte is sticky (socket-buffered) so there is no lost-wakeup
// window. A genuine stall (producer died) trips the bounded retry cap.
bool got = false;
for (int tries = 0; tries < 2'000'000 && !got; ++tries)
{
drained.clear();
got = pair.sandbox.popInputBlock(dst, drained, samples, 5000);
if (!got) std::this_thread::yield();
}
if (!got) { ++mismatches; break; }
if (dst.getSample(0, 0) != (float)i) ++mismatches; // ordering / torn handoff
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
if (n != i % 7) ++mismatches; // stale-slot MIDI
}
producer.join();
CHECK(producerOk.load());
CHECK(mismatches.load() == 0);
// xruns are EXPECTED here: the spin-retry producer deliberately hammers a
// full ring (the real host audio thread would drop instead), so xruns
// climbing just means back-pressure worked — not asserted. What matters is
// that every block arrived exactly once, in order, with its MIDI intact.
}
} // namespace
int main()
{
std::printf("=== audio_channel_midi_test ===\n");
testRoundtripSmallBuffer();
testSysExBumpsOverflow();
testOverCapBumpsOverflow();
testFramePastSamplesDropped();
testNumSamplesOverCapRejected();
testSlotReuseAcrossWraparound();
testThreadedProducerConsumer();
std::printf("\n%d passed, %d failed\n", g_passed, g_failed);
return g_failed == 0 ? 0 : 1;
}