Skip to content

Commit 3c1a282

Browse files
committed
feat(python-env): migrate to pyproject.toml with uv sync support
- Add pyproject.toml with modern dependency management (lock funasr-onnx<0.3 to avoid torch) - Support uv sync for faster dev environment setup - Fix broken symlink detection in prepare-python-env.js - Add patch fallback for funasr_onnx torch dependency (compatibility layer) - Update README with new dependency management instructions
1 parent a1aa349 commit 3c1a282

4 files changed

Lines changed: 1685 additions & 12 deletions

File tree

desktop/README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,17 @@ pnpm run prepare:python
8585
- 本项目桌面端的本地 ASR **默认使用 FunASR ONNX(`funasr-onnx + onnxruntime`**,因此 **不需要安装 `torch`**
8686
- Windows 也使用 FunASR ONNX,无需额外安装 faster-whisper。
8787

88-
补充说明(关于 `prepare:python`
88+
**依赖管理**
8989

90-
- **默认(开发模式)**:在 macOS 上会优先使用本机的 `uv` 来创建 `python-env` 并安装依赖(更轻量),无 `uv` 时回退到 venv/pip。
91-
- **打包/发布(bundle 模式)**:如你需要在本机构建可搬运的内置 Python 环境,可执行:
90+
- 项目使用 `pyproject.toml` 管理 Python 依赖(现代标准)
91+
- 推荐安装 [uv](https://docs.astral.sh/uv/)(更快的包管理器):`brew install uv`
9292

93-
```bash
94-
PREPARE_PYTHON_MODE=bundle pnpm run prepare:python
95-
```
93+
**关于 `prepare:python`**
94+
95+
| 模式 | 命令 | 说明 |
96+
|------|------|------|
97+
| 开发(默认) | `pnpm run prepare:python` | macOS 优先使用 `uv sync`,无 uv 时回退到 venv/pip |
98+
| 打包/发布 | `PREPARE_PYTHON_MODE=bundle pnpm run prepare:python` | 创建可搬运的内置 Python 环境 |
9699

97100
### 启动
98101

@@ -107,7 +110,7 @@ pnpm dev
107110
- **Windows**: 10 / 11
108111
- **macOS**: 12.0+
109112
- 需要麦克风权限
110-
- Node.js 20+, Python 3.8+
113+
- Node.js 20+, Python 3.10+(推荐安装 [uv](https://docs.astral.sh/uv/)
111114

112115
---
113116

desktop/pyproject.toml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
[project]
2+
name = "livegalgame-desktop-backend"
3+
version = "0.1.0"
4+
description = "LiveGalGame Desktop ASR Backend"
5+
readme = "README.md"
6+
requires-python = ">=3.10,<3.13"
7+
8+
dependencies = [
9+
# ASR 核心(0.2.5 没有 SenseVoice 导入,不需要 torch)
10+
"funasr-onnx>=0.2.5,<0.3",
11+
"onnxruntime",
12+
13+
# 音频处理
14+
"soundfile",
15+
"numpy",
16+
17+
# 模型下载
18+
"huggingface_hub",
19+
"modelscope",
20+
21+
# Web API
22+
"fastapi",
23+
"uvicorn[standard]",
24+
"websockets",
25+
"python-multipart",
26+
27+
# 网络请求(含 socks 代理支持)
28+
"requests[socks]",
29+
"httpx[socks]",
30+
31+
# FunASR ONNX 内部依赖
32+
"jieba",
33+
34+
# 打包
35+
"pyinstaller",
36+
]
37+
38+
[tool.uv]
39+
# 使用 uv 管理的 Python(自动下载)
40+
managed = true

desktop/scripts/prepare-python-env.js

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const venvDir = path.join(projectRoot, 'python-env');
2020
const bootstrapDir = path.join(projectRoot, 'python-bootstrap');
2121
const miniforgePrefix = path.join(bootstrapDir, 'miniforge');
2222
const requirementsPath = path.join(projectRoot, 'requirements.txt');
23+
const pyprojectPath = path.join(projectRoot, 'pyproject.toml');
2324

2425
const isWin = process.platform === 'win32';
2526
const isMac = process.platform === 'darwin';
@@ -92,6 +93,38 @@ function hasUv() {
9293
return res.success;
9394
}
9495

96+
function hasPyproject() {
97+
return fs.existsSync(pyprojectPath);
98+
}
99+
100+
function pathExistsOrSymlink(p) {
101+
// fs.existsSync 会跟随 symlink;当 symlink 断链时会返回 false。
102+
// 但我们需要把“断链 symlink”也当作“路径占位存在”,否则创建目录会报 File exists。
103+
if (fs.existsSync(p)) return true;
104+
try {
105+
fs.lstatSync(p);
106+
return true;
107+
} catch {
108+
return false;
109+
}
110+
}
111+
112+
function removeIfBrokenSymlink(p) {
113+
try {
114+
const stat = fs.lstatSync(p);
115+
if (!stat.isSymbolicLink()) return false;
116+
// 如果是断链 symlink:existsSync 会是 false;这时应删除 link 本身
117+
if (!fs.existsSync(p)) {
118+
console.warn(`[prepare-python-env] Detected broken symlink, removing: ${p}`);
119+
fs.unlinkSync(p);
120+
return true;
121+
}
122+
return false;
123+
} catch {
124+
return false;
125+
}
126+
}
127+
95128
function bootstrapMiniforge() {
96129
if (process.platform !== 'darwin') {
97130
throw new Error(
@@ -144,6 +177,9 @@ function bootstrapMiniforge() {
144177
}
145178

146179
function ensureCondaEnv(miniforgePython, { forceRebuild = false } = {}) {
180+
// 兼容:venvDir 可能是断链 symlink(例如 python-env -> .venv 且 .venv 不存在)
181+
removeIfBrokenSymlink(venvDir);
182+
147183
let condaBin = isWin
148184
? path.join(miniforgePrefix, 'Scripts', 'conda.exe')
149185
: path.join(miniforgePrefix, 'bin', 'conda');
@@ -185,6 +221,9 @@ function ensureCondaEnv(miniforgePython, { forceRebuild = false } = {}) {
185221
}
186222

187223
function ensureVenv(pythonCmd, { forceRebuild = false } = {}) {
224+
// 兼容:venvDir 可能是断链 symlink(例如 python-env -> .venv 且 .venv 不存在)
225+
removeIfBrokenSymlink(venvDir);
226+
188227
if (fs.existsSync(venvDir) && forceRebuild) {
189228
console.log(`[prepare-python-env] removing existing venv for rebuild: ${venvDir}`);
190229
fs.rmSync(venvDir, { recursive: true, force: true });
@@ -310,7 +349,10 @@ function packCondaEnv() {
310349

311350
function ensureUvVenv(pythonSpec, { forceRebuild = false, relocatable = false } = {}) {
312351
const useRelocatable = relocatable ? '--relocatable' : '';
313-
if (fs.existsSync(venvDir) && (forceRebuild || !isVenvPrefix(venvDir))) {
352+
// 兼容:venvDir 可能是断链 symlink(例如 python-env -> .venv 且 .venv 不存在)
353+
removeIfBrokenSymlink(venvDir);
354+
355+
if (pathExistsOrSymlink(venvDir) && (forceRebuild || !isVenvPrefix(venvDir))) {
314356
console.log(`[prepare-python-env] (uv) clearing existing env: ${venvDir}`);
315357
// uv venv --clear 会清理目标目录;这里也兼容“目录存在但不是 venv”的情况
316358
run(`uv venv "${venvDir}" --clear ${useRelocatable} --python "${pythonSpec}" --managed-python`);
@@ -333,6 +375,69 @@ function installDepsWithUv() {
333375
run(`uv pip install -r "${requirementsPath}" -p "${pythonPath}"`);
334376
}
335377

378+
/**
379+
* 使用 uv sync(pyproject.toml 模式)一次性创建 venv 并安装依赖
380+
* 比 uv venv + uv pip install 更简洁、更可靠
381+
*/
382+
function ensureUvSync({ forceRebuild = false, pythonSpec = desiredPy } = {}) {
383+
// 清理断链 symlink
384+
removeIfBrokenSymlink(venvDir);
385+
386+
if (forceRebuild && pathExistsOrSymlink(venvDir)) {
387+
console.log(`[prepare-python-env] (uv sync) removing existing env for rebuild: ${venvDir}`);
388+
fs.rmSync(venvDir, { recursive: true, force: true });
389+
}
390+
391+
console.log(`[prepare-python-env] (uv sync) syncing project (python=${pythonSpec}) ...`);
392+
// uv sync 会自动:创建 venv(如不存在)、解析 pyproject.toml、安装依赖
393+
// --python 指定版本,--managed-python 让 uv 自动下载对应版本
394+
// UV_PROJECT_ENVIRONMENT 指定 venv 路径(默认是 .venv,我们改成 python-env)
395+
run(`uv sync --python "${pythonSpec}" --managed-python`, {
396+
cwd: projectRoot,
397+
env: { ...process.env, UV_PROJECT_ENVIRONMENT: venvDir },
398+
});
399+
}
400+
401+
/**
402+
* Patch funasr_onnx/__init__.py,移除对 torch 的无用依赖(兼容层)
403+
* funasr_onnx >= 0.3 的 __init__.py 无条件导入了 sensevoice_bin(需要 torch),
404+
* 但本项目不使用 SenseVoice。当前 pyproject.toml 锁定 funasr-onnx < 0.3 以避免此问题,
405+
* 此 patch 仅作为兼容层,以防未来升级。
406+
*/
407+
function patchFunasrOnnx() {
408+
const sitePackages = path.join(venvDir, isWin ? 'Lib/site-packages' : 'lib/python' + desiredPy + '/site-packages');
409+
const initFile = path.join(sitePackages, 'funasr_onnx', '__init__.py');
410+
411+
if (!fs.existsSync(initFile)) {
412+
console.log('[prepare-python-env] funasr_onnx not found, skipping patch');
413+
return;
414+
}
415+
416+
let content = fs.readFileSync(initFile, 'utf-8');
417+
418+
// 检查是否已经 patch 过
419+
if (content.includes('# PATCHED: sensevoice_bin')) {
420+
console.log('[prepare-python-env] funasr_onnx already patched');
421+
return;
422+
}
423+
424+
// 替换 sensevoice_bin 的导入为 try/except
425+
const originalImport = 'from .sensevoice_bin import SenseVoiceSmall';
426+
const patchedImport = `# PATCHED: sensevoice_bin requires torch, but we don't use SenseVoice
427+
try:
428+
from .sensevoice_bin import SenseVoiceSmall
429+
except ImportError:
430+
SenseVoiceSmall = None`;
431+
432+
if (content.includes(originalImport)) {
433+
content = content.replace(originalImport, patchedImport);
434+
fs.writeFileSync(initFile, content);
435+
console.log('[prepare-python-env] patched funasr_onnx to skip torch dependency');
436+
} else {
437+
console.log('[prepare-python-env] funasr_onnx import pattern not found, skipping patch');
438+
}
439+
}
440+
336441
/**
337442
* 修复 venv 中的 Python 符号链接为实际文件副本
338443
* venv 创建的符号链接是绝对路径,打包后在其他机器上会失效
@@ -398,10 +503,20 @@ function main() {
398503
// - dev: 默认用 uv(若可用)创建 venv 并安装依赖,避免 Miniforge+conda-pack 的笨重流程
399504
// - bundle: 默认使用 conda env + conda-pack,确保可搬运(打包发布)
400505
if (useUv) {
401-
// dev 默认不需要 relocatable;bundle 模式下允许用 uv 的 relocatable venv(实验性)
402-
ensureUvVenv(desiredPy, { forceRebuild, relocatable: useBundle });
403-
installDepsWithUv();
404-
fixPythonSymlinks();
506+
// 优先使用 pyproject.toml + uv sync(更现代、更干净)
507+
if (hasPyproject()) {
508+
ensureUvSync({ forceRebuild, pythonSpec: desiredPy });
509+
} else {
510+
// 回退:uv venv + uv pip install(兼容只有 requirements.txt 的场景)
511+
ensureUvVenv(desiredPy, { forceRebuild, relocatable: useBundle });
512+
installDepsWithUv();
513+
}
514+
// patch funasr_onnx 以移除对 torch 的无用依赖
515+
patchFunasrOnnx();
516+
// 只在 bundle 模式下 fix symlinks(开发模式保持 symlink 指向 uv managed python)
517+
if (useBundle) {
518+
fixPythonSymlinks();
519+
}
405520
} else if (useBundle) {
406521
const updatedPy = ensureCondaEnv(pythonCmd, { forceRebuild });
407522
if (updatedPy) {

0 commit comments

Comments
 (0)