@happyvertical/smrt-ui 0.51.38 → 0.52.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 (212) hide show
  1. package/AGENTS.md +14 -2
  2. package/README.md +257 -5
  3. package/dist/actions/__tests__/swipe-dismiss.test.js +69 -0
  4. package/dist/actions/swipe-dismiss.d.ts +42 -0
  5. package/dist/actions/swipe-dismiss.d.ts.map +1 -0
  6. package/dist/actions/swipe-dismiss.js +69 -0
  7. package/dist/components/calendar/Calendar.svelte +5 -0
  8. package/dist/components/calendar/Calendar.svelte.d.ts.map +1 -1
  9. package/dist/components/calendar/CalendarView.svelte +1250 -0
  10. package/dist/components/calendar/CalendarView.svelte.d.ts +66 -0
  11. package/dist/components/calendar/CalendarView.svelte.d.ts.map +1 -0
  12. package/dist/components/calendar/DayView.svelte +5 -0
  13. package/dist/components/calendar/DayView.svelte.d.ts.map +1 -1
  14. package/dist/components/calendar/__tests__/CalendarView.test.js +226 -0
  15. package/dist/components/calendar/__tests__/calendar-model.test.js +138 -0
  16. package/dist/components/calendar/calendar-model.d.ts +178 -0
  17. package/dist/components/calendar/calendar-model.d.ts.map +1 -0
  18. package/dist/components/calendar/calendar-model.js +362 -0
  19. package/dist/components/calendar/index.d.ts +12 -1
  20. package/dist/components/calendar/index.d.ts.map +1 -1
  21. package/dist/components/calendar/index.js +11 -1
  22. package/dist/components/data/CollectionList.svelte +23 -5
  23. package/dist/components/data/CollectionList.svelte.d.ts +8 -2
  24. package/dist/components/data/CollectionList.svelte.d.ts.map +1 -1
  25. package/dist/components/data/DataTable.svelte +118 -40
  26. package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
  27. package/dist/components/data/DataTableController.d.ts +4 -0
  28. package/dist/components/data/DataTableController.d.ts.map +1 -1
  29. package/dist/components/data/DataTableController.js +11 -4
  30. package/dist/components/data/ListSortSelect.svelte +121 -0
  31. package/dist/components/data/ListSortSelect.svelte.d.ts +22 -0
  32. package/dist/components/data/ListSortSelect.svelte.d.ts.map +1 -0
  33. package/dist/components/data/SortableHeader.svelte +169 -0
  34. package/dist/components/data/SortableHeader.svelte.d.ts +30 -0
  35. package/dist/components/data/SortableHeader.svelte.d.ts.map +1 -0
  36. package/dist/components/data/__tests__/CollectionList.test.js +37 -0
  37. package/dist/components/data/__tests__/DataTable.test.js +76 -0
  38. package/dist/components/data/__tests__/DataTableController.test.js +35 -0
  39. package/dist/components/data/__tests__/SortableHeader.test.js +101 -0
  40. package/dist/components/data/__tests__/link-step-surface.test.js +172 -0
  41. package/dist/components/data/__tests__/list-sort-state.svelte.test.js +71 -0
  42. package/dist/components/data/__tests__/list-sort.test.js +91 -0
  43. package/dist/components/data/__tests__/surface-navigation.test.js +121 -0
  44. package/dist/components/data/data-surface.d.ts +1 -0
  45. package/dist/components/data/data-surface.d.ts.map +1 -1
  46. package/dist/components/data/data-surface.js +1 -0
  47. package/dist/components/data/index.d.ts +6 -0
  48. package/dist/components/data/index.d.ts.map +1 -1
  49. package/dist/components/data/index.js +6 -0
  50. package/dist/components/data/link-surface.d.ts +79 -0
  51. package/dist/components/data/link-surface.d.ts.map +1 -0
  52. package/dist/components/data/link-surface.js +155 -0
  53. package/dist/components/data/list-sort-state.svelte.d.ts +39 -0
  54. package/dist/components/data/list-sort-state.svelte.d.ts.map +1 -0
  55. package/dist/components/data/list-sort-state.svelte.js +47 -0
  56. package/dist/components/data/list-sort.d.ts +94 -0
  57. package/dist/components/data/list-sort.d.ts.map +1 -0
  58. package/dist/components/data/list-sort.js +182 -0
  59. package/dist/components/data/step-surface.d.ts +73 -0
  60. package/dist/components/data/step-surface.d.ts.map +1 -0
  61. package/dist/components/data/step-surface.js +177 -0
  62. package/dist/components/data/surface-navigation.d.ts +49 -0
  63. package/dist/components/data/surface-navigation.d.ts.map +1 -0
  64. package/dist/components/data/surface-navigation.js +100 -0
  65. package/dist/components/data/types.d.ts +19 -0
  66. package/dist/components/data/types.d.ts.map +1 -1
  67. package/dist/components/feedback/PhoneSheet.svelte +224 -0
  68. package/dist/components/feedback/PhoneSheet.svelte.d.ts +35 -0
  69. package/dist/components/feedback/PhoneSheet.svelte.d.ts.map +1 -0
  70. package/dist/components/feedback/WorkingStrip.svelte +426 -0
  71. package/dist/components/feedback/WorkingStrip.svelte.d.ts +37 -0
  72. package/dist/components/feedback/WorkingStrip.svelte.d.ts.map +1 -0
  73. package/dist/components/feedback/__tests__/PhoneSheet.test.js +101 -0
  74. package/dist/components/feedback/__tests__/WorkingStrip.test.js +172 -0
  75. package/dist/components/feedback/index.d.ts +4 -0
  76. package/dist/components/feedback/index.d.ts.map +1 -1
  77. package/dist/components/feedback/index.js +5 -0
  78. package/dist/components/feedback/working-status.d.ts +34 -0
  79. package/dist/components/feedback/working-status.d.ts.map +1 -0
  80. package/dist/components/feedback/working-status.js +5 -0
  81. package/dist/components/forms/Checkbox.svelte +26 -2
  82. package/dist/components/forms/Combobox.svelte +68 -12
  83. package/dist/components/forms/Combobox.svelte.d.ts +6 -0
  84. package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
  85. package/dist/components/forms/DictationButton.svelte +128 -0
  86. package/dist/components/forms/DictationButton.svelte.d.ts +19 -0
  87. package/dist/components/forms/DictationButton.svelte.d.ts.map +1 -0
  88. package/dist/components/forms/DictationStatus.svelte +164 -0
  89. package/dist/components/forms/DictationStatus.svelte.d.ts +11 -0
  90. package/dist/components/forms/DictationStatus.svelte.d.ts.map +1 -0
  91. package/dist/components/forms/Fieldset.svelte +5 -1
  92. package/dist/components/forms/Fieldset.svelte.d.ts +2 -0
  93. package/dist/components/forms/Fieldset.svelte.d.ts.map +1 -1
  94. package/dist/components/forms/FormActionBar.svelte +84 -0
  95. package/dist/components/forms/FormActionBar.svelte.d.ts +23 -0
  96. package/dist/components/forms/FormActionBar.svelte.d.ts.map +1 -0
  97. package/dist/components/forms/Input.svelte +9 -0
  98. package/dist/components/forms/Radio.svelte +15 -4
  99. package/dist/components/forms/SearchInput.svelte +207 -0
  100. package/dist/components/forms/SearchInput.svelte.d.ts +22 -0
  101. package/dist/components/forms/SearchInput.svelte.d.ts.map +1 -0
  102. package/dist/components/forms/Select.svelte +9 -0
  103. package/dist/components/forms/StagedControlReview.svelte +21 -12
  104. package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -1
  105. package/dist/components/forms/Switch.svelte +11 -5
  106. package/dist/components/forms/TagsInput.svelte +8 -1
  107. package/dist/components/forms/Textarea.svelte +8 -0
  108. package/dist/components/forms/__tests__/Combobox.test.js +87 -0
  109. package/dist/components/forms/__tests__/ControlHitArea.test.js +73 -0
  110. package/dist/components/forms/__tests__/DictationButton.test.js +176 -0
  111. package/dist/components/forms/__tests__/Fieldset.test.js +37 -0
  112. package/dist/components/forms/__tests__/FormActionBar.test.js +22 -0
  113. package/dist/components/forms/__tests__/SearchInput.test.js +92 -0
  114. package/dist/components/forms/__tests__/StagedControlReview.test.js +37 -3
  115. package/dist/components/forms/__tests__/combobox-labels.fixture.svelte +19 -0
  116. package/dist/components/forms/__tests__/combobox-labels.fixture.svelte.d.ts +11 -0
  117. package/dist/components/forms/__tests__/combobox-labels.fixture.svelte.d.ts.map +1 -0
  118. package/dist/components/forms/__tests__/composite-registration.fixture.svelte +29 -0
  119. package/dist/components/forms/__tests__/composite-registration.fixture.svelte.d.ts +8 -0
  120. package/dist/components/forms/__tests__/composite-registration.fixture.svelte.d.ts.map +1 -0
  121. package/dist/components/forms/__tests__/control-interaction.test.js +19 -1
  122. package/dist/components/forms/__tests__/control-proposals.test.js +63 -0
  123. package/dist/components/forms/__tests__/dictation-recording.test.js +582 -0
  124. package/dist/components/forms/__tests__/dictation.test.js +486 -0
  125. package/dist/components/forms/__tests__/long-press.test.js +175 -0
  126. package/dist/components/forms/__tests__/place-picker.fixture.svelte +33 -0
  127. package/dist/components/forms/__tests__/place-picker.fixture.svelte.d.ts +12 -0
  128. package/dist/components/forms/__tests__/place-picker.fixture.svelte.d.ts.map +1 -0
  129. package/dist/components/forms/audio-capture.d.ts +69 -0
  130. package/dist/components/forms/audio-capture.d.ts.map +1 -0
  131. package/dist/components/forms/audio-capture.js +193 -0
  132. package/dist/components/forms/control-interaction.d.ts +6 -0
  133. package/dist/components/forms/control-interaction.d.ts.map +1 -1
  134. package/dist/components/forms/control-interaction.js +12 -1
  135. package/dist/components/forms/control-proposals.d.ts +55 -0
  136. package/dist/components/forms/control-proposals.d.ts.map +1 -0
  137. package/dist/components/forms/control-proposals.js +188 -0
  138. package/dist/components/forms/dictation-transcribe.d.ts +36 -0
  139. package/dist/components/forms/dictation-transcribe.d.ts.map +1 -0
  140. package/dist/components/forms/dictation-transcribe.js +65 -0
  141. package/dist/components/forms/dictation.svelte.d.ts +200 -0
  142. package/dist/components/forms/dictation.svelte.d.ts.map +1 -0
  143. package/dist/components/forms/dictation.svelte.js +686 -0
  144. package/dist/components/forms/index.d.ts +14 -1
  145. package/dist/components/forms/index.d.ts.map +1 -1
  146. package/dist/components/forms/index.js +14 -1
  147. package/dist/components/forms/insert-text.d.ts +12 -0
  148. package/dist/components/forms/insert-text.d.ts.map +1 -0
  149. package/dist/components/forms/insert-text.js +38 -0
  150. package/dist/components/forms/long-press.d.ts +88 -0
  151. package/dist/components/forms/long-press.d.ts.map +1 -0
  152. package/dist/components/forms/long-press.js +213 -0
  153. package/dist/components/forms/ready-beep.d.ts +24 -0
  154. package/dist/components/forms/ready-beep.d.ts.map +1 -0
  155. package/dist/components/forms/ready-beep.js +82 -0
  156. package/dist/components/layout/PageHeader.svelte +151 -17
  157. package/dist/components/layout/PageHeader.svelte.d.ts +47 -7
  158. package/dist/components/layout/PageHeader.svelte.d.ts.map +1 -1
  159. package/dist/components/layout/__tests__/PageHeader.test.js +87 -1
  160. package/dist/components/layout/index.d.ts +1 -0
  161. package/dist/components/layout/index.d.ts.map +1 -1
  162. package/dist/components/layout/index.js +1 -0
  163. package/dist/components/layout/page-header-context.d.ts +25 -0
  164. package/dist/components/layout/page-header-context.d.ts.map +1 -0
  165. package/dist/components/layout/page-header-context.js +16 -0
  166. package/dist/components/nav/Tabs.svelte +241 -0
  167. package/dist/components/nav/Tabs.svelte.d.ts +15 -0
  168. package/dist/components/nav/Tabs.svelte.d.ts.map +1 -1
  169. package/dist/components/nav/__tests__/LinkTabs.test.js +117 -0
  170. package/dist/components/nav/index.d.ts +1 -0
  171. package/dist/components/nav/index.d.ts.map +1 -1
  172. package/dist/components/nav/index.js +1 -0
  173. package/dist/components/nav/tabs-overflow.d.ts +12 -0
  174. package/dist/components/nav/tabs-overflow.d.ts.map +1 -0
  175. package/dist/components/nav/tabs-overflow.js +17 -0
  176. package/dist/components/nav/types.d.ts +8 -0
  177. package/dist/components/nav/types.d.ts.map +1 -1
  178. package/dist/components/ui/Button.svelte +7 -0
  179. package/dist/components/ui/Card.svelte +14 -5
  180. package/dist/components/ui/Dropdown.svelte +55 -1
  181. package/dist/components/ui/Dropdown.svelte.d.ts +8 -0
  182. package/dist/components/ui/Dropdown.svelte.d.ts.map +1 -1
  183. package/dist/components/ui/IconToggle.svelte +139 -0
  184. package/dist/components/ui/IconToggle.svelte.d.ts +32 -0
  185. package/dist/components/ui/IconToggle.svelte.d.ts.map +1 -0
  186. package/dist/components/ui/IconToggleGroup.svelte +56 -0
  187. package/dist/components/ui/IconToggleGroup.svelte.d.ts +21 -0
  188. package/dist/components/ui/IconToggleGroup.svelte.d.ts.map +1 -0
  189. package/dist/components/ui/Pagination.svelte +1 -1
  190. package/dist/components/ui/Popover.svelte +17 -1
  191. package/dist/components/ui/Popover.svelte.d.ts +8 -0
  192. package/dist/components/ui/Popover.svelte.d.ts.map +1 -1
  193. package/dist/components/ui/__tests__/IconToggle.test.js +59 -0
  194. package/dist/components/ui/__tests__/IconToggleGroup.test.js +30 -0
  195. package/dist/components/ui/__tests__/Popover.test.js +44 -0
  196. package/dist/components/ui/__tests__/dropdown-popover-icon.test.js +125 -0
  197. package/dist/components/ui/__tests__/phone-touch-targets.test.js +39 -0
  198. package/dist/components/ui/index.d.ts +2 -0
  199. package/dist/components/ui/index.d.ts.map +1 -1
  200. package/dist/components/ui/index.js +2 -0
  201. package/dist/i18n/strings.ui.d.ts +41 -0
  202. package/dist/i18n/strings.ui.d.ts.map +1 -1
  203. package/dist/i18n/strings.ui.js +46 -0
  204. package/dist/svelte/__tests__/playground.test.js +1 -0
  205. package/dist/svelte/playground/CalendarPreview.svelte +140 -0
  206. package/dist/svelte/playground/CalendarPreview.svelte.d.ts +4 -0
  207. package/dist/svelte/playground/CalendarPreview.svelte.d.ts.map +1 -0
  208. package/dist/svelte/playground.d.ts.map +1 -1
  209. package/dist/svelte/playground.js +10 -0
  210. package/dist/types-generic.d.ts +2 -0
  211. package/dist/types-generic.d.ts.map +1 -1
  212. package/package.json +3 -3
@@ -0,0 +1,686 @@
1
+ /**
2
+ * Dictation: speak into a text field.
3
+ *
4
+ * The state machine behind the microphone button and "long-press a field to
5
+ * speak". It does not recognise speech itself: the host hands it a speech
6
+ * source, normally smrt-svelte's browser speech-to-text adapter
7
+ * (`createSttDictationSource` in `@happyvertical/smrt-svelte/browser-ai`),
8
+ * whose adapters already satisfy `DictationSpeechSource`. That keeps smrt-ui
9
+ * free of the Provider and the speech engines, and gives one speech engine
10
+ * for forms, chat, and anything else.
11
+ *
12
+ * States: `idle` → `starting` → `listening` → `stopping` → `idle`, and
13
+ * `error` (with `errorKind`) from any of them. A "ready" beep plays when the
14
+ * microphone is actually listening. Final phrases go to `onText` (put them
15
+ * in the field with `insertTextAtCursor`); the phrase in progress is
16
+ * `interim`.
17
+ *
18
+ * How listening stops: tap the microphone again (`toggle`/`stop`), send the
19
+ * form, press Escape, or pause talking (the browser ends it). A long press
20
+ * starts listening and it keeps listening after the finger comes up, so the
21
+ * person can press, hear the beep, let go, and talk.
22
+ *
23
+ * On first use it asks for the microphone (`getUserMedia`) and waits for the
24
+ * answer before it starts recognising, so the permission prompt cannot cut
25
+ * the recogniser off and a "no" shows as "the microphone is blocked".
26
+ *
27
+ * It never stops silently. Every recogniser error becomes an `error` state
28
+ * with a plain `errorKind` (and the raw `errorCode`, and a console warning);
29
+ * a recogniser that ends on its own within `earlyEndMs` of starting without
30
+ * hearing anything (Brave's speech recognition does this) is an error too.
31
+ *
32
+ * Recording fallback: give it a `transcribe` function and, when the browser
33
+ * has no speech recognition (Firefox), or it fails with no speech service
34
+ * behind it (Brave: Web Speech `network` / `service-not-allowed`, or an end
35
+ * straight away), it records the message with `MediaRecorder` instead
36
+ * (WebM/Opus, or MP4 on Safari; capped at `maxDurationMs` and `maxBytes`).
37
+ * When the person stops, the state is `transcribing` ("Writing it down…")
38
+ * while `transcribe(audio, { mimeType, language })` turns the recording into
39
+ * text, which goes to `onText` like a spoken phrase. Once the browser's
40
+ * recogniser has failed, this `Dictation` records straight away next time.
41
+ */
42
+ import { canCaptureDictationAudio, createMediaRecorderCapture, DictationError, } from './audio-capture.js';
43
+ import { playReadyBeep } from './ready-beep.js';
44
+ const DICTATION_ERROR_KINDS = new Set([
45
+ 'unsupported',
46
+ 'denied',
47
+ 'no-speech',
48
+ 'microphone',
49
+ 'interrupted',
50
+ 'too-long',
51
+ 'not-transcribed',
52
+ 'unavailable',
53
+ 'forbidden',
54
+ 'failed',
55
+ ]);
56
+ const TRANSCRIBE_ERROR_KINDS = new Set([
57
+ 'too-long',
58
+ 'not-transcribed',
59
+ 'unavailable',
60
+ 'forbidden',
61
+ 'no-speech',
62
+ ]);
63
+ const SPEECH_ERROR_KINDS = {
64
+ network: 'unsupported',
65
+ 'service-not-allowed': 'unsupported',
66
+ 'language-not-supported': 'unsupported',
67
+ 'not-allowed': 'denied',
68
+ 'no-speech': 'no-speech',
69
+ 'audio-capture': 'microphone',
70
+ aborted: 'interrupted',
71
+ };
72
+ /** The raw code of a speech or media error, when it has one. */
73
+ export function dictationErrorCode(error) {
74
+ if (error instanceof DictationError)
75
+ return error.code;
76
+ const e = (error ?? {});
77
+ if (typeof e.speechError === 'string')
78
+ return e.speechError;
79
+ // A raw SpeechRecognitionErrorEvent carries the code as `error`.
80
+ if (typeof e.error === 'string')
81
+ return e.error;
82
+ if (typeof e.code === 'string')
83
+ return e.code;
84
+ if (typeof e.name === 'string' && e.name !== 'Error')
85
+ return e.name;
86
+ return null;
87
+ }
88
+ /**
89
+ * Sort a speech error into a `DictationErrorKind`. Reads the error's `code`
90
+ * and `name` (smrt-svelte browser-ai errors: `PERMISSION_DENIED`,
91
+ * `CAPABILITY_NOT_AVAILABLE`) and the Web Speech error names.
92
+ */
93
+ export function classifyDictationError(error) {
94
+ const known = error?.dictationKind;
95
+ if (typeof known === 'string' &&
96
+ DICTATION_ERROR_KINDS.has(known)) {
97
+ return known;
98
+ }
99
+ const e = (error ?? {});
100
+ const speechCode = typeof e.speechError === 'string'
101
+ ? e.speechError
102
+ : typeof e.error === 'string'
103
+ ? e.error
104
+ : '';
105
+ if (speechCode && SPEECH_ERROR_KINDS[speechCode]) {
106
+ return SPEECH_ERROR_KINDS[speechCode];
107
+ }
108
+ const code = typeof e.code === 'string' ? e.code : '';
109
+ const name = typeof e.name === 'string' ? e.name : '';
110
+ const message = typeof e.message === 'string'
111
+ ? e.message.toLowerCase()
112
+ : String(error ?? '').toLowerCase();
113
+ if (code === 'PERMISSION_DENIED' ||
114
+ name === 'PermissionDeniedError' ||
115
+ name === 'NotAllowedError' ||
116
+ /not-allowed|service-not-allowed|permission denied/.test(message)) {
117
+ return 'denied';
118
+ }
119
+ if (code === 'CAPABILITY_NOT_AVAILABLE' ||
120
+ code === 'UNSUPPORTED_ADAPTER' ||
121
+ name === 'CapabilityNotAvailableError' ||
122
+ name === 'NotSupportedError' ||
123
+ /not available|not supported|unsupported/.test(message)) {
124
+ return 'unsupported';
125
+ }
126
+ if (name === 'NotFoundError' ||
127
+ name === 'NotReadableError' ||
128
+ name === 'OverconstrainedError' ||
129
+ /audio capture|audio-capture/.test(message)) {
130
+ return 'microphone';
131
+ }
132
+ if (/no speech|no-speech/.test(message))
133
+ return 'no-speech';
134
+ if (/network/.test(message))
135
+ return 'unsupported';
136
+ if (/aborted/.test(message))
137
+ return 'interrupted';
138
+ return 'failed';
139
+ }
140
+ function pageLanguage() {
141
+ if (typeof document !== 'undefined') {
142
+ const lang = document.documentElement.getAttribute('lang');
143
+ if (lang)
144
+ return lang;
145
+ }
146
+ return 'en-US';
147
+ }
148
+ function defaultLog(event) {
149
+ // biome-ignore lint/suspicious/noConsole: dictation must never fail silently; this is the diagnostic trail.
150
+ console.warn(`[dictation] stopped (${event.kind}) at ${event.stage}` +
151
+ (event.code ? ` [${event.code}]` : '') +
152
+ `: ${event.message}` +
153
+ (event.fallback === 'recording' ? ' (recording instead)' : ''));
154
+ }
155
+ export class Dictation {
156
+ /** Where dictation is (see the module comment). */
157
+ state = $state('idle');
158
+ /** Why it stopped, while `state` is `error`. */
159
+ errorKind = $state(null);
160
+ /** The raw error code behind `errorKind` (`network`, `NotAllowedError`…). */
161
+ errorCode = $state(null);
162
+ /** The phrase being spoken right now. */
163
+ interim = $state('');
164
+ /** The ready beep could not play yet (no user gesture); `unlock` plays it. */
165
+ beepPending = $state(false);
166
+ /**
167
+ * `true` while this message is being recorded (the fallback) rather than
168
+ * recognised by the browser.
169
+ */
170
+ recording = $state(false);
171
+ #options;
172
+ #source = null;
173
+ #sourcePromise = null;
174
+ #unsubscribe = [];
175
+ #session = 0;
176
+ #stopTimer = null;
177
+ #disposed = false;
178
+ #microphoneReady = false;
179
+ /** When the source said it is listening (for the early-end check). */
180
+ #listeningAt = 0;
181
+ /** A result arrived in this session. */
182
+ #heard = false;
183
+ /** The browser's recogniser failed for want of a speech service. */
184
+ #speechBroken = false;
185
+ /** The recorder for this message, in the recording fallback. */
186
+ #capture = null;
187
+ /** The recorder is recording (its `start` resolved). */
188
+ #captureReady = false;
189
+ #captureOff = null;
190
+ /** Cancels writing a recording down. */
191
+ #transcribeAbort = null;
192
+ constructor(options) {
193
+ this.#options = options;
194
+ }
195
+ /** A speech source or a `transcribe` was given (the browser may still turn out unable). */
196
+ get available() {
197
+ return Boolean(this.#options.source || this.#options.transcribe);
198
+ }
199
+ /** Starting or listening. */
200
+ get active() {
201
+ return this.state === 'starting' || this.state === 'listening';
202
+ }
203
+ /** Replace options (for example a new `onText`). */
204
+ setOptions(options) {
205
+ this.#options = { ...this.#options, ...options };
206
+ }
207
+ /** Start listening. A no-op while already starting, listening, or writing down. */
208
+ async start() {
209
+ if (this.#disposed ||
210
+ this.active ||
211
+ this.state === 'stopping' ||
212
+ this.state === 'transcribing')
213
+ return;
214
+ this.errorKind = null;
215
+ this.errorCode = null;
216
+ this.interim = '';
217
+ this.#heard = false;
218
+ this.#listeningAt = 0;
219
+ this.recording = false;
220
+ this.state = 'starting';
221
+ const session = ++this.#session;
222
+ const provider = this.#options.source;
223
+ if (this.#canRecord() && (this.#speechBroken || !provider)) {
224
+ await this.#startRecording(session);
225
+ return;
226
+ }
227
+ if (!provider) {
228
+ this.#fail('unsupported', null, 'No speech source', 'source');
229
+ return;
230
+ }
231
+ let source;
232
+ try {
233
+ source = await this.#resolveSource(provider);
234
+ }
235
+ catch (error) {
236
+ if (session === this.#session)
237
+ this.#failOrRecord(error, 'source', session);
238
+ return;
239
+ }
240
+ if (session !== this.#session || this.#disposed)
241
+ return;
242
+ // Ask for the microphone first and wait for the answer: the recogniser
243
+ // then starts with the permission settled instead of racing the prompt.
244
+ try {
245
+ await this.#ensureMicrophone();
246
+ }
247
+ catch (error) {
248
+ if (session === this.#session)
249
+ this.#failWith(error, 'microphone');
250
+ return;
251
+ }
252
+ if (session !== this.#session ||
253
+ this.#disposed ||
254
+ this.state !== 'starting')
255
+ return;
256
+ try {
257
+ await source.start({
258
+ language: this.#language(),
259
+ continuous: true,
260
+ interimResults: true,
261
+ });
262
+ }
263
+ catch (error) {
264
+ if (session === this.#session)
265
+ this.#failOrRecord(error, 'start', session);
266
+ return;
267
+ }
268
+ if (session !== this.#session || this.recording)
269
+ return;
270
+ if (!source.onStart && this.state === 'starting')
271
+ this.#listening();
272
+ }
273
+ /**
274
+ * Stop listening; the phrase in progress is kept if the source finishes it.
275
+ * A recording is written down (`transcribing`) and its text handed over.
276
+ */
277
+ async stop() {
278
+ if (!this.active)
279
+ return;
280
+ if (this.recording) {
281
+ await this.#finishRecording();
282
+ return;
283
+ }
284
+ this.state = 'stopping';
285
+ const session = this.#session;
286
+ this.#clearStopTimer();
287
+ this.#stopTimer = setTimeout(() => {
288
+ if (session === this.#session && this.state === 'stopping')
289
+ this.#idle();
290
+ }, this.#options.stopTimeoutMs ?? 1500);
291
+ try {
292
+ await this.#source?.stop();
293
+ }
294
+ catch {
295
+ if (session === this.#session)
296
+ this.#idle();
297
+ }
298
+ }
299
+ /** Tap on the microphone: start, or stop while listening. */
300
+ async toggle() {
301
+ if (this.active)
302
+ await this.stop();
303
+ else
304
+ await this.start();
305
+ }
306
+ /** Clear an error so the field looks normal again. */
307
+ clearError() {
308
+ if (this.state === 'error') {
309
+ this.state = 'idle';
310
+ this.errorKind = null;
311
+ this.errorCode = null;
312
+ }
313
+ }
314
+ /**
315
+ * Call from a user gesture (a tap, a long press's release): plays the
316
+ * ready beep if it could not play when listening started.
317
+ */
318
+ async unlock() {
319
+ if (!this.beepPending)
320
+ return;
321
+ const played = await this.#playBeep();
322
+ if (played)
323
+ this.beepPending = false;
324
+ }
325
+ dispose() {
326
+ this.#disposed = true;
327
+ this.#session++;
328
+ this.#clearStopTimer();
329
+ if (this.active && !this.recording)
330
+ void this.#source?.stop().catch(() => undefined);
331
+ this.#dropCapture(true);
332
+ this.#transcribeAbort?.abort();
333
+ this.#transcribeAbort = null;
334
+ for (const off of this.#unsubscribe)
335
+ off();
336
+ this.#unsubscribe = [];
337
+ this.state = 'idle';
338
+ this.recording = false;
339
+ }
340
+ #language() {
341
+ return this.#options.language ?? pageLanguage();
342
+ }
343
+ /** A `transcribe` was given and this browser (or host) can record. */
344
+ #canRecord() {
345
+ return Boolean(this.#options.transcribe &&
346
+ (this.#options.capture || canCaptureDictationAudio()));
347
+ }
348
+ /**
349
+ * The browser's recogniser failed in a way that means it has no speech
350
+ * service (or none at all): record instead, if we can and nothing was
351
+ * heard yet.
352
+ */
353
+ #shouldRecordInstead(kind, stage) {
354
+ if (!this.#canRecord() || this.#heard)
355
+ return false;
356
+ return (kind === 'unsupported' || (kind === 'interrupted' && stage === 'end'));
357
+ }
358
+ #failOrRecord(error, stage, session) {
359
+ const kind = classifyDictationError(error);
360
+ if (this.#shouldRecordInstead(kind, stage)) {
361
+ this.#log(kind, dictationErrorCode(error), errorMessage(error), stage, true);
362
+ void this.#switchToRecording(session);
363
+ return;
364
+ }
365
+ this.#failWith(error, stage);
366
+ }
367
+ /** Leave the browser's recogniser and record this message instead. */
368
+ async #switchToRecording(session) {
369
+ this.#speechBroken = true;
370
+ this.interim = '';
371
+ this.#clearStopTimer();
372
+ const source = this.#source;
373
+ this.recording = true;
374
+ if (source)
375
+ void source.stop().catch(() => undefined);
376
+ await this.#startRecording(session);
377
+ }
378
+ async #startRecording(session) {
379
+ this.recording = true;
380
+ const factory = this.#options.capture ?? createMediaRecorderCapture;
381
+ let capture;
382
+ try {
383
+ capture = factory({
384
+ maxDurationMs: this.#options.maxDurationMs,
385
+ maxBytes: this.#options.maxBytes,
386
+ });
387
+ }
388
+ catch (error) {
389
+ if (session === this.#session)
390
+ this.#failWith(error, 'record');
391
+ return;
392
+ }
393
+ this.#dropCapture(true);
394
+ this.#capture = capture;
395
+ // Reaching the time cap writes the recording down; growing past the size
396
+ // cap fails (`stop` then rejects with `too-long`). Either way, stop.
397
+ this.#captureOff = capture.onLimit(() => {
398
+ if (session === this.#session && this.state === 'listening') {
399
+ void this.#finishRecording();
400
+ }
401
+ });
402
+ try {
403
+ await capture.start();
404
+ if (this.#capture === capture)
405
+ this.#captureReady = true;
406
+ }
407
+ catch (error) {
408
+ if (session === this.#session && this.#capture === capture) {
409
+ this.#dropCapture(true);
410
+ this.#failWith(error, 'record');
411
+ }
412
+ else {
413
+ capture.cancel();
414
+ }
415
+ return;
416
+ }
417
+ if (session !== this.#session ||
418
+ this.#disposed ||
419
+ this.#capture !== capture ||
420
+ !this.active) {
421
+ if (this.#capture === capture)
422
+ this.#dropCapture(true);
423
+ else
424
+ capture.cancel();
425
+ return;
426
+ }
427
+ // Recording from the start: it is listening now (and beeps). Switched
428
+ // over mid-message: it already said "Listening…".
429
+ if (this.state === 'starting')
430
+ this.#listening();
431
+ }
432
+ /** Stop recording, write it down, and hand the text over. */
433
+ async #finishRecording() {
434
+ const session = this.#session;
435
+ const capture = this.#capture;
436
+ if (!capture || !this.#captureReady) {
437
+ // Still opening the microphone: nothing recorded yet, just stop.
438
+ this.#dropCapture(true);
439
+ this.#session++;
440
+ this.#idle();
441
+ return;
442
+ }
443
+ this.#dropCapture(false);
444
+ this.state = 'transcribing';
445
+ this.interim = '';
446
+ this.beepPending = false;
447
+ let recording;
448
+ try {
449
+ recording = await capture.stop();
450
+ }
451
+ catch (error) {
452
+ if (session === this.#session)
453
+ this.#failWith(error, 'record');
454
+ return;
455
+ }
456
+ if (session !== this.#session || this.#disposed)
457
+ return;
458
+ if (recording.audio.size === 0) {
459
+ this.#fail('no-speech', null, 'The recording is empty', 'record');
460
+ return;
461
+ }
462
+ const transcribe = this.#options.transcribe;
463
+ if (!transcribe) {
464
+ this.#fail('unavailable', null, 'No transcribe function', 'transcribe');
465
+ return;
466
+ }
467
+ const controller = new AbortController();
468
+ this.#transcribeAbort = controller;
469
+ let timedOut = false;
470
+ const timer = setTimeout(() => {
471
+ timedOut = true;
472
+ controller.abort();
473
+ }, this.#options.transcribeTimeoutMs ?? 60_000);
474
+ let text = '';
475
+ try {
476
+ const result = await transcribe(recording.audio, {
477
+ mimeType: recording.mimeType,
478
+ language: this.#language(),
479
+ durationMs: recording.durationMs,
480
+ signal: controller.signal,
481
+ });
482
+ text = (typeof result === 'string' ? result : (result?.text ?? '')).trim();
483
+ }
484
+ catch (error) {
485
+ if (session !== this.#session || this.#disposed)
486
+ return;
487
+ if (timedOut) {
488
+ this.#fail('not-transcribed', 'timeout', 'Writing the recording down took too long', 'transcribe');
489
+ }
490
+ else {
491
+ // Only the writing-down kinds make sense here; a provider's
492
+ // "network" error is not the browser lacking speech recognition.
493
+ const kind = classifyDictationError(error);
494
+ this.#fail(TRANSCRIBE_ERROR_KINDS.has(kind) ? kind : 'not-transcribed', dictationErrorCode(error), errorMessage(error), 'transcribe');
495
+ }
496
+ return;
497
+ }
498
+ finally {
499
+ clearTimeout(timer);
500
+ if (this.#transcribeAbort === controller)
501
+ this.#transcribeAbort = null;
502
+ }
503
+ if (session !== this.#session || this.#disposed)
504
+ return;
505
+ if (!text) {
506
+ this.#fail('no-speech', null, 'Nothing was written down', 'transcribe');
507
+ return;
508
+ }
509
+ this.#idle();
510
+ this.#options.onText(text);
511
+ }
512
+ /** Forget the recorder; `cancel` throws its audio away too. */
513
+ #dropCapture(cancel) {
514
+ this.#captureOff?.();
515
+ this.#captureOff = null;
516
+ if (cancel)
517
+ this.#capture?.cancel();
518
+ this.#capture = null;
519
+ this.#captureReady = false;
520
+ }
521
+ async #ensureMicrophone() {
522
+ if (this.#microphoneReady || this.#options.requestMicrophone === false)
523
+ return;
524
+ const media = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
525
+ if (!media || typeof media.getUserMedia !== 'function')
526
+ return;
527
+ const stream = await media.getUserMedia({ audio: true });
528
+ // Only the permission was wanted; the recogniser opens its own stream.
529
+ for (const track of stream.getTracks())
530
+ track.stop();
531
+ this.#microphoneReady = true;
532
+ }
533
+ async #resolveSource(provider) {
534
+ if (this.#source)
535
+ return this.#source;
536
+ this.#sourcePromise ??= Promise.resolve().then(provider);
537
+ try {
538
+ const source = await this.#sourcePromise;
539
+ if (!this.#source) {
540
+ this.#source = source;
541
+ this.#subscribe(source);
542
+ }
543
+ return source;
544
+ }
545
+ catch (error) {
546
+ this.#sourcePromise = null;
547
+ throw error;
548
+ }
549
+ }
550
+ #subscribe(source) {
551
+ this.#unsubscribe.push(source.onResult((result) => {
552
+ if (this.#disposed || this.recording)
553
+ return;
554
+ if (!(this.active || this.state === 'stopping'))
555
+ return;
556
+ this.#heard = true;
557
+ if (result.isFinal) {
558
+ this.interim = '';
559
+ this.#options.onInterim?.('');
560
+ const text = result.text.trim();
561
+ if (text)
562
+ this.#options.onText(text);
563
+ }
564
+ else {
565
+ this.interim = result.text;
566
+ this.#options.onInterim?.(result.text);
567
+ }
568
+ }), source.onError((error) => {
569
+ if (this.#disposed || this.recording)
570
+ return;
571
+ if (this.state === 'idle' || this.state === 'error')
572
+ return;
573
+ // Cut off after the person asked it to stop: that is the stop.
574
+ if (this.state === 'stopping' &&
575
+ classifyDictationError(error) === 'interrupted')
576
+ return;
577
+ if (this.active) {
578
+ this.#failOrRecord(error, 'error', this.#session);
579
+ return;
580
+ }
581
+ this.#failWith(error, 'error');
582
+ }), source.onEnd(() => {
583
+ if (this.#disposed || this.recording)
584
+ return;
585
+ if (this.state === 'error' || this.state === 'idle')
586
+ return;
587
+ if (this.state === 'stopping') {
588
+ this.#idle();
589
+ return;
590
+ }
591
+ // Ended by itself, with no error. Before it ever listened, or within
592
+ // `earlyEndMs` without hearing anything, it did not really work
593
+ // (Brave ends like this): say so rather than going quiet, or record
594
+ // instead when we can.
595
+ const early = this.#options.earlyEndMs ?? 1000;
596
+ if (this.state === 'starting' ||
597
+ (early > 0 && !this.#heard && Date.now() - this.#listeningAt < early)) {
598
+ const message = 'Speech recognition ended straight away';
599
+ if (this.#shouldRecordInstead('interrupted', 'end')) {
600
+ this.#log('interrupted', null, message, 'end', true);
601
+ void this.#switchToRecording(this.#session);
602
+ return;
603
+ }
604
+ this.#fail('interrupted', null, message, 'end');
605
+ return;
606
+ }
607
+ this.#idle();
608
+ }));
609
+ if (source.onStart) {
610
+ this.#unsubscribe.push(source.onStart(() => {
611
+ if (this.recording)
612
+ return;
613
+ if (!this.#disposed && this.state === 'starting') {
614
+ this.#listening();
615
+ return;
616
+ }
617
+ if (!this.#disposed && this.state === 'listening')
618
+ return;
619
+ // Stopped (or disposed) before the microphone opened: close it again.
620
+ void source.stop().catch(() => undefined);
621
+ }));
622
+ }
623
+ }
624
+ #listening() {
625
+ this.state = 'listening';
626
+ this.#listeningAt = Date.now();
627
+ void this.#playBeep().then((played) => {
628
+ this.beepPending = !played && this.state === 'listening';
629
+ });
630
+ }
631
+ async #playBeep() {
632
+ const beep = this.#options.beep ?? true;
633
+ if (beep === false)
634
+ return true;
635
+ try {
636
+ return beep === true ? await playReadyBeep() : Boolean(await beep());
637
+ }
638
+ catch {
639
+ return false;
640
+ }
641
+ }
642
+ #idle() {
643
+ this.#clearStopTimer();
644
+ this.state = 'idle';
645
+ this.interim = '';
646
+ this.beepPending = false;
647
+ this.recording = false;
648
+ }
649
+ #failWith(error, stage) {
650
+ this.#fail(classifyDictationError(error), dictationErrorCode(error), errorMessage(error), stage);
651
+ }
652
+ #log(kind, code, message, stage, fallback = false) {
653
+ const log = this.#options.log ?? defaultLog;
654
+ try {
655
+ log({
656
+ kind,
657
+ code,
658
+ message,
659
+ stage,
660
+ ...(fallback ? { fallback: 'recording' } : {}),
661
+ });
662
+ }
663
+ catch {
664
+ // A broken logger must not hide the error from the person.
665
+ }
666
+ }
667
+ #fail(kind, code, message, stage) {
668
+ this.#clearStopTimer();
669
+ this.#dropCapture(true);
670
+ this.#log(kind, code, message, stage);
671
+ this.errorKind = kind;
672
+ this.errorCode = code;
673
+ this.state = 'error';
674
+ this.interim = '';
675
+ this.beepPending = false;
676
+ this.recording = false;
677
+ }
678
+ #clearStopTimer() {
679
+ if (this.#stopTimer)
680
+ clearTimeout(this.#stopTimer);
681
+ this.#stopTimer = null;
682
+ }
683
+ }
684
+ function errorMessage(error) {
685
+ return error instanceof Error ? error.message : String(error ?? 'unknown');
686
+ }