Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .claude/skills/audio-nodes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ AudioParam::AudioParam(
: GeneralizedAudioParam(minValue, maxValue, context),
value_(defaultValue),
defaultValue_(defaultValue),
eventRenderQueue_(defaultValue),
eventRenderQueue_(defaultValue, context->getSampleRate()),
inputBuffer_(
std::make_shared<DSPAudioBuffer>(RENDER_QUANTUM_SIZE, 1, context->getSampleRate())) {}

Expand Down Expand Up @@ -74,15 +74,19 @@ std::shared_ptr<DSPAudioBuffer> 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<int>(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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,33 @@
#include <audioapi/core/utils/param/RenderParamEvent.h>
#include <audioapi/dsp/AudioUtils.h>
#include <audioapi/utils/AudioArray.hpp>
#include <algorithm>
#include <cstddef>
#include <memory>
#include <utility>

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);
Expand All @@ -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<float>(
startValue + (endValue - startValue) * (time - startTime) / (endTime - startTime));
if (endTime <= startTime) {
return endValue;
}

return endValue;
return static_cast<float>(
startValue + (endValue - startValue) * (time - startTime) / (endTime - startTime));
};

return RenderParamEvent(
Expand All @@ -52,17 +57,12 @@ class ParamRenderEventFactory {
return startValue;
}

if (time < startTime) {
return startValue;
}

if (time < endTime) {
return static_cast<float>(
startValue *
pow(endValue / startValue, (time - startTime) / (endTime - startTime)));
if (endTime <= startTime) {
return endValue;
}

return endValue;
return static_cast<float>(
startValue * pow(endValue / startValue, (time - startTime) / (endTime - startTime)));
};

return RenderParamEvent(
Expand All @@ -81,10 +81,6 @@ class ParamRenderEventFactory {
return target;
}

if (time < startTime) {
return startValue;
}

return static_cast<float>(
target + (startValue - target) * exp(-(time - startTime) / timeConstant));
};
Expand All @@ -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<int>(std::floor(
static_cast<double>(length - 1) / (endTime - startTime) * (time - startTime)));
// Calculate interpolation factor between adjacent array elements
auto factor = static_cast<float>(
(time - startTime) * static_cast<double>(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<double>(length - 1) / (endTime - startTime) * (time - startTime),
0.0,
static_cast<double>(length - 1));
auto k = static_cast<size_t>(position);
size_t nextIndex = std::min(k + 1, length - 1);
auto factor = static_cast<float>(position - static_cast<double>(k));
return dsp::linearInterpolate(values->span(), k, nextIndex, factor);
};

return RenderParamEvent(
Expand Down
Loading
Loading