from concurrent.futures import ThreadPoolExecutor import glob import os from pathlib import Path import shutil import signal import socket import sys import tempfile import threading import time import trace import traceback import boto3 from numpy import Inf from lib.redis_client import RedisClient, getenv import uuid import subprocess import json import zipfile from lib.progress import ProgressHandler from lib.audio_utils import download_audio, upload_audio import psycopg from psycopg.rows import dict_row import backoff SAMPLE_RATE = 48000 # rate used for rvc MAX_CACHE_SIZE = 4 * 1024 * 1024 * 1024 # 4GB def recvall(sock): data = bytearray() while True: packet = sock.recv(4096) if not packet: return data data.extend(packet) RVC_MODELS_S3_BUCKET = getenv("RVC_MODELS_S3_BUCKET") RVC_SAMPLES_S3_BUCKET = getenv("RVC_SAMPLES_S3_BUCKET") RVC_CACHE_DIR = getenv("RVC_CACHE_DIR") os.makedirs(RVC_CACHE_DIR, exist_ok=True) def run_with_line_callback_thd(popen_handle, callback): pipe = popen_handle.stdout def terminate(): try: os.killpg(popen_handle.pid, signal.SIGINT) except Exception as e: print(f"Failed to terminate subprocess process group: {e}") while True: line = pipe.readline() if not line: break try: sys.stdout.write(line) callback(line, terminate) except Exception as e: print(f"line callback failed: {e}") def run_with_line_callback(args, callback): p = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) thd = threading.Thread( target=run_with_line_callback_thd, args=(p, callback), daemon=True, ) try: thd.start() p.wait() if p.returncode != 0: raise RuntimeError(f"Subprocess exited with code {p.returncode}") finally: if p.poll() is None: p.kill() p.wait() try: os.killpg(p.pid, signal.SIGINT) except Exception as e: print(f"Failed to terminate subprocess process group: {e}") if thd.is_alive(): thd.join() class RVCTrainer: def __init__(self, models_s3_bucket, samples_s3_bucket, cache_dir): self.s3 = boto3.client("s3", verify=False) self.models_s3_bucket = models_s3_bucket self.samples_s3_bucket = samples_s3_bucket self.cache_dir = cache_dir @classmethod def from_env(cls): return cls(RVC_MODELS_S3_BUCKET, RVC_SAMPLES_S3_BUCKET, RVC_CACHE_DIR) def _execute(self, query, args, fetchall=False): # pylint: disable=E1129 with psycopg.connect("", row_factory=dict_row, connect_timeout=10) as conn: conn.autocommit = True with conn.cursor() as cur: cur.execute(query, args) if fetchall: return cur.fetchall() @backoff.on_exception( backoff.fibo, ( psycopg.OperationalError, psycopg.InterfaceError, psycopg.errors.ConnectionTimeout, OSError, ), max_time=60, ) def _execute_backoff(self, query, args, fetchall=False): return self._execute(query, args, fetchall=fetchall) def run(self): # pylint: disable=E1129 with psycopg.connect("", row_factory=dict_row, connect_timeout=10) as conn: conn.autocommit = True with conn.transaction(), conn.cursor() as cur: cur.execute( """ SELECT r.id, vcm.uuid model_uuid, COALESCE(corpus_samples.sample_s3_keys, '{}') corpus_s3_keys FROM voice_conversion_model_training_runs r JOIN voice_conversion_models vcm ON vcm.id = r.model_id LEFT JOIN LATERAL ( SELECT ARRAY_AGG(DISTINCT COALESCE(s.s3_key, s.compressed_s3_key)) sample_s3_keys FROM voice_conversion_model_corpus c JOIN samples s ON s.id = c.sample_id WHERE c.model_id = vcm.id ) corpus_samples ON true WHERE NOT vcm.archived AND r.state = 'pending' ORDER BY r.created_at ASC LIMIT 1 FOR UPDATE OF r SKIP LOCKED """ ) rows = cur.fetchall() if len(rows) == 0: print("No pending training runs - exiting") sys.exit(0) row = rows[0] training_run_id = row["id"] model_uuid = row["model_uuid"] corpus_s3_keys = row["corpus_s3_keys"] cur.execute( "UPDATE voice_conversion_model_training_runs SET (state, trainer_version) = ('training', 'v0') WHERE id = %s", (training_run_id,), ) try: with open("/trainer_working", "w") as f: f.write("ready") except (PermissionError, FileNotFoundError): print(f"Failed to write /trainer_working") def notify_progress(progress): try: self._execute( "UPDATE voice_conversion_model_training_runs SET progress = %s WHERE id = %s", (progress, training_run_id), ) except Exception as e: print(f"Failed to update progress: {e}") def poll_cancellation(): try: rows = self._execute( "SELECT 1 FROM voice_conversion_model_training_runs WHERE id = %s AND state = 'training'", (training_run_id,), fetchall=True, ) return len(rows) == 0 except Exception as e: print(f"Failed to poll cancellation: {e}") return False try: s3_key = self.handle_training_request( corpus_s3_keys, model_uuid, notify_progress, poll_cancellation ) except Exception as e: formatted_exn = traceback.format_exception(e) print(formatted_exn) self._execute_backoff( "UPDATE voice_conversion_model_training_runs SET (state, error) = ('failed', %s) WHERE id = %s", (formatted_exn, training_run_id), ) return self._execute_backoff( "UPDATE voice_conversion_model_training_runs SET (state, s3_key) = ('finished', %s) WHERE id = %s", (s3_key, training_run_id), ) def handle_training_request( self, corpus_s3_keys, model_uuid, notify_progress, poll_cancellation ): with tempfile.TemporaryDirectory() as tmpdir, ThreadPoolExecutor( max_workers=4 ) as executor: notify_progress(1.0) download_futures = [ executor.submit( download_audio, self.s3, self.samples_s3_bucket, s3_key, tmpdir, sample_rate=SAMPLE_RATE, ) for s3_key in corpus_s3_keys ] for future in download_futures: future.result() notify_progress(2.0) epoch_count = 0 epoch_total = 100.0 def progress_callback(line, terminate): nonlocal epoch_total nonlocal epoch_count if line.startswith("INFO:rvc_model:====> Epoch:"): epoch_count += 1 if poll_cancellation(): terminate() notify_progress(2.0 + epoch_count / epoch_total * 97.0) elif line.startswith("************ Training for "): try: epoch_total = float(line.split(" ")[3]) except Exception: print("Failed to parse total epoch count") with tempfile.TemporaryDirectory() as rundir: run_with_line_callback( [ "python", "train-cli.py", tmpdir, "rvc_model", rundir, ], progress_callback, ) model_uuid = uuid.uuid4() s3_key = f"{model_uuid}.zip" with tempfile.TemporaryDirectory( dir=self.cache_dir, ignore_cleanup_errors=True ) as pack_dir: shutil.move( f"{rundir}/assets/weights/rvc_model.pth", f"{pack_dir}/rvc_model.pth", ) for path in glob.glob(f"{rundir}/logs/rvc_model/*.index"): shutil.move(path, f"{pack_dir}/{os.path.basename(path)}") with zipfile.ZipFile(f"{tmpdir}/{s3_key}", "w") as zip: for path in glob.glob(f"{pack_dir}/*"): zip.write(path, os.path.basename(path)) shutil.move(pack_dir, f"{self.cache_dir}/{model_uuid}") self.s3.upload_file(f"{tmpdir}/{s3_key}", self.models_s3_bucket, s3_key) notify_progress(100.0) return s3_key # hack to make local training work class RVCRedisClientLocalTraining(RedisClient): def __init__(self, models_s3_bucket, samples_s3_bucket, cache_dir, *args): super().__init__(*args) self.s3 = boto3.client("s3", verify=False) self.models_s3_bucket = models_s3_bucket self.samples_s3_bucket = samples_s3_bucket self.cache_dir = cache_dir @classmethod def from_env(cls): return super().from_env( RVC_MODELS_S3_BUCKET, RVC_SAMPLES_S3_BUCKET, RVC_CACHE_DIR, ) def train(self, corpus_s3_keys, model_uuid): r = RVCTrainer.from_env() def notify_progress(progress): self.client.rpush( f"rvc_progress:{model_uuid}", json.dumps( { "state": "training", "progress": progress, } ), ) def poll_cancellation(): return self.client.lpop(f"rvc_cancel:{model_uuid}") is not None try: s3_key = r.handle_training_request( corpus_s3_keys, model_uuid, notify_progress, poll_cancellation ) self.client.rpush( f"rvc_progress:{model_uuid}", json.dumps( { "state": "finished", "s3Key": s3_key, } ), ) except Exception as e: formatted_exn = traceback.format_exception(e) print(formatted_exn) self.client.rpush( f"rvc_progress:{model_uuid}", json.dumps( { "state": "failed", "error": formatted_exn, } ), ) def dirsize(path): total_size = 0 for dirpath, _, filenames in os.walk(path): for f in filenames: fp = os.path.join(dirpath, f) total_size += os.path.getsize(fp) return total_size class RVCRedisClient(RedisClient): def __init__(self, models_s3_bucket, samples_s3_bucket, cache_dir, *args): super().__init__(*args) self.s3 = boto3.client("s3", verify=False) self.models_s3_bucket = models_s3_bucket self.samples_s3_bucket = samples_s3_bucket self.cache_dir = cache_dir self.inference_proc = subprocess.Popen( ["python", "tools/infer_socket.py"], ) self.inference_watchdog = threading.Thread( target=self._die_when_subprocess_exits, args=(self.inference_proc,), daemon=True, ) self.inference_watchdog.start() @classmethod def from_env(cls): return super().from_env( RVC_MODELS_S3_BUCKET, RVC_SAMPLES_S3_BUCKET, RVC_CACHE_DIR, ) def _die_when_subprocess_exits(self, proc): proc.wait() os.kill(os.getpid(), signal.SIGINT) def _cleanup_cache(self): cache_size = dirsize(self.cache_dir) for _ in range(5): # don't work too hard if cache_size / MAX_CACHE_SIZE < 0.8: return items = sorted( (os.path.getmtime(f"{self.cache_dir}/{f}"), f) for f in os.listdir(self.cache_dir) ) for _, f in items: print(f"cache: removing {f}") if os.path.isdir(f"{self.cache_dir}/{f}"): cache_size -= dirsize(f"{self.cache_dir}/{f}") shutil.rmtree(f"{self.cache_dir}/{f}") else: cache_size -= os.path.getsize(f"{self.cache_dir}/{f}") os.unlink(f"{self.cache_dir}/{f}") def _download_model(self, s3_key, notify_progress): progress_handler = ProgressHandler(notify_progress, phase="Downloading model") try: self._cleanup_cache() except Exception as e: print(f"Failed to cleanup cache: {e}") model_uuid = s3_key.split(".")[0] dest_dir = f"{self.cache_dir}/{model_uuid}" if os.path.exists(dest_dir): progress_handler(1.0) os.utime(dest_dir) return dest_dir with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory( dir=self.cache_dir, ignore_cleanup_errors=True ) as unpack_dir: meta_data = self.s3.head_object(Bucket=self.models_s3_bucket, Key=s3_key) total_size = meta_data.get("ContentLength", None) bytes_downloaded = 0 last_notify_time = 0 def s3_progress_callback(chunk): nonlocal bytes_downloaded, last_notify_time bytes_downloaded += chunk if total_size is None: return if time.time() - last_notify_time < 0.5: return last_notify_time = time.time() progress_handler(bytes_downloaded / total_size) self.s3.download_file( self.models_s3_bucket, s3_key, f"{tmpdir}/{s3_key}", Callback=s3_progress_callback, ) with zipfile.ZipFile(f"{tmpdir}/{s3_key}", "r") as z: z.extractall(unpack_dir) shutil.move(unpack_dir, dest_dir) progress_handler(1.0) return dest_dir def warmup(self): for i in range(2): with tempfile.TemporaryDirectory() as rundir: os.makedirs(f"{rundir}/assets/weights", exist_ok=True) shutil.copy( f"./assets/weights/warmup.pth", f"{rundir}/assets/weights/warmup.pth", ) os.makedirs(f"{rundir}/logs/warmup", exist_ok=True) for path in glob.glob(f"./logs/warmup/*.index"): shutil.copy(path, f"{rundir}/logs/warmup/{os.path.basename(path)}") self._run_inference( 0, "assets/warmup.wav", "/tmp/deleteme.wav", "warmup.pth", None if i == 0 else [[0.0, 400.0], [4.0, 500.0]], rundir, ) print("warmup ok") def _run_inference( self, f0up_key, input_path, output_path, model_name, input_pitch_curve, rundir, ): request = { "f0up_key": f0up_key, "input_path": input_path, "opt_path": output_path, "model_name": model_name, "index_rate": 0.66, "filter_radius": 3, "rms_mix_rate": 1, "protect": 0.33, "pitch_curve": input_pitch_curve, "rundir": rundir, } with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: while True: try: s.connect(("localhost", 12345)) break except ConnectionRefusedError: time.sleep(1) s.sendall(json.dumps(request).encode("utf-8")) s.shutdown(socket.SHUT_WR) buffer = recvall(s) response = json.loads(buffer.decode("utf-8")) if "status" not in response: raise RuntimeError("No status in response") if response["status"] == "error": raise RuntimeError(response["message"]) if not os.path.exists(output_path): raise RuntimeError("Output file not found") return response["pitch_curve"] def check_pitch_curve(self, pitch_curve): assert pitch_curve is None or ( isinstance(pitch_curve, list) and all( isinstance(item, list) and len(item) == 2 and all(isinstance(x, float) for x in item) for item in pitch_curve ) and all( pitch_curve[i][0] < pitch_curve[i + 1][0] for i in range(len(pitch_curve) - 1) ) ), "pitch curve must be a list of pairs of floats (t,p) where t is increasing" return pitch_curve def handle_request(self, request_data, recorder, notify_progress, times_out_at): sample_s3_key = request_data["sampleS3Key"] model_s3_key = request_data["modelS3Key"] duration = max(0.1, request_data.get("sampleDuration", 10.0)) codec = "ogg" input_pitch_curve = self.check_pitch_curve(request_data.get("pitchCurve", None)) model_uuid = model_s3_key.split(".")[0] f0up_key = int(request_data.get("f0Delta", 0)) model_dir = self._download_model(model_s3_key, notify_progress) with tempfile.TemporaryDirectory() as rundir: os.makedirs(f"{rundir}/assets/weights", exist_ok=True) shutil.copy( f"{model_dir}/rvc_model.pth", f"{rundir}/assets/weights/{model_uuid}.pth", ) os.makedirs(f"{rundir}/logs/{model_uuid}", exist_ok=True) for path in glob.glob(f"{model_dir}/*.index"): shutil.copy( path, f"{rundir}/logs/{model_uuid}/{os.path.basename(path)}" ) input_path = download_audio( self.s3, self.samples_s3_bucket, sample_s3_key, rundir, progress_handler=ProgressHandler( notify_progress, duration=duration, phase="Decoding" ), sample_rate=SAMPLE_RATE, ) output_path = f"{rundir}/out.wav" inference_start_time = time.time() output_pitch_curve = self._run_inference( f0up_key, input_path, output_path, f"{model_uuid}.pth", input_pitch_curve, rundir, ) inference_duration = time.time() - inference_start_time print(f"Inference took {inference_duration} seconds") output_s3_key = f"{uuid.uuid4()}.{codec}" upload_audio( self.s3, self.samples_s3_bucket, output_s3_key, output_path, codec, progress_handler=ProgressHandler( notify_progress, duration=duration, phase="Encoding" ), ) return { "state": "finished", "s3Key": output_s3_key, "pitchCurve": output_pitch_curve, } if __name__ == "__main__": if len(sys.argv) > 1: if sys.argv[1] == "--train": client = RVCTrainer.from_env() elif sys.argv[1] == "--local-train": trainer = RVCRedisClientLocalTraining.from_env() trainer.train(sys.argv[2].split(","), sys.argv[3]) sys.exit(0) else: raise RuntimeError("Unknown argument") else: client = RVCRedisClient.from_env() client.run()