diff --git a/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt b/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt new file mode 100644 index 00000000000..7d1e69b7207 --- /dev/null +++ b/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt @@ -0,0 +1,345 @@ +package com.friend.ios + +import android.content.Context +import android.util.Log +import org.json.JSONObject +import java.io.File +import java.io.RandomAccessFile +import java.util.Locale + +/** + * Batch (offline) capture sink. When the user enables "batch mode", incoming BLE + * audio is NOT streamed to the transcription socket — instead each opus/pcm frame + * is appended to a local `.bin` file with the exact length-prefixed layout the + * Omi offline-sync backend (`POST /v2/sync-local-files`) expects: + * + * [4-byte little-endian uint32 frame_length][frame bytes] ... repeated + * + * Files are named `audio_omibatch_{codec}_{sampleRate}_{channel}_fs{frameSize}_{startSec}.bin` + * (the `omibatch` device marker distinguishes these from offline-sync WAL files, + * which share this directory; the backend treats the device segment as a label.) + * so the backend can parse the codec, frame size and start timestamp. Flutter later + * scans the directory, registers each finalized file as a WAL and uploads it. + * + * This runs entirely native (called from [OmiBleForegroundService]'s characteristic + * listener) so the Flutter engine does no per-packet work while the app is + * minimized/closed. It self-gates on the `batchModeEnabled` pref and is mutually + * exclusive with [OmiBackgroundAudioStreamer] (which is disabled in batch mode via + * `nativeBleStreamingEnabled=false`). + * + * Durability (improving on fieldy, which has none): frames are written with a + * periodic `fsync`, a `.batch_journal` names the file currently being appended so + * Flutter never ingests a half-written file, and a free-space guard stops writing + * (rather than crashing) when storage runs low. + */ +class OmiBatchAudioWriter(private val context: Context) { + companion object { + private const val TAG = "OmiBle.BatchWriter" + private const val FLUTTER_PREFS = "FlutterSharedPreferences" + private const val PART_SUFFIX = ".part" // active (still-being-written) files end .bin.part + private const val MAX_FILE_BYTES = 32L * 1024 * 1024 // ~32 MB per file + private const val MAX_FILE_SECONDS = 1800L // 30 min per file + private const val GAP_MS = 30_000L // close current file after this silence gap + private const val FSYNC_INTERVAL_MS = 2_000L + private const val MIN_FREE_BYTES = 200L * 1024 * 1024 // stop writing below 200 MB free + } + + private data class Config( + val deviceId: String, + val codec: String, + val sampleRate: Int, + val serviceUuid: String, + val characteristicUuid: String, + val deviceType: String, + val dir: String, + ) + + private val lock = Any() + private var raf: RandomAccessFile? = null + private var currentFile: File? = null + private var currentStartSec: Long = 0 + private var currentBytes: Long = 0 + private var currentFrames: Long = 0 + private var lastFrameMs: Long = 0 + private var lastFsyncMs: Long = 0 + private var storageFull = false + private var recovered = false + + /** Audio target for this device if batch mode is on — used by the foreground + * service to subscribe to the audio characteristic when Flutter is dead. */ + fun configuredAudioTargetFor(address: String): Pair? { + val config = loadConfig() ?: return null + if (!config.deviceId.equals(address, ignoreCase = true)) return null + return config.serviceUuid to config.characteristicUuid + } + + fun handleCharacteristic(address: String, serviceUuid: String, characteristicUuid: String, value: ByteArray) { + val config = loadConfig() + if (config == null) { + // Batch mode disabled — finalize any open file so it can be ingested. + closeCurrent("disabled") + return + } + if (!config.deviceId.equals(address, ignoreCase = true)) return + if (!matches(config, serviceUuid, characteristicUuid)) return + + val frames = transformFrames(config.deviceType, value) + if (frames.isEmpty()) return + + synchronized(lock) { + val now = System.currentTimeMillis() + + // Gap finalize: a pause longer than GAP_MS starts a new file (so the + // backend places resumed audio as a separate conversation). + if (raf != null && lastFrameMs > 0 && now - lastFrameMs > GAP_MS) { + closeCurrentLocked("gap") + } + // Rotation: bound file size/duration (between packets, never mid-packet). + if (raf != null && (currentBytes >= MAX_FILE_BYTES || (now / 1000 - currentStartSec) >= MAX_FILE_SECONDS)) { + closeCurrentLocked("rotate") + } + + ensureOpenLocked(config, now) + val out = raf ?: return // storage full or open failed — drop this packet + + try { + for (frame in frames) { + writeFrameLocked(out, frame) + } + } catch (e: Exception) { + Log.e(TAG, "write failed: ${e.message}") + closeCurrentLocked("write_error") + return + } + + lastFrameMs = now + if (now - lastFsyncMs >= FSYNC_INTERVAL_MS) { + fsyncLocked() + lastFsyncMs = now + } + } + } + + /** Finalize + fsync the current file (e.g. on service destroy). */ + fun stop(reason: String) { + synchronized(lock) { closeCurrentLocked(reason) } + } + + private fun closeCurrent(reason: String) { + synchronized(lock) { closeCurrentLocked(reason) } + } + + // ── File lifecycle (caller holds lock) ── + + private fun ensureOpenLocked(config: Config, nowMs: Long) { + if (raf != null) return + + val dir = File(config.dir) + if (!dir.exists() && !dir.mkdirs()) { + Log.e(TAG, "cannot create batch dir ${config.dir}") + return + } + // Recover from a previous process that died mid-write: any leftover .bin.part + // is a finalized-by-crash orphan — promote it to .bin so it becomes ingestable. + if (!recovered) { + recovered = true + recoverStalePartFiles(dir) + } + if (dir.usableSpace < MIN_FREE_BYTES) { + if (!storageFull) { + Log.w(TAG, "storage low (${dir.usableSpace} bytes free) — pausing batch capture") + setStorageFullFlag(true) + storageFull = true + } + return + } + if (storageFull) { + storageFull = false + setStorageFullFlag(false) + } + + val startSec = nowMs / 1000 + val frameSize = if (config.codec == "opus_fs320") 320 else 160 + // Tag the device segment as `omibatch` so the Dart scanner can tell batch + // recordings apart from offline-sync WAL flushes, which share this directory + // and the same audio_*.bin naming. The backend ignores this segment; keep it + // in sync with Dart's `batchRecordingDevice`. + // Write to a .bin.part file while active; rename to .bin only once finalized so + // Flutter (which scans *.bin) never picks up a half-written file. + val name = "audio_omibatch_${config.codec}_${config.sampleRate}_1_fs${frameSize}_${startSec}.bin$PART_SUFFIX" + val file = File(dir, name) + + try { + val out = RandomAccessFile(file, "rw") + out.seek(out.length()) // append-safe (same-second restart reuses the file) + raf = out + currentFile = file + currentStartSec = startSec + currentBytes = file.length() + currentFrames = 0 + lastFsyncMs = nowMs + Log.i(TAG, "opened batch file $name") + } catch (e: Exception) { + Log.e(TAG, "open failed for $name: ${e.message}") + raf = null + currentFile = null + } + } + + private fun writeFrameLocked(out: RandomAccessFile, frame: ByteArray) { + val len = frame.size + val header = byteArrayOf( + (len and 0xFF).toByte(), + ((len shr 8) and 0xFF).toByte(), + ((len shr 16) and 0xFF).toByte(), + ((len shr 24) and 0xFF).toByte(), + ) + out.write(header) + out.write(frame) + currentBytes += 4 + len + currentFrames++ + } + + private fun fsyncLocked() { + try { + raf?.fd?.sync() + } catch (e: Exception) { + Log.w(TAG, "fsync failed: ${e.message}") + } + } + + private fun closeCurrentLocked(reason: String) { + val out = raf ?: return + val partFile = currentFile + try { + out.fd.sync() + } catch (_: Exception) { + } + try { + out.close() + } catch (_: Exception) { + } + if (partFile != null) { + if (currentBytes > 0) { + // Atomically promote .bin.part -> .bin so it becomes ingestable. + val finalFile = File(partFile.parentFile, partFile.name.removeSuffix(PART_SUFFIX)) + val renamed = partFile.renameTo(finalFile) + if (!renamed) Log.w(TAG, "failed to finalize ${partFile.name}") + Log.i(TAG, "finalized ${finalFile.name} ($currentFrames frames, $currentBytes bytes, reason=$reason)") + } else { + partFile.delete() // nothing written — drop the empty placeholder + } + } + raf = null + currentFile = null + currentStartSec = 0 + currentBytes = 0 + currentFrames = 0 + lastFrameMs = 0 + } + + // ── Crash recovery ── + + /** Promote any leftover `*.bin.part` from a previous (crashed) process to `.bin` + * so finalized-by-crash recordings are not lost. Empty placeholders are deleted. */ + private fun recoverStalePartFiles(dir: File) { + try { + val parts = dir.listFiles { f -> + f.isFile && f.name.startsWith("audio_") && f.name.endsWith(".bin$PART_SUFFIX") + } ?: return + for (p in parts) { + if (p.length() > 0L) { + val finalFile = File(dir, p.name.removeSuffix(PART_SUFFIX)) + if (p.renameTo(finalFile)) Log.i(TAG, "recovered stale batch file -> ${finalFile.name}") + } else { + p.delete() + } + } + } catch (e: Exception) { + Log.w(TAG, "recoverStalePartFiles failed: ${e.message}") + } + } + + // ── Frame extraction (mirrors OmiBackgroundAudioStreamer.transformFrames) ── + + private fun transformFrames(deviceType: String, value: ByteArray): List = + when (deviceType) { + "omi", "openglass" -> if (value.size <= 3) emptyList() else listOf(value.copyOfRange(3, value.size)) + "friendPendant" -> { + if (value.size <= 5) { + emptyList() + } else { + val payload = value.copyOfRange(0, value.size - 5) + val frames = mutableListOf() + var offset = 0 + while (offset + 30 <= payload.size) { + frames.add(payload.copyOfRange(offset, offset + 30)) + offset += 30 + } + frames + } + } + else -> { + Log.w(TAG, "unsupported batch device type: $deviceType") + emptyList() + } + } + + private fun matches(config: Config, serviceUuid: String, characteristicUuid: String): Boolean = + config.serviceUuid.equals(serviceUuid, ignoreCase = true) && + config.characteristicUuid.equals(characteristicUuid, ignoreCase = true) + + // ── Config + prefs ── + + private fun loadConfig(): Config? { + if (!boolPref("batchModeEnabled", false)) return null + val dir = stringPref("batchAudioDir") + if (dir.isEmpty()) return null + val raw = stringPref("nativeBleStreamConfig") + if (raw.isEmpty()) return null + return try { + val json = JSONObject(raw) + val deviceId = json.optString("deviceId") + val serviceUuid = json.optString("serviceUuid").lowercase(Locale.US) + val characteristicUuid = json.optString("characteristicUuid").lowercase(Locale.US) + if (deviceId.isEmpty() || serviceUuid.isEmpty() || characteristicUuid.isEmpty()) return null + Config( + deviceId = deviceId, + codec = json.optString("codec", "opus"), + sampleRate = json.optInt("sampleRate", 16000), + serviceUuid = serviceUuid, + characteristicUuid = characteristicUuid, + deviceType = json.optString("deviceType", "omi"), + dir = dir, + ) + } catch (e: Exception) { + Log.w(TAG, "invalid batch config: ${e.message}") + null + } + } + + private fun setStorageFullFlag(full: Boolean) { + try { + prefs().edit().putBoolean("flutter.batchStorageFull", full).apply() + } catch (_: Exception) { + } + } + + private fun prefs() = context.getSharedPreferences(FLUTTER_PREFS, Context.MODE_PRIVATE) + + private fun prefValue(key: String): Any? = prefs().all["flutter.$key"] + + private fun stringPref(key: String, defaultValue: String = ""): String = + when (val value = prefValue(key)) { + is String -> value + null -> defaultValue + else -> value.toString() + } + + private fun boolPref(key: String, defaultValue: Boolean): Boolean = + when (val value = prefValue(key)) { + is Boolean -> value + is String -> value.toBooleanStrictOrNull() ?: defaultValue + else -> defaultValue + } +} diff --git a/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt b/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt index 89f7909ea3d..bb3a16013d8 100644 --- a/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt +++ b/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt @@ -79,6 +79,17 @@ class OmiBleForegroundService : Service() { context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) .getBoolean("flutter.backgroundModeEnabled", false) + /** Batch (offline) capture opt-in. Like background mode, it keeps the BLE + * foreground service alive past app close so native can keep storing audio. */ + fun isBatchModeEnabled(context: Context): Boolean = + context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + .getBoolean("flutter.batchModeEnabled", false) + + /** True when the service must persist past app close (sticky) — either for + * background streaming or batch capture. */ + fun isPersistentModeEnabled(context: Context): Boolean = + isBackgroundModeEnabled(context) || isBatchModeEnabled(context) + fun startService(context: Context, deviceAddress: String, requiresBond: Boolean = false, caller: String = "unknown") { if (caller.startsWith("CompanionSvc")) { val now = System.currentTimeMillis() @@ -149,6 +160,7 @@ class OmiBleForegroundService : Service() { private val syncLock = Any() private val bleManager get() = OmiBleManager.instance private val backgroundAudioStreamer by lazy { OmiBackgroundAudioStreamer(applicationContext) } + private val batchAudioWriter by lazy { OmiBatchAudioWriter(applicationContext) } // ── Connection listener — receives GATT events from OmiBleManager ── @@ -258,7 +270,11 @@ class OmiBleForegroundService : Service() { private fun ensureBackgroundAudioSubscription(address: String, services: List) { if (OmiBleManager.isFlutterAlive) return - val target = backgroundAudioStreamer.configuredAudioTargetFor(address) ?: return + // Subscribe for background streaming OR batch capture (whichever is configured) + // so native keeps receiving audio after the Flutter engine is gone. + val target = backgroundAudioStreamer.configuredAudioTargetFor(address) + ?: batchAudioWriter.configuredAudioTargetFor(address) + ?: return val hasTarget = services.any { service -> service.uuid.equals(target.first, ignoreCase = true) && service.characteristicUuids.any { it.equals(target.second, ignoreCase = true) } @@ -469,6 +485,10 @@ class OmiBleForegroundService : Service() { managed.currentGattHash = null } + // Finalize the in-progress batch recording so it's saved + ingestable right away + // (a plain BLE disconnect never delivers another packet to trigger the gap finalize). + batchAudioWriter.stop("ble_disconnected") + val addr = address.uppercase() val error = when { @@ -633,6 +653,9 @@ class OmiBleForegroundService : Service() { characteristicUuid: String, value: ByteArray ) { + // Batch mode and background streaming are mutually exclusive (gated by + // their respective prefs); calling both is safe — each self-gates. + batchAudioWriter.handleCharacteristic(address, serviceUuid, characteristicUuid, value) backgroundAudioStreamer.handleCharacteristic(address, serviceUuid, characteristicUuid, value) } } @@ -643,12 +666,13 @@ class OmiBleForegroundService : Service() { startForeground(NOTIFICATION_ID, buildNotification("Connecting to Omi...")) val backgroundMode = isBackgroundModeEnabled(this) + val persistentMode = isPersistentModeEnabled(this) val address = intent?.getStringExtra("device_address") val requiresBond = intent?.getBooleanExtra("requires_bond", false) ?: false if (address != null) { manageDevice(address, requiresBond) - } else if (backgroundMode) { + } else if (persistentMode) { // Restart after process death (sticky): restore the device we were managing. val saved = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(PREFS_KEY, null) val parts = saved?.split("|") @@ -664,13 +688,13 @@ class OmiBleForegroundService : Service() { stopSelf() } - // Background mode off ⇒ non-sticky, so the OS won't resurrect the service after the app is closed. - return if (backgroundMode) START_STICKY else START_NOT_STICKY + // Persistent mode off ⇒ non-sticky, so the OS won't resurrect the service after the app is closed. + return if (persistentMode) START_STICKY else START_NOT_STICKY } override fun onTaskRemoved(rootIntent: Intent?) { - if (isBackgroundModeEnabled(this)) { - Log.i(TAG, "Task removed; keeping BLE foreground service alive (background mode on)") + if (isPersistentModeEnabled(this)) { + Log.i(TAG, "Task removed; keeping BLE foreground service alive (background/batch mode on)") } else { Log.i(TAG, "Task removed; stopping BLE foreground service (background mode off)") stopSelf() @@ -682,6 +706,7 @@ class OmiBleForegroundService : Service() { Log.d(TAG, "Service destroying") isDestroying = true backgroundAudioStreamer.stop("service_destroyed") + batchAudioWriter.stop("service_destroyed") for ((addr, managed) in managedDevices) { managed.pendingReconnect?.let { handler.removeCallbacks(it) } diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj index 21ddb8dd22d..6f0ee874310 100644 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 42A7BA3E2E788BD400138969 /* omiWatchApp.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 42A7BA342E788BD300138969 /* omiWatchApp.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); platformFilter = iphoneos; }; }; 42A7BA4C2E788F0100138969 /* WatchConnectivity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 42A7BA4B2E788F0100138969 /* WatchConnectivity.framework */; }; 42B17C622F69B9CF00BFDA9D /* OmiBleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B17C612F69B9CF00BFDA9D /* OmiBleManager.swift */; }; + BAADF00DF00DF00DF00DF0D2 /* BatchAudioWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAADF00DF00DF00DF00DF0D1 /* BatchAudioWriter.swift */; }; 42B17C642F69B9E100BFDA9D /* BleHostApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B17C632F69B9E100BFDA9D /* BleHostApiImpl.swift */; }; 42B17C7C2F69BC6600BFDA9D /* PigeonCommunicator.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B17C7B2F69BC6600BFDA9D /* PigeonCommunicator.g.swift */; }; 42BABFB82F2A81B100E78A3C /* OmiRecordingAudioDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BABFB62F2A81B100E78A3C /* OmiRecordingAudioDevice.swift */; }; @@ -126,6 +127,7 @@ 42A7BA342E788BD300138969 /* omiWatchApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = omiWatchApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 42A7BA4B2E788F0100138969 /* WatchConnectivity.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WatchConnectivity.framework; path = System/Library/Frameworks/WatchConnectivity.framework; sourceTree = SDKROOT; }; 42B17C612F69B9CF00BFDA9D /* OmiBleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmiBleManager.swift; sourceTree = ""; }; + BAADF00DF00DF00DF00DF0D1 /* BatchAudioWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchAudioWriter.swift; sourceTree = ""; }; 42B17C632F69B9E100BFDA9D /* BleHostApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BleHostApiImpl.swift; sourceTree = ""; }; 42B17C7B2F69BC6600BFDA9D /* PigeonCommunicator.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonCommunicator.g.swift; sourceTree = ""; }; 42BABFB62F2A81B100E78A3C /* OmiRecordingAudioDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmiRecordingAudioDevice.swift; sourceTree = ""; }; @@ -339,6 +341,7 @@ 42B17C7B2F69BC6600BFDA9D /* PigeonCommunicator.g.swift */, 42B17C632F69B9E100BFDA9D /* BleHostApiImpl.swift */, 42B17C612F69B9CF00BFDA9D /* OmiBleManager.swift */, + BAADF00DF00DF00DF00DF0D1 /* BatchAudioWriter.swift */, 42BABFC02F2A81B100E78A3C /* PhoneCalls */, 4255287C2E7C4B8300FD5CFB /* RecorderHostApiImpl.swift */, 5FCEB2FF2C2758C000B17EE8 /* RunnerRelease-dev.entitlements */, @@ -636,6 +639,7 @@ files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 42B17C622F69B9CF00BFDA9D /* OmiBleManager.swift in Sources */, + BAADF00DF00DF00DF00DF0D2 /* BatchAudioWriter.swift in Sources */, AB12345678901234567890AB /* AppleRemindersService.swift in Sources */, AB12345678901234567890AC /* AppleHealthService.swift in Sources */, AB12345678901234567890AD /* AudioInterruptionManager.swift in Sources */, diff --git a/app/ios/Runner/BatchAudioWriter.swift b/app/ios/Runner/BatchAudioWriter.swift new file mode 100644 index 00000000000..ad2de6933eb --- /dev/null +++ b/app/ios/Runner/BatchAudioWriter.swift @@ -0,0 +1,283 @@ +import Foundation + +/// Batch (offline) capture sink for iOS. When the user enables "batch mode", +/// incoming BLE audio is stored to local `.bin` files instead of being forwarded +/// to Dart for realtime transcription. Mirrors the Android `OmiBatchAudioWriter`. +/// +/// On-disk format (what `POST /v2/sync-local-files` decodes): +/// [4-byte little-endian uint32 frame_length][frame bytes] ... repeated +/// File name: audio_omibatch_{codec}_{sampleRate}_{channel}_fs{frameSize}_{startSec}.bin +/// (the `omibatch` device marker distinguishes these from offline-sync WAL files, +/// which share this directory; the backend treats the device segment as a label.) +/// +/// Hooked from `OmiBleManager.peripheral(_:didUpdateValueFor:)`. When it consumes a +/// packet it returns `true`, and the manager skips the Dart forward — so the Flutter +/// engine does no per-packet work (the iOS battery win). +/// +/// Durability: frames are written to a `.bin.part` file with periodic `fsync`, and +/// the file is atomically renamed to `.bin` only once finalized (rotation / silence +/// gap / stop) so a half-written file is never ingested. A stale `.bin.part` left by +/// a crashed process is recovered (promoted to `.bin`) on the next writer start. A +/// free-space guard stops writing (and flags Flutter) rather than failing hard. +final class BatchAudioWriter { + static let shared = BatchAudioWriter() + private init() {} + + private let queue = DispatchQueue(label: "com.omi.batchAudioWriter") + + // Active-file state (only touched on `queue`). + private var fileHandle: FileHandle? + private var currentURL: URL? + private var currentStartSec: Int64 = 0 + private var currentBytes: Int64 = 0 + private var currentFrames: Int64 = 0 + private var lastFrameMs: Int64 = 0 + private var lastFsyncMs: Int64 = 0 + private var storageFull = false + private var recovered = false + private var diagLoggedNoConfig = false + private var diagLoggedMatch = false + + private let maxFileBytes: Int64 = 32 * 1024 * 1024 // ~32 MB per file + private let maxFileSeconds: Int64 = 1800 // 30 min per file + private let gapMs: Int64 = 30_000 // start a new file after this silence gap + private let fsyncIntervalMs: Int64 = 2_000 + private let minFreeBytes: Int64 = 200 * 1024 * 1024 // stop below 200 MB free + private let partSuffix = "part" + + private struct Config { + let deviceId: String + let codec: String + let sampleRate: Int + let serviceUuid: String + let characteristicUuid: String + let deviceType: String + let dir: String + } + + /// Returns true if the packet was consumed for batch capture (caller must then + /// skip forwarding it to Dart). Returns false to let realtime forwarding proceed. + @discardableResult + func handle(peripheralUuid: String, serviceUuid: String, characteristicUuid: String, value: Data) -> Bool { + guard let config = loadConfig() else { + if !diagLoggedNoConfig { + diagLoggedNoConfig = true + let d = UserDefaults.standard + NSLog("[BatchWriter] no config: batchModeEnabled=\(d.bool(forKey: "flutter.batchModeEnabled")) dir=\(d.string(forKey: "flutter.batchAudioDir") ?? "nil") hasStreamConfig=\(d.string(forKey: "flutter.nativeBleStreamConfig") != nil)") + } + queue.async { self.closeCurrentLocked("disabled") } + return false + } + diagLoggedNoConfig = false + guard config.deviceId.lowercased() == peripheralUuid.lowercased() else { return false } + guard config.serviceUuid == serviceUuid.lowercased(), + config.characteristicUuid == characteristicUuid.lowercased() else { return false } + + if !diagLoggedMatch { + diagLoggedMatch = true + NSLog("[BatchWriter] matched audio characteristic — batch capture active (device=\(peripheralUuid), dir=\(config.dir))") + } + + let frames = transformFrames(deviceType: config.deviceType, value: value) + if !frames.isEmpty { + queue.async { self.writeFrames(frames, config: config) } + } + // Audio packet on the configured characteristic: consume it (do not forward + // to Dart) even if it carried no payload, to keep the engine idle. + return true + } + + /// Finalize the current file (e.g. on disconnect or app teardown). + func stop(_ reason: String) { + queue.async { self.closeCurrentLocked(reason) } + } + + // MARK: - Writing (on `queue`) + + private func writeFrames(_ frames: [Data], config: Config) { + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + + if fileHandle != nil, lastFrameMs > 0, nowMs - lastFrameMs > gapMs { + closeCurrentLocked("gap") + } + if fileHandle != nil, currentBytes >= maxFileBytes || (nowMs / 1000 - currentStartSec) >= maxFileSeconds { + closeCurrentLocked("rotate") + } + + ensureOpen(config: config, nowMs: nowMs) + guard let fh = fileHandle else { return } // storage full or open failed — drop packet + + do { + for frame in frames { + var len = UInt32(frame.count).littleEndian + let header = Data(bytes: &len, count: 4) + try fh.write(contentsOf: header) + try fh.write(contentsOf: frame) + currentBytes += Int64(4 + frame.count) + currentFrames += 1 + } + } catch { + NSLog("[BatchWriter] write failed: \(error)") + closeCurrentLocked("write_error") + return + } + + lastFrameMs = nowMs + if nowMs - lastFsyncMs >= fsyncIntervalMs { + try? fh.synchronize() + lastFsyncMs = nowMs + } + } + + private func ensureOpen(config: Config, nowMs: Int64) { + if fileHandle != nil { return } + + let dir = URL(fileURLWithPath: config.dir, isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + if !recovered { + recovered = true + recoverStalePartFiles(dir) + } + + if freeBytes(at: dir) < minFreeBytes { + if !storageFull { + NSLog("[BatchWriter] storage low — pausing batch capture") + setStorageFullFlag(true) + storageFull = true + } + return + } + if storageFull { + storageFull = false + setStorageFullFlag(false) + } + + let startSec = nowMs / 1000 + let frameSize = config.codec == "opus_fs320" ? 320 : 160 + // Tag the device segment as `omibatch` so the Dart scanner can tell batch + // recordings apart from offline-sync WAL flushes, which share this directory + // and the same audio_*.bin naming. The backend ignores this segment; keep it + // in sync with Dart's `batchRecordingDevice`. + let name = "audio_omibatch_\(config.codec)_\(config.sampleRate)_1_fs\(frameSize)_\(startSec).bin.\(partSuffix)" + let url = dir.appendingPathComponent(name) + + if !FileManager.default.fileExists(atPath: url.path) { + FileManager.default.createFile(atPath: url.path, contents: nil) + } + guard let fh = try? FileHandle(forWritingTo: url) else { + NSLog("[BatchWriter] open failed for \(name)") + return + } + let end = (try? fh.seekToEnd()) ?? 0 + fileHandle = fh + currentURL = url + currentStartSec = startSec + currentBytes = Int64(end) + currentFrames = 0 + lastFsyncMs = nowMs + NSLog("[BatchWriter] opened \(name)") + } + + private func closeCurrentLocked(_ reason: String) { + guard let fh = fileHandle else { return } + try? fh.synchronize() + try? fh.close() + if let part = currentURL { + if currentBytes > 0 { + let finalURL = part.deletingPathExtension() // strip ".part" -> "....bin" + try? FileManager.default.removeItem(at: finalURL) + do { + try FileManager.default.moveItem(at: part, to: finalURL) + NSLog("[BatchWriter] finalized \(finalURL.lastPathComponent) (\(currentFrames) frames, \(currentBytes) bytes, reason=\(reason))") + } catch { + NSLog("[BatchWriter] finalize failed: \(error)") + } + } else { + try? FileManager.default.removeItem(at: part) + } + } + fileHandle = nil + currentURL = nil + currentStartSec = 0 + currentBytes = 0 + currentFrames = 0 + lastFrameMs = 0 + } + + // MARK: - Crash recovery + + private func recoverStalePartFiles(_ dir: URL) { + guard let items = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: [.fileSizeKey] + ) else { return } + for url in items { + let name = url.lastPathComponent + guard name.hasPrefix("audio_"), name.hasSuffix(".bin.\(partSuffix)") else { continue } + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + if size > 0 { + let finalURL = url.deletingPathExtension() + try? FileManager.default.moveItem(at: url, to: finalURL) + NSLog("[BatchWriter] recovered stale batch file -> \(finalURL.lastPathComponent)") + } else { + try? FileManager.default.removeItem(at: url) + } + } + } + + // MARK: - Frame extraction (mirrors Android transformFrames) + + private func transformFrames(deviceType: String, value: Data) -> [Data] { + switch deviceType { + case "omi", "openglass": + return value.count <= 3 ? [] : [value.subdata(in: 3 ..< value.count)] + case "friendPendant": + if value.count <= 5 { return [] } + let payload = value.subdata(in: 0 ..< (value.count - 5)) + var frames: [Data] = [] + var offset = 0 + while offset + 30 <= payload.count { + frames.append(payload.subdata(in: offset ..< (offset + 30))) + offset += 30 + } + return frames + default: + return [] + } + } + + // MARK: - Config + helpers + + private func loadConfig() -> Config? { + let d = UserDefaults.standard + guard d.bool(forKey: "flutter.batchModeEnabled") else { return nil } + guard let dir = d.string(forKey: "flutter.batchAudioDir"), !dir.isEmpty else { return nil } + guard let raw = d.string(forKey: "flutter.nativeBleStreamConfig"), + let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + guard let deviceId = json["deviceId"] as? String, !deviceId.isEmpty, + let serviceUuid = json["serviceUuid"] as? String, !serviceUuid.isEmpty, + let charUuid = json["characteristicUuid"] as? String, !charUuid.isEmpty else { return nil } + return Config( + deviceId: deviceId, + codec: (json["codec"] as? String) ?? "opus", + sampleRate: (json["sampleRate"] as? Int) ?? 16000, + serviceUuid: serviceUuid.lowercased(), + characteristicUuid: charUuid.lowercased(), + deviceType: (json["deviceType"] as? String) ?? "omi", + dir: dir + ) + } + + private func freeBytes(at dir: URL) -> Int64 { + if let vals = try? dir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]), + let cap = vals.volumeAvailableCapacityForImportantUsage { + return cap + } + return Int64.max + } + + private func setStorageFullFlag(_ full: Bool) { + UserDefaults.standard.set(full, forKey: "flutter.batchStorageFull") + } +} diff --git a/app/ios/Runner/OmiBleManager.swift b/app/ios/Runner/OmiBleManager.swift index d9f9d53d15d..b4b10bcbc03 100644 --- a/app/ios/Runner/OmiBleManager.swift +++ b/app/ios/Runner/OmiBleManager.swift @@ -641,6 +641,10 @@ extension OmiBleManager: CBCentralManagerDelegate { NSLog("[OmiBle] didDisconnect: \(peripheral.name ?? ""), uuid=\(uuid), error=\(error?.localizedDescription ?? "nil")") cleanupPeripheral(uuid) + // Finalize the in-progress batch recording so it's saved + ingestable right away + // (a plain BLE disconnect never delivers another packet to trigger the gap finalize). + BatchAudioWriter.shared.stop("disconnected") + if !isManual { let reason = Self.bleReasonString(from: error) let code = (error as? CBError)?.code.rawValue ?? -1 @@ -755,6 +759,18 @@ extension OmiBleManager: CBPeripheralDelegate { persistBatteryReading(uuid: uuid, level: Int(firstByte)) } + // Batch (offline) mode: store audio natively and skip the Dart forward so the + // Flutter engine stays idle. Returns true only for the configured audio + // characteristic while batch mode is on; everything else falls through. + if BatchAudioWriter.shared.handle( + peripheralUuid: uuid, + serviceUuid: serviceUuid, + characteristicUuid: charUuid, + value: data + ) { + return + } + let typedData = FlutterStandardTypedData(bytes: data) flutterApi?.onCharacteristicValueUpdated( peripheralUuid: uuid, diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index 1f3e5ec9ce4..4b7bd23d5e7 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -62,6 +62,13 @@ class SharedPreferencesUtil { set backgroundModeEnabled(bool value) => saveBool('backgroundModeEnabled', value); + // Batch (offline) capture mode: when on, BLE audio is stored to local .bin files + // by the native layer instead of being transcribed in real time. Mutually + // exclusive with the realtime transcription socket (see CaptureProvider). + bool get batchModeEnabled => getBool('batchModeEnabled'); + + set batchModeEnabled(bool value) => saveBool('batchModeEnabled', value); + // Double tap behavior: 0 = end conversation (default), 1 = pause/mute, 2 = star ongoing conversation int get doubleTapAction => getInt('doubleTapAction'); diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 020385eb0b6..52d84fa82cb 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -11022,5 +11022,21 @@ "syncCustomSttWarningMessage": "You use your own transcription provider. Syncing these recordings transcribes them on Omi's servers instead, and they count toward your plan's transcription limit.", "@syncCustomSttWarningMessage": { "description": "Body warning that syncing transcribes on Omi servers and counts toward the plan limit" + }, + "offlineModeTitle": "Offline Mode", + "@offlineModeTitle": { + "description": "Title for the offline (batch) capture mode toggle in device settings" + }, + "offlineModeDescription": "Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.", + "@offlineModeDescription": { + "description": "Subtitle explaining the offline mode toggle" + }, + "offlineModeNote": "Works with Omi devices for now. Audio stays on your phone until you choose to upload it.", + "@offlineModeNote": { + "description": "Caveat note shown in the Offline Mode sheet" + }, + "offlineModeStorageFull": "Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.", + "@offlineModeStorageFull": { + "description": "Warning shown in the Offline Mode sheet when local storage is too low to keep recording" } } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 53a87fc39da..b97ccd4ad09 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -17378,6 +17378,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'You use your own transcription provider. Syncing these recordings transcribes them on Omi\'s servers instead, and they count toward your plan\'s transcription limit.'** String get syncCustomSttWarningMessage; + + /// Title for the offline (batch) capture mode toggle in device settings + /// + /// In en, this message translates to: + /// **'Offline Mode'** + String get offlineModeTitle; + + /// Subtitle explaining the offline mode toggle + /// + /// In en, this message translates to: + /// **'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'** + String get offlineModeDescription; + + /// Caveat note shown in the Offline Mode sheet + /// + /// In en, this message translates to: + /// **'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'** + String get offlineModeNote; + + /// Warning shown in the Offline Mode sheet when local storage is too low to keep recording + /// + /// In en, this message translates to: + /// **'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'** + String get offlineModeStorageFull; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/app/lib/l10n/app_localizations_ar.dart b/app/lib/l10n/app_localizations_ar.dart index 2c81b768f51..a51a68499d4 100644 --- a/app/lib/l10n/app_localizations_ar.dart +++ b/app/lib/l10n/app_localizations_ar.dart @@ -9268,4 +9268,19 @@ class AppLocalizationsAr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'أنت تستخدم مزوّد النسخ الخاص بك. مزامنة هذه التسجيلات تنسخها على خوادم Omi بدلاً من ذلك، وتُحتسب ضمن حد النسخ في باقتك.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_be.dart b/app/lib/l10n/app_localizations_be.dart index fe707dc0c5c..9f33a0408a5 100644 --- a/app/lib/l10n/app_localizations_be.dart +++ b/app/lib/l10n/app_localizations_be.dart @@ -9351,4 +9351,19 @@ class AppLocalizationsBe extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Вы карыстаецеся ўласным сэрвісам транскрыпцыі. Сінхранізацыя гэтых запісаў транскрыбуе іх на серверах Omi, і яны залічацца ў ліміт транскрыпцыі вашага тарыфу.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_bg.dart b/app/lib/l10n/app_localizations_bg.dart index f21529ef4fb..be38eca9163 100644 --- a/app/lib/l10n/app_localizations_bg.dart +++ b/app/lib/l10n/app_localizations_bg.dart @@ -9358,4 +9358,19 @@ class AppLocalizationsBg extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Използвате собствен доставчик за транскрипция. Синхронизирането на тези записи ги транскрибира на сървърите на Omi и те се отчитат към лимита за транскрипция на вашия план.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_bn.dart b/app/lib/l10n/app_localizations_bn.dart index 241e7c48944..dafb3bf4889 100644 --- a/app/lib/l10n/app_localizations_bn.dart +++ b/app/lib/l10n/app_localizations_bn.dart @@ -9330,4 +9330,19 @@ class AppLocalizationsBn extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'আপনি নিজের ট্রান্সক্রিপশন প্রদানকারী ব্যবহার করেন। এই রেকর্ডিংগুলি সিঙ্ক করলে সেগুলি Omi-এর সার্ভারে ট্রান্সক্রাইব হবে এবং আপনার প্ল্যানের ট্রান্সক্রিপশন সীমার মধ্যে গণনা হবে।'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_bs.dart b/app/lib/l10n/app_localizations_bs.dart index 0b9b741b0c4..56a58e304bd 100644 --- a/app/lib/l10n/app_localizations_bs.dart +++ b/app/lib/l10n/app_localizations_bs.dart @@ -9348,4 +9348,19 @@ class AppLocalizationsBs extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Koristite vlastiti provajder transkripcije. Sinkronizacija ovih snimaka transkribuje ih na Omi serverima i broje se u limit transkripcije vašeg plana.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ca.dart b/app/lib/l10n/app_localizations_ca.dart index c4d659f7d86..7471e0cf7eb 100644 --- a/app/lib/l10n/app_localizations_ca.dart +++ b/app/lib/l10n/app_localizations_ca.dart @@ -9377,4 +9377,19 @@ class AppLocalizationsCa extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Utilitzes el teu propi proveïdor de transcripció. Sincronitzar aquests enregistraments els transcriu als servidors d\'Omi i compten per al límit de transcripció del teu pla.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_cs.dart b/app/lib/l10n/app_localizations_cs.dart index 7ab6de174bb..e163d6802f2 100644 --- a/app/lib/l10n/app_localizations_cs.dart +++ b/app/lib/l10n/app_localizations_cs.dart @@ -9323,4 +9323,19 @@ class AppLocalizationsCs extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Používáte vlastního poskytovatele přepisu. Synchronizace těchto nahrávek je přepíše na serverech Omi a započítají se do limitu přepisu vašeho tarifu.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_da.dart b/app/lib/l10n/app_localizations_da.dart index 49a05d077dc..e0fe6a74b1b 100644 --- a/app/lib/l10n/app_localizations_da.dart +++ b/app/lib/l10n/app_localizations_da.dart @@ -9308,4 +9308,19 @@ class AppLocalizationsDa extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Du bruger din egen transskriptionsudbyder. Synkronisering af disse optagelser transskriberer dem på Omis servere i stedet, og de tæller med i din plans transskriptionsgrænse.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 51f4b8b0cb2..0c9555bb355 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -9400,4 +9400,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Du verwendest einen eigenen Transkriptionsanbieter. Beim Synchronisieren werden diese Aufnahmen stattdessen auf den Servern von Omi transkribiert und auf das Transkriptionslimit deines Tarifs angerechnet.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_el.dart b/app/lib/l10n/app_localizations_el.dart index 036980170de..489b170a8f1 100644 --- a/app/lib/l10n/app_localizations_el.dart +++ b/app/lib/l10n/app_localizations_el.dart @@ -9389,4 +9389,19 @@ class AppLocalizationsEl extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Χρησιμοποιείτε δικό σας πάροχο μεταγραφής. Ο συγχρονισμός αυτών των ηχογραφήσεων τις μεταγράφει στους διακομιστές του Omi και προσμετρώνται στο όριο μεταγραφής του προγράμματός σας.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 5ed0af50b0c..b81225ff76d 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -9318,4 +9318,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'You use your own transcription provider. Syncing these recordings transcribes them on Omi\'s servers instead, and they count toward your plan\'s transcription limit.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 9f49af58d81..489e51b5cd7 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -9344,4 +9344,19 @@ class AppLocalizationsEs extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Usas tu propio proveedor de transcripción. Sincronizar estas grabaciones las transcribe en los servidores de Omi y cuentan para el límite de transcripción de tu plan.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_et.dart b/app/lib/l10n/app_localizations_et.dart index 456452787d4..8b492a10a40 100644 --- a/app/lib/l10n/app_localizations_et.dart +++ b/app/lib/l10n/app_localizations_et.dart @@ -9320,4 +9320,19 @@ class AppLocalizationsEt extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Kasutate oma transkriptsiooniteenust. Nende salvestiste sünkroonimine transkribeerib need Omi serverites ja need arvestatakse teie paketi transkriptsioonilimiidi sisse.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_fa.dart b/app/lib/l10n/app_localizations_fa.dart index e99e3f4e960..91d4257d6ff 100644 --- a/app/lib/l10n/app_localizations_fa.dart +++ b/app/lib/l10n/app_localizations_fa.dart @@ -9325,4 +9325,19 @@ class AppLocalizationsFa extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'شما از سرویس رونویسی خودتان استفاده می‌کنید. همگام‌سازی این ضبط‌ها آن‌ها را روی سرورهای Omi رونویسی می‌کند و در سقف رونویسی پلن شما محاسبه می‌شوند.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_fi.dart b/app/lib/l10n/app_localizations_fi.dart index 60f81331970..0009cc39d75 100644 --- a/app/lib/l10n/app_localizations_fi.dart +++ b/app/lib/l10n/app_localizations_fi.dart @@ -9323,4 +9323,19 @@ class AppLocalizationsFi extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Käytät omaa litterointipalveluasi. Näiden tallenteiden synkronointi litteroi ne Omin palvelimilla, ja ne lasketaan tilauksesi litterointirajaan.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_fr.dart b/app/lib/l10n/app_localizations_fr.dart index 3ce57438bfc..01c73304f56 100644 --- a/app/lib/l10n/app_localizations_fr.dart +++ b/app/lib/l10n/app_localizations_fr.dart @@ -9407,4 +9407,19 @@ class AppLocalizationsFr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Vous utilisez votre propre fournisseur de transcription. Synchroniser ces enregistrements les transcrit sur les serveurs d\'Omi et ils comptent dans la limite de transcription de votre forfait.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_he.dart b/app/lib/l10n/app_localizations_he.dart index 04c649ed82f..b6b5416369a 100644 --- a/app/lib/l10n/app_localizations_he.dart +++ b/app/lib/l10n/app_localizations_he.dart @@ -9253,4 +9253,19 @@ class AppLocalizationsHe extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'אתה משתמש בספק תמלול משלך. סנכרון ההקלטות האלה יתמלל אותן בשרתי Omi והן ייכללו במגבלת התמלול של התוכנית שלך.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_hi.dart b/app/lib/l10n/app_localizations_hi.dart index 4b6ceeebad9..abe362dd9cb 100644 --- a/app/lib/l10n/app_localizations_hi.dart +++ b/app/lib/l10n/app_localizations_hi.dart @@ -9300,4 +9300,19 @@ class AppLocalizationsHi extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'आप अपना स्वयं का ट्रांसक्रिप्शन प्रदाता उपयोग करते हैं। इन रिकॉर्डिंग को सिंक करने पर ये Omi के सर्वर पर ट्रांसक्राइब होंगी और आपकी योजना की ट्रांसक्रिप्शन सीमा में गिनी जाएंगी।'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_hr.dart b/app/lib/l10n/app_localizations_hr.dart index 9e83d2eebd1..f3deb75957d 100644 --- a/app/lib/l10n/app_localizations_hr.dart +++ b/app/lib/l10n/app_localizations_hr.dart @@ -9355,4 +9355,19 @@ class AppLocalizationsHr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Koristite vlastiti pružatelj transkripcije. Sinkronizacija ovih snimaka transkribira ih na Omijevim poslužiteljima i broje se u ograničenje transkripcije vašeg plana.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_hu.dart b/app/lib/l10n/app_localizations_hu.dart index 886f8873046..89d386b42b4 100644 --- a/app/lib/l10n/app_localizations_hu.dart +++ b/app/lib/l10n/app_localizations_hu.dart @@ -9363,4 +9363,19 @@ class AppLocalizationsHu extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Saját átírási szolgáltatót használsz. Ezek a felvételek szinkronizáláskor az Omi szerverein kerülnek átírásra, és beleszámítanak a csomagod átírási keretébe.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_id.dart b/app/lib/l10n/app_localizations_id.dart index 36e52d7fc93..a5f0acd7aca 100644 --- a/app/lib/l10n/app_localizations_id.dart +++ b/app/lib/l10n/app_localizations_id.dart @@ -9331,4 +9331,19 @@ class AppLocalizationsId extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Anda memakai penyedia transkripsi sendiri. Menyinkronkan rekaman ini akan mentranskripsikannya di server Omi dan dihitung dalam batas transkripsi paket Anda.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_it.dart b/app/lib/l10n/app_localizations_it.dart index 74f3b65f99d..83fd8d1ea60 100644 --- a/app/lib/l10n/app_localizations_it.dart +++ b/app/lib/l10n/app_localizations_it.dart @@ -9378,4 +9378,19 @@ class AppLocalizationsIt extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Usi un tuo fornitore di trascrizione. Sincronizzare queste registrazioni le trascrive sui server di Omi e contano per il limite di trascrizione del tuo piano.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 640050aeefe..3773d24b07b 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -9172,4 +9172,19 @@ class AppLocalizationsJa extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'ご自身の文字起こしプロバイダーを使用しています。これらの録音を同期すると Omi のサーバーで文字起こしされ、プランの文字起こし上限にカウントされます。'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_kn.dart b/app/lib/l10n/app_localizations_kn.dart index bedc2cb963e..cc83d57919e 100644 --- a/app/lib/l10n/app_localizations_kn.dart +++ b/app/lib/l10n/app_localizations_kn.dart @@ -9354,4 +9354,19 @@ class AppLocalizationsKn extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'ನೀವು ನಿಮ್ಮ ಸ್ವಂತ ಪ್ರತಿಲೇಖನ ಪೂರೈಕೆದಾರರನ್ನು ಬಳಸುತ್ತೀರಿ. ಈ ಧ್ವನಿಮುದ್ರಣಗಳನ್ನು ಸಿಂಕ್ ಮಾಡಿದರೆ ಅವು Omi ಸರ್ವರ್‌ಗಳಲ್ಲಿ ಪ್ರತಿಲೇಖನಗೊಳ್ಳುತ್ತವೆ ಮತ್ತು ನಿಮ್ಮ ಯೋಜನೆಯ ಪ್ರತಿಲೇಖನ ಮಿತಿಗೆ ಎಣಿಸಲಾಗುತ್ತದೆ.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 656b6ea6f3e..87439b9888f 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -9173,4 +9173,19 @@ class AppLocalizationsKo extends AppLocalizations { @override String get syncCustomSttWarningMessage => '직접 설정한 전사 제공자를 사용 중입니다. 이 녹음을 동기화하면 Omi 서버에서 전사되며 요금제의 전사 한도에 포함됩니다.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_lt.dart b/app/lib/l10n/app_localizations_lt.dart index 010d6626bef..afa6b6c3b5e 100644 --- a/app/lib/l10n/app_localizations_lt.dart +++ b/app/lib/l10n/app_localizations_lt.dart @@ -9337,4 +9337,19 @@ class AppLocalizationsLt extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Naudojate savo transkripcijos paslaugą. Sinchronizuojant šiuos įrašus jie transkribuojami Omi serveriuose ir įskaičiuojami į jūsų plano transkripcijos limitą.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_lv.dart b/app/lib/l10n/app_localizations_lv.dart index 74918e0df4d..912320d418b 100644 --- a/app/lib/l10n/app_localizations_lv.dart +++ b/app/lib/l10n/app_localizations_lv.dart @@ -9345,4 +9345,19 @@ class AppLocalizationsLv extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Jūs izmantojat savu transkripcijas pakalpojumu. Šo ierakstu sinhronizēšana tos transkribē Omi serveros, un tie tiek ieskaitīti jūsu plāna transkripcijas limitā.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_mk.dart b/app/lib/l10n/app_localizations_mk.dart index a81a6062a14..2614f3ee47e 100644 --- a/app/lib/l10n/app_localizations_mk.dart +++ b/app/lib/l10n/app_localizations_mk.dart @@ -9372,4 +9372,19 @@ class AppLocalizationsMk extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Користите сопствен провајдер за транскрипција. Синхронизирањето на овие снимки ги транскрибира на серверите на Omi и се сметаат во лимитот за транскрипција на вашиот план.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_mr.dart b/app/lib/l10n/app_localizations_mr.dart index 17f9b596327..fb774f5d191 100644 --- a/app/lib/l10n/app_localizations_mr.dart +++ b/app/lib/l10n/app_localizations_mr.dart @@ -9333,4 +9333,19 @@ class AppLocalizationsMr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'तुम्ही तुमचा स्वतःचा ट्रान्सक्रिप्शन प्रदाता वापरता. ही रेकॉर्डिंग सिंक केल्यास ती Omi च्या सर्व्हरवर ट्रान्सक्राइब होतील आणि तुमच्या प्लॅनच्या ट्रान्सक्रिप्शन मर्यादेत मोजली जातील.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ms.dart b/app/lib/l10n/app_localizations_ms.dart index 71bc3f4ad83..306918daec4 100644 --- a/app/lib/l10n/app_localizations_ms.dart +++ b/app/lib/l10n/app_localizations_ms.dart @@ -9346,4 +9346,19 @@ class AppLocalizationsMs extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Anda menggunakan penyedia transkripsi sendiri. Menyegerakkan rakaman ini akan mentranskripsikannya di pelayan Omi dan dikira dalam had transkripsi pelan anda.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_nl.dart b/app/lib/l10n/app_localizations_nl.dart index f8ac1f45441..526668afec8 100644 --- a/app/lib/l10n/app_localizations_nl.dart +++ b/app/lib/l10n/app_localizations_nl.dart @@ -9349,4 +9349,19 @@ class AppLocalizationsNl extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Je gebruikt je eigen transcriptieprovider. Door deze opnames te synchroniseren worden ze op de servers van Omi getranscribeerd en tellen ze mee voor de transcriptielimiet van je abonnement.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_no.dart b/app/lib/l10n/app_localizations_no.dart index c1cef039bac..1a2435b1133 100644 --- a/app/lib/l10n/app_localizations_no.dart +++ b/app/lib/l10n/app_localizations_no.dart @@ -9320,4 +9320,19 @@ class AppLocalizationsNo extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Du bruker din egen transkripsjonsleverandør. Synkronisering av disse opptakene transkriberer dem på Omis servere, og de teller mot transkripsjonsgrensen i abonnementet ditt.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_pl.dart b/app/lib/l10n/app_localizations_pl.dart index 37861d8f7b4..83323512471 100644 --- a/app/lib/l10n/app_localizations_pl.dart +++ b/app/lib/l10n/app_localizations_pl.dart @@ -9348,4 +9348,19 @@ class AppLocalizationsPl extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Używasz własnego dostawcy transkrypcji. Synchronizacja tych nagrań transkrybuje je na serwerach Omi i są wliczane do limitu transkrypcji w Twoim planie.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_pt.dart b/app/lib/l10n/app_localizations_pt.dart index ea286fa49ee..baa536be4d3 100644 --- a/app/lib/l10n/app_localizations_pt.dart +++ b/app/lib/l10n/app_localizations_pt.dart @@ -9328,4 +9328,19 @@ class AppLocalizationsPt extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Você usa seu próprio provedor de transcrição. Sincronizar estas gravações as transcreve nos servidores da Omi e elas contam para o limite de transcrição do seu plano.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ro.dart b/app/lib/l10n/app_localizations_ro.dart index cda35611da6..a6399665596 100644 --- a/app/lib/l10n/app_localizations_ro.dart +++ b/app/lib/l10n/app_localizations_ro.dart @@ -9368,4 +9368,19 @@ class AppLocalizationsRo extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Folosești propriul furnizor de transcriere. Sincronizarea acestor înregistrări le transcrie pe serverele Omi și se iau în calcul pentru limita de transcriere a planului tău.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ru.dart b/app/lib/l10n/app_localizations_ru.dart index a376087a8bb..3953ed65992 100644 --- a/app/lib/l10n/app_localizations_ru.dart +++ b/app/lib/l10n/app_localizations_ru.dart @@ -9356,4 +9356,19 @@ class AppLocalizationsRu extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Вы используете собственный сервис транскрипции. Синхронизация этих записей расшифрует их на серверах Omi, и они будут засчитаны в лимит транскрипции вашего тарифа.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_sk.dart b/app/lib/l10n/app_localizations_sk.dart index 52366229d5b..869396cfd5a 100644 --- a/app/lib/l10n/app_localizations_sk.dart +++ b/app/lib/l10n/app_localizations_sk.dart @@ -9315,4 +9315,19 @@ class AppLocalizationsSk extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Používate vlastného poskytovateľa prepisu. Synchronizácia týchto nahrávok ich prepíše na serveroch Omi a započítajú sa do limitu prepisu vášho plánu.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_sl.dart b/app/lib/l10n/app_localizations_sl.dart index a56d697c88e..331c75fc18e 100644 --- a/app/lib/l10n/app_localizations_sl.dart +++ b/app/lib/l10n/app_localizations_sl.dart @@ -9350,4 +9350,19 @@ class AppLocalizationsSl extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Uporabljate svojega ponudnika prepisa. Sinhronizacija teh posnetkov jih prepiše na strežnikih Omi in se štejejo v omejitev prepisa vašega paketa.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_sr.dart b/app/lib/l10n/app_localizations_sr.dart index c4ec734517e..0ee7fb1464b 100644 --- a/app/lib/l10n/app_localizations_sr.dart +++ b/app/lib/l10n/app_localizations_sr.dart @@ -9335,4 +9335,19 @@ class AppLocalizationsSr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Користите сопственог пружаоца транскрипције. Синхронизација ових снимака их транскрибује на Omi серверима и рачунају се у лимит транскрипције вашег плана.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_sv.dart b/app/lib/l10n/app_localizations_sv.dart index 7c541351934..b03fd2f6ef6 100644 --- a/app/lib/l10n/app_localizations_sv.dart +++ b/app/lib/l10n/app_localizations_sv.dart @@ -9328,4 +9328,19 @@ class AppLocalizationsSv extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Du använder din egen transkriberingsleverantör. Att synkronisera dessa inspelningar transkriberar dem på Omis servrar i stället, och de räknas mot din plans transkriberingsgräns.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ta.dart b/app/lib/l10n/app_localizations_ta.dart index ba3c083a4ca..bc1e0092c01 100644 --- a/app/lib/l10n/app_localizations_ta.dart +++ b/app/lib/l10n/app_localizations_ta.dart @@ -9389,4 +9389,19 @@ class AppLocalizationsTa extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'நீங்கள் உங்கள் சொந்த படியெடுப்பு வழங்குநரைப் பயன்படுத்துகிறீர்கள். இந்தப் பதிவுகளை ஒத்திசைத்தால் அவை Omi சேவையகங்களில் படியெடுக்கப்படும், மேலும் உங்கள் திட்டத்தின் படியெடுப்பு வரம்பில் கணக்கிடப்படும்.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_te.dart b/app/lib/l10n/app_localizations_te.dart index e519f04f7ac..489717d50d6 100644 --- a/app/lib/l10n/app_localizations_te.dart +++ b/app/lib/l10n/app_localizations_te.dart @@ -9372,4 +9372,19 @@ class AppLocalizationsTe extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'మీరు మీ సొంత ట్రాన్స్‌క్రిప్షన్ ప్రొవైడర్‌ను ఉపయోగిస్తున్నారు. ఈ రికార్డింగ్‌లను సింక్ చేస్తే అవి Omi సర్వర్‌లలో ట్రాన్స్‌క్రైబ్ చేయబడతాయి మరియు మీ ప్లాన్ ట్రాన్స్‌క్రిప్షన్ పరిమితిలో లెక్కించబడతాయి.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_th.dart b/app/lib/l10n/app_localizations_th.dart index 1d71adf2cf7..adff0f93172 100644 --- a/app/lib/l10n/app_localizations_th.dart +++ b/app/lib/l10n/app_localizations_th.dart @@ -9273,4 +9273,19 @@ class AppLocalizationsTh extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'คุณใช้ผู้ให้บริการถอดเสียงของคุณเอง การซิงค์การบันทึกเหล่านี้จะถอดเสียงบนเซิร์ฟเวอร์ของ Omi และจะนับรวมในขีดจำกัดการถอดเสียงของแพ็กเกจของคุณ'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_tl.dart b/app/lib/l10n/app_localizations_tl.dart index 04a5cc1aebd..39c54299180 100644 --- a/app/lib/l10n/app_localizations_tl.dart +++ b/app/lib/l10n/app_localizations_tl.dart @@ -9408,4 +9408,19 @@ class AppLocalizationsTl extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Gumagamit ka ng sarili mong transcription provider. Kapag in-sync ang mga recording na ito, ita-transcribe ang mga ito sa mga server ng Omi at mabibilang sa limitasyon ng transcription ng iyong plan.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_tr.dart b/app/lib/l10n/app_localizations_tr.dart index 6f104aeb7f6..b4abf4947e9 100644 --- a/app/lib/l10n/app_localizations_tr.dart +++ b/app/lib/l10n/app_localizations_tr.dart @@ -9334,4 +9334,19 @@ class AppLocalizationsTr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Kendi transkripsiyon sağlayıcınızı kullanıyorsunuz. Bu kayıtları eşitlemek onları Omi sunucularında yazıya döker ve planınızın transkripsiyon sınırına sayılır.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_uk.dart b/app/lib/l10n/app_localizations_uk.dart index a36c29b4b11..ab2645e5ae2 100644 --- a/app/lib/l10n/app_localizations_uk.dart +++ b/app/lib/l10n/app_localizations_uk.dart @@ -9341,4 +9341,19 @@ class AppLocalizationsUk extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Ви використовуєте власний сервіс транскрипції. Синхронізація цих записів розшифрує їх на серверах Omi, і вони зараховуються до ліміту транскрипції вашого тарифу.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ur.dart b/app/lib/l10n/app_localizations_ur.dart index c0202d769fe..5b599b803ed 100644 --- a/app/lib/l10n/app_localizations_ur.dart +++ b/app/lib/l10n/app_localizations_ur.dart @@ -9337,4 +9337,19 @@ class AppLocalizationsUr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'آپ اپنا ذاتی ٹرانسکرپشن فراہم کنندہ استعمال کرتے ہیں۔ ان ریکارڈنگز کو سنک کرنے پر یہ Omi کے سرورز پر ٹرانسکرائب ہوں گی اور آپ کے پلان کی ٹرانسکرپشن حد میں شمار ہوں گی۔'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_vi.dart b/app/lib/l10n/app_localizations_vi.dart index e905238c037..1b723e219ae 100644 --- a/app/lib/l10n/app_localizations_vi.dart +++ b/app/lib/l10n/app_localizations_vi.dart @@ -9321,4 +9321,19 @@ class AppLocalizationsVi extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Bạn đang dùng nhà cung cấp phiên âm riêng. Đồng bộ các bản ghi này sẽ phiên âm chúng trên máy chủ của Omi và được tính vào giới hạn phiên âm của gói của bạn.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 1be7890fe9d..0e4f357b0ae 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -9157,4 +9157,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get syncCustomSttWarningMessage => '您使用的是自己的转录服务。同步这些录音会改为在 Omi 的服务器上转录,并计入您套餐的转录额度。'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/main.dart b/app/lib/main.dart index 6fac302a762..f145f749877 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -48,6 +48,7 @@ import 'package:omi/providers/folder_provider.dart'; import 'package:omi/providers/goals_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/integration_provider.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; import 'package:omi/providers/locale_provider.dart'; import 'package:omi/providers/mcp_provider.dart'; import 'package:omi/providers/memories_provider.dart'; @@ -292,21 +293,21 @@ class _MyAppState extends State with WidgetsBindingObserver { update: (BuildContext context, value, MessageProvider? previous) => (previous?..updateAppProvider(value)) ?? MessageProvider(), ), - ChangeNotifierProxyProvider4< - ConversationProvider, - MessageProvider, - PeopleProvider, - UsageProvider, - CaptureProvider - >( + ChangeNotifierProxyProvider4( create: (context) => CaptureProvider(), update: (BuildContext context, conversation, message, people, usage, CaptureProvider? previous) => (previous?..updateProviderInstances(conversation, message, people, usage)) ?? CaptureProvider(), ), - ChangeNotifierProxyProvider( + ChangeNotifierProxyProvider( + create: (context) => LocalRecordingsProvider(), + update: (BuildContext context, conversation, LocalRecordingsProvider? previous) => + (previous?..setConversationProvider(conversation)) ?? LocalRecordingsProvider(), + ), + ChangeNotifierProxyProvider2( create: (context) => DeviceProvider(), - update: (BuildContext context, captureProvider, DeviceProvider? previous) => - (previous?..setProviders(captureProvider)) ?? DeviceProvider(), + update: (BuildContext context, captureProvider, localRecordings, DeviceProvider? previous) => + (previous?..setProviders(captureProvider, localRecordings)) ?? DeviceProvider(), ), ChangeNotifierProxyProvider( create: (context) => OnboardingProvider(), diff --git a/app/lib/models/local_recording.dart b/app/lib/models/local_recording.dart new file mode 100644 index 00000000000..54e191e5f50 --- /dev/null +++ b/app/lib/models/local_recording.dart @@ -0,0 +1,92 @@ +import 'package:omi/backend/schema/bt_device/bt_device.dart'; +import 'package:omi/utils/batch_recording.dart'; + +/// Lifecycle of a local recording captured in batch/offline mode. +enum LocalRecordingState { + /// On disk, not yet uploaded. The user can transcribe it. + pending, + + /// Upload in flight (multipart POST to /v2/sync-local-files). + uploading, + + /// Uploaded; the server is transcribing in the background (job reconciling). + processing, + + /// The last upload attempt failed; the file is intact and can be retried. + failed, +} + +/// A recording captured in batch/offline mode and written natively to the phone +/// as a length-prefixed `.bin` file (see [BatchRecordingInfo]). +/// +/// Unlike a WAL it is **not** backed by the offline-sync store — the file on +/// disk is the single source of truth and this object is derived on demand by +/// scanning the recordings directory (fieldy-style). Uploading one turns it +/// into a conversation. See [LocalRecordingsProvider]. +class LocalRecording { + /// The `.bin` file name. Doubles as the stable id and the relative path that + /// `AudioPlayerUtils`/`Wal.getFilePath` resolve against the app documents dir. + final String fileName; + + /// Absolute path on disk. + final String filePath; + + /// Recording start time, unix seconds (parsed from the filename). + final int timerStart; + + final BleAudioCodec codec; + final int frameSize; + final int sizeBytes; + + /// Estimated duration in seconds (the backend computes the exact value). + final int seconds; + + /// Server job id once uploaded (HTTP 202); null while [LocalRecordingState.pending]. + final String? jobId; + + final LocalRecordingState state; + + const LocalRecording({ + required this.fileName, + required this.filePath, + required this.timerStart, + required this.codec, + required this.frameSize, + required this.sizeBytes, + required this.seconds, + required this.state, + this.jobId, + }); + + String get id => fileName; + + DateTime get startedAt => DateTime.fromMillisecondsSinceEpoch(timerStart * 1000); + + /// True while uploading or processing — playback/delete stay allowed, but a + /// second upload must not start. + bool get isBusy => state == LocalRecordingState.uploading || state == LocalRecordingState.processing; + + /// Build from a finalized batch `.bin` file on disk. Returns null if [fileName] + /// isn't a parseable recording filename or the file is empty. + static LocalRecording? fromFile({ + required String fileName, + required String filePath, + required int sizeBytes, + String? jobId, + required LocalRecordingState state, + }) { + final info = BatchRecordingInfo.fromFileName(fileName); + if (info == null || sizeBytes <= 0) return null; + return LocalRecording( + fileName: fileName, + filePath: filePath, + timerStart: info.timerStart, + codec: info.codec, + frameSize: info.frameSize, + sizeBytes: sizeBytes, + seconds: info.estimateSeconds(sizeBytes), + jobId: jobId, + state: state, + ); + } +} diff --git a/app/lib/pages/conversations/conversations_page.dart b/app/lib/pages/conversations/conversations_page.dart index ce491f5a20d..4fd471ed9db 100644 --- a/app/lib/pages/conversations/conversations_page.dart +++ b/app/lib/pages/conversations/conversations_page.dart @@ -17,6 +17,8 @@ import 'package:omi/pages/conversations/widgets/search_widget.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; +import 'package:omi/models/local_recording.dart'; import 'package:omi/providers/folder_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/services/app_review_service.dart'; @@ -64,6 +66,9 @@ class _ConversationsPageState extends State with AutomaticKee if (!mounted) return; + // Surface any unsynced batch recordings written by the native layer. + context.read().refresh(); + // Load folders for folder tabs final folderProvider = context.read(); if (folderProvider.folders.isEmpty) { @@ -250,6 +255,28 @@ class _ConversationsPageState extends State with AutomaticKee super.build(context); return Consumer( builder: (context, convoProvider, child) { + // Unsynced local recordings (batch/offline mode) shown inline with conversations, + // grouped into the same date buckets. Only in the default view (no search/folder/ + // starred/daily-summaries filter). + final recordingsProvider = context.watch(); + final bool showRecordings = convoProvider.previousQuery.isEmpty && + convoProvider.selectedFolderId == null && + !convoProvider.showStarredOnly && + !convoProvider.showDailySummaries; + final recordingsByDate = >{}; + if (showRecordings) { + // Batch/offline-mode recordings captured locally — a separate subsystem + // from device offline-sync (which lives on the Sync page). + for (final rec in recordingsProvider.recordings) { + final dt = DateTime.fromMillisecondsSinceEpoch(rec.timerStart * 1000); + final day = DateTime(dt.year, dt.month, dt.day); + (recordingsByDate[day] ??= []).add(rec); + } + } + final bool hasRecordings = recordingsByDate.isNotEmpty; + final mergedDates = {...convoProvider.groupedConversations.keys, ...recordingsByDate.keys}.toList() + ..sort((a, b) => b.compareTo(a)); + return RefreshIndicator( onRefresh: () async { HapticFeedback.mediumImpact(); @@ -369,6 +396,7 @@ class _ConversationsPageState extends State with AutomaticKee if (convoProvider.showDailySummaries) const DailySummariesList() else if (_nonDiscardedConversationCount(convoProvider) == 0 && + !hasRecordings && !convoProvider.isLoadingConversations && !convoProvider.isFetchingConversations && !convoProvider.isAwaitingInitialFetchRetry && @@ -380,6 +408,7 @@ class _ConversationsPageState extends State with AutomaticKee child: Center(child: _buildNoConversationsHero(context)), ) else if (convoProvider.groupedConversations.isEmpty && + !hasRecordings && !convoProvider.isLoadingConversations && !convoProvider.isFetchingConversations && !convoProvider.isAwaitingInitialFetchRetry) @@ -392,17 +421,18 @@ class _ConversationsPageState extends State with AutomaticKee ), ) else if (convoProvider.groupedConversations.isEmpty && + !hasRecordings && (convoProvider.isLoadingConversations || convoProvider.isFetchingConversations || convoProvider.isAwaitingInitialFetchRetry)) _buildLoadingShimmer() else SliverList( - delegate: SliverChildBuilderDelegate(childCount: convoProvider.groupedConversations.length + 1, ( + delegate: SliverChildBuilderDelegate(childCount: mergedDates.length + 1, ( context, index, ) { - if (index == convoProvider.groupedConversations.length) { + if (index == mergedDates.length) { Logger.debug('loading more conversations'); if (convoProvider.isLoadingConversations) { return _buildLoadMoreShimmer(); @@ -427,8 +457,10 @@ class _ConversationsPageState extends State with AutomaticKee child: const SizedBox(height: 20, width: double.maxFinite), ); } else { - var date = convoProvider.groupedConversations.keys.elementAt(index); - List memoriesForDate = convoProvider.groupedConversations[date]!; + var date = mergedDates[index]; + List memoriesForDate = + convoProvider.groupedConversations[date] ?? const []; + List recordingsForDate = recordingsByDate[date] ?? const []; return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -437,6 +469,7 @@ class _ConversationsPageState extends State with AutomaticKee key: ValueKey(date), isFirst: index == 0, conversations: memoriesForDate, + recordings: recordingsForDate, date: date, ), ], diff --git a/app/lib/pages/conversations/recording_detail/recording_detail_page.dart b/app/lib/pages/conversations/recording_detail/recording_detail_page.dart new file mode 100644 index 00000000000..4df694ea269 --- /dev/null +++ b/app/lib/pages/conversations/recording_detail/recording_detail_page.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/models/local_recording.dart'; +import 'package:omi/models/playback_state.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; +import 'package:omi/ui/molecules/omi_confirm_dialog.dart'; +import 'package:omi/utils/l10n_extensions.dart'; +import 'package:omi/utils/other/temp.dart'; +import 'package:omi/utils/other/time_utils.dart'; +import 'package:omi/widgets/waveform_section.dart'; + +/// Detail screen for a batch/offline-mode recording captured locally. Unlike the +/// WAL detail page it has no SD/flash transfer flow (recordings are always on the +/// phone) and adds a primary "transcribe" action that uploads the file and turns +/// it into a conversation. Backed entirely by [LocalRecordingsProvider]. +class RecordingDetailPage extends StatefulWidget { + final LocalRecording recording; + + const RecordingDetailPage({super.key, required this.recording}); + + @override + State createState() => _RecordingDetailPageState(); +} + +class _RecordingDetailPageState extends State { + List? _waveformData; + bool _isProcessingWaveform = false; + LocalRecordingsProvider? _provider; + + @override + void initState() { + super.initState(); + _generateWaveform(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _provider = context.read(); + } + + @override + void dispose() { + // Stop playback when leaving the page. + if (_provider != null && _provider!.isPlaying(widget.recording)) { + _provider!.togglePlayback(widget.recording); + } + super.dispose(); + } + + Future _generateWaveform() async { + if (!mounted) return; + setState(() => _isProcessingWaveform = true); + final data = await context.read().getWaveform(widget.recording); + if (mounted) { + setState(() { + _waveformData = data; + _isProcessingWaveform = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + automaticallyImplyLeading: true, + title: Text(context.l10n.recordingDetails, style: Theme.of(context).textTheme.titleLarge), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.more_horiz, color: Colors.white), + onPressed: () => _showOptionsMenu(context), + ), + ], + ), + backgroundColor: Theme.of(context).colorScheme.primary, + body: Consumer( + builder: (context, provider, child) { + final rec = provider.getById(widget.recording.id) ?? widget.recording; + final isPlaying = provider.isPlaying(rec); + final playbackState = PlaybackState( + isPlaying: isPlaying, + isProcessing: provider.isProcessingAudio && isPlaying, + canPlayOrShare: provider.canPlay(rec), + isSynced: false, + hasError: rec.state == LocalRecordingState.failed, + currentPosition: provider.currentPosition, + totalDuration: provider.totalDuration, + playbackProgress: provider.playbackProgress, + ); + + return Column( + children: [ + // Title section + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + children: [ + Text( + dateTimeFormat('dd MMM yyyy', rec.startedAt), + style: + Theme.of(context).textTheme.titleLarge!.copyWith(fontSize: 28, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + dateTimeFormat('H:mm', rec.startedAt), + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Colors.grey.shade400, + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.security, color: Colors.grey.shade400, size: 14), + const SizedBox(width: 6), + Text( + context.l10n.privateAndSecureOnDevice, + style: TextStyle(color: Colors.grey.shade400, fontSize: 12, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], + ), + ), + + // Waveform — dominant space + Expanded( + flex: 6, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: WaveformSection( + seconds: rec.seconds, + waveformData: _waveformData, + isProcessingWaveform: _isProcessingWaveform, + playbackState: playbackState, + isPlaying: isPlaying, + ), + ), + ), + + // Timer + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + _formatPosition(isPlaying ? provider.currentPosition : Duration.zero), + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith(fontSize: 48, fontWeight: FontWeight.w300, letterSpacing: 2), + ), + ), + + // Transport controls + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildControlButton( + icon: Icons.replay_10, + onPressed: playbackState.canPlayOrShare && isPlaying ? () => provider.skipBackward() : null, + size: 60, + ), + _buildControlButton( + icon: playbackState.isProcessing + ? Icons.hourglass_empty + : (isPlaying ? Icons.pause : Icons.play_arrow), + size: 80, + backgroundColor: Theme.of(context).colorScheme.secondary, + iconColor: Colors.white, + onPressed: playbackState.canPlayOrShare && !playbackState.isProcessing + ? () => provider.togglePlayback(rec) + : null, + ), + _buildControlButton( + icon: Icons.forward_10, + onPressed: playbackState.canPlayOrShare && isPlaying ? () => provider.skipForward() : null, + size: 60, + ), + ], + ), + ), + + // Primary action: transcribe (upload → conversation) + Padding( + padding: const EdgeInsets.fromLTRB(40, 12, 40, 32), + child: SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: rec.isBusy ? null : () => _handleTranscribe(provider, rec), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurpleAccent, + disabledBackgroundColor: Colors.deepPurple.withValues(alpha: 0.3), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (rec.isBusy) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + else + const Icon(Icons.cloud_upload_outlined, color: Colors.white, size: 22), + const SizedBox(width: 12), + Text( + rec.isBusy ? context.l10n.syncStatusUploaded : context.l10n.syncNow, + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ), + ); + } + + String _formatPosition(Duration duration) { + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + final centis = (duration.inMilliseconds.remainder(1000) / 10).floor(); + return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')},${centis.toString().padLeft(2, '0')}'; + } + + Widget _buildControlButton({ + required IconData icon, + VoidCallback? onPressed, + double size = 48, + Color? backgroundColor, + Color? iconColor, + }) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: backgroundColor ?? Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + ), + child: IconButton( + onPressed: onPressed, + icon: Icon(icon, color: iconColor ?? Colors.white, size: size * 0.4), + ), + ); + } + + Future _handleTranscribe(LocalRecordingsProvider provider, LocalRecording rec) async { + await provider.upload(rec); + if (mounted) Navigator.of(context).maybePop(); + } + + void _showOptionsMenu(BuildContext context) { + final provider = context.read(); + final rec = provider.getById(widget.recording.id) ?? widget.recording; + + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1F1F25), + builder: (sheetContext) => Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.info_outline, color: Colors.white), + title: Text(context.l10n.recordingInfo, style: Theme.of(sheetContext).textTheme.bodyMedium), + onTap: () { + Navigator.pop(sheetContext); + _showFileDetailsDialog(context, rec); + }, + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.share, color: Colors.white, size: 18), + title: Text(context.l10n.shareRecording, style: Theme.of(sheetContext).textTheme.bodyMedium), + onTap: () { + Navigator.pop(sheetContext); + provider.share(rec); + }, + ), + ListTile( + leading: Icon(Icons.delete, color: rec.isBusy ? Colors.grey : Colors.red), + title: Text( + context.l10n.deleteRecording, + style: + Theme.of(sheetContext).textTheme.bodyMedium!.copyWith(color: rec.isBusy ? Colors.grey : Colors.red), + ), + onTap: rec.isBusy + ? null + : () { + Navigator.pop(sheetContext); + _showDeleteDialog(context, provider, rec); + }, + ), + ], + ), + ), + ); + } + + void _showDeleteDialog(BuildContext context, LocalRecordingsProvider provider, LocalRecording rec) async { + final navigator = Navigator.of(context); + final confirmed = await OmiConfirmDialog.show( + context, + title: context.l10n.deleteRecording, + message: context.l10n.deleteRecordingConfirmation, + confirmLabel: context.l10n.delete, + confirmColor: Colors.red, + ); + if (confirmed == true && mounted) { + navigator.pop(); + provider.delete(rec); + } + } + + void _showFileDetailsDialog(BuildContext context, LocalRecording rec) { + final theme = Theme.of(context); + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1A1A1A), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow(context.l10n.dateTimeLabel, dateTimeFormat('MMM dd, yyyy h:mm:ss a', rec.startedAt)), + _buildDetailRow(context.l10n.durationLabel, secondsToHumanReadable(rec.seconds, context)), + _buildDetailRow(context.l10n.audioFormatLabel, rec.codec.toFormattedString()), + _buildDetailRow(context.l10n.estimatedSizeLabel, _formatBytes(rec.sizeBytes)), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + context.l10n.close, + style: theme.textTheme.labelMedium?.copyWith(color: theme.colorScheme.secondary), + ), + ), + ], + ), + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium!.copyWith(color: Colors.grey.shade400)), + const SizedBox(height: 2), + Text(value, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} diff --git a/app/lib/pages/conversations/widgets/conversations_group_widget.dart b/app/lib/pages/conversations/widgets/conversations_group_widget.dart index 60108a986f2..6e47aca38a3 100644 --- a/app/lib/pages/conversations/widgets/conversations_group_widget.dart +++ b/app/lib/pages/conversations/widgets/conversations_group_widget.dart @@ -1,35 +1,57 @@ import 'package:flutter/material.dart'; import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/models/local_recording.dart'; import 'conversation_list_item.dart'; import 'date_list_item.dart'; +import 'recording_list_item.dart'; class ConversationsGroupWidget extends StatelessWidget { final List conversations; + + /// Unsynced local recordings (batch/offline mode) for this date, interleaved with + /// conversations by time. They have no title/icon yet — see [RecordingListItem]. + final List recordings; final DateTime date; final bool isFirst; - const ConversationsGroupWidget({super.key, required this.conversations, required this.date, required this.isFirst}); + const ConversationsGroupWidget({ + super.key, + required this.conversations, + this.recordings = const [], + required this.date, + required this.isFirst, + }); @override Widget build(BuildContext context) { - if (conversations.isNotEmpty) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - DateListItem(date: date, isFirst: isFirst), - ...conversations.map((conversation) { + if (conversations.isEmpty && recordings.isEmpty) { + return const SizedBox.shrink(); + } + + // Merge conversations and recordings into one time-sorted list (newest first), + // matching how conversations are ordered within a date. + final entries = <({DateTime time, ServerConversation? convo, LocalRecording? rec})>[ + for (final c in conversations) (time: c.startedAt ?? c.createdAt, convo: c, rec: null), + for (final r in recordings) (time: r.startedAt, convo: null, rec: r), + ]..sort((a, b) => b.time.compareTo(a.time)); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + DateListItem(date: date, isFirst: isFirst), + ...entries.map((e) { + if (e.convo != null) { return ConversationListItem( - key: ValueKey(conversation.id), - conversation: conversation, - conversationIdx: conversations.indexOf(conversation), + key: ValueKey(e.convo!.id), + conversation: e.convo!, + conversationIdx: conversations.indexOf(e.convo!), date: date, ); - }), - const SizedBox(height: 10), - ], - ); - } else { - return const SizedBox.shrink(); - } + } + return RecordingListItem(key: ValueKey('rec_${e.rec!.id}'), recording: e.rec!); + }), + const SizedBox(height: 10), + ], + ); } } diff --git a/app/lib/pages/conversations/widgets/recording_list_item.dart b/app/lib/pages/conversations/widgets/recording_list_item.dart new file mode 100644 index 00000000000..b72f5da3c63 --- /dev/null +++ b/app/lib/pages/conversations/widgets/recording_list_item.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/models/local_recording.dart'; +import 'package:omi/pages/conversations/recording_detail/recording_detail_page.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; +import 'package:omi/utils/l10n_extensions.dart'; +import 'package:omi/utils/other/temp.dart'; + +/// A row in the conversations list for a batch/offline-mode recording captured +/// locally. Unlike a conversation it has no title/icon yet — it shows the +/// recording's time + duration, its state, and an inline play/pause button that +/// decodes and plays the local audio on device. Tapping opens the recording +/// detail page (transcribe, share, delete). +class RecordingListItem extends StatelessWidget { + final LocalRecording recording; + + const RecordingListItem({super.key, required this.recording}); + + String _formatDuration(int seconds) { + final m = seconds ~/ 60; + final s = seconds % 60; + return '$m:${s.toString().padLeft(2, '0')}'; + } + + (Color, String) _status(BuildContext context) { + final l = context.l10n; + switch (recording.state) { + case LocalRecordingState.uploading: + return (Colors.grey.shade300, l.syncStatusBackingUp); + case LocalRecordingState.processing: + return (Colors.grey.shade400, l.syncStatusUploaded); + case LocalRecordingState.failed: + return (Colors.redAccent, l.failedStatus); + case LocalRecordingState.pending: + return (Colors.grey.shade500, l.privateAndSecureOnDevice); + } + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + final (statusColor, statusLabel) = _status(context); + final isPlaying = provider.isPlaying(recording); + final timeStr = + dateTimeFormat('h:mm a', recording.startedAt, locale: Localizations.localeOf(context).languageCode); + + return Padding( + padding: const EdgeInsets.only(top: 12, left: 16, right: 16), + child: Container( + width: double.maxFinite, + decoration: BoxDecoration(color: const Color(0xFF1F1F25), borderRadius: BorderRadius.circular(24.0)), + child: ClipRRect( + borderRadius: BorderRadius.circular(24.0), + child: Dismissible( + key: ValueKey('rec_${recording.id}'), + direction: recording.isBusy ? DismissDirection.none : DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + color: Colors.red, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + onDismissed: (_) => provider.delete(recording), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => RecordingDetailPage(recording: recording)), + ); + }, + child: Padding( + padding: const EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 18), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.deepPurple.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.graphic_eq, color: Colors.deepPurpleAccent, size: 20), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$timeStr · ${_formatDuration(recording.seconds)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 3), + Text( + statusLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: statusColor, fontSize: 12), + ), + ], + ), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: () => provider.togglePlayback(recording), + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Colors.deepPurpleAccent.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.deepPurpleAccent, + size: 24, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index 2e34e8c9bd7..86c0ba395c4 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -49,6 +49,7 @@ import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/connectivity_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/device_provider.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; import 'package:omi/providers/announcement_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/message_provider.dart'; @@ -188,7 +189,10 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker // Reload convos if (mounted) { Provider.of(context, listen: false).refreshConversations(); - Provider.of(context, listen: false).refreshInProgressConversations(); + final captureProvider = Provider.of(context, listen: false); + captureProvider.refreshInProgressConversations(); + // Pick up any batch recordings the native layer wrote while backgrounded/closed. + Provider.of(context, listen: false).refresh(); } // Ensure agent VM is running and restart keepalive diff --git a/app/lib/pages/settings/profile.dart b/app/lib/pages/settings/profile.dart index 18ce7e10177..5219099c5df 100644 --- a/app/lib/pages/settings/profile.dart +++ b/app/lib/pages/settings/profile.dart @@ -3,7 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:omi/backend/preferences.dart'; +import 'package:omi/providers/capture_provider.dart'; import 'package:omi/pages/payments/payments_page.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; import 'package:omi/pages/settings/change_name_widget.dart'; import 'package:omi/pages/settings/language_settings_page.dart'; import 'package:omi/pages/settings/custom_vocabulary_page.dart'; @@ -315,6 +318,118 @@ class _ProfilePageState extends State { ); } + void _showOfflineModeSheet() { + final captureProvider = context.read(); + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1C1C1E), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + final enabled = SharedPreferencesUtil().batchModeEnabled; + Future setEnabled(bool value) async { + SharedPreferencesUtil().batchModeEnabled = value; + final docs = await getApplicationDocumentsDirectory(); + await SharedPreferencesUtil().saveString('batchAudioDir', docs.path); + // Batch capture takes precedence over background streaming. + await SharedPreferencesUtil() + .saveBool('nativeBleStreamingEnabled', !value && SharedPreferencesUtil().backgroundModeEnabled); + setSheetState(() {}); + setState(() {}); + // Re-apply capture so the transcription socket closes/opens to match. + try { + await captureProvider.onRecordProfileSettingChanged(); + } catch (_) {} + } + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(bottom: 16), + width: 36, + height: 4, + decoration: + BoxDecoration(color: const Color(0xFF3C3C43), borderRadius: BorderRadius.circular(2)), + ), + ), + Row( + children: [ + Expanded( + child: Text( + context.l10n.offlineModeTitle, + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), + ), + ), + Switch( + value: enabled, + activeThumbColor: Colors.white, + activeTrackColor: const Color(0xFF8B5CF6), + onChanged: (v) => setEnabled(v), + ), + ], + ), + const SizedBox(height: 8), + Text( + context.l10n.offlineModeDescription, + style: TextStyle(color: Colors.grey.shade400, fontSize: 14, height: 1.4), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: + BoxDecoration(color: const Color(0xFF2A2A2E), borderRadius: BorderRadius.circular(12)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: Colors.grey.shade400, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + context.l10n.offlineModeNote, + style: TextStyle(color: Colors.grey.shade400, fontSize: 13, height: 1.4), + ), + ), + ], + ), + ), + if (SharedPreferencesUtil().getBool('batchStorageFull')) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: + BoxDecoration(color: const Color(0xFF3A2A2A), borderRadius: BorderRadius.circular(12)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: Color(0xFFE0A030), size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + context.l10n.offlineModeStorageFull, + style: TextStyle(color: Colors.orange.shade200, fontSize: 13, height: 1.4), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ); + }, + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -419,6 +534,14 @@ class _ProfilePageState extends State { onTap: _showBackgroundModeSheet, ), ], + const Divider(height: 1, color: Color(0xFF3C3C43)), + _buildProfileItem( + title: context.l10n.offlineModeTitle, + icon: const FaIcon(FontAwesomeIcons.floppyDisk, color: Color(0xFF8E8E93), size: 20), + showBetaTag: true, + chipValue: SharedPreferencesUtil().batchModeEnabled ? context.l10n.on : context.l10n.off, + onTap: _showOfflineModeSheet, + ), ], ), const SizedBox(height: 32), diff --git a/app/lib/providers/capture_provider.dart b/app/lib/providers/capture_provider.dart index 0ec05d6bc2e..0b09c90fed4 100644 --- a/app/lib/providers/capture_provider.dart +++ b/app/lib/providers/capture_provider.dart @@ -523,6 +523,14 @@ class CaptureProvider extends ChangeNotifier }) async { Logger.debug('initiateWebsocket in capture_provider'); + // Batch (offline) mode: never open the realtime transcription socket. The + // native layer stores incoming BLE audio to local .bin files instead, and + // the user uploads recordings later. See _saveNativeBleStreamConfig. + if (SharedPreferencesUtil().batchModeEnabled) { + Logger.debug('Batch mode enabled — skipping transcription websocket'); + return; + } + BleAudioCodec codec = audioCodec; sampleRate ??= mapCodecToSampleRate(codec); channels ??= (codec == BleAudioCodec.pcm16 || codec == BleAudioCodec.pcm8) ? 1 : 2; @@ -765,9 +773,10 @@ class CaptureProvider extends ChangeNotifier _commandBytes.add(payload); } - // Local storage syncs - var checkWalSupported = (_recordingDevice?.type == DeviceType.omi || - _recordingDevice?.type == DeviceType.openglass) && + // Local storage syncs. In batch mode the native layer owns writing the + // .bin files, so the Dart WAL writer must stay off to avoid double-writes. + var checkWalSupported = !SharedPreferencesUtil().batchModeEnabled && + (_recordingDevice?.type == DeviceType.omi || _recordingDevice?.type == DeviceType.openglass) && codec.isOpusSupported() && (_socket?.state != SocketServiceState.connected || SharedPreferencesUtil().unlimitedLocalStorageEnabled); if (checkWalSupported != _isWalSupported) { @@ -942,8 +951,18 @@ class CaptureProvider extends ChangeNotifier 'deviceType': device.type.name, }), ); + // Batch (offline) capture: tell the native writer where to store .bin files + // and ensure the native realtime socket is disabled while batch mode is on + // (batch mode takes precedence over background streaming). + final batchMode = SharedPreferencesUtil().batchModeEnabled; + final docsDir = await getApplicationDocumentsDirectory(); + await SharedPreferencesUtil().saveString('batchAudioDir', docsDir.path); + await SharedPreferencesUtil().saveBool('nativeBleForegroundReady', false); - await SharedPreferencesUtil().saveBool('nativeBleStreamingEnabled', SharedPreferencesUtil().backgroundModeEnabled); + await SharedPreferencesUtil() + .saveBool('nativeBleStreamingEnabled', !batchMode && SharedPreferencesUtil().backgroundModeEnabled); + Logger.debug('[batch] config saved: batchMode=$batchMode dir=${docsDir.path} ' + 'deviceId=${device.id} svc=${audioTarget.key} char=${audioTarget.value} type=${device.type.name}'); } MapEntry? _nativeBleAudioTarget(BtDevice device) { diff --git a/app/lib/providers/device_provider.dart b/app/lib/providers/device_provider.dart index 64c8b9d1634..8cbab71f534 100644 --- a/app/lib/providers/device_provider.dart +++ b/app/lib/providers/device_provider.dart @@ -13,6 +13,7 @@ import 'package:omi/app_globals.dart'; import 'package:omi/pages/home/firmware_update.dart'; import 'package:omi/pages/home/omiglass_ota_update.dart'; import 'package:omi/providers/capture_provider.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; import 'package:omi/services/devices.dart'; import 'package:omi/services/devices/omi_connection.dart'; import 'package:omi/services/notifications.dart'; @@ -27,6 +28,7 @@ import 'package:omi/widgets/confirmation_dialog.dart'; class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption { CaptureProvider? captureProvider; + LocalRecordingsProvider? localRecordingsProvider; bool isConnecting = false; bool isConnected = false; @@ -75,8 +77,9 @@ class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption ServiceManager.instance().device.subscribe(this, this); } - void setProviders(CaptureProvider provider) { + void setProviders(CaptureProvider provider, LocalRecordingsProvider recordingsProvider) { captureProvider = provider; + localRecordingsProvider = recordingsProvider; notifyListeners(); } @@ -385,6 +388,13 @@ class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption captureProvider?.updateRecordingDevice(null); + // Batch mode: the native writer finalizes the in-progress recording on + // disconnect (.bin.part -> .bin). Rescan shortly after the rename completes + // so the new recording shows up in the conversations list. + Future.delayed(const Duration(seconds: 1), () { + localRecordingsProvider?.refresh(); + }); + // Wals ServiceManager.instance().wal.getSyncs().sdcard.setDevice(null); ServiceManager.instance().wal.getSyncs().flashPage.setDevice(null); diff --git a/app/lib/providers/local_recordings_provider.dart b/app/lib/providers/local_recordings_provider.dart new file mode 100644 index 00000000000..7f485f811ae --- /dev/null +++ b/app/lib/providers/local_recordings_provider.dart @@ -0,0 +1,354 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:omi/backend/preferences.dart'; +import 'package:omi/models/local_recording.dart'; +import 'package:omi/providers/conversation_provider.dart'; +import 'package:omi/services/wals.dart'; +import 'package:omi/utils/audio_player_utils.dart'; +import 'package:omi/utils/batch_recording.dart'; +import 'package:omi/utils/conversation_sync_utils.dart'; +import 'package:omi/utils/logger.dart'; +import 'package:omi/utils/waveform_utils.dart'; + +/// Owns the batch/offline-mode recordings captured natively to the phone. +/// +/// Design (fieldy-style): the recordings directory is the queue and the `.bin` +/// files are the single source of truth — there is no WAL/offline-sync entry. +/// The list is derived by scanning the dir; uploading a recording turns it into +/// a conversation. Only the truly-stateless leaf helpers are reused: +/// [uploadLocalFilesV2] (the `/v2/sync-local-files` call), [fetchSyncJobStatus] +/// (reconcile), and [AudioPlayerUtils] (opus playback, fed a transient [Wal]). +/// +/// Robustness beyond fieldy: an in-flight upload's `jobId` is persisted in a +/// tiny sidecar (SharedPreferences) so an app-kill mid-processing reconciles on +/// next launch, and the local file is deleted only once the job reports +/// `completed` (never fire-and-forget). +class LocalRecordingsProvider extends ChangeNotifier { + final AudioPlayerUtils _audio = AudioPlayerUtils.instance; + + // Sidecar: fileName -> server jobId for recordings uploaded but not yet + // confirmed transcribed. Persisted as JSON under [_jobsPrefKey]. + static const String _jobsPrefKey = 'localRecordingJobs'; + Map _jobs = {}; + + List _recordings = []; + List get recordings => _recordings; + + bool _isLoading = false; + bool get isLoading => _isLoading; + + // Single-flight upload guard + the file currently uploading (for its state). + bool _isUploading = false; + String? _uploadingName; + String? _failedName; // last upload that errored (file intact, retriable) + + Timer? _reconcileTimer; + bool _disposed = false; + + ConversationProvider? _conversationProvider; + + LocalRecordingsProvider() { + _audio.addListener(_onAudioChanged); + _jobs = _loadJobs(); + refresh(); + if (_jobs.isNotEmpty) { + _startReconcileTimer(); + _reconcile(); + } + } + + /// Wired from main.dart so a finished transcription can surface its + /// conversation into the list the user is looking at. + void setConversationProvider(ConversationProvider provider) { + _conversationProvider = provider; + } + + // ───────────────────────── scanning ───────────────────────── + + Future _dir() async { + final configured = SharedPreferencesUtil().getString('batchAudioDir'); + if (configured.isNotEmpty) return Directory(configured); + return getApplicationDocumentsDirectory(); + } + + LocalRecordingState _stateFor(String name) { + if (name == _uploadingName) return LocalRecordingState.uploading; + if (_jobs.containsKey(name)) return LocalRecordingState.processing; + if (name == _failedName) return LocalRecordingState.failed; + return LocalRecordingState.pending; + } + + /// Rescan the recordings directory and rebuild [recordings]. Cheap and + /// idempotent — call it on app foreground, after a BLE disconnect, etc. + Future refresh() async { + _isLoading = true; + notifyListeners(); + try { + final dir = await _dir(); + if (dir == null || !await dir.exists()) { + _recordings = []; + return; + } + final list = []; + for (final entity in dir.listSync().whereType()) { + final name = entity.path.split('/').last; + // Only batch recordings (audio_omibatch_*) — never offline-sync WAL flushes, + // which share this directory and the same audio_*.bin naming. The native + // writer stamps the `batchRecordingDevice` marker into the device segment. + if (!name.startsWith('audio_${batchRecordingDevice}_') || !name.endsWith('.bin')) continue; + final size = await entity.length(); + final rec = LocalRecording.fromFile( + fileName: name, + filePath: entity.path, + sizeBytes: size, + jobId: _jobs[name], + state: _stateFor(name), + ); + if (rec != null) list.add(rec); + } + list.sort((a, b) => b.timerStart.compareTo(a.timerStart)); + _recordings = list; + } catch (e) { + Logger.error('LocalRecordings: scan failed: $e'); + } finally { + _isLoading = false; + // Resume polling if recordings are still awaiting transcription (e.g. the + // timer was dropped while backgrounded and we just resumed). + if (_jobs.isNotEmpty) _startReconcileTimer(); + if (!_disposed) notifyListeners(); + } + } + + LocalRecording? getById(String id) { + for (final r in _recordings) { + if (r.id == id) return r; + } + return null; + } + + // ───────────────────── upload / transcribe ───────────────────── + + /// Upload a single recording → backend transcribes it into a conversation. + /// 200 fast-path: delete the file + surface the conversation immediately. + /// 202 queued: persist the jobId and let the reconciler finish it. + Future upload(LocalRecording rec) async { + if (_isUploading || rec.isBusy) return; + if (SyncRateLimiter.instance.isLimited) { + notifyListeners(); + return; + } + + _isUploading = true; + _uploadingName = rec.fileName; + _failedName = null; + await refresh(); + + try { + final file = File(rec.filePath); + if (!file.existsSync()) { + Logger.error('LocalRecordings: file missing on upload: ${rec.fileName}'); + return; + } + final result = await uploadLocalFilesV2([file]); + SyncRateLimiter.instance.clear(); + + if (result.completed != null) { + await _deleteFileOnly(rec.fileName); + await _surface(result.completed!.newConversationIds, result.completed!.updatedConversationIds); + } else if (result.jobId != null) { + _jobs[rec.fileName] = result.jobId!; + await _saveJobs(); + _startReconcileTimer(); + } + } on SyncRateLimitedException catch (e) { + SyncRateLimiter.instance.markLimited(retryAfterSeconds: e.retryAfterSeconds); + } catch (e) { + _failedName = rec.fileName; + Logger.error('LocalRecordings: upload failed for ${rec.fileName}: $e'); + } finally { + _isUploading = false; + _uploadingName = null; + await refresh(); + } + } + + // ───────────────────────── reconcile ───────────────────────── + + void _startReconcileTimer() { + _reconcileTimer ??= Timer.periodic(const Duration(seconds: 15), (_) => _reconcile()); + } + + void _stopReconcileTimer() { + _reconcileTimer?.cancel(); + _reconcileTimer = null; + } + + /// Poll every pending job once. `completed` → delete file + surface the + /// conversation. `failed`/`notFound` → drop the job; the file stays on disk + /// so it reverts to a pending, retriable recording. + Future _reconcile() async { + if (_jobs.isEmpty) { + _stopReconcileTimer(); + return; + } + final newIds = []; + final updIds = []; + bool changed = false; + + for (final entry in Map.from(_jobs).entries) { + final name = entry.key; + final jobId = entry.value; + SyncJobFetch fetch; + try { + fetch = await fetchSyncJobStatus(jobId); + } catch (_) { + continue; // transient — retry next tick + } + switch (fetch.outcome) { + case SyncJobFetchOutcome.transient: + break; + case SyncJobFetchOutcome.notFound: + _jobs.remove(name); + changed = true; + break; + case SyncJobFetchOutcome.ok: + final s = fetch.status!; + if (!s.isTerminal) break; + if (s.result != null) { + newIds.addAll(s.result!.newConversationIds); + updIds.addAll(s.result!.updatedConversationIds); + } + if (s.status == 'completed') { + await _deleteFileOnly(name); + } + // completed or failed: stop tracking. On failure the file is kept + // (only `completed` deletes it) so the recording becomes pending again. + _jobs.remove(name); + changed = true; + break; + } + } + + if (changed) await _saveJobs(); + if (newIds.isNotEmpty || updIds.isNotEmpty) await _surface(newIds, updIds); + await refresh(); + if (_jobs.isEmpty) _stopReconcileTimer(); + } + + Future _surface(List newIds, List updatedIds) async { + if (_conversationProvider == null) return; + if (newIds.isEmpty && updatedIds.isEmpty) return; + try { + final pointers = await ConversationSyncUtils.processConversationIds( + newConversationIds: newIds, + updatedConversationIds: updatedIds, + ); + for (final p in pointers) { + _conversationProvider!.upsertConversation(p.conversation); + } + } catch (e) { + Logger.error('LocalRecordings: surfacing conversations failed: $e'); + } + } + + // ───────────────────────── delete ───────────────────────── + + /// Delete a recording the user no longer wants. Stops playback first. + Future delete(LocalRecording rec) async { + if (isPlaying(rec)) { + await togglePlayback(rec); + } + await _deleteFileOnly(rec.fileName); + if (_jobs.remove(rec.fileName) != null) await _saveJobs(); + await refresh(); + } + + Future _deleteFileOnly(String fileName) async { + try { + final dir = await _dir(); + if (dir == null) return; + final file = File('${dir.path}/$fileName'); + if (file.existsSync()) await file.delete(); + } catch (e) { + Logger.error('LocalRecordings: delete failed for $fileName: $e'); + } + } + + // ───────────────────── playback / waveform ───────────────────── + + /// A throwaway [Wal] used only to drive [AudioPlayerUtils] (opus decode + + /// playback). Never stored anywhere. `filePath` is the relative name, which + /// `Wal.getFilePath` resolves against the app documents dir (== batchAudioDir). + Wal _walFor(LocalRecording r) => Wal( + timerStart: r.timerStart, + codec: r.codec, + seconds: r.seconds, + sampleRate: 16000, + channel: 1, + status: WalStatus.miss, + storage: WalStorage.disk, + filePath: r.fileName, + device: batchRecordingDevice, + ); + + String? get currentPlayingId => _audio.currentPlayingId; + bool get isProcessingAudio => _audio.isProcessingAudio; + Duration get currentPosition => _audio.currentPosition; + Duration get totalDuration => _audio.totalDuration; + double get playbackProgress => _audio.playbackProgress; + + bool isPlaying(LocalRecording r) => _audio.isPlaying(_walFor(r).id); + bool canPlay(LocalRecording r) => _audio.canPlayOrShare(_walFor(r)); + Future togglePlayback(LocalRecording r) => _audio.togglePlayback(_walFor(r)); + Future share(LocalRecording r) => _audio.shareAsAudio(_walFor(r)); + Future seekTo(Duration position) => _audio.seekToPosition(position); + Future skipForward() => _audio.skipForward(); + Future skipBackward() => _audio.skipBackward(); + + Future?> getWaveform(LocalRecording r) async { + final wal = _walFor(r); + String? wavPath = _audio.getCachedAudioPath(wal.id); + if (wavPath == null && _audio.canPlayOrShare(wal)) { + wavPath = await _audio.ensureAudioFileExists(wal); + } + return compute(_generateWaveform, {'id': wal.id, 'path': wavPath}); + } + + static Future?> _generateWaveform(Map params) { + return WaveformUtils.generateWaveform(params['id'] as String, params['path'] as String?); + } + + // ───────────────────────── sidecar ───────────────────────── + + Map _loadJobs() { + try { + final raw = SharedPreferencesUtil().getString(_jobsPrefKey); + if (raw.isEmpty) return {}; + final decoded = jsonDecode(raw) as Map; + return decoded.map((k, v) => MapEntry(k, v.toString())); + } catch (_) { + return {}; + } + } + + Future _saveJobs() async { + await SharedPreferencesUtil().saveString(_jobsPrefKey, jsonEncode(_jobs)); + } + + void _onAudioChanged() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _stopReconcileTimer(); + _audio.removeListener(_onAudioChanged); + super.dispose(); + } +} diff --git a/app/lib/utils/batch_recording.dart b/app/lib/utils/batch_recording.dart new file mode 100644 index 00000000000..ddec85d2329 --- /dev/null +++ b/app/lib/utils/batch_recording.dart @@ -0,0 +1,64 @@ +import 'package:omi/backend/schema/bt_device/bt_device.dart'; + +/// Marker stored in [Wal.device] for recordings produced by offline/batch mode. +/// Lets the conversations list show *only* batch recordings — never the device +/// SD-card/flash sync WALs or realtime offline buffers (which live on the Sync page). +const String batchRecordingDevice = 'omibatch'; + +/// Metadata parsed from a batch recording filename written by the native layer: +/// +/// audio_{device}_{codec}_{sampleRate}_{channel}_fs{frameSize}_{timestamp}.bin +/// +/// Mirrors how the backend `/v2/sync-local-files` pipeline interprets the name: +/// codec is detected from the `_pcm16_`/`_pcm8_` markers (otherwise opus), the +/// frame size from `_fs`, and the timestamp from the trailing segment +/// (milliseconds are normalized to seconds). Kept pure so it can be unit tested. +class BatchRecordingInfo { + /// Recording start time, unix seconds. + final int timerStart; + final BleAudioCodec codec; + final int frameSize; + + const BatchRecordingInfo({ + required this.timerStart, + required this.codec, + required this.frameSize, + }); + + /// Returns null if [name] is not a parseable, finalized batch `.bin` filename + /// (e.g. a `.bin.part` in-progress file, or an unrelated file). + static BatchRecordingInfo? fromFileName(String name) { + if (!name.startsWith('audio_') || !name.endsWith('.bin')) return null; + + final base = name.substring(0, name.length - 4); // strip ".bin" + final ts = int.tryParse(base.split('_').last); + if (ts == null) return null; + final timerStart = ts > 100000000000 ? ts ~/ 1000 : ts; // ms -> s + + final fsMatch = RegExp(r'_fs(\d+)').firstMatch(name); + final frameSize = fsMatch != null ? int.parse(fsMatch.group(1)!) : 160; + + final BleAudioCodec codec; + if (name.contains('_pcm16_')) { + codec = BleAudioCodec.pcm16; + } else if (name.contains('_pcm8_')) { + codec = BleAudioCodec.pcm8; + } else { + codec = frameSize == 320 ? BleAudioCodec.opusFS320 : BleAudioCodec.opus; + } + + return BatchRecordingInfo(timerStart: timerStart, codec: codec, frameSize: frameSize); + } + + /// Rough duration in seconds from file size — for display/stats only. The + /// backend recomputes the exact duration from the decoded audio. Accounts for + /// ~16 kbps opus plus the 4-byte per-frame length prefix, or raw PCM rates. + int estimateSeconds(int sizeBytes) { + final bytesPerSec = codec == BleAudioCodec.pcm16 + ? 32200 + : codec == BleAudioCodec.pcm8 + ? 16100 + : 2400; + return (sizeBytes / bytesPerSec).round().clamp(1, 24 * 3600); + } +} diff --git a/app/test/unit/batch_recording_test.dart b/app/test/unit/batch_recording_test.dart new file mode 100644 index 00000000000..0c2c2cb9908 --- /dev/null +++ b/app/test/unit/batch_recording_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/backend/schema/bt_device/bt_device.dart'; +import 'package:omi/services/wals.dart'; +import 'package:omi/utils/batch_recording.dart'; + +void main() { + group('BatchRecordingInfo.fromFileName', () { + test('parses opus fs160', () { + final info = BatchRecordingInfo.fromFileName('audio_omi_opus_16000_1_fs160_1735689600.bin'); + expect(info, isNotNull); + expect(info!.codec, BleAudioCodec.opus); + expect(info.frameSize, 160); + expect(info.timerStart, 1735689600); + }); + + test('parses opus_fs320 (frame size disambiguates the codec)', () { + final info = BatchRecordingInfo.fromFileName('audio_omi_opus_fs320_16000_1_fs320_1735689600.bin'); + expect(info, isNotNull); + expect(info!.codec, BleAudioCodec.opusFS320); + expect(info.frameSize, 320); + expect(info.timerStart, 1735689600); + }); + + test('parses pcm16', () { + final info = BatchRecordingInfo.fromFileName('audio_omi_pcm16_16000_1_fs160_1735689600.bin'); + expect(info!.codec, BleAudioCodec.pcm16); + }); + + test('parses pcm8', () { + final info = BatchRecordingInfo.fromFileName('audio_omi_pcm8_8000_1_fs160_1735689600.bin'); + expect(info!.codec, BleAudioCodec.pcm8); + }); + + test('normalizes millisecond timestamps to seconds', () { + final info = BatchRecordingInfo.fromFileName('audio_omi_opus_16000_1_fs160_1735689600000.bin'); + expect(info!.timerStart, 1735689600); + }); + + test('rejects non-batch / in-progress / malformed files', () { + expect(BatchRecordingInfo.fromFileName('wals.json'), isNull); + // .bin.part is the in-progress file — must not be ingested + expect(BatchRecordingInfo.fromFileName('audio_omi_opus_16000_1_fs160_1735689600.bin.part'), isNull); + expect(BatchRecordingInfo.fromFileName('audio_omi_opus_16000_1_fs160_notanumber.bin'), isNull); + expect(BatchRecordingInfo.fromFileName('random_file.bin'), isNull); + }); + + test('round-trips with Wal.getFileName', () { + final wal = Wal(timerStart: 1735689600, codec: BleAudioCodec.opus, seconds: 60, device: 'omi'); + final info = BatchRecordingInfo.fromFileName(wal.getFileName()); + expect(info, isNotNull); + expect(info!.codec, BleAudioCodec.opus); + expect(info.timerStart, 1735689600); + expect(info.frameSize, wal.frameSize); + }); + + test('estimateSeconds is bounded and codec-aware', () { + final opus = BatchRecordingInfo.fromFileName('audio_omi_opus_16000_1_fs160_1735689600.bin')!; + // ~2400 B/s for opus -> 240000 bytes ~= 100s + expect(opus.estimateSeconds(240000), inInclusiveRange(90, 110)); + expect(opus.estimateSeconds(0), 1); // clamped to >= 1 + }); + }); +}