From 151ffde8fbace2b1c60a2c0370574e5b17b4f487 Mon Sep 17 00:00:00 2001 From: traviskn Date: Thu, 25 Jun 2026 11:45:37 -0600 Subject: [PATCH 1/2] feat: add duration-only audio file API --- .../docs/core/base-audio-context.mdx | 21 +++ packages/audiodocs/docs/utils/decoding.mdx | 27 +++- .../utils/AudioDecoderHostObject.cpp | 23 +++ .../utils/AudioDecoderHostObject.h | 1 + .../cpp/audioapi/core/utils/AudioDecoding.cpp | 42 ++++++ .../cpp/audioapi/core/utils/AudioDecoding.hpp | 4 + .../cpp/test/src/utils/AudioDecodingTest.cpp | 76 ++++++++++ packages/react-native-audio-api/src/api.ts | 6 +- .../react-native-audio-api/src/api.web.ts | 1 + .../src/core/AudioDecoder.ts | 39 +++++- .../src/core/BaseAudioContext.ts | 17 ++- .../src/jsi-interfaces.ts | 1 + .../react-native-audio-api/src/mock/index.ts | 51 +++++++ packages/react-native-audio-api/src/types.ts | 1 + .../src/web-core/AudioContext.web.ts | 12 +- .../src/web-core/AudioDecoder.web.ts | 20 ++- .../src/web-core/BaseAudioContext.web.ts | 3 +- .../src/web-core/OfflineAudioContext.web.ts | 11 +- .../tests/audio-decoder.test.ts | 131 ++++++++++++++++++ .../react-native-audio-api/tests/mock.test.ts | 15 ++ 20 files changed, 492 insertions(+), 10 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/test/src/utils/AudioDecodingTest.cpp create mode 100644 packages/react-native-audio-api/tests/audio-decoder.test.ts diff --git a/packages/audiodocs/docs/core/base-audio-context.mdx b/packages/audiodocs/docs/core/base-audio-context.mdx index f7b646381..95b7e39ed 100644 --- a/packages/audiodocs/docs/core/base-audio-context.mdx +++ b/packages/audiodocs/docs/core/base-audio-context.mdx @@ -287,6 +287,27 @@ const buffer = await audioContext.decodeAudioData(url); ``` +### `getAudioDuration` + +Reads the duration of a local audio file without decoding the full file into an [`AudioBuffer`](/docs/sources/audio-buffer). +Use this when you only need the encoded file duration. Use [`decodeAudioData`](/docs/core/base-audio-context#decodeaudiodata) when you need sample data for playback or processing. + +This API currently supports local file paths and `file://` URIs. Remote URLs, asset module ids, `ArrayBuffer` input, base64 data URLs, and blob URLs are rejected explicitly. +There is no `sampleRate` option because the returned duration belongs to the encoded file and does not depend on the context's output sample rate. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `input` | `string` | Local file path or `file://` URI. | + +#### Returns `Promise` with the duration in seconds. + +
+Example reading local file duration +```tsx +const duration = await audioContext.getAudioDuration('file:///tmp/recording.wav'); +``` +
+ ### `decodePCMInBase64` Decodes base64-encoded PCM audio data. diff --git a/packages/audiodocs/docs/utils/decoding.mdx b/packages/audiodocs/docs/utils/decoding.mdx index a39f67631..21a97078d 100644 --- a/packages/audiodocs/docs/utils/decoding.mdx +++ b/packages/audiodocs/docs/utils/decoding.mdx @@ -6,8 +6,8 @@ import { Optional, MobileOnly } from '@site/src/components/Badges'; # Decoding -You can decode audio data independently, without creating an AudioContext, using the exported functions [`decodeAudioData`](/docs/utils/decoding#decodeaudiodata) and -[`decodePCMInBase64`](/docs/utils/decoding#decodepcminbase64). +You can decode audio data independently, without creating an AudioContext, using the exported functions [`decodeAudioData`](/docs/utils/decoding#decodeaudiodata), +[`decodePCMInBase64`](/docs/utils/decoding#decodepcminbase64), and [`getAudioDuration`](/docs/utils/decoding#getaudioduration). If you already have an audio context, you can decode audio data directly using its [`decodeAudioData`](/docs/core/base-audio-context#decodeaudiodata) function; the decoded audio will then be automatically resampled to match the context's `sampleRate`. @@ -85,6 +85,29 @@ const buffer = await decodeAudioData(url); ``` +### `getAudioDuration` + +Reads the duration of a local audio file without decoding the full file into an [`AudioBuffer`](/docs/sources/audio-buffer). +Use this when you only need the encoded file duration. Use [`decodeAudioData`](/docs/utils/decoding#decodeaudiodata) when you need sample data for playback or processing. + +This API currently supports local file paths and `file://` URIs. Remote URLs, asset module ids, `ArrayBuffer` input, base64 data URLs, and blob URLs are rejected explicitly. +There is no `sampleRate` option because the returned duration belongs to the encoded file and does not depend on a caller-selected output sample rate. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `input` | `string` | Local file path or `file://` URI. | + +#### Returns `Promise` with the duration in seconds. + +
+Example reading local file duration +```tsx +import { getAudioDuration } from 'react-native-audio-api'; + +const duration = await getAudioDuration('file:///tmp/recording.wav'); +``` +
+ ### `decodePCMInBase64` Decodes base64-encoded PCM audio data. 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 57514a37d..6f04d10c6 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 @@ -16,6 +16,7 @@ AudioDecoderHostObject::AudioDecoderHostObject( addFunctions( JSI_EXPORT_FUNCTION(AudioDecoderHostObject, decodeWithPCMInBase64), JSI_EXPORT_FUNCTION(AudioDecoderHostObject, decodeWithFilePath), + JSI_EXPORT_FUNCTION(AudioDecoderHostObject, getDurationWithFilePath), JSI_EXPORT_FUNCTION(AudioDecoderHostObject, decodeWithMemoryBlock)); } @@ -76,6 +77,28 @@ JSI_HOST_FUNCTION_IMPL(AudioDecoderHostObject, decodeWithFilePath) { return promise; } +JSI_HOST_FUNCTION_IMPL(AudioDecoderHostObject, getDurationWithFilePath) { + auto sourcePath = args[0].getString(runtime).utf8(runtime); + + auto promise = promiseVendor_->createAsyncPromise([sourcePath]() -> PromiseResolver { + auto result = audiodecoding::getDurationWithFilePath(sourcePath); + + if (result.is_err()) { + return [result = std::move(result)]( + jsi::Runtime &runtime) -> std::variant { + return result.unwrap_err(); + }; + } + + const auto duration = result.unwrap(); + return [duration](jsi::Runtime &runtime) -> std::variant { + return jsi::Value(duration); + }; + }); + + return promise; +} + JSI_HOST_FUNCTION_IMPL(AudioDecoderHostObject, decodeWithPCMInBase64) { auto b64 = args[0].getString(runtime).utf8(runtime); auto inputSampleRate = static_cast(args[1].getNumber()); 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 61457dc59..8e08285ca 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 @@ -16,6 +16,7 @@ class AudioDecoderHostObject : public JsiHostObject { const std::shared_ptr &callInvoker); JSI_HOST_FUNCTION_DECL(decodeWithMemoryBlock); JSI_HOST_FUNCTION_DECL(decodeWithFilePath); + JSI_HOST_FUNCTION_DECL(getDurationWithFilePath); JSI_HOST_FUNCTION_DECL(decodeWithPCMInBase64); private: 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 92201703f..d128b83d2 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 @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -108,6 +109,19 @@ bool pathHasExtension(const std::string &path, const std::vector &e extensions, [&pathLower](const std::string &ext) { return pathLower.ends_with(ext); }); } +bool isValidDuration(float duration) { + return duration > 0.0F && std::isfinite(duration); +} + +AudioDurationResult resolveDurationFromDecoder(decoding::IncrementalAudioDecoder &decoder) { + const float duration = decoder.getDurationInSeconds(); + if (!isValidDuration(duration)) { + return Err("Audio duration metadata is unavailable"); + } + + return Ok(duration); +} + AudioBufferResult decodeWithFilePath(const std::string &path, float sampleRate) { const int sr = static_cast(sampleRate); @@ -136,6 +150,34 @@ AudioBufferResult decodeWithFilePath(const std::string &path, float sampleRate) return result; } +AudioDurationResult getDurationWithFilePath(const std::string &path) { + constexpr int useDecoderNativeSampleRate = 0; + + if (needsFFmpegByPath(path)) { +#if !RN_AUDIO_API_FFMPEG_DISABLED + ffmpeg_decoder::FFmpegDecoder decoder; + const auto openResult = decoder.openFile(useDecoderNativeSampleRate, path); + if (openResult.is_err()) { + return Err("Failed to open file with FFmpeg decoder: " + openResult.unwrap_err()); + } + auto result = resolveDurationFromDecoder(decoder); + decoder.close(); + return result; +#else + return Err("FFmpeg is disabled, cannot inspect duration with file path"); +#endif // RN_AUDIO_API_FFMPEG_DISABLED + } + + miniaudio_decoder::MiniAudioDecoder decoder; + const auto openResult = decoder.openFile(useDecoderNativeSampleRate, path); + if (openResult.is_err()) { + return Err("Failed to open file with miniaudio decoder: " + openResult.unwrap_err()); + } + auto result = resolveDurationFromDecoder(decoder); + decoder.close(); + return result; +} + AudioBufferResult decodeWithMemoryBlock(const void *data, size_t size, float sampleRate) { const int sr = static_cast(sampleRate); const AudioFormat format = detectAudioFormat(data, size); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.hpp index 867d362f8..45a42eb5a 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDecoding.hpp @@ -14,8 +14,10 @@ namespace audioapi::audiodecoding { using AudioBufferResult = Result, std::string>; +using AudioDurationResult = Result; [[nodiscard]] AudioBufferResult decodeWithFilePath(const std::string &path, float sampleRate); +[[nodiscard]] AudioDurationResult getDurationWithFilePath(const std::string &path); [[nodiscard]] AudioBufferResult decodeWithMemoryBlock(const void *data, size_t size, float sampleRate); [[nodiscard]] AudioBufferResult decodeWithPCMInBase64( @@ -30,6 +32,8 @@ decodeWithMemoryBlock(const void *data, size_t size, float sampleRate); const std::string &path, const std::vector &extensions); +[[nodiscard]] bool isValidDuration(float duration); + [[nodiscard]] inline bool needsFFmpeg(AudioFormat format) { return format == AudioFormat::MP4 || format == AudioFormat::M4A || format == AudioFormat::AAC; } diff --git a/packages/react-native-audio-api/common/cpp/test/src/utils/AudioDecodingTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/utils/AudioDecodingTest.cpp new file mode 100644 index 000000000..5d702c68f --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/utils/AudioDecodingTest.cpp @@ -0,0 +1,76 @@ +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace audioapi; + +// NOLINTBEGIN + +namespace { + +constexpr ma_uint32 sampleRate = 48000; +constexpr ma_uint32 channelCount = 1; + +std::string testFilePath(const std::string &name) { + return ::testing::TempDir() + name; +} + +void removeFile(const std::string &path) { + std::remove(path.c_str()); +} + +void writeWavFile(const std::string &path, const std::vector &frames) { + ma_encoder encoder; + ma_encoder_config config = + ma_encoder_config_init(ma_encoding_format_wav, ma_format_f32, channelCount, sampleRate); + ASSERT_EQ(ma_encoder_init_file(path.c_str(), &config, &encoder), MA_SUCCESS); + + ma_uint64 framesWritten = 0; + EXPECT_EQ( + ma_encoder_write_pcm_frames( + &encoder, frames.data(), static_cast(frames.size()), &framesWritten), + MA_SUCCESS); + EXPECT_EQ(framesWritten, frames.size()); + + ma_encoder_uninit(&encoder); +} + +} // namespace + +TEST(AudioDecodingTest, ValidatesDurationMetadata) { + EXPECT_FALSE(audiodecoding::isValidDuration(0.0F)); + EXPECT_FALSE(audiodecoding::isValidDuration(-1.0F)); + EXPECT_FALSE(audiodecoding::isValidDuration(std::numeric_limits::infinity())); + EXPECT_FALSE(audiodecoding::isValidDuration(std::nanf(""))); + EXPECT_TRUE(audiodecoding::isValidDuration(0.001F)); +} + +TEST(AudioDecodingTest, ReturnsDurationForLocalWavFile) { + const std::string input = testFilePath("audio-duration.wav"); + removeFile(input); + + writeWavFile(input, std::vector(sampleRate / 2, 0.25F)); + + auto result = audiodecoding::getDurationWithFilePath(input); + + if (result.is_err()) { + FAIL() << result.unwrap_err(); + } + EXPECT_NEAR(result.unwrap(), 0.5F, 0.001F); + + removeFile(input); +} + +TEST(AudioDecodingTest, RejectsUnavailableDurationMetadata) { + auto result = audiodecoding::getDurationWithFilePath(""); + + EXPECT_TRUE(result.is_err()); +} + +// NOLINTEND diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index 54b2ab76b..e9ac2313f 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -5,7 +5,11 @@ export { default as AudioBuffer } from './core/AudioBuffer'; export { default as AudioBufferQueueSourceNode } from './core/AudioBufferQueueSourceNode'; export { default as AudioBufferSourceNode } from './core/AudioBufferSourceNode'; export { default as AudioContext } from './core/AudioContext'; -export { decodeAudioData, decodePCMInBase64 } from './core/AudioDecoder'; +export { + decodeAudioData, + decodePCMInBase64, + getAudioDuration, +} from './core/AudioDecoder'; export { concatAudioFiles } from './core/AudioFileUtils'; export { default as AudioDestinationNode } from './core/AudioDestinationNode'; export { default as AudioNode } from './core/AudioNode'; diff --git a/packages/react-native-audio-api/src/api.web.ts b/packages/react-native-audio-api/src/api.web.ts index f23b4bdd7..262a5ce21 100644 --- a/packages/react-native-audio-api/src/api.web.ts +++ b/packages/react-native-audio-api/src/api.web.ts @@ -24,6 +24,7 @@ export { default as AudioDecoder, decodeAudioData, decodePCMInBase64, + getAudioDuration, } from './web-core/AudioDecoder.web'; export * from './web-core/custom'; diff --git a/packages/react-native-audio-api/src/core/AudioDecoder.ts b/packages/react-native-audio-api/src/core/AudioDecoder.ts index cc4a9e05f..49d3b6228 100644 --- a/packages/react-native-audio-api/src/core/AudioDecoder.ts +++ b/packages/react-native-audio-api/src/core/AudioDecoder.ts @@ -3,7 +3,7 @@ import { NativeAudioAPIModule } from '../specs'; import { AudioApiError } from '../errors'; import { IAudioDecoder } from '../jsi-interfaces'; -import { DecodeDataInput } from '../types'; +import { AudioDurationInput, DecodeDataInput } from '../types'; import { isBase64Source, isDataBlobString, @@ -126,6 +126,13 @@ class AudioDecoder { return new AudioBuffer(buffer); } + private async getDurationFromLocalFile( + stringSource: string + ): Promise { + const filePath = this.resolveLocalFilePath(stringSource); + return await this.decoder.getDurationWithFilePath(filePath); + } + public static getInstance(): AudioDecoder { if (!AudioDecoder.instance) { AudioDecoder.instance = new AudioDecoder(); @@ -166,6 +173,30 @@ class AudioDecoder { ); return new AudioBuffer(buffer); } + + public async getAudioDurationInstance( + input: DecodeDataInput + ): Promise { + if (input instanceof ArrayBuffer) { + throw new AudioApiError( + 'ArrayBuffer duration probing is not currently supported.' + ); + } + + if (typeof input !== 'string') { + throw new TypeError('Input must be a local file path or file:// URI.'); + } + + this.assertSupportedStringSource(input); + + if (isRemoteSource(input)) { + throw new AudioApiError( + 'Remote source duration probing is not currently supported.' + ); + } + + return await this.getDurationFromLocalFile(input); + } } export async function decodeAudioData( @@ -193,3 +224,9 @@ export async function decodePCMInBase64( isInterleaved ); } + +export async function getAudioDuration( + input: AudioDurationInput +): Promise { + return AudioDecoder.getInstance().getAudioDurationInstance(input); +} diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index 90916ccdd..53b4b8acd 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -4,13 +4,22 @@ import { NotSupportedError, } from '../errors'; import { IBaseAudioContext } from '../jsi-interfaces'; -import { AudioWorkletRuntime, ContextState, DecodeDataInput } from '../types'; +import { + AudioDurationInput, + AudioWorkletRuntime, + ContextState, + DecodeDataInput, +} from '../types'; import { assertWorkletsEnabled } from '../utils'; import AnalyserNode from './AnalyserNode'; import AudioBuffer from './AudioBuffer'; import AudioBufferQueueSourceNode from './AudioBufferQueueSourceNode'; import AudioBufferSourceNode from './AudioBufferSourceNode'; -import { decodeAudioData, decodePCMInBase64 } from './AudioDecoder'; +import { + decodeAudioData, + decodePCMInBase64, + getAudioDuration, +} from './AudioDecoder'; import AudioDestinationNode from './AudioDestinationNode'; import BiquadFilterNode from './BiquadFilterNode'; import ConstantSourceNode from './ConstantSourceNode'; @@ -68,6 +77,10 @@ export default class BaseAudioContext { ); } + public async getAudioDuration(input: AudioDurationInput): Promise { + return await getAudioDuration(input); + } + createWorkletNode( callback: (audioData: Array, channelCount: number) => void, bufferLength: number, diff --git a/packages/react-native-audio-api/src/jsi-interfaces.ts b/packages/react-native-audio-api/src/jsi-interfaces.ts index baeb2f50c..d96c35549 100644 --- a/packages/react-native-audio-api/src/jsi-interfaces.ts +++ b/packages/react-native-audio-api/src/jsi-interfaces.ts @@ -370,6 +370,7 @@ export interface IAudioDecoder { sourcePath: string, sampleRate?: number ) => Promise; + getDurationWithFilePath: (sourcePath: string) => Promise; decodeWithPCMInBase64: ( b64: string, inputSampleRate: number, diff --git a/packages/react-native-audio-api/src/mock/index.ts b/packages/react-native-audio-api/src/mock/index.ts index fbfc53849..a35cd0105 100644 --- a/packages/react-native-audio-api/src/mock/index.ts +++ b/packages/react-native-audio-api/src/mock/index.ts @@ -23,6 +23,8 @@ import { ConstantSourceOptions, ConvolverOptions, DelayOptions, + AudioDurationInput, + DecodeDataInput, GainOptions, OscillatorOptions, PeriodicWaveOptions, @@ -588,6 +590,10 @@ class BaseAudioContextMock { ); } + getAudioDuration(_input: AudioDurationInput): Promise { + return getAudioDuration(_input); + } + createAnalyser(options?: AnalyserOptions): AnalyserNodeMock { return new AnalyserNodeMock(this, options); } @@ -867,6 +873,49 @@ const decodePCMInBase64 = (_base64Data: string): Promise => { ); }; +const getAudioDuration = (_input: DecodeDataInput): Promise => { + if (_input instanceof ArrayBuffer) { + return Promise.reject( + new AudioApiErrorMock( + 'ArrayBuffer duration probing is not currently supported.' + ) + ); + } + + if ( + typeof _input === 'string' && + (_input.startsWith('http://') || _input.startsWith('https://')) + ) { + return Promise.reject( + new AudioApiErrorMock( + 'Remote source duration probing is not currently supported.' + ) + ); + } + + if ( + typeof _input === 'string' && + _input.startsWith('data:audio/') && + _input.includes(';base64,') + ) { + return Promise.reject( + new AudioApiErrorMock( + 'Base64 source decoding is not currently supported, to decode raw PCM base64 strings use decodePCMInBase64 method.' + ) + ); + } + + if (typeof _input === 'string' && _input.startsWith('blob:')) { + return Promise.reject( + new AudioApiErrorMock( + 'Data Blob string decoding is not currently supported.' + ) + ); + } + + return Promise.resolve(1); +}; + const changePlaybackSpeed = ( buffer: AudioBufferMock, _speed: number @@ -1069,6 +1118,7 @@ export { concatAudioFiles, decodeAudioData, decodePCMInBase64, + getAudioDuration, setMockSystemVolume, useSystemVolume, }; @@ -1168,6 +1218,7 @@ export default { // Functions decodeAudioData, decodePCMInBase64, + getAudioDuration, changePlaybackSpeed, concatAudioFiles, useSystemVolume, diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts index 891568dac..2707bec5f 100644 --- a/packages/react-native-audio-api/src/types.ts +++ b/packages/react-native-audio-api/src/types.ts @@ -252,6 +252,7 @@ export interface WaveShaperOptions extends AudioNodeOptions { } export type DecodeDataInput = number | string | ArrayBuffer; +export type AudioDurationInput = string; export interface AudioRecorderStartOptions { fileNameOverride?: string; diff --git a/packages/react-native-audio-api/src/web-core/AudioContext.web.ts b/packages/react-native-audio-api/src/web-core/AudioContext.web.ts index 235e857db..2456a0c24 100644 --- a/packages/react-native-audio-api/src/web-core/AudioContext.web.ts +++ b/packages/react-native-audio-api/src/web-core/AudioContext.web.ts @@ -1,5 +1,10 @@ import { InvalidAccessError, NotSupportedError } from '../errors'; -import { AudioContextOptions, ContextState, DecodeDataInput } from '../types'; +import { + AudioContextOptions, + AudioDurationInput, + ContextState, + DecodeDataInput, +} from '../types'; import AnalyserNode from './AnalyserNode.web'; import AudioBuffer from './AudioBuffer.web'; import AudioBufferSourceNode from './AudioBufferSourceNode.web'; @@ -16,6 +21,7 @@ import PeriodicWave from './PeriodicWave.web'; import StereoPannerNode from './StereoPannerNode.web'; import ConstantSourceNode from './ConstantSourceNode.web'; import WaveShaperNode from './WaveShaperNode.web'; +import { getAudioDuration } from './AudioDecoder.web'; export default class AudioContext implements BaseAudioContext { readonly context: globalThis.AudioContext; @@ -168,6 +174,10 @@ export default class AudioContext implements BaseAudioContext { throw new TypeError('Unsupported source for decodeAudioData: ' + source); } + async getAudioDuration(input: AudioDurationInput): Promise { + return await getAudioDuration(input); + } + async close(): Promise { await this.context.close(); } diff --git a/packages/react-native-audio-api/src/web-core/AudioDecoder.web.ts b/packages/react-native-audio-api/src/web-core/AudioDecoder.web.ts index 3c7e796df..0182c447a 100644 --- a/packages/react-native-audio-api/src/web-core/AudioDecoder.web.ts +++ b/packages/react-native-audio-api/src/web-core/AudioDecoder.web.ts @@ -1,5 +1,5 @@ import { AudioApiError } from '../errors'; -import { DecodeDataInput } from '../types'; +import { AudioDurationInput, DecodeDataInput } from '../types'; import { base64ToArrayBuffer } from '../utils'; import AudioBuffer from './AudioBuffer.web'; import OfflineAudioContext from './OfflineAudioContext.web'; @@ -122,6 +122,20 @@ export default class AudioDecoder { throw new AudioApiError('Failed to decode PCM data.'); } } + + public getAudioDurationInstance(input: DecodeDataInput): Promise { + if (input instanceof ArrayBuffer) { + return Promise.reject( + new AudioApiError( + 'ArrayBuffer duration probing is not currently supported.' + ) + ); + } + + return Promise.reject( + new AudioApiError('getAudioDuration is not supported on web.') + ); + } } export async function decodeAudioData( @@ -149,3 +163,7 @@ export async function decodePCMInBase64( isInterleaved ); } + +export function getAudioDuration(input: AudioDurationInput): Promise { + return AudioDecoder.getInstance().getAudioDurationInstance(input); +} diff --git a/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts b/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts index 84850534b..f60b59e7d 100644 --- a/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts +++ b/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts @@ -1,4 +1,4 @@ -import { ContextState } from '../types'; +import { AudioDurationInput, ContextState } from '../types'; import AnalyserNode from './AnalyserNode.web'; import AudioBuffer from './AudioBuffer.web'; import AudioBufferSourceNode from './AudioBufferSourceNode.web'; @@ -47,4 +47,5 @@ export default interface BaseAudioContext { arrayBuffer: ArrayBuffer, fetchOptions?: RequestInit ): Promise; + getAudioDuration(input: AudioDurationInput): Promise; } diff --git a/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts b/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts index 0b7828a1d..a1ac691e1 100644 --- a/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts +++ b/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts @@ -1,4 +1,8 @@ -import { ContextState, OfflineAudioContextOptions } from '../types'; +import { + AudioDurationInput, + ContextState, + OfflineAudioContextOptions, +} from '../types'; import { InvalidAccessError, NotSupportedError } from '../errors'; import BaseAudioContext from './BaseAudioContext.web'; import AnalyserNode from './AnalyserNode.web'; @@ -16,6 +20,7 @@ import WaveShaperNode from './WaveShaperNode.web'; import ConvolverNode from './ConvolverNode.web'; import DelayNode from './DelayNode.web'; +import { getAudioDuration } from './AudioDecoder.web'; export default class OfflineAudioContext implements BaseAudioContext { readonly context: globalThis.OfflineAudioContext; @@ -150,6 +155,10 @@ export default class OfflineAudioContext implements BaseAudioContext { return new AudioBuffer(await this.context.decodeAudioData(arrayBuffer)); } + async getAudioDuration(input: AudioDurationInput): Promise { + return await getAudioDuration(input); + } + async startRendering(): Promise { return new AudioBuffer(await this.context.startRendering()); } diff --git a/packages/react-native-audio-api/tests/audio-decoder.test.ts b/packages/react-native-audio-api/tests/audio-decoder.test.ts new file mode 100644 index 000000000..f6d711869 --- /dev/null +++ b/packages/react-native-audio-api/tests/audio-decoder.test.ts @@ -0,0 +1,131 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + +import type { IBaseAudioContext, IAudioDecoder } from '../src/jsi-interfaces'; + +type AudioDecoderExports = typeof import('../src/core/AudioDecoder'); + +jest.mock('react-native', () => ({ + Image: { + resolveAssetSource: jest.fn((input: number) => ({ + uri: `file:///asset-${input}.wav`, + })), + }, + Platform: { + OS: 'ios', + }, + TurboModuleRegistry: { + get: jest.fn(() => ({ + install: jest.fn(), + readAndroidReleaseAssetBytesAsBase64: jest.fn(), + })), + }, +})); + +const createNativeAudioNode = () => ({ + numberOfInputs: 0, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'explicit', + channelInterpretation: 'speakers', + connect: jest.fn(), + disconnect: jest.fn(), +}); + +const createDecoder = (duration: number = 12.5) => + ({ + decodeWithMemoryBlock: jest.fn(), + decodeWithFilePath: jest.fn(), + getDurationWithFilePath: jest.fn().mockResolvedValue(duration), + decodeWithPCMInBase64: jest.fn(), + }) as unknown as jest.Mocked; + +const installDecoder = (decoder: IAudioDecoder) => { + globalThis.createAudioDecoder = jest.fn(() => decoder); +}; + +const loadAudioDecoder = (): AudioDecoderExports => { + return require('../src/core/AudioDecoder') as AudioDecoderExports; +}; + +describe('getAudioDuration', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('routes local file paths to the native duration decoder', async () => { + const decoder = createDecoder(); + installDecoder(decoder); + + const { getAudioDuration } = loadAudioDecoder(); + + await expect( + getAudioDuration('file:///tmp/audio%20file.wav') + ).resolves.toBe(12.5); + expect(decoder.getDurationWithFilePath).toHaveBeenCalledWith( + '/tmp/audio file.wav' + ); + expect(decoder.decodeWithFilePath).not.toHaveBeenCalled(); + }); + + it('rejects ArrayBuffer input without decoding audio data', async () => { + const decoder = createDecoder(); + installDecoder(decoder); + + const { getAudioDuration } = loadAudioDecoder(); + + await expect( + getAudioDuration(new ArrayBuffer(8) as unknown as string) + ).rejects.toThrow( + 'ArrayBuffer duration probing is not currently supported.' + ); + expect(decoder.getDurationWithFilePath).not.toHaveBeenCalled(); + }); + + it('rejects asset module ids without resolving bundled assets', async () => { + const decoder = createDecoder(); + installDecoder(decoder); + + const { getAudioDuration } = loadAudioDecoder(); + + await expect(getAudioDuration(1 as unknown as string)).rejects.toThrow( + 'Input must be a local file path or file:// URI.' + ); + expect(decoder.getDurationWithFilePath).not.toHaveBeenCalled(); + }); + + it('rejects remote URL input without fetching or decoding', async () => { + const decoder = createDecoder(); + installDecoder(decoder); + + const { getAudioDuration } = loadAudioDecoder(); + + await expect( + getAudioDuration('https://example.com/audio.mp3') + ).rejects.toThrow( + 'Remote source duration probing is not currently supported.' + ); + expect(decoder.getDurationWithFilePath).not.toHaveBeenCalled(); + }); + + it('exposes duration probing through BaseAudioContext', async () => { + const decoder = createDecoder(3.25); + installDecoder(decoder); + + const BaseAudioContext = require('../src/core/BaseAudioContext') + .default as typeof import('../src/core/BaseAudioContext').default; + + const context = new BaseAudioContext({ + destination: createNativeAudioNode(), + sampleRate: 44100, + currentTime: 0, + state: 'running', + } as unknown as IBaseAudioContext); + + await expect(context.getAudioDuration('/tmp/audio.wav')).resolves.toBe( + 3.25 + ); + expect(decoder.getDurationWithFilePath).toHaveBeenCalledWith( + '/tmp/audio.wav' + ); + }); +}); diff --git a/packages/react-native-audio-api/tests/mock.test.ts b/packages/react-native-audio-api/tests/mock.test.ts index ea045cef9..bb61e8792 100644 --- a/packages/react-native-audio-api/tests/mock.test.ts +++ b/packages/react-native-audio-api/tests/mock.test.ts @@ -336,6 +336,21 @@ describe('React Native Audio API Mocks', () => { expect(buffer).toBeInstanceOf(MockAPI.AudioBuffer); }); + it('should get audio duration', async () => { + await expect( + MockAPI.getAudioDuration('file:///tmp/audio.wav') + ).resolves.toBe(1); + }); + + it('should reject unsupported audio duration inputs', async () => { + await expect( + MockAPI.getAudioDuration('data:audio/wav;base64,AAAA') + ).rejects.toThrow('Base64 source decoding is not currently supported'); + await expect(MockAPI.getAudioDuration('blob:audio')).rejects.toThrow( + 'Data Blob string decoding is not currently supported.' + ); + }); + it('should change playback speed', async () => { const context = new MockAPI.AudioContext(); const inputBuffer = context.createBuffer(2, 1024, 44100); From a0639a444b3179688f309d873d66d753c20fc5dc Mon Sep 17 00:00:00 2001 From: Traviskn Date: Sun, 28 Jun 2026 14:10:25 -0600 Subject: [PATCH 2/2] refactor: add web support for get audio duration --- .../docs/core/base-audio-context.mdx | 22 ---- packages/audiodocs/docs/utils/decoding.mdx | 13 +- .../src/core/BaseAudioContext.ts | 17 +-- .../react-native-audio-api/src/mock/index.ts | 5 - .../src/web-core/AudioContext.web.ts | 12 +- .../src/web-core/AudioDecoder.web.ts | 70 ++++++++++- .../src/web-core/BaseAudioContext.web.ts | 3 +- .../src/web-core/OfflineAudioContext.web.ts | 11 +- .../tests/audio-decoder.test.ts | 119 ++++++++++++++---- 9 files changed, 172 insertions(+), 100 deletions(-) diff --git a/packages/audiodocs/docs/core/base-audio-context.mdx b/packages/audiodocs/docs/core/base-audio-context.mdx index 95b7e39ed..e0f31c959 100644 --- a/packages/audiodocs/docs/core/base-audio-context.mdx +++ b/packages/audiodocs/docs/core/base-audio-context.mdx @@ -287,27 +287,6 @@ const buffer = await audioContext.decodeAudioData(url); ``` -### `getAudioDuration` - -Reads the duration of a local audio file without decoding the full file into an [`AudioBuffer`](/docs/sources/audio-buffer). -Use this when you only need the encoded file duration. Use [`decodeAudioData`](/docs/core/base-audio-context#decodeaudiodata) when you need sample data for playback or processing. - -This API currently supports local file paths and `file://` URIs. Remote URLs, asset module ids, `ArrayBuffer` input, base64 data URLs, and blob URLs are rejected explicitly. -There is no `sampleRate` option because the returned duration belongs to the encoded file and does not depend on the context's output sample rate. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `input` | `string` | Local file path or `file://` URI. | - -#### Returns `Promise` with the duration in seconds. - -
-Example reading local file duration -```tsx -const duration = await audioContext.getAudioDuration('file:///tmp/recording.wav'); -``` -
- ### `decodePCMInBase64` Decodes base64-encoded PCM audio data. @@ -351,4 +330,3 @@ The audio context is running normally. - `closed` The audio context has been closed (with [`close`](/docs/core/audio-context#close) method). - diff --git a/packages/audiodocs/docs/utils/decoding.mdx b/packages/audiodocs/docs/utils/decoding.mdx index 21a97078d..776d22100 100644 --- a/packages/audiodocs/docs/utils/decoding.mdx +++ b/packages/audiodocs/docs/utils/decoding.mdx @@ -2,7 +2,7 @@ sidebar_position: 1 --- -import { Optional, MobileOnly } from '@site/src/components/Badges'; +import { Optional } from '@site/src/components/Badges'; # Decoding @@ -85,17 +85,18 @@ const buffer = await decodeAudioData(url); ``` -### `getAudioDuration` +### `getAudioDuration` -Reads the duration of a local audio file without decoding the full file into an [`AudioBuffer`](/docs/sources/audio-buffer). +Reads the duration of an audio source without decoding the full file into an [`AudioBuffer`](/docs/sources/audio-buffer). Use this when you only need the encoded file duration. Use [`decodeAudioData`](/docs/utils/decoding#decodeaudiodata) when you need sample data for playback or processing. -This API currently supports local file paths and `file://` URIs. Remote URLs, asset module ids, `ArrayBuffer` input, base64 data URLs, and blob URLs are rejected explicitly. -There is no `sampleRate` option because the returned duration belongs to the encoded file and does not depend on a caller-selected output sample rate. +On mobile, this API supports local file paths and `file://` URIs. Remote URLs, asset module ids, `ArrayBuffer` input, base64 data URLs, and blob URLs are rejected explicitly. +On web, this API uses browser audio metadata loading and supports sources the browser can load, such as remote URLs, relative URLs, blob URLs, and data URLs. +There is no `sampleRate` option because the returned duration belongs to the encoded source and does not depend on a caller-selected output sample rate. | Parameter | Type | Description | |-----------|------|-------------| -| `input` | `string` | Local file path or `file://` URI. | +| `input` | `string` | Audio source URL or path. | #### Returns `Promise` with the duration in seconds. diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index 53b4b8acd..90916ccdd 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -4,22 +4,13 @@ import { NotSupportedError, } from '../errors'; import { IBaseAudioContext } from '../jsi-interfaces'; -import { - AudioDurationInput, - AudioWorkletRuntime, - ContextState, - DecodeDataInput, -} from '../types'; +import { AudioWorkletRuntime, ContextState, DecodeDataInput } from '../types'; import { assertWorkletsEnabled } from '../utils'; import AnalyserNode from './AnalyserNode'; import AudioBuffer from './AudioBuffer'; import AudioBufferQueueSourceNode from './AudioBufferQueueSourceNode'; import AudioBufferSourceNode from './AudioBufferSourceNode'; -import { - decodeAudioData, - decodePCMInBase64, - getAudioDuration, -} from './AudioDecoder'; +import { decodeAudioData, decodePCMInBase64 } from './AudioDecoder'; import AudioDestinationNode from './AudioDestinationNode'; import BiquadFilterNode from './BiquadFilterNode'; import ConstantSourceNode from './ConstantSourceNode'; @@ -77,10 +68,6 @@ export default class BaseAudioContext { ); } - public async getAudioDuration(input: AudioDurationInput): Promise { - return await getAudioDuration(input); - } - createWorkletNode( callback: (audioData: Array, channelCount: number) => void, bufferLength: number, diff --git a/packages/react-native-audio-api/src/mock/index.ts b/packages/react-native-audio-api/src/mock/index.ts index a35cd0105..d1e58abdc 100644 --- a/packages/react-native-audio-api/src/mock/index.ts +++ b/packages/react-native-audio-api/src/mock/index.ts @@ -23,7 +23,6 @@ import { ConstantSourceOptions, ConvolverOptions, DelayOptions, - AudioDurationInput, DecodeDataInput, GainOptions, OscillatorOptions, @@ -590,10 +589,6 @@ class BaseAudioContextMock { ); } - getAudioDuration(_input: AudioDurationInput): Promise { - return getAudioDuration(_input); - } - createAnalyser(options?: AnalyserOptions): AnalyserNodeMock { return new AnalyserNodeMock(this, options); } diff --git a/packages/react-native-audio-api/src/web-core/AudioContext.web.ts b/packages/react-native-audio-api/src/web-core/AudioContext.web.ts index 2456a0c24..235e857db 100644 --- a/packages/react-native-audio-api/src/web-core/AudioContext.web.ts +++ b/packages/react-native-audio-api/src/web-core/AudioContext.web.ts @@ -1,10 +1,5 @@ import { InvalidAccessError, NotSupportedError } from '../errors'; -import { - AudioContextOptions, - AudioDurationInput, - ContextState, - DecodeDataInput, -} from '../types'; +import { AudioContextOptions, ContextState, DecodeDataInput } from '../types'; import AnalyserNode from './AnalyserNode.web'; import AudioBuffer from './AudioBuffer.web'; import AudioBufferSourceNode from './AudioBufferSourceNode.web'; @@ -21,7 +16,6 @@ import PeriodicWave from './PeriodicWave.web'; import StereoPannerNode from './StereoPannerNode.web'; import ConstantSourceNode from './ConstantSourceNode.web'; import WaveShaperNode from './WaveShaperNode.web'; -import { getAudioDuration } from './AudioDecoder.web'; export default class AudioContext implements BaseAudioContext { readonly context: globalThis.AudioContext; @@ -174,10 +168,6 @@ export default class AudioContext implements BaseAudioContext { throw new TypeError('Unsupported source for decodeAudioData: ' + source); } - async getAudioDuration(input: AudioDurationInput): Promise { - return await getAudioDuration(input); - } - async close(): Promise { await this.context.close(); } diff --git a/packages/react-native-audio-api/src/web-core/AudioDecoder.web.ts b/packages/react-native-audio-api/src/web-core/AudioDecoder.web.ts index 0182c447a..f43021106 100644 --- a/packages/react-native-audio-api/src/web-core/AudioDecoder.web.ts +++ b/packages/react-native-audio-api/src/web-core/AudioDecoder.web.ts @@ -123,6 +123,66 @@ export default class AudioDecoder { } } + private loadDurationFromAudioElement(input: string): Promise { + if (typeof globalThis.Audio !== 'function') { + return Promise.reject( + new AudioApiError('getAudioDuration requires HTMLAudioElement support.') + ); + } + + return new Promise((resolve, reject) => { + const audio = new globalThis.Audio(); + let settled = false; + + const cleanup = () => { + audio.removeEventListener('loadedmetadata', handleLoadedMetadata); + audio.removeEventListener('error', handleError); + audio.src = ''; + audio.load(); + }; + + const settle = (callback: () => void) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + callback(); + }; + + const handleLoadedMetadata = () => { + const duration = audio.duration; + + if (Number.isFinite(duration) && duration >= 0) { + settle(() => resolve(duration)); + return; + } + + settle(() => + reject(new AudioApiError('Audio duration metadata is unavailable')) + ); + }; + + const handleError = () => { + const errorMessage = audio.error?.message + ? `: ${audio.error.message}` + : ''; + settle(() => + reject( + new AudioApiError(`Failed to load audio metadata${errorMessage}`) + ) + ); + }; + + audio.preload = 'metadata'; + audio.addEventListener('loadedmetadata', handleLoadedMetadata); + audio.addEventListener('error', handleError); + audio.src = input; + audio.load(); + }); + } + public getAudioDurationInstance(input: DecodeDataInput): Promise { if (input instanceof ArrayBuffer) { return Promise.reject( @@ -132,9 +192,13 @@ export default class AudioDecoder { ); } - return Promise.reject( - new AudioApiError('getAudioDuration is not supported on web.') - ); + if (typeof input !== 'string') { + return Promise.reject( + new TypeError('Input must be a valid string URL path.') + ); + } + + return this.loadDurationFromAudioElement(input); } } diff --git a/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts b/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts index f60b59e7d..84850534b 100644 --- a/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts +++ b/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts @@ -1,4 +1,4 @@ -import { AudioDurationInput, ContextState } from '../types'; +import { ContextState } from '../types'; import AnalyserNode from './AnalyserNode.web'; import AudioBuffer from './AudioBuffer.web'; import AudioBufferSourceNode from './AudioBufferSourceNode.web'; @@ -47,5 +47,4 @@ export default interface BaseAudioContext { arrayBuffer: ArrayBuffer, fetchOptions?: RequestInit ): Promise; - getAudioDuration(input: AudioDurationInput): Promise; } diff --git a/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts b/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts index a1ac691e1..0b7828a1d 100644 --- a/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts +++ b/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts @@ -1,8 +1,4 @@ -import { - AudioDurationInput, - ContextState, - OfflineAudioContextOptions, -} from '../types'; +import { ContextState, OfflineAudioContextOptions } from '../types'; import { InvalidAccessError, NotSupportedError } from '../errors'; import BaseAudioContext from './BaseAudioContext.web'; import AnalyserNode from './AnalyserNode.web'; @@ -20,7 +16,6 @@ import WaveShaperNode from './WaveShaperNode.web'; import ConvolverNode from './ConvolverNode.web'; import DelayNode from './DelayNode.web'; -import { getAudioDuration } from './AudioDecoder.web'; export default class OfflineAudioContext implements BaseAudioContext { readonly context: globalThis.OfflineAudioContext; @@ -155,10 +150,6 @@ export default class OfflineAudioContext implements BaseAudioContext { return new AudioBuffer(await this.context.decodeAudioData(arrayBuffer)); } - async getAudioDuration(input: AudioDurationInput): Promise { - return await getAudioDuration(input); - } - async startRendering(): Promise { return new AudioBuffer(await this.context.startRendering()); } diff --git a/packages/react-native-audio-api/tests/audio-decoder.test.ts b/packages/react-native-audio-api/tests/audio-decoder.test.ts index f6d711869..a7b7c9dbe 100644 --- a/packages/react-native-audio-api/tests/audio-decoder.test.ts +++ b/packages/react-native-audio-api/tests/audio-decoder.test.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -import type { IBaseAudioContext, IAudioDecoder } from '../src/jsi-interfaces'; +import type { IAudioDecoder } from '../src/jsi-interfaces'; type AudioDecoderExports = typeof import('../src/core/AudioDecoder'); +type WebAudioDecoderExports = typeof import('../src/web-core/AudioDecoder.web'); jest.mock('react-native', () => ({ Image: { @@ -21,16 +22,6 @@ jest.mock('react-native', () => ({ }, })); -const createNativeAudioNode = () => ({ - numberOfInputs: 0, - numberOfOutputs: 1, - channelCount: 2, - channelCountMode: 'explicit', - channelInterpretation: 'speakers', - connect: jest.fn(), - disconnect: jest.fn(), -}); - const createDecoder = (duration: number = 12.5) => ({ decodeWithMemoryBlock: jest.fn(), @@ -47,11 +38,66 @@ const loadAudioDecoder = (): AudioDecoderExports => { return require('../src/core/AudioDecoder') as AudioDecoderExports; }; +const loadWebAudioDecoder = (): WebAudioDecoderExports => { + return require('../src/web-core/AudioDecoder.web') as WebAudioDecoderExports; +}; + +const installMockAudio = ({ + duration, + errorMessage, + resetDurationOnCleanup = false, +}: { + duration: number; + errorMessage?: string; + resetDurationOnCleanup?: boolean; +}) => { + const listeners = new Map void>(); + const audio = { + duration, + error: errorMessage ? { message: errorMessage } : null, + preload: '', + src: '', + addEventListener: jest.fn((eventName: string, listener: () => void) => { + listeners.set(eventName, listener); + }), + removeEventListener: jest.fn((eventName: string) => { + listeners.delete(eventName); + }), + load: jest.fn(() => { + if (audio.src === '') { + if (resetDurationOnCleanup) { + audio.duration = Number.NaN; + } + return; + } + + const eventName = errorMessage ? 'error' : 'loadedmetadata'; + listeners.get(eventName)?.(); + }), + } as unknown as HTMLAudioElement; + const AudioMock = jest.fn(() => audio); + + (globalThis as unknown as { Audio: typeof Audio }).Audio = + AudioMock as unknown as typeof Audio; + + return { audio, AudioMock }; +}; + describe('getAudioDuration', () => { + const originalAudio = globalThis.Audio; + beforeEach(() => { jest.resetModules(); }); + afterEach(() => { + if (originalAudio) { + (globalThis as unknown as { Audio: typeof Audio }).Audio = originalAudio; + } else { + delete (globalThis as unknown as { Audio?: typeof Audio }).Audio; + } + }); + it('routes local file paths to the native duration decoder', async () => { const decoder = createDecoder(); installDecoder(decoder); @@ -107,25 +153,46 @@ describe('getAudioDuration', () => { expect(decoder.getDurationWithFilePath).not.toHaveBeenCalled(); }); - it('exposes duration probing through BaseAudioContext', async () => { - const decoder = createDecoder(3.25); - installDecoder(decoder); + it('reads duration from web audio metadata', async () => { + const { audio, AudioMock } = installMockAudio({ + duration: 4.75, + resetDurationOnCleanup: true, + }); + const { getAudioDuration } = loadWebAudioDecoder(); - const BaseAudioContext = require('../src/core/BaseAudioContext') - .default as typeof import('../src/core/BaseAudioContext').default; + await expect(getAudioDuration('/audio/file.mp3')).resolves.toBe(4.75); - const context = new BaseAudioContext({ - destination: createNativeAudioNode(), - sampleRate: 44100, - currentTime: 0, - state: 'running', - } as unknown as IBaseAudioContext); + expect(AudioMock).toHaveBeenCalledTimes(1); + expect(audio.preload).toBe('metadata'); + expect(audio.addEventListener).toHaveBeenCalledWith( + 'loadedmetadata', + expect.any(Function) + ); + expect(audio.src).toBe(''); + }); + + it('resolves zero-duration web audio metadata', async () => { + installMockAudio({ duration: 0 }); + const { getAudioDuration } = loadWebAudioDecoder(); + + await expect(getAudioDuration('/audio/empty.wav')).resolves.toBe(0); + }); - await expect(context.getAudioDuration('/tmp/audio.wav')).resolves.toBe( - 3.25 + it('rejects web audio sources when metadata has no finite duration', async () => { + installMockAudio({ duration: Infinity }); + const { getAudioDuration } = loadWebAudioDecoder(); + + await expect(getAudioDuration('/audio/live-stream.mp3')).rejects.toThrow( + 'Audio duration metadata is unavailable' ); - expect(decoder.getDurationWithFilePath).toHaveBeenCalledWith( - '/tmp/audio.wav' + }); + + it('rejects web audio sources when metadata loading fails', async () => { + installMockAudio({ duration: 0, errorMessage: 'unsupported source' }); + const { getAudioDuration } = loadWebAudioDecoder(); + + await expect(getAudioDuration('/audio/file.mp3')).rejects.toThrow( + 'Failed to load audio metadata: unsupported source' ); }); });