Skip to content

[DRAFT] Record wave level detection merge after #158#184

Draft
fel-cesar wants to merge 60 commits into
mainfrom
fel-cesar/feature/96-record-engine-expo-audio-waveform
Draft

[DRAFT] Record wave level detection merge after #158#184
fel-cesar wants to merge 60 commits into
mainfrom
fel-cesar/feature/96-record-engine-expo-audio-waveform

Conversation

@fel-cesar

@fel-cesar fel-cesar commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

TLDR

Replaces the interim placeholder waveform with a real live-recording waveform driven by expo-audio input metering. Bar heights are data-driven from normalized dBFS samples (no synthetic animation, no native code), per the tech-lead guidance that #96 is a component + hook change — not a native module like the #176 remux. Review-state playback progress is unchanged and stays out of scope here.

Stacked on #158 (#49 Record tab). Base shows against main but the diff includes the not-yet-merged #49 work — merge after #158 (or onto main once #158 lands). Review the commits prefixed [#96] for the scope of this PR.

⚠️ Overlaps with #178. Both this PR and #178 touch RecordingWaveform.tsx. If #178 merges first, this feature will need a rebase and a small re-apply of the live-bar rendering onto its waveform — low risk, but expected.

Reviewer checklist

  • GitHub issue linked in Details (Closes #96)
  • How to verify steps completed or valid waiver noted below
  • Acceptance criteria met — code-complete; device-QA AC pending (see How to verify)
  • Scope limited to this issue — only [#96] commits; capture/resume format unchanged, no native module added
  • Android device tested (mic + metering) — not yet verified on a device

Details

Closes #96

The live waveform previously animated a synthetic sine keyed off elapsed time, so it did not reflect the actual audio being captured. This wires it to real input levels: expo-audio metering is enabled on the recorder, sampled on the existing recording tick, normalized from dBFS to a 0..1 range, and kept in a bounded ring buffer (one sample per bar, newest last) that resets per take and freezes on pause. RecordingWaveform renders those levels as right-aligned bars.

Per the tech lead: waveform rendering needs no platform APIs, so this is intentionally JS-only (a hook + a component) — unlike the seekable-remux work in #176. The rationale for rejecting a third-party native waveform module is captured in a short decision doc.

Type of change:

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Maintenance / refactor

Technical changes

  • src/hooks/useRecorder.ts — enable isMeteringEnabled; add dbfsToLevel() (dBFS → 0..1, floor -60 dB, missing/invalid → silence) and METERING_SAMPLE_CAP; sample recorder.getStatus().metering on the tick into a bounded meteringLevels buffer, reset on new take, frozen on pause; expose meteringLevels on the recorder API.
  • src/app/tabs/drafting/record/components/RecordingWaveform.tsx — drive live bar heights from a levels prop (right-aligned, newest sample rightmost) instead of the elapsed-time sine; more and thinner bars, and a silent sample renders as a small round dot rather than a flat stub.
  • src/app/tabs/drafting/record/RecordTab.tsx — pass recorder.meteringLevels into RecordingWaveform.
  • src/test/mocks/expo-audio.ts — add optional metering to the recorder status mock.
  • docs/guides/waveform-decision.md — record why the waveform is rendered in React from expo-audio data rather than a native waveform dependency, and how live metering differs from review-playback progress.

Testing

  • npm run format:check, npm run lint, npm run typecheck — all clean.
  • npm test -- --ci on the affected suites (useRecorder, RecordingWaveform) — 47 passing. New coverage: metering enabled on the recorder, bounded/freezing/reset buffer behavior, dbfsToLevel normalization + clamping, and data-driven right-aligned bar heights / dim-on-pause / dot-at-silence.

How to verify

  1. npm run format:check && npm run lint && npm run typecheck && npm test -- --ci
  2. npm run prebuild && npm run android
  3. On device: start recording and speak at varying volume — bars should track loudness in real time; silence should collapse bars to small dots. Pause and confirm the waveform freezes; re-record/start a new take and confirm it resets to empty.

Expected: the live waveform reflects real input level (not a canned animation), silence reads as dots, pause freezes the trace, and a new take starts clean. Capture/resume format is unchanged (still ADTS AAC).

Follow-ups

fel-cesar and others added 30 commits July 2, 2026 09:08
Install expo-audio (~56.0.12) for microphone capture and playback,
and expo-crypto (~56.0.4) for RFC 4122 v4 IDs used as recordings.id.
Register expo-audio as a config plugin with a Fluent-specific
microphone permission string so prebuild injects RECORD_AUDIO and
MODIFY_AUDIO_SETTINGS into the Android manifest.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Add Recording / RecordingRow types and RecordingSyncStatus union so the
existing recordings schema is typed end-to-end.

Introduce repository.insertRecording that atomically demotes the prior
is_latest row for a bible_text_id, increments take_number, and inserts
the new take, plus deleteRecordingById for the delete flow. Add
queries.getBibleTextId to resolve verse coordinates to bible_texts.id
and queries.getLatestRecordingForVerse for the Record tab review view.

Cover both modules with unit tests over a mocked op-sqlite handle.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Add PausedTakeMarker and get/set/clearPausedTake helpers over the
op-sqlite KV store so a paused take (verse, temp file URI, elapsed ms,
started-at) survives app backgrounding or crash and can be resumed or
recovered on next launch.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the ViewChapter stub with a DraftingPage shell that hosts the
selected verse, a disabled ChapterAudioPlayerBar placeholder for the
upcoming #47 player, and a DraftingTabBar switching between a Bible
placeholder and the new RecordTab.

Add useRecorder hook implementing the Idle / Recording / Paused /
Review state machine on top of expo-audio: request and track
microphone permission (unknown | granted | denied | blocked), capture
takes into a temp file, insert them via repository.insertRecording,
mint recording IDs with expo-crypto randomUUID, and persist a paused-
take marker via the storage KV so pauses survive backgrounding.

RecordTab renders verse chevrons, the source-text accordion, per-state
controls (start, pause, resume, stop, re-record, delete, playback), a
duration counter, a delete confirmation, and a navigate-away prompt.
When permission is blocked the tab opens system settings via
Linking.openSettings, otherwise it re-requests through the OS dialog.

Point AppNavigator.VerseDetail at DraftingPage and delete the legacy
ViewChapter screen. Cover the hook and screen with Jest tests using
the LoginScreen mocking pattern.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Point the onboarding map and project-overview rule at
drafting/DraftingPage and drafting/RecordTab. Mark the recordings-
table wiring TODO as done and record the remaining follow-ups
(#47 chapter audio player, upload/sync).

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Split the pill-shaped record CTA into an 88px red circle carrying just
the target icon, a centered verse-reference label below it, and a
disabled play-button placeholder that hints at future draft playback.
Add a dedicated recordAccent theme token so the red is not overloaded
with destructive semantics. Update the RecordTab smoke test for the
new record-start-label and record-play-idle-placeholder test IDs.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Swap the recording controls so the muted stop circle sits on the left
and the prominent red pause circle on the right, mirror the same
layout for paused (stop + resume), and lift the duration counter above
the buttons in muted tabular-nums. Retune the live waveform to a
narrower, taller red band with 22 rounded bars and split the bar style
into live (red) and static (blue) so the review waveform keeps its
own accent.

Add per-state coaching tips under the buttons — "Tap pause to study
the source, stop to finish." while recording, and "Recording paused —
review the source below, then resume." while paused.

Rework the review state to mirror the idle affordance: a muted
record-done placeholder next to a prominent blue play circle, with
Re-record and Delete moved into labeled rounded rectangles (light card
fill and destructive-outlined) using a shared placeholder-circle
style with the idle play hint.

Extend RecordTab.test.tsx to cover the recording tip, the paused-tip
wording, and the new review-state controls.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Compose prevDisabled / nextDisabled from the existing edge flags plus a
recording-active check so the prev/next chevrons cannot navigate away
mid-take. Paused, idle, and review keep their existing guards (paused
still prompts Resume/Discard via withPausedGuard). The muted foreground
color and reduced-opacity icon style follow the disabled state so the
lock is visually obvious.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Recorder previously polled recorder.currentTime which keeps advancing on
some devices while the native pipeline is paused, so elapsedMs inflated
past a Resume by the length of every prior pause. Replace it with
wall-clock timing: baseElapsedRef accumulates completed segments,
runningSinceRef marks the start of the current active segment, and each
tick reports base + (now - runningSince). Pause commits the segment
before native pause, resume opens a new segment, and stop derives the
final duration from the same refs so persistence matches the display.

Drop the tick interval to 50 ms so the readout is smooth at
centisecond precision, and format duration as MM:SS:HH with zero-padded
minutes so verses over a minute align in the UI.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Switch the verse-nav row to justifyContent: 'space-around' so the
chevrons and reference sit with balanced margins instead of hugging the
edges. Delete the two commented-out waveform style lines that were left
behind during recording-state polish.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Split the player concern out of useRecorder into useDraftPlayback, which
takes only the take's file URI and owns the playback-side audio routing
and rewind-on-finish. useRecorder composes it with a one-way dependency
and stops playback explicitly before re-record/delete, keeping its public
API unchanged.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Hook the review play button up to togglePlayback with a play/pause
affordance, and stop playback when navigating between verses.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Recordings need to move out of the evictable cache into the document
directory and be read back at absolute paths, which requires the
expo-file-system File/Directory/Paths APIs.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce recordingStorage with a user/project/book/chapter/verse key
layout rooted at the document directory. Persist only relative keys and
resolve absolute uris at read time so paths survive reinstalls, plus
best-effort move/delete helpers for takes and per-user cleanup.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Add user_id and chapter_assignment_id columns (with migrations for
existing DBs), thread them through the Recording types, inserts and
verse queries, and resolve a project id for a project unit to build
storage keys. deleteRecordingById now also unlinks the durable file.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Accept optional attribution context (user/project/book/chapter/verse)
via a ref, move each committed take out of the cache into its durable
key before inserting the row so a record never points at an evictable
file, and resolve relative keys to absolute uris for playback.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Resolve the active user and owning project id in DraftingPage and pass
them, along with the chapter assignment and book code, through RecordTab
into useRecorder so committed takes are stored under the correct key.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Configure useAudioRecorder to write into the document directory instead
of the evictable cache so paused or backgrounded partial takes survive a
process kill until they are moved or deleted. On discardPaused, read the
persisted paused-take marker (recorder.uri is unreliable after a kill)
and unlink the durable partial file before clearing state.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Split useRecorder into a use-case-agnostic state machine that owns only
recording mechanics (transitions, elapsed ticking, permissions,
background auto-pause, playback) and injects persistence, storage layout,
and domain identity through a RecorderAdapter. Move all verse-specific
SQLite, durable-storage, and paused-marker wiring into a new
useVerseRecorder adapter and point RecordTab at it, so the recorder can
be reused for other capture use cases without dragging DB concerns along.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Read the committed take's playback uri via adapterRef.current instead of
the adapter prop, matching every other adapter call in useRecorder. This
keeps the ref indirection consistent so the hook never depends on a fresh
adapter object reference when handing a uri to useDraftPlayback.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
The recorder loads its state from the local DB asynchronously, so the tab
briefly rendered the idle "Record" button before snapping to review when an
existing take resolved. Hold the record/review UI back until the recorder is
ready (capped at 100ms so a slow load still surfaces), showing a
height-reserved placeholder meanwhile so the layout does not shift.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Split the monolithic Record tab into components, hooks, and utils under
drafting/record and colocate useVerseRecorder with its screen.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Reject corrupted paused-take markers unless bibleTextId is a finite
number matching the KV lookup key.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Precompute static bar heights in StyleSheet and split live active/paused
styles so react-native/no-inline-styles lint passes cleanly.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Install SDK 56-aligned expo-asset and register its config plugin so
expo-doctor passes and native prebuild deduplicates expo-asset/constants.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Reset bibleTextId to null synchronously when chapterData or
selectedVerse changes so RecordTab cannot act on a stale id while
getBibleTextId is in flight.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Wrap discardPaused in try/catch so a failed discard does not leave
verse switches or navigation blocked without user feedback.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Extend the Record tab beforeRemove listener to cover recording state,
not just paused, so users must discard the take before leaving.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
If moveIntoStore succeeds but the DB insert rejects, remove the moved
file before returning null so commit failures stay recoverable without
leaving orphaned audio on disk.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Await recorder pause/resume/stop and surface user-visible alerts when
native recording operations reject, matching discardPaused error handling.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
fel-cesar and others added 27 commits July 3, 2026 01:56
Keep RecordTab mounted while switching Bible/Record tabs, prompt before
leaving an in-progress take, use shared enums for tab/status literals,
and delete finalized audio files when discarding straight from Recording.

Refs #49

Co-authored-by: Cursor <cursoragent@cursor.com>
Surface the otherwise-silent dead record button when the selected
verse hasn't resolved a bible_text id (sync gap / unstable backend
contract). Temporary stopgap until the team settles the session-key /
bible_text_id approach once sync is stable.

Refs #49
expo-audio's native module auto-resumes a recorder it paused on
background, which left our JS state machine out of sync and made Resume
double-start the recorder (IllegalStateException). Undo the native
auto-resume on foreground and guard resume() against an already-running
recorder so takes reliably continue where they left off.
Record verse drafts as ADTS AAC and model a take as an ordered list of
segments so an in-progress recording survives a process kill. A .m4a
partial is unplayable until stop() writes its moov atom; ADTS is
self-framing, so a killed segment stays playable and segments merge by
byte append.

On relaunch the persisted marker rehydrates the take as a recoverable,
resumable Paused state: Resume opens a new appended segment preserving
elapsed time, Stop commits the existing segments straight from the
recovery prompt, and Discard unlinks every segment. On commit the
segments are concatenated, moved into durable storage, and the raw
parts unlinked. The paused marker stores a segments list (with legacy
single-fileUri coercion).

Refs #49
Explain why .m4a partials are unrecoverable, the ADTS AAC segmented
model, the rehydrate/resume/stop/discard lifecycle, and the caveats
(absolute-URI segments, on-device validation, .aac downstream).

Refs #49
Write the paused-take manifest the moment recording starts (and refresh
it on pause), not only on pause/background. A hard task-swipe kill can
destroy the process before the background auto-pause runs, so a
pause-only marker never existed for the very case recovery targets.

Capture the verse's navigation context (chapterAssignmentId,
verseNumber) on the marker so a recovered take can be surfaced and
navigated to without a reverse lookup, and add findPausedTake() to fetch
the single outstanding marker for the home prompt.

Refs #49
Add getVerseDetailNavByChapterAssignment() so a recovered take, which
only carries its chapterAssignmentId, can rebuild the VerseDetail route
params (chapter id/name, project name, language) needed to navigate.

Refs #49
On the home screen (after sync) surface any recording recovered after a
process kill and force a decision: Continue navigates to that verse's
Record tab — landing on the recovered verse via a new recoverVerse route
param, where the paused take rehydrates — or Discard deletes the partial
segments. The prompt is skipped when the take can't be resolved to a
verse.

Refs #49
Update the kill-resilience doc for the home recovery prompt
(findPausedTake, Continue/Discard) and the marker-at-start persistence,
and note the timer-vs-audio duration trade-off on kill.

Refs #49
The committed durationMs came from the wall-clock timer, which
undercounts the real audio length (worst after a hard kill, where a
Stop-from-prompt could commit elapsedMs = 0). Parse the merged file's
ADTS frames to get the sample-accurate length and use it as the source
of truth, falling back to the timer only when the probe yields nothing.
Also surface the persisted duration in the Review timer via an optional
resolveDurationMs adapter hook.

Refs #49
Wrap the merged-file creation and byte-append loop in concatenateAacSegments
so a mid-loop bytes()/write() failure best-effort deletes the orphaned
temporary merge file before rethrowing, instead of leaking it in the
document directory.

Refs #49
Update expo, expo-asset, and expo-build-properties to their latest
56.0.x patches (with transitive bumps to @expo/* tooling,
expo-modules-core, and iconv-lite) to keep the SDK aligned.

Refs #49
Support seeking during review: expose position/duration and a seek()
on useDraftPlayback, and make the decorative review waveform tappable
and draggable to seek (no new dependency; real amplitude stays deferred
to #96). Group the recorder's playback surface into a nested
`playback` sub-object so it reads as a concern the recorder only
coordinates rather than one it owns.

Refs #49
Rename useDraftPlayback to useAudioPlayback (and its API type) so the
single-source player reads as reusable for any clip — draft review now,
source/original verse audio later — rather than being draft-specific.
No behavior change; the recorder still coordinates it via its gated
playback sub-object.

Refs #49
Drop the duplicate RecorderPlaybackApi interface — it was structurally
identical to the hook's type with a single use site — and type
recorder.playback as UseAudioPlaybackApi directly.

Refs #49
Revert the review waveform to a non-interactive decorative bar: drop the
touch-responder seek handlers, position/duration-driven progress fill, and
the props that fed them. The underlying useAudioPlayback seek/position/
duration capability stays on the recorder's playback surface for later use.

Refs #49
DraftingProvider seeded selectedVerse from initialVerse only on mount, so
a later prop change (e.g. recording recovery) would not update the
selection if the provider stayed mounted. Sync selectedVerse when
initialVerse changes so recovery no longer depends on the loading-gated
remount. Add a provider test covering the re-point behavior.
…-playback

# Conflicts:
#	src/app/context/DraftingContext.tsx
#	src/app/tabs/BibleTab.tsx
Enable isMeteringEnabled on the expo-audio recorder and sample the input
level on the existing tick, normalizing dBFS to a 0..1 range and keeping
a bounded ring buffer (newest last) that resets per take and freezes on
pause. Exposed as meteringLevels for the waveform to render. No native
code — metering is provided by expo-audio.

Refs #96
Drive RecordingWaveform bar heights from the recorder's meteringLevels
instead of a synthetic sine, right-aligned so the newest sample is the
rightmost bar. Use more, thinner bars and render a silent sample as a
small round dot rather than a flat stub.

Refs #96
Record why the waveform is rendered in React from expo-audio data
rather than a third-party native waveform module, and how live metering
differs from review playback progress.

Refs #96
@fel-cesar fel-cesar changed the title [DRAFT] Record wave level detection [DRAFT] Record wave level detection merge after #158 Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Playback engine on expo-audio + waveform decision

1 participant