Custom audio tracks: push PCM from JS into a published WebRTC track#68
Open
MiloszFilimowski wants to merge 8 commits into
Open
Custom audio tracks: push PCM from JS into a published WebRTC track#68MiloszFilimowski wants to merge 8 commits into
MiloszFilimowski wants to merge 8 commits into
Conversation
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).
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
createCustomAudioTrack()— the audio counterpart of the existing custom video track support. JS pushes PCM (any chunk size,Float32ArrayorInt16Array) into a WebRTC audio track that publishes through the normal Fishjam path, e.g. audio produced with react-native-audio-api.How
src/createCustomAudioTrack.ts— mirrorscreateCustomVideoTrack.tsidiom-for-idiom: sameensureInstalled()(10 s timeout,E_NO_JSI→ "requires the New Architecture"), same by-reference JSI host-object sink (__fishjamWebrtcGetCustomAudioSink), sameMediaStream+handle result, disposal via the normaltrack.stop()path.pushAudioSamplesis'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 pastmaxBufferedDurationMs. 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 ofFJVideoPushJSI, 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.WebRTCModule+CustomAudio.mm,CustomAudioSourceController) —RTCExternalAudioSource→RTCAudioTrack, landed inlocalTracks/localStreamsso 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/stopCapturestay no-ops — a disabled audio track is muted by the sender itself, matching microphone semantics.FJAudioPushInstaller.{java,cpp,h},WebRTCModule,GetUserMediaImpl, newwebrtc-custom-audio-trackCMake 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 aThreadScope(it isn't JVM-attached) with a directByteBufferwrapping the scheduler scratch, consumed synchronously. The push direction stays hop-free in C++.customAudioSourcesis concurrent so the feeder never races the executor mutatingtracks;disposeTrackdrops the source first (in-flight emits become no-ops), then joins the feeder, then disposes.FishjamWebRTC ~> 124.0.2.3/com.github.fishjam-cloud:webrtc:v124.0.2.3, which carry the requiredExternalAudioSource(Add ExternalAudioSource: application-pushed PCM into local audio tracks webrtc#1).No changes needed in
react-client:useCustomSourceManageralready publishesstream.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:
OscillatorNodes rendered in oneOfflineAudioContextpass 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).AudioState, so it neither engages the mic nor mixes mic audio in.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 forcesAVAudioSessionCategoryPlayback, which starves WebRTC'sAUVoiceIOunit —AURemoteIO::Initializethen times out and aborts the app. Worth documenting when the consumer-facing hook lands.Follow-ups (not in this PR)
useCustomAudioSourcehook inmobile-client, in theuseCamera/useCustomSourcefamily.