Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,6 @@ infrastructure installed first.
| CocoaPods | `brew install cocoapods` |
| An Expo-based RN app | Expo SDK 52+ recommended (this repo uses SDK 57) |

### Step 0 (bare RN only): add Expo Modules support

Skip this if your app already uses Expo. For a bare React Native app, install the Expo Modules
runtime once — it's what provides `requireNativeModule` and the autolinking `PlaudSdk`
depends on:
Expand All @@ -86,6 +84,15 @@ depends on:
npx install-expo-modules@latest
```

### Step 0: Install the skill from this repo

The Skill has context on the Plaud Embedded plugin to help you implement this
plugin for your react-native app.

```bash
npx skills add Plaud-AI/embedded-react-native
```

### Step 1: copy the module into your app

Place the module where Expo autolinking looks — a `modules/` folder at your project root:
Expand Down
181 changes: 181 additions & 0 deletions skills/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
---
name: setup-plaud-react-native
description: Set up the Plaud SDK Expo/React Native module (BLE connect, on-device recording, file list, audio export) in an existing or new React Native app. Use when a user wants to integrate Plaud's native iOS device SDK into a React Native / Expo app, wire up scan → connect → list → export, or troubleshoot why the module isn't linking or is unavailable at runtime.
---

# Setting up the Plaud React Native module

`plaud-sdk` is a local [Expo module](https://docs.expo.dev/modules/overview/) that bridges
Plaud's precompiled native iOS device SDK into React Native. It exposes BLE scan/connect,
on-device recording events, file listing, and audio export to JavaScript, with a typed event
stream. The module lives at `modules/plaud-sdk/` in this repo; a full reference app is at
`react-native-demo/` (`src/app/index.tsx` is the canonical usage example).

Use this skill to add the module to an app and get it building on a device.

## ⚠️ Read these constraints before anything else

The Plaud frameworks are **arm64, iOS 15+, device-only**. There is **no simulator slice** and
**no Android support**. This dictates the entire workflow:

- You **must run on a physical iPhone** (`npx expo run:ios --device`), never the simulator.
- You **must use a custom dev build**, not Expo Go (this is custom native code).
- On Android or the simulator, `isAvailable` is `false` and every `PlaudSdk` method rejects.
Guard every call site with `isAvailable` so the app stays functional (just without the SDK)
on those targets.

If the user is on the simulator or expects Android support, stop and set expectations first —
no amount of setup makes the SDK run there.

## Prerequisites

| Tool | Notes |
| --- | --- |
| Node.js | v20+ (v24 used in this repo) |
| Xcode | 16.x+ (26.x used here), with a physical iPhone + Apple ID |
| CocoaPods | `brew install cocoapods` |
| An Expo-based RN app | Expo SDK 52+ (this repo uses SDK 57). Bare RN works after Step 0. |

The module is built on Expo's module system, so the smoothest path is an Expo (or
Expo-prebuild) app. Bare React Native works too — you just install the Expo Modules
infrastructure first.

## Setup workflow

Work through these steps in order. Do not skip the `isAvailable` guard (Step 4) — it is the
difference between an app that degrades gracefully off-device and one that crashes.

### Step 0 — (bare RN only) add Expo Modules support

Skip if the app already uses Expo. For a bare React Native app, install the Expo Modules
runtime once — it provides `requireNativeModule` and the autolinking the module depends on:

```bash
npx install-expo-modules@latest
```

### Step 1 — copy the module into the app

Place it where Expo autolinking looks: a `modules/` folder at the project root.

```bash
cp -R modules/plaud-sdk /path/to/your-app/modules/plaud-sdk
```

Then reference it from the app's `package.json` so Metro and TypeScript resolve the
`plaud-sdk` import to the local folder:

```jsonc
// your-app/package.json
{
"dependencies": {
"plaud-sdk": "file:./modules/plaud-sdk"
}
}
```

```bash
npm install
```

The module's `expo-module.config.json` (which registers `PlaudSdkModule`) is what makes
autolinking pick it up — **no manual native linking, no Podfile edits, no Xcode edits.** The
three Plaud `.xcframework`s in `ios/Frameworks/` are vendored by `PlaudSdk.podspec` and
CocoaPods embeds and code-signs them automatically.

### Step 2 — declare BLE permissions in `app.json`

These live in the app config (not the module) so they survive `expo prebuild`. Add them under
`expo.ios.infoPlist`:

```jsonc
{
"expo": {
"ios": {
"infoPlist": {
"NSBluetoothAlwaysUsageDescription": "Plaud uses Bluetooth to connect to your recorder and sync recordings.",
"UIBackgroundModes": ["bluetooth-central"]
}
}
}
}
```

Without `NSBluetoothAlwaysUsageDescription` the app crashes the moment it touches Bluetooth.
`UIBackgroundModes: ["bluetooth-central"]` keeps BLE alive when backgrounded.

### Step 3 — generate the native project and build

```bash
npx expo prebuild -p ios # regenerates ios/ from app.json and runs pod install
npx expo run:ios --device # build + install on a connected iPhone
```

Re-run `expo prebuild` after **any** native config change (permissions, bundle id, plugins).
In a bare app that manages `ios/` by hand, run `pod install` from `ios/` instead — autolinking
still discovers the module.

### Step 4 — use it from JS (guard, init, subscribe, drive, clean up)

The module is **event-driven**: JS calls (`startScan`, `connectBleDevice`, `getFileList`) kick
off work, and results arrive on the event stream, not as return values. The five-part shape:

```ts
import { PlaudSdk, isAvailable } from 'plaud-sdk';

// 1. GUARD — off-iOS the native module isn't linked. Degrade gracefully.
if (!isAvailable) { /* show a "device required" state; skip SDK calls */ }

// 2. INIT — once, with a per-user JWT (see references/transcription-and-tokens.md).
await PlaudSdk.initSDK({
userAccessToken, // per-user Bearer JWT (your backend mints this)
customDomain: 'platform-us.plaud.ai', // domain only, no https://
userId: 'your-app-user-id', // reused as the default connect deviceToken
});

// 3. SUBSCRIBE — this is where results land.
const subs = [
PlaudSdk.addListener('scanResult', ({ devices }) => {/* show devices */}),
PlaudSdk.addListener('connectState', ({ connected, failed }) => {
if (connected) PlaudSdk.getFileList(); // ask for recordings once connected
}),
PlaudSdk.addListener('fileList', ({ files }) => {/* show recordings */}),
PlaudSdk.addListener('exportProgress', ({ progress, message }) => {/* progress UI */}),
];

// 4. DRIVE it.
await PlaudSdk.startScan();
await PlaudSdk.connectBleDevice({ uuid: device.uuid }); // from a scanResult device
const { outputPath } = await PlaudSdk.exportAudio({ sessionId, format: 'mp3' });

// 5. CLEAN UP listeners on unmount.
subs.forEach((s) => s.remove());
```

`react-native-demo/src/app/index.tsx` is a complete, production-shaped version (React state,
error handling, live-recording banners, unpair flow). **Read it before building your own
screen** — it shows the correct event → state wiring for every callback.

## References

Pull these in only when the task needs them:

- **`references/api-reference.md`** — every `PlaudSdk` method, every event and its payload,
and all the TypeScript types. Consult when writing call sites or handling a specific event.
- **`references/transcription-and-tokens.md`** — where the per-user JWT comes from, and the
optional export → upload → transcribe HTTP flow (which is **not** part of the native module
and belongs behind a backend in production).
- **`references/troubleshooting.md`** — symptom → cause table for the common failures
(`isAvailable` false, scan returns nothing, connect fails, pod/build errors).

## Key facts to keep straight

- **Never edit `ios/` by hand in an Expo app.** It's generated by `expo prebuild`. Put native
config in `app.json` and regenerate.
- **`customDomain` is domain-only** — `platform-us.plaud.ai`, not `https://platform-us.plaud.ai`.
- **`initSDK` does not mint the token.** The per-user Bearer JWT is an app/backend
responsibility. For local testing, `EXPO_PUBLIC_PLAUD_ACCESS_TOKEN` works (Expo inlines
`EXPO_PUBLIC_*` at build), but those vars are extractable from the bundle — never ship
credentials in the client.
- **`readFile` / `putBinary` do not exist here.** They were Capacitor WKWebView CORS shims. In
RN, read exported files with `expo-file-system` and upload with `fetch`.
131 changes: 131 additions & 0 deletions skills/references/api-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Plaud SDK — JS API reference

The typed native module `PlaudSdk` (from `plaud-sdk`). Source of truth:
`modules/plaud-sdk/src/PlaudSdk.types.ts` and `modules/plaud-sdk/ios/PlaudSdkModule.swift`.

All methods are iOS-device-only and reject on Android / the simulator. Guard call sites with
`isAvailable`. Methods that fetch data (`startScan`, `getFileList`) resolve immediately and
deliver results later via **events** — the promise resolving means "the request was sent," not
"here's the data."

## Module exports

```ts
import { PlaudSdk, isAvailable } from 'plaud-sdk';
```

- `isAvailable: boolean` — `true` only when the native module is linked and callable (physical
iOS device). `false` on Android/simulator, where `PlaudSdk` is a no-op `Proxy` whose methods
reject and whose `addListener` returns a harmless `{ remove() {} }`.
- `PlaudSdk: PlaudSdkModule` — the typed handle (also the default export).

## Methods

| Method | Signature | Notes |
| --- | --- | --- |
| `initSDK` | `(o: { userAccessToken: string; customDomain: string; userId?: string }) => Promise<void>` | Call once before anything else. `customDomain` is domain-only (no `https://`). `userId` is reused as the default connect `deviceToken`. Rejects `ERR_PLAUD_ARGS` if token/domain missing. |
| `startScan` | `() => Promise<void>` | Begins BLE scan. Internally waits for CoreBluetooth to reach `.poweredOn` (polls ~18s); emits `scanTimeout` with `reason: 'bluetoothNotPoweredOn'` if it never powers on. Devices arrive via `scanResult`. |
| `stopScan` | `() => Promise<void>` | Stops scanning. |
| `connectBleDevice` | `(o: { uuid?: string; serialNumber?: string; deviceToken?: string }) => Promise<void>` | Connect to a device from a prior `scanResult`. Prefer `uuid`. Must scan first (device objects are cached natively) or it rejects `ERR_PLAUD_UNKNOWN_DEVICE`. Connection result arrives via `connectState`. |
| `disconnect` | `() => Promise<void>` | Disconnect the current device. |
| `depair` | `(o?: { clear?: boolean }) => Promise<void>` | Unpair. `clear` defaults `true` (also clears local pairing state). Result via `depair` event. |
| `isConnected` | `() => Promise<{ connected: boolean }>` | Synchronous-ish status check (this one returns data directly). |
| `getFileList` | `(o?: { startSessionId?: number }) => Promise<void>` | Request the on-device recording list. Results arrive via the `fileList` event. |
| `exportAudio` | `(o: { sessionId: number; format?: PlaudAudioFormat; channels?: number }) => Promise<{ sessionId: number; outputPath: string }>` | Decode a recording to a file in `Documents/PlaudExports`. Resolves with the written path; emits `exportProgress` events along the way. `format` defaults to `'mp3'`. Rejects `ERR_PLAUD_ARGS` (bad sessionId) or `ERR_PLAUD_EXPORT`. |

`PlaudAudioFormat = 'pcm' | 'mp3' | 'wav' | 'opus'`.

`exportAudio` returns a raw path; prefix with `file://` if not already present before handing
it to `expo-file-system` / `fetch`.

## Events

Subscribe with `PlaudSdk.addListener(name, cb)`, which returns `{ remove() }`. Always remove on
unmount. `addListener`/`removeListener`/`removeAllListeners` come from the Expo `NativeModule`
base and are fully typed.

| Event | Payload | When |
| --- | --- | --- |
| `scanResult` | `{ devices: PlaudScanDevice[] }` | Devices discovered during a scan (may fire repeatedly with a growing list). |
| `scanTimeout` | `{ reason?: string }` | Scan window ended, or BLE never powered on (`reason: 'bluetoothNotPoweredOn'`). |
| `connectState` | `{ connected: boolean; failed: boolean; state: number }` | Connection state changed. `state`: `1`=connected, `0`=disconnected, `{2,-1,-2}`=failure (`failed: true`). |
| `penState` | `PlaudPenState` | Device status snapshot (privacy, key state, uDisk, tokens). |
| `bind` | `{ sn: string \| null; status: number; protVersion: number }` | Bind/pairing handshake result. |
| `fileList` | `{ files: PlaudFile[] }` | Response to `getFileList`. |
| `exportProgress` | `{ sessionId: number; progress: number; message: string }` | Progress during `exportAudio`. |
| `recordStart` | `PlaudRecordStart` | Device-initiated recording started (physical button / VAD). |
| `recordStop` | `PlaudRecordStop` | Device-initiated recording stopped; includes resulting file info. |
| `recordPause` | `PlaudRecordStop` | Recording paused. |
| `recordResume` | `PlaudRecordResume` | Recording resumed. |
| `depair` | `{ status: number }` | Unpair completed — reset all local device state here. |

`recordStart`/`recordStop`/etc. are **device-initiated** (the user pressed the button on the
recorder). Refresh the file list on `recordStop` to pick up the new recording.

## Types

```ts
interface PlaudScanDevice {
name: string;
uuid: string; // CoreBluetooth peripheral id — use this to connect
serialNumber: string;
rssi: number;
supportWiFi: boolean;
}

interface PlaudConnectState {
connected: boolean;
failed: boolean; // true for handshake failure (state 2/-1/-2), not a normal disconnect
state: number;
}

interface PlaudPenState {
state: number; privacy: number; keyState: number; uDisk: number;
findMyToken: number; hasSndpKey: number; deviceAccessToken: number;
}

interface PlaudFile {
sn: string;
sessionId: number; // identifies the recording for exportAudio
size: number; // bytes
scenes: number;
channels: number;
isOgg: boolean;
isMusic: boolean;
duration: number; // seconds
}

interface PlaudExportProgress { sessionId: number; progress: number; message: string; }

interface PlaudRecordStart {
sessionId: number; start: number; status: number;
scene: number; startTime: number; reason: number;
}

interface PlaudRecordStop {
sessionId: number; reason: number; fileExist: boolean; fileSize: number;
}

interface PlaudRecordResume {
sessionId: number; start: number; status: number; scene: number; startTime: number;
}
```

## Canonical scan → connect → list → export flow

```ts
PlaudSdk.addListener('scanResult', ({ devices }) => setDevices(devices));
PlaudSdk.addListener('connectState', ({ connected, failed }) => {
if (connected) PlaudSdk.getFileList(); // load recordings on connect
else if (failed) showError('Connection failed — move closer and retry');
});
PlaudSdk.addListener('fileList', ({ files }) => setFiles(files));

await PlaudSdk.startScan();
// user picks a device:
await PlaudSdk.connectBleDevice({ uuid: device.uuid });
// user picks a file:
const { outputPath } = await PlaudSdk.exportAudio({ sessionId: file.sessionId, format: 'mp3' });
```

See `react-native-demo/src/app/index.tsx` for the full stateful version.
Loading
Loading