wakeforge / src /builder.py
eoinedge's picture
Upload folder using huggingface_hub
5838d6a verified
Raw
History Blame Contribute Delete
8.64 kB
"""Dataset builder: orchestrates TTS synthesis, augmentation and layout.
Produces both an Edge Impulse-ready folder layout and the metadata needed
for a Hugging Face dataset:
<out_dir>/
edge_impulse_upload/
training/ <label>.<id>.wav
testing/ <label>.<id>.wav
by_label/
<label>/ <human-readable>.wav
metadata.csv
selected_voices.csv
dataset_summary.json
"""
from __future__ import annotations
import csv
import json
import random
import shutil
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Callable, Dict, List, Optional
import numpy as np
from . import audio as A
from .backends import TTSBackend
from .config import NOISE_TYPES, DatasetConfig
ProgressFn = Callable[[str], None]
@dataclass
class BuildResult:
out_dir: str
backend_source: str
total_samples: int
label_counts: Dict[str, int]
split_counts: Dict[str, int]
voices: List[Dict[str, str]]
metadata_csv: str
summary_json: str
generated_base: int = 0
generated_augmented: int = 0
failed: int = 0
warnings: List[str] = field(default_factory=list)
def _choose_split(test_ratio: float) -> str:
return "testing" if random.random() < test_ratio else "training"
def _reset_dirs(out_dir: Path, labels: List[str]) -> None:
if out_dir.exists():
shutil.rmtree(out_dir)
for split in ("training", "testing"):
(out_dir / "edge_impulse_upload" / split).mkdir(parents=True, exist_ok=True)
for label in labels:
(out_dir / "by_label" / label).mkdir(parents=True, exist_ok=True)
def build_dataset(
config: DatasetConfig,
backend: TTSBackend,
progress: Optional[ProgressFn] = None,
) -> BuildResult:
def log(message: str) -> None:
if progress:
progress(message)
else:
print(message)
random.seed(config.seed)
np.random.seed(config.seed)
out_dir = Path(config.out_dir).resolve()
labels = [config.wake_label, config.unknown_label, config.noise_label]
_reset_dirs(out_dir, labels)
voices = backend.voices()
log(f"Backend: {backend.source} with {len(voices)} voice(s).")
# Persist selected voices.
voice_rows = [
{"name": v.name, "language_code": v.language_code, "description": v.description}
for v in voices
]
with (out_dir / "selected_voices.csv").open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["name", "language_code", "description"])
writer.writeheader()
writer.writerows(voice_rows)
rows: List[Dict[str, object]] = []
warnings: List[str] = []
generated_base = 0
generated_aug = 0
failed = 0
def save_item(
audio: np.ndarray,
label: str,
phrase: str,
voice_name: str,
locale: str,
source: str,
augmentation: str,
) -> None:
split = _choose_split(config.test_ratio)
uid = A.stable_hash(
json.dumps(
{
"label": label,
"phrase": phrase,
"voice": voice_name,
"locale": locale,
"source": source,
"augmentation": augmentation,
"rand": random.random(),
},
sort_keys=True,
)
)
filename = f"{label}.{uid}.wav"
ei_path = out_dir / "edge_impulse_upload" / split / filename
A.write_wav_file(ei_path, audio, config.sample_rate_hz)
human = f"{label}__{A.slugify(phrase)}__{A.slugify(locale)}__{A.slugify(voice_name)}__{uid}.wav"
by_label_path = out_dir / "by_label" / label / human
shutil.copy2(ei_path, by_label_path)
rows.append(
{
"filepath": str(by_label_path.relative_to(out_dir)),
"edge_impulse_filepath": str(ei_path.relative_to(out_dir)),
"label": label,
"phrase": phrase,
"voice_name": voice_name,
"language_code": locale,
"sample_rate_hz": config.sample_rate_hz,
"duration_seconds": config.duration_seconds,
"split": split,
"source": source,
"augmentation": augmentation,
}
)
# -- Speech clips ---------------------------------------------------- #
phrase_groups = [
(config.wake_label, config.wake_phrases),
(config.unknown_label, config.unknown_phrases),
]
target_samples = config.target_samples
for voice in voices:
for label, phrases in phrase_groups:
for phrase in phrases:
for _ in range(config.base_repeats_per_phrase_per_voice):
try:
result = backend.synthesize(phrase, voice)
clip = A.resample(result.audio, result.sample_rate_hz, config.sample_rate_hz)
clip = A.pad_or_trim(clip, target_samples)
clip = A.normalize(clip, 24000.0)
save_item(clip, label, phrase, voice.name, voice.language_code, backend.source, "original")
generated_base += 1
for aug_idx in range(config.augmentations_per_speech_clip):
aug = A.augment(clip, config.sample_rate_hz)
save_item(
aug, label, phrase, voice.name, voice.language_code,
backend.source, f"aug_{aug_idx:02d}",
)
generated_aug += 1
if generated_base % 10 == 0:
log(f"Synthesized {generated_base} base clips...")
except Exception as exc: # noqa: BLE001
failed += 1
msg = f"Failed voice={voice.name} phrase={phrase!r}: {exc}"
warnings.append(msg)
log(f"WARNING: {msg}")
# -- Background noise ------------------------------------------------ #
for _ in range(config.background_noise_samples):
noise_type = random.choice(NOISE_TYPES)
clip = A.make_background_noise(noise_type, target_samples, config.sample_rate_hz)
save_item(clip, config.noise_label, "", "synthetic_noise", "", "synthetic_noise", noise_type)
# -- Metadata & summary --------------------------------------------- #
label_counts: Dict[str, int] = {}
split_counts: Dict[str, int] = {}
for row in rows:
label_counts[row["label"]] = label_counts.get(row["label"], 0) + 1
split_counts[row["split"]] = split_counts.get(row["split"], 0) + 1
metadata_csv = out_dir / "metadata.csv"
fieldnames = list(rows[0].keys()) if rows else [
"filepath", "edge_impulse_filepath", "label", "phrase", "voice_name",
"language_code", "sample_rate_hz", "duration_seconds", "split", "source", "augmentation",
]
with metadata_csv.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
summary = {
"dataset": config.dataset_name,
"backend": backend.source,
"sample_rate_hz": config.sample_rate_hz,
"duration_seconds": config.duration_seconds,
"total_samples": len(rows),
"generated_base_speech_clips": generated_base,
"generated_augmented_speech_clips": generated_aug,
"background_noise_samples": config.background_noise_samples,
"failed_speech_samples": failed,
"labels": label_counts,
"splits": split_counts,
"voices": voice_rows,
"wake_phrases": config.wake_phrases,
"unknown_phrases": config.unknown_phrases,
}
summary_json = out_dir / "dataset_summary.json"
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
log(f"Done. Total samples: {len(rows)} (base={generated_base}, augmented={generated_aug}, failed={failed}).")
return BuildResult(
out_dir=str(out_dir),
backend_source=backend.source,
total_samples=len(rows),
label_counts=label_counts,
split_counts=split_counts,
voices=voice_rows,
metadata_csv=str(metadata_csv),
summary_json=str(summary_json),
generated_base=generated_base,
generated_augmented=generated_aug,
failed=failed,
warnings=warnings,
)