VoiceStudio / Studio.py
DillonLD's picture
Upload 11 files
a66f20a verified
Raw History Blame Contribute Delete
75.7 kB
#!/usr/bin/env python3
"""
studio.py
A local web app for managing cloned voices: write a script, pick a voice, generate takes,
compare profiles, and approve audio. Runs on your own computer; nothing is uploaded anywhere.
.venv-tts\\Scripts\\activate
python studio.py
Then open http://127.0.0.1:7860 (it opens automatically). The voice model loads once and
stays loaded, so every generation after the first starts right away.
Options:
--model standard|turbo which Chatterbox model (voice profiles must match it)
--port 7860
--host 127.0.0.1 use 0.0.0.0 to let others on your network open it (see README)
"""
import os
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS", "1")
import argparse
import csv
import json
import math
import mimetypes
import queue
import random
import re
import shutil
import tempfile
import threading
import time
import traceback
import uuid
import webbrowser
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import parse_qs, unquote, urlparse
import soundfile as sf
import speak
ROOT = Path.cwd()
OUTPUT = ROOT / "output"
VOICES = ROOT / "voices"
LEGACY_REFERENCE_DIR = ROOT / "voice_refs"
NAMED_REFERENCE_SUFFIX = "_voice_refs"
REFERENCE_DIR = (
ROOT / "brian_voice_refs"
if (ROOT / "brian_voice_refs").exists() or not LEGACY_REFERENCE_DIR.exists()
else LEGACY_REFERENCE_DIR
)
PEOPLE_MANIFEST = VOICES / "people.json"
REFERENCE_COUNT = 6
MAX_VIDEO_UPLOAD_BYTES = 8 * 1024 ** 3
MAX_REFERENCE_UPLOAD_BYTES = 100 * 1024 ** 2
VIDEO_UPLOAD_EXTS = {".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".mts", ".wav", ".mp3", ".m4a"}
REFERENCE_LOCK = threading.Lock()
AUDIO_EXTS = {".wav", ".flac", ".mp3", ".ogg", ".m4a"}
HTML = Path(__file__).with_name("studio.html")
APP_ASSETS = {
"/manifest.webmanifest": (Path(__file__).with_name("manifest.webmanifest"), "application/manifest+json"),
"/mobile-manifest.webmanifest": (Path(__file__).with_name("mobile-manifest.webmanifest"), "application/manifest+json"),
"/icon.svg": (Path(__file__).with_name("icon.svg"), "image/svg+xml"),
"/sw.js": (Path(__file__).with_name("sw.js"), "text/javascript; charset=utf-8"),
}
def people_registry():
if not PEOPLE_MANIFEST.is_file():
return {"people": [], "sources": {}}
data = json.loads(PEOPLE_MANIFEST.read_text(encoding="utf-8"))
if not isinstance(data, dict) or not isinstance(data.get("people", []), list):
raise ValueError("People registry is invalid.")
data.setdefault("sources", {})
return data
def save_people_registry(data):
VOICES.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=VOICES,
suffix=".tmp", delete=False) as handle:
temp_path = Path(handle.name)
json.dump(data, handle, indent=2)
try:
temp_path.replace(PEOPLE_MANIFEST)
except Exception:
temp_path.unlink(missing_ok=True)
raise
def available_people():
registry = people_registry()
records = [{"id": "brian", "name": "Brian"}]
records.extend(item for item in registry["people"] if item.get("id") != "brian")
known_ids = {person["id"] for person in records}
for directory in named_reference_dirs():
folder_name = directory.name[:-len(NAMED_REFERENCE_SUFFIX)]
person_id = re.sub(r"[^a-z0-9]+", "-", folder_name.lower()).strip("-")[:40].strip("-")
if person_id and person_id not in known_ids:
name = re.sub(r"[_-]+", " ", folder_name).strip().title()
records.append({"id": person_id, "name": name})
known_ids.add(person_id)
return records
def get_person(person_id):
if not isinstance(person_id, str) or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,39}", person_id):
raise ValueError("Choose a valid person.")
for person in available_people():
if person["id"] == person_id:
return person
raise ValueError("That person does not exist. Refresh the page and try again.")
def create_person(name):
if not isinstance(name, str) or not name.strip():
raise ValueError("Enter a name for the person.")
name = name.strip()
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:40].strip("-")
if not slug:
raise ValueError("Use a name that includes at least one letter or number.")
with REFERENCE_LOCK:
registry = people_registry()
if any(person["id"] == slug for person in available_people()):
raise ValueError("A person with that name already exists.")
person = {"id": slug, "name": name}
registry["people"].append(person)
registry["sources"].setdefault(slug, [])
save_people_registry(registry)
return person
def save_person_sources(person_id, sources):
person = get_person(person_id)
if not isinstance(sources, list):
raise ValueError("Source folders must be a list.")
available = {item["path"] for item, _ in profile_sources()}
if any(not isinstance(source, str) or source not in available for source in sources):
raise ValueError("One or more selected source folders are no longer available.")
registry = people_registry()
registry.setdefault("sources", {})[person["id"]] = list(dict.fromkeys(sources))
save_people_registry(registry)
return registry["sources"][person["id"]]
def person_profile_paths(person, kind):
if kind not in {"standard", "turbo"}:
raise ValueError("Choose a supported voice model.")
if person["id"] == "brian":
stem = "brian" if kind == "standard" else "brian_turbo"
directory = VOICES
else:
stem = kind
directory = VOICES / "people" / person["id"]
active = directory / f"{stem}.pt"
candidate_stem = f"{stem}_candidate"
return {
"active": active,
"candidate": directory / f"{candidate_stem}.pt",
}
def person_reference_dir(person):
if person["id"] == "brian":
return REFERENCE_DIR
folder_name = re.sub(r"[^a-z0-9]+", "_", person["id"].lower()).strip("_")
named_dir = ROOT / f"{folder_name}{NAMED_REFERENCE_SUFFIX}"
if named_dir.is_dir():
return named_dir
return VOICES / "people" / person["id"] / "references"
def person_reference_manifest(person):
return person_reference_dir(person) / "reference_pool.json"
def person_video_dir(person):
return VOICES / "people" / person["id"] / "uploads" / "videos"
def person_dataset_dir(person):
return VOICES / "people" / person["id"] / "datasets" / "prepared"
def person_reference_audio(person):
directory = person_reference_dir(person)
return [
directory / name
for name in [*(f"voice_ref{index}.wav" for index in range(1, 6)),
*(f"ref_{index:02d}.wav" for index in range(1, 7))]
if (directory / name).is_file()
][:5]
def person_media_status(person_id):
person = get_person(person_id)
video_dir = person_video_dir(person)
videos = sorted(
path.name for path in video_dir.iterdir()
if path.is_file() and path.suffix.lower() in
{".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".mts", ".wav", ".mp3", ".m4a"}
) if video_dir.is_dir() else []
references = person_reference_audio(person)
return {
"person": person,
"videos": videos,
"references": [path.name for path in references],
"dataset": rel(person_dataset_dir(person)),
"dataset_ready": (person_dataset_dir(person) / "report.csv").is_file(),
}
def delete_uploaded_voice_sample(person_id, name):
person = get_person(person_id)
if not isinstance(name, str) or not re.fullmatch(r"voice_ref[1-5]\.wav", name):
raise ValueError("Choose one of this person's uploaded voice samples.")
directory = person_reference_dir(person)
target = directory / name
with REFERENCE_LOCK:
if not target.is_file():
raise ValueError("That voice sample is no longer available.")
target.unlink()
manifest_path = person_reference_manifest(person)
if manifest_path.is_file():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
slots = manifest.get("slots", {})
if name in slots:
del slots[name]
save_reference_manifest(manifest, person_id)
return {"deleted": name, "media": person_media_status(person_id)}
def managed_reference_dirs():
return [person_reference_dir(person) for person in available_people()]
def named_reference_dirs():
return sorted(
path for path in ROOT.iterdir()
if path.is_dir() and path.name.lower().endswith(NAMED_REFERENCE_SUFFIX)
)
def clip_dirs():
return list(dict.fromkeys([
ROOT / "refs",
LEGACY_REFERENCE_DIR,
*named_reference_dirs(),
*[person_reference_dir(person) for person in available_people() if person["id"] != "brian"],
*[person_video_dir(person) for person in available_people()],
]))
# ---------------------------------------------------------------- engine (one model, one job at a time)
class Engine:
def __init__(self, kind, device):
self.kind = kind
self.device = device
self.model = None
self.error = None
self.voice_key = None
self.jobs = {}
self.rebuild_jobs = {}
self.dataset_jobs = {}
self.compare_jobs = {}
self.rebuild_submit_lock = threading.Lock()
self.dataset_submit_lock = threading.Lock()
self.queue = queue.Queue()
self.lock = threading.Lock()
threading.Thread(target=self._load, daemon=True).start()
threading.Thread(target=self._worker, daemon=True).start()
# -- model
def _load(self):
try:
self.model = speak.load_model(self.kind, self.device)
except Exception as e:
self.error = f"The voice model didn't load: {e}"
traceback.print_exc()
def status(self):
busy = sum(1 for j in self.jobs.values() if j["state"] in ("queued", "running"))
return {"ready": self.model is not None, "error": self.error, "model": self.kind,
"device": self.device, "jobs_waiting": busy}
# -- voices
def use_voice(self, voice):
"""voice = {"type": "profile"|"clip", "path": ...}; only re-processes when it changes."""
path = safe_path(voice["path"], [VOICES] if voice["type"] == "profile" else clip_dirs())
file_version = None
if voice["type"] == "clip":
stat = path.stat()
file_version = (stat.st_mtime_ns, stat.st_size)
key = (voice["type"], voice["path"], voice.get("exaggeration") if voice["type"] == "clip" else None,
file_version)
if key == self.voice_key:
return
if voice["type"] == "profile":
meta = path.with_suffix(".json")
if meta.exists():
made_for = json.loads(meta.read_text(encoding="utf-8")).get("model", self.kind)
if made_for != self.kind:
raise ValueError(f"{path.name} was built for the {made_for} model, but the studio is "
f"running {self.kind}. Restart with --model {made_for}.")
speak.load_voice_profile(self.model, self.kind, path, self.device)
else:
if sf.info(str(path)).duration < 5.5:
raise ValueError(f"{path.name} is too short to copy a voice from. Use a clip of 7-15 seconds.")
speak.prepare_voice(self.model, self.kind, path, voice.get("exaggeration", 0.5))
self.voice_key = key
# -- jobs
def submit(self, req):
job_id = uuid.uuid4().hex[:10]
voice = req.get("voice") or {}
safe_path(voice.get("path", ""), [VOICES] if voice.get("type") == "profile" else clip_dirs())
text = (req.get("text") or "").strip()
if not text:
raise ValueError("Write a script first.")
if len(text) > 6000:
raise ValueError("That script is very long. Split it into parts of about 6,000 characters.")
takes = max(1, min(int(req.get("takes", 1)), 5))
seed = req.get("seed")
seed = int(seed) if str(seed).strip().lstrip("-").isdigit() else random.randint(0, 2 ** 31 - 1)
chunks = speak.split_text(text, int(req.get("max_chars", 280)))
if not chunks:
raise ValueError("Write a script first.")
job = {
"id": job_id, "state": "queued", "created": time.time(), "text": text,
"voice": req["voice"], "takes_wanted": takes, "seed": seed,
"settings": {
"exaggeration": float(req.get("exaggeration", 0.5)),
"cfg": float(req.get("cfg", 0.5)),
"temperature": float(req.get("temperature", 0.8)),
"pause": float(req.get("pause", 0.25)),
"para_pause": float(req.get("para_pause", 0.7)),
"max_chars": int(req.get("max_chars", 280)),
},
"chunks": chunks, "take": 0, "piece": 0, "pieces": len(chunks) * takes,
"done_pieces": 0, "results": [], "error": None,
}
self.jobs[job_id] = job
self.queue.put(job_id)
return job
def _worker(self):
while True:
job_id = self.queue.get()
job = self.jobs.get(job_id)
if not job:
continue
while self.model is None and self.error is None:
time.sleep(0.5)
if self.error:
job.update(state="error", error=self.error)
continue
job["state"] = "running"
job["started"] = time.time()
try:
with self.lock:
self._run(job)
job["state"] = "done"
except ValueError as e: # expected problems (bad voice file etc.): message only
job.update(state="error", error=str(e))
except Exception as e:
traceback.print_exc()
job.update(state="error", error=f"Generation failed: {e}")
job["finished"] = time.time()
def _run(self, job):
s = job["settings"]
voice = dict(job["voice"], exaggeration=s["exaggeration"])
self.use_voice(voice)
args = SimpleNamespace(exaggeration=s["exaggeration"], cfg=s["cfg"], temperature=s["temperature"])
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
OUTPUT.mkdir(parents=True, exist_ok=True)
for k in range(job["takes_wanted"]):
seed = job["seed"] + k
job["take"] = k + 1
speak.seed_everything(seed)
pieces = []
for i, (chunk, pause_kind) in enumerate(job["chunks"], 1):
job["piece"] = i
wav = speak.generate_piece(self.model, self.kind, chunk, args)
pieces.append((speak.trim(wav, self.model.sr), pause_kind))
job["done_pieces"] += 1
audio = speak.assemble(pieces, self.model.sr, s["pause"], s["para_pause"])
name = f"studio_{stamp}_{job['id']}_take{k + 1}.wav"
path = OUTPUT / name
sf.write(str(path), audio, self.model.sr, subtype="PCM_16")
settings = {
"text": job["text"],
"reference": job["voice"]["path"] if job["voice"]["type"] == "clip" else None,
"voice_profile": job["voice"]["path"] if job["voice"]["type"] == "profile" else None,
"model": self.kind, "seed": seed, **{k2: s[k2] for k2 in
("exaggeration", "cfg", "temperature", "pause", "para_pause", "max_chars")},
"created": datetime.now().isoformat(timespec="seconds"),
"take": k + 1, "of_takes": job["takes_wanted"], "source": "studio",
"approved": False,
"note": "AI-generated voice (Chatterbox, Perth watermark).",
}
path.with_suffix(".json").write_text(json.dumps(settings, indent=2), encoding="utf-8")
job["results"].append(entry_for(path, settings))
def public(self, job):
keys = ("id", "state", "take", "takes_wanted", "piece", "pieces", "done_pieces",
"results", "error", "seed")
out = {k: job.get(k) for k in keys}
out["chunks"] = len(job["chunks"])
if job.get("started"):
out["elapsed"] = round((job.get("finished") or time.time()) - job["started"], 1)
ahead = [j for j in self.jobs.values()
if j["state"] in ("queued", "running") and j["created"] < job["created"]]
out["ahead"] = len(ahead)
return out
# -- voice profile rebuilds
def submit_dataset(self, person_id):
person = get_person(person_id)
media = person_media_status(person["id"])
references = person_reference_audio(person)
if not media["videos"]:
raise ValueError(f"Upload one or more videos for {person['name']} first.")
if not 1 <= len(references) <= 5:
raise ValueError(
f"Upload 1-5 clean 10-30 second WAV voice samples for {person['name']} first."
)
with self.dataset_submit_lock:
if any(job["state"] in ("queued", "running") for job in self.dataset_jobs.values()):
raise ValueError("A video-to-dataset job is already in progress.")
job_id = uuid.uuid4().hex[:10]
job = {
"id": job_id, "person": person["id"], "state": "queued", "progress": 0,
"message": "Waiting to prepare clips…", "error": None, "result": None,
"created": time.time(),
}
self.dataset_jobs[job_id] = job
settings = {
"person": person["id"],
"input": person_video_dir(person),
"references": references,
"output": person_dataset_dir(person),
}
threading.Thread(target=self._dataset_worker, args=(job_id, settings), daemon=True).start()
return self.public_dataset(job)
def _dataset_worker(self, job_id, settings):
job = self.dataset_jobs[job_id]
job.update(state="running", started=time.time(), message="Starting clip preparation…")
try:
import prepare_dataset
def report(message, progress):
job.update(message=message, progress=progress)
while self.model is None and self.error is None:
job.update(message="Waiting for Studio to finish loading before preparing clips…")
time.sleep(0.5)
device = self.device if self.device in {"cpu", "cuda"} else \
("cuda" if prepare_dataset.torch.cuda.is_available() else "cpu")
args = SimpleNamespace(
input=settings["input"], reference=settings["references"], out=settings["output"],
threshold=0.60, sentence_threshold=0.55, min_unit=0.6, sample_rate=44100,
min_len=3.0, max_len=15.0, max_gap=0.6, pad=0.10, min_words=2,
min_dbfs=-40.0, whisper_model="large-v3", language="en", device=device,
keep_temp=False,
)
with self.lock:
reload_voice_model = self.model is not None
if reload_voice_model:
job.update(message="Freeing GPU memory for video processing…")
self.model = None
self.voice_key = None
import gc
gc.collect()
if prepare_dataset.torch.cuda.is_available():
prepare_dataset.torch.cuda.empty_cache()
try:
result = prepare_dataset.run_dataset(args, progress=report)
finally:
if reload_voice_model:
job.update(message="Reloading Studio’s voice model…", progress=99)
gc.collect()
if prepare_dataset.torch.cuda.is_available():
prepare_dataset.torch.cuda.empty_cache()
try:
self.model = speak.load_model(self.kind, self.device)
except Exception as e:
self.error = f"The voice model didn't reload after video preparation: {e}"
raise RuntimeError(self.error) from e
self.voice_key = None
if result["videos_processed"] == 0 and result["failures"]:
failed = "; ".join(
f"{item['video']}: {item['error']}" for item in result["failures"]
)
raise RuntimeError(f"No videos were processed successfully. Failed: {failed}")
registry = people_registry()
sources = registry.setdefault("sources", {}).setdefault(settings["person"], [])
dataset_path = rel(settings["output"])
if dataset_path not in sources:
sources.append(dataset_path)
save_people_registry(registry)
job.update(
state="done", progress=100,
message=f"Prepared {result['clips_kept']} clips from {result['videos_processed']} videos.",
result={**result, "dataset": dataset_path},
)
except (ValueError, RuntimeError) as e:
job.update(state="error", error=str(e), message="Video clip preparation failed.")
except Exception as e:
traceback.print_exc()
job.update(state="error", error=f"Video clip preparation failed: {e}",
message="Video clip preparation failed.")
finally:
job["finished"] = time.time()
@staticmethod
def public_dataset(job):
result = job.get("result")
if result:
result = {key: result.get(key) for key in (
"videos_found", "videos_processed", "videos_skipped", "clips_kept",
"failures", "dataset",
)}
return {
**{key: job.get(key) for key in (
"id", "person", "state", "progress", "message", "error",
)},
"result": result,
}
def submit_rebuild(self, req):
if not isinstance(req, dict):
raise ValueError("Rebuild request must be an object.")
person = get_person(req.get("person", "brian"))
available_sources = {item["path"]: path for item, path in profile_sources()}
requested_sources = req.get("sources")
if not isinstance(requested_sources, list) or not requested_sources:
raise ValueError("Select at least one audio source.")
if any(not isinstance(source, str) or source not in available_sources for source in requested_sources):
raise ValueError("One or more selected audio sources are no longer available. Refresh the page.")
available_anchors = {item["path"]: path for item, path in profile_anchors(requested_sources)}
anchor_key = req.get("anchor")
if not isinstance(anchor_key, str) or anchor_key not in available_anchors:
raise ValueError("Choose an available anchor clip.")
try:
max_clips = int(req.get("max_clips", 400))
min_sim = float(req.get("min_sim", 0.65))
blend = float(req.get("blend", 1.0))
except (TypeError, ValueError) as e:
raise ValueError("Rebuild settings must be valid numbers.") from e
if not 3 <= max_clips <= 4000 or not math.isfinite(min_sim) or not math.isfinite(blend) or \
not 0 <= min_sim <= 1 or not 0 <= blend <= 1:
raise ValueError("Use 3-4000 clips, similarity 0-1, and blend 0-1.")
with self.rebuild_submit_lock:
if any(job["state"] in ("queued", "running") for job in self.rebuild_jobs.values()):
raise ValueError("A voice rebuild is already in progress.")
job_id = uuid.uuid4().hex[:10]
job = {
"id": job_id, "state": "queued", "progress": 0, "message": "Waiting for the voice model…",
"error": None, "result": None, "created": time.time(),
}
self.rebuild_jobs[job_id] = job
settings = {
"person": person["id"],
"sources": [available_sources[source] for source in dict.fromkeys(requested_sources)],
"anchor": available_anchors[anchor_key],
"max_clips": max_clips, "min_sim": min_sim, "blend": blend,
}
threading.Thread(target=self._rebuild_worker, args=(job_id, settings), daemon=True).start()
return self.public_rebuild(job)
def _rebuild_worker(self, job_id, settings):
job = self.rebuild_jobs[job_id]
while self.model is None and self.error is None:
time.sleep(0.5)
if self.error:
job.update(state="error", error=self.error, message="The voice model failed to load.")
return
job.update(state="running", started=time.time(), message="Starting voice profile rebuild…")
try:
from build_voice import build_voice_profile
def report(message, progress):
job.update(message=message, progress=progress)
profile_path = person_profile_paths(get_person(settings["person"]), self.kind)["candidate"]
with self.lock:
result = build_voice_profile(
self.model, self.kind, self.device, settings["sources"], settings["anchor"],
profile_path, max_clips=settings["max_clips"], min_sim=settings["min_sim"],
blend=settings["blend"], progress=report,
)
self.voice_key = None
job.update(state="done", progress=100, message="Voice profile rebuilt.",
result={**result, "path": rel(profile_path)})
except ValueError as e:
job.update(state="error", error=str(e), message="Voice profile rebuild failed.")
except Exception as e:
traceback.print_exc()
job.update(state="error", error=f"Voice profile rebuild failed: {e}",
message="Voice profile rebuild failed.")
finally:
job["finished"] = time.time()
@staticmethod
def public_rebuild(job):
result = job.get("result")
if result:
result = {key: result.get(key) for key in
("path", "clips_used", "clips_rejected_quality", "source_groups",
"anchor_agreement", "blend", "created")}
return {
**{key: job.get(key) for key in ("id", "state", "progress", "message", "error")},
"result": result,
}
def submit_compare(self, req):
if not isinstance(req, dict):
raise ValueError("Comparison request must be an object.")
text = req.get("text")
if not isinstance(text, str) or not text.strip() or len(text) > 500:
raise ValueError("Enter a comparison script of 1-500 characters.")
person = get_person(req.get("person", "brian"))
paths = person_profile_paths(person, self.kind)
current, candidate = paths["active"], paths["candidate"]
if not current.is_file() or not candidate.is_file():
raise ValueError("Build both the current voice profile and a candidate before comparing.")
settings = {
"text": text.strip(),
"exaggeration": self._bounded_setting(req.get("exaggeration", 0.5), 0.25, 1.2, "Energy"),
"cfg": self._bounded_setting(req.get("cfg", 0.5), 0.1, 0.9, "Pace"),
"temperature": self._bounded_setting(req.get("temperature", 0.8), 0.4, 1.1, "Variation"),
"seed": self._bounded_seed(req.get("seed")),
"current": current,
"candidate": candidate,
}
job_id = uuid.uuid4().hex[:10]
job = {"id": job_id, "state": "queued", "progress": 0, "message": "Waiting for voice model…",
"error": None, "result": None}
self.compare_jobs[job_id] = job
threading.Thread(target=self._compare_worker, args=(job_id, settings), daemon=True).start()
return self.public_compare(job)
@staticmethod
def _bounded_setting(value, minimum, maximum, label):
try:
number = float(value)
except (TypeError, ValueError) as e:
raise ValueError(f"{label} must be a number.") from e
if not math.isfinite(number) or not minimum <= number <= maximum:
raise ValueError(f"{label} must be between {minimum} and {maximum}.")
return number
@staticmethod
def _bounded_seed(value):
try:
seed = int(value)
except (TypeError, ValueError) as e:
raise ValueError("Comparison seed must be an integer.") from e
if not 0 <= seed <= 2**31 - 1:
raise ValueError("Comparison seed must be between 0 and 2147483647.")
return seed
def _compare_worker(self, job_id, settings):
job = self.compare_jobs[job_id]
while self.model is None and self.error is None:
time.sleep(0.5)
if self.error:
job.update(state="error", error=self.error, message="The voice model failed to load.")
return
job.update(state="running", message="Generating matched A/B samples…")
compare_dir = OUTPUT / ".compare"
outputs = []
args = SimpleNamespace(
exaggeration=settings["exaggeration"], cfg=settings["cfg"], temperature=settings["temperature"]
)
chunks = speak.split_text(settings["text"], 280)
if not chunks:
job.update(state="error", error="Enter a comparison script.", message="A/B comparison failed.")
return
try:
with self.lock:
previous_conds = self.model.conds
previous_voice_key = self.voice_key
try:
compare_dir.mkdir(parents=True, exist_ok=True)
for index, profile in enumerate((settings["current"], settings["candidate"])):
job.update(progress=index * 50, message=f"Generating {'A' if index == 0 else 'B'} sample…")
self.use_voice({"type": "profile", "path": rel(profile)})
speak.seed_everything(settings["seed"])
pieces = []
for chunk, pause_kind in chunks:
wav = speak.generate_piece(self.model, self.kind, chunk, args)
pieces.append((speak.trim(wav, self.model.sr), pause_kind))
audio = speak.assemble(pieces, self.model.sr, 0.25, 0.7)
label = "current" if index == 0 else "candidate"
path = compare_dir / f"{job_id}_{label}.wav"
sf.write(str(path), audio, self.model.sr, subtype="PCM_16")
outputs.append({"label": label, "file": rel(path)})
finally:
self.model.conds = previous_conds
self.voice_key = previous_voice_key
job.update(state="done", progress=100, message="A/B samples are ready.", result=outputs)
except ValueError as e:
job.update(state="error", error=str(e), message="A/B comparison failed.")
except Exception as e:
traceback.print_exc()
job.update(state="error", error=f"A/B comparison failed: {e}", message="A/B comparison failed.")
@staticmethod
def public_compare(job):
return {key: job.get(key) for key in
("id", "state", "progress", "message", "error", "result")}
def adopt_candidate(self, person_id="brian"):
person = get_person(person_id)
paths = person_profile_paths(person, self.kind)
current, candidate = paths["active"], paths["candidate"]
candidate_meta = candidate.with_suffix(".json")
if not candidate.is_file() or not candidate_meta.is_file():
raise ValueError("Build a candidate profile before using it.")
try:
metadata = json.loads(candidate_meta.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
raise ValueError("Candidate profile metadata is unreadable.") from e
if metadata.get("model") != self.kind:
raise ValueError(f"Candidate profile is not built for the {self.kind} model.")
with self.lock:
current.parent.mkdir(parents=True, exist_ok=True)
temp_profile = current.with_name(current.name + ".tmp")
temp_meta = current.with_suffix(".json.tmp")
try:
import shutil
shutil.copy2(candidate, temp_profile)
shutil.copy2(candidate_meta, temp_meta)
os.replace(temp_profile, current)
os.replace(temp_meta, current.with_suffix(".json"))
finally:
for path in (temp_profile, temp_meta):
try:
path.unlink()
except FileNotFoundError:
pass
self.voice_key = None
return {"path": rel(current), "created": metadata.get("created"), "clips_used": metadata.get("clips_used")}
# ---------------------------------------------------------------- files
def safe_path(rel, allowed_dirs):
"""Resolve a path the browser sent and make sure it stays inside the allowed folders."""
p = (ROOT / unquote(rel)).resolve()
for d in allowed_dirs:
try:
p.relative_to(d.resolve())
if p.is_file():
return p
except ValueError:
continue
raise ValueError("That file isn't available.")
def rel(p):
return p.resolve().relative_to(ROOT.resolve()).as_posix()
def entry_for(wav, meta):
try:
seconds = round(sf.info(str(wav)).duration, 1)
except Exception:
seconds = None
return {
"file": rel(wav), "name": wav.name, "seconds": seconds,
"text": meta.get("text", ""), "seed": meta.get("seed"),
"voice": Path(meta.get("voice_profile") or meta.get("reference") or "").name or None,
"created": meta.get("created"), "take": meta.get("take"), "of_takes": meta.get("of_takes"),
"approved": bool(meta.get("approved")), "approved_at": meta.get("approved_at"),
"settings": {k: meta.get(k) for k in ("exaggeration", "cfg", "temperature", "model")},
}
def list_voices():
profiles = []
people = {person["id"]: person for person in available_people()}
for p in sorted(VOICES.rglob("*.pt")) if VOICES.exists() else []:
meta = {}
if p.with_suffix(".json").exists():
try:
meta = json.loads(p.with_suffix(".json").read_text(encoding="utf-8"))
except Exception:
pass
person_id = p.parent.name if p.parent.parent == VOICES / "people" else "brian"
person = people.get(person_id, {"name": p.stem})
model_name = meta.get("model")
display_name = person["name"] if person_id == "brian" else \
f"{person['name']} ({model_name or p.stem})"
profiles.append({"type": "profile", "path": rel(p), "name": display_name,
"clips": meta.get("clips_used"), "model": meta.get("model"),
"anchor": Path(meta.get("anchor", "")).name or None})
clips = []
managed_dirs = {path.resolve() for path in managed_reference_dirs()}
for d in clip_dirs():
if not d.exists():
continue
for p in sorted(d.glob("*.wav")):
if p.parent == REFERENCE_DIR and not re.fullmatch(r"ref_0[1-6]\.wav", p.name):
continue
if p.parent != REFERENCE_DIR and p.name.startswith("ref_") and not re.fullmatch(r"ref_0[1-6]\.wav", p.name):
continue
try:
dur = sf.info(str(p)).duration
except Exception:
continue
active_reference = (
p.parent.resolve() in managed_dirs and
re.fullmatch(r"(?:ref_0[1-6]|voice_ref[1-6])\.wav", p.name)
)
if active_reference or 5.5 <= dur <= 40:
clips.append({"type": "clip", "path": rel(p), "name": f"{d.name}/{p.stem}",
"seconds": round(dur, 1)})
return {"profiles": profiles, "clips": clips}
def profile_sources():
sources = []
candidates = [
ROOT / "refs", LEGACY_REFERENCE_DIR, *named_reference_dirs(),
*managed_reference_dirs(),
*[person_dataset_dir(person) for person in available_people()],
]
candidates.extend(
path for path in ROOT.iterdir()
if path.is_dir() and (path / "report.csv").is_file() and (path / "wavs").is_dir()
)
seen = set()
for path in candidates:
if not path.is_dir() or path.resolve() in seen:
continue
seen.add(path.resolve())
if (path / "report.csv").is_file() and (path / "wavs").is_dir():
with open(path / "report.csv", newline="", encoding="utf-8") as f:
count = sum(1 for row in csv.DictReader(f) if row.get("status") == "kept")
else:
count = sum(1 for item in path.rglob("*") if item.is_file() and item.suffix.lower() in
{".wav", ".flac", ".mp3", ".ogg", ".m4a"})
if count:
sources.append(({"path": rel(path), "name": path.name, "clips": count}, path))
return sources
def profile_anchors(requested_sources=None):
sources = profile_sources()
if requested_sources is None:
folders = [path for _, path in sources]
else:
available = {item["path"]: path for item, path in sources}
if any(source not in available for source in requested_sources):
raise ValueError("One or more selected source folders are no longer available.")
folders = [available[source] for source in dict.fromkeys(requested_sources)]
anchors, seen = [], set()
for folder in folders:
search_root = folder / "wavs" if (folder / "report.csv").is_file() and (folder / "wavs").is_dir() else folder
for path in search_root.rglob("*"):
if not path.is_file() or path.suffix.lower() not in AUDIO_EXTS or path.resolve() in seen:
continue
seen.add(path.resolve())
try:
duration = sf.info(str(path)).duration
except (OSError, RuntimeError):
continue
if 5.5 <= duration <= 40:
anchors.append(({"path": rel(path), "name": f"{path.parent.name}/{path.name}",
"seconds": round(duration, 1)}, path))
return anchors
def reference_candidates(requested_sources):
available = {item["path"]: path for item, path in profile_sources()}
if not isinstance(requested_sources, list) or not requested_sources:
raise ValueError("Select at least one audio source.")
if any(not isinstance(source, str) or source not in available for source in requested_sources):
raise ValueError("One or more selected audio sources are no longer available. Refresh the page.")
from pick_voice_ref import analyze
candidates, seen = [], set()
for source_key in dict.fromkeys(requested_sources):
folder = available[source_key]
if (folder / "report.csv").is_file() and (folder / "wavs").is_dir():
with open(folder / "report.csv", newline="", encoding="utf-8") as report:
rows = ((row, folder / "wavs" / f"{row.get('clip_id', '')}.wav")
for row in csv.DictReader(report) if row.get("status") == "kept")
for row, path in rows:
try:
similarity = float(row["similarity"])
except (KeyError, TypeError, ValueError):
continue
add_reference_candidate(candidates, seen, path, similarity, row.get("text", ""), analyze)
else:
for path in folder.rglob("*"):
if path.is_file() and path.suffix.lower() in AUDIO_EXTS:
add_reference_candidate(candidates, seen, path, None, "", analyze)
candidates.sort(key=lambda item: (-item["score"], item["path"]))
return candidates
def add_reference_candidate(candidates, seen, path, similarity, text, analyze):
resolved = path.resolve()
if resolved.parent in {directory.resolve() for directory in managed_reference_dirs()} and \
re.fullmatch(r"(?:ref_0[1-6]|voice_ref[1-5])\.wav", resolved.name):
return
try:
resolved.relative_to(REFERENCE_DIR.resolve())
except ValueError:
pass
else:
return
if resolved in seen or not resolved.is_file():
return
seen.add(resolved)
try:
quality = analyze(resolved)
except (OSError, RuntimeError, ValueError):
return
if quality is None or not 7 <= quality["seconds"] <= 16 or quality["clipped"] > 0.0005:
return
match_score = similarity if similarity is not None else 0.5
length_bonus = 1.0 - min(abs(quality["seconds"] - 12.0) / 6.0, 1.0)
candidates.append({
"path": rel(resolved), "name": resolved.name, "seconds": round(quality["seconds"], 1),
"source": resolved.parent.parent.name if resolved.parent.name == "wavs" else resolved.parent.name,
"similarity": round(similarity, 3) if similarity is not None else None,
"score": round(3.0 * match_score + quality["speech_ratio"] + 0.5 * length_bonus, 4),
"text": text,
})
def reference_items(person_id="brian"):
person = get_person(person_id)
reference_dir = person_reference_dir(person)
manifest_path = person_reference_manifest(person)
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest_path.is_file() else {}
slots = manifest.get("slots", {})
items = []
for index in range(1, REFERENCE_COUNT + 1):
standard_name = f"ref_{index:02d}.wav"
legacy_name = f"voice_ref{index}.wav"
filename = standard_name if (reference_dir / standard_name).is_file() else legacy_name
path = reference_dir / filename
if not path.is_file():
continue
entry = slots.get(filename, {})
try:
seconds = round(sf.info(str(path)).duration, 1)
except (OSError, RuntimeError):
seconds = None
items.append({
"slot": filename, "file": rel(path), "name": filename, "seconds": seconds,
"source": entry.get("source"), "similarity": entry.get("similarity"),
"source_clip": Path(entry.get("path", "")).name or None,
"text": entry.get("text", ""),
})
return items
def stage_reference_copy(source, person_id="brian"):
reference_dir = person_reference_dir(get_person(person_id))
reference_dir.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=reference_dir, suffix=".wav", delete=False) as handle:
staged_path = Path(handle.name)
try:
audio, sample_rate = sf.read(str(source), dtype="float32", always_2d=True)
sf.write(str(staged_path), audio, sample_rate, subtype="PCM_16")
except Exception:
staged_path.unlink(missing_ok=True)
raise
return staged_path
def save_reference_manifest(manifest, person_id="brian"):
reference_dir = person_reference_dir(get_person(person_id))
manifest_path = person_reference_manifest(get_person(person_id))
reference_dir.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=reference_dir,
suffix=".tmp", delete=False) as handle:
temp_path = Path(handle.name)
json.dump(manifest, handle, indent=2)
try:
temp_path.replace(manifest_path)
except Exception:
temp_path.unlink(missing_ok=True)
raise
def active_reference_score(path, manifest, person_id="brian"):
known = manifest.get("slots", {})
entry = known.get(path.name, {})
if entry.get("managed_path") == rel(path) or "score" in entry:
return float(entry.get("score", 0))
from pick_voice_ref import analyze
try:
quality = analyze(path)
except (OSError, RuntimeError, ValueError):
return 0
if quality is None:
return 0
length_bonus = 1.0 - min(abs(quality["seconds"] - 12.0) / 6.0, 1.0)
return 1.5 + quality["speech_ratio"] + 0.5 * length_bonus
def start_reference_review(requested_sources, person_id="brian"):
person = get_person(person_id)
candidates = reference_candidates(requested_sources)
selected_sources = list(dict.fromkeys(requested_sources))
with REFERENCE_LOCK:
manifest_path = person_reference_manifest(person)
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) \
if manifest_path.is_file() else {}
manifest["review_sources"] = selected_sources
manifest["review_pool"] = candidates
manifest.setdefault("reviewed", {})
save_reference_manifest(manifest, person_id)
return next_reference_candidate(manifest)
def next_reference_candidate(manifest):
reviewed = manifest.get("reviewed", {})
promoted = set(manifest.get("promoted", []))
active = {entry.get("path") for entry in manifest.get("slots", {}).values()}
remaining = [
candidate for candidate in manifest.get("review_pool", [])
if candidate.get("path") not in reviewed and candidate.get("path") not in promoted and
candidate.get("path") not in active and Path(ROOT / candidate.get("path", "")).is_file()
]
result = {
"top_candidates": remaining[:6],
"remaining": len(remaining),
}
if remaining:
return {**result, "candidate": remaining[0]}
return {**result, "candidate": None}
def mark_dataset_clip_deleted(source_root, audio_path):
report_path = source_root / "report.csv"
if not report_path.is_file():
return
with report_path.open(newline="", encoding="utf-8") as handle:
reader = csv.DictReader(handle)
fieldnames = reader.fieldnames
rows = list(reader)
if not fieldnames or not {"clip_id", "status", "reason"}.issubset(fieldnames):
raise ValueError("The dataset report format is not recognized; the clip was not deleted.")
clip_id = audio_path.stem
matches = [row for row in rows if row.get("clip_id") == clip_id]
if len(matches) != 1:
raise ValueError("The dataset report does not identify this clip uniquely; it was not deleted.")
matches[0]["status"] = "rejected"
matches[0]["reason"] = "deleted_in_studio_review"
with tempfile.NamedTemporaryFile("w", newline="", encoding="utf-8",
dir=source_root, suffix=".tmp", delete=False) as handle:
temp_path = Path(handle.name)
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
try:
temp_path.replace(report_path)
except Exception:
temp_path.unlink(missing_ok=True)
raise
def review_reference(action, candidate_path, requested_sources, person_id="brian"):
person = get_person(person_id)
reference_dir = person_reference_dir(person)
manifest_path = person_reference_manifest(person)
if action not in {"keep", "delete", "promote"}:
raise ValueError("Choose Keep, Delete, or Promote.")
if not isinstance(candidate_path, str):
raise ValueError("No review clip was selected.")
if not isinstance(requested_sources, list) or not requested_sources:
raise ValueError("Select at least one source folder.")
if not manifest_path.is_file():
raise ValueError("Start a reference review before choosing an action.")
with REFERENCE_LOCK:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
sources = manifest.get("review_sources", [])
if list(dict.fromkeys(requested_sources or [])) != sources:
raise ValueError("The selected sources changed. Restart the reference review.")
available = {item["path"]: path for item, path in profile_sources()}
if not sources or any(source not in available for source in sources):
raise ValueError("A selected source folder is no longer available.")
candidate = next((item for item in manifest.get("review_pool", [])
if item.get("path") == candidate_path), None)
if candidate is None:
raise ValueError("That clip is not in the current review queue.")
allowed_dirs = [available[source] for source in sources]
audio_path = safe_path(candidate_path, allowed_dirs)
if candidate_path in manifest.get("reviewed", {}):
raise ValueError("This clip has already been reviewed.")
if action == "delete":
source_root = max(
(folder for folder in allowed_dirs
if audio_path == folder or folder in audio_path.parents),
key=lambda folder: len(folder.parts), default=None,
)
if source_root is None:
raise ValueError("The clip is outside the selected source folders.")
report_path = source_root / "report.csv"
report_backup = report_path.read_bytes() if report_path.is_file() else None
if report_path.is_file():
mark_dataset_clip_deleted(source_root, audio_path)
try:
audio_path.unlink()
except OSError:
if report_backup is not None:
report_path.write_bytes(report_backup)
raise
manifest.setdefault("rejected", []).append(candidate_path)
result_message = "Source clip deleted."
elif action == "keep":
manifest.setdefault("reviewed", {})[candidate_path] = "kept"
result_message = "Clip kept for later; active references unchanged."
else:
slots = manifest.setdefault("slots", {})
active_items = reference_items(person_id)
occupied = {item["slot"] for item in active_items}
if len(occupied) < REFERENCE_COUNT:
slot = next(f"ref_{index:02d}.wav" for index in range(1, REFERENCE_COUNT + 1)
if f"ref_{index:02d}.wav" not in occupied)
else:
weakest = min(active_items, key=lambda item: (
active_reference_score(reference_dir / item["name"], manifest, person_id), item["slot"]))
slot = weakest["slot"]
target = reference_dir / slot
backup = target.read_bytes() if target.is_file() else None
staged_path = stage_reference_copy(audio_path, person_id)
try:
staged_path.replace(target)
slots[slot] = {**candidate, "managed_path": rel(target)}
manifest.setdefault("promoted", []).append(candidate_path)
manifest.setdefault("reviewed", {})[candidate_path] = "promoted"
save_reference_manifest(manifest, person_id)
except Exception:
staged_path.unlink(missing_ok=True)
if backup is not None:
target.write_bytes(backup)
else:
target.unlink(missing_ok=True)
raise
result_message = f"Promoted into {slot}, replacing the weakest active reference."
promoted_slot = slot
if action != "promote":
manifest.setdefault("reviewed", {})[candidate_path] = action
save_reference_manifest(manifest, person_id)
next_item = next_reference_candidate(manifest)
result = {"message": result_message, **next_item, "references": reference_items(person_id)}
if action == "promote":
result["promoted_slot"] = promoted_slot
return result
def history(limit=60, approved_only=False):
items = []
if OUTPUT.exists():
for meta_path in OUTPUT.glob("*.json"):
wav = meta_path.with_suffix(".wav")
if not wav.exists():
continue
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
continue
if approved_only and not meta.get("approved"):
continue
items.append((wav.stat().st_mtime, entry_for(wav, meta)))
items.sort(key=lambda x: -x[0])
return [e for _, e in items[:limit]]
def set_approved(file, approved):
wav = safe_path(file, [OUTPUT])
meta_path = wav.with_suffix(".json")
meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.exists() else {}
meta["approved"] = bool(approved)
meta["approved_at"] = datetime.now().isoformat(timespec="seconds") if approved else None
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
return entry_for(wav, meta)
def discard_take(file):
if not isinstance(file, str) or not file.lower().endswith(".wav"):
raise ValueError("Choose a Studio audio take to discard.")
wav = safe_path(file, [OUTPUT])
meta_path = wav.with_suffix(".json")
if not meta_path.is_file():
raise ValueError("This take has no Studio metadata and cannot be discarded here.")
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
raise ValueError("This take's metadata can't be read, so it was not deleted.") from e
if not isinstance(meta, dict) or (
meta.get("source") != "studio" and
not str(meta.get("note", "")).startswith("AI-generated voice (Chatterbox")
):
raise ValueError("Only Chatterbox audio takes with matching project metadata can be discarded here.")
wav.unlink()
meta_path.unlink()
return {"deleted": True, "file": rel(wav)}
# ---------------------------------------------------------------- web server
class Handler(BaseHTTPRequestHandler):
engine: Engine = None
server_version = "VoiceStudio/1.0"
def log_message(self, fmt, *args): # keep the terminal quiet except for errors
if args and str(args[1]).startswith(("4", "5")) and "favicon" not in str(args[0]):
super().log_message(fmt, *args)
def _json(self, obj, code=200):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _error(self, msg, code=400):
self._json({"error": msg}, code)
def _body(self):
n = int(self.headers.get("Content-Length") or 0)
return json.loads(self.rfile.read(n) or b"{}") if n else {}
def do_GET(self):
url = urlparse(self.path)
path = url.path
try:
if path in ("/", "/index.html", "/mobile", "/mobile/"):
body = HTML.read_bytes()
if path in ("/mobile", "/mobile/"):
body = body.replace(
b'href="/manifest.webmanifest"',
b'href="/mobile-manifest.webmanifest"',
1,
)
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
elif path in APP_ASSETS:
asset, content_type = APP_ASSETS[path]
body = asset.read_bytes()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
elif path == "/api/status":
self._json(self.engine.status())
elif path == "/api/voices":
self._json(list_voices())
elif path == "/api/people":
registry = people_registry()
self._json({"people": available_people(), "sources": registry.get("sources", {})})
elif path == "/api/person/media":
person_id = parse_qs(url.query).get("person", ["brian"])[0]
self._json(person_media_status(person_id))
elif path == "/api/person/reference-audio":
query = parse_qs(url.query)
person_id = query.get("person", ["brian"])[0]
name = query.get("name", [""])[0]
person = get_person(person_id)
sample = next(
(path for path in person_reference_audio(person) if path.name == name and
re.fullmatch(r"voice_ref[1-5]\.wav", name)),
None,
)
if sample is None:
return self._error("That voice sample is not available.", 404)
self._file(sample)
elif path == "/api/rebuild/options":
query = parse_qs(url.query)
person = get_person(query.get("person", ["brian"])[0])
paths = person_profile_paths(person, self.engine.kind)
profile_path, candidate_path = paths["active"], paths["candidate"]
registry = people_registry()
source_items = [item for item, _ in profile_sources()]
selected_sources = registry.get("sources", {}).get(person["id"])
if selected_sources is None and person["id"] == "brian":
selected_sources = [item["path"] for item in source_items]
elif selected_sources is None:
person_sources = [
item["path"] for item in source_items
if Path(item["path"]).resolve() == person_reference_dir(person).resolve()
]
selected_sources = person_sources
meta_path = profile_path.with_suffix(".json")
candidate_meta_path = candidate_path.with_suffix(".json")
profile_meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.is_file() else {}
candidate_meta = json.loads(candidate_meta_path.read_text(encoding="utf-8")) \
if candidate_meta_path.is_file() else {}
profile_anchor = ""
if profile_meta.get("anchor"):
try:
profile_anchor = Path(profile_meta["anchor"]).resolve().relative_to(ROOT.resolve()).as_posix()
except ValueError:
pass
self._json({
"person": person,
"sources": [
{**item, "selected": item["path"] in (selected_sources or [])}
for item in source_items
],
"anchors": [item for item, _ in profile_anchors(selected_sources or [])],
"profile": {"path": rel(profile_path), "exists": profile_path.is_file(),
"created": profile_meta.get("created"), "clips_used": profile_meta.get("clips_used"),
"anchor": profile_anchor},
"candidate": {"path": rel(candidate_path), "exists": candidate_path.is_file(),
"created": candidate_meta.get("created"),
"clips_used": candidate_meta.get("clips_used"),
"anchor_agreement": candidate_meta.get("anchor_agreement")},
})
elif path == "/api/references":
query = parse_qs(url.query)
person_id = query.get("person", ["brian"])[0]
self._json({"items": reference_items(person_id), "count": REFERENCE_COUNT})
elif path == "/api/reference-review/audio":
requested = parse_qs(url.query).get("path", [""])[0]
person_id = parse_qs(url.query).get("person", ["brian"])[0]
manifest_path = person_reference_manifest(get_person(person_id))
if not manifest_path.is_file():
return self._error("Start a reference review before previewing clips.", 404)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
candidate = next((item for item in manifest.get("review_pool", [])
if item.get("path") == requested), None)
sources = manifest.get("review_sources", [])
available = {item["path"]: folder for item, folder in profile_sources()}
if candidate is None or any(source not in available for source in sources):
return self._error("That review clip is not available.", 404)
self._file(safe_path(requested, [available[source] for source in sources]))
elif path.startswith("/api/compare/"):
job = self.engine.compare_jobs.get(path.rsplit("/", 1)[-1])
self._json(self.engine.public_compare(job)) if job else self._error("No such comparison.", 404)
elif path.startswith("/api/rebuild/"):
job = self.engine.rebuild_jobs.get(path.rsplit("/", 1)[-1])
self._json(self.engine.public_rebuild(job)) if job else self._error("No such rebuild job.", 404)
elif path.startswith("/api/person/dataset/"):
job = self.engine.dataset_jobs.get(path.rsplit("/", 1)[-1])
self._json(self.engine.public_dataset(job)) if job else self._error("No such dataset job.", 404)
elif path == "/api/history":
q = parse_qs(url.query)
self._json({"items": history(approved_only=q.get("approved") == ["1"])})
elif path == "/api/anchor-audio":
query = parse_qs(url.query)
requested = query.get("path", [""])[0]
person = get_person(query.get("person", ["brian"])[0])
registry = people_registry()
selected_sources = registry.get("sources", {}).get(person["id"])
if selected_sources is None and person["id"] == "brian":
selected_sources = [item["path"] for item, _ in profile_sources()]
anchors = {item["path"]: audio_path
for item, audio_path in profile_anchors(selected_sources or [])}
anchor_path = anchors.get(requested)
if anchor_path is None:
return self._error("Choose an available anchor clip.", 404)
self._file(anchor_path)
elif path.startswith("/api/jobs/"):
job = self.engine.jobs.get(path.rsplit("/", 1)[-1])
self._json(self.engine.public(job)) if job else self._error("No such job.", 404)
elif path.startswith("/files/"):
self._file(safe_path(path[len("/files/"):], [OUTPUT, *clip_dirs()]))
elif path == "/favicon.ico":
self.send_response(204)
self.end_headers()
else:
self._error("Not found.", 404)
except ValueError as e:
self._error(str(e), 404)
except (BrokenPipeError, ConnectionResetError):
pass
def do_POST(self):
try:
parsed = urlparse(self.path)
if parsed.path == "/api/person/upload":
self._upload_person_file(parsed)
return
data = self._body()
if self.path == "/api/generate":
voice = data.get("voice") or {}
if voice.get("type") not in ("profile", "clip") or not voice.get("path"):
return self._error("Choose a voice first.")
job = self.engine.submit(data)
self._json(self.engine.public(job))
elif self.path == "/api/people":
self._json({"person": create_person(data.get("name")), "people": available_people()}, 201)
elif self.path == "/api/person/reference/delete":
self._json(delete_uploaded_voice_sample(data.get("person", "brian"), data.get("name")))
elif self.path == "/api/person/sources":
person_id = data.get("person", "brian")
self._json({"sources": save_person_sources(person_id, data.get("sources"))})
elif self.path == "/api/person/prepare-dataset":
self._json(self.engine.submit_dataset(data.get("person", "brian")), 202)
elif self.path == "/api/approve":
self._json(set_approved(data.get("file", ""), data.get("approved", True)))
elif self.path == "/api/discard":
self._json(discard_take(data.get("file", "")))
elif self.path == "/api/rebuild":
self._json(self.engine.submit_rebuild(data), 202)
elif self.path == "/api/reference-review/start":
self._json(start_reference_review(data.get("sources"), data.get("person", "brian")))
elif self.path == "/api/reference-review/action":
self._json(review_reference(data.get("action"), data.get("path"), data.get("sources"),
data.get("person", "brian")))
elif self.path == "/api/compare":
self._json(self.engine.submit_compare(data), 202)
elif self.path == "/api/rebuild/adopt":
self._json(self.engine.adopt_candidate(data.get("person", "brian")))
else:
self._error("Not found.", 404)
except ValueError as e:
self._error(str(e))
except Exception as e:
traceback.print_exc()
self._error(f"Something went wrong: {e}", 500)
def _upload_person_file(self, parsed):
query = parse_qs(parsed.query)
person = get_person(query.get("person", [""])[0])
kind = query.get("kind", [""])[0]
if any(
job.get("person") == person["id"] and job["state"] in ("queued", "running")
for job in self.engine.dataset_jobs.values()
):
return self._error("Wait for video clip preparation to finish before uploading more files.")
filename = query.get("name", [""])[0].replace("\\", "/").rsplit("/", 1)[-1]
suffix = Path(filename).suffix.lower()
stem = re.sub(r"[^A-Za-z0-9_-]+", "_", Path(filename).stem).strip("_")
if not stem or kind not in {"video", "reference"}:
return self._error("Choose a valid file and upload type.")
if kind == "video" and suffix not in VIDEO_UPLOAD_EXTS:
return self._error("Upload a supported video or audio file.")
if kind == "reference" and suffix != ".wav":
return self._error("Voice samples must be WAV files.")
try:
length = int(self.headers.get("Content-Length", ""))
except ValueError:
return self._error("The upload size is missing or invalid.")
limit = MAX_VIDEO_UPLOAD_BYTES if kind == "video" else MAX_REFERENCE_UPLOAD_BYTES
if length <= 0:
return self._error("The selected file is empty.")
if length > limit:
return self._error(
f"This file exceeds the {limit // (1024 ** 2)} MB upload limit.", 413
)
directory = person_video_dir(person) if kind == "video" else person_reference_dir(person)
directory.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=directory, suffix=".upload", delete=False) as handle:
temporary = Path(handle.name)
remaining = length
while remaining:
chunk = self.rfile.read(min(1024 * 1024, remaining))
if not chunk:
raise ValueError("The upload ended before the whole file was received.")
handle.write(chunk)
remaining -= len(chunk)
if kind == "reference":
try:
duration = sf.info(str(temporary)).duration
except (OSError, RuntimeError) as e:
raise ValueError("The voice sample is not a readable WAV file.") from e
if not 10 <= duration <= 30:
raise ValueError("Voice samples must be 10-30 seconds long.")
with REFERENCE_LOCK:
occupied = {
path.name for path in directory.iterdir()
if path.is_file() and re.fullmatch(
r"(?:voice_ref[1-5]|ref_0[1-6])\.wav", path.name
)
}
if len(occupied) >= 5:
raise ValueError("This person already has five voice samples.")
slot = next((f"voice_ref{index}.wav" for index in range(1, 6)
if f"voice_ref{index}.wav" not in occupied), None)
if slot is None:
raise ValueError("This person already has five voice samples.")
target = directory / slot
temporary.replace(target)
registry = people_registry()
sources = registry.setdefault("sources", {}).setdefault(person["id"], [])
source = rel(directory)
if source not in sources:
sources.append(source)
save_people_registry(registry)
else:
target = directory / f"{uuid.uuid4().hex[:8]}_{stem}{suffix}"
temporary.replace(target)
temporary = None
self._json({"file": rel(target), "media": person_media_status(person["id"])}, 201)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def _file(self, p):
"""Serve a file with byte-range support, so audio players can seek."""
size = p.stat().st_size
ctype = mimetypes.guess_type(p.name)[0] or "application/octet-stream"
rng = self.headers.get("Range")
start, end = 0, size - 1
if rng and (m := re.match(r"bytes=(\d*)-(\d*)", rng)):
if m.group(1):
start = int(m.group(1))
end = int(m.group(2)) if m.group(2) else end
elif m.group(2):
start = max(0, size - int(m.group(2)))
end = min(end, size - 1)
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
else:
self.send_response(200)
length = end - start + 1
self.send_header("Content-Type", ctype)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(length))
self.send_header("Cache-Control", "no-store")
if parse_qs(urlparse(self.path).query).get("download"):
self.send_header("Content-Disposition", f'attachment; filename="{p.name}"')
self.end_headers()
with open(p, "rb") as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(65536, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def main():
p = argparse.ArgumentParser(description="Local voice cloning and generation studio.")
p.add_argument("--model", choices=["standard", "turbo"], default="standard")
p.add_argument("--device", default="auto")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=7860)
p.add_argument("--no-browser", action="store_true")
args = p.parse_args()
if not HTML.exists():
raise SystemExit(f"studio.html is missing. Put it next to studio.py ({HTML.parent}).")
device = speak.pick_device(args.device)
Handler.engine = Engine(args.model, device)
server = ThreadingHTTPServer((args.host, args.port), Handler)
url = f"http://{'127.0.0.1' if args.host in ('0.0.0.0', '') else args.host}:{args.port}"
print(f"\nVoice Studio running at {url}")
if args.host not in ("127.0.0.1", "localhost"):
print("Others on your network can open it at http://<this computer's IP>:"
f"{args.port}. Anyone who can reach it can generate audio, so only do this on a trusted network.")
print("The voice model is loading in the background. Press Ctrl+C to stop.\n")
if not args.no_browser:
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
if __name__ == "__main__":
main()