@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,73 @@
1
+ import { DEFAULT_BUTTON_MAPPING, SPEECHMIKE_CONFIG } from "./constants";
2
+ import type { DictationSupportEnum } from "./dictation-support-loader";
3
+ import type { EphiaSpeechMikeAction } from "./types";
4
+
5
+ export type ButtonRouterEvent = {
6
+ buttons: Set<string>;
7
+ actions: EphiaSpeechMikeAction[];
8
+ rawBitMask: number;
9
+ };
10
+
11
+ export class SpeechMikeButtonRouter {
12
+ private lastEventTime = 0;
13
+ private readonly processedEvents = new Set<string>();
14
+ private mapping: Record<string, EphiaSpeechMikeAction>;
15
+
16
+ constructor(
17
+ private readonly buttonEnum: DictationSupportEnum,
18
+ mapping: Partial<Record<string, EphiaSpeechMikeAction>> = {},
19
+ private readonly debounceMs = SPEECHMIKE_CONFIG.BUTTON_DEBOUNCE_MS,
20
+ ) {
21
+ this.mapping = mergeButtonMapping(mapping);
22
+ }
23
+
24
+ setMapping(mapping: Partial<Record<string, EphiaSpeechMikeAction>>): void {
25
+ this.mapping = mergeButtonMapping(mapping);
26
+ }
27
+
28
+ parse(bitMask: number): ButtonRouterEvent | null {
29
+ const now = Date.now();
30
+ const buttons = new Set<string>();
31
+
32
+ for (const value of Object.values(this.buttonEnum)) {
33
+ const numeric = Number(value);
34
+ if (Number.isNaN(numeric)) continue;
35
+
36
+ if (bitMask & numeric) {
37
+ const name = this.buttonEnum[numeric];
38
+ if (name) buttons.add(name);
39
+ }
40
+ }
41
+
42
+ if (buttons.size === 0) {
43
+ return { buttons, actions: [], rawBitMask: bitMask };
44
+ }
45
+
46
+ if (now - this.lastEventTime < this.debounceMs) return null;
47
+
48
+ const eventKey = `${[...buttons].join(",")}_${Math.floor(now / this.debounceMs)}`;
49
+ if (this.processedEvents.has(eventKey)) return null;
50
+
51
+ this.processedEvents.add(eventKey);
52
+ if (typeof window !== "undefined") {
53
+ window.setTimeout(() => this.processedEvents.delete(eventKey), 1000);
54
+ }
55
+ this.lastEventTime = now;
56
+
57
+ const actions = [...buttons]
58
+ .map((button) => this.mapping[button] ?? "none")
59
+ .filter((action): action is Exclude<EphiaSpeechMikeAction, "none"> => action !== "none");
60
+
61
+ return { buttons, actions, rawBitMask: bitMask };
62
+ }
63
+ }
64
+
65
+ function mergeButtonMapping(
66
+ mapping: Partial<Record<string, EphiaSpeechMikeAction>>,
67
+ ): Record<string, EphiaSpeechMikeAction> {
68
+ const merged: Record<string, EphiaSpeechMikeAction> = { ...DEFAULT_BUTTON_MAPPING };
69
+ for (const [button, action] of Object.entries(mapping)) {
70
+ if (action !== undefined) merged[button] = action;
71
+ }
72
+ return merged;
73
+ }
@@ -0,0 +1,461 @@
1
+ import {
2
+ getNavigatorHid,
3
+ matchesSpeechMikeIdentity,
4
+ withTimeout,
5
+ type HidConnectionEventLike,
6
+ type HidDeviceLike,
7
+ } from "./browser";
8
+ import { SPEECHMIKE_CONFIG } from "./constants";
9
+ import {
10
+ loadDictationSupport,
11
+ type DictationButtonCallback,
12
+ type DictationDevice,
13
+ type DictationDeviceCallback,
14
+ type DictationDeviceManagerLike,
15
+ type DictationMotionCallback,
16
+ type DictationSupportRuntime,
17
+ } from "./dictation-support-loader";
18
+ import { SpeechMikeButtonRouter, type ButtonRouterEvent } from "./speechmike-button-router";
19
+ import type {
20
+ EphiaSpeechMikeAction,
21
+ EphiaSpeechMikeDeviceInfo,
22
+ EphiaSpeechMikeError,
23
+ EphiaSpeechMikeHidStatus,
24
+ } from "./types";
25
+
26
+ export type SpeechMikeDeviceManagerEvents = {
27
+ status: (status: EphiaSpeechMikeHidStatus) => void;
28
+ device: (device: unknown | null) => void;
29
+ deviceInfo: (info: EphiaSpeechMikeDeviceInfo | null) => void;
30
+ presence: (present: boolean) => void;
31
+ button: (event: ButtonRouterEvent) => void;
32
+ error: (error: EphiaSpeechMikeError) => void;
33
+ };
34
+
35
+ export type SpeechMikeDeviceManagerOptions = {
36
+ buttonMapping?: Partial<Record<string, EphiaSpeechMikeAction>>;
37
+ loadRuntime?: () => Promise<DictationSupportRuntime>;
38
+ };
39
+
40
+ type EventListenerMap = {
41
+ [K in keyof SpeechMikeDeviceManagerEvents]: Set<SpeechMikeDeviceManagerEvents[K]>;
42
+ };
43
+
44
+ type DeviceWithHid = {
45
+ hidDevice?: HidDeviceLike;
46
+ };
47
+
48
+ function toError(error: unknown, fallbackCode: string): EphiaSpeechMikeError {
49
+ if (error && typeof error === "object" && "code" in error) {
50
+ const code = String((error as { code?: unknown }).code ?? fallbackCode);
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ return { code, message };
53
+ }
54
+
55
+ return {
56
+ code: fallbackCode,
57
+ message: error instanceof Error ? error.message : String(error),
58
+ };
59
+ }
60
+
61
+ function toRecord(value: unknown): Record<string, unknown> | null {
62
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
63
+ }
64
+
65
+ function getDeviceHidDevice(device: unknown): HidDeviceLike | null {
66
+ const maybeDevice = toRecord(device) as DeviceWithHid | null;
67
+ return maybeDevice?.hidDevice ?? null;
68
+ }
69
+
70
+ function sameHidDevice(a: HidDeviceLike | null | undefined, b: HidDeviceLike | null | undefined): boolean {
71
+ if (!a || !b) return false;
72
+ return a === b;
73
+ }
74
+
75
+ function extractDeviceInfo(value: unknown): EphiaSpeechMikeDeviceInfo | null {
76
+ const record = toRecord(value);
77
+ if (!record) return null;
78
+
79
+ const productName = typeof record.productName === "string" ? record.productName : undefined;
80
+ const manufacturerName =
81
+ typeof record.manufacturerName === "string" ? record.manufacturerName : undefined;
82
+ const vendorId = typeof record.vendorId === "number" ? record.vendorId : undefined;
83
+ const productId = typeof record.productId === "number" ? record.productId : undefined;
84
+
85
+ if (
86
+ productName === undefined &&
87
+ manufacturerName === undefined &&
88
+ vendorId === undefined &&
89
+ productId === undefined
90
+ ) {
91
+ return null;
92
+ }
93
+
94
+ return { productName, manufacturerName, vendorId, productId };
95
+ }
96
+
97
+ export class SpeechMikeDeviceManager {
98
+ private readonly listeners: EventListenerMap = {
99
+ status: new Set(),
100
+ device: new Set(),
101
+ deviceInfo: new Set(),
102
+ presence: new Set(),
103
+ button: new Set(),
104
+ error: new Set(),
105
+ };
106
+
107
+ private readonly loadRuntime: () => Promise<DictationSupportRuntime>;
108
+ private readonly initialButtonMapping: Partial<Record<string, EphiaSpeechMikeAction>>;
109
+ private manager: DictationDeviceManagerLike | null = null;
110
+ private router: SpeechMikeButtonRouter | null = null;
111
+ private currentDevice: DictationDevice | null = null;
112
+ private currentDeviceInfo: EphiaSpeechMikeDeviceInfo | null = null;
113
+ private status: EphiaSpeechMikeHidStatus = "idle";
114
+ private physicallyPresent = false;
115
+ private initialized = false;
116
+ private disposed = false;
117
+ private reconnectLock = false;
118
+ private watchdogInterval: number | null = null;
119
+
120
+ private readonly onButtonEvent: DictationButtonCallback = (_device, bitMask) => {
121
+ const event = this.router?.parse(bitMask);
122
+ if (event) this.emit("button", event);
123
+ };
124
+
125
+ private readonly onDeviceConnected: DictationDeviceCallback = (device) => {
126
+ void this.attachDevice(device, "sdk-connect");
127
+ };
128
+
129
+ private readonly onDeviceDisconnected: DictationDeviceCallback = () => {
130
+ this.detachDevice("sdk-disconnect");
131
+ };
132
+
133
+ private readonly onMotionEvent: DictationMotionCallback = () => {};
134
+
135
+ private readonly onHidConnect = (event: Event) => {
136
+ void this.handleHidConnect(event as HidConnectionEventLike);
137
+ };
138
+
139
+ private readonly onHidDisconnect = (event: Event) => {
140
+ void this.handleHidDisconnect(event as HidConnectionEventLike);
141
+ };
142
+
143
+ constructor(options: SpeechMikeDeviceManagerOptions = {}) {
144
+ this.loadRuntime = options.loadRuntime ?? loadDictationSupport;
145
+ this.initialButtonMapping = options.buttonMapping ?? {};
146
+ }
147
+
148
+ async initialize(): Promise<void> {
149
+ if (this.disposed) return;
150
+ if (this.initialized) return;
151
+ this.initialized = true;
152
+
153
+ const hid = getNavigatorHid();
154
+ if (!hid) {
155
+ this.setStatus("unsupported");
156
+ return;
157
+ }
158
+
159
+ this.setStatus("initializing");
160
+
161
+ try {
162
+ const runtime = await this.loadRuntime();
163
+ if (this.disposed) return;
164
+
165
+ this.router = new SpeechMikeButtonRouter(runtime.ButtonEvent, this.initialButtonMapping);
166
+ const manager = new runtime.DictationDeviceManager();
167
+ this.manager = manager;
168
+
169
+ manager.addButtonEventListener?.(this.onButtonEvent);
170
+ manager.addDeviceConnectedEventListener?.(this.onDeviceConnected);
171
+ manager.addDeviceDisconnectedEventListener?.(this.onDeviceDisconnected);
172
+ manager.addMotionEventListener?.(this.onMotionEvent);
173
+
174
+ hid.addEventListener("connect", this.onHidConnect);
175
+ hid.addEventListener("disconnect", this.onHidDisconnect);
176
+
177
+ if (manager.init) {
178
+ await withTimeout(
179
+ manager.init(),
180
+ SPEECHMIKE_CONFIG.DEVICE_OPERATION_TIMEOUT_MS,
181
+ "speechmike.init",
182
+ );
183
+ }
184
+
185
+ await this.reconnect();
186
+ this.startWatchdog();
187
+ } catch (error) {
188
+ const err = toError(error, "speechmike.initialize_failed");
189
+ this.emit("error", err);
190
+ this.setStatus(err.code === "speechmike.driver_missing" ? "driver_missing" : "error");
191
+ }
192
+ }
193
+
194
+ async requestAuthorization(): Promise<void> {
195
+ await this.initialize();
196
+ if (this.disposed) return;
197
+ if (!this.manager) {
198
+ const error = {
199
+ code: "speechmike.manager_unavailable",
200
+ message: "SpeechMike manager is not initialized",
201
+ };
202
+ this.emit("error", error);
203
+ throw new Error(error.message);
204
+ }
205
+
206
+ this.setStatus("authorization_required");
207
+
208
+ try {
209
+ await withTimeout(
210
+ this.manager.requestDevice(),
211
+ SPEECHMIKE_CONFIG.REQUEST_DEVICE_TIMEOUT_MS,
212
+ "speechmike.requestDevice",
213
+ );
214
+ const device = this.getManagerDevices()[0] ?? null;
215
+ if (!device) {
216
+ this.setStatus("error");
217
+ throw new Error("No SpeechMike device returned after authorization");
218
+ }
219
+ await this.attachDevice(device, "user-grant");
220
+ } catch (error) {
221
+ const err = toError(error, "speechmike.authorization_failed");
222
+ this.emit("error", err);
223
+ this.setStatus("present_unauthorized");
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ async reconnect(): Promise<void> {
229
+ if (this.reconnectLock || this.disposed) return;
230
+ this.reconnectLock = true;
231
+ this.setStatus(this.currentDevice ? "recovering" : "restoring");
232
+
233
+ try {
234
+ const device = this.getManagerDevices()[0] ?? null;
235
+ if (device) {
236
+ await this.attachDevice(device, "reconnect");
237
+ return;
238
+ }
239
+
240
+ const present = await this.detectPhysicalPresence();
241
+ this.setPhysicallyPresent(present);
242
+ this.detachDevice(present ? "present-unauthorized" : "not-present", {
243
+ status: present ? "present_unauthorized" : "not_present",
244
+ });
245
+ } catch (error) {
246
+ const err = toError(error, "speechmike.reconnect_failed");
247
+ this.emit("error", err);
248
+ this.setStatus("error");
249
+ } finally {
250
+ this.reconnectLock = false;
251
+ }
252
+ }
253
+
254
+ dispose(): void {
255
+ this.disposed = true;
256
+ if (this.watchdogInterval !== null && typeof window !== "undefined") {
257
+ window.clearInterval(this.watchdogInterval);
258
+ this.watchdogInterval = null;
259
+ }
260
+
261
+ const hid = getNavigatorHid();
262
+ hid?.removeEventListener("connect", this.onHidConnect);
263
+ hid?.removeEventListener("disconnect", this.onHidDisconnect);
264
+
265
+ this.manager?.removeButtonEventListener?.(this.onButtonEvent);
266
+ this.manager?.removeDeviceConnectedEventListener?.(this.onDeviceConnected);
267
+ this.manager?.removeDeviceDisconnectedEventListener?.(this.onDeviceDisconnected);
268
+ this.manager?.removeMotionEventListener?.(this.onMotionEvent);
269
+ this.manager?.disconnect?.();
270
+
271
+ this.currentDevice = null;
272
+ this.currentDeviceInfo = null;
273
+ this.manager = null;
274
+ this.router = null;
275
+ this.initialized = false;
276
+ this.emit("device", null);
277
+ this.emit("deviceInfo", null);
278
+ }
279
+
280
+ getCurrentDevice(): unknown | null {
281
+ return this.currentDevice;
282
+ }
283
+
284
+ getStatus(): EphiaSpeechMikeHidStatus {
285
+ return this.status;
286
+ }
287
+
288
+ getPhysicallyPresent(): boolean {
289
+ return this.physicallyPresent;
290
+ }
291
+
292
+ getDeviceInfo(): EphiaSpeechMikeDeviceInfo | null {
293
+ return this.currentDeviceInfo;
294
+ }
295
+
296
+ setButtonMapping(mapping: Partial<Record<string, EphiaSpeechMikeAction>>): void {
297
+ this.router?.setMapping(mapping);
298
+ }
299
+
300
+ on<K extends keyof SpeechMikeDeviceManagerEvents>(
301
+ event: K,
302
+ cb: SpeechMikeDeviceManagerEvents[K],
303
+ ): () => void {
304
+ this.listeners[event].add(cb);
305
+ return () => {
306
+ this.listeners[event].delete(cb);
307
+ };
308
+ }
309
+
310
+ private async attachDevice(device: DictationDevice, _cause: string): Promise<void> {
311
+ if (this.disposed) return;
312
+
313
+ this.currentDevice = device;
314
+ this.setPhysicallyPresent(true);
315
+ this.currentDeviceInfo = await this.resolveDeviceInfo(device);
316
+ this.emit("device", device);
317
+ this.emit("deviceInfo", this.currentDeviceInfo);
318
+ this.setStatus("connected");
319
+ }
320
+
321
+ private detachDevice(
322
+ _cause: string,
323
+ options: { status?: EphiaSpeechMikeHidStatus } = {},
324
+ ): void {
325
+ this.currentDevice = null;
326
+ this.currentDeviceInfo = null;
327
+ this.emit("device", null);
328
+ this.emit("deviceInfo", null);
329
+ this.setStatus(options.status ?? "disconnected");
330
+ }
331
+
332
+ private async handleHidConnect(event: HidConnectionEventLike): Promise<void> {
333
+ if (this.disposed) return;
334
+ const present = await this.detectPhysicalPresence();
335
+ this.setPhysicallyPresent(present);
336
+ if (!present) return;
337
+
338
+ const devices = this.getManagerDevices();
339
+ const connectedHidDevice = event.device;
340
+ const match =
341
+ devices.find((device) => sameHidDevice(getDeviceHidDevice(device), connectedHidDevice)) ??
342
+ devices[0] ??
343
+ null;
344
+
345
+ if (match) {
346
+ await this.attachDevice(match, "hid-connect");
347
+ } else {
348
+ this.setStatus("present_unauthorized");
349
+ }
350
+ }
351
+
352
+ private async handleHidDisconnect(event: HidConnectionEventLike): Promise<void> {
353
+ if (this.disposed) return;
354
+ const currentHidDevice = getDeviceHidDevice(this.currentDevice);
355
+ if (sameHidDevice(currentHidDevice, event.device)) {
356
+ this.detachDevice("hid-disconnect");
357
+ }
358
+ this.setPhysicallyPresent(await this.detectPhysicalPresence());
359
+ }
360
+
361
+ private startWatchdog(): void {
362
+ if (this.watchdogInterval !== null || typeof window === "undefined") return;
363
+ this.watchdogInterval = window.setInterval(() => {
364
+ void this.watchdogTick();
365
+ }, SPEECHMIKE_CONFIG.WATCHDOG_INTERVAL_MS);
366
+ }
367
+
368
+ private async watchdogTick(): Promise<void> {
369
+ if (this.reconnectLock || this.disposed) return;
370
+ this.reconnectLock = true;
371
+
372
+ try {
373
+ const present = await this.detectPhysicalPresence();
374
+ this.setPhysicallyPresent(present);
375
+
376
+ if (!this.currentDevice && present) {
377
+ const device = this.getManagerDevices()[0] ?? null;
378
+ if (device) {
379
+ await this.attachDevice(device, "watchdog-restore");
380
+ } else {
381
+ this.setStatus("present_unauthorized");
382
+ }
383
+ } else if (this.currentDevice && !present) {
384
+ this.detachDevice("watchdog-absent", { status: "disconnected" });
385
+ }
386
+ } finally {
387
+ this.reconnectLock = false;
388
+ }
389
+ }
390
+
391
+ private async detectPhysicalPresence(): Promise<boolean> {
392
+ const hid = getNavigatorHid();
393
+ if (hid) {
394
+ try {
395
+ const devices = await hid.getDevices();
396
+ if (devices.some(matchesSpeechMikeIdentity)) return true;
397
+ } catch {
398
+ // Fall through to MediaDevices.
399
+ }
400
+ }
401
+
402
+ try {
403
+ const devices = await navigator.mediaDevices?.enumerateDevices?.();
404
+ return !!devices?.some((device) => {
405
+ const label = (device.label ?? "").toLowerCase();
406
+ return (
407
+ device.kind === "audioinput" &&
408
+ (label.includes("speechmike") ||
409
+ label.includes("philips") ||
410
+ label.includes("dictation"))
411
+ );
412
+ });
413
+ } catch {
414
+ return false;
415
+ }
416
+ }
417
+
418
+ private async resolveDeviceInfo(
419
+ device: DictationDevice,
420
+ ): Promise<EphiaSpeechMikeDeviceInfo | null> {
421
+ const fromDevice = extractDeviceInfo(device);
422
+ if (fromDevice) return fromDevice;
423
+
424
+ const hidDevice = getDeviceHidDevice(device);
425
+ const fromHid = extractDeviceInfo(hidDevice);
426
+ if (fromHid) return fromHid;
427
+
428
+ try {
429
+ const info = await this.manager?.getDeviceInfo?.(device);
430
+ return extractDeviceInfo(info);
431
+ } catch {
432
+ return null;
433
+ }
434
+ }
435
+
436
+ private getManagerDevices(): DictationDevice[] {
437
+ const devices = this.manager?.getDevices?.() ?? [];
438
+ return Array.isArray(devices) ? devices : [];
439
+ }
440
+
441
+ private setStatus(status: EphiaSpeechMikeHidStatus): void {
442
+ if (this.status === status) return;
443
+ this.status = status;
444
+ this.emit("status", status);
445
+ }
446
+
447
+ private setPhysicallyPresent(present: boolean): void {
448
+ if (this.physicallyPresent === present) return;
449
+ this.physicallyPresent = present;
450
+ this.emit("presence", present);
451
+ }
452
+
453
+ private emit<K extends keyof SpeechMikeDeviceManagerEvents>(
454
+ event: K,
455
+ payload: Parameters<SpeechMikeDeviceManagerEvents[K]>[0],
456
+ ): void {
457
+ for (const listener of this.listeners[event]) {
458
+ (listener as (value: typeof payload) => void)(payload);
459
+ }
460
+ }
461
+ }
@@ -0,0 +1,78 @@
1
+ import { DEFAULT_LED_INTENTS, SPEECHMIKE_CONFIG } from "./constants";
2
+ import { withTimeout } from "./browser";
3
+ import type { EphiaSpeechMikeLedIntent } from "./types";
4
+
5
+ type LedCapableDevice = {
6
+ setLed?: (index: number, mode: number) => Promise<void>;
7
+ setLeds?: (states: Record<number, number>) => Promise<void>;
8
+ };
9
+
10
+ function isLedCapableDevice(device: unknown): device is LedCapableDevice {
11
+ if (!device || typeof device !== "object") return false;
12
+ return "setLed" in device || "setLeds" in device;
13
+ }
14
+
15
+ export class SpeechMikeLedController {
16
+ private queue: Promise<void> = Promise.resolve();
17
+
18
+ constructor(private readonly getDevice: () => unknown | null) {}
19
+
20
+ setIntent(intent: EphiaSpeechMikeLedIntent): void {
21
+ this.setBatchQueued(DEFAULT_LED_INTENTS[intent]);
22
+ }
23
+
24
+ setBatchQueued(states: Record<number, number>): void {
25
+ this.queue = this.queue
26
+ .catch(() => undefined)
27
+ .then(() => this.setBatch(states))
28
+ .catch(() => undefined);
29
+ }
30
+
31
+ async setBatch(states: Record<number, number>): Promise<void> {
32
+ const device = this.getLedDevice();
33
+ if (!device) return;
34
+
35
+ if (typeof device.setLeds === "function") {
36
+ try {
37
+ await withTimeout(
38
+ device.setLeds(states),
39
+ SPEECHMIKE_CONFIG.LED_OPERATION_TIMEOUT_MS,
40
+ "setLeds",
41
+ );
42
+ return;
43
+ } catch {
44
+ // Fall back to individual LED calls.
45
+ }
46
+ }
47
+
48
+ await Promise.allSettled(
49
+ Object.entries(states).map(([index, mode]) => this.setLed(Number(index), mode)),
50
+ );
51
+ }
52
+
53
+ async setLed(index: number, mode: number): Promise<boolean> {
54
+ const device = this.getLedDevice();
55
+ if (!device?.setLed) return false;
56
+
57
+ try {
58
+ await withTimeout(
59
+ device.setLed(index, mode),
60
+ SPEECHMIKE_CONFIG.LED_OPERATION_TIMEOUT_MS,
61
+ `setLed(${index})`,
62
+ );
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ clear(): void {
70
+ this.setIntent("off");
71
+ }
72
+
73
+ private getLedDevice(): LedCapableDevice | null {
74
+ const device = this.getDevice();
75
+ return isLedCapableDevice(device) ? device : null;
76
+ }
77
+ }
78
+
@@ -0,0 +1,96 @@
1
+ export type EphiaSpeechMikeHidStatus =
2
+ | "unsupported"
3
+ | "driver_missing"
4
+ | "idle"
5
+ | "initializing"
6
+ | "not_present"
7
+ | "present_unauthorized"
8
+ | "authorization_required"
9
+ | "restoring"
10
+ | "connected"
11
+ | "disconnected"
12
+ | "recovering"
13
+ | "error";
14
+
15
+ export type EphiaSpeechMikeAudioStatus =
16
+ | "idle"
17
+ | "permission_required"
18
+ | "resolving"
19
+ | "ready"
20
+ | "not_found"
21
+ | "error";
22
+
23
+ export interface EphiaSpeechMikeStatus {
24
+ hid: EphiaSpeechMikeHidStatus;
25
+ audio: EphiaSpeechMikeAudioStatus;
26
+ }
27
+
28
+ export type EphiaSpeechMikeButton =
29
+ | "RECORD"
30
+ | "PLAY"
31
+ | "FORWARD"
32
+ | "REWIND"
33
+ | "F1"
34
+ | "F1_A"
35
+ | "F2"
36
+ | "F2_B"
37
+ | "F3"
38
+ | "F3_C"
39
+ | "F4"
40
+ | "F4_D"
41
+ | "INS"
42
+ | "OVR"
43
+ | "INS_OVR"
44
+ | "EOL"
45
+ | "EOL_PRIO"
46
+ | string;
47
+
48
+ export type EphiaSpeechMikeAction =
49
+ | "record.toggle"
50
+ | "record.start"
51
+ | "record.stop"
52
+ | "play.toggle"
53
+ | "seek.forward"
54
+ | "seek.backward"
55
+ | "audio.submit"
56
+ | "audio.discard"
57
+ | "agent.open"
58
+ | "exam.new"
59
+ | "resources.toggle"
60
+ | "none";
61
+
62
+ export type EphiaSpeechMikeLedIntent =
63
+ | "off"
64
+ | "idle"
65
+ | "recording"
66
+ | "processing"
67
+ | "audio_available"
68
+ | "success"
69
+ | "error"
70
+ | "cancelled";
71
+
72
+ export interface EphiaSpeechMikeDeviceInfo {
73
+ productName?: string;
74
+ manufacturerName?: string;
75
+ vendorId?: number;
76
+ productId?: number;
77
+ }
78
+
79
+ export interface EphiaSpeechMikeError {
80
+ code: string;
81
+ message: string;
82
+ }
83
+
84
+ export interface EphiaSpeechMikeState {
85
+ status: EphiaSpeechMikeStatus;
86
+ device: unknown | null;
87
+ deviceInfo: EphiaSpeechMikeDeviceInfo | null;
88
+ physicallyPresent: boolean;
89
+ audioInputDeviceId: string | null;
90
+ audioInputDeviceLabel: string | null;
91
+ pressedButtons: Set<string>;
92
+ lastButton: string | null;
93
+ lastAction: EphiaSpeechMikeAction | null;
94
+ error: EphiaSpeechMikeError | null;
95
+ }
96
+