Skip to content
Draft
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: 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 @@ -19,6 +19,7 @@ export { default as ConstantSourceNode } from './web-core/ConstantSourceNode';
export { default as ConvolverNode } from './web-core/ConvolverNode';
export { default as PeriodicWave } from './web-core/PeriodicWave';
export { default as WaveShaperNode } from './web-core/WaveShaperNode';
export { decodeAudioData, decodePCMInBase64 } from './web-core/AudioDecoder';

export * from './web-core/custom';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const Audio = React.forwardRef<AudioTagHandle, AudioProps>((props, ref) => {
fileSourceRef.current?.dispose();
fileSourceRef.current = null;
};
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps

const effectiveMutedState = useMemo(() => {
return mutedState ?? muted;
Expand Down
8 changes: 8 additions & 0 deletions packages/react-native-audio-api/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AudioBuffer from './core/AudioBuffer';
import PeriodicWave from './core/PeriodicWave';
import { IAudioBuffer } from './interfaces';
import AudioBufferWeb from './web-core/AudioBuffer';

export type Result<T> =
| ({ status: 'success' } & T)
Expand Down Expand Up @@ -163,6 +164,13 @@ export interface AudioBufferSourceOptions extends BaseAudioBufferSourceOptions {
loopEnd?: number;
}

export interface AudioBufferSourceOptionsWeb extends BaseAudioBufferSourceOptions {
buffer?: AudioBufferWeb;
loop?: boolean;
loopStart?: number;
loopEnd?: number;
}

// options that are passed to c++ layer
export interface IAudioBufferSourceOptions extends BaseAudioBufferSourceOptions {
buffer?: IAudioBuffer;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import BaseAudioContext from './BaseAudioContext';
import AudioNode from './AudioNode';

import { clamp } from '../utils';
import { AudioBufferSourceOptions } from '../types';
import { AudioBufferSourceOptionsWeb } from '../types';
import { globalWasmPromise, globalTag } from './custom/LoadCustomWasm';
import { LoadCustomWasm } from './custom';

interface ScheduleOptions {
rate?: number;
Expand Down Expand Up @@ -214,10 +215,11 @@ class AudioBufferSourceNodeStretcher implements IAudioAPIBufferSourceNodeWeb {
private _buffer: AudioBuffer | null = null;
private bufferHasBeenSet: boolean = false;

constructor(context: BaseAudioContext) {
constructor(context: BaseAudioContext, options: AudioBufferSourceOptionsWeb) {
const promise = async () => {
await LoadCustomWasm('/react-native-audio-api');
await globalWasmPromise;
return window[globalTag](new window.AudioContext());
return window[globalTag](context.context);
};
this.context = context;
this.stretcherPromise = promise();
Expand All @@ -227,7 +229,7 @@ class AudioBufferSourceNodeStretcher implements IAudioAPIBufferSourceNodeWeb {

this.detune = new AudioParam(
new IStretcherNodeAudioParam(
0,
options.detune ?? 0,
this.setDetune.bind(this),
'a-rate',
-1200,
Expand All @@ -239,7 +241,7 @@ class AudioBufferSourceNodeStretcher implements IAudioAPIBufferSourceNodeWeb {

this.playbackRate = new AudioParam(
new IStretcherNodeAudioParam(
1,
options.playbackRate ?? 1,
this.setPlaybackRate.bind(this),
'a-rate',
0,
Expand All @@ -248,6 +250,8 @@ class AudioBufferSourceNodeStretcher implements IAudioAPIBufferSourceNodeWeb {
),
context
);

this.buffer = options.buffer ?? null;
}

connect(destination: AudioNode | AudioParam): AudioNode | AudioParam {
Expand Down Expand Up @@ -489,10 +493,14 @@ class AudioBufferSourceNodeWeb implements IAudioAPIBufferSourceNodeWeb {
readonly playbackRate: AudioParam;
readonly detune: AudioParam;

constructor(context: BaseAudioContext, options?: AudioBufferSourceOptions) {
constructor(
context: BaseAudioContext,
options?: AudioBufferSourceOptionsWeb
) {
const { buffer, ...rest } = options ?? {};
this.node = new globalThis.AudioBufferSourceNode(context.context, {
...options,
...(options?.buffer ? { buffer: options.buffer.buffer } : {}),
...rest,
...(buffer ? { buffer: buffer.buffer } : {}),
});
this.detune = new AudioParam(this.node.detune, context);
this.playbackRate = new AudioParam(this.node.playbackRate, context);
Expand Down Expand Up @@ -619,9 +627,12 @@ class AudioBufferSourceNodeWeb implements IAudioAPIBufferSourceNodeWeb {

export default class AudioBufferSourceNode implements IAudioAPIBufferSourceNodeWeb {
private node: AudioBufferSourceNodeStretcher | AudioBufferSourceNodeWeb;
constructor(context: BaseAudioContext, options?: AudioBufferSourceOptions) {
constructor(
context: BaseAudioContext,
options?: AudioBufferSourceOptionsWeb
) {
this.node = options?.pitchCorrection
? new AudioBufferSourceNodeStretcher(context)
? new AudioBufferSourceNodeStretcher(context, options)
: new AudioBufferSourceNodeWeb(context, options);
}

Expand Down
150 changes: 150 additions & 0 deletions packages/react-native-audio-api/src/web-core/AudioDecoder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { AudioApiError } from '../errors';
import { DecodeDataInput } from '../types';
import { base64ToArrayBuffer } from '../utils';
import { isRemoteSource } from '../utils/paths';
import AudioBuffer from './AudioBuffer';

const MAX_INT16_VALUE = 32768.0;

export default class AudioDecoder {
private static instance: AudioDecoder | null = null;

// keep it a singleton pattern
// eslint-disable-next-line no-useless-constructor
private constructor() {}

public static getInstance(): AudioDecoder {
if (!AudioDecoder.instance) {
AudioDecoder.instance = new AudioDecoder();
}

return AudioDecoder.instance;
}

private async decodeAudioDataImplementation(
input: DecodeDataInput,
sampleRate?: number,
fetchOptions?: RequestInit
): Promise<AudioBuffer | null | undefined> {
const rate = sampleRate ?? 0;

if (input instanceof ArrayBuffer) {
return this.decodeFromArrayBuffer(input, rate);
}

const isUri = typeof input === 'string' && isRemoteSource(input);
if (!isUri) {
throw new TypeError('Input must be a an uri or ArrayBuffer');
}

return this.decodeFromRemoteUrl(input, rate, fetchOptions);
}

private async decodeFromArrayBuffer(
arrayBuffer: ArrayBuffer,
sampleRate: number
): Promise<AudioBuffer> {
const targetRate = sampleRate > 0 ? sampleRate : 44100;
const context = new OfflineAudioContext(1, 1, targetRate);
return new AudioBuffer(await context.decodeAudioData(arrayBuffer));
}

private async decodeFromRemoteUrl(
url: string,
sampleRate: number,
fetchOptions?: RequestInit
): Promise<AudioBuffer> {
const arrayBuffer = await fetch(url, fetchOptions).then((res) =>
res.arrayBuffer()
);
return this.decodeFromArrayBuffer(arrayBuffer, sampleRate);
}

public async decodeAudioDataInstance(
input: DecodeDataInput,
sampleRate?: number,
fetchOptions?: RequestInit
): Promise<AudioBuffer> {
const audioBuffer = await this.decodeAudioDataImplementation(
input,
sampleRate,
fetchOptions
);

if (!audioBuffer) {
throw new AudioApiError('Failed to decode audio data.');
}

return audioBuffer;
}

public async decodePCMInBase64Instance(
base64String: string,
inputSampleRate: number,
inputChannelCount: number,
interleaved: boolean
): Promise<AudioBuffer> {
try {
const arrayBuffer = base64ToArrayBuffer(base64String);
// map as 16 bits
const int16samples = new Int16Array(arrayBuffer);
const totalSampleCount = int16samples.length;
const frameCount = totalSampleCount / inputChannelCount;

// get requested buffer to write into
const context = new OfflineAudioContext(
inputChannelCount,
frameCount,
inputSampleRate
);
const buffer = context.createBuffer(
inputChannelCount,
frameCount,
inputSampleRate
);

// deinterleave
let outIndex: number;
for (let channel = 0; channel < inputChannelCount; channel++) {
const channelData = buffer.getChannelData(channel);
for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) {
outIndex = interleaved
? channel + frameIndex * inputChannelCount // Ch1, Ch2, Ch1, Ch2, ...
: frameIndex + channel * frameCount; // Ch1, Ch1, Ch1, ..., Ch2, Ch2, Ch2, ...

// normalize
channelData[frameIndex] = int16samples[outIndex] / MAX_INT16_VALUE;
}
}
return Promise.resolve(new AudioBuffer(buffer));
} catch {
throw new AudioApiError('Failed to decode PCM data.');
}
}
}

export async function decodeAudioData(
input: DecodeDataInput,
sampleRate?: number,
fetchOptions?: RequestInit
): Promise<AudioBuffer> {
return AudioDecoder.getInstance().decodeAudioDataInstance(
input,
sampleRate,
fetchOptions
);
}

export async function decodePCMInBase64(
base64String: string,
inputSampleRate: number,
inputChannelCount: number,
isInterleaved: boolean = true
): Promise<AudioBuffer> {
return AudioDecoder.getInstance().decodePCMInBase64Instance(
base64String,
inputSampleRate,
inputChannelCount,
isInterleaved
);
}
Loading