#!/usr/bin/env python """ Orpheus-3B Hebrew TTS — 5 sequential continual-fine-tuning rounds. Self-contained. Runs on a RunPod A100 started with dockerStartCmd (no SSH). Writes progress to HuggingFace Hub (checkpoint per round = round complete) and to a log file that is uploaded to a progress repo so the driving agent can retrieve it without pod access. Critical reference: canopyai/Orpheus-TTS (GitHub) - prompt format from orpheus_tts_pypi/orpheus_tts/engine_class.py::_format_prompt - audio encode/decode layout from orpheus_tts_pypi/orpheus_tts/decoder.py - training data format: (input_ids, labels) pre-tokenized, labels=input_ids default Vocab mapping (verified from unsloth/orpheus-3b-0.1-ft tokenizer.json): -> vocab_id 128256 + N 128257 = SOA () 128258 = EOA () 128259 = SOT () 128260, 128261 = separators (, ) Audio token formula (per canopy decoder.turn_token_into_id): vocab_id = 128266 + (pos_in_frame * 4096) + snac_code where pos_in_frame is 0..6 and the 7-slot frame layout (from decoder) is: slot 0: L0[j] (coarsest, 1 per frame) slot 1: L1[2j] (mid, first of pair) slot 2: L2[4j] (finest, first quad) slot 3: L2[4j+1] slot 4: L1[2j+1] slot 5: L2[4j+2] slot 6: L2[4j+3] """ from __future__ import annotations import json import os import pathlib import sys import time import traceback from datetime import datetime # ---------- CONFIG ---------- BASE_MODEL = "unsloth/orpheus-3b-0.1-ft" SNAC_MODEL = "hubertsiuzdak/snac_24khz" DATASET_ID = "imvladikon/hebrew_speech_kan" WHISPER_MODEL = "openai/whisper-large-v3" HF_USER = "oridror" REPO_TEMPLATE = "{user}/orpheus-3b-hebrew-r{round}" PROGRESS_REPO = f"{HF_USER}/orpheus-hebrew-progress" ROUND_SIZES = {1: 1000, 2: 1500, 3: 1500, 4: 1500, 5: 1500} EVAL_SIZE = 10 MAX_AUDIO_SECONDS = 12.0 MIN_AUDIO_SECONDS = 1.0 MAX_SEQ_LEN = 2560 WORKDIR = pathlib.Path("/workspace/orpheus") DATA_CACHE = WORKDIR / "data" MODEL_CACHE = WORKDIR / "models" CKPT_DIR = WORKDIR / "ckpt" LOG_FILE = WORKDIR / "progress.jsonl" # Orpheus special token IDs BOS = 128000 # <|begin_of_text|> EOT = 128009 # <|eot_id|> SOT = 128259 # start of "speak this" prompt SEP_A = 128260 SEP_B = 128261 SOA = 128257 # start of audio EOA = 128258 # end of audio PAD = 128263 AUDIO_TOK_BASE = 128266 # code offset base (from canopy decoder formula) CODEBOOK_SIZE = 4096 # LoRA LORA_R = 64 LORA_ALPHA = 128 LORA_DROPOUT = 0.05 LR = 5e-5 WARMUP_STEPS = 20 BATCH_SIZE = 1 GRAD_ACCUM = 8 EPOCHS_PER_ROUND = 1 def log(msg: str, **extra) -> None: rec = {"ts": datetime.utcnow().isoformat() + "Z", "msg": msg, **extra} line = json.dumps(rec, ensure_ascii=False) print(line, flush=True) WORKDIR.mkdir(parents=True, exist_ok=True) with LOG_FILE.open("a", encoding="utf-8") as f: f.write(line + "\n") def setup_env() -> None: WORKDIR.mkdir(parents=True, exist_ok=True) DATA_CACHE.mkdir(parents=True, exist_ok=True) MODEL_CACHE.mkdir(parents=True, exist_ok=True) CKPT_DIR.mkdir(parents=True, exist_ok=True) os.environ["HF_HOME"] = str(MODEL_CACHE) os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "1" log("env.ready", workdir=str(WORKDIR)) # ---------- SNAC ENCODE / DECODE (canopy-compatible) ---------- def snac_encode_one(wav_24k, snac_model, device): """Encode one 24kHz mono numpy array to a flat list of per-position vocab IDs. Returns a list of length 7 * num_frames of int32 vocab IDs. """ import numpy as np import torch w = torch.tensor(wav_24k, dtype=torch.float32, device=device).unsqueeze(0).unsqueeze(0) with torch.no_grad(): codes = snac_model.encode(w) c0 = codes[0][0].cpu().tolist() # (T,) c1 = codes[1][0].cpu().tolist() # (2T,) c2 = codes[2][0].cpu().tolist() # (4T,) T = len(c0) flat = [] for j in range(T): flat.append(AUDIO_TOK_BASE + 0 * CODEBOOK_SIZE + c0[j]) flat.append(AUDIO_TOK_BASE + 1 * CODEBOOK_SIZE + c1[2 * j]) flat.append(AUDIO_TOK_BASE + 2 * CODEBOOK_SIZE + c2[4 * j]) flat.append(AUDIO_TOK_BASE + 3 * CODEBOOK_SIZE + c2[4 * j + 1]) flat.append(AUDIO_TOK_BASE + 4 * CODEBOOK_SIZE + c1[2 * j + 1]) flat.append(AUDIO_TOK_BASE + 5 * CODEBOOK_SIZE + c2[4 * j + 2]) flat.append(AUDIO_TOK_BASE + 6 * CODEBOOK_SIZE + c2[4 * j + 3]) return flat def snac_decode(flat_vocab_ids, snac_model, device): """Inverse of snac_encode_one. Takes flat list of vocab IDs, returns 24kHz wav np array.""" import torch # Convert to per-position codes codes_by_pos = [[] for _ in range(7)] for idx, vid in enumerate(flat_vocab_ids): pos = idx % 7 code = vid - AUDIO_TOK_BASE - pos * CODEBOOK_SIZE if code < 0 or code >= CODEBOOK_SIZE: return None codes_by_pos[pos].append(code) T = len(codes_by_pos[0]) if T == 0: return None c0_list = codes_by_pos[0] c1_list = [] c2_list = [] for j in range(T): c1_list.append(codes_by_pos[1][j]) c2_list.append(codes_by_pos[2][j]) c2_list.append(codes_by_pos[3][j]) c1_list.append(codes_by_pos[4][j]) c2_list.append(codes_by_pos[5][j]) c2_list.append(codes_by_pos[6][j]) c0 = torch.tensor(c0_list, dtype=torch.int32, device=device).unsqueeze(0) c1 = torch.tensor(c1_list, dtype=torch.int32, device=device).unsqueeze(0) c2 = torch.tensor(c2_list, dtype=torch.int32, device=device).unsqueeze(0) with torch.inference_mode(): wav = snac_model.decode([c0, c1, c2]) return wav.squeeze().cpu().float().numpy() # ---------- TRAIN SAMPLE BUILD ---------- def build_sample(text, audio_vocab_ids, tokenizer, voice_prefix="hebrew: "): """Build input_ids per canopy format: BOS, SOT, , EOT, SEP_A, SEP_B, SOA,