@happyvertical/smrt-ui 0.55.0 → 0.55.16

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 (84) hide show
  1. package/AGENTS.md +3 -1
  2. package/README.md +113 -5
  3. package/dist/components/forms/CodeSelect.svelte +12 -2
  4. package/dist/components/forms/CodeSelect.svelte.d.ts.map +1 -1
  5. package/dist/components/forms/Combobox.svelte +50 -7
  6. package/dist/components/forms/Combobox.svelte.d.ts +26 -0
  7. package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
  8. package/dist/components/forms/DictationButton.svelte +110 -3
  9. package/dist/components/forms/DictationButton.svelte.d.ts.map +1 -1
  10. package/dist/components/forms/DictationStatus.svelte +33 -5
  11. package/dist/components/forms/DictationStatus.svelte.d.ts +2 -0
  12. package/dist/components/forms/DictationStatus.svelte.d.ts.map +1 -1
  13. package/dist/components/forms/FieldLabel.svelte +44 -0
  14. package/dist/components/forms/FieldLabel.svelte.d.ts +20 -0
  15. package/dist/components/forms/FieldLabel.svelte.d.ts.map +1 -0
  16. package/dist/components/forms/FormGroup.svelte +2 -17
  17. package/dist/components/forms/FormGroup.svelte.d.ts.map +1 -1
  18. package/dist/components/forms/RelationInput.svelte +378 -0
  19. package/dist/components/forms/RelationInput.svelte.d.ts +75 -0
  20. package/dist/components/forms/RelationInput.svelte.d.ts.map +1 -0
  21. package/dist/components/forms/code-select-options.d.ts +8 -1
  22. package/dist/components/forms/code-select-options.d.ts.map +1 -1
  23. package/dist/components/forms/code-select-options.js +46 -5
  24. package/dist/components/forms/code-select-types.d.ts +4 -0
  25. package/dist/components/forms/code-select-types.d.ts.map +1 -1
  26. package/dist/components/forms/dictation.svelte.d.ts +84 -2
  27. package/dist/components/forms/dictation.svelte.d.ts.map +1 -1
  28. package/dist/components/forms/dictation.svelte.js +236 -2
  29. package/dist/components/forms/hands-free-capture.d.ts +70 -0
  30. package/dist/components/forms/hands-free-capture.d.ts.map +1 -0
  31. package/dist/components/forms/hands-free-capture.js +19 -0
  32. package/dist/components/forms/hands-free-vad.d.ts +5 -0
  33. package/dist/components/forms/hands-free-vad.d.ts.map +1 -0
  34. package/dist/components/forms/hands-free-vad.js +80 -0
  35. package/dist/components/forms/index.d.ts +4 -1
  36. package/dist/components/forms/index.d.ts.map +1 -1
  37. package/dist/components/forms/index.js +3 -0
  38. package/dist/components/forms/types.d.ts +9 -0
  39. package/dist/components/forms/types.d.ts.map +1 -1
  40. package/dist/components/layout/ActionGroup.svelte +55 -0
  41. package/dist/components/layout/ActionGroup.svelte.d.ts +18 -0
  42. package/dist/components/layout/ActionGroup.svelte.d.ts.map +1 -0
  43. package/dist/components/layout/Container.svelte +1 -0
  44. package/dist/components/layout/Grid.svelte +2 -1
  45. package/dist/components/layout/PageHeader.svelte +3 -3
  46. package/dist/components/layout/PageHeader.svelte.d.ts +3 -3
  47. package/dist/components/layout/PageLayout.svelte +61 -0
  48. package/dist/components/layout/PageLayout.svelte.d.ts +18 -0
  49. package/dist/components/layout/PageLayout.svelte.d.ts.map +1 -0
  50. package/dist/components/layout/index.d.ts +2 -0
  51. package/dist/components/layout/index.d.ts.map +1 -1
  52. package/dist/components/layout/index.js +2 -0
  53. package/dist/components/permissions/PermissionPicker.svelte +213 -0
  54. package/dist/components/permissions/PermissionPicker.svelte.d.ts +27 -0
  55. package/dist/components/permissions/PermissionPicker.svelte.d.ts.map +1 -0
  56. package/dist/currency.d.ts +3 -0
  57. package/dist/currency.d.ts.map +1 -0
  58. package/dist/currency.js +2 -0
  59. package/dist/i18n/strings.ui.d.ts +7 -0
  60. package/dist/i18n/strings.ui.d.ts.map +1 -1
  61. package/dist/i18n/strings.ui.js +7 -0
  62. package/dist/index.d.ts +1 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +1 -0
  65. package/dist/svelte/playground/InteractiveControlsPreview.svelte +22 -0
  66. package/dist/svelte/playground/InteractiveControlsPreview.svelte.d.ts.map +1 -1
  67. package/dist/svelte/playground/PermissionPickerPreview.svelte +27 -0
  68. package/dist/svelte/playground/PermissionPickerPreview.svelte.d.ts +19 -0
  69. package/dist/svelte/playground/PermissionPickerPreview.svelte.d.ts.map +1 -0
  70. package/dist/svelte/playground.d.ts +14 -2
  71. package/dist/svelte/playground.d.ts.map +1 -1
  72. package/dist/svelte/playground.js +10 -0
  73. package/dist/themes/README.md +5 -0
  74. package/dist/themes/index.d.ts +1 -6
  75. package/dist/themes/index.d.ts.map +1 -1
  76. package/dist/themes/index.js +2 -8
  77. package/dist/themes/presets.d.ts +26 -0
  78. package/dist/themes/presets.d.ts.map +1 -0
  79. package/dist/themes/presets.js +22 -0
  80. package/dist/themes/styles/base.css +31 -0
  81. package/dist/themes/styles/index.d.ts +2 -0
  82. package/dist/themes/styles/index.d.ts.map +1 -1
  83. package/dist/themes/styles/index.js +2 -0
  84. package/package.json +22 -2
@@ -1 +1 @@
1
- {"version":3,"file":"code-select-options.d.ts","sourceRoot":"","sources":["../../../src/components/forms/code-select-options.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAiT/D,qEAAqE;AACrE,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAMlE;AACD,mDAAmD;AACnD,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAGjE;AACD,8EAA8E;AAC9E,wBAAgB,eAAe,CAC7B,OAAO,EAAE,MAAM,GACd,SAAS,gBAAgB,EAAE,GAAG,SAAS,CAEzC"}
1
+ {"version":3,"file":"code-select-options.d.ts","sourceRoot":"","sources":["../../../src/components/forms/code-select-options.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAiT/D;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,EAAE,WAAW,CAAC,MAAM,CA4B7D,CAAC;AACH,uFAAuF;AACvF,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAWlE;AACD,mDAAmD;AACnD,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAGjE;AACD,8EAA8E;AAC9E,wBAAgB,eAAe,CAC7B,OAAO,EAAE,MAAM,GACd,SAAS,gBAAgB,EAAE,GAAG,SAAS,CAEzC"}
@@ -296,13 +296,54 @@ function displayNames(locale, type) {
296
296
  return new Intl.DisplayNames('en', { type });
297
297
  }
298
298
  }
299
- /** Build localized choices from the shared ISO currency metadata. */
299
+ /**
300
+ * ISO 4217 codes that are not circulating currencies: funds codes, precious
301
+ * metals, the IMF unit and bond-market units, and the testing / "no currency"
302
+ * codes. They stay valid stored values (the select retains an unknown value)
303
+ * but are not offered as choices.
304
+ */
305
+ export const NON_CIRCULATING_CURRENCY_CODES = new Set([
306
+ // Funds codes
307
+ 'BOV',
308
+ 'CHE',
309
+ 'CHW',
310
+ 'CLF',
311
+ 'COU',
312
+ 'MXV',
313
+ 'USN',
314
+ 'USS',
315
+ 'UYI',
316
+ 'UYW',
317
+ // Precious metals
318
+ 'XAG',
319
+ 'XAU',
320
+ 'XPD',
321
+ 'XPT',
322
+ // Bond-market units, IMF special drawing rights, other supranational units
323
+ 'XBA',
324
+ 'XBB',
325
+ 'XBC',
326
+ 'XBD',
327
+ 'XDR',
328
+ 'XSU',
329
+ 'XUA',
330
+ // Testing and "no currency"
331
+ 'XTS',
332
+ 'XXX',
333
+ ]);
334
+ /** Build localized choices for circulating currencies from the shared ISO metadata. */
300
335
  export function currencyOptions(locale) {
301
336
  const names = displayNames(locale, 'currency');
302
- return [...ISO_4217_MINOR_UNITS.keys()].sort().map((value) => ({
303
- value,
304
- label: `${value} — ${names.of(value) ?? value}`,
305
- }));
337
+ return [...ISO_4217_MINOR_UNITS.keys()]
338
+ .filter((value) => !NON_CIRCULATING_CURRENCY_CODES.has(value))
339
+ .sort()
340
+ .flatMap((value) => {
341
+ const name = names.of(value);
342
+ // A code the runtime cannot name (shown as "XYZ — XYZ") is not a currency to offer.
343
+ if (!name || name === value)
344
+ return [];
345
+ return [{ value, label: `${value} — ${name}` }];
346
+ });
306
347
  }
307
348
  /** Build localized ISO alpha-2 country choices. */
308
349
  export function countryOptions(locale) {
@@ -21,6 +21,10 @@ export interface CodeSelectProps extends Omit<HTMLSelectAttributes, 'children' |
21
21
  interaction?: ControlInteractionOptions | false;
22
22
  /** Custom choices replace defaults; an empty list is an intentional override. */
23
23
  options?: readonly CodeSelectOption[];
24
+ /** Visible label rendered above the control with the shared field label; becomes its accessible name. */
25
+ label?: string;
26
+ /** Shows the required marker on `label`. */
27
+ required?: boolean;
24
28
  /** Locale used for built-in currency/country display names; invalid locales use English. */
25
29
  locale?: string;
26
30
  /** Empty choice label. Defaults to an em dash and never auto-selects a code. */
@@ -1 +1 @@
1
- {"version":3,"file":"code-select-types.d.ts","sourceRoot":"","sources":["../../../src/components/forms/code-select-types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC3E,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AAE1E,iFAAiF;AACjF,MAAM,WAAW,gBAAgB;IAC/B,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAC;IACd,qDAAqD;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AACD,uFAAuF;AACvF,MAAM,WAAW,eACf,SAAQ,IAAI,CACV,oBAAoB,EACpB,UAAU,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,CAC5C;IACD,uCAAuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC;IAClC,sDAAsD;IACtD,WAAW,CAAC,EAAE,yBAAyB,GAAG,KAAK,CAAC;IAChD,iFAAiF;IACjF,OAAO,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACtC,4FAA4F;IAC5F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mFAAmF;IACnF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AACD,wDAAwD;AACxD,MAAM,WAAW,mBAAoB,SAAQ,eAAe;CAAG;AAC/D,wDAAwD;AACxD,MAAM,WAAW,kBAAmB,SAAQ,eAAe;CAAG;AAC9D,6FAA6F;AAC7F,MAAM,WAAW,mBACf,SAAQ,IAAI,CAAC,eAAe,EAAE,MAAM,aAAa,CAAC,iBAAiB,CAAC,CAAC,EACnE,IAAI,CAAC,aAAa,CAAC,gBAAgB,GAAG,iBAAiB,CAAC,EAAE,UAAU,CAAC;IACvE,mFAAmF;IACnF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
1
+ {"version":3,"file":"code-select-types.d.ts","sourceRoot":"","sources":["../../../src/components/forms/code-select-types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC3E,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AAE1E,iFAAiF;AACjF,MAAM,WAAW,gBAAgB;IAC/B,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAC;IACd,qDAAqD;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AACD,uFAAuF;AACvF,MAAM,WAAW,eACf,SAAQ,IAAI,CACV,oBAAoB,EACpB,UAAU,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,CAC5C;IACD,uCAAuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC;IAClC,sDAAsD;IACtD,WAAW,CAAC,EAAE,yBAAyB,GAAG,KAAK,CAAC;IAChD,iFAAiF;IACjF,OAAO,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACtC,yGAAyG;IACzG,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,4FAA4F;IAC5F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mFAAmF;IACnF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AACD,wDAAwD;AACxD,MAAM,WAAW,mBAAoB,SAAQ,eAAe;CAAG;AAC/D,wDAAwD;AACxD,MAAM,WAAW,kBAAmB,SAAQ,eAAe;CAAG;AAC9D,6FAA6F;AAC7F,MAAM,WAAW,mBACf,SAAQ,IAAI,CAAC,eAAe,EAAE,MAAM,aAAa,CAAC,iBAAiB,CAAC,CAAC,EACnE,IAAI,CAAC,aAAa,CAAC,gBAAgB,GAAG,iBAAiB,CAAC,EAAE,UAAU,CAAC;IACvE,mFAAmF;IACnF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
@@ -38,9 +38,21 @@
38
38
  * while `transcribe(audio, { mimeType, language })` turns the recording into
39
39
  * text, which goes to `onText` like a spoken phrase. Once the browser's
40
40
  * recogniser has failed, this `Dictation` records straight away next time.
41
+ *
42
+ * Hands-free: with `mode: 'hands-free'`, a `handsFreeCapture` factory and a
43
+ * source that has `transcribePcm` (smrt-svelte's on-device `whisper-local`
44
+ * and `moonshine`), listening starts once and then stays on. The microphone
45
+ * is cut into utterances on the device: `speaking` is true while one is
46
+ * being spoken, `level` follows the voice, and each finished utterance is
47
+ * written down (`queued` counts those waiting) and handed to `onText` in
48
+ * the order spoken, while the next one is already being heard. One tap ends
49
+ * it; whatever was said last is still written down (`transcribing`) before
50
+ * it goes `idle`. Where any of the three is missing it is ordinary
51
+ * press-to-talk.
41
52
  */
42
53
  import { type DictationAudioCaptureFactory } from './audio-capture.js';
43
54
  import type { DictationTranscribe } from './dictation-transcribe.js';
55
+ import type { HandsFreeCaptureFactory, HandsFreeVadOptions } from './hands-free-capture.js';
44
56
  export type DictationState = 'idle' | 'starting' | 'listening' | 'stopping' | 'transcribing' | 'error';
45
57
  /**
46
58
  * Why dictation stopped with an error:
@@ -55,9 +67,11 @@ export type DictationState = 'idle' | 'starting' | 'listening' | 'stopping' | 't
55
67
  * - `not-transcribed`: the recording could not be written down;
56
68
  * - `unavailable`: writing recordings down is not set up here;
57
69
  * - `forbidden`: this person may not use it here;
70
+ * - `model-missing`: the on-device speech model is not downloaded (dictation
71
+ * never downloads it; the host asks first and loads it);
58
72
  * - `failed`: anything else.
59
73
  */
60
- export type DictationErrorKind = 'unsupported' | 'denied' | 'no-speech' | 'microphone' | 'interrupted' | 'too-long' | 'not-transcribed' | 'unavailable' | 'forbidden' | 'failed';
74
+ export type DictationErrorKind = 'unsupported' | 'denied' | 'no-speech' | 'microphone' | 'interrupted' | 'too-long' | 'not-transcribed' | 'unavailable' | 'model-missing' | 'forbidden' | 'failed';
61
75
  export interface DictationSpeechResult {
62
76
  text: string;
63
77
  isFinal: boolean;
@@ -80,6 +94,29 @@ export interface DictationSpeechSource {
80
94
  onEnd(callback: () => void): () => void;
81
95
  /** When given, listening (and the beep) starts on this event. */
82
96
  onStart?(callback: () => void): () => void;
97
+ /**
98
+ * Write down one finished utterance (16 kHz mono PCM, -1 to 1) and resolve
99
+ * the text (empty when nothing intelligible was said). A source that has
100
+ * it can be used in hands-free mode, where `Dictation` captures and
101
+ * segments the microphone itself and never calls `start`.
102
+ */
103
+ transcribePcm?(pcm: Float32Array, options?: {
104
+ language?: string;
105
+ }): Promise<string>;
106
+ /**
107
+ * Check the source can write down speech now, before the microphone opens
108
+ * (hands-free calls it; push-to-talk sources do the same inside `start`).
109
+ * Rejects when it cannot, for example an on-device model that is not
110
+ * downloaded yet. It must never start a download itself.
111
+ */
112
+ prepare?(): Promise<void>;
113
+ /**
114
+ * How long to wait for the source to finish after `stop()` before giving
115
+ * up (default: the `stopTimeoutMs` option). A source that writes the
116
+ * message down after the person stops (a model running in the browser)
117
+ * needs far longer than one that has the text already.
118
+ */
119
+ readonly stopTimeoutMs?: number;
83
120
  }
84
121
  /** Resolves the speech source on first use (it may load lazily). */
85
122
  export type DictationSourceProvider = () => DictationSpeechSource | Promise<DictationSpeechSource>;
@@ -126,6 +163,19 @@ export interface DictationOptions {
126
163
  transcribe?: DictationTranscribe | null;
127
164
  /** Records the message (default: `MediaRecorder`). For tests and hosts. */
128
165
  capture?: DictationAudioCaptureFactory;
166
+ /**
167
+ * `'push'` (default): listen until stopped. `'hands-free'`: stay on and
168
+ * write down each utterance as it ends (see the module comment). Needs
169
+ * `handsFreeCapture` and a source with `transcribePcm`, else it is `'push'`.
170
+ */
171
+ mode?: 'push' | 'hands-free';
172
+ /**
173
+ * The microphone for hands-free mode, normally `createHandsFreeCapture`
174
+ * from `@happyvertical/smrt-ui/forms/hands-free`.
175
+ */
176
+ handsFreeCapture?: HandsFreeCaptureFactory | null;
177
+ /** Voice activity tuning for hands-free mode (pause length, sensitivity). */
178
+ vad?: HandsFreeVadOptions;
129
179
  /** Longest recording, in ms; it stops and is written down then. Default 120000. */
130
180
  maxDurationMs?: number;
131
181
  /** Largest recording, in bytes. Default 10 MB. */
@@ -143,7 +193,7 @@ export interface DictationLogEvent {
143
193
  * Where it happened: `microphone`, `source`, `start`, `error`, `end`, or
144
194
  * in the recording fallback `record` / `transcribe`.
145
195
  */
146
- stage: 'microphone' | 'source' | 'start' | 'error' | 'end' | 'record' | 'transcribe';
196
+ stage: 'microphone' | 'source' | 'start' | 'error' | 'end' | 'record' | 'transcribe' | 'hands-free';
147
197
  /** Set when dictation carried on by recording instead of stopping. */
148
198
  fallback?: 'recording';
149
199
  }
@@ -172,9 +222,27 @@ export declare class Dictation {
172
222
  * recognised by the browser.
173
223
  */
174
224
  recording: boolean;
225
+ /** This session is hands-free: the microphone stays on between phrases. */
226
+ handsFree: boolean;
227
+ /** Hands-free: someone is speaking right now (an utterance is open). */
228
+ speaking: boolean;
229
+ /**
230
+ * Hands-free: listening is paused (`suspend()`), for example while the page
231
+ * reads a reply aloud, so the microphone does not hear the assistant.
232
+ */
233
+ suspended: boolean;
234
+ /** Hands-free: how loud the voice is, 0 to 1 (in steps of 0.05). */
235
+ level: number;
236
+ /** Hands-free: utterances heard that are not written down yet. */
237
+ queued: number;
175
238
  constructor(options: DictationOptions);
176
239
  /** A speech source or a `transcribe` was given (the browser may still turn out unable). */
177
240
  get available(): boolean;
241
+ /**
242
+ * Hands-free is set up: `mode: 'hands-free'` with a microphone factory.
243
+ * (Whether the speech source can do it is only known once it loads.)
244
+ */
245
+ get wantsHandsFree(): boolean;
178
246
  /** Starting or listening. */
179
247
  get active(): boolean;
180
248
  /** Replace options (for example a new `onText`). */
@@ -186,6 +254,14 @@ export declare class Dictation {
186
254
  * A recording is written down (`transcribing`) and its text handed over.
187
255
  */
188
256
  stop(): Promise<void>;
257
+ /**
258
+ * Hands-free half-duplex gate: stop hearing while the page plays audio
259
+ * (the microphone stays open). Safe to call before, during or after
260
+ * hands-free; it applies whenever hands-free is on. Idempotent.
261
+ */
262
+ suspend(): void;
263
+ /** Undo `suspend()`: hear again after a short guard against the echo's tail. */
264
+ resume(): void;
189
265
  /** Tap on the microphone: start, or stop while listening. */
190
266
  toggle(): Promise<void>;
191
267
  /** Clear an error so the field looks normal again. */
@@ -196,5 +272,11 @@ export declare class Dictation {
196
272
  */
197
273
  unlock(): Promise<void>;
198
274
  dispose(): void;
275
+ /**
276
+ * Resolves once every hands-free utterance heard so far has been written
277
+ * down (and handed to `onText`). A caller that reads the field right after
278
+ * `stop()` (a send) awaits this to see the last sentence.
279
+ */
280
+ whenSettled(): Promise<void>;
199
281
  }
200
282
  //# sourceMappingURL=dictation.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dictation.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/dictation.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,OAAO,EAIL,KAAK,4BAA4B,EAElC,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAGrE,MAAM,MAAM,cAAc,GACtB,MAAM,GACN,UAAU,GACV,WAAW,GACX,UAAU,GACV,cAAc,GACd,OAAO,CAAC;AAEZ;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,kBAAkB,GAC1B,aAAa,GACb,QAAQ,GACR,WAAW,GACX,YAAY,GACZ,aAAa,GACb,UAAU,GACV,iBAAiB,GACjB,aAAa,GACb,WAAW,GACX,QAAQ,CAAC;AAeb,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,OAAO,CAAC,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACxE,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACtD,KAAK,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;IACxC,iEAAiE;IACjE,OAAO,CAAC,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;CAC5C;AAED,oEAAoE;AACpE,MAAM,MAAM,uBAAuB,GAAG,MAClC,qBAAqB,GACrB,OAAO,CAAC,qBAAqB,CAAC,CAAC;AAEnC,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,MAAM,CAAC,EAAE,uBAAuB,GAAG,IAAI,CAAC;IACxC,+CAA+C;IAC/C,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,kDAAkD;IAClD,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,iEAAiE;IACjE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC;IACpD,6EAA6E;IAC7E,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,GAAG,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IACzC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACxC,2EAA2E;IAC3E,OAAO,CAAC,EAAE,4BAA4B,CAAC;IACvC,mFAAmF;IACnF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,qCAAqC;AACrC,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,kBAAkB,CAAC;IACzB,yEAAyE;IACzE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,KAAK,EACD,YAAY,GACZ,QAAQ,GACR,OAAO,GACP,OAAO,GACP,KAAK,GACL,QAAQ,GACR,YAAY,CAAC;IACjB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,WAAW,CAAC;CACxB;AAoBD,gEAAgE;AAChE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAchE;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,kBAAkB,CA2DzE;AAoBD,qBAAa,SAAS;;IACpB,mDAAmD;IACnD,KAAK,iBAAkC;IACvC,gDAAgD;IAChD,SAAS,4BAA2C;IACpD,6EAA6E;IAC7E,SAAS,gBAA+B;IACxC,yCAAyC;IACzC,OAAO,SAAc;IACrB,8EAA8E;IAC9E,WAAW,UAAiB;IAC5B;;;OAGG;IACH,SAAS,UAAiB;gBAwBd,OAAO,EAAE,gBAAgB;IAIrC,2FAA2F;IAC3F,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,6BAA6B;IAC7B,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED,oDAAoD;IACpD,UAAU,CAAC,OAAO,EAAE,OAAO,CAAC,gBAAgB,CAAC,GAAG,IAAI;IAIpD,mFAAmF;IAC7E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA+D5B;;;OAGG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAmB3B,6DAA6D;IACvD,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAK7B,sDAAsD;IACtD,UAAU,IAAI,IAAI;IAQlB;;;OAGG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAM7B,OAAO,IAAI,IAAI;CA6YhB"}
1
+ {"version":3,"file":"dictation.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/dictation.svelte.ts"],"names":[],"mappings":"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"}
@@ -38,6 +38,17 @@
38
38
  * while `transcribe(audio, { mimeType, language })` turns the recording into
39
39
  * text, which goes to `onText` like a spoken phrase. Once the browser's
40
40
  * recogniser has failed, this `Dictation` records straight away next time.
41
+ *
42
+ * Hands-free: with `mode: 'hands-free'`, a `handsFreeCapture` factory and a
43
+ * source that has `transcribePcm` (smrt-svelte's on-device `whisper-local`
44
+ * and `moonshine`), listening starts once and then stays on. The microphone
45
+ * is cut into utterances on the device: `speaking` is true while one is
46
+ * being spoken, `level` follows the voice, and each finished utterance is
47
+ * written down (`queued` counts those waiting) and handed to `onText` in
48
+ * the order spoken, while the next one is already being heard. One tap ends
49
+ * it; whatever was said last is still written down (`transcribing`) before
50
+ * it goes `idle`. Where any of the three is missing it is ordinary
51
+ * press-to-talk.
41
52
  */
42
53
  import { canCaptureDictationAudio, createMediaRecorderCapture, DictationError, } from './audio-capture.js';
43
54
  import { playReadyBeep } from './ready-beep.js';
@@ -50,6 +61,7 @@ const DICTATION_ERROR_KINDS = new Set([
50
61
  'too-long',
51
62
  'not-transcribed',
52
63
  'unavailable',
64
+ 'model-missing',
53
65
  'forbidden',
54
66
  'failed',
55
67
  ]);
@@ -168,6 +180,21 @@ export class Dictation {
168
180
  * recognised by the browser.
169
181
  */
170
182
  recording = $state(false);
183
+ /** This session is hands-free: the microphone stays on between phrases. */
184
+ handsFree = $state(false);
185
+ /** Hands-free: someone is speaking right now (an utterance is open). */
186
+ speaking = $state(false);
187
+ /**
188
+ * Hands-free: listening is paused (`suspend()`), for example while the page
189
+ * reads a reply aloud, so the microphone does not hear the assistant.
190
+ */
191
+ suspended = $state(false);
192
+ /** `suspend()` was called and not yet undone (it may precede the start). */
193
+ #suspendWanted = false;
194
+ /** Hands-free: how loud the voice is, 0 to 1 (in steps of 0.05). */
195
+ level = $state(0);
196
+ /** Hands-free: utterances heard that are not written down yet. */
197
+ queued = $state(0);
171
198
  #options;
172
199
  #source = null;
173
200
  #sourcePromise = null;
@@ -189,6 +216,12 @@ export class Dictation {
189
216
  #captureOff = null;
190
217
  /** Cancels writing a recording down. */
191
218
  #transcribeAbort = null;
219
+ /** The hands-free microphone, while open. */
220
+ #handsFreeCapture = null;
221
+ /** Hands-free utterances are written down one at a time, in order. */
222
+ #utteranceQueue = Promise.resolve();
223
+ /** The person ended hands-free; the queue is being drained. */
224
+ #handsFreeDraining = false;
192
225
  constructor(options) {
193
226
  this.#options = options;
194
227
  }
@@ -196,6 +229,14 @@ export class Dictation {
196
229
  get available() {
197
230
  return Boolean(this.#options.source || this.#options.transcribe);
198
231
  }
232
+ /**
233
+ * Hands-free is set up: `mode: 'hands-free'` with a microphone factory.
234
+ * (Whether the speech source can do it is only known once it loads.)
235
+ */
236
+ get wantsHandsFree() {
237
+ return (this.#options.mode === 'hands-free' &&
238
+ Boolean(this.#options.handsFreeCapture));
239
+ }
199
240
  /** Starting or listening. */
200
241
  get active() {
201
242
  return this.state === 'starting' || this.state === 'listening';
@@ -239,6 +280,10 @@ export class Dictation {
239
280
  }
240
281
  if (session !== this.#session || this.#disposed)
241
282
  return;
283
+ if (this.#wantsHandsFree(source)) {
284
+ await this.#startHandsFree(session, source);
285
+ return;
286
+ }
242
287
  // Ask for the microphone first and wait for the answer: the recogniser
243
288
  // then starts with the permission settled instead of racing the prompt.
244
289
  try {
@@ -281,13 +326,17 @@ export class Dictation {
281
326
  await this.#finishRecording();
282
327
  return;
283
328
  }
329
+ if (this.handsFree) {
330
+ this.#stopHandsFree();
331
+ return;
332
+ }
284
333
  this.state = 'stopping';
285
334
  const session = this.#session;
286
335
  this.#clearStopTimer();
287
336
  this.#stopTimer = setTimeout(() => {
288
337
  if (session === this.#session && this.state === 'stopping')
289
338
  this.#idle();
290
- }, this.#options.stopTimeoutMs ?? 1500);
339
+ }, this.#source?.stopTimeoutMs ?? this.#options.stopTimeoutMs ?? 1500);
291
340
  try {
292
341
  await this.#source?.stop();
293
342
  }
@@ -296,6 +345,28 @@ export class Dictation {
296
345
  this.#idle();
297
346
  }
298
347
  }
348
+ /**
349
+ * Hands-free half-duplex gate: stop hearing while the page plays audio
350
+ * (the microphone stays open). Safe to call before, during or after
351
+ * hands-free; it applies whenever hands-free is on. Idempotent.
352
+ */
353
+ suspend() {
354
+ this.#suspendWanted = true;
355
+ if (!this.handsFree || this.suspended)
356
+ return;
357
+ this.#handsFreeCapture?.suspend?.();
358
+ this.suspended = true;
359
+ this.speaking = false;
360
+ this.level = 0;
361
+ }
362
+ /** Undo `suspend()`: hear again after a short guard against the echo's tail. */
363
+ resume() {
364
+ this.#suspendWanted = false;
365
+ if (!this.suspended)
366
+ return;
367
+ this.#handsFreeCapture?.resume?.();
368
+ this.suspended = false;
369
+ }
299
370
  /** Tap on the microphone: start, or stop while listening. */
300
371
  async toggle() {
301
372
  if (this.active)
@@ -326,9 +397,10 @@ export class Dictation {
326
397
  this.#disposed = true;
327
398
  this.#session++;
328
399
  this.#clearStopTimer();
329
- if (this.active && !this.recording)
400
+ if (this.active && !this.recording && !this.handsFree)
330
401
  void this.#source?.stop().catch(() => undefined);
331
402
  this.#dropCapture(true);
403
+ this.#dropHandsFree();
332
404
  this.#transcribeAbort?.abort();
333
405
  this.#transcribeAbort = null;
334
406
  for (const off of this.#unsubscribe)
@@ -336,6 +408,7 @@ export class Dictation {
336
408
  this.#unsubscribe = [];
337
409
  this.state = 'idle';
338
410
  this.recording = false;
411
+ this.#resetHandsFree();
339
412
  }
340
413
  #language() {
341
414
  return this.#options.language ?? pageLanguage();
@@ -509,6 +582,160 @@ export class Dictation {
509
582
  this.#idle();
510
583
  this.#options.onText(text);
511
584
  }
585
+ /** Hands-free is wanted and possible with this source. */
586
+ #wantsHandsFree(source) {
587
+ return Boolean(this.#options.mode === 'hands-free' &&
588
+ this.#options.handsFreeCapture &&
589
+ typeof source.transcribePcm === 'function');
590
+ }
591
+ async #startHandsFree(session, source) {
592
+ const factory = this.#options.handsFreeCapture;
593
+ if (!factory)
594
+ return;
595
+ try {
596
+ await source.prepare?.();
597
+ }
598
+ catch (error) {
599
+ if (session === this.#session)
600
+ this.#failWith(error, 'source');
601
+ return;
602
+ }
603
+ if (session !== this.#session || this.#disposed)
604
+ return;
605
+ this.#resetHandsFree();
606
+ this.handsFree = true;
607
+ let capture;
608
+ try {
609
+ capture = factory({
610
+ vad: this.#options.vad,
611
+ onUtterance: (utterance) => {
612
+ // Backstop for captures without their own gate.
613
+ if (this.suspended)
614
+ return;
615
+ this.#enqueueUtterance(session, source, utterance);
616
+ },
617
+ onSpeaking: (speaking) => {
618
+ if (session === this.#session && this.handsFree && !this.suspended) {
619
+ this.speaking = speaking;
620
+ }
621
+ },
622
+ onLevel: (level) => {
623
+ if (session !== this.#session || !this.handsFree)
624
+ return;
625
+ const stepped = Math.round(Math.min(1, Math.max(0, level)) * 20) / 20;
626
+ if (stepped !== this.level)
627
+ this.level = stepped;
628
+ },
629
+ onError: (error) => {
630
+ if (session === this.#session)
631
+ this.#failWith(error, 'hands-free');
632
+ },
633
+ });
634
+ }
635
+ catch (error) {
636
+ if (session === this.#session)
637
+ this.#failWith(error, 'hands-free');
638
+ return;
639
+ }
640
+ this.#handsFreeCapture = capture;
641
+ if (this.#suspendWanted) {
642
+ capture.suspend?.();
643
+ this.suspended = true;
644
+ }
645
+ try {
646
+ await capture.start();
647
+ }
648
+ catch (error) {
649
+ if (this.#handsFreeCapture === capture)
650
+ this.#handsFreeCapture = null;
651
+ if (session === this.#session)
652
+ this.#failWith(error, 'microphone');
653
+ return;
654
+ }
655
+ if (session !== this.#session ||
656
+ this.#disposed ||
657
+ this.#handsFreeCapture !== capture ||
658
+ !this.active) {
659
+ // Stopped (or disposed) while the microphone was opening.
660
+ if (this.#handsFreeCapture === capture)
661
+ this.#handsFreeCapture = null;
662
+ capture.cancel();
663
+ return;
664
+ }
665
+ if (this.state === 'starting')
666
+ this.#listening();
667
+ }
668
+ /** Write one utterance down, after the ones before it. */
669
+ #enqueueUtterance(session, source, utterance) {
670
+ if (this.#disposed || session !== this.#session)
671
+ return;
672
+ this.queued += 1;
673
+ const language = this.#language();
674
+ this.#utteranceQueue = this.#utteranceQueue.then(async () => {
675
+ if (this.#disposed || session !== this.#session)
676
+ return;
677
+ let text = '';
678
+ try {
679
+ text = ((await source.transcribePcm?.(utterance.pcm, { language })) ?? '').trim();
680
+ }
681
+ catch (error) {
682
+ if (session === this.#session) {
683
+ const kind = classifyDictationError(error);
684
+ this.#fail(TRANSCRIBE_ERROR_KINDS.has(kind) ? kind : 'not-transcribed', dictationErrorCode(error), errorMessage(error), 'transcribe');
685
+ }
686
+ return;
687
+ }
688
+ if (this.#disposed || session !== this.#session)
689
+ return;
690
+ this.queued = Math.max(0, this.queued - 1);
691
+ // Models write "[BLANK_AUDIO]" or "(silence)" for noise: not words.
692
+ if (text && !/^[[(].*[\])]$/.test(text))
693
+ this.#options.onText(text);
694
+ if (this.#handsFreeDraining && this.queued === 0)
695
+ this.#idle();
696
+ });
697
+ }
698
+ /**
699
+ * Resolves once every hands-free utterance heard so far has been written
700
+ * down (and handed to `onText`). A caller that reads the field right after
701
+ * `stop()` (a send) awaits this to see the last sentence.
702
+ */
703
+ async whenSettled() {
704
+ await this.#utteranceQueue;
705
+ }
706
+ /** The person ended hands-free: finish the phrase, then write it all down. */
707
+ #stopHandsFree() {
708
+ const capture = this.#handsFreeCapture;
709
+ this.#handsFreeCapture = null;
710
+ this.speaking = false;
711
+ this.suspended = false;
712
+ this.level = 0;
713
+ this.beepPending = false;
714
+ // Hands over the utterance in progress, then releases the microphone.
715
+ capture?.stop();
716
+ if (this.queued > 0) {
717
+ this.#handsFreeDraining = true;
718
+ this.state = 'transcribing';
719
+ }
720
+ else {
721
+ this.#idle();
722
+ }
723
+ }
724
+ /** Release the hands-free microphone and drop what it heard. */
725
+ #dropHandsFree() {
726
+ const capture = this.#handsFreeCapture;
727
+ this.#handsFreeCapture = null;
728
+ capture?.cancel();
729
+ }
730
+ #resetHandsFree() {
731
+ this.handsFree = false;
732
+ this.suspended = false;
733
+ this.speaking = false;
734
+ this.level = 0;
735
+ this.queued = 0;
736
+ this.#handsFreeDraining = false;
737
+ this.#utteranceQueue = Promise.resolve();
738
+ }
512
739
  /** Forget the recorder; `cancel` throws its audio away too. */
513
740
  #dropCapture(cancel) {
514
741
  this.#captureOff?.();
@@ -645,6 +872,7 @@ export class Dictation {
645
872
  this.interim = '';
646
873
  this.beepPending = false;
647
874
  this.recording = false;
875
+ this.#resetHandsFree();
648
876
  }
649
877
  #failWith(error, stage) {
650
878
  this.#fail(classifyDictationError(error), dictationErrorCode(error), errorMessage(error), stage);
@@ -667,6 +895,11 @@ export class Dictation {
667
895
  #fail(kind, code, message, stage) {
668
896
  this.#clearStopTimer();
669
897
  this.#dropCapture(true);
898
+ if (this.handsFree) {
899
+ // Whatever was queued dies with the session.
900
+ this.#session++;
901
+ this.#dropHandsFree();
902
+ }
670
903
  this.#log(kind, code, message, stage);
671
904
  this.errorKind = kind;
672
905
  this.errorCode = code;
@@ -674,6 +907,7 @@ export class Dictation {
674
907
  this.interim = '';
675
908
  this.beepPending = false;
676
909
  this.recording = false;
910
+ this.#resetHandsFree();
677
911
  }
678
912
  #clearStopTimer() {
679
913
  if (this.#stopTimer)
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The microphone side of hands-free dictation, as an interface.
3
+ *
4
+ * `Dictation` in `mode: 'hands-free'` opens the microphone once and cuts it
5
+ * into utterances on the device (voice activity detection, no audio leaves
6
+ * the page): each pause hands over one 16 kHz mono utterance, which the speech
7
+ * source writes down while the next is still being spoken. A
8
+ * `HandsFreeCaptureFactory` supplies that microphone. The one that ships is
9
+ * `createHandsFreeCapture` in `@happyvertical/smrt-ui/forms/hands-free`, built
10
+ * on `@happyvertical/speech/browser`'s `createVadCapture`; it lives in its own
11
+ * entry so only apps that opt in bundle the optional peer.
12
+ */
13
+ /** Tuning for the detector. Every field is optional. */
14
+ export interface HandsFreeVadOptions {
15
+ /** Silence that ends an utterance, in ms. Default 800. */
16
+ silenceMs?: number;
17
+ /** Speech shorter than this is a click or a cough, not an utterance. Default 150. */
18
+ minSpeechMs?: number;
19
+ /** Audio kept from before the onset so the first syllable is not clipped. Default 300. */
20
+ preRollMs?: number;
21
+ /** An utterance this long is cut and the next one starts at once. Default 30000. */
22
+ maxUtteranceMs?: number;
23
+ /**
24
+ * 0 (needs loud, close speech) to 1 (picks up quiet speech, and more
25
+ * noise). Default 0.5.
26
+ */
27
+ sensitivity?: number;
28
+ }
29
+ /** A finished utterance, ready for a speech model. */
30
+ export interface HandsFreeUtterance {
31
+ /** Mono samples (-1 to 1) at `sampleRate` (16 kHz), pre-roll included. */
32
+ pcm: Float32Array;
33
+ sampleRate: number;
34
+ durationMs: number;
35
+ /** Why it ended: a pause, the length cap, or the person turning it off. */
36
+ reason: 'silence' | 'max' | 'flush';
37
+ }
38
+ export interface HandsFreeCaptureOptions {
39
+ vad?: HandsFreeVadOptions;
40
+ onUtterance: (utterance: HandsFreeUtterance) => void;
41
+ /** An utterance began (`true`) or ended (`false`). */
42
+ onSpeaking?: (speaking: boolean) => void;
43
+ /** Loudness 0 to 1, many times a second. */
44
+ onLevel?: (level: number) => void;
45
+ /** The microphone failed after it opened. */
46
+ onError?: (error: Error) => void;
47
+ /** Media constraints for `getUserMedia`. */
48
+ constraints?: MediaStreamConstraints;
49
+ }
50
+ export interface HandsFreeCapture {
51
+ /** Opens the microphone and starts detecting. Rejects when it cannot. */
52
+ start(): Promise<void>;
53
+ /** Hands over any utterance in progress, then releases the microphone. */
54
+ stop(): void;
55
+ /** Releases the microphone and throws the audio away. */
56
+ cancel(): void;
57
+ /**
58
+ * Half-duplex gate: stop hearing (the microphone stays open) while the page
59
+ * plays audio, so the assistant's voice is not transcribed. An utterance in
60
+ * progress is discarded. Optional; `Dictation` also ignores utterances that
61
+ * arrive while it is suspended.
62
+ */
63
+ suspend?(): void;
64
+ /** Hear again, after a short guard that ignores the playback's tail. */
65
+ resume?(): void;
66
+ }
67
+ export type HandsFreeCaptureFactory = (options: HandsFreeCaptureOptions) => HandsFreeCapture;
68
+ /** Whether this browser can open a microphone and tap it with Web Audio. */
69
+ export declare function canCaptureHandsFree(): boolean;
70
+ //# sourceMappingURL=hands-free-capture.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hands-free-capture.d.ts","sourceRoot":"","sources":["../../../src/components/forms/hands-free-capture.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,wDAAwD;AACxD,MAAM,WAAW,mBAAmB;IAClC,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0FAA0F;IAC1F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,sDAAsD;AACtD,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,GAAG,EAAE,YAAY,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,MAAM,EAAE,SAAS,GAAG,KAAK,GAAG,OAAO,CAAC;CACrC;AAED,MAAM,WAAW,uBAAuB;IACtC,GAAG,CAAC,EAAE,mBAAmB,CAAC;IAC1B,WAAW,EAAE,CAAC,SAAS,EAAE,kBAAkB,KAAK,IAAI,CAAC;IACrD,sDAAsD;IACtD,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IACzC,4CAA4C;IAC5C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,6CAA6C;IAC7C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,4CAA4C;IAC5C,WAAW,CAAC,EAAE,sBAAsB,CAAC;CACtC;AAED,MAAM,WAAW,gBAAgB;IAC/B,yEAAyE;IACzE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,0EAA0E;IAC1E,IAAI,IAAI,IAAI,CAAC;IACb,yDAAyD;IACzD,MAAM,IAAI,IAAI,CAAC;IACf;;;;;OAKG;IACH,OAAO,CAAC,IAAI,IAAI,CAAC;IACjB,wEAAwE;IACxE,MAAM,CAAC,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,MAAM,uBAAuB,GAAG,CACpC,OAAO,EAAE,uBAAuB,KAC7B,gBAAgB,CAAC;AAEtB,4EAA4E;AAC5E,wBAAgB,mBAAmB,IAAI,OAAO,CAO7C"}