Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/audiodocs/docs/core/base-audio-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -330,4 +330,3 @@ The audio context is running normally.
- `closed`

The audio context has been closed (with [`close`](/docs/core/audio-context#close) method).

30 changes: 27 additions & 3 deletions packages/audiodocs/docs/utils/decoding.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
sidebar_position: 1
---

import { Optional, MobileOnly } from '@site/src/components/Badges';
import { Optional } 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`.
Expand Down Expand Up @@ -85,6 +85,30 @@ const buffer = await decodeAudioData(url);
```
</details>

### `getAudioDuration`

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.

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` | Audio source URL or path. |

#### Returns `Promise<number>` with the duration in seconds.

<details>
<summary>Example reading local file duration</summary>
```tsx
import { getAudioDuration } from 'react-native-audio-api';

const duration = await getAudioDuration('file:///tmp/recording.wav');
```
</details>

### `decodePCMInBase64`

Decodes base64-encoded PCM audio data.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down Expand Up @@ -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<jsi::Value, std::string> {
return result.unwrap_err();
};
}

const auto duration = result.unwrap();
return [duration](jsi::Runtime &runtime) -> std::variant<jsi::Value, std::string> {
return jsi::Value(duration);
};
});

return promise;
}

JSI_HOST_FUNCTION_IMPL(AudioDecoderHostObject, decodeWithPCMInBase64) {
auto b64 = args[0].getString(runtime).utf8(runtime);
auto inputSampleRate = static_cast<float>(args[1].getNumber());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class AudioDecoderHostObject : public JsiHostObject {
const std::shared_ptr<react::CallInvoker> &callInvoker);
JSI_HOST_FUNCTION_DECL(decodeWithMemoryBlock);
JSI_HOST_FUNCTION_DECL(decodeWithFilePath);
JSI_HOST_FUNCTION_DECL(getDurationWithFilePath);
JSI_HOST_FUNCTION_DECL(decodeWithPCMInBase64);

private:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <memory>
Expand Down Expand Up @@ -108,6 +109,19 @@ bool pathHasExtension(const std::string &path, const std::vector<std::string> &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<int>(sampleRate);

Expand Down Expand Up @@ -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<int>(sampleRate);
const AudioFormat format = detectAudioFormat(data, size);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
namespace audioapi::audiodecoding {

using AudioBufferResult = Result<std::shared_ptr<AudioBuffer>, std::string>;
using AudioDurationResult = Result<float, std::string>;

[[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(
Expand All @@ -30,6 +32,8 @@ decodeWithMemoryBlock(const void *data, size_t size, float sampleRate);
const std::string &path,
const std::vector<std::string> &extensions);

[[nodiscard]] bool isValidDuration(float duration);

[[nodiscard]] inline bool needsFFmpeg(AudioFormat format) {
return format == AudioFormat::MP4 || format == AudioFormat::M4A || format == AudioFormat::AAC;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#include <audioapi/core/utils/AudioDecoding.hpp>
#include <audioapi/libs/miniaudio/miniaudio.h>
#include <gtest/gtest.h>

#include <cmath>
#include <cstdio>
#include <limits>
#include <string>
#include <vector>

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<float> &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<ma_uint64>(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<float>::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<float>(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
6 changes: 5 additions & 1 deletion packages/react-native-audio-api/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/react-native-audio-api/src/api.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
default as AudioDecoder,
decodeAudioData,
decodePCMInBase64,
getAudioDuration,
} from './web-core/AudioDecoder.web';

export * from './web-core/custom';
Expand Down
39 changes: 38 additions & 1 deletion packages/react-native-audio-api/src/core/AudioDecoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -126,6 +126,13 @@ class AudioDecoder {
return new AudioBuffer(buffer);
}

private async getDurationFromLocalFile(
stringSource: string
): Promise<number> {
const filePath = this.resolveLocalFilePath(stringSource);
return await this.decoder.getDurationWithFilePath(filePath);
}

public static getInstance(): AudioDecoder {
if (!AudioDecoder.instance) {
AudioDecoder.instance = new AudioDecoder();
Expand Down Expand Up @@ -166,6 +173,30 @@ class AudioDecoder {
);
return new AudioBuffer(buffer);
}

public async getAudioDurationInstance(
input: DecodeDataInput
): Promise<number> {
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(
Expand Down Expand Up @@ -193,3 +224,9 @@ export async function decodePCMInBase64(
isInterleaved
);
}

export async function getAudioDuration(
input: AudioDurationInput
): Promise<number> {
return AudioDecoder.getInstance().getAudioDurationInstance(input);
}
1 change: 1 addition & 0 deletions packages/react-native-audio-api/src/jsi-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ export interface IAudioDecoder {
sourcePath: string,
sampleRate?: number
) => Promise<IAudioBuffer>;
getDurationWithFilePath: (sourcePath: string) => Promise<number>;
decodeWithPCMInBase64: (
b64: string,
inputSampleRate: number,
Expand Down
Loading