diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index 79db070cf..74a47ec25 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -2550,7 +2550,7 @@ SPEC CHECKSUMS: ReactCodegen: d07ee3c8db75b43d1cbe479ae6affebf9925c733 ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360 ReactNativeDependencies: 4d5ce2683b6d74f7c686bf90a88c7d381295cf3c - RNAudioAPI: 764858df27270ed9a55803bb4c9c0ccb5bb14e9a + RNAudioAPI: 6668f71bdd9850005984acf39a3daef4935cec02 RNGestureHandler: 187c5c7936abf427bc4d22d6c3b1ac80ad1f63c0 RNReanimated: 64f4b3b33b48b19e0ba76a352571b52b1e931981 RNScreens: 01b065ded2dfe7987bcce770ff3a196be417ff41 diff --git a/packages/audiodocs/docs/experimental/audio-tag.mdx b/packages/audiodocs/docs/experimental/audio-tag.mdx index 60d97ac46..927dd12c0 100644 --- a/packages/audiodocs/docs/experimental/audio-tag.mdx +++ b/packages/audiodocs/docs/experimental/audio-tag.mdx @@ -54,7 +54,7 @@ Only **required** field is a `source`. Callbacks default to no-ops if omitted. | `loop` | `boolean` | `false` | Loop playback. | | `muted` | `boolean` | `false` | Muted state. | | `volume` | `number` | `1` | Linear volume passed to the underlying source. | -| `preload` | `PreloadType` | `'auto'` | *Web support only* | +| `preload` | `PreloadType` | `'auto'` | Loading strategy for the source (`'none'`, `'metadata'`, `'auto'`). | | `playbackRate` | `number` | `1` | *Web support only* | | `preservesPitch` | `boolean` | `true` | *Web support only* | | `onLoadStart` | `() => void` | no-op | Load started. | @@ -72,6 +72,24 @@ Only **required** field is a `source`. Callbacks default to no-ops if omitted. - `number` — Result of `require('./file.mp3')` (bundled asset). - `AudioURISource` — `{ uri?: string; headers?: Record }` for fetch with custom headers. +### `preload` + +- `none` - Do not load on mount. +- `metadata` - Load enough to probe duration. +- `auto` (and empty string) - Load source immediately on mount (default behavior). + +#### `metadata` format support (native) + +Metadata preload without a full download is only supported for remote sources whose path ends with one of these extensions: + +| Extension | Codec / container | +| :--- | :--- | +| `.opus` | Opus | +| `.mp4` | MP4 | +| `.m4a` | M4A | +| `.wav` | WAV | +| `.flac` | FLAC | + ## Ref handle (`AudioTagHandle`) methods ### `play` @@ -123,7 +141,6 @@ type AudioComponentContextType = { preload: PreloadType; playbackRate: number; preservesPitch: boolean; - audioContext: BaseAudioContext | null; }; ``` diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp index 14d999109..23b38c504 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp @@ -23,7 +23,7 @@ #include #include #include -#include +#include #include #include diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioDecoderHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioDecoderHostObject.cpp index cf9deaca7..2ab437b2b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioDecoderHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioDecoderHostObject.cpp @@ -1,6 +1,6 @@ #include #include -#include +#include #include #include @@ -28,7 +28,7 @@ JSI_HOST_FUNCTION_IMPL(AudioDecoderHostObject, decodeWithMemoryBlock) { auto sampleRate = static_cast(args[1].getNumber()); auto promise = promiseVendor_->createAsyncPromise([data, size, sampleRate]() -> PromiseResolver { - auto result = audiodecoder::decodeWithMemoryBlock(data, size, sampleRate); + auto result = audiodecoding::decodeWithMemoryBlock(data, size, sampleRate); if (result.is_err()) { return [result = std::move(result)]( @@ -54,7 +54,7 @@ JSI_HOST_FUNCTION_IMPL(AudioDecoderHostObject, decodeWithFilePath) { auto sampleRate = static_cast(args[1].getNumber()); auto promise = promiseVendor_->createAsyncPromise([sourcePath, sampleRate]() -> PromiseResolver { - auto result = audiodecoder::decodeWithFilePath(sourcePath, sampleRate); + auto result = audiodecoding::decodeWithFilePath(sourcePath, sampleRate); if (result.is_err()) { return [result = std::move(result)]( @@ -84,7 +84,7 @@ JSI_HOST_FUNCTION_IMPL(AudioDecoderHostObject, decodeWithPCMInBase64) { auto promise = promiseVendor_->createAsyncPromise( [b64, inputSampleRate, inputChannelCount, interleaved]() -> PromiseResolver { - auto result = audiodecoder::decodeWithPCMInBase64( + auto result = audiodecoding::decodeWithPCMInBase64( b64, inputSampleRate, inputChannelCount, interleaved); if (result.is_err()) { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioDecoderHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioDecoderHostObject.h index 35c4a5bc5..61457dc59 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioDecoderHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioDecoderHostObject.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioFileUtilsHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioFileUtilsHostObject.cpp index f6dbdf0b9..c9382f19c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioFileUtilsHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioFileUtilsHostObject.cpp @@ -1,20 +1,48 @@ #include #include +#if !RN_AUDIO_API_FFMPEG_DISABLED +#include +#endif // RN_AUDIO_API_FFMPEG_DISABLED +#include #include +#include #include +#include #include +#include #include #include #include namespace audioapi { +namespace { + +std::optional probeDurationWithDecoder(const uint8_t *data, size_t size, int sampleRate) { + auto duration = std::optional(); + duration = + audiodecoding::probeDuration(data, size, sampleRate); + if (duration.has_value()) { + return duration; + } +#if !RN_AUDIO_API_FFMPEG_DISABLED + duration = audiodecoding::probeDuration(data, size, sampleRate); + return duration; +#else + return std::nullopt; +#endif // RN_AUDIO_API_FFMPEG_DISABLED +} + +} // namespace + AudioFileUtilsHostObject::AudioFileUtilsHostObject( jsi::Runtime *runtime, const std::shared_ptr &callInvoker) { promiseVendor_ = std::make_shared(runtime, callInvoker); - addFunctions(JSI_EXPORT_FUNCTION(AudioFileUtilsHostObject, concatAudioFiles)); + addFunctions( + JSI_EXPORT_FUNCTION(AudioFileUtilsHostObject, concatAudioFiles), + JSI_EXPORT_FUNCTION(AudioFileUtilsHostObject, probeDuration)); } JSI_HOST_FUNCTION_IMPL(AudioFileUtilsHostObject, concatAudioFiles) { @@ -57,4 +85,39 @@ JSI_HOST_FUNCTION_IMPL(AudioFileUtilsHostObject, concatAudioFiles) { return promise; } +JSI_HOST_FUNCTION_IMPL(AudioFileUtilsHostObject, probeDuration) { + if (count < 1 || !args[0].isObject()) { + throw jsi::JSError(runtime, "probeDuration expects an ArrayBuffer."); + } + + auto arrayBufferObject = args[0].asObject(runtime); + if (!arrayBufferObject.isArrayBuffer(runtime)) { + throw jsi::JSError(runtime, "probeDuration expects an ArrayBuffer."); + } + + auto arrayBuffer = arrayBufferObject.getArrayBuffer(runtime); + const auto *data = arrayBuffer.data(runtime); + const auto size = arrayBuffer.size(runtime); + + const auto sampleRate = + count > 1 && args[1].isNumber() ? static_cast(args[1].getNumber()) : 0; + + auto promise = promiseVendor_->createAsyncPromise( + [bytes = std::vector(data, data + size), sampleRate]() -> PromiseResolver { + auto duration = probeDurationWithDecoder(bytes.data(), bytes.size(), sampleRate); + if (!duration.has_value()) { + return [](jsi::Runtime &runtime) -> std::variant { + return jsi::Value::null(); + }; + } + + return [duration = duration.value()]( + jsi::Runtime &runtime) -> std::variant { + return jsi::Value(duration); + }; + }); + + return promise; +} + } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioFileUtilsHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioFileUtilsHostObject.h index 415c7c5d0..ffbb3b211 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioFileUtilsHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/AudioFileUtilsHostObject.h @@ -16,6 +16,7 @@ class AudioFileUtilsHostObject : public JsiHostObject { const std::shared_ptr &callInvoker); JSI_HOST_FUNCTION_DECL(concatAudioFiles); + JSI_HOST_FUNCTION_DECL(probeDuration); private: std::shared_ptr promiseVendor_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h index 4e0212d5e..abdf389b7 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h @@ -8,7 +8,7 @@ #include #include -#include +#include #include namespace audioapi::option_parser { @@ -309,14 +309,14 @@ inline AudioFileSourceOptions parseAudioFileSourceOptions( if (sourceValue.isString()) { options.filePath = sourceValue.asString(runtime).utf8(runtime); options.requiresFFmpeg = - audiodecoder::pathHasExtension(options.filePath, {".mp4", ".m4a", ".aac"}); + audiodecoding::pathHasExtension(options.filePath, {".mp4", ".m4a", ".aac"}); } else if (sourceValue.isObject()) { auto sourceObj = sourceValue.asObject(runtime); if (sourceObj.isArrayBuffer(runtime)) { auto arrayBuffer = sourceObj.getArrayBuffer(runtime); auto *data = arrayBuffer.data(runtime); auto size = arrayBuffer.size(runtime); - auto format = audiodecoder::detectAudioFormat(data, size); + auto format = audiodecoding::detectAudioFormat(data, size); options.requiresFFmpeg = format == AudioFormat::MP4 || format == AudioFormat::M4A || format == AudioFormat::AAC; options.data = std::vector(data, data + size); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp index 41e95a136..4662637cd 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp @@ -21,7 +21,7 @@ #include #endif // RN_AUDIO_API_FFMPEG_DISABLED #include -#include +#include #include #include #include diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp index 6ae7b44e5..b93669c24 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp @@ -86,7 +86,7 @@ void AudioFileSourceNode::initDecoders( decoding::DecoderResult openResult = Ok(None); if (requiresFFmpeg_) { #if !RN_AUDIO_API_FFMPEG_DISABLED - decoder_ = std::make_unique(); + decoder_ = std::make_unique(); #endif // RN_AUDIO_API_FFMPEG_DISABLED } else { decoder_ = std::make_unique(); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoder.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.cpp similarity index 90% rename from packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoder.cpp rename to packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.cpp index c26475f96..6cedfebf1 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoder.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include @@ -16,7 +16,7 @@ #include #include -namespace audioapi::audiodecoder { +namespace audioapi::audiodecoding { // Drains an incremental decoder into an AudioBuffer. Total frame count is not // known up front for some formats (e.g. Vorbis), so we read in fixed-size @@ -113,7 +113,7 @@ AudioBufferResult decodeWithFilePath(const std::string &path, float sampleRate) if (needsFFmpegByPath(path)) { #if !RN_AUDIO_API_FFMPEG_DISABLED - ffmpegdecoder::FFmpegDecoder decoder; + ffmpeg_decoder::FFmpegDecoder decoder; const auto openResult = decoder.openFile(sr, path); if (openResult.is_err()) { return Err("Failed to open file with FFmpeg decoder: " + openResult.unwrap_err()); @@ -142,7 +142,7 @@ AudioBufferResult decodeWithMemoryBlock(const void *data, size_t size, float sam if (needsFFmpeg(format)) { #if !RN_AUDIO_API_FFMPEG_DISABLED - ffmpegdecoder::FFmpegDecoder decoder; + ffmpeg_decoder::FFmpegDecoder decoder; const auto openResult = decoder.openMemory(sr, data, size); if (openResult.is_err()) { return Err("Failed to open memory block with FFmpeg decoder: " + openResult.unwrap_err()); @@ -197,4 +197,17 @@ AudioBufferResult decodeWithPCMInBase64( return Ok(std::move(audioBuffer)); } -} // namespace audioapi::audiodecoder +template +concept Decoder = std::is_base_of_v; + +template +std::optional probeDuration(const void *data, size_t size, int outputSampleRate) { + D decoder; + const auto openResult = decoder.openMemory(outputSampleRate, data, size); + if (openResult.is_err()) { + return std::nullopt; + } + return static_cast(decoder.getDurationInSeconds()); +} + +} // namespace audioapi::audiodecoding diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoder.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.h similarity index 85% rename from packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoder.h rename to packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.h index 7a916fa00..22a58e822 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoder.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.h @@ -5,10 +5,11 @@ #include #include #include +#include #include #include -namespace audioapi::audiodecoder { +namespace audioapi::audiodecoding { using AudioBufferResult = Result, std::string>; @@ -39,4 +40,8 @@ decodeWithMemoryBlock(const void *data, size_t size, float sampleRate); return static_cast(static_cast((byte2 << CHAR_BIT) | byte1)) / INT16_MAX; } -} // namespace audioapi::audiodecoder +template +[[nodiscard]] std::optional +probeDuration(const void *data, size_t size, int outputSampleRate); + +} // namespace audioapi::audiodecoding diff --git a/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.cpp b/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.cpp index 57c366cf1..c9d0e0951 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.cpp @@ -24,7 +24,7 @@ extern "C" { #include } -namespace audioapi::ffmpegdecoder { +namespace audioapi::ffmpeg_decoder { namespace { @@ -555,11 +555,11 @@ std::shared_ptr decodeWithMemoryBlock(const void *data, size_t size return buildAudioBufferFromInterleaved(acc, dec.outputChannels(), dec.outputSampleRate()); } -} // namespace audioapi::ffmpegdecoder +} // namespace audioapi::ffmpeg_decoder #else -namespace audioapi::ffmpegdecoder { +namespace audioapi::ffmpeg_decoder { FFmpegDecoder::~FFmpegDecoder() = default; void FFmpegDecoder::close() {} decoding::DecoderResult FFmpegDecoder::openFile(int, const std::string &) { @@ -580,6 +580,9 @@ decoding::DecoderResult FFmpegDecoder::seekToTime(double) { size_t FFmpegDecoder::readPcmFrames(float *, size_t) { return 0; } +std::optional FFmpegDecoder::probeDuration(const void *, size_t, int) { + return std::nullopt; +} std::shared_ptr decodeWithFilePath(const std::string &, int) { return nullptr; } @@ -587,6 +590,6 @@ std::shared_ptr decodeWithMemoryBlock(const void *, size_t, int) { return nullptr; } -} // namespace audioapi::ffmpegdecoder +} // namespace audioapi::ffmpeg_decoder #endif // !RN_AUDIO_API_FFMPEG_DISABLED diff --git a/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.h b/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.h index 4863c9b1c..4afbc4a83 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.h @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -24,7 +25,7 @@ extern "C" { #include } -namespace audioapi::ffmpegdecoder { +namespace audioapi::ffmpeg_decoder { /// Opaque IO state for openMemory (must outlive decode until close). struct MemoryIOContext { @@ -42,11 +43,8 @@ struct MemoryIOContext { class FFmpegDecoder : public decoding::IncrementalAudioDecoder { public: FFmpegDecoder() = default; - FFmpegDecoder(const FFmpegDecoder &) = delete; - FFmpegDecoder &operator=(const FFmpegDecoder &) = delete; - FFmpegDecoder(FFmpegDecoder &&other) = delete; - FFmpegDecoder &operator=(FFmpegDecoder &&other) = delete; ~FFmpegDecoder() override; + DELETE_COPY_AND_MOVE(FFmpegDecoder); [[nodiscard]] decoding::DecoderResult openFile( int outputSampleRate, @@ -101,4 +99,4 @@ class FFmpegDecoder : public decoding::IncrementalAudioDecoder { std::shared_ptr decodeWithMemoryBlock(const void *data, size_t size, int sample_rate); std::shared_ptr decodeWithFilePath(const std::string &path, int sample_rate); -} // namespace audioapi::ffmpegdecoder +} // namespace audioapi::ffmpeg_decoder diff --git a/packages/react-native-audio-api/common/cpp/audioapi/libs/miniaudio/MiniAudioDecoding.cpp b/packages/react-native-audio-api/common/cpp/audioapi/libs/miniaudio/MiniAudioDecoding.cpp index 82c595d03..1babec16e 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/libs/miniaudio/MiniAudioDecoding.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/libs/miniaudio/MiniAudioDecoding.cpp @@ -45,8 +45,6 @@ ma_decoder_config makeDecoderConfig(const int outputSampleRate) { } // namespace -MiniAudioDecoder::MiniAudioDecoder() = default; - MiniAudioDecoder::~MiniAudioDecoder() { close(); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/libs/miniaudio/MiniAudioDecoding.h b/packages/react-native-audio-api/common/cpp/audioapi/libs/miniaudio/MiniAudioDecoding.h index 22b0183a0..4c5e8ba24 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/libs/miniaudio/MiniAudioDecoding.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/libs/miniaudio/MiniAudioDecoding.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -15,11 +16,11 @@ namespace audioapi::miniaudio_decoder { /** * MiniAudio-backed incremental decoder (Vorbis/Opus/WAV, etc. via ma_decoder + custom backends). - * Same usage contract as ffmpegdecoder::FFmpegDecoder. + * Same usage contract as ffmpeg_decoder::FFmpegDecoder. */ class MiniAudioDecoder : public decoding::IncrementalAudioDecoder { public: - MiniAudioDecoder(); + MiniAudioDecoder() = default; ~MiniAudioDecoder() override; DELETE_COPY_AND_MOVE(MiniAudioDecoder); diff --git a/packages/react-native-audio-api/src/core/AudioFileUtils.ts b/packages/react-native-audio-api/src/core/AudioFileUtils.ts index dfb403f81..0470e4509 100644 --- a/packages/react-native-audio-api/src/core/AudioFileUtils.ts +++ b/packages/react-native-audio-api/src/core/AudioFileUtils.ts @@ -1,5 +1,6 @@ import { AudioApiError } from '../errors'; import { IAudioFileUtils } from '../interfaces'; +import { prefetchFileSegments } from '../utils/metadataPrefetching'; class AudioFileUtils { private static instance: AudioFileUtils | null = null; @@ -36,6 +37,13 @@ class AudioFileUtils { return this.fileUtils.concatAudioFiles(inputPaths, outputPath); } + + public async probeDurationInstance( + data: ArrayBuffer, + sampleRate?: number + ): Promise { + return this.fileUtils.probeDuration(data, sampleRate); + } } export async function concatAudioFiles( @@ -47,3 +55,22 @@ export async function concatAudioFiles( outputPath ); } + +export async function probeDuration( + url: string, + startBytes: number, + endBytes: number, + sampleRate?: number, + headers?: { [key: string]: string } +): Promise { + const prefetchedData = await prefetchFileSegments({ + url, + startBytes, + endBytes, + headers, + }); + return AudioFileUtils.getInstance().probeDurationInstance( + prefetchedData, + sampleRate + ); +} diff --git a/packages/react-native-audio-api/src/development/react/Audio/Audio.tsx b/packages/react-native-audio-api/src/development/react/Audio/Audio.tsx index f3a9a6cb7..b376e9b5b 100644 --- a/packages/react-native-audio-api/src/development/react/Audio/Audio.tsx +++ b/packages/react-native-audio-api/src/development/react/Audio/Audio.tsx @@ -6,21 +6,19 @@ import React, { useRef, useState, } from 'react'; -import { View, Image, Platform } from 'react-native'; +import { View } from 'react-native'; import type { AudioTagHandle, AudioProps, AudioTagPlaybackState, + PreloadType, } from './types'; import { AudioComponentContext } from './AudioTagContext'; -import { AudioFileSourceNode } from './AudioFileSourceNode'; -import { useStableAudioProps } from './utils'; -import { NotSupportedError } from '../../../errors'; -import { NativeAudioAPIModule } from '../../../specs'; +import { useStableAudioProps, resolveSourcePath } from './utils'; import { AudioControls } from '..'; -import { base64ToArrayBuffer } from '../../../utils'; +import { useAudioSourceLoader } from './useAudioSourceLoader'; const Audio = React.forwardRef((props, ref) => { const { children } = props; @@ -44,35 +42,18 @@ const Audio = React.forwardRef((props, ref) => { onPause, onVolumeChange, } = useStableAudioProps(props); - const audioContext = context ?? null; const [volumeState, setVolumeState] = useState(null); const [mutedState, setMutedState] = useState(null); const [ready, setReady] = useState(false); - - const path = useMemo(() => { - if (!source) { - return ''; - } - if (typeof source === 'string') { - return source; - } - // number - if (typeof source === 'number') { - return Image.resolveAssetSource(source).uri; - } - // AudioURISource - return source.uri ?? ''; - }, [source]); - - const fileSourceRef = useRef(null); - const sourceRef = useRef(null); - - const lastEffectiveVolumeRef = useRef(muted ? 0 : volume); - const [playbackState, setPlaybackState] = useState('idle'); const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); + + const path = useMemo(() => resolveSourcePath(source), [source]); + + const preloadMode: PreloadType = + preload === 'none' || preload === 'metadata' ? preload : 'auto'; + const lastEffectiveVolumeRef = useRef(muted ? 0 : volume); const effectiveMutedState = useMemo(() => { return mutedState ?? muted; @@ -85,21 +66,73 @@ const Audio = React.forwardRef((props, ref) => { const effectiveVolumeRef = useRef(effectiveVolumeState); effectiveVolumeRef.current = effectiveVolumeState; + const playRef = useRef<() => void>(() => {}); + const handleAutoPlay = useCallback(() => { + playRef.current(); + }, []); + const handleEnded = useCallback( + (endedDuration: number) => { + setPlaybackState('idle'); + setCurrentTime(endedDuration); + onEndedCallback(); + }, + [onEndedCallback] + ); + + const { + fileSourceRef, + ready: loaderReady, + duration, + loadForPlayback, + } = useAudioSourceLoader({ + context, + path, + source, + preloadMode, + loop, + autoPlay, + effectiveVolumeRef, + onLoadStart, + onLoad, + onError, + onEnded: handleEnded, + onAutoPlay: handleAutoPlay, + }); + + useEffect(() => { + setReady(loaderReady); + }, [loaderReady]); + useEffect(() => { fileSourceRef.current?.setVolume(effectiveVolumeState); - }, [effectiveVolumeState]); + }, [effectiveVolumeState, fileSourceRef]); - const play = useCallback(() => { - fileSourceRef.current?.play(); + const play = useCallback(async () => { + if ((preloadMode === 'none' || preloadMode === 'metadata') && !ready) { + const loaded = await loadForPlayback(); + if (!loaded) { + return; + } + } + + if (!fileSourceRef.current) { + return; + } + + fileSourceRef.current.play(); setPlaybackState('playing'); onPlay(); - }, [onPlay]); + }, [fileSourceRef, loadForPlayback, onPlay, preloadMode, ready]); + + playRef.current = () => { + play(); + }; const pause = useCallback(() => { fileSourceRef.current?.pause(); setPlaybackState('paused'); onPause(); - }, [onPause]); + }, [onPause, fileSourceRef]); const seekToTime = useCallback( (seconds: number) => { @@ -111,116 +144,15 @@ const Audio = React.forwardRef((props, ref) => { setCurrentTime(nextTime); onPositionChange(nextTime); }, - [duration, setCurrentTime, onPositionChange] + [duration, setCurrentTime, onPositionChange, fileSourceRef] ); - const spawnFileSource = useCallback(() => { - const nextSource = sourceRef.current; - if (!context || !nextSource) { - return; - } - - fileSourceRef.current?.dispose(); - setCurrentTime(0); - setDuration(0); - setPlaybackState('idle'); - - const initialVolume = effectiveVolumeRef.current; - - const node = context.context.createFileSource({ - source: nextSource, - loop, - volume: initialVolume, - }); - if (!node) { - onError(new NotSupportedError('This file format requires FFmpeg build')); - return; - } - - const fileSource = new AudioFileSourceNode(context, node); - const { duration: nextDuration } = fileSource.attach({ - loop, - onEnded: () => { - setPlaybackState('idle'); - setCurrentTime(nextDuration); - onEndedCallback(); - spawnFileSource(); - }, - }); - - fileSource.setVolume(initialVolume); - fileSourceRef.current = fileSource; - setDuration(nextDuration); - onLoad(); - - if (autoPlay) { - fileSource.play(); - setPlaybackState('playing'); - onPlay(); - } - }, [context, loop, onError, onEndedCallback, onLoad, onPlay, autoPlay]); - useEffect(() => { if (!path) { - fileSourceRef.current?.dispose(); - sourceRef.current = null; setPlaybackState('idle'); setCurrentTime(0); - setDuration(0); - return; } - - let isCancelled = false; - - const run = async () => { - setReady(false); - onLoadStart(); - try { - if (path.startsWith('http')) { - const arrayBuffer = await fetch(path, { - headers: - typeof source === 'object' && source && 'headers' in source - ? source.headers - : undefined, - }).then((response) => response.arrayBuffer()); - sourceRef.current = arrayBuffer; - } else if ( - Platform.OS === 'android' && - !__DEV__ && - !path.startsWith('file://') - ) { - const base64Payload = - await NativeAudioAPIModule.readAndroidReleaseAssetBytesAsBase64( - path - ); - const arrayBuffer = base64ToArrayBuffer(base64Payload); - sourceRef.current = arrayBuffer; - } else if (path.startsWith('file://')) { - sourceRef.current = path.replace('file://', ''); - } else { - sourceRef.current = path; - } - - if (!isCancelled) { - spawnFileSource(); - setReady(true); - } - } catch (error) { - if (!isCancelled) { - onError(error as Error); - } - setReady(false); - } - }; - - run(); - - return () => { - isCancelled = true; - fileSourceRef.current?.stopPositionTracking(); - fileSourceRef.current?.dispose(); - }; - }, [path, source, spawnFileSource, onError, onLoadStart]); + }, [path]); useEffect(() => { if (lastEffectiveVolumeRef.current !== effectiveVolumeState) { @@ -231,22 +163,23 @@ const Audio = React.forwardRef((props, ref) => { useEffect(() => { fileSourceRef.current?.setLoop(loop); - }, [loop]); + }, [loop, fileSourceRef]); useEffect(() => { if (playbackState !== 'playing') { return; } - fileSourceRef.current?.startPositionTracking((seconds) => { + const fileSource = fileSourceRef.current; + fileSource?.startPositionTracking((seconds) => { setCurrentTime(seconds); onPositionChange(seconds); }); return () => { - fileSourceRef.current?.stopPositionTracking(); + fileSource?.stopPositionTracking(); }; - }, [onPositionChange, playbackState]); + }, [onPositionChange, playbackState, fileSourceRef]); useImperativeHandle( ref, @@ -281,7 +214,6 @@ const Audio = React.forwardRef((props, ref) => { preservesPitch, sourcePath: path, source, - audioContext, }), [ play, @@ -303,7 +235,6 @@ const Audio = React.forwardRef((props, ref) => { preservesPitch, path, source, - audioContext, ] ); diff --git a/packages/react-native-audio-api/src/development/react/Audio/AudioTagContext.ts b/packages/react-native-audio-api/src/development/react/Audio/AudioTagContext.ts index b5860cfdd..c10396f9c 100644 --- a/packages/react-native-audio-api/src/development/react/Audio/AudioTagContext.ts +++ b/packages/react-native-audio-api/src/development/react/Audio/AudioTagContext.ts @@ -1,5 +1,4 @@ import { createContext, useContext } from 'react'; -import type BaseAudioContext from '../../../core/BaseAudioContext'; import type { AudioTagPlaybackState, PreloadType } from './types'; export type AudioComponentContextType = { @@ -20,7 +19,6 @@ export type AudioComponentContextType = { preload: PreloadType; playbackRate: number; preservesPitch: boolean; - audioContext: BaseAudioContext | null; }; export const AudioComponentContext = createContext< diff --git a/packages/react-native-audio-api/src/development/react/Audio/useAudioSourceLoader.ts b/packages/react-native-audio-api/src/development/react/Audio/useAudioSourceLoader.ts new file mode 100644 index 000000000..086ad67ef --- /dev/null +++ b/packages/react-native-audio-api/src/development/react/Audio/useAudioSourceLoader.ts @@ -0,0 +1,259 @@ +import { + Dispatch, + RefObject, + SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { Platform } from 'react-native'; + +import type BaseAudioContext from '../../../core/BaseAudioContext'; +import { probeDuration } from '../../../core/AudioFileUtils'; +import { NotSupportedError } from '../../../errors'; +import { NativeAudioAPIModule } from '../../../specs'; +import { base64ToArrayBuffer } from '../../../utils'; +import { + DEFAULT_METADATA_SEGMENT_BYTES, + supportsMetadataProbe, +} from '../../../utils/metadataPrefetching'; +import { AudioFileSourceNode } from './AudioFileSourceNode'; +import type { AudioSource, PreloadType } from './types'; +import { getSourceHeaders } from './utils'; + +type UseAudioSourceLoaderParams = { + context: BaseAudioContext | undefined; + path: string; + source: AudioSource; + preloadMode: PreloadType; + loop: boolean; + autoPlay: boolean; + effectiveVolumeRef: RefObject; + onLoadStart: () => void; + onLoad: () => void; + onError: (error: Error) => void; + onEnded: (duration: number) => void; + onAutoPlay: () => void; +}; + +type UseAudioSourceLoaderResult = { + fileSourceRef: RefObject; + ready: boolean; + duration: number; + setDuration: Dispatch>; + loadForPlayback: () => Promise; + disposeSource: () => void; +}; + +export function useAudioSourceLoader({ + context, + path, + source, + preloadMode, + loop, + autoPlay, + effectiveVolumeRef, + onLoadStart, + onLoad, + onError, + onEnded, + onAutoPlay, +}: UseAudioSourceLoaderParams): UseAudioSourceLoaderResult { + const [ready, setReady] = useState(false); + const [duration, setDuration] = useState(0); + + const fileSourceRef = useRef(null); + const sourceRef = useRef(null); + const isFetchingCancelled = useRef(false); + const fullDataFetched = useRef(false); + + const disposeSource = useCallback(() => { + fileSourceRef.current?.stopPositionTracking(); + fileSourceRef.current?.dispose(); + sourceRef.current = null; + fullDataFetched.current = false; + }, []); + + const spawnFileSource = useCallback((): boolean => { + const nextSource = sourceRef.current; + if (!context || !nextSource) { + return false; + } + + fileSourceRef.current?.dispose(); + const initialVolume = effectiveVolumeRef.current; + + const node = context.context.createFileSource({ + source: nextSource, + loop, + volume: initialVolume, + }); + if (!node) { + onError(new NotSupportedError('This file format requires FFmpeg build')); + return false; + } + + const fileSource = new AudioFileSourceNode(context, node); + const { duration: nextDuration } = fileSource.attach({ + loop, + onEnded: () => { + onEnded(nextDuration); + spawnFileSource(); + }, + }); + + fileSource.setVolume(initialVolume); + fileSourceRef.current = fileSource; + setDuration(nextDuration); + onLoad(); + + if (autoPlay) { + onAutoPlay(); + } + + return true; + }, [ + autoPlay, + context, + effectiveVolumeRef, + loop, + onAutoPlay, + onEnded, + onError, + onLoad, + ]); + + const loadPlaybackSource = useCallback(async (): Promise => { + if (!path) { + return false; + } + + isFetchingCancelled.current = false; + setReady(false); + onLoadStart(); + const headers = getSourceHeaders(source); + + try { + if (path.startsWith('http')) { + const arrayBuffer = await fetch(path, { headers }).then((response) => + response.arrayBuffer() + ); + sourceRef.current = arrayBuffer; + } else if ( + Platform.OS === 'android' && + !__DEV__ && + !path.startsWith('file://') + ) { + const base64Payload = + await NativeAudioAPIModule.readAndroidReleaseAssetBytesAsBase64(path); + sourceRef.current = base64ToArrayBuffer(base64Payload); + } else if (path.startsWith('file://')) { + sourceRef.current = path.replace('file://', ''); + } else { + sourceRef.current = path; + } + + fullDataFetched.current = true; + + if (!isFetchingCancelled.current && spawnFileSource()) { + setReady(true); + return true; + } + + return false; + } catch (error) { + if (!isFetchingCancelled.current) { + onError(error as Error); + } + setReady(false); + return false; + } + }, [onError, onLoadStart, path, source, spawnFileSource]); + + const probeMetadataOnly = useCallback(async (): Promise => { + if (!path.startsWith('http') || !supportsMetadataProbe(path)) { + setReady(true); + return; + } + + isFetchingCancelled.current = false; + setReady(false); + onLoadStart(); + + try { + const probedDuration = await probeDuration( + path, + DEFAULT_METADATA_SEGMENT_BYTES, + DEFAULT_METADATA_SEGMENT_BYTES, + context?.sampleRate, + getSourceHeaders(source) + ); + + if (probedDuration && !isFetchingCancelled.current) { + setDuration(probedDuration); + } + setReady(true); + } catch (error) { + if (!isFetchingCancelled.current) { + onError(error as Error); + } + setReady(false); + } + }, [context?.sampleRate, onError, onLoadStart, path, source]); + + const loadForPlayback = useCallback(async (): Promise => { + if (fullDataFetched.current) { + return fileSourceRef.current != null; + } + + return loadPlaybackSource(); + }, [loadPlaybackSource]); + + useEffect(() => { + isFetchingCancelled.current = false; + fullDataFetched.current = false; + + if (!path) { + setReady(false); + setDuration(0); + disposeSource(); + return () => { + isFetchingCancelled.current = true; + disposeSource(); + }; + } + + if (preloadMode === 'none') { + setReady(true); + return () => { + isFetchingCancelled.current = true; + disposeSource(); + }; + } + + if (preloadMode === 'metadata') { + probeMetadataOnly(); + return () => { + isFetchingCancelled.current = true; + disposeSource(); + }; + } + + loadPlaybackSource(); + + return () => { + isFetchingCancelled.current = true; + disposeSource(); + }; + }, [disposeSource, loadPlaybackSource, path, preloadMode, probeMetadataOnly]); + + return { + fileSourceRef, + ready, + duration, + setDuration, + loadForPlayback, + disposeSource, + }; +} diff --git a/packages/react-native-audio-api/src/development/react/Audio/utils.ts b/packages/react-native-audio-api/src/development/react/Audio/utils.ts index 8e56e4fe3..f6094e793 100644 --- a/packages/react-native-audio-api/src/development/react/Audio/utils.ts +++ b/packages/react-native-audio-api/src/development/react/Audio/utils.ts @@ -1,8 +1,8 @@ import { useMemo } from 'react'; -import { Platform } from 'react-native'; +import { Image, Platform } from 'react-native'; import AudioContext from '../../../core/AudioContext'; import type BaseAudioContext from '../../../core/BaseAudioContext'; -import { AudioProps, AudioPropsBase } from './types'; +import { AudioProps, AudioPropsBase, AudioSource } from './types'; const noop = () => {}; const noopError = (_error: Error) => {}; @@ -17,13 +17,15 @@ export function withPropsDefaults( props: AudioProps, resolvedContext: BaseAudioContext | undefined ): AudioPropsBase { + const normalizedPreload = props.preload || 'auto'; + return { ...props, autoPlay: props.autoPlay ?? false, controls: props.controls ?? false, loop: props.loop ?? false, muted: props.muted ?? false, - preload: props.preload ?? 'auto', + preload: normalizedPreload, source: props.source ?? [], playbackRate: props.playbackRate ?? 1.0, preservesPitch: props.preservesPitch ?? true, @@ -118,3 +120,25 @@ export function useStableAudioProps(props: AudioProps): AudioPropsBase { ] ); } + +export function resolveSourcePath(source: AudioSource): string { + if (typeof source === 'string') { + return source; + } + + if (typeof source === 'number') { + return Image.resolveAssetSource(source).uri; + } + + return source.uri ?? ''; +} + +export function getSourceHeaders( + source: AudioSource +): Record | undefined { + if (typeof source === 'object' && source && 'headers' in source) { + return source.headers; + } + + return undefined; +} diff --git a/packages/react-native-audio-api/src/interfaces.ts b/packages/react-native-audio-api/src/interfaces.ts index 6e86c66af..137452025 100644 --- a/packages/react-native-audio-api/src/interfaces.ts +++ b/packages/react-native-audio-api/src/interfaces.ts @@ -384,6 +384,10 @@ export interface IAudioFileUtils { inputPaths: string[], outputPath: string ) => Promise; + probeDuration: ( + data: ArrayBuffer, + sampleRate?: number + ) => Promise; } export interface IAudioEventEmitter { diff --git a/packages/react-native-audio-api/src/utils/metadataPrefetching.ts b/packages/react-native-audio-api/src/utils/metadataPrefetching.ts new file mode 100644 index 000000000..a47fc11c8 --- /dev/null +++ b/packages/react-native-audio-api/src/utils/metadataPrefetching.ts @@ -0,0 +1,96 @@ +export const METADATA_PROBE_EXTENSIONS = [ + '.opus', + '.mp4', + '.m4a', + '.wav', + '.flac', +] as const; + +export const DEFAULT_METADATA_SEGMENT_BYTES = 1024 * 16; + +type PrefetchConfig = { + url: string; + headers?: Record; + startBytes?: number; + endBytes?: number; +}; + +type PrefetchedSegment = { + buffer: ArrayBuffer; + status: number; +}; + +export function supportsMetadataProbe(path: string): boolean { + const normalizedPath = path.split('?')[0].toLowerCase(); + return METADATA_PROBE_EXTENSIONS.some((extension) => + normalizedPath.endsWith(extension) + ); +} + +/** + * Fetch small chunks at the start and end to probe duration without full + * download. + */ +export async function prefetchFileSegments({ + url, + startBytes, + endBytes, + headers, +}: PrefetchConfig): Promise { + const fetchSegment = async ( + range: string + ): Promise => { + const response = await fetch(url, { + headers: { ...headers, Range: range }, + }); + + if (response.status !== 206 && response.status !== 200) { + return null; + } + + const buffer = await response.arrayBuffer(); + return { buffer, status: response.status }; + }; + + const startPromise = + startBytes && startBytes > 0 + ? fetchSegment(`bytes=0-${startBytes - 1}`) + : Promise.resolve(null); + const endPromise = + endBytes && endBytes > 0 + ? fetchSegment(`bytes=-${endBytes}`) + : Promise.resolve(null); + + const [startSegment, endSegment] = await Promise.all([ + startPromise, + endPromise, + ]); + + if (startSegment?.status === 200) { + return startSegment.buffer; + } + if (endSegment?.status === 200) { + return endSegment.buffer; + } + + const startBuffer = startSegment?.buffer ?? null; + const endBuffer = endSegment?.buffer ?? null; + + if (startBuffer && endBuffer) { + const combined = new Uint8Array( + startBuffer.byteLength + endBuffer.byteLength + ); + combined.set(new Uint8Array(startBuffer), 0); + combined.set(new Uint8Array(endBuffer), startBuffer.byteLength); + return combined.buffer; + } + + if (startBuffer) { + return startBuffer; + } + if (endBuffer) { + return endBuffer; + } + + throw new Error('Failed to prefetch remote file segments.'); +}