/**
* 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 (
);
}
/* -------------------------------------------------------------------------- */
/* 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 (
);
}
/* -------------------------------------------------------------------------- */
/* 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';