From 94e6d7ada2d467824ac2b8d025d888c374d0ad6e Mon Sep 17 00:00:00 2001 From: michal Date: Tue, 19 May 2026 14:05:31 +0200 Subject: [PATCH 1/4] feat: preloading in audio tag --- apps/fabric-example/ios/Podfile.lock | 2 +- .../audiodocs/docs/experimental/audio-tag.mdx | 20 ++- .../utils/AudioFileUtilsHostObject.cpp | 63 ++++++++- .../utils/AudioFileUtilsHostObject.h | 1 + .../audioapi/libs/ffmpeg/FFmpegDecoding.cpp | 21 +++ .../cpp/audioapi/libs/ffmpeg/FFmpegDecoding.h | 7 + .../libs/miniaudio/MiniAudioDecoding.cpp | 18 +++ .../libs/miniaudio/MiniAudioDecoding.h | 7 + .../src/core/AudioFileUtils.ts | 14 ++ .../src/development/react/Audio/Audio.tsx | 127 +++++++++++++----- .../react/Audio/metadataPrefetching.ts | 75 +++++++++++ .../src/development/react/Audio/utils.ts | 7 +- .../react-native-audio-api/src/interfaces.ts | 4 + 13 files changed, 332 insertions(+), 34 deletions(-) create mode 100644 packages/react-native-audio-api/src/development/react/Audio/metadataPrefetching.ts 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..e7489de69 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` 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..b4a27c857 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,46 @@ #include #include +#if !RN_AUDIO_API_FFMPEG_DISABLED +#include +#endif // RN_AUDIO_API_FFMPEG_DISABLED #include +#include #include +#include #include +#include #include #include #include namespace audioapi { +namespace { + +std::optional probeDurationWithDecoder(const uint8_t *data, size_t size, int sampleRate) { + if (auto miniaudioDuration = + miniaudio_decoder::MiniAudioDecoder::probeDuration(data, size, sampleRate); + miniaudioDuration.has_value()) { + return miniaudioDuration; + } + +#if !RN_AUDIO_API_FFMPEG_DISABLED + return ffmpegdecoder::FFmpegDecoder::probeDuration(data, size, sampleRate); +#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 +83,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/libs/ffmpeg/FFmpegDecoding.cpp b/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.cpp index 57c366cf1..709e720c3 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 @@ -462,6 +462,24 @@ decoding::DecoderResult FFmpegDecoder::seekToTime(double seconds) { return Ok(None); } +std::optional FFmpegDecoder::probeDuration( + const void *data, + size_t size, + int outputSampleRate) { + FFmpegDecoder decoder; + const auto openResult = decoder.openMemory(outputSampleRate, data, size); + if (openResult.is_err()) { + return std::nullopt; + } + + const auto duration = static_cast(decoder.getDurationInSeconds()); + if (duration <= 0) { + return std::nullopt; + } + + return duration; +} + size_t FFmpegDecoder::readPcmFrames(float *outInterleaved, size_t frameCount) { if (!isOpen() || outInterleaved == nullptr || frameCount == 0 || output_channels_ <= 0) { return 0; @@ -580,6 +598,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; } 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..736669c29 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 @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -73,6 +74,12 @@ class FFmpegDecoder : public decoding::IncrementalAudioDecoder { [[nodiscard]] decoding::DecoderResult seekToTime(double seconds) override; + /// Opens only enough decoder state to probe media duration from memory and then closes. + [[nodiscard]] static std::optional probeDuration( + const void *data, + size_t size, + int outputSampleRate = 0); + private: [[nodiscard]] decoding::DecoderResult setupSwr(); [[nodiscard]] decoding::DecoderResult feedPipeline(); 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..c4e80ba22 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 @@ -200,6 +200,24 @@ decoding::DecoderResult MiniAudioDecoder::seekToTime(double seconds) { return Ok(None); } +std::optional MiniAudioDecoder::probeDuration( + const void *data, + size_t size, + int outputSampleRate) { + MiniAudioDecoder decoder; + const auto openResult = decoder.openMemory(outputSampleRate, data, size); + if (openResult.is_err()) { + return std::nullopt; + } + + const auto duration = static_cast(decoder.getDurationInSeconds()); + if (duration <= 0) { + return std::nullopt; + } + + return duration; +} + namespace { std::shared_ptr buildAudioBufferFromInterleaved( 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..724a5be51 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 @@ -39,6 +40,12 @@ class MiniAudioDecoder : public decoding::IncrementalAudioDecoder { [[nodiscard]] float getCurrentPositionInSeconds() const override; [[nodiscard]] decoding::DecoderResult seekToTime(double seconds) override; + /// Opens only enough decoder state to probe media duration from memory and then closes. + [[nodiscard]] static std::optional probeDuration( + const void *data, + size_t size, + int outputSampleRate = 0); + private: void teardownDecoder(); diff --git a/packages/react-native-audio-api/src/core/AudioFileUtils.ts b/packages/react-native-audio-api/src/core/AudioFileUtils.ts index dfb403f81..de21869a2 100644 --- a/packages/react-native-audio-api/src/core/AudioFileUtils.ts +++ b/packages/react-native-audio-api/src/core/AudioFileUtils.ts @@ -36,6 +36,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 +54,10 @@ export async function concatAudioFiles( outputPath ); } + +export async function probeDuration( + data: ArrayBuffer, + sampleRate?: number +): Promise { + return AudioFileUtils.getInstance().probeDurationInstance(data, 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..97a484c79 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 @@ -12,6 +12,7 @@ import type { AudioTagHandle, AudioProps, AudioTagPlaybackState, + PreloadType, } from './types'; import { AudioComponentContext } from './AudioTagContext'; @@ -20,7 +21,9 @@ import { useStableAudioProps } from './utils'; import { NotSupportedError } from '../../../errors'; import { NativeAudioAPIModule } from '../../../specs'; import { AudioControls } from '..'; +import { probeDuration } from '../../../core/AudioFileUtils'; import { base64ToArrayBuffer } from '../../../utils'; +import { prefetchFileSegments } from './metadataPrefetching'; const Audio = React.forwardRef((props, ref) => { const { children } = props; @@ -48,6 +51,10 @@ const Audio = React.forwardRef((props, ref) => { const [volumeState, setVolumeState] = useState(null); const [mutedState, setMutedState] = useState(null); const [ready, setReady] = useState(false); + const [playbackState, setPlaybackState] = + useState('idle'); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); const path = useMemo(() => { if (!source) { @@ -64,16 +71,18 @@ const Audio = React.forwardRef((props, ref) => { return source.uri ?? ''; }, [source]); + const preloadMode: PreloadType = + preload === 'none' || preload === 'metadata' ? preload : 'auto'; const fileSourceRef = useRef(null); + const fetchDataRef = useRef<(probe?: boolean) => Promise>( + async () => {} + ); const sourceRef = useRef(null); + const isFetchingCancelled = useRef(false); + const fullDataFetched = useRef(false); const lastEffectiveVolumeRef = useRef(muted ? 0 : volume); - const [playbackState, setPlaybackState] = - useState('idle'); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const effectiveMutedState = useMemo(() => { return mutedState ?? muted; }, [mutedState, muted]); @@ -89,11 +98,17 @@ const Audio = React.forwardRef((props, ref) => { fileSourceRef.current?.setVolume(effectiveVolumeState); }, [effectiveVolumeState]); - const play = useCallback(() => { + const play = useCallback(async () => { + if ( + (preloadMode === 'none' || preloadMode === 'metadata') && + !fullDataFetched.current + ) { + await fetchDataRef.current(false); + } fileSourceRef.current?.play(); setPlaybackState('playing'); onPlay(); - }, [onPlay]); + }, [onPlay, preloadMode]); const pause = useCallback(() => { fileSourceRef.current?.pause(); @@ -154,29 +169,46 @@ const Audio = React.forwardRef((props, ref) => { 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; + play(); } + }, [context, loop, onError, onEndedCallback, onLoad, autoPlay, play]); - let isCancelled = false; - - const run = async () => { + const fetchData = useCallback( + async (probe: boolean = false) => { + isFetchingCancelled.current = false; setReady(false); onLoadStart(); try { if (path.startsWith('http')) { + if ( + preloadMode === 'metadata' && + probe && + ['opus', 'mp4', 'm4a', 'wav', 'flac'].some((extension) => + path.endsWith(extension) + ) + ) { + // fetch only metadata for codec that supports it + const requestHeaders = + typeof source === 'object' && source && 'headers' in source + ? source.headers + : undefined; + const SEGMENT_SIZE = 1024 * 16; + const prefetchedData = await prefetchFileSegments({ + url: path, + headers: requestHeaders, + startBytes: SEGMENT_SIZE, + endBytes: SEGMENT_SIZE, + }); + const probedDuration = await probeDuration( + prefetchedData, + context?.sampleRate + ); + if (probedDuration != null && probedDuration > 0) { + setDuration(probedDuration); + } + setReady(true); + return; + } const arrayBuffer = await fetch(path, { headers: typeof source === 'object' && source && 'headers' in source @@ -200,27 +232,62 @@ const Audio = React.forwardRef((props, ref) => { } else { sourceRef.current = path; } + fullDataFetched.current = true; + setReady(true); - if (!isCancelled) { + if (!isFetchingCancelled.current) { spawnFileSource(); setReady(true); } } catch (error) { - if (!isCancelled) { + if (!isFetchingCancelled.current) { onError(error as Error); } setReady(false); } - }; + }, + [ + context?.sampleRate, + onError, + onLoadStart, + path, + preloadMode, + source, + spawnFileSource, + ] + ); + fetchDataRef.current = fetchData; + + useEffect(() => { + isFetchingCancelled.current = false; + fullDataFetched.current = false; + + if (!path) { + setPlaybackState('idle'); + setCurrentTime(0); + setDuration(0); + fileSourceRef.current?.dispose(); + sourceRef.current = null; + return; + } + + if (preloadMode === 'none') { + setReady(true); + return; + } - run(); + if (preloadMode === 'metadata') { + fetchData(true); + return; + } + fetchData(); return () => { - isCancelled = true; + isFetchingCancelled.current = true; fileSourceRef.current?.stopPositionTracking(); fileSourceRef.current?.dispose(); }; - }, [path, source, spawnFileSource, onError, onLoadStart]); + }, [fetchData, path, preloadMode, source, spawnFileSource]); useEffect(() => { if (lastEffectiveVolumeRef.current !== effectiveVolumeState) { diff --git a/packages/react-native-audio-api/src/development/react/Audio/metadataPrefetching.ts b/packages/react-native-audio-api/src/development/react/Audio/metadataPrefetching.ts new file mode 100644 index 000000000..edfb40a93 --- /dev/null +++ b/packages/react-native-audio-api/src/development/react/Audio/metadataPrefetching.ts @@ -0,0 +1,75 @@ +type PrefetchConfig = { + url: string; + headers?: Record; + startBytes?: number; + endBytes?: number; +}; + +type PrefetchedSegment = { + buffer: ArrayBuffer; + status: number; +}; + +export async function prefetchFileSegments({ + url, + headers, + startBytes, + endBytes, +}: 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.'); +} 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..62422a8e1 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 @@ -17,13 +17,18 @@ export function withPropsDefaults( props: AudioProps, resolvedContext: BaseAudioContext | undefined ): AudioPropsBase { + const normalizedPreload = + (props.preload as string | undefined) === '' + ? 'auto' + : (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, 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 { From 42a6483181c5be0c614b1aa42e192b20d99f1664 Mon Sep 17 00:00:00 2001 From: michal Date: Wed, 20 May 2026 15:48:43 +0200 Subject: [PATCH 2/4] feat: comments --- .../audiodocs/docs/experimental/audio-tag.mdx | 1 - .../BaseAudioContextHostObject.cpp | 2 +- .../utils/AudioDecoderHostObject.cpp | 8 +- .../utils/AudioDecoderHostObject.h | 1 - .../utils/AudioFileUtilsHostObject.cpp | 14 +- .../HostObjects/utils/NodeOptionsParser.h | 6 +- .../cpp/audioapi/core/BaseAudioContext.cpp | 2 +- .../core/sources/AudioFileSourceNode.cpp | 2 +- .../{AudioDecoder.cpp => AudioDecoding.cpp} | 33 ++- .../utils/{AudioDecoder.h => AudioDecoding.h} | 8 +- .../audioapi/libs/ffmpeg/FFmpegDecoding.cpp | 26 +- .../cpp/audioapi/libs/ffmpeg/FFmpegDecoding.h | 17 +- .../libs/miniaudio/MiniAudioDecoding.cpp | 20 -- .../libs/miniaudio/MiniAudioDecoding.h | 10 +- .../src/core/AudioFileUtils.ts | 19 +- .../src/development/react/Audio/Audio.tsx | 268 +++++------------- .../react/Audio/AudioTagContext.ts | 2 - .../react/Audio/useAudioSourceLoader.ts | 259 +++++++++++++++++ .../src/development/react/Audio/utils.ts | 31 +- .../Audio => utils}/metadataPrefetching.ts | 23 +- 20 files changed, 450 insertions(+), 302 deletions(-) rename packages/react-native-audio-api/common/cpp/audioapi/core/utils/{AudioDecoder.cpp => AudioDecoding.cpp} (86%) rename packages/react-native-audio-api/common/cpp/audioapi/core/utils/{AudioDecoder.h => AudioDecoding.h} (86%) create mode 100644 packages/react-native-audio-api/src/development/react/Audio/useAudioSourceLoader.ts rename packages/react-native-audio-api/src/{development/react/Audio => utils}/metadataPrefetching.ts (78%) diff --git a/packages/audiodocs/docs/experimental/audio-tag.mdx b/packages/audiodocs/docs/experimental/audio-tag.mdx index e7489de69..927dd12c0 100644 --- a/packages/audiodocs/docs/experimental/audio-tag.mdx +++ b/packages/audiodocs/docs/experimental/audio-tag.mdx @@ -141,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 b4a27c857..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 @@ -3,6 +3,7 @@ #if !RN_AUDIO_API_FFMPEG_DISABLED #include #endif // RN_AUDIO_API_FFMPEG_DISABLED +#include #include #include @@ -19,14 +20,15 @@ namespace audioapi { namespace { std::optional probeDurationWithDecoder(const uint8_t *data, size_t size, int sampleRate) { - if (auto miniaudioDuration = - miniaudio_decoder::MiniAudioDecoder::probeDuration(data, size, sampleRate); - miniaudioDuration.has_value()) { - return miniaudioDuration; + auto duration = std::optional(); + duration = + audiodecoding::probeDuration(data, size, sampleRate); + if (duration.has_value()) { + return duration; } - #if !RN_AUDIO_API_FFMPEG_DISABLED - return ffmpegdecoder::FFmpegDecoder::probeDuration(data, size, sampleRate); + duration = audiodecoding::probeDuration(data, size, sampleRate); + return duration; #else return std::nullopt; #endif // RN_AUDIO_API_FFMPEG_DISABLED 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 86% 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..4c8067aec 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,27 @@ 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()); +} + +template std::optional probeDuration( + const void *data, + size_t size, + int outputSampleRate); + +#if !RN_AUDIO_API_FFMPEG_DISABLED +template std::optional +probeDuration(const void *data, size_t size, int outputSampleRate); +#endif // RN_AUDIO_API_FFMPEG_DISABLED + +} // 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 86% 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..73353827d 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 @@ -8,7 +8,7 @@ #include #include -namespace audioapi::audiodecoder { +namespace audioapi::audiodecoding { using AudioBufferResult = Result, std::string>; @@ -39,4 +39,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 709e720c3..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 { @@ -462,24 +462,6 @@ decoding::DecoderResult FFmpegDecoder::seekToTime(double seconds) { return Ok(None); } -std::optional FFmpegDecoder::probeDuration( - const void *data, - size_t size, - int outputSampleRate) { - FFmpegDecoder decoder; - const auto openResult = decoder.openMemory(outputSampleRate, data, size); - if (openResult.is_err()) { - return std::nullopt; - } - - const auto duration = static_cast(decoder.getDurationInSeconds()); - if (duration <= 0) { - return std::nullopt; - } - - return duration; -} - size_t FFmpegDecoder::readPcmFrames(float *outInterleaved, size_t frameCount) { if (!isOpen() || outInterleaved == nullptr || frameCount == 0 || output_channels_ <= 0) { return 0; @@ -573,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 &) { @@ -608,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 736669c29..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,9 +12,9 @@ #include #include +#include #include #include -#include #include #include @@ -25,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 { @@ -43,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, @@ -74,12 +71,6 @@ class FFmpegDecoder : public decoding::IncrementalAudioDecoder { [[nodiscard]] decoding::DecoderResult seekToTime(double seconds) override; - /// Opens only enough decoder state to probe media duration from memory and then closes. - [[nodiscard]] static std::optional probeDuration( - const void *data, - size_t size, - int outputSampleRate = 0); - private: [[nodiscard]] decoding::DecoderResult setupSwr(); [[nodiscard]] decoding::DecoderResult feedPipeline(); @@ -108,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 c4e80ba22..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(); } @@ -200,24 +198,6 @@ decoding::DecoderResult MiniAudioDecoder::seekToTime(double seconds) { return Ok(None); } -std::optional MiniAudioDecoder::probeDuration( - const void *data, - size_t size, - int outputSampleRate) { - MiniAudioDecoder decoder; - const auto openResult = decoder.openMemory(outputSampleRate, data, size); - if (openResult.is_err()) { - return std::nullopt; - } - - const auto duration = static_cast(decoder.getDurationInSeconds()); - if (duration <= 0) { - return std::nullopt; - } - - return duration; -} - namespace { std::shared_ptr buildAudioBufferFromInterleaved( 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 724a5be51..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 @@ -16,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); @@ -40,12 +40,6 @@ class MiniAudioDecoder : public decoding::IncrementalAudioDecoder { [[nodiscard]] float getCurrentPositionInSeconds() const override; [[nodiscard]] decoding::DecoderResult seekToTime(double seconds) override; - /// Opens only enough decoder state to probe media duration from memory and then closes. - [[nodiscard]] static std::optional probeDuration( - const void *data, - size_t size, - int outputSampleRate = 0); - private: void teardownDecoder(); diff --git a/packages/react-native-audio-api/src/core/AudioFileUtils.ts b/packages/react-native-audio-api/src/core/AudioFileUtils.ts index de21869a2..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; @@ -56,8 +57,20 @@ export async function concatAudioFiles( } export async function probeDuration( - data: ArrayBuffer, - sampleRate?: number + url: string, + startBytes: number, + endBytes: number, + sampleRate?: number, + headers?: { [key: string]: string } ): Promise { - return AudioFileUtils.getInstance().probeDurationInstance(data, sampleRate); + 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 97a484c79..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,7 +6,7 @@ import React, { useRef, useState, } from 'react'; -import { View, Image, Platform } from 'react-native'; +import { View } from 'react-native'; import type { AudioTagHandle, @@ -16,14 +16,9 @@ import type { } 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 { probeDuration } from '../../../core/AudioFileUtils'; -import { base64ToArrayBuffer } from '../../../utils'; -import { prefetchFileSegments } from './metadataPrefetching'; +import { useAudioSourceLoader } from './useAudioSourceLoader'; const Audio = React.forwardRef((props, ref) => { const { children } = props; @@ -47,40 +42,17 @@ 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 [playbackState, setPlaybackState] = useState('idle'); const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - 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 path = useMemo(() => resolveSourcePath(source), [source]); const preloadMode: PreloadType = preload === 'none' || preload === 'metadata' ? preload : 'auto'; - const fileSourceRef = useRef(null); - const fetchDataRef = useRef<(probe?: boolean) => Promise>( - async () => {} - ); - const sourceRef = useRef(null); - - const isFetchingCancelled = useRef(false); - const fullDataFetched = useRef(false); const lastEffectiveVolumeRef = useRef(muted ? 0 : volume); const effectiveMutedState = useMemo(() => { @@ -94,27 +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(async () => { - if ( - (preloadMode === 'none' || preloadMode === 'metadata') && - !fullDataFetched.current - ) { - await fetchDataRef.current(false); + if ((preloadMode === 'none' || preloadMode === 'metadata') && !ready) { + const loaded = await loadForPlayback(); + if (!loaded) { + return; + } + } + + if (!fileSourceRef.current) { + return; } - fileSourceRef.current?.play(); + + fileSourceRef.current.play(); setPlaybackState('playing'); onPlay(); - }, [onPlay, preloadMode]); + }, [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) => { @@ -126,168 +144,15 @@ const Audio = React.forwardRef((props, ref) => { setCurrentTime(nextTime); onPositionChange(nextTime); }, - [duration, setCurrentTime, onPositionChange] - ); - - 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) { - play(); - } - }, [context, loop, onError, onEndedCallback, onLoad, autoPlay, play]); - - const fetchData = useCallback( - async (probe: boolean = false) => { - isFetchingCancelled.current = false; - setReady(false); - onLoadStart(); - try { - if (path.startsWith('http')) { - if ( - preloadMode === 'metadata' && - probe && - ['opus', 'mp4', 'm4a', 'wav', 'flac'].some((extension) => - path.endsWith(extension) - ) - ) { - // fetch only metadata for codec that supports it - const requestHeaders = - typeof source === 'object' && source && 'headers' in source - ? source.headers - : undefined; - const SEGMENT_SIZE = 1024 * 16; - const prefetchedData = await prefetchFileSegments({ - url: path, - headers: requestHeaders, - startBytes: SEGMENT_SIZE, - endBytes: SEGMENT_SIZE, - }); - const probedDuration = await probeDuration( - prefetchedData, - context?.sampleRate - ); - if (probedDuration != null && probedDuration > 0) { - setDuration(probedDuration); - } - setReady(true); - return; - } - 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; - } - fullDataFetched.current = true; - setReady(true); - - if (!isFetchingCancelled.current) { - spawnFileSource(); - setReady(true); - } - } catch (error) { - if (!isFetchingCancelled.current) { - onError(error as Error); - } - setReady(false); - } - }, - [ - context?.sampleRate, - onError, - onLoadStart, - path, - preloadMode, - source, - spawnFileSource, - ] + [duration, setCurrentTime, onPositionChange, fileSourceRef] ); - fetchDataRef.current = fetchData; useEffect(() => { - isFetchingCancelled.current = false; - fullDataFetched.current = false; - if (!path) { setPlaybackState('idle'); setCurrentTime(0); - setDuration(0); - fileSourceRef.current?.dispose(); - sourceRef.current = null; - return; } - - if (preloadMode === 'none') { - setReady(true); - return; - } - - if (preloadMode === 'metadata') { - fetchData(true); - return; - } - fetchData(); - - return () => { - isFetchingCancelled.current = true; - fileSourceRef.current?.stopPositionTracking(); - fileSourceRef.current?.dispose(); - }; - }, [fetchData, path, preloadMode, source, spawnFileSource]); + }, [path]); useEffect(() => { if (lastEffectiveVolumeRef.current !== effectiveVolumeState) { @@ -298,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, @@ -348,7 +214,6 @@ const Audio = React.forwardRef((props, ref) => { preservesPitch, sourcePath: path, source, - audioContext, }), [ play, @@ -370,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 62422a8e1..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,10 +17,7 @@ export function withPropsDefaults( props: AudioProps, resolvedContext: BaseAudioContext | undefined ): AudioPropsBase { - const normalizedPreload = - (props.preload as string | undefined) === '' - ? 'auto' - : (props.preload ?? 'auto'); + const normalizedPreload = props.preload || 'auto'; return { ...props, @@ -123,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/development/react/Audio/metadataPrefetching.ts b/packages/react-native-audio-api/src/utils/metadataPrefetching.ts similarity index 78% rename from packages/react-native-audio-api/src/development/react/Audio/metadataPrefetching.ts rename to packages/react-native-audio-api/src/utils/metadataPrefetching.ts index edfb40a93..a47fc11c8 100644 --- a/packages/react-native-audio-api/src/development/react/Audio/metadataPrefetching.ts +++ b/packages/react-native-audio-api/src/utils/metadataPrefetching.ts @@ -1,3 +1,13 @@ +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; @@ -10,11 +20,22 @@ type PrefetchedSegment = { 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, - headers, startBytes, endBytes, + headers, }: PrefetchConfig): Promise { const fetchSegment = async ( range: string From 3379f1a0cebcea077230a6ca5bebd179ce725dd2 Mon Sep 17 00:00:00 2001 From: michal Date: Wed, 20 May 2026 17:24:08 +0200 Subject: [PATCH 3/4] fix: test --- .../common/cpp/audioapi/core/utils/AudioDecoding.h | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.h index 73353827d..22a58e822 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include From 3b1b23428ef05344ea40349b9a10b840747a726e Mon Sep 17 00:00:00 2001 From: michal Date: Wed, 20 May 2026 17:28:49 +0200 Subject: [PATCH 4/4] fix: testv2 --- .../common/cpp/audioapi/core/utils/AudioDecoding.cpp | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.cpp index 4c8067aec..6cedfebf1 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.cpp @@ -210,14 +210,4 @@ std::optional probeDuration(const void *data, size_t size, int outputSam return static_cast(decoder.getDurationInSeconds()); } -template std::optional probeDuration( - const void *data, - size_t size, - int outputSampleRate); - -#if !RN_AUDIO_API_FFMPEG_DISABLED -template std::optional -probeDuration(const void *data, size_t size, int outputSampleRate); -#endif // RN_AUDIO_API_FFMPEG_DISABLED - } // namespace audioapi::audiodecoding