"""Hoot alignment and video generation application on modal.""" import json import re import tempfile import traceback import numpy as np import time import contextlib import os import modal from suno_utils.worker.hoot_stub_mp import HootBackgroundWorker, LoadAudioResult import torch from suno_utils.audio import Audio from suno_utils.gpt import chirp_v2_5 as chirp_v3 from suno_utils.tasks.hoot import ( encode, LOGIT_DURATION_S, ) from suno_utils.tasks.lyrics_alignment.shortest_path_aligner import ( ShortestPathHootV6Config, ) from suno_utils.worker.loader import S3Loader, retry_s3_download from suno_utils.worker.schema import QueueItem from suno_utils.worker.modal_runner_sdxl_worker import IMAGE_PREFIX, LARGE_IMAGE_PREFIX from suno_utils.worker.settings import s3_client from suno_utils.worker.utils import retry_decorator from suno_utils.worker.modal_base import get_modal_base_image from suno_utils.worker.modal_model_volume import ( MODEL_STORE_VOLUME_DIR, model_store_volume, MODEL_STORE_VOLUME_PREFIX, ) ############## CHANGE THESE ############## DEPLOYMENT_TYPE = "dev" # priority, staging, dev, prod, msft #### APP_NAME = f"videos-v2-{DEPLOYMENT_TYPE}" assert APP_NAME.endswith(DEPLOYMENT_TYPE) SECRETS = [ modal.Secret.from_name("studio-aws"), modal.Secret.from_name("redis-test"), modal.Secret.from_name("api-callback-token"), ] base_image = ( get_modal_base_image() .pip_install_private_repos( "github.com/suno-ai/neon.git@0ffdb64#subdirectory=hoot", git_user="mcamac", secrets=[modal.Secret.from_name("victor-modal-github-token")], ) .pip_install("biopython>=1.81") .add_local_python_source("suno_utils", copy=False) ) HOOT_CKPT_PATH = "/checkpoints/hoot_v6/2025-06-23_16-49-02.pt" HOOT_TOKENIZER_PATH = "/checkpoints/hoot_v6/tokenizer.model" S3_BUCKET_NAME = "suno-data-uploads" S3_FOLDER_NAME = "studio/uploads" class HootWorker: def __init__(self): print("Start loading models") chirp_v3.preload_hoot_models( checkpoint_filepath=f"{MODEL_STORE_VOLUME_PREFIX}{HOOT_CKPT_PATH}", tokenizer_filepath=f"{MODEL_STORE_VOLUME_PREFIX}{HOOT_TOKENIZER_PATH}", ) print("Finish loading models") app = modal.App(APP_NAME, image=base_image) N_CPU_HOOT = 32 @app.cls( cpu=N_CPU_HOOT, gpu="A10G", secrets=SECRETS, timeout=200, scaledown_window=300, memory=128 * 1024, min_containers=1 if DEPLOYMENT_TYPE == "dev" else 20, max_containers=400, # cloud="aws", region="us-east", buffer_containers=1 if DEPLOYMENT_TYPE == "dev" else 5, volumes={MODEL_STORE_VOLUME_DIR: model_store_volume}, ) @modal.concurrent(max_inputs=32) class HootAlignmentStub: def __init__(self): from threading import Semaphore # other cores will be used by the background worker torch.set_num_threads(1) os.environ["OMP_NUM_THREADS"] = "1" num_gpus = torch.cuda.device_count() print(f"Found {num_gpus} GPUs.") self.worker = HootWorker() # only allow 2 concurrent encode calls, more risks CUDA OOM self.encode_semaphore = Semaphore(2) t0 = time.time() self.background_worker = HootBackgroundWorker( tokenizer_filepath=f"{MODEL_STORE_VOLUME_PREFIX}{HOOT_TOKENIZER_PATH}", aligner_config=ShortestPathHootV6Config, num_workers=2 * N_CPU_HOOT - 1, ) print(f"HootBackgroundWorker initialized in {time.time() - t0} seconds") @modal.exit() def cleanup_pool(self): self.background_worker.cleanup() def _get_audio_info_and_upload( self, queue_item: str, audio_result: LoadAudioResult, words_timing: list[dict] = [] ) -> None: """Add waveform to the json and upload to s3.""" item = QueueItem(**json.loads(queue_item)) fname = f"{item.id}_hoot.json" s3_fname = f"{S3_FOLDER_NAME}/{fname}" metadata = { "metadata": { "audio_rms": audio_result.waveform, } } words_timing.append(metadata) # Convert words_timing dictionary to a JSON string and encode it to bytes words_timing_str = json.dumps(words_timing, ensure_ascii=False, indent=4) print(f"Hoot {item.id}: before encoding {fname}.") words_timing_bytes = words_timing_str.encode("utf-8") print(f"Hoot {item.id}: before uploading {fname} to s3.") # Upload the bytes directly to S3 s3_client.put_object(Bucket=S3_BUCKET_NAME, Key=s3_fname, Body=words_timing_bytes) print(f"Hoot {item.id}: Uploaded {fname}.") @contextlib.contextmanager def _audio_temp_file(self, s3_id: str): # download to file so we can load in background worker with tempfile.NamedTemporaryFile(suffix=".mp3") as tmp_file: retry_s3_download(S3_BUCKET_NAME, f"{S3_FOLDER_NAME}/{s3_id}.mp3", tmp_file) tmp_file.flush() yield tmp_file.name @modal.method() def get_aligned_lyrics(self, queue_item: str) -> list[dict]: """Basically hit the hoot API, very efficiently.""" start_time = time.time() item = QueueItem(**json.loads(queue_item)) do_callback_aligned_lyrics = item.metadata.get("callback_aligned_lyrics", False) enable_augmentation = item.metadata.get("enable_augmentation", True) words_timing = [] response_json = {"type": "hoot_alignment_finished", "id": item.id} if do_callback_aligned_lyrics: response_json["alignment"] = {} try: print( f"Hoot {item.id}: Starting. full info {queue_item}. took {round(time.time() - start_time, 2)} seconds" ) with self._audio_temp_file(item.id) as audio_path: audio_result = self.background_worker.load_audio(audio_path) lyrics = item.prompt_text if item.prompt_text else "" if not lyrics.strip(): self._get_audio_info_and_upload(queue_item, audio_result, []) item.notify_progress( response_json, ) return [] print( f"Hoot {item.id}: getting alignment. took {round(time.time() - start_time, 2)} seconds" ) with self.encode_semaphore: with torch.cuda.amp.autocast(): logits = encode( audio_result.audio_tensor, return_logits=True, batch_size=4, return_torch=True ) assert isinstance(logits, torch.Tensor) align_interval_start_s = item.metadata.get("align_interval_start_s") align_interval_end_s = item.metadata.get("align_interval_end_s") align_offset = 0.0 if align_interval_start_s is not None and align_interval_end_s is not None: align_first_logit = int(np.floor(align_interval_start_s / LOGIT_DURATION_S)) align_last_logit = int(np.ceil(align_interval_end_s / LOGIT_DURATION_S)) assert ( align_first_logit >= 0 and align_last_logit <= logits.shape[0] and align_first_logit < align_last_logit ) logits = logits[align_first_logit:align_last_logit] print( f"Hoot {item.id}: aligned logits shape: {logits.shape} between {align_first_logit} and {align_last_logit}" ) align_offset = align_first_logit * LOGIT_DURATION_S aligner_start_time = time.time() words_timing, hoot_lyrics, hoot_cer = self.background_worker.align( lyrics, logits, override_enable_jumps=enable_augmentation ) print( f"Hoot {item.id}: shortest path alignment took {round(time.time() - aligner_start_time, 2)} seconds (not cumulative)" ) # these values don't make sense for shortest path alignment # but downstream consumers expect them for w in words_timing: w["success"] = True w["p_align"] = 1.0 w["start_s"] += align_offset w["end_s"] += align_offset words_timing.extend( [ {"hoot_lyrics": hoot_lyrics}, {"hoot_cer": hoot_cer}, {"decoder": f"ShortestPathAligner{'-no-jumps' if not enable_augmentation else ''}"}, ] ) if DEPLOYMENT_TYPE == "dev": print(f"Hoot {item.id}: words_timing: {words_timing}") print( f"Hoot {item.id}: finished alignment. took {round(time.time() - start_time, 2)} seconds" ) if not do_callback_aligned_lyrics: # do not update the json if the call is to just get aligned lyrics # spin this up but not blocking the gpu thread self._get_audio_info_and_upload(queue_item, audio_result, words_timing) except Exception as e: print(f"Hoot {item.id}: failed with: {str(e)}") traceback.print_exc() print(f"Hoot {item.id}: finished. took {round(time.time() - start_time, 2)} seconds") if do_callback_aligned_lyrics: response_json["alignment"] = words_timing # send notification to the client item.notify_progress( response_json, ) return words_timing @app.cls( cpu=4, secrets=SECRETS, timeout=200, scaledown_window=360, retries=modal.Retries( max_retries=2, backoff_coefficient=2.0, initial_delay=5.0, ), # cloud="aws", region="us-east", buffer_containers=1 if DEPLOYMENT_TYPE == "dev" else 20, ) @modal.concurrent(max_inputs=4) class DummyV0Stub: def __init__(self): self.worker = S3Loader() self.modal_f_hoot_alignment = modal.Cls.lookup( f"videos-v2-{'dev' if DEPLOYMENT_TYPE == 'dev' else 'prod'}", "HootAlignmentStub", )().get_aligned_lyrics @modal.method() def write_video( self, queue_item: str, image_id: str | None = None, is_square: bool | None = False, # TODO: remove this after redeploy all engines... audio: Audio | None = None, ): print(f"Get video gen request {queue_item}, image id {image_id}") # use wait_seconds 10, since the images / audios should have been generated by now long_retry_s3_download = retry_decorator(3, wait_seconds=10)(s3_client.download_fileobj) item = QueueItem(**json.loads(queue_item)) words_timing = [] try: backlog = self.modal_f_hoot_alignment.get_current_stats().backlog if backlog <= 1000: fn_calls = self.modal_f_hoot_alignment.spawn(item.json()) words_timing = fn_calls.get(timeout=60) words_timing = [word for word in words_timing if "metadata" not in word] except Exception as e: print(f"Failed to get hoot alignment: {str(e)}") if not words_timing: words_timing = [ {"word": s} for s in re.split(r"([\w'\-]+)", item.prompt_text if item.prompt_text else "") if len(s) > 0 ] should_generate_video = item.metadata.get("generate_video", False) if not should_generate_video: # technically might not be required, but just do this to be consistent item.notify_progress( {"type": "video_finished", "video_generated": False, "id": item.id}, ) return elif should_generate_video and image_id: with tempfile.NamedTemporaryFile() as tmp_file: # check if large image exists prefered_image_path = ( f"{S3_FOLDER_NAME}/{image_id.replace(IMAGE_PREFIX, LARGE_IMAGE_PREFIX)}" ) if prefered_image_path.endswith(".png"): prefered_image_jepg_path = prefered_image_path.replace(".png", ".jpeg") try: s3_client.get_object(Bucket=S3_BUCKET_NAME, Key=prefered_image_jepg_path) print(f"Successfully got jpeg image for {item.id}") # replace the path and then download... prefered_image_path = prefered_image_jepg_path except Exception as e: try: print(f"Failed to get jpeg image for {item.id} -- try png: {str(e)}") s3_client.get_object(Bucket=S3_BUCKET_NAME, Key=prefered_image_path) except Exception as e: print(f"Failed to get png image for {item.id} -- try small png: {str(e)}") # large image doesn't exist -- could due to the fact that it is uploaded, not generated prefered_image_path = f"studio/uploads/{image_id}" # check if image exists try: s3_client.get_object(Bucket=S3_BUCKET_NAME, Key=prefered_image_path) except Exception as e: print( f"Failed to get small png image for {item.id} -- use default: {str(e)}" ) # the image doesn't exists -- go to fallback default image FIXED_IMAGE_FILE = "image_large_default_bird.jpeg" prefered_image_path = f"studio/uploads/{FIXED_IMAGE_FILE}" elif prefered_image_path.endswith(".jpeg"): try: s3_client.get_object(Bucket=S3_BUCKET_NAME, Key=prefered_image_path) except Exception as e: print(f"Failed to get jpeg image for {item.id} -- try small jpeg: {str(e)}") # sometimes the large image doesn't exist... prefered_image_path = prefered_image_path.replace( LARGE_IMAGE_PREFIX, IMAGE_PREFIX ) try: s3_client.get_object(Bucket=S3_BUCKET_NAME, Key=prefered_image_path) except Exception as e: print( f"Failed to get small jpeg image for {item.id} -- use default: {str(e)}" ) FIXED_IMAGE_FILE = "image_large_default_bird.jpeg" prefered_image_path = f"{S3_FOLDER_NAME}/{FIXED_IMAGE_FILE}" # this should always be able to download long_retry_s3_download(S3_BUCKET_NAME, prefered_image_path, tmp_file) try: self.worker._write_video( item, show_text=True, visualizer=True, image_path=tmp_file, aligned_text=words_timing, audio=audio, # don't need to go through s3 if audio is around ) item.notify_progress( {"type": "video_finished", "video_generated": True, "id": item.id}, ) except FileNotFoundError: # this means the audio failed to download # could happen if the audio isn't uploaded successfully...or deleted print(f"Failed to download audio for {item.id}") item.notify_progress( { "id": item.id, "type": "error", "error_type": "video_generation_failure", "error_message": "Video generation failed due to missing audio.", } ) @app.local_entrypoint() def main(): import time # Configuration for stress test NUM_STRESS_TASKS = 50000 BATCH_SIZE = 1000 # Process in batches to avoid overwhelming the system print(f"Starting stress test with {NUM_STRESS_TASKS} tasks") # Initialize models hoot_model = HootAlignmentStub() video_model = DummyV0Stub() # Use the same valid test inputs as before - these actually exist in S3 hoot_input_1 = json.dumps( { "id": "6c1dfbff-129b-46f9-b3b4-31b1201cd726", "prompt_audio": None, "prompt_npz": None, "prompt_text": "[Instrumental]", "metadata": { "credit_cost": 5, "feature_flags": 3, "type": "gen", "source": "web", "prompt": "", "tags": "death metal aggressive thunderous", "gpt_prompt": None, "gpt_description_prompt": "metal song", "stream": True, "make_instrumental": True, "priority": 0, "user_id": 224, "is_bot": False, "task": None, "experiment": ["chirp-v3p5-engine-s-8", "chirp-v3p5-engine-s-8"], "experiment_version": "v_156", }, "gen_duration": 12, "callback_url": "https://studio-api.suno.ai/api/generate/finish-clip/", "model_name": "chirp-v3p5-engine-s-8", "title": "Eternal Carnage", "ids": None, } ) hoot_input_2_aug_true = json.dumps( { "id": "1cee79db-a0ee-44e4-9646-d312cd620997", "prompt_text": """ [verse 1] 滚滚长江东逝水, 浪花淘尽英雄。 [verse 2] 是非成败转头空 青山依旧在 几度夕阳红。 [bridge] 白发渔樵江渚上 惯看秋月春风。 [chorus] 一壶浊酒喜相逢。 古今多少事,都付笑谈中。 [Refrain] 一壶浊酒喜相逢。 古今多少事,都付笑谈中。 [end] """, "metadata": { "tags": "中文流行 chinese pop", "enable_augmentation": True, "generate_video": False, "align_interval_start_s": 1.0, "align_interval_end_s": 220.0, }, "title": "临江仙", "callback_url": "https://studio-api.suno.ai/api/generate/finish-clip/", } ) hoot_input_2_aug_false = json.dumps( { "id": "1cee79db-a0ee-44e4-9646-d312cd620997", "prompt_text": """ [verse 1] 滚滚长江东逝水, 浪花淘尽英雄。 [verse 2] 是非成败转头空 青山依旧在 几度夕阳红。 [bridge] 白发渔樵江渚上 惯看秋月春风。 [chorus] 一壶浊酒喜相逢。 古今多少事,都付笑谈中。 [Refrain] 一壶浊酒喜相逢。 古今多少事,都付笑谈中。 [end] """, "metadata": { "tags": "中文流行 chinese pop", "enable_augmentation": False, "generate_video": False, "align_interval_start_s": 1.0, "align_interval_end_s": 220.0, }, "title": "临江仙", "callback_url": "https://studio-api.suno.ai/api/generate/finish-clip/", } ) # Video generation input (same as hoot_input_2_aug_false but with video enabled) video_input = json.dumps( { "id": "1cee79db-a0ee-44e4-9646-d312cd620997", "prompt_text": """ [verse 1] 滚滚长江东逝水, 浪花淘尽英雄。 [verse 2] 是非成败转头空 青山依旧在 几度夕阳红。 [bridge] 白发渔樵江渚上 惯看秋月春风。 [chorus] 一壶浊酒喜相逢。 古今多少事,都付笑谈中。 [Refrain] 一壶浊酒喜相逢。 古今多少事,都付笑谈中。 [end] """, "metadata": { "tags": "中文流行 chinese pop", "enable_augmentation": False, "generate_video": True, "align_interval_start_s": 1.0, "align_interval_end_s": 220.0, }, "title": "临江仙", "callback_url": "https://studio-api.suno.ai/api/generate/finish-clip/", } ) # Test inputs to cycle through test_inputs = [hoot_input_1, hoot_input_2_aug_true, hoot_input_2_aug_false] all_tasks = [] start_time = time.time() print("Spawning tasks in batches...") for batch_num in range(0, NUM_STRESS_TASKS, BATCH_SIZE): batch_tasks = [] batch_end = min(batch_num + BATCH_SIZE, NUM_STRESS_TASKS) print(f"Spawning batch {batch_num//BATCH_SIZE + 1}: tasks {batch_num} to {batch_end-1}") for i in range(batch_num, batch_end): # Cycle through the valid test inputs input_to_use = test_inputs[i % len(test_inputs)] # Spawn hoot alignment task hoot_task = hoot_model.get_aligned_lyrics.spawn(input_to_use) batch_tasks.append(("hoot", hoot_task, f"task_{i}")) # Every 10th task also spawns a video generation task if i % 10 == 0: video_task = video_model.write_video.spawn( video_input, "image_large_default_bird.jpeg", ) batch_tasks.append(("video", video_task, f"video_task_{i}")) all_tasks.extend(batch_tasks) # Small delay between batches to avoid overwhelming the spawn mechanism time.sleep(0.1) spawn_time = time.time() - start_time print(f"All {len(all_tasks)} tasks spawned in {spawn_time:.2f} seconds") print(f"Average spawn rate: {len(all_tasks)/spawn_time:.2f} tasks/second") # Wait for all tasks to complete print("Waiting for all tasks to complete...") completed_tasks = 0 failed_tasks = 0 completion_start = time.time() for task_type, task, task_id in all_tasks: try: result = task.get(timeout=600) # 10 minute timeout per task completed_tasks += 1 if completed_tasks % 1000 == 0: elapsed = time.time() - completion_start print( f"Completed {completed_tasks}/{len(all_tasks)} tasks in {elapsed:.2f}s (rate: {completed_tasks/elapsed:.2f} tasks/s)" ) except Exception as e: failed_tasks += 1 if failed_tasks <= 10: # Only print first 10 failures to avoid spam print(f"Task {task_id} ({task_type}) failed: {str(e)}") elif failed_tasks == 11: print("... (suppressing further failure messages)") total_time = time.time() - start_time completion_time = time.time() - completion_start print("\n" + "=" * 60) print("STRESS TEST RESULTS") print("=" * 60) print(f"Total tasks spawned: {len(all_tasks)}") print(f"Tasks completed successfully: {completed_tasks}") print(f"Tasks failed: {failed_tasks}") print(f"Success rate: {(completed_tasks/len(all_tasks)*100):.2f}%") print(f"Total time (spawn + execution): {total_time:.2f} seconds") print(f"Spawn time: {spawn_time:.2f} seconds") print(f"Execution time: {completion_time:.2f} seconds") print(f"Average task completion rate: {completed_tasks/completion_time:.2f} tasks/second") print("=" * 60) print("Stress test completed!")