@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,459 @@
1
+ /**
2
+ * Transport LiveKit — implémentation du Transport Ephia pour l'agent ephia-transcribe-agent.
3
+ *
4
+ * Wire V2 :
5
+ * • SDK → backend : ephia.client.control
6
+ * • backend → SDK : ephia.server.event
7
+ */
8
+
9
+ import {
10
+ Room,
11
+ RoomEvent,
12
+ DataPacket_Kind,
13
+ LocalAudioTrack,
14
+ LocalTrackPublication,
15
+ ConnectionState,
16
+ ConnectionQuality,
17
+ MediaDeviceFailure,
18
+ AudioPresets,
19
+ Track,
20
+ } from "livekit-client";
21
+ import type { ReconnectContext, ReconnectPolicy } from "livekit-client";
22
+ import type {
23
+ EphiaAudioEvent,
24
+ EphiaClientMessage,
25
+ EphiaServerEvent,
26
+ } from "ephia-protocol";
27
+ import { ephiaWireServerEventSchema, PROTOCOL_VERSION } from "ephia-protocol";
28
+ import { EphiaSdkError } from "../../shared/errors/EphiaSdkError";
29
+ import { dbgRawEvent, dbgState } from "../../debug/sdk-debug-collector";
30
+ import type {
31
+ Transport,
32
+ TransportConnectParams,
33
+ TransportState,
34
+ } from "./Transport";
35
+
36
+ const TOPIC_CLIENT_CONTROL = "ephia.client.control";
37
+ const TOPIC_SERVER_EVENT = "ephia.server.event";
38
+ const TRACK_MIC = "ephia-microphone";
39
+
40
+ const textDecoder = new TextDecoder();
41
+ const textEncoder = new TextEncoder();
42
+
43
+ // P1 backend : parsing défensif des champs additifs optionnels du payload
44
+ // JSON brut (snake_case) — le backend peut omettre ces champs (versions
45
+ // antérieures, fallback) sans que le transport ne plante.
46
+ function asString(value: unknown): string | undefined {
47
+ return typeof value === "string" ? value : undefined;
48
+ }
49
+
50
+ const RECONNECT_POLICY: ReconnectPolicy = {
51
+ nextRetryDelayInMs(ctx: ReconnectContext): number | null {
52
+ if (ctx.retryCount >= 10) return null;
53
+ const base = Math.min(300 * 2 ** ctx.retryCount, 30_000);
54
+ const jitter = Math.floor(Math.random() * 1000);
55
+ return base + jitter;
56
+ },
57
+ };
58
+
59
+ function uuid(): string {
60
+ const c = globalThis.crypto;
61
+ if (c?.randomUUID) return c.randomUUID();
62
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
63
+ }
64
+
65
+ export class LiveKitTransport implements Transport {
66
+ private room: Room | null = null;
67
+ private _eventCallbacks: Set<(event: EphiaAudioEvent) => void> = new Set();
68
+ private _serverEventCallbacks: Set<(event: EphiaServerEvent) => void> = new Set();
69
+ private _stateCallbacks: Set<(state: TransportState) => void> = new Set();
70
+ private _errorCallbacks: Set<(error: EphiaSdkError) => void> = new Set();
71
+ private _state: TransportState = {
72
+ status: "idle",
73
+ localAudioPublished: false,
74
+ localAudioMuted: false,
75
+ reconnectCount: 0,
76
+ };
77
+ private _localAudioTrack: LocalTrackPublication | null = null;
78
+ private _krispProcessor: { destroy?: () => void } | null = null;
79
+
80
+ private _sessionId = "";
81
+ private _clientSeq = 0;
82
+
83
+ async connect(params: TransportConnectParams): Promise<void> {
84
+ if (!this.room) {
85
+ this.room = new Room(this._roomOptions());
86
+ } else if (
87
+ this.room.state === ConnectionState.Connected ||
88
+ this.room.state === ConnectionState.Reconnecting ||
89
+ this.room.state === ConnectionState.Connecting
90
+ ) {
91
+ await this.disconnect("reconnect");
92
+ this.room = new Room(this._roomOptions());
93
+ } else {
94
+ this.room.removeAllListeners();
95
+ }
96
+
97
+ this._sessionId = (params as any).sessionId ?? params.roomName;
98
+ this._clientSeq = 0;
99
+
100
+ this._setState({ status: "connecting", roomName: params.roomName });
101
+ this._attachRoomListeners(params);
102
+ await this.room.connect(params.livekitUrl, params.token);
103
+
104
+ if (this.room.state !== ConnectionState.Connected) {
105
+ throw new EphiaSdkError(
106
+ "transport.connect_failed",
107
+ `LiveKit room not connected: ${this.room.state}`
108
+ );
109
+ }
110
+
111
+ this._setState({
112
+ status: "connected",
113
+ roomName: this.room.name,
114
+ participantIdentity: this.room.localParticipant?.identity,
115
+ });
116
+
117
+ }
118
+
119
+ async prepareConnection(url: string, token?: string): Promise<void> {
120
+ if (!this.room) {
121
+ this.room = new Room(this._roomOptions());
122
+ }
123
+ await this.room.prepareConnection(url, token);
124
+ }
125
+
126
+ async disconnect(_reason?: string): Promise<void> {
127
+ if (this.room) {
128
+ const room = this.room;
129
+ await room.disconnect();
130
+ room.removeAllListeners();
131
+ this.room = null;
132
+ }
133
+ this._localAudioTrack = null;
134
+ this._setState({
135
+ status: "disconnected",
136
+ localAudioPublished: false,
137
+ localAudioMuted: false,
138
+ });
139
+ }
140
+
141
+ async sendMessage(message: EphiaClientMessage): Promise<void> {
142
+ if (!this.room) {
143
+ throw new EphiaSdkError("transport.not_connected", "Cannot send message: room is null");
144
+ }
145
+ if (this.room.state !== ConnectionState.Connected) {
146
+ throw new EphiaSdkError("transport.not_connected", `Cannot send message: room state is ${this.room.state}`);
147
+ }
148
+
149
+ const wire = this._toWireClientEvent(message);
150
+ await this.room.localParticipant.publishData(textEncoder.encode(JSON.stringify(wire)), {
151
+ reliable: true,
152
+ topic: TOPIC_CLIENT_CONTROL,
153
+ });
154
+ }
155
+
156
+ onEvent(callback: (event: EphiaAudioEvent) => void): () => void {
157
+ this._eventCallbacks.add(callback);
158
+ return () => this._eventCallbacks.delete(callback);
159
+ }
160
+
161
+ onServerEvent(callback: (event: EphiaServerEvent) => void): () => void {
162
+ this._serverEventCallbacks.add(callback);
163
+ return () => this._serverEventCallbacks.delete(callback);
164
+ }
165
+
166
+ onTransportState(callback: (state: TransportState) => void): () => void {
167
+ this._stateCallbacks.add(callback);
168
+ return () => this._stateCallbacks.delete(callback);
169
+ }
170
+
171
+ onError(callback: (error: EphiaSdkError) => void): () => void {
172
+ this._errorCallbacks.add(callback);
173
+ return () => this._errorCallbacks.delete(callback);
174
+ }
175
+
176
+ getState(): TransportState {
177
+ return { ...this._state };
178
+ }
179
+
180
+ getLocalAudioPublication(): LocalTrackPublication | null {
181
+ return this._localAudioTrack;
182
+ }
183
+
184
+ async publishAudio(track: MediaStreamTrack, options?: { enableNoiseFilter?: boolean }): Promise<void> {
185
+ if (this._localAudioTrack) {
186
+ await this.unpublishAudio();
187
+ }
188
+ if (!this.room) {
189
+ throw new EphiaSdkError("transport.connect_failed", "Cannot publish audio: room is null");
190
+ }
191
+ if (this.room.state !== ConnectionState.Connected) {
192
+ throw new EphiaSdkError("transport.connect_failed", `Room not connected: ${this.room.state}`);
193
+ }
194
+
195
+ this._localAudioTrack = await this.room.localParticipant.publishTrack(track, {
196
+ name: TRACK_MIC,
197
+ source: Track.Source.Microphone,
198
+ audioPreset: AudioPresets.speech,
199
+ } as any);
200
+
201
+ this._setState({ localAudioMuted: false });
202
+
203
+ const tryKrisp = options?.enableNoiseFilter === true;
204
+ if (tryKrisp && this._localAudioTrack?.track instanceof LocalAudioTrack) {
205
+ try {
206
+ const { KrispNoiseFilter, isKrispNoiseFilterSupported } = await import("@livekit/krisp-noise-filter");
207
+ if (isKrispNoiseFilterSupported()) {
208
+ const processor = KrispNoiseFilter();
209
+ await this._localAudioTrack.track.setProcessor(processor);
210
+ this._krispProcessor = processor;
211
+ await track.applyConstraints({
212
+ echoCancellation: false,
213
+ noiseSuppression: false,
214
+ autoGainControl: false,
215
+ });
216
+ this._setState({ krispActive: true });
217
+ }
218
+ } catch (err) {
219
+ console.warn("[LiveKitTransport] Krisp initialization failed — fallback WebRTC natif", err);
220
+ }
221
+ }
222
+ }
223
+
224
+ async unpublishAudio(): Promise<void> {
225
+ if (this._localAudioTrack && this.room) {
226
+ if (this._krispProcessor && this._localAudioTrack.track instanceof LocalAudioTrack) {
227
+ try {
228
+ await this._localAudioTrack.track.stopProcessor();
229
+ } catch { /* ignore */ }
230
+ this._krispProcessor = null;
231
+ }
232
+ await this.room.localParticipant.unpublishTrack(this._localAudioTrack.track!);
233
+ this._localAudioTrack = null;
234
+ this._setState({ krispActive: false, localAudioMuted: false });
235
+ }
236
+ }
237
+
238
+ async performRpc(method: string, payload: unknown, timeout = 3000): Promise<string> {
239
+ if (!this.room) {
240
+ throw new EphiaSdkError("transport.not_connected", "Cannot perform RPC: room not available");
241
+ }
242
+ return this.room.localParticipant.performRpc({
243
+ destinationIdentity: "ephia-transcribe-agent",
244
+ method,
245
+ payload: JSON.stringify(payload),
246
+ responseTimeout: timeout,
247
+ });
248
+ }
249
+
250
+ async switchActiveDevice(kind: MediaDeviceKind, deviceId: string): Promise<boolean> {
251
+ if (!this.room) {
252
+ throw new EphiaSdkError("transport.not_connected", "Cannot switch device: room not available");
253
+ }
254
+ return this.room.switchActiveDevice(kind, deviceId);
255
+ }
256
+
257
+ // ------------------------------------------------------------------
258
+ // Internals
259
+ // ------------------------------------------------------------------
260
+
261
+ private _toWireClientEvent(message: EphiaClientMessage): Record<string, unknown> {
262
+ const msgType = (message as { type: string }).type;
263
+ return {
264
+ protocolVersion: PROTOCOL_VERSION,
265
+ type: msgType,
266
+ eventId: uuid(),
267
+ sessionId: this._sessionId,
268
+ clientSeq: ++this._clientSeq,
269
+ sentAt: new Date().toISOString(),
270
+ payload: this._toWireClientPayload(message),
271
+ };
272
+ }
273
+
274
+ private _toWireClientPayload(message: EphiaClientMessage): Record<string, unknown> {
275
+ const msgType = (message as { type: string }).type;
276
+ const payload = (message as { payload?: unknown }).payload;
277
+ const raw = payload && typeof payload === "object" && !Array.isArray(payload)
278
+ ? (payload as Record<string, unknown>)
279
+ : {};
280
+
281
+ if (msgType === "session.reset") {
282
+ const scope = raw.scope === "target" ? "target" : "global";
283
+ const targetId = asString(raw.targetId) ?? asString(raw.target_id);
284
+ const reason = asString(raw.reason) ?? "user_explicit";
285
+
286
+ if (scope === "target" && !targetId) {
287
+ throw new EphiaSdkError(
288
+ "protocol.invalid_event",
289
+ "session.reset target scope requires targetId"
290
+ );
291
+ }
292
+
293
+ return scope === "target" && targetId
294
+ ? { scope: "target", targetId, reason }
295
+ : { scope: "global", reason };
296
+ }
297
+
298
+ if (msgType === "session.target.changed") {
299
+ return { targetId: asString(raw.targetId) ?? asString(raw.target_id) ?? "" };
300
+ }
301
+
302
+ if (msgType === "session.editor_context.update") {
303
+ return {
304
+ targetId: asString(raw.targetId) ?? asString(raw.target_id) ?? "",
305
+ documentEmpty: raw.documentEmpty ?? raw.document_empty ?? false,
306
+ insertionMode: raw.insertionMode ?? raw.insertion_mode ?? "append",
307
+ leftContext: raw.leftContext ?? raw.left_context ?? "",
308
+ rightContext: raw.rightContext ?? raw.right_context ?? "",
309
+ selectedText: raw.selectedText ?? raw.selected_text,
310
+ cursorOffset: raw.cursorOffset ?? raw.cursor_offset,
311
+ };
312
+ }
313
+
314
+ return raw;
315
+ }
316
+
317
+ private _attachRoomListeners(_params: TransportConnectParams): void {
318
+ if (!this.room) return;
319
+
320
+ this.room.on(RoomEvent.ConnectionStateChanged, (lkState) => {
321
+ dbgRawEvent("livekit", "RoomEvent.ConnectionStateChanged", { state: lkState });
322
+ switch (lkState) {
323
+ case ConnectionState.Connected:
324
+ this._setState({ status: "connected" });
325
+ break;
326
+ case ConnectionState.Disconnected:
327
+ this._setState({ status: "disconnected" });
328
+ break;
329
+ case ConnectionState.Reconnecting:
330
+ this._setState({
331
+ status: "reconnecting",
332
+ reconnectCount: this._state.reconnectCount + 1,
333
+ });
334
+ break;
335
+ }
336
+ dbgState("livekit.transport", this._state);
337
+ });
338
+
339
+ this.room.on(RoomEvent.Reconnecting, () => {
340
+ dbgRawEvent("livekit", "RoomEvent.Reconnecting", {});
341
+ this._setState({
342
+ status: "reconnecting",
343
+ reconnectCount: this._state.reconnectCount + 1,
344
+ });
345
+ dbgState("livekit.transport", this._state);
346
+ });
347
+
348
+ this.room.on(RoomEvent.Reconnected, () => {
349
+ dbgRawEvent("livekit", "RoomEvent.Reconnected", {});
350
+ this._setState({ status: "reconnected" });
351
+ dbgState("livekit.transport", this._state);
352
+ });
353
+
354
+ this.room.on(RoomEvent.ConnectionQualityChanged, (quality) => {
355
+ dbgRawEvent("livekit", "RoomEvent.ConnectionQualityChanged", { quality });
356
+ const mapped =
357
+ quality === ConnectionQuality.Excellent ? "excellent" :
358
+ quality === ConnectionQuality.Good ? "good" :
359
+ quality === ConnectionQuality.Poor ? "poor" : "unknown";
360
+ this._setState({ connectionQuality: mapped });
361
+ dbgState("livekit.transport", this._state);
362
+ });
363
+
364
+ this.room.on(RoomEvent.LocalTrackPublished, () => {
365
+ dbgRawEvent("livekit", "RoomEvent.LocalTrackPublished", {});
366
+ this._setState({ localAudioPublished: true });
367
+ dbgState("livekit.transport", this._state);
368
+ });
369
+
370
+ this.room.on(RoomEvent.LocalTrackUnpublished, () => {
371
+ dbgRawEvent("livekit", "RoomEvent.LocalTrackUnpublished", {});
372
+ this._setState({ localAudioPublished: false });
373
+ dbgState("livekit.transport", this._state);
374
+ });
375
+
376
+ this.room.on(RoomEvent.LocalAudioSilenceDetected, () => {
377
+ dbgRawEvent("livekit", "RoomEvent.LocalAudioSilenceDetected", {});
378
+ this._setState({ localAudioMuted: true });
379
+ dbgState("livekit.transport", this._state);
380
+ });
381
+
382
+ this.room.on(RoomEvent.MediaDevicesError, (err) => {
383
+ dbgRawEvent("livekit", "RoomEvent.MediaDevicesError", { error: err instanceof Error ? err.message : String(err) });
384
+ const failure = MediaDeviceFailure.getFailure(err);
385
+ switch (failure) {
386
+ case MediaDeviceFailure.PermissionDenied:
387
+ this._emitError("audio.permission_denied", "Autorisation micro refusée — vérifier les paramètres du navigateur");
388
+ break;
389
+ case MediaDeviceFailure.NotFound:
390
+ this._emitError("audio.no_input_device", "Aucun microphone détecté");
391
+ break;
392
+ case MediaDeviceFailure.DeviceInUse:
393
+ this._emitError("audio.device_in_use", "Micro occupé par une autre application");
394
+ break;
395
+ default:
396
+ this._emitError("audio.no_input_device", err instanceof Error ? err.message : String(err));
397
+ }
398
+ });
399
+
400
+ // Écoute le topic serveur unique du wire V2.
401
+ this.room.on(RoomEvent.DataReceived, (payload, _participant, kind, topic) => {
402
+ const isReliable = kind === DataPacket_Kind.RELIABLE;
403
+ const isLossy = kind === DataPacket_Kind.LOSSY;
404
+
405
+ if (topic !== TOPIC_SERVER_EVENT) return;
406
+ if (!isReliable && !isLossy) return;
407
+
408
+ try {
409
+ const data = JSON.parse(textDecoder.decode(payload)) as Record<string, unknown>;
410
+ dbgRawEvent("livekit", "RoomEvent.DataReceived", { topic, kind, data });
411
+ const parsed = ephiaWireServerEventSchema.safeParse(data);
412
+ if (!parsed.success) {
413
+ console.warn("[LiveKitTransport] invalid server event schema (ignored):", parsed.error.issues);
414
+ return;
415
+ }
416
+ this._handleWireServerEvent(parsed.data);
417
+ } catch (err) {
418
+ console.warn("[LiveKitTransport] failed to parse data channel payload", err);
419
+ }
420
+ });
421
+ }
422
+
423
+ private _handleWireServerEvent(event: EphiaServerEvent): void {
424
+ this._serverEventCallbacks.forEach((cb) => cb(event));
425
+ }
426
+
427
+ private _roomOptions() {
428
+ return {
429
+ adaptiveStream: false,
430
+ dynacast: false,
431
+ disconnectOnPageLeave: false,
432
+ reconnectPolicy: RECONNECT_POLICY,
433
+ audioCaptureDefaults: {
434
+ echoCancellation: true,
435
+ noiseSuppression: true,
436
+ autoGainControl: true,
437
+ },
438
+ };
439
+ }
440
+
441
+ private _setState(partial: Partial<TransportState>): void {
442
+ const next = { ...this._state, ...partial };
443
+ let changed = false;
444
+ for (const key in partial) {
445
+ if ((next as any)[key] !== (this._state as any)[key]) {
446
+ changed = true;
447
+ break;
448
+ }
449
+ }
450
+ if (!changed) return;
451
+ this._state = next;
452
+ this._stateCallbacks.forEach((cb) => cb(this._state));
453
+ }
454
+
455
+ private _emitError(code: EphiaSdkError["code"], message: string): void {
456
+ const err = new EphiaSdkError(code, message);
457
+ this._errorCallbacks.forEach((cb) => cb(err));
458
+ }
459
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Transport mock pour tests, storybook et sandbox sans backend.
3
+ */
4
+
5
+ import type { EphiaAudioEvent, EphiaClientMessage, EphiaServerEvent } from "ephia-protocol";
6
+ import { PROTOCOL_VERSION, ephiaAudioEventSchema } from "ephia-protocol";
7
+ import { EphiaSdkError } from "../../shared/errors/EphiaSdkError";
8
+ import type {
9
+ Transport,
10
+ TransportConnectParams,
11
+ TransportState,
12
+ } from "./Transport";
13
+
14
+ function uuid(): string {
15
+ const c = globalThis.crypto;
16
+ if (c?.randomUUID) return c.randomUUID();
17
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
18
+ }
19
+
20
+ type MockScriptStep = {
21
+ type: string;
22
+ delayMs: number;
23
+ payload?: Record<string, unknown>;
24
+ };
25
+
26
+ type MockTransportOptions = {
27
+ script?: MockScriptStep[];
28
+ autoEmitReady?: boolean;
29
+ latencyMs?: number;
30
+ };
31
+
32
+ export class MockTransport implements Transport {
33
+ private _state: TransportState = {
34
+ status: "idle",
35
+ localAudioPublished: false,
36
+ localAudioMuted: false,
37
+ reconnectCount: 0,
38
+ };
39
+
40
+ private _eventCallbacks: Array<(event: EphiaAudioEvent) => void> = [];
41
+ private _serverEventCallbacks: Array<(event: EphiaServerEvent) => void> = [];
42
+ private _stateCallbacks: Array<(state: TransportState) => void> = [];
43
+ private _errorCallbacks: Array<(error: EphiaSdkError) => void> = [];
44
+ private _running = false;
45
+ private _scriptTimer: ReturnType<typeof setTimeout> | null = null;
46
+ private _options: MockTransportOptions;
47
+
48
+ constructor(options: MockTransportOptions = {}) {
49
+ this._options = options;
50
+ }
51
+
52
+ async connect(params: TransportConnectParams): Promise<void> {
53
+ this._running = true;
54
+ this._setState({ status: "connecting", roomName: params.roomName });
55
+
56
+ await this._delay(this._options.latencyMs ?? 100);
57
+
58
+ if (!this._running) return;
59
+
60
+ this._setState({ status: "connected" });
61
+
62
+ if (this._options.autoEmitReady !== false) {
63
+ this._emit({
64
+ protocolVersion: PROTOCOL_VERSION,
65
+ eventId: "00000000-0000-4000-8000-000000000001",
66
+ sessionId: "mock-session",
67
+ type: "session.ready",
68
+ seq: 0,
69
+ timestampMs: Date.now(),
70
+ payload: { roomName: params.roomName },
71
+ });
72
+ }
73
+
74
+ this._runScript();
75
+ }
76
+
77
+ async disconnect(_reason?: string): Promise<void> {
78
+ this._running = false;
79
+ if (this._scriptTimer) {
80
+ clearTimeout(this._scriptTimer);
81
+ this._scriptTimer = null;
82
+ }
83
+ this._setState({ status: "disconnected" });
84
+ }
85
+
86
+ async publishAudio(_track: MediaStreamTrack): Promise<void> {
87
+ this._setState({ localAudioPublished: true });
88
+ }
89
+
90
+ async unpublishAudio(): Promise<void> {
91
+ this._setState({ localAudioPublished: false });
92
+ }
93
+
94
+ async sendMessage(message: EphiaClientMessage): Promise<void> {
95
+ if (message.type === "session.stop") {
96
+ this._emit({
97
+ protocolVersion: PROTOCOL_VERSION,
98
+ eventId: "00000000-0000-4000-8000-000000000002",
99
+ sessionId: "mock-session",
100
+ type: "session.closed",
101
+ seq: 999,
102
+ timestampMs: Date.now(),
103
+ payload: { reason: "user_stop" },
104
+ });
105
+ }
106
+ }
107
+
108
+ onEvent(callback: (event: EphiaAudioEvent) => void): () => void {
109
+ this._eventCallbacks.push(callback);
110
+ return () => {
111
+ this._eventCallbacks = this._eventCallbacks.filter((c) => c !== callback);
112
+ };
113
+ }
114
+
115
+ onServerEvent(callback: (event: EphiaServerEvent) => void): () => void {
116
+ this._serverEventCallbacks.push(callback);
117
+ return () => {
118
+ this._serverEventCallbacks = this._serverEventCallbacks.filter((c) => c !== callback);
119
+ };
120
+ }
121
+
122
+ onTransportState(callback: (state: TransportState) => void): () => void {
123
+ this._stateCallbacks.push(callback);
124
+ return () => {
125
+ this._stateCallbacks = this._stateCallbacks.filter((c) => c !== callback);
126
+ };
127
+ }
128
+
129
+ onError(callback: (error: EphiaSdkError) => void): () => void {
130
+ this._errorCallbacks.push(callback);
131
+ return () => {
132
+ this._errorCallbacks = this._errorCallbacks.filter((c) => c !== callback);
133
+ };
134
+ }
135
+
136
+ getState(): TransportState {
137
+ return { ...this._state };
138
+ }
139
+
140
+ async prepareConnection(_url: string, _token?: string): Promise<void> {
141
+ // no-op in mock
142
+ }
143
+
144
+ async performRpc(method: string, payload: unknown, _timeout?: number): Promise<string> {
145
+ if (method === "ephia.session.sync") {
146
+ const lastSeq = Number((payload as { lastSeq?: number } | undefined)?.lastSeq ?? 0);
147
+ return JSON.stringify({
148
+ status: "ok",
149
+ mode: "replay",
150
+ fromSeq: lastSeq,
151
+ toSeq: lastSeq,
152
+ events: [],
153
+ });
154
+ }
155
+ // Délègue vers sendMessage pour que les sous-classes de test (PauseAckTransport, etc.)
156
+ // puissent continuer à intercepter via sendMessage sans modification.
157
+ const msgType = method.replace("ephia.", "") as EphiaClientMessage["type"];
158
+ await this.sendMessage({ type: msgType, payload: payload as any } as EphiaClientMessage);
159
+ return JSON.stringify({ status: "ok" });
160
+ }
161
+
162
+ /** Émet manuellement un event legacy (utile pour les tests). */
163
+ simulateEvent(event: EphiaAudioEvent): void {
164
+ this._emit(event);
165
+ }
166
+
167
+ /** Émet manuellement un event serveur V2 (utile pour les tests de TranscriptApplier). */
168
+ simulateServerEvent(event: EphiaServerEvent): void {
169
+ this._serverEventCallbacks.forEach((cb) => cb(event));
170
+ }
171
+
172
+ /** Force manuellement un état transport (utile pour les tests). */
173
+ simulateState(state: Partial<TransportState>): void {
174
+ this._setState(state);
175
+ }
176
+
177
+ // ------------------------------------------------------------------
178
+ // Internals
179
+ // ------------------------------------------------------------------
180
+
181
+ private _setState(partial: Partial<TransportState>): void {
182
+ this._state = { ...this._state, ...partial };
183
+ this._stateCallbacks.forEach((cb) => cb(this._state));
184
+ }
185
+
186
+ private _emit(event: EphiaAudioEvent): void {
187
+ const parsed = ephiaAudioEventSchema.safeParse(event);
188
+ if (!parsed.success) {
189
+ console.warn(
190
+ "[MockTransport] invalid event schema (ignored):",
191
+ parsed.error.issues
192
+ );
193
+ return;
194
+ }
195
+ this._eventCallbacks.forEach((cb) => cb(parsed.data));
196
+ }
197
+
198
+ private async _delay(ms: number): Promise<void> {
199
+ return new Promise((resolve) => setTimeout(resolve, ms));
200
+ }
201
+
202
+ private _runScript(): void {
203
+ const script = this._options.script ?? [];
204
+ let index = 0;
205
+
206
+ const runNext = () => {
207
+ if (!this._running || index >= script.length) return;
208
+ const step = script[index];
209
+ index++;
210
+
211
+ this._scriptTimer = setTimeout(() => {
212
+ if (!this._running) return;
213
+
214
+ const baseEvent: EphiaAudioEvent = {
215
+ protocolVersion: PROTOCOL_VERSION,
216
+ eventId: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
217
+ sessionId: "mock-session",
218
+ type: step.type as any,
219
+ seq: index,
220
+ timestampMs: Date.now(),
221
+ payload: (step.payload ?? {}) as any,
222
+ };
223
+
224
+ this._emit(baseEvent);
225
+ runNext();
226
+ }, step.delayMs);
227
+ };
228
+
229
+ runNext();
230
+ }
231
+ }