From 1deaf4cc310f0fda7a8fc6cd0d2c965a1a5a5cba Mon Sep 17 00:00:00 2001 From: michal Date: Tue, 6 Oct 2026 19:18:39 +0200 Subject: [PATCH 1/5] feat: add adts aac file codec for crash-recoverable compressed recordings --- .../build-compilation-dependencies/SKILL.md | 6 +- apps/common-app/src/demos/Record/Record.tsx | 3 +- .../IOSAudioRecorderTests.mm | 75 +++++++- .../audiodocs/docs/inputs/audio-recorder.mdx | 4 + .../docs/utils/file-concatenation.mdx | 9 +- .../cpp/audioapi/android/AndroidEncoder.cpp | 101 ++++++++++ .../cpp/audioapi/encoding/AdtsHeader.cpp | 89 +++++++++ .../common/cpp/audioapi/encoding/AdtsHeader.h | 53 ++++++ .../encoding/AudioFileConcatenator.cpp | 177 +++++++++++++++++- .../audioapi/encoding/EncoderCapabilities.h | 6 + .../cpp/audioapi/encoding/EncoderOutputSpec.h | 3 + .../cpp/audioapi/utils/AudioFileProperties.h | 1 + .../encoding/AudioFileConcatenatorTest.cpp | 130 ++++++++++++- .../src/encoding/EncoderCapabilitiesTest.cpp | 7 + packages/react-native-audio-api/src/types.ts | 1 + 15 files changed, 649 insertions(+), 16 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.h diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index 635462481..bbac02cd9 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -31,7 +31,7 @@ react-native-audio-api/ │ │ └── CMakeLists.txt # Actual Android C++ build target │ ├── common/cpp/audioapi/ # Shared C++ (used by all platforms) │ │ ├── decoding/ # Decoder factory, backends, SeekDecoderDaemon, AudioDecoding -│ │ ├── encoding/ # AudioEncoder interface, EncoderCapabilities, OS encoder/remux selector headers, AudioFileConcatenator +│ │ ├── encoding/ # AudioEncoder interface, EncoderCapabilities, OS encoder/remux selector headers, AudioFileConcatenator, AdtsHeader (ADTS framing) │ │ ├── libs/ # Third-party wrappers (FFmpeg, miniaudio, pffft, …) │ │ └── external/ # Prebuilt binaries per platform │ │ ├── android/ # .a static libs (Opus, Ogg, Vorbis, OpenSSL) @@ -373,6 +373,10 @@ Resolution pitfalls learned the hard way (both handled inside `package-root.js`) | iOS compile error `unknown type 'id'` | C++ file included ObjC-only header | Compile that file as ObjC++ (separate subspec with `-x objective-c++`) | | `RCT_NEW_ARCH_ENABLED` undefined on Android | Old RN gradle plugin | Ensure `newArchEnabled=true` in app's `gradle.properties` | | iOS: `'to_chars' is unavailable: introduced in iOS 16.3` from `formatter_floating_point.h`, instantiated by `std::format<...>` | `std::format` in code compiled for iOS. libc++ availability-gates the whole `` library to iOS 16.3; the podspec minimum is `ios_min_version = '14.0'`. The desktop C++ test build and Android NDK have no such gate, so `yarn test:cpp` passes and only the iOS build fails. | Use `std::string` concatenation / `std::to_string` in `common/cpp` and `ios/`. Zero-pad by hand (`insert(0, n, '0')`). Android-only files (`android/src/main/cpp`) may keep `std::format`. | +| iOS test target: `Undefined symbol: audioapi::` for a function that exists in `common/cpp` and compiles in the pod | The XCTest bundle links against the host app, and the host app only pulls the static-lib members its own code references. A common symbol nobody in `ios/` or the HostObjects uses never reaches the host. | Compute it inline in the test, or make the library reference it; do not add a fake use in production code. | +| iOS: new `common/cpp` `.cpp` not compiled at all (`Undefined symbol` with no `CompileC` line for it) | CocoaPods expands `source_files` globs at `pod install`, not at build time | `cd apps/fabric-example/ios && pod install` after adding a source file | +| `check-audio-file-properties-enum-sync.sh` passes while `FileFormat` is out of sync | Its extractor only reads `Name = ` lines; `FileFormat` has no explicit values on either side, so it compares two empty lists ("in sync (0 entries)") | Keep the C++ and TS `FileFormat` lists in the same order by hand; `EncoderCapabilities.h`'s `static_assert` only checks the C++ count | +| Android/iOS: after editing a C++/TS mirrored enum (`FileFormat`, `BitDepth`, …) the app silently uses the wrong value (e.g. records FLAC when JS asked for ADTS) | Metro hot-reloaded the TS enum with the new numbering, but the installed binary still carries the old C++ numbering; enum values cross JSI as plain numbers | Reinstall the app (`installDebug` / `run-ios`) after any enum reorder; a JS reload is never enough | | clangd only: `'React/RCTBridgeModule.h' file not found` in `.mm` files | `compile_commands.json` has no framework search path | See *clangd compile database* below — regenerate with `yarn setup:clangd` | ## clangd compile database diff --git a/apps/common-app/src/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx index aa1720b01..a7bb35ae1 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -21,7 +21,7 @@ import RecordingVisualization from './RecordingVisualization'; import Status from './Status'; import { RecordingState } from './types'; -// concatAudioFiles supports WAV, M4A, and FLAC — the formats recordable on +// concatAudioFiles supports WAV, M4A, FLAC, and ADTS — the formats recordable on // both iOS and Android. const RECORDING_EXTENSION = FileFormat.M4A; const ROTATING_SIZE = 250_000; @@ -30,6 +30,7 @@ const RECORDING_EXTENSION_NAME_MAP = { [FileFormat.Wav]: 'wav', [FileFormat.M4A]: 'm4a', [FileFormat.Flac]: 'flac', + [FileFormat.Adts]: 'aac', }; const Record: FC = () => { diff --git a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm index b3fedd24f..dced7e30c 100644 --- a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm @@ -12,11 +12,16 @@ #import #import #import +#import +#import +#import #import +#include #include #include #include +#include using namespace audioapi; @@ -300,7 +305,7 @@ - (void)tearDown { .subDirectory = "fabric-example-tests", .fileName = "ios-recorder-test", }, - AudioFileProperties::StreamConfig{.sampleRate = 44100, .channelCount = 2}, + AudioLayout{.sampleRate = 44100, .channelCount = 2}, AudioFileProperties::EncodingConfig{ .format = AudioFileProperties::FileFormat::WAV, .bitRate = 128000, @@ -627,3 +632,71 @@ - (void)testConnectWhileActiveInitializesAdapterAndDisconnectClearsIt { } @end + +/// The file type AVAudioFile writes follows the path extension, so the ADTS format is +/// proven at the byte level: every frame must start with a sync word and its length must +/// land exactly on the next one. +@interface IOSEncoderAdtsTests : XCTestCase +@end + +@implementation IOSEncoderAdtsTests + +- (void)testAacExtensionWritesSelfDelimitingAdtsFrames { + NSString *path = [NSTemporaryDirectory() + stringByAppendingPathComponent:@"ios-encoder-adts-test.aac"]; + [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; + + const AudioLayout layout{.sampleRate = 44100, .channelCount = 1}; + ios::encoder::IOSEncoder encoder(EncoderSettings{ + .fileLayout = layout, + .encoding = + AudioFileProperties::EncodingConfig{ + .format = AudioFileProperties::FileFormat::ADTS, + .bitRate = 64000, + .bitDepth = AudioFileProperties::BitDepth::Bit16, + .flacCompressionLevel = 0, + .iosAudioQuality = AudioFileProperties::IOSAudioQuality::High, + }, + }); + const EncoderOutputSpec spec = + encoder_capabilities::specForFormat(AudioFileProperties::FileFormat::ADTS); + XCTAssertEqual(spec.container, AudioContainer::ADTS); + + auto openResult = encoder.open( + StreamFormat{.layout = layout, .maxFramesPerBuffer = 1024}, spec, path.UTF8String); + XCTAssertTrue(openResult.is_ok(), @"%s", + openResult.is_err() ? openResult.unwrap_err().c_str() : ""); + + std::vector samples(1024, 0.25f); + const float *channels[1] = {samples.data()}; + for (int block = 0; block < 40; ++block) { + XCTAssertTrue(encoder.encode(channels, 1024).is_ok()); + } + XCTAssertTrue(encoder.close().is_ok()); + + NSData *data = [NSData dataWithContentsOfFile:path]; + XCTAssertGreaterThan(data.length, adts::HEADER_SIZE); + const auto *bytes = static_cast(data.bytes); + + size_t offset = 0; + size_t frames = 0; + while (offset + adts::HEADER_SIZE <= data.length) { + adts::HeaderBytes header{}; + std::memcpy(header.data(), bytes + offset, adts::HEADER_SIZE); + XCTAssertEqual(header[0], 0xFF, @"frame %zu", frames); + XCTAssertEqual(header[1] & 0xF6, 0xF0, @"frame %zu", frames); + // 13-bit frame length straddles bytes 3..5; computed here because nothing in the iOS + // library references the ADTS helpers, so they are not linked into the test host. + const size_t frameLength = ((static_cast(header[3]) & 0x3U) << 11) | + (static_cast(header[4]) << 3) | (static_cast(header[5]) >> 5); + XCTAssertGreaterThan(frameLength, adts::HEADER_SIZE, @"frame %zu", frames); + offset += frameLength; + frames += 1; + } + XCTAssertEqual(offset, data.length); + XCTAssertGreaterThan(frames, 0u); + + [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; +} + +@end diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index 4e4c58241..20d0fd046 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -983,6 +983,7 @@ enum FileFormat { Wav, Caf, M4A, + Adts, Flac, Aiff, Alac, @@ -1001,6 +1002,7 @@ The table below lists which formats each platform can encode with its system API | `Wav` | WAV / PCM | ✅ | ✅ | | `M4A` | M4A / AAC-LC | ✅ | ✅ | | `Flac` | FLAC / FLAC | ✅ | ✅ | +| `Adts` | ADTS / AAC-LC | ✅ | ✅ | | `Caf` | CAF / PCM | ✅ | ❌ | | `Aiff` | AIFF / PCM | ✅ | ❌ | | `Alac` | M4A / Apple Lossless | ✅ | ❌ | @@ -1010,6 +1012,8 @@ The table below lists which formats each platform can encode with its system API | `OpusWebm` | WebM / Opus | ❌ | ✅ | | `VorbisWebm` | WebM / Vorbis | ❌ | ✅ | +`Adts` writes raw AAC-LC frames in ADTS framing (`.aac` extension) with no container. Every frame carries its own sync word and length, so a recording cut off by a crash or force-kill stays decodable up to the last complete frame, like `Flac` but at lossy AAC file sizes. + :::caution Platform support Selecting a format the current platform cannot encode (for example `Caf` on Android or `OpusOgg` on iOS) fails when file output is enabled, with a descriptive error. Some Android formats also depend on the device OS version (Opus/OGG muxing requires newer releases); if a device lacks a system encoder for the requested format, recording start returns an error. Use `Wav`, `M4A`, or `Flac` for the widest cross-platform support. ::: diff --git a/packages/audiodocs/docs/utils/file-concatenation.mdx b/packages/audiodocs/docs/utils/file-concatenation.mdx index 219daebbd..af7bce878 100644 --- a/packages/audiodocs/docs/utils/file-concatenation.mdx +++ b/packages/audiodocs/docs/utils/file-concatenation.mdx @@ -46,7 +46,7 @@ If playback quality is poor after joining rotated M4A files, increase `rotateInt Concatenates compatible local audio files into a single output file. | Parameter | Type | Description | -| :---: | :---: | :---- | +| :----------: | :--------: | :------------------------------------------------------------------------------------ | | `inputPaths` | `string[]` | Local filesystem paths or `file://` URLs to concatenate, in output order. | | `outputPath` | `string` | Local filesystem path or `file://` URL where the joined audio file should be written. | @@ -68,7 +68,12 @@ come from the same recorder configuration: Use an output path with an extension that matches the input files, for example joining `.m4a` segments into an `.m4a` output file, `.wav` segments into a -`.wav` output file, or `.flac` segments into a `.flac` output file. +`.wav` output file, `.flac` segments into a `.flac` output file, or `.aac` +(ADTS) segments into an `.aac` output file. + +ADTS segments are joined by appending their frames, with no decoding or +re-encoding, on every platform. A segment that ends in an incomplete frame, +such as a recording cut off by a crash, contributes its complete frames only.
Example usage with rotated recordings diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AndroidEncoder.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AndroidEncoder.cpp index f1aef5e5e..1f7abe592 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AndroidEncoder.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AndroidEncoder.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -662,6 +663,100 @@ class FlacBackend : public MediaCodecBackend { size_t bytesWritten_{0}; }; +// --------------------------------------------------------------------------- +// ADTS backend — MediaCodec AAC-LC encoder, each access unit written behind its own +// ADTS header. No muxer: the stream is complete after any frame, so a killed +// recording keeps everything up to its last complete frame. +// --------------------------------------------------------------------------- + +class AdtsBackend : public MediaCodecBackend { + public: + std::string open( + const AudioLayout &desired, + const std::string &filePath, + const AudioFileProperties::EncodingConfig &encoding, + const EncoderOutputSpec &outputSpec, + AudioLayout &effective) override { + (void)outputSpec; + effective = desired; + + auto layoutResult = adts::validateLayout(desired); + if (layoutResult.is_err()) { + return "AdtsBackend: " + layoutResult.unwrap_err(); + } + samplingFrequencyIndex_ = *adts::samplingFrequencyIndex(static_cast(desired.sampleRate)); + + file_ = std::fopen(filePath.c_str(), "wb"); + if (file_ == nullptr) { + return "AdtsBackend: failed to open file for writing"; + } + + std::string err = startCodec( + "audio/mp4a-latm", + static_cast(desired.sampleRate), + desired.channelCount, + static_cast(encoding.bitRate), + true); + if (!err.empty()) { + std::fclose(file_); + file_ = nullptr; + return err; + } + return ""; + } + + [[nodiscard]] size_t getFileSizeBytes() const override { + return bytesWritten_; + } + + protected: + std::string onFormatChanged(AMediaFormat *outputFormat) override { + (void)outputFormat; + return ""; + } + + std::string onEncodedSample(const uint8_t *data, const AMediaCodecBufferInfo &info) override { + // The AudioSpecificConfig the codec emits first belongs in a container; ADTS headers + // carry the same information per frame. + if ((info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG) != 0) { + return ""; + } + if (file_ == nullptr) { + return "AdtsBackend: file not open"; + } + + const auto payloadSize = static_cast(info.size); + auto header = adts::makeHeader(samplingFrequencyIndex_, channelCount_, payloadSize); + if (!header.has_value()) { + return "AdtsBackend: encoded frame too long for an ADTS header"; + } + + if (std::fwrite(header->data(), 1, header->size(), file_) != header->size() || + std::fwrite(data + info.offset, 1, payloadSize, file_) != payloadSize) { + return "AdtsBackend: short write"; + } + // Flushed per frame, so the crash-recoverability the format exists for holds at frame + // granularity rather than at stdio's buffer size. + std::fflush(file_); + bytesWritten_ += header->size() + payloadSize; + return ""; + } + + std::string finalize() override { + codec_.reset(); + if (file_ != nullptr) { + std::fclose(file_); + file_ = nullptr; + } + return ""; + } + + private: + std::FILE *file_{nullptr}; + uint8_t samplingFrequencyIndex_{0}; + size_t bytesWritten_{0}; +}; + // --------------------------------------------------------------------------- // Conversion state — planar scratch for channel mapping and rate conversion, built at // open() only when the input differs from the backend's effective format. @@ -731,6 +826,12 @@ OpenEncoderResult AndroidEncoder::open( backend_ = std::make_unique(); break; case AudioCodec::AAC: + if (outputSpec.container == AudioContainer::ADTS) { + backend_ = std::make_unique(); + } else { + backend_ = std::make_unique(); + } + break; case AudioCodec::OPUS: case AudioCodec::VORBIS: backend_ = std::make_unique(); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp new file mode 100644 index 000000000..d5a99c422 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp @@ -0,0 +1,89 @@ +#include + +#include +#include + +namespace audioapi::adts { + +namespace { + +/// Indexed by the sampling frequency index of the AudioSpecificConfig. +constexpr std::array SAMPLING_FREQUENCIES = + {96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350}; + +/// MPEG-4, layer 0, no CRC. +constexpr uint8_t SYNCWORD_HIGH = 0xFF; +constexpr uint8_t SYNCWORD_LOW_FLAGS = 0xF1; +/// AAC-LC is object type 2, stored as (type - 1) in the 2-bit profile field. +constexpr unsigned PROFILE_AAC_LC = 1; +/// 0x7FF: variable bit rate, no buffer fullness information. +constexpr unsigned BUFFER_FULLNESS_VBR = 0x7FF; + +} // namespace + +std::optional samplingFrequencyIndex(int sampleRate) { + for (size_t index = 0; index < SAMPLING_FREQUENCIES.size(); ++index) { + if (SAMPLING_FREQUENCIES[index] == sampleRate) { + return static_cast(index); + } + } + return std::nullopt; +} + +Result validateLayout(const AudioLayout &layout) { + const int sampleRate = static_cast(layout.sampleRate); + if (!samplingFrequencyIndex(sampleRate).has_value()) { + return Err("Sample rate not expressible in an ADTS header: " + std::to_string(sampleRate)); + } + if (layout.channelCount < 1 || layout.channelCount > MAX_CHANNEL_CONFIGURATION) { + return Err( + "Channel count not expressible in an ADTS header: " + std::to_string(layout.channelCount)); + } + return Ok(None); +} + +std::optional +makeHeader(uint8_t samplingFrequencyIndex, int channelCount, size_t payloadSize) { + const size_t frameLength = payloadSize + HEADER_SIZE; + if (frameLength > MAX_FRAME_LENGTH) { + return std::nullopt; + } + + const auto channelConfiguration = static_cast(channelCount); + const auto length = static_cast(frameLength); + + HeaderBytes header{}; + header[0] = SYNCWORD_HIGH; + header[1] = SYNCWORD_LOW_FLAGS; + header[2] = static_cast( + (PROFILE_AAC_LC << 6) | (static_cast(samplingFrequencyIndex) << 2) | + ((channelConfiguration >> 2) & 0x1U)); + header[3] = static_cast(((channelConfiguration & 0x3U) << 6) | ((length >> 11) & 0x3U)); + header[4] = static_cast((length >> 3) & 0xFFU); + header[5] = static_cast(((length & 0x7U) << 5) | ((BUFFER_FULLNESS_VBR >> 6) & 0x1FU)); + // Low 6 bits of buffer fullness, then "1 raw data block per frame" (count - 1 = 0). + header[6] = static_cast((BUFFER_FULLNESS_VBR & 0x3FU) << 2); + return header; +} + +std::optional parseHeader(const HeaderBytes &header) { + const bool hasSyncword = header[0] == SYNCWORD_HIGH && (header[1] & 0xF6U) == 0xF0U; + if (!hasSyncword) { + return std::nullopt; + } + + FrameInfo info{ + .frameLength = ((static_cast(header[3]) & 0x3U) << 11) | + (static_cast(header[4]) << 3) | (static_cast(header[5]) >> 5), + .samplingFrequencyIndex = static_cast((header[2] >> 2) & 0xFU), + .channelConfiguration = static_cast(((header[2] & 0x1U) << 2) | (header[3] >> 6)), + .hasCrc = (header[1] & 0x1U) == 0, + }; + const size_t minimumLength = HEADER_SIZE + (info.hasCrc ? 2 : 0); + if (info.frameLength < minimumLength) { + return std::nullopt; + } + return info; +} + +} // namespace audioapi::adts diff --git a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.h b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.h new file mode 100644 index 000000000..f613e101d --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +/// ADTS framing for raw AAC-LC streams (ISO/IEC 13818-7 / 14496-3): every access unit is +/// prefixed with a 7-byte header carrying the sync word, the stream layout and the frame +/// length, so a file cut off at any point stays decodable up to its last complete frame. +namespace audioapi::adts { + +inline constexpr size_t HEADER_SIZE = 7; +/// Largest header + payload the 13-bit frame-length field can describe. +inline constexpr size_t MAX_FRAME_LENGTH = (1U << 13) - 1; +/// The 3-bit channel configuration maps 1..6 to the channel count; 7 means 7.1 and 0 means +/// "declared by an in-band PCE", which these headers never carry. +inline constexpr int MAX_CHANNEL_CONFIGURATION = 6; + +using HeaderBytes = std::array; + +/// MPEG-4 sampling frequency index of @p sampleRate, or nullopt when ADTS cannot express it. +std::optional samplingFrequencyIndex(int sampleRate); + +/// Whether @p layout fits in a header; the error names the field that does not. +Result validateLayout(const AudioLayout &layout); + +/// Header for one AAC-LC access unit of @p payloadSize bytes, without CRC. @p samplingFrequencyIndex +/// and @p channelCount come from a layout validateLayout() accepted. Nullopt when the frame +/// would exceed MAX_FRAME_LENGTH. +std::optional +makeHeader(uint8_t samplingFrequencyIndex, int channelCount, size_t payloadSize); + +/// What a fixed header declares about its frame. +struct FrameInfo { + /// Header (and CRC, when present) included. + size_t frameLength = 0; + uint8_t samplingFrequencyIndex = 0; + uint8_t channelConfiguration = 0; + bool hasCrc = false; + + bool operator==(const FrameInfo &) const = default; +}; + +/// Reads @p header back; nullopt when the sync word is missing or the frame length could +/// not hold the header. The inverse of makeHeader(), and the way a stream is walked. +std::optional parseHeader(const HeaderBytes &header); + +} // namespace audioapi::adts diff --git a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AudioFileConcatenator.cpp b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AudioFileConcatenator.cpp index 12964e449..02003243e 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AudioFileConcatenator.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AudioFileConcatenator.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -10,9 +11,11 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -66,7 +69,11 @@ ValidationResult validatePaths( /// The formats concat can write. The output path's extension picks one, and every input must /// carry the same extension. -constexpr std::array CONCAT_OUTPUT_FORMATS = {FileFormat::WAV, FileFormat::M4A, FileFormat::FLAC}; +constexpr std::array CONCAT_OUTPUT_FORMATS = { + FileFormat::WAV, + FileFormat::M4A, + FileFormat::FLAC, + FileFormat::ADTS}; std::string extensionOf(FileFormat format) { return std::string(encoder_capabilities::specForFormat(format).extension); @@ -356,6 +363,148 @@ AudioFileConcatResult concatAudioFilesWithEncoder( return Ok(outputPath); } +/// What a walk over an ADTS input found: the layout its frames declare and how many bytes +/// hold complete frames. A recording cut off by a crash ends in a partial frame, which is +/// left behind so the frame after it, from the next input, is not swallowed by a desync. +struct AdtsInputInfo { + uint8_t samplingFrequencyIndex = 0; + uint8_t channelConfiguration = 0; + std::streamoff completeFrameBytes = 0; +}; + +constexpr size_t COPY_CHUNK_BYTES = 64 * 1024; + +Result scanAdtsFrames(const std::string &inputPath) { + std::ifstream input(inputPath, std::ios::binary | std::ios::ate); + if (!input.is_open()) { + return Err("Failed to open input file '" + inputPath + "'."); + } + const std::streamoff fileSize = input.tellg(); + + std::optional firstFrame; + std::streamoff offset = 0; + // Only the headers are read; each payload is skipped with a seek. + while (offset + static_cast(adts::HEADER_SIZE) <= fileSize) { + adts::HeaderBytes header{}; + input.seekg(offset); + input.read( + reinterpret_cast(header.data()), static_cast(header.size())); + if (!input) { + return Err("Failed to read input file '" + inputPath + "'."); + } + + const auto frame = adts::parseHeader(header); + if (!frame.has_value()) { + return Err( + "Input file '" + inputPath + "' is not an ADTS stream (no frame at byte " + + std::to_string(offset) + ")."); + } + if (firstFrame.has_value() && + (frame->samplingFrequencyIndex != firstFrame->samplingFrequencyIndex || + frame->channelConfiguration != firstFrame->channelConfiguration)) { + return Err( + "Input file '" + inputPath + "' changes its sample rate or channel count mid-stream."); + } + if (!firstFrame.has_value()) { + firstFrame = frame; + } + + const auto frameEnd = offset + static_cast(frame->frameLength); + if (frameEnd > fileSize) { + break; + } + offset = frameEnd; + } + + if (!firstFrame.has_value()) { + return Err("Input file '" + inputPath + "' contains no ADTS frames."); + } + return Ok( + AdtsInputInfo{ + .samplingFrequencyIndex = firstFrame->samplingFrequencyIndex, + .channelConfiguration = firstFrame->channelConfiguration, + .completeFrameBytes = offset}); +} + +/// Appends the first @p byteCount bytes of @p inputPath to @p output. +ValidationResult +appendLeadingBytes(const std::string &inputPath, std::streamoff byteCount, std::ofstream &output) { + std::ifstream input(inputPath, std::ios::binary); + if (!input.is_open()) { + return Err("Failed to open input file '" + inputPath + "'."); + } + + std::vector chunk(COPY_CHUNK_BYTES); + std::streamoff remaining = byteCount; + while (remaining > 0) { + const auto toRead = static_cast( + std::min(remaining, static_cast(chunk.size()))); + input.read(chunk.data(), toRead); + const std::streamsize bytesRead = input.gcount(); + if (bytesRead <= 0) { + return Err("Failed to read input file '" + inputPath + "'."); + } + output.write(chunk.data(), bytesRead); + if (!output) { + return Err("Failed to write the output file."); + } + remaining -= bytesRead; + } + return Ok(None); +} + +/// ADTS has no container, so the inputs' frames follow each other unchanged. Every input must +/// be an ADTS stream with the first one's sample rate and channel configuration; nothing is +/// decoded, so this works on every platform. +AudioFileConcatResult concatAudioFilesWithFrameAppend( + const std::vector &inputPaths, + const std::string &outputPath) { + for (const auto &inputPath : inputPaths) { + if (!path::hasExtension(inputPath, {extensionOf(FileFormat::ADTS)})) { + return Err("concatAudioFiles ADTS output requires all input files to use the AAC extension."); + } + } + + std::vector inputs; + inputs.reserve(inputPaths.size()); + for (const auto &inputPath : inputPaths) { + auto infoResult = scanAdtsFrames(inputPath); + if (infoResult.is_err()) { + return Err(std::move(infoResult).unwrap_err()); + } + inputs.push_back(std::move(infoResult).unwrap()); + + const auto &reference = inputs.front(); + if (inputs.back().samplingFrequencyIndex != reference.samplingFrequencyIndex) { + return Err("Input file '" + inputPath + "' uses a different sample rate."); + } + if (inputs.back().channelConfiguration != reference.channelConfiguration) { + return Err("Input file '" + inputPath + "' uses a different channel count."); + } + } + + std::ofstream output(outputPath, std::ios::binary | std::ios::trunc); + if (!output.is_open()) { + return Err("Failed to open ADTS output '" + outputPath + "'."); + } + + for (size_t i = 0; i < inputPaths.size(); ++i) { + auto copyResult = appendLeadingBytes(inputPaths[i], inputs[i].completeFrameBytes, output); + if (copyResult.is_err()) { + output.close(); + file_system::removeFile(outputPath); + return Err(std::move(copyResult).unwrap_err()); + } + } + + output.close(); + if (!output) { + file_system::removeFile(outputPath); + return Err("Failed to finalize ADTS output '" + outputPath + "'."); + } + return Ok(outputPath); +} + EncoderFactory platformEncoderFactory() { #if RN_AUDIO_API_HAS_OS_ENCODER return &createOsEncoder; @@ -389,18 +538,28 @@ AudioFileConcatResult concatAudioFiles( return Err(std::move(pathValidationResult).unwrap_err()); } - // M4A remuxes the inputs' AAC packets through OS APIs. WAV and FLAC have no remux path on - // either platform, so their inputs are decoded and re-encoded through the system encoder: - // WAV as float32 PCM, FLAC losslessly. + // M4A remuxes the inputs' AAC packets through OS APIs. ADTS frames are self-delimiting, so + // its inputs are appended byte for byte. WAV and FLAC have no remux path on either platform, + // so their inputs are decoded and re-encoded through the system encoder: WAV as float32 + // PCM, FLAC losslessly. const auto outputFormat = outputFormatForPath(normalizedOutputPath); if (!outputFormat.has_value()) { - return Err("concatAudioFiles supports WAV, M4A, and FLAC output."); + return Err("concatAudioFiles supports WAV, M4A, FLAC, and ADTS output."); } - auto result = *outputFormat == FileFormat::M4A - ? concatAudioFilesWithOsRemux(normalizedInputPaths, normalizedOutputPath) - : concatAudioFilesWithEncoder( - normalizedInputPaths, normalizedOutputPath, *outputFormat, createEncoder); + AudioFileConcatResult result = Err(std::string{}); + switch (*outputFormat) { + case FileFormat::M4A: + result = concatAudioFilesWithOsRemux(normalizedInputPaths, normalizedOutputPath); + break; + case FileFormat::ADTS: + result = concatAudioFilesWithFrameAppend(normalizedInputPaths, normalizedOutputPath); + break; + default: + result = concatAudioFilesWithEncoder( + normalizedInputPaths, normalizedOutputPath, *outputFormat, createEncoder); + break; + } return std::move(result).map([&outputPath](const std::string &) { return outputPath; }); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/encoding/EncoderCapabilities.h b/packages/react-native-audio-api/common/cpp/audioapi/encoding/EncoderCapabilities.h index b5a305b0b..8656a6fd1 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/encoding/EncoderCapabilities.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/encoding/EncoderCapabilities.h @@ -25,6 +25,10 @@ inline constexpr std::array kSpecsByFormat = { .container = AudioContainer::M4A, .codec = AudioCodec::AAC, .extension = "m4a"}, // M4A + EncoderOutputSpec{ + .container = AudioContainer::ADTS, + .codec = AudioCodec::AAC, + .extension = "aac"}, // ADTS EncoderOutputSpec{ .container = AudioContainer::FLAC, .codec = AudioCodec::FLAC, @@ -74,6 +78,7 @@ inline constexpr std::array kSupportedFormats = { AudioFileProperties::FileFormat::FLAC, AudioFileProperties::FileFormat::ULAW, AudioFileProperties::FileFormat::ALAW, + AudioFileProperties::FileFormat::ADTS, }; #elif defined(__ANDROID__) inline constexpr std::array kSupportedFormats = { @@ -83,6 +88,7 @@ inline constexpr std::array kSupportedFormats = { AudioFileProperties::FileFormat::OPUS_OGG, AudioFileProperties::FileFormat::OPUS_WEBM, AudioFileProperties::FileFormat::VORBIS_WEBM, + AudioFileProperties::FileFormat::ADTS, }; #else inline constexpr std::array kSupportedFormats = {}; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/encoding/EncoderOutputSpec.h b/packages/react-native-audio-api/common/cpp/audioapi/encoding/EncoderOutputSpec.h index 6ead1f0fc..55bf779ea 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/encoding/EncoderOutputSpec.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/encoding/EncoderOutputSpec.h @@ -24,6 +24,7 @@ enum class AudioContainer : uint8_t { WEBM, OGG, FLAC, + ADTS, }; struct EncoderOutputSpec { @@ -70,6 +71,8 @@ inline const char *toString(AudioContainer container) { return "OGG"; case AudioContainer::FLAC: return "FLAC"; + case AudioContainer::ADTS: + return "ADTS"; } return "unknown"; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioFileProperties.h b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioFileProperties.h index 8ebda41f7..03ea7a501 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioFileProperties.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioFileProperties.h @@ -29,6 +29,7 @@ class AudioFileProperties { WAV, CAF, M4A, + ADTS, FLAC, AIFF, ALAC, diff --git a/packages/react-native-audio-api/common/cpp/test/src/encoding/AudioFileConcatenatorTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/encoding/AudioFileConcatenatorTest.cpp index ab4406858..b406edfa8 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/encoding/AudioFileConcatenatorTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/encoding/AudioFileConcatenatorTest.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -7,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -134,6 +136,31 @@ std::vector readWavFile(const std::string &path) { return frames; } +/// One ADTS frame per payload byte count, so a stream's framing is known to the byte. +std::vector makeAdtsStream( + uint8_t samplingFrequencyIndex, + int channelCount, + const std::vector &payloadSizes) { + std::vector bytes; + for (size_t payload : payloadSizes) { + const auto header = *adts::makeHeader(samplingFrequencyIndex, channelCount, payload); + bytes.insert(bytes.end(), header.begin(), header.end()); + bytes.insert(bytes.end(), payload, static_cast(0xAB)); + } + return bytes; +} + +void writeBytes(const std::string &path, const std::vector &bytes) { + std::ofstream output(path, std::ios::binary); + ASSERT_TRUE(output.is_open()); + output.write(bytes.data(), static_cast(bytes.size())); +} + +std::vector readBytes(const std::string &path) { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} + } // namespace TEST(AudioFileConcatenatorTest, RejectsEmptyInputList) { @@ -262,7 +289,7 @@ TEST(AudioFileConcatenatorTest, RejectsUnsupportedOutputFormat) { auto result = concatAudioFiles({"/tmp/input.ogg"}, "/tmp/output.ogg"); EXPECT_TRUE(result.is_err()); - EXPECT_EQ(result.unwrap_err(), "concatAudioFiles supports WAV, M4A, and FLAC output."); + EXPECT_EQ(result.unwrap_err(), "concatAudioFiles supports WAV, M4A, FLAC, and ADTS output."); } TEST(AudioFileConcatenatorTest, ReturnsUnavailableErrorForFLACOnDesktop) { @@ -285,7 +312,106 @@ TEST(AudioFileConcatenatorTest, RejectsMismatchedRemuxExtensions) { auto result = concatAudioFiles({"/tmp/input.m4a"}, "/tmp/output.caf"); EXPECT_TRUE(result.is_err()); - EXPECT_EQ(result.unwrap_err(), "concatAudioFiles supports WAV, M4A, and FLAC output."); + EXPECT_EQ(result.unwrap_err(), "concatAudioFiles supports WAV, M4A, FLAC, and ADTS output."); +} + +TEST(AudioFileConcatenatorTest, AppendsAdtsFramesByteForByte) { + const std::string inputA = testFilePath("audio-concat-a.aac"); + const std::string inputB = testFilePath("audio-concat-b.aac"); + const std::string output = testFilePath("audio-concat-output.aac"); + removeFile(inputA); + removeFile(inputB); + removeFile(output); + + const auto streamA = makeAdtsStream(4, 2, {100, 250}); + const auto streamB = makeAdtsStream(4, 2, {80}); + writeBytes(inputA, streamA); + writeBytes(inputB, streamB); + + auto result = concatAudioFiles({inputA, inputB}, output); + + if (result.is_err()) { + FAIL() << result.unwrap_err(); + } + EXPECT_EQ(result.unwrap(), output); + std::vector expected = streamA; + expected.insert(expected.end(), streamB.begin(), streamB.end()); + EXPECT_EQ(readBytes(output), expected); + + removeFile(inputA); + removeFile(inputB); + removeFile(output); +} + +TEST(AudioFileConcatenatorTest, DropsATrailingPartialAdtsFrame) { + const std::string inputA = testFilePath("audio-concat-cut.aac"); + const std::string inputB = testFilePath("audio-concat-next.aac"); + const std::string output = testFilePath("audio-concat-cut-output.aac"); + removeFile(inputA); + removeFile(inputB); + removeFile(output); + + // A recording killed mid-frame: the second frame is missing its last 10 bytes. + auto streamA = makeAdtsStream(4, 1, {100, 50}); + const auto completeBytes = streamA.size() - 50 - adts::HEADER_SIZE; + streamA.resize(streamA.size() - 10); + const auto streamB = makeAdtsStream(4, 1, {60}); + writeBytes(inputA, streamA); + writeBytes(inputB, streamB); + + auto result = concatAudioFiles({inputA, inputB}, output); + + if (result.is_err()) { + FAIL() << result.unwrap_err(); + } + std::vector expected(streamA.begin(), streamA.begin() + completeBytes); + expected.insert(expected.end(), streamB.begin(), streamB.end()); + EXPECT_EQ(readBytes(output), expected); + + removeFile(inputA); + removeFile(inputB); + removeFile(output); +} + +TEST(AudioFileConcatenatorTest, RejectsAdtsInputsWithDifferentLayouts) { + const std::string inputA = testFilePath("audio-concat-stereo.aac"); + const std::string inputB = testFilePath("audio-concat-mono.aac"); + const std::string output = testFilePath("audio-concat-layout-output.aac"); + removeFile(inputA); + removeFile(inputB); + removeFile(output); + + writeBytes(inputA, makeAdtsStream(4, 2, {100})); + writeBytes(inputB, makeAdtsStream(4, 1, {100})); + + auto result = concatAudioFiles({inputA, inputB}, output); + + EXPECT_TRUE(result.is_err()); + EXPECT_EQ(result.unwrap_err(), "Input file '" + inputB + "' uses a different channel count."); + EXPECT_FALSE(std::filesystem::exists(output)); + + removeFile(inputA); + removeFile(inputB); +} + +TEST(AudioFileConcatenatorTest, RejectsAdtsOutputWithNonAdtsInputs) { + const std::string input = testFilePath("audio-concat-not-adts.aac"); + removeFile(input); + writeBytes(input, {'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'A', 'V', 'E'}); + + auto wrongExtension = concatAudioFiles({"/tmp/input.m4a"}, "/tmp/output.aac"); + EXPECT_TRUE(wrongExtension.is_err()); + EXPECT_EQ( + wrongExtension.unwrap_err(), + "concatAudioFiles ADTS output requires all input files to use the AAC extension."); + + auto notAStream = concatAudioFiles({input}, testFilePath("audio-concat-not-adts-output.aac")); + EXPECT_TRUE(notAStream.is_err()); + EXPECT_EQ( + notAStream.unwrap_err(), + "Input file '" + input + "' is not an ADTS stream (no frame at byte 0)."); + + removeFile(input); } // NOLINTEND diff --git a/packages/react-native-audio-api/common/cpp/test/src/encoding/EncoderCapabilitiesTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/encoding/EncoderCapabilitiesTest.cpp index e56f0b74b..798c00409 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/encoding/EncoderCapabilitiesTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/encoding/EncoderCapabilitiesTest.cpp @@ -20,6 +20,7 @@ const std::vector kAllFormats = { FileFormat::WAV, FileFormat::CAF, FileFormat::M4A, + FileFormat::ADTS, FileFormat::FLAC, FileFormat::AIFF, FileFormat::ALAC, @@ -46,6 +47,10 @@ TEST(EncoderCapabilitiesTest, SpecForFormatMapsKnownFormats) { EXPECT_EQ(encoder_capabilities::specForFormat(FileFormat::FLAC).container, AudioContainer::FLAC); EXPECT_EQ(encoder_capabilities::specForFormat(FileFormat::FLAC).codec, AudioCodec::FLAC); + + EXPECT_EQ(encoder_capabilities::specForFormat(FileFormat::ADTS).container, AudioContainer::ADTS); + EXPECT_EQ(encoder_capabilities::specForFormat(FileFormat::ADTS).codec, AudioCodec::AAC); + EXPECT_EQ(encoder_capabilities::specForFormat(FileFormat::ADTS).extension, "aac"); } TEST(EncoderCapabilitiesTest, ExtensionsAreLowercaseAndNonEmpty) { @@ -84,6 +89,7 @@ TEST(EncoderCapabilitiesTest, ApplePlatformSupportsCoreFormats) { EXPECT_TRUE(encoder_capabilities::isSupported(AudioContainer::WAV, AudioCodec::PCM)); EXPECT_TRUE(encoder_capabilities::isSupported(AudioContainer::CAF, AudioCodec::PCM)); EXPECT_TRUE(encoder_capabilities::isSupported(AudioContainer::M4A, AudioCodec::AAC)); + EXPECT_TRUE(encoder_capabilities::isSupported(AudioContainer::ADTS, AudioCodec::AAC)); EXPECT_FALSE(encoder_capabilities::isSupported(AudioContainer::WEBM, AudioCodec::OPUS)); } #elif defined(__ANDROID__) @@ -91,6 +97,7 @@ TEST(EncoderCapabilitiesTest, AndroidPlatformSupportsCoreFormats) { EXPECT_TRUE(encoder_capabilities::isSupported(AudioContainer::WAV, AudioCodec::PCM)); EXPECT_TRUE(encoder_capabilities::isSupported(AudioContainer::M4A, AudioCodec::AAC)); EXPECT_TRUE(encoder_capabilities::isSupported(AudioContainer::WEBM, AudioCodec::OPUS)); + EXPECT_TRUE(encoder_capabilities::isSupported(AudioContainer::ADTS, AudioCodec::AAC)); EXPECT_FALSE(encoder_capabilities::isSupported(AudioContainer::CAF, AudioCodec::PCM)); } #endif diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts index 1d7c070d1..489de2039 100644 --- a/packages/react-native-audio-api/src/types.ts +++ b/packages/react-native-audio-api/src/types.ts @@ -78,6 +78,7 @@ export enum FileFormat { Wav, Caf, M4A, + Adts, Flac, Aiff, Alac, From 8b7d3124ae4f8452d3c5dcebfb1bc800d4a3b8c0 Mon Sep 17 00:00:00 2001 From: michal Date: Thu, 8 Oct 2026 14:59:14 +0200 Subject: [PATCH 2/5] feat: final look --- .../audiodocs/docs/inputs/audio-recorder.mdx | 9 +- .../docs/utils/file-concatenation.mdx | 13 +-- .../cpp/audioapi/encoding/AdtsHeader.cpp | 2 +- .../common/cpp/audioapi/encoding/AdtsHeader.h | 2 +- .../encoding/AudioFileConcatenator.cpp | 42 ++++++- .../common/cpp/audioapi/utils/FileSystem.hpp | 9 ++ .../encoding/AudioFileConcatenatorTest.cpp | 109 ++++++++++++++++-- 7 files changed, 156 insertions(+), 30 deletions(-) diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index 20d0fd046..1db89ee1c 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -1012,10 +1012,13 @@ The table below lists which formats each platform can encode with its system API | `OpusWebm` | WebM / Opus | ❌ | ✅ | | `VorbisWebm` | WebM / Vorbis | ❌ | ✅ | -`Adts` writes raw AAC-LC frames in ADTS framing (`.aac` extension) with no container. Every frame carries its own sync word and length, so a recording cut off by a crash or force-kill stays decodable up to the last complete frame, like `Flac` but at lossy AAC file sizes. +:::tip +Both `adts` and `flac` formats stays decodable after an app crash, but only former is lossy. +`Adts` accepts only the sample rates an ADTS header can express: 7350, 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000, 64000, 88200 and 96000 Hz. +::: :::caution Platform support -Selecting a format the current platform cannot encode (for example `Caf` on Android or `OpusOgg` on iOS) fails when file output is enabled, with a descriptive error. Some Android formats also depend on the device OS version (Opus/OGG muxing requires newer releases); if a device lacks a system encoder for the requested format, recording start returns an error. Use `Wav`, `M4A`, or `Flac` for the widest cross-platform support. +Selecting a format the current platform cannot encode (for example `Caf` on Android or `OpusOgg` on iOS) fails when file output is enabled, with a descriptive error. Some Android formats also depend on the device OS version (Opus/OGG muxing requires newer releases); if a device lacks a system encoder for the requested format, recording start returns an error. Use `Wav`, `M4A`, `Flac` or `Adts` for the widest cross-platform support. ::: #### FileInfo @@ -1174,7 +1177,7 @@ export interface FilePresetType {
- Specifies the sampling frequency used during recording. Higher sample rates capture a wider frequency range but increase processing and storage requirements. + Specifies the sampling frequency used during recording. Higher sample rates capture a wider frequency range but increase processing and storage requirements. AAC output in the `Adts` format is limited to the standard AAC rates listed under [`FileFormat`](#fileformat).
diff --git a/packages/audiodocs/docs/utils/file-concatenation.mdx b/packages/audiodocs/docs/utils/file-concatenation.mdx index af7bce878..36c03a7bf 100644 --- a/packages/audiodocs/docs/utils/file-concatenation.mdx +++ b/packages/audiodocs/docs/utils/file-concatenation.mdx @@ -45,9 +45,9 @@ If playback quality is poor after joining rotated M4A files, increase `rotateInt Concatenates compatible local audio files into a single output file. -| Parameter | Type | Description | -| :----------: | :--------: | :------------------------------------------------------------------------------------ | -| `inputPaths` | `string[]` | Local filesystem paths or `file://` URLs to concatenate, in output order. | +| Parameter | Type | Description| +| :----------: | :--------: | :--- | +| `inputPaths` | `string[]` | Local filesystem paths or `file://` URLs to concatenate, in output order. | | `outputPath` | `string` | Local filesystem path or `file://` URL where the joined audio file should be written. | #### Returns `Promise`. @@ -68,12 +68,7 @@ come from the same recorder configuration: Use an output path with an extension that matches the input files, for example joining `.m4a` segments into an `.m4a` output file, `.wav` segments into a -`.wav` output file, `.flac` segments into a `.flac` output file, or `.aac` -(ADTS) segments into an `.aac` output file. - -ADTS segments are joined by appending their frames, with no decoding or -re-encoding, on every platform. A segment that ends in an incomplete frame, -such as a recording cut off by a crash, contributes its complete frames only. +`.wav` output file and so on.
Example usage with rotated recordings diff --git a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp index d5a99c422..2c731f349 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp @@ -11,8 +11,8 @@ namespace { constexpr std::array SAMPLING_FREQUENCIES = {96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350}; -/// MPEG-4, layer 0, no CRC. constexpr uint8_t SYNCWORD_HIGH = 0xFF; +/// The low 4 sync bits, then ID 0 (MPEG-4), layer 0 and protection_absent 1 (no CRC). constexpr uint8_t SYNCWORD_LOW_FLAGS = 0xF1; /// AAC-LC is object type 2, stored as (type - 1) in the 2-bit profile field. constexpr unsigned PROFILE_AAC_LC = 1; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.h b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.h index f613e101d..528cc6818 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.h @@ -47,7 +47,7 @@ struct FrameInfo { }; /// Reads @p header back; nullopt when the sync word is missing or the frame length could -/// not hold the header. The inverse of makeHeader(), and the way a stream is walked. +/// not hold the header. The inverse of makeHeader(). std::optional parseHeader(const HeaderBytes &header); } // namespace audioapi::adts diff --git a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AudioFileConcatenator.cpp b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AudioFileConcatenator.cpp index 02003243e..b4d088c00 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AudioFileConcatenator.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AudioFileConcatenator.cpp @@ -15,10 +15,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -88,6 +90,22 @@ std::optional outputFormatForPath(const std::string &path) { return std::nullopt; } +/// A sibling of @p outputPath under a name no file holds yet. The extension is kept because +/// the iOS writers pick the container from it. +std::string makeStagingPath(const std::string &outputPath) { + const std::filesystem::path finalPath(outputPath); + const std::string stem = finalPath.stem().string(); + const std::string extension = finalPath.extension().string(); + std::random_device entropy; + while (true) { + std::filesystem::path candidate = finalPath; + candidate.replace_filename(stem + ".partial-" + std::to_string(entropy()) + extension); + if (!file_system::fileExists(candidate.string())) { + return candidate.string(); + } + } +} + } // namespace AudioFileReader::AudioFileReader(AudioFileReader &&other) noexcept @@ -383,7 +401,6 @@ Result scanAdtsFrames(const std::string &inputPath) std::optional firstFrame; std::streamoff offset = 0; - // Only the headers are read; each payload is skipped with a seek. while (offset + static_cast(adts::HEADER_SIZE) <= fileSize) { adts::HeaderBytes header{}; input.seekg(offset); @@ -547,20 +564,35 @@ AudioFileConcatResult concatAudioFiles( return Err("concatAudioFiles supports WAV, M4A, FLAC, and ADTS output."); } + // The output is written under a staging name and moved into place once complete, so a failure + // never leaves a truncated file at the output path and the output may be one of the inputs. + const std::string stagingPath = makeStagingPath(normalizedOutputPath); AudioFileConcatResult result = Err(std::string{}); switch (*outputFormat) { case FileFormat::M4A: - result = concatAudioFilesWithOsRemux(normalizedInputPaths, normalizedOutputPath); + result = concatAudioFilesWithOsRemux(normalizedInputPaths, stagingPath); break; case FileFormat::ADTS: - result = concatAudioFilesWithFrameAppend(normalizedInputPaths, normalizedOutputPath); + result = concatAudioFilesWithFrameAppend(normalizedInputPaths, stagingPath); break; default: result = concatAudioFilesWithEncoder( - normalizedInputPaths, normalizedOutputPath, *outputFormat, createEncoder); + normalizedInputPaths, stagingPath, *outputFormat, createEncoder); break; } - return std::move(result).map([&outputPath](const std::string &) { return outputPath; }); + if (result.is_err()) { + file_system::removeFile(stagingPath); + return Err(std::move(result).unwrap_err()); + } + + const std::error_code moveError = file_system::moveFile(stagingPath, normalizedOutputPath); + if (moveError) { + file_system::removeFile(stagingPath); + return Err( + "Failed to move the concatenated file to '" + normalizedOutputPath + + "': " + moveError.message()); + } + return Ok(outputPath); } } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/FileSystem.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/FileSystem.hpp index b96b20fe8..53bace1a8 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/FileSystem.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/FileSystem.hpp @@ -26,4 +26,13 @@ inline void removeFile(const std::string &path) { std::filesystem::remove(path, ignored); } +/// Replaces @p toPath when it already exists. Returns the OS error when the move fails. +[[nodiscard]] inline std::error_code moveFile( + const std::string &fromPath, + const std::string &toPath) { + std::error_code error; + std::filesystem::rename(fromPath, toPath, error); + return error; +} + } // namespace audioapi::file_system diff --git a/packages/react-native-audio-api/common/cpp/test/src/encoding/AudioFileConcatenatorTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/encoding/AudioFileConcatenatorTest.cpp index b406edfa8..393fae9cd 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/encoding/AudioFileConcatenatorTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/encoding/AudioFileConcatenatorTest.cpp @@ -60,13 +60,16 @@ void writeWavFile(const std::string &path, const std::vector &frames) { test::writeFloatWavFile(path, frames, sampleRate, channelCount); } +enum class EncodeOutcome { ACCEPTS_FRAMES, REFUSES_FRAMES }; + /// Stands in for the platform encoder, which the desktop build does not have. class FakeWavEncoder final : public AudioEncoder { public: - FakeWavEncoder(const EncoderSettings &settings, bool failEncode) - : AudioEncoder(settings), failEncode_(failEncode) {} + FakeWavEncoder(const EncoderSettings &settings, EncodeOutcome outcome) + : AudioEncoder(settings), outcome_(outcome) {} - /// Creates the file straight away, as the platform encoders do. + /// Creates the file on open like the platform encoders, so a concat that fails after this + /// point has a partial output to clean up. OpenEncoderResult open( const StreamFormat & /*inputFormat*/, const EncoderOutputSpec & /*outputSpec*/, @@ -78,7 +81,7 @@ class FakeWavEncoder final : public AudioEncoder { } EncodeResult encode(const float *const *channels, int numFrames) override { - if (failEncode_) { + if (outcome_ == EncodeOutcome::REFUSES_FRAMES) { return EncodeResult::Err("encoder refused the frames"); } frames_.insert(frames_.end(), channels[0], channels[0] + numFrames); @@ -96,16 +99,18 @@ class FakeWavEncoder final : public AudioEncoder { } private: - bool failEncode_; + EncodeOutcome outcome_; std::vector frames_; }; CreateEncoderResult createFakeWavEncoder(const EncoderSettings &settings) { - return CreateEncoderResult::Ok(std::make_unique(settings, /*failEncode=*/false)); + return CreateEncoderResult::Ok( + std::make_unique(settings, EncodeOutcome::ACCEPTS_FRAMES)); } CreateEncoderResult createFailingWavEncoder(const EncoderSettings &settings) { - return CreateEncoderResult::Ok(std::make_unique(settings, /*failEncode=*/true)); + return CreateEncoderResult::Ok( + std::make_unique(settings, EncodeOutcome::REFUSES_FRAMES)); } std::vector readWavFile(const std::string &path) { @@ -351,10 +356,12 @@ TEST(AudioFileConcatenatorTest, DropsATrailingPartialAdtsFrame) { removeFile(inputB); removeFile(output); - // A recording killed mid-frame: the second frame is missing its last 10 bytes. - auto streamA = makeAdtsStream(4, 1, {100, 50}); - const auto completeBytes = streamA.size() - 50 - adts::HEADER_SIZE; - streamA.resize(streamA.size() - 10); + // A recording killed mid-frame. + constexpr size_t lastPayloadBytes = 50; + constexpr size_t bytesLostToTheKill = 10; + auto streamA = makeAdtsStream(4, 1, {100, lastPayloadBytes}); + const auto completeBytes = streamA.size() - lastPayloadBytes - adts::HEADER_SIZE; + streamA.resize(streamA.size() - bytesLostToTheKill); const auto streamB = makeAdtsStream(4, 1, {60}); writeBytes(inputA, streamA); writeBytes(inputB, streamB); @@ -414,4 +421,84 @@ TEST(AudioFileConcatenatorTest, RejectsAdtsOutputWithNonAdtsInputs) { removeFile(input); } +namespace { + +std::vector tempFilesNamedLike(const std::string &prefix) { + std::vector names; + for (const auto &entry : std::filesystem::directory_iterator(::testing::TempDir())) { + const std::string name = entry.path().filename().string(); + if (name.rfind(prefix, 0) == 0) { + names.push_back(name); + } + } + return names; +} + +} // namespace + +TEST(AudioFileConcatenatorTest, ConcatenatesInPlaceWhenTheOutputIsAnInput) { + const std::string recording = testFilePath("audio-concat-inplace.aac"); + const std::string segment = testFilePath("audio-concat-inplace-segment.aac"); + removeFile(recording); + removeFile(segment); + + const auto streamA = makeAdtsStream(4, 2, {100, 250}); + const auto streamB = makeAdtsStream(4, 2, {80}); + writeBytes(recording, streamA); + writeBytes(segment, streamB); + + auto result = concatAudioFiles({recording, segment}, recording); + + if (result.is_err()) { + FAIL() << result.unwrap_err(); + } + std::vector expected = streamA; + expected.insert(expected.end(), streamB.begin(), streamB.end()); + EXPECT_EQ(readBytes(recording), expected); + EXPECT_EQ(tempFilesNamedLike("audio-concat-inplace").size(), 2U); + + removeFile(recording); + removeFile(segment); +} + +TEST(AudioFileConcatenatorTest, KeepsAnExistingOutputWhenConcatenationFails) { + const std::string input = testFilePath("audio-concat-keep-input.wav"); + const std::string output = testFilePath("audio-concat-keep-output.wav"); + removeFile(input); + removeFile(output); + + writeWavFile(input, {0.1F, 0.2F}); + const std::vector previous = {'o', 'l', 'd'}; + writeBytes(output, previous); + + auto result = concatAudioFiles({input}, output, createFailingWavEncoder); + + EXPECT_TRUE(result.is_err()); + EXPECT_EQ(readBytes(output), previous); + EXPECT_EQ(tempFilesNamedLike("audio-concat-keep-output").size(), 1U); + + removeFile(input); + removeFile(output); +} + +TEST(AudioFileConcatenatorTest, LeavesNoStagingFileAfterAFailedAdtsConcat) { + const std::string inputA = testFilePath("audio-concat-nostage-a.aac"); + const std::string inputB = testFilePath("audio-concat-nostage-b.aac"); + const std::string output = testFilePath("audio-concat-nostage-output.aac"); + removeFile(inputA); + removeFile(inputB); + removeFile(output); + + writeBytes(inputA, makeAdtsStream(4, 2, {100})); + writeBytes(inputB, makeAdtsStream(4, 1, {100})); + + auto result = concatAudioFiles({inputA, inputB}, output); + + EXPECT_TRUE(result.is_err()); + EXPECT_TRUE(tempFilesNamedLike("audio-concat-nostage-output").empty()); + + removeFile(inputA); + removeFile(inputB); +} + // NOLINTEND From 6701fc29d75347a4786deb32199e60a6bf369ba7 Mon Sep 17 00:00:00 2001 From: Sean Holbert Date: Tue, 13 Oct 2026 00:40:55 -0700 Subject: [PATCH 3/5] fix(android): handle ADTS file write and close failures (#1342) --- .../docs/utils/file-concatenation.mdx | 10 +++++++-- .../cpp/audioapi/android/AndroidEncoder.cpp | 21 ++++++++++--------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/audiodocs/docs/utils/file-concatenation.mdx b/packages/audiodocs/docs/utils/file-concatenation.mdx index 36c03a7bf..41b93f802 100644 --- a/packages/audiodocs/docs/utils/file-concatenation.mdx +++ b/packages/audiodocs/docs/utils/file-concatenation.mdx @@ -21,12 +21,17 @@ recording is finished. ::: :::caution -**Only WAV, M4A, and FLAC concatenation is supported** — the recording formats +**Only WAV, M4A, FLAC, and ADTS concatenation is supported** — the recording formats available on both iOS and Android. WAV and FLAC decode the segments and write them out again through the system encoder — WAV as 32-bit float PCM, FLAC losslessly. M4A remuxes AAC packets with system APIs — iOS AVFoundation and Android MediaExtractor/MediaMuxer. + +ADTS (`.aac`) appends complete encoded frames without decoding or re-encoding. +Inputs must use the same sample rate and channel configuration. An incomplete +trailing frame, such as one left by an app crash, is discarded before the next +file's frames are appended. ::: :::tip Segment size and quality @@ -68,7 +73,8 @@ come from the same recorder configuration: Use an output path with an extension that matches the input files, for example joining `.m4a` segments into an `.m4a` output file, `.wav` segments into a -`.wav` output file and so on. +`.wav` output file, `.flac` segments into a `.flac` output file, or ADTS `.aac` +segments into an `.aac` output file.
Example usage with rotated recordings diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AndroidEncoder.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AndroidEncoder.cpp index 1f7abe592..9b9176ffc 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AndroidEncoder.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AndroidEncoder.cpp @@ -686,7 +686,7 @@ class AdtsBackend : public MediaCodecBackend { } samplingFrequencyIndex_ = *adts::samplingFrequencyIndex(static_cast(desired.sampleRate)); - file_ = std::fopen(filePath.c_str(), "wb"); + file_.reset(std::fopen(filePath.c_str(), "wb")); if (file_ == nullptr) { return "AdtsBackend: failed to open file for writing"; } @@ -698,8 +698,7 @@ class AdtsBackend : public MediaCodecBackend { static_cast(encoding.bitRate), true); if (!err.empty()) { - std::fclose(file_); - file_ = nullptr; + file_.reset(); return err; } return ""; @@ -731,28 +730,30 @@ class AdtsBackend : public MediaCodecBackend { return "AdtsBackend: encoded frame too long for an ADTS header"; } - if (std::fwrite(header->data(), 1, header->size(), file_) != header->size() || - std::fwrite(data + info.offset, 1, payloadSize, file_) != payloadSize) { + if (std::fwrite(header->data(), 1, header->size(), file_.get()) != header->size() || + std::fwrite(data + info.offset, 1, payloadSize, file_.get()) != payloadSize) { return "AdtsBackend: short write"; } // Flushed per frame, so the crash-recoverability the format exists for holds at frame // granularity rather than at stdio's buffer size. - std::fflush(file_); + if (std::fflush(file_.get()) != 0) { + return "AdtsBackend: failed to flush file"; + } bytesWritten_ += header->size() + payloadSize; return ""; } std::string finalize() override { codec_.reset(); - if (file_ != nullptr) { - std::fclose(file_); - file_ = nullptr; + if (file_ != nullptr && std::fclose(file_.release()) != 0) { + return "AdtsBackend: failed to close file"; } return ""; } private: - std::FILE *file_{nullptr}; + // Also closes the file when codec draining fails before finalize() is reached. + std::unique_ptr file_{nullptr, &std::fclose}; uint8_t samplingFrequencyIndex_{0}; size_t bytesWritten_{0}; }; From 59ea817b7c0b09811af787cecfc085b785fbbdf6 Mon Sep 17 00:00:00 2001 From: michal Date: Fri, 23 Oct 2026 10:36:41 +0200 Subject: [PATCH 4/5] docs: extensions to uppercase --- packages/audiodocs/docs/inputs/audio-recorder.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index 984070032..ca31a67d0 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -1029,7 +1029,7 @@ The table below lists which formats each platform can encode with its system API | `VorbisWebm` | WebM / Vorbis | ❌ | ✅ | :::tip -Both `adts` and `flac` formats stays decodable after an app crash, but only former is lossy. +Both `Adts` and `Flac` files stay decodable after an app crash, but only the former is lossy. `Adts` accepts only the sample rates an ADTS header can express: 7350, 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000, 64000, 88200 and 96000 Hz. ::: From a1dc9567ec236848f4564f538b89b6334a4ad8bf Mon Sep 17 00:00:00 2001 From: michal Date: Fri, 23 Oct 2026 15:07:26 +0200 Subject: [PATCH 5/5] feat: remove the weird frequency --- .../audiodocs/docs/inputs/audio-recorder.mdx | 3 ++- .../common/cpp/audioapi/encoding/AdtsHeader.cpp | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index ca31a67d0..3f24defe8 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -1030,7 +1030,8 @@ The table below lists which formats each platform can encode with its system API :::tip Both `Adts` and `Flac` files stay decodable after an app crash, but only the former is lossy. -`Adts` accepts only the sample rates an ADTS header can express: 7350, 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000, 64000, 88200 and 96000 Hz. +`Adts` accepts only the sample rates an ADTS header can express: 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000, 64000, 88200 and 96000 Hz. +Those numbers come straight from MPEG-2 specification. ::: :::caution Platform support diff --git a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp index 2c731f349..9c0d38688 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/encoding/AdtsHeader.cpp @@ -8,8 +8,20 @@ namespace audioapi::adts { namespace { /// Indexed by the sampling frequency index of the AudioSpecificConfig. -constexpr std::array SAMPLING_FREQUENCIES = - {96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350}; +constexpr std::array SAMPLING_FREQUENCIES = { + 96000, + 88200, + 64000, + 48000, + 44100, + 32000, + 24000, + 22050, + 16000, + 12000, + 11025, + 8000, +}; constexpr uint8_t SYNCWORD_HIGH = 0xFF; /// The low 4 sync bits, then ID 0 (MPEG-4), layer 0 and protection_absent 1 (no CRC).