Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .claude/skills/thread-safety-itc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ On Android, `AndroidAudioPlayer::onErrorAfterClose` also takes `driverMutex_` be

**Player base class:** all three drivers (`AndroidAudioPlayer` on Android, `IOSAudioPlayer`, `NodeAudioPlayer` in `wpt_tests/src/`) derive from `AudioPlayer` (`common/cpp/audioapi/core/AudioPlayer.h`). It owns the shared driver state as protected members: `renderAudio_`, `renderBuffer_` (one render quantum, allocated once in the constructor), `sampleRate_`, `channelCount_`, `currentRenders_`, `context_`, `driverMutex_`, and `isRunning_`. It also takes one constructor signature for every platform. `AudioContext::initialize()` calls `createPlatformAudioPlayer()` (declared in `AudioPlayer.h`, defined next to each subclass; the recorder has the matching `createPlatformAudioRecorder()` in `AudioRecorder.h`) instead of including a platform header. Keep it that way: `IOSAudioPlayer.h` and `IOSAudioRecorder.h` are Objective-C++ only, and a plain-C++ `.cpp` that includes them sees a different class definition (raw pointers instead of ARC `__strong` members), so any constructor or inline member emitted from that TU skips the retain and the native object is freed early. Render through `renderNormalizedQuantum()`, which keeps the output peak-limiting in the player so offline renders are never limited. `AudioPlayerTests.mm` (fabric-example XCTest) hand-copies `IOSAudioPlayer`'s member layout to reach its private members, so any change to `IOSAudioPlayer`'s data members must also be made there.

**Output timestamps (`getOutputTimestamp`):** the audio thread never asks the platform when a buffer will be heard — `AVAudioSession.outputLatency` is an Objective-C session call and Oboe's `getTimestamp` enters AAudio. Each platform callback only publishes an `OutputAnchor` (context frame of its first output sample, the callback's host time, and on Android `getFramesWritten()`) through the base class's `TripleBuffer`; the JS thread, its single reader, adds the latency or queries the device position in `getOutputPresentation()`, and `outputTimestampAt()` (`core/utils/OutputTimestamp.h`, pure and unit-tested) extrapolates that to now. The context frame comes from the player's own `renderedFrames_`, which equals the context's `currentSampleFrame_` only because the realtime context renders exclusively through `renderNormalizedQuantum()` — keep it that way. On iOS subtract `pendingSavedCount_`: a hardware buffer can start with the unplayed tail of the previous quantum. Players that cannot tell (the WPT `NodeAudioPlayer`) inherit the `std::nullopt` default, which the context reports as the spec's `{0, 0}`.

**Graph producer self-drain:** `Graph::enableProducerSelfDrain()` makes producer threads drain the event channels themselves after each enqueue; `disableProducerSelfDrain()` hands consumption back to the audio/render thread. Both flush the channels internally (no separate `processEvents()` call needed) and serialize with in-flight drains via `selfDrainMutex_`, because two producers can drain concurrently: the JS thread (mutations) and the GC finalizer thread (`removeNode`, which self-drains after its Channel B orphan send — otherwise a finalizer burst with no consumer fills the bounded channel and blocks forever, e.g. at process exit). Enable only when there is no audio/render consumer (realtime: construction + after `suspend`/`close` quiescence; offline: before `startRendering` and after a scheduled suspend); disable *before* starting the audio/render consumer; re-enable if start/resume fails.

**Context lifecycle promises:** HostObjects wrap JSI `Promise`s via `ContextPromiseResolver`
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/web-audio-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Current status (from `packages/audiodocs/docs/other/web-audio-api-coverage.mdx`)
### Partially implemented 🚧
| Interface | What's available |
|---|---|
| `AudioContext` | `close`, `suspend`, `resume`, `currentTime`, `destination`, `sampleRate`, `state` |
| `AudioContext` | `baseLatency`, `outputLatency`, `getOutputTimestamp`, `close`, `suspend`, `resume`, `currentTime`, `destination`, `sampleRate`, `state` |
| `BaseAudioContext` | `currentTime`, `destination`, `listener`, `sampleRate`, `state`, `decodeAudioData`, all `create*` for available nodes |
| `AudioListener` | All nine AudioParams; used by `PannerNode` for 3D spatialization; deprecated `setPosition` / `setOrientation` provided in JS (set AudioParam values) |
| `PannerNode` | Equal-power spatialization, distance models, cone gain; `panningModel` is `'equalpower'` only |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

[[nodiscard]] double getBaseLatency() const override;
[[nodiscard]] double getOutputLatency() const override;
[[nodiscard]] std::optional<OutputPresentation> getOutputPresentation() override;

protected:
NativeAudioPlayer *createNativePlayer();
Expand Down Expand Up @@ -334,7 +335,7 @@ - (void)tearDown

- (NativeAudioPlayer *)createPlayerWithRenderCallCount:(NSInteger *)renderCallCount
{
return [[NativeAudioPlayer alloc] initWithRenderAudio:^(AudioBufferList *outputBuffer, int numFrames) {
return [[NativeAudioPlayer alloc] initWithRenderAudio:^(AudioBufferList *outputBuffer, int numFrames, const AudioTimeStamp *) {
if (renderCallCount != nullptr) {
*renderCallCount += 1;
}
Expand Down Expand Up @@ -399,7 +400,7 @@ - (void)testRenderBlockInvokesRenderAudioWhenBufferCountMatches
__block AVAudioFrameCount renderedFrameCount = 0;
__block AudioBufferList *renderedBufferList = nullptr;
NativeAudioPlayer *player = [[NativeAudioPlayer alloc]
initWithRenderAudio:^(AudioBufferList *outputBuffer, int numFrames) {
initWithRenderAudio:^(AudioBufferList *outputBuffer, int numFrames, const AudioTimeStamp *) {
renderCallCount += 1;
renderedFrameCount = static_cast<AVAudioFrameCount>(numFrames);
renderedBufferList = outputBuffer;
Expand Down
23 changes: 23 additions & 0 deletions packages/audiodocs/docs/core/audio-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ Creates [`MediaElementAudioSourceNode`](../sources/media-element-audio-source-no

#### Returns [`MediaElementAudioSourceNode`](../sources/media-element-audio-source-node.mdx).

### `getOutputTimestamp`

Returns the context time of the sample leaving the output device now, together with now on the `performance.now()` clock. Use it to line audio up with anything else timed by `performance.now()`: animations, network messages, other devices.

The pair is measured on the audio thread from the platform's own presentation timestamps, so output latency is already included and it is not limited to the render-quantum steps `currentTime` moves in. Both values are `0` until the context has rendered its first block. While the context is suspended the values stop advancing.

```tsx
const { contextTime, performanceTime } = audioContext.getOutputTimestamp();

// The context time at which a sound is heard at `perfMs` (a performance.now() value):
const heardAt = (perfMs: number) =>
contextTime + (perfMs - performanceTime) / 1000;

oscillator.start(heardAt(performance.now() + 500)); // heard ~500 ms from now
```

#### Returns `AudioTimestamp`.

| Name | Type | Description |
| :---: | :---: | :---- |
| `contextTime` | `number` | Seconds, on the [`currentTime`](./base-audio-context.mdx#properties) axis. Never exceeds `currentTime`. |
| `performanceTime` | `number` | Milliseconds, on the `performance.now()` axis. |

### `close`

Closes the audio context, releasing any system audio resources that it uses.
Expand Down
2 changes: 1 addition & 1 deletion packages/audiodocs/docs/other/web-audio-api-coverage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ This page tracks how closely React Native Audio API follows the [Web Audio API](
| PeriodicWave | ✅ |
| StereoPannerNode | ✅ |
| WaveShaperNode | ✅ |
| AudioContext | 🚧 | Available props and methods: `baseLatency`, `outputLatency`, `close`, `suspend`, `resume` |
| AudioContext | 🚧 | Available props and methods: `baseLatency`, `outputLatency`, `getOutputTimestamp`, `close`, `suspend`, `resume` |
| BaseAudioContext | 🚧 | Available props and methods: `currentTime`, `destination`, `listener`, `sampleRate`, `state`, `decodeAudioData`, all create methods for available or partially implemented nodes |
| PannerNode | 🚧 | Equal-power spatialization, distance models, and cone gain. `panningModel` is `'equalpower'` only (`'HRTF'` not exposed yet). |
| AudioSinkInfo | ❌ |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@

#include <jni.h>

#include <time.h>

#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <mutex>
#include <utility>
Expand Down Expand Up @@ -89,6 +94,7 @@ bool AndroidAudioPlayer::rebuildStreamLocked() {
}

bool AndroidAudioPlayer::startStreamLocked() {
streamStartedAtNs_ = steadyClockNowNs();
const bool started = mStream_ != nullptr && mStream_->requestStart() == oboe::Result::OK;
isRunning_.store(started, std::memory_order_release);
return started;
Expand Down Expand Up @@ -176,6 +182,15 @@ AndroidAudioPlayer::onAudioReady(AudioStream *oboeStream, void *audioData, int32
lastCallbackFrameCount_.store(numFrames, std::memory_order_release);
}

if (isRunning_.load(std::memory_order_acquire)) {
// Oboe counts this buffer into getFramesWritten() only after the callback returns.
publishOutputAnchor(
{.contextFrame = renderedFrames_,
.hostTimeNs = steadyClockNowNs(),
.streamFrame = oboeStream->getFramesWritten(),
.isValid = true});
}

const CurrentRenderScope renderScope(currentRenders_);

auto *buffer = static_cast<float *>(audioData);
Expand Down Expand Up @@ -298,6 +313,34 @@ double AndroidAudioPlayer::getBaseLatency() const {
return static_cast<double>(RENDER_QUANTUM_SIZE) / static_cast<double>(sampleRate_);
}

std::optional<OutputPresentation> AndroidAudioPlayer::getOutputPresentation() {
const auto anchor = latestOutputAnchor();
if (!anchor) {
return std::nullopt;
}

{
std::scoped_lock lock(streamMutex_);
// An anchor from before the last start may belong to a stream a rebuild replaced, whose
// positions the current stream does not continue. A device timestamp from before it is
// AAudio's stale pre-pause report, which would interpolate across the pause.
if (isRunningLocked() && anchor->hostTimeNs >= streamStartedAtNs_) {
const auto presented = mStream_->getTimestamp(CLOCK_MONOTONIC);
if (presented && presented.value().timestamp >= streamStartedAtNs_) {
return OutputPresentation{
.contextFrame =
anchor->contextFrame + (presented.value().position - anchor->streamFrame),
.presentationTimeNs = presented.value().timestamp};
}
}
}

const auto outputLatencyNs = static_cast<std::int64_t>(std::llround(getOutputLatency() * 1e9));
return OutputPresentation{
.contextFrame = anchor->contextFrame,
.presentationTimeNs = anchor->hostTimeNs + outputLatencyNs};
}

double AndroidAudioPlayer::getOutputLatency() const {
std::scoped_lock lock(streamMutex_);
if (mStream_ == nullptr || !isInitialized_.load(std::memory_order_acquire) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ class AndroidAudioPlayer : public AudioPlayer,
[[nodiscard]] double getBaseLatency() const override;
[[nodiscard]] double getOutputLatency() const override;

/// Maps the stream position Oboe reports as presented (`getTimestamp`) onto context frames.
/// Without a device timestamp (OpenSL ES, or right after start) it assumes the last callback's
/// first frame plays one output latency after that callback ran.
[[nodiscard]] std::optional<OutputPresentation> getOutputPresentation() override;

DataCallbackResult onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames)
override;

Expand All @@ -61,6 +66,8 @@ class AndroidAudioPlayer : public AudioPlayer,
/// Updated on the audio thread from each Oboe callback `numFrames`.
std::atomic<int32_t> lastCallbackFrameCount_{0};
AndroidOutputProfile outputProfile_;
/// `steady_clock` time of the last `startStreamLocked`; guarded by `streamMutex_`.
std::int64_t streamStartedAtNs_ = 0;

bool openAudioStreamLocked();
bool rebuildStreamLocked();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ AudioContextHostObject::AudioContextHostObject(
JSI_EXPORT_FUNCTION(AudioContextHostObject, close),
JSI_EXPORT_FUNCTION(AudioContextHostObject, resume),
JSI_EXPORT_FUNCTION(AudioContextHostObject, suspend),
JSI_EXPORT_FUNCTION(AudioContextHostObject, createMediaElementSource));
JSI_EXPORT_FUNCTION(AudioContextHostObject, createMediaElementSource),
JSI_EXPORT_FUNCTION(AudioContextHostObject, getOutputTimestamp));
}

AudioContextHostObject::~AudioContextHostObject() {
Expand Down Expand Up @@ -64,6 +65,16 @@ JSI_PROPERTY_GETTER_IMPL(AudioContextHostObject, outputLatency) {
return {audioContext->getOutputLatency()};
}

JSI_HOST_FUNCTION_IMPL(AudioContextHostObject, getOutputTimestamp) {
auto audioContext = std::static_pointer_cast<AudioContext>(context_);
const auto timestamp = audioContext->getOutputTimestamp();

auto jsTimestamp = jsi::Object(runtime);
jsTimestamp.setProperty(runtime, "contextTime", timestamp.contextTime);
jsTimestamp.setProperty(runtime, "performanceTime", timestamp.performanceTime);
return jsTimestamp;
}

JSI_PROPERTY_GETTER_IMPL(AudioContextHostObject, baseLatency) {
auto audioContext = std::static_pointer_cast<AudioContext>(context_);
return {audioContext->getBaseLatency()};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class AudioContextHostObject : public BaseAudioContextHostObject {
JSI_HOST_FUNCTION_DECL(createMediaElementSource);

JSI_PROPERTY_GETTER_DECL(outputLatency);
JSI_HOST_FUNCTION_DECL(getOutputTimestamp);
JSI_PROPERTY_GETTER_DECL(baseLatency);
JSI_PROPERTY_SETTER_DECL(onerror);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include <audioapi/core/AudioContext.h>
#include <audioapi/core/AudioPlayer.h>
#include <audioapi/core/destinations/AudioDestinationNode.h>
#include <audioapi/core/utils/OutputTimestamp.h>

#include <memory>
#include <thread>
Expand Down Expand Up @@ -173,6 +174,20 @@ double AudioContext::getBaseLatency() const {
return audioPlayer_->getBaseLatency();
}

AudioTimestamp AudioContext::getOutputTimestamp() const {
if (audioPlayer_ == nullptr) {
return {};
}

const auto presentation = audioPlayer_->getOutputPresentation();
if (!presentation) {
return {};
}

return outputTimestampAt(
*presentation, getSampleRate(), getCurrentSampleFrame(), audioPlayer_->isRunning());
}

double AudioContext::getOutputLatency() const {
if (audioPlayer_ == nullptr) {
return 0.0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <audioapi/core/BaseAudioContext.h>
#include <audioapi/core/types/AudioContextLatencyHint.h>
#include <audioapi/core/types/AudioContextOptions.h>
#include <audioapi/core/types/AudioTimestamp.h>
#include <audioapi/events/AudioEvent.h>
#include <audioapi/events/EventCaller.hpp>
#include <audioapi/jsi/ContextPromiseResolver.hpp>
Expand Down Expand Up @@ -43,6 +44,12 @@ class AudioContext : public BaseAudioContext {
/// @returns The output latency in seconds.
[[nodiscard]] double getOutputLatency() const;

/// @brief Spec `getOutputTimestamp()`: the context time of the sample leaving the output device
/// now, and now on the `performance.now()` clock. Both are zero before the first rendered block
/// or where the platform cannot tell.
/// @note JS Thread only.
[[nodiscard]] AudioTimestamp getOutputTimestamp() const;

/// @brief Called when the audio stream failed to rebuild.
/// @note This method is called when the audio stream fails.
void onStreamFail();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
#include <audioapi/core/types/AudioContextLatencyHint.h>
#include <audioapi/core/types/AudioContextOptions.h>
#include <audioapi/core/utils/Constants.h>
#include <audioapi/core/utils/OutputTimestamp.h>
#include <audioapi/utils/AudioBuffer.hpp>
#include <audioapi/utils/Macros.h>
#include <audioapi/utils/TripleBuffer.hpp>

#include <atomic>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <utility>

namespace audioapi {
Expand Down Expand Up @@ -50,14 +54,43 @@ class AudioPlayer {

[[nodiscard]] virtual bool isRunning() const = 0;

/// @brief Which context frame leaves the output device when, or `std::nullopt` before the
/// first rendered callback or on a platform that cannot tell.
/// @note JS Thread only: it is the single reader of the anchors published from the audio thread.
[[nodiscard]] virtual std::optional<OutputPresentation> getOutputPresentation() = 0;

protected:
/// One render callback's first output sample, tied to the platform's own timeline.
struct OutputAnchor {
/// Context frame of the callback's first output sample.
std::int64_t contextFrame = 0;
/// `steady_clock` time the platform attached to that sample, before output latency.
std::int64_t hostTimeNs = 0;
/// Index of that sample in the device stream, counted by platform.
std::int64_t streamFrame = 0;
bool isValid = false;
};

/// @note Audio Thread only.
void publishOutputAnchor(const OutputAnchor &anchor) {
*outputAnchors_.getForWriter() = anchor;
outputAnchors_.publish();
}

/// @note JS Thread only.
[[nodiscard]] std::optional<OutputAnchor> latestOutputAnchor() {
const OutputAnchor anchor = *outputAnchors_.getForReader();
return anchor.isValid ? std::optional(anchor) : std::nullopt;
}

/// @brief Pulls `framesToProcess` frames from the graph into `renderBuffer_` and peak-normalizes
/// them.
/// @note Audio Thread only. The limiting lives in the player (not the destination node) so
/// offline renders stay spec-accurate.
void renderNormalizedQuantum(int framesToProcess) {
renderAudio_(renderBuffer_.get(), framesToProcess);
renderBuffer_->normalize();
renderedFrames_ += framesToProcess;
}

std::function<void(DSPAudioBuffer *, int)> renderAudio_;
Expand All @@ -70,6 +103,13 @@ class AudioPlayer {
std::mutex *driverMutex_;
std::atomic<bool> isRunning_{false};
AudioContextLatencyHint latencyHint_;
/// Frames pulled from the graph so far. The realtime context renders only through this player,
/// and `processGraph` advances its frame counter by exactly these frames, so this equals the
/// context frame of the next frame to render. Audio Thread only.
std::int64_t renderedFrames_ = 0;

private:
TripleBuffer<OutputAnchor> outputAnchors_;
};

/// Builds the platform's AudioPlayer.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#pragma once

namespace audioapi {

/// Web Audio `AudioTimestamp`: one moment expressed on the context clock and on the
/// `performance.now()` clock.
struct AudioTimestamp {
/// Seconds, on the context's `currentTime` axis.
double contextTime = 0.0;
/// Milliseconds, on the `performance.now()` axis (`std::chrono::steady_clock`).
double performanceTime = 0.0;
};

} // namespace audioapi
Loading
Loading