Short-form institutional video — record and edit on-device, upload to your own server.
Capture knowledge before it walks out the door — training walkthroughs, process demos, handoffs — without sending a single frame to a third-party cloud.
Pulse is a React Native (Expo) app for capturing institutional knowledge as short-form video. Everything happens on the device: segmented recording, trimming, merging, and even speech-to-text captioning run locally. When you're ready to publish, Pulse uploads to a PulseVault server you run — your organization keeps the content on its own infrastructure and owns auth, retention, and quota decisions. No central Pulse service exists.
Two words show up throughout Pulse — here's what they mean.
A Pulse is a single short-form video, assembled on-device from an ordered set of clips. Record a few, import a few, reorder them — on export they merge (losslessly where formats match) into one .mp4. Publishing a Pulse sends a small bundle: the merged video, its captions (WebVTT), a beat manifest, and a thumbnail. On-device each Pulse is a draft in your library; drafts move between devices as .pulse bundles.
flowchart LR
subgraph pulse["🫀 One Pulse — an ordered set of clips"]
direction LR
c0["Clip 0"] --> c1["Clip 1"] --> c2["Clip 2"]
end
pulse ==>|"merge on export"| merged["Merged .mp4"]
merged --> vtt["Captions · .vtt"]
merged --> man["Beat manifest"]
merged --> thumb["Thumbnail"]
A Beat is a timestamp range inside the Pulse's one video: where one recorded clip starts and ends — { segmentId, order, startMs, endMs }. The beat manifest records every clip's start/end so any moment in the video can be traced back to the segment it came from (and later deep-linked — e.g. HLS chapter jumps). Beats are contiguous — each beat's endMs equals the next beat's startMs — and sum exactly to the Pulse's true duration.
flowchart TB
subgraph clips["Recorded clips, in order"]
direction LR
s0["Clip 0"] --> s1["Clip 1"] --> s2["Clip 2"]
end
subgraph tl["One video · 0 → durationMs"]
direction LR
b0["Beat 0<br/>0 – 4200 ms"] --- b1["Beat 1<br/>4200 – 9100 ms"] --- b2["Beat 2<br/>9100 – 15000 ms"]
end
s0 -.-> b0
s1 -.-> b1
s2 -.-> b2
- 🎬 Segmented recording — build a walkthrough from multiple clips, reorder them, re-record the ones you fumbled
- ✂️ Non-compounding edits — trims, crops, rotations and speed are stored as settings over the pristine original and rendered once at export, so re-editing never stacks generation loss
- 🗣️ On-device captions — Whisper (whisper.cpp) transcription with word-level timing, no audio ever leaves the phone
- 📡 Resumable uploads — TUS v1 protocol; a two-minute capture survives signal drops, app kills, and relaunches
- 🔐 Self-hosted by design — pair with your server via QR / deep link; capability tokens live in the device keychain
- 📦 Local-first drafts — SQLite-backed library that works fully offline, shareable device-to-device as
.pulsebundles
- Multi-clip segmented recording with VisionCamera v5 — segments append to one draft with a live segment bar and drag-to-reorder
- Front/back flip, torch, mic mute, tap-to-focus, pinch-to-zoom, lens selection, and stabilization levels (off / on / high / auto), all persisted across sessions
- Call-aware capture: a custom native module (audio-interruption detection on iOS, audio-mode detection on Android) gates the microphone when the audio session is interrupted so recordings don't silently freeze
- Import existing videos from Photos instead of recording
- Per-clip trim and transform via FFmpeg — destructive but safe: re-editing always reopens the untouched original
- Smart merge on export: clips with matching formats are concatenated losslessly (passthrough); mixed resolution/fps/codec/orientation drafts conform only the outlier clips, with a full re-encode fallback
- Save to Photos or Files, share anywhere, or upload — with a live progress ring
- Fully on-device speech-to-text with whisper.rn — Parakeet for English, or a multilingual Whisper (Large Turbo on iOS, Small on Android), downloaded from the editor's Captions tool
- Each clip is transcribed once, in the background; the draft's captions are composed from the clips through their trims, speed, mutes and order, so an edit never re-runs the model
- Silero VAD pre-gate skips silent clips and suppresses Whisper's hallucinated filler on empty audio
- Word-level timestamps reflowed into broadcast-style cues (line-length, duration, and sentence-boundary aware; Chinese, Japanese and Thai keep their per-character timing), never across a cut
- Captions live in the editor: shown over the preview and on a lane under the clips; tap a word to edit or delete it, split or merge sentences at the playhead, drag the caption to move them all — edits are kept per clip and undoable
- Captions upload as WebVTT with word-level cue timestamps (karaoke-ready) and their position, alongside the video
- Local-first draft library on Drizzle ORM + SQLite — create, rename, delete, with live-updating thumbnails
- Device-to-device draft sharing as
.pulsebundles (zip + manifest, with unpack safety limits) - First-run onboarding tour
- Pair with a server by scanning a QR / opening a
pulsecam://deep link — trust-on-first-use confirmation, capability negotiation against the server's/capabilitiesendpoint - TUS v1 uploads that retry with exponential backoff, resuming from the server's true byte offset; they carry on in the background, and a link is used once — if an upload can't finish (or the app is killed), it's cleaned up and a new link is the retry
- A link can say which place in the host app its upload is for — a ticket, a team's update (protocol 2.7). Pulse then opens the recorder on the draft already started for that place, or on a new one named by the link, preselects the place when you upload, and offers the way back to the host once the video is in. The recorder opens once the server has answered (a toast says it is being asked, a few seconds at most), so a first link needs a connection; a link the place already holds opens its recorder at once. Until a draft uses it, a place has one link on the device: a newer one the server accepted replaces it, an expired one is refused. The link is still one upload — after a failed or cancelled one, a new link for the place comes back to the same draft
- Which place a link is for is read from its token, which Pulse can't verify: only a link the server has accepted replaces the one a place already has. What a link calls the place, the recording and the host app is unsigned text: shown as one clean line, never deciding where a video goes, and always beside the server's host. The way back opens only on a tap, and its button names the host it leads to
- A Pulse uploads as one video, plus its captions (WebVTT), beat manifest and thumbnail — each declaring
relatedTothe video - A finished upload's Watch and Copy link live in the draft's ⋯ menu on Home. Copy link shares a read-only view link (servers on protocol 2.2+), never the upload token
- Bearer tokens stored in the secure keychain, never in the database
Pulse runs on iOS and Android from a single codebase. iOS is the original platform; Android has been brought to parity and verified end-to-end on device — recording, editing, merge/export, on-device captions, .pulse sharing, pairing, and resumable uploads (including upload survival through backgrounding and Doze via a foreground service).
Platform notes:
- High (cinematic) stabilization is iOS-only (VisionCamera exposes it via AVFoundation); Android offers off / on / auto
- Whisper transcription uses Metal GPU acceleration on iOS; Android runs on CPU, so larger models are noticeably slower — Base (en) is the recommended starting point
- Call-aware capture uses AVAudioSession interruptions on iOS (no CallKit — it must stay out of the binary for China App Store distribution) and an audio-mode heuristic on Android (no extra permissions), so both platforms pause the mic when the session is interrupted
- On Android, merged exports save through MediaStore into
DCIM/, and uploads run as adataSyncforeground service with a progress notification
┌─────────────────────────── on device ───────────────────────────┐
│ │
│ Record (VisionCamera) ─▶ Trim/Merge (FFmpeg) ─▶ Draft (SQLite) │
│ │ │
│ Whisper + VAD ─▶ captions (VTT) │
│ │
└──────────────────────────────┬───────────────────────────────────┘
│ pair via pulsecam:// QR / deep link
▼
TUS v1 resumable upload (video + VTT)
│
▼
┌──────────────────────────────┐
│ PulseVault (self-hosted) │
│ your auth · your storage │
└──────────────────────────────┘
The server side lives in pulsevault-mieweb/: a Fastify plugin (with a framework-agnostic core for Express, Meteor, or plain Node http) that receives Pulse uploads into filesystem-first storage, with authorize / validatePayload / onUploadComplete hooks for wiring in your SSO, audit trail, transcoding, or AI pipeline. The wire contract is documented in PROTOCOL.md — an implementation-independent spec (capability discovery, artifact kinds, tokens, TUS) so anyone can build a compatible server.
Git LFS required. The dev-seed video fixtures in
assets/dev/*.mp4are stored in Git LFS. Install it before cloning (or pull after), otherwise those files arrive as tiny pointer stubs and the dev+ seedbutton breaks:brew install git-lfs && git lfs install # one-time git lfs pull # if you cloned before installingThe ~400 MB fixture-regen master (
fixtures/bbb_master.mov) is intentionally excluded from normal clones (see.lfsconfig); fetch it only when regenerating fixtures:git lfs pull --include "fixtures/*.mov".
Requires Node.js ≥ 24.3.0 (the engines field in package.json; CI uses Node 24).
# 1. Clone with the PulseVault submodule (or, in an existing clone: git submodule update --init)
git clone --recurse-submodules https://github.com/mieweb/pulse.git && cd pulse
# 2. Install dependencies
npm install
# 3. Run a development build (native modules — Expo Go won't work)
npm run ios # or: npm run androidPulse uses native modules (VisionCamera, Whisper, FFmpeg), so it needs a dev build, not Expo Go.
The pulsevault-mieweb/ submodule pins the server this app is built against. It holds the protocol JSON Schemas that src/features/upload/protocol.gen.ts is generated from, and it is the server used by the integration suite (see Testing).
No camera? No problem. In a dev build, the Home screen has + seed / clear buttons that create a "Dev sample" draft from the bundled assets/dev/ clips — including deliberately mismatched resolution/fps/codec/orientation clips that exercise the export-normalization path — so the full editor is drivable on a simulator. See assets/dev/README.md.
| Command | What it does |
|---|---|
npm start |
Start the Metro dev server |
npm run ios / npm run android |
Build & run the dev client |
npm test |
Run the Jest unit-test suite |
npm run lint |
ESLint via expo lint |
npx tsc --noEmit |
Typecheck |
npm run format / npm run format:check |
Prettier (write / check only) |
node scripts/gen-protocol-types.mjs |
Regenerate src/features/upload/protocol.gen.ts from the submodule's schemas and package.json pulseProtocol (--check fails if it's stale) |
CI (.github/workflows/ci.yml) runs the typecheck, lint, gen-protocol-types.mjs --check, and unit tests on every PR, plus the integration suite against both the pinned PulseVault and the latest npm release.
src/
├── app/ # expo-router screens: home, recorder, editor, export, onboarding
├── features/ # recorder, transcription, export, upload, draft-transfer, home, …
├── db/ # Drizzle ORM schema + queries (expo-sqlite)
├── components/ # shared themed UI
└── dev/ # __DEV__-only seed tooling (dead-code-eliminated in release)
modules/ # custom native modules (expo-call-detector)
plugins/ # Expo config plugins
pulsevault-mieweb/ # self-hosted upload server + protocol spec
fixtures/ # fixture-regen sources (Big Buck Bunny, CC-BY)
Unit tests are co-located with the code and scoped to pure logic — cue reflow, upload/TUS state machines, deep-link parsing, edit history, autosave gating — so they run in a plain Node environment with no native rendering. PulseVault ships its own suite covering the HTTP adapters, capability tokens, and checksum handling.
npm testA heavier suite is opt-in and skips itself in a plain npm test:
# Cross-repo integration: the app's real pairing + upload code against a real PulseVault
# server built from the submodule (set PV_CORE=<path to core.js> to test another build)
(cd pulsevault-mieweb && npm ci && npm run build)
PULSE_INTEGRATION=1 npx jest pv-integration --forceExitSee LICENSE.
Made with ❤️ at MIE — built on React Native, Expo, whisper.cpp, and FFmpeg.