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.
- package/AGENTS.md +16 -0
- package/CHANGELOG.md +53 -0
- package/README.md +61 -0
- package/dist/client.d.ts +19 -3
- package/dist/client.js +15 -3
- package/dist/commands/balance.js +5 -2
- package/dist/commands/img-tools.js +2 -1
- package/dist/commands/speak.d.ts +9 -0
- package/dist/commands/speak.js +41 -0
- package/dist/commands/upload.d.ts +4 -1
- package/dist/commands/upload.js +15 -2
- package/dist/compress.js +8 -0
- package/dist/index.js +19 -3
- package/dist/local-bg.d.ts +23 -0
- package/dist/local-bg.js +68 -19
- package/dist/mimo-speech.d.ts +31 -0
- package/dist/mimo-speech.js +127 -0
- package/package.json +1 -1
- package/src/client.ts +23 -5
- package/src/commands/balance.ts +5 -2
- package/src/commands/img-tools.ts +2 -1
- package/src/commands/speak.ts +54 -0
- package/src/commands/upload.ts +18 -2
- package/src/compress.ts +8 -0
- package/src/index.ts +22 -3
- package/src/local-bg.ts +66 -18
- package/src/mimo-speech.ts +139 -0
|
@@ -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
package/src/client.ts
CHANGED
|
@@ -320,8 +320,13 @@ export interface ModelOption {
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
export interface Balance {
|
|
323
|
-
/**
|
|
324
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
/**
|
package/src/commands/balance.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/upload.ts
CHANGED
|
@@ -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(
|
|
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
|
|
188
|
+
.description('本地抠图去背景(BiRefNet/ISNet/U2Net + onnxruntime,免登录):输出带 alpha 的 PNG。首次使用自动下载模型(缓存 ~/.museav-models)')
|
|
188
189
|
.option('--out <path>', '输出路径(默认 <名>-nobg.png)')
|
|
189
|
-
.option('--model <name>', 'isnet
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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(
|
|
113
|
+
.resize(edge, edge, { fit: 'fill', kernel: 'lanczos3' })
|
|
87
114
|
.raw()
|
|
88
115
|
.toBuffer()
|
|
89
|
-
|
|
90
|
-
|
|
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] /
|
|
93
|
-
f32[N + i] = (small[i * 3 + 1] /
|
|
94
|
-
f32[2 * N + i] = (small[i * 3 + 2] /
|
|
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,
|
|
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
|
-
// ──
|
|
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 =
|
|
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++)
|
|
114
|
-
|
|
115
|
-
.
|
|
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
|
+
}
|