multimodalart HF Staff commited on
Commit
85a30fe
·
verified ·
1 Parent(s): 69dedb2

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +23 -7
  2. app.py +241 -0
  3. demo/ref_en.json +1 -0
  4. demo/ref_en.mp3 +0 -0
  5. omnivoice/__init__.py +34 -0
  6. omnivoice/cli/__init__.py +0 -0
  7. omnivoice/cli/demo.py +538 -0
  8. omnivoice/cli/infer.py +149 -0
  9. omnivoice/cli/infer_batch.py +552 -0
  10. omnivoice/cli/train.py +74 -0
  11. omnivoice/data/__init__.py +0 -0
  12. omnivoice/data/batching.py +190 -0
  13. omnivoice/data/collator.py +166 -0
  14. omnivoice/data/dataset.py +540 -0
  15. omnivoice/data/processor.py +257 -0
  16. omnivoice/data/word_control.py +188 -0
  17. omnivoice/eval/__init__.py +4 -0
  18. omnivoice/eval/models/ecapa_tdnn_wavlm.py +373 -0
  19. omnivoice/eval/models/utmos.py +369 -0
  20. omnivoice/eval/mos/utmos.py +306 -0
  21. omnivoice/eval/speaker_similarity/sim.py +324 -0
  22. omnivoice/eval/utils.py +82 -0
  23. omnivoice/eval/wer/common.py +89 -0
  24. omnivoice/eval/wer/fleurs.py +517 -0
  25. omnivoice/eval/wer/hubert.py +316 -0
  26. omnivoice/eval/wer/minimax.py +596 -0
  27. omnivoice/eval/wer/norm_config_module.py +291 -0
  28. omnivoice/eval/wer/punctuations.lst +188 -0
  29. omnivoice/eval/wer/seedtts.py +411 -0
  30. omnivoice/eval/wer/sensevoice.py +343 -0
  31. omnivoice/eval/wer/text_norm_omni.py +112 -0
  32. omnivoice/models/__init__.py +0 -0
  33. omnivoice/models/omnivoice.py +1727 -0
  34. omnivoice/scripts/__init__.py +0 -0
  35. omnivoice/scripts/denoise_audio.py +1049 -0
  36. omnivoice/scripts/extract_audio_tokens.py +625 -0
  37. omnivoice/scripts/extract_audio_tokens_add_noise.py +823 -0
  38. omnivoice/scripts/jsonl_to_webdataset.py +444 -0
  39. omnivoice/training/__init__.py +0 -0
  40. omnivoice/training/builder.py +248 -0
  41. omnivoice/training/checkpoint.py +180 -0
  42. omnivoice/training/config.py +110 -0
  43. omnivoice/training/trainer.py +355 -0
  44. omnivoice/utils/__init__.py +0 -0
  45. omnivoice/utils/audio.py +343 -0
  46. omnivoice/utils/common.py +78 -0
  47. omnivoice/utils/data_utils.py +68 -0
  48. omnivoice/utils/duration.py +282 -0
  49. omnivoice/utils/lang_map.py +698 -0
  50. omnivoice/utils/text.py +429 -0
README.md CHANGED
@@ -1,13 +1,29 @@
1
  ---
2
- title: Omnivoice Word Control
3
- emoji: 📚
4
- colorFrom: gray
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.13'
9
  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: OmniVoice Word-Control
3
+ emoji: 🎛️
4
+ colorFrom: indigo
5
+ colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 6.10.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: cc-by-nc-4.0
11
+ short_description: Voice cloning with word-level prosody control (WordVoice-5A)
12
+ models:
13
+ - multimodalart/omnivoice-word-control
14
+ - k2-fsa/OmniVoice
15
  ---
16
 
17
+ # OmniVoice Word-Control
18
+
19
+ Zero-shot voice cloning with explicit **word-level control** over duration, boundary/pauses,
20
+ energy, pitch, and tone contour — the [WordVoice](https://huggingface.co/papers/2607.06461)
21
+ task realized on [OmniVoice](https://huggingface.co/k2-fsa/OmniVoice)'s masked-diffusion LM
22
+ via inline control tokens, fine-tuned on
23
+ [WordVoice-5A](https://huggingface.co/datasets/XXH333/WordVoice-5A) (English, ~2,138h).
24
+
25
+ UI and inline-tag syntax adapted from
26
+ [hugging-apps/wordvoice-tts](https://huggingface.co/spaces/hugging-apps/wordvoice-tts);
27
+ generation flow adapted from the official
28
+ [k2-fsa/OmniVoice](https://huggingface.co/spaces/k2-fsa/OmniVoice) Space.
29
+ Example reference clip from the WordVoice-5A test split (CC-BY-4.0).
app.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """OmniVoice Word-Control — ZeroGPU Space.
3
+
4
+ Voice cloning with explicit word-level acoustic control (WordVoice-5A style:
5
+ duration / boundary / energy / pitch / tone), on an OmniVoice checkpoint
6
+ fine-tuned with inline control tokens.
7
+
8
+ UI + inline-tag syntax adapted from hugging-apps/wordvoice-tts; model loading
9
+ and generation flow adapted from the official k2-fsa/OmniVoice Space.
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ import re
16
+
17
+ logging.basicConfig(level=logging.INFO)
18
+
19
+ import numpy as np
20
+ import spaces
21
+ import torch
22
+ import gradio as gr
23
+
24
+ from omnivoice import OmniVoice, OmniVoiceGenerationConfig
25
+ from omnivoice.data.word_control import (
26
+ BND_CLASSES,
27
+ TONE_CLASSES,
28
+ dur_bin,
29
+ eng_bin,
30
+ pit_bin,
31
+ )
32
+
33
+ CHECKPOINT = os.environ.get("OMNIVOICE_MODEL", "multimodalart/omnivoice-word-control")
34
+
35
+ print(f"Loading model from {CHECKPOINT} ...")
36
+ model = OmniVoice.from_pretrained(
37
+ CHECKPOINT,
38
+ device_map="cuda",
39
+ dtype=torch.float16,
40
+ load_asr=True, # auto-transcribes the reference clip when no transcript given
41
+ )
42
+ sampling_rate = model.sampling_rate
43
+ print("Model loaded successfully!")
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Inline tag parsing: word[pit:0.8][dur:400] -> <|pit_18|><|dur_9|>word
47
+ # ---------------------------------------------------------------------------
48
+ TAG_RE = re.compile(r"\[(dur|bnd|eng|pit|ton)\s*:\s*([-+]?\d+(?:\.\d+)?|[A-Za-z]\w*)\]")
49
+
50
+
51
+ def _tags_to_tokens(tags):
52
+ """Map user-facing tag values to the checkpoint's control tokens
53
+ (fixed training order: dur, bnd, eng, pit, ton)."""
54
+ toks = []
55
+ if "dur" in tags: # milliseconds
56
+ toks.append(f"<|dur_{dur_bin(float(tags['dur']) / 1000.0)}|>")
57
+ if "bnd" in tags and tags["bnd"] in BND_CLASSES:
58
+ toks.append(f"<|bnd_{BND_CLASSES.index(tags['bnd'])}|>")
59
+ if "eng" in tags: # 0..1
60
+ toks.append(f"<|eng_{eng_bin(float(tags['eng']))}|>")
61
+ if "pit" in tags: # -1..1
62
+ toks.append(f"<|pit_{pit_bin(float(tags['pit']))}|>")
63
+ if "ton" in tags and tags["ton"] in TONE_CLASSES:
64
+ toks.append(f"<|ton_{tags['ton']}|>")
65
+ return "".join(toks)
66
+
67
+
68
+ def parse_control_text(text):
69
+ """Convert `word[tag:val]...` syntax into control-token-annotated text.
70
+
71
+ Returns (model_text, n_tagged_words, table_rows).
72
+ """
73
+ out, rows, n_tagged = [], [], 0
74
+ for token in text.split():
75
+ found = {m.group(1): m.group(2) for m in TAG_RE.finditer(token)}
76
+ word = TAG_RE.sub("", token)
77
+ if found:
78
+ n_tagged += 1
79
+ prefix = _tags_to_tokens(found)
80
+ out.append(prefix + word)
81
+ rows.append((word, found, prefix))
82
+ else:
83
+ out.append(word)
84
+ return " ".join(out), n_tagged, rows
85
+
86
+
87
+ def _fmt_plan(rows):
88
+ if not rows:
89
+ return "No control tags — the model plans all prosody freely."
90
+ md = "| word | requested | control tokens |\n|---|---|---|\n"
91
+ for word, found, prefix in rows:
92
+ req = ", ".join(f"{k}={v}" for k, v in found.items())
93
+ md += f"| {word} | {req} | `{prefix}` |\n"
94
+ return md
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Generation
99
+ # ---------------------------------------------------------------------------
100
+ def _gen_core(
101
+ ref_audio,
102
+ ref_text,
103
+ text,
104
+ num_step,
105
+ guidance_scale,
106
+ speed,
107
+ duration,
108
+ ):
109
+ if not text or not text.strip():
110
+ return None, "Please enter the text to synthesize."
111
+ if not ref_audio:
112
+ return None, "Please upload or record a reference audio clip."
113
+
114
+ model_text, n_tagged, rows = parse_control_text(text.strip())
115
+
116
+ gen_config = OmniVoiceGenerationConfig(
117
+ num_step=int(num_step or 32),
118
+ guidance_scale=float(guidance_scale) if guidance_scale is not None else 2.0,
119
+ )
120
+
121
+ kw = dict(text=model_text, language="en", generation_config=gen_config)
122
+ if speed is not None and float(speed) != 1.0:
123
+ kw["speed"] = float(speed)
124
+ if duration is not None and float(duration) > 0:
125
+ kw["duration"] = float(duration)
126
+
127
+ kw["voice_clone_prompt"] = model.create_voice_clone_prompt(
128
+ ref_audio=ref_audio,
129
+ ref_text=ref_text.strip() if ref_text and ref_text.strip() else None,
130
+ )
131
+
132
+ try:
133
+ audio = model.generate(**kw)
134
+ except Exception as e: # noqa: BLE001
135
+ return None, f"Error: {type(e).__name__}: {e}"
136
+
137
+ waveform = (np.clip(audio[0], -1.0, 1.0) * 32767).astype(np.int16)
138
+ info = f"Done — {len(waveform) / sampling_rate:.1f}s generated, {n_tagged} word(s) controlled.\n\n"
139
+ return (sampling_rate, waveform), info + _fmt_plan(rows)
140
+
141
+
142
+ @spaces.GPU(duration=90)
143
+ def generate_fn(*args):
144
+ return _gen_core(*args)
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # UI
149
+ # ---------------------------------------------------------------------------
150
+ CSS = "#col-container { max-width: 1100px; margin: 0 auto; }"
151
+
152
+ DESCRIPTION = """# 🎛️ OmniVoice Word-Control — voice cloning with word-level prosody control
153
+
154
+ Zero-shot **voice cloning** with *explicit, decoupled word-level control* over five acoustic
155
+ dimensions, à la [WordVoice](https://huggingface.co/papers/2607.06461) — but built on
156
+ [OmniVoice](https://huggingface.co/k2-fsa/OmniVoice) (masked-diffusion LM TTS), fine-tuned on
157
+ [WordVoice-5A](https://huggingface.co/datasets/XXH333/WordVoice-5A) with inline control tokens.
158
+
159
+ Upload a reference clip (+ optional transcript — auto-transcribed if empty), write your text,
160
+ and attach **inline tags** to any word to steer its prosody. Untagged words are planned freely.
161
+
162
+ [Model](https://huggingface.co/multimodalart/omnivoice-word-control) · fine-tune of k2-fsa/OmniVoice
163
+ """
164
+
165
+ CONTROL_HELP = """
166
+ ### Inline control tags
167
+ Attach one or more tags immediately after a word — e.g. `crazy[eng:0.9][dur:400]` or
168
+ `never[pit:0][ton:ffall]`.
169
+
170
+ | Tag | Meaning | Range |
171
+ |-----|---------|-------|
172
+ | `[dur:N]` | word duration | milliseconds (40ms steps, 40–2560) |
173
+ | `[eng:x]` | energy / loudness | `0`–`1` |
174
+ | `[pit:x]` | pitch (core F0) | `-1`–`1` |
175
+ | `[bnd:b]` | pause after word | `b0` (none) … `b4` (long) |
176
+ | `[ton:t]` | pitch contour | `flat, rise, rrise, fall, ffall, peak, valley` |
177
+
178
+ Tags are converted to the checkpoint's control tokens (shown in the output table).
179
+ Anything you leave untagged is planned by the model. Tip: for strict overall timing,
180
+ also set the *total duration* slider.
181
+ """
182
+
183
+ REF_META = json.load(open(os.path.join(os.path.dirname(__file__), "demo", "ref_en.json")))
184
+ REF_WAV = os.path.join(os.path.dirname(__file__), "demo", "ref_en.mp3")
185
+
186
+ EXAMPLES = [
187
+ [REF_WAV, REF_META["text"],
188
+ "I will never[pit:-0.6][ton:ffall] agree to this[bnd:b4], are[pit:0.4] you crazy[eng:0.9][dur:520]?"],
189
+ [REF_WAV, REF_META["text"],
190
+ "The quiet[eng:0.2] river drifted slowly[dur:700][ton:fall] under the old stone bridge."],
191
+ [REF_WAV, REF_META["text"],
192
+ "This is a zero shot text to speech tool with explicit word level control."],
193
+ ]
194
+
195
+ with gr.Blocks() as demo:
196
+ with gr.Column(elem_id="col-container"):
197
+ gr.Markdown(DESCRIPTION)
198
+ with gr.Row():
199
+ with gr.Column():
200
+ ref_audio = gr.Audio(
201
+ sources=["upload", "microphone"], type="filepath",
202
+ label="Reference audio (≤ ~20s)",
203
+ value=REF_WAV,
204
+ )
205
+ ref_text = gr.Textbox(
206
+ label="Reference transcript (optional — auto-transcribed if empty)",
207
+ value=REF_META["text"], lines=2,
208
+ )
209
+ text = gr.Textbox(
210
+ label="Text to synthesize (attach inline control tags — see reference below)",
211
+ value=EXAMPLES[0][2], lines=3,
212
+ )
213
+ run = gr.Button("Synthesize", variant="primary")
214
+ with gr.Accordion("Advanced", open=False):
215
+ num_step = gr.Slider(4, 64, value=32, step=1, label="Diffusion steps")
216
+ guidance_scale = gr.Slider(1.0, 6.0, value=2.0, step=0.1, label="Guidance scale")
217
+ speed = gr.Slider(0.5, 2.0, value=1.0, step=0.05, label="Speed")
218
+ duration = gr.Slider(
219
+ 0, 30, value=0, step=0.5,
220
+ label="Total duration (s) — 0 = auto (set for strict timing with dur tags)",
221
+ )
222
+ with gr.Column():
223
+ audio_out = gr.Audio(label="Synthesized audio", autoplay=True)
224
+ info_out = gr.Markdown(label="Control plan")
225
+ with gr.Accordion("Control-tag reference", open=False):
226
+ gr.Markdown(CONTROL_HELP)
227
+
228
+ gr.Examples(
229
+ examples=EXAMPLES,
230
+ inputs=[ref_audio, ref_text, text],
231
+ )
232
+
233
+ run.click(
234
+ generate_fn,
235
+ inputs=[ref_audio, ref_text, text, num_step, guidance_scale, speed, duration],
236
+ outputs=[audio_out, info_out],
237
+ api_name="synthesize",
238
+ )
239
+
240
+ if __name__ == "__main__":
241
+ demo.queue(default_concurrency_limit=2).launch(css=CSS, mcp_server=True)
demo/ref_en.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"text": "Well, here Frost seems to be spelling things out, making a declaration, making a statement.", "utt": "en_EN_B00055_S04110_W000153"}
demo/ref_en.mp3 ADDED
Binary file (44 kB). View file
 
omnivoice/__init__.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import warnings
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ warnings.filterwarnings("ignore", module="torchaudio")
5
+ warnings.filterwarnings(
6
+ "ignore",
7
+ category=SyntaxWarning,
8
+ message="invalid escape sequence",
9
+ module="pydub.utils",
10
+ )
11
+ warnings.filterwarnings(
12
+ "ignore",
13
+ category=FutureWarning,
14
+ module="torch.distributed.algorithms.ddp_comm_hooks",
15
+ )
16
+
17
+ try:
18
+ __version__ = version("omnivoice")
19
+ except PackageNotFoundError:
20
+ __version__ = "0.0.0"
21
+
22
+ from omnivoice.models.omnivoice import (
23
+ OmniVoice,
24
+ OmniVoiceConfig,
25
+ OmniVoiceGenerationConfig,
26
+ VoiceClonePrompt,
27
+ )
28
+
29
+ __all__ = [
30
+ "OmniVoice",
31
+ "OmniVoiceConfig",
32
+ "OmniVoiceGenerationConfig",
33
+ "VoiceClonePrompt",
34
+ ]
omnivoice/cli/__init__.py ADDED
File without changes
omnivoice/cli/demo.py ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ """
18
+ Gradio demo for OmniVoice.
19
+
20
+ Supports voice cloning and voice design.
21
+
22
+ Usage:
23
+ omnivoice-demo --model /path/to/checkpoint --port 8000
24
+ """
25
+
26
+ import argparse
27
+ import logging
28
+ from typing import Any, Dict
29
+
30
+ import gradio as gr
31
+ import numpy as np
32
+ import torch
33
+
34
+ from omnivoice import OmniVoice, OmniVoiceGenerationConfig
35
+ from omnivoice.utils.common import get_best_device
36
+ from omnivoice.utils.lang_map import LANG_NAMES, lang_display_name
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Language list — all 600+ supported languages
41
+ # ---------------------------------------------------------------------------
42
+ _ALL_LANGUAGES = ["Auto"] + sorted(lang_display_name(n) for n in LANG_NAMES)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Voice Design instruction templates
47
+ # ---------------------------------------------------------------------------
48
+ # Each option is displayed as "English / 中文".
49
+ # The model expects English for accents and Chinese for dialects.
50
+ _CATEGORIES = {
51
+ "Gender / 性别": ["Male / 男", "Female / 女"],
52
+ "Age / 年龄": [
53
+ "Child / 儿童",
54
+ "Teenager / 少年",
55
+ "Young Adult / 青年",
56
+ "Middle-aged / 中年",
57
+ "Elderly / 老年",
58
+ ],
59
+ "Pitch / 音调": [
60
+ "Very Low Pitch / 极低音调",
61
+ "Low Pitch / 低音调",
62
+ "Moderate Pitch / 中音调",
63
+ "High Pitch / 高音调",
64
+ "Very High Pitch / 极高音调",
65
+ ],
66
+ "Style / 风格": ["Whisper / 耳语"],
67
+ "English Accent / 英文口音": [
68
+ "American Accent / 美式口音",
69
+ "Australian Accent / 澳大利亚口音",
70
+ "British Accent / 英国口音",
71
+ "Chinese Accent / 中国口音",
72
+ "Canadian Accent / 加拿大口音",
73
+ "Indian Accent / 印度口音",
74
+ "Korean Accent / 韩国口音",
75
+ "Portuguese Accent / 葡萄牙口音",
76
+ "Russian Accent / 俄罗斯口音",
77
+ "Japanese Accent / 日本口音",
78
+ ],
79
+ "Chinese Dialect / 中文方言": [
80
+ "Henan Dialect / 河南话",
81
+ "Shaanxi Dialect / 陕西话",
82
+ "Sichuan Dialect / 四川话",
83
+ "Guizhou Dialect / 贵州话",
84
+ "Yunnan Dialect / 云南话",
85
+ "Guilin Dialect / 桂林话",
86
+ "Jinan Dialect / 济南话",
87
+ "Shijiazhuang Dialect / 石家庄话",
88
+ "Gansu Dialect / 甘肃话",
89
+ "Ningxia Dialect / 宁夏话",
90
+ "Qingdao Dialect / 青岛话",
91
+ "Northeast Dialect / 东北话",
92
+ ],
93
+ }
94
+
95
+ _ATTR_INFO = {
96
+ "English Accent / 英文口音": "Only effective for English speech.",
97
+ "Chinese Dialect / 中文方言": "Only effective for Chinese speech.",
98
+ }
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Argument parser
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def build_parser() -> argparse.ArgumentParser:
106
+ parser = argparse.ArgumentParser(
107
+ prog="omnivoice-demo",
108
+ description="Launch a Gradio demo for OmniVoice.",
109
+ formatter_class=argparse.RawTextHelpFormatter,
110
+ )
111
+ parser.add_argument(
112
+ "--model",
113
+ default="k2-fsa/OmniVoice",
114
+ help="Model checkpoint path or HuggingFace repo id.",
115
+ )
116
+ parser.add_argument(
117
+ "--device", default=None, help="Device to use. Auto-detected if not specified."
118
+ )
119
+ parser.add_argument("--ip", default="0.0.0.0", help="Server IP (default: 0.0.0.0).")
120
+ parser.add_argument(
121
+ "--port", type=int, default=7860, help="Server port (default: 7860)."
122
+ )
123
+ parser.add_argument(
124
+ "--root-path",
125
+ default=None,
126
+ help="Root path for reverse proxy.",
127
+ )
128
+ parser.add_argument(
129
+ "--share", action="store_true", default=False, help="Create public link."
130
+ )
131
+ parser.add_argument(
132
+ "--no-asr",
133
+ action="store_true",
134
+ default=False,
135
+ help="Skip loading Whisper ASR model. Reference text auto-transcription"
136
+ " will be unavailable.",
137
+ )
138
+ parser.add_argument(
139
+ "--asr-model",
140
+ default="openai/whisper-large-v3-turbo",
141
+ help="ASR model path or HuggingFace repo id"
142
+ " (default: openai/whisper-large-v3-turbo).",
143
+ )
144
+ return parser
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Build demo
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ def build_demo(
153
+ model: OmniVoice,
154
+ checkpoint: str,
155
+ generate_fn=None,
156
+ ) -> gr.Blocks:
157
+ sampling_rate = model.sampling_rate
158
+
159
+ # -- shared generation core --
160
+ def _gen_core(
161
+ text,
162
+ language,
163
+ ref_audio,
164
+ instruct,
165
+ num_step,
166
+ guidance_scale,
167
+ denoise,
168
+ speed,
169
+ duration,
170
+ preprocess_prompt,
171
+ postprocess_output,
172
+ mode,
173
+ ref_text=None,
174
+ ):
175
+ if not text or not text.strip():
176
+ return None, "Please enter the text to synthesize."
177
+
178
+ gen_config = OmniVoiceGenerationConfig(
179
+ num_step=int(num_step or 32),
180
+ guidance_scale=float(guidance_scale) if guidance_scale is not None else 2.0,
181
+ denoise=bool(denoise) if denoise is not None else True,
182
+ preprocess_prompt=bool(preprocess_prompt),
183
+ postprocess_output=bool(postprocess_output),
184
+ )
185
+
186
+ lang = language if (language and language != "Auto") else None
187
+
188
+ kw: Dict[str, Any] = dict(
189
+ text=text.strip(), language=lang, generation_config=gen_config
190
+ )
191
+
192
+ if speed is not None and float(speed) != 1.0:
193
+ kw["speed"] = float(speed)
194
+ if duration is not None and float(duration) > 0:
195
+ kw["duration"] = float(duration)
196
+
197
+ if mode == "clone":
198
+ if not ref_audio:
199
+ return None, "Please upload a reference audio."
200
+ kw["voice_clone_prompt"] = model.create_voice_clone_prompt(
201
+ ref_audio=ref_audio,
202
+ ref_text=ref_text,
203
+ )
204
+
205
+ if instruct and instruct.strip():
206
+ kw["instruct"] = instruct.strip()
207
+
208
+ try:
209
+ audio = model.generate(**kw)
210
+ except Exception as e:
211
+ return None, f"Error: {type(e).__name__}: {e}"
212
+
213
+ waveform = (audio[0] * 32767).astype(np.int16)
214
+ return (sampling_rate, waveform), "Done."
215
+
216
+ # Allow external wrappers (e.g. spaces.GPU for ZeroGPU Spaces)
217
+ _gen = generate_fn if generate_fn is not None else _gen_core
218
+
219
+ # =====================================================================
220
+ # UI
221
+ # =====================================================================
222
+ theme = gr.themes.Soft(
223
+ font=["Inter", "Arial", "sans-serif"],
224
+ )
225
+ css = """
226
+ .gradio-container {max-width: 100% !important; font-size: 16px !important;}
227
+ .gradio-container h1 {font-size: 1.5em !important;}
228
+ .gradio-container .prose {font-size: 1.1em !important;}
229
+ .compact-audio audio {height: 60px !important;}
230
+ .compact-audio .waveform {min-height: 80px !important;}
231
+ """
232
+
233
+ # Reusable: language dropdown component
234
+ def _lang_dropdown(label="Language (optional) / 语种 (可选)", value="Auto"):
235
+ return gr.Dropdown(
236
+ label=label,
237
+ choices=_ALL_LANGUAGES,
238
+ value=value,
239
+ allow_custom_value=False,
240
+ interactive=True,
241
+ info="Keep as Auto to auto-detect the language.",
242
+ )
243
+
244
+ # Reusable: optional generation settings accordion
245
+ def _gen_settings():
246
+ with gr.Accordion("Generation Settings (optional)", open=False):
247
+ sp = gr.Slider(
248
+ 0.5,
249
+ 1.5,
250
+ value=1.0,
251
+ step=0.05,
252
+ label="Speed",
253
+ info="1.0 = normal. >1 faster, <1 slower. Ignored if Duration is set.",
254
+ )
255
+ du = gr.Number(
256
+ value=None,
257
+ label="Duration (seconds)",
258
+ info=(
259
+ "Leave empty to use speed. Set a fixed duration to override speed."
260
+ ),
261
+ )
262
+ ns = gr.Slider(
263
+ 4,
264
+ 64,
265
+ value=32,
266
+ step=1,
267
+ label="Inference Steps",
268
+ info="Default: 32. Lower = faster, higher = better quality.",
269
+ )
270
+ dn = gr.Checkbox(
271
+ label="Denoise",
272
+ value=True,
273
+ info="Default: enabled. Uncheck to disable denoising.",
274
+ )
275
+ gs = gr.Slider(
276
+ 0.0,
277
+ 4.0,
278
+ value=2.0,
279
+ step=0.1,
280
+ label="Guidance Scale (CFG)",
281
+ info="Default: 2.0.",
282
+ )
283
+ pp = gr.Checkbox(
284
+ label="Preprocess Prompt",
285
+ value=True,
286
+ info="apply silence removal and trimming to the reference "
287
+ "audio, add punctuation in the end of reference text (if not already)",
288
+ )
289
+ po = gr.Checkbox(
290
+ label="Postprocess Output",
291
+ value=True,
292
+ info="Remove long silences from generated audio.",
293
+ )
294
+ return ns, gs, dn, sp, du, pp, po
295
+
296
+ with gr.Blocks(theme=theme, css=css, title="OmniVoice Demo") as demo:
297
+ gr.Markdown(
298
+ """
299
+ # OmniVoice Demo
300
+
301
+ State-of-the-art text-to-speech model for **600+ languages**, supporting:
302
+
303
+ - **Voice Clone** — Clone any voice from a reference audio
304
+ - **Voice Design** — Create custom voices with speaker attributes
305
+
306
+ Built with [OmniVoice](https://github.com/k2-fsa/OmniVoice)
307
+ by Xiaomi AI Lab Next-gen Kaldi team.
308
+ """
309
+ )
310
+
311
+ with gr.Tabs():
312
+ # ==============================================================
313
+ # Voice Clone
314
+ # ==============================================================
315
+ with gr.TabItem("Voice Clone"):
316
+ with gr.Row():
317
+ with gr.Column(scale=1):
318
+ vc_text = gr.Textbox(
319
+ label="Text to Synthesize / 待合成文本",
320
+ lines=4,
321
+ placeholder="Enter the text you want to synthesize...",
322
+ )
323
+ vc_ref_audio = gr.Audio(
324
+ label="Reference Audio / 参考音频",
325
+ type="filepath",
326
+ elem_classes="compact-audio",
327
+ )
328
+ gr.Markdown(
329
+ "<span style='font-size:0.85em;color:#888;'>"
330
+ "Recommended: 3–10 seconds audio. "
331
+ "</span>"
332
+ )
333
+ vc_ref_text = gr.Textbox(
334
+ label=("Reference Text (optional) / 参考音频文本(可选)"),
335
+ lines=2,
336
+ placeholder="Transcript of the reference audio. Leave empty"
337
+ " to auto-transcribe via ASR models.",
338
+ )
339
+ vc_lang = _lang_dropdown("Language (optional) / 语种 (可选)")
340
+ with gr.Accordion("Instruct (optional)", open=False):
341
+ vc_instruct = gr.Textbox(label="Instruct", lines=2)
342
+ (
343
+ vc_ns,
344
+ vc_gs,
345
+ vc_dn,
346
+ vc_sp,
347
+ vc_du,
348
+ vc_pp,
349
+ vc_po,
350
+ ) = _gen_settings()
351
+ vc_btn = gr.Button("Generate / 生成", variant="primary")
352
+ with gr.Column(scale=1):
353
+ vc_audio = gr.Audio(
354
+ label="Output Audio / 合成结果",
355
+ type="numpy",
356
+ )
357
+ vc_status = gr.Textbox(label="Status / 状态", lines=2)
358
+
359
+ def _clone_fn(
360
+ text, lang, ref_aud, ref_text, instruct, ns, gs, dn, sp, du, pp, po
361
+ ):
362
+ return _gen(
363
+ text,
364
+ lang,
365
+ ref_aud,
366
+ instruct,
367
+ ns,
368
+ gs,
369
+ dn,
370
+ sp,
371
+ du,
372
+ pp,
373
+ po,
374
+ mode="clone",
375
+ ref_text=ref_text or None,
376
+ )
377
+
378
+ vc_btn.click(
379
+ _clone_fn,
380
+ inputs=[
381
+ vc_text,
382
+ vc_lang,
383
+ vc_ref_audio,
384
+ vc_ref_text,
385
+ vc_instruct,
386
+ vc_ns,
387
+ vc_gs,
388
+ vc_dn,
389
+ vc_sp,
390
+ vc_du,
391
+ vc_pp,
392
+ vc_po,
393
+ ],
394
+ outputs=[vc_audio, vc_status],
395
+ )
396
+
397
+ # ==============================================================
398
+ # Voice Design
399
+ # ==============================================================
400
+ with gr.TabItem("Voice Design"):
401
+ with gr.Row():
402
+ with gr.Column(scale=1):
403
+ vd_text = gr.Textbox(
404
+ label="Text to Synthesize / 待合成文本",
405
+ lines=4,
406
+ placeholder="Enter the text you want to synthesize...",
407
+ )
408
+ vd_lang = _lang_dropdown()
409
+
410
+ _AUTO = "Auto"
411
+ vd_groups = []
412
+ for _cat, _choices in _CATEGORIES.items():
413
+ vd_groups.append(
414
+ gr.Dropdown(
415
+ label=_cat,
416
+ choices=[_AUTO] + _choices,
417
+ value=_AUTO,
418
+ info=_ATTR_INFO.get(_cat),
419
+ )
420
+ )
421
+
422
+ (
423
+ vd_ns,
424
+ vd_gs,
425
+ vd_dn,
426
+ vd_sp,
427
+ vd_du,
428
+ vd_pp,
429
+ vd_po,
430
+ ) = _gen_settings()
431
+ vd_btn = gr.Button("Generate / 生成", variant="primary")
432
+ with gr.Column(scale=1):
433
+ vd_audio = gr.Audio(
434
+ label="Output Audio / 合成结果",
435
+ type="numpy",
436
+ )
437
+ vd_status = gr.Textbox(label="Status / 状态", lines=2)
438
+
439
+ def _build_instruct(groups):
440
+ """Extract instruct text from UI dropdowns.
441
+
442
+ Language unification and validation is handled by
443
+ _resolve_instruct inside _preprocess_all.
444
+ """
445
+ selected = [g for g in groups if g and g != "Auto"]
446
+ if not selected:
447
+ return None
448
+ parts = []
449
+ for v in selected:
450
+ if " / " in v:
451
+ en, zh = v.split(" / ", 1)
452
+ # Dialects have no English equivalent
453
+ if "Dialect" in v.split(" / ")[0]:
454
+ parts.append(zh.strip())
455
+ else:
456
+ parts.append(en.strip())
457
+ else:
458
+ parts.append(v)
459
+ return ", ".join(parts)
460
+
461
+ def _design_fn(text, lang, ns, gs, dn, sp, du, pp, po, *groups):
462
+ return _gen(
463
+ text,
464
+ lang,
465
+ None,
466
+ _build_instruct(groups),
467
+ ns,
468
+ gs,
469
+ dn,
470
+ sp,
471
+ du,
472
+ pp,
473
+ po,
474
+ mode="design",
475
+ )
476
+
477
+ vd_btn.click(
478
+ _design_fn,
479
+ inputs=[
480
+ vd_text,
481
+ vd_lang,
482
+ vd_ns,
483
+ vd_gs,
484
+ vd_dn,
485
+ vd_sp,
486
+ vd_du,
487
+ vd_pp,
488
+ vd_po,
489
+ ]
490
+ + vd_groups,
491
+ outputs=[vd_audio, vd_status],
492
+ )
493
+
494
+ return demo
495
+
496
+
497
+ # ---------------------------------------------------------------------------
498
+ # Main
499
+ # ---------------------------------------------------------------------------
500
+
501
+
502
+ def main(argv=None) -> int:
503
+ logging.basicConfig(
504
+ level=logging.INFO,
505
+ format="%(asctime)s %(name)s %(levelname)s: %(message)s",
506
+ )
507
+ parser = build_parser()
508
+ args = parser.parse_args(argv)
509
+
510
+ device = args.device or get_best_device()
511
+
512
+ checkpoint = args.model
513
+ if not checkpoint:
514
+ parser.print_help()
515
+ return 0
516
+ logging.info(f"Loading model from {checkpoint}, device={device} ...")
517
+ model = OmniVoice.from_pretrained(
518
+ checkpoint,
519
+ device_map=device,
520
+ dtype=torch.float16,
521
+ load_asr=not args.no_asr,
522
+ asr_model_name=args.asr_model,
523
+ )
524
+ print("Model loaded.")
525
+
526
+ demo = build_demo(model, checkpoint)
527
+
528
+ demo.queue().launch(
529
+ server_name=args.ip,
530
+ server_port=args.port,
531
+ share=args.share,
532
+ root_path=args.root_path,
533
+ )
534
+ return 0
535
+
536
+
537
+ if __name__ == "__main__":
538
+ raise SystemExit(main())
omnivoice/cli/infer.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-item inference CLI for OmniVoice.
2
+
3
+ Generates audio from a single text input using voice cloning,
4
+ voice design, or auto voice.
5
+
6
+ Usage:
7
+ # Voice cloning
8
+ omnivoice-infer --model k2-fsa/OmniVoice \
9
+ --text "Hello, this is a text for text-to-speech." \
10
+ --ref_audio ref.wav --ref_text "Reference transcript." --output out.wav
11
+
12
+ # Voice design
13
+ omnivoice-infer --model k2-fsa/OmniVoice \
14
+ --text "Hello, this is a text for text-to-speech." \
15
+ --instruct "male, British accent" --output out.wav
16
+
17
+ # Auto voice
18
+ omnivoice-infer --model k2-fsa/OmniVoice \
19
+ --text "Hello, this is a text for text-to-speech." --output out.wav
20
+ """
21
+
22
+ import argparse
23
+ import logging
24
+
25
+ import torch
26
+
27
+ import soundfile as sf
28
+
29
+ from omnivoice.models.omnivoice import OmniVoice
30
+ from omnivoice.utils.common import get_best_device, str2bool
31
+
32
+
33
+ def get_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(
35
+ description="OmniVoice single-item inference",
36
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
37
+ )
38
+ parser.add_argument(
39
+ "--model",
40
+ type=str,
41
+ default="k2-fsa/OmniVoice",
42
+ help="Model checkpoint path or HuggingFace repo id.",
43
+ )
44
+ parser.add_argument(
45
+ "--text",
46
+ type=str,
47
+ required=True,
48
+ help="Text to synthesize.",
49
+ )
50
+ parser.add_argument(
51
+ "--output",
52
+ type=str,
53
+ required=True,
54
+ help="Output WAV file path.",
55
+ )
56
+ # Voice cloning
57
+ parser.add_argument(
58
+ "--ref_audio",
59
+ type=str,
60
+ default=None,
61
+ help="Reference audio file path for voice cloning.",
62
+ )
63
+ parser.add_argument(
64
+ "--ref_text",
65
+ type=str,
66
+ default=None,
67
+ help="Reference text describing the reference audio.",
68
+ )
69
+ # Voice design
70
+ parser.add_argument(
71
+ "--instruct",
72
+ type=str,
73
+ default=None,
74
+ help="Style instruction for voice design mode.",
75
+ )
76
+ parser.add_argument(
77
+ "--language",
78
+ type=str,
79
+ default=None,
80
+ help="Language name (e.g. 'English') or code (e.g. 'en').",
81
+ )
82
+ # Generation parameters
83
+ parser.add_argument("--num_step", type=int, default=32)
84
+ parser.add_argument("--guidance_scale", type=float, default=2.0)
85
+ parser.add_argument("--speed", type=float, default=1.0)
86
+ parser.add_argument(
87
+ "--duration",
88
+ type=float,
89
+ default=None,
90
+ help="Fixed output duration in seconds. If set, overrides the "
91
+ "model's duration estimation. The speed factor is automatically "
92
+ "adjusted to match while preserving language-aware pacing.",
93
+ )
94
+ parser.add_argument("--t_shift", type=float, default=0.1)
95
+ parser.add_argument("--denoise", type=str2bool, default=True)
96
+ parser.add_argument(
97
+ "--postprocess_output",
98
+ type=str2bool,
99
+ default=True,
100
+ )
101
+ parser.add_argument("--layer_penalty_factor", type=float, default=5.0)
102
+ parser.add_argument("--position_temperature", type=float, default=5.0)
103
+ parser.add_argument("--class_temperature", type=float, default=0.0)
104
+ parser.add_argument(
105
+ "--device",
106
+ type=str,
107
+ default=None,
108
+ help="Device to use for inference. Auto-detected if not specified.",
109
+ )
110
+ return parser
111
+
112
+
113
+ def main():
114
+ formatter = "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s"
115
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
116
+
117
+ args = get_parser().parse_args()
118
+
119
+ device = args.device or get_best_device()
120
+ logging.info(f"Loading model from {args.model} on {device} ...")
121
+ model = OmniVoice.from_pretrained(
122
+ args.model, device_map=device, dtype=torch.float16
123
+ )
124
+
125
+ logging.info(f"Generating audio for: {args.text[:80]}...")
126
+ audios = model.generate(
127
+ text=args.text,
128
+ language=args.language,
129
+ ref_audio=args.ref_audio,
130
+ ref_text=args.ref_text,
131
+ instruct=args.instruct,
132
+ duration=args.duration,
133
+ num_step=args.num_step,
134
+ guidance_scale=args.guidance_scale,
135
+ speed=args.speed,
136
+ t_shift=args.t_shift,
137
+ denoise=args.denoise,
138
+ postprocess_output=args.postprocess_output,
139
+ layer_penalty_factor=args.layer_penalty_factor,
140
+ position_temperature=args.position_temperature,
141
+ class_temperature=args.class_temperature,
142
+ )
143
+
144
+ sf.write(args.output, audios[0], model.sampling_rate)
145
+ logging.info(f"Saved to {args.output}")
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
omnivoice/cli/infer_batch.py ADDED
@@ -0,0 +1,552 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Batch inference CLI for OmniVoice.
19
+
20
+ Distributes TTS generation across multiple GPUs for large-scale tasks.
21
+ Reads a JSONL test list, generates audio in parallel, and saves results.
22
+
23
+ Usage:
24
+ omnivoice-infer-batch --model k2-fsa/OmniVoice \
25
+ --test_list test.jsonl --res_dir results/
26
+
27
+ Test list format (JSONL, one JSON object per line):
28
+ Required fields: "id", "text"
29
+ Voice cloning: "ref_audio", "ref_text"
30
+ Voice design: "instruct"
31
+ Optional: "language_id", "duration", "speed"
32
+ """
33
+
34
+ import argparse
35
+ import logging
36
+ import multiprocessing as mp
37
+ import os
38
+ import signal
39
+ import time
40
+ import traceback
41
+ from concurrent.futures import ProcessPoolExecutor, as_completed
42
+ from typing import List, Optional, Tuple
43
+
44
+ import torch
45
+ from tqdm import tqdm
46
+
47
+ from omnivoice.models.omnivoice import OmniVoice
48
+ import soundfile as sf
49
+
50
+ from omnivoice.utils.audio import load_audio
51
+ from omnivoice.utils.common import get_best_device_with_count, str2bool
52
+ from omnivoice.utils.data_utils import read_test_list
53
+ from omnivoice.utils.duration import RuleDurationEstimator
54
+
55
+
56
+ worker_model = None
57
+ SAMPLING_RATE = 24000
58
+
59
+
60
+ def get_parser():
61
+ parser = argparse.ArgumentParser(description="Infer OmniVoice Model")
62
+ parser.add_argument(
63
+ "--model",
64
+ type=str,
65
+ default="k2-fsa/OmniVoice",
66
+ help="Path to the model checkpoint (local dir or HF repo id). "
67
+ "Audio tokenizer is expected at <checkpoint>/audio_tokenizer/.",
68
+ )
69
+ parser.add_argument(
70
+ "--test_list",
71
+ type=str,
72
+ required=True,
73
+ help="Path to the JSONL file containing test samples. "
74
+ "Each line is a JSON object with the following fields: "
75
+ '"id" (str, required): unique name for the output file; '
76
+ '"text" (str, required): text to synthesize; '
77
+ '"ref_audio" (str): path to reference audio for voice cloning; '
78
+ '"ref_text" (str): transcript of the reference audio; '
79
+ '"instruct" (str): instruction for voice design (used when ref_audio is absent); '
80
+ '"language_id" (str): language code, e.g. "en"; '
81
+ '"duration" (float): target duration in seconds; '
82
+ '"speed" (float): speaking speed multiplier. '
83
+ "Only id and text are required; all other fields are optional.",
84
+ )
85
+ parser.add_argument(
86
+ "--res_dir",
87
+ type=str,
88
+ required=True,
89
+ help="Directory to save the generated audio files.",
90
+ )
91
+ parser.add_argument(
92
+ "--num_step",
93
+ type=int,
94
+ default=32,
95
+ help="Number of steps for iterative decoding.",
96
+ )
97
+ parser.add_argument(
98
+ "--guidance_scale",
99
+ type=float,
100
+ default=2.0,
101
+ help="Scale for Classifier-Free Guidance.",
102
+ )
103
+ parser.add_argument(
104
+ "--t_shift",
105
+ type=float,
106
+ default=0.1,
107
+ help="Shift t to smaller ones if t_shift < 1.0",
108
+ )
109
+ parser.add_argument(
110
+ "--nj_per_gpu",
111
+ type=int,
112
+ default=1,
113
+ help="Number of worker processes to spawn per GPU.",
114
+ )
115
+ parser.add_argument(
116
+ "--audio_chunk_duration",
117
+ type=float,
118
+ default=15.0,
119
+ help="Maximum duration of audio chunk (in seconds) for splitting. "
120
+ '"Not split" if <= 0.',
121
+ )
122
+ parser.add_argument(
123
+ "--audio_chunk_threshold",
124
+ type=float,
125
+ default=30.0,
126
+ help=(
127
+ "The duration threshold (in seconds) to decide"
128
+ " whether to split audio into chunks."
129
+ ),
130
+ )
131
+ parser.add_argument(
132
+ "--batch_duration",
133
+ type=float,
134
+ default=1000.0,
135
+ help="Maximum total duration (reference + generated) per batch (seconds).",
136
+ )
137
+ parser.add_argument(
138
+ "--batch_size",
139
+ type=int,
140
+ default=0,
141
+ help="Fixed batch size (number of samples per batch). "
142
+ "If > 0, use fixed-size batching instead of duration-based batching.",
143
+ )
144
+ parser.add_argument(
145
+ "--warmup",
146
+ type=int,
147
+ default=0,
148
+ help="Number of dummy inference runs per worker before real inference "
149
+ "starts, to warm up CUDA kernels and caches.",
150
+ )
151
+ parser.add_argument(
152
+ "--preprocess_prompt",
153
+ type=str2bool,
154
+ default=True,
155
+ help="Whether to preprocess reference audio (silence removal, trimming). "
156
+ "Set to False to keep raw audio.",
157
+ )
158
+ parser.add_argument(
159
+ "--postprocess_output",
160
+ type=str2bool,
161
+ default=True,
162
+ help="Whether to post-process generated audio (remove silence).",
163
+ )
164
+ parser.add_argument(
165
+ "--layer_penalty_factor",
166
+ type=float,
167
+ default=5.0,
168
+ help="The penalty factor for layer-wise sampling.",
169
+ )
170
+ parser.add_argument(
171
+ "--position_temperature",
172
+ type=float,
173
+ default=5.0,
174
+ help="The temperature for position selection.",
175
+ )
176
+ parser.add_argument(
177
+ "--class_temperature",
178
+ type=float,
179
+ default=0.0,
180
+ help="The temperature for class token sampling.",
181
+ )
182
+ parser.add_argument(
183
+ "--denoise",
184
+ type=str2bool,
185
+ default=True,
186
+ help="Whether to add <|denoise|> token in the reference.",
187
+ )
188
+ parser.add_argument(
189
+ "--lang_id",
190
+ type=str,
191
+ default=None,
192
+ help="Language id to use when test_list JSONL entries do not contain "
193
+ "a language_id field.",
194
+ )
195
+ return parser
196
+
197
+
198
+ def process_init(rank_queue, model_checkpoint, warmup=0):
199
+ """Initializer for each worker process.
200
+
201
+ Loads model (with tokenizers and duration estimator) onto a specific GPU
202
+ via ``OmniVoice.from_pretrained()``.
203
+ """
204
+ global worker_model
205
+
206
+ torch.set_num_threads(2)
207
+ torch.set_num_interop_threads(2)
208
+
209
+ formatter = (
210
+ "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] "
211
+ "[Worker %(process)d] %(message)s"
212
+ )
213
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
214
+
215
+ rank = rank_queue.get()
216
+ device_type, device_id = rank
217
+ if device_type == "cpu":
218
+ worker_device = "cpu"
219
+ elif device_type == "mps":
220
+ worker_device = "mps"
221
+ else:
222
+ worker_device = f"{device_type}:{device_id}"
223
+
224
+ logging.info(f"Initializing worker on device: {worker_device}")
225
+
226
+ worker_model = OmniVoice.from_pretrained(
227
+ model_checkpoint,
228
+ device_map=worker_device,
229
+ dtype=torch.float16,
230
+ )
231
+
232
+ if warmup > 0:
233
+ logging.info(f"Running {warmup} warmup iterations on {worker_device}")
234
+ dummy_ref_audio = (
235
+ torch.randn(1, SAMPLING_RATE),
236
+ SAMPLING_RATE,
237
+ ) # 1s dummy audio
238
+ for i in range(warmup):
239
+ worker_model.generate(
240
+ text=["hello"],
241
+ language=["en"],
242
+ ref_audio=[dummy_ref_audio],
243
+ ref_text=["hello"],
244
+ )
245
+ logging.info(f"Warmup complete on {worker_device}")
246
+
247
+ logging.info(f"Worker on {worker_device} initialized successfully.")
248
+
249
+
250
+ def _get_audio_duration(audio_path: str) -> float:
251
+ """Return the duration of an audio file in seconds.
252
+
253
+ Reads only the file header, so the samples are never decoded or resampled.
254
+ Falls back to a full decode for formats ``soundfile`` cannot inspect
255
+ (e.g. MP3/M4A on older libsndfile builds).
256
+ """
257
+ try:
258
+ info = sf.info(audio_path)
259
+ return info.frames / info.samplerate
260
+ except Exception:
261
+ wav = load_audio(audio_path, SAMPLING_RATE)
262
+ return wav.shape[-1] / SAMPLING_RATE
263
+
264
+
265
+ def estimate_sample_total_duration(
266
+ duration_estimator: RuleDurationEstimator,
267
+ text: str,
268
+ ref_text: Optional[str],
269
+ ref_audio_path: Optional[str],
270
+ gen_duration: Optional[float] = None,
271
+ ) -> float:
272
+ """Estimate total duration (ref + generated) for a single sample.
273
+
274
+ When ``ref_audio_path`` is ``None`` (instruct / voice-design mode),
275
+ the reference duration is treated as 0 and only the estimated generated
276
+ duration contributes to the total.
277
+ """
278
+ if ref_audio_path is not None:
279
+ ref_duration = _get_audio_duration(ref_audio_path)
280
+ else:
281
+ ref_duration = 0
282
+
283
+ if gen_duration is None:
284
+ if ref_audio_path is not None:
285
+ gen_duration = duration_estimator.estimate_duration(
286
+ text, ref_text or "", ref_duration, low_threshold=2.0
287
+ )
288
+ else:
289
+ gen_duration = duration_estimator.estimate_duration(
290
+ text, "Nice to meet you.", 0.5, low_threshold=2.0
291
+ )
292
+
293
+ total_duration = ref_duration + gen_duration
294
+ return total_duration
295
+
296
+
297
+ def _sort_samples_by_duration(
298
+ samples: List[Tuple],
299
+ duration_estimator: RuleDurationEstimator,
300
+ ) -> List[Tuple[Tuple, float]]:
301
+ """Return (sample, total_duration) pairs sorted by duration descending."""
302
+ sample_with_duration = []
303
+ for sample in samples:
304
+ _, ref_text, ref_audio_path, text, _, dur, _, _ = sample
305
+ total_duration = estimate_sample_total_duration(
306
+ duration_estimator, text, ref_text, ref_audio_path, gen_duration=dur
307
+ )
308
+ sample_with_duration.append((sample, total_duration))
309
+ sample_with_duration.sort(key=lambda x: x[1], reverse=True)
310
+ return sample_with_duration
311
+
312
+
313
+ def cluster_samples_by_duration(
314
+ samples: List[Tuple],
315
+ duration_estimator: RuleDurationEstimator,
316
+ batch_duration: float,
317
+ ) -> List[List[Tuple]]:
318
+ sample_with_duration = _sort_samples_by_duration(samples, duration_estimator)
319
+ batches = []
320
+ current_batch = []
321
+ current_total_duration = 0.0
322
+
323
+ for sample, duration in sample_with_duration:
324
+ if duration > batch_duration:
325
+ batches.append([sample])
326
+ continue
327
+
328
+ if current_total_duration + duration <= batch_duration:
329
+ current_batch.append(sample)
330
+ current_total_duration += duration
331
+ else:
332
+ batches.append(current_batch)
333
+ current_batch = [sample]
334
+ current_total_duration = duration
335
+
336
+ if current_batch:
337
+ batches.append(current_batch)
338
+
339
+ logging.info(f"Clustered {len(samples)} samples into {len(batches)} batches")
340
+ return batches
341
+
342
+
343
+ def cluster_samples_by_batch_size(
344
+ samples: List[Tuple],
345
+ duration_estimator: RuleDurationEstimator,
346
+ batch_size: int,
347
+ ) -> List[List[Tuple]]:
348
+ """Split samples into fixed-size batches, sorted by duration to minimize padding."""
349
+ sample_with_duration = _sort_samples_by_duration(samples, duration_estimator)
350
+ sorted_samples = [s for s, _ in sample_with_duration]
351
+
352
+ batches = [
353
+ sorted_samples[i : i + batch_size]
354
+ for i in range(0, len(sorted_samples), batch_size)
355
+ ]
356
+ logging.info(
357
+ f"Split {len(samples)} samples into {len(batches)} batches "
358
+ f"(fixed batch_size={batch_size}, sorted by duration)"
359
+ )
360
+ return batches
361
+
362
+
363
+ def run_inference_batch(
364
+ batch_samples: List[Tuple],
365
+ res_dir: str,
366
+ **gen_kwargs,
367
+ ) -> List[Tuple]:
368
+ global worker_model
369
+
370
+ save_names = []
371
+ ref_texts = []
372
+ ref_audio_paths = []
373
+ texts = []
374
+ langs = []
375
+ durations = []
376
+ speeds = []
377
+ instructs = []
378
+
379
+ for sample in batch_samples:
380
+ save_name, ref_text, ref_audio_path, text, lang_id, dur, spd, instruct = sample
381
+ save_names.append(save_name)
382
+ ref_texts.append(ref_text)
383
+ ref_audio_paths.append(ref_audio_path)
384
+ texts.append(text)
385
+ langs.append(lang_id)
386
+ durations.append(dur)
387
+ speeds.append(spd)
388
+ instructs.append(instruct)
389
+
390
+ start_time = time.time()
391
+ audios = worker_model.generate(
392
+ text=texts,
393
+ language=langs,
394
+ ref_audio=ref_audio_paths
395
+ if any(p is not None for p in ref_audio_paths)
396
+ else None,
397
+ ref_text=ref_texts if any(t is not None for t in ref_texts) else None,
398
+ duration=durations if any(d is not None for d in durations) else None,
399
+ speed=speeds if any(s is not None for s in speeds) else None,
400
+ instruct=instructs if any(i is not None for i in instructs) else None,
401
+ **gen_kwargs,
402
+ )
403
+ batch_synth_time = time.time() - start_time
404
+
405
+ results = []
406
+ for save_name, audio in zip(save_names, audios):
407
+ save_path = os.path.join(res_dir, save_name + ".wav")
408
+ sf.write(save_path, audio, worker_model.sampling_rate)
409
+ audio_duration = audio.shape[-1] / worker_model.sampling_rate
410
+ results.append(
411
+ (
412
+ save_name,
413
+ batch_synth_time / len(batch_samples),
414
+ audio_duration,
415
+ "success",
416
+ )
417
+ )
418
+
419
+ return results
420
+
421
+
422
+ def main():
423
+ formatter = "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s"
424
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
425
+ mp.set_start_method("spawn", force=True)
426
+
427
+ args = get_parser().parse_args()
428
+ os.makedirs(args.res_dir, exist_ok=True)
429
+
430
+ device_type, num_devices = get_best_device_with_count()
431
+ if device_type == "cpu":
432
+ logging.warning(
433
+ "No GPU found. Falling back to CPU inference. This might be slow."
434
+ )
435
+
436
+ num_processes = num_devices * args.nj_per_gpu
437
+ logging.info(
438
+ f"Using {device_type} ({num_devices} device(s))."
439
+ f" Spawning {num_processes} worker processes."
440
+ )
441
+
442
+ manager = mp.Manager()
443
+ rank_queue = manager.Queue()
444
+ for rank in list(range(num_devices)) * args.nj_per_gpu:
445
+ rank_queue.put((device_type, rank))
446
+
447
+ samples_raw = read_test_list(args.test_list)
448
+ samples = []
449
+ for s in samples_raw:
450
+ lang_id = args.lang_id if args.lang_id is not None else s.get("language_id")
451
+ samples.append(
452
+ (
453
+ s["id"],
454
+ s.get("ref_text"),
455
+ s.get("ref_audio"),
456
+ s["text"],
457
+ lang_id,
458
+ s.get("duration"),
459
+ s.get("speed"),
460
+ s.get("instruct"),
461
+ )
462
+ )
463
+
464
+ total_synthesis_time = []
465
+ total_audio_duration = []
466
+
467
+ try:
468
+ with ProcessPoolExecutor(
469
+ max_workers=num_processes,
470
+ initializer=process_init,
471
+ initargs=(rank_queue, args.model, args.warmup),
472
+ ) as executor:
473
+ futures = []
474
+
475
+ logging.info("Running batch inference")
476
+
477
+ # Split samples by mode (voice-clone vs non-voice-clone) before
478
+ # clustering so that each batch is homogeneous. Mixing ref_audio
479
+ # and non-ref_audio samples in the same batch would crash in
480
+ # generate() → create_voice_clone_prompt().
481
+ clone_samples = [s for s in samples if s[2] is not None]
482
+ other_samples = [s for s in samples if s[2] is None]
483
+
484
+ duration_estimator = RuleDurationEstimator()
485
+ batches = []
486
+ for subset in (clone_samples, other_samples):
487
+ if not subset:
488
+ continue
489
+ if args.batch_size > 0:
490
+ batches.extend(
491
+ cluster_samples_by_batch_size(
492
+ subset, duration_estimator, args.batch_size
493
+ )
494
+ )
495
+ else:
496
+ batches.extend(
497
+ cluster_samples_by_duration(
498
+ subset, duration_estimator, args.batch_duration
499
+ )
500
+ )
501
+
502
+ args_dict = vars(args)
503
+
504
+ for batch in batches:
505
+ futures.append(
506
+ executor.submit(
507
+ run_inference_batch, batch_samples=batch, **args_dict
508
+ )
509
+ )
510
+
511
+ for future in tqdm(
512
+ as_completed(futures), total=len(futures), desc="Processing samples"
513
+ ):
514
+ try:
515
+ result = future.result()
516
+ for s_name, synth_time, audio_dur, status in result:
517
+ total_synthesis_time.append(synth_time)
518
+ total_audio_duration.append(audio_dur)
519
+ rtf = synth_time / audio_dur if audio_dur > 0 else float("inf")
520
+ logging.debug(
521
+ f"Processed {s_name}: Audio Duration={audio_dur:.2f}s, "
522
+ f"Synthesis Time={synth_time:.2f}s, RTF={rtf:.4f}"
523
+ )
524
+ except Exception as e:
525
+ logging.error(f"Failed to process sample: {e}")
526
+ detailed_error = traceback.format_exc()
527
+ logging.error(f"Detailed error: {detailed_error}")
528
+
529
+ except (Exception, KeyboardInterrupt) as e:
530
+ logging.critical(
531
+ f"An unrecoverable error occurred: {e}. Terminating all processes."
532
+ )
533
+ detailed_error_info = traceback.format_exc()
534
+ logging.error(f"--- DETAILED TRACEBACK ---\n{detailed_error_info}")
535
+ os.killpg(os.getpgid(os.getpid()), signal.SIGKILL)
536
+
537
+ total_synthesis_time = sum(total_synthesis_time)
538
+ total_audio_duration = sum(total_audio_duration)
539
+ logging.info("--- Summary ---")
540
+ logging.info(f"Total audio duration: {total_audio_duration:.2f}s")
541
+ logging.info(f"Total synthesis time: {total_synthesis_time:.2f}s")
542
+ if total_audio_duration > 0:
543
+ average_rtf = total_synthesis_time / total_audio_duration
544
+ logging.info(f"Average RTF: {average_rtf:.4f}")
545
+ else:
546
+ logging.warning("No speech was generated. RTF cannot be computed.")
547
+
548
+ logging.info("Done!")
549
+
550
+
551
+ if __name__ == "__main__":
552
+ main()
omnivoice/cli/train.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Training CLI for OmniVoice.
19
+
20
+ Launches distributed training via HuggingFace Accelerate.
21
+ Supports pre-training on Emilia data and finetuning on custom data.
22
+
23
+ Usage:
24
+ accelerate launch --gpu_ids 0,1,2,3 --num_processes 4 \\
25
+ -m omnivoice.cli.train \\
26
+ --train_config train_config.json \\
27
+ --data_config data_config.json \\
28
+ --output_dir output/
29
+
30
+ See examples/run_emilia.sh and examples/run_finetune.sh for full pipelines.
31
+ """
32
+
33
+ import argparse
34
+
35
+ from omnivoice.training.builder import build_dataloaders, build_model_and_tokenizer
36
+ from omnivoice.training.config import TrainingConfig
37
+ from omnivoice.training.trainer import OmniTrainer
38
+
39
+
40
+ def main():
41
+ parser = argparse.ArgumentParser(description="OmniVoice Training Entry Point")
42
+ parser.add_argument(
43
+ "--train_config", type=str, required=True, help="Path to config JSON"
44
+ )
45
+ parser.add_argument(
46
+ "--output_dir", type=str, required=True, help="Where to save checkpoints"
47
+ )
48
+ parser.add_argument(
49
+ "--data_config", type=str, required=True, help="Path to data config JSON"
50
+ )
51
+ args = parser.parse_args()
52
+
53
+ # 1. Load Configuration
54
+ config = TrainingConfig.from_json(args.train_config)
55
+ config.output_dir = args.output_dir
56
+ config.data_config = args.data_config
57
+
58
+ # 2. Build Components
59
+ model, tokenizer = build_model_and_tokenizer(config)
60
+ train_loader, eval_loader = build_dataloaders(config, tokenizer)
61
+
62
+ # 3. Initialize Trainer and Start
63
+ trainer = OmniTrainer(
64
+ model=model,
65
+ config=config,
66
+ train_dataloader=train_loader,
67
+ eval_dataloader=eval_loader,
68
+ tokenizer=tokenizer,
69
+ )
70
+ trainer.train()
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
omnivoice/data/__init__.py ADDED
File without changes
omnivoice/data/batching.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Batching strategies for streaming/iterable datasets.
19
+
20
+ Provides length-based grouping and packing for efficient training with
21
+ variable-length audio.
22
+
23
+ Key classes:
24
+ - ``PackingIterableDataset``: Packs multiple samples into fixed-length sequences
25
+ for training. Used by ``omnivoice.training.builder`` with flex_attention.
26
+ - ``StreamLengthGroupDataset``: Groups samples by length into buckets. Used by
27
+ data processing scripts (e.g. ``omnivoice/scripts/``) and by
28
+ ``omnivoice.training.builder`` when ``attn_implementation != "flex_attention"``.
29
+ """
30
+
31
+ import bisect
32
+ import logging
33
+ from typing import Any, Dict, Iterator, List, Optional
34
+
35
+ import numpy as np
36
+
37
+ from omnivoice.data.dataset import IterableDataReader, WrappedIterableDataset
38
+
39
+
40
+ class StreamLengthGroupDataset(WrappedIterableDataset):
41
+ """A streaming dataset that groups samples by their lengths into buckets.
42
+
43
+ By default, length is measured as audio duration in seconds from a raw
44
+ waveform field. Pass a custom ``length_fn`` to use a different measure —
45
+ e.g. ``lambda s: s["length"]`` for processed training data, in which case
46
+ ``batch_duration`` and ``min/max_length`` should use the same units.
47
+
48
+ If ``processor`` is provided, each raw sample is processed before length
49
+ measurement and bucketing, and the yielded batches contain **processed**
50
+ samples. This allows accurate bucketing by post-processing token length
51
+ (used in the SDPA training path).
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ dataset: IterableDataReader,
57
+ batch_duration: float,
58
+ min_length: float = 0.5,
59
+ max_length: float = 30.0,
60
+ num_buckets: int = 20,
61
+ audio_key: str = "audio",
62
+ drop_last: bool = False,
63
+ max_sample: Optional[int] = None,
64
+ length_fn: Optional[Any] = None,
65
+ processor: Optional[Any] = None,
66
+ ):
67
+ self.dataset = dataset
68
+ self.batch_duration = batch_duration
69
+ self.min_length = min_length
70
+ self.max_length = max_length
71
+ self.num_buckets = num_buckets
72
+ self.audio_key = audio_key
73
+ self.drop_last = drop_last
74
+ self.max_sample = max_sample if max_sample is not None else float("inf")
75
+ self.length_fn = length_fn
76
+ self.processor = processor
77
+
78
+ self.boundaries = np.linspace(min_length, max_length, num_buckets + 1)[1:]
79
+
80
+ def set_epoch(self, epoch: int):
81
+ """
82
+ Set the epoch for shuffling.
83
+ """
84
+ self.dataset.set_epoch(epoch)
85
+
86
+ def _get_bucket_id(self, length: float) -> int:
87
+ return bisect.bisect_left(self.boundaries, length)
88
+
89
+ def __iter__(self) -> Iterator[List[Dict[str, Any]]]:
90
+ buckets = [[] for _ in range(self.num_buckets)]
91
+ bucket_max_len = [0.0] * self.num_buckets
92
+
93
+ for sample in self.dataset:
94
+ if self.processor is not None:
95
+ try:
96
+ sample = self.processor(sample)
97
+ except Exception as e:
98
+ logging.warning(f"Error processing sample: {e}")
99
+ continue
100
+
101
+ if self.length_fn is not None:
102
+ duration = self.length_fn(sample)
103
+ else:
104
+ audio = sample[self.audio_key]
105
+ duration = audio.size(-1) / self.dataset.sample_rate
106
+
107
+ if duration < self.min_length or duration > self.max_length:
108
+ # logging.warning(f"Skipping sample with duration {duration:.2f}s")
109
+ continue
110
+
111
+ b_id = self._get_bucket_id(duration)
112
+ buckets[b_id].append(sample)
113
+
114
+ if duration > bucket_max_len[b_id]:
115
+ bucket_max_len[b_id] = duration
116
+
117
+ if (
118
+ bucket_max_len[b_id] * (len(buckets[b_id]) + 1) >= self.batch_duration
119
+ or len(buckets[b_id]) >= self.max_sample
120
+ ):
121
+ yield buckets[b_id]
122
+ buckets[b_id] = []
123
+ bucket_max_len[b_id] = 0.0
124
+
125
+ if not self.drop_last:
126
+ for b_idx, bucket in enumerate(buckets):
127
+ if bucket:
128
+ yield bucket
129
+ buckets[b_idx] = []
130
+
131
+
132
+ class PackingIterableDataset(WrappedIterableDataset):
133
+ """
134
+ An IterableDataset that dynamically processes samples using a processor
135
+ and packs them into batches based on the real token count.
136
+
137
+ Args:
138
+ dataset (Iterable): The raw dataset to process.
139
+ processor (Callable): A processor to process each sample.
140
+ batch_tokens (int): Maximum number of tokens per batch.
141
+ """
142
+
143
+ def __init__(
144
+ self,
145
+ dataset: IterableDataReader,
146
+ processor: Any,
147
+ batch_tokens: int,
148
+ ):
149
+ self.dataset = dataset
150
+ self.processor = processor
151
+ self.batch_tokens = batch_tokens
152
+ self.skip_batches = 0
153
+
154
+ def set_epoch(self, epoch: int):
155
+ """
156
+ Set the epoch for shuffling.
157
+ """
158
+ self.dataset.set_epoch(epoch)
159
+
160
+ def __iter__(self) -> Iterator[List[Dict[str, Any]]]:
161
+ current_batch = []
162
+ current_token_count = 0
163
+
164
+ for raw_sample in self.dataset:
165
+ # Process the sample using the processor
166
+ try:
167
+ processed_sample = self.processor(raw_sample)
168
+ except Exception as e:
169
+ logging.warning(f"Error processing sample {raw_sample}: {e}")
170
+ continue
171
+
172
+ sample_length = processed_sample["length"]
173
+
174
+ if sample_length > self.batch_tokens:
175
+ continue
176
+
177
+ # Check if adding this sample exceeds the batch token limit
178
+ if current_token_count + sample_length > self.batch_tokens:
179
+ # Yield the current batch and start a new one
180
+ yield current_batch
181
+ current_batch = []
182
+ current_token_count = 0
183
+
184
+ # Add the processed sample to the current batch
185
+ current_batch.append(processed_sample)
186
+ current_token_count += sample_length
187
+
188
+ # Yield the last batch if it's not empty
189
+ if current_batch:
190
+ yield current_batch
omnivoice/data/collator.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Data collators for OmniVoice training.
19
+
20
+ Two strategies are available:
21
+
22
+ - ``PackingDataCollator``: Concatenates samples into a single long sequence
23
+ (sequence packing). Used with flex_attention. Batch shape is ``[1, C, L]``.
24
+ - ``PaddingDataCollator``: Pads samples to the same length and stacks them.
25
+ Used with SDPA/eager attention. Batch shape is ``[B, C, max_len]``.
26
+ """
27
+
28
+ from typing import Any, Dict, List
29
+
30
+ import torch
31
+
32
+
33
+ class PaddingDataCollator:
34
+ """Pads a list of processed samples to the same length and stacks them.
35
+
36
+ Produces a standard ``[B, C, max_len]`` batch suitable for SDPA/eager
37
+ attention, where B is the number of samples in the batch, C is the number
38
+ of audio codebook layers, and max_len is the longest sequence in the batch.
39
+
40
+ A 4D boolean attention mask of shape ``[B, 1, max_len, max_len]`` is included.
41
+ Each query position can attend to all non-padding key positions (bidirectional),
42
+ matching the masked-diffusion training objective. When passed as a 4D tensor,
43
+ HuggingFace models use it directly without adding an additional causal mask.
44
+
45
+ No ``document_ids`` are emitted — each sample occupies its own batch row.
46
+ """
47
+
48
+ def __init__(self, processor, batch_tokens: int):
49
+ self.batch_tokens = batch_tokens
50
+ self.processor = processor
51
+
52
+ def __call__(self, processed_samples: List[Dict[str, Any]]) -> Dict[str, Any]:
53
+ pad_id = self.processor.text_tokenizer.pad_token_id
54
+ max_len = max(s["length"] for s in processed_samples)
55
+ B = len(processed_samples)
56
+
57
+ padded_input_ids = []
58
+ padded_labels = []
59
+ padded_audio_mask = []
60
+ padded_position_ids = []
61
+ # valid[b, j] = True if position j is a real (non-padding) token for sample b
62
+ valid = torch.zeros(B, max_len, dtype=torch.bool)
63
+
64
+ for i, s in enumerate(processed_samples):
65
+ length = s["length"]
66
+ pad = max_len - length
67
+
68
+ padded_input_ids.append(
69
+ torch.nn.functional.pad(s["input_ids"], (0, pad), value=pad_id)
70
+ ) # [C, max_len]
71
+ padded_labels.append(
72
+ torch.nn.functional.pad(s["labels"], (0, pad), value=-100)
73
+ ) # [C, max_len]
74
+ padded_audio_mask.append(
75
+ torch.nn.functional.pad(s["audio_mask"], (0, pad), value=False)
76
+ ) # [max_len]
77
+ padded_position_ids.append(
78
+ torch.nn.functional.pad(
79
+ torch.arange(length, dtype=torch.long), (0, pad), value=0
80
+ )
81
+ ) # [max_len]
82
+ valid[i, :length] = True
83
+
84
+ # Stack into [B, C, max_len] / [B, max_len]
85
+ input_ids = torch.stack(padded_input_ids, dim=0) # [B, C, max_len]
86
+ labels = torch.stack(padded_labels, dim=0) # [B, C, max_len]
87
+ audio_mask = torch.stack(padded_audio_mask, dim=0) # [B, max_len]
88
+ position_ids = torch.stack(padded_position_ids, dim=0) # [B, max_len]
89
+
90
+ # 4D bidirectional attention mask: mask[b, 0, i, j] = valid[b, j]
91
+ # All query positions attend to all non-padding key positions.
92
+ attention_mask = (
93
+ valid[:, None, None, :].expand(B, 1, max_len, max_len).contiguous()
94
+ )
95
+
96
+ return {
97
+ "input_ids": input_ids, # [B, C, max_len]
98
+ "labels": labels, # [B, C, max_len]
99
+ "audio_mask": audio_mask, # [B, max_len]
100
+ "position_ids": position_ids, # [B, max_len]
101
+ "attention_mask": attention_mask, # [B, 1, max_len, max_len]
102
+ }
103
+
104
+
105
+ class PackingDataCollator:
106
+ def __init__(self, processor, batch_tokens: int):
107
+ self.batch_tokens = batch_tokens
108
+ self.processor = processor
109
+
110
+ def __call__(self, processed_samples: List[Dict[str, Any]]) -> Dict[str, Any]:
111
+ target_length = self.batch_tokens
112
+
113
+ input_ids = torch.cat(
114
+ [s["input_ids"] for s in processed_samples], dim=1
115
+ ) # [C, Total_Len], C is the number of codebook layers of the audio tokenizer
116
+ labels = torch.cat(
117
+ [s["labels"] for s in processed_samples], dim=1
118
+ ) # [C, Total_Len]
119
+ audio_mask = torch.cat(
120
+ [s["audio_mask"] for s in processed_samples], dim=0
121
+ ) # [Total_Len]
122
+
123
+ position_ids = torch.cat(
124
+ [torch.arange(s["length"], dtype=torch.long) for s in processed_samples],
125
+ dim=0,
126
+ ) # [Total_Len]
127
+
128
+ pad_length = target_length - input_ids.shape[1]
129
+
130
+ input_ids = torch.nn.functional.pad(
131
+ input_ids,
132
+ pad=(0, pad_length),
133
+ value=self.processor.text_tokenizer.pad_token_id,
134
+ )
135
+
136
+ labels = torch.nn.functional.pad(labels, pad=(0, pad_length), value=-100)
137
+
138
+ audio_mask = torch.nn.functional.pad(
139
+ audio_mask, pad=(0, pad_length), value=False
140
+ )
141
+
142
+ position_ids = torch.nn.functional.pad(
143
+ position_ids, pad=(0, pad_length), value=0
144
+ )
145
+
146
+ return_list = {
147
+ "input_ids": input_ids.unsqueeze(0), # [1, C, L]
148
+ "labels": labels.unsqueeze(0), # [1, C, L]
149
+ "audio_mask": audio_mask.unsqueeze(0), # [1, L]
150
+ "position_ids": position_ids.unsqueeze(0), # [1, L]
151
+ }
152
+
153
+ document_ids_list = []
154
+
155
+ for i, s in enumerate(processed_samples):
156
+ seq_len = s["length"]
157
+ document_ids_list.append(torch.full((seq_len,), i, dtype=torch.int32))
158
+
159
+ document_ids = torch.cat(document_ids_list, dim=0)
160
+
161
+ document_ids = torch.nn.functional.pad(
162
+ document_ids, pad=(0, pad_length), value=-1
163
+ )
164
+ return_list["document_ids"] = document_ids.unsqueeze(0) # [1, L]
165
+
166
+ return return_list
omnivoice/data/dataset.py ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Dataset and data-loading utilities for training and evaluation.
19
+
20
+ Provides WebDataset-based iterable datasets, manifest parsing, and audio/token
21
+ loading. Used by ``omnivoice.training.builder.build_dataloaders()`` to construct
22
+ train and eval data loaders.
23
+
24
+ Key functions:
25
+ - ``prepare_data_manifests_from_json()``: Parses a data config JSON into train/dev
26
+ manifests.
27
+
28
+ Key classes:
29
+ - ``WebDatasetReader``: Reads audio/text pairs from WebDataset tar shards as an
30
+ iterable dataset.
31
+ - ``MuxWebDatasetReader``: Multiplexes multiple WebDataset readers for
32
+ multilingual data.
33
+ - ``JsonlDatasetReader``: Reads audio/text pairs from a JSONL manifest file.
34
+ Used by data processing scripts (e.g. ``omnivoice/scripts/``).
35
+ - ``SampleDecoder``: Decodes individual samples (audio or tokens + labels).
36
+ """
37
+
38
+ import io
39
+ import json
40
+ import logging
41
+ import os
42
+ import random
43
+ from typing import Any, Dict, Iterator, List, Optional, Tuple
44
+
45
+ import torch
46
+ import torch.distributed as dist
47
+ import webdataset as wds
48
+
49
+ from omnivoice.utils.audio import load_audio, load_audio_bytes
50
+ from torch.utils.data import IterableDataset
51
+
52
+
53
+ def load_audio_webdataset(data, sample_rate: int = 24000, device="cpu"):
54
+ """
55
+ Load audio from bytes data and resample to the target sample rate if needed.
56
+ Return a tensor of shape (1, num_samples)
57
+ """
58
+ audio = torch.from_numpy(load_audio_bytes(data, sample_rate))
59
+ audio = audio.to(device)
60
+ return audio
61
+
62
+
63
+ def prepare_data_manifests_from_json(
64
+ data_config: str,
65
+ ) -> Tuple[List[Tuple[str, str, int, float]], List[Tuple[str, str, int, float]]]:
66
+ """
67
+ Prepare data manifests from a json file.
68
+ A typical multilingual json file is in the following format:
69
+ {
70
+ "train":
71
+ [
72
+ {
73
+ "language_id": "en",
74
+ "manifest_path": [
75
+ "/Emilia/EN/data.lst"
76
+ ],
77
+ "repeat": 1
78
+ },
79
+ {
80
+ "language_id": "zh",
81
+ "manifest_path": [
82
+ "/Emilia/ZH/data.lst"
83
+ ],
84
+ "repeat": 1
85
+ }
86
+ ],
87
+ "dev":
88
+ [
89
+ {
90
+ "language_id": "en",
91
+ "manifest_path": [
92
+ "/Emilia/EN-dev/data.lst"
93
+ ],
94
+ "repeat": 1
95
+ },
96
+ {
97
+ "language_id": "zh",
98
+ "manifest_path": [
99
+ "/Emilia/ZH-dev/data.lst"
100
+ ],
101
+ "repeat": 1
102
+ }
103
+ ]
104
+ }
105
+
106
+ "language_id" is not used, just for better organization of multilingual data.
107
+ "repeat" is an optional field, default to 1, which indicates how many times
108
+ the manifest should be repeated.
109
+
110
+ The simplist format is like:
111
+ {
112
+ "train":
113
+ [
114
+ {
115
+ "manifest_path": [
116
+ "/Emilia/EN/data.lst",
117
+ "/Emilia/ZH/data.lst"
118
+ ],
119
+ }
120
+ ],
121
+ "dev":
122
+ [
123
+ {
124
+ "manifest_path": [
125
+ "/Emilia/EN-dev/data.lst",
126
+ "/Emilia/ZH-dev/data.lst"
127
+ ],
128
+ }
129
+ ]
130
+
131
+ data.lst format (items separated by space):
132
+ /path/to/data.tar /path/to/label.jsonl num_items num_seconds
133
+ """
134
+ train_manifests = []
135
+ dev_manifests = []
136
+ with open(data_config, "r", encoding="utf-8") as f:
137
+ data = json.load(f)
138
+ for item in data["train"]:
139
+ manifest_paths = item["manifest_path"]
140
+ repeat = item.get("repeat", 1)
141
+ for manifest_path in manifest_paths:
142
+ # assert manifest_path is a file
143
+ assert os.path.isfile(manifest_path), f"{manifest_path} is not a file."
144
+ train_manifests.extend(
145
+ webdataset_manifest_reader(manifest_path) * repeat
146
+ )
147
+ if "dev" in data:
148
+ for item in data["dev"]:
149
+ manifest_paths = item["manifest_path"]
150
+ repeat = item.get("repeat", 1)
151
+ for manifest_path in manifest_paths:
152
+ dev_manifests.extend(
153
+ webdataset_manifest_reader(manifest_path) * repeat
154
+ )
155
+ return train_manifests, dev_manifests
156
+
157
+
158
+ def webdataset_manifest_reader(
159
+ manifest_path: str,
160
+ ) -> List[Tuple[str, str]]:
161
+ """
162
+ Read a manifest file containing webdataset tar paths and label jsonl paths.
163
+ Each line in the manifest file is in the format of:
164
+ /path/to/data.tar /path/to/label.jsonl num_items num_seconds
165
+ """
166
+ manifests = []
167
+ with open(manifest_path, "r", encoding="utf-8") as f:
168
+ for line in f:
169
+ line = line.strip()
170
+ if not line:
171
+ continue
172
+ parts = line.split()
173
+ if len(parts) != 4:
174
+ raise ValueError(
175
+ f"Invalid manifest line: {line}. "
176
+ f"Each line must contain "
177
+ "tar_path, label_jsonl_path, num_items, num_seconds."
178
+ )
179
+ tar_path, label_jsonl_path, num_items, num_seconds = (
180
+ parts[0],
181
+ parts[1],
182
+ int(parts[2]),
183
+ float(parts[3]),
184
+ )
185
+ manifests.append((tar_path, label_jsonl_path, num_items, num_seconds))
186
+ return manifests
187
+
188
+
189
+ class SampleDecoder:
190
+ """
191
+ Decode a sample from webdataset, including loading audio/tokens and fetching label.
192
+ """
193
+
194
+ def __init__(
195
+ self,
196
+ tar_to_label: Dict,
197
+ sample_rate: int = 24000,
198
+ audio_format: Optional[Tuple[str]] = None,
199
+ normalize_audio: bool = True,
200
+ ):
201
+ """
202
+ Args:
203
+ tar_to_label:
204
+ A dict mapping from audio tar file to label tar file.
205
+ sample_rate:
206
+ Target sample rate for audio. Required if audio is loaded.
207
+ audio_format:
208
+ Tuple of audio file extensions to look for in the sample.
209
+ """
210
+ self.tar_to_label = tar_to_label
211
+ self.sample_rate = sample_rate
212
+ self.label_dataset = None
213
+ if audio_format is None:
214
+ self.audio_format = ("flac", "wav", "mp3")
215
+ else:
216
+ self.audio_format = audio_format
217
+ self.normalize_audio = normalize_audio
218
+
219
+ def __call__(self, sample):
220
+ return_dict = {}
221
+ src = sample["__url__"]
222
+ key = sample["__key__"]
223
+ if (
224
+ self.label_dataset is None
225
+ or self.label_dataset.path != self.tar_to_label[src]
226
+ ):
227
+ self.label_dataset = LabelDataset(self.tar_to_label[src])
228
+
229
+ audio = torch.empty(0)
230
+ if "npy" in sample:
231
+ audio_tokens = torch.from_numpy(sample["npy"])
232
+ return_dict["audio_tokens"] = audio_tokens
233
+ else:
234
+ for ext in self.audio_format:
235
+ if ext in sample:
236
+ # load audio (1, num_samples)
237
+ audio = load_audio_webdataset(
238
+ sample[ext], sample_rate=self.sample_rate
239
+ )
240
+ if self.normalize_audio:
241
+ audio = (audio / (audio.abs().max() + 1e-7)) * 0.9
242
+ break
243
+ return_dict["audio"] = audio
244
+ return_dict["audio_duration"] = audio.size(-1) / self.sample_rate
245
+
246
+ label = self.label_dataset[key]
247
+
248
+ return_dict["label"] = label
249
+ return return_dict
250
+
251
+
252
+ class LabelDataset:
253
+ def __init__(self, jsonl_path: str):
254
+ """
255
+ Load labels from a jsonl file.
256
+ Args:
257
+ jsonl_path:
258
+ Path to the jsonl file containing labels.
259
+ Each line in the manifest file is in the format of:
260
+ {"idx": "idx", "text": "transcription text"}
261
+ """
262
+ self._labels = {}
263
+ self.path = jsonl_path
264
+ if not os.path.exists(jsonl_path):
265
+ raise FileNotFoundError(f"Label jsonl file {jsonl_path} does not exist.")
266
+ with open(jsonl_path, "r", encoding="utf-8") as f:
267
+ for line in f:
268
+ line = line.strip()
269
+ if not line:
270
+ continue
271
+ item = json.loads(line)
272
+ if "id" in item:
273
+ self._labels[item["id"]] = item
274
+
275
+ def __getitem__(self, key):
276
+ return self._labels[key]
277
+
278
+
279
+ class IterableDataReader:
280
+ "Interfaces for classes reading data."
281
+
282
+ sample_rate: int
283
+
284
+ def set_epoch(self, epoch: int):
285
+ raise NotImplementedError
286
+
287
+ def __iter__(self) -> Iterator[Dict[str, Any]]:
288
+ raise NotImplementedError
289
+
290
+ def __len__(self) -> int:
291
+ raise NotImplementedError
292
+
293
+
294
+ class WrappedIterableDataset(IterableDataset):
295
+ "IterableDataset interfaces in this project."
296
+
297
+ def set_epoch(self, epoch: int):
298
+ raise NotImplementedError
299
+
300
+ def __iter__(self) -> Iterator[List[Dict[str, Any]]]:
301
+ raise NotImplementedError
302
+
303
+
304
+ class WebDatasetReader(IterableDataReader):
305
+ def __init__(
306
+ self,
307
+ manifests: List[Tuple[str, str, int, float]],
308
+ evaluation: bool = False,
309
+ shuffle_buffer_size: int = 20000,
310
+ sample_rate: int = 24000,
311
+ ):
312
+ self.shuffle_buffer_size = shuffle_buffer_size
313
+ self.evaluation = evaluation
314
+ self.epoch = 0
315
+
316
+ self.orig_urls = []
317
+ self.tar_to_label = {}
318
+ self.num_items = 0
319
+ self.num_seconds = 0.0
320
+ for tar_path, label_jsonl_path, num_items, num_seconds in manifests:
321
+ self.orig_urls.append(tar_path)
322
+ self.tar_to_label[tar_path] = label_jsonl_path
323
+ self.num_items += num_items
324
+ self.num_seconds += num_seconds
325
+ self.urls = self.orig_urls.copy()
326
+ self.sample_decoder = SampleDecoder(
327
+ tar_to_label=self.tar_to_label,
328
+ sample_rate=sample_rate,
329
+ )
330
+ self.sample_rate = sample_rate
331
+
332
+ def set_epoch(self, epoch: int):
333
+ """
334
+ Set the epoch for shuffling.
335
+ """
336
+ self.epoch = epoch
337
+ self.urls = self.orig_urls.copy()
338
+ if not self.evaluation:
339
+ random.Random(epoch).shuffle(self.urls)
340
+
341
+ def __iter__(self) -> Iterator[Dict[str, Any]]:
342
+ dataset = wds.WebDataset(
343
+ self.urls,
344
+ shardshuffle=False,
345
+ workersplitter=wds.split_by_worker,
346
+ nodesplitter=wds.split_by_node,
347
+ )
348
+
349
+ pipeline = dataset.decode().map(self.sample_decoder)
350
+ if not self.evaluation:
351
+ pipeline = pipeline.shuffle(self.shuffle_buffer_size, seed=self.epoch)
352
+ return iter(pipeline)
353
+
354
+ def __len__(self) -> int:
355
+ return self.num_items
356
+
357
+
358
+ class JsonlDatasetReader(IterableDataReader):
359
+ """Read raw JSONL and load audio files, matching WebDatasetReader output format.
360
+
361
+ Each JSONL line should be a JSON object with at least:
362
+ {"id": "...", "audio_path": "/path/to/audio.wav", ...}
363
+
364
+ Yields dicts of the form: {"audio": Tensor(1, T), "label": dict}
365
+ """
366
+
367
+ def __init__(
368
+ self,
369
+ jsonl_path: str,
370
+ sample_rate: int = 24_000,
371
+ shuffle: bool = True,
372
+ shuffle_seed: int = 42,
373
+ normalize_audio: bool = True,
374
+ ):
375
+ self.jsonl_path = jsonl_path
376
+ self.sample_rate = sample_rate
377
+ self.shuffle = shuffle
378
+ self.shuffle_seed = shuffle_seed
379
+ self.normalize_audio = normalize_audio
380
+
381
+ def set_epoch(self, epoch: int):
382
+ self.shuffle_seed = epoch
383
+
384
+ def _read_lines(self) -> list[dict]:
385
+ entries = []
386
+ with open(self.jsonl_path, "r", encoding="utf-8") as f:
387
+ for line in f:
388
+ line = line.strip()
389
+ if line:
390
+ entries.append(json.loads(line))
391
+ if self.shuffle:
392
+ random.seed(self.shuffle_seed)
393
+ random.shuffle(entries)
394
+ logging.info(
395
+ f"Shuffled {len(entries)} JSONL entries (seed={self.shuffle_seed})"
396
+ )
397
+ return entries
398
+
399
+ def _stream_lines(self):
400
+ with open(self.jsonl_path, "r", encoding="utf-8") as f:
401
+ for line in f:
402
+ line = line.strip()
403
+ if line:
404
+ yield json.loads(line)
405
+
406
+ def __iter__(self):
407
+ source = self._read_lines() if self.shuffle else self._stream_lines()
408
+
409
+ # Split data across distributed ranks (multi-GPU / DDP)
410
+ if dist.is_initialized():
411
+ rank = dist.get_rank()
412
+ world_size = dist.get_world_size()
413
+ source = [item for i, item in enumerate(source) if i % world_size == rank]
414
+
415
+ # Split data across DataLoader workers to avoid duplication
416
+ worker_info = torch.utils.data.get_worker_info()
417
+ if worker_info is not None:
418
+ source = (
419
+ item
420
+ for i, item in enumerate(source)
421
+ if i % worker_info.num_workers == worker_info.id
422
+ )
423
+
424
+ for meta in source:
425
+ audio_path = meta.get("audio_path")
426
+ if not audio_path or not os.path.exists(audio_path):
427
+ logging.warning(
428
+ f"Skipping {meta.get('id', '?')}: audio_path missing or not found"
429
+ )
430
+ continue
431
+ try:
432
+ waveform = torch.from_numpy(load_audio(audio_path, self.sample_rate))
433
+ if self.normalize_audio:
434
+ waveform = (waveform / (waveform.abs().max() + 1e-7)) * 0.9
435
+ meta["audio_duration"] = waveform.shape[1] / self.sample_rate
436
+ yield {"audio": waveform, "label": meta}
437
+ except Exception as e:
438
+ logging.warning(f"Skipping {meta.get('id', '?')}: {e}")
439
+
440
+
441
+ class MuxWebDatasetReader(IterableDataReader):
442
+ def __init__(
443
+ self,
444
+ readers: List[WebDatasetReader],
445
+ weights: Optional[List[float]] = None,
446
+ stop_early: bool = False,
447
+ seed: int = 0,
448
+ ):
449
+ self.readers = readers
450
+ self.stop_early = stop_early
451
+ self.mux_iterator = LazyIteratorMultiplexer(
452
+ *readers,
453
+ stop_early=stop_early,
454
+ weights=weights,
455
+ seed=seed,
456
+ )
457
+
458
+ def set_epoch(self, epoch: int):
459
+ """
460
+ Set the epoch for shuffling.
461
+ """
462
+ for reader in self.readers:
463
+ reader.set_epoch(epoch)
464
+
465
+ def __iter__(self) -> Iterator[Dict[str, Any]]:
466
+ return iter(self.mux_iterator)
467
+
468
+
469
+ class LazyIteratorMultiplexer:
470
+ """
471
+ A wrapper over multiple iterators that enables to combine
472
+ lazy manifests in Lhotse. During iteration, unlike
473
+ :class:`.LazyIteratorChain`,
474
+ :class:`.LazyIteratorMultiplexer` at each step randomly
475
+ selects the iterable used to yield an item.
476
+
477
+ Since the iterables might be of different length, we provide
478
+ a ``weights`` parameter to let the user decide which iterables
479
+ should be sampled more frequently than others.
480
+ When an iterable is exhausted, we will keep sampling from the other iterables, until
481
+ we exhaust them all, unless ``stop_early`` is set to ``True``.
482
+ """
483
+
484
+ def __init__(
485
+ self,
486
+ *iterators: IterableDataReader,
487
+ stop_early: bool = False,
488
+ weights: Optional[List[float]] = None,
489
+ seed: int = 0,
490
+ ) -> None:
491
+ self.iterators = list(iterators)
492
+ self.stop_early = stop_early
493
+ self.seed = seed
494
+
495
+ assert len(self.iterators) > 1, (
496
+ "There have to be at least two iterables to multiplex."
497
+ )
498
+
499
+ if weights is None:
500
+ if all(hasattr(it, "__len__") for it in self.iterators):
501
+ lengths = [len(it) for it in self.iterators]
502
+ total_length = sum(lengths)
503
+ self.weights = [length / total_length for length in lengths]
504
+ else:
505
+ self.weights = [1] * len(self.iterators)
506
+ else:
507
+ self.weights = weights
508
+
509
+ assert len(self.iterators) == len(self.weights)
510
+
511
+ def __iter__(self):
512
+ rng = random.Random(self.seed)
513
+ iters = [iter(it) for it in self.iterators]
514
+ exhausted = [False for _ in range(len(iters))]
515
+
516
+ def should_continue():
517
+ if self.stop_early:
518
+ return not any(exhausted)
519
+ else:
520
+ return not all(exhausted)
521
+
522
+ while should_continue():
523
+ active_indexes, active_weights = zip(
524
+ *[
525
+ (i, w)
526
+ for i, (is_exhausted, w) in enumerate(zip(exhausted, self.weights))
527
+ if not is_exhausted
528
+ ]
529
+ )
530
+ idx = rng.choices(active_indexes, weights=active_weights, k=1)[0]
531
+ selected = iters[idx]
532
+ try:
533
+ item = next(selected)
534
+ yield item
535
+ except StopIteration:
536
+ exhausted[idx] = True
537
+ continue
538
+
539
+ def __len__(self) -> int:
540
+ return sum(len(iterator) for iterator in self.iterators)
omnivoice/data/processor.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Training sample processor for OmniVoice.
19
+
20
+ Converts raw audio/text samples into model-ready tensors: applies prompt/mask
21
+ tokenization, randomly drops conditioning, and injects language/instruct tokens.
22
+ Used by ``omnivoice.training.builder`` to build the data pipeline.
23
+
24
+ Contains two processor classes:
25
+ - ``OmniVoiceSampleProcessor``: Full processor used for training.
26
+ - ``OmniVoiceSimpleSampleProcessor``: Simplified processor (not used for training).
27
+ """
28
+
29
+ import random
30
+ from typing import Any, Dict
31
+
32
+ import torch
33
+
34
+
35
+ class OmniVoiceSampleProcessor:
36
+ """
37
+ Handles the logic of processing a raw sample into tensors
38
+ (masking, tokenization, etc.).
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ text_tokenizer: Any,
44
+ num_channels: int,
45
+ audio_mask_id: int,
46
+ prompt_ratio_range: tuple,
47
+ mask_ratio_range: tuple,
48
+ drop_cond_ratio: float,
49
+ language_ratio: float,
50
+ use_pinyin_ratio: float,
51
+ instruct_ratio: float,
52
+ only_instruct_ratio: float,
53
+ ):
54
+ self.text_tokenizer = text_tokenizer
55
+ self.num_channels = num_channels
56
+ self.audio_mask_id = audio_mask_id
57
+ self.prompt_ratio_range = prompt_ratio_range
58
+ self.mask_ratio_range = mask_ratio_range
59
+ self.drop_cond_ratio = drop_cond_ratio
60
+
61
+ self.language_ratio = language_ratio
62
+ self.use_pinyin_ratio = use_pinyin_ratio
63
+ self.instruct_ratio = instruct_ratio
64
+ self.only_instruct_ratio = only_instruct_ratio
65
+
66
+ def __call__(self, sample: Dict[str, Any]) -> Dict[str, Any]:
67
+ # clean_start_token_idx is only used for prompt denoising training,
68
+ # where the prompt region is augmented with noises and the model
69
+ # needs to learn to recover the clean prompt.
70
+ # clean_start_token_idx indicates the start index of the clean generated token.
71
+ if "clean_start_token_idx" in sample["label"]:
72
+ drop_cond = False
73
+ else:
74
+ drop_cond = random.uniform(0, 1) < self.drop_cond_ratio
75
+
76
+ if drop_cond:
77
+ prompt_ratio = 0.0
78
+ drop_text = True
79
+ use_language = False
80
+ use_instruct = False
81
+ else:
82
+ prompt_ratio = random.uniform(*self.prompt_ratio_range)
83
+ drop_text = False
84
+ use_language = random.uniform(0, 1) < self.language_ratio
85
+ use_instruct = random.uniform(0, 1) < self.instruct_ratio
86
+ if use_instruct and random.uniform(0, 1) < self.only_instruct_ratio:
87
+ prompt_ratio = 0.0
88
+
89
+ mask_ratio = random.uniform(*self.mask_ratio_range)
90
+
91
+ # --- Style ---
92
+ style = ""
93
+ if use_language:
94
+ language = sample["label"].get("language_id", "None")
95
+ else:
96
+ language = "None"
97
+ if use_instruct:
98
+ instruct = sample["label"].get("instruct", "None")
99
+ else:
100
+ instruct = "None"
101
+
102
+ if "clean_start_token_idx" in sample["label"]:
103
+ style += "<|denoise|>"
104
+
105
+ style += f"<|lang_start|>{language}<|lang_end|>"
106
+ style += f"<|instruct_start|>{instruct}<|instruct_end|>"
107
+
108
+ style_inputs = self.text_tokenizer(style, return_tensors="pt").input_ids.repeat(
109
+ self.num_channels, 1
110
+ )
111
+ style_labels = torch.full(
112
+ style_inputs.shape, -100
113
+ ) # Style prompt does not compute loss
114
+
115
+ # --- Text ---
116
+ if (
117
+ "text_pinyin" in sample["label"]
118
+ and random.uniform(0, 1) < self.use_pinyin_ratio
119
+ ):
120
+ text = sample["label"]["text_pinyin"]
121
+ else:
122
+ text = sample["label"]["text"]
123
+ text_inputs = self.text_tokenizer(
124
+ f"<|text_start|>{text}<|text_end|>", return_tensors="pt"
125
+ ).input_ids.repeat(self.num_channels, 1)
126
+ text_labels = torch.full(text_inputs.shape, -100) # Text does not compute loss
127
+
128
+ # --- Audio ---
129
+ audio_tokens = sample["audio_tokens"].long()
130
+
131
+ # Masking Logic
132
+ if "clean_start_token_idx" in sample["label"]:
133
+ prompt_length = sample["label"]["clean_start_token_idx"]
134
+ else:
135
+ prompt_length = int(audio_tokens.shape[1] * prompt_ratio)
136
+
137
+ audio_inputs = audio_tokens.clone()
138
+ audio_labels = audio_tokens.clone()
139
+
140
+ # Apply masking
141
+ maskable_region = audio_tokens[:, prompt_length:]
142
+ token_mask = torch.rand(maskable_region.shape) < mask_ratio
143
+ audio_inputs[:, prompt_length:][token_mask] = self.audio_mask_id
144
+ audio_labels[:, prompt_length:][
145
+ ~token_mask
146
+ ] = -100 # Only compute loss on masked tokens
147
+ if not drop_cond:
148
+ audio_labels[:, :prompt_length] = -100 # No loss on prompt region
149
+
150
+ # --- Concatenation ---
151
+ if drop_text:
152
+ input_ids = audio_inputs
153
+ labels = audio_labels
154
+ total_length = input_ids.shape[1]
155
+ audio_mask = torch.ones(total_length, dtype=torch.bool)
156
+ else:
157
+ input_ids = torch.cat([style_inputs, text_inputs, audio_inputs], dim=1)
158
+ labels = torch.cat([style_labels, text_labels, audio_labels], dim=1)
159
+ total_length = input_ids.shape[1]
160
+ audio_start_idx = style_inputs.shape[1] + text_inputs.shape[1]
161
+ audio_mask = torch.zeros(total_length, dtype=torch.bool)
162
+ audio_mask[audio_start_idx:] = True
163
+
164
+ return_dict = {
165
+ "input_ids": input_ids, # [C, L]
166
+ "labels": labels, # [C, L]
167
+ "audio_mask": audio_mask, # [L]
168
+ "length": total_length,
169
+ }
170
+
171
+ return return_dict
172
+
173
+
174
+ class OmniVoiceSimpleSampleProcessor:
175
+ """
176
+ Handles the logic of processing a raw sample into tensors
177
+ (masking, tokenization, etc.).
178
+ This is a simpler version that does not include language, instructions,
179
+ or denoising prompts.
180
+ We do not use it for training as OmniVoiceSampleProcessor can cover this case.
181
+ We keep it as a reference implementation for users to understand the basic logics.
182
+ """
183
+
184
+ def __init__(
185
+ self,
186
+ text_tokenizer: Any,
187
+ num_channels: int,
188
+ audio_mask_id: int,
189
+ prompt_ratio_range: tuple,
190
+ mask_ratio_range: tuple,
191
+ drop_cond_ratio: float,
192
+ ):
193
+ self.text_tokenizer = text_tokenizer
194
+ self.num_channels = num_channels
195
+ self.audio_mask_id = audio_mask_id
196
+ self.prompt_ratio_range = prompt_ratio_range
197
+ self.mask_ratio_range = mask_ratio_range
198
+ self.drop_cond_ratio = drop_cond_ratio
199
+
200
+ def __call__(self, sample: Dict[str, Any]) -> Dict[str, Any]:
201
+ drop_cond = random.uniform(0, 1) < self.drop_cond_ratio
202
+ mask_ratio = random.uniform(*self.mask_ratio_range)
203
+
204
+ if drop_cond:
205
+ prompt_ratio = 0.0
206
+ else:
207
+ prompt_ratio = random.uniform(*self.prompt_ratio_range)
208
+
209
+ # --- Text ---
210
+ text = sample["label"]["text"]
211
+ text_inputs = self.text_tokenizer(
212
+ f"<|text_start|>{text}<|text_end|>", return_tensors="pt"
213
+ ).input_ids.repeat(self.num_channels, 1)
214
+ text_labels = torch.full(text_inputs.shape, -100) # Text does not compute loss
215
+
216
+ # --- Audio ---
217
+ audio_tokens = sample["audio_tokens"].long()
218
+
219
+ # Masking Logic
220
+ prompt_length = int(audio_tokens.shape[1] * prompt_ratio)
221
+ audio_inputs = audio_tokens.clone()
222
+ audio_labels = audio_tokens.clone()
223
+
224
+ # Apply masking
225
+ maskable_region = audio_tokens[:, prompt_length:]
226
+ token_mask = torch.rand(maskable_region.shape) < mask_ratio
227
+ audio_inputs[:, prompt_length:][token_mask] = self.audio_mask_id
228
+ audio_labels[:, prompt_length:][
229
+ ~token_mask
230
+ ] = -100 # Only compute loss on masked tokens
231
+
232
+ if not drop_cond:
233
+ # No loss on prompt region
234
+ audio_labels[:, :prompt_length] = -100
235
+
236
+ # --- Concatenation ---
237
+ if drop_cond:
238
+ input_ids = audio_inputs
239
+ labels = audio_labels
240
+ total_length = input_ids.shape[1]
241
+ audio_mask = torch.ones(total_length, dtype=torch.bool)
242
+ else:
243
+ input_ids = torch.cat([text_inputs, audio_inputs], dim=1)
244
+ labels = torch.cat([text_labels, audio_labels], dim=1)
245
+ total_length = input_ids.shape[1]
246
+ audio_start_idx = text_inputs.shape[1]
247
+ audio_mask = torch.zeros(total_length, dtype=torch.bool)
248
+ audio_mask[audio_start_idx:] = True
249
+
250
+ return_dict = {
251
+ "input_ids": input_ids, # [C, L]
252
+ "labels": labels, # [C, L]
253
+ "audio_mask": audio_mask, # [L]
254
+ "length": total_length,
255
+ }
256
+
257
+ return return_dict
omnivoice/data/word_control.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Word-level acoustic control for OmniVoice (WordVoice-5A style).
3
+
4
+ Injects per-word acoustic attribute tags (duration, boundary, energy, pitch,
5
+ tone) as special tokens inline in the text conditioning, following the
6
+ five-dimensional annotation scheme of WordVoice-5A (arXiv:2607.06461).
7
+
8
+ Tag format, prepended to each word (fixed order dur, bnd, eng, pit, ton):
9
+ <|dur_8|><|bnd_0|><|eng_12|><|pit_9|><|ton_flat|>word
10
+
11
+ Attributes are discretized: duration into 40ms frames (64 bins), energy [0,1]
12
+ and pitch [-1,1] into 20 uniform bins, boundary b0-b4, tone 7 morphologies.
13
+ Any subset of tags may be present (partial control); absent tags = free mode.
14
+ """
15
+
16
+ import random
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from omnivoice.data.processor import OmniVoiceSampleProcessor
20
+
21
+ TONE_CLASSES = ["flat", "rise", "rrise", "fall", "ffall", "peak", "valley"]
22
+ BND_CLASSES = ["b0", "b1", "b2", "b3", "b4"]
23
+ NUM_DUR_BINS = 64
24
+ DUR_FRAME_SEC = 0.04
25
+ NUM_ENG_BINS = 20
26
+ NUM_PIT_BINS = 20
27
+
28
+
29
+ def control_token_vocab() -> List[str]:
30
+ """All special tokens for word-level control (116 tokens)."""
31
+ tokens = [f"<|dur_{i}|>" for i in range(NUM_DUR_BINS)]
32
+ tokens += [f"<|bnd_{i}|>" for i in range(len(BND_CLASSES))]
33
+ tokens += [f"<|eng_{i}|>" for i in range(NUM_ENG_BINS)]
34
+ tokens += [f"<|pit_{i}|>" for i in range(NUM_PIT_BINS)]
35
+ tokens += [f"<|ton_{t}|>" for t in TONE_CLASSES]
36
+ return tokens
37
+
38
+
39
+ def dur_bin(seconds: float) -> int:
40
+ return max(0, min(NUM_DUR_BINS - 1, round(seconds / DUR_FRAME_SEC) - 1))
41
+
42
+
43
+ def eng_bin(v: float) -> int:
44
+ return max(0, min(NUM_ENG_BINS - 1, int(v * NUM_ENG_BINS)))
45
+
46
+
47
+ def pit_bin(v: float) -> int:
48
+ return max(0, min(NUM_PIT_BINS - 1, int((v + 1.0) / 2.0 * NUM_PIT_BINS)))
49
+
50
+
51
+ def word_tags(
52
+ dur_s: Optional[float] = None,
53
+ bnd: Optional[str] = None,
54
+ eng: Optional[float] = None,
55
+ pit: Optional[float] = None,
56
+ ton: Optional[str] = None,
57
+ ) -> str:
58
+ """Tag string for one word; None skips that attribute."""
59
+ parts = []
60
+ if dur_s is not None:
61
+ parts.append(f"<|dur_{dur_bin(dur_s)}|>")
62
+ if bnd is not None and bnd in BND_CLASSES:
63
+ parts.append(f"<|bnd_{BND_CLASSES.index(bnd)}|>")
64
+ if eng is not None:
65
+ parts.append(f"<|eng_{eng_bin(eng)}|>")
66
+ if pit is not None:
67
+ parts.append(f"<|pit_{pit_bin(pit)}|>")
68
+ if ton is not None and ton in TONE_CLASSES:
69
+ parts.append(f"<|ton_{ton}|>")
70
+ return "".join(parts)
71
+
72
+
73
+ def _is_cjk(text: str) -> bool:
74
+ return any("一" <= c <= "鿿" for c in text)
75
+
76
+
77
+ def _align_by_find(text: str, words: List[Dict], tag_strs: List[str]) -> Optional[str]:
78
+ """Place tags before each mfa word located sequentially in the original
79
+ text (case-insensitive). Keeps punctuation/casing. None if any word is
80
+ not found in order."""
81
+ hay = text.lower()
82
+ out = []
83
+ ti = 0
84
+ for w, tags in zip(words, tag_strs):
85
+ needle = w["word"].lower()
86
+ j = hay.find(needle, ti)
87
+ if j < 0:
88
+ return None
89
+ out.append(text[ti:j])
90
+ out.append(tags + text[j : j + len(needle)])
91
+ ti = j + len(needle)
92
+ out.append(text[ti:])
93
+ return "".join(out)
94
+
95
+
96
+ def build_tagged_text(
97
+ label: Dict[str, Any],
98
+ rng: random.Random,
99
+ drop_all_p: float = 0.0,
100
+ word_drop_p: float = 0.0,
101
+ attr_keep_p: float = 1.0,
102
+ ) -> str:
103
+ """Build tag-annotated text from a WordVoice-5A label dict.
104
+
105
+ Alignment cascade: whitespace tokens (en fast path) -> sequential
106
+ case-insensitive find in the original text (zh chars, punctuation-attached
107
+ en words) -> mfa_text tokens. Returns plain text when annotations are
108
+ missing or the utterance-level drop fires.
109
+ """
110
+ words = label.get("mfa_words")
111
+ text = label.get("text", "")
112
+ if not words or rng.uniform(0, 1) < drop_all_p:
113
+ return text
114
+
115
+ n = len(words)
116
+ f0 = label.get("f0") or [None] * n
117
+ eng = label.get("eng") or [None] * n
118
+ ton = label.get("tone") or [None] * n
119
+ bnd = label.get("bnd") or [None] * n
120
+ if not (len(f0) == len(eng) == len(ton) == len(bnd) == n):
121
+ return text
122
+
123
+ tag_strs = []
124
+ for i in range(n):
125
+ if rng.uniform(0, 1) < word_drop_p:
126
+ tag_strs.append("")
127
+ continue
128
+ keep = [rng.uniform(0, 1) < attr_keep_p for _ in range(5)]
129
+ w = words[i]
130
+ tag_strs.append(
131
+ word_tags(
132
+ dur_s=(w["end"] - w["start"]) if keep[0] else None,
133
+ bnd=bnd[i] if keep[1] else None,
134
+ eng=eng[i] if keep[2] else None,
135
+ pit=f0[i] if keep[3] else None,
136
+ ton=ton[i] if keep[4] else None,
137
+ )
138
+ )
139
+
140
+ text_tokens = text.split()
141
+ if len(text_tokens) == n and not _is_cjk(text):
142
+ return " ".join(t + tok for t, tok in zip(tag_strs, text_tokens))
143
+
144
+ found = _align_by_find(text, words, tag_strs)
145
+ if found is not None:
146
+ return found
147
+
148
+ mfa_tokens = label.get("mfa_text", "").split()
149
+ if len(mfa_tokens) == n:
150
+ sep = "" if _is_cjk(text) else " "
151
+ return sep.join(t + tok for t, tok in zip(tag_strs, mfa_tokens))
152
+ return text
153
+
154
+
155
+ class WordControlSampleProcessor(OmniVoiceSampleProcessor):
156
+ """OmniVoiceSampleProcessor that rewrites text with word-level control tags."""
157
+
158
+ def __init__(
159
+ self,
160
+ *args,
161
+ wc_drop_all_ratio: float = 0.15,
162
+ wc_word_drop_ratio: float = 0.2,
163
+ wc_attr_keep_ratio: float = 0.7,
164
+ wc_deterministic: bool = False,
165
+ **kwargs,
166
+ ):
167
+ super().__init__(*args, **kwargs)
168
+ self.wc_drop_all_ratio = wc_drop_all_ratio
169
+ self.wc_word_drop_ratio = wc_word_drop_ratio
170
+ self.wc_attr_keep_ratio = wc_attr_keep_ratio
171
+ self.wc_deterministic = wc_deterministic
172
+
173
+ def __call__(self, sample: Dict[str, Any]) -> Dict[str, Any]:
174
+ label = sample["label"]
175
+ if self.wc_deterministic:
176
+ rng = random.Random(hash(label.get("id", "")) & 0xFFFFFFFF)
177
+ tagged = build_tagged_text(label, rng, 0.0, 0.0, 1.0)
178
+ else:
179
+ tagged = build_tagged_text(
180
+ label,
181
+ random,
182
+ self.wc_drop_all_ratio,
183
+ self.wc_word_drop_ratio,
184
+ self.wc_attr_keep_ratio,
185
+ )
186
+ sample = dict(sample)
187
+ sample["label"] = dict(label, text=tagged)
188
+ return super().__call__(sample)
omnivoice/eval/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import warnings
2
+
3
+ # Suppress specific warnings from zhconv that are not relevant to WER calculation
4
+ warnings.filterwarnings("ignore", category=UserWarning)
omnivoice/eval/models/ecapa_tdnn_wavlm.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ import os
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ import torch.nn.functional as F
23
+
24
+
25
+ class ECAPA_TDNN_WAVLM(nn.Module):
26
+ def __init__(
27
+ self,
28
+ feat_dim=80,
29
+ channels=512,
30
+ emb_dim=192,
31
+ global_context_att=False,
32
+ sr=16000,
33
+ ssl_model_path=None,
34
+ ):
35
+ super().__init__()
36
+ self.sr = sr
37
+
38
+ if ssl_model_path is None:
39
+ self.feature_extract = torch.hub.load("s3prl/s3prl", "wavlm_large")
40
+ else:
41
+ self.feature_extract = torch.hub.load(
42
+ os.path.dirname(ssl_model_path),
43
+ "wavlm_local",
44
+ source="local",
45
+ ckpt=os.path.join(ssl_model_path, "wavlm_large.pt"),
46
+ )
47
+
48
+ if len(self.feature_extract.model.encoder.layers) == 24 and hasattr(
49
+ self.feature_extract.model.encoder.layers[23].self_attn,
50
+ "fp32_attention",
51
+ ):
52
+ self.feature_extract.model.encoder.layers[
53
+ 23
54
+ ].self_attn.fp32_attention = False
55
+ if len(self.feature_extract.model.encoder.layers) == 24 and hasattr(
56
+ self.feature_extract.model.encoder.layers[11].self_attn,
57
+ "fp32_attention",
58
+ ):
59
+ self.feature_extract.model.encoder.layers[
60
+ 11
61
+ ].self_attn.fp32_attention = False
62
+
63
+ self.feat_num = self.get_feat_num()
64
+ self.feature_weight = nn.Parameter(torch.zeros(self.feat_num))
65
+
66
+ self.instance_norm = nn.InstanceNorm1d(feat_dim)
67
+ # self.channels = [channels] * 4 + [channels * 3]
68
+ self.channels = [channels] * 4 + [1536]
69
+
70
+ self.layer1 = Conv1dReluBn(feat_dim, self.channels[0], kernel_size=5, padding=2)
71
+ self.layer2 = SE_Res2Block(
72
+ self.channels[0],
73
+ self.channels[1],
74
+ kernel_size=3,
75
+ stride=1,
76
+ padding=2,
77
+ dilation=2,
78
+ scale=8,
79
+ se_bottleneck_dim=128,
80
+ )
81
+ self.layer3 = SE_Res2Block(
82
+ self.channels[1],
83
+ self.channels[2],
84
+ kernel_size=3,
85
+ stride=1,
86
+ padding=3,
87
+ dilation=3,
88
+ scale=8,
89
+ se_bottleneck_dim=128,
90
+ )
91
+ self.layer4 = SE_Res2Block(
92
+ self.channels[2],
93
+ self.channels[3],
94
+ kernel_size=3,
95
+ stride=1,
96
+ padding=4,
97
+ dilation=4,
98
+ scale=8,
99
+ se_bottleneck_dim=128,
100
+ )
101
+
102
+ # self.conv = nn.Conv1d(self.channels[-1], self.channels[-1], kernel_size=1)
103
+ cat_channels = channels * 3
104
+ self.conv = nn.Conv1d(cat_channels, self.channels[-1], kernel_size=1)
105
+ self.pooling = AttentiveStatsPool(
106
+ self.channels[-1],
107
+ attention_channels=128,
108
+ global_context_att=global_context_att,
109
+ )
110
+ self.bn = nn.BatchNorm1d(self.channels[-1] * 2)
111
+ self.linear = nn.Linear(self.channels[-1] * 2, emb_dim)
112
+
113
+ def get_feat_num(self):
114
+ self.feature_extract.eval()
115
+ wav = [torch.randn(self.sr).to(next(self.feature_extract.parameters()).device)]
116
+ with torch.no_grad():
117
+ features = self.feature_extract(wav)
118
+ select_feature = features["hidden_states"]
119
+ if isinstance(select_feature, (list, tuple)):
120
+ return len(select_feature)
121
+ else:
122
+ return 1
123
+
124
+ def get_feat(self, x):
125
+ with torch.no_grad():
126
+ x = self.feature_extract([sample for sample in x])
127
+
128
+ x = x["hidden_states"]
129
+ if isinstance(x, (list, tuple)):
130
+ x = torch.stack(x, dim=0)
131
+ else:
132
+ x = x.unsqueeze(0)
133
+ norm_weights = (
134
+ F.softmax(self.feature_weight, dim=-1)
135
+ .unsqueeze(-1)
136
+ .unsqueeze(-1)
137
+ .unsqueeze(-1)
138
+ )
139
+ x = (norm_weights * x).sum(dim=0)
140
+ x = torch.transpose(x, 1, 2) + 1e-6
141
+
142
+ x = self.instance_norm(x)
143
+ return x
144
+
145
+ def forward(self, x):
146
+ x = self.get_feat(x)
147
+
148
+ out1 = self.layer1(x)
149
+ out2 = self.layer2(out1)
150
+ out3 = self.layer3(out2)
151
+ out4 = self.layer4(out3)
152
+
153
+ out = torch.cat([out2, out3, out4], dim=1)
154
+ out = F.relu(self.conv(out))
155
+ out = self.bn(self.pooling(out))
156
+ out = self.linear(out)
157
+
158
+ return out
159
+
160
+
161
+ # part of the code is borrowed from https://github.com/lawlict/ECAPA-TDNN
162
+
163
+ """ Res2Conv1d + BatchNorm1d + ReLU
164
+ """
165
+
166
+
167
+ class Res2Conv1dReluBn(nn.Module):
168
+ """
169
+ in_channels == out_channels == channels
170
+ """
171
+
172
+ def __init__(
173
+ self,
174
+ channels,
175
+ kernel_size=1,
176
+ stride=1,
177
+ padding=0,
178
+ dilation=1,
179
+ bias=True,
180
+ scale=4,
181
+ ):
182
+ super().__init__()
183
+ assert channels % scale == 0, "{} % {} != 0".format(channels, scale)
184
+ self.scale = scale
185
+ self.width = channels // scale
186
+ self.nums = scale if scale == 1 else scale - 1
187
+
188
+ self.convs = []
189
+ self.bns = []
190
+ for i in range(self.nums):
191
+ self.convs.append(
192
+ nn.Conv1d(
193
+ self.width,
194
+ self.width,
195
+ kernel_size,
196
+ stride,
197
+ padding,
198
+ dilation,
199
+ bias=bias,
200
+ )
201
+ )
202
+ self.bns.append(nn.BatchNorm1d(self.width))
203
+ self.convs = nn.ModuleList(self.convs)
204
+ self.bns = nn.ModuleList(self.bns)
205
+
206
+ def forward(self, x):
207
+ out = []
208
+ spx = torch.split(x, self.width, 1)
209
+ for i in range(self.nums):
210
+ if i == 0:
211
+ sp = spx[i]
212
+ else:
213
+ sp = sp + spx[i]
214
+ # Order: conv -> relu -> bn
215
+ sp = self.convs[i](sp)
216
+ sp = self.bns[i](F.relu(sp))
217
+ out.append(sp)
218
+ if self.scale != 1:
219
+ out.append(spx[self.nums])
220
+ out = torch.cat(out, dim=1)
221
+
222
+ return out
223
+
224
+
225
+ """ Conv1d + BatchNorm1d + ReLU
226
+ """
227
+
228
+
229
+ class Conv1dReluBn(nn.Module):
230
+ def __init__(
231
+ self,
232
+ in_channels,
233
+ out_channels,
234
+ kernel_size=1,
235
+ stride=1,
236
+ padding=0,
237
+ dilation=1,
238
+ bias=True,
239
+ ):
240
+ super().__init__()
241
+ self.conv = nn.Conv1d(
242
+ in_channels,
243
+ out_channels,
244
+ kernel_size,
245
+ stride,
246
+ padding,
247
+ dilation,
248
+ bias=bias,
249
+ )
250
+ self.bn = nn.BatchNorm1d(out_channels)
251
+
252
+ def forward(self, x):
253
+ return self.bn(F.relu(self.conv(x)))
254
+
255
+
256
+ """ The SE connection of 1D case.
257
+ """
258
+
259
+
260
+ class SE_Connect(nn.Module):
261
+ def __init__(self, channels, se_bottleneck_dim=128):
262
+ super().__init__()
263
+ self.linear1 = nn.Linear(channels, se_bottleneck_dim)
264
+ self.linear2 = nn.Linear(se_bottleneck_dim, channels)
265
+
266
+ def forward(self, x):
267
+ out = x.mean(dim=2)
268
+ out = F.relu(self.linear1(out))
269
+ out = torch.sigmoid(self.linear2(out))
270
+ out = x * out.unsqueeze(2)
271
+
272
+ return out
273
+
274
+
275
+ """ SE-Res2Block of the ECAPA-TDNN architecture.
276
+ """
277
+
278
+
279
+ # def SE_Res2Block(channels, kernel_size, stride, padding, dilation, scale):
280
+ # return nn.Sequential(
281
+ # Conv1dReluBn(channels, 512, kernel_size=1, stride=1, padding=0),
282
+ # Res2Conv1dReluBn(512, kernel_size, stride, padding, dilation, scale=scale),
283
+ # Conv1dReluBn(512, channels, kernel_size=1, stride=1, padding=0),
284
+ # SE_Connect(channels)
285
+ # )
286
+
287
+
288
+ class SE_Res2Block(nn.Module):
289
+ def __init__(
290
+ self,
291
+ in_channels,
292
+ out_channels,
293
+ kernel_size,
294
+ stride,
295
+ padding,
296
+ dilation,
297
+ scale,
298
+ se_bottleneck_dim,
299
+ ):
300
+ super().__init__()
301
+ self.Conv1dReluBn1 = Conv1dReluBn(
302
+ in_channels, out_channels, kernel_size=1, stride=1, padding=0
303
+ )
304
+ self.Res2Conv1dReluBn = Res2Conv1dReluBn(
305
+ out_channels, kernel_size, stride, padding, dilation, scale=scale
306
+ )
307
+ self.Conv1dReluBn2 = Conv1dReluBn(
308
+ out_channels, out_channels, kernel_size=1, stride=1, padding=0
309
+ )
310
+ self.SE_Connect = SE_Connect(out_channels, se_bottleneck_dim)
311
+
312
+ self.shortcut = None
313
+ if in_channels != out_channels:
314
+ self.shortcut = nn.Conv1d(
315
+ in_channels=in_channels,
316
+ out_channels=out_channels,
317
+ kernel_size=1,
318
+ )
319
+
320
+ def forward(self, x):
321
+ residual = x
322
+ if self.shortcut:
323
+ residual = self.shortcut(x)
324
+
325
+ x = self.Conv1dReluBn1(x)
326
+ x = self.Res2Conv1dReluBn(x)
327
+ x = self.Conv1dReluBn2(x)
328
+ x = self.SE_Connect(x)
329
+
330
+ return x + residual
331
+
332
+
333
+ """ Attentive weighted mean and standard deviation pooling.
334
+ """
335
+
336
+
337
+ class AttentiveStatsPool(nn.Module):
338
+ def __init__(self, in_dim, attention_channels=128, global_context_att=False):
339
+ super().__init__()
340
+ self.global_context_att = global_context_att
341
+
342
+ # Use Conv1d with stride == 1 rather than Linear,
343
+ # then we don't need to transpose inputs.
344
+ if global_context_att:
345
+ self.linear1 = nn.Conv1d(
346
+ in_dim * 3, attention_channels, kernel_size=1
347
+ ) # equals W and b in the paper
348
+ else:
349
+ self.linear1 = nn.Conv1d(
350
+ in_dim, attention_channels, kernel_size=1
351
+ ) # equals W and b in the paper
352
+ self.linear2 = nn.Conv1d(
353
+ attention_channels, in_dim, kernel_size=1
354
+ ) # equals V and k in the paper
355
+
356
+ def forward(self, x):
357
+ if self.global_context_att:
358
+ context_mean = torch.mean(x, dim=-1, keepdim=True).expand_as(x)
359
+ context_std = torch.sqrt(
360
+ torch.var(x, dim=-1, keepdim=True) + 1e-10
361
+ ).expand_as(x)
362
+ x_in = torch.cat((x, context_mean, context_std), dim=1)
363
+ else:
364
+ x_in = x
365
+
366
+ # DON'T use ReLU here! In experiments, I find ReLU hard to converge.
367
+ alpha = torch.tanh(self.linear1(x_in))
368
+ # alpha = F.relu(self.linear1(x_in))
369
+ alpha = torch.softmax(self.linear2(alpha), dim=2)
370
+ mean = torch.sum(alpha * x, dim=2)
371
+ residuals = torch.sum(alpha * (x**2), dim=2) - mean**2
372
+ std = torch.sqrt(residuals.clamp(min=1e-9))
373
+ return torch.cat([mean, std], dim=1)
omnivoice/eval/models/utmos.py ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ UTMOS strong model.
20
+ Implementation from https://github.com/tarepan/SpeechMOS
21
+
22
+ """
23
+
24
+ import math
25
+ from typing import List, Optional, Tuple
26
+
27
+ import torch
28
+ import torch.nn.functional as F
29
+ from torch import Tensor, nn
30
+
31
+
32
+ class UTMOS22Strong(nn.Module):
33
+ """Saeki_2022 paper's `UTMOS strong learner` inference model
34
+ (w/o Phoneme encoder)."""
35
+
36
+ def __init__(self):
37
+ """Init."""
38
+
39
+ super().__init__() # pyright: ignore [reportUnknownMemberType]
40
+
41
+ feat_ssl, feat_domain_emb, feat_judge_emb, feat_rnn_h, feat_proj_h = (
42
+ 768,
43
+ 128,
44
+ 128,
45
+ 512,
46
+ 2048,
47
+ )
48
+ feat_cat = feat_ssl + feat_domain_emb + feat_judge_emb
49
+
50
+ # SSL/DataDomainEmb/JudgeIdEmb/BLSTM/Projection
51
+ self.wav2vec2 = Wav2Vec2Model()
52
+ self.domain_emb = nn.Parameter(
53
+ data=torch.empty(1, feat_domain_emb), requires_grad=False
54
+ )
55
+ self.judge_emb = nn.Parameter(
56
+ data=torch.empty(1, feat_judge_emb), requires_grad=False
57
+ )
58
+ self.blstm = nn.LSTM(
59
+ input_size=feat_cat,
60
+ hidden_size=feat_rnn_h,
61
+ batch_first=True,
62
+ bidirectional=True,
63
+ )
64
+ self.projection = nn.Sequential(
65
+ nn.Linear(feat_rnn_h * 2, feat_proj_h), nn.ReLU(), nn.Linear(feat_proj_h, 1)
66
+ )
67
+
68
+ def forward(self, wave: Tensor, sr: int) -> Tensor: # pylint: disable=invalid-name
69
+ """wave-to-score :: (B, T) -> (B,)"""
70
+
71
+ # Feature extraction :: (B, T) -> (B, Frame, Feat)
72
+ unit_series = self.wav2vec2(wave)
73
+ bsz, frm, _ = unit_series.size()
74
+
75
+ # DataDomain/JudgeId Embedding's Batch/Time expansion ::
76
+ # (B=1, Feat) -> (B=bsz, Frame=frm, Feat)
77
+ domain_series = self.domain_emb.unsqueeze(1).expand(bsz, frm, -1)
78
+ judge_series = self.judge_emb.unsqueeze(1).expand(bsz, frm, -1)
79
+
80
+ # Feature concatenation :: (B, Frame, Feat=f1) + (B, Frame, Feat=f2) +
81
+ # (B, Frame, Feat=f3) -> (B, Frame, Feat=f1+f2+f3)
82
+ cat_series = torch.cat([unit_series, domain_series, judge_series], dim=2)
83
+
84
+ # Frame-scale score estimation :: (B, Frame, Feat) -> (B, Frame, Feat)
85
+ # -> (B, Frame, Feat=1) - BLSTM/Projection
86
+ feat_series = self.blstm(cat_series)[0]
87
+ score_series = self.projection(feat_series)
88
+
89
+ # Utterance-scale score :: (B, Frame, Feat=1) -> (B, Feat=1)
90
+ # -> (B,) - Time averaging
91
+ utter_score = score_series.mean(dim=1).squeeze(1) * 2 + 3
92
+
93
+ return utter_score
94
+
95
+
96
+ class Wav2Vec2Model(nn.Module):
97
+ """Wav2Vev2."""
98
+
99
+ def __init__(self):
100
+ super().__init__() # pyright: ignore [reportUnknownMemberType]
101
+
102
+ feat_h1, feat_h2 = 512, 768
103
+ feature_enc_layers = (
104
+ [(feat_h1, 10, 5)] + [(feat_h1, 3, 2)] * 4 + [(feat_h1, 2, 2)] * 2
105
+ )
106
+
107
+ self.feature_extractor = ConvFeatureExtractionModel(
108
+ conv_layers=feature_enc_layers
109
+ ) # pyright: ignore [reportGeneralTypeIssues]
110
+ self.layer_norm = nn.LayerNorm(feat_h1)
111
+ self.post_extract_proj = nn.Linear(feat_h1, feat_h2)
112
+ self.dropout_input = nn.Dropout(0.1)
113
+ self.encoder = TransformerEncoder(feat_h2)
114
+
115
+ # Remnants
116
+ self.mask_emb = nn.Parameter(torch.FloatTensor(feat_h2))
117
+
118
+ def forward(self, source: Tensor):
119
+ """FeatureEncoder + ContextTransformer"""
120
+
121
+ # Feature encoding
122
+ features = self.feature_extractor(source)
123
+ features = features.transpose(1, 2)
124
+ features = self.layer_norm(features)
125
+ features = self.post_extract_proj(features)
126
+
127
+ # Context transformer
128
+ x = self.encoder(features)
129
+
130
+ return x
131
+
132
+
133
+ class ConvFeatureExtractionModel(nn.Module):
134
+ """Feature Encoder."""
135
+
136
+ def __init__(self, conv_layers: List[Tuple[int, int, int]]):
137
+ super().__init__() # pyright: ignore [reportUnknownMemberType]
138
+
139
+ def block(
140
+ n_in: int, n_out: int, k: int, stride: int, is_group_norm: bool = False
141
+ ):
142
+ if is_group_norm:
143
+ return nn.Sequential(
144
+ nn.Conv1d(n_in, n_out, k, stride=stride, bias=False),
145
+ nn.Dropout(p=0.0),
146
+ nn.GroupNorm(dim, dim, affine=True),
147
+ nn.GELU(),
148
+ )
149
+ else:
150
+ return nn.Sequential(
151
+ nn.Conv1d(n_in, n_out, k, stride=stride, bias=False),
152
+ nn.Dropout(p=0.0),
153
+ nn.GELU(),
154
+ )
155
+
156
+ in_d = 1
157
+ self.conv_layers = nn.ModuleList()
158
+ for i, params in enumerate(conv_layers):
159
+ (dim, k, stride) = params
160
+ self.conv_layers.append(block(in_d, dim, k, stride, is_group_norm=i == 0))
161
+ in_d = dim
162
+
163
+ def forward(self, series: Tensor) -> Tensor:
164
+ """:: (B, T) -> (B, Feat, Frame)"""
165
+
166
+ series = series.unsqueeze(1)
167
+ for conv in self.conv_layers:
168
+ series = conv(series)
169
+
170
+ return series
171
+
172
+
173
+ class TransformerEncoder(nn.Module):
174
+ """Transformer."""
175
+
176
+ def build_encoder_layer(self, feat: int):
177
+ """Layer builder."""
178
+ return TransformerSentenceEncoderLayer(
179
+ embedding_dim=feat,
180
+ ffn_embedding_dim=3072,
181
+ num_attention_heads=12,
182
+ activation_fn="gelu",
183
+ dropout=0.1,
184
+ attention_dropout=0.1,
185
+ activation_dropout=0.0,
186
+ layer_norm_first=False,
187
+ )
188
+
189
+ def __init__(self, feat: int):
190
+ super().__init__() # pyright: ignore [reportUnknownMemberType]
191
+
192
+ self.required_seq_len_multiple = 2
193
+
194
+ self.pos_conv = nn.Sequential(
195
+ *[
196
+ nn.utils.weight_norm(
197
+ nn.Conv1d(feat, feat, kernel_size=128, padding=128 // 2, groups=16),
198
+ name="weight",
199
+ dim=2,
200
+ ),
201
+ SamePad(128),
202
+ nn.GELU(),
203
+ ]
204
+ )
205
+ self.layer_norm = nn.LayerNorm(feat)
206
+ self.layers = nn.ModuleList([self.build_encoder_layer(feat) for _ in range(12)])
207
+
208
+ def forward(self, x: Tensor) -> Tensor:
209
+ x_conv = self.pos_conv(x.transpose(1, 2)).transpose(1, 2)
210
+ x = x + x_conv
211
+
212
+ x = self.layer_norm(x)
213
+
214
+ # pad to the sequence length dimension
215
+ x, pad_length = pad_to_multiple(
216
+ x, self.required_seq_len_multiple, dim=-2, value=0
217
+ )
218
+ if pad_length > 0:
219
+ padding_mask = x.new_zeros((x.size(0), x.size(1)), dtype=torch.bool)
220
+ padding_mask[:, -pad_length:] = True
221
+ else:
222
+ padding_mask, _ = pad_to_multiple(
223
+ None, self.required_seq_len_multiple, dim=-1, value=True
224
+ )
225
+
226
+ # :: (B, T, Feat) -> (T, B, Feat)
227
+ x = x.transpose(0, 1)
228
+ for layer in self.layers:
229
+ x = layer(x, padding_mask)
230
+ # :: (T, B, Feat) -> (B, T, Feat)
231
+ x = x.transpose(0, 1)
232
+
233
+ # undo paddding
234
+ if pad_length > 0:
235
+ x = x[:, :-pad_length]
236
+
237
+ return x
238
+
239
+
240
+ class SamePad(nn.Module):
241
+ """Tail inverse padding."""
242
+
243
+ def __init__(self, kernel_size: int):
244
+ super().__init__() # pyright: ignore [reportUnknownMemberType]
245
+ assert kernel_size % 2 == 0, "`SamePad` now support only even kernel."
246
+
247
+ def forward(self, x: Tensor) -> Tensor:
248
+ return x[:, :, :-1]
249
+
250
+
251
+ def pad_to_multiple(
252
+ x: Optional[Tensor], multiple: int, dim: int = -1, value: float = 0
253
+ ) -> Tuple[Optional[Tensor], int]:
254
+ """Tail padding."""
255
+ if x is None:
256
+ return None, 0
257
+ tsz = x.size(dim)
258
+ m = tsz / multiple
259
+ remainder = math.ceil(m) * multiple - tsz
260
+ if m.is_integer():
261
+ return x, 0
262
+ pad_offset = (0,) * (-1 - dim) * 2
263
+
264
+ return F.pad(x, (*pad_offset, 0, remainder), value=value), remainder
265
+
266
+
267
+ class TransformerSentenceEncoderLayer(nn.Module):
268
+ """Transformer Encoder Layer used in BERT/XLM style pre-trained models."""
269
+
270
+ def __init__(
271
+ self,
272
+ embedding_dim: int,
273
+ ffn_embedding_dim: int,
274
+ num_attention_heads: int,
275
+ activation_fn: str,
276
+ dropout: float,
277
+ attention_dropout: float,
278
+ activation_dropout: float,
279
+ layer_norm_first: bool,
280
+ ) -> None:
281
+ super().__init__() # pyright: ignore [reportUnknownMemberType]
282
+
283
+ assert layer_norm_first is False, "`layer_norm_first` is fixed to `False`"
284
+ assert activation_fn == "gelu", "`activation_fn` is fixed to `gelu`"
285
+
286
+ feat = embedding_dim
287
+
288
+ self.self_attn = MultiheadAttention(
289
+ feat, num_attention_heads, attention_dropout
290
+ )
291
+ self.dropout1 = nn.Dropout(dropout)
292
+ self.dropout2 = nn.Dropout(activation_dropout)
293
+ self.dropout3 = nn.Dropout(dropout)
294
+ self.fc1 = nn.Linear(feat, ffn_embedding_dim)
295
+ self.fc2 = nn.Linear(ffn_embedding_dim, feat)
296
+ self.self_attn_layer_norm = nn.LayerNorm(feat)
297
+ self.final_layer_norm = nn.LayerNorm(feat)
298
+
299
+ def forward(self, x: Tensor, self_attn_padding_mask: Optional[Tensor]):
300
+ # Res[Attn-Do]-LN
301
+ residual = x
302
+ x = self.self_attn(x, x, x, self_attn_padding_mask)
303
+ x = self.dropout1(x)
304
+ x = residual + x
305
+ x = self.self_attn_layer_norm(x)
306
+
307
+ # Res[SegFC-GELU-Do-SegFC-Do]-LN
308
+ residual = x
309
+ x = F.gelu(self.fc1(x)) # pyright: ignore [reportUnknownMemberType]
310
+ x = self.dropout2(x)
311
+ x = self.fc2(x)
312
+ x = self.dropout3(x)
313
+ x = residual + x
314
+ x = self.final_layer_norm(x)
315
+
316
+ return x
317
+
318
+
319
+ class MultiheadAttention(nn.Module):
320
+ """Multi-headed attention."""
321
+
322
+ def __init__(self, embed_dim: int, num_heads: int, dropout: float):
323
+ super().__init__() # pyright: ignore [reportUnknownMemberType]
324
+
325
+ self.embed_dim, self.num_heads, self.p_dropout = embed_dim, num_heads, dropout
326
+ self.q_proj = nn.Linear(embed_dim, embed_dim, bias=True)
327
+ self.k_proj = nn.Linear(embed_dim, embed_dim, bias=True)
328
+ self.v_proj = nn.Linear(embed_dim, embed_dim, bias=True)
329
+ self.out_proj = nn.Linear(embed_dim, embed_dim, bias=True)
330
+
331
+ def forward(
332
+ self,
333
+ query: Tensor,
334
+ key: Tensor,
335
+ value: Tensor,
336
+ key_padding_mask: Optional[Tensor],
337
+ ) -> Tensor:
338
+ """
339
+ Args:
340
+ query :: (T, B, Feat)
341
+ key_padding_mask :: (B, src_len) - mask to exclude keys that are pads
342
+ , where padding elements are indicated by 1s.
343
+ """
344
+ return F.multi_head_attention_forward(
345
+ query=query,
346
+ key=key,
347
+ value=value,
348
+ embed_dim_to_check=self.embed_dim,
349
+ num_heads=self.num_heads,
350
+ in_proj_weight=torch.empty([0]),
351
+ in_proj_bias=torch.cat(
352
+ (self.q_proj.bias, self.k_proj.bias, self.v_proj.bias)
353
+ ),
354
+ bias_k=None,
355
+ bias_v=None,
356
+ add_zero_attn=False,
357
+ dropout_p=self.p_dropout,
358
+ out_proj_weight=self.out_proj.weight,
359
+ out_proj_bias=self.out_proj.bias,
360
+ training=False,
361
+ key_padding_mask=key_padding_mask.bool()
362
+ if key_padding_mask is not None
363
+ else None,
364
+ need_weights=False,
365
+ use_separate_proj_weight=True,
366
+ q_proj_weight=self.q_proj.weight,
367
+ k_proj_weight=self.k_proj.weight,
368
+ v_proj_weight=self.v_proj.weight,
369
+ )[0]
omnivoice/eval/mos/utmos.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ Calculate UTMOS score with automatic Mean Opinion Score (MOS) prediction system
20
+ """
21
+
22
+ import argparse
23
+ import logging
24
+ import multiprocessing as mp
25
+ import os
26
+ import sys
27
+ import traceback
28
+ import warnings
29
+ from concurrent.futures import ProcessPoolExecutor, as_completed
30
+
31
+ import numpy as np
32
+ import torch
33
+ from tqdm import tqdm
34
+
35
+ from omnivoice.eval.models.utmos import UTMOS22Strong
36
+ from omnivoice.eval.utils import load_eval_waveform
37
+ from omnivoice.utils.data_utils import read_test_list
38
+
39
+ warnings.filterwarnings("ignore")
40
+
41
+ # Global variables for workers
42
+ worker_model = None
43
+ worker_device = None
44
+ worker_sr = 16000
45
+
46
+
47
+ def get_parser() -> argparse.ArgumentParser:
48
+ parser = argparse.ArgumentParser(
49
+ description="Calculate UTMOS score using UTMOS22Strong model."
50
+ )
51
+ parser.add_argument(
52
+ "--wav-path",
53
+ type=str,
54
+ required=True,
55
+ help="Path to the directory containing evaluated speech files.",
56
+ )
57
+ parser.add_argument(
58
+ "--test-list",
59
+ type=str,
60
+ required=True,
61
+ help="Path to the JSONL test list. Each line is a JSON object "
62
+ "with fields: id, text, ref_audio, ref_text, language_id, language_name.",
63
+ )
64
+ parser.add_argument(
65
+ "--model-dir",
66
+ type=str,
67
+ required=True,
68
+ help="Local path of our evaluation model repository."
69
+ "Download from https://huggingface.co/k2-fsa/TTS_eval_models."
70
+ "Will use 'tts_eval_models/mos/utmos22_strong_step7459_v1.pt'"
71
+ " in this script",
72
+ )
73
+ parser.add_argument(
74
+ "--extension",
75
+ type=str,
76
+ default="wav",
77
+ help="Extension of the speech files. Default: wav",
78
+ )
79
+ parser.add_argument(
80
+ "--decode-path",
81
+ type=str,
82
+ default=None,
83
+ help="Path to the output file where UTMOS information will be saved. "
84
+ "If not provided, results are only printed to console.",
85
+ )
86
+ parser.add_argument(
87
+ "--nj-per-gpu",
88
+ type=int,
89
+ default=1,
90
+ help="Number of worker processes to spawn per GPU.",
91
+ )
92
+ return parser
93
+
94
+
95
+ def get_device(rank: int = 0) -> torch.device:
96
+ assert torch.cuda.is_available(), "CUDA is required but not available."
97
+ device = torch.device(f"cuda:{rank}")
98
+ torch.cuda.set_device(rank)
99
+ return device
100
+
101
+
102
+ def worker_init(
103
+ rank_queue,
104
+ model_path,
105
+ ):
106
+ """Initialize worker process with model and device."""
107
+ global worker_model, worker_device, worker_sr
108
+
109
+ # Limit CPU threads per worker
110
+ torch.set_num_threads(2)
111
+
112
+ formatter = "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] [Worker %(process)d] %(message)s"
113
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
114
+
115
+ rank = rank_queue.get() if rank_queue else -1
116
+
117
+ worker_device = get_device(rank)
118
+ worker_sr = 16000
119
+
120
+ logging.debug(f"Initializing UTMOS worker on {worker_device}")
121
+
122
+ # Initialize Model
123
+ worker_model = UTMOS22Strong()
124
+ try:
125
+ # Load weights to CPU first, then move to device
126
+ state_dict = torch.load(model_path, map_location="cpu")
127
+ worker_model.load_state_dict(state_dict)
128
+ except Exception as e:
129
+ logging.error(f"Failed to load model from {model_path}: {e}")
130
+ raise
131
+
132
+ worker_model.to(worker_device)
133
+ worker_model.eval()
134
+
135
+
136
+ @torch.no_grad()
137
+ def run_utmos_worker(file_idx, wav_path, language_name):
138
+ """Worker function to process a single audio file."""
139
+ try:
140
+ if not os.path.exists(wav_path):
141
+ return (
142
+ file_idx,
143
+ wav_path,
144
+ language_name,
145
+ f"File not found: {wav_path}",
146
+ "error",
147
+ )
148
+
149
+ # Load and preprocess waveform
150
+ speech = load_eval_waveform(wav_path, worker_sr, device=worker_device)
151
+
152
+ # Compute score
153
+ # UTMOS expects input shape (Batch, Time)
154
+ score = worker_model(speech.unsqueeze(0), worker_sr)
155
+
156
+ return file_idx, wav_path, language_name, score.item(), "success"
157
+
158
+ except Exception as e:
159
+ error_detail = (
160
+ f"Error processing {wav_path}: {str(e)}\n"
161
+ f"Traceback:\n{traceback.format_exc()}"
162
+ )
163
+ return file_idx, wav_path, language_name, error_detail, "error"
164
+
165
+
166
+ def main():
167
+ parser = get_parser()
168
+ args = parser.parse_args()
169
+
170
+ # Main process thread setting
171
+ torch.set_num_threads(2)
172
+
173
+ mp.set_start_method("spawn", force=True)
174
+
175
+ formatter = "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s"
176
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
177
+
178
+ # Validate inputs
179
+ if not os.path.isdir(args.wav_path):
180
+ logging.error(f"Invalid directory: {args.wav_path}")
181
+ sys.exit(1)
182
+
183
+ model_path = os.path.join(args.model_dir, "mos/utmos22_strong_step7459_v1.pt")
184
+ if not os.path.exists(model_path):
185
+ logging.error(f"Model file not found at {model_path}")
186
+ sys.exit(1)
187
+
188
+ # Scan directory for files
189
+ logging.info(f"Calculating UTMOS for {args.wav_path}")
190
+
191
+ wav_files = []
192
+ try:
193
+ samples = read_test_list(args.test_list)
194
+ for s in samples:
195
+ language_name = s.get("language_name") or "unknown"
196
+ eval_wav_path = os.path.join(args.wav_path, f"{s['id']}.{args.extension}")
197
+ wav_files.append((eval_wav_path, language_name))
198
+ except Exception as e:
199
+ raise ValueError(f"Error reading test list {args.test_list}: {e}")
200
+
201
+ # Setup Parallel Processing
202
+ num_gpus = torch.cuda.device_count()
203
+ assert num_gpus > 0, "No GPU found. GPU is required."
204
+ total_procs = num_gpus * args.nj_per_gpu
205
+
206
+ logging.info(
207
+ f"Starting evaluation with {total_procs} processes on {num_gpus} GPUs."
208
+ )
209
+
210
+ manager = mp.Manager()
211
+ rank_queue = manager.Queue()
212
+
213
+ for rank in list(range(num_gpus)) * args.nj_per_gpu:
214
+ rank_queue.put(rank)
215
+
216
+ scores = []
217
+
218
+ fout = None
219
+ if args.decode_path:
220
+ os.makedirs(os.path.dirname(args.decode_path), exist_ok=True)
221
+ fout = open(args.decode_path, "w", encoding="utf8")
222
+ logging.info(f"Saving detailed UTMOS results to: {args.decode_path}")
223
+ fout.write("Name\tUTMOS\n")
224
+
225
+ try:
226
+ with ProcessPoolExecutor(
227
+ max_workers=total_procs,
228
+ initializer=worker_init,
229
+ initargs=(
230
+ rank_queue,
231
+ model_path,
232
+ ),
233
+ ) as executor:
234
+ futures = []
235
+ for i, (wav_path, language_name) in enumerate(wav_files):
236
+ futures.append(
237
+ executor.submit(run_utmos_worker, i, wav_path, language_name)
238
+ )
239
+
240
+ pbar = tqdm(
241
+ as_completed(futures), total=len(wav_files), desc="Evaluating UTMOS"
242
+ )
243
+ lang_stats = {}
244
+ for future in pbar:
245
+ idx, path, language_name, result, status = future.result()
246
+ if status == "success":
247
+ if language_name not in lang_stats:
248
+ lang_stats[language_name] = []
249
+ lang_stats[language_name].append(result)
250
+ scores.append(result)
251
+ if fout:
252
+ if language_name == "unknown":
253
+ fout.write(f"{os.path.basename(path)}\t{result:.2f}\n")
254
+ else:
255
+ fout.write(
256
+ f"{language_name}\t{os.path.basename(path)}\t{result:.2f}\n"
257
+ )
258
+ else:
259
+ pbar.write(f"!!! FAILED [File {idx}]: {path} | {result}")
260
+
261
+ except (Exception, KeyboardInterrupt) as e:
262
+ logging.critical(
263
+ f"An unrecoverable error occurred: {e}. Terminating all processes."
264
+ )
265
+ detailed_error_info = traceback.format_exc()
266
+ logging.error(f"--- DETAILED TRACEBACK ---\n{detailed_error_info}")
267
+ sys.exit(1)
268
+
269
+ print("-" * 50)
270
+
271
+ if len(lang_stats) > 1:
272
+ lang_scores = []
273
+ for lang in sorted(lang_stats.keys()):
274
+ l_scores = lang_stats[lang]
275
+ l_avg = np.mean(l_scores)
276
+ lang_scores.append(l_scores)
277
+ l_count = len(l_scores)
278
+ logging.info(f"[{lang}] UTMOS score: {l_avg:.3f} ({l_count} samples)")
279
+ if fout:
280
+ fout.write(f"[{lang}] UTMOS: {l_avg:.3f} ({l_count} samples)\n")
281
+ logging.info(
282
+ f"Macro-average UTMOS over {len(lang_stats)} languages: "
283
+ f"{np.mean([np.mean(ls) for ls in lang_scores]):.3f}"
284
+ )
285
+ if fout:
286
+ fout.write(
287
+ f"\nMacro-average UTMOS over {len(lang_stats)} languages: "
288
+ f"{np.mean([np.mean(ls) for ls in lang_scores]):.3f}\n"
289
+ )
290
+
291
+ if scores:
292
+ avg_score = np.mean(scores)
293
+ logging.info(f"Processed {len(scores)}/{len(wav_files)} files.")
294
+ logging.info(f"UTMOS score: {avg_score:.2f}")
295
+ if fout:
296
+ fout.write(f"\nAverage UTMOS: {avg_score:.2f}\n")
297
+ else:
298
+ logging.error("No valid scores computed.")
299
+ print("-" * 50)
300
+
301
+ if fout:
302
+ fout.close()
303
+
304
+
305
+ if __name__ == "__main__":
306
+ main()
omnivoice/eval/speaker_similarity/sim.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ Computes speaker similarity (SIM-o) using a WavLM-based
20
+ ECAPA-TDNN speaker verification model.
21
+ """
22
+
23
+ import argparse
24
+ import logging
25
+ import multiprocessing as mp
26
+ import os
27
+ import sys
28
+ import traceback
29
+ import warnings
30
+ from concurrent.futures import ProcessPoolExecutor, as_completed
31
+
32
+ import numpy as np
33
+ import torch
34
+ from tqdm import tqdm
35
+
36
+ from omnivoice.eval.models.ecapa_tdnn_wavlm import ECAPA_TDNN_WAVLM
37
+ from omnivoice.eval.utils import load_eval_waveform
38
+ from omnivoice.utils.data_utils import read_test_list
39
+
40
+ warnings.filterwarnings("ignore")
41
+
42
+ # Global variables for workers
43
+ worker_model = None
44
+ worker_device = None
45
+ worker_sr = 16000
46
+
47
+
48
+ def get_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ description="Calculate speaker similarity (SIM-o) score."
51
+ )
52
+ parser.add_argument(
53
+ "--wav-path",
54
+ type=str,
55
+ required=True,
56
+ help="Path to the directory containing evaluated speech files.",
57
+ )
58
+ parser.add_argument(
59
+ "--test-list",
60
+ type=str,
61
+ required=True,
62
+ help="Path to the JSONL test list. Each line is a JSON object "
63
+ "with fields: id, text, ref_audio, ref_text, language_id, language_name.",
64
+ )
65
+ parser.add_argument(
66
+ "--model-dir",
67
+ type=str,
68
+ required=True,
69
+ help="Local path of our evaluation model repository."
70
+ "Download from https://huggingface.co/k2-fsa/TTS_eval_models."
71
+ "Will use 'tts_eval_models/speaker_similarity/wavlm_large_finetune.pth'"
72
+ "and 'tts_eval_models/speaker_similarity/wavlm_large/' in this script",
73
+ )
74
+ parser.add_argument(
75
+ "--extension",
76
+ type=str,
77
+ default="wav",
78
+ help="Extension of the speech files.",
79
+ )
80
+ parser.add_argument(
81
+ "--decode-path",
82
+ type=str,
83
+ default=None,
84
+ help="Path to the output file where SIM-o information will be saved. "
85
+ "If not provided, results are only printed to console.",
86
+ )
87
+ parser.add_argument(
88
+ "--nj-per-gpu",
89
+ type=int,
90
+ default=1,
91
+ help="Number of worker processes to spawn per GPU.",
92
+ )
93
+ return parser
94
+
95
+
96
+ def get_device(rank: int = 0) -> torch.device:
97
+ assert torch.cuda.is_available(), "CUDA is required but not available."
98
+ device = torch.device(f"cuda:{rank}")
99
+ torch.cuda.set_device(rank)
100
+ return device
101
+
102
+
103
+ def worker_init(
104
+ rank_queue,
105
+ sv_model_path,
106
+ ssl_model_path,
107
+ ):
108
+ """Initialize worker process with model and device."""
109
+ global worker_model, worker_device, worker_sr
110
+
111
+ torch.set_num_threads(2)
112
+
113
+ formatter = "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] [Worker %(process)d] %(message)s"
114
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
115
+
116
+ rank = rank_queue.get() if rank_queue else -1
117
+
118
+ worker_device = get_device(rank)
119
+ worker_sr = 16000
120
+
121
+ logging.debug(f"Initializing SIM-o worker on {worker_device}")
122
+ # Temporarily suppress INFO logs to hide verbose WavLM config
123
+ logging.disable(logging.INFO)
124
+
125
+ # Initialize Model
126
+ try:
127
+ worker_model = ECAPA_TDNN_WAVLM(
128
+ feat_dim=1024,
129
+ channels=512,
130
+ emb_dim=256,
131
+ sr=worker_sr,
132
+ ssl_model_path=ssl_model_path,
133
+ )
134
+ state_dict = torch.load(
135
+ sv_model_path, map_location=lambda storage, loc: storage
136
+ )
137
+ worker_model.load_state_dict(state_dict["model"], strict=False)
138
+ worker_model.to(worker_device)
139
+ worker_model.eval()
140
+ finally:
141
+ # Restore normal logging
142
+ logging.disable(logging.NOTSET)
143
+
144
+
145
+ @torch.no_grad()
146
+ def get_embedding(wav_path: str) -> torch.Tensor:
147
+ """Extract embedding for a single file."""
148
+ speech = load_eval_waveform(
149
+ wav_path, worker_sr, device=worker_device, max_seconds=120
150
+ )
151
+ return worker_model([speech])
152
+
153
+
154
+ def run_similarity_worker(line_idx, sample, wav_dir, extension):
155
+ """Worker function to process a single pair."""
156
+ try:
157
+ wav_name = sample["id"]
158
+ ref_wav_path = sample["ref_audio"]
159
+ language_name = sample.get("language_name") or "unknown"
160
+ eval_wav_path = os.path.join(wav_dir, f"{wav_name}.{extension}")
161
+
162
+ if not os.path.exists(ref_wav_path):
163
+ return line_idx, f"Reference not found: {ref_wav_path}", None, "error"
164
+ if not os.path.exists(eval_wav_path):
165
+ return line_idx, f"Eval wav not found: {eval_wav_path}", None, "error"
166
+
167
+ # Compute embeddings pair-wise
168
+ ref_emb = get_embedding(ref_wav_path)
169
+ eval_emb = get_embedding(eval_wav_path)
170
+
171
+ # Cosine Similarity
172
+ similarity = torch.nn.functional.cosine_similarity(ref_emb, eval_emb, dim=-1)
173
+
174
+ return (
175
+ line_idx,
176
+ (ref_wav_path, eval_wav_path, language_name),
177
+ similarity.item(),
178
+ "success",
179
+ )
180
+
181
+ except Exception as e:
182
+ error_detail = f"Error: {str(e)}\nTraceback:\n{traceback.format_exc()}"
183
+ return line_idx, str(sample), error_detail, "error"
184
+
185
+
186
+ def main():
187
+ parser = get_parser()
188
+ args = parser.parse_args()
189
+
190
+ # Main process thread setting
191
+ torch.set_num_threads(2)
192
+
193
+ mp.set_start_method("spawn", force=True)
194
+
195
+ formatter = "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s"
196
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
197
+
198
+ # Prepare paths
199
+ sv_model_path = os.path.join(
200
+ args.model_dir, "speaker_similarity/wavlm_large_finetune.pth"
201
+ )
202
+ ssl_model_path = os.path.join(args.model_dir, "speaker_similarity/wavlm_large/")
203
+
204
+ if not os.path.exists(sv_model_path) or not os.path.exists(ssl_model_path):
205
+ logging.error("Model files not found. Please check --model-dir.")
206
+ sys.exit(1)
207
+
208
+ logging.info(f"Calculating SIM-o for {args.wav_path}")
209
+ # Read list
210
+ samples = read_test_list(args.test_list)
211
+
212
+ # Setup Parallel Processing
213
+ num_gpus = torch.cuda.device_count()
214
+ assert num_gpus > 0, "No GPU found. GPU is required."
215
+ total_procs = num_gpus * args.nj_per_gpu
216
+
217
+ logging.info(
218
+ f"Starting evaluation with {total_procs} processes on {num_gpus} GPUs."
219
+ )
220
+
221
+ manager = mp.Manager()
222
+ rank_queue = manager.Queue()
223
+
224
+ for rank in list(range(num_gpus)) * args.nj_per_gpu:
225
+ rank_queue.put(rank)
226
+
227
+ scores = []
228
+
229
+ fout = None
230
+ if args.decode_path:
231
+ os.makedirs(os.path.dirname(args.decode_path), exist_ok=True)
232
+ fout = open(args.decode_path, "w", encoding="utf8")
233
+ logging.info(f"Saving detailed SIM-o results to: {args.decode_path}")
234
+ fout.write("Prompt-path\tEval-path\tSIM-o\n")
235
+
236
+ try:
237
+ with ProcessPoolExecutor(
238
+ max_workers=total_procs,
239
+ initializer=worker_init,
240
+ initargs=(
241
+ rank_queue,
242
+ sv_model_path,
243
+ ssl_model_path,
244
+ ),
245
+ ) as executor:
246
+ futures = []
247
+ for i, sample in enumerate(samples):
248
+ futures.append(
249
+ executor.submit(
250
+ run_similarity_worker, i, sample, args.wav_path, args.extension
251
+ )
252
+ )
253
+
254
+ pbar = tqdm(
255
+ as_completed(futures), total=len(samples), desc="Evaluating SIM-o"
256
+ )
257
+
258
+ lang_stats = {}
259
+
260
+ for future in pbar:
261
+ idx, context, result, status = future.result()
262
+ if status == "success":
263
+ prompt_path, eval_path, lang = context
264
+ scores.append(result)
265
+
266
+ # Accumulate per-language
267
+ if lang not in lang_stats:
268
+ lang_stats[lang] = []
269
+ lang_stats[lang].append(result)
270
+
271
+ if fout:
272
+ if lang == "unknown":
273
+ fout.write(f"{prompt_path}\t{eval_path}\t{result:.2f}\n")
274
+ else:
275
+ fout.write(
276
+ f"{lang}\t{context[0]}\t{context[1]}\t{result:.2f}\n"
277
+ )
278
+ else:
279
+ pbar.write(f"!!! FAILED [Line {idx}]: {context} | Error: {result}")
280
+
281
+ except (Exception, KeyboardInterrupt) as e:
282
+ logging.critical(
283
+ f"An unrecoverable error occurred: {e}. Terminating all processes."
284
+ )
285
+ detailed_error_info = traceback.format_exc()
286
+ logging.error(f"--- DETAILED TRACEBACK ---\n{detailed_error_info}")
287
+ sys.exit(1)
288
+
289
+ print("-" * 50)
290
+ if len(lang_stats) > 1:
291
+ lang_scores = []
292
+ for lang in sorted(lang_stats.keys()):
293
+ l_scores = lang_stats[lang]
294
+ l_avg = np.mean(l_scores)
295
+ lang_scores.append(l_scores)
296
+ l_count = len(l_scores)
297
+ logging.info(f"[{lang}] SIM-o score: {l_avg:.3f} ({l_count} pairs)")
298
+ if fout:
299
+ fout.write(f"[{lang}] SIM-o: {l_avg:.3f} ({l_count} pairs)\n")
300
+ logging.info(
301
+ f"Macro-average SIM-o over {len(lang_stats)} languages: "
302
+ f"{np.mean([np.mean(ls) for ls in lang_scores]):.3f}"
303
+ )
304
+ if fout:
305
+ fout.write(
306
+ f"\nMacro-average SIM-o over {len(lang_stats)} languages: "
307
+ f"{np.mean([np.mean(ls) for ls in lang_scores]):.3f}\n"
308
+ )
309
+
310
+ if scores:
311
+ avg_score = np.mean(scores)
312
+ logging.info(f"Processed {len(scores)}/{len(samples)} pairs.")
313
+ logging.info(f"SIM-o score: {avg_score:.3f}")
314
+ if fout:
315
+ fout.write(f"\nAverage SIM-o: {avg_score:.3f}\n")
316
+ else:
317
+ logging.error("No valid scores computed.")
318
+ if fout:
319
+ fout.close()
320
+ print("-" * 50)
321
+
322
+
323
+ if __name__ == "__main__":
324
+ main()
omnivoice/eval/utils.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ import logging
19
+ from typing import Optional
20
+
21
+ import soundfile as sf
22
+ import torch
23
+ import torchaudio
24
+
25
+
26
+ def load_eval_waveform(
27
+ fname: str,
28
+ sample_rate: int,
29
+ dtype: str = "float32",
30
+ device: torch.device = torch.device("cpu"),
31
+ return_numpy: bool = False,
32
+ max_seconds: Optional[float] = None,
33
+ ) -> torch.Tensor:
34
+ """
35
+ Load an audio file, preprocess it, and convert to a PyTorch tensor.
36
+
37
+ Args:
38
+ fname (str): Path to the audio file.
39
+ sample_rate (int): Target sample rate for resampling.
40
+ dtype (str, optional): Data type to load audio as (default: "float32").
41
+ device (torch.device, optional): Device to place the resulting tensor
42
+ on (default: CPU).
43
+ return_numpy (bool): If True, returns a NumPy array instead of a
44
+ PyTorch tensor.
45
+ max_seconds (float): Maximum length (seconds) of the audio tensor.
46
+ If the audio is longer than this, it will be truncated.
47
+
48
+ Returns:
49
+ torch.Tensor: Processed audio waveform as a PyTorch tensor,
50
+ with shape (num_samples,).
51
+
52
+ Notes:
53
+ - If the audio is stereo, it will be converted to mono by averaging channels.
54
+ - If the audio's sample rate differs from the target, it will be resampled.
55
+ """
56
+ # Load audio file with specified data type
57
+ wav_data, sr = sf.read(fname, dtype=dtype)
58
+
59
+ # Convert stereo to mono if necessary
60
+ if len(wav_data.shape) == 2:
61
+ wav_data = wav_data.mean(1)
62
+
63
+ # Resample to target sample rate if needed
64
+ if sr != sample_rate:
65
+ wav_data = torchaudio.functional.resample(
66
+ torch.from_numpy(wav_data), orig_freq=sr, new_freq=sample_rate
67
+ ).numpy()
68
+
69
+ if max_seconds is not None:
70
+ # Trim to max length
71
+ max_length = int(sample_rate * max_seconds)
72
+ if len(wav_data) > max_length:
73
+ wav_data = wav_data[:max_length]
74
+ logging.warning(
75
+ f"Wav file {fname} is longer than {max_seconds}s, "
76
+ f"truncated to {max_seconds}s to avoid OOM."
77
+ )
78
+ if return_numpy:
79
+ return wav_data
80
+ else:
81
+ wav_data = torch.from_numpy(wav_data)
82
+ return wav_data.to(device)
omnivoice/eval/wer/common.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ Shared utilities for WER evaluation scripts.
20
+ """
21
+
22
+ import logging
23
+
24
+ import numpy as np
25
+ from jiwer import compute_measures
26
+
27
+
28
+ def process_one(hypothesis: str, truth: str, post_process, lang: str = None) -> dict:
29
+ """
30
+ Computes WER and related metrics for a single hypothesis-truth pair.
31
+
32
+ Args:
33
+ hypothesis (str): The transcribed text from the ASR model.
34
+ truth (str): The ground truth transcript.
35
+ post_process (callable): Text normalization function defined by each script.
36
+ Signature: post_process(text, lang) or post_process(text).
37
+ lang (str): The language code for post_process. Pass None if post_process
38
+ does not accept a lang argument.
39
+
40
+ Returns:
41
+ dict: A dict containing:
42
+ - truth (str): Post-processed ground truth text.
43
+ - hypothesis (str): Post-processed hypothesis text.
44
+ - wer (float): Word Error Rate.
45
+ - substitutions (int): Number of substitutions.
46
+ - deletions (int): Number of deletions.
47
+ - insertions (int): Number of insertions.
48
+ - word_num (int): Number of words in the post-processed ground truth.
49
+ """
50
+ if lang is not None:
51
+ truth_processed = post_process(truth, lang)
52
+ hypothesis_processed = post_process(hypothesis, lang)
53
+ else:
54
+ truth_processed = post_process(truth)
55
+ hypothesis_processed = post_process(hypothesis)
56
+ measures = compute_measures(truth_processed, hypothesis_processed)
57
+ word_num = len(truth_processed.split(" "))
58
+ return {
59
+ "truth": truth_processed,
60
+ "hypo": hypothesis_processed,
61
+ "wer": measures["wer"],
62
+ "substitutions": measures["substitutions"],
63
+ "deletions": measures["deletions"],
64
+ "insertions": measures["insertions"],
65
+ "word_num": word_num,
66
+ }
67
+
68
+
69
+ def log_metrics(fout, prefix, i_list, d_list, s_list, w_total, ndigits=2):
70
+ """Log weighted WER metrics for a subset of results."""
71
+ metrics_wer = round(
72
+ (np.sum(s_list) + np.sum(d_list) + np.sum(i_list)) / w_total * 100, ndigits
73
+ )
74
+ metrics_inse = np.sum(i_list)
75
+ metrics_dele = np.sum(d_list)
76
+ metrics_subs = np.sum(s_list)
77
+
78
+ logging.info(f"{prefix} WER: {metrics_wer}%")
79
+ logging.info(
80
+ f"{prefix} Errors: {metrics_inse} ins, {metrics_dele} del, "
81
+ f"{metrics_subs} sub / {w_total} words"
82
+ )
83
+ if fout:
84
+ fout.write(f"{prefix} WER: {metrics_wer}%\n")
85
+ fout.write(
86
+ f"{prefix} Errors: {metrics_inse} ins, {metrics_dele} del, "
87
+ f"{metrics_subs} sub / {w_total} words\n"
88
+ )
89
+ return metrics_wer
omnivoice/eval/wer/fleurs.py ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Computes word error rate (WER) for FLEURS multilingual evaluation.
19
+
20
+ Uses omnilingual-asr for ASR transcription across 100+ languages.
21
+ Requires a separate environment with ``omnilingual_asr`` installed.
22
+
23
+ Usage:
24
+ python3 omnivoice/eval/wer/fleurs.py \\
25
+ --wav-path results/fleurs \\
26
+ --test-list test.jsonl \\
27
+ --decode-path results/fleurs.wer.log \\
28
+ --model-card omniASR_LLM_Unlimited_7B_v2 \\
29
+ --chunk-size 100 --batch-size 50
30
+ """
31
+
32
+ import argparse
33
+ import logging
34
+ import multiprocessing as mp
35
+ import os
36
+ import re
37
+ import sys
38
+ import traceback
39
+ import types
40
+ from collections import defaultdict
41
+ from concurrent.futures import ProcessPoolExecutor, as_completed
42
+ from pathlib import Path
43
+ from typing import List, Union
44
+
45
+ import numpy as np
46
+ import torch
47
+ from tqdm import tqdm
48
+
49
+ try:
50
+ from omnilingual_asr.models.inference.pipeline import ASRInferencePipeline
51
+ from omnilingual_asr.models.wav2vec2_llama.lang_ids import supported_langs
52
+ except ImportError:
53
+ logging.error("Please install omnilingual_asr first.")
54
+ exit(1)
55
+
56
+ # omnilingual-asr may pull a transformers version that lacks
57
+ # HiggsAudioV2TokenizerModel. Pre-register stubs to bypass
58
+ # omnivoice/__init__.py heavy imports.
59
+ if "omnivoice" not in sys.modules:
60
+ _root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
61
+ for _name in (
62
+ "omnivoice",
63
+ "omnivoice.eval",
64
+ "omnivoice.eval.wer",
65
+ "omnivoice.utils",
66
+ ):
67
+ if _name not in sys.modules:
68
+ _m = types.ModuleType(_name)
69
+ _m.__path__ = [os.path.join(_root, *_name.split(".")[1:])]
70
+ _m.__package__ = _name
71
+ sys.modules[_name] = _m
72
+
73
+ from omnivoice.eval.wer.common import log_metrics, process_one
74
+ from omnivoice.eval.wer.text_norm_omni import text_normalize
75
+ from omnivoice.utils.data_utils import read_test_list
76
+
77
+ # --- Global variables for worker processes ---
78
+ worker_pipe = None
79
+ worker_device = None
80
+
81
+
82
+ # fix mismatched language codes between OmniVoice and Omnilingual-ASR model
83
+ rename = {
84
+ "et": "ekk",
85
+ "ms": "zsm",
86
+ "sw": "swh",
87
+ "npi": "nep",
88
+ }
89
+
90
+
91
+ def read_language_mapping_from_tsv(
92
+ mapping_path: Path,
93
+ ) -> dict[str, Union[str, List[str]]]:
94
+ with open(mapping_path, "r", encoding="utf-8") as f:
95
+ _ = f.readline() # Skip header
96
+ language_mapping = {}
97
+ for line in f:
98
+ parts = line.strip().split("\t")
99
+ mixed_id, language_name, iso_639_3_id, duration = parts
100
+ language_mapping[iso_639_3_id] = mixed_id
101
+ return language_mapping
102
+
103
+
104
+ iso_639_3_id_to_mixed_id = read_language_mapping_from_tsv(
105
+ Path(f"{os.path.dirname(__file__)}/../../../docs/lang_id_name_map.tsv")
106
+ )
107
+
108
+ mixed_id_to_omnilingual_asr_lang = {}
109
+
110
+ for lang in supported_langs:
111
+ if lang in ("cmn_Hant",):
112
+ continue
113
+ iso_639_3_lang_code = lang.split("_")[0]
114
+ if iso_639_3_lang_code in iso_639_3_id_to_mixed_id:
115
+ mixed_id = iso_639_3_id_to_mixed_id[iso_639_3_lang_code]
116
+ mixed_id_to_omnilingual_asr_lang[mixed_id] = lang
117
+ else:
118
+ mixed_id_to_omnilingual_asr_lang[iso_639_3_lang_code] = lang
119
+
120
+
121
+ def clean_cjk_spaces(text):
122
+ """
123
+ Removes spaces adjacent to Chinese and Japanese characters while preserving
124
+ meaningful spaces in English or other languages (like Korean).
125
+ """
126
+
127
+ # Define CJK (Chinese, Japanese) Unicode ranges
128
+ # \u4e00-\u9fff: CJK Unified Ideographs (Chinese)
129
+ # \u3040-\u309f: Hiragana (Japanese)
130
+ # \u30a0-\u30ff: Katakana (Japanese)
131
+ # \u3000-\u303f: CJK Symbols and Punctuation
132
+ cjk_range = r"\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\u3000-\u303f"
133
+
134
+ # 1. Remove spaces between two CJK characters
135
+ # Example: "我 爱 你" -> "我爱你"
136
+ text = re.sub(f"([{cjk_range}])\\s+([{cjk_range}])", r"\1\2", text)
137
+
138
+ # 2. Remove spaces between a CJK character and a non-CJK character (English/Numbers)
139
+ # Example: "我 爱 you" -> "我爱you"
140
+ text = re.sub(f"([{cjk_range}])\\s+", r"\1", text)
141
+ text = re.sub(f"\\s+([{cjk_range}])", r"\1", text)
142
+
143
+ # 3. Collapse multiple spaces into one for the remaining parts (e.g., English words)
144
+ text = re.sub(r"\s+", " ", text)
145
+
146
+ return text.strip()
147
+
148
+
149
+ def get_parser():
150
+ parser = argparse.ArgumentParser(
151
+ description="Computes WER with Whisper.",
152
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
153
+ )
154
+
155
+ parser.add_argument(
156
+ "--wav-path",
157
+ type=str,
158
+ required=True,
159
+ help="Path to the directory containing speech files.",
160
+ )
161
+
162
+ parser.add_argument(
163
+ "--extension",
164
+ type=str,
165
+ default="wav",
166
+ help="Extension of the speech files. Default: wav",
167
+ )
168
+
169
+ parser.add_argument(
170
+ "--decode-path",
171
+ type=str,
172
+ default=None,
173
+ help="Path to the output file where WER information will be saved. "
174
+ "If not provided, results are only printed to console.",
175
+ )
176
+ parser.add_argument(
177
+ "--model-card",
178
+ type=str,
179
+ default="omniASR_LLM_7B",
180
+ help="Model card name for OmniASR (e.g., omniASR_LLM_7B) or local path.",
181
+ )
182
+ parser.add_argument(
183
+ "--test-list",
184
+ type=str,
185
+ default="test.jsonl",
186
+ help="path of the JSONL test list. Each line is a JSON object "
187
+ "with fields: id, text, ref_audio, ref_text, language_id, language_name.",
188
+ )
189
+ parser.add_argument(
190
+ "--lang",
191
+ type=str,
192
+ default=None,
193
+ help="""Language code to evaluate (e.g., 'en' for English, 'zh' for Chinese).
194
+ If not provided, the script will evaluate all languages found in the test list.
195
+ If specified, only samples of the given language will be evaluated.
196
+ """,
197
+ )
198
+ parser.add_argument(
199
+ "--batch-size",
200
+ type=int,
201
+ default=8,
202
+ help="Batch size for decoding with the Hugging Face pipeline.",
203
+ )
204
+ parser.add_argument(
205
+ "--nj-per-gpu", type=int, default=1, help="Number of workers per GPU."
206
+ )
207
+ parser.add_argument(
208
+ "--chunk-size",
209
+ type=int,
210
+ default=300,
211
+ help="Number of samples per task chunk sent to workers.",
212
+ )
213
+ return parser
214
+
215
+
216
+ def load_omni_model(model_card, device):
217
+ logging.info(f"Loading OmniASR model ({model_card}) on {device}...")
218
+ try:
219
+ pipeline = ASRInferencePipeline(model_card=model_card, device=str(device))
220
+ return pipeline
221
+ except Exception as e:
222
+ logging.error(f"Failed to load OmniASR pipeline: {e}")
223
+ return None
224
+
225
+
226
+ def process_init(rank_queue, model_card):
227
+ """
228
+ Initializer for each worker process.
229
+ """
230
+ global worker_pipe, worker_device
231
+
232
+ # Configure threads constraint
233
+ torch.set_num_threads(2)
234
+
235
+ try:
236
+ rank = rank_queue.get(timeout=10)
237
+ except Exception:
238
+ raise RuntimeError("Failed to get GPU rank from queue.")
239
+
240
+ assert torch.cuda.is_available(), "CUDA is required but not available."
241
+ worker_device = torch.device(f"cuda:{rank}")
242
+ torch.cuda.set_device(rank)
243
+
244
+ logging.info(f"Initializing worker on device: {worker_device}")
245
+
246
+ try:
247
+ # Using the model_card argument
248
+ worker_pipe = load_omni_model(model_card, worker_device)
249
+ if worker_pipe is None:
250
+ raise RuntimeError("Model loading failed.")
251
+ except Exception as e:
252
+ logging.critical(f"Failed to load model on {worker_device}: {e}")
253
+ raise e
254
+
255
+
256
+ def post_process(text: str, lang: str) -> str:
257
+ """
258
+ Cleans and normalizes text for WER calculation.
259
+ Args:
260
+ text (str): The input text to be processed.
261
+ lang (str): The language of the input text.
262
+
263
+ Returns:
264
+ str: The cleaned and normalized text.
265
+ """
266
+ lang_id = lang[:3] # Extract ISO 639-3 code (e.g., 'eng' from 'eng_Latn')
267
+ text = text_normalize(
268
+ text,
269
+ iso_code=lang_id,
270
+ lower_case=True,
271
+ remove_numbers=False,
272
+ remove_brackets=False,
273
+ )
274
+ text = clean_cjk_spaces(text)
275
+ text = text.replace(" ", "|")
276
+ text = " ".join([x for x in text])
277
+ return text
278
+
279
+
280
+ def run_eval_worker(data_chunk, language, batch_size):
281
+ """
282
+ Worker function to process a chunk of data.
283
+ Uses the global worker_pipe initialized by process_init.
284
+ """
285
+ global worker_pipe
286
+ if worker_pipe is None:
287
+ logging.error("Worker pipeline is not initialized!")
288
+ return []
289
+
290
+ metrics_buffer = []
291
+ try:
292
+ # Prepare batch lists for OmniASR
293
+ audio_paths = [item["wav_path"] for item in data_chunk]
294
+
295
+ # OmniASR expects explicit language codes for each file if not auto-detected.
296
+ # Using the language passed to the worker function, or item specific language
297
+ # Assuming item['lang_id'] is compatible (e.g., 'en', 'zh', 'arb_Arab')
298
+ # If the model needs full tokens like 'en_Latn', conversion might be needed here depending on input data.
299
+ lang_list = [item.get("lang_id", language) for item in data_chunk]
300
+
301
+ # Use the pipeline to infer batch
302
+ # OmniASR pipeline.transcribe returns a list of strings
303
+ transcriptions = worker_pipe.transcribe(
304
+ audio_paths, lang=lang_list, batch_size=batch_size
305
+ )
306
+
307
+ for i, hypo_text in enumerate(transcriptions):
308
+ ref_item = data_chunk[i]
309
+ truth = ref_item["truth_text"]
310
+ wav_path = ref_item["wav_path"]
311
+ lang_id = ref_item.get("lang_id")
312
+ lang_name = ref_item.get("lang_name")
313
+
314
+ m = process_one(hypo_text, truth, post_process, lang_id)
315
+ m["wav_path"] = wav_path
316
+ m["lang_name"] = lang_name
317
+ metrics_buffer.append(m)
318
+
319
+ except Exception:
320
+ logging.error(
321
+ f"Worker failed on chunk (Lang: {language}):\n{traceback.format_exc()}"
322
+ )
323
+ return []
324
+
325
+ return metrics_buffer
326
+
327
+
328
+ def main():
329
+ parser = get_parser()
330
+ args = parser.parse_args()
331
+
332
+ logging.basicConfig(
333
+ format="%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s",
334
+ level=logging.INFO,
335
+ force=True,
336
+ )
337
+
338
+ # 1. Prepare Data
339
+ logging.info("Reading test list...")
340
+ data_by_lang = defaultdict(list)
341
+ total_files = 0
342
+ wav_root = Path(args.wav_path)
343
+
344
+ samples = read_test_list(args.test_list)
345
+ for s in samples:
346
+ wav_path = str(wav_root / f"{s['id']}.{args.extension}")
347
+ if not os.path.exists(wav_path):
348
+ logging.warning(f"File missing: {wav_path}")
349
+ continue
350
+
351
+ lang_id = s.get("language_id") or "unknown"
352
+ if lang_id in rename:
353
+ lang_id = mixed_id_to_omnilingual_asr_lang[rename[lang_id]]
354
+ else:
355
+ lang_id = mixed_id_to_omnilingual_asr_lang[lang_id]
356
+ item = {
357
+ "wav_path": wav_path,
358
+ "truth_text": s["text"],
359
+ "lang_id": lang_id,
360
+ "lang_name": s.get("language_name") or "unknown",
361
+ }
362
+ if args.lang and s.get("language_id") != args.lang:
363
+ continue
364
+
365
+ data_by_lang[s.get("language_name") or "unknown"].append(item)
366
+
367
+ total_files += 1
368
+
369
+ logging.info(f"Total files: {total_files} in {len(data_by_lang)} languages.")
370
+
371
+ # 2. Worker config
372
+ num_gpus = torch.cuda.device_count()
373
+ assert num_gpus > 0, "No GPU found. GPU is required."
374
+ total_workers = num_gpus * args.nj_per_gpu
375
+
376
+ mp.set_start_method("spawn", force=True)
377
+ manager = mp.Manager()
378
+ rank_queue = manager.Queue()
379
+
380
+ for _ in range(args.nj_per_gpu):
381
+ for rank in range(num_gpus):
382
+ rank_queue.put(rank)
383
+
384
+ # 3. Scheduling: Split languages into chunks
385
+ # This prevents one huge language from blocking a worker for too long,
386
+ # allows better load balancing across the pool.
387
+ tasks = []
388
+ chunk_size = args.chunk_size
389
+
390
+ for lang_name, items in data_by_lang.items():
391
+ # Slicing the list into chunks
392
+ for i in range(0, len(items), chunk_size):
393
+ chunk = items[i : i + chunk_size]
394
+ tasks.append({"chunk": chunk, "lang": lang_name})
395
+
396
+ logging.info(
397
+ f"Split data into {len(tasks)} chunks (size ~{chunk_size}). Spawning {total_workers} workers."
398
+ )
399
+
400
+ # 4. Execution
401
+ results = []
402
+
403
+ with ProcessPoolExecutor(
404
+ max_workers=total_workers,
405
+ initializer=process_init,
406
+ initargs=(rank_queue, args.model_card),
407
+ ) as executor:
408
+ futures = []
409
+ for task in tasks:
410
+ futures.append(
411
+ executor.submit(
412
+ run_eval_worker, task["chunk"], task["lang"], args.batch_size
413
+ )
414
+ )
415
+
416
+ # Unified progress bar
417
+ with tqdm(total=total_files, desc="Eval Progress", dynamic_ncols=True) as pbar:
418
+ for future in as_completed(futures):
419
+ try:
420
+ chunk_metrics = future.result()
421
+ results.extend(chunk_metrics)
422
+ pbar.update(len(chunk_metrics))
423
+ except Exception as e:
424
+ logging.error(f"Task failed: {e}")
425
+
426
+ # 5. Metrics Aggregation
427
+ wers, inses, deles, subses = [], [], [], []
428
+ word_nums = 0
429
+
430
+ # Store metrics per language
431
+ lang_stats = {}
432
+
433
+ fout = None
434
+ if args.decode_path:
435
+ os.makedirs(os.path.dirname(args.decode_path), exist_ok=True)
436
+ logging.info(f"Saving detailed WER results to: {args.decode_path}")
437
+ fout = open(args.decode_path, "w", encoding="utf-8")
438
+
439
+ for res in results:
440
+ wers.append(float(res["wer"]))
441
+ inses.append(float(res["insertions"]))
442
+ deles.append(float(res["deletions"]))
443
+ subses.append(float(res["substitutions"]))
444
+ word_nums += res["word_num"]
445
+
446
+ if fout:
447
+ fout.write(
448
+ f"{res['wav_path']}\t{res['wer']}\t{res['truth']}\t"
449
+ f"{res['hypo']}\t{res['insertions']}\t{res['deletions']}\t"
450
+ f"{res['substitutions']}\n"
451
+ )
452
+ lang_name = res["lang_name"]
453
+
454
+ # Per language stats
455
+ if lang_name not in lang_stats:
456
+ lang_stats[lang_name] = {
457
+ "inses": [],
458
+ "deles": [],
459
+ "subses": [],
460
+ "word_nums": 0,
461
+ }
462
+ lang_stats[lang_name]["inses"].append(float(res["insertions"]))
463
+ lang_stats[lang_name]["deles"].append(float(res["deletions"]))
464
+ lang_stats[lang_name]["subses"].append(float(res["substitutions"]))
465
+ lang_stats[lang_name]["word_nums"] += res["word_num"]
466
+
467
+ print("-" * 50)
468
+ # Log per-language stats
469
+ per_lang_wers = []
470
+ for lang in sorted(lang_stats.keys()):
471
+ stats = lang_stats[lang]
472
+ if stats["word_nums"] > 0:
473
+ lang_wer = log_metrics(
474
+ fout,
475
+ f"[{lang}]",
476
+ stats["inses"],
477
+ stats["deles"],
478
+ stats["subses"],
479
+ stats["word_nums"],
480
+ )
481
+ per_lang_wers.append(lang_wer)
482
+ print("-" * 50)
483
+
484
+ # Log Macro-average WER
485
+ if len(per_lang_wers) > 1:
486
+ macro_wer = np.mean(per_lang_wers)
487
+ logging.info(
488
+ f"Macro-average WER over {len(per_lang_wers)} languages: {macro_wer:.2f}%"
489
+ )
490
+ if fout:
491
+ fout.write(
492
+ f"Macro-average WER over {len(per_lang_wers)} languages: {macro_wer:.2f}%\n"
493
+ )
494
+ count_le_5 = sum(1 for w in per_lang_wers if w <= 5.0)
495
+ count_le_10 = sum(1 for w in per_lang_wers if w <= 10.0)
496
+ count_le_20 = sum(1 for w in per_lang_wers if w <= 20.0)
497
+
498
+ stats_msg = (
499
+ f"Languages with WER/CER <= 5%: {count_le_5}/{len(per_lang_wers)}\n"
500
+ f"Languages with WER/CER <= 10%: {count_le_10}/{len(per_lang_wers)}\n"
501
+ f"Languages with WER/CER <= 20%: {count_le_20}/{len(per_lang_wers)}"
502
+ )
503
+
504
+ logging.info("\n" + stats_msg)
505
+ if fout:
506
+ fout.write(stats_msg + "\n")
507
+
508
+ # Log overall stats
509
+ if word_nums > 0:
510
+ log_metrics(fout, "Overall", inses, deles, subses, word_nums)
511
+
512
+ if fout:
513
+ fout.close()
514
+
515
+
516
+ if __name__ == "__main__":
517
+ main()
omnivoice/eval/wer/hubert.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ Computes word error rate (WER) with Hubert models for LibriSpeech test sets.
20
+ """
21
+
22
+ import argparse
23
+ import logging
24
+ import multiprocessing as mp
25
+ import os
26
+ import re
27
+ import traceback
28
+ from concurrent.futures import ProcessPoolExecutor, as_completed
29
+ from pathlib import Path
30
+
31
+ import numpy as np
32
+ import torch
33
+ from tqdm import tqdm
34
+
35
+ from omnivoice.eval.utils import load_eval_waveform
36
+ from omnivoice.eval.wer.common import process_one
37
+ from omnivoice.utils.data_utils import read_test_list
38
+
39
+ # --- Global variables for worker processes ---
40
+ worker_pipe = None
41
+ worker_device = None
42
+
43
+
44
+ def get_parser():
45
+ parser = argparse.ArgumentParser(
46
+ description="Computes WER with Hubert-based ASR model.",
47
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
48
+ )
49
+ parser.add_argument(
50
+ "--wav-path",
51
+ type=str,
52
+ required=True,
53
+ help="Path to the directory containing speech files.",
54
+ )
55
+ parser.add_argument(
56
+ "--extension",
57
+ type=str,
58
+ default="wav",
59
+ help="Extension of the speech files. Default: wav",
60
+ )
61
+ parser.add_argument(
62
+ "--decode-path",
63
+ type=str,
64
+ default=None,
65
+ help="Path to the output file where WER information will be saved. "
66
+ "If not provided, results are only printed to console.",
67
+ )
68
+ parser.add_argument(
69
+ "--model-dir",
70
+ type=str,
71
+ required=True,
72
+ help="Local path of our evaluation model repository."
73
+ "Download from https://huggingface.co/k2-fsa/TTS_eval_models."
74
+ "Will use 'tts_eval_models/wer/hubert-large-ls960-ft/'"
75
+ " in this script",
76
+ )
77
+ parser.add_argument(
78
+ "--test-list",
79
+ type=str,
80
+ default="transcript.jsonl",
81
+ help="path of the JSONL test list. Each line is a JSON object "
82
+ "with fields: id, text, ref_audio, ref_text, language_id, language_name.",
83
+ )
84
+ parser.add_argument(
85
+ "--batch-size",
86
+ type=int,
87
+ default=16,
88
+ help="Batch size for decoding with the Hugging Face pipeline.",
89
+ )
90
+ parser.add_argument(
91
+ "--nj-per-gpu", type=int, default=1, help="Number of workers per GPU."
92
+ )
93
+ return parser
94
+
95
+
96
+ def process_init(rank_queue, model_dir):
97
+ global worker_pipe, worker_device
98
+
99
+ torch.set_num_threads(2)
100
+
101
+ try:
102
+ rank = rank_queue.get(timeout=10)
103
+ except Exception:
104
+ raise RuntimeError("Failed to get GPU rank from queue.")
105
+
106
+ assert torch.cuda.is_available(), "CUDA is required but not available."
107
+ worker_device = torch.device(f"cuda:{rank}")
108
+ torch.cuda.set_device(rank)
109
+
110
+ logging.info(f"Initializing worker on device: {worker_device}")
111
+
112
+ try:
113
+ worker_pipe = load_hubert_model(model_dir, worker_device)
114
+ if worker_pipe is None:
115
+ raise RuntimeError("Model loading failed.")
116
+ except Exception as e:
117
+ logging.critical(f"Failed to load model on {worker_device}: {e}")
118
+ raise e
119
+
120
+
121
+ def load_hubert_model(model_dir, device):
122
+ model_path = os.path.join(model_dir, "wer/hubert-large-ls960-ft/")
123
+ if not os.path.exists(model_path):
124
+ logging.error(
125
+ f"Hubert model not found at {model_path}. "
126
+ "Please download from https://huggingface.co/k2-fsa/TTS_eval_models"
127
+ )
128
+ return None
129
+
130
+ logging.debug(f"Loading Hubert-based ASR model on {device}...")
131
+ import transformers
132
+
133
+ # Suppress transformers logging
134
+ transformers.logging.set_verbosity_error()
135
+
136
+ pipe = transformers.pipeline(
137
+ "automatic-speech-recognition",
138
+ model=model_path,
139
+ device=device,
140
+ tokenizer=model_path,
141
+ )
142
+ return pipe
143
+
144
+
145
+ def post_process(text: str) -> str:
146
+ """
147
+ Cleans and normalizes text for WER calculation.
148
+ Args:
149
+ text (str): The input text to be processed.
150
+
151
+ Returns:
152
+ str: The cleaned and normalized text.
153
+ """
154
+ text = text.replace("‘", "'").replace("’", "'")
155
+ text = re.sub(r"[^a-zA-Z0-9']", " ", text.lower())
156
+ text = re.sub(r"\s+", " ", text).strip()
157
+ return text
158
+
159
+
160
+ def run_eval_worker(data_chunk, batch_size):
161
+ global worker_pipe
162
+ if worker_pipe is None:
163
+ logging.error("Worker pipeline is not initialized!")
164
+ return []
165
+
166
+ metrics_buffer = []
167
+ try:
168
+ dataset = [
169
+ {
170
+ "array": load_eval_waveform(
171
+ item["wav_path"], sample_rate=16000, return_numpy=True
172
+ ),
173
+ "sampling_rate": 16000,
174
+ }
175
+ for item in data_chunk
176
+ ]
177
+ generate_kwargs = {"language": "english", "task": "transcribe"}
178
+
179
+ iterator = worker_pipe(
180
+ dataset, generate_kwargs=generate_kwargs, batch_size=batch_size
181
+ )
182
+
183
+ for i, out in enumerate(iterator):
184
+ hypothesis = out["text"].strip()
185
+ ref_item = data_chunk[i]
186
+ truth = ref_item["truth_text"]
187
+ wav_path = ref_item["wav_path"]
188
+
189
+ m = process_one(hypothesis, truth, post_process)
190
+ m["wav_path"] = wav_path
191
+ metrics_buffer.append(m)
192
+
193
+ except Exception:
194
+ logging.error(f"Worker failed on chunk:\n{traceback.format_exc()}")
195
+ return []
196
+
197
+ return metrics_buffer
198
+
199
+
200
+ def main():
201
+ parser = get_parser()
202
+ args = parser.parse_args()
203
+
204
+ logging.basicConfig(
205
+ format="%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s",
206
+ level=logging.INFO,
207
+ force=True,
208
+ )
209
+
210
+ logging.info(f"Calculating WER for {args.wav_path}")
211
+
212
+ data_list = []
213
+ samples = read_test_list(args.test_list)
214
+ for s in samples:
215
+ wav_full_path = str(Path(args.wav_path) / (s["id"] + "." + args.extension))
216
+ if not os.path.exists(wav_full_path):
217
+ logging.warning(f"File missing: {wav_full_path}")
218
+ continue
219
+ data_list.append(
220
+ {
221
+ "wav_path": wav_full_path,
222
+ "truth_text": s["text"],
223
+ }
224
+ )
225
+ total_files = len(data_list)
226
+
227
+ num_gpus = torch.cuda.device_count()
228
+ assert num_gpus > 0, "No GPU found. GPU is required."
229
+ total_workers = num_gpus * args.nj_per_gpu
230
+
231
+ mp.set_start_method("spawn", force=True)
232
+ manager = mp.Manager()
233
+ rank_queue = manager.Queue()
234
+
235
+ for _ in range(args.nj_per_gpu):
236
+ for rank in range(num_gpus):
237
+ rank_queue.put(rank)
238
+
239
+ chunk_size = max(1, args.batch_size)
240
+ tasks = [data_list[i : i + chunk_size] for i in range(0, total_files, chunk_size)]
241
+
242
+ logging.info(
243
+ f"Split data into {len(tasks)} chunks (size ~{chunk_size}). "
244
+ f"Spawning {total_workers} workers."
245
+ )
246
+
247
+ results = []
248
+
249
+ with ProcessPoolExecutor(
250
+ max_workers=total_workers,
251
+ initializer=process_init,
252
+ initargs=(rank_queue, args.model_dir),
253
+ ) as executor:
254
+ futures = []
255
+ for chunk in tasks:
256
+ futures.append(executor.submit(run_eval_worker, chunk, args.batch_size))
257
+
258
+ with tqdm(total=total_files, desc="Eval Progress", dynamic_ncols=True) as pbar:
259
+ for future in as_completed(futures):
260
+ chunk_metrics = future.result()
261
+ results.extend(chunk_metrics)
262
+ pbar.update(len(chunk_metrics))
263
+
264
+ wers, inses, deles, subses = [], [], [], []
265
+ word_nums = 0
266
+
267
+ fout = None
268
+ if args.decode_path:
269
+ os.makedirs(os.path.dirname(args.decode_path), exist_ok=True)
270
+ fout = open(args.decode_path, "w", encoding="utf8")
271
+ logging.info(f"Saving detailed WER results to: {args.decode_path}")
272
+ fout.write(
273
+ "Name\tWER\tTruth\tHypothesis\tInsertions\tDeletions\tSubstitutions\n"
274
+ )
275
+
276
+ for res in results:
277
+ wers.append(float(res["wer"]))
278
+ inses.append(float(res["insertions"]))
279
+ deles.append(float(res["deletions"]))
280
+ subses.append(float(res["substitutions"]))
281
+ word_nums += res["word_num"]
282
+
283
+ if fout:
284
+ fout.write(
285
+ f"{res['wav_path']}\t{res['wer']}\t{res['truth']}\t"
286
+ f"{res['hypo']}\t{res['insertions']}\t{res['deletions']}\t"
287
+ f"{res['substitutions']}\n"
288
+ )
289
+
290
+ wer_weighted = (
291
+ round((np.sum(subses) + np.sum(deles) + np.sum(inses)) / word_nums * 100, 2)
292
+ if word_nums > 0
293
+ else float("nan")
294
+ )
295
+
296
+ inse_sum = np.sum(inses)
297
+ dele_sum = np.sum(deles)
298
+ subs_sum = np.sum(subses)
299
+
300
+ print("-" * 50)
301
+ logging.info(f"Processed {len(results)}/{total_files} files.")
302
+ wer_info = f"WER: {wer_weighted}%"
303
+ detailed_info = (
304
+ f"Errors: {inse_sum} ins, {dele_sum} del, {subs_sum} sub / {word_nums} words"
305
+ )
306
+ logging.info(wer_info)
307
+ logging.info(detailed_info)
308
+ print("-" * 50)
309
+
310
+ if fout:
311
+ fout.write(wer_info + "\n" + detailed_info + "\n")
312
+ fout.close()
313
+
314
+
315
+ if __name__ == "__main__":
316
+ main()
omnivoice/eval/wer/minimax.py ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ Computes word error rate (WER) with Whisper-large-v3 for English and
20
+ Paraformer for Chinese. Intended to evaluate WERs on Seed-TTS test sets.
21
+ """
22
+
23
+ import argparse
24
+ import logging
25
+ import multiprocessing as mp
26
+ import os
27
+ import traceback
28
+ from collections import defaultdict
29
+ from concurrent.futures import ProcessPoolExecutor, as_completed
30
+ from pathlib import Path
31
+ from typing import List, Union
32
+
33
+ import numpy as np
34
+ import torch
35
+ import zhconv
36
+ from tqdm import tqdm
37
+
38
+ from omnivoice.eval.utils import load_eval_waveform
39
+ from omnivoice.eval.wer.common import log_metrics, process_one
40
+ from omnivoice.eval.wer.text_norm_omni import text_normalize
41
+ from omnivoice.utils.data_utils import read_test_list
42
+
43
+ # --- Global variables for worker processes ---
44
+ worker_pipe = None
45
+ worker_paraformer = None
46
+ worker_device = None
47
+
48
+
49
+ def read_language_mapping_from_tsv(
50
+ mapping_path: Path,
51
+ ) -> dict[str, Union[str, List[str]]]:
52
+ with open(mapping_path, "r", encoding="utf-8") as f:
53
+ _ = f.readline() # Skip header
54
+ language_mapping = {}
55
+ for line in f:
56
+ parts = line.strip().split("\t")
57
+ mixed_id, language_name, iso_639_3_id, duration = parts
58
+ language_mapping[mixed_id] = iso_639_3_id
59
+ return language_mapping
60
+
61
+
62
+ mixed_id_to_iso_639_3_id = read_language_mapping_from_tsv(
63
+ Path(f"{os.path.dirname(__file__)}/../../../docs/lang_id_name_map.tsv")
64
+ )
65
+
66
+
67
+ def get_parser():
68
+ parser = argparse.ArgumentParser(
69
+ description="Computes WER with Whisper.",
70
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
71
+ )
72
+
73
+ parser.add_argument(
74
+ "--wav-path",
75
+ type=str,
76
+ required=True,
77
+ help="Path to the directory containing speech files.",
78
+ )
79
+
80
+ parser.add_argument(
81
+ "--extension",
82
+ type=str,
83
+ default="wav",
84
+ help="Extension of the speech files. Default: wav",
85
+ )
86
+
87
+ parser.add_argument(
88
+ "--decode-path",
89
+ type=str,
90
+ default=None,
91
+ help="Path to the output file where WER information will be saved. "
92
+ "If not provided, results are only printed to console.",
93
+ )
94
+ parser.add_argument(
95
+ "--model-dir",
96
+ type=str,
97
+ required=True,
98
+ help="Local path of evaluation models repository. "
99
+ "Download from https://huggingface.co/k2-fsa/TTS_eval_models. ",
100
+ )
101
+ parser.add_argument(
102
+ "--test-list",
103
+ type=str,
104
+ default="test.jsonl",
105
+ help="path of the JSONL test list. Each line is a JSON object "
106
+ "with fields: id, text, ref_audio, ref_text, language_id, language_name.",
107
+ )
108
+ parser.add_argument(
109
+ "--lang",
110
+ type=str,
111
+ default=None,
112
+ help="""Language code to evaluate (e.g., 'en' for English, 'zh' for Chinese).
113
+ If not provided, the script will evaluate all languages found in the test list.
114
+ If specified, only samples of the given language will be evaluated.
115
+ """,
116
+ )
117
+ parser.add_argument(
118
+ "--batch-size",
119
+ type=int,
120
+ default=16,
121
+ help="Batch size for decoding with the Hugging Face pipeline.",
122
+ )
123
+ parser.add_argument(
124
+ "--nj-per-gpu", type=int, default=1, help="Number of workers per GPU."
125
+ )
126
+ parser.add_argument(
127
+ "--chunk-size",
128
+ type=int,
129
+ default=10,
130
+ help="Number of samples per task chunk sent to workers.",
131
+ )
132
+ return parser
133
+
134
+
135
+ def load_whisper_model(model_dir, device):
136
+ model_path = os.path.join(model_dir, "wer/whisper-large-v3/")
137
+ if not os.path.exists(model_path):
138
+ logging.error(f"Whisper model not found at {model_path}.")
139
+ return None
140
+
141
+ import transformers
142
+
143
+ # Suppress transformers logging
144
+ transformers.logging.set_verbosity_error()
145
+
146
+ logging.info(f"Loading Whisper model on {device}...")
147
+ pipe = transformers.pipeline(
148
+ "automatic-speech-recognition",
149
+ model=model_path,
150
+ chunk_length_s=30,
151
+ dtype=torch.float16 if "cuda" in str(device) else torch.float32,
152
+ device=device,
153
+ )
154
+ return pipe
155
+
156
+
157
+ def load_paraformer_model(model_dir, device):
158
+ model_path = os.path.join(model_dir, "wer/paraformer-zh/")
159
+ if not os.path.exists(model_path):
160
+ logging.error(f"Paraformer model not found at {model_path}.")
161
+ return None
162
+
163
+ logging.info(f"Loading Paraformer model on {device}...")
164
+
165
+ previous_level = logging.root.manager.disable
166
+ logging.disable(logging.CRITICAL)
167
+
168
+ try:
169
+ from funasr import AutoModel
170
+
171
+ model = AutoModel(
172
+ model=model_path,
173
+ device=str(device),
174
+ disable_update=True,
175
+ disable_pbar=True,
176
+ verbose=False,
177
+ )
178
+ finally:
179
+ logging.disable(previous_level)
180
+
181
+ return model
182
+
183
+
184
+ def _worker_setup(rank_queue):
185
+ """Common worker setup: get rank, configure device and threads."""
186
+ global worker_device
187
+
188
+ torch.set_num_threads(2)
189
+
190
+ try:
191
+ rank = rank_queue.get(timeout=10)
192
+ except Exception:
193
+ raise RuntimeError("Failed to get GPU rank from queue.")
194
+
195
+ assert torch.cuda.is_available(), "CUDA is required but not available."
196
+ worker_device = torch.device(f"cuda:{rank}")
197
+ torch.cuda.set_device(rank)
198
+
199
+ logging.info(f"Initializing worker on device: {worker_device}")
200
+
201
+
202
+ def process_init(rank_queue, model_dir):
203
+ """Initializer for Whisper worker processes."""
204
+ global worker_pipe
205
+
206
+ _worker_setup(rank_queue)
207
+
208
+ try:
209
+ worker_pipe = load_whisper_model(model_dir, worker_device)
210
+ if worker_pipe is None:
211
+ raise RuntimeError("Whisper model loading failed.")
212
+ except Exception as e:
213
+ logging.critical(f"Failed to load Whisper model on {worker_device}: {e}")
214
+ raise e
215
+
216
+
217
+ def process_init_paraformer(rank_queue, model_dir):
218
+ """Initializer for Paraformer worker processes (Chinese evaluation)."""
219
+ global worker_paraformer
220
+
221
+ _worker_setup(rank_queue)
222
+
223
+ try:
224
+ worker_paraformer = load_paraformer_model(model_dir, worker_device)
225
+ if worker_paraformer is None:
226
+ raise RuntimeError("Paraformer model loading failed.")
227
+ except Exception as e:
228
+ logging.critical(f"Failed to load Paraformer model on {worker_device}: {e}")
229
+ raise e
230
+
231
+
232
+ def post_process(text: str, lang: str) -> str:
233
+ """
234
+ Cleans and normalizes text for WER calculation.
235
+ Args:
236
+ text (str): The input text to be processed.
237
+ lang (str): The language of the input text.
238
+
239
+ Returns:
240
+ str: The cleaned and normalized text.
241
+ """
242
+ if lang != "unknown":
243
+ iso_639_3_code = mixed_id_to_iso_639_3_id[lang]
244
+ text = text_normalize(
245
+ text,
246
+ iso_code=iso_639_3_code,
247
+ lower_case=True,
248
+ remove_numbers=False,
249
+ remove_brackets=False,
250
+ )
251
+
252
+ if lang in ["zh", "yue"]:
253
+ text = zhconv.convert(text, "zh-cn")
254
+
255
+ # Processing spaces for languages using CER (consistent with the practice
256
+ # in paper Minimax-Speech), specifically: zh, yue, ja, ko, th, arb, vi, hi, el.
257
+ if lang in ("zh", "yue", "ja"):
258
+ # For languages where spaces are not semantically meaningful, remove spaces.
259
+ text = text.replace(" ", "")
260
+ text = " ".join([x for x in text])
261
+ elif lang in ("ko", "th", "arb", "vi", "hi", "el"):
262
+ # For languages where spaces are semantically meaningful, replace spaces with |.
263
+ text = text.replace(" ", "|")
264
+ text = " ".join([x for x in text])
265
+ text = text.lower()
266
+ return text.strip()
267
+
268
+
269
+ class SpeechEvalDataset(torch.utils.data.Dataset):
270
+ def __init__(self, data_list):
271
+ self.data_list = data_list
272
+
273
+ def __len__(self):
274
+ return len(self.data_list)
275
+
276
+ def __getitem__(self, index):
277
+ item = self.data_list[index]
278
+ waveform = load_eval_waveform(
279
+ item["wav_path"], sample_rate=16000, return_numpy=True
280
+ )
281
+ return {
282
+ "array": waveform,
283
+ "sampling_rate": 16000,
284
+ "truth_text": item["truth_text"],
285
+ }
286
+
287
+
288
+ def run_eval_worker(data_chunk, language, batch_size):
289
+ """
290
+ Worker function to process a chunk of data.
291
+ Uses the global worker_pipe initialized by process_init.
292
+ """
293
+ global worker_pipe
294
+ if worker_pipe is None:
295
+ logging.error("Worker pipeline is not initialized!")
296
+ return []
297
+
298
+ metrics_buffer = []
299
+ try:
300
+ dataset = SpeechEvalDataset(data_chunk)
301
+ if language != "unknown":
302
+ generate_kwargs = {"language": language, "task": "transcribe"}
303
+ else:
304
+ generate_kwargs = {"task": "transcribe"}
305
+
306
+ # Use the pipeline to infer batch
307
+ # Note: We iterate through the iterator returned by pipe
308
+ iterator = worker_pipe(
309
+ dataset, generate_kwargs=generate_kwargs, batch_size=batch_size
310
+ )
311
+
312
+ for i, out in enumerate(iterator):
313
+ hypothesis = out["text"].strip()
314
+
315
+ ref_item = data_chunk[i]
316
+ truth = ref_item["truth_text"]
317
+ wav_path = ref_item["wav_path"]
318
+ lang_id = ref_item.get("lang_id")
319
+ lang_name = ref_item.get("lang_name")
320
+
321
+ m = process_one(hypothesis, truth, post_process, lang_id)
322
+ m["wav_path"] = wav_path
323
+ m["lang_name"] = lang_name
324
+ metrics_buffer.append(m)
325
+
326
+ except Exception:
327
+ logging.error(
328
+ f"Worker failed on chunk (Lang: {language}):\n{traceback.format_exc()}"
329
+ )
330
+ return []
331
+
332
+ return metrics_buffer
333
+
334
+
335
+ def run_eval_worker_paraformer(data_chunk, batch_size):
336
+ """
337
+ Worker function for Chinese evaluation using Paraformer.
338
+ Uses the global worker_paraformer initialized by process_init_paraformer.
339
+ """
340
+ global worker_paraformer
341
+ if worker_paraformer is None:
342
+ logging.error("Paraformer worker pipeline is not initialized!")
343
+ return []
344
+
345
+ metrics_buffer = []
346
+ try:
347
+ wav_paths = [item["wav_path"] for item in data_chunk]
348
+
349
+ for i in range(0, len(wav_paths), batch_size):
350
+ batch_paths = wav_paths[i : i + batch_size]
351
+ res_batch = worker_paraformer.generate(
352
+ input=batch_paths, batch_size=batch_size, disable_pbar=True
353
+ )
354
+
355
+ for j, res in enumerate(res_batch):
356
+ hypothesis = res["text"]
357
+ ref_item = data_chunk[i + j]
358
+ truth = ref_item["truth_text"]
359
+ wav_path = ref_item["wav_path"]
360
+ lang_name = ref_item.get("lang_name")
361
+
362
+ m = process_one(hypothesis, truth, post_process, "zh")
363
+ m["wav_path"] = wav_path
364
+ m["lang_name"] = lang_name
365
+ metrics_buffer.append(m)
366
+
367
+ except Exception:
368
+ logging.error(f"Paraformer worker failed on chunk:\n{traceback.format_exc()}")
369
+ return []
370
+
371
+ return metrics_buffer
372
+
373
+
374
+ def main():
375
+ parser = get_parser()
376
+ args = parser.parse_args()
377
+
378
+ logging.basicConfig(
379
+ format="%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s",
380
+ level=logging.INFO,
381
+ force=True,
382
+ )
383
+
384
+ # 1. Prepare Data
385
+ logging.info("Reading test list...")
386
+ data_by_lang = defaultdict(list)
387
+ total_files = 0
388
+ wav_root = Path(args.wav_path)
389
+
390
+ samples = read_test_list(args.test_list)
391
+ for s in samples:
392
+ wav_path = str(wav_root / f"{s['id']}.{args.extension}")
393
+ if not os.path.exists(wav_path):
394
+ logging.warning(f"File missing: {wav_path}")
395
+ continue
396
+
397
+ lang_id = s.get("language_id") or "unknown"
398
+ lang_name = s.get("language_name") or "unknown"
399
+
400
+ item = {
401
+ "wav_path": wav_path,
402
+ "truth_text": s["text"],
403
+ "lang_id": lang_id,
404
+ "lang_name": lang_name,
405
+ }
406
+ if args.lang and s.get("language_id") != args.lang:
407
+ continue
408
+
409
+ data_by_lang[lang_name].append(item)
410
+ total_files += 1
411
+
412
+ logging.info(f"Total files: {total_files} in {len(data_by_lang)} languages.")
413
+
414
+ # 2. Worker config
415
+ num_gpus = torch.cuda.device_count()
416
+ assert num_gpus > 0, "No GPU found. GPU is required."
417
+ total_workers = num_gpus * args.nj_per_gpu
418
+
419
+ mp.set_start_method("spawn", force=True)
420
+ manager = mp.Manager()
421
+
422
+ # 3. Scheduling: Split data into Chinese (Paraformer) and non-Chinese (Whisper)
423
+ zh_items = []
424
+ non_zh_items = []
425
+ for lang_name, items in data_by_lang.items():
426
+ lang_id = items[0].get("lang_id", "") if items else ""
427
+ if lang_name == "Chinese" or (lang_id and lang_id.startswith("zh")):
428
+ zh_items.extend(items)
429
+ else:
430
+ non_zh_items.extend(items)
431
+
432
+ chunk_size = args.chunk_size
433
+
434
+ whisper_tasks = []
435
+ for i in range(0, len(non_zh_items), chunk_size):
436
+ chunk = non_zh_items[i : i + chunk_size]
437
+ lang_name = chunk[0].get("lang_name", "unknown")
438
+ whisper_tasks.append({"chunk": chunk, "lang": lang_name})
439
+
440
+ paraformer_tasks = []
441
+ for i in range(0, len(zh_items), chunk_size):
442
+ paraformer_tasks.append(zh_items[i : i + chunk_size])
443
+
444
+ logging.info(
445
+ f"Whisper tasks: {len(whisper_tasks)} chunks ({len(non_zh_items)} files). "
446
+ f"Paraformer tasks: {len(paraformer_tasks)} chunks ({len(zh_items)} files). "
447
+ f"Spawning {total_workers} workers per pool."
448
+ )
449
+
450
+ # 4. Execution — run Whisper and Paraformer pools sequentially
451
+ results = []
452
+
453
+ # 4a. Whisper pool for non-Chinese languages
454
+ if whisper_tasks:
455
+ whisper_rank_queue = manager.Queue()
456
+ for _ in range(args.nj_per_gpu):
457
+ for rank in range(num_gpus):
458
+ whisper_rank_queue.put(rank)
459
+
460
+ with ProcessPoolExecutor(
461
+ max_workers=total_workers,
462
+ initializer=process_init,
463
+ initargs=(whisper_rank_queue, args.model_dir),
464
+ ) as executor:
465
+ futures = []
466
+ for task in whisper_tasks:
467
+ futures.append(
468
+ executor.submit(
469
+ run_eval_worker, task["chunk"], task["lang"], args.batch_size
470
+ )
471
+ )
472
+
473
+ with tqdm(
474
+ total=len(non_zh_items),
475
+ desc="Whisper Eval",
476
+ dynamic_ncols=True,
477
+ ) as pbar:
478
+ for future in as_completed(futures):
479
+ try:
480
+ chunk_metrics = future.result()
481
+ results.extend(chunk_metrics)
482
+ pbar.update(len(chunk_metrics))
483
+ except Exception as e:
484
+ logging.error(f"Whisper task failed: {e}")
485
+
486
+ # 4b. Paraformer pool for Chinese
487
+ if paraformer_tasks:
488
+ para_rank_queue = manager.Queue()
489
+ for _ in range(args.nj_per_gpu):
490
+ for rank in range(num_gpus):
491
+ para_rank_queue.put(rank)
492
+
493
+ with ProcessPoolExecutor(
494
+ max_workers=total_workers,
495
+ initializer=process_init_paraformer,
496
+ initargs=(para_rank_queue, args.model_dir),
497
+ ) as executor:
498
+ futures = []
499
+ for chunk in paraformer_tasks:
500
+ futures.append(
501
+ executor.submit(run_eval_worker_paraformer, chunk, args.batch_size)
502
+ )
503
+
504
+ with tqdm(
505
+ total=len(zh_items),
506
+ desc="Paraformer Eval",
507
+ dynamic_ncols=True,
508
+ ) as pbar:
509
+ for future in as_completed(futures):
510
+ try:
511
+ chunk_metrics = future.result()
512
+ results.extend(chunk_metrics)
513
+ pbar.update(len(chunk_metrics))
514
+ except Exception as e:
515
+ logging.error(f"Paraformer task failed: {e}")
516
+
517
+ # 5. Metrics Aggregation
518
+ wers, inses, deles, subses = [], [], [], []
519
+ word_nums = 0
520
+
521
+ # Store metrics per language
522
+ lang_stats = {}
523
+
524
+ fout = None
525
+ if args.decode_path:
526
+ os.makedirs(os.path.dirname(args.decode_path), exist_ok=True)
527
+ logging.info(f"Saving detailed WER results to: {args.decode_path}")
528
+ fout = open(args.decode_path, "w", encoding="utf-8")
529
+
530
+ for res in results:
531
+ wers.append(float(res["wer"]))
532
+ inses.append(float(res["insertions"]))
533
+ deles.append(float(res["deletions"]))
534
+ subses.append(float(res["substitutions"]))
535
+ word_nums += res["word_num"]
536
+
537
+ if fout:
538
+ fout.write(
539
+ f"{res['wav_path']}\t{res['wer']}\t{res['truth']}\t"
540
+ f"{res['hypo']}\t{res['insertions']}\t{res['deletions']}\t"
541
+ f"{res['substitutions']}\n"
542
+ )
543
+ lang_name = res["lang_name"]
544
+
545
+ # Per language stats
546
+ if lang_name not in lang_stats:
547
+ lang_stats[lang_name] = {
548
+ "inses": [],
549
+ "deles": [],
550
+ "subses": [],
551
+ "word_nums": 0,
552
+ }
553
+ lang_stats[lang_name]["inses"].append(float(res["insertions"]))
554
+ lang_stats[lang_name]["deles"].append(float(res["deletions"]))
555
+ lang_stats[lang_name]["subses"].append(float(res["substitutions"]))
556
+ lang_stats[lang_name]["word_nums"] += res["word_num"]
557
+
558
+ print("-" * 50)
559
+ # Log per-language stats
560
+ per_lang_wers = []
561
+ for lang in sorted(lang_stats.keys()):
562
+ stats = lang_stats[lang]
563
+ if stats["word_nums"] > 0:
564
+ lang_wer = log_metrics(
565
+ fout,
566
+ f"[{lang}]",
567
+ stats["inses"],
568
+ stats["deles"],
569
+ stats["subses"],
570
+ stats["word_nums"],
571
+ ndigits=3,
572
+ )
573
+ per_lang_wers.append(lang_wer)
574
+ print("-" * 50)
575
+
576
+ # Log Macro-average WER
577
+ if len(per_lang_wers) > 1:
578
+ macro_wer = np.mean(per_lang_wers)
579
+ logging.info(
580
+ f"Macro-average WER over {len(per_lang_wers)} languages: {macro_wer:.2f}%"
581
+ )
582
+ if fout:
583
+ fout.write(
584
+ f"Macro-average WER over {len(per_lang_wers)} languages: {macro_wer:.2f}%\n"
585
+ )
586
+
587
+ # Log overall stats
588
+ if word_nums > 0:
589
+ log_metrics(fout, "Overall", inses, deles, subses, word_nums)
590
+
591
+ if fout:
592
+ fout.close()
593
+
594
+
595
+ if __name__ == "__main__":
596
+ main()
omnivoice/eval/wer/norm_config_module.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ # All rights reserved.
4
+ #
5
+ # This source code is licensed under the BSD-style license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ """
9
+ This module defines the normalization configuration for WER evaluation.
10
+ Copied from https://github.com/facebookresearch/omnilingual-asr/blob/81f51e224ce9e74b02cc2a3eaf21b2d91d743455/workflows/dataprep/norm_config_module.py
11
+ """
12
+
13
+ # type: ignore
14
+ import os
15
+ import re
16
+
17
+ colon = ":"
18
+ comma = ","
19
+ exclamation_mark = "!"
20
+ period = re.escape(".")
21
+ question_mark = re.escape("?")
22
+ semicolon = ";"
23
+
24
+ left_curly_bracket = "{"
25
+ right_curly_bracket = "}"
26
+ quotation_mark = '"'
27
+
28
+ basic_punc = (
29
+ period
30
+ + question_mark
31
+ + comma
32
+ + colon
33
+ + exclamation_mark
34
+ + left_curly_bracket
35
+ + right_curly_bracket
36
+ )
37
+
38
+ # General punc unicode block (0x2000-0x206F)
39
+ zero_width_space = r"\u200B"
40
+ zero_width_nonjoiner = r"\u200C"
41
+ left_to_right_mark = r"\u200E"
42
+ right_to_left_mark = r"\u200F"
43
+ left_to_right_embedding = r"\u202A"
44
+ pop_directional_formatting = r"\u202C"
45
+
46
+ # Here are some commonly ill-typed versions of apostrophe
47
+ right_single_quotation_mark = r"\u2019"
48
+ left_single_quotation_mark = r"\u2018"
49
+
50
+ # Language specific definitions
51
+ # Spanish
52
+ inverted_exclamation_mark = r"\u00A1"
53
+ inverted_question_mark = r"\u00BF"
54
+
55
+
56
+ # Hindi
57
+ hindi_danda = "\u0964"
58
+
59
+ # Egyptian Arabic
60
+ # arabic_percent = r"\u066A"
61
+ arabic_comma = r"\u060C"
62
+ arabic_question_mark = r"\u061F"
63
+ arabic_semicolon = r"\u061B"
64
+ arabic_diacritics = r"\u064B-\u0652"
65
+
66
+
67
+ arabic_subscript_alef_and_inverted_damma = r"\u0656-\u0657"
68
+
69
+
70
+ # Chinese
71
+ full_stop = r"\u3002"
72
+ full_comma = r"\uFF0C"
73
+ full_exclamation_mark = r"\uFF01"
74
+ full_question_mark = r"\uFF1F"
75
+ full_semicolon = r"\uFF1B"
76
+ full_colon = r"\uFF1A"
77
+ full_parentheses = r"\uFF08\uFF09"
78
+ quotation_mark_horizontal = r"\u300C-\u300F"
79
+ quotation_mark_vertical = r"\uFF41-\uFF44"
80
+ title_marks = r"\u3008-\u300B"
81
+ wavy_low_line = r"\uFE4F"
82
+ ellipsis = r"\u22EF"
83
+ enumeration_comma = r"\u3001"
84
+ hyphenation_point = r"\u2027"
85
+ forward_slash = r"\uFF0F"
86
+ wavy_dash = r"\uFF5E"
87
+ box_drawings_light_horizontal = r"\u2500"
88
+ fullwidth_low_line = r"\uFF3F"
89
+ chinese_punc = (
90
+ full_stop
91
+ + full_comma
92
+ + full_exclamation_mark
93
+ + full_question_mark
94
+ + full_semicolon
95
+ + full_colon
96
+ + full_parentheses
97
+ + quotation_mark_horizontal
98
+ + quotation_mark_vertical
99
+ + title_marks
100
+ + wavy_low_line
101
+ + ellipsis
102
+ + enumeration_comma
103
+ + hyphenation_point
104
+ + forward_slash
105
+ + wavy_dash
106
+ + box_drawings_light_horizontal
107
+ + fullwidth_low_line
108
+ )
109
+
110
+ # Armenian
111
+ armenian_apostrophe = r"\u055A"
112
+ emphasis_mark = r"\u055B"
113
+ exclamation_mark = r"\u055C"
114
+ armenian_comma = r"\u055D"
115
+ armenian_question_mark = r"\u055E"
116
+ abbreviation_mark = r"\u055F"
117
+ armenian_full_stop = r"\u0589"
118
+ armenian_punc = (
119
+ armenian_apostrophe
120
+ + emphasis_mark
121
+ + exclamation_mark
122
+ + armenian_comma
123
+ + armenian_question_mark
124
+ + abbreviation_mark
125
+ + armenian_full_stop
126
+ )
127
+
128
+ lesser_than_symbol = r"&lt;"
129
+ greater_than_symbol = r"&gt;"
130
+
131
+ lesser_than_sign = r"\u003c"
132
+ greater_than_sign = r"\u003e"
133
+
134
+ nbsp_written_form = r"&nbsp"
135
+
136
+ # Quotation marks
137
+ left_double_quotes = r"\u201c"
138
+ right_double_quotes = r"\u201d"
139
+ left_double_angle = r"\u00ab"
140
+ right_double_angle = r"\u00bb"
141
+ left_single_angle = r"\u2039"
142
+ right_single_angle = r"\u203a"
143
+ low_double_quotes = r"\u201e"
144
+ low_single_quotes = r"\u201a"
145
+ high_double_quotes = r"\u201f"
146
+ high_single_quotes = r"\u201b"
147
+
148
+ all_punct_quotes = (
149
+ left_double_quotes
150
+ + right_double_quotes
151
+ + left_double_angle
152
+ + right_double_angle
153
+ + left_single_angle
154
+ + right_single_angle
155
+ + low_double_quotes
156
+ + low_single_quotes
157
+ + high_double_quotes
158
+ + high_single_quotes
159
+ + right_single_quotation_mark
160
+ + left_single_quotation_mark
161
+ )
162
+ mapping_quotes = (
163
+ "["
164
+ + high_single_quotes
165
+ + right_single_quotation_mark
166
+ + left_single_quotation_mark
167
+ + "]"
168
+ )
169
+
170
+
171
+ # Digits
172
+
173
+ english_digits = r"\u0030-\u0039"
174
+ bengali_digits = r"\u09e6-\u09ef"
175
+ khmer_digits = r"\u17e0-\u17e9"
176
+ devanagari_digits = r"\u0966-\u096f"
177
+ oriya_digits = r"\u0b66-\u0b6f"
178
+ extended_arabic_indic_digits = r"\u06f0-\u06f9"
179
+ kayah_li_digits = r"\ua900-\ua909"
180
+ fullwidth_digits = r"\uff10-\uff19"
181
+ malayam_digits = r"\u0d66-\u0d6f"
182
+ myanmar_digits = r"\u1040-\u1049"
183
+ roman_numeral = r"\u2170-\u2179"
184
+ nominal_digit_shapes = r"\u206f"
185
+
186
+ # Load punctuations
187
+ with open(f"{os.path.dirname(__file__)}/punctuations.lst", "r") as punc_f:
188
+ punc_list = [
189
+ line
190
+ for line in punc_f.readlines()
191
+ if line.strip() and not line.strip().startswith("#")
192
+ ]
193
+
194
+ punct_pattern = r""
195
+ for punc in punc_list:
196
+ # the first character in the tab separated line is the punc to be removed
197
+ punct_pattern += re.escape(punc.split("\t")[0])
198
+
199
+ shared_digits = (
200
+ english_digits
201
+ + bengali_digits
202
+ + khmer_digits
203
+ + devanagari_digits
204
+ + oriya_digits
205
+ + extended_arabic_indic_digits
206
+ + kayah_li_digits
207
+ + fullwidth_digits
208
+ + malayam_digits
209
+ + myanmar_digits
210
+ + roman_numeral
211
+ + nominal_digit_shapes
212
+ )
213
+
214
+ shared_punc_list = (
215
+ basic_punc
216
+ + all_punct_quotes
217
+ + greater_than_sign
218
+ + lesser_than_sign
219
+ + inverted_question_mark
220
+ + full_stop
221
+ + semicolon
222
+ + armenian_punc
223
+ + inverted_exclamation_mark
224
+ + arabic_comma
225
+ + enumeration_comma
226
+ + hindi_danda
227
+ + quotation_mark
228
+ + arabic_semicolon
229
+ + arabic_question_mark
230
+ + chinese_punc
231
+ + punct_pattern
232
+ )
233
+
234
+ shared_mappping = {
235
+ lesser_than_symbol: "",
236
+ greater_than_symbol: "",
237
+ nbsp_written_form: "",
238
+ r"(\S+)" + mapping_quotes + r"(\S+)": r"\1'\2",
239
+ }
240
+
241
+ shared_deletion_list = (
242
+ left_to_right_mark
243
+ + zero_width_nonjoiner
244
+ + arabic_subscript_alef_and_inverted_damma
245
+ + zero_width_space
246
+ + arabic_diacritics
247
+ + pop_directional_formatting
248
+ + right_to_left_mark
249
+ + left_to_right_embedding
250
+ )
251
+
252
+ norm_config = {
253
+ "*": {
254
+ "lower_case": True,
255
+ "punc_set": shared_punc_list,
256
+ "del_set": shared_deletion_list,
257
+ "mapping": shared_mappping,
258
+ "digit_set": shared_digits,
259
+ "unicode_norm": "NFKC",
260
+ "rm_diacritics": False,
261
+ }
262
+ }
263
+
264
+ # =============== Mongolian ===============#
265
+
266
+ norm_config["mon"] = norm_config["*"].copy()
267
+ # add soft hyphen to punc list to match with fleurs
268
+ norm_config["mon"]["del_set"] += r"\u00AD"
269
+
270
+ norm_config["khk"] = norm_config["mon"].copy()
271
+
272
+ # =============== Hebrew ===============#
273
+
274
+ norm_config["heb"] = norm_config["*"].copy()
275
+ # add "HEBREW POINT" symbols to match with fleurs
276
+ norm_config["heb"]["del_set"] += r"\u05B0-\u05BF\u05C0-\u05CF"
277
+
278
+ # =============== Thai ===============#
279
+
280
+ norm_config["tha"] = norm_config["*"].copy()
281
+ # add "Zero width joiner" symbols to match with fleurs
282
+ norm_config["tha"]["punc_set"] += r"\u200D"
283
+
284
+ # =============== Arabic ===============#
285
+ norm_config["ara"] = norm_config["*"].copy()
286
+ norm_config["ara"]["mapping"]["ٱ"] = "ا"
287
+ norm_config["arb"] = norm_config["ara"].copy()
288
+
289
+ # =============== Javanese ===============#
290
+ norm_config["jav"] = norm_config["*"].copy()
291
+ norm_config["jav"]["rm_diacritics"] = True
omnivoice/eval/wer/punctuations.lst ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+  7355 INVALID UNICODE 0x81
2
+  5265 INVALID UNICODE 0x90
3
+  75 INVALID UNICODE 0x8
4
+  31 INVALID UNICODE 0x8d
5
+ ” 3 INVALID UNICODE 0x94
6
+  2 INVALID UNICODE 0x8f
7
+  2 INVALID UNICODE 0x1a
8
+  1 INVALID UNICODE 0x9d
9
+ “ 1 INVALID UNICODE 0x93
10
+ ’ 1 INVALID UNICODE 0x92
11
+  8647 INVALID UNICODE 0xe295
12
+  6650 INVALID UNICODE 0xf21d
13
+  6234 INVALID UNICODE 0xf62d
14
+  4815 INVALID UNICODE 0xf173
15
+  4789 INVALID UNICODE 0xe514
16
+  4409 INVALID UNICODE 0xe293
17
+  3881 INVALID UNICODE 0xf523
18
+  3788 INVALID UNICODE 0xe233
19
+  2448 INVALID UNICODE 0xf50f
20
+  2177 INVALID UNICODE 0xe232
21
+  1955 INVALID UNICODE 0xea7b
22
+  1926 INVALID UNICODE 0xf172
23
+  973 INVALID UNICODE 0xe290
24
+  972 INVALID UNICODE 0xf519
25
+  661 INVALID UNICODE 0xe292
26
+  591 INVALID UNICODE 0xe328
27
+  509 INVALID UNICODE 0xe2fa
28
+  458 INVALID UNICODE 0xe234
29
+  446 INVALID UNICODE 0xe043
30
+  419 INVALID UNICODE 0xe040
31
+  399 INVALID UNICODE 0xe2fb
32
+  387 INVALID UNICODE 0xe32b
33
+  381 INVALID UNICODE 0xe236
34
+  374 INVALID UNICODE 0xf511
35
+  314 INVALID UNICODE 0xe517
36
+  296 INVALID UNICODE 0xe2fe
37
+  293 INVALID UNICODE 0xe492
38
+  291 INVALID UNICODE 0xf52d
39
+  289 INVALID UNICODE 0xe2fc
40
+  195 INVALID UNICODE 0xf521
41
+  190 INVALID UNICODE 0xe516
42
+  182 INVALID UNICODE 0xe041
43
+  178 INVALID UNICODE 0xf529
44
+  113 INVALID UNICODE 0xe2f9
45
+  87 INVALID UNICODE 0xe2d9
46
+  78 INVALID UNICODE 0xe32a
47
+  76 INVALID UNICODE 0xe291
48
+  74 INVALID UNICODE 0xe296
49
+  66 INVALID UNICODE 0xe518
50
+  52 INVALID UNICODE 0xe32c
51
+  46 INVALID UNICODE 0xe2db
52
+  41 INVALID UNICODE 0xe231
53
+  34 INVALID UNICODE 0xf522
54
+  33 INVALID UNICODE 0xf518
55
+  32 INVALID UNICODE 0xf513
56
+  27 INVALID UNICODE 0xe32d
57
+  25 INVALID UNICODE 0xe32e
58
+  23 INVALID UNICODE 0xe06b
59
+  15 INVALID UNICODE 0xea01
60
+  12 INVALID UNICODE 0xe294
61
+  11 INVALID UNICODE 0xe203
62
+  8 INVALID UNICODE 0xf218
63
+  7 INVALID UNICODE 0xe070
64
+  7 INVALID UNICODE 0xe013
65
+  5 INVALID UNICODE 0xe2de
66
+  4 INVALID UNICODE 0xe493
67
+  3 INVALID UNICODE 0xf7e8
68
+  3 INVALID UNICODE 0xf7d0
69
+  3 INVALID UNICODE 0xe313
70
+  2 INVALID UNICODE 0xe329
71
+  2 INVALID UNICODE 0xe06d
72
+  2 INVALID UNICODE 0xe003
73
+  1 INVALID UNICODE 0xf50e
74
+  1 INVALID UNICODE 0xf171
75
+  1 INVALID UNICODE 0xe01d
76
+  71 NOMINAL DIGIT SHAPES 0x206f
77
+ ⁠ 3 WORD JOINER 0x2060
78
+ ― 126545 HORIZONTAL BAR 0x2015
79
+ ־ 1028 HEBREW PUNCTUATION MAQAF 0x5be
80
+ ) 98429 RIGHT PARENTHESIS 0x29
81
+ ] 27108 RIGHT SQUARE BRACKET 0x5d
82
+ ⌋ 1567 RIGHT FLOOR 0x230b
83
+ 〕 97 RIGHT TORTOISE SHELL BRACKET 0x3015
84
+ 】 36 RIGHT BLACK LENTICULAR BRACKET 0x3011
85
+ ﴾ 14 ORNATE LEFT PARENTHESIS 0xfd3e
86
+ & 170517 AMPERSAND 0x26
87
+ ། 106330 TIBETAN MARK SHAD 0xf0d
88
+ ። 90203 ETHIOPIC FULL STOP 0x1362
89
+ ፥ 60484 ETHIOPIC COLON 0x1365
90
+ ༌ 60464 TIBETAN MARK DELIMITER TSHEG BSTAR 0xf0c
91
+ ။ 51567 MYANMAR SIGN SECTION 0x104b
92
+ / 46929 SOLIDUS 0x2f
93
+ ၊ 38042 MYANMAR SIGN LITTLE SECTION 0x104a
94
+ · 37985 MIDDLE DOT 0xb7
95
+ ‸ 36310 CARET 0x2038
96
+ * 34793 ASTERISK 0x2a
97
+ ۔ 32432 ARABIC FULL STOP 0x6d4
98
+ ፤ 31906 ETHIOPIC SEMICOLON 0x1364
99
+ ၏ 21519 MYANMAR SYMBOL GENITIVE 0x104f
100
+ ។ 20834 KHMER SIGN KHAN 0x17d4
101
+ ꓾ 15773 LISU PUNCTUATION COMMA 0xa4fe
102
+ ᙮ 13473 CANADIAN SYLLABICS FULL STOP 0x166e
103
+ ꤯ 12892 KAYAH LI SIGN SHYA 0xa92f
104
+ ⵰ 11478 TIFINAGH SEPARATOR MARK 0x2d70
105
+ ꓿ 11118 LISU PUNCTUATION FULL STOP 0xa4ff
106
+ ॥ 10763 DEVANAGARI DOUBLE DANDA 0x965
107
+ ؞ 10403 ARABIC TRIPLE DOT PUNCTUATION MARK 0x61e
108
+ ၍ 8936 MYANMAR SYMBOL COMPLETED 0x104d
109
+ · 8431 GREEK ANO TELEIA 0x387
110
+ † 7477 DAGGER 0x2020
111
+ ၌ 6632 MYANMAR SYMBOL LOCATIVE 0x104c
112
+ ፣ 5719 ETHIOPIC COMMA 0x1363
113
+ ៖ 5528 KHMER SIGN CAMNUC PII KUUH 0x17d6
114
+ ꤮ 4791 KAYAH LI SIGN CWI 0xa92e
115
+ ※ 3439 REFERENCE MARK 0x203b
116
+ ፦ 2727 ETHIOPIC PREFACE COLON 0x1366
117
+ • 1749 BULLET 0x2022
118
+ ¶ 1507 PILCROW SIGN 0xb6
119
+ ၎ 1386 MYANMAR SYMBOL AFOREMENTIONED 0x104e
120
+ ﹖ 1224 SMALL QUESTION MARK 0xfe56
121
+ ; 975 GREEK QUESTION MARK 0x37e
122
+ … 827 HORIZONTAL ELLIPSIS 0x2026
123
+ % 617 PERCENT SIGN 0x25
124
+ ・ 468 KATAKANA MIDDLE DOT 0x30fb
125
+ ༎ 306 TIBETAN MARK NYIS SHAD 0xf0e
126
+ ‡ 140 DOUBLE DAGGER 0x2021
127
+ # 137 NUMBER SIGN 0x23
128
+ @ 125 COMMERCIAL AT 0x40
129
+ ፡ 121 ETHIOPIC WORDSPACE 0x1361
130
+ ៚ 55 KHMER SIGN KOOMUUT 0x17da
131
+ ៕ 49 KHMER SIGN BARIYOOSAN 0x17d5
132
+ ﹐ 10 SMALL COMMA 0xfe50
133
+ ༅ 6 TIBETAN MARK CLOSING YIG MGO SGAB MA 0xf05
134
+ ༄ 6 TIBETAN MARK INITIAL YIG MGO MDUN MA 0xf04
135
+ . 2 FULLWIDTH FULL STOP 0xff0e
136
+ ﹗ 2 SMALL EXCLAMATION MARK 0xfe57
137
+ ﹕ 2 SMALL COLON 0xfe55
138
+ ‰ 2 PER MILLE SIGN 0x2030
139
+ ・ 1 HALFWIDTH KATAKANA MIDDLE DOT 0xff65
140
+ ( 98504 LEFT PARENTHESIS 0x28
141
+ [ 27245 LEFT SQUARE BRACKET 0x5b
142
+ ⌊ 1567 LEFT FLOOR 0x230a
143
+ 〔 95 LEFT TORTOISE SHELL BRACKET 0x3014
144
+ 【 36 LEFT BLACK LENTICULAR BRACKET 0x3010
145
+ ﴿ 14 ORNATE RIGHT PARENTHESIS 0xfd3f
146
+ _ 4851 LOW LINE 0x5f
147
+ $ 72 DOLLAR SIGN 0x24
148
+ € 14 EURO SIGN 0x20ac
149
+ £ 2 POUND SIGN 0xa3
150
+ ~ 27462 TILDE 0x7e
151
+ = 11450 EQUALS SIGN 0x3d
152
+ | 8430 VERTICAL LINE 0x7c
153
+ − 3971 MINUS SIGN 0x2212
154
+ ≫ 1904 MUCH GREATER-THAN 0x226b
155
+ ≪ 1903 MUCH LESS-THAN 0x226a
156
+ + 1450 PLUS SIGN 0x2b
157
+ < 345 FULLWIDTH LESS-THAN SIGN 0xff1c
158
+ > 344 FULLWIDTH GREATER-THAN SIGN 0xff1e
159
+ ¬ 5 NOT SIGN 0xac
160
+ × 4 MULTIPLICATION SIGN 0xd7
161
+ → 2 RIGHTWARDS ARROW 0x2192
162
+ ᙭ 537 CANADIAN SYLLABICS CHI SIGN 0x166d
163
+ ° 499 DEGREE SIGN 0xb0
164
+ ႟ 421 MYANMAR SYMBOL SHAN EXCLAMATION 0x109f
165
+ � 192 REPLACEMENT CHARACTER 0xfffd
166
+ ⌟ 54 BOTTOM RIGHT CORNER 0x231f
167
+ ⌞ 54 BOTTOM LEFT CORNER 0x231e
168
+ © 2 COPYRIGHT SIGN 0xa9
169
+   40 NARROW NO-BREAK SPACE 0x202f
170
+   1 SIX-PER-EM SPACE 0x2006
171
+ ˜ 40261 SMALL TILDE 0x2dc
172
+ ^ 6469 CIRCUMFLEX ACCENT 0x5e
173
+ ¯ 20 MACRON 0xaf
174
+ ˇ 191442 CARON 0x2c7
175
+ ⁿ 38144 SUPERSCRIPT LATIN SMALL LETTER N 0x207f
176
+ ـ 9440 ARABIC TATWEEL 0x640
177
+ ๆ 6766 THAI CHARACTER MAIYAMOK 0xe46
178
+ ៗ 3310 KHMER SIGN LEK TOO 0x17d7
179
+ 々 678 IDEOGRAPHIC ITERATION MARK 0x3005
180
+ ໆ 430 LAO KO LA 0xec6
181
+ ー 319 KATAKANA-HIRAGANA PROLONGED SOUND MARK 0x30fc
182
+ ⁱ 137 SUPERSCRIPT LATIN SMALL LETTER I 0x2071
183
+ ৷ 11056 BENGALI CURRENCY NUMERATOR FOUR 0x9f7
184
+ ⅓ 26 VULGAR FRACTION ONE THIRD 0x2153
185
+ ½ 26 VULGAR FRACTION ONE HALF 0xbd
186
+ ¼ 4 VULGAR FRACTION ONE QUARTER 0xbc
187
+ ⅟ 1 FRACTION NUMERATOR ONE 0x215f
188
+ ⁄ 57 FRACTION SLASH 0x2044
omnivoice/eval/wer/seedtts.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ Computes word error rate (WER) with Whisper-large-v3 for English and
20
+ Paraformer for Chinese. Intended to evaluate WERs on Seed-TTS test sets.
21
+ """
22
+
23
+ import argparse
24
+ import logging
25
+ import multiprocessing as mp
26
+ import os
27
+ import string
28
+ import traceback
29
+ from concurrent.futures import ProcessPoolExecutor, as_completed
30
+ from pathlib import Path
31
+
32
+ import numpy as np
33
+ import torch
34
+ import zhconv
35
+ from tqdm import tqdm
36
+ from zhon.hanzi import punctuation
37
+
38
+ from omnivoice.eval.utils import load_eval_waveform
39
+ from omnivoice.eval.wer.common import process_one
40
+ from omnivoice.utils.data_utils import read_test_list
41
+
42
+ # --- Global variables for worker processes ---
43
+ worker_pipe = None
44
+ worker_device = None
45
+
46
+
47
+ def get_parser():
48
+ parser = argparse.ArgumentParser(
49
+ description="Computes WER with Whisper/Paraformer.",
50
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
51
+ )
52
+ parser.add_argument(
53
+ "--wav-path",
54
+ type=str,
55
+ required=True,
56
+ help="Path to the directory containing speech files.",
57
+ )
58
+ parser.add_argument(
59
+ "--extension",
60
+ type=str,
61
+ default="wav",
62
+ help="Extension of the speech files. Default: wav",
63
+ )
64
+ parser.add_argument(
65
+ "--decode-path",
66
+ type=str,
67
+ default=None,
68
+ help="Path to the output file where WER information will be saved. "
69
+ "If not provided, results are only printed to console.",
70
+ )
71
+ parser.add_argument(
72
+ "--model-dir",
73
+ type=str,
74
+ required=True,
75
+ help="Local path of evaluation models repository. "
76
+ "Download from https://huggingface.co/k2-fsa/TTS_eval_models. "
77
+ "This script expects 'tts_eval_models/wer/whisper-large-v3/' for English "
78
+ "and 'tts_eval_models/wer/paraformer-zh/' for Chinese within this directory.",
79
+ )
80
+ parser.add_argument(
81
+ "--test-list",
82
+ type=str,
83
+ default="test.jsonl",
84
+ help="path of the JSONL test list. Each line is a JSON object "
85
+ "with fields: id, text, ref_audio, ref_text, language_id, language_name.",
86
+ )
87
+ parser.add_argument(
88
+ "--lang",
89
+ type=str,
90
+ choices=["zh", "en"],
91
+ required=True,
92
+ help="Language of the audio and transcripts for "
93
+ "decoding ('zh' for Chinese or 'en' for English).",
94
+ )
95
+ parser.add_argument(
96
+ "--batch-size",
97
+ type=int,
98
+ default=16,
99
+ help="Batch size for decoding with the Hugging Face pipeline.",
100
+ )
101
+ parser.add_argument(
102
+ "--nj-per-gpu", type=int, default=1, help="Number of workers per GPU."
103
+ )
104
+ return parser
105
+
106
+
107
+ def load_whisper_model(model_dir, device):
108
+ model_path = os.path.join(model_dir, "wer/whisper-large-v3/")
109
+ if not os.path.exists(model_path):
110
+ logging.error(f"Whisper model not found at {model_path}.")
111
+ return None
112
+
113
+ logging.debug(f"Loading Whisper model on {device}...")
114
+
115
+ import transformers
116
+
117
+ # Suppress transformers logging
118
+ transformers.logging.set_verbosity_error()
119
+
120
+ pipe = transformers.pipeline(
121
+ "automatic-speech-recognition",
122
+ model=model_path,
123
+ dtype=torch.float16 if "cuda" in str(device) else torch.float32,
124
+ device=device,
125
+ )
126
+ return pipe
127
+
128
+
129
+ def load_paraformer_model(model_dir, device):
130
+ model_path = os.path.join(model_dir, "wer/paraformer-zh/")
131
+ if not os.path.exists(model_path):
132
+ logging.error(f"Paraformer model not found at {model_path}.")
133
+ return None
134
+
135
+ logging.debug(f"Loading Paraformer model on {device}...")
136
+
137
+ previous_level = logging.root.manager.disable
138
+ logging.disable(logging.CRITICAL)
139
+
140
+ try:
141
+ from funasr import AutoModel
142
+
143
+ # FunASR AutoModel accepts "cuda:0" string or torch.device
144
+ model = AutoModel(
145
+ model=model_path,
146
+ device=str(device),
147
+ disable_update=True,
148
+ disable_pbar=True,
149
+ verbose=False,
150
+ )
151
+ finally:
152
+ logging.disable(previous_level)
153
+
154
+ return model
155
+
156
+
157
+ def post_process(text: str, lang: str) -> str:
158
+ """
159
+ Cleans and normalizes text for WER calculation.
160
+ Args:
161
+ text (str): The input text to be processed.
162
+ lang (str): The language of the input text.
163
+
164
+ Returns:
165
+ str: The cleaned and normalized text.
166
+ """
167
+ punctuation_all = punctuation + string.punctuation
168
+ for x in punctuation_all:
169
+ if x == "'":
170
+ continue
171
+ text = text.replace(x, "")
172
+
173
+ text = text.replace(" ", " ")
174
+
175
+ if lang == "zh":
176
+ text = " ".join([x for x in text])
177
+ elif lang == "en":
178
+ text = text.lower()
179
+ else:
180
+ raise NotImplementedError
181
+ return text
182
+
183
+
184
+ def process_init(rank_queue, model_dir, lang):
185
+ """
186
+ Initializer for each worker process.
187
+ Loads model onto a specific GPU, once per process.
188
+ """
189
+ global worker_pipe, worker_device
190
+
191
+ torch.set_num_threads(2)
192
+
193
+ try:
194
+ rank = rank_queue.get(timeout=10)
195
+ except Exception:
196
+ raise RuntimeError("Failed to get GPU rank from queue.")
197
+
198
+ assert torch.cuda.is_available(), "CUDA is required but not available."
199
+ worker_device = torch.device(f"cuda:{rank}")
200
+ torch.cuda.set_device(rank)
201
+
202
+ logging.info(f"Initializing worker on device: {worker_device}")
203
+
204
+ try:
205
+ if lang == "en":
206
+ worker_pipe = load_whisper_model(model_dir, worker_device)
207
+ elif lang == "zh":
208
+ worker_pipe = load_paraformer_model(model_dir, worker_device)
209
+ if worker_pipe is None:
210
+ raise RuntimeError("Model loading failed.")
211
+ except Exception as e:
212
+ logging.critical(f"Failed to load model on {worker_device}: {e}")
213
+ raise e
214
+
215
+
216
+ def run_eval_worker(data_chunk, lang, batch_size):
217
+ """
218
+ Worker function to process a chunk of data.
219
+ Uses the global worker_pipe initialized by process_init.
220
+ """
221
+ global worker_pipe
222
+ if worker_pipe is None:
223
+ logging.error("Worker pipeline is not initialized!")
224
+ return []
225
+
226
+ metrics_buffer = []
227
+ try:
228
+ if lang == "en":
229
+ # Load waveforms as arrays, truncating to 30s
230
+ dataset = [
231
+ {
232
+ "array": load_eval_waveform(
233
+ item["wav_path"], sample_rate=16000, return_numpy=True
234
+ )[: 16000 * 30],
235
+ "sampling_rate": 16000,
236
+ }
237
+ for item in data_chunk
238
+ ]
239
+ generate_kwargs = {"language": "english", "task": "transcribe"}
240
+
241
+ iterator = worker_pipe(
242
+ dataset, generate_kwargs=generate_kwargs, batch_size=batch_size
243
+ )
244
+
245
+ for i, out in enumerate(iterator):
246
+ hypothesis = out["text"].strip()
247
+ ref_item = data_chunk[i]
248
+ truth = ref_item["truth_text"]
249
+ wav_path = ref_item["wav_path"]
250
+
251
+ m = process_one(hypothesis, truth, post_process, lang)
252
+ m["wav_path"] = wav_path
253
+ metrics_buffer.append(m)
254
+
255
+ elif lang == "zh":
256
+ wav_paths = [item["wav_path"] for item in data_chunk]
257
+
258
+ for i in range(0, len(wav_paths), batch_size):
259
+ batch_paths = wav_paths[i : i + batch_size]
260
+ res_batch = worker_pipe.generate(
261
+ input=batch_paths, batch_size=batch_size, disable_pbar=True
262
+ )
263
+
264
+ for j, res in enumerate(res_batch):
265
+ hypothesis = zhconv.convert(res["text"], "zh-cn")
266
+ ref_item = data_chunk[i + j]
267
+ truth = ref_item["truth_text"]
268
+ wav_path = ref_item["wav_path"]
269
+
270
+ m = process_one(hypothesis, truth, post_process, lang)
271
+ m["wav_path"] = wav_path
272
+ metrics_buffer.append(m)
273
+
274
+ except Exception:
275
+ logging.error(
276
+ f"Worker failed on chunk (Lang: {lang}):\n{traceback.format_exc()}"
277
+ )
278
+ return []
279
+
280
+ return metrics_buffer
281
+
282
+
283
+ def main():
284
+ parser = get_parser()
285
+ args = parser.parse_args()
286
+
287
+ logging.basicConfig(
288
+ format="%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s",
289
+ level=logging.INFO,
290
+ force=True,
291
+ )
292
+
293
+ logging.info(f"Calculating WER for {args.wav_path}")
294
+
295
+ # 1. Prepare Data
296
+ logging.info("Reading test list...")
297
+ data_list = []
298
+ samples = read_test_list(args.test_list)
299
+ for s in samples:
300
+ wav_path = str(Path(args.wav_path) / f"{s['id']}.{args.extension}")
301
+ if not os.path.exists(wav_path):
302
+ logging.warning(f"File missing: {wav_path}")
303
+ continue
304
+ data_list.append({"wav_path": wav_path, "truth_text": s["text"]})
305
+ total_files = len(data_list)
306
+ logging.info(f"Total files: {total_files}.")
307
+
308
+ # 2. Worker config
309
+ num_gpus = torch.cuda.device_count()
310
+ assert num_gpus > 0, "No GPU found. GPU is required."
311
+ total_workers = num_gpus * args.nj_per_gpu
312
+
313
+ mp.set_start_method("spawn", force=True)
314
+ manager = mp.Manager()
315
+ rank_queue = manager.Queue()
316
+
317
+ for _ in range(args.nj_per_gpu):
318
+ for rank in range(num_gpus):
319
+ rank_queue.put(rank)
320
+
321
+ # 3. Scheduling: Split data into chunks for better load balancing
322
+ chunk_size = max(1, args.batch_size)
323
+ tasks = []
324
+ for i in range(0, total_files, chunk_size):
325
+ tasks.append(data_list[i : i + chunk_size])
326
+
327
+ logging.info(
328
+ f"Split data into {len(tasks)} chunks (size ~{chunk_size}). "
329
+ f"Spawning {total_workers} workers."
330
+ )
331
+
332
+ # 4. Execution
333
+ results = []
334
+
335
+ with ProcessPoolExecutor(
336
+ max_workers=total_workers,
337
+ initializer=process_init,
338
+ initargs=(rank_queue, args.model_dir, args.lang),
339
+ ) as executor:
340
+ futures = []
341
+ for chunk in tasks:
342
+ futures.append(
343
+ executor.submit(run_eval_worker, chunk, args.lang, args.batch_size)
344
+ )
345
+
346
+ # Unified progress bar
347
+ with tqdm(total=total_files, desc="Eval Progress", dynamic_ncols=True) as pbar:
348
+ for future in as_completed(futures):
349
+ try:
350
+ chunk_metrics = future.result()
351
+ results.extend(chunk_metrics)
352
+ pbar.update(len(chunk_metrics))
353
+ except Exception as e:
354
+ logging.error(f"Task failed: {e}")
355
+
356
+ wers, inses, deles, subses = [], [], [], []
357
+ word_nums = 0
358
+
359
+ fout = None
360
+ if args.decode_path:
361
+ os.makedirs(os.path.dirname(args.decode_path), exist_ok=True)
362
+ fout = open(args.decode_path, "w", encoding="utf8")
363
+ logging.info(f"Saving detailed WER results to: {args.decode_path}")
364
+ fout.write(
365
+ "Name\tWER\tTruth\tHypothesis\tInsertions\tDeletions\tSubstitutions\n"
366
+ )
367
+
368
+ for res in results:
369
+ wers.append(float(res["wer"]))
370
+ inses.append(float(res["insertions"]))
371
+ deles.append(float(res["deletions"]))
372
+ subses.append(float(res["substitutions"]))
373
+ word_nums += res["word_num"]
374
+
375
+ if fout:
376
+ fout.write(
377
+ f"{res['wav_path']}\t{res['wer']}\t{res['truth']}\t"
378
+ f"{res['hypo']}\t{res['insertions']}\t{res['deletions']}\t"
379
+ f"{res['substitutions']}\n"
380
+ )
381
+
382
+ wer_avg = round(np.mean(wers) * 100, 2) if wers else float("nan")
383
+ wer_weighted = (
384
+ round((np.sum(subses) + np.sum(deles) + np.sum(inses)) / word_nums * 100, 2)
385
+ if word_nums > 0
386
+ else float("nan")
387
+ )
388
+
389
+ inse_sum = np.sum(inses)
390
+ dele_sum = np.sum(deles)
391
+ subs_sum = np.sum(subses)
392
+
393
+ print("-" * 50)
394
+ logging.info(f"Processed {len(results)}/{total_files} files.")
395
+ seedtts_wer_info = f"Seed-TTS WER (Avg of WERs): {wer_avg}%"
396
+ wer_info = f"WER (Weighted): {wer_weighted}%"
397
+ detailed_info = (
398
+ f"Errors: {inse_sum} ins, {dele_sum} del, {subs_sum} sub / {word_nums} words"
399
+ )
400
+ logging.info(seedtts_wer_info)
401
+ logging.info(wer_info)
402
+ logging.info(detailed_info)
403
+ print("-" * 50)
404
+
405
+ if fout:
406
+ fout.write(seedtts_wer_info + "\n" + wer_info + "\n" + detailed_info + "\n")
407
+ fout.close()
408
+
409
+
410
+ if __name__ == "__main__":
411
+ main()
omnivoice/eval/wer/sensevoice.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ Computes Character Error Rate (CER) for Cantonese (yue) using SenseVoiceSmall.
20
+ """
21
+
22
+ import argparse
23
+ import logging
24
+ import multiprocessing as mp
25
+ import os
26
+ import re
27
+ import traceback
28
+ from concurrent.futures import ProcessPoolExecutor, as_completed
29
+ from pathlib import Path
30
+
31
+ import cn2an
32
+ import torch
33
+ import zhconv
34
+ from tqdm import tqdm
35
+
36
+ from omnivoice.eval.wer.common import log_metrics, process_one
37
+ from omnivoice.eval.wer.text_norm_omni import text_normalize
38
+ from omnivoice.utils.data_utils import read_test_list
39
+
40
+ # --- Global variables for worker processes ---
41
+ worker_sensevoice = None
42
+ worker_device = None
43
+
44
+
45
+ def get_parser():
46
+ parser = argparse.ArgumentParser(
47
+ description="Computes CER for Cantonese using SenseVoiceSmall.",
48
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
49
+ )
50
+
51
+ parser.add_argument(
52
+ "--wav-path",
53
+ type=str,
54
+ required=True,
55
+ help="Path to the directory containing speech files.",
56
+ )
57
+
58
+ parser.add_argument(
59
+ "--extension",
60
+ type=str,
61
+ default="wav",
62
+ help="Extension of the speech files. Default: wav",
63
+ )
64
+
65
+ parser.add_argument(
66
+ "--decode-path",
67
+ type=str,
68
+ default=None,
69
+ help="Path to the output file where CER information will be saved. ",
70
+ )
71
+ parser.add_argument(
72
+ "--model-dir",
73
+ type=str,
74
+ required=True,
75
+ help="Local path of evaluation models repository. ",
76
+ )
77
+ parser.add_argument(
78
+ "--test-list",
79
+ type=str,
80
+ default="test.jsonl",
81
+ help="path of the JSONL test list.",
82
+ )
83
+ parser.add_argument(
84
+ "--batch-size",
85
+ type=int,
86
+ default=16,
87
+ help="Batch size for decoding.",
88
+ )
89
+ parser.add_argument(
90
+ "--nj-per-gpu", type=int, default=1, help="Number of workers per GPU."
91
+ )
92
+ parser.add_argument(
93
+ "--chunk-size",
94
+ type=int,
95
+ default=10,
96
+ help="Number of samples per task chunk sent to workers.",
97
+ )
98
+ return parser
99
+
100
+
101
+ def load_sensevoice_model(model_dir, device):
102
+ model_path = os.path.join(model_dir, "wer/SenseVoiceSmall")
103
+ if not os.path.exists(model_path):
104
+ # Fallback if specific sensevoice spelling isn't found
105
+ logging.warning(
106
+ f"SenseVoiceSmall not found at {model_path}. "
107
+ f"Please ensure it is present in eval models."
108
+ )
109
+
110
+ logging.info(f"Loading SenseVoice model on {device}...")
111
+
112
+ previous_level = logging.root.manager.disable
113
+ logging.disable(logging.CRITICAL)
114
+
115
+ try:
116
+ from funasr import AutoModel
117
+
118
+ model = AutoModel(
119
+ model="iic/SenseVoiceSmall",
120
+ device=str(device),
121
+ disable_update=True,
122
+ disable_pbar=True,
123
+ verbose=False,
124
+ )
125
+ finally:
126
+ logging.disable(previous_level)
127
+
128
+ return model
129
+
130
+
131
+ def _worker_setup(rank_queue):
132
+ global worker_device
133
+
134
+ torch.set_num_threads(2)
135
+
136
+ try:
137
+ rank = rank_queue.get(timeout=10)
138
+ except Exception:
139
+ raise RuntimeError("Failed to get GPU rank from queue.")
140
+
141
+ assert torch.cuda.is_available(), "CUDA is required but not available."
142
+ worker_device = torch.device(f"cuda:{rank}")
143
+ torch.cuda.set_device(rank)
144
+
145
+ logging.info(f"Initializing worker on device: {worker_device}")
146
+
147
+
148
+ def process_init_sensevoice(rank_queue, model_dir):
149
+ global worker_sensevoice
150
+
151
+ _worker_setup(rank_queue)
152
+
153
+ try:
154
+ worker_sensevoice = load_sensevoice_model(model_dir, worker_device)
155
+ if worker_sensevoice is None:
156
+ raise RuntimeError("SenseVoice model loading failed.")
157
+ except Exception as e:
158
+ logging.critical(f"Failed to load SenseVoice model on {worker_device}: {e}")
159
+ raise e
160
+
161
+
162
+ def post_process(text: str, lang: str) -> str:
163
+ """
164
+ Cleans and normalizes text for calculation.
165
+ """
166
+ assert lang == "yue", "this script is designed for Cantonese (yue) evaluation only."
167
+ text = text_normalize(
168
+ text,
169
+ iso_code="yue",
170
+ lower_case=True,
171
+ remove_numbers=False,
172
+ remove_brackets=False,
173
+ )
174
+
175
+ text = zhconv.convert(text, "zh-cn")
176
+
177
+ text = cn2an.transform(text, "an2cn")
178
+
179
+ text = text.replace(" ", "")
180
+ text = " ".join([x for x in text])
181
+ text = text.lower()
182
+ return text.strip()
183
+
184
+
185
+ def run_eval_worker_sensevoice(data_chunk, batch_size):
186
+ global worker_sensevoice
187
+ if worker_sensevoice is None:
188
+ logging.error("SenseVoice worker pipeline is not initialized!")
189
+ return []
190
+
191
+ metrics_buffer = []
192
+ try:
193
+ wav_paths = [item["wav_path"] for item in data_chunk]
194
+
195
+ for i in range(0, len(wav_paths), batch_size):
196
+ batch_paths = wav_paths[i : i + batch_size]
197
+
198
+ # SenseVoice generate call, target lang mapped to yue
199
+ res_batch = worker_sensevoice.generate(
200
+ input=batch_paths,
201
+ batch_size=batch_size,
202
+ language="yue",
203
+ use_itn=False,
204
+ disable_pbar=True,
205
+ )
206
+
207
+ for j, res in enumerate(res_batch):
208
+ hypothesis = res["text"]
209
+ # SenseVoice may format output with language tags,
210
+ # cleaning basic tags if any
211
+ hypothesis = re.sub(r"<\|[^|]*\|>", "", hypothesis).strip()
212
+
213
+ ref_item = data_chunk[i + j]
214
+ truth = ref_item["truth_text"]
215
+ wav_path = ref_item["wav_path"]
216
+ lang_name = ref_item.get("lang_name")
217
+
218
+ m = process_one(hypothesis, truth, post_process, "yue")
219
+ m["wav_path"] = wav_path
220
+ m["lang_name"] = lang_name
221
+ metrics_buffer.append(m)
222
+
223
+ except Exception:
224
+ logging.error(f"SenseVoice worker failed on chunk:\n{traceback.format_exc()}")
225
+ return []
226
+
227
+ return metrics_buffer
228
+
229
+
230
+ def main():
231
+ parser = get_parser()
232
+ args = parser.parse_args()
233
+
234
+ logging.basicConfig(
235
+ format="%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s",
236
+ level=logging.INFO,
237
+ force=True,
238
+ )
239
+
240
+ logging.info("Reading test list and filtering for Cantonese (yue)...")
241
+ yue_items = []
242
+ wav_root = Path(args.wav_path)
243
+
244
+ samples = read_test_list(args.test_list)
245
+ for s in samples:
246
+ lang_id = s.get("language_id", "")
247
+ if lang_id != "yue":
248
+ continue
249
+
250
+ wav_path = str(wav_root / f"{s['id']}.{args.extension}")
251
+ if not os.path.exists(wav_path):
252
+ logging.warning(f"File missing: {wav_path}")
253
+ continue
254
+
255
+ yue_items.append(
256
+ {
257
+ "wav_path": wav_path,
258
+ "truth_text": s["text"],
259
+ "lang_id": "yue",
260
+ "lang_name": s.get("language_name", "Cantonese"),
261
+ }
262
+ )
263
+
264
+ logging.info(f"Total Cantonese files found: {len(yue_items)}.")
265
+ if len(yue_items) == 0:
266
+ logging.warning("No files to evaluate. Exiting.")
267
+ return
268
+
269
+ num_gpus = torch.cuda.device_count()
270
+ assert num_gpus > 0, "No GPU found. GPU is required."
271
+ total_workers = num_gpus * args.nj_per_gpu
272
+
273
+ mp.set_start_method("spawn", force=True)
274
+ manager = mp.Manager()
275
+
276
+ chunk_size = args.chunk_size
277
+ tasks = []
278
+ for i in range(0, len(yue_items), chunk_size):
279
+ tasks.append(yue_items[i : i + chunk_size])
280
+
281
+ results = []
282
+ rank_queue = manager.Queue()
283
+ for _ in range(args.nj_per_gpu):
284
+ for rank in range(num_gpus):
285
+ rank_queue.put(rank)
286
+
287
+ with ProcessPoolExecutor(
288
+ max_workers=total_workers,
289
+ initializer=process_init_sensevoice,
290
+ initargs=(rank_queue, args.model_dir),
291
+ ) as executor:
292
+ futures = []
293
+ for chunk in tasks:
294
+ futures.append(
295
+ executor.submit(run_eval_worker_sensevoice, chunk, args.batch_size)
296
+ )
297
+
298
+ with tqdm(
299
+ total=len(yue_items),
300
+ desc="SenseVoice Eval (Cantonese)",
301
+ dynamic_ncols=True,
302
+ ) as pbar:
303
+ for future in as_completed(futures):
304
+ try:
305
+ chunk_metrics = future.result()
306
+ results.extend(chunk_metrics)
307
+ pbar.update(len(chunk_metrics))
308
+ except Exception as e:
309
+ logging.error(f"Task failed: {e}")
310
+
311
+ # Metrics Aggregation
312
+ inses, deles, subses = [], [], []
313
+ word_nums = 0
314
+
315
+ fout = None
316
+ if args.decode_path:
317
+ os.makedirs(os.path.dirname(args.decode_path), exist_ok=True)
318
+ logging.info(f"Saving detailed CER results to: {args.decode_path}")
319
+ fout = open(args.decode_path, "w", encoding="utf-8")
320
+
321
+ for res in results:
322
+ inses.append(float(res["insertions"]))
323
+ deles.append(float(res["deletions"]))
324
+ subses.append(float(res["substitutions"]))
325
+ word_nums += res["word_num"]
326
+
327
+ if fout:
328
+ fout.write(
329
+ f"{res['wav_path']}\t{res['wer']}\t{res['truth']}\t"
330
+ f"{res['hypo']}\t{res['insertions']}\t{res['deletions']}\t"
331
+ f"{res['substitutions']}\n"
332
+ )
333
+
334
+ print("-" * 50)
335
+ if word_nums > 0:
336
+ log_metrics(fout, "[yue] Cantonese", inses, deles, subses, word_nums)
337
+
338
+ if fout:
339
+ fout.close()
340
+
341
+
342
+ if __name__ == "__main__":
343
+ main()
omnivoice/eval/wer/text_norm_omni.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ # All rights reserved.
4
+ #
5
+ # This source code is licensed under the BSD-style license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ """
9
+ This module contains the text normalization function for WER evaluation.
10
+ Copied from https://github.com/facebookresearch/omnilingual-asr/blob/81f51e224ce9e74b02cc2a3eaf21b2d91d743455/workflows/dataprep/text_tools.py
11
+ """
12
+
13
+ import re
14
+ import unicodedata
15
+
16
+ from unidecode import unidecode
17
+
18
+ import omnivoice.eval.wer.norm_config_module as norm_config_module
19
+
20
+ norm_config = norm_config_module.norm_config # type: ignore
21
+
22
+
23
+ def text_normalize(
24
+ text, iso_code, lower_case=True, remove_numbers=True, remove_brackets=False
25
+ ):
26
+ """Given a text, normalize it by changing to lower case, removing punctuations, removing words that only contain digits and removing extra spaces
27
+
28
+ Args:
29
+ text : The string to be normalized
30
+ iso_code :
31
+ remove_numbers : Boolean flag to specify if words containing only digits should be removed
32
+
33
+ Returns:
34
+ normalized_text : the string after all normalization
35
+
36
+ """
37
+
38
+ config = norm_config.get(iso_code, norm_config["*"])
39
+
40
+ for field in [
41
+ "lower_case",
42
+ "punc_set",
43
+ "del_set",
44
+ "mapping",
45
+ "digit_set",
46
+ "unicode_norm",
47
+ ]:
48
+ if field not in config:
49
+ config[field] = norm_config["*"][field]
50
+
51
+ text = unicodedata.normalize(config["unicode_norm"], text)
52
+
53
+ # Convert to lower case
54
+
55
+ if config["lower_case"] and lower_case:
56
+ text = text.lower()
57
+
58
+ # brackets
59
+
60
+ # always text inside brackets with numbers in them. Usually corresponds to "(Sam 23:17)"
61
+ text = re.sub(r"\([^\)]*\d[^\)]*\)", " ", text)
62
+ if remove_brackets:
63
+ text = re.sub(r"\([^\)]*\)", " ", text)
64
+
65
+ # Apply mappings
66
+
67
+ for old, new in config["mapping"].items():
68
+ text = re.sub(old, new, text)
69
+
70
+ # Replace punctutations with space
71
+
72
+ punct_pattern = r"[" + config["punc_set"]
73
+
74
+ punct_pattern += "]"
75
+
76
+ normalized_text = re.sub(punct_pattern, " ", text)
77
+
78
+ # remove characters in delete list
79
+
80
+ delete_patten = r"[" + config["del_set"] + "]"
81
+
82
+ normalized_text = re.sub(delete_patten, "", normalized_text)
83
+
84
+ # Remove words containing only digits
85
+ # We check for 3 cases a)text starts with a number b) a number is present somewhere in the middle of the text c) the text ends with a number
86
+ # For each case we use lookaround regex pattern to see if the digit pattern in preceded and followed by whitespaces, only then we replace the numbers with space
87
+ # The lookaround enables overlapping pattern matches to be replaced
88
+
89
+ if remove_numbers:
90
+ digits_pattern = "[" + config["digit_set"]
91
+
92
+ digits_pattern += "]+"
93
+
94
+ complete_digit_pattern = (
95
+ r"^"
96
+ + digits_pattern
97
+ + r"(?=\s)|(?<=\s)"
98
+ + digits_pattern
99
+ + r"(?=\s)|(?<=\s)"
100
+ + digits_pattern
101
+ + "$"
102
+ )
103
+
104
+ normalized_text = re.sub(complete_digit_pattern, " ", normalized_text)
105
+
106
+ if config["rm_diacritics"]:
107
+ normalized_text = unidecode(normalized_text)
108
+
109
+ # Remove extra spaces
110
+ normalized_text = re.sub(r"\s+", " ", normalized_text).strip()
111
+
112
+ return normalized_text
omnivoice/models/__init__.py ADDED
File without changes
omnivoice/models/omnivoice.py ADDED
@@ -0,0 +1,1727 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Core OmniVoice model implementation.
19
+
20
+ Defines the ``OmniVoice`` model class, generation config, and inference pipeline.
21
+ This is the main entry point for both inference and training:
22
+
23
+ - **Inference**: ``OmniVoice.from_pretrained()`` loads the model, then
24
+ ``model.generate()`` supports voice cloning, voice design, and auto voice.
25
+ - **Training**: ``model.forward()`` computes the training loss; the model is
26
+ built and used by ``omnivoice.training.builder`` and ``omnivoice.training.trainer``.
27
+
28
+ """
29
+
30
+ import difflib
31
+ import logging
32
+ import math
33
+ import os
34
+ import re
35
+ from dataclasses import dataclass, fields
36
+ from functools import partial
37
+ from typing import Any, List, Optional, Union
38
+
39
+ import numpy as np
40
+ import torch
41
+ import torch.nn as nn
42
+ import torch.nn.functional as F
43
+ import torchaudio
44
+
45
+ try:
46
+ from torch.nn.attention.flex_attention import create_block_mask
47
+
48
+ _flex_attention_available = True
49
+ except ImportError:
50
+ _flex_attention_available = False
51
+ from transformers import (
52
+ AutoFeatureExtractor,
53
+ AutoModel,
54
+ AutoTokenizer,
55
+ HiggsAudioV2TokenizerModel,
56
+ PretrainedConfig,
57
+ PreTrainedModel,
58
+ )
59
+ from transformers.modeling_outputs import ModelOutput
60
+ from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS, AttentionInterface
61
+ from transformers.models.auto import CONFIG_MAPPING, AutoConfig
62
+
63
+ from omnivoice.utils.audio import (
64
+ cross_fade_chunks,
65
+ fade_and_pad_audio,
66
+ load_audio,
67
+ remove_silence,
68
+ trim_long_audio,
69
+ )
70
+ from omnivoice.utils.duration import RuleDurationEstimator
71
+ from omnivoice.utils.lang_map import LANG_IDS, LANG_NAMES
72
+ from omnivoice.utils.text import (
73
+ add_punctuation,
74
+ chunk_text_punctuation,
75
+ normalize_text as _normalize_text,
76
+ )
77
+ from omnivoice.utils.voice_design import (
78
+ _INSTRUCT_ALL_VALID,
79
+ _INSTRUCT_EN_TO_ZH,
80
+ _INSTRUCT_MUTUALLY_EXCLUSIVE,
81
+ _INSTRUCT_VALID_EN,
82
+ _INSTRUCT_VALID_ZH,
83
+ _INSTRUCT_ZH_TO_EN,
84
+ _ZH_RE,
85
+ )
86
+
87
+ logger = logging.getLogger(__name__)
88
+
89
+ _AUTOCAST_FLEX_ATTENTION = "omnivoice_flex_attention"
90
+
91
+
92
+ def _autocast_flex_attention(module, query, key, value, *args, **kwargs):
93
+ """flex_attention with the same autocast treatment SDPA already gets.
94
+
95
+ Mixed-precision training keeps fp32 master weights, so Qwen3's
96
+ ``q_norm``/``k_norm`` (fp32 weight x bf16 activation) silently promote
97
+ q/k — and, through the fp32 RoPE constants, v — back to fp32. SDPA is
98
+ on autocast's cast list and is downcast at the kernel boundary;
99
+ ``flex_attention`` is not, so with ``attn_implementation:
100
+ "flex_attention"`` all attention math runs in fp32: the fp32 backward
101
+ template is ~12x slower at head_dim=128 (61.8ms vs 5.1ms per
102
+ layer-call, H100, identical mask/shape/layout), and the flex and sdpa
103
+ paths become numerically inconsistent with each other. Casting here
104
+ restores the treatment autocast applies to every other matmul; softmax
105
+ accumulation inside the kernel is fp32 either way.
106
+ """
107
+ if torch.is_autocast_enabled(query.device.type) and query.dtype == torch.float32:
108
+ dtype = torch.get_autocast_dtype(query.device.type)
109
+ query, key, value = (t.to(dtype) for t in (query, key, value))
110
+ return ALL_ATTENTION_FUNCTIONS["flex_attention"](
111
+ module, query, key, value, *args, **kwargs
112
+ )
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Dataclasses
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ _VOICE_CLONE_PROMPT_FORMAT_VERSION = 1
121
+
122
+
123
+ @dataclass
124
+ class VoiceClonePrompt:
125
+ ref_audio_tokens: torch.Tensor # (C, T)
126
+ ref_text: str
127
+ ref_rms: float
128
+
129
+ def save(self, path: str) -> None:
130
+ """Save this prompt to ``path`` for reuse in a later session.
131
+
132
+ The file stores a plain dict with the audio tokens moved to CPU, so
133
+ it can be loaded with ``torch.load(weights_only=True)`` (the default
134
+ since torch 2.6) and is portable across devices.
135
+
136
+ Args:
137
+ path: Destination file path (e.g. ``"my_voice.pt"``).
138
+ """
139
+ torch.save(
140
+ {
141
+ "format_version": _VOICE_CLONE_PROMPT_FORMAT_VERSION,
142
+ "ref_audio_tokens": self.ref_audio_tokens.detach().cpu(),
143
+ "ref_text": self.ref_text,
144
+ "ref_rms": float(self.ref_rms),
145
+ },
146
+ path,
147
+ )
148
+
149
+ @classmethod
150
+ def load(cls, path: str, map_location: str = "cpu") -> "VoiceClonePrompt":
151
+ """Load a prompt saved with :meth:`save`.
152
+
153
+ The returned prompt can be passed directly to
154
+ :meth:`OmniVoice.generate`; the audio tokens are moved to the model
155
+ device automatically during generation, so no manual ``.to(device)``
156
+ is needed.
157
+
158
+ Args:
159
+ path: File path previously written by :meth:`save`.
160
+ map_location: Device to load the audio tokens onto.
161
+ Returns:
162
+ The restored :class:`VoiceClonePrompt`.
163
+ """
164
+ data = torch.load(path, map_location=map_location, weights_only=True)
165
+ version = data.get("format_version")
166
+ if version != _VOICE_CLONE_PROMPT_FORMAT_VERSION:
167
+ raise ValueError(f"Unsupported VoiceClonePrompt format version: {version}")
168
+ return cls(
169
+ ref_audio_tokens=data["ref_audio_tokens"],
170
+ ref_text=data["ref_text"],
171
+ ref_rms=data["ref_rms"],
172
+ )
173
+
174
+
175
+ @dataclass
176
+ class OmniVoiceGenerationConfig:
177
+ num_step: int = 32
178
+ guidance_scale: float = 2.0
179
+ t_shift: float = 0.1
180
+ layer_penalty_factor: float = 5.0
181
+ position_temperature: float = 5.0
182
+ class_temperature: float = 0.0
183
+ denoise: bool = True
184
+ preprocess_prompt: bool = True
185
+ postprocess_output: bool = True
186
+ audio_chunk_duration: float = 15.0
187
+ audio_chunk_threshold: float = 30.0
188
+ pad_duration: float = 0.1
189
+ fade_duration: float = 0.1
190
+
191
+ @classmethod
192
+ def from_dict(cls, kwargs_dict):
193
+ valid_keys = {f.name for f in fields(cls)}
194
+ filtered = {k: v for k, v in kwargs_dict.items() if k in valid_keys}
195
+ return cls(**filtered)
196
+
197
+
198
+ @dataclass
199
+ class GenerationTask:
200
+ batch_size: int
201
+ texts: List[str]
202
+ target_lens: List[int]
203
+ langs: List[Optional[str]]
204
+ instructs: List[Optional[str]]
205
+ ref_texts: List[Optional[str]]
206
+ ref_audio_tokens: List[Optional[torch.Tensor]]
207
+ ref_rms: List[Optional[float]]
208
+ speed: Optional[List[float]] = None
209
+
210
+ def get_indices(self, config: OmniVoiceGenerationConfig, frame_rate: int):
211
+ threshold = int(config.audio_chunk_threshold * frame_rate)
212
+ short_idx = [i for i, l in enumerate(self.target_lens) if l <= threshold]
213
+ long_idx = [i for i, l in enumerate(self.target_lens) if l > threshold]
214
+ return short_idx, long_idx
215
+
216
+ def slice_task(self, indices: List[int]):
217
+ if not indices:
218
+ return None
219
+ return GenerationTask(
220
+ batch_size=len(indices),
221
+ texts=[self.texts[i] for i in indices],
222
+ target_lens=[self.target_lens[i] for i in indices],
223
+ langs=[self.langs[i] for i in indices],
224
+ instructs=[self.instructs[i] for i in indices],
225
+ ref_texts=[self.ref_texts[i] for i in indices],
226
+ ref_audio_tokens=[self.ref_audio_tokens[i] for i in indices],
227
+ ref_rms=[self.ref_rms[i] for i in indices],
228
+ speed=[self.speed[i] for i in indices] if self.speed else None,
229
+ )
230
+
231
+
232
+ @dataclass
233
+ class OmniVoiceModelOutput(ModelOutput):
234
+ loss: Optional[torch.Tensor] = None
235
+ logits: Optional[torch.Tensor] = None
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Config & Model
240
+ # ---------------------------------------------------------------------------
241
+
242
+
243
+ class OmniVoiceConfig(PretrainedConfig):
244
+ model_type = "omnivoice"
245
+ sub_configs = {"llm_config": AutoConfig}
246
+
247
+ def __init__(
248
+ self,
249
+ audio_vocab_size: int = 1025,
250
+ audio_mask_id: int = 1024,
251
+ num_audio_codebook: int = 8,
252
+ audio_codebook_weights: Optional[list[float]] = None,
253
+ llm_config: Optional[Union[dict, PretrainedConfig]] = None,
254
+ **kwargs,
255
+ ):
256
+ if isinstance(llm_config, dict):
257
+ llm_config = CONFIG_MAPPING[llm_config["model_type"]](**llm_config)
258
+
259
+ self.llm_config = llm_config
260
+
261
+ super().__init__(**kwargs)
262
+ self.audio_vocab_size = audio_vocab_size
263
+ self.audio_mask_id = audio_mask_id
264
+ self.num_audio_codebook = num_audio_codebook
265
+ if audio_codebook_weights is None:
266
+ audio_codebook_weights = [8, 8, 6, 6, 4, 4, 2, 2]
267
+ self.audio_codebook_weights = audio_codebook_weights
268
+
269
+
270
+ def _resolve_model_path(name_or_path: str) -> str:
271
+ if os.path.isdir(name_or_path):
272
+ return name_or_path
273
+ from huggingface_hub import snapshot_download
274
+
275
+ return snapshot_download(name_or_path)
276
+
277
+
278
+ class OmniVoice(PreTrainedModel):
279
+ _supports_flex_attn = True
280
+ _supports_flash_attn_2 = True
281
+ _supports_sdpa = True
282
+ config_class = OmniVoiceConfig
283
+
284
+ def __init__(self, config: OmniVoiceConfig, llm: Optional[PreTrainedModel] = None):
285
+ super().__init__(config)
286
+
287
+ if llm is not None:
288
+ # If an LLM instance is provided, use it directly
289
+ # (skipping config-based init).
290
+ self.llm = llm
291
+ else:
292
+ # Otherwise, initialize the LLM from the config.
293
+ self.llm = AutoModel.from_config(self.config.llm_config)
294
+
295
+ if self.llm.config._attn_implementation == "flex_attention":
296
+ AttentionInterface.register(
297
+ _AUTOCAST_FLEX_ATTENTION, _autocast_flex_attention
298
+ )
299
+ self.llm.set_attn_implementation(_AUTOCAST_FLEX_ATTENTION)
300
+
301
+ self.audio_embeddings = nn.Embedding(
302
+ config.num_audio_codebook * config.audio_vocab_size,
303
+ self.config.llm_config.hidden_size,
304
+ )
305
+ self.register_buffer(
306
+ "codebook_layer_offsets",
307
+ torch.arange(config.num_audio_codebook) * config.audio_vocab_size,
308
+ )
309
+
310
+ self.audio_heads = nn.Linear(
311
+ self.config.llm_config.hidden_size,
312
+ config.num_audio_codebook * config.audio_vocab_size,
313
+ bias=False,
314
+ )
315
+
316
+ self.normalized_audio_codebook_weights = [
317
+ w / sum(config.audio_codebook_weights)
318
+ for w in config.audio_codebook_weights
319
+ ]
320
+
321
+ self.post_init()
322
+
323
+ # Inference-only attributes (set by from_pretrained when not in train mode)
324
+ self.text_tokenizer = None
325
+ self.audio_tokenizer = None
326
+ self.duration_estimator = None
327
+ self.sampling_rate = None
328
+ self._asr_pipe = None
329
+ self._asr_model_name = "openai/whisper-large-v3-turbo"
330
+ self._asr_device = None
331
+
332
+ @classmethod
333
+ def from_pretrained(cls, pretrained_model_name_or_path, *args, **kwargs):
334
+ train_mode = kwargs.pop("train", False)
335
+ load_asr = kwargs.pop("load_asr", False)
336
+ asr_model_name = kwargs.pop("asr_model_name", None)
337
+ asr_device = kwargs.pop("asr_device", None)
338
+
339
+ # Suppress noisy INFO logs from transformers/huggingface_hub during loading
340
+ _prev_disable = logging.root.manager.disable
341
+ logging.disable(logging.INFO)
342
+
343
+ try:
344
+ # Resolve to local path first; download only if not already cached
345
+ resolved_path = _resolve_model_path(pretrained_model_name_or_path)
346
+
347
+ model = super().from_pretrained(resolved_path, *args, **kwargs)
348
+
349
+ if not train_mode:
350
+ model.text_tokenizer = AutoTokenizer.from_pretrained(resolved_path)
351
+
352
+ audio_tokenizer_path = os.path.join(resolved_path, "audio_tokenizer")
353
+
354
+ if not os.path.isdir(audio_tokenizer_path):
355
+ audio_tokenizer_path = _resolve_model_path(
356
+ "eustlb/higgs-audio-v2-tokenizer"
357
+ )
358
+
359
+ # higgs-audio-v2-tokenizer does not support MPS
360
+ # (output channels > 65536)
361
+ tokenizer_device = (
362
+ "cpu" if str(model.device).startswith("mps") else model.device
363
+ )
364
+ model.audio_tokenizer = HiggsAudioV2TokenizerModel.from_pretrained(
365
+ audio_tokenizer_path, device_map=tokenizer_device
366
+ )
367
+ model.feature_extractor = AutoFeatureExtractor.from_pretrained(
368
+ audio_tokenizer_path
369
+ )
370
+
371
+ model.sampling_rate = model.feature_extractor.sampling_rate
372
+
373
+ model.duration_estimator = RuleDurationEstimator()
374
+
375
+ if asr_model_name is not None:
376
+ model._asr_model_name = asr_model_name
377
+ if asr_device is not None:
378
+ model._asr_device = asr_device
379
+ if load_asr:
380
+ model.load_asr_model()
381
+ finally:
382
+ logging.disable(_prev_disable)
383
+
384
+ return model
385
+
386
+ # -------------------------------------------------------------------
387
+ # ASR support (optional, for auto-transcription)
388
+ # -------------------------------------------------------------------
389
+
390
+ def load_asr_model(
391
+ self, model_name: Optional[str] = None, device: Optional[str] = None
392
+ ):
393
+ """Load a Whisper ASR model for reference audio transcription.
394
+
395
+ Args:
396
+ model_name: HuggingFace model name or local path for the Whisper
397
+ model. Defaults to the ``asr_model_name`` passed to
398
+ :meth:`from_pretrained` (``openai/whisper-large-v3-turbo``
399
+ if unset).
400
+ device: Device to load the ASR model on (e.g. ``"cuda:1"`` or
401
+ ``"cpu"``). Defaults to the ``asr_device`` passed to
402
+ :meth:`from_pretrained`, falling back to the main model's
403
+ device (its first shard when sharded across GPUs).
404
+ """
405
+ from transformers import pipeline as hf_pipeline
406
+
407
+ if model_name is None:
408
+ model_name = self._asr_model_name
409
+ if device is None:
410
+ device = self._asr_device if self._asr_device is not None else self.device
411
+
412
+ logger.info("Loading ASR model %s ...", model_name)
413
+ asr_dtype = (
414
+ torch.float16 if str(device).startswith(("cuda", "xpu")) else torch.float32
415
+ )
416
+
417
+ model_name = _resolve_model_path(model_name)
418
+
419
+ # Use `device=` (single-device placement) rather than `device_map=`:
420
+ # pipeline() ignores a plain device string in `device_map` and
421
+ # auto-selects an accelerator, which is why the ASR model could not
422
+ # be moved off the default GPU (#180).
423
+ self._asr_pipe = hf_pipeline(
424
+ "automatic-speech-recognition",
425
+ model=model_name,
426
+ dtype=asr_dtype,
427
+ device=device,
428
+ )
429
+ logger.info("ASR model loaded on %s.", device)
430
+
431
+ @torch.inference_mode()
432
+ def transcribe(
433
+ self,
434
+ audio: Union[str, tuple],
435
+ ) -> str:
436
+ """Transcribe audio using the loaded Whisper ASR model.
437
+
438
+ Args:
439
+ audio: File path or ``(waveform, sample_rate)`` tuple.
440
+ Waveform can be a numpy array or torch.Tensor of shape
441
+ ``(1, T)`` or ``(T,)``.
442
+
443
+ Returns:
444
+ Transcribed text.
445
+ """
446
+ if self._asr_pipe is None:
447
+ raise RuntimeError(
448
+ "ASR model is not loaded. Call model.load_asr_model() first."
449
+ )
450
+
451
+ if isinstance(audio, str):
452
+ return self._asr_pipe(audio)["text"].strip()
453
+ else:
454
+ waveform, sr = audio
455
+ if isinstance(waveform, torch.Tensor):
456
+ waveform = waveform.cpu().numpy()
457
+ waveform = np.squeeze(waveform) # (1, T) or (T,) → (T,)
458
+ audio_input = {
459
+ "array": waveform,
460
+ "sampling_rate": sr,
461
+ }
462
+ return self._asr_pipe(audio_input)["text"].strip()
463
+
464
+ def get_input_embeddings(self):
465
+ return self.llm.get_input_embeddings()
466
+
467
+ def set_input_embeddings(self, value):
468
+ self.llm.set_input_embeddings(value)
469
+
470
+ def _prepare_embed_inputs(
471
+ self, input_ids: torch.Tensor, audio_mask: torch.Tensor
472
+ ) -> torch.Tensor:
473
+ """
474
+ Prepares embeddings from input_ids of shape (batch_size, layers, seq_length).
475
+ Embedding shape is (batch_size, seq_length, hidden_size).
476
+ """
477
+ text_embeds = self.get_input_embeddings()(input_ids[:, 0, :])
478
+
479
+ # Apply shift to audio IDs based on codebook layer
480
+ # audio_ids: [Batch, 8, Seq]
481
+ # codebook_layer_offsets: [1, 8, 1]
482
+ # Result: Layer 0 ID Layer 1 ID + Layer 2 ID + 2050...
483
+ shifted_ids = (
484
+ input_ids * audio_mask.unsqueeze(1)
485
+ ) + self.codebook_layer_offsets.view(1, -1, 1)
486
+
487
+ # input: [Batch, 8, Seq] -> output: [Batch, Seq, Hidden]
488
+ audio_embeds = self.audio_embeddings(shifted_ids).sum(dim=1)
489
+
490
+ return torch.where(audio_mask.unsqueeze(-1), audio_embeds, text_embeds)
491
+
492
+ def forward(
493
+ self,
494
+ input_ids: torch.LongTensor,
495
+ audio_mask: torch.Tensor,
496
+ labels: Optional[torch.LongTensor] = None,
497
+ attention_mask: Optional[torch.Tensor] = None,
498
+ document_ids: Optional[torch.Tensor] = None,
499
+ position_ids: Optional[torch.LongTensor] = None,
500
+ ):
501
+ inputs_embeds = self._prepare_embed_inputs(input_ids, audio_mask)
502
+
503
+ if attention_mask is None and document_ids is not None:
504
+ if not _flex_attention_available:
505
+ raise RuntimeError(
506
+ "flex_attention is not available in the current environment. "
507
+ "If you do not need flex_attention, set "
508
+ '"attn_implementation": "sdpa" in your training config.'
509
+ )
510
+ attention_mask = create_block_mask(
511
+ _get_packed_mask(
512
+ document_ids[0].to(inputs_embeds.device),
513
+ ),
514
+ B=None,
515
+ H=None,
516
+ Q_LEN=input_ids.size(-1),
517
+ KV_LEN=input_ids.size(-1),
518
+ _compile=True,
519
+ device=inputs_embeds.device,
520
+ )
521
+
522
+ llm_outputs = self.llm(
523
+ inputs_embeds=inputs_embeds,
524
+ attention_mask=attention_mask,
525
+ return_dict=True,
526
+ position_ids=position_ids,
527
+ )
528
+ hidden_states = llm_outputs[0]
529
+
530
+ loss = None
531
+
532
+ # Shape: [B, S, C * Vocab]
533
+ batch_size, seq_len, _ = hidden_states.shape
534
+ logits_flat = self.audio_heads(hidden_states)
535
+ # Shape: [B, S, C, Vocab] -> [B, C, S, Vocab]
536
+ audio_logits = logits_flat.view(
537
+ batch_size,
538
+ seq_len,
539
+ self.config.num_audio_codebook,
540
+ self.config.audio_vocab_size,
541
+ ).permute(0, 2, 1, 3)
542
+
543
+ if labels is not None:
544
+ # audio_logits.permute(0, 3, 1, 2):
545
+ # [Batch, Layer, Seq, Vocab] -> [Batch, Vocab, Layer, Seq]
546
+ # per_token_loss shape: [Batch, Layer, Seq],ignore -100
547
+ per_token_loss = torch.nn.functional.cross_entropy(
548
+ audio_logits.permute(0, 3, 1, 2),
549
+ labels,
550
+ reduction="none",
551
+ ignore_index=-100,
552
+ )
553
+ # valid_mask shape: [Batch, Layer, Seq]
554
+ valid_mask = (labels != -100).float()
555
+
556
+ # layer_means shape: [num_layers]
557
+ layer_means = (per_token_loss * valid_mask).sum(
558
+ dim=(0, 2)
559
+ ) / valid_mask.sum(dim=(0, 2)).clamp(min=1.0)
560
+
561
+ weights = torch.tensor(
562
+ self.normalized_audio_codebook_weights, device=audio_logits.device
563
+ )
564
+ loss = (layer_means * weights).sum()
565
+
566
+ return OmniVoiceModelOutput(
567
+ loss=loss,
568
+ logits=audio_logits,
569
+ )
570
+
571
+ def supported_language_ids(self) -> set[str]:
572
+ """Return a list of supported language IDs."""
573
+ return LANG_IDS
574
+
575
+ def supported_language_names(self) -> set[str]:
576
+ """Return a list of supported language names."""
577
+ return LANG_NAMES
578
+
579
+ # -------------------------------------------------------------------
580
+ # Inference API
581
+ # -------------------------------------------------------------------
582
+
583
+ @torch.inference_mode()
584
+ def generate(
585
+ self,
586
+ text: Union[str, list[str]],
587
+ language: Union[str, list[str], None] = None,
588
+ ref_text: Union[str, list[str], None] = None,
589
+ ref_audio: Union[
590
+ str,
591
+ list[str],
592
+ tuple[torch.Tensor, int],
593
+ list[tuple[torch.Tensor, int]],
594
+ None,
595
+ ] = None,
596
+ voice_clone_prompt: Union[
597
+ VoiceClonePrompt, list[VoiceClonePrompt], None
598
+ ] = None,
599
+ instruct: Union[str, list[str], None] = None,
600
+ duration: Union[float, list[Optional[float]], None] = None,
601
+ speed: Union[float, list[Optional[float]], None] = None,
602
+ generation_config: Optional[OmniVoiceGenerationConfig] = None,
603
+ normalize_text: bool = False,
604
+ **kwargs,
605
+ ) -> list[np.ndarray]:
606
+ """Generate speech audio given text in various modes.
607
+
608
+ Supports three modes:
609
+
610
+ 1. **Voice clone** — clone the voice style from the reference audio.
611
+ Should provide ``voice_clone_prompt`` (from
612
+ :meth:`create_voice_clone_prompt`) or ``ref_text`` + ``ref_audio``.
613
+ 2. **Voice design** — provide ``instruct`` text describing
614
+ the desired voice style; no reference audio needed.
615
+ 3. **Auto** — provide neither; the model picks a voice itself.
616
+
617
+ Args:
618
+ text: Target text (single string or list for batch).
619
+ language: Language name (e.g. ``"English"``) or code
620
+ (e.g. ``"en"``). ``None`` for language-agnostic mode.
621
+ Performance is slightly better if you specify the language.
622
+ ref_text: Optional reference text for voice cloning mode.
623
+ ref_audio: Optional reference audio for voice cloning mode.
624
+ Can be a file path or a (waveform, sample_rate) tuple.
625
+ voice_clone_prompt: Reusable prompt from :meth:`create_voice_clone_prompt`
626
+ or :meth:`VoiceClonePrompt.load`.
627
+ If provided, it overrides ``ref_text`` and ``ref_audio``.
628
+ instruct: Style instruction for voice design mode.
629
+ duration: Fixed output duration in seconds. If a single float,
630
+ applies to all items; if a list, one value per item.
631
+ ``None`` (default) lets the model estimate duration from text.
632
+ Overrides ``speed`` when both are provided.
633
+ speed: Speaking speed factor. ``> 1.0`` for faster, ``< 1.0`` for
634
+ slower. If a list, one value per item. ``None`` (default) uses
635
+ the model's default estimation.
636
+ normalize_text: If ``True``, run text normalization on the target
637
+ text before synthesis (numbers, dates, currency, etc. are
638
+ converted to their spoken form, e.g. ``"2345"`` ->
639
+ ``"twenty three forty five"``). Default ``False`` (paper
640
+ reproducibility is unaffected). Chinese/English require the
641
+ optional ``omnivoice[tn]`` dependency (WeTextProcessing); other
642
+ languages use ``num2words`` for bare integers when installed.
643
+ Inline control syntax (``[laughter]``, ``[B EY1 S]``, pinyin
644
+ tone markers) is preserved. See :func:`omnivoice.utils.text.normalize_text`.
645
+ generation_config: Explicit config object. If provided, takes
646
+ precedence over ``**kwargs``.
647
+ **kwargs: Generation config or its fields:
648
+ denoise: Whether to prepend the ``<|denoise|>`` token.
649
+ num_step: Number of iterative decoding steps.
650
+ guidance_scale: Classifier-free guidance scale.
651
+ t_shift: Time-step shift (smaller → emphasise low-SNR).
652
+ postprocess_output: Post-process output (remove silence, fade-in/out, pad edges).
653
+ layer_penalty_factor: Penalty encouraging earlier codebook
654
+ layers to unmask first.
655
+ position_temperature: Temperature for position selection.
656
+ class_temperature: Temperature for token sampling (0 = greedy).
657
+ audio_chunk_duration: If > 0, split long text into chunks of
658
+ this duration (seconds) and generate chunk by chunk.
659
+ audio_chunk_threshold: Only apply chunking if estimated audio
660
+ duration exceeds this threshold (seconds).
661
+ pad_duration: Silence padding duration per side in seconds
662
+ (0 to disable).
663
+ fade_duration: Fade-in/out curve duration in seconds
664
+ (0 to disable).
665
+ Returns:
666
+ ``audios`` a list of 1-D ``np.ndarray`` with shape ``(T,)`` and
667
+ sampling rate consistent with the model's audio tokenizer
668
+ (usually 24 000 Hz). Can be saved directly with
669
+ ``soundfile.write("out.wav", audios[0], model.sampling_rate)``.
670
+ """
671
+
672
+ if self.audio_tokenizer is None or self.text_tokenizer is None:
673
+ raise RuntimeError(
674
+ "Model is not loaded with audio/text tokenizers. Make sure you "
675
+ "loaded the model with OmniVoice.from_pretrained()."
676
+ )
677
+ gen_config = (
678
+ generation_config
679
+ if generation_config is not None
680
+ else OmniVoiceGenerationConfig.from_dict(kwargs)
681
+ )
682
+
683
+ self.eval()
684
+
685
+ full_task = self._preprocess_all(
686
+ text=text,
687
+ language=language,
688
+ ref_text=ref_text,
689
+ ref_audio=ref_audio,
690
+ voice_clone_prompt=voice_clone_prompt,
691
+ instruct=instruct,
692
+ preprocess_prompt=gen_config.preprocess_prompt,
693
+ speed=speed,
694
+ duration=duration,
695
+ normalize_text=normalize_text,
696
+ )
697
+
698
+ short_idx, long_idx = full_task.get_indices(
699
+ gen_config, self.audio_tokenizer.config.frame_rate
700
+ )
701
+
702
+ results = [None] * full_task.batch_size
703
+
704
+ if short_idx:
705
+ short_task = full_task.slice_task(short_idx)
706
+ short_results = self._generate_iterative(short_task, gen_config)
707
+ for idx, res in zip(short_idx, short_results):
708
+ results[idx] = res
709
+
710
+ if long_idx:
711
+ long_task = full_task.slice_task(long_idx)
712
+ long_results = self._generate_chunked(long_task, gen_config)
713
+ for idx, res in zip(long_idx, long_results):
714
+ results[idx] = res
715
+
716
+ generated_audios = []
717
+ for i in range(full_task.batch_size):
718
+ assert results[i] is not None, f"Result {i} was not generated"
719
+ generated_audios.append(
720
+ self._decode_and_post_process(
721
+ results[i],
722
+ full_task.ref_rms[i],
723
+ gen_config, # type: ignore[arg-type]
724
+ )
725
+ )
726
+
727
+ return generated_audios
728
+
729
+ def create_voice_clone_prompt(
730
+ self,
731
+ ref_audio: Union[str, tuple[torch.Tensor, int]],
732
+ ref_text: Optional[str] = None,
733
+ preprocess_prompt: bool = True,
734
+ ) -> VoiceClonePrompt:
735
+ """Create a reusable voice clone prompt from reference audio.
736
+
737
+ Args:
738
+ ref_audio: File path (str) or ``(waveform, sample_rate)`` tuple.
739
+ waveform should be a 1-D or 2-D torch.Tensor (channels x samples).
740
+ ref_text: Transcript of the reference audio. If ``None``, the
741
+ ASR model will be used to auto-transcribe (must call
742
+ :meth:`load_asr_model` first).
743
+ preprocess_prompt: If ``True`` (default), apply silence removal and
744
+ trimming to the reference audio, add punctuation in the end
745
+ of reference text (if not already)
746
+
747
+ Returns:
748
+ A :class:`VoiceClonePrompt` that can be passed to :meth:`generate`.
749
+ """
750
+ if self.audio_tokenizer is None:
751
+ raise RuntimeError(
752
+ "Audio tokenizer is not loaded. Make sure you loaded the model "
753
+ "with OmniVoice.from_pretrained()."
754
+ )
755
+
756
+ if isinstance(ref_audio, str):
757
+ ref_wav = load_audio(ref_audio, self.sampling_rate)
758
+ else:
759
+ waveform, sr = ref_audio
760
+ if isinstance(waveform, torch.Tensor):
761
+ waveform = waveform.cpu().numpy()
762
+ if waveform.ndim == 1:
763
+ waveform = waveform[np.newaxis, :]
764
+ if waveform.shape[0] > 1:
765
+ waveform = np.mean(waveform, axis=0, keepdims=True)
766
+ if sr != self.sampling_rate:
767
+ waveform = torchaudio.functional.resample(
768
+ torch.from_numpy(waveform),
769
+ orig_freq=sr,
770
+ new_freq=self.sampling_rate,
771
+ ).numpy()
772
+ ref_wav = waveform
773
+
774
+ ref_rms = float(np.sqrt(np.mean(ref_wav**2)))
775
+ if 0 < ref_rms < 0.1:
776
+ ref_wav = ref_wav * 0.1 / ref_rms
777
+
778
+ if preprocess_prompt:
779
+ # Trim long reference audio (>20s) by splitting at the largest silence gap.
780
+ # Skip trimming when ref_text is user-provided, otherwise the
781
+ # trimmed audio will no longer match the full transcript.
782
+ if ref_text is None:
783
+ ref_wav = trim_long_audio(
784
+ ref_wav, self.sampling_rate, trim_threshold=20.0
785
+ )
786
+ ref_wav = remove_silence(
787
+ ref_wav,
788
+ self.sampling_rate,
789
+ mid_sil=200,
790
+ lead_sil=100,
791
+ trail_sil=200,
792
+ )
793
+ if ref_wav.shape[-1] == 0:
794
+ raise ValueError(
795
+ "Reference audio is empty after silence removal. "
796
+ "Try setting preprocess_prompt=False."
797
+ )
798
+
799
+ ref_duration = ref_wav.shape[-1] / self.sampling_rate
800
+ if ref_duration > 20.0:
801
+ logger.warning(
802
+ "Reference audio is %.1fs long (>20s). This may cause slower "
803
+ "generation, higher memory usage, and degraded voice cloning "
804
+ "quality. We recommend trimming it to 3-10s.",
805
+ ref_duration,
806
+ )
807
+
808
+ # Auto-transcribe if ref_text not provided
809
+ if ref_text is None:
810
+ if self._asr_pipe is None:
811
+ logger.info("ASR model not loaded yet, loading on-the-fly ...")
812
+ self.load_asr_model()
813
+ ref_text = self.transcribe((ref_wav, self.sampling_rate))
814
+ logger.debug("Auto-transcribed ref_text: %s", ref_text)
815
+
816
+ chunk_size = self.audio_tokenizer.config.hop_length
817
+ clip_size = int(ref_wav.shape[-1] % chunk_size)
818
+ ref_wav = ref_wav[:, :-clip_size] if clip_size > 0 else ref_wav
819
+ # numpy → torch at tokenizer boundary
820
+ ref_wav_tensor = torch.from_numpy(ref_wav).to(self.audio_tokenizer.device)
821
+ ref_audio_tokens = self.audio_tokenizer.encode(
822
+ ref_wav_tensor.unsqueeze(0),
823
+ ).audio_codes.squeeze(0) # (C, T)
824
+
825
+ if preprocess_prompt:
826
+ ref_text = add_punctuation(ref_text)
827
+
828
+ return VoiceClonePrompt(
829
+ ref_audio_tokens=ref_audio_tokens,
830
+ ref_text=ref_text,
831
+ ref_rms=ref_rms,
832
+ )
833
+
834
+ def _decode_and_post_process(
835
+ self,
836
+ tokens: Union[torch.Tensor, List[torch.Tensor]],
837
+ rms: Union[float, None],
838
+ gen_config: OmniVoiceGenerationConfig,
839
+ ) -> np.ndarray:
840
+ """
841
+ Args:
842
+ tokens: Audio tokens — either a single tensor of shape
843
+ (num_codebooks, seq_len) or a list of chunk tensors.
844
+ rms: RMS of the reference audio for volume adjustment.
845
+ gen_config: Generation config for post-processing options.
846
+ Returns:
847
+ Decoded and post-processed audio array of shape (T,).
848
+ """
849
+ tokenizer_device = self.audio_tokenizer.device
850
+ if isinstance(tokens, list):
851
+ chunk_audios = [
852
+ self.audio_tokenizer.decode(t.to(tokenizer_device).unsqueeze(0))
853
+ .audio_values[0]
854
+ .cpu()
855
+ .numpy()
856
+ for t in tokens
857
+ ]
858
+ audio_waveform = cross_fade_chunks(chunk_audios, self.sampling_rate)
859
+ else:
860
+ audio_waveform = (
861
+ self.audio_tokenizer.decode(tokens.to(tokenizer_device).unsqueeze(0))
862
+ .audio_values[0]
863
+ .cpu()
864
+ .numpy()
865
+ )
866
+
867
+ audio_waveform = self._post_process_audio(
868
+ audio_waveform,
869
+ ref_rms=rms,
870
+ gen_config=gen_config,
871
+ )
872
+ return audio_waveform.squeeze(0)
873
+
874
+ def _post_process_audio(
875
+ self,
876
+ generated_audio: np.ndarray,
877
+ ref_rms: Union[float, None],
878
+ gen_config: OmniVoiceGenerationConfig,
879
+ ) -> np.ndarray:
880
+ """Optionally remove long silences, adjust volume, and add edge padding.
881
+
882
+ Args:
883
+ generated_audio: Numpy array of shape (1, T).
884
+ ref_rms: RMS of the reference audio for volume normalisation.
885
+ gen_config: Generation config controlling post-processing behaviour.
886
+ Returns:
887
+ Processed numpy array of shape (1, T).
888
+ """
889
+ if gen_config.postprocess_output:
890
+ generated_audio = remove_silence(
891
+ generated_audio,
892
+ self.sampling_rate,
893
+ mid_sil=500,
894
+ lead_sil=100,
895
+ trail_sil=100,
896
+ )
897
+
898
+ if ref_rms is not None and ref_rms < 0.1:
899
+ generated_audio = generated_audio * ref_rms / 0.1
900
+ elif ref_rms is None:
901
+ peak = np.abs(generated_audio).max()
902
+ if peak > 1e-6:
903
+ generated_audio = generated_audio / peak * 0.5
904
+
905
+ generated_audio = fade_and_pad_audio(
906
+ generated_audio,
907
+ pad_duration=gen_config.pad_duration,
908
+ fade_duration=gen_config.fade_duration,
909
+ sample_rate=self.sampling_rate,
910
+ )
911
+ return generated_audio
912
+
913
+ def _generate_chunked(
914
+ self, task: GenerationTask, gen_config: OmniVoiceGenerationConfig
915
+ ) -> List[List[torch.Tensor]]:
916
+ """Generate long audio by splitting text into chunks and batching.
917
+
918
+ Each item in the returned list corresponds to one input and contains
919
+ a list of audio token tensors — one per text chunk.
920
+
921
+ Args:
922
+ task: A :class:`GenerationTask` with one or more items whose
923
+ estimated audio exceeds ``audio_chunk_threshold``.
924
+ gen_config: Generation config (``audio_chunk_duration`` controls
925
+ chunk size).
926
+ Returns:
927
+ Per-item list of chunk token-tensor lists.
928
+ """
929
+ # Chunk each item's text
930
+ all_chunks = []
931
+ for i in range(task.batch_size):
932
+ avg_tokens_per_char = task.target_lens[i] / len(task.texts[i])
933
+ text_chunk_len = int(
934
+ gen_config.audio_chunk_duration
935
+ * self.audio_tokenizer.config.frame_rate
936
+ / avg_tokens_per_char
937
+ )
938
+ chunks = chunk_text_punctuation(
939
+ text=task.texts[i],
940
+ chunk_len=text_chunk_len,
941
+ min_chunk_len=3,
942
+ )
943
+ logger.debug(f"Item {i} chunked into {len(chunks)} pieces: {chunks}")
944
+ all_chunks.append(chunks)
945
+
946
+ has_ref = [t is not None for t in task.ref_audio_tokens]
947
+ assert all(has_ref) or not any(has_ref), (
948
+ "Chunked inference requires all items to either have or not have "
949
+ "ref_audio. Mixed ref/non-ref is not supported."
950
+ )
951
+
952
+ max_num_chunks = max(len(c) for c in all_chunks)
953
+
954
+ # chunk_results[item_idx] = list of generated token tensors per chunk
955
+ chunk_results = [[] for _ in range(task.batch_size)]
956
+
957
+ def _run_batch(indices, texts, ref_audios, ref_texts):
958
+ speed_list = task.speed
959
+ target_lens = [
960
+ self._estimate_target_tokens(
961
+ texts[j],
962
+ ref_texts[j],
963
+ ref_audios[j].size(-1) if ref_audios[j] is not None else None,
964
+ speed=speed_list[i] if speed_list else 1.0,
965
+ )
966
+ for j, i in enumerate(indices)
967
+ ]
968
+ sub_task = GenerationTask(
969
+ batch_size=len(indices),
970
+ texts=texts,
971
+ target_lens=target_lens,
972
+ langs=[task.langs[i] for i in indices],
973
+ instructs=[task.instructs[i] for i in indices],
974
+ ref_texts=ref_texts,
975
+ ref_audio_tokens=ref_audios,
976
+ ref_rms=[task.ref_rms[i] for i in indices],
977
+ speed=[task.speed[i] for i in indices] if task.speed else None,
978
+ )
979
+ gen_tokens = self._generate_iterative(sub_task, gen_config)
980
+ for j, idx in enumerate(indices):
981
+ chunk_results[idx].append(gen_tokens[j])
982
+
983
+ if all(has_ref):
984
+ # All items have reference audio.
985
+ # We still sequentially generate chunks within each item, but we
986
+ # batch across items for the same chunk index. This allows to keep
987
+ # the VRAM usage manageable while still benefiting from batching.
988
+ for ci in range(max_num_chunks):
989
+ indices = [i for i in range(task.batch_size) if ci < len(all_chunks[i])]
990
+ if not indices:
991
+ continue
992
+ _run_batch(
993
+ indices,
994
+ texts=[all_chunks[i][ci] for i in indices],
995
+ ref_audios=[task.ref_audio_tokens[i] for i in indices],
996
+ ref_texts=[task.ref_texts[i] for i in indices],
997
+ )
998
+ else:
999
+ # No reference audio — generate chunk 0 for all items first,
1000
+ # then use chunk 0 output as reference for all subsequent chunks.
1001
+ indices_0 = [i for i in range(task.batch_size) if len(all_chunks[i]) > 0]
1002
+ _run_batch(
1003
+ indices_0,
1004
+ texts=[all_chunks[i][0] for i in indices_0],
1005
+ ref_audios=[None] * len(indices_0),
1006
+ ref_texts=[None] * len(indices_0),
1007
+ )
1008
+ first_chunk_map = {idx: chunk_results[idx][0] for idx in indices_0}
1009
+
1010
+ # Batch all remaining chunks, using chunk 0 as fixed reference
1011
+ for ci in range(1, max_num_chunks):
1012
+ indices = [i for i in range(task.batch_size) if ci < len(all_chunks[i])]
1013
+ if not indices:
1014
+ continue
1015
+ _run_batch(
1016
+ indices,
1017
+ texts=[all_chunks[i][ci] for i in indices],
1018
+ ref_audios=[first_chunk_map[i] for i in indices],
1019
+ ref_texts=[all_chunks[i][0] for i in indices],
1020
+ )
1021
+
1022
+ return chunk_results
1023
+
1024
+ def _preprocess_all(
1025
+ self,
1026
+ text: Union[str, list[str]],
1027
+ language: Union[str, list[str], None] = None,
1028
+ ref_text: Union[str, list[str], None] = None,
1029
+ ref_audio: Union[
1030
+ str,
1031
+ list[str],
1032
+ tuple[torch.Tensor, int],
1033
+ list[tuple[torch.Tensor, int]],
1034
+ None,
1035
+ ] = None,
1036
+ voice_clone_prompt: Union[
1037
+ VoiceClonePrompt, list[VoiceClonePrompt], None
1038
+ ] = None,
1039
+ instruct: Union[str, list[str], None] = None,
1040
+ preprocess_prompt: bool = True,
1041
+ speed: Union[float, list[Optional[float]], None] = None,
1042
+ duration: Union[float, list[Optional[float]], None] = None,
1043
+ normalize_text: bool = False,
1044
+ ) -> GenerationTask:
1045
+ if isinstance(text, str):
1046
+ text_list = [text]
1047
+ else:
1048
+ assert isinstance(text, list), (
1049
+ "text should be a string or a list of strings"
1050
+ )
1051
+ text_list = text
1052
+ batch_size = len(text_list)
1053
+
1054
+ language_list = self._ensure_list(language, batch_size)
1055
+ language_list = [_resolve_language(lang) for lang in language_list]
1056
+
1057
+ # Optional text normalization (opt-in). Applied to the target text only
1058
+ # (not ref_text, which must stay aligned with the reference audio),
1059
+ # before duration estimation so the estimate matches the spoken form.
1060
+ if normalize_text:
1061
+ text_list = [
1062
+ _normalize_text(t, lang) for t, lang in zip(text_list, language_list)
1063
+ ]
1064
+ instruct_list = self._ensure_list(instruct, batch_size)
1065
+ for i, s in enumerate(instruct_list):
1066
+ if s is None:
1067
+ continue
1068
+ use_zh = bool(text_list[i] and _ZH_RE.search(text_list[i]))
1069
+ instruct_list[i] = _resolve_instruct(s, use_zh=use_zh)
1070
+
1071
+ if voice_clone_prompt is not None and (
1072
+ ref_text is not None or ref_audio is not None
1073
+ ):
1074
+ logger.warning(
1075
+ "Both voice_clone_prompt and ref_text/ref_audio are provided. "
1076
+ "ref_text/ref_audio will be ignored."
1077
+ )
1078
+ if voice_clone_prompt is None and ref_audio is not None:
1079
+ # If voice_clone_prompt is not provided, create it from
1080
+ # ref_audio (ref_text will be auto-transcribed if not given).
1081
+ ref_text_list = self._ensure_list(ref_text, batch_size, auto_repeat=False)
1082
+ ref_audio_list = self._ensure_list(ref_audio, batch_size, auto_repeat=False)
1083
+
1084
+ voice_clone_prompt = []
1085
+ for i in range(len(ref_text_list)):
1086
+ voice_clone_prompt.append(
1087
+ self.create_voice_clone_prompt(
1088
+ ref_audio=ref_audio_list[i],
1089
+ ref_text=ref_text_list[i],
1090
+ preprocess_prompt=preprocess_prompt,
1091
+ )
1092
+ )
1093
+
1094
+ voice_clone_prompt_list = self._ensure_list(voice_clone_prompt, batch_size)
1095
+ if voice_clone_prompt_list[0] is not None:
1096
+ ref_text_list = [vc.ref_text for vc in voice_clone_prompt_list]
1097
+ ref_audio_tokens_list = [
1098
+ vc.ref_audio_tokens for vc in voice_clone_prompt_list
1099
+ ]
1100
+ ref_rms_list = [vc.ref_rms for vc in voice_clone_prompt_list]
1101
+ else:
1102
+ ref_text_list = [None] * batch_size
1103
+ ref_audio_tokens_list = [None] * batch_size
1104
+ ref_rms_list = [None] * batch_size
1105
+
1106
+ # Normalize speed/duration to per-item lists (may contain None).
1107
+ if speed is not None:
1108
+ if isinstance(speed, (int, float)):
1109
+ user_speed = [float(speed)] * batch_size
1110
+ else:
1111
+ user_speed = list(speed)
1112
+ else:
1113
+ user_speed = None
1114
+
1115
+ if duration is not None:
1116
+ if isinstance(duration, (int, float)):
1117
+ durations = [float(duration)] * batch_size
1118
+ else:
1119
+ durations = list(duration)
1120
+ else:
1121
+ durations = None
1122
+
1123
+ num_target_tokens_list = []
1124
+ for i in range(batch_size):
1125
+ # duration[i] overrides speed for estimation: use speed=1.0
1126
+ # to get the raw estimate, then override target_lens below.
1127
+ has_dur = durations is not None and durations[i] is not None
1128
+ item_speed = 1.0 if has_dur else (user_speed[i] if user_speed else 1.0)
1129
+ est = self._estimate_target_tokens(
1130
+ text_list[i],
1131
+ ref_text_list[i],
1132
+ ref_audio_tokens_list[i].size(-1)
1133
+ if ref_audio_tokens_list[i] is not None
1134
+ else None,
1135
+ speed=item_speed,
1136
+ )
1137
+ num_target_tokens_list.append(est)
1138
+
1139
+ # Per-item duration overrides: set target_lens to exact frame count
1140
+ # and compute speed ratio so chunked generation scales proportionally.
1141
+ speed_list: Optional[List[float]] = None
1142
+ if durations is not None:
1143
+ frame_rate = self.audio_tokenizer.config.frame_rate
1144
+ speed_list = []
1145
+ for i in range(batch_size):
1146
+ if durations[i] is not None:
1147
+ target_tokens = max(1, int(durations[i] * frame_rate))
1148
+ est = num_target_tokens_list[i]
1149
+ speed_list.append(est / target_tokens if target_tokens > 0 else 1.0)
1150
+ num_target_tokens_list[i] = target_tokens
1151
+ else:
1152
+ s = user_speed[i] if user_speed else None
1153
+ speed_list.append(s if s is not None else 1.0)
1154
+ elif user_speed is not None:
1155
+ speed_list = [s if s is not None else 1.0 for s in user_speed]
1156
+
1157
+ return GenerationTask(
1158
+ batch_size=batch_size,
1159
+ texts=text_list,
1160
+ target_lens=num_target_tokens_list,
1161
+ langs=language_list,
1162
+ instructs=instruct_list,
1163
+ ref_texts=ref_text_list,
1164
+ ref_audio_tokens=ref_audio_tokens_list,
1165
+ ref_rms=ref_rms_list,
1166
+ speed=speed_list,
1167
+ )
1168
+
1169
+ def _estimate_target_tokens(self, text, ref_text, num_ref_audio_tokens, speed=1.0):
1170
+ """Estimate number of target audio tokens."""
1171
+ if num_ref_audio_tokens is None or ref_text is None or len(ref_text) == 0:
1172
+ # Fall back to a simple heuristic
1173
+ ref_text = "Nice to meet you."
1174
+ num_ref_audio_tokens = 25
1175
+
1176
+ est = self.duration_estimator.estimate_duration(
1177
+ text, ref_text, num_ref_audio_tokens
1178
+ )
1179
+ if speed > 0 and speed != 1.0:
1180
+ est = est / speed
1181
+ return max(1, int(est))
1182
+
1183
+ def _ensure_list(
1184
+ self, x: Union[Any, List[Any]], batch_size: int, auto_repeat: bool = True
1185
+ ) -> List[Any]:
1186
+ x_list = x if isinstance(x, list) else [x]
1187
+ if len(x_list) not in (
1188
+ 1,
1189
+ batch_size,
1190
+ ):
1191
+ raise ValueError(
1192
+ f"should be either the number of the text or 1, but got {len(x_list)}"
1193
+ )
1194
+ if auto_repeat and len(x_list) == 1 and batch_size is not None:
1195
+ x_list = x_list * batch_size
1196
+ return x_list
1197
+
1198
+ def _prepare_inference_inputs(
1199
+ self,
1200
+ text: str,
1201
+ num_target_tokens: int,
1202
+ ref_text: Optional[str] = None,
1203
+ ref_audio_tokens: Optional[torch.Tensor] = None,
1204
+ lang: Optional[str] = None,
1205
+ instruct: Optional[str] = None,
1206
+ denoise: bool = True,
1207
+ ):
1208
+ """Prepare input_ids and audio masks for inference.
1209
+ Args:
1210
+ text: Target text to generate.
1211
+ num_target_tokens: Number of audio tokens to generate.
1212
+ ref_text: Optional reference text for voice cloning.
1213
+ ref_audio_tokens: Optional reference audio tokens for voice cloning.
1214
+ with shape (C, T).
1215
+ lang: Optional language ID.
1216
+ instruct: Optional style instruction for voice design.
1217
+ denoise: Whether to include the <|denoise|> token.
1218
+ """
1219
+
1220
+ # Build style tokens: <|denoise|> + <|lang_start|>...<|lang_end|>
1221
+ # + <|instruct_start|>...<|instruct_end|>
1222
+ style_text = ""
1223
+ if denoise and ref_audio_tokens is not None:
1224
+ style_text += "<|denoise|>"
1225
+ lang_str = lang if lang else "None"
1226
+ instruct_str = instruct if instruct else "None"
1227
+ style_text += f"<|lang_start|>{lang_str}<|lang_end|>"
1228
+ style_text += f"<|instruct_start|>{instruct_str}<|instruct_end|>"
1229
+
1230
+ style_tokens = (
1231
+ self.text_tokenizer(style_text, return_tensors="pt")
1232
+ .input_ids.repeat(self.config.num_audio_codebook, 1)
1233
+ .unsqueeze(0)
1234
+ ).to(self.device) # [1, C, N1]
1235
+
1236
+ # Build text tokens
1237
+ full_text = _combine_text(ref_text=ref_text, text=text)
1238
+ wrapped_text = f"<|text_start|>{full_text}<|text_end|>"
1239
+ text_tokens = (
1240
+ _tokenize_with_nonverbal_tags(wrapped_text, self.text_tokenizer)
1241
+ .repeat(self.config.num_audio_codebook, 1)
1242
+ .unsqueeze(0)
1243
+ ).to(self.device) # [1, C, N2]
1244
+
1245
+ # Target: all MASK
1246
+ target_audio_tokens = torch.full(
1247
+ (1, self.config.num_audio_codebook, num_target_tokens),
1248
+ self.config.audio_mask_id,
1249
+ dtype=torch.long,
1250
+ device=self.device,
1251
+ )
1252
+
1253
+ # Conditional input
1254
+ parts = [style_tokens, text_tokens]
1255
+ if ref_audio_tokens is not None:
1256
+ parts.append(ref_audio_tokens.unsqueeze(0).to(self.device))
1257
+ parts.append(target_audio_tokens)
1258
+ cond_input_ids = torch.cat(parts, dim=2)
1259
+
1260
+ cond_total_length = cond_input_ids.shape[2]
1261
+ cond_audio_start_idx = cond_total_length - num_target_tokens
1262
+ if ref_audio_tokens is not None:
1263
+ cond_audio_start_idx -= ref_audio_tokens.size(-1)
1264
+
1265
+ cond_audio_mask = torch.zeros(
1266
+ 1, cond_total_length, dtype=torch.bool, device=self.device
1267
+ )
1268
+ cond_audio_mask[0, cond_audio_start_idx:] = True
1269
+
1270
+ return {
1271
+ "input_ids": cond_input_ids,
1272
+ "audio_mask": cond_audio_mask,
1273
+ }
1274
+
1275
+ def _generate_iterative(
1276
+ self, task: GenerationTask, gen_config: OmniVoiceGenerationConfig
1277
+ ) -> List[torch.Tensor]:
1278
+ """N-step iterative unmasked decoding.
1279
+
1280
+ Args:
1281
+ task: A :class:`GenerationTask` containing batch texts, target
1282
+ lengths, languages, instructions, and optional reference data.
1283
+ gen_config: A :class:`OmniVoiceGenerationConfig` controlling
1284
+ decoding steps, guidance, temperatures, etc.
1285
+ Returns:
1286
+ List of generated audio token tensors of shape (C, T) (one per
1287
+ input text).
1288
+ """
1289
+
1290
+ B = task.batch_size
1291
+
1292
+ for i in range(B):
1293
+ logger.debug(
1294
+ "Item %d — text: %s | ref_text: %s | instruct: %s | lang: %s | target_tokens: %d",
1295
+ i,
1296
+ task.texts[i],
1297
+ task.ref_texts[i],
1298
+ task.instructs[i],
1299
+ task.langs[i],
1300
+ task.target_lens[i],
1301
+ )
1302
+
1303
+ inputs_list = [
1304
+ self._prepare_inference_inputs(
1305
+ task.texts[i],
1306
+ task.target_lens[i],
1307
+ task.ref_texts[i],
1308
+ task.ref_audio_tokens[i],
1309
+ task.langs[i],
1310
+ task.instructs[i],
1311
+ gen_config.denoise,
1312
+ )
1313
+ for i in range(B)
1314
+ ]
1315
+
1316
+ c_lens = [inp["input_ids"].size(2) for inp in inputs_list]
1317
+ max_c_len = max(c_lens)
1318
+ pad_id = self.config.audio_mask_id # Or any other tokens
1319
+
1320
+ batch_input_ids = torch.full(
1321
+ (2 * B, self.config.num_audio_codebook, max_c_len),
1322
+ pad_id,
1323
+ dtype=torch.long,
1324
+ device=self.device,
1325
+ )
1326
+ batch_audio_mask = torch.zeros(
1327
+ (2 * B, max_c_len), dtype=torch.bool, device=self.device
1328
+ )
1329
+ batch_attention_mask = torch.zeros(
1330
+ (2 * B, 1, max_c_len, max_c_len), dtype=torch.bool, device=self.device
1331
+ )
1332
+
1333
+ for i, inp in enumerate(inputs_list):
1334
+ c_len, u_len = c_lens[i], task.target_lens[i]
1335
+
1336
+ # Cond (0 ~ B-1)
1337
+ batch_input_ids[i, :, :c_len] = inp["input_ids"]
1338
+ batch_audio_mask[i, :c_len] = inp["audio_mask"]
1339
+ batch_attention_mask[i, :, :c_len, :c_len] = True
1340
+
1341
+ # Uncond (B ~ 2B-1)
1342
+ batch_input_ids[B + i, :, :u_len] = inp["input_ids"][..., -u_len:]
1343
+ batch_audio_mask[B + i, :u_len] = inp["audio_mask"][..., -u_len:]
1344
+ batch_attention_mask[B + i, :, :u_len, :u_len] = True
1345
+ if max_c_len > u_len:
1346
+ pad_diag = torch.arange(u_len, max_c_len, device=self.device)
1347
+ batch_attention_mask[B + i, :, pad_diag, pad_diag] = True
1348
+
1349
+ tokens = torch.full(
1350
+ (B, self.config.num_audio_codebook, max(task.target_lens)),
1351
+ self.config.audio_mask_id,
1352
+ dtype=torch.long,
1353
+ device=self.device,
1354
+ )
1355
+
1356
+ timesteps = _get_time_steps(
1357
+ t_start=0.0,
1358
+ t_end=1.0,
1359
+ num_step=gen_config.num_step,
1360
+ t_shift=gen_config.t_shift,
1361
+ ).tolist()
1362
+ schedules = []
1363
+ for t_len in task.target_lens:
1364
+ total_mask = t_len * self.config.num_audio_codebook
1365
+ rem = total_mask
1366
+ sched = []
1367
+ for step in range(gen_config.num_step):
1368
+ num = (
1369
+ rem
1370
+ if step == gen_config.num_step - 1
1371
+ else min(
1372
+ math.ceil(total_mask * (timesteps[step + 1] - timesteps[step])),
1373
+ rem,
1374
+ )
1375
+ )
1376
+ sched.append(int(num))
1377
+ rem -= int(num)
1378
+ schedules.append(sched)
1379
+
1380
+ layer_ids = torch.arange(
1381
+ self.config.num_audio_codebook, device=self.device
1382
+ ).view(1, -1, 1)
1383
+
1384
+ for step in range(gen_config.num_step):
1385
+ batch_logits = self(
1386
+ input_ids=batch_input_ids,
1387
+ audio_mask=batch_audio_mask,
1388
+ attention_mask=batch_attention_mask,
1389
+ ).logits.to(torch.float32)
1390
+
1391
+ for i in range(B):
1392
+ k = schedules[i][step]
1393
+ if k <= 0:
1394
+ continue
1395
+
1396
+ c_len, t_len = c_lens[i], task.target_lens[i]
1397
+
1398
+ # Extract real target Logits
1399
+ # [1, C, T, V]
1400
+ c_logits = batch_logits[i : i + 1, :, c_len - t_len : c_len, :]
1401
+ u_logits = batch_logits[B + i : B + i + 1, :, :t_len, :]
1402
+
1403
+ pred_tokens, scores = self._predict_tokens_with_scoring(
1404
+ c_logits, u_logits, gen_config
1405
+ )
1406
+
1407
+ scores = scores - (layer_ids * gen_config.layer_penalty_factor)
1408
+
1409
+ if gen_config.position_temperature > 0.0:
1410
+ scores = _gumbel_sample(scores, gen_config.position_temperature)
1411
+
1412
+ sample_tokens = tokens[i : i + 1, :, :t_len]
1413
+ scores.masked_fill_(
1414
+ sample_tokens != self.config.audio_mask_id, -float("inf")
1415
+ )
1416
+
1417
+ _, topk_idx = torch.topk(scores.flatten(), k)
1418
+ flat_tokens = sample_tokens.flatten()
1419
+ flat_tokens[topk_idx] = pred_tokens.flatten()[topk_idx]
1420
+ sample_tokens.copy_(flat_tokens.view_as(sample_tokens))
1421
+
1422
+ # Update individual slices into batched structure
1423
+ tokens[i : i + 1, :, :t_len] = sample_tokens
1424
+ batch_input_ids[i : i + 1, :, c_len - t_len : c_len] = sample_tokens
1425
+ batch_input_ids[B + i : B + i + 1, :, :t_len] = sample_tokens
1426
+
1427
+ return [tokens[i, :, : task.target_lens[i]] for i in range(B)]
1428
+
1429
+ def _predict_tokens_with_scoring(self, c_logits, u_logits, gen_config):
1430
+ if gen_config.guidance_scale != 0:
1431
+ c_log_probs = F.log_softmax(c_logits, dim=-1)
1432
+ u_log_probs = F.log_softmax(u_logits, dim=-1)
1433
+ log_probs = torch.log_softmax(
1434
+ c_log_probs + gen_config.guidance_scale * (c_log_probs - u_log_probs),
1435
+ dim=-1,
1436
+ )
1437
+ else:
1438
+ log_probs = F.log_softmax(c_logits, dim=-1)
1439
+
1440
+ log_probs[..., self.config.audio_mask_id] = -float("inf")
1441
+
1442
+ if gen_config.class_temperature > 0.0:
1443
+ filtered_probs = _filter_top_k(log_probs, ratio=0.1)
1444
+ pred_tokens = _gumbel_sample(
1445
+ filtered_probs, gen_config.class_temperature
1446
+ ).argmax(dim=-1)
1447
+ else:
1448
+ pred_tokens = log_probs.argmax(dim=-1)
1449
+
1450
+ confidence_scores = log_probs.max(dim=-1)[0]
1451
+
1452
+ return pred_tokens, confidence_scores
1453
+
1454
+
1455
+ # ---------------------------------------------------------------------------
1456
+ # Standalone helpers
1457
+ # ---------------------------------------------------------------------------
1458
+
1459
+
1460
+ def _get_packed_mask(document_ids):
1461
+ return partial(_mask_mod_packed, document_ids)
1462
+
1463
+
1464
+ def _mask_mod_packed(document_ids, b, h, q_idx, kv_idx):
1465
+ # 1. Sequence Packing Logic: Tokens must belong to the same document.
1466
+ # Note: The doc_id for padding tokens is -1, which will automatically not match
1467
+ # (if handled correctly) or be ignored.
1468
+ same_doc = document_ids[q_idx] == document_ids[kv_idx]
1469
+ return same_doc
1470
+
1471
+
1472
+ def _resolve_language(language: Optional[str]) -> Union[str, None]:
1473
+ from omnivoice.utils.lang_map import LANG_IDS, LANG_NAME_TO_ID
1474
+
1475
+ if language is None or language.lower() == "none":
1476
+ return None
1477
+ if language in LANG_IDS:
1478
+ return language
1479
+ key = language.lower()
1480
+ if key in LANG_NAME_TO_ID:
1481
+ return LANG_NAME_TO_ID[key]
1482
+ logger.warning(
1483
+ f"Language '{language}' is not recognized. "
1484
+ f"Please use a valid language ID (e.g., 'en', 'zh', 'ja', 'de') "
1485
+ f"or a full language name (e.g., 'English', 'Chinese', 'Japanese'). "
1486
+ f"See supported_language_ids() or supported_language_names() for details. "
1487
+ f"Falling back to None (language-agnostic mode)."
1488
+ )
1489
+ return None
1490
+
1491
+
1492
+ def _resolve_instruct(
1493
+ instruct: Optional[str], use_zh: bool = False
1494
+ ) -> Union[str, None]:
1495
+ """Validate and normalise a voice-design instruct string.
1496
+
1497
+ Supported instruct items (case-insensitive for English):
1498
+
1499
+ English (comma + space separated):
1500
+ gender: male, female
1501
+ age: child, teenager, young adult, middle-aged, elderly
1502
+ pitch: very low pitch, low pitch, moderate pitch,
1503
+ high pitch, very high pitch
1504
+ style: whisper
1505
+ accent: american accent, british accent, australian accent, ...
1506
+
1507
+ Chinese (full-width comma separated):
1508
+ gender: 男, 女
1509
+ age: 儿童, 少年, 青年, 中年, 老年
1510
+ pitch: 极低音调, 低音调, 中音调, 高音调, 极高音调
1511
+ style: 耳语
1512
+ dialect: 河南话, 陕西话, 四川话, 贵州话, 云南话,
1513
+ 桂林话, 济南话, 石家庄话, 甘肃话, 宁夏话,
1514
+ 青岛话, 东北话
1515
+
1516
+ Minor issues (auto-fixed):
1517
+ - Wrong separator (half-width comma in Chinese instruct or
1518
+ full-width comma in English instruct)
1519
+ - Leading / trailing commas
1520
+
1521
+ Major issues (raise ``ValueError``):
1522
+ - Unsupported or misspelled instruct items
1523
+ - Suggestions are offered for close matches
1524
+
1525
+ Args:
1526
+ instruct: Raw instruct string, or ``None``.
1527
+ use_zh: If True, normalise all items to Chinese (used when the
1528
+ synthesis text contains Chinese and no accent is specified).
1529
+
1530
+ Returns:
1531
+ Normalised instruct string, or ``None``.
1532
+
1533
+ Raises:
1534
+ ValueError: if any instruct item is unsupported or misspelled.
1535
+ """
1536
+ if instruct is None:
1537
+ return None
1538
+
1539
+ instruct_str = instruct.strip()
1540
+ if not instruct_str:
1541
+ return None
1542
+
1543
+ # Split on both half-width and full-width commas
1544
+ raw_items = re.split(r"\s*[,,]\s*", instruct_str)
1545
+ raw_items = [x for x in raw_items if x]
1546
+
1547
+ # Validate each item
1548
+ unknown = []
1549
+ normalised = []
1550
+ for raw in raw_items:
1551
+ n = raw.strip().lower()
1552
+ if n in _INSTRUCT_ALL_VALID:
1553
+ normalised.append(n)
1554
+ else:
1555
+ sug = difflib.get_close_matches(n, _INSTRUCT_ALL_VALID, n=1, cutoff=0.6)
1556
+ unknown.append((raw, n, sug[0] if sug else None))
1557
+
1558
+ if unknown:
1559
+ lines = []
1560
+ for raw, n, sug in unknown:
1561
+ if sug:
1562
+ lines.append(f" '{raw}' -> '{n}' (unsupported; did you mean '{sug}'?)")
1563
+ else:
1564
+ lines.append(f" '{raw}' -> '{n}' (unsupported)")
1565
+ err = (
1566
+ f"Unsupported instruct items found in {instruct_str}:\n"
1567
+ + "\n".join(lines)
1568
+ + "\n\nValid English items: "
1569
+ + ", ".join(sorted(_INSTRUCT_VALID_EN))
1570
+ + "\nValid Chinese items: "
1571
+ + ",".join(sorted(_INSTRUCT_VALID_ZH))
1572
+ + "\n\nTip: Use only English or only Chinese instructs. "
1573
+ "English instructs should use comma + space (e.g. "
1574
+ "'male, indian accent'),\nChinese instructs should use full-width "
1575
+ "comma (e.g. '男,河南话')."
1576
+ )
1577
+ raise ValueError(err)
1578
+
1579
+ # --- Language consistency: dialect forces Chinese, accent forces English ---
1580
+ has_dialect = any(n.endswith("话") for n in normalised)
1581
+ has_accent = any(" accent" in n for n in normalised)
1582
+
1583
+ if has_dialect and has_accent:
1584
+ raise ValueError(
1585
+ "Cannot mix Chinese dialect and English accent in a single instruct. "
1586
+ "Dialects are for Chinese speech, accents for English speech."
1587
+ )
1588
+
1589
+ if has_dialect:
1590
+ use_zh = True
1591
+ elif has_accent:
1592
+ use_zh = False
1593
+
1594
+ # --- Unify to single language ---
1595
+ if use_zh:
1596
+ normalised = [_INSTRUCT_EN_TO_ZH.get(n, n) for n in normalised]
1597
+ else:
1598
+ normalised = [_INSTRUCT_ZH_TO_EN.get(n, n) for n in normalised]
1599
+
1600
+ # --- Category conflict check ---
1601
+ conflicts = []
1602
+ for cat in _INSTRUCT_MUTUALLY_EXCLUSIVE:
1603
+ hits = [n for n in normalised if n in cat]
1604
+ if len(hits) > 1:
1605
+ conflicts.append(hits)
1606
+ if conflicts:
1607
+ parts = []
1608
+ for group in conflicts:
1609
+ parts.append(" vs ".join(f"'{x}'" for x in group))
1610
+ raise ValueError(
1611
+ "Conflicting instruct items within the same category: "
1612
+ + "; ".join(parts)
1613
+ + ". Each category (gender, age, pitch, style, accent, dialect) "
1614
+ "allows at most one item."
1615
+ )
1616
+
1617
+ # Determine separator based on language
1618
+ has_zh = any(any("\u4e00" <= c <= "\u9fff" for c in n) for n in normalised)
1619
+ separator = "," if has_zh else ", "
1620
+
1621
+ return separator.join(normalised)
1622
+
1623
+
1624
+ def _filter_top_k(logits: torch.Tensor, ratio: float = 0.1) -> torch.Tensor:
1625
+ k = math.ceil(ratio * logits.shape[-1])
1626
+ val, ind = logits.topk(k, dim=-1)
1627
+ probs = torch.full_like(logits, float("-inf"))
1628
+ probs.scatter_(-1, ind, val)
1629
+ return probs
1630
+
1631
+
1632
+ def _gumbel_sample(logits: torch.Tensor, temperature: float) -> torch.Tensor:
1633
+ scaled_logits = logits / temperature
1634
+ u = torch.rand_like(scaled_logits)
1635
+ gumbel_noise = -torch.log(-torch.log(u + 1e-10) + 1e-10)
1636
+ return scaled_logits + gumbel_noise
1637
+
1638
+
1639
+ def _get_time_steps(
1640
+ t_start: float = 0.0,
1641
+ t_end: float = 1.0,
1642
+ num_step: int = 10,
1643
+ t_shift: float = 1.0,
1644
+ device: torch.device = torch.device("cpu"),
1645
+ ) -> torch.Tensor:
1646
+ timesteps = torch.linspace(t_start, t_end, num_step + 1).to(device)
1647
+ timesteps = t_shift * timesteps / (1 + (t_shift - 1) * timesteps)
1648
+ return timesteps
1649
+
1650
+
1651
+ _NONVERBAL_PATTERN = re.compile(
1652
+ r"\[(laughter|sigh|confirmation-en|question-en|question-ah|question-oh|"
1653
+ r"question-ei|question-yi|surprise-ah|surprise-oh|surprise-wa|"
1654
+ r"surprise-yo|dissatisfaction-hnn)\]"
1655
+ )
1656
+
1657
+
1658
+ def _tokenize_with_nonverbal_tags(text: str, tokenizer) -> torch.Tensor:
1659
+ """Tokenize text containing non-verbal tags, handling each tag independently.
1660
+
1661
+ Non-verbal tags are tokenized standalone to guarantee consistent token
1662
+ IDs regardless of surrounding language context (Chinese, English, etc.).
1663
+
1664
+ Args:
1665
+ text: Full text string potentially containing non-verbal tags.
1666
+ tokenizer: HuggingFace text tokenizer instance.
1667
+ Returns:
1668
+ Token IDs tensor of shape (1, seq_len).
1669
+ """
1670
+ parts = []
1671
+ last_end = 0
1672
+ for m in _NONVERBAL_PATTERN.finditer(text):
1673
+ if m.start() > last_end:
1674
+ segment = text[last_end : m.start()]
1675
+ ids = tokenizer(segment, add_special_tokens=False).input_ids
1676
+ if ids:
1677
+ parts.append(ids)
1678
+ tag_ids = tokenizer(m.group(), add_special_tokens=False).input_ids
1679
+ if tag_ids:
1680
+ parts.append(tag_ids)
1681
+ last_end = m.end()
1682
+ if last_end < len(text):
1683
+ segment = text[last_end:]
1684
+ ids = tokenizer(segment, add_special_tokens=False).input_ids
1685
+ if ids:
1686
+ parts.append(ids)
1687
+
1688
+ if not parts:
1689
+ result = tokenizer(text, return_tensors="pt").input_ids
1690
+ else:
1691
+ combined = []
1692
+ for p in parts:
1693
+ combined.extend(p)
1694
+ result = torch.tensor([combined], dtype=torch.long)
1695
+ return result
1696
+
1697
+
1698
+ def _combine_text(text, ref_text: Optional[str] = None) -> str:
1699
+ # combine with reference text if not None
1700
+ if ref_text:
1701
+ full_text = ref_text.strip() + " " + text.strip()
1702
+ else:
1703
+ full_text = text.strip()
1704
+
1705
+ # filter out newline / carriage-return characters
1706
+ full_text = re.sub(r"[\r\n]+", "", full_text)
1707
+
1708
+ # replace Chinese parentheses with English ones
1709
+ full_text = full_text.replace("\uff08", "(").replace("\uff09", ")")
1710
+
1711
+ # collapse consecutive spaces / tabs into a single space
1712
+ full_text = re.sub(r"[ \t]+", " ", full_text)
1713
+
1714
+ # remove spaces around chinese characters
1715
+ chinese_range = r"[\u4e00-\u9fff]"
1716
+ pattern = rf"(?<={chinese_range})\s+|\s+(?={chinese_range})"
1717
+ full_text = re.sub(pattern, "", full_text)
1718
+
1719
+ return full_text
1720
+
1721
+
1722
+ # ---------------------------------------------------------------------------
1723
+ # Register with HuggingFace Auto classes
1724
+ # ---------------------------------------------------------------------------
1725
+
1726
+ AutoConfig.register("omnivoice", OmniVoiceConfig)
1727
+ AutoModel.register(OmniVoiceConfig, OmniVoice)
omnivoice/scripts/__init__.py ADDED
File without changes
omnivoice/scripts/denoise_audio.py ADDED
@@ -0,0 +1,1049 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Denoise audio with Sidon and pack results into WebDataset shards.
19
+
20
+ Supports two input modes:
21
+
22
+ 1. WebDataset manifest (data.lst):
23
+ python denoise_audio.py \
24
+ --input_manifest data.lst \
25
+ --tar_output_pattern output/audios/shard-%06d.tar \
26
+ --jsonl_output_pattern output/txts/shard-%06d.jsonl \
27
+ --feature_extractor_path sidon-v0.1/feature_extractor_cuda.pt \
28
+ --decoder_path sidon-v0.1/decoder_cuda.pt
29
+
30
+ 2. Raw JSONL (each line: {"id": "...", "audio_path": "...", ...}):
31
+ python denoise_audio.py \
32
+ --input_jsonl data.jsonl \
33
+ --tar_output_pattern output/audios/shard-%06d.tar \
34
+ --jsonl_output_pattern output/txts/shard-%06d.jsonl \
35
+ --feature_extractor_path sidon-v0.1/feature_extractor_cuda.pt \
36
+ --decoder_path sidon-v0.1/decoder_cuda.pt
37
+
38
+ Output structure:
39
+ output_dir/
40
+ ├── audios/ # WebDataset tar shards (.flac audio + .json metadata)
41
+ │ ├── shard_000000.tar
42
+ │ └── ...
43
+ ├── txts/ # Per-shard JSONL metadata
44
+ │ ├── shard_000000.jsonl
45
+ │ └── ...
46
+ ├── data.lst # Manifest: <tar_path> <jsonl_path> <sample_count> <total_duration>
47
+ └── errors.jsonl # Failed samples with error details
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import argparse
53
+ import io
54
+ import json
55
+ import logging
56
+ import os
57
+ import pickle
58
+ import struct
59
+ import subprocess
60
+ import sys
61
+ import threading
62
+ from concurrent.futures import FIRST_COMPLETED, Future, wait
63
+ from dataclasses import dataclass
64
+ from pathlib import Path
65
+ from typing import Any, Dict, List, Optional, Sequence, Union
66
+
67
+ import numpy as np
68
+ import torch
69
+ import torchaudio
70
+ import webdataset as wds
71
+ from torch.utils.data import DataLoader
72
+ from tqdm.auto import tqdm
73
+
74
+ from omnivoice.data.batching import StreamLengthGroupDataset
75
+ from omnivoice.data.dataset import JsonlDatasetReader, WebDatasetReader
76
+ import soundfile as sf
77
+ from omnivoice.utils.common import str2bool
78
+
79
+ SIDON_INPUT_SAMPLE_RATE = 16_000
80
+ SIDON_OUTPUT_SAMPLE_RATE = 48_000
81
+
82
+
83
+ def build_parser() -> argparse.ArgumentParser:
84
+ parser = argparse.ArgumentParser(description=__doc__)
85
+
86
+ # ── Input (mutually exclusive) ──
87
+ parser.add_argument(
88
+ "--input_manifest",
89
+ default=None,
90
+ help="WebDataset manifest (data.lst). Each line: "
91
+ "<tar_path> <jsonl_path> <num_items> <duration>",
92
+ )
93
+ parser.add_argument(
94
+ "--input_jsonl",
95
+ default=None,
96
+ help='Raw JSONL file. Each line: {"id": "...", "audio_path": "...", ...}',
97
+ )
98
+
99
+ # ── Output ──
100
+ parser.add_argument(
101
+ "--tar_output_pattern",
102
+ default=None,
103
+ help="Tar shard pattern, e.g. output/audios/shard_%%06d.tar",
104
+ )
105
+ parser.add_argument(
106
+ "--jsonl_output_pattern",
107
+ default=None,
108
+ help="JSONL shard pattern, e.g. output/txts/shard_%%06d.jsonl",
109
+ )
110
+ parser.add_argument(
111
+ "--samples_per_shard",
112
+ type=int,
113
+ default=1_000,
114
+ help="Maximum records per output shard",
115
+ )
116
+
117
+ # ── Model ──
118
+ parser.add_argument(
119
+ "--feature_extractor_path",
120
+ default=None,
121
+ help="Path to feature_extractor_cuda.pt",
122
+ )
123
+ parser.add_argument(
124
+ "--decoder_path",
125
+ default=None,
126
+ help="Path to decoder_cuda.pt",
127
+ )
128
+ parser.add_argument(
129
+ "--target_sample_rate",
130
+ type=int,
131
+ default=24_000,
132
+ help="Sample rate of the denoised output audio",
133
+ )
134
+
135
+ # ── Filtering ──
136
+ parser.add_argument(
137
+ "--min_length",
138
+ type=float,
139
+ default=0.0,
140
+ help="Minimum audio duration in seconds",
141
+ )
142
+ parser.add_argument(
143
+ "--max_length",
144
+ type=float,
145
+ default=80.0,
146
+ help="Maximum audio duration in seconds",
147
+ )
148
+
149
+ # ── Batching ──
150
+ parser.add_argument(
151
+ "--batch_duration",
152
+ type=float,
153
+ default=200.0,
154
+ help="Target batch duration in seconds for dynamic batching",
155
+ )
156
+ parser.add_argument(
157
+ "--max_sample",
158
+ type=int,
159
+ default=32,
160
+ help="Maximum samples per batch for dynamic batching",
161
+ )
162
+
163
+ # ── Distributed ──
164
+ parser.add_argument(
165
+ "--num_machines",
166
+ type=int,
167
+ default=1,
168
+ help="Total number of machines for distributed runs",
169
+ )
170
+ parser.add_argument(
171
+ "--machine_index",
172
+ type=int,
173
+ default=0,
174
+ help="Zero-based machine index when distributing across multiple "
175
+ "machines (e.g. 0, 1, ... num_machines-1)",
176
+ )
177
+
178
+ # ── Parallelism ──
179
+ parser.add_argument(
180
+ "--nj_per_gpu",
181
+ type=int,
182
+ default=1,
183
+ help="Worker processes per GPU (default 1)",
184
+ )
185
+ parser.add_argument(
186
+ "--loader_workers",
187
+ type=int,
188
+ default=16,
189
+ help="PyTorch DataLoader worker threads",
190
+ )
191
+
192
+ # ── Data order (JSONL mode) ──
193
+ parser.add_argument(
194
+ "--shuffle",
195
+ type=str2bool,
196
+ default=True,
197
+ help="Shuffle JSONL entries",
198
+ )
199
+ parser.add_argument(
200
+ "--shuffle_seed",
201
+ type=int,
202
+ default=42,
203
+ help="Seed for JSONL shuffle",
204
+ )
205
+
206
+ # ── Error handling ──
207
+ parser.add_argument(
208
+ "--skip_errors",
209
+ action="store_true",
210
+ help="Skip items that fail to denoise instead of aborting",
211
+ )
212
+ parser.add_argument(
213
+ "--_subprocess_worker",
214
+ action="store_true",
215
+ help=argparse.SUPPRESS,
216
+ )
217
+ return parser
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Utilities
222
+ # ---------------------------------------------------------------------------
223
+
224
+
225
+ def count_lines(path: str) -> int:
226
+ """Count newlines efficiently by reading binary chunks."""
227
+ count = 0
228
+ with open(path, "rb") as f:
229
+ for chunk in iter(lambda: f.read(1 << 20), b""):
230
+ count += chunk.count(b"\n")
231
+ return count
232
+
233
+
234
+ PaddingStrategy = Union[bool, str]
235
+ ReturnType = Union[torch.Tensor, np.ndarray]
236
+
237
+
238
+ def extract_seamless_m4t_features(
239
+ raw_speech: Union[torch.Tensor, List[float], List[torch.Tensor], List[List[float]]],
240
+ sampling_rate: int = 16000,
241
+ num_mel_bins: int = 80,
242
+ frame_length: int = 25,
243
+ frame_shift: int = 10,
244
+ preemphasis_coefficient: float = 0.97,
245
+ dither: float = 0.0,
246
+ window_type: str = "povey",
247
+ do_normalize_per_mel_bins: bool = True,
248
+ stride: int = 2,
249
+ padding: PaddingStrategy = "longest",
250
+ max_length: Optional[int] = None,
251
+ pad_to_multiple_of: Optional[int] = 2,
252
+ return_tensors: Optional[str] = "pt",
253
+ return_attention_mask: bool = True,
254
+ padding_value: float = 0.0,
255
+ device: torch.device = torch.device("cpu"),
256
+ ) -> Dict[str, ReturnType]:
257
+ """Extract SeamlessM4T features using Torch-only operators."""
258
+ if not isinstance(raw_speech, list):
259
+ raw_speech = [raw_speech]
260
+
261
+ processed_speech = [
262
+ torch.as_tensor(sample, dtype=torch.float32, device=device)
263
+ for sample in raw_speech
264
+ ]
265
+
266
+ features: List[torch.Tensor] = []
267
+ for waveform in processed_speech:
268
+ if waveform.ndim > 1:
269
+ waveform = waveform[0]
270
+ waveform_tensor = waveform.unsqueeze(0)
271
+ feature = torchaudio.compliance.kaldi.fbank(
272
+ waveform=waveform_tensor,
273
+ sample_frequency=sampling_rate,
274
+ num_mel_bins=num_mel_bins,
275
+ frame_length=frame_length,
276
+ frame_shift=frame_shift,
277
+ dither=dither,
278
+ preemphasis_coefficient=preemphasis_coefficient,
279
+ remove_dc_offset=True,
280
+ window_type=window_type,
281
+ use_energy=False,
282
+ energy_floor=1.192092955078125e-07,
283
+ )
284
+ features.append(feature.squeeze(0))
285
+
286
+ if do_normalize_per_mel_bins:
287
+ normalised: List[torch.Tensor] = []
288
+ for feature in features:
289
+ mean = feature.mean(0, keepdim=True)
290
+ var = feature.var(0, keepdim=True)
291
+ normalised.append((feature - mean) / torch.sqrt(var + 1e-5))
292
+ features = normalised
293
+
294
+ def _pad_batch(
295
+ features: List[torch.Tensor],
296
+ padding_strategy: PaddingStrategy = "longest",
297
+ max_length: Optional[int] = None,
298
+ pad_to_multiple_of: Optional[int] = None,
299
+ padding_value: float = 0.0,
300
+ ) -> tuple[torch.Tensor, torch.Tensor]:
301
+ if padding_strategy == "longest":
302
+ target_length = max(f.shape[0] for f in features)
303
+ elif max_length is not None:
304
+ target_length = max_length
305
+ else:
306
+ raise ValueError(
307
+ "max_length must be provided when padding_strategy is not 'longest'"
308
+ )
309
+
310
+ if pad_to_multiple_of is not None:
311
+ target_length = (
312
+ (target_length + pad_to_multiple_of - 1)
313
+ // pad_to_multiple_of
314
+ * pad_to_multiple_of
315
+ )
316
+
317
+ batch_size = len(features)
318
+ feature_dim = features[0].shape[1]
319
+ device = features[0].device
320
+
321
+ padded_features = torch.full(
322
+ (batch_size, target_length, feature_dim),
323
+ padding_value,
324
+ dtype=torch.float32,
325
+ device=device,
326
+ )
327
+ attention_mask = torch.zeros(
328
+ (batch_size, target_length),
329
+ dtype=torch.int64,
330
+ device=device,
331
+ )
332
+
333
+ for index, feature_tensor in enumerate(features):
334
+ seq_len = feature_tensor.shape[0]
335
+ padded_features[index, :seq_len] = feature_tensor
336
+ attention_mask[index, :seq_len] = 1
337
+
338
+ return padded_features, attention_mask
339
+
340
+ input_features, attention_mask = _pad_batch(
341
+ features,
342
+ padding_strategy=padding,
343
+ max_length=max_length,
344
+ pad_to_multiple_of=pad_to_multiple_of,
345
+ padding_value=padding_value,
346
+ )
347
+
348
+ batch_size, num_frames, num_channels = input_features.shape
349
+ new_num_frames = (num_frames // stride) * stride
350
+ input_features = input_features[:, :new_num_frames, :]
351
+ if return_attention_mask:
352
+ attention_mask = attention_mask[:, :new_num_frames]
353
+
354
+ input_features = input_features.reshape(
355
+ batch_size, new_num_frames // stride, num_channels * stride
356
+ )
357
+
358
+ output: Dict[str, ReturnType] = {"input_features": input_features}
359
+ if return_attention_mask:
360
+ output["attention_mask"] = attention_mask[:, 1::stride]
361
+
362
+ if return_tensors == "np":
363
+ for key, value in output.items():
364
+ output[key] = value.cpu().numpy() # type: ignore[assignment]
365
+
366
+ return output
367
+
368
+
369
+ def serialise_flac(key: str, waveform: torch.Tensor, sample_rate: int) -> dict:
370
+ buffer = io.BytesIO()
371
+ audio = waveform.to(dtype=torch.float32).cpu().numpy()
372
+ if audio.ndim == 2:
373
+ audio = audio.T # (C, T) → (T, C) for soundfile
374
+ sf.write(buffer, audio, sample_rate, format="FLAC")
375
+ return {"__key__": key, "flac": buffer.getvalue()}
376
+
377
+
378
+ def _normalise_value(value: Any) -> Any:
379
+ """Convert tensors and NumPy scalars to serialisable Python objects."""
380
+ if isinstance(value, torch.Tensor):
381
+ if value.ndim == 0:
382
+ return value.item()
383
+ return value.cpu().tolist()
384
+ if isinstance(value, np.generic):
385
+ return value.item()
386
+ if isinstance(value, np.ndarray):
387
+ return value.tolist()
388
+ return value
389
+
390
+
391
+ def _encode_metadata(metadata: dict[str, Any]) -> bytes:
392
+ cleaned: dict[str, Any] = {}
393
+ for key, value in metadata.items():
394
+ if value is None:
395
+ continue
396
+ cleaned[key] = _normalise_value(value)
397
+ return json.dumps(cleaned, ensure_ascii=False).encode("utf-8")
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # Denoising model
402
+ # ---------------------------------------------------------------------------
403
+
404
+
405
+ class SpeechDenoisingProcessor:
406
+ """Run the TorchScripted feature extractor and decoder."""
407
+
408
+ def __init__(
409
+ self,
410
+ feature_extractor_path: str,
411
+ decoder_path: str,
412
+ device: str,
413
+ ) -> None:
414
+ self.device = torch.device(device)
415
+ self.feature_extractor = torch.jit.load(
416
+ feature_extractor_path, map_location=self.device
417
+ )
418
+ self.decoder = torch.jit.load(decoder_path, map_location=self.device)
419
+ self.feature_extractor.eval()
420
+ self.decoder.eval()
421
+
422
+ @torch.inference_mode()
423
+ def process(self, waveform: torch.Tensor, sample_rate: int) -> torch.Tensor:
424
+ return self.process_batch([waveform], [sample_rate])[0]
425
+
426
+ @torch.inference_mode()
427
+ def process_batch(
428
+ self,
429
+ waveforms: Sequence[torch.Tensor] | torch.Tensor,
430
+ sample_rates: Optional[Sequence[int]] = None,
431
+ expected_lengths: Optional[Sequence[int]] = None,
432
+ ) -> List[torch.Tensor]:
433
+ if expected_lengths is None:
434
+ expected_lengths: list[int] = []
435
+ for waveform, sample_rate in zip(waveforms, sample_rates):
436
+ duration_seconds = waveform.shape[-1] / float(sample_rate)
437
+ expected_lengths.append(
438
+ int(round(duration_seconds * SIDON_OUTPUT_SAMPLE_RATE))
439
+ )
440
+ waveforms = torch.nn.functional.pad(waveforms, (0, 24000))
441
+
442
+ features = extract_seamless_m4t_features(
443
+ [x for x in waveforms],
444
+ return_tensors="pt",
445
+ padding_value=1.0,
446
+ device=self.device,
447
+ )
448
+ feature_tensor = self.feature_extractor(
449
+ features["input_features"].to(self.device)
450
+ )["last_hidden_state"]
451
+ restored_waveforms = self.decoder(feature_tensor.transpose(1, 2)).cpu()
452
+
453
+ results: List[torch.Tensor] = []
454
+ for sample_idx, sample in enumerate(restored_waveforms):
455
+ restored_waveform = sample.view(-1)
456
+ target_length = expected_lengths[sample_idx]
457
+ current_length = restored_waveform.shape[-1]
458
+ if target_length > 0 and current_length != target_length:
459
+ diff = target_length - current_length
460
+ if diff > 0:
461
+ restored_waveform = torch.nn.functional.pad(
462
+ restored_waveform, (0, diff)
463
+ )
464
+ elif diff < 0:
465
+ restored_waveform = restored_waveform[:target_length]
466
+ results.append(restored_waveform.contiguous())
467
+
468
+ return results
469
+
470
+
471
+ # ---------------------------------------------------------------------------
472
+ # Batch collation
473
+ # ---------------------------------------------------------------------------
474
+
475
+
476
+ class CollateFunction:
477
+ """Collate a list of samples into a padded batch."""
478
+
479
+ def __init__(
480
+ self,
481
+ sample_rate: int,
482
+ skip_errors: bool,
483
+ ) -> None:
484
+ self.sample_rate = sample_rate
485
+ self.skip_errors = skip_errors
486
+
487
+ def __call__(self, samples: Sequence[dict[str, Any]]) -> CollatedBatch:
488
+ keys: list[str] = []
489
+ waveforms: list[torch.Tensor] = []
490
+ durations: list[float] = []
491
+ metadata: list[dict[str, Any]] = []
492
+
493
+ for sample in samples:
494
+ keys.append(sample["label"]["id"])
495
+ waveforms.append(sample["audio"].squeeze(0))
496
+ durations.append(sample["audio"].size(-1) / self.sample_rate)
497
+ metadata.append(sample["label"])
498
+ waveforms = torch.nn.utils.rnn.pad_sequence(waveforms, batch_first=True)
499
+
500
+ return CollatedBatch(
501
+ keys=keys, waveforms=waveforms, durations=durations, metadata=metadata
502
+ )
503
+
504
+
505
+ @dataclass
506
+ class CollatedBatch:
507
+ """Batch payload returned by the DataLoader collate function."""
508
+
509
+ keys: list[str]
510
+ waveforms: list[torch.Tensor]
511
+ durations: list[float]
512
+ metadata: list[dict[str, Any]]
513
+
514
+ @property
515
+ def size(self) -> int:
516
+ return len(self.keys)
517
+
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # Subprocess-based GPU worker pool
521
+ # ---------------------------------------------------------------------------
522
+ #
523
+ # Problem: PyTorch ≥2.8 caches CUDA device state at import time. Neither
524
+ # forkserver nor spawn lets us change CUDA_VISIBLE_DEVICES *before* the CUDA
525
+ # runtime captures the device list. The only reliable approach is to launch
526
+ # each worker as a **subprocess** with CUDA_VISIBLE_DEVICES set in the
527
+ # subprocess environment, guaranteeing it takes effect before `import torch`.
528
+ #
529
+ # Protocol (parent ↔ child, length-prefixed pickle over stdin/stdout):
530
+ # Parent → child: 4-byte LE uint32 length + pickle(CollatedBatch)
531
+ # Child → parent: 4-byte LE uint32 length + pickle(result dict)
532
+ # Shutdown signal: 4 zero bytes (length == 0)
533
+
534
+
535
+ def _subprocess_recv():
536
+ """Read a length-prefixed pickled object from stdin. Returns None on shutdown."""
537
+ raw = sys.stdin.buffer.read(4)
538
+ if len(raw) < 4:
539
+ return None
540
+ (length,) = struct.unpack("<I", raw)
541
+ if length == 0:
542
+ return None
543
+ data = sys.stdin.buffer.read(length)
544
+ return pickle.loads(data)
545
+
546
+
547
+ def _subprocess_send(obj):
548
+ """Send a pickled object with a 4-byte length prefix to stdout."""
549
+ data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
550
+ sys.stdout.buffer.write(struct.pack("<I", len(data)))
551
+ sys.stdout.buffer.write(data)
552
+ sys.stdout.buffer.flush()
553
+
554
+
555
+ def subprocess_worker_main():
556
+ """Entry point for a GPU worker subprocess.
557
+
558
+ Expected environment: CUDA_VISIBLE_DEVICES already set by the parent.
559
+ Receives initargs via stdin, then processes batches in a loop.
560
+ """
561
+ formatter = "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] [Worker PID %(process)d] %(message)s"
562
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
563
+
564
+ initargs = _subprocess_recv()
565
+ feature_extractor_path, decoder_path = initargs
566
+
567
+ device = "cpu"
568
+ if torch.cuda.is_available():
569
+ torch.cuda.set_device(0)
570
+ device = "cuda:0"
571
+ else:
572
+ logging.warning("CUDA not available in worker subprocess.")
573
+
574
+ logging.info(
575
+ f"Worker PID={os.getpid()}, "
576
+ f"CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES')}, device={device}"
577
+ )
578
+
579
+ processor = SpeechDenoisingProcessor(
580
+ feature_extractor_path=feature_extractor_path,
581
+ decoder_path=decoder_path,
582
+ device=device,
583
+ )
584
+
585
+ # Process batches until shutdown signal
586
+ while True:
587
+ msg = _subprocess_recv()
588
+ if msg is None:
589
+ break
590
+ req_id = msg["_req_id"]
591
+ batch = msg["_batch"]
592
+ try:
593
+ cleaned_waveforms = processor.process_batch(
594
+ batch.waveforms,
595
+ expected_lengths=[
596
+ round(d * SIDON_OUTPUT_SAMPLE_RATE) for d in batch.durations
597
+ ],
598
+ )
599
+ cleaned_cpu = [w.cpu() for w in cleaned_waveforms]
600
+ result = {
601
+ "_req_id": req_id,
602
+ "status": "success",
603
+ "keys": batch.keys,
604
+ "results": cleaned_cpu,
605
+ "metadata": batch.metadata,
606
+ "size": batch.size,
607
+ }
608
+ except Exception as e:
609
+ result = {
610
+ "_req_id": req_id,
611
+ "status": "error",
612
+ "keys": batch.keys,
613
+ "error": str(e),
614
+ "size": batch.size,
615
+ }
616
+ _subprocess_send(result)
617
+
618
+
619
+ class _GPUWorker:
620
+ """Handle to a single GPU worker subprocess."""
621
+
622
+ def __init__(self, physical_gpu_id, feature_extractor_path, decoder_path):
623
+ env = os.environ.copy()
624
+ if physical_gpu_id is not None:
625
+ env["CUDA_VISIBLE_DEVICES"] = str(physical_gpu_id)
626
+ self.proc = subprocess.Popen(
627
+ [
628
+ sys.executable,
629
+ "-m",
630
+ "omnivoice.scripts.denoise_audio",
631
+ "--_subprocess_worker",
632
+ ],
633
+ stdin=subprocess.PIPE,
634
+ stdout=subprocess.PIPE,
635
+ env=env,
636
+ )
637
+ # Send init args
638
+ init_data = pickle.dumps(
639
+ (feature_extractor_path, decoder_path), protocol=pickle.HIGHEST_PROTOCOL
640
+ )
641
+ self.proc.stdin.write(struct.pack("<I", len(init_data)))
642
+ self.proc.stdin.write(init_data)
643
+ self.proc.stdin.flush()
644
+ self._lock = threading.Lock()
645
+
646
+ def submit(self, batch_with_id):
647
+ """Send a batch dict (containing _req_id + _batch) for processing."""
648
+ with self._lock:
649
+ data = pickle.dumps(batch_with_id, protocol=pickle.HIGHEST_PROTOCOL)
650
+ self.proc.stdin.write(struct.pack("<I", len(data)))
651
+ self.proc.stdin.write(data)
652
+ self.proc.stdin.flush()
653
+
654
+ def read_result(self):
655
+ """Blocking read for one result."""
656
+ raw = self.proc.stdout.read(4)
657
+ if len(raw) < 4:
658
+ return None
659
+ (length,) = struct.unpack("<I", raw)
660
+ if length == 0:
661
+ return None
662
+ data = self.proc.stdout.read(length)
663
+ return pickle.loads(data)
664
+
665
+ def shutdown(self):
666
+ """Send shutdown signal and wait for process."""
667
+ try:
668
+ with self._lock:
669
+ self.proc.stdin.write(struct.pack("<I", 0))
670
+ self.proc.stdin.flush()
671
+ except Exception:
672
+ pass
673
+ self.proc.wait(timeout=30)
674
+
675
+
676
+ class GPUWorkerPool:
677
+ """Pool of GPU worker subprocesses with round-robin task submission."""
678
+
679
+ def __init__(self, pool_specs, feature_extractor_path, decoder_path):
680
+ """
681
+ Args:
682
+ pool_specs: list of (physical_gpu_id, num_workers) tuples.
683
+ feature_extractor_path: path to JIT feature extractor.
684
+ decoder_path: path to JIT decoder.
685
+ """
686
+ self.workers: list[_GPUWorker] = []
687
+ for physical_gpu_id, num_workers in pool_specs:
688
+ for _ in range(num_workers):
689
+ self.workers.append(
690
+ _GPUWorker(physical_gpu_id, feature_extractor_path, decoder_path)
691
+ )
692
+ self._rr = 0
693
+ self._futures: dict[int, Future] = {}
694
+ self._futures_lock = threading.Lock()
695
+ self._next_id = 0
696
+ # Start reader threads for each worker
697
+ self._reader_threads = []
698
+ for worker in self.workers:
699
+ t = threading.Thread(target=self._reader_loop, args=(worker,), daemon=True)
700
+ t.start()
701
+ self._reader_threads.append(t)
702
+
703
+ def _reader_loop(self, worker):
704
+ while True:
705
+ result = worker.read_result()
706
+ if result is None:
707
+ break
708
+ req_id = result.pop("_req_id", None)
709
+ with self._futures_lock:
710
+ fut = self._futures.pop(req_id, None)
711
+ if fut is not None:
712
+ fut.set_result(result)
713
+
714
+ def submit(self, batch) -> Future:
715
+ worker = self.workers[self._rr % len(self.workers)]
716
+ self._rr += 1
717
+ with self._futures_lock:
718
+ req_id = self._next_id
719
+ self._next_id += 1
720
+ fut = Future()
721
+ self._futures[req_id] = fut
722
+ batch_dict = {
723
+ "_req_id": req_id,
724
+ "_batch": batch,
725
+ }
726
+ worker.submit(batch_dict)
727
+ return fut
728
+
729
+ def shutdown(self):
730
+ for worker in self.workers:
731
+ worker.shutdown()
732
+ for t in self._reader_threads:
733
+ t.join(timeout=5)
734
+
735
+
736
+ # ---------------------------------------------------------------------------
737
+ # Main
738
+ # ---------------------------------------------------------------------------
739
+
740
+
741
+ def main() -> None:
742
+ formatter = "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s"
743
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
744
+ parser = build_parser()
745
+ args = parser.parse_args()
746
+
747
+ # ── Subprocess worker mode ──
748
+ if args._subprocess_worker:
749
+ subprocess_worker_main()
750
+ return
751
+
752
+ # Validate input arguments
753
+ assert args.tar_output_pattern is not None, "--tar_output_pattern is required."
754
+ assert args.jsonl_output_pattern is not None, "--jsonl_output_pattern is required."
755
+ assert bool(args.input_manifest) != bool(args.input_jsonl), (
756
+ "Exactly one of --input_manifest or --input_jsonl must be provided."
757
+ )
758
+
759
+ if args.num_machines > 1:
760
+ assert 0 <= args.machine_index < args.num_machines, (
761
+ f"machine_index {args.machine_index} must be in [0, {args.num_machines})"
762
+ )
763
+
764
+ # ── Build base dataset and count total samples ──
765
+ if args.input_jsonl:
766
+ logging.info(f"Input mode: raw JSONL ({args.input_jsonl})")
767
+ total_samples = count_lines(args.input_jsonl)
768
+ base_dataset = JsonlDatasetReader(
769
+ args.input_jsonl,
770
+ sample_rate=SIDON_INPUT_SAMPLE_RATE,
771
+ shuffle=args.shuffle,
772
+ shuffle_seed=args.shuffle_seed,
773
+ )
774
+ loader_workers = args.loader_workers
775
+ else:
776
+ logging.info(f"Input mode: WebDataset manifest ({args.input_manifest})")
777
+ manifest_num_lines = count_lines(args.input_manifest)
778
+ loader_workers = min(args.loader_workers, manifest_num_lines)
779
+ total_samples = 0
780
+ manifests = []
781
+ with open(args.input_manifest, "r", encoding="utf-8") as f:
782
+ for line_id, line in tqdm(
783
+ enumerate(f),
784
+ total=manifest_num_lines,
785
+ desc="Calculating dataset length",
786
+ ):
787
+ items = line.strip().split(" ")
788
+ tar_path, jsonl_path, num_items, duration = (
789
+ items[0],
790
+ items[1],
791
+ int(items[2]),
792
+ float(items[3]),
793
+ )
794
+ assert os.path.exists(tar_path), f"File {tar_path} does not exist."
795
+ assert os.path.exists(jsonl_path), f"File {jsonl_path} does not exist."
796
+ assert jsonl_path.endswith(".jsonl"), (
797
+ f"File {jsonl_path} is not a .jsonl file."
798
+ )
799
+ if (
800
+ args.num_machines > 1
801
+ and line_id % args.num_machines != args.machine_index
802
+ ):
803
+ continue
804
+ total_samples += num_items
805
+ manifests.append((tar_path, jsonl_path, num_items, duration))
806
+ logging.info(
807
+ f"Total shards: {manifest_num_lines}, "
808
+ f"Shards for current index: {len(manifests)}"
809
+ )
810
+ base_dataset = WebDatasetReader(
811
+ manifests=manifests,
812
+ sample_rate=SIDON_INPUT_SAMPLE_RATE,
813
+ evaluation=True,
814
+ )
815
+
816
+ # ── Dynamic batching + DataLoader ──
817
+ batched_dataset = StreamLengthGroupDataset(
818
+ dataset=base_dataset,
819
+ batch_duration=args.batch_duration,
820
+ max_sample=args.max_sample,
821
+ min_length=args.min_length,
822
+ max_length=args.max_length,
823
+ )
824
+
825
+ collate_fn = CollateFunction(
826
+ skip_errors=args.skip_errors,
827
+ sample_rate=SIDON_INPUT_SAMPLE_RATE,
828
+ )
829
+
830
+ dataloader = DataLoader(
831
+ dataset=batched_dataset,
832
+ batch_size=None,
833
+ collate_fn=collate_fn,
834
+ num_workers=loader_workers,
835
+ prefetch_factor=10 if loader_workers > 0 else None,
836
+ pin_memory=True,
837
+ persistent_workers=loader_workers > 0,
838
+ )
839
+
840
+ # ── Multi-GPU process pool ──
841
+ num_devices = torch.cuda.device_count()
842
+ if num_devices == 0:
843
+ logging.warning("No GPUs detected - using CPU for processing")
844
+ num_processes = args.nj_per_gpu
845
+ else:
846
+ num_processes = num_devices * args.nj_per_gpu
847
+ logging.info(
848
+ f"GPU count: {num_devices}, Processes per GPU: {args.nj_per_gpu}, "
849
+ f"Total processes: {num_processes}"
850
+ )
851
+
852
+ # Build a list of (physical_gpu_id, num_workers) for each pool.
853
+ # When num_devices == 0 we use a single CPU pool.
854
+ if num_devices == 0:
855
+ pool_specs = [(None, num_processes)]
856
+ else:
857
+ pool_specs = [(gpu_id, args.nj_per_gpu) for gpu_id in range(num_devices)]
858
+
859
+ # ── Output paths ──
860
+ tar_output_pattern = str(Path(args.tar_output_pattern).expanduser())
861
+ jsonl_output_pattern = str(Path(args.jsonl_output_pattern).expanduser())
862
+ Path(tar_output_pattern).parent.mkdir(parents=True, exist_ok=True)
863
+ Path(jsonl_output_pattern).parent.mkdir(parents=True, exist_ok=True)
864
+
865
+ output_dir = Path(tar_output_pattern).parent.parent
866
+ error_log_path = str(output_dir / "errors.jsonl")
867
+ manifest_path = str(output_dir / "data.lst")
868
+
869
+ error_logger = logging.getLogger("error_log")
870
+ error_logger.setLevel(logging.ERROR)
871
+ error_logger.handlers.clear()
872
+ error_fh = logging.FileHandler(error_log_path, mode="w", encoding="utf-8")
873
+ error_fh.setFormatter(logging.Formatter("%(message)s"))
874
+ error_logger.addHandler(error_fh)
875
+
876
+ # ── Progress and shard tracking ──
877
+ processed_count = 0
878
+ error_count = 0
879
+ write_error_count = 0
880
+ failed_ids = []
881
+ shard_idx = 0
882
+ shard_sample_count = 0
883
+ shard_duration = 0.0
884
+ samples_per_shard = args.samples_per_shard
885
+ shard_manifest = {}
886
+ target_sample_rate = args.target_sample_rate
887
+
888
+ tar_writer = None
889
+ jsonl_file = None
890
+
891
+ def open_new_shard():
892
+ nonlocal tar_writer, jsonl_file, shard_idx, shard_sample_count, shard_duration
893
+ if tar_writer is not None:
894
+ tar_writer.close()
895
+ if jsonl_file is not None:
896
+ jsonl_file.close()
897
+ if shard_idx > 0 and shard_sample_count > 0:
898
+ prev_idx = shard_idx - 1
899
+ shard_manifest[prev_idx] = (
900
+ os.path.abspath(tar_output_pattern % prev_idx),
901
+ os.path.abspath(jsonl_output_pattern % prev_idx),
902
+ shard_sample_count,
903
+ shard_duration,
904
+ )
905
+ tar_fname = tar_output_pattern % shard_idx
906
+ jsonl_fname = jsonl_output_pattern % shard_idx
907
+ tar_writer = wds.TarWriter(tar_fname)
908
+ jsonl_file = open(jsonl_fname, "w", encoding="utf-8")
909
+ shard_idx += 1
910
+ shard_sample_count = 0
911
+ shard_duration = 0.0
912
+
913
+ def write_sample(key, waveform, metadata):
914
+ nonlocal shard_sample_count, write_error_count, shard_duration
915
+ assert tar_writer is not None and jsonl_file is not None
916
+ try:
917
+ if target_sample_rate != SIDON_OUTPUT_SAMPLE_RATE:
918
+ waveform = torchaudio.functional.resample(
919
+ waveform,
920
+ orig_freq=SIDON_OUTPUT_SAMPLE_RATE,
921
+ new_freq=target_sample_rate,
922
+ )
923
+ waveform = (waveform / (waveform.abs().max() + 1e-7)) * 0.6
924
+
925
+ record = serialise_flac(key, waveform, target_sample_rate)
926
+ jsonl_record = _encode_metadata(metadata)
927
+ tar_writer.write(record)
928
+ jsonl_file.write(jsonl_record.decode("utf-8") + "\n")
929
+ shard_sample_count += 1
930
+ shard_duration += metadata.get("audio_duration", 0.0)
931
+ except Exception as exc:
932
+ write_error_count += 1
933
+ failed_ids.append(key)
934
+ error_logger.error(
935
+ json.dumps({"id": key, "reason": str(exc)}, ensure_ascii=False)
936
+ )
937
+ logging.error(f"Write failed for sample {key}: {exc}")
938
+
939
+ def handle_result(result):
940
+ nonlocal processed_count, error_count
941
+ if result["status"] == "success":
942
+ for key, cleaned, metadata in zip(
943
+ result["keys"], result["results"], result["metadata"]
944
+ ):
945
+ if tar_writer is None or shard_sample_count >= samples_per_shard:
946
+ open_new_shard()
947
+ write_sample(key, cleaned, metadata)
948
+ processed_count += 1
949
+ else:
950
+ error_count += result["size"]
951
+ failed_ids.extend(result["keys"])
952
+ for key in result["keys"]:
953
+ error_logger.error(
954
+ json.dumps(
955
+ {"id": key, "reason": result["error"]},
956
+ ensure_ascii=False,
957
+ )
958
+ )
959
+ if not args.skip_errors:
960
+ raise RuntimeError(
961
+ f"Batch starting with {result['keys'][0]} failed - terminating"
962
+ )
963
+ logging.warning(
964
+ f"Skipping failed batch starting with {result['keys'][0]}: "
965
+ f"{result['error']}"
966
+ )
967
+
968
+ # ── Main processing loop ──
969
+ main_progress = tqdm(total=total_samples, desc="Denoising Audio")
970
+
971
+ # Launch subprocess-based GPU workers. CUDA_VISIBLE_DEVICES is set in the
972
+ # subprocess Popen environment so it takes effect before import torch.
973
+ pool = GPUWorkerPool(pool_specs, args.feature_extractor_path, args.decoder_path)
974
+ logging.info(f"Submitting tasks... ({num_processes} subprocess workers)")
975
+ try:
976
+ futures = set()
977
+ max_pending = num_processes * 2
978
+
979
+ def drain_completed():
980
+ nonlocal futures
981
+ done, _ = wait(futures, return_when=FIRST_COMPLETED)
982
+ for f in done:
983
+ futures.discard(f)
984
+ result = f.result()
985
+ main_progress.update(result["size"])
986
+ handle_result(result)
987
+ main_progress.set_postfix(
988
+ OK=processed_count,
989
+ Err=error_count,
990
+ )
991
+
992
+ for batch in dataloader:
993
+ if batch.size == 0:
994
+ continue
995
+ if len(futures) >= max_pending:
996
+ drain_completed()
997
+ futures.add(pool.submit(batch))
998
+
999
+ logging.info("Processing remaining pending batches...")
1000
+ while futures:
1001
+ drain_completed()
1002
+
1003
+ except Exception:
1004
+ logging.error("Critical error during processing", exc_info=True)
1005
+ raise
1006
+ finally:
1007
+ pool.shutdown()
1008
+ main_progress.close()
1009
+ if tar_writer is not None:
1010
+ tar_writer.close()
1011
+ if jsonl_file is not None:
1012
+ jsonl_file.close()
1013
+ if shard_idx > 0 and shard_sample_count > 0:
1014
+ last_idx = shard_idx - 1
1015
+ shard_manifest[last_idx] = (
1016
+ os.path.abspath(tar_output_pattern % last_idx),
1017
+ os.path.abspath(jsonl_output_pattern % last_idx),
1018
+ shard_sample_count,
1019
+ shard_duration,
1020
+ )
1021
+
1022
+ # ── Write manifest (data.lst) ──
1023
+ with open(manifest_path, "w", encoding="utf-8") as mf:
1024
+ for idx in sorted(shard_manifest.keys()):
1025
+ tar_path, jsonl_path, count, duration = shard_manifest[idx]
1026
+ mf.write(f"{tar_path} {jsonl_path} {count} {duration:.3f}\n")
1027
+
1028
+ # ── Summary ──
1029
+ total_failed = error_count + write_error_count
1030
+ filtered_and_skipped = total_samples - processed_count - total_failed
1031
+ logging.info(
1032
+ f"Processing Complete - Successful: {processed_count}, Failed: {total_failed}, "
1033
+ f"Filtered/Skipped: {filtered_and_skipped}, Shards written: {shard_idx}"
1034
+ )
1035
+ logging.info(f"Manifest written to: {manifest_path} ({len(shard_manifest)} shards)")
1036
+ if total_failed > 0:
1037
+ logging.info(f"Error details: {error_log_path}")
1038
+ if failed_ids and args.skip_errors:
1039
+ logging.warning(
1040
+ f"Failed sample IDs (count: {len(failed_ids)}): {failed_ids[:100]}..."
1041
+ )
1042
+ if write_error_count > 0 and not args.skip_errors:
1043
+ raise RuntimeError(
1044
+ f"{write_error_count} samples failed to write - check logs for details"
1045
+ )
1046
+
1047
+
1048
+ if __name__ == "__main__":
1049
+ main()
omnivoice/scripts/extract_audio_tokens.py ADDED
@@ -0,0 +1,625 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ Extract audio tokens from audio data and pack them into WebDataset shards.
20
+
21
+ Supports two input modes:
22
+
23
+ 1. WebDataset manifest (data.lst):
24
+ python extract_audio_tokens.py \
25
+ --input_manifest data.lst \
26
+ --tar_output_pattern output/audios/shard-%06d.tar \
27
+ --jsonl_output_pattern output/txts/shard-%06d.jsonl
28
+
29
+ 2. Raw JSONL (each line: {"id": "...", "audio_path": "...", "text": "...", ...}):
30
+ python extract_audio_tokens.py \
31
+ --input_jsonl data.jsonl \
32
+ --tar_output_pattern output/audios/shard-%06d.tar \
33
+ --jsonl_output_pattern output/txts/shard-%06d.jsonl
34
+
35
+ Output structure:
36
+ output_dir/
37
+ ├── audios/ # WebDataset tar shards (.npy audio tokens + .json metadata)
38
+ │ ├── shard_000000.tar
39
+ │ └── ...
40
+ ├── txts/ # Per-shard JSONL metadata
41
+ │ ├── shard_000000.jsonl
42
+ │ └── ...
43
+ ├── data.lst # Manifest: <tar_path> <jsonl_path> <sample_count> <total_duration>
44
+ └── errors.jsonl # Failed samples with error details
45
+ """
46
+
47
+ import argparse
48
+ import io
49
+ import json
50
+ import logging
51
+ import multiprocessing as mp
52
+ import os
53
+ import warnings
54
+ from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, wait
55
+ from pathlib import Path
56
+ from typing import Any
57
+
58
+ import numpy as np
59
+ import torch
60
+ import webdataset as wds
61
+ from torch.utils.data import DataLoader, IterableDataset
62
+ from tqdm.auto import tqdm
63
+ from transformers import AutoFeatureExtractor, HiggsAudioV2TokenizerModel
64
+
65
+ from omnivoice.data.dataset import JsonlDatasetReader, WebDatasetReader
66
+ from omnivoice.utils.common import str2bool
67
+
68
+ warnings.filterwarnings(
69
+ "ignore", category=FutureWarning, module="torch.nn.utils.weight_norm"
70
+ )
71
+
72
+ HIGGS_INPUT_SAMPLE_RATE = 24_000
73
+
74
+
75
+ # Global variables: Store tokenizer and device for each worker process
76
+ worker_tokenizer = None
77
+ worker_feature_extractor = None
78
+
79
+
80
+ def build_parser() -> argparse.ArgumentParser:
81
+ parser = argparse.ArgumentParser(description=__doc__)
82
+ parser.add_argument(
83
+ "--input_manifest",
84
+ default=None,
85
+ help="Path to input dataset manifest (data.lst).",
86
+ )
87
+ parser.add_argument(
88
+ "--input_jsonl",
89
+ default=None,
90
+ help="Path to raw JSONL file (alternative to --input_manifest).",
91
+ )
92
+ parser.add_argument(
93
+ "--tar_output_pattern",
94
+ required=True,
95
+ help="Tar shard pattern passed to WebDataset",
96
+ )
97
+ parser.add_argument(
98
+ "--jsonl_output_pattern",
99
+ required=True,
100
+ help="Jsonl shard pattern passed to WebDataset",
101
+ )
102
+ parser.add_argument(
103
+ "--samples_per_shard",
104
+ type=int,
105
+ default=1000,
106
+ help="Maximum records per shard",
107
+ )
108
+ parser.add_argument(
109
+ "--min_num_shards",
110
+ type=int,
111
+ default=32,
112
+ help="Minimum number of output shards (use to ensure "
113
+ "shard count >= num_gpu * num_workers)",
114
+ )
115
+ parser.add_argument(
116
+ "--tokenizer_path",
117
+ type=str,
118
+ default="eustlb/higgs-audio-v2-tokenizer",
119
+ help="Path to audio tokenizer.",
120
+ )
121
+ parser.add_argument(
122
+ "--skip_errors", action="store_true", help="Skip items that fail to process"
123
+ )
124
+ parser.add_argument(
125
+ "--min_length",
126
+ type=float,
127
+ default=0.0,
128
+ help="Minimum audio duration in seconds (e.g. 2.0)",
129
+ )
130
+ parser.add_argument(
131
+ "--max_length",
132
+ type=float,
133
+ default=float("inf"),
134
+ help="Maximum audio duration in seconds (e.g. 15.0)",
135
+ )
136
+ parser.add_argument(
137
+ "--num_machines",
138
+ type=int,
139
+ default=1,
140
+ help="Total number of machines for distributed runs",
141
+ )
142
+ parser.add_argument(
143
+ "--machine_index",
144
+ type=int,
145
+ default=0,
146
+ help="Zero-based machine index when distributing across multiple "
147
+ "machines (e.g. 0, 1, ... num_machines-1)",
148
+ )
149
+ parser.add_argument(
150
+ "--nj_per_gpu",
151
+ type=int,
152
+ default=3,
153
+ help="Number of worker processes to spawn per GPU.",
154
+ )
155
+ parser.add_argument(
156
+ "--loader_workers",
157
+ type=int,
158
+ default=24,
159
+ help="Number of DataLoader workers for streaming IterableDataset.",
160
+ )
161
+ parser.add_argument(
162
+ "--shuffle",
163
+ type=str2bool,
164
+ default=True,
165
+ help="Shuffle data by default.",
166
+ )
167
+ parser.add_argument(
168
+ "--shuffle-seed",
169
+ type=int,
170
+ default=42,
171
+ help="Random seed for shuffle (default: 42).",
172
+ )
173
+ return parser
174
+
175
+
176
+ def count_lines(path):
177
+ with open(path, "rb") as f:
178
+ return sum(buf.count(b"\n") for buf in iter(lambda: f.read(1 << 20), b""))
179
+
180
+
181
+ def serialise_numpy(key: str, tokens: np.ndarray) -> dict:
182
+ buffer = io.BytesIO()
183
+ np.save(buffer, tokens)
184
+ return {"__key__": key, "npy": buffer.getvalue()}
185
+
186
+
187
+ def process_init(rank_queue, tokenizer_path):
188
+ """
189
+ Initialization function for each worker process.
190
+ Assigns a specific GPU to the process and loads the tokenizer.
191
+ """
192
+ global worker_tokenizer, worker_feature_extractor
193
+
194
+ # Configure worker process logging
195
+ formatter = (
196
+ "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d]"
197
+ " [Worker %(process)d] %(message)s"
198
+ )
199
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
200
+
201
+ # Get assigned GPU rank
202
+ rank = rank_queue.get()
203
+ # Determine device
204
+ if rank != -1 and torch.cuda.is_available():
205
+ worker_device = torch.device(f"cuda:{rank}")
206
+ else:
207
+ worker_device = torch.device("cpu")
208
+
209
+ logging.debug(f"Worker process initialized with device: {worker_device}")
210
+ # Load tokenizer onto the specified device
211
+ worker_feature_extractor = AutoFeatureExtractor.from_pretrained(tokenizer_path)
212
+ worker_tokenizer = HiggsAudioV2TokenizerModel.from_pretrained(
213
+ tokenizer_path, device_map=worker_device
214
+ )
215
+ logging.debug(f"Tokenizer loaded successfully on device {worker_device}")
216
+
217
+
218
+ def process_single_sample(sample: dict[str, Any]) -> dict[str, Any]:
219
+ """
220
+ Single-sample processing function executed in worker processes.
221
+ Skips invalid samples during streaming processing.
222
+ """
223
+ try:
224
+ audio_tensor = sample.get("audio", None) # shape (1, T)
225
+ if audio_tensor is None:
226
+ raise ValueError("Sample missing 'audio' field")
227
+
228
+ with torch.inference_mode():
229
+ key = sample["label"]["id"]
230
+ inputs = worker_feature_extractor(
231
+ raw_audio=audio_tensor.squeeze(0).numpy(),
232
+ sampling_rate=HIGGS_INPUT_SAMPLE_RATE,
233
+ return_tensors="pt",
234
+ ).to(worker_tokenizer.device)
235
+ audio_tokens = worker_tokenizer.encode(
236
+ inputs["input_values"],
237
+ ).audio_codes.squeeze(0)
238
+
239
+ assert len(audio_tokens.shape) == 2
240
+ assert audio_tokens.size(0) == 8
241
+
242
+ num_tokens = audio_tokens.size(1)
243
+ metadata = sample["label"]
244
+ metadata["num_tokens"] = num_tokens
245
+
246
+ # Convert to numpy format for subsequent serialization (int16 to save space)
247
+ audio_tokens_np = audio_tokens.to(torch.int16).cpu().numpy()
248
+
249
+ return {
250
+ "status": "success",
251
+ "key": key,
252
+ "audio_tokens": audio_tokens_np,
253
+ "metadata": metadata,
254
+ "error_msg": None,
255
+ }
256
+ except Exception as e:
257
+ sample_id = sample.get("label", {}).get("id", "unknown")
258
+ logging.error(f"Failed to process sample {sample_id}: {e}")
259
+ return {
260
+ "status": "error",
261
+ "key": sample_id,
262
+ "audio_tokens": None,
263
+ "metadata": None,
264
+ "error_msg": str(e),
265
+ }
266
+
267
+
268
+ def _normalise_value(value: Any) -> Any:
269
+ """Convert tensors and NumPy scalars to serialisable Python objects."""
270
+ if isinstance(value, torch.Tensor):
271
+ if value.ndim == 0:
272
+ return value.item()
273
+ return value.cpu().tolist()
274
+ if isinstance(value, np.generic):
275
+ return value.item()
276
+ if isinstance(value, np.ndarray):
277
+ return value.tolist()
278
+ return value
279
+
280
+
281
+ def _encode_metadata(metadata: dict[str, Any]) -> bytes:
282
+ cleaned: dict[str, Any] = {}
283
+ for key, value in metadata.items():
284
+ if value is None:
285
+ continue
286
+ cleaned[key] = _normalise_value(value)
287
+ return json.dumps(cleaned, ensure_ascii=False).encode("utf-8")
288
+
289
+
290
+ class StreamingLengthFilteredDataset(IterableDataset):
291
+ def __init__(
292
+ self,
293
+ base_iterable,
294
+ min_len: float,
295
+ max_len: float,
296
+ sr: int,
297
+ ):
298
+ self.base_iterable = base_iterable
299
+ self.min_len = min_len
300
+ self.max_len = max_len
301
+ self.sr = sr
302
+ self.filtered_count = 0
303
+
304
+ def __iter__(self):
305
+ """Stream samples one by one and filter on the fly."""
306
+ for sample in self.base_iterable:
307
+ try:
308
+ duration = sample["audio"].size(-1) / self.sr
309
+ if self.min_len <= duration <= self.max_len:
310
+ yield sample
311
+ else:
312
+ self.filtered_count += 1
313
+ logging.warning(
314
+ f"Filtered sample (duration out of range): "
315
+ f"{sample['label']['id']} ({duration:.2f}s)"
316
+ )
317
+ except Exception as e:
318
+ logging.warning(f"Skipped invalid sample during streaming: {e}")
319
+ continue
320
+
321
+
322
+ def main() -> None:
323
+ formatter = "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s"
324
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
325
+ parser = build_parser()
326
+ args = parser.parse_args()
327
+ mp.set_start_method("spawn", force=True)
328
+
329
+ # Validate input arguments
330
+ assert bool(args.input_manifest) != bool(args.input_jsonl), (
331
+ "Exactly one of --input_manifest or --input_jsonl must be provided."
332
+ )
333
+
334
+ if args.num_machines > 1:
335
+ assert 0 <= args.machine_index < args.num_machines, (
336
+ f"machine_index {args.machine_index} must be in [0, {args.num_machines})"
337
+ )
338
+
339
+ # Build base dataset and count total samples based on input mode
340
+ if args.input_jsonl:
341
+ logging.info(f"Input mode: raw JSONL ({args.input_jsonl})")
342
+ total_samples = count_lines(args.input_jsonl)
343
+ base_dataset = JsonlDatasetReader(
344
+ args.input_jsonl,
345
+ sample_rate=HIGGS_INPUT_SAMPLE_RATE,
346
+ shuffle=args.shuffle,
347
+ shuffle_seed=args.shuffle_seed,
348
+ )
349
+ loader_workers = args.loader_workers
350
+ else:
351
+ logging.info(f"Input mode: WebDataset manifest ({args.input_manifest})")
352
+ manifest_num_lines = count_lines(args.input_manifest)
353
+ loader_workers = min(args.loader_workers, manifest_num_lines)
354
+ total_samples = 0
355
+ manifests = []
356
+ with open(args.input_manifest, "r", encoding="utf-8") as f:
357
+ for line_id, line in tqdm(
358
+ enumerate(f),
359
+ total=manifest_num_lines,
360
+ desc="Calculating dataset length",
361
+ ):
362
+ items = line.strip().split(" ")
363
+ tar_path, jsonl_path, num_items, duration = (
364
+ items[0],
365
+ items[1],
366
+ int(items[2]),
367
+ float(items[3]),
368
+ )
369
+ assert os.path.exists(tar_path), f"File {tar_path} does not exist."
370
+ assert os.path.exists(jsonl_path), f"File {jsonl_path} does not exist."
371
+ assert jsonl_path.endswith(".jsonl"), (
372
+ f"File {jsonl_path} is not a .jsonl file."
373
+ )
374
+ if (
375
+ args.num_machines > 1
376
+ and line_id % args.num_machines != args.machine_index
377
+ ):
378
+ continue
379
+ total_samples += num_items
380
+ manifests.append((tar_path, jsonl_path, num_items, duration))
381
+ logging.info(
382
+ f"Total shards: {manifest_num_lines}, "
383
+ f"Shards for current index: {len(manifests)}"
384
+ )
385
+ base_dataset = WebDatasetReader(
386
+ manifests=manifests,
387
+ sample_rate=HIGGS_INPUT_SAMPLE_RATE,
388
+ evaluation=True,
389
+ )
390
+
391
+ # Adjust samples_per_shard if min_num_shards would be violated
392
+ samples_per_shard = args.samples_per_shard
393
+ if total_samples > 0:
394
+ estimated_shards = max(
395
+ 1, (total_samples + samples_per_shard - 1) // samples_per_shard
396
+ )
397
+ if estimated_shards < args.min_num_shards:
398
+ samples_per_shard = max(1, total_samples // args.min_num_shards)
399
+ logging.info(
400
+ f"Adjusted samples_per_shard from {args.samples_per_shard} to "
401
+ f"{samples_per_shard} to meet min_num_shards={args.min_num_shards} "
402
+ f"(total_samples={total_samples})"
403
+ )
404
+
405
+ # Apply length filter and create DataLoader
406
+ filtered_dataset = StreamingLengthFilteredDataset(
407
+ base_iterable=base_dataset,
408
+ min_len=args.min_length,
409
+ max_len=args.max_length,
410
+ sr=HIGGS_INPUT_SAMPLE_RATE,
411
+ )
412
+ dataloader = DataLoader(
413
+ dataset=filtered_dataset,
414
+ batch_size=None,
415
+ num_workers=loader_workers,
416
+ persistent_workers=loader_workers > 0,
417
+ pin_memory=False,
418
+ )
419
+
420
+ # Configure multi-GPU multi-process setup
421
+ num_devices = torch.cuda.device_count()
422
+ if num_devices == 0:
423
+ logging.warning("No GPUs detected - using CPU for processing")
424
+ num_processes = args.nj_per_gpu
425
+ else:
426
+ num_processes = num_devices * args.nj_per_gpu
427
+ logging.info(
428
+ f"GPU count: {num_devices}, Processes per GPU: {args.nj_per_gpu}, "
429
+ f"Total processes: {num_processes}"
430
+ )
431
+
432
+ # Shared GPU rank queue for process assignment
433
+ manager = mp.Manager()
434
+ rank_queue = manager.Queue()
435
+ for rank in list(range(num_devices)) * args.nj_per_gpu:
436
+ rank_queue.put(rank)
437
+ if num_devices == 0:
438
+ for _ in range(num_processes):
439
+ rank_queue.put(-1)
440
+
441
+ # Prepare output paths
442
+ tar_output_pattern = str(Path(args.tar_output_pattern).expanduser())
443
+ jsonl_output_pattern = str(Path(args.jsonl_output_pattern).expanduser())
444
+ Path(tar_output_pattern).parent.mkdir(parents=True, exist_ok=True)
445
+ Path(jsonl_output_pattern).parent.mkdir(parents=True, exist_ok=True)
446
+
447
+ # Determine output directory from tar_output_pattern
448
+ output_dir = Path(tar_output_pattern).parent.parent
449
+ error_log_path = str(output_dir / "errors.jsonl")
450
+ manifest_path = str(output_dir / "data.lst")
451
+
452
+ # Setup error logger (writes to errors.jsonl)
453
+ error_logger = logging.getLogger("error_log")
454
+ error_logger.setLevel(logging.ERROR)
455
+ error_logger.handlers.clear()
456
+ error_fh = logging.FileHandler(error_log_path, mode="w", encoding="utf-8")
457
+ error_fh.setFormatter(logging.Formatter("%(message)s"))
458
+ error_logger.addHandler(error_fh)
459
+
460
+ # Progress and error tracking
461
+ processed_count = 0
462
+ error_count = 0
463
+ write_error_count = 0
464
+ failed_ids = []
465
+ shard_idx = 0
466
+ shard_sample_count = 0
467
+ shard_duration = 0.0
468
+ shard_manifest = {} # shard_idx -> (tar_path, jsonl_path, count, duration)
469
+
470
+ tar_writer = None
471
+ jsonl_file = None
472
+
473
+ def open_new_shard():
474
+ nonlocal tar_writer, jsonl_file, shard_idx, shard_sample_count, shard_duration
475
+ if tar_writer is not None:
476
+ tar_writer.close()
477
+ if jsonl_file is not None:
478
+ jsonl_file.close()
479
+ # Record manifest for the previous shard
480
+ if shard_idx > 0 and shard_sample_count > 0:
481
+ prev_idx = shard_idx - 1
482
+ shard_manifest[prev_idx] = (
483
+ os.path.abspath(tar_output_pattern % prev_idx),
484
+ os.path.abspath(jsonl_output_pattern % prev_idx),
485
+ shard_sample_count,
486
+ shard_duration,
487
+ )
488
+ tar_fname = tar_output_pattern % shard_idx
489
+ jsonl_fname = jsonl_output_pattern % shard_idx
490
+ tar_writer = wds.TarWriter(tar_fname)
491
+ jsonl_file = open(jsonl_fname, "w", encoding="utf-8")
492
+ shard_idx += 1
493
+ shard_sample_count = 0
494
+ shard_duration = 0.0
495
+
496
+ def write_sample(key, audio_tokens_np, metadata):
497
+ nonlocal shard_sample_count, write_error_count, shard_duration
498
+ assert tar_writer is not None and jsonl_file is not None
499
+ try:
500
+ token_record = serialise_numpy(key, audio_tokens_np)
501
+ json_record = _encode_metadata(metadata)
502
+ tar_writer.write(token_record)
503
+ jsonl_file.write(json_record.decode("utf-8") + "\n")
504
+ shard_sample_count += 1
505
+ shard_duration += metadata.get("audio_duration", 0.0)
506
+ except Exception as exc:
507
+ write_error_count += 1
508
+ failed_ids.append(key)
509
+ error_logger.error(
510
+ json.dumps({"id": key, "reason": str(exc)}, ensure_ascii=False)
511
+ )
512
+ logging.error(f"Write failed for sample {key}: {exc}")
513
+
514
+ def handle_result(result):
515
+ nonlocal processed_count, error_count
516
+ if result["status"] == "success":
517
+ # Rotate shard if needed
518
+ if tar_writer is None or shard_sample_count >= samples_per_shard:
519
+ open_new_shard()
520
+ write_sample(result["key"], result["audio_tokens"], result["metadata"])
521
+ processed_count += 1
522
+ else:
523
+ error_count += 1
524
+ failed_ids.append(result["key"])
525
+ error_logger.error(
526
+ json.dumps(
527
+ {"id": result["key"], "reason": result["error_msg"]},
528
+ ensure_ascii=False,
529
+ )
530
+ )
531
+ if not args.skip_errors:
532
+ raise RuntimeError(
533
+ f"Sample {result['key']} processing failed due "
534
+ f"to {result['error_msg']} - terminating"
535
+ )
536
+ logging.warning(
537
+ f"Skipping failed sample {result['key']}: {result['error_msg']}"
538
+ )
539
+
540
+ main_progress = tqdm(total=total_samples, desc="Extracting Audio Tokens")
541
+
542
+ try:
543
+ with ProcessPoolExecutor(
544
+ max_workers=num_processes,
545
+ initializer=process_init,
546
+ initargs=(rank_queue, args.tokenizer_path),
547
+ ) as executor:
548
+ logging.info(f"Submitting tasks... ({num_processes} workers)")
549
+ futures = set()
550
+ max_pending = num_processes * 10
551
+
552
+ def drain_completed():
553
+ """Wait for at least one future to complete, process all done."""
554
+ nonlocal futures
555
+ done, _ = wait(futures, return_when=FIRST_COMPLETED)
556
+ for f in done:
557
+ futures.discard(f)
558
+ result = f.result()
559
+ main_progress.update(1)
560
+ handle_result(result)
561
+ main_progress.set_postfix(
562
+ Samples=processed_count,
563
+ Errors=error_count,
564
+ )
565
+
566
+ # Stream samples from DataLoader
567
+ for sample in dataloader:
568
+ if len(futures) >= max_pending:
569
+ drain_completed()
570
+
571
+ future = executor.submit(process_single_sample, sample)
572
+ futures.add(future)
573
+
574
+ # Process remaining futures
575
+ logging.info("Processing remaining pending samples...")
576
+ while futures:
577
+ drain_completed()
578
+
579
+ except Exception:
580
+ logging.error("Critical error during processing", exc_info=True)
581
+ raise
582
+ finally:
583
+ main_progress.close()
584
+ if tar_writer is not None:
585
+ tar_writer.close()
586
+ if jsonl_file is not None:
587
+ jsonl_file.close()
588
+ # Record the last shard in the manifest
589
+ if shard_idx > 0 and shard_sample_count > 0:
590
+ last_idx = shard_idx - 1
591
+ shard_manifest[last_idx] = (
592
+ os.path.abspath(tar_output_pattern % last_idx),
593
+ os.path.abspath(jsonl_output_pattern % last_idx),
594
+ shard_sample_count,
595
+ shard_duration,
596
+ )
597
+
598
+ # Write manifest file (data.lst)
599
+ with open(manifest_path, "w", encoding="utf-8") as mf:
600
+ for idx in sorted(shard_manifest.keys()):
601
+ tar_path, jsonl_path, count, duration = shard_manifest[idx]
602
+ mf.write(f"{tar_path} {jsonl_path} {count} {duration:.3f}\n")
603
+
604
+ # Output final statistics
605
+ total_failed = error_count + write_error_count
606
+ filtered_and_skipped = total_samples - processed_count - total_failed
607
+ logging.info(
608
+ f"Processing Complete - Successful: {processed_count}, Failed: {total_failed}, "
609
+ f"Filtered/Skipped: {filtered_and_skipped}, Shards written: {shard_idx}"
610
+ )
611
+ logging.info(f"Manifest written to: {manifest_path} ({len(shard_manifest)} shards)")
612
+ if total_failed > 0:
613
+ logging.info(f"Error details: {error_log_path}")
614
+ if failed_ids and args.skip_errors:
615
+ logging.warning(
616
+ f"Failed sample IDs (count: {len(failed_ids)}): {failed_ids[:100]}..."
617
+ )
618
+ if write_error_count > 0 and not args.skip_errors:
619
+ raise RuntimeError(
620
+ f"{write_error_count} samples failed to write - check logs for details"
621
+ )
622
+
623
+
624
+ if __name__ == "__main__":
625
+ main()
omnivoice/scripts/extract_audio_tokens_add_noise.py ADDED
@@ -0,0 +1,823 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ Extract audio tokens from audio data and pack them into WebDataset shards.
20
+
21
+ Extends ``extract_audio_tokens.py`` with optional noise and reverberation
22
+ augmentation on the prompt (reference) portion of the audio. Requires a
23
+ noise manifest and/or RIR manifest.
24
+
25
+ Supports two input modes:
26
+
27
+ 1. WebDataset manifest (data.lst):
28
+ python extract_audio_tokens_add_noise.py \\
29
+ --input_manifest data.lst \\
30
+ --noise_manifest noise.lst \\
31
+ --tar_output_pattern output/audios/shard-%06d.tar \\
32
+ --jsonl_output_pattern output/txts/shard-%06d.jsonl
33
+
34
+ 2. Raw JSONL (each line: {"id": "...", "audio_path": "...", "text": "...", ...}):
35
+ python extract_audio_tokens_add_noise.py \\
36
+ --input_jsonl data.jsonl \\
37
+ --noise_manifest noise.lst \\
38
+ --tar_output_pattern output/audios/shard-%06d.tar \\
39
+ --jsonl_output_pattern output/txts/shard-%06d.jsonl
40
+
41
+ Output structure:
42
+ output_dir/
43
+ ├── audios/ # WebDataset tar shards (.npy audio tokens + .json metadata)
44
+ │ ├── shard_000000.tar
45
+ │ └── ...
46
+ ├── txts/ # Per-shard JSONL metadata
47
+ │ ├── shard_000000.jsonl
48
+ │ └── ...
49
+ ├── data.lst # Manifest: <tar_path> <jsonl_path> <sample_count> <total_duration>
50
+ └── errors.jsonl # Failed samples with error details
51
+ """
52
+
53
+ import argparse
54
+ import io
55
+ import json
56
+ import logging
57
+ import math
58
+ import multiprocessing as mp
59
+ import os
60
+ import random
61
+ import warnings
62
+ from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, wait
63
+ from pathlib import Path
64
+ from typing import Any
65
+
66
+ import numpy as np
67
+ import torch
68
+ import torch.nn.functional as F
69
+ import webdataset as wds
70
+ from torch.utils.data import DataLoader, IterableDataset
71
+ from tqdm.auto import tqdm
72
+ from transformers import AutoFeatureExtractor, HiggsAudioV2TokenizerModel
73
+
74
+ from omnivoice.data.dataset import JsonlDatasetReader, WebDatasetReader
75
+ from omnivoice.utils.audio import load_audio_bytes
76
+ from omnivoice.utils.common import str2bool
77
+
78
+ warnings.filterwarnings(
79
+ "ignore", category=FutureWarning, module="torch.nn.utils.weight_norm"
80
+ )
81
+
82
+ HIGGS_INPUT_SAMPLE_RATE = 24_000
83
+
84
+ # Global variables: Store tokenizer and device for each worker process
85
+ worker_tokenizer = None
86
+ worker_feature_extractor = None
87
+ worker_noise_sampler = None
88
+ worker_rir_sampler = None
89
+
90
+
91
+ def build_parser() -> argparse.ArgumentParser:
92
+ parser = argparse.ArgumentParser(description=__doc__)
93
+ parser.add_argument(
94
+ "--input_manifest",
95
+ default=None,
96
+ help="Path to input dataset manifest (data.lst).",
97
+ )
98
+ parser.add_argument(
99
+ "--input_jsonl",
100
+ default=None,
101
+ help="Path to raw JSONL file (alternative to --input_manifest).",
102
+ )
103
+ parser.add_argument(
104
+ "--tar_output_pattern",
105
+ required=True,
106
+ help="Tar shard pattern passed to WebDataset",
107
+ )
108
+ parser.add_argument(
109
+ "--jsonl_output_pattern",
110
+ required=True,
111
+ help="Jsonl shard pattern passed to WebDataset",
112
+ )
113
+ parser.add_argument(
114
+ "--samples_per_shard",
115
+ type=int,
116
+ default=1000,
117
+ help="Maximum records per shard",
118
+ )
119
+ parser.add_argument(
120
+ "--min_num_shards",
121
+ type=int,
122
+ default=32,
123
+ help="Minimum number of output shards (use to ensure "
124
+ "shard count >= num_gpu * num_workers)",
125
+ )
126
+ parser.add_argument(
127
+ "--tokenizer_path",
128
+ type=str,
129
+ default="eustlb/higgs-audio-v2-tokenizer",
130
+ help="Path to audio tokenizer.",
131
+ )
132
+ parser.add_argument(
133
+ "--skip_errors", action="store_true", help="Skip items that fail to process"
134
+ )
135
+ parser.add_argument(
136
+ "--min_length",
137
+ type=float,
138
+ default=0.0,
139
+ help="Minimum audio duration in seconds (e.g. 2.0)",
140
+ )
141
+ parser.add_argument(
142
+ "--max_length",
143
+ type=float,
144
+ default=float("inf"),
145
+ help="Maximum audio duration in seconds (e.g. 15.0)",
146
+ )
147
+ parser.add_argument(
148
+ "--num_machines",
149
+ type=int,
150
+ default=1,
151
+ help="Total number of machines for distributed runs",
152
+ )
153
+ parser.add_argument(
154
+ "--machine_index",
155
+ type=int,
156
+ default=0,
157
+ help="Zero-based machine index when distributing across multiple "
158
+ "machines (e.g. 0, 1, ... num_machines-1)",
159
+ )
160
+ parser.add_argument(
161
+ "--nj_per_gpu",
162
+ type=int,
163
+ default=3,
164
+ help="Number of worker processes to spawn per GPU.",
165
+ )
166
+ parser.add_argument(
167
+ "--loader_workers",
168
+ type=int,
169
+ default=24,
170
+ help="Number of DataLoader workers for streaming IterableDataset.",
171
+ )
172
+ parser.add_argument(
173
+ "--shuffle",
174
+ type=str2bool,
175
+ default=True,
176
+ help="Shuffle data by default.",
177
+ )
178
+ parser.add_argument(
179
+ "--shuffle-seed",
180
+ type=int,
181
+ default=42,
182
+ help="Random seed for shuffle (default: 42).",
183
+ )
184
+ parser.add_argument(
185
+ "--noise_manifest",
186
+ default=None,
187
+ help="Path to noise manifest (list of tar files). Enables prompt noise augmentation.",
188
+ )
189
+ parser.add_argument(
190
+ "--rir_manifest",
191
+ default=None,
192
+ help="Path to RIR manifest (list of tar files). Enables prompt reverb augmentation.",
193
+ )
194
+ return parser
195
+
196
+
197
+ def count_lines(path):
198
+ with open(path, "rb") as f:
199
+ return sum(buf.count(b"\n") for buf in iter(lambda: f.read(1 << 20), b""))
200
+
201
+
202
+ def serialise_numpy(key: str, tokens: np.ndarray) -> dict:
203
+ buffer = io.BytesIO()
204
+ np.save(buffer, tokens)
205
+ return {"__key__": key, "npy": buffer.getvalue()}
206
+
207
+
208
+ def _load_aug_audio(data, sample_rate=24000):
209
+ """Simple audio loader for augmentation files."""
210
+ return torch.from_numpy(load_audio_bytes(data, sample_rate))
211
+
212
+
213
+ class SimpleWorkerSampler:
214
+ """A lightweight infinite sampler for noise/RIR within a worker process."""
215
+
216
+ def __init__(self, tar_paths, sample_rate=24000):
217
+ self.dataset = (
218
+ wds.WebDataset(
219
+ tar_paths, shardshuffle=True, nodesplitter=None, workersplitter=None
220
+ )
221
+ .decode()
222
+ .map(lambda s: self._decode(s, sample_rate))
223
+ .select(lambda x: x is not None)
224
+ .shuffle(100)
225
+ .repeat()
226
+ )
227
+ self.iterator = iter(self.dataset)
228
+
229
+ def _decode(self, sample, sample_rate):
230
+ for ext in ["wav", "flac", "mp3"]:
231
+ if ext in sample:
232
+ return _load_aug_audio(sample[ext], sample_rate)
233
+ return None
234
+
235
+ def sample_segment(self, target_len, allow_repeat=True):
236
+ """Get a random segment of noise matching the target length."""
237
+ try:
238
+ audio = next(self.iterator)
239
+ except StopIteration:
240
+ self.iterator = iter(self.dataset)
241
+ audio = next(self.iterator)
242
+
243
+ cur_len = audio.size(-1)
244
+ if cur_len < target_len and allow_repeat:
245
+ if cur_len > 0:
246
+ num_repeats = math.ceil(target_len / cur_len)
247
+ audio = audio.repeat(1, num_repeats)
248
+ else:
249
+ audio = F.pad(audio, (0, target_len), mode="constant")
250
+ cur_len = audio.size(-1)
251
+
252
+ if cur_len > target_len:
253
+ start = random.randint(0, cur_len - target_len)
254
+ audio = audio[..., start : start + target_len]
255
+
256
+ return audio
257
+
258
+
259
+ def _convolve1d(signal: torch.Tensor, kernel: torch.Tensor) -> torch.Tensor:
260
+ m = signal.size(-1)
261
+ n = kernel.size(-1)
262
+ padded_size = m + n - 1
263
+ f_signal = torch.fft.rfft(signal, n=padded_size)
264
+ f_kernel = torch.fft.rfft(kernel, n=padded_size)
265
+ f_result = f_signal * f_kernel
266
+ result = torch.fft.irfft(f_result, n=padded_size)
267
+ return result[:padded_size]
268
+
269
+
270
+ def _apply_rir(audio, rir, mix_ratio=0.5):
271
+ rir_scaling_factor = 0.5**15
272
+ N_in = audio.shape[-1]
273
+ rir_d = rir[0, :] * rir_scaling_factor
274
+ aug_d = _convolve1d(audio[0], rir_d)
275
+ shift_index = torch.argmax(torch.abs(rir_d))
276
+ end_index = shift_index + N_in
277
+ if end_index > aug_d.shape[0]:
278
+ augmented = F.pad(aug_d[shift_index:], (0, end_index - aug_d.shape[0]))
279
+ else:
280
+ augmented = aug_d[shift_index:end_index]
281
+ power_before = torch.sum(audio[0] ** 2)
282
+ power_after = torch.sum(augmented**2)
283
+ if power_after > 0:
284
+ augmented *= torch.sqrt(power_before / power_after)
285
+ mixed = (1 - mix_ratio) * audio[0] + mix_ratio * augmented
286
+ return mixed.unsqueeze(0)
287
+
288
+
289
+ def process_init(rank_queue, tokenizer_path, noise_manifest=None, rir_manifest=None):
290
+ """
291
+ Initialization function for each worker process.
292
+ Assigns a specific GPU to the process and loads the tokenizer.
293
+ """
294
+ global \
295
+ worker_tokenizer, \
296
+ worker_feature_extractor, \
297
+ worker_noise_sampler, \
298
+ worker_rir_sampler
299
+
300
+ # Configure worker process logging
301
+ formatter = (
302
+ "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d]"
303
+ " [Worker %(process)d] %(message)s"
304
+ )
305
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
306
+
307
+ # Get assigned GPU rank
308
+ rank = rank_queue.get()
309
+ # Determine device
310
+ if rank != -1 and torch.cuda.is_available():
311
+ worker_device = torch.device(f"cuda:{rank}")
312
+ else:
313
+ worker_device = torch.device("cpu")
314
+
315
+ logging.debug(f"Worker process initialized with device: {worker_device}")
316
+ # Load tokenizer onto the specified device
317
+ worker_feature_extractor = AutoFeatureExtractor.from_pretrained(tokenizer_path)
318
+ worker_tokenizer = HiggsAudioV2TokenizerModel.from_pretrained(
319
+ tokenizer_path, device_map=worker_device
320
+ )
321
+ logging.debug(f"Tokenizer loaded successfully on device {worker_device}")
322
+
323
+ # Initialize augmentation samplers (optional)
324
+ if noise_manifest:
325
+ try:
326
+ with open(noise_manifest, "r") as f:
327
+ tars = [l.strip().split()[0] for l in f if l.strip()]
328
+ worker_noise_sampler = SimpleWorkerSampler(
329
+ tars, sample_rate=HIGGS_INPUT_SAMPLE_RATE
330
+ )
331
+ logging.debug("Noise sampler initialized.")
332
+ except Exception as e:
333
+ logging.warning(f"Failed to load noise manifest: {e}")
334
+
335
+ if rir_manifest:
336
+ try:
337
+ with open(rir_manifest, "r") as f:
338
+ tars = [l.strip().split()[0] for l in f if l.strip()]
339
+ worker_rir_sampler = SimpleWorkerSampler(
340
+ tars, sample_rate=HIGGS_INPUT_SAMPLE_RATE
341
+ )
342
+ logging.debug("RIR sampler initialized.")
343
+ except Exception as e:
344
+ logging.warning(f"Failed to load RIR manifest: {e}")
345
+
346
+
347
+ def _augment_prompt(audio_tensor: torch.Tensor) -> tuple[torch.Tensor, int]:
348
+ """Apply noise/reverb augmentation to the front portion of audio.
349
+
350
+ Returns the augmented audio and the sample index where clean audio starts.
351
+ """
352
+ # Pre-normalization
353
+ max_val = audio_tensor.abs().max() + 1e-7
354
+ audio_tensor = (audio_tensor / max_val) * 0.6
355
+
356
+ total_len = audio_tensor.size(-1)
357
+ ratio = random.uniform(0.1, 0.3)
358
+ split_idx = int(total_len * ratio)
359
+ front_part = audio_tensor[:, :split_idx].clone()
360
+
361
+ # Apply noise
362
+ if worker_noise_sampler is not None:
363
+ noise = worker_noise_sampler.sample_segment(split_idx)
364
+ snr_db = random.uniform(5, 15)
365
+ sig_rms = front_part.norm(p=2) / (split_idx**0.5)
366
+ noise_rms = noise.norm(p=2) / (split_idx**0.5)
367
+ if noise_rms > 1e-9:
368
+ snr = 10 ** (snr_db / 20)
369
+ scale = sig_rms / (snr * noise_rms + 1e-8)
370
+ front_part = front_part + noise * scale
371
+
372
+ # Apply RIR (30% probability)
373
+ if worker_rir_sampler is not None and random.random() < 0.3:
374
+ rir = worker_rir_sampler.sample_segment(split_idx, allow_repeat=False)
375
+ reverb_amt = random.uniform(0.3, 1.0)
376
+ try:
377
+ front_part = _apply_rir(front_part, rir, reverb_amt)
378
+ except Exception as e:
379
+ logging.warning(f"RIR failed: {e}")
380
+
381
+ # Merge back
382
+ if front_part.device != audio_tensor.device:
383
+ front_part = front_part.to(audio_tensor.device)
384
+ audio_tensor[:, :split_idx] = front_part
385
+
386
+ # Post-normalization
387
+ max_val = audio_tensor.abs().max() + 1e-7
388
+ audio_tensor = (audio_tensor / max_val) * 0.9
389
+
390
+ return audio_tensor, split_idx
391
+
392
+
393
+ def process_single_sample(sample: dict[str, Any]) -> dict[str, Any]:
394
+ """
395
+ Single-sample processing function executed in worker processes.
396
+ Skips invalid samples during streaming processing.
397
+ """
398
+ try:
399
+ audio_tensor = sample.get("audio", None) # shape (1, T)
400
+ if audio_tensor is None:
401
+ raise ValueError("Sample missing 'audio' field")
402
+
403
+ # Apply prompt augmentation if noise/rir samplers are available
404
+ enable_aug = worker_noise_sampler is not None or worker_rir_sampler is not None
405
+ clean_sample_idx = 0
406
+ if enable_aug:
407
+ audio_tensor, clean_sample_idx = _augment_prompt(audio_tensor)
408
+
409
+ with torch.inference_mode():
410
+ key = sample["label"]["id"]
411
+
412
+ inputs = worker_feature_extractor(
413
+ raw_audio=audio_tensor.squeeze(0).numpy(),
414
+ sampling_rate=HIGGS_INPUT_SAMPLE_RATE,
415
+ return_tensors="pt",
416
+ ).to(worker_tokenizer.device)
417
+ audio_tokens = worker_tokenizer.encode(
418
+ inputs["input_values"],
419
+ ).audio_codes.squeeze(0)
420
+
421
+ assert len(audio_tokens.shape) == 2
422
+ assert audio_tokens.size(0) == 8
423
+
424
+ num_tokens = audio_tokens.size(1)
425
+ metadata = sample["label"]
426
+ metadata["num_tokens"] = num_tokens
427
+
428
+ if enable_aug:
429
+ clean_token_idx = math.ceil(
430
+ clean_sample_idx / worker_tokenizer.config.hop_length
431
+ )
432
+ metadata["clean_start_token_idx"] = clean_token_idx
433
+
434
+ # Convert to numpy format for subsequent serialization (int16 to save space)
435
+ audio_tokens_np = audio_tokens.to(torch.int16).cpu().numpy()
436
+
437
+ return {
438
+ "status": "success",
439
+ "key": key,
440
+ "audio_tokens": audio_tokens_np,
441
+ "metadata": metadata,
442
+ "error_msg": None,
443
+ }
444
+ except Exception as e:
445
+ sample_id = sample.get("label", {}).get("id", "unknown")
446
+ logging.error(f"Failed to process sample {sample_id}: {e}")
447
+ return {
448
+ "status": "error",
449
+ "key": sample_id,
450
+ "audio_tokens": None,
451
+ "metadata": None,
452
+ "error_msg": str(e),
453
+ }
454
+
455
+
456
+ def _normalise_value(value: Any) -> Any:
457
+ """Convert tensors and NumPy scalars to serialisable Python objects."""
458
+ if isinstance(value, torch.Tensor):
459
+ if value.ndim == 0:
460
+ return value.item()
461
+ return value.cpu().tolist()
462
+ if isinstance(value, np.generic):
463
+ return value.item()
464
+ if isinstance(value, np.ndarray):
465
+ return value.tolist()
466
+ return value
467
+
468
+
469
+ def _encode_metadata(metadata: dict[str, Any]) -> bytes:
470
+ cleaned: dict[str, Any] = {}
471
+ for key, value in metadata.items():
472
+ if value is None:
473
+ continue
474
+ cleaned[key] = _normalise_value(value)
475
+ return json.dumps(cleaned, ensure_ascii=False).encode("utf-8")
476
+
477
+
478
+ class StreamingLengthFilteredDataset(IterableDataset):
479
+ def __init__(
480
+ self,
481
+ base_iterable,
482
+ min_len: float,
483
+ max_len: float,
484
+ sr: int,
485
+ ):
486
+ self.base_iterable = base_iterable
487
+ self.min_len = min_len
488
+ self.max_len = max_len
489
+ self.sr = sr
490
+ self.filtered_count = 0
491
+
492
+ def __iter__(self):
493
+ """Stream samples one by one and filter on the fly."""
494
+ for sample in self.base_iterable:
495
+ try:
496
+ duration = sample["audio"].size(-1) / self.sr
497
+ if self.min_len <= duration <= self.max_len:
498
+ yield sample
499
+ else:
500
+ self.filtered_count += 1
501
+ logging.warning(
502
+ f"Filtered sample (duration out of range): "
503
+ f"{sample['label']['id']} ({duration:.2f}s)"
504
+ )
505
+ except Exception as e:
506
+ logging.warning(f"Skipped invalid sample during streaming: {e}")
507
+ continue
508
+
509
+
510
+ def main() -> None:
511
+ formatter = "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s"
512
+ logging.basicConfig(format=formatter, level=logging.INFO, force=True)
513
+ parser = build_parser()
514
+ args = parser.parse_args()
515
+ mp.set_start_method("spawn", force=True)
516
+
517
+ # Validate input arguments
518
+ assert bool(args.input_manifest) != bool(args.input_jsonl), (
519
+ "Exactly one of --input_manifest or --input_jsonl must be provided."
520
+ )
521
+
522
+ if args.num_machines > 1:
523
+ assert 0 <= args.machine_index < args.num_machines, (
524
+ f"machine_index {args.machine_index} must be in [0, {args.num_machines})"
525
+ )
526
+
527
+ # Build base dataset and count total samples based on input mode
528
+ if args.input_jsonl:
529
+ logging.info(f"Input mode: raw JSONL ({args.input_jsonl})")
530
+ total_samples = count_lines(args.input_jsonl)
531
+ base_dataset = JsonlDatasetReader(
532
+ args.input_jsonl,
533
+ sample_rate=HIGGS_INPUT_SAMPLE_RATE,
534
+ shuffle=args.shuffle,
535
+ shuffle_seed=args.shuffle_seed,
536
+ )
537
+ loader_workers = args.loader_workers
538
+ else:
539
+ logging.info(f"Input mode: WebDataset manifest ({args.input_manifest})")
540
+ manifest_num_lines = count_lines(args.input_manifest)
541
+ loader_workers = min(args.loader_workers, manifest_num_lines)
542
+ total_samples = 0
543
+ manifests = []
544
+ with open(args.input_manifest, "r", encoding="utf-8") as f:
545
+ for line_id, line in tqdm(
546
+ enumerate(f),
547
+ total=manifest_num_lines,
548
+ desc="Calculating dataset length",
549
+ ):
550
+ items = line.strip().split(" ")
551
+ tar_path, jsonl_path, num_items, duration = (
552
+ items[0],
553
+ items[1],
554
+ int(items[2]),
555
+ float(items[3]),
556
+ )
557
+ assert os.path.exists(tar_path), f"File {tar_path} does not exist."
558
+ assert os.path.exists(jsonl_path), f"File {jsonl_path} does not exist."
559
+ assert jsonl_path.endswith(".jsonl"), (
560
+ f"File {jsonl_path} is not a .jsonl file."
561
+ )
562
+ if (
563
+ args.num_machines > 1
564
+ and line_id % args.num_machines != args.machine_index
565
+ ):
566
+ continue
567
+ total_samples += num_items
568
+ manifests.append((tar_path, jsonl_path, num_items, duration))
569
+ logging.info(
570
+ f"Total shards: {manifest_num_lines}, "
571
+ f"Shards for current index: {len(manifests)}"
572
+ )
573
+ base_dataset = WebDatasetReader(
574
+ manifests=manifests,
575
+ sample_rate=HIGGS_INPUT_SAMPLE_RATE,
576
+ evaluation=True,
577
+ )
578
+
579
+ # Apply length filter and create DataLoader
580
+ filtered_dataset = StreamingLengthFilteredDataset(
581
+ base_iterable=base_dataset,
582
+ min_len=args.min_length,
583
+ max_len=args.max_length,
584
+ sr=HIGGS_INPUT_SAMPLE_RATE,
585
+ )
586
+ dataloader = DataLoader(
587
+ dataset=filtered_dataset,
588
+ batch_size=None,
589
+ num_workers=loader_workers,
590
+ persistent_workers=loader_workers > 0,
591
+ pin_memory=False,
592
+ )
593
+
594
+ # Adjust samples_per_shard if min_num_shards would be violated
595
+ samples_per_shard = args.samples_per_shard
596
+ if total_samples > 0:
597
+ estimated_shards = max(
598
+ 1, (total_samples + samples_per_shard - 1) // samples_per_shard
599
+ )
600
+ if estimated_shards < args.min_num_shards:
601
+ samples_per_shard = max(1, total_samples // args.min_num_shards)
602
+ logging.info(
603
+ f"Adjusted samples_per_shard from {args.samples_per_shard} to "
604
+ f"{samples_per_shard} to meet min_num_shards={args.min_num_shards} "
605
+ f"(total_samples={total_samples})"
606
+ )
607
+
608
+ # Configure multi-GPU multi-process setup
609
+ num_devices = torch.cuda.device_count()
610
+ if num_devices == 0:
611
+ logging.warning("No GPUs detected - using CPU for processing")
612
+ num_processes = args.nj_per_gpu
613
+ else:
614
+ num_processes = num_devices * args.nj_per_gpu
615
+ logging.info(
616
+ f"GPU count: {num_devices}, Processes per GPU: {args.nj_per_gpu}, "
617
+ f"Total processes: {num_processes}"
618
+ )
619
+ if args.noise_manifest or args.rir_manifest:
620
+ logging.info(
621
+ f"Prompt augmentation enabled - "
622
+ f"noise: {args.noise_manifest or 'off'}, rir: {args.rir_manifest or 'off'}"
623
+ )
624
+
625
+ # Shared GPU rank queue for process assignment
626
+ manager = mp.Manager()
627
+ rank_queue = manager.Queue()
628
+ for rank in list(range(num_devices)) * args.nj_per_gpu:
629
+ rank_queue.put(rank)
630
+ if num_devices == 0:
631
+ for _ in range(num_processes):
632
+ rank_queue.put(-1)
633
+
634
+ # Prepare output paths
635
+ tar_output_pattern = str(Path(args.tar_output_pattern).expanduser())
636
+ jsonl_output_pattern = str(Path(args.jsonl_output_pattern).expanduser())
637
+ Path(tar_output_pattern).parent.mkdir(parents=True, exist_ok=True)
638
+ Path(jsonl_output_pattern).parent.mkdir(parents=True, exist_ok=True)
639
+
640
+ # Determine output directory from tar_output_pattern
641
+ output_dir = Path(tar_output_pattern).parent.parent
642
+ error_log_path = str(output_dir / "errors.jsonl")
643
+ manifest_path = str(output_dir / "data.lst")
644
+
645
+ # Setup error logger (writes to errors.jsonl)
646
+ error_logger = logging.getLogger("error_log")
647
+ error_logger.setLevel(logging.ERROR)
648
+ error_logger.handlers.clear()
649
+ error_fh = logging.FileHandler(error_log_path, mode="w", encoding="utf-8")
650
+ error_fh.setFormatter(logging.Formatter("%(message)s"))
651
+ error_logger.addHandler(error_fh)
652
+
653
+ # Progress and error tracking
654
+ processed_count = 0
655
+ error_count = 0
656
+ write_error_count = 0
657
+ failed_ids = []
658
+ shard_idx = 0
659
+ shard_sample_count = 0
660
+ shard_duration = 0.0
661
+ shard_manifest = {} # shard_idx -> (tar_path, jsonl_path, count, duration)
662
+
663
+ tar_writer = None
664
+ jsonl_file = None
665
+
666
+ def open_new_shard():
667
+ nonlocal tar_writer, jsonl_file, shard_idx, shard_sample_count, shard_duration
668
+ if tar_writer is not None:
669
+ tar_writer.close()
670
+ if jsonl_file is not None:
671
+ jsonl_file.close()
672
+ # Record manifest for the previous shard
673
+ if shard_idx > 0 and shard_sample_count > 0:
674
+ prev_idx = shard_idx - 1
675
+ shard_manifest[prev_idx] = (
676
+ os.path.abspath(tar_output_pattern % prev_idx),
677
+ os.path.abspath(jsonl_output_pattern % prev_idx),
678
+ shard_sample_count,
679
+ shard_duration,
680
+ )
681
+ tar_fname = tar_output_pattern % shard_idx
682
+ jsonl_fname = jsonl_output_pattern % shard_idx
683
+ tar_writer = wds.TarWriter(tar_fname)
684
+ jsonl_file = open(jsonl_fname, "w", encoding="utf-8")
685
+ shard_idx += 1
686
+ shard_sample_count = 0
687
+ shard_duration = 0.0
688
+
689
+ def write_sample(key, audio_tokens_np, metadata):
690
+ nonlocal shard_sample_count, write_error_count, shard_duration
691
+ assert tar_writer is not None and jsonl_file is not None
692
+ try:
693
+ token_record = serialise_numpy(key, audio_tokens_np)
694
+ json_record = _encode_metadata(metadata)
695
+ tar_writer.write(token_record)
696
+ jsonl_file.write(json_record.decode("utf-8") + "\n")
697
+ shard_sample_count += 1
698
+ shard_duration += metadata.get("audio_duration", 0.0)
699
+ except Exception as exc:
700
+ write_error_count += 1
701
+ failed_ids.append(key)
702
+ error_logger.error(
703
+ json.dumps({"id": key, "reason": str(exc)}, ensure_ascii=False)
704
+ )
705
+ logging.error(f"Write failed for sample {key}: {exc}")
706
+
707
+ def handle_result(result):
708
+ nonlocal processed_count, error_count
709
+ if result["status"] == "success":
710
+ # Rotate shard if needed
711
+ if tar_writer is None or shard_sample_count >= samples_per_shard:
712
+ open_new_shard()
713
+ write_sample(result["key"], result["audio_tokens"], result["metadata"])
714
+ processed_count += 1
715
+ else:
716
+ error_count += 1
717
+ failed_ids.append(result["key"])
718
+ error_logger.error(
719
+ json.dumps(
720
+ {"id": result["key"], "reason": result["error_msg"]},
721
+ ensure_ascii=False,
722
+ )
723
+ )
724
+ if not args.skip_errors:
725
+ raise RuntimeError(
726
+ f"Sample {result['key']} processing failed due "
727
+ f"to {result['error_msg']} - terminating"
728
+ )
729
+ logging.warning(
730
+ f"Skipping failed sample {result['key']}: {result['error_msg']}"
731
+ )
732
+
733
+ main_progress = tqdm(total=total_samples, desc="Extracting Audio Tokens")
734
+
735
+ try:
736
+ with ProcessPoolExecutor(
737
+ max_workers=num_processes,
738
+ initializer=process_init,
739
+ initargs=(
740
+ rank_queue,
741
+ args.tokenizer_path,
742
+ args.noise_manifest,
743
+ args.rir_manifest,
744
+ ),
745
+ ) as executor:
746
+ logging.info(f"Submitting tasks... ({num_processes} workers)")
747
+ futures = set()
748
+ max_pending = num_processes * 10
749
+
750
+ def drain_completed():
751
+ """Wait for at least one future to complete, process all done."""
752
+ nonlocal futures
753
+ done, _ = wait(futures, return_when=FIRST_COMPLETED)
754
+ for f in done:
755
+ futures.discard(f)
756
+ result = f.result()
757
+ main_progress.update(1)
758
+ handle_result(result)
759
+ main_progress.set_postfix(
760
+ Samples=processed_count,
761
+ Errors=error_count,
762
+ )
763
+
764
+ # Stream samples from DataLoader
765
+ for sample in dataloader:
766
+ if len(futures) >= max_pending:
767
+ drain_completed()
768
+
769
+ future = executor.submit(process_single_sample, sample)
770
+ futures.add(future)
771
+
772
+ # Process remaining futures
773
+ logging.info("Processing remaining pending samples...")
774
+ while futures:
775
+ drain_completed()
776
+
777
+ except Exception:
778
+ logging.error("Critical error during processing", exc_info=True)
779
+ raise
780
+ finally:
781
+ main_progress.close()
782
+ if tar_writer is not None:
783
+ tar_writer.close()
784
+ if jsonl_file is not None:
785
+ jsonl_file.close()
786
+ # Record the last shard in the manifest
787
+ if shard_idx > 0 and shard_sample_count > 0:
788
+ last_idx = shard_idx - 1
789
+ shard_manifest[last_idx] = (
790
+ os.path.abspath(tar_output_pattern % last_idx),
791
+ os.path.abspath(jsonl_output_pattern % last_idx),
792
+ shard_sample_count,
793
+ shard_duration,
794
+ )
795
+
796
+ # Write manifest file (data.lst)
797
+ with open(manifest_path, "w", encoding="utf-8") as mf:
798
+ for idx in sorted(shard_manifest.keys()):
799
+ tar_path, jsonl_path, count, duration = shard_manifest[idx]
800
+ mf.write(f"{tar_path} {jsonl_path} {count} {duration:.3f}\n")
801
+
802
+ # Output final statistics
803
+ total_failed = error_count + write_error_count
804
+ filtered_and_skipped = total_samples - processed_count - total_failed
805
+ logging.info(
806
+ f"Processing Complete - Successful: {processed_count}, Failed: {total_failed}, "
807
+ f"Filtered/Skipped: {filtered_and_skipped}, Shards written: {shard_idx}"
808
+ )
809
+ logging.info(f"Manifest written to: {manifest_path} ({len(shard_manifest)} shards)")
810
+ if total_failed > 0:
811
+ logging.info(f"Error details: {error_log_path}")
812
+ if failed_ids and args.skip_errors:
813
+ logging.warning(
814
+ f"Failed sample IDs (count: {len(failed_ids)}): {failed_ids[:100]}..."
815
+ )
816
+ if write_error_count > 0 and not args.skip_errors:
817
+ raise RuntimeError(
818
+ f"{write_error_count} samples failed to write - check logs for details"
819
+ )
820
+
821
+
822
+ if __name__ == "__main__":
823
+ main()
omnivoice/scripts/jsonl_to_webdataset.py ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ Pack a JSONL audio dataset into a customed WebDataset shards
20
+ (paired .tar and .jsonl files).
21
+
22
+ Usage:
23
+ python jsonl_to_webdataset.py \
24
+ --input data.jsonl \
25
+ --output output_dir/ \
26
+ --workers 16 \
27
+ --threads 4 \
28
+ --shard-size 1000 \
29
+ --sr 24000
30
+
31
+ Input JSONL format (one JSON object per line):
32
+ {"id": "utt_001", "audio_path": "/data/wavs/001.wav", "text": "hello world", ...}
33
+
34
+ Required fields: "id", "audio_path", "text"
35
+ All other fields are preserved in the output metadata.
36
+
37
+ Output structure:
38
+ output_dir/
39
+ ├── audios/ # WebDataset tar shards
40
+ │ ├── shard_000000.tar
41
+ │ ├── shard_000001.tar
42
+ │ └── ...
43
+ ├── txts/ # Per-shard JSONL metadata (with audio_duration added)
44
+ │ ├── shard_000000.jsonl
45
+ │ ├── shard_000001.jsonl
46
+ │ └── ...
47
+ ├── data.lst # Manifest: <tar_path> <jsonl_path> <sample_count> <total_duration>
48
+ └── errors.jsonl # Failed samples with error details
49
+ """
50
+
51
+ import argparse
52
+ import io
53
+ import json
54
+ import logging
55
+ import multiprocessing as mp
56
+ import os
57
+ import random
58
+ from concurrent.futures import (
59
+ FIRST_COMPLETED,
60
+ ProcessPoolExecutor,
61
+ ThreadPoolExecutor,
62
+ as_completed,
63
+ wait,
64
+ )
65
+ from itertools import islice
66
+ from pathlib import Path
67
+
68
+ import torch
69
+ import torchaudio
70
+ import webdataset as wds
71
+ from tqdm import tqdm
72
+
73
+ import soundfile as sf
74
+
75
+ from omnivoice.utils.audio import load_waveform
76
+ from omnivoice.utils.common import str2bool
77
+
78
+
79
+ def build_parser() -> argparse.ArgumentParser:
80
+ parser = argparse.ArgumentParser(
81
+ description="Pack JSONL audio dataset into WebDataset shards."
82
+ )
83
+ parser.add_argument(
84
+ "--input", type=str, default="data.jsonl", help="Path to input JSONL file"
85
+ )
86
+ parser.add_argument(
87
+ "--output",
88
+ type=str,
89
+ default="emilia",
90
+ help="Path to output directory",
91
+ )
92
+ parser.add_argument(
93
+ "--workers",
94
+ type=int,
95
+ default=16,
96
+ help="Number of worker processes (default: 16)",
97
+ )
98
+ parser.add_argument(
99
+ "--threads",
100
+ type=int,
101
+ default=4,
102
+ help="Number of threads per worker process.",
103
+ )
104
+ parser.add_argument(
105
+ "--shard-size",
106
+ type=int,
107
+ default=1000,
108
+ help="Number of samples per shard (default: 1000)",
109
+ )
110
+ parser.add_argument(
111
+ "--sr", type=int, default=24000, help="Target sample rate (default: 24000)"
112
+ )
113
+ parser.add_argument(
114
+ "--shuffle",
115
+ type=str2bool,
116
+ default=True,
117
+ help="Shuffle data by default.",
118
+ )
119
+ parser.add_argument(
120
+ "--shuffle-seed",
121
+ type=int,
122
+ default=42,
123
+ help="Random seed for shuffle (default: 42)",
124
+ )
125
+ parser.add_argument(
126
+ "--min-duration",
127
+ type=float,
128
+ default=None,
129
+ help="Filter out samples shorter than this (seconds).",
130
+ )
131
+ parser.add_argument(
132
+ "--max-duration",
133
+ type=float,
134
+ default=None,
135
+ help="Filter out samples >= this duration (seconds).",
136
+ )
137
+ return parser
138
+
139
+
140
+ def read_jsonl(file_path):
141
+ with open(file_path, "r", encoding="utf-8") as f:
142
+ for line in f:
143
+ line = line.strip()
144
+ if line:
145
+ yield json.loads(line)
146
+
147
+
148
+ def chunked_reader(iterator, chunk_size):
149
+ it = iter(iterator)
150
+ while chunk := list(islice(it, chunk_size)):
151
+ yield chunk
152
+
153
+
154
+ def process_audio_item(meta, target_sr):
155
+ key = meta.get("id")
156
+ audio_path = meta.get("audio_path")
157
+
158
+ if not key or not audio_path:
159
+ return {
160
+ "error": {
161
+ "id": key,
162
+ "audio_path": audio_path,
163
+ "reason": "missing id or audio_path",
164
+ }
165
+ }
166
+
167
+ try:
168
+ if not os.path.exists(audio_path):
169
+ raise FileNotFoundError(f"{audio_path} not found")
170
+
171
+ waveform, sr = load_waveform(audio_path)
172
+ audio_duration = waveform.shape[1] / sr
173
+ meta["audio_duration"] = audio_duration
174
+
175
+ if target_sr and sr != target_sr:
176
+ waveform = torchaudio.functional.resample(
177
+ torch.from_numpy(waveform), orig_freq=sr, new_freq=target_sr
178
+ ).numpy()
179
+ sr = target_sr
180
+
181
+ audio_buffer = io.BytesIO()
182
+ sf.write(audio_buffer, waveform.T, sr, format="FLAC")
183
+ audio_bytes = audio_buffer.getvalue()
184
+
185
+ sample = {
186
+ "__key__": key,
187
+ "flac": audio_bytes,
188
+ }
189
+
190
+ return {"ok": (sample, meta)}
191
+
192
+ except Exception as e:
193
+ return {"error": {"id": key, "audio_path": audio_path, "reason": str(e)}}
194
+
195
+
196
+ def process_single_shard(
197
+ shard_idx,
198
+ records,
199
+ output_tar_pattern,
200
+ output_jsonl_pattern,
201
+ target_sr,
202
+ num_threads=4,
203
+ min_duration=None,
204
+ max_duration=None,
205
+ ):
206
+ tar_fname = output_tar_pattern % shard_idx
207
+ jsonl_fname = output_jsonl_pattern % shard_idx
208
+
209
+ processed_count = 0
210
+ filtered_count = 0
211
+ error_count = 0
212
+ total_duration = 0.0
213
+ errors = []
214
+
215
+ with (
216
+ wds.TarWriter(tar_fname) as sink,
217
+ open(jsonl_fname, "w", encoding="utf-8") as jsonl_f,
218
+ ):
219
+ with ThreadPoolExecutor(max_workers=num_threads) as thread_pool:
220
+ futures = []
221
+
222
+ for meta in records:
223
+ f = thread_pool.submit(process_audio_item, meta, target_sr)
224
+ futures.append(f)
225
+
226
+ for f in as_completed(futures):
227
+ result = f.result()
228
+
229
+ if "error" in result:
230
+ error_count += 1
231
+ errors.append(result["error"])
232
+ continue
233
+
234
+ sample, meta = result["ok"]
235
+ dur = meta.get("audio_duration", 0.0)
236
+
237
+ # Duration filtering (based on actual audio_duration computed above)
238
+ if min_duration is not None and dur < min_duration:
239
+ filtered_count += 1
240
+ continue
241
+ if max_duration is not None and dur >= max_duration:
242
+ filtered_count += 1
243
+ continue
244
+
245
+ sink.write(sample)
246
+
247
+ jsonl_f.write(json.dumps(meta, ensure_ascii=False) + "\n")
248
+
249
+ total_duration += dur
250
+ processed_count += 1
251
+
252
+ # Clean up empty shard files
253
+ if processed_count == 0:
254
+ for p in (tar_fname, jsonl_fname):
255
+ if os.path.exists(p):
256
+ os.remove(p)
257
+
258
+ return (
259
+ shard_idx,
260
+ processed_count,
261
+ error_count,
262
+ filtered_count,
263
+ total_duration,
264
+ errors,
265
+ )
266
+
267
+
268
+ def count_lines(path):
269
+ with open(path, "rb") as f:
270
+ return sum(buf.count(b"\n") for buf in iter(lambda: f.read(1 << 20), b""))
271
+
272
+
273
+ def pack_dataset(
274
+ input_jsonl,
275
+ output_dir,
276
+ samples_per_shard=5000,
277
+ num_workers=16,
278
+ target_sr=24000,
279
+ threads_per_worker=4,
280
+ shuffle=False,
281
+ shuffle_seed=None,
282
+ min_duration=None,
283
+ max_duration=None,
284
+ ):
285
+ input_path = Path(input_jsonl)
286
+ output_dir = Path(output_dir)
287
+ output_tar_dir = output_dir / "audios"
288
+ output_tar_dir.mkdir(parents=True, exist_ok=True)
289
+ output_jsonl_dir = output_dir / "txts"
290
+ output_jsonl_dir.mkdir(parents=True, exist_ok=True)
291
+
292
+ output_tar_pattern = str(output_tar_dir / "shard-%06d.tar")
293
+ output_jsonl_pattern = str(output_jsonl_dir / "shard-%06d.jsonl")
294
+
295
+ error_log_path = str(output_dir / "errors.jsonl")
296
+
297
+ # Setup error logger
298
+ error_logger = logging.getLogger("error_log")
299
+ error_logger.setLevel(logging.ERROR)
300
+ error_logger.handlers.clear()
301
+ fh = logging.FileHandler(error_log_path, mode="w", encoding="utf-8")
302
+ fh.setFormatter(logging.Formatter("%(message)s"))
303
+ error_logger.addHandler(fh)
304
+
305
+ shard_manifest = {}
306
+
307
+ print(f"Reading input: {input_path}")
308
+ print(f"Output dir: {output_dir}")
309
+ print(f"Strategy: {num_workers} Processes x {threads_per_worker} Threads")
310
+
311
+ if shuffle:
312
+ print("Load input dataset...")
313
+ entries = list(read_jsonl(input_path))
314
+ random.seed(shuffle_seed)
315
+ random.shuffle(entries)
316
+ print(f"Shuffled {len(entries)} entries (seed={shuffle_seed})")
317
+ total_lines = len(entries)
318
+ chunk_gen = chunked_reader(iter(entries), samples_per_shard)
319
+ else:
320
+ print("Calculating total lines...")
321
+ total_lines = count_lines(input_path)
322
+ chunk_gen = chunked_reader(read_jsonl(input_path), samples_per_shard)
323
+
324
+ if min_duration is not None or max_duration is not None:
325
+ print(
326
+ f"Duration filter: [{min_duration or 0:.2f}s"
327
+ f", {max_duration or float('inf'):.1f}s) (applied after audio decoding)"
328
+ )
329
+
330
+ total_shards_est = (total_lines + samples_per_shard - 1) // samples_per_shard
331
+ print(f"Total samples: {total_lines}, Estimated shards: {total_shards_est}")
332
+
333
+ with ProcessPoolExecutor(max_workers=num_workers) as executor:
334
+ futures = set()
335
+
336
+ shard_idx = 0
337
+ total_processed = 0
338
+ total_errors = 0
339
+ total_filtered = 0
340
+
341
+ pbar = tqdm(
342
+ total=total_shards_est,
343
+ desc="Shards Processed",
344
+ unit="shard",
345
+ )
346
+
347
+ def submit_next_chunks(limit):
348
+ """Pull up to `limit` chunks from generator, submit them."""
349
+ nonlocal shard_idx
350
+ submitted = 0
351
+ for chunk in chunk_gen:
352
+ f = executor.submit(
353
+ process_single_shard,
354
+ shard_idx,
355
+ chunk,
356
+ output_tar_pattern,
357
+ output_jsonl_pattern,
358
+ target_sr,
359
+ threads_per_worker,
360
+ min_duration,
361
+ max_duration,
362
+ )
363
+ futures.add(f)
364
+ shard_idx += 1
365
+ submitted += 1
366
+ if submitted >= limit:
367
+ break
368
+
369
+ submit_next_chunks(num_workers * 2)
370
+
371
+ while futures:
372
+ done, _ = wait(futures, return_when=FIRST_COMPLETED)
373
+
374
+ for f in done:
375
+ futures.remove(f)
376
+
377
+ try:
378
+ s_idx, p_count, e_count, f_count, s_duration, errors = f.result()
379
+ total_processed += p_count
380
+ total_errors += e_count
381
+ total_filtered += f_count
382
+
383
+ # Write error log
384
+ for err in errors:
385
+ err["shard_idx"] = s_idx
386
+ error_logger.error(json.dumps(err, ensure_ascii=False))
387
+
388
+ if p_count > 0:
389
+ tar_abs = os.path.abspath(output_tar_pattern % s_idx)
390
+ jsonl_abs = os.path.abspath(output_jsonl_pattern % s_idx)
391
+ shard_manifest[s_idx] = (
392
+ tar_abs,
393
+ jsonl_abs,
394
+ p_count,
395
+ s_duration,
396
+ )
397
+
398
+ pbar.set_postfix(
399
+ {
400
+ "Samples": total_processed,
401
+ "Filtered": total_filtered,
402
+ "Errors": total_errors,
403
+ }
404
+ )
405
+ pbar.update(1)
406
+ except Exception as e:
407
+ print(f"Shard task failed: {e}")
408
+
409
+ submit_next_chunks(1)
410
+
411
+ pbar.close()
412
+
413
+ # Write final manifest file (data.lst)
414
+ manifest_path = str(output_dir / "data.lst")
415
+ with open(manifest_path, "w", encoding="utf-8") as mf:
416
+ for idx in sorted(shard_manifest.keys()):
417
+ tar_path, jsonl_path, count, duration = shard_manifest[idx]
418
+ mf.write(f"{tar_path} {jsonl_path} {count} {duration:.3f}\n")
419
+
420
+ print(f"\nDone! Output saved to {output_dir}")
421
+ print(f"Successfully packed: {total_processed}")
422
+ print(f"Filtered by duration: {total_filtered}")
423
+ print(f"Failed: {total_errors}")
424
+ print(f"Manifest written to: {manifest_path} ({len(shard_manifest)} shards)")
425
+ if total_errors > 0:
426
+ print(f"Error details: {error_log_path}")
427
+
428
+
429
+ if __name__ == "__main__":
430
+ mp.set_start_method("spawn", force=True)
431
+
432
+ args = build_parser().parse_args()
433
+ pack_dataset(
434
+ input_jsonl=args.input,
435
+ output_dir=args.output,
436
+ samples_per_shard=args.shard_size,
437
+ num_workers=args.workers,
438
+ target_sr=args.sr,
439
+ threads_per_worker=args.threads,
440
+ shuffle=args.shuffle,
441
+ shuffle_seed=args.shuffle_seed,
442
+ min_duration=args.min_duration,
443
+ max_duration=args.max_duration,
444
+ )
omnivoice/training/__init__.py ADDED
File without changes
omnivoice/training/builder.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Builders for constructing training components.
19
+
20
+ Provides factory functions to assemble the model, tokenizer, and data loaders
21
+ from a ``TrainingConfig``. Called by ``omnivoice.cli.train`` to set up training.
22
+
23
+ Key functions:
24
+ - ``build_model_and_tokenizer()``: Loads the model and text tokenizer.
25
+ - ``build_dataloaders()``: Builds train/eval data loaders from a data config JSON.
26
+ The batching strategy is chosen based on ``TrainingConfig.attn_implementation``:
27
+
28
+ - ``"flex_attention"``: sequence packing via ``PackingIterableDataset`` +
29
+ ``PackingDataCollator``. Batch shape is ``[1, C, batch_tokens]``.
30
+ - other (e.g. ``"sdpa"``): length-grouped padding via
31
+ ``StreamLengthGroupDataset`` + ``PaddingDataCollator``. Batch shape
32
+ is ``[B, C, max_len]`` where B ≥ 1 and max_len ≤ batch_tokens.
33
+ """
34
+
35
+ import logging
36
+ from functools import partial
37
+ from typing import Tuple
38
+
39
+ import torch
40
+ from torch.utils.data import DataLoader
41
+ from transformers import AutoConfig, AutoModel, AutoTokenizer
42
+ from transformers import logging as hf_logging
43
+ from transformers.trainer_utils import seed_worker
44
+
45
+ from omnivoice.data.batching import PackingIterableDataset, StreamLengthGroupDataset
46
+ from omnivoice.data.collator import PackingDataCollator, PaddingDataCollator
47
+ from omnivoice.data.dataset import WebDatasetReader, prepare_data_manifests_from_json
48
+ from omnivoice.data.processor import OmniVoiceSampleProcessor
49
+ from omnivoice.models.omnivoice import OmniVoice, OmniVoiceConfig, _resolve_model_path
50
+ from omnivoice.training.config import TrainingConfig
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ def build_model_and_tokenizer(
56
+ config: TrainingConfig,
57
+ ) -> Tuple[OmniVoice, AutoTokenizer]:
58
+ """Load Tokenizer and Model, handle resizing and special tokens."""
59
+ logger.info("Initializing Model & Tokenizer...")
60
+
61
+ # 1. Tokenizer
62
+ tokenizer_path = (
63
+ config.init_from_checkpoint
64
+ if config.init_from_checkpoint
65
+ else config.llm_name_or_path
66
+ )
67
+ tokenizer_path = _resolve_model_path(tokenizer_path)
68
+ tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
69
+ if tokenizer.pad_token is None:
70
+ tokenizer.pad_token = tokenizer.eos_token
71
+
72
+ new_tokens = [
73
+ "<|denoise|>",
74
+ "<|lang_start|>",
75
+ "<|lang_end|>",
76
+ "<|instruct_start|>",
77
+ "<|instruct_end|>",
78
+ "<|text_start|>",
79
+ "<|text_end|>",
80
+ ]
81
+
82
+ if getattr(config, "word_control", False):
83
+ from omnivoice.data.word_control import control_token_vocab
84
+
85
+ new_tokens = new_tokens + control_token_vocab()
86
+
87
+ tokens_to_add = [t for t in new_tokens if t not in tokenizer.get_vocab()]
88
+ if tokens_to_add:
89
+ tokenizer.add_special_tokens({"additional_special_tokens": tokens_to_add})
90
+
91
+ if config.init_from_checkpoint:
92
+ logger.info(f"Loading weights from {config.init_from_checkpoint}")
93
+ model = OmniVoice.from_pretrained(
94
+ config.init_from_checkpoint,
95
+ attn_implementation=config.attn_implementation,
96
+ dtype=torch.float32,
97
+ train=True,
98
+ )
99
+ else:
100
+ resolved_llm = _resolve_model_path(config.llm_name_or_path)
101
+ llm_config = AutoConfig.from_pretrained(resolved_llm)
102
+
103
+ ov_config = OmniVoiceConfig(
104
+ audio_vocab_size=config.audio_vocab_size,
105
+ audio_mask_id=config.audio_mask_id,
106
+ num_audio_codebook=config.num_audio_codebook,
107
+ audio_codebook_weights=config.audio_codebook_weights,
108
+ llm_config=llm_config,
109
+ )
110
+
111
+ original_level = hf_logging.get_verbosity()
112
+ hf_logging.set_verbosity_error() # suppress expected lm_head.weight warnings
113
+
114
+ llm = AutoModel.from_pretrained(
115
+ resolved_llm,
116
+ attn_implementation=config.attn_implementation,
117
+ dtype=torch.float32,
118
+ )
119
+
120
+ hf_logging.set_verbosity(original_level)
121
+ model = OmniVoice(config=ov_config, llm=llm)
122
+
123
+ # 3. Resize Embeddings
124
+ if len(tokenizer) != model.config.llm_config.vocab_size:
125
+ model.llm.resize_token_embeddings(len(tokenizer))
126
+ model.config.llm_config.vocab_size = len(tokenizer)
127
+
128
+ # 4. Config IDs
129
+ model.config.pad_token_id = tokenizer.pad_token_id
130
+ model.config.bos_token_id = tokenizer.bos_token_id
131
+ model.config.eos_token_id = tokenizer.eos_token_id
132
+
133
+ return model, tokenizer
134
+
135
+
136
+ def build_dataloaders(
137
+ config: TrainingConfig, tokenizer: AutoTokenizer
138
+ ) -> Tuple[DataLoader, DataLoader]:
139
+ """Setup Data Pipeline: Manifests -> WDS -> Batching -> Loaders.
140
+
141
+ Batching strategy depends on ``config.attn_implementation``:
142
+ - ``"flex_attention"``: sequence packing (PackingIterableDataset +
143
+ PackingDataCollator). All samples are concatenated into one long sequence.
144
+ - other (e.g. ``"sdpa"``): length-grouped padding
145
+ (LengthGroupedIterableDataset + PaddingDataCollator). Samples with
146
+ similar token lengths are batched together and padded to the same length.
147
+ """
148
+ logger.info("Initializing Data Readers...")
149
+
150
+ processor_kwargs = dict(
151
+ text_tokenizer=tokenizer,
152
+ num_channels=config.num_audio_codebook,
153
+ audio_mask_id=config.audio_mask_id,
154
+ prompt_ratio_range=config.prompt_ratio_range,
155
+ mask_ratio_range=config.mask_ratio_range,
156
+ drop_cond_ratio=config.drop_cond_ratio,
157
+ language_ratio=config.language_ratio,
158
+ use_pinyin_ratio=config.use_pinyin_ratio,
159
+ instruct_ratio=config.instruct_ratio,
160
+ only_instruct_ratio=config.only_instruct_ratio,
161
+ )
162
+ if getattr(config, "word_control", False):
163
+ from omnivoice.data.word_control import WordControlSampleProcessor
164
+
165
+ processor = WordControlSampleProcessor(
166
+ **processor_kwargs,
167
+ wc_drop_all_ratio=config.wc_drop_all_ratio,
168
+ wc_word_drop_ratio=config.wc_word_drop_ratio,
169
+ wc_attr_keep_ratio=config.wc_attr_keep_ratio,
170
+ )
171
+ else:
172
+ processor = OmniVoiceSampleProcessor(**processor_kwargs)
173
+
174
+ train_manifests, dev_manifests = prepare_data_manifests_from_json(
175
+ config.data_config
176
+ )
177
+ raw_train_ds = WebDatasetReader(manifests=train_manifests, evaluation=False)
178
+
179
+ use_packing = config.attn_implementation == "flex_attention"
180
+
181
+ if use_packing:
182
+ train_dataset = PackingIterableDataset(
183
+ raw_train_ds, processor, config.batch_tokens
184
+ )
185
+ collate_fn = PackingDataCollator(processor, config.batch_tokens)
186
+ else:
187
+ train_dataset = StreamLengthGroupDataset(
188
+ raw_train_ds,
189
+ batch_duration=config.batch_tokens,
190
+ min_length=config.min_sample_tokens,
191
+ max_length=config.max_sample_tokens,
192
+ max_sample=config.max_batch_size,
193
+ processor=processor,
194
+ length_fn=lambda s: s["length"],
195
+ )
196
+ collate_fn = PaddingDataCollator(processor, config.batch_tokens)
197
+
198
+ logger.info(
199
+ "Using %s (attn_implementation=%s)",
200
+ "sequence packing" if use_packing else "length-grouped padding",
201
+ config.attn_implementation,
202
+ )
203
+
204
+ init_fn = partial(
205
+ seed_worker,
206
+ num_workers=config.num_workers,
207
+ rank=(
208
+ torch.distributed.get_rank() if torch.distributed.is_initialized() else 0
209
+ ),
210
+ )
211
+
212
+ train_loader = DataLoader(
213
+ train_dataset,
214
+ batch_size=None,
215
+ num_workers=config.num_workers,
216
+ collate_fn=collate_fn,
217
+ worker_init_fn=init_fn,
218
+ pin_memory=True,
219
+ prefetch_factor=4,
220
+ )
221
+
222
+ eval_loader = None
223
+ if dev_manifests:
224
+ raw_dev_ds = WebDatasetReader(manifests=dev_manifests, evaluation=True)
225
+ if use_packing:
226
+ dev_dataset = PackingIterableDataset(
227
+ raw_dev_ds, processor, config.batch_tokens
228
+ )
229
+ else:
230
+ dev_dataset = StreamLengthGroupDataset(
231
+ raw_dev_ds,
232
+ batch_duration=config.batch_tokens,
233
+ min_length=config.min_sample_tokens,
234
+ max_length=config.max_sample_tokens,
235
+ max_sample=config.max_batch_size,
236
+ processor=processor,
237
+ length_fn=lambda s: s["length"],
238
+ )
239
+ eval_loader = DataLoader(
240
+ dev_dataset,
241
+ batch_size=None, # Each item is already a collated batch
242
+ num_workers=1,
243
+ collate_fn=collate_fn,
244
+ pin_memory=True,
245
+ prefetch_factor=2,
246
+ )
247
+
248
+ return train_loader, eval_loader
omnivoice/training/checkpoint.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Checkpoint saving, resuming, and training logging.
19
+
20
+ Provides utilities for saving/loading training checkpoints and logging metrics
21
+ to console and trackers (TensorBoard/WandB). Used by ``OmniTrainer``.
22
+
23
+ Key components:
24
+ - ``TrainLogger``: Logs training metrics to console and Accelerate trackers.
25
+ - ``save_checkpoint()``: Saves model, optimizer, and scheduler state.
26
+ - ``load_checkpoint()``: Restores training state from a checkpoint directory.
27
+ """
28
+
29
+ import logging
30
+ import os
31
+ import shutil
32
+ import time
33
+ from typing import Any, Dict, Optional
34
+
35
+ import torch
36
+ from accelerate import Accelerator
37
+ from tqdm.auto import tqdm
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class TrainLogger:
43
+ """
44
+ Handles logging to console and trackers (TensorBoard/WandB)
45
+ """
46
+
47
+ def __init__(self, accelerator: Accelerator, total_steps: int, logging_steps: int):
48
+ self.accelerator = accelerator
49
+ self.total_steps = total_steps
50
+ self.logging_steps = logging_steps
51
+ self.start_time = None
52
+ self.progress_bar = None
53
+
54
+ def start(self, start_step: int = 0):
55
+ self.start_time = time.time()
56
+
57
+ if self.accelerator.is_main_process:
58
+ self.progress_bar = tqdm(
59
+ total=self.total_steps,
60
+ initial=start_step,
61
+ desc="Training",
62
+ dynamic_ncols=True,
63
+ disable=not self.accelerator.is_local_main_process,
64
+ )
65
+
66
+ def update(
67
+ self, step: int, loss: Optional[float] = None, lr: Optional[float] = None
68
+ ):
69
+ """
70
+ Called every step to update the progress bar UI.
71
+ """
72
+ if self.progress_bar:
73
+ self.progress_bar.update(1)
74
+
75
+ # Update real-time metrics on the progress bar itself
76
+ postfix = {}
77
+ if loss is not None:
78
+ postfix["loss"] = f"{loss:.4f}"
79
+ if lr is not None:
80
+ postfix["lr"] = f"{lr:.2e}"
81
+
82
+ if postfix:
83
+ self.progress_bar.set_postfix(postfix)
84
+
85
+ def log_metrics(self, step: int, metrics: Dict[str, Any]):
86
+ """
87
+ Called periodically to log to TensorBoard/WandB and console.
88
+ """
89
+ # Log to trackers (TensorBoard, etc.)
90
+ self.accelerator.log(metrics, step=step)
91
+
92
+ if self.accelerator.is_main_process:
93
+ # Format for console log (separate from tqdm)
94
+ # Remove keys that are redundant or too verbose for one line
95
+ formatted_metrics = []
96
+ for k, v in metrics.items():
97
+ if isinstance(v, float):
98
+ val_str = f"{v:.4f}"
99
+ if val_str == "0.0000" and v != 0:
100
+ formatted_metrics.append(f"{k}: {v:.2e}")
101
+ else:
102
+ formatted_metrics.append(f"{k}: {val_str}")
103
+ else:
104
+ formatted_metrics.append(f"{k}: {v}")
105
+
106
+ # Use external logger to write to file, tqdm.write to avoid breaking bar
107
+ msg = f"Step {step} | " + " | ".join(formatted_metrics)
108
+ if self.progress_bar:
109
+ self.progress_bar.write(msg)
110
+ else:
111
+ logger.info(msg)
112
+
113
+ def close(self):
114
+ if self.progress_bar:
115
+ self.progress_bar.close()
116
+
117
+
118
+ def save_checkpoint(
119
+ accelerator: Accelerator,
120
+ model: torch.nn.Module,
121
+ tokenizer: Any,
122
+ output_dir: str,
123
+ step: int,
124
+ keep_last_n: int = 3,
125
+ ):
126
+ """
127
+ Saves model, tokenizer, and accelerator states (optimizer/scheduler).
128
+ Manages rotation of checkpoints.
129
+ """
130
+ checkpoint_dir = os.path.join(output_dir, f"checkpoint-{step}")
131
+
132
+ # 1. Save Accelerator State (Optimizer, Scheduler, RNG, Scaler)
133
+ accelerator.save_state(checkpoint_dir)
134
+
135
+ # 2. Save Model in HF format (config.json + pytorch_model.bin/safetensors)
136
+ unwrap_model = accelerator.unwrap_model(model)
137
+ unwrap_model.save_pretrained(
138
+ checkpoint_dir,
139
+ is_main_process=accelerator.is_main_process,
140
+ save_function=accelerator.save,
141
+ )
142
+
143
+ # 3. Save Tokenizer
144
+ if accelerator.is_main_process:
145
+ tokenizer.save_pretrained(checkpoint_dir)
146
+
147
+ logger.info(f"Saved checkpoint to {checkpoint_dir}")
148
+
149
+ # 4. Rotate checkpoints (Keep last N)
150
+ if accelerator.is_main_process and keep_last_n > 0:
151
+ checkpoints = [
152
+ d
153
+ for d in os.listdir(output_dir)
154
+ if d.startswith("checkpoint-")
155
+ and os.path.isdir(os.path.join(output_dir, d))
156
+ ]
157
+ # Sort by step number
158
+ checkpoints.sort(key=lambda x: int(x.split("-")[-1]))
159
+
160
+ if len(checkpoints) > keep_last_n:
161
+ to_remove = checkpoints[:-keep_last_n]
162
+ for d in to_remove:
163
+ shutil.rmtree(os.path.join(output_dir, d))
164
+ logger.info(f"Removed old checkpoint {d}")
165
+
166
+
167
+ def load_checkpoint(accelerator: Accelerator, checkpoint_path: str):
168
+ """
169
+ Resumes training state.
170
+ """
171
+ logger.info(f"Resuming from {checkpoint_path}")
172
+ accelerator.load_state(checkpoint_path)
173
+
174
+ # Try to infer step
175
+ try:
176
+ clean_path = os.path.normpath(checkpoint_path)
177
+ step = int(os.path.basename(clean_path).split("-")[-1])
178
+ return step
179
+ except ValueError:
180
+ return 0
omnivoice/training/config.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Training configuration dataclass.
19
+
20
+ Defines ``TrainingConfig``, a dataclass that holds all hyperparameters and paths
21
+ for training. Loaded from a JSON config file via ``TrainingConfig.from_json()``
22
+ in ``omnivoice.cli.train``.
23
+ """
24
+
25
+ import json
26
+ from dataclasses import asdict, dataclass, field
27
+ from typing import List, Optional, Tuple
28
+
29
+
30
+ @dataclass
31
+ class TrainingConfig:
32
+ # Key Paths
33
+ output_dir: Optional[str] = None
34
+ data_config: Optional[str] = None
35
+
36
+ # Model Specific
37
+ llm_name_or_path: str = "Qwen/Qwen3-0.6B"
38
+ audio_vocab_size: int = 1025 # valid vocab size + 1 (mask token)
39
+ audio_mask_id: int = 1024 # 1024 is the 1025-th token
40
+ num_audio_codebook: int = 8
41
+
42
+ # Model Training Specific
43
+ audio_codebook_weights: List[float | int] = field(
44
+ default_factory=lambda: [8, 8, 6, 6, 4, 4, 2, 2]
45
+ )
46
+ drop_cond_ratio: float = 0.1
47
+ prompt_ratio_range: Tuple[float, float] = field(default_factory=lambda: (0.0, 0.3))
48
+ mask_ratio_range: Tuple[float, float] = field(default_factory=lambda: (0.0, 1.0))
49
+ language_ratio: float = 0.8
50
+ use_pinyin_ratio: float = 0.3
51
+ instruct_ratio: float = 1.0
52
+ only_instruct_ratio: float = 0.5
53
+
54
+ # Word-level control (WordVoice-5A tags)
55
+ word_control: bool = False
56
+ wc_drop_all_ratio: float = 0.15
57
+ wc_word_drop_ratio: float = 0.2
58
+ wc_attr_keep_ratio: float = 0.7
59
+
60
+ # Init settings
61
+ resume_from_checkpoint: Optional[str] = None
62
+ init_from_checkpoint: Optional[str] = None
63
+
64
+ # Training Hyperparams
65
+ learning_rate: float = 1e-4
66
+ weight_decay: float = 0.01
67
+ max_grad_norm: float = 1.0
68
+ steps: int = 300000
69
+ seed: int = 42
70
+ lr_scheduler_type: str = "cosine"
71
+ warmup_type: str = "ratio"
72
+ warmup_ratio: float = 0.03
73
+ warmup_steps: int = 2000
74
+
75
+ # Data
76
+ batch_tokens: int = 8192
77
+ gradient_accumulation_steps: int = 1
78
+ num_workers: int = 8
79
+
80
+ # System
81
+ mixed_precision: str = "bf16"
82
+ allow_tf32: bool = True
83
+ use_deepspeed: bool = False
84
+ deepspeed_config: Optional[str] = None
85
+ attn_implementation: str = "flex_attention"
86
+
87
+ # Length-grouped batching (only used when attn_implementation != "flex_attention")
88
+ max_sample_tokens: int = 2000
89
+ min_sample_tokens: int = 50
90
+ max_batch_size: int = 64
91
+
92
+ # Logging
93
+ logging_steps: int = 100
94
+ eval_steps: int = 1000
95
+ save_steps: int = 10000
96
+ keep_last_n_checkpoints: int = -1
97
+
98
+ @classmethod
99
+ def from_json(cls, json_path: str):
100
+ with open(json_path, "r") as f:
101
+ cfg_dict = json.load(f)
102
+ valid_keys = cls.__annotations__.keys()
103
+ filtered_dict = {k: v for k, v in cfg_dict.items() if k in valid_keys}
104
+ instance = cls(**filtered_dict)
105
+ return instance
106
+
107
+ def save_to_json(self, json_path: str):
108
+ data = asdict(self)
109
+ with open(json_path, "w") as f:
110
+ json.dump(data, f, indent=4)
omnivoice/training/trainer.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Training loop for OmniVoice.
19
+
20
+ Wraps the HuggingFace Accelerate training loop with checkpoint saving/resuming,
21
+ evaluation, gradient accumulation, and learning rate scheduling.
22
+ Launched via ``omnivoice.cli.train``.
23
+ """
24
+
25
+ import logging
26
+ import math
27
+ import os
28
+ import sys
29
+ import time
30
+ from datetime import timedelta
31
+ from typing import Any, Optional
32
+
33
+ import torch
34
+ from accelerate import Accelerator, DistributedDataParallelKwargs
35
+ from accelerate.utils import DeepSpeedPlugin, InitProcessGroupKwargs, set_seed
36
+ from torch.utils.data import DataLoader
37
+ from transformers import (
38
+ get_cosine_schedule_with_warmup,
39
+ get_constant_schedule_with_warmup,
40
+ )
41
+
42
+ from omnivoice.training.checkpoint import TrainLogger, load_checkpoint
43
+ from omnivoice.training.checkpoint import save_checkpoint as engine_save_checkpoint
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ def _to_device(batch, device):
49
+ """Move all tensors in a batch dict to the target device."""
50
+ return {
51
+ k: v.to(device, non_blocking=True) if isinstance(v, torch.Tensor) else v
52
+ for k, v in batch.items()
53
+ }
54
+
55
+
56
+ class OmniTrainer:
57
+ def __init__(
58
+ self,
59
+ model: torch.nn.Module,
60
+ config: Any, # TrainingConfig
61
+ train_dataloader: DataLoader,
62
+ eval_dataloader: Optional[DataLoader] = None,
63
+ tokenizer: Optional[Any] = None,
64
+ optimizer: Optional[torch.optim.Optimizer] = None,
65
+ lr_scheduler: Optional[Any] = None,
66
+ ):
67
+ self.config = config
68
+ self.model = model
69
+ self.tokenizer = tokenizer
70
+ self.train_dataloader = train_dataloader
71
+ self.eval_dataloader = eval_dataloader
72
+
73
+ # 1. Initialize Accelerator
74
+ self.accelerator = self._init_accelerator()
75
+
76
+ # 2. Setup Optimizer & Scheduler if not provided
77
+ if optimizer is None:
78
+ self.optimizer, self.lr_scheduler = self.create_optimizer_and_scheduler()
79
+ else:
80
+ self.optimizer = optimizer
81
+ self.lr_scheduler = lr_scheduler
82
+
83
+ # 3. DeepSpeed Hack (Batch Size fix)
84
+ if self.accelerator.distributed_type == "DEEPSPEED":
85
+ self.accelerator.state.deepspeed_plugin.deepspeed_config[
86
+ "train_micro_batch_size_per_gpu"
87
+ ] = 1
88
+
89
+ # 4. Prepare with Accelerator
90
+ (
91
+ self.model,
92
+ self.optimizer,
93
+ self.lr_scheduler,
94
+ ) = self.accelerator.prepare(
95
+ self.model,
96
+ self.optimizer,
97
+ self.lr_scheduler,
98
+ )
99
+
100
+ self.global_step = 0
101
+ self.epoch = 0
102
+
103
+ def _init_accelerator(self) -> Accelerator:
104
+ """Initialize Accelerator, DeepSpeed, and Logging."""
105
+ # TF32 setup
106
+ if getattr(self.config, "allow_tf32", False):
107
+ torch.set_float32_matmul_precision("high")
108
+
109
+ # Init handlers
110
+ ddp_kwargs = DistributedDataParallelKwargs(find_unused_parameters=False)
111
+ init_kwargs = InitProcessGroupKwargs(timeout=timedelta(minutes=60))
112
+
113
+ # DeepSpeed setup
114
+ deepspeed_plugin = None
115
+ if self.config.use_deepspeed and self.config.deepspeed_config:
116
+ if not os.path.exists(self.config.deepspeed_config):
117
+ raise FileNotFoundError(
118
+ f"DeepSpeed config not found: {self.config.deepspeed_config}"
119
+ )
120
+ deepspeed_plugin = DeepSpeedPlugin(
121
+ hf_ds_config=self.config.deepspeed_config,
122
+ gradient_accumulation_steps=self.config.gradient_accumulation_steps,
123
+ gradient_clipping=self.config.max_grad_norm,
124
+ )
125
+
126
+ accelerator = Accelerator(
127
+ gradient_accumulation_steps=self.config.gradient_accumulation_steps,
128
+ mixed_precision=self.config.mixed_precision,
129
+ log_with="tensorboard",
130
+ project_dir=self.config.output_dir,
131
+ step_scheduler_with_optimizer=False,
132
+ kwargs_handlers=[ddp_kwargs, init_kwargs],
133
+ deepspeed_plugin=deepspeed_plugin,
134
+ split_batches=False,
135
+ )
136
+
137
+ # Logging setup
138
+ if accelerator.is_main_process:
139
+ os.makedirs(self.config.output_dir, exist_ok=True)
140
+ # Try to save config if it has the method
141
+ if hasattr(self.config, "save_to_json"):
142
+ self.config.save_to_json(
143
+ os.path.join(self.config.output_dir, "initial_config.json")
144
+ )
145
+
146
+ logging.basicConfig(
147
+ format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
148
+ datefmt="%m/%d/%Y %H:%M:%S",
149
+ level=logging.INFO,
150
+ handlers=[
151
+ logging.StreamHandler(sys.stdout),
152
+ logging.FileHandler(
153
+ os.path.join(self.config.output_dir, "train.log")
154
+ ),
155
+ ],
156
+ )
157
+ else:
158
+ logging.basicConfig(level=logging.ERROR)
159
+
160
+ logger.info(f"Loaded Config: {self.config}")
161
+ set_seed(self.config.seed)
162
+ accelerator.init_trackers("tensorboard")
163
+ return accelerator
164
+
165
+ def create_optimizer_and_scheduler(self):
166
+ """Default AdamW + configurable LR Scheduler."""
167
+ optimizer = torch.optim.AdamW(
168
+ self.model.parameters(),
169
+ lr=self.config.learning_rate,
170
+ weight_decay=self.config.weight_decay,
171
+ )
172
+
173
+ if self.config.warmup_type == "ratio":
174
+ final_warmup_steps = math.ceil(self.config.steps * self.config.warmup_ratio)
175
+ else:
176
+ final_warmup_steps = self.config.warmup_steps
177
+
178
+ if self.config.lr_scheduler_type == "constant":
179
+ lr_scheduler = get_constant_schedule_with_warmup(
180
+ optimizer=optimizer,
181
+ num_warmup_steps=final_warmup_steps,
182
+ )
183
+ else:
184
+ lr_scheduler = get_cosine_schedule_with_warmup(
185
+ optimizer=optimizer,
186
+ num_warmup_steps=final_warmup_steps,
187
+ num_training_steps=self.config.steps,
188
+ )
189
+ return optimizer, lr_scheduler
190
+
191
+ def save_checkpoint(self, step):
192
+ """Wrapper for engine save_checkpoint."""
193
+ engine_save_checkpoint(
194
+ self.accelerator,
195
+ self.model,
196
+ self.tokenizer,
197
+ self.config.output_dir,
198
+ step,
199
+ self.config.keep_last_n_checkpoints,
200
+ )
201
+ # Save config copy for convenience
202
+ if self.accelerator.is_main_process and hasattr(self.config, "save_to_json"):
203
+ checkpoint_dir = os.path.join(self.config.output_dir, f"checkpoint-{step}")
204
+ self.config.save_to_json(os.path.join(checkpoint_dir, "train_config.json"))
205
+
206
+ def load_checkpoint(self, checkpoint_path):
207
+ """Wrapper for loading."""
208
+ step = load_checkpoint(self.accelerator, checkpoint_path)
209
+ self.global_step = step
210
+ logger.info(f"Resumed from step {self.global_step}")
211
+ return step
212
+
213
+ def evaluate(self):
214
+ """Evaluation loop."""
215
+ if self.eval_dataloader is None:
216
+ return {}
217
+
218
+ self.model.eval()
219
+ logger.info(f"Running evaluation at step {self.global_step}...")
220
+
221
+ local_loss_sum = torch.tensor(0.0, device=self.accelerator.device)
222
+ eval_count = 0
223
+
224
+ with torch.no_grad():
225
+ for eval_batch in self.eval_dataloader:
226
+ eval_batch = _to_device(eval_batch, self.accelerator.device)
227
+ outputs = self.model(**eval_batch)
228
+ local_loss_sum += outputs.loss.detach()
229
+ eval_count += 1
230
+
231
+ if eval_count > 0:
232
+ local_mean = local_loss_sum / eval_count
233
+ else:
234
+ local_mean = torch.tensor(0.0, device=self.accelerator.device)
235
+
236
+ all_means = self.accelerator.gather(local_mean)
237
+ final_eval_loss = all_means.mean().item()
238
+
239
+ eval_metrics = {"eval/loss": final_eval_loss}
240
+ self.accelerator.log(eval_metrics, step=self.global_step)
241
+ logger.info(f"Eval Loss: {final_eval_loss:.4f}")
242
+
243
+ self.accelerator.wait_for_everyone()
244
+ self.model.train()
245
+ return eval_metrics
246
+
247
+ def train(self):
248
+ """Main training loop."""
249
+ logger.info("Starting Training Loop...")
250
+
251
+ # Resume if configured
252
+ if self.config.resume_from_checkpoint:
253
+ self.load_checkpoint(self.config.resume_from_checkpoint)
254
+
255
+ # Handle IterableDataset Epochs
256
+ if hasattr(self.train_dataloader.dataset, "set_epoch"):
257
+ self.train_dataloader.dataset.set_epoch(self.epoch)
258
+
259
+ # Logger
260
+ train_logger = TrainLogger(
261
+ self.accelerator, self.config.steps, self.config.logging_steps
262
+ )
263
+ train_logger.start(self.global_step)
264
+
265
+ self.model.train()
266
+ train_iterator = iter(self.train_dataloader)
267
+
268
+ logging_start_time = time.time()
269
+ logging_start_step = self.global_step
270
+ tr_loss = torch.tensor(0.0).to(self.accelerator.device)
271
+ logging_loss_scalar = 0.0
272
+
273
+ while self.global_step < self.config.steps:
274
+ try:
275
+ batch = next(train_iterator)
276
+ except StopIteration:
277
+ self.epoch += 1
278
+ logger.info(f"Epoch {self.epoch} starting. Resetting dataloader...")
279
+ if hasattr(self.train_dataloader.dataset, "set_epoch"):
280
+ self.train_dataloader.dataset.set_epoch(self.epoch)
281
+
282
+ train_iterator = iter(self.train_dataloader)
283
+ batch = next(train_iterator)
284
+
285
+ batch = _to_device(batch, self.accelerator.device)
286
+
287
+ with self.accelerator.accumulate(self.model):
288
+ outputs = self.model(**batch)
289
+ loss = outputs.loss
290
+ tr_loss += loss.detach()
291
+ self.accelerator.backward(loss)
292
+
293
+ if self.accelerator.sync_gradients:
294
+ # Clipping
295
+ grad_norm = 0.0
296
+ if self.config.max_grad_norm > 0:
297
+ grad_norm = self.accelerator.clip_grad_norm_(
298
+ self.model.parameters(), self.config.max_grad_norm
299
+ )
300
+ grad_norm = grad_norm.item() if grad_norm is not None else 0.0
301
+
302
+ self.optimizer.step()
303
+ self.lr_scheduler.step()
304
+ self.optimizer.zero_grad()
305
+ self.global_step += 1
306
+
307
+ # Logging
308
+ current_lr = self.lr_scheduler.get_last_lr()[0]
309
+ train_logger.update(
310
+ step=self.global_step, loss=loss.item(), lr=current_lr
311
+ )
312
+
313
+ if self.global_step % self.config.logging_steps == 0:
314
+ elapsed = time.time() - logging_start_time
315
+ steps_per_sec = (
316
+ (self.global_step - logging_start_step) / elapsed
317
+ if elapsed > 0
318
+ else 0
319
+ )
320
+
321
+ tr_loss_scalar = self.accelerator.gather(tr_loss).mean().item()
322
+ current_interval_loss = tr_loss_scalar - logging_loss_scalar
323
+ avg_loss = current_interval_loss / (
324
+ self.config.logging_steps
325
+ * self.config.gradient_accumulation_steps
326
+ )
327
+ logging_loss_scalar = tr_loss_scalar
328
+
329
+ logs = {
330
+ "train/loss": avg_loss,
331
+ "train/learning_rate": current_lr,
332
+ "train/grad_norm": grad_norm,
333
+ "train/epoch": self.epoch,
334
+ "train/steps_per_sec": steps_per_sec,
335
+ }
336
+ train_logger.log_metrics(step=self.global_step, metrics=logs)
337
+
338
+ logging_start_time = time.time()
339
+ logging_start_step = self.global_step
340
+
341
+ # Evaluate
342
+ if (
343
+ self.eval_dataloader is not None
344
+ and self.global_step % self.config.eval_steps == 0
345
+ ):
346
+ self.evaluate()
347
+
348
+ # Save
349
+ if self.global_step % self.config.save_steps == 0:
350
+ self.save_checkpoint(self.global_step)
351
+
352
+ # Final Save
353
+ self.save_checkpoint(self.global_step)
354
+ train_logger.close()
355
+ self.accelerator.end_training()
omnivoice/utils/__init__.py ADDED
File without changes
omnivoice/utils/audio.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Audio I/O and processing utilities.
19
+
20
+ Provides functions for loading, resampling, silence removal,
21
+ chunking, cross-fading, and format conversion.
22
+
23
+ All public functions in this module operate on **numpy float32 arrays**
24
+ with shape ``(C, T)`` (channels-first).
25
+ """
26
+
27
+ import io
28
+ import logging
29
+
30
+ import numpy as np
31
+ import soundfile as sf
32
+ import torch
33
+ import torchaudio
34
+ from pydub import AudioSegment
35
+ from pydub.silence import detect_leading_silence, detect_nonsilent, split_on_silence
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Loading
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def load_waveform(audio_path: str):
46
+ """Load audio from a file path, returning (data, sample_rate).
47
+
48
+ Tries two backends in order:
49
+ 1. soundfile — covers WAV/FLAC/OGG etc., no ffmpeg needed.
50
+ 2. librosa — covers MP3/M4A etc. via audioread + ffmpeg.
51
+
52
+ Returns:
53
+ (data, sample_rate) where data is a numpy float32 array of
54
+ shape (C, T).
55
+ """
56
+ try:
57
+ data, sr = sf.read(audio_path, dtype="float32", always_2d=True)
58
+ return data.T, sr # (T, C) → (C, T)
59
+ except Exception:
60
+ # soundfile cannot handle MP3/M4A etc., fall back to librosa.
61
+ import librosa
62
+
63
+ data, sr = librosa.load(audio_path, sr=None, mono=False)
64
+ if data.ndim == 1:
65
+ data = data[np.newaxis, :]
66
+ return data, sr
67
+
68
+
69
+ def load_audio(audio_path: str, sampling_rate: int) -> np.ndarray:
70
+ """Load a waveform from file and resample to the target rate.
71
+
72
+ Parameters:
73
+ audio_path: path of the audio.
74
+ sampling_rate: target sampling rate.
75
+
76
+ Returns:
77
+ Numpy float32 array of shape (1, T).
78
+ """
79
+ data, sr = load_waveform(audio_path)
80
+
81
+ if data.shape[0] > 1:
82
+ data = np.mean(data, axis=0, keepdims=True)
83
+ if sr != sampling_rate:
84
+ data = torchaudio.functional.resample(
85
+ torch.from_numpy(data), orig_freq=sr, new_freq=sampling_rate
86
+ ).numpy()
87
+
88
+ return data
89
+
90
+
91
+ def load_audio_bytes(raw: bytes, sampling_rate: int) -> np.ndarray:
92
+ """Load audio from in-memory bytes and resample.
93
+
94
+ Parameters:
95
+ raw: raw audio file bytes (e.g. from WebDataset).
96
+ sampling_rate: target sampling rate.
97
+
98
+ Returns:
99
+ Numpy float32 array of shape (1, T).
100
+ """
101
+ buf = io.BytesIO(raw)
102
+
103
+ try:
104
+ data, sr = sf.read(buf, dtype="float32", always_2d=True)
105
+ data = data.T # (T, C) → (C, T)
106
+ except Exception:
107
+ import librosa
108
+
109
+ buf.seek(0)
110
+ data, sr = librosa.load(buf, sr=None, mono=False)
111
+ if data.ndim == 1:
112
+ data = data[np.newaxis, :]
113
+
114
+ if data.shape[0] > 1:
115
+ data = np.mean(data, axis=0, keepdims=True)
116
+ if sr != sampling_rate:
117
+ data = torchaudio.functional.resample(
118
+ torch.from_numpy(data), orig_freq=sr, new_freq=sampling_rate
119
+ ).numpy()
120
+
121
+ return data
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Audio processing (all numpy in / numpy out)
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def numpy_to_audiosegment(audio: np.ndarray, sample_rate: int) -> AudioSegment:
130
+ """Convert a numpy float32 array of shape (C, T) to a pydub AudioSegment."""
131
+ audio_int = (audio * 32768.0).clip(-32768, 32767).astype(np.int16)
132
+ if audio_int.shape[0] > 1:
133
+ audio_int = audio_int.T.flatten() # interleave channels
134
+ return AudioSegment(
135
+ data=audio_int.tobytes(),
136
+ sample_width=2,
137
+ frame_rate=sample_rate,
138
+ channels=audio.shape[0],
139
+ )
140
+
141
+
142
+ def audiosegment_to_numpy(aseg: AudioSegment) -> np.ndarray:
143
+ """Convert a pydub AudioSegment to a numpy float32 array of shape (C, T)."""
144
+ data = np.array(aseg.get_array_of_samples()).astype(np.float32) / 32768.0
145
+ if aseg.channels == 1:
146
+ return data[np.newaxis, :]
147
+ return data.reshape(-1, aseg.channels).T
148
+
149
+
150
+ def remove_silence(
151
+ audio: np.ndarray,
152
+ sampling_rate: int,
153
+ mid_sil: int = 300,
154
+ lead_sil: int = 100,
155
+ trail_sil: int = 300,
156
+ ) -> np.ndarray:
157
+ """Remove middle silences longer than *mid_sil* ms and trim edge silences.
158
+
159
+ Parameters:
160
+ audio: numpy array with shape (C, T).
161
+ sampling_rate: sampling rate of the audio.
162
+ mid_sil: middle-silence threshold in ms (0 to skip).
163
+ lead_sil: kept leading silence in ms.
164
+ trail_sil: kept trailing silence in ms.
165
+
166
+ Returns:
167
+ Numpy array with shape (C, T').
168
+ """
169
+ wave = numpy_to_audiosegment(audio, sampling_rate)
170
+
171
+ if mid_sil > 0:
172
+ non_silent_segs = split_on_silence(
173
+ wave,
174
+ min_silence_len=mid_sil,
175
+ silence_thresh=-50,
176
+ keep_silence=mid_sil,
177
+ seek_step=10,
178
+ )
179
+ wave = AudioSegment.silent(duration=0)
180
+ for seg in non_silent_segs:
181
+ wave += seg
182
+
183
+ wave = remove_silence_edges(wave, lead_sil, trail_sil, -50)
184
+
185
+ return audiosegment_to_numpy(wave)
186
+
187
+
188
+ def remove_silence_edges(
189
+ audio: AudioSegment,
190
+ lead_sil: int = 100,
191
+ trail_sil: int = 300,
192
+ silence_threshold: float = -50,
193
+ ) -> AudioSegment:
194
+ """Remove edge silences, keeping *lead_sil* / *trail_sil* ms."""
195
+ start_idx = detect_leading_silence(audio, silence_threshold=silence_threshold)
196
+ start_idx = max(0, start_idx - lead_sil)
197
+ audio = audio[start_idx:]
198
+
199
+ audio = audio.reverse()
200
+ start_idx = detect_leading_silence(audio, silence_threshold=silence_threshold)
201
+ start_idx = max(0, start_idx - trail_sil)
202
+ audio = audio[start_idx:]
203
+ audio = audio.reverse()
204
+
205
+ return audio
206
+
207
+
208
+ def fade_and_pad_audio(
209
+ audio: np.ndarray,
210
+ pad_duration: float = 0.1,
211
+ fade_duration: float = 0.1,
212
+ sample_rate: int = 24000,
213
+ ) -> np.ndarray:
214
+ """Apply fade-in/out and pad with silence to prevent clicks.
215
+
216
+ Args:
217
+ audio: numpy array of shape (C, T).
218
+ pad_duration: silence padding duration per side (seconds).
219
+ fade_duration: fade curve duration (seconds).
220
+ sample_rate: audio sampling rate.
221
+
222
+ Returns:
223
+ Processed numpy array of shape (C, T_new).
224
+ """
225
+ if audio.shape[-1] == 0:
226
+ return audio
227
+
228
+ fade_samples = int(fade_duration * sample_rate)
229
+ pad_samples = int(pad_duration * sample_rate)
230
+
231
+ processed = audio.copy()
232
+
233
+ if fade_samples > 0:
234
+ k = min(fade_samples, processed.shape[-1] // 2)
235
+ if k > 0:
236
+ fade_in = np.linspace(0, 1, k, dtype=np.float32)[np.newaxis, :]
237
+ processed[..., :k] *= fade_in
238
+
239
+ fade_out = np.linspace(1, 0, k, dtype=np.float32)[np.newaxis, :]
240
+ processed[..., -k:] *= fade_out
241
+
242
+ if pad_samples > 0:
243
+ silence = np.zeros(
244
+ (processed.shape[0], pad_samples),
245
+ dtype=processed.dtype,
246
+ )
247
+ processed = np.concatenate([silence, processed, silence], axis=-1)
248
+
249
+ return processed
250
+
251
+
252
+ def trim_long_audio(
253
+ audio: np.ndarray,
254
+ sampling_rate: int,
255
+ max_duration: float = 15.0,
256
+ min_duration: float = 3.0,
257
+ trim_threshold: float = 20.0,
258
+ ) -> np.ndarray:
259
+ """Trim audio to <= *max_duration* by splitting at the largest silence gap.
260
+
261
+ Only trims when the audio exceeds *trim_threshold* seconds.
262
+
263
+ Args:
264
+ audio: numpy array of shape (C, T).
265
+ sampling_rate: audio sampling rate.
266
+ max_duration: maximum duration in seconds.
267
+ min_duration: minimum duration in seconds.
268
+ trim_threshold: only trim if audio is longer than this (seconds).
269
+
270
+ Returns:
271
+ Trimmed numpy array.
272
+ """
273
+ duration = audio.shape[-1] / sampling_rate
274
+ if duration <= trim_threshold:
275
+ return audio
276
+
277
+ seg = numpy_to_audiosegment(audio, sampling_rate)
278
+ nonsilent = detect_nonsilent(
279
+ seg, min_silence_len=100, silence_thresh=-40, seek_step=10
280
+ )
281
+ if not nonsilent:
282
+ return audio
283
+
284
+ max_ms = int(max_duration * 1000)
285
+ min_ms = int(min_duration * 1000)
286
+
287
+ best_split = 0
288
+ for start, end in nonsilent:
289
+ if start > best_split and start <= max_ms:
290
+ best_split = start
291
+ if end > max_ms:
292
+ break
293
+
294
+ if best_split < min_ms:
295
+ best_split = min(max_ms, len(seg))
296
+
297
+ trimmed = seg[:best_split]
298
+ return audiosegment_to_numpy(trimmed)
299
+
300
+
301
+ def cross_fade_chunks(
302
+ chunks: list[np.ndarray],
303
+ sample_rate: int,
304
+ silence_duration: float = 0.3,
305
+ ) -> np.ndarray:
306
+ """Concatenate audio chunks with silence gaps and cross-fade at boundaries.
307
+
308
+ Args:
309
+ chunks: list of numpy arrays, each (C, T).
310
+ sample_rate: audio sample rate.
311
+ silence_duration: total silence gap duration in seconds.
312
+
313
+ Returns:
314
+ Merged numpy array (C, T_total).
315
+ """
316
+ if len(chunks) == 1:
317
+ return chunks[0]
318
+
319
+ total_n = int(silence_duration * sample_rate)
320
+ fade_n = total_n // 3
321
+ silence_n = fade_n
322
+ merged = chunks[0].copy()
323
+
324
+ for chunk in chunks[1:]:
325
+ parts = [merged]
326
+
327
+ fout_n = min(fade_n, merged.shape[-1])
328
+ if fout_n > 0:
329
+ w_out = np.linspace(1, 0, fout_n, dtype=np.float32)[np.newaxis, :]
330
+ parts[-1][..., -fout_n:] *= w_out
331
+
332
+ parts.append(np.zeros((chunks[0].shape[0], silence_n), dtype=np.float32))
333
+
334
+ fade_in = chunk.copy()
335
+ fin_n = min(fade_n, fade_in.shape[-1])
336
+ if fin_n > 0:
337
+ w_in = np.linspace(0, 1, fin_n, dtype=np.float32)[np.newaxis, :]
338
+ fade_in[..., :fin_n] *= w_in
339
+
340
+ parts.append(fade_in)
341
+ merged = np.concatenate(parts, axis=-1)
342
+
343
+ return merged
omnivoice/utils/common.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Shared utility functions."""
19
+
20
+ import argparse
21
+ import random
22
+
23
+ import numpy as np
24
+ import torch
25
+
26
+
27
+ def str2bool(v):
28
+ """Used in argparse.ArgumentParser.add_argument to indicate
29
+ that a type is a bool type and user can enter
30
+
31
+ - yes, true, t, y, 1, to represent True
32
+ - no, false, f, n, 0, to represent False
33
+
34
+ See https://stackoverflow.com/questions/15008758/parsing-boolean-values-with-argparse # noqa
35
+ """
36
+ if isinstance(v, bool):
37
+ return v
38
+ if v.lower() in ("yes", "true", "t", "y", "1"):
39
+ return True
40
+ elif v.lower() in ("no", "false", "f", "n", "0"):
41
+ return False
42
+ else:
43
+ raise argparse.ArgumentTypeError("Boolean value expected.")
44
+
45
+
46
+ def get_best_device():
47
+ """Auto-detect the best available device: CUDA > XPU > MPS > CPU."""
48
+ if torch.cuda.is_available():
49
+ return "cuda"
50
+ if hasattr(torch, "xpu") and torch.xpu.is_available():
51
+ return "xpu"
52
+ if torch.backends.mps.is_available():
53
+ return "mps"
54
+ return "cpu"
55
+
56
+
57
+ def get_best_device_with_count():
58
+ """Auto-detect best device and return (device_type, device_count)."""
59
+ if torch.cuda.is_available():
60
+ return "cuda", torch.cuda.device_count()
61
+ if hasattr(torch, "xpu") and torch.xpu.is_available():
62
+ return "xpu", torch.xpu.device_count()
63
+ if torch.backends.mps.is_available():
64
+ return "mps", 1
65
+ return "cpu", 1
66
+
67
+
68
+ def fix_random_seed(random_seed: int):
69
+ """
70
+ Set the same random seed for the libraries and modules.
71
+ Includes the ``random`` module, numpy, and torch.
72
+ """
73
+ random.seed(random_seed)
74
+ np.random.seed(random_seed)
75
+ torch.random.manual_seed(random_seed)
76
+ # Ensure deterministic ID creation
77
+ rd = random.Random()
78
+ rd.seed(random_seed)
omnivoice/utils/data_utils.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Data utilities for batch inference and evaluation.
19
+
20
+ Provides ``read_test_list()`` to parse JSONL test list files used by
21
+ ``omnivoice.cli.infer_batch`` and evaluation scripts.
22
+ """
23
+
24
+ import json
25
+ import logging
26
+ from pathlib import Path
27
+
28
+
29
+ def read_test_list(path):
30
+ """Read a JSONL test list file.
31
+
32
+ Each line should be a JSON object. Only ``id`` and ``text`` are required;
33
+ all other fields are optional (default to ``None``):
34
+ id, text, ref_audio, ref_text, instruct,
35
+ language_id, language_name, duration, speed
36
+
37
+ Note: ``language_name`` is only used by evaluation scripts (under
38
+ ``omnivoice/eval/``) for grouping and reporting results. The model
39
+ itself only consumes ``language_id``.
40
+
41
+ Returns a list of dicts.
42
+ """
43
+ path = Path(path)
44
+ samples = []
45
+ with path.open("r", encoding="utf-8") as f:
46
+ for line_no, line in enumerate(f, 1):
47
+ line = line.strip()
48
+ if not line:
49
+ continue
50
+ try:
51
+ obj = json.loads(line)
52
+ except json.JSONDecodeError:
53
+ logging.warning(f"Skipping malformed JSON at line {line_no}: {line}")
54
+ continue
55
+
56
+ sample = {
57
+ "id": obj.get("id"),
58
+ "text": obj.get("text"),
59
+ "ref_audio": obj.get("ref_audio"),
60
+ "ref_text": obj.get("ref_text"),
61
+ "language_id": obj.get("language_id"),
62
+ "language_name": obj.get("language_name"),
63
+ "duration": obj.get("duration"),
64
+ "speed": obj.get("speed"),
65
+ "instruct": obj.get("instruct"),
66
+ }
67
+ samples.append(sample)
68
+ return samples
omnivoice/utils/duration.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Text duration estimation for TTS generation.
19
+
20
+ Provides ``RuleDurationEstimator``, which estimates audio duration from text
21
+ using character phonetic weights across 600+ languages. Used by
22
+ ``OmniVoice.generate()`` to determine output length when no duration is specified.
23
+ """
24
+
25
+ import bisect
26
+ import unicodedata
27
+ from functools import lru_cache
28
+ from typing import Optional
29
+
30
+
31
+ class RuleDurationEstimator:
32
+ def __init__(self):
33
+ # ==========================================
34
+ # 1. Phonetic Weights Table
35
+ # ==========================================
36
+ # The weight represents the relative speaking time compared to
37
+ # a standard Latin letter.
38
+ # Benchmark: 1.0 = One Latin Character (~40-50ms)
39
+ self.weights = {
40
+ # --- Logographic (1 char = full syllable/word) ---
41
+ "cjk": 3.0, # Chinese, Japanese Kanji, etc.
42
+ # --- Syllabic / Blocks
43
+ "hangul": 2.5, # Korean Hangul
44
+ "kana": 2.2, # Japanese Hiragana/Katakana
45
+ "ethiopic": 3.0, # Amharic/Ge'ez
46
+ "yi": 3.0, # Yi script
47
+ # --- Abugida (Consonant-Vowel complexes) ---
48
+ "indic": 1.8, # Hindi, Bengali, Tamil, etc.
49
+ "thai_lao": 1.5, # Thai, Lao
50
+ "khmer_myanmar": 1.8, # Khmer, Myanmar
51
+ # --- Abjad (Consonant-heavy) ---
52
+ "arabic": 1.5, # Arabic, Persian, Urdu
53
+ "hebrew": 1.5, # Hebrew
54
+ # --- Alphabet (Segmental) ---
55
+ "latin": 1.0, # English, Spanish, French, Vietnamese, etc. (Baseline)
56
+ "cyrillic": 1.0, # Russian, Ukrainian
57
+ "greek": 1.0, # Greek
58
+ "armenian": 1.0, # Armenian
59
+ "georgian": 1.0, # Georgian
60
+ # --- Symbols & Misc ---
61
+ "punctuation": 0.5, # Pause capability
62
+ "space": 0.2, # Word boundary/Breath (0.05 / 0.22)
63
+ "digit": 3.5, # Numbers
64
+ "mark": 0.0, # Diacritics/Accents (Silent modifiers)
65
+ "default": 1.0, # Fallback for unknown scripts
66
+ }
67
+
68
+ # ==========================================
69
+ # 2. Unicode Range Mapping
70
+ # ==========================================
71
+ # Format: (End_Codepoint, Type_Key)
72
+ # Used for fast binary search (bisect).
73
+ self.ranges = [
74
+ (0x02AF, "latin"), # Latin (Basic, Supplement, Ext, IPA)
75
+ (0x03FF, "greek"), # Greek & Coptic
76
+ (0x052F, "cyrillic"), # Cyrillic
77
+ (0x058F, "armenian"), # Armenian
78
+ (0x05FF, "hebrew"), # Hebrew
79
+ (0x077F, "arabic"), # Arabic, Syriac, Arabic Supplement
80
+ (0x089F, "arabic"), # Arabic Extended-B (+ Syriac Supp)
81
+ (0x08FF, "arabic"), # Arabic Extended-A
82
+ (0x097F, "indic"), # Devanagari
83
+ (0x09FF, "indic"), # Bengali
84
+ (0x0A7F, "indic"), # Gurmukhi
85
+ (0x0AFF, "indic"), # Gujarati
86
+ (0x0B7F, "indic"), # Oriya
87
+ (0x0BFF, "indic"), # Tamil
88
+ (0x0C7F, "indic"), # Telugu
89
+ (0x0CFF, "indic"), # Kannada
90
+ (0x0D7F, "indic"), # Malayalam
91
+ (0x0DFF, "indic"), # Sinhala
92
+ (0x0EFF, "thai_lao"), # Thai & Lao
93
+ (0x0FFF, "indic"), # Tibetan (Abugida)
94
+ (0x109F, "khmer_myanmar"), # Myanmar
95
+ (0x10FF, "georgian"), # Georgian
96
+ (0x11FF, "hangul"), # Hangul Jamo
97
+ (0x137F, "ethiopic"), # Ethiopic
98
+ (0x139F, "ethiopic"), # Ethiopic Supplement
99
+ (0x13FF, "default"), # Cherokee
100
+ (0x167F, "default"), # Canadian Aboriginal Syllabics
101
+ (0x169F, "default"), # Ogham
102
+ (0x16FF, "default"), # Runic
103
+ (0x171F, "default"), # Tagalog (Baybayin)
104
+ (0x173F, "default"), # Hanunoo
105
+ (0x175F, "default"), # Buhid
106
+ (0x177F, "default"), # Tagbanwa
107
+ (0x17FF, "khmer_myanmar"), # Khmer
108
+ (0x18AF, "default"), # Mongolian
109
+ (0x18FF, "default"), # Canadian Aboriginal Syllabics Ext
110
+ (0x194F, "indic"), # Limbu
111
+ (0x19DF, "indic"), # Tai Le & New Tai Lue
112
+ (0x19FF, "khmer_myanmar"), # Khmer Symbols
113
+ (0x1A1F, "indic"), # Buginese
114
+ (0x1AAF, "indic"), # Tai Tham
115
+ (0x1B7F, "indic"), # Balinese
116
+ (0x1BBF, "indic"), # Sundanese
117
+ (0x1BFF, "indic"), # Batak
118
+ (0x1C4F, "indic"), # Lepcha
119
+ (0x1C7F, "indic"), # Ol Chiki (Santali)
120
+ (0x1C8F, "cyrillic"), # Cyrillic Extended-C
121
+ (0x1CBF, "georgian"), # Georgian Extended
122
+ (0x1CCF, "indic"), # Sundanese Supplement
123
+ (0x1CFF, "indic"), # Vedic Extensions
124
+ (0x1D7F, "latin"), # Phonetic Extensions
125
+ (0x1DBF, "latin"), # Phonetic Extensions Supplement
126
+ (0x1DFF, "default"), # Combining Diacritical Marks Supplement
127
+ (0x1EFF, "latin"), # Latin Extended Additional (Vietnamese)
128
+ (0x309F, "kana"), # Hiragana
129
+ (0x30FF, "kana"), # Katakana
130
+ (0x312F, "cjk"), # Bopomofo (Pinyin)
131
+ (0x318F, "hangul"), # Hangul Compatibility Jamo
132
+ (0x9FFF, "cjk"), # CJK Unified Ideographs (Main)
133
+ (0xA4CF, "yi"), # Yi Syllables
134
+ (0xA4FF, "default"), # Lisu
135
+ (0xA63F, "default"), # Vai
136
+ (0xA69F, "cyrillic"), # Cyrillic Extended-B
137
+ (0xA6FF, "default"), # Bamum
138
+ (0xA7FF, "latin"), # Latin Extended-D
139
+ (0xA82F, "indic"), # Syloti Nagri
140
+ (0xA87F, "default"), # Phags-pa
141
+ (0xA8DF, "indic"), # Saurashtra
142
+ (0xA8FF, "indic"), # Devanagari Extended
143
+ (0xA92F, "indic"), # Kayah Li
144
+ (0xA95F, "indic"), # Rejang
145
+ (0xA97F, "hangul"), # Hangul Jamo Extended-A
146
+ (0xA9DF, "indic"), # Javanese
147
+ (0xA9FF, "khmer_myanmar"), # Myanmar Extended-B
148
+ (0xAA5F, "indic"), # Cham
149
+ (0xAA7F, "khmer_myanmar"), # Myanmar Extended-A
150
+ (0xAADF, "indic"), # Tai Viet
151
+ (0xAAFF, "indic"), # Meetei Mayek Extensions
152
+ (0xAB2F, "ethiopic"), # Ethiopic Extended-A
153
+ (0xAB6F, "latin"), # Latin Extended-E
154
+ (0xABBF, "default"), # Cherokee Supplement
155
+ (0xABFF, "indic"), # Meetei Mayek
156
+ (0xD7AF, "hangul"), # Hangul Syllables
157
+ (0xFAFF, "cjk"), # CJK Compatibility
158
+ (0xFDFF, "arabic"), # Arabic Presentation Forms-A
159
+ (0xFE6F, "default"), # Variation Selectors
160
+ (0xFEFF, "arabic"), # Arabic Presentation Forms-B
161
+ (0xFFEF, "latin"), # Fullwidth Latin
162
+ ]
163
+ self.breakpoints = [r[0] for r in self.ranges]
164
+
165
+ @lru_cache(maxsize=4096)
166
+ def _get_char_weight(self, char):
167
+ """Determines the weight of a single character."""
168
+ code = ord(char)
169
+ if (65 <= code <= 90) or (97 <= code <= 122):
170
+ return self.weights["latin"]
171
+ if code == 32:
172
+ return self.weights["space"]
173
+
174
+ # Ignore arabic Tatweel
175
+ if code == 0x0640:
176
+ return self.weights["mark"]
177
+
178
+ category = unicodedata.category(char)
179
+
180
+ if category.startswith("M"):
181
+ return self.weights["mark"]
182
+
183
+ if category.startswith("P") or category.startswith("S"):
184
+ return self.weights["punctuation"]
185
+
186
+ if category.startswith("Z"):
187
+ return self.weights["space"]
188
+
189
+ if category.startswith("N"):
190
+ return self.weights["digit"]
191
+
192
+ # 3. Binary search for Unicode Block (此时区间里绝不会再混进标点符号)
193
+ idx = bisect.bisect_left(self.breakpoints, code)
194
+ if idx < len(self.ranges):
195
+ script_type = self.ranges[idx][1]
196
+ return self.weights.get(script_type, self.weights["default"])
197
+
198
+ # 4. Handle upper planes (CJK Ext B/C/D, Historic scripts)
199
+ if code > 0x20000:
200
+ return self.weights["cjk"]
201
+
202
+ return self.weights["default"]
203
+
204
+ def calculate_total_weight(self, text):
205
+ """Sums up the normalized weights for a string."""
206
+ return sum(self._get_char_weight(c) for c in text)
207
+
208
+ def estimate_duration(
209
+ self,
210
+ target_text: str,
211
+ ref_text: str,
212
+ ref_duration: float,
213
+ low_threshold: Optional[float] = 50,
214
+ boost_strength: float = 3,
215
+ ) -> float:
216
+ """
217
+
218
+ Args:
219
+ target_text (str): The text for which we want to estimate the duration.
220
+ ref_text (str): The reference text that was used to measure
221
+ the ref_duration.
222
+ ref_duration (float): The actual duration it took
223
+ to speak the ref_text.
224
+ low_threshold (float): The minimum duration threshold below which the
225
+ estimation will be considered unreliable.
226
+ boost_strength (float): Controls the power-curve boost for short durations.
227
+ Higher values boost small durations more aggressively.
228
+ 1 = no boost (linear), 2 = sqrt-like
229
+
230
+ Returns:
231
+ float: The estimated duration for the target_text based
232
+ on the ref_text and ref_duration.
233
+ """
234
+ if ref_duration <= 0 or not ref_text:
235
+ return 0.0
236
+
237
+ ref_weight = self.calculate_total_weight(ref_text)
238
+ if ref_weight == 0:
239
+ return 0.0
240
+
241
+ speed_factor = ref_weight / ref_duration
242
+ target_weight = self.calculate_total_weight(target_text)
243
+
244
+ estimated_duration = target_weight / speed_factor
245
+ if low_threshold is not None and estimated_duration < low_threshold:
246
+ alpha = 1.0 / boost_strength
247
+ return low_threshold * (estimated_duration / low_threshold) ** alpha
248
+ else:
249
+ return estimated_duration
250
+
251
+
252
+ # ==========================================
253
+ # Example Usage
254
+ # ==========================================
255
+ if __name__ == "__main__":
256
+ estimator = RuleDurationEstimator()
257
+
258
+ ref_txt = "Hello, world."
259
+ ref_dur = 1.5
260
+
261
+ test_cases = [
262
+ ("Hindi (With complex marks)", "नमस्ते दुनिया"),
263
+ ("Arabic (With vowels)", "مَرْحَبًا بِالْعَالَم"),
264
+ ("Vietnamese (Lots of diacritics)", "Chào thế giới"),
265
+ ("Chinese", "你好,世界!"),
266
+ ("Mixed Emoji", "Hello 🌍! This is fun 🎉"),
267
+ ]
268
+
269
+ print("--- Reference ---")
270
+ print(f"Reference Text: '{ref_txt}'")
271
+ print(f"Reference Duration: {ref_dur}s")
272
+ print("-" * 30)
273
+
274
+ for lang, txt in test_cases:
275
+ est_time = estimator.estimate_duration(txt, ref_txt, ref_dur)
276
+ weight = estimator.calculate_total_weight(txt)
277
+
278
+ print(f"[{lang}]")
279
+ print(f"Text: {txt}")
280
+ print(f"Total Weight: {weight:.2f}")
281
+ print(f"Estimated Duration: {est_time:.2f} s")
282
+ print("-" * 30)
omnivoice/utils/lang_map.py ADDED
@@ -0,0 +1,698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Language name to ISO 639-3 code mapping.
19
+
20
+ Auto-generated from ``docs/lang_id_name_map.tsv``. Provides ``LANG_NAME_TO_ID``
21
+ (for resolving language names to codes) and ``LANG_IDS`` (the set of supported
22
+ ISO 639-3 codes). Used by ``OmniVoice.generate()`` to resolve user-provided
23
+ language names.
24
+ """
25
+
26
+ # Auto-generated from docs/lang_id_name_map.tsv
27
+ # Maps lowercase language name -> language ID code
28
+
29
+ LANG_NAME_TO_ID = {
30
+ "abadi": "kbt",
31
+ "abkhazian": "ab",
32
+ "abron": "abr",
33
+ "abua": "abn",
34
+ "adamawa fulfulde": "fub",
35
+ "adyghe": "ady",
36
+ "afade": "aal",
37
+ "afrikaans": "af",
38
+ "agwagwune": "yay",
39
+ "aja (benin)": "ajg",
40
+ "akebu": "keu",
41
+ "alago": "ala",
42
+ "albanian": "sq",
43
+ "algerian arabic": "arq",
44
+ "algerian saharan arabic": "aao",
45
+ "ambo-pasco quechua": "qva",
46
+ "ambonese malay": "abs",
47
+ "amdo tibetan": "adx",
48
+ "amharic": "am",
49
+ "anaang": "anw",
50
+ "angika": "anp",
51
+ "antankarana malagasy": "xmv",
52
+ "aragonese": "an",
53
+ "arbëreshë albanian": "aae",
54
+ "arequipa-la unión quechua": "qxu",
55
+ "armenian": "hy",
56
+ "ashe": "ahs",
57
+ "ashéninka perené": "prq",
58
+ "askopan": "eiv",
59
+ "assamese": "as",
60
+ "asturian": "ast",
61
+ "atayal": "tay",
62
+ "awak": "awo",
63
+ "ayacucho quechua": "quy",
64
+ "azerbaijani": "az",
65
+ "baatonum": "bba",
66
+ "bacama": "bcy",
67
+ "bade": "bde",
68
+ "bafia": "ksf",
69
+ "bafut": "bfd",
70
+ "bagirmi fulfulde": "fui",
71
+ "bago-kusuntu": "bqg",
72
+ "baharna arabic": "abv",
73
+ "bakoko": "bkh",
74
+ "balanta-ganja": "bjt",
75
+ "balti": "bft",
76
+ "bamenyam": "bce",
77
+ "bamun": "bax",
78
+ "bangwinji": "bsj",
79
+ "banjar": "bjn",
80
+ "bankon": "abb",
81
+ "baoulé": "bci",
82
+ "bara malagasy": "bhr",
83
+ "barok": "bjk",
84
+ "basa (cameroon)": "bas",
85
+ "basa (nigeria)": "bzw",
86
+ "bashkir": "ba",
87
+ "basque": "eu",
88
+ "batak mandailing": "btm",
89
+ "batanga": "bnm",
90
+ "bateri": "btv",
91
+ "bats": "bbl",
92
+ "bayot": "bda",
93
+ "bebele": "beb",
94
+ "belarusian": "be",
95
+ "bengali": "bn",
96
+ "betawi": "bew",
97
+ "bhili": "bhb",
98
+ "bhojpuri": "bho",
99
+ "bilur": "bxf",
100
+ "bima": "bhp",
101
+ "bodo": "brx",
102
+ "boghom": "bux",
103
+ "bokyi": "bky",
104
+ "bomu": "bmq",
105
+ "bondei": "bou",
106
+ "borgu fulfulde": "fue",
107
+ "bosnian": "bs",
108
+ "brahui": "brh",
109
+ "braj": "bra",
110
+ "breton": "br",
111
+ "buduma": "bdm",
112
+ "buginese": "bug",
113
+ "bukharic": "bhh",
114
+ "bulgarian": "bg",
115
+ "bulu (cameroon)": "bum",
116
+ "bundeli": "bns",
117
+ "bunun": "bnn",
118
+ "bura-pabir": "bwr",
119
+ "burak": "bys",
120
+ "burmese": "my",
121
+ "burushaski": "bsk",
122
+ "cacaloxtepec mixtec": "miu",
123
+ "cajatambo north lima quechua": "qvl",
124
+ "cakfem-mushere": "cky",
125
+ "cameroon pidgin": "wes",
126
+ "campidanese sardinian": "sro",
127
+ "cantonese": "yue",
128
+ "catalan": "ca",
129
+ "cebuano": "ceb",
130
+ "cen": "cen",
131
+ "central kurdish": "ckb",
132
+ "central nahuatl": "nhn",
133
+ "central pame": "pbs",
134
+ "central pashto": "pst",
135
+ "central puebla nahuatl": "ncx",
136
+ "central tarahumara": "tar",
137
+ "central yupik": "esu",
138
+ "central-eastern niger fulfulde": "fuq",
139
+ "chadian arabic": "shu",
140
+ "chichewa": "ny",
141
+ "chichicapan zapotec": "zpv",
142
+ "chiga": "cgg",
143
+ "chimalapa zoque": "zoh",
144
+ "chimborazo highland quichua": "qug",
145
+ "chinese": "zh",
146
+ "chiquián ancash quechua": "qxa",
147
+ "chitwania tharu": "the",
148
+ "chokwe": "cjk",
149
+ "chuvash": "cv",
150
+ "cibak": "ckl",
151
+ "coastal konjo": "kjc",
152
+ "copainalá zoque": "zoc",
153
+ "cornish": "kw",
154
+ "corongo ancash quechua": "qwa",
155
+ "croatian": "hr",
156
+ "cross river mbembe": "mfn",
157
+ "cuyamecalco mixtec": "xtu",
158
+ "czech": "cs",
159
+ "dadiya": "dbd",
160
+ "dagbani": "dag",
161
+ "dameli": "dml",
162
+ "danish": "da",
163
+ "dargwa": "dar",
164
+ "dazaga": "dzg",
165
+ "deccan": "dcc",
166
+ "degema": "deg",
167
+ "dera (nigeria)": "kna",
168
+ "dghwede": "dgh",
169
+ "dhatki": "mki",
170
+ "dhivehi": "dv",
171
+ "dhofari arabic": "adf",
172
+ "dijim-bwilim": "cfa",
173
+ "dogri": "dgo",
174
+ "domaaki": "dmk",
175
+ "dotyali": "dty",
176
+ "duala": "dua",
177
+ "dutch": "nl",
178
+ "dũya": "ldb",
179
+ "dyula": "dyu",
180
+ "eastern balochi": "bgp",
181
+ "eastern bolivian guaraní": "gui",
182
+ "eastern egyptian bedawi arabic": "avl",
183
+ "eastern krahn": "kqo",
184
+ "eastern mari": "mhr",
185
+ "eastern yiddish": "ydd",
186
+ "ebrié": "ebr",
187
+ "eggon": "ego",
188
+ "egyptian arabic": "arz",
189
+ "ejagham": "etu",
190
+ "eleme": "elm",
191
+ "eloyi": "afo",
192
+ "embu": "ebu",
193
+ "english": "en",
194
+ "erzya": "myv",
195
+ "esan": "ish",
196
+ "esperanto": "eo",
197
+ "estonian": "et",
198
+ "eton (cameroon)": "eto",
199
+ "ewondo": "ewo",
200
+ "extremaduran": "ext",
201
+ "fang (equatorial guinea)": "fan",
202
+ "fanti": "fat",
203
+ "farefare": "gur",
204
+ "fe'fe'": "fmp",
205
+ "filipino": "fil",
206
+ "filomena mata-coahuitlán totonac": "tlp",
207
+ "finnish": "fi",
208
+ "fipa": "fip",
209
+ "french": "fr",
210
+ "fulah": "ff",
211
+ "galician": "gl",
212
+ "gambian wolof": "wof",
213
+ "ganda": "lg",
214
+ "garhwali": "gbm",
215
+ "gawar-bati": "gwt",
216
+ "gawri": "gwc",
217
+ "gbagyi": "gbr",
218
+ "gbari": "gby",
219
+ "geji": "gyz",
220
+ "gen": "gej",
221
+ "georgian": "ka",
222
+ "german": "de",
223
+ "geser-gorom": "ges",
224
+ "gheg albanian": "aln",
225
+ "ghomálá'": "bbj",
226
+ "gidar": "gid",
227
+ "glavda": "glw",
228
+ "goan konkani": "gom",
229
+ "goaria": "gig",
230
+ "goemai": "ank",
231
+ "gola": "gol",
232
+ "greek": "el",
233
+ "guarani": "gn",
234
+ "guduf-gava": "gdf",
235
+ "guerrero amuzgo": "amu",
236
+ "gujarati": "gu",
237
+ "gujari": "gju",
238
+ "gulf arabic": "afb",
239
+ "gurgula": "ggg",
240
+ "gusii": "guz",
241
+ "gusilay": "gsl",
242
+ "gweno": "gwe",
243
+ "güilá zapotec": "ztu",
244
+ "hadothi": "hoj",
245
+ "hahon": "hah",
246
+ "haitian": "ht",
247
+ "hakha chin": "cnh",
248
+ "hakö": "hao",
249
+ "halia": "hla",
250
+ "hausa": "ha",
251
+ "hawaiian": "haw",
252
+ "hazaragi": "haz",
253
+ "hebrew": "he",
254
+ "hemba": "hem",
255
+ "herero": "hz",
256
+ "highland konjo": "kjk",
257
+ "hijazi arabic": "acw",
258
+ "hindi": "hi",
259
+ "huarijio": "var",
260
+ "huautla mazatec": "mau",
261
+ "huaxcaleca nahuatl": "nhq",
262
+ "huba": "hbb",
263
+ "huitepec mixtec": "mxs",
264
+ "hula": "hul",
265
+ "hungarian": "hu",
266
+ "hunjara-kaina ke": "hkk",
267
+ "hwana": "hwo",
268
+ "ibibio": "ibb",
269
+ "icelandic": "is",
270
+ "idakho-isukha-tiriki": "ida",
271
+ "idoma": "idu",
272
+ "igbo": "ig",
273
+ "igo": "ahl",
274
+ "ikposo": "kpo",
275
+ "ikwere": "ikw",
276
+ "imbabura highland quichua": "qvi",
277
+ "indonesian": "id",
278
+ "indus kohistani": "mvy",
279
+ "interlingua (international auxiliary language association)": "ia",
280
+ "inupiaq": "ik",
281
+ "irish": "ga",
282
+ "iron ossetic": "os",
283
+ "isekiri": "its",
284
+ "isoko": "iso",
285
+ "italian": "it",
286
+ "ito": "itw",
287
+ "itzá": "itz",
288
+ "ixtayutla mixtec": "vmj",
289
+ "izon": "ijc",
290
+ "jambi malay": "jax",
291
+ "japanese": "ja",
292
+ "jaqaru": "jqr",
293
+ "jauja wanca quechua": "qxw",
294
+ "jaunsari": "jns",
295
+ "javanese": "jv",
296
+ "jiba": "juo",
297
+ "jju": "kaj",
298
+ "judeo-moroccan arabic": "aju",
299
+ "juxtlahuaca mixtec": "vmc",
300
+ "kabardian": "kbd",
301
+ "kabras": "lkb",
302
+ "kabuverdianu": "kea",
303
+ "kabyle": "kab",
304
+ "kachi koli": "gjk",
305
+ "kairak": "ckr",
306
+ "kalabari": "ijn",
307
+ "kalasha": "kls",
308
+ "kalenjin": "kln",
309
+ "kalkoti": "xka",
310
+ "kamba": "kam",
311
+ "kamo": "kcq",
312
+ "kanauji": "bjj",
313
+ "kanembu": "kbl",
314
+ "kannada": "kn",
315
+ "karekare": "kai",
316
+ "kashmiri": "ks",
317
+ "kathoriya tharu": "tkt",
318
+ "kati": "bsh",
319
+ "kazakh": "kk",
320
+ "keiyo": "eyo",
321
+ "khams tibetan": "khg",
322
+ "khana": "ogo",
323
+ "khetrani": "xhe",
324
+ "khmer": "km",
325
+ "khowar": "khw",
326
+ "kinga": "zga",
327
+ "kinnauri": "kfk",
328
+ "kinyarwanda": "rw",
329
+ "kirghiz": "ky",
330
+ "kirya-konzəl": "fkk",
331
+ "kochila tharu": "thq",
332
+ "kohistani shina": "plk",
333
+ "kohumono": "bcs",
334
+ "kok borok": "trp",
335
+ "kol (papua new guinea)": "kol",
336
+ "kom (cameroon)": "bkm",
337
+ "koma": "kmy",
338
+ "konkani": "knn",
339
+ "konzo": "koo",
340
+ "korean": "ko",
341
+ "korwa": "kfp",
342
+ "kota (india)": "kfe",
343
+ "koti": "eko",
344
+ "kuanua": "ksd",
345
+ "kuanyama": "kj",
346
+ "kui (india)": "uki",
347
+ "kulung (nigeria)": "bbu",
348
+ "kuot": "kto",
349
+ "kushi": "kuh",
350
+ "kwambi": "kwm",
351
+ "kwasio": "nmg",
352
+ "lala-roba": "lla",
353
+ "lamang": "hia",
354
+ "lao": "lo",
355
+ "larike-wakasihu": "alo",
356
+ "lasi": "lss",
357
+ "latgalian": "ltg",
358
+ "latvian": "lv",
359
+ "levantine arabic": "apc",
360
+ "liana-seti": "ste",
361
+ "liberia kpelle": "xpe",
362
+ "liberian english": "lir",
363
+ "libyan arabic": "ayl",
364
+ "ligurian": "lij",
365
+ "lijili": "mgi",
366
+ "lingala": "ln",
367
+ "lithuanian": "lt",
368
+ "loarki": "lrk",
369
+ "logooli": "rag",
370
+ "logudorese sardinian": "src",
371
+ "loja highland quichua": "qvj",
372
+ "loloda": "loa",
373
+ "longuda": "lnu",
374
+ "loxicha zapotec": "ztp",
375
+ "luba-lulua": "lua",
376
+ "luo": "luo",
377
+ "lushai": "lus",
378
+ "luxembourgish": "lb",
379
+ "maasina fulfulde": "ffm",
380
+ "maba (chad)": "mde",
381
+ "macedo-romanian": "rup",
382
+ "macedonian": "mk",
383
+ "mada (cameroon)": "mxu",
384
+ "mafa": "maf",
385
+ "maithili": "mai",
386
+ "malay": "ms",
387
+ "malayalam": "ml",
388
+ "mali": "gcc",
389
+ "malinaltepec me'phaa": "tcf",
390
+ "maltese": "mt",
391
+ "mandara": "tbf",
392
+ "mandjak": "mfv",
393
+ "manggarai": "mqy",
394
+ "manipuri": "mni",
395
+ "mansoanka": "msw",
396
+ "manx": "gv",
397
+ "maori": "mi",
398
+ "marathi": "mr",
399
+ "marghi central": "mrt",
400
+ "marghi south": "mfm",
401
+ "maria (india)": "mrr",
402
+ "marwari (pakistan)": "mve",
403
+ "masana": "mcn",
404
+ "masikoro malagasy": "msh",
405
+ "matsés": "mcf",
406
+ "mazaltepec zapotec": "zpy",
407
+ "mazatlán mazatec": "vmz",
408
+ "mazatlán mixe": "mzl",
409
+ "mbe": "mfo",
410
+ "mbo (cameroon)": "mbo",
411
+ "mbum": "mdd",
412
+ "medumba": "byv",
413
+ "mekeo": "mek",
414
+ "meru": "mer",
415
+ "mesopotamian arabic": "acm",
416
+ "mewari": "mtr",
417
+ "min nan chinese": "nan",
418
+ "mingrelian": "xmf",
419
+ "mitlatongo mixtec": "vmm",
420
+ "miya": "mkf",
421
+ "mokpwe": "bri",
422
+ "moksha": "mdf",
423
+ "mom jango": "ver",
424
+ "mongolian": "mn",
425
+ "moroccan arabic": "ary",
426
+ "motu": "meu",
427
+ "mpiemo": "mcx",
428
+ "mpumpong": "mgg",
429
+ "mundang": "mua",
430
+ "mungaka": "mhk",
431
+ "musey": "mse",
432
+ "musgu": "mug",
433
+ "musi": "mui",
434
+ "naba": "mne",
435
+ "najdi arabic": "ars",
436
+ "nalik": "nal",
437
+ "nawdm": "nmz",
438
+ "ndonga": "ng",
439
+ "neapolitan": "nap",
440
+ "nepali": "npi",
441
+ "ngamo": "nbh",
442
+ "ngas": "anc",
443
+ "ngiemboon": "nnh",
444
+ "ngizim": "ngi",
445
+ "ngomba": "jgo",
446
+ "ngombale": "nla",
447
+ "nigerian fulfulde": "fuv",
448
+ "nigerian pidgin": "pcm",
449
+ "nimadi": "noe",
450
+ "nobiin": "fia",
451
+ "north mesopotamian arabic": "ayp",
452
+ "north moluccan malay": "max",
453
+ "northern betsimisaraka malagasy": "bmm",
454
+ "northern hindko": "hno",
455
+ "northern kurdish": "kmr",
456
+ "northern pame": "pmq",
457
+ "northern pashto": "pbu",
458
+ "northern uzbek": "uzn",
459
+ "northwest gbaya": "gya",
460
+ "norwegian": "no",
461
+ "norwegian bokmål": "nb",
462
+ "norwegian nynorsk": "nn",
463
+ "notsi": "ncf",
464
+ "nyankpa": "yes",
465
+ "nyungwe": "nyu",
466
+ "nzanyi": "nja",
467
+ "nüpode huitoto": "hux",
468
+ "occitan": "oc",
469
+ "od": "odk",
470
+ "odia": "ory",
471
+ "odual": "odu",
472
+ "omani arabic": "acx",
473
+ "orizaba nahuatl": "nlv",
474
+ "orma": "orc",
475
+ "ormuri": "oru",
476
+ "oromo": "om",
477
+ "pahari-potwari": "phr",
478
+ "paiwan": "pwn",
479
+ "panjabi": "pa",
480
+ "papuan malay": "pmy",
481
+ "parkari koli": "kvx",
482
+ "pedi": "nso",
483
+ "pero": "pip",
484
+ "persian": "fa",
485
+ "petats": "pex",
486
+ "phalura": "phl",
487
+ "piemontese": "pms",
488
+ "piya-kwonci": "piy",
489
+ "plateau malagasy": "plt",
490
+ "polish": "pl",
491
+ "poqomam": "poc",
492
+ "portuguese": "pt",
493
+ "pulaar": "fuc",
494
+ "pular": "fuf",
495
+ "puno quechua": "qxp",
496
+ "pushto": "ps",
497
+ "pökoot": "pko",
498
+ "qaqet": "byx",
499
+ "quiotepec chinantec": "chq",
500
+ "rana tharu": "thr",
501
+ "rangi": "lag",
502
+ "rapoisi": "kyx",
503
+ "ratahan": "rth",
504
+ "rayón zoque": "zor",
505
+ "romanian": "ro",
506
+ "romansh": "rm",
507
+ "rombo": "rof",
508
+ "rotokas": "roo",
509
+ "rukai": "dru",
510
+ "russian": "ru",
511
+ "sacapulteco": "quv",
512
+ "saidi arabic": "aec",
513
+ "sakalava malagasy": "skg",
514
+ "sakizaya": "szy",
515
+ "saleman": "sau",
516
+ "samba daka": "ccg",
517
+ "samba leko": "ndi",
518
+ "san felipe otlaltepec popoloca": "pow",
519
+ "san francisco del mar huave": "hue",
520
+ "san juan atzingo popoloca": "poe",
521
+ "san martín itunyoso triqui": "trq",
522
+ "san miguel el grande mixtec": "mig",
523
+ "sansi": "ssi",
524
+ "sanskrit": "sa",
525
+ "santa ana de tusi pasco quechua": "qxt",
526
+ "santa catarina albarradas zapotec": "ztn",
527
+ "santali": "sat",
528
+ "santiago del estero quichua": "qus",
529
+ "saposa": "sps",
530
+ "saraiki": "skr",
531
+ "sardinian": "sc",
532
+ "saya": "say",
533
+ "sediq": "trv",
534
+ "serbian": "sr",
535
+ "seri": "sei",
536
+ "shina": "scl",
537
+ "shona": "sn",
538
+ "siar-lak": "sjr",
539
+ "sibe": "nco",
540
+ "sicilian": "scn",
541
+ "sihuas ancash quechua": "qws",
542
+ "sikkimese": "sip",
543
+ "sinaugoro": "snc",
544
+ "sindhi": "sd",
545
+ "sindhi bhil": "sbn",
546
+ "sinhala": "si",
547
+ "sinicahua mixtec": "xti",
548
+ "sipacapense": "qum",
549
+ "siwai": "siw",
550
+ "slovak": "sk",
551
+ "slovenian": "sl",
552
+ "solos": "sol",
553
+ "somali": "so",
554
+ "soninke": "snk",
555
+ "south giziga": "giz",
556
+ "south ucayali ashéninka": "cpy",
557
+ "southeastern nochixtlán mixtec": "mxy",
558
+ "southern betsimisaraka malagasy": "bzc",
559
+ "southern pashto": "pbt",
560
+ "southern pastaza quechua": "qup",
561
+ "soyaltepec mazatec": "vmp",
562
+ "spanish": "es",
563
+ "standard arabic": "arb",
564
+ "standard moroccan tamazight": "zgh",
565
+ "sudanese arabic": "apd",
566
+ "sulka": "sua",
567
+ "svan": "sva",
568
+ "swahili": "sw",
569
+ "swedish": "sv",
570
+ "tae'": "rob",
571
+ "tahaggart tamahaq": "thv",
572
+ "taita": "dav",
573
+ "tajik": "tg",
574
+ "tamil": "ta",
575
+ "tandroy-mahafaly malagasy": "tdx",
576
+ "tangale": "tan",
577
+ "tanosy malagasy": "txy",
578
+ "tarok": "yer",
579
+ "tatar": "tt",
580
+ "tedaga": "tuq",
581
+ "telugu": "te",
582
+ "tem": "kdh",
583
+ "teop": "tio",
584
+ "tepeuxila cuicatec": "cux",
585
+ "tepinapa chinantec": "cte",
586
+ "tera": "ttr",
587
+ "terei": "buo",
588
+ "termanu": "twu",
589
+ "tesaka malagasy": "tkg",
590
+ "tetelcingo nahuatl": "nhg",
591
+ "teutila cuicatec": "cut",
592
+ "thai": "th",
593
+ "tibetan": "bo",
594
+ "tidaá mixtec": "mtx",
595
+ "tidore": "tvo",
596
+ "tigak": "tgc",
597
+ "tigre": "tig",
598
+ "tigrinya": "ti",
599
+ "tilquiapan zapotec": "zts",
600
+ "tinputz": "tpz",
601
+ "tlacoapa me'phaa": "tpl",
602
+ "tlacoatzintepec chinantec": "ctl",
603
+ "tlingit": "tli",
604
+ "toki pona": "tok",
605
+ "tomoip": "tqp",
606
+ "tondano": "tdn",
607
+ "tonsea": "txs",
608
+ "tooro": "ttj",
609
+ "torau": "ttu",
610
+ "torwali": "trw",
611
+ "tsimihety malagasy": "xmw",
612
+ "tsotso": "lto",
613
+ "tswana": "tn",
614
+ "tugen": "tuy",
615
+ "tuki": "bag",
616
+ "tula": "tul",
617
+ "tulu": "tcy",
618
+ "tunen": "tvu",
619
+ "tungag": "lcm",
620
+ "tunisian arabic": "aeb",
621
+ "tupuri": "tui",
622
+ "turkana": "tuv",
623
+ "turkish": "tr",
624
+ "turkmen": "tk",
625
+ "tututepec mixtec": "mtu",
626
+ "twi": "tw",
627
+ "ubaghara": "byc",
628
+ "uighur": "ug",
629
+ "ukrainian": "uk",
630
+ "umbundu": "umb",
631
+ "upper sorbian": "hsb",
632
+ "urdu": "ur",
633
+ "ushojo": "ush",
634
+ "uzbek": "uz",
635
+ "vai": "vai",
636
+ "vietnamese": "vi",
637
+ "votic": "vot",
638
+ "võro": "vro",
639
+ "waci gbe": "wci",
640
+ "wadiyara koli": "kxp",
641
+ "waja": "wja",
642
+ "wakhi": "wbl",
643
+ "wanga": "lwg",
644
+ "wapan": "juk",
645
+ "warji": "wji",
646
+ "welsh": "cy",
647
+ "wemale": "weo",
648
+ "western frisian": "fy",
649
+ "western highland purepecha": "pua",
650
+ "western juxtlahuaca mixtec": "jmx",
651
+ "western maninkakan": "mlq",
652
+ "western mari": "mrj",
653
+ "western niger fulfulde": "fuh",
654
+ "western panjabi": "pnb",
655
+ "wolof": "wo",
656
+ "wuzlam": "udl",
657
+ "xanaguía zapotec": "ztg",
658
+ "xhosa": "xh",
659
+ "yace": "ekr",
660
+ "yakut": "sah",
661
+ "yalahatan": "jal",
662
+ "yanahuanca pasco quechua": "qur",
663
+ "yangben": "yav",
664
+ "yaqui": "yaq",
665
+ "yauyos quechua": "qux",
666
+ "yekhee": "ets",
667
+ "yiddish": "yi",
668
+ "yidgha": "ydg",
669
+ "yoruba": "yo",
670
+ "yutanduchi mixtec": "mab",
671
+ "zacatlán-ahuacatlán-tepetzintla nahuatl": "nhi",
672
+ "zarma": "dje",
673
+ "zaza": "zza",
674
+ "zulu": "zu",
675
+ "ömie": "aom",
676
+ }
677
+
678
+ LANG_NAMES = set(LANG_NAME_TO_ID.keys())
679
+ LANG_IDS = set(LANG_NAME_TO_ID.values())
680
+
681
+ # Exceptions where .title() doesn't match the canonical casing from the TSV.
682
+ _TITLE_EXCEPTIONS = {
683
+ "fe'fe'": "Fe'fe'",
684
+ "dũya": "Dũya",
685
+ "santiago del estero quichua": "Santiago del Estero Quichua",
686
+ "santa ana de tusi pasco quechua": "Santa Ana de Tusi Pasco Quechua",
687
+ "malinaltepec me'phaa": "Malinaltepec Me'phaa",
688
+ "tlacoapa me'phaa": "Tlacoapa Me'phaa",
689
+ }
690
+
691
+
692
+ def lang_display_name(name: str) -> str:
693
+ """Return a display-friendly version of a lowercase language name.
694
+
695
+ Uses .title() for most names, with manual exceptions for cases like
696
+ apostrophes and small words (de, del) that should stay lowercase.
697
+ """
698
+ return _TITLE_EXCEPTIONS.get(name, name.title())
omnivoice/utils/text.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
3
+ #
4
+ # See ../../LICENSE for clarification regarding multiple authors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """Text processing utilities for TTS inference.
19
+
20
+ Provides:
21
+ - ``chunk_text_punctuation()``: Splits long text into model-friendly chunks at
22
+ sentence boundaries, with abbreviation-aware punctuation splitting.
23
+ - ``add_punctuation()``: Appends missing end punctuation (Chinese or English).
24
+ - ``normalize_text()``: Optional text normalization (numbers, dates, currency,
25
+ etc.) into their spoken form, while preserving inline control syntax.
26
+ """
27
+
28
+ import logging
29
+ import re
30
+ from typing import Callable, List, Optional
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ SPLIT_PUNCTUATION = set(".,;:!?。,;:!?")
36
+ CLOSING_MARKS = set("\"'“”‘’)]》>」】")
37
+
38
+ END_PUNCTUATION = {
39
+ ";",
40
+ ":",
41
+ ",",
42
+ ".",
43
+ "!",
44
+ "?",
45
+ "…",
46
+ ")",
47
+ "]",
48
+ "}",
49
+ '"',
50
+ "'",
51
+ "“",
52
+ "”",
53
+ "‘",
54
+ "’",
55
+ ";",
56
+ ":",
57
+ ",",
58
+ "。",
59
+ "!",
60
+ "?",
61
+ "、",
62
+ "……",
63
+ ")",
64
+ "】",
65
+ }
66
+
67
+
68
+ ABBREVIATIONS = {
69
+ "Mr.",
70
+ "Mrs.",
71
+ "Ms.",
72
+ "Dr.",
73
+ "Prof.",
74
+ "Sr.",
75
+ "Jr.",
76
+ "Rev.",
77
+ "Fr.",
78
+ "Hon.",
79
+ "Pres.",
80
+ "Gov.",
81
+ "Capt.",
82
+ "Gen.",
83
+ "Sen.",
84
+ "Rep.",
85
+ "Col.",
86
+ "Maj.",
87
+ "Lt.",
88
+ "Cmdr.",
89
+ "Sgt.",
90
+ "Cpl.",
91
+ "Co.",
92
+ "Corp.",
93
+ "Inc.",
94
+ "Ltd.",
95
+ "Est.",
96
+ "Dept.",
97
+ "St.",
98
+ "Ave.",
99
+ "Blvd.",
100
+ "Rd.",
101
+ "Mt.",
102
+ "Ft.",
103
+ "No.",
104
+ "Jan.",
105
+ "Feb.",
106
+ "Mar.",
107
+ "Apr.",
108
+ "Aug.",
109
+ "Sep.",
110
+ "Sept.",
111
+ "Oct.",
112
+ "Nov.",
113
+ "Dec.",
114
+ "i.e.",
115
+ "e.g.",
116
+ "vs.",
117
+ "Vs.",
118
+ "Etc.",
119
+ "approx.",
120
+ "fig.",
121
+ "def.",
122
+ }
123
+
124
+
125
+ def chunk_text_punctuation(
126
+ text: str,
127
+ chunk_len: int,
128
+ min_chunk_len: Optional[int] = None,
129
+ ) -> List[str]:
130
+ """
131
+ Splits the input tokens list into chunks according to punctuations,
132
+ avoiding splits on common abbreviations (e.g., Mr., No.).
133
+ """
134
+
135
+ # 1. Split the tokens according to punctuations.
136
+ sentences = []
137
+ current_sentence = []
138
+
139
+ tokens_list = list(text)
140
+
141
+ for token in tokens_list:
142
+ # If the first token of current sentence is punctuation,
143
+ # append it to the end of the previous sentence.
144
+ if (
145
+ len(current_sentence) == 0
146
+ and len(sentences) != 0
147
+ and (token in SPLIT_PUNCTUATION or token in CLOSING_MARKS)
148
+ ):
149
+ sentences[-1].append(token)
150
+ # Otherwise, append the current token to the current sentence.
151
+ else:
152
+ current_sentence.append(token)
153
+
154
+ # Split the sentence in positions of punctuations.
155
+ if token in SPLIT_PUNCTUATION:
156
+ is_abbreviation = False
157
+
158
+ if token == ".":
159
+ temp_str = "".join(current_sentence).strip()
160
+ if temp_str:
161
+ last_word = temp_str.split()[-1]
162
+ if last_word in ABBREVIATIONS:
163
+ is_abbreviation = True
164
+
165
+ if not is_abbreviation:
166
+ sentences.append(current_sentence)
167
+ current_sentence = []
168
+ # Assume the last few tokens are also a sentence
169
+ if len(current_sentence) != 0:
170
+ sentences.append(current_sentence)
171
+
172
+ # 2. Merge short sentences.
173
+ merged_chunks = []
174
+ current_chunk = []
175
+ for sentence in sentences:
176
+ if len(current_chunk) + len(sentence) <= chunk_len:
177
+ current_chunk.extend(sentence)
178
+ else:
179
+ if len(current_chunk) > 0:
180
+ merged_chunks.append(current_chunk)
181
+ current_chunk = sentence
182
+
183
+ if len(current_chunk) > 0:
184
+ merged_chunks.append(current_chunk)
185
+
186
+ # 4. Post-process: Check for undersized chunks and merge them
187
+ # with the previous chunk or next chunk (if it's the first chunk).
188
+ if min_chunk_len is not None:
189
+ first_chunk_short_flag = (
190
+ len(merged_chunks) > 0 and len(merged_chunks[0]) < min_chunk_len
191
+ )
192
+ final_chunks = []
193
+ for i, chunk in enumerate(merged_chunks):
194
+ if i == 1 and first_chunk_short_flag:
195
+ final_chunks[-1].extend(chunk)
196
+ else:
197
+ if len(chunk) >= min_chunk_len:
198
+ final_chunks.append(chunk)
199
+ else:
200
+ if len(final_chunks) == 0:
201
+ final_chunks.append(chunk)
202
+ else:
203
+ final_chunks[-1].extend(chunk)
204
+ else:
205
+ final_chunks = merged_chunks
206
+
207
+ chunk_strings = [
208
+ "".join(chunk).strip() for chunk in final_chunks if "".join(chunk).strip()
209
+ ]
210
+ return chunk_strings
211
+
212
+
213
+ def add_punctuation(text: str):
214
+ """Add punctuation if there is not in the end of text"""
215
+ text = text.strip()
216
+
217
+ if not text:
218
+ return text
219
+
220
+ if text[-1] not in END_PUNCTUATION:
221
+ is_chinese = any("\u4e00" <= char <= "\u9fff" for char in text)
222
+
223
+ text += "。" if is_chinese else "."
224
+
225
+ return text
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Optional text normalization (opt-in via ``generate(normalize_text=True)``)
230
+ # ---------------------------------------------------------------------------
231
+ #
232
+ # Arabic numerals, dates, currency, etc. are converted into their spoken form
233
+ # so the model reads them correctly (e.g. "2345" -> "twenty three forty five",
234
+ # "199" -> the Chinese reading). Chinese/English go through WeTextProcessing;
235
+ # any other language falls back to ``num2words`` for bare integers when
236
+ # available.
237
+ #
238
+ # The OmniVoice inline control syntax must survive normalization:
239
+ # * bracketed non-verbal tags, e.g. ``[laughter]``, ``[sigh]``;
240
+ # * bracketed CMU pronunciation overrides, e.g. ``[B EY1 S]`` -- the stress
241
+ # digit would otherwise be read as a number;
242
+ # * Chinese pinyin tone markers (uppercase pinyin + tone digit) -- likewise.
243
+ # Protected spans are held out and re-inserted verbatim around normalization.
244
+
245
+ # Any ``[...]`` span covers both non-verbal tags and CMU pronunciation.
246
+ _BRACKET_TAG_RE = re.compile(r"\[[^\[\]]*\]")
247
+ # Uppercase pinyin followed by a tone digit 1-5 (Chinese pronunciation control).
248
+ _PINYIN_TONE_RE = re.compile(r"[A-Z]+[1-5]")
249
+ _CJK_RE = re.compile(r"[\u4e00-\u9fff]")
250
+
251
+ _TN_INSTALL_MSG = (
252
+ "Text normalization (normalize_text=True) requires WeTextProcessing, which "
253
+ "is not installed.\n"
254
+ " pip install WeTextProcessing # or: pip install 'omnivoice[tn]'\n"
255
+ "WeTextProcessing depends on pynini, which has no prebuilt wheel for macOS "
256
+ "arm64 (Apple Silicon). On macOS, install pynini from conda-forge first:\n"
257
+ " conda install -c conda-forge pynini\n"
258
+ "then: pip install WeTextProcessing"
259
+ )
260
+
261
+ # Normalizer construction builds FSTs and is comparatively slow, so instances
262
+ # are cached per language for the lifetime of the process.
263
+ _ZH_NORMALIZER = None
264
+ _EN_NORMALIZER = None
265
+
266
+
267
+ def _get_zh_normalizer():
268
+ global _ZH_NORMALIZER
269
+ if _ZH_NORMALIZER is None:
270
+ try:
271
+ from tn.chinese.normalizer import Normalizer
272
+ except ImportError as e: # pragma: no cover - depends on optional extra
273
+ raise ImportError(_TN_INSTALL_MSG) from e
274
+ # Conservative flags: normalize numbers/symbols only. Keep interjections
275
+ # and erhua (they are spoken), keep the user's original characters, and
276
+ # do not delete or rewrite anything beyond numeric/symbolic tokens.
277
+ _ZH_NORMALIZER = Normalizer(
278
+ remove_interjections=False,
279
+ remove_erhua=False,
280
+ traditional_to_simple=False,
281
+ remove_puncts=False,
282
+ full_to_half=False,
283
+ )
284
+ return _ZH_NORMALIZER
285
+
286
+
287
+ def _get_en_normalizer():
288
+ global _EN_NORMALIZER
289
+ if _EN_NORMALIZER is None:
290
+ try:
291
+ from tn.english.normalizer import Normalizer
292
+ except ImportError as e: # pragma: no cover - depends on optional extra
293
+ raise ImportError(_TN_INSTALL_MSG) from e
294
+ _EN_NORMALIZER = Normalizer()
295
+ return _EN_NORMALIZER
296
+
297
+
298
+ def _resolve_lang_code(language: Optional[str], text: str) -> str:
299
+ """Map a language name/code to ``"zh"``/``"en"``/other code.
300
+
301
+ When ``language`` is ``None`` (or unrecognized), fall back to detecting
302
+ Chinese vs. English by the presence of CJK characters.
303
+ """
304
+ if language is not None:
305
+ code = language.strip().lower()
306
+ if code and code != "none":
307
+ if code in ("zh", "en"):
308
+ return code
309
+ try:
310
+ from omnivoice.utils.lang_map import LANG_IDS, LANG_NAME_TO_ID
311
+
312
+ if code in LANG_IDS:
313
+ return code
314
+ if code in LANG_NAME_TO_ID:
315
+ return LANG_NAME_TO_ID[code]
316
+ except Exception: # pragma: no cover - lang_map should be importable
317
+ pass
318
+ return code # assume it is already a language id, e.g. "ja", "de"
319
+ return "zh" if _CJK_RE.search(text) else "en"
320
+
321
+
322
+ def _num2words_segment(text: str, lang: str) -> str:
323
+ """Best-effort integer-to-words fallback for non zh/en languages."""
324
+ try:
325
+ from num2words import num2words
326
+ except ImportError:
327
+ return text # fallback is best-effort; silently skip when unavailable
328
+
329
+ def _repl(match):
330
+ try:
331
+ return num2words(int(match.group()), lang=lang)
332
+ except Exception:
333
+ return match.group() # unsupported language / value: leave as-is
334
+
335
+ return re.sub(r"\d+", _repl, text)
336
+
337
+
338
+ def _normalize_segment(fn: Callable[[str], str], segment: str) -> str:
339
+ """Normalize one non-protected segment, never raising on bad input.
340
+
341
+ Leading/trailing whitespace is preserved explicitly because the underlying
342
+ normalizers strip it, which would otherwise glue words to an adjacent
343
+ protected span (e.g. ``the [B EY1 S] guitar`` -> ``the[B EY1 S]guitar``).
344
+ """
345
+ if not segment.strip():
346
+ return segment
347
+ lead = segment[: len(segment) - len(segment.lstrip())]
348
+ trail = segment[len(segment.rstrip()) :]
349
+ try:
350
+ core = fn(segment.strip())
351
+ except Exception as e: # pragma: no cover - defensive
352
+ logger.warning(
353
+ "Text normalization failed on a segment (%s); keeping it unchanged.",
354
+ type(e).__name__,
355
+ )
356
+ return segment
357
+ return lead + core + trail
358
+
359
+
360
+ def _apply_with_protection(
361
+ text: str, fn: Callable[[str], str], protect_pinyin: bool
362
+ ) -> str:
363
+ """Run ``fn`` on ``text`` while holding out protected control spans."""
364
+ spans = [m.span() for m in _BRACKET_TAG_RE.finditer(text)]
365
+ if protect_pinyin:
366
+ spans += [m.span() for m in _PINYIN_TONE_RE.finditer(text)]
367
+ if not spans:
368
+ return _normalize_segment(fn, text)
369
+
370
+ # Merge overlapping/adjacent protected spans, then normalize the gaps.
371
+ spans.sort()
372
+ merged: List[List[int]] = []
373
+ for start, end in spans:
374
+ if merged and start <= merged[-1][1]:
375
+ merged[-1][1] = max(merged[-1][1], end)
376
+ else:
377
+ merged.append([start, end])
378
+
379
+ out: List[str] = []
380
+ last = 0
381
+ for start, end in merged:
382
+ if start > last:
383
+ out.append(_normalize_segment(fn, text[last:start]))
384
+ out.append(text[start:end]) # protected span, verbatim
385
+ last = end
386
+ if last < len(text):
387
+ out.append(_normalize_segment(fn, text[last:]))
388
+ return "".join(out)
389
+
390
+
391
+ def normalize_text(text: str, language: Optional[str] = None) -> str:
392
+ """Normalize numbers, dates, currency, etc. into their spoken form.
393
+
394
+ Chinese is routed to WeTextProcessing's ``ZhNormalizer`` and English to its
395
+ ``EnNormalizer`` (configured to only rewrite numeric/symbolic tokens). Any
396
+ other language falls back to ``num2words`` for bare integers when it is
397
+ installed, otherwise the text is returned unchanged.
398
+
399
+ Inline OmniVoice control syntax is preserved: bracketed non-verbal tags
400
+ (``[laughter]``) and CMU pronunciation overrides (``[B EY1 S]``) are passed
401
+ through untouched, and Chinese pinyin tone markers (uppercase pinyin +
402
+ tone digit) are protected so the tone digit is not read as a number.
403
+
404
+ Args:
405
+ text: Input text.
406
+ language: Language code (``"en"``/``"zh"``) or full name (``"English"``).
407
+ ``None`` auto-detects Chinese vs. English by script.
408
+
409
+ Returns:
410
+ The normalized text.
411
+
412
+ Raises:
413
+ ImportError: For Chinese/English when the optional ``omnivoice[tn]``
414
+ dependency (WeTextProcessing) is not installed.
415
+ """
416
+ if not text or not text.strip():
417
+ return text
418
+
419
+ code = _resolve_lang_code(language, text)
420
+ if code == "zh":
421
+ normalizer = _get_zh_normalizer()
422
+ return _apply_with_protection(text, normalizer.normalize, protect_pinyin=True)
423
+ if code == "en":
424
+ normalizer = _get_en_normalizer()
425
+ return _apply_with_protection(text, normalizer.normalize, protect_pinyin=False)
426
+ # Other languages: best-effort integer conversion via num2words.
427
+ return _apply_with_protection(
428
+ text, lambda s: _num2words_segment(s, code), protect_pinyin=False
429
+ )