RASMUS commited on
Commit
6264e00
Β·
verified Β·
1 Parent(s): 79e2b98

Add scripts/browser_pipeline_sim.py

Browse files
Files changed (1) hide show
  1. scripts/browser_pipeline_sim.py +303 -0
scripts/browser_pipeline_sim.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ browser_pipeline_sim.py
3
+
4
+ Python simulation of the browser worker.ts logic.
5
+ Mirrors the EXACT token assembly, CFG loop, sampling, and decoding
6
+ that the browser worker does β€” making it trivial to diff against worker.ts.
7
+
8
+ If this produces correct audio, worker.ts with the same logic will too.
9
+ If there's a bug in the browser, you can find it by diffing this script against worker.ts.
10
+
11
+ Usage:
12
+ cd /workspaces/work
13
+ conda run -n chatterbox-onnx python browser_pipeline_sim.py [text] [ref_audio]
14
+
15
+ Outputs:
16
+ _cmp/browser_sim_output.wav
17
+ """
18
+
19
+ import os, sys, time, json, struct, math
20
+ import numpy as np
21
+ import onnxruntime as ort
22
+ import soundfile as sf
23
+ import librosa
24
+ import requests
25
+ from pathlib import Path
26
+ from huggingface_hub import hf_hub_download
27
+
28
+ sys.path.insert(0, "Chatterbox-Finnish")
29
+
30
+ # ── Config β€” mirrors browser worker constants ─────────────────────────────────
31
+ TEXT = sys.argv[1] if len(sys.argv) > 1 else \
32
+ "Tervetuloa kokeilemaan hienoviritettyΓ€ suomenkielistΓ€ Chatterbox-puhesynteesiΓ€."
33
+ REF_AUDIO = sys.argv[2] if len(sys.argv) > 2 else \
34
+ "Chatterbox-Finnish/samples/reference_finnish.wav"
35
+
36
+ HF_BASE = "onnx-community/chatterbox-multilingual-ONNX"
37
+ HF_FI = "RASMUS/Chatterbox-Finnish-ONNX"
38
+ CACHE_DIR = "_onnx_cache"
39
+ OUT_DIR = Path("_cmp"); OUT_DIR.mkdir(exist_ok=True)
40
+
41
+ # Worker constants (from worker.ts)
42
+ SOT = 255 # [START] token
43
+ EOT = 0 # [STOP] token
44
+ START_SPEECH = 6561 # BOS speech token
45
+ STOP_SPEECH = 6562 # EOS speech token
46
+ CFG_WEIGHT = 0.5
47
+ REP_PENALTY = 1.2
48
+ TEMPERATURE = 0.8
49
+ EXAGGERATION = 0.6
50
+ MIN_SPEECH_TOKENS = 40
51
+ MAX_STEPS = 1000
52
+ SAMPLE_RATE = 24000
53
+
54
+ GROQ_KEY = os.environ.get("GROQ_API_KEY", "")
55
+
56
+ # ── puncNorm β€” mirrors worker.ts puncNorm() ───────────────────────────────────
57
+ def punc_norm(text: str) -> str:
58
+ """Mirrors puncNorm() in worker.ts"""
59
+ import re
60
+ t = text.strip()
61
+ t = re.sub(r'\s+', ' ', t) # normalize whitespace
62
+ t = t[0].upper() + t[1:] if t else t # capitalize first letter
63
+ t = t.replace(' .', '.').replace(' ,', ',') # remove space before punct
64
+ t = t.replace(' ?', '?').replace(' !', '!')
65
+ if t and t[-1] not in '.!?…': # ensure ending punctuation
66
+ t += '.'
67
+ return t
68
+
69
+
70
+ # ── EnTokenizer (same as Chatterbox-Finnish/src/chatterbox_/models/tokenizers/tokenizer.py)
71
+ def load_tokenizer(tokenizer_path: str):
72
+ from src.chatterbox_.models.tokenizers.tokenizer import EnTokenizer
73
+ return EnTokenizer(tokenizer_path)
74
+
75
+
76
+ # ── puncNorm + tokenize β†’ mirrors worker.ts tokenize() ───────────────────────
77
+ def tokenize(tok, text: str) -> list[int]:
78
+ """
79
+ Mirrors browser worker tokenize():
80
+ 1. puncNorm
81
+ 2. replace spaces with [SPACE] token
82
+ 3. encode grapheme by grapheme
83
+ 4. wrap with [SOT, ..., EOT]
84
+ """
85
+ normed = punc_norm(text)
86
+ ids = tok.encode(normed)
87
+ return [SOT] + ids + [EOT]
88
+
89
+
90
+ # ── Download helpers ───────────────────────────────────────────────────────────
91
+ def dl(repo_id, filename):
92
+ return hf_hub_download(repo_id=repo_id, filename=filename,
93
+ local_dir=CACHE_DIR, local_dir_use_symlinks=False)
94
+
95
+
96
+ # ── KV cache helpers ───────────────────────────────────────────────────────────
97
+ def empty_kv(n_layers=30, kv_dtype=np.float32):
98
+ return [(np.zeros((1, 16, 0, 64), dtype=kv_dtype),
99
+ np.zeros((1, 16, 0, 64), dtype=kv_dtype))
100
+ for _ in range(n_layers)]
101
+
102
+
103
+ def make_kv_feeds(kv_cache):
104
+ feeds = {}
105
+ for i, (k, v) in enumerate(kv_cache):
106
+ feeds[f"past_key_values.{i}.key"] = k
107
+ feeds[f"past_key_values.{i}.value"] = v
108
+ return feeds
109
+
110
+
111
+ # ── Sampling helpers ───────────────────────────────────────────────────────────
112
+ def apply_rep_penalty(logits, generated_set, penalty):
113
+ logits = logits.copy()
114
+ for tok in generated_set:
115
+ logits[tok] = logits[tok] / penalty if logits[tok] > 0 else logits[tok] * penalty
116
+ return logits
117
+
118
+
119
+ def apply_min_p(logits, p=0.05):
120
+ """Mirrors applyMinP() in worker.ts"""
121
+ logits = logits.copy()
122
+ probs = np.exp(logits - logits.max())
123
+ probs /= probs.sum()
124
+ threshold = probs.max() * p
125
+ logits[probs < threshold] = -1e9
126
+ return logits
127
+
128
+
129
+ def sample_with_temperature(logits, temperature):
130
+ """Mirrors sampleWithTemperature() in worker.ts"""
131
+ logits = (logits / temperature).astype(np.float64)
132
+ logits -= logits.max()
133
+ probs = np.exp(logits)
134
+ probs /= probs.sum()
135
+ return int(np.random.choice(len(probs), p=probs))
136
+
137
+
138
+ # ── Main pipeline ─────────────────────────────────────────────────────────────
139
+ def main():
140
+ providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
141
+ opts = ort.SessionOptions()
142
+ opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
143
+
144
+ # ── Load sessions ──
145
+ print("Loading ONNX sessions...")
146
+ sess_se = ort.InferenceSession(dl(HF_BASE, "onnx/speech_encoder.onnx"), opts, providers)
147
+ sess_et = ort.InferenceSession(dl(HF_BASE, "onnx/embed_tokens.onnx"), opts, providers)
148
+ sess_lm = ort.InferenceSession(dl(HF_FI, "onnx/language_model.onnx"), opts, providers)
149
+ sess_cd = ort.InferenceSession(dl(HF_BASE, "onnx/conditional_decoder.onnx"), opts, providers)
150
+
151
+ # ── Precomputed Finnish conditioning ──
152
+ cond_emb_raw = open(dl(HF_FI, "onnx/finnish_cond_emb.bin"), "rb").read()
153
+ cond_emb = np.frombuffer(cond_emb_raw, dtype=np.float32).reshape(1, 34, 1024)
154
+ print(f" cond_emb: {cond_emb.shape}")
155
+
156
+ # ── KV dtype from model ──
157
+ kv_input_name = "past_key_values.0.key"
158
+ kv_dtype_str = next(
159
+ (inp.type for inp in sess_lm.get_inputs() if inp.name == kv_input_name), "tensor(float)"
160
+ )
161
+ kv_dtype = np.float16 if "float16" in kv_dtype_str else np.float32
162
+ print(f" KV cache dtype: {kv_dtype}")
163
+
164
+ # ── Step 1: Reference audio β†’ speaker embeddings ──
165
+ print(f"\nStep 1: speech_encoder ({REF_AUDIO})")
166
+ ref_audio, ref_sr = librosa.load(REF_AUDIO, sr=None)
167
+ ref_16k = librosa.resample(ref_audio, orig_sr=ref_sr, target_sr=16000).astype(np.float32)
168
+ ref_input = ref_16k[np.newaxis, :] # [1, T]
169
+
170
+ se_outs = sess_se.run(None, {"audio": ref_input})
171
+ speaker_emb = se_outs[0] # [1, 256]
172
+ prompt_tokens = se_outs[1] # [1, N]
173
+ print(f" speaker_emb: {speaker_emb.shape}")
174
+ print(f" prompt_tokens: {prompt_tokens.shape}")
175
+ # Note: se_outs[2] is cond_emb from base model β€” we ignore it, use precomputed Finnish version
176
+
177
+ # ── Step 2: Tokenize text (mirrors worker.ts tokenize()) ──
178
+ print(f"\nStep 2: tokenize '{TEXT[:60]}...'")
179
+ tok = load_tokenizer("Chatterbox-Finnish/pretrained_models/tokenizer.json")
180
+ token_ids = tokenize(tok, TEXT)
181
+ print(f" token_ids ({len(token_ids)}): {token_ids[:6]}...{token_ids[-3:]}")
182
+ text_ids_np = np.array([token_ids], dtype=np.int64) # [1, T]
183
+
184
+ # ── Step 3: Embed text tokens ──
185
+ print(f"\nStep 3: embed_tokens")
186
+ text_embeds = sess_et.run(None, {"input_ids": text_ids_np})[0] # [1, T, 1024]
187
+ print(f" text_embeds: {text_embeds.shape}")
188
+
189
+ # Embed BOS speech token
190
+ bos_ids = np.array([[START_SPEECH]], dtype=np.int64)
191
+ bos_emb = sess_et.run(None, {"input_ids": bos_ids})[0] # [1, 1, 1024]
192
+
193
+ # ── Step 4: Build prefill ──
194
+ # Matches PyTorch: inputs_embeds = cat([cond_emb, text_emb, bos_emb])
195
+ prefill_cond = np.concatenate([cond_emb, text_embeds, bos_emb], axis=1)
196
+ zeros_text = np.zeros_like(text_embeds)
197
+ prefill_uncond = np.concatenate([cond_emb, zeros_text, bos_emb], axis=1)
198
+
199
+ mask_cond = np.ones((1, prefill_cond.shape[1]), dtype=np.int64)
200
+ mask_uncond = np.ones((1, prefill_uncond.shape[1]), dtype=np.int64)
201
+
202
+ kv_empty_layer = np.zeros((1, 16, 0, 64), dtype=kv_dtype)
203
+ kv_cond = [(kv_empty_layer.copy(), kv_empty_layer.copy()) for _ in range(30)]
204
+ kv_uncond = [(kv_empty_layer.copy(), kv_empty_layer.copy()) for _ in range(30)]
205
+
206
+ def lm_step(embeds, mask, kv):
207
+ feeds = {"inputs_embeds": embeds, "attention_mask": mask}
208
+ feeds.update(make_kv_feeds(kv))
209
+ outs = sess_lm.run(None, feeds)
210
+ logits = outs[0] # [1, seq, vocab]
211
+ new_kv = [(outs[1 + i*2], outs[1 + i*2 + 1]) for i in range(30)]
212
+ return logits, new_kv
213
+
214
+ # ── Step 5: Prefill both streams ──
215
+ print(f"\nStep 4: prefill ({prefill_cond.shape[1]} tokens)")
216
+ t0 = time.time()
217
+ logits_c, kv_cond = lm_step(prefill_cond, mask_cond, kv_cond)
218
+ logits_uc, kv_uncond = lm_step(prefill_uncond, mask_uncond, kv_uncond)
219
+ print(f" prefill done ({time.time()-t0:.1f}s)")
220
+
221
+ # ── Step 6: Autoregressive generation ──
222
+ print(f"\nStep 5: generate (max {MAX_STEPS} steps)")
223
+ generated = [START_SPEECH]
224
+ speech_tokens = []
225
+ t0 = time.time()
226
+
227
+ for step in range(MAX_STEPS):
228
+ last_ids = np.array([[generated[-1]]], dtype=np.int64)
229
+ last_emb = sess_et.run(None, {"input_ids": last_ids})[0] # [1, 1, 1024]
230
+
231
+ seq_len_c = kv_cond[0][0].shape[2] + 1
232
+ seq_len_uc = kv_uncond[0][0].shape[2] + 1
233
+ mask_c = np.ones((1, seq_len_c), dtype=np.int64)
234
+ mask_uc = np.ones((1, seq_len_uc), dtype=np.int64)
235
+
236
+ logits_c, kv_cond = lm_step(last_emb, mask_c, kv_cond)
237
+ logits_uc, kv_uncond = lm_step(last_emb, mask_uc, kv_uncond)
238
+
239
+ # CFG: final = cond + cfg_weight * (cond - uncond)
240
+ lc = logits_c[0, -1].astype(np.float32)
241
+ luc = logits_uc[0, -1].astype(np.float32)
242
+ final_logits = lc + CFG_WEIGHT * (lc - luc)
243
+
244
+ # Apply rep penalty + min_p + temperature sample
245
+ final_logits = apply_rep_penalty(final_logits, set(generated), REP_PENALTY)
246
+ final_logits = apply_min_p(final_logits, p=0.05)
247
+ token = sample_with_temperature(final_logits, TEMPERATURE)
248
+
249
+ if token == STOP_SPEECH and len(speech_tokens) >= MIN_SPEECH_TOKENS:
250
+ print(f" EOS at step {step} ({len(speech_tokens)} speech tokens)")
251
+ break
252
+
253
+ generated.append(token)
254
+ if token < START_SPEECH:
255
+ speech_tokens.append(token)
256
+
257
+ if (step + 1) % 100 == 0:
258
+ elapsed = time.time() - t0
259
+ rate = (step + 1) / elapsed
260
+ print(f" step {step+1:4d}: {len(speech_tokens):3d} speech tokens ({rate:.1f} tok/s)")
261
+
262
+ elapsed = time.time() - t0
263
+ print(f" generation done: {len(speech_tokens)} speech tokens in {elapsed:.1f}s")
264
+
265
+ # ── Step 7: Decode β†’ waveform ──
266
+ print(f"\nStep 6: conditional_decoder")
267
+ speech_tok_arr = np.array([speech_tokens], dtype=np.int64)
268
+ cd_out = sess_cd.run(None, {
269
+ "speech_tokens": speech_tok_arr,
270
+ "speaker_embeddings": speaker_emb,
271
+ })
272
+ wav = cd_out[0].squeeze().astype(np.float32)
273
+
274
+ # Normalize (mirrors browser worker)
275
+ peak = np.abs(wav).max()
276
+ if peak < 0.01:
277
+ print(f" warning: very low amplitude (peak={peak:.4f}), auto-normalizing")
278
+ wav = wav * (0.9 / peak)
279
+ wav = np.clip(wav, -1.0, 1.0)
280
+
281
+ out_path = str(OUT_DIR / "browser_sim_output.wav")
282
+ sf.write(out_path, wav, SAMPLE_RATE)
283
+ print(f"\nSaved: {out_path} ({len(wav)/SAMPLE_RATE:.2f}s, peak={np.abs(wav).max():.4f})")
284
+
285
+ # ── Transcribe ──
286
+ if GROQ_KEY:
287
+ print("\nTranscribing with Groq Whisper...")
288
+ with open(out_path, "rb") as f:
289
+ r = requests.post(
290
+ "https://api.groq.com/openai/v1/audio/transcriptions",
291
+ headers={"Authorization": f"Bearer {GROQ_KEY}"},
292
+ files={"file": (os.path.basename(out_path), f, "audio/wav")},
293
+ data={"model": "whisper-large-v3", "language": "fi", "response_format": "text"},
294
+ )
295
+ if r.ok:
296
+ print(f"\nTranscript: '{r.text.strip()}'")
297
+ print(f"Target text: '{TEXT}'")
298
+ else:
299
+ print(f" Groq error: {r.status_code} {r.text}")
300
+
301
+
302
+ if __name__ == "__main__":
303
+ main()