VoiceStudio / prepare_dataset.py
DillonLD's picture
Upload 11 files
a66f20a verified
Raw History Blame Contribute Delete
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()
@torch.no_grad()
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()