@ephia/dova-sdk 1.0.0

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 (247) hide show
  1. package/README.md +89 -0
  2. package/dist/EphiaBinding-BvRmlqqC.d.ts +36 -0
  3. package/dist/EphiaFloatingButton-CxiF86VW.d.ts +65 -0
  4. package/dist/EphiaTextarea-B4_CAVUg.d.ts +183 -0
  5. package/dist/NativeBinding-ChG0GeSz.d.ts +53 -0
  6. package/dist/TargetBinding-BKGQwUMc.d.ts +89 -0
  7. package/dist/TiptapBinding-B-agfV2H.d.ts +45 -0
  8. package/dist/Transport-zdeA4Pou.d.ts +63 -0
  9. package/dist/audio-state-kZ3KSvux.d.ts +39 -0
  10. package/dist/chunk-35AJK2IO.js +1 -0
  11. package/dist/chunk-35AJK2IO.js.map +1 -0
  12. package/dist/chunk-3LXZODL4.js +886 -0
  13. package/dist/chunk-3LXZODL4.js.map +1 -0
  14. package/dist/chunk-5IK5TLSK.js +67 -0
  15. package/dist/chunk-5IK5TLSK.js.map +1 -0
  16. package/dist/chunk-7E43RY75.js +9 -0
  17. package/dist/chunk-7E43RY75.js.map +1 -0
  18. package/dist/chunk-A5UEXJ5R.js +183 -0
  19. package/dist/chunk-A5UEXJ5R.js.map +1 -0
  20. package/dist/chunk-AEE554FT.js +51 -0
  21. package/dist/chunk-AEE554FT.js.map +1 -0
  22. package/dist/chunk-DIEWY3IT.js +1332 -0
  23. package/dist/chunk-DIEWY3IT.js.map +1 -0
  24. package/dist/chunk-EGIAN7FH.js +18 -0
  25. package/dist/chunk-EGIAN7FH.js.map +1 -0
  26. package/dist/chunk-EMOEAPVU.js +486 -0
  27. package/dist/chunk-EMOEAPVU.js.map +1 -0
  28. package/dist/chunk-IDC7FHIZ.js +40 -0
  29. package/dist/chunk-IDC7FHIZ.js.map +1 -0
  30. package/dist/chunk-ITJFN3VM.js +601 -0
  31. package/dist/chunk-ITJFN3VM.js.map +1 -0
  32. package/dist/chunk-K24GNU27.js +22 -0
  33. package/dist/chunk-K24GNU27.js.map +1 -0
  34. package/dist/chunk-LXMCRXXF.js +778 -0
  35. package/dist/chunk-LXMCRXXF.js.map +1 -0
  36. package/dist/chunk-MJCEOOLW.js +122 -0
  37. package/dist/chunk-MJCEOOLW.js.map +1 -0
  38. package/dist/chunk-N7U5M3VZ.js +33 -0
  39. package/dist/chunk-N7U5M3VZ.js.map +1 -0
  40. package/dist/chunk-PSYX674B.js +27 -0
  41. package/dist/chunk-PSYX674B.js.map +1 -0
  42. package/dist/chunk-RFQRV7ML.js +33 -0
  43. package/dist/chunk-RFQRV7ML.js.map +1 -0
  44. package/dist/chunk-THNHRV2B.js +18 -0
  45. package/dist/chunk-THNHRV2B.js.map +1 -0
  46. package/dist/chunk-VSLGR64U.js +62 -0
  47. package/dist/chunk-VSLGR64U.js.map +1 -0
  48. package/dist/chunk-W2ZP674X.js +346 -0
  49. package/dist/chunk-W2ZP674X.js.map +1 -0
  50. package/dist/chunk-YWZUMUYE.js +695 -0
  51. package/dist/chunk-YWZUMUYE.js.map +1 -0
  52. package/dist/client-options-Uo6jXO8k.d.ts +64 -0
  53. package/dist/connection-state-Bk33YprE.d.ts +32 -0
  54. package/dist/core/bindings/index.d.ts +24 -0
  55. package/dist/core/bindings/index.js +1025 -0
  56. package/dist/core/bindings/index.js.map +1 -0
  57. package/dist/core/index.d.ts +383 -0
  58. package/dist/core/index.js +1284 -0
  59. package/dist/core/index.js.map +1 -0
  60. package/dist/createEphiaClient-BhdZ183V.d.ts +69 -0
  61. package/dist/devices/speechmike/index.d.ts +148 -0
  62. package/dist/devices/speechmike/index.js +40 -0
  63. package/dist/devices/speechmike/index.js.map +1 -0
  64. package/dist/headless/index.d.ts +10 -0
  65. package/dist/headless/index.js +25 -0
  66. package/dist/headless/index.js.map +1 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +119 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/react/index.d.ts +38 -0
  71. package/dist/react/index.js +70 -0
  72. package/dist/react/index.js.map +1 -0
  73. package/dist/rich-editor/index.d.ts +46 -0
  74. package/dist/rich-editor/index.js +13 -0
  75. package/dist/rich-editor/index.js.map +1 -0
  76. package/dist/schema-B2ycPlNB.d.ts +87 -0
  77. package/dist/session-APaXR48R.d.ts +12 -0
  78. package/dist/shared/index.d.ts +16 -0
  79. package/dist/shared/index.js +30 -0
  80. package/dist/shared/index.js.map +1 -0
  81. package/dist/style.css +1093 -0
  82. package/dist/testing/index.d.ts +84 -0
  83. package/dist/testing/index.js +36 -0
  84. package/dist/testing/index.js.map +1 -0
  85. package/dist/types-D5SXPSwR.d.ts +32 -0
  86. package/dist/ui/index.d.ts +30 -0
  87. package/dist/ui/index.js +34 -0
  88. package/dist/ui/index.js.map +1 -0
  89. package/dist/useEphiaSpeechMike-CjD7DWnh.d.ts +64 -0
  90. package/package.json +110 -0
  91. package/src/core/audio/audio-worklet-source.ts +30 -0
  92. package/src/core/audio/index.ts +3 -0
  93. package/src/core/audio/voice-level-meter.test.ts +27 -0
  94. package/src/core/audio/voice-level-meter.ts +270 -0
  95. package/src/core/bindings/EphiaBinding.ts +41 -0
  96. package/src/core/bindings/SegmentBindingBridge.test.ts +422 -0
  97. package/src/core/bindings/SegmentBindingBridge.ts +377 -0
  98. package/src/core/bindings/TargetBinding.ts +142 -0
  99. package/src/core/bindings/adapters/NativeAdapter.test.ts +85 -0
  100. package/src/core/bindings/adapters/NativeAdapter.ts +216 -0
  101. package/src/core/bindings/adapters/ProseMirrorAdapter.ts +231 -0
  102. package/src/core/bindings/adapters/index.ts +2 -0
  103. package/src/core/bindings/binding-factory.ts +78 -0
  104. package/src/core/bindings/detect-editor-type.ts +87 -0
  105. package/src/core/bindings/index.ts +13 -0
  106. package/src/core/bindings/insertion-boundary.test.ts +38 -0
  107. package/src/core/bindings/insertion-boundary.ts +26 -0
  108. package/src/core/bindings/native/NativeBinding.test.ts +277 -0
  109. package/src/core/bindings/native/NativeBinding.ts +239 -0
  110. package/src/core/bindings/resolver.ts +18 -0
  111. package/src/core/bindings/targets/codemirror.binding.ts +293 -0
  112. package/src/core/bindings/targets/contenteditable.binding.ts +452 -0
  113. package/src/core/bindings/targets/index.ts +10 -0
  114. package/src/core/bindings/targets/monaco.binding.ts +315 -0
  115. package/src/core/bindings/targets/tiptap.binding.test.ts +417 -0
  116. package/src/core/bindings/targets/tiptap.binding.ts +1192 -0
  117. package/src/core/bindings/tiptap/TiptapBinding.test.ts +63 -0
  118. package/src/core/bindings/tiptap/TiptapBinding.ts +464 -0
  119. package/src/core/bindings/types.ts +41 -0
  120. package/src/core/client/EphiaAudioClient.ts +654 -0
  121. package/src/core/client/audio-capture.ts +263 -0
  122. package/src/core/client/client-options.ts +39 -0
  123. package/src/core/client/client-state.ts +18 -0
  124. package/src/core/client/constants.ts +23 -0
  125. package/src/core/client/session-api.ts +415 -0
  126. package/src/core/connection/connection-state.ts +78 -0
  127. package/src/core/connection/index.ts +6 -0
  128. package/src/core/index.ts +47 -0
  129. package/src/core/operations/textToDocumentOperations.test.ts +69 -0
  130. package/src/core/operations/textToDocumentOperations.ts +92 -0
  131. package/src/core/runtime/DictationRuntime.test.ts +578 -0
  132. package/src/core/runtime/DictationRuntime.ts +434 -0
  133. package/src/core/runtime/TranscriptApplier.test.ts +355 -0
  134. package/src/core/runtime/TranscriptApplier.ts +229 -0
  135. package/src/core/runtime/index.ts +18 -0
  136. package/src/core/session/index.ts +2 -0
  137. package/src/core/session/session-machine.test.ts +16 -0
  138. package/src/core/session/session-machine.ts +59 -0
  139. package/src/core/targets/EditorContextCollector.ts +71 -0
  140. package/src/core/targets/TargetManager.test.ts +194 -0
  141. package/src/core/targets/TargetManager.ts +194 -0
  142. package/src/core/targets/index.ts +10 -0
  143. package/src/core/text-processing/index.ts +11 -0
  144. package/src/core/text-processing/overlap.test.ts +35 -0
  145. package/src/core/text-processing/overlap.ts +101 -0
  146. package/src/core/text-processing/voice-formatting.normalizer.test.ts +132 -0
  147. package/src/core/text-processing/voice-formatting.normalizer.ts +284 -0
  148. package/src/core/transcript/client-transcript.reducer.ts +366 -0
  149. package/src/core/transcript/client-transcript.state.ts +25 -0
  150. package/src/core/transcript/index.ts +19 -0
  151. package/src/core/transcript/transcript.assembler.test.ts +205 -0
  152. package/src/core/transcript/transcript.assembler.ts +152 -0
  153. package/src/core/transcript/transcript.reducer.test.ts +199 -0
  154. package/src/core/transcript/transcript.reducer.ts +771 -0
  155. package/src/core/transcript/transcript.state.ts +123 -0
  156. package/src/core/transport/LiveKitTransport.publish.test.ts +226 -0
  157. package/src/core/transport/LiveKitTransport.ts +459 -0
  158. package/src/core/transport/MockTransport.ts +231 -0
  159. package/src/core/transport/Transport.ts +82 -0
  160. package/src/debug/sdk-debug-collector.ts +79 -0
  161. package/src/devices/index.ts +2 -0
  162. package/src/devices/speechmike/__tests__/EphiaSpeechMikeProvider.test.tsx +99 -0
  163. package/src/devices/speechmike/__tests__/speechmike-audio-resolver.test.ts +96 -0
  164. package/src/devices/speechmike/__tests__/speechmike-button-router.test.ts +66 -0
  165. package/src/devices/speechmike/__tests__/speechmike-device-manager.test.ts +201 -0
  166. package/src/devices/speechmike/__tests__/speechmike-led-controller.test.ts +68 -0
  167. package/src/devices/speechmike/browser.ts +80 -0
  168. package/src/devices/speechmike/constants.ts +74 -0
  169. package/src/devices/speechmike/dictation-support-loader.ts +81 -0
  170. package/src/devices/speechmike/index.ts +11 -0
  171. package/src/devices/speechmike/react/EphiaSpeechMikeContext.ts +34 -0
  172. package/src/devices/speechmike/react/EphiaSpeechMikeProvider.tsx +287 -0
  173. package/src/devices/speechmike/react/useEphiaSpeechMike.ts +26 -0
  174. package/src/devices/speechmike/speechmike-audio-resolver.ts +58 -0
  175. package/src/devices/speechmike/speechmike-button-router.ts +73 -0
  176. package/src/devices/speechmike/speechmike-device-manager.ts +461 -0
  177. package/src/devices/speechmike/speechmike-led-controller.ts +78 -0
  178. package/src/devices/speechmike/types.ts +96 -0
  179. package/src/dictation_support.d.ts +31 -0
  180. package/src/global.d.ts +10 -0
  181. package/src/headless/createEphiaClient.ts +220 -0
  182. package/src/headless/index.ts +18 -0
  183. package/src/index.ts +89 -0
  184. package/src/react/EphiaAuto.tsx +87 -0
  185. package/src/react/components/EphiaDictationButton.tsx +88 -0
  186. package/src/react/components/EphiaStatusBar.tsx +59 -0
  187. package/src/react/components/EphiaTextarea.tsx +295 -0
  188. package/src/react/ephia-react.css +318 -0
  189. package/src/react/hooks/targets/index.ts +3 -0
  190. package/src/react/hooks/targets/useEphiaCodemirror.ts +35 -0
  191. package/src/react/hooks/targets/useEphiaMonaco.ts +35 -0
  192. package/src/react/hooks/targets/useEphiaTiptap.ts +23 -0
  193. package/src/react/hooks/useEphia.lifecycle.test.tsx +389 -0
  194. package/src/react/hooks/useEphia.ts +367 -0
  195. package/src/react/hooks/useEphiaDiscardTarget.ts +53 -0
  196. package/src/react/hooks/useEphiaServerEvent.ts +33 -0
  197. package/src/react/hooks/useEphiaTarget.ts +47 -0
  198. package/src/react/hooks/useEphiaTranscript.ts +22 -0
  199. package/src/react/index.ts +58 -0
  200. package/src/react/provider/EphiaContext.ts +63 -0
  201. package/src/react/provider/EphiaInternalContext.ts +32 -0
  202. package/src/react/provider/EphiaProvider.tsx +373 -0
  203. package/src/react/registry/binding-factory.ts +7 -0
  204. package/src/react/registry/detect-editor-type.ts +2 -0
  205. package/src/react/registry/events.ts +37 -0
  206. package/src/react/registry/registries/CodeMirrorInstanceRegistry.ts +24 -0
  207. package/src/react/registry/registries/MonacoInstanceRegistry.ts +23 -0
  208. package/src/react/registry/registries/TargetRegistry.ts +327 -0
  209. package/src/react/registry/registries/TiptapInstanceRegistry.ts +43 -0
  210. package/src/react/registry/registries/index.ts +5 -0
  211. package/src/react/store/create-ephia-store.ts +36 -0
  212. package/src/react/store/types.ts +41 -0
  213. package/src/react/utils/flash-range.ts +24 -0
  214. package/src/react/utils/index.ts +1 -0
  215. package/src/rich-editor/adapters/tiptap.test.ts +86 -0
  216. package/src/rich-editor/adapters/tiptap.ts +23 -0
  217. package/src/rich-editor/index.ts +3 -0
  218. package/src/rich-editor/types.ts +24 -0
  219. package/src/rich-editor/use-ephia-rich-editor.test.tsx +202 -0
  220. package/src/rich-editor/use-ephia-rich-editor.ts +47 -0
  221. package/src/shared/config/endpoint.test.ts +45 -0
  222. package/src/shared/config/endpoint.ts +39 -0
  223. package/src/shared/config/schema.ts +32 -0
  224. package/src/shared/effective-text.ts +13 -0
  225. package/src/shared/errors/EphiaSdkError.ts +54 -0
  226. package/src/shared/errors/messages.ts +40 -0
  227. package/src/shared/index.ts +27 -0
  228. package/src/shared/state/audio-state.ts +45 -0
  229. package/src/shared/state/index.ts +2 -0
  230. package/src/shared/store/document-store.ts +32 -0
  231. package/src/shared/store/index.ts +2 -0
  232. package/src/shared/types/editors.ts +28 -0
  233. package/src/shared/types/session.ts +12 -0
  234. package/src/style.css +2 -0
  235. package/src/testing/index.tsx +60 -0
  236. package/src/ui/assets/ephia-logo.svg +4 -0
  237. package/src/ui/components/EphiaLogo.tsx +77 -0
  238. package/src/ui/index.ts +24 -0
  239. package/src/ui/primitives/Button.tsx +53 -0
  240. package/src/ui/primitives/Spinner.tsx +21 -0
  241. package/src/ui/primitives/index.ts +5 -0
  242. package/src/ui/recorder/EphiaFloatingButton.tsx +489 -0
  243. package/src/ui/recorder/MinimalProcessingBars.tsx +122 -0
  244. package/src/ui/recorder/StandardIntensityVisualizer.tsx +148 -0
  245. package/src/ui/recorder/appearance.ts +9 -0
  246. package/src/ui/recorder/index.ts +8 -0
  247. package/src/ui/theme.css +775 -0
@@ -0,0 +1,80 @@
1
+ export type HidDeviceLike = {
2
+ productName?: string;
3
+ manufacturerName?: string;
4
+ vendorId?: number;
5
+ productId?: number;
6
+ };
7
+
8
+ export type HidConnectionEventLike = Event & {
9
+ device?: HidDeviceLike;
10
+ };
11
+
12
+ export type HidLike = {
13
+ getDevices: () => Promise<HidDeviceLike[]>;
14
+ addEventListener: (
15
+ type: "connect" | "disconnect",
16
+ listener: (event: Event) => void,
17
+ ) => void;
18
+ removeEventListener: (
19
+ type: "connect" | "disconnect",
20
+ listener: (event: Event) => void,
21
+ ) => void;
22
+ };
23
+
24
+ type NavigatorWithHid = Navigator & {
25
+ hid?: HidLike;
26
+ };
27
+
28
+ export function isBrowser(): boolean {
29
+ return typeof window !== "undefined" && typeof navigator !== "undefined";
30
+ }
31
+
32
+ export function getNavigatorHid(): HidLike | null {
33
+ if (!isBrowser()) return null;
34
+ return (navigator as NavigatorWithHid).hid ?? null;
35
+ }
36
+
37
+ export function isWebHidAvailable(): boolean {
38
+ return getNavigatorHid() !== null;
39
+ }
40
+
41
+ export function createTimeoutError(operation: string, timeoutMs: number): Error {
42
+ return new Error(`${operation} timeout after ${timeoutMs}ms`);
43
+ }
44
+
45
+ export function withTimeout<T>(
46
+ promise: Promise<T>,
47
+ timeoutMs: number,
48
+ operation: string,
49
+ ): Promise<T> {
50
+ if (typeof window === "undefined") return promise;
51
+
52
+ let timeoutId: number | null = null;
53
+
54
+ return Promise.race([
55
+ promise.finally(() => {
56
+ if (timeoutId !== null) window.clearTimeout(timeoutId);
57
+ }),
58
+ new Promise<T>((_, reject) => {
59
+ timeoutId = window.setTimeout(() => {
60
+ reject(createTimeoutError(operation, timeoutMs));
61
+ }, timeoutMs);
62
+ }),
63
+ ]);
64
+ }
65
+
66
+ export function matchesSpeechMikeIdentity(device: {
67
+ productName?: string;
68
+ manufacturerName?: string;
69
+ vendorId?: number;
70
+ productId?: number;
71
+ }): boolean {
72
+ const productName = device.productName?.toLowerCase() ?? "";
73
+ const manufacturerName = device.manufacturerName?.toLowerCase() ?? "";
74
+
75
+ return (
76
+ productName.includes("speechmike") ||
77
+ productName.includes("philips") ||
78
+ manufacturerName.includes("philips")
79
+ );
80
+ }
@@ -0,0 +1,74 @@
1
+ import type { EphiaSpeechMikeAction, EphiaSpeechMikeLedIntent } from "./types";
2
+
3
+ export const SPEECHMIKE_VENDOR_IDS = {
4
+ PHILIPS: 0x0911,
5
+ } as const;
6
+
7
+ export const SPEECHMIKE_CONFIG = {
8
+ BUTTON_DEBOUNCE_MS: 150,
9
+ WATCHDOG_INTERVAL_MS: 10_000,
10
+ DEVICE_OPERATION_TIMEOUT_MS: 2_000,
11
+ REQUEST_DEVICE_TIMEOUT_MS: 15_000,
12
+ LED_OPERATION_TIMEOUT_MS: 500,
13
+ } as const;
14
+
15
+ export const LED_MODE = {
16
+ OFF: 0,
17
+ ON: 1,
18
+ ON_BRIGHT: 3,
19
+ } as const;
20
+
21
+ export const DEFAULT_BUTTON_MAPPING: Record<string, EphiaSpeechMikeAction> = {
22
+ RECORD: "record.toggle",
23
+ PLAY: "play.toggle",
24
+ FORWARD: "seek.forward",
25
+ REWIND: "seek.backward",
26
+
27
+ F1: "audio.discard",
28
+ F1_A: "audio.discard",
29
+ F2: "audio.discard",
30
+ F2_B: "audio.discard",
31
+
32
+ F3: "audio.submit",
33
+ F3_C: "audio.submit",
34
+ F4: "audio.submit",
35
+ F4_D: "audio.submit",
36
+
37
+ INS: "agent.open",
38
+ OVR: "exam.new",
39
+ INS_OVR: "exam.new",
40
+
41
+ EOL: "resources.toggle",
42
+ EOL_PRIO: "resources.toggle",
43
+ };
44
+
45
+ const ALL_LEDS_OFF: Record<number, number> = {
46
+ 0: 0,
47
+ 1: 0,
48
+ 2: 0,
49
+ 3: 0,
50
+ 4: 0,
51
+ 5: 0,
52
+ 6: 0,
53
+ 7: 0,
54
+ 8: 0,
55
+ 9: 0,
56
+ };
57
+
58
+ export const DEFAULT_LED_INTENTS: Record<EphiaSpeechMikeLedIntent, Record<number, number>> = {
59
+ off: { ...ALL_LEDS_OFF },
60
+ idle: { ...ALL_LEDS_OFF, 2: LED_MODE.ON_BRIGHT },
61
+ recording: { ...ALL_LEDS_OFF, 1: LED_MODE.ON_BRIGHT },
62
+ processing: { ...ALL_LEDS_OFF, 2: LED_MODE.ON, 4: LED_MODE.ON },
63
+ audio_available: {
64
+ ...ALL_LEDS_OFF,
65
+ 6: LED_MODE.ON_BRIGHT,
66
+ 7: LED_MODE.ON_BRIGHT,
67
+ 8: LED_MODE.ON_BRIGHT,
68
+ 9: LED_MODE.ON_BRIGHT,
69
+ },
70
+ success: { ...ALL_LEDS_OFF, 2: 2 },
71
+ error: { ...ALL_LEDS_OFF, 3: 2 },
72
+ cancelled: { ...ALL_LEDS_OFF, 3: LED_MODE.ON_BRIGHT },
73
+ };
74
+
@@ -0,0 +1,81 @@
1
+ export type DictationDevice = unknown;
2
+
3
+ export type DictationButtonCallback = (
4
+ device: DictationDevice,
5
+ bitMask: number,
6
+ ) => void;
7
+
8
+ export type DictationDeviceCallback = (device: DictationDevice) => void;
9
+
10
+ export type DictationMotionCallback = (
11
+ device: DictationDevice,
12
+ motion: number,
13
+ ) => void;
14
+
15
+ export type DictationDeviceManagerLike = {
16
+ init?: () => Promise<void>;
17
+ requestDevice: () => Promise<DictationDevice[]>;
18
+ getDeviceInfo?: (device: DictationDevice) => Promise<unknown>;
19
+ getDevices?: () => DictationDevice[];
20
+
21
+ addButtonEventListener?: (cb: DictationButtonCallback) => void;
22
+ removeButtonEventListener?: (cb: DictationButtonCallback) => void;
23
+
24
+ addDeviceConnectedEventListener?: (cb: DictationDeviceCallback) => void;
25
+ removeDeviceConnectedEventListener?: (cb: DictationDeviceCallback) => void;
26
+
27
+ addDeviceDisconnectedEventListener?: (cb: DictationDeviceCallback) => void;
28
+ removeDeviceDisconnectedEventListener?: (cb: DictationDeviceCallback) => void;
29
+
30
+ addMotionEventListener?: (cb: DictationMotionCallback) => void;
31
+ removeMotionEventListener?: (cb: DictationMotionCallback) => void;
32
+
33
+ disconnect?: () => void;
34
+ };
35
+
36
+ export type DictationSupportEnum = Record<number, string> & Record<string, number>;
37
+
38
+ export type DictationSupportRuntime = {
39
+ DictationDeviceManager: new () => DictationDeviceManagerLike;
40
+ ButtonEvent: DictationSupportEnum;
41
+ MotionEvent?: DictationSupportEnum;
42
+ };
43
+
44
+ let runtimePromise: Promise<DictationSupportRuntime> | null = null;
45
+
46
+ type DictationSupportModule = {
47
+ DictationDeviceManager?: new () => DictationDeviceManagerLike;
48
+ ButtonEvent?: DictationSupportEnum;
49
+ MotionEvent?: DictationSupportEnum;
50
+ };
51
+
52
+ export function resetDictationSupportLoaderForTests(): void {
53
+ runtimePromise = null;
54
+ }
55
+
56
+ export function loadDictationSupport(): Promise<DictationSupportRuntime> {
57
+ if (runtimePromise) return runtimePromise;
58
+
59
+ const specifier = "dictation_support";
60
+ runtimePromise = import(/* @vite-ignore */ specifier)
61
+ .then((mod: DictationSupportModule) => {
62
+ if (!mod.DictationDeviceManager || !mod.ButtonEvent) {
63
+ throw new Error("dictation_support exports are incomplete");
64
+ }
65
+
66
+ return {
67
+ DictationDeviceManager: mod.DictationDeviceManager,
68
+ ButtonEvent: mod.ButtonEvent,
69
+ MotionEvent: mod.MotionEvent,
70
+ };
71
+ })
72
+ .catch((error: unknown) => {
73
+ runtimePromise = null;
74
+ throw Object.assign(new Error("dictation_support unavailable"), {
75
+ code: "speechmike.driver_missing",
76
+ cause: error,
77
+ });
78
+ });
79
+
80
+ return runtimePromise;
81
+ }
@@ -0,0 +1,11 @@
1
+ export * from "./types";
2
+ export * from "./constants";
3
+ export * from "./dictation-support-loader";
4
+ export * from "./speechmike-audio-resolver";
5
+ export * from "./speechmike-button-router";
6
+ export * from "./speechmike-led-controller";
7
+ export * from "./speechmike-device-manager";
8
+ export * from "./react/EphiaSpeechMikeProvider";
9
+ export * from "./react/EphiaSpeechMikeContext";
10
+ export * from "./react/useEphiaSpeechMike";
11
+
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { createContext } from "react";
4
+ import type {
5
+ EphiaSpeechMikeAction,
6
+ EphiaSpeechMikeLedIntent,
7
+ EphiaSpeechMikeState,
8
+ } from "../types";
9
+
10
+ export type EphiaSpeechMikeContextValue = EphiaSpeechMikeState & {
11
+ isConnected: boolean;
12
+ isReady: boolean;
13
+ requestAuthorization: () => Promise<void>;
14
+ reconnect: () => Promise<void>;
15
+ setLedIntent: (intent: EphiaSpeechMikeLedIntent) => void;
16
+ setButtonMapping: (mapping: Partial<Record<string, EphiaSpeechMikeAction>>) => void;
17
+ };
18
+
19
+ export const FALLBACK_EPHIA_SPEECHMIKE_STATE: EphiaSpeechMikeState = {
20
+ status: { hid: "unsupported", audio: "idle" },
21
+ device: null,
22
+ deviceInfo: null,
23
+ physicallyPresent: false,
24
+ audioInputDeviceId: null,
25
+ audioInputDeviceLabel: null,
26
+ pressedButtons: new Set<string>(),
27
+ lastButton: null,
28
+ lastAction: null,
29
+ error: null,
30
+ };
31
+
32
+ export const EphiaSpeechMikeContext =
33
+ createContext<EphiaSpeechMikeContextValue | null>(null);
34
+
@@ -0,0 +1,287 @@
1
+ "use client";
2
+
3
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { useEphiaInternal } from "../../../react/provider/EphiaInternalContext";
5
+ import { SpeechMikeDeviceManager } from "../speechmike-device-manager";
6
+ import { SpeechMikeLedController } from "../speechmike-led-controller";
7
+ import { resolveSpeechMikeAudioInput } from "../speechmike-audio-resolver";
8
+ import {
9
+ EphiaSpeechMikeContext,
10
+ type EphiaSpeechMikeContextValue,
11
+ } from "./EphiaSpeechMikeContext";
12
+ import type {
13
+ EphiaSpeechMikeAction,
14
+ EphiaSpeechMikeAudioStatus,
15
+ EphiaSpeechMikeError,
16
+ EphiaSpeechMikeLedIntent,
17
+ EphiaSpeechMikeState,
18
+ } from "../types";
19
+
20
+ export type EphiaSpeechMikeProviderProps = {
21
+ children: React.ReactNode;
22
+ enabled?: boolean;
23
+ autoConnect?: boolean;
24
+ preferAudioInput?: boolean;
25
+ requestAudioPermissionOnInit?: boolean;
26
+ ledSync?: boolean;
27
+ initialLedIntent?: EphiaSpeechMikeLedIntent;
28
+ buttonMapping?: Partial<Record<string, EphiaSpeechMikeAction>>;
29
+ onAction?: (action: EphiaSpeechMikeAction) => void;
30
+ onError?: (error: EphiaSpeechMikeError) => void;
31
+ onStatusChange?: (state: EphiaSpeechMikeState) => void;
32
+ };
33
+
34
+ const INITIAL_STATE: EphiaSpeechMikeState = {
35
+ status: { hid: "idle", audio: "idle" },
36
+ device: null,
37
+ deviceInfo: null,
38
+ physicallyPresent: false,
39
+ audioInputDeviceId: null,
40
+ audioInputDeviceLabel: null,
41
+ pressedButtons: new Set<string>(),
42
+ lastButton: null,
43
+ lastAction: null,
44
+ error: null,
45
+ };
46
+
47
+ type StatePatch =
48
+ | Partial<EphiaSpeechMikeState>
49
+ | ((prev: EphiaSpeechMikeState) => EphiaSpeechMikeState);
50
+
51
+ export function EphiaSpeechMikeProvider({
52
+ children,
53
+ enabled = true,
54
+ autoConnect = true,
55
+ preferAudioInput = true,
56
+ requestAudioPermissionOnInit = false,
57
+ ledSync = true,
58
+ initialLedIntent = "idle",
59
+ buttonMapping,
60
+ onAction,
61
+ onError,
62
+ onStatusChange,
63
+ }: EphiaSpeechMikeProviderProps): React.ReactElement {
64
+ const internal = useEphiaInternal();
65
+ const managerRef = useRef<SpeechMikeDeviceManager | null>(null);
66
+ const ledRef = useRef<SpeechMikeLedController | null>(null);
67
+ const onActionRef = useRef(onAction);
68
+ const onErrorRef = useRef(onError);
69
+ const onStatusChangeRef = useRef(onStatusChange);
70
+ const ledSyncRef = useRef(ledSync);
71
+ const initialLedIntentRef = useRef(initialLedIntent);
72
+ const buttonMappingRef = useRef(buttonMapping);
73
+
74
+ const [state, setState] = useState<EphiaSpeechMikeState>(INITIAL_STATE);
75
+
76
+ onActionRef.current = onAction;
77
+ onErrorRef.current = onError;
78
+ onStatusChangeRef.current = onStatusChange;
79
+ ledSyncRef.current = ledSync;
80
+ initialLedIntentRef.current = initialLedIntent;
81
+ buttonMappingRef.current = buttonMapping;
82
+
83
+ const patchState = useCallback((patch: StatePatch): void => {
84
+ setState((prev) => {
85
+ const next =
86
+ typeof patch === "function"
87
+ ? patch(prev)
88
+ : {
89
+ ...prev,
90
+ ...patch,
91
+ };
92
+ onStatusChangeRef.current?.(next);
93
+ return next;
94
+ });
95
+ }, []);
96
+
97
+ const setAudioStatus = useCallback(
98
+ (audio: EphiaSpeechMikeAudioStatus): void => {
99
+ patchState((prev) => ({
100
+ ...prev,
101
+ status: { ...prev.status, audio },
102
+ }));
103
+ },
104
+ [patchState],
105
+ );
106
+
107
+ const resolveAndApplyAudioInput = useCallback(async (): Promise<void> => {
108
+ if (!preferAudioInput) return;
109
+
110
+ setAudioStatus("resolving");
111
+
112
+ try {
113
+ const match = await resolveSpeechMikeAudioInput({
114
+ requestPermission: requestAudioPermissionOnInit,
115
+ });
116
+
117
+ if (!match) {
118
+ patchState((prev) => ({
119
+ ...prev,
120
+ status: { ...prev.status, audio: "not_found" },
121
+ audioInputDeviceId: null,
122
+ audioInputDeviceLabel: null,
123
+ }));
124
+ return;
125
+ }
126
+
127
+ internal.clientRef.current?.setPreferredAudioInputDeviceId(match.deviceId);
128
+ patchState((prev) => ({
129
+ ...prev,
130
+ status: { ...prev.status, audio: "ready" },
131
+ audioInputDeviceId: match.deviceId,
132
+ audioInputDeviceLabel: match.label,
133
+ error: null,
134
+ }));
135
+ } catch (error) {
136
+ const err = {
137
+ code: "speechmike.audio_resolve_failed",
138
+ message: error instanceof Error ? error.message : String(error),
139
+ };
140
+ patchState((prev) => ({
141
+ ...prev,
142
+ status: { ...prev.status, audio: "error" },
143
+ error: err,
144
+ }));
145
+ onErrorRef.current?.(err);
146
+ }
147
+ }, [
148
+ internal.clientRef,
149
+ patchState,
150
+ preferAudioInput,
151
+ requestAudioPermissionOnInit,
152
+ setAudioStatus,
153
+ ]);
154
+
155
+ useEffect(() => {
156
+ if (!enabled) {
157
+ patchState(INITIAL_STATE);
158
+ return;
159
+ }
160
+
161
+ const manager = new SpeechMikeDeviceManager({
162
+ buttonMapping: buttonMappingRef.current,
163
+ });
164
+ const led = new SpeechMikeLedController(() => manager.getCurrentDevice());
165
+ managerRef.current = manager;
166
+ ledRef.current = led;
167
+
168
+ const unsubs = [
169
+ manager.on("status", (hidStatus) => {
170
+ patchState((prev) => ({
171
+ ...prev,
172
+ status: { ...prev.status, hid: hidStatus },
173
+ }));
174
+ }),
175
+ manager.on("presence", (physicallyPresent) => {
176
+ patchState({ physicallyPresent });
177
+ }),
178
+ manager.on("deviceInfo", (deviceInfo) => {
179
+ patchState({ deviceInfo });
180
+ }),
181
+ manager.on("device", (device) => {
182
+ patchState({ device });
183
+ if (device && ledSyncRef.current) {
184
+ led.setIntent(initialLedIntentRef.current);
185
+ }
186
+ if (device && preferAudioInput) {
187
+ void resolveAndApplyAudioInput();
188
+ }
189
+ }),
190
+ manager.on("button", ({ buttons, actions }) => {
191
+ const buttonList = [...buttons];
192
+ const lastButton = buttonList[buttonList.length - 1] ?? null;
193
+ const lastAction = actions[actions.length - 1] ?? null;
194
+
195
+ patchState({
196
+ pressedButtons: new Set(buttons),
197
+ lastButton,
198
+ lastAction,
199
+ });
200
+
201
+ for (const action of actions) {
202
+ onActionRef.current?.(action);
203
+ }
204
+ }),
205
+ manager.on("error", (error) => {
206
+ patchState({ error });
207
+ onErrorRef.current?.(error);
208
+ }),
209
+ ];
210
+
211
+ if (autoConnect) {
212
+ manager.initialize().catch((error: unknown) => {
213
+ const err = {
214
+ code: "speechmike.initialize_failed",
215
+ message: error instanceof Error ? error.message : String(error),
216
+ };
217
+ patchState({ error: err });
218
+ onErrorRef.current?.(err);
219
+ });
220
+ }
221
+
222
+ return () => {
223
+ unsubs.forEach((unsub) => unsub());
224
+ led.clear();
225
+ manager.dispose();
226
+ managerRef.current = null;
227
+ ledRef.current = null;
228
+ };
229
+ }, [
230
+ autoConnect,
231
+ enabled,
232
+ patchState,
233
+ preferAudioInput,
234
+ resolveAndApplyAudioInput,
235
+ ]);
236
+
237
+ useEffect(() => {
238
+ managerRef.current?.setButtonMapping(buttonMapping ?? {});
239
+ }, [buttonMapping]);
240
+
241
+ useEffect(() => {
242
+ if (!enabled || !preferAudioInput) return;
243
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.addEventListener) return;
244
+
245
+ const handler = (): void => {
246
+ void resolveAndApplyAudioInput();
247
+ };
248
+
249
+ navigator.mediaDevices.addEventListener("devicechange", handler);
250
+ return () => {
251
+ navigator.mediaDevices.removeEventListener("devicechange", handler);
252
+ };
253
+ }, [enabled, preferAudioInput, resolveAndApplyAudioInput]);
254
+
255
+ const value = useMemo<EphiaSpeechMikeContextValue>(
256
+ () => ({
257
+ ...state,
258
+ isConnected: state.status.hid === "connected",
259
+ isReady: state.status.hid === "connected" && state.status.audio === "ready",
260
+ requestAuthorization: async () => {
261
+ if (!enabled) {
262
+ throw new Error("EphiaSpeechMikeProvider is disabled");
263
+ }
264
+ await managerRef.current?.requestAuthorization();
265
+ await resolveAndApplyAudioInput();
266
+ },
267
+ reconnect: async () => {
268
+ if (!enabled) return;
269
+ await managerRef.current?.reconnect();
270
+ await resolveAndApplyAudioInput();
271
+ },
272
+ setLedIntent: (intent) => {
273
+ ledRef.current?.setIntent(intent);
274
+ },
275
+ setButtonMapping: (mapping) => {
276
+ managerRef.current?.setButtonMapping(mapping);
277
+ },
278
+ }),
279
+ [enabled, resolveAndApplyAudioInput, state],
280
+ );
281
+
282
+ return (
283
+ <EphiaSpeechMikeContext.Provider value={value}>
284
+ {children}
285
+ </EphiaSpeechMikeContext.Provider>
286
+ );
287
+ }
@@ -0,0 +1,26 @@
1
+ "use client";
2
+
3
+ import { useContext } from "react";
4
+ import {
5
+ EphiaSpeechMikeContext,
6
+ FALLBACK_EPHIA_SPEECHMIKE_STATE,
7
+ type EphiaSpeechMikeContextValue,
8
+ } from "./EphiaSpeechMikeContext";
9
+
10
+ export function useEphiaSpeechMike(): EphiaSpeechMikeContextValue {
11
+ const ctx = useContext(EphiaSpeechMikeContext);
12
+ if (ctx) return ctx;
13
+
14
+ return {
15
+ ...FALLBACK_EPHIA_SPEECHMIKE_STATE,
16
+ isConnected: false,
17
+ isReady: false,
18
+ requestAuthorization: async () => {
19
+ throw new Error("EphiaSpeechMikeProvider not mounted");
20
+ },
21
+ reconnect: async () => {},
22
+ setLedIntent: () => {},
23
+ setButtonMapping: () => {},
24
+ };
25
+ }
26
+
@@ -0,0 +1,58 @@
1
+ export type SpeechMikeAudioMatch = {
2
+ deviceId: string;
3
+ label: string;
4
+ score: number;
5
+ raw: MediaDeviceInfo;
6
+ };
7
+
8
+ export function scoreSpeechMikeAudioDevice(device: MediaDeviceInfo): number {
9
+ if (device.kind !== "audioinput") return 0;
10
+
11
+ const label = (device.label ?? "").toLowerCase();
12
+ let score = 0;
13
+
14
+ if (label.includes("speechmike")) score += 100;
15
+ if (label.includes("philips")) score += 60;
16
+ if (label.includes("dictation")) score += 40;
17
+ if (label.includes("microphone")) score += 10;
18
+
19
+ return score;
20
+ }
21
+
22
+ export async function listAudioInputDevices(): Promise<MediaDeviceInfo[]> {
23
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.enumerateDevices) {
24
+ return [];
25
+ }
26
+
27
+ const devices = await navigator.mediaDevices.enumerateDevices();
28
+ return devices.filter((device) => device.kind === "audioinput");
29
+ }
30
+
31
+ export async function resolveSpeechMikeAudioInput(options?: {
32
+ requestPermission?: boolean;
33
+ }): Promise<SpeechMikeAudioMatch | null> {
34
+ if (typeof navigator === "undefined" || !navigator.mediaDevices) return null;
35
+
36
+ let permissionStream: MediaStream | null = null;
37
+ if (options?.requestPermission) {
38
+ permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true });
39
+ }
40
+
41
+ try {
42
+ const inputs = await listAudioInputDevices();
43
+ const matches = inputs
44
+ .map((device) => ({
45
+ deviceId: device.deviceId,
46
+ label: device.label,
47
+ score: scoreSpeechMikeAudioDevice(device),
48
+ raw: device,
49
+ }))
50
+ .filter((match) => match.score > 0)
51
+ .sort((a, b) => b.score - a.score);
52
+
53
+ return matches[0] ?? null;
54
+ } finally {
55
+ permissionStream?.getTracks().forEach((track) => track.stop());
56
+ }
57
+ }
58
+