forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwal.dart
More file actions
304 lines (267 loc) · 11 KB
/
Copy pathwal.dart
File metadata and controls
304 lines (267 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
import 'package:path_provider/path_provider.dart';
import 'package:omi/backend/schema/bt_device/bt_device.dart';
import 'package:omi/backend/schema/geolocation.dart';
const chunkSizeInSeconds = 60;
const flushIntervalInSeconds = 90;
const sdcardChunkSizeSecs = 180;
const newFrameSyncDelaySeconds = 15;
const framesPerFlashPage = 8;
const secondsPerFlashPage = 1.4;
/// Sync lifecycle of a recording.
///
/// - [inProgress] — still being written (audio is live).
/// - [miss] — finalized locally, not yet uploaded (or reverted to retry).
/// - [uploaded] — audio safely received by the server (HTTP 202); the server
/// job is processing. NOT yet confirmed and NOT deletable —
/// the local file is retained until [synced]. A reconciler
/// resolves the job_id to [synced] / [miss] / [corrupted].
/// - [synced] — server job confirmed success; conversation created. Safe to clean up.
/// - [corrupted] — the underlying local file is missing/unreadable.
/// - [outsideRecoveryWindow] — the server permanently refused this recording
/// because it is older than the automatic-recovery window
/// (HTTP 422 `backfill_lookback_exceeded`). The local file is
/// intact, but re-uploading it can never succeed, so it is
/// terminal for sync rather than pending work.
enum WalStatus { inProgress, miss, uploaded, synced, corrupted, outsideRecoveryWindow }
enum WalStorage { mem, disk, sdcard, flashPage }
enum SyncMethod { ble }
/// User-facing sync state for a single recording, derived from [Wal.status],
/// [Wal.isSyncing] and [Wal.retryCount]. This is what the sync UI renders so a
/// recording is never shown as an indistinct row — every state is explicit.
///
/// - [syncing] — actively uploading right now
/// - [uploaded] — uploaded; processing on Omi's servers (will finish in the background)
/// - [synced] — safely backed up to the cloud
/// - [waiting] — recorded, never attempted yet (will sync automatically)
/// - [retrying] — a sync attempt failed; will be retried automatically
/// - [failed] — auto-retries exhausted; needs a manual retry
/// - [corrupted] — the underlying file is missing/unreadable
/// - [outsideRecoveryWindow] — too old for the server to accept; retrying
/// cannot help, so the row explains that instead of offering
/// a Retry the user would spend forever
enum WalSyncDisplayState { syncing, uploaded, synced, waiting, retrying, failed, corrupted, outsideRecoveryWindow }
/// Max automatic sync attempts before a recording is considered [WalSyncDisplayState.failed].
/// Mirrors the `maxRetries` used by the auto-sync loop in capture_provider.
const int walMaxAutoRetries = 3;
class WalStats {
final int totalFiles;
final int phoneFiles;
final int sdcardFiles;
final int fromSdcardFiles;
final int limitlessFiles;
final int fromFlashPageFiles;
final int phoneSize;
final int sdcardSize;
final int syncedFiles;
final int missedFiles;
WalStats({
required this.totalFiles,
required this.phoneFiles,
required this.sdcardFiles,
required this.fromSdcardFiles,
required this.limitlessFiles,
required this.fromFlashPageFiles,
required this.phoneSize,
required this.sdcardSize,
required this.syncedFiles,
required this.missedFiles,
});
int get sdcardRelatedFiles => sdcardFiles + fromSdcardFiles;
int get flashPageRelatedFiles => limitlessFiles + fromFlashPageFiles;
String get totalSizeFormatted => _formatBytes(phoneSize + sdcardSize);
String get phoneSizeFormatted => _formatBytes(phoneSize);
String get sdcardSizeFormatted => _formatBytes(sdcardSize);
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';
}
}
class Wal {
int timerStart;
BleAudioCodec codec;
int channel;
int sampleRate;
int seconds;
String device;
String? deviceModel;
WalStatus status;
WalStorage storage;
String? filePath;
List<List<int>> data;
int storageOffset = 0;
int storageTotalBytes = 0;
int fileNum = 1;
bool isSyncing = false;
DateTime? syncStartedAt;
int? syncEtaSeconds;
double? syncSpeedKBps;
SyncMethod syncMethod = SyncMethod.ble;
int frameSize = 160;
int totalFrames = 0;
int syncedFrameOffset = 0;
WalStorage? originalStorage;
/// The conversation this WAL belongs to. Stamped when ConversationProcessingStartedEvent
/// arrives so WALs survive app kill and can be recovered on startup.
String? conversationId;
/// Canonical start-time location snapshot for delayed/offline finalization.
Geolocation? geolocation;
/// Number of sync retry attempts for this WAL.
int retryCount;
/// Unix timestamp (seconds) of the last sync retry attempt.
int lastRetryAt;
/// Server job id assigned when this recording's audio was uploaded (HTTP 202).
/// The reconciler polls this to resolve [WalStatus.uploaded] → synced / miss /
/// corrupted. Null until uploaded. Multiple WALs in one upload batch share it.
String? jobId;
/// Unix timestamp (seconds) when the audio was uploaded (202 received).
int uploadedAt;
String get id => '${device}_$timerStart';
/// Single source of truth for how this recording's sync state is shown to the
/// user. The sync page renders an explicit label + icon for every value so a
/// not-yet-synced recording is never visually identical to a failed one.
WalSyncDisplayState get syncDisplayState {
// Corruption and a server lookback rejection are terminal. Neither must be
// visually downgraded to an active upload if a transient flag was left
// behind by an interrupted attempt.
if (status == WalStatus.corrupted) return WalSyncDisplayState.corrupted;
if (status == WalStatus.outsideRecoveryWindow) return WalSyncDisplayState.outsideRecoveryWindow;
if (isSyncing) return WalSyncDisplayState.syncing;
switch (status) {
case WalStatus.uploaded:
return WalSyncDisplayState.uploaded;
case WalStatus.synced:
return WalSyncDisplayState.synced;
case WalStatus.corrupted:
return WalSyncDisplayState.corrupted;
case WalStatus.outsideRecoveryWindow:
return WalSyncDisplayState.outsideRecoveryWindow;
case WalStatus.miss:
if (retryCount >= walMaxAutoRetries) return WalSyncDisplayState.failed;
if (retryCount > 0) return WalSyncDisplayState.retrying;
return WalSyncDisplayState.waiting;
case WalStatus.inProgress:
return WalSyncDisplayState.waiting;
}
}
/// Marks this recording as terminally unavailable and clears any transient
/// upload presentation left by an interrupted attempt.
void markCorrupted() {
status = WalStatus.corrupted;
isSyncing = false;
syncStartedAt = null;
syncEtaSeconds = null;
syncSpeedKBps = null;
}
/// Marks this recording as permanently refused by the server for being older
/// than the automatic-recovery window. The local file is deliberately kept —
/// only the sync attempt is terminal.
void markOutsideRecoveryWindow() {
status = WalStatus.outsideRecoveryWindow;
isSyncing = false;
syncStartedAt = null;
syncEtaSeconds = null;
syncSpeedKBps = null;
}
Wal({
required this.timerStart,
required this.codec,
required this.seconds,
this.sampleRate = 16000,
this.channel = 1,
this.status = WalStatus.inProgress,
this.storage = WalStorage.mem,
this.filePath,
this.device = "phone",
this.deviceModel,
this.storageOffset = 0,
this.storageTotalBytes = 0,
this.fileNum = 1,
List<List<int>>? data,
this.totalFrames = 0,
this.syncedFrameOffset = 0,
this.originalStorage,
this.conversationId,
this.geolocation,
this.retryCount = 0,
this.lastRetryAt = 0,
this.jobId,
this.uploadedAt = 0,
}) : data = data ?? [] {
frameSize = codec.getFrameSize();
}
factory Wal.fromJson(Map<String, dynamic> json) {
return Wal(
timerStart: json['timer_start'],
codec: mapNameToCodec(json['codec']),
channel: json['channel'] ?? 1,
sampleRate: json['sample_rate'] ?? 16000,
status: WalStatus.values.asNameMap()[json['status']] ?? WalStatus.inProgress,
storage: WalStorage.values.asNameMap()[json['storage']] ?? WalStorage.mem,
filePath: json['file_path'],
seconds: json['seconds'] ?? chunkSizeInSeconds,
device: json['device'] ?? "phone",
deviceModel: json['device_model'],
storageOffset: json['storage_offset'] ?? 0,
storageTotalBytes: json['storage_total_bytes'] ?? 0,
fileNum: json['file_num'] ?? 1,
totalFrames: json['total_frames'] ?? 0,
syncedFrameOffset: json['synced_frame_offset'] ?? 0,
originalStorage:
json['original_storage'] != null ? WalStorage.values.asNameMap()[json['original_storage']] : null,
conversationId: json['conversation_id'],
geolocation: json['geolocation'] is Map<String, dynamic>
? Geolocation.fromJson(json['geolocation'] as Map<String, dynamic>)
: null,
retryCount: json['retry_count'] ?? 0,
lastRetryAt: json['last_retry_at'] ?? 0,
jobId: json['job_id'],
uploadedAt: json['uploaded_at'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'timer_start': timerStart,
'codec': codec.toString(),
'channel': channel,
'sample_rate': sampleRate,
'status': status.name,
'storage': storage.name,
'file_path': filePath,
'seconds': seconds,
'device': device,
'device_model': deviceModel,
'storage_offset': storageOffset,
'storage_total_bytes': storageTotalBytes,
'file_num': fileNum,
'total_frames': totalFrames,
'synced_frame_offset': syncedFrameOffset,
'original_storage': originalStorage?.name,
'conversation_id': conversationId,
'geolocation': geolocation?.toJson(),
'retry_count': retryCount,
'last_retry_at': lastRetryAt,
'job_id': jobId,
'uploaded_at': uploadedAt,
};
}
static List<Wal> fromJsonList(List<dynamic> jsonList) => jsonList.map((e) => Wal.fromJson(e)).toList();
getFileName() {
return "audio_${device.replaceAll(RegExp(r'[^a-zA-Z0-9]'), "").toLowerCase()}_${codec}_${sampleRate}_${channel}_fs${frameSize}_${timerStart}.bin";
}
getFileNameByTimeStarts(int timestarts) {
return "audio_${device.replaceAll(RegExp(r'[^a-zA-Z0-9]'), "").toLowerCase()}_${codec}_${sampleRate}_${channel}_fs${frameSize}_${timestarts}.bin";
}
static Future<String?> getFilePath(String? pathOrName) async {
if (pathOrName == null || pathOrName.isEmpty) {
return null;
}
final directory = await getApplicationDocumentsDirectory();
if (pathOrName.contains('/')) {
final filename = pathOrName.split('/').last;
return '${directory.path}/$filename';
}
return '${directory.path}/$pathOrName';
}
}