Skip to content

Custom audio tracks: push PCM from JS into a published WebRTC track#68

Open
MiloszFilimowski wants to merge 8 commits into
masterfrom
feat/custom-audio-track
Open

Custom audio tracks: push PCM from JS into a published WebRTC track#68
MiloszFilimowski wants to merge 8 commits into
masterfrom
feat/custom-audio-track

Conversation

@MiloszFilimowski

Copy link
Copy Markdown
Collaborator

What

Adds createCustomAudioTrack() — the audio counterpart of the existing custom video track support. JS pushes PCM (any chunk size, Float32Array or Int16Array) into a WebRTC audio track that publishes through the normal Fishjam path, e.g. audio produced with react-native-audio-api.

const { stream, track } = await createCustomAudioTrack({ sampleRateHz: 48000 });
await setStream(stream);                    // publishes as `customAudio`
pushAudioSamples(track, samples);           // worklet-safe

How

  • src/createCustomAudioTrack.ts — mirrors createCustomVideoTrack.ts idiom-for-idiom: same ensureInstalled() (10 s timeout, E_NO_JSI → "requires the New Architecture"), same by-reference JSI host-object sink (__fishjamWebrtcGetCustomAudioSink), same MediaStream+handle result, disposal via the normal track.stop() path. pushAudioSamples is 'worklet'-tagged.
  • common/cpp/fishjam-audio/FJAudioFrameScheduler.{h,cpp} — the piece that makes this usable. The fork's source demands exactly one 10 ms int16 frame per call, in real time; JS produces audio in whatever size it likes, whenever it likes. A per-track FIFO + feeder thread re-paces pushes onto an absolute-deadline clock, inserting silence when the buffer runs dry so the track behaves like a live microphone, and dropping oldest whole frames past maxBufferedDurationMs. This is why callers can hand over a whole buffer at once and hear a steady tone — pacing from a JS timer instead produces audible beating.
  • common/cpp/fishjam-audio/FJAudioPushJSI.{h,cpp} — structural twin of FJVideoPushJSI, with one deliberate difference: instead of routing each push to a platform deliver callback, it owns the per-track schedulers (registerTrack/unregisterTrack). Float32→Int16 conversion happens in C++; malformed input is dropped, never thrown, on the hot path.
  • iOS (WebRTCModule+CustomAudio.mm, CustomAudioSourceController) — RTCExternalAudioSourceRTCAudioTrack, landed in localTracks/localStreams so the existing publish path is untouched. The scheduler feeder pushes straight into the ObjC source. The controller is pure lifecycle: the release path runs an idempotent teardown that unregisters the scheduler (joining the feeder) before the source is released. startCapture/stopCapture stay no-ops — a disabled audio track is muted by the sender itself, matching microphone semantics.
  • Android (FJAudioPushInstaller.{java,cpp,h}, WebRTCModule, GetUserMediaImpl, new webrtc-custom-audio-track CMake lib) — the emit direction differs: the source is a Java object and this module's C++ doesn't link webrtc, so the feeder calls up into Java under a ThreadScope (it isn't JVM-attached) with a direct ByteBuffer wrapping the scheduler scratch, consumed synchronously. The push direction stays hop-free in C++. customAudioSources is concurrent so the feeder never races the executor mutating tracks; disposeTrack drops the source first (in-flight emits become no-ops), then joins the feeder, then disposes.
  • PinsFishjamWebRTC ~> 124.0.2.3 / com.github.fishjam-cloud:webrtc:v124.0.2.3, which carry the required ExternalAudioSource (Add ExternalAudioSource: application-pushed PCM into local audio tracks webrtc#1).

No changes needed in react-client: useCustomSourceManager already publishes stream.getAudioTracks()[0] as { type: 'customAudio' }.

Validation

End-to-end against a Fishjam staging room, both platforms, with a second (web) client decoding the received track via WebAudio FFT:

  • Steady tone — 440 Hz pushed from JS decoded at RMS 0.178 vs 0.177 expected (amplitude 0.25/√2): bit-accurate through Float32 → JSI → int16 → 10 ms re-pacing → Opus → SFU.
  • A real chord, natively mixed — three OscillatorNodes rendered in one OfflineAudioContext pass and pushed as a single buffer decoded as 264 / 328 / 393 Hz simultaneously (C4+E4+G4, RMS ~0.22) on both iOS and Android. Mixing belongs in the audio graph; this API is the transport (it appends to the FIFO, it does not mix).
  • Microphone and custom tracks publish simultaneously and stay independent; the external stream never registers with AudioState, so it neither engages the mic nor mixes mic audio in.
  • Clean teardown: stopping removes the track at the receiver.
  • Pins verified against the published artifacts: CocoaPods installs FishjamWebRTC (124.0.2.3) (symbols present in both slices), Gradle pulls the JitPack AAR with sha256 identical to the released asset.

Note for consumers using react-native-audio-api

Call AudioManager.disableSessionManagement(). audio-api otherwise takes over the iOS audio session and forces AVAudioSessionCategoryPlayback, which starves WebRTC's AUVoiceIO unit — AURemoteIO::Initialize then times out and aborts the app. Worth documenting when the consumer-facing hook lands.

Follow-ups (not in this PR)

  • useCustomAudioSource hook in mobile-client, in the useCamera/useCustomSource family.
  • A productized demo screen (validation used a temporary synth pad in fishjam-chat).

createCustomAudioTrack(init) returns a publishable MediaStream plus a
worklet-serializable push handle; pushAudioSamples(track, samples)
accepts arbitrary-size Float32Array/Int16Array chunks. The shared C++
core mirrors the custom-video JSI channel
(__fishjamWebrtcGetCustomAudioSink host-object sink) and adds
FJAudioFrameScheduler: a per-track FIFO + feeder thread that re-paces
pushes into the continuous one-10ms-int16-frame-per-call stream the
fork's ExternalAudioSource expects, inserting silence when the buffer
runs dry and dropping oldest whole frames beyond
maxBufferedDurationMs.
WebRTCModule+CustomAudio.mm exposes installCustomAudioJSI (E_NO_JSI on
the old architecture) and createCustomAudioTrack: it builds the fork's
RTCExternalAudioSource + RTCAudioTrack, registers the track's pacing
scheduler (whose feeder pushes 10 ms frames straight into the source),
and lands the track in localTracks/localStreams so the existing publish
path works untouched. CustomAudioSourceController is pure lifecycle
glue: the track-release path runs its idempotent teardown, which
unregisters the scheduler (joining the feeder thread) before the source
is released. startCapture/stopCapture stay no-ops — a disabled audio
track is muted by the WebRTC sender itself, matching microphone
semantics.
WebRTCModule exposes installCustomAudioJSI (E_NO_JSI on the old
architecture; no API-26 gate — the audio push lib references no
__INTRODUCED_IN(26) symbols) and createCustomAudioTrack, which builds
the fork's ExternalAudioSource + AudioTrack and lands them in
tracks/localStreams so the existing publish and dispose paths work
untouched.

The emit direction differs from iOS: the source is a Java object and
this module's C++ does not link webrtc, so each track's pacing
scheduler feeder calls up into Java (FJAudioPushInstaller.emitAudioFrame
-> ExternalAudioSource.pushAudioFrame) under a ThreadScope, handing over
a direct ByteBuffer that wraps the scheduler scratch and is consumed
synchronously. The push direction stays hop-free in C++.

customAudioSources is a concurrent registry so the feeder thread never
races the executor mutating tracks; disposeTrack drops the source first
(in-flight emits become no-ops), then joins the feeder, then disposes.
The custom audio track API needs the fork's ExternalAudioSource, released
in v124.0.2.3 (fishjam-cloud/webrtc#1). Verified against the published
artifacts on both platforms: CocoaPods resolves FishjamWebRTC (124.0.2.3)
and Gradle resolves com.github.fishjam-cloud:webrtc:v124.0.2.3 from
JitPack (AAR sha256 identical to the released asset).
Copilot AI review requested due to automatic review settings July 17, 2026 13:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

MiloszFilimowski and others added 4 commits July 17, 2026 15:57
clang-format (.h/.m) and prettier (src/**/*.ts), per npm run format.
… trim per-frame JNI work

Review follow-ups on the custom audio track scheduler and Android emit path:

- Truncate every enqueue to a whole number of frames: one odd-size push to a
  stereo track would otherwise swap L/R for the rest of the track's life (the
  FIFO's consumers always remove exact multiples of channelCount, so
  misalignment could only enter here and never heal).
- Convert Float32 samples to int16 before taking the FIFO mutex. A large push
  (up to maxBufferedDurationMs of audio in one call) converted under the lock
  could hold it for tens of milliseconds and stall the feeder's 10 ms tick.
- Android emit path: attach the feeder thread to the JVM once (thread_local
  ThreadScope, detached at thread exit) instead of per 10 ms frame, and hoist
  the per-frame jstring allocation into a per-registration global ref.
- Align the iOS E_NO_JSI message with Android's wording.
- Drop the unused <condition_variable> include.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l rejections

- Add a dependency-free C++ test harness for FJAudioFrameScheduler (underrun
  silence, drain order, stereo odd-push truncation, Float32 conversion and
  clamping, oldest-whole-frame overflow drop, stop/join finality, pacing
  sanity), run via 'npm run test:cpp' and wired into Simple CI.
- ensureInstalled (audio + video): attach a no-op catch to the inner install
  promise so a rejection arriving after the timeout won the race doesn't
  surface as an unhandled rejection.
- Correct the sink.push docs (audio + video): each access returns a fresh,
  functionally identical function; hoisting advice unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the timeout

The absorbed post-timeout rejection was silent, losing the underlying cause;
route it through the module Logger (rn-webrtc:customAudio / :customVideo),
gated on the timeout having actually fired so the normal rejection path (race
propagates to the caller) is not double-reported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants