eoinedge commited on
Commit
5838d6a
·
verified ·
1 Parent(s): f071b6c

Upload folder using huggingface_hub

Browse files
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}"