Add audio safety WAL indicator + auto-sync to live capture#6294
Conversation
… queries Filters WALs by session time window and miss status, enabling the live capture screen to show only current-session unsynced WALs. Closes partially #6293 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rovider Tracks session start time when WebSocket connects, exposes unsyncedSessionWals for the UI indicator, and auto-syncs missed WALs to the created conversation after processing completes. Closes partially #6293 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Shows a subtle widget at the bottom of the transcript timeline when unsynced WALs exist in the current session, reassuring users their audio bytes are saved locally and will sync automatically. Closes partially #6293 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds conversation_id query param to v1/v2 sync-local-files endpoints and threads it through background processing to process_segment, allowing auto-synced WALs to attach to the correct conversation instead of creating orphan conversations. Closes partially #6293 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Passes optional conversation_id query parameter when syncing local files, so auto-synced WALs from live capture attach to the correct conversation. Closes #6293 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds two related user-facing improvements: a live WAL safety indicator that shows how many seconds of audio are saved locally but not yet synced, and an auto-sync mechanism that attaches those missed WALs to the correct conversation when processing completes — preventing orphan conversations from WebSocket drops during recording. Key changes:
Issues found:
Confidence Score: 3/5Not safe to merge as-is: the auto-sync path has a persistence bug that will cause WAL re-uploads on restart, and user-facing strings are not localized. The backend changes are clean and the new
Important Files Changed
Sequence DiagramsequenceDiagram
participant App as Flutter App
participant CP as CaptureProvider
participant LWSI as LocalWalSyncImpl
participant API as Backend (sync.py)
participant DB as Firestore
App->>CP: WebSocket connects
CP->>CP: _sessionStartSeconds = now()
loop Audio capture
CP->>LWSI: onFrameCaptured(frame)
LWSI->>LWSI: chunk + flush → Wal(status=miss)
end
App->>CP: unsyncedSessionWals (getter)
CP->>LWSI: getSessionUnsyncedWals(_sessionStartSeconds)
LWSI-->>CP: [Wal(miss), ...]
CP-->>App: renders WAL indicator
App->>CP: forceProcessingCurrentConversation()
CP->>CP: sessionStart = _sessionStartSeconds
CP->>CP: _resetStateVariables()
CP->>API: processInProgressConversation()
API-->>CP: result (conversation.id)
CP->>CP: _autoSyncSessionWals(sessionStart, conversationId)
CP->>LWSI: getSessionUnsyncedWals(sessionStart)
LWSI-->>CP: [Wal(miss), ...]
loop For each missed WAL
CP->>API: POST /v2/sync-local-files?conversation_id=...
API->>DB: get_conversation(uid, conversation_id)
DB-->>API: conversation
API->>API: process_segment → attach segments
API-->>CP: 202 accepted
CP->>CP: wal.status = WalStatus.synced ⚠️ not persisted
end
Reviews (1): Last reviewed commit: "Add conversationId param to syncLocalFil..." | Re-trigger Greptile |
| final file = File(fullPath); | ||
| if (!file.existsSync()) continue; | ||
| await syncLocalFilesV2([file], conversationId: conversationId); | ||
| wal.status = WalStatus.synced; |
There was a problem hiding this comment.
WAL status change not persisted to disk
After setting wal.status = WalStatus.synced, _saveWalsToFile() is never called. The change lives only in memory, so if the app is killed or restarted before the background WAL sweeper runs, the same files will be re-uploaded during the next regular sync. Every other sync path in LocalWalSyncImpl pairs a status change with _saveWalsToFile() followed by listener.onWalUpdated() — this one does not.
Concrete risk: on the next app start _initializeWals() reloads from disk, the WALs are still WalStatus.miss, and syncAll() re-submits them to the backend. The dedup guard in process_segment prevents duplicate transcript segments from being written, but the upload, VAD, and Deepgram costs are incurred again.
The fix requires calling back into LocalWalSyncImpl. Easiest approach: expose a small persistence helper on LocalWalSyncImpl (or call deleteAllSyncedWals after marking), or delegate the whole save through the existing syncWal API so the lifecycle is managed in one place.
| children: [ | ||
| const Icon(Icons.lock_outline, size: 14, color: Color(0xFF8B8B9E)), | ||
| const SizedBox(width: 6), | ||
| Text( | ||
| '$label audio saved locally', | ||
| style: const TextStyle(color: Color(0xFF8B8B9E), fontSize: 12, height: 1.3), | ||
| ), | ||
| const SizedBox(width: 6), | ||
| const Text('·', style: TextStyle(color: Color(0xFF8B8B9E), fontSize: 12)), | ||
| const SizedBox(width: 6), | ||
| const Text( | ||
| 'will sync automatically', | ||
| style: TextStyle(color: Color(0xFF6B6B7E), fontSize: 11, height: 1.3), | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| String _getTimeoutDisplayText(BuildContext context) { |
There was a problem hiding this comment.
Hardcoded user-facing strings violate l10n requirement
The indicator renders two English literals directly:
'$label audio saved locally'
'will sync automatically'Per the project's localization rules (and matching the pattern used everywhere else in this file, e.g. context.l10n.waitingForTranscriptOrPhotos), all user-facing strings must be looked up via context.l10n.keyName. Hardcoded strings won't be translated for the 33 non-English locales the app supports.
These strings need to be added to app/lib/l10n/app_en.arb (and all locale ARBs) and then referenced as context.l10n.audioSavedLocally(label) / context.l10n.willSyncAutomatically (or similar key names).
Context Used: Flutter localization - all user-facing strings mus... (source)
| try { | ||
| // Step 1: Submit files | ||
| var url = '${Env.apiBaseUrl}v2/sync-local-files'; | ||
| if (conversationId != null) { | ||
| url += '?conversation_id=$conversationId'; | ||
| } |
There was a problem hiding this comment.
conversation_id is not URL-encoded
The ID is concatenated directly into the URL string:
url += '?conversation_id=$conversationId';Firestore document IDs are alphanumeric, so this is unlikely to cause a production problem today. However, if the ID format ever changes (e.g. includes a -, +, or %), the resulting URL could be malformed and the backend would receive a garbled or missing value. Use Uri.encodeQueryComponent to be safe:
| try { | |
| // Step 1: Submit files | |
| var url = '${Env.apiBaseUrl}v2/sync-local-files'; | |
| if (conversationId != null) { | |
| url += '?conversation_id=$conversationId'; | |
| } | |
| url += '?conversation_id=${Uri.encodeQueryComponent(conversationId)}'; |
| /// Empty when all frames have been streamed successfully (clean UI). | ||
| List<Wal> get unsyncedSessionWals { | ||
| if (_sessionStartSeconds == 0) return []; | ||
| final syncs = (_wal as WalService).getSyncs() as WalSyncs; |
There was a problem hiding this comment.
Fragile double type-cast may throw
TypeError in non-production paths
final syncs = (_wal as WalService).getSyncs() as WalSyncs;IWalService.getSyncs() returns dynamic, so both casts are unchecked at the call site. If _wal is ever backed by a non-WalService implementation (e.g. a test mock, a stub, or a future refactor), this line throws a TypeError and crashes the getter — which is called directly from the widget tree on every rebuild.
Consider adding a guard, or typing the interface's return value so the second cast is unnecessary:
final walService = _wal;
if (walService is! WalService) return [];
final syncs = walService.getSyncs();
if (syncs is! WalSyncs) return [];
return syncs.phone.getSessionUnsyncedWals(_sessionStartSeconds);Ensures auto-synced WALs are saved to disk and trigger onWalUpdated, preventing re-sync after app restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Save session start before _resetStateVariables in ConversationProcessingStartedEvent, then trigger auto-sync on ConversationEvent. Also use markWalSyncedAndPersist for durable WAL status updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Restructure layout so the audio safety indicator appears in the waiting-for-transcript state, covering the WebSocket-drop-before-any- transcript failure mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevents orphan conversation creation when a stale conversation ID is passed to the sync endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevents in-memory WALs that haven't been flushed yet from being silently skipped during auto-sync. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use _wal.getSyncs().phone directly (dynamic-typed interface) to match existing patterns in capture_provider.dart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ocales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Without this, the conversation title/summary/action-items stay stale when recovered audio segments are appended via the auto-sync path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests cover disk-only filtering, session window boundaries, status filtering, and persistent status updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rker Tests verify conversation ID is forwarded to process_segment and defaults to None when not provided. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Test results:
Remaining uncovered paths are integration-scope: by AI for @beastoin |
CP9 Changed-Path Coverage Checklist
L1 SynthesisAll 10 changed paths (P1-P10) verified at Level 1 — zero UNTESTED paths. Backend paths (P1-P3) proven via curl endpoint testing + 70/70 unit tests. App paths (P4-P5) proven via APK build + app launch. P6-P7 proven live via VM Service event injection: L2 SynthesisAll 10 changed paths (P1-P10) verified at Level 2 — zero UNTESTED paths. Backend on port 10161 + app on emulator both running. Backend accepted by AI for @beastoin |
CP8 Test Detail Table
by AI for @beastoin |
Enables VM Service evaluate to set session start time for P10 WAL indicator widget live testing without exposing the field publicly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
P10 Live Evidence: WAL Indicator Widget RenderingPath: Method: VM Service Protocol evaluate on running Flutter app (anonymous Firebase auth, emulator):
Result: WAL indicator widget rendered at bottom of capture page showing "1m 15s audio saved locally · will sync automatically" — proves the full l10n path, duration formatting, and conditional rendering logic. Screenshot: by AI for @beastoin |
P6 + P7 Live Evidence: Auto-Sync FlowPaths:
Method: VM Service Protocol evaluate on running Flutter app (anonymous Firebase auth, emulator). Steps:
Logcat evidence: Result:
by AI for @beastoin |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
jq defaulted to 2-space output when adding new keys, causing every line in all ARB files to be re-indented. This restores the original 4-space format so the diff only shows the 2 new l10n keys. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Image now hosted at gs://omi-pr-assets/pr-6294/01-wal-indicator.png Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
lgtm |
Bump build number 803 → 804 for mobile release. Includes PR #6294 (audio safety WAL widget + auto-sync). --- _by AI for @beastoin_
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bump build number 803 → 804 for mobile release. Includes PR BasedHardware#6294 (audio safety WAL widget + auto-sync). --- _by AI for @beastoin_

Closes #6293
Users on the live transcript screen now see a subtle indicator when audio WALs are saved locally but haven't synced yet. When the conversation finishes processing, any missed WALs from the session are automatically synced to that conversation instead of creating orphan conversations.
Changes
Frontend (Flutter)
local_wal_sync.dart:getSessionUnsyncedWals()(disk-only filter) +markWalSyncedAndPersist()for durable status updatescapture_provider.dart: Session tracking,unsyncedSessionWalsgetter, auto-sync on both manual and socket-driven conversation completionconversation_capturing/page.dart: Inline WAL safety indicator with l10n, visible even when no transcript/photos yetBackend (Python)
sync.py:conversation_idquery param on v1/v2 sync-local-files → threaded through background processing →process_segment. Falls back to timestamp lookup when target not found. Triggers reprocessing on auto-sync merges.tests/unit/test_sync_v2.py: Updated mocks for newtarget_conversation_idparamAPI client
conversations.dart:conversationIdparam onsyncLocalFilesV2()with URL encodingL10n
audioSavedLocallyandwillSyncAutomaticallykeys in all 34 localesReview cycle fixes
Test evidence
app/test.sh: 362 passed, 2 pre-existing failures (clock_skew_detection_test.dart)backend/test.sh: 9 passed, 3 pre-existing failures (test_conversation_source_unknown.py)pytest tests/unit/test_sync_v2.py: 68 passedKnown limitations (follow-up: #6318)
Post-merge deep-dive + Codex consultation identified root issues that this PR does NOT fully solve:
_pendingAutoSyncSessionStartis lost on app kill or WebSocket disconnect. No fallback._chunk()intentionally keeps newest frames in memory; they die on app kill even with this PR's changes.conversationIdon WAL model — after app kill, orphaned WALs cannot be linked back to their conversation. No startup recovery.See #6318 for the complete fix plan.
by AI for @beastoin