diff --git a/.eslintrc.js b/.eslintrc.js index b6f736890..3e5b94b88 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,6 +24,7 @@ module.exports = { '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/no-empty-object-type': 'off', + '@typescript-eslint/no-inline-styles': 'off', '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/ban-ts-comment': [ 'error', diff --git a/apps/common-app/src/App.tsx b/apps/common-app/src/App.tsx index c877dc619..9bf407505 100644 --- a/apps/common-app/src/App.tsx +++ b/apps/common-app/src/App.tsx @@ -170,8 +170,7 @@ const App: FC = () => { headerTintColor: colors.white, headerBackTitle: 'Back', headerBackAccessibilityLabel: 'Go back', - }} - > + }}> void; +} + +const VerticalSlider: React.FC = ({ + label, + value, + onValueChange, +}) => { + const progress = useSharedValue(value); + const startValue = useSharedValue(0); + + useEffect(() => { + progress.value = value; + }, [value, progress]); + + const gesture = Gesture.Pan() + .onStart(() => { + 'worklet'; + startValue.value = progress.value; + }) + .onUpdate((e) => { + 'worklet'; + const change = -e.translationY / TRACK_HEIGHT; + const newValue = startValue.value + change; + progress.value = Math.min(Math.max(newValue, 0), 1); + scheduleOnRN(onValueChange, progress.value); + }); + + const thumbStyle = useAnimatedStyle(() => { + const translateY = (1 - progress.value) * TRACK_HEIGHT; + return { + transform: [{ translateY }], + }; + }); + + return ( + + {label} + + + + + + + + + {(value * 100).toFixed(0)} + + ); +}; + +const styles = StyleSheet.create({ + sliderContainer: { + alignItems: 'center', + gap: 5, + height: SLIDER_HEIGHT + 40, + }, + sliderLabel: { + fontWeight: 'bold', + fontSize: 12, + color: '#333', + }, + sliderTrackContainer: { + width: 40, + height: SLIDER_HEIGHT, + justifyContent: 'center', + alignItems: 'center', + }, + sliderTrack: { + position: 'absolute', + width: 4, + height: '100%', + backgroundColor: '#111', + borderRadius: 2, + }, + sliderThumbHitArea: { + position: 'absolute', + top: 0, + width: 40, + height: THUMB_SIZE, + justifyContent: 'center', + alignItems: 'center', + }, + sliderThumb: { + width: 30, + height: 15, + backgroundColor: '#222', + borderWidth: 1, + borderColor: '#fff', + borderRadius: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.5, + shadowRadius: 2, + }, + sliderValue: { + fontSize: 10, + color: '#555', + fontVariant: ['tabular-nums'], + }, +}); + +export default VerticalSlider; diff --git a/apps/common-app/src/components/index.ts b/apps/common-app/src/components/index.ts index 95b235bb7..c3e93660e 100644 --- a/apps/common-app/src/components/index.ts +++ b/apps/common-app/src/components/index.ts @@ -5,3 +5,4 @@ export { default as Switch } from './Switch'; export { default as Select } from './Select'; export { default as Container } from './Container'; export { default as BGGradient } from './BGGradient'; +export { default as VerticalSlider } from './VerticalSlider'; diff --git a/apps/common-app/src/demos/GuitarPedal/GuitarPedal.tsx b/apps/common-app/src/demos/GuitarPedal/GuitarPedal.tsx new file mode 100644 index 000000000..723b3da07 --- /dev/null +++ b/apps/common-app/src/demos/GuitarPedal/GuitarPedal.tsx @@ -0,0 +1,285 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { GestureDetector, Gesture } from 'react-native-gesture-handler'; +import { + GainNode, + WaveShaperNode, + BiquadFilterNode, + AudioBuffer, + AudioBufferSourceNode, +} from 'react-native-audio-api'; +import { Container, VerticalSlider } from '../../components'; +import { makeDistortionCurve } from './makeDistortionCurve'; +import { audioContext } from '../../singletons'; + +const URL = 'https://files.catbox.moe/xbj6gn.flac'; + +const MIN_DRIVE_GAIN = 0.05; +const MAX_DRIVE_GAIN = 10; +const MIN_FREQ = 500; +const MAX_FREQ = 20000; + +export default function GuitarPedal() { + const [isActive, setIsActive] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [buffer, setBuffer] = useState(null); + + const [drive, setDrive] = useState(0.5); + const [tone, setTone] = useState(0.5); + const [level, setLevel] = useState(0.5); + + const sourceNodeRef = useRef(null); + const driveNodeRef = useRef(null); + const shaperNodeRef = useRef(null); + const toneNodeRef = useRef(null); + const levelNodeRef = useRef(null); + + useEffect(() => { + const init = async () => { + setIsLoading(true); + + try { + const audioBuffer = await fetch(URL, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Android; Mobile; rv:122.0) Gecko/122.0 Firefox/122.0', + }, + }) + .then((response) => response.arrayBuffer()) + .then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer)); + + setBuffer(audioBuffer); + } catch (error) { + console.error('Error loading audio:', error); + } finally { + setIsLoading(false); + } + }; + init(); + + return () => { + stopAudio(); + }; + }, []); + + const startAudio = async () => { + if (!buffer) return; + + if (audioContext.state === 'suspended') { + await audioContext.resume(); + } + + const source = audioContext.createBufferSource(); + source.buffer = buffer; + source.onEnded = () => { + setIsActive(false); + }; + + const driveNode = audioContext.createGain(); + const shaper = audioContext.createWaveShaper(); + const toneNode = audioContext.createBiquadFilter(); + const levelNode = audioContext.createGain(); + + shaper.oversample = '4x'; + shaper.curve = makeDistortionCurve(50, audioContext.sampleRate); + + toneNode.type = 'lowpass'; + toneNode.Q.value = 1; + + source.connect(driveNode); + driveNode.connect(shaper); + shaper.connect(toneNode); + toneNode.connect(levelNode); + levelNode.connect(audioContext.destination); + + source.start(); + source.onEnded = () => { + setIsActive(false); + }; + + sourceNodeRef.current = source; + driveNodeRef.current = driveNode; + shaperNodeRef.current = shaper; + toneNodeRef.current = toneNode; + levelNodeRef.current = levelNode; + + updateAudioParams(drive, tone, level); + setIsActive(true); + }; + + const stopAudio = () => { + if (sourceNodeRef.current) { + sourceNodeRef.current.stop(); + } + + sourceNodeRef.current = null; + driveNodeRef.current = null; + shaperNodeRef.current = null; + toneNodeRef.current = null; + levelNodeRef.current = null; + + setIsActive(false); + }; + + const togglePower = () => { + if (isLoading || !buffer) return; + + if (isActive) { + stopAudio(); + } else { + startAudio(); + } + }; + + const updateAudioParams = (d: number, t: number, l: number) => { + if (!driveNodeRef.current || !toneNodeRef.current || !levelNodeRef.current) + return; + + const driveGain = MIN_DRIVE_GAIN + d * (MAX_DRIVE_GAIN - MIN_DRIVE_GAIN); + driveNodeRef.current.gain.value = driveGain; + + // logarithmic mapping for tone + const freq = MIN_FREQ * Math.pow(MAX_FREQ / MIN_FREQ, t); + toneNodeRef.current.frequency.value = freq; + + levelNodeRef.current.gain.value = l; + }; + + useEffect(() => { + updateAudioParams(drive, tone, level); + }, [drive, tone, level]); + + return ( + + + + RN AUDIO API + OVERDRIVE + + + + + + + + + + + + + + + + + + {isLoading ? 'LOADING' : isActive ? 'ON' : 'BYPASS'} + + + + + + ); +} + +const styles = StyleSheet.create({ + pedalBody: { + flex: 1, + backgroundColor: '#e6b800', + margin: 20, + borderRadius: 20, + borderWidth: 4, + borderColor: '#b38f00', + padding: 20, + justifyContent: 'space-between', + shadowColor: '#000', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.5, + shadowRadius: 10, + elevation: 10, + }, + header: { + alignItems: 'center', + marginTop: 20, + }, + brand: { + fontSize: 16, + fontWeight: 'bold', + color: '#333', + letterSpacing: 2, + }, + model: { + fontSize: 32, + fontWeight: '900', + color: '#000', + fontStyle: 'italic', + }, + controlsRow: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + }, + footer: { + alignItems: 'center', + marginBottom: 40, + }, + switchContainer: { + alignItems: 'center', + gap: 10, + }, + led: { + width: 15, + height: 15, + borderRadius: 8, + borderWidth: 1, + borderColor: '#000', + marginBottom: 10, + shadowColor: '#f00', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.8, + shadowRadius: 5, + }, + stompSwitch: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#c0c0c0', + borderWidth: 2, + borderColor: '#888', + justifyContent: 'center', + alignItems: 'center', + elevation: 5, + }, + stompInner: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#e0e0e0', + borderWidth: 1, + borderColor: '#aaa', + }, + switchLabel: { + fontWeight: 'bold', + color: '#333', + }, +}); diff --git a/apps/common-app/src/demos/GuitarPedal/makeDistortionCurve.ts b/apps/common-app/src/demos/GuitarPedal/makeDistortionCurve.ts new file mode 100644 index 000000000..71ad3cf7c --- /dev/null +++ b/apps/common-app/src/demos/GuitarPedal/makeDistortionCurve.ts @@ -0,0 +1,10 @@ +export function makeDistortionCurve(k: number, sampleRate: number) { + const curve = new Float32Array(sampleRate); + const deg = Math.PI / 180; + + for (let i = 0; i < sampleRate; ++i) { + const x = (i * 2) / sampleRate - 1; + curve[i] = ((3 + k) * x * 20 * deg) / (Math.PI + k * Math.abs(x)); + } + return curve; +} diff --git a/apps/common-app/src/demos/index.ts b/apps/common-app/src/demos/index.ts index 2742281dd..f559e5fe6 100644 --- a/apps/common-app/src/demos/index.ts +++ b/apps/common-app/src/demos/index.ts @@ -1,6 +1,7 @@ import { icons } from 'lucide-react-native'; import Record from './Record/Record'; +import GuitarPedal from './GuitarPedal/GuitarPedal'; interface SimplifiedIconProps { color?: string; @@ -24,4 +25,12 @@ export const demos: DemoScreen[] = [ icon: icons.Mic, screen: Record, }, + { + key: 'GuitarPedal', + title: 'Guitar Pedal', + subtitle: + 'Simulates a guitar pedal with distortion, tone, and level controls.', + icon: icons.Guitar, + screen: GuitarPedal, + }, ] as const; diff --git a/apps/fabric-example/ios/FabricExample/Info.plist b/apps/fabric-example/ios/FabricExample/Info.plist index 5997ebbfc..a8908a82d 100644 --- a/apps/fabric-example/ios/FabricExample/Info.plist +++ b/apps/fabric-example/ios/FabricExample/Info.plist @@ -41,12 +41,12 @@ NSMicrophoneUsageDescription $(PRODUCT_NAME) wants to access your microphone in order to use voice memo recording + RCTNewArchEnabled + UIBackgroundModes audio - RCTNewArchEnabled - UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities diff --git a/packages/audiodocs/docs/core/base-audio-context.mdx b/packages/audiodocs/docs/core/base-audio-context.mdx index 2e6a098bf..c6f31682f 100644 --- a/packages/audiodocs/docs/core/base-audio-context.mdx +++ b/packages/audiodocs/docs/core/base-audio-context.mdx @@ -195,6 +195,12 @@ Creates [`StreamerNode`](/docs/sources/streamer-node). #### Returns `StreamerNode`. +### `createWaveShaper` + +Creates [`WaveShaperNode`](/docs/effects/wave-shaper-node). + +#### Returns `WaveShaperNode`. + ### `createWorkletNode` Creates [`WorkletNode`](/docs/worklets/worklet-node). diff --git a/packages/audiodocs/docs/effects/wave-shaper-node.mdx b/packages/audiodocs/docs/effects/wave-shaper-node.mdx new file mode 100644 index 000000000..1d020ccb3 --- /dev/null +++ b/packages/audiodocs/docs/effects/wave-shaper-node.mdx @@ -0,0 +1,57 @@ +--- +sidebar_position: 6 +--- + +import AudioNodePropsTable from "@site/src/components/AudioNodePropsTable" +import { ReadOnly } from '@site/src/components/Badges'; + +# WaveShaperNode + +The `WaveShaperNode` interface represents non-linear signal distortion effects. +Non-linear distortion is commonly used for both subtle non-linear warming, or more obvious distortion effects. + +#### [`AudioNode`](/docs/core/audio-node#properties) properties + + + +## Constructor + +[`BaseAudioContext.createWaveShaper()`](/docs/core/base-audio-context#createwaveshaper) + +## Properties + +It inherits all properties from [`AudioNode`](/docs/core/audio-node#properties). + +| Name | Type | Description | +| :--: | :--: | :---------- | +| `curve` | `Float32Array \| null` | The shaping curve used for waveshaping effect. | +| `oversample` | [`OverSampleType`](/docs/effects/wave-shaper-node#oversampletype) | Specifies what type of oversampling should be used when applying shaping curve. | + +## Methods + +`WaveShaperNode` does not define any additional methods. +It inherits all methods from [`AudioNode`](/docs/core/audio-node#methods). + +## Remarks + +#### `curve` +- Default value is null +- Contains at least two values. +- Subsequent modifications of curve have no effects. To change the curve, assign a new Float32Array object to this property. + +#### `oversample` +- Default value `none` +- Value of `2x` or `4x` can increases quality of the effect, but in some cases it is better not to use oversampling for very accurate shaping curve. + +### `OverSampleType` + +
+Type definitions + +```typescript +// Do not oversample | Oversample two times | Oversample four times +type OverSampleType = 'none' | '2x' | '4x'; +``` + +
+ diff --git a/packages/audiodocs/docs/other/web-audio-api-coverage.mdx b/packages/audiodocs/docs/other/web-audio-api-coverage.mdx index 22a6fcbe7..d0e5b8a68 100644 --- a/packages/audiodocs/docs/other/web-audio-api-coverage.mdx +++ b/packages/audiodocs/docs/other/web-audio-api-coverage.mdx @@ -27,6 +27,7 @@ sidebar_position: 2 | OscillatorNode | ✅ | | PeriodicWave | ✅ | | StereoPannerNode | ✅ | +| WaveShaperNode | ✅ | | AudioContext | 🚧 | Available props and methods: `close`, `suspend`, `resume` | | BaseAudioContext | 🚧 | Available props and methods: `currentTime`, `destination`, `sampleRate`, `state`, `decodeAudioData`, all create methods for available or partially implemented nodes | | AudioListener | ❌ | @@ -42,7 +43,6 @@ sidebar_position: 2 | MediaStreamAudioDestinationNode | ❌ | | MediaStreamAudioSourceNode | ❌ | | PannerNode | ❌ | -| WaveShaperNode | ❌ | ### Description diff --git a/packages/audiodocs/static/audio/music/guitar-sample.flac b/packages/audiodocs/static/audio/music/guitar-sample.flac new file mode 100644 index 000000000..6c87fdc2f Binary files /dev/null and b/packages/audiodocs/static/audio/music/guitar-sample.flac differ 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 8487e859c..a7ec66a0a 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 @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -57,7 +58,8 @@ BaseAudioContextHostObject::BaseAudioContextHostObject( JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createBuffer), JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createPeriodicWave), JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createConvolver), - JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createAnalyser)); + JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createAnalyser), + JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createWaveShaper)); } JSI_PROPERTY_GETTER_IMPL(BaseAudioContextHostObject, destination) { @@ -308,4 +310,10 @@ JSI_HOST_FUNCTION_IMPL(BaseAudioContextHostObject, createConvolver) { } return jsiObject; } + +JSI_HOST_FUNCTION_IMPL(BaseAudioContextHostObject, createWaveShaper) { + auto waveShaper = context_->createWaveShaper(); + auto waveShaperHostObject = std::make_shared(waveShaper); + return jsi::Object::createFromHostObject(runtime, waveShaperHostObject); +} } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h index 37daf4e04..63b9cb152 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h @@ -43,6 +43,7 @@ class BaseAudioContextHostObject : public JsiHostObject { JSI_HOST_FUNCTION_DECL(createPeriodicWave); JSI_HOST_FUNCTION_DECL(createAnalyser); JSI_HOST_FUNCTION_DECL(createConvolver); + JSI_HOST_FUNCTION_DECL(createWaveShaper); JSI_HOST_FUNCTION_DECL(createDelay); std::shared_ptr context_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/WaveShaperNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/WaveShaperNodeHostObject.cpp new file mode 100644 index 000000000..01b225fbf --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/WaveShaperNodeHostObject.cpp @@ -0,0 +1,72 @@ +#include +#include +#include + +#include +#include + +namespace audioapi { + +WaveShaperNodeHostObject::WaveShaperNodeHostObject(const std::shared_ptr &node) + : AudioNodeHostObject(node) { + addGetters( + JSI_EXPORT_PROPERTY_GETTER(WaveShaperNodeHostObject, oversample), + JSI_EXPORT_PROPERTY_GETTER(WaveShaperNodeHostObject, curve)); + + addSetters(JSI_EXPORT_PROPERTY_SETTER(WaveShaperNodeHostObject, oversample)); + addFunctions(JSI_EXPORT_FUNCTION(WaveShaperNodeHostObject, setCurve)); +} + +JSI_PROPERTY_GETTER_IMPL(WaveShaperNodeHostObject, oversample) { + auto waveShaperNode = std::static_pointer_cast(node_); + return jsi::String::createFromUtf8(runtime, waveShaperNode->getOversample()); +} + +JSI_PROPERTY_GETTER_IMPL(WaveShaperNodeHostObject, curve) { + auto waveShaperNode = std::static_pointer_cast(node_); + auto curve = waveShaperNode->getCurve(); + + if (curve == nullptr) { + return jsi::Value::null(); + } + + // copy AudioArray holding curve data to avoid subsequent modifications + auto audioArray = std::make_shared(*curve); + auto audioArrayBuffer = std::make_shared(audioArray); + auto arrayBuffer = jsi::ArrayBuffer(runtime, audioArrayBuffer); + + auto float32ArrayCtor = runtime.global().getPropertyAsFunction(runtime, "Float32Array"); + auto float32Array = float32ArrayCtor.callAsConstructor(runtime, arrayBuffer).getObject(runtime); + float32Array.setExternalMemoryPressure(runtime, audioArrayBuffer->size()); + + return float32Array; +} + +JSI_PROPERTY_SETTER_IMPL(WaveShaperNodeHostObject, oversample) { + auto waveShaperNode = std::static_pointer_cast(node_); + std::string type = value.asString(runtime).utf8(runtime); + waveShaperNode->setOversample(type); +} + +JSI_HOST_FUNCTION_IMPL(WaveShaperNodeHostObject, setCurve) { + auto waveShaperNode = std::static_pointer_cast(node_); + + if (args[0].isNull()) { + waveShaperNode->setCurve(std::shared_ptr(nullptr)); + return jsi::Value::undefined(); + } + + auto arrayBuffer = + args[0].getObject(runtime).getPropertyAsObject(runtime, "buffer").getArrayBuffer(runtime); + + auto curve = std::make_shared( + reinterpret_cast(arrayBuffer.data(runtime)), + static_cast(arrayBuffer.size(runtime) / sizeof(float))); + + waveShaperNode->setCurve(curve); + thisValue.asObject(runtime).setExternalMemoryPressure(runtime, arrayBuffer.size(runtime)); + + return jsi::Value::undefined(); +} + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/WaveShaperNodeHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/WaveShaperNodeHostObject.h new file mode 100644 index 000000000..cdddf01fe --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/WaveShaperNodeHostObject.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include +#include + +namespace audioapi { +using namespace facebook; + +class WaveShaperNode; + +class WaveShaperNodeHostObject : public AudioNodeHostObject { + public: + explicit WaveShaperNodeHostObject(const std::shared_ptr &node); + + JSI_PROPERTY_GETTER_DECL(oversample); + JSI_PROPERTY_GETTER_DECL(curve); + + JSI_PROPERTY_SETTER_DECL(oversample); + JSI_HOST_FUNCTION_DECL(setCurve); +}; +} // namespace audioapi 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 afebaf2b5..9e8a69bbe 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 @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -206,6 +207,12 @@ std::shared_ptr BaseAudioContext::createConvolver( return convolver; } +std::shared_ptr BaseAudioContext::createWaveShaper() { + auto waveShaper = std::make_shared(this); + nodeManager_->addProcessingNode(waveShaper); + return waveShaper; +} + AudioNodeManager *BaseAudioContext::getNodeManager() { return nodeManager_.get(); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h index b17304977..674d1d580 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h @@ -37,6 +37,7 @@ class WorkletSourceNode; class WorkletNode; class WorkletProcessingNode; class StreamerNode; +class WaveShaperNode; class BaseAudioContext { public: @@ -88,6 +89,7 @@ class BaseAudioContext { std::shared_ptr createConvolver( std::shared_ptr buffer, bool disableNormalization); + std::shared_ptr createWaveShaper(); std::shared_ptr getBasicWaveForm(OscillatorType type); [[nodiscard]] float getNyquistFrequency() const; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/WaveShaperNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/WaveShaperNode.cpp new file mode 100644 index 000000000..14dcaddde --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/WaveShaperNode.cpp @@ -0,0 +1,79 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace audioapi { + +WaveShaperNode::WaveShaperNode(BaseAudioContext *context) + : AudioNode(context), oversample_(OverSampleType::OVERSAMPLE_NONE) { + + waveShapers_.reserve(6); + for (int i = 0; i < channelCount_; i++) { + waveShapers_.emplace_back(std::make_unique(nullptr)); + } + + // to change after graph processing improvement - should be max + channelCountMode_ = ChannelCountMode::CLAMPED_MAX; + isInitialized_ = true; +} + +std::string WaveShaperNode::getOversample() const { + return overSampleTypeToString(oversample_.load(std::memory_order_acquire)); +} + +void WaveShaperNode::setOversample(const std::string &type) { + std::scoped_lock lock(mutex_); + oversample_.store(overSampleTypeFromString(type), std::memory_order_release); + + for (int i = 0; i < waveShapers_.size(); i++) { + waveShapers_[i]->setOversample(overSampleTypeFromString(type)); + } +} + +std::shared_ptr WaveShaperNode::getCurve() const { + std::scoped_lock lock(mutex_); + return curve_; +} + +void WaveShaperNode::setCurve(const std::shared_ptr &curve) { + std::scoped_lock lock(mutex_); + curve_ = curve; + + for (int i = 0; i < waveShapers_.size(); i++) { + waveShapers_[i]->setCurve(curve); + } +} + +std::shared_ptr WaveShaperNode::processNode( + const std::shared_ptr &processingBus, + int framesToProcess) { + if (!isInitialized_) { + return processingBus; + } + + std::unique_lock lock(mutex_, std::try_to_lock); + + if (!lock.owns_lock()) { + return processingBus; + } + + if (curve_ == nullptr) { + return processingBus; + } + + for (int channel = 0; channel < processingBus->getNumberOfChannels(); channel++) { + auto channelData = processingBus->getSharedChannel(channel); + + waveShapers_[channel]->process(channelData, framesToProcess); + } + + return processingBus; +} + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/WaveShaperNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/WaveShaperNode.h new file mode 100644 index 000000000..d5a64eab7 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/WaveShaperNode.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace audioapi { + +class AudioBus; +class AudioArray; + +class WaveShaperNode : public AudioNode { + public: + explicit WaveShaperNode(BaseAudioContext *context); + + [[nodiscard]] std::string getOversample() const; + [[nodiscard]] std::shared_ptr getCurve() const; + + void setOversample(const std::string &type); + void setCurve(const std::shared_ptr &curve); + + protected: + std::shared_ptr processNode( + const std::shared_ptr &processingBus, + int framesToProcess) override; + + private: + std::atomic oversample_; + std::shared_ptr curve_{}; + mutable std::mutex mutex_; + + std::vector> waveShapers_{}; + + static OverSampleType overSampleTypeFromString(const std::string &type) { + std::string lowerType = type; + std::transform(lowerType.begin(), lowerType.end(), lowerType.begin(), ::tolower); + + if (lowerType == "2x") + return OverSampleType::OVERSAMPLE_2X; + if (lowerType == "4x") + return OverSampleType::OVERSAMPLE_4X; + + return OverSampleType::OVERSAMPLE_NONE; + } + + static std::string overSampleTypeToString(OverSampleType type) { + switch (type) { + case OverSampleType::OVERSAMPLE_2X: + return "2x"; + case OverSampleType::OVERSAMPLE_4X: + return "4x"; + default: + return "none"; + } + } +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/types/OverSampleType.h b/packages/react-native-audio-api/common/cpp/audioapi/core/types/OverSampleType.h new file mode 100644 index 000000000..8ba765169 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/types/OverSampleType.h @@ -0,0 +1,7 @@ +#pragma once + +namespace audioapi { + +enum class OverSampleType { OVERSAMPLE_NONE, OVERSAMPLE_2X, OVERSAMPLE_4X }; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/Resampler.cpp b/packages/react-native-audio-api/common/cpp/audioapi/dsp/Resampler.cpp new file mode 100644 index 000000000..c90f9bc9d --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/Resampler.cpp @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2010 Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of + * its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include +#include +#include +#include + +#if defined(__ARM_NEON) +#include +#endif + +// based on WebKit UpSampler and DownSampler implementation + +namespace audioapi { + +Resampler::Resampler(int maxBlockSize, int kernelSize): + kernelSize_(kernelSize), + kernel_(std::make_shared(kernelSize)), + stateBuffer_(std::make_shared(2 * maxBlockSize)) { + stateBuffer_->zero(); +} + +// https://en.wikipedia.org/wiki/Window_function +float Resampler::computeBlackmanWindow(double x) const { + double alpha = 0.16; + double a0 = 0.5 * (1.0 - alpha); + double a1 = 0.5; + double a2 = 0.5 * alpha; + double n = x / kernelSize_; + return static_cast(a0 - a1 * std::cos(2.0 * PI * n) + a2 * std::cos(4.0 * PI * n)); +} + +float Resampler::computeConvolution(const float *stateStart, const float *kernelStart) const { + float sum = 0.0f; + int k = 0; + +#ifdef __ARM_NEON + float32x4_t vSum = vdupq_n_f32(0.0f); + + // process 4 samples at a time + for (; k <= kernelSize_ - 4; k += 4) { + float32x4_t vState = vld1q_f32(stateStart + k); + float32x4_t vKernel = vld1q_f32(kernelStart + k); + + // fused multiply-add: vSum += vState * vKernel + vSum = vmlaq_f32(vSum, vState, vKernel); + } + + // horizontal reduction: Sum the 4 lanes of vSum into a single float + sum += vgetq_lane_f32(vSum, 0); + sum += vgetq_lane_f32(vSum, 1); + sum += vgetq_lane_f32(vSum, 2); + sum += vgetq_lane_f32(vSum, 3); +#endif + for (; k < kernelSize_; ++k) { + sum += stateStart[k] * kernelStart[k]; + } + + return sum; +} + +void Resampler::reset() { + if (stateBuffer_) { + stateBuffer_->zero(); + } +} + +UpSampler::UpSampler(int maxBlockSize, int kernelSize) : Resampler(maxBlockSize, kernelSize) { + initializeKernel(); +} + +void UpSampler::initializeKernel() { + auto kData = kernel_->getData(); + int halfSize = kernelSize_ / 2; + double subSampleOffset = -0.5; + + for (int i = 0; i < kernelSize_; ++i) { + // we want to sample the sinc function halfway between integer points + auto x = static_cast(i - halfSize) - subSampleOffset; + + // https://en.wikipedia.org/wiki/Sinc_filter + // sets cutoff frequency to nyquist + double sinc = (std::abs(x) < 1e-9) ? 1.0 : std::sin(x * PI) / (x * PI); + + // apply window in order smooth out the edges, because sinc extends to infinity in both directions + kData[i] = static_cast(sinc * computeBlackmanWindow(i - subSampleOffset)); + } + + // reverse kernel to match convolution implementation + std::reverse(kData, kData + kernelSize_); +} + +int UpSampler::process( + const std::shared_ptr &input, + const std::shared_ptr &output, + int framesToProcess) { + + const float *inputData = input->getData(); + float *outputData = output->getData(); + float *state = stateBuffer_->getData(); + const float *kernel = kernel_->getData(); + + // copy new input [ HISTORY | NEW DATA ] + std::memcpy(state + kernelSize_, inputData, framesToProcess * sizeof(float)); + + int halfKernel = kernelSize_ / 2; + + for (int i = 0; i < framesToProcess; ++i) { + // direct copy for even samples with half kernel latency compensation + outputData[2 * i] = state[kernelSize_ + i - halfKernel]; + + // convolution for odd samples + // symmetric Linear Phase filter has latency of half kernel size + outputData[2 * i + 1] = computeConvolution(&state[i + 1], kernel); + } + + // move new data to history [ NEW DATA | EMPTY ] + std::memmove(state, state + framesToProcess, kernelSize_ * sizeof(float)); + + return framesToProcess * 2; +} + +DownSampler::DownSampler(int maxBlockSize, int kernelSize) : Resampler(maxBlockSize, kernelSize) { + initializeKernel(); +} + +void DownSampler::initializeKernel() { + auto kData = kernel_->getData(); + int halfSize = kernelSize_ / 2; + + for (int i = 0; i < kernelSize_; ++i) { + // we want to sample the sinc function halfway between integer points + auto x = static_cast(i - halfSize); + + // https://en.wikipedia.org/wiki/Sinc_filter + // sets cutoff frequency to nyquist / 2 + double sinc = (std::abs(x) < 1e-9) ? 1.0 : std::sin(x * PI * 0.5) / (x * PI * 0.5); + sinc *= 0.5; + + // apply window in order smooth out the edges, because sinc extends to infinity in both directions + kData[i] = static_cast(sinc * computeBlackmanWindow(i)); + } + + // reverse kernel to match convolution implementation + std::reverse(kData, kData + kernelSize_); +} + +int DownSampler::process( + const std::shared_ptr &input, + const std::shared_ptr &output, + int framesToProcess) { + const float *inputData = input->getData(); + float *outputData = output->getData(); + float *state = stateBuffer_->getData(); + const float *kernel = kernel_->getData(); + + // copy new input [ HISTORY | NEW DATA ] + std::memcpy(state + kernelSize_, inputData, framesToProcess * sizeof(float)); + + auto outputCount = framesToProcess / 2; + + for (int i = 0; i < outputCount; ++i) { + // convolution for downsampled samples + outputData[i] = computeConvolution(&state[2 * i + 1], kernel); + } + + // move new data to history [ NEW DATA | EMPTY ] + std::memmove(state, state + framesToProcess, kernelSize_ * sizeof(float)); + + return outputCount; +} +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/Resampler.h b/packages/react-native-audio-api/common/cpp/audioapi/dsp/Resampler.h new file mode 100644 index 000000000..4a99f9f45 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/Resampler.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include + +namespace audioapi { + +class Resampler { + public: + /// Constructor + /// @param maxBlockSize Maximum block size that will be processed + /// @param kernelSize Size of the resampling kernel + /// @note maxBlockSize >= kernelSize + Resampler(int maxBlockSize, int kernelSize); + virtual ~Resampler() = default; + + virtual int process( + const std::shared_ptr &input, + const std::shared_ptr &output, + int framesToProcess) = 0; + void reset(); + + protected: + [[nodiscard]] float computeBlackmanWindow(double x) const; + float computeConvolution(const float *stateStart, const float *kernelStart) const; + virtual void initializeKernel() = 0; + + int kernelSize_; + + std::shared_ptr kernel_; + // [ HISTORY | NEW DATA ] + std::shared_ptr stateBuffer_; +}; + +class UpSampler : public Resampler { + public: + UpSampler(int maxBlockSize, int kernelSize); + + // N -> 2N + int process( + const std::shared_ptr &input, + const std::shared_ptr &output, + int framesToProcess) override; + + protected: + void initializeKernel() final; +}; + +class DownSampler : public Resampler { + public: + DownSampler(int maxBlockSize, int kernelSize); + + // N -> N / 2 + int process( + const std::shared_ptr &input, + const std::shared_ptr &output, + int framesToProcess) override; + + protected: + void initializeKernel() final; +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/WaveShaper.cpp b/packages/react-native-audio-api/common/cpp/audioapi/dsp/WaveShaper.cpp new file mode 100644 index 000000000..9ee3c7207 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/WaveShaper.cpp @@ -0,0 +1,105 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace audioapi { + +WaveShaper::WaveShaper(const std::shared_ptr &curve) : curve_(curve) { + tempBuffer2x_ = std::make_shared(RENDER_QUANTUM_SIZE * 2); + tempBuffer2x_->zero(); + tempBuffer4x_ = std::make_shared(RENDER_QUANTUM_SIZE * 4); + tempBuffer4x_->zero(); + + upSampler_ = std::make_unique(RENDER_QUANTUM_SIZE, RENDER_QUANTUM_SIZE); + downSampler_ = std::make_unique(2 * RENDER_QUANTUM_SIZE, 2 * RENDER_QUANTUM_SIZE); + upSampler2_ = std::make_unique(2 * RENDER_QUANTUM_SIZE, RENDER_QUANTUM_SIZE); + downSampler2_ = std::make_unique(4 * RENDER_QUANTUM_SIZE, 2 * RENDER_QUANTUM_SIZE); +} + +void WaveShaper::setCurve(const std::shared_ptr &curve) { + curve_ = curve; +} + +void WaveShaper::setOversample(OverSampleType type) { + oversample_ = type; + + if (upSampler_) { + upSampler_->reset(); + } + + if (downSampler_) { + downSampler_->reset(); + } + + if (upSampler2_) { + upSampler2_->reset(); + } + + if (downSampler2_) { + downSampler2_->reset(); + } +} + +void WaveShaper::process(const std::shared_ptr &channelData, int framesToProcess) { + if (curve_ == nullptr) { + return; + } + + switch (oversample_) { + case OverSampleType::OVERSAMPLE_2X: + process2x(channelData, framesToProcess); + break; + case OverSampleType::OVERSAMPLE_4X: + process4x(channelData, framesToProcess); + break; + case OverSampleType::OVERSAMPLE_NONE: + default: + processNone(channelData, framesToProcess); + break; + } +} + +// based on https://webaudio.github.io/web-audio-api/#WaveShaperNode +void WaveShaper::processNone(const std::shared_ptr &channelData, int framesToProcess) { + auto curveArray = curve_->getData(); + auto curveSize = curve_->getSize(); + + auto data = channelData->getData(); + + for (int i = 0; i < framesToProcess; i++) { + float v = (static_cast(curveSize) - 1) * 0.5f * (data[i] + 1.0f); + + if (v <= 0) { + data[i] = curveArray[0]; + } else if (v >= static_cast(curveSize) - 1) { + data[i] = curveArray[curveSize - 1]; + } else { + auto k = std::floor(v); + auto f = v - k; + auto kIndex = static_cast(k); + data[i] = (1 - f) * curveArray[kIndex] + f * curveArray[kIndex + 1]; + } + } +} + +void WaveShaper::process2x(const std::shared_ptr &channelData, int framesToProcess) { + auto outputFrames = upSampler_->process(channelData, tempBuffer2x_, framesToProcess); + processNone(tempBuffer2x_, outputFrames); + downSampler_->process(tempBuffer2x_, channelData, outputFrames); +} + +void WaveShaper::process4x(const std::shared_ptr &channelData, int framesToProcess) { + auto upSamplerOutputFrames = upSampler_->process(channelData, tempBuffer2x_, framesToProcess); + auto upSampler2OutputFrames = upSampler2_->process(tempBuffer2x_, tempBuffer4x_, upSamplerOutputFrames); + processNone(tempBuffer4x_, upSampler2OutputFrames); + auto downSampler2OutputFrames = downSampler2_->process(tempBuffer4x_, tempBuffer2x_, upSampler2OutputFrames); + downSampler_->process(tempBuffer2x_, channelData, downSampler2OutputFrames); +} + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/WaveShaper.h b/packages/react-native-audio-api/common/cpp/audioapi/dsp/WaveShaper.h new file mode 100644 index 000000000..fd5a04b44 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/WaveShaper.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace audioapi { + +class AudioBus; +class AudioArray; + +class WaveShaper { + public: + explicit WaveShaper(const std::shared_ptr &curve); + + void process(const std::shared_ptr &channelData, int framesToProcess); + + void setCurve(const std::shared_ptr &curve); + void setOversample(OverSampleType type); + + private: + OverSampleType oversample_ = OverSampleType::OVERSAMPLE_NONE; + std::shared_ptr curve_; + + // stage 1 Filters (1x <-> 2x) + std::unique_ptr upSampler_; + std::unique_ptr downSampler_; + + // stage 2 Filters (2x <-> 4x) + std::unique_ptr upSampler2_; + std::unique_ptr downSampler2_; + + std::shared_ptr tempBuffer2x_; + std::shared_ptr tempBuffer4x_; + + void processNone(const std::shared_ptr &channelData, int framesToProcess); + void process2x(const std::shared_ptr &channelData, int framesToProcess); + void process4x(const std::shared_ptr &channelData, int framesToProcess); +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioArray.cpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioArray.cpp index 967a8ed65..0e321ed80 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioArray.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioArray.cpp @@ -14,6 +14,11 @@ AudioArray::AudioArray(const AudioArray &other) : data_(nullptr), size_(0) { copy(&other); } +AudioArray::AudioArray(const float *data, size_t size) : size_(size) { + data_ = new float[size_]; + memcpy(data_, data, size_ * sizeof(float)); +} + AudioArray::~AudioArray() { if (data_) { delete[] data_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioArray.h b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioArray.h index 6f17d23ed..9291f22ff 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioArray.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioArray.h @@ -11,6 +11,12 @@ class AudioArray { public: explicit AudioArray(size_t size); AudioArray(const AudioArray &other); + + /// @brief Construct AudioArray from raw float data + /// @param data Pointer to the float data + /// @param size Number of float samples + /// @note The data is copied, so it does not take ownership of the pointer + AudioArray(const float *data, size_t size); ~AudioArray(); [[nodiscard]] size_t getSize() const; diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/effects/WaveShaperNodeTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/effects/WaveShaperNodeTest.cpp new file mode 100644 index 000000000..5abee5ea9 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/core/effects/WaveShaperNodeTest.cpp @@ -0,0 +1,75 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace audioapi; + +class WaveShaperNodeTest : public ::testing::Test { + protected: + std::shared_ptr eventRegistry; + std::unique_ptr context; + static constexpr int sampleRate = 44100; + + void SetUp() override { + eventRegistry = std::make_shared(); + context = std::make_unique( + 2, 5 * sampleRate, sampleRate, eventRegistry, RuntimeRegistry{}); + } +}; + +class TestableWaveShaperNode : public WaveShaperNode { + public: + explicit TestableWaveShaperNode(BaseAudioContext *context) : WaveShaperNode(context) { + testCurve_ = std::make_shared(3); + auto data = testCurve_->getData(); + data[0] = -2.0f; + data[1] = 0.0f; + data[2] = 2.0f; + } + + std::shared_ptr processNode( + const std::shared_ptr &processingBus, + int framesToProcess) override { + return WaveShaperNode::processNode(processingBus, framesToProcess); + } + + std::shared_ptr testCurve_; +}; + +TEST_F(WaveShaperNodeTest, WaveShaperNodeCanBeCreated) { + auto waveShaper = context->createWaveShaper(); + ASSERT_NE(waveShaper, nullptr); +} + +TEST_F(WaveShaperNodeTest, NullCanBeAsignedToCurve) { + auto waveShaper = context->createWaveShaper(); + ASSERT_NO_THROW(waveShaper->setCurve(nullptr)); + ASSERT_EQ(waveShaper->getCurve(), nullptr); +} + +TEST_F(WaveShaperNodeTest, NoneOverSamplingProcessesCorrectly) { + static constexpr int FRAMES_TO_PROCESS = 5; + auto waveShaper = std::make_shared(context.get()); + waveShaper->setOversample("none"); + waveShaper->setCurve(waveShaper->testCurve_); + + auto bus = std::make_shared(FRAMES_TO_PROCESS, 1, sampleRate); + for (size_t i = 0; i < bus->getSize(); ++i) { + bus->getChannel(0)->getData()[i] = -1.0f + i * 0.5f; + } + + auto resultBus = waveShaper->processNode(bus, FRAMES_TO_PROCESS); + auto curveData = waveShaper->testCurve_->getData(); + auto resultData = resultBus->getChannel(0)->getData(); + + EXPECT_FLOAT_EQ(resultData[0], curveData[0]); + EXPECT_FLOAT_EQ(resultData[1], -1.0f); + EXPECT_FLOAT_EQ(resultData[2], 0.0f); + EXPECT_FLOAT_EQ(resultData[3], 1.0f); + EXPECT_FLOAT_EQ(resultData[4], curveData[2]); +} diff --git a/packages/react-native-audio-api/common/cpp/test/src/dsp/ResamplerTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/dsp/ResamplerTest.cpp new file mode 100644 index 000000000..f62fc9824 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/dsp/ResamplerTest.cpp @@ -0,0 +1,117 @@ +#include +#include +#include +#include +#include +#include +#include + +using namespace audioapi; + +class ResamplerTest : public ::testing::Test { + protected: + static constexpr int KERNEL_SIZE = RENDER_QUANTUM_SIZE; +}; + +class TestableUpSampler : public UpSampler { + public: + explicit TestableUpSampler(int maxBlockSize, int kernelSize) + : UpSampler(maxBlockSize, kernelSize) {} + + std::shared_ptr getKernel() { + return kernel_; + } +}; + +class TestableDownSampler : public DownSampler { + public: + explicit TestableDownSampler(int maxBlockSize, int kernelSize) + : DownSampler(maxBlockSize, kernelSize) {} + + std::shared_ptr getKernel() { + return kernel_; + } +}; + +TEST_F(ResamplerTest, UpSamplerCanBeCreated) { + auto upSampler = std::make_unique(RENDER_QUANTUM_SIZE, RENDER_QUANTUM_SIZE); + ASSERT_NE(upSampler, nullptr); +} + +TEST_F(ResamplerTest, DownSamplerCanBeCreated) { + auto downSampler = std::make_unique(RENDER_QUANTUM_SIZE * 2, RENDER_QUANTUM_SIZE * 2); + ASSERT_NE(downSampler, nullptr); +} + +TEST_F(ResamplerTest, UpSamplerKernelSymmetry) { + auto upSampler = std::make_unique(RENDER_QUANTUM_SIZE, RENDER_QUANTUM_SIZE); + auto kernel = upSampler->getKernel(); + + // check for symmetry around the center point + for (size_t i = 0; i < kernel->getSize() / 2; ++i) { + EXPECT_NEAR((*kernel)[i], (*kernel)[kernel->getSize() - 1 - i], 1e-6); + } +} + +TEST_F(ResamplerTest, DownSamplerKernelSymmetry) { + auto downSampler = + std::make_unique(RENDER_QUANTUM_SIZE * 2, RENDER_QUANTUM_SIZE * 2); + auto kernel = downSampler->getKernel(); + + // check for symmetry around the center point + // as the kernel size is even, we compare pairs around the center -> kernel[size/2 - 1] + // last value is skipped + for (size_t i = 0; i < kernel->getSize() / 2; ++i) { + EXPECT_NEAR((*kernel)[i], (*kernel)[kernel->getSize() - 2 - i], 1e-6); + } + + EXPECT_FLOAT_EQ((*kernel)[kernel->getSize() / 2 - 1], 0.5f); +} + +TEST_F(ResamplerTest, UpSamplerKernelSum) { + auto upSampler = std::make_unique(RENDER_QUANTUM_SIZE, RENDER_QUANTUM_SIZE); + auto kernel = upSampler->getKernel(); + + float sum = 0.0f; + for (size_t i = 0; i < kernel->getSize(); ++i) { + sum += (*kernel)[i]; + } + + EXPECT_NEAR(sum, 1.0f, 1e-6); +} + +TEST_F(ResamplerTest, DownSamplerKernelSum) { + auto downSampler = + std::make_unique(RENDER_QUANTUM_SIZE * 2, RENDER_QUANTUM_SIZE * 2); + auto kernel = downSampler->getKernel(); + + float sum = 0.0f; + for (size_t i = 0; i < kernel->getSize(); ++i) { + sum += (*kernel)[i]; + } + + EXPECT_NEAR(sum, 1.0f, 1e-6); +} + +TEST_F(ResamplerTest, UpDownSamplingProcess) { + auto upSampler = std::make_unique(RENDER_QUANTUM_SIZE, RENDER_QUANTUM_SIZE); + auto downSampler = + std::make_unique(RENDER_QUANTUM_SIZE * 2, RENDER_QUANTUM_SIZE * 2); + + auto inputArray = std::make_shared(4); + (*inputArray)[0] = 1.0f; + (*inputArray)[1] = 0.0f; + (*inputArray)[2] = -1.0f; + (*inputArray)[3] = 1.0f; + + auto outputArray = std::make_shared(8); + + int upSamplerOutputFrames; + int downSamplerOutputFrames; + + EXPECT_NO_THROW(upSamplerOutputFrames = upSampler->process(inputArray, outputArray, 4)); + EXPECT_NO_THROW(downSamplerOutputFrames = downSampler->process(outputArray, inputArray, 8)); + + EXPECT_EQ(upSamplerOutputFrames, 8); + EXPECT_EQ(downSamplerOutputFrames, 4); +} diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index 5c9a21ed6..e443f1ac7 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -22,6 +22,7 @@ export { default as OscillatorNode } from './core/OscillatorNode'; export { default as RecorderAdapterNode } from './core/RecorderAdapterNode'; export { default as StereoPannerNode } from './core/StereoPannerNode'; export { default as StreamerNode } from './core/StreamerNode'; +export { default as WaveShaperNode } from './core/WaveShaperNode'; export { default as WorkletNode } from './core/WorkletNode'; export { default as WorkletProcessingNode } from './core/WorkletProcessingNode'; export { default as WorkletSourceNode } from './core/WorkletSourceNode'; diff --git a/packages/react-native-audio-api/src/api.web.ts b/packages/react-native-audio-api/src/api.web.ts index 22571c557..aad59cfc6 100644 --- a/packages/react-native-audio-api/src/api.web.ts +++ b/packages/react-native-audio-api/src/api.web.ts @@ -14,6 +14,7 @@ export { default as OscillatorNode } from './web-core/OscillatorNode'; export { default as StereoPannerNode } from './web-core/StereoPannerNode'; export { default as ConstantSourceNode } from './web-core/ConstantSourceNode'; export { default as ConvolverNode } from './web-core/ConvolverNode'; +export { default as WaveShaperNode } from './web-core/WaveShaperNode'; export * from './web-core/custom'; diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index ed60cf395..1718414e2 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -31,6 +31,7 @@ import PeriodicWave from './PeriodicWave'; import RecorderAdapterNode from './RecorderAdapterNode'; import StereoPannerNode from './StereoPannerNode'; import StreamerNode from './StreamerNode'; +import WaveShaperNode from './WaveShaperNode'; import WorkletNode from './WorkletNode'; import WorkletProcessingNode from './WorkletProcessingNode'; import WorkletSourceNode from './WorkletSourceNode'; @@ -355,4 +356,8 @@ export default class BaseAudioContext { this.context.createConvolver(buffer?.buffer, disableNormalization) ); } + + createWaveShaper(): WaveShaperNode { + return new WaveShaperNode(this, this.context.createWaveShaper()); + } } diff --git a/packages/react-native-audio-api/src/core/WaveShaperNode.ts b/packages/react-native-audio-api/src/core/WaveShaperNode.ts new file mode 100644 index 000000000..069c4d52b --- /dev/null +++ b/packages/react-native-audio-api/src/core/WaveShaperNode.ts @@ -0,0 +1,43 @@ +import AudioNode from './AudioNode'; +import { InvalidStateError } from '../errors'; +import { IWaveShaperNode } from '../interfaces'; + +export default class WaveShaperNode extends AudioNode { + private isCurveSet: boolean = false; + + get curve(): Float32Array | null { + if (!this.isCurveSet) { + return null; + } + + return (this.node as IWaveShaperNode).curve; + } + + get oversample(): OverSampleType { + return (this.node as IWaveShaperNode).oversample; + } + + set curve(curve: Float32Array | null) { + if (curve !== null) { + if (this.isCurveSet) { + throw new InvalidStateError( + 'The curve can only be set once and cannot be changed afterwards.' + ); + } + + if (curve.length < 2) { + throw new InvalidStateError( + 'The curve must have at least two values if not null.' + ); + } + + this.isCurveSet = true; + } + + (this.node as IWaveShaperNode).setCurve(curve); + } + + set oversample(value: OverSampleType) { + (this.node as IWaveShaperNode).oversample = value; + } +} diff --git a/packages/react-native-audio-api/src/interfaces.ts b/packages/react-native-audio-api/src/interfaces.ts index 331586881..36ca08d57 100644 --- a/packages/react-native-audio-api/src/interfaces.ts +++ b/packages/react-native-audio-api/src/interfaces.ts @@ -10,6 +10,7 @@ import type { OscillatorType, Result, WindowType, + OverSampleType, } from './types'; // IMPORTANT: use only IClass, because it is a part of contract between cpp host object and js layer @@ -91,6 +92,7 @@ export interface IBaseAudioContext { disableNormalization: boolean ) => IConvolverNode; createStreamer: () => IStreamerNode | null; // null when FFmpeg is not enabled + createWaveShaper: () => IWaveShaperNode; } export interface IAudioContext extends IBaseAudioContext { @@ -289,6 +291,12 @@ export interface IWorkletSourceNode extends IAudioScheduledSourceNode {} export interface IWorkletProcessingNode extends IAudioNode {} +export interface IWaveShaperNode extends IAudioNode { + readonly curve: Float32Array | null; + oversample: OverSampleType; + + setCurve(curve: Float32Array | null): void; +} export interface IAudioRecorderCallbackOptions extends AudioRecorderCallbackOptions { callbackId: string; @@ -343,7 +351,7 @@ export interface IAudioDecoder { export interface IAudioStretcher { changePlaybackSpeed: ( - arrayBuffer: AudioBuffer, + arrayBuffer: IAudioBuffer, playbackSpeed: number ) => Promise; } diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts index a8ac7080a..550d14c5a 100644 --- a/packages/react-native-audio-api/src/types.ts +++ b/packages/react-native-audio-api/src/types.ts @@ -121,6 +121,8 @@ export interface ConvolverNodeOptions { disableNormalization?: boolean; } +export type OverSampleType = 'none' | '2x' | '4x'; + export interface AudioRecorderCallbackOptions { /** * The desired sample rate (in Hz) for audio buffers delivered to the diff --git a/packages/react-native-audio-api/src/web-core/AudioContext.tsx b/packages/react-native-audio-api/src/web-core/AudioContext.tsx index f2770ef11..ed800f75b 100644 --- a/packages/react-native-audio-api/src/web-core/AudioContext.tsx +++ b/packages/react-native-audio-api/src/web-core/AudioContext.tsx @@ -23,6 +23,7 @@ import { ConvolverNodeOptions } from './ConvolverNodeOptions'; import { globalWasmPromise, globalTag } from './custom/LoadCustomWasm'; import ConstantSourceNode from './ConstantSourceNode'; +import WaveShaperNode from './WaveShaperNode'; export default class AudioContext implements BaseAudioContext { readonly context: globalThis.AudioContext; @@ -175,6 +176,10 @@ export default class AudioContext implements BaseAudioContext { return new AnalyserNode(this, this.context.createAnalyser()); } + createWaveShaper(): WaveShaperNode { + return new WaveShaperNode(this, this.context.createWaveShaper()); + } + async decodeAudioDataSource(source: string): Promise { const arrayBuffer = await fetch(source).then((response) => response.arrayBuffer() diff --git a/packages/react-native-audio-api/src/web-core/BaseAudioContext.tsx b/packages/react-native-audio-api/src/web-core/BaseAudioContext.tsx index c9341289f..d94b2be64 100644 --- a/packages/react-native-audio-api/src/web-core/BaseAudioContext.tsx +++ b/packages/react-native-audio-api/src/web-core/BaseAudioContext.tsx @@ -8,6 +8,7 @@ import AudioDestinationNode from './AudioDestinationNode'; import AudioBuffer from './AudioBuffer'; import AudioBufferSourceNode from './AudioBufferSourceNode'; import BiquadFilterNode from './BiquadFilterNode'; +import DelayNode from './DelayNode'; import IIRFilterNode from './IIRFilterNode'; import GainNode from './GainNode'; import OscillatorNode from './OscillatorNode'; @@ -15,7 +16,7 @@ import PeriodicWave from './PeriodicWave'; import StereoPannerNode from './StereoPannerNode'; import ConstantSourceNode from './ConstantSourceNode'; import ConvolverNode from './ConvolverNode'; -import DelayNode from './DelayNode'; +import WaveShaperNode from './WaveShaperNode'; export default interface BaseAudioContext { readonly context: globalThis.BaseAudioContext; @@ -45,6 +46,7 @@ export default interface BaseAudioContext { constraints?: PeriodicWaveConstraints ): PeriodicWave; createAnalyser(): AnalyserNode; + createWaveShaper(): WaveShaperNode; decodeAudioDataSource(source: string): Promise; decodeAudioData(arrayBuffer: ArrayBuffer): Promise; } diff --git a/packages/react-native-audio-api/src/web-core/OfflineAudioContext.tsx b/packages/react-native-audio-api/src/web-core/OfflineAudioContext.tsx index 54c7f9288..e0dc900cf 100644 --- a/packages/react-native-audio-api/src/web-core/OfflineAudioContext.tsx +++ b/packages/react-native-audio-api/src/web-core/OfflineAudioContext.tsx @@ -18,6 +18,7 @@ import OscillatorNode from './OscillatorNode'; import PeriodicWave from './PeriodicWave'; import StereoPannerNode from './StereoPannerNode'; import ConstantSourceNode from './ConstantSourceNode'; +import WaveShaperNode from './WaveShaperNode'; import { globalWasmPromise, globalTag } from './custom/LoadCustomWasm'; import ConvolverNode from './ConvolverNode'; @@ -181,6 +182,10 @@ export default class OfflineAudioContext implements BaseAudioContext { return new AnalyserNode(this, this.context.createAnalyser()); } + createWaveShaper(): WaveShaperNode { + return new WaveShaperNode(this, this.context.createWaveShaper()); + } + async decodeAudioDataSource(source: string): Promise { const arrayBuffer = await fetch(source).then((response) => response.arrayBuffer() diff --git a/packages/react-native-audio-api/src/web-core/WaveShaperNode.tsx b/packages/react-native-audio-api/src/web-core/WaveShaperNode.tsx new file mode 100644 index 000000000..96addac2d --- /dev/null +++ b/packages/react-native-audio-api/src/web-core/WaveShaperNode.tsx @@ -0,0 +1,42 @@ +import { InvalidStateError } from '../errors'; +import AudioNode from './AudioNode'; + +export default class WaveShaperNode extends AudioNode { + private isCurveSet: boolean = false; + + get curve(): Float32Array | null { + if (!this.isCurveSet) { + return null; + } + + return (this.node as globalThis.WaveShaperNode).curve; + } + + get oversample(): OverSampleType { + return (this.node as globalThis.WaveShaperNode).oversample; + } + + set curve(curve: Float32Array | null) { + if (curve !== null) { + if (this.isCurveSet) { + throw new InvalidStateError( + 'The curve can only be set once and cannot be changed afterwards.' + ); + } + + if (curve.length < 2) { + throw new InvalidStateError( + 'The curve must have at least two values if not null.' + ); + } + + this.isCurveSet = true; + } + + (this.node as globalThis.WaveShaperNode).curve = curve; + } + + set oversample(value: OverSampleType) { + (this.node as globalThis.WaveShaperNode).oversample = value; + } +}