@djangocfg/ui-tools 2.1.381 → 2.1.382
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/README.md +132 -899
- package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
- package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
- package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
- package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
- package/dist/DictationField-2ZLQWLYV.mjs +4 -0
- package/dist/DictationField-2ZLQWLYV.mjs.map +1 -0
- package/dist/DictationField-IPPJ54CU.cjs +13 -0
- package/dist/DictationField-IPPJ54CU.cjs.map +1 -0
- package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
- package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
- package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
- package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
- package/dist/chunk-4LXG3NBV.mjs +833 -0
- package/dist/chunk-4LXG3NBV.mjs.map +1 -0
- package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
- package/dist/chunk-FIRK5CEH.cjs.map +1 -0
- package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
- package/dist/chunk-HIK6BPL7.mjs.map +1 -0
- package/dist/chunk-KMSBGNVC.cjs +835 -0
- package/dist/chunk-KMSBGNVC.cjs.map +1 -0
- package/dist/chunk-OZAU3QWD.cjs +2493 -0
- package/dist/chunk-OZAU3QWD.cjs.map +1 -0
- package/dist/chunk-UWVP6LCW.mjs +2447 -0
- package/dist/chunk-UWVP6LCW.mjs.map +1 -0
- package/dist/index.cjs +1532 -100
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1148 -107
- package/dist/index.d.ts +1148 -107
- package/dist/index.mjs +1421 -51
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -8
- package/src/audio-assets.d.ts +8 -0
- package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
- package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
- package/src/stories/index.ts +32 -2
- package/src/tools/Chat/README.md +347 -530
- package/src/tools/Chat/components/Attachments.tsx +6 -1
- package/src/tools/Chat/components/ChatRoot.tsx +30 -2
- package/src/tools/Chat/components/Composer.tsx +20 -3
- package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
- package/src/tools/Chat/components/MessageActions.tsx +3 -1
- package/src/tools/Chat/components/MessageBubble.tsx +6 -5
- package/src/tools/Chat/components/MessageList.tsx +87 -1
- package/src/tools/Chat/components/ToolCalls.tsx +21 -3
- package/src/tools/Chat/context/ChatProvider.tsx +21 -3
- package/src/tools/Chat/core/audio/audioBus.ts +10 -163
- package/src/tools/Chat/core/audio/defaults.ts +43 -0
- package/src/tools/Chat/core/audio/index.ts +1 -0
- package/src/tools/Chat/core/audio/preferences.ts +5 -59
- package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
- package/src/tools/Chat/core/audio/types.ts +28 -0
- package/src/tools/Chat/core/reducer.ts +33 -0
- package/src/tools/Chat/core/transport/index.ts +13 -0
- package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
- package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
- package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
- package/src/tools/Chat/core/transport/sse.ts +18 -5
- package/src/tools/Chat/hooks/index.ts +25 -0
- package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
- package/src/tools/Chat/hooks/useChat.ts +28 -0
- package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
- package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
- package/src/tools/Chat/hooks/useChatReset.ts +70 -0
- package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
- package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
- package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
- package/src/tools/Chat/index.ts +69 -1
- package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
- package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
- package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
- package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
- package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
- package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
- package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
- package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
- package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
- package/src/tools/Chat/launcher/index.ts +46 -0
- package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
- package/src/tools/Chat/stories/01-basic.story.tsx +64 -0
- package/src/tools/Chat/stories/02-bubbles.story.tsx +21 -0
- package/src/tools/Chat/stories/03-tool-calls.story.tsx +59 -0
- package/src/tools/Chat/stories/04-personas.story.tsx +78 -0
- package/src/tools/Chat/stories/05-launcher.story.tsx +321 -0
- package/src/tools/Chat/stories/06-header.story.tsx +147 -0
- package/src/tools/Chat/stories/07-audio-actions.story.tsx +112 -0
- package/src/tools/Chat/stories/shared/Frame.tsx +21 -0
- package/src/tools/Chat/stories/shared/index.ts +5 -0
- package/src/tools/Chat/stories/shared/messages.ts +39 -0
- package/src/tools/Chat/stories/shared/personas.ts +13 -0
- package/src/tools/Chat/stories/shared/seeds.ts +92 -0
- package/src/tools/Chat/stories/shared/transports.ts +36 -0
- package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
- package/src/tools/Chat/styles/index.ts +16 -0
- package/src/tools/Chat/styles/useChatStyles.ts +101 -0
- package/src/tools/Chat/types/attachment.ts +25 -0
- package/src/tools/Chat/types/config.ts +48 -0
- package/src/tools/Chat/types/events.ts +35 -0
- package/src/tools/Chat/types/index.ts +34 -0
- package/src/tools/Chat/types/labels.ts +38 -0
- package/src/tools/Chat/types/message.ts +32 -0
- package/src/tools/Chat/types/persona.ts +31 -0
- package/src/tools/Chat/types/session.ts +43 -0
- package/src/tools/Chat/types/tool-call.ts +17 -0
- package/src/tools/Chat/types/transport.ts +28 -0
- package/src/tools/Chat/types.ts +5 -240
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
- package/src/tools/MarkdownEditor/index.ts +1 -1
- package/src/tools/SpeechRecognition/README.md +336 -0
- package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
- package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
- package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
- package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
- package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
- package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
- package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
- package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
- package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
- package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
- package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
- package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
- package/src/tools/SpeechRecognition/components/index.ts +16 -0
- package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
- package/src/tools/SpeechRecognition/context/index.ts +6 -0
- package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
- package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
- package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
- package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
- package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
- package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
- package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
- package/src/tools/SpeechRecognition/core/ids.ts +11 -0
- package/src/tools/SpeechRecognition/core/index.ts +14 -0
- package/src/tools/SpeechRecognition/core/language.ts +78 -0
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
- package/src/tools/SpeechRecognition/core/logger.ts +3 -0
- package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
- package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
- package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
- package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
- package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
- package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
- package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
- package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
- package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
- package/src/tools/SpeechRecognition/index.ts +82 -0
- package/src/tools/SpeechRecognition/lazy.tsx +19 -0
- package/src/tools/SpeechRecognition/store/index.ts +2 -0
- package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
- package/src/tools/SpeechRecognition/stories/01-basic.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/02-dictation-field.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/03-push-to-talk.story.tsx +27 -0
- package/src/tools/SpeechRecognition/stories/04-mic-meter.story.tsx +35 -0
- package/src/tools/SpeechRecognition/stories/05-custom-engine-http.story.tsx +40 -0
- package/src/tools/SpeechRecognition/stories/06-custom-engine-ws.story.tsx +48 -0
- package/src/tools/SpeechRecognition/stories/07-language-device.story.tsx +57 -0
- package/src/tools/SpeechRecognition/stories/08-errors-permissions.story.tsx +25 -0
- package/src/tools/SpeechRecognition/stories/09-chat-voice.story.tsx +90 -0
- package/src/tools/SpeechRecognition/stories/shared.tsx +123 -0
- package/src/tools/SpeechRecognition/types.ts +133 -0
- package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
- package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
- package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
- package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
- package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
- package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
- package/dist/chunk-NWUT327A.mjs.map +0 -1
- package/dist/chunk-QLMKCSR6.mjs +0 -2420
- package/dist/chunk-QLMKCSR6.mjs.map +0 -1
- package/dist/chunk-SI5RD2GD.cjs +0 -2460
- package/dist/chunk-SI5RD2GD.cjs.map +0 -1
- package/dist/chunk-XACCHZH2.cjs.map +0 -1
- package/src/tools/Chat/Chat.story.tsx +0 -1457
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
let counter = 0;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cheap monotonic id — collisions are fine across sessions, we just need
|
|
5
|
+
* uniqueness within one component lifecycle. Avoids pulling in nanoid for
|
|
6
|
+
* a tool that already keeps the lazy chunk small.
|
|
7
|
+
*/
|
|
8
|
+
export function newSegmentId(): string {
|
|
9
|
+
counter = (counter + 1) % Number.MAX_SAFE_INTEGER;
|
|
10
|
+
return `seg_${Date.now().toString(36)}_${counter.toString(36)}`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { newSegmentId } from './ids';
|
|
2
|
+
export { sttLogger } from './logger';
|
|
3
|
+
export {
|
|
4
|
+
EMPTY_TRANSCRIPT,
|
|
5
|
+
buildTranscript,
|
|
6
|
+
joinFinal,
|
|
7
|
+
normaliseFinal,
|
|
8
|
+
} from './transcript';
|
|
9
|
+
export {
|
|
10
|
+
INITIAL_STATE,
|
|
11
|
+
reducer,
|
|
12
|
+
type RecognitionAction,
|
|
13
|
+
type RecognitionState,
|
|
14
|
+
} from './reducer';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps 2-letter ISO 639-1 codes (`en`, `ru`, `ko` — what
|
|
3
|
+
* `@djangocfg/i18n` exposes via `useLocale()`) to BCP-47 tags
|
|
4
|
+
* (`en-US`, `ru-RU`, `ko-KR`) that the Web Speech API and most cloud
|
|
5
|
+
* STT services expect.
|
|
6
|
+
*
|
|
7
|
+
* We keep a small built-in table for the locales we ship translations
|
|
8
|
+
* for; everything else falls through to `<code>-<UPPER(code)>`, which
|
|
9
|
+
* works for the majority of regions. The mapping is also re-exported
|
|
10
|
+
* so consumers can extend it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const ISO_TO_BCP47: Record<string, string> = {
|
|
14
|
+
en: 'en-US',
|
|
15
|
+
ru: 'ru-RU',
|
|
16
|
+
ko: 'ko-KR',
|
|
17
|
+
ja: 'ja-JP',
|
|
18
|
+
zh: 'zh-CN',
|
|
19
|
+
de: 'de-DE',
|
|
20
|
+
fr: 'fr-FR',
|
|
21
|
+
it: 'it-IT',
|
|
22
|
+
es: 'es-ES',
|
|
23
|
+
nl: 'nl-NL',
|
|
24
|
+
ar: 'ar-SA',
|
|
25
|
+
tr: 'tr-TR',
|
|
26
|
+
pl: 'pl-PL',
|
|
27
|
+
sv: 'sv-SE',
|
|
28
|
+
no: 'nb-NO',
|
|
29
|
+
da: 'da-DK',
|
|
30
|
+
pt: 'pt-BR',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_ISO_TO_BCP47 = ISO_TO_BCP47;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalise any of:
|
|
37
|
+
* - BCP-47 ("en-US", "ru-RU") — passed through.
|
|
38
|
+
* - ISO 639-1 ("en", "ru") — mapped via the table above, or
|
|
39
|
+
* falls back to `<code>-<UPPER(code)>`.
|
|
40
|
+
* - `null`/`undefined`/empty — returns `undefined`.
|
|
41
|
+
*/
|
|
42
|
+
export function toBCP47(
|
|
43
|
+
code: string | null | undefined,
|
|
44
|
+
table: Record<string, string> = ISO_TO_BCP47,
|
|
45
|
+
): string | undefined {
|
|
46
|
+
if (!code) return undefined;
|
|
47
|
+
const trimmed = code.trim();
|
|
48
|
+
if (!trimmed) return undefined;
|
|
49
|
+
if (trimmed.includes('-')) return trimmed; // already BCP-47
|
|
50
|
+
const lower = trimmed.toLowerCase();
|
|
51
|
+
return table[lower] ?? `${lower}-${lower.toUpperCase()}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the language tag for a speech session in priority order:
|
|
56
|
+
* 1. `explicit` prop (always wins) — host-supplied override.
|
|
57
|
+
* 2. `prefs` — value stored in `useSpeechPrefs` (user picked it
|
|
58
|
+
* via `<LanguagePicker>` or programmatically).
|
|
59
|
+
* 3. `i18n` — current i18n locale (2-letter ISO).
|
|
60
|
+
* 4. `navigator.language` — browser default.
|
|
61
|
+
* 5. `'en-US'` — last-resort safety net.
|
|
62
|
+
*
|
|
63
|
+
* All inputs may be ISO-2 or BCP-47; the function normalises before
|
|
64
|
+
* returning.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveSpeechLanguage(opts: {
|
|
67
|
+
explicit?: string;
|
|
68
|
+
prefs?: string | null;
|
|
69
|
+
i18n?: string | null;
|
|
70
|
+
}): string {
|
|
71
|
+
return (
|
|
72
|
+
toBCP47(opts.explicit) ??
|
|
73
|
+
toBCP47(opts.prefs) ??
|
|
74
|
+
toBCP47(opts.i18n) ??
|
|
75
|
+
toBCP47(typeof navigator !== 'undefined' ? navigator.language : null) ??
|
|
76
|
+
'en-US'
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical list of BCP-47 language tags that the browser Web Speech
|
|
3
|
+
* API is known to accept. Sourced from the official Google Chrome
|
|
4
|
+
* Speech API demo (`google.com/intl/en/chrome/demos/speech.html`),
|
|
5
|
+
* which is the de-facto reference — the spec itself doesn't expose a
|
|
6
|
+
* way to enumerate supported languages, so this list is the
|
|
7
|
+
* best-effort guarantee for what works in Chromium-based browsers.
|
|
8
|
+
*
|
|
9
|
+
* Each entry groups one human-readable language with its dialect
|
|
10
|
+
* variants. The default tag (first in `dialects`) is what `lang` is
|
|
11
|
+
* set to when the user picks the language without a regional dialect.
|
|
12
|
+
*
|
|
13
|
+
* For custom engines (cmdop wails-whisper, Deepgram, …) hosts can
|
|
14
|
+
* pass their own subset via the `availableLanguages` prop on the
|
|
15
|
+
* picker — backend may support more or fewer tags than the browser.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface SpeechLanguageDialect {
|
|
19
|
+
/** BCP-47 tag (e.g. `en-US`). */
|
|
20
|
+
code: string;
|
|
21
|
+
/** Region label in the language's native script (e.g. "United States"). */
|
|
22
|
+
region: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SpeechLanguage {
|
|
26
|
+
/** Native-script name (e.g. "Русский", "中文"). */
|
|
27
|
+
name: string;
|
|
28
|
+
/**
|
|
29
|
+
* English name used as a secondary search key so users typing
|
|
30
|
+
* "russian" / "chinese" / "korean" land on the right row regardless
|
|
31
|
+
* of the native script. Always lowercase.
|
|
32
|
+
*/
|
|
33
|
+
englishName: string;
|
|
34
|
+
/**
|
|
35
|
+
* Primary-subtag ISO-639 code (e.g. `en`, `ru`, `cmn`). Used as the
|
|
36
|
+
* map key into the `LanguageSelect` ui-core component.
|
|
37
|
+
*/
|
|
38
|
+
iso: string;
|
|
39
|
+
/** One or more region dialects. Length >= 1. */
|
|
40
|
+
dialects: SpeechLanguageDialect[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const WEB_SPEECH_LANGUAGES: SpeechLanguage[] = [
|
|
44
|
+
{ name: 'Afrikaans', iso: 'af', englishName: 'afrikaans', dialects: [{ code: 'af-ZA', region: 'South Africa' }] },
|
|
45
|
+
{ name: 'አማርኛ', iso: 'am', englishName: 'amharic', dialects: [{ code: 'am-ET', region: 'Ethiopia' }] },
|
|
46
|
+
{ name: 'Azərbaycanca', iso: 'az', englishName: 'azerbaijani', dialects: [{ code: 'az-AZ', region: 'Azerbaijan' }] },
|
|
47
|
+
{
|
|
48
|
+
name: 'বাংলা', iso: 'bn', englishName: 'bengali',
|
|
49
|
+
dialects: [
|
|
50
|
+
{ code: 'bn-BD', region: 'Bangladesh' },
|
|
51
|
+
{ code: 'bn-IN', region: 'India' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{ name: 'Bahasa Indonesia', iso: 'id', englishName: 'indonesian', dialects: [{ code: 'id-ID', region: 'Indonesia' }] },
|
|
55
|
+
{ name: 'Bahasa Melayu', iso: 'ms', englishName: 'malay', dialects: [{ code: 'ms-MY', region: 'Malaysia' }] },
|
|
56
|
+
{ name: 'Català', iso: 'ca', englishName: 'catalan', dialects: [{ code: 'ca-ES', region: 'Spain' }] },
|
|
57
|
+
{ name: 'Čeština', iso: 'cs', englishName: 'czech', dialects: [{ code: 'cs-CZ', region: 'Czechia' }] },
|
|
58
|
+
{ name: 'Dansk', iso: 'da', englishName: 'danish', dialects: [{ code: 'da-DK', region: 'Denmark' }] },
|
|
59
|
+
{ name: 'Deutsch', iso: 'de', englishName: 'german', dialects: [{ code: 'de-DE', region: 'Germany' }] },
|
|
60
|
+
{
|
|
61
|
+
name: 'English', iso: 'en', englishName: 'english',
|
|
62
|
+
dialects: [
|
|
63
|
+
{ code: 'en-US', region: 'United States' },
|
|
64
|
+
{ code: 'en-GB', region: 'United Kingdom' },
|
|
65
|
+
{ code: 'en-AU', region: 'Australia' },
|
|
66
|
+
{ code: 'en-CA', region: 'Canada' },
|
|
67
|
+
{ code: 'en-IN', region: 'India' },
|
|
68
|
+
{ code: 'en-NZ', region: 'New Zealand' },
|
|
69
|
+
{ code: 'en-PH', region: 'Philippines' },
|
|
70
|
+
{ code: 'en-ZA', region: 'South Africa' },
|
|
71
|
+
{ code: 'en-NG', region: 'Nigeria' },
|
|
72
|
+
{ code: 'en-GH', region: 'Ghana' },
|
|
73
|
+
{ code: 'en-KE', region: 'Kenya' },
|
|
74
|
+
{ code: 'en-TZ', region: 'Tanzania' },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'Español', iso: 'es', englishName: 'spanish',
|
|
79
|
+
dialects: [
|
|
80
|
+
{ code: 'es-ES', region: 'España' },
|
|
81
|
+
{ code: 'es-MX', region: 'México' },
|
|
82
|
+
{ code: 'es-US', region: 'Estados Unidos' },
|
|
83
|
+
{ code: 'es-AR', region: 'Argentina' },
|
|
84
|
+
{ code: 'es-CL', region: 'Chile' },
|
|
85
|
+
{ code: 'es-CO', region: 'Colombia' },
|
|
86
|
+
{ code: 'es-PE', region: 'Perú' },
|
|
87
|
+
{ code: 'es-VE', region: 'Venezuela' },
|
|
88
|
+
{ code: 'es-EC', region: 'Ecuador' },
|
|
89
|
+
{ code: 'es-GT', region: 'Guatemala' },
|
|
90
|
+
{ code: 'es-CR', region: 'Costa Rica' },
|
|
91
|
+
{ code: 'es-PA', region: 'Panamá' },
|
|
92
|
+
{ code: 'es-DO', region: 'Rep. Dominicana' },
|
|
93
|
+
{ code: 'es-UY', region: 'Uruguay' },
|
|
94
|
+
{ code: 'es-PY', region: 'Paraguay' },
|
|
95
|
+
{ code: 'es-BO', region: 'Bolivia' },
|
|
96
|
+
{ code: 'es-SV', region: 'El Salvador' },
|
|
97
|
+
{ code: 'es-HN', region: 'Honduras' },
|
|
98
|
+
{ code: 'es-NI', region: 'Nicaragua' },
|
|
99
|
+
{ code: 'es-PR', region: 'Puerto Rico' },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
{ name: 'Euskara', iso: 'eu', englishName: 'basque', dialects: [{ code: 'eu-ES', region: 'Spain' }] },
|
|
103
|
+
{ name: 'Filipino', iso: 'fil', englishName: 'filipino tagalog', dialects: [{ code: 'fil-PH', region: 'Philippines' }] },
|
|
104
|
+
{ name: 'Français', iso: 'fr', englishName: 'french', dialects: [{ code: 'fr-FR', region: 'France' }] },
|
|
105
|
+
{ name: 'Basa Jawa', iso: 'jv', englishName: 'javanese', dialects: [{ code: 'jv-ID', region: 'Indonesia' }] },
|
|
106
|
+
{ name: 'Galego', iso: 'gl', englishName: 'galician', dialects: [{ code: 'gl-ES', region: 'Spain' }] },
|
|
107
|
+
{ name: 'ગુજરાતી', iso: 'gu', englishName: 'gujarati', dialects: [{ code: 'gu-IN', region: 'India' }] },
|
|
108
|
+
{ name: 'Hrvatski', iso: 'hr', englishName: 'croatian', dialects: [{ code: 'hr-HR', region: 'Croatia' }] },
|
|
109
|
+
{ name: 'IsiZulu', iso: 'zu', englishName: 'zulu', dialects: [{ code: 'zu-ZA', region: 'South Africa' }] },
|
|
110
|
+
{ name: 'Íslenska', iso: 'is', englishName: 'icelandic', dialects: [{ code: 'is-IS', region: 'Iceland' }] },
|
|
111
|
+
{
|
|
112
|
+
name: 'Italiano', iso: 'it', englishName: 'italian',
|
|
113
|
+
dialects: [
|
|
114
|
+
{ code: 'it-IT', region: 'Italia' },
|
|
115
|
+
{ code: 'it-CH', region: 'Svizzera' },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
{ name: 'ಕನ್ನಡ', iso: 'kn', englishName: 'kannada', dialects: [{ code: 'kn-IN', region: 'India' }] },
|
|
119
|
+
{ name: 'ភាសាខ្មែរ', iso: 'km', englishName: 'khmer cambodian', dialects: [{ code: 'km-KH', region: 'Cambodia' }] },
|
|
120
|
+
{ name: 'Latviešu', iso: 'lv', englishName: 'latvian', dialects: [{ code: 'lv-LV', region: 'Latvia' }] },
|
|
121
|
+
{ name: 'Lietuvių', iso: 'lt', englishName: 'lithuanian', dialects: [{ code: 'lt-LT', region: 'Lithuania' }] },
|
|
122
|
+
{ name: 'മലയാളം', iso: 'ml', englishName: 'malayalam', dialects: [{ code: 'ml-IN', region: 'India' }] },
|
|
123
|
+
{ name: 'मराठी', iso: 'mr', englishName: 'marathi', dialects: [{ code: 'mr-IN', region: 'India' }] },
|
|
124
|
+
{ name: 'Magyar', iso: 'hu', englishName: 'hungarian', dialects: [{ code: 'hu-HU', region: 'Hungary' }] },
|
|
125
|
+
{ name: 'ລາວ', iso: 'lo', englishName: 'lao laotian', dialects: [{ code: 'lo-LA', region: 'Laos' }] },
|
|
126
|
+
{ name: 'Nederlands', iso: 'nl', englishName: 'dutch', dialects: [{ code: 'nl-NL', region: 'Netherlands' }] },
|
|
127
|
+
{ name: 'नेपाली भाषा', iso: 'ne', englishName: 'nepali', dialects: [{ code: 'ne-NP', region: 'Nepal' }] },
|
|
128
|
+
{ name: 'Norsk bokmål', iso: 'nb', englishName: 'norwegian bokmal', dialects: [{ code: 'nb-NO', region: 'Norway' }] },
|
|
129
|
+
{ name: 'Polski', iso: 'pl', englishName: 'polish', dialects: [{ code: 'pl-PL', region: 'Poland' }] },
|
|
130
|
+
{
|
|
131
|
+
name: 'Português', iso: 'pt', englishName: 'portuguese',
|
|
132
|
+
dialects: [
|
|
133
|
+
{ code: 'pt-BR', region: 'Brasil' },
|
|
134
|
+
{ code: 'pt-PT', region: 'Portugal' },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{ name: 'Română', iso: 'ro', englishName: 'romanian', dialects: [{ code: 'ro-RO', region: 'Romania' }] },
|
|
138
|
+
{ name: 'සිංහල', iso: 'si', englishName: 'sinhala sinhalese', dialects: [{ code: 'si-LK', region: 'Sri Lanka' }] },
|
|
139
|
+
{ name: 'Slovenščina', iso: 'sl', englishName: 'slovenian', dialects: [{ code: 'sl-SI', region: 'Slovenia' }] },
|
|
140
|
+
{ name: 'Basa Sunda', iso: 'su', englishName: 'sundanese', dialects: [{ code: 'su-ID', region: 'Indonesia' }] },
|
|
141
|
+
{ name: 'Slovenčina', iso: 'sk', englishName: 'slovak', dialects: [{ code: 'sk-SK', region: 'Slovakia' }] },
|
|
142
|
+
{ name: 'Suomi', iso: 'fi', englishName: 'finnish', dialects: [{ code: 'fi-FI', region: 'Finland' }] },
|
|
143
|
+
{ name: 'Svenska', iso: 'sv', englishName: 'swedish', dialects: [{ code: 'sv-SE', region: 'Sweden' }] },
|
|
144
|
+
{
|
|
145
|
+
name: 'Kiswahili', iso: 'sw', englishName: 'swahili',
|
|
146
|
+
dialects: [
|
|
147
|
+
{ code: 'sw-TZ', region: 'Tanzania' },
|
|
148
|
+
{ code: 'sw-KE', region: 'Kenya' },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{ name: 'ქართული', iso: 'ka', englishName: 'georgian', dialects: [{ code: 'ka-GE', region: 'Georgia' }] },
|
|
152
|
+
{ name: 'Հայերեն', iso: 'hy', englishName: 'armenian', dialects: [{ code: 'hy-AM', region: 'Armenia' }] },
|
|
153
|
+
{
|
|
154
|
+
name: 'தமிழ்', iso: 'ta', englishName: 'tamil',
|
|
155
|
+
dialects: [
|
|
156
|
+
{ code: 'ta-IN', region: 'இந்தியா' },
|
|
157
|
+
{ code: 'ta-SG', region: 'சிங்கப்பூர்' },
|
|
158
|
+
{ code: 'ta-LK', region: 'இலங்கை' },
|
|
159
|
+
{ code: 'ta-MY', region: 'மலேசியா' },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
{ name: 'తెలుగు', iso: 'te', englishName: 'telugu', dialects: [{ code: 'te-IN', region: 'India' }] },
|
|
163
|
+
{ name: 'Tiếng Việt', iso: 'vi', englishName: 'vietnamese', dialects: [{ code: 'vi-VN', region: 'Vietnam' }] },
|
|
164
|
+
{ name: 'Türkçe', iso: 'tr', englishName: 'turkish', dialects: [{ code: 'tr-TR', region: 'Türkiye' }] },
|
|
165
|
+
{
|
|
166
|
+
name: 'اُردُو', iso: 'ur', englishName: 'urdu',
|
|
167
|
+
dialects: [
|
|
168
|
+
{ code: 'ur-PK', region: 'پاکستان' },
|
|
169
|
+
{ code: 'ur-IN', region: 'بھارت' },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
{ name: 'Ελληνικά', iso: 'el', englishName: 'greek', dialects: [{ code: 'el-GR', region: 'Greece' }] },
|
|
173
|
+
{ name: 'български', iso: 'bg', englishName: 'bulgarian', dialects: [{ code: 'bg-BG', region: 'Bulgaria' }] },
|
|
174
|
+
{ name: 'Русский', iso: 'ru', englishName: 'russian', dialects: [{ code: 'ru-RU', region: 'Russia' }] },
|
|
175
|
+
{ name: 'Српски', iso: 'sr', englishName: 'serbian', dialects: [{ code: 'sr-RS', region: 'Serbia' }] },
|
|
176
|
+
{ name: 'Українська', iso: 'uk', englishName: 'ukrainian', dialects: [{ code: 'uk-UA', region: 'Ukraine' }] },
|
|
177
|
+
{ name: '한국어', iso: 'ko', englishName: 'korean', dialects: [{ code: 'ko-KR', region: 'Korea' }] },
|
|
178
|
+
{
|
|
179
|
+
name: '中文', iso: 'cmn', englishName: 'chinese mandarin cantonese',
|
|
180
|
+
dialects: [
|
|
181
|
+
{ code: 'cmn-Hans-CN', region: '普通话 (中国大陆)' },
|
|
182
|
+
{ code: 'cmn-Hans-HK', region: '普通话 (香港)' },
|
|
183
|
+
{ code: 'cmn-Hant-TW', region: '中文 (台灣)' },
|
|
184
|
+
{ code: 'yue-Hant-HK', region: '粵語 (香港)' },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
{ name: '日本語', iso: 'ja', englishName: 'japanese', dialects: [{ code: 'ja-JP', region: 'Japan' }] },
|
|
188
|
+
{ name: 'हिन्दी', iso: 'hi', englishName: 'hindi', dialects: [{ code: 'hi-IN', region: 'India' }] },
|
|
189
|
+
{ name: 'ภาษาไทย', iso: 'th', englishName: 'thai', dialects: [{ code: 'th-TH', region: 'Thailand' }] },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
/** Flat list of every supported BCP-47 tag, useful for validation. */
|
|
193
|
+
export const WEB_SPEECH_TAGS: string[] = WEB_SPEECH_LANGUAGES.flatMap((l) =>
|
|
194
|
+
l.dialects.map((d) => d.code),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Find the human-readable language entry that owns a given BCP-47 tag.
|
|
199
|
+
* Returns `null` for unknown / custom tags (custom engines may use
|
|
200
|
+
* codes outside this catalogue).
|
|
201
|
+
*/
|
|
202
|
+
export function findSpeechLanguage(tag: string | null | undefined): {
|
|
203
|
+
language: SpeechLanguage;
|
|
204
|
+
dialect: SpeechLanguageDialect;
|
|
205
|
+
} | null {
|
|
206
|
+
if (!tag) return null;
|
|
207
|
+
const lower = tag.toLowerCase();
|
|
208
|
+
for (const language of WEB_SPEECH_LANGUAGES) {
|
|
209
|
+
for (const dialect of language.dialects) {
|
|
210
|
+
if (dialect.code.toLowerCase() === lower) return { language, dialect };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Extract the ISO-3166 country code (2 uppercase letters) from a
|
|
218
|
+
* BCP-47 tag, or `null` if the tag has no region subtag. Used by the
|
|
219
|
+
* language flag button to find the right country flag asset.
|
|
220
|
+
*/
|
|
221
|
+
export function countryFromTag(tag: string | null | undefined): string | null {
|
|
222
|
+
if (!tag) return null;
|
|
223
|
+
const parts = tag.split('-');
|
|
224
|
+
for (let i = parts.length - 1; i >= 0; i -= 1) {
|
|
225
|
+
const p = parts[i];
|
|
226
|
+
if (p.length === 2 && /^[A-Za-z]{2}$/.test(p)) return p.toUpperCase();
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { newSegmentId } from './ids';
|
|
2
|
+
import type {
|
|
3
|
+
RecognitionError,
|
|
4
|
+
RecognitionStatus,
|
|
5
|
+
Segment,
|
|
6
|
+
} from '../types';
|
|
7
|
+
|
|
8
|
+
export interface RecognitionState {
|
|
9
|
+
status: RecognitionStatus;
|
|
10
|
+
segments: Segment[];
|
|
11
|
+
error: RecognitionError | null;
|
|
12
|
+
startedAt: number | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const INITIAL_STATE: RecognitionState = {
|
|
16
|
+
status: 'idle',
|
|
17
|
+
segments: [],
|
|
18
|
+
error: null,
|
|
19
|
+
startedAt: null,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type RecognitionAction =
|
|
23
|
+
| { type: 'START' }
|
|
24
|
+
| { type: 'STARTED' }
|
|
25
|
+
| { type: 'STOP' }
|
|
26
|
+
| { type: 'STOPPED' }
|
|
27
|
+
| { type: 'ABORT' }
|
|
28
|
+
| {
|
|
29
|
+
type: 'PARTIAL';
|
|
30
|
+
text: string;
|
|
31
|
+
segmentId: string;
|
|
32
|
+
confidence?: number;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
type: 'FINAL';
|
|
36
|
+
text: string;
|
|
37
|
+
segmentId: string;
|
|
38
|
+
confidence?: number;
|
|
39
|
+
}
|
|
40
|
+
| { type: 'ERROR'; error: RecognitionError }
|
|
41
|
+
| { type: 'RESET' };
|
|
42
|
+
|
|
43
|
+
function nowSinceStart(state: RecognitionState): number {
|
|
44
|
+
return state.startedAt ? Date.now() - state.startedAt : 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function upsertSegment(
|
|
48
|
+
segments: Segment[],
|
|
49
|
+
patch: Segment,
|
|
50
|
+
): Segment[] {
|
|
51
|
+
const idx = segments.findIndex((s) => s.id === patch.id);
|
|
52
|
+
if (idx === -1) return [...segments, patch];
|
|
53
|
+
const next = segments.slice();
|
|
54
|
+
next[idx] = { ...next[idx], ...patch };
|
|
55
|
+
return next;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function reducer(
|
|
59
|
+
state: RecognitionState,
|
|
60
|
+
action: RecognitionAction,
|
|
61
|
+
): RecognitionState {
|
|
62
|
+
switch (action.type) {
|
|
63
|
+
case 'START':
|
|
64
|
+
return {
|
|
65
|
+
...state,
|
|
66
|
+
status: 'starting',
|
|
67
|
+
error: null,
|
|
68
|
+
startedAt: Date.now(),
|
|
69
|
+
};
|
|
70
|
+
case 'STARTED':
|
|
71
|
+
return { ...state, status: 'listening' };
|
|
72
|
+
case 'STOP':
|
|
73
|
+
return { ...state, status: 'stopping' };
|
|
74
|
+
case 'STOPPED':
|
|
75
|
+
case 'ABORT':
|
|
76
|
+
return { ...state, status: 'idle' };
|
|
77
|
+
case 'PARTIAL': {
|
|
78
|
+
const seg: Segment = {
|
|
79
|
+
id: action.segmentId,
|
|
80
|
+
text: action.text,
|
|
81
|
+
isFinal: false,
|
|
82
|
+
confidence: action.confidence,
|
|
83
|
+
startedAt: nowSinceStart(state),
|
|
84
|
+
};
|
|
85
|
+
return { ...state, segments: upsertSegment(state.segments, seg) };
|
|
86
|
+
}
|
|
87
|
+
case 'FINAL': {
|
|
88
|
+
const seg: Segment = {
|
|
89
|
+
id: action.segmentId || newSegmentId(),
|
|
90
|
+
text: action.text,
|
|
91
|
+
isFinal: true,
|
|
92
|
+
confidence: action.confidence,
|
|
93
|
+
startedAt: nowSinceStart(state),
|
|
94
|
+
endedAt: nowSinceStart(state),
|
|
95
|
+
};
|
|
96
|
+
return { ...state, segments: upsertSegment(state.segments, seg) };
|
|
97
|
+
}
|
|
98
|
+
case 'ERROR':
|
|
99
|
+
return { ...state, status: 'error', error: action.error };
|
|
100
|
+
case 'RESET':
|
|
101
|
+
return { ...INITIAL_STATE };
|
|
102
|
+
default:
|
|
103
|
+
return state;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Segment, Transcript } from '../types';
|
|
2
|
+
|
|
3
|
+
export const EMPTY_TRANSCRIPT: Transcript = {
|
|
4
|
+
interim: '',
|
|
5
|
+
final: '',
|
|
6
|
+
segments: [],
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function joinFinal(segments: Segment[]): string {
|
|
10
|
+
let out = '';
|
|
11
|
+
for (const seg of segments) {
|
|
12
|
+
if (!seg.isFinal) continue;
|
|
13
|
+
const text = seg.text.trim();
|
|
14
|
+
if (!text) continue;
|
|
15
|
+
out = out ? `${out} ${text}` : text;
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildTranscript(segments: Segment[]): Transcript {
|
|
21
|
+
const last = segments[segments.length - 1];
|
|
22
|
+
const interim = last && !last.isFinal ? last.text : '';
|
|
23
|
+
return {
|
|
24
|
+
interim,
|
|
25
|
+
final: joinFinal(segments),
|
|
26
|
+
segments,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Polite text normalisation between concatenated finals — strips double
|
|
32
|
+
* spaces / leading punctuation that some engines emit when the user pauses.
|
|
33
|
+
*/
|
|
34
|
+
export function normaliseFinal(text: string): string {
|
|
35
|
+
return text.replace(/\s+/g, ' ').replace(/\s+([,.!?])/g, '$1').trim();
|
|
36
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { useSpeechRecognition } from './useSpeechRecognition';
|
|
2
|
+
export { useDictation } from './useDictation';
|
|
3
|
+
export type { UseDictationConfig, UseDictationReturn } from './useDictation';
|
|
4
|
+
export { useMicDevices } from './useMicDevices';
|
|
5
|
+
export type { MicDevice } from './useMicDevices';
|
|
6
|
+
export { useMicLevel } from './useMicLevel';
|
|
7
|
+
export { usePushToTalk } from './usePushToTalk';
|
|
8
|
+
export type { UsePushToTalkOptions } from './usePushToTalk';
|
|
9
|
+
export { useEnginePrefs } from './useEnginePrefs';
|
|
10
|
+
export { useVoiceSupport } from './useVoiceSupport';
|
|
11
|
+
export type { VoiceSupport, VoiceUnsupportedReason } from './useVoiceSupport';
|
|
12
|
+
export { useResolvedLanguage } from './useResolvedLanguage';
|
|
13
|
+
export { useSpeechLanguageInfo } from './useSpeechLanguageInfo';
|
|
14
|
+
export type { SpeechLanguageInfo } from './useSpeechLanguageInfo';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { normaliseFinal } from '../core/transcript';
|
|
6
|
+
import type { UseSpeechRecognitionConfig, UseSpeechRecognitionReturn } from '../types';
|
|
7
|
+
import { useSpeechRecognition } from './useSpeechRecognition';
|
|
8
|
+
|
|
9
|
+
export interface UseDictationConfig
|
|
10
|
+
extends Omit<UseSpeechRecognitionConfig, 'onFinal'> {
|
|
11
|
+
/** Controlled value the dictation is appending to. */
|
|
12
|
+
value: string;
|
|
13
|
+
/** Called with the next value after each final segment lands. */
|
|
14
|
+
onChange: (next: string) => void;
|
|
15
|
+
/** Joiner between the previous value and the new segment. Default ' '. */
|
|
16
|
+
separator?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseDictationReturn extends UseSpeechRecognitionReturn {
|
|
20
|
+
/** Convenience — same as `toggle`, named for dictation UIs. */
|
|
21
|
+
toggleDictation: () => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Convenience adapter that pipes final transcript segments straight into
|
|
26
|
+
* a controlled string (`<textarea>` / `<input>` / TipTap). Interim text
|
|
27
|
+
* is left alone — bind `transcript.interim` separately if you want to
|
|
28
|
+
* show a live ghost.
|
|
29
|
+
*/
|
|
30
|
+
export function useDictation(config: UseDictationConfig): UseDictationReturn {
|
|
31
|
+
const { value, onChange, separator = ' ', ...rest } = config;
|
|
32
|
+
|
|
33
|
+
// Stash latest value in a ref so the onFinal closure always sees the
|
|
34
|
+
// freshest text without forcing the underlying hook to resubscribe.
|
|
35
|
+
const valueRef = useRef(value);
|
|
36
|
+
valueRef.current = value;
|
|
37
|
+
const onChangeRef = useRef(onChange);
|
|
38
|
+
onChangeRef.current = onChange;
|
|
39
|
+
|
|
40
|
+
const rec = useSpeechRecognition({
|
|
41
|
+
...rest,
|
|
42
|
+
onFinal: (text) => {
|
|
43
|
+
const clean = normaliseFinal(text);
|
|
44
|
+
if (!clean) return;
|
|
45
|
+
const prev = valueRef.current;
|
|
46
|
+
const next = prev ? `${prev}${separator}${clean}` : clean;
|
|
47
|
+
onChangeRef.current(next);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
valueRef.current = value;
|
|
53
|
+
}, [value]);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
...rec,
|
|
57
|
+
toggleDictation: rec.toggle,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSpeechPrefs } from '../store/prefsStore';
|
|
4
|
+
import type { SpeechPrefs } from '../store/prefsStore';
|
|
5
|
+
|
|
6
|
+
/** Thin selector hook so consumers can subscribe to prefs without pulling the whole zustand store. */
|
|
7
|
+
export function useEnginePrefs(): SpeechPrefs & {
|
|
8
|
+
setLanguage: (v: string) => void;
|
|
9
|
+
setDeviceId: (v: string | null) => void;
|
|
10
|
+
setEngineId: (v: string | null) => void;
|
|
11
|
+
setEarcons: (v: boolean) => void;
|
|
12
|
+
reset: () => void;
|
|
13
|
+
} {
|
|
14
|
+
return useSpeechPrefs();
|
|
15
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { sttLogger } from '../core/logger';
|
|
6
|
+
|
|
7
|
+
export interface MicDevice {
|
|
8
|
+
deviceId: string;
|
|
9
|
+
label: string;
|
|
10
|
+
groupId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Enumerates `audioinput` devices. The browser hides labels until the
|
|
15
|
+
* page has been granted mic permission at least once — the consumer
|
|
16
|
+
* should call `useSpeechRecognition().start()` first to populate labels.
|
|
17
|
+
*/
|
|
18
|
+
export function useMicDevices(): {
|
|
19
|
+
devices: MicDevice[];
|
|
20
|
+
refresh: () => Promise<void>;
|
|
21
|
+
} {
|
|
22
|
+
const [devices, setDevices] = useState<MicDevice[]>([]);
|
|
23
|
+
|
|
24
|
+
async function refresh(): Promise<void> {
|
|
25
|
+
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const list = await navigator.mediaDevices.enumerateDevices();
|
|
30
|
+
setDevices(
|
|
31
|
+
list
|
|
32
|
+
.filter((d) => d.kind === 'audioinput')
|
|
33
|
+
.map((d) => ({
|
|
34
|
+
deviceId: d.deviceId,
|
|
35
|
+
label: d.label || 'Microphone',
|
|
36
|
+
groupId: d.groupId,
|
|
37
|
+
})),
|
|
38
|
+
);
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
sttLogger.warn('[devices] enumerate failed', cause);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
void refresh();
|
|
46
|
+
if (typeof navigator === 'undefined' || !navigator.mediaDevices) return undefined;
|
|
47
|
+
const handler = (): void => {
|
|
48
|
+
void refresh();
|
|
49
|
+
};
|
|
50
|
+
navigator.mediaDevices.addEventListener?.('devicechange', handler);
|
|
51
|
+
return () => {
|
|
52
|
+
navigator.mediaDevices.removeEventListener?.('devicechange', handler);
|
|
53
|
+
};
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
return { devices, refresh };
|
|
57
|
+
}
|