Spaces:
Paused
Paused
Download prepare_dataset.py from DillonLD/VoiceStudio: direct link, hf CLI and curl.
- Browser
- Download file 17.8 kB
-
https://huggingface.co/spaces/DillonLD/VoiceStudio/resolve/main/prepare_dataset.py
- Command line
-
hf download hf://spaces/DillonLD/VoiceStudio/prepare_dataset.py
-
curl -L -o prepare_dataset.py https://huggingface.co/spaces/DillonLD/VoiceStudio/resolve/main/prepare_dataset.py
17.8 kB
| #!/usr/bin/env python3 | |
| """ | |
| prepare_dataset.py | |
| Build a clean, single-speaker voice dataset from a folder of video files. | |
| Pipeline (per video): | |
| 1. ffmpeg -> extract the audio track | |
| 2. Demucs -> strip music / background noise, keep vocals only | |
| 3. Whisper -> transcribe with word timestamps (faster-whisper), cut into sentences | |
| 4. Speaker ID -> check every sentence against the reference voice (ECAPA embeddings) | |
| 5. Chunking -> merge consecutive target-speaker sentences into 3-15 s clips, | |
| then re-check each finished clip | |
| 6. Quality -> reject clipped, too-quiet, or near-empty clips | |
| 7. Export -> wavs/ + metadata.csv (LJSpeech style) + report.csv (every candidate, kept or not) | |
| Resumable: videos already processed are skipped on re-run. | |
| """ | |
| import os | |
| os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1") # quiet a harmless Windows warning | |
| os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS", "1") | |
| import argparse | |
| import csv | |
| import hashlib | |
| import json | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| import numpy as np | |
| import soundfile as sf | |
| import torch | |
| import torchaudio | |
| from tqdm import tqdm | |
| VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".mts", ".wav", ".mp3", ".m4a"} | |
| EMBED_SR = 16000 # speaker model + whisper both work at 16 kHz | |
| # ---------------------------------------------------------------- utilities | |
| def run(cmd): | |
| """Run a command; raise with its stderr if it fails.""" | |
| result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"Command failed: {' '.join(map(str, cmd))}\n{result.stderr[-2000:]}") | |
| def check_ffmpeg(): | |
| if shutil.which("ffmpeg") is None: | |
| raise ValueError("ffmpeg not found. Install it first (see README).") | |
| def decode_audio(src, dst, sr, channels): | |
| run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(src), "-vn", | |
| "-ac", str(channels), "-ar", str(sr), "-c:a", "pcm_s16le", str(dst)]) | |
| def load_mono(path): | |
| data, sr = sf.read(str(path), dtype="float32", always_2d=True) | |
| return data.mean(axis=1), sr | |
| def resample(x, sr_in, sr_out): | |
| if sr_in == sr_out: | |
| return x | |
| return torchaudio.functional.resample(torch.from_numpy(x), sr_in, sr_out).numpy() | |
| def video_id(path): | |
| slug = re.sub(r"[^A-Za-z0-9]+", "_", path.stem).strip("_")[:40] or "video" | |
| digest = hashlib.md5(str(path.resolve()).encode()).hexdigest()[:6] | |
| return f"{slug}_{digest}" | |
| def dbfs(x): | |
| rms = np.sqrt(np.mean(np.square(x)) + 1e-12) | |
| return 20 * np.log10(rms + 1e-12) | |
| # ---------------------------------------------------------------- vocal separation | |
| def separate_vocals(wav_path, workdir, device): | |
| run([sys.executable, "-m", "demucs", "--two-stems", "vocals", "-n", "htdemucs", | |
| "-d", device, "-o", str(workdir), str(wav_path)]) | |
| vocals = workdir / "htdemucs" / wav_path.stem / "vocals.wav" | |
| if not vocals.exists(): | |
| raise RuntimeError(f"Demucs did not produce {vocals}") | |
| return vocals | |
| # ---------------------------------------------------------------- speaker filter | |
| class SpeakerFilter: | |
| """Scores how closely a clip matches the reference speaker (cosine similarity, -1..1).""" | |
| def __init__(self, reference_paths, device, tmpdir): | |
| from importlib import import_module | |
| try: | |
| EncoderClassifier = import_module("speechbrain.inference.speaker").EncoderClassifier | |
| except ImportError: # speechbrain < 1.0 | |
| EncoderClassifier = import_module("speechbrain.pretrained").EncoderClassifier | |
| self.device = device | |
| kwargs = dict( | |
| source="speechbrain/spkrec-ecapa-voxceleb", | |
| savedir=str(Path.home() / ".cache" / "voiceprep" / "ecapa"), | |
| run_opts={"device": device}, | |
| ) | |
| # Windows blocks symlinks for normal users, so copy the model files instead. | |
| try: | |
| from speechbrain.utils.fetching import LocalStrategy | |
| kwargs["local_strategy"] = LocalStrategy.COPY | |
| except ImportError: # older speechbrain without LocalStrategy | |
| pass | |
| self.model = EncoderClassifier.from_hparams(**kwargs) | |
| embs = [] | |
| for i, ref in enumerate(reference_paths): | |
| tmp = Path(tmpdir) / f"ref_{i}.wav" | |
| decode_audio(ref, tmp, EMBED_SR, 1) | |
| x, _ = load_mono(tmp) | |
| embs.append(self.embed(x)) | |
| ref = torch.stack(embs).mean(dim=0) | |
| self.ref = ref / ref.norm() | |
| def embed(self, x16): | |
| wav = torch.from_numpy(x16).float().unsqueeze(0).to(self.device) | |
| emb = self.model.encode_batch(wav).squeeze().cpu() | |
| return emb / emb.norm() | |
| def score(self, x16): | |
| return float(torch.dot(self.embed(x16), self.ref)) | |
| # ---------------------------------------------------------------- chunking | |
| class Unit: | |
| """A sentence-sized piece of speech with timing, text, and a speaker verdict.""" | |
| def __init__(self, start, end, text): | |
| self.start, self.end, self.text = start, end, text | |
| self.ok = True | |
| self.sim = None | |
| def split_sentences(segments, pause=0.5): | |
| """Re-cut Whisper segments into sentence-sized units using word timestamps. | |
| A new unit starts after sentence-ending punctuation or a pause, so a quick | |
| back-and-forth between two people ends up in separate units.""" | |
| units = [] | |
| for seg in segments: | |
| words = [w for w in (seg.words or []) if w.word.strip()] | |
| if not words: | |
| if seg.text.strip(): | |
| units.append(Unit(seg.start, seg.end, seg.text.strip())) | |
| continue | |
| cur = None | |
| for w in words: | |
| if cur and (w.start - cur.end > pause): | |
| units.append(cur) | |
| cur = None | |
| if cur is None: | |
| cur = Unit(w.start, w.end, w.word.strip()) | |
| else: | |
| cur.end = w.end | |
| cur.text += w.word if w.word.startswith(" ") else " " + w.word | |
| if w.word.strip()[-1:] in ".?!": | |
| units.append(cur) | |
| cur = None | |
| if cur: | |
| units.append(cur) | |
| for u in units: | |
| u.text = re.sub(r"\s+", " ", u.text).strip() | |
| return units | |
| def build_chunks(segments, min_len, max_len, max_gap): | |
| """Merge consecutive speech units into clips between min_len and max_len seconds. | |
| Units are never split, so text stays aligned with audio. A unit marked as | |
| another speaker (ok=False) always ends the current clip, so voices never mix.""" | |
| chunks, cur = [], None | |
| for seg in segments: | |
| text = seg.text.strip() | |
| if not text: | |
| continue | |
| if not getattr(seg, "ok", True): # someone else talking: never merge across it | |
| if cur: | |
| chunks.append(cur) | |
| cur = None | |
| continue | |
| if seg.end - seg.start > max_len: # a single overlong segment can't be aligned safely | |
| if cur: | |
| chunks.append(cur) | |
| cur = None | |
| continue | |
| if cur and (seg.start - cur["end"] <= max_gap) and (seg.end - cur["start"] <= max_len): | |
| cur["end"] = seg.end | |
| cur["text"] += " " + text | |
| else: | |
| if cur: | |
| chunks.append(cur) | |
| cur = {"start": seg.start, "end": seg.end, "text": text} | |
| if cur: | |
| chunks.append(cur) | |
| return [c for c in chunks if c["end"] - c["start"] >= min_len] | |
| # ---------------------------------------------------------------- main per-video work | |
| def process_video(path, args, whisper, spk, writers, tmp_root, report=None): | |
| vid = video_id(path) | |
| work = Path(tempfile.mkdtemp(prefix=vid + "_", dir=tmp_root)) | |
| say = report or (lambda msg: tqdm.write(f" [{path.name}] {msg}")) | |
| try: | |
| say("extracting audio...") | |
| full = work / f"{vid}.wav" | |
| decode_audio(path, full, 44100, 2) | |
| say("removing music and background noise (slowest step)...") | |
| vocals_path = separate_vocals(full, work, args.device) | |
| vocals, sr = load_mono(vocals_path) | |
| vocals16 = resample(vocals, sr, EMBED_SR) | |
| out_audio = resample(vocals, sr, args.sample_rate) | |
| total = len(vocals16) / EMBED_SR | |
| say("transcribing...") | |
| segments, _ = whisper.transcribe( | |
| vocals16, language=args.language, vad_filter=True, beam_size=5, | |
| condition_on_previous_text=False, word_timestamps=True, | |
| ) | |
| units = split_sentences(list(segments)) | |
| # Check the speaker on every sentence BEFORE merging, so mixed clips can't form. | |
| say(f"checking who is speaking in {len(units)} sentences...") | |
| other = 0 | |
| for j, u in enumerate(units): | |
| dur = u.end - u.start | |
| if dur < args.min_unit: # too short to judge reliably ("Yep.") -> treat as a break | |
| u.ok = False | |
| continue | |
| s, e = u.start, u.end | |
| if dur < 1.5: # give the voice model at least 1.5 s of context | |
| mid = (s + e) / 2 | |
| s, e = max(0.0, mid - 0.75), min(total, mid + 0.75) | |
| u.sim = spk.score(vocals16[int(s * EMBED_SR):int(e * EMBED_SR)]) | |
| u.ok = u.sim >= args.sentence_threshold | |
| if not u.ok: | |
| other += 1 | |
| writers["report"].writerow([f"{vid}_s{j:04d}", path.name, f"{u.start:.2f}", | |
| f"{u.end:.2f}", f"{dur:.2f}", f"{u.sim:.3f}", "", | |
| "rejected", "other_speaker_sentence", u.text]) | |
| say(f"{other} sentences from other speakers removed, building clips...") | |
| chunks = build_chunks(units, args.min_len, args.max_len, args.max_gap) | |
| kept = 0 | |
| pad = args.pad | |
| for i, c in enumerate(chunks): | |
| start = max(0.0, c["start"] - pad) | |
| end = min(len(vocals16) / EMBED_SR, c["end"] + pad) | |
| text = re.sub(r"\s+", " ", c["text"]).strip() | |
| clip_id = f"{vid}_{i:04d}" | |
| seg16 = vocals16[int(start * EMBED_SR):int(end * EMBED_SR)] | |
| seg_out = out_audio[int(start * args.sample_rate):int(end * args.sample_rate)] | |
| sim = spk.score(seg16) | |
| level = dbfs(seg_out) | |
| clipped = float(np.mean(np.abs(seg_out) >= 0.999)) | |
| reason = "" | |
| if len(text.split()) < args.min_words: | |
| reason = "too_few_words" | |
| elif sim < args.threshold: | |
| reason = "other_speaker" | |
| elif level < args.min_dbfs: | |
| reason = "too_quiet" | |
| elif clipped > 0.001: | |
| reason = "clipped" | |
| status = "rejected" if reason else "kept" | |
| if not reason: | |
| peak = np.max(np.abs(seg_out)) + 1e-9 | |
| seg_out = seg_out * (0.89 / peak) # peak-normalize to about -1 dBFS | |
| sf.write(str(args.out / "wavs" / f"{clip_id}.wav"), seg_out, args.sample_rate, | |
| subtype="PCM_16") | |
| writers["meta"].writerow([clip_id, text, text]) | |
| kept += 1 | |
| writers["report"].writerow([clip_id, path.name, f"{start:.2f}", f"{end:.2f}", | |
| f"{end - start:.2f}", f"{sim:.3f}", f"{level:.1f}", | |
| status, reason, text]) | |
| return kept, len(chunks) | |
| finally: | |
| if args.keep_temp: | |
| print(f" temp files kept in {work}") | |
| else: | |
| shutil.rmtree(work, ignore_errors=True) | |
| def open_csv(path, header, delimiter=","): | |
| new = not path.exists() | |
| f = open(path, "a", newline="", encoding="utf-8") | |
| w = csv.writer(f, delimiter=delimiter) | |
| if new and header: | |
| w.writerow(header) | |
| return f, w | |
| def run_dataset(args, progress=None): | |
| report_progress = progress or (lambda message, value: None) | |
| check_ffmpeg() | |
| videos = sorted(f for f in args.input.rglob("*") if f.suffix.lower() in VIDEO_EXTS) | |
| if not videos: | |
| raise ValueError(f"No video/audio files found in {args.input}") | |
| (args.out / "wavs").mkdir(parents=True, exist_ok=True) | |
| progress_file = args.out / "progress.json" | |
| done = set(json.loads(progress_file.read_text())) if progress_file.exists() else set() | |
| todo = [v for v in videos if str(v.resolve()) not in done] | |
| print(f"{len(videos)} files found, {len(todo)} left to process, device={args.device}") | |
| if args.device == "cpu": | |
| print("Warning: no GPU detected. This will be very slow on a large archive.") | |
| if not todo: | |
| result = { | |
| "videos_found": len(videos), "videos_processed": 0, | |
| "videos_skipped": len(videos), "clips_kept": 0, | |
| "failures": [], "dataset": str(args.out.resolve()), | |
| } | |
| report_progress("All uploaded videos have already been processed.", 100) | |
| return result | |
| tmp_root = Path(tempfile.mkdtemp(prefix="voiceprep_")) | |
| meta_f = rep_f = None | |
| try: | |
| report_progress("Loading transcription and speaker-recognition models…", 0) | |
| from faster_whisper import WhisperModel | |
| whisper = WhisperModel(args.whisper_model, device=args.device, | |
| compute_type="float16" if args.device == "cuda" else "int8") | |
| spk = SpeakerFilter(args.reference, args.device, tmp_root) | |
| meta_f, meta_w = open_csv(args.out / "metadata.csv", None, delimiter="|") | |
| rep_f, rep_w = open_csv( | |
| args.out / "report.csv", | |
| ["clip_id", "source", "start", "end", "duration", "similarity", | |
| "dbfs", "status", "reason", "text"], | |
| ) | |
| writers = {"meta": meta_w, "report": rep_w} | |
| total_kept = 0 | |
| failures = [] | |
| for index, v in enumerate(todo, 1): | |
| base_progress = (index - 1) / max(len(todo), 1) * 100 | |
| def report_video(message, current=index, current_path=v, base=base_progress): | |
| report_progress( | |
| f"{current_path.name}: {message}", | |
| min(99, round(base + 5 / max(len(todo), 1))), | |
| ) | |
| report_progress(f"Processing video {index} of {len(todo)}: {v.name}", round(base_progress)) | |
| try: | |
| kept, cands = process_video(v, args, whisper, spk, writers, tmp_root, | |
| report=report_video) | |
| total_kept += kept | |
| tqdm.write(f"{v.name}: kept {kept}/{cands} clips") | |
| except Exception as e: # one bad file shouldn't stop the whole run | |
| tqdm.write(f"{v.name}: FAILED - {e}") | |
| failures.append({"video": v.name, "error": str(e)}) | |
| report_progress(f"{v.name} failed: {e}", round(index / max(len(todo), 1) * 100)) | |
| continue | |
| meta_f.flush() | |
| rep_f.flush() | |
| done.add(str(v.resolve())) | |
| progress_file.write_text(json.dumps(sorted(done))) | |
| result = { | |
| "videos_found": len(videos), | |
| "videos_processed": len(todo) - len(failures), | |
| "videos_skipped": len(videos) - len(todo), | |
| "clips_kept": total_kept, | |
| "failures": failures, | |
| "dataset": str(args.out.resolve()), | |
| } | |
| finally: | |
| if meta_f is not None: | |
| meta_f.close() | |
| if rep_f is not None: | |
| rep_f.close() | |
| shutil.rmtree(tmp_root, ignore_errors=True) | |
| report_progress( | |
| f"Dataset ready: {total_kept} new clips from {result['videos_processed']} videos.", | |
| 100, | |
| ) | |
| print(f"\nDone. {total_kept} new clips this run. Dataset: {args.out.resolve()}") | |
| print("Review report.csv to tune --threshold before training.") | |
| return result | |
| def main(): | |
| p = argparse.ArgumentParser(description="Build a single-speaker voice dataset from videos.") | |
| p.add_argument("input", type=Path, help="Folder of videos (searched recursively)") | |
| p.add_argument("--reference", type=Path, nargs="+", required=True, | |
| help="1-5 short clips (10-30 s each) of ONLY the target voice") | |
| p.add_argument("--out", type=Path, default=Path("dataset")) | |
| p.add_argument("--threshold", type=float, default=0.60, | |
| help="Speaker similarity cutoff for finished clips") | |
| p.add_argument("--sentence-threshold", type=float, default=0.55, | |
| help="Speaker cutoff for each sentence before merging (raise if other " | |
| "voices slip into clips)") | |
| p.add_argument("--min-unit", type=float, default=0.6, | |
| help="Sentences shorter than this (s) are too short to judge and split clips") | |
| p.add_argument("--sample-rate", type=int, default=44100, help="Output clip sample rate") | |
| p.add_argument("--min-len", type=float, default=3.0) | |
| p.add_argument("--max-len", type=float, default=15.0) | |
| p.add_argument("--max-gap", type=float, default=0.6, help="Max pause (s) when merging segments") | |
| p.add_argument("--pad", type=float, default=0.10, help="Silence padding (s) around each clip") | |
| p.add_argument("--min-words", type=int, default=2) | |
| p.add_argument("--min-dbfs", type=float, default=-40.0) | |
| p.add_argument("--whisper-model", default="large-v3", | |
| help="large-v3 (best), medium, small (faster on weak GPUs)") | |
| p.add_argument("--language", default="en") | |
| p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") | |
| p.add_argument("--keep-temp", action="store_true", help="Keep extracted/separated audio") | |
| args = p.parse_args() | |
| try: | |
| run_dataset(args) | |
| except ValueError as e: | |
| sys.exit(str(e)) | |
| if __name__ == "__main__": | |
| main() | |