From b147797a743dc3b8305004488dfa80a61527211f Mon Sep 17 00:00:00 2001 From: contra Date: Wed, 10 Jun 2026 18:20:23 -0700 Subject: [PATCH 1/2] fix(ios): push the frames AVAudioConverter actually produced in the recorder callback convertToBuffer:error:withInputFromBlock: sets frameLength to the number of frames it wrote, but the recorder callback overwrote it with a computed estimate and pushed ceil(numFrames * ratio) frames into the circular buffer. When the SRC's filter phase produced fewer frames than the estimate, the extra pushed samples were stale data from a previous render block - an audible click at nearly every block boundary whenever the requested callback rate differs from the hardware input rate. Fixes #1096 --- .../ios/core/utils/IOSRecorderCallback.mm | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRecorderCallback.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRecorderCallback.mm index 5362ddef9..2c132bfcd 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRecorderCallback.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRecorderCallback.mm @@ -202,8 +202,6 @@ static inline void freeOwnedAudioBufferList(const AudioBufferList *bufferList) return; } - size_t outputFrameCount = ceil(numFrames * (sampleRate_ / bufferFormat_.sampleRate)); - for (size_t i = 0; i < bufferFormat_.channelCount; ++i) { memcpy( converterInputBuffer_.mutableAudioBufferList->mBuffers[i].mData, @@ -230,7 +228,6 @@ static inline void freeOwnedAudioBufferList(const AudioBufferList *bufferList) }; [converter_ convertToBuffer:converterOutputBuffer_ error:&error withInputFromBlock:inputBlock]; - converterOutputBuffer_.frameLength = sampleRate_ / bufferFormat_.sampleRate * numFrames; if (error != nil) { invokeOnErrorCallback( @@ -239,9 +236,21 @@ static inline void freeOwnedAudioBufferList(const AudioBufferList *bufferList) return; } + // `convertToBuffer` sets `frameLength` to the number of frames it actually + // produced. The sample-rate converter's filter phase makes this fluctuate + // around `numFrames * ratio` from call to call, so pushing a computed + // estimate (the previous `ceil(...)`) reads stale samples from the end of + // the output buffer whenever the converter produced fewer frames — + // an audible click at the boundary of nearly every render block when the + // hardware rate differs from the requested callback rate. + AVAudioFrameCount producedFrames = converterOutputBuffer_.frameLength; + if (producedFrames == 0) { + return; + } + for (int i = 0; i < channelCount_; ++i) { auto *data = static_cast(converterOutputBuffer_.audioBufferList->mBuffers[i].mData); - circularBuffer_[i]->push_back(data, outputFrameCount); + circularBuffer_[i]->push_back(data, producedFrames); } if (circularBuffer_[0]->getNumberOfAvailableFrames() >= bufferLength_) { From 211d73688d482fc2aa3400c9067bed839c1d155a Mon Sep 17 00:00:00 2001 From: michal Date: Thu, 11 Jun 2026 19:16:16 +0200 Subject: [PATCH 2/2] refactor: better memory handling in ios recorder --- .../core/utils/AndroidRotatingFileWriter.cpp | 8 ++- .../audioapi/ios/core/utils/IOSFileWriter.h | 1 + .../audioapi/ios/core/utils/IOSFileWriter.mm | 64 +++++++++++-------- .../ios/core/utils/IOSRecorderCallback.mm | 45 ++----------- .../ios/core/utils/IOSRotatingFileWriter.mm | 8 ++- .../ios/core/utils/OwnedAudioBufferList.h | 16 +++++ .../ios/core/utils/OwnedAudioBufferList.mm | 53 +++++++++++++++ 7 files changed, 121 insertions(+), 74 deletions(-) create mode 100644 packages/react-native-audio-api/ios/audioapi/ios/core/utils/OwnedAudioBufferList.h create mode 100644 packages/react-native-audio-api/ios/audioapi/ios/core/utils/OwnedAudioBufferList.mm diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/utils/AndroidRotatingFileWriter.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/utils/AndroidRotatingFileWriter.cpp index 465972c11..6e55a9eee 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/utils/AndroidRotatingFileWriter.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/utils/AndroidRotatingFileWriter.cpp @@ -55,12 +55,14 @@ void AndroidRotatingFileWriter::writeAudioData(AudioDataType data, int numFrames rotateFiles(); } } - framesWritten_.fetch_add(numFrames, std::memory_order_relaxed); } double AndroidRotatingFileWriter::getCurrentDuration() const { - return static_cast(framesWritten_.load(std::memory_order_relaxed)) / - fileProperties_->sampleRate; + double currentSegmentDuration = 0.0; + if (currentWriter_ != nullptr) { + currentSegmentDuration = currentWriter_->getCurrentDuration(); + } + return cumulativeDurationSec_ + currentSegmentDuration; } void AndroidRotatingFileWriter::rotateFiles() { diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.h b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.h index 4881d4225..07d82f25a 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.h @@ -18,6 +18,7 @@ typedef struct objc_object AVAudioConverter; #endif // __OBJC__ struct WriterData { + // Owned by the worker thread; freed in IOSFileWriter::taskOffloaderFunction. const AudioBufferList *audioBufferList; int numFrames; }; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm index f1fe799de..3714aa929 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm @@ -4,12 +4,16 @@ #include #include #include +#include #include #include #include namespace audioapi { +using ios::copyAudioBufferList; +using ios::freeOwnedAudioBufferList; + IOSFileWriter::IOSFileWriter( const std::shared_ptr &audioEventHandlerRegistry, const std::shared_ptr &fileProperties) @@ -157,34 +161,45 @@ { if (audioFile_ == nil) { invokeOnErrorCallback("Attempted to write audio data when file is not open"); - } else { - offloader_->getSender()->send({audioBufferList, numFrames}); + return; + } + + // CoreAudio owns `audioBufferList` only for the duration of this synchronous + // callback. Copy into an owned AudioBufferList before handing off to the + // worker thread; the consumer in taskOffloaderFunction frees it. + AudioBufferList *owned = copyAudioBufferList(audioBufferList); + if (owned == nullptr) { + return; } + + offloader_->getSender()->send({.audioBufferList = owned, .numFrames = numFrames}); } void IOSFileWriter::taskOffloaderFunction(WriterData data) { auto [audioBufferList, numFrames] = data; - if (audioBufferList == nullptr) + if (audioBufferList == nullptr) { return; + } @autoreleasepool { NSError *error = nil; + + for (size_t i = 0; i < bufferFormat_.channelCount; ++i) { + memcpy( + converterInputBuffer_.mutableAudioBufferList->mBuffers[i].mData, + audioBufferList->mBuffers[i].mData, + audioBufferList->mBuffers[i].mDataByteSize); + } + + freeOwnedAudioBufferList(audioBufferList); + audioBufferList = nullptr; + converterInputBuffer_.frameLength = numFrames; + AVAudioFormat *fileFormat = [audioFile_ processingFormat]; if (bufferFormat_.sampleRate == fileFormat.sampleRate && bufferFormat_.channelCount == fileFormat.channelCount && bufferFormat_.isInterleaved == fileFormat.isInterleaved) { - // We can use the converter input buffer as a "transport" layer to the file - for (size_t i = 0; i < bufferFormat_.channelCount; ++i) { - memcpy( - converterInputBuffer_.mutableAudioBufferList->mBuffers[i].mData, - audioBufferList->mBuffers[i].mData, - audioBufferList->mBuffers[i].mDataByteSize); - } - - audioBufferList = nullptr; - converterInputBuffer_.frameLength = numFrames; - [audioFile_ writeFromBuffer:converterInputBuffer_ error:&error]; if (error != nil) { @@ -198,16 +213,6 @@ return; } - for (size_t i = 0; i < bufferFormat_.channelCount; ++i) { - memcpy( - converterInputBuffer_.mutableAudioBufferList->mBuffers[i].mData, - audioBufferList->mBuffers[i].mData, - audioBufferList->mBuffers[i].mDataByteSize); - } - - audioBufferList = nullptr; - converterInputBuffer_.frameLength = numFrames; - __block BOOL handedOff = false; AVAudioConverterInputBlock inputBlock = ^AVAudioBuffer *_Nullable( AVAudioPacketCount inNumberOfPackets, AVAudioConverterInputStatus *outStatus) @@ -223,8 +228,6 @@ }; [converter_ convertToBuffer:converterOutputBuffer_ error:&error withInputFromBlock:inputBlock]; - converterOutputBuffer_.frameLength = - fileProperties_->sampleRate / bufferFormat_.sampleRate * numFrames; if (error != nil) { invokeOnErrorCallback( @@ -233,6 +236,11 @@ return; } + AVAudioFrameCount producedFrames = converterOutputBuffer_.frameLength; + if (producedFrames == 0) { + return; + } + [audioFile_ writeFromBuffer:converterOutputBuffer_ error:&error]; if (error != nil) { @@ -242,14 +250,14 @@ return; } - framesWritten_.fetch_add(numFrames, std::memory_order_acq_rel); + framesWritten_.fetch_add(producedFrames, std::memory_order_acq_rel); } } double IOSFileWriter::getCurrentDuration() const { return static_cast(framesWritten_.load(std::memory_order_acquire)) / - bufferFormat_.sampleRate; + fileProperties_->sampleRate; } std::string IOSFileWriter::getFilePath() const diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRecorderCallback.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRecorderCallback.mm index 2c132bfcd..26608f4e7 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRecorderCallback.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRecorderCallback.mm @@ -6,18 +6,19 @@ #include #include #include +#include #include #include #include #include -#include -#include -#include #include #include namespace audioapi { +using ios::copyAudioBufferList; +using ios::freeOwnedAudioBufferList; + IOSRecorderCallback::IOSRecorderCallback( const std::shared_ptr &audioEventHandlerRegistry, float sampleRate, @@ -118,17 +119,6 @@ } } -static inline void freeOwnedAudioBufferList(const AudioBufferList *bufferList) -{ - if (bufferList == nullptr) { - return; - } - for (UInt32 i = 0; i < bufferList->mNumberBuffers; ++i) { - std::free(bufferList->mBuffers[i].mData); - } - std::free(const_cast(bufferList)); -} - /// @brief Receives audio data from the recorder, processes it, and stores it in the circular buffer. /// The data is converted using AVAudioConverter if the input format differs from the user desired callback format. /// This method runs on the audio thread. @@ -148,28 +138,10 @@ static inline void freeOwnedAudioBufferList(const AudioBufferList *bufferList) // CoreAudio owns `audioBufferList` only for the duration of this synchronous // callback. Copy into an owned AudioBufferList before handing off to the // worker thread; the consumer in taskOffloaderFunction frees it. - UInt32 bufferCount = audioBufferList->mNumberBuffers; - size_t headerSize = offsetof(AudioBufferList, mBuffers) + sizeof(AudioBuffer) * bufferCount; - AudioBufferList *owned = static_cast(std::malloc(headerSize)); + AudioBufferList *owned = copyAudioBufferList(audioBufferList); if (owned == nullptr) { return; } - owned->mNumberBuffers = bufferCount; - for (UInt32 i = 0; i < bufferCount; ++i) { - UInt32 byteSize = audioBufferList->mBuffers[i].mDataByteSize; - owned->mBuffers[i].mNumberChannels = audioBufferList->mBuffers[i].mNumberChannels; - owned->mBuffers[i].mDataByteSize = byteSize; - void *channelData = std::malloc(byteSize); - if (channelData == nullptr) { - for (UInt32 j = 0; j < i; ++j) { - std::free(owned->mBuffers[j].mData); - } - std::free(owned); - return; - } - std::memcpy(channelData, audioBufferList->mBuffers[i].mData, byteSize); - owned->mBuffers[i].mData = channelData; - } offloader_->getSender()->send({.audioBufferList = owned, .numFrames = numFrames}); } @@ -236,13 +208,6 @@ static inline void freeOwnedAudioBufferList(const AudioBufferList *bufferList) return; } - // `convertToBuffer` sets `frameLength` to the number of frames it actually - // produced. The sample-rate converter's filter phase makes this fluctuate - // around `numFrames * ratio` from call to call, so pushing a computed - // estimate (the previous `ceil(...)`) reads stale samples from the end of - // the output buffer whenever the converter produced fewer frames — - // an audible click at the boundary of nearly every render block when the - // hardware rate differs from the requested callback rate. AVAudioFrameCount producedFrames = converterOutputBuffer_.frameLength; if (producedFrames == 0) { return; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm index b630a5add..9706c2e98 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm @@ -60,7 +60,6 @@ rotateFiles(); } } - framesWritten_.fetch_add(numFrames, std::memory_order_relaxed); } CloseFileResult IOSRotatingFileWriter::closeFile() @@ -76,8 +75,11 @@ double IOSRotatingFileWriter::getCurrentDuration() const { - return static_cast(framesWritten_.load(std::memory_order_relaxed)) / - fileProperties_->sampleRate; + double currentSegmentDuration = 0.0; + if (currentWriter_ != nullptr) { + currentSegmentDuration = currentWriter_->getCurrentDuration(); + } + return cumulativeDurationSec_ + currentSegmentDuration; } OpenFileResult IOSRotatingFileWriter::openInnerWriter() diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/OwnedAudioBufferList.h b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/OwnedAudioBufferList.h new file mode 100644 index 000000000..aefb844af --- /dev/null +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/OwnedAudioBufferList.h @@ -0,0 +1,16 @@ +#pragma once + +#ifndef __OBJC__ +typedef struct AudioBufferList AudioBufferList; +#endif + +namespace audioapi::ios { + +/// Frees an AudioBufferList allocated by `copyAudioBufferList`. +void freeOwnedAudioBufferList(const AudioBufferList *bufferList); + +/// Deep-copies a Core Audio buffer list so it can outlive a synchronous I/O callback. +/// Returns nullptr on allocation failure. Caller must free with `freeOwnedAudioBufferList`. +AudioBufferList *copyAudioBufferList(const AudioBufferList *audioBufferList); + +} // namespace audioapi::ios diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/OwnedAudioBufferList.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/OwnedAudioBufferList.mm new file mode 100644 index 000000000..03af6c97f --- /dev/null +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/OwnedAudioBufferList.mm @@ -0,0 +1,53 @@ +#import + +#include + +#include +#include +#include + +namespace audioapi::ios { + +void freeOwnedAudioBufferList(const AudioBufferList *bufferList) +{ + if (bufferList == nullptr) { + return; + } + for (UInt32 i = 0; i < bufferList->mNumberBuffers; ++i) { + std::free(bufferList->mBuffers[i].mData); + } + std::free(const_cast(bufferList)); +} + +AudioBufferList *copyAudioBufferList(const AudioBufferList *audioBufferList) +{ + if (audioBufferList == nullptr) { + return nullptr; + } + + UInt32 bufferCount = audioBufferList->mNumberBuffers; + size_t headerSize = offsetof(AudioBufferList, mBuffers) + sizeof(AudioBuffer) * bufferCount; + auto *owned = static_cast(std::malloc(headerSize)); + if (owned == nullptr) { + return nullptr; + } + owned->mNumberBuffers = bufferCount; + for (UInt32 i = 0; i < bufferCount; ++i) { + UInt32 byteSize = audioBufferList->mBuffers[i].mDataByteSize; + owned->mBuffers[i].mNumberChannels = audioBufferList->mBuffers[i].mNumberChannels; + owned->mBuffers[i].mDataByteSize = byteSize; + void *channelData = std::malloc(byteSize); + if (channelData == nullptr) { + for (UInt32 j = 0; j < i; ++j) { + std::free(owned->mBuffers[j].mData); + } + std::free(owned); + return nullptr; + } + std::memcpy(channelData, audioBufferList->mBuffers[i].mData, byteSize); + owned->mBuffers[i].mData = channelData; + } + return owned; +} + +} // namespace audioapi::ios