diff --git a/.claude/skills/audio-nodes/SKILL.md b/.claude/skills/audio-nodes/SKILL.md index da108b451..eb7af2812 100644 --- a/.claude/skills/audio-nodes/SKILL.md +++ b/.claude/skills/audio-nodes/SKILL.md @@ -278,6 +278,22 @@ quantum). Consequently, unit tests that re-process the same node must advance th `getValueAtTimeUnmodulated` / `ParamRenderQueue`). Clip only in `finalizeKRate` / `finalizeARate` after adding modulation — never on the intrinsic alone before modulation. +**Automation timing model (hybrid snapped/raw times):** `ParamRenderQueue::push` stores each +event's times twice — the inherited start/end are snapped to `round(T * sampleRate) / sampleRate` +and drive ordering, popping, and effect boundaries (matching Blink and the WPT reference's +`timeToSampleFrame`), while `rawStartTime`/`rawEndTime` keep the scheduled times and feed the +`calculateValue` interpolation (a ramp between two times inside one frame must interpolate on +the real times — WPT `audioparam-close.html`). A queued event supersedes the current one as soon +as its snapped start is due, even mid-ramp. On the evaluation side, `processARateParam` derives +each sample's time as `(quantumStartFrame + i) / sampleRate` — never accumulate +`time += 1/sampleRate`, the ULP drift lands boundaries one frame late. + +**Param queue capacity:** both param event queues (`ParamRenderQueue` on `AudioParam`, +`ParamControlQueue` on the host object) are bounded by `AUDIO_PARAM_MAX_QUEUED_EVENTS` and +**silently drop** events past capacity (`BoundedPriorityQueue::push` returns false, nobody +checks). Automation scheduled far ahead must fit entirely; the WPT audioparam suites queue +100 events per file. Symptom of overflow: automation freezes at the last accepted event. + ### JS → Audio Thread parameter updates `CrossThreadEventScheduler` is a lock-free SPSC channel. When JS calls `param.setValueAtTime(...)`, it enqueues a lambda on the scheduler. The audio thread drains the queue at the start of each `processARateParam` / `processKRateParam` call. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioParamHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioParamHostObject.h index ea2f4fda9..7846fcc9f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioParamHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioParamHostObject.h @@ -16,8 +16,10 @@ using namespace facebook; class AudioParam; /// Rough native footprint of an AudioParamHostObject: -/// two DSPAudioBuffer(RQ) (k-rate + a-rate scratch) + control queue + atomics. -inline constexpr size_t kAudioParamBytes = 2 * RENDER_QUANTUM_SIZE * sizeof(float) + 512; +/// two DSPAudioBuffer(RQ) (k-rate + a-rate scratch) + the preallocated control +/// and render event queues (~160 B combined per slot) + atomics. +inline constexpr size_t kAudioParamBytes = + 2 * RENDER_QUANTUM_SIZE * sizeof(float) + AUDIO_PARAM_MAX_QUEUED_EVENTS * 160 + 512; /// @brief Host object for AudioParam that owns its BridgeNode. /// diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.cpp index 2a485b527..c597ef2ec 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.cpp @@ -16,7 +16,7 @@ AudioParam::AudioParam( : GeneralizedAudioParam(minValue, maxValue, context), value_(defaultValue), defaultValue_(defaultValue), - eventRenderQueue_(defaultValue), + eventRenderQueue_(defaultValue, context->getSampleRate()), inputBuffer_( std::make_shared(RENDER_QUANTUM_SIZE, 1, context->getSampleRate())) {} @@ -74,15 +74,19 @@ std::shared_ptr AudioParam::processARateParam(int framesToProces } float sampleRate = context->getSampleRate(); - double timeCache = time; - double timeStep = 1.0 / sampleRate; + // Evaluate each sample at the exact frame time `frame / sampleRate` instead + // of accumulating `time += 1/sampleRate`: accumulation drifts by a few ULPs + // per quantum, which is enough to observe a snapped event boundary one + // frame late. + auto quantumStartFrame = static_cast(dsp::timeToSampleFrame(time, sampleRate)); // Read modulation from input buffer (filled by BridgeNode if connected, otherwise zeros) auto inputData = inputBuffer_->getChannel(0)->span(); auto outputData = outputBuffer_->getChannel(0)->span(); - for (int i = 0; i < framesToProcess; i++, timeCache += timeStep) { - outputData[i] = inputData[i] + getValueAtTimeUnmodulated(timeCache); + for (int i = 0; i < framesToProcess; i++) { + double frameTime = dsp::sampleFrameToTime(quantumStartFrame + i, sampleRate); + outputData[i] = inputData[i] + getValueAtTimeUnmodulated(frameTime); } inputBuffer_->zero(); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h index e84191247..535f44861 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h @@ -49,5 +49,11 @@ constexpr std::size_t hardware_destructive_interference_size = 64; #endif // audio param -inline constexpr size_t AUDIO_PARAM_MAX_QUEUED_EVENTS = 64; +/// Slots preallocated per param queue (both the render queue on AudioParam and +/// the control queue on AudioParamHostObject). Events past capacity are +/// SILENTLY DROPPED — automation scheduled far ahead (e.g. a per-beat value +/// sequence for a whole track, or the 100-event WPT audioparam suites) must +/// fit here in full, so keep a generous margin. Cost: each slot reserves +/// ~100 B in AudioParam and ~60 B in its host object. +inline constexpr size_t AUDIO_PARAM_MAX_QUEUED_EVENTS = 256; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamQueueBase.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamQueueBase.hpp index 4139db34e..546f611e0 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamQueueBase.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamQueueBase.hpp @@ -20,7 +20,7 @@ class ParamQueueBase { /// @brief Cancel scheduled parameter changes at or after the given time. /// @param cancelTime The time at which to cancel scheduled changes. - void cancelScheduledValues(double cancelTime) { + virtual void cancelScheduledValues(double cancelTime) { eventQueue_.erase(eventQueue_.lowerBound(cancelTime), eventQueue_.end()); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderEventFactory.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderEventFactory.h index a45e3449c..757e1d6f7 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderEventFactory.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderEventFactory.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include #include @@ -10,17 +12,24 @@ namespace audioapi { /// @brief A factory for creating RenderParamEvents and resolving their values /// based on the current state of the queue. +/// +/// The calculateValue functions are pure interpolation formulas evaluated on an +/// event's RAW times; whether an event is in effect at a given frame is decided +/// by ParamRenderQueue::computeValueAtTime against the snapped times. Because a +/// frame's exact time can fall fractionally outside [rawStartTime, rawEndTime) +/// while the frame still belongs to the event, each formula must extrapolate +/// gracefully (clamp, or accept a sub-ULP overshoot) instead of branching on +/// the raw boundary. class ParamRenderEventFactory { public: static RenderParamEvent createSetValueEvent(float value, double startTime) { - auto calculateValue = - [](double startTime, double /* endTime */, float startValue, float endValue, double time) { - if (time < startTime) { - return startValue; - } - - return endValue; - }; + auto calculateValue = [](double /* startTime */, + double /* endTime */, + float /* startValue */, + float endValue, + double /* time */) { + return endValue; + }; return RenderParamEvent( startTime, startTime, value, value, std::move(calculateValue), ParamEventType::SET_VALUE); @@ -29,16 +38,12 @@ class ParamRenderEventFactory { static RenderParamEvent createLinearRampEvent(float value, double endTime) { auto calculateValue = [](double startTime, double endTime, float startValue, float endValue, double time) { - if (time < startTime) { - return startValue; - } - - if (time < endTime) { - return static_cast( - startValue + (endValue - startValue) * (time - startTime) / (endTime - startTime)); + if (endTime <= startTime) { + return endValue; } - return endValue; + return static_cast( + startValue + (endValue - startValue) * (time - startTime) / (endTime - startTime)); }; return RenderParamEvent( @@ -52,17 +57,12 @@ class ParamRenderEventFactory { return startValue; } - if (time < startTime) { - return startValue; - } - - if (time < endTime) { - return static_cast( - startValue * - pow(endValue / startValue, (time - startTime) / (endTime - startTime))); + if (endTime <= startTime) { + return endValue; } - return endValue; + return static_cast( + startValue * pow(endValue / startValue, (time - startTime) / (endTime - startTime))); }; return RenderParamEvent( @@ -81,10 +81,6 @@ class ParamRenderEventFactory { return target; } - if (time < startTime) { - return startValue; - } - return static_cast( target + (startValue - target) * exp(-(time - startTime) / timeConstant)); }; @@ -106,21 +102,20 @@ class ParamRenderEventFactory { auto calculateValue = [values, length]( double startTime, double endTime, float startValue, float endValue, double time) { - if (time < startTime) { - return startValue; - } - - if (time < endTime) { - // Calculate position in the array based on time progress - auto k = static_cast(std::floor( - static_cast(length - 1) / (endTime - startTime) * (time - startTime))); - // Calculate interpolation factor between adjacent array elements - auto factor = static_cast( - (time - startTime) * static_cast(length - 1) / (endTime - startTime) - k); - return dsp::linearInterpolate(values->span(), k, k + 1, factor); + if (endTime <= startTime) { + return endValue; } - return endValue; + // Position in the array based on time progress, clamped so a frame + // snapped fractionally outside the raw interval stays in range. + double position = std::clamp( + static_cast(length - 1) / (endTime - startTime) * (time - startTime), + 0.0, + static_cast(length - 1)); + auto k = static_cast(position); + size_t nextIndex = std::min(k + 1, length - 1); + auto factor = static_cast(position - static_cast(k)); + return dsp::linearInterpolate(values->span(), k, nextIndex, factor); }; return RenderParamEvent( diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.cpp index 00f947719..9ec3d88c3 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.cpp @@ -2,17 +2,25 @@ #include #include #include +#include #include #include #include namespace audioapi { +double ParamRenderQueue::snapToSampleFrameTime(double time) const { + return dsp::sampleFrameToTime( + static_cast(dsp::timeToSampleFrame(time, sampleRate_)), sampleRate_); +} + std::optional ParamRenderQueue::computeValueAtTime(double time) { - while ( - !eventQueue_.isEmpty() && - (!currentEvent_ || - (time >= currentEvent_->getEndTime() && eventQueue_.peekFront().getStartTime() <= time))) { + // A queued event whose snapped effect time has arrived supersedes the + // current one even when the current event's own interval has not elapsed + // (e.g. a setValueAtTime whose frame lands fractionally before a ramp's raw + // end time). + while (!eventQueue_.isEmpty() && + (!currentEvent_ || eventQueue_.peekFront().getStartTime() <= time)) { RenderParamEvent next; eventQueue_.pop(next); currentEvent_ = std::move(next); @@ -22,15 +30,35 @@ std::optional ParamRenderQueue::computeValueAtTime(double time) { return std::nullopt; } - return currentEvent_->getCalculateValue()( - currentEvent_->getStartTime(), - currentEvent_->getEndTime(), - currentEvent_->getStartValue(), - currentEvent_->getEndValue(), + const RenderParamEvent &event = *currentEvent_; + if (time < event.getStartTime()) { + return event.getStartValue(); + } + + // Ramps stay active until their raw end so sub-frame intervals still + // interpolate; other finite events end on their snapped boundary. SetTarget + // never ends on its own. + double effectiveEndTime = event.isRampType() ? event.getRawEndTime() : event.getEndTime(); + if (event.getType() != ParamEventType::SET_TARGET && time >= effectiveEndTime) { + return event.getEndValue(); + } + + return event.getCalculateValue()( + event.getRawStartTime(), + event.getRawEndTime(), + event.getStartValue(), + event.getEndValue(), time); } bool ParamRenderQueue::push(RenderParamEvent &&event) { + // Keep the scheduled times for interpolation; snap the effect boundaries + // onto the sample-frame grid for ordering and comparisons. + event.setRawStartTime(event.getStartTime()); + event.setRawEndTime(event.getEndTime()); + event.setStartTime(snapToSampleFrameTime(event.getStartTime())); + event.setEndTime(snapToSampleFrameTime(event.getEndTime())); + resolveEventValues(event); return ParamQueueBase::push(std::move(event)); } @@ -45,14 +73,16 @@ void ParamRenderQueue::resolveEventValues(RenderParamEvent &event) { // if the new event is a ramp resolve its startTime and startValue from the predecessor event if (event.isRampType()) { event.setStartTime(predIt->getEndTime()); + event.setRawStartTime(predIt->getRawEndTime()); } - event.setStartValue(getValueOfPreviousEventAt(*predIt, event.getStartTime())); + event.setStartValue(getValueOfPreviousEventAt(*predIt, event.getRawStartTime())); // If the predecessor is a setTarget event, adjust its endTime and endValue to connect to the new event if (predIt->getType() == ParamEventType::SET_TARGET) { - float newEndValue = getValueOfPreviousEventAt(*predIt, event.getStartTime()); + float newEndValue = getValueOfPreviousEventAt(*predIt, event.getRawStartTime()); auto node = eventQueue_.extract(predIt); node.value().setEndTime(event.getStartTime()); + node.value().setRawEndTime(event.getRawStartTime()); node.value().setEndValue(newEndValue); eventQueue_.insert(it, std::move(node)); } @@ -62,13 +92,16 @@ void ParamRenderQueue::resolveEventValues(RenderParamEvent &event) { // if the new event is a ramp resolve its startTime and startValue from the predecessor event if (event.isRampType()) { event.setStartTime(currentEvent_->getEndTime()); + event.setRawStartTime(currentEvent_->getRawEndTime()); } - event.setStartValue(getValueOfPreviousEventAt(*currentEvent_, event.getStartTime())); + event.setStartValue(getValueOfPreviousEventAt(*currentEvent_, event.getRawStartTime())); // If the predecessor is a setTarget event, adjust its endTime and endValue to connect to the new event if (currentEvent_->getType() == ParamEventType::SET_TARGET) { + currentEvent_->setEndValue( + getValueOfPreviousEventAt(*currentEvent_, event.getRawStartTime())); currentEvent_->setEndTime(event.getStartTime()); - currentEvent_->setEndValue(getValueOfPreviousEventAt(*currentEvent_, event.getStartTime())); + currentEvent_->setRawEndTime(event.getRawStartTime()); } } else { // Case 3: no predecessor at all — fall back to default value @@ -80,6 +113,7 @@ void ParamRenderQueue::resolveEventValues(RenderParamEvent &event) { auto hint = std::next(it); auto node = eventQueue_.extract(it); node.value().setStartTime(event.getEndTime()); + node.value().setRawStartTime(event.getRawEndTime()); node.value().setStartValue(event.getEndValue()); eventQueue_.insert(hint, std::move(node)); } @@ -88,25 +122,32 @@ void ParamRenderQueue::resolveEventValues(RenderParamEvent &event) { float ParamRenderQueue::getValueOfPreviousEventAt(const RenderParamEvent &event, double time) { if (event.getType() == ParamEventType::SET_TARGET) { return event.getCalculateValue()( - event.getStartTime(), event.getEndTime(), event.getStartValue(), event.getEndValue(), time); + event.getRawStartTime(), + event.getRawEndTime(), + event.getStartValue(), + event.getEndValue(), + time); } return event.getEndValue(); } void ParamRenderQueue::cancelAndHoldAtTime(double cancelTime) { + cancelTime = snapToSampleFrameTime(cancelTime); + // E2: first event with automationTime strictly after cancelTime auto e2It = eventQueue_.upperBound(cancelTime); if (e2It != eventQueue_.end() && e2It->isRampType()) { // Spec step 3: E2 is a ramp — truncate it to end at cancelTime float holdValue = e2It->getCalculateValue()( - e2It->getStartTime(), - e2It->getEndTime(), + e2It->getRawStartTime(), + e2It->getRawEndTime(), e2It->getStartValue(), e2It->getEndValue(), cancelTime); auto node = eventQueue_.extract(e2It); node.value().setEndTime(cancelTime); + node.value().setRawEndTime(cancelTime); node.value().setEndValue(holdValue); auto insertPos = eventQueue_.upperBound(cancelTime); eventQueue_.insert(insertPos, std::move(node)); @@ -130,14 +171,15 @@ void ParamRenderQueue::cancelAndHoldAtTime(double cancelTime) { if (e1It->getType() == ParamEventType::SET_VALUE_CURVE && cancelTime <= e1It->getEndTime()) { // Truncate curve; compute holdValue using original endTime to preserve sampling behaviour float holdValue = e1It->getCalculateValue()( - e1It->getStartTime(), - e1It->getEndTime(), + e1It->getRawStartTime(), + e1It->getRawEndTime(), e1It->getStartValue(), e1It->getEndValue(), cancelTime); auto hint = std::next(e1It); auto node = eventQueue_.extract(e1It); node.value().setEndTime(cancelTime); + node.value().setRawEndTime(cancelTime); node.value().setEndValue(holdValue); eventQueue_.insert(hint, std::move(node)); // fall through to step 5 @@ -155,12 +197,13 @@ void ParamRenderQueue::cancelAndHoldAtTime(double cancelTime) { if (currentEvent_->getType() == ParamEventType::SET_VALUE_CURVE && cancelTime <= currentEvent_->getEndTime()) { float holdValue = currentEvent_->getCalculateValue()( - currentEvent_->getStartTime(), - currentEvent_->getEndTime(), + currentEvent_->getRawStartTime(), + currentEvent_->getRawEndTime(), currentEvent_->getStartValue(), currentEvent_->getEndValue(), cancelTime); currentEvent_->setEndTime(cancelTime); + currentEvent_->setRawEndTime(cancelTime); currentEvent_->setEndValue(holdValue); // fall through to step 5 } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.h index 5f8adf7ef..08b42a532 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.h @@ -8,9 +8,15 @@ namespace audioapi { /// @brief A queue for managing audio parameter change events on the audio render thread. /// @note The invariant of the queue is that its internal buffer always contains non-overlapping events. +/// +/// Event times are kept in two forms (see RenderParamEvent): push() snaps the +/// inherited times onto the sample-frame grid of @c sampleRate while preserving +/// the scheduled times in the raw fields, so effect boundaries land on +/// round(T * sampleRate) frames and interpolation still runs on real times. class ParamRenderQueue : public ParamQueueBase { public: - explicit ParamRenderQueue(float defaultValue) : defaultValue_(defaultValue) {} + explicit ParamRenderQueue(float defaultValue, float sampleRate) + : defaultValue_(defaultValue), sampleRate_(sampleRate) {} /// @brief Compute the value at a specific time based on the events in the queue. /// @param time The time at which to compute the value. @@ -22,12 +28,24 @@ class ParamRenderQueue : public ParamQueueBase { /// @return True if the event was successfully added, false if the queue is full. bool push(RenderParamEvent &&event) override; + /// @brief Cancel scheduled parameter changes at or after the given time + /// (compared on the snapped time grid). + void cancelScheduledValues(double cancelTime) override { + ParamQueueBase::cancelScheduledValues(snapToSampleFrameTime(cancelTime)); + } + /// @brief Cancel scheduled parameter changes and hold the current value at the given time. /// @param cancelTime The time at which to cancel scheduled changes. void cancelAndHoldAtTime(double cancelTime); private: float defaultValue_; + float sampleRate_; + + /// @brief Snap a time to the exact time of its nearest sample frame + /// (round(time * sampleRate) / sampleRate), the grid the render + /// loop evaluates frames on. + [[nodiscard]] double snapToSampleFrameTime(double time) const; /// @brief Resolve new event's startValue and startTime based on the previous event in the queue, /// and adjust neighboring events to maintain the invariant of non-overlapping events in the queue. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/RenderParamEvent.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/RenderParamEvent.h index 6d5b843f0..f63502681 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/RenderParamEvent.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/RenderParamEvent.h @@ -11,6 +11,15 @@ namespace audioapi { /// @brief A RenderParamEvent extends ParamEvent with additional properties and a value calculation /// function that can compute the parameter value at any time during the event's active period /// based on its type and the current state of the queue. +/// +/// Times exist in two forms. The inherited startTime/endTime are snapped to the +/// sample-frame grid by ParamRenderQueue::push and drive ordering and +/// effect-boundary decisions, so an event scheduled at T takes effect exactly +/// at frame round(T * sampleRate) — the convention Blink and the WPT reference +/// use. rawStartTime/rawEndTime keep the times as scheduled and feed the value +/// interpolation, which the spec defines on real times: a ramp between two +/// times inside one frame must still interpolate on those times, not on their +/// collapsed snapped values. class RenderParamEvent : public ParamEvent { public: RenderParamEvent() = default; @@ -25,6 +34,8 @@ class RenderParamEvent : public ParamEvent { ParamEventType type) : ParamEvent(type, startTime, endTime), calculateValue_(std::move(calculateValue)), + rawStartTime_(startTime), + rawEndTime_(endTime), startValue_(startValue), endValue_(endValue) {} @@ -34,6 +45,8 @@ class RenderParamEvent : public ParamEvent { RenderParamEvent(RenderParamEvent &&other) noexcept : ParamEvent(std::move(other)), calculateValue_(std::move(other.calculateValue_)), + rawStartTime_(other.rawStartTime_), + rawEndTime_(other.rawEndTime_), startValue_(other.startValue_), endValue_(other.endValue_) {} @@ -41,12 +54,30 @@ class RenderParamEvent : public ParamEvent { if (this != &other) { ParamEvent::operator=(std::move(other)); calculateValue_ = std::move(other.calculateValue_); + rawStartTime_ = other.rawStartTime_; + rawEndTime_ = other.rawEndTime_; startValue_ = other.startValue_; endValue_ = other.endValue_; } return *this; } + [[nodiscard]] double getRawStartTime() const noexcept { + return rawStartTime_; + } + + [[nodiscard]] double getRawEndTime() const noexcept { + return rawEndTime_; + } + + void setRawStartTime(double rawStartTime) noexcept { + rawStartTime_ = rawStartTime; + } + + void setRawEndTime(double rawEndTime) noexcept { + rawEndTime_ = rawEndTime; + } + [[nodiscard]] float getEndValue() const noexcept { return endValue_; } @@ -70,6 +101,8 @@ class RenderParamEvent : public ParamEvent { private: std::function calculateValue_; + double rawStartTime_ = 0.0; + double rawEndTime_ = 0.0; float startValue_ = 0.0f; float endValue_ = 0.0f; }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.h b/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.h index 272cfd0d0..7fb28fcd1 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.h @@ -7,8 +7,12 @@ namespace audioapi::dsp { +/// Round to the nearest frame, matching Blink and the WPT reference +/// (audit-util.js). Truncation lands one frame late whenever +/// `time * sampleRate` computes fractionally below the intended integer +/// (e.g. 0.03 * 44100 = 1322.9999...). [[nodiscard]] inline size_t timeToSampleFrame(double time, float sampleRate) { - return static_cast(time * sampleRate); + return static_cast(0.5 + time * sampleRate); } [[nodiscard]] inline double sampleFrameToTime(int sampleFrame, float sampleRate) { diff --git a/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts b/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts index 2f82eccac..9a63079c2 100644 --- a/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts +++ b/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts @@ -1,4 +1,4 @@ -import { AudioEventEmitter } from '../events'; +import { AudioEventEmitter, AudioEventSubscription } from '../events'; import type { EventEmptyType } from '../events/types'; import type { IAudioFileSourceNode, @@ -18,16 +18,21 @@ export class AudioFileSourceNode extends AudioScheduledSourceNode { globalThis.AudioEventEmitter ); + private attachedEndedSubscription: AudioEventSubscription | null = null; + private positionSubscription: AudioEventSubscription | null = null; + private bufferingSubscription: AudioEventSubscription | null = null; + attach(options: AttachFileSourceOptions): { duration: number } { this.resetNodeAndSubscriptions(); - const sub = this.emitter.addAudioEventListener( + this.attachedEndedSubscription = this.emitter.addAudioEventListener( 'ended', (_event: EventEmptyType) => { options.onEnded(); } ); - (this.node as IAudioFileSourceNode).onEnded = sub.subscriptionId; + (this.node as IAudioFileSourceNode).onEnded = + this.attachedEndedSubscription.subscriptionId; return { duration: (this.node as IAudioFileSourceNode).duration, @@ -93,16 +98,20 @@ export class AudioFileSourceNode extends AudioScheduledSourceNode { return; } this.stopPositionTracking(); - const sub = this.emitter.addAudioEventListener( + this.positionSubscription = this.emitter.addAudioEventListener( 'positionChanged', (event) => { onTime(event.value); } ); - (this.node as IAudioFileSourceNode).onPositionChanged = sub.subscriptionId; + (this.node as IAudioFileSourceNode).onPositionChanged = + this.positionSubscription.subscriptionId; } stopPositionTracking(): void { + this.positionSubscription?.remove(); + this.positionSubscription = null; + if (this.node) { (this.node as IAudioFileSourceNode).onPositionChanged = '0'; } @@ -115,26 +124,32 @@ export class AudioFileSourceNode extends AudioScheduledSourceNode { return; } this.stopBufferingTracking(); - const sub = this.emitter.addAudioEventListener( + this.bufferingSubscription = this.emitter.addAudioEventListener( 'bufferingStateChanged', (event) => { onBufferingChange(event.value); } ); (this.node as IAudioFileSourceNode).onBufferingStateChanged = - sub.subscriptionId; + this.bufferingSubscription.subscriptionId; } stopBufferingTracking(): void { + this.bufferingSubscription?.remove(); + this.bufferingSubscription = null; + if (this.node) { (this.node as IAudioFileSourceNode).onBufferingStateChanged = '0'; } } private resetNodeAndSubscriptions(): void { + this.stopPositionTracking(); + this.stopBufferingTracking(); + this.attachedEndedSubscription?.remove(); + this.attachedEndedSubscription = null; + if (this.node) { - (this.node as IAudioFileSourceNode).onPositionChanged = '0'; - (this.node as IAudioFileSourceNode).onBufferingStateChanged = '0'; (this.node as IAudioFileSourceNode).onEnded = '0'; this.node.disconnect(undefined); } diff --git a/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts index d9d01ab8a..a16c0beb3 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts @@ -4,11 +4,13 @@ import { EventTypeWithValue } from '../events/types'; import { IAudioBufferBaseSourceNode } from '../jsi-interfaces'; import AudioScheduledSourceNode from './AudioScheduledSourceNode'; import { AudioNodeOptions } from '../types'; +import { AudioEventSubscription } from '../events'; export default class AudioBufferBaseSourceNode extends AudioScheduledSourceNode { readonly playbackRate: AudioParam; readonly detune: AudioParam; private onPositionChangedCallback?: (event: EventTypeWithValue) => void; + private onPositionChangedSubscription: AudioEventSubscription | null = null; constructor( context: BaseAudioContext, @@ -30,6 +32,9 @@ export default class AudioBufferBaseSourceNode extends AudioScheduledSourceNode public set onPositionChanged( callback: ((event: EventTypeWithValue) => void) | null ) { + this.onPositionChangedSubscription?.remove(); + this.onPositionChangedSubscription = null; + if (!callback) { (this.node as IAudioBufferBaseSourceNode).onPositionChanged = '0'; this.onPositionChangedCallback = undefined; @@ -37,13 +42,11 @@ export default class AudioBufferBaseSourceNode extends AudioScheduledSourceNode } this.onPositionChangedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener( - 'positionChanged', - callback - ); + this.onPositionChangedSubscription = + this.audioEventEmitter.addAudioEventListener('positionChanged', callback); (this.node as IAudioBufferBaseSourceNode).onPositionChanged = - sub.subscriptionId; + this.onPositionChangedSubscription.subscriptionId; } public get onPositionChangedInterval(): number { diff --git a/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts index 0a3a7f957..3e4111247 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts @@ -8,9 +8,11 @@ import { AudioBufferQueueSourceState, } from '../types'; import { OnBufferEndEventType } from '../events/types'; +import { AudioEventSubscription } from '../events'; export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNode { private onBufferEndedCallback?: (event: OnBufferEndEventType) => void; + private onBufferEndedSubscription: AudioEventSubscription | null = null; private state: AudioBufferQueueSourceState = AudioBufferQueueSourceState.IDLE; constructor( @@ -60,6 +62,7 @@ export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNod this.state = AudioBufferQueueSourceState.PLAYING; (this.node as IAudioBufferQueueSourceNode).start(when, offset); + this.context.markRunningOnSourceStart(); } public override stop(when: number = 0): void { @@ -82,6 +85,9 @@ export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNod public set onBufferEnded( callback: ((event: OnBufferEndEventType) => void) | null ) { + this.onBufferEndedSubscription?.remove(); + this.onBufferEndedSubscription = null; + if (!callback) { (this.node as IAudioBufferQueueSourceNode).onBufferEnded = '0'; this.onBufferEndedCallback = undefined; @@ -89,13 +95,11 @@ export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNod } this.onBufferEndedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener( - 'bufferEnded', - callback - ); + this.onBufferEndedSubscription = + this.audioEventEmitter.addAudioEventListener('bufferEnded', callback); (this.node as IAudioBufferQueueSourceNode).onBufferEnded = - sub.subscriptionId; + this.onBufferEndedSubscription.subscriptionId; } public pause(): void { diff --git a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts index b5dbee150..2972fac2d 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts @@ -5,9 +5,11 @@ import { InvalidStateError, RangeError } from '../errors'; import { EventEmptyType } from '../events/types'; import { AudioBufferSourceOptions } from '../types'; import type BaseAudioContext from './BaseAudioContext'; +import { AudioEventSubscription } from '../events'; export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { private onLoopEndedCallback?: (event: EventEmptyType) => void; + private onLoopEndedSubscription: AudioEventSubscription | null = null; private _buffer: AudioBuffer | null = null; private bufferHasBeenSet: boolean = false; @@ -112,6 +114,7 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { this.hasBeenStarted = true; (this.node as IAudioBufferSourceNode).start(when, offset, duration); + this.context.markRunningOnSourceStart(); } public get onLoopEnded(): ((event: EventEmptyType) => void) | undefined { @@ -119,6 +122,9 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { } public set onLoopEnded(callback: ((event: EventEmptyType) => void) | null) { + this.onLoopEndedSubscription?.remove(); + this.onLoopEndedSubscription = null; + if (!callback) { (this.node as IAudioBufferSourceNode).onLoopEnded = '0'; this.onLoopEndedCallback = undefined; @@ -126,11 +132,12 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { } this.onLoopEndedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener( + this.onLoopEndedSubscription = this.audioEventEmitter.addAudioEventListener( 'loopEnded', callback ); - (this.node as IAudioBufferSourceNode).onLoopEnded = sub.subscriptionId; + (this.node as IAudioBufferSourceNode).onLoopEnded = + this.onLoopEndedSubscription.subscriptionId; } } diff --git a/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts b/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts index 45c27dce7..e5421fcf0 100644 --- a/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts @@ -2,7 +2,7 @@ import { IAudioScheduledSourceNode } from '../jsi-interfaces'; import AudioNode from './AudioNode'; import { InvalidStateError, RangeError } from '../errors'; import { EventEmptyType } from '../events/types'; -import { AudioEventEmitter } from '../events'; +import { AudioEventEmitter, AudioEventSubscription } from '../events'; export default class AudioScheduledSourceNode extends AudioNode { protected hasBeenStarted: boolean = false; @@ -11,6 +11,8 @@ export default class AudioScheduledSourceNode extends AudioNode { ); private onEndedCallback?: (event: EventEmptyType) => void; + private endedListeners = new Set<(event: EventEmptyType) => void>(); + private endedSubscription: AudioEventSubscription | null = null; public start(when: number = 0): void { if (when < 0) { @@ -44,20 +46,81 @@ export default class AudioScheduledSourceNode extends AudioNode { (this.node as IAudioScheduledSourceNode).stop(when); } + /** + * Web Audio API spec spelling of the ended-event handler. Delegates to + * `onEnded` so both spellings drive the same native subscription. + */ + public get onended(): ((event: EventEmptyType) => void) | undefined { + return this.onEnded; + } + + public set onended(callback: ((event: EventEmptyType) => void) | null) { + this.onEnded = callback; + } + public get onEnded(): ((event: EventEmptyType) => void) | undefined { return this.onEndedCallback; } public set onEnded(callback: ((event: EventEmptyType) => void) | null) { - if (!callback) { + this.onEndedCallback = callback ?? undefined; + this.syncEndedSubscription(); + } + + /** + * EventTarget-style registration for the `ended` event, sharing one native + * subscription with the `onEnded`/`onended` handler. Other event types are + * ignored: the node dispatches nothing else. + */ + public addEventListener( + type: string, + listener: (event: EventEmptyType) => void + ): void { + if (type !== 'ended') { + return; + } + + this.endedListeners.add(listener); + this.syncEndedSubscription(); + } + + public removeEventListener( + type: string, + listener: (event: EventEmptyType) => void + ): void { + if (type !== 'ended') { + return; + } + + this.endedListeners.delete(listener); + this.syncEndedSubscription(); + } + + /** + * Keep exactly one native `ended` subscription alive while any consumer + * (handler or listener) exists, and none otherwise — an orphaned subscription + * would retain this node in the native handler registry. + */ + private syncEndedSubscription(): void { + this.endedSubscription?.remove(); + this.endedSubscription = null; + + if (!this.onEndedCallback && this.endedListeners.size === 0) { (this.node as IAudioScheduledSourceNode).onEnded = '0'; - this.onEndedCallback = undefined; return; } - this.onEndedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener('ended', callback); + this.endedSubscription = this.audioEventEmitter.addAudioEventListener( + 'ended', + (event: EventEmptyType) => this.dispatchEnded(event) + ); + (this.node as IAudioScheduledSourceNode).onEnded = + this.endedSubscription.subscriptionId; + } - (this.node as IAudioScheduledSourceNode).onEnded = sub.subscriptionId; + private dispatchEnded(event: EventEmptyType): void { + const endedEvent = { ...event, type: 'ended', target: this }; + this.onEndedCallback?.(endedEvent); + this.endedListeners.forEach((listener) => listener(endedEvent)); } } diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs index 444d586da..53516044d 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs @@ -98,6 +98,16 @@ const candidate = loadReport(options.candidate); const baselineCategories = categoryPassMap(baseline); const candidateCategories = categoryPassMap(candidate); +/** + * The audit harness interleaves real assertion failures (`X `) with + * roll-up lines that embed a running count — `< [task] 3 out of 3 assertions + * were failed.` and `# AUDIT TASK RUNNER FINISHED: 6 out of 17 tasks were + * failed.`. Fixing an assertion rewrites those counts, so a literal set + * difference reads the improved roll-up as a brand-new failure. Only the + * assertion lines identify a subtest, so only they gate the comparison. + */ +const isAssertionFailure = (message) => message.startsWith('X '); + /** * Exact per-file comparison. Category pass counts cannot see an equal-count * swap (test X regresses while test Y starts passing), so when both reports @@ -129,10 +139,10 @@ function compareFiles(baselineReport, candidateReport) { continue; } - const baseFailures = new Set(base.failures ?? []); - const subtests = (head.failures ?? []).filter( - (message) => !baseFailures.has(message) - ); + const baseFailures = new Set((base.failures ?? []).filter(isAssertionFailure)); + const subtests = (head.failures ?? []) + .filter(isAssertionFailure) + .filter((message) => !baseFailures.has(message)); if (subtests.length > 0) { newFailures.push({ path: filePath, subtests }); }