Fix voice clone, path defaults, README rewrite; add pydub dep
Browse files- README.md +8 -6
- pyproject.toml +1 -0
- scripts/omni_o_call.py +115 -4
- uv.lock +11 -0
README.md
CHANGED
|
@@ -218,12 +218,14 @@ scripts/ # Inference, serving, conversion
|
|
| 218 |
|
| 219 |
### Trained models
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
|
| 224 |
-
|
|
| 225 |
-
| Omni
|
| 226 |
-
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|