forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstorage.py
More file actions
1840 lines (1473 loc) · 68.2 KB
/
Copy pathstorage.py
File metadata and controls
1840 lines (1473 loc) · 68.2 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
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import datetime
import hashlib
import io
import json
import os
import struct
import threading
import time
import wave
from contextlib import contextmanager
from typing import Any, Callable, Dict, List, Optional, Tuple
from concurrent.futures import as_completed, wait, FIRST_COMPLETED
from utils.executors import postprocess_executor, storage_executor
try:
import opuslib
except Exception as e:
opuslib = None
_opus_import_error: Optional[Exception] = e
else:
_opus_import_error = None
from google.cloud.exceptions import NotFound, NotFound as BlobNotFound
from database.redis_db import cache_signed_url, get_cached_signed_url, delete_cached_signed_url
from database.legal_holds import external_write_fence
from utils import encryption
from utils.cloud_tasks import enqueue_audio_merge_job, is_audio_merge_dispatch_enabled
from utils.observability.fallback import record_fallback
from utils.other.deferred_delete import DeferredDeleter
from utils.other.local_storage import create_storage_client, local_public_url
from database import users as users_db
import logging
logger = logging.getLogger(__name__)
# Per-request fan-out limits for storage_executor (#7387)
_STORAGE_CHUNK_SEM = threading.BoundedSemaphore(32)
# 4 → 2 in #7526 was load-shedding while the pool was full of sleeping
# per-file deletion timers; restored to 4 now that the janitor thread
# (deferred_delete.py) holds those instead of pool threads.
_PRECACHE_FILE_SEM = threading.BoundedSemaphore(4)
_CHUNK_WINDOW_SIZE = 8
_merge_tracker_lock = threading.Lock()
_active_merges: dict[str, float] = {}
_recent_merges: dict[str, tuple[float, str]] = {}
_RECENT_MERGE_WINDOW = 300
_MERGE_TRACKER_MAX = 2000
# Opus encoding constants
OPUS_SAMPLE_RATE = 16000
OPUS_CHANNELS = 1
OPUS_FRAME_DURATION_MS = 20 # 20ms frames (standard for voice)
OPUS_FRAME_SIZE = OPUS_SAMPLE_RATE * OPUS_FRAME_DURATION_MS // 1000 # 320 samples per frame
# Valid private cloud sync extensions (longest first for correct matching)
PRIVATE_CLOUD_EXTENSIONS = ['.batch.enc', '.batch.bin', '.opus.enc', '.opus', '.enc', '.bin']
storage_client = None
_storage_client_lock = threading.Lock()
def _get_storage_client() -> Any:
"""Return the GCS client lazily so importing this module never probes ADC/GCE metadata."""
global storage_client
if storage_client is None:
with _storage_client_lock:
if storage_client is None:
storage_client = create_storage_client()
return storage_client
speech_profiles_bucket = (os.getenv('BUCKET_SPEECH_PROFILES') or '').strip() or None
postprocessing_audio_bucket = os.getenv('BUCKET_POSTPROCESSING')
memories_recordings_bucket = (os.getenv('BUCKET_MEMORIES_RECORDINGS') or '').strip() or None
private_cloud_sync_bucket = os.getenv('BUCKET_PRIVATE_CLOUD_SYNC', 'omi-private-cloud-sync')
syncing_local_bucket = os.getenv('BUCKET_TEMPORAL_SYNC_LOCAL')
omi_apps_bucket = os.getenv('BUCKET_PLUGINS_LOGOS')
app_thumbnails_bucket = os.getenv('BUCKET_APP_THUMBNAILS')
chat_files_bucket = os.getenv('BUCKET_CHAT_FILES')
desktop_updates_bucket = os.getenv('BUCKET_DESKTOP_UPDATES')
screen_frames_bucket = os.getenv('BUCKET_SCREEN_FRAMES')
_did_warn_missing_speech_profiles_bucket = False
def _blob_public_url(blob: Any, bucket_name: Optional[str], path: str) -> str:
"""Return the active provider URL while keeping lightweight fakes usable."""
if local_url := local_public_url(bucket_name, path):
return local_url
public_url = getattr(blob, 'public_url', None)
if isinstance(public_url, str) and public_url:
return public_url
return f'https://storage.googleapis.com/{bucket_name}/{path}'
def _uses_real_gcs_bucket(bucket: Any) -> bool:
"""Return whether ``bucket`` is a concrete GCS bucket, not a test double.
Storage unit tests inject lightweight bucket fakes. They represent the
local/offline provider and must not require Firestore authority. A real
google-cloud-storage bucket always comes from the SDK module, so production
writes still contend on the account gate even when no stage environment is
set.
"""
return type(bucket).__module__.startswith('google.cloud.storage')
@contextmanager
def owner_storage_write_gate(uid: str, bucket: Any = None):
"""Fence one owner-scoped GCS mutation against account deletion.
The fence is checked after authorization/encoding but before the
upload/copy call: a write is refused while the account is being deleted
or a destructive operation owns the account gate. It takes no lock, so
concurrent uploads for one account never contend with each other; the
deletion side verifies its purges left nothing behind. Local/offline
providers and injected test buckets remain hermetic and do not need
Firestore authority.
"""
if not uid:
raise ValueError('owner storage writes require a uid')
stage = os.getenv('OMI_ENV_STAGE', '').strip().lower()
provider_mode = os.getenv('PROVIDER_MODE', '').strip().lower()
if stage in {'local', 'offline'} or provider_mode == 'offline' or not _uses_real_gcs_bucket(bucket):
yield None
return
with external_write_fence(uid):
yield None
def _owner_uid_from_sync_path(file_path: str) -> Optional[str]:
"""Extract the owner from the only UID-scoped temporary-sync layout."""
parts = str(file_path).replace('\\', '/').split('/')
if len(parts) >= 2 and parts[0] == 'syncing' and parts[1] and parts[1] not in {'.', '..'}:
return parts[1]
return None
def _delete_owner_bucket_prefix(bucket: Any, prefix: str) -> int:
"""Delete and verify one owner prefix, failing closed on a torn purge."""
blobs = list(bucket.list_blobs(prefix=prefix))
deleted = 0
for blob in blobs:
blob.delete()
deleted += 1
remaining = list(bucket.list_blobs(prefix=prefix))
if remaining:
raise RuntimeError(f'owner storage purge left {len(remaining)} objects under {prefix}')
return deleted
def delete_all_user_storage_objects(uid: str) -> int:
"""Purge every non-recordings configured GCS prefix owned by ``uid``.
The account deletion worker calls this while it owns the account-wide
destructive-operation gate. Prefix enumeration is intentionally broader
than Firestore's current ID inventories so playback, merge caches, stale
markers, and uploads from an in-flight request cannot survive the wipe.
``delete_all_conversation_recordings`` handles its dedicated bucket in the
same account-deletion phase, preserving its existing operational metric.
"""
if not uid:
return 0
stage = os.getenv('OMI_ENV_STAGE', '').strip().lower()
if stage in {'local', 'offline'} or os.getenv('PROVIDER_MODE', '').strip().lower() == 'offline':
return 0
configured: list[tuple[Optional[str], tuple[str, ...]]] = [
(speech_profiles_bucket, (f'{uid}/',)),
(
private_cloud_sync_bucket,
tuple(f'{prefix}/{uid}/' for prefix in ('chunks', 'audio', 'merged', PLAYBACK_ARTIFACT_PREFIX)),
),
(syncing_local_bucket, (f'syncing/{uid}/',)),
(chat_files_bucket, (f'{uid}/',)),
]
deleted = 0
seen_buckets: set[tuple[str, str]] = set()
for bucket_name, prefixes in configured:
if not bucket_name:
continue
bucket = _get_storage_client().bucket(bucket_name)
for prefix in prefixes:
key = (bucket_name, prefix)
if key in seen_buckets:
continue
seen_buckets.add(key)
deleted += _delete_owner_bucket_prefix(bucket, prefix)
return deleted
def _get_opuslib() -> Any:
if opuslib is None:
raise RuntimeError(
'Opus support requires opuslib and the native libopus library. '
'Install the OS-level Opus package before encoding or decoding .opus audio.'
) from _opus_import_error
return opuslib
def _get_speech_profiles_bucket(required: bool = False) -> Optional[Any]:
global _did_warn_missing_speech_profiles_bucket
if speech_profiles_bucket:
return _get_storage_client().bucket(speech_profiles_bucket)
if not _did_warn_missing_speech_profiles_bucket:
logger.warning('BUCKET_SPEECH_PROFILES is not configured; speech profile storage is disabled')
_did_warn_missing_speech_profiles_bucket = True
if required:
raise RuntimeError('BUCKET_SPEECH_PROFILES is not configured')
return None
# *******************************************
# ************* SPEECH PROFILE **************
# *******************************************
def upload_profile_audio(file_path: str, uid: str) -> str:
bucket = _get_speech_profiles_bucket(required=True)
assert bucket is not None # required=True raises if missing
path = f'{uid}/speech_profile.wav'
blob = bucket.blob(path)
with owner_storage_write_gate(uid, bucket):
blob.upload_from_filename(file_path)
return _blob_public_url(blob, speech_profiles_bucket, path)
def get_user_has_speech_profile(uid: str) -> bool:
# No age cutoff: the listen pipeline (routers/transcribe.py) uses the profile
# regardless of age, so reporting an old profile as absent only causes the app
# to re-prompt users whose profile is still in active use (#5128).
bucket = _get_speech_profiles_bucket()
if bucket is None:
return False
return bucket.blob(f'{uid}/speech_profile.wav').exists()
def get_profile_audio_if_exists(uid: str, download: bool = True) -> Optional[str]:
bucket = _get_speech_profiles_bucket()
if bucket is None:
return None
path = f'{uid}/speech_profile.wav'
blob = bucket.blob(path)
if blob.exists():
if download:
file_path = f'_temp/{uid}_speech_profile.wav'
blob.download_to_filename(file_path)
return file_path
return _get_signed_url(blob, 60)
return None
def delete_additional_profile_audio(uid: str, file_name: str) -> None:
bucket = _get_speech_profiles_bucket()
if bucket is None:
return
blob = bucket.blob(f'{uid}/additional_profile_recordings/{file_name}')
if blob.exists():
logger.info(f'delete_additional_profile_audio deleting {file_name}')
blob.delete()
def get_additional_profile_recordings(uid: str, download: bool = False) -> List[str]:
bucket = _get_speech_profiles_bucket()
if bucket is None:
return []
blobs = bucket.list_blobs(prefix=f'{uid}/additional_profile_recordings/')
if download:
paths: List[str] = []
for blob in blobs:
file_path = f'_temp/{uid}_{blob.name.split("/")[-1]}'
blob.download_to_filename(file_path)
paths.append(file_path)
return paths
return [_get_signed_url(blob, 60) for blob in blobs]
# ********************************************
# ************* PEOPLE PROFILES **************
# ********************************************
def delete_user_person_speech_sample(uid: str, person_id: str, file_name: str) -> None:
bucket = _get_speech_profiles_bucket()
if bucket is None:
return
blob = bucket.blob(f'{uid}/people_profiles/{person_id}/{file_name}')
if blob.exists():
blob.delete()
def delete_user_person_speech_samples(uid: str, person_id: str) -> None:
bucket = _get_speech_profiles_bucket()
if bucket is None:
return
blobs = bucket.list_blobs(prefix=f'{uid}/people_profiles/{person_id}/')
for blob in blobs:
blob.delete()
def upload_person_speech_sample_from_bytes(
audio_bytes: bytes,
uid: str,
person_id: str,
sample_rate: int = 16000,
) -> str:
"""Upload PCM audio bytes as WAV speech sample. Returns GCS path."""
import uuid as uuid_module
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, 'wb') as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2) # 16-bit audio
wav_file.setframerate(sample_rate)
wav_file.writeframes(audio_bytes)
bucket = _get_speech_profiles_bucket(required=True)
assert bucket is not None # required=True raises if missing
filename = f"{uuid_module.uuid4()}.wav"
path = f'{uid}/people_profiles/{person_id}/{filename}'
blob = bucket.blob(path)
with owner_storage_write_gate(uid, bucket):
blob.upload_from_string(wav_buffer.getvalue(), content_type='audio/wav')
return path
def get_user_people_ids(uid: str) -> List[str]:
bucket = _get_speech_profiles_bucket()
if bucket is None:
return []
blobs = bucket.list_blobs(prefix=f'{uid}/people_profiles/')
return [blob.name.split("/")[-2] for blob in blobs]
def get_user_person_speech_samples(uid: str, person_id: str, download: bool = False) -> List[str]:
bucket = _get_speech_profiles_bucket()
if bucket is None:
return []
blobs = bucket.list_blobs(prefix=f'{uid}/people_profiles/{person_id}/')
if download:
paths: List[str] = []
for blob in blobs:
file_path = f'_temp/{uid}_person_{blob.name.split("/")[-1]}'
blob.download_to_filename(file_path)
paths.append(file_path)
return paths
return [_get_signed_url(blob, 60) for blob in blobs]
def get_speech_sample_signed_urls(paths: List[str]) -> List[str]:
"""
Generate signed URLs for speech samples given their GCS paths.
Uses the paths stored in Firestore instead of listing GCS blobs.
Args:
paths: List of GCS paths (e.g., '{uid}/people_profiles/{person_id}/{filename}')
Returns:
List of signed URLs
"""
if not paths:
return []
bucket = _get_speech_profiles_bucket()
if bucket is None:
return []
signed_urls: List[str] = []
for path in paths:
blob = bucket.blob(path)
signed_urls.append(_get_signed_url(blob, 60))
return signed_urls
# ********************************************
# ************* POST PROCESSING **************
# ********************************************
def upload_postprocessing_audio(file_path: str) -> str:
bucket = _get_storage_client().bucket(postprocessing_audio_bucket)
blob = bucket.blob(file_path)
blob.upload_from_filename(file_path)
return blob.public_url
def delete_postprocessing_audio(file_path: str) -> None:
bucket = _get_storage_client().bucket(postprocessing_audio_bucket)
blob = bucket.blob(file_path)
blob.delete()
# ***********************************
# ************* SDCARD **************
# ***********************************
def upload_sdcard_audio(file_path: str) -> str:
bucket = _get_storage_client().bucket(postprocessing_audio_bucket)
blob = bucket.blob(f'sdcard/{file_path}')
blob.upload_from_filename(file_path)
return blob.public_url
def download_postprocessing_audio(file_path: str, destination_file_path: str) -> None:
bucket = _get_storage_client().bucket(postprocessing_audio_bucket)
blob = bucket.blob(file_path)
blob.download_to_filename(destination_file_path)
# ************************************************
# *********** CONVERSATIONS RECORDINGS ***********
# ************************************************
def upload_conversation_recording(file_path: str, uid: str, conversation_id: str) -> str:
bucket = _get_storage_client().bucket(memories_recordings_bucket)
path = f'{uid}/{conversation_id}.wav'
blob = bucket.blob(path)
with owner_storage_write_gate(uid, bucket):
blob.upload_from_filename(file_path)
return _blob_public_url(blob, memories_recordings_bucket, path)
def get_conversation_recording_if_exists(uid: str, memory_id: str) -> Optional[str]:
logger.info(f'get_conversation_recording_if_exists {uid} {memory_id}')
bucket = _get_storage_client().bucket(memories_recordings_bucket)
path = f'{uid}/{memory_id}.wav'
blob = bucket.blob(path)
if blob.exists():
file_path = f'_temp/{memory_id}.wav'
blob.download_to_filename(file_path)
return file_path
return None
def delete_all_conversation_recordings(uid: str) -> int:
if not uid:
return 0
stage = os.getenv('OMI_ENV_STAGE', '').strip().lower()
if stage in {'local', 'offline'} or os.getenv('PROVIDER_MODE', '').strip().lower() == 'offline':
return 0
if not memories_recordings_bucket:
# A required purge failure blocks the irreversible Firestore wipe (see
# services/users/account_deletion.py), so an unconfigured bucket must not raise here:
# uploads resolve the same name, so a deployment without it cannot have stored recordings.
logger.warning('BUCKET_MEMORIES_RECORDINGS is not configured; skipping conversation recordings purge')
return 0
bucket = _get_storage_client().bucket(memories_recordings_bucket)
# Trailing slash so a uid is not a prefix of another uid's folder (e.g. "abc" matching "abcd/").
blobs = bucket.list_blobs(prefix=f"{uid}/")
deleted = 0
for blob in blobs:
blob.delete()
deleted += 1
# Concrete GCS has strong list consistency. Lightweight custom fakes also
# support this proof; only the legacy MagicMock fixture is exempt because
# it intentionally returns the same static blob on every listing.
if type(bucket).__module__ != 'unittest.mock' and list(bucket.list_blobs(prefix=f'{uid}/')):
raise RuntimeError(f'owner storage purge left objects under {uid}/')
return deleted
# ********************************************
# ************* SYNCING FILES **************
# ********************************************
def get_syncing_file_temporal_url(file_path: str):
bucket = _get_storage_client().bucket(syncing_local_bucket)
blob = bucket.blob(file_path)
owner_uid = _owner_uid_from_sync_path(file_path)
if owner_uid:
with owner_storage_write_gate(owner_uid, bucket):
blob.upload_from_filename(file_path)
else:
blob.upload_from_filename(file_path)
return _blob_public_url(blob, syncing_local_bucket, file_path)
def get_syncing_file_temporal_signed_url(file_path: str):
bucket = _get_storage_client().bucket(syncing_local_bucket)
blob = bucket.blob(file_path)
owner_uid = _owner_uid_from_sync_path(file_path)
if owner_uid:
with owner_storage_write_gate(owner_uid, bucket):
blob.upload_from_filename(file_path)
else:
blob.upload_from_filename(file_path)
return _get_signed_url(blob, 15)
def delete_syncing_temporal_file(file_path: str):
bucket = _get_storage_client().bucket(syncing_local_bucket)
blob = bucket.blob(file_path)
try:
owner_uid = _owner_uid_from_sync_path(file_path)
if owner_uid:
with owner_storage_write_gate(owner_uid, bucket):
blob.delete()
else:
blob.delete()
except BlobNotFound:
pass
# Long enough for every signed-URL consumer (Deepgram fetch, speaker-ID
# download) to finish; the URLs themselves expire at 15 minutes.
SYNCING_TEMPORAL_DELETE_DELAY_SECONDS = 480
_syncing_temporal_deleter = DeferredDeleter(delete_syncing_temporal_file, name='syncing-blob-janitor')
def schedule_syncing_temporal_file_deletion(
file_path: str, delay_seconds: float = SYNCING_TEMPORAL_DELETE_DELAY_SECONDS
):
"""Delete a temporal syncing blob once its signed-URL consumers are done.
One janitor thread + a due-time heap, instead of the previous per-file
time.sleep(480) that parked a storage_executor thread per blob (#7531).
"""
_syncing_temporal_deleter.schedule(file_path, delay_seconds)
def upload_syncing_temporal_file(file_path: str):
"""Stage a local file in the syncing bucket (blob name = local relative path)."""
bucket = _get_storage_client().bucket(syncing_local_bucket)
blob = bucket.blob(file_path)
owner_uid = _owner_uid_from_sync_path(file_path)
if owner_uid:
with owner_storage_write_gate(owner_uid, bucket):
blob.upload_from_filename(file_path)
else:
blob.upload_from_filename(file_path)
def download_syncing_temporal_file(file_path: str) -> bool:
"""Download a staged blob back to its local relative path.
Returns False when the blob no longer exists (e.g. deleted by the
bucket's 1-day lifecycle rule before a deeply delayed task ran).
"""
bucket = _get_storage_client().bucket(syncing_local_bucket)
blob = bucket.blob(file_path)
directory = os.path.dirname(file_path)
if directory:
os.makedirs(directory, exist_ok=True)
try:
blob.download_to_filename(file_path)
return True
except BlobNotFound:
return False
# ************************************************
# *********** PRIVATE CLOUD SYNC *****************
# ************************************************
def encode_pcm_to_opus(pcm_data: bytes, sample_rate: int = OPUS_SAMPLE_RATE, channels: int = OPUS_CHANNELS) -> bytes:
"""
Encode PCM16 audio to Opus.
Format: 4-byte little-endian packet count, then for each packet:
2-byte little-endian length prefix followed by the Opus packet bytes.
This allows exact reconstruction on decode.
Args:
pcm_data: Raw PCM16 audio bytes
sample_rate: Sample rate in Hz (default 16000)
channels: Number of audio channels (default 1)
Returns:
Length-prefixed Opus packets as bytes
"""
opus = _get_opuslib()
encoder = opus.Encoder(sample_rate, channels, opus.APPLICATION_VOIP)
frame_size = sample_rate * OPUS_FRAME_DURATION_MS // 1000
bytes_per_frame = frame_size * channels * 2 # 16-bit = 2 bytes per sample
packets: List[bytes] = []
offset = 0
while offset + bytes_per_frame <= len(pcm_data):
frame = pcm_data[offset : offset + bytes_per_frame]
encoded = encoder.encode(frame, frame_size)
packets.append(encoded)
offset += bytes_per_frame
# Encode remaining samples (pad with silence)
if offset < len(pcm_data):
remaining = pcm_data[offset:]
padded = remaining + b'\x00' * (bytes_per_frame - len(remaining))
encoded = encoder.encode(padded, frame_size)
packets.append(encoded)
# Pack: [packet_count (4 bytes)] + [original_pcm_len (4 bytes)] + [len (2 bytes) + data] per packet
output: bytes = struct.pack('<I', len(packets))
output += struct.pack('<I', len(pcm_data))
for pkt in packets:
output += struct.pack('<H', len(pkt)) + pkt
return output
def decode_opus_to_pcm(opus_data: bytes, sample_rate: int = OPUS_SAMPLE_RATE, channels: int = OPUS_CHANNELS) -> bytes:
"""
Decode length-prefixed Opus packets back to PCM16.
Args:
opus_data: Length-prefixed Opus packets (from encode_pcm_to_opus)
sample_rate: Sample rate in Hz (default 16000)
channels: Number of audio channels (default 1)
Returns:
Raw PCM16 audio bytes
Raises:
ValueError: If opus_data is too short or has invalid header/packet structure
"""
if len(opus_data) < 8:
raise ValueError(f"Opus data too short: {len(opus_data)} bytes (need at least 8 for header)")
frame_size = sample_rate * OPUS_FRAME_DURATION_MS // 1000
offset = 0
packet_count = struct.unpack_from('<I', opus_data, offset)[0]
offset += 4
original_pcm_len = struct.unpack_from('<I', opus_data, offset)[0]
offset += 4
packets: List[bytes] = []
for i in range(packet_count):
if offset + 2 > len(opus_data):
raise ValueError(f"Truncated Opus data: expected packet {i}/{packet_count} length at offset {offset}")
pkt_len = struct.unpack_from('<H', opus_data, offset)[0]
offset += 2
if offset + pkt_len > len(opus_data):
raise ValueError(
f"Truncated Opus data: packet {i} needs {pkt_len} bytes at offset {offset}, only {len(opus_data) - offset} available"
)
packets.append(opus_data[offset : offset + pkt_len])
offset += pkt_len
opus = _get_opuslib()
decoder = opus.Decoder(sample_rate, channels)
pcm_parts: List[bytes] = []
for pkt_data in packets:
decoded = decoder.decode(pkt_data, frame_size)
pcm_parts.append(decoded)
result = b''.join(pcm_parts)
# Trim to original PCM length to remove padding from partial final frame
if original_pcm_len > 0 and original_pcm_len < len(result):
result = result[:original_pcm_len]
return result
def _get_extension_for_path(path: str) -> str:
"""Extract the private cloud sync extension from a GCS path."""
if path.endswith('.batch.enc'):
return 'batch.enc'
elif path.endswith('.batch.bin'):
return 'batch.bin'
elif path.endswith('.opus.enc'):
return 'opus.enc'
elif path.endswith('.opus'):
return 'opus'
elif path.endswith('.enc'):
return 'enc'
elif path.endswith('.bin'):
return 'bin'
return 'bin'
def _strip_extension(filename: str) -> str:
"""Strip private cloud sync extension to get the timestamp string.
Handles both single-chunk filenames (e.g. '1000.000.opus') and
batch filenames (e.g. '1000.000-1010.000.batch.bin').
"""
for ext in ('.batch.enc', '.batch.bin', '.opus.enc', '.opus', '.enc', '.bin'):
if filename.endswith(ext):
return filename[: -len(ext)]
return filename.rsplit('.', 1)[0]
def upload_audio_chunk(
chunk_data: bytes, uid: str, conversation_id: str, timestamp: float, data_protection_level: Optional[str] = None
) -> str:
"""
Upload an audio chunk to Google Cloud Storage with optional encryption.
Args:
chunk_data: Raw audio bytes (PCM16)
uid: User ID
conversation_id: Conversation ID
timestamp: Unix timestamp when chunk was recorded
data_protection_level: Optional cached protection level. When provided,
skips the per-chunk Firestore read. Falls back to DB read when None.
Returns:
GCS path of the uploaded chunk
"""
bucket = _get_storage_client().bucket(private_cloud_sync_bucket)
protection_level = (
data_protection_level if data_protection_level is not None else users_db.get_data_protection_level(uid)
)
# Format timestamp to 3 decimal places for cleaner filenames
formatted_timestamp = f'{timestamp:.3f}'
upload_data = encode_pcm_to_opus(chunk_data)
with owner_storage_write_gate(uid, bucket):
if protection_level == 'enhanced':
encrypted_chunk = encryption.encrypt_audio_chunk(upload_data, uid)
path = f'chunks/{uid}/{conversation_id}/{formatted_timestamp}.opus.enc'
blob = bucket.blob(path)
blob.upload_from_string(encrypted_chunk, content_type='application/octet-stream')
else:
path = f'chunks/{uid}/{conversation_id}/{formatted_timestamp}.opus'
blob = bucket.blob(path)
blob.upload_from_string(upload_data, content_type='application/octet-stream')
del upload_data
return path
def upload_audio_chunks_batch(
chunks: List[Dict[str, Any]],
uid: str,
conversation_id: str,
data_protection_level: Optional[str] = None,
) -> List[str]:
"""
Upload multiple audio chunks to GCS in a single streaming write.
Concatenates all chunk data into one GCS object (1 write op instead of N).
Args:
chunks: List of dicts with 'data' (bytes) and 'timestamp' (float).
uid: User ID.
conversation_id: Conversation ID.
data_protection_level: Optional cached protection level. When provided,
skips the Firestore read. Falls back to DB read when None.
Returns:
List of GCS paths for the uploaded batch.
"""
if not chunks:
return []
# Sort by timestamp for consistent ordering
sorted_chunks = sorted(chunks, key=lambda c: c['timestamp'])
# Resolve protection level once for the entire batch
protection_level = (
data_protection_level if data_protection_level is not None else users_db.get_data_protection_level(uid)
)
bucket = _get_storage_client().bucket(private_cloud_sync_bucket)
# Build batch filename from first and last timestamps
first_ts = f'{sorted_chunks[0]["timestamp"]:.3f}'
last_ts = f'{sorted_chunks[-1]["timestamp"]:.3f}'
batch_name = f'{first_ts}-{last_ts}' if len(sorted_chunks) > 1 else first_ts
with owner_storage_write_gate(uid, bucket):
if protection_level == 'enhanced':
# Encrypt each chunk individually (length-prefixed), stream to GCS
path = f'chunks/{uid}/{conversation_id}/{batch_name}.batch.enc'
blob = bucket.blob(path)
with blob.open('wb', content_type='application/octet-stream') as f:
for chunk in sorted_chunks:
encrypted_chunk = encryption.encrypt_audio_chunk(chunk['data'], uid)
f.write(encrypted_chunk)
del encrypted_chunk
else:
# Standard — stream raw PCM data to GCS
path = f'chunks/{uid}/{conversation_id}/{batch_name}.batch.bin'
blob = bucket.blob(path)
with blob.open('wb', content_type='application/octet-stream') as f:
for chunk in sorted_chunks:
f.write(chunk['data'])
return [path]
def delete_audio_chunks(uid: str, conversation_id: str, timestamps: List[float]) -> None:
"""Delete audio chunks after they've been merged.
Handles both single-chunk blobs (per-timestamp lookup) and batch blobs
(listed and matched by start timestamp).
"""
bucket = _get_storage_client().bucket(private_cloud_sync_bucket)
deleted_batch_paths: set[str] = set()
for timestamp in timestamps:
# Format timestamp to match upload format (3 decimal places)
formatted_timestamp = f'{timestamp:.3f}'
# Try single-chunk extensions first
for extension in PRIVATE_CLOUD_EXTENSIONS:
if extension in ('.batch.enc', '.batch.bin'):
continue # batch blobs handled separately below
chunk_path = f'chunks/{uid}/{conversation_id}/{formatted_timestamp}{extension}'
blob = bucket.blob(chunk_path)
if blob.exists():
blob.delete()
# Try batch blobs: exact single-timestamp batch (e.g. "1000.000.batch.bin")
for batch_ext in ('.batch.enc', '.batch.bin'):
batch_path = f'chunks/{uid}/{conversation_id}/{formatted_timestamp}{batch_ext}'
if batch_path not in deleted_batch_paths:
blob = bucket.blob(batch_path)
if blob.exists():
blob.delete()
deleted_batch_paths.add(batch_path)
# Scan for range-named batch blobs whose start timestamp matches any requested timestamp
ts_set = {f'{ts:.3f}' for ts in timestamps}
prefix = f'chunks/{uid}/{conversation_id}/'
for blob in bucket.list_blobs(prefix=prefix):
if blob.name in deleted_batch_paths:
continue
filename = blob.name.split('/')[-1]
if '.batch.' not in filename:
continue
timestamp_str = _strip_extension(filename)
if '-' in timestamp_str:
start_ts = timestamp_str.split('-', 1)[0]
if start_ts in ts_set:
blob.delete()
deleted_batch_paths.add(blob.name)
def list_audio_chunks(uid: str, conversation_id: str) -> List[Dict[str, Any]]:
"""
List all audio chunks for a conversation.
Returns:
List of dicts with chunk info: {'timestamp': float, 'path': str, 'size': int}
"""
bucket = _get_storage_client().bucket(private_cloud_sync_bucket)
prefix = f'chunks/{uid}/{conversation_id}/'
blobs = bucket.list_blobs(prefix=prefix)
chunks: List[Dict[str, Any]] = []
for blob in blobs:
# Extract timestamp from filename
# Supports single-chunk: '1234567890.123.opus', '1234567890.123.opus.enc', etc.
# Supports batch: '1234567890.123-1234567900.123.batch.bin', '1234567890.123.batch.enc'
filename = blob.name.split('/')[-1]
has_valid_ext = any(filename.endswith(ext) for ext in PRIVATE_CLOUD_EXTENSIONS)
if has_valid_ext:
try:
timestamp_str = _strip_extension(filename)
is_batch = '.batch.' in filename
if is_batch and '-' in timestamp_str:
# Batch blob with timestamp range: "first_ts-last_ts"
first_ts_str, _ = timestamp_str.split('-', 1)
timestamp = float(first_ts_str)
else:
timestamp = float(timestamp_str)
chunks.append(
{
'timestamp': timestamp,
'path': blob.name,
'size': blob.size,
'is_batch': is_batch,
}
)
except ValueError:
continue
return sorted(chunks, key=lambda x: x['timestamp'])
def delete_conversation_audio_files(uid: str, conversation_id: str) -> None:
"""Delete all audio files (chunks and merged) for a conversation."""
bucket = _get_storage_client().bucket(private_cloud_sync_bucket)
# Delete chunks
chunks_prefix = f'chunks/{uid}/{conversation_id}/'
for blob in bucket.list_blobs(prefix=chunks_prefix):
blob.delete()
# Delete merged files
audio_prefix = f'audio/{uid}/{conversation_id}/'
for blob in bucket.list_blobs(prefix=audio_prefix):
blob.delete()
# PCM16 mono: one sample is 2 bytes. A chunk whose byte count is not a multiple
# of this is truncated mid-sample.
_PCM16_FRAME_BYTES = 2
def _align_pcm16_frames(pcm_data: bytes, source: str) -> bytes:
"""Drop a trailing partial PCM16 sample so decoded chunks stay frame-aligned.
A chunk stored truncated mid-sample (interrupted upload) makes every later
chunk in the merge byte-misaligned and leaves the merged buffer an odd byte
count, which pydub rejects with a deterministic ValueError. The audio-merge
Cloud Task retried that unretryable error to exhaustion and then marked
playback permanently unavailable, losing the artifact for the conversation.
Trimming the partial sample costs 1/32000s and keeps the merge buildable.
"""
remainder = len(pcm_data) % _PCM16_FRAME_BYTES
if not remainder:
return pcm_data
record_fallback(
component='audio_merge',
from_mode='pcm16_frames',
to_mode='pcm16_frames_truncated',
reason='malformed_doc',
outcome='recovered',
log=logger,
)
logger.warning(f'audio chunk not PCM16 frame-aligned, trimming {remainder} trailing byte(s): {source}')
return pcm_data[:-remainder]
def download_audio_chunks_and_merge(
uid: str,
conversation_id: str,
timestamps: List[float],
fill_gaps: bool = True,
sample_rate: int = 16000,
) -> bytes:
"""
Download and merge audio chunks on-demand, handling mixed encryption states.
Downloads chunks in parallel.
Normalizes all chunks to unencrypted PCM format for consistent merging.
Supports both single-chunk blobs and batch blobs (from upload_audio_chunks_batch).
Args:
uid: User ID
conversation_id: Conversation ID
timestamps: List of chunk timestamps to merge
fill_gaps: If True, insert silence (zero bytes) between chunks to maintain
continuous time-aligned audio. Default True.
sample_rate: Audio sample rate in Hz (default 16000)
Returns:
Merged audio bytes (PCM16)
"""
bucket = _get_storage_client().bucket(private_cloud_sync_bucket)
# Resolve actual GCS paths — needed to find batch blobs whose filenames
# contain timestamp ranges instead of single timestamps
actual_chunks = list_audio_chunks(uid, conversation_id)
ts_set = {round(ts, 3) for ts in timestamps}
# Build batch blob map: for batch blobs, track which timestamps they cover
batch_paths: Dict[str, Dict[str, Any]] = {} # path -> chunk_info (deduplicate downloads)
ts_to_batch_path: Dict[float, str] = {} # timestamp -> batch_path (for timestamps inside batch range)
single_chunk_timestamps: List[float] = [] # timestamps that have individual blobs
for chunk in actual_chunks:
if chunk.get('is_batch'):
path = chunk['path']
batch_paths[path] = chunk
# Parse batch range to determine covered timestamps
filename = path.split('/')[-1]
ts_str = _strip_extension(filename)
if '-' in ts_str:
start_str, end_str = ts_str.split('-', 1)
batch_start = float(start_str)
batch_end = float(end_str)
else:
batch_start = batch_end = float(ts_str)
# Map requested timestamps that fall within this batch's range
for ts in timestamps:
if batch_start <= round(ts, 3) <= batch_end:
ts_to_batch_path[round(ts, 3)] = path
elif round(chunk['timestamp'], 3) in ts_set: