Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backends/advanced/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ dependencies = [
"redis>=5.0.0",
"rq>=1.16.0",
"soundfile>=0.12.1",
"google-api-python-client>=2.0.0",
"google-auth-oauthlib>=1.0.0",
"google-auth-httplib2>=0.2.0",
"websockets>=12.0",
]

Expand Down
24 changes: 24 additions & 0 deletions backends/advanced/src/advanced_omi_backend/app_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ def __init__(self):
# Memory service configuration
self.memory_service_supports_threshold = self.memory_provider == "friend_lite"

self.gdrive_credentials_path = "data/gdrive_service_account.json"
self.gdrive_scopes = ["https://www.googleapis.com/auth/drive.readonly"]
self._gdrive_service = None

def get_gdrive_service(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably shouldn't be in app_config. can have a gdrive service in services or have a google_client in clients. Then we initialise it at the point of use

"""Return Google Drive API client using stored service account."""
from google.oauth2.service_account import Credentials
from googleapiclient.discovery import build
if self._gdrive_service:
return self._gdrive_service

if not os.path.exists(self.gdrive_credentials_path):
raise FileNotFoundError(
f"Missing Google Drive credentials at {self.gdrive_credentials_path}"
)

creds = Credentials.from_service_account_file(
self.gdrive_credentials_path,
scopes=self.gdrive_scopes,
)

self._gdrive_service = build("drive", "v3", credentials=creds)
return self._gdrive_service


# Global configuration instance
app_config = AppConfig()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ async def upload_and_process_audio_files(
# Read file content
content = await file.read()

gdrive_file_id = getattr(file, "gdrive_file_id", None)

# Generate audio UUID and timestamp
audio_uuid = str(uuid.uuid4())
timestamp = int(time.time() * 1000)
Expand All @@ -103,7 +105,8 @@ async def upload_and_process_audio_files(
user_email=user.email,
timestamp=timestamp,
chunk_dir=chunk_dir,
validate=True # Validate WAV format, convert stereo→mono
validate=True, # Validate WAV format, convert stereo→mono
gdrive_file_id=gdrive_file_id
)
except AudioValidationError as e:
processed_files.append({
Expand Down Expand Up @@ -133,7 +136,7 @@ async def upload_and_process_audio_files(
# Use the relative path returned by write_audio_file (already includes folder prefix if applicable)
conversation.audio_path = relative_audio_path
await conversation.insert()
conversation_id = conversation.conversation_id # Get the auto-generated ID
conversation_id = conversation.conversation_id # Get the auto-gener ated ID
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

audio_logger.info(f"📝 Created conversation {conversation_id} for uploaded file")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,15 @@ class AudioFile(Document):
description="Speech detection results"
)

gdrive_file_id: Optional[str] = Field(default=None, description="Google Drive file id")


class Settings:
name = "audio_chunks"
indexes = [
"audio_uuid",
"client_id",
"user_id",
"timestamp"
"timestamp",
"gdrive_file_id"
]
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,26 @@
from advanced_omi_backend.auth import current_superuser, current_active_user_optional, get_user_from_token_param
from advanced_omi_backend.controllers import audio_controller
from advanced_omi_backend.models.user import User
from advanced_omi_backend.app_config import get_audio_chunk_dir
from advanced_omi_backend.utils.gdrive_audio_utils import download_audio_files_from_drive

router = APIRouter(prefix="/audio", tags=["audio"])


@router.post("/upload_audio_from_url")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this router name suggests I can use any url, but looks like it's just Gdrive?
Probably the way we want to do this would be to upload from any url, but provide credentials in the header that we get from a google_auth client.

I'm not super familiar with how gdrive works though

async def upload_audio_from_drive_folder(
drive_folder_id: str = Query(...,alias="url", description="Google Drive Folder ID containing audio files (e.g., the string after /folders/ in the URL)"),
current_user: User = Depends(current_superuser),
device_name: str = Query(default="upload"),
auto_generate_client: bool = Query(default=True),
):
files = await download_audio_files_from_drive(drive_folder_id)

return await audio_controller.upload_and_process_audio_files(
current_user, files, device_name, auto_generate_client
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@router.get("/get_audio/{conversation_id}")
async def get_conversation_audio(
conversation_id: str,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ async def write_audio_file(
user_email: str,
timestamp: int,
chunk_dir: Optional[Path] = None,
validate: bool = True
validate: bool = True,
gdrive_file_id: Optional[str] = None,
) -> tuple[str, str, float]:
"""
Validate, write audio data to WAV file, and create AudioSession database entry.
Expand Down Expand Up @@ -203,7 +204,8 @@ async def write_audio_file(
user_id=user_id,
user_email=user_email,
has_speech=False, # Will be updated by transcription
speech_analysis={}
speech_analysis={},
gdrive_file_id=gdrive_file_id
)
await audio_file.insert()

Expand Down
123 changes: 123 additions & 0 deletions backends/advanced/src/advanced_omi_backend/utils/gdrive_audio_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import io
import tempfile
from typing import List
import logging
from starlette.datastructures import UploadFile as StarletteUploadFile
from googleapiclient.http import MediaIoBaseDownload
from advanced_omi_backend.app_config import get_app_config

logger = logging.getLogger(__name__)
audio_logger = logging.getLogger("audio_processing")

AUDIO_EXTENSIONS = (".wav", ".mp3", ".flac", ".ogg", ".m4a")
FOLDER_MIMETYPE = "application/vnd.google-apps.folder"


class AudioValidationError(Exception):
pass

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

async def download_and_wrap_drive_file(service, file_item):
file_id = file_item["id"]
name = file_item["name"]

request = service.files().get_media(fileId=file_id)

fh = io.BytesIO()
downloader = MediaIoBaseDownload(fh, request)

done = False
while not done:
_status, done = downloader.next_chunk()

content = fh.getvalue()

if not content:
raise AudioValidationError(f"Downloaded Google Drive file '{name}' was empty")

tmp_file = tempfile.SpooledTemporaryFile(max_size=10*1024*1024) # 10 MB
tmp_file.write(content)
tmp_file.seek(0)
upload_file = StarletteUploadFile(filename=name, file=tmp_file)

original_close = upload_file.close

def wrapped_close():
try:
original_close()
finally:
# SpooledTemporaryFile auto-cleans when closed; no unlink needed
pass

upload_file.close = wrapped_close

return upload_file
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# -------------------------------------------------------------
# LIST + DOWNLOAD FILES IN FOLDER (OAUTH)
# -------------------------------------------------------------
async def download_audio_files_from_drive(folder_id: str) -> List[StarletteUploadFile]:
if not folder_id:
raise AudioValidationError("Google Drive folder ID is required.")

service = get_app_config().get_gdrive_service()

try:
escaped_folder_id = folder_id.replace("\\", "\\\\").replace("'", "\\'")
query = f"'{escaped_folder_id}' in parents and trashed = false"

response = service.files().list(
q=query,
fields="files(id, name, mimeType)",
includeItemsFromAllDrives=False,
supportsAllDrives=False,
).execute()

all_files = response.get("files", [])

audio_files_metadata = [
f for f in all_files
if f["name"].lower().endswith(AUDIO_EXTENSIONS)
]

if not audio_files_metadata:
raise AudioValidationError("No audio files found in folder.")

wrapped_files = []
skipped_count = 0

for item in audio_files_metadata:
file_id = item["id"] # Get the Google Drive File ID

# Check if the file is already processed
if await is_drive_file_already_processed(file_id):
audio_logger.info(f"Skipping already processed file: {item['name']}") # Use your logger
skipped_count += 1
continue

# synchronous call now (but make the parent function async)
wrapped_file = await download_and_wrap_drive_file(service, item)
# Attach the file_id to the UploadFile object for later use
setattr(wrapped_file, "gdrive_file_id", file_id)
wrapped_files.append(wrapped_file)

if not wrapped_files and skipped_count > 0:
raise AudioValidationError(f"All {skipped_count} files in the folder have already been processed.")

return wrapped_files

except Exception as e:
if isinstance(e, AudioValidationError):
raise
raise AudioValidationError(f"Google Drive API Error: {e}") from e


async def is_drive_file_already_processed(gdrive_file_id: str) -> bool:
"""Check if an AudioFile document already exists for the given GDrive File ID."""
if not gdrive_file_id:
return False
from advanced_omi_backend.models.audio_file import AudioFile
existing_file = await AudioFile.find_one(
AudioFile.gdrive_file_id == gdrive_file_id
)
return existing_file is not None
80 changes: 80 additions & 0 deletions backends/advanced/webui/src/pages/Upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,53 @@ export default function Upload() {
const [isUploading, setIsUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [audioUrl, setAudioUrl] = useState('')

const { isAdmin } = useAuth()

const generateId = () => Math.random().toString(36).substr(2, 9)

const [urlUploadStatus, setUrlUploadStatus] = useState<{
type: 'success' | 'error' | null
message: string
}>({ type: null, message: '' })


// Handle URL submission
const handleUrlSubmit = async () => {
if (!audioUrl) return

setIsUploading(true)
setUrlUploadStatus({ type: null, message: '' })

try {
const response = await uploadApi.uploadAudioFromUrl({
drive_folder_id: audioUrl,
device_name: 'upload',
auto_generate_client: true,
})

console.log('URL Upload response:', response)

setUrlUploadStatus({
type: 'success',
message: `Audio submitted successfully`,
})

setAudioUrl('')
} catch (err: any) {
console.error('URL upload failed:', err)

setUrlUploadStatus({
type: 'error',
message:
err?.response?.data?.detail || 'Failed to upload audio from URL',
})
} finally {
setIsUploading(false)
}
}

const handleFileSelect = (selectedFiles: FileList | null) => {
if (!selectedFiles) return

Expand Down Expand Up @@ -152,6 +194,44 @@ export default function Upload() {
Upload Audio Files
</h1>
</div>
{/* URL Input */}
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<label className="block mb-2 font-medium text-gray-900 dark:text-gray-100">
Paste audio URL:
</label>

<div className="flex space-x-2">
<input
type="text"
value={audioUrl}
onChange={(e) => setAudioUrl(e.target.value)}
placeholder="https://example.com/audio.wav"
className="flex-1 px-3 py-2 border rounded-lg dark:bg-gray-800 dark:text-gray-100"
/>

<button
onClick={handleUrlSubmit}
disabled={isUploading || !audioUrl}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? 'Submitting...' : 'Submit URL'}
</button>
</div>

{/* ✅ Add the status message here */}
{urlUploadStatus.type && (
<div
className={`mt-3 p-3 rounded-lg text-sm ${
urlUploadStatus.type === 'success'
? 'bg-green-100 text-green-800 border border-green-300'
: 'bg-red-100 text-red-800 border border-red-300'
}`}
>
{urlUploadStatus.message}
</div>
)}
</div>


{/* Drop Zone */}
<div
Expand Down
17 changes: 15 additions & 2 deletions backends/advanced/webui/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export const queueApi = {
}

export const uploadApi = {
uploadAudioFiles: (files: FormData, onProgress?: (progress: number) => void) =>
uploadAudioFiles: (files: FormData, onProgress?: (progress: number) => void) =>
api.post('/api/audio/upload', files, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000, // 5 minutes
Expand All @@ -188,10 +188,23 @@ export const uploadApi = {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(progress)
}
}
},
}),

uploadAudioFromUrl: (payload: { drive_folder_id: string; device_name?: string; auto_generate_client?: boolean }) =>
// 1. Set the POST body to null (or leave it out, though explicit null is cleaner for no body)
api.post('/api/audio/upload_audio_from_url', null, {
// 2. Pass the entire payload object to the 'params' configuration key
params: {
url: payload.drive_folder_id, // IMPORTANT: Use 'url' here to match the backend's alias
device_name: payload.device_name,
auto_generate_client: payload.auto_generate_client,
},
timeout: 300000,
}),
}


export const chatApi = {
// Session management
createSession: (title?: string) => api.post('/api/chat/sessions', { title }),
Expand Down
Loading