Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessio
import { AgentAudioVisualizerAura, AgentAudioVisualizerAuraProps } from '@livekit/agents-ui';
import { useTheme } from 'next-themes';
import { useAgent } from '@livekit/components-react';
import { useSimulatedVolumeBands } from './useSimulatedVolumeBands';

export default {
component: AgentAudioVisualizerAura,
Expand Down Expand Up @@ -64,3 +65,25 @@ export default {
export const Default: StoryObj<AgentAudioVisualizerAuraProps> = {
args: {},
};

// Demonstrates the `volume` override prop with a simulated speech waveform instead of
// live audio.
export const OverrideVolume: StoryObj<AgentAudioVisualizerAuraProps> = {
args: {
state: 'speaking',
},
render: (args: AgentAudioVisualizerAuraProps) => {
const { microphoneTrack } = useAgent();
const { resolvedTheme = 'dark' } = useTheme();
const [volume] = useSimulatedVolumeBands(1);

return (
<AgentAudioVisualizerAura
{...args}
audioTrack={microphoneTrack}
volume={volume}
themeMode={resolvedTheme as 'dark' | 'light'}
/>
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { StoryObj } from '@storybook/react-vite';
import { useAgent } from '@livekit/components-react';
import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider';
import { AgentAudioVisualizerBar, AgentAudioVisualizerBarProps } from '@livekit/agents-ui';
import { useSimulatedVolumeBands } from './useSimulatedVolumeBands';

export default {
component: AgentAudioVisualizerBar,
Expand Down Expand Up @@ -54,8 +55,20 @@ export const Default: StoryObj<AgentAudioVisualizerBarProps> = {
args: {},
};

export const Demo1: StoryObj<AgentAudioVisualizerBarProps> = {
// Demonstrates the `volumeBands` override prop with a simulated speech waveform instead
// of live audio. The array length doesn't need to match `barCount` — it's trimmed or
// padded automatically.
export const OverrideVolumeBands: StoryObj<AgentAudioVisualizerBarProps> = {
args: {
color: '#F9B11F',
state: 'speaking',
barCount: 5,
},
render: (args: AgentAudioVisualizerBarProps) => {
const { microphoneTrack } = useAgent();
const volumeBands = useSimulatedVolumeBands(args.barCount ?? 5);

return (
<AgentAudioVisualizerBar {...args} audioTrack={microphoneTrack} volumeBands={volumeBands} />
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '@livekit/agents-ui';
import { useAgent } from '@livekit/components-react';
import { cn } from '@/lib/utils';
import { useSimulatedVolumeBands } from './useSimulatedVolumeBands';

export default {
component: AgentAudioVisualizerGrid,
Expand Down Expand Up @@ -135,3 +136,21 @@ export const Demo4: StoryObj<AgentAudioVisualizerGridProps> = {
),
},
};

// Demonstrates the `volumeBands` override prop with a simulated speech waveform instead
// of live audio. The array length doesn't need to match `columnCount` — it's trimmed or
// padded automatically.
export const OverrideVolumeBands: StoryObj<AgentAudioVisualizerGridProps> = {
args: {
state: 'speaking',
columnCount: 9,
},
render: (args: AgentAudioVisualizerGridProps) => {
const { microphoneTrack } = useAgent();
const volumeBands = useSimulatedVolumeBands(args.columnCount ?? 9);

return (
<AgentAudioVisualizerGrid {...args} audioTrack={microphoneTrack} volumeBands={volumeBands} />
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { StoryObj } from '@storybook/react-vite';
import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider';
import { AgentAudioVisualizerRadial, AgentAudioVisualizerRadialProps } from '@livekit/agents-ui';
import { useAgent } from '@livekit/components-react';
import { useSimulatedVolumeBands } from './useSimulatedVolumeBands';

export default {
component: AgentAudioVisualizerRadial,
Expand Down Expand Up @@ -57,3 +58,25 @@ export default {
export const Default: StoryObj<AgentAudioVisualizerRadialProps> = {
args: {},
};

// Demonstrates the `volumeBands` override prop with a simulated speech waveform instead
// of live audio. The array length doesn't need to match `barCount` — it's trimmed or
// padded automatically.
export const OverrideVolumeBands: StoryObj<AgentAudioVisualizerRadialProps> = {
args: {
state: 'speaking',
barCount: 24,
},
render: (args: AgentAudioVisualizerRadialProps) => {
const { microphoneTrack } = useAgent();
const volumeBands = useSimulatedVolumeBands(args.barCount ?? 24);

return (
<AgentAudioVisualizerRadial
{...args}
audioTrack={microphoneTrack}
volumeBands={volumeBands}
/>
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { StoryObj } from '@storybook/react-vite';
import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider';
import { AgentAudioVisualizerWave, AgentAudioVisualizerWaveProps } from '@livekit/agents-ui';
import { useAgent } from '@livekit/components-react';
import { useSimulatedVolumeBands } from './useSimulatedVolumeBands';

export default {
component: AgentAudioVisualizerWave,
Expand Down Expand Up @@ -63,3 +64,17 @@ export default {
export const Default: StoryObj<AgentAudioVisualizerWaveProps> = {
args: {},
};

// Demonstrates the `volume` override prop with a simulated speech waveform instead of
// live audio.
export const OverrideVolume: StoryObj<AgentAudioVisualizerWaveProps> = {
args: {
state: 'speaking',
},
render: (args: AgentAudioVisualizerWaveProps) => {
const { microphoneTrack } = useAgent();
const [volume] = useSimulatedVolumeBands(1);

return <AgentAudioVisualizerWave {...args} audioTrack={microphoneTrack} volume={volume} />;
},
};
140 changes: 140 additions & 0 deletions docs/storybook/stories/agents-ui/useSimulatedVolumeBands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
'use client';

import { useEffect, useState } from 'react';

const TICK_MS = 16;
const FRAME_CYCLE = 240;
const MIN_V = 0.4;
const MAX_V = 1.0;
const RANGE = MAX_V - MIN_V;
const ATTACK = 0.6;
const DECAY = 0.1;
const BELL_FLOOR = 0.1;
const SPEECH_TICKS = [90, 200];
const PAUSE_TICKS = [18, 55];
const NOTIFY_EVERY = 2;
const RHYTHM_BEATS = 12;
const PHI = 1.618033988749;

type VolumeListener = (volumes: number[]) => void;

let frameIndex = 0;
let interval: ReturnType<typeof setInterval> | undefined;
let speechScale = 1;
let speechTicksLeft = 120;
let speechSeed = 12345;

const listenersByBarCount = new Map<number, Set<VolumeListener>>();
const volumesByBarCount = new Map<number, number[]>();
const cache = new Map<
number,
{ phases: number[]; baselines: number[]; variation: number[]; bell: number[] }
>();

function nextDuration(isPause: boolean): number {
speechSeed = (speechSeed * 1103515245 + 12345) >>> 0;
const [min, max] = (isPause ? PAUSE_TICKS : SPEECH_TICKS) as [number, number];
return min + (speechSeed % (max - min + 1));
}

function getBarConfig(barCount: number) {
let c = cache.get(barCount);
if (c) return c;
const n = barCount;
const last = Math.max(1, n - 1);
const phases = Array.from({ length: n }, (_, i) => (i * 2.1 + n * 0.7) * Math.PI);
const baselines = Array.from({ length: n }, (_, i) => 0.08 + 0.25 * ((i * PHI) % 1));
const variation =
n <= 1
? [1]
: Array.from({ length: n }, (_, i) => 0.25 + 0.75 * (1 - Math.sin((Math.PI * i) / last)));
const bell = Array.from({ length: n }, (_, i) => {
const x = i / last;
return BELL_FLOOR + (1 - BELL_FLOOR) * Math.pow(Math.sin(Math.PI * x), 1.5);
});
c = { phases, baselines, variation, bell };
cache.set(barCount, c);
return c;
}

function generateVolumes(barCount: number, prev: number[]): number[] {
const t = frameIndex / FRAME_CYCLE;
const phaseBase = t * Math.PI * 2;
const { phases, baselines, variation, bell } = getBarConfig(barCount);
let rhythm = 1;
if (speechScale > 0.5) {
const beat = t * RHYTHM_BEATS * Math.PI * 2;
const wobble = 0.52 * Math.sin(t * 5 * Math.PI * 2);
const swing = 0.8 * Math.sin(beat * 1.31);
const wave = Math.max(-1, Math.min(1, Math.sin(beat + wobble) + swing));
rhythm = 0.6 + 0.4 * (0.5 + 0.5 * wave);
}

return Array.from({ length: barCount }, (_, i) => {
const phase = phaseBase + (phases[i] ?? 0);
const h =
0.45 +
0.3 * Math.sin(phase * 1.7) +
0.2 * Math.sin(phase * 3.2) +
0.12 * Math.sin(phase * 5.1) +
(baselines[i] ?? 0.2);
const sharp = Math.pow(Math.min(1, Math.max(0, h)), 0.5);
const activity = 0.5 + (sharp - 0.5) * (variation[i] ?? 1);
const target = (MIN_V + activity * RANGE * (bell[i] ?? 1)) * speechScale * rhythm;
const p = prev[i] ?? 0;
const f = target > p ? ATTACK : DECAY;
return p + (target - p) * f;
});
}

function tick() {
frameIndex = (frameIndex + 1) % FRAME_CYCLE;

if (--speechTicksLeft <= 0) {
const toPause = speechScale > 0.5;
speechScale = toPause ? 0 : 1;
speechTicksLeft = nextDuration(toPause);
}

const notify = frameIndex % NOTIFY_EVERY === 0;
for (const [barCount, listeners] of listenersByBarCount.entries()) {
if (listeners.size === 0) continue;
const next = generateVolumes(barCount, volumesByBarCount.get(barCount) ?? []);
volumesByBarCount.set(barCount, next);
if (notify) for (const fn of listeners) fn(next);
}
}

function subscribe(barCount: number, listener: VolumeListener): () => void {
const set = listenersByBarCount.get(barCount) ?? new Set<VolumeListener>();
set.add(listener);
listenersByBarCount.set(barCount, set);

const existing = volumesByBarCount.get(barCount);
if (existing) listener(existing);
else {
const init = generateVolumes(barCount, []);
volumesByBarCount.set(barCount, init);
listener(init);
}

if (!interval) interval = setInterval(tick, TICK_MS);

return () => {
const s = listenersByBarCount.get(barCount);
if (!s) return;
s.delete(listener);
if (s.size === 0) listenersByBarCount.delete(barCount);
const hasAny = [...listenersByBarCount.values()].some((set) => set.size > 0);
if (!hasAny && interval) {
clearInterval(interval);
interval = undefined;
}
};
}

export function useSimulatedVolumeBands(barCount: number) {
const [volumes, setVolumes] = useState<number[]>([]);
useEffect(() => subscribe(barCount, setVolumes), [barCount]);
return volumes;
}
Loading
Loading