Repository navigation
fix(voice): bundle native audio addon into standalone archives #5628
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
ae93d51
fe50b80
df4e1c3
2709c77
26d8120
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,6 +11,7 @@ import crypto from 'node:crypto'; | |||||||||||||
| import fs from 'node:fs'; | ||||||||||||||
| import os from 'node:os'; | ||||||||||||||
| import path from 'node:path'; | ||||||||||||||
| import { createRequire } from 'node:module'; | ||||||||||||||
| import { pipeline } from 'node:stream/promises'; | ||||||||||||||
| import { fileURLToPath } from 'node:url'; | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -36,6 +37,17 @@ const TARGETS = new Map([ | |||||||||||||
| ['win-x64', { outputExtension: 'zip', nodeExecutable: ['node.exe'] }], | ||||||||||||||
| ]); | ||||||||||||||
|
|
||||||||||||||
| // Standalone target -> prebuildify platform-arch dir name (process.platform | ||||||||||||||
| // based, so Windows is 'win32'). Only this archive's matching prebuild is | ||||||||||||||
| // bundled, keeping each archive lean and correct-arch. | ||||||||||||||
| const TARGET_PREBUILD_DIR = new Map([ | ||||||||||||||
| ['darwin-arm64', 'darwin-arm64'], | ||||||||||||||
| ['darwin-x64', 'darwin-x64'], | ||||||||||||||
| ['linux-arm64', 'linux-arm64'], | ||||||||||||||
| ['linux-x64', 'linux-x64'], | ||||||||||||||
| ['win-x64', 'win32-x64'], | ||||||||||||||
| ]); | ||||||||||||||
|
|
||||||||||||||
| const DIST_REQUIRED_PATHS = [ | ||||||||||||||
| 'cli.js', | ||||||||||||||
| 'chunks', | ||||||||||||||
|
|
@@ -118,6 +130,7 @@ async function main() { | |||||||||||||
| fs.mkdirSync(runtimeExtractDir, { recursive: true }); | ||||||||||||||
|
|
||||||||||||||
| copyRuntimeAssets(packageRoot, outDir); | ||||||||||||||
| copyNativeAddon(packageRoot, target); | ||||||||||||||
| extractNodeArchive(nodeArchive, runtimeExtractDir); | ||||||||||||||
| const nodeDir = path.join(packageRoot, 'node'); | ||||||||||||||
| copyExtractedNode(runtimeExtractDir, nodeDir); | ||||||||||||||
|
|
@@ -278,6 +291,71 @@ function copyRuntimeAssets(packageRoot, outDir) { | |||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Bundle the @qwen-code/audio-capture native addon (compiled JS + only this | ||||||||||||||
| // target's prebuild + its runtime dep node-gyp-build) into lib/node_modules so | ||||||||||||||
| // streaming voice works in standalone installs. The addon is esbuild-external | ||||||||||||||
| // and resolved at runtime via import('@qwen-code/audio-capture') from | ||||||||||||||
| // lib/cli.js, so lib/node_modules is where Node looks. Without it, standalone | ||||||||||||||
| // users fall back to SoX/arecord (batch only) — #5502 follow-up #5590. | ||||||||||||||
| function copyNativeAddon(packageRoot, target) { | ||||||||||||||
| const prebuildDirName = TARGET_PREBUILD_DIR.get(target); | ||||||||||||||
| const addonSrc = path.join(rootDir, 'packages', 'audio-capture'); | ||||||||||||||
| const prebuildSrc = path.join(addonSrc, 'prebuilds', prebuildDirName); | ||||||||||||||
| if (!fs.existsSync(prebuildSrc)) { | ||||||||||||||
| // No prebuild for this target (e.g. a local build without the release | ||||||||||||||
| // artifacts). Ship without the addon: voice degrades to the SoX/arecord | ||||||||||||||
| // fallback, streaming is unavailable. The release pipeline downloads | ||||||||||||||
| // prebuilds before packaging, so release archives do bundle it. | ||||||||||||||
| console.warn( | ||||||||||||||
| `[standalone] no audio-capture prebuild for ${prebuildDirName}; ` + | ||||||||||||||
| 'bundling without the native addon (streaming voice unavailable; ' + | ||||||||||||||
| 'batch via SoX still works).', | ||||||||||||||
| ); | ||||||||||||||
| return; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const nodeRequire = createRequire(import.meta.url); | ||||||||||||||
| const nodeGypBuildSrc = path.dirname( | ||||||||||||||
| nodeRequire.resolve('node-gyp-build/package.json'), | ||||||||||||||
| ); | ||||||||||||||
|
|
||||||||||||||
| const modulesDir = path.join(packageRoot, 'lib', 'node_modules'); | ||||||||||||||
| const addonDest = path.join(modulesDir, '@qwen-code', 'audio-capture'); | ||||||||||||||
| fs.mkdirSync(addonDest, { recursive: true }); | ||||||||||||||
|
|
||||||||||||||
| // Trimmed manifest: keep type/exports so ESM resolution works; drop the | ||||||||||||||
| // install hook (no npm runs inside the archive). | ||||||||||||||
| const addonPkg = JSON.parse( | ||||||||||||||
| fs.readFileSync(path.join(addonSrc, 'package.json'), 'utf8'), | ||||||||||||||
| ); | ||||||||||||||
| delete addonPkg.scripts; | ||||||||||||||
| delete addonPkg.devDependencies; | ||||||||||||||
| fs.writeFileSync( | ||||||||||||||
| path.join(addonDest, 'package.json'), | ||||||||||||||
| JSON.stringify(addonPkg, null, 2) + '\n', | ||||||||||||||
| ); | ||||||||||||||
|
|
||||||||||||||
| const copyOpts = { | ||||||||||||||
| recursive: true, | ||||||||||||||
| dereference: true, | ||||||||||||||
| verbatimSymlinks: false, | ||||||||||||||
| }; | ||||||||||||||
| fs.cpSync( | ||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion]
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||
| path.join(addonSrc, 'dist'), | ||||||||||||||
| path.join(addonDest, 'dist'), | ||||||||||||||
| copyOpts, | ||||||||||||||
| ); | ||||||||||||||
| fs.cpSync( | ||||||||||||||
| prebuildSrc, | ||||||||||||||
| path.join(addonDest, 'prebuilds', prebuildDirName), | ||||||||||||||
| copyOpts, | ||||||||||||||
| ); | ||||||||||||||
| // node-gyp-build is the addon's only runtime dependency (zero-dep itself). | ||||||||||||||
| fs.cpSync(nodeGypBuildSrc, path.join(modulesDir, 'node-gyp-build'), copyOpts); | ||||||||||||||
|
|
||||||||||||||
| assertNoSymlinks(modulesDir, 'Bundled native addon still contains symlinks.'); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function topLevelDistEntryForPath(candidatePath) { | ||||||||||||||
| const relative = path.relative(distDir, candidatePath); | ||||||||||||||
| if ( | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion]
copyNativeAddoncan throw rawENOENT(ifpackages/audio-capture/dist/is missing) orMODULE_NOT_FOUND(ifnode-gyp-buildisn't resolvable) without a contextual error message. The rest of this file usesfail()with descriptive messages (e.g.,"Required dist asset missing: ..."). Wrapping these calls in try/catch withfail(...)would match the existing pattern and make build failures self-diagnosing — especially important in CI where a rawENOENTfromcpSyncdoesn't mention the addon-bundling step.Similarly, add an existence check before the
dist/copy:— qwen3.7-max via Qwen Code /review