#!/usr/bin/env python3 """Add a new audio clip to the face viewer: run v18m inference and register it. /data/miniconda3/bin/python tools/export/add_audio.py \ --audio data/audio_new/my_line.mp3 --sid my_line --emotion joy [--text "대사 텍스트"] [--vad V A D] Steps: (1) transcode to mp3 (44.1 kHz) if needed → data/audio_new/.mp3 (2) scripts/infer_song.py --variant v18m → data/viewer/_pred_v18m_dataset.json + _dataset.mp3 (3) manifest.json entries so the editor's dropdown discovers it (4) turn_bounds.json entry (single turn) Run from the animasync-face-v3 directory. Emotion must be one of the 16 labels (see data/emotion/emotion_labels.json). """ import argparse, json, os, subprocess, sys, shutil from pathlib import Path ROOT = Path(__file__).resolve().parents[2] PY = sys.executable ap = argparse.ArgumentParser() ap.add_argument("--audio", required=True, type=Path) ap.add_argument("--sid", required=True, help="scenario id (letters/digits/_ only) — shows in the dropdown") ap.add_argument("--emotion", required=True) ap.add_argument("--vad", nargs=3, type=float, default=None) ap.add_argument("--text", default="", help="preview text shown in the dropdown") ap.add_argument("--variant", default="v18m") ap.add_argument("--extra", nargs=argparse.REMAINDER, default=[], help="extra args passed to infer_song.py (e.g. --no-blinks)") a = ap.parse_args() sid = "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in a.sid) labels = [l["name"] for l in json.load(open(ROOT / "data/emotion/emotion_labels.json"))["labels"]] if a.emotion not in labels: sys.exit(f"emotion must be one of: {', '.join(labels)}") if not a.audio.is_file(): sys.exit(f"audio not found: {a.audio}") os.chdir(ROOT) # 1) transcode to a real mp3 the browser can stream new_dir = ROOT / "data/audio_new"; new_dir.mkdir(exist_ok=True) mp3 = new_dir / f"{sid}.mp3" if a.audio.resolve() != mp3.resolve(): subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(a.audio), "-ar", "44100", "-ac", "1", "-b:a", "128k", str(mp3)], check=True) dur = float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(mp3)], capture_output=True, text=True).stdout.strip()) # 2) inference cmd = [PY, "scripts/infer_song.py", "--audio", str(mp3), "--sid", sid, "--emotion", a.emotion, "--variant", a.variant] + (["--vad", *map(str, a.vad)] if a.vad else []) + a.extra print("[add_audio] running:", " ".join(cmd)) env = dict(os.environ, PYTHONPATH=str(ROOT)) r = subprocess.run(cmd, env=env) if r.returncode != 0: sys.exit("inference failed") pred = ROOT / "data/viewer" / f"{sid}_pred_{a.variant}_dataset.json" if not pred.is_file(): sys.exit(f"expected output missing: {pred}") # replace the symlinked audio with a real copy (symlinks are fine locally, copies are safer for the server) for name in (f"{sid}_pred_{a.variant}_dataset.mp3", f"{sid}_dataset.mp3"): p = ROOT / "data/viewer" / name if p.is_symlink() or p.exists(): p.unlink() shutil.copy2(mp3, p) # 3) manifest mf = ROOT / "data/viewer/manifest.json" man = json.loads(mf.read_text()) if mf.exists() else {"scenarios": []} man["scenarios"] = [s for s in man["scenarios"] if s.get("base") not in (f"{sid}_pred_{a.variant}_dataset", f"{sid}_dataset")] d = json.load(open(pred)); n_turns = len(d.get("turns", [])) text = a.text or f"[{a.audio.name}]" man["scenarios"].append({"base": f"{sid}_dataset", "scenario_id": f"{sid}_dataset", "variants": [], "n_turns": n_turns, "emotions": [a.emotion], "text_preview": text}) man["scenarios"].append({"base": f"{sid}_pred_{a.variant}_dataset", "scenario_id": f"{sid}_pred_{a.variant}_dataset", "variants": [a.variant], "n_turns": n_turns, "emotions": [a.emotion], "text_preview": text}) mf.write_text(json.dumps(man, ensure_ascii=False, indent=2)) # 4) turn bounds tb = ROOT / "data/viewer/turn_bounds.json" bounds = json.loads(tb.read_text()) if tb.exists() else {} bounds[sid] = {"n_turns": n_turns, "bounds": [], "duration": dur, "method": "single"} tb.write_text(json.dumps(bounds, ensure_ascii=False)) print(f"[add_audio] done → scenario '{sid}' ({a.emotion}, {dur:.1f}s, {d['num_frames']} frames) — reload the editor and pick it from the list")