@livx.cc/native-kit 0.23.0 → 0.25.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.
- package/package.json +1 -1
- package/src/core/NativeKit.ts +5 -1
- package/src/core/web-adapter.ts +204 -0
- package/src/index.ts +3 -0
- package/src/modules/scanner.ts +63 -0
- package/src/modules/speech.ts +99 -0
package/package.json
CHANGED
package/src/core/NativeKit.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AppwrapAdapter } from './appwrap-adapter';
|
|
2
2
|
import { WebAdapter } from './web-adapter';
|
|
3
|
-
import { Capability, Handshake, InvokeOptions, KIT_PROTOCOL, KitError, NativeKitAdapter, Unsubscribe } from './types';
|
|
3
|
+
import { Capability, Handshake, InvokeOptions, KIT_PROTOCOL, KitError, NativeKitAdapter, Platform, Unsubscribe } from './types';
|
|
4
4
|
import { AppModule } from '../modules/app';
|
|
5
5
|
import { BillingModule } from '../modules/billing/billing';
|
|
6
6
|
import { BiometricsModule } from '../modules/biometrics';
|
|
@@ -23,8 +23,10 @@ import { OAuthModule } from '../modules/oauth';
|
|
|
23
23
|
import { PhotosModule } from '../modules/photos';
|
|
24
24
|
import { PushModule } from '../modules/push';
|
|
25
25
|
import { ReviewsModule } from '../modules/reviews';
|
|
26
|
+
import { ScannerModule } from '../modules/scanner';
|
|
26
27
|
import { ScreenModule } from '../modules/screen';
|
|
27
28
|
import { ShareModule } from '../modules/share';
|
|
29
|
+
import { SpeechModule } from '../modules/speech';
|
|
28
30
|
import { StorageModule } from '../modules/storage';
|
|
29
31
|
import { ToastModule } from '../modules/toast';
|
|
30
32
|
import { UiModule } from '../modules/ui';
|
|
@@ -91,6 +93,8 @@ export class NativeKit {
|
|
|
91
93
|
public readonly health = new HealthModule(this);
|
|
92
94
|
public readonly media = new MediaModule(this);
|
|
93
95
|
public readonly contacts = new ContactsModule(this);
|
|
96
|
+
public readonly scanner = new ScannerModule(this);
|
|
97
|
+
public readonly speech = new SpeechModule(this);
|
|
94
98
|
public readonly calendar = new CalendarModule(this);
|
|
95
99
|
public readonly app = new AppModule(this);
|
|
96
100
|
public readonly browser = new BrowserModule(this);
|
package/src/core/web-adapter.ts
CHANGED
|
@@ -38,6 +38,10 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
38
38
|
private listeners = new Map<string, Set<(payload: unknown) => void>>();
|
|
39
39
|
private geoWatchId: number | null = null;
|
|
40
40
|
private motionHandler: ((e: DeviceMotionEvent) => void) | null = null;
|
|
41
|
+
/** Tear-down for an in-progress scanner.scan loop (stops the camera, removes the overlay). */
|
|
42
|
+
private scanCancel: (() => void) | null = null;
|
|
43
|
+
/** Stop an in-progress speech.listen session (resolves it with the best transcript so far). */
|
|
44
|
+
private listenStop: (() => void) | null = null;
|
|
41
45
|
|
|
42
46
|
detect(): boolean {
|
|
43
47
|
return typeof window !== 'undefined';
|
|
@@ -75,6 +79,15 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
75
79
|
calendar: 'none',
|
|
76
80
|
camera: 'web', // <input capture> — mobile browsers open the camera
|
|
77
81
|
media: (navigator as any).mediaDevices?.getUserMedia ? 'web' : 'none',
|
|
82
|
+
// BarcodeDetector (Chrome/Android) decodes; needs a getUserMedia stream to feed it. Both → 'web'.
|
|
83
|
+
scanner: typeof (window as any).BarcodeDetector !== 'undefined' && (navigator as any).mediaDevices?.getUserMedia ? 'web' : 'none',
|
|
84
|
+
// TTS: SpeechSynthesis API (broad support). STT: SpeechRecognition (Chrome/webkit only).
|
|
85
|
+
speech: typeof (window as any).speechSynthesis !== 'undefined' ? 'web' : 'none',
|
|
86
|
+
speechRecognition:
|
|
87
|
+
typeof (window as any).SpeechRecognition !== 'undefined' ||
|
|
88
|
+
typeof (window as any).webkitSpeechRecognition !== 'undefined'
|
|
89
|
+
? 'web'
|
|
90
|
+
: 'none',
|
|
78
91
|
app: 'web', // openUrl via window.open; openSettings unsupported
|
|
79
92
|
browser: 'web', // new tab/window
|
|
80
93
|
billing: 'none', // no IAP in a plain browser — wire a web checkout yourself
|
|
@@ -321,6 +334,25 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
321
334
|
case 'calendar.createEvent':
|
|
322
335
|
throw new KitError('UNSUPPORTED', 'No calendar access on web');
|
|
323
336
|
|
|
337
|
+
case 'scanner.scan':
|
|
338
|
+
return this.scanBarcode(p.formats, p.camera === 'front' ? 'user' : 'environment') as Promise<T>;
|
|
339
|
+
case 'scanner.cancel':
|
|
340
|
+
this.scanCancel?.();
|
|
341
|
+
return undefined as T;
|
|
342
|
+
|
|
343
|
+
case 'speech.speak':
|
|
344
|
+
return this.speak(String(p.text ?? ''), p) as Promise<T>;
|
|
345
|
+
case 'speech.stop':
|
|
346
|
+
(window as any).speechSynthesis?.cancel?.();
|
|
347
|
+
return undefined as T;
|
|
348
|
+
case 'speech.voices':
|
|
349
|
+
return this.listVoices() as Promise<T>;
|
|
350
|
+
case 'speech.listen':
|
|
351
|
+
return this.listen(p) as Promise<T>;
|
|
352
|
+
case 'speech.stopListening':
|
|
353
|
+
this.listenStop?.();
|
|
354
|
+
return undefined as T;
|
|
355
|
+
|
|
324
356
|
case 'photos.pick':
|
|
325
357
|
return this.pickImageFile(false, !!p.dataUrl, p.maxSize) as Promise<T>;
|
|
326
358
|
case 'camera.capture':
|
|
@@ -447,6 +479,178 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
447
479
|
this.listeners.get(event)?.forEach((cb) => cb(payload));
|
|
448
480
|
}
|
|
449
481
|
|
|
482
|
+
/** Map our small format enum → BarcodeDetector `formats` strings. Omit/`'all'` = detector default. */
|
|
483
|
+
private toDetectorFormats(formats?: string | string[]): string[] | undefined {
|
|
484
|
+
const want = (Array.isArray(formats) ? formats : formats ? [formats] : []).filter((f) => f && f !== 'all');
|
|
485
|
+
if (!want.length) return undefined;
|
|
486
|
+
const map: Record<string, string> = { qr: 'qr_code', ean13: 'ean_13', code128: 'code_128' };
|
|
487
|
+
return want.map((f) => map[f] ?? f);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Reverse map a detector format string → our enum (best-effort). */
|
|
491
|
+
private fromDetectorFormat(f: string): 'qr' | 'ean13' | 'code128' | 'unknown' {
|
|
492
|
+
if (f === 'qr_code') return 'qr';
|
|
493
|
+
if (f === 'ean_13') return 'ean13';
|
|
494
|
+
if (f === 'code_128') return 'code128';
|
|
495
|
+
return 'unknown';
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Open a full-screen camera overlay, run a BarcodeDetector loop, resolve the FIRST hit then tear
|
|
500
|
+
* down. Resolves `{ cancelled: true }` if the user taps the close button or `scanner.cancel` fires.
|
|
501
|
+
*/
|
|
502
|
+
private scanBarcode(
|
|
503
|
+
formats: string | string[] | undefined,
|
|
504
|
+
facingMode: 'environment' | 'user'
|
|
505
|
+
): Promise<{ value: string; format: string; bounds?: any } | { cancelled: true }> {
|
|
506
|
+
const Detector = (window as any).BarcodeDetector;
|
|
507
|
+
const getUserMedia = (navigator as any).mediaDevices?.getUserMedia?.bind((navigator as any).mediaDevices);
|
|
508
|
+
if (!Detector || !getUserMedia) throw new KitError('UNSUPPORTED', 'BarcodeDetector / camera unavailable in this browser');
|
|
509
|
+
|
|
510
|
+
return new Promise(async (resolve, reject) => {
|
|
511
|
+
let stream: MediaStream | null = null;
|
|
512
|
+
let raf = 0;
|
|
513
|
+
let done = false;
|
|
514
|
+
const wrap = document.createElement('div');
|
|
515
|
+
const video = document.createElement('video');
|
|
516
|
+
|
|
517
|
+
const teardown = () => {
|
|
518
|
+
if (done) return;
|
|
519
|
+
done = true;
|
|
520
|
+
cancelAnimationFrame(raf);
|
|
521
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
522
|
+
wrap.remove();
|
|
523
|
+
// Only clear if it's still OUR handler — a newer scan may have replaced it.
|
|
524
|
+
if (this.scanCancel === cancelHandler) this.scanCancel = null;
|
|
525
|
+
};
|
|
526
|
+
const cancelHandler = () => { teardown(); resolve({ cancelled: true }); };
|
|
527
|
+
this.scanCancel = cancelHandler;
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
const detector = new Detector(this.toDetectorFormats(formats) ? { formats: this.toDetectorFormats(formats) } : undefined);
|
|
531
|
+
stream = await getUserMedia({ video: { facingMode } });
|
|
532
|
+
wrap.style.cssText = 'position:fixed;inset:0;background:#000;z-index:99999;display:flex;flex-direction:column';
|
|
533
|
+
video.setAttribute('playsinline', '');
|
|
534
|
+
video.muted = true;
|
|
535
|
+
video.style.cssText = 'flex:1;width:100%;height:100%;object-fit:cover';
|
|
536
|
+
(video as any).srcObject = stream;
|
|
537
|
+
const close = document.createElement('button');
|
|
538
|
+
close.textContent = 'Cancel';
|
|
539
|
+
close.style.cssText = 'position:absolute;top:max(12px,env(safe-area-inset-top));right:16px;z-index:1;background:rgba(0,0,0,.5);color:#fff;border:0;border-radius:18px;padding:8px 16px;font:15px system-ui';
|
|
540
|
+
close.onclick = () => { teardown(); resolve({ cancelled: true }); };
|
|
541
|
+
wrap.appendChild(video);
|
|
542
|
+
wrap.appendChild(close);
|
|
543
|
+
document.body.appendChild(wrap);
|
|
544
|
+
await video.play();
|
|
545
|
+
|
|
546
|
+
const tick = async () => {
|
|
547
|
+
if (done) return;
|
|
548
|
+
try {
|
|
549
|
+
const codes = await detector.detect(video);
|
|
550
|
+
if (codes?.length) {
|
|
551
|
+
const c = codes[0];
|
|
552
|
+
const b = c.boundingBox;
|
|
553
|
+
teardown();
|
|
554
|
+
resolve({
|
|
555
|
+
value: c.rawValue,
|
|
556
|
+
format: this.fromDetectorFormat(c.format),
|
|
557
|
+
bounds: b ? { x: b.x, y: b.y, width: b.width, height: b.height } : undefined,
|
|
558
|
+
});
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
} catch { /* transient detect error — keep looping */ }
|
|
562
|
+
raf = requestAnimationFrame(tick);
|
|
563
|
+
};
|
|
564
|
+
raf = requestAnimationFrame(tick);
|
|
565
|
+
} catch (e: any) {
|
|
566
|
+
teardown();
|
|
567
|
+
reject(new KitError(e?.name === 'NotAllowedError' ? 'DENIED' : 'NATIVE_ERROR', e?.message ?? 'scan failed'));
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── speech (Web Speech API) ─────────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
/** Speak via SpeechSynthesis; resolve when the utterance ends (or rejects on synth error). */
|
|
575
|
+
private speak(text: string, opts: Record<string, any>): Promise<void> {
|
|
576
|
+
const synth = (window as any).speechSynthesis;
|
|
577
|
+
if (!synth) throw new KitError('UNSUPPORTED', 'SpeechSynthesis unavailable');
|
|
578
|
+
return new Promise((resolve, reject) => {
|
|
579
|
+
const u = new (window as any).SpeechSynthesisUtterance(text);
|
|
580
|
+
if (opts.lang) u.lang = opts.lang;
|
|
581
|
+
if (opts.rate != null) u.rate = opts.rate;
|
|
582
|
+
if (opts.pitch != null) u.pitch = opts.pitch;
|
|
583
|
+
if (opts.voice) {
|
|
584
|
+
const v = synth.getVoices().find((vc: any) => vc.voiceURI === opts.voice || vc.name === opts.voice);
|
|
585
|
+
if (v) u.voice = v;
|
|
586
|
+
}
|
|
587
|
+
u.onend = () => resolve();
|
|
588
|
+
u.onerror = (e: any) => reject(new KitError('NATIVE_ERROR', e?.error ?? 'speech synthesis failed'));
|
|
589
|
+
synth.speak(u);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** List synthesizer voices, awaiting the async `voiceschanged` event when the list is empty. */
|
|
594
|
+
private listVoices(): Promise<Array<{ id: string; name: string; lang: string }>> {
|
|
595
|
+
const synth = (window as any).speechSynthesis;
|
|
596
|
+
if (!synth) throw new KitError('UNSUPPORTED', 'SpeechSynthesis unavailable');
|
|
597
|
+
const map = (vs: any[]) => vs.map((v) => ({ id: v.voiceURI ?? v.name, name: v.name, lang: v.lang }));
|
|
598
|
+
const ready = synth.getVoices();
|
|
599
|
+
if (ready.length) return Promise.resolve(map(ready));
|
|
600
|
+
// Chrome populates voices asynchronously — wait once for voiceschanged, with a short fallback.
|
|
601
|
+
return new Promise((resolve) => {
|
|
602
|
+
let settled = false;
|
|
603
|
+
const done = () => {
|
|
604
|
+
if (settled) return;
|
|
605
|
+
settled = true;
|
|
606
|
+
resolve(map(synth.getVoices()));
|
|
607
|
+
};
|
|
608
|
+
synth.addEventListener?.('voiceschanged', done, { once: true });
|
|
609
|
+
setTimeout(done, 1000);
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Capture the mic via SpeechRecognition; resolve the FINAL transcript, stream partials. */
|
|
614
|
+
private listen(opts: Record<string, any>): Promise<string> {
|
|
615
|
+
const Rec = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
|
616
|
+
if (!Rec) throw new KitError('UNSUPPORTED', 'SpeechRecognition unavailable (Chrome only)');
|
|
617
|
+
return new Promise((resolve, reject) => {
|
|
618
|
+
const rec = new Rec();
|
|
619
|
+
if (opts.lang) rec.lang = opts.lang;
|
|
620
|
+
rec.interimResults = !!opts.partial;
|
|
621
|
+
rec.continuous = false;
|
|
622
|
+
let best = '';
|
|
623
|
+
let settled = false;
|
|
624
|
+
const finish = () => {
|
|
625
|
+
if (settled) return;
|
|
626
|
+
settled = true;
|
|
627
|
+
if (this.listenStop === stop) this.listenStop = null;
|
|
628
|
+
resolve(best);
|
|
629
|
+
};
|
|
630
|
+
const stop = () => { try { rec.stop(); } catch { /* already stopped */ } };
|
|
631
|
+
this.listenStop = stop;
|
|
632
|
+
rec.onresult = (e: any) => {
|
|
633
|
+
let interim = '';
|
|
634
|
+
let finalText = '';
|
|
635
|
+
for (let i = 0; i < e.results.length; i++) {
|
|
636
|
+
const t = e.results[i][0]?.transcript ?? '';
|
|
637
|
+
if (e.results[i].isFinal) finalText += t;
|
|
638
|
+
else interim += t;
|
|
639
|
+
}
|
|
640
|
+
best = (finalText || interim).trim();
|
|
641
|
+
if (opts.partial && interim) this.emit('speech.partial', { transcript: interim.trim() });
|
|
642
|
+
};
|
|
643
|
+
rec.onerror = (e: any) => {
|
|
644
|
+
if (settled) return;
|
|
645
|
+
settled = true;
|
|
646
|
+
if (this.listenStop === stop) this.listenStop = null;
|
|
647
|
+
reject(new KitError(e?.error === 'not-allowed' ? 'DENIED' : 'NATIVE_ERROR', e?.error ?? 'recognition failed'));
|
|
648
|
+
};
|
|
649
|
+
rec.onend = finish; // fires after stop() or natural end → resolve with best-so-far
|
|
650
|
+
rec.start();
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
450
654
|
private pickImageFile(
|
|
451
655
|
capture: boolean,
|
|
452
656
|
wantDataUrl = false,
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,9 @@ export type { ActionOptions, AlertOptions, ConfirmOptions, SafeAreaInsets } from
|
|
|
35
35
|
export type { MotionSample } from './modules/motion';
|
|
36
36
|
export type { UpdateStatus, UpdatesOptions } from './modules/updates';
|
|
37
37
|
export type { PickedContact } from './modules/contacts';
|
|
38
|
+
export type { ScanFormat, ScanOptions, ScanResult, ScanCancelled } from './modules/scanner';
|
|
39
|
+
export { isScanResult } from './modules/scanner';
|
|
40
|
+
export type { SpeechVoice, SpeakOptions, ListenOptions, SpeechPartial } from './modules/speech';
|
|
38
41
|
export type { CalendarEventOptions } from './modules/calendar';
|
|
39
42
|
export type { BrowserOptions } from './modules/browser';
|
|
40
43
|
export type { OAuthAuthorizeParams, OAuthResult } from './modules/oauth';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
/** Symbologies the scanner can decode. `'all'` (default) accepts every supported format. */
|
|
4
|
+
export type ScanFormat = 'qr' | 'ean13' | 'code128' | 'all';
|
|
5
|
+
|
|
6
|
+
/** A successfully decoded code. */
|
|
7
|
+
export interface ScanResult {
|
|
8
|
+
/** Decoded payload (the string the barcode/QR encodes). */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Detected symbology — one of {@link ScanFormat} minus `'all'`, or `'unknown'`. */
|
|
11
|
+
format: ScanFormat | 'unknown';
|
|
12
|
+
/** Bounding box of the code in the camera preview (CSS px), when the platform reports it. */
|
|
13
|
+
bounds?: { x: number; y: number; width: number; height: number };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Scan resolved without a code because the user dismissed the scanner. */
|
|
17
|
+
export interface ScanCancelled {
|
|
18
|
+
cancelled: true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScanOptions {
|
|
22
|
+
/** Symbology filter — a single format or a list. Omit / `'all'` = any supported code. */
|
|
23
|
+
formats?: ScanFormat | ScanFormat[];
|
|
24
|
+
/** Which camera to open. Default `'back'`. */
|
|
25
|
+
camera?: 'back' | 'front';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True when a {@link ScanResult.value} is present (not the cancelled shape). */
|
|
29
|
+
export function isScanResult(r: ScanResult | ScanCancelled): r is ScanResult {
|
|
30
|
+
return (r as ScanCancelled).cancelled !== true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Camera barcode / QR decoding — ONE API across platforms.
|
|
35
|
+
*
|
|
36
|
+
* {@link scan} opens a native full-screen capture UI, returns the FIRST decoded code, then
|
|
37
|
+
* auto-dismisses. If the user dismisses it first, it resolves to `{ cancelled: true }` (NOT a
|
|
38
|
+
* rejection) — branch with {@link isScanResult}. {@link cancel} dismisses an in-progress scan
|
|
39
|
+
* programmatically, which makes that same pending {@link scan} resolve `{ cancelled: true }`.
|
|
40
|
+
*
|
|
41
|
+
* Native (iOS `AVCaptureMetadataOutput`, Android ZXing capture Activity) reports
|
|
42
|
+
* `capability === 'native'`. Web maps to the `BarcodeDetector` API where present (Chrome/Android
|
|
43
|
+
* → `'web'`); where absent (Safari/Firefox today) it's `'none'` and {@link scan} throws
|
|
44
|
+
* `KitError('UNSUPPORTED')` — no heavy JS decoder fallback. Branch on {@link capability}.
|
|
45
|
+
*/
|
|
46
|
+
export class ScannerModule {
|
|
47
|
+
constructor(private kit: NativeKit) {}
|
|
48
|
+
|
|
49
|
+
/** 'native' on a shell · 'web' where BarcodeDetector exists · else 'none'. */
|
|
50
|
+
get capability() {
|
|
51
|
+
return this.kit.capability('scanner');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Open the scanner; resolve the first code, or `{ cancelled: true }` if dismissed. */
|
|
55
|
+
scan(opts: ScanOptions = {}): Promise<ScanResult | ScanCancelled> {
|
|
56
|
+
return this.kit.invoke('scanner.scan', opts);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Dismiss an in-progress {@link scan} (its promise then resolves `{ cancelled: true }`). */
|
|
60
|
+
cancel(): Promise<void> {
|
|
61
|
+
return this.kit.invoke('scanner.cancel');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
import type { Unsubscribe } from '../core/types';
|
|
3
|
+
|
|
4
|
+
/** An installed synthesizer voice, as offered by {@link SpeechModule.voices}. */
|
|
5
|
+
export interface SpeechVoice {
|
|
6
|
+
/** Platform voice identifier — pass back as {@link SpeakOptions.voice} to select it. */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Human-readable name (e.g. 'Samantha'). */
|
|
9
|
+
name: string;
|
|
10
|
+
/** BCP-47 language tag the voice speaks (e.g. 'en-US'). */
|
|
11
|
+
lang: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SpeakOptions {
|
|
15
|
+
/** BCP-47 language for the utterance (e.g. 'en-US'). Defaults to the system/voice language. */
|
|
16
|
+
lang?: string;
|
|
17
|
+
/** Speaking rate. ~0.5 slow … 1 normal … 2 fast (platforms clamp to their own range). */
|
|
18
|
+
rate?: number;
|
|
19
|
+
/** Voice pitch. ~0.5 low … 1 normal … 2 high. */
|
|
20
|
+
pitch?: number;
|
|
21
|
+
/** A {@link SpeechVoice.id} from {@link SpeechModule.voices} to speak with. */
|
|
22
|
+
voice?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ListenOptions {
|
|
26
|
+
/** BCP-47 language to recognize (e.g. 'en-US'). Defaults to the device locale. */
|
|
27
|
+
lang?: string;
|
|
28
|
+
/** Stream interim results via {@link SpeechModule.onPartial} while listening. Default false. */
|
|
29
|
+
partial?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Payload of {@link SpeechModule.onPartial}: the best-so-far interim transcript. */
|
|
33
|
+
export interface SpeechPartial {
|
|
34
|
+
/** Interim transcript text (not final — may change as recognition continues). */
|
|
35
|
+
transcript: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Voice I/O — text-to-speech (TTS) and speech-to-text (STT) — ONE API across platforms.
|
|
40
|
+
*
|
|
41
|
+
* TTS is permission-free: {@link speak} reads text aloud and resolves when the utterance finishes;
|
|
42
|
+
* {@link stop} cancels current/queued speech; {@link voices} lists installed synthesizer voices.
|
|
43
|
+
*
|
|
44
|
+
* STT carries microphone + speech-recognition permissions (the opt-in `speech` module stamps them).
|
|
45
|
+
* {@link listen} starts capture and resolves the FINAL transcript string; pass `{ partial: true }` to
|
|
46
|
+
* also stream interim results via {@link onPartial}. {@link stopListening} ends capture early and
|
|
47
|
+
* resolves the pending {@link listen} with the best transcript so far.
|
|
48
|
+
*
|
|
49
|
+
* Two HONEST capability flags — synthesis availability ≠ recognition availability:
|
|
50
|
+
* - {@link capability} (TTS): native iOS `AVSpeechSynthesizer` / Android `TextToSpeech`; web `'web'`
|
|
51
|
+
* when `speechSynthesis` exists, else `'none'`.
|
|
52
|
+
* - {@link recognitionCapability} (STT): native iOS `SFSpeechRecognizer` / Android `SpeechRecognizer`;
|
|
53
|
+
* web `'web'` when `SpeechRecognition`/`webkitSpeechRecognition` exists (Chrome) else `'none'` —
|
|
54
|
+
* then {@link listen} throws `KitError('UNSUPPORTED')`. Branch on the flag, not try/catch.
|
|
55
|
+
*/
|
|
56
|
+
export class SpeechModule {
|
|
57
|
+
constructor(private kit: NativeKit) {}
|
|
58
|
+
|
|
59
|
+
/** TTS availability: 'native' on a shell · 'web' where speechSynthesis exists · else 'none'. */
|
|
60
|
+
get capability() {
|
|
61
|
+
return this.kit.capability('speech');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** STT availability: 'native' on a shell · 'web' where SpeechRecognition exists · else 'none'. */
|
|
65
|
+
get recognitionCapability() {
|
|
66
|
+
return this.kit.capability('speechRecognition');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Speak `text` aloud; resolves when the utterance finishes (or is stopped). */
|
|
70
|
+
speak(text: string, opts: SpeakOptions = {}): Promise<void> {
|
|
71
|
+
return this.kit.invoke('speech.speak', { text, ...opts });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Cancel the current/queued utterance immediately. */
|
|
75
|
+
stop(): Promise<void> {
|
|
76
|
+
return this.kit.invoke('speech.stop');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** List installed synthesizer voices. */
|
|
80
|
+
voices(): Promise<SpeechVoice[]> {
|
|
81
|
+
return this.kit.invoke('speech.voices');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Start capturing the mic; resolves the FINAL transcript. With `{ partial:true }`, interim
|
|
85
|
+
* results stream via {@link onPartial}. Throws `KitError('UNSUPPORTED')` where STT is absent. */
|
|
86
|
+
listen(opts: ListenOptions = {}): Promise<string> {
|
|
87
|
+
return this.kit.invoke('speech.listen', opts);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Stop capture early; the pending {@link listen} resolves with the best transcript so far. */
|
|
91
|
+
stopListening(): Promise<void> {
|
|
92
|
+
return this.kit.invoke('speech.stopListening');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Interim transcripts while listening (only when {@link listen} was called with `partial:true`). */
|
|
96
|
+
onPartial(cb: (p: SpeechPartial) => void): Unsubscribe {
|
|
97
|
+
return this.kit.on('speech.partial', (p) => cb(p as SpeechPartial));
|
|
98
|
+
}
|
|
99
|
+
}
|