OHMOHM Studio

OHM RN SDK (React Native)

@ohm_studio/sdk-react-native — OHM Studio in your mobile app.

View as Markdown

Short name: OHM RN SDK  ·  Full npm name: @ohm_studio/sdk-react-native

Same API as the OHM SDK (@ohm_studio/sdk), with two RN-specific adaptations:

  1. File shape: { uri, name, type } instead of Blob.
  2. Bundle-key safeguard: ohms_live_* keys are blocked unless you opt in.

Install

npm install @ohm_studio/sdk-react-native

Initialise

import { OHM } from "@ohm_studio/sdk-react-native";

// OHM is hospital-deployed. Each hospital exposes its own API URL — set
// baseUrl to the hospital your app integrates with. `api.ohm.doctor` is
// OHM's own demo hospital; use it only for evaluation.
const ohm = new OHM({
  apiKey: TEST_KEY,                                     // ohms_test_*
  baseUrl: "https://api.<hospital>.example",            // your hospital
  acknowledgeBundledKey: true,                           // dev-only override
});

Recording audio

The SDK ships a clinical-grade ExpoRecorder and a useRecorder() hook for the Expo flow. Bare-RN apps can use any recorder library — the SDK only needs { uri, name, type }.

import { Pressable, Text, View } from "react-native";
import { Audio } from "expo-av";
import { OHM } from "@ohm_studio/sdk-react-native";
import { OhmProvider, useRecorder } from "@ohm_studio/sdk-react-native/react";

const ohm = new OHM({ apiKey: TEST_KEY, acknowledgeBundledKey: true });

export default function App() {
  return (
    <OhmProvider client={ohm}>
      <Visit />
    </OhmProvider>
  );
}

function Visit() {
  const r = useRecorder({
    audio: Audio,                     // pass the Expo module in
    apiSlug: "opd-clinic",            // auto-extracts on stop
    silenceAutoStop: { ms: 6000 },
    maxDurationMs: 15 * 60_000,
  });

  return (
    <View>
      <Pressable onPress={r.isRecording ? r.stop : r.start}>
        <Text>
          {r.extracting   ? "Extracting…" :
           r.isRecording  ? `Stop (${r.durationSec.toFixed(0)}s)` :
                            "Record"}
        </Text>
      </Pressable>
      {r.transcript && <Text>{r.transcript}</Text>}
    </View>
  );
}
import { Audio } from "expo-av";
import { ExpoRecorder } from "@ohm_studio/sdk-react-native";

const rec = new ExpoRecorder(Audio, {
  silenceAutoStop: { ms: 6000 },
  maxDurationMs: 15 * 60_000,
  onLevel: setVu,
  onError: (e) => toast(e.message),
  // optional — wire to expo-keep-awake if you want it:
  keepAwake: {
    activate:   () => activateKeepAwake(),
    deactivate: () => deactivateKeepAwake(),
  },
});

await rec.start();
const file = await rec.stop();        // → { uri, name, type }
const { data } = await ohm.audio.extract({ apiSlug: "opd-clinic", file });

The default recording preset is 16 kHz mono AAC at 32 kbps — tuned for clinical speech and small file size. Override via recordingOptions: if you need something else. The recorder also configures the iOS audio session so recording works in silent mode.

expo-audio replaces expo-av from Expo SDK 54 onwards. The API shifted enough (class-based AudioRecorder instead of static Audio.Recording) that we don't shim both — instead, drive it directly and hand the resulting URI to ohm.audio.extract:

import { useAudioRecorder, RecordingPresets, setAudioModeAsync }
  from "expo-audio";

const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);

async function start() {
  await setAudioModeAsync({
    allowsRecording: true,
    playsInSilentMode: true,
  });
  await recorder.prepare();
  recorder.record();
}

async function stopAndExtract() {
  await recorder.stop();
  const uri = recorder.uri!;
  const { data, transcript } = await ohm.audio.extract({
    apiSlug: "opd-clinic",
    file: { uri, name: "rec.m4a", type: "audio/mp4" },
  });
}

The OHM SDK only needs { uri, name, type }; it doesn't care which recorder lib produced the file. You lose the SDK's silence-auto-stop and typed errors when going this route — wire your own from recorder.getStatus() if you need them.

For bare React Native (no Expo), pair react-native-audio-recorder-player with our BareRecorder class — same lifecycle and error model as ExpoRecorder:

import AudioRecorderPlayer from "react-native-audio-recorder-player";
import { BareRecorder } from "@ohm_studio/sdk-react-native";

const rec = new BareRecorder(AudioRecorderPlayer, {
  silenceAutoStop: { ms: 6000 },
  maxDurationMs: 15 * 60_000,
  onLevel: setVu,
});

await rec.start();
const file = await rec.stop();
const { data } = await ohm.audio.extract({ apiSlug: "opd-clinic", file });

BareRecorder configures the native module with a clinical preset (16 kHz mono AAC at 32 kbps via Android's VOICE_RECOGNITION source to attenuate noise) and emits the same RecorderError codes as ExpoRecorder.

Recorder reference

ExpoRecorder and useRecorder() mirror the browser Recorder — same options, same lifecycle, same error codes. Mobile-specific notes:

ConcernBehaviour
iOS silent modeAudio session set to playsInSilentModeIOS: true, allowsRecordingIOS: true automatically. Toggle off with configureIosAudioSession: false.
MeteringExpo emits metering in dBFS; the SDK linearises to 0–1 for onLevel / r.level.
Pause / resumeSupported on Expo SDK 50+. Older SDKs throw RecorderError("NotSupported").
Keep-awakeNot bundled. Pass { activate, deactivate } from expo-keep-awake if you want long consults to keep the screen on.
Background recordingNot enabled — staysActiveInBackground: false. Hospitals using a kiosk app can override.
Mic permissionrequestPermissionsAsync runs on start(); preflight via rec.probePermission().
Audio interruptions (incoming call)interruptionModeIOS: DoNotMix so a call pauses the recording instead of mixing audio.

Typed errors

import { RecorderError } from "@ohm_studio/sdk-react-native";

try { await rec.start() }
catch (e) {
  if (e instanceof RecorderError) {
    if (e.code === "PermissionDenied") openSettings();
    if (e.code === "Interrupted")     showRetryUI();
    if (e.code === "NotSupported")    fallbackToBareRN();
  }
}

For SDK-call errors (extract / audio.extract / jobs.* / etc.) RN uses the same hierarchy as the JS SDK:

import {
  OHMAbortError,
  OHMTimeoutError,
  OHMNetworkError,        // pair with OhmQueue for offline replay
  OHMNotFoundError,
  OHMRateLimitError,
  OHMValidationError,
  OHMAuthError,
  OHMServerError,
  OHM_ERROR_CODES,
} from "@ohm_studio/sdk-react-native";

OHMNetworkError is the canonical "offline → queue locally" signal — spotty hospital wifi makes this the most common failure mode in production. The pattern:

try {
  await ohm.audio.extract({ apiSlug, file, ... });
} catch (e) {
  if (e instanceof OHMNetworkError) {
    await queue.enqueue("audio.extract", { apiSlug, file, ... });
    toast("Saved offline — will send when connected");
  }
}

See the JavaScript SDK errors reference for the full class catalogue + OHM_ERROR_CODES constants.

Other React hooks

import {
  OhmProvider,
  useOhmAudioExtract,
  useOhmExtract,
  useOhmSummarize,
} from "@ohm_studio/sdk-react-native/react";

function ManualUploader() {
  const { mutateAsync, data, isPending } = useOhmAudioExtract({
    apiSlug: "opd-clinic",
  });
  // call mutateAsync({ file: { uri, name, type } }) yourself
}

Sync vs streaming vs async — all three work in RN

The React Native SDK exposes the same three extraction modes as the JS SDK. Pick the one that fits your audio length + UX:

// Sync — best for ≤ 10 min audio, tight UX
const r = await ohm.audio.extract({
  apiSlug: "opd-clinic",
  file: { uri, name: "rec.m4a", type: "audio/mp4" },
});

// Streaming — best for 10–30 min, transcript shows up first
const stream = ohm.audio.extractStream({ apiSlug, file: rnFile });
for await (const chunk of stream) {
  if (chunk.type === "transcript") setTranscript(chunk.transcript);
  if (chunk.type === "data") setData(chunk.data);
}

// Async — best for 30 min+, mobile-background, hour-long recordings
const { jobId } = await ohm.audio.jobs.create({
  apiSlug: "long-consult",
  file: rnFile,
  webhookUrl: "https://your-backend/ohm-callback",  // optional
});
const final = await ohm.audio.jobs.poll(jobId, {
  intervalMs: 3000,
  onProgress: (j) => setProgress(j.workerProgress),
});

// One-line submit-and-await
const final = await ohm.audio.extractAsync({
  apiSlug,
  file: rnFile,
  intervalMs: 3000,
  onProgress: (j) => setProgress(j.workerProgress),
});

Same audit fields (patientHash, recordedById, idempotencyKey) work identically across all three modes.

Same useRecorder hook drives all three — pass apiSlug to auto-extract sync, or call r.stop() to get the file and submit it yourself via any mode:

const r = useRecorder({ audio: Audio });

const onStop = async () => {
  const file = await r.stop();
  if (!file) return;
  // Pick your mode based on the recording length:
  if (r.durationSec < 600) {
    await ohm.audio.extract({ apiSlug, file });           // sync
  } else {
    await ohm.audio.jobs.create({                         // async
      apiSlug,
      file,
      webhookUrl: "https://your-backend/ohm-callback",
    });
  }
};

For the full async-extraction reference with webhook receiver template, see Async extraction.

Offline queue — OhmQueue

Hospital basements / OT lifts / ICU corridors lose network mid-call. OhmQueue persists failed extractions to AsyncStorage and replays them when connectivity returns. Bring your own storage adapter (usually @react-native-async-storage/async-storage) so the SDK doesn't bundle a peer dep:

import {
  OhmQueue,
  makeAsyncStorageAdapter,
  OHMServerError,
} from "@ohm_studio/sdk-react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";

const queue = new OhmQueue({
  storage: makeAsyncStorageAdapter(AsyncStorage),
  client: ohm,
});

// On a failed call, enqueue:
try {
  await ohm.audio.extract({ apiSlug, file, patientHash });
} catch (e) {
  if (e instanceof OHMServerError && e.status === 0) {
    await queue.enqueue("audio.extract", { apiSlug, file, patientHash });
    toast("Queued for retry — will send when online");
  }
}

// On reconnect (use NetInfo to detect):
const { replayed, failed, skipped } = await queue.flush();

flush() is sequential — stops on first auth error so a permanent 401 doesn't burn the entire queue. Each entry retries up to 5 times before being skipped. Surface a "X uploads pending" badge using await queue.list() for end-user transparency.

PHI redaction — restoreTokens

When an API has Redact PHI before extraction enabled, the server returns typed tokens ([PATIENT_1], [MRN_1]) instead of patient identifiers. Restore originals client-side:

import { restoreTokens } from "@ohm_studio/sdk-react-native";

const result = await ohm.extract({ apiSlug, text });
if (result.phiTokenMap) {
  const restored = restoreTokens(result.data, result.phiTokenMap);
  form.setValues(restored);
}

Discovery & audit

Same apis.list(), apis.get(slug), invocations.searchByPatient(...) surfaces as the JS SDK. See the JavaScript SDK reference for usage — identical signatures.

Never bundle live keys

Read API key handling before shipping. The proxy pattern is the only safe way to use a live key from a mobile app.