@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,1332 @@
1
+ import {
2
+ resolveEphiaSdkApiUrl
3
+ } from "./chunk-PSYX674B.js";
4
+ import {
5
+ initialAudioState
6
+ } from "./chunk-EGIAN7FH.js";
7
+ import {
8
+ LiveKitTransport,
9
+ canTransition,
10
+ dbgState,
11
+ initialClientState,
12
+ transitionSessionStatus
13
+ } from "./chunk-LXMCRXXF.js";
14
+ import {
15
+ EphiaSdkError
16
+ } from "./chunk-N7U5M3VZ.js";
17
+
18
+ // src/core/client/constants.ts
19
+ import { PROTOCOL_VERSION as WIRE_PROTOCOL_VERSION } from "ephia-protocol";
20
+ var SDK_VERSION = "2.0.0";
21
+ var PROTOCOL_VERSION = WIRE_PROTOCOL_VERSION;
22
+ var AUDIO_SESSIONS_PATH = "/api/v1/audio/sessions";
23
+ var TOKEN_MAX_AGE_MS = 5 * 60 * 1e3;
24
+ var ACTIVE_TOKEN_TTL_MS = 6 * 60 * 60 * 1e3;
25
+
26
+ // src/core/client/session-api.ts
27
+ import { z } from "zod";
28
+ var createSessionResponseSchema = z.object({
29
+ session_id: z.string(),
30
+ room_name: z.string(),
31
+ token: z.string(),
32
+ livekit_url: z.string(),
33
+ protocol_version: z.string().optional(),
34
+ capabilities: z.object({
35
+ supports_realtime: z.boolean().optional()
36
+ }).optional()
37
+ });
38
+ var SessionApiClient = class {
39
+ opts;
40
+ _preloadedSession = null;
41
+ _preloadPromise = null;
42
+ _preloadToken = 0;
43
+ _preloadRefreshTimer = null;
44
+ constructor(opts) {
45
+ this.opts = opts;
46
+ }
47
+ get preloadedSession() {
48
+ return this._preloadedSession;
49
+ }
50
+ get preloadToken() {
51
+ return this._preloadToken;
52
+ }
53
+ incrementPreloadToken() {
54
+ this._preloadToken++;
55
+ }
56
+ takePreloadedSession() {
57
+ const s = this._preloadedSession;
58
+ if (!s) return null;
59
+ this._preloadedSession = null;
60
+ return {
61
+ sessionId: s.sessionId,
62
+ roomName: s.roomName,
63
+ token: s.token,
64
+ livekitUrl: s.livekitUrl
65
+ };
66
+ }
67
+ discardPreloadedSession() {
68
+ const s = this._preloadedSession;
69
+ this._preloadedSession = null;
70
+ return s;
71
+ }
72
+ clearPreloadPromise() {
73
+ this._preloadPromise = null;
74
+ }
75
+ hasPreloadInFlight() {
76
+ return this._preloadPromise !== null;
77
+ }
78
+ async awaitPreloadIfPending(signal) {
79
+ const preloadPromise = this._preloadPromise;
80
+ if (!preloadPromise) return;
81
+ if (signal?.aborted) {
82
+ throw new EphiaSdkError("client.start_failed", "Start aborted");
83
+ }
84
+ if (!signal) {
85
+ await preloadPromise;
86
+ return;
87
+ }
88
+ await new Promise((resolve, reject) => {
89
+ const onAbort = () => {
90
+ signal.removeEventListener("abort", onAbort);
91
+ reject(new EphiaSdkError("client.start_failed", "Start aborted"));
92
+ };
93
+ signal.addEventListener("abort", onAbort, { once: true });
94
+ preloadPromise.then(
95
+ () => {
96
+ signal.removeEventListener("abort", onAbort);
97
+ resolve();
98
+ },
99
+ (err) => {
100
+ signal.removeEventListener("abort", onAbort);
101
+ reject(err);
102
+ }
103
+ );
104
+ });
105
+ }
106
+ cancelTimers() {
107
+ if (this._preloadRefreshTimer !== null) {
108
+ clearTimeout(this._preloadRefreshTimer);
109
+ this._preloadRefreshTimer = null;
110
+ }
111
+ }
112
+ async preload(optionsOrSignal, maybeSignal) {
113
+ const signal = optionsOrSignal && "aborted" in optionsOrSignal ? optionsOrSignal : maybeSignal;
114
+ const startOptions = optionsOrSignal && !("aborted" in optionsOrSignal) ? optionsOrSignal : void 0;
115
+ if (this.opts.isIdle && !this.opts.isIdle()) {
116
+ console.info("[EphiaAudioClient] preload: skipped, not idle");
117
+ return;
118
+ }
119
+ if (this._preloadedSession) {
120
+ console.info(
121
+ "[EphiaAudioClient] preload: already preloaded",
122
+ this._preloadedSession.sessionId
123
+ );
124
+ return;
125
+ }
126
+ if (this._preloadPromise) {
127
+ return this._preloadPromise;
128
+ }
129
+ const token = ++this._preloadToken;
130
+ const preloadPromise = this.createSession(signal, startOptions).then((data) => {
131
+ if (token !== this._preloadToken) {
132
+ void this.stopBackendSession(data.sessionId);
133
+ return;
134
+ }
135
+ this._preloadedSession = {
136
+ ...data,
137
+ createdAt: Date.now(),
138
+ initialTargetId: startOptions?.initialTargetId
139
+ };
140
+ this.opts.onPrepareConnection?.(data.livekitUrl, data.token);
141
+ this.opts.onPreloadComplete?.(data);
142
+ console.info("[EphiaAudioClient] preload:done", data.sessionId);
143
+ this.schedulePreloadRefresh();
144
+ }).catch((err) => {
145
+ if (err instanceof Error && err.name === "AbortError") return;
146
+ console.warn("[EphiaAudioClient] preload:failed", err);
147
+ this.opts.onPreloadFailed?.();
148
+ }).finally(() => {
149
+ if (this._preloadPromise === preloadPromise) {
150
+ this._preloadPromise = null;
151
+ }
152
+ });
153
+ this._preloadPromise = preloadPromise;
154
+ return this._preloadPromise;
155
+ }
156
+ schedulePreloadRefresh() {
157
+ if (this._preloadRefreshTimer !== null) {
158
+ clearTimeout(this._preloadRefreshTimer);
159
+ }
160
+ this._preloadRefreshTimer = setTimeout(() => {
161
+ this._preloadRefreshTimer = null;
162
+ if (this.opts.isIdle?.()) {
163
+ console.info("[EphiaAudioClient] preload: auto-refreshing expired token");
164
+ const orphaned = this.discardPreloadedSession();
165
+ if (orphaned) {
166
+ void this.stopBackendSession(orphaned.sessionId);
167
+ }
168
+ this._preloadPromise = null;
169
+ const options = orphaned?.initialTargetId ? { initialTargetId: orphaned.initialTargetId } : void 0;
170
+ void this.preload(options).catch(() => {
171
+ });
172
+ }
173
+ }, TOKEN_MAX_AGE_MS * 0.8);
174
+ }
175
+ /** Refresh token LiveKit — retourne le nouveau token ou null. */
176
+ async refreshToken(sessionId) {
177
+ const apiUrl = resolveEphiaSdkApiUrl(this.opts.apiUrl);
178
+ const headers = this.authHeaders();
179
+ try {
180
+ const r = await fetch(
181
+ `${apiUrl}${AUDIO_SESSIONS_PATH}/${encodeURIComponent(sessionId)}/token`,
182
+ { headers }
183
+ );
184
+ const data = await r.json();
185
+ if (data.token) {
186
+ console.info("[EphiaAudioClient] token refreshed for next reconnect", sessionId);
187
+ return data.token;
188
+ }
189
+ } catch (err) {
190
+ console.warn("[EphiaAudioClient] token refresh failed (non-fatal)", err);
191
+ }
192
+ return null;
193
+ }
194
+ get activeTokenRefreshDelayMs() {
195
+ return ACTIVE_TOKEN_TTL_MS * 0.8;
196
+ }
197
+ isPreloadedSessionExpired() {
198
+ if (!this._preloadedSession) return false;
199
+ return Date.now() - this._preloadedSession.createdAt > TOKEN_MAX_AGE_MS;
200
+ }
201
+ async createSessionWithRetry(signal, startOptions) {
202
+ const maxRetries = 2;
203
+ let lastErr;
204
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
205
+ if (signal?.aborted) {
206
+ throw new EphiaSdkError("client.start_failed", "Start aborted");
207
+ }
208
+ try {
209
+ return await this.createSession(signal, startOptions);
210
+ } catch (err) {
211
+ lastErr = err;
212
+ if (err instanceof EphiaSdkError) throw err;
213
+ if (signal?.aborted) throw err;
214
+ if (attempt < maxRetries) {
215
+ const delay = 250 * Math.pow(2, attempt) + Math.random() * 100;
216
+ console.warn(
217
+ `[EphiaAudioClient] createSession:retry attempt=${attempt + 1} delay=${Math.round(delay)}ms`,
218
+ err
219
+ );
220
+ await new Promise((resolve) => setTimeout(resolve, delay));
221
+ }
222
+ }
223
+ }
224
+ throw lastErr;
225
+ }
226
+ async createSession(signal, startOptions) {
227
+ const apiUrl = resolveEphiaSdkApiUrl(this.opts.apiUrl);
228
+ const headers = {
229
+ "Content-Type": "application/json",
230
+ ...this.authHeaders()
231
+ };
232
+ const so = this.opts.sessionOptions;
233
+ const body = {
234
+ session_mode: "dictation",
235
+ client_type: this.opts.clientType ?? "sdk",
236
+ language: so?.language ?? "fr",
237
+ mode: so?.mode ?? "smart_s",
238
+ debug_chunks: so?.debugChunks ?? false,
239
+ sdk_version: SDK_VERSION,
240
+ protocol_version: String(PROTOCOL_VERSION)
241
+ };
242
+ if (startOptions?.initialTargetId) {
243
+ body.initial_target_id = startOptions.initialTargetId;
244
+ }
245
+ const response = await fetch(`${apiUrl}${AUDIO_SESSIONS_PATH}`, {
246
+ method: "POST",
247
+ headers,
248
+ body: JSON.stringify(body),
249
+ signal
250
+ });
251
+ const rawText = await response.text();
252
+ if (!response.ok) {
253
+ let message = `Failed to create session: ${response.statusText}`;
254
+ try {
255
+ const errBody = JSON.parse(rawText);
256
+ const detail = errBody.detail;
257
+ if (typeof detail === "string") {
258
+ message = detail;
259
+ } else if (Array.isArray(detail)) {
260
+ message = detail.map((item) => {
261
+ const loc = item.loc?.join(".") ?? "body";
262
+ return `${loc}: ${item.msg ?? item.type ?? "validation error"}`;
263
+ }).join(" \u2014 ");
264
+ } else if (detail && typeof detail === "object") {
265
+ const parts = [detail.message, detail.hint].filter(Boolean);
266
+ if (parts.length > 0) message = parts.join(" \u2014 ");
267
+ }
268
+ } catch {
269
+ if (rawText) message = rawText;
270
+ }
271
+ throw new EphiaSdkError("session.create_failed", message, {
272
+ status: response.status
273
+ });
274
+ }
275
+ const parsed = createSessionResponseSchema.safeParse(JSON.parse(rawText));
276
+ if (!parsed.success) {
277
+ throw new EphiaSdkError(
278
+ "session.create_failed",
279
+ "Invalid session response from backend",
280
+ { issues: parsed.error.issues }
281
+ );
282
+ }
283
+ return {
284
+ sessionId: parsed.data.session_id,
285
+ roomName: parsed.data.room_name,
286
+ token: parsed.data.token,
287
+ livekitUrl: parsed.data.livekit_url
288
+ };
289
+ }
290
+ async stopBackendSession(sessionId) {
291
+ try {
292
+ const apiUrl = resolveEphiaSdkApiUrl(this.opts.apiUrl);
293
+ const headers = {
294
+ "Content-Type": "application/json",
295
+ ...this.authHeaders()
296
+ };
297
+ await fetch(`${apiUrl}${AUDIO_SESSIONS_PATH}/${encodeURIComponent(sessionId)}/stop`, {
298
+ method: "POST",
299
+ headers
300
+ });
301
+ console.info("[EphiaAudioClient] stopBackendSession:done", sessionId);
302
+ } catch (err) {
303
+ console.warn("[EphiaAudioClient] stopBackendSession:failed", sessionId, err);
304
+ }
305
+ }
306
+ /** Best-effort stop on tab close (fetch keepalive supports auth headers). */
307
+ stopBackendSessionOnPageHide(sessionId) {
308
+ if (typeof window === "undefined" || !sessionId) return;
309
+ try {
310
+ const apiUrl = resolveEphiaSdkApiUrl(this.opts.apiUrl);
311
+ const headers = {
312
+ "Content-Type": "application/json",
313
+ ...this.authHeaders()
314
+ };
315
+ void fetch(`${apiUrl}${AUDIO_SESSIONS_PATH}/${encodeURIComponent(sessionId)}/stop`, {
316
+ method: "POST",
317
+ headers,
318
+ keepalive: true
319
+ });
320
+ } catch {
321
+ }
322
+ }
323
+ authHeaders() {
324
+ const headers = {};
325
+ if (this.opts.apiKey) {
326
+ headers["X-API-Key"] = this.opts.apiKey;
327
+ } else if (this.opts.bearerToken) {
328
+ headers["Authorization"] = `Bearer ${this.opts.bearerToken}`;
329
+ }
330
+ if (this.opts.clientType) {
331
+ headers["X-Ephia-Client"] = this.opts.clientType;
332
+ headers["X-Ephia-App"] = this.opts.clientType;
333
+ }
334
+ return headers;
335
+ }
336
+ };
337
+
338
+ // src/core/client/audio-capture.ts
339
+ import { Room } from "livekit-client";
340
+
341
+ // src/core/audio/audio-worklet-source.ts
342
+ var AUDIO_WORKLET_SOURCE = `
343
+ class AudioCaptureProcessor extends AudioWorkletProcessor {
344
+ process(inputs) {
345
+ const input = inputs[0];
346
+ if (input.length > 0) {
347
+ this.port.postMessage(new Float32Array(input[0]));
348
+ }
349
+ return true;
350
+ }
351
+ }
352
+ registerProcessor('ephia-audio-capture', AudioCaptureProcessor);
353
+ `;
354
+ var sharedWorkletModuleUrl = null;
355
+ function getAudioWorkletModuleUrl() {
356
+ if (!sharedWorkletModuleUrl) {
357
+ sharedWorkletModuleUrl = URL.createObjectURL(
358
+ new Blob([AUDIO_WORKLET_SOURCE], { type: "application/javascript" })
359
+ );
360
+ }
361
+ return sharedWorkletModuleUrl;
362
+ }
363
+
364
+ // src/core/audio/voice-level-meter.ts
365
+ var VOLUME_THROTTLE_MS = 33;
366
+ var SILENCE_THRESHOLD_RMS = 5e-3;
367
+ var RAW_RMS_SCALE = 7;
368
+ var TARGET_SAMPLE_RATE = 16e3;
369
+ function clamp01(value) {
370
+ return Math.max(0, Math.min(1, value));
371
+ }
372
+ function isAudioContextClosed(audioContext) {
373
+ return audioContext.state === "closed";
374
+ }
375
+ function resampleLinear(input, sourceSampleRate, targetSampleRate) {
376
+ if (sourceSampleRate === targetSampleRate || input.length <= 1) {
377
+ return input;
378
+ }
379
+ const outputLength = Math.max(
380
+ 1,
381
+ Math.round(input.length * targetSampleRate / sourceSampleRate)
382
+ );
383
+ const output = new Float32Array(outputLength);
384
+ const scale = sourceSampleRate / targetSampleRate;
385
+ for (let i = 0; i < outputLength; i += 1) {
386
+ const sourceIndex = i * scale;
387
+ const leftIndex = Math.floor(sourceIndex);
388
+ const rightIndex = Math.min(leftIndex + 1, input.length - 1);
389
+ const fraction = sourceIndex - leftIndex;
390
+ const left = input[leftIndex] ?? 0;
391
+ const right = input[rightIndex] ?? left;
392
+ output[i] = left + (right - left) * fraction;
393
+ }
394
+ return output;
395
+ }
396
+ var VoiceLevelMeter = class {
397
+ audioContext = null;
398
+ processor = null;
399
+ silentGain = null;
400
+ source = null;
401
+ callbacks = /* @__PURE__ */ new Set();
402
+ pendingRms = 0;
403
+ lastVolumeUpdateMs = 0;
404
+ volumeTimer = null;
405
+ _pipelineReady = null;
406
+ // Startup capture state (shared pipeline avec StartupAudioBuffer)
407
+ _captureBuffers = [];
408
+ _captureTotalSamples = 0;
409
+ _captureMaxSamples = 0;
410
+ _captureEnabled = false;
411
+ _actualSampleRate = TARGET_SAMPLE_RATE;
412
+ attach(stream) {
413
+ this.detach();
414
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
415
+ if (!AudioContextClass) {
416
+ console.warn("[VoiceLevelMeter] AudioContext not supported");
417
+ return Promise.resolve();
418
+ }
419
+ this._pipelineReady = this._startWorkletPipeline(stream, AudioContextClass);
420
+ return this._pipelineReady;
421
+ }
422
+ isAttached() {
423
+ return this.processor !== null;
424
+ }
425
+ async _startWorkletPipeline(stream, AudioContextClass) {
426
+ try {
427
+ const audioContext = new AudioContextClass({
428
+ sampleRate: TARGET_SAMPLE_RATE,
429
+ latencyHint: "interactive"
430
+ });
431
+ this.audioContext = audioContext;
432
+ this._actualSampleRate = audioContext.sampleRate || TARGET_SAMPLE_RATE;
433
+ if (audioContext.state === "suspended") {
434
+ await audioContext.resume();
435
+ }
436
+ if (this.audioContext !== audioContext || isAudioContextClosed(audioContext)) {
437
+ return;
438
+ }
439
+ await audioContext.audioWorklet.addModule(getAudioWorkletModuleUrl());
440
+ if (this.audioContext !== audioContext || isAudioContextClosed(audioContext)) {
441
+ return;
442
+ }
443
+ const source = audioContext.createMediaStreamSource(stream);
444
+ const processor = new AudioWorkletNode(audioContext, "ephia-audio-capture");
445
+ const silentGain = audioContext.createGain();
446
+ silentGain.gain.value = 0;
447
+ processor.connect(silentGain);
448
+ silentGain.connect(audioContext.destination);
449
+ processor.port.onmessage = (event) => {
450
+ const inputData = event.data;
451
+ if (!inputData?.length) return;
452
+ if (this._captureEnabled) {
453
+ const chunk = new Float32Array(inputData);
454
+ this._captureBuffers.push(chunk);
455
+ this._captureTotalSamples += chunk.length;
456
+ while (this._captureTotalSamples > this._captureMaxSamples && this._captureBuffers.length > 0) {
457
+ const removed = this._captureBuffers.shift();
458
+ this._captureTotalSamples -= removed.length;
459
+ }
460
+ }
461
+ let sum = 0;
462
+ for (let i = 0; i < inputData.length; i++) {
463
+ const x = inputData[i];
464
+ sum += x * x;
465
+ }
466
+ const rawRms = Math.sqrt(sum / inputData.length);
467
+ this.pendingRms = clamp01(rawRms * RAW_RMS_SCALE);
468
+ const now = Date.now();
469
+ if (this.volumeTimer === null && now - this.lastVolumeUpdateMs > VOLUME_THROTTLE_MS) {
470
+ this.volumeTimer = setTimeout(() => {
471
+ this.volumeTimer = null;
472
+ this.lastVolumeUpdateMs = Date.now();
473
+ this._emitSnapshot();
474
+ }, VOLUME_THROTTLE_MS);
475
+ }
476
+ };
477
+ this.source = source;
478
+ this.processor = processor;
479
+ this.silentGain = silentGain;
480
+ source.connect(processor);
481
+ } catch (err) {
482
+ console.warn("[VoiceLevelMeter] worklet pipeline failed", err);
483
+ this.detach();
484
+ }
485
+ }
486
+ _emitSnapshot() {
487
+ const rms = this.pendingRms;
488
+ const snapshot = {
489
+ level: clamp01(rms),
490
+ rms
491
+ };
492
+ const isSilent = rms < SILENCE_THRESHOLD_RMS;
493
+ if (isSilent) {
494
+ snapshot.level = 0;
495
+ snapshot.rms = 0;
496
+ }
497
+ this.callbacks.forEach((cb) => cb(snapshot));
498
+ }
499
+ detach() {
500
+ if (this.volumeTimer !== null) {
501
+ clearTimeout(this.volumeTimer);
502
+ this.volumeTimer = null;
503
+ }
504
+ this.pendingRms = 0;
505
+ this.lastVolumeUpdateMs = 0;
506
+ if (this.source) {
507
+ try {
508
+ this.source.disconnect();
509
+ } catch {
510
+ }
511
+ this.source = null;
512
+ }
513
+ if (this.processor) {
514
+ try {
515
+ this.processor.port.onmessage = null;
516
+ this.processor.disconnect();
517
+ } catch {
518
+ }
519
+ this.processor = null;
520
+ }
521
+ if (this.silentGain) {
522
+ try {
523
+ this.silentGain.disconnect();
524
+ } catch {
525
+ }
526
+ this.silentGain = null;
527
+ }
528
+ if (this.audioContext) {
529
+ this.audioContext.close().catch(() => {
530
+ });
531
+ this.audioContext = null;
532
+ }
533
+ }
534
+ onLevel(callback) {
535
+ this.callbacks.add(callback);
536
+ return () => {
537
+ this.callbacks.delete(callback);
538
+ };
539
+ }
540
+ /** Active la capture startup sur le pipeline existant. maxSamples = taille FIFO. */
541
+ enableStartupCapture(maxSamples) {
542
+ this._captureBuffers = [];
543
+ this._captureTotalSamples = 0;
544
+ this._captureMaxSamples = Math.ceil(
545
+ maxSamples * this._actualSampleRate / TARGET_SAMPLE_RATE
546
+ );
547
+ this._captureEnabled = true;
548
+ }
549
+ /** Stoppe la capture et retourne le PCM16 accumulé (ou null si vide). Le level meter continue. */
550
+ drainStartupCapture() {
551
+ this._captureEnabled = false;
552
+ const buffers = this._captureBuffers;
553
+ const totalSamples = this._captureTotalSamples;
554
+ this._captureBuffers = [];
555
+ this._captureTotalSamples = 0;
556
+ if (buffers.length === 0) return null;
557
+ const float32 = new Float32Array(totalSamples);
558
+ let offset = 0;
559
+ for (const buf of buffers) {
560
+ float32.set(buf, offset);
561
+ offset += buf.length;
562
+ }
563
+ const resampled = resampleLinear(
564
+ float32,
565
+ this._actualSampleRate,
566
+ TARGET_SAMPLE_RATE
567
+ );
568
+ const pcm16 = new Int16Array(resampled.length);
569
+ for (let i = 0; i < resampled.length; i++) {
570
+ const s = Math.max(-1, Math.min(1, resampled[i] ?? 0));
571
+ pcm16[i] = s < 0 ? s * 32768 : s * 32767;
572
+ }
573
+ return pcm16.buffer;
574
+ }
575
+ };
576
+
577
+ // src/core/client/audio-capture.ts
578
+ var AudioCaptureManager = class {
579
+ voiceLevelMeter = new VoiceLevelMeter();
580
+ _activeStream = null;
581
+ _warmupStream = null;
582
+ _warmupPromise = null;
583
+ _levelUnsubscribe;
584
+ _silenceStartMs = null;
585
+ _lastAudioState = { ...initialAudioState };
586
+ deps;
587
+ constructor(deps) {
588
+ this.deps = deps;
589
+ }
590
+ get lastAudioState() {
591
+ return this._lastAudioState;
592
+ }
593
+ get silenceStartMs() {
594
+ return this._silenceStartMs;
595
+ }
596
+ get hasActiveStream() {
597
+ return !!this._activeStream;
598
+ }
599
+ get hasWarmupStream() {
600
+ return !!this._warmupStream;
601
+ }
602
+ setActiveStream(stream) {
603
+ this._activeStream = stream;
604
+ }
605
+ emitAudioState(state) {
606
+ this._lastAudioState = { ...state };
607
+ this.deps.onAudioState(this._lastAudioState);
608
+ }
609
+ patchAudioState(patch) {
610
+ this.emitAudioState({ ...this._lastAudioState, ...patch });
611
+ }
612
+ stopAudioLevelImmediately() {
613
+ this._levelUnsubscribe?.();
614
+ this._levelUnsubscribe = void 0;
615
+ this.voiceLevelMeter.detach();
616
+ this.patchAudioState({
617
+ level: 0,
618
+ rms: 0,
619
+ peak: 0,
620
+ isSilent: true,
621
+ silenceDurationMs: 0,
622
+ localAudioPublished: false,
623
+ muted: false
624
+ });
625
+ }
626
+ async warmupMic() {
627
+ if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) return;
628
+ if (this._warmupStream && this._warmupStream.getAudioTracks().some((t) => t.readyState === "live")) {
629
+ console.info("[EphiaAudioClient] warmupMic: already warmed up");
630
+ return;
631
+ }
632
+ if (this._warmupPromise) {
633
+ return this._warmupPromise;
634
+ }
635
+ this._warmupPromise = this.acquireMicStream().then(async (stream) => {
636
+ this._warmupStream = stream;
637
+ await this.voiceLevelMeter.attach(stream);
638
+ console.info("[EphiaAudioClient] warmupMic:done", {
639
+ trackCount: stream.getAudioTracks().length
640
+ });
641
+ }).catch((err) => {
642
+ console.warn("[EphiaAudioClient] warmupMic:failed", err);
643
+ }).finally(() => {
644
+ this._warmupPromise = null;
645
+ });
646
+ return this._warmupPromise;
647
+ }
648
+ injectWarmupStream(stream) {
649
+ this.cleanupWarmupStream();
650
+ this._warmupStream = stream;
651
+ this.voiceLevelMeter.attach(stream).catch(() => {
652
+ });
653
+ }
654
+ enableStartupCapture(maxSamples) {
655
+ this.voiceLevelMeter.enableStartupCapture(maxSamples);
656
+ }
657
+ drainStartupCapture() {
658
+ return this.voiceLevelMeter.drainStartupCapture();
659
+ }
660
+ async acquireMicStream(deviceId) {
661
+ if (this._warmupStream && this._warmupStream.getAudioTracks().some((t) => t.readyState === "live")) {
662
+ const warmupTracks = this._warmupStream.getAudioTracks();
663
+ const warmupDeviceId = warmupTracks[0]?.getSettings().deviceId;
664
+ if (!deviceId || warmupDeviceId === deviceId) {
665
+ console.info("[EphiaAudioClient] getUserMedia: using warmed-up stream", {
666
+ warmupDeviceId
667
+ });
668
+ const stream2 = this._warmupStream;
669
+ this._warmupStream = null;
670
+ return stream2;
671
+ }
672
+ console.info("[EphiaAudioClient] getUserMedia: warmup device mismatch, releasing warmup", {
673
+ warmupDeviceId,
674
+ requestedDeviceId: deviceId
675
+ });
676
+ this.cleanupWarmupStream();
677
+ }
678
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
679
+ const isSecure = typeof window !== "undefined" && window.isSecureContext === false;
680
+ throw new EphiaSdkError(
681
+ "audio.no_input_device",
682
+ isSecure ? "getUserMedia not available in non-secure context (requires HTTPS or localhost)" : "getUserMedia not available in this browser/context"
683
+ );
684
+ }
685
+ console.info("[EphiaAudioClient] getUserMedia:start", deviceId ? { deviceId } : void 0);
686
+ const audioConstraints = {
687
+ echoCancellation: { ideal: false },
688
+ noiseSuppression: { ideal: false },
689
+ autoGainControl: { ideal: false },
690
+ channelCount: { ideal: 1 },
691
+ sampleRate: { ideal: 16e3 }
692
+ };
693
+ if (deviceId) {
694
+ audioConstraints.deviceId = { exact: deviceId };
695
+ }
696
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints });
697
+ const tracks = stream.getAudioTracks();
698
+ console.info("[EphiaAudioClient] getUserMedia:done", {
699
+ trackCount: tracks.length,
700
+ tracks: tracks.map((t) => ({
701
+ id: t.id,
702
+ label: t.label,
703
+ readyState: t.readyState
704
+ }))
705
+ });
706
+ return stream;
707
+ }
708
+ async setupAudioLevel(stream) {
709
+ const track = stream.getAudioTracks()[0];
710
+ if (!track) return;
711
+ const trackSettings = track.getSettings();
712
+ const deviceId = trackSettings.deviceId ?? "";
713
+ const deviceLabel = track.label ?? "";
714
+ this.emitAudioState({
715
+ permission: "granted",
716
+ hasInputDevice: true,
717
+ inputDeviceId: deviceId,
718
+ inputDeviceLabel: deviceLabel,
719
+ level: 0,
720
+ rms: 0,
721
+ peak: 0,
722
+ isSilent: false,
723
+ silenceDurationMs: 0,
724
+ muted: track.muted,
725
+ localAudioPublished: false,
726
+ micReady: true,
727
+ speaking: false
728
+ });
729
+ this._levelUnsubscribe?.();
730
+ if (!this.voiceLevelMeter.isAttached()) {
731
+ try {
732
+ await this.voiceLevelMeter.attach(stream);
733
+ } catch (err) {
734
+ console.warn(
735
+ "[EphiaAudioClient] _setupAudioLevel: voiceLevelMeter attach failed (non-fatal)",
736
+ err
737
+ );
738
+ }
739
+ }
740
+ this._levelUnsubscribe = this.voiceLevelMeter.onLevel((snapshot) => {
741
+ const isSilent = snapshot.level < 5e-3;
742
+ if (isSilent && this._silenceStartMs === null) {
743
+ this._silenceStartMs = Date.now();
744
+ } else if (!isSilent) {
745
+ this._silenceStartMs = null;
746
+ }
747
+ const silenceDurationMs = isSilent && this._silenceStartMs !== null ? Date.now() - this._silenceStartMs : 0;
748
+ this.patchAudioState({
749
+ permission: "granted",
750
+ hasInputDevice: true,
751
+ inputDeviceId: deviceId,
752
+ inputDeviceLabel: deviceLabel,
753
+ level: snapshot.level,
754
+ rms: snapshot.rms,
755
+ peak: snapshot.rms,
756
+ isSilent,
757
+ silenceDurationMs,
758
+ muted: track.muted,
759
+ localAudioPublished: this.deps.getLocalAudioPublished(),
760
+ micReady: true
761
+ });
762
+ });
763
+ }
764
+ cleanupActiveStream() {
765
+ this._levelUnsubscribe?.();
766
+ this._levelUnsubscribe = void 0;
767
+ this._silenceStartMs = null;
768
+ this.voiceLevelMeter.detach();
769
+ if (this._activeStream) {
770
+ this._activeStream.getTracks().forEach((t) => t.stop());
771
+ this._activeStream = null;
772
+ }
773
+ }
774
+ cleanupWarmupStream() {
775
+ if (this._warmupStream) {
776
+ this._warmupStream.getTracks().forEach((t) => t.stop());
777
+ this._warmupStream = null;
778
+ }
779
+ this._warmupPromise = null;
780
+ }
781
+ async getAvailableMicrophones() {
782
+ return Room.getLocalDevices("audioinput");
783
+ }
784
+ };
785
+
786
+ // src/core/client/EphiaAudioClient.ts
787
+ var EphiaAudioClient = class {
788
+ options;
789
+ transport;
790
+ state = { ...initialClientState };
791
+ _unbindTransport;
792
+ _stopping = false;
793
+ _intentionalDisconnect = false;
794
+ _startAbortController = null;
795
+ _lastConnectParams = null;
796
+ _tokenRefreshTimer = null;
797
+ _serverSessionClosed = false;
798
+ sessionApi;
799
+ audio;
800
+ constructor(options = {}) {
801
+ this.options = options;
802
+ this.transport = options.transport ?? new LiveKitTransport();
803
+ this.sessionApi = new SessionApiClient({
804
+ apiUrl: options.apiUrl,
805
+ apiKey: options.apiKey,
806
+ bearerToken: options.bearerToken,
807
+ clientType: options.clientType,
808
+ sessionOptions: options.sessionOptions,
809
+ isIdle: () => this.state.status === "idle",
810
+ onPrepareConnection: (url, token) => {
811
+ this.transport.prepareConnection?.(url, token).catch(() => {
812
+ });
813
+ },
814
+ onPreloadComplete: () => {
815
+ this._emitSdkDebug("sdk.preload.done", { ts: Date.now() });
816
+ },
817
+ onPreloadFailed: () => {
818
+ this._emitSdkDebug("sdk.preload.failed", { ts: Date.now() });
819
+ }
820
+ });
821
+ this.audio = new AudioCaptureManager({
822
+ onAudioState: (s) => this.options.onAudioState?.(s),
823
+ getLocalAudioPublished: () => this.state.isMicEnabled
824
+ });
825
+ }
826
+ async preload(optionsOrSignal, maybeSignal) {
827
+ if (this.state.status !== "idle") return;
828
+ if (optionsOrSignal && "aborted" in optionsOrSignal) {
829
+ return this.sessionApi.preload(optionsOrSignal);
830
+ }
831
+ return this.sessionApi.preload(optionsOrSignal, maybeSignal);
832
+ }
833
+ releasePreloadedSession() {
834
+ this.sessionApi.cancelTimers();
835
+ const orphaned = this.sessionApi.discardPreloadedSession();
836
+ if (orphaned) {
837
+ this.sessionApi.stopBackendSession(orphaned.sessionId).catch(() => {
838
+ });
839
+ }
840
+ }
841
+ async warmupMic() {
842
+ return this.audio.warmupMic();
843
+ }
844
+ injectWarmupStream(stream) {
845
+ this.audio.injectWarmupStream(stream);
846
+ }
847
+ async start(options) {
848
+ if (this.state.status === "ended" || this.state.status === "error") {
849
+ this._resetForStart();
850
+ }
851
+ if (this.state.status === "paused" && this._hasReusableRoom()) {
852
+ await this._resumeAudio(options);
853
+ return { sessionId: this.state.sessionId };
854
+ }
855
+ if (!canTransition(this.state.status, "creating_session")) {
856
+ throw new EphiaSdkError(
857
+ "client.invalid_state",
858
+ `Cannot start while status is ${this.state.status}`
859
+ );
860
+ }
861
+ this._setStatus("creating_session");
862
+ this._startAbortController = new AbortController();
863
+ const { signal } = this._startAbortController;
864
+ let acquiredStream;
865
+ let sessionData;
866
+ try {
867
+ const micPromise = this.audio.acquireMicStream(this.options.preferredAudioInputDeviceId).then((stream) => {
868
+ if (!signal.aborted) {
869
+ acquiredStream = stream;
870
+ this.audio.setActiveStream(stream);
871
+ } else {
872
+ stream.getTracks().forEach((t) => t.stop());
873
+ }
874
+ });
875
+ let sessionPromise;
876
+ const preloaded = this.sessionApi.takePreloadedSession();
877
+ if (preloaded) {
878
+ sessionData = preloaded;
879
+ this.state = { ...this.state, sessionId: preloaded.sessionId, roomName: preloaded.roomName };
880
+ this.options.onStateChange?.(this.getState());
881
+ sessionPromise = Promise.resolve();
882
+ } else {
883
+ if (this.sessionApi.hasPreloadInFlight()) {
884
+ await this.sessionApi.awaitPreloadIfPending(signal);
885
+ const fromFlight = this.sessionApi.takePreloadedSession();
886
+ if (fromFlight) {
887
+ sessionData = fromFlight;
888
+ this.state = { ...this.state, sessionId: fromFlight.sessionId, roomName: fromFlight.roomName };
889
+ this.options.onStateChange?.(this.getState());
890
+ sessionPromise = Promise.resolve();
891
+ } else {
892
+ sessionPromise = this.sessionApi.createSessionWithRetry(signal, options).then((data) => {
893
+ if (!signal.aborted) {
894
+ sessionData = data;
895
+ this.state = { ...this.state, sessionId: data.sessionId, roomName: data.roomName };
896
+ this.options.onStateChange?.(this.getState());
897
+ }
898
+ });
899
+ }
900
+ } else {
901
+ sessionPromise = this.sessionApi.createSessionWithRetry(signal, options).then((data) => {
902
+ if (!signal.aborted) {
903
+ sessionData = data;
904
+ this.state = { ...this.state, sessionId: data.sessionId, roomName: data.roomName };
905
+ this.options.onStateChange?.(this.getState());
906
+ }
907
+ });
908
+ }
909
+ }
910
+ await Promise.all([micPromise, sessionPromise]);
911
+ if (signal.aborted) {
912
+ this._handleStartAborted();
913
+ return { sessionId: null };
914
+ }
915
+ if (!acquiredStream || !sessionData) {
916
+ throw new EphiaSdkError("client.start_failed", "Missing stream or session");
917
+ }
918
+ this._setStatus("connecting_transport");
919
+ this._unbindTransport?.();
920
+ this._unbindTransport = this._bindTransportEvents();
921
+ const connectParams = {
922
+ livekitUrl: sessionData.livekitUrl,
923
+ token: sessionData.token,
924
+ roomName: sessionData.roomName,
925
+ sessionId: sessionData.sessionId
926
+ };
927
+ this._lastConnectParams = connectParams;
928
+ this._scheduleTokenRefresh(sessionData.sessionId);
929
+ await this._connectTransportWithRetry(connectParams, signal);
930
+ await this._sendInitialTargetBeforeAudio(options?.initialTargetId, signal);
931
+ if (signal.aborted) {
932
+ await this._disconnectTransport().catch(() => {
933
+ });
934
+ this._handleStartAborted();
935
+ return { sessionId: null };
936
+ }
937
+ this._setStatus("ready");
938
+ const track = acquiredStream.getAudioTracks()[0];
939
+ if (!track) {
940
+ throw new EphiaSdkError("audio.no_input_device", "No audio track");
941
+ }
942
+ if (!track.enabled) track.enabled = true;
943
+ try {
944
+ await this.transport.publishAudio(track, { enableNoiseFilter: this.options.noiseFilter });
945
+ } catch (err) {
946
+ if (err instanceof EphiaSdkError) throw err;
947
+ const message = err instanceof Error ? err.message : String(err);
948
+ throw new EphiaSdkError("audio.track_publish_failed", message);
949
+ }
950
+ if (signal.aborted) {
951
+ await this.transport.unpublishAudio().catch(() => {
952
+ });
953
+ await this._disconnectTransport().catch(() => {
954
+ });
955
+ this._handleStartAborted();
956
+ return { sessionId: null };
957
+ }
958
+ this._setStatus("recording");
959
+ this.state = { ...this.state, isMicEnabled: true };
960
+ this.options.onStateChange?.(this.getState());
961
+ await this.audio.setupAudioLevel(acquiredStream);
962
+ this._emitSdkDebug("sdk.record.started", { sessionId: sessionData.sessionId, ts: Date.now() });
963
+ return { sessionId: sessionData.sessionId };
964
+ } catch (err) {
965
+ if (signal.aborted) {
966
+ await this._disconnectTransport().catch(() => {
967
+ });
968
+ this._handleStartAborted();
969
+ return { sessionId: null };
970
+ }
971
+ const code = err instanceof EphiaSdkError ? err.code : "client.start_failed";
972
+ const message = err instanceof Error ? err.message : String(err);
973
+ this._setErrorState(code, message);
974
+ this.audio.cleanupActiveStream();
975
+ await this._disconnectTransport().catch(() => {
976
+ });
977
+ throw err;
978
+ } finally {
979
+ if (this._startAbortController?.signal === signal) {
980
+ this._startAbortController = null;
981
+ }
982
+ }
983
+ }
984
+ /** Arrête l'enregistrement sans fermer la room (reprise possible via start()). */
985
+ async stop() {
986
+ if (this._stopping) return;
987
+ this.audio.stopAudioLevelImmediately?.();
988
+ if (this._startAbortController) {
989
+ this._stopping = true;
990
+ this._startAbortController.abort();
991
+ this._startAbortController = null;
992
+ this._handleStartAborted();
993
+ this._stopping = false;
994
+ return;
995
+ }
996
+ if (this.state.status === "idle" || this.state.status === "paused" || this.state.status === "ended" || this.state.status === "error" || this.state.status === "disposed") {
997
+ return;
998
+ }
999
+ if (this.state.status === "creating_session" || this.state.status === "connecting_transport") {
1000
+ this._stopping = true;
1001
+ await this._disconnectTransport().catch(() => {
1002
+ });
1003
+ this._handleStartAborted();
1004
+ this._stopping = false;
1005
+ return;
1006
+ }
1007
+ this._stopping = true;
1008
+ try {
1009
+ await this.transport.unpublishAudio().catch(() => {
1010
+ });
1011
+ this.audio.cleanupActiveStream();
1012
+ this.state = { ...this.state, isMicEnabled: false };
1013
+ this._setStatus("paused");
1014
+ this.options.onStateChange?.(this.getState());
1015
+ } finally {
1016
+ this._stopping = false;
1017
+ }
1018
+ }
1019
+ /** Termine complètement la session : déconnecte la room et stop le backend. */
1020
+ async endSession() {
1021
+ if (this._stopping) return;
1022
+ this.audio.stopAudioLevelImmediately?.();
1023
+ if (this._startAbortController) {
1024
+ this._stopping = true;
1025
+ this._startAbortController.abort();
1026
+ this._startAbortController = null;
1027
+ this._handleStartAborted();
1028
+ this._stopping = false;
1029
+ return;
1030
+ }
1031
+ if (this.state.status === "idle" || this.state.status === "ended" || this.state.status === "disposed") {
1032
+ return;
1033
+ }
1034
+ this._stopping = true;
1035
+ const sessionId = this.state.sessionId;
1036
+ try {
1037
+ await this.transport.unpublishAudio().catch(() => {
1038
+ });
1039
+ this.audio.cleanupActiveStream();
1040
+ this._intentionalDisconnect = true;
1041
+ await this._disconnectTransport().catch(() => {
1042
+ });
1043
+ if (sessionId) {
1044
+ this.sessionApi.stopBackendSession(sessionId).catch(() => {
1045
+ });
1046
+ }
1047
+ this._markSessionEnded();
1048
+ } finally {
1049
+ this._intentionalDisconnect = false;
1050
+ this._stopping = false;
1051
+ }
1052
+ }
1053
+ registerPageHideStop() {
1054
+ if (typeof window === "undefined") return () => {
1055
+ };
1056
+ const handler = () => {
1057
+ const sessionId = this.state.sessionId;
1058
+ if (sessionId) this.sessionApi.stopBackendSessionOnPageHide(sessionId);
1059
+ };
1060
+ window.addEventListener("pagehide", handler);
1061
+ return () => window.removeEventListener("pagehide", handler);
1062
+ }
1063
+ async dispose() {
1064
+ if (this._startAbortController) {
1065
+ this._startAbortController.abort();
1066
+ this._startAbortController = null;
1067
+ this._handleStartAborted();
1068
+ return;
1069
+ }
1070
+ const status = this.state.status;
1071
+ if (status === "recording" || status === "ready") {
1072
+ await this.endSession();
1073
+ return;
1074
+ }
1075
+ this.sessionApi.cancelTimers();
1076
+ const orphaned = this.sessionApi.discardPreloadedSession();
1077
+ if (orphaned) this.sessionApi.stopBackendSession(orphaned.sessionId).catch(() => {
1078
+ });
1079
+ this._intentionalDisconnect = true;
1080
+ await this._disconnectTransport().catch(() => {
1081
+ });
1082
+ this._intentionalDisconnect = false;
1083
+ this._unbindTransport?.();
1084
+ this._unbindTransport = void 0;
1085
+ this.audio.cleanupActiveStream();
1086
+ this.audio.cleanupWarmupStream?.();
1087
+ this.state = { ...this.state, status: "disposed" };
1088
+ this.options.onStateChange?.(this.getState());
1089
+ }
1090
+ getState() {
1091
+ return { ...this.state };
1092
+ }
1093
+ setPreferredAudioInputDeviceId(deviceId) {
1094
+ this.options = { ...this.options, preferredAudioInputDeviceId: deviceId };
1095
+ }
1096
+ onTransportState(callback) {
1097
+ return this.transport.onTransportState(callback);
1098
+ }
1099
+ onServerEvent(callback) {
1100
+ return this.transport.onServerEvent(callback);
1101
+ }
1102
+ async sendMessage(message) {
1103
+ await this.transport.sendMessage(message);
1104
+ }
1105
+ async getAvailableMicrophones() {
1106
+ return this.audio.getAvailableMicrophones();
1107
+ }
1108
+ async switchMicrophone(deviceId) {
1109
+ await this.transport.switchActiveDevice?.("audioinput", deviceId);
1110
+ }
1111
+ exportTrace() {
1112
+ return {
1113
+ sdkVersion: SDK_VERSION,
1114
+ protocolVersion: PROTOCOL_VERSION,
1115
+ sessionId: this.state.sessionId,
1116
+ state: this.state
1117
+ };
1118
+ }
1119
+ getDebugSnapshot() {
1120
+ return {
1121
+ sdkVersion: SDK_VERSION,
1122
+ protocolVersion: PROTOCOL_VERSION,
1123
+ state: this.state,
1124
+ lastAudioState: this.audio.lastAudioState,
1125
+ stopping: this._stopping,
1126
+ intentionalDisconnect: this._intentionalDisconnect,
1127
+ preloadedSession: this.sessionApi.preloadedSession,
1128
+ lastConnectParams: this._lastConnectParams ? { ...this._lastConnectParams, token: "[redacted]" } : null,
1129
+ hasActiveStream: this.audio.hasActiveStream
1130
+ };
1131
+ }
1132
+ // ── Internals ────────────────────────────────────────────────────────────────
1133
+ _bindTransportEvents() {
1134
+ const unsubServerEvent = this.transport.onServerEvent((event) => {
1135
+ if (event.type === "session.error") {
1136
+ this._setErrorState(event.payload.code, event.payload.message);
1137
+ this._serverSessionClosed = true;
1138
+ }
1139
+ if (event.type === "session.status") {
1140
+ if (event.payload.status === "closed") {
1141
+ this._serverSessionClosed = true;
1142
+ } else if (event.payload.status === "ready") {
1143
+ this._serverSessionClosed = false;
1144
+ }
1145
+ }
1146
+ });
1147
+ const unsubState = this.transport.onTransportState((tState) => {
1148
+ if (tState.status === "disconnected" && !this._intentionalDisconnect && !this._stopping) {
1149
+ this._setErrorState("transport.disconnected", "Connexion perdue");
1150
+ }
1151
+ });
1152
+ const unsubError = this.transport.onError((err) => {
1153
+ this._setErrorState(err.code, err.message);
1154
+ });
1155
+ return () => {
1156
+ unsubServerEvent();
1157
+ unsubState();
1158
+ unsubError();
1159
+ };
1160
+ }
1161
+ async _resumeAudio(options) {
1162
+ if (this.state.isMicEnabled) return;
1163
+ try {
1164
+ const stream = await this.audio.acquireMicStream(this.options.preferredAudioInputDeviceId);
1165
+ if (!stream) throw new EphiaSdkError("audio.no_input_device", "No mic stream");
1166
+ this.audio.setActiveStream(stream);
1167
+ const track = stream.getAudioTracks()[0];
1168
+ if (!track) throw new EphiaSdkError("audio.no_input_device", "No audio track");
1169
+ await this._sendInitialTargetBeforeAudio(options?.initialTargetId);
1170
+ try {
1171
+ await this.transport.publishAudio(track, { enableNoiseFilter: this.options.noiseFilter });
1172
+ } catch (err) {
1173
+ if (err instanceof EphiaSdkError) throw err;
1174
+ const message = err instanceof Error ? err.message : String(err);
1175
+ throw new EphiaSdkError("audio.track_publish_failed", message);
1176
+ }
1177
+ await this.transport.sendMessage({
1178
+ type: "session.resume",
1179
+ payload: {
1180
+ context: options?.context
1181
+ }
1182
+ });
1183
+ this._setStatus("recording");
1184
+ this.state = { ...this.state, isMicEnabled: true };
1185
+ this.options.onStateChange?.(this.getState());
1186
+ await this.audio.setupAudioLevel(stream);
1187
+ } catch (err) {
1188
+ const code = err instanceof EphiaSdkError ? err.code : "client.start_failed";
1189
+ const message = err instanceof Error ? err.message : String(err);
1190
+ this._setErrorState(code, message);
1191
+ this.audio.cleanupActiveStream();
1192
+ throw err;
1193
+ }
1194
+ }
1195
+ _hasReusableRoom() {
1196
+ const status = this.transport.getState().status;
1197
+ return !!this.state.sessionId && (status === "connected" || status === "reconnected");
1198
+ }
1199
+ _handleStartAborted() {
1200
+ this.audio.cleanupActiveStream();
1201
+ const orphaned = this.sessionApi.discardPreloadedSession();
1202
+ if (orphaned) this.sessionApi.stopBackendSession(orphaned.sessionId).catch(() => {
1203
+ });
1204
+ this._forceIdle();
1205
+ }
1206
+ _forceIdle() {
1207
+ this.state = { ...this.state, status: "idle", isMicEnabled: false };
1208
+ this.options.onStateChange?.(this.getState());
1209
+ }
1210
+ _resetForStart() {
1211
+ this._unbindTransport?.();
1212
+ this._unbindTransport = void 0;
1213
+ this.audio.cleanupActiveStream();
1214
+ const orphaned = this.sessionApi.discardPreloadedSession();
1215
+ if (orphaned) this.sessionApi.stopBackendSession(orphaned.sessionId).catch(() => {
1216
+ });
1217
+ this.sessionApi.cancelTimers();
1218
+ if (this._tokenRefreshTimer !== null) {
1219
+ clearTimeout(this._tokenRefreshTimer);
1220
+ this._tokenRefreshTimer = null;
1221
+ }
1222
+ this._lastConnectParams = null;
1223
+ this.state = {
1224
+ ...this.state,
1225
+ status: "idle",
1226
+ sessionId: null,
1227
+ roomName: null,
1228
+ error: null,
1229
+ isMicEnabled: false
1230
+ };
1231
+ }
1232
+ _markSessionEnded() {
1233
+ this._unbindTransport?.();
1234
+ this._unbindTransport = void 0;
1235
+ if (this._tokenRefreshTimer !== null) {
1236
+ clearTimeout(this._tokenRefreshTimer);
1237
+ this._tokenRefreshTimer = null;
1238
+ }
1239
+ this._lastConnectParams = null;
1240
+ if (this.state.status !== "ended" && this.state.status !== "error" && this.state.status !== "disposed") {
1241
+ this._setStatus("ended");
1242
+ }
1243
+ this.state = { ...this.state, isMicEnabled: false };
1244
+ this.options.onStateChange?.(this.getState());
1245
+ }
1246
+ _setStatus(next) {
1247
+ const current = this.state.status;
1248
+ if (current === next) return;
1249
+ const status = transitionSessionStatus(current, next);
1250
+ this.state = { ...this.state, status };
1251
+ dbgState("sdk.client", this.getDebugSnapshot());
1252
+ this.options.onStateChange?.(this.getState());
1253
+ }
1254
+ _setErrorState(code, message) {
1255
+ const error = { code, message };
1256
+ const current = this.state.status;
1257
+ if (canTransition(current, "error")) {
1258
+ this._setStatus("error");
1259
+ } else {
1260
+ this.state = { ...this.state, status: "error" };
1261
+ }
1262
+ this.state = { ...this.state, error, isMicEnabled: false };
1263
+ this.options.onStateChange?.(this.getState());
1264
+ this.options.onError?.(error);
1265
+ }
1266
+ _scheduleTokenRefresh(sessionId) {
1267
+ if (this._tokenRefreshTimer !== null) clearTimeout(this._tokenRefreshTimer);
1268
+ this._tokenRefreshTimer = setTimeout(() => {
1269
+ this._tokenRefreshTimer = null;
1270
+ if (!this._lastConnectParams) return;
1271
+ void this.sessionApi.refreshToken(sessionId).then((token) => {
1272
+ if (token && this._lastConnectParams) {
1273
+ this._lastConnectParams = { ...this._lastConnectParams, token };
1274
+ }
1275
+ });
1276
+ }, this.sessionApi.activeTokenRefreshDelayMs);
1277
+ }
1278
+ async _connectTransportWithRetry(params, signal) {
1279
+ const maxRetries = 2;
1280
+ let lastErr;
1281
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1282
+ if (signal?.aborted) throw new EphiaSdkError("client.start_failed", "Start aborted");
1283
+ try {
1284
+ await this.transport.connect(params);
1285
+ return;
1286
+ } catch (err) {
1287
+ lastErr = err;
1288
+ if (err instanceof EphiaSdkError) throw err;
1289
+ if (signal?.aborted) throw err;
1290
+ if (attempt < maxRetries) {
1291
+ const delay = 300 * Math.pow(2, attempt) + Math.random() * 100;
1292
+ await new Promise((resolve) => setTimeout(resolve, delay));
1293
+ }
1294
+ }
1295
+ }
1296
+ throw lastErr;
1297
+ }
1298
+ async _sendInitialTargetBeforeAudio(targetId, signal) {
1299
+ if (!targetId) return;
1300
+ for (let attempt = 0; attempt < 3; attempt++) {
1301
+ if (signal?.aborted) throw new EphiaSdkError("client.start_failed", "Start aborted");
1302
+ try {
1303
+ await this.transport.sendMessage({
1304
+ type: "session.target.changed",
1305
+ payload: { targetId }
1306
+ });
1307
+ return;
1308
+ } catch (err) {
1309
+ if (attempt === 2) throw err;
1310
+ await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
1311
+ }
1312
+ }
1313
+ }
1314
+ async _disconnectTransport() {
1315
+ this._unbindTransport?.();
1316
+ this._unbindTransport = void 0;
1317
+ await this.transport.disconnect();
1318
+ }
1319
+ _emitSdkDebug(type, payload) {
1320
+ if (typeof window === "undefined") return;
1321
+ window.dispatchEvent(
1322
+ new CustomEvent("ephia:sdk-debug", {
1323
+ detail: { type, sessionId: this.state.sessionId ?? null, payload }
1324
+ })
1325
+ );
1326
+ }
1327
+ };
1328
+
1329
+ export {
1330
+ EphiaAudioClient
1331
+ };
1332
+ //# sourceMappingURL=chunk-DIEWY3IT.js.map