@kitn.ai/ui 0.19.0 → 0.20.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 (236) hide show
  1. package/LICENSE +6 -0
  2. package/NOTICE +54 -0
  3. package/dist/components/audio-visualizer/audio-visualizer.voice-fixture.d.ts +59 -0
  4. package/dist/components/audio-visualizer/aurora.glsl.d.ts +114 -0
  5. package/dist/components/audio-visualizer/index.d.ts +155 -0
  6. package/dist/components/audio-visualizer/shader-canvas.d.ts +130 -0
  7. package/dist/components/audio-visualizer/sizes.d.ts +25 -0
  8. package/dist/components/audio-visualizer/variant-aurora.d.ts +89 -0
  9. package/dist/components/audio-visualizer/variant-bar.d.ts +46 -0
  10. package/dist/components/audio-visualizer/variant-custom.d.ts +73 -0
  11. package/dist/components/audio-visualizer/variant-grid.d.ts +22 -0
  12. package/dist/components/audio-visualizer/variant-radial.d.ts +13 -0
  13. package/dist/components/audio-visualizer/variant-wave.d.ts +11 -0
  14. package/dist/components/audio-visualizer/wave.glsl.d.ts +16 -0
  15. package/dist/create-tween-hvpmSuwW.js +7 -0
  16. package/dist/custom-elements.json +282 -0
  17. package/dist/elements/agent-card.js +1 -1
  18. package/dist/elements/artifact.js +1 -1
  19. package/dist/elements/attachments.js +1 -1
  20. package/dist/elements/audio-visualizer.d.ts +1 -0
  21. package/dist/elements/audio-visualizer.js +1 -0
  22. package/dist/elements/autoloader.js +1 -1
  23. package/dist/elements/avatar.js +1 -1
  24. package/dist/elements/badge.js +1 -1
  25. package/dist/elements/button.js +1 -1
  26. package/dist/elements/card.js +1 -1
  27. package/dist/elements/cards.js +1 -1
  28. package/dist/elements/chain-of-thought.js +1 -1
  29. package/dist/elements/chat-scope-picker.js +1 -1
  30. package/dist/elements/chat-workspace.js +1 -1
  31. package/dist/elements/chat.js +1 -1
  32. package/dist/elements/checkpoint.js +1 -1
  33. package/dist/elements/choice.js +1 -1
  34. package/dist/elements/chunks/{Icon-11SRdjJ4.js → Icon-Tj58ES_d.js} +1 -1
  35. package/dist/elements/chunks/{action-icons-CtRg17uX.js → action-icons-ns1Fnedj.js} +1 -1
  36. package/dist/elements/chunks/arrow-left-DQocUFvy.js +1 -0
  37. package/dist/elements/chunks/{attachments-CBqQRh60.js → attachments-mCB5F1Fq.js} +1 -1
  38. package/dist/elements/chunks/audio-visualizer-BqNy7dfm.js +1 -0
  39. package/dist/elements/chunks/{badge-CxklEXQm.js → badge-B4Zuajl2.js} +1 -1
  40. package/dist/elements/chunks/{button-BD7lUvVB.js → button-ZWNif3--.js} +1 -1
  41. package/dist/elements/chunks/{card-B-PRGXdj.js → card-BkHbp4BA.js} +1 -1
  42. package/dist/elements/chunks/{chat-thread-DIZbRIKG.js → chat-thread-CkRN6pZ7.js} +1 -1
  43. package/dist/elements/chunks/check-B9-gn6SS.js +1 -0
  44. package/dist/elements/chunks/chevron-down-BNsxc2hk.js +1 -0
  45. package/dist/elements/chunks/chevron-right-Hv00StBO.js +1 -0
  46. package/dist/elements/chunks/circle-0e3KJTDr.js +1 -0
  47. package/dist/elements/chunks/circle-check-BNAS0JEy.js +1 -0
  48. package/dist/elements/chunks/{circle-x-mtjCO_Ok.js → circle-x-B7faJFlY.js} +1 -1
  49. package/dist/elements/chunks/{code-block-BeWVttmU.js → code-block-BFUHn6Va.js} +1 -1
  50. package/dist/elements/chunks/collapsible-DM1Ckd4e.js +1 -0
  51. package/dist/elements/chunks/{composer-CavJww3d.js → composer-BguS0QL_.js} +2 -2
  52. package/dist/elements/chunks/{context-CnlsNJSJ.js → context-Cb7k4F-v.js} +1 -1
  53. package/dist/elements/chunks/{conversation-list-5O_KdI_I.js → conversation-list-CvoAkWhN.js} +1 -1
  54. package/dist/elements/chunks/create-tween-oVvAIG8r.js +7 -0
  55. package/dist/elements/chunks/{default-input-CB46cJJ1.js → default-input-Cqqilg9t.js} +1 -1
  56. package/dist/elements/chunks/define-D1din7Ld.js +1 -0
  57. package/dist/elements/chunks/{disclosure-BtFK3a-M.js → disclosure-CIZ5c8nO.js} +1 -1
  58. package/dist/elements/chunks/{download-qJv1dS3M.js → download-Dsbg3ste.js} +1 -1
  59. package/dist/elements/chunks/{dropdown-UhtAueYR.js → dropdown-BDd1-Zgi.js} +1 -1
  60. package/dist/elements/chunks/{ellipsis-B1sOOqiJ.js → ellipsis-Dw6qrss-.js} +1 -1
  61. package/dist/elements/chunks/{external-link-DToP_VKE.js → external-link-Vt74zAoL.js} +1 -1
  62. package/dist/elements/chunks/{file-text-BwUs_Pj4.js → file-text-CZrZF6_v.js} +1 -1
  63. package/dist/elements/chunks/{file-tree-C6RhmSnX.js → file-tree-DF4okKyo.js} +1 -1
  64. package/dist/elements/chunks/{folder-H_Xp0GYP.js → folder-glP5rha4.js} +1 -1
  65. package/dist/elements/chunks/{hover-card-Dn3yrk1l.js → hover-card-Cmm6Q53m.js} +1 -1
  66. package/dist/elements/chunks/{icon-BrxJQXzC.js → icon-BLb8beoi.js} +1 -1
  67. package/dist/elements/chunks/{info-11eT2q5H.js → info-CIsv92JK.js} +1 -1
  68. package/dist/elements/chunks/{input-B9oZKDfb.js → input-Dds1kFyt.js} +1 -1
  69. package/dist/elements/chunks/{kbd-D2fx3rWh.js → kbd-De_xbgi9.js} +1 -1
  70. package/dist/elements/chunks/{link-B-RNYWNw.js → link-i4r_k7z4.js} +1 -1
  71. package/dist/elements/chunks/{loader-BJh_aRlp.js → loader-BmQH4s3V.js} +1 -1
  72. package/dist/elements/chunks/{markdown-BIpjrDFv.js → markdown-Zi21WhUz.js} +7 -7
  73. package/dist/elements/chunks/{message-CPfqj8Gi.js → message-BqyXDN3-.js} +1 -1
  74. package/dist/elements/chunks/{message-feedback-8uj0aVYh.js → message-feedback-Djr3z6Yb.js} +1 -1
  75. package/dist/elements/chunks/message-square-vFAgbDaX.js +1 -0
  76. package/dist/elements/chunks/{minimize-2-C9qkiTJg.js → minimize-2--ifeCGrl.js} +1 -1
  77. package/dist/elements/chunks/{model-switcher-DRrMcRXH.js → model-switcher-B94YAKsM.js} +1 -1
  78. package/dist/elements/chunks/{overlay-C8_FUL8q.js → overlay-kD8dwRv_.js} +1 -1
  79. package/dist/elements/chunks/{paperclip-MA9hxLhW.js → paperclip-q21dYOSK.js} +1 -1
  80. package/dist/elements/chunks/{progress-bar-y5G6HDjg.js → progress-bar-BrAIIaaJ.js} +1 -1
  81. package/dist/elements/chunks/{prompt-suggestion-LalWvCJy.js → prompt-suggestion-Bjn2K-fR.js} +1 -1
  82. package/dist/elements/chunks/reasoning-DTpCGtnY.js +1 -0
  83. package/dist/elements/chunks/{resizable-xhYF5lCQ.js → resizable-Dp4JExql.js} +1 -1
  84. package/dist/elements/chunks/{rotate-cw-2ndEXjgS.js → rotate-cw-CFWWkpj2.js} +1 -1
  85. package/dist/elements/chunks/{scroll-area-BzOPDXrZ.js → scroll-area-Bb0kHH1j.js} +1 -1
  86. package/dist/elements/chunks/scroll-button-B0cH5fXf.js +1 -0
  87. package/dist/elements/chunks/{separator-CPvL5Xdl.js → separator-9kRgr1X9.js} +1 -1
  88. package/dist/elements/chunks/{settings-hmzIuNXD.js → settings-B8lmec4D.js} +1 -1
  89. package/dist/elements/chunks/{settings-group-BiIym17O.js → settings-group-I0hEuXiN.js} +1 -1
  90. package/dist/elements/chunks/{share-BbziTAzn.js → share-C3lTiYGt.js} +1 -1
  91. package/dist/elements/chunks/{skeleton-Bxhs1thL.js → skeleton-DSXW5Yya.js} +1 -1
  92. package/dist/elements/chunks/{star-BHL3Va5p.js → star-CSJtGxgv.js} +1 -1
  93. package/dist/elements/chunks/{store-tpnnWlxY.js → store-CHyW95TP.js} +1 -1
  94. package/dist/elements/chunks/{text-shimmer-1DCKivKx.js → text-shimmer-Cn9TmNpI.js} +1 -1
  95. package/dist/elements/chunks/{textarea-Czaowo9F.js → textarea-CUwqAX0G.js} +1 -1
  96. package/dist/elements/chunks/{thumbs-up-CdLqA13R.js → thumbs-up-PNVVThEF.js} +1 -1
  97. package/dist/elements/chunks/{toast-store-BvBquUFA.js → toast-store-DKbQn3Wn.js} +1 -1
  98. package/dist/elements/chunks/{tool-4P_ne8Go.js → tool-DjTRpIpl.js} +1 -1
  99. package/dist/elements/chunks/tooltip-CgBZCVKp.js +1 -0
  100. package/dist/elements/chunks/{triangle-alert-BllqxXq8.js → triangle-alert-DsHTHCPm.js} +1 -1
  101. package/dist/elements/chunks/{use-card-resolution-73xiddIW.js → use-card-resolution-BSu_XHjH.js} +1 -1
  102. package/dist/elements/chunks/variant-aurora-Dc4nQUia.js +218 -0
  103. package/dist/elements/chunks/variant-custom-y_3kVQhS.js +1 -0
  104. package/dist/elements/chunks/variant-wave-D5OR1-9Z.js +116 -0
  105. package/dist/elements/chunks/{video-O9rA6k7S.js → video-B7vmenoC.js} +1 -1
  106. package/dist/elements/chunks/x-GRUeeQFT.js +1 -0
  107. package/dist/elements/coachmark.js +1 -1
  108. package/dist/elements/code-block.js +1 -1
  109. package/dist/elements/command.js +1 -1
  110. package/dist/elements/compare.js +1 -1
  111. package/dist/elements/composer.js +1 -1
  112. package/dist/elements/confirm-card.js +1 -1
  113. package/dist/elements/context-meter.js +1 -1
  114. package/dist/elements/conversation-list.js +1 -1
  115. package/dist/elements/define.d.ts +11 -0
  116. package/dist/elements/dialog.js +1 -1
  117. package/dist/elements/editable-label.js +1 -1
  118. package/dist/elements/embed.js +1 -1
  119. package/dist/elements/empty.js +1 -1
  120. package/dist/elements/feedback-bar.js +1 -1
  121. package/dist/elements/file-tree.js +1 -1
  122. package/dist/elements/file-upload.js +1 -1
  123. package/dist/elements/form.js +1 -1
  124. package/dist/elements/hover-card.js +1 -1
  125. package/dist/elements/icon.js +1 -1
  126. package/dist/elements/image.js +1 -1
  127. package/dist/elements/input.js +1 -1
  128. package/dist/elements/kbd.js +1 -1
  129. package/dist/elements/link-preview.js +1 -1
  130. package/dist/elements/loader.js +1 -1
  131. package/dist/elements/markdown.js +1 -1
  132. package/dist/elements/menu.js +1 -1
  133. package/dist/elements/message-skills.js +1 -1
  134. package/dist/elements/message.js +1 -1
  135. package/dist/elements/model-switcher.js +1 -1
  136. package/dist/elements/nav.js +1 -1
  137. package/dist/elements/notice.js +1 -1
  138. package/dist/elements/pane-group.js +1 -1
  139. package/dist/elements/pane.js +1 -1
  140. package/dist/elements/popover.js +1 -1
  141. package/dist/elements/progress-bar.js +1 -1
  142. package/dist/elements/prompt-dock.js +1 -1
  143. package/dist/elements/prompt-input.js +1 -1
  144. package/dist/elements/prompt-suggestions.js +1 -1
  145. package/dist/elements/reasoning.js +1 -1
  146. package/dist/elements/remote.js +1 -1
  147. package/dist/elements/resizable.js +1 -1
  148. package/dist/elements/response-stream.js +1 -1
  149. package/dist/elements/screen.js +1 -1
  150. package/dist/elements/scroll-area.js +1 -1
  151. package/dist/elements/scroll-button.js +1 -1
  152. package/dist/elements/search.js +1 -1
  153. package/dist/elements/segmented.js +1 -1
  154. package/dist/elements/separator.js +1 -1
  155. package/dist/elements/setting-item.js +1 -1
  156. package/dist/elements/settings-group.js +1 -1
  157. package/dist/elements/skeleton.js +1 -1
  158. package/dist/elements/slots.d.ts +12 -0
  159. package/dist/elements/source.js +1 -1
  160. package/dist/elements/status.js +1 -1
  161. package/dist/elements/switch.js +1 -1
  162. package/dist/elements/tabs.js +1 -1
  163. package/dist/elements/tasks.js +1 -1
  164. package/dist/elements/text-shimmer.js +1 -1
  165. package/dist/elements/thinking-bar.js +1 -1
  166. package/dist/elements/thread.js +1 -1
  167. package/dist/elements/toast.js +1 -1
  168. package/dist/elements/tool.js +1 -1
  169. package/dist/elements/tooltip.js +1 -1
  170. package/dist/elements/voice-input.js +1 -1
  171. package/dist/elements/voice-output.js +1 -1
  172. package/dist/elements.d.ts +38 -0
  173. package/dist/index.js +18 -17
  174. package/dist/index.server.js +16404 -0
  175. package/dist/kai.es.js +1 -1
  176. package/dist/llms/llms-full.txt +40 -4
  177. package/dist/llms/llms.txt +3 -3
  178. package/dist/primitives/audio-bands.d.ts +106 -0
  179. package/dist/primitives/create-tween.d.ts +33 -0
  180. package/dist/primitives/use-audio-analysis.d.ts +149 -0
  181. package/dist/primitives/use-sequencer.d.ts +13 -0
  182. package/dist/primitives/use-voice-recorder.d.ts +1 -0
  183. package/dist/primitives/visualizer-sequences.d.ts +39 -0
  184. package/dist/react/index.d.ts +41 -0
  185. package/dist/react.js +159 -153
  186. package/dist/register-impl-DQyejdfb.js +145 -0
  187. package/dist/test-utils/fake-clock.d.ts +45 -0
  188. package/dist/variant-aurora-DVtGqVH6.js +218 -0
  189. package/dist/variant-custom-Dif-IV4u.js +1 -0
  190. package/dist/variant-wave-DbfNdvSi.js +116 -0
  191. package/frameworks/react/index.tsx +42 -0
  192. package/llms-full.txt +40 -4
  193. package/llms.txt +3 -3
  194. package/package.json +11 -3
  195. package/src/components/audio-visualizer/audio-visualizer.voice-fixture.ts +257 -0
  196. package/src/components/audio-visualizer/aurora.glsl.ts +330 -0
  197. package/src/components/audio-visualizer/index.tsx +442 -0
  198. package/src/components/audio-visualizer/shader-canvas.tsx +846 -0
  199. package/src/components/audio-visualizer/sizes.ts +54 -0
  200. package/src/components/audio-visualizer/variant-aurora.tsx +299 -0
  201. package/src/components/audio-visualizer/variant-bar.tsx +143 -0
  202. package/src/components/audio-visualizer/variant-custom.tsx +338 -0
  203. package/src/components/audio-visualizer/variant-grid.tsx +170 -0
  204. package/src/components/audio-visualizer/variant-radial.tsx +158 -0
  205. package/src/components/audio-visualizer/variant-wave.tsx +125 -0
  206. package/src/components/audio-visualizer/wave.glsl.ts +130 -0
  207. package/src/components/component-meta.json +24 -24
  208. package/src/elements/audio-visualizer.tsx +183 -0
  209. package/src/elements/compiled.css +1 -1
  210. package/src/elements/define.tsx +12 -1
  211. package/src/elements/element-manifest.json +4 -0
  212. package/src/elements/element-meta.json +170 -0
  213. package/src/elements/element-types.d.ts +38 -0
  214. package/src/elements/register-impl.ts +1 -0
  215. package/src/elements/slots.ts +35 -0
  216. package/src/primitives/audio-bands.ts +174 -0
  217. package/src/primitives/create-tween.ts +214 -0
  218. package/src/primitives/use-audio-analysis.ts +321 -0
  219. package/src/primitives/use-sequencer.ts +53 -0
  220. package/src/primitives/use-voice-recorder.ts +16 -4
  221. package/src/primitives/visualizer-sequences.ts +350 -0
  222. package/src/test-utils/fake-clock.ts +88 -0
  223. package/dist/elements/chunks/arrow-left-CifsGq8x.js +0 -1
  224. package/dist/elements/chunks/check-CUKS495w.js +0 -1
  225. package/dist/elements/chunks/chevron-down-AELmYT1j.js +0 -1
  226. package/dist/elements/chunks/chevron-right-C8g26oKh.js +0 -1
  227. package/dist/elements/chunks/circle-DGcI8Cft.js +0 -1
  228. package/dist/elements/chunks/circle-check-DuU0IcBQ.js +0 -1
  229. package/dist/elements/chunks/collapsible-DGjADCN6.js +0 -1
  230. package/dist/elements/chunks/define-DfTe4zwZ.js +0 -1
  231. package/dist/elements/chunks/message-square-CCSQ4M9g.js +0 -1
  232. package/dist/elements/chunks/reasoning-CW3SDdb4.js +0 -1
  233. package/dist/elements/chunks/scroll-button-B1x4cYqM.js +0 -1
  234. package/dist/elements/chunks/tooltip-CBeFqJKk.js +0 -1
  235. package/dist/elements/chunks/x-DeS3rQhx.js +0 -1
  236. package/dist/register-impl-CaaHJcea.js +0 -145
@@ -0,0 +1,214 @@
1
+ import { createSignal, onCleanup, untrack, type Accessor } from 'solid-js';
2
+
3
+ export type Transition =
4
+ | { duration: number; ease?: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' }
5
+ | { type: 'spring'; duration: number; bounce: number };
6
+
7
+ /**
8
+ * Builds an easing function from a CSS-style cubic bezier. The curve's
9
+ * endpoints are fixed at (0, 0) and (1, 1); (x1, y1) and (x2, y2) are the
10
+ * two control points. Given a progress value `t` used as the curve's `x`,
11
+ * solves `X(s) === t` for the curve parameter `s` via Newton-Raphson (a
12
+ * handful of iterations converges for these curves), falls back to
13
+ * bisection if the derivative gets too small or a step would leave
14
+ * [0, 1], then returns `Y(s)`.
15
+ */
16
+ function cubicBezier(x1: number, y1: number, x2: number, y2: number): (t: number) => number {
17
+ const x = (s: number) => 3 * (1 - s) * (1 - s) * s * x1 + 3 * (1 - s) * s * s * x2 + s * s * s;
18
+ const y = (s: number) => 3 * (1 - s) * (1 - s) * s * y1 + 3 * (1 - s) * s * s * y2 + s * s * s;
19
+ const xDerivative = (s: number) =>
20
+ 3 * (1 - s) * (1 - s) * x1 + 6 * (1 - s) * s * (x2 - x1) + 3 * s * s * (1 - x2);
21
+
22
+ function bisect(t: number): number {
23
+ let lo = 0;
24
+ let hi = 1;
25
+ for (let i = 0; i < 30; i++) {
26
+ const mid = (lo + hi) / 2;
27
+ if (x(mid) < t) lo = mid;
28
+ else hi = mid;
29
+ }
30
+ return (lo + hi) / 2;
31
+ }
32
+
33
+ return (t: number) => {
34
+ if (t <= 0) return 0;
35
+ if (t >= 1) return 1;
36
+
37
+ let s = t;
38
+ for (let i = 0; i < 8; i++) {
39
+ const derivative = xDerivative(s);
40
+ if (Math.abs(derivative) < 1e-6) return y(bisect(t));
41
+
42
+ const next = s - (x(s) - t) / derivative;
43
+ if (next <= 0 || next >= 1) return y(bisect(t));
44
+
45
+ s = next;
46
+ }
47
+ return y(s);
48
+ };
49
+ }
50
+
51
+ // Matches motion's named curves so the visualizers feel like their LiveKit
52
+ // counterparts rather than a plausible-looking approximation.
53
+ const EASINGS = {
54
+ linear: (t: number) => t,
55
+ easeIn: cubicBezier(0.42, 0, 1, 1),
56
+ easeOut: cubicBezier(0, 0, 0.58, 1),
57
+ easeInOut: cubicBezier(0.42, 0, 0.58, 1),
58
+ } as const;
59
+
60
+ /**
61
+ * Damped-spring position at normalized time `t` (0..1), settling at 1.
62
+ *
63
+ * `bounce` maps to the damping ratio: 0 is critically damped (no overshoot),
64
+ * higher values overshoot more. Matches the feel of motion's spring defaults
65
+ * closely enough for shader uniforms, without the dependency.
66
+ */
67
+ function spring(t: number, bounce: number): number {
68
+ const zeta = Math.max(0.05, 1 - Math.min(0.95, bounce));
69
+ const omega = 10;
70
+ const damped = omega * Math.sqrt(Math.max(0, 1 - zeta * zeta));
71
+ return (
72
+ 1 -
73
+ Math.exp(-zeta * omega * t) *
74
+ (Math.cos(damped * t) + ((zeta * omega) / (damped || 1)) * Math.sin(damped * t))
75
+ );
76
+ }
77
+
78
+ function isSpring(tr: Transition): tr is { type: 'spring'; duration: number; bounce: number } {
79
+ return 'type' in tr && tr.type === 'spring';
80
+ }
81
+
82
+ /**
83
+ * A tweened number driven by requestAnimationFrame.
84
+ *
85
+ * Exists so shader uniforms can animate without pulling in a motion library.
86
+ * CSS transitions cannot help here: uniforms are plain JS numbers handed to
87
+ * WebGL every frame.
88
+ *
89
+ * `to()` interrupts whatever is in flight. An array target ping-pongs between
90
+ * its two values forever, which is how the listening and thinking pulses read.
91
+ * `duration: 0` (or no transition) sets instantly, which live volume needs so
92
+ * the picture never lags the audio.
93
+ *
94
+ * `animating` is true exactly while a frame loop is armed: from an animated
95
+ * `to()` until it lands (a ping-pong never lands, so it stays true), false
96
+ * for instant sets and after dispose. It is a real signal on purpose -- the
97
+ * aurora variant's volume-override effect reads it as its upstream-parity
98
+ * "scale tween idle" guard, and being reactive means that effect re-runs
99
+ * the moment a landing settles instead of waiting for the next volume tick.
100
+ */
101
+ export function createTween(initial: number): {
102
+ value: Accessor<number>;
103
+ animating: Accessor<boolean>;
104
+ to(target: number | [number, number], transition?: Transition): void;
105
+ } {
106
+ const [value, setValue] = createSignal(initial);
107
+ const [animating, setAnimating] = createSignal(false);
108
+ let raf = 0;
109
+ let disposed = false;
110
+
111
+ const stop = () => {
112
+ if (raf) cancelAnimationFrame(raf);
113
+ raf = 0;
114
+ };
115
+
116
+ onCleanup(() => {
117
+ disposed = true;
118
+ stop();
119
+ setAnimating(false);
120
+ });
121
+
122
+ function to(target: number | [number, number], transition?: Transition) {
123
+ stop();
124
+ if (disposed) return;
125
+
126
+ const pingPong = Array.isArray(target);
127
+ // Untracked: `.to()` is an imperative command ("animate to this
128
+ // target"), not a reactive read. Its normal caller is a createEffect
129
+ // reacting to some OTHER signal (e.g. state changing); reading this
130
+ // tween's own `value()` here without untrack() would make that effect
131
+ // implicitly depend on the tween's own signal too. That is invisible
132
+ // for a single tween (it would just restart itself every frame and
133
+ // still crawl toward the target) but is fatal the moment a caller
134
+ // creates a SECOND tween and `.to()`'s both from the same effect (see
135
+ // the wave shader variant, which does exactly this for amplitude and
136
+ // frequency): a browser fires one frame's queued
137
+ // requestAnimationFrame callbacks in the order they were registered, so
138
+ // the FIRST tween's step -- which runs first, and whose `setValue`
139
+ // synchronously re-triggers the (now-dependent) effect -- cancels the
140
+ // SECOND tween's already-queued step before the browser ever reaches
141
+ // it. That repeats identically every single frame, so the second
142
+ // tween's `step` never fires even once and its value never leaves its
143
+ // initial one, permanently, no matter how long the effect keeps
144
+ // "restarting" it.
145
+ const [a, b] = pingPong ? target : [untrack(value), target];
146
+
147
+ if (!transition || (!isSpring(transition) && transition.duration === 0)) {
148
+ setAnimating(false);
149
+ setValue(pingPong ? a : b);
150
+ return;
151
+ }
152
+
153
+ const durationMs = transition.duration * 1000;
154
+ if (durationMs <= 0) {
155
+ setAnimating(false);
156
+ setValue(b);
157
+ return;
158
+ }
159
+
160
+ if (typeof requestAnimationFrame === 'undefined') {
161
+ setAnimating(false);
162
+ setValue(b);
163
+ return;
164
+ }
165
+
166
+ const ease = isSpring(transition)
167
+ ? (t: number) => spring(t, transition.bounce)
168
+ : EASINGS[transition.ease ?? 'easeOut'];
169
+
170
+ // A ping-pong starts from its first value rather than wherever it was, so
171
+ // the pulse reads the same every time it restarts. `a` is already an
172
+ // untracked read of the current value (or the ping-pong's first target
173
+ // value) from above -- reused here instead of reading `value()` again.
174
+ let from = a;
175
+ let to_ = b;
176
+ if (pingPong) setValue(a);
177
+
178
+ // requestAnimationFrame's timestamp and performance.now() share a time
179
+ // origin per spec, so capturing the origin here, at the moment to() is
180
+ // called, is directly comparable to the `now` a later frame reports.
181
+ // That means elapsed time is measured from when this leg actually
182
+ // started, correct on the very first frame with no lazy-capture dance
183
+ // and no risk of an idle gap since the last tween being charged against
184
+ // this one.
185
+ let legStart = performance.now();
186
+ const step = (now: number) => {
187
+ const t = Math.min(1, (now - legStart) / durationMs);
188
+ setValue(from + (to_ - from) * ease(t));
189
+
190
+ if (t < 1) {
191
+ raf = requestAnimationFrame(step);
192
+ return;
193
+ }
194
+
195
+ if (!pingPong) {
196
+ setValue(to_);
197
+ raf = 0;
198
+ setAnimating(false);
199
+ return;
200
+ }
201
+
202
+ // Reverse and run again, from a fresh origin for the new leg.
203
+ setValue(to_);
204
+ [from, to_] = [to_, from];
205
+ legStart = performance.now();
206
+ raf = requestAnimationFrame(step);
207
+ };
208
+
209
+ setAnimating(true);
210
+ raf = requestAnimationFrame(step);
211
+ }
212
+
213
+ return { value, animating, to };
214
+ }
@@ -0,0 +1,321 @@
1
+ import { createSignal, createEffect, onCleanup, type Accessor } from 'solid-js';
2
+ import { reduceToBands, reduceToVolume } from './audio-bands';
3
+
4
+ export interface AudioAnalysisOptions {
5
+ /**
6
+ * Number of frequency buckets to produce, or a live accessor for it.
7
+ * Default 5.
8
+ *
9
+ * An accessor is resolved INSIDE the analysis effect, so a change to
10
+ * whatever signal backs it (e.g. a caller's variant or size switching)
11
+ * rebuilds the analyser at the new bucket count instead of silently
12
+ * leaving `bands()` padded or truncated to a stale size.
13
+ */
14
+ bands?: number | (() => number);
15
+ /**
16
+ * Low bin index of the pass window. NOT a frequency. Default 100 --
17
+ * upstream LiveKit's component value (see DEFAULTS below).
18
+ */
19
+ loPass?: number;
20
+ /**
21
+ * High bin index of the pass window. NOT a frequency. Default 200 --
22
+ * upstream LiveKit's component value (see DEFAULTS below).
23
+ *
24
+ * Legitimately input-dependent, unlike `fftSize`/`smoothingTimeConstant`:
25
+ * the default window expects PROCESSED speech (an agent's TTS track, or a
26
+ * mic captured with AGC/noise suppression on). For raw, unprocessed input
27
+ * -- an un-gained recording, music, ambience -- a wide low window such as
28
+ * `loPass: 4, hiPass: 120` reads energy the default deliberately gates
29
+ * out; see DEFAULTS below for the trade both ways.
30
+ */
31
+ hiPass?: number;
32
+ /** Minimum ms between updates. Default 32 (about 30fps). */
33
+ updateInterval?: number;
34
+ }
35
+
36
+ /**
37
+ * The default window is upstream LiveKit's COMPONENT window, bins 100-200:
38
+ * every shipped agents-ui visualizer passes `{ loPass: 100, hiPass: 200 }`
39
+ * (agent-audio-visualizer-bar.tsx:181-183, grid.tsx:279-281,
40
+ * radial.tsx:142-144 -- radial's loPass was 80 until their PR #1265). Their
41
+ * HOOK's own default is 100-600 (useTrackVolume.ts:95-101), but no shipped
42
+ * component uses it, so parity means matching the components.
43
+ *
44
+ * At fftSize 2048 that is ~2.34-4.69kHz at 48kHz (2.15-4.31kHz at 44.1kHz):
45
+ * the sibilance band, ABOVE where a room's noise floor lives. Everything at
46
+ * or below -100dB normalizes to exactly 0 (see normalizeDb), and a real
47
+ * room's tone measures ~17dB BELOW that floor across this window while
48
+ * sitting ~27dB ABOVE it across 86-258Hz (noise-floor diagnosis, table A).
49
+ * The narrow high window plus the hard -100dB floor IS upstream's noise
50
+ * gate -- there is no explicit gate anywhere in their pipeline -- and it is
51
+ * what keeps their idle bars perfectly still where a wide window shows the
52
+ * room breathing.
53
+ *
54
+ * The trade, measured on a real un-processed recording ("this is a test,
55
+ * testing one two three, hello world"): through 100-200, quiet
56
+ * natural-volume speech frames read all-zero (54% of that clip's frames;
57
+ * consonants and sibilance still register on the louder ones). Upstream
58
+ * lives with that because their input is conditioned before analysis -- an
59
+ * agent's loud TTS track, or a local mic captured with autoGainControl +
60
+ * noiseSuppression + echoCancellation + voiceIsolation (livekit-client
61
+ * defaults.ts:27-30) that lifts speech toward target level. Ours follows:
62
+ * the mic stories request the same constraint set. For RAW input where that
63
+ * trade reads wrong (un-gained recordings, music), `loPass: 4, hiPass: 120`
64
+ * (86Hz-2.6kHz: fundamental + first two formants) remains the measured
65
+ * opt-in -- it read those same quiet frames at mean 0.234 with only true
66
+ * silence at zero -- at the documented cost of visualizing the room's noise
67
+ * floor on the centre elements (the defect that reverted this default).
68
+ */
69
+ export const DEFAULTS = {
70
+ bands: 5,
71
+ loPass: 100,
72
+ hiPass: 200,
73
+ updateInterval: 32,
74
+ } as const;
75
+
76
+ /**
77
+ * `fftSize`/`smoothingTimeConstant` are deliberately NOT caller-configurable
78
+ * options, unlike everything above. They exist in two different, non-tunable
79
+ * shapes below instead: one AnalyserNode per reduction, each matching
80
+ * upstream LiveKit's own hook for that reduction. A single flat
81
+ * `smoothingTimeConstant` option (this hook's original design) is exactly
82
+ * the bug that made bars snap back to rest instead of easing like upstream's
83
+ * do: one value silently applied to both a fast reduction and a slow one.
84
+ * Hard-coding removes the only way a caller (or a future edit here) could
85
+ * reintroduce that.
86
+ *
87
+ * These analyser shapes (and DEFAULTS above) are EXPORTED as the kit's
88
+ * analysis-settings contract: external instrumentation -- the
89
+ * examples/internal/livekit-parity probe -- imports them instead of
90
+ * restating the numbers, so a change here propagates instead of drifting.
91
+ */
92
+
93
+ /**
94
+ * Matches upstream's `useMultibandTrackVolume`, which drives the DOM
95
+ * bar/grid/radial variants: `{ fftSize: 2048 }`, no `smoothingTimeConstant`
96
+ * given, so the AnalyserNode uses the Web Audio spec default of 0.8. That
97
+ * slow decay (roughly 330ms to fall to 10% of a peak, at this hook's 32ms
98
+ * sample interval) is what makes bars ease back to rest after speech stops
99
+ * instead of snapping.
100
+ */
101
+ export const BANDS_ANALYSER = {
102
+ fftSize: 2048,
103
+ smoothingTimeConstant: 0.8,
104
+ } as const;
105
+
106
+ /**
107
+ * Matches upstream's `useTrackVolume` as the wave/aura hooks call it:
108
+ * `{ fftSize: 512, smoothingTimeConstant: 0.55 }`
109
+ * (use-agent-audio-visualizer-wave.ts:55-56, aura.ts:62-63). Faster decay
110
+ * (roughly 123ms to 10% of peak) keeps the shaders' reactivity within the
111
+ * ~33ms lag the aurora variant was tuned against; the bands' slower 0.8
112
+ * here would make the shaders visibly sluggish. Upstream reaches the same
113
+ * two-analyser structure differently -- two hooks, each with its own
114
+ * analyser (and its own AudioContext) -- but the constants match ours
115
+ * exactly.
116
+ *
117
+ * `minDecibels`/`maxDecibels` are upstream's `createAudioAnalyser` defaults
118
+ * (livekit-client src/room/utils.ts:548-553), NOT the Web Audio spec's
119
+ * -100/-30. They only rescale getByteFrequencyData -- this analyser's whole
120
+ * output -- mapping the byte range over a 20dB window that saturates at
121
+ * -80dB. That is what makes upstream's volume scalar run hot (speech
122
+ * ~0.5-0.9) and their wave/aura feel alive; on the spec scale the same
123
+ * speech read ~3x colder here (0.31 vs 0.80, noise-floor diagnosis,
124
+ * addendum 1), leaving our shaders under-reacting. The BANDS analyser gets
125
+ * no such pair: it is read with getFloatFrequencyData, where these two
126
+ * properties have no effect at all.
127
+ */
128
+ export const VOLUME_ANALYSER = {
129
+ fftSize: 512,
130
+ smoothingTimeConstant: 0.55,
131
+ minDecibels: -100,
132
+ maxDecibels: -80,
133
+ } as const;
134
+
135
+ /** `bands` accepts a plain number or a live accessor; read whichever was given. */
136
+ function resolveBandCount(bands: number | (() => number)): number {
137
+ return typeof bands === 'function' ? bands() : bands;
138
+ }
139
+
140
+ /**
141
+ * One AudioContext for the whole page. Contexts are expensive and browsers cap
142
+ * how many can exist, so a per-mount context would break a page with several
143
+ * visualizers on it.
144
+ */
145
+ let sharedContext: AudioContext | undefined;
146
+
147
+ function getContext(): AudioContext | undefined {
148
+ if (typeof AudioContext === 'undefined') return undefined;
149
+ sharedContext ??= new AudioContext();
150
+ return sharedContext;
151
+ }
152
+
153
+ /**
154
+ * `createMediaElementSource` THROWS if called twice for the same element, and
155
+ * there is no API to ask whether an element already has a source node. Cache
156
+ * them. A WeakMap so a removed <audio> can still be collected.
157
+ */
158
+ const elementSources = new WeakMap<HTMLMediaElement, MediaElementAudioSourceNode>();
159
+
160
+ /**
161
+ * `createMediaStreamSource` does NOT throw on a second call for the same
162
+ * stream, unlike the element API above -- it is legal per spec to build
163
+ * several independent source nodes from one MediaStream. In practice several
164
+ * simultaneous nodes reading the same stream is a known source of
165
+ * intermittent silent data loss in Chromium (observed here as `volume`
166
+ * sticking at 0 for several seconds with three or more visualizers on one
167
+ * live microphone). Cache and share one node per stream, exactly like the
168
+ * element path, so N consumers is one node with N analyser taps rather than
169
+ * N nodes racing each other.
170
+ */
171
+ const streamSources = new WeakMap<MediaStream, MediaStreamAudioSourceNode>();
172
+
173
+ /**
174
+ * `instanceof MediaStream` is not usable here: that global does not exist in
175
+ * every environment (jsdom included), and referencing it throws a
176
+ * ReferenceError instead of returning false. Every DOM element carries a
177
+ * `tagName`; a MediaStream never does, so it is a safe, environment-agnostic
178
+ * discriminator between the two accepted source types.
179
+ */
180
+ function isMediaElement(src: MediaStream | HTMLMediaElement): src is HTMLMediaElement {
181
+ return 'tagName' in src;
182
+ }
183
+
184
+ /** Resume a context parked by the autoplay policy, on the first user gesture. */
185
+ function resumeOnGesture(ctx: AudioContext): () => void {
186
+ if (ctx.state !== 'suspended') return () => {};
187
+ if (typeof document === 'undefined') return () => {};
188
+
189
+ const resume = () => void ctx.resume().catch(() => {});
190
+ const events = ['pointerdown', 'keydown', 'touchstart'] as const;
191
+ events.forEach((e) => document.addEventListener(e, resume, { once: true, passive: true }));
192
+ return () => events.forEach((e) => document.removeEventListener(e, resume));
193
+ }
194
+
195
+ /**
196
+ * Turn a live audio source into numbers a visualizer can draw.
197
+ *
198
+ * Runs TWO analysers off one source node: `bands` for the DOM variants and a
199
+ * scalar `volume` for the shader ones, each with its own `fftSize` and
200
+ * `smoothingTimeConstant` matching upstream's two separate hooks (see
201
+ * BANDS_ANALYSER / VOLUME_ANALYSER above). An earlier version of this hook
202
+ * shared a single analyser between both reductions as an optimization; that
203
+ * silently forced one smoothing behavior onto both and made bars snap back
204
+ * to rest instead of easing like upstream's do. What this still saves over
205
+ * upstream: one shared, cached source node per element/stream (see
206
+ * elementSources/streamSources below) instead of a fresh one per hook
207
+ * instance, and one requestAnimationFrame loop reading both analysers each
208
+ * tick instead of two independent timers.
209
+ *
210
+ * Safe to call with no source: it emits zeros and never constructs a context,
211
+ * which is what makes the state-driven (no audio) mode work.
212
+ */
213
+ export function useAudioAnalysis(
214
+ source: () => MediaStream | HTMLMediaElement | undefined,
215
+ options: AudioAnalysisOptions = {},
216
+ ): { bands: Accessor<number[]>; volume: Accessor<number> } {
217
+ const opts = { ...DEFAULTS, ...options };
218
+ const [bands, setBands] = createSignal<number[]>(new Array(resolveBandCount(opts.bands)).fill(0));
219
+ const [volume, setVolume] = createSignal(0);
220
+
221
+ createEffect(() => {
222
+ const src = source();
223
+ // Read INSIDE the effect, not from the outer `opts.bands` closure: when
224
+ // this is an accessor, calling it here is what makes the effect track it,
225
+ // so a later change reruns this whole setup (new analyser, right-sized
226
+ // arrays) instead of leaving the old bucket count wired up forever.
227
+ const bandCount = resolveBandCount(opts.bands);
228
+
229
+ // Reset to a correctly-sized zero array whenever the source or the band
230
+ // count changes, so a stale picture never lingers after the mic stops or
231
+ // the requested resolution changes.
232
+ setBands(new Array(bandCount).fill(0));
233
+ setVolume(0);
234
+
235
+ if (!src) return;
236
+
237
+ const ctx = getContext();
238
+ if (!ctx) return; // SSR, or a browser without Web Audio.
239
+ if (typeof requestAnimationFrame === 'undefined') return;
240
+
241
+ const stopResume = resumeOnGesture(ctx);
242
+
243
+ let node: AudioNode;
244
+ if (!isMediaElement(src)) {
245
+ let streamNode = streamSources.get(src);
246
+ if (!streamNode) {
247
+ streamNode = ctx.createMediaStreamSource(src);
248
+ streamSources.set(src, streamNode);
249
+ }
250
+ node = streamNode;
251
+ // Deliberately NOT connected to destination: that would echo the mic.
252
+ // Unlike the element path below, a stream source NEVER reaches
253
+ // destination, cached or not.
254
+ } else {
255
+ const el = src;
256
+ let elNode = elementSources.get(el);
257
+ if (!elNode) {
258
+ elNode = ctx.createMediaElementSource(el);
259
+ // Connect to destination exactly once, right here at creation, so the
260
+ // audio path does not depend on how many visualizers attach. Both
261
+ // analysers below are terminal side-taps: an AnalyserNode still
262
+ // receives data with nothing connected downstream of it, so neither
263
+ // one also connects to destination. If either did, N consumers on
264
+ // one element would sum to N times the amplitude.
265
+ elNode.connect(ctx.destination);
266
+ elementSources.set(el, elNode);
267
+ }
268
+ node = elNode;
269
+ }
270
+
271
+ // Two analysers off the same source node, not one: see BANDS_ANALYSER /
272
+ // VOLUME_ANALYSER above for why they cannot share a smoothingTimeConstant.
273
+ const bandsAnalyser = ctx.createAnalyser();
274
+ bandsAnalyser.fftSize = BANDS_ANALYSER.fftSize;
275
+ bandsAnalyser.smoothingTimeConstant = BANDS_ANALYSER.smoothingTimeConstant;
276
+
277
+ const volumeAnalyser = ctx.createAnalyser();
278
+ volumeAnalyser.fftSize = VOLUME_ANALYSER.fftSize;
279
+ volumeAnalyser.smoothingTimeConstant = VOLUME_ANALYSER.smoothingTimeConstant;
280
+ // Byte-scale rescale, volume analyser ONLY -- see VOLUME_ANALYSER above.
281
+ volumeAnalyser.minDecibels = VOLUME_ANALYSER.minDecibels;
282
+ volumeAnalyser.maxDecibels = VOLUME_ANALYSER.maxDecibels;
283
+
284
+ node.connect(bandsAnalyser);
285
+ node.connect(volumeAnalyser);
286
+
287
+ const freq = new Float32Array(bandsAnalyser.frequencyBinCount);
288
+ const bytes = new Uint8Array(volumeAnalyser.frequencyBinCount);
289
+
290
+ let raf = 0;
291
+ let last = 0;
292
+ const step = (now: number) => {
293
+ if (now - last >= opts.updateInterval) {
294
+ bandsAnalyser.getFloatFrequencyData(freq);
295
+ volumeAnalyser.getByteFrequencyData(bytes);
296
+ setBands(reduceToBands(freq, bandCount, opts.loPass, opts.hiPass));
297
+ setVolume(reduceToVolume(bytes));
298
+ last = now;
299
+ }
300
+ raf = requestAnimationFrame(step);
301
+ };
302
+ raf = requestAnimationFrame(step);
303
+
304
+ onCleanup(() => {
305
+ cancelAnimationFrame(raf);
306
+ stopResume();
307
+ bandsAnalyser.disconnect();
308
+ volumeAnalyser.disconnect();
309
+ // Both the element and stream source nodes above are cached and shared
310
+ // across every consumer of the same element/stream: another consumer
311
+ // may still be using this one, and it can never be recreated. Drop
312
+ // only this consumer's own taps into it (the two-argument form, once
313
+ // per analyser), never the no-argument node.disconnect() -- that would
314
+ // tear the source down for everyone still using it.
315
+ node.disconnect(bandsAnalyser);
316
+ node.disconnect(volumeAnalyser);
317
+ });
318
+ });
319
+
320
+ return { bands, volume };
321
+ }
@@ -0,0 +1,53 @@
1
+ import { createSignal, createEffect, onCleanup, createMemo, type Accessor } from 'solid-js';
2
+
3
+ /**
4
+ * One requestAnimationFrame loop emitting a monotonically increasing tick
5
+ * whenever `interval()` milliseconds have elapsed.
6
+ *
7
+ * RAF rather than setInterval: the browser parks it on hidden tabs, so an
8
+ * off-screen visualizer costs nothing. Upstream leaks a timer here.
9
+ *
10
+ * An interval of `Infinity` (or anything non-finite) parks the loop entirely
11
+ * and holds the tick at 0. The radial variant uses that while `thinking`,
12
+ * where a CSS spin drives the motion instead.
13
+ */
14
+ export function useSequencer(interval: () => number): Accessor<number> {
15
+ const [tick, setTick] = createSignal(0);
16
+
17
+ // Memoize the interval VALUE to isolate the effect from whatever else the
18
+ // interval getter might be transitively subscribed to (e.g. a props proxy
19
+ // that includes unrelated signals like `bands`). The memo only notifies
20
+ // downstream when the number itself changes, not when intermediate accessors
21
+ // that feed it change to the same final value. This is the same pattern as
22
+ // `uniformShapeKey` in `shader-canvas.tsx`.
23
+ const intervalMs = createMemo(() => interval());
24
+
25
+ createEffect(() => {
26
+ const ms = intervalMs();
27
+
28
+ // Reset on every interval change so a state transition restarts the
29
+ // sequence from its first frame rather than resuming mid-pattern.
30
+ // With the memo, this only fires on real changes (the interval VALUE),
31
+ // not on spurious re-runs from unrelated props churn.
32
+ setTick(0);
33
+
34
+ if (!Number.isFinite(ms) || ms <= 0) return;
35
+ if (typeof requestAnimationFrame === 'undefined') return;
36
+
37
+ let raf = 0;
38
+ let last = performance.now();
39
+
40
+ const step = (now: number) => {
41
+ if (now - last >= ms) {
42
+ setTick((t) => t + 1);
43
+ last = now;
44
+ }
45
+ raf = requestAnimationFrame(step);
46
+ };
47
+
48
+ raf = requestAnimationFrame(step);
49
+ onCleanup(() => cancelAnimationFrame(raf));
50
+ });
51
+
52
+ return tick;
53
+ }
@@ -8,6 +8,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}) {
8
8
  const mimeType = options.mimeType ?? 'audio/webm;codecs=opus';
9
9
  const [isRecording, setIsRecording] = createSignal(false);
10
10
  const [error, setError] = createSignal<string | null>(null);
11
+ const [stream, setStream] = createSignal<MediaStream | undefined>();
11
12
 
12
13
  let mediaRecorder: MediaRecorder | undefined;
13
14
  let chunks: Blob[] = [];
@@ -17,14 +18,16 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}) {
17
18
  setError(null);
18
19
  chunks = [];
19
20
  try {
20
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
21
- mediaRecorder = new MediaRecorder(stream, { mimeType });
21
+ const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
22
+ setStream(mediaStream);
23
+ mediaRecorder = new MediaRecorder(mediaStream, { mimeType });
22
24
  mediaRecorder.ondataavailable = (e) => {
23
25
  if (e.data.size > 0) chunks.push(e.data);
24
26
  };
25
27
  mediaRecorder.onstop = () => {
26
28
  const blob = new Blob(chunks, { type: mimeType });
27
- stream.getTracks().forEach((t) => t.stop());
29
+ mediaStream.getTracks().forEach((t) => t.stop());
30
+ setStream(undefined);
28
31
  setIsRecording(false);
29
32
  resolveBlob?.(blob);
30
33
  };
@@ -32,6 +35,15 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}) {
32
35
  setIsRecording(true);
33
36
  return new Promise<Blob>((resolve) => { resolveBlob = resolve; });
34
37
  } catch (err) {
38
+ // getUserMedia may have already succeeded (stream() is live) even
39
+ // though something after it threw, e.g. an unsupported mimeType
40
+ // rejected by `new MediaRecorder(...)`. Leaving that stream open would
41
+ // keep the microphone live with no indication anything is recording.
42
+ const liveStream = stream();
43
+ if (liveStream) {
44
+ liveStream.getTracks().forEach((t) => t.stop());
45
+ setStream(undefined);
46
+ }
35
47
  setError(err instanceof Error ? err.message : 'Microphone access denied');
36
48
  setIsRecording(false);
37
49
  throw err;
@@ -46,5 +58,5 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}) {
46
58
 
47
59
  onCleanup(() => stop());
48
60
 
49
- return { isRecording, error, start, stop };
61
+ return { isRecording, error, stream, start, stop };
50
62
  }