Was a section tacked onto Pipeline Studio; Steve asked for it as a separate tab instead to keep the existing tabs from getting crowded. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01SN15Vxn4svKuhYxFsVtMJf
951 lines
44 KiB
Python
951 lines
44 KiB
Python
"""
|
|
Pipeline studio: Demucs (strip) -> AuK (edit voice, with preview/undo) ->
|
|
MOSS-TTS (generate speech in the edited voice). Plus a Characters tab for
|
|
browsing the-service-station's cast and creating new characters/emotion
|
|
candidates without SSH + manual YAML editing.
|
|
|
|
Orchestrates across the three existing isolated venvs (demucs/, auk/, tts/)
|
|
via subprocess, rather than merging their conflicting dependencies
|
|
(MOSS-TTS needs transformers 5.0.0, AuK needs 4.57.6) into one environment.
|
|
|
|
Run with: webui/.venv/bin/python webui/app.py
|
|
|
|
2026-09-15 refresh, after building the-service-station's cast surfaced real
|
|
gaps in this UI:
|
|
- Speed editing was a silent no-op (AuK needs --gen_seconds or it just
|
|
preserves the input's duration - see vendor/AuK/docs/COOKBOOK.md 3.2).
|
|
- AuK's Emotion task has a fixed 8-word vocabulary; anything else needs the
|
|
Timbre task's free-form template instead.
|
|
- A chained AuK edit can leak fragments of its own instruction text into
|
|
the spoken content. This went undetected through a full listen-and-
|
|
approve cycle on a real character voice because nothing had ever
|
|
transcribed the *reference* audio, only generated lines. Every AuK edit
|
|
and MOSS generation here now gets transcribed automatically so that's
|
|
visible immediately instead of discovered later by ear.
|
|
"""
|
|
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
import gradio as gr
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
DEMUCS_DIR = REPO_ROOT / "demucs"
|
|
AUK_DIR = REPO_ROOT / "auk"
|
|
TTS_DIR = REPO_ROOT / "tts"
|
|
|
|
DEMUCS_PYTHON = DEMUCS_DIR / ".venv" / "bin" / "python3"
|
|
AUK_INFER = AUK_DIR / ".venv" / "bin" / "auk-infer"
|
|
AUK_CKPT = AUK_DIR / "ckpts" / "AuK" / "auk_base.safetensors"
|
|
AUK_FLASH_CKPT = AUK_DIR / "ckpts" / "AuK-Flash" / "auk_flash.safetensors"
|
|
AUK_QWEN_PATH = AUK_DIR / "ckpts" / "Qwen2.5-Omni-3B"
|
|
TTS_PYTHON = TTS_DIR / ".venv" / "bin" / "python3"
|
|
TTS_GENERATE = TTS_DIR / "generate.py"
|
|
TTS_GENERATE_SFX = TTS_DIR / "generate_sfx.py"
|
|
|
|
MODEL_BASE = "Base (full quality)"
|
|
MODEL_FLASH = "Flash (fast draft, 4-step)"
|
|
|
|
# The-service-station checkout on this same box - where character cards
|
|
# live. Override with the env var if running this UI somewhere else.
|
|
SERVICE_STATION_DIR = Path(os.environ.get("SERVICE_STATION_REPO", "/root/projects/the-service-station"))
|
|
SS_VOICES_DIR = SERVICE_STATION_DIR / "voices"
|
|
|
|
WORKDIR = Path(tempfile.mkdtemp(prefix="audio-demo-webui-"))
|
|
|
|
# AuK's Emotion task has exactly this vocabulary (vendor/AuK/docs/COOKBOOK.md
|
|
# 4.1) - anything else needs the Timbre task's free-form template instead
|
|
# (see auk_emotion_instruction below). Kept as the dropdown's options since
|
|
# these are the only words guaranteed to hit the dedicated task.
|
|
EMOTIONS = ["happy", "angry", "sad", "fearful", "surprised", "disgusted", "calm", "excited"]
|
|
PITCH_OPTIONS = ["-3", "-2", "-1", "+1", "+2", "+3"]
|
|
SPEED_OPTIONS = ["0.5", "0.75", "1.25", "1.5", "2.0"]
|
|
VOLUME_OPTIONS = ["+5", "+10", "+15", "-5", "-10", "-15"]
|
|
LANGUAGES = [
|
|
"English", "Spanish", "French", "German", "Portuguese", "Italian",
|
|
"Chinese", "Japanese", "Korean", "Russian",
|
|
]
|
|
|
|
COMFYUI_BASE = "http://localhost:8188"
|
|
|
|
|
|
def auk_emotion_instruction(word: str) -> str:
|
|
"""Same logic as the-service-station/workflows/generate_episode_lines.py's
|
|
auk_emotion_instruction() - duplicated rather than shared because this
|
|
repo and the-service-station use separate venvs/dependency sets, but
|
|
kept in sync deliberately. AuK's Emotion task only understands the 8
|
|
words in EMOTIONS; anything else goes through Timbre's free-form
|
|
description instead, which has no fixed vocabulary."""
|
|
if word in EMOTIONS:
|
|
return f"Change the emotion to {word}."
|
|
return f'Keep the spoken content unchanged and change the timbre to: "the same voice, sounding {word}".'
|
|
|
|
|
|
def _comfyui_queue_empty() -> bool:
|
|
try:
|
|
import urllib.error
|
|
import urllib.request
|
|
with urllib.request.urlopen(f"{COMFYUI_BASE}/queue", timeout=3) as resp:
|
|
data = json.loads(resp.read())
|
|
return not data.get("queue_running") and not data.get("queue_pending")
|
|
except Exception:
|
|
return True # ComfyUI not reachable -> nothing to worry about
|
|
|
|
|
|
def _free_comfyui_memory() -> None:
|
|
import urllib.error
|
|
import urllib.request
|
|
if not _comfyui_queue_empty():
|
|
return
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"{COMFYUI_BASE}/free",
|
|
data=json.dumps({"unload_models": True, "free_memory": True}).encode(),
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
urllib.request.urlopen(req, timeout=5)
|
|
except (urllib.error.URLError, OSError):
|
|
pass
|
|
|
|
|
|
def _run(cmd: list[str], cwd: Path, env: dict | None = None) -> None:
|
|
result = subprocess.run(
|
|
cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=1800
|
|
)
|
|
if result.returncode != 0:
|
|
tail = "\n".join(result.stderr.strip().splitlines()[-25:])
|
|
raise gr.Error(f"Command failed ({cmd[0]}):\n{tail}")
|
|
|
|
|
|
def _ensure_wav(path: str) -> str:
|
|
"""AuK's loader (librosa/soundfile) can't read compressed containers like
|
|
.m4a/.mp3 - only demucs's loader handles those. Normalize once, here, at
|
|
the point a reference is set, so everything downstream is always wav."""
|
|
if Path(path).suffix.lower() == ".wav":
|
|
return path
|
|
out_path = WORKDIR / f"converted_{int(time.time() * 1000)}.wav"
|
|
result = subprocess.run(
|
|
["ffmpeg", "-nostdin", "-y", "-i", path, "-ar", "44100", "-ac", "1", str(out_path)],
|
|
capture_output=True, text=True, timeout=120,
|
|
)
|
|
if result.returncode != 0:
|
|
tail = "\n".join(result.stderr.strip().splitlines()[-15:])
|
|
raise gr.Error(f"Could not convert '{path}' to wav:\n{tail}")
|
|
return str(out_path)
|
|
|
|
|
|
def _duration(path: str) -> float:
|
|
r = subprocess.run(
|
|
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
"-of", "default=noprint_wrappers=1:nokey=1", path],
|
|
capture_output=True, text=True,
|
|
)
|
|
return float(r.stdout.strip())
|
|
|
|
|
|
def _ld_library_path_for_venv(venv_dir: Path) -> str:
|
|
matches = list(venv_dir.glob(".venv/lib/python*/site-packages"))
|
|
if not matches:
|
|
return ""
|
|
site_packages = matches[0]
|
|
nvidia_dir = site_packages / "nvidia"
|
|
if not nvidia_dir.is_dir():
|
|
return ""
|
|
lib_dirs = sorted(p for p in nvidia_dir.glob("*/lib") if p.is_dir())
|
|
return ":".join(str(p) for p in lib_dirs)
|
|
|
|
|
|
TTS_LD_LIBRARY_PATH = _ld_library_path_for_venv(TTS_DIR)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Whisper content verification - added 2026-09-15 after a chained AuK edit
|
|
# leaked fragments of its own instruction text into a character's spoken
|
|
# reference, undetected through a full listen-and-approve round because
|
|
# nobody had transcribed the reference itself. Loaded once, lazily, since
|
|
# most sessions of this UI won't need it immediately.
|
|
# ---------------------------------------------------------------------------
|
|
_whisper_model = None
|
|
|
|
|
|
def _whisper_transcribe(path: str) -> str:
|
|
global _whisper_model
|
|
try:
|
|
if _whisper_model is None:
|
|
from faster_whisper import WhisperModel
|
|
# "small" mis-heard casual contractions as formal phrasing on
|
|
# short/quiet clips (e.g. "it's gonna" -> "it is going to be") -
|
|
# "medium" is noticeably more faithful to actual speech patterns
|
|
# at the cost of a few more seconds per transcription on CPU,
|
|
# worth it since this check exists specifically to catch content
|
|
# problems - a transcript that itself distorts casual speech
|
|
# defeats that purpose.
|
|
_whisper_model = WhisperModel("medium", device="cpu", compute_type="int8")
|
|
segments, _ = _whisper_model.transcribe(path, language="en", beam_size=5)
|
|
return " ".join(s.text.strip() for s in segments)
|
|
except Exception as e:
|
|
return f"(transcription failed: {e})"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 1: input / demucs stripping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def strip_vocals(input_audio: str):
|
|
if not input_audio:
|
|
raise gr.Error("Upload or record some audio first.")
|
|
_free_comfyui_memory()
|
|
out_dir = WORKDIR / f"demucs_{int(time.time())}"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
_run(
|
|
[
|
|
str(DEMUCS_PYTHON), "-m", "demucs",
|
|
"--two-stems=vocals", "-n", "htdemucs",
|
|
"-o", str(out_dir), input_audio,
|
|
],
|
|
cwd=DEMUCS_DIR,
|
|
)
|
|
stem_name = Path(input_audio).stem
|
|
vocals = out_dir / "htdemucs" / stem_name / "vocals.wav"
|
|
no_vocals = out_dir / "htdemucs" / stem_name / "no_vocals.wav"
|
|
return str(vocals), str(no_vocals)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 2: AuK voice editing, with history for undo/reset
|
|
#
|
|
# history_state is a list of {"path": str, "instruction": str|None} dicts,
|
|
# one entry per actual AuK call (not per button click - a click can queue
|
|
# several edits, e.g. enhance+emotion, and each becomes its own history
|
|
# entry so "Save as character" can derive a sensibly-scoped
|
|
# generation_recipe and Undo is granular).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def use_as_reference(audio_path: str, history: list):
|
|
if not audio_path:
|
|
raise gr.Error("No audio to use yet.")
|
|
audio_path = _ensure_wav(audio_path)
|
|
history = [{"path": audio_path, "instruction": None}]
|
|
transcript = _whisper_transcribe(audio_path)
|
|
return audio_path, history, _history_label(history), transcript
|
|
|
|
|
|
def _history_label(history: list) -> str:
|
|
n = len(history) - 1
|
|
if n <= 0:
|
|
return "No edits applied yet." if history else "No reference set yet."
|
|
steps = "\n".join(f"{i}. {h['instruction']}" for i, h in enumerate(history[1:], 1))
|
|
return f"{n} edit(s) applied on top of the original reference:\n{steps}"
|
|
|
|
|
|
ENHANCE_INSTRUCTIONS = {
|
|
"none": None,
|
|
"General (denoise + dereverb)": (
|
|
"Preserve all speakers, remove noise and reverberation, and output "
|
|
"clean speech of the same length."
|
|
),
|
|
"Telephone/call recording": (
|
|
"Repair the telephone effect and restore natural, clear speech."
|
|
),
|
|
}
|
|
|
|
|
|
def _auk_model_args(model_choice: str) -> list[str]:
|
|
"""AuK-Flash is the same architecture distilled to 4 fixed diffusion
|
|
steps with CFG=0 (vendor README) - same --qwen_path, different --ckpt
|
|
plus --nfe/--cfg. VRAM footprint is essentially identical to Base
|
|
(measured in the README), so no extra offload concerns switching."""
|
|
if model_choice == MODEL_FLASH:
|
|
if not AUK_FLASH_CKPT.exists():
|
|
raise gr.Error(
|
|
f"AuK-Flash isn't downloaded yet ({AUK_FLASH_CKPT} not found) - "
|
|
f"run `hf download tencent/AuK-Flash --local-dir ./ckpts/AuK-Flash` "
|
|
f"from the auk/ directory first, or switch back to Base."
|
|
)
|
|
return ["--ckpt", str(AUK_FLASH_CKPT), "--qwen_path", str(AUK_QWEN_PATH), "--nfe", "4", "--cfg", "0"]
|
|
return ["--ckpt", str(AUK_CKPT), "--qwen_path", str(AUK_QWEN_PATH)]
|
|
|
|
|
|
def apply_edit(
|
|
current_ref: str,
|
|
history: list,
|
|
model_choice: str,
|
|
enhance: str,
|
|
timbre_description: str,
|
|
emotion: str,
|
|
other_emotion: str,
|
|
pitch: str,
|
|
speed: str,
|
|
deaccent: bool,
|
|
volume: str,
|
|
whisper_direction: str,
|
|
nonverbal_instruction: str,
|
|
nonverbal_gen_seconds: float,
|
|
custom_instruction: str,
|
|
):
|
|
if not current_ref:
|
|
raise gr.Error("Set a voice reference first (upload, strip with Demucs, or use a previous edit).")
|
|
|
|
instructions = []
|
|
# Enhancement goes first when picked - clean the recording up before any
|
|
# character/accent editing, not after (garbage in, garbage timbre-edited).
|
|
if enhance and ENHANCE_INSTRUCTIONS.get(enhance):
|
|
instructions.append(ENHANCE_INSTRUCTIONS[enhance])
|
|
if timbre_description and timbre_description.strip():
|
|
# This is AuK's Timbre task - the one actually used to build every
|
|
# character's base voice in this project (accent, age, register,
|
|
# personality all described in one free-form sentence). It has no
|
|
# fixed vocabulary, unlike Emotion below.
|
|
instructions.append(
|
|
f'Keep the spoken content unchanged and change the timbre to: "{timbre_description.strip()}".'
|
|
)
|
|
if emotion and emotion != "none":
|
|
instructions.append(auk_emotion_instruction(emotion))
|
|
if other_emotion and other_emotion.strip():
|
|
# Free-text emotion word, not one of AuK's 8 - always goes through
|
|
# the Timbre task's free-form template (see auk_emotion_instruction).
|
|
instructions.append(auk_emotion_instruction(other_emotion.strip()))
|
|
if pitch and pitch != "none":
|
|
direction = "Raise" if pitch.startswith("+") else "Lower"
|
|
instructions.append(f"{direction} the pitch by {pitch.lstrip('+-')} semitone(s).")
|
|
if speed and speed != "none":
|
|
speed_gen_seconds = _duration(current_ref) / float(speed)
|
|
instructions.append((f"Adjust the speech speed to {speed}x.", speed_gen_seconds))
|
|
if deaccent:
|
|
instructions.append("Remove the regional accent while preserving the speaker's voice and content.")
|
|
if volume and volume != "none":
|
|
direction = "Increase" if volume.startswith("+") else "Decrease"
|
|
instructions.append(f"{direction} the volume by {volume.lstrip('+-')} dB.")
|
|
if whisper_direction and whisper_direction != "none":
|
|
if whisper_direction == "To whisper":
|
|
instructions.append("Convert this speech into a soft whisper while preserving the speaker and content.")
|
|
else:
|
|
instructions.append("Convert this whispered speech into a normal speaking voice while preserving the speaker and content.")
|
|
if nonverbal_instruction and nonverbal_instruction.strip():
|
|
# Add/remove changes duration unpredictably (vendor cookbook 4.4 sets
|
|
# --gen_seconds for both directions, not just "add") - no way to
|
|
# auto-estimate this one like Speed, the user has to know roughly
|
|
# what they want. Defaults to the current reference's own duration
|
|
# if left blank.
|
|
target_seconds = float(nonverbal_gen_seconds) if nonverbal_gen_seconds else _duration(current_ref)
|
|
instructions.append((nonverbal_instruction.strip(), target_seconds))
|
|
if custom_instruction and custom_instruction.strip():
|
|
instructions.append(custom_instruction.strip())
|
|
|
|
if not instructions:
|
|
raise gr.Error("Pick at least one edit.")
|
|
|
|
_free_comfyui_memory()
|
|
ref = current_ref
|
|
out_dir = WORKDIR / f"auk_{int(time.time())}"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
last_transcript = ""
|
|
for i, instruction in enumerate(instructions):
|
|
gen_seconds = None
|
|
if isinstance(instruction, tuple):
|
|
instruction, gen_seconds = instruction
|
|
out_path = out_dir / f"edit_{i}.wav"
|
|
cmd = (
|
|
[str(AUK_INFER)] + _auk_model_args(model_choice) +
|
|
["--audio", ref, "--instruction", instruction, "--output", str(out_path), "--cpu_offload"]
|
|
)
|
|
if gen_seconds is not None:
|
|
cmd += ["--gen_seconds", str(round(gen_seconds, 2))]
|
|
_run(cmd, cwd=AUK_DIR)
|
|
ref = str(out_path)
|
|
last_transcript = _whisper_transcribe(ref)
|
|
history = history + [{"path": ref, "instruction": instruction}]
|
|
|
|
return ref, history, _history_label(history), last_transcript
|
|
|
|
|
|
def undo_edit(history: list):
|
|
if len(history) <= 1:
|
|
raise gr.Error("Nothing to undo.")
|
|
history = history[:-1]
|
|
transcript = _whisper_transcribe(history[-1]["path"])
|
|
return history[-1]["path"], history, _history_label(history), transcript
|
|
|
|
|
|
def reset_to_original(history: list):
|
|
if not history:
|
|
raise gr.Error("No reference set yet.")
|
|
history = [history[0]]
|
|
transcript = _whisper_transcribe(history[0]["path"])
|
|
return history[0]["path"], history, _history_label(history), transcript
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 3: MOSS-TTS generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def generate_speech(reference: str, text: str, language: str):
|
|
if not reference:
|
|
raise gr.Error("Set a voice reference first (upload, strip, and/or edit it above).")
|
|
if not text or not text.strip():
|
|
raise gr.Error("Enter some text to synthesize.")
|
|
|
|
_free_comfyui_memory()
|
|
out_path = WORKDIR / f"tts_{int(time.time())}.wav"
|
|
env = None
|
|
if TTS_LD_LIBRARY_PATH:
|
|
env = os.environ.copy()
|
|
env["LD_LIBRARY_PATH"] = TTS_LD_LIBRARY_PATH
|
|
|
|
_run(
|
|
[
|
|
str(TTS_PYTHON), str(TTS_GENERATE),
|
|
"--text", text,
|
|
"--reference", reference,
|
|
"--language", language,
|
|
"--output", str(out_path),
|
|
],
|
|
cwd=TTS_DIR,
|
|
env=env,
|
|
)
|
|
transcript = _whisper_transcribe(str(out_path))
|
|
return str(out_path), transcript
|
|
|
|
|
|
def generate_sound_effect(description: str):
|
|
"""MOSS-SoundEffect - text-only ambience/SFX generation, no reference
|
|
audio or character voice involved (different generation primitive from
|
|
the AuK/MOSS-TTS voice-cloning flow above, so it's a standalone section
|
|
rather than wired into that pipeline's history/reference state).
|
|
No whisper verification here - there's no speech content to check."""
|
|
if not description or not description.strip():
|
|
raise gr.Error("Enter a description of the sound effect/ambience.")
|
|
|
|
_free_comfyui_memory()
|
|
out_path = WORKDIR / f"sfx_{int(time.time())}.wav"
|
|
env = None
|
|
if TTS_LD_LIBRARY_PATH:
|
|
env = os.environ.copy()
|
|
env["LD_LIBRARY_PATH"] = TTS_LD_LIBRARY_PATH
|
|
|
|
_run(
|
|
[str(TTS_PYTHON), str(TTS_GENERATE_SFX), "--text", description.strip(), "--output", str(out_path)],
|
|
cwd=TTS_DIR,
|
|
env=env,
|
|
)
|
|
return str(out_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Characters (the-service-station cast)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _slugify(text: str) -> str:
|
|
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
|
|
|
|
def read_character_cards() -> dict:
|
|
"""name -> parsed card dict (frontmatter + prose), read-only. Never
|
|
round-trips these through yaml.dump - only used for display and for
|
|
resolving a base reference_audio, never for rewriting an existing file
|
|
(that would silently strip hand-authored inline comments)."""
|
|
cards = {}
|
|
if not SS_VOICES_DIR.is_dir():
|
|
return cards
|
|
for card_path in sorted(SS_VOICES_DIR.glob("*/CHARACTER.md")):
|
|
try:
|
|
text = card_path.read_text(encoding="utf-8")
|
|
parts = text.split("---", 2)
|
|
if len(parts) < 3:
|
|
continue
|
|
frontmatter = yaml.safe_load(parts[1]) or {}
|
|
frontmatter["_prose"] = parts[2].strip()
|
|
frontmatter["_dir"] = card_path.parent
|
|
cards[frontmatter.get("name", card_path.parent.name)] = frontmatter
|
|
except Exception as e:
|
|
cards[card_path.parent.name] = {"_error": str(e), "_dir": card_path.parent}
|
|
return cards
|
|
|
|
|
|
def character_names() -> list[str]:
|
|
return sorted(read_character_cards().keys())
|
|
|
|
|
|
def load_character(name: str):
|
|
if not name:
|
|
return "Pick a character.", None
|
|
cards = read_character_cards()
|
|
card = cards.get(name)
|
|
if not card:
|
|
return f"No card found for '{name}'.", None
|
|
if "_error" in card:
|
|
return f"Could not parse {name}'s card: {card['_error']}", None
|
|
|
|
ref_audio = card.get("reference_audio")
|
|
ref_path = str(SERVICE_STATION_DIR / ref_audio) if ref_audio else None
|
|
|
|
lib = card.get("emotion_library") or {}
|
|
lib_lines = [f"- **{emo}**: {entry.get('status', '?')}" for emo, entry in lib.items()] or ["(none approved yet)"]
|
|
|
|
cache_dir = card["_dir"] / "emotion-cache"
|
|
cached = sorted(p.stem for p in cache_dir.glob("*.wav")) if cache_dir.is_dir() else []
|
|
cache_lines = [f"- {e} (cached, not yet approved)" for e in cached] or ["(none pending)"]
|
|
|
|
info = (
|
|
f"**{name}** — {card.get('role', '?')}\n\n"
|
|
f"Status: `{card.get('status', '?')}` | Engine: `{card.get('engine', '?')}` | "
|
|
f"Default emotion (documentation only): `{card.get('default_emotion', '?')}`\n\n"
|
|
f"### Approved emotion_library\n" + "\n".join(lib_lines) + "\n\n"
|
|
f"### Pending (emotion-cache, not approved)\n" + "\n".join(cache_lines) + "\n\n"
|
|
f"---\n{card.get('_prose', '')}"
|
|
)
|
|
return info, ref_path
|
|
|
|
|
|
def list_character_voices(name: str) -> dict[str, str]:
|
|
"""label -> reference audio path, for every voice available for a
|
|
character: the base voice, every approved emotion_library entry, and
|
|
every pending (not yet approved) emotion-cache candidate. Used both by
|
|
the clone-as-character tool below and could back a similar picker
|
|
anywhere else a character's variants need listing."""
|
|
cards = read_character_cards()
|
|
card = cards.get(name)
|
|
if not card or "_error" in card:
|
|
return {}
|
|
voices = {}
|
|
ref_audio = card.get("reference_audio")
|
|
if ref_audio:
|
|
voices["Base (natural voice)"] = str(SERVICE_STATION_DIR / ref_audio)
|
|
for emo, entry in (card.get("emotion_library") or {}).items():
|
|
ref = entry.get("reference_audio")
|
|
if ref:
|
|
voices[f"{emo} (approved)"] = str(SERVICE_STATION_DIR / ref)
|
|
cache_dir = card["_dir"] / "emotion-cache"
|
|
if cache_dir.is_dir():
|
|
for p in sorted(cache_dir.glob("*.wav")):
|
|
voices[f"{p.stem} (cached, not approved)"] = str(p)
|
|
return voices
|
|
|
|
|
|
def refresh_voice_choices(name: str):
|
|
voices = list_character_voices(name)
|
|
labels = list(voices.keys())
|
|
return gr.update(choices=labels, value=labels[0] if labels else None)
|
|
|
|
|
|
def clone_as_character(character_name: str, voice_label: str, text: str, language: str):
|
|
if not character_name:
|
|
raise gr.Error("Pick a character first.")
|
|
if not voice_label:
|
|
raise gr.Error("Pick a voice (base or one of its emotions).")
|
|
if not text or not text.strip():
|
|
raise gr.Error("Enter some text for them to say.")
|
|
|
|
voices = list_character_voices(character_name)
|
|
reference = voices.get(voice_label)
|
|
if not reference:
|
|
raise gr.Error(f"Could not resolve '{voice_label}' for {character_name} - try Refresh.")
|
|
|
|
_free_comfyui_memory()
|
|
env = None
|
|
if TTS_LD_LIBRARY_PATH:
|
|
env = os.environ.copy()
|
|
env["LD_LIBRARY_PATH"] = TTS_LD_LIBRARY_PATH
|
|
out_path = WORKDIR / f"clone_{int(time.time())}.wav"
|
|
_run(
|
|
[str(TTS_PYTHON), str(TTS_GENERATE), "--text", text.strip(),
|
|
"--reference", reference, "--language", language, "--output", str(out_path)],
|
|
cwd=TTS_DIR, env=env,
|
|
)
|
|
transcript = _whisper_transcribe(str(out_path))
|
|
return str(out_path), transcript
|
|
|
|
|
|
def create_character(
|
|
name: str, role: str, prose: str, language: str,
|
|
current_ref: str, history: list,
|
|
):
|
|
if not name or not name.strip():
|
|
raise gr.Error("Give the character a name.")
|
|
if not current_ref or not history:
|
|
raise gr.Error("Set up a reference and edit it above first - there's nothing to save yet.")
|
|
|
|
slug = _slugify(name)
|
|
char_dir = SS_VOICES_DIR / slug / "webui-created"
|
|
if (SS_VOICES_DIR / slug / "CHARACTER.md").exists():
|
|
raise gr.Error(f"voices/{slug}/CHARACTER.md already exists - this UI won't overwrite an existing character.")
|
|
char_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
source_dest = char_dir / f"{slug}_source.wav"
|
|
ref_dest = char_dir / f"{slug}_ref_v1.wav"
|
|
subprocess.run(["cp", history[0]["path"], str(source_dest)], check=True)
|
|
subprocess.run(["cp", current_ref, str(ref_dest)], check=True)
|
|
|
|
recipe = [
|
|
{"tool": "auk", "instruction": h["instruction"]}
|
|
for h in history[1:] if h["instruction"]
|
|
]
|
|
|
|
frontmatter = {
|
|
"name": name.strip(),
|
|
"role": role.strip() or "Standing guest",
|
|
"engine": "moss-tts",
|
|
"model": "OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5",
|
|
"reference_audio": str(ref_dest.relative_to(SERVICE_STATION_DIR)),
|
|
"source_clip": str(source_dest.relative_to(SERVICE_STATION_DIR)),
|
|
"source_clip_note": "Created via the webui pipeline studio - see generation_recipe for the exact edit chain.",
|
|
"generation_recipe": recipe,
|
|
"seed": None,
|
|
"emotion_library": {},
|
|
"language": language,
|
|
"status": "draft",
|
|
"last_updated": date.today().isoformat(),
|
|
"provenance": "webui",
|
|
"appearances": [],
|
|
}
|
|
frontmatter_yaml = yaml.dump(frontmatter, sort_keys=False, allow_unicode=True)
|
|
body = f"---\n{frontmatter_yaml}---\n\n# {name.strip()}\n\n{prose.strip()}\n"
|
|
(SS_VOICES_DIR / slug / "CHARACTER.md").write_text(body, encoding="utf-8")
|
|
|
|
return (
|
|
f"Created voices/{slug}/CHARACTER.md with status: draft. "
|
|
f"Not committed - review, listen, and commit it yourself (or ask an agent to) "
|
|
f"once you're happy, same as every other voice in this project."
|
|
)
|
|
|
|
|
|
def generate_emotion_candidate(character_name: str, emotion_word: str, model_choice: str):
|
|
if not character_name:
|
|
raise gr.Error("Pick a character first.")
|
|
if not emotion_word or not emotion_word.strip():
|
|
raise gr.Error("Enter an emotion word.")
|
|
emotion_word = emotion_word.strip()
|
|
|
|
cards = read_character_cards()
|
|
card = cards.get(character_name)
|
|
if not card or "_error" in card:
|
|
raise gr.Error(f"Could not load {character_name}'s card.")
|
|
|
|
base_reference = str(SERVICE_STATION_DIR / card["reference_audio"])
|
|
cache_dir = card["_dir"] / "emotion-cache"
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
cache_path = cache_dir / f"{_slugify(emotion_word)}.wav"
|
|
|
|
_free_comfyui_memory()
|
|
instruction = auk_emotion_instruction(emotion_word)
|
|
cmd = (
|
|
[str(AUK_INFER)] + _auk_model_args(model_choice) +
|
|
["--audio", base_reference, "--instruction", instruction,
|
|
"--output", str(cache_path), "--cpu_offload"]
|
|
)
|
|
_run(cmd, cwd=AUK_DIR)
|
|
|
|
ref_transcript = _whisper_transcribe(str(cache_path))
|
|
|
|
# Preview clone so the candidate can actually be judged, not just the
|
|
# edited reference in isolation.
|
|
env = None
|
|
if TTS_LD_LIBRARY_PATH:
|
|
env = os.environ.copy()
|
|
env["LD_LIBRARY_PATH"] = TTS_LD_LIBRARY_PATH
|
|
preview_text = f"Testing the {emotion_word} emotion for {character_name}."
|
|
preview_path = WORKDIR / f"emotion_preview_{int(time.time())}.wav"
|
|
_run(
|
|
[str(TTS_PYTHON), str(TTS_GENERATE), "--text", preview_text,
|
|
"--reference", str(cache_path), "--language", "English", "--output", str(preview_path)],
|
|
cwd=TTS_DIR, env=env,
|
|
)
|
|
preview_transcript = _whisper_transcribe(str(preview_path))
|
|
|
|
model_note = (
|
|
" Generated with **Flash** - if this direction is right, regenerate the same "
|
|
"word with **Base** before promoting it, since Flash trades fidelity for speed "
|
|
"and this is a candidate you might lock in permanently."
|
|
if model_choice == MODEL_FLASH else ""
|
|
)
|
|
status = (
|
|
f"Saved to `{cache_path.relative_to(SERVICE_STATION_DIR)}` (cached, **not approved**).{model_note}\n\n"
|
|
f"Reference transcript: {ref_transcript}\n\n"
|
|
f"Preview clone transcript: {preview_transcript}\n\n"
|
|
f"If this sounds right, promote it manually per `.claude/skills/produce-episode/"
|
|
f"SKILL.md`'s \"Adding an emotion to a character's library\" - this UI won't do "
|
|
f"that step for you."
|
|
)
|
|
return str(cache_path), str(preview_path), status
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# UI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
with gr.Blocks(title="Audio-demo pipeline studio") as demo:
|
|
with gr.Tabs():
|
|
with gr.Tab("Pipeline Studio"):
|
|
gr.Markdown(
|
|
"# Pipeline studio\n"
|
|
"Strip a voice with **Demucs**, edit it with **AuK**, then generate new "
|
|
"speech in that voice with **MOSS-TTS**. Each stage shells out to its own "
|
|
"isolated venv, so this box's GPU is the real constraint — heavy edits "
|
|
"(long clips) can OOM AuK; keep reference clips short (under ~25s)."
|
|
)
|
|
|
|
reference_state = gr.State(None)
|
|
history_state = gr.State([])
|
|
|
|
with gr.Row():
|
|
with gr.Column():
|
|
gr.Markdown("## 1. Input")
|
|
input_audio = gr.Audio(label="Upload or record", type="filepath")
|
|
strip_btn = gr.Button("Strip vocals with Demucs")
|
|
with gr.Row():
|
|
vocals_out = gr.Audio(label="Vocals (isolated)", type="filepath")
|
|
no_vocals_out = gr.Audio(label="Background (no vocals)", type="filepath")
|
|
with gr.Row():
|
|
use_vocals_btn = gr.Button("Use vocals as reference")
|
|
use_no_vocals_btn = gr.Button("Use background as reference")
|
|
use_original_btn = gr.Button("Use uploaded audio as-is")
|
|
|
|
with gr.Column():
|
|
gr.Markdown("## 2. Voice editor (AuK)")
|
|
current_ref_audio = gr.Audio(label="Current reference", type="filepath", interactive=False)
|
|
ref_transcript_box = gr.Textbox(
|
|
label="Whisper transcript of current reference (editable - fix any mis-hearing before using it)",
|
|
info="Whisper can mishear casual speech as more formal phrasing on short/quiet clips (e.g. \"it's gonna\" -> \"it is going to be\") - correct it here before sending it anywhere.",
|
|
)
|
|
use_ref_transcript_btn = gr.Button("Use this transcript as 'Text to speak' →")
|
|
history_label = gr.Markdown("No reference set yet.")
|
|
model_dd = gr.Radio(
|
|
[MODEL_BASE, MODEL_FLASH], value=MODEL_BASE, label="AuK model",
|
|
info="Draft with Flash (fast, 4-step, lower fidelity), confirm with Base before committing a voice or emotion you're actually going to keep.",
|
|
)
|
|
enhance_dd = gr.Dropdown(
|
|
list(ENHANCE_INSTRUCTIONS.keys()), value="none",
|
|
label="Enhance (denoise/dereverb/restore) - runs first, before character edits",
|
|
)
|
|
timbre_tb = gr.Textbox(
|
|
label="Timbre / voice description (accent, age, register, personality)",
|
|
placeholder=(
|
|
'e.g. "British male, mid-30s, dry and understated, slight Essex '
|
|
'accent, deadpan delivery" - this is what actually built every '
|
|
"character's base voice in this project. Free-form, no fixed "
|
|
"vocabulary (unlike Emotion below)."
|
|
),
|
|
lines=2,
|
|
)
|
|
emotion_dd = gr.Dropdown(
|
|
["none"] + EMOTIONS, value="none",
|
|
label="Emotion (AuK's fixed vocabulary - anything else, use the field below)",
|
|
)
|
|
other_emotion_tb = gr.Textbox(
|
|
label="Other emotion word (not in AuK's list above)",
|
|
placeholder='e.g. "bemused" - a single word for a momentary reaction, wrapped '
|
|
"as a Timbre instruction automatically (different from the "
|
|
"voice-description field above, which is for the character's "
|
|
"lasting accent/age/personality)",
|
|
)
|
|
pitch_dd = gr.Dropdown(["none"] + PITCH_OPTIONS, value="none", label="Pitch (semitones)")
|
|
speed_dd = gr.Dropdown(["none"] + SPEED_OPTIONS, value="none", label="Speed")
|
|
gr.Markdown(
|
|
"*Pitch editing has been unreliable in testing — even \"1 semitone\" "
|
|
"has overshot to ~4+ semitones on some voices. Prefer a free-form "
|
|
"custom instruction like \"a little lower in register\" over the Pitch "
|
|
"dropdown for character work.*\n\n"
|
|
"*Speed now sets AuK's required `--gen_seconds` automatically. On "
|
|
"references longer than ~15s this can stall (a 27s target hung this "
|
|
"box for 20+ minutes swapping to disk with 0% GPU use, while a 12s "
|
|
"source completed in seconds) - trim your reference shorter first if "
|
|
"using Speed.*"
|
|
)
|
|
with gr.Accordion("More AuK tasks (de-accent, volume, whisper, nonverbal)", open=False):
|
|
deaccent_cb = gr.Checkbox(label="De-accent (remove regional accent, keep voice)")
|
|
volume_dd = gr.Dropdown(
|
|
["none"] + VOLUME_OPTIONS, value="none", label="Volume (dB)",
|
|
)
|
|
whisper_dd = gr.Dropdown(
|
|
["none", "To whisper", "From whisper"], value="none",
|
|
label="Whisper conversion",
|
|
)
|
|
nonverbal_tb = gr.Textbox(
|
|
label="Nonverbal editing (advanced)",
|
|
placeholder='e.g. "Add a cough before \'We tested\'" or "Remove all laughs from the audio."',
|
|
)
|
|
nonverbal_seconds_nb = gr.Number(
|
|
label="Target duration for the nonverbal edit above (seconds)",
|
|
value=None,
|
|
info="AuK needs an explicit target length for add/remove nonverbal edits - it can't be auto-computed like Speed. Defaults to the current reference's own duration; increase it a little for \"add\", decrease it a little for \"remove\".",
|
|
)
|
|
custom_instruction = gr.Textbox(
|
|
label="Custom instruction (advanced, optional)",
|
|
placeholder='e.g. "Lower the pitch very slightly."',
|
|
)
|
|
apply_btn = gr.Button("Apply edit(s)", variant="primary")
|
|
with gr.Row():
|
|
undo_btn = gr.Button("Undo last edit")
|
|
reset_btn = gr.Button("Reset to original")
|
|
|
|
with gr.Column():
|
|
gr.Markdown("## 3. Generate (MOSS-TTS)")
|
|
gen_text = gr.Textbox(label="Text to speak", lines=6)
|
|
gen_language = gr.Dropdown(LANGUAGES, value="English", label="Language")
|
|
generate_btn = gr.Button("Generate speech", variant="primary")
|
|
generated_audio = gr.Audio(label="Generated speech", type="filepath")
|
|
gen_transcript_box = gr.Textbox(label="Whisper transcript of generated speech")
|
|
|
|
gr.Markdown("## 4. Save as a the-service-station character")
|
|
new_char_name = gr.Textbox(label="Name")
|
|
new_char_role = gr.Textbox(label="Role", placeholder="e.g. Standing guest - weather")
|
|
new_char_prose = gr.Textbox(label="Character brief (prose)", lines=4)
|
|
save_char_btn = gr.Button("Save as new character (status: draft)")
|
|
save_char_status = gr.Markdown()
|
|
|
|
strip_btn.click(strip_vocals, inputs=[input_audio], outputs=[vocals_out, no_vocals_out])
|
|
use_vocals_btn.click(
|
|
use_as_reference, inputs=[vocals_out, history_state],
|
|
outputs=[current_ref_audio, history_state, history_label, ref_transcript_box],
|
|
)
|
|
use_no_vocals_btn.click(
|
|
use_as_reference, inputs=[no_vocals_out, history_state],
|
|
outputs=[current_ref_audio, history_state, history_label, ref_transcript_box],
|
|
)
|
|
use_original_btn.click(
|
|
use_as_reference, inputs=[input_audio, history_state],
|
|
outputs=[current_ref_audio, history_state, history_label, ref_transcript_box],
|
|
)
|
|
apply_btn.click(
|
|
apply_edit,
|
|
inputs=[current_ref_audio, history_state, model_dd, enhance_dd, timbre_tb, emotion_dd,
|
|
other_emotion_tb, pitch_dd, speed_dd, deaccent_cb, volume_dd, whisper_dd,
|
|
nonverbal_tb, nonverbal_seconds_nb, custom_instruction],
|
|
outputs=[current_ref_audio, history_state, history_label, ref_transcript_box],
|
|
)
|
|
undo_btn.click(
|
|
undo_edit, inputs=[history_state],
|
|
outputs=[current_ref_audio, history_state, history_label, ref_transcript_box],
|
|
)
|
|
reset_btn.click(
|
|
reset_to_original, inputs=[history_state],
|
|
outputs=[current_ref_audio, history_state, history_label, ref_transcript_box],
|
|
)
|
|
generate_btn.click(
|
|
generate_speech, inputs=[current_ref_audio, gen_text, gen_language],
|
|
outputs=[generated_audio, gen_transcript_box],
|
|
)
|
|
use_ref_transcript_btn.click(lambda t: t, inputs=[ref_transcript_box], outputs=[gen_text])
|
|
save_char_btn.click(
|
|
create_character,
|
|
inputs=[new_char_name, new_char_role, new_char_prose, gen_language, current_ref_audio, history_state],
|
|
outputs=[save_char_status],
|
|
)
|
|
with gr.Tab("Sound Effects"):
|
|
gr.Markdown(
|
|
"# Sound effects (MOSS-SoundEffect)\n"
|
|
"Text-only ambience/SFX generation - a separate model from the "
|
|
"voice pipeline in Pipeline Studio, no reference audio or "
|
|
"character voice involved."
|
|
)
|
|
sfx_description = gr.Textbox(
|
|
label="Description",
|
|
placeholder=(
|
|
'e.g. "A door creaking open slowly, then closing with a soft thud." '
|
|
'or "A busy outdoor market with distant chatter and footsteps."'
|
|
),
|
|
lines=2,
|
|
)
|
|
sfx_btn = gr.Button("Generate sound effect", variant="primary")
|
|
sfx_audio = gr.Audio(label="Generated sound effect", type="filepath")
|
|
sfx_btn.click(generate_sound_effect, inputs=[sfx_description], outputs=[sfx_audio])
|
|
|
|
with gr.Tab("Characters"):
|
|
gr.Markdown(
|
|
f"# The Service Station cast\n"
|
|
f"Reading from `{SERVICE_STATION_DIR}`. Approving a character or an emotion "
|
|
f"is a manual step (editing the card) - this tab creates drafts and "
|
|
f"candidates for you to listen to and promote yourself, it never marks "
|
|
f"anything `approved` on its own."
|
|
)
|
|
with gr.Row():
|
|
char_dd = gr.Dropdown(label="Character", choices=character_names())
|
|
refresh_btn = gr.Button("Refresh")
|
|
char_info = gr.Markdown()
|
|
char_audio = gr.Audio(label="Reference audio", type="filepath", interactive=False)
|
|
|
|
gr.Markdown(
|
|
"## Clone text as this character\n"
|
|
"Say anything in the selected character's voice - the base voice, an "
|
|
"approved emotion, or a pending (not yet approved) candidate from the "
|
|
"cache. Useful both for \"how would they say this?\" and for testing an "
|
|
"emotion candidate with real dialogue instead of the fixed test line below."
|
|
)
|
|
clone_voice_dd = gr.Dropdown(label="Voice", choices=list(list_character_voices(None).keys()))
|
|
clone_text_tb = gr.Textbox(label="Text to speak", lines=3, placeholder="Type anything.")
|
|
clone_language_dd = gr.Dropdown(LANGUAGES, value="English", label="Language")
|
|
clone_btn = gr.Button("Clone", variant="primary")
|
|
clone_audio = gr.Audio(label="Cloned speech", type="filepath", interactive=False)
|
|
clone_transcript_box = gr.Textbox(label="Whisper transcript")
|
|
|
|
gr.Markdown("## Generate a new emotion candidate")
|
|
with gr.Row():
|
|
emotion_char_dd = gr.Dropdown(label="Character", choices=character_names())
|
|
emotion_word_tb = gr.Textbox(label="Emotion word", placeholder="e.g. surprised, bemused, wistful")
|
|
emotion_model_dd = gr.Radio(
|
|
[MODEL_BASE, MODEL_FLASH], value=MODEL_FLASH, label="AuK model",
|
|
info="Flash defaults here since this is exploring candidates - regenerate the same word with Base before promoting one.",
|
|
)
|
|
gen_emotion_btn = gr.Button("Generate candidate", variant="primary")
|
|
emotion_ref_audio = gr.Audio(label="Edited reference (candidate)", type="filepath", interactive=False)
|
|
emotion_preview_audio = gr.Audio(label="Preview clone", type="filepath", interactive=False)
|
|
emotion_status = gr.Markdown()
|
|
|
|
refresh_btn.click(
|
|
lambda: (gr.update(choices=character_names()), gr.update(choices=character_names())),
|
|
outputs=[char_dd, emotion_char_dd],
|
|
)
|
|
char_dd.change(load_character, inputs=[char_dd], outputs=[char_info, char_audio])
|
|
char_dd.change(refresh_voice_choices, inputs=[char_dd], outputs=[clone_voice_dd])
|
|
clone_btn.click(
|
|
clone_as_character,
|
|
inputs=[char_dd, clone_voice_dd, clone_text_tb, clone_language_dd],
|
|
outputs=[clone_audio, clone_transcript_box],
|
|
)
|
|
gen_emotion_btn.click(
|
|
generate_emotion_candidate,
|
|
inputs=[emotion_char_dd, emotion_word_tb, emotion_model_dd],
|
|
outputs=[emotion_ref_audio, emotion_preview_audio, emotion_status],
|
|
).then(
|
|
# So a freshly-generated candidate is immediately pickable in
|
|
# "Clone text as this character" without reselecting the
|
|
# character to force a refresh. Keyed off char_dd (the
|
|
# browser above), not emotion_char_dd - the clone tool
|
|
# always uses whichever character char_dd is showing, and
|
|
# those two dropdowns can legitimately point at different
|
|
# characters at once.
|
|
refresh_voice_choices, inputs=[char_dd], outputs=[clone_voice_dd],
|
|
)
|
|
# Dropdown choices are otherwise fixed at server-start time, so a
|
|
# character created earlier in the same running session (or by
|
|
# someone else) wouldn't show up without this - refresh
|
|
# automatically on every page load, in addition to the manual
|
|
# Refresh button above for mid-session use without reloading.
|
|
demo.load(
|
|
lambda: (gr.update(choices=character_names()), gr.update(choices=character_names())),
|
|
outputs=[char_dd, emotion_char_dd],
|
|
)
|
|
|
|
if __name__ == "__main__":
|
|
# Gradio refuses to serve a file back to the browser unless it's under
|
|
# the app's own directory, the system temp dir, or explicitly allowed -
|
|
# every character's reference audio lives in the-service-station repo,
|
|
# a different path entirely, so every single Characters-tab selection
|
|
# errored without this (found via a real user report + the server log,
|
|
# not by inspection - the error only surfaces server-side, not in the
|
|
# browser's generic error toast).
|
|
demo.launch(server_name="0.0.0.0", server_port=7860, allowed_paths=[str(SERVICE_STATION_DIR)])
|