@inferencesh/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +68 -2
  3. package/components.json +22 -0
  4. package/package.json +231 -4
  5. package/src/components/audio-player.tsx +549 -0
  6. package/src/components/button.tsx +13 -0
  7. package/src/components/chart.tsx +403 -0
  8. package/src/components/circuit.tsx +276 -0
  9. package/src/components/code-block/code-block.tsx +260 -0
  10. package/src/components/code-block/index.ts +18 -0
  11. package/src/components/code-block/languages.ts +311 -0
  12. package/src/components/code-block/styles.ts +39 -0
  13. package/src/components/code-block/tokenizer.ts +290 -0
  14. package/src/components/code-block/types.ts +38 -0
  15. package/src/components/code-block/utils.ts +39 -0
  16. package/src/components/code.tsx +48 -0
  17. package/src/components/cookie-consent.tsx +137 -0
  18. package/src/components/copy-button.tsx +44 -0
  19. package/src/components/country-dropdown.tsx +188 -0
  20. package/src/components/glow-loader-fast.tsx +32 -0
  21. package/src/components/glow-loader-mono.tsx +33 -0
  22. package/src/components/glow-loader-sm-fast.tsx +32 -0
  23. package/src/components/glow-loader-sm-mono.tsx +34 -0
  24. package/src/components/glow-loader-sm.tsx +32 -0
  25. package/src/components/glow-loader.tsx +32 -0
  26. package/src/components/glow-logo.tsx +32 -0
  27. package/src/components/gradient.tsx +80 -0
  28. package/src/components/index.ts +45 -0
  29. package/src/components/inline-sidebar.tsx +351 -0
  30. package/src/components/input-color.tsx +349 -0
  31. package/src/components/logo-base.tsx +213 -0
  32. package/src/components/logo-color.tsx +23 -0
  33. package/src/components/logo-full.tsx +71 -0
  34. package/src/components/logo-svg.tsx +23 -0
  35. package/src/components/menu-navigation.tsx +359 -0
  36. package/src/components/multi-select.tsx +721 -0
  37. package/src/components/pastel-gradient.tsx +25 -0
  38. package/src/components/pixel-symmetry.tsx +151 -0
  39. package/src/components/progress.tsx +28 -0
  40. package/src/components/scroll-more.tsx +131 -0
  41. package/src/components/select.tsx +85 -0
  42. package/src/components/shimmering-text.tsx +108 -0
  43. package/src/components/sidebar-light.tsx +102 -0
  44. package/src/components/sonner.tsx +38 -0
  45. package/src/components/theme-switcher-sidebar.tsx +56 -0
  46. package/src/components/theme-switcher-simple.tsx +110 -0
  47. package/src/components/theme-switcher.tsx +47 -0
  48. package/src/components/toggle-group.tsx +55 -0
  49. package/src/components/tooltip-button.tsx +91 -0
  50. package/src/components/tooltip.tsx +71 -0
  51. package/src/components/tree-view.tsx +499 -0
  52. package/src/components/unglow-loader.tsx +33 -0
  53. package/src/components/waveform.tsx +1632 -0
  54. package/src/components/zoomable-image.tsx +76 -0
  55. package/src/docs/api-docs.tsx +763 -0
  56. package/src/docs/callout.tsx +160 -0
  57. package/src/docs/code-group.tsx +215 -0
  58. package/src/docs/copy-menu.tsx +143 -0
  59. package/src/docs/doc-tabs.tsx +84 -0
  60. package/src/docs/index.ts +16 -0
  61. package/src/docs/steps.tsx +105 -0
  62. package/src/docs/table-of-contents.tsx +174 -0
  63. package/src/examples/guide-markdown.ts +129 -0
  64. package/src/examples/index.ts +1153 -0
  65. package/src/global.d.ts +1 -0
  66. package/src/hooks/use-copy-to-clipboard.ts +36 -0
  67. package/src/hooks/use-mobile.ts +19 -0
  68. package/src/lib/display-name.ts +15 -0
  69. package/src/lib/example-input.ts +130 -0
  70. package/src/lib/plans.ts +44 -0
  71. package/src/lib/safe-content.ts +137 -0
  72. package/src/lib/schema.ts +320 -0
  73. package/src/lib/storage.ts +108 -0
  74. package/src/markdown/docs-markdown-renderer.tsx +518 -0
  75. package/src/markdown/helpers.ts +95 -0
  76. package/src/markdown/index.ts +24 -0
  77. package/src/markdown/markdown-renderer.tsx +232 -0
  78. package/src/markdown/youtube-embed.tsx +43 -0
  79. package/src/og/image.ts +312 -0
  80. package/src/og/index.tsx +430 -0
  81. package/src/og/query.ts +26 -0
  82. package/src/shadcn/accordion.tsx +71 -0
  83. package/src/shadcn/alert-dialog.tsx +187 -0
  84. package/src/shadcn/alert.tsx +75 -0
  85. package/src/shadcn/aspect-ratio.tsx +22 -0
  86. package/src/shadcn/attachment.tsx +207 -0
  87. package/src/shadcn/avatar.tsx +106 -0
  88. package/src/shadcn/badge.tsx +51 -0
  89. package/src/shadcn/breadcrumb.tsx +124 -0
  90. package/src/shadcn/bubble.tsx +127 -0
  91. package/src/shadcn/button-group.tsx +87 -0
  92. package/src/shadcn/button.tsx +57 -0
  93. package/src/shadcn/calendar.tsx +219 -0
  94. package/src/shadcn/card.tsx +102 -0
  95. package/src/shadcn/carousel.tsx +242 -0
  96. package/src/shadcn/chart.tsx +372 -0
  97. package/src/shadcn/checkbox.tsx +28 -0
  98. package/src/shadcn/collapsible.tsx +19 -0
  99. package/src/shadcn/combobox.tsx +297 -0
  100. package/src/shadcn/command.tsx +194 -0
  101. package/src/shadcn/context-menu.tsx +270 -0
  102. package/src/shadcn/dialog.tsx +158 -0
  103. package/src/shadcn/direction.tsx +4 -0
  104. package/src/shadcn/drawer.tsx +227 -0
  105. package/src/shadcn/dropdown-menu.tsx +265 -0
  106. package/src/shadcn/empty.tsx +103 -0
  107. package/src/shadcn/field.tsx +238 -0
  108. package/src/shadcn/form.tsx +173 -0
  109. package/src/shadcn/hover-card.tsx +50 -0
  110. package/src/shadcn/index.ts +53 -0
  111. package/src/shadcn/input-group.tsx +156 -0
  112. package/src/shadcn/input-otp.tsx +84 -0
  113. package/src/shadcn/input.tsx +19 -0
  114. package/src/shadcn/item.tsx +201 -0
  115. package/src/shadcn/kbd.tsx +26 -0
  116. package/src/shadcn/label.tsx +19 -0
  117. package/src/shadcn/marker.tsx +70 -0
  118. package/src/shadcn/menubar.tsx +278 -0
  119. package/src/shadcn/message-scroller.tsx +131 -0
  120. package/src/shadcn/message.tsx +91 -0
  121. package/src/shadcn/native-select.tsx +60 -0
  122. package/src/shadcn/navigation-menu.tsx +167 -0
  123. package/src/shadcn/pagination.tsx +130 -0
  124. package/src/shadcn/popover.tsx +87 -0
  125. package/src/shadcn/progress.tsx +82 -0
  126. package/src/shadcn/radio-group.tsx +35 -0
  127. package/src/shadcn/resizable.tsx +49 -0
  128. package/src/shadcn/scroll-area.tsx +52 -0
  129. package/src/shadcn/select.tsx +200 -0
  130. package/src/shadcn/separator.tsx +22 -0
  131. package/src/shadcn/sheet.tsx +136 -0
  132. package/src/shadcn/sidebar.tsx +721 -0
  133. package/src/shadcn/skeleton.tsx +13 -0
  134. package/src/shadcn/slider.tsx +51 -0
  135. package/src/shadcn/sonner.tsx +49 -0
  136. package/src/shadcn/spinner.tsx +10 -0
  137. package/src/shadcn/switch.tsx +29 -0
  138. package/src/shadcn/table.tsx +115 -0
  139. package/src/shadcn/tabs.tsx +79 -0
  140. package/src/shadcn/textarea.tsx +17 -0
  141. package/src/shadcn/toggle-group.tsx +89 -0
  142. package/src/shadcn/toggle.tsx +44 -0
  143. package/src/shadcn/tooltip.tsx +63 -0
  144. package/src/theme/config.ts +48 -0
  145. package/src/theme/css/base.css +92 -0
  146. package/src/theme/css/bitboy.css +75 -0
  147. package/src/theme/css/dark.css +47 -0
  148. package/src/theme/css/designer.css +122 -0
  149. package/src/theme/css/fonts.css +33 -0
  150. package/src/theme/css/globals.css +14 -0
  151. package/src/theme/css/hackintosh.css +57 -0
  152. package/src/theme/css/llama.css +103 -0
  153. package/src/theme/css/safelist.css +39 -0
  154. package/src/theme/css/shadcn.css +77 -0
  155. package/src/theme/css/theme.css +216 -0
  156. package/src/theme/embed-theme.ts +146 -0
  157. package/src/theme/fonts/FiraCode-VF.woff2 +0 -0
  158. package/src/theme/fonts/Hack-Regular.woff2 +0 -0
  159. package/src/theme/fonts/LICENSE-FiraCode.txt +93 -0
  160. package/src/theme/fonts/LICENSE-Hack.txt +45 -0
  161. package/src/theme/fonts/LICENSE-MonaSans.txt +93 -0
  162. package/src/theme/fonts/Mona-Sans.woff2 +0 -0
  163. package/src/theme/index.ts +15 -0
  164. package/src/utils/cn.ts +3 -0
  165. package/src/utils/index.ts +2 -0
@@ -0,0 +1,1632 @@
1
+ "use client"
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type HTMLAttributes
10
+ } from "react"
11
+
12
+ import { cn } from "cn"
13
+
14
+ export type WaveformProps = HTMLAttributes<HTMLDivElement> & {
15
+ data?: number[]
16
+ barWidth?: number
17
+ barGap?: number
18
+ barRadius?: number
19
+ barColor?: string
20
+ fadeEdges?: boolean
21
+ fadeWidth?: number
22
+ height?: string | number
23
+ active?: boolean
24
+ onBarClick?: (index: number, value: number) => void
25
+ }
26
+
27
+ export const Waveform = ({
28
+ data = [],
29
+ barWidth = 4,
30
+ barGap = 2,
31
+ barRadius = 2,
32
+ barColor,
33
+ fadeEdges = true,
34
+ fadeWidth = 24,
35
+ height = 128,
36
+ onBarClick,
37
+ className,
38
+ ...props
39
+ }: WaveformProps) => {
40
+ const canvasRef = useRef<HTMLCanvasElement>(null)
41
+ const containerRef = useRef<HTMLDivElement>(null)
42
+ const heightStyle = typeof height === "number" ? `${height}px` : height
43
+
44
+ useEffect(() => {
45
+ const canvas = canvasRef.current
46
+ const container = containerRef.current
47
+ if (!canvas || !container) return
48
+
49
+ const resizeObserver = new ResizeObserver(() => {
50
+ const rect = container.getBoundingClientRect()
51
+ const dpr = window.devicePixelRatio || 1
52
+
53
+ canvas.width = rect.width * dpr
54
+ canvas.height = rect.height * dpr
55
+ canvas.style.width = `${rect.width}px`
56
+ canvas.style.height = `${rect.height}px`
57
+
58
+ const ctx = canvas.getContext("2d")
59
+ if (ctx) {
60
+ ctx.scale(dpr, dpr)
61
+ renderWaveform()
62
+ }
63
+ })
64
+
65
+ const renderWaveform = () => {
66
+ const ctx = canvas.getContext("2d")
67
+ if (!ctx) return
68
+
69
+ const rect = canvas.getBoundingClientRect()
70
+ ctx.clearRect(0, 0, rect.width, rect.height)
71
+
72
+ const computedBarColor =
73
+ barColor ||
74
+ getComputedStyle(canvas).getPropertyValue("--color-foreground") ||
75
+ "#000"
76
+
77
+ const barCount = Math.floor(rect.width / (barWidth + barGap))
78
+ const centerY = rect.height / 2
79
+
80
+ for (let i = 0; i < barCount; i++) {
81
+ const dataIndex = Math.floor((i / barCount) * data.length)
82
+ const value = data[dataIndex] || 0
83
+ const barHeight = Math.max(4, value * canvas.height)
84
+ const x = i * (barWidth + barGap)
85
+ const y = centerY - barHeight / 2
86
+
87
+ ctx.fillStyle = computedBarColor
88
+ ctx.globalAlpha = 0.3 + value * 0.7
89
+
90
+ if (barRadius > 0) {
91
+ ctx.beginPath()
92
+ ctx.roundRect(x, y, barWidth, barHeight, barRadius)
93
+ ctx.fill()
94
+ } else {
95
+ ctx.fillRect(x, y, barWidth, barHeight)
96
+ }
97
+ }
98
+
99
+ if (fadeEdges && fadeWidth > 0 && rect.width > 0) {
100
+ const gradient = ctx.createLinearGradient(0, 0, rect.width, 0)
101
+ const fadePercent = Math.min(0.2, fadeWidth / rect.width)
102
+
103
+ gradient.addColorStop(0, "rgba(255,255,255,1)")
104
+ gradient.addColorStop(fadePercent, "rgba(255,255,255,0)")
105
+ gradient.addColorStop(1 - fadePercent, "rgba(255,255,255,0)")
106
+ gradient.addColorStop(1, "rgba(255,255,255,1)")
107
+
108
+ ctx.globalCompositeOperation = "destination-out"
109
+ ctx.fillStyle = gradient
110
+ ctx.fillRect(0, 0, rect.width, rect.height)
111
+ ctx.globalCompositeOperation = "source-over"
112
+ }
113
+
114
+ ctx.globalAlpha = 1
115
+ }
116
+
117
+ resizeObserver.observe(container)
118
+ renderWaveform()
119
+
120
+ return () => resizeObserver.disconnect()
121
+ }, [data, barWidth, barGap, barRadius, barColor, fadeEdges, fadeWidth])
122
+
123
+ const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
124
+ if (!onBarClick) return
125
+
126
+ const rect = canvasRef.current?.getBoundingClientRect()
127
+ if (!rect) return
128
+
129
+ const x = e.clientX - rect.left
130
+ const barIndex = Math.floor(x / (barWidth + barGap))
131
+ const dataIndex = Math.floor(
132
+ (barIndex * data.length) / Math.floor(rect.width / (barWidth + barGap))
133
+ )
134
+
135
+ if (dataIndex >= 0 && dataIndex < data.length) {
136
+ onBarClick(dataIndex, data[dataIndex])
137
+ }
138
+ }
139
+
140
+ return (
141
+ <div
142
+ className={cn("relative", className)}
143
+ ref={containerRef}
144
+ style={{ height: heightStyle }}
145
+ {...props}
146
+ >
147
+ <canvas
148
+ className="block h-full w-full"
149
+ onClick={handleClick}
150
+ ref={canvasRef}
151
+ />
152
+ </div>
153
+ )
154
+ }
155
+
156
+ export type ScrollingWaveformProps = Omit<
157
+ WaveformProps,
158
+ "data" | "onBarClick"
159
+ > & {
160
+ speed?: number
161
+ barCount?: number
162
+ data?: number[]
163
+ }
164
+
165
+ export const ScrollingWaveform = ({
166
+ speed = 50,
167
+ barCount = 60,
168
+ barWidth = 4,
169
+ barGap = 2,
170
+ barRadius = 2,
171
+ barColor,
172
+ fadeEdges = true,
173
+ fadeWidth = 24,
174
+ height = 128,
175
+ data,
176
+ className,
177
+ ...props
178
+ }: ScrollingWaveformProps) => {
179
+ const canvasRef = useRef<HTMLCanvasElement>(null)
180
+ const containerRef = useRef<HTMLDivElement>(null)
181
+ const barsRef = useRef<Array<{ x: number; height: number }>>([])
182
+ const animationRef = useRef<number>(0)
183
+ const lastTimeRef = useRef<number>(0)
184
+ const seedRef = useRef(Math.random())
185
+ const dataIndexRef = useRef(0)
186
+ const heightStyle = typeof height === "number" ? `${height}px` : height
187
+
188
+ useEffect(() => {
189
+ const canvas = canvasRef.current
190
+ const container = containerRef.current
191
+ if (!canvas || !container) return
192
+
193
+ const resizeObserver = new ResizeObserver(() => {
194
+ const rect = container.getBoundingClientRect()
195
+ const dpr = window.devicePixelRatio || 1
196
+
197
+ canvas.width = rect.width * dpr
198
+ canvas.height = rect.height * dpr
199
+ canvas.style.width = `${rect.width}px`
200
+ canvas.style.height = `${rect.height}px`
201
+
202
+ const ctx = canvas.getContext("2d")
203
+ if (ctx) {
204
+ ctx.scale(dpr, dpr)
205
+ }
206
+
207
+ if (barsRef.current.length === 0) {
208
+ const step = barWidth + barGap
209
+ let currentX = rect.width
210
+ let index = 0
211
+ const seededRandom = (i: number) => {
212
+ const x = Math.sin(seedRef.current * 10000 + i) * 10000
213
+ return x - Math.floor(x)
214
+ }
215
+ while (currentX > -step) {
216
+ barsRef.current.push({
217
+ x: currentX,
218
+ height: 0.2 + seededRandom(index++) * 0.6,
219
+ })
220
+ currentX -= step
221
+ }
222
+ }
223
+ })
224
+
225
+ resizeObserver.observe(container)
226
+ return () => resizeObserver.disconnect()
227
+ }, [barWidth, barGap])
228
+
229
+ useEffect(() => {
230
+ const canvas = canvasRef.current
231
+ if (!canvas) return
232
+
233
+ const ctx = canvas.getContext("2d")
234
+ if (!ctx) return
235
+
236
+ const animate = (currentTime: number) => {
237
+ const deltaTime = lastTimeRef.current
238
+ ? (currentTime - lastTimeRef.current) / 1000
239
+ : 0
240
+ lastTimeRef.current = currentTime
241
+
242
+ const rect = canvas.getBoundingClientRect()
243
+ ctx.clearRect(0, 0, rect.width, rect.height)
244
+
245
+ const computedBarColor =
246
+ barColor ||
247
+ getComputedStyle(canvas).getPropertyValue("--foreground") ||
248
+ "#000"
249
+
250
+ const step = barWidth + barGap
251
+ for (let i = 0; i < barsRef.current.length; i++) {
252
+ barsRef.current[i].x -= speed * deltaTime
253
+ }
254
+
255
+ barsRef.current = barsRef.current.filter(
256
+ (bar) => bar.x + barWidth > -step
257
+ )
258
+
259
+ while (
260
+ barsRef.current.length === 0 ||
261
+ barsRef.current[barsRef.current.length - 1].x < rect.width
262
+ ) {
263
+ const lastBar = barsRef.current[barsRef.current.length - 1]
264
+ const nextX = lastBar ? lastBar.x + step : rect.width
265
+
266
+ let newHeight: number
267
+ if (data && data.length > 0) {
268
+ newHeight = data[dataIndexRef.current % data.length] || 0.1
269
+ dataIndexRef.current = (dataIndexRef.current + 1) % data.length
270
+ } else {
271
+ const time = Date.now() / 1000
272
+ const uniqueIndex = barsRef.current.length + time * 0.01
273
+ const seededRandom = (index: number) => {
274
+ const x = Math.sin(seedRef.current * 10000 + index * 137.5) * 10000
275
+ return x - Math.floor(x)
276
+ }
277
+ const wave1 = Math.sin(uniqueIndex * 0.1) * 0.2
278
+ const wave2 = Math.cos(uniqueIndex * 0.05) * 0.15
279
+ const randomComponent = seededRandom(uniqueIndex) * 0.4
280
+ newHeight = Math.max(
281
+ 0.1,
282
+ Math.min(0.9, 0.3 + wave1 + wave2 + randomComponent)
283
+ )
284
+ }
285
+
286
+ barsRef.current.push({
287
+ x: nextX,
288
+ height: newHeight,
289
+ })
290
+ if (barsRef.current.length > barCount * 2) break
291
+ }
292
+
293
+ const centerY = rect.height / 2
294
+ for (const bar of barsRef.current) {
295
+ if (bar.x < rect.width && bar.x + barWidth > 0) {
296
+ const barHeight = Math.max(4, bar.height * canvas.height * 0.6)
297
+ const y = centerY - barHeight / 2
298
+
299
+ ctx.fillStyle = computedBarColor
300
+ ctx.globalAlpha = 0.3 + bar.height * 0.7
301
+
302
+ if (barRadius > 0) {
303
+ ctx.beginPath()
304
+ ctx.roundRect(bar.x, y, barWidth, barHeight, barRadius)
305
+ ctx.fill()
306
+ } else {
307
+ ctx.fillRect(bar.x, y, barWidth, barHeight)
308
+ }
309
+ }
310
+ }
311
+
312
+ if (fadeEdges && fadeWidth > 0) {
313
+ const gradient = ctx.createLinearGradient(0, 0, rect.width, 0)
314
+ const fadePercent = Math.min(0.2, fadeWidth / rect.width)
315
+
316
+ gradient.addColorStop(0, "rgba(255,255,255,1)")
317
+ gradient.addColorStop(fadePercent, "rgba(255,255,255,0)")
318
+ gradient.addColorStop(1 - fadePercent, "rgba(255,255,255,0)")
319
+ gradient.addColorStop(1, "rgba(255,255,255,1)")
320
+
321
+ ctx.globalCompositeOperation = "destination-out"
322
+ ctx.fillStyle = gradient
323
+ ctx.fillRect(0, 0, rect.width, rect.height)
324
+ ctx.globalCompositeOperation = "source-over"
325
+ }
326
+
327
+ ctx.globalAlpha = 1
328
+
329
+ animationRef.current = requestAnimationFrame(animate)
330
+ }
331
+
332
+ animationRef.current = requestAnimationFrame(animate)
333
+
334
+ return () => {
335
+ if (animationRef.current) {
336
+ cancelAnimationFrame(animationRef.current)
337
+ }
338
+ }
339
+ }, [
340
+ speed,
341
+ barCount,
342
+ barWidth,
343
+ barGap,
344
+ barRadius,
345
+ barColor,
346
+ fadeEdges,
347
+ fadeWidth,
348
+ data,
349
+ ])
350
+
351
+ return (
352
+ <div
353
+ className={cn("relative flex items-center", className)}
354
+ ref={containerRef}
355
+ style={{ height: heightStyle }}
356
+ {...props}
357
+ >
358
+ <canvas className="block h-full w-full" ref={canvasRef} />
359
+ </div>
360
+ )
361
+ }
362
+
363
+ export type AudioScrubberProps = WaveformProps & {
364
+ currentTime?: number
365
+ duration?: number
366
+ onSeek?: (time: number) => void
367
+ showHandle?: boolean
368
+ }
369
+
370
+ export const AudioScrubber = ({
371
+ data = [],
372
+ currentTime = 0,
373
+ duration = 100,
374
+ onSeek,
375
+ showHandle = true,
376
+ barWidth = 3,
377
+ barGap = 1,
378
+ barRadius = 1,
379
+ barColor,
380
+ height = 128,
381
+ className,
382
+ ...props
383
+ }: AudioScrubberProps) => {
384
+ const [isDragging, setIsDragging] = useState(false)
385
+ const [localProgress, setLocalProgress] = useState(0)
386
+ const containerRef = useRef<HTMLDivElement>(null)
387
+
388
+ const waveformData =
389
+ data.length > 0
390
+ ? data
391
+ : Array.from({ length: 100 }, () => 0.2 + Math.random() * 0.6)
392
+
393
+ useEffect(() => {
394
+ if (!isDragging && duration > 0) {
395
+ setLocalProgress(currentTime / duration)
396
+ }
397
+ }, [currentTime, duration, isDragging])
398
+
399
+ const handleScrub = useCallback(
400
+ (clientX: number) => {
401
+ const container = containerRef.current
402
+ if (!container) return
403
+
404
+ const rect = container.getBoundingClientRect()
405
+ const x = Math.max(0, Math.min(clientX - rect.left, rect.width))
406
+ const progress = x / rect.width
407
+ const newTime = progress * duration
408
+
409
+ setLocalProgress(progress)
410
+ onSeek?.(newTime)
411
+ },
412
+ [duration, onSeek]
413
+ )
414
+
415
+ const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
416
+ e.preventDefault()
417
+ setIsDragging(true)
418
+ handleScrub(e.clientX)
419
+ }
420
+
421
+ useEffect(() => {
422
+ if (!isDragging) return
423
+
424
+ const handleMouseMove = (e: MouseEvent) => {
425
+ handleScrub(e.clientX)
426
+ }
427
+
428
+ const handleMouseUp = () => {
429
+ setIsDragging(false)
430
+ }
431
+
432
+ document.addEventListener("mousemove", handleMouseMove)
433
+ document.addEventListener("mouseup", handleMouseUp)
434
+
435
+ return () => {
436
+ document.removeEventListener("mousemove", handleMouseMove)
437
+ document.removeEventListener("mouseup", handleMouseUp)
438
+ }
439
+ }, [isDragging, duration, handleScrub])
440
+
441
+ const heightStyle = typeof height === "number" ? `${height}px` : height
442
+
443
+ return (
444
+ <div
445
+ aria-label="Audio waveform scrubber"
446
+ aria-valuemax={duration}
447
+ aria-valuemin={0}
448
+ aria-valuenow={currentTime}
449
+ className={cn("relative cursor-pointer select-none", className)}
450
+ onMouseDown={handleMouseDown}
451
+ ref={containerRef}
452
+ role="slider"
453
+ style={{ height: heightStyle }}
454
+ tabIndex={0}
455
+ {...props}
456
+ >
457
+ <Waveform
458
+ barColor={barColor}
459
+ barGap={barGap}
460
+ barRadius={barRadius}
461
+ barWidth={barWidth}
462
+ data={waveformData}
463
+ fadeEdges={false}
464
+ />
465
+
466
+ <div
467
+ className="bg-primary/20 pointer-events-none absolute inset-y-0 left-0"
468
+ style={{ width: `${localProgress * 100}%` }}
469
+ />
470
+
471
+ <div
472
+ className="bg-primary pointer-events-none absolute top-0 bottom-0 w-0.5"
473
+ style={{ left: `${localProgress * 100}%` }}
474
+ />
475
+
476
+ {showHandle && (
477
+ <div
478
+ className="border-background bg-primary pointer-events-none absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow-lg transition-transform hover:scale-110"
479
+ style={{ left: `${localProgress * 100}%` }}
480
+ />
481
+ )}
482
+ </div>
483
+ )
484
+ }
485
+
486
+ export type MicrophoneWaveformProps = WaveformProps & {
487
+ active?: boolean
488
+ processing?: boolean
489
+ fftSize?: number
490
+ smoothingTimeConstant?: number
491
+ sensitivity?: number
492
+ onError?: (error: Error) => void
493
+ }
494
+
495
+ export const MicrophoneWaveform = ({
496
+ active = false,
497
+ processing = false,
498
+ fftSize = 256,
499
+ smoothingTimeConstant = 0.8,
500
+ sensitivity = 1,
501
+ onError,
502
+ ...props
503
+ }: MicrophoneWaveformProps) => {
504
+ const [data, setData] = useState<number[]>([])
505
+ const analyserRef = useRef<AnalyserNode | null>(null)
506
+ const audioContextRef = useRef<AudioContext | null>(null)
507
+ const streamRef = useRef<MediaStream | null>(null)
508
+ const animationIdRef = useRef<number | null>(null)
509
+ const processingAnimationRef = useRef<number | null>(null)
510
+ const lastActiveDataRef = useRef<number[]>([])
511
+ const transitionProgressRef = useRef(0)
512
+
513
+ useEffect(() => {
514
+ if (processing && !active) {
515
+ let time = 0
516
+ transitionProgressRef.current = 0
517
+
518
+ const animateProcessing = () => {
519
+ time += 0.03
520
+ transitionProgressRef.current = Math.min(
521
+ 1,
522
+ transitionProgressRef.current + 0.02
523
+ )
524
+
525
+ const processingData = []
526
+ const barCount = 45
527
+
528
+ for (let i = 0; i < barCount; i++) {
529
+ const normalizedPosition = (i - barCount / 2) / (barCount / 2)
530
+ const centerWeight = 1 - Math.abs(normalizedPosition) * 0.4
531
+
532
+ const wave1 = Math.sin(time * 1.5 + i * 0.15) * 0.25
533
+ const wave2 = Math.sin(time * 0.8 - i * 0.1) * 0.2
534
+ const wave3 = Math.cos(time * 2 + i * 0.05) * 0.15
535
+ const combinedWave = wave1 + wave2 + wave3
536
+ const processingValue = (0.2 + combinedWave) * centerWeight
537
+
538
+ let finalValue = processingValue
539
+ if (
540
+ lastActiveDataRef.current.length > 0 &&
541
+ transitionProgressRef.current < 1
542
+ ) {
543
+ const lastDataIndex = Math.floor(
544
+ (i / barCount) * lastActiveDataRef.current.length
545
+ )
546
+ const lastValue = lastActiveDataRef.current[lastDataIndex] || 0
547
+ finalValue =
548
+ lastValue * (1 - transitionProgressRef.current) +
549
+ processingValue * transitionProgressRef.current
550
+ }
551
+
552
+ processingData.push(Math.max(0.05, Math.min(1, finalValue)))
553
+ }
554
+
555
+ setData(processingData)
556
+ processingAnimationRef.current =
557
+ requestAnimationFrame(animateProcessing)
558
+ }
559
+
560
+ animateProcessing()
561
+
562
+ return () => {
563
+ if (processingAnimationRef.current) {
564
+ cancelAnimationFrame(processingAnimationRef.current)
565
+ }
566
+ }
567
+ } else if (!active && !processing) {
568
+ if (data.length > 0) {
569
+ let fadeProgress = 0
570
+ const fadeToIdle = () => {
571
+ fadeProgress += 0.03
572
+ if (fadeProgress < 1) {
573
+ const fadedData = data.map((value) => value * (1 - fadeProgress))
574
+ setData(fadedData)
575
+ requestAnimationFrame(fadeToIdle)
576
+ } else {
577
+ setData([])
578
+ }
579
+ }
580
+ fadeToIdle()
581
+ }
582
+ return
583
+ }
584
+ }, [processing, active])
585
+
586
+ useEffect(() => {
587
+ if (!active) {
588
+ if (streamRef.current) {
589
+ streamRef.current.getTracks().forEach((track) => track.stop())
590
+ }
591
+ if (
592
+ audioContextRef.current &&
593
+ audioContextRef.current.state !== "closed"
594
+ ) {
595
+ audioContextRef.current.close()
596
+ }
597
+ if (animationIdRef.current) {
598
+ cancelAnimationFrame(animationIdRef.current)
599
+ }
600
+ return
601
+ }
602
+
603
+ const setupMicrophone = async () => {
604
+ try {
605
+ const stream = await navigator.mediaDevices.getUserMedia({
606
+ audio: true,
607
+ })
608
+ streamRef.current = stream
609
+
610
+ const audioContext = new (window.AudioContext ||
611
+ (window as unknown as { webkitAudioContext: typeof AudioContext })
612
+ .webkitAudioContext)()
613
+ const analyser = audioContext.createAnalyser()
614
+ analyser.fftSize = fftSize
615
+ analyser.smoothingTimeConstant = smoothingTimeConstant
616
+
617
+ const source = audioContext.createMediaStreamSource(stream)
618
+ source.connect(analyser)
619
+
620
+ audioContextRef.current = audioContext
621
+ analyserRef.current = analyser
622
+
623
+ const dataArray = new Uint8Array(analyser.frequencyBinCount)
624
+
625
+ const updateData = () => {
626
+ if (!analyserRef.current || !active) return
627
+
628
+ analyserRef.current.getByteFrequencyData(dataArray)
629
+
630
+ const startFreq = Math.floor(dataArray.length * 0.05)
631
+ const endFreq = Math.floor(dataArray.length * 0.4)
632
+ const relevantData = dataArray.slice(startFreq, endFreq)
633
+
634
+ const halfLength = Math.floor(relevantData.length / 2)
635
+ const normalizedData = []
636
+
637
+ for (let i = halfLength - 1; i >= 0; i--) {
638
+ const value = Math.min(1, (relevantData[i] / 255) * sensitivity)
639
+ normalizedData.push(value)
640
+ }
641
+
642
+ for (let i = 0; i < halfLength; i++) {
643
+ const value = Math.min(1, (relevantData[i] / 255) * sensitivity)
644
+ normalizedData.push(value)
645
+ }
646
+
647
+ setData(normalizedData)
648
+ lastActiveDataRef.current = normalizedData
649
+
650
+ animationIdRef.current = requestAnimationFrame(updateData)
651
+ }
652
+
653
+ updateData()
654
+ } catch (error) {
655
+ onError?.(error as Error)
656
+ }
657
+ }
658
+
659
+ setupMicrophone()
660
+
661
+ return () => {
662
+ if (streamRef.current) {
663
+ streamRef.current.getTracks().forEach((track) => track.stop())
664
+ }
665
+ if (
666
+ audioContextRef.current &&
667
+ audioContextRef.current.state !== "closed"
668
+ ) {
669
+ audioContextRef.current.close()
670
+ }
671
+ if (animationIdRef.current) {
672
+ cancelAnimationFrame(animationIdRef.current)
673
+ }
674
+ }
675
+ }, [active, fftSize, smoothingTimeConstant, sensitivity, onError])
676
+
677
+ return <Waveform data={data} {...props} />
678
+ }
679
+
680
+ export type StaticWaveformProps = WaveformProps & {
681
+ bars?: number
682
+ seed?: number
683
+ }
684
+
685
+ export const StaticWaveform = ({
686
+ bars = 40,
687
+ seed = 42,
688
+ ...props
689
+ }: StaticWaveformProps) => {
690
+ const data = useMemo(() => {
691
+ const random = (seedValue: number) => {
692
+ const x = Math.sin(seedValue) * 10000
693
+ return x - Math.floor(x)
694
+ }
695
+
696
+ return Array.from({ length: bars }, (_, i) => 0.2 + random(seed + i) * 0.6)
697
+ }, [bars, seed])
698
+
699
+ return <Waveform data={data} {...props} />
700
+ }
701
+
702
+ export type LiveMicrophoneWaveformProps = Omit<
703
+ ScrollingWaveformProps,
704
+ "barCount"
705
+ > & {
706
+ active?: boolean
707
+ fftSize?: number
708
+ smoothingTimeConstant?: number
709
+ sensitivity?: number
710
+ onError?: (error: Error) => void
711
+ historySize?: number
712
+ updateRate?: number
713
+ savedHistoryRef?: React.MutableRefObject<number[]>
714
+ dragOffset?: number
715
+ setDragOffset?: (offset: number) => void
716
+ enableAudioPlayback?: boolean
717
+ playbackRate?: number
718
+ }
719
+
720
+ export const LiveMicrophoneWaveform = ({
721
+ active = false,
722
+ fftSize = 256,
723
+ smoothingTimeConstant = 0.8,
724
+ sensitivity = 1,
725
+ onError,
726
+ historySize = 150,
727
+ updateRate = 50,
728
+ barWidth = 3,
729
+ barGap = 1,
730
+ barRadius = 1,
731
+ barColor,
732
+ fadeEdges = true,
733
+ fadeWidth = 24,
734
+ height = 128,
735
+ className,
736
+ savedHistoryRef,
737
+ dragOffset: externalDragOffset,
738
+ setDragOffset: externalSetDragOffset,
739
+ enableAudioPlayback = true,
740
+ playbackRate = 1,
741
+ ...props
742
+ }: LiveMicrophoneWaveformProps) => {
743
+ const canvasRef = useRef<HTMLCanvasElement>(null)
744
+ const containerRef = useRef<HTMLDivElement>(null)
745
+ const internalHistoryRef = useRef<number[]>([])
746
+ const historyRef = savedHistoryRef || internalHistoryRef
747
+ const analyserRef = useRef<AnalyserNode | null>(null)
748
+ const audioContextRef = useRef<AudioContext | null>(null)
749
+ const streamRef = useRef<MediaStream | null>(null)
750
+ const animationRef = useRef<number>(0)
751
+ const lastUpdateRef = useRef<number>(0)
752
+ const [internalDragOffset, setInternalDragOffset] = useState(0)
753
+ const [isDragging, setIsDragging] = useState(false)
754
+ const [playbackPosition, setPlaybackPosition] = useState<number | null>(null)
755
+ const dragStartXRef = useRef<number>(0)
756
+ const dragStartOffsetRef = useRef<number>(0)
757
+ const playbackStartTimeRef = useRef<number>(0)
758
+
759
+ // Audio recording and playback refs
760
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null)
761
+ const audioChunksRef = useRef<Blob[]>([])
762
+ const audioBufferRef = useRef<AudioBuffer | null>(null)
763
+ const sourceNodeRef = useRef<AudioBufferSourceNode | null>(null)
764
+ const scrubSourceRef = useRef<AudioBufferSourceNode | null>(null)
765
+
766
+ // Use external drag state if provided, otherwise use internal
767
+ const dragOffset = externalDragOffset ?? internalDragOffset
768
+ const setDragOffset = externalSetDragOffset ?? setInternalDragOffset
769
+
770
+ const heightStyle = typeof height === "number" ? `${height}px` : height
771
+
772
+ useEffect(() => {
773
+ const canvas = canvasRef.current
774
+ const container = containerRef.current
775
+ if (!canvas || !container) return
776
+
777
+ const resizeObserver = new ResizeObserver(() => {
778
+ const rect = container.getBoundingClientRect()
779
+ const dpr = window.devicePixelRatio || 1
780
+
781
+ canvas.width = rect.width * dpr
782
+ canvas.height = rect.height * dpr
783
+ canvas.style.width = `${rect.width}px`
784
+ canvas.style.height = `${rect.height}px`
785
+
786
+ const ctx = canvas.getContext("2d")
787
+ if (ctx) {
788
+ ctx.scale(dpr, dpr)
789
+ }
790
+ })
791
+
792
+ resizeObserver.observe(container)
793
+ return () => resizeObserver.disconnect()
794
+ }, [])
795
+
796
+ useEffect(() => {
797
+ if (!active) {
798
+ if (
799
+ mediaRecorderRef.current &&
800
+ mediaRecorderRef.current.state !== "inactive"
801
+ ) {
802
+ mediaRecorderRef.current.stop()
803
+ }
804
+ if (streamRef.current) {
805
+ streamRef.current.getTracks().forEach((track) => track.stop())
806
+ }
807
+ // Process recorded audio when stopping
808
+ if (enableAudioPlayback && audioChunksRef.current.length > 0) {
809
+ const audioBlob = new Blob(audioChunksRef.current, {
810
+ type: "audio/webm",
811
+ })
812
+ processAudioBlob(audioBlob)
813
+ }
814
+ return
815
+ }
816
+
817
+ setDragOffset?.(0)
818
+ historyRef.current = []
819
+ audioChunksRef.current = []
820
+ audioBufferRef.current = null
821
+ setPlaybackPosition(null)
822
+
823
+ const setupMicrophone = async () => {
824
+ try {
825
+ const stream = await navigator.mediaDevices.getUserMedia({
826
+ audio: true,
827
+ })
828
+ streamRef.current = stream
829
+
830
+ const audioContext = new (window.AudioContext ||
831
+ (window as unknown as { webkitAudioContext: typeof AudioContext })
832
+ .webkitAudioContext)()
833
+ const analyser = audioContext.createAnalyser()
834
+ analyser.fftSize = fftSize
835
+ analyser.smoothingTimeConstant = smoothingTimeConstant
836
+
837
+ const source = audioContext.createMediaStreamSource(stream)
838
+ source.connect(analyser)
839
+
840
+ audioContextRef.current = audioContext
841
+ analyserRef.current = analyser
842
+
843
+ if (enableAudioPlayback) {
844
+ const mediaRecorder = new MediaRecorder(stream)
845
+ mediaRecorderRef.current = mediaRecorder
846
+
847
+ mediaRecorder.ondataavailable = (event) => {
848
+ if (event.data.size > 0) {
849
+ audioChunksRef.current.push(event.data)
850
+ }
851
+ }
852
+
853
+ mediaRecorder.start(100)
854
+ }
855
+ } catch (error) {
856
+ onError?.(error as Error)
857
+ }
858
+ }
859
+
860
+ setupMicrophone()
861
+
862
+ return () => {
863
+ if (
864
+ mediaRecorderRef.current &&
865
+ mediaRecorderRef.current.state !== "inactive"
866
+ ) {
867
+ mediaRecorderRef.current.stop()
868
+ }
869
+ if (streamRef.current) {
870
+ streamRef.current.getTracks().forEach((track) => track.stop())
871
+ }
872
+ if (sourceNodeRef.current) {
873
+ sourceNodeRef.current.stop()
874
+ }
875
+ if (scrubSourceRef.current) {
876
+ scrubSourceRef.current.stop()
877
+ }
878
+ }
879
+ }, [
880
+ active,
881
+ fftSize,
882
+ smoothingTimeConstant,
883
+ onError,
884
+ setDragOffset,
885
+ enableAudioPlayback,
886
+ historyRef,
887
+ ])
888
+
889
+ const processAudioBlob = async (blob: Blob) => {
890
+ try {
891
+ const arrayBuffer = await blob.arrayBuffer()
892
+ if (audioContextRef.current) {
893
+ const audioBuffer =
894
+ await audioContextRef.current.decodeAudioData(arrayBuffer)
895
+ audioBufferRef.current = audioBuffer
896
+ }
897
+ } catch (error) {
898
+ console.error("Error processing audio:", error)
899
+ }
900
+ }
901
+
902
+ const playScrubSound = useCallback(
903
+ (position: number, direction: number) => {
904
+ if (
905
+ !enableAudioPlayback ||
906
+ !audioBufferRef.current ||
907
+ !audioContextRef.current
908
+ )
909
+ return
910
+
911
+ if (scrubSourceRef.current) {
912
+ try {
913
+ scrubSourceRef.current.stop()
914
+ } catch {}
915
+ }
916
+
917
+ const source = audioContextRef.current.createBufferSource()
918
+ source.buffer = audioBufferRef.current
919
+
920
+ const speed = Math.abs(direction)
921
+ const playbackRate =
922
+ direction > 0
923
+ ? Math.min(3, 1 + speed * 0.1)
924
+ : Math.max(-3, -1 - speed * 0.1)
925
+
926
+ source.playbackRate.value = playbackRate
927
+
928
+ const filter = audioContextRef.current.createBiquadFilter()
929
+ filter.type = "lowpass"
930
+ filter.frequency.value = Math.max(200, 2000 - speed * 100)
931
+
932
+ source.connect(filter)
933
+ filter.connect(audioContextRef.current.destination)
934
+
935
+ const startTime = Math.max(
936
+ 0,
937
+ Math.min(position, audioBufferRef.current.duration - 0.1)
938
+ )
939
+ source.start(0, startTime, 0.1)
940
+ scrubSourceRef.current = source
941
+ },
942
+ [enableAudioPlayback]
943
+ )
944
+
945
+ const playFromPosition = useCallback(
946
+ (position: number) => {
947
+ if (
948
+ !enableAudioPlayback ||
949
+ !audioBufferRef.current ||
950
+ !audioContextRef.current
951
+ )
952
+ return
953
+
954
+ if (sourceNodeRef.current) {
955
+ try {
956
+ sourceNodeRef.current.stop()
957
+ } catch {}
958
+ }
959
+
960
+ const source = audioContextRef.current.createBufferSource()
961
+ source.buffer = audioBufferRef.current
962
+ source.playbackRate.value = playbackRate
963
+ source.connect(audioContextRef.current.destination)
964
+
965
+ const startTime = Math.max(
966
+ 0,
967
+ Math.min(position, audioBufferRef.current.duration)
968
+ )
969
+ source.start(0, startTime)
970
+ sourceNodeRef.current = source
971
+
972
+ playbackStartTimeRef.current =
973
+ audioContextRef.current.currentTime - startTime
974
+ setPlaybackPosition(startTime)
975
+
976
+ source.onended = () => {
977
+ setPlaybackPosition(null)
978
+ }
979
+ },
980
+ [enableAudioPlayback, playbackRate]
981
+ )
982
+
983
+ useEffect(() => {
984
+ if (playbackPosition === null || !audioBufferRef.current) return
985
+
986
+ let animationId: number
987
+ const updatePlaybackVisual = () => {
988
+ if (
989
+ audioContextRef.current &&
990
+ sourceNodeRef.current &&
991
+ audioBufferRef.current
992
+ ) {
993
+ const elapsed =
994
+ audioContextRef.current.currentTime - playbackStartTimeRef.current
995
+ const currentPos = playbackPosition + elapsed * playbackRate
996
+
997
+ if (currentPos < audioBufferRef.current.duration) {
998
+ const progressRatio = currentPos / audioBufferRef.current.duration
999
+ const currentBarIndex = Math.floor(
1000
+ progressRatio * historyRef.current.length
1001
+ )
1002
+ const step = barWidth + barGap
1003
+
1004
+ const containerWidth =
1005
+ containerRef.current?.getBoundingClientRect().width || 0
1006
+ const viewBars = Math.floor(containerWidth / step)
1007
+ const targetOffset =
1008
+ -(currentBarIndex - (historyRef.current.length - viewBars)) * step
1009
+ const clampedOffset = Math.max(
1010
+ -(historyRef.current.length - viewBars) * step,
1011
+ Math.min(0, targetOffset)
1012
+ )
1013
+
1014
+ setDragOffset?.(clampedOffset)
1015
+ animationId = requestAnimationFrame(updatePlaybackVisual)
1016
+ } else {
1017
+ setPlaybackPosition(null)
1018
+ const step = barWidth + barGap
1019
+ const containerWidth =
1020
+ containerRef.current?.getBoundingClientRect().width || 0
1021
+ const viewBars = Math.floor(containerWidth / step)
1022
+ setDragOffset?.(-(historyRef.current.length - viewBars) * step)
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ animationId = requestAnimationFrame(updatePlaybackVisual)
1028
+
1029
+ return () => {
1030
+ if (animationId) cancelAnimationFrame(animationId)
1031
+ }
1032
+ }, [
1033
+ playbackPosition,
1034
+ playbackRate,
1035
+ barWidth,
1036
+ barGap,
1037
+ setDragOffset,
1038
+ historyRef,
1039
+ ])
1040
+
1041
+ useEffect(() => {
1042
+ const canvas = canvasRef.current
1043
+ if (!canvas) return
1044
+ if (!active && historyRef.current.length === 0 && playbackPosition === null)
1045
+ return
1046
+
1047
+ const ctx = canvas.getContext("2d")
1048
+ if (!ctx) return
1049
+
1050
+ const animate = (currentTime: number) => {
1051
+ if (active && currentTime - lastUpdateRef.current > updateRate) {
1052
+ lastUpdateRef.current = currentTime
1053
+
1054
+ if (analyserRef.current) {
1055
+ const dataArray = new Uint8Array(
1056
+ analyserRef.current.frequencyBinCount
1057
+ )
1058
+ analyserRef.current.getByteFrequencyData(dataArray)
1059
+
1060
+ let sum = 0
1061
+ for (let i = 0; i < dataArray.length; i++) {
1062
+ sum += dataArray[i]
1063
+ }
1064
+ const average = (sum / dataArray.length / 255) * sensitivity
1065
+
1066
+ historyRef.current.push(Math.min(1, Math.max(0.05, average)))
1067
+
1068
+ if (historyRef.current.length > historySize) {
1069
+ historyRef.current.shift()
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ const rect = canvas.getBoundingClientRect()
1075
+ ctx.clearRect(0, 0, rect.width, rect.height)
1076
+
1077
+ const computedBarColor =
1078
+ barColor ||
1079
+ getComputedStyle(canvas).getPropertyValue("--foreground") ||
1080
+ "#000"
1081
+
1082
+ const step = barWidth + barGap
1083
+ const barCount = Math.floor(rect.width / step)
1084
+ const centerY = rect.height / 2
1085
+
1086
+ const dataToRender = historyRef.current
1087
+
1088
+ if (dataToRender.length > 0) {
1089
+ const offsetInBars = Math.floor(dragOffset / step)
1090
+
1091
+ for (let i = 0; i < barCount; i++) {
1092
+ let dataIndex
1093
+
1094
+ if (active) {
1095
+ dataIndex = dataToRender.length - 1 - i
1096
+ } else {
1097
+ dataIndex = Math.max(
1098
+ 0,
1099
+ Math.min(
1100
+ dataToRender.length - 1,
1101
+ dataToRender.length - 1 - i - Math.floor(offsetInBars)
1102
+ )
1103
+ )
1104
+ }
1105
+
1106
+ if (dataIndex >= 0 && dataIndex < dataToRender.length) {
1107
+ const value = dataToRender[dataIndex]
1108
+ if (value !== undefined) {
1109
+ const x = rect.width - (i + 1) * step
1110
+ const barHeight = Math.max(4, value * rect.height * 0.7)
1111
+ const y = centerY - barHeight / 2
1112
+
1113
+ ctx.fillStyle = computedBarColor
1114
+ ctx.globalAlpha = 0.3 + value * 0.7
1115
+
1116
+ if (barRadius > 0) {
1117
+ ctx.beginPath()
1118
+ ctx.roundRect(x, y, barWidth, barHeight, barRadius)
1119
+ ctx.fill()
1120
+ } else {
1121
+ ctx.fillRect(x, y, barWidth, barHeight)
1122
+ }
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ if (fadeEdges && fadeWidth > 0) {
1129
+ const gradient = ctx.createLinearGradient(0, 0, rect.width, 0)
1130
+ const fadePercent = Math.min(0.2, fadeWidth / rect.width)
1131
+
1132
+ gradient.addColorStop(0, "rgba(255,255,255,1)")
1133
+ gradient.addColorStop(fadePercent, "rgba(255,255,255,0)")
1134
+ gradient.addColorStop(1 - fadePercent, "rgba(255,255,255,0)")
1135
+ gradient.addColorStop(1, "rgba(255,255,255,1)")
1136
+
1137
+ ctx.globalCompositeOperation = "destination-out"
1138
+ ctx.fillStyle = gradient
1139
+ ctx.fillRect(0, 0, rect.width, rect.height)
1140
+ ctx.globalCompositeOperation = "source-over"
1141
+ }
1142
+
1143
+ ctx.globalAlpha = 1
1144
+
1145
+ animationRef.current = requestAnimationFrame(animate)
1146
+ }
1147
+
1148
+ if (active || historyRef.current.length > 0 || playbackPosition !== null) {
1149
+ animationRef.current = requestAnimationFrame(animate)
1150
+ }
1151
+
1152
+ return () => {
1153
+ if (animationRef.current) {
1154
+ cancelAnimationFrame(animationRef.current)
1155
+ }
1156
+ }
1157
+ }, [
1158
+ active,
1159
+ sensitivity,
1160
+ updateRate,
1161
+ historySize,
1162
+ barWidth,
1163
+ barGap,
1164
+ barRadius,
1165
+ barColor,
1166
+ fadeEdges,
1167
+ fadeWidth,
1168
+ dragOffset,
1169
+ playbackPosition,
1170
+ historyRef,
1171
+ ])
1172
+
1173
+ const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
1174
+ if (active || historyRef.current.length === 0) return
1175
+
1176
+ e.preventDefault()
1177
+ setIsDragging(true)
1178
+ dragStartXRef.current = e.clientX
1179
+ dragStartOffsetRef.current = dragOffset
1180
+ }
1181
+
1182
+ useEffect(() => {
1183
+ if (!isDragging) return
1184
+
1185
+ let lastScrubTime = 0
1186
+ let lastMouseX = dragStartXRef.current
1187
+ const handleMouseMove = (e: MouseEvent) => {
1188
+ const deltaX = e.clientX - dragStartXRef.current
1189
+ const newOffset = dragStartOffsetRef.current - deltaX * 0.5 // Reduce sensitivity
1190
+
1191
+ const step = barWidth + barGap
1192
+ const maxBars = historyRef.current.length
1193
+ const viewWidth = canvasRef.current?.getBoundingClientRect().width || 0
1194
+ const viewBars = Math.floor(viewWidth / step)
1195
+
1196
+ const maxOffset = Math.max(0, (maxBars - viewBars) * step)
1197
+ const minOffset = 0
1198
+ const clampedOffset = Math.max(minOffset, Math.min(maxOffset, newOffset))
1199
+
1200
+ setDragOffset?.(clampedOffset)
1201
+
1202
+ const now = Date.now()
1203
+ if (
1204
+ enableAudioPlayback &&
1205
+ audioBufferRef.current &&
1206
+ now - lastScrubTime > 50
1207
+ ) {
1208
+ lastScrubTime = now
1209
+ const offsetBars = Math.floor(clampedOffset / step)
1210
+ const rightmostBarIndex = Math.max(
1211
+ 0,
1212
+ Math.min(maxBars - 1, maxBars - 1 - offsetBars)
1213
+ )
1214
+ const audioPosition =
1215
+ (rightmostBarIndex / maxBars) * audioBufferRef.current.duration
1216
+ const direction = e.clientX - lastMouseX
1217
+ lastMouseX = e.clientX
1218
+ playScrubSound(
1219
+ Math.max(
1220
+ 0,
1221
+ Math.min(audioBufferRef.current.duration - 0.1, audioPosition)
1222
+ ),
1223
+ direction
1224
+ )
1225
+ }
1226
+ }
1227
+
1228
+ const handleMouseUp = () => {
1229
+ setIsDragging(false)
1230
+
1231
+ if (enableAudioPlayback && audioBufferRef.current) {
1232
+ const step = barWidth + barGap
1233
+ const maxBars = historyRef.current.length
1234
+ const offsetBars = Math.floor(dragOffset / step)
1235
+ const rightmostBarIndex = Math.max(
1236
+ 0,
1237
+ Math.min(maxBars - 1, maxBars - 1 - offsetBars)
1238
+ )
1239
+ const audioPosition =
1240
+ (rightmostBarIndex / maxBars) * audioBufferRef.current.duration
1241
+ playFromPosition(
1242
+ Math.max(
1243
+ 0,
1244
+ Math.min(audioBufferRef.current.duration - 0.1, audioPosition)
1245
+ )
1246
+ )
1247
+ }
1248
+
1249
+ if (scrubSourceRef.current) {
1250
+ try {
1251
+ scrubSourceRef.current.stop()
1252
+ } catch {}
1253
+ }
1254
+ }
1255
+
1256
+ document.addEventListener("mousemove", handleMouseMove)
1257
+ document.addEventListener("mouseup", handleMouseUp)
1258
+
1259
+ return () => {
1260
+ document.removeEventListener("mousemove", handleMouseMove)
1261
+ document.removeEventListener("mouseup", handleMouseUp)
1262
+ }
1263
+ }, [
1264
+ isDragging,
1265
+ barWidth,
1266
+ barGap,
1267
+ setDragOffset,
1268
+ dragOffset,
1269
+ enableAudioPlayback,
1270
+ playScrubSound,
1271
+ playFromPosition,
1272
+ historyRef,
1273
+ ])
1274
+
1275
+ return (
1276
+ <div
1277
+ className={cn(
1278
+ "relative flex items-center",
1279
+ !active && historyRef.current.length > 0 && "cursor-pointer",
1280
+ className
1281
+ )}
1282
+ onMouseDown={handleMouseDown}
1283
+ ref={containerRef}
1284
+ role={!active && historyRef.current.length > 0 ? "slider" : undefined}
1285
+ aria-label={
1286
+ !active && historyRef.current.length > 0
1287
+ ? "Drag to scrub through recording"
1288
+ : undefined
1289
+ }
1290
+ aria-valuenow={
1291
+ !active && historyRef.current.length > 0
1292
+ ? Math.abs(dragOffset)
1293
+ : undefined
1294
+ }
1295
+ aria-valuemin={!active && historyRef.current.length > 0 ? 0 : undefined}
1296
+ aria-valuemax={
1297
+ !active && historyRef.current.length > 0
1298
+ ? historyRef.current.length
1299
+ : undefined
1300
+ }
1301
+ tabIndex={!active && historyRef.current.length > 0 ? 0 : undefined}
1302
+ style={{ height: heightStyle }}
1303
+ {...props}
1304
+ >
1305
+ <canvas className="block h-full w-full" ref={canvasRef} />
1306
+ </div>
1307
+ )
1308
+ }
1309
+
1310
+ export type RecordingWaveformProps = Omit<
1311
+ WaveformProps,
1312
+ "data" | "onBarClick"
1313
+ > & {
1314
+ recording?: boolean
1315
+ fftSize?: number
1316
+ smoothingTimeConstant?: number
1317
+ sensitivity?: number
1318
+ onError?: (error: Error) => void
1319
+ onRecordingComplete?: (data: number[]) => void
1320
+ updateRate?: number
1321
+ showHandle?: boolean
1322
+ }
1323
+
1324
+ export const RecordingWaveform = ({
1325
+ recording = false,
1326
+ fftSize = 256,
1327
+ smoothingTimeConstant = 0.8,
1328
+ sensitivity = 1,
1329
+ onError,
1330
+ onRecordingComplete,
1331
+ updateRate = 50,
1332
+ showHandle = true,
1333
+ barWidth = 3,
1334
+ barGap = 1,
1335
+ barRadius = 1,
1336
+ barColor,
1337
+ height = 128,
1338
+ className,
1339
+ ...props
1340
+ }: RecordingWaveformProps) => {
1341
+ const [recordedData, setRecordedData] = useState<number[]>([])
1342
+ const [viewPosition, setViewPosition] = useState(1)
1343
+ const [isDragging, setIsDragging] = useState(false)
1344
+ const [isRecordingComplete, setIsRecordingComplete] = useState(false)
1345
+
1346
+ const canvasRef = useRef<HTMLCanvasElement>(null)
1347
+ const containerRef = useRef<HTMLDivElement>(null)
1348
+ const recordingDataRef = useRef<number[]>([])
1349
+ const analyserRef = useRef<AnalyserNode | null>(null)
1350
+ const audioContextRef = useRef<AudioContext | null>(null)
1351
+ const streamRef = useRef<MediaStream | null>(null)
1352
+ const animationRef = useRef<number>(0)
1353
+ const lastUpdateRef = useRef<number>(0)
1354
+
1355
+ const heightStyle = typeof height === "number" ? `${height}px` : height
1356
+
1357
+ useEffect(() => {
1358
+ const canvas = canvasRef.current
1359
+ const container = containerRef.current
1360
+ if (!canvas || !container) return
1361
+
1362
+ const resizeObserver = new ResizeObserver(() => {
1363
+ const rect = container.getBoundingClientRect()
1364
+ const dpr = window.devicePixelRatio || 1
1365
+
1366
+ canvas.width = rect.width * dpr
1367
+ canvas.height = rect.height * dpr
1368
+ canvas.style.width = `${rect.width}px`
1369
+ canvas.style.height = `${rect.height}px`
1370
+
1371
+ const ctx = canvas.getContext("2d")
1372
+ if (ctx) {
1373
+ ctx.scale(dpr, dpr)
1374
+ }
1375
+ })
1376
+
1377
+ resizeObserver.observe(container)
1378
+ return () => resizeObserver.disconnect()
1379
+ }, [])
1380
+
1381
+ useEffect(() => {
1382
+ if (!recording) {
1383
+ if (streamRef.current) {
1384
+ streamRef.current.getTracks().forEach((track) => track.stop())
1385
+ }
1386
+ if (
1387
+ audioContextRef.current &&
1388
+ audioContextRef.current.state !== "closed"
1389
+ ) {
1390
+ audioContextRef.current.close()
1391
+ }
1392
+
1393
+ if (recordingDataRef.current.length > 0) {
1394
+ setRecordedData([...recordingDataRef.current])
1395
+ setIsRecordingComplete(true)
1396
+ onRecordingComplete?.(recordingDataRef.current)
1397
+ }
1398
+ return
1399
+ }
1400
+
1401
+ setIsRecordingComplete(false)
1402
+ recordingDataRef.current = []
1403
+ setRecordedData([])
1404
+ setViewPosition(1)
1405
+
1406
+ const setupMicrophone = async () => {
1407
+ try {
1408
+ const stream = await navigator.mediaDevices.getUserMedia({
1409
+ audio: true,
1410
+ })
1411
+ streamRef.current = stream
1412
+
1413
+ const audioContext = new (window.AudioContext ||
1414
+ (window as unknown as { webkitAudioContext: typeof AudioContext })
1415
+ .webkitAudioContext)()
1416
+ const analyser = audioContext.createAnalyser()
1417
+ analyser.fftSize = fftSize
1418
+ analyser.smoothingTimeConstant = smoothingTimeConstant
1419
+
1420
+ const source = audioContext.createMediaStreamSource(stream)
1421
+ source.connect(analyser)
1422
+
1423
+ audioContextRef.current = audioContext
1424
+ analyserRef.current = analyser
1425
+ } catch (error) {
1426
+ onError?.(error as Error)
1427
+ }
1428
+ }
1429
+
1430
+ setupMicrophone()
1431
+
1432
+ return () => {
1433
+ if (streamRef.current) {
1434
+ streamRef.current.getTracks().forEach((track) => track.stop())
1435
+ }
1436
+ if (
1437
+ audioContextRef.current &&
1438
+ audioContextRef.current.state !== "closed"
1439
+ ) {
1440
+ audioContextRef.current.close()
1441
+ }
1442
+ }
1443
+ }, [recording, fftSize, smoothingTimeConstant, onError, onRecordingComplete])
1444
+
1445
+ useEffect(() => {
1446
+ const canvas = canvasRef.current
1447
+ if (!canvas) return
1448
+
1449
+ const ctx = canvas.getContext("2d")
1450
+ if (!ctx) return
1451
+
1452
+ const animate = (currentTime: number) => {
1453
+ if (recording && currentTime - lastUpdateRef.current > updateRate) {
1454
+ lastUpdateRef.current = currentTime
1455
+
1456
+ if (analyserRef.current) {
1457
+ const dataArray = new Uint8Array(
1458
+ analyserRef.current.frequencyBinCount
1459
+ )
1460
+ analyserRef.current.getByteFrequencyData(dataArray)
1461
+
1462
+ let sum = 0
1463
+ for (let i = 0; i < dataArray.length; i++) {
1464
+ sum += dataArray[i]
1465
+ }
1466
+ const average = (sum / dataArray.length / 255) * sensitivity
1467
+
1468
+ recordingDataRef.current.push(Math.min(1, Math.max(0.05, average)))
1469
+ }
1470
+ }
1471
+
1472
+ const rect = canvas.getBoundingClientRect()
1473
+ ctx.clearRect(0, 0, rect.width, rect.height)
1474
+
1475
+ const computedBarColor =
1476
+ barColor ||
1477
+ getComputedStyle(canvas).getPropertyValue("--foreground") ||
1478
+ "#000"
1479
+
1480
+ const dataToRender = recording ? recordingDataRef.current : recordedData
1481
+
1482
+ if (dataToRender.length > 0) {
1483
+ const step = barWidth + barGap
1484
+ const barsVisible = Math.floor(rect.width / step)
1485
+ const centerY = rect.height / 2
1486
+
1487
+ let startIndex = 0
1488
+ if (!recording && isRecordingComplete) {
1489
+ const totalBars = dataToRender.length
1490
+ if (totalBars > barsVisible) {
1491
+ startIndex = Math.floor((totalBars - barsVisible) * viewPosition)
1492
+ }
1493
+ } else if (recording) {
1494
+ startIndex = Math.max(0, dataToRender.length - barsVisible)
1495
+ }
1496
+
1497
+ for (
1498
+ let i = 0;
1499
+ i < barsVisible && startIndex + i < dataToRender.length;
1500
+ i++
1501
+ ) {
1502
+ const value = dataToRender[startIndex + i] || 0.1
1503
+ const x = i * step
1504
+ const barHeight = Math.max(4, value * rect.height * 0.7)
1505
+ const y = centerY - barHeight / 2
1506
+
1507
+ ctx.fillStyle = computedBarColor
1508
+ ctx.globalAlpha = 0.3 + value * 0.7
1509
+
1510
+ if (barRadius > 0) {
1511
+ ctx.beginPath()
1512
+ ctx.roundRect(x, y, barWidth, barHeight, barRadius)
1513
+ ctx.fill()
1514
+ } else {
1515
+ ctx.fillRect(x, y, barWidth, barHeight)
1516
+ }
1517
+ }
1518
+
1519
+ if (!recording && isRecordingComplete && showHandle) {
1520
+ const indicatorX = rect.width * viewPosition
1521
+
1522
+ ctx.strokeStyle = computedBarColor
1523
+ ctx.globalAlpha = 0.5
1524
+ ctx.lineWidth = 2
1525
+ ctx.beginPath()
1526
+ ctx.moveTo(indicatorX, 0)
1527
+ ctx.lineTo(indicatorX, rect.height)
1528
+ ctx.stroke()
1529
+ ctx.fillStyle = computedBarColor
1530
+ ctx.globalAlpha = 1
1531
+ ctx.beginPath()
1532
+ ctx.arc(indicatorX, centerY, 6, 0, Math.PI * 2)
1533
+ ctx.fill()
1534
+ }
1535
+ }
1536
+
1537
+ ctx.globalAlpha = 1
1538
+
1539
+ animationRef.current = requestAnimationFrame(animate)
1540
+ }
1541
+
1542
+ animationRef.current = requestAnimationFrame(animate)
1543
+
1544
+ return () => {
1545
+ if (animationRef.current) {
1546
+ cancelAnimationFrame(animationRef.current)
1547
+ }
1548
+ }
1549
+ }, [
1550
+ recording,
1551
+ recordedData,
1552
+ viewPosition,
1553
+ isRecordingComplete,
1554
+ sensitivity,
1555
+ updateRate,
1556
+ showHandle,
1557
+ barWidth,
1558
+ barGap,
1559
+ barRadius,
1560
+ barColor,
1561
+ ])
1562
+
1563
+ const handleScrub = useCallback(
1564
+ (clientX: number) => {
1565
+ const container = containerRef.current
1566
+ if (!container || recording || !isRecordingComplete) return
1567
+
1568
+ const rect = container.getBoundingClientRect()
1569
+ const x = Math.max(0, Math.min(clientX - rect.left, rect.width))
1570
+ const position = x / rect.width
1571
+
1572
+ setViewPosition(position)
1573
+ },
1574
+ [recording, isRecordingComplete]
1575
+ )
1576
+
1577
+ const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
1578
+ if (recording || !isRecordingComplete) return
1579
+
1580
+ e.preventDefault()
1581
+ setIsDragging(true)
1582
+ handleScrub(e.clientX)
1583
+ }
1584
+
1585
+ useEffect(() => {
1586
+ if (!isDragging) return
1587
+
1588
+ const handleMouseMove = (e: MouseEvent) => {
1589
+ handleScrub(e.clientX)
1590
+ }
1591
+
1592
+ const handleMouseUp = () => {
1593
+ setIsDragging(false)
1594
+ }
1595
+
1596
+ document.addEventListener("mousemove", handleMouseMove)
1597
+ document.addEventListener("mouseup", handleMouseUp)
1598
+
1599
+ return () => {
1600
+ document.removeEventListener("mousemove", handleMouseMove)
1601
+ document.removeEventListener("mouseup", handleMouseUp)
1602
+ }
1603
+ }, [isDragging, handleScrub])
1604
+
1605
+ return (
1606
+ <div
1607
+ aria-label={
1608
+ isRecordingComplete && !recording
1609
+ ? "Drag to scrub through recording"
1610
+ : undefined
1611
+ }
1612
+ aria-valuenow={
1613
+ isRecordingComplete && !recording ? viewPosition * 100 : undefined
1614
+ }
1615
+ aria-valuemin={isRecordingComplete && !recording ? 0 : undefined}
1616
+ aria-valuemax={isRecordingComplete && !recording ? 100 : undefined}
1617
+ className={cn(
1618
+ "relative flex items-center",
1619
+ isRecordingComplete && !recording && "cursor-pointer",
1620
+ className
1621
+ )}
1622
+ onMouseDown={handleMouseDown}
1623
+ ref={containerRef}
1624
+ role={isRecordingComplete && !recording ? "slider" : undefined}
1625
+ style={{ height: heightStyle }}
1626
+ tabIndex={isRecordingComplete && !recording ? 0 : undefined}
1627
+ {...props}
1628
+ >
1629
+ <canvas className="block h-full w-full" ref={canvasRef} />
1630
+ </div>
1631
+ )
1632
+ }