{"_id":"@duyquangnvx/voice-engine","_rev":"6-8837d919db7fb7d2c0aea007b71e3140","name":"@duyquangnvx/voice-engine","dist-tags":{"latest":"0.5.1"},"versions":{"0.1.0":{"name":"@duyquangnvx/voice-engine","version":"0.1.0","keywords":["tts","text-to-speech","voice-cloning","omnivoice","local","offline"],"author":"duyquangnvx <duyquangnvx@gmail.com>","license":"MIT","_id":"@duyquangnvx/voice-engine@0.1.0","maintainers":[{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"}],"homepage":"https://github.com/duyquangnvx/voice-engine#readme","bugs":"https://github.com/duyquangnvx/voice-engine/issues","dist":{"shasum":"817246daa96e18080c1ff1fa71d0923eebacbe9c","tarball":"https://registry.npmjs.org/@duyquangnvx/voice-engine/-/voice-engine-0.1.0.tgz","fileCount":119,"integrity":"sha512-ZXKTye1aE7llQVjdgIC9tgZCcEpTQm3APT0KWoZTUjBBGokmq9r/AOa17fIs1dMmI24xX9fwIzF9KcHK6SJgAw==","signatures":[{"sig":"MEUCIHsJ1hdKMzylzMMp5lcNvGNB2pgCmbOnKW3Bf/J3DuMQAiEAr/95q3ACwZcsaBxmsU5gZNCDIgar6HKDkmeJqN9yzN8=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":376083},"type":"module","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./fake":{"types":"./dist/fake/index.d.ts","default":"./dist/fake/index.js"}},"scripts":{"fix":"biome check --write","test":"vitest run","build":"tsc -p tsconfig.build.json","check":"biome check","speak":"pnpm build && node scripts/speak.mjs","typecheck":"tsc --noEmit","test:watch":"vitest"},"_npmUser":{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"},"repository":{"url":"git+https://github.com/duyquangnvx/voice-engine.git","type":"git"},"description":"Local voice cloning for TypeScript, driving an OmniVoice sidecar over loopback HTTP","directories":{},"_nodeVersion":"24.17.0","dependencies":{"zod":"^4.5.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"husky":"^9.1.7","vitest":"^3.2.4","typescript":"^5.9.3","@types/node":"^24.10.1","lint-staged":"^17.4.1","@biomejs/biome":"^2.5.12"},"_npmOperationalInternal":{"tmp":"tmp/voice-engine_0.1.0_1788577620540_0.6177839694410328","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@duyquangnvx/voice-engine","version":"0.2.0","keywords":["tts","text-to-speech","voice-cloning","omnivoice","local","offline"],"author":"duyquangnvx <duyquangnvx@gmail.com>","license":"MIT","_id":"@duyquangnvx/voice-engine@0.2.0","maintainers":[{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"}],"homepage":"https://github.com/duyquangnvx/voice-engine#readme","bugs":"https://github.com/duyquangnvx/voice-engine/issues","dist":{"shasum":"b4419b979b31372fd2b24f28836e3f12fa43c79c","tarball":"https://registry.npmjs.org/@duyquangnvx/voice-engine/-/voice-engine-0.2.0.tgz","fileCount":122,"integrity":"sha512-zgaE4GIblPkDKAnSf4qlrj7lNeRJO1QHfMmyza7mMtsqzJE1aadh0FvPMpeOBpkjcypP6u3cOcj7T4WgIEgwKg==","signatures":[{"sig":"MEYCIQDWPEbzP+3+te0tDEmnK/D5Ixzo72thIlpipFzdbO+32QIhALIm1hsGXRl7fkOCnt/0aAB7r+VYuHQHwsvcamBl2mlZ","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":408806},"type":"module","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./fake":{"types":"./dist/fake/index.d.ts","default":"./dist/fake/index.js"}},"scripts":{"fix":"biome check --write","test":"vitest run","build":"tsc -p tsconfig.build.json","check":"biome check","speak":"pnpm build && node scripts/speak.mjs","typecheck":"tsc --noEmit","test:watch":"vitest"},"_npmUser":{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"},"repository":{"url":"git+https://github.com/duyquangnvx/voice-engine.git","type":"git"},"description":"Local voice cloning for TypeScript, driving an OmniVoice sidecar over loopback HTTP","directories":{},"_nodeVersion":"24.17.0","dependencies":{"zod":"^4.5.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"husky":"^9.1.7","vitest":"^3.2.4","typescript":"^5.9.3","@types/node":"^24.10.1","lint-staged":"^17.4.1","@biomejs/biome":"^2.5.12"},"_npmOperationalInternal":{"tmp":"tmp/voice-engine_0.2.0_1788603128673_0.19327399643455245","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@duyquangnvx/voice-engine","version":"0.3.0","keywords":["tts","text-to-speech","voice-cloning","omnivoice","local","offline"],"author":"duyquangnvx <duyquangnvx@gmail.com>","license":"MIT","_id":"@duyquangnvx/voice-engine@0.3.0","maintainers":[{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"}],"homepage":"https://github.com/duyquangnvx/voice-engine#readme","bugs":"https://github.com/duyquangnvx/voice-engine/issues","dist":{"shasum":"0def648f4ecb7c05dafbe95430f72d5466a61e5c","tarball":"https://registry.npmjs.org/@duyquangnvx/voice-engine/-/voice-engine-0.3.0.tgz","fileCount":128,"integrity":"sha512-DScwNJXjGyr19aRHkhbs5Cni6CaUP1TfyD/LpstYFIw+0gW7d0imcwJfOkGenUKN1evqV2PHxkgkXHy6xiUIYg==","signatures":[{"sig":"MEYCIQD6AGabQPFHYW0jpxtuYXRXM/qWpW7Os2IbHnQGQbhDQAIhANxBBNOkt15ydGPRrVUkzme126HPi0dz69BG0TYH4LLs","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":426901},"type":"module","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./fake":{"types":"./dist/fake/index.d.ts","default":"./dist/fake/index.js"}},"scripts":{"fix":"biome check --write","test":"vitest run","build":"tsc -p tsconfig.build.json","check":"biome check","speak":"pnpm build && node scripts/speak.mjs","typecheck":"tsc --noEmit","test:watch":"vitest"},"_npmUser":{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"},"repository":{"url":"git+https://github.com/duyquangnvx/voice-engine.git","type":"git"},"description":"Local voice cloning for TypeScript, driving an OmniVoice sidecar over loopback HTTP","directories":{},"_nodeVersion":"24.17.0","dependencies":{"zod":"^4.5.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"husky":"^9.1.7","vitest":"^3.2.4","typescript":"^5.9.3","@types/node":"^24.10.1","lint-staged":"^17.4.1","@biomejs/biome":"^2.5.12"},"_npmOperationalInternal":{"tmp":"tmp/voice-engine_0.3.0_1788612431825_0.8991421514444","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"name":"@duyquangnvx/voice-engine","version":"0.4.0","keywords":["tts","text-to-speech","voice-cloning","omnivoice","local","offline"],"author":"duyquangnvx <duyquangnvx@gmail.com>","license":"MIT","_id":"@duyquangnvx/voice-engine@0.4.0","maintainers":[{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"}],"homepage":"https://github.com/duyquangnvx/voice-engine#readme","bugs":"https://github.com/duyquangnvx/voice-engine/issues","dist":{"shasum":"81da1670d217a9456da89d1093d4fbc697e72a0c","tarball":"https://registry.npmjs.org/@duyquangnvx/voice-engine/-/voice-engine-0.4.0.tgz","fileCount":133,"integrity":"sha512-JkHRee2Gxsdph7R/7NcpEL4PFa+tX0n3Azkl+RMXBF/mtq4A+uLf8cjsrnRXUOS7vykEusQtDR0Wq1kepqAcmg==","signatures":[{"sig":"MEUCIClEFZectP5dFy1XRspHjhH5Atimq1/w4z+nHhdlY+AXAiEAyNUntDGR7y6dquCOj+BvEAK2mjlYq3JKOAadOnE9bNM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":431171},"type":"module","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./fake":{"types":"./dist/fake/index.d.ts","default":"./dist/fake/index.js"}},"scripts":{"fix":"biome check --write","test":"vitest run","build":"tsc -p tsconfig.build.json","check":"biome check","speak":"pnpm build && node scripts/speak.mjs","typecheck":"tsc --noEmit","test:watch":"vitest"},"_npmUser":{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"},"repository":{"url":"git+https://github.com/duyquangnvx/voice-engine.git","type":"git"},"description":"Local voice cloning for TypeScript, driving an OmniVoice sidecar over loopback HTTP","directories":{},"_nodeVersion":"24.17.0","dependencies":{"zod":"^4.5.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"husky":"^9.1.7","vitest":"^3.2.4","typescript":"^5.9.3","@types/node":"^24.10.1","lint-staged":"^17.4.1","@biomejs/biome":"^2.5.12"},"_npmOperationalInternal":{"tmp":"tmp/voice-engine_0.4.0_1788668751991_0.9566388971005244","host":"s3://npm-registry-packages-npm-production"}},"0.5.0":{"name":"@duyquangnvx/voice-engine","version":"0.5.0","keywords":["tts","text-to-speech","voice-cloning","omnivoice","local","offline"],"author":{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"},"license":"MIT","_id":"@duyquangnvx/voice-engine@0.5.0","maintainers":[{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"}],"homepage":"https://github.com/duyquangnvx/voice-engine#readme","bugs":{"url":"https://github.com/duyquangnvx/voice-engine/issues"},"bin":{"voice-engine":"dist/cli.js"},"dist":{"shasum":"9c26445fa933687a29ee1f68c4ceb3aab04b7a49","tarball":"https://registry.npmjs.org/@duyquangnvx/voice-engine/-/voice-engine-0.5.0.tgz","fileCount":186,"integrity":"sha512-D+VcPx8/M/I3JgAla/5JY/8qFagolOgTbntTMAj9hBPt+Ohj7LdJBl2MGoHR/dwHjwsACt2AUG4Az86LZEP8Mw==","signatures":[{"sig":"MEUCIQDK7EkkE5XzEomCa9VVu5uih6xnI8bEvBI52d08LsubvQIgQETHyQqgbRrvKUjt3fVszNStgizSWMTSDfOMbWQgLP0=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":520027},"type":"module","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./fake":{"types":"./dist/fake/index.d.ts","default":"./dist/fake/index.js"}},"gitHead":"0157dd97383b981ca15f0930908a856a011d1a5f","scripts":{"fix":"biome check --write","test":"vitest run","build":"tsc -p tsconfig.build.json","check":"biome check","speak":"pnpm build && node scripts/speak.mjs","prepare":"husky","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"pnpm check && pnpm typecheck && pnpm test && pnpm build"},"_npmUser":{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"},"repository":{"url":"git+https://github.com/duyquangnvx/voice-engine.git","type":"git"},"_npmVersion":"11.13.0","description":"Local voice cloning for TypeScript, driving an OmniVoice sidecar over loopback HTTP","directories":{},"_nodeVersion":"24.17.0","dependencies":{"zod":"^4.5.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"husky":"^9.1.7","vitest":"^3.2.4","typescript":"^5.9.3","@types/node":"^24.10.1","lint-staged":"^17.4.1","@biomejs/biome":"^2.5.12"},"_npmOperationalInternal":{"tmp":"tmp/voice-engine_0.5.0_1789280594087_0.49214611708028544","host":"s3://npm-registry-packages-npm-production"}},"0.5.1":{"name":"@duyquangnvx/voice-engine","version":"0.5.1","type":"module","description":"Local voice cloning for TypeScript, driving an OmniVoice sidecar over loopback HTTP","keywords":["tts","text-to-speech","voice-cloning","omnivoice","local","offline"],"license":"MIT","author":{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"},"repository":{"type":"git","url":"git+https://github.com/duyquangnvx/voice-engine.git"},"homepage":"https://github.com/duyquangnvx/voice-engine#readme","bugs":{"url":"https://github.com/duyquangnvx/voice-engine/issues"},"publishConfig":{"access":"public"},"engines":{"node":">=22"},"bin":{"voice-engine":"dist/cli.js"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./fake":{"types":"./dist/fake/index.d.ts","default":"./dist/fake/index.js"}},"scripts":{"build":"tsc -p tsconfig.build.json","typecheck":"tsc --noEmit","check":"biome check","fix":"biome check --write","test":"vitest run","test:watch":"vitest","test:sidecar":"uv run --locked --directory sidecar pytest","speak":"pnpm build && node scripts/speak.mjs","prepare":"husky","prepublishOnly":"pnpm check && pnpm typecheck && pnpm test && pnpm test:sidecar && pnpm build"},"devDependencies":{"@biomejs/biome":"^2.5.12","@types/node":"^24.10.1","husky":"^9.1.7","lint-staged":"^17.4.1","typescript":"^5.9.3","vitest":"^3.2.4"},"dependencies":{"zod":"^4.5.4"},"gitHead":"fe90f245f69e86821566282d87a408a0c043bed7","_id":"@duyquangnvx/voice-engine@0.5.1","_nodeVersion":"24.17.0","_npmVersion":"11.13.0","dist":{"integrity":"sha512-ssQSAeL9YFWkB7QL43gobr83Ae/yr5nT4b0t90RmZN8BXPzvLhLsGCCLOSf1OIYWii4NXJ0r2zi9XaShze8gKA==","shasum":"2f9f13a64675166de3bbc9a1254aee0577b25c80","tarball":"https://registry.npmjs.org/@duyquangnvx/voice-engine/-/voice-engine-0.5.1.tgz","fileCount":186,"unpackedSize":520821,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCICszfHnWkSSI6w6QW7VQL3cFvcuq8J6p+XiVkF/Bg3NPAiEAhId+95wWvBTdZS9h9gQiC+4rEdjHjbsFIsRW0AvPq9U="}]},"_npmUser":{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"},"directories":{},"maintainers":[{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/voice-engine_0.5.1_1789288664153_0.3855045703803357"},"_hasShrinkwrap":false}},"time":{"created":"2026-09-05T03:07:00.386Z","modified":"2026-09-13T08:37:44.516Z","0.1.0":"2026-09-05T03:07:00.692Z","0.2.0":"2026-09-05T10:12:08.831Z","0.3.0":"2026-09-05T12:47:11.997Z","0.4.0":"2026-09-06T04:25:52.153Z","0.5.0":"2026-09-13T06:23:14.259Z","0.5.1":"2026-09-13T08:37:44.296Z"},"bugs":{"url":"https://github.com/duyquangnvx/voice-engine/issues"},"author":{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"},"license":"MIT","homepage":"https://github.com/duyquangnvx/voice-engine#readme","keywords":["tts","text-to-speech","voice-cloning","omnivoice","local","offline"],"repository":{"type":"git","url":"git+https://github.com/duyquangnvx/voice-engine.git"},"description":"Local voice cloning for TypeScript, driving an OmniVoice sidecar over loopback HTTP","maintainers":[{"name":"duyquangnvx","email":"duyquangnvx@gmail.com"}],"readme":"# @duyquangnvx/voice-engine\n\nThư viện TypeScript cho voice cloning chạy hoàn toàn trên máy local. Nó điều khiển một\n**Sidecar** Python giữ sẵn Speech backend (OmniVoice) trong VRAM và phục vụ qua HTTP loopback.\n\nTừ vựng của dự án nằm ở [`CONTEXT.md`](./CONTEXT.md); các quyết định nền ở [`docs/adr/`](./docs/adr/).\n\n## Cài đặt\n\n```\npnpm add @duyquangnvx/voice-engine\n```\n\nGó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:\n\n- [`uv`](https://docs.astral.sh/uv/) trên `PATH` — nó tự lo Python 3.12 và toàn bộ dependency\n  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ể\n  dài hơn ngân sách chờ của `openVoiceEngine`; khi ấy lỗi nói sidecar vẫn đang được dựng tiếp,\n  cứ gọi lại.\n- GPU NVIDIA còn đủ VRAM. Weights được tải từ HuggingFace lúc chạy và mang điều khoản riêng\n  của chúng — bản quyền của gói này chỉ phủ phần code ở đây.\n\n`uv` ghim Python 3.12 và `torch==2.8.0+cu128`. Bản wheel mặc định trên PyPI **không** chứa\nkernel cho GPU Blackwell (compute capability 12.0) và hỏng lúc chạy chứ không lúc cài, nên\nindex wheel được khai báo tường minh trong `sidecar/pyproject.toml`.\n\nChẩ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:\n\n```ts\nimport { diagnose } from \"@duyquangnvx/voice-engine\";\n\nconsole.log(await diagnose());\n```\n\n`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à\nmọ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.\n\n## Dùng\n\n```ts\nimport { writeFile } from \"node:fs/promises\";\nimport { openVoiceEngine, writeWav } from \"@duyquangnvx/voice-engine\";\n\nconst engine = await openVoiceEngine();\n\nconst { pcm, sampleRate } = await engine.synthesize({\n  text: \"Hôm nay trời đẹp.\",\n  voice: {\n    recording: { path: \"D:/voices/ana.wav\", transcript: \"Xin chào, tôi là Ana.\" },\n  },\n});\n\nawait writeFile(\"out/hello.wav\", writeWav(pcm, sampleRate));\nawait engine.close();\n```\n\n`pcm` là **Speech samples** trần: mono, int16 little-endian, không header. Engine không trả file\nvì header không mang thông tin nào bạn chưa có — `sampleRate` và `durationMs` nằm ngay cạnh nó —\ncò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**\n(xem `collectInOrder` bên dưới) rồi `writeWav` một lần ra file đúng tổng thời lượng. Xem\n[`docs/adr/0004-the-engine-returns-samples.md`](./docs/adr/0004-the-engine-returns-samples.md).\n\n`engine.info` là ảnh chụp lúc connect, lấy trong đúng một round-trip vì không thứ nào trong\nđó đổi trong đời một sidecar: `sampleRate`, `autoAsr`, `languages` (danh sách ngôn ngữ được\nnhận), `maxReferenceSeconds` (trần độ dài Reference recording), và `backend` với `name` cùng\n`identity`. Ghi `backend.identity` lại cạnh audio đã sinh — nó gộp bản thư viện, snapshot weights\nvà audio tokenizer, tức là toàn bộ những thứ quyết định bản đọc nghe ra sao.\n\nSinh hàng loạt trả về async iterable; một item lỗi được *yield* ra chứ không làm đổ cả mẻ,\ncòn lỗi hạ tầng thì *ném* và kết thúc vòng lặp:\n\n```ts\nfor await (const result of engine.synthesizeMany(lines)) {\n  if (result.ok)\n    await writeFile(`out/${result.index}.wav`, writeWav(result.pcm, result.sampleRate));\n  else console.error(result.index, result.error.code, result.error.message);\n}\n```\n\nKết quả về **theo lô, không theo thứ tự gửi**: sidecar gom lô theo độ dài text để lô đệm ít nhất\ncó 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à đó\nlà cách duy nhất đặt nó về chỗ cũ. Xem\n[`docs/adr/0006-results-follow-batches-not-submission-order.md`](./docs/adr/0006-results-follow-batches-not-submission-order.md).\n\nVoice 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\nkhá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à\nthứ tự lỗi: lỗi *validate* (`invalid_text`, `unsupported_language`, …) vẫn ra trước mọi dòng\nsinh và theo thứ tự gửi, còn lỗi *biên dịch prompt* (`invalid_reference_audio`) ra tại vị trí\nlô của nó, xen giữa các dòng sinh. Cả hai đều mang đúng `index` của item.\n\nCầ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ả\nmẻ 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:\n\n```ts\nimport { collectInOrder, writeWav } from \"@duyquangnvx/voice-engine\";\n\nconst results = await collectInOrder(engine.synthesizeMany(chapters));\nconst spoken = results.filter((result) => result.ok);\nconst pcm = Buffer.concat(spoken.map((result) => result.pcm));\nawait writeFile(\"out/book.wav\", writeWav(pcm, engine.info.sampleRate));\n```\n\nNgừ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\nkế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;\nnhữ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ạ\n`--batch-max-items` của sidecar, đổi lại thông lượng.\n\n`result.batch` là chi phí sinh thật: `size` là số câu trong lô, `generationMs` là thời gian\nsinh 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\nkhô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\nđượ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.\n\nMộ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ả\n`openVoiceEngine` lẫn `startVoiceEngine`:\n\n| Tuỳ chọn | Mặc định | Chặn cái gì |\n| --- | --- | --- |\n| `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. |\n| `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. |\n| `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. |\n\nHế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\nkhác đang nối vào cùng endpoint.\n\n`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\nsinh 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ó\ndò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\ngiâ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\nphát cả kết quả lẫn nhịp thì vẫn hết giờ như cũ. Số đo ở\n[`docs/research/oom-recovery.md`](./docs/research/oom-recovery.md).\n\n`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ó\ntham số seed nào trên đường inference, nên sidecar tự đặt seed của torch. Cùng hình dạng lô,\ncù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\nkế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á\n(`--batch-max-items`, `--batch-max-chars`). Đó là số đo chứ không phải lời khai — nó là một\nassertion trong `tests/contract.test.ts`.\n\n## Shared sidecar\n\nCả máy dùng chung một bản model trong VRAM: `openVoiceEngine()` nối vào **Shared sidecar** nếu\nnó đ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ẽ ở\n[`docs/adr/0007-a-shared-sidecar-has-no-owner.md`](./docs/adr/0007-a-shared-sidecar-has-no-owner.md).\n\nShared sidecar không có **Sidecar owner**: `engine.close()` **không bao giờ** dừng nó, kể cả khi\nchính tiến trình đó đã dựng nó. Nó giữ VRAM tới khi được bảo dừng:\n\n```\npnpm exec voice-engine sidecar start    # dựng rồi chờ tới khi model nạp xong\npnpm exec voice-engine sidecar status   # đang chạy ở đâu, hay không có gì\npnpm exec voice-engine sidecar stop     # trả VRAM\n```\n\n`start` 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\nđ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.\nSidecar chạy tay không có `--shared` thì vô hình: Voice Engine lặng lẽ dựng một bản khác.\n\nHai tuỳ chọn đổi chỗ `openVoiceEngine` tìm sidecar, và cả hai **không bao giờ tự dựng**:\n\n- `{ start: false }` chỉ nối vào Shared sidecar đang chạy, không có thì ném\n  `SidecarUnavailableError` chỉ tới `voice-engine sidecar start`. Dành cho app mỗi lệnh một\n  process: tự dựng ở đó nghĩa là một lệnh chạy xong để lại vài GB VRAM.\n- `{ endpoint }` nối đích danh một sidecar trên máy này.\n\nShared sidecar đang chạy mà khác **Protocol**, hoặc thiếu auto-ASR mà `{ autoAsr: true }` đòi,\nthì `openVoiceEngine` ném `SidecarUnavailableError` chứ không dừng nó hay dựng bản thứ hai.\n\n`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:\n`stopped`, `warming` (đang nạp model), `running` kèm `info`, hoặc `incompatible`.\n`voice-engine sidecar status` in đúng thứ này.\n\n`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ờ\ncông bố cho máy. Dành cho test và script một lần dùng — `scripts/speak.mjs` đi đường này.\n\n## Reference recording\n\nSidecar chuẩn hoá Reference recording trước khi biên dịch Voice prompt, và chỉ làm việc đó trên\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\nmẻ 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 để\nbản ghi nhọn không bị clip. File toàn im lặng bị từ chối bằng `invalid_reference_audio`.\n\nTrầ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ả\nphầ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`\nthì sidecar cắt rồi để backend nghe lại bản đã cắt; không bật thì nó trả về\n`invalid_reference_audio` và bạn tự cắt kèm transcript khớp.\n\n`compileVoicePrompt` trả về `reference: { seconds, isCut }` — độ dài lời nói **sau khi cắt khoảng\nlặ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:\nchú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\nmàn hình upload là dùng đúng chỗ; so với `engine.info.maxReferenceSeconds` để nói được cần cắt\nxuống bao nhiêu mà không hard-code con số.\n\nNgưỡng đo trên chính backend này, không chép từ dự án khác:\n[`docs/research/reference-audio-behaviour.md`](./docs/research/reference-audio-behaviour.md),\nquyết định ở [`docs/adr/0003-sidecar-normalises-the-reference-recording.md`](./docs/adr/0003-sidecar-normalises-the-reference-recording.md).\n\n## Test ở dự án hạ nguồn\n\n`@duyquangnvx/voice-engine/fake` là một `VoiceEngine` thuần TypeScript — không Python, không uv,\nkhông GPU, không tiến trình con — để unit test code gọi Voice Engine.\n\n```ts\nimport { createFakeVoiceEngine } from \"@duyquangnvx/voice-engine/fake\";\n\nconst engine = createFakeVoiceEngine();\nconst { pcm, batch } = await engine.synthesize({ text: \"Hôm nay trời đẹp.\", voice });\n```\n\nCùng seed ra cùng audio, seed khác ra audio khác. Audio là **tiếng nghe được**, không phải im\nlặng — một pipeline nuốt mất audio vì thế lộ ra thay vì đi qua êm ru.\n\nFake tự bắt chước phần lớn những gì sidecar thật làm: validate item (`invalid_text`,\n`missing_transcript`, `unsupported_language`, `invalid_parameter`, `invalid_reference_audio`,\n`unknown_generation_option`) — **cùng mã và cùng thông điệp**, nên khẳng định được cả `message`\nchứ không chỉ `code`; `cacheHit` là cache của **Voice prompt** chứ không phải cache audio; handle\nđú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\ngiả định thứ tự gửi sẽ vỡ ở đây, đúng như nó sẽ vỡ với sidecar thật.\n\nÉ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à\nchí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\n(`out_of_memory`, hoặc một mã fake không biết) được *ném* và stream dừng ở đó.\n\n```ts\nconst engine = createFakeVoiceEngine({\n  // ghi đè từng phần SidecarInfo\n  info: { autoAsr: true, languages: [\"vi\"], generationOptions: [\"num_step\"] },\n  batchMaxItems: 2,\n  failCompile: (candidate) =>\n    candidate === voice ? { code: \"invalid_reference_audio\", message: \"prompt hong\" } : undefined,\n  failSynthesis: (_request, index) =>\n    index === 1 ? { code: \"generation_failed\", message: \"gave up on it\" } : undefined,\n  referenceOf: () => ({ seconds: 25.4, isCut: true }),\n});\n```\n\n`languages` mặc định của fake — `[\"en\", \"vi\", \"zh\"]` — là mã OmniVoice thật nhận, và\n`tests/contract.test.ts` chốt nó là tập con của danh sách backend thật khai. Đổi backend thì\noverride `info.languages` cho khớp, nếu không test xanh ở fake sẽ đỏ lúc chạy thật.\n\n`referenceOf` quyết định `reference` của prompt fake biên dịch ra — đây là cách duy nhất test được\nmà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\nprompt 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ì\nfake phát lại đúng giá trị đã lưu, y như sidecar.\n\n`failCompile` nổ ở mọi chỗ prompt được biên dịch: `compileVoicePrompt`, và một lần cho mỗi dòng\nngay 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\n`VoiceRequestError` — đúng như route trả 400. `failSynthesis` chỉ nổ ở đường sinh.\n\n`EngineClosedError` không cần hook: `close()` rồi gọi tiếp là ra.\n\nHai chỗ fake **cố ý không** giống, khai thành code trong `ContractDifferences` ở\n[`tests/conformance/contract.ts`](./tests/conformance/contract.ts) chứ không nằm trong comment: nó không\nché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\nrecording suy từ đường dẫn chứ không từ nội dung file — fake không đọc đĩa.\n\nChỗ 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\ndùng chung một giọng, item báo `cacheHit: false` là item nào **phụ thuộc thứ tự lô** — mà thứ tự\nlô 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ó\nđúng một lần trượt.\n\n## Nghe thử\n\n`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:\n\n```\npnpm speak -- --voice assets/sample-voice.wav --auto-asr --text \"Hôm nay trời đẹp.\" --out out/hello.wav\n```\n\n`--auto-asr` để sidecar tự nghe ra transcript của Reference recording (tốn thêm ~1,6 GB VRAM);\nbiết sẵn thì dùng `--transcript` cho nhanh hơn. Lặp `--text` nhiều lần để sinh cả mẻ, khi đó\n`--out` được đánh số. `pnpm speak -- --help` liệt kê đủ cờ.\n\n## Phát triển\n\n```\npnpm install\nuv sync --directory sidecar\n```\n\n```\npnpm typecheck && pnpm test          # TypeScript, không cần GPU\nuv run --directory sidecar pytest    # sidecar, không nạp model thật\npnpm check                           # Biome: format + lint + thứ tự import\npnpm fix                             # Biome tự sửa những gì sửa được\nuv run --directory sidecar ruff check --fix && uv run --directory sidecar ruff format\n```\n\nHai bộ đầu chạy ngược vào fake ở hai seam: TypeScript nói chuyện với một HTTP server dựng\ntrong test, còn sidecar nạp một backend giả qua `VOICE_ENGINE_BACKEND=fake`.\n\nHợp đồng của `VoiceEngine` sống ở [`tests/conformance/`](./tests/conformance/) — từ vựng trong\n`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\nvới fake, một lần với sidecar thật chạy backend giả. Đó là\nnguồ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ỉ\nadapter đó mới có.\n\nPre-commit hook (husky) tự chạy Biome và Ruff trên đúng file được stage, rồi `pnpm typecheck`.\nTest không nằm trong hook vì chậm; chạy tay trước khi push. `git commit --no-verify` để bỏ qua.\n\nBộ 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\nkhông\" chứ không phải \"code đúng chưa\":\n\n```\nVOICE_ENGINE_SMOKE=1 uv run --directory sidecar pytest tests/test_smoke_gpu.py\nuv run --directory sidecar python scripts/measure_batch_size.py\n```\n\nBộ contract test trả lời câu hỏi thứ ba — \"sidecar khai về mình có đúng không\". Nó đi qua đúng\nbề mặt công khai với Speech backend thật và **đo lại** sample rate, identity, danh sách ngôn ngữ\ncù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\ncó gate, cũng cần GPU và model đã tải:\n\n```\nVOICE_ENGINE_CONTRACT=1 pnpm test\n```\n","readmeFilename":"README.md"}