| #include "rive/audio/audio_engine.hpp" | |
| #include "rive/audio/audio_source.hpp" | |
| #include "rive/audio/audio_sound.hpp" | |
| #include "rive/audio/audio_reader.hpp" | |
| #include "rive/audio_event.hpp" | |
| #include "rive/assets/audio_asset.hpp" | |
| #include "rive_file_reader.hpp" | |
| #include "catch.hpp" | |
| #include "rive/animation/state_machine_instance.hpp" | |
| #include "rive/artboard_component_list.hpp" | |
| #include "rive/nested_artboard.hpp" | |
| #include "rive/viewmodel/viewmodel_instance_artboard.hpp" | |
| #include "rive/lua/scripting_vm.hpp" | |
| #ifdef WITH_RIVE_SCRIPTING_LUAU | |
| #include "rive/lua/rive_lua_libs.hpp" | |
| #endif | |
| #include <algorithm> | |
| #include <cmath> | |
| #include <cstdint> | |
| #include <string> | |
| #include <utility> | |
| #include <vector> | |
| using namespace rive; | |
| TEST_CASE("audio engine initializes", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 44100); | |
| REQUIRE(engine != nullptr); | |
| } | |
| namespace | |
| { | |
| using Block = std::pair<uint32_t, uint64_t>; | |
| // Renders stereo silence and keeps each block's frames and start frame. | |
| class BlockLog : public AudioRenderer | |
| { | |
| public: | |
| std::vector<Block> blocks; | |
| void render(float* out, uint32_t frames, uint64_t startFrame) override | |
| { | |
| std::fill(out, out + (size_t)frames * 2, 0.0f); | |
| blocks.emplace_back(frames, startFrame); | |
| } | |
| }; | |
| // Attaches another renderer during its first block, as a processor's init | |
| // thread can while the audio thread mixes. | |
| class AttachesDuringMix : public BlockLog | |
| { | |
| public: | |
| AudioEngine* engine = nullptr; | |
| AudioRenderer* late = nullptr; | |
| AudioRendererNode* lateNode = nullptr; | |
| void render(float* out, uint32_t frames, uint64_t startFrame) override | |
| { | |
| BlockLog::render(out, frames, startFrame); | |
| if (lateNode == nullptr) | |
| { | |
| lateNode = engine->attachRenderer(late); | |
| } | |
| } | |
| }; | |
| // Stops a sound during its first block, as a script can while the audio | |
| // thread mixes. | |
| class StopsDuringMix : public BlockLog | |
| { | |
| public: | |
| AudioSound* sound = nullptr; | |
| uint64_t fade = 0; | |
| void render(float* out, uint32_t frames, uint64_t startFrame) override | |
| { | |
| BlockLog::render(out, frames, startFrame); | |
| if (blocks.size() == 1) | |
| { | |
| sound->stop(fade); | |
| } | |
| } | |
| }; | |
| rcp<AudioEngine> engineWithBuffer(uint32_t frames) | |
| { | |
| AudioEngine::SetBufferFrames(frames); | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 48000); | |
| AudioEngine::SetBufferFrames(0); | |
| REQUIRE(engine != nullptr); | |
| return engine; | |
| } | |
| rcp<AudioSource> stereo(std::vector<float> samples) | |
| { | |
| return rcp<AudioSource>( | |
| new AudioSource(Span<float>(samples.data(), samples.size()), 2, 48000)); | |
| } | |
| // Stereo samples at full scale, so every frame a sound reaches reads 1. | |
| rcp<AudioSource> fullScale(uint32_t frames) | |
| { | |
| return stereo(std::vector<float>((size_t)frames * 2, 1.0f)); | |
| } | |
| // Each frame reads its own level, so a frame out of place reads wrong. | |
| rcp<AudioSource> ramp(uint32_t frames) | |
| { | |
| std::vector<float> samples((size_t)frames * 2); | |
| for (size_t i = 0; i < samples.size(); i++) | |
| { | |
| samples[i] = (float)(i / 2 + 1) / frames; | |
| } | |
| return stereo(std::move(samples)); | |
| } | |
| std::vector<float> mix(AudioEngine* engine, uint32_t frames) | |
| { | |
| std::vector<float> out((size_t)frames * 2); | |
| REQUIRE(engine->readAudioFrames(out.data(), frames)); | |
| return out; | |
| } | |
| int64_t firstHeard(const std::vector<float>& stereo) | |
| { | |
| for (size_t i = 0; i < stereo.size(); i += 2) | |
| { | |
| if (stereo[i] != 0.0f) | |
| { | |
| return (int64_t)(i / 2); | |
| } | |
| } | |
| return -1; | |
| } | |
| int64_t lastHeard(const std::vector<float>& stereo) | |
| { | |
| for (size_t i = stereo.size(); i > 0; i -= 2) | |
| { | |
| if (stereo[i - 2] != 0.0f) | |
| { | |
| return (int64_t)(i / 2 - 1); | |
| } | |
| } | |
| return -1; | |
| } | |
| } // namespace | |
| TEST_CASE("an engine mixes in blocks of the buffer it opens with", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = engineWithBuffer(2048); | |
| CHECK(engine->bufferFrames() == 2048); | |
| BlockLog log; | |
| AudioRendererNode* node = engine->attachRenderer(&log); | |
| REQUIRE(node != nullptr); | |
| // Pulls of fewer frames than a block still mix whole blocks. | |
| std::vector<float> frames(800 * 2); | |
| for (int pull = 0; pull < 4; pull++) | |
| { | |
| REQUIRE(engine->readAudioFrames(frames.data(), 800)); | |
| } | |
| CHECK(log.blocks == std::vector<Block>{{2048, 0}, {2048, 2048}}); | |
| engine->detachRenderer(node); | |
| } | |
| TEST_CASE( | |
| "the runtime engine opens with the buffer set before it is made", | |
| "[audio]") | |
| { | |
| AudioEngine::ReleaseRuntimeEngine(); | |
| AudioEngine::SetBufferFrames(4096); | |
| rcp<AudioEngine> made = AudioEngine::RuntimeEngine(); | |
| REQUIRE(made != nullptr); | |
| CHECK(made->bufferFrames() == 4096); | |
| rcp<AudioEngine> stored = AudioEngine::MakeAndStore(2, 44100); | |
| REQUIRE(stored != nullptr); | |
| CHECK(stored->bufferFrames() == 4096); | |
| AudioEngine::SetBufferFrames(0); | |
| CHECK(AudioEngine::RuntimeEngine()->bufferFrames() == 4096); | |
| AudioEngine::ReleaseRuntimeEngine(); | |
| CHECK(AudioEngine::RuntimeEngine()->bufferFrames() == 128); | |
| AudioEngine::ReleaseRuntimeEngine(); | |
| } | |
| TEST_CASE( | |
| "an engine with no device runs at the default rate unless given one", | |
| "[audio]") | |
| { | |
| const uint32_t defaultRate = AudioEngine::defaultSampleRate; | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 0); | |
| REQUIRE(engine != nullptr); | |
| CHECK(engine->sampleRate() == defaultRate); | |
| rcp<AudioEngine> given = AudioEngine::Make(2, 44100); | |
| REQUIRE(given != nullptr); | |
| CHECK(given->sampleRate() == 44100); | |
| // The recorder encodes what its runtime engine mixes at the default rate. | |
| AudioEngine::ReleaseRuntimeEngine(); | |
| CHECK(AudioEngine::RuntimeEngine()->sampleRate() == defaultRate); | |
| rcp<AudioEngine> stored = AudioEngine::MakeAndStore(2, 0); | |
| REQUIRE(stored != nullptr); | |
| CHECK(stored->sampleRate() == defaultRate); | |
| AudioEngine::ReleaseRuntimeEngine(); | |
| } | |
| TEST_CASE( | |
| "a renderer attached during a mix starts on the engine's clock", | |
| "[audio]") | |
| { | |
| rcp<AudioEngine> engine = engineWithBuffer(4096); | |
| BlockLog late; | |
| AttachesDuringMix first; | |
| first.engine = engine.get(); | |
| first.late = ⪭ | |
| AudioRendererNode* node = engine->attachRenderer(&first); | |
| REQUIRE(node != nullptr); | |
| std::vector<float> frames(4096 * 2); | |
| REQUIRE(engine->readAudioFrames(frames.data(), 4096)); | |
| REQUIRE(engine->readAudioFrames(frames.data(), 4096)); | |
| REQUIRE(first.lateNode != nullptr); | |
| CHECK(first.blocks == std::vector<Block>{{4096, 0}, {4096, 4096}}); | |
| CHECK(late.blocks == std::vector<Block>{{4096, 4096}}); | |
| engine->detachRenderer(first.lateNode); | |
| engine->detachRenderer(node); | |
| } | |
| TEST_CASE( | |
| "an engine starts a scheduled sound on its frame inside a block", | |
| "[audio]") | |
| { | |
| for (uint64_t frame : {1000u, 5000u}) | |
| { | |
| rcp<AudioEngine> engine = engineWithBuffer(4096); | |
| REQUIRE(engine->play(fullScale(48000), frame, 0, 0) != nullptr); | |
| CHECK(firstHeard(mix(engine.get(), 2 * 4096)) == (int64_t)frame); | |
| } | |
| // As a float, 0.251 falls just short of 251 milliseconds. | |
| rcp<AudioEngine> timed = engineWithBuffer(4096); | |
| REQUIRE(timed->playSeconds(fullScale(48000), 0.251f, 0, 0) != nullptr); | |
| CHECK(firstHeard(mix(timed.get(), 3 * 4096)) == 12048); | |
| // A sound played at a frame already mixed starts with the next block, and | |
| // the clock only runs while something is attached. | |
| rcp<AudioEngine> late = engineWithBuffer(4096); | |
| BlockLog clock; | |
| AudioRendererNode* node = late->attachRenderer(&clock); | |
| REQUIRE(node != nullptr); | |
| mix(late.get(), 4096); | |
| REQUIRE(late->timeInFrames() == 4096); | |
| REQUIRE(late->play(fullScale(48000), 100, 0, 0) != nullptr); | |
| CHECK(firstHeard(mix(late.get(), 4096)) == 0); | |
| late->detachRenderer(node); | |
| } | |
| TEST_CASE( | |
| "an engine fades a sound out to silence over its fade frames", | |
| "[audio]") | |
| { | |
| rcp<AudioEngine> engine = engineWithBuffer(4096); | |
| rcp<AudioSound> sound = engine->play(fullScale(48000), 0, 0, 0); | |
| REQUIRE(sound != nullptr); | |
| mix(engine.get(), 4096); | |
| sound->stop(1000); | |
| std::vector<float> out = mix(engine.get(), 4096); | |
| int wrong = 0; | |
| for (size_t i = 0; i < out.size(); i++) | |
| { | |
| size_t frame = i / 2; | |
| float want = frame < 1000 ? 1.0f - frame / 1000.0f : 0.0f; | |
| wrong += std::abs(out[i] - want) > 1e-6f; | |
| } | |
| CHECK(wrong == 0); | |
| } | |
| TEST_CASE("a sound stopped with a fade before it starts never plays", "[audio]") | |
| { | |
| for (uint64_t fade : {1000u, 4000u}) | |
| { | |
| rcp<AudioEngine> engine = engineWithBuffer(4096); | |
| rcp<AudioSound> sound = engine->play(fullScale(2000), 3000, 0, 0); | |
| REQUIRE(sound != nullptr); | |
| sound->stop(fade); | |
| // A whole block mixes straight into out, so a write past the block | |
| // lands in its second half. | |
| std::vector<float> out(2 * 4096 * 2, 0.5f); | |
| REQUIRE(engine->readAudioFrames(out.data(), 4096)); | |
| CHECK(std::count(out.begin() + 4096 * 2, out.end(), 0.5f) == 4096 * 2); | |
| std::vector<float> heard(out.begin(), out.begin() + 4096 * 2); | |
| std::vector<float> rest = mix(engine.get(), 2 * 4096); | |
| heard.insert(heard.end(), rest.begin(), rest.end()); | |
| CHECK(firstHeard(heard) == -1); | |
| CHECK_FALSE(sound->completed()); | |
| CHECK(engine->playingSoundCount() == 1); | |
| } | |
| } | |
| TEST_CASE("a sound stopped during the block it starts in fades out", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = engineWithBuffer(4096); | |
| // Attached before the sound, so it renders after the sound mixed. | |
| StopsDuringMix stopper; | |
| AudioRendererNode* node = engine->attachRenderer(&stopper); | |
| REQUIRE(node != nullptr); | |
| rcp<AudioSound> sound = engine->play(fullScale(48000), 1000, 0, 0); | |
| REQUIRE(sound != nullptr); | |
| stopper.sound = sound.get(); | |
| stopper.fade = 8000; | |
| std::vector<float> out = mix(engine.get(), 3 * 4096); | |
| CHECK(firstHeard(out) == 1000); | |
| CHECK(lastHeard(out) == 7999); | |
| engine->detachRenderer(node); | |
| } | |
| TEST_CASE("a sound that seeks plays none of what it read ahead", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = engineWithBuffer(128); | |
| // Starting inside a block leaves frames read ahead past it. | |
| rcp<AudioSound> sound = engine->play(ramp(200000), 50, 0, 0); | |
| REQUIRE(sound != nullptr); | |
| mix(engine.get(), 1280); | |
| REQUIRE(sound->seek(0)); | |
| std::vector<float> out = mix(engine.get(), 128); | |
| CHECK(out[0] == 1.0f / 200000); | |
| CHECK(out[2] == 2.0f / 200000); | |
| } | |
| TEST_CASE("a scheduled sound completes in the block it ends in", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = engineWithBuffer(4096); | |
| rcp<AudioSound> sound = engine->play(fullScale(1000), 1000, 0, 0); | |
| REQUIRE(sound != nullptr); | |
| mix(engine.get(), 4096); | |
| CHECK(sound->completed()); | |
| CHECK(engine->playingSoundCount() == 0); | |
| } | |
| TEST_CASE( | |
| "an engine with no device starts a sound on its frame under a newer one", | |
| "[audio]") | |
| { | |
| const uint32_t length = 4096; | |
| for (bool monitored : {false, true}) | |
| { | |
| for (uint32_t read : {1000u, 2048u, 4096u}) | |
| { | |
| for (uint64_t start : {1u, 479u, 480u, 1000u, 2047u}) | |
| { | |
| INFO( | |
| "monitored " << monitored << ", reads of " << read << ", start " | |
| << start); | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 48000); | |
| REQUIRE(engine != nullptr); | |
| if (monitored) | |
| { | |
| engine->initLevelMonitor(); | |
| } | |
| REQUIRE(engine->play(ramp(length), start, 0, 0) != nullptr); | |
| REQUIRE( | |
| engine->play(stereo(std::vector<float>(length * 2)), 0, 0, 0) != | |
| nullptr); | |
| std::vector<float> heard; | |
| while (heard.size() < (start + length) * 2) | |
| { | |
| std::vector<float> out = mix(engine.get(), read); | |
| heard.insert(heard.end(), out.begin(), out.end()); | |
| } | |
| CHECK(firstHeard(heard) == (int64_t)start); | |
| int wrong = 0; | |
| for (size_t i = 0; i < (start + length) * 2; i++) | |
| { | |
| size_t frame = i / 2; | |
| float want = frame < start ? 0.0f | |
| : (float)(frame - start + 1) / length; | |
| wrong += std::abs(heard[i] - want) > 1e-6f; | |
| } | |
| CHECK(wrong == 0); | |
| CHECK(engine->playingSoundCount() == 0); | |
| } | |
| } | |
| } | |
| } | |
| static std::vector<uint8_t> loadFile(const char* filename) | |
| { | |
| FILE* fp = fopen(filename, "rb"); | |
| REQUIRE(fp != nullptr); | |
| fseek(fp, 0, SEEK_END); | |
| const size_t length = ftell(fp); | |
| fseek(fp, 0, SEEK_SET); | |
| std::vector<uint8_t> bytes(length); | |
| REQUIRE(fread(bytes.data(), 1, length, fp) == length); | |
| fclose(fp); | |
| return bytes; | |
| } | |
| TEST_CASE("audio source can be opened", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 44100); | |
| REQUIRE(engine != nullptr); | |
| auto file = loadFile("assets/audio/what.wav"); | |
| auto span = Span<uint8_t>(file); | |
| rcp<AudioSource> audioSource = rcp<AudioSource>(new AudioSource(span)); | |
| REQUIRE(audioSource->channels() == 2); | |
| REQUIRE(audioSource->sampleRate() == 44100); | |
| // Try some different sample rates. | |
| { | |
| auto reader = audioSource->makeReader(2, 44100); | |
| REQUIRE(reader != nullptr); | |
| REQUIRE(reader->lengthInFrames() == 9688); | |
| } | |
| { | |
| auto reader = audioSource->makeReader(1, 48000); | |
| REQUIRE(reader != nullptr); | |
| // The frames that decode. | |
| REQUIRE(reader->lengthInFrames() == 10545); | |
| } | |
| { | |
| auto reader = audioSource->makeReader(2, 32000); | |
| REQUIRE(reader != nullptr); | |
| REQUIRE(reader->lengthInFrames() == 7030); | |
| } | |
| float channels[2] = {0, 0}; | |
| engine->initLevelMonitor(); | |
| engine->levels(Span<float>(&channels[0], 2)); | |
| REQUIRE(channels[0] == 0); | |
| REQUIRE(channels[1] == 0); | |
| auto sound = engine->play(audioSource, 0, 0, 0); | |
| float frames[512 * 2] = {}; | |
| engine->readAudioFrames(frames, 512); | |
| engine->levels(Span<float>(&channels[0], 2)); | |
| REQUIRE(channels[0] != 0); | |
| REQUIRE(channels[1] != 0); | |
| engine->readAudioFrames(frames, 512); | |
| REQUIRE(engine->level(0) != 0); | |
| REQUIRE(engine->level(1) != 0); | |
| } | |
| TEST_CASE("file with audio loads correctly", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/sound.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| auto audioEvents = artboard->find<AudioEvent>(); | |
| REQUIRE(audioEvents.size() == 1); | |
| auto audioEvent = audioEvents[0]; | |
| REQUIRE(audioEvent->asset() != nullptr); | |
| REQUIRE(audioEvent->asset()->hasAudioSource()); | |
| // auto textObjects = artboard->find<rive::Text>(); | |
| // REQUIRE(textObjects.size() == 5); | |
| // auto styleObjects = artboard->find<rive::TextStyle>(); | |
| // REQUIRE(styleObjects.size() == 13); | |
| // auto runObjects = artboard->find<rive::TextValueRun>(); | |
| // REQUIRE(runObjects.size() == 22); | |
| // artboard->advance(0.0f); | |
| // rive::NoOpRenderer renderer; | |
| // artboard->draw(&renderer); | |
| } | |
| TEST_CASE("audio sound can outlive engine", "[audio]") | |
| { | |
| rcp<AudioSound> sound; | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 44100); | |
| REQUIRE(engine != nullptr); | |
| auto file = loadFile("assets/audio/what.wav"); | |
| auto span = Span<uint8_t>(file); | |
| rcp<AudioSource> audioSource = rcp<AudioSource>(new AudioSource(span)); | |
| REQUIRE(audioSource->channels() == 2); | |
| REQUIRE(audioSource->sampleRate() == 44100); | |
| sound = engine->play(audioSource, 0, 0, 0); | |
| float frames[512 * 2] = {}; | |
| engine->readAudioFrames(frames, 512); | |
| } | |
| sound->stop(); | |
| } | |
| TEST_CASE("many audio sounds can outlive engine", "[audio]") | |
| { | |
| std::vector<rcp<AudioSound>> sounds; | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 44100); | |
| REQUIRE(engine != nullptr); | |
| auto file = loadFile("assets/audio/what.wav"); | |
| auto span = Span<uint8_t>(file); | |
| rcp<AudioSource> audioSource = rcp<AudioSource>(new AudioSource(span)); | |
| REQUIRE(audioSource->channels() == 2); | |
| REQUIRE(audioSource->sampleRate() == 44100); | |
| for (int i = 0; i < 20; i++) | |
| { | |
| sounds.emplace_back(engine->play(audioSource, 0, 0, 0)); | |
| } | |
| float frames[512 * 2] = {}; | |
| engine->readAudioFrames(frames, 512); | |
| } | |
| for (auto sound : sounds) | |
| { | |
| sound->stop(); | |
| } | |
| } | |
| TEST_CASE("audio sounds from different artboards stop accordingly", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 44100); | |
| auto file = ReadRiveFile("assets/sound.riv"); | |
| auto artboard = file->artboardDefault(); | |
| artboard->audioEngine(engine); | |
| auto artboard2 = file->artboardDefault(); | |
| artboard2->audioEngine(engine); | |
| REQUIRE(artboard != nullptr); | |
| auto audioEvents = artboard->find<AudioEvent>(); | |
| REQUIRE(audioEvents.size() == 1); | |
| auto audioEvent = audioEvents[0]; | |
| REQUIRE(audioEvent->asset() != nullptr); | |
| REQUIRE(audioEvent->asset()->hasAudioSource()); | |
| audioEvent->play(); | |
| audioEvent->play(); | |
| REQUIRE(engine->playingSoundCount() == 2); | |
| auto audioEvent2 = artboard2->find<AudioEvent>()[0]; | |
| audioEvent2->play(); | |
| REQUIRE(engine->playingSoundCount() == 3); | |
| audioEvent->play(); | |
| REQUIRE(engine->playingSoundCount() == 4); | |
| // The three playing sounds owned by the first artboard should now stop. | |
| artboard = nullptr; | |
| REQUIRE(engine->playingSoundCount() == 1); | |
| // The last one belonging to artboard2 should now stop too. | |
| artboard2 = nullptr; | |
| REQUIRE(engine->playingSoundCount() == 0); | |
| } | |
| // An artboard with no engine of its own still starts its sounds on the | |
| // runtime engine, because AudioEvent::play falls back to it. Teardown has to | |
| // resolve the engine the same way, or those sounds keep playing into whatever | |
| // the process renders next. Artboard::audioEngine only pushes down the tree at | |
| // the moment it is called, so artboards instantiated later -- list items from | |
| // ArtboardComponentList::createArtboard -- still reach teardown with none. | |
| TEST_CASE( | |
| "audio sounds stop when the artboard has no engine assigned", | |
| "[audio]") | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::MakeAndStore(2, 44100); | |
| REQUIRE(AudioEngine::RuntimeEngine(false) == engine); | |
| auto file = ReadRiveFile("assets/sound.riv"); | |
| auto artboard = file->artboardDefault(); | |
| REQUIRE(artboard != nullptr); | |
| // Deliberately no artboard->audioEngine(engine) here. | |
| auto audioEvent = artboard->find<AudioEvent>()[0]; | |
| REQUIRE(audioEvent->asset() != nullptr); | |
| REQUIRE(audioEvent->asset()->hasAudioSource()); | |
| audioEvent->play(); | |
| audioEvent->play(); | |
| REQUIRE(engine->playingSoundCount() == 2); | |
| artboard = nullptr; | |
| REQUIRE(engine->playingSoundCount() == 0); | |
| } | |
| TEST_CASE("Artboard has audio", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 44100); | |
| auto file = ReadRiveFile("assets/sound2.riv"); | |
| auto artboard = file->artboardNamed("child"); | |
| artboard->audioEngine(engine); | |
| REQUIRE(artboard != nullptr); | |
| auto audioEvents = artboard->find<AudioEvent>(); | |
| REQUIRE(audioEvents.size() == 1); | |
| REQUIRE(artboard->hasAudio() == true); | |
| } | |
| TEST_CASE("Artboard has audio in nested artboard", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 44100); | |
| auto file = ReadRiveFile("assets/sound2.riv"); | |
| auto artboard = file->artboardNamed("grand-parent"); | |
| artboard->audioEngine(engine); | |
| REQUIRE(artboard != nullptr); | |
| auto audioEvents = artboard->find<AudioEvent>(); | |
| REQUIRE(audioEvents.size() == 0); | |
| REQUIRE(artboard->hasAudio() == true); | |
| } | |
| TEST_CASE("Artboard does not have audio", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 44100); | |
| auto file = ReadRiveFile("assets/sound2.riv"); | |
| auto artboard = file->artboardNamed("no-audio"); | |
| artboard->audioEngine(engine); | |
| REQUIRE(artboard != nullptr); | |
| auto audioEvents = artboard->find<AudioEvent>(); | |
| REQUIRE(audioEvents.size() == 0); | |
| REQUIRE(artboard->hasAudio() == false); | |
| } | |
| // List items are created after the engine is set, when the list's view model | |
| // fills in, so they have to pick it up from their host. | |
| TEST_CASE("List items use their host's audio engine", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 44100); | |
| auto file = ReadRiveFile("assets/component_list_1.riv"); | |
| auto artboard = file->artboard("Main")->instance(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->audioEngine(engine); | |
| auto list = artboard->find<ArtboardComponentList>("List"); | |
| REQUIRE(list != nullptr); | |
| REQUIRE(list->artboardCount() == 0); | |
| auto viewModelInstance = file->createDefaultViewModelInstance(artboard.get()); | |
| REQUIRE(viewModelInstance != nullptr); | |
| artboard->bindViewModelInstance(viewModelInstance); | |
| artboard->advance(0.0f); | |
| REQUIRE(list->artboardCount() > 0); | |
| for (int i = 0; i < (int)list->artboardCount(); i++) | |
| { | |
| auto item = list->artboardInstance(i); | |
| REQUIRE(item != nullptr); | |
| CHECK(item->audioEngine() == engine); | |
| } | |
| } | |
| // Same for a nested artboard swapped in by data binding after the engine is | |
| // set. | |
| TEST_CASE("Swapped nested artboards use their host's audio engine", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::Make(2, 44100); | |
| auto file = ReadRiveFile("assets/data_binding_artboards_test.riv"); | |
| auto artboard = file->artboardDefault(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->audioEngine(engine); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| int viewModelId = artboard.get()->viewModelId(); | |
| auto vmi = viewModelId == -1 ? file->createViewModelInstance(artboard.get()) | |
| : file->createViewModelInstance(viewModelId, 0); | |
| stateMachine->bindViewModelInstance(vmi); | |
| stateMachine->advanceAndApply(0.1f); | |
| auto nestedArtboards = artboard->find<NestedArtboard>(); | |
| std::vector<ArtboardInstance*> before; | |
| for (auto nested : nestedArtboards) | |
| { | |
| before.push_back(nested->artboardInstance()); | |
| } | |
| auto vmiArtboard = vmi->propertyValue("ab")->as<ViewModelInstanceArtboard>(); | |
| vmiArtboard->asset(file->bindableArtboardNamed("ch1")); | |
| stateMachine->advanceAndApply(0.1f); | |
| bool swapped = false; | |
| for (size_t i = 0; i < nestedArtboards.size(); i++) | |
| { | |
| auto instance = nestedArtboards[i]->artboardInstance(); | |
| if (instance == nullptr) | |
| { | |
| continue; | |
| } | |
| swapped = swapped || instance != before[i]; | |
| CHECK(instance->audioEngine() == engine); | |
| } | |
| REQUIRE(swapped); | |
| } | |
| // Scripted audio drives a Luau VM; the wasm backend covers audio through | |
| // the differential harness instead. | |
| #ifdef WITH_RIVE_SCRIPTING_LUAU | |
| TEST_CASE("Scripted audio plays", "[audio]") | |
| { | |
| rcp<AudioEngine> engine = AudioEngine::MakeAndStore(2, 44100); | |
| auto file = ReadRiveFile("assets/audio_script.riv"); | |
| auto artboard = file->artboardDefault(); | |
| artboard->audioEngine(engine); | |
| auto vm = file->scriptingVM(); | |
| auto context = vm->context(); | |
| context->isPlaying(true); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| auto vmi = file->createDefaultViewModelInstance(artboard.get()); | |
| stateMachine->bindViewModelInstance(vmi); | |
| stateMachine->advanceAndApply(0.016f); | |
| REQUIRE(engine->playingSoundCount() == 0); | |
| stateMachine->pointerDown(rive::Vec2D(25.0f, 25.0f)); | |
| stateMachine->pointerUp(rive::Vec2D(25.0f, 25.0f)); | |
| stateMachine->advanceAndApply(0.016f); | |
| REQUIRE(artboard != nullptr); | |
| REQUIRE(engine->playingSoundCount() == 1); | |
| auto sound = engine->playingSoundsHead(); | |
| REQUIRE(sound->volume() == 1.0f); | |
| stateMachine->pointerDown(rive::Vec2D(200.0f, 200.0f)); | |
| stateMachine->pointerUp(rive::Vec2D(200.0f, 200.0f)); | |
| stateMachine->advanceAndApply(0.016f); | |
| REQUIRE(sound->volume() == 0.1f); | |
| } | |
| #endif | |
| TEST_CASE("audio source duration from file", "[audio]") | |
| { | |
| auto file = loadFile("assets/audio/what.wav"); | |
| auto span = Span<uint8_t>(file); | |
| rcp<AudioSource> audioSource = rcp<AudioSource>(new AudioSource(span)); | |
| REQUIRE(audioSource->channels() == 2); | |
| REQUIRE(audioSource->sampleRate() == 44100); | |
| float duration = audioSource->duration(); | |
| // 9688 frames at 44100 Hz | |
| REQUIRE(duration == Approx(9688.0f / 44100.0f)); | |
| // Verify caching: second call returns same value without re-decoding. | |
| REQUIRE(audioSource->duration() == duration); | |
| } | |
| TEST_CASE("audio source duration from buffered samples", "[audio]") | |
| { | |
| const uint32_t numChannels = 2; | |
| const uint32_t sampleRate = 48000; | |
| const uint32_t numFrames = 48000; // 1 second worth of frames | |
| std::vector<float> samples(numFrames * numChannels, 0.0f); | |
| auto span = Span<float>(samples.data(), samples.size()); | |
| rcp<AudioSource> audioSource = rcp<AudioSource>( | |
| new AudioSource(span, numChannels, sampleRate)); | |
| REQUIRE(audioSource->isBuffered()); | |
| REQUIRE(audioSource->duration() == Approx(1.0f)); | |
| } | |
| #ifndef MA_NO_MP3 | |
| TEST_CASE("audio source duration mp3", "[audio]") | |
| { | |
| auto file = loadFile("assets/audio/song.mp3"); | |
| auto span = Span<uint8_t>(file); | |
| rcp<AudioSource> audioSource = rcp<AudioSource>(new AudioSource(span)); | |
| float duration = audioSource->duration(); | |
| // Duration should be positive for a valid mp3. | |
| REQUIRE(duration > 0.0f); | |
| // Cached value should match. | |
| REQUIRE(audioSource->duration() == duration); | |
| } | |
| #endif | |
| #ifndef MA_NO_MP3 | |
| // Our miniaudio carries dr_libs' seek table fixes, which its releases lacked. | |
| TEST_CASE("an mp3 reader seeks to the frames a whole decode has", "[audio]") | |
| { | |
| auto file = loadFile("assets/audio/song.mp3"); | |
| rcp<AudioSource> audioSource = rcp<AudioSource>( | |
| new AudioSource(Span<uint8_t>(file))); | |
| std::vector<float> whole; | |
| REQUIRE(audioSource->makeReader(0, 0)->readAll(UINT64_MAX, whole)); | |
| auto reader = audioSource->makeReader(0, 0); | |
| REQUIRE(reader != nullptr); | |
| uint32_t channels = reader->channels(); | |
| uint64_t total = whole.size() / channels; | |
| REQUIRE(total > 100000); | |
| // Backwards too, and before the first seek point. | |
| for (uint64_t frame : {total * 3 / 4, total / 3, (uint64_t)12345}) | |
| { | |
| INFO("frame " << frame); | |
| REQUIRE(reader->seek(frame)); | |
| std::vector<float> read(4096 * channels); | |
| uint64_t framesRead = 0; | |
| REQUIRE(reader->readInto(read.data(), 4096, framesRead)); | |
| REQUIRE(framesRead == 4096); | |
| for (size_t i = 0; i < read.size(); i++) | |
| { | |
| REQUIRE(read[i] == whole[frame * channels + i]); | |
| } | |
| } | |
| // Just past where a first read leaves the decoder, in frames that count | |
| // its encoder delay of 1105. | |
| { | |
| auto fresh = audioSource->makeReader(0, 0); | |
| std::vector<float> first(4096 * channels); | |
| uint64_t framesRead = 0; | |
| REQUIRE(fresh->readInto(first.data(), 4096, framesRead)); | |
| REQUIRE(fresh->seek(4096 + 1105)); | |
| REQUIRE(fresh->readInto(first.data(), 4096, framesRead)); | |
| REQUIRE(framesRead == 4096); | |
| for (size_t i = 0; i < first.size(); i++) | |
| { | |
| REQUIRE(first[i] == whole[(4096 + 1105) * channels + i]); | |
| } | |
| } | |
| // Past the end it lands at the end, with nothing more to read. | |
| uint64_t landed = 0; | |
| REQUIRE(reader->seek(total + 1000, &landed)); | |
| REQUIRE(landed == total); | |
| std::vector<float> tail(16 * channels); | |
| uint64_t framesRead = 1; | |
| REQUIRE(reader->readInto(tail.data(), 16, framesRead)); | |
| REQUIRE(framesRead == 0); | |
| } | |
| // iTunes counts its Xing frame in the header's frame count. | |
| TEST_CASE( | |
| "an mp3 whose header counts a frame too many reports what decodes", | |
| "[audio]") | |
| { | |
| auto file = loadFile("assets/audio/song.mp3"); | |
| std::vector<float> whole; | |
| REQUIRE(rcp<AudioSource>(new AudioSource(Span<uint8_t>(file))) | |
| ->makeReader(0, 0) | |
| ->readAll(UINT64_MAX, whole)); | |
| const uint8_t info[] = {'I', 'n', 'f', 'o'}; | |
| auto tag = std::search(file.begin(), file.end(), info, info + 4); | |
| REQUIRE(tag != file.end()); | |
| REQUIRE((tag[7] & 1) != 0); | |
| uint8_t* count = &tag[8]; | |
| uint32_t frames = (uint32_t)count[0] << 24 | (uint32_t)count[1] << 16 | | |
| (uint32_t)count[2] << 8 | count[3]; | |
| frames++; | |
| for (int i = 0; i < 4; i++) | |
| { | |
| count[i] = (uint8_t)(frames >> (24 - i * 8)); | |
| } | |
| rcp<AudioSource> audioSource = rcp<AudioSource>( | |
| new AudioSource(Span<uint8_t>(file))); | |
| auto reader = audioSource->makeReader(0, 0); | |
| REQUIRE(reader != nullptr); | |
| std::vector<float> decoded; | |
| REQUIRE(reader->readAll(UINT64_MAX, decoded)); | |
| REQUIRE(decoded.size() == whole.size()); | |
| REQUIRE(reader->lengthInFrames() == whole.size() / reader->channels()); | |
| REQUIRE( | |
| audioSource->duration() == | |
| (float)reader->lengthInFrames() / (float)reader->sampleRate()); | |
| } | |
| #endif | |
| TEST_CASE("a resampled reader filters as a resampling decoder does", "[audio]") | |
| { | |
| auto file = loadFile("assets/audio/what.wav"); | |
| rcp<AudioSource> audioSource = rcp<AudioSource>( | |
| new AudioSource(Span<uint8_t>(file))); | |
| ma_decoder_config config = ma_decoder_config_init(ma_format_f32, 1, 16000); | |
| ma_decoder decoder; | |
| REQUIRE( | |
| ma_decoder_init_memory(file.data(), file.size(), &config, &decoder) == | |
| MA_SUCCESS); | |
| std::vector<float> expected(2048); | |
| ma_uint64 decoded = 0; | |
| ma_decoder_read_pcm_frames(&decoder, expected.data(), 2048, &decoded); | |
| ma_decoder_uninit(&decoder); | |
| auto reader = audioSource->makeReader(1, 16000); | |
| std::vector<float> read(2048); | |
| uint64_t framesRead = 0; | |
| REQUIRE(reader->readInto(read.data(), 2048, framesRead)); | |
| REQUIRE(framesRead == decoded); | |
| for (size_t i = 0; i < read.size(); i++) | |
| { | |
| INFO("frame " << i); | |
| REQUIRE(read[i] == Approx(expected[i]).margin(1e-6)); | |
| } | |
| } | |
| TEST_CASE("an audio decode stops past its cap", "[audio]") | |
| { | |
| auto file = loadFile("assets/audio/what.wav"); | |
| rcp<AudioSource> audioSource = rcp<AudioSource>( | |
| new AudioSource(Span<uint8_t>(file))); | |
| std::vector<float> samples; | |
| REQUIRE(audioSource->makeReader(1, 0)->readAll(1000, samples)); | |
| // It stops at the first chunk past the cap, which the caller refuses. | |
| REQUIRE(samples.size() > 1000); | |
| REQUIRE(samples.size() <= 1000 + 4096); | |
| } | |
| // TODO check if sound->stop calls completed callback!!! |