{
	"$schema": "https://shadcn-svelte.com/schema/registry-item.json",
	"name": "bar-visualizer",
	"title": "Bar Visualizer",
	"type": "registry:ui",
	"description": "A discrete bar-style audio level visualizer driven by a frequency array.",
	"files": [
		{
			"content": "<script lang=\"ts\" module>\n\timport type { HTMLAttributes } from \"svelte/elements\";\n\n\texport type AgentState = \"connecting\" | \"initializing\" | \"listening\" | \"speaking\" | \"thinking\";\n\n\texport type BarVisualizerProps = HTMLAttributes<HTMLDivElement> & {\n\t\t/**\n\t\t * Voice-agent lifecycle state. Drives the bar highlight sequence and\n\t\t * animation cadence. Leave undefined for a static row.\n\t\t */\n\t\tstate?: AgentState;\n\t\t/**\n\t\t * Number of bars to render across the visualizer.\n\t\t * @default 15\n\t\t */\n\t\tbarCount?: number;\n\t\t/**\n\t\t * Live audio source used for FFT analysis. Ignored when `demo` is\n\t\t * `true`. Pass `null` to disable analysis without unmounting.\n\t\t */\n\t\tmediaStream?: MediaStream | null;\n\t\t/**\n\t\t * Minimum bar height as a percentage of the container.\n\t\t * @default 20\n\t\t */\n\t\tminHeight?: number;\n\t\t/**\n\t\t * Maximum bar height as a percentage of the container.\n\t\t * @default 100\n\t\t */\n\t\tmaxHeight?: number;\n\t\t/**\n\t\t * When `true`, replaces the FFT feed with a synthetic oscillating\n\t\t * pattern. Useful for previews and documentation.\n\t\t * @default false\n\t\t */\n\t\tdemo?: boolean;\n\t\t/**\n\t\t * Align bars from the vertical center rather than growing up from the\n\t\t * bottom.\n\t\t * @default false\n\t\t */\n\t\tcenterAlign?: boolean;\n\t\t/** Bound reference to the root container element. */\n\t\tref?: HTMLDivElement | null;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport { cn } from \"$UTILS$.js\";\n\timport {\n\t\tcreateAudioAnalyser,\n\t\tgenerateConnectingSequenceBar,\n\t\tgenerateListeningSequenceBar,\n\t\tnormalizeDb,\n\t} from \"./utils.js\";\n\n\tlet {\n\t\tstate: agentState,\n\t\tbarCount = 15,\n\t\tmediaStream,\n\t\tminHeight = 20,\n\t\tmaxHeight = 100,\n\t\tdemo = false,\n\t\tcenterAlign = false,\n\t\tclass: className,\n\t\tstyle,\n\t\tref = $bindable(null),\n\t\t...restProps\n\t}: BarVisualizerProps = $props();\n\n\tlet realBands = $state<number[]>(new Array(barCount).fill(0));\n\tlet fakeBands = $state<number[]>(new Array(barCount).fill(0.2));\n\tlet highlightedIndices = $state<number[]>([]);\n\n\tconst volumeBands = $derived(demo ? fakeBands : realBands);\n\n\tconst animInterval = $derived(\n\t\tagentState === \"connecting\"\n\t\t\t? 2000 / barCount\n\t\t\t: agentState === \"thinking\"\n\t\t\t\t? 150\n\t\t\t\t: agentState === \"listening\"\n\t\t\t\t\t? 500\n\t\t\t\t\t: 1000\n\t);\n\n\t// useMultibandVolume — real FFT analysis\n\t$effect(() => {\n\t\tif (demo || !mediaStream) {\n\t\t\trealBands = new Array(barCount).fill(0);\n\t\t\treturn;\n\t\t}\n\t\tconst currentBarCount = barCount;\n\t\tconst { analyser, cleanup } = createAudioAnalyser(mediaStream, { fftSize: 2048 });\n\n\t\tconst bufferLength = analyser.frequencyBinCount;\n\t\tconst dataArray = new Float32Array(bufferLength);\n\t\tconst sliceStart = 100;\n\t\tconst sliceEnd = 200;\n\t\tconst sliceLength = sliceEnd - sliceStart;\n\t\tconst chunkSize = Math.ceil(sliceLength / currentBarCount);\n\t\tconst updateInterval = 32;\n\n\t\tlet lastUpdate = 0;\n\t\tlet rafId: number | null = null;\n\t\tconst bandsRef = new Array(currentBarCount).fill(0);\n\n\t\tconst tick = (timestamp: number) => {\n\t\t\tif (timestamp - lastUpdate >= updateInterval) {\n\t\t\t\tanalyser.getFloatFrequencyData(dataArray);\n\t\t\t\tconst chunks = new Array<number>(currentBarCount);\n\t\t\t\tfor (let i = 0; i < currentBarCount; i++) {\n\t\t\t\t\tlet sum = 0;\n\t\t\t\t\tlet count = 0;\n\t\t\t\t\tconst startIdx = sliceStart + i * chunkSize;\n\t\t\t\t\tconst endIdx = Math.min(sliceStart + (i + 1) * chunkSize, sliceEnd);\n\t\t\t\t\tfor (let j = startIdx; j < endIdx; j++) {\n\t\t\t\t\t\tsum += normalizeDb(dataArray[j]);\n\t\t\t\t\t\tcount++;\n\t\t\t\t\t}\n\t\t\t\t\tchunks[i] = count > 0 ? sum / count : 0;\n\t\t\t\t}\n\n\t\t\t\tlet hasChanged = false;\n\t\t\t\tfor (let i = 0; i < chunks.length; i++) {\n\t\t\t\t\tif (Math.abs(chunks[i] - bandsRef[i]) > 0.01) {\n\t\t\t\t\t\thasChanged = true;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (hasChanged) {\n\t\t\t\t\tfor (let i = 0; i < chunks.length; i++) bandsRef[i] = chunks[i];\n\t\t\t\t\trealBands = chunks;\n\t\t\t\t}\n\t\t\t\tlastUpdate = timestamp;\n\t\t\t}\n\t\t\trafId = requestAnimationFrame(tick);\n\t\t};\n\n\t\trafId = requestAnimationFrame(tick);\n\t\treturn () => {\n\t\t\tcleanup();\n\t\t\tif (rafId !== null) cancelAnimationFrame(rafId);\n\t\t};\n\t});\n\n\t// Demo mode fake bands\n\t$effect(() => {\n\t\tif (!demo) return;\n\t\tconst currentBarCount = barCount;\n\t\tif (agentState !== \"speaking\" && agentState !== \"listening\") {\n\t\t\tfakeBands = new Array(currentBarCount).fill(0.2);\n\t\t\treturn;\n\t\t}\n\n\t\tlet lastUpdate = 0;\n\t\tconst updateInterval = 50;\n\t\tconst startTime = Date.now() / 1000;\n\t\tconst bandsRef = new Array(currentBarCount).fill(0.2);\n\t\tlet rafId: number | null = null;\n\n\t\tconst tick = (timestamp: number) => {\n\t\t\tif (timestamp - lastUpdate >= updateInterval) {\n\t\t\t\tconst time = Date.now() / 1000 - startTime;\n\t\t\t\tconst newBands = new Array<number>(currentBarCount);\n\t\t\t\tfor (let i = 0; i < currentBarCount; i++) {\n\t\t\t\t\tconst waveOffset = i * 0.5;\n\t\t\t\t\tconst baseVolume = Math.sin(time * 2 + waveOffset) * 0.3 + 0.5;\n\t\t\t\t\tconst randomNoise = Math.random() * 0.2;\n\t\t\t\t\tnewBands[i] = Math.max(0.1, Math.min(1, baseVolume + randomNoise));\n\t\t\t\t}\n\n\t\t\t\tlet hasChanged = false;\n\t\t\t\tfor (let i = 0; i < currentBarCount; i++) {\n\t\t\t\t\tif (Math.abs(newBands[i] - bandsRef[i]) > 0.05) {\n\t\t\t\t\t\thasChanged = true;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (hasChanged) {\n\t\t\t\t\tfor (let i = 0; i < currentBarCount; i++) bandsRef[i] = newBands[i];\n\t\t\t\t\tfakeBands = newBands;\n\t\t\t\t}\n\t\t\t\tlastUpdate = timestamp;\n\t\t\t}\n\t\t\trafId = requestAnimationFrame(tick);\n\t\t};\n\n\t\trafId = requestAnimationFrame(tick);\n\t\treturn () => {\n\t\t\tif (rafId !== null) cancelAnimationFrame(rafId);\n\t\t};\n\t});\n\n\t// useBarAnimator — state-driven sequence\n\t$effect(() => {\n\t\tconst currentState = agentState;\n\t\tconst currentBarCount = barCount;\n\t\tconst currentInterval = animInterval;\n\n\t\tlet sequence: number[][];\n\t\tif (currentState === \"thinking\" || currentState === \"listening\") {\n\t\t\tsequence = generateListeningSequenceBar(currentBarCount);\n\t\t} else if (currentState === \"connecting\" || currentState === \"initializing\") {\n\t\t\tsequence = generateConnectingSequenceBar(currentBarCount);\n\t\t} else if (currentState === undefined || currentState === \"speaking\") {\n\t\t\tsequence = [new Array(currentBarCount).fill(0).map((_, idx) => idx)];\n\t\t} else {\n\t\t\tsequence = [[]];\n\t\t}\n\n\t\tlet index = 0;\n\t\thighlightedIndices = sequence[0] || [];\n\n\t\tlet startTime = performance.now();\n\t\tlet rafId: number | null = null;\n\n\t\tconst animate = (time: number) => {\n\t\t\tif (time - startTime >= currentInterval) {\n\t\t\t\tindex = (index + 1) % sequence.length;\n\t\t\t\thighlightedIndices = sequence[index] || [];\n\t\t\t\tstartTime = time;\n\t\t\t}\n\t\t\trafId = requestAnimationFrame(animate);\n\t\t};\n\n\t\trafId = requestAnimationFrame(animate);\n\t\treturn () => {\n\t\t\tif (rafId !== null) cancelAnimationFrame(rafId);\n\t\t};\n\t});\n</script>\n\n<div\n\tbind:this={ref}\n\tdata-state={agentState}\n\tclass={cn(\n\t\t\"relative flex justify-center gap-1.5\",\n\t\tcenterAlign ? \"items-center\" : \"items-end\",\n\t\t\"bg-muted h-32 w-full overflow-hidden rounded-lg p-4\",\n\t\tclassName\n\t)}\n\t{style}\n\t{...restProps}\n\tdata-slot=\"bar-visualizer\"\n>\n\t{#each volumeBands as volume, index (index)}\n\t\t{@const heightPct = Math.min(maxHeight, Math.max(minHeight, volume * 100 + 5))}\n\t\t{@const isHighlighted = highlightedIndices.includes(index)}\n\t\t<div\n\t\t\tdata-highlighted={isHighlighted}\n\t\t\tclass={cn(\n\t\t\t\t\"max-w-[12px] min-w-[8px] flex-1 rounded-full transition-all duration-150\",\n\t\t\t\t\"bg-border data-[highlighted=true]:bg-primary\",\n\t\t\t\tagentState === \"speaking\" && \"bg-primary\",\n\t\t\t\tagentState === \"thinking\" && isHighlighted && \"animate-pulse\"\n\t\t\t)}\n\t\t\tstyle=\"height: {heightPct}%;{agentState === 'thinking' ? ' animation-duration: 300ms;' : ''}\"\n\t\t></div>\n\t{/each}\n</div>\n",
			"type": "registry:ui",
			"target": "bar-visualizer/bar-visualizer.svelte"
		},
		{
			"content": "export interface AudioAnalyserOptions {\n\tfftSize?: number;\n\tsmoothingTimeConstant?: number;\n\tminDecibels?: number;\n\tmaxDecibels?: number;\n}\n\nexport interface MultiBandVolumeOptions {\n\tbands?: number;\n\tloPass?: number;\n\thiPass?: number;\n\tupdateInterval?: number;\n\tanalyserOptions?: AudioAnalyserOptions;\n}\n\nexport function createAudioAnalyser(\n\tmediaStream: MediaStream,\n\toptions: AudioAnalyserOptions = {}\n): { analyser: AnalyserNode; audioContext: AudioContext; cleanup: () => void } {\n\tconst AudioContextCtor =\n\t\twindow.AudioContext ||\n\t\t(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;\n\tconst audioContext = new AudioContextCtor();\n\tconst source = audioContext.createMediaStreamSource(mediaStream);\n\tconst analyser = audioContext.createAnalyser();\n\n\tif (options.fftSize) analyser.fftSize = options.fftSize;\n\tif (options.smoothingTimeConstant !== undefined) {\n\t\tanalyser.smoothingTimeConstant = options.smoothingTimeConstant;\n\t}\n\tif (options.minDecibels !== undefined) analyser.minDecibels = options.minDecibels;\n\tif (options.maxDecibels !== undefined) analyser.maxDecibels = options.maxDecibels;\n\n\tsource.connect(analyser);\n\n\tconst cleanup = () => {\n\t\tsource.disconnect();\n\t\tif (audioContext.state !== \"closed\") audioContext.close();\n\t};\n\n\treturn { analyser, audioContext, cleanup };\n}\n\nexport function normalizeDb(value: number): number {\n\tif (value === -Infinity) return 0;\n\tconst minDb = -100;\n\tconst maxDb = -10;\n\tconst db = 1 - (Math.max(minDb, Math.min(maxDb, value)) * -1) / 100;\n\treturn Math.sqrt(db);\n}\n\nexport function generateConnectingSequenceBar(columns: number): number[][] {\n\tconst seq: number[][] = [];\n\tfor (let x = 0; x < columns; x++) {\n\t\tseq.push([x, columns - 1 - x]);\n\t}\n\treturn seq;\n}\n\nexport function generateListeningSequenceBar(columns: number): number[][] {\n\tconst center = Math.floor(columns / 2);\n\treturn [[center], [-1]];\n}\n",
			"type": "registry:ui",
			"target": "bar-visualizer/utils.ts"
		},
		{
			"content": "import Root from \"./bar-visualizer.svelte\";\n\nexport {\n\tRoot,\n\t//\n\tRoot as BarVisualizer,\n};\nexport type { BarVisualizerProps, AgentState } from \"./bar-visualizer.svelte\";\nexport type { AudioAnalyserOptions, MultiBandVolumeOptions } from \"./utils.js\";\n",
			"type": "registry:ui",
			"target": "bar-visualizer/index.ts"
		}
	]
}