@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,771 @@
1
+ import type { EphiaAudioEvent, DocumentPatchEvent } from "ephia-protocol";
2
+ import type { LiveTranscriptState, LiveTranscriptChunk, LiveTranscriptDiagnostics } from "./transcript.state";
3
+ import { initialLiveTranscriptState } from "./transcript.state";
4
+ import { assembleTranscript, buildDisplayText, assembleCommittedDocument, joinWithSpacing } from "./transcript.assembler";
5
+ import { pickEffectiveText } from "../../shared/effective-text";
6
+
7
+ export type LiveTranscriptAction =
8
+ | { type: "event"; event: EphiaAudioEvent }
9
+ | { type: "reset" }
10
+ | { type: "remove_segments"; segmentIds: string[] }
11
+ | { type: "client_error"; error: { code: string; message: string } }
12
+ | { type: "tick" };
13
+
14
+ export function liveTranscriptReducer(
15
+ state: LiveTranscriptState,
16
+ action: LiveTranscriptAction
17
+ ): LiveTranscriptState {
18
+ if (action.type === "reset") {
19
+ return { ...initialLiveTranscriptState };
20
+ }
21
+
22
+ if (action.type === "remove_segments") {
23
+ const removeSet = new Set(action.segmentIds);
24
+ if (removeSet.size === 0) return state;
25
+ const newChunks: Record<string, LiveTranscriptChunk> = {};
26
+ for (const [id, chunk] of Object.entries(state.chunks)) {
27
+ if (!removeSet.has(id)) {
28
+ newChunks[id] = chunk;
29
+ }
30
+ }
31
+ const orderedSegmentIds = state.orderedSegmentIds.filter(
32
+ (id) => !removeSet.has(id)
33
+ );
34
+ const documentText = assembleCommittedDocument(newChunks, orderedSegmentIds);
35
+ return {
36
+ ...state,
37
+ chunks: newChunks,
38
+ orderedSegmentIds,
39
+ documentText,
40
+ diagnostics: computeDiagnostics(
41
+ newChunks,
42
+ orderedSegmentIds,
43
+ state.diagnostics,
44
+ state.finalText,
45
+ state.previewText
46
+ ),
47
+ };
48
+ }
49
+
50
+ if (action.type === "client_error") {
51
+ return {
52
+ ...state,
53
+ status: "error",
54
+ error: action.error,
55
+ };
56
+ }
57
+
58
+ if (action.type === "tick") {
59
+ const hasProcessing = Object.values(state.chunks).some(
60
+ (c) => c.status === "opened" || c.status === "processing"
61
+ );
62
+ if (!hasProcessing) return state;
63
+ const nextTick = state.spinnerTick + 1;
64
+ const previewText = buildPreviewText(state.chunks, state.orderedSegmentIds, nextTick);
65
+ return {
66
+ ...state,
67
+ spinnerTick: nextTick,
68
+ previewText,
69
+ displayText: buildLiveDisplayText(state.chunks, state.orderedSegmentIds, state.finalText, previewText),
70
+ diagnostics: computeDiagnostics(state.chunks, state.orderedSegmentIds, state.diagnostics, state.finalText, previewText),
71
+ };
72
+ }
73
+
74
+ if (action.type !== "event") {
75
+ return state;
76
+ }
77
+
78
+ const event = action.event;
79
+
80
+ // Vérifier séquence monotone (strictement consécutive)
81
+ // Après un session.sync réussi, les events redeviennent consécutifs :
82
+ // on autorise la réparation automatique de seqValid.
83
+ const lastSeq = state.lastEvent?.seq;
84
+ const isFirstEvent = lastSeq === undefined;
85
+ const isConsecutive = lastSeq !== undefined && event.seq === lastSeq + 1;
86
+ const isRecoveredFromSync = !state.diagnostics.seqValid && isConsecutive;
87
+ const seqValid =
88
+ (state.diagnostics.seqValid || isRecoveredFromSync) &&
89
+ (isFirstEvent || isConsecutive);
90
+
91
+ const base: LiveTranscriptState = {
92
+ ...state,
93
+ events: [...state.events, event],
94
+ lastEvent: event,
95
+ diagnostics: {
96
+ ...state.diagnostics,
97
+ eventsCount: state.diagnostics.eventsCount + 1,
98
+ seqValid,
99
+ },
100
+ };
101
+
102
+ switch (event.type) {
103
+ case "session.started":
104
+ return {
105
+ ...base,
106
+ sessionId: event.sessionId,
107
+ status: "connecting",
108
+ };
109
+
110
+ case "session.ready":
111
+ return {
112
+ ...base,
113
+ status: "recording",
114
+ };
115
+
116
+ case "session.paused":
117
+ return {
118
+ ...base,
119
+ status: "ended",
120
+ diagnostics: {
121
+ ...base.diagnostics,
122
+ previewActive: false,
123
+ },
124
+ };
125
+
126
+ case "session.closed":
127
+ return {
128
+ ...base,
129
+ status: "ended",
130
+ diagnostics: {
131
+ ...base.diagnostics,
132
+ previewActive: false,
133
+ },
134
+ };
135
+
136
+ case "session.error":
137
+ return {
138
+ ...base,
139
+ status: "error",
140
+ error: {
141
+ code: event.payload.code,
142
+ message: event.payload.message,
143
+ },
144
+ };
145
+
146
+ case "session.context.reset": {
147
+ const contextOnly = (event as { payload: { contextOnly?: boolean } }).payload.contextOnly ?? false;
148
+ if (!contextOnly) {
149
+ const finalizedBeforeReset: Record<string, true> = {};
150
+ for (const [id, chunk] of Object.entries(base.chunks)) {
151
+ if (chunk.status === "done") finalizedBeforeReset[id] = true;
152
+ }
153
+ return { ...initialLiveTranscriptState, finalizedBeforeReset };
154
+ }
155
+ // contextOnly=true : conserver les chunks committés, vider les previews
156
+ const newChunks: Record<string, LiveTranscriptChunk> = {};
157
+ const newOrdered: string[] = [];
158
+ for (const id of base.orderedSegmentIds) {
159
+ const chunk = base.chunks[id];
160
+ if (chunk && (chunk.committedText || chunk.status === "done")) {
161
+ newChunks[id] = { ...chunk, previewText: undefined };
162
+ newOrdered.push(id);
163
+ }
164
+ }
165
+ const documentText = assembleCommittedDocument(newChunks, newOrdered);
166
+ return {
167
+ ...base,
168
+ chunks: newChunks,
169
+ orderedSegmentIds: newOrdered,
170
+ documentText,
171
+ previewText: "",
172
+ displayText: documentText,
173
+ diagnostics: computeDiagnostics(newChunks, newOrdered, base.diagnostics, base.finalText, ""),
174
+ };
175
+ }
176
+
177
+ case "transcript.segment.opened": {
178
+ const chunk: LiveTranscriptChunk = {
179
+ segmentId: event.payload.segmentId,
180
+ segmentSeq: event.payload.segmentSeq,
181
+ status: "opened",
182
+ text: "",
183
+ revision: 0,
184
+ startedAtMs: event.payload.startedAtMs,
185
+ };
186
+ const ordered = base.orderedSegmentIds.includes(event.payload.segmentId)
187
+ ? base.orderedSegmentIds
188
+ : [...base.orderedSegmentIds, event.payload.segmentId];
189
+ const newChunks: Record<string, LiveTranscriptChunk> = { ...base.chunks, [event.payload.segmentId]: chunk };
190
+ const previewText = buildPreviewText(newChunks, ordered, base.spinnerTick);
191
+ return {
192
+ ...base,
193
+ chunks: newChunks,
194
+ orderedSegmentIds: ordered,
195
+ previewText,
196
+ displayText: buildLiveDisplayText(newChunks, ordered, base.finalText, previewText),
197
+ diagnostics: computeDiagnostics(newChunks, ordered, base.diagnostics, base.finalText, previewText),
198
+ };
199
+ }
200
+
201
+ case "transcript.segment.processing": {
202
+ const existing = base.chunks[event.payload.segmentId];
203
+ if (!existing) return base;
204
+ const newChunks: Record<string, LiveTranscriptChunk> = {
205
+ ...base.chunks,
206
+ [event.payload.segmentId]: {
207
+ ...existing,
208
+ status: "processing",
209
+ durationMs: event.payload.durationMs,
210
+ } as LiveTranscriptChunk,
211
+ };
212
+ const previewText = buildPreviewText(newChunks, base.orderedSegmentIds, base.spinnerTick);
213
+ return {
214
+ ...base,
215
+ chunks: newChunks,
216
+ previewText,
217
+ displayText: buildLiveDisplayText(newChunks, base.orderedSegmentIds, base.finalText, previewText),
218
+ diagnostics: computeDiagnostics(newChunks, base.orderedSegmentIds, base.diagnostics, base.finalText, previewText),
219
+ };
220
+ }
221
+
222
+ case "transcript.preview": {
223
+ const existing = base.chunks[event.payload.segmentId];
224
+ const partialText = event.payload.text;
225
+ if (!partialText) {
226
+ return base;
227
+ }
228
+ if (existing) {
229
+ // Ignorer si chunk déjà finalisé
230
+ if (existing.status === "done") {
231
+ return base;
232
+ }
233
+ // Ignorer si révision non croissante
234
+ if (event.payload.revision <= existing.revision) {
235
+ return base;
236
+ }
237
+ const newChunks: Record<string, LiveTranscriptChunk> = {
238
+ ...base.chunks,
239
+ [event.payload.segmentId]: {
240
+ ...existing,
241
+ status: "preview",
242
+ previewText: partialText,
243
+ previewContinuesPreviousWord: false,
244
+ stablePrefix: event.payload.text,
245
+ revision: event.payload.revision,
246
+ } as LiveTranscriptChunk,
247
+ };
248
+ const previewText = buildPreviewText(newChunks, base.orderedSegmentIds, base.spinnerTick);
249
+ return {
250
+ ...base,
251
+ chunks: newChunks,
252
+ previewText,
253
+ displayText: buildLiveDisplayText(newChunks, base.orderedSegmentIds, base.finalText, previewText),
254
+ diagnostics: computeDiagnostics(newChunks, base.orderedSegmentIds, base.diagnostics, base.finalText, previewText),
255
+ };
256
+ }
257
+ // Partial sans chunk connu (fallback)
258
+ const chunk: LiveTranscriptChunk = {
259
+ segmentId: event.payload.segmentId,
260
+ segmentSeq: event.payload.segmentSeq,
261
+ status: "preview",
262
+ text: "",
263
+ previewText: partialText,
264
+ previewContinuesPreviousWord: false,
265
+ stablePrefix: event.payload.text,
266
+ revision: event.payload.revision,
267
+ };
268
+ const ordered = base.orderedSegmentIds.includes(event.payload.segmentId)
269
+ ? base.orderedSegmentIds
270
+ : [...base.orderedSegmentIds, event.payload.segmentId];
271
+ const newChunks: Record<string, LiveTranscriptChunk> = { ...base.chunks, [event.payload.segmentId]: chunk };
272
+ const previewText = buildPreviewText(newChunks, ordered, base.spinnerTick);
273
+ return {
274
+ ...base,
275
+ chunks: newChunks,
276
+ orderedSegmentIds: ordered,
277
+ previewText,
278
+ displayText: buildLiveDisplayText(newChunks, ordered, base.finalText, previewText),
279
+ diagnostics: computeDiagnostics(newChunks, ordered, base.diagnostics, base.finalText, previewText),
280
+ };
281
+ }
282
+
283
+ case "segment.dropped": {
284
+ const newChunks: Record<string, LiveTranscriptChunk> = { ...base.chunks };
285
+ delete newChunks[event.payload.segmentId];
286
+ const orderedSegmentIds = base.orderedSegmentIds.filter(
287
+ (id) => id !== event.payload.segmentId
288
+ );
289
+ const finalText = assembleTranscript(Object.values(newChunks));
290
+ const documentText = assembleCommittedDocument(newChunks, orderedSegmentIds);
291
+ const previewText = buildPreviewText(newChunks, orderedSegmentIds, base.spinnerTick);
292
+ return {
293
+ ...base,
294
+ chunks: newChunks,
295
+ orderedSegmentIds,
296
+ finalText,
297
+ documentText,
298
+ previewText,
299
+ displayText: buildLiveDisplayText(newChunks, orderedSegmentIds, finalText, previewText),
300
+ diagnostics: computeDiagnostics(
301
+ newChunks,
302
+ orderedSegmentIds,
303
+ base.diagnostics,
304
+ finalText,
305
+ previewText
306
+ ),
307
+ };
308
+ }
309
+
310
+ case "segment.absorbed": {
311
+ if (base.finalizedBeforeReset?.[event.payload.segmentId]) return base;
312
+ const absorbedId = event.payload.segmentId;
313
+ const existing = base.chunks[absorbedId];
314
+ if (!existing) return base;
315
+ const newChunks: Record<string, LiveTranscriptChunk> = {
316
+ ...base.chunks,
317
+ [absorbedId]: {
318
+ ...existing,
319
+ status: "done",
320
+ text: "",
321
+ committedText: "",
322
+ previewText: undefined,
323
+ mergedWith: event.payload.absorbedInto,
324
+ } as LiveTranscriptChunk,
325
+ };
326
+ const orderedSegmentIds = base.orderedSegmentIds.filter(id => id !== absorbedId);
327
+ const documentText = assembleCommittedDocument(newChunks, orderedSegmentIds);
328
+ const previewText = buildPreviewText(newChunks, orderedSegmentIds, base.spinnerTick);
329
+ return {
330
+ ...base,
331
+ chunks: newChunks,
332
+ orderedSegmentIds,
333
+ documentText,
334
+ previewText,
335
+ displayText: buildLiveDisplayText(newChunks, orderedSegmentIds, base.finalText, previewText),
336
+ diagnostics: computeDiagnostics(newChunks, orderedSegmentIds, base.diagnostics, base.finalText, previewText),
337
+ };
338
+ }
339
+
340
+ case "transcript.preview.stable": {
341
+ const existing = base.chunks[event.payload.segmentId];
342
+ if (!existing) return base;
343
+ const newChunks: Record<string, LiveTranscriptChunk> = {
344
+ ...base.chunks,
345
+ [event.payload.segmentId]: {
346
+ ...existing,
347
+ status: "preview",
348
+ previewText: event.payload.text,
349
+ previewContinuesPreviousWord: false,
350
+ stablePrefix: event.payload.text,
351
+ revision: event.payload.revision,
352
+ } as LiveTranscriptChunk,
353
+ };
354
+ const previewText = buildPreviewText(newChunks, base.orderedSegmentIds, base.spinnerTick);
355
+ return {
356
+ ...base,
357
+ chunks: newChunks,
358
+ previewText,
359
+ displayText: buildLiveDisplayText(newChunks, base.orderedSegmentIds, base.finalText, previewText),
360
+ diagnostics: computeDiagnostics(newChunks, base.orderedSegmentIds, base.diagnostics, base.finalText, previewText),
361
+ };
362
+ }
363
+
364
+ case "transcript.final": {
365
+ const existing = base.chunks[event.payload.segmentId];
366
+ let newChunks: Record<string, LiveTranscriptChunk>;
367
+ let ordered = base.orderedSegmentIds;
368
+
369
+ const meta = event.meta || {};
370
+ const chunkUpdate: Partial<LiveTranscriptChunk> = {
371
+ status: "done",
372
+ text: event.payload.text,
373
+ previewText: undefined,
374
+ provider: event.payload.provider,
375
+ uncertain: meta.uncertain as boolean | undefined,
376
+ reason: meta.reason ? (meta.reason as string) : undefined,
377
+ reasonCodes: meta.reasonCodes as string[] | undefined,
378
+ parseFallback: meta.parseFallback as boolean | undefined,
379
+ };
380
+
381
+ if (!existing) {
382
+ const chunk: LiveTranscriptChunk = {
383
+ segmentId: event.payload.segmentId,
384
+ segmentSeq: event.payload.segmentSeq,
385
+ revision: 0,
386
+ ...chunkUpdate,
387
+ } as LiveTranscriptChunk;
388
+ ordered = base.orderedSegmentIds.includes(event.payload.segmentId)
389
+ ? base.orderedSegmentIds
390
+ : [...base.orderedSegmentIds, event.payload.segmentId];
391
+ newChunks = { ...base.chunks, [event.payload.segmentId]: chunk };
392
+ } else {
393
+ newChunks = {
394
+ ...base.chunks,
395
+ [event.payload.segmentId]: {
396
+ ...existing,
397
+ ...chunkUpdate,
398
+ } as LiveTranscriptChunk,
399
+ };
400
+ }
401
+
402
+ const finalText = assembleTranscript(Object.values(newChunks));
403
+ const previewText = buildPreviewText(newChunks, ordered, base.spinnerTick);
404
+ const latency = event.meta?.latencyMs as number | undefined;
405
+ const contextChars = event.meta?.contextChars as number | undefined;
406
+ const provider = event.payload.provider;
407
+
408
+ return {
409
+ ...base,
410
+ chunks: newChunks,
411
+ orderedSegmentIds: ordered,
412
+ finalText,
413
+ previewText,
414
+ displayText: buildLiveDisplayText(newChunks, ordered, finalText, previewText),
415
+ diagnostics: computeDiagnostics(newChunks, ordered, base.diagnostics, finalText, previewText, latency, contextChars, provider),
416
+ };
417
+ }
418
+
419
+ case "transcript.segment.ready": {
420
+ const existing = base.chunks[event.payload.segmentId];
421
+ const effectiveText = pickEffectiveText(event.payload) || event.payload.text;
422
+ const ordered = base.orderedSegmentIds.includes(event.payload.segmentId)
423
+ ? base.orderedSegmentIds
424
+ : [...base.orderedSegmentIds, event.payload.segmentId];
425
+ const newChunks: Record<string, LiveTranscriptChunk> = {
426
+ ...base.chunks,
427
+ [event.payload.segmentId]: {
428
+ ...(existing ?? {
429
+ segmentId: event.payload.segmentId,
430
+ segmentSeq: event.payload.segmentSeq,
431
+ status: "processing",
432
+ text: "",
433
+ revision: 0,
434
+ }),
435
+ finalizedText: effectiveText,
436
+ provider: event.payload.provider,
437
+ previewText: undefined,
438
+ } as LiveTranscriptChunk,
439
+ };
440
+ const previewText = buildPreviewText(newChunks, ordered, base.spinnerTick);
441
+ return {
442
+ ...base,
443
+ chunks: newChunks,
444
+ orderedSegmentIds: ordered,
445
+ previewText,
446
+ displayText: buildLiveDisplayText(newChunks, ordered, base.finalText, previewText),
447
+ diagnostics: computeDiagnostics(newChunks, ordered, base.diagnostics, base.finalText, previewText),
448
+ };
449
+ }
450
+
451
+ case "transcript.segment.committed": {
452
+ if (base.finalizedBeforeReset?.[event.payload.segmentId]) return base;
453
+ const existing = base.chunks[event.payload.segmentId];
454
+ const effectiveText = pickEffectiveText(event.payload) || event.payload.text;
455
+ const ordered = base.orderedSegmentIds.includes(event.payload.segmentId)
456
+ ? base.orderedSegmentIds
457
+ : [...base.orderedSegmentIds, event.payload.segmentId];
458
+ const mergedIds: string[] = event.payload.mergedWith
459
+ ? Array.isArray(event.payload.mergedWith)
460
+ ? event.payload.mergedWith
461
+ : [event.payload.mergedWith]
462
+ : [];
463
+
464
+ let newChunks: Record<string, LiveTranscriptChunk> = {
465
+ ...base.chunks,
466
+ [event.payload.segmentId]: {
467
+ ...(existing ?? {
468
+ segmentId: event.payload.segmentId,
469
+ segmentSeq: event.payload.segmentSeq,
470
+ revision: 0,
471
+ }),
472
+ status: "done",
473
+ committedText: effectiveText,
474
+ text: effectiveText,
475
+ source: event.payload.source,
476
+ mergedWith: mergedIds.length > 0 ? mergedIds : undefined,
477
+ previewText: undefined,
478
+ // P1 backend : additif, n'affecte ni le statut ni le texte affiché.
479
+ effectiveSource: event.payload.effectiveSource,
480
+ correctionPending: event.payload.correctionPending,
481
+ correctionAccepted: event.payload.correctionAccepted,
482
+ correctionRejectedReason: event.payload.correctionRejectedReason,
483
+ commandsApplied: event.payload.commandsApplied,
484
+ normalizationsApplied: event.payload.normalizationsApplied,
485
+ safety: event.payload.safety,
486
+ layout: event.payload.layout,
487
+ rawStt: event.payload.rawStt,
488
+ realtimeText: event.payload.realtimeText,
489
+ hintSentToSmall: event.payload.hintSentToSmall,
490
+ fallbackText: event.payload.fallbackText,
491
+ smallRawText: event.payload.smallRawText,
492
+ correctionOutcome: event.payload.correctionOutcome,
493
+ debug: event.payload.debug,
494
+ } as LiveTranscriptChunk,
495
+ };
496
+
497
+ // Purger les segments absorbés pour qu'ils ne polluent plus previewText
498
+ for (const absorbedId of mergedIds) {
499
+ const absorbed = newChunks[absorbedId];
500
+ if (absorbed) {
501
+ newChunks = {
502
+ ...newChunks,
503
+ [absorbedId]: {
504
+ ...absorbed,
505
+ status: "done",
506
+ text: "",
507
+ committedText: "",
508
+ previewText: undefined,
509
+ mergedWith: event.payload.segmentId,
510
+ } as LiveTranscriptChunk,
511
+ };
512
+ }
513
+ }
514
+ const finalText = assembleTranscript(Object.values(newChunks));
515
+ const documentText = assembleCommittedDocument(newChunks, ordered);
516
+ const previewText = buildPreviewText(newChunks, ordered, base.spinnerTick);
517
+ return {
518
+ ...base,
519
+ chunks: newChunks,
520
+ orderedSegmentIds: ordered,
521
+ finalText,
522
+ documentText,
523
+ previewText,
524
+ displayText: buildLiveDisplayText(newChunks, ordered, finalText, previewText),
525
+ diagnostics: computeDiagnostics(newChunks, ordered, base.diagnostics, finalText, previewText),
526
+ };
527
+ }
528
+
529
+ case "segment.opened": {
530
+ const id = event.payload.segmentId;
531
+ if (base.chunks[id]) return base;
532
+ const chunk: LiveTranscriptChunk = {
533
+ segmentId: id,
534
+ segmentSeq: event.payload.segmentSeq,
535
+ status: "opened",
536
+ text: "",
537
+ revision: 0,
538
+ startedAtMs: event.payload.startMs,
539
+ };
540
+ const ordered = [...base.orderedSegmentIds, id];
541
+ const newChunks = { ...base.chunks, [id]: chunk };
542
+ return {
543
+ ...base,
544
+ chunks: newChunks,
545
+ orderedSegmentIds: ordered,
546
+ diagnostics: computeDiagnostics(newChunks, ordered, base.diagnostics, base.finalText, base.previewText),
547
+ };
548
+ }
549
+
550
+ case "document.patch": {
551
+ let newChunks = { ...base.chunks };
552
+ for (const op of (event as DocumentPatchEvent).payload.ops) {
553
+ if (op.type === "merge" && op.to && newChunks[op.to]) {
554
+ newChunks = {
555
+ ...newChunks,
556
+ [op.to]: {
557
+ ...newChunks[op.to],
558
+ committedText: op.text ?? newChunks[op.to].committedText,
559
+ text: op.text ?? newChunks[op.to].text,
560
+ mergedWith: op.from,
561
+ } as LiveTranscriptChunk,
562
+ };
563
+ } else if (op.type === "replace" && op.blockId && newChunks[op.blockId]) {
564
+ newChunks = {
565
+ ...newChunks,
566
+ [op.blockId]: {
567
+ ...newChunks[op.blockId],
568
+ committedText: op.text ?? newChunks[op.blockId].committedText,
569
+ text: op.text ?? newChunks[op.blockId].text,
570
+ } as LiveTranscriptChunk,
571
+ };
572
+ }
573
+ }
574
+ const documentText = assembleCommittedDocument(newChunks, base.orderedSegmentIds);
575
+ return {
576
+ ...base,
577
+ chunks: newChunks,
578
+ documentText,
579
+ diagnostics: computeDiagnostics(newChunks, base.orderedSegmentIds, base.diagnostics, base.finalText, base.previewText),
580
+ };
581
+ }
582
+
583
+ case "transcript.segment.completed": {
584
+ const existing = base.chunks[event.payload.segmentId];
585
+ if (!existing) return base;
586
+ const newChunks: Record<string, LiveTranscriptChunk> = {
587
+ ...base.chunks,
588
+ [event.payload.segmentId]: {
589
+ ...existing,
590
+ status: existing.status === "done" || existing.status === "preview" ? existing.status : "done",
591
+ durationMs: event.payload.durationMs,
592
+ provider: event.payload.provider,
593
+ } as LiveTranscriptChunk,
594
+ };
595
+ return {
596
+ ...base,
597
+ chunks: newChunks,
598
+ diagnostics: computeDiagnostics(newChunks, base.orderedSegmentIds, base.diagnostics, base.finalText, base.previewText),
599
+ };
600
+ }
601
+
602
+ case "transcript.segment.error": {
603
+ const existing = base.chunks[event.payload.segmentId];
604
+ const chunk: LiveTranscriptChunk = {
605
+ ...(existing ?? {
606
+ segmentId: event.payload.segmentId,
607
+ segmentSeq: event.payload.segmentSeq,
608
+ text: "",
609
+ revision: 0,
610
+ }),
611
+ status: "error",
612
+ error: `${event.payload.code}: ${event.payload.message}`,
613
+ };
614
+ const ordered = base.orderedSegmentIds.includes(event.payload.segmentId)
615
+ ? base.orderedSegmentIds
616
+ : [...base.orderedSegmentIds, event.payload.segmentId];
617
+ const newChunks = { ...base.chunks, [event.payload.segmentId]: chunk };
618
+ return {
619
+ ...base,
620
+ chunks: newChunks,
621
+ orderedSegmentIds: ordered,
622
+ diagnostics: computeDiagnostics(newChunks, ordered, base.diagnostics, base.finalText, base.previewText),
623
+ };
624
+ }
625
+
626
+ case "audio.vad.start":
627
+ return {
628
+ ...base,
629
+ diagnostics: { ...base.diagnostics, mode: "vad" },
630
+ };
631
+
632
+ case "segment.audio.closed": {
633
+ const existing = base.chunks[event.payload.segmentId];
634
+ if (!existing) return base;
635
+ const newChunks: Record<string, LiveTranscriptChunk> = {
636
+ ...base.chunks,
637
+ [event.payload.segmentId]: {
638
+ ...existing,
639
+ audioClosedAtMs: event.payload.endMs,
640
+ // Conserver le dernier preview jusqu'au committed — évite le flash vide
641
+ // entre la fin audio VAD et la finalisation Voxtral.
642
+ status: existing.status === "preview" ? "processing" : existing.status,
643
+ } as LiveTranscriptChunk,
644
+ };
645
+ const previewText = buildPreviewText(newChunks, base.orderedSegmentIds, base.spinnerTick);
646
+ return {
647
+ ...base,
648
+ chunks: newChunks,
649
+ previewText,
650
+ displayText: buildLiveDisplayText(newChunks, base.orderedSegmentIds, base.finalText, previewText),
651
+ diagnostics: computeDiagnostics(newChunks, base.orderedSegmentIds, base.diagnostics, base.finalText, previewText),
652
+ };
653
+ }
654
+
655
+ case "audio.vad.end":
656
+ case "session.pong":
657
+ default:
658
+ return base;
659
+ }
660
+ }
661
+
662
+ const SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
663
+
664
+ function buildPreviewText(
665
+ chunks: Record<string, LiveTranscriptChunk>,
666
+ orderedSegmentIds: string[],
667
+ spinnerTick: number = 0
668
+ ): string {
669
+ const sorted = [...orderedSegmentIds]
670
+ .map((id) => chunks[id])
671
+ .filter((c): c is LiveTranscriptChunk => !!c)
672
+ .sort((a, b) => a.segmentSeq - b.segmentSeq);
673
+ let result = "";
674
+ let hasPendingWithoutPreview = false;
675
+ for (const chunk of sorted) {
676
+ if (chunk.status === "done") continue;
677
+ if ((chunk.status === "preview" || chunk.status === "processing" || chunk.status === "opened") && chunk.previewText) {
678
+ result = joinWithSpacing(result, chunk.previewText);
679
+ } else if (chunk.status === "processing" || chunk.status === "opened") {
680
+ hasPendingWithoutPreview = true;
681
+ }
682
+ }
683
+ if (hasPendingWithoutPreview && !result) {
684
+ return SPINNER_CHARS[spinnerTick % SPINNER_CHARS.length];
685
+ }
686
+ return result;
687
+ }
688
+
689
+ function previewContinuesPreviousWord(
690
+ chunks: Record<string, LiveTranscriptChunk>,
691
+ orderedSegmentIds: string[]
692
+ ): boolean {
693
+ const sorted = [...orderedSegmentIds]
694
+ .map((id) => chunks[id])
695
+ .filter((c): c is LiveTranscriptChunk => !!c)
696
+ .sort((a, b) => a.segmentSeq - b.segmentSeq);
697
+ const firstPreview = sorted.find(
698
+ (chunk) =>
699
+ chunk.status !== "done" &&
700
+ !!chunk.previewText &&
701
+ (chunk.status === "preview" ||
702
+ chunk.status === "processing" ||
703
+ chunk.status === "opened")
704
+ );
705
+ return !!firstPreview?.previewContinuesPreviousWord;
706
+ }
707
+
708
+ function buildLiveDisplayText(
709
+ chunks: Record<string, LiveTranscriptChunk>,
710
+ orderedSegmentIds: string[],
711
+ finalText: string,
712
+ previewText: string
713
+ ): string {
714
+ return buildDisplayText(
715
+ finalText,
716
+ previewText,
717
+ previewContinuesPreviousWord(chunks, orderedSegmentIds)
718
+ );
719
+ }
720
+
721
+
722
+ function computeDiagnostics(
723
+ chunks: Record<string, LiveTranscriptChunk>,
724
+ orderedSegmentIds: string[],
725
+ prevDiagnostics: LiveTranscriptDiagnostics,
726
+ finalText: string,
727
+ previewText: string,
728
+ newLatencyMs?: number,
729
+ newContextChars?: number,
730
+ newProvider?: string,
731
+ ): LiveTranscriptDiagnostics {
732
+ const all = Object.values(chunks);
733
+ const processingChunks = all.filter((c) => c.status === "opened" || c.status === "processing").length;
734
+ const doneChunks = all.filter((c) => c.status === "done").length;
735
+ const errorChunks = all.filter((c) => c.status === "error").length;
736
+ const previewActive = all.some((c) =>
737
+ (c.status === "preview" || c.status === "processing" || c.status === "opened") && !!c.previewText
738
+ );
739
+ const uncertainChunks = all.filter((c) => c.uncertain).length;
740
+ const parseFallbackChunks = all.filter((c) => c.parseFallback).length;
741
+
742
+ // Moyenne glissante de latence
743
+ let averageLatencyMs = prevDiagnostics.averageLatencyMs;
744
+ let lastLatencyMs = prevDiagnostics.lastLatencyMs;
745
+ if (newLatencyMs !== undefined) {
746
+ lastLatencyMs = newLatencyMs;
747
+ const prevAvg = prevDiagnostics.averageLatencyMs ?? 0;
748
+ const count = doneChunks;
749
+ if (count <= 1) {
750
+ averageLatencyMs = newLatencyMs;
751
+ } else {
752
+ averageLatencyMs = Math.round((prevAvg * (count - 1) + newLatencyMs) / count);
753
+ }
754
+ }
755
+
756
+ return {
757
+ ...prevDiagnostics,
758
+ chunksCount: orderedSegmentIds.length,
759
+ processingChunks,
760
+ doneChunks,
761
+ errorChunks,
762
+ averageLatencyMs,
763
+ lastLatencyMs,
764
+ lastProvider: newProvider ?? prevDiagnostics.lastProvider,
765
+ finalTextLength: finalText.length,
766
+ previewActive,
767
+ uncertainChunks,
768
+ parseFallbackChunks,
769
+ contextChars: newContextChars ?? prevDiagnostics.contextChars ?? 0,
770
+ };
771
+ }