{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "waveform", "files": [ { "path": "components/ui/waveform.tsx", "content": "\"use client\"\n\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type HTMLAttributes,\n} from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type WaveformProps = HTMLAttributes & {\n data?: number[]\n barWidth?: number\n barHeight?: number\n barGap?: number\n barRadius?: number\n barColor?: string\n fadeEdges?: boolean\n fadeWidth?: number\n height?: string | number\n active?: boolean\n onBarClick?: (index: number, value: number) => void\n}\n\nexport const Waveform = ({\n data = [],\n barWidth = 4,\n barHeight: baseBarHeight = 4,\n barGap = 2,\n barRadius = 2,\n barColor,\n fadeEdges = true,\n fadeWidth = 24,\n height = 128,\n onBarClick,\n className,\n ...props\n}: WaveformProps) => {\n const canvasRef = useRef(null)\n const containerRef = useRef(null)\n const heightStyle = typeof height === \"number\" ? `${height}px` : height\n\n useEffect(() => {\n const canvas = canvasRef.current\n const container = containerRef.current\n if (!canvas || !container) return\n\n const resizeObserver = new ResizeObserver(() => {\n const rect = container.getBoundingClientRect()\n const dpr = window.devicePixelRatio || 1\n\n canvas.width = rect.width * dpr\n canvas.height = rect.height * dpr\n canvas.style.width = `${rect.width}px`\n canvas.style.height = `${rect.height}px`\n\n const ctx = canvas.getContext(\"2d\")\n if (ctx) {\n ctx.scale(dpr, dpr)\n renderWaveform()\n }\n })\n\n const renderWaveform = () => {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n const rect = canvas.getBoundingClientRect()\n ctx.clearRect(0, 0, rect.width, rect.height)\n\n const computedBarColor =\n barColor ||\n getComputedStyle(canvas).getPropertyValue(\"--foreground\") ||\n \"#000\"\n\n const barCount = Math.floor(rect.width / (barWidth + barGap))\n const centerY = rect.height / 2\n\n for (let i = 0; i < barCount; i++) {\n const dataIndex = Math.floor((i / barCount) * data.length)\n const value = data[dataIndex] || 0\n const barHeight = Math.max(baseBarHeight, value * rect.height * 0.8)\n const x = i * (barWidth + barGap)\n const y = centerY - barHeight / 2\n\n ctx.fillStyle = computedBarColor\n ctx.globalAlpha = 0.3 + value * 0.7\n\n if (barRadius > 0) {\n ctx.beginPath()\n ctx.roundRect(x, y, barWidth, barHeight, barRadius)\n ctx.fill()\n } else {\n ctx.fillRect(x, y, barWidth, barHeight)\n }\n }\n\n if (fadeEdges && fadeWidth > 0 && rect.width > 0) {\n const gradient = ctx.createLinearGradient(0, 0, rect.width, 0)\n const fadePercent = Math.min(0.2, fadeWidth / rect.width)\n\n gradient.addColorStop(0, \"rgba(255,255,255,1)\")\n gradient.addColorStop(fadePercent, \"rgba(255,255,255,0)\")\n gradient.addColorStop(1 - fadePercent, \"rgba(255,255,255,0)\")\n gradient.addColorStop(1, \"rgba(255,255,255,1)\")\n\n ctx.globalCompositeOperation = \"destination-out\"\n ctx.fillStyle = gradient\n ctx.fillRect(0, 0, rect.width, rect.height)\n ctx.globalCompositeOperation = \"source-over\"\n }\n\n ctx.globalAlpha = 1\n }\n\n resizeObserver.observe(container)\n renderWaveform()\n\n return () => resizeObserver.disconnect()\n }, [\n data,\n barWidth,\n baseBarHeight,\n barGap,\n barRadius,\n barColor,\n fadeEdges,\n fadeWidth,\n ])\n\n const handleClick = (e: React.MouseEvent) => {\n if (!onBarClick) return\n\n const rect = canvasRef.current?.getBoundingClientRect()\n if (!rect) return\n\n const x = e.clientX - rect.left\n const barIndex = Math.floor(x / (barWidth + barGap))\n const dataIndex = Math.floor(\n (barIndex * data.length) / Math.floor(rect.width / (barWidth + barGap))\n )\n\n if (dataIndex >= 0 && dataIndex < data.length) {\n onBarClick(dataIndex, data[dataIndex])\n }\n }\n\n return (\n \n \n \n )\n}\n\nexport type ScrollingWaveformProps = Omit<\n WaveformProps,\n \"data\" | \"onBarClick\"\n> & {\n speed?: number\n barCount?: number\n data?: number[]\n}\n\nexport const ScrollingWaveform = ({\n speed = 50,\n barCount = 60,\n barWidth = 4,\n barHeight: baseBarHeight = 4,\n barGap = 2,\n barRadius = 2,\n barColor,\n fadeEdges = true,\n fadeWidth = 24,\n height = 128,\n data,\n className,\n ...props\n}: ScrollingWaveformProps) => {\n const canvasRef = useRef(null)\n const containerRef = useRef(null)\n const barsRef = useRef>([])\n const animationRef = useRef(0)\n const lastTimeRef = useRef(0)\n const seedRef = useRef(Math.random())\n const dataIndexRef = useRef(0)\n const heightStyle = typeof height === \"number\" ? `${height}px` : height\n\n useEffect(() => {\n const canvas = canvasRef.current\n const container = containerRef.current\n if (!canvas || !container) return\n\n const resizeObserver = new ResizeObserver(() => {\n const rect = container.getBoundingClientRect()\n const dpr = window.devicePixelRatio || 1\n\n canvas.width = rect.width * dpr\n canvas.height = rect.height * dpr\n canvas.style.width = `${rect.width}px`\n canvas.style.height = `${rect.height}px`\n\n const ctx = canvas.getContext(\"2d\")\n if (ctx) {\n ctx.scale(dpr, dpr)\n }\n\n if (barsRef.current.length === 0) {\n const step = barWidth + barGap\n let currentX = rect.width\n let index = 0\n const seededRandom = (i: number) => {\n const x = Math.sin(seedRef.current * 10000 + i) * 10000\n return x - Math.floor(x)\n }\n while (currentX > -step) {\n barsRef.current.push({\n x: currentX,\n height: 0.2 + seededRandom(index++) * 0.6,\n })\n currentX -= step\n }\n }\n })\n\n resizeObserver.observe(container)\n return () => resizeObserver.disconnect()\n }, [barWidth, barGap])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n const animate = (currentTime: number) => {\n const deltaTime = lastTimeRef.current\n ? (currentTime - lastTimeRef.current) / 1000\n : 0\n lastTimeRef.current = currentTime\n\n const rect = canvas.getBoundingClientRect()\n ctx.clearRect(0, 0, rect.width, rect.height)\n\n const computedBarColor =\n barColor ||\n getComputedStyle(canvas).getPropertyValue(\"--foreground\") ||\n \"#000\"\n\n const step = barWidth + barGap\n for (let i = 0; i < barsRef.current.length; i++) {\n barsRef.current[i].x -= speed * deltaTime\n }\n\n barsRef.current = barsRef.current.filter(\n (bar) => bar.x + barWidth > -step\n )\n\n while (\n barsRef.current.length === 0 ||\n barsRef.current[barsRef.current.length - 1].x < rect.width\n ) {\n const lastBar = barsRef.current[barsRef.current.length - 1]\n const nextX = lastBar ? lastBar.x + step : rect.width\n\n let newHeight: number\n if (data && data.length > 0) {\n newHeight = data[dataIndexRef.current % data.length] || 0.1\n dataIndexRef.current = (dataIndexRef.current + 1) % data.length\n } else {\n const time = Date.now() / 1000\n const uniqueIndex = barsRef.current.length + time * 0.01\n const seededRandom = (index: number) => {\n const x = Math.sin(seedRef.current * 10000 + index * 137.5) * 10000\n return x - Math.floor(x)\n }\n const wave1 = Math.sin(uniqueIndex * 0.1) * 0.2\n const wave2 = Math.cos(uniqueIndex * 0.05) * 0.15\n const randomComponent = seededRandom(uniqueIndex) * 0.4\n newHeight = Math.max(\n 0.1,\n Math.min(0.9, 0.3 + wave1 + wave2 + randomComponent)\n )\n }\n\n barsRef.current.push({\n x: nextX,\n height: newHeight,\n })\n if (barsRef.current.length > barCount * 2) break\n }\n\n const centerY = rect.height / 2\n for (const bar of barsRef.current) {\n if (bar.x < rect.width && bar.x + barWidth > 0) {\n const barHeight = Math.max(\n baseBarHeight,\n bar.height * rect.height * 0.6\n )\n const y = centerY - barHeight / 2\n\n ctx.fillStyle = computedBarColor\n ctx.globalAlpha = 0.3 + bar.height * 0.7\n\n if (barRadius > 0) {\n ctx.beginPath()\n ctx.roundRect(bar.x, y, barWidth, barHeight, barRadius)\n ctx.fill()\n } else {\n ctx.fillRect(bar.x, y, barWidth, barHeight)\n }\n }\n }\n\n if (fadeEdges && fadeWidth > 0) {\n const gradient = ctx.createLinearGradient(0, 0, rect.width, 0)\n const fadePercent = Math.min(0.2, fadeWidth / rect.width)\n\n gradient.addColorStop(0, \"rgba(255,255,255,1)\")\n gradient.addColorStop(fadePercent, \"rgba(255,255,255,0)\")\n gradient.addColorStop(1 - fadePercent, \"rgba(255,255,255,0)\")\n gradient.addColorStop(1, \"rgba(255,255,255,1)\")\n\n ctx.globalCompositeOperation = \"destination-out\"\n ctx.fillStyle = gradient\n ctx.fillRect(0, 0, rect.width, rect.height)\n ctx.globalCompositeOperation = \"source-over\"\n }\n\n ctx.globalAlpha = 1\n\n animationRef.current = requestAnimationFrame(animate)\n }\n\n animationRef.current = requestAnimationFrame(animate)\n\n return () => {\n if (animationRef.current) {\n cancelAnimationFrame(animationRef.current)\n }\n }\n }, [\n speed,\n barCount,\n barWidth,\n baseBarHeight,\n barGap,\n barRadius,\n barColor,\n fadeEdges,\n fadeWidth,\n data,\n ])\n\n return (\n \n \n \n )\n}\n\nexport type AudioScrubberProps = WaveformProps & {\n currentTime?: number\n duration?: number\n onSeek?: (time: number) => void\n showHandle?: boolean\n}\n\nexport const AudioScrubber = ({\n data = [],\n currentTime = 0,\n duration = 100,\n onSeek,\n showHandle = true,\n barWidth = 3,\n barHeight,\n barGap = 1,\n barRadius = 1,\n barColor,\n height = 128,\n className,\n ...props\n}: AudioScrubberProps) => {\n const [isDragging, setIsDragging] = useState(false)\n const [localProgress, setLocalProgress] = useState(0)\n const containerRef = useRef(null)\n\n const waveformData =\n data.length > 0\n ? data\n : Array.from({ length: 100 }, () => 0.2 + Math.random() * 0.6)\n\n useEffect(() => {\n if (!isDragging && duration > 0) {\n setLocalProgress(currentTime / duration)\n }\n }, [currentTime, duration, isDragging])\n\n const handleScrub = useCallback(\n (clientX: number) => {\n const container = containerRef.current\n if (!container) return\n\n const rect = container.getBoundingClientRect()\n const x = Math.max(0, Math.min(clientX - rect.left, rect.width))\n const progress = x / rect.width\n const newTime = progress * duration\n\n setLocalProgress(progress)\n onSeek?.(newTime)\n },\n [duration, onSeek]\n )\n\n const handleMouseDown = (e: React.MouseEvent) => {\n e.preventDefault()\n setIsDragging(true)\n handleScrub(e.clientX)\n }\n\n useEffect(() => {\n if (!isDragging) return\n\n const handleMouseMove = (e: MouseEvent) => {\n handleScrub(e.clientX)\n }\n\n const handleMouseUp = () => {\n setIsDragging(false)\n }\n\n document.addEventListener(\"mousemove\", handleMouseMove)\n document.addEventListener(\"mouseup\", handleMouseUp)\n\n return () => {\n document.removeEventListener(\"mousemove\", handleMouseMove)\n document.removeEventListener(\"mouseup\", handleMouseUp)\n }\n }, [isDragging, duration, handleScrub])\n\n const heightStyle = typeof height === \"number\" ? `${height}px` : height\n\n return (\n \n \n\n \n\n \n\n {showHandle && (\n \n )}\n \n )\n}\n\nexport type MicrophoneWaveformProps = WaveformProps & {\n active?: boolean\n processing?: boolean\n fftSize?: number\n smoothingTimeConstant?: number\n sensitivity?: number\n onError?: (error: Error) => void\n}\n\nexport const MicrophoneWaveform = ({\n active = false,\n processing = false,\n fftSize = 256,\n smoothingTimeConstant = 0.8,\n sensitivity = 1,\n onError,\n ...props\n}: MicrophoneWaveformProps) => {\n const [data, setData] = useState([])\n const analyserRef = useRef(null)\n const audioContextRef = useRef(null)\n const streamRef = useRef(null)\n const animationIdRef = useRef(null)\n const processingAnimationRef = useRef(null)\n const lastActiveDataRef = useRef([])\n const transitionProgressRef = useRef(0)\n\n useEffect(() => {\n if (processing && !active) {\n let time = 0\n transitionProgressRef.current = 0\n\n const animateProcessing = () => {\n time += 0.03\n transitionProgressRef.current = Math.min(\n 1,\n transitionProgressRef.current + 0.02\n )\n\n const processingData = []\n const barCount = 45\n\n for (let i = 0; i < barCount; i++) {\n const normalizedPosition = (i - barCount / 2) / (barCount / 2)\n const centerWeight = 1 - Math.abs(normalizedPosition) * 0.4\n\n const wave1 = Math.sin(time * 1.5 + i * 0.15) * 0.25\n const wave2 = Math.sin(time * 0.8 - i * 0.1) * 0.2\n const wave3 = Math.cos(time * 2 + i * 0.05) * 0.15\n const combinedWave = wave1 + wave2 + wave3\n const processingValue = (0.2 + combinedWave) * centerWeight\n\n let finalValue = processingValue\n if (\n lastActiveDataRef.current.length > 0 &&\n transitionProgressRef.current < 1\n ) {\n const lastDataIndex = Math.floor(\n (i / barCount) * lastActiveDataRef.current.length\n )\n const lastValue = lastActiveDataRef.current[lastDataIndex] || 0\n finalValue =\n lastValue * (1 - transitionProgressRef.current) +\n processingValue * transitionProgressRef.current\n }\n\n processingData.push(Math.max(0.05, Math.min(1, finalValue)))\n }\n\n setData(processingData)\n processingAnimationRef.current =\n requestAnimationFrame(animateProcessing)\n }\n\n animateProcessing()\n\n return () => {\n if (processingAnimationRef.current) {\n cancelAnimationFrame(processingAnimationRef.current)\n }\n }\n } else if (!active && !processing) {\n if (data.length > 0) {\n let fadeProgress = 0\n const fadeToIdle = () => {\n fadeProgress += 0.03\n if (fadeProgress < 1) {\n const fadedData = data.map((value) => value * (1 - fadeProgress))\n setData(fadedData)\n requestAnimationFrame(fadeToIdle)\n } else {\n setData([])\n }\n }\n fadeToIdle()\n }\n return\n }\n }, [processing, active])\n\n useEffect(() => {\n if (!active) {\n if (streamRef.current) {\n streamRef.current.getTracks().forEach((track) => track.stop())\n }\n if (\n audioContextRef.current &&\n audioContextRef.current.state !== \"closed\"\n ) {\n audioContextRef.current.close()\n }\n if (animationIdRef.current) {\n cancelAnimationFrame(animationIdRef.current)\n }\n return\n }\n\n const setupMicrophone = async () => {\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: true,\n })\n streamRef.current = stream\n\n const audioContext = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)()\n const analyser = audioContext.createAnalyser()\n analyser.fftSize = fftSize\n analyser.smoothingTimeConstant = smoothingTimeConstant\n\n const source = audioContext.createMediaStreamSource(stream)\n source.connect(analyser)\n\n audioContextRef.current = audioContext\n analyserRef.current = analyser\n\n const dataArray = new Uint8Array(analyser.frequencyBinCount)\n\n const updateData = () => {\n if (!analyserRef.current || !active) return\n\n analyserRef.current.getByteFrequencyData(dataArray)\n\n const startFreq = Math.floor(dataArray.length * 0.05)\n const endFreq = Math.floor(dataArray.length * 0.4)\n const relevantData = dataArray.slice(startFreq, endFreq)\n\n const halfLength = Math.floor(relevantData.length / 2)\n const normalizedData = []\n\n for (let i = halfLength - 1; i >= 0; i--) {\n const value = Math.min(1, (relevantData[i] / 255) * sensitivity)\n normalizedData.push(value)\n }\n\n for (let i = 0; i < halfLength; i++) {\n const value = Math.min(1, (relevantData[i] / 255) * sensitivity)\n normalizedData.push(value)\n }\n\n setData(normalizedData)\n lastActiveDataRef.current = normalizedData\n\n animationIdRef.current = requestAnimationFrame(updateData)\n }\n\n updateData()\n } catch (error) {\n onError?.(error as Error)\n }\n }\n\n setupMicrophone()\n\n return () => {\n if (streamRef.current) {\n streamRef.current.getTracks().forEach((track) => track.stop())\n }\n if (\n audioContextRef.current &&\n audioContextRef.current.state !== \"closed\"\n ) {\n audioContextRef.current.close()\n }\n if (animationIdRef.current) {\n cancelAnimationFrame(animationIdRef.current)\n }\n }\n }, [active, fftSize, smoothingTimeConstant, sensitivity, onError])\n\n return \n}\n\nexport type StaticWaveformProps = WaveformProps & {\n bars?: number\n seed?: number\n}\n\nexport const StaticWaveform = ({\n bars = 40,\n seed = 42,\n ...props\n}: StaticWaveformProps) => {\n const data = useMemo(() => {\n const random = (seedValue: number) => {\n const x = Math.sin(seedValue) * 10000\n return x - Math.floor(x)\n }\n\n return Array.from({ length: bars }, (_, i) => 0.2 + random(seed + i) * 0.6)\n }, [bars, seed])\n\n return \n}\n\nexport type LiveMicrophoneWaveformProps = Omit<\n ScrollingWaveformProps,\n \"barCount\"\n> & {\n active?: boolean\n fftSize?: number\n smoothingTimeConstant?: number\n sensitivity?: number\n onError?: (error: Error) => void\n historySize?: number\n updateRate?: number\n savedHistoryRef?: React.MutableRefObject\n dragOffset?: number\n setDragOffset?: (offset: number) => void\n enableAudioPlayback?: boolean\n playbackRate?: number\n}\n\nexport const LiveMicrophoneWaveform = ({\n active = false,\n fftSize = 256,\n smoothingTimeConstant = 0.8,\n sensitivity = 1,\n onError,\n historySize = 150,\n updateRate = 50,\n barWidth = 3,\n barHeight: baseBarHeight = 4,\n barGap = 1,\n barRadius = 1,\n barColor,\n fadeEdges = true,\n fadeWidth = 24,\n height = 128,\n className,\n savedHistoryRef,\n dragOffset: externalDragOffset,\n setDragOffset: externalSetDragOffset,\n enableAudioPlayback = true,\n playbackRate = 1,\n ...props\n}: LiveMicrophoneWaveformProps) => {\n const canvasRef = useRef(null)\n const containerRef = useRef(null)\n const internalHistoryRef = useRef([])\n const historyRef = savedHistoryRef || internalHistoryRef\n const analyserRef = useRef(null)\n const audioContextRef = useRef(null)\n const streamRef = useRef(null)\n const animationRef = useRef(0)\n const lastUpdateRef = useRef(0)\n const [internalDragOffset, setInternalDragOffset] = useState(0)\n const [isDragging, setIsDragging] = useState(false)\n const [playbackPosition, setPlaybackPosition] = useState(null)\n const dragStartXRef = useRef(0)\n const dragStartOffsetRef = useRef(0)\n const playbackStartTimeRef = useRef(0)\n\n // Audio recording and playback refs\n const mediaRecorderRef = useRef(null)\n const audioChunksRef = useRef([])\n const audioBufferRef = useRef(null)\n const sourceNodeRef = useRef(null)\n const scrubSourceRef = useRef(null)\n\n // Use external drag state if provided, otherwise use internal\n const dragOffset = externalDragOffset ?? internalDragOffset\n const setDragOffset = externalSetDragOffset ?? setInternalDragOffset\n\n const heightStyle = typeof height === \"number\" ? `${height}px` : height\n\n useEffect(() => {\n const canvas = canvasRef.current\n const container = containerRef.current\n if (!canvas || !container) return\n\n const resizeObserver = new ResizeObserver(() => {\n const rect = container.getBoundingClientRect()\n const dpr = window.devicePixelRatio || 1\n\n canvas.width = rect.width * dpr\n canvas.height = rect.height * dpr\n canvas.style.width = `${rect.width}px`\n canvas.style.height = `${rect.height}px`\n\n const ctx = canvas.getContext(\"2d\")\n if (ctx) {\n ctx.scale(dpr, dpr)\n }\n })\n\n resizeObserver.observe(container)\n return () => resizeObserver.disconnect()\n }, [])\n\n useEffect(() => {\n if (!active) {\n if (\n mediaRecorderRef.current &&\n mediaRecorderRef.current.state !== \"inactive\"\n ) {\n mediaRecorderRef.current.stop()\n }\n if (streamRef.current) {\n streamRef.current.getTracks().forEach((track) => track.stop())\n }\n // Process recorded audio when stopping\n if (enableAudioPlayback && audioChunksRef.current.length > 0) {\n const audioBlob = new Blob(audioChunksRef.current, {\n type: \"audio/webm\",\n })\n processAudioBlob(audioBlob)\n }\n return\n }\n\n setDragOffset?.(0)\n historyRef.current = []\n audioChunksRef.current = []\n audioBufferRef.current = null\n setPlaybackPosition(null)\n\n const setupMicrophone = async () => {\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: true,\n })\n streamRef.current = stream\n\n const audioContext = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)()\n const analyser = audioContext.createAnalyser()\n analyser.fftSize = fftSize\n analyser.smoothingTimeConstant = smoothingTimeConstant\n\n const source = audioContext.createMediaStreamSource(stream)\n source.connect(analyser)\n\n audioContextRef.current = audioContext\n analyserRef.current = analyser\n\n if (enableAudioPlayback) {\n const mediaRecorder = new MediaRecorder(stream)\n mediaRecorderRef.current = mediaRecorder\n\n mediaRecorder.ondataavailable = (event) => {\n if (event.data.size > 0) {\n audioChunksRef.current.push(event.data)\n }\n }\n\n mediaRecorder.start(100)\n }\n } catch (error) {\n onError?.(error as Error)\n }\n }\n\n setupMicrophone()\n\n return () => {\n if (\n mediaRecorderRef.current &&\n mediaRecorderRef.current.state !== \"inactive\"\n ) {\n mediaRecorderRef.current.stop()\n }\n if (streamRef.current) {\n streamRef.current.getTracks().forEach((track) => track.stop())\n }\n if (sourceNodeRef.current) {\n sourceNodeRef.current.stop()\n }\n if (scrubSourceRef.current) {\n scrubSourceRef.current.stop()\n }\n }\n }, [\n active,\n fftSize,\n smoothingTimeConstant,\n onError,\n setDragOffset,\n enableAudioPlayback,\n historyRef,\n ])\n\n const processAudioBlob = async (blob: Blob) => {\n try {\n const arrayBuffer = await blob.arrayBuffer()\n if (audioContextRef.current) {\n const audioBuffer =\n await audioContextRef.current.decodeAudioData(arrayBuffer)\n audioBufferRef.current = audioBuffer\n }\n } catch (error) {\n console.error(\"Error processing audio:\", error)\n }\n }\n\n const playScrubSound = useCallback(\n (position: number, direction: number) => {\n if (\n !enableAudioPlayback ||\n !audioBufferRef.current ||\n !audioContextRef.current\n )\n return\n\n if (scrubSourceRef.current) {\n try {\n scrubSourceRef.current.stop()\n } catch {}\n }\n\n const source = audioContextRef.current.createBufferSource()\n source.buffer = audioBufferRef.current\n\n const speed = Math.abs(direction)\n const playbackRate =\n direction > 0\n ? Math.min(3, 1 + speed * 0.1)\n : Math.max(-3, -1 - speed * 0.1)\n\n source.playbackRate.value = playbackRate\n\n const filter = audioContextRef.current.createBiquadFilter()\n filter.type = \"lowpass\"\n filter.frequency.value = Math.max(200, 2000 - speed * 100)\n\n source.connect(filter)\n filter.connect(audioContextRef.current.destination)\n\n const startTime = Math.max(\n 0,\n Math.min(position, audioBufferRef.current.duration - 0.1)\n )\n source.start(0, startTime, 0.1)\n scrubSourceRef.current = source\n },\n [enableAudioPlayback]\n )\n\n const playFromPosition = useCallback(\n (position: number) => {\n if (\n !enableAudioPlayback ||\n !audioBufferRef.current ||\n !audioContextRef.current\n )\n return\n\n if (sourceNodeRef.current) {\n try {\n sourceNodeRef.current.stop()\n } catch {}\n }\n\n const source = audioContextRef.current.createBufferSource()\n source.buffer = audioBufferRef.current\n source.playbackRate.value = playbackRate\n source.connect(audioContextRef.current.destination)\n\n const startTime = Math.max(\n 0,\n Math.min(position, audioBufferRef.current.duration)\n )\n source.start(0, startTime)\n sourceNodeRef.current = source\n\n playbackStartTimeRef.current =\n audioContextRef.current.currentTime - startTime\n setPlaybackPosition(startTime)\n\n source.onended = () => {\n setPlaybackPosition(null)\n }\n },\n [enableAudioPlayback, playbackRate]\n )\n\n useEffect(() => {\n if (playbackPosition === null || !audioBufferRef.current) return\n\n let animationId: number\n const updatePlaybackVisual = () => {\n if (\n audioContextRef.current &&\n sourceNodeRef.current &&\n audioBufferRef.current\n ) {\n const elapsed =\n audioContextRef.current.currentTime - playbackStartTimeRef.current\n const currentPos = playbackPosition + elapsed * playbackRate\n\n if (currentPos < audioBufferRef.current.duration) {\n const progressRatio = currentPos / audioBufferRef.current.duration\n const currentBarIndex = Math.floor(\n progressRatio * historyRef.current.length\n )\n const step = barWidth + barGap\n\n const containerWidth =\n containerRef.current?.getBoundingClientRect().width || 0\n const viewBars = Math.floor(containerWidth / step)\n const targetOffset =\n -(currentBarIndex - (historyRef.current.length - viewBars)) * step\n const clampedOffset = Math.max(\n -(historyRef.current.length - viewBars) * step,\n Math.min(0, targetOffset)\n )\n\n setDragOffset?.(clampedOffset)\n animationId = requestAnimationFrame(updatePlaybackVisual)\n } else {\n setPlaybackPosition(null)\n const step = barWidth + barGap\n const containerWidth =\n containerRef.current?.getBoundingClientRect().width || 0\n const viewBars = Math.floor(containerWidth / step)\n setDragOffset?.(-(historyRef.current.length - viewBars) * step)\n }\n }\n }\n\n animationId = requestAnimationFrame(updatePlaybackVisual)\n\n return () => {\n if (animationId) cancelAnimationFrame(animationId)\n }\n }, [\n playbackPosition,\n playbackRate,\n barWidth,\n baseBarHeight,\n barGap,\n setDragOffset,\n historyRef,\n ])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n if (!active && historyRef.current.length === 0 && playbackPosition === null)\n return\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n const animate = (currentTime: number) => {\n if (active && currentTime - lastUpdateRef.current > updateRate) {\n lastUpdateRef.current = currentTime\n\n if (analyserRef.current) {\n const dataArray = new Uint8Array(\n analyserRef.current.frequencyBinCount\n )\n analyserRef.current.getByteFrequencyData(dataArray)\n\n let sum = 0\n for (let i = 0; i < dataArray.length; i++) {\n sum += dataArray[i]\n }\n const average = (sum / dataArray.length / 255) * sensitivity\n\n historyRef.current.push(Math.min(1, Math.max(0.05, average)))\n\n if (historyRef.current.length > historySize) {\n historyRef.current.shift()\n }\n }\n }\n\n const rect = canvas.getBoundingClientRect()\n ctx.clearRect(0, 0, rect.width, rect.height)\n\n const computedBarColor =\n barColor ||\n getComputedStyle(canvas).getPropertyValue(\"--foreground\") ||\n \"#000\"\n\n const step = barWidth + barGap\n const barCount = Math.floor(rect.width / step)\n const centerY = rect.height / 2\n\n const dataToRender = historyRef.current\n\n if (dataToRender.length > 0) {\n const offsetInBars = Math.floor(dragOffset / step)\n\n for (let i = 0; i < barCount; i++) {\n let dataIndex\n\n if (active) {\n dataIndex = dataToRender.length - 1 - i\n } else {\n dataIndex = Math.max(\n 0,\n Math.min(\n dataToRender.length - 1,\n dataToRender.length - 1 - i - Math.floor(offsetInBars)\n )\n )\n }\n\n if (dataIndex >= 0 && dataIndex < dataToRender.length) {\n const value = dataToRender[dataIndex]\n if (value !== undefined) {\n const x = rect.width - (i + 1) * step\n const barHeight = Math.max(\n baseBarHeight,\n value * rect.height * 0.7\n )\n const y = centerY - barHeight / 2\n\n ctx.fillStyle = computedBarColor\n ctx.globalAlpha = 0.3 + value * 0.7\n\n if (barRadius > 0) {\n ctx.beginPath()\n ctx.roundRect(x, y, barWidth, barHeight, barRadius)\n ctx.fill()\n } else {\n ctx.fillRect(x, y, barWidth, barHeight)\n }\n }\n }\n }\n }\n\n if (fadeEdges && fadeWidth > 0) {\n const gradient = ctx.createLinearGradient(0, 0, rect.width, 0)\n const fadePercent = Math.min(0.2, fadeWidth / rect.width)\n\n gradient.addColorStop(0, \"rgba(255,255,255,1)\")\n gradient.addColorStop(fadePercent, \"rgba(255,255,255,0)\")\n gradient.addColorStop(1 - fadePercent, \"rgba(255,255,255,0)\")\n gradient.addColorStop(1, \"rgba(255,255,255,1)\")\n\n ctx.globalCompositeOperation = \"destination-out\"\n ctx.fillStyle = gradient\n ctx.fillRect(0, 0, rect.width, rect.height)\n ctx.globalCompositeOperation = \"source-over\"\n }\n\n ctx.globalAlpha = 1\n\n animationRef.current = requestAnimationFrame(animate)\n }\n\n if (active || historyRef.current.length > 0 || playbackPosition !== null) {\n animationRef.current = requestAnimationFrame(animate)\n }\n\n return () => {\n if (animationRef.current) {\n cancelAnimationFrame(animationRef.current)\n }\n }\n }, [\n active,\n sensitivity,\n updateRate,\n historySize,\n barWidth,\n baseBarHeight,\n barGap,\n barRadius,\n barColor,\n fadeEdges,\n fadeWidth,\n dragOffset,\n playbackPosition,\n historyRef,\n ])\n\n const handleMouseDown = (e: React.MouseEvent) => {\n if (active || historyRef.current.length === 0) return\n\n e.preventDefault()\n setIsDragging(true)\n dragStartXRef.current = e.clientX\n dragStartOffsetRef.current = dragOffset\n }\n\n useEffect(() => {\n if (!isDragging) return\n\n let lastScrubTime = 0\n let lastMouseX = dragStartXRef.current\n const handleMouseMove = (e: MouseEvent) => {\n const deltaX = e.clientX - dragStartXRef.current\n const newOffset = dragStartOffsetRef.current - deltaX * 0.5 // Reduce sensitivity\n\n const step = barWidth + barGap\n const maxBars = historyRef.current.length\n const viewWidth = canvasRef.current?.getBoundingClientRect().width || 0\n const viewBars = Math.floor(viewWidth / step)\n\n const maxOffset = Math.max(0, (maxBars - viewBars) * step)\n const minOffset = 0\n const clampedOffset = Math.max(minOffset, Math.min(maxOffset, newOffset))\n\n setDragOffset?.(clampedOffset)\n\n const now = Date.now()\n if (\n enableAudioPlayback &&\n audioBufferRef.current &&\n now - lastScrubTime > 50\n ) {\n lastScrubTime = now\n const offsetBars = Math.floor(clampedOffset / step)\n const rightmostBarIndex = Math.max(\n 0,\n Math.min(maxBars - 1, maxBars - 1 - offsetBars)\n )\n const audioPosition =\n (rightmostBarIndex / maxBars) * audioBufferRef.current.duration\n const direction = e.clientX - lastMouseX\n lastMouseX = e.clientX\n playScrubSound(\n Math.max(\n 0,\n Math.min(audioBufferRef.current.duration - 0.1, audioPosition)\n ),\n direction\n )\n }\n }\n\n const handleMouseUp = () => {\n setIsDragging(false)\n\n if (enableAudioPlayback && audioBufferRef.current) {\n const step = barWidth + barGap\n const maxBars = historyRef.current.length\n const offsetBars = Math.floor(dragOffset / step)\n const rightmostBarIndex = Math.max(\n 0,\n Math.min(maxBars - 1, maxBars - 1 - offsetBars)\n )\n const audioPosition =\n (rightmostBarIndex / maxBars) * audioBufferRef.current.duration\n playFromPosition(\n Math.max(\n 0,\n Math.min(audioBufferRef.current.duration - 0.1, audioPosition)\n )\n )\n }\n\n if (scrubSourceRef.current) {\n try {\n scrubSourceRef.current.stop()\n } catch {}\n }\n }\n\n document.addEventListener(\"mousemove\", handleMouseMove)\n document.addEventListener(\"mouseup\", handleMouseUp)\n\n return () => {\n document.removeEventListener(\"mousemove\", handleMouseMove)\n document.removeEventListener(\"mouseup\", handleMouseUp)\n }\n }, [\n isDragging,\n barWidth,\n barGap,\n setDragOffset,\n dragOffset,\n enableAudioPlayback,\n playScrubSound,\n playFromPosition,\n historyRef,\n ])\n\n return (\n 0 && \"cursor-pointer\",\n className\n )}\n onMouseDown={handleMouseDown}\n ref={containerRef}\n role={!active && historyRef.current.length > 0 ? \"slider\" : undefined}\n aria-label={\n !active && historyRef.current.length > 0\n ? \"Drag to scrub through recording\"\n : undefined\n }\n aria-valuenow={\n !active && historyRef.current.length > 0\n ? Math.abs(dragOffset)\n : undefined\n }\n aria-valuemin={!active && historyRef.current.length > 0 ? 0 : undefined}\n aria-valuemax={\n !active && historyRef.current.length > 0\n ? historyRef.current.length\n : undefined\n }\n tabIndex={!active && historyRef.current.length > 0 ? 0 : undefined}\n style={{ height: heightStyle }}\n {...props}\n >\n \n \n )\n}\n\nexport type RecordingWaveformProps = Omit<\n WaveformProps,\n \"data\" | \"onBarClick\"\n> & {\n recording?: boolean\n fftSize?: number\n smoothingTimeConstant?: number\n sensitivity?: number\n onError?: (error: Error) => void\n onRecordingComplete?: (data: number[]) => void\n updateRate?: number\n showHandle?: boolean\n}\n\nexport const RecordingWaveform = ({\n recording = false,\n fftSize = 256,\n smoothingTimeConstant = 0.8,\n sensitivity = 1,\n onError,\n onRecordingComplete,\n updateRate = 50,\n showHandle = true,\n barWidth = 3,\n barHeight: baseBarHeight = 4,\n barGap = 1,\n barRadius = 1,\n barColor,\n height = 128,\n className,\n ...props\n}: RecordingWaveformProps) => {\n const [recordedData, setRecordedData] = useState([])\n const [viewPosition, setViewPosition] = useState(1)\n const [isDragging, setIsDragging] = useState(false)\n const [isRecordingComplete, setIsRecordingComplete] = useState(false)\n\n const canvasRef = useRef(null)\n const containerRef = useRef(null)\n const recordingDataRef = useRef([])\n const analyserRef = useRef(null)\n const audioContextRef = useRef(null)\n const streamRef = useRef(null)\n const animationRef = useRef(0)\n const lastUpdateRef = useRef(0)\n\n const heightStyle = typeof height === \"number\" ? `${height}px` : height\n\n useEffect(() => {\n const canvas = canvasRef.current\n const container = containerRef.current\n if (!canvas || !container) return\n\n const resizeObserver = new ResizeObserver(() => {\n const rect = container.getBoundingClientRect()\n const dpr = window.devicePixelRatio || 1\n\n canvas.width = rect.width * dpr\n canvas.height = rect.height * dpr\n canvas.style.width = `${rect.width}px`\n canvas.style.height = `${rect.height}px`\n\n const ctx = canvas.getContext(\"2d\")\n if (ctx) {\n ctx.scale(dpr, dpr)\n }\n })\n\n resizeObserver.observe(container)\n return () => resizeObserver.disconnect()\n }, [])\n\n useEffect(() => {\n if (!recording) {\n if (streamRef.current) {\n streamRef.current.getTracks().forEach((track) => track.stop())\n }\n if (\n audioContextRef.current &&\n audioContextRef.current.state !== \"closed\"\n ) {\n audioContextRef.current.close()\n }\n\n if (recordingDataRef.current.length > 0) {\n setRecordedData([...recordingDataRef.current])\n setIsRecordingComplete(true)\n onRecordingComplete?.(recordingDataRef.current)\n }\n return\n }\n\n setIsRecordingComplete(false)\n recordingDataRef.current = []\n setRecordedData([])\n setViewPosition(1)\n\n const setupMicrophone = async () => {\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: true,\n })\n streamRef.current = stream\n\n const audioContext = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)()\n const analyser = audioContext.createAnalyser()\n analyser.fftSize = fftSize\n analyser.smoothingTimeConstant = smoothingTimeConstant\n\n const source = audioContext.createMediaStreamSource(stream)\n source.connect(analyser)\n\n audioContextRef.current = audioContext\n analyserRef.current = analyser\n } catch (error) {\n onError?.(error as Error)\n }\n }\n\n setupMicrophone()\n\n return () => {\n if (streamRef.current) {\n streamRef.current.getTracks().forEach((track) => track.stop())\n }\n if (\n audioContextRef.current &&\n audioContextRef.current.state !== \"closed\"\n ) {\n audioContextRef.current.close()\n }\n }\n }, [recording, fftSize, smoothingTimeConstant, onError, onRecordingComplete])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n const animate = (currentTime: number) => {\n if (recording && currentTime - lastUpdateRef.current > updateRate) {\n lastUpdateRef.current = currentTime\n\n if (analyserRef.current) {\n const dataArray = new Uint8Array(\n analyserRef.current.frequencyBinCount\n )\n analyserRef.current.getByteFrequencyData(dataArray)\n\n let sum = 0\n for (let i = 0; i < dataArray.length; i++) {\n sum += dataArray[i]\n }\n const average = (sum / dataArray.length / 255) * sensitivity\n\n recordingDataRef.current.push(Math.min(1, Math.max(0.05, average)))\n }\n }\n\n const rect = canvas.getBoundingClientRect()\n ctx.clearRect(0, 0, rect.width, rect.height)\n\n const computedBarColor =\n barColor ||\n getComputedStyle(canvas).getPropertyValue(\"--foreground\") ||\n \"#000\"\n\n const dataToRender = recording ? recordingDataRef.current : recordedData\n\n if (dataToRender.length > 0) {\n const step = barWidth + barGap\n const barsVisible = Math.floor(rect.width / step)\n const centerY = rect.height / 2\n\n let startIndex = 0\n if (!recording && isRecordingComplete) {\n const totalBars = dataToRender.length\n if (totalBars > barsVisible) {\n startIndex = Math.floor((totalBars - barsVisible) * viewPosition)\n }\n } else if (recording) {\n startIndex = Math.max(0, dataToRender.length - barsVisible)\n }\n\n for (\n let i = 0;\n i < barsVisible && startIndex + i < dataToRender.length;\n i++\n ) {\n const value = dataToRender[startIndex + i] || 0.1\n const x = i * step\n const barHeight = Math.max(baseBarHeight, value * rect.height * 0.7)\n const y = centerY - barHeight / 2\n\n ctx.fillStyle = computedBarColor\n ctx.globalAlpha = 0.3 + value * 0.7\n\n if (barRadius > 0) {\n ctx.beginPath()\n ctx.roundRect(x, y, barWidth, barHeight, barRadius)\n ctx.fill()\n } else {\n ctx.fillRect(x, y, barWidth, barHeight)\n }\n }\n\n if (!recording && isRecordingComplete && showHandle) {\n const indicatorX = rect.width * viewPosition\n\n ctx.strokeStyle = computedBarColor\n ctx.globalAlpha = 0.5\n ctx.lineWidth = 2\n ctx.beginPath()\n ctx.moveTo(indicatorX, 0)\n ctx.lineTo(indicatorX, rect.height)\n ctx.stroke()\n ctx.fillStyle = computedBarColor\n ctx.globalAlpha = 1\n ctx.beginPath()\n ctx.arc(indicatorX, centerY, 6, 0, Math.PI * 2)\n ctx.fill()\n }\n }\n\n ctx.globalAlpha = 1\n\n animationRef.current = requestAnimationFrame(animate)\n }\n\n animationRef.current = requestAnimationFrame(animate)\n\n return () => {\n if (animationRef.current) {\n cancelAnimationFrame(animationRef.current)\n }\n }\n }, [\n recording,\n recordedData,\n viewPosition,\n isRecordingComplete,\n sensitivity,\n updateRate,\n showHandle,\n barWidth,\n baseBarHeight,\n barGap,\n barRadius,\n barColor,\n ])\n\n const handleScrub = useCallback(\n (clientX: number) => {\n const container = containerRef.current\n if (!container || recording || !isRecordingComplete) return\n\n const rect = container.getBoundingClientRect()\n const x = Math.max(0, Math.min(clientX - rect.left, rect.width))\n const position = x / rect.width\n\n setViewPosition(position)\n },\n [recording, isRecordingComplete]\n )\n\n const handleMouseDown = (e: React.MouseEvent) => {\n if (recording || !isRecordingComplete) return\n\n e.preventDefault()\n setIsDragging(true)\n handleScrub(e.clientX)\n }\n\n useEffect(() => {\n if (!isDragging) return\n\n const handleMouseMove = (e: MouseEvent) => {\n handleScrub(e.clientX)\n }\n\n const handleMouseUp = () => {\n setIsDragging(false)\n }\n\n document.addEventListener(\"mousemove\", handleMouseMove)\n document.addEventListener(\"mouseup\", handleMouseUp)\n\n return () => {\n document.removeEventListener(\"mousemove\", handleMouseMove)\n document.removeEventListener(\"mouseup\", handleMouseUp)\n }\n }, [isDragging, handleScrub])\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui" } ], "type": "registry:ui" }