mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-11-03 03:18:52 +00:00
The Lint job has never once passed — 7 runs, 7 failures — on 48 errors that are
all in apps/v4, and apps/v4 is a VENDORED upstream mirror. Every flagged file
arrives from a sync commit ("feat: sync upstream shadcn/ui"): hooks/use-layout.tsx
and examples/{base,radix}/** from 16c4453cc, components/command-menu.tsx from
b8b958f57. The examples/base and examples/radix copies are byte-identical to each
other, so a third of the list is the same file counted twice.
eslint-config-next 16 turns the React Compiler rules on. Upstream shadcn does not
satisfy them, so we were linting somebody else's code against a stricter config
than they use. Patching it would fork ~20 vendored files from upstream and have
to be redone at the next sync, to quiet advisories in a demo app.
This is not a new exemption, it is finishing an existing one. The config already
turned off react-hooks/incompatible-library and react-hooks/purity for precisely
this reason, and TWO files already carried inline
`// eslint-disable-next-line react-hooks/set-state-in-effect`. The same rule was
being suppressed already — just inconsistently, in 2 of the 20+ places it fires.
Those two inline directives are now redundant and are removed here; eslint
flagged them itself once the rule went off.
set-state-in-effect "Calling setState synchronously within an effect can
trigger cascading renders" 42 of 48
refs "Cannot access refs during render" 6 of 48
Scope is exactly apps/v4: that config governs nothing else. Our own source —
app/, pkgs/, packages/ — lints under its own config, which does NOT disable any
react-hooks rule, and still passes. Nothing we author is exempted.
If these rules should hold for vendored code too, the fix belongs upstream in
shadcn/ui rather than as a local fork of 20 files.
Verified with the workspace eslint (9.39.4, the version CI installs — a stray
npx picks up 10.5.0 and crashes eslint-plugin-react before linting anything):
`turbo run lint` is 2 successful / 2 total, 0 errors, 0 warnings.
Co-authored-by: Hanzo Dev <[email protected]>
1155 lines
35 KiB
TypeScript
1155 lines
35 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import {
|
|
Example,
|
|
ExampleWrapper,
|
|
} from "@/registry/bases/base/components/example"
|
|
import { Button } from "@/registry/bases/base/ui/button"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/registry/bases/base/ui/card"
|
|
|
|
export default function ElevenlabsBlocks() {
|
|
return (
|
|
<ExampleWrapper>
|
|
<BarVisualizerDemo />
|
|
<WaveformDemo />
|
|
</ExampleWrapper>
|
|
)
|
|
}
|
|
|
|
function BarVisualizerDemo() {
|
|
const [state, setState] = useState<AgentState>("speaking")
|
|
|
|
return (
|
|
<Example title="Bar Visualizer">
|
|
<Card className="">
|
|
<CardHeader>
|
|
<CardTitle>Audio Frequency Visualizer</CardTitle>
|
|
<CardDescription>
|
|
Real-time frequency band visualization with animated state
|
|
transitions
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<BarVisualizer
|
|
state={state}
|
|
demo={true}
|
|
barCount={20}
|
|
minHeight={15}
|
|
maxHeight={90}
|
|
className="h-40 max-w-full"
|
|
/>
|
|
</CardContent>
|
|
<CardFooter className="gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant={state === "connecting" ? "default" : "outline"}
|
|
onClick={() => setState("connecting")}
|
|
>
|
|
Connecting
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={state === "listening" ? "default" : "outline"}
|
|
onClick={() => setState("listening")}
|
|
>
|
|
Listening
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={state === "speaking" ? "default" : "outline"}
|
|
onClick={() => setState("speaking")}
|
|
>
|
|
Speaking
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
</Example>
|
|
)
|
|
}
|
|
|
|
function WaveformDemo() {
|
|
const [active, setActive] = useState(false)
|
|
const [processing, setProcessing] = useState(true)
|
|
const [mode, setMode] = useState<"static" | "scrolling">("static")
|
|
const handleToggleActive = () => {
|
|
setActive(!active)
|
|
if (!active) {
|
|
setProcessing(false)
|
|
}
|
|
}
|
|
const handleToggleProcessing = () => {
|
|
setProcessing(!processing)
|
|
if (!processing) {
|
|
setActive(false)
|
|
}
|
|
}
|
|
return (
|
|
<Example title="Waveform" className="items-center justify-center">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Live Audio Waveform</CardTitle>
|
|
<CardDescription>
|
|
Real-time microphone input visualization with audio reactivity
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<LiveWaveform
|
|
active={active}
|
|
processing={processing}
|
|
height={80}
|
|
barWidth={3}
|
|
barGap={2}
|
|
mode={mode}
|
|
fadeEdges={true}
|
|
barColor="gray"
|
|
historySize={120}
|
|
/>
|
|
</CardContent>
|
|
<CardFooter className="gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant={active ? "default" : "outline"}
|
|
onClick={handleToggleActive}
|
|
>
|
|
{active ? "Stop" : "Start"} Listening
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={processing ? "default" : "outline"}
|
|
onClick={handleToggleProcessing}
|
|
>
|
|
{processing ? "Stop" : "Start"} Processing
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setMode(mode === "static" ? "scrolling" : "static")}
|
|
>
|
|
{mode === "static" ? "Static" : "Scrolling"}
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
</Example>
|
|
)
|
|
}
|
|
|
|
export interface AudioAnalyserOptions {
|
|
fftSize?: number
|
|
smoothingTimeConstant?: number
|
|
minDecibels?: number
|
|
maxDecibels?: number
|
|
}
|
|
|
|
function createAudioAnalyser(
|
|
mediaStream: MediaStream,
|
|
options: AudioAnalyserOptions = {}
|
|
) {
|
|
const audioContext = new (window.AudioContext ||
|
|
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
|
.webkitAudioContext)()
|
|
const source = audioContext.createMediaStreamSource(mediaStream)
|
|
const analyser = audioContext.createAnalyser()
|
|
|
|
if (options.fftSize) analyser.fftSize = options.fftSize
|
|
if (options.smoothingTimeConstant !== undefined) {
|
|
analyser.smoothingTimeConstant = options.smoothingTimeConstant
|
|
}
|
|
if (options.minDecibels !== undefined)
|
|
analyser.minDecibels = options.minDecibels
|
|
if (options.maxDecibels !== undefined)
|
|
analyser.maxDecibels = options.maxDecibels
|
|
|
|
source.connect(analyser)
|
|
|
|
const cleanup = () => {
|
|
source.disconnect()
|
|
audioContext.close()
|
|
}
|
|
|
|
return { analyser, audioContext, cleanup }
|
|
}
|
|
|
|
/**
|
|
* Hook for tracking the volume of an audio stream using the Web Audio API.
|
|
* @param mediaStream - The MediaStream to analyze
|
|
* @param options - Audio analyser options
|
|
* @returns The current volume level (0-1)
|
|
*/
|
|
export function useAudioVolume(
|
|
mediaStream?: MediaStream | null,
|
|
options: AudioAnalyserOptions = { fftSize: 32, smoothingTimeConstant: 0 }
|
|
) {
|
|
const [volume, setVolume] = useState(0)
|
|
const volumeRef = useRef(0)
|
|
const frameId = useRef<number | undefined>(undefined)
|
|
|
|
// Memoize options to prevent unnecessary re-renders
|
|
const memoizedOptions = useMemo(() => options, [options])
|
|
|
|
useEffect(() => {
|
|
if (!mediaStream) {
|
|
setVolume(0)
|
|
volumeRef.current = 0
|
|
return
|
|
}
|
|
|
|
const { analyser, cleanup } = createAudioAnalyser(
|
|
mediaStream,
|
|
memoizedOptions
|
|
)
|
|
|
|
const bufferLength = analyser.frequencyBinCount
|
|
const dataArray = new Uint8Array(bufferLength)
|
|
let lastUpdate = 0
|
|
const updateInterval = 1000 / 30 // 30 FPS
|
|
|
|
const updateVolume = (timestamp: number) => {
|
|
if (timestamp - lastUpdate >= updateInterval) {
|
|
analyser.getByteFrequencyData(dataArray)
|
|
let sum = 0
|
|
for (let i = 0; i < dataArray.length; i++) {
|
|
const a = dataArray[i]
|
|
sum += a * a
|
|
}
|
|
const newVolume = Math.sqrt(sum / dataArray.length) / 255
|
|
|
|
// Only update state if volume changed significantly
|
|
if (Math.abs(newVolume - volumeRef.current) > 0.01) {
|
|
volumeRef.current = newVolume
|
|
setVolume(newVolume)
|
|
}
|
|
lastUpdate = timestamp
|
|
}
|
|
frameId.current = requestAnimationFrame(updateVolume)
|
|
}
|
|
|
|
frameId.current = requestAnimationFrame(updateVolume)
|
|
|
|
return () => {
|
|
cleanup()
|
|
if (frameId.current) {
|
|
cancelAnimationFrame(frameId.current)
|
|
}
|
|
}
|
|
}, [mediaStream, memoizedOptions])
|
|
|
|
return volume
|
|
}
|
|
|
|
export interface MultiBandVolumeOptions {
|
|
bands?: number
|
|
loPass?: number // Low frequency cutoff
|
|
hiPass?: number // High frequency cutoff
|
|
updateInterval?: number // Update interval in ms
|
|
analyserOptions?: AudioAnalyserOptions
|
|
}
|
|
|
|
const multibandDefaults: MultiBandVolumeOptions = {
|
|
bands: 5,
|
|
loPass: 100,
|
|
hiPass: 600,
|
|
updateInterval: 32,
|
|
analyserOptions: { fftSize: 2048 },
|
|
}
|
|
|
|
// Memoized normalization function to avoid recreating on each render
|
|
const normalizeDb = (value: number) => {
|
|
if (value === -Infinity) return 0
|
|
const minDb = -100
|
|
const maxDb = -10
|
|
const db = 1 - (Math.max(minDb, Math.min(maxDb, value)) * -1) / 100
|
|
return Math.sqrt(db)
|
|
}
|
|
|
|
/**
|
|
* Hook for tracking volume across multiple frequency bands
|
|
* @param mediaStream - The MediaStream to analyze
|
|
* @param options - Multiband options
|
|
* @returns Array of volume levels for each frequency band
|
|
*/
|
|
export function useMultibandVolume(
|
|
mediaStream?: MediaStream | null,
|
|
options: MultiBandVolumeOptions = {}
|
|
) {
|
|
const opts = useMemo(() => ({ ...multibandDefaults, ...options }), [options])
|
|
|
|
const [frequencyBands, setFrequencyBands] = useState<number[]>(() =>
|
|
new Array(opts.bands).fill(0)
|
|
)
|
|
const bandsRef = useRef<number[]>(new Array(opts.bands).fill(0))
|
|
const frameId = useRef<number | undefined>(undefined)
|
|
|
|
useEffect(() => {
|
|
if (!mediaStream) {
|
|
const emptyBands = new Array(opts.bands).fill(0)
|
|
setTimeout(() => {
|
|
setFrequencyBands(emptyBands)
|
|
}, 0)
|
|
bandsRef.current = emptyBands
|
|
return
|
|
}
|
|
|
|
const { analyser, cleanup } = createAudioAnalyser(
|
|
mediaStream,
|
|
opts.analyserOptions
|
|
)
|
|
|
|
const bufferLength = analyser.frequencyBinCount
|
|
const dataArray = new Float32Array(bufferLength)
|
|
const sliceStart = opts.loPass!
|
|
const sliceEnd = opts.hiPass!
|
|
const sliceLength = sliceEnd - sliceStart
|
|
const chunkSize = Math.ceil(sliceLength / opts.bands!)
|
|
|
|
let lastUpdate = 0
|
|
const updateInterval = opts.updateInterval!
|
|
|
|
const updateVolume = (timestamp: number) => {
|
|
if (timestamp - lastUpdate >= updateInterval) {
|
|
analyser.getFloatFrequencyData(dataArray)
|
|
|
|
// Process directly without creating intermediate arrays
|
|
const chunks = new Array(opts.bands!)
|
|
|
|
for (let i = 0; i < opts.bands!; i++) {
|
|
let sum = 0
|
|
let count = 0
|
|
const startIdx = sliceStart + i * chunkSize
|
|
const endIdx = Math.min(sliceStart + (i + 1) * chunkSize, sliceEnd)
|
|
|
|
for (let j = startIdx; j < endIdx; j++) {
|
|
sum += normalizeDb(dataArray[j])
|
|
count++
|
|
}
|
|
|
|
chunks[i] = count > 0 ? sum / count : 0
|
|
}
|
|
|
|
// Only update state if bands changed significantly
|
|
let hasChanged = false
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
if (Math.abs(chunks[i] - bandsRef.current[i]) > 0.01) {
|
|
hasChanged = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (hasChanged) {
|
|
bandsRef.current = chunks
|
|
setFrequencyBands(chunks)
|
|
}
|
|
|
|
lastUpdate = timestamp
|
|
}
|
|
|
|
frameId.current = requestAnimationFrame(updateVolume)
|
|
}
|
|
|
|
frameId.current = requestAnimationFrame(updateVolume)
|
|
|
|
return () => {
|
|
cleanup()
|
|
if (frameId.current) {
|
|
cancelAnimationFrame(frameId.current)
|
|
}
|
|
}
|
|
}, [mediaStream, opts])
|
|
|
|
return frequencyBands
|
|
}
|
|
|
|
type AnimationState =
|
|
| "connecting"
|
|
| "initializing"
|
|
| "listening"
|
|
| "speaking"
|
|
| "thinking"
|
|
| undefined
|
|
|
|
export const useBarAnimator = (
|
|
state: AnimationState,
|
|
columns: number,
|
|
interval: number
|
|
): number[] => {
|
|
const indexRef = useRef(0)
|
|
const [currentFrame, setCurrentFrame] = useState<number[]>([])
|
|
const animationFrameId = useRef<number | null>(null)
|
|
|
|
// Memoize sequence generation
|
|
const sequence = useMemo(() => {
|
|
if (state === "thinking" || state === "listening") {
|
|
return generateListeningSequenceBar(columns)
|
|
} else if (state === "connecting" || state === "initializing") {
|
|
return generateConnectingSequenceBar(columns)
|
|
} else if (state === undefined || state === "speaking") {
|
|
return [new Array(columns).fill(0).map((_, idx) => idx)]
|
|
} else {
|
|
return [[]]
|
|
}
|
|
}, [state, columns])
|
|
|
|
useEffect(() => {
|
|
indexRef.current = 0
|
|
setTimeout(() => {
|
|
setCurrentFrame(sequence[0] || [])
|
|
}, 0)
|
|
}, [sequence])
|
|
|
|
useEffect(() => {
|
|
let startTime = performance.now()
|
|
|
|
const animate = (time: DOMHighResTimeStamp) => {
|
|
const timeElapsed = time - startTime
|
|
|
|
if (timeElapsed >= interval) {
|
|
indexRef.current = (indexRef.current + 1) % sequence.length
|
|
setCurrentFrame(sequence[indexRef.current] || [])
|
|
startTime = time
|
|
}
|
|
|
|
animationFrameId.current = requestAnimationFrame(animate)
|
|
}
|
|
|
|
animationFrameId.current = requestAnimationFrame(animate)
|
|
|
|
return () => {
|
|
if (animationFrameId.current !== null) {
|
|
cancelAnimationFrame(animationFrameId.current)
|
|
}
|
|
}
|
|
}, [interval, sequence])
|
|
|
|
return currentFrame
|
|
}
|
|
|
|
// Memoize sequence generators
|
|
const generateConnectingSequenceBar = (columns: number): number[][] => {
|
|
const seq = []
|
|
for (let x = 0; x < columns; x++) {
|
|
seq.push([x, columns - 1 - x])
|
|
}
|
|
return seq
|
|
}
|
|
|
|
const generateListeningSequenceBar = (columns: number): number[][] => {
|
|
const center = Math.floor(columns / 2)
|
|
const noIndex = -1
|
|
return [[center], [noIndex]]
|
|
}
|
|
|
|
export type AgentState =
|
|
| "connecting"
|
|
| "initializing"
|
|
| "listening"
|
|
| "speaking"
|
|
| "thinking"
|
|
|
|
export interface BarVisualizerProps
|
|
extends React.HTMLAttributes<HTMLDivElement> {
|
|
/** Voice assistant state */
|
|
state?: AgentState
|
|
/** Number of bars to display */
|
|
barCount?: number
|
|
/** Audio source */
|
|
mediaStream?: MediaStream | null
|
|
/** Min/max height as percentage */
|
|
minHeight?: number
|
|
maxHeight?: number
|
|
/** Enable demo mode with fake audio data */
|
|
demo?: boolean
|
|
/** Align bars from center instead of bottom */
|
|
centerAlign?: boolean
|
|
}
|
|
|
|
const BarVisualizerComponent = React.forwardRef<
|
|
HTMLDivElement,
|
|
BarVisualizerProps
|
|
>(
|
|
(
|
|
{
|
|
state,
|
|
barCount = 15,
|
|
mediaStream,
|
|
minHeight = 20,
|
|
maxHeight = 100,
|
|
demo = false,
|
|
centerAlign = false,
|
|
className,
|
|
style,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
// Audio processing
|
|
const realVolumeBands = useMultibandVolume(mediaStream, {
|
|
bands: barCount,
|
|
loPass: 100,
|
|
hiPass: 200,
|
|
})
|
|
|
|
// Generate fake volume data for demo mode using refs to avoid state updates
|
|
const fakeVolumeBandsRef = useRef<number[]>(new Array(barCount).fill(0.2))
|
|
const [fakeVolumeBands, setFakeVolumeBands] = useState<number[]>(() =>
|
|
new Array(barCount).fill(0.2)
|
|
)
|
|
const fakeAnimationRef = useRef<number | undefined>(undefined)
|
|
|
|
// Animate fake volume bands for speaking and listening states
|
|
useEffect(() => {
|
|
if (!demo) return
|
|
|
|
if (state !== "speaking" && state !== "listening") {
|
|
const bands = new Array(barCount).fill(0.2)
|
|
fakeVolumeBandsRef.current = bands
|
|
setTimeout(() => {
|
|
setFakeVolumeBands(bands)
|
|
}, 0)
|
|
return
|
|
}
|
|
|
|
let lastUpdate = 0
|
|
const updateInterval = 50
|
|
const startTime = Date.now() / 1000
|
|
|
|
const updateFakeVolume = (timestamp: number) => {
|
|
if (timestamp - lastUpdate >= updateInterval) {
|
|
const time = Date.now() / 1000 - startTime
|
|
const newBands = new Array(barCount)
|
|
|
|
for (let i = 0; i < barCount; i++) {
|
|
const waveOffset = i * 0.5
|
|
const baseVolume = Math.sin(time * 2 + waveOffset) * 0.3 + 0.5
|
|
const randomNoise = Math.random() * 0.2
|
|
newBands[i] = Math.max(0.1, Math.min(1, baseVolume + randomNoise))
|
|
}
|
|
|
|
// Only update if values changed significantly
|
|
let hasChanged = false
|
|
for (let i = 0; i < barCount; i++) {
|
|
if (Math.abs(newBands[i] - fakeVolumeBandsRef.current[i]) > 0.05) {
|
|
hasChanged = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (hasChanged) {
|
|
fakeVolumeBandsRef.current = newBands
|
|
setFakeVolumeBands(newBands)
|
|
}
|
|
|
|
lastUpdate = timestamp
|
|
}
|
|
|
|
fakeAnimationRef.current = requestAnimationFrame(updateFakeVolume)
|
|
}
|
|
|
|
fakeAnimationRef.current = requestAnimationFrame(updateFakeVolume)
|
|
|
|
return () => {
|
|
if (fakeAnimationRef.current) {
|
|
cancelAnimationFrame(fakeAnimationRef.current)
|
|
}
|
|
}
|
|
}, [demo, state, barCount])
|
|
|
|
// Use fake or real volume data based on demo mode
|
|
const volumeBands = useMemo(
|
|
() => (demo ? fakeVolumeBands : realVolumeBands),
|
|
[demo, fakeVolumeBands, realVolumeBands]
|
|
)
|
|
|
|
// Animation sequencing
|
|
const highlightedIndices = useBarAnimator(
|
|
state,
|
|
barCount,
|
|
state === "connecting"
|
|
? 2000 / barCount
|
|
: state === "thinking"
|
|
? 150
|
|
: state === "listening"
|
|
? 500
|
|
: 1000
|
|
)
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
data-state={state}
|
|
className={cn(
|
|
"relative flex justify-center gap-1.5",
|
|
centerAlign ? "items-center" : "items-end",
|
|
"bg-muted h-32 w-full overflow-hidden rounded-lg p-4",
|
|
className
|
|
)}
|
|
style={{
|
|
...style,
|
|
}}
|
|
{...props}
|
|
>
|
|
{volumeBands.map((volume, index) => {
|
|
const heightPct = Math.min(
|
|
maxHeight,
|
|
Math.max(minHeight, volume * 100 + 5)
|
|
)
|
|
const isHighlighted = highlightedIndices?.includes(index) ?? false
|
|
|
|
return (
|
|
<Bar
|
|
key={index}
|
|
heightPct={heightPct}
|
|
isHighlighted={isHighlighted}
|
|
state={state}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
|
|
// Memoized Bar component to prevent unnecessary re-renders
|
|
const Bar = React.memo<{
|
|
heightPct: number
|
|
isHighlighted: boolean
|
|
state?: AgentState
|
|
}>(({ heightPct, isHighlighted, state }) => (
|
|
<div
|
|
data-highlighted={isHighlighted}
|
|
className={cn(
|
|
"max-w-[12px] min-w-[8px] flex-1 transition-all duration-150",
|
|
"rounded-full",
|
|
"bg-border data-[highlighted=true]:bg-primary",
|
|
state === "speaking" && "bg-primary",
|
|
state === "thinking" && isHighlighted && "animate-pulse"
|
|
)}
|
|
style={{
|
|
height: `${heightPct}%`,
|
|
animationDuration: state === "thinking" ? "300ms" : undefined,
|
|
}}
|
|
/>
|
|
))
|
|
|
|
Bar.displayName = "Bar"
|
|
|
|
// Wrap the main component with React.memo for prop comparison optimization
|
|
const BarVisualizer = React.memo(
|
|
BarVisualizerComponent,
|
|
(prevProps, nextProps) => {
|
|
return (
|
|
prevProps.state === nextProps.state &&
|
|
prevProps.barCount === nextProps.barCount &&
|
|
prevProps.mediaStream === nextProps.mediaStream &&
|
|
prevProps.minHeight === nextProps.minHeight &&
|
|
prevProps.maxHeight === nextProps.maxHeight &&
|
|
prevProps.demo === nextProps.demo &&
|
|
prevProps.centerAlign === nextProps.centerAlign &&
|
|
prevProps.className === nextProps.className &&
|
|
JSON.stringify(prevProps.style) === JSON.stringify(nextProps.style)
|
|
)
|
|
}
|
|
)
|
|
|
|
BarVisualizerComponent.displayName = "BarVisualizerComponent"
|
|
BarVisualizer.displayName = "BarVisualizer"
|
|
|
|
export type LiveWaveformProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
active?: boolean
|
|
processing?: boolean
|
|
deviceId?: string
|
|
barWidth?: number
|
|
barHeight?: number
|
|
barGap?: number
|
|
barRadius?: number
|
|
barColor?: string
|
|
fadeEdges?: boolean
|
|
fadeWidth?: number
|
|
height?: string | number
|
|
sensitivity?: number
|
|
smoothingTimeConstant?: number
|
|
fftSize?: number
|
|
historySize?: number
|
|
updateRate?: number
|
|
mode?: "scrolling" | "static"
|
|
onError?: (error: Error) => void
|
|
onStreamReady?: (stream: MediaStream) => void
|
|
onStreamEnd?: () => void
|
|
}
|
|
export const LiveWaveform = ({
|
|
active = false,
|
|
processing = false,
|
|
deviceId,
|
|
barWidth = 3,
|
|
barGap = 1,
|
|
barRadius = 1.5,
|
|
barColor,
|
|
fadeEdges = true,
|
|
fadeWidth = 24,
|
|
barHeight: baseBarHeight = 4,
|
|
height = 64,
|
|
sensitivity = 1,
|
|
smoothingTimeConstant = 0.8,
|
|
fftSize = 256,
|
|
historySize = 60,
|
|
updateRate = 30,
|
|
mode = "static",
|
|
onError,
|
|
onStreamReady,
|
|
onStreamEnd,
|
|
className,
|
|
...props
|
|
}: LiveWaveformProps) => {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const historyRef = useRef<number[]>([])
|
|
const analyserRef = useRef<AnalyserNode | null>(null)
|
|
const audioContextRef = useRef<AudioContext | null>(null)
|
|
const streamRef = useRef<MediaStream | null>(null)
|
|
const animationRef = useRef<number>(0)
|
|
const lastUpdateRef = useRef<number>(0)
|
|
const processingAnimationRef = useRef<number | null>(null)
|
|
const lastActiveDataRef = useRef<number[]>([])
|
|
const transitionProgressRef = useRef(0)
|
|
const staticBarsRef = useRef<number[]>([])
|
|
const needsRedrawRef = useRef(true)
|
|
const gradientCacheRef = useRef<CanvasGradient | null>(null)
|
|
const lastWidthRef = useRef(0)
|
|
const heightStyle = typeof height === "number" ? `${height}px` : height
|
|
// Handle canvas resizing
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
const container = containerRef.current
|
|
if (!canvas || !container) return
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
const rect = container.getBoundingClientRect()
|
|
const dpr = window.devicePixelRatio || 1
|
|
canvas.width = rect.width * dpr
|
|
canvas.height = rect.height * dpr
|
|
canvas.style.width = `${rect.width}px`
|
|
canvas.style.height = `${rect.height}px`
|
|
const ctx = canvas.getContext("2d")
|
|
if (ctx) {
|
|
ctx.scale(dpr, dpr)
|
|
}
|
|
gradientCacheRef.current = null
|
|
lastWidthRef.current = rect.width
|
|
needsRedrawRef.current = true
|
|
})
|
|
resizeObserver.observe(container)
|
|
return () => resizeObserver.disconnect()
|
|
}, [])
|
|
useEffect(() => {
|
|
if (processing && !active) {
|
|
let time = 0
|
|
transitionProgressRef.current = 0
|
|
const animateProcessing = () => {
|
|
time += 0.03
|
|
transitionProgressRef.current = Math.min(
|
|
1,
|
|
transitionProgressRef.current + 0.02
|
|
)
|
|
const processingData = []
|
|
const barCount = Math.floor(
|
|
(containerRef.current?.getBoundingClientRect().width || 200) /
|
|
(barWidth + barGap)
|
|
)
|
|
if (mode === "static") {
|
|
const halfCount = Math.floor(barCount / 2)
|
|
for (let i = 0; i < barCount; i++) {
|
|
const normalizedPosition = (i - halfCount) / halfCount
|
|
const centerWeight = 1 - Math.abs(normalizedPosition) * 0.4
|
|
const wave1 = Math.sin(time * 1.5 + normalizedPosition * 3) * 0.25
|
|
const wave2 = Math.sin(time * 0.8 - normalizedPosition * 2) * 0.2
|
|
const wave3 = Math.cos(time * 2 + normalizedPosition) * 0.15
|
|
const combinedWave = wave1 + wave2 + wave3
|
|
const processingValue = (0.2 + combinedWave) * centerWeight
|
|
let finalValue = processingValue
|
|
if (
|
|
lastActiveDataRef.current.length > 0 &&
|
|
transitionProgressRef.current < 1
|
|
) {
|
|
const lastDataIndex = Math.min(
|
|
i,
|
|
lastActiveDataRef.current.length - 1
|
|
)
|
|
const lastValue = lastActiveDataRef.current[lastDataIndex] || 0
|
|
finalValue =
|
|
lastValue * (1 - transitionProgressRef.current) +
|
|
processingValue * transitionProgressRef.current
|
|
}
|
|
processingData.push(Math.max(0.05, Math.min(1, finalValue)))
|
|
}
|
|
} else {
|
|
for (let i = 0; i < barCount; i++) {
|
|
const normalizedPosition = (i - barCount / 2) / (barCount / 2)
|
|
const centerWeight = 1 - Math.abs(normalizedPosition) * 0.4
|
|
const wave1 = Math.sin(time * 1.5 + i * 0.15) * 0.25
|
|
const wave2 = Math.sin(time * 0.8 - i * 0.1) * 0.2
|
|
const wave3 = Math.cos(time * 2 + i * 0.05) * 0.15
|
|
const combinedWave = wave1 + wave2 + wave3
|
|
const processingValue = (0.2 + combinedWave) * centerWeight
|
|
let finalValue = processingValue
|
|
if (
|
|
lastActiveDataRef.current.length > 0 &&
|
|
transitionProgressRef.current < 1
|
|
) {
|
|
const lastDataIndex = Math.floor(
|
|
(i / barCount) * lastActiveDataRef.current.length
|
|
)
|
|
const lastValue = lastActiveDataRef.current[lastDataIndex] || 0
|
|
finalValue =
|
|
lastValue * (1 - transitionProgressRef.current) +
|
|
processingValue * transitionProgressRef.current
|
|
}
|
|
processingData.push(Math.max(0.05, Math.min(1, finalValue)))
|
|
}
|
|
}
|
|
if (mode === "static") {
|
|
staticBarsRef.current = processingData
|
|
} else {
|
|
historyRef.current = processingData
|
|
}
|
|
needsRedrawRef.current = true
|
|
processingAnimationRef.current =
|
|
requestAnimationFrame(animateProcessing)
|
|
}
|
|
animateProcessing()
|
|
return () => {
|
|
if (processingAnimationRef.current) {
|
|
cancelAnimationFrame(processingAnimationRef.current)
|
|
}
|
|
}
|
|
} else if (!active && !processing) {
|
|
const hasData =
|
|
mode === "static"
|
|
? staticBarsRef.current.length > 0
|
|
: historyRef.current.length > 0
|
|
if (hasData) {
|
|
let fadeProgress = 0
|
|
const fadeToIdle = () => {
|
|
fadeProgress += 0.03
|
|
if (fadeProgress < 1) {
|
|
if (mode === "static") {
|
|
staticBarsRef.current = staticBarsRef.current.map(
|
|
(value) => value * (1 - fadeProgress)
|
|
)
|
|
} else {
|
|
historyRef.current = historyRef.current.map(
|
|
(value) => value * (1 - fadeProgress)
|
|
)
|
|
}
|
|
needsRedrawRef.current = true
|
|
requestAnimationFrame(fadeToIdle)
|
|
} else {
|
|
if (mode === "static") {
|
|
staticBarsRef.current = []
|
|
} else {
|
|
historyRef.current = []
|
|
}
|
|
}
|
|
}
|
|
fadeToIdle()
|
|
}
|
|
}
|
|
}, [processing, active, barWidth, barGap, mode])
|
|
// Handle microphone setup and teardown
|
|
useEffect(() => {
|
|
if (!active) {
|
|
if (streamRef.current) {
|
|
streamRef.current.getTracks().forEach((track) => track.stop())
|
|
streamRef.current = null
|
|
onStreamEnd?.()
|
|
}
|
|
if (
|
|
audioContextRef.current &&
|
|
audioContextRef.current.state !== "closed"
|
|
) {
|
|
audioContextRef.current.close()
|
|
audioContextRef.current = null
|
|
}
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current)
|
|
animationRef.current = 0
|
|
}
|
|
return
|
|
}
|
|
const setupMicrophone = async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: deviceId
|
|
? {
|
|
deviceId: { exact: deviceId },
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
}
|
|
: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
},
|
|
})
|
|
streamRef.current = stream
|
|
onStreamReady?.(stream)
|
|
const AudioContextConstructor =
|
|
window.AudioContext ||
|
|
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
|
.webkitAudioContext
|
|
const audioContext = new AudioContextConstructor()
|
|
const analyser = audioContext.createAnalyser()
|
|
analyser.fftSize = fftSize
|
|
analyser.smoothingTimeConstant = smoothingTimeConstant
|
|
const source = audioContext.createMediaStreamSource(stream)
|
|
source.connect(analyser)
|
|
audioContextRef.current = audioContext
|
|
analyserRef.current = analyser
|
|
// Clear history when starting
|
|
historyRef.current = []
|
|
} catch (error) {
|
|
onError?.(error as Error)
|
|
}
|
|
}
|
|
setupMicrophone()
|
|
return () => {
|
|
if (streamRef.current) {
|
|
streamRef.current.getTracks().forEach((track) => track.stop())
|
|
streamRef.current = null
|
|
onStreamEnd?.()
|
|
}
|
|
if (
|
|
audioContextRef.current &&
|
|
audioContextRef.current.state !== "closed"
|
|
) {
|
|
audioContextRef.current.close()
|
|
audioContextRef.current = null
|
|
}
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current)
|
|
animationRef.current = 0
|
|
}
|
|
}
|
|
}, [
|
|
active,
|
|
deviceId,
|
|
fftSize,
|
|
smoothingTimeConstant,
|
|
onError,
|
|
onStreamReady,
|
|
onStreamEnd,
|
|
])
|
|
// Animation loop
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
const ctx = canvas.getContext("2d")
|
|
if (!ctx) return
|
|
let rafId: number
|
|
const animate = (currentTime: number) => {
|
|
// Render waveform
|
|
const rect = canvas.getBoundingClientRect()
|
|
// Update audio data if active
|
|
if (active && currentTime - lastUpdateRef.current > updateRate) {
|
|
lastUpdateRef.current = currentTime
|
|
if (analyserRef.current) {
|
|
const dataArray = new Uint8Array(
|
|
analyserRef.current.frequencyBinCount
|
|
)
|
|
analyserRef.current.getByteFrequencyData(dataArray)
|
|
if (mode === "static") {
|
|
// For static mode, update bars in place
|
|
const startFreq = Math.floor(dataArray.length * 0.05)
|
|
const endFreq = Math.floor(dataArray.length * 0.4)
|
|
const relevantData = dataArray.slice(startFreq, endFreq)
|
|
const barCount = Math.floor(rect.width / (barWidth + barGap))
|
|
const halfCount = Math.floor(barCount / 2)
|
|
const newBars: number[] = []
|
|
// Mirror the data for symmetric display
|
|
for (let i = halfCount - 1; i >= 0; i--) {
|
|
const dataIndex = Math.floor(
|
|
(i / halfCount) * relevantData.length
|
|
)
|
|
const value = Math.min(
|
|
1,
|
|
(relevantData[dataIndex] / 255) * sensitivity
|
|
)
|
|
newBars.push(Math.max(0.05, value))
|
|
}
|
|
for (let i = 0; i < halfCount; i++) {
|
|
const dataIndex = Math.floor(
|
|
(i / halfCount) * relevantData.length
|
|
)
|
|
const value = Math.min(
|
|
1,
|
|
(relevantData[dataIndex] / 255) * sensitivity
|
|
)
|
|
newBars.push(Math.max(0.05, value))
|
|
}
|
|
staticBarsRef.current = newBars
|
|
lastActiveDataRef.current = newBars
|
|
} else {
|
|
// Scrolling mode - original behavior
|
|
let sum = 0
|
|
const startFreq = Math.floor(dataArray.length * 0.05)
|
|
const endFreq = Math.floor(dataArray.length * 0.4)
|
|
const relevantData = dataArray.slice(startFreq, endFreq)
|
|
for (let i = 0; i < relevantData.length; i++) {
|
|
sum += relevantData[i]
|
|
}
|
|
const average = (sum / relevantData.length / 255) * sensitivity
|
|
// Add to history
|
|
historyRef.current.push(Math.min(1, Math.max(0.05, average)))
|
|
lastActiveDataRef.current = [...historyRef.current]
|
|
// Maintain history size
|
|
if (historyRef.current.length > historySize) {
|
|
historyRef.current.shift()
|
|
}
|
|
}
|
|
needsRedrawRef.current = true
|
|
}
|
|
}
|
|
// Only redraw if needed
|
|
if (!needsRedrawRef.current && !active) {
|
|
rafId = requestAnimationFrame(animate)
|
|
return
|
|
}
|
|
needsRedrawRef.current = active
|
|
ctx.clearRect(0, 0, rect.width, rect.height)
|
|
const computedBarColor =
|
|
barColor ||
|
|
(() => {
|
|
const style = getComputedStyle(canvas)
|
|
// Try to get the computed color value directly
|
|
const color = style.color
|
|
return color || "#000"
|
|
})()
|
|
const step = barWidth + barGap
|
|
const barCount = Math.floor(rect.width / step)
|
|
const centerY = rect.height / 2
|
|
// Draw bars based on mode
|
|
if (mode === "static") {
|
|
// Static mode - bars in fixed positions
|
|
const dataToRender = processing
|
|
? staticBarsRef.current
|
|
: active
|
|
? staticBarsRef.current
|
|
: staticBarsRef.current.length > 0
|
|
? staticBarsRef.current
|
|
: []
|
|
for (let i = 0; i < barCount && i < dataToRender.length; i++) {
|
|
const value = dataToRender[i] || 0.1
|
|
const x = i * step
|
|
const barHeight = Math.max(baseBarHeight, value * rect.height * 0.8)
|
|
const y = centerY - barHeight / 2
|
|
ctx.fillStyle = computedBarColor
|
|
ctx.globalAlpha = 0.4 + value * 0.6
|
|
if (barRadius > 0) {
|
|
ctx.beginPath()
|
|
ctx.roundRect(x, y, barWidth, barHeight, barRadius)
|
|
ctx.fill()
|
|
} else {
|
|
ctx.fillRect(x, y, barWidth, barHeight)
|
|
}
|
|
}
|
|
} else {
|
|
// Scrolling mode - original behavior
|
|
for (let i = 0; i < barCount && i < historyRef.current.length; i++) {
|
|
const dataIndex = historyRef.current.length - 1 - i
|
|
const value = historyRef.current[dataIndex] || 0.1
|
|
const x = rect.width - (i + 1) * step
|
|
const barHeight = Math.max(baseBarHeight, value * rect.height * 0.8)
|
|
const y = centerY - barHeight / 2
|
|
ctx.fillStyle = computedBarColor
|
|
ctx.globalAlpha = 0.4 + value * 0.6
|
|
if (barRadius > 0) {
|
|
ctx.beginPath()
|
|
ctx.roundRect(x, y, barWidth, barHeight, barRadius)
|
|
ctx.fill()
|
|
} else {
|
|
ctx.fillRect(x, y, barWidth, barHeight)
|
|
}
|
|
}
|
|
}
|
|
// Apply edge fading
|
|
if (fadeEdges && fadeWidth > 0 && rect.width > 0) {
|
|
// Cache gradient if width hasn't changed
|
|
if (!gradientCacheRef.current || lastWidthRef.current !== rect.width) {
|
|
const gradient = ctx.createLinearGradient(0, 0, rect.width, 0)
|
|
const fadePercent = Math.min(0.3, fadeWidth / rect.width)
|
|
// destination-out: removes destination where source alpha is high
|
|
// We want: fade edges out, keep center solid
|
|
// Left edge: start opaque (1) = remove, fade to transparent (0) = keep
|
|
gradient.addColorStop(0, "rgba(255,255,255,1)")
|
|
gradient.addColorStop(fadePercent, "rgba(255,255,255,0)")
|
|
// Center stays transparent = keep everything
|
|
gradient.addColorStop(1 - fadePercent, "rgba(255,255,255,0)")
|
|
// Right edge: fade from transparent (0) = keep to opaque (1) = remove
|
|
gradient.addColorStop(1, "rgba(255,255,255,1)")
|
|
gradientCacheRef.current = gradient
|
|
lastWidthRef.current = rect.width
|
|
}
|
|
ctx.globalCompositeOperation = "destination-out"
|
|
ctx.fillStyle = gradientCacheRef.current
|
|
ctx.fillRect(0, 0, rect.width, rect.height)
|
|
ctx.globalCompositeOperation = "source-over"
|
|
}
|
|
ctx.globalAlpha = 1
|
|
rafId = requestAnimationFrame(animate)
|
|
}
|
|
rafId = requestAnimationFrame(animate)
|
|
return () => {
|
|
if (rafId) {
|
|
cancelAnimationFrame(rafId)
|
|
}
|
|
}
|
|
}, [
|
|
active,
|
|
processing,
|
|
sensitivity,
|
|
updateRate,
|
|
historySize,
|
|
barWidth,
|
|
baseBarHeight,
|
|
barGap,
|
|
barRadius,
|
|
barColor,
|
|
fadeEdges,
|
|
fadeWidth,
|
|
mode,
|
|
])
|
|
return (
|
|
<div
|
|
className={cn("relative h-full w-full", className)}
|
|
ref={containerRef}
|
|
style={{ height: heightStyle }}
|
|
aria-label={
|
|
active
|
|
? "Live audio waveform"
|
|
: processing
|
|
? "Processing audio"
|
|
: "Audio waveform idle"
|
|
}
|
|
role="img"
|
|
{...props}
|
|
>
|
|
{!active && !processing && (
|
|
<div className="border-muted-foreground/20 absolute top-1/2 right-0 left-0 -translate-y-1/2 border-t-2 border-dotted" />
|
|
)}
|
|
<canvas
|
|
className="block h-full w-full"
|
|
ref={canvasRef}
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|