sobonphon commited on
Commit
13ff032
Β·
verified Β·
1 Parent(s): 0f5f0ce

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +24 -5
  2. app.py +344 -0
  3. requirements.txt +6 -0
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: Tool ASR Test
3
- emoji: 🐠
4
- colorFrom: indigo
5
- colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.22.0
8
  python_version: '3.12'
@@ -10,4 +10,23 @@ app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Tool ASR Generate
3
+ emoji: 🌍
4
+ colorFrom: purple
5
+ colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.22.0
8
  python_version: '3.12'
 
10
  pinned: false
11
  ---
12
 
13
+ # Tool ASR Generate
14
+
15
+ Batch text-to-speech using [openbmb/VoxCPM2](https://huggingface.co/openbmb/VoxCPM2) on a **CPU-only** Space,
16
+ focused on **πŸ‡°πŸ‡­ Khmer** and **πŸ‡¬πŸ‡§ English**.
17
+
18
+ Upload a CSV / JSON / XLSX file with a `text` column, set an optional reference voice and
19
+ generation settings in the sidebar, click **Generate All**, and download a ZIP with every
20
+ generated `.wav` plus a `metadata.csv` / `metadata.json` manifest β€” ready to push to a
21
+ Hugging Face Dataset repo. A preview of the first 10 generated rows (text + playable audio)
22
+ shows up right after generation.
23
+
24
+ Each row is auto-detected as English or Khmer (from the script), or set explicitly with a
25
+ `language` column β€” this is only used to label the output metadata, generation itself uses
26
+ one shared reference voice for every row.
27
+
28
+ ⚠️ This model has 2B parameters. On CPU hardware generation is much slower than on GPU
29
+ (roughly tens of seconds per sentence, depending on the Space's CPU) β€” plan batch sizes
30
+ accordingly, or upgrade the Space's hardware tier if you need faster throughput.
31
+
32
+ See `sample_input.csv` for the expected input format (includes example Khmer and English rows).
app.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tool ASR Generate - Batch Text-to-Speech with VoxCPM2 (CPU Space)
3
+ Focused on Khmer (km) and English (en).
4
+
5
+ Upload a CSV / JSON / XLSX file containing a "text" column, and this app will
6
+ generate speech for every row using openbmb/VoxCPM2, then package all the
7
+ generated audio + a metadata.csv/json manifest into a downloadable ZIP that is
8
+ already structured the way Hugging Face `datasets` expects an audio folder
9
+ (audio/<file>.wav + metadata.csv with a "file_name" column), so you can push
10
+ it straight to a Dataset repo.
11
+
12
+ This version runs entirely on CPU (no ZeroGPU / spaces.GPU dependency), so it
13
+ works on a plain CPU Space. VoxCPM2 is a 2B-parameter model, so CPU inference
14
+ is significantly slower than GPU β€” expect roughly tens of seconds per
15
+ sentence depending on the Space's CPU hardware. For large batches, consider
16
+ running fewer, shorter texts per run.
17
+
18
+ Language handling:
19
+ - VoxCPM2 auto-detects language from the text itself (no language tag
20
+ needed). Each row is still labeled "km" or "en" in the output metadata
21
+ (either from an optional "language" column, or auto-detected from the
22
+ Khmer Unicode script range), which is useful for filtering the dataset
23
+ later, but generation itself uses a single shared reference voice.
24
+ - The sidebar lets you upload one optional reference voice clip (+
25
+ transcript) used for cloning across every row, regardless of language.
26
+
27
+ Input file requirements:
28
+ - text (required) the sentence/paragraph to synthesize
29
+ - id (optional) used as the output filename, auto-generated if missing
30
+ - language (optional) "en" or "km" β€” auto-detected if omitted
31
+ - voice_description (optional) natural language voice style, e.g. "a calm young woman"
32
+ (will be prepended as "(voice_description)text",
33
+ overrides the reference-audio cloning for that row)
34
+ """
35
+
36
+ import os
37
+ import io
38
+ import json
39
+ import uuid
40
+ import zipfile
41
+ import tempfile
42
+
43
+ import numpy as np
44
+ import pandas as pd
45
+ import soundfile as sf
46
+ import gradio as gr
47
+ import torch
48
+
49
+ from voxcpm import VoxCPM
50
+
51
+ MODEL_ID = "openbmb/VoxCPM2"
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Model loading (CPU only)
55
+ # ---------------------------------------------------------------------------
56
+ # Force CPU execution β€” no CUDA / ZeroGPU involved.
57
+ torch.set_num_threads(os.cpu_count() or 4)
58
+
59
+ print(f"Loading {MODEL_ID} on CPU ...")
60
+ model = VoxCPM.from_pretrained(MODEL_ID, load_denoiser=False)
61
+ # Make sure every submodule actually sits on CPU (in case the library
62
+ # defaults to auto-detecting CUDA when available).
63
+ try:
64
+ model.tts_model.to("cpu")
65
+ except Exception:
66
+ pass
67
+ SAMPLE_RATE = getattr(model.tts_model, "sample_rate", 48000)
68
+ print(f"Model loaded on CPU. Sample rate = {SAMPLE_RATE}")
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Language detection (Khmer vs English)
73
+ # ---------------------------------------------------------------------------
74
+ KHMER_RANGE = (0x1780, 0x17FF) # Khmer Unicode block
75
+
76
+
77
+ def detect_language(text: str) -> str:
78
+ for ch in str(text):
79
+ if KHMER_RANGE[0] <= ord(ch) <= KHMER_RANGE[1]:
80
+ return "km"
81
+ return "en"
82
+
83
+
84
+ def normalize_language(value: str) -> str:
85
+ v = str(value).strip().lower()
86
+ if v in ("km", "kh", "khm", "khmer", "cambodian"):
87
+ return "km"
88
+ if v in ("en", "eng", "english"):
89
+ return "en"
90
+ return "" # unrecognized -> fall back to auto-detect
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # File parsing helpers
95
+ # ---------------------------------------------------------------------------
96
+ def load_table(file_path: str) -> pd.DataFrame:
97
+ ext = os.path.splitext(file_path)[1].lower()
98
+
99
+ if ext == ".csv":
100
+ df = pd.read_csv(file_path)
101
+ elif ext in (".xlsx", ".xls"):
102
+ df = pd.read_excel(file_path)
103
+ elif ext == ".json":
104
+ with open(file_path, "r", encoding="utf-8") as f:
105
+ data = json.load(f)
106
+ if isinstance(data, dict):
107
+ for key in ("items", "data", "rows"):
108
+ if key in data and isinstance(data[key], list):
109
+ data = data[key]
110
+ break
111
+ df = pd.DataFrame(data)
112
+ else:
113
+ raise ValueError(f"Unsupported file type: {ext}. Use .csv, .json, or .xlsx")
114
+
115
+ df.columns = [str(c).strip().lower() for c in df.columns]
116
+
117
+ if "text" not in df.columns:
118
+ for alt in ("content", "sentence", "script", "transcript"):
119
+ if alt in df.columns:
120
+ df = df.rename(columns={alt: "text"})
121
+ break
122
+
123
+ if "text" not in df.columns:
124
+ raise ValueError("No 'text' column found in the uploaded file.")
125
+
126
+ if "id" not in df.columns:
127
+ df.insert(0, "id", [f"{i + 1:04d}" for i in range(len(df))])
128
+ else:
129
+ df["id"] = df["id"].astype(str)
130
+
131
+ if "voice_description" not in df.columns:
132
+ df["voice_description"] = ""
133
+ df["voice_description"] = df["voice_description"].fillna("").astype(str)
134
+
135
+ df["text"] = df["text"].astype(str)
136
+ df = df[df["text"].str.strip() != ""].reset_index(drop=True)
137
+
138
+ if "language" in df.columns:
139
+ df["language"] = df["language"].fillna("").apply(normalize_language)
140
+ else:
141
+ df["language"] = ""
142
+ # fill anything unrecognized/empty with auto-detection from the text script
143
+ needs_detect = df["language"] == ""
144
+ df.loc[needs_detect, "language"] = df.loc[needs_detect, "text"].apply(detect_language)
145
+
146
+ return df
147
+
148
+
149
+ def preview_file(file):
150
+ if file is None:
151
+ return None, "Upload a CSV, JSON, or XLSX file with a `text` column."
152
+ try:
153
+ df = load_table(file)
154
+ except Exception as e:
155
+ return None, f"❌ Error reading file: {e}"
156
+ preview = df[["id", "language", "text", "voice_description"]].head(20)
157
+ n_en = int((df["language"] == "en").sum())
158
+ n_km = int((df["language"] == "km").sum())
159
+ return preview, f"βœ… Loaded **{len(df)}** rows β€” English: {n_en}, Khmer: {n_km}. Showing first {min(20, len(df))}."
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Single-item generation (runs on CPU)
164
+ # ---------------------------------------------------------------------------
165
+ def _generate_one(text, voice_description, ref_wav_path, ref_text, cfg_value, inference_timesteps):
166
+ prompt = text
167
+ if voice_description and voice_description.strip():
168
+ prompt = f"({voice_description.strip()}){text}"
169
+
170
+ kwargs = dict(
171
+ text=prompt,
172
+ cfg_value=float(cfg_value),
173
+ inference_timesteps=int(inference_timesteps),
174
+ )
175
+
176
+ # voice_description (Voice Design) and reference-audio cloning are
177
+ # alternative modes β€” only clone when no per-row description was given.
178
+ if ref_wav_path and not (voice_description and voice_description.strip()):
179
+ kwargs["reference_wav_path"] = ref_wav_path
180
+ if ref_text and ref_text.strip():
181
+ # transcript + reference wav given -> ultimate cloning mode
182
+ kwargs["prompt_wav_path"] = ref_wav_path
183
+ kwargs["prompt_text"] = ref_text.strip()
184
+
185
+ with torch.no_grad():
186
+ wav = model.generate(**kwargs)
187
+ return np.asarray(wav)
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Batch orchestration (sequential, CPU)
192
+ # ---------------------------------------------------------------------------
193
+ N_SAMPLES = 10 # how many generated rows to preview inline after a run
194
+
195
+
196
+ def run_batch(
197
+ file,
198
+ ref_audio,
199
+ ref_text,
200
+ cfg_value,
201
+ inference_timesteps,
202
+ progress=gr.Progress(),
203
+ ):
204
+ if file is None:
205
+ raise gr.Error("Please upload a text file (CSV / JSON / XLSX) first.")
206
+
207
+ df = load_table(file)
208
+ if len(df) == 0:
209
+ raise gr.Error("No valid text rows found in the uploaded file.")
210
+
211
+ work_dir = tempfile.mkdtemp(prefix="voxcpm_batch_")
212
+ audio_dir = os.path.join(work_dir, "audio")
213
+ os.makedirs(audio_dir, exist_ok=True)
214
+
215
+ records = []
216
+ errors = []
217
+ sample_pairs = [] # [(text, wav_path), ...] for the first N_SAMPLES successes
218
+
219
+ rows = list(df.iterrows())
220
+ for _, row in progress.tqdm(rows, desc="Generating speech"):
221
+ rid = str(row["id"])
222
+ text = row["text"]
223
+ lang = row["language"] # "en" or "km", kept for metadata/labeling only
224
+ vdesc = row.get("voice_description", "")
225
+ fname = f"{rid}.wav"
226
+ fpath = os.path.join(audio_dir, fname)
227
+
228
+ try:
229
+ wav = _generate_one(text, vdesc, ref_audio, ref_text, cfg_value, inference_timesteps)
230
+ sf.write(fpath, wav, SAMPLE_RATE)
231
+ duration = float(len(wav)) / SAMPLE_RATE
232
+ records.append(
233
+ {
234
+ "file_name": f"audio/{fname}",
235
+ "id": rid,
236
+ "language": lang,
237
+ "text": text,
238
+ "voice_description": vdesc,
239
+ "duration_sec": round(duration, 3),
240
+ }
241
+ )
242
+ if len(sample_pairs) < N_SAMPLES:
243
+ sample_pairs.append((text, fpath))
244
+ except Exception as e:
245
+ errors.append(f"{rid} ({lang}): {e}")
246
+
247
+ meta_df = pd.DataFrame(records)
248
+ meta_csv_path = os.path.join(work_dir, "metadata.csv")
249
+ meta_df.to_csv(meta_csv_path, index=False, encoding="utf-8")
250
+
251
+ meta_json_path = os.path.join(work_dir, "metadata.json")
252
+ meta_df.to_json(meta_json_path, orient="records", force_ascii=False, indent=2)
253
+
254
+ zip_path = os.path.join(tempfile.gettempdir(), f"voxcpm_output_{uuid.uuid4().hex[:8]}.zip")
255
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
256
+ for root, _, files in os.walk(work_dir):
257
+ for fn in files:
258
+ full = os.path.join(root, fn)
259
+ arc = os.path.relpath(full, work_dir)
260
+ zf.write(full, arc)
261
+
262
+ status = f"βœ… Done: **{len(records)}** file(s) generated."
263
+ if errors:
264
+ status += f"\n\n⚠️ {len(errors)} failed:\n" + "\n".join(f"- {e}" for e in errors[:15])
265
+
266
+ # build fixed-length update lists for the N_SAMPLES (text, audio) preview slots
267
+ sample_updates = []
268
+ for i in range(N_SAMPLES):
269
+ if i < len(sample_pairs):
270
+ t, wav_path = sample_pairs[i]
271
+ sample_updates.append(gr.update(value=t, visible=True))
272
+ sample_updates.append(gr.update(value=wav_path, visible=True))
273
+ else:
274
+ sample_updates.append(gr.update(value="", visible=False))
275
+ sample_updates.append(gr.update(value=None, visible=False))
276
+
277
+ return [zip_path, meta_df, status] + sample_updates
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # UI
282
+ # ---------------------------------------------------------------------------
283
+ with gr.Blocks(title="Tool ASR Generate") as demo:
284
+
285
+ with gr.Sidebar():
286
+ gr.Markdown("## πŸŽ™οΈ Reference Voice")
287
+ gr.Markdown(
288
+ "Optional. Upload a short clip to clone that voice for every row "
289
+ "(works for both English and Khmer text). A per-row "
290
+ "`voice_description` in your file overrides this for that row."
291
+ )
292
+ ref_audio = gr.Audio(label="Reference clip", type="filepath")
293
+ ref_text = gr.Textbox(
294
+ label="Reference transcript (optional, improves cloning quality)",
295
+ placeholder="Exact transcript of the reference clip above",
296
+ lines=3,
297
+ )
298
+ gr.Markdown("## βš™οΈ Generation settings")
299
+ cfg_value = gr.Slider(0.5, 4.0, value=2.0, step=0.1, label="CFG value")
300
+ inference_timesteps = gr.Slider(4, 30, value=10, step=1, label="Inference timesteps")
301
+
302
+ gr.Markdown(
303
+ """
304
+ # 🌍 Tool ASR Generate β€” Batch Text-to-Speech (VoxCPM2, CPU)
305
+ ### Focused on πŸ‡°πŸ‡­ Khmer and πŸ‡¬πŸ‡§ English
306
+ Upload a **CSV / JSON / XLSX** file with a `text` column and generate speech for
307
+ every row using [openbmb/VoxCPM2](https://huggingface.co/openbmb/VoxCPM2).
308
+ Set the reference voice and generation settings in the sidebar. When it's done,
309
+ download a ZIP with all the audio + a `metadata.csv` / `metadata.json` manifest,
310
+ ready to push to a Hugging Face Dataset repo.
311
+ """
312
+ )
313
+
314
+ file_in = gr.File(
315
+ label="Upload text file (.csv / .json / .xlsx)",
316
+ file_types=[".csv", ".json", ".xlsx", ".xls"],
317
+ )
318
+
319
+ preview_df = gr.Dataframe(label="Preview (first 20 rows)", interactive=False)
320
+ preview_status = gr.Markdown()
321
+ file_in.change(preview_file, inputs=file_in, outputs=[preview_df, preview_status])
322
+
323
+ generate_btn = gr.Button("πŸš€ Generate All", variant="primary")
324
+
325
+ result_zip = gr.File(label="Download ZIP (audio/ + metadata.csv + metadata.json)")
326
+ result_meta = gr.Dataframe(label="Generated metadata")
327
+ run_status = gr.Markdown()
328
+
329
+ gr.Markdown("### πŸ”Š Sample preview (first 10 generated rows)")
330
+ sample_components = []
331
+ for i in range(N_SAMPLES):
332
+ with gr.Row():
333
+ t = gr.Textbox(label=f"Text {i + 1}", interactive=False, scale=3, visible=False)
334
+ a = gr.Audio(label=f"Audio {i + 1}", interactive=False, scale=2, visible=False)
335
+ sample_components.append(t)
336
+ sample_components.append(a)
337
+
338
+ generate_btn.click(
339
+ run_batch,
340
+ inputs=[file_in, ref_audio, ref_text, cfg_value, inference_timesteps],
341
+ outputs=[result_zip, result_meta, run_status] + sample_components,
342
+ )
343
+
344
+ demo.queue(max_size=10, default_concurrency_limit=1).launch()
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ voxcpm
2
+ soundfile
3
+ pandas
4
+ numpy
5
+ openpyxl
6
+ huggingface_hub