@callimacus/thamyr-react-native 4.3.4

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 ADDED
@@ -0,0 +1,173 @@
1
+ # @callimacus/thamyr-react-native
2
+
3
+ The Thamyr SDK for React Native (Expo and bare). It wraps
4
+ `@callimacus/thamyr-react`, so the hooks, store, and response types are the same
5
+ ones the web SDK exposes — this package narrows them to what works on native and
6
+ adds a native voice recorder.
7
+
8
+ ```bash
9
+ npm install @callimacus/thamyr-react-native
10
+ ```
11
+
12
+ Peer dependencies: `react >=18`, `react-native >=0.72`.
13
+
14
+ ## Getting started
15
+
16
+ ```tsx
17
+ import {useInitThamyr, useThamyr, useConnection} from '@callimacus/thamyr-react-native';
18
+
19
+ function App() {
20
+ useInitThamyr({clientId: 'cal-pk-…'});
21
+ return <Chat />;
22
+ }
23
+
24
+ function Chat() {
25
+ const {history, sendEvent} = useThamyr();
26
+ const {status} = useConnection();
27
+ // …
28
+ }
29
+ ```
30
+
31
+ ## What differs from the web SDK
32
+
33
+ Everything not listed here behaves identically, because it is literally the same
34
+ code.
35
+
36
+ ### Voice recording returns bytes
37
+
38
+ The web recorder hands you a `Blob`; this one hands you a `Uint8Array`. Audio
39
+ travels as `inputEvent.data.file` over socket.io, and only typed arrays survive
40
+ that trip on native: a React Native `Blob` is a handle into the native
41
+ BlobModule whose bytes never exist in JS, so socket.io cannot read it, and
42
+ `new File([...])` — what the web path uses — has no useful native equivalent.
43
+ The server drops the filename and mime type either way (socket.io's binary
44
+ framing strips them), so nothing is lost.
45
+
46
+ The recorder needs an adapter, which you build from `expo-audio`:
47
+
48
+ ```tsx
49
+ import {useMemo} from 'react';
50
+ import * as ExpoAudio from 'expo-audio';
51
+ import {
52
+ createExpoAudioAdapter,
53
+ useVoiceRecorder,
54
+ useThamyr,
55
+ SLInputEventType,
56
+ UserInteractionType,
57
+ } from '@callimacus/thamyr-react-native';
58
+
59
+ function VoiceButton() {
60
+ const {sendEvent} = useThamyr();
61
+ const adapter = useMemo(() => createExpoAudioAdapter(ExpoAudio), []);
62
+
63
+ const {state, audioLevels, startRecording, stopRecording, error} = useVoiceRecorder({
64
+ adapter,
65
+ onRecordingComplete(audio) {
66
+ sendEvent(UserInteractionType.CREATE_ROUND, {
67
+ inputEvent: {type: SLInputEventType.question, data: {value: '', file: audio}},
68
+ });
69
+ },
70
+ });
71
+ // …
72
+ }
73
+ ```
74
+
75
+ The adapter is passed in rather than defaulted on purpose. If this package
76
+ imported `expo-audio` itself, Metro would resolve it statically for every
77
+ consumer, so an app that never records audio would fail to bundle merely for not
78
+ having installed it. Handing the module in also keeps `expo-audio` out of this
79
+ package's dependency graph entirely, so it typechecks and tests without a native
80
+ toolchain. For a recorder other than `expo-audio`, implement
81
+ `VoiceRecorderAdapter` yourself — `start`/`stop`/`cancel` over any native module.
82
+
83
+ Recording auto-stops after `silenceDuration` (default 1800 ms) below
84
+ `silenceThreshold` (default −45 dBFS), and hard-stops at `maxDuration` (default
85
+ 30 s).
86
+
87
+ ### Hooks that are not re-exported
88
+
89
+ | Not available | Why | Instead |
90
+ |---|---|---|
91
+ | `useTtsAudio` | Constructs an `HTMLAudioElement` (`new Audio(url)`) | Play the URL from `extractTtsAudio(block.data)` with an `expo-audio` player |
92
+ | `uiObserver` config | Snapshots mean `querySelectorAll` + `getBoundingClientRect` | Not yet available on native (SOL-1084) |
93
+ | `setConsent`, `setGeo`, `initIntent`, … | `@callimacus/intent`'s browser API | `@callimacus/intent/native` — see below |
94
+
95
+ `extractTtsAudio` and the `TtsAudio` type *are* exported, so you can get the
96
+ audio URL out of a response and play it however you like; only the web playback
97
+ hook is missing.
98
+
99
+ ### Intent tracking
100
+
101
+ The intent web build cannot be imported on native at all: it reads
102
+ `localStorage` and `document` at module scope, which throws under Hermes before
103
+ your app renders. On React Native the SDK therefore resolves
104
+ `@callimacus/thamyr-core`'s `react-native` entry, where intent is absent — its
105
+ functions are present as warn-once no-ops so shared code does not crash, but they
106
+ do nothing.
107
+
108
+ Intent has its own native entry with a different shape. Wire it up directly:
109
+
110
+ ```ts
111
+ import * as RN from 'react-native';
112
+ import {MMKV} from 'react-native-mmkv';
113
+ import {createNativeAdapters, initIntentNative, mmkvStorage} from '@callimacus/intent/native';
114
+
115
+ initIntentNative({
116
+ clientId: 'cal-pk-…',
117
+ consent: true,
118
+ adapters: createNativeAdapters(RN, {storage: mmkvStorage(new MMKV())}),
119
+ });
120
+ ```
121
+
122
+ ## Transports, and why voice does not work yet
123
+
124
+ Text chat works. **Sending audio does not**, and the cause is neither this package
125
+ nor your app — it is a TLS mismatch that forces the connection onto a transport
126
+ which cannot carry binary on React Native. The detail matters because the symptom
127
+ is misleading.
128
+
129
+ **React Native cannot open a WebSocket to `api.callimacus.ai`.** The host is
130
+ TLS 1.3-only — it rejects a TLS 1.2 handshake outright with a
131
+ `protocol_version` alert (verified with `openssl s_client -no_tls1_3`). React
132
+ Native's WebSocket fails against it with close code 1006 and
133
+ `OSStatus -9836`, which is Apple's `errSSLPeerProtocolVersion` ("bad protocol
134
+ version"). React Native's *HTTP* stack is fine with the same host, since that
135
+ goes through `NSURLSession`, which does negotiate TLS 1.3 — consistent with the
136
+ WebSocket path using the older SecureTransport route that does not.
137
+
138
+ The SDK therefore falls back to HTTP long-polling, and text works over it.
139
+
140
+ **But binary cannot traverse polling on React Native.** engine.io's polling
141
+ payload is text, so `encodePayload` encodes every packet with
142
+ `supportsBinary: false` ("force base64 encoding for binary packets"), and in
143
+ engine.io-parser's browser build that path does
144
+ `encodeBlobAsBase64(new Blob([data]))` — while React Native refuses to construct
145
+ a Blob from an `ArrayBuffer` at all ("Creating blobs from 'ArrayBuffer' and
146
+ 'ArrayBufferView' are not supported"). So the audio bytes are dropped with a
147
+ console error. `forceBase64` cannot help: polling already forces it, and that is
148
+ precisely the branch that builds the Blob.
149
+
150
+ **The fix is to let React Native use the WebSocket transport, by enabling TLS 1.2
151
+ on the API host** (nginx: keep `TLSv1.3` and add `TLSv1.2` with a matching cipher
152
+ suite). Over WebSocket, `encodePacket` runs with `supportsBinary: true` and hands
153
+ the typed array straight to `WebSocket.send()` — no Blob, no base64 — so audio
154
+ works with no further SDK change, and text gets lower latency as a bonus.
155
+
156
+ Until then, recording works and `onRecordingComplete` gives you correct bytes;
157
+ only the send fails.
158
+
159
+ ## Things worth knowing
160
+
161
+ - **UUIDs.** Hermes ships no global `crypto`. Core falls back to
162
+ `crypto.getRandomValues` and then `Math.random()`, so nothing throws, but
163
+ installing [`react-native-get-random-values`](https://github.com/LinusU/react-native-get-random-values)
164
+ at the top of your entry file gets you cryptographically random interaction ids.
165
+ - **`gatherClientInfo()`** returns empty strings for `userAgent`, `language`, and
166
+ `screenResolution` on native — there is no `navigator.userAgent` or
167
+ `window.screen`. Read screen size from `Dimensions` if you need it.
168
+ - **Microphone permissions.** Add `NSMicrophoneUsageDescription` (iOS) and
169
+ `RECORD_AUDIO` (Android). Under Expo, `expo-audio`'s config plugin does this.
170
+ - **Socket auth** goes through socket.io's `auth` payload, not an
171
+ `Authorization` header — React Native's WebSocket transport ignores
172
+ `extraHeaders`, and the server reads `handshake.auth.token`. No action needed;
173
+ it is noted because the header being dropped looks alarming in logs.
@@ -0,0 +1,9 @@
1
+ /*!
2
+ * Copyright © 2025–2026 Solomei AI SRL. All rights reserved.
3
+ *
4
+ * Proprietary software, licensed for use by Callimacus customers only.
5
+ * See LICENSE.md for the full terms.
6
+ *
7
+ */
8
+ import{useInitThamyr as e}from"@callimacus/thamyr-react";export{CallimacusEvent,ChapterStatus,ClientEvents,GalleryMode,SLInputEventType,ServerEvents,ThamyrHttpError,ThamyrResponseType,UserInteractionType,callimacusService,extractTtsAudio,isSetProduct,logger,thamyrApi,transformImageUrl,useApi,useCallimacusStore,useCart,useConnection,useOnConnectionChange,useOnError,useOnResponse,useSitemap,useSkesis,useThamyr}from"@callimacus/thamyr-react";import{useState as r,useRef as t,useCallback as n,useEffect as o}from"react";function i(r){e(r)}async function s(e){return new Promise((r,t)=>{const n=new XMLHttpRequest;n.responseType="arraybuffer","arraybuffer"===n.responseType?(n.addEventListener("load",()=>{const o=n.response;o instanceof ArrayBuffer?0!==o.byteLength?r(new Uint8Array(o)):t(new Error(`The recording at ${e} is empty`)):t(new Error(`Reading the recording at ${e} produced ${typeof o}, expected an ArrayBuffer`))}),n.addEventListener("error",()=>{t(new Error(`Failed to read the recording at ${e}`))}),n.open("GET",e,!0),n.send(null)):t(new Error('This runtime does not support XMLHttpRequest responseType "arraybuffer", so the recording cannot be read. Pass a custom adapter that returns bytes.'))})}const a=-45,c=1800,u=3e4;function d(e){if(!Number.isFinite(e))return 0;const r=(e- -60)/60;return Math.min(1,Math.max(0,r))}function l({silenceThreshold:e=a,silenceDuration:i=c,maxDuration:l=u,onRecordingComplete:p,adapter:y}){const[m,f]=r("idle"),[g,h]=r([]),[w,v]=r(void 0),[E,T]=r(void 0),M=t(y);M.current=y;const R=t(void 0),A=t([]),S=t(0),b=t(void 0),C=t(!1),I=t(p);I.current=p;const L=n(()=>{void 0!==b.current&&(clearTimeout(b.current),b.current=void 0)},[]),x=n(()=>{A.current=[],S.current=0,h([])},[]),H=n(()=>{if(C.current)return;C.current=!0,L(),f("processing");(async()=>{const e=R.current;R.current=void 0;try{if(!e)throw new Error("No active recording to stop");const r=await e.stop();T(r.uri),I.current(await s(r.uri))}catch(e){v(e instanceof Error?e.message:"Failed to finish recording")}finally{C.current=!1,f("idle")}})()},[L]),P=n(r=>{if(C.current)return;const t=[...A.current,d(r)];t.length>32&&t.splice(0,t.length-32),A.current=t,h(t),r<e?0===S.current?S.current=Date.now():Date.now()-S.current>i&&H():S.current=0},[e,i,H]),U=n(async()=>{v(void 0),C.current=!1,x();const e=M.current;R.current=e;try{await e.start({onMetering:P}),f("recording"),b.current=setTimeout(()=>{H()},l)}catch(r){R.current=void 0,v(r instanceof Error?r.message:"Microphone access denied"),f("idle"),await e.cancel()}},[P,l,x,H]),D=n(()=>{f("idle"),v(void 0),x()},[x]);return o(()=>()=>{L();const e=R.current;R.current=void 0,e?.cancel()},[L]),{state:m,audioLevels:g,startRecording:U,stopRecording:H,reset:D,error:w,lastRecordingUri:E}}function p(e){let r,t;function n(){void 0!==t&&(clearInterval(t),t=void 0)}return{async start({onMetering:n}){if(!(await e.requestRecordingPermissionsAsync()).granted)throw new Error("Microphone permission denied");await e.setAudioModeAsync({allowsRecording:!0,playsInSilentMode:!0});const o=new e.AudioModule.AudioRecorder({...e.RecordingPresets.HIGH_QUALITY,isMeteringEnabled:!0});await o.prepareToRecordAsync(),o.record(),r=o,t=setInterval(()=>{const e=o.getStatus();"number"==typeof e.metering&&n?.(e.metering)},100)},async stop(){if(!r)throw new Error("No active recording to stop");n();const e=r;r=void 0,await e.stop();const{uri:t}=e;if(null===t||""===t)throw new Error("Recording finished without producing a file");return{uri:t,durationMillis:e.getStatus().durationMillis}},async cancel(){if(n(),!r)return;const e=r;r=void 0;try{await e.stop()}catch{}}}}export{p as createExpoAudioAdapter,s as readRecording,i as useInitThamyr,l as useVoiceRecorder};
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../../../src/callimacus/index.ts","../../../src/voice/read-recording.ts","../../../src/voice/use-voice-recorder.ts","../../../src/voice/adapters/expo-audio.ts"],"sourcesContent":[null,null,null,null],"names":["useInitThamyr","config","useInitThamyrWeb","async","readRecording","uri","Promise","resolve","reject","request","XMLHttpRequest","responseType","addEventListener","response","ArrayBuffer","byteLength","Uint8Array","Error","open","send","defaultOptions","normalizeMetering","db","Number","isFinite","level","Math","min","max","useVoiceRecorder","silenceThreshold","silenceDuration","maxDuration","onRecordingComplete","adapter","state","setState","useState","audioLevels","setAudioLevels","error","setError","undefined","lastRecordingUri","setLastRecordingUri","adapterRef","useRef","current","activeAdapterRef","levelsRef","silenceStartRef","maxDurationTimerRef","stoppingRef","onCompleteRef","clearMaxDurationTimer","useCallback","clearTimeout","resetWaveform","stopRecording","active","result","stop","error_","message","finish","handleMetering","next","length","splice","Date","now","startRecording","start","onMetering","setTimeout","cancel","reset","useEffect","createExpoAudioAdapter","expoAudio","recorder","meterTimer","stopMeterTimer","clearInterval","requestRecordingPermissionsAsync","granted","setAudioModeAsync","allowsRecording","playsInSilentMode","AudioModule","AudioRecorder","RecordingPresets","HIGH_QUALITY","isMeteringEnabled","prepareToRecordAsync","record","setInterval","status","getStatus","metering","durationMillis"],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ /*!
2
+ * Copyright © 2025–2026 Solomei AI SRL. All rights reserved.
3
+ *
4
+ * Proprietary software, licensed for use by Callimacus customers only.
5
+ * See LICENSE.md for the full terms.
6
+ *
7
+ */
8
+ export*from"@callimacus/thamyr-react/query";
9
+ //# sourceMappingURL=query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;"}
@@ -0,0 +1,277 @@
1
+ /*!
2
+ * Copyright © 2025–2026 Solomei AI SRL. All rights reserved.
3
+ *
4
+ * Proprietary software, licensed for use by Callimacus customers only.
5
+ * See LICENSE.md for the full terms.
6
+ *
7
+ */
8
+ export { Block, BoundingBox, CallimacusEvent, CartActions, Chapter, ChapterStatus, ClientEvents, ClientInfo, ConnectionStatus, ConnectionStatusCallback, ErrorCallback, ErrorMessage, Gallery, GalleryMode, Image, ImageEdits, ImageFormatEdit, ImageResizeEdit, InteractionContext, InteractionPayloadMap, LocalizedEPArtwork, LocalizedEPChapter, LogLevel, LoggerConfig, ProductMatch, SLDocument, SLDocumentSection, SLInputEvent, SLInputEventType, ServerEvents, ServerResponseCallback, SitemapActions, SkesisActions, Story, ThamyrHttpError, ThamyrResponse, ThamyrResponseType, Topic, TtsAudio, UiElement, UiSnapshot, UserInteraction, UserInteractionType, Video, WhisperPayload, callimacusService, extractTtsAudio, isSetProduct, logger, thamyrApi, transformImageUrl, useApi, useCallimacusStore, useCart, useConnection, useOnConnectionChange, useOnError, useOnResponse, useSitemap, useSkesis, useThamyr } from '@callimacus/thamyr-react';
9
+
10
+ /**
11
+ Configuration for initializing the Thamyr SDK via `useInitThamyr` on React Native.
12
+
13
+ Intentionally narrower than the web (`@callimacus/thamyr-react`) config: the
14
+ `uiObserver` option is **omitted** because collecting a snapshot means
15
+ `querySelectorAll` + `getBoundingClientRect`, which have no React Native analog.
16
+ The `UiSnapshot` data contract itself is platform-neutral, so responses can still
17
+ carry that context; only the collection side is web-only. React Native
18
+ UI-snapshot support is its own feature (SOL-1084) and will need a different
19
+ design — explicit registration through viewability hooks / `measureInWindow`
20
+ rather than an autonomous observer.
21
+ */
22
+ type CallimacusInitConfig = {
23
+ /**
24
+ Your Callimacus public client ID (`cal-pk-…`).
25
+ */
26
+ clientId?: string;
27
+ /**
28
+ Server endpoint URL. Uses the default Thamyr endpoint when omitted.
29
+ */
30
+ endpoint?: string;
31
+ /**
32
+ Maximum number of messages to retain in history. Set to `0` for unlimited. Defaults to `50`.
33
+ */
34
+ maxMessages?: number;
35
+ };
36
+
37
+ /**
38
+ Initialize the Thamyr SDK for React Native usage.
39
+
40
+ Thin wrapper over the shared initializer from `@callimacus/thamyr-react` that
41
+ narrows the config to the React Native surface (no `uiObserver`). Call this once
42
+ in your root component. When `clientId` or `endpoint` change, the SDK is torn
43
+ down and re-initialized.
44
+
45
+ @param config - SDK configuration (client ID, endpoint, max messages).
46
+
47
+ @example
48
+ ```tsx
49
+ function App() {
50
+ useInitThamyr({clientId: 'cal-pk-…'});
51
+ return <RootNavigator />;
52
+ }
53
+ ```
54
+ */
55
+ declare function useInitThamyr(config: CallimacusInitConfig): void;
56
+
57
+ type VoiceRecorderState = 'idle' | 'recording' | 'processing';
58
+ type UseVoiceRecorderOptions = {
59
+ /**
60
+ Metering level (in dBFS, a negative value) below which audio is considered silence.
61
+ Defaults to `-45`.
62
+ */
63
+ silenceThreshold?: number;
64
+ /**
65
+ How long (ms) the level must stay below the threshold before auto-stopping. Defaults to `1800`.
66
+ */
67
+ silenceDuration?: number;
68
+ /**
69
+ Hard cap (ms) on a single recording. Defaults to `30_000`.
70
+ */
71
+ maxDuration?: number;
72
+ /**
73
+ Called once recording finishes, with the captured audio as raw bytes.
74
+
75
+ This is a `Uint8Array` rather than the web recorder's `Blob` on purpose.
76
+ Audio travels as `inputEvent.data.file` over socket.io, which serialises typed
77
+ arrays as binary attachments that arrive server-side as a Node `Buffer`. A
78
+ React Native `Blob` is a handle into the native BlobModule whose bytes never
79
+ exist in JS, so socket.io cannot read it; and `new File([...])` — what the web
80
+ path uses — has no useful React Native equivalent. The server discards the
81
+ filename and mime type anyway (socket.io's binary framing strips them), so
82
+ nothing is lost by sending bytes directly.
83
+
84
+ Pass it to `sendUserInteraction` rather than `sendAudio`, which is typed for
85
+ the web `Blob` — see {@link useVoiceRecorder} for the call.
86
+ */
87
+ onRecordingComplete: (audio: Uint8Array) => void;
88
+ /**
89
+ The recorder implementation. Build one from `expo-audio` with
90
+ `createExpoAudioAdapter(ExpoAudio)`, or supply your own for a different native
91
+ recorder in bare React Native.
92
+
93
+ Required, rather than defaulting to `expo-audio`: a default would mean this
94
+ package referencing `expo-audio` itself, which Metro resolves statically — so
95
+ apps that never record audio would fail to bundle without it installed. Pass a
96
+ memoized adapter so it keeps a stable identity across renders.
97
+ */
98
+ adapter: VoiceRecorderAdapter;
99
+ };
100
+ type UseVoiceRecorderReturn = {
101
+ state: VoiceRecorderState;
102
+ /**
103
+ Rolling, normalised (0..1) waveform of recent audio levels, length 32.
104
+ */
105
+ audioLevels: number[];
106
+ startRecording: () => Promise<void>;
107
+ stopRecording: () => void;
108
+ reset: () => void;
109
+ error: string | undefined;
110
+ /**
111
+ File URI of the most recent completed recording, if any.
112
+ */
113
+ lastRecordingUri: string | undefined;
114
+ };
115
+ /**
116
+ Result of a completed native recording.
117
+ */
118
+ type RecordingResult = {
119
+ /**
120
+ Local file URI of the recording (e.g. `file:///...`).
121
+ */
122
+ uri: string;
123
+ /**
124
+ Recording duration in milliseconds, when the adapter reports it.
125
+ */
126
+ durationMillis?: number;
127
+ };
128
+ /**
129
+ Abstraction over a native audio recorder so the hook stays independent of any
130
+ single library. The built-in adapter targets `expo-audio`; bare React Native apps
131
+ can implement this against `react-native-audio-recorder-player` or similar.
132
+ */
133
+ type VoiceRecorderAdapter = {
134
+ /**
135
+ Begins recording. `onMetering` should be invoked periodically with the current
136
+ input level in dBFS (a negative number; `0` is loudest). It drives both the
137
+ waveform and silence detection.
138
+ */
139
+ start: (options: {
140
+ onMetering?: (db: number) => void;
141
+ }) => Promise<void>;
142
+ /**
143
+ Stops recording and resolves with the resulting file.
144
+ */
145
+ stop: () => Promise<RecordingResult>;
146
+ /**
147
+ Aborts recording and discards any output. Safe to call when not recording.
148
+ */
149
+ cancel: () => Promise<void>;
150
+ };
151
+
152
+ /**
153
+ React Native voice recorder hook. Mirrors the control surface of the web recorder
154
+ in `@callimacus/thamyr-react` (`state`, `audioLevels`, `startRecording`,
155
+ `stopRecording`, `reset`, `error`) so cross-platform call sites stay aligned.
156
+
157
+ It differs in one deliberate way: `onRecordingComplete` receives a `Uint8Array`
158
+ rather than a `Blob`, because that is what survives socket.io's binary framing on
159
+ native. See {@link UseVoiceRecorderOptions.onRecordingComplete}.
160
+
161
+ Recording is delegated to a {@link VoiceRecorderAdapter} — see
162
+ {@link UseVoiceRecorderOptions.adapter} for why it is passed in rather than
163
+ defaulted. Silence detection and the max-duration cap run in JS off the adapter's
164
+ metering stream.
165
+
166
+ @example
167
+ ```tsx
168
+ import * as ExpoAudio from 'expo-audio';
169
+
170
+ const {sendEvent} = useThamyr();
171
+ const adapter = useMemo(() => createExpoAudioAdapter(ExpoAudio), []);
172
+ const {state, startRecording, stopRecording} = useVoiceRecorder({
173
+ adapter,
174
+ onRecordingComplete(audio) {
175
+ sendEvent(UserInteractionType.CREATE_ROUND, {
176
+ inputEvent: {type: SLInputEventType.question, data: {value: '', file: audio}},
177
+ });
178
+ },
179
+ });
180
+ ```
181
+ */
182
+ declare function useVoiceRecorder({ silenceThreshold, silenceDuration, maxDuration, onRecordingComplete, adapter, }: UseVoiceRecorderOptions): UseVoiceRecorderReturn;
183
+
184
+ /**
185
+ The slice of a recorder instance this adapter drives.
186
+
187
+ Structural rather than `import type {AudioRecorder} from 'expo-audio'` so this
188
+ package neither depends on `expo-audio` nor leaks it into consumers' typechecks.
189
+ */
190
+ type ExpoAudioRecorderLike = {
191
+ uri: string | null;
192
+ prepareToRecordAsync: () => Promise<unknown>;
193
+ record: () => void;
194
+ stop: () => Promise<unknown>;
195
+ getStatus: () => {
196
+ metering?: number;
197
+ durationMillis?: number;
198
+ };
199
+ };
200
+ /**
201
+ The slice of the `expo-audio` module this adapter uses. `import * as ExpoAudio
202
+ from 'expo-audio'` satisfies it.
203
+ */
204
+ type ExpoAudioLike = {
205
+ AudioModule: {
206
+ AudioRecorder: new (options: Record<string, unknown>) => ExpoAudioRecorderLike;
207
+ };
208
+ RecordingPresets: Record<string, Record<string, unknown>>;
209
+ setAudioModeAsync: (mode: {
210
+ allowsRecording?: boolean;
211
+ playsInSilentMode?: boolean;
212
+ }) => Promise<unknown>;
213
+ requestRecordingPermissionsAsync: () => Promise<{
214
+ granted: boolean;
215
+ }>;
216
+ };
217
+ /**
218
+ {@link VoiceRecorderAdapter} backed by `expo-audio` — the current Expo audio
219
+ module, and the successor to `expo-av`, which Expo removed in SDK 54.
220
+
221
+ The module is **handed in rather than imported**, which is the whole point of the
222
+ signature:
223
+
224
+ - `require('expo-audio')` inside this package would make Metro resolve it
225
+ statically for every consumer, so an app that never records audio would fail to
226
+ bundle merely for not having installed it. An "optional peer dependency" that
227
+ breaks the build when absent is not optional.
228
+ - `await import('expo-audio')` is worse: Metro serves no async bundle splits at
229
+ runtime, so it fails with "Could not load bundle".
230
+ - Keeping `expo-audio` out of this package's dependency graph also means it
231
+ typechecks and tests with no native toolchain, against a plain object.
232
+
233
+ Metering is delivered by polling `recorder.getStatus().metering`, since
234
+ `expo-audio` exposes no push-based metering stream.
235
+
236
+ @param expoAudio - The `expo-audio` module namespace.
237
+
238
+ @example
239
+ ```tsx
240
+ import * as ExpoAudio from 'expo-audio';
241
+ import {createExpoAudioAdapter, useVoiceRecorder} from '@callimacus/thamyr-react-native';
242
+
243
+ const adapter = useMemo(() => createExpoAudioAdapter(ExpoAudio), []);
244
+ const recorder = useVoiceRecorder({adapter, onRecordingComplete: sendAudio});
245
+ ```
246
+ */
247
+ declare function createExpoAudioAdapter(expoAudio: ExpoAudioLike): VoiceRecorderAdapter;
248
+
249
+ /**
250
+ Reads a local recording file into raw bytes.
251
+
252
+ `XMLHttpRequest` with `responseType = 'arraybuffer'` is the route that actually
253
+ works on React Native, and the choice is worth recording because the two obvious
254
+ alternatives do not:
255
+
256
+ - `fetch(uri).then(r => r.arrayBuffer())` — React Native's fetch is the
257
+ whatwg-fetch polyfill, whose `arrayBuffer()` goes through
258
+ `FileReader.readAsArrayBuffer` on a Blob. That works on current React Native
259
+ but adds a Blob round-trip for no benefit.
260
+ - `new Blob([arrayBuffer])` — React Native forbids it outright ("Creating blobs
261
+ from 'ArrayBuffer' and 'ArrayBufferView' are not supported"), so any path that
262
+ tries to rebuild a Blob from bytes is a dead end.
263
+
264
+ React Native's XHR maps `responseType = 'arraybuffer'` onto a native base64
265
+ response and decodes it into a real `ArrayBuffer` (see
266
+ `react-native/Libraries/Network/XMLHttpRequest.js`), which is exactly the one
267
+ conversion needed here. `arraybuffer` support is itself conditional on
268
+ `global.ArrayBuffer` in that file, so it is feature-detected below rather than
269
+ assumed.
270
+
271
+ @param uri - A local file URI, as produced by the recorder adapter.
272
+ @returns The file's bytes.
273
+ */
274
+ declare function readRecording(uri: string): Promise<Uint8Array>;
275
+
276
+ export { createExpoAudioAdapter, readRecording, useInitThamyr, useVoiceRecorder };
277
+ export type { CallimacusInitConfig, ExpoAudioLike, RecordingResult, UseVoiceRecorderOptions, UseVoiceRecorderReturn, VoiceRecorderAdapter, VoiceRecorderState };
@@ -0,0 +1,8 @@
1
+ /*!
2
+ * Copyright © 2025–2026 Solomei AI SRL. All rights reserved.
3
+ *
4
+ * Proprietary software, licensed for use by Callimacus customers only.
5
+ * See LICENSE.md for the full terms.
6
+ *
7
+ */
8
+ export * from '@callimacus/thamyr-react/query';
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@callimacus/thamyr-react-native",
3
+ "version": "4.3.4",
4
+ "private": false,
5
+ "description": "React Native package for Thamyr",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
+ "homepage": "https://docs.callimacus.ai/thamyr-sdk/react-native/1-overview",
8
+ "main": "dist/esm/index.js",
9
+ "module": "dist/esm/index.js",
10
+ "types": "dist/types/index.d.ts",
11
+ "react-native": "dist/esm/index.js",
12
+ "exports": {
13
+ ".": {
14
+ "import": "./dist/esm/index.js",
15
+ "types": "./dist/types/index.d.ts"
16
+ },
17
+ "./query": {
18
+ "import": "./dist/esm/query.js",
19
+ "types": "./dist/types/query.d.ts"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "CHANGELOG.md",
25
+ "CHANGELOG.core.md",
26
+ "MIGRATION.md"
27
+ ],
28
+ "type": "module",
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "registry": "https://registry.npmjs.org",
32
+ "@callimacus:registry": "https://registry.npmjs.org"
33
+ },
34
+ "scripts": {
35
+ "prepack": "cp ../../MIGRATION.md ./MIGRATION.md && cp ../core/CHANGELOG.md ./CHANGELOG.core.md",
36
+ "build": "rollup -c",
37
+ "dev": "rollup -c -w",
38
+ "clean": "rm -rf dist",
39
+ "test": "vitest run",
40
+ "lint": "eslint src"
41
+ },
42
+ "dependencies": {
43
+ "@callimacus/thamyr-react": "^4.3.4"
44
+ },
45
+ "peerDependencies": {
46
+ "react": ">=18",
47
+ "react-native": ">=0.72",
48
+ "@tanstack/react-query": "^5"
49
+ },
50
+ "devDependencies": {
51
+ "@eslint-react/eslint-plugin": "^5.20.8",
52
+ "@testing-library/react": "^16.3.3",
53
+ "@types/react": "^19.3.0",
54
+ "eslint-config-xo": "^4.0.1",
55
+ "eslint-plugin-react-hooks": "^7.1.1",
56
+ "jsdom": "^30.1.1",
57
+ "react": "^19.3.0",
58
+ "react-native": "^0.87.1",
59
+ "vitest": "^5.0.2",
60
+ "@tanstack/react-query": "^5"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "@tanstack/react-query": {
64
+ "optional": true
65
+ }
66
+ }
67
+ }