Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- src/__init__.py +8 -0
- src/audio.py +240 -0
- src/backends/__init__.py +61 -0
- src/backends/base.py +48 -0
- src/backends/gcp.py +126 -0
- src/backends/piper.py +89 -0
- src/builder.py +244 -0
- src/config.py +153 -0
- src/edge_impulse.py +126 -0
- src/hf_export.py +168 -0
src/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wake-word dataset creator package.
|
| 2 |
+
|
| 3 |
+
Generates Edge Impulse / Hugging Face ready wake-word datasets using
|
| 4 |
+
Google Cloud TTS (when an API key is available) with an automatic
|
| 5 |
+
fall back to free, local Piper TTS.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
__all__ = ["config", "audio", "builder", "hf_export", "backends", "edge_impulse"]
|
src/audio.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio helpers: WAV I/O, resampling, augmentation and synthetic noise.
|
| 2 |
+
|
| 3 |
+
Kept dependency-light: only numpy is required (no ffmpeg / scipy), so it
|
| 4 |
+
runs anywhere including a minimal Hugging Face Space.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import io
|
| 11 |
+
import math
|
| 12 |
+
import random
|
| 13 |
+
import re
|
| 14 |
+
import wave
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Tuple
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# --------------------------------------------------------------------------- #
|
| 22 |
+
# Naming helpers
|
| 23 |
+
# --------------------------------------------------------------------------- #
|
| 24 |
+
|
| 25 |
+
def slugify(text: str, max_len: int = 80) -> str:
|
| 26 |
+
text = str(text).strip().lower()
|
| 27 |
+
text = re.sub(r"[^a-z0-9]+", "_", text)
|
| 28 |
+
text = re.sub(r"_+", "_", text).strip("_")
|
| 29 |
+
return (text or "item")[:max_len]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def stable_hash(text: str, length: int = 12) -> str:
|
| 33 |
+
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:length]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# --------------------------------------------------------------------------- #
|
| 37 |
+
# WAV read / write / resample
|
| 38 |
+
# --------------------------------------------------------------------------- #
|
| 39 |
+
|
| 40 |
+
def read_wav_bytes(wav_bytes: bytes) -> Tuple[np.ndarray, int]:
|
| 41 |
+
"""Decode 16-bit PCM WAV bytes into mono float32 samples and sample rate."""
|
| 42 |
+
with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
|
| 43 |
+
channels = wf.getnchannels()
|
| 44 |
+
sample_width = wf.getsampwidth()
|
| 45 |
+
sample_rate = wf.getframerate()
|
| 46 |
+
frames = wf.readframes(wf.getnframes())
|
| 47 |
+
|
| 48 |
+
if sample_width != 2:
|
| 49 |
+
raise ValueError(f"Expected 16-bit PCM WAV, got sample width {sample_width}")
|
| 50 |
+
|
| 51 |
+
audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32)
|
| 52 |
+
if channels > 1:
|
| 53 |
+
audio = audio.reshape(-1, channels).mean(axis=1)
|
| 54 |
+
return audio, sample_rate
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def read_wav_file(path: Path) -> Tuple[np.ndarray, int]:
|
| 58 |
+
return read_wav_bytes(Path(path).read_bytes())
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def write_wav_file(path: Path, audio: np.ndarray, sample_rate_hz: int) -> None:
|
| 62 |
+
path = Path(path)
|
| 63 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 64 |
+
audio_i16 = np.clip(audio, -32768, 32767).astype(np.int16)
|
| 65 |
+
with wave.open(str(path), "wb") as wf:
|
| 66 |
+
wf.setnchannels(1)
|
| 67 |
+
wf.setsampwidth(2)
|
| 68 |
+
wf.setframerate(sample_rate_hz)
|
| 69 |
+
wf.writeframes(audio_i16.tobytes())
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def resample(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
|
| 73 |
+
"""Linear-interpolation resample. Good enough for a bootstrap dataset."""
|
| 74 |
+
if src_rate == dst_rate or len(audio) == 0:
|
| 75 |
+
return audio.astype(np.float32)
|
| 76 |
+
duration = len(audio) / float(src_rate)
|
| 77 |
+
dst_len = max(1, int(round(duration * dst_rate)))
|
| 78 |
+
src_idx = np.linspace(0.0, len(audio) - 1, num=dst_len)
|
| 79 |
+
return np.interp(src_idx, np.arange(len(audio)), audio).astype(np.float32)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# --------------------------------------------------------------------------- #
|
| 83 |
+
# Shaping
|
| 84 |
+
# --------------------------------------------------------------------------- #
|
| 85 |
+
|
| 86 |
+
def normalize(audio: np.ndarray, peak: float = 28000.0) -> np.ndarray:
|
| 87 |
+
if len(audio) == 0:
|
| 88 |
+
return audio
|
| 89 |
+
m = float(np.max(np.abs(audio)))
|
| 90 |
+
if m < 1.0:
|
| 91 |
+
return audio
|
| 92 |
+
return (audio * (peak / m)).astype(np.float32)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def pad_or_trim(audio: np.ndarray, target_samples: int, random_crop: bool = False) -> np.ndarray:
|
| 96 |
+
current = len(audio)
|
| 97 |
+
if current == target_samples:
|
| 98 |
+
return audio
|
| 99 |
+
if current > target_samples:
|
| 100 |
+
start = (
|
| 101 |
+
random.randint(0, current - target_samples)
|
| 102 |
+
if random_crop
|
| 103 |
+
else (current - target_samples) // 2
|
| 104 |
+
)
|
| 105 |
+
return audio[start:start + target_samples]
|
| 106 |
+
pad_total = target_samples - current
|
| 107 |
+
pad_left = pad_total // 2
|
| 108 |
+
pad_right = pad_total - pad_left
|
| 109 |
+
return np.pad(audio, (pad_left, pad_right), mode="constant")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# --------------------------------------------------------------------------- #
|
| 113 |
+
# Augmentation
|
| 114 |
+
# --------------------------------------------------------------------------- #
|
| 115 |
+
|
| 116 |
+
def _gain(audio: np.ndarray, gain_db: float) -> np.ndarray:
|
| 117 |
+
return audio * (10.0 ** (gain_db / 20.0))
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _time_shift(audio: np.ndarray, max_shift: int) -> np.ndarray:
|
| 121 |
+
return np.roll(audio, random.randint(-max_shift, max_shift))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _add_noise(audio: np.ndarray, snr_db: float) -> np.ndarray:
|
| 125 |
+
noise = np.random.normal(0.0, 1.0, len(audio)).astype(np.float32)
|
| 126 |
+
clean_power = float(np.mean(audio ** 2))
|
| 127 |
+
noise_power = float(np.mean(noise ** 2))
|
| 128 |
+
if clean_power < 1.0 or noise_power < 1e-9:
|
| 129 |
+
return audio
|
| 130 |
+
target_noise_power = clean_power / (10.0 ** (snr_db / 10.0))
|
| 131 |
+
noise *= math.sqrt(target_noise_power / noise_power)
|
| 132 |
+
return audio + noise
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _echo(audio: np.ndarray, sr: int) -> np.ndarray:
|
| 136 |
+
delay = random.randint(int(0.03 * sr), int(0.12 * sr))
|
| 137 |
+
decay = random.uniform(0.08, 0.25)
|
| 138 |
+
out = audio.copy()
|
| 139 |
+
if 0 < delay < len(audio):
|
| 140 |
+
out[delay:] += audio[:-delay] * decay
|
| 141 |
+
return out
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def augment(audio: np.ndarray, sr: int) -> np.ndarray:
|
| 145 |
+
out = audio.copy()
|
| 146 |
+
out = _gain(out, random.uniform(-6.0, 3.0))
|
| 147 |
+
out = _time_shift(out, max_shift=int(0.12 * sr))
|
| 148 |
+
if random.random() < 0.75:
|
| 149 |
+
out = _add_noise(out, random.choice([30, 25, 20, 15, 10]))
|
| 150 |
+
if random.random() < 0.35:
|
| 151 |
+
out = _echo(out, sr)
|
| 152 |
+
return normalize(out, 28000.0)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# --------------------------------------------------------------------------- #
|
| 156 |
+
# Synthetic background noise
|
| 157 |
+
# --------------------------------------------------------------------------- #
|
| 158 |
+
|
| 159 |
+
def _white(n: int) -> np.ndarray:
|
| 160 |
+
return np.random.normal(0.0, 1.0, n).astype(np.float32)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _pink(n: int) -> np.ndarray:
|
| 164 |
+
white = _white(n)
|
| 165 |
+
out = np.zeros_like(white)
|
| 166 |
+
alpha = 0.985
|
| 167 |
+
for i in range(1, n):
|
| 168 |
+
out[i] = alpha * out[i - 1] + (1.0 - alpha) * white[i]
|
| 169 |
+
return out.astype(np.float32)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _brown(n: int) -> np.ndarray:
|
| 173 |
+
brown = np.cumsum(_white(n))
|
| 174 |
+
brown = brown - np.mean(brown)
|
| 175 |
+
return normalize(brown.astype(np.float32), 1.0)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _hum(n: int, sr: int) -> np.ndarray:
|
| 179 |
+
t = np.arange(n, dtype=np.float32) / float(sr)
|
| 180 |
+
hum = (
|
| 181 |
+
np.sin(2.0 * math.pi * 50.0 * t)
|
| 182 |
+
+ 0.5 * np.sin(2.0 * math.pi * 100.0 * t)
|
| 183 |
+
+ 0.25 * np.sin(2.0 * math.pi * 150.0 * t)
|
| 184 |
+
)
|
| 185 |
+
hum += 0.04 * _white(n)
|
| 186 |
+
return hum.astype(np.float32)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _fan(n: int, sr: int) -> np.ndarray:
|
| 190 |
+
base = _pink(n)
|
| 191 |
+
t = np.arange(n, dtype=np.float32) / float(sr)
|
| 192 |
+
blade_rate = random.uniform(18.0, 45.0)
|
| 193 |
+
modulation = 0.65 + 0.35 * np.sin(2.0 * math.pi * blade_rate * t)
|
| 194 |
+
return (base * modulation).astype(np.float32)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _cafe(n: int, sr: int) -> np.ndarray:
|
| 198 |
+
base = 0.55 * _pink(n) + 0.45 * _white(n)
|
| 199 |
+
transient_count = max(1, int((n / sr) * random.uniform(2.0, 6.0)))
|
| 200 |
+
for _ in range(transient_count):
|
| 201 |
+
pos = random.randint(0, max(0, n - 1))
|
| 202 |
+
length = random.randint(max(1, int(0.008 * sr)), max(2, int(0.05 * sr)))
|
| 203 |
+
end = min(n, pos + length)
|
| 204 |
+
if end <= pos:
|
| 205 |
+
continue
|
| 206 |
+
click = np.hanning(end - pos).astype(np.float32)
|
| 207 |
+
base[pos:end] += click * random.uniform(0.5, 2.0)
|
| 208 |
+
return base.astype(np.float32)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _street(n: int, sr: int) -> np.ndarray:
|
| 212 |
+
base = 0.7 * _brown(n) + 0.3 * _white(n)
|
| 213 |
+
t = np.arange(n, dtype=np.float32) / float(sr)
|
| 214 |
+
for _ in range(random.randint(1, 3)):
|
| 215 |
+
center = random.uniform(0.2, max(0.21, t[-1] - 0.2))
|
| 216 |
+
width = random.uniform(0.2, 0.8)
|
| 217 |
+
envelope = np.exp(-0.5 * ((t - center) / width) ** 2)
|
| 218 |
+
freq = random.uniform(70.0, 180.0)
|
| 219 |
+
base += 0.35 * envelope * np.sin(2.0 * math.pi * freq * t)
|
| 220 |
+
return base.astype(np.float32)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def make_background_noise(noise_type: str, num_samples: int, sr: int) -> np.ndarray:
|
| 224 |
+
if noise_type == "white":
|
| 225 |
+
noise = _white(num_samples)
|
| 226 |
+
elif noise_type == "pink":
|
| 227 |
+
noise = _pink(num_samples)
|
| 228 |
+
elif noise_type == "brown":
|
| 229 |
+
noise = _brown(num_samples)
|
| 230 |
+
elif noise_type == "hum":
|
| 231 |
+
noise = _hum(num_samples, sr)
|
| 232 |
+
elif noise_type == "fan":
|
| 233 |
+
noise = _fan(num_samples, sr)
|
| 234 |
+
elif noise_type == "cafe":
|
| 235 |
+
noise = _cafe(num_samples, sr)
|
| 236 |
+
elif noise_type == "street":
|
| 237 |
+
noise = _street(num_samples, sr)
|
| 238 |
+
else:
|
| 239 |
+
raise ValueError(f"Unknown noise type: {noise_type}")
|
| 240 |
+
return normalize(noise, random.uniform(6000, 22000))
|
src/backends/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TTS backend abstraction and automatic backend selection.
|
| 2 |
+
|
| 3 |
+
The dataset builder is agnostic to which engine produces audio. Two
|
| 4 |
+
backends are provided:
|
| 5 |
+
|
| 6 |
+
* :class:`~src.backends.gcp.GCPTTSBackend` — Google Cloud TTS via REST
|
| 7 |
+
API key. Used when a key is available.
|
| 8 |
+
* :class:`~src.backends.piper.PiperTTSBackend` — free, local Piper TTS.
|
| 9 |
+
Used as the fall back.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import List, Optional
|
| 15 |
+
|
| 16 |
+
from .base import SynthesisResult, TTSBackend, Voice
|
| 17 |
+
from .gcp import GCPTTSBackend
|
| 18 |
+
from .piper import PiperTTSBackend
|
| 19 |
+
|
| 20 |
+
__all__ = [
|
| 21 |
+
"SynthesisResult",
|
| 22 |
+
"TTSBackend",
|
| 23 |
+
"Voice",
|
| 24 |
+
"GCPTTSBackend",
|
| 25 |
+
"PiperTTSBackend",
|
| 26 |
+
"select_backend",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def select_backend(
|
| 31 |
+
gcp_api_key: Optional[str],
|
| 32 |
+
language_prefixes: List[str],
|
| 33 |
+
max_gcp_voices_per_locale: int,
|
| 34 |
+
max_piper_voices: int,
|
| 35 |
+
sample_rate_hz: int,
|
| 36 |
+
) -> TTSBackend:
|
| 37 |
+
"""Return a ready TTS backend.
|
| 38 |
+
|
| 39 |
+
Prefers Google Cloud TTS when ``gcp_api_key`` is provided and the key
|
| 40 |
+
validates. Otherwise (no key, or the key fails to initialise) falls
|
| 41 |
+
back to the free Piper backend.
|
| 42 |
+
"""
|
| 43 |
+
if gcp_api_key and gcp_api_key.strip():
|
| 44 |
+
backend = GCPTTSBackend(
|
| 45 |
+
api_key=gcp_api_key.strip(),
|
| 46 |
+
language_prefixes=language_prefixes,
|
| 47 |
+
max_voices_per_locale=max_gcp_voices_per_locale,
|
| 48 |
+
sample_rate_hz=sample_rate_hz,
|
| 49 |
+
)
|
| 50 |
+
try:
|
| 51 |
+
backend.prepare()
|
| 52 |
+
return backend
|
| 53 |
+
except Exception as exc: # noqa: BLE001 - any failure => fall back
|
| 54 |
+
print(f"[backend] Google Cloud TTS unavailable ({exc}); falling back to Piper.")
|
| 55 |
+
|
| 56 |
+
backend = PiperTTSBackend(
|
| 57 |
+
max_voices=max_piper_voices,
|
| 58 |
+
sample_rate_hz=sample_rate_hz,
|
| 59 |
+
)
|
| 60 |
+
backend.prepare()
|
| 61 |
+
return backend
|
src/backends/base.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Abstract TTS backend interface."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from abc import ABC, abstractmethod
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import List
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class Voice:
|
| 14 |
+
"""A speaker/voice exposed by a backend."""
|
| 15 |
+
|
| 16 |
+
name: str
|
| 17 |
+
language_code: str
|
| 18 |
+
description: str = ""
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class SynthesisResult:
|
| 23 |
+
"""Output of a single synthesis call: mono float32 audio at a known rate."""
|
| 24 |
+
|
| 25 |
+
audio: np.ndarray
|
| 26 |
+
sample_rate_hz: int
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TTSBackend(ABC):
|
| 30 |
+
"""Common interface for every TTS engine."""
|
| 31 |
+
|
| 32 |
+
#: Short identifier stored in dataset metadata (e.g. "google_cloud_tts").
|
| 33 |
+
source: str = "tts"
|
| 34 |
+
|
| 35 |
+
@abstractmethod
|
| 36 |
+
def prepare(self) -> None:
|
| 37 |
+
"""Validate credentials, download models and list voices.
|
| 38 |
+
|
| 39 |
+
Must raise if the backend cannot be used.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
@abstractmethod
|
| 43 |
+
def voices(self) -> List[Voice]:
|
| 44 |
+
"""Return the selected voices to synthesize with."""
|
| 45 |
+
|
| 46 |
+
@abstractmethod
|
| 47 |
+
def synthesize(self, text: str, voice: Voice) -> SynthesisResult:
|
| 48 |
+
"""Synthesize ``text`` with ``voice`` into mono float32 audio."""
|
src/backends/gcp.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google Cloud Text-to-Speech backend using an API key (REST API).
|
| 2 |
+
|
| 3 |
+
Uses the public REST endpoints so the only requirement is an API key,
|
| 4 |
+
which is the simplest credential to provide inside a Hugging Face Space
|
| 5 |
+
(store it as a Space secret named ``GCP_TTS_API_KEY``):
|
| 6 |
+
|
| 7 |
+
* ``GET /v1/voices`` — list available voices
|
| 8 |
+
* ``POST /v1/text:synthesize`` — synthesize speech
|
| 9 |
+
|
| 10 |
+
Requesting ``LINEAR16`` at 16 kHz returns ready-to-use PCM WAV bytes.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import base64
|
| 16 |
+
from typing import List, Optional
|
| 17 |
+
|
| 18 |
+
import requests
|
| 19 |
+
|
| 20 |
+
from ..audio import read_wav_bytes
|
| 21 |
+
from .base import SynthesisResult, TTSBackend, Voice
|
| 22 |
+
|
| 23 |
+
_BASE_URL = "https://texttospeech.googleapis.com/v1"
|
| 24 |
+
_TIMEOUT = 30
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class GCPTTSBackend(TTSBackend):
|
| 28 |
+
source = "google_cloud_tts"
|
| 29 |
+
|
| 30 |
+
def __init__(
|
| 31 |
+
self,
|
| 32 |
+
api_key: str,
|
| 33 |
+
language_prefixes: List[str],
|
| 34 |
+
max_voices_per_locale: int,
|
| 35 |
+
sample_rate_hz: int,
|
| 36 |
+
prefer_voice_types: Optional[List[str]] = None,
|
| 37 |
+
) -> None:
|
| 38 |
+
self.api_key = api_key
|
| 39 |
+
self.language_prefixes = [p.lower() for p in language_prefixes]
|
| 40 |
+
self.max_voices_per_locale = max_voices_per_locale
|
| 41 |
+
self.sample_rate_hz = sample_rate_hz
|
| 42 |
+
self.prefer_voice_types = prefer_voice_types or [
|
| 43 |
+
"chirp",
|
| 44 |
+
"neural",
|
| 45 |
+
"wavenet",
|
| 46 |
+
"studio",
|
| 47 |
+
"journey",
|
| 48 |
+
"standard",
|
| 49 |
+
]
|
| 50 |
+
self._voices: List[Voice] = []
|
| 51 |
+
|
| 52 |
+
# ------------------------------------------------------------------ #
|
| 53 |
+
|
| 54 |
+
def prepare(self) -> None:
|
| 55 |
+
response = requests.get(
|
| 56 |
+
f"{_BASE_URL}/voices",
|
| 57 |
+
params={"key": self.api_key},
|
| 58 |
+
timeout=_TIMEOUT,
|
| 59 |
+
)
|
| 60 |
+
if response.status_code != 200:
|
| 61 |
+
raise RuntimeError(
|
| 62 |
+
f"Google TTS voices request failed ({response.status_code}): "
|
| 63 |
+
f"{response.text[:200]}"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
raw_voices = response.json().get("voices", [])
|
| 67 |
+
by_locale: dict[str, List[Voice]] = {}
|
| 68 |
+
for item in raw_voices:
|
| 69 |
+
name = item.get("name", "")
|
| 70 |
+
for locale in item.get("languageCodes", []):
|
| 71 |
+
prefix = locale.split("-")[0].lower()
|
| 72 |
+
if prefix not in self.language_prefixes:
|
| 73 |
+
continue
|
| 74 |
+
by_locale.setdefault(locale, []).append(
|
| 75 |
+
Voice(name=name, language_code=locale, description="google_cloud_tts")
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
def rank(voice: Voice) -> tuple[int, str]:
|
| 79 |
+
lower = voice.name.lower()
|
| 80 |
+
for idx, preferred in enumerate(self.prefer_voice_types):
|
| 81 |
+
if preferred in lower:
|
| 82 |
+
return idx, voice.name
|
| 83 |
+
return 999, voice.name
|
| 84 |
+
|
| 85 |
+
selected: List[Voice] = []
|
| 86 |
+
for locale in sorted(by_locale):
|
| 87 |
+
ordered = sorted(by_locale[locale], key=rank)
|
| 88 |
+
selected.extend(ordered[: self.max_voices_per_locale])
|
| 89 |
+
|
| 90 |
+
if not selected:
|
| 91 |
+
raise RuntimeError("Google TTS returned no voices matching the requested locales.")
|
| 92 |
+
self._voices = selected
|
| 93 |
+
|
| 94 |
+
def voices(self) -> List[Voice]:
|
| 95 |
+
return list(self._voices)
|
| 96 |
+
|
| 97 |
+
def synthesize(self, text: str, voice: Voice) -> SynthesisResult:
|
| 98 |
+
payload = {
|
| 99 |
+
"input": {"text": text},
|
| 100 |
+
"voice": {
|
| 101 |
+
"languageCode": voice.language_code,
|
| 102 |
+
"name": voice.name,
|
| 103 |
+
},
|
| 104 |
+
"audioConfig": {
|
| 105 |
+
"audioEncoding": "LINEAR16",
|
| 106 |
+
"sampleRateHertz": self.sample_rate_hz,
|
| 107 |
+
},
|
| 108 |
+
}
|
| 109 |
+
response = requests.post(
|
| 110 |
+
f"{_BASE_URL}/text:synthesize",
|
| 111 |
+
params={"key": self.api_key},
|
| 112 |
+
json=payload,
|
| 113 |
+
timeout=_TIMEOUT,
|
| 114 |
+
)
|
| 115 |
+
if response.status_code != 200:
|
| 116 |
+
raise RuntimeError(
|
| 117 |
+
f"Google TTS synthesize failed ({response.status_code}): "
|
| 118 |
+
f"{response.text[:200]}"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
audio_b64 = response.json().get("audioContent")
|
| 122 |
+
if not audio_b64:
|
| 123 |
+
raise RuntimeError("Google TTS response contained no audioContent.")
|
| 124 |
+
|
| 125 |
+
audio, sample_rate = read_wav_bytes(base64.b64decode(audio_b64))
|
| 126 |
+
return SynthesisResult(audio=audio, sample_rate_hz=sample_rate)
|
src/backends/piper.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Free, local Piper TTS backend.
|
| 2 |
+
|
| 3 |
+
Voice models are pulled from the ``rhasspy/piper-voices`` repo on the
|
| 4 |
+
Hugging Face Hub and synthesized with the ``piper`` Python package. No
|
| 5 |
+
network access is needed at synthesis time and no paid service is used.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import tempfile
|
| 11 |
+
import wave
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import List, Optional
|
| 14 |
+
|
| 15 |
+
from ..audio import read_wav_file, resample
|
| 16 |
+
from ..config import PIPER_VOICES
|
| 17 |
+
from .base import SynthesisResult, TTSBackend, Voice
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class PiperTTSBackend(TTSBackend):
|
| 21 |
+
source = "piper_tts"
|
| 22 |
+
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
max_voices: int,
|
| 26 |
+
sample_rate_hz: int,
|
| 27 |
+
cache_dir: Optional[str] = None,
|
| 28 |
+
) -> None:
|
| 29 |
+
self.max_voices = max_voices
|
| 30 |
+
self.sample_rate_hz = sample_rate_hz
|
| 31 |
+
self.cache_dir = Path(cache_dir) if cache_dir else Path(tempfile.gettempdir()) / "piper_voices"
|
| 32 |
+
self._voices: List[Voice] = []
|
| 33 |
+
self._models: dict[str, "object"] = {} # voice name -> PiperVoice instance
|
| 34 |
+
|
| 35 |
+
# ------------------------------------------------------------------ #
|
| 36 |
+
|
| 37 |
+
def prepare(self) -> None:
|
| 38 |
+
from huggingface_hub import hf_hub_download # local import: heavy dep
|
| 39 |
+
from piper import PiperVoice as PiperModel # local import: heavy dep
|
| 40 |
+
|
| 41 |
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
selected = PIPER_VOICES[: self.max_voices]
|
| 43 |
+
|
| 44 |
+
for spec in selected:
|
| 45 |
+
try:
|
| 46 |
+
onnx_path = hf_hub_download(
|
| 47 |
+
repo_id="rhasspy/piper-voices",
|
| 48 |
+
filename=f"{spec.repo_path}.onnx",
|
| 49 |
+
cache_dir=str(self.cache_dir),
|
| 50 |
+
)
|
| 51 |
+
config_path = hf_hub_download(
|
| 52 |
+
repo_id="rhasspy/piper-voices",
|
| 53 |
+
filename=f"{spec.repo_path}.onnx.json",
|
| 54 |
+
cache_dir=str(self.cache_dir),
|
| 55 |
+
)
|
| 56 |
+
model = PiperModel.load(onnx_path, config_path=config_path)
|
| 57 |
+
self._models[spec.voice_id] = model
|
| 58 |
+
self._voices.append(
|
| 59 |
+
Voice(
|
| 60 |
+
name=spec.voice_id,
|
| 61 |
+
language_code=spec.locale,
|
| 62 |
+
description=spec.description,
|
| 63 |
+
)
|
| 64 |
+
)
|
| 65 |
+
except Exception as exc: # noqa: BLE001 - skip individual bad voices
|
| 66 |
+
print(f"[piper] Skipping voice {spec.voice_id}: {exc}")
|
| 67 |
+
|
| 68 |
+
if not self._voices:
|
| 69 |
+
raise RuntimeError("No Piper voices could be downloaded or loaded.")
|
| 70 |
+
|
| 71 |
+
def voices(self) -> List[Voice]:
|
| 72 |
+
return list(self._voices)
|
| 73 |
+
|
| 74 |
+
def synthesize(self, text: str, voice: Voice) -> SynthesisResult:
|
| 75 |
+
model = self._models.get(voice.name)
|
| 76 |
+
if model is None:
|
| 77 |
+
raise RuntimeError(f"Piper voice not loaded: {voice.name}")
|
| 78 |
+
|
| 79 |
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
| 80 |
+
tmp_path = Path(tmp.name)
|
| 81 |
+
try:
|
| 82 |
+
with wave.open(str(tmp_path), "wb") as wav_file:
|
| 83 |
+
model.synthesize(text, wav_file)
|
| 84 |
+
audio, src_rate = read_wav_file(tmp_path)
|
| 85 |
+
finally:
|
| 86 |
+
tmp_path.unlink(missing_ok=True)
|
| 87 |
+
|
| 88 |
+
audio = resample(audio, src_rate, self.sample_rate_hz)
|
| 89 |
+
return SynthesisResult(audio=audio, sample_rate_hz=self.sample_rate_hz)
|
src/builder.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset builder: orchestrates TTS synthesis, augmentation and layout.
|
| 2 |
+
|
| 3 |
+
Produces both an Edge Impulse-ready folder layout and the metadata needed
|
| 4 |
+
for a Hugging Face dataset:
|
| 5 |
+
|
| 6 |
+
<out_dir>/
|
| 7 |
+
edge_impulse_upload/
|
| 8 |
+
training/ <label>.<id>.wav
|
| 9 |
+
testing/ <label>.<id>.wav
|
| 10 |
+
by_label/
|
| 11 |
+
<label>/ <human-readable>.wav
|
| 12 |
+
metadata.csv
|
| 13 |
+
selected_voices.csv
|
| 14 |
+
dataset_summary.json
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import csv
|
| 20 |
+
import json
|
| 21 |
+
import random
|
| 22 |
+
import shutil
|
| 23 |
+
from dataclasses import asdict, dataclass, field
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Callable, Dict, List, Optional
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
|
| 29 |
+
from . import audio as A
|
| 30 |
+
from .backends import TTSBackend
|
| 31 |
+
from .config import NOISE_TYPES, DatasetConfig
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
ProgressFn = Callable[[str], None]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class BuildResult:
|
| 39 |
+
out_dir: str
|
| 40 |
+
backend_source: str
|
| 41 |
+
total_samples: int
|
| 42 |
+
label_counts: Dict[str, int]
|
| 43 |
+
split_counts: Dict[str, int]
|
| 44 |
+
voices: List[Dict[str, str]]
|
| 45 |
+
metadata_csv: str
|
| 46 |
+
summary_json: str
|
| 47 |
+
generated_base: int = 0
|
| 48 |
+
generated_augmented: int = 0
|
| 49 |
+
failed: int = 0
|
| 50 |
+
warnings: List[str] = field(default_factory=list)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _choose_split(test_ratio: float) -> str:
|
| 54 |
+
return "testing" if random.random() < test_ratio else "training"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _reset_dirs(out_dir: Path, labels: List[str]) -> None:
|
| 58 |
+
if out_dir.exists():
|
| 59 |
+
shutil.rmtree(out_dir)
|
| 60 |
+
for split in ("training", "testing"):
|
| 61 |
+
(out_dir / "edge_impulse_upload" / split).mkdir(parents=True, exist_ok=True)
|
| 62 |
+
for label in labels:
|
| 63 |
+
(out_dir / "by_label" / label).mkdir(parents=True, exist_ok=True)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def build_dataset(
|
| 67 |
+
config: DatasetConfig,
|
| 68 |
+
backend: TTSBackend,
|
| 69 |
+
progress: Optional[ProgressFn] = None,
|
| 70 |
+
) -> BuildResult:
|
| 71 |
+
def log(message: str) -> None:
|
| 72 |
+
if progress:
|
| 73 |
+
progress(message)
|
| 74 |
+
else:
|
| 75 |
+
print(message)
|
| 76 |
+
|
| 77 |
+
random.seed(config.seed)
|
| 78 |
+
np.random.seed(config.seed)
|
| 79 |
+
|
| 80 |
+
out_dir = Path(config.out_dir).resolve()
|
| 81 |
+
labels = [config.wake_label, config.unknown_label, config.noise_label]
|
| 82 |
+
_reset_dirs(out_dir, labels)
|
| 83 |
+
|
| 84 |
+
voices = backend.voices()
|
| 85 |
+
log(f"Backend: {backend.source} with {len(voices)} voice(s).")
|
| 86 |
+
|
| 87 |
+
# Persist selected voices.
|
| 88 |
+
voice_rows = [
|
| 89 |
+
{"name": v.name, "language_code": v.language_code, "description": v.description}
|
| 90 |
+
for v in voices
|
| 91 |
+
]
|
| 92 |
+
with (out_dir / "selected_voices.csv").open("w", newline="", encoding="utf-8") as f:
|
| 93 |
+
writer = csv.DictWriter(f, fieldnames=["name", "language_code", "description"])
|
| 94 |
+
writer.writeheader()
|
| 95 |
+
writer.writerows(voice_rows)
|
| 96 |
+
|
| 97 |
+
rows: List[Dict[str, object]] = []
|
| 98 |
+
warnings: List[str] = []
|
| 99 |
+
generated_base = 0
|
| 100 |
+
generated_aug = 0
|
| 101 |
+
failed = 0
|
| 102 |
+
|
| 103 |
+
def save_item(
|
| 104 |
+
audio: np.ndarray,
|
| 105 |
+
label: str,
|
| 106 |
+
phrase: str,
|
| 107 |
+
voice_name: str,
|
| 108 |
+
locale: str,
|
| 109 |
+
source: str,
|
| 110 |
+
augmentation: str,
|
| 111 |
+
) -> None:
|
| 112 |
+
split = _choose_split(config.test_ratio)
|
| 113 |
+
uid = A.stable_hash(
|
| 114 |
+
json.dumps(
|
| 115 |
+
{
|
| 116 |
+
"label": label,
|
| 117 |
+
"phrase": phrase,
|
| 118 |
+
"voice": voice_name,
|
| 119 |
+
"locale": locale,
|
| 120 |
+
"source": source,
|
| 121 |
+
"augmentation": augmentation,
|
| 122 |
+
"rand": random.random(),
|
| 123 |
+
},
|
| 124 |
+
sort_keys=True,
|
| 125 |
+
)
|
| 126 |
+
)
|
| 127 |
+
filename = f"{label}.{uid}.wav"
|
| 128 |
+
ei_path = out_dir / "edge_impulse_upload" / split / filename
|
| 129 |
+
A.write_wav_file(ei_path, audio, config.sample_rate_hz)
|
| 130 |
+
|
| 131 |
+
human = f"{label}__{A.slugify(phrase)}__{A.slugify(locale)}__{A.slugify(voice_name)}__{uid}.wav"
|
| 132 |
+
by_label_path = out_dir / "by_label" / label / human
|
| 133 |
+
shutil.copy2(ei_path, by_label_path)
|
| 134 |
+
|
| 135 |
+
rows.append(
|
| 136 |
+
{
|
| 137 |
+
"filepath": str(by_label_path.relative_to(out_dir)),
|
| 138 |
+
"edge_impulse_filepath": str(ei_path.relative_to(out_dir)),
|
| 139 |
+
"label": label,
|
| 140 |
+
"phrase": phrase,
|
| 141 |
+
"voice_name": voice_name,
|
| 142 |
+
"language_code": locale,
|
| 143 |
+
"sample_rate_hz": config.sample_rate_hz,
|
| 144 |
+
"duration_seconds": config.duration_seconds,
|
| 145 |
+
"split": split,
|
| 146 |
+
"source": source,
|
| 147 |
+
"augmentation": augmentation,
|
| 148 |
+
}
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
# -- Speech clips ---------------------------------------------------- #
|
| 152 |
+
phrase_groups = [
|
| 153 |
+
(config.wake_label, config.wake_phrases),
|
| 154 |
+
(config.unknown_label, config.unknown_phrases),
|
| 155 |
+
]
|
| 156 |
+
target_samples = config.target_samples
|
| 157 |
+
|
| 158 |
+
for voice in voices:
|
| 159 |
+
for label, phrases in phrase_groups:
|
| 160 |
+
for phrase in phrases:
|
| 161 |
+
for _ in range(config.base_repeats_per_phrase_per_voice):
|
| 162 |
+
try:
|
| 163 |
+
result = backend.synthesize(phrase, voice)
|
| 164 |
+
clip = A.resample(result.audio, result.sample_rate_hz, config.sample_rate_hz)
|
| 165 |
+
clip = A.pad_or_trim(clip, target_samples)
|
| 166 |
+
clip = A.normalize(clip, 24000.0)
|
| 167 |
+
|
| 168 |
+
save_item(clip, label, phrase, voice.name, voice.language_code, backend.source, "original")
|
| 169 |
+
generated_base += 1
|
| 170 |
+
|
| 171 |
+
for aug_idx in range(config.augmentations_per_speech_clip):
|
| 172 |
+
aug = A.augment(clip, config.sample_rate_hz)
|
| 173 |
+
save_item(
|
| 174 |
+
aug, label, phrase, voice.name, voice.language_code,
|
| 175 |
+
backend.source, f"aug_{aug_idx:02d}",
|
| 176 |
+
)
|
| 177 |
+
generated_aug += 1
|
| 178 |
+
|
| 179 |
+
if generated_base % 10 == 0:
|
| 180 |
+
log(f"Synthesized {generated_base} base clips...")
|
| 181 |
+
except Exception as exc: # noqa: BLE001
|
| 182 |
+
failed += 1
|
| 183 |
+
msg = f"Failed voice={voice.name} phrase={phrase!r}: {exc}"
|
| 184 |
+
warnings.append(msg)
|
| 185 |
+
log(f"WARNING: {msg}")
|
| 186 |
+
|
| 187 |
+
# -- Background noise ------------------------------------------------ #
|
| 188 |
+
for _ in range(config.background_noise_samples):
|
| 189 |
+
noise_type = random.choice(NOISE_TYPES)
|
| 190 |
+
clip = A.make_background_noise(noise_type, target_samples, config.sample_rate_hz)
|
| 191 |
+
save_item(clip, config.noise_label, "", "synthetic_noise", "", "synthetic_noise", noise_type)
|
| 192 |
+
|
| 193 |
+
# -- Metadata & summary --------------------------------------------- #
|
| 194 |
+
label_counts: Dict[str, int] = {}
|
| 195 |
+
split_counts: Dict[str, int] = {}
|
| 196 |
+
for row in rows:
|
| 197 |
+
label_counts[row["label"]] = label_counts.get(row["label"], 0) + 1
|
| 198 |
+
split_counts[row["split"]] = split_counts.get(row["split"], 0) + 1
|
| 199 |
+
|
| 200 |
+
metadata_csv = out_dir / "metadata.csv"
|
| 201 |
+
fieldnames = list(rows[0].keys()) if rows else [
|
| 202 |
+
"filepath", "edge_impulse_filepath", "label", "phrase", "voice_name",
|
| 203 |
+
"language_code", "sample_rate_hz", "duration_seconds", "split", "source", "augmentation",
|
| 204 |
+
]
|
| 205 |
+
with metadata_csv.open("w", newline="", encoding="utf-8") as f:
|
| 206 |
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
| 207 |
+
writer.writeheader()
|
| 208 |
+
writer.writerows(rows)
|
| 209 |
+
|
| 210 |
+
summary = {
|
| 211 |
+
"dataset": config.dataset_name,
|
| 212 |
+
"backend": backend.source,
|
| 213 |
+
"sample_rate_hz": config.sample_rate_hz,
|
| 214 |
+
"duration_seconds": config.duration_seconds,
|
| 215 |
+
"total_samples": len(rows),
|
| 216 |
+
"generated_base_speech_clips": generated_base,
|
| 217 |
+
"generated_augmented_speech_clips": generated_aug,
|
| 218 |
+
"background_noise_samples": config.background_noise_samples,
|
| 219 |
+
"failed_speech_samples": failed,
|
| 220 |
+
"labels": label_counts,
|
| 221 |
+
"splits": split_counts,
|
| 222 |
+
"voices": voice_rows,
|
| 223 |
+
"wake_phrases": config.wake_phrases,
|
| 224 |
+
"unknown_phrases": config.unknown_phrases,
|
| 225 |
+
}
|
| 226 |
+
summary_json = out_dir / "dataset_summary.json"
|
| 227 |
+
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
| 228 |
+
|
| 229 |
+
log(f"Done. Total samples: {len(rows)} (base={generated_base}, augmented={generated_aug}, failed={failed}).")
|
| 230 |
+
|
| 231 |
+
return BuildResult(
|
| 232 |
+
out_dir=str(out_dir),
|
| 233 |
+
backend_source=backend.source,
|
| 234 |
+
total_samples=len(rows),
|
| 235 |
+
label_counts=label_counts,
|
| 236 |
+
split_counts=split_counts,
|
| 237 |
+
voices=voice_rows,
|
| 238 |
+
metadata_csv=str(metadata_csv),
|
| 239 |
+
summary_json=str(summary_json),
|
| 240 |
+
generated_base=generated_base,
|
| 241 |
+
generated_augmented=generated_aug,
|
| 242 |
+
failed=failed,
|
| 243 |
+
warnings=warnings,
|
| 244 |
+
)
|
src/config.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration, phrase banks and default catalogs for the dataset creator."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import List
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# --------------------------------------------------------------------------- #
|
| 10 |
+
# Default phrase banks (target phrase: "Hey Android")
|
| 11 |
+
# --------------------------------------------------------------------------- #
|
| 12 |
+
|
| 13 |
+
DEFAULT_WAKE_PHRASES: List[str] = [
|
| 14 |
+
"Hey Android",
|
| 15 |
+
"Hey, Android",
|
| 16 |
+
"Hello Android",
|
| 17 |
+
"Okay Android",
|
| 18 |
+
"OK Android",
|
| 19 |
+
"Android",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
DEFAULT_UNKNOWN_PHRASES: List[str] = [
|
| 23 |
+
"Hey Andrew",
|
| 24 |
+
"Hey Adrian",
|
| 25 |
+
"Hey Andrea",
|
| 26 |
+
"Hello phone",
|
| 27 |
+
"Open settings",
|
| 28 |
+
"Start recording",
|
| 29 |
+
"Stop recording",
|
| 30 |
+
"Good morning",
|
| 31 |
+
"Where is my phone",
|
| 32 |
+
"Androids are useful",
|
| 33 |
+
"This is a test",
|
| 34 |
+
"Turn on the light",
|
| 35 |
+
"Turn off the light",
|
| 36 |
+
"Increase volume",
|
| 37 |
+
"Decrease volume",
|
| 38 |
+
"Play music",
|
| 39 |
+
"Pause music",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
# The three primary keyword-spotting classes.
|
| 43 |
+
WAKE_LABEL = "hey_android"
|
| 44 |
+
UNKNOWN_LABEL = "unknown"
|
| 45 |
+
NOISE_LABEL = "background_noise"
|
| 46 |
+
|
| 47 |
+
NOISE_TYPES: List[str] = ["white", "pink", "brown", "hum", "fan", "cafe", "street"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# --------------------------------------------------------------------------- #
|
| 51 |
+
# Piper voice catalog (free, downloaded from rhasspy/piper-voices on the Hub)
|
| 52 |
+
# --------------------------------------------------------------------------- #
|
| 53 |
+
|
| 54 |
+
@dataclass(frozen=True)
|
| 55 |
+
class PiperVoice:
|
| 56 |
+
"""A downloadable Piper voice model on the Hugging Face Hub."""
|
| 57 |
+
|
| 58 |
+
voice_id: str
|
| 59 |
+
repo_path: str # path inside rhasspy/piper-voices (without extension)
|
| 60 |
+
locale: str
|
| 61 |
+
description: str
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
PIPER_VOICES: List[PiperVoice] = [
|
| 65 |
+
PiperVoice(
|
| 66 |
+
"en_US-lessac-medium",
|
| 67 |
+
"en/en_US/lessac/medium/en_US-lessac-medium",
|
| 68 |
+
"en-US",
|
| 69 |
+
"English US",
|
| 70 |
+
),
|
| 71 |
+
PiperVoice(
|
| 72 |
+
"en_GB-alba-medium",
|
| 73 |
+
"en/en_GB/alba/medium/en_GB-alba-medium",
|
| 74 |
+
"en-GB",
|
| 75 |
+
"English GB",
|
| 76 |
+
),
|
| 77 |
+
PiperVoice(
|
| 78 |
+
"en_GB-northern_english_male-medium",
|
| 79 |
+
"en/en_GB/northern_english_male/medium/en_GB-northern_english_male-medium",
|
| 80 |
+
"en-GB",
|
| 81 |
+
"English GB Northern male",
|
| 82 |
+
),
|
| 83 |
+
PiperVoice(
|
| 84 |
+
"nl_NL-mls-medium",
|
| 85 |
+
"nl/nl_NL/mls/medium/nl_NL-mls-medium",
|
| 86 |
+
"nl-NL",
|
| 87 |
+
"Dutch NL",
|
| 88 |
+
),
|
| 89 |
+
PiperVoice(
|
| 90 |
+
"de_DE-thorsten-medium",
|
| 91 |
+
"de/de_DE/thorsten/medium/de_DE-thorsten-medium",
|
| 92 |
+
"de-DE",
|
| 93 |
+
"German",
|
| 94 |
+
),
|
| 95 |
+
PiperVoice(
|
| 96 |
+
"fr_FR-siwis-medium",
|
| 97 |
+
"fr/fr_FR/siwis/medium/fr_FR-siwis-medium",
|
| 98 |
+
"fr-FR",
|
| 99 |
+
"French",
|
| 100 |
+
),
|
| 101 |
+
PiperVoice(
|
| 102 |
+
"es_ES-sharvard-medium",
|
| 103 |
+
"es/es_ES/sharvard/medium/es_ES-sharvard-medium",
|
| 104 |
+
"es-ES",
|
| 105 |
+
"Spanish",
|
| 106 |
+
),
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# --------------------------------------------------------------------------- #
|
| 111 |
+
# Dataset generation config
|
| 112 |
+
# --------------------------------------------------------------------------- #
|
| 113 |
+
|
| 114 |
+
@dataclass
|
| 115 |
+
class DatasetConfig:
|
| 116 |
+
"""All knobs controlling a single dataset build."""
|
| 117 |
+
|
| 118 |
+
# Output
|
| 119 |
+
out_dir: str = "output"
|
| 120 |
+
dataset_name: str = "hey_android"
|
| 121 |
+
|
| 122 |
+
# Audio
|
| 123 |
+
sample_rate_hz: int = 16000
|
| 124 |
+
duration_seconds: float = 2.0
|
| 125 |
+
|
| 126 |
+
# Classes / phrases
|
| 127 |
+
wake_label: str = WAKE_LABEL
|
| 128 |
+
unknown_label: str = UNKNOWN_LABEL
|
| 129 |
+
noise_label: str = NOISE_LABEL
|
| 130 |
+
wake_phrases: List[str] = field(default_factory=lambda: list(DEFAULT_WAKE_PHRASES))
|
| 131 |
+
unknown_phrases: List[str] = field(default_factory=lambda: list(DEFAULT_UNKNOWN_PHRASES))
|
| 132 |
+
|
| 133 |
+
# Size controls
|
| 134 |
+
base_repeats_per_phrase_per_voice: int = 1
|
| 135 |
+
augmentations_per_speech_clip: int = 8
|
| 136 |
+
background_noise_samples: int = 200
|
| 137 |
+
max_piper_voices: int = 7
|
| 138 |
+
max_gcp_voices_per_locale: int = 3
|
| 139 |
+
|
| 140 |
+
# GCP voice selection
|
| 141 |
+
gcp_language_prefixes: List[str] = field(
|
| 142 |
+
default_factory=lambda: ["en", "nl", "de", "fr", "es"]
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Split
|
| 146 |
+
test_ratio: float = 0.2
|
| 147 |
+
|
| 148 |
+
# Reproducibility
|
| 149 |
+
seed: int = 1337
|
| 150 |
+
|
| 151 |
+
@property
|
| 152 |
+
def target_samples(self) -> int:
|
| 153 |
+
return int(self.sample_rate_hz * self.duration_seconds)
|
src/edge_impulse.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Upload a generated dataset directly to an Edge Impulse project.
|
| 2 |
+
|
| 3 |
+
Uses the Edge Impulse ingestion REST API with a project API key
|
| 4 |
+
(Project → Dashboard → Keys). WAV filenames use the ``label.<id>.wav``
|
| 5 |
+
convention, so Edge Impulse auto-assigns labels from the filename prefix.
|
| 6 |
+
|
| 7 |
+
Ingestion endpoints:
|
| 8 |
+
POST https://ingestion.edgeimpulse.com/api/training/files
|
| 9 |
+
POST https://ingestion.edgeimpulse.com/api/testing/files
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Callable, List, Optional
|
| 17 |
+
|
| 18 |
+
import requests
|
| 19 |
+
|
| 20 |
+
_INGESTION_URL = "https://ingestion.edgeimpulse.com/api/{category}/files"
|
| 21 |
+
_TIMEOUT = 60
|
| 22 |
+
_BATCH_SIZE = 25
|
| 23 |
+
|
| 24 |
+
ProgressFn = Callable[[str], None]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class UploadResult:
|
| 29 |
+
uploaded: int = 0
|
| 30 |
+
failed: int = 0
|
| 31 |
+
errors: List[str] = field(default_factory=list)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def verify_api_key(api_key: str) -> bool:
|
| 35 |
+
"""Return True if the ingestion API accepts the key.
|
| 36 |
+
|
| 37 |
+
A key with no files still returns a non-auth error, so we treat any
|
| 38 |
+
non-401/403 response as "key looks usable".
|
| 39 |
+
"""
|
| 40 |
+
if not api_key or not api_key.strip():
|
| 41 |
+
return False
|
| 42 |
+
try:
|
| 43 |
+
response = requests.post(
|
| 44 |
+
_INGESTION_URL.format(category="training"),
|
| 45 |
+
headers={"x-api-key": api_key.strip()},
|
| 46 |
+
timeout=_TIMEOUT,
|
| 47 |
+
)
|
| 48 |
+
except requests.RequestException:
|
| 49 |
+
return False
|
| 50 |
+
return response.status_code not in (401, 403)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _upload_batch(
|
| 54 |
+
category: str,
|
| 55 |
+
api_key: str,
|
| 56 |
+
wav_paths: List[Path],
|
| 57 |
+
allow_duplicates: bool,
|
| 58 |
+
) -> tuple[int, Optional[str]]:
|
| 59 |
+
headers = {"x-api-key": api_key}
|
| 60 |
+
if not allow_duplicates:
|
| 61 |
+
headers["x-disallow-duplicates"] = "1"
|
| 62 |
+
|
| 63 |
+
files = []
|
| 64 |
+
handles = []
|
| 65 |
+
try:
|
| 66 |
+
for path in wav_paths:
|
| 67 |
+
handle = path.open("rb")
|
| 68 |
+
handles.append(handle)
|
| 69 |
+
files.append(("data", (path.name, handle, "audio/wav")))
|
| 70 |
+
response = requests.post(
|
| 71 |
+
_INGESTION_URL.format(category=category),
|
| 72 |
+
headers=headers,
|
| 73 |
+
files=files,
|
| 74 |
+
timeout=_TIMEOUT,
|
| 75 |
+
)
|
| 76 |
+
finally:
|
| 77 |
+
for handle in handles:
|
| 78 |
+
handle.close()
|
| 79 |
+
|
| 80 |
+
if response.status_code == 200:
|
| 81 |
+
return len(wav_paths), None
|
| 82 |
+
return 0, f"HTTP {response.status_code}: {response.text[:200]}"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def upload_dataset(
|
| 86 |
+
dataset_dir: str,
|
| 87 |
+
api_key: str,
|
| 88 |
+
allow_duplicates: bool = False,
|
| 89 |
+
progress: Optional[ProgressFn] = None,
|
| 90 |
+
) -> UploadResult:
|
| 91 |
+
"""Upload the ``edge_impulse_upload/{training,testing}`` WAVs to a project."""
|
| 92 |
+
def log(message: str) -> None:
|
| 93 |
+
if progress:
|
| 94 |
+
progress(message)
|
| 95 |
+
else:
|
| 96 |
+
print(message)
|
| 97 |
+
|
| 98 |
+
api_key = (api_key or "").strip()
|
| 99 |
+
if not api_key:
|
| 100 |
+
raise ValueError("An Edge Impulse API key is required to upload.")
|
| 101 |
+
|
| 102 |
+
base = Path(dataset_dir) / "edge_impulse_upload"
|
| 103 |
+
result = UploadResult()
|
| 104 |
+
|
| 105 |
+
for src_split, category in (("training", "training"), ("testing", "testing")):
|
| 106 |
+
split_dir = base / src_split
|
| 107 |
+
if not split_dir.exists():
|
| 108 |
+
continue
|
| 109 |
+
wavs = sorted(split_dir.glob("*.wav"))
|
| 110 |
+
if not wavs:
|
| 111 |
+
continue
|
| 112 |
+
|
| 113 |
+
log(f"Uploading {len(wavs)} {category} file(s) to Edge Impulse...")
|
| 114 |
+
for start in range(0, len(wavs), _BATCH_SIZE):
|
| 115 |
+
batch = wavs[start:start + _BATCH_SIZE]
|
| 116 |
+
uploaded, error = _upload_batch(category, api_key, batch, allow_duplicates)
|
| 117 |
+
if error is None:
|
| 118 |
+
result.uploaded += uploaded
|
| 119 |
+
log(f" {category}: {result.uploaded} uploaded")
|
| 120 |
+
else:
|
| 121 |
+
result.failed += len(batch)
|
| 122 |
+
result.errors.append(f"{category} batch @ {start}: {error}")
|
| 123 |
+
log(f" WARNING: {category} batch failed: {error}")
|
| 124 |
+
|
| 125 |
+
log(f"Edge Impulse upload complete: {result.uploaded} uploaded, {result.failed} failed.")
|
| 126 |
+
return result
|
src/hf_export.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face dataset export: audio layout, metadata, card, optional push."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import csv
|
| 6 |
+
import json
|
| 7 |
+
import shutil
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
from .builder import BuildResult
|
| 12 |
+
from .config import DatasetConfig
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _dataset_card(config: DatasetConfig, result: BuildResult, repo_id: str) -> str:
|
| 16 |
+
labels_table = "\n".join(
|
| 17 |
+
f"| `{label}` | {count} |" for label, count in sorted(result.label_counts.items())
|
| 18 |
+
)
|
| 19 |
+
return f"""---
|
| 20 |
+
license: cc-by-4.0
|
| 21 |
+
pretty_name: {config.dataset_name} wake word synthetic speech dataset
|
| 22 |
+
task_categories:
|
| 23 |
+
- audio-classification
|
| 24 |
+
tags:
|
| 25 |
+
- audio
|
| 26 |
+
- speech
|
| 27 |
+
- wake-word
|
| 28 |
+
- keyword-spotting
|
| 29 |
+
- text-to-speech
|
| 30 |
+
- synthetic-data
|
| 31 |
+
- edge-impulse
|
| 32 |
+
- tinyml
|
| 33 |
+
size_categories:
|
| 34 |
+
- n<1K
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
# {config.dataset_name} — Wake Word Synthetic Speech Dataset
|
| 38 |
+
|
| 39 |
+
Synthetic, augmented audio for training a small wake-word / keyword-spotting
|
| 40 |
+
model. Generated with **{result.backend_source}** and local audio augmentation.
|
| 41 |
+
|
| 42 |
+
## Classes
|
| 43 |
+
|
| 44 |
+
| Label | Samples |
|
| 45 |
+
|---|---|
|
| 46 |
+
{labels_table}
|
| 47 |
+
|
| 48 |
+
- `{config.wake_label}` — the target wake phrase and close variants.
|
| 49 |
+
- `{config.unknown_label}` — near-miss and unrelated short phrases.
|
| 50 |
+
- `{config.noise_label}` — synthetic background noise.
|
| 51 |
+
|
| 52 |
+
## Audio Specification
|
| 53 |
+
|
| 54 |
+
| Property | Value |
|
| 55 |
+
|---|---|
|
| 56 |
+
| Format | WAV |
|
| 57 |
+
| Channels | Mono |
|
| 58 |
+
| Sample rate | {config.sample_rate_hz} Hz |
|
| 59 |
+
| Clip length | {config.duration_seconds} seconds |
|
| 60 |
+
|
| 61 |
+
## Layout
|
| 62 |
+
|
| 63 |
+
```text
|
| 64 |
+
audio/
|
| 65 |
+
train/ {config.wake_label}.<id>.wav ...
|
| 66 |
+
test/ {config.wake_label}.<id>.wav ...
|
| 67 |
+
metadata.csv
|
| 68 |
+
hf_metadata.csv
|
| 69 |
+
selected_voices.csv
|
| 70 |
+
dataset_summary.json
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
## Loading
|
| 74 |
+
|
| 75 |
+
```python
|
| 76 |
+
from datasets import load_dataset, Audio
|
| 77 |
+
|
| 78 |
+
ds = load_dataset("{repo_id}")
|
| 79 |
+
ds = ds.cast_column("audio", Audio(sampling_rate={config.sample_rate_hz}))
|
| 80 |
+
print(ds)
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
## Edge Impulse
|
| 84 |
+
|
| 85 |
+
Filenames follow the Edge Impulse label-prefix convention
|
| 86 |
+
(`{config.wake_label}.<id>.wav`) so they upload directly:
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
edge-impulse-uploader --category training audio/train/*.wav
|
| 90 |
+
edge-impulse-uploader --category testing audio/test/*.wav
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
## Limitations
|
| 94 |
+
|
| 95 |
+
Synthetic TTS is a bootstrap, not a production benchmark. Add real device
|
| 96 |
+
and environment recordings before deploying a wake-word product.
|
| 97 |
+
|
| 98 |
+
## License
|
| 99 |
+
|
| 100 |
+
CC BY 4.0. Verify that your use of the generated synthetic speech complies
|
| 101 |
+
with the terms of the voice models and tools used to create it.
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def export_hf_dataset(
|
| 106 |
+
config: DatasetConfig,
|
| 107 |
+
result: BuildResult,
|
| 108 |
+
hf_dir: str,
|
| 109 |
+
repo_id: str = "your-username/your-dataset",
|
| 110 |
+
) -> str:
|
| 111 |
+
"""Build a Hugging Face-ready folder from a completed build. Returns its path."""
|
| 112 |
+
source_dir = Path(result.out_dir)
|
| 113 |
+
hf_path = Path(hf_dir).resolve()
|
| 114 |
+
if hf_path.exists():
|
| 115 |
+
shutil.rmtree(hf_path)
|
| 116 |
+
(hf_path / "audio" / "train").mkdir(parents=True, exist_ok=True)
|
| 117 |
+
(hf_path / "audio" / "test").mkdir(parents=True, exist_ok=True)
|
| 118 |
+
|
| 119 |
+
for src_split, hf_split in (("training", "train"), ("testing", "test")):
|
| 120 |
+
src = source_dir / "edge_impulse_upload" / src_split
|
| 121 |
+
dst = hf_path / "audio" / hf_split
|
| 122 |
+
for wav in sorted(src.glob("*.wav")):
|
| 123 |
+
shutil.copy2(wav, dst / wav.name)
|
| 124 |
+
|
| 125 |
+
for name in ("metadata.csv", "selected_voices.csv", "dataset_summary.json"):
|
| 126 |
+
src = source_dir / name
|
| 127 |
+
if src.exists():
|
| 128 |
+
shutil.copy2(src, hf_path / name)
|
| 129 |
+
|
| 130 |
+
# HF metadata.csv-style index (audio path + label).
|
| 131 |
+
hf_rows: List[Dict[str, str]] = []
|
| 132 |
+
for split in ("train", "test"):
|
| 133 |
+
for wav in sorted((hf_path / "audio" / split).glob("*.wav")):
|
| 134 |
+
hf_rows.append(
|
| 135 |
+
{
|
| 136 |
+
"audio": str(wav.relative_to(hf_path)),
|
| 137 |
+
"label": wav.name.split(".")[0],
|
| 138 |
+
"split": split,
|
| 139 |
+
"filename": wav.name,
|
| 140 |
+
}
|
| 141 |
+
)
|
| 142 |
+
with (hf_path / "hf_metadata.csv").open("w", newline="", encoding="utf-8") as f:
|
| 143 |
+
writer = csv.DictWriter(f, fieldnames=["audio", "label", "split", "filename"])
|
| 144 |
+
writer.writeheader()
|
| 145 |
+
writer.writerows(hf_rows)
|
| 146 |
+
|
| 147 |
+
(hf_path / "README.md").write_text(
|
| 148 |
+
_dataset_card(config, result, repo_id), encoding="utf-8"
|
| 149 |
+
)
|
| 150 |
+
(hf_path / "hf_dataset_summary.json").write_text(
|
| 151 |
+
json.dumps({"total_wavs": len(hf_rows), "repo_id": repo_id}, indent=2),
|
| 152 |
+
encoding="utf-8",
|
| 153 |
+
)
|
| 154 |
+
return str(hf_path)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def push_to_hub(hf_dir: str, repo_id: str, token: str, private: bool = False) -> str:
|
| 158 |
+
"""Upload the HF dataset folder to the Hub. Returns the dataset URL."""
|
| 159 |
+
from huggingface_hub import HfApi
|
| 160 |
+
|
| 161 |
+
api = HfApi(token=token)
|
| 162 |
+
api.create_repo(repo_id=repo_id, repo_type="dataset", exist_ok=True, private=private)
|
| 163 |
+
api.upload_folder(
|
| 164 |
+
folder_path=hf_dir,
|
| 165 |
+
repo_id=repo_id,
|
| 166 |
+
repo_type="dataset",
|
| 167 |
+
)
|
| 168 |
+
return f"https://huggingface.co/datasets/{repo_id}"
|