@duyquangnvx/voice-engine
v0.5.1
Published
Local voice cloning for TypeScript, driving an OmniVoice sidecar over loopback HTTP
Maintainers
Readme
@duyquangnvx/voice-engine
Thư viện TypeScript cho voice cloning chạy hoàn toàn trên máy local. Nó điều khiển một Sidecar Python giữ sẵn Speech backend (OmniVoice) trong VRAM và phục vụ qua HTTP loopback.
Từ vựng của dự án nằm ở CONTEXT.md; các quyết định nền ở docs/adr/.
Cài đặt
pnpm add @duyquangnvx/voice-engineGói npm mang theo cả nguồn của Sidecar, nhưng không mang môi trường Python. Máy chạy cần:
uvtrênPATH— nó tự lo Python 3.12 và toàn bộ dependency từuv.lockđi kèm, ở lần dựng sidecar đầu tiên (tải vài GB, một lần). Lượt tải đó có thể dài hơn ngân sách chờ củaopenVoiceEngine; khi ấy lỗi nói sidecar vẫn đang được dựng tiếp, cứ gọi lại.- GPU NVIDIA còn đủ VRAM. Weights được tải từ HuggingFace lúc chạy và mang điều khoản riêng của chúng — bản quyền của gói này chỉ phủ phần code ở đây.
uv ghim Python 3.12 và torch==2.8.0+cu128. Bản wheel mặc định trên PyPI không chứa
kernel cho GPU Blackwell (compute capability 12.0) và hỏng lúc chạy chứ không lúc cài, nên
index wheel được khai báo tường minh trong sidecar/pyproject.toml.
Chẩn đoán môi trường — có chạy thật một lần sinh, vì rủi ro kernel chỉ lộ ra khi kernel chạy:
import { diagnose } from "@duyquangnvx/voice-engine";
console.log(await diagnose());timeoutMs (mặc định 300 000) chặn cả lần chẩn đoán. Hết ngân sách thì tiến trình doctor và
mọi thứ nó sinh ra bị kết thúc, và báo cáo về dưới dạng một check hỏng nói rõ nguyên nhân.
Dùng
import { writeFile } from "node:fs/promises";
import { openVoiceEngine, writeWav } from "@duyquangnvx/voice-engine";
const engine = await openVoiceEngine();
const { pcm, sampleRate } = await engine.synthesize({
text: "Hôm nay trời đẹp.",
voice: {
recording: { path: "D:/voices/ana.wav", transcript: "Xin chào, tôi là Ana." },
},
});
await writeFile("out/hello.wav", writeWav(pcm, sampleRate));
await engine.close();pcm là Speech samples trần: mono, int16 little-endian, không header. Engine không trả file
vì header không mang thông tin nào bạn chưa có — sampleRate và durationMs nằm ngay cạnh nó —
còn nối audio của nhiều kết quả thì trần mới đúng: Buffer.concat các pcm theo thứ tự gửi
(xem collectInOrder bên dưới) rồi writeWav một lần ra file đúng tổng thời lượng. Xem
docs/adr/0004-the-engine-returns-samples.md.
engine.info là ảnh chụp lúc connect, lấy trong đúng một round-trip vì không thứ nào trong
đó đổi trong đời một sidecar: sampleRate, autoAsr, languages (danh sách ngôn ngữ được
nhận), maxReferenceSeconds (trần độ dài Reference recording), và backend với name cùng
identity. Ghi backend.identity lại cạnh audio đã sinh — nó gộp bản thư viện, snapshot weights
và audio tokenizer, tức là toàn bộ những thứ quyết định bản đọc nghe ra sao.
Sinh hàng loạt trả về async iterable; một item lỗi được yield ra chứ không làm đổ cả mẻ, còn lỗi hạ tầng thì ném và kết thúc vòng lặp:
for await (const result of engine.synthesizeMany(lines)) {
if (result.ok)
await writeFile(`out/${result.index}.wav`, writeWav(result.pcm, result.sampleRate));
else console.error(result.index, result.error.code, result.error.message);
}Kết quả về theo lô, không theo thứ tự gửi: sidecar gom lô theo độ dài text để lô đệm ít nhất
có thể, nên câu ngắn về trước câu dài dù gửi sau. Vị trí của một item nằm ở result.index và đó
là cách duy nhất đặt nó về chỗ cũ. Xem
docs/adr/0006-results-follow-batches-not-submission-order.md.
Voice prompt được biên dịch theo từng lô, không phải cả mẻ lên trước: một mẻ nhiều giọng
khác nhau trả dòng đầu tiên ngay sau lô đầu, không chờ hết số lần biên dịch. Kéo theo đó là
thứ tự lỗi: lỗi validate (invalid_text, unsupported_language, …) vẫn ra trước mọi dòng
sinh và theo thứ tự gửi, còn lỗi biên dịch prompt (invalid_reference_audio) ra tại vị trí
lô của nó, xen giữa các dòng sinh. Cả hai đều mang đúng index của item.
Cần đúng thứ tự gửi — nối nhiều chương thành một file là ca chính — thì collectInOrder gom cả
mẻ rồi xếp lại. Đổi lại nó giữ cả mẻ trong bộ nhớ và mất kết quả từng phần nếu stream ném:
import { collectInOrder, writeWav } from "@duyquangnvx/voice-engine";
const results = await collectInOrder(engine.synthesizeMany(chapters));
const spoken = results.filter((result) => result.ok);
const pcm = Buffer.concat(spoken.map((result) => result.pcm));
await writeFile("out/book.wav", writeWav(pcm, engine.info.sampleRate));Ngừng tiêu thụ vòng lặp thì sidecar ngừng sinh — nhưng chỉ mịn tới mức lô. Nó kiểm tra
kết nối giữa hai lô chứ không giữa hai câu, nên lô đang chạy vẫn chạy hết trước khi dừng;
những prompt chưa tới lượt cũng không được biên dịch nữa. Muốn dừng nhạy hơn thì hạ
--batch-max-items của sidecar, đổi lại thông lượng.
result.batch là chi phí sinh thật: size là số câu trong lô, generationMs là thời gian
sinh của cả lô đó. Trong một lô, thời gian sinh của từng câu riêng lẻ không tồn tại, nên
không có trường nào giả vờ là như vậy — chia đều ra chỉ là bịa một con số. Một câu lỗi rồi
được thử lại một mình báo size: 1, vì lần sinh đó thật sự là một lô một câu.
Một sidecar kẹt giữa chừng không kéo theo phía Node treo. Các ngân sách dưới đây đặt được ở cả
openVoiceEngine lẫn startVoiceEngine:
| Tuỳ chọn | Mặc định | Chặn cái gì |
| --- | --- | --- |
| streamIdleTimeoutMs | 60 000 | Khoảng lặng của sidecar. Mẻ 500 câu chạy hàng giờ vẫn hợp lệ; nhịp chậm của phía tiêu thụ không bị tính vào. |
| compileTimeoutMs | 120 000 | Tổng thời gian biên dịch một Voice prompt — đủ rộng cho đường auto-ASR chạy Whisper. |
| readyTimeoutMs | 300 000 | Từ lúc gọi tới khi sidecar trả lời health; mặc định này là của openVoiceEngine. Rộng vì nó bao cả một lần nạp model mà caller có thể không khởi động. |
Hết giờ ở hai dòng đầu chỉ huỷ request của chính caller. Sidecar vẫn sống và vẫn phục vụ các dự án khác đang nối vào cùng endpoint.
streamIdleTimeoutMs đo sidecar còn sống hay không, không đo nhịp giao kết quả: một câu dài
sinh lâu hơn ngân sách vẫn về được, vì sidecar tự phát nhịp báo nó còn đang làm khi đã lâu không có
dòng nào (--keepalive-seconds, mặc định 15). Cần thế vì Speech backend cắt câu dài thành khúc 15
giây rồi sinh tuần tự — một item 4 000 ký tự mất khoảng 80 giây mà không có dòng nào ở giữa. Ngừng
phát cả kết quả lẫn nhịp thì vẫn hết giờ như cũ. Số đo ở
docs/research/oom-recovery.md.
seed tái lập được trong cùng một hình dạng lô, không rộng hơn: Speech backend không có
tham số seed nào trên đường inference, nên sidecar tự đặt seed của torch. Cùng hình dạng lô,
cùng seed đo được ra audio byte-identical; cùng một câu nằm trong hai lô khác hình dạng vẫn ra
kết quả khác nhau do đệm và lựa chọn kernel. Muốn tái lập chặt thì phải cố định luôn cách lô hoá
(--batch-max-items, --batch-max-chars). Đó là số đo chứ không phải lời khai — nó là một
assertion trong tests/contract.test.ts.
Shared sidecar
Cả máy dùng chung một bản model trong VRAM: openVoiceEngine() nối vào Shared sidecar nếu
nó đang chạy, dựng nó nếu chưa — hai dự án cùng khởi động nguội vẫn chỉ ra một bản. Lý lẽ ở
docs/adr/0007-a-shared-sidecar-has-no-owner.md.
Shared sidecar không có Sidecar owner: engine.close() không bao giờ dừng nó, kể cả khi
chính tiến trình đó đã dựng nó. Nó giữ VRAM tới khi được bảo dừng:
pnpm exec voice-engine sidecar start # dựng rồi chờ tới khi model nạp xong
pnpm exec voice-engine sidecar status # đang chạy ở đâu, hay không có gì
pnpm exec voice-engine sidecar stop # trả VRAMstart nhận cờ của sidecar (start --help liệt kê), nhưng cờ chỉ áp dụng lúc dựng: gặp sidecar
đang chạy thì phải stop trước. stop khi không có gì chạy vẫn thành công, nên lặp lại được.
Sidecar chạy tay không có --shared thì vô hình: Voice Engine lặng lẽ dựng một bản khác.
Hai tuỳ chọn đổi chỗ openVoiceEngine tìm sidecar, và cả hai không bao giờ tự dựng:
{ start: false }chỉ nối vào Shared sidecar đang chạy, không có thì némSidecarUnavailableErrorchỉ tớivoice-engine sidecar start. Dành cho app mỗi lệnh một process: tự dựng ở đó nghĩa là một lệnh chạy xong để lại vài GB VRAM.{ endpoint }nối đích danh một sidecar trên máy này.
Shared sidecar đang chạy mà khác Protocol, hoặc thiếu auto-ASR mà { autoAsr: true } đòi,
thì openVoiceEngine ném SidecarUnavailableError chứ không dừng nó hay dựng bản thứ hai.
sidecarStatus() hỏi trạng thái mà không nối, không dựng, không ném khi không có gì chạy:
stopped, warming (đang nạp model), running kèm info, hoặc incompatible.
voice-engine sidecar status in đúng thứ này.
startVoiceEngine() là cửa còn lại: một sidecar riêng có owner là tiến trình gọi, không bao giờ
công bố cho máy. Dành cho test và script một lần dùng — scripts/speak.mjs đi đường này.
Reference recording
Sidecar chuẩn hoá Reference recording trước khi biên dịch Voice prompt, và chỉ làm việc đó trên
đường cache miss. Nó cắt khoảng lặng hai đầu và kéo mức lời nói về một điểm cố định, nên một
mẻ nhiều giọng ra loudness đều dù các file thu to nhỏ khác nhau — kèm một chốt chặn peak để
bản ghi nhọn không bị clip. File toàn im lặng bị từ chối bằng invalid_reference_audio.
Trần độ dài là 20 giây. Dài hơn thì phải cắt, mà cắt xong transcript đi kèm không còn mô tả
phần audio còn lại — điều đó làm hỏng nhịp nói nặng hơn là chính độ dài. Nên: bật --auto-asr
thì sidecar cắt rồi để backend nghe lại bản đã cắt; không bật thì nó trả về
invalid_reference_audio và bạn tự cắt kèm transcript khớp.
compileVoicePrompt trả về reference: { seconds, isCut } — độ dài lời nói sau khi cắt khoảng
lặng hai đầu, và bản ghi có chạm trần hay không. Hai con số luôn có mặt, kể cả khi trúng cache:
chúng được lưu cạnh Voice prompt nên không có trường nào lúc có lúc không. Hiện được số giây ngay
màn hình upload là dùng đúng chỗ; so với engine.info.maxReferenceSeconds để nói được cần cắt
xuống bao nhiêu mà không hard-code con số.
Ngưỡng đo trên chính backend này, không chép từ dự án khác:
docs/research/reference-audio-behaviour.md,
quyết định ở docs/adr/0003-sidecar-normalises-the-reference-recording.md.
Test ở dự án hạ nguồn
@duyquangnvx/voice-engine/fake là một VoiceEngine thuần TypeScript — không Python, không uv,
không GPU, không tiến trình con — để unit test code gọi Voice Engine.
import { createFakeVoiceEngine } from "@duyquangnvx/voice-engine/fake";
const engine = createFakeVoiceEngine();
const { pcm, batch } = await engine.synthesize({ text: "Hôm nay trời đẹp.", voice });Cùng seed ra cùng audio, seed khác ra audio khác. Audio là tiếng nghe được, không phải im lặng — một pipeline nuốt mất audio vì thế lộ ra thay vì đi qua êm ru.
Fake tự bắt chước phần lớn những gì sidecar thật làm: validate item (invalid_text,
missing_transcript, unsupported_language, invalid_parameter, invalid_reference_audio,
unknown_generation_option) — cùng mã và cùng thông điệp, nên khẳng định được cả message
chứ không chỉ code; cacheHit là cache của Voice prompt chứ không phải cache audio; handle
đúng hình dạng vp1_<sha256>; và kết quả về theo lô chứ không theo thứ tự gửi — code nào ngầm
giả định thứ tự gửi sẽ vỡ ở đây, đúng như nó sẽ vỡ với sidecar thật.
Ép lỗi bằng hai hook, cùng hình dạng: trả { code, message } — cùng từ vựng sidecar dùng, và
chính mã quyết định tầng. Mã tầng item rơi vào đúng dòng đó rồi stream chạy tiếp; mã tầng fatal
(out_of_memory, hoặc một mã fake không biết) được ném và stream dừng ở đó.
const engine = createFakeVoiceEngine({
// ghi đè từng phần SidecarInfo
info: { autoAsr: true, languages: ["vi"], generationOptions: ["num_step"] },
batchMaxItems: 2,
failCompile: (candidate) =>
candidate === voice ? { code: "invalid_reference_audio", message: "prompt hong" } : undefined,
failSynthesis: (_request, index) =>
index === 1 ? { code: "generation_failed", message: "gave up on it" } : undefined,
referenceOf: () => ({ seconds: 25.4, isCut: true }),
});languages mặc định của fake — ["en", "vi", "zh"] — là mã OmniVoice thật nhận, và
tests/contract.test.ts chốt nó là tập con của danh sách backend thật khai. Đổi backend thì
override info.languages cho khớp, nếu không test xanh ở fake sẽ đỏ lúc chạy thật.
referenceOf quyết định reference của prompt fake biên dịch ra — đây là cách duy nhất test được
màn hình cảnh báo "bản ghi đã bị cắt" mà không cần một file audio dài thật. Không khai thì mọi
prompt báo cùng một hằng (6 giây, chưa chạm trần). Nó chỉ được hỏi lúc biên dịch: trúng cache thì
fake phát lại đúng giá trị đã lưu, y như sidecar.
failCompile nổ ở mọi chỗ prompt được biên dịch: compileVoicePrompt, và một lần cho mỗi dòng
ngay trước khi sinh. Ở compileVoicePrompt không có dòng nào để gắn lỗi vào nên mã tầng item thành
VoiceRequestError — đúng như route trả 400. failSynthesis chỉ nổ ở đường sinh.
EngineClosedError không cần hook: close() rồi gọi tiếp là ra.
Hai chỗ fake cố ý không giống, khai thành code trong ContractDifferences ở
tests/conformance/contract.ts chứ không nằm trong comment: nó không
chép cách xếp lô chính xác của sidecar (sort theo độ dài, --batch-max-chars), và handle của file
recording suy từ đường dẫn chứ không từ nội dung file — fake không đọc đĩa.
Chỗ thứ nhất kéo theo một hệ quả đáng biết. Prompt được biên dịch theo lô, nên trong nhiều item
dùng chung một giọng, item báo cacheHit: false là item nào phụ thuộc thứ tự lô — mà thứ tự
lô của fake khác của sidecar. Đừng khẳng định item nào là lần trượt cache; chỉ khẳng định có
đúng một lần trượt.
Nghe thử
scripts/speak.mjs đi qua đúng bề mặt công khai mà một dự án khác sẽ dùng, và ghi ra file WAV:
pnpm speak -- --voice assets/sample-voice.wav --auto-asr --text "Hôm nay trời đẹp." --out out/hello.wav--auto-asr để sidecar tự nghe ra transcript của Reference recording (tốn thêm ~1,6 GB VRAM);
biết sẵn thì dùng --transcript cho nhanh hơn. Lặp --text nhiều lần để sinh cả mẻ, khi đó
--out được đánh số. pnpm speak -- --help liệt kê đủ cờ.
Phát triển
pnpm install
uv sync --directory sidecarpnpm typecheck && pnpm test # TypeScript, không cần GPU
uv run --directory sidecar pytest # sidecar, không nạp model thật
pnpm check # Biome: format + lint + thứ tự import
pnpm fix # Biome tự sửa những gì sửa được
uv run --directory sidecar ruff check --fix && uv run --directory sidecar ruff formatHai bộ đầu chạy ngược vào fake ở hai seam: TypeScript nói chuyện với một HTTP server dựng
trong test, còn sidecar nạp một backend giả qua VOICE_ENGINE_BACKEND=fake.
Hợp đồng của VoiceEngine sống ở tests/conformance/ — từ vựng trong
contract.ts, một nhóm assertion mỗi file dưới groups/ — và pnpm test chạy nó hai lần: một lần
với fake, một lần với sidecar thật chạy backend giả. Đó là
nguồn sự thật của hợp đồng — hành vi chỉ khẳng định trong test riêng của một adapter là hành vi chỉ
adapter đó mới có.
Pre-commit hook (husky) tự chạy Biome và Ruff trên đúng file được stage, rồi pnpm typecheck.
Test không nằm trong hook vì chậm; chạy tay trước khi push. git commit --no-verify để bỏ qua.
Bộ smoke test dùng GPU thật có gate riêng, và trả lời câu hỏi khác — "máy này chạy được không" chứ không phải "code đúng chưa":
VOICE_ENGINE_SMOKE=1 uv run --directory sidecar pytest tests/test_smoke_gpu.py
uv run --directory sidecar python scripts/measure_batch_size.pyBộ contract test trả lời câu hỏi thứ ba — "sidecar khai về mình có đúng không". Nó đi qua đúng
bề mặt công khai với Speech backend thật và đo lại sample rate, identity, danh sách ngôn ngữ
cùng tính tất định của seed, nên một bản OmniVoice mới làm lệch interface sẽ lộ ra ở đây. Cũng
có gate, cũng cần GPU và model đã tải:
VOICE_ENGINE_CONTRACT=1 pnpm test