museav-cli 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * 小米 MiMo 语音能力(合成 / 音色设计 / 音色克隆 / 识别)—— 直连上游,不经中台。
3
+ *
4
+ * 为什么直连:中台的出音链路还没接完(media_type=audio 的路由与落盘在做),而这批能力
5
+ * 目前只给内部用、不开放给租户。CLI 直连能立刻用上,也天然不会漏给租户——租户手里
6
+ * 没有这把 key。等中台接完再决定要不要把 CLI 切过去。
7
+ *
8
+ * ⚠️ 协议层的真源是 museav-manager 的 `shared/mimo-audio.js`(那边有 11 个单测钉着)。
9
+ * 这里是 TS 副本,**改协议要同步两边**。复制而不是共享的原因:CLI 是独立发布的 npm 包,
10
+ * 跨仓 import 会把中台仓变成它的构建依赖。
11
+ *
12
+ * ## 四个反直觉的点(实测踩出来的,写错不会报错,只是拿不到音频)
13
+ *
14
+ * 1. 合成**不走 /v1/audio/speech**。OpenAI 那套音频端点这边一个都没有(试了七个全 404),
15
+ * 四种能力共用 `/v1/chat/completions`,靠 model 区分。
16
+ * 2. **待合成文本放 assistant 角色**,user 放音色指令。反过来写会得到一段「回答」而不是朗读。
17
+ * 3. 音频是 **base64** 回在 `message.audio.data`,不是二进制流。
18
+ * 4. 识别的输入音频在 user 的 content **数组**里(`type: 'input_audio'`),且要裸 base64。
19
+ *
20
+ * key 走 MIMO_API_KEY 环境变量(跟 OLLAMA_HOST / MUSEAV_LOCAL_VLM 一个路子),不进
21
+ * ~/.museav.json —— 那个文件存的是中台身份,跟这个上游是两回事。
22
+ */
23
+ import { readFile } from 'node:fs/promises';
24
+ /** 专属 Base URL。换端点用 MIMO_BASE_URL,不用改代码 */
25
+ const BASE = (process.env.MIMO_BASE_URL || 'https://token-plan-cn.xiaomimimo.com/v1').replace(/\/+$/, '');
26
+ const MODELS = {
27
+ tts: 'mimo-v2.5-tts',
28
+ design: 'mimo-v2.5-tts-voicedesign',
29
+ clone: 'mimo-v2.5-tts-voiceclone',
30
+ asr: 'mimo-v2.5-asr',
31
+ };
32
+ /** 默认预置音色。上游没有「列出音色」的接口,这个是文档给出且实测可用的 */
33
+ export const DEFAULT_VOICE = 'Chloe';
34
+ export function mimoKey() {
35
+ const key = process.env.MIMO_API_KEY || '';
36
+ if (!key) {
37
+ throw new Error('缺少 MIMO_API_KEY。语音能力直连小米 MiMo,不走中台身份:\n'
38
+ + ' export MIMO_API_KEY=... 或\n'
39
+ + ' cs kyvault run --env MIMO_API_KEY=secret://mimo/api-key -- museav speak ...');
40
+ }
41
+ return key;
42
+ }
43
+ async function call(body) {
44
+ const res = await fetch(`${BASE}/chat/completions`, {
45
+ method: 'POST',
46
+ headers: { Authorization: `Bearer ${mimoKey()}`, 'Content-Type': 'application/json' },
47
+ body: JSON.stringify(body),
48
+ });
49
+ const text = await res.text();
50
+ if (!res.ok)
51
+ throw new Error(`上游 HTTP ${res.status}:${text.slice(0, 200)}`);
52
+ let data;
53
+ try {
54
+ data = JSON.parse(text);
55
+ }
56
+ catch {
57
+ throw new Error(`上游返回不是 JSON:${text.slice(0, 200)}`);
58
+ }
59
+ if (data?.error)
60
+ throw new Error(data.error.message || String(data.error));
61
+ if (!Array.isArray(data?.choices) || !data.choices.length)
62
+ throw new Error('上游返回里没有 choices');
63
+ return data;
64
+ }
65
+ /** 用哪种模式,取决于给了什么参数——克隆 > 设计 > 预置音色 */
66
+ export function speechMode(opts) {
67
+ if (opts.clonePath)
68
+ return 'clone';
69
+ if (opts.design)
70
+ return 'design';
71
+ return 'tts';
72
+ }
73
+ /**
74
+ * 合成语音,返回 WAV 数据。
75
+ * @param text 要读出来的文本
76
+ */
77
+ export async function synthesize(text, opts = {}) {
78
+ const content = String(text || '').trim();
79
+ if (!content)
80
+ throw new Error('要合成的文本是空的');
81
+ const mode = speechMode(opts);
82
+ const messages = [];
83
+ // user 放指令:设计模式靠它定义音色,其余模式靠它调语气
84
+ const instruction = mode === 'design' ? opts.design : opts.instruction;
85
+ if (instruction)
86
+ messages.push({ role: 'user', content: instruction });
87
+ // 待合成文本必须是 assistant,见文件头第 2 条
88
+ messages.push({ role: 'assistant', content });
89
+ const audio = { format: 'wav' };
90
+ if (mode === 'clone') {
91
+ const buf = await readFile(opts.clonePath);
92
+ audio.voice = `data:audio/wav;base64,${buf.toString('base64')}`;
93
+ }
94
+ else if (mode === 'tts') {
95
+ audio.voice = opts.voice || DEFAULT_VOICE;
96
+ }
97
+ const data = await call({ model: MODELS[mode], messages, audio });
98
+ const b64 = data.choices[0]?.message?.audio?.data;
99
+ if (!b64)
100
+ throw new Error('上游没有返回音频(audio.data 为空)');
101
+ return Buffer.from(b64, 'base64');
102
+ }
103
+ /**
104
+ * 识别音频里的文字。
105
+ *
106
+ * ⚠️ 质量有波动:同一段合成音频两次实测,一次「声影成诗,一念成相」(同音字级别),
107
+ * 一次「上庸城失,一面呈象」(整句都错)。别把结果直接当可信文本用在计费或入库口径上。
108
+ */
109
+ export async function transcribe(audioPath) {
110
+ const buf = await readFile(audioPath);
111
+ const format = /\.(wav|mp3|m4a|flac|ogg|pcm)$/i.exec(audioPath)?.[1]?.toLowerCase() || 'wav';
112
+ const data = await call({
113
+ model: MODELS.asr,
114
+ messages: [{ role: 'user', content: [{ type: 'input_audio', input_audio: { data: buf.toString('base64'), format } }] }],
115
+ });
116
+ const text = data.choices[0]?.message?.content;
117
+ if (typeof text !== 'string' || !text.trim())
118
+ throw new Error('上游没有返回识别文本');
119
+ return text.trim();
120
+ }
121
+ /** WAV 时长(秒),用于给用户一个「出了多长」的反馈。头部损坏时返回 null 而不是抛错 */
122
+ export function wavSeconds(buf) {
123
+ if (buf.length < 44 || buf.subarray(0, 4).toString() !== 'RIFF')
124
+ return null;
125
+ const byteRate = buf.readUInt32LE(28);
126
+ return byteRate > 0 ? (buf.length - 44) / byteRate : null;
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "museav-cli",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "MUSE AV 出图中台官方 CLI —— 命令行调中台 API 出图、出视频、读图逆向、图生模板",
5
5
  "type": "module",
6
6
  "bin": {
package/src/client.ts CHANGED
@@ -320,8 +320,13 @@ export interface ModelOption {
320
320
  }
321
321
 
322
322
  export interface Balance {
323
- /** 数值单位是 ¥(人民币)。字段名带 usd 是历史遗留命名,不代表美元——中台侧不存在汇率换算 */
324
- balance_usd: number
323
+ /** 余额(¥)。中台 2026-08-21 起发这个字段名 */
324
+ balance_cny?: number
325
+ /**
326
+ * 同一个数的旧字段名,中台仍在双发。字段名带 usd 纯属历史遗留,值一直是人民币——
327
+ * 中台侧不存在汇率换算。老版本 CLI 只认这个名字,所以中台不会立刻停发。
328
+ */
329
+ balance_usd?: number
325
330
  /** 租户加价率(0.2 = 加价 20%) */
326
331
  markup_pct: number
327
332
  checked_at: string
@@ -624,9 +629,22 @@ export class StudioClient {
624
629
  * 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
625
630
  * 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
626
631
  */
627
- async uploadRef(filePath: string): Promise<{ url: string; media_type?: string; mime?: string }> {
628
- const r = await this.request('upload-ref', { method: 'POST', body: await fileForm(filePath) })
629
- return { url: r.url, media_type: r.media_type, mime: r.mime }
632
+ /**
633
+ * 上传素材。默认只存文件、回直链(参考图/垫图就该这样)。
634
+ *
635
+ * asWork=true 时另外记一条作品:不落库的话文件只存在 R2 里,
636
+ * 作品页、后台画廊、项目归档全都看不见它 —— 「传到我的账户」就没发生。
637
+ * 只对账户身份生效(作品要归到具体某个人头上),租户 key 传了也会被中台忽略。
638
+ */
639
+ async uploadRef(
640
+ filePath: string,
641
+ opts: { asWork?: boolean; workspaceId?: string } = {},
642
+ ): Promise<{ url: string; media_type?: string; mime?: string; job_id?: string | null }> {
643
+ const form = await fileForm(filePath)
644
+ if (opts.asWork) form.append('as_work', '1')
645
+ if (opts.workspaceId) form.append('workspace_id', opts.workspaceId)
646
+ const r = await this.request('upload-ref', { method: 'POST', body: form })
647
+ return { url: r.url, media_type: r.media_type, mime: r.mime, job_id: r.job_id ?? null }
630
648
  }
631
649
 
632
650
  /**
@@ -3,8 +3,11 @@ import type { StudioClient } from '../client.js'
3
3
 
4
4
  export async function balance(client: StudioClient): Promise<void> {
5
5
  const r = await client.balance()
6
- // 单位 ¥ 人民币(后台 2026-08-09 起只返回租户自己的余额,不再下发上游供应商聚合数据)
7
- process.stderr.write(`余额: ¥${r.balance_usd?.toFixed(2) ?? '?'}`)
6
+ // 单位 ¥ 人民币(后台 2026-08-09 起只返回租户自己的余额,不再下发上游供应商聚合数据)。
7
+ // 优先读 balance_cny:中台已改用这个名字,balance_usd 是双发过渡期的旧名,
8
+ // 两个值永远相等,但等中台停发旧名时这里不用再改一次。
9
+ const cny = r.balance_cny ?? r.balance_usd
10
+ process.stderr.write(`余额: ¥${cny?.toFixed(2) ?? '?'}`)
8
11
  if (r.markup_pct) process.stderr.write(` 加价率: ${(r.markup_pct * 100).toFixed(0)}%`)
9
12
  if (r.checked_at) process.stderr.write(` 校验时间: ${r.checked_at.slice(0, 19).replace('T', ' ')}`)
10
13
  process.stderr.write('\n')
@@ -82,7 +82,8 @@ export interface RemoveBgOpts {
82
82
 
83
83
  export async function removeBgCmd(input: string, opts: RemoveBgOpts): Promise<void> {
84
84
  if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
85
- const modelKey = (opts.model || 'isnet') as BgModelKey
85
+ // 默认 birefnet:实测对毛发、白色主体、低对比度背景的召回远好于 isnet/u2net
86
+ const modelKey = (opts.model || 'birefnet') as BgModelKey
86
87
  if (!(modelKey in BG_MODELS)) throw new Error(`--model 只支持 ${Object.keys(BG_MODELS).join(' / ')}`)
87
88
 
88
89
  const start = Date.now()
@@ -0,0 +1,54 @@
1
+ /**
2
+ * museav speak —— 文本转语音,stdout 输出生成的文件路径。
3
+ * museav transcribe —— 语音转文本,stdout 输出识别结果。
4
+ *
5
+ * 三种音色来源,给了什么参数就走哪条:
6
+ * 默认 预置音色(--voice Chloe)
7
+ * --design 一句话描述音色,当场造一个
8
+ * --clone 拿一段音频当样本,克隆它的音色
9
+ *
10
+ * 直连小米 MiMo,不经中台,需要 MIMO_API_KEY —— 原因见 src/mimo-speech.ts 的文件头。
11
+ */
12
+ import { writeFile } from 'node:fs/promises'
13
+ import { basename, resolve } from 'node:path'
14
+ import { synthesize, transcribe, speechMode, wavSeconds, DEFAULT_VOICE } from '../mimo-speech.js'
15
+
16
+ const MODE_LABEL = { tts: '预置音色', design: '音色设计', clone: '音色克隆' } as const
17
+
18
+ export interface SpeakCliOptions {
19
+ out?: string
20
+ voice?: string
21
+ design?: string
22
+ clone?: string
23
+ instruction?: string
24
+ }
25
+
26
+ export async function speak(text: string, opts: SpeakCliOptions = {}): Promise<void> {
27
+ const mode = speechMode({ clonePath: opts.clone, design: opts.design })
28
+ // 克隆模式下 opts.clone 是整条路径,进度行里只留文件名——绝对路径会把这行顶到换行
29
+ const detail = mode === 'tts' ? (opts.voice || DEFAULT_VOICE)
30
+ : mode === 'clone' ? basename(opts.clone || '') : (opts.design || '')
31
+ process.stderr.write(`合成中(${MODE_LABEL[mode]}${detail ? ` · ${detail}` : ''})...\n`)
32
+
33
+ const buf = await synthesize(text, {
34
+ voice: opts.voice,
35
+ design: opts.design,
36
+ clonePath: opts.clone,
37
+ instruction: opts.instruction,
38
+ })
39
+
40
+ // 默认落在当前目录,文件名带时间戳避免连续合成互相覆盖
41
+ const out = resolve(opts.out || `speech-${Date.now()}.wav`)
42
+ await writeFile(out, buf)
43
+ const secs = wavSeconds(buf)
44
+ process.stderr.write(`✅ ${(buf.length / 1024).toFixed(0)}KB${secs ? ` · ${secs.toFixed(2)}s` : ''}\n`)
45
+ console.log(out)
46
+ }
47
+
48
+ export async function transcribeCmd(audioPath: string): Promise<void> {
49
+ process.stderr.write(`识别中 ${audioPath} ...\n`)
50
+ const text = await transcribe(audioPath)
51
+ // 质量有波动(见 mimo-speech.ts 的注释),提醒一句,但不影响 stdout 的机器可读性
52
+ process.stderr.write('✅ 识别完成(同音字可能有误,重要场景请核对)\n')
53
+ console.log(text)
54
+ }
@@ -9,10 +9,26 @@ import type { StudioClient } from '../client.js'
9
9
 
10
10
  const KIND_LABEL: Record<string, string> = { image: '图片', audio: '音频', video: '视频' }
11
11
 
12
- export async function upload(client: StudioClient, filePath: string): Promise<void> {
12
+ export async function upload(
13
+ client: StudioClient,
14
+ filePath: string,
15
+ opts: { toWorks?: boolean; workspace?: string } = {},
16
+ ): Promise<void> {
13
17
  process.stderr.write(`上传 ${filePath} ...\n`)
14
- const { url, media_type, mime } = await client.uploadRef(filePath)
18
+ const { url, media_type, mime, job_id } = await client.uploadRef(filePath, {
19
+ asWork: opts.toWorks,
20
+ workspaceId: opts.workspace,
21
+ })
15
22
  const kind = media_type ? `${KIND_LABEL[media_type] || media_type}${mime ? ` · ${mime}` : ''}` : ''
16
23
  process.stderr.write(`✅ 上传成功${kind ? `(${kind})` : ''}\n`)
24
+ if (opts.toWorks) {
25
+ // 说清有没有真的进作品库:租户 key 调用时中台不记作品,只提示「已上传」会让人以为进去了
26
+ process.stderr.write(job_id
27
+ ? '📁 已收进你的作品库,在「我的作品」里能看到\n'
28
+ : '⚠️ 文件已上传,但没能记进作品库(租户 Key 调用不记作品,作品要归到具体账户)\n')
29
+ } else if (media_type === 'video') {
30
+ // 传视频十有八九是想收成品,顺手提一句 —— 但不擅自替他决定
31
+ process.stderr.write('提示:加 --to-works 可以把它收进「我的作品」\n')
32
+ }
17
33
  console.log(url)
18
34
  }
package/src/compress.ts CHANGED
@@ -59,6 +59,14 @@ export async function compressForVision(filePath: string): Promise<CompressResul
59
59
  try {
60
60
  meta = await sharp(filePath).metadata()
61
61
  } catch {
62
+ // HEIC 解码失败会走到这里(libheif 安全检查拒绝),但它不是「视频/音频」——
63
+ // 原样上传会被中台按魔数判成 image/heic,而 gpt-image-2 上游解码不了 HEIC
64
+ // 字节 → 400 invalid_image_input。与其等到出图时才炸,不如上传前就报清楚。
65
+ // 判断依据:文件名扩展名 .heic/.heif/.avif 最可靠(HEIC 与 MP4 共享 ftyp 容器头,
66
+ // 不能靠字节区分;sharp 读不了恰恰说明它是图不是视频——视频不喂 sharp 走这里)。
67
+ if (/\.(heic|heif|avif)$/i.test(name)) {
68
+ throw new Error(`HEIC 图片 ${name} 无法直接上传:上游模型认不得 HEIC 字节。请先转成 JPG/PNG 再上传(macOS 可用「预览」打开后另存为 JPEG,或用 museav img min 转换)`)
69
+ }
62
70
  return { buffer: null, filename: name, note: '' } // 不是 sharp 认识的图(视频/音频)→ 原样传
63
71
  }
64
72
 
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ import { jobs } from './commands/jobs.js'
28
28
  import { whoami } from './commands/whoami.js'
29
29
  import { products } from './commands/products.js'
30
30
  import { assets } from './commands/assets.js'
31
+ import { speak, transcribeCmd } from './commands/speak.js'
31
32
 
32
33
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) as { name: string; version: string }
33
34
  // 每 12 小时最多查一次 npm registry,过期才提示,不拖慢日常调用
@@ -184,9 +185,9 @@ program
184
185
 
185
186
  program
186
187
  .command('remove-bg <file>')
187
- .description('本地抠图去背景(ISNet/U2Net + onnxruntime,免登录):输出带 alpha 的 PNG。首次使用自动下载模型(~170MB,缓存 ~/.museav-models)')
188
+ .description('本地抠图去背景(BiRefNet/ISNet/U2Net + onnxruntime,免登录):输出带 alpha 的 PNG。首次使用自动下载模型(缓存 ~/.museav-models)')
188
189
  .option('--out <path>', '输出路径(默认 <名>-nobg.png)')
189
- .option('--model <name>', 'isnet(默认,质量优先)/ u2net')
190
+ .option('--model <name>', 'birefnet(默认,细节最好,~214MB)/ isnet / u2net')
190
191
  .option('--overwrite', '允许覆盖已存在的输出文件')
191
192
  .action(asyncRun((input: string, opts: any) => removeBgCmd(input, opts)))
192
193
 
@@ -263,7 +264,25 @@ program
263
264
  program
264
265
  .command('upload <file>')
265
266
  .description('上传素材(图片/音频/视频,按字节内容判类型;图片 8MB / 音频 20MB / 视频 50MB),stdout 输出公网直链')
266
- .action(withClient((client: StudioClient, file: string) => upload(client, file)))
267
+ .option('--to-works', '同时收进「我的作品」(在外面做好的成品视频/图片用这个;参考图不用)')
268
+ .option('--workspace <id>', '归档到指定项目')
269
+ .action(withClient((client: StudioClient, file: string, opts: { toWorks?: boolean; workspace?: string }) =>
270
+ upload(client, file, { toWorks: opts.toWorks, workspace: opts.workspace })))
271
+
272
+ program
273
+ .command('speak <text>')
274
+ .description('文本转语音(小米 MiMo,直连上游需 MIMO_API_KEY,不走中台身份):stdout 输出 wav 路径')
275
+ .option('--out <path>', '输出路径(默认 speech-<时间戳>.wav)')
276
+ .option('--voice <name>', '预置音色,默认 Chloe')
277
+ .option('--design <desc>', '一句话描述音色,当场造一个(如「低沉沙哑的中年男声」)')
278
+ .option('--clone <file>', '拿这段音频当样本,克隆它的音色')
279
+ .option('--instruction <text>', '语气/风格指令(三种模式都可用)')
280
+ .action(asyncRun((text: string, opts: any) => speak(text, opts)))
281
+
282
+ program
283
+ .command('transcribe <audio>')
284
+ .description('语音转文本(小米 MiMo,需 MIMO_API_KEY):stdout 输出识别结果。同音字可能有误,重要场景请核对')
285
+ .action(asyncRun((audio: string) => transcribeCmd(audio)))
267
286
 
268
287
  program
269
288
  .command('models')
package/src/local-bg.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 本地抠图(去背景)—— remove-bg 的核心实现。
3
- * 模型走 ONNX(ISNet / U2Net,均 Apache-2.0),推理走 onnxruntime-node(MIT),
3
+ * 模型走 ONNX(ISNet / U2Net / BiRefNet-Lite,均 Apache-2.0),推理走 onnxruntime-node(MIT),
4
4
  * 前后处理走 sharp —— 整条链路许可证干净(imgly 那个 npm 包是 AGPL,不进依赖),
5
5
  * 且三个依赖在 macOS / Windows / Linux 都有预编译,无平台特化代码。
6
6
  * 模型文件首次使用时下载到 ~/.museav-models/ 缓存(一次性 ~170MB)。
@@ -9,24 +9,48 @@ import { mkdir, writeFile, stat } from 'node:fs/promises'
9
9
  import { join } from 'node:path'
10
10
  import { homedir } from 'node:os'
11
11
 
12
+ /**
13
+ * 每个模型的输入尺寸与归一化参数**都不一样**,必须随模型带着走。
14
+ * 取值对齐 rembg 的 session 定义(sessions/dis_general_use.py、sessions/u2net.py):
15
+ * isnet-general-use → resize 1024,mean (0.5,0.5,0.5) std (1,1,1)
16
+ * u2net → resize 320,mean ImageNet std ImageNet
17
+ * 用错任何一项都不会报错,只会安静地输出一张糊掉的 mask —— 这是最难查的那种 bug。
18
+ */
12
19
  export const BG_MODELS = {
13
20
  isnet: {
14
21
  file: 'isnet-general-use.onnx',
15
22
  // rembg 官方 release 托管的同一份模型(Apache-2.0,源自 xuebinqin/DIS)
16
23
  url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/isnet-general-use.onnx',
17
24
  label: 'ISNet(通用,质量优先)',
25
+ edge: 1024,
26
+ mean: [0.5, 0.5, 0.5] as const,
27
+ std: [1.0, 1.0, 1.0] as const,
18
28
  },
19
29
  u2net: {
20
30
  file: 'u2net.onnx',
21
31
  url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx',
22
32
  label: 'U2Net(经典通用)',
33
+ edge: 320,
34
+ mean: [0.485, 0.456, 0.406] as const,
35
+ std: [0.229, 0.224, 0.225] as const,
36
+ },
37
+ birefnet: {
38
+ file: 'birefnet-general-lite.onnx',
39
+ url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/BiRefNet-general-bb_swin_v1_tiny-epoch_232.onnx',
40
+ label: 'BiRefNet-Lite(细节最好,毛发/低对比度主体优先用它)',
41
+ edge: 1024,
42
+ mean: [0.485, 0.456, 0.406] as const,
43
+ std: [0.229, 0.224, 0.225] as const,
44
+ // BiRefNet 输出的是 logits,要先过 sigmoid 才是概率;ISNet/U2Net 的输出已经是 0-1 区间。
45
+ // 漏掉这步不会报错,只会得到一张几乎全是半透明的 mask。
46
+ sigmoid: true,
47
+ size: 214, // MB,下载提示用
23
48
  },
24
49
  } as const
25
50
 
26
51
  export type BgModelKey = keyof typeof BG_MODELS
27
52
 
28
53
  const MODEL_DIR = join(homedir(), '.museav-models')
29
- const INPUT_EDGE = 1024
30
54
 
31
55
  function modelPath(key: BgModelKey): string {
32
56
  return join(MODEL_DIR, BG_MODELS[key].file)
@@ -43,7 +67,8 @@ export async function ensureBgModel(key: BgModelKey): Promise<string> {
43
67
  }
44
68
  await mkdir(MODEL_DIR, { recursive: true })
45
69
  const def = BG_MODELS[key]
46
- process.stderr.write(`↓ 首次使用,下载 ${def.label}(~170MB,一次性,缓存到 ${MODEL_DIR})...\n`)
70
+ const mb = 'size' in def ? `~${def.size}MB` : '~170MB'
71
+ process.stderr.write(`↓ 首次使用,下载 ${def.label}(${mb},一次性,缓存到 ${MODEL_DIR})...\n`)
47
72
  const resp = await fetch(def.url)
48
73
  if (!resp.ok || !resp.body) throw new Error(`模型下载失败 HTTP ${resp.status}:${def.url}`)
49
74
  const total = Number(resp.headers.get('content-length') || 0)
@@ -81,40 +106,63 @@ export async function removeBackgroundLocal(inputPath: string, modelKey: BgModel
81
106
  const { data: rgb, info } = await sharp(inputPath).rotate().removeAlpha().raw().toBuffer({ resolveWithObject: true })
82
107
  if (info.channels !== 3) throw new Error(`预处理得到 ${info.channels} 通道(预期 3)`)
83
108
 
84
- // ── 模型输入:拉伸到 1024×1024,(x/255 - 0.5)/0.5 归一化,HWC → CHW ──
109
+ // ── 模型输入:按该模型的 edge 拉伸,(x/max - mean)/std 归一化,HWC → CHW ──
110
+ const def = BG_MODELS[modelKey]
111
+ const edge = def.edge
85
112
  const small = await sharp(rgb, { raw: { width: info.width, height: info.height, channels: 3 } })
86
- .resize(INPUT_EDGE, INPUT_EDGE, { fit: 'fill' })
113
+ .resize(edge, edge, { fit: 'fill', kernel: 'lanczos3' })
87
114
  .raw()
88
115
  .toBuffer()
89
- const f32 = new Float32Array(3 * INPUT_EDGE * INPUT_EDGE)
90
- const N = INPUT_EDGE * INPUT_EDGE
116
+ // 分母是「这张图的最大像素值」而不是固定 255 —— 对齐 rembg im_ary / max(im_ary)
117
+ // 整体偏暗的图用 255 归一化会让输入分布整体偏小,mask 跟着糊。
118
+ let peak = 0
119
+ for (let i = 0; i < small.length; i++) if (small[i] > peak) peak = small[i]
120
+ const scale = Math.max(peak, 1e-6)
121
+ const N = edge * edge
122
+ const f32 = new Float32Array(3 * N)
91
123
  for (let i = 0; i < N; i++) {
92
- f32[i] = (small[i * 3] / 255 - 0.5) / 0.5
93
- f32[N + i] = (small[i * 3 + 1] / 255 - 0.5) / 0.5
94
- f32[2 * N + i] = (small[i * 3 + 2] / 255 - 0.5) / 0.5
124
+ f32[i] = (small[i * 3] / scale - def.mean[0]) / def.std[0]
125
+ f32[N + i] = (small[i * 3 + 1] / scale - def.mean[1]) / def.std[1]
126
+ f32[2 * N + i] = (small[i * 3 + 2] / scale - def.mean[2]) / def.std[2]
95
127
  }
96
128
  const feeds: Record<string, import('onnxruntime-node').Tensor> = {}
97
- feeds[session.inputNames[0]] = new ort.Tensor('float32', f32, [1, 3, INPUT_EDGE, INPUT_EDGE])
129
+ feeds[session.inputNames[0]] = new ort.Tensor('float32', f32, [1, 3, edge, edge])
98
130
  const results = await session.run(feeds)
99
131
  const out = results[session.outputNames[0]]
100
132
  const maskFlat = out.data as Float32Array
101
133
  if (maskFlat.length < N) throw new Error(`模型输出尺寸异常(${maskFlat.length})`)
102
134
 
103
- // ── 后处理:min-max 归一化到 0-255,再缩回原图尺寸 ──
135
+ // ── 后处理:(按模型)sigmoid → min-max 归一化到 0-255 → 缩回原图尺寸 ──
136
+ const needSigmoid = 'sigmoid' in def && def.sigmoid
137
+ const prob = new Float32Array(N)
138
+ for (let i = 0; i < N; i++) {
139
+ prob[i] = needSigmoid ? 1 / (1 + Math.exp(-maskFlat[i])) : maskFlat[i]
140
+ }
104
141
  let lo = Infinity
105
142
  let hi = -Infinity
106
143
  for (let i = 0; i < N; i++) {
107
- const v = maskFlat[i]
144
+ const v = prob[i]
108
145
  if (v < lo) lo = v
109
146
  if (v > hi) hi = v
110
147
  }
111
148
  const range = hi - lo || 1
112
149
  const mask8 = Buffer.alloc(N)
113
- for (let i = 0; i < N; i++) mask8[i] = Math.round(((maskFlat[i] - lo) / range) * 255)
114
- const maskFull = await sharp(mask8, { raw: { width: INPUT_EDGE, height: INPUT_EDGE, channels: 1 } })
115
- .resize(info.width, info.height, { fit: 'fill' })
150
+ for (let i = 0; i < N; i++) {
151
+ const v = (prob[i] - lo) / range
152
+ mask8[i] = Math.round(Math.min(1, Math.max(0, v)) * 255) // clip(0,1) 对齐 rembg
153
+ }
154
+ // ⚠️ sharp 对单通道 raw 做 resize 后会吐出 3 通道(灰度被展开成 RGB)。
155
+ // 按单通道去索引 maskFull[i] 就会以 1/3 的步长错位采样,输出一张隔行残影的图 ——
156
+ // 不报错、不崩,只是抠出来的东西是条纹状的。所以这里既强制灰度色彩空间,
157
+ // 又用实际返回的 channels 来索引,两道保险。
158
+ const { data: maskFull, info: maskInfo } = await sharp(mask8, {
159
+ raw: { width: edge, height: edge, channels: 1 },
160
+ })
161
+ .resize(info.width, info.height, { fit: 'fill', kernel: 'lanczos3' })
162
+ .toColourspace('b-w')
116
163
  .raw()
117
- .toBuffer()
164
+ .toBuffer({ resolveWithObject: true })
165
+ const maskStride = maskInfo.channels || 1
118
166
 
119
167
  // ── alpha 合成:直接构造 RGBA(alpha = mask),不依赖 composite 的混合语义 ──
120
168
  const w = info.width
@@ -124,7 +172,7 @@ export async function removeBackgroundLocal(inputPath: string, modelKey: BgModel
124
172
  rgba[i * 4] = rgb[i * 3]
125
173
  rgba[i * 4 + 1] = rgb[i * 3 + 1]
126
174
  rgba[i * 4 + 2] = rgb[i * 3 + 2]
127
- rgba[i * 4 + 3] = maskFull[i]
175
+ rgba[i * 4 + 3] = maskFull[i * maskStride]
128
176
  }
129
177
  return sharp(rgba, { raw: { width: w, height: h, channels: 4 } }).png().toBuffer()
130
178
  }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * 小米 MiMo 语音能力(合成 / 音色设计 / 音色克隆 / 识别)—— 直连上游,不经中台。
3
+ *
4
+ * 为什么直连:中台的出音链路还没接完(media_type=audio 的路由与落盘在做),而这批能力
5
+ * 目前只给内部用、不开放给租户。CLI 直连能立刻用上,也天然不会漏给租户——租户手里
6
+ * 没有这把 key。等中台接完再决定要不要把 CLI 切过去。
7
+ *
8
+ * ⚠️ 协议层的真源是 museav-manager 的 `shared/mimo-audio.js`(那边有 11 个单测钉着)。
9
+ * 这里是 TS 副本,**改协议要同步两边**。复制而不是共享的原因:CLI 是独立发布的 npm 包,
10
+ * 跨仓 import 会把中台仓变成它的构建依赖。
11
+ *
12
+ * ## 四个反直觉的点(实测踩出来的,写错不会报错,只是拿不到音频)
13
+ *
14
+ * 1. 合成**不走 /v1/audio/speech**。OpenAI 那套音频端点这边一个都没有(试了七个全 404),
15
+ * 四种能力共用 `/v1/chat/completions`,靠 model 区分。
16
+ * 2. **待合成文本放 assistant 角色**,user 放音色指令。反过来写会得到一段「回答」而不是朗读。
17
+ * 3. 音频是 **base64** 回在 `message.audio.data`,不是二进制流。
18
+ * 4. 识别的输入音频在 user 的 content **数组**里(`type: 'input_audio'`),且要裸 base64。
19
+ *
20
+ * key 走 MIMO_API_KEY 环境变量(跟 OLLAMA_HOST / MUSEAV_LOCAL_VLM 一个路子),不进
21
+ * ~/.museav.json —— 那个文件存的是中台身份,跟这个上游是两回事。
22
+ */
23
+ import { readFile } from 'node:fs/promises'
24
+
25
+ /** 专属 Base URL。换端点用 MIMO_BASE_URL,不用改代码 */
26
+ const BASE = (process.env.MIMO_BASE_URL || 'https://token-plan-cn.xiaomimimo.com/v1').replace(/\/+$/, '')
27
+
28
+ const MODELS = {
29
+ tts: 'mimo-v2.5-tts',
30
+ design: 'mimo-v2.5-tts-voicedesign',
31
+ clone: 'mimo-v2.5-tts-voiceclone',
32
+ asr: 'mimo-v2.5-asr',
33
+ } as const
34
+
35
+ /** 默认预置音色。上游没有「列出音色」的接口,这个是文档给出且实测可用的 */
36
+ export const DEFAULT_VOICE = 'Chloe'
37
+
38
+ export function mimoKey(): string {
39
+ const key = process.env.MIMO_API_KEY || ''
40
+ if (!key) {
41
+ throw new Error(
42
+ '缺少 MIMO_API_KEY。语音能力直连小米 MiMo,不走中台身份:\n'
43
+ + ' export MIMO_API_KEY=... 或\n'
44
+ + ' cs kyvault run --env MIMO_API_KEY=secret://mimo/api-key -- museav speak ...',
45
+ )
46
+ }
47
+ return key
48
+ }
49
+
50
+ async function call(body: unknown): Promise<any> {
51
+ const res = await fetch(`${BASE}/chat/completions`, {
52
+ method: 'POST',
53
+ headers: { Authorization: `Bearer ${mimoKey()}`, 'Content-Type': 'application/json' },
54
+ body: JSON.stringify(body),
55
+ })
56
+ const text = await res.text()
57
+ if (!res.ok) throw new Error(`上游 HTTP ${res.status}:${text.slice(0, 200)}`)
58
+ let data: any
59
+ try { data = JSON.parse(text) } catch { throw new Error(`上游返回不是 JSON:${text.slice(0, 200)}`) }
60
+ if (data?.error) throw new Error(data.error.message || String(data.error))
61
+ if (!Array.isArray(data?.choices) || !data.choices.length) throw new Error('上游返回里没有 choices')
62
+ return data
63
+ }
64
+
65
+ export interface SpeakOptions {
66
+ /** 预置音色名(默认 Chloe)。与 design / clonePath 互斥 */
67
+ voice?: string
68
+ /** 一句话描述音色 → 走音色设计 */
69
+ design?: string
70
+ /** 音色样本音频路径 → 走音色克隆 */
71
+ clonePath?: string
72
+ /** 风格/语气指令。三种模式都能用 */
73
+ instruction?: string
74
+ }
75
+
76
+ /** 合成的三种模式。asr 不在里面——它是识别,不由 speechMode 决定 */
77
+ export type SpeechMode = 'tts' | 'design' | 'clone'
78
+
79
+ /** 用哪种模式,取决于给了什么参数——克隆 > 设计 > 预置音色 */
80
+ export function speechMode(opts: SpeakOptions): SpeechMode {
81
+ if (opts.clonePath) return 'clone'
82
+ if (opts.design) return 'design'
83
+ return 'tts'
84
+ }
85
+
86
+ /**
87
+ * 合成语音,返回 WAV 数据。
88
+ * @param text 要读出来的文本
89
+ */
90
+ export async function synthesize(text: string, opts: SpeakOptions = {}): Promise<Buffer> {
91
+ const content = String(text || '').trim()
92
+ if (!content) throw new Error('要合成的文本是空的')
93
+
94
+ const mode = speechMode(opts)
95
+ const messages: Array<{ role: string; content: string }> = []
96
+ // user 放指令:设计模式靠它定义音色,其余模式靠它调语气
97
+ const instruction = mode === 'design' ? opts.design : opts.instruction
98
+ if (instruction) messages.push({ role: 'user', content: instruction })
99
+ // 待合成文本必须是 assistant,见文件头第 2 条
100
+ messages.push({ role: 'assistant', content })
101
+
102
+ const audio: Record<string, string> = { format: 'wav' }
103
+ if (mode === 'clone') {
104
+ const buf = await readFile(opts.clonePath as string)
105
+ audio.voice = `data:audio/wav;base64,${buf.toString('base64')}`
106
+ } else if (mode === 'tts') {
107
+ audio.voice = opts.voice || DEFAULT_VOICE
108
+ }
109
+
110
+ const data = await call({ model: MODELS[mode], messages, audio })
111
+ const b64 = data.choices[0]?.message?.audio?.data
112
+ if (!b64) throw new Error('上游没有返回音频(audio.data 为空)')
113
+ return Buffer.from(b64, 'base64')
114
+ }
115
+
116
+ /**
117
+ * 识别音频里的文字。
118
+ *
119
+ * ⚠️ 质量有波动:同一段合成音频两次实测,一次「声影成诗,一念成相」(同音字级别),
120
+ * 一次「上庸城失,一面呈象」(整句都错)。别把结果直接当可信文本用在计费或入库口径上。
121
+ */
122
+ export async function transcribe(audioPath: string): Promise<string> {
123
+ const buf = await readFile(audioPath)
124
+ const format = /\.(wav|mp3|m4a|flac|ogg|pcm)$/i.exec(audioPath)?.[1]?.toLowerCase() || 'wav'
125
+ const data = await call({
126
+ model: MODELS.asr,
127
+ messages: [{ role: 'user', content: [{ type: 'input_audio', input_audio: { data: buf.toString('base64'), format } }] }],
128
+ })
129
+ const text = data.choices[0]?.message?.content
130
+ if (typeof text !== 'string' || !text.trim()) throw new Error('上游没有返回识别文本')
131
+ return text.trim()
132
+ }
133
+
134
+ /** WAV 时长(秒),用于给用户一个「出了多长」的反馈。头部损坏时返回 null 而不是抛错 */
135
+ export function wavSeconds(buf: Buffer): number | null {
136
+ if (buf.length < 44 || buf.subarray(0, 4).toString() !== 'RIFF') return null
137
+ const byteRate = buf.readUInt32LE(28)
138
+ return byteRate > 0 ? (buf.length - 44) / byteRate : null
139
+ }