#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "audio/Audio.h" #include "audio/AudioZone.h" #include "audio/Source.h" #include "action/ActionAudioBlock.h" #include "action/ActionAudioWaveform.h" #include "action/ActionBlockRuntime.h" #include "action/ActionNotifyRegistry.h" #include "action/ActionPreview.h" #include "action/ActionParameterCurve.h" #include "common/Capability.h" #include "audio/UnderwaterAudio.h" #include "common/Exception.h" #include "data/ByteData.h" #include "filesystem/FileData.h" #include "filesystem/Filesystem.h" #include "sound/Decoder.h" #include "sound/Sound.h" #include "sound/SoundData.h" #include "scene/NodeDesc.h" #include "scene/Scene.h" #include "scene/SceneObject.h" #include #include #include #include #include #include #include #include #include #include namespace { #include "PathBesideSource.h" EVE_DEFINE_PATH_BESIDE_SOURCE() std::vector readBinaryFile(const std::string &path) { std::ifstream in(path, std::ios::binary); if (!in) return {}; return std::vector((std::istreambuf_iterator(in)), std::istreambuf_iterator()); } class TestAudioAttachmentSource final : public eve::IAttachmentPointSource { public: eve::Result sampleAttachmentPoint( std::string_view name, eve::AttachmentPoint localOffset) const override { ++calls; if (name != "hand_r") return eve::Result::failure( eve::Diagnostic::error(eve::DiagnosticCode::NotFound, "missing test bone", "bone")); return eve::Result::success( {20.f + localOffset.x, 30.f + localOffset.y, 40.f + localOffset.z}); } mutable int calls = 0; }; } // namespace static eve::LogicalId actionLogicalId(std::string_view value) { auto parsed = eve::LogicalId::parse(value); REQUIRE(parsed.has_value()); return std::move(*parsed); } static eve::audio::Audio *tryCreateAudio() { try { return eve::audio::Audio::create(); } catch (const eve::Exception &) { std::cerr << "N/A: OpenAL device not available\n"; return nullptr; } } static std::vector makeSilentWav(int samples = 441) { const int dataBytes = samples * 2; std::vector buf(44 + dataBytes, 0); auto w32 = [&](int off, uint32_t v) { std::memcpy(buf.data() + off, &v, 4); }; auto w16 = [&](int off, uint16_t v) { std::memcpy(buf.data() + off, &v, 2); }; std::memcpy(buf.data(), "RIFF", 4); w32(4, 36 + dataBytes); std::memcpy(buf.data() + 8, "WAVEfmt ", 8); w32(16, 16); w16(20, 1); w16(22, 1); w32(24, 44100); w32(28, 44100 * 2); w16(32, 2); w16(34, 16); std::memcpy(buf.data() + 36, "data", 4); w32(40, dataBytes); return buf; } TEST_CASE("audio.device") { auto *audio = tryCreateAudio(); if (!audio) return; audio->setVolume(0.5f); CHECK(audio->getVolume() == 0.5f); audio->setPosition(1.f, 2.f, 3.f); audio->pump(); } TEST_CASE("audio.staticSource.playStop") { auto *audio = tryCreateAudio(); if (!audio) return; auto *sound = eve::sound::Sound::create(); auto wav = makeSilentWav(4410); eve::data::ByteData data(wav.data(), wav.size()); auto *sd = sound->newSoundData(&data); auto *src = audio->newSource(sd); REQUIRE(src != nullptr); src->setVolume(0.2f); src->setPitch(1.0f); src->setLooping(false); src->play(); CHECK(src->isPlaying()); src->stop(); CHECK(!src->isPlaying()); delete src; delete sd; } TEST_CASE("audio.actionBlockProviderOwnsRealSourceEnterExit") { auto* audio = tryCreateAudio(); if (!audio) return; auto* filesystem = eve::filesystem::Filesystem::create(); REQUIRE(filesystem->mountRealDirectory(EVENGINE_SOURCE_DIR, "/", false)); auto* scene = eve::scene::Scene::create(); auto mounted = scene->mountAs("action-audio", eve::scene::node("source").withPosition(8.f, 9.f, 10.f)); REQUIRE(mounted.ok()); auto source = eve::scene::SceneObject::createObject("action-audio", "source"); REQUIRE(source.ok()); auto registry = eve::action::ActionNotifyRegistry::withBuiltins(); REQUIRE(registry.ok()); eve::action::ActionBlockRuntime runtime(registry.value()); eve::action::ActionAdvance advance; advance.id = eve::action::ActionExecutionId{92}; eve::Value::Object payload{{"uri", "test/fixtures/resource_formats/tone.wav"}, {"bone", "hand_r"}, {"randomUris", eve::Value::Array{"test/fixtures/resource_formats/tone.wav"}}, {"volume", 0.25}, {"pitch", 1.0}, {"randomPitchOffset", 0.05}, {"spatialBlend", 1.0}, {"minDistance", 2.0}, {"maxDistance", 30.0}, {"looping", true}}; advance.timelineEvents.push_back({eve::action::ActionTimelineEventKind::StateEnter, actionLogicalId("presentation-track:audio"), actionLogicalId("presentation-audio:loop"), actionLogicalId("presentation:audio-state"), eve::Duration::zero(), payload}); advance.activeBlocks.push_back({actionLogicalId("presentation-track:audio"), actionLogicalId("presentation-audio:loop"), actionLogicalId("presentation:audio-state"), eve::Duration::zero(), eve::Duration::fromNanoseconds(100), payload}); eve::action::ActionNotifyContext context; context.executionId = advance.id; context.source = ecs::handle_of(source.value()); TestAudioAttachmentSource attachmentSource; context.sourceAttachment = std::cref(attachmentSource); REQUIRE(runtime.apply(advance, context).ok()); CHECK_EQ(attachmentSource.calls, 2); const int sourceCount = audio->getSourceCount(); REQUIRE(runtime.interrupt(context).ok()); CHECK_EQ(audio->getSourceCount(), sourceCount - 1); } TEST_CASE("audio.instantActionNotifyRetainsThenDeterministicallyReleasesSource") { auto* audio = tryCreateAudio(); if (!audio) return; auto* filesystem = eve::filesystem::Filesystem::create(); REQUIRE(filesystem->mountRealDirectory(EVENGINE_SOURCE_DIR, "/", false)); auto registry = eve::action::ActionNotifyRegistry::withBuiltins(); REQUIRE(registry.ok()); eve::action::ActionBlockRuntime runtime(registry.value()); const int before = audio->getSourceCount(); eve::action::ActionAdvance trigger; trigger.id = eve::action::ActionExecutionId{93}; trigger.timelineEvents.push_back({eve::action::ActionTimelineEventKind::Notify, actionLogicalId("presentation-track:audio"), actionLogicalId("presentation-audio:hit"), actionLogicalId("presentation:audio"), eve::Duration::zero(), {{"uri", "test/fixtures/resource_formats/tone.wav"}, {"volume", 0.5}}}); eve::action::ActionNotifyContext context; context.executionId = trigger.id; REQUIRE(runtime.apply(trigger, context).ok()); CHECK_EQ(audio->getSourceCount(), before + 1); eve::action::ActionAdvance expired; expired.id = trigger.id; expired.totalElapsed = eve::Duration::fromNanoseconds(10'000'000'000LL); REQUIRE(runtime.apply(expired, context).ok()); CHECK_EQ(audio->getSourceCount(), before); } TEST_CASE("audio.actionStateFadeOutUsesInjectedActionTimeAndReleasesSource") { auto* audio = tryCreateAudio(); if (!audio) return; auto* filesystem = eve::filesystem::Filesystem::create(); REQUIRE(filesystem->mountRealDirectory(EVENGINE_SOURCE_DIR, "/", false)); auto registry = eve::action::ActionNotifyRegistry::withBuiltins(); REQUIRE(registry.ok()); eve::action::ActionBlockRuntime runtime(registry.value()); const int before = audio->getSourceCount(); const auto execution = eve::action::ActionExecutionId{94}; const auto track = actionLogicalId("presentation-track:audio"); const auto item = actionLogicalId("presentation-audio:fade"); const auto type = actionLogicalId("presentation:audio-state"); eve::Value::Object payload{{"uri", "test/fixtures/resource_formats/tone.wav"}, {"volume", 0.8}, {"looping", true}, {"fadeOutOnExit", true}, {"fadeOutDuration", 0.2}}; eve::action::ActionNotifyContext context; context.executionId = execution; eve::action::ActionAdvance entered; entered.id = execution; entered.timelineEvents.push_back({eve::action::ActionTimelineEventKind::StateEnter, track, item, type, eve::Duration::zero(), payload}); entered.activeBlocks.push_back({track, item, type, eve::Duration::zero(), eve::Duration::fromSeconds(1.0).takeValue(), payload}); REQUIRE(runtime.apply(entered, context).ok()); CHECK_EQ(audio->getSourceCount(), before + 1); eve::action::ActionAdvance exited; exited.id = execution; exited.totalElapsed = eve::Duration::fromSeconds(1.0).takeValue(); exited.timelineEvents.push_back({eve::action::ActionTimelineEventKind::StateExit, track, item, type, exited.totalElapsed, payload}); REQUIRE(runtime.apply(exited, context).ok()); CHECK_EQ(audio->getSourceCount(), before + 1); exited.timelineEvents.clear(); exited.totalElapsed = eve::Duration::fromSeconds(1.1).takeValue(); REQUIRE(runtime.apply(exited, context).ok()); CHECK_EQ(audio->getSourceCount(), before + 1); exited.totalElapsed = eve::Duration::fromSeconds(1.2).takeValue(); REQUIRE(runtime.apply(exited, context).ok()); CHECK_EQ(audio->getSourceCount(), before); } TEST_CASE("audio.actionPreviewRetainsAdvanceSeeksRefreshAndStopsOnExit") { auto* audio = tryCreateAudio(); if (!audio) return; auto* filesystem = eve::filesystem::Filesystem::create(); REQUIRE(filesystem->mountRealDirectory(EVENGINE_SOURCE_DIR, "/", false)); REQUIRE_EQ(eve::cap::listenerCount(), 1u); auto* provider = eve::cap::listenerAt(0); REQUIRE(provider != nullptr); auto sink = provider->createActionPreviewSink(); REQUIRE(sink.ok()); const int before = audio->getSourceCount(); eve::Value::Object payload{{"uri", "test/fixtures/resource_formats/tone.wav"}, {"volume", 0.35}, {"pitch", 1.25}, {"looping", true}}; eve::action::ActionPreviewFrame frame; frame.reason = eve::action::ActionPreviewReason::Seek; frame.activeBlocks.push_back({actionLogicalId("presentation-track:audio"), actionLogicalId("presentation-audio:preview"), actionLogicalId("presentation:audio-state"), eve::Duration::fromNanoseconds(5000000), eve::Duration::fromNanoseconds(100000000), payload}); REQUIRE(sink.value()->prepare(frame).ok()); CHECK_EQ(audio->getSourceCount(), before + 1); sink.value()->present(frame); frame.reason = eve::action::ActionPreviewReason::Advance; REQUIRE(sink.value()->prepare(frame).ok()); sink.value()->present(frame); CHECK_EQ(audio->getSourceCount(), before + 1); frame.reason = eve::action::ActionPreviewReason::Refresh; REQUIRE(sink.value()->prepare(frame).ok()); CHECK_EQ(audio->getSourceCount(), before + 2); sink.value()->present(frame); CHECK_EQ(audio->getSourceCount(), before + 1); auto invalid = frame; invalid.activeBlocks.front().payload["pitch"] = 0.0; CHECK(!sink.value()->prepare(invalid).ok()); CHECK_EQ(audio->getSourceCount(), before + 1); eve::action::ActionPreviewFrame empty; REQUIRE(sink.value()->prepare(empty).ok()); sink.value()->present(empty); CHECK_EQ(audio->getSourceCount(), before); eve::action::ActionPreviewCue instant; instant.kind = eve::action::ActionPreviewCueKind::Audio; instant.itemId = actionLogicalId("presentation-audio:instant-preview"); instant.type = actionLogicalId("presentation:audio"); instant.payload = {{"uri", "test/fixtures/resource_formats/tone.wav"}, {"volume", 0.5}}; empty.cues.push_back(std::move(instant)); REQUIRE(sink.value()->prepare(empty).ok()); sink.value()->present(empty); CHECK_EQ(audio->getSourceCount(), before + 1); sink.value().reset(); CHECK_EQ(audio->getSourceCount(), before); } TEST_CASE("audio.actionWaveformProjectsRealPcmWithPitchLoopAndBoundedBuckets") { auto* audio = tryCreateAudio(); if (!audio) return; auto* filesystem = eve::filesystem::Filesystem::create(); REQUIRE(filesystem->mountRealDirectory(EVENGINE_SOURCE_DIR, "/", false)); auto* provider = eve::cap::query(); REQUIRE(provider != nullptr); eve::action::ActionAudioWaveformRequest request; request.binding.uri = "test/fixtures/resource_formats/tone.wav"; request.binding.pitch = 2.0; request.binding.looping = true; request.blockDuration = eve::Duration::fromNanoseconds(200'000'000); request.bucketCount = 64; auto waveform = provider->waveform(request); REQUIRE(waveform.ok()); CHECK(waveform.value().clipDurationSeconds > 0.0); REQUIRE_EQ(waveform.value().buckets.size(), 64u); CHECK(std::any_of(waveform.value().buckets.begin(), waveform.value().buckets.end(), [](const auto& bucket) { return bucket.minimum < -0.01f || bucket.maximum > 0.01f; })); request.bucketCount = 5000; CHECK(!provider->waveform(request).ok()); } TEST_CASE("audio.actionParameterCurveDrivesAndRestoresMasterVolume") { auto* audio = tryCreateAudio(); if (!audio) return; audio->setVolume(0.8f); auto registry = eve::action::ActionNotifyRegistry::withBuiltins(); REQUIRE(registry.ok()); eve::action::ActionBlockRuntime runtime(registry.value()); eve::Value::Object payload{ {"target", "audio:master-volume"}, {"operation", "multiply"}, {"keys", eve::Value::Array{ eve::Value::Object{{"time", 0.0}, {"value", 1.0}, {"interpolation", "linear"}}, eve::Value::Object{{"time", 1.0}, {"value", 0.0}, {"interpolation", "linear"}}}}}; eve::action::ActionAdvance advance; advance.id = eve::action::ActionExecutionId{94}; advance.timelineEvents.push_back({eve::action::ActionTimelineEventKind::StateEnter, actionLogicalId("presentation-track:parameter"), actionLogicalId("presentation-parameter:master-fade"), actionLogicalId("presentation:parameter-curve"), eve::Duration::zero(), payload}); advance.activeBlocks.push_back({actionLogicalId("presentation-track:parameter"), actionLogicalId("presentation-parameter:master-fade"), actionLogicalId("presentation:parameter-curve"), eve::Duration::fromNanoseconds(500'000'000), eve::Duration::fromNanoseconds(1'000'000'000), payload}); eve::action::ActionNotifyContext context; context.executionId = advance.id; REQUIRE(runtime.apply(advance, context).ok()); CHECK(std::abs(audio->getVolume() - 0.4f) < 0.0001f); REQUIRE(runtime.interrupt(context).ok()); CHECK(std::abs(audio->getVolume() - 0.8f) < 0.0001f); } TEST_CASE("audio.underwater.transitionsAndAtomicValidation") { auto *audio = tryCreateAudio(); if (!audio) return; auto *sd = new eve::sound::SoundData(std::vector(44100 * 2, 0), 44100, 16, 1); auto *down = audio->newSource(sd); auto *up = audio->newSource(sd); auto *ambience = audio->newSource(sd); CHECK(!eve::audio::applyUnderwaterAudio(down, up, ambience, true, false, true, 1.5F)); CHECK(!down->isPlaying()); CHECK(!ambience->isPlaying()); REQUIRE(static_cast( eve::audio::applyUnderwaterAudio(down, up, ambience, true, false, true, 0.35F))); CHECK(down->isPlaying()); CHECK(ambience->isPlaying()); CHECK(ambience->isLooping()); CHECK(ambience->getVolume() == 0.35F); REQUIRE(static_cast( eve::audio::applyUnderwaterAudio(down, up, ambience, false, true, false, 0.35F))); CHECK(up->isPlaying()); CHECK(!ambience->isPlaying()); delete ambience; delete up; delete down; delete sd; } TEST_CASE("audio.pcgZone.outputDrivesRealSource") { auto *audio = tryCreateAudio(); if (!audio) return; auto *sd = new eve::sound::SoundData(std::vector(44100 * 2, 0), 44100, 16, 1); auto *source = audio->newSource(sd); eve::audio::AudioZoneOutput command; command.play=true; command.volume=.35f; REQUIRE(eve::audio::applyAudioZoneOutput(source,command).ok()); CHECK(source->isPlaying()); CHECK(source->getVolume()==.35f); command.play=false; command.stop=true; command.volume=0.f; REQUIRE(eve::audio::applyAudioZoneOutput(source,command).ok()); CHECK(!source->isPlaying()); command.volume=std::numeric_limits::quiet_NaN(); CHECK(!eve::audio::applyAudioZoneOutput(source,command).ok()); delete source; delete sd; } TEST_CASE("audio.streamSource.pump") { auto *audio = tryCreateAudio(); if (!audio) return; auto wav = makeSilentWav(44100); eve::data::ByteData data(wav.data(), wav.size()); auto *src = audio->newSourceFromData(&data, "stream"); REQUIRE(src != nullptr); src->setLooping(true); src->play(); for (int i = 0; i < 30; ++i) { audio->pump(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } CHECK(src->isPlaying()); src->stop(); audio->pump(); delete src; } TEST_CASE("audio.spatial.api") { auto *audio = tryCreateAudio(); if (!audio) return; auto *sound = eve::sound::Sound::create(); auto wav = makeSilentWav(); eve::data::ByteData data(wav.data(), wav.size()); auto *sd = sound->newSoundData(&data); auto *src = audio->newSource(sd); audio->setPosition(0, 0, 0); src->setRelative(false); src->setPosition(5.f, 0.f, 0.f); src->setAttenuationDistances(1.f, 50.f); src->play(); audio->pump(); src->stop(); delete src; delete sd; } TEST_CASE("audio.stopAll") { auto *audio = tryCreateAudio(); if (!audio) return; auto *sound = eve::sound::Sound::create(); auto wav = makeSilentWav(44100); eve::data::ByteData data(wav.data(), wav.size()); auto *sd = sound->newSoundData(&data); auto *src1 = audio->newSource(sd); auto *src2 = audio->newSource(sd); src1->play(); src2->play(); audio->pump(); CHECK(src1->isPlaying()); CHECK(src2->isPlaying()); audio->stopAll(); audio->pump(); CHECK(!src1->isPlaying()); CHECK(!src2->isPlaying()); delete src1; delete src2; delete sd; } TEST_CASE("audio.pause") { auto *audio = tryCreateAudio(); if (!audio) return; auto *sound = eve::sound::Sound::create(); auto wav = makeSilentWav(44100); eve::data::ByteData data(wav.data(), wav.size()); auto *sd = sound->newSoundData(&data); auto *src = audio->newSource(sd); src->play(); audio->pump(); CHECK(src->isPlaying()); audio->pause(src); CHECK(!src->isPlaying()); delete src; delete sd; } TEST_CASE("audio.source.seekTell") { auto *audio = tryCreateAudio(); if (!audio) return; auto *sound = eve::sound::Sound::create(); auto wav = makeSilentWav(44100); eve::data::ByteData data(wav.data(), wav.size()); auto *sd = sound->newSoundData(&data); auto *src = audio->newSource(sd); CHECK(src->getDuration() > 0.9); src->play(); audio->pump(); CHECK(src->seek(0.5)); audio->pump(); std::this_thread::sleep_for(std::chrono::milliseconds(50)); double pos = src->tell(); // OpenAL tell() can drift after pump/sleep; keep a loose window around seek(0.5). CHECK(pos >= 0.40); CHECK(pos <= 0.65); src->stop(); delete src; delete sd; } TEST_CASE("audio.source.pitch") { auto *audio = tryCreateAudio(); if (!audio) return; auto *sound = eve::sound::Sound::create(); auto wav = makeSilentWav(); eve::data::ByteData data(wav.data(), wav.size()); auto *sd = sound->newSoundData(&data); auto *src = audio->newSource(sd); src->setPitch(1.5f); CHECK(src->getPitch() == 1.5f); src->setPitch(0.5f); CHECK(src->getPitch() == 0.5f); delete src; delete sd; } TEST_CASE("audio.volumeRoundTrip") { auto *audio = tryCreateAudio(); if (!audio) return; audio->setVolume(0.75f); CHECK(audio->getVolume() == 0.75f); auto *sound = eve::sound::Sound::create(); auto wav = makeSilentWav(); eve::data::ByteData data(wav.data(), wav.size()); auto *sd = sound->newSoundData(&data); auto *src = audio->newSource(sd); src->setVolume(0.3f); CHECK(src->getVolume() == 0.3f); delete src; delete sd; } TEST_CASE("audio.modplug.midi.play") { auto *audio = tryCreateAudio(); if (!audio) return; const std::string path = pathBesideThisSource("angel.mid"); auto bytes = readBinaryFile(path); REQUIRE(!bytes.empty()); eve::filesystem::FileData fd("angel.mid", bytes.size()); std::memcpy(fd.getData(), bytes.data(), bytes.size()); // Prefill stream buffers before play — empty AL sources leave AL_INITIAL. eve::audio::Source *src = nullptr; try { src = audio->newSourceFromData(&fd, "stream"); } catch (const eve::Exception &e) { std::cerr << "MIDI stream source failed: " << e.what() << "\n"; CHECK(false); return; } REQUIRE(src != nullptr); std::cerr << "Checking angel.mid stream (4s deadline) (duration=" << src->getDuration() << "s)\n"; std::cerr << "(ModPlug may warn about missing timidity.cfg / instrument patches)\n"; src->setVolume(1.0f); for (int i = 0; i < 20; ++i) { audio->pump(); std::this_thread::sleep_for(std::chrono::milliseconds(5)); } src->play(); bool heardPlaying = false; double maxTell = 0.0; const auto streamDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(4); while (std::chrono::steady_clock::now() < streamDeadline) { audio->pump(); if (src->isPlaying()) heardPlaying = true; maxTell = std::max(maxTell, src->tell()); if (heardPlaying && maxTell > 0.05) break; std::this_thread::sleep_for(std::chrono::milliseconds(10)); } std::cerr << "stream: isPlayingSeen=" << (heardPlaying ? "yes" : "no") << " maxTell=" << maxTell << "s\n"; REQUIRE(heardPlaying); REQUIRE(maxTell > 0.05); // stream path currently underfills; static path is the reliable play test src->stop(); audio->pump(); delete src; // Also verify static path (full decode → OpenAL buffer) briefly. eve::filesystem::FileData fd2("angel.mid", bytes.size()); std::memcpy(fd2.getData(), bytes.data(), bytes.size()); auto *sound = eve::sound::Sound::create(); auto *sd = sound->newSoundData(&fd2); REQUIRE(sd != nullptr); CHECK(sd->getSampleCount() > 0); auto *staticSrc = audio->newSource(sd); std::cerr << "Checking angel.mid static (2s deadline) (samples=" << sd->getSampleCount() << ")\n"; staticSrc->setVolume(1.0f); staticSrc->play(); REQUIRE(staticSrc->isPlaying()); const auto staticDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); while (staticSrc->tell() <= 0.5 && std::chrono::steady_clock::now() < staticDeadline) { audio->pump(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } REQUIRE(staticSrc->tell() > 0.5); staticSrc->stop(); delete staticSrc; delete sd; }