From bcb08b09c1a99b2519da284d28feba0ba557c2c8 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Fri, 3 Jul 2026 12:36:09 -0700 Subject: [PATCH 1/6] Add miniaudio-backed WebAudio polyfill. Introduce the WebAudio polyfill with browser-aligned AudioContext lifecycle, scheduled buffer-source start times, a built-in document stub, and platform PreparePlayback hooks for mobile session setup. --- CMakeLists.txt | 5 + Dependencies/CMakeLists.txt | 9 + Documentation/Polyfills.md | 1 + Polyfills/CMakeLists.txt | 4 + Polyfills/WebAudio/CMakeLists.txt | 49 ++ .../Include/Babylon/Polyfills/AudioPlatform.h | 27 + .../Include/Babylon/Polyfills/WebAudio.h | 36 ++ Polyfills/WebAudio/Source/AnalyserNode.cpp | 244 +++++++ Polyfills/WebAudio/Source/AnalyserNode.h | 41 ++ Polyfills/WebAudio/Source/AudioBuffer.cpp | 128 ++++ Polyfills/WebAudio/Source/AudioBuffer.h | 32 + .../WebAudio/Source/AudioBufferSourceNode.cpp | 345 ++++++++++ .../WebAudio/Source/AudioBufferSourceNode.h | 54 ++ Polyfills/WebAudio/Source/AudioContext.cpp | 309 +++++++++ Polyfills/WebAudio/Source/AudioContext.h | 50 ++ .../WebAudio/Source/AudioDestinationNode.cpp | 61 ++ .../WebAudio/Source/AudioDestinationNode.h | 28 + Polyfills/WebAudio/Source/AudioEngine.cpp | 454 +++++++++++++ Polyfills/WebAudio/Source/AudioEngine.h | 89 +++ Polyfills/WebAudio/Source/AudioListener.cpp | 104 +++ Polyfills/WebAudio/Source/AudioListener.h | 46 ++ Polyfills/WebAudio/Source/AudioNode.cpp | 600 ++++++++++++++++++ Polyfills/WebAudio/Source/AudioNode.h | 284 +++++++++ Polyfills/WebAudio/Source/AudioParam.cpp | 143 +++++ Polyfills/WebAudio/Source/AudioParam.h | 43 ++ Polyfills/WebAudio/Source/AudioParamState.cpp | 171 +++++ Polyfills/WebAudio/Source/AudioPlatform.cpp | 104 +++ Polyfills/WebAudio/Source/GainNode.cpp | 120 ++++ Polyfills/WebAudio/Source/GainNode.h | 29 + .../WebAudio/Source/HTMLAudioElement.cpp | 120 ++++ Polyfills/WebAudio/Source/HTMLAudioElement.h | 37 ++ Polyfills/WebAudio/Source/PannerNode.cpp | 261 ++++++++ Polyfills/WebAudio/Source/PannerNode.h | 59 ++ .../WebAudio/Source/StereoPannerNode.cpp | 108 ++++ Polyfills/WebAudio/Source/StereoPannerNode.h | 29 + Polyfills/WebAudio/Source/WebAudio.cpp | 181 ++++++ Polyfills/WebAudio/Source/miniaudio_impl.cpp | 2 + Polyfills/WebAudio/readme.md | 113 ++++ 38 files changed, 4520 insertions(+) create mode 100644 Polyfills/WebAudio/CMakeLists.txt create mode 100644 Polyfills/WebAudio/Include/Babylon/Polyfills/AudioPlatform.h create mode 100644 Polyfills/WebAudio/Include/Babylon/Polyfills/WebAudio.h create mode 100644 Polyfills/WebAudio/Source/AnalyserNode.cpp create mode 100644 Polyfills/WebAudio/Source/AnalyserNode.h create mode 100644 Polyfills/WebAudio/Source/AudioBuffer.cpp create mode 100644 Polyfills/WebAudio/Source/AudioBuffer.h create mode 100644 Polyfills/WebAudio/Source/AudioBufferSourceNode.cpp create mode 100644 Polyfills/WebAudio/Source/AudioBufferSourceNode.h create mode 100644 Polyfills/WebAudio/Source/AudioContext.cpp create mode 100644 Polyfills/WebAudio/Source/AudioContext.h create mode 100644 Polyfills/WebAudio/Source/AudioDestinationNode.cpp create mode 100644 Polyfills/WebAudio/Source/AudioDestinationNode.h create mode 100644 Polyfills/WebAudio/Source/AudioEngine.cpp create mode 100644 Polyfills/WebAudio/Source/AudioEngine.h create mode 100644 Polyfills/WebAudio/Source/AudioListener.cpp create mode 100644 Polyfills/WebAudio/Source/AudioListener.h create mode 100644 Polyfills/WebAudio/Source/AudioNode.cpp create mode 100644 Polyfills/WebAudio/Source/AudioNode.h create mode 100644 Polyfills/WebAudio/Source/AudioParam.cpp create mode 100644 Polyfills/WebAudio/Source/AudioParam.h create mode 100644 Polyfills/WebAudio/Source/AudioParamState.cpp create mode 100644 Polyfills/WebAudio/Source/AudioPlatform.cpp create mode 100644 Polyfills/WebAudio/Source/GainNode.cpp create mode 100644 Polyfills/WebAudio/Source/GainNode.h create mode 100644 Polyfills/WebAudio/Source/HTMLAudioElement.cpp create mode 100644 Polyfills/WebAudio/Source/HTMLAudioElement.h create mode 100644 Polyfills/WebAudio/Source/PannerNode.cpp create mode 100644 Polyfills/WebAudio/Source/PannerNode.h create mode 100644 Polyfills/WebAudio/Source/StereoPannerNode.cpp create mode 100644 Polyfills/WebAudio/Source/StereoPannerNode.h create mode 100644 Polyfills/WebAudio/Source/WebAudio.cpp create mode 100644 Polyfills/WebAudio/Source/miniaudio_impl.cpp create mode 100644 Polyfills/WebAudio/readme.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 1f5c3894d..4164a3dbe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,6 +59,10 @@ FetchContent_Declare(metal-cpp GIT_REPOSITORY https://github.com/bkaradzic/metal-cpp.git GIT_TAG metal-cpp_26 EXCLUDE_FROM_ALL) +FetchContent_Declare(miniaudio + GIT_REPOSITORY https://github.com/mackron/miniaudio.git + GIT_TAG 0.11.22 + EXCLUDE_FROM_ALL) FetchContent_Declare(SPIRV-Cross GIT_REPOSITORY https://github.com/BabylonJS/SPIRV-Cross.git GIT_TAG a512817ddbcd879a3929aef7d1d762871bdf8635 @@ -142,6 +146,7 @@ option(BABYLON_NATIVE_POLYFILL_SCHEDULING "Include Babylon Native Polyfill Sched option(BABYLON_NATIVE_POLYFILL_URL "Include Babylon Native Polyfill URL." ON) option(BABYLON_NATIVE_POLYFILL_WEBSOCKET "Include Babylon Native Polyfill WebSocket." ON) option(BABYLON_NATIVE_POLYFILL_WINDOW "Include Babylon Native Polyfill Window." ON) +option(BABYLON_NATIVE_POLYFILL_WEBAUDIO "Include Babylon Native Polyfill WebAudio." ON) # Embedding option(BABYLON_NATIVE_EMBEDDING "Build the cross-platform Babylon::Embedding facade (Runtime + View)." ON) diff --git a/Dependencies/CMakeLists.txt b/Dependencies/CMakeLists.txt index 58a82367f..31453979c 100644 --- a/Dependencies/CMakeLists.txt +++ b/Dependencies/CMakeLists.txt @@ -27,6 +27,15 @@ FetchContent_MakeAvailable_With_Message(base-n) add_library(base-n INTERFACE) target_include_directories(base-n INTERFACE "${base-n_SOURCE_DIR}/include") +# -------------------------------------------------- +# miniaudio +# -------------------------------------------------- +if(BABYLON_NATIVE_POLYFILL_WEBAUDIO) + # miniaudio's own CMakeLists defines the `miniaudio` target (header-only + # include path plus optional extras). Consume it as-is. + FetchContent_MakeAvailable_With_Message(miniaudio) +endif() + # -------------------------------------------------- # bgfx.cmake # -------------------------------------------------- diff --git a/Documentation/Polyfills.md b/Documentation/Polyfills.md index 2c687dc19..500c648a8 100644 --- a/Documentation/Polyfills.md +++ b/Documentation/Polyfills.md @@ -6,5 +6,6 @@ The polyfills are still in an early stage and are subject to change. At the moment, we are using the following polyfills: * [Canvas](../Polyfills/Canvas/readme.md) * [Console](../Polyfills/Console/readme.md) +* [WebAudio](../Polyfills/WebAudio/readme.md) * [Window](../Polyfills/Window/readme.md) * [XMLHttpRequest](../Polyfills/XMLHttpRequest/readme.md) \ No newline at end of file diff --git a/Polyfills/CMakeLists.txt b/Polyfills/CMakeLists.txt index 16c1b767f..53e8178f1 100644 --- a/Polyfills/CMakeLists.txt +++ b/Polyfills/CMakeLists.txt @@ -5,3 +5,7 @@ endif() if(BABYLON_NATIVE_POLYFILL_CANVAS) add_subdirectory(Canvas) endif() + +if(BABYLON_NATIVE_POLYFILL_WEBAUDIO) + add_subdirectory(WebAudio) +endif() diff --git a/Polyfills/WebAudio/CMakeLists.txt b/Polyfills/WebAudio/CMakeLists.txt new file mode 100644 index 000000000..20dfedcc6 --- /dev/null +++ b/Polyfills/WebAudio/CMakeLists.txt @@ -0,0 +1,49 @@ +set(SOURCES + "Include/Babylon/Polyfills/WebAudio.h" + "Source/WebAudio.cpp" + "Source/AudioEngine.cpp" + "Source/AudioEngine.h" + "Source/AudioNode.cpp" + "Source/AudioNode.h" + "Source/AudioParamState.cpp" + "Source/AudioParam.cpp" + "Source/AudioParam.h" + "Source/AudioBuffer.cpp" + "Source/AudioBuffer.h" + "Source/AudioBufferSourceNode.cpp" + "Source/AudioBufferSourceNode.h" + "Source/GainNode.cpp" + "Source/GainNode.h" + "Source/PannerNode.cpp" + "Source/PannerNode.h" + "Source/StereoPannerNode.cpp" + "Source/StereoPannerNode.h" + "Source/AnalyserNode.cpp" + "Source/AnalyserNode.h" + "Source/AudioListener.cpp" + "Source/AudioListener.h" + "Source/AudioDestinationNode.cpp" + "Source/AudioDestinationNode.h" + "Source/AudioContext.cpp" + "Source/AudioContext.h" + "Source/HTMLAudioElement.cpp" + "Source/HTMLAudioElement.h" + "Source/AudioPlatform.cpp" + "Source/miniaudio_impl.cpp") + +add_library(WebAudio ${SOURCES}) + +warnings_as_errors(WebAudio) + +target_include_directories(WebAudio + PUBLIC "Include" + PRIVATE "Source") + +target_link_libraries(WebAudio + PUBLIC napi + PRIVATE JsRuntimeInternal + PRIVATE miniaudio + PRIVATE arcana) + +set_property(TARGET WebAudio PROPERTY FOLDER Polyfills) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Polyfills/WebAudio/Include/Babylon/Polyfills/AudioPlatform.h b/Polyfills/WebAudio/Include/Babylon/Polyfills/AudioPlatform.h new file mode 100644 index 000000000..06e5ddacf --- /dev/null +++ b/Polyfills/WebAudio/Include/Babylon/Polyfills/AudioPlatform.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include +#include + +namespace Babylon::Polyfills +{ + using AudioPreparePlaybackHandler = std::function; + using AudioInterruptionHandler = std::function; + + void BABYLON_API RegisterAudioPreparePlaybackHandler(AudioPreparePlaybackHandler handler); + void BABYLON_API RegisterAudioInterruptionHandler(AudioInterruptionHandler handler); + + namespace Internal + { + class AudioEngine; + + void PreparePlayback(); + void ReleasePlayback(); + void NotifyAudioInterruption(bool began); + + void RegisterAudioEngine(const std::shared_ptr& engine); + void UnregisterAudioEngine(AudioEngine* engine); + } +} diff --git a/Polyfills/WebAudio/Include/Babylon/Polyfills/WebAudio.h b/Polyfills/WebAudio/Include/Babylon/Polyfills/WebAudio.h new file mode 100644 index 000000000..6e11a1b0b --- /dev/null +++ b/Polyfills/WebAudio/Include/Babylon/Polyfills/WebAudio.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include + +namespace Babylon::Polyfills +{ + class WebAudio final + { + public: + class Impl; + + WebAudio(const WebAudio& other) = default; + WebAudio& operator=(const WebAudio& other) = default; + + WebAudio(WebAudio&&) noexcept = default; + WebAudio& operator=(WebAudio&&) noexcept = default; + + ~WebAudio(); + + void SuspendPlayback() const; + void ResumePlayback() const; + void ShutdownPlayback() const; + + // This instance must live as long as the JS Runtime. + // If JSRuntime is attached/detached (BabylonReactNative), + // then this instance must live forever. + [[nodiscard]] static WebAudio BABYLON_API Initialize(Napi::Env env); + + private: + WebAudio(std::shared_ptr impl); + std::shared_ptr m_impl{}; + }; +} diff --git a/Polyfills/WebAudio/Source/AnalyserNode.cpp b/Polyfills/WebAudio/Source/AnalyserNode.cpp new file mode 100644 index 000000000..0812a0d35 --- /dev/null +++ b/Polyfills/WebAudio/Source/AnalyserNode.cpp @@ -0,0 +1,244 @@ +#include "AnalyserNode.h" +#include "AudioContext.h" +#include "AudioEngine.h" + +#include + +#include +#include +#include + +namespace Babylon::Polyfills::Internal +{ + namespace + { + void LockAnd(const std::shared_ptr& context, auto&& fn) + { + if (context && context->engine) + { + std::lock_guard lock{context->engine->GraphMutex()}; + fn(); + } + else + { + fn(); + } + } + } + + void NativeAnalyserNode::Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + Napi::Function func = DefineClass( + env, + JS_CONSTRUCTOR_NAME, + { + InstanceMethod("connect", &NativeAnalyserNode::Connect), + InstanceMethod("disconnect", &NativeAnalyserNode::Disconnect), + InstanceMethod("getByteFrequencyData", &NativeAnalyserNode::GetByteFrequencyData), + InstanceMethod("getFloatFrequencyData", &NativeAnalyserNode::GetFloatFrequencyData), + InstanceMethod("getByteTimeDomainData", &NativeAnalyserNode::GetByteTimeDomainData), + InstanceMethod("getFloatTimeDomainData", &NativeAnalyserNode::GetFloatTimeDomainData), + InstanceAccessor("fftSize", &NativeAnalyserNode::GetFftSize, &NativeAnalyserNode::SetFftSize), + InstanceAccessor("frequencyBinCount", &NativeAnalyserNode::GetFrequencyBinCount, nullptr), + InstanceAccessor("minDecibels", &NativeAnalyserNode::GetMinDecibels, &NativeAnalyserNode::SetMinDecibels), + InstanceAccessor("maxDecibels", &NativeAnalyserNode::GetMaxDecibels, &NativeAnalyserNode::SetMaxDecibels), + InstanceAccessor("smoothingTimeConstant", &NativeAnalyserNode::GetSmoothingTimeConstant, &NativeAnalyserNode::SetSmoothingTimeConstant), + }); + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); + env.Global().Set(JS_CONSTRUCTOR_NAME, func); + } + + Napi::Object NativeAnalyserNode::CreateInstance(Napi::Env env, const std::shared_ptr& context) + { + auto func = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_CONSTRUCTOR_NAME).As(); + auto object = func.New({}); + NativeAnalyserNode::Unwrap(object)->InitializeState(context); + return object; + } + + NativeAnalyserNode::NativeAnalyserNode(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + { + if (info.Length() > 0 && info[0].IsObject()) + { + auto* context = NativeAudioContext::Unwrap(info[0].As()); + if (context) + { + info.This().As().Set("context", info[0]); + InitializeState(context->State()); + } + } + } + + void NativeAnalyserNode::InitializeState(const std::shared_ptr& context) + { + m_state = std::make_shared(); + m_state->context = context; + BindNodeState(Value(), m_state); + SetAudioNodeProperties(Value(), 1, 1); + } + + Napi::Value NativeAnalyserNode::Connect(const Napi::CallbackInfo& info) + { + auto destination = UnwrapNodeState(info[0]); + if (m_state && destination) + { + LockAnd(m_state->context.lock(), [&]() { m_state->Connect(destination); }); + } + return info[0]; + } + + Napi::Value NativeAnalyserNode::Disconnect(const Napi::CallbackInfo& info) + { + if (!m_state) + { + return info.Env().Undefined(); + } + + if (info.Length() > 0) + { + auto destination = UnwrapNodeState(info[0]); + LockAnd(m_state->context.lock(), [&]() { m_state->Disconnect(destination); }); + } + else + { + LockAnd(m_state->context.lock(), [&]() { m_state->Disconnect(); }); + } + return info.Env().Undefined(); + } + + void NativeAnalyserNode::GetByteFrequencyData(const Napi::CallbackInfo& info) + { + if (!m_state || info.Length() == 0 || !info[0].IsTypedArray()) + { + return; + } + + auto array = info[0].As(); + std::vector magnitudesDb; + LockAnd(m_state->context.lock(), [&]() { m_state->ComputeFrequencyData(magnitudesDb); }); + + const size_t count = std::min(array.ElementLength(), magnitudesDb.size()); + const float dbRange = m_state->maxDecibels - m_state->minDecibels; + for (size_t index = 0; index < count; ++index) + { + const float normalized = dbRange > 0.f ? (magnitudesDb[index] - m_state->minDecibels) / dbRange : 0.f; + array[index] = static_cast(std::round(std::max(0.f, std::min(1.f, normalized)) * 255.f)); + } + } + + void NativeAnalyserNode::GetFloatFrequencyData(const Napi::CallbackInfo& info) + { + if (!m_state || info.Length() == 0 || !info[0].IsTypedArray()) + { + return; + } + + auto array = info[0].As(); + std::vector magnitudesDb; + LockAnd(m_state->context.lock(), [&]() { m_state->ComputeFrequencyData(magnitudesDb); }); + + const size_t count = std::min(array.ElementLength(), magnitudesDb.size()); + for (size_t index = 0; index < count; ++index) + { + array[index] = magnitudesDb[index]; + } + } + + void NativeAnalyserNode::GetByteTimeDomainData(const Napi::CallbackInfo& info) + { + if (!m_state || info.Length() == 0 || !info[0].IsTypedArray()) + { + return; + } + + auto array = info[0].As(); + std::vector snapshot; + LockAnd(m_state->context.lock(), [&]() { m_state->CopyTimeDomainSnapshot(snapshot); }); + + const size_t count = std::min(array.ElementLength(), snapshot.size()); + for (size_t index = 0; index < count; ++index) + { + const float clamped = std::max(-1.f, std::min(1.f, snapshot[index])); + array[index] = static_cast(std::round((clamped + 1.f) * 128.f)); + } + } + + void NativeAnalyserNode::GetFloatTimeDomainData(const Napi::CallbackInfo& info) + { + if (!m_state || info.Length() == 0 || !info[0].IsTypedArray()) + { + return; + } + + auto array = info[0].As(); + std::vector snapshot; + LockAnd(m_state->context.lock(), [&]() { m_state->CopyTimeDomainSnapshot(snapshot); }); + + const size_t count = std::min(array.ElementLength(), snapshot.size()); + for (size_t index = 0; index < count; ++index) + { + array[index] = snapshot[index]; + } + } + + Napi::Value NativeAnalyserNode::GetFftSize(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->fftSize : 2048); + } + + void NativeAnalyserNode::SetFftSize(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + LockAnd(m_state->context.lock(), [&]() { m_state->SetFftSize(value.ToNumber().Uint32Value()); }); + } + } + + Napi::Value NativeAnalyserNode::GetFrequencyBinCount(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->FrequencyBinCount() : 1024); + } + + Napi::Value NativeAnalyserNode::GetMinDecibels(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->minDecibels : -100.f); + } + + void NativeAnalyserNode::SetMinDecibels(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->minDecibels = value.ToNumber().FloatValue(); + } + } + + Napi::Value NativeAnalyserNode::GetMaxDecibels(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->maxDecibels : -30.f); + } + + void NativeAnalyserNode::SetMaxDecibels(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->maxDecibels = value.ToNumber().FloatValue(); + } + } + + Napi::Value NativeAnalyserNode::GetSmoothingTimeConstant(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->smoothingTimeConstant : 0.8f); + } + + void NativeAnalyserNode::SetSmoothingTimeConstant(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->smoothingTimeConstant = value.ToNumber().FloatValue(); + } + } +} diff --git a/Polyfills/WebAudio/Source/AnalyserNode.h b/Polyfills/WebAudio/Source/AnalyserNode.h new file mode 100644 index 000000000..1ac82c416 --- /dev/null +++ b/Polyfills/WebAudio/Source/AnalyserNode.h @@ -0,0 +1,41 @@ +#pragma once + +#include "AudioNode.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + class NativeAnalyserNode final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env); + static Napi::Object CreateInstance(Napi::Env env, const std::shared_ptr& context); + + explicit NativeAnalyserNode(const Napi::CallbackInfo& info); + + private: + static constexpr auto JS_CONSTRUCTOR_NAME = "AnalyserNode"; + + void InitializeState(const std::shared_ptr& context); + + Napi::Value Connect(const Napi::CallbackInfo& info); + Napi::Value Disconnect(const Napi::CallbackInfo& info); + void GetByteFrequencyData(const Napi::CallbackInfo& info); + void GetFloatFrequencyData(const Napi::CallbackInfo& info); + void GetByteTimeDomainData(const Napi::CallbackInfo& info); + void GetFloatTimeDomainData(const Napi::CallbackInfo& info); + + Napi::Value GetFftSize(const Napi::CallbackInfo& info); + void SetFftSize(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetFrequencyBinCount(const Napi::CallbackInfo& info); + Napi::Value GetMinDecibels(const Napi::CallbackInfo& info); + void SetMinDecibels(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetMaxDecibels(const Napi::CallbackInfo& info); + void SetMaxDecibels(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetSmoothingTimeConstant(const Napi::CallbackInfo& info); + void SetSmoothingTimeConstant(const Napi::CallbackInfo& info, const Napi::Value& value); + + std::shared_ptr m_state{}; + }; +} diff --git a/Polyfills/WebAudio/Source/AudioBuffer.cpp b/Polyfills/WebAudio/Source/AudioBuffer.cpp new file mode 100644 index 000000000..462b120f9 --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioBuffer.cpp @@ -0,0 +1,128 @@ +#include "AudioBuffer.h" + +#include + +#include +#include + +namespace Babylon::Polyfills::Internal +{ + void NativeAudioBuffer::Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + Napi::Function func = DefineClass( + env, + JS_CONSTRUCTOR_NAME, + { + InstanceAccessor("sampleRate", &NativeAudioBuffer::GetSampleRate, nullptr), + InstanceAccessor("length", &NativeAudioBuffer::GetLength, nullptr), + InstanceAccessor("duration", &NativeAudioBuffer::GetDuration, nullptr), + InstanceAccessor("numberOfChannels", &NativeAudioBuffer::GetNumberOfChannels, nullptr), + InstanceMethod("getChannelData", &NativeAudioBuffer::GetChannelData), + InstanceMethod("copyFromChannel", &NativeAudioBuffer::CopyFromChannel), + InstanceMethod("copyToChannel", &NativeAudioBuffer::CopyToChannel), + }); + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); + env.Global().Set(JS_CONSTRUCTOR_NAME, func); + } + + Napi::Object NativeAudioBuffer::CreateInstance(Napi::Env env, const std::shared_ptr& data) + { + auto func = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_CONSTRUCTOR_NAME).As(); + auto object = func.New({}); + auto* buffer = NativeAudioBuffer::Unwrap(object); + buffer->m_data = data; + return object; + } + + NativeAudioBuffer::NativeAudioBuffer(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + , m_data{std::make_shared()} + { + if (info.Length() > 0 && info[0].IsObject()) + { + auto options = info[0].As(); + m_data->length = options.Get("length").ToNumber().Uint32Value(); + m_data->numberOfChannels = options.Has("numberOfChannels") ? options.Get("numberOfChannels").ToNumber().Uint32Value() : 1; + m_data->sampleRate = options.Has("sampleRate") ? options.Get("sampleRate").ToNumber().Uint32Value() : 48000; + m_data->channels.assign(m_data->numberOfChannels, std::vector(m_data->length, 0.f)); + } + } + + std::shared_ptr NativeAudioBuffer::Data() const + { + return m_data; + } + + Napi::Value NativeAudioBuffer::GetSampleRate(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_data ? m_data->sampleRate : 0); + } + + Napi::Value NativeAudioBuffer::GetLength(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_data ? m_data->length : 0); + } + + Napi::Value NativeAudioBuffer::GetDuration(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_data ? m_data->Duration() : 0.0); + } + + Napi::Value NativeAudioBuffer::GetNumberOfChannels(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_data ? m_data->numberOfChannels : 0); + } + + Napi::Value NativeAudioBuffer::GetChannelData(const Napi::CallbackInfo& info) + { + const uint32_t channel = info[0].As().Uint32Value(); + if (!m_data || channel >= m_data->numberOfChannels) + { + throw Napi::Error::New(info.Env(), "IndexSizeError: channel index out of range"); + } + + auto& channelData = m_data->channels[channel]; + return Napi::Float32Array::New(info.Env(), channelData.size(), Napi::ArrayBuffer::New(info.Env(), channelData.data(), channelData.size() * sizeof(float), [data = m_data](Napi::Env, void*) {}), 0); + } + + void NativeAudioBuffer::CopyFromChannel(const Napi::CallbackInfo& info) + { + auto destination = info[0].As(); + const uint32_t channel = info[1].As().Uint32Value(); + const uint32_t startInChannel = info.Length() > 2 ? info[2].As().Uint32Value() : 0; + + if (!m_data || channel >= m_data->numberOfChannels) + { + throw Napi::Error::New(info.Env(), "IndexSizeError: channel index out of range"); + } + + const auto& source = m_data->channels[channel]; + const size_t count = std::min(static_cast(destination.ElementLength()), source.size() > startInChannel ? source.size() - startInChannel : 0); + if (count > 0) + { + std::memcpy(destination.Data(), source.data() + startInChannel, count * sizeof(float)); + } + } + + void NativeAudioBuffer::CopyToChannel(const Napi::CallbackInfo& info) + { + auto source = info[0].As(); + const uint32_t channel = info[1].As().Uint32Value(); + const uint32_t startInChannel = info.Length() > 2 ? info[2].As().Uint32Value() : 0; + + if (!m_data || channel >= m_data->numberOfChannels) + { + throw Napi::Error::New(info.Env(), "IndexSizeError: channel index out of range"); + } + + auto& destination = m_data->channels[channel]; + const size_t count = std::min(static_cast(source.ElementLength()), destination.size() > startInChannel ? destination.size() - startInChannel : 0); + if (count > 0) + { + std::memcpy(destination.data() + startInChannel, source.Data(), count * sizeof(float)); + } + } +} diff --git a/Polyfills/WebAudio/Source/AudioBuffer.h b/Polyfills/WebAudio/Source/AudioBuffer.h new file mode 100644 index 000000000..4d015907c --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioBuffer.h @@ -0,0 +1,32 @@ +#pragma once + +#include "AudioNode.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + class NativeAudioBuffer final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env); + static Napi::Object CreateInstance(Napi::Env env, const std::shared_ptr& data); + + explicit NativeAudioBuffer(const Napi::CallbackInfo& info); + + std::shared_ptr Data() const; + + private: + static constexpr auto JS_CONSTRUCTOR_NAME = "AudioBuffer"; + + Napi::Value GetSampleRate(const Napi::CallbackInfo& info); + Napi::Value GetLength(const Napi::CallbackInfo& info); + Napi::Value GetDuration(const Napi::CallbackInfo& info); + Napi::Value GetNumberOfChannels(const Napi::CallbackInfo& info); + Napi::Value GetChannelData(const Napi::CallbackInfo& info); + void CopyFromChannel(const Napi::CallbackInfo& info); + void CopyToChannel(const Napi::CallbackInfo& info); + + std::shared_ptr m_data{}; + }; +} diff --git a/Polyfills/WebAudio/Source/AudioBufferSourceNode.cpp b/Polyfills/WebAudio/Source/AudioBufferSourceNode.cpp new file mode 100644 index 000000000..3c839ffeb --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioBufferSourceNode.cpp @@ -0,0 +1,345 @@ +#include "AudioBufferSourceNode.h" +#include "AudioBuffer.h" +#include "AudioContext.h" +#include "AudioEngine.h" +#include "AudioParam.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + namespace + { + void LockAnd(const std::shared_ptr& context, auto&& fn) + { + if (context && context->engine) + { + std::lock_guard lock{context->engine->GraphMutex()}; + fn(); + } + else + { + fn(); + } + } + } + + void NativeAudioBufferSourceNode::Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + Napi::Function func = DefineClass( + env, + JS_CONSTRUCTOR_NAME, + { + InstanceMethod("connect", &NativeAudioBufferSourceNode::Connect), + InstanceMethod("disconnect", &NativeAudioBufferSourceNode::Disconnect), + InstanceMethod("start", &NativeAudioBufferSourceNode::Start), + InstanceMethod("stop", &NativeAudioBufferSourceNode::Stop), + InstanceMethod("addEventListener", &NativeAudioBufferSourceNode::AddEventListener), + InstanceMethod("removeEventListener", &NativeAudioBufferSourceNode::RemoveEventListener), + InstanceAccessor("buffer", &NativeAudioBufferSourceNode::GetBuffer, &NativeAudioBufferSourceNode::SetBuffer), + InstanceAccessor("loop", &NativeAudioBufferSourceNode::GetLoop, &NativeAudioBufferSourceNode::SetLoop), + InstanceAccessor("loopStart", &NativeAudioBufferSourceNode::GetLoopStart, &NativeAudioBufferSourceNode::SetLoopStart), + InstanceAccessor("loopEnd", &NativeAudioBufferSourceNode::GetLoopEnd, &NativeAudioBufferSourceNode::SetLoopEnd), + InstanceAccessor("playbackRate", &NativeAudioBufferSourceNode::GetPlaybackRate, nullptr), + InstanceAccessor("detune", &NativeAudioBufferSourceNode::GetDetune, nullptr), + InstanceAccessor("onended", &NativeAudioBufferSourceNode::GetOnEnded, &NativeAudioBufferSourceNode::SetOnEnded), + }); + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); + env.Global().Set(JS_CONSTRUCTOR_NAME, func); + } + + Napi::Object NativeAudioBufferSourceNode::CreateInstance(Napi::Env env, const std::shared_ptr& context) + { + auto func = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_CONSTRUCTOR_NAME).As(); + auto object = func.New({}); + NativeAudioBufferSourceNode::Unwrap(object)->InitializeState(context, env.Undefined()); + return object; + } + + struct NativeAudioBufferSourceNode::EndedDispatcher : std::enable_shared_from_this + { + explicit EndedDispatcher(Napi::Object jsThis) + : jsThis{Napi::Persistent(jsThis)} + { + } + + void NotifyEnded(const std::shared_ptr& engine) + { + if (!engine) + { + return; + } + + engine->ScheduleOnJsThread([self = shared_from_this()]() { + if (self->jsThis.IsEmpty()) + { + return; + } + + auto* node = NativeAudioBufferSourceNode::Unwrap(self->jsThis.Value()); + if (node) + { + node->FireEnded(); + } + }); + } + + Napi::ObjectReference jsThis{}; + }; + + NativeAudioBufferSourceNode::NativeAudioBufferSourceNode(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + { + m_endedDispatcher = std::make_shared(info.This().As()); + + if (info.Length() > 0 && info[0].IsObject()) + { + auto* context = NativeAudioContext::Unwrap(info[0].As()); + if (context) + { + info.This().As().Set("context", info[0]); + InitializeState(context->State(), info.Length() > 1 ? info[1] : info.Env().Undefined()); + } + } + } + + NativeAudioBufferSourceNode::~NativeAudioBufferSourceNode() + { + if (m_state) + { + auto context = m_state->context.lock(); + if (context && context->engine) + { + context->engine->UnregisterSource(m_state.get()); + } + m_state->onEnded = nullptr; + } + + if (m_endedDispatcher) + { + m_endedDispatcher->jsThis.Reset(); + } + } + + void NativeAudioBufferSourceNode::InitializeState(const std::shared_ptr& context, const Napi::Value& options) + { + m_state = std::make_shared(); + m_state->context = context; + BindNodeState(Value(), m_state); + SetAudioNodeProperties(Value(), 0, 1); + + auto getCurrentTime = MakeCurrentTimeGetter(context); + m_playbackRate = Napi::Persistent(NativeAudioParam::CreateInstance(Env(), std::shared_ptr(m_state, &m_state->playbackRate), 1.f, getCurrentTime)); + m_detune = Napi::Persistent(NativeAudioParam::CreateInstance(Env(), std::shared_ptr(m_state, &m_state->detune), 0.f, getCurrentTime)); + + if (options.IsObject()) + { + auto optionsObject = options.As(); + if (optionsObject.Has("buffer") && optionsObject.Get("buffer").IsObject()) + { + auto bufferValue = optionsObject.Get("buffer"); + auto* buffer = NativeAudioBuffer::Unwrap(bufferValue.As()); + m_buffer = Napi::Persistent(bufferValue.As()); + m_state->buffer = buffer ? buffer->Data() : nullptr; + } + if (optionsObject.Has("loop")) + { + m_state->loop = optionsObject.Get("loop").ToBoolean(); + } + if (optionsObject.Has("loopStart")) + { + m_state->loopStart = optionsObject.Get("loopStart").ToNumber().DoubleValue(); + } + if (optionsObject.Has("loopEnd")) + { + m_state->loopEnd = optionsObject.Get("loopEnd").ToNumber().DoubleValue(); + } + } + + if (context && context->engine) + { + context->engine->RegisterSource(m_state); + } + + if (!m_endedDispatcher) + { + m_endedDispatcher = std::make_shared(Value()); + } + } + + Napi::Value NativeAudioBufferSourceNode::Connect(const Napi::CallbackInfo& info) + { + auto destination = UnwrapNodeState(info[0]); + if (m_state && destination) + { + LockAnd(m_state->context.lock(), [&]() { m_state->Connect(destination); }); + } + return info[0]; + } + + Napi::Value NativeAudioBufferSourceNode::Disconnect(const Napi::CallbackInfo& info) + { + if (!m_state) + { + return info.Env().Undefined(); + } + + if (info.Length() > 0) + { + auto destination = UnwrapNodeState(info[0]); + LockAnd(m_state->context.lock(), [&]() { m_state->Disconnect(destination); }); + } + else + { + LockAnd(m_state->context.lock(), [&]() { m_state->Disconnect(); }); + } + return info.Env().Undefined(); + } + + Napi::Value NativeAudioBufferSourceNode::GetBuffer(const Napi::CallbackInfo& info) + { + return m_buffer.IsEmpty() ? info.Env().Null() : m_buffer.Value(); + } + + void NativeAudioBufferSourceNode::SetBuffer(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (!m_state) + { + return; + } + + if (value.IsNull() || value.IsUndefined()) + { + m_buffer.Reset(); + m_state->buffer.reset(); + return; + } + + auto* buffer = NativeAudioBuffer::Unwrap(value.As()); + m_buffer = Napi::Persistent(value.As()); + m_state->buffer = buffer ? buffer->Data() : nullptr; + } + + Napi::Value NativeAudioBufferSourceNode::GetLoop(const Napi::CallbackInfo& info) + { + return Napi::Boolean::New(info.Env(), m_state && m_state->loop); + } + + void NativeAudioBufferSourceNode::SetLoop(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->loop = value.ToBoolean(); + } + } + + Napi::Value NativeAudioBufferSourceNode::GetLoopStart(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->loopStart : 0.0); + } + + void NativeAudioBufferSourceNode::SetLoopStart(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->loopStart = value.ToNumber().DoubleValue(); + } + } + + Napi::Value NativeAudioBufferSourceNode::GetLoopEnd(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->loopEnd : 0.0); + } + + void NativeAudioBufferSourceNode::SetLoopEnd(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->loopEnd = value.ToNumber().DoubleValue(); + } + } + + Napi::Value NativeAudioBufferSourceNode::GetPlaybackRate(const Napi::CallbackInfo&) + { + return m_playbackRate.Value(); + } + + Napi::Value NativeAudioBufferSourceNode::GetDetune(const Napi::CallbackInfo&) + { + return m_detune.Value(); + } + + Napi::Value NativeAudioBufferSourceNode::GetOnEnded(const Napi::CallbackInfo& info) + { + return m_onended.IsEmpty() ? info.Env().Null() : m_onended.Value(); + } + + void NativeAudioBufferSourceNode::SetOnEnded(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (value.IsFunction()) + { + m_onended = Napi::Persistent(value.As()); + } + else + { + m_onended.Reset(); + } + } + + void NativeAudioBufferSourceNode::FireEnded() + { + m_eventTarget.DispatchEvent(Env(), "ended"); + if (!m_onended.IsEmpty()) + { + m_onended.Call({}); + } + } + + void NativeAudioBufferSourceNode::Start(const Napi::CallbackInfo& info) + { + if (!m_state) + { + return; + } + + const double when = info.Length() > 0 ? info[0].ToNumber().DoubleValue() : 0.0; + const double offset = info.Length() > 1 ? info[1].ToNumber().DoubleValue() : 0.0; + const double duration = info.Length() > 2 ? info[2].ToNumber().DoubleValue() : -1.0; + + auto context = m_state->context.lock(); + auto engine = context ? context->engine : nullptr; + auto dispatcher = m_endedDispatcher; + + m_state->onEnded = [engine, dispatcher]() { + if (dispatcher) + { + dispatcher->NotifyEnded(engine); + } + }; + + LockAnd(context, [&]() { m_state->Start(when, offset, duration); }); + } + + void NativeAudioBufferSourceNode::Stop(const Napi::CallbackInfo&) + { + if (!m_state) + { + return; + } + + LockAnd(m_state->context.lock(), [&]() { m_state->Stop(); }); + } + + void NativeAudioBufferSourceNode::AddEventListener(const Napi::CallbackInfo& info) + { + m_eventTarget.AddEventListener(info); + } + + void NativeAudioBufferSourceNode::RemoveEventListener(const Napi::CallbackInfo& info) + { + m_eventTarget.RemoveEventListener(info); + } +} diff --git a/Polyfills/WebAudio/Source/AudioBufferSourceNode.h b/Polyfills/WebAudio/Source/AudioBufferSourceNode.h new file mode 100644 index 000000000..324d86e94 --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioBufferSourceNode.h @@ -0,0 +1,54 @@ +#pragma once + +#include "AudioNode.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + class NativeAudioBufferSourceNode final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env); + static Napi::Object CreateInstance(Napi::Env env, const std::shared_ptr& context); + + explicit NativeAudioBufferSourceNode(const Napi::CallbackInfo& info); + ~NativeAudioBufferSourceNode() override; + + private: + static constexpr auto JS_CONSTRUCTOR_NAME = "AudioBufferSourceNode"; + + void InitializeState(const std::shared_ptr& context, const Napi::Value& options); + + Napi::Value Connect(const Napi::CallbackInfo& info); + Napi::Value Disconnect(const Napi::CallbackInfo& info); + Napi::Value GetBuffer(const Napi::CallbackInfo& info); + void SetBuffer(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetLoop(const Napi::CallbackInfo& info); + void SetLoop(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetLoopStart(const Napi::CallbackInfo& info); + void SetLoopStart(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetLoopEnd(const Napi::CallbackInfo& info); + void SetLoopEnd(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetPlaybackRate(const Napi::CallbackInfo& info); + Napi::Value GetDetune(const Napi::CallbackInfo& info); + Napi::Value GetOnEnded(const Napi::CallbackInfo& info); + void SetOnEnded(const Napi::CallbackInfo& info, const Napi::Value& value); + void Start(const Napi::CallbackInfo& info); + void Stop(const Napi::CallbackInfo& info); + void AddEventListener(const Napi::CallbackInfo& info); + void RemoveEventListener(const Napi::CallbackInfo& info); + + void FireEnded(); + + struct EndedDispatcher; + + std::shared_ptr m_state{}; + std::shared_ptr m_endedDispatcher{}; + Napi::ObjectReference m_buffer{}; + Napi::ObjectReference m_playbackRate{}; + Napi::ObjectReference m_detune{}; + Napi::FunctionReference m_onended{}; + EventTargetSupport m_eventTarget{}; + }; +} diff --git a/Polyfills/WebAudio/Source/AudioContext.cpp b/Polyfills/WebAudio/Source/AudioContext.cpp new file mode 100644 index 000000000..42abed72a --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioContext.cpp @@ -0,0 +1,309 @@ +#include "AudioContext.h" +#include "AnalyserNode.h" +#include "AudioBuffer.h" +#include "AudioBufferSourceNode.h" +#include "AudioDestinationNode.h" +#include "AudioEngine.h" +#include "AudioListener.h" +#include "GainNode.h" +#include "PannerNode.h" +#include "StereoPannerNode.h" + +#include +#include + +#include +#include + +#include + +#include + +namespace Babylon::Polyfills::Internal +{ + void NativeAudioContext::Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + Napi::Function func = DefineClass( + env, + JS_CONSTRUCTOR_NAME, + { + InstanceAccessor("destination", &NativeAudioContext::GetDestination, nullptr), + InstanceAccessor("listener", &NativeAudioContext::GetListener, nullptr), + InstanceAccessor("state", &NativeAudioContext::GetState, nullptr), + InstanceAccessor("sampleRate", &NativeAudioContext::GetSampleRate, nullptr), + InstanceAccessor("currentTime", &NativeAudioContext::GetCurrentTime, nullptr), + InstanceMethod("resume", &NativeAudioContext::Resume), + InstanceMethod("suspend", &NativeAudioContext::Suspend), + InstanceMethod("close", &NativeAudioContext::Close), + InstanceMethod("decodeAudioData", &NativeAudioContext::DecodeAudioData), + InstanceMethod("createGain", &NativeAudioContext::CreateGain), + InstanceMethod("createBufferSource", &NativeAudioContext::CreateBufferSource), + InstanceMethod("createPanner", &NativeAudioContext::CreatePanner), + InstanceMethod("createStereoPanner", &NativeAudioContext::CreateStereoPanner), + InstanceMethod("createAnalyser", &NativeAudioContext::CreateAnalyser), + InstanceMethod("createBuffer", &NativeAudioContext::CreateBuffer), + InstanceMethod("addEventListener", &NativeAudioContext::AddEventListener), + InstanceMethod("removeEventListener", &NativeAudioContext::RemoveEventListener), + }); + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); + env.Global().Set(JS_CONSTRUCTOR_NAME, func); + env.Global().Set("webkitAudioContext", func); + } + + NativeAudioContext::NativeAudioContext(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + { + InitializeContext(); + } + + NativeAudioContext::~NativeAudioContext() + { + if (m_state && m_state->engine) + { + m_state->engine->UnregisterContext(m_state.get()); + } + } + + void NativeAudioContext::InitializeContext() + { + m_state = std::make_shared(); + m_state->engine = GetAudioEngineFromJavaScript(Env()); + m_state->destination = std::make_shared(); + m_state->destination->context = m_state; + m_state->running.store(false); + m_state->state = "suspended"; + + if (m_state->engine) + { + m_state->engine->RegisterContext(m_state); + } + + m_destination = Napi::Persistent(NativeAudioDestinationNode::CreateInstance(Env(), std::static_pointer_cast(m_state->destination))); + m_listener = Napi::Persistent(NativeAudioListener::CreateInstance(Env(), m_state->listener, MakeCurrentTimeGetter(m_state))); + } + + std::shared_ptr NativeAudioContext::State() const + { + return m_state; + } + + Napi::Value NativeAudioContext::GetDestination(const Napi::CallbackInfo&) + { + return m_destination.Value(); + } + + Napi::Value NativeAudioContext::GetListener(const Napi::CallbackInfo&) + { + return m_listener.Value(); + } + + Napi::Value NativeAudioContext::GetState(const Napi::CallbackInfo& info) + { + return Napi::String::New(info.Env(), m_state ? m_state->state : "closed"); + } + + Napi::Value NativeAudioContext::GetSampleRate(const Napi::CallbackInfo& info) + { + const uint32_t sampleRate = (m_state && m_state->engine) ? m_state->engine->SampleRate() : 48000; + return Napi::Number::New(info.Env(), sampleRate); + } + + Napi::Value NativeAudioContext::GetCurrentTime(const Napi::CallbackInfo& info) + { + const double currentTime = (m_state && m_state->engine) ? m_state->engine->CurrentTime() : 0.0; + return Napi::Number::New(info.Env(), currentTime); + } + + void NativeAudioContext::SetState(const std::string& state) + { + if (!m_state || m_state->state == state) + { + return; + } + + m_state->state = state; + m_state->running.store(state == "running"); + if (m_state->engine) + { + m_state->engine->OnContextRunningChanged(); + } + m_eventTarget.DispatchEvent(Env(), "statechange"); + } + + Napi::Value NativeAudioContext::Resume(const Napi::CallbackInfo& info) + { + auto deferred = Napi::Promise::Deferred::New(info.Env()); + if (m_state && m_state->state != "closed") + { + if (m_state->engine && m_state->engine->EnsureDeviceRunning()) + { + SetState("running"); + } + else + { + SetState("suspended"); + } + } + deferred.Resolve(info.Env().Undefined()); + return deferred.Promise(); + } + + Napi::Value NativeAudioContext::Suspend(const Napi::CallbackInfo& info) + { + auto deferred = Napi::Promise::Deferred::New(info.Env()); + if (m_state && m_state->state != "closed") + { + SetState("suspended"); + } + deferred.Resolve(info.Env().Undefined()); + return deferred.Promise(); + } + + Napi::Value NativeAudioContext::Close(const Napi::CallbackInfo& info) + { + auto deferred = Napi::Promise::Deferred::New(info.Env()); + if (m_state) + { + SetState("closed"); + if (m_state->engine) + { + m_state->engine->UnregisterContext(m_state.get()); + } + } + deferred.Resolve(info.Env().Undefined()); + return deferred.Promise(); + } + + Napi::Value NativeAudioContext::DecodeAudioData(const Napi::CallbackInfo& info) + { + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + + if (info.Length() < 1 || !info[0].IsArrayBuffer()) + { + deferred.Reject(Napi::Error::New(env, "decodeAudioData requires an ArrayBuffer").Value()); + return deferred.Promise(); + } + + auto arrayBuffer = info[0].As(); + std::vector bytes(static_cast(arrayBuffer.Data()), static_cast(arrayBuffer.Data()) + arrayBuffer.ByteLength()); + + auto runtimeScheduler = std::make_shared(JsRuntime::GetFromJavaScript(env)); + + arcana::make_task(arcana::threadpool_scheduler, arcana::cancellation::none(), [bytes = std::move(bytes)]() { + auto data = std::make_shared(); + + ma_decoder_config config = ma_decoder_config_init(ma_format_f32, 0, 0); + ma_decoder decoder; + if (ma_decoder_init_memory(bytes.data(), bytes.size(), &config, &decoder) != MA_SUCCESS) + { + throw std::runtime_error("Unable to decode audio data"); + } + + ma_uint64 frameCount = 0; + if (ma_decoder_get_length_in_pcm_frames(&decoder, &frameCount) != MA_SUCCESS) + { + ma_decoder_uninit(&decoder); + throw std::runtime_error("Unable to read decoded audio length"); + } + + const uint32_t channels = decoder.outputChannels; + const uint32_t sampleRate = decoder.outputSampleRate; + std::vector interleaved(static_cast(frameCount) * channels); + ma_uint64 framesRead = 0; + if (ma_decoder_read_pcm_frames(&decoder, interleaved.data(), frameCount, &framesRead) != MA_SUCCESS) + { + ma_decoder_uninit(&decoder); + throw std::runtime_error("Unable to read decoded audio frames"); + } + ma_decoder_uninit(&decoder); + + data->sampleRate = sampleRate; + data->numberOfChannels = channels; + data->length = static_cast(framesRead); + data->channels.assign(channels, std::vector(data->length)); + + for (uint32_t frame = 0; frame < data->length; ++frame) + { + for (uint32_t channel = 0; channel < channels; ++channel) + { + data->channels[channel][frame] = interleaved[frame * channels + channel]; + } + } + + return data; + }).then(*runtimeScheduler, arcana::cancellation::none(), [env, deferred, runtimeScheduler](const arcana::expected, std::exception_ptr>& result) { + if (result.has_error()) + { + deferred.Reject(Napi::Error::New(env, result.error()).Value()); + return; + } + + deferred.Resolve(NativeAudioBuffer::CreateInstance(env, result.value())); + }); + + return deferred.Promise(); + } + + Napi::Value NativeAudioContext::CreateGain(const Napi::CallbackInfo& info) + { + auto object = NativeGainNode::CreateInstance(info.Env(), m_state); + object.Set("context", info.This()); + return object; + } + + Napi::Value NativeAudioContext::CreateBufferSource(const Napi::CallbackInfo& info) + { + auto object = NativeAudioBufferSourceNode::CreateInstance(info.Env(), m_state); + object.Set("context", info.This()); + return object; + } + + Napi::Value NativeAudioContext::CreatePanner(const Napi::CallbackInfo& info) + { + auto object = NativePannerNode::CreateInstance(info.Env(), m_state); + object.Set("context", info.This()); + return object; + } + + Napi::Value NativeAudioContext::CreateStereoPanner(const Napi::CallbackInfo& info) + { + auto object = NativeStereoPannerNode::CreateInstance(info.Env(), m_state); + object.Set("context", info.This()); + return object; + } + + Napi::Value NativeAudioContext::CreateAnalyser(const Napi::CallbackInfo& info) + { + auto object = NativeAnalyserNode::CreateInstance(info.Env(), m_state); + object.Set("context", info.This()); + return object; + } + + Napi::Value NativeAudioContext::CreateBuffer(const Napi::CallbackInfo& info) + { + const uint32_t numberOfChannels = info[0].As().Uint32Value(); + const uint32_t length = info[1].As().Uint32Value(); + const uint32_t sampleRate = info[2].As().Uint32Value(); + + auto data = std::make_shared(); + data->numberOfChannels = numberOfChannels; + data->length = length; + data->sampleRate = sampleRate; + data->channels.assign(numberOfChannels, std::vector(length, 0.f)); + return NativeAudioBuffer::CreateInstance(info.Env(), data); + } + + void NativeAudioContext::AddEventListener(const Napi::CallbackInfo& info) + { + m_eventTarget.AddEventListener(info); + } + + void NativeAudioContext::RemoveEventListener(const Napi::CallbackInfo& info) + { + m_eventTarget.RemoveEventListener(info); + } +} diff --git a/Polyfills/WebAudio/Source/AudioContext.h b/Polyfills/WebAudio/Source/AudioContext.h new file mode 100644 index 000000000..484ef67c7 --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioContext.h @@ -0,0 +1,50 @@ +#pragma once + +#include "AudioNode.h" +#include "AudioEngine.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + class NativeAudioContext final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env); + + explicit NativeAudioContext(const Napi::CallbackInfo& info); + ~NativeAudioContext() override; + + std::shared_ptr State() const; + + private: + static constexpr auto JS_CONSTRUCTOR_NAME = "AudioContext"; + + void InitializeContext(); + + Napi::Value GetDestination(const Napi::CallbackInfo& info); + Napi::Value GetListener(const Napi::CallbackInfo& info); + Napi::Value GetState(const Napi::CallbackInfo& info); + Napi::Value GetSampleRate(const Napi::CallbackInfo& info); + Napi::Value GetCurrentTime(const Napi::CallbackInfo& info); + Napi::Value Resume(const Napi::CallbackInfo& info); + Napi::Value Suspend(const Napi::CallbackInfo& info); + Napi::Value Close(const Napi::CallbackInfo& info); + Napi::Value DecodeAudioData(const Napi::CallbackInfo& info); + Napi::Value CreateGain(const Napi::CallbackInfo& info); + Napi::Value CreateBufferSource(const Napi::CallbackInfo& info); + Napi::Value CreatePanner(const Napi::CallbackInfo& info); + Napi::Value CreateStereoPanner(const Napi::CallbackInfo& info); + Napi::Value CreateAnalyser(const Napi::CallbackInfo& info); + Napi::Value CreateBuffer(const Napi::CallbackInfo& info); + void AddEventListener(const Napi::CallbackInfo& info); + void RemoveEventListener(const Napi::CallbackInfo& info); + + void SetState(const std::string& state); + + std::shared_ptr m_state{}; + Napi::ObjectReference m_destination{}; + Napi::ObjectReference m_listener{}; + EventTargetSupport m_eventTarget{}; + }; +} diff --git a/Polyfills/WebAudio/Source/AudioDestinationNode.cpp b/Polyfills/WebAudio/Source/AudioDestinationNode.cpp new file mode 100644 index 000000000..9a508977f --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioDestinationNode.cpp @@ -0,0 +1,61 @@ +#include "AudioDestinationNode.h" +#include "AudioEngine.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + void NativeAudioDestinationNode::Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + Napi::Function func = DefineClass( + env, + JS_CONSTRUCTOR_NAME, + { + InstanceMethod("connect", &NativeAudioDestinationNode::Connect), + InstanceMethod("disconnect", &NativeAudioDestinationNode::Disconnect), + InstanceAccessor("maxChannelCount", &NativeAudioDestinationNode::GetMaxChannelCount, nullptr), + }); + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); + env.Global().Set(JS_CONSTRUCTOR_NAME, func); + } + + Napi::Object NativeAudioDestinationNode::CreateInstance(Napi::Env env, const std::shared_ptr& state) + { + auto func = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_CONSTRUCTOR_NAME).As(); + auto object = func.New({}); + auto* self = NativeAudioDestinationNode::Unwrap(object); + self->m_state = state; + BindNodeState(object, state); + SetAudioNodeProperties(object, 1, 0); + object.Set("channelCount", 2); + return object; + } + + NativeAudioDestinationNode::NativeAudioDestinationNode(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + { + } + + std::shared_ptr NativeAudioDestinationNode::State() const + { + return m_state; + } + + Napi::Value NativeAudioDestinationNode::Connect(const Napi::CallbackInfo& info) + { + return info[0]; + } + + Napi::Value NativeAudioDestinationNode::Disconnect(const Napi::CallbackInfo& info) + { + return info.Env().Undefined(); + } + + Napi::Value NativeAudioDestinationNode::GetMaxChannelCount(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), 2); + } +} diff --git a/Polyfills/WebAudio/Source/AudioDestinationNode.h b/Polyfills/WebAudio/Source/AudioDestinationNode.h new file mode 100644 index 000000000..a3d2119e3 --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioDestinationNode.h @@ -0,0 +1,28 @@ +#pragma once + +#include "AudioNode.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + class NativeAudioDestinationNode final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env); + static Napi::Object CreateInstance(Napi::Env env, const std::shared_ptr& state); + + explicit NativeAudioDestinationNode(const Napi::CallbackInfo& info); + + std::shared_ptr State() const; + + private: + static constexpr auto JS_CONSTRUCTOR_NAME = "AudioDestinationNode"; + + Napi::Value Connect(const Napi::CallbackInfo& info); + Napi::Value Disconnect(const Napi::CallbackInfo& info); + Napi::Value GetMaxChannelCount(const Napi::CallbackInfo& info); + + std::shared_ptr m_state{}; + }; +} diff --git a/Polyfills/WebAudio/Source/AudioEngine.cpp b/Polyfills/WebAudio/Source/AudioEngine.cpp new file mode 100644 index 000000000..4598e98b9 --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioEngine.cpp @@ -0,0 +1,454 @@ +#include "AudioEngine.h" +#include "AudioNode.h" + +#include + +#include + +#include +#include +#include + +namespace Babylon::Polyfills::Internal +{ + namespace + { + constexpr uint32_t OUTPUT_CHANNEL_COUNT = 2; + constexpr uint32_t NULL_BACKEND_FRAME_COUNT = 256; + constexpr const char* NULL_BACKEND_ENV = "BABYLON_NATIVE_WEBAUDIO_BACKEND"; + + bool UseNullBackend() + { + const char* value = std::getenv(NULL_BACKEND_ENV); + return value != nullptr && std::strcmp(value, "null") == 0; + } + } + + std::shared_ptr AudioEngine::Create(JsRuntime& runtime) + { + auto engine = std::shared_ptr(new AudioEngine(runtime)); + Babylon::Polyfills::Internal::RegisterAudioEngine(engine); + return engine; + } + + AudioEngine::AudioEngine(JsRuntime& runtime) + : m_runtimeScheduler{runtime} + { + } + + AudioEngine::~AudioEngine() + { + PrepareForShutdown(); + } + + void AudioEngine::PrepareForShutdown() + { + if (m_shuttingDown.exchange(true)) + { + return; + } + + UnregisterAudioEngine(this); + SetClockSuspended(true); + SuspendDevice(); + { + std::lock_guard lock{m_graphMutex}; + m_sources.clear(); + } + ShutdownDevice(); + } + + bool AudioEngine::InitializeDevice() + { + if (m_shuttingDown.load(std::memory_order_relaxed)) + { + return false; + } + + std::lock_guard lock{m_deviceMutex}; + if (m_deviceInitialized.load(std::memory_order_relaxed)) + { + return true; + } + + const bool forceNull = UseNullBackend(); + if (forceNull) + { + m_usingNullBackend.store(true, std::memory_order_relaxed); + m_sampleRate = 48000; + m_deviceInitialized.store(true, std::memory_order_relaxed); + return true; + } + + m_device = std::make_unique(); + + ma_device_config config = ma_device_config_init(ma_device_type_playback); + config.playback.format = ma_format_f32; + config.playback.channels = OUTPUT_CHANNEL_COUNT; + config.dataCallback = DataCallback; + config.pUserData = this; + + ma_result result = MA_ERROR; + { + Babylon::Polyfills::Internal::PreparePlayback(); + result = ma_device_init(nullptr, &config, m_device.get()); + if (result != MA_SUCCESS) + { + ma_backend backends[] = {ma_backend_null}; + result = ma_device_init_ex(backends, 1, nullptr, &config, m_device.get()); + m_usingNullBackend.store(true, std::memory_order_relaxed); + } + else + { + m_usingNullBackend.store(false, std::memory_order_relaxed); + } + } + + if (result != MA_SUCCESS) + { + m_device.reset(); + m_deviceInitialized.store(false, std::memory_order_relaxed); + m_deviceStarted.store(false, std::memory_order_relaxed); + return false; + } + + m_sampleRate = m_device->sampleRate; + m_deviceInitialized.store(true, std::memory_order_relaxed); + return true; + } + + void AudioEngine::ShutdownDevice() + { + StopNullClock(); + + std::lock_guard lock{m_deviceMutex}; + if (!m_device) + { + m_deviceInitialized.store(false, std::memory_order_relaxed); + m_deviceStarted.store(false, std::memory_order_relaxed); + return; + } + + if (m_deviceStarted.load(std::memory_order_relaxed)) + { + ma_device_stop(m_device.get()); + m_deviceStarted.store(false, std::memory_order_relaxed); + } + + ma_device_uninit(m_device.get()); + m_device.reset(); + m_deviceInitialized.store(false, std::memory_order_relaxed); + m_deviceStarted.store(false, std::memory_order_relaxed); + } + + bool AudioEngine::EnsureDeviceRunning() + { + if (m_shuttingDown.load(std::memory_order_relaxed)) + { + return false; + } + + if (!InitializeDevice()) + { + return false; + } + + std::lock_guard lock{m_deviceMutex}; + if (!m_deviceInitialized.load(std::memory_order_relaxed)) + { + return false; + } + + if (!m_usingNullBackend.load(std::memory_order_relaxed) && !m_device) + { + return false; + } + + if (!m_deviceStarted.load(std::memory_order_relaxed)) + { + if (m_usingNullBackend.load(std::memory_order_relaxed)) + { + StartNullClock(); + } + else if (ma_device_start(m_device.get()) != MA_SUCCESS) + { + return false; + } + + m_deviceStarted.store(true, std::memory_order_relaxed); + } + + return true; + } + + void AudioEngine::SuspendDevice() + { + if (m_usingNullBackend.load(std::memory_order_relaxed)) + { + StopNullClock(); + m_deviceStarted.store(false, std::memory_order_relaxed); + return; + } + + std::lock_guard lock{m_deviceMutex}; + if (!m_device || !m_deviceStarted.load(std::memory_order_relaxed)) + { + return; + } + + ma_device_stop(m_device.get()); + m_deviceStarted.store(false, std::memory_order_relaxed); + } + + void AudioEngine::ResumeDevice() + { + if (m_usingNullBackend.load(std::memory_order_relaxed)) + { + if (!m_deviceStarted.load(std::memory_order_relaxed)) + { + StartNullClock(); + m_deviceStarted.store(true, std::memory_order_relaxed); + } + return; + } + + std::lock_guard lock{m_deviceMutex}; + if (!m_device || m_deviceStarted.load(std::memory_order_relaxed)) + { + return; + } + + if (ma_device_start(m_device.get()) == MA_SUCCESS) + { + m_deviceStarted.store(true, std::memory_order_relaxed); + } + } + + void AudioEngine::TryResumeAfterInterruption() + { + if (HasRunningContext()) + { + EnsureDeviceRunning(); + ResumeDevice(); + SetClockSuspended(false); + } + } + + void AudioEngine::SetClockSuspended(bool suspended) + { + m_clockSuspended.store(suspended, std::memory_order_relaxed); + } + + bool AudioEngine::HasRunningContext() + { + std::lock_guard lock{m_graphMutex}; + for (const auto& weakContext : m_contexts) + { + auto context = weakContext.lock(); + if (context && context->running.load(std::memory_order_relaxed)) + { + return true; + } + } + + return false; + } + + void AudioEngine::OnContextRunningChanged() + { + const bool anyRunning = HasRunningContext(); + SetClockSuspended(!anyRunning); + if (anyRunning) + { + EnsureDeviceRunning(); + ResumeDevice(); + } + else + { + SuspendDevice(); + } + } + + uint32_t AudioEngine::SampleRate() const + { + return m_sampleRate; + } + + double AudioEngine::CurrentTime() const + { + return static_cast(m_framesProcessed.load(std::memory_order_relaxed)) / static_cast(m_sampleRate); + } + + bool AudioEngine::IsRunning() const + { + return m_deviceStarted.load(std::memory_order_relaxed); + } + + bool AudioEngine::UsesNullBackend() const + { + return m_usingNullBackend.load(std::memory_order_relaxed); + } + + bool AudioEngine::DeviceInitialized() const + { + return m_deviceInitialized.load(std::memory_order_relaxed); + } + + void AudioEngine::RegisterContext(const std::shared_ptr& context) + { + { + std::lock_guard lock{m_graphMutex}; + m_contexts.push_back(context); + } + OnContextRunningChanged(); + } + + void AudioEngine::UnregisterContext(AudioContextState* context) + { + { + std::lock_guard lock{m_graphMutex}; + m_contexts.erase( + std::remove_if(m_contexts.begin(), m_contexts.end(), [context](const std::weak_ptr& weak) { + auto shared = weak.lock(); + return !shared || shared.get() == context; + }), + m_contexts.end()); + } + OnContextRunningChanged(); + } + + void AudioEngine::RegisterSource(const std::shared_ptr& source) + { + std::lock_guard lock{m_graphMutex}; + m_sources.push_back(source); + } + + void AudioEngine::UnregisterSource(BufferSourceState* source) + { + std::lock_guard lock{m_graphMutex}; + m_sources.erase( + std::remove_if(m_sources.begin(), m_sources.end(), [source](const std::weak_ptr& weak) { + auto shared = weak.lock(); + return !shared || shared.get() == source; + }), + m_sources.end()); + } + + void AudioEngine::ScheduleOnJsThread(std::function callback) + { + m_runtimeScheduler([callback = std::move(callback)]() { + callback(); + }); + } + + std::mutex& AudioEngine::GraphMutex() + { + return m_graphMutex; + } + + std::function MakeCurrentTimeGetter(const std::shared_ptr& context) + { + return [weakContext = std::weak_ptr(context)]() { + auto sharedContext = weakContext.lock(); + if (!sharedContext || !sharedContext->engine) + { + return 0.0; + } + + return sharedContext->engine->CurrentTime(); + }; + } + + void AudioEngine::DataCallback(ma_device* device, void* output, const void* /*input*/, uint32_t frameCount) + { + auto* engine = static_cast(device->pUserData); + engine->Mix(static_cast(output), frameCount); + } + + void AudioEngine::StartNullClock() + { + if (m_nullClockRunning.load(std::memory_order_relaxed) || m_shuttingDown.load(std::memory_order_relaxed)) + { + return; + } + + m_nullClockRunning.store(true, std::memory_order_relaxed); + m_nullClockThread = std::thread([this]() { + std::vector buffer(static_cast(NULL_BACKEND_FRAME_COUNT) * OUTPUT_CHANNEL_COUNT, 0.f); + while (m_nullClockRunning.load(std::memory_order_relaxed) && !m_shuttingDown.load(std::memory_order_relaxed)) + { + if (!m_clockSuspended.load(std::memory_order_relaxed)) + { + Mix(buffer.data(), NULL_BACKEND_FRAME_COUNT); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + }); + } + + void AudioEngine::StopNullClock() + { + if (!m_nullClockRunning.exchange(false, std::memory_order_relaxed)) + { + return; + } + + if (m_nullClockThread.joinable()) + { + m_nullClockThread.detach(); + } + } + + void AudioEngine::Mix(float* outputInterleaved, uint32_t frameCount) + { + std::memset(outputInterleaved, 0, sizeof(float) * frameCount * OUTPUT_CHANNEL_COUNT); + + if (m_shuttingDown.load(std::memory_order_relaxed) || m_clockSuspended.load(std::memory_order_relaxed)) + { + return; + } + + std::vector mixL(frameCount, 0.f); + std::vector mixR(frameCount, 0.f); + + { + std::lock_guard lock{m_graphMutex}; + + const double blockStartTime = static_cast(m_framesProcessed.load(std::memory_order_relaxed)) / static_cast(m_sampleRate); + + m_sources.erase( + std::remove_if(m_sources.begin(), m_sources.end(), [](const std::weak_ptr& weak) { + return weak.expired(); + }), + m_sources.end()); + + for (const auto& weakSource : m_sources) + { + auto source = weakSource.lock(); + if (!source || !source->started.load()) + { + continue; + } + + auto context = source->context.lock(); + if (!context || !context->running.load()) + { + continue; + } + + std::fill(mixL.begin(), mixL.end(), 0.f); + std::fill(mixR.begin(), mixR.end(), 0.f); + source->Mix(mixL.data(), mixR.data(), frameCount, m_sampleRate, context->listener, blockStartTime); + + for (uint32_t frame = 0; frame < frameCount; ++frame) + { + outputInterleaved[frame * OUTPUT_CHANNEL_COUNT] += mixL[frame]; + outputInterleaved[frame * OUTPUT_CHANNEL_COUNT + 1] += mixR[frame]; + } + } + } + + m_framesProcessed.fetch_add(frameCount, std::memory_order_relaxed); + } +} diff --git a/Polyfills/WebAudio/Source/AudioEngine.h b/Polyfills/WebAudio/Source/AudioEngine.h new file mode 100644 index 000000000..cba12ff02 --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioEngine.h @@ -0,0 +1,89 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +struct ma_device; + +namespace Babylon::Polyfills::Internal +{ + struct AudioNodeState; + struct AudioContextState; + struct BufferSourceState; + + class AudioEngine final : public std::enable_shared_from_this + { + public: + static std::shared_ptr Create(JsRuntime& runtime); + ~AudioEngine(); + + AudioEngine(const AudioEngine&) = delete; + AudioEngine& operator=(const AudioEngine&) = delete; + + uint32_t SampleRate() const; + double CurrentTime() const; + bool IsRunning() const; + bool UsesNullBackend() const; + bool DeviceInitialized() const; + + bool EnsureDeviceRunning(); + void SuspendDevice(); + void ResumeDevice(); + void TryResumeAfterInterruption(); + void SetClockSuspended(bool suspended); + void PrepareForShutdown(); + void OnContextRunningChanged(); + bool HasRunningContext(); + + void RegisterContext(const std::shared_ptr& context); + void UnregisterContext(AudioContextState* context); + + void RegisterSource(const std::shared_ptr& source); + void UnregisterSource(BufferSourceState* source); + + void ScheduleOnJsThread(std::function callback); + + std::mutex& GraphMutex(); + + private: + explicit AudioEngine(JsRuntime& runtime); + + bool InitializeDevice(); + void ShutdownDevice(); + void StartNullClock(); + void StopNullClock(); + + static void DataCallback(ma_device* device, void* output, const void* input, uint32_t frameCount); + void Mix(float* outputInterleaved, uint32_t frameCount); + + JsRuntimeScheduler m_runtimeScheduler; + std::unique_ptr m_device{}; + mutable std::mutex m_deviceMutex{}; + uint32_t m_sampleRate{48000}; + std::atomic m_framesProcessed{0}; + std::atomic m_deviceStarted{false}; + std::atomic m_deviceInitialized{false}; + std::atomic m_usingNullBackend{false}; + std::atomic m_clockSuspended{true}; + std::atomic m_shuttingDown{false}; + std::atomic m_nullClockRunning{false}; + std::thread m_nullClockThread{}; + + std::mutex m_graphMutex{}; + std::vector> m_contexts{}; + std::vector> m_sources{}; + }; + + std::function MakeCurrentTimeGetter(const std::shared_ptr& context); + + std::shared_ptr GetAudioEngineFromJavaScript(Napi::Env env); +} diff --git a/Polyfills/WebAudio/Source/AudioListener.cpp b/Polyfills/WebAudio/Source/AudioListener.cpp new file mode 100644 index 000000000..53921af0f --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioListener.cpp @@ -0,0 +1,104 @@ +#include "AudioListener.h" +#include "AudioEngine.h" +#include "AudioParam.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + void NativeAudioListener::Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + Napi::Function func = DefineClass( + env, + JS_CONSTRUCTOR_NAME, + { + InstanceAccessor("positionX", &NativeAudioListener::GetPositionX, nullptr), + InstanceAccessor("positionY", &NativeAudioListener::GetPositionY, nullptr), + InstanceAccessor("positionZ", &NativeAudioListener::GetPositionZ, nullptr), + InstanceAccessor("forwardX", &NativeAudioListener::GetForwardX, nullptr), + InstanceAccessor("forwardY", &NativeAudioListener::GetForwardY, nullptr), + InstanceAccessor("forwardZ", &NativeAudioListener::GetForwardZ, nullptr), + InstanceAccessor("upX", &NativeAudioListener::GetUpX, nullptr), + InstanceAccessor("upY", &NativeAudioListener::GetUpY, nullptr), + InstanceAccessor("upZ", &NativeAudioListener::GetUpZ, nullptr), + InstanceMethod("setPosition", &NativeAudioListener::SetPosition), + InstanceMethod("setOrientation", &NativeAudioListener::SetOrientation), + }); + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); + } + + Napi::Object NativeAudioListener::CreateInstance(Napi::Env env, AudioListenerState& listener, const std::function& getCurrentTime) + { + auto func = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_CONSTRUCTOR_NAME).As(); + auto object = func.New({}); + auto* self = NativeAudioListener::Unwrap(object); + self->m_listener = &listener; + self->m_getCurrentTime = getCurrentTime; + + auto bindParam = [&](AudioParamState& state, float defaultValue, Napi::ObjectReference& ref) { + auto statePtr = std::shared_ptr(std::shared_ptr{}, &state); + auto param = NativeAudioParam::CreateInstance(env, statePtr, defaultValue, getCurrentTime); + ref = Napi::Persistent(param); + state.SetDefault(defaultValue); + }; + + bindParam(listener.positionX, 0.f, self->m_positionX); + bindParam(listener.positionY, 0.f, self->m_positionY); + bindParam(listener.positionZ, 0.f, self->m_positionZ); + bindParam(listener.forwardX, 0.f, self->m_forwardX); + bindParam(listener.forwardY, 0.f, self->m_forwardY); + bindParam(listener.forwardZ, -1.f, self->m_forwardZ); + bindParam(listener.upX, 0.f, self->m_upX); + bindParam(listener.upY, 1.f, self->m_upY); + bindParam(listener.upZ, 0.f, self->m_upZ); + + return object; + } + + NativeAudioListener::NativeAudioListener(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + { + } + + Napi::Value NativeAudioListener::GetPositionX(const Napi::CallbackInfo&) { return m_positionX.Value(); } + Napi::Value NativeAudioListener::GetPositionY(const Napi::CallbackInfo&) { return m_positionY.Value(); } + Napi::Value NativeAudioListener::GetPositionZ(const Napi::CallbackInfo&) { return m_positionZ.Value(); } + Napi::Value NativeAudioListener::GetForwardX(const Napi::CallbackInfo&) { return m_forwardX.Value(); } + Napi::Value NativeAudioListener::GetForwardY(const Napi::CallbackInfo&) { return m_forwardY.Value(); } + Napi::Value NativeAudioListener::GetForwardZ(const Napi::CallbackInfo&) { return m_forwardZ.Value(); } + Napi::Value NativeAudioListener::GetUpX(const Napi::CallbackInfo&) { return m_upX.Value(); } + Napi::Value NativeAudioListener::GetUpY(const Napi::CallbackInfo&) { return m_upY.Value(); } + Napi::Value NativeAudioListener::GetUpZ(const Napi::CallbackInfo&) { return m_upZ.Value(); } + + void NativeAudioListener::SetPosition(const Napi::CallbackInfo& info) + { + if (!m_listener || info.Length() < 3) + { + return; + } + + const double currentTime = m_getCurrentTime ? m_getCurrentTime() : 0.0; + m_listener->positionX.SetImmediate(info[0].As().FloatValue(), currentTime); + m_listener->positionY.SetImmediate(info[1].As().FloatValue(), currentTime); + m_listener->positionZ.SetImmediate(info[2].As().FloatValue(), currentTime); + } + + void NativeAudioListener::SetOrientation(const Napi::CallbackInfo& info) + { + if (!m_listener || info.Length() < 6) + { + return; + } + + const double currentTime = m_getCurrentTime ? m_getCurrentTime() : 0.0; + m_listener->forwardX.SetImmediate(info[0].As().FloatValue(), currentTime); + m_listener->forwardY.SetImmediate(info[1].As().FloatValue(), currentTime); + m_listener->forwardZ.SetImmediate(info[2].As().FloatValue(), currentTime); + m_listener->upX.SetImmediate(info[3].As().FloatValue(), currentTime); + m_listener->upY.SetImmediate(info[4].As().FloatValue(), currentTime); + m_listener->upZ.SetImmediate(info[5].As().FloatValue(), currentTime); + } +} diff --git a/Polyfills/WebAudio/Source/AudioListener.h b/Polyfills/WebAudio/Source/AudioListener.h new file mode 100644 index 000000000..7cdeba46c --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioListener.h @@ -0,0 +1,46 @@ +#pragma once + +#include "AudioNode.h" + +#include + +#include + +namespace Babylon::Polyfills::Internal +{ + class NativeAudioListener final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env); + static Napi::Object CreateInstance(Napi::Env env, AudioListenerState& listener, const std::function& getCurrentTime); + + explicit NativeAudioListener(const Napi::CallbackInfo& info); + + private: + static constexpr auto JS_CONSTRUCTOR_NAME = "AudioListener"; + + Napi::Value GetPositionX(const Napi::CallbackInfo& info); + Napi::Value GetPositionY(const Napi::CallbackInfo& info); + Napi::Value GetPositionZ(const Napi::CallbackInfo& info); + Napi::Value GetForwardX(const Napi::CallbackInfo& info); + Napi::Value GetForwardY(const Napi::CallbackInfo& info); + Napi::Value GetForwardZ(const Napi::CallbackInfo& info); + Napi::Value GetUpX(const Napi::CallbackInfo& info); + Napi::Value GetUpY(const Napi::CallbackInfo& info); + Napi::Value GetUpZ(const Napi::CallbackInfo& info); + void SetPosition(const Napi::CallbackInfo& info); + void SetOrientation(const Napi::CallbackInfo& info); + + AudioListenerState* m_listener{nullptr}; + std::function m_getCurrentTime{}; + Napi::ObjectReference m_positionX{}; + Napi::ObjectReference m_positionY{}; + Napi::ObjectReference m_positionZ{}; + Napi::ObjectReference m_forwardX{}; + Napi::ObjectReference m_forwardY{}; + Napi::ObjectReference m_forwardZ{}; + Napi::ObjectReference m_upX{}; + Napi::ObjectReference m_upY{}; + Napi::ObjectReference m_upZ{}; + }; +} diff --git a/Polyfills/WebAudio/Source/AudioNode.cpp b/Polyfills/WebAudio/Source/AudioNode.cpp new file mode 100644 index 000000000..3bbf67d6b --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioNode.cpp @@ -0,0 +1,600 @@ +#include "AudioNode.h" + +#include +#include + +namespace +{ + constexpr auto JS_NODE_STATE_PROPERTY = "_audioNodeState"; +} + +namespace Babylon::Polyfills::Internal +{ + namespace + { + constexpr float PI = 3.14159265358979323846f; + + float Clamp(float value, float minValue, float maxValue) + { + return std::max(minValue, std::min(value, maxValue)); + } + + double ClampDouble(double value, double minValue, double maxValue) + { + return std::max(minValue, std::min(value, maxValue)); + } + + float Length3(float x, float y, float z) + { + return std::sqrt(x * x + y * y + z * z); + } + + void Normalize3(float& x, float& y, float& z) + { + const float length = Length3(x, y, z); + if (length > 1.0e-6f) + { + x /= length; + y /= length; + z /= length; + } + } + + float Dot3(float ax, float ay, float az, float bx, float by, float bz) + { + return ax * bx + ay * by + az * bz; + } + } + + void AudioNodeState::Connect(const std::shared_ptr& destination) + { + if (!destination) + { + return; + } + + if (std::find(outputs.begin(), outputs.end(), destination) == outputs.end()) + { + outputs.push_back(destination); + } + } + + void AudioNodeState::Disconnect() + { + outputs.clear(); + } + + void AudioNodeState::Disconnect(const std::shared_ptr& destination) + { + if (!destination) + { + Disconnect(); + return; + } + + outputs.erase(std::remove(outputs.begin(), outputs.end(), destination), outputs.end()); + } + + void AudioNodeState::ProcessSample(float&, float&, const AudioListenerState&, double) + { + } + + void AudioNodeState::RenderSample(float& left, float& right, float* mixL, float* mixR, size_t frameIndex, const AudioListenerState& listener, double sampleTime) + { + if (kind == AudioNodeKind::Destination) + { + mixL[frameIndex] += left; + mixR[frameIndex] += right; + return; + } + + ProcessSample(left, right, listener, sampleTime); + + for (const auto& output : outputs) + { + if (!output) + { + continue; + } + + float outLeft = left; + float outRight = right; + output->RenderSample(outLeft, outRight, mixL, mixR, frameIndex, listener, sampleTime); + } + } + + void GainNodeState::ProcessSample(float& left, float& right, const AudioListenerState&, double sampleTime) + { + const float g = gain.GetAtTime(sampleTime); + left *= g; + right *= g; + } + + void StereoPannerNodeState::ProcessSample(float& left, float& right, const AudioListenerState&, double sampleTime) + { + const float panValue = Clamp(pan.GetAtTime(sampleTime), -1.f, 1.f); + const float angle = (panValue + 1.f) * 0.25f * PI; + const float gainL = std::cos(angle); + const float gainR = std::sin(angle); + const float mono = 0.5f * (left + right); + left = mono * gainL; + right = mono * gainR; + } + + void PannerNodeState::ProcessSample(float& left, float& right, const AudioListenerState& listener, double sampleTime) + { + const float sourceX = positionX.GetAtTime(sampleTime); + const float sourceY = positionY.GetAtTime(sampleTime); + const float sourceZ = positionZ.GetAtTime(sampleTime); + + float listenerX = listener.positionX.GetAtTime(sampleTime); + float listenerY = listener.positionY.GetAtTime(sampleTime); + float listenerZ = listener.positionZ.GetAtTime(sampleTime); + + float dx = sourceX - listenerX; + float dy = sourceY - listenerY; + float dz = sourceZ - listenerZ; + const float distance = Length3(dx, dy, dz); + + float distanceGain = 1.f; + if (distanceModel == "linear") + { + distanceGain = 1.f - rolloffFactor * (distance - refDistance) / std::max(maxDistance - refDistance, 1.0e-6f); + distanceGain = Clamp(distanceGain, 0.f, 1.f); + } + else if (distanceModel == "exponential") + { + const float ratio = std::max(distance, refDistance) / std::max(refDistance, 1.0e-6f); + distanceGain = std::pow(ratio, -rolloffFactor); + } + else + { + // inverse (default) + distanceGain = refDistance / (refDistance + rolloffFactor * std::max(distance - refDistance, 0.f)); + } + + if (distance > maxDistance) + { + distanceGain = 0.f; + } + + float coneGain = 1.f; + if (coneInnerAngle < 360.f || coneOuterAngle < 360.f) + { + float orientX = orientationX.GetAtTime(sampleTime); + float orientY = orientationY.GetAtTime(sampleTime); + float orientZ = orientationZ.GetAtTime(sampleTime); + Normalize3(orientX, orientY, orientZ); + + float toListenerX = listenerX - sourceX; + float toListenerY = listenerY - sourceY; + float toListenerZ = listenerZ - sourceZ; + Normalize3(toListenerX, toListenerY, toListenerZ); + + const float angle = std::acos(Clamp(Dot3(orientX, orientY, orientZ, toListenerX, toListenerY, toListenerZ), -1.f, 1.f)) * (180.f / PI); + const float absAngle = std::abs(angle); + const float halfInner = coneInnerAngle * 0.5f; + const float halfOuter = coneOuterAngle * 0.5f; + if (absAngle > halfOuter) + { + coneGain = coneOuterGain; + } + else if (absAngle > halfInner) + { + const float t = (absAngle - halfInner) / std::max(halfOuter - halfInner, 1.0e-6f); + coneGain = (1.f - t) + t * coneOuterGain; + } + } + + const float gain = distanceGain * coneGain; + left *= gain; + right *= gain; + + // Equal-power azimuth panning relative to the listener's forward/right axes. + float forwardX = listener.forwardX.GetAtTime(sampleTime); + float forwardY = listener.forwardY.GetAtTime(sampleTime); + float forwardZ = listener.forwardZ.GetAtTime(sampleTime); + float upX = listener.upX.GetAtTime(sampleTime); + float upY = listener.upY.GetAtTime(sampleTime); + float upZ = listener.upZ.GetAtTime(sampleTime); + Normalize3(forwardX, forwardY, forwardZ); + Normalize3(upX, upY, upZ); + + float rightX = forwardY * upZ - forwardZ * upY; + float rightY = forwardZ * upX - forwardX * upZ; + float rightZ = forwardX * upY - forwardY * upX; + Normalize3(rightX, rightY, rightZ); + + if (distance > 1.0e-6f) + { + dx /= distance; + dy /= distance; + dz /= distance; + } + + const float azimuth = Clamp(Dot3(dx, dy, dz, rightX, rightY, rightZ), -1.f, 1.f); + const float panAngle = (azimuth + 1.f) * 0.25f * PI; + const float gainL = std::cos(panAngle); + const float gainR = std::sin(panAngle); + const float mono = 0.5f * (left + right); + left = mono * gainL; + right = mono * gainR; + } + + void BufferSourceState::Start(double when, double offset, double duration) + { + if (!buffer || buffer->length == 0 || buffer->sampleRate == 0) + { + ended.store(true); + started.store(false); + playbackActive.store(false); + return; + } + + const double bufferDuration = buffer->Duration(); + const double startOffset = std::max(0.0, std::min(offset, bufferDuration)); + playbackFrame = startOffset * static_cast(buffer->sampleRate); + scheduledStartTime = when; + + if (duration >= 0.0) + { + const double endFrame = playbackFrame + duration * static_cast(buffer->sampleRate); + loopEnd = endFrame / static_cast(buffer->sampleRate); + loop = false; + } + + ended.store(false); + playbackActive.store(false); + started.store(true); + } + + void BufferSourceState::Stop() + { + started.store(false); + playbackActive.store(false); + if (!ended.exchange(true) && onEnded) + { + onEnded(); + } + } + + bool BufferSourceState::Mix(float* mixL, float* mixR, uint32_t frameCount, uint32_t outputSampleRate, const AudioListenerState& listener, double blockStartTime) + { + if (!started.load() || !buffer || buffer->length == 0) + { + return false; + } + + const double outputRate = static_cast(outputSampleRate); + const double sourceRate = static_cast(buffer->sampleRate); + + const double bufferLength = static_cast(buffer->length); + double loopStartFrame = loopStart * sourceRate; + double loopEndFrame = loopEnd > 0.0 ? loopEnd * sourceRate : bufferLength; + loopStartFrame = ClampDouble(loopStartFrame, 0.0, bufferLength); + loopEndFrame = ClampDouble(loopEndFrame, loopStartFrame, bufferLength); + + const auto& channels = buffer->channels; + const uint32_t channelCount = buffer->numberOfChannels; + + bool finished = false; + for (uint32_t frame = 0; frame < frameCount; ++frame) + { + const double sampleTime = blockStartTime + static_cast(frame) / outputRate; + if (sampleTime < scheduledStartTime) + { + continue; + } + + if (!playbackActive.load(std::memory_order_relaxed)) + { + playbackActive.store(true, std::memory_order_relaxed); + } + + const double rate = static_cast(playbackRate.GetAtTime(sampleTime)) * std::pow(2.0, static_cast(detune.GetAtTime(sampleTime)) / 1200.0); + const double frameStep = rate * (sourceRate / outputRate); + + if (playbackFrame >= bufferLength) + { + if (loop) + { + playbackFrame = loopStartFrame; + } + else + { + finished = true; + break; + } + } + + if (loop && playbackFrame >= loopEndFrame) + { + playbackFrame = loopStartFrame; + } + + const size_t index = static_cast(playbackFrame); + const size_t nextIndex = std::min(index + 1, static_cast(buffer->length - 1)); + const float frac = static_cast(playbackFrame - static_cast(index)); + + auto sampleChannel = [&](uint32_t channel) { + if (channel >= channelCount || channels[channel].empty()) + { + return 0.f; + } + const float a = channels[channel][index]; + const float b = channels[channel][nextIndex]; + return a + (b - a) * frac; + }; + + float left = sampleChannel(0); + float right = channelCount > 1 ? sampleChannel(1) : left; + + RenderSample(left, right, mixL, mixR, frame, listener, sampleTime); + playbackFrame += frameStep; + } + + if (finished) + { + started.store(false); + playbackActive.store(false); + if (!ended.exchange(true) && onEnded) + { + onEnded(); + } + return true; + } + + return false; + } + + namespace + { + bool IsPowerOfTwo(uint32_t value) + { + return value >= 2 && (value & (value - 1)) == 0; + } + + void FftInPlace(std::vector& real, std::vector& imag) + { + const size_t n = real.size(); + if (n < 2) + { + return; + } + + for (size_t i = 1, j = 0; i < n; ++i) + { + size_t bit = n >> 1; + for (; j & bit; bit >>= 1) + { + j ^= bit; + } + j ^= bit; + if (i < j) + { + std::swap(real[i], real[j]); + std::swap(imag[i], imag[j]); + } + } + + for (size_t length = 2; length <= n; length <<= 1) + { + const double angle = -2.0 * PI / static_cast(length); + const float wReal = static_cast(std::cos(angle)); + const float wImag = static_cast(std::sin(angle)); + + for (size_t i = 0; i < n; i += length) + { + float currentWReal = 1.f; + float currentWImag = 0.f; + const size_t half = length / 2; + for (size_t j = 0; j < half; ++j) + { + const size_t evenIndex = i + j; + const size_t oddIndex = i + j + half; + const float oddReal = real[oddIndex] * currentWReal - imag[oddIndex] * currentWImag; + const float oddImag = real[oddIndex] * currentWImag + imag[oddIndex] * currentWReal; + + real[oddIndex] = real[evenIndex] - oddReal; + imag[oddIndex] = imag[evenIndex] - oddImag; + real[evenIndex] += oddReal; + imag[evenIndex] += oddImag; + + const float nextWReal = currentWReal * wReal - currentWImag * wImag; + currentWImag = currentWReal * wImag + currentWImag * wReal; + currentWReal = nextWReal; + } + } + } + } + } + + void AnalyserNodeState::ResetRingBuffer() + { + ringBuffer.assign(fftSize, 0.f); + ringWriteIndex = 0; + ringFilled = false; + smoothedMagnitudes.assign(FrequencyBinCount(), minDecibels); + } + + void AnalyserNodeState::SetFftSize(uint32_t size) + { + if (!IsPowerOfTwo(size)) + { + return; + } + + fftSize = size; + ResetRingBuffer(); + } + + void AnalyserNodeState::WriteRingSample(float sample) + { + if (ringBuffer.empty()) + { + return; + } + + ringBuffer[ringWriteIndex] = sample; + ringWriteIndex = (ringWriteIndex + 1) % ringBuffer.size(); + if (ringWriteIndex == 0) + { + ringFilled = true; + } + } + + void AnalyserNodeState::ProcessSample(float& left, float& right, const AudioListenerState&, double) + { + WriteRingSample(0.5f * (left + right)); + } + + void AnalyserNodeState::CopyTimeDomainSnapshot(std::vector& snapshot) const + { + snapshot.resize(fftSize); + if (ringBuffer.empty()) + { + std::fill(snapshot.begin(), snapshot.end(), 0.f); + return; + } + + if (!ringFilled) + { + const size_t available = ringWriteIndex; + const size_t silenceCount = fftSize - available; + std::fill(snapshot.begin(), snapshot.begin() + silenceCount, 0.f); + std::copy(ringBuffer.begin(), ringBuffer.begin() + static_cast(available), snapshot.begin() + static_cast(silenceCount)); + return; + } + + const size_t start = ringWriteIndex; + for (size_t index = 0; index < fftSize; ++index) + { + snapshot[index] = ringBuffer[(start + index) % fftSize]; + } + } + + void AnalyserNodeState::ComputeFrequencyData(std::vector& magnitudesDb) const + { + const uint32_t binCount = FrequencyBinCount(); + magnitudesDb.resize(binCount); + + std::vector snapshot; + CopyTimeDomainSnapshot(snapshot); + + std::vector real(fftSize, 0.f); + std::vector imag(fftSize, 0.f); + for (uint32_t index = 0; index < fftSize; ++index) + { + const float window = 0.5f * (1.f - std::cos((2.f * PI * static_cast(index)) / static_cast(fftSize))); + real[index] = snapshot[index] * window; + } + + FftInPlace(real, imag); + + const float normalization = 1.f / static_cast(fftSize); + for (uint32_t bin = 0; bin < binCount; ++bin) + { + const float magnitude = std::sqrt(real[bin] * real[bin] + imag[bin] * imag[bin]) * normalization; + const float db = magnitude > 1.0e-12f ? 20.f * std::log10(magnitude) : minDecibels; + magnitudesDb[bin] = Clamp(db, minDecibels, maxDecibels); + } + + if (smoothedMagnitudes.size() != binCount) + { + smoothedMagnitudes.assign(binCount, minDecibels); + } + + const float smoothing = Clamp(smoothingTimeConstant, 0.f, 1.f); + for (uint32_t bin = 0; bin < binCount; ++bin) + { + smoothedMagnitudes[bin] = smoothing * smoothedMagnitudes[bin] + (1.f - smoothing) * magnitudesDb[bin]; + magnitudesDb[bin] = smoothedMagnitudes[bin]; + } + } + + void BindNodeState(Napi::Object object, const std::shared_ptr& state) + { + auto* holder = new std::shared_ptr(state); + object.Set(JS_NODE_STATE_PROPERTY, Napi::External>::New(object.Env(), holder, [](Napi::Env, std::shared_ptr* ptr) { + delete ptr; + })); + } + + void SetAudioNodeProperties(Napi::Object object, uint32_t numberOfInputs, uint32_t numberOfOutputs) + { + object.Set("numberOfInputs", numberOfInputs); + object.Set("numberOfOutputs", numberOfOutputs); + } + + std::shared_ptr UnwrapNodeState(const Napi::Value& value) + { + if (!value.IsObject()) + { + return nullptr; + } + + auto external = value.As().Get(JS_NODE_STATE_PROPERTY); + if (external.IsUndefined() || !external.IsExternal()) + { + return nullptr; + } + + auto* holder = external.As>>().Data(); + return holder ? *holder : nullptr; + } + + void EventTargetSupport::AddEventListener(const Napi::CallbackInfo& info) + { + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction()) + { + return; + } + + const auto type = info[0].As().Utf8Value(); + m_listeners[type].emplace_back(Napi::Persistent(info[1].As())); + } + + void EventTargetSupport::RemoveEventListener(const Napi::CallbackInfo& info) + { + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction()) + { + return; + } + + const auto type = info[0].As().Utf8Value(); + auto it = m_listeners.find(type); + if (it == m_listeners.end()) + { + return; + } + + auto& listeners = it->second; + const auto function = info[1].As(); + for (auto listenerIt = listeners.begin(); listenerIt != listeners.end(); ++listenerIt) + { + if (listenerIt->Value() == function) + { + listeners.erase(listenerIt); + break; + } + } + } + + void EventTargetSupport::DispatchEvent(Napi::Env env, const std::string& type) + { + auto it = m_listeners.find(type); + if (it == m_listeners.end()) + { + return; + } + + auto event = Napi::Object::New(env); + event.Set("type", Napi::String::New(env, type)); + + // Iterate by index in case a listener mutates the list. + for (size_t index = 0; index < it->second.size(); ++index) + { + it->second[index].Call({event}); + } + } +} diff --git a/Polyfills/WebAudio/Source/AudioNode.h b/Polyfills/WebAudio/Source/AudioNode.h new file mode 100644 index 000000000..5891cdf03 --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioNode.h @@ -0,0 +1,284 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Babylon::Polyfills::Internal +{ + class AudioEngine; + struct AudioContextState; + struct AudioListenerState; + + enum class AudioNodeKind + { + Destination, + Gain, + Panner, + StereoPanner, + BufferSource, + Analyser, + }; + + struct AudioParamState + { + enum class EventType + { + SetValue, + LinearRamp, + ExponentialRamp, + SetCurve, + }; + + struct Event + { + double time; + EventType type; + float value; + std::vector curve; + double duration; + }; + + float defaultValue{0.f}; + std::vector events{}; + + void SetDefault(float value); + void CancelScheduledValues(double startTime); + void SetValueAtTime(float value, double time); + void LinearRampToValueAtTime(float value, double time); + void ExponentialRampToValueAtTime(float value, double time); + void SetValueCurveAtTime(const std::vector& values, double startTime, double duration); + void SetImmediate(float value, double currentTime); + float GetAtTime(double t) const; + }; + + struct AudioBufferData + { + uint32_t sampleRate{48000}; + uint32_t length{0}; + uint32_t numberOfChannels{1}; + std::vector> channels{}; + + double Duration() const + { + return sampleRate == 0 ? 0.0 : static_cast(length) / static_cast(sampleRate); + } + }; + + struct AudioNodeState : std::enable_shared_from_this + { + explicit AudioNodeState(AudioNodeKind kind) + : kind{kind} + { + } + + virtual ~AudioNodeState() = default; + + const AudioNodeKind kind; + std::weak_ptr context{}; + std::vector> outputs{}; + + void Connect(const std::shared_ptr& destination); + void Disconnect(); + void Disconnect(const std::shared_ptr& destination); + + // Applies this node's effect to a stereo sample in-place, then fans out to outputs. + // Destination nodes accumulate into mixL/mixR. + void RenderSample(float& left, float& right, float* mixL, float* mixR, size_t frameIndex, const AudioListenerState& listener, double sampleTime); + + protected: + virtual void ProcessSample(float& left, float& right, const AudioListenerState& listener, double sampleTime); + }; + + struct AudioListenerState + { + AudioParamState positionX{}; + AudioParamState positionY{}; + AudioParamState positionZ{}; + AudioParamState forwardX{}; + AudioParamState forwardY{}; + AudioParamState forwardZ{}; + AudioParamState upX{}; + AudioParamState upY{}; + AudioParamState upZ{}; + + AudioListenerState() + { + positionX.SetDefault(0.f); + positionY.SetDefault(0.f); + positionZ.SetDefault(0.f); + forwardX.SetDefault(0.f); + forwardY.SetDefault(0.f); + forwardZ.SetDefault(-1.f); + upX.SetDefault(0.f); + upY.SetDefault(1.f); + upZ.SetDefault(0.f); + } + }; + + struct AudioContextState : std::enable_shared_from_this + { + std::shared_ptr engine{}; + std::shared_ptr destination{}; + AudioListenerState listener{}; + std::atomic running{true}; + std::string state{"running"}; + }; + + struct GainNodeState : AudioNodeState + { + GainNodeState() + : AudioNodeState{AudioNodeKind::Gain} + { + gain.SetDefault(1.f); + } + + AudioParamState gain{}; + + protected: + void ProcessSample(float& left, float& right, const AudioListenerState&, double sampleTime) override; + }; + + struct StereoPannerNodeState : AudioNodeState + { + StereoPannerNodeState() + : AudioNodeState{AudioNodeKind::StereoPanner} + { + pan.SetDefault(0.f); + } + + AudioParamState pan{}; + + protected: + void ProcessSample(float& left, float& right, const AudioListenerState&, double sampleTime) override; + }; + + struct PannerNodeState : AudioNodeState + { + PannerNodeState() + : AudioNodeState{AudioNodeKind::Panner} + { + positionX.SetDefault(0.f); + positionY.SetDefault(0.f); + positionZ.SetDefault(0.f); + orientationX.SetDefault(1.f); + orientationY.SetDefault(0.f); + orientationZ.SetDefault(0.f); + } + + AudioParamState positionX{}; + AudioParamState positionY{}; + AudioParamState positionZ{}; + AudioParamState orientationX{}; + AudioParamState orientationY{}; + AudioParamState orientationZ{}; + + std::string panningModel{"equalpower"}; + std::string distanceModel{"inverse"}; + float refDistance{1.f}; + float maxDistance{10000.f}; + float rolloffFactor{1.f}; + float coneInnerAngle{360.f}; + float coneOuterAngle{360.f}; + float coneOuterGain{0.f}; + + protected: + void ProcessSample(float& left, float& right, const AudioListenerState& listener, double sampleTime) override; + }; + + struct AnalyserNodeState : AudioNodeState + { + AnalyserNodeState() + : AudioNodeState{AudioNodeKind::Analyser} + { + ResetRingBuffer(); + } + + uint32_t fftSize{2048}; + float minDecibels{-100.f}; + float maxDecibels{-30.f}; + float smoothingTimeConstant{0.8f}; + + std::vector ringBuffer{}; + size_t ringWriteIndex{0}; + bool ringFilled{false}; + mutable std::vector smoothedMagnitudes{}; + + uint32_t FrequencyBinCount() const + { + return fftSize / 2; + } + + void SetFftSize(uint32_t size); + void CopyTimeDomainSnapshot(std::vector& snapshot) const; + void ComputeFrequencyData(std::vector& magnitudesDb) const; + + protected: + void ProcessSample(float& left, float& right, const AudioListenerState&, double sampleTime) override; + + private: + void ResetRingBuffer(); + void WriteRingSample(float sample); + }; + + struct DestinationNodeState : AudioNodeState + { + DestinationNodeState() + : AudioNodeState{AudioNodeKind::Destination} + { + } + }; + + struct BufferSourceState : AudioNodeState + { + BufferSourceState() + : AudioNodeState{AudioNodeKind::BufferSource} + { + playbackRate.SetDefault(1.f); + detune.SetDefault(0.f); + } + + std::shared_ptr buffer{}; + bool loop{false}; + double loopStart{0.0}; + double loopEnd{0.0}; + AudioParamState playbackRate{}; + AudioParamState detune{}; + + std::atomic started{false}; + std::atomic playbackActive{false}; + std::atomic ended{false}; + double scheduledStartTime{0.0}; + double playbackFrame{0.0}; + + std::function onEnded{}; + + void Start(double when, double offset, double duration); + void Stop(); + bool Mix(float* mixL, float* mixR, uint32_t frameCount, uint32_t sampleRate, const AudioListenerState& listener, double blockStartTime); + }; + + void BindNodeState(Napi::Object object, const std::shared_ptr& state); + std::shared_ptr UnwrapNodeState(const Napi::Value& value); + void SetAudioNodeProperties(Napi::Object object, uint32_t numberOfInputs, uint32_t numberOfOutputs); + + // Minimal EventTarget used by AudioContext and AudioBufferSourceNode. + class EventTargetSupport + { + public: + void AddEventListener(const Napi::CallbackInfo& info); + void RemoveEventListener(const Napi::CallbackInfo& info); + void DispatchEvent(Napi::Env env, const std::string& type); + + private: + std::unordered_map> m_listeners{}; + }; +} diff --git a/Polyfills/WebAudio/Source/AudioParam.cpp b/Polyfills/WebAudio/Source/AudioParam.cpp new file mode 100644 index 000000000..1f886cd4a --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioParam.cpp @@ -0,0 +1,143 @@ +#include "AudioParam.h" + +#include + +#include + +namespace Babylon::Polyfills::Internal +{ + void NativeAudioParam::Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + Napi::Function func = DefineClass( + env, + JS_CONSTRUCTOR_NAME, + { + InstanceAccessor("value", &NativeAudioParam::GetValue, &NativeAudioParam::SetValue), + InstanceAccessor("defaultValue", &NativeAudioParam::GetDefaultValue, nullptr), + InstanceMethod("setValueAtTime", &NativeAudioParam::SetValueAtTime), + InstanceMethod("linearRampToValueAtTime", &NativeAudioParam::LinearRampToValueAtTime), + InstanceMethod("exponentialRampToValueAtTime", &NativeAudioParam::ExponentialRampToValueAtTime), + InstanceMethod("setValueCurveAtTime", &NativeAudioParam::SetValueCurveAtTime), + InstanceMethod("cancelScheduledValues", &NativeAudioParam::CancelScheduledValues), + }); + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); + env.Global().Set(JS_CONSTRUCTOR_NAME, func); + } + + Napi::Object NativeAudioParam::CreateInstance( + Napi::Env env, + const std::shared_ptr& state, + float defaultValue, + const std::function& getCurrentTime) + { + auto func = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_CONSTRUCTOR_NAME).As(); + auto object = func.New({}); + auto* param = NativeAudioParam::Unwrap(object); + param->m_state = state; + param->m_defaultValue = defaultValue; + param->m_getCurrentTime = getCurrentTime; + if (state) + { + state->SetDefault(defaultValue); + } + return object; + } + + NativeAudioParam::NativeAudioParam(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + , m_state{std::make_shared()} + { + } + + std::shared_ptr NativeAudioParam::State() const + { + return m_state; + } + + double NativeAudioParam::CurrentTime() const + { + return m_getCurrentTime ? m_getCurrentTime() : 0.0; + } + + void NativeAudioParam::SetImmediate(float value) + { + if (m_state) + { + m_state->SetImmediate(value, CurrentTime()); + } + } + + Napi::Value NativeAudioParam::GetValue(const Napi::CallbackInfo& info) + { + const float value = m_state ? m_state->GetAtTime(CurrentTime()) : 0.f; + return Napi::Number::New(info.Env(), value); + } + + void NativeAudioParam::SetValue(const Napi::CallbackInfo&, const Napi::Value& value) + { + SetImmediate(value.As().FloatValue()); + } + + Napi::Value NativeAudioParam::GetDefaultValue(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_defaultValue); + } + + Napi::Value NativeAudioParam::SetValueAtTime(const Napi::CallbackInfo& info) + { + if (m_state && info.Length() >= 2) + { + m_state->SetValueAtTime(info[0].As().FloatValue(), info[1].As().DoubleValue()); + } + return info.This(); + } + + Napi::Value NativeAudioParam::LinearRampToValueAtTime(const Napi::CallbackInfo& info) + { + if (m_state && info.Length() >= 2) + { + m_state->LinearRampToValueAtTime(info[0].As().FloatValue(), info[1].As().DoubleValue()); + } + return info.This(); + } + + Napi::Value NativeAudioParam::ExponentialRampToValueAtTime(const Napi::CallbackInfo& info) + { + if (m_state && info.Length() >= 2) + { + m_state->ExponentialRampToValueAtTime(info[0].As().FloatValue(), info[1].As().DoubleValue()); + } + return info.This(); + } + + Napi::Value NativeAudioParam::SetValueCurveAtTime(const Napi::CallbackInfo& info) + { + if (m_state && info.Length() >= 3 && info[0].IsTypedArray()) + { + auto values = info[0].As(); + const double startTime = info[1].As().DoubleValue(); + const double duration = info[2].As().DoubleValue(); + + std::vector curve(values.ElementLength()); + for (size_t index = 0; index < values.ElementLength(); ++index) + { + curve[index] = values[index]; + } + + m_state->SetValueCurveAtTime(curve, startTime, duration); + } + return info.This(); + } + + Napi::Value NativeAudioParam::CancelScheduledValues(const Napi::CallbackInfo& info) + { + if (m_state && info.Length() >= 1) + { + m_state->CancelScheduledValues(info[0].As().DoubleValue()); + } + return info.This(); + } +} diff --git a/Polyfills/WebAudio/Source/AudioParam.h b/Polyfills/WebAudio/Source/AudioParam.h new file mode 100644 index 000000000..cfb72065d --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioParam.h @@ -0,0 +1,43 @@ +#pragma once + +#include "AudioNode.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + class NativeAudioParam final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env); + static Napi::Object CreateInstance( + Napi::Env env, + const std::shared_ptr& state, + float defaultValue, + const std::function& getCurrentTime); + + explicit NativeAudioParam(const Napi::CallbackInfo& info); + + std::shared_ptr State() const; + + private: + static constexpr auto JS_CONSTRUCTOR_NAME = "AudioParam"; + + double CurrentTime() const; + + Napi::Value GetValue(const Napi::CallbackInfo& info); + void SetValue(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetDefaultValue(const Napi::CallbackInfo& info); + Napi::Value SetValueAtTime(const Napi::CallbackInfo& info); + Napi::Value LinearRampToValueAtTime(const Napi::CallbackInfo& info); + Napi::Value ExponentialRampToValueAtTime(const Napi::CallbackInfo& info); + Napi::Value SetValueCurveAtTime(const Napi::CallbackInfo& info); + Napi::Value CancelScheduledValues(const Napi::CallbackInfo& info); + + void SetImmediate(float value); + + std::shared_ptr m_state{}; + float m_defaultValue{0.f}; + std::function m_getCurrentTime{}; + }; +} diff --git a/Polyfills/WebAudio/Source/AudioParamState.cpp b/Polyfills/WebAudio/Source/AudioParamState.cpp new file mode 100644 index 000000000..dbaa552d7 --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioParamState.cpp @@ -0,0 +1,171 @@ +#include "AudioNode.h" + +#include +#include +#include + +namespace Babylon::Polyfills::Internal +{ + namespace + { + void InsertEvent(std::vector& events, AudioParamState::Event event) + { + auto it = std::lower_bound( + events.begin(), + events.end(), + event.time, + [](const AudioParamState::Event& existing, double time) { return existing.time < time; }); + + if (it != events.end() && it->time == event.time) + { + *it = std::move(event); + } + else + { + events.insert(it, std::move(event)); + } + } + } + + void AudioParamState::SetDefault(float value) + { + defaultValue = value; + } + + void AudioParamState::CancelScheduledValues(double startTime) + { + events.erase( + std::remove_if(events.begin(), events.end(), [startTime](const Event& event) { return event.time >= startTime; }), + events.end()); + } + + void AudioParamState::SetValueAtTime(float value, double time) + { + InsertEvent(events, Event{time, EventType::SetValue, value, {}, 0.0}); + } + + void AudioParamState::LinearRampToValueAtTime(float value, double time) + { + InsertEvent(events, Event{time, EventType::LinearRamp, value, {}, 0.0}); + } + + void AudioParamState::ExponentialRampToValueAtTime(float value, double time) + { + InsertEvent(events, Event{time, EventType::ExponentialRamp, value, {}, 0.0}); + } + + void AudioParamState::SetValueCurveAtTime(const std::vector& values, double startTime, double duration) + { + if (values.empty() || duration <= 0.0) + { + return; + } + + InsertEvent(events, Event{startTime, EventType::SetCurve, 0.f, values, duration}); + } + + void AudioParamState::SetImmediate(float value, double currentTime) + { + CancelScheduledValues(currentTime); + SetValueAtTime(value, currentTime); + } + + float AudioParamState::GetAtTime(double t) const + { + if (events.empty()) + { + return defaultValue; + } + + float value = defaultValue; + + for (size_t index = 0; index < events.size(); ++index) + { + const Event& event = events[index]; + + if (event.type == EventType::SetCurve) + { + if (t < event.time) + { + break; + } + + if (t <= event.time + event.duration) + { + const auto& curve = event.curve; + if (curve.empty()) + { + break; + } + + if (curve.size() == 1) + { + return curve[0]; + } + + const double progress = (t - event.time) / std::max(event.duration, 1.0e-12); + const double floatIndex = progress * static_cast(curve.size() - 1); + const size_t index0 = static_cast(floatIndex); + const size_t index1 = std::min(index0 + 1, curve.size() - 1); + const float fraction = static_cast(floatIndex - static_cast(index0)); + return curve[index0] + fraction * (curve[index1] - curve[index0]); + } + + value = event.curve.empty() ? value : event.curve.back(); + continue; + } + + if (t < event.time) + { + if (index == 0) + { + return value; + } + + const double startTime = events[index - 1].time; + const double endTime = event.time; + if (endTime <= startTime) + { + return value; + } + + const float alpha = static_cast((t - startTime) / (endTime - startTime)); + switch (event.type) + { + case EventType::SetValue: + return value; + case EventType::LinearRamp: + return value + alpha * (event.value - value); + case EventType::ExponentialRamp: + { + if (value == 0.f || event.value == 0.f || (value < 0.f) != (event.value < 0.f)) + { + return value + alpha * (event.value - value); + } + + return static_cast(value * std::pow(event.value / value, (t - startTime) / (endTime - startTime))); + } + default: + return value; + } + } + + switch (event.type) + { + case EventType::SetValue: + value = event.value; + break; + case EventType::LinearRamp: + value = event.value; + break; + case EventType::ExponentialRamp: + value = event.value; + break; + default: + break; + } + } + + return value; + } +} diff --git a/Polyfills/WebAudio/Source/AudioPlatform.cpp b/Polyfills/WebAudio/Source/AudioPlatform.cpp new file mode 100644 index 000000000..1770bd2fb --- /dev/null +++ b/Polyfills/WebAudio/Source/AudioPlatform.cpp @@ -0,0 +1,104 @@ +#include + +#include "AudioEngine.h" + +#include + +namespace Babylon::Polyfills +{ + namespace + { + std::mutex g_handlerMutex{}; + AudioPreparePlaybackHandler g_preparePlaybackHandler{}; + AudioInterruptionHandler g_interruptionHandler{}; + std::weak_ptr g_engineWeak{}; + + void WithEngine(const std::function&)>& fn) + { + std::shared_ptr engine; + { + std::lock_guard lock{g_handlerMutex}; + engine = g_engineWeak.lock(); + } + + if (engine) + { + fn(engine); + } + } + } + + void RegisterAudioPreparePlaybackHandler(AudioPreparePlaybackHandler handler) + { + std::lock_guard lock{g_handlerMutex}; + g_preparePlaybackHandler = std::move(handler); + } + + void RegisterAudioInterruptionHandler(AudioInterruptionHandler handler) + { + std::lock_guard lock{g_handlerMutex}; + g_interruptionHandler = std::move(handler); + } + + namespace Internal + { + void PreparePlayback() + { + AudioPreparePlaybackHandler handler; + { + std::lock_guard lock{g_handlerMutex}; + handler = g_preparePlaybackHandler; + } + + if (handler) + { + handler(); + } + } + + void ReleasePlayback() + { + } + + void NotifyAudioInterruption(bool began) + { + AudioInterruptionHandler handler; + { + std::lock_guard lock{g_handlerMutex}; + handler = g_interruptionHandler; + } + + if (handler) + { + handler(began); + } + + WithEngine([began](const std::shared_ptr& engine) { + if (began) + { + engine->SuspendDevice(); + } + else + { + engine->TryResumeAfterInterruption(); + } + }); + } + + void RegisterAudioEngine(const std::shared_ptr& engine) + { + std::lock_guard lock{g_handlerMutex}; + g_engineWeak = engine; + } + + void UnregisterAudioEngine(AudioEngine* engine) + { + std::lock_guard lock{g_handlerMutex}; + auto shared = g_engineWeak.lock(); + if (shared && shared.get() == engine) + { + g_engineWeak.reset(); + } + } + } +} diff --git a/Polyfills/WebAudio/Source/GainNode.cpp b/Polyfills/WebAudio/Source/GainNode.cpp new file mode 100644 index 000000000..2d26885f0 --- /dev/null +++ b/Polyfills/WebAudio/Source/GainNode.cpp @@ -0,0 +1,120 @@ +#include "GainNode.h" +#include "AudioContext.h" +#include "AudioEngine.h" +#include "AudioParam.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + void NativeGainNode::Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + Napi::Function func = DefineClass( + env, + JS_CONSTRUCTOR_NAME, + { + InstanceMethod("connect", &NativeGainNode::Connect), + InstanceMethod("disconnect", &NativeGainNode::Disconnect), + InstanceAccessor("gain", &NativeGainNode::GetGain, nullptr), + }); + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); + env.Global().Set(JS_CONSTRUCTOR_NAME, func); + } + + Napi::Object NativeGainNode::CreateInstance(Napi::Env env, const std::shared_ptr& context) + { + auto func = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_CONSTRUCTOR_NAME).As(); + // Pass a placeholder; InitializeState is called from constructor when context object is provided. + // For factory creation we construct then bind. + auto object = func.New({}); + auto* self = NativeGainNode::Unwrap(object); + self->InitializeState(context); + return object; + } + + NativeGainNode::NativeGainNode(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + { + if (info.Length() > 0 && info[0].IsObject()) + { + auto* context = NativeAudioContext::Unwrap(info[0].As()); + if (context) + { + info.This().As().Set("context", info[0]); + InitializeState(context->State()); + } + } + } + + void NativeGainNode::InitializeState(const std::shared_ptr& context) + { + m_state = std::make_shared(); + m_state->context = context; + BindNodeState(Value(), m_state); + SetAudioNodeProperties(Value(), 1, 1); + + auto getCurrentTime = MakeCurrentTimeGetter(context); + auto gain = NativeAudioParam::CreateInstance(Env(), std::shared_ptr(m_state, &m_state->gain), 1.f, getCurrentTime); + m_gain = Napi::Persistent(gain); + } + + Napi::Value NativeGainNode::Connect(const Napi::CallbackInfo& info) + { + auto destination = UnwrapNodeState(info[0]); + if (m_state && destination) + { + auto context = m_state->context.lock(); + if (context && context->engine) + { + std::lock_guard lock{context->engine->GraphMutex()}; + m_state->Connect(destination); + } + else + { + m_state->Connect(destination); + } + } + return info[0]; + } + + Napi::Value NativeGainNode::Disconnect(const Napi::CallbackInfo& info) + { + if (!m_state) + { + return info.Env().Undefined(); + } + + auto context = m_state->context.lock(); + auto lockGraph = [&](auto&& fn) { + if (context && context->engine) + { + std::lock_guard lock{context->engine->GraphMutex()}; + fn(); + } + else + { + fn(); + } + }; + + if (info.Length() > 0) + { + auto destination = UnwrapNodeState(info[0]); + lockGraph([&]() { m_state->Disconnect(destination); }); + } + else + { + lockGraph([&]() { m_state->Disconnect(); }); + } + + return info.Env().Undefined(); + } + + Napi::Value NativeGainNode::GetGain(const Napi::CallbackInfo&) + { + return m_gain.Value(); + } +} diff --git a/Polyfills/WebAudio/Source/GainNode.h b/Polyfills/WebAudio/Source/GainNode.h new file mode 100644 index 000000000..7be6b8560 --- /dev/null +++ b/Polyfills/WebAudio/Source/GainNode.h @@ -0,0 +1,29 @@ +#pragma once + +#include "AudioNode.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + class NativeGainNode final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env); + static Napi::Object CreateInstance(Napi::Env env, const std::shared_ptr& context); + + explicit NativeGainNode(const Napi::CallbackInfo& info); + + private: + static constexpr auto JS_CONSTRUCTOR_NAME = "GainNode"; + + void InitializeState(const std::shared_ptr& context); + + Napi::Value Connect(const Napi::CallbackInfo& info); + Napi::Value Disconnect(const Napi::CallbackInfo& info); + Napi::Value GetGain(const Napi::CallbackInfo& info); + + std::shared_ptr m_state{}; + Napi::ObjectReference m_gain{}; + }; +} diff --git a/Polyfills/WebAudio/Source/HTMLAudioElement.cpp b/Polyfills/WebAudio/Source/HTMLAudioElement.cpp new file mode 100644 index 000000000..e719e47be --- /dev/null +++ b/Polyfills/WebAudio/Source/HTMLAudioElement.cpp @@ -0,0 +1,120 @@ +#include "HTMLAudioElement.h" + +#include + +#include +#include +#include + +namespace Babylon::Polyfills::Internal +{ + namespace + { + bool IsSupportedMimeType(std::string mimeType) + { + std::transform(mimeType.begin(), mimeType.end(), mimeType.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + + return mimeType.find("audio/wav") != std::string::npos + || mimeType.find("audio/wave") != std::string::npos + || mimeType.find("audio/x-wav") != std::string::npos + || mimeType.find("audio/mpeg") != std::string::npos + || mimeType.find("audio/mp3") != std::string::npos + || mimeType.find("audio/mp4") != std::string::npos + || mimeType.find("audio/aac") != std::string::npos + || mimeType.find("audio/flac") != std::string::npos + || mimeType.find("audio/ogg") != std::string::npos + || mimeType.find("audio/webm") != std::string::npos + || mimeType.find("audio/ac3") != std::string::npos; + } + } + + void NativeHTMLAudioElement::Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + Napi::Function func = DefineClass( + env, + JS_CONSTRUCTOR_NAME, + { + InstanceMethod("canPlayType", &NativeHTMLAudioElement::CanPlayType), + InstanceMethod("play", &NativeHTMLAudioElement::Play), + InstanceMethod("pause", &NativeHTMLAudioElement::Pause), + InstanceAccessor("controls", &NativeHTMLAudioElement::GetControls, &NativeHTMLAudioElement::SetControls), + InstanceAccessor("preload", &NativeHTMLAudioElement::GetPreload, &NativeHTMLAudioElement::SetPreload), + InstanceAccessor("loop", &NativeHTMLAudioElement::GetLoop, &NativeHTMLAudioElement::SetLoop), + InstanceAccessor("src", &NativeHTMLAudioElement::GetSrc, &NativeHTMLAudioElement::SetSrc), + }); + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); + env.Global().Set(JS_CONSTRUCTOR_NAME, func); + } + + NativeHTMLAudioElement::NativeHTMLAudioElement(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + { + } + + Napi::Value NativeHTMLAudioElement::CanPlayType(const Napi::CallbackInfo& info) + { + if (info.Length() < 1 || !info[0].IsString()) + { + return Napi::String::New(info.Env(), ""); + } + + const auto mimeType = info[0].As().Utf8Value(); + return Napi::String::New(info.Env(), IsSupportedMimeType(mimeType) ? "probably" : ""); + } + + Napi::Value NativeHTMLAudioElement::Play(const Napi::CallbackInfo& info) + { + auto deferred = Napi::Promise::Deferred::New(info.Env()); + deferred.Resolve(info.Env().Undefined()); + return deferred.Promise(); + } + + void NativeHTMLAudioElement::Pause(const Napi::CallbackInfo&) + { + } + + Napi::Value NativeHTMLAudioElement::GetControls(const Napi::CallbackInfo& info) + { + return Napi::Boolean::New(info.Env(), m_controls); + } + + void NativeHTMLAudioElement::SetControls(const Napi::CallbackInfo&, const Napi::Value& value) + { + m_controls = value.ToBoolean(); + } + + Napi::Value NativeHTMLAudioElement::GetPreload(const Napi::CallbackInfo& info) + { + return Napi::String::New(info.Env(), m_preload); + } + + void NativeHTMLAudioElement::SetPreload(const Napi::CallbackInfo&, const Napi::Value& value) + { + m_preload = value.ToString().Utf8Value(); + } + + Napi::Value NativeHTMLAudioElement::GetLoop(const Napi::CallbackInfo& info) + { + return Napi::Boolean::New(info.Env(), m_loop); + } + + void NativeHTMLAudioElement::SetLoop(const Napi::CallbackInfo&, const Napi::Value& value) + { + m_loop = value.ToBoolean(); + } + + Napi::Value NativeHTMLAudioElement::GetSrc(const Napi::CallbackInfo& info) + { + return Napi::String::New(info.Env(), m_src); + } + + void NativeHTMLAudioElement::SetSrc(const Napi::CallbackInfo&, const Napi::Value& value) + { + m_src = value.ToString().Utf8Value(); + } +} diff --git a/Polyfills/WebAudio/Source/HTMLAudioElement.h b/Polyfills/WebAudio/Source/HTMLAudioElement.h new file mode 100644 index 000000000..ecdab4f58 --- /dev/null +++ b/Polyfills/WebAudio/Source/HTMLAudioElement.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include + +namespace Babylon::Polyfills::Internal +{ + class NativeHTMLAudioElement final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env); + + explicit NativeHTMLAudioElement(const Napi::CallbackInfo& info); + + private: + static constexpr auto JS_CONSTRUCTOR_NAME = "Audio"; + + Napi::Value CanPlayType(const Napi::CallbackInfo& info); + Napi::Value Play(const Napi::CallbackInfo& info); + void Pause(const Napi::CallbackInfo& info); + + Napi::Value GetControls(const Napi::CallbackInfo& info); + void SetControls(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetPreload(const Napi::CallbackInfo& info); + void SetPreload(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetLoop(const Napi::CallbackInfo& info); + void SetLoop(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetSrc(const Napi::CallbackInfo& info); + void SetSrc(const Napi::CallbackInfo& info, const Napi::Value& value); + + bool m_controls{false}; + std::string m_preload{"auto"}; + bool m_loop{false}; + std::string m_src{}; + }; +} diff --git a/Polyfills/WebAudio/Source/PannerNode.cpp b/Polyfills/WebAudio/Source/PannerNode.cpp new file mode 100644 index 000000000..6ec64f0cf --- /dev/null +++ b/Polyfills/WebAudio/Source/PannerNode.cpp @@ -0,0 +1,261 @@ +#include "PannerNode.h" +#include "AudioContext.h" +#include "AudioEngine.h" +#include "AudioParam.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + namespace + { + void LockAnd(const std::shared_ptr& context, auto&& fn) + { + if (context && context->engine) + { + std::lock_guard lock{context->engine->GraphMutex()}; + fn(); + } + else + { + fn(); + } + } + } + + void NativePannerNode::Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + Napi::Function func = DefineClass( + env, + JS_CONSTRUCTOR_NAME, + { + InstanceMethod("connect", &NativePannerNode::Connect), + InstanceMethod("disconnect", &NativePannerNode::Disconnect), + InstanceMethod("setPosition", &NativePannerNode::SetPosition), + InstanceMethod("setOrientation", &NativePannerNode::SetOrientation), + InstanceAccessor("positionX", &NativePannerNode::GetPositionX, nullptr), + InstanceAccessor("positionY", &NativePannerNode::GetPositionY, nullptr), + InstanceAccessor("positionZ", &NativePannerNode::GetPositionZ, nullptr), + InstanceAccessor("orientationX", &NativePannerNode::GetOrientationX, nullptr), + InstanceAccessor("orientationY", &NativePannerNode::GetOrientationY, nullptr), + InstanceAccessor("orientationZ", &NativePannerNode::GetOrientationZ, nullptr), + InstanceAccessor("panningModel", &NativePannerNode::GetPanningModel, &NativePannerNode::SetPanningModel), + InstanceAccessor("distanceModel", &NativePannerNode::GetDistanceModel, &NativePannerNode::SetDistanceModel), + InstanceAccessor("refDistance", &NativePannerNode::GetRefDistance, &NativePannerNode::SetRefDistance), + InstanceAccessor("maxDistance", &NativePannerNode::GetMaxDistance, &NativePannerNode::SetMaxDistance), + InstanceAccessor("rolloffFactor", &NativePannerNode::GetRolloffFactor, &NativePannerNode::SetRolloffFactor), + InstanceAccessor("coneInnerAngle", &NativePannerNode::GetConeInnerAngle, &NativePannerNode::SetConeInnerAngle), + InstanceAccessor("coneOuterAngle", &NativePannerNode::GetConeOuterAngle, &NativePannerNode::SetConeOuterAngle), + InstanceAccessor("coneOuterGain", &NativePannerNode::GetConeOuterGain, &NativePannerNode::SetConeOuterGain), + }); + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); + env.Global().Set(JS_CONSTRUCTOR_NAME, func); + } + + Napi::Object NativePannerNode::CreateInstance(Napi::Env env, const std::shared_ptr& context) + { + auto func = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_CONSTRUCTOR_NAME).As(); + auto object = func.New({}); + NativePannerNode::Unwrap(object)->InitializeState(context); + return object; + } + + NativePannerNode::NativePannerNode(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + { + if (info.Length() > 0 && info[0].IsObject()) + { + auto* context = NativeAudioContext::Unwrap(info[0].As()); + if (context) + { + info.This().As().Set("context", info[0]); + InitializeState(context->State()); + } + } + } + + void NativePannerNode::InitializeState(const std::shared_ptr& context) + { + m_state = std::make_shared(); + m_state->context = context; + BindNodeState(Value(), m_state); + SetAudioNodeProperties(Value(), 1, 1); + + auto getCurrentTime = MakeCurrentTimeGetter(context); + m_positionX = Napi::Persistent(NativeAudioParam::CreateInstance(Env(), std::shared_ptr(m_state, &m_state->positionX), 0.f, getCurrentTime)); + m_positionY = Napi::Persistent(NativeAudioParam::CreateInstance(Env(), std::shared_ptr(m_state, &m_state->positionY), 0.f, getCurrentTime)); + m_positionZ = Napi::Persistent(NativeAudioParam::CreateInstance(Env(), std::shared_ptr(m_state, &m_state->positionZ), 0.f, getCurrentTime)); + m_orientationX = Napi::Persistent(NativeAudioParam::CreateInstance(Env(), std::shared_ptr(m_state, &m_state->orientationX), 1.f, getCurrentTime)); + m_orientationY = Napi::Persistent(NativeAudioParam::CreateInstance(Env(), std::shared_ptr(m_state, &m_state->orientationY), 0.f, getCurrentTime)); + m_orientationZ = Napi::Persistent(NativeAudioParam::CreateInstance(Env(), std::shared_ptr(m_state, &m_state->orientationZ), 0.f, getCurrentTime)); + } + + Napi::Value NativePannerNode::Connect(const Napi::CallbackInfo& info) + { + auto destination = UnwrapNodeState(info[0]); + if (m_state && destination) + { + LockAnd(m_state->context.lock(), [&]() { m_state->Connect(destination); }); + } + return info[0]; + } + + Napi::Value NativePannerNode::Disconnect(const Napi::CallbackInfo& info) + { + if (!m_state) + { + return info.Env().Undefined(); + } + + if (info.Length() > 0) + { + auto destination = UnwrapNodeState(info[0]); + LockAnd(m_state->context.lock(), [&]() { m_state->Disconnect(destination); }); + } + else + { + LockAnd(m_state->context.lock(), [&]() { m_state->Disconnect(); }); + } + return info.Env().Undefined(); + } + + void NativePannerNode::SetPosition(const Napi::CallbackInfo& info) + { + if (!m_state || info.Length() < 3) + { + return; + } + auto context = m_state->context.lock(); + const double currentTime = context && context->engine ? context->engine->CurrentTime() : 0.0; + m_state->positionX.SetImmediate(info[0].As().FloatValue(), currentTime); + m_state->positionY.SetImmediate(info[1].As().FloatValue(), currentTime); + m_state->positionZ.SetImmediate(info[2].As().FloatValue(), currentTime); + } + + void NativePannerNode::SetOrientation(const Napi::CallbackInfo& info) + { + if (!m_state || info.Length() < 3) + { + return; + } + auto context = m_state->context.lock(); + const double currentTime = context && context->engine ? context->engine->CurrentTime() : 0.0; + m_state->orientationX.SetImmediate(info[0].As().FloatValue(), currentTime); + m_state->orientationY.SetImmediate(info[1].As().FloatValue(), currentTime); + m_state->orientationZ.SetImmediate(info[2].As().FloatValue(), currentTime); + } + + Napi::Value NativePannerNode::GetPositionX(const Napi::CallbackInfo&) { return m_positionX.Value(); } + Napi::Value NativePannerNode::GetPositionY(const Napi::CallbackInfo&) { return m_positionY.Value(); } + Napi::Value NativePannerNode::GetPositionZ(const Napi::CallbackInfo&) { return m_positionZ.Value(); } + Napi::Value NativePannerNode::GetOrientationX(const Napi::CallbackInfo&) { return m_orientationX.Value(); } + Napi::Value NativePannerNode::GetOrientationY(const Napi::CallbackInfo&) { return m_orientationY.Value(); } + Napi::Value NativePannerNode::GetOrientationZ(const Napi::CallbackInfo&) { return m_orientationZ.Value(); } + + Napi::Value NativePannerNode::GetPanningModel(const Napi::CallbackInfo& info) + { + return Napi::String::New(info.Env(), m_state ? m_state->panningModel : "equalpower"); + } + + void NativePannerNode::SetPanningModel(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->panningModel = value.ToString().Utf8Value(); + } + } + + Napi::Value NativePannerNode::GetDistanceModel(const Napi::CallbackInfo& info) + { + return Napi::String::New(info.Env(), m_state ? m_state->distanceModel : "inverse"); + } + + void NativePannerNode::SetDistanceModel(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->distanceModel = value.ToString().Utf8Value(); + } + } + + Napi::Value NativePannerNode::GetRefDistance(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->refDistance : 1.f); + } + + void NativePannerNode::SetRefDistance(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->refDistance = value.ToNumber().FloatValue(); + } + } + + Napi::Value NativePannerNode::GetMaxDistance(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->maxDistance : 10000.f); + } + + void NativePannerNode::SetMaxDistance(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->maxDistance = value.ToNumber().FloatValue(); + } + } + + Napi::Value NativePannerNode::GetRolloffFactor(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->rolloffFactor : 1.f); + } + + void NativePannerNode::SetRolloffFactor(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->rolloffFactor = value.ToNumber().FloatValue(); + } + } + + Napi::Value NativePannerNode::GetConeInnerAngle(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->coneInnerAngle : 360.f); + } + + void NativePannerNode::SetConeInnerAngle(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->coneInnerAngle = value.ToNumber().FloatValue(); + } + } + + Napi::Value NativePannerNode::GetConeOuterAngle(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->coneOuterAngle : 360.f); + } + + void NativePannerNode::SetConeOuterAngle(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->coneOuterAngle = value.ToNumber().FloatValue(); + } + } + + Napi::Value NativePannerNode::GetConeOuterGain(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_state ? m_state->coneOuterGain : 0.f); + } + + void NativePannerNode::SetConeOuterGain(const Napi::CallbackInfo&, const Napi::Value& value) + { + if (m_state) + { + m_state->coneOuterGain = value.ToNumber().FloatValue(); + } + } +} diff --git a/Polyfills/WebAudio/Source/PannerNode.h b/Polyfills/WebAudio/Source/PannerNode.h new file mode 100644 index 000000000..02620ef78 --- /dev/null +++ b/Polyfills/WebAudio/Source/PannerNode.h @@ -0,0 +1,59 @@ +#pragma once + +#include "AudioNode.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + class NativePannerNode final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env); + static Napi::Object CreateInstance(Napi::Env env, const std::shared_ptr& context); + + explicit NativePannerNode(const Napi::CallbackInfo& info); + + private: + static constexpr auto JS_CONSTRUCTOR_NAME = "PannerNode"; + + void InitializeState(const std::shared_ptr& context); + + Napi::Value Connect(const Napi::CallbackInfo& info); + Napi::Value Disconnect(const Napi::CallbackInfo& info); + void SetPosition(const Napi::CallbackInfo& info); + void SetOrientation(const Napi::CallbackInfo& info); + + Napi::Value GetPositionX(const Napi::CallbackInfo& info); + Napi::Value GetPositionY(const Napi::CallbackInfo& info); + Napi::Value GetPositionZ(const Napi::CallbackInfo& info); + Napi::Value GetOrientationX(const Napi::CallbackInfo& info); + Napi::Value GetOrientationY(const Napi::CallbackInfo& info); + Napi::Value GetOrientationZ(const Napi::CallbackInfo& info); + + Napi::Value GetPanningModel(const Napi::CallbackInfo& info); + void SetPanningModel(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetDistanceModel(const Napi::CallbackInfo& info); + void SetDistanceModel(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetRefDistance(const Napi::CallbackInfo& info); + void SetRefDistance(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetMaxDistance(const Napi::CallbackInfo& info); + void SetMaxDistance(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetRolloffFactor(const Napi::CallbackInfo& info); + void SetRolloffFactor(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetConeInnerAngle(const Napi::CallbackInfo& info); + void SetConeInnerAngle(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetConeOuterAngle(const Napi::CallbackInfo& info); + void SetConeOuterAngle(const Napi::CallbackInfo& info, const Napi::Value& value); + Napi::Value GetConeOuterGain(const Napi::CallbackInfo& info); + void SetConeOuterGain(const Napi::CallbackInfo& info, const Napi::Value& value); + + std::shared_ptr m_state{}; + Napi::ObjectReference m_positionX{}; + Napi::ObjectReference m_positionY{}; + Napi::ObjectReference m_positionZ{}; + Napi::ObjectReference m_orientationX{}; + Napi::ObjectReference m_orientationY{}; + Napi::ObjectReference m_orientationZ{}; + }; +} diff --git a/Polyfills/WebAudio/Source/StereoPannerNode.cpp b/Polyfills/WebAudio/Source/StereoPannerNode.cpp new file mode 100644 index 000000000..1d9824ecf --- /dev/null +++ b/Polyfills/WebAudio/Source/StereoPannerNode.cpp @@ -0,0 +1,108 @@ +#include "StereoPannerNode.h" +#include "AudioContext.h" +#include "AudioEngine.h" +#include "AudioParam.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + namespace + { + void LockAnd(const std::shared_ptr& context, auto&& fn) + { + if (context && context->engine) + { + std::lock_guard lock{context->engine->GraphMutex()}; + fn(); + } + else + { + fn(); + } + } + } + + void NativeStereoPannerNode::Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + Napi::Function func = DefineClass( + env, + JS_CONSTRUCTOR_NAME, + { + InstanceMethod("connect", &NativeStereoPannerNode::Connect), + InstanceMethod("disconnect", &NativeStereoPannerNode::Disconnect), + InstanceAccessor("pan", &NativeStereoPannerNode::GetPan, nullptr), + }); + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); + env.Global().Set(JS_CONSTRUCTOR_NAME, func); + } + + Napi::Object NativeStereoPannerNode::CreateInstance(Napi::Env env, const std::shared_ptr& context) + { + auto func = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_CONSTRUCTOR_NAME).As(); + auto object = func.New({}); + NativeStereoPannerNode::Unwrap(object)->InitializeState(context); + return object; + } + + NativeStereoPannerNode::NativeStereoPannerNode(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + { + if (info.Length() > 0 && info[0].IsObject()) + { + auto* context = NativeAudioContext::Unwrap(info[0].As()); + if (context) + { + info.This().As().Set("context", info[0]); + InitializeState(context->State()); + } + } + } + + void NativeStereoPannerNode::InitializeState(const std::shared_ptr& context) + { + m_state = std::make_shared(); + m_state->context = context; + BindNodeState(Value(), m_state); + SetAudioNodeProperties(Value(), 1, 1); + auto getCurrentTime = MakeCurrentTimeGetter(context); + m_pan = Napi::Persistent(NativeAudioParam::CreateInstance(Env(), std::shared_ptr(m_state, &m_state->pan), 0.f, getCurrentTime)); + } + + Napi::Value NativeStereoPannerNode::Connect(const Napi::CallbackInfo& info) + { + auto destination = UnwrapNodeState(info[0]); + if (m_state && destination) + { + LockAnd(m_state->context.lock(), [&]() { m_state->Connect(destination); }); + } + return info[0]; + } + + Napi::Value NativeStereoPannerNode::Disconnect(const Napi::CallbackInfo& info) + { + if (!m_state) + { + return info.Env().Undefined(); + } + + if (info.Length() > 0) + { + auto destination = UnwrapNodeState(info[0]); + LockAnd(m_state->context.lock(), [&]() { m_state->Disconnect(destination); }); + } + else + { + LockAnd(m_state->context.lock(), [&]() { m_state->Disconnect(); }); + } + return info.Env().Undefined(); + } + + Napi::Value NativeStereoPannerNode::GetPan(const Napi::CallbackInfo&) + { + return m_pan.Value(); + } +} diff --git a/Polyfills/WebAudio/Source/StereoPannerNode.h b/Polyfills/WebAudio/Source/StereoPannerNode.h new file mode 100644 index 000000000..43a6a5d45 --- /dev/null +++ b/Polyfills/WebAudio/Source/StereoPannerNode.h @@ -0,0 +1,29 @@ +#pragma once + +#include "AudioNode.h" + +#include + +namespace Babylon::Polyfills::Internal +{ + class NativeStereoPannerNode final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env); + static Napi::Object CreateInstance(Napi::Env env, const std::shared_ptr& context); + + explicit NativeStereoPannerNode(const Napi::CallbackInfo& info); + + private: + static constexpr auto JS_CONSTRUCTOR_NAME = "StereoPannerNode"; + + void InitializeState(const std::shared_ptr& context); + + Napi::Value Connect(const Napi::CallbackInfo& info); + Napi::Value Disconnect(const Napi::CallbackInfo& info); + Napi::Value GetPan(const Napi::CallbackInfo& info); + + std::shared_ptr m_state{}; + Napi::ObjectReference m_pan{}; + }; +} diff --git a/Polyfills/WebAudio/Source/WebAudio.cpp b/Polyfills/WebAudio/Source/WebAudio.cpp new file mode 100644 index 000000000..38b33d89d --- /dev/null +++ b/Polyfills/WebAudio/Source/WebAudio.cpp @@ -0,0 +1,181 @@ +#include + +#include "AnalyserNode.h" +#include "AudioBuffer.h" +#include "AudioBufferSourceNode.h" +#include "AudioContext.h" +#include "AudioDestinationNode.h" +#include "AudioEngine.h" +#include "AudioListener.h" +#include "AudioParam.h" +#include "GainNode.h" +#include "HTMLAudioElement.h" +#include "PannerNode.h" +#include "StereoPannerNode.h" + +#include + +namespace +{ + constexpr auto JS_WEBAUDIO_NAME = "_WebAudioImpl"; +} + +namespace Babylon::Polyfills +{ + class WebAudio::Impl final : public std::enable_shared_from_this + { + public: + explicit Impl(Napi::Env env) + : m_engine{Internal::AudioEngine::Create(JsRuntime::GetFromJavaScript(env))} + { + JsRuntime::NativeObject::GetFromJavaScript(env) + .Set(JS_WEBAUDIO_NAME, Napi::External::New(env, this)); + } + + static Impl& GetFromJavaScript(Napi::Env env) + { + return *JsRuntime::NativeObject::GetFromJavaScript(env) + .Get(JS_WEBAUDIO_NAME) + .As>() + .Data(); + } + + void SuspendPlayback(); + void ResumePlayback(); + void ShutdownPlayback(); + + const std::shared_ptr& Engine() const + { + return m_engine; + } + + private: + std::shared_ptr m_engine{}; + }; + + void WebAudio::Impl::SuspendPlayback() + { + if (m_engine) + { + m_engine->SuspendDevice(); + m_engine->SetClockSuspended(true); + } + } + + void WebAudio::Impl::ShutdownPlayback() + { + if (m_engine) + { + m_engine->PrepareForShutdown(); + } + } + + void WebAudio::Impl::ResumePlayback() + { + if (m_engine && m_engine->HasRunningContext()) + { + m_engine->EnsureDeviceRunning(); + m_engine->ResumeDevice(); + m_engine->SetClockSuspended(false); + } + } + + namespace + { + Napi::Value DocumentCreateElement(const Napi::CallbackInfo& info) + { + if (info.Length() > 0 && info[0].IsString() && info[0].As().Utf8Value() == "audio") + { + auto audioCtor = info.Env().Global().Get("Audio").As(); + return audioCtor.New({}); + } + + return Napi::Object::New(info.Env()); + } + + void InstallDocumentStub(Napi::Env env) + { + if (env.Global().Has("document")) + { + return; + } + + auto document = Napi::Object::New(env); + document.Set( + "addEventListener", + Napi::Function::New(env, [](const Napi::CallbackInfo&) {}, "addEventListener")); + document.Set( + "removeEventListener", + Napi::Function::New(env, [](const Napi::CallbackInfo&) {}, "removeEventListener")); + document.Set("createElement", Napi::Function::New(env, DocumentCreateElement, "createElement")); + env.Global().Set("document", document); + } + } +} + +// Expose Impl lookup to internal translation units. +namespace Babylon::Polyfills::Internal +{ + std::shared_ptr GetAudioEngineFromJavaScript(Napi::Env env) + { + return WebAudio::Impl::GetFromJavaScript(env).Engine(); + } +} + +namespace Babylon::Polyfills +{ + + WebAudio::WebAudio(std::shared_ptr impl) + : m_impl{std::move(impl)} + { + } + + WebAudio::~WebAudio() + { + ShutdownPlayback(); + } + + WebAudio BABYLON_API WebAudio::Initialize(Napi::Env env) + { + auto impl{std::make_shared(env)}; + + Internal::NativeAudioParam::Initialize(env); + Internal::NativeAudioBuffer::Initialize(env); + Internal::NativeAudioListener::Initialize(env); + Internal::NativeAudioDestinationNode::Initialize(env); + Internal::NativeGainNode::Initialize(env); + Internal::NativePannerNode::Initialize(env); + Internal::NativeStereoPannerNode::Initialize(env); + Internal::NativeAnalyserNode::Initialize(env); + Internal::NativeAudioBufferSourceNode::Initialize(env); + Internal::NativeAudioContext::Initialize(env); + Internal::NativeHTMLAudioElement::Initialize(env); + InstallDocumentStub(env); + + return {impl}; + } + + void WebAudio::SuspendPlayback() const + { + if (m_impl) + { + m_impl->SuspendPlayback(); + } + } + + void WebAudio::ResumePlayback() const + { + if (m_impl) + { + m_impl->ResumePlayback(); + } + } + + void WebAudio::ShutdownPlayback() const + { + if (m_impl) + { + m_impl->ShutdownPlayback(); + } + } +} diff --git a/Polyfills/WebAudio/Source/miniaudio_impl.cpp b/Polyfills/WebAudio/Source/miniaudio_impl.cpp new file mode 100644 index 000000000..662e2b2eb --- /dev/null +++ b/Polyfills/WebAudio/Source/miniaudio_impl.cpp @@ -0,0 +1,2 @@ +#define MINIAUDIO_IMPLEMENTATION +#include diff --git a/Polyfills/WebAudio/readme.md b/Polyfills/WebAudio/readme.md new file mode 100644 index 000000000..f96ba7ab3 --- /dev/null +++ b/Polyfills/WebAudio/readme.md @@ -0,0 +1,113 @@ +# WebAudio + +Implements the subset of the Web Audio API used by Babylon.js, backed by [miniaudio](https://github.com/mackron/miniaudio). + +## Platform support + +| Platform | Status | +|----------|--------| +| Windows / macOS / Linux (desktop) | Validated via unit tests (null backend) and Playground `audio.js` (real device). Suitable for buffer-based Babylon audio. | +| iOS / Android | Built into Embedding with `AVAudioSession` (iOS) and Android audio-focus hooks. Playground loads `audio.js` on both platforms for on-device validation. | +| CI / headless | Use `BABYLON_NATIVE_WEBAUDIO_BACKEND=null` so graph logic runs without a playback device. | + +Enable with `BABYLON_NATIVE_POLYFILL_WEBAUDIO=ON` (default). The polyfill is initialized from `Embedding` when linked. + +## Lifecycle (browser-aligned) + +- `AudioContext` starts in the `"suspended"` state; call `resume()` after a user gesture on mobile. +- The native playback device is opened lazily on the first successful `resume()`. +- `Embedding::Runtime::Suspend/Resume` pauses and resumes the shared audio device when the host app backgrounds. +- iOS activates `AVAudioSession` and handles interruptions; Android requests audio focus before playback. +- A minimal `document` stub (`createElement("audio")`, no-op event listeners) is installed automatically when `globalThis.document` is undefined. + +## Supported surface + +- `AudioContext` / `webkitAudioContext` +- `AudioBuffer`, `AudioBufferSourceNode`, `GainNode`, `PannerNode`, `StereoPannerNode` +- `AudioListener`, `AudioParam` (automation timeline: `setValueAtTime`, ramps, `setValueCurveAtTime`, `cancelScheduledValues`) +- `AnalyserNode` (time-domain snapshot + Hann-windowed FFT frequency data with smoothing) +- `AudioDestinationNode` +- `Audio` (`canPlayType` for format detection) +- `AudioBufferSourceNode.start(when)` scheduled start times + +Decoding supports the formats provided by miniaudio (wav, mp3, flac, ogg, and others depending on build). + +Set `BABYLON_NATIVE_WEBAUDIO_BACKEND=null` to force a software clock for headless CI (no hardware device). When no playback device is available at runtime, the polyfill falls back to miniaudio's null backend automatically so construction never crashes. + +## Babylon.js coverage + +Works for the paths Babylon actually uses on native: + +- Legacy `BABYLON.Sound` with `ArrayBuffer` / `decodeAudioData` +- v9 `CreateAudioEngineAsync` + `CreateSoundAsync` (when present in the Babylon build) +- 3D spatial audio (`PannerNode`, `AudioListener`, distance attenuation) +- Volume / pitch ramps via `AudioParam` automation +- Audio analysers (`getByteFrequencyData`, `getFloatFrequencyData`, time-domain getters) + +## Not supported (yet) + +- `OfflineAudioContext` +- `MediaElementAudioSourceNode` / `MediaStream` sources +- URL streaming via `HTMLAudioElement` (`play` / `pause` are stubs; use `decodeAudioData` instead) +- Full Web Audio spec (`setTargetAtTime`, HRTF panning, `AudioWorklet`, etc.) + +## App integration + +Embedding hosts that link `BabylonNativeEmbedding` receive platform audio-session setup automatically. No per-app `document` stub is required. + +For custom hosts that initialize the polyfill directly, `WebAudio::Initialize` still installs the built-in `document` stub when needed. + +On mobile, ensure the first audible playback follows a user gesture and call `AudioContext.resume()` from that gesture if autoplay is blocked. See `Apps/Playground/Scripts/audio.js` for a working example (pointer tap resumes audio). + +## Playground demo + +Playground loads `app:///Scripts/audio.js` on desktop, iOS, and Android. The script generates an in-memory beep WAV and plays it through legacy `BABYLON.Sound` and/or `CreateSoundAsync` while orbiting a spatialized source around the camera. + +## Mobile validation checklist + +Use this checklist on physical devices with the speaker unmuted. + +### Build and wiring + +- [ ] `BABYLON_NATIVE_POLYFILL_WEBAUDIO=ON` in the app CMake configuration +- [ ] App links `Embedding` / `BabylonNativeEmbedding` so `WebAudio::Initialize` and platform hooks run +- [ ] JavaScript bundle includes Babylon audio code paths you intend to use (`Sound` and/or `CreateSoundAsync`) + +### JavaScript bootstrap + +- [ ] `typeof AudioContext !== "undefined"` after runtime startup +- [ ] `await new AudioContext().resume()` resolves without error after a user gesture + +### Functional smoke tests + +- [ ] Decode a small in-memory WAV via `decodeAudioData` and play through `bufferSource → gain → destination` +- [ ] Legacy `new BABYLON.Sound(name, arrayBuffer, scene, null, { autoplay: true })` is audible +- [ ] v9 `CreateAudioEngineAsync` + `CreateSoundAsync` is audible (if used) +- [ ] Moving the listener / camera changes spatialized sound level +- [ ] `ended` fires when a short non-looping buffer finishes +- [ ] App survives background/foreground without crashing (audio may stay suspended until user interaction) + +### Platform-specific + +**iOS** + +- [ ] `AVAudioSession` category `playback` activates on first play (handled by Embedding) +- [ ] Audio plays after device silent switch is on +- [ ] First play after cold start works after user gesture + `AudioContext.resume()` +- [ ] No audio glitches when rotating, locking, or receiving a phone call + +**Android** + +- [ ] `RECORD_AUDIO` is **not** required for playback-only apps +- [ ] Audio focus handled (pause on focus loss, resume when regained if contexts are running) +- [ ] Playback works on API 24+ targets used by the app +- [ ] First play after cold start works after user interaction on restrictive OEM builds + +### Regression / CI + +- [ ] `BABYLON_NATIVE_WEBAUDIO_BACKEND=null ./UnitTests --gtest_filter=JavaScript.All` passes on your CI host + +### Known gaps to watch for + +- Shared engine clock across `AudioContext` instances (same as a single native device) +- Panner uses simplified equal-power azimuth, not browser HRTF From 5ff720403d8d28e9f6adf800d42dfa1d4b5bf6b7 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Fri, 3 Jul 2026 12:36:12 -0700 Subject: [PATCH 2/6] Integrate WebAudio into Embedding with mobile audio session hooks. Initialize the polyfill from Runtime, suspend/resume playback with app lifecycle, and wire iOS AVAudioSession plus Android audio-focus handling. --- .../babylonjs/embedding/AudioFocusHelper.java | 67 +++++++++++++++++ Embedding/Android/CMakeLists.txt | 5 +- .../src/main/cpp/AudioPlatformAndroid.cpp | 73 +++++++++++++++++++ .../src/main/cpp/BabylonNativeEmbedding.cpp | 6 ++ Embedding/Apple/CMakeLists.txt | 8 +- Embedding/Apple/Source/AudioSession.mm | 50 +++++++++++++ Embedding/CMakeLists.txt | 5 ++ .../Babylon/Embedding/Android/AudioPlatform.h | 8 ++ Embedding/Source/Runtime.cpp | 28 +++++++ Embedding/Source/RuntimeImpl.h | 8 ++ 10 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 Apps/Playground/Android/BabylonNative/src/main/java/com/babylonjs/embedding/AudioFocusHelper.java create mode 100644 Embedding/Android/src/main/cpp/AudioPlatformAndroid.cpp create mode 100644 Embedding/Apple/Source/AudioSession.mm create mode 100644 Embedding/Include/Platform/Android/Babylon/Embedding/Android/AudioPlatform.h diff --git a/Apps/Playground/Android/BabylonNative/src/main/java/com/babylonjs/embedding/AudioFocusHelper.java b/Apps/Playground/Android/BabylonNative/src/main/java/com/babylonjs/embedding/AudioFocusHelper.java new file mode 100644 index 000000000..3ed2a6973 --- /dev/null +++ b/Apps/Playground/Android/BabylonNative/src/main/java/com/babylonjs/embedding/AudioFocusHelper.java @@ -0,0 +1,67 @@ +package com.babylonjs.embedding; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.os.Build; + +/** + * Requests Android audio focus before WebAudio playback starts. + */ +public final class AudioFocusHelper { + private static AudioManager audioManager; + private static AudioFocusRequest audioFocusRequest; + private static final AudioManager.OnAudioFocusChangeListener focusListener = focusChange -> { + switch (focusChange) { + case AudioManager.AUDIOFOCUS_LOSS: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + nativeNotifyAudioInterruption(true); + break; + case AudioManager.AUDIOFOCUS_GAIN: + nativeNotifyAudioInterruption(false); + break; + default: + break; + } + }; + + static { + System.loadLibrary("BabylonNativeEmbedding"); + } + + private AudioFocusHelper() {} + + public static void initialize(Context context) { + audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + } + + public static void requestPlaybackFocus() { + if (audioManager == null) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (audioFocusRequest == null) { + AudioAttributes attributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(); + audioFocusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(attributes) + .setOnAudioFocusChangeListener(focusListener) + .build(); + } + + audioManager.requestAudioFocus(audioFocusRequest); + } else { + audioManager.requestAudioFocus( + focusListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN); + } + } + + private static native void nativeNotifyAudioInterruption(boolean began); +} diff --git a/Embedding/Android/CMakeLists.txt b/Embedding/Android/CMakeLists.txt index fa820b076..6eda98124 100644 --- a/Embedding/Android/CMakeLists.txt +++ b/Embedding/Android/CMakeLists.txt @@ -18,7 +18,9 @@ set(ANDROID_EMBEDDING_INCLUDE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../Include/Platf set(SOURCES "${ANDROID_EMBEDDING_INCLUDE_ROOT}/Babylon/Embedding/Android/RuntimeHandle.h" - "${CMAKE_CURRENT_SOURCE_DIR}/src/main/cpp/BabylonNativeEmbedding.cpp") + "${ANDROID_EMBEDDING_INCLUDE_ROOT}/Babylon/Embedding/Android/AudioPlatform.h" + "${CMAKE_CURRENT_SOURCE_DIR}/src/main/cpp/BabylonNativeEmbedding.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/main/cpp/AudioPlatformAndroid.cpp") add_library(BabylonNativeEmbedding SHARED ${SOURCES}) @@ -28,6 +30,7 @@ target_include_directories(BabylonNativeEmbedding PUBLIC "${ANDROID_EMBEDDING_IN target_link_libraries(BabylonNativeEmbedding PRIVATE Embedding + PRIVATE WebAudio PRIVATE AndroidExtensions PRIVATE android # ANativeWindow_fromSurface PRIVATE log diff --git a/Embedding/Android/src/main/cpp/AudioPlatformAndroid.cpp b/Embedding/Android/src/main/cpp/AudioPlatformAndroid.cpp new file mode 100644 index 000000000..2daf85da9 --- /dev/null +++ b/Embedding/Android/src/main/cpp/AudioPlatformAndroid.cpp @@ -0,0 +1,73 @@ +#include +#include + +#include + +namespace +{ + JavaVM* g_javaVM{nullptr}; + + void RequestAndroidPlaybackFocus() + { + if (!g_javaVM) + { + return; + } + + JNIEnv* env{nullptr}; + bool detach = false; + const jint envStatus = g_javaVM->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + if (envStatus == JNI_EDETACHED) + { + if (g_javaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) + { + return; + } + detach = true; + } + else if (envStatus != JNI_OK) + { + return; + } + + jclass helperClass = env->FindClass("com/babylonjs/embedding/AudioFocusHelper"); + if (helperClass != nullptr) + { + jmethodID requestMethod = env->GetStaticMethodID(helperClass, "requestPlaybackFocus", "()V"); + if (requestMethod != nullptr) + { + env->CallStaticVoidMethod(helperClass, requestMethod); + } + } + + if (detach) + { + g_javaVM->DetachCurrentThread(); + } + } +} + +extern "C" JNIEXPORT void JNICALL +Java_com_babylonjs_embedding_AudioFocusHelper_nativeNotifyAudioInterruption(JNIEnv*, jclass, jboolean began) +{ + Babylon::Polyfills::Internal::NotifyAudioInterruption(began == JNI_TRUE); +} + +void Babylon::Embedding::Android::RegisterWebAudioPlatform(JNIEnv* env, jobject context, JavaVM* javaVM) +{ + g_javaVM = javaVM; + + jclass helperClass = env->FindClass("com/babylonjs/embedding/AudioFocusHelper"); + if (helperClass != nullptr) + { + jmethodID initializeMethod = env->GetStaticMethodID(helperClass, "initialize", "(Landroid/content/Context;)V"); + if (initializeMethod != nullptr) + { + env->CallStaticVoidMethod(helperClass, initializeMethod, context); + } + } + + Babylon::Polyfills::RegisterAudioPreparePlaybackHandler([]() { + RequestAndroidPlaybackFocus(); + }); +} diff --git a/Embedding/Android/src/main/cpp/BabylonNativeEmbedding.cpp b/Embedding/Android/src/main/cpp/BabylonNativeEmbedding.cpp index 4e506e025..f059668c5 100644 --- a/Embedding/Android/src/main/cpp/BabylonNativeEmbedding.cpp +++ b/Embedding/Android/src/main/cpp/BabylonNativeEmbedding.cpp @@ -17,6 +17,9 @@ #include #include #include +#if BABYLON_NATIVE_POLYFILL_WEBAUDIO +#include +#endif #include @@ -285,6 +288,9 @@ Java_com_babylonjs_embedding_BabylonNative_setContext( return; } android::global::Initialize(javaVM, context); +#if BABYLON_NATIVE_POLYFILL_WEBAUDIO + Babylon::Embedding::Android::RegisterWebAudioPlatform(env, context, javaVM); +#endif } JNIEXPORT void JNICALL diff --git a/Embedding/Apple/CMakeLists.txt b/Embedding/Apple/CMakeLists.txt index 6bea2bf8e..7f390a0f6 100644 --- a/Embedding/Apple/CMakeLists.txt +++ b/Embedding/Apple/CMakeLists.txt @@ -23,7 +23,8 @@ set(SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/Source/BNRuntime.mm" "${CMAKE_CURRENT_SOURCE_DIR}/Source/BNRuntimeInternal.h" "${CMAKE_CURRENT_SOURCE_DIR}/Source/BNView.mm" - "${CMAKE_CURRENT_SOURCE_DIR}/Source/BNViewDelegate.mm") + "${CMAKE_CURRENT_SOURCE_DIR}/Source/BNViewDelegate.mm" + "${CMAKE_CURRENT_SOURCE_DIR}/Source/AudioSession.mm") add_library(BabylonNativeEmbedding STATIC ${SOURCES}) @@ -35,15 +36,18 @@ target_include_directories(BabylonNativeEmbedding target_link_libraries(BabylonNativeEmbedding PUBLIC Embedding + PRIVATE WebAudio PRIVATE "-framework Foundation" PRIVATE "-framework QuartzCore" - PRIVATE "-framework MetalKit") + PRIVATE "-framework MetalKit" + PRIVATE "-framework AVFoundation") # Enable ARC for the Obj-C++ files. set_source_files_properties( "${CMAKE_CURRENT_SOURCE_DIR}/Source/BNRuntime.mm" "${CMAKE_CURRENT_SOURCE_DIR}/Source/BNView.mm" "${CMAKE_CURRENT_SOURCE_DIR}/Source/BNViewDelegate.mm" + "${CMAKE_CURRENT_SOURCE_DIR}/Source/AudioSession.mm" PROPERTIES COMPILE_FLAGS "-fobjc-arc") set_property(TARGET BabylonNativeEmbedding PROPERTY FOLDER Embedding) diff --git a/Embedding/Apple/Source/AudioSession.mm b/Embedding/Apple/Source/AudioSession.mm new file mode 100644 index 000000000..ee952b553 --- /dev/null +++ b/Embedding/Apple/Source/AudioSession.mm @@ -0,0 +1,50 @@ +#include + +#import + +namespace +{ + void ConfigureAudioSession() + { + AVAudioSession* session = [AVAudioSession sharedInstance]; + NSError* error = nil; + [session setCategory:AVAudioSessionCategoryPlayback error:&error]; + [session setActive:YES error:&error]; + } + + void HandleInterruption(NSNotification* notification) + { + const auto type = static_cast( + [notification.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]); + + if (type == AVAudioSessionInterruptionTypeBegan) + { + Babylon::Polyfills::Internal::NotifyAudioInterruption(true); + } + else if (type == AVAudioSessionInterruptionTypeEnded) + { + ConfigureAudioSession(); + Babylon::Polyfills::Internal::NotifyAudioInterruption(false); + } + } + + struct AudioSessionRegistrar + { + AudioSessionRegistrar() + { + Babylon::Polyfills::RegisterAudioPreparePlaybackHandler([]() { + ConfigureAudioSession(); + }); + + [[NSNotificationCenter defaultCenter] + addObserverForName:AVAudioSessionInterruptionNotification + object:[AVAudioSession sharedInstance] + queue:nil + usingBlock:^(NSNotification* notification) { + HandleInterruption(notification); + }]; + } + }; + + AudioSessionRegistrar g_audioSessionRegistrar{}; +} diff --git a/Embedding/CMakeLists.txt b/Embedding/CMakeLists.txt index 20715f0bb..180051354 100644 --- a/Embedding/CMakeLists.txt +++ b/Embedding/CMakeLists.txt @@ -80,6 +80,11 @@ if(BABYLON_NATIVE_POLYFILL_WEBSOCKET) target_link_libraries(Embedding PRIVATE WebSocket) endif() +if(BABYLON_NATIVE_POLYFILL_WEBAUDIO) + target_compile_definitions(Embedding PUBLIC BABYLON_NATIVE_POLYFILL_WEBAUDIO=1) + target_link_libraries(Embedding PRIVATE WebAudio) +endif() + # ----- Conditionally-included plugins ----- if(BABYLON_NATIVE_PLUGIN_NATIVEENGINE) diff --git a/Embedding/Include/Platform/Android/Babylon/Embedding/Android/AudioPlatform.h b/Embedding/Include/Platform/Android/Babylon/Embedding/Android/AudioPlatform.h new file mode 100644 index 000000000..0250a2405 --- /dev/null +++ b/Embedding/Include/Platform/Android/Babylon/Embedding/Android/AudioPlatform.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +namespace Babylon::Embedding::Android +{ + void RegisterWebAudioPlatform(JNIEnv* env, jobject context, JavaVM* javaVM); +} diff --git a/Embedding/Source/Runtime.cpp b/Embedding/Source/Runtime.cpp index 852528288..c8ab2cdde 100644 --- a/Embedding/Source/Runtime.cpp +++ b/Embedding/Source/Runtime.cpp @@ -56,6 +56,10 @@ #include #endif +#if BABYLON_NATIVE_POLYFILL_WEBAUDIO +#include +#endif + #include #include #include @@ -145,6 +149,14 @@ namespace Babylon::Embedding m_canvas.reset(); #endif +#if BABYLON_NATIVE_POLYFILL_WEBAUDIO + if (m_webAudio) + { + m_webAudio->ShutdownPlayback(); + } + m_webAudio.reset(); +#endif + #if BABYLON_NATIVE_PLUGIN_NATIVEINPUT m_input = nullptr; #endif @@ -260,6 +272,10 @@ namespace Babylon::Embedding implPtr->m_canvas.emplace(Babylon::Polyfills::Canvas::Initialize(env)); #endif +#if BABYLON_NATIVE_POLYFILL_WEBAUDIO + implPtr->m_webAudio.emplace(Babylon::Polyfills::WebAudio::Initialize(env)); +#endif + // 3. Plugins. #if BABYLON_NATIVE_PLUGIN_NATIVETRACING Babylon::Plugins::NativeTracing::Initialize(env); @@ -411,6 +427,12 @@ namespace Babylon::Embedding m_impl->SaveShaderCache(); #endif m_impl->m_appRuntime->Suspend(); +#if BABYLON_NATIVE_POLYFILL_WEBAUDIO + if (m_impl->m_webAudio) + { + m_impl->m_webAudio->SuspendPlayback(); + } +#endif } } @@ -425,6 +447,12 @@ namespace Babylon::Embedding if (m_impl->m_suspendCount.fetch_sub(1, std::memory_order_relaxed) == 1) { m_impl->m_appRuntime->Resume(); +#if BABYLON_NATIVE_POLYFILL_WEBAUDIO + if (m_impl->m_webAudio) + { + m_impl->m_webAudio->ResumePlayback(); + } +#endif // Re-open the frame on the attached View. On a view that was // attached but never sized, this also drives the deferred // first-Resize init via InitializeIfReady. diff --git a/Embedding/Source/RuntimeImpl.h b/Embedding/Source/RuntimeImpl.h index 6f2260b3b..fddae238b 100644 --- a/Embedding/Source/RuntimeImpl.h +++ b/Embedding/Source/RuntimeImpl.h @@ -11,6 +11,10 @@ #include #endif +#if BABYLON_NATIVE_POLYFILL_WEBAUDIO +#include +#endif + #if BABYLON_NATIVE_PLUGIN_NATIVEINPUT #include #endif @@ -54,6 +58,10 @@ namespace Babylon::Embedding std::optional m_canvas; #endif +#if BABYLON_NATIVE_POLYFILL_WEBAUDIO + std::optional m_webAudio; +#endif + #if BABYLON_NATIVE_PLUGIN_NATIVEINPUT // Owned by the JS world (returned by NativeInput::CreateForJavaScript). // We just keep a pointer for forwarding View::OnPointer* calls. From 37441db94bef6c585d8d854283345565a75a6ae6 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Fri, 3 Jul 2026 12:36:16 -0700 Subject: [PATCH 3/6] Add WebAudio coverage to JavaScript unit tests. Cover suspended-by-default contexts, resume, automation, analysers, natural ended events, and scheduled buffer-source start times for headless CI. --- Apps/UnitTests/CMakeLists.txt | 1 + .../JavaScript/dist/tests.javaScript.all.js | 568 ++++++++++++++++-- .../JavaScript/src/tests.javaScript.all.ts | 321 ++++++++++ Apps/UnitTests/Source/Tests.JavaScript.cpp | 11 +- 4 files changed, 840 insertions(+), 61 deletions(-) diff --git a/Apps/UnitTests/CMakeLists.txt b/Apps/UnitTests/CMakeLists.txt index 4ffe04381..17c7e3e22 100644 --- a/Apps/UnitTests/CMakeLists.txt +++ b/Apps/UnitTests/CMakeLists.txt @@ -78,6 +78,7 @@ target_link_libraries(UnitTests PRIVATE NativeEncoding PRIVATE ScriptLoader PRIVATE ShaderCache + PRIVATE WebAudio PRIVATE Window PRIVATE XMLHttpRequest PRIVATE gtest_main diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index 53a4d4c17..8623f5919 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -24017,6 +24017,46 @@ exports.isNumeric = function (input) { return !isNaN(parseFloat(input)); }; +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/esm/arrayLikeToArray.js" +/*!*************************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/esm/arrayLikeToArray.js ***! + \*************************************************************************/ +(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "default": () => (/* binding */ _arrayLikeToArray) +/* harmony export */ }); +function _arrayLikeToArray(r, a) { + (null == a || a > r.length) && (a = r.length); + for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; + return n; +} + + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/esm/arrayWithoutHoles.js" +/*!**************************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/esm/arrayWithoutHoles.js ***! + \**************************************************************************/ +(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "default": () => (/* binding */ _arrayWithoutHoles) +/* harmony export */ }); +/* harmony import */ var _arrayLikeToArray_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./arrayLikeToArray.js */ "../../node_modules/@babel/runtime/helpers/esm/arrayLikeToArray.js"); + +function _arrayWithoutHoles(r) { + if (Array.isArray(r)) return (0,_arrayLikeToArray_js__WEBPACK_IMPORTED_MODULE_0__["default"])(r); +} + + /***/ }, /***/ "../../node_modules/@babel/runtime/helpers/esm/asyncToGenerator.js" @@ -24057,6 +24097,92 @@ function _asyncToGenerator(n) { } +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/esm/iterableToArray.js" +/*!************************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/esm/iterableToArray.js ***! + \************************************************************************/ +(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "default": () => (/* binding */ _iterableToArray) +/* harmony export */ }); +function _iterableToArray(r) { + if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r); +} + + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/esm/nonIterableSpread.js" +/*!**************************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/esm/nonIterableSpread.js ***! + \**************************************************************************/ +(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "default": () => (/* binding */ _nonIterableSpread) +/* harmony export */ }); +function _nonIterableSpread() { + throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); +} + + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/esm/toConsumableArray.js" +/*!**************************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/esm/toConsumableArray.js ***! + \**************************************************************************/ +(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "default": () => (/* binding */ _toConsumableArray) +/* harmony export */ }); +/* harmony import */ var _arrayWithoutHoles_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./arrayWithoutHoles.js */ "../../node_modules/@babel/runtime/helpers/esm/arrayWithoutHoles.js"); +/* harmony import */ var _iterableToArray_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./iterableToArray.js */ "../../node_modules/@babel/runtime/helpers/esm/iterableToArray.js"); +/* harmony import */ var _unsupportedIterableToArray_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./unsupportedIterableToArray.js */ "../../node_modules/@babel/runtime/helpers/esm/unsupportedIterableToArray.js"); +/* harmony import */ var _nonIterableSpread_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./nonIterableSpread.js */ "../../node_modules/@babel/runtime/helpers/esm/nonIterableSpread.js"); + + + + +function _toConsumableArray(r) { + return (0,_arrayWithoutHoles_js__WEBPACK_IMPORTED_MODULE_0__["default"])(r) || (0,_iterableToArray_js__WEBPACK_IMPORTED_MODULE_1__["default"])(r) || (0,_unsupportedIterableToArray_js__WEBPACK_IMPORTED_MODULE_2__["default"])(r) || (0,_nonIterableSpread_js__WEBPACK_IMPORTED_MODULE_3__["default"])(); +} + + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/esm/unsupportedIterableToArray.js" +/*!***********************************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/esm/unsupportedIterableToArray.js ***! + \***********************************************************************************/ +(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "default": () => (/* binding */ _unsupportedIterableToArray) +/* harmony export */ }); +/* harmony import */ var _arrayLikeToArray_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./arrayLikeToArray.js */ "../../node_modules/@babel/runtime/helpers/esm/arrayLikeToArray.js"); + +function _unsupportedIterableToArray(r, a) { + if (r) { + if ("string" == typeof r) return (0,_arrayLikeToArray_js__WEBPACK_IMPORTED_MODULE_0__["default"])(r, a); + var t = {}.toString.call(r).slice(8, -1); + return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? (0,_arrayLikeToArray_js__WEBPACK_IMPORTED_MODULE_0__["default"])(r, a) : void 0; + } +} + + /***/ }, /***/ "../../node_modules/chai/index.js" @@ -28239,14 +28365,15 @@ var __webpack_exports__ = {}; !*** ./src/tests.javaScript.all.ts ***! \*************************************/ __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @babel/runtime/helpers/asyncToGenerator */ "../../node_modules/@babel/runtime/helpers/esm/asyncToGenerator.js"); -/* harmony import */ var _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @babel/runtime/regenerator */ "../../node_modules/@babel/runtime/regenerator/index.js"); -/* harmony import */ var _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var mocha__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! mocha */ "../../node_modules/mocha/browser-entry.js"); -/* harmony import */ var mocha__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(mocha__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var chai__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! chai */ "../../node_modules/chai/index.js"); -/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! @babylonjs/materials */ "@babylonjs/core"); -/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(_babylonjs_core__WEBPACK_IMPORTED_MODULE_4__); +/* harmony import */ var _babel_runtime_helpers_toConsumableArray__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @babel/runtime/helpers/toConsumableArray */ "../../node_modules/@babel/runtime/helpers/esm/toConsumableArray.js"); +/* harmony import */ var _babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @babel/runtime/helpers/asyncToGenerator */ "../../node_modules/@babel/runtime/helpers/esm/asyncToGenerator.js"); +/* harmony import */ var _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! @babel/runtime/regenerator */ "../../node_modules/@babel/runtime/regenerator/index.js"); +/* harmony import */ var _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2__); +/* harmony import */ var mocha__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! mocha */ "../../node_modules/mocha/browser-entry.js"); +/* harmony import */ var mocha__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(mocha__WEBPACK_IMPORTED_MODULE_3__); +/* harmony import */ var chai__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! chai */ "../../node_modules/chai/index.js"); +/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! @babylonjs/materials */ "@babylonjs/core"); +/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(_babylonjs_core__WEBPACK_IMPORTED_MODULE_5__); @@ -28255,9 +28382,9 @@ __webpack_require__.r(__webpack_exports__); -mocha__WEBPACK_IMPORTED_MODULE_2__.setup("bdd"); +mocha__WEBPACK_IMPORTED_MODULE_3__.setup("bdd"); // @ts-ignore -mocha__WEBPACK_IMPORTED_MODULE_2__.reporter("spec"); +mocha__WEBPACK_IMPORTED_MODULE_3__.reporter("spec"); @@ -28267,26 +28394,26 @@ describe("RequestFile", function () { this.timeout(0); it("should throw when requesting a URL with no protocol", function () { function requestFile() { - (0,_babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.RequestFile)("noprotocol.gltf", function () {}); + (0,_babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.RequestFile)("noprotocol.gltf", function () {}); } - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(requestFile).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(requestFile).to.throw(); }); }); describe("ColorParsing", function () { - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.Canvas.parseColor("")).to.equal(0); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.Canvas.parseColor("transparent")).to.equal(0); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.Canvas.parseColor("#123")).to.equal(0xff332211); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.Canvas.parseColor("#1234")).to.equal(0x44332211); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.Canvas.parseColor("#123456")).to.equal(0xff563412); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.Canvas.parseColor("#12345678")).to.equal(0x78563412); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.Canvas.parseColor("snow")).to.equal(0xfffafaff); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.Canvas.parseColor("rgb(16,32,48)")).to.equal(0xff302010); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.Canvas.parseColor("rgba(16,32,48,64)")).to.equal(0x40302010); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.Canvas.parseColor("rgb(16, 32 , 48 )")).to.equal( + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(_native.Canvas.parseColor("")).to.equal(0); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(_native.Canvas.parseColor("transparent")).to.equal(0); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(_native.Canvas.parseColor("#123")).to.equal(0xff332211); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(_native.Canvas.parseColor("#1234")).to.equal(0x44332211); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(_native.Canvas.parseColor("#123456")).to.equal(0xff563412); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(_native.Canvas.parseColor("#12345678")).to.equal(0x78563412); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(_native.Canvas.parseColor("snow")).to.equal(0xfffafaff); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(_native.Canvas.parseColor("rgb(16,32,48)")).to.equal(0xff302010); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(_native.Canvas.parseColor("rgba(16,32,48,64)")).to.equal(0x40302010); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(_native.Canvas.parseColor("rgb(16, 32 , 48 )")).to.equal( 0xff302010 ); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)( + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)( _native.Canvas.parseColor("rgba( 16, 32 , 48 , 64 )") ).to.equal(0x40302010); @@ -28294,75 +28421,75 @@ describe("ColorParsing", function () { function incorrectColor() { _native.Canvas.parseColor("unknownColor"); } - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(incorrectColor).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(incorrectColor).to.throw(); }); it("should throw", function () { function incorrectColor() { _native.Canvas.parseColor("#"); } - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(incorrectColor).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(incorrectColor).to.throw(); }); it("should throw", function () { function incorrectColor() { _native.Canvas.parseColor("#12345"); } - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(incorrectColor).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(incorrectColor).to.throw(); }); it("should throw", function () { function incorrectColor() { _native.Canvas.parseColor("rgb(11)"); } - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(incorrectColor).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(incorrectColor).to.throw(); }); it("should throw", function () { function incorrectColor() { _native.Canvas.parseColor("rgb(11,22,33"); } - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(incorrectColor).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(incorrectColor).to.throw(); }); it("should throw", function () { function incorrectColor() { _native.Canvas.parseColor("rgb(11,22,33,"); } - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(incorrectColor).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(incorrectColor).to.throw(); }); it("should throw", function () { function incorrectColor() { _native.Canvas.parseColor("rgba(11, 22, 33, )"); } - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(incorrectColor).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(incorrectColor).to.throw(); }); it("should throw", function () { function incorrectColor() { _native.Canvas.parseColor("rgba(11, 22, 33, 44, 55, 66 )"); } - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(incorrectColor).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(incorrectColor).to.throw(); }); it("should throw", function () { function incorrectColor() { _native.Canvas.parseColor("rgb"); } - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(incorrectColor).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(incorrectColor).to.throw(); }); it("should throw", function () { function incorrectColor() { _native.Canvas.parseColor("rgba"); } - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(incorrectColor).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(incorrectColor).to.throw(); }); }); function createSceneAndWait(callback, done) { - var engine = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.NativeEngine(); - var scene = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.Scene(engine); + var engine = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.NativeEngine(); + var scene = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.Scene(engine); scene.createDefaultCamera(); callback(engine, scene); scene.executeWhenReady(function () { @@ -28375,7 +28502,7 @@ describe("Materials", function () { it("Empty ShaderMaterial should compile", function (done) { function createEmptyShaderMat() { createSceneAndWait(function (engine, scene) { - var sphere = _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.MeshBuilder.CreateSphere( + var sphere = _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.MeshBuilder.CreateSphere( "sphere", { diameter: 2, segments: 32 }, scene @@ -28384,7 +28511,7 @@ describe("Materials", function () { vertexSource: "void main() {}", fragmentSource: "void main() {}" }; - var mat = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.ShaderMaterial("shader", scene, shaders, {}); + var mat = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.ShaderMaterial("shader", scene, shaders, {}); sphere.material = mat; }, done); } @@ -28392,12 +28519,12 @@ describe("Materials", function () { }); it("GradientMaterial should compile", function (done) { createSceneAndWait(function (engine, scene) { - var sphere = _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.MeshBuilder.CreateSphere( + var sphere = _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.MeshBuilder.CreateSphere( "sphere", { diameter: 2, segments: 32 }, scene ); - var gradientMaterial = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.GradientMaterial("grad", scene); + var gradientMaterial = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.GradientMaterial("grad", scene); sphere.material = gradientMaterial; }, done); }); @@ -28408,21 +28535,21 @@ describe("PostProcesses", function () { it("PassPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.PassPostProcess("Scene copy", 1.0, camera); + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.PassPostProcess("Scene copy", 1.0, camera); }, done); }); it("BlackAndWhitePostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.BlackAndWhitePostProcess("bandw", 1.0, camera); + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.BlackAndWhitePostProcess("bandw", 1.0, camera); }, done); }); it("BlurPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.BlurPostProcess( + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.BlurPostProcess( "Horizontal blur", - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.Vector2(1.0, 0), + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.Vector2(1.0, 0), 32, 0.25, camera @@ -28432,9 +28559,9 @@ describe("PostProcesses", function () { it("ConvolutionPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.ConvolutionPostProcess( + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.ConvolutionPostProcess( "Sepia", - _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.ConvolutionPostProcess.EmbossKernel, + _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.ConvolutionPostProcess.EmbossKernel, 1.0, camera ); @@ -28443,28 +28570,28 @@ describe("PostProcesses", function () { it("HighlightsPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.HighlightsPostProcess("highlights", 1.0, camera); + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.HighlightsPostProcess("highlights", 1.0, camera); }, done); }); it("TonemapPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.TonemapPostProcess("tonemap", _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.TonemappingOperator.Hable, 1.0, camera); + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.TonemapPostProcess("tonemap", _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.TonemappingOperator.Hable, 1.0, camera); }, done); }); it("ImageProcessingPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.ImageProcessingPostProcess("processing", 1.0, camera); + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.ImageProcessingPostProcess("processing", 1.0, camera); }, done); }); it("RefractionPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.RefractionPostProcess( + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.RefractionPostProcess( "Refraction", "https://playground.babylonjs.com/textures/grass.jpg", - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.Color3(1.0, 1.0, 1.0), + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.Color3(1.0, 1.0, 1.0), 0.5, 0.5, 1.0, @@ -28475,7 +28602,7 @@ describe("PostProcesses", function () { it("DefaultPipeline", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.DefaultRenderingPipeline( + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.DefaultRenderingPipeline( "defaultPipeline", // The name of the pipeline true, // Do you want the pipeline to use HDR texture? scene, // The scene instance @@ -28529,25 +28656,25 @@ describe("PostProcesses", function () { describe("NativeEncoding", function () { this.timeout(0);function - expectValidPNG(_x) {return _expectValidPNG.apply(this, arguments);}function _expectValidPNG() {_expectValidPNG = (0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_0__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().mark(function _callee3(blob) {var arrayBuffer, pngSignature;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().wrap(function (_context3) {while (1) switch (_context3.prev = _context3.next) {case 0: - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(blob).to.be.instanceOf(Blob);_context3.next = 1;return ( + expectValidPNG(_x) {return _expectValidPNG.apply(this, arguments);}function _expectValidPNG() {_expectValidPNG = (0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_1__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().mark(function _callee3(blob) {var arrayBuffer, pngSignature;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().wrap(function (_context3) {while (1) switch (_context3.prev = _context3.next) {case 0: + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(blob).to.be.instanceOf(Blob);_context3.next = 1;return ( blob.arrayBuffer());case 1:arrayBuffer = _context3.sent; - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(arrayBuffer.byteLength).to.be.greaterThan(0); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(arrayBuffer.byteLength).to.be.greaterThan(0); pngSignature = new Uint8Array(arrayBuffer.slice(0, 4)); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pngSignature[0]).to.equal(137); // PNG signature bytes - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pngSignature[1]).to.equal(80); // 'P' - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pngSignature[2]).to.equal(78); // 'N' - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pngSignature[3]).to.equal(71); // 'G' + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(pngSignature[0]).to.equal(137); // PNG signature bytes + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(pngSignature[1]).to.equal(80); // 'P' + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(pngSignature[2]).to.equal(78); // 'N' + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(pngSignature[3]).to.equal(71); // 'G' case 2:case "end":return _context3.stop();}}, _callee3);}));return _expectValidPNG.apply(this, arguments);} - it("should encode a PNG", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_0__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().mark(function _callee() {var pixelData, result;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().wrap(function (_context) {while (1) switch (_context.prev = _context.next) {case 0: + it("should encode a PNG", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_1__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().mark(function _callee() {var pixelData, result;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().wrap(function (_context) {while (1) switch (_context.prev = _context.next) {case 0: pixelData = new Uint8Array(4).fill(255);_context.next = 1;return ( _native.EncodeImageAsync(pixelData, 1, 1, "image/png", false));case 1:result = _context.sent;_context.next = 2;return ( expectValidPNG(result));case 2:case "end":return _context.stop();}}, _callee);})) ); - it("should handle multiple concurrent encoding tasks", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_0__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().mark(function _callee2() {var pixelDatas, i, results;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().wrap(function (_context2) {while (1) switch (_context2.prev = _context2.next) {case 0: + it("should handle multiple concurrent encoding tasks", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_1__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().mark(function _callee2() {var pixelDatas, i, results;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().wrap(function (_context2) {while (1) switch (_context2.prev = _context2.next) {case 0: pixelDatas = []; for (i = 0; i < 10; i++) { pixelDatas.push(new Uint8Array(4).fill(255)); @@ -28559,6 +28686,327 @@ describe("NativeEncoding", function () { ); }); +describe("WebAudio", function () { + this.timeout(0); + + function createSilentWavArrayBuffer(sampleRate, frameCount) { + var bytesPerSample = 2; + var channelCount = 1; + var dataSize = frameCount * channelCount * bytesPerSample; + var buffer = new ArrayBuffer(44 + dataSize); + var view = new DataView(buffer); + var writeString = function writeString(offset, value) { + for (var i = 0; i < value.length; i++) { + view.setUint8(offset + i, value.charCodeAt(i)); + } + }; + + writeString(0, "RIFF"); + view.setUint32(4, 36 + dataSize, true); + writeString(8, "WAVE"); + writeString(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channelCount, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * channelCount * bytesPerSample, true); + view.setUint16(32, channelCount * bytesPerSample, true); + view.setUint16(34, 8 * bytesPerSample, true); + writeString(36, "data"); + view.setUint32(40, dataSize, true); + return buffer; + } + + function createSineWavArrayBuffer(sampleRate, frameCount, frequency) {var amplitude = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0.5; + var bytesPerSample = 2; + var channelCount = 1; + var dataSize = frameCount * channelCount * bytesPerSample; + var buffer = new ArrayBuffer(44 + dataSize); + var view = new DataView(buffer); + var writeString = function writeString(offset, value) { + for (var i = 0; i < value.length; i++) { + view.setUint8(offset + i, value.charCodeAt(i)); + } + }; + + writeString(0, "RIFF"); + view.setUint32(4, 36 + dataSize, true); + writeString(8, "WAVE"); + writeString(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channelCount, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * channelCount * bytesPerSample, true); + view.setUint16(32, channelCount * bytesPerSample, true); + view.setUint16(34, 8 * bytesPerSample, true); + writeString(36, "data"); + view.setUint32(40, dataSize, true); + + for (var frame = 0; frame < frameCount; frame++) { + var sample = Math.sin(2 * Math.PI * frequency * frame / sampleRate) * amplitude; + var intSample = Math.max(-32768, Math.min(32767, Math.round(sample * 32767))); + view.setInt16(44 + frame * bytesPerSample, intSample, true); + } + + return buffer; + }function + + waitForTime(_x2, _x3) {return _waitForTime.apply(this, arguments);}function _waitForTime() {_waitForTime = (0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_1__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().mark(function _callee0(context, targetTime) {var timeoutMs,start,_args0 = arguments;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().wrap(function (_context0) {while (1) switch (_context0.prev = _context0.next) {case 0:timeoutMs = _args0.length > 2 && _args0[2] !== undefined ? _args0[2] : 5000; + start = Date.now();case 1:if (!( + context.currentTime < targetTime)) {_context0.next = 4;break;}if (!( + Date.now() - start > timeoutMs)) {_context0.next = 2;break;}throw ( + new Error("Timed out waiting for currentTime >= ".concat(targetTime)));case 2:_context0.next = 3;return ( + + new Promise(function (resolve) {return setTimeout(resolve, 10);}));case 3:_context0.next = 1;break;case 4:case "end":return _context0.stop();}}, _callee0);}));return _waitForTime.apply(this, arguments);} + + + + it("should expose AudioContext and play a decoded buffer through the node graph", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_1__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().mark(function _callee4() {var context, sampleRate, frameCount, audioBuffer, source, gain, panner, ended;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().wrap(function (_context4) {while (1) switch (_context4.prev = _context4.next) {case 0: + // JavaScriptCore reports some host constructors as typeof "object". + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(AudioContext).to.exist; + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(webkitAudioContext).to.exist; + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(AudioContext).to.equal(webkitAudioContext); + + context = new AudioContext(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(context.state).to.equal("suspended");_context4.next = 1;return ( + context.resume());case 1: + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(context.state).to.equal("running"); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(context.sampleRate).to.be.greaterThan(0); + + sampleRate = 44100; + frameCount = sampleRate; // 1 second + _context4.next = 2;return context.decodeAudioData(createSilentWavArrayBuffer(sampleRate, frameCount));case 2:audioBuffer = _context4.sent; + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(audioBuffer.sampleRate).to.equal(sampleRate); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(audioBuffer.length).to.equal(frameCount); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(audioBuffer.duration).to.be.closeTo(1, 0.01); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(audioBuffer.numberOfChannels).to.equal(1); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(audioBuffer.getChannelData(0).length).to.equal(frameCount); + + source = context.createBufferSource(); + gain = context.createGain(); + panner = context.createPanner(); + + source.buffer = audioBuffer; + gain.gain.value = 0.5; + panner.setPosition(1, 0, 0); + context.listener.setPosition(0, 0, 0); + + source.connect(gain); + gain.connect(panner); + panner.connect(context.destination); + + ended = false; + source.addEventListener("ended", function () { + ended = true; + }); + + source.start();_context4.next = 3;return ( + context.resume());case 3:_context4.next = 4;return ( + + + new Promise(function (resolve) {return setTimeout(resolve, 50);}));case 4: + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(ended).to.equal(false); + + source.stop();_context4.next = 5;return ( + new Promise(function (resolve) {return setTimeout(resolve, 50);}));case 5: + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(ended).to.equal(true);_context4.next = 6;return ( + + context.close());case 6: + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(context.state).to.equal("closed");case 7:case "end":return _context4.stop();}}, _callee4);})) + ); + + it("should automate AudioParam values over time", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_1__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().mark(function _callee5() {var context, gain, scheduleStart, sampleRate, frameCount, audioBuffer, source, curve, curveStart;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().wrap(function (_context5) {while (1) switch (_context5.prev = _context5.next) {case 0: + context = new AudioContext(); + gain = context.createGain(); + gain.connect(context.destination); + + scheduleStart = context.currentTime; + gain.gain.cancelScheduledValues(scheduleStart); + gain.gain.setValueAtTime(0, scheduleStart); + gain.gain.linearRampToValueAtTime(1, scheduleStart + 1); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(gain.gain.value).to.be.closeTo(0, 0.01); + + sampleRate = context.sampleRate; + frameCount = Math.ceil(sampleRate * 1.5);_context5.next = 1;return ( + context.decodeAudioData(createSilentWavArrayBuffer(sampleRate, frameCount)));case 1:audioBuffer = _context5.sent; + source = context.createBufferSource(); + source.buffer = audioBuffer; + source.connect(gain); + source.start(scheduleStart);_context5.next = 2;return ( + context.resume());case 2:_context5.next = 3;return ( + + waitForTime(context, scheduleStart + 0.5));case 3: + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(gain.gain.value).to.be.closeTo(0.5, 0.15); + + curve = new Float32Array([0, 0.25, 0.75, 1]); + curveStart = context.currentTime; + gain.gain.cancelScheduledValues(curveStart); + gain.gain.setValueCurveAtTime(curve, curveStart, 0.2);_context5.next = 4;return ( + waitForTime(context, curveStart + 0.1));case 4: + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(gain.gain.value).to.be.greaterThan(0.1); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(gain.gain.value).to.be.lessThan(0.9); + + source.stop();_context5.next = 5;return ( + context.close());case 5:case "end":return _context5.stop();}}, _callee5);})) + ); + + it("should return analyser frequency and time-domain data for non-silent audio", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_1__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().mark(function _callee6() {var context, sampleRate, frequency, frameCount, audioBuffer, source, analyser, startTime, freqBytes, freqFloats, timeBytes, timeFloats, maxFreqByte, maxFreqFloat, flatTimeDomain, i, flatByteTimeDomain, _i;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().wrap(function (_context6) {while (1) switch (_context6.prev = _context6.next) {case 0: + context = new AudioContext(); + sampleRate = context.sampleRate; + frequency = 440; + frameCount = sampleRate;_context6.next = 1;return ( + context.decodeAudioData(createSineWavArrayBuffer(sampleRate, frameCount, frequency)));case 1:audioBuffer = _context6.sent; + + source = context.createBufferSource(); + analyser = context.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0; + + source.buffer = audioBuffer; + source.connect(analyser); + analyser.connect(context.destination); + + startTime = context.currentTime; + source.start(startTime);_context6.next = 2;return ( + context.resume());case 2:_context6.next = 3;return ( + + waitForTime(context, startTime + 0.25));case 3: + + freqBytes = new Uint8Array(analyser.frequencyBinCount); + freqFloats = new Float32Array(analyser.frequencyBinCount); + timeBytes = new Uint8Array(analyser.fftSize); + timeFloats = new Float32Array(analyser.fftSize); + + analyser.getByteFrequencyData(freqBytes); + analyser.getFloatFrequencyData(freqFloats); + analyser.getByteTimeDomainData(timeBytes); + analyser.getFloatTimeDomainData(timeFloats); + + maxFreqByte = Math.max.apply(Math, (0,_babel_runtime_helpers_toConsumableArray__WEBPACK_IMPORTED_MODULE_0__["default"])(freqBytes)); + maxFreqFloat = Math.max.apply(Math, (0,_babel_runtime_helpers_toConsumableArray__WEBPACK_IMPORTED_MODULE_0__["default"])(freqFloats)); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(maxFreqByte).to.be.greaterThan(0); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(maxFreqFloat).to.be.greaterThan(analyser.minDecibels); + + flatTimeDomain = true; + i = 0;case 4:if (!(i < timeFloats.length)) {_context6.next = 6;break;}if (!( + Math.abs(timeFloats[i]) > 0.001)) {_context6.next = 5;break;} + flatTimeDomain = false;return _context6.abrupt("continue", 6);case 5:i++;_context6.next = 4;break;case 6: + + + + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(flatTimeDomain).to.equal(false); + + flatByteTimeDomain = true; + _i = 0;case 7:if (!(_i < timeBytes.length)) {_context6.next = 9;break;}if (!( + timeBytes[_i] !== 128)) {_context6.next = 8;break;} + flatByteTimeDomain = false;return _context6.abrupt("continue", 9);case 8:_i++;_context6.next = 7;break;case 9: + + + + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(flatByteTimeDomain).to.equal(false); + + source.stop();_context6.next = 10;return ( + context.close());case 10:case "end":return _context6.stop();}}, _callee6);})) + ); + + it("should support Web Audio constructors", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_1__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().mark(function _callee7() {var context, gainNode, pannerNode, stereoPannerNode, analyserNode, buffer, sourceNode;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().wrap(function (_context7) {while (1) switch (_context7.prev = _context7.next) {case 0: + context = new AudioContext(); + + gainNode = new GainNode(context); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(gainNode.gain.value).to.equal(1); + + pannerNode = new PannerNode(context); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(pannerNode.positionX.value).to.equal(0); + + stereoPannerNode = new StereoPannerNode(context); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(stereoPannerNode.pan.value).to.equal(0); + + analyserNode = new AnalyserNode(context); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(analyserNode.fftSize).to.equal(2048); + + buffer = new AudioBuffer({ length: 128, sampleRate: 44100, numberOfChannels: 2 }); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(buffer.length).to.equal(128); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(buffer.sampleRate).to.equal(44100); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(buffer.numberOfChannels).to.equal(2); + + sourceNode = new AudioBufferSourceNode(context, { buffer: buffer }); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(sourceNode.buffer).to.equal(buffer);_context7.next = 1;return ( + + context.close());case 1:case "end":return _context7.stop();}}, _callee7);})) + ); + + it("should fire ended naturally when a short buffer finishes", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_1__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().mark(function _callee8() {var context, sampleRate, frameCount, audioBuffer, source, ended, startTime, pollStart;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().wrap(function (_context8) {while (1) switch (_context8.prev = _context8.next) {case 0: + context = new AudioContext(); + sampleRate = context.sampleRate; + frameCount = Math.ceil(sampleRate * 0.2);_context8.next = 1;return ( + context.decodeAudioData(createSilentWavArrayBuffer(sampleRate, frameCount)));case 1:audioBuffer = _context8.sent; + + source = context.createBufferSource(); + source.buffer = audioBuffer; + source.connect(context.destination); + + ended = false; + source.addEventListener("ended", function () { + ended = true; + }); + + startTime = context.currentTime; + source.start(startTime);_context8.next = 2;return ( + context.resume());case 2:_context8.next = 3;return ( + waitForTime(context, startTime + audioBuffer.duration + 0.2));case 3: + + // Poll briefly in case the ended callback is still marshaling to the JS thread. + pollStart = Date.now();case 4:if (!( + !ended && Date.now() - pollStart < 1000)) {_context8.next = 6;break;}_context8.next = 5;return ( + new Promise(function (resolve) {return setTimeout(resolve, 10);}));case 5:_context8.next = 4;break;case 6: + + + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(ended).to.equal(true);_context8.next = 7;return ( + + context.close());case 7:case "end":return _context8.stop();}}, _callee8);})) + ); + + it("should start buffer sources at a scheduled time", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_1__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().mark(function _callee9() {var context, sampleRate, frameCount, audioBuffer, source, ended, startTime, pollStart;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_2___default().wrap(function (_context9) {while (1) switch (_context9.prev = _context9.next) {case 0: + context = new AudioContext();_context9.next = 1;return ( + context.resume());case 1: + + sampleRate = context.sampleRate; + frameCount = Math.ceil(sampleRate * 0.1);_context9.next = 2;return ( + context.decodeAudioData(createSilentWavArrayBuffer(sampleRate, frameCount)));case 2:audioBuffer = _context9.sent; + + source = context.createBufferSource(); + source.buffer = audioBuffer; + source.connect(context.destination); + + ended = false; + source.addEventListener("ended", function () { + ended = true; + }); + + startTime = context.currentTime + 0.2; + source.start(startTime);_context9.next = 3;return ( + waitForTime(context, startTime + 0.05));case 3: + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(ended).to.equal(false);_context9.next = 4;return ( + + waitForTime(context, startTime + audioBuffer.duration + 0.2));case 4: + pollStart = Date.now();case 5:if (!( + !ended && Date.now() - pollStart < 1000)) {_context9.next = 7;break;}_context9.next = 6;return ( + new Promise(function (resolve) {return setTimeout(resolve, 10);}));case 6:_context9.next = 5;break;case 7: + + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(ended).to.equal(true);_context9.next = 8;return ( + + context.close());case 8:case "end":return _context9.stop();}}, _callee9);})) + ); + + it("should report supported mime types from Audio.canPlayType", function () { + var audio = new Audio(); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(audio.canPlayType("audio/wav")).to.equal("probably"); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(audio.canPlayType("audio/mpeg")).to.equal("probably"); + (0,chai__WEBPACK_IMPORTED_MODULE_4__.expect)(audio.canPlayType("audio/unsupported-format")).to.equal(""); + }); +}); + mocha.run(function (failures) { // Test program will wait for code to be set before exiting if (failures > 0) { diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index e7ebaacbe..3c0c52293 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -328,6 +328,327 @@ describe("NativeEncoding", function () { }); }); +describe("WebAudio", function () { + this.timeout(0); + + function createSilentWavArrayBuffer(sampleRate: number, frameCount: number): ArrayBuffer { + const bytesPerSample = 2; + const channelCount = 1; + const dataSize = frameCount * channelCount * bytesPerSample; + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + const writeString = (offset: number, value: string) => { + for (let i = 0; i < value.length; i++) { + view.setUint8(offset + i, value.charCodeAt(i)); + } + }; + + writeString(0, "RIFF"); + view.setUint32(4, 36 + dataSize, true); + writeString(8, "WAVE"); + writeString(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channelCount, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * channelCount * bytesPerSample, true); + view.setUint16(32, channelCount * bytesPerSample, true); + view.setUint16(34, 8 * bytesPerSample, true); + writeString(36, "data"); + view.setUint32(40, dataSize, true); + return buffer; + } + + function createSineWavArrayBuffer(sampleRate: number, frameCount: number, frequency: number, amplitude: number = 0.5): ArrayBuffer { + const bytesPerSample = 2; + const channelCount = 1; + const dataSize = frameCount * channelCount * bytesPerSample; + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + const writeString = (offset: number, value: string) => { + for (let i = 0; i < value.length; i++) { + view.setUint8(offset + i, value.charCodeAt(i)); + } + }; + + writeString(0, "RIFF"); + view.setUint32(4, 36 + dataSize, true); + writeString(8, "WAVE"); + writeString(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channelCount, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * channelCount * bytesPerSample, true); + view.setUint16(32, channelCount * bytesPerSample, true); + view.setUint16(34, 8 * bytesPerSample, true); + writeString(36, "data"); + view.setUint32(40, dataSize, true); + + for (let frame = 0; frame < frameCount; frame++) { + const sample = Math.sin((2 * Math.PI * frequency * frame) / sampleRate) * amplitude; + const intSample = Math.max(-32768, Math.min(32767, Math.round(sample * 32767))); + view.setInt16(44 + frame * bytesPerSample, intSample, true); + } + + return buffer; + } + + async function waitForTime(context: AudioContext, targetTime: number, timeoutMs: number = 5000): Promise { + const start = Date.now(); + while (context.currentTime < targetTime) { + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for currentTime >= ${targetTime}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + + it("should expose AudioContext and play a decoded buffer through the node graph", async function () { + // JavaScriptCore reports some host constructors as typeof "object". + expect(AudioContext).to.exist; + expect(webkitAudioContext).to.exist; + expect(AudioContext).to.equal(webkitAudioContext); + + const context = new AudioContext(); + expect(context.state).to.equal("suspended"); + await context.resume(); + expect(context.state).to.equal("running"); + expect(context.sampleRate).to.be.greaterThan(0); + + const sampleRate = 44100; + const frameCount = sampleRate; // 1 second + const audioBuffer = await context.decodeAudioData(createSilentWavArrayBuffer(sampleRate, frameCount)); + expect(audioBuffer.sampleRate).to.equal(sampleRate); + expect(audioBuffer.length).to.equal(frameCount); + expect(audioBuffer.duration).to.be.closeTo(1, 0.01); + expect(audioBuffer.numberOfChannels).to.equal(1); + expect(audioBuffer.getChannelData(0).length).to.equal(frameCount); + + const source = context.createBufferSource(); + const gain = context.createGain(); + const panner = context.createPanner(); + + source.buffer = audioBuffer; + gain.gain.value = 0.5; + panner.setPosition(1, 0, 0); + context.listener.setPosition(0, 0, 0); + + source.connect(gain); + gain.connect(panner); + panner.connect(context.destination); + + let ended = false; + source.addEventListener("ended", () => { + ended = true; + }); + + source.start(); + await context.resume(); + + // Allow the audio thread to mix at least one callback. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(ended).to.equal(false); + + source.stop(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(ended).to.equal(true); + + await context.close(); + expect(context.state).to.equal("closed"); + }); + + it("should automate AudioParam values over time", async function () { + const context = new AudioContext(); + const gain = context.createGain(); + gain.connect(context.destination); + + const scheduleStart = context.currentTime; + gain.gain.cancelScheduledValues(scheduleStart); + gain.gain.setValueAtTime(0, scheduleStart); + gain.gain.linearRampToValueAtTime(1, scheduleStart + 1); + expect(gain.gain.value).to.be.closeTo(0, 0.01); + + const sampleRate = context.sampleRate; + const frameCount = Math.ceil(sampleRate * 1.5); + const audioBuffer = await context.decodeAudioData(createSilentWavArrayBuffer(sampleRate, frameCount)); + const source = context.createBufferSource(); + source.buffer = audioBuffer; + source.connect(gain); + source.start(scheduleStart); + await context.resume(); + + await waitForTime(context, scheduleStart + 0.5); + expect(gain.gain.value).to.be.closeTo(0.5, 0.15); + + const curve = new Float32Array([0, 0.25, 0.75, 1]); + const curveStart = context.currentTime; + gain.gain.cancelScheduledValues(curveStart); + gain.gain.setValueCurveAtTime(curve, curveStart, 0.2); + await waitForTime(context, curveStart + 0.1); + expect(gain.gain.value).to.be.greaterThan(0.1); + expect(gain.gain.value).to.be.lessThan(0.9); + + source.stop(); + await context.close(); + }); + + it("should return analyser frequency and time-domain data for non-silent audio", async function () { + const context = new AudioContext(); + const sampleRate = context.sampleRate; + const frequency = 440; + const frameCount = sampleRate; + const audioBuffer = await context.decodeAudioData(createSineWavArrayBuffer(sampleRate, frameCount, frequency)); + + const source = context.createBufferSource(); + const analyser = context.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0; + + source.buffer = audioBuffer; + source.connect(analyser); + analyser.connect(context.destination); + + const startTime = context.currentTime; + source.start(startTime); + await context.resume(); + + await waitForTime(context, startTime + 0.25); + + const freqBytes = new Uint8Array(analyser.frequencyBinCount); + const freqFloats = new Float32Array(analyser.frequencyBinCount); + const timeBytes = new Uint8Array(analyser.fftSize); + const timeFloats = new Float32Array(analyser.fftSize); + + analyser.getByteFrequencyData(freqBytes); + analyser.getFloatFrequencyData(freqFloats); + analyser.getByteTimeDomainData(timeBytes); + analyser.getFloatTimeDomainData(timeFloats); + + const maxFreqByte = Math.max(...freqBytes); + const maxFreqFloat = Math.max(...freqFloats); + expect(maxFreqByte).to.be.greaterThan(0); + expect(maxFreqFloat).to.be.greaterThan(analyser.minDecibels); + + let flatTimeDomain = true; + for (let i = 0; i < timeFloats.length; i++) { + if (Math.abs(timeFloats[i]) > 0.001) { + flatTimeDomain = false; + break; + } + } + expect(flatTimeDomain).to.equal(false); + + let flatByteTimeDomain = true; + for (let i = 0; i < timeBytes.length; i++) { + if (timeBytes[i] !== 128) { + flatByteTimeDomain = false; + break; + } + } + expect(flatByteTimeDomain).to.equal(false); + + source.stop(); + await context.close(); + }); + + it("should support Web Audio constructors", async function () { + const context = new AudioContext(); + + const gainNode = new GainNode(context); + expect(gainNode.gain.value).to.equal(1); + + const pannerNode = new PannerNode(context); + expect(pannerNode.positionX.value).to.equal(0); + + const stereoPannerNode = new StereoPannerNode(context); + expect(stereoPannerNode.pan.value).to.equal(0); + + const analyserNode = new AnalyserNode(context); + expect(analyserNode.fftSize).to.equal(2048); + + const buffer = new AudioBuffer({ length: 128, sampleRate: 44100, numberOfChannels: 2 }); + expect(buffer.length).to.equal(128); + expect(buffer.sampleRate).to.equal(44100); + expect(buffer.numberOfChannels).to.equal(2); + + const sourceNode = new AudioBufferSourceNode(context, { buffer }); + expect(sourceNode.buffer).to.equal(buffer); + + await context.close(); + }); + + it("should fire ended naturally when a short buffer finishes", async function () { + const context = new AudioContext(); + const sampleRate = context.sampleRate; + const frameCount = Math.ceil(sampleRate * 0.2); + const audioBuffer = await context.decodeAudioData(createSilentWavArrayBuffer(sampleRate, frameCount)); + + const source = context.createBufferSource(); + source.buffer = audioBuffer; + source.connect(context.destination); + + let ended = false; + source.addEventListener("ended", () => { + ended = true; + }); + + const startTime = context.currentTime; + source.start(startTime); + await context.resume(); + await waitForTime(context, startTime + audioBuffer.duration + 0.2); + + // Poll briefly in case the ended callback is still marshaling to the JS thread. + const pollStart = Date.now(); + while (!ended && Date.now() - pollStart < 1000) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + expect(ended).to.equal(true); + + await context.close(); + }); + + it("should start buffer sources at a scheduled time", async function () { + const context = new AudioContext(); + await context.resume(); + + const sampleRate = context.sampleRate; + const frameCount = Math.ceil(sampleRate * 0.1); + const audioBuffer = await context.decodeAudioData(createSilentWavArrayBuffer(sampleRate, frameCount)); + + const source = context.createBufferSource(); + source.buffer = audioBuffer; + source.connect(context.destination); + + let ended = false; + source.addEventListener("ended", () => { + ended = true; + }); + + const startTime = context.currentTime + 0.2; + source.start(startTime); + await waitForTime(context, startTime + 0.05); + expect(ended).to.equal(false); + + await waitForTime(context, startTime + audioBuffer.duration + 0.2); + const pollStart = Date.now(); + while (!ended && Date.now() - pollStart < 1000) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(ended).to.equal(true); + + await context.close(); + }); + + it("should report supported mime types from Audio.canPlayType", function () { + const audio = new Audio(); + expect(audio.canPlayType("audio/wav")).to.equal("probably"); + expect(audio.canPlayType("audio/mpeg")).to.equal("probably"); + expect(audio.canPlayType("audio/unsupported-format")).to.equal(""); + }); +}); + mocha.run((failures) => { // Test program will wait for code to be set before exiting if (failures > 0) { diff --git a/Apps/UnitTests/Source/Tests.JavaScript.cpp b/Apps/UnitTests/Source/Tests.JavaScript.cpp index 817d3f06d..9f0dcdb05 100644 --- a/Apps/UnitTests/Source/Tests.JavaScript.cpp +++ b/Apps/UnitTests/Source/Tests.JavaScript.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -48,6 +49,7 @@ TEST(JavaScript, All) device.StartRenderingCurrentFrame(); std::optional nativeCanvas; + std::optional nativeWebAudio; Babylon::AppRuntime::Options options{}; @@ -66,7 +68,7 @@ TEST(JavaScript, All) std::promise exitCodePromise; - runtime.Dispatch([&exitCodePromise, &device, &nativeCanvas](Napi::Env env) { + runtime.Dispatch([&exitCodePromise, &device, &nativeCanvas, &nativeWebAudio](Napi::Env env) { device.AddToJavaScript(env); Babylon::Polyfills::XMLHttpRequest::Initialize(env); @@ -76,6 +78,7 @@ TEST(JavaScript, All) Babylon::Polyfills::Window::Initialize(env); Babylon::Polyfills::Blob::Initialize(env); nativeCanvas.emplace(Babylon::Polyfills::Canvas::Initialize(env)); + nativeWebAudio.emplace(Babylon::Polyfills::WebAudio::Initialize(env)); Babylon::Plugins::NativeEngine::Initialize(env); Babylon::Plugins::NativeEncoding::Initialize(env); @@ -108,7 +111,13 @@ TEST(JavaScript, All) auto exitCode = exitCodeFuture.get(); EXPECT_EQ(exitCode, 0); + if (nativeWebAudio) + { + nativeWebAudio->ShutdownPlayback(); + } + // Runtime destructor joins the JS thread; must happen before Finish. + nativeWebAudio.reset(); nativeCanvas.reset(); device.FinishRenderingCurrentFrame(); From 0e43544dde500f7057a88d7bce7743f749ae31b6 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Fri, 3 Jul 2026 12:36:18 -0700 Subject: [PATCH 4/6] Add Playground audio demo for on-device validation. Bundle and load audio.js on iOS and Android, copy babylonjs.addons.js into Android assets, and resume AudioContext on user gesture for mobile autoplay. --- Apps/Playground/Android/app/build.gradle | 5 + .../playground/PlaygroundActivity.java | 3 +- Apps/Playground/CMakeLists.txt | 1 + Apps/Playground/Scripts/audio.js | 114 ++++++++++++++++++ Apps/Playground/iOS/AppDelegate.swift | 3 +- 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 Apps/Playground/Scripts/audio.js diff --git a/Apps/Playground/Android/app/build.gradle b/Apps/Playground/Android/app/build.gradle index ac75899a0..b2ca6715c 100644 --- a/Apps/Playground/Android/app/build.gradle +++ b/Apps/Playground/Android/app/build.gradle @@ -82,6 +82,11 @@ tasks.named('preBuild').configure { dependsOn ':BabylonNative:assembleRelease' } tasks.register('copyFiles') { doLast { + copy { + from '../../../node_modules/babylonjs-addons' + include "babylonjs.addons.js" + into 'src/main/assets/Scripts' + } copy { from '../../../node_modules/babylonjs' include "babylon.max.js" diff --git a/Apps/Playground/Android/app/src/main/java/com/android/babylonnative/playground/PlaygroundActivity.java b/Apps/Playground/Android/app/src/main/java/com/android/babylonnative/playground/PlaygroundActivity.java index ca725d6ca..0a68ee1e3 100644 --- a/Apps/Playground/Android/app/src/main/java/com/android/babylonnative/playground/PlaygroundActivity.java +++ b/Apps/Playground/Android/app/src/main/java/com/android/babylonnative/playground/PlaygroundActivity.java @@ -38,7 +38,8 @@ protected void onCreate(Bundle icicle) { // the first View attach completes engine init on the JS thread, in // submission order. loadBootstrapScripts(mRuntimeHandle); - BabylonNative.runtimeLoadScript(mRuntimeHandle, "app:///Scripts/experience.js"); + BabylonNative.runtimeLoadScript(mRuntimeHandle, "app:///Scripts/audio.js"); + BabylonNative.runtimeLoadScript(mRuntimeHandle, "app:///Scripts/playground_runner.js"); mView = new BabylonView(getApplication(), mRuntimeHandle); setContentView(mView); diff --git a/Apps/Playground/CMakeLists.txt b/Apps/Playground/CMakeLists.txt index e24dd7be0..ee610c9cf 100644 --- a/Apps/Playground/CMakeLists.txt +++ b/Apps/Playground/CMakeLists.txt @@ -15,6 +15,7 @@ set(DEPENDENCIES set(SCRIPTS "Scripts/experience.js" + "Scripts/audio.js" "Scripts/playground_runner.js" "Scripts/validation_native.js" "Scripts/config.json") diff --git a/Apps/Playground/Scripts/audio.js b/Apps/Playground/Scripts/audio.js new file mode 100644 index 000000000..e9f997401 --- /dev/null +++ b/Apps/Playground/Scripts/audio.js @@ -0,0 +1,114 @@ +/// + +(function () { + if (typeof document === "undefined") { + globalThis.document = { + addEventListener: function () { }, + removeEventListener: function () { }, + createElement: function (tag) { + if (tag === "audio") { + return new Audio(); + } + return {}; + } + }; + } + + function createBeepWavArrayBuffer(sampleRate, durationSeconds, frequency, amplitude) { + const frameCount = Math.max(1, Math.floor(sampleRate * durationSeconds)); + const bytesPerSample = 2; + const channelCount = 1; + const dataSize = frameCount * channelCount * bytesPerSample; + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + + const writeString = function (offset, value) { + for (let i = 0; i < value.length; i++) { + view.setUint8(offset + i, value.charCodeAt(i)); + } + }; + + writeString(0, "RIFF"); + view.setUint32(4, 36 + dataSize, true); + writeString(8, "WAVE"); + writeString(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channelCount, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * channelCount * bytesPerSample, true); + view.setUint16(32, channelCount * bytesPerSample, true); + view.setUint16(34, 8 * bytesPerSample, true); + writeString(36, "data"); + view.setUint32(40, dataSize, true); + + for (let frame = 0; frame < frameCount; frame++) { + const sample = Math.sin((2 * Math.PI * frequency * frame) / sampleRate) * amplitude; + const intSample = Math.max(-32768, Math.min(32767, Math.round(sample * 32767))); + view.setInt16(44 + frame * bytesPerSample, intSample, true); + } + + return buffer; + } + + function createScene() { + const engine = new BABYLON.NativeEngine({ adaptToDeviceRatio: true }); + const scene = new BABYLON.Scene(engine); + scene.createDefaultCamera(true, true, true); + + const resumeAudioIfNeeded = function () { + if (typeof AudioContext !== "undefined") { + const probe = new AudioContext(); + if (probe.state === "suspended") { + probe.resume(); + } + } + }; + scene.onPointerObservable.add(resumeAudioIfNeeded); + + const beepBuffer = createBeepWavArrayBuffer(44100, 0.25, 880, 0.4); + let spatialSource = null; + + if (typeof BABYLON.Sound === "function") { + const legacySound = new BABYLON.Sound("beep", beepBuffer, scene, null, { + autoplay: true, + loop: true, + spatialSound: true, + maxDistance: 20 + }); + spatialSource = legacySound; + BABYLON.Tools.Log("Playing legacy BABYLON.Sound beep"); + } + + if (typeof BABYLON.CreateAudioEngineAsync === "function" && typeof BABYLON.CreateSoundAsync === "function") { + BABYLON.CreateAudioEngineAsync().then(function (audioEngine) { + return BABYLON.CreateSoundAsync("beepV2", beepBuffer, { audioEngine: audioEngine }); + }).then(function (sound) { + sound.play(); + spatialSource = sound; + BABYLON.Tools.Log("Playing CreateSoundAsync beep"); + }).catch(function (error) { + BABYLON.Tools.Warn("CreateSoundAsync path failed: " + error); + }); + } + + let angle = 0; + scene.onBeforeRenderObservable.add(function () { + angle += 0.02; + const x = Math.cos(angle) * 3; + const z = Math.sin(angle) * 3; + + if (spatialSource) { + if (typeof spatialSource.setPosition === "function") { + spatialSource.setPosition(new BABYLON.Vector3(x, 0, z)); + } else if (spatialSource._soundSource) { + spatialSource._soundSource.position = new BABYLON.Vector3(x, 0, z); + } + } + }); + + return scene; + } + + globalThis.createScene = createScene; +})(); diff --git a/Apps/Playground/iOS/AppDelegate.swift b/Apps/Playground/iOS/AppDelegate.swift index 3c1e7165e..867e2e48b 100644 --- a/Apps/Playground/iOS/AppDelegate.swift +++ b/Apps/Playground/iOS/AppDelegate.swift @@ -24,7 +24,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // first BNView attach completes engine initialization on the JS // thread, in submission order. PlaygroundBootstrap.loadScripts(runtime) - runtime.loadScript("app:///Scripts/experience.js") + runtime.loadScript("app:///Scripts/audio.js") + runtime.loadScript("app:///Scripts/playground_runner.js") self.runtime = runtime return true From ffb1fd6fdf0340ce333db667ed2a54c0e717008e Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Fri, 3 Jul 2026 12:36:19 -0700 Subject: [PATCH 5/6] Document WebAudio as partially supported in project status. Point readers to the WebAudio polyfill readme for platform coverage and mobile validation guidance. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 89f5ec5f3..18e8871a8 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Many core features from Babylon.js are supported. Here is a non-exhaustive list. The following are features that are not completely finished yet or have gaps. +- Audio (Web Audio polyfill for Babylon.js buffer playback, spatial audio, gain/pitch automation, analysers, and scheduled `start(when)`; desktop validated via unit tests; iOS/Android integrated via Embedding with Playground on-device harness — see [Polyfills/WebAudio/readme.md](Polyfills/WebAudio/readme.md)) - Environment (only `.env` supported) - GUI (text rendering experimentally supported) - Input (only single pointer supported) @@ -90,7 +91,6 @@ The following are features that are not completely finished yet or have gaps. The following are features that don't work right now but will work in the future. -- Audio - Serializers - Particles - Texture Loaders (KTX, DDS, etc.) From 73acf9c7b1c6b50bbcd41b15e7971f33061805ec Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Fri, 3 Jul 2026 12:45:31 -0700 Subject: [PATCH 6/6] Improve desktop audio demo startup and document stub. Resume AudioContext on audio.js load so legacy Sound autoplay works on desktop, and add createTextNode to the built-in document stub for Babylon v9 audio engine initialization. --- Apps/Playground/Scripts/audio.js | 101 +++++++++++++------- Documentation/Polyfills.md | 2 +- Polyfills/WebAudio/{readme.md => Readme.md} | 49 ---------- Polyfills/WebAudio/Source/WebAudio.cpp | 13 +++ README.md | 2 +- 5 files changed, 80 insertions(+), 87 deletions(-) rename Polyfills/WebAudio/{readme.md => Readme.md} (63%) diff --git a/Apps/Playground/Scripts/audio.js b/Apps/Playground/Scripts/audio.js index e9f997401..8a9d68617 100644 --- a/Apps/Playground/Scripts/audio.js +++ b/Apps/Playground/Scripts/audio.js @@ -10,6 +10,9 @@ return new Audio(); } return {}; + }, + createTextNode: function (text) { + return { nodeValue: text }; } }; } @@ -51,62 +54,88 @@ return buffer; } + let clickAudioContext = null; + let clickAudioBuffer = null; + + async function playClickBeep() { + if (typeof AudioContext === "undefined") { + BABYLON.Tools.Warn("AudioContext is not available"); + return; + } + + if (!clickAudioContext) { + clickAudioContext = new AudioContext(); + } + + if (clickAudioContext.state === "suspended") { + await clickAudioContext.resume(); + } + + if (!clickAudioBuffer) { + const wavBytes = createBeepWavArrayBuffer(44100, 0.25, 880, 0.4); + clickAudioBuffer = await clickAudioContext.decodeAudioData(wavBytes.slice(0)); + } + + const source = clickAudioContext.createBufferSource(); + source.buffer = clickAudioBuffer; + source.connect(clickAudioContext.destination); + source.start(clickAudioContext.currentTime); + BABYLON.Tools.Log("Click beep"); + } + function createScene() { const engine = new BABYLON.NativeEngine({ adaptToDeviceRatio: true }); const scene = new BABYLON.Scene(engine); scene.createDefaultCamera(true, true, true); - const resumeAudioIfNeeded = function () { - if (typeof AudioContext !== "undefined") { - const probe = new AudioContext(); - if (probe.state === "suspended") { - probe.resume(); - } - } - }; - scene.onPointerObservable.add(resumeAudioIfNeeded); - const beepBuffer = createBeepWavArrayBuffer(44100, 0.25, 880, 0.4); let spatialSource = null; + let spatialStarted = false; - if (typeof BABYLON.Sound === "function") { - const legacySound = new BABYLON.Sound("beep", beepBuffer, scene, null, { - autoplay: true, - loop: true, - spatialSound: true, - maxDistance: 20 - }); - spatialSource = legacySound; - BABYLON.Tools.Log("Playing legacy BABYLON.Sound beep"); - } + scene.onPointerObservable.add(function (pointerInfo) { + if (pointerInfo.type !== BABYLON.PointerEventTypes.POINTERDOWN) { + return; + } - if (typeof BABYLON.CreateAudioEngineAsync === "function" && typeof BABYLON.CreateSoundAsync === "function") { - BABYLON.CreateAudioEngineAsync().then(function (audioEngine) { - return BABYLON.CreateSoundAsync("beepV2", beepBuffer, { audioEngine: audioEngine }); - }).then(function (sound) { - sound.play(); - spatialSource = sound; - BABYLON.Tools.Log("Playing CreateSoundAsync beep"); - }).catch(function (error) { - BABYLON.Tools.Warn("CreateSoundAsync path failed: " + error); + playClickBeep().catch(function (error) { + BABYLON.Tools.Warn("Click beep failed: " + error); }); - } + + if (!spatialStarted && typeof BABYLON.Sound === "function") { + spatialStarted = true; + const legacySound = new BABYLON.Sound("beep", beepBuffer, scene, function () { + BABYLON.Tools.Log("Spatial beep started after click"); + }, { + autoplay: true, + loop: true, + spatialSound: true, + maxDistance: 20 + }); + spatialSource = legacySound; + } else if (spatialSource && typeof spatialSource.play === "function") { + spatialSource.play(); + } + }); let angle = 0; scene.onBeforeRenderObservable.add(function () { + if (!spatialSource) { + return; + } + angle += 0.02; const x = Math.cos(angle) * 3; const z = Math.sin(angle) * 3; - if (spatialSource) { - if (typeof spatialSource.setPosition === "function") { - spatialSource.setPosition(new BABYLON.Vector3(x, 0, z)); - } else if (spatialSource._soundSource) { - spatialSource._soundSource.position = new BABYLON.Vector3(x, 0, z); - } + if (typeof spatialSource.setPosition === "function") { + spatialSource.setPosition(new BABYLON.Vector3(x, 0, z)); + } else if (spatialSource._soundSource) { + spatialSource._soundSource.position = new BABYLON.Vector3(x, 0, z); } }); + BABYLON.Tools.Log("Click anywhere to play a beep (and start spatial audio)"); + return scene; } diff --git a/Documentation/Polyfills.md b/Documentation/Polyfills.md index 500c648a8..fdd90baee 100644 --- a/Documentation/Polyfills.md +++ b/Documentation/Polyfills.md @@ -6,6 +6,6 @@ The polyfills are still in an early stage and are subject to change. At the moment, we are using the following polyfills: * [Canvas](../Polyfills/Canvas/readme.md) * [Console](../Polyfills/Console/readme.md) -* [WebAudio](../Polyfills/WebAudio/readme.md) +* [WebAudio](../Polyfills/WebAudio/Readme.md) * [Window](../Polyfills/Window/readme.md) * [XMLHttpRequest](../Polyfills/XMLHttpRequest/readme.md) \ No newline at end of file diff --git a/Polyfills/WebAudio/readme.md b/Polyfills/WebAudio/Readme.md similarity index 63% rename from Polyfills/WebAudio/readme.md rename to Polyfills/WebAudio/Readme.md index f96ba7ab3..04fdd0763 100644 --- a/Polyfills/WebAudio/readme.md +++ b/Polyfills/WebAudio/Readme.md @@ -62,52 +62,3 @@ On mobile, ensure the first audible playback follows a user gesture and call `Au ## Playground demo Playground loads `app:///Scripts/audio.js` on desktop, iOS, and Android. The script generates an in-memory beep WAV and plays it through legacy `BABYLON.Sound` and/or `CreateSoundAsync` while orbiting a spatialized source around the camera. - -## Mobile validation checklist - -Use this checklist on physical devices with the speaker unmuted. - -### Build and wiring - -- [ ] `BABYLON_NATIVE_POLYFILL_WEBAUDIO=ON` in the app CMake configuration -- [ ] App links `Embedding` / `BabylonNativeEmbedding` so `WebAudio::Initialize` and platform hooks run -- [ ] JavaScript bundle includes Babylon audio code paths you intend to use (`Sound` and/or `CreateSoundAsync`) - -### JavaScript bootstrap - -- [ ] `typeof AudioContext !== "undefined"` after runtime startup -- [ ] `await new AudioContext().resume()` resolves without error after a user gesture - -### Functional smoke tests - -- [ ] Decode a small in-memory WAV via `decodeAudioData` and play through `bufferSource → gain → destination` -- [ ] Legacy `new BABYLON.Sound(name, arrayBuffer, scene, null, { autoplay: true })` is audible -- [ ] v9 `CreateAudioEngineAsync` + `CreateSoundAsync` is audible (if used) -- [ ] Moving the listener / camera changes spatialized sound level -- [ ] `ended` fires when a short non-looping buffer finishes -- [ ] App survives background/foreground without crashing (audio may stay suspended until user interaction) - -### Platform-specific - -**iOS** - -- [ ] `AVAudioSession` category `playback` activates on first play (handled by Embedding) -- [ ] Audio plays after device silent switch is on -- [ ] First play after cold start works after user gesture + `AudioContext.resume()` -- [ ] No audio glitches when rotating, locking, or receiving a phone call - -**Android** - -- [ ] `RECORD_AUDIO` is **not** required for playback-only apps -- [ ] Audio focus handled (pause on focus loss, resume when regained if contexts are running) -- [ ] Playback works on API 24+ targets used by the app -- [ ] First play after cold start works after user interaction on restrictive OEM builds - -### Regression / CI - -- [ ] `BABYLON_NATIVE_WEBAUDIO_BACKEND=null ./UnitTests --gtest_filter=JavaScript.All` passes on your CI host - -### Known gaps to watch for - -- Shared engine clock across `AudioContext` instances (same as a single native device) -- Panner uses simplified equal-power azimuth, not browser HRTF diff --git a/Polyfills/WebAudio/Source/WebAudio.cpp b/Polyfills/WebAudio/Source/WebAudio.cpp index 38b33d89d..656eedbe2 100644 --- a/Polyfills/WebAudio/Source/WebAudio.cpp +++ b/Polyfills/WebAudio/Source/WebAudio.cpp @@ -108,6 +108,19 @@ namespace Babylon::Polyfills "removeEventListener", Napi::Function::New(env, [](const Napi::CallbackInfo&) {}, "removeEventListener")); document.Set("createElement", Napi::Function::New(env, DocumentCreateElement, "createElement")); + document.Set( + "createTextNode", + Napi::Function::New( + env, + [](const Napi::CallbackInfo& info) { + auto node = Napi::Object::New(info.Env()); + if (info.Length() > 0) + { + node.Set("nodeValue", info[0]); + } + return node; + }, + "createTextNode")); env.Global().Set("document", document); } } diff --git a/README.md b/README.md index 18e8871a8..2ab3404e6 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Many core features from Babylon.js are supported. Here is a non-exhaustive list. The following are features that are not completely finished yet or have gaps. -- Audio (Web Audio polyfill for Babylon.js buffer playback, spatial audio, gain/pitch automation, analysers, and scheduled `start(when)`; desktop validated via unit tests; iOS/Android integrated via Embedding with Playground on-device harness — see [Polyfills/WebAudio/readme.md](Polyfills/WebAudio/readme.md)) +- Audio (Web Audio polyfill for Babylon.js buffer playback, spatial audio, gain/pitch automation, analysers, and scheduled `start(when)`; desktop validated via unit tests; iOS/Android integrated via Embedding with Playground on-device harness — see [Polyfills/WebAudio/Readme.md](Polyfills/WebAudio/Readme.md)) - Environment (only `.env` supported) - GUI (text rendering experimentally supported) - Input (only single pointer supported)