diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index 505ed2a7f..0e78a0518 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -368,6 +368,7 @@ Resolution pitfalls learned the hard way (both handled inside `package-root.js`) | New `.cpp` not compiled in tests | Glob picks it up automatically — may need cmake reconfigure | Delete `test/build/` and re-run | | iOS compile error `unknown type 'id'` | C++ file included ObjC-only header | Compile that file as ObjC++ (separate subspec with `-x objective-c++`) | | `RCT_NEW_ARCH_ENABLED` undefined on Android | Old RN gradle plugin | Ensure `newArchEnabled=true` in app's `gradle.properties` | +| Android incremental build only: `Unresolved reference` to a top-level Kotlin function, or `Cannot access '…': it is internal in file`, while `--rerun-tasks` passes | Incremental Kotlin rounds treat the module's own earlier classes as a foreign module (library is built through the `node_modules/react-native-audio-api` symlink) | Use only public classes/objects across files in `android/src/main/java`; no top-level functions, no `internal` | | clangd only: `'React/RCTBridgeModule.h' file not found` in `.mm` files | `compile_commands.json` has no framework search path | See *clangd compile database* below — regenerate with `yarn setup:clangd` | ## clangd compile database diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 8d4d6b222..52be48903 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -132,7 +132,7 @@ Per-quantum processable state (`ALWAYS_`/`CONDITIONAL_`/`NOT_PROCESSABLE`) is de | Non-primitive, can be written by audio thread | Triple buffer (see `AnalyserNode` for reference) | | CPU-heavy work, must not block JS or audio | `TaskOffloader` on a dedicated worker thread | | Context lifecycle (`resume`/`suspend`/`close`) | `scheduleContextPromise` → `pendingPromisesOffloader_` | -| Platform code must reach the live recorder without going through JS | Process-global handle (`ActiveRecorderHandle` — recursive mutex + `weak_ptr`; `tryStart` / `stopAndReturnInfo` / `stopAndReturnState` / `pause` / `resume` take the handle mutex before `AudioRecorder`). Android: static-JNI `NativeRecorderControl` (no HybridData). iOS: `AudioAPIModule.setAudioSessionActivity(false)` calls `stopAndReturnInfo()` before deactivating the session. HostObject `start` / `stop` go through the handle; HostObject `pause` / `resume` still call `AudioRecorder` directly. Android blocking calls run on a Kotlin executor (`goAsync()` in receivers), never a detached `std::thread` | +| Platform code must reach the live recorder without going through JS | Process-global handle (`ActiveRecorderHandle` — recursive mutex + `weak_ptr`; `tryStart` / `stopAndReturnInfo` / `stopAndReturnState` / `pause` / `resume` take the handle mutex before `AudioRecorder`). Android: static-JNI `NativeRecorderControl` (no HybridData). iOS: `AudioAPIModule.setSystemActivity(false)` calls `stopAndReturnInfo()` before deactivating the session. HostObject `start` / `stop` go through the handle; HostObject `pause` / `resume` still call `AudioRecorder` directly. Android blocking calls run on a Kotlin executor (`goAsync()` in receivers), never a detached `std::thread` | --- diff --git a/.claude/skills/turbo-modules/SKILL.md b/.claude/skills/turbo-modules/SKILL.md index 3c6372582..ba9667e27 100644 --- a/.claude/skills/turbo-modules/SKILL.md +++ b/.claude/skills/turbo-modules/SKILL.md @@ -80,8 +80,8 @@ Defines the TypeScript interface for the native module. Codegen (React Native's interface Spec extends TurboModule { install(): boolean; // synchronous — MUST run first getDevicePreferredSampleRate(): number; // synchronous - setAudioSessionActivity(enabled: boolean): Promise; - setAudioSessionOptions(...): void; + setSystemActivity(enabled: boolean): Promise; + setSystemOptions(...): void; observeAudioInterruptions(focusType, enabled): void; requestRecordingPermissions(): Promise; // ... audio devices, notifications ... diff --git a/CLAUDE.md b/CLAUDE.md index 9c0545389..a1e50e43a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ packages/custom-node-generator/ # Code generation tooling - **Optional FFmpeg**: Audio decoding via FFmpeg can be conditionally compiled out - **Audio Worklets**: JavaScript runs on the audio thread via React Native Worklets - **Notification-Driven Foreground Service (Android)**: `NotificationRegistry.showNotification` → `ForegroundServiceManager.subscribe` → `CentralizedForegroundService`; service lifetime follows notification visibility, never recorder/player state. The library manifest is empty — consuming apps declare the `` (Expo plugin `withAudioAPI.ts` or manually), where `android:stopWithTask` (plugin option `androidFSStopWithTask`) decides whether the service and an in-progress recording survive task removal. The recording notification's swipe policy (`dismissible` option) is fixed when the notification is built and travels as an extra of its delete intent, so `RecordingNotificationReceiver` stays stateless: a pinned notification is re-posted after a swipe (Android 14+ lets users swipe `setOngoing(true)` notifications; 13 honours the flag; 12 and older pin every foreground-service notification), a dismissible one stops the recording through the same path as the stop action -- **JS-Independent Recorder Control**: `ActiveRecorderHandle` (common C++, one-slot `weak_ptr` published by `tryStart`, released by a successful stop) is the process-global occupant. Android notification actions reach it through static-JNI `NativeRecorderControl` (no HybridData/React context — the reverse of `NativeFileInfo`). iOS `setAudioSessionActivity(false)` stops a live recording through the same handle before deactivating the session. Session methods take the handle mutex before `AudioRecorder`. `pause` / `resume` / `stopAndReturnState` / `currentState` return `RecorderState` (`core/inputs/RecorderState.h`, Kotlin ordinal mirror) so `RecordingNotificationReceiver` can render from that value. `stopAndReturnInfo` returns the recorder's file-info `Result` (JS `stop`). Successful stops with paths stash consume-once for `AudioRecorder.consumeLastRecordingResult()`; `AudioRecorder.isRecordingOngoing()` probes a session that outlived the UI +- **JS-Independent Recorder Control**: `ActiveRecorderHandle` (common C++, one-slot `weak_ptr` published by `tryStart`, released by a successful stop) is the process-global occupant. Android notification actions reach it through static-JNI `NativeRecorderControl` (no HybridData/React context — the reverse of `NativeFileInfo`). iOS `setSystemActivity(false)` stops a live recording through the same handle before deactivating the session. Session methods take the handle mutex before `AudioRecorder`. `pause` / `resume` / `stopAndReturnState` / `currentState` return `RecorderState` (`core/inputs/RecorderState.h`, Kotlin ordinal mirror) so `RecordingNotificationReceiver` can render from that value. `stopAndReturnInfo` returns the recorder's file-info `Result` (JS `stop`). Successful stops with paths stash consume-once for `AudioRecorder.consumeLastRecordingResult()`; `AudioRecorder.isRecordingOngoing()` probes a session that outlived the UI - **Testable C++ dependencies**: consumers take interface types (`std::shared_ptr`); construct concrete implementations only at platform bootstrap. Example: audio event registry (use `IAudioEventHandlerRegistry` more often than `AudioEventHandlerRegistry`). ### Native Module Entry Points diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 0132554e3..3d99b42b7 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -33,7 +33,7 @@ apps/ Bottom Tabs ├── Tests → 2-column grid of Examples (simple feature demos) ├── Demo Apps → Single-column list of Demos (full mini-apps) -└── Other → Placeholder +└── Other → Internal tooling / repro screens, registered in `other/index.ts` (`otherScreens`); add the key to `NavigationParamList` in `examples/index.ts` ``` Each item navigates into a Stack screen. No manual stack registration needed — `examples/index.ts` and `demos/index.ts` drive it automatically. @@ -264,12 +264,12 @@ function MyPedal({ context, inputNode, outputNode }: PedalProps) { ```tsx import { AudioManager } from 'react-native-audio-api'; -AudioManager.setAudioSessionOptions({ +AudioManager.setSystemOptions({ iosCategory: 'playback', // or 'playAndRecord' for mic input iosMode: 'default', iosOptions: [], }); -await AudioManager.setAudioSessionActivity(true); +await AudioManager.setSystemActivity(true); ``` ### Recording permission diff --git a/apps/common-app/src/demos/Crossfade/Crossfade.tsx b/apps/common-app/src/demos/Crossfade/Crossfade.tsx index d56641b42..a8c763370 100644 --- a/apps/common-app/src/demos/Crossfade/Crossfade.tsx +++ b/apps/common-app/src/demos/Crossfade/Crossfade.tsx @@ -160,12 +160,12 @@ const Crossfade: FC = () => { return; } - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playback', iosMode: 'default', iosOptions: [], }); - await AudioManager.setAudioSessionActivity(true); + await AudioManager.setSystemActivity(true); if (audioContext.current.state === 'suspended') { await audioContext.current.resume(); @@ -226,7 +226,7 @@ const Crossfade: FC = () => { gainNode2.current = null; await audioContext.current.suspend(); - await AudioManager.setAudioSessionActivity(false); + await AudioManager.setSystemActivity(false); setIsPlaying(false); }, [isPlaying]); diff --git a/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx b/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx index 82f7ec02c..c8e80012a 100644 --- a/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx +++ b/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx @@ -38,11 +38,11 @@ export default function PedalBoard() { useEffect(() => { const init = async () => { setIsLoading(true); - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playAndRecord', iosMode: 'default', }) - AudioManager.setAudioSessionActivity(true); + AudioManager.setSystemActivity(true); try { // Load audio file diff --git a/apps/common-app/src/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx index 37ddb0e2b..4f88707b1 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -85,14 +85,14 @@ const Record: FC = () => { setHasPermissions(true); } - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playAndRecord', iosMode: 'default', iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'], }); try { - await AudioManager.setAudioSessionActivity(true); + await AudioManager.setSystemActivity(true); } catch (error) { console.error(error); Alert.alert('Error', 'Failed to activate audio session for recording.'); @@ -337,7 +337,7 @@ const Record: FC = () => { stopPlayback(); if (!AudioRecorder.isRecordingOngoing()) { - AudioManager.setAudioSessionActivity(false); + AudioManager.setSystemActivity(false); } }; }, [stopPlayback]); diff --git a/apps/common-app/src/examples/AudioFile/AudioFile.tsx b/apps/common-app/src/examples/AudioFile/AudioFile.tsx index 6e88994bc..612e9bbe1 100644 --- a/apps/common-app/src/examples/AudioFile/AudioFile.tsx +++ b/apps/common-app/src/examples/AudioFile/AudioFile.tsx @@ -29,14 +29,14 @@ const AudioFile: FC = () => { setPositionPercentage(offset); }); - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playback', iosMode: 'default', iosOptions: [], }); try { - await AudioManager.setAudioSessionActivity(true); + await AudioManager.setSystemActivity(true); } catch (error) { console.error('Failed to activate audio session:', error); Alert.alert( @@ -100,7 +100,7 @@ const AudioFile: FC = () => { return () => { AudioPlayer.reset(); PlaybackNotificationManager.hide(); - AudioManager.setAudioSessionActivity(false); + AudioManager.setSystemActivity(false); }; }, [fetchAudioBuffer]); @@ -152,7 +152,7 @@ const AudioFile: FC = () => { if (event.type === 'ended' && wasPlaying) { BackgroundTimer.setTimeout(async () => { AudioPlayer.setVolume(1.0); - AudioManager.setAudioSessionActivity(true); + AudioManager.setSystemActivity(true); await AudioPlayer.play(); setIsPlaying(true); setWasPlaying(false); diff --git a/apps/common-app/src/examples/ChannelMergerSplitter/ChannelMergerSplitter.tsx b/apps/common-app/src/examples/ChannelMergerSplitter/ChannelMergerSplitter.tsx index 39d154941..3bc5a0d35 100644 --- a/apps/common-app/src/examples/ChannelMergerSplitter/ChannelMergerSplitter.tsx +++ b/apps/common-app/src/examples/ChannelMergerSplitter/ChannelMergerSplitter.tsx @@ -64,12 +64,12 @@ const ChannelMergerSplitter: FC = () => { return; } - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playback', iosMode: 'default', iosOptions: [], }); - await AudioManager.setAudioSessionActivity(true); + await AudioManager.setSystemActivity(true); if (ctx.state === 'suspended') { await ctx.resume(); @@ -170,7 +170,7 @@ const ChannelMergerSplitter: FC = () => { bufferRef.current = null; ctx.close().catch(() => {}); audioContextRef.current = null; - AudioManager.setAudioSessionActivity(false).catch(() => {}); + AudioManager.setSystemActivity(false).catch(() => {}); }; }, [teardown]); diff --git a/apps/common-app/src/examples/ConvolverIR/ConvolverIR.tsx b/apps/common-app/src/examples/ConvolverIR/ConvolverIR.tsx index 4a1598f35..dd8d5f339 100644 --- a/apps/common-app/src/examples/ConvolverIR/ConvolverIR.tsx +++ b/apps/common-app/src/examples/ConvolverIR/ConvolverIR.tsx @@ -62,7 +62,7 @@ const ConvolverIR: FC = () => { bufferRef.current = null; ctx.close().catch(() => {}); audioContextRef.current = null; - AudioManager.setAudioSessionActivity(false).catch(() => {}); + AudioManager.setSystemActivity(false).catch(() => {}); }; }, []); @@ -72,7 +72,7 @@ const ConvolverIR: FC = () => { return; } bufferSourceRef.current?.stop(0); - // await AudioManager.setAudioSessionActivity(false); + // await AudioManager.setSystemActivity(false); setIsPlaying(false); }, []); @@ -88,12 +88,12 @@ const ConvolverIR: FC = () => { return; } - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playback', iosMode: 'default', iosOptions: [], }); - await AudioManager.setAudioSessionActivity(true); + await AudioManager.setSystemActivity(true); if (ctx.state === 'suspended') { await ctx.resume(); diff --git a/apps/common-app/src/examples/EchoCancellation/EchoCancellation.tsx b/apps/common-app/src/examples/EchoCancellation/EchoCancellation.tsx new file mode 100644 index 000000000..5ee8348f6 --- /dev/null +++ b/apps/common-app/src/examples/EchoCancellation/EchoCancellation.tsx @@ -0,0 +1,410 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import { Platform, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { + AudioContext, + AudioManager, + AudioRecorder, +} from 'react-native-audio-api'; +import type { + AndroidAudioMode, + AndroidInputPreset, + AndroidOutputProfile, + AudioBufferSourceNode, + IOSMode, +} from 'react-native-audio-api'; + +import { Button, Container } from '../../components'; +import { colors, layout } from '../../styles'; +import { UnsupportedNotice } from '../../testComponents'; +import voiceSample from '../AudioFile/voice-sample-landing.mp3'; + +interface SetupText { + title: string; + description: string; +} + +interface AndroidEchoConfig extends SetupText { + androidMode?: AndroidAudioMode; + /** + * Routes voice-communication output to the speaker via setCommunicationDevice + * without a session. + */ + requestSpeakerWithoutSession?: boolean; + outputProfile: AndroidOutputProfile; + inputPreset: AndroidInputPreset; +} + +interface IOSEchoConfig extends SetupText { + iosMode: IOSMode; + voiceProcessing: boolean; +} + +interface EchoSetup { + key: string; + android: AndroidEchoConfig; + ios: IOSEchoConfig; +} + +const ECHO_SETUPS: EchoSetup[] = [ + { + key: 'sessionAndVoiceStreams', + android: { + title: 'Session + voice streams', + description: + 'inCommunication session routed to the speaker, voiceCommunication output and input', + androidMode: 'inCommunication', + outputProfile: 'voiceCommunication', + inputPreset: 'voiceCommunication', + }, + ios: { + title: 'voiceChat + voice processing', + description: + 'playAndRecord with voiceChat mode on the speaker, recorder voice processing on', + iosMode: 'voiceChat', + voiceProcessing: true, + }, + }, + { + key: 'voiceStreamsOnly', + android: { + title: 'Voice streams, no session', + description: + 'voiceCommunication output and input, normal audio mode, speaker requested with setCommunicationDevice', + requestSpeakerWithoutSession: true, + outputProfile: 'voiceCommunication', + inputPreset: 'voiceCommunication', + }, + ios: { + title: 'Voice processing, default mode', + description: + 'playAndRecord with default mode on the speaker, recorder voice processing on', + iosMode: 'default', + voiceProcessing: true, + }, + }, + { + key: 'noEchoCancellation', + android: { + title: 'No echo cancellation', + description: + 'media output, voiceRecognition input (platform default), normal audio mode', + outputProfile: 'media', + inputPreset: 'voiceRecognition', + }, + ios: { + title: 'No echo cancellation', + description: + 'playAndRecord with default mode on the speaker, recorder voice processing off', + iosMode: 'default', + voiceProcessing: false, + }, + }, +]; + +const variantOf = (setup: EchoSetup): SetupText => + Platform.OS === 'ios' ? setup.ios : setup.android; + +const HINT = Platform.select({ + ios: 'Stay silent, keep the phone on the table, and set the volume to maximum. Voice processing also lowers the playback volume, so use Replay to hear how much of the drop is cancelled echo.', + default: + 'Stay silent, keep the phone on the table, and set both media and call volume to maximum: the voice setups play at call volume.', +}); + +const NOISE_FLOOR_SECONDS = 1.5; +const PLAYBACK_SECONDS = 5; +// Skips the start of playback so output latency does not dilute the echo level. +const PLAYBACK_SETTLE_SECONDS = 0.3; +const RECORDER_CHUNK_FRAMES = 2048; + +interface EchoResult { + setup: EchoSetup; + noiseFloorDb: number; + echoDb: number; + samples: Float32Array; + sampleRate: number; +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const rmsDb = (samples: Float32Array, from: number, to: number) => { + let sumOfSquares = 0; + const end = Math.min(to, samples.length); + for (let i = from; i < end; i++) { + sumOfSquares += samples[i] * samples[i]; + } + const rms = Math.sqrt(sumOfSquares / Math.max(1, end - from)); + return 20 * Math.log10(Math.max(rms, 1e-9)); +}; + +const concatChunks = (chunks: Float32Array[]) => { + const samples = new Float32Array( + chunks.reduce((length, chunk) => length + chunk.length, 0) + ); + let offset = 0; + chunks.forEach((chunk) => { + samples.set(chunk, offset); + offset += chunk.length; + }); + return samples; +}; + +const formatDb = (db: number) => `${db.toFixed(1)} dBFS`; + +async function recordEcho(setup: EchoSetup): Promise { + const { android, ios } = setup; + AudioManager.setSystemOptions({ + iosCategory: 'playAndRecord', + iosMode: ios.iosMode, + // playAndRecord routes to the receiver unless told otherwise. + iosOptions: ['defaultToSpeaker'], + androidMode: android.androidMode, + androidCommunicationDevice: android.androidMode ? 'speaker' : undefined, + }); + // Without androidMode this only activates the iOS session; Android no-ops. + await AudioManager.setSystemActivity(true); + if (Platform.OS === 'android' && android.requestSpeakerWithoutSession) { + await AudioManager.setCommunicationDevice('speaker'); + } + + const context = new AudioContext({ + androidOutputProfile: android.outputProfile, + }); + const recorder = new AudioRecorder({ + androidInputPreset: android.inputPreset, + iosVoiceProcessing: ios.voiceProcessing, + }); + let source: AudioBufferSourceNode | null = null; + + try { + const voice = await context.decodeAudioData(voiceSample); + if (context.state === 'suspended') { + await context.resume(); + } + + const chunks: Float32Array[] = []; + const callback = recorder.onAudioReady( + { + sampleRate: context.sampleRate, + channelCount: 1, + bufferLength: RECORDER_CHUNK_FRAMES, + }, + (event) => chunks.push(event.buffer.getChannelData(0).slice()) + ); + if (callback.status === 'error') { + throw new Error(callback.message); + } + + const started = await recorder.start(); + if (started.status === 'error') { + throw new Error(started.message); + } + + await wait(NOISE_FLOOR_SECONDS * 1000); + const playbackStartFrame = chunks.reduce( + (length, chunk) => length + chunk.length, + 0 + ); + + source = context.createBufferSource(); + source.buffer = voice; + source.loop = true; + source.connect(context.destination); + source.start(); + await wait(PLAYBACK_SECONDS * 1000); + + source.stop(); + await recorder.stop(); + + const samples = concatChunks(chunks); + const settleFrames = Math.round( + PLAYBACK_SETTLE_SECONDS * context.sampleRate + ); + return { + setup, + noiseFloorDb: rmsDb(samples, 0, playbackStartFrame), + echoDb: rmsDb(samples, playbackStartFrame + settleFrames, samples.length), + samples, + sampleRate: context.sampleRate, + }; + } finally { + source?.stop(); + recorder.clearOnAudioReady(); + if (recorder.isRecording()) { + await recorder.stop(); + } + await context.close(); + await AudioManager.setSystemActivity(false).catch(() => {}); + } +} + +const EchoCancellation: FC = () => { + const [runningSetup, setRunningSetup] = useState(null); + const [results, setResults] = useState([]); + const [error, setError] = useState(null); + const replayContextRef = useRef(null); + + useEffect( + () => () => { + replayContextRef.current?.close(); + replayContextRef.current = null; + }, + [] + ); + + const runSetup = async (setup: EchoSetup) => { + setError(null); + const permission = await AudioManager.requestRecordingPermissions(); + if (permission !== 'Granted') { + setError(`Recording permission: ${permission}`); + return; + } + + setRunningSetup(setup.key); + try { + const result = await recordEcho(setup); + setResults((previous) => [ + result, + ...previous.filter((entry) => entry.setup.key !== setup.key), + ]); + } catch (runError) { + setError( + `${variantOf(setup).title} failed: ${runError instanceof Error ? runError.message : String(runError)}` + ); + } finally { + setRunningSetup(null); + } + }; + + // Replays as plain media playback so every recording is heard at the same + // volume, without voice processing. + const replay = async (result: EchoResult) => { + AudioManager.setSystemOptions({ + iosCategory: 'playback', + iosMode: 'default', + iosOptions: [], + }); + await AudioManager.setSystemActivity(true); + + replayContextRef.current?.close(); + const context = new AudioContext({ androidOutputProfile: 'media' }); + replayContextRef.current = context; + + const buffer = context.createBuffer( + 1, + result.samples.length, + result.sampleRate + ); + buffer.copyToChannel(result.samples, 0); + const source = context.createBufferSource(); + source.buffer = buffer; + source.connect(context.destination); + source.start(); + }; + + if (Platform.OS !== 'android' && Platform.OS !== 'ios') { + return ( + + + + ); + } + + return ( + + + + Each run records {NOISE_FLOOR_SECONDS} s of silence, then{' '} + {PLAYBACK_SECONDS} s while the voice sample plays on the speaker.{' '} + {HINT} The smaller the rise above the floor, the more echo was + cancelled. + + {ECHO_SETUPS.map((setup) => ( + + + {variantOf(setup).title} + {variantOf(setup).description} + +