/** * Soundwave — what a voice looks like while an app is listening to it. * * ```tsx * * ``` * * Four looks, because a voice screen needs different ones in different places: * `pills` for the few big capsules over a microphone button, `bars` for a * metering strip in a transcript, `line` for a travelling wave while the * assistant talks back, and `ambient` for a glow that takes the whole screen. * * ## It draws a level; it does not record one * * Nothing here touches the microphone. The app owns the recorder — the * permission prompt, the session, the platform quirks — and hands over a number * between 0 and 1, which keeps this component free of an audio dependency and * usable against a real meter, a synthesised one, or a remote peer's. * * With no `level` at all it animates its own plausible motion for the current * `state`, so a screen can be built and reviewed before any audio exists. * * ## Levels arrive faster than React should re-render * * A recorder reports every 30–60ms. Setting that in state is dozens of renders * a second for a number that only moves pixels, so `level` also accepts a * `SharedValue`: write metering straight into it and nothing above this * component ever re-renders. A plain number works too and is smoothed the same * way — it is the right choice when the level comes from something slow, like a * server-side speaking flag. * * Either way the value is smoothed with a fast attack and a slow release, which * is what makes a meter read as a meter: it snaps up on a syllable and falls * back gently, instead of chattering around every sample. * * ## How each one is drawn * * `bars` and `line` are a *single* animated SVG path — one vertical segment per * bar with a round cap, or one polyline — so forty bars cost one animated prop * a frame rather than forty animated views, and the capsule ends come free from * the stroke cap. `pills` is a handful of views, where that machinery would be * more expensive than the thing it saves. Everything runs in a worklet on the * UI thread; React renders on a resize and otherwise not at all. */ import { useEffect, useId, useState, type ReactElement, type ReactNode } from 'react'; import { StyleSheet, View, type LayoutChangeEvent, type ViewProps, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import Animated, { useAnimatedProps, useAnimatedStyle, useFrameCallback, useReducedMotion, useSharedValue, type SharedValue, } from 'react-native-reanimated'; import Svg, { Defs, LinearGradient as SvgGradient, Path, Stop } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { useIconColor } from '../../icons'; import { cn } from '../../utils/cn'; const AnimatedPath = Animated.createAnimatedComponent(Path); export type SoundwaveVariant = 'pills' | 'bars' | 'line' | 'ambient'; export type SoundwaveState = 'idle' | 'listening' | 'thinking' | 'speaking'; /** What each state is doing, for anyone who cannot see it. */ const STATE_LABEL: Record = { idle: 'Idle', listening: 'Listening', thinking: 'Thinking', speaking: 'Speaking', }; /** * How fast the smoothed level rises and falls, per second. * * Wildly asymmetric on purpose. A meter that rises and falls at the same rate * either lags the syllable that caused it or flickers on every sample; snapping * up and easing down tracks speech the way an ear expects it to. */ const ATTACK = 16; const RELEASE = 5; /** Never quite silent: a wave flat at zero reads as broken rather than quiet. */ const FLOOR = 0.04; /* -------------------------------------------------------------------------- */ /* Worklet maths */ /* -------------------------------------------------------------------------- */ function clamp01(value: number): number { 'worklet'; return value < 0 ? 0 : value > 1 ? 1 : value; } /** Deterministic hash in `[0, 1)`. Stable across frames and across mounts. */ function hashD(a: number, b: number): number { 'worklet'; const h = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453; return h - Math.floor(h); } /** One decimal place. Path strings are rebuilt every frame; every digit costs. */ function q(value: number): number { 'worklet'; return Math.round(value * 10) / 10; } /** * The motion a state runs on its own, with no level supplied. * * Layered at unrelated tempi so it never repeats on a beat — a single sine * reads as a pulse, and a pulse reads as a progress indicator rather than as a * voice. */ function idleEnergy(state: SoundwaveState, t: number): number { 'worklet'; if (state === 'listening') { return clamp01( 0.34 + 0.22 * Math.sin(t * 2.3) + 0.16 * Math.sin(t * 3.9 + 1.3) + 0.1 * Math.sin(t * 7.1 + 0.6) ); } if (state === 'speaking') { return clamp01( 0.46 + 0.27 * Math.sin(t * 3.1) + 0.18 * Math.sin(t * 5.7 + 0.8) + 0.09 * Math.sin(t * 9.3) ); } if (state === 'thinking') { return clamp01(0.18 + 0.1 * Math.sin(t * 1.6) + 0.05 * Math.sin(t * 2.9 + 2.1)); } return clamp01(0.07 + 0.05 * Math.sin(t * 1.1)); } /* -------------------------------------------------------------------------- */ /* The clock every variant shares */ /* -------------------------------------------------------------------------- */ interface Engine { /** Seconds since mount, scaled by `speed`. */ clock: SharedValue; /** Smoothed level, 0–1. What every variant actually draws. */ energy: SharedValue; /** Newest first is at the end. Only filled in `scrolling` mode. */ samples: SharedValue; } const isShared = (value: unknown): value is SharedValue => typeof value === 'object' && value !== null && 'value' in value; /** * One shared value to read, whether the caller passed a number or their own. * * A plain number is copied in; a `SharedValue` is handed straight back, which * is the whole reason for accepting one — metering written into it never * re-renders anything. */ function useNumberSource(value: number | SharedValue | undefined): SharedValue { const own = useSharedValue(0); const external = isShared(value) ? value : null; useEffect(() => { if (typeof value === 'number') own.value = value; }, [value, own]); return external ?? own; } /** How often `scrolling` mode takes a sample, in seconds. */ const SAMPLE_INTERVAL = 1 / 24; interface EngineOptions { level?: number | SharedValue; state: SoundwaveState; sensitivity: number; speed: number; paused: boolean; /** Sample count to keep for `scrolling` mode. Zero keeps none. */ history: number; } function useEngine({ level, state, sensitivity, speed, paused, history, }: EngineOptions): Engine { const reducedMotion = useReducedMotion(); const clock = useSharedValue(0); const energy = useSharedValue(0); const samples = useSharedValue([]); const nextSample = useSharedValue(0); const source = useNumberSource(level); const driven = level !== undefined; const running = !paused && !reducedMotion; const frame = useFrameCallback((info) => { 'worklet'; // Elapsed time is accumulated rather than read off the total, so `speed` // can change mid-animation without the wave jumping to wherever the new // rate would have put it. A dropped frame is clamped rather than honoured — // a 300ms hitch played back at full rate is a lurch. const delta = Math.min(info.timeSincePreviousFrame ?? 16, 48) / 1000; clock.value += delta * speed; const target = driven ? clamp01(source.value * sensitivity) : idleEnergy(state, clock.value); const rate = target > energy.value ? ATTACK : RELEASE; energy.value += (target - energy.value) * Math.min(1, delta * rate); if (history > 0 && clock.value >= nextSample.value) { nextSample.value = clock.value + SAMPLE_INTERVAL; // A little per-sample texture, so a held note is a band of varying bars // rather than a solid block — real speech never gives two equal samples. const jitter = 0.82 + 0.36 * hashD(clock.value, 3.3); const next = samples.value.slice(samples.value.length >= history ? 1 : 0); next.push(clamp01(energy.value * jitter)); samples.value = next; } }, false); const { setActive } = frame; useEffect(() => { setActive(running); return () => setActive(false); }, [running, setActive]); // Stopped is not empty: reduced motion and `paused` both get a representative // frame rather than a flat line, which is the difference between "not // animating" and "broken". useEffect(() => { if (running) return; const still = driven ? clamp01(source.value * sensitivity) : 0.42; energy.value = still; if (history > 0) { samples.value = Array.from({ length: history }, (_unused, index) => clamp01(still * (0.45 + 0.55 * Math.abs(Math.sin(index * 0.7)))) ); } }, [running, driven, sensitivity, history, source, energy, samples]); return { clock, energy, samples }; } /* -------------------------------------------------------------------------- */ /* pills */ /* -------------------------------------------------------------------------- */ /** * One capsule. * * A component rather than a loop of `useAnimatedStyle` in the parent, because * the count is a prop — hooks in a loop is a rule waiting to be broken by * whoever changes the default. */ function Pill({ index, count, engine, width, minHeight, maxHeight, color, }: { index: number; count: number; engine: Engine; width: number; minHeight: number; maxHeight: number; color: string; }) { const style = useAnimatedStyle(() => { /* * Each capsule runs at its own tempo and phase, hashed off its index. Give * them one tempo and they rise and fall as a block, which reads as a * loading bar; detune them and the group reads as something responding to a * voice, even though they all follow the same level. */ const rate = 2.2 + hashD(index, 1.7) * 2.6; const phase = hashD(index, 5.1) * Math.PI * 2; const wobble = 0.55 + 0.45 * Math.sin(engine.clock.value * rate + phase); // The middle capsules lead. A voice meter with a flat profile looks like a // level indicator; a slight hump looks like a mouth. const centre = count > 1 ? 1 - Math.abs((index / (count - 1)) * 2 - 1) : 1; const shape = 0.68 + 0.32 * centre; const value = clamp01(Math.max(FLOOR, engine.energy.value) * wobble * shape); return { height: minHeight + (maxHeight - minHeight) * value }; }); return ( ); } function PillsWave({ engine, count, barWidth, barGap, height, color, }: { engine: Engine; count: number; barWidth: number; barGap: number; height: number; color: string; }) { // Never shorter than a circle: a capsule squashed past its own width stops // being a capsule. const minHeight = barWidth * 1.35; return ( {Array.from({ length: count }, (_unused, index) => ( ))} ); } /* -------------------------------------------------------------------------- */ /* bars */ /* -------------------------------------------------------------------------- */ /** * One bar's height, 0–1, from whichever source is driving this wave. * * Pulled out of the drawing loop because two paths walk the same bars — the * played part of a recording and the rest of it — and a bar has to come out * identical in both or the split shows as a step. */ function barValue( i: number, count: number, scrolling: boolean, supplied: number[], history: number[], clock: number, energy: number ): number { 'worklet'; if (scrolling) { // The buffer fills from empty, so the newest sample sits at the trailing // edge from the first frame rather than the wave sliding in from nowhere. const offset = i - (count - history.length); return offset >= 0 ? (history[offset] ?? 0) : 0; } if (supplied.length) { // Real bands, resampled to the bar count — an analysis rarely hands back // exactly as many numbers as there are bars, and a recorded waveform never // does. return supplied[Math.floor((i / count) * supplied.length)] ?? 0; } const rate = 2.1 + hashD(i, 1.3) * 2.9; const phase = hashD(i, 4.7) * Math.PI * 2; const wobble = 0.5 + 0.5 * Math.sin(clock * rate + phase); const centre = count > 1 ? Math.sin((Math.PI * (i + 0.5)) / count) : 1; return energy * wobble * (0.45 + 0.55 * centre); } /** Ink left on the part of a recording that has not played yet. */ const UNPLAYED_OPACITY = 0.3; /** * The stroke a wave is painted with, when it is not one flat colour. * * One definition covers both jobs, because they are the same object: a run of * colour stops across the width, optionally pinned to transparent at each end. * Doing the edge fade this way rather than as an overlay in the background * colour is what lets a wave sit on a card, a bubble or a photograph — an * overlay only disappears against the one surface it was told about. */ function WaveGradient({ id, colors, fade, edge, }: { id: string; colors: readonly string[]; fade: boolean; edge: number; }) { const stops = colors.length ? colors : ['#000000']; const span = fade ? 1 - edge * 2 : 1; const from = fade ? edge : 0; const nodes: ReactElement[] = []; if (fade) nodes.push(); stops.forEach((stop, index) => { const at = stops.length > 1 ? from + (index / (stops.length - 1)) * span : from; nodes.push( ); }); if (fade) { nodes.push( ); } return ( {/* Built as a list rather than inline, because the stop count varies with the palette and with whether the ends are faded. */} {nodes} ); } function BarsWave({ engine, bands, progress, hasProgress, count, barWidth, height, width, centered, scrolling, stroke, trackStroke, defs, }: { engine: Engine; bands: SharedValue; progress: SharedValue; hasProgress: boolean; count: number; barWidth: number; height: number; width: number; centered: boolean; scrolling: boolean; stroke: string; /** Explicit colour for the unplayed part, if the caller set one. */ trackStroke: string | null; defs: ReactNode; }) { /* * Two paths, split at the playhead: the bars behind it keep full ink, the * ones ahead are dimmed. It is two animated props a frame rather than one, * and still nothing per bar — which is the point of drawing bars as a stroked * path in the first place. * * With no `progress` the first path takes everything and the second is empty, * so a live meter and a voice note run the same code. */ const drawSegment = (played: boolean) => () => { 'worklet'; const step = count > 1 ? (width - barWidth) / (count - 1) : 0; const span = height - barWidth; const supplied = bands.value; const history = engine.samples.value; const head = hasProgress ? clamp01(progress.value) * count : count; let d = ''; for (let i = 0; i < count; i++) { // The playhead cuts between bars, not through one: a bar is either // played or it is not, which is what makes the fill land on a beat. const isPlayed = i + 0.5 <= head; if (isPlayed !== played) continue; const value = barValue( i, count, scrolling, supplied, history, engine.clock.value, engine.energy.value ); const length = span * clamp01(Math.max(FLOOR, value)); const x = q(barWidth / 2 + i * step); if (centered) { const half = length / 2; d += `M${x} ${q(height / 2 - half)}L${x} ${q(height / 2 + half)}`; } else { d += `M${x} ${q(height - barWidth / 2)}L${x} ${q(height - barWidth / 2 - length)}`; } } return { d }; }; const playedProps = useAnimatedProps(drawSegment(true)); const restProps = useAnimatedProps(drawSegment(false)); return ( {defs} ); } /* -------------------------------------------------------------------------- */ /* line */ /* -------------------------------------------------------------------------- */ /** Points along the wave. Enough to read as a curve, few enough to rebuild. */ const LINE_POINTS = 56; function LineWave({ engine, width, height, strokeWidth, stroke, defs, }: { engine: Engine; width: number; height: number; strokeWidth: number; stroke: string; defs: ReactNode; }) { const animatedProps = useAnimatedProps(() => { const mid = height / 2; const amplitude = (height / 2 - strokeWidth) * clamp01(Math.max(FLOOR, engine.energy.value)); const t = engine.clock.value; let d = ''; for (let i = 0; i <= LINE_POINTS; i++) { const f = i / LINE_POINTS; const x = f * width; /* * Three waves at unrelated wavelengths, tapered to nothing at both ends. * The taper is what makes it a wave and not a rope: the line leaves and * meets the edges flat, so there is no hard stop where it is cut off. */ const taper = Math.sin(Math.PI * f); const y = mid - amplitude * taper * (0.62 * Math.sin(f * 12.6 - t * 3.1) + 0.26 * Math.sin(f * 21.4 - t * 4.7 + 1.1) + 0.12 * Math.sin(f * 33.2 - t * 2.3)); d += `${i === 0 ? 'M' : 'L'}${q(x)} ${q(y)}`; } return { d }; }); return ( {defs} ); } /* -------------------------------------------------------------------------- */ /* ambient */ /* -------------------------------------------------------------------------- */ /** A theme token name — `--color-info` — rather than a literal colour. */ const isToken = (value: string | undefined): value is string => typeof value === 'string' && value.startsWith('--'); /** `useCSSVariable` can hand back a non-string when a variable is unset. */ const asString = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined; /** Turns any resolved colour into the same colour at a given alpha. */ function withAlpha(color: string, alpha: number): string { if (color.startsWith('#')) { const hex = color.slice(1); const full = hex.length === 3 ? hex .split('') .map((c) => c + c) .join('') : hex.slice(0, 6); const r = parseInt(full.slice(0, 2), 16); const g = parseInt(full.slice(2, 4), 16); const b = parseInt(full.slice(4, 6), 16); if (Number.isNaN(r + g + b)) return color; return `rgba(${r}, ${g}, ${b}, ${alpha})`; } const channels = color.match(/rgba?\(([^)]+)\)/)?.[1]; if (channels) { const [r, g, b] = channels.split(',').map((part) => part.trim()); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } return color; } /** * The glow that fills a screen: a bloom rising off the bottom edge and a rim of * light around the rest. * * Both breathe on the level rather than only fading in and out — a glow that * changes opacity alone reads as a screen dimming, where one that also grows * reads as something in the room getting louder. */ function AmbientWave({ engine, color, bloomColors, radius, }: { engine: Engine; color: string; /** Bottom to top. Two or more colours make the bloom a gradient of its own. */ bloomColors: readonly string[] | null; radius: number; }) { const bloom = useAnimatedStyle(() => { const e = clamp01(Math.max(FLOOR, engine.energy.value)); const breath = 0.94 + 0.06 * Math.sin(engine.clock.value * 1.7); return { opacity: 0.25 + 0.75 * e, transform: [{ scaleY: (0.72 + 0.4 * e) * breath }], }; }); const rim = useAnimatedStyle(() => { const e = clamp01(Math.max(FLOOR, engine.energy.value)); return { opacity: 0.12 + 0.5 * e }; }); /* * The bloom fades in from nothing at the top whatever it is made of, so a * supplied palette is ramped rather than used flat — a gradient that starts * at full strength has a visible edge across the middle of the screen. */ const ramp: string[] = bloomColors?.length ? [ withAlpha(bloomColors[0]!, 0), ...bloomColors.map((stop, index) => withAlpha(stop, 0.16 + (0.34 * index) / Math.max(1, bloomColors.length - 1)) ), ] : [withAlpha(color, 0), withAlpha(color, 0.16), withAlpha(color, 0.5)]; return ( {/* The rim, as three rings of falling alpha. Three flat borders read as a soft edge where one crisp border reads as a frame around the screen. */} {[0, 1, 2].map((ring) => ( ))} ); } /* -------------------------------------------------------------------------- */ /* Component */ /* -------------------------------------------------------------------------- */ /** Per-variant geometry, so a bare `` looks right. */ const DEFAULTS: Record< SoundwaveVariant, { bars: number; barWidth: number; barGap: number; height: number } > = { pills: { bars: 4, barWidth: 28, barGap: 10, height: 96 }, bars: { bars: 40, barWidth: 3, barGap: 3, height: 56 }, line: { bars: 0, barWidth: 3, barGap: 0, height: 72 }, ambient: { bars: 0, barWidth: 0, barGap: 0, height: 0 }, }; export interface SoundwaveProps extends Omit { className?: string; /** * Which look to draw: `pills` for a few capsules over a microphone button, * `bars` for a metering strip, `line` for a travelling wave, `ambient` for a * glow that fills its parent. */ variant?: SoundwaveVariant; /** * What the app is doing. With no `level` supplied this picks the motion the * wave runs on its own; it always sets what a screen reader announces. */ state?: SoundwaveState; /** * Input level, 0–1, from your own recorder's metering. Pass a `SharedValue` * to keep updates off the JS thread entirely. Omit it and the wave animates * plausible motion for the current `state`. */ level?: number | SharedValue; /** * Per-band levels, 0–1 each, for `bars` in `static` mode when the app has a * real frequency analysis — or the stored shape of a finished recording. * Resampled to the bar count, and drawn still rather than animated. */ levels?: number[]; /** * Playback position through a recording, 0–1. Bars behind it keep full ink * and the rest are dimmed, which is the voice-note progress bar. `bars` only, * and it goes with `levels` — a recording has a fixed shape to play through. */ progress?: number | SharedValue; /** How many capsules (`pills`) or bars (`bars`) to draw. */ bars?: number; /** Capsule width, or bar stroke width. Also the stroke width of `line`. */ barWidth?: number; /** Gap between capsules. `bars` spaces itself evenly across the width. */ barGap?: number; /** Drawing height. `ambient` ignores it and fills its parent. */ height?: number; /** * `static` gives every bar a band of the current level; `scrolling` keeps a * history that slides across, newest at the trailing edge. `bars` only. */ mode?: 'static' | 'scrolling'; /** Grow bars from the middle out rather than up from the baseline. */ centered?: boolean; /** Fade the wave out at both ends, so it does not stop at a hard edge. */ fadeEdges?: boolean; /** Multiplier on the incoming level, applied before it is clamped to 1. */ sensitivity?: number; /** Multiplier on the wave's own tempo, including how fast history scrolls. */ speed?: number; /** * Ink. Takes a colour — `#f97316`, `rgba(…)` — or a **theme token name**, * `color="--color-info"`, which resolves against the active theme and follows * it into dark mode. * * Left unset, a wave inside a surface that publishes a foreground (a chat * bubble, a button) is drawn in that foreground, and anywhere else in * `--color-foreground` — or `--color-info` for `ambient`. */ color?: string; /** * Colour across the wave instead of one flat ink: two or more colours spread * left to right, or ramped up from the bottom edge for `ambient`. Literal * colours only — for a themed one, resolve the tokens with `useCSSVariable` * and pass the result. */ gradient?: readonly string[]; /** * Colour of the part of a recording that has not played yet. Defaults to the * ink at low opacity, which is right for most surfaces; set it when you want * the track to read as its own thing. `bars` with `progress`. */ trackColor?: string; /** Freeze on the current frame. */ paused?: boolean; /** Corner radius `ambient` traces. Match it to the screen it sits on. */ radius?: number; /** Overrides the per-state default announced to screen readers. */ accessibilityLabel?: string; } export function Soundwave({ className, variant = 'pills', state = 'listening', level, levels, progress, bars, barWidth, barGap, height, mode = 'static', centered = true, fadeEdges, sensitivity = 1, speed = 1, color, gradient, trackColor, paused = false, radius = 44, accessibilityLabel, style, ...props }: SoundwaveProps) { const defaults = DEFAULTS[variant]; const count = bars ?? defaults.bars; const stroke = barWidth ?? defaults.barWidth; const gap = barGap ?? defaults.barGap; const box = height ?? defaults.height; const scrolling = variant === 'bars' && mode === 'scrolling'; const fade = fadeEdges ?? (variant === 'line' || scrolling); const foreground = useCSSVariable('--color-foreground'); const info = useCSSVariable('--color-info'); /* * A token name is resolved here rather than by the caller, so `color` and * `trackColor` can be written the way the rest of the library is themed — * `--color-info` instead of a hex someone has to keep in step with the theme. */ const colorToken = useCSSVariable(isToken(color) ? color : '--color-foreground'); const trackToken = useCSSVariable(isToken(trackColor) ? trackColor : '--color-foreground'); const themed = variant === 'ambient' ? info : foreground; /* * A wave inside a coloured surface has to be drawn in that surface's * foreground, not the page's: a sent chat bubble is painted in the primary * colour, and in most themes the primary colour *is* the text colour — so a * wave that resolved `--color-foreground` for itself would be invisible on * the one screen it is most likely to appear on. Surfaces already publish * their readable foreground for icons; a wave is ink for the same reason. * * `ambient` opts out: it is a glow behind a whole screen, not ink on a * surface, and it wants its own colour. */ const inherited = useIconColor(); const surface = variant === 'ambient' ? undefined : inherited; const explicit = isToken(color) ? asString(colorToken) : color; const ink = explicit ?? surface ?? asString(themed) ?? '#0a0a0a'; const track = isToken(trackColor) ? asString(trackToken) : trackColor; /* * A recording drawn from `levels` has no motion in it: every bar comes from * the stored shape, so the clock advances a frame nobody reads. A transcript * can hold twenty voice notes, and twenty idle frame callbacks is twenty too * many — so a supplied waveform stops the engine, and `progress` still * redraws it, because that is a shared value of its own. */ const stillWaveform = variant === 'bars' && !scrolling && (levels?.length ?? 0) > 0; const engine = useEngine({ level, state, sensitivity, speed, paused: paused || stillWaveform, history: scrolling ? count : 0, }); // Supplied bands are copied into a shared value so the drawing worklet has a // single place to read from, whichever way the level arrived. const bands = useSharedValue([]); useEffect(() => { bands.value = levels ?? []; }, [levels, bands]); const playhead = useNumberSource(progress); // `bars` and `line` are drawn to the width they are given, which is only // known after layout — a metering strip is nearly always as wide as its row. const [measured, setMeasured] = useState(0); const onLayout = (event: LayoutChangeEvent) => { const next = Math.round(event.nativeEvent.layout.width); if (next !== measured) setMeasured(next); }; // Two Defs in one tree cannot share an id, and a screen can hold more than // one wave. const gradientId = `panelui-soundwave-${useId().replace(/[^a-zA-Z0-9]/g, '')}`; /* * A gradient and an edge fade are the same object — colour stops across the * width — so one definition serves both, and a wave that needs neither is * painted with a flat colour and no `Defs` at all. */ const ramp = gradient?.length ? gradient : null; const needsDefs = fade || (ramp !== null && ramp.length > 1); const paint = needsDefs ? `url(#${gradientId})` : ink; const defs = needsDefs ? ( ) : null; let content: ReactNode = null; if (variant === 'pills') { content = ( ); } else if (variant === 'ambient') { content = ( ); } else if (measured > 0) { content = variant === 'bars' ? ( ) : ( ); } return ( {content} ); } Soundwave.displayName = 'Soundwave';