Skip to content

Commit 1a8fa1e

Browse files
beveradbclaude
andcommitted
feat: add GCS URI support to /separate endpoint
Allow callers to pass a gcs_uri (gs://bucket/path) instead of uploading the audio file as a multipart POST body. This avoids Cloud Run's 32MB request body limit for large FLAC files. - Server: new gcs_uri parameter on /separate, downloads from GCS directly - Client: separate_audio() and separate_audio_and_wait() accept gcs_uri - Backward compatible: file upload still works when gcs_uri is not set - Version bump to 0.44.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 153b2e4 commit 1a8fa1e

4 files changed

Lines changed: 148 additions & 19 deletions

File tree

audio_separator/remote/api_client.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ def __init__(self, api_url: str, logger: logging.Logger):
2929

3030
def separate_audio(
3131
self,
32-
file_path: str,
32+
file_path: Optional[str] = None,
3333
model: Optional[str] = None,
3434
models: Optional[List[str]] = None,
3535
preset: Optional[str] = None,
36+
gcs_uri: Optional[str] = None,
3637
# Output parameters
3738
output_format: str = "flac",
3839
output_bitrate: Optional[str] = None,
@@ -70,13 +71,28 @@ def separate_audio(
7071
mdxc_batch_size: int = 1,
7172
mdxc_pitch_shift: int = 0,
7273
) -> dict:
73-
"""Submit audio separation job (asynchronous processing)."""
74-
if not os.path.exists(file_path):
75-
raise FileNotFoundError(f"Audio file not found: {file_path}")
74+
"""Submit audio separation job (asynchronous processing).
75+
76+
Provide either file_path (uploads file) or gcs_uri (server fetches from GCS).
77+
"""
78+
if not file_path and not gcs_uri:
79+
raise ValueError("Must provide either file_path or gcs_uri")
80+
if file_path and gcs_uri:
81+
raise ValueError("Provide either file_path or gcs_uri, not both")
82+
83+
files = {}
84+
file_handle = None
85+
if file_path:
86+
if not os.path.exists(file_path):
87+
raise FileNotFoundError(f"Audio file not found: {file_path}")
88+
file_handle = open(file_path, "rb")
89+
files = {"file": (os.path.basename(file_path), file_handle)}
7690

77-
files = {"file": (os.path.basename(file_path), open(file_path, "rb"))}
7891
data = {}
7992

93+
if gcs_uri:
94+
data["gcs_uri"] = gcs_uri
95+
8096
# Handle model/preset parameters
8197
if preset:
8298
data["preset"] = preset
@@ -133,21 +149,28 @@ def separate_audio(
133149

134150
try:
135151
# Increase timeout for large files (5 minutes)
136-
response = self.session.post(f"{self.api_url}/separate", files=files, data=data, timeout=300)
152+
response = self.session.post(
153+
f"{self.api_url}/separate",
154+
files=files if files else None,
155+
data=data,
156+
timeout=300,
157+
)
137158
response.raise_for_status()
138159
return response.json()
139160
except requests.RequestException as e:
140161
self.logger.error(f"Separation request failed: {e}")
141162
raise
142163
finally:
143-
files["file"][1].close()
164+
if file_handle:
165+
file_handle.close()
144166

145167
def separate_audio_and_wait(
146168
self,
147-
file_path: str,
169+
file_path: Optional[str] = None,
148170
model: Optional[str] = None,
149171
models: Optional[List[str]] = None,
150172
preset: Optional[str] = None,
173+
gcs_uri: Optional[str] = None,
151174
timeout: int = 600,
152175
poll_interval: int = 10,
153176
download: bool = True,
@@ -192,9 +215,10 @@ def separate_audio_and_wait(
192215
and optionally download the result files.
193216
194217
Args:
195-
file_path: Path to the audio file to separate
218+
file_path: Path to the audio file to separate (or None if using gcs_uri)
196219
model: Single model to use for separation (for backwards compatibility)
197220
models: List of models to use for separation
221+
gcs_uri: GCS URI (gs://bucket/path) - server fetches directly from GCS
198222
timeout: Maximum time to wait for completion in seconds (default: 600)
199223
poll_interval: How often to check status in seconds (default: 10)
200224
download: Whether to automatically download result files (default: True)
@@ -216,13 +240,15 @@ def separate_audio_and_wait(
216240
models_desc = f"preset:{preset}"
217241
else:
218242
models_desc = models or ([model] if model else ["default"])
219-
self.logger.info(f"Submitting separation job for '{file_path}' with {models_desc} (audio-separator v{AUDIO_SEPARATOR_VERSION})")
243+
source_desc = gcs_uri if gcs_uri else file_path
244+
self.logger.info(f"Submitting separation job for '{source_desc}' with {models_desc} (audio-separator v{AUDIO_SEPARATOR_VERSION})")
220245

221246
result = self.separate_audio(
222247
file_path,
223248
model,
224249
models,
225250
preset,
251+
gcs_uri,
226252
output_format,
227253
output_bitrate,
228254
normalization_threshold,

audio_separator/remote/deploy_cloudrun.py

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,37 @@ def generate_file_hash(filename: str) -> str:
6161
return hashlib.sha256(filename.encode("utf-8")).hexdigest()[:16]
6262

6363

64+
def download_from_gcs(gcs_uri: str) -> tuple[bytes, str]:
65+
"""Download an audio file from GCS.
66+
67+
Args:
68+
gcs_uri: GCS URI in the format gs://bucket/path/to/file
69+
70+
Returns:
71+
Tuple of (file_bytes, filename)
72+
"""
73+
from google.cloud import storage
74+
75+
if not gcs_uri.startswith("gs://"):
76+
raise ValueError(f"Invalid GCS URI (must start with gs://): {gcs_uri}")
77+
78+
# Parse gs://bucket/path
79+
without_prefix = gcs_uri[len("gs://"):]
80+
slash_idx = without_prefix.index("/")
81+
bucket_name = without_prefix[:slash_idx]
82+
blob_path = without_prefix[slash_idx + 1:]
83+
filename = os.path.basename(blob_path)
84+
85+
logger.info(f"Downloading from GCS: bucket={bucket_name}, path={blob_path}")
86+
client = storage.Client()
87+
bucket = client.bucket(bucket_name)
88+
blob = bucket.blob(blob_path)
89+
audio_bytes = blob.download_as_bytes()
90+
logger.info(f"Downloaded {len(audio_bytes)} bytes from GCS")
91+
92+
return audio_bytes, filename
93+
94+
6495
try:
6596
AUDIO_SEPARATOR_VERSION = version("audio-separator")
6697
except Exception:
@@ -335,7 +366,8 @@ def render(self, content: typing.Any) -> bytes:
335366

336367
@web_app.post("/separate")
337368
async def separate_audio(
338-
file: UploadFile = File(..., description="Audio file to separate"),
369+
file: Optional[UploadFile] = File(None, description="Audio file to separate"),
370+
gcs_uri: Optional[str] = Form(None, description="GCS URI (gs://bucket/path) to fetch audio from instead of uploading"),
339371
model: Optional[str] = Form(None, description="Single model to use for separation"),
340372
models: Optional[str] = Form(None, description='JSON list of models, e.g. ["model1.ckpt", "model2.onnx"]'),
341373
preset: Optional[str] = Form(None, description="Ensemble preset name (e.g. instrumental_clean, karaoke)"),
@@ -376,9 +408,14 @@ async def separate_audio(
376408
mdxc_batch_size: int = Form(1),
377409
mdxc_pitch_shift: int = Form(0),
378410
) -> dict:
379-
"""Upload an audio file and separate it into stems."""
380-
if not file.filename:
381-
raise HTTPException(status_code=400, detail="No file provided")
411+
"""Upload an audio file (or provide a GCS URI) and separate it into stems."""
412+
# Validate: must provide exactly one of file or gcs_uri
413+
has_file = file is not None and file.filename
414+
has_gcs = gcs_uri is not None and gcs_uri.strip()
415+
if not has_file and not has_gcs:
416+
raise HTTPException(status_code=400, detail="Must provide either a file upload or gcs_uri parameter")
417+
if has_file and has_gcs:
418+
raise HTTPException(status_code=400, detail="Provide either file upload or gcs_uri, not both")
382419

383420
try:
384421
# Parse models parameter
@@ -403,15 +440,24 @@ async def separate_audio(
403440
except json.JSONDecodeError as e:
404441
raise HTTPException(status_code=400, detail=f"Invalid JSON in custom_output_names parameter: {e}")
405442

406-
audio_data = await file.read()
443+
# Get audio data from file upload or GCS
444+
if has_gcs:
445+
try:
446+
audio_data, filename = download_from_gcs(gcs_uri.strip())
447+
except Exception as e:
448+
raise HTTPException(status_code=400, detail=f"Failed to download from GCS: {e}")
449+
else:
450+
audio_data = await file.read()
451+
filename = file.filename
452+
407453
task_id = str(uuid.uuid4())
408454

409455
# Set initial status
410456
job_status_store[task_id] = {
411457
"task_id": task_id,
412458
"status": "submitted",
413459
"progress": 0,
414-
"original_filename": file.filename,
460+
"original_filename": filename,
415461
"models_used": [f"preset:{preset}"] if preset else (models_list or ["default"]),
416462
"total_models": 1 if preset else (len(models_list) if models_list else 1),
417463
"current_model_index": 0,
@@ -425,7 +471,7 @@ async def separate_audio(
425471
None,
426472
lambda: separate_audio_sync(
427473
audio_data,
428-
file.filename,
474+
filename,
429475
task_id,
430476
models_list,
431477
preset,
@@ -608,7 +654,7 @@ async def root() -> dict:
608654
"All MDX, VR, Demucs, and MDXC architectures supported",
609655
],
610656
"endpoints": {
611-
"POST /separate": "Upload and separate audio file (supports presets, multiple models, all parameters)",
657+
"POST /separate": "Separate audio file via upload or GCS URI (supports presets, multiple models, all parameters)",
612658
"GET /status/{task_id}": "Get job status and progress",
613659
"GET /download/{task_id}/{file_hash}": "Download separated file using hash identifier",
614660
"GET /presets": "List available ensemble presets",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
44

55
[tool.poetry]
66
name = "audio-separator"
7-
version = "0.43.1"
7+
version = "0.44.0"
88
description = "Easy to use audio stem separation, using various models from UVR trained primarily by @Anjok07"
99
authors = ["Andrew Beveridge <andrew@beveridge.uk>"]
1010
license = "MIT"

tests/unit/test_remote_api_client.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,3 +517,60 @@ def test_separate_audio_and_wait_with_hash_format(self, mock_sleep, mock_downloa
517517
]
518518
actual_calls = [call.args for call in mock_download_hash.call_args_list]
519519
assert actual_calls == expected_calls
520+
521+
@patch("requests.Session.post")
522+
def test_separate_audio_with_gcs_uri(self, mock_post, api_client):
523+
"""Test audio separation using GCS URI instead of file upload."""
524+
mock_response = Mock()
525+
mock_response.json.return_value = {
526+
"task_id": "test-task-gcs",
527+
"status": "submitted",
528+
}
529+
mock_response.raise_for_status.return_value = None
530+
mock_post.return_value = mock_response
531+
532+
result = api_client.separate_audio(
533+
gcs_uri="gs://my-bucket/path/to/audio.flac",
534+
preset="instrumental_clean",
535+
)
536+
537+
assert result["task_id"] == "test-task-gcs"
538+
539+
# Verify gcs_uri was sent in form data, no file upload
540+
call_args = mock_post.call_args
541+
assert call_args[1]["files"] is None
542+
assert call_args[1]["data"]["gcs_uri"] == "gs://my-bucket/path/to/audio.flac"
543+
544+
def test_separate_audio_requires_file_or_gcs_uri(self, api_client):
545+
"""Test that either file_path or gcs_uri must be provided."""
546+
with pytest.raises(ValueError, match="Must provide either"):
547+
api_client.separate_audio()
548+
549+
def test_separate_audio_rejects_both_file_and_gcs_uri(self, api_client, mock_audio_file):
550+
"""Test that providing both file_path and gcs_uri raises an error."""
551+
with pytest.raises(ValueError, match="not both"):
552+
api_client.separate_audio(
553+
file_path=mock_audio_file,
554+
gcs_uri="gs://bucket/file.flac",
555+
)
556+
557+
@patch.object(AudioSeparatorAPIClient, "separate_audio")
558+
@patch.object(AudioSeparatorAPIClient, "get_job_status")
559+
@patch("time.sleep")
560+
def test_separate_audio_and_wait_with_gcs_uri(self, mock_sleep, mock_status, mock_separate, api_client):
561+
"""Test separate_audio_and_wait with GCS URI."""
562+
mock_separate.return_value = {"task_id": "test-task-gcs"}
563+
mock_status.side_effect = [
564+
{"status": "completed", "files": {"hash1": "output.flac"}},
565+
]
566+
567+
result = api_client.separate_audio_and_wait(
568+
gcs_uri="gs://my-bucket/audio.flac",
569+
preset="instrumental_clean",
570+
download=False,
571+
)
572+
573+
assert result["status"] == "completed"
574+
# Verify gcs_uri was passed through to separate_audio
575+
call_args = mock_separate.call_args
576+
assert call_args[0][4] == "gs://my-bucket/audio.flac" # positional arg for gcs_uri

0 commit comments

Comments
 (0)