chenbhao commited on
Commit
bf14804
·
1 Parent(s): f2c9122

Fix voice clone, path defaults, README rewrite; add pydub dep

Browse files
Files changed (4) hide show
  1. README.md +8 -6
  2. pyproject.toml +1 -0
  3. scripts/omni_o_call.py +115 -4
  4. uv.lock +11 -0
README.md CHANGED
@@ -218,12 +218,14 @@ scripts/ # Inference, serving, conversion
218
 
219
  ### Trained models
220
 
221
- | Model | Params | Modalities | Architecture | Repo |
222
- |:---|:---|:---|:---|:---|
223
- | Omni | 4.2B | Text | Dense LM | [🧑‍💻](https://huggingface.co/chenbhao/omni) |
224
- | Omni-V | 4.4B | Text + Vision | Dense VLM | [🧑‍💻](https://huggingface.co/chenbhao/omni) |
225
- | Omni-O | 5.6B | Text + Vision + Speech | Dense VAM | [🧑‍💻](https://huggingface.co/chenbhao/omni) |
226
- | Omni-O MoE | 5.8B | Text + Vision + Speech | MoE VAM | [🧑‍💻](https://huggingface.co/chenbhao/omni) |
 
 
227
 
228
  ### Real-time voice/video call
229
 
 
218
 
219
  ### Trained models
220
 
221
+ All models share the same base LM backbone (hidden_size=768, 8 layers, 8 heads, GQA).
222
+
223
+ | Model | Params | Active / token | Modalities | Highlights |
224
+ |:---|---:|---:|:---|:---|
225
+ | Omni | 64M | 64M | Text | Dense LM, tied embeddings, SwiGLU, RoPE |
226
+ | Omni-V | 65M | 65M | Text + Vision | Omni + frozen SigLIP + vision projector |
227
+ | Omni-O | 113M | 113M | Text + Vision + Speech | Omni-V + audio projector + 4-layer Talker |
228
+ | Omni-O MoE | 315M | 113M | Text + Vision + Speech | Omni-O with 4-expert MoE, top-1 routing |
229
 
230
  ### Real-time voice/video call
231
 
pyproject.toml CHANGED
@@ -23,6 +23,7 @@ dependencies = [
23
  "pillow>=10.4.0",
24
  "pyarrow>=17.0.0",
25
  "requests>=2.32.3",
 
26
  ]
27
 
28
  [project.optional-dependencies]
 
23
  "pillow>=10.4.0",
24
  "pyarrow>=17.0.0",
25
  "requests>=2.32.3",
26
+ "pydub>=0.25.1",
27
  ]
28
 
29
  [project.optional-dependencies]
scripts/omni_o_call.py CHANGED
@@ -14,6 +14,8 @@ MODEL_LOCK = threading.Lock()
14
  VOICES_BUILTIN, VOICES_UNSEEN, VOICES_MANUAL = [], [], []
15
  SAMPLES_PER_FRAME = 1920
16
  REF_FRAMES = 300
 
 
17
 
18
  def sse(d): return f"data: {json.dumps(d)}\n\n"
19
 
@@ -71,6 +73,81 @@ def stream_pcm(frames, flush=False):
71
  p = pcm_bytes(frames[-(rem + ov):], ov)
72
  if p: yield p
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def register_voice(name, value, group='manual'):
75
  V[name] = value
76
  groups = {'builtin': VOICES_BUILTIN, 'unseen': VOICES_UNSEEN, 'manual': VOICES_MANUAL}
@@ -80,7 +157,11 @@ def register_voice(name, value, group='manual'):
80
  if k != group and name in lst: lst.remove(name)
81
 
82
  def voice_args(name):
83
- if name and name != 'default' and name in V:
 
 
 
 
84
  v = V[name]
85
  dev = M['device']
86
  rc = v['ref_codes'].unsqueeze(0).to(dev)
@@ -138,6 +219,35 @@ def init_web_app():
138
  def get_models():
139
  return json.dumps({'models': [M.get('model_name', 'omni-o')], 'current': M.get('model_name', 'omni-o')})
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  @app.route('/chat', methods=['POST'])
142
  def chat():
143
  d = request.json
@@ -381,11 +491,11 @@ def init_model(args):
381
  M['vad_path'] = os.path.join(root, args.vad_dir, 'silero_vad.onnx')
382
 
383
  spk_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'model', 'speaker')
384
- for fn, group in [('voices.pt', 'builtin'), ('voices_unseen.pt', 'unseen')]:
385
  fp = os.path.join(spk_dir, fn)
386
  if os.path.exists(fp):
387
  for speaker, v in torch.load(fp, map_location='cpu').items():
388
- if speaker not in V:
389
  register_voice(speaker, v, group=group)
390
  if V: print(f'Loaded {len(V)} voices')
391
 
@@ -406,7 +516,7 @@ def init_model(args):
406
 
407
  if __name__ == '__main__':
408
  p = argparse.ArgumentParser(description='Omni-O Real-time Voice Call')
409
- p.add_argument('--load_from', default='checkpoint/omni-o-hf', help='HF 模型权重目录(自动检测 .pth 目录兼容)')
410
  p.add_argument('--weight', default='', help='权重文件名(仅 .pth 模式,不含后缀)')
411
  p.add_argument('--tokenizer_dir', default='checkpoint/omni/native_hf', help='tokenizer目录')
412
  p.add_argument('--sensevoice_dir', default='checkpoint/sensevoice', help='SenseVoice ASR目录')
@@ -426,4 +536,5 @@ if __name__ == '__main__':
426
  init_model(args)
427
  app = init_web_app()
428
  print(f'Omni-O Call server started at http://0.0.0.0:{args.port}/')
 
429
  app.run(host='0.0.0.0', port=args.port, threaded=True)
 
14
  VOICES_BUILTIN, VOICES_UNSEEN, VOICES_MANUAL = [], [], []
15
  SAMPLES_PER_FRAME = 1920
16
  REF_FRAMES = 300
17
+ CLONE_VOICE = 'voice_clone'
18
+ CLONE_FILE = 'voice_clone.pt'
19
 
20
  def sse(d): return f"data: {json.dumps(d)}\n\n"
21
 
 
73
  p = pcm_bytes(frames[-(rem + ov):], ov)
74
  if p: yield p
75
 
76
+ def clone_voice_path():
77
+ return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'model', 'speaker', CLONE_FILE)
78
+
79
+ def delete_manual_voice(name):
80
+ if name not in VOICES_MANUAL:
81
+ raise RuntimeError('can only delete manually cloned voices')
82
+ out_path = clone_voice_path()
83
+ saved = torch.load(out_path, map_location='cpu') if os.path.exists(out_path) else {}
84
+ if name in saved:
85
+ saved.pop(name)
86
+ torch.save(saved, out_path)
87
+ V.pop(name, None)
88
+ if name in VOICES_MANUAL:
89
+ VOICES_MANUAL.remove(name)
90
+
91
+ def normalize_voice_name(name):
92
+ name = ' '.join(str(name or '').split())
93
+ if not name:
94
+ name = CLONE_VOICE
95
+ if len(name) > 24:
96
+ raise RuntimeError('voice name too long (max 24 chars)')
97
+ if name.lower() == 'default':
98
+ raise RuntimeError('default is reserved')
99
+ if name in VOICES_BUILTIN or name in VOICES_UNSEEN:
100
+ raise RuntimeError('name already taken by an existing voice')
101
+ return name
102
+
103
+ def validate_clone_audio(w16):
104
+ if w16.numel() < int(16000 * 1.8):
105
+ raise RuntimeError('audio too short, read the full sentence')
106
+ peak = w16.abs().max().item()
107
+ frame, hop = 800, 400
108
+ if w16.numel() >= frame:
109
+ rms = w16.unfold(0, frame, hop).pow(2).mean(dim=1).sqrt().cpu().numpy()
110
+ else:
111
+ rms = np.array([w16.pow(2).mean().sqrt().item()])
112
+ hi = float(np.quantile(rms, 0.95))
113
+ lo = float(np.quantile(rms, 0.2))
114
+ if hi < 0.008:
115
+ raise RuntimeError('audio too quiet, move closer to mic')
116
+ if hi > 0 and lo / hi > 0.45:
117
+ raise RuntimeError('too much background noise')
118
+ if peak > 0.995:
119
+ raise RuntimeError('audio clipped, move away from mic')
120
+
121
+ def build_clone_voice(audio_b64):
122
+ if M.get('mimi') is None or M.get('campplus') is None or M.get('mel_fn') is None:
123
+ raise RuntimeError('Mimi or CAM++ not loaded')
124
+ from pydub import AudioSegment
125
+ seg = AudioSegment.from_file(io.BytesIO(base64.b64decode(audio_b64))).set_channels(1).set_sample_width(2)
126
+ if len(seg) < 1000:
127
+ raise RuntimeError('audio too short, record at least 1 second')
128
+ try:
129
+ seg = seg.speedup(playback_speed=1.5, chunk_size=150, crossfade=25)
130
+ except Exception:
131
+ seg = seg.speedup(playback_speed=1.5)
132
+ seg24 = seg.set_frame_rate(24000)
133
+ seg16 = seg.set_frame_rate(16000)
134
+ w24 = torch.tensor(np.frombuffer(seg24.raw_data, dtype=np.int16).astype(np.float32) / 32768.0)
135
+ w16 = torch.tensor(np.frombuffer(seg16.raw_data, dtype=np.int16).astype(np.float32) / 32768.0)
136
+ validate_clone_audio(w16)
137
+ mimi_dev = next(M['mimi'].parameters()).device
138
+ mimi_dtype = torch.float16 if mimi_dev.type != 'cpu' else torch.float32
139
+ with torch.inference_mode():
140
+ t = w24.unsqueeze(0).unsqueeze(0).to(device=mimi_dev, dtype=mimi_dtype)
141
+ codes = M['mimi'].encode(t).audio_codes
142
+ nf = math.ceil(w24.shape[-1] / SAMPLES_PER_FRAME)
143
+ ref_codes = codes[0, :8, :nf].cpu()[:, :min(nf, REF_FRAMES)]
144
+ with torch.no_grad():
145
+ mel = M['mel_fn'](w16.unsqueeze(0).to(M['device']))
146
+ feat = mel.clamp(min=1e-10).log().transpose(1, 2)
147
+ feat = feat - feat.mean(dim=1, keepdim=True)
148
+ spk_emb = M['campplus'](feat).squeeze(0).cpu()
149
+ return {'ref_codes': ref_codes, 'spk_emb': spk_emb}
150
+
151
  def register_voice(name, value, group='manual'):
152
  V[name] = value
153
  groups = {'builtin': VOICES_BUILTIN, 'unseen': VOICES_UNSEEN, 'manual': VOICES_MANUAL}
 
157
  if k != group and name in lst: lst.remove(name)
158
 
159
  def voice_args(name):
160
+ if not name or name == 'default':
161
+ name = VOICES_BUILTIN[0] if VOICES_BUILTIN else (list(V.keys())[0] if V else None)
162
+ if name is None:
163
+ return {}
164
+ if name in V:
165
  v = V[name]
166
  dev = M['device']
167
  rc = v['ref_codes'].unsqueeze(0).to(dev)
 
219
  def get_models():
220
  return json.dumps({'models': [M.get('model_name', 'omni-o')], 'current': M.get('model_name', 'omni-o')})
221
 
222
+ @app.route('/clone_voice', methods=['POST'])
223
+ def clone_voice():
224
+ d = request.json or {}
225
+ if not d.get('audio'):
226
+ return Response(json.dumps({'ok': False, 'error': 'missing audio'}), status=400, mimetype='application/json')
227
+ try:
228
+ name = normalize_voice_name(d.get('name'))
229
+ value = build_clone_voice(d['audio'])
230
+ out_path = clone_voice_path()
231
+ saved = torch.load(out_path, map_location='cpu') if os.path.exists(out_path) else {}
232
+ saved[name] = value
233
+ torch.save(saved, out_path)
234
+ register_voice(name, value, group='manual')
235
+ return Response(json.dumps({'ok': True, 'voice': name, 'path': './model/speaker/' + CLONE_FILE}), mimetype='application/json')
236
+ except Exception as e:
237
+ return Response(json.dumps({'ok': False, 'error': str(e)}), status=500, mimetype='application/json')
238
+
239
+ @app.route('/delete_voice', methods=['POST'])
240
+ def delete_voice():
241
+ d = request.json or {}
242
+ name = ' '.join(str(d.get('name') or '').split())
243
+ if not name:
244
+ return Response(json.dumps({'ok': False, 'error': 'missing name'}), status=400, mimetype='application/json')
245
+ try:
246
+ delete_manual_voice(name)
247
+ return Response(json.dumps({'ok': True, 'voice': name}), mimetype='application/json')
248
+ except Exception as e:
249
+ return Response(json.dumps({'ok': False, 'error': str(e)}), status=500, mimetype='application/json')
250
+
251
  @app.route('/chat', methods=['POST'])
252
  def chat():
253
  d = request.json
 
491
  M['vad_path'] = os.path.join(root, args.vad_dir, 'silero_vad.onnx')
492
 
493
  spk_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'model', 'speaker')
494
+ for fn, group in [('voices.pt', 'builtin'), ('voices_unseen.pt', 'unseen'), (CLONE_FILE, 'manual')]:
495
  fp = os.path.join(spk_dir, fn)
496
  if os.path.exists(fp):
497
  for speaker, v in torch.load(fp, map_location='cpu').items():
498
+ if speaker not in V or fn == CLONE_FILE:
499
  register_voice(speaker, v, group=group)
500
  if V: print(f'Loaded {len(V)} voices')
501
 
 
516
 
517
  if __name__ == '__main__':
518
  p = argparse.ArgumentParser(description='Omni-O Real-time Voice Call')
519
+ p.add_argument('--load_from', default='checkpoint/omni-o/omni-o-hf', help='HF 模型权重目录(自动检测 .pth 目录兼容)')
520
  p.add_argument('--weight', default='', help='权重文件名(仅 .pth 模式,不含后缀)')
521
  p.add_argument('--tokenizer_dir', default='checkpoint/omni/native_hf', help='tokenizer目录')
522
  p.add_argument('--sensevoice_dir', default='checkpoint/sensevoice', help='SenseVoice ASR目录')
 
536
  init_model(args)
537
  app = init_web_app()
538
  print(f'Omni-O Call server started at http://0.0.0.0:{args.port}/')
539
+ print(f'Open http://127.0.0.1:{args.port} in Firefox for mic/camera access')
540
  app.run(host='0.0.0.0', port=args.port, threaded=True)
uv.lock CHANGED
@@ -1402,6 +1402,7 @@ dependencies = [
1402
  { name = "pillow" },
1403
  { name = "pyarrow" },
1404
  { name = "pydantic" },
 
1405
  { name = "pyyaml" },
1406
  { name = "regex" },
1407
  { name = "requests" },
@@ -1461,6 +1462,7 @@ requires-dist = [
1461
  { name = "pyarrow", specifier = ">=17.0.0" },
1462
  { name = "pydantic", specifier = ">=2.11.5" },
1463
  { name = "pydantic", marker = "extra == 'serve'", specifier = ">=2.11.5" },
 
1464
  { name = "pyyaml", specifier = ">=6.0.2" },
1465
  { name = "regex" },
1466
  { name = "requests", specifier = ">=2.32.3" },
@@ -2055,6 +2057,15 @@ wheels = [
2055
  { url = "https://files.pythonhosted.org/packages/6f/34/3998411437aff304a9ed4fa37a6fe1ef3132bcd2b5eac59851b80c86123c/pydeck-0.9.3-py2.py3-none-any.whl", hash = "sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6", size = 11428091, upload-time = "2026-07-02T23:27:06.399Z" },
2056
  ]
2057
 
 
 
 
 
 
 
 
 
 
2058
  [[package]]
2059
  name = "pyecharts"
2060
  version = "2.1.0"
 
1402
  { name = "pillow" },
1403
  { name = "pyarrow" },
1404
  { name = "pydantic" },
1405
+ { name = "pydub" },
1406
  { name = "pyyaml" },
1407
  { name = "regex" },
1408
  { name = "requests" },
 
1462
  { name = "pyarrow", specifier = ">=17.0.0" },
1463
  { name = "pydantic", specifier = ">=2.11.5" },
1464
  { name = "pydantic", marker = "extra == 'serve'", specifier = ">=2.11.5" },
1465
+ { name = "pydub", specifier = ">=0.25.1" },
1466
  { name = "pyyaml", specifier = ">=6.0.2" },
1467
  { name = "regex" },
1468
  { name = "requests", specifier = ">=2.32.3" },
 
2057
  { url = "https://files.pythonhosted.org/packages/6f/34/3998411437aff304a9ed4fa37a6fe1ef3132bcd2b5eac59851b80c86123c/pydeck-0.9.3-py2.py3-none-any.whl", hash = "sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6", size = 11428091, upload-time = "2026-07-02T23:27:06.399Z" },
2058
  ]
2059
 
2060
+ [[package]]
2061
+ name = "pydub"
2062
+ version = "0.25.1"
2063
+ source = { registry = "https://pypi.org/simple" }
2064
+ sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" }
2065
+ wheels = [
2066
+ { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" },
2067
+ ]
2068
+
2069
  [[package]]
2070
  name = "pyecharts"
2071
  version = "2.1.0"