@kmchai/cortico-world-voice 0.2.0 → 0.3.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/CHANGELOG.md +16 -0
- package/README.md +12 -9
- package/describe.json +1 -1
- package/dist/webui.css +1 -0
- package/dist/webui.js +1 -0
- package/package.json +9 -3
- package/src/ENV_PROMPT.md +1 -1
- package/src/config.ts +42 -46
- package/src/definition.ts +3 -5
- package/src/models.ts +66 -0
- package/src/panel.ts +48 -0
- package/src/service.ts +86 -5
- package/src/speech/tags.ts +8 -3
- package/src/speech/text.ts +4 -1
- package/src/types.ts +47 -2
- package/src/world.ts +234 -91
- package/src/options.ts +0 -36
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## 0.3.0
|
|
2
|
+
|
|
3
|
+
- 系统 Config tab 移除「音色与情绪」组;音色、情绪控制、默认情绪、默认表演指令、语速与副语言标签移到「模型」页。
|
|
4
|
+
- 朗读风格按模型保存进 `worlds.voice.modelStyles[model]`;切换模型时面板读回该模型的音色、风格与参数,不用先保存。旧全局字段保留为没有模型设置时的回落。
|
|
5
|
+
- `voice_style` 在选定模型后只写当前模型的风格;没有模型时仍写全局回落值。
|
|
6
|
+
- 服务返回的 Ming 3.0 模型参数(模板、语言、指令语言、音高、音量、生成与分块参数)在模型页显示;IndexTTS2 的中文参数名继续保留。
|
|
7
|
+
- 文本过滤、合成与 `tts.voice` 事件都使用当前模型生效的风格。
|
|
8
|
+
|
|
9
|
+
## 0.2.1
|
|
10
|
+
|
|
11
|
+
- 新增 World 页的「模型」面板:模型与音色在面板里选,不再占系统 Config tab 的字段。
|
|
12
|
+
- 按服务 `/v1/models` 返回的参数元数据渲染字段:开关、枚举、数字与文本;IndexTTS2 的参数带中文名。
|
|
13
|
+
- 模型专属参数存进 `worlds.voice.modelOptions[model]`,请求时作为 `options` 发给服务;空对象删掉该模型的条目。
|
|
14
|
+
- 面板显示当前模型的提示词片段;目录缓存在进程内,服务不可达时保留上一次结果并报错。
|
|
15
|
+
- 新增 `scripts/build-webui.mjs`(esbuild)与 `dist/webui.js` / `dist/webui.css`。
|
|
16
|
+
|
|
1
17
|
## 0.2.0
|
|
2
18
|
|
|
3
19
|
分层:协议、进程托管与 HTTP 网关移到 `@kmchai/cortico-world-service`;语音只保留工具、概率朗读、文本过滤、情绪与标签语义、队列和本机播放,经 HTTP 调服务。
|
package/README.md
CHANGED
|
@@ -34,7 +34,6 @@ corepack pnpm add "D:/path/to/cortico-world-voice" --ignore-workspace
|
|
|
34
34
|
"serviceUrl": "http://127.0.0.1:9883",
|
|
35
35
|
"serviceTokenSecret": "",
|
|
36
36
|
"model": "ming3-tts",
|
|
37
|
-
"voice": "4016",
|
|
38
37
|
"playback": "local",
|
|
39
38
|
"autoSpeak": true,
|
|
40
39
|
"autoProbability": 0.3
|
|
@@ -45,6 +44,10 @@ corepack pnpm add "D:/path/to/cortico-world-voice" --ignore-workspace
|
|
|
45
44
|
|
|
46
45
|
`serviceUrl` 默认为 `http://127.0.0.1:9883`。`serviceTokenSecret` 填部署 `.env` 里的变量名,值只在请求时读取;服务监听非回环地址时必须配令牌。后端条目、模型 profile 与上游密钥都配在服务 World 里,见[服务包 README](https://github.com/Kmch-AI/cortico-world-service#readme)。
|
|
47
46
|
|
|
47
|
+
## 模型面板
|
|
48
|
+
|
|
49
|
+
World 页的「模型」tab 选模型与音色,并按服务 `/v1/models` 返回的参数元数据渲染字段。同一页的「声音与情绪」按模型保存情绪控制方式、默认情绪、默认表演指令、语速与副语言标签策略;切换模型时读回该模型自己的设置。模型设置存 `worlds.voice.modelStyles[model]`,模型参数存 `worlds.voice.modelOptions[model]`;合成时作为 `options` 发给服务。模型专属的文本与情绪写法显示在同页的「模型提示词」折叠区。系统 Config tab 只留服务地址、触发、过滤与输出。
|
|
50
|
+
|
|
48
51
|
## 工具
|
|
49
52
|
|
|
50
53
|
| 工具 | 作用 |
|
|
@@ -78,7 +81,7 @@ corepack pnpm add "D:/path/to/cortico-world-voice" --ignore-workspace
|
|
|
78
81
|
|
|
79
82
|
## 情绪、语速与标签
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
情绪与语速设置按模型保存在「模型」页,不再出现在系统 Config tab。语音只把语义字段发给服务,由服务的模型 profile 决定怎么落到具体模型:
|
|
82
85
|
|
|
83
86
|
| `emotionMode` | 语音发送 | `audiocpp-ming3` profile 的落点 |
|
|
84
87
|
|---|---|---|
|
|
@@ -92,10 +95,10 @@ OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求
|
|
|
92
95
|
|
|
93
96
|
`response_format` 每次都带;`model` 与 `voice` 配了才带,工具参数里的音色优先于配置默认值。
|
|
94
97
|
|
|
95
|
-
- 情绪来源:工具参数 > 正文里的 `[emotion:开心]` 标记 > `defaultEmotion`。
|
|
96
|
-
- `voice_style`
|
|
98
|
+
- 情绪来源:工具参数 > 正文里的 `[emotion:开心]` 标记 > 当前模型的 `defaultEmotion`。
|
|
99
|
+
- `voice_style` 读写当前模型的长期风格(情绪、指令、音色、语速),写进 `modelStyles[model]`;还没选模型时写到全局回落值。
|
|
97
100
|
- 副语言标签:`[Breathing] [Cough] [Sigh] [Laughter] [Surprise-ah] [Surprise-oh] [Dissatisfaction-hnn] [Uhm] [Confirmation-en] [Question-ei] [Question-ah] [Question-en] [Question-oh] [Question-huh]`。
|
|
98
|
-
`paralinguistic: auto`
|
|
101
|
+
`paralinguistic: auto` 只在当前模型支持标签(Ming 3.0 系)时保留,IndexTTS2 与通用模型剥掉;`keep` 一律保留,`strip` 一律删除。表外 `[...]` 会从朗读文本里剥掉,防止被念出来。
|
|
99
102
|
|
|
100
103
|
## 文本过滤
|
|
101
104
|
|
|
@@ -121,8 +124,8 @@ OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求
|
|
|
121
124
|
|---|---|
|
|
122
125
|
| `POST /v1/audio/speech` | 合成音频;体 `{model?, input, voice?, response_format, speed?, instructions?, emotion?}`。 |
|
|
123
126
|
| `GET /health` | 探活;World 页的服务灯每 15 秒刷新。 |
|
|
124
|
-
| `GET /v1/models` |
|
|
125
|
-
| `GET /v1/audio/voices?model=` |
|
|
127
|
+
| `GET /v1/models` | 模型、音色与请求参数元数据。 |
|
|
128
|
+
| `GET /v1/audio/voices?model=` | 当前模型的音色表。 |
|
|
126
129
|
|
|
127
130
|
服务不可达时:模型与音色下拉保留上一次读到的表,服务灯变红,`voice_speak` 回失败。语音 0.2.0 不保留直连后端的回退路径。服务不重试上游,语音也不重试。
|
|
128
131
|
|
|
@@ -137,7 +140,7 @@ OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求
|
|
|
137
140
|
| `httpApi` / `httpPort` / `httpToken` / `httpPlay` | 删除;服务的网关取代本地语音接口。 |
|
|
138
141
|
| `singUrl` / `singToken`、`voice_sing`、`【唱】` | 删除;唱歌由服务的 `/v1/audio/sing` 与网关接入。 |
|
|
139
142
|
| `model` / `voice` / `format` / `timeoutMs` | 保留,含义不变。 |
|
|
140
|
-
| `emotionMode` / `defaultEmotion` / `defaultInstruct` / `speed` / `paralinguistic` |
|
|
143
|
+
| `emotionMode` / `defaultEmotion` / `defaultInstruct` / `speed` / `paralinguistic` | 移到「模型」页,按模型保存;旧全局值仍作为没有模型设置时的回落。 |
|
|
141
144
|
| 触发、过滤、输出配置 | 保留。 |
|
|
142
145
|
|
|
143
146
|
## 验证
|
|
@@ -149,7 +152,7 @@ pnpm smoke:player out.wav # 本机播放器,音量 0
|
|
|
149
152
|
pnpm smoke:chain http://127.0.0.1:9883 # 触发→过滤→经服务合成→tts.voice 全链路
|
|
150
153
|
```
|
|
151
154
|
|
|
152
|
-
装进 Cortico 后应看到:World 页多一张「Kmch AI · 语音」卡,服务灯、队列灯与 `service` / `model` / `voice`
|
|
155
|
+
装进 Cortico 后应看到:World 页多一张「Kmch AI · 语音」卡,服务灯、队列灯与 `service` / `model` / `voice` 徽标;「模型」tab 能选模型与音色,按模型调整声音与情绪,并按服务参数渲染字段、保存;服务在线时灯亮;让 bot 调 `voice_speak` 能听见;说「念给我听」会绕过概率;`playback: event` 时时间线出现 `tts.voice`。
|
|
153
156
|
|
|
154
157
|
## 许可
|
|
155
158
|
|
package/describe.json
CHANGED
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"key": "worlds.voice.envPrompt",
|
|
127
127
|
"title": "语音 · 环境提示词",
|
|
128
128
|
"role": "envPrompt",
|
|
129
|
-
"content": "语音由 Voice 提供:它只把文字变成声音,从对方的扬声器念出来,不负责把回复发到聊天渠道;可见的回复仍然走渠道自己的发送工具。\n\n自动朗读现在是 {{voice.auto}}(音色 {{voice.voice}},情绪 {{voice.emotion}},指令 {{voice.instruct}})。自动朗读开着时,普通回复不必调用语音工具;关着时,想出声就调用 voice_speak。\n\n需要出声的时候:\n- 对方明确要语音、要你念出来、要听你的声音时,调用 voice_speak,把要念的正文放进 text。它会绕过概率,必定出声。\n- 这一句需要特定情绪、语气、音色或语速时,调用 voice_speak 并填对应参数。想让同样的语气之后一直生效,调用 voice_style;它影响之后的自动朗读。\n- 某一句只用于别的渠道(例如语音条),不要在本机播放:voice_speak 带 silent: true,音频随回执交给渠道。\n- 对方让你安静、停下时调用 voice_hush。\n\n文本规则:\n- text 只放真正要念的正文,不要放动作描写、Markdown、代码、网址或解释。\n-
|
|
129
|
+
"content": "语音由 Voice 提供:它只把文字变成声音,从对方的扬声器念出来,不负责把回复发到聊天渠道;可见的回复仍然走渠道自己的发送工具。\n\n自动朗读现在是 {{voice.auto}}(音色 {{voice.voice}},情绪 {{voice.emotion}},指令 {{voice.instruct}})。自动朗读开着时,普通回复不必调用语音工具;关着时,想出声就调用 voice_speak。\n\n需要出声的时候:\n- 对方明确要语音、要你念出来、要听你的声音时,调用 voice_speak,把要念的正文放进 text。它会绕过概率,必定出声。\n- 这一句需要特定情绪、语气、音色或语速时,调用 voice_speak 并填对应参数。想让同样的语气之后一直生效,调用 voice_style;它影响之后的自动朗读。\n- 某一句只用于别的渠道(例如语音条),不要在本机播放:voice_speak 带 silent: true,音频随回执交给渠道。\n- 对方让你安静、停下时调用 voice_hush。\n\n文本规则:\n- text 只放真正要念的正文,不要放动作描写、Markdown、代码、网址或解释。\n- {{voice.modelPrompt}}\n- 不要把控制标记写进可见的回复正文;语音参数只放在工具调用里。\n"
|
|
130
130
|
}
|
|
131
131
|
]
|
|
132
132
|
}
|
package/dist/webui.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.vx-top{display:flex;flex-wrap:wrap;align-items:end;gap:10px}.vx-pick{display:flex;flex:1 1 160px;min-width:0;flex-direction:column;gap:4px}.vx-pick-label{font:var(--text-detail)/1.2 var(--sans);color:var(--ink-soft)}.vx-pick select.field{width:100%;box-sizing:border-box}.vx-top>.btn{margin-left:auto;align-self:end}.vx-params{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px 18px;margin-top:14px}.vx-params:empty{display:none}.vx-field{display:flex;min-width:0;flex-direction:column;gap:5px}.vx-head{display:flex;align-items:baseline;gap:7px}.vx-label{font:13px/1.3 var(--sans);color:var(--ink)}.vx-name{font:11px/1.3 var(--mono);color:var(--ink-soft)}.vx-field>.field,.vx-field>label.check{width:100%;box-sizing:border-box}.vx-desc{font:12px/1.4 var(--sans);color:var(--ink-soft)}.vx-params+.actions{margin-top:14px}.vx-prompt{margin:0;padding:10px 12px;max-height:260px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.55 var(--mono);color:var(--ink-soft);background:var(--sheet-2);border:1px solid var(--line);border-radius:8px}
|
package/dist/webui.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var B={zh:{title:"\u6A21\u578B",model:"\u6A21\u578B",voice:"\u97F3\u8272",refresh:"\u5237\u65B0",save:"\u4FDD\u5B58",saved:"\u5DF2\u4FDD\u5B58",loading:"\u8BFB\u53D6\u4E2D\u2026",promptTitle:"\u6A21\u578B\u63D0\u793A\u8BCD",styleTitle:"\u58F0\u97F3\u4E0E\u60C5\u7EEA",paramsTitle:"\u6A21\u578B\u53C2\u6570",emotionMode:"\u60C5\u7EEA\u63A7\u5236",defaultEmotion:"\u9ED8\u8BA4\u60C5\u7EEA",defaultInstruct:"\u9ED8\u8BA4\u8868\u6F14\u6307\u4EE4",speed:"\u8BED\u901F",paralinguistic:"\u526F\u8BED\u8A00\u6807\u7B7E",emotionModeOptions:[{value:"instruct",label:"\u81EA\u7136\u8BED\u8A00"},{value:"categorical",label:"\u60C5\u7EEA\u6807\u7B7E"},{value:"basic",label:"\u8BED\u901F"},{value:"tags",label:"\u6B63\u6587\u6807\u7B7E"},{value:"none",label:"\u4E0D\u53D1\u9001"}],paralinguisticOptions:[{value:"auto",label:"\u81EA\u52A8"},{value:"keep",label:"\u4FDD\u7559"},{value:"strip",label:"\u5220\u9664"}]},en:{title:"\u6A21\u578B",model:"Model",voice:"Voice",refresh:"Refresh",save:"Save",saved:"Saved",loading:"Loading\u2026",promptTitle:"Model prompt",styleTitle:"Voice and emotion",paramsTitle:"Model parameters",emotionMode:"Emotion control",defaultEmotion:"Default emotion",defaultInstruct:"Default direction",speed:"Speed",paralinguistic:"Paralinguistic tags",emotionModeOptions:[{value:"instruct",label:"Natural language"},{value:"categorical",label:"Emotion label"},{value:"basic",label:"Speed"},{value:"tags",label:"Inline tags"},{value:"none",label:"Send nothing"}],paralinguisticOptions:[{value:"auto",label:"Auto"},{value:"keep",label:"Keep"},{value:"strip",label:"Strip"}]}};function j(e){return e.toLowerCase().startsWith("zh")?B.zh:B.en}var D={template_name:"\u6A21\u677F",language:"\u8BED\u8A00",instruction_schema:"\u6307\u4EE4\u8BED\u8A00",pitch:"\u97F3\u9AD8",volume:"\u97F3\u91CF",guidance_scale:"\u5F15\u5BFC\u5F3A\u5EA6",sigma:"\u566A\u58F0\u5C3A\u5EA6",raw_text:"\u6574\u6BB5\u53D1\u9001",trim_silence:"\u4FEE\u526A\u9759\u97F3",emotion_alpha:"\u60C5\u7EEA\u5F3A\u5EA6",emotion_text:"\u60C5\u7EEA\u63CF\u8FF0",use_emotion_text:"\u4F7F\u7528\u6587\u5B57\u60C5\u7EEA",use_random_emotion:"\u968F\u673A\u60C5\u7EEA",duration_scale:"\u65F6\u957F\u7F29\u653E",interval_silence_ms:"\u5206\u6BB5\u9759\u97F3",do_sample:"\u91C7\u6837",top_p:"top_p",top_k:"top_k",temperature:"\u6E29\u5EA6",length_penalty:"\u957F\u5EA6\u60E9\u7F5A",num_beams:"beam \u6570",repetition_penalty:"\u91CD\u590D\u60E9\u7F5A",max_tokens:"\u6700\u5927 token",seed:"\u79CD\u5B50",text_chunk_size:"\u6587\u672C\u5206\u5757\u5927\u5C0F",text_chunk_mode:"\u5206\u5757\u6A21\u5F0F"};var Q={voice:"",emotionMode:"instruct",defaultEmotion:"",defaultInstruct:"",speed:1,paralinguistic:"auto"};function Z(e,t){return Object.prototype.hasOwnProperty.call(t,e.name)?t[e.name]:e.default}function O(e){return e==null?"":Array.isArray(e)?e.map(t=>String(t)).join(", "):String(e)}function $(e){return typeof e=="number"&&Number.isFinite(e)?String(e):typeof e=="string"&&e.trim()&&Number.isFinite(Number(e))?e.trim():""}function ee(e,t,l){let n=e.ui,i=n.h("div","vx-field"),f=n.h("div","vx-head"),p=D[t.name];p&&p!==t.name?f.append(n.h("span","vx-label",p),n.h("span","vx-name",t.name)):f.append(n.h("span","vx-label",t.name)),i.append(f);let m=Z(t,l),c;switch(t.type){case"bool":{let a=n.checkbox("",{checked:m===!0});i.append(a.el),c=()=>a.checked;break}case"enum":{let a=t.values??[],r=O(m),u=[{value:"",label:"\u2014"},...a.map(y=>({value:y,label:y}))];r&&!a.includes(r)&&u.push({value:r,label:r});let x=n.select({options:u,value:r});i.append(x),c=()=>x.value||void 0;break}case"int":case"float":{let a=n.input({type:"number",value:$(m)});t.min!==void 0&&(a.min=String(t.min)),t.max!==void 0&&(a.max=String(t.max)),a.step=t.type==="int"?"1":"any",i.append(a),c=()=>{let r=a.value.trim();if(!r)return;let u=Number(r);if(Number.isFinite(u))return t.type==="int"?Math.trunc(u):u};break}case"string_list":case"path_list":{let a=n.input({type:"text",value:O(m)});i.append(a),c=()=>{let r=a.value.split(/[,,]/).map(u=>u.trim()).filter(Boolean);return r.length>0?r:void 0};break}default:{let a=n.input({type:"text",value:O(m)});i.append(a),c=()=>a.value.trim()||void 0;break}}return t.description&&i.append(n.h("div","vx-desc",t.description)),{param:t,el:i,read:c}}function M(e,t,l){let n=e.ui.h("div","vx-field"),i=e.ui.h("div","vx-head");return i.append(e.ui.h("span","vx-label",t)),n.append(i,l),n}function te(e,t,l){let n=e.ui,i=n.h("div","vx-params"),f=n.select({options:t.emotionModeOptions.map(({value:r,label:u})=>({value:r,label:u})),value:l.emotionMode}),p=n.input({type:"text",value:l.defaultEmotion}),m=n.input({type:"text",value:l.defaultInstruct}),c=n.input({type:"number",value:String(l.speed)});c.min="0.5",c.max="2",c.step="0.05";let a=n.select({options:t.paralinguisticOptions.map(({value:r,label:u})=>({value:r,label:u})),value:l.paralinguistic});return i.append(M(e,t.emotionMode,f),M(e,t.defaultEmotion,p),M(e,t.defaultInstruct,m),M(e,t.speed,c),M(e,t.paralinguistic,a)),{el:i,read:()=>{let r=Number(c.value);return{voice:l.voice,emotionMode:f.value,defaultEmotion:p.value.trim(),defaultInstruct:m.value.trim(),speed:Number.isFinite(r)?Math.min(2,Math.max(.5,r)):l.speed,paralinguistic:a.value}}}}function ne(e){let t=j(e.language),l=e.ui,n=null,i="",f=[],p=!1,m=l.sheet({title:t.title}),c=l.h("div","vx-top"),a=l.select({onChange:()=>K(a.value)}),r=l.select({}),u=l.h("label","vx-pick");u.append(l.h("span","vx-pick-label",t.model),a);let x=l.h("label","vx-pick");x.append(l.h("span","vx-pick-label",t.voice),r);let y=l.button(t.refresh,{size:"sm",onClick:()=>{H("refresh")}});c.append(u,x,y);let Y=l.section(t.styleTitle),V=l.h("div","vx-section"),k=l.section(t.paramsTitle);k.hidden=!0;let S=l.h("div","vx-params"),L=l.button(t.save,{variant:"primary",size:"sm",onClick:()=>{J()}}),P=l.msgline(),F=l.actions();F.append(L),m.body.append(c,Y,V,k,S,F,P),e.root.append(m.el);let _=l.foldSheet("voice-model-prompt",{title:t.promptTitle,defaultOpen:!1}),I=l.h("pre","vx-prompt");_.body.append(I),e.root.append(_.el);let T=null;function g(o,s=o){let d=l.h("option");return d.value=o,d.textContent=s,d}function b(o,s){P.textContent=o,P.classList.toggle("bad",s)}function v(){let o=n!==null&&n.models.length>0;a.disabled=p||!o,r.disabled=p||!o||!i,L.disabled=p||!o||!i,y.disabled=p}function w(o){return n?.settings?.[o]}function z(o){return w(o)?.style??(n?.model===o?n.style:void 0)??Q}function A(){let s=n?.models.find(E=>E.id===i)?.voices??[],d=w(i)?.voice??(n?.model===i?n.voice:""),h=[...s];d&&!h.includes(d)&&h.unshift(d);let C=h.map(E=>g(E));d||C.unshift(g("","\u2014")),r.replaceChildren(...C.length>0?C:[g("","\u2014")]),r.value=d}function R(){T=te(e,t,z(i)),V.replaceChildren(T.el)}function W(){let o=n?.models.find(d=>d.id===i),s=w(i)?.options??(n?.model===i?n.options:{});f=[],S.replaceChildren();for(let d of o?.parameters??[]){let h=ee(e,d,s);f.push(h),S.append(h.el)}k.hidden=(o?.parameters?.length??0)===0}function N(){let o=n?.prompts?.[i]??(n?.model===i?n.prompt:"");I.textContent=o,_.note.textContent=i||"\u2014"}function K(o){i=o,A(),R(),W(),N(),v()}function U(o){n=o,i=o.model,X(),R(),W(),N(),v(),b(o.error??"",o.error!==void 0)}function X(){let o=n?.models??[],s=o.map(d=>g(d.id,d.label||d.id));!i&&o.length>0?s.unshift(g("","\u2014")):i&&!o.some(d=>d.id===i)&&s.unshift(g(i,i)),a.replaceChildren(...s),a.value=i,A()}function q(){let o={};for(let s of f){let d=s.read();d!==void 0&&(o[s.param.name]=d)}return o}function G(){return{...T?.read()??z(i),voice:r.value}}async function H(o){if(!p){p=!0,v(),b(t.loading,!1);try{let s=await e.invoke(o,[]);e.signal.aborted||U(s)}catch(s){e.signal.aborted||b(s instanceof Error?s.message:String(s),!0)}finally{p=!1,e.signal.aborted||v()}}}async function J(){if(p||!n)return;let o={model:i,voice:r.value,style:G(),options:q()};p=!0,v(),b("",!1);try{let s=await e.invoke("save",[o]);e.signal.aborted||(U(s),b(t.saved,!1))}catch(s){e.signal.aborted||b(s instanceof Error?s.message:String(s),!0)}finally{p=!1,e.signal.aborted||v()}}v(),H("state")}var ie={panels:{model:{mount:ne}}},de=ie;export{de as default};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kmchai/cortico-world-voice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Cortico World: voice tools, probabilistic reading, emotion and paralinguistic tags, text filtering and local playback; speech is synthesized by the Kmch AI service over HTTP",
|
|
6
6
|
"author": "kmchai",
|
|
@@ -49,23 +49,29 @@
|
|
|
49
49
|
"requires-extension",
|
|
50
50
|
"experimental"
|
|
51
51
|
],
|
|
52
|
-
"describe": "describe.json"
|
|
52
|
+
"describe": "describe.json",
|
|
53
|
+
"webUiClient": "dist/webui.js",
|
|
54
|
+
"webUiStyle": "dist/webui.css"
|
|
53
55
|
},
|
|
54
56
|
"files": [
|
|
55
57
|
"src",
|
|
58
|
+
"dist/webui.js",
|
|
59
|
+
"dist/webui.css",
|
|
56
60
|
"README.md",
|
|
57
61
|
"CHANGELOG.md",
|
|
58
62
|
"describe.json"
|
|
59
63
|
],
|
|
60
64
|
"scripts": {
|
|
61
65
|
"test": "vitest run",
|
|
62
|
-
"typecheck": "tsc --noEmit",
|
|
66
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.webui.json",
|
|
67
|
+
"build:webui": "node scripts/build-webui.mjs",
|
|
63
68
|
"smoke:player": "tsx scripts/smoke-player.ts",
|
|
64
69
|
"smoke:chain": "tsx scripts/smoke-chain.ts"
|
|
65
70
|
},
|
|
66
71
|
"devDependencies": {
|
|
67
72
|
"@types/node": "^22.10.0",
|
|
68
73
|
"cortico": "^0.2.12",
|
|
74
|
+
"esbuild": "^0.28.2",
|
|
69
75
|
"tsx": "^4.19.2",
|
|
70
76
|
"typescript": "^5.7.2",
|
|
71
77
|
"vitest": "^2.1.8"
|
package/src/ENV_PROMPT.md
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/** `worlds.voice` 的默认值、配置组与界面文案。 */
|
|
2
2
|
import type { ConfigGroup } from 'cortico/core/types.ts';
|
|
3
3
|
import { pick, type Language, type LanguageTable } from 'cortico/core/language.ts';
|
|
4
|
-
import type { VoiceConfigSection } from './types.ts';
|
|
4
|
+
import type { VoiceConfigSection, VoiceModelStyle } from './types.ts';
|
|
5
5
|
|
|
6
6
|
export const VOICE_ID = 'voice';
|
|
7
|
-
/**
|
|
8
|
-
export const
|
|
9
|
-
/** 配置里 `x-options` 用的动态模型表。 */
|
|
10
|
-
export const VOICE_MODELS_KIND = 'voice.models';
|
|
7
|
+
/** World 定义、WebUI 与配置页共用同一个显示名。 */
|
|
8
|
+
export const VOICE_LABEL = 'Kmch AI · 语音';
|
|
11
9
|
|
|
12
10
|
export const VOICE_DEFAULTS: VoiceConfigSection = {
|
|
13
11
|
enabled: false,
|
|
@@ -16,6 +14,8 @@ export const VOICE_DEFAULTS: VoiceConfigSection = {
|
|
|
16
14
|
serviceTokenSecret: '',
|
|
17
15
|
model: '',
|
|
18
16
|
voice: '',
|
|
17
|
+
modelOptions: {},
|
|
18
|
+
modelStyles: {},
|
|
19
19
|
format: 'wav',
|
|
20
20
|
timeoutMs: 180_000,
|
|
21
21
|
|
|
@@ -50,32 +50,49 @@ export const VOICE_DEFAULTS: VoiceConfigSection = {
|
|
|
50
50
|
shareAudio: false,
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 当前模型的朗读风格。模型页保存的值优先,缺省字段回落到旧的全局值,
|
|
55
|
+
* 这样升级前已有的配置仍然有效。
|
|
56
|
+
*/
|
|
57
|
+
export function modelStyle(cfg: VoiceConfigSection, model = cfg.model): VoiceModelStyle {
|
|
58
|
+
const saved = cfg.modelStyles?.[model] ?? {};
|
|
59
|
+
return {
|
|
60
|
+
voice: saved.voice ?? cfg.voice,
|
|
61
|
+
emotionMode: saved.emotionMode ?? cfg.emotionMode,
|
|
62
|
+
defaultEmotion: saved.defaultEmotion ?? cfg.defaultEmotion,
|
|
63
|
+
defaultInstruct: saved.defaultInstruct ?? cfg.defaultInstruct,
|
|
64
|
+
speed: saved.speed ?? cfg.speed,
|
|
65
|
+
paralinguistic: saved.paralinguistic ?? cfg.paralinguistic,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 把模型风格盖到配置上,给过滤、合成与提示词读取。 */
|
|
70
|
+
export function styledConfig(cfg: VoiceConfigSection, model = cfg.model): VoiceConfigSection {
|
|
71
|
+
return { ...cfg, ...modelStyle(cfg, model) };
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
const TEXT = {
|
|
54
75
|
zh: {
|
|
55
|
-
label: '语音',
|
|
76
|
+
label: 'Kmch AI · 语音',
|
|
56
77
|
envTitle: '语音 · 环境提示词',
|
|
57
78
|
lampService: '语音服务',
|
|
58
79
|
lampQueue: '朗读队列',
|
|
59
80
|
servicePage: '服务设置',
|
|
81
|
+
modelPanel: '模型',
|
|
82
|
+
modelSaveShape: '保存失败:参数格式不对。',
|
|
83
|
+
modelSaveModel: '保存失败:模型 id 不能为空。',
|
|
84
|
+
modelSaveVoice: '保存失败:音色必须是字符串。',
|
|
85
|
+
modelSaveStyle: '保存失败:朗读风格格式不对。',
|
|
86
|
+
modelSaveOptions: '保存失败:模型参数必须是对象。',
|
|
87
|
+
modelUnknownMethod: '未知的面板方法。',
|
|
60
88
|
connection: '连接',
|
|
61
89
|
connectionDesc: '语音服务的 HTTP 地址与令牌。',
|
|
62
90
|
serviceUrl: '服务地址',
|
|
63
91
|
serviceUrlDesc: 'Kmch AI · 服务 的网关地址。',
|
|
64
92
|
serviceTokenSecret: '服务令牌变量名',
|
|
65
93
|
serviceTokenSecretDesc: '部署 .env 里的变量名,留空表示服务不校验。',
|
|
66
|
-
model: '模型',
|
|
67
|
-
voice: '音色',
|
|
68
94
|
format: '音频格式',
|
|
69
95
|
timeoutMs: '超时(毫秒)',
|
|
70
|
-
style: '音色与情绪',
|
|
71
|
-
styleDesc: '',
|
|
72
|
-
emotionMode: '情绪控制',
|
|
73
|
-
emotionModeDesc: 'instruct 折成自然语言指令;categorical 只发情绪标签;basic 只发语速;tags 只用正文标签;none 不发。',
|
|
74
|
-
defaultEmotion: '默认情绪',
|
|
75
|
-
defaultInstruct: '默认表演指令',
|
|
76
|
-
speed: '语速',
|
|
77
|
-
paralinguistic: '副语言标签',
|
|
78
|
-
paralinguisticDesc: 'auto 保留,由服务与模型决定;strip 一律从朗读文本里删掉。',
|
|
79
96
|
triggers: '触发',
|
|
80
97
|
triggersDesc: '',
|
|
81
98
|
autoSpeak: '自动朗读',
|
|
@@ -111,30 +128,26 @@ const TEXT = {
|
|
|
111
128
|
shareAudio: '回执带音频附件',
|
|
112
129
|
},
|
|
113
130
|
en: {
|
|
114
|
-
label: '
|
|
131
|
+
label: 'Kmch AI · 语音',
|
|
115
132
|
envTitle: 'Voice · environment prompt',
|
|
116
133
|
lampService: 'Speech service',
|
|
117
134
|
lampQueue: 'Speech queue',
|
|
118
135
|
servicePage: 'Service settings',
|
|
136
|
+
modelPanel: '模型',
|
|
137
|
+
modelSaveShape: 'Save failed: invalid arguments.',
|
|
138
|
+
modelSaveModel: 'Save failed: the model id must not be empty.',
|
|
139
|
+
modelSaveVoice: 'Save failed: voice must be a string.',
|
|
140
|
+
modelSaveStyle: 'Save failed: invalid voice style.',
|
|
141
|
+
modelSaveOptions: 'Save failed: model options must be an object.',
|
|
142
|
+
modelUnknownMethod: 'Unknown panel method.',
|
|
119
143
|
connection: 'Connection',
|
|
120
144
|
connectionDesc: 'Where the speech service is and which token it expects.',
|
|
121
145
|
serviceUrl: 'Service URL',
|
|
122
146
|
serviceUrlDesc: 'Gateway address of Kmch AI · Service.',
|
|
123
147
|
serviceTokenSecret: 'Service token variable',
|
|
124
148
|
serviceTokenSecretDesc: 'Name in the deployment .env; empty means the service does not check.',
|
|
125
|
-
model: 'Model',
|
|
126
|
-
voice: 'Voice',
|
|
127
149
|
format: 'Audio format',
|
|
128
150
|
timeoutMs: 'Timeout (ms)',
|
|
129
|
-
style: 'Voice and emotion',
|
|
130
|
-
styleDesc: '',
|
|
131
|
-
emotionMode: 'Emotion control',
|
|
132
|
-
emotionModeDesc: 'instruct turns it into natural language; categorical sends a label; basic sends speed; tags uses inline tags only; none sends nothing.',
|
|
133
|
-
defaultEmotion: 'Default emotion',
|
|
134
|
-
defaultInstruct: 'Default direction',
|
|
135
|
-
speed: 'Speed',
|
|
136
|
-
paralinguistic: 'Paralinguistic tags',
|
|
137
|
-
paralinguisticDesc: 'auto keeps them and lets the service and model decide; strip removes them from the spoken text.',
|
|
138
151
|
triggers: 'Triggers',
|
|
139
152
|
triggersDesc: '',
|
|
140
153
|
autoSpeak: 'Automatic speech',
|
|
@@ -192,28 +205,11 @@ export function voiceConfigGroups(language: Language): ConfigGroup[] {
|
|
|
192
205
|
properties: {
|
|
193
206
|
[`${p}serviceUrl`]: { type: 'string', title: t.serviceUrl, description: t.serviceUrlDesc, 'x-hot': true },
|
|
194
207
|
[`${p}serviceTokenSecret`]: { type: 'string', title: t.serviceTokenSecret, description: t.serviceTokenSecretDesc, 'x-hot': true },
|
|
195
|
-
[`${p}model`]: { type: 'string', title: t.model, 'x-options': VOICE_MODELS_KIND, 'x-hot': true },
|
|
196
|
-
[`${p}voice`]: { type: 'string', title: t.voice, 'x-options': VOICE_OPTIONS_KIND, 'x-hot': true },
|
|
197
208
|
[`${p}format`]: { type: 'string', title: t.format, enum: ['wav', 'mp3'], 'x-hot': true },
|
|
198
209
|
[`${p}timeoutMs`]: { type: 'integer', title: t.timeoutMs, minimum: 1000, maximum: 600_000, 'x-hot': true },
|
|
199
210
|
},
|
|
200
211
|
},
|
|
201
212
|
},
|
|
202
|
-
{
|
|
203
|
-
id: 'world:voice-style',
|
|
204
|
-
owner: 'world:voice',
|
|
205
|
-
schema: {
|
|
206
|
-
type: 'object',
|
|
207
|
-
title: t.style,
|
|
208
|
-
properties: {
|
|
209
|
-
[`${p}emotionMode`]: { type: 'string', title: t.emotionMode, description: t.emotionModeDesc, enum: ['instruct', 'categorical', 'basic', 'tags', 'none'], 'x-hot': true },
|
|
210
|
-
[`${p}defaultEmotion`]: { type: 'string', title: t.defaultEmotion, 'x-hot': true },
|
|
211
|
-
[`${p}defaultInstruct`]: { type: 'string', title: t.defaultInstruct, 'x-hot': true },
|
|
212
|
-
[`${p}speed`]: { type: 'number', title: t.speed, minimum: 0.5, maximum: 2, multipleOf: 0.05, 'x-hot': true },
|
|
213
|
-
[`${p}paralinguistic`]: { type: 'string', title: t.paralinguistic, description: t.paralinguisticDesc, enum: ['auto', 'keep', 'strip'], 'x-hot': true },
|
|
214
|
-
},
|
|
215
|
-
},
|
|
216
|
-
},
|
|
217
213
|
{
|
|
218
214
|
id: 'world:voice-triggers',
|
|
219
215
|
owner: 'world:voice',
|
package/src/definition.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { describeTools, type WorldDefinition } from 'cortico/world.ts';
|
|
2
|
-
import { VOICE_DEFAULTS, VOICE_ID } from './config.ts';
|
|
3
|
-
import { currentOptions } from './options.ts';
|
|
2
|
+
import { VOICE_DEFAULTS, VOICE_ID, VOICE_LABEL } from './config.ts';
|
|
4
3
|
import { VOICE_TOOL_DECLS } from './tools.ts';
|
|
5
4
|
import type { VoiceConfigSection } from './types.ts';
|
|
6
5
|
import { VoiceWorld } from './world.ts';
|
|
7
6
|
|
|
8
7
|
export const VOICE: WorldDefinition<VoiceConfigSection> = {
|
|
9
8
|
id: VOICE_ID,
|
|
10
|
-
label:
|
|
11
|
-
defaults: () => ({ ...VOICE_DEFAULTS }),
|
|
12
|
-
configOptions: (kind) => currentOptions(kind),
|
|
9
|
+
label: VOICE_LABEL,
|
|
10
|
+
defaults: () => ({ ...VOICE_DEFAULTS, modelOptions: {}, modelStyles: {} }),
|
|
13
11
|
create: (ctx) => new VoiceWorld({
|
|
14
12
|
cfg: ctx.cfg,
|
|
15
13
|
timezone: ctx.timezone,
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型能力与提示词片段。
|
|
3
|
+
*
|
|
4
|
+
* 服务返回的模型 id 决定两件事:正文里能不能写副语言标签,以及环境提示词
|
|
5
|
+
* 该怎么描述文本与情绪。按 id 前缀匹配,大小写不敏感;空模型与未知模型按
|
|
6
|
+
* 通用 OpenAI 兼容模型处理。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface ModelInfo {
|
|
10
|
+
/** 模型 id 的前缀,小写。 */
|
|
11
|
+
readonly match: readonly string[];
|
|
12
|
+
/** 给人看的名字;未知模型为空。 */
|
|
13
|
+
readonly label: string;
|
|
14
|
+
/** 是否支持 `[Laughter]` 这类副语言标签。 */
|
|
15
|
+
readonly tags: boolean;
|
|
16
|
+
/** 环境提示词里的文本与情绪写法。 */
|
|
17
|
+
readonly prompt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MING3: ModelInfo = {
|
|
21
|
+
match: ['ming3', 'ming-3', 'ming_3'],
|
|
22
|
+
label: 'Ming 3.0',
|
|
23
|
+
tags: true,
|
|
24
|
+
prompt: '支持 [Laughter] [Sigh] [Breathing] [Uhm] [Question-ei] 等副语言标签;情绪可用 instruct/emotion.',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const INDEXTTS2: ModelInfo = {
|
|
28
|
+
match: ['index-tts2', 'index_tts2', 'index-tts2.5', 'index_tts2_5', '2.5'],
|
|
29
|
+
label: 'IndexTTS2',
|
|
30
|
+
tags: false,
|
|
31
|
+
prompt: '情绪用自然语言(emotion_text),不要写 [Laughter] 这类方括号标签;采样/时长参数在语音模型页配置,不要写进正文.',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const GENERIC: ModelInfo = {
|
|
35
|
+
match: [],
|
|
36
|
+
label: '',
|
|
37
|
+
tags: false,
|
|
38
|
+
prompt: '用 instructions 描述语气;不要用方括号标签,除非确认模型支持.',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const KNOWN: readonly ModelInfo[] = [MING3, INDEXTTS2];
|
|
42
|
+
|
|
43
|
+
function normalize(model: string): string {
|
|
44
|
+
return typeof model === 'string' ? model.trim().toLowerCase() : '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function profileOf(model: string): ModelInfo | null {
|
|
48
|
+
const id = normalize(model);
|
|
49
|
+
if (!id) return null;
|
|
50
|
+
return KNOWN.find((info) => info.match.some((prefix) => id.startsWith(prefix))) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 当前模型是否支持副语言标签;未知与空模型按通用模型处理,返回 false。 */
|
|
54
|
+
export function modelSupportsTags(model: string): boolean {
|
|
55
|
+
return profileOf(model)?.tags ?? GENERIC.tags;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 给人看的模型名;未知或空模型返回空串,由调用方退回模型 id。 */
|
|
59
|
+
export function modelLabel(model: string): string {
|
|
60
|
+
return profileOf(model)?.label ?? '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 环境提示词里的文本与情绪写法;空模型与未知模型用通用片段。 */
|
|
64
|
+
export function modelPrompt(model: string): string {
|
|
65
|
+
return (profileOf(model) ?? GENERIC).prompt;
|
|
66
|
+
}
|
package/src/panel.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「模型」面板的数据形状。服务端经由 `World.webUi().invoke` 构造,浏览器端只
|
|
3
|
+
* `import type` 这些声明;面板不接触服务地址与令牌。
|
|
4
|
+
*/
|
|
5
|
+
import type { VoiceModel, VoiceModelStyle } from './types.ts';
|
|
6
|
+
|
|
7
|
+
/** 一个模型在面板里的已保存状态。 */
|
|
8
|
+
export interface PanelModelSetting {
|
|
9
|
+
voice: string;
|
|
10
|
+
style: VoiceModelStyle;
|
|
11
|
+
options: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PanelModelState {
|
|
15
|
+
/** `worlds.voice.model`。 */
|
|
16
|
+
model: string;
|
|
17
|
+
/** 当前模型的音色。 */
|
|
18
|
+
voice: string;
|
|
19
|
+
/** 当前模型的环境提示词片段,来自 `models.ts`。 */
|
|
20
|
+
prompt: string;
|
|
21
|
+
/** 每个模型的环境提示词片段,切换模型时立即更新。 */
|
|
22
|
+
prompts: Record<string, string>;
|
|
23
|
+
/** 当前模型已保存的请求参数。 */
|
|
24
|
+
options: Record<string, unknown>;
|
|
25
|
+
/** 当前模型生效的朗读风格;没有专属设置时回落到全局值。 */
|
|
26
|
+
style: VoiceModelStyle;
|
|
27
|
+
/** 服务目录;服务不可达时是上一次可用的结果。 */
|
|
28
|
+
models: VoiceModel[];
|
|
29
|
+
/** 每个模型的音色、风格与请求参数;面板切模型时不用等保存。 */
|
|
30
|
+
settings: Record<string, PanelModelSetting>;
|
|
31
|
+
/** 最近一次读取服务目录失败的原因。 */
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `invoke('model','save', [save])` 的请求体。 */
|
|
36
|
+
export interface PanelModelSave {
|
|
37
|
+
model: string;
|
|
38
|
+
voice: string;
|
|
39
|
+
style?: Partial<VoiceModelStyle>;
|
|
40
|
+
options: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 只认普通对象:数组、null 与类对象都不算。 */
|
|
44
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
46
|
+
const proto = Object.getPrototypeOf(value);
|
|
47
|
+
return proto === Object.prototype || proto === null;
|
|
48
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* 令牌只从 secret() 现读,不写日志、不留副本。
|
|
6
6
|
*/
|
|
7
7
|
import type {
|
|
8
|
-
AudioFormat,
|
|
8
|
+
AudioFormat, ModelParameter, ModelParameterType, ServiceHealth, TtsAudio, TtsRequest,
|
|
9
|
+
VoiceConfigSection, VoiceModel, VoiceOption,
|
|
9
10
|
} from './types.ts';
|
|
10
11
|
|
|
11
12
|
const PROBE_TIMEOUT_MS = 8000;
|
|
@@ -37,6 +38,9 @@ export class ServiceClient implements SpeechService {
|
|
|
37
38
|
};
|
|
38
39
|
const model = cfg.model.trim();
|
|
39
40
|
if (model) body.model = model;
|
|
41
|
+
// 只带当前模型自己的参数;其他模型的配置不混进这次请求。
|
|
42
|
+
const options = cfg.modelOptions?.[model];
|
|
43
|
+
if (options && Object.keys(options).length > 0) body.options = options;
|
|
40
44
|
const voice = (req.voice ?? '').trim() || cfg.voice.trim();
|
|
41
45
|
if (voice) body.voice = voice;
|
|
42
46
|
const instruct = (req.instruct ?? '').trim();
|
|
@@ -96,7 +100,7 @@ export class ServiceClient implements SpeechService {
|
|
|
96
100
|
}
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
async models(): Promise<
|
|
103
|
+
async models(): Promise<VoiceModel[]> {
|
|
100
104
|
const payload = await this.request(
|
|
101
105
|
this.endpoint('/v1/models'),
|
|
102
106
|
{ method: 'GET', headers: this.headers() },
|
|
@@ -105,9 +109,9 @@ export class ServiceClient implements SpeechService {
|
|
|
105
109
|
(res) => this.readJson(res),
|
|
106
110
|
) as { data?: unknown } | unknown[] | null;
|
|
107
111
|
const raw = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : [];
|
|
108
|
-
return raw.flatMap((item):
|
|
109
|
-
const
|
|
110
|
-
return
|
|
112
|
+
return raw.flatMap((item): VoiceModel[] => {
|
|
113
|
+
const model = modelOf(item);
|
|
114
|
+
return model ? [model] : [];
|
|
111
115
|
});
|
|
112
116
|
}
|
|
113
117
|
|
|
@@ -234,6 +238,83 @@ function optionOf(item: unknown): { value: string; label: string } | null {
|
|
|
234
238
|
return { value, label };
|
|
235
239
|
}
|
|
236
240
|
|
|
241
|
+
const PARAMETER_TYPES = new Set<ModelParameterType>([
|
|
242
|
+
'string', 'enum', 'bool', 'int', 'float', 'string_list', 'path', 'path_list',
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
function firstString(...values: unknown[]): string {
|
|
246
|
+
for (const value of values) {
|
|
247
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
248
|
+
}
|
|
249
|
+
return '';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 目录里的音色/取值可能写成字符串,也可能写成对象;只留去重后的字符串。 */
|
|
253
|
+
function stringItems(value: unknown): string[] {
|
|
254
|
+
if (!Array.isArray(value)) return [];
|
|
255
|
+
const out: string[] = [];
|
|
256
|
+
for (const item of value) {
|
|
257
|
+
const text = typeof item === 'string'
|
|
258
|
+
? item.trim()
|
|
259
|
+
: item && typeof item === 'object' && !Array.isArray(item)
|
|
260
|
+
? firstString(
|
|
261
|
+
(item as Record<string, unknown>).value,
|
|
262
|
+
(item as Record<string, unknown>).id,
|
|
263
|
+
(item as Record<string, unknown>).name,
|
|
264
|
+
(item as Record<string, unknown>).label,
|
|
265
|
+
)
|
|
266
|
+
: '';
|
|
267
|
+
if (text && !out.includes(text)) out.push(text);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
273
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parameterOf(item: unknown): ModelParameter | null {
|
|
277
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
278
|
+
const o = item as Record<string, unknown>;
|
|
279
|
+
const name = firstString(o.name);
|
|
280
|
+
const type = firstString(o.type) as ModelParameterType;
|
|
281
|
+
if (!name || !PARAMETER_TYPES.has(type)) return null;
|
|
282
|
+
const description = firstString(o.description);
|
|
283
|
+
const values = stringItems(o.values);
|
|
284
|
+
const min = finiteNumber(o.min);
|
|
285
|
+
const max = finiteNumber(o.max);
|
|
286
|
+
return {
|
|
287
|
+
name,
|
|
288
|
+
type,
|
|
289
|
+
...(description ? { description } : {}),
|
|
290
|
+
...(values.length ? { values } : {}),
|
|
291
|
+
...(o.default !== undefined ? { default: o.default } : {}),
|
|
292
|
+
...(min !== undefined ? { min } : {}),
|
|
293
|
+
...(max !== undefined ? { max } : {}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 服务目录的一项:模型 id、显示名、音色与请求参数;不认识的字段不带走。 */
|
|
298
|
+
function modelOf(item: unknown): VoiceModel | null {
|
|
299
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
300
|
+
const o = item as Record<string, unknown>;
|
|
301
|
+
const id = firstString(o.id, o.value, o.name);
|
|
302
|
+
if (!id) return null;
|
|
303
|
+
const label = firstString(o.display_name, o.displayName, o.label);
|
|
304
|
+
const parameters = Array.isArray(o.parameters)
|
|
305
|
+
? o.parameters.flatMap((entry): ModelParameter[] => {
|
|
306
|
+
const parameter = parameterOf(entry);
|
|
307
|
+
return parameter ? [parameter] : [];
|
|
308
|
+
})
|
|
309
|
+
: [];
|
|
310
|
+
return {
|
|
311
|
+
id,
|
|
312
|
+
...(label ? { label } : {}),
|
|
313
|
+
voices: stringItems(o.voices),
|
|
314
|
+
...(parameters.length ? { parameters } : {}),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
237
318
|
/** 音频回应只接受 wav / mpeg;octet-stream 按请求的格式归一。 */
|
|
238
319
|
function normalizeMime(raw: string | null, format: AudioFormat): string | null {
|
|
239
320
|
const type = (raw ?? '').split(';')[0]!.trim().toLowerCase();
|
package/src/speech/tags.ts
CHANGED
|
@@ -57,10 +57,15 @@ export function stripUnknownTags(text: string): string {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* auto
|
|
60
|
+
* auto 只在当前模型支持标签时保留;keep 一律保留;strip 一律删掉。
|
|
61
61
|
*/
|
|
62
|
-
export function keepsParalinguistic(
|
|
63
|
-
|
|
62
|
+
export function keepsParalinguistic(
|
|
63
|
+
mode: 'auto' | 'keep' | 'strip',
|
|
64
|
+
tagsSupported: boolean,
|
|
65
|
+
): boolean {
|
|
66
|
+
if (mode === 'strip') return false;
|
|
67
|
+
if (mode === 'keep') return true;
|
|
68
|
+
return tagsSupported;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
const EMOTION_MARKERS: ReadonlyArray<RegExp> = [
|
package/src/speech/text.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** 把一段回复变成能念的正文:取 `<tts>`、去代码/网址/Markdown/动作标记/符号。 */
|
|
2
|
+
import { modelSupportsTags } from '../models.ts';
|
|
2
3
|
import type { VoiceConfigSection } from '../types.ts';
|
|
3
4
|
import {
|
|
4
5
|
canonicalizeTags,
|
|
@@ -86,7 +87,9 @@ export function filterSpeech(raw: string, cfg: VoiceConfigSection): FilteredSpee
|
|
|
86
87
|
|
|
87
88
|
text = canonicalizeTags(text);
|
|
88
89
|
text = stripUnknownTags(text);
|
|
89
|
-
if (!keepsParalinguistic(cfg.paralinguistic))
|
|
90
|
+
if (!keepsParalinguistic(cfg.paralinguistic, modelSupportsTags(cfg.model))) {
|
|
91
|
+
text = stripParalinguisticTags(text);
|
|
92
|
+
}
|
|
90
93
|
|
|
91
94
|
if (cfg.filterEmoji) text = text.replace(EMOJI_RE, '').replace(SYMBOL_RE, '');
|
|
92
95
|
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,17 @@ export type EmotionMode = 'instruct' | 'categorical' | 'basic' | 'tags' | 'none'
|
|
|
7
7
|
export type ParalinguisticMode = 'auto' | 'keep' | 'strip';
|
|
8
8
|
export type PlaybackMode = 'local' | 'event' | 'none';
|
|
9
9
|
|
|
10
|
+
/** 单个模型的朗读风格;缺省字段回落到 `worlds.voice` 上的全局同名值。 */
|
|
11
|
+
export interface VoiceModelStyle {
|
|
12
|
+
voice: string;
|
|
13
|
+
emotionMode: EmotionMode;
|
|
14
|
+
defaultEmotion: string;
|
|
15
|
+
defaultInstruct: string;
|
|
16
|
+
/** 语速倍率;1 为原速。 */
|
|
17
|
+
speed: number;
|
|
18
|
+
paralinguistic: ParalinguisticMode;
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
/** `config.json` 的 `worlds.voice` 段。 */
|
|
11
22
|
export interface VoiceConfigSection {
|
|
12
23
|
enabled: boolean;
|
|
@@ -17,11 +28,16 @@ export interface VoiceConfigSection {
|
|
|
17
28
|
/** 服务令牌的环境变量名;空表示服务不校验。 */
|
|
18
29
|
serviceTokenSecret: string;
|
|
19
30
|
model: string;
|
|
31
|
+
/** 旧的全局音色;只作为没有模型专属设置时的回落值。 */
|
|
20
32
|
voice: string;
|
|
33
|
+
/** 模型专属的 request 参数,按模型 id 分组;由「模型」面板写回,不进 ConfigGroup。 */
|
|
34
|
+
modelOptions: Record<string, Record<string, unknown>>;
|
|
35
|
+
/** 模型专属的朗读风格;由「模型」面板写回,不进 ConfigGroup。 */
|
|
36
|
+
modelStyles: Record<string, Partial<VoiceModelStyle>>;
|
|
21
37
|
format: AudioFormat;
|
|
22
38
|
timeoutMs: number;
|
|
23
39
|
|
|
24
|
-
//
|
|
40
|
+
// 朗读风格的全局回落值(旧配置仍可用;界面上已移到模型页)
|
|
25
41
|
emotionMode: EmotionMode;
|
|
26
42
|
defaultEmotion: string;
|
|
27
43
|
defaultInstruct: string;
|
|
@@ -90,7 +106,36 @@ export interface SelectOption {
|
|
|
90
106
|
}
|
|
91
107
|
|
|
92
108
|
export type VoiceOption = SelectOption;
|
|
93
|
-
|
|
109
|
+
|
|
110
|
+
/** 服务 `GET /v1/models` 里 `parameters[].type` 的取值。 */
|
|
111
|
+
export type ModelParameterType =
|
|
112
|
+
| 'string'
|
|
113
|
+
| 'enum'
|
|
114
|
+
| 'bool'
|
|
115
|
+
| 'int'
|
|
116
|
+
| 'float'
|
|
117
|
+
| 'string_list'
|
|
118
|
+
| 'path'
|
|
119
|
+
| 'path_list';
|
|
120
|
+
|
|
121
|
+
/** 模型请求参数的元数据;服务按模型给出,面板据此渲染字段。 */
|
|
122
|
+
export interface ModelParameter {
|
|
123
|
+
name: string;
|
|
124
|
+
type: ModelParameterType;
|
|
125
|
+
description?: string;
|
|
126
|
+
values?: string[];
|
|
127
|
+
default?: unknown;
|
|
128
|
+
min?: number;
|
|
129
|
+
max?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 服务目录里的一项模型;音色与请求参数元数据随模型走。 */
|
|
133
|
+
export interface VoiceModel {
|
|
134
|
+
id: string;
|
|
135
|
+
label?: string;
|
|
136
|
+
voices: string[];
|
|
137
|
+
parameters?: ModelParameter[];
|
|
138
|
+
}
|
|
94
139
|
|
|
95
140
|
/** `GET /health` 的摘要;`ok` 只表示网关有回应,`ready` 表示至少有一个服务能合成。 */
|
|
96
141
|
export interface ServiceHealth {
|
package/src/world.ts
CHANGED
|
@@ -14,11 +14,12 @@ import type {
|
|
|
14
14
|
Logger, ToolDef, ToolOutcome, World, WorldHost, WorldLamp, WorldWebUiDecl,
|
|
15
15
|
} from 'cortico/core/types.ts';
|
|
16
16
|
import { nullLogger, nowIso } from 'cortico/core/util.ts';
|
|
17
|
-
import {
|
|
17
|
+
import { modelStyle, styledConfig, VOICE_DEFAULTS, VOICE_ID, voiceConfigGroups, voiceText } from './config.ts';
|
|
18
18
|
import type { AudioPlayer } from './audio/player.ts';
|
|
19
19
|
import { createPlayer } from './audio/player.ts';
|
|
20
20
|
import { SpeechQueue, type SpeechJob } from './audio/queue.ts';
|
|
21
|
-
import {
|
|
21
|
+
import { modelPrompt } from './models.ts';
|
|
22
|
+
import { isPlainObject, type PanelModelSetting, type PanelModelState } from './panel.ts';
|
|
22
23
|
import { ServiceClient, type SpeechService } from './service.ts';
|
|
23
24
|
import { splitSpeech, truncateSpeech } from './speech/chunk.ts';
|
|
24
25
|
import { buildInstruct, resolveStyle } from './speech/style.ts';
|
|
@@ -26,14 +27,14 @@ import { filterSpeech } from './speech/text.ts';
|
|
|
26
27
|
import { EventPoller, eventTypes, hasKeyword, latestUserText, splitList, VoiceTap } from './triggers.ts';
|
|
27
28
|
import { VOICE_TOOL_DECLS } from './tools.ts';
|
|
28
29
|
import type {
|
|
29
|
-
|
|
30
|
+
EmotionMode, ParalinguisticMode, ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection,
|
|
31
|
+
VoiceModel, VoiceModelStyle,
|
|
30
32
|
} from './types.ts';
|
|
31
33
|
|
|
32
34
|
const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
|
|
33
35
|
const DEDUPE_WINDOW_MS = 5000;
|
|
34
36
|
const HEALTH_INTERVAL_MS = 15_000;
|
|
35
37
|
const HEALTH_TTL_MS = 15_000;
|
|
36
|
-
const CATALOG_TTL_MS = 10 * 60_000;
|
|
37
38
|
|
|
38
39
|
export interface VoiceWorldDeps {
|
|
39
40
|
fetch: typeof fetch;
|
|
@@ -130,18 +131,11 @@ export class VoiceWorld implements World {
|
|
|
130
131
|
private tempSeq = 0;
|
|
131
132
|
private serviceState: ServiceState | null = null;
|
|
132
133
|
private probing = false;
|
|
133
|
-
private
|
|
134
|
-
private
|
|
135
|
-
private
|
|
136
|
-
private voicesLoading = false;
|
|
137
|
-
private models: ModelOption[] = [];
|
|
138
|
-
private modelsAt = 0;
|
|
139
|
-
private modelsSignature = '';
|
|
140
|
-
private modelsLoading = false;
|
|
134
|
+
private catalog: VoiceModel[] | null = null;
|
|
135
|
+
private catalogError: string | undefined;
|
|
136
|
+
private catalogLoad: Promise<void> | null = null;
|
|
141
137
|
private readonly lastSpoken = new Map<string, number>();
|
|
142
138
|
private lastFailureAt = 0;
|
|
143
|
-
private readonly modelOptions = (): readonly ModelOption[] => this.models;
|
|
144
|
-
private readonly voiceOptions = (): readonly VoiceOption[] => this.voices;
|
|
145
139
|
|
|
146
140
|
constructor(private readonly opts: VoiceWorldOptions) {
|
|
147
141
|
this.log = opts.log ?? nullLogger();
|
|
@@ -149,7 +143,7 @@ export class VoiceWorld implements World {
|
|
|
149
143
|
this.service = this.deps.service ?? new ServiceClient({
|
|
150
144
|
fetch: this.deps.fetch,
|
|
151
145
|
secret: (name) => this.opts.secret(name),
|
|
152
|
-
cfg: () => this.
|
|
146
|
+
cfg: () => this.styled,
|
|
153
147
|
});
|
|
154
148
|
this.player = this.deps.createPlayer(this.log);
|
|
155
149
|
this.queue = new SpeechQueue({
|
|
@@ -173,13 +167,19 @@ export class VoiceWorld implements World {
|
|
|
173
167
|
return this.opts.cfg;
|
|
174
168
|
}
|
|
175
169
|
|
|
170
|
+
/** 当前模型的配置视图:模型专属的朗读风格盖在全局值上。 */
|
|
171
|
+
private get styled(): VoiceConfigSection {
|
|
172
|
+
return styledConfig(this.cfg, this.cfg.model);
|
|
173
|
+
}
|
|
174
|
+
|
|
176
175
|
envPromptVars(): Record<string, string> {
|
|
177
|
-
const cfg = this.
|
|
176
|
+
const cfg = this.styled;
|
|
178
177
|
return {
|
|
179
|
-
'voice.auto': cfg.autoSpeak ? `${Math.round(cfg.autoProbability * 100)}%` : 'off',
|
|
178
|
+
'voice.auto': this.cfg.autoSpeak ? `${Math.round(this.cfg.autoProbability * 100)}%` : 'off',
|
|
180
179
|
'voice.voice': cfg.voice || 'default',
|
|
181
180
|
'voice.emotion': cfg.defaultEmotion || 'neutral',
|
|
182
181
|
'voice.instruct': cfg.defaultInstruct || 'none',
|
|
182
|
+
'voice.modelPrompt': modelPrompt(this.cfg.model),
|
|
183
183
|
};
|
|
184
184
|
}
|
|
185
185
|
|
|
@@ -202,7 +202,7 @@ export class VoiceWorld implements World {
|
|
|
202
202
|
webUi(language: Language = 'zh'): WorldWebUiDecl {
|
|
203
203
|
const t = voiceText(language);
|
|
204
204
|
this.maybeRefresh();
|
|
205
|
-
const cfg = this.
|
|
205
|
+
const cfg = this.styled;
|
|
206
206
|
const stats = this.queue.stats();
|
|
207
207
|
const serviceLamp: WorldLamp = this.serviceState === null
|
|
208
208
|
? { label: t.lampService, state: this.started ? 'loading' : 'offline' }
|
|
@@ -224,8 +224,22 @@ export class VoiceWorld implements World {
|
|
|
224
224
|
{ label: 'model', value: cfg.model || '—', tone: 'plain' },
|
|
225
225
|
{ label: 'voice', value: cfg.voice || '—', tone: 'plain' },
|
|
226
226
|
],
|
|
227
|
+
panels: [{ id: 'model', title: t.modelPanel, getMethods: ['state'] }],
|
|
227
228
|
config: voiceConfigGroups(language),
|
|
228
229
|
links: [{ label: t.servicePage, href: '#/provider/world:service' }],
|
|
230
|
+
invoke: async (panel, method, args) => {
|
|
231
|
+
if (panel !== 'model') throw new Error(t.modelUnknownMethod);
|
|
232
|
+
switch (method) {
|
|
233
|
+
case 'state':
|
|
234
|
+
return this.modelPanelState();
|
|
235
|
+
case 'refresh':
|
|
236
|
+
return this.refreshModelPanel();
|
|
237
|
+
case 'save':
|
|
238
|
+
return this.saveModelPanel(language, args);
|
|
239
|
+
default:
|
|
240
|
+
throw new Error(t.modelUnknownMethod);
|
|
241
|
+
}
|
|
242
|
+
},
|
|
229
243
|
promptDocs: [{
|
|
230
244
|
key: 'worlds.voice.envPrompt',
|
|
231
245
|
title: t.envTitle,
|
|
@@ -237,11 +251,177 @@ export class VoiceWorld implements World {
|
|
|
237
251
|
{ name: 'voice.voice', description: 'Current voice id.' },
|
|
238
252
|
{ name: 'voice.emotion', description: 'Standing emotion.' },
|
|
239
253
|
{ name: 'voice.instruct', description: 'Standing direction.' },
|
|
254
|
+
{ name: 'voice.modelPrompt', description: 'How to write text and emotion for the current model.' },
|
|
240
255
|
],
|
|
241
256
|
}],
|
|
242
257
|
};
|
|
243
258
|
}
|
|
244
259
|
|
|
260
|
+
// ———————— 模型面板 ————————
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 第一次读面板时拉一次目录;之后直接回缓存。刷新失败保留上一次可用的目录,
|
|
264
|
+
* 只把错误一起交回面板。
|
|
265
|
+
*/
|
|
266
|
+
private async modelPanelState(): Promise<PanelModelState> {
|
|
267
|
+
if (!this.catalog) await this.loadCatalog(this.cfg.model);
|
|
268
|
+
return this.panelSnapshot();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async refreshModelPanel(): Promise<PanelModelState> {
|
|
272
|
+
if (this.catalogLoad) await this.catalogLoad;
|
|
273
|
+
await this.loadCatalog(this.cfg.model);
|
|
274
|
+
return this.panelSnapshot();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private panelSnapshot(): PanelModelState {
|
|
278
|
+
const cfg = this.cfg;
|
|
279
|
+
const settings = this.panelSettings();
|
|
280
|
+
const current = settings[cfg.model];
|
|
281
|
+
const style = current?.style ?? modelStyle(cfg, cfg.model);
|
|
282
|
+
return {
|
|
283
|
+
model: cfg.model,
|
|
284
|
+
voice: style.voice,
|
|
285
|
+
prompt: modelPrompt(cfg.model),
|
|
286
|
+
prompts: Object.fromEntries(Object.keys(settings).map((id) => [id, modelPrompt(id)])),
|
|
287
|
+
options: current?.options ?? {},
|
|
288
|
+
style,
|
|
289
|
+
models: this.catalog ?? [],
|
|
290
|
+
settings,
|
|
291
|
+
...(this.catalogError ? { error: this.catalogError } : {}),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 目录里每个模型的音色、风格与参数;当前模型与已保存过设置的模型也各带一条。 */
|
|
296
|
+
private panelSettings(): Record<string, PanelModelSetting> {
|
|
297
|
+
const ids = new Set((this.catalog ?? []).map((model) => model.id));
|
|
298
|
+
if (this.cfg.model.trim()) ids.add(this.cfg.model.trim());
|
|
299
|
+
for (const id of Object.keys(this.cfg.modelStyles ?? {})) if (id.trim()) ids.add(id);
|
|
300
|
+
for (const id of Object.keys(this.cfg.modelOptions ?? {})) if (id.trim()) ids.add(id);
|
|
301
|
+
const out: Record<string, PanelModelSetting> = {};
|
|
302
|
+
for (const id of ids) {
|
|
303
|
+
const style = modelStyle(this.cfg, id);
|
|
304
|
+
const saved = this.cfg.modelOptions?.[id];
|
|
305
|
+
out[id] = {
|
|
306
|
+
voice: style.voice,
|
|
307
|
+
style,
|
|
308
|
+
options: isPlainObject(saved) ? { ...saved } : {},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private loadCatalog(selected: string): Promise<void> {
|
|
315
|
+
if (this.catalogLoad) return this.catalogLoad;
|
|
316
|
+
this.catalogLoad = this.fetchCatalog(selected).finally(() => {
|
|
317
|
+
this.catalogLoad = null;
|
|
318
|
+
});
|
|
319
|
+
return this.catalogLoad;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 先取模型表,再尽力为当前模型补一次更细的音色表。模型表是权威来源,已经带
|
|
324
|
+
* 音色与参数;补音色失败只损失一次补全,不能让整份目录跟着作废。
|
|
325
|
+
*/
|
|
326
|
+
private async fetchCatalog(selected: string): Promise<void> {
|
|
327
|
+
let models: VoiceModel[];
|
|
328
|
+
try {
|
|
329
|
+
models = await this.service.models();
|
|
330
|
+
} catch (error) {
|
|
331
|
+
this.catalogError = this.catalogFailure(error);
|
|
332
|
+
this.markService({ ok: false, detail: this.catalogError });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const id = selected.trim() || this.cfg.model.trim() || models[0]?.id || '';
|
|
337
|
+
let catalog = models;
|
|
338
|
+
if (id) {
|
|
339
|
+
try {
|
|
340
|
+
const voices = await this.service.voices(id);
|
|
341
|
+
const names = [...new Set(voices.map((voice) => voice.value.trim()).filter(Boolean))];
|
|
342
|
+
if (names.length > 0) {
|
|
343
|
+
catalog = models.map((model) => (model.id === id ? { ...model, voices: names } : model));
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
this.log.debug('音色表读取失败,保留模型表里的音色', {
|
|
347
|
+
err: error instanceof Error ? error.message : String(error),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this.catalog = catalog;
|
|
353
|
+
this.catalogError = undefined;
|
|
354
|
+
this.markService({ ok: true });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 错误要回面板,但不能把配置里的服务地址(可能带令牌)一起带出去。 */
|
|
358
|
+
private catalogFailure(error: unknown): string {
|
|
359
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
360
|
+
const url = this.cfg.serviceUrl.trim();
|
|
361
|
+
return url && message.includes(url) ? message.split(url).join('[service]') : message;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private saveModelPanel(language: Language, args: unknown[]): PanelModelState {
|
|
365
|
+
const t = voiceText(language);
|
|
366
|
+
const raw = args[0];
|
|
367
|
+
if (!isPlainObject(raw)) throw new Error(t.modelSaveShape);
|
|
368
|
+
const model = typeof raw.model === 'string' ? raw.model.trim() : '';
|
|
369
|
+
if (!model) throw new Error(t.modelSaveModel);
|
|
370
|
+
if (typeof raw.voice !== 'string') throw new Error(t.modelSaveVoice);
|
|
371
|
+
if (raw.style !== undefined && !isPlainObject(raw.style)) throw new Error(t.modelSaveStyle);
|
|
372
|
+
if (!isPlainObject(raw.options)) throw new Error(t.modelSaveOptions);
|
|
373
|
+
|
|
374
|
+
const style = this.readModelStyle(raw.style, model, raw.voice.trim(), t.modelSaveStyle);
|
|
375
|
+
const nextStyles: VoiceConfigSection['modelStyles'] = { ...(this.cfg.modelStyles ?? {}) };
|
|
376
|
+
nextStyles[model] = style;
|
|
377
|
+
|
|
378
|
+
const nextOptions: VoiceConfigSection['modelOptions'] = { ...(this.cfg.modelOptions ?? {}) };
|
|
379
|
+
// 空对象表示删掉这个模型的条目,回到服务的默认参数。
|
|
380
|
+
if (Object.keys(raw.options).length === 0) delete nextOptions[model];
|
|
381
|
+
else nextOptions[model] = { ...raw.options };
|
|
382
|
+
// 配置层对对象做深合并,删掉的键不会随 patch 传播;先让活引用指向新对象再写盘。
|
|
383
|
+
this.cfg.modelStyles = nextStyles;
|
|
384
|
+
this.cfg.modelOptions = nextOptions;
|
|
385
|
+
this.opts.persist({ model, modelStyles: nextStyles, modelOptions: nextOptions });
|
|
386
|
+
return this.panelSnapshot();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** 面板传来的风格字段逐个校验;没给的字段沿用这个模型当前生效的值。 */
|
|
390
|
+
private readModelStyle(
|
|
391
|
+
raw: Record<string, unknown> | undefined,
|
|
392
|
+
model: string,
|
|
393
|
+
voice: string,
|
|
394
|
+
shapeError: string,
|
|
395
|
+
): VoiceModelStyle {
|
|
396
|
+
const current = modelStyle(this.cfg, model);
|
|
397
|
+
if (!raw) return { ...current, voice };
|
|
398
|
+
|
|
399
|
+
const emotionModes: readonly EmotionMode[] = ['instruct', 'categorical', 'basic', 'tags', 'none'];
|
|
400
|
+
const paralinguisticModes: readonly ParalinguisticMode[] = ['auto', 'keep', 'strip'];
|
|
401
|
+
const emotionMode = raw.emotionMode ?? current.emotionMode;
|
|
402
|
+
if (typeof emotionMode !== 'string' || !emotionModes.includes(emotionMode as EmotionMode)) {
|
|
403
|
+
throw new Error(shapeError);
|
|
404
|
+
}
|
|
405
|
+
const paralinguistic = raw.paralinguistic ?? current.paralinguistic;
|
|
406
|
+
if (typeof paralinguistic !== 'string' || !paralinguisticModes.includes(paralinguistic as ParalinguisticMode)) {
|
|
407
|
+
throw new Error(shapeError);
|
|
408
|
+
}
|
|
409
|
+
const defaultEmotion = raw.defaultEmotion ?? current.defaultEmotion;
|
|
410
|
+
if (typeof defaultEmotion !== 'string') throw new Error(shapeError);
|
|
411
|
+
const defaultInstruct = raw.defaultInstruct ?? current.defaultInstruct;
|
|
412
|
+
if (typeof defaultInstruct !== 'string') throw new Error(shapeError);
|
|
413
|
+
const rawSpeed = raw.speed ?? current.speed;
|
|
414
|
+
if (typeof rawSpeed !== 'number' || !Number.isFinite(rawSpeed)) throw new Error(shapeError);
|
|
415
|
+
return {
|
|
416
|
+
voice,
|
|
417
|
+
emotionMode: emotionMode as EmotionMode,
|
|
418
|
+
defaultEmotion: defaultEmotion.trim().slice(0, 40),
|
|
419
|
+
defaultInstruct: defaultInstruct.trim().slice(0, 500),
|
|
420
|
+
speed: Math.min(2, Math.max(0.5, rawSpeed)),
|
|
421
|
+
paralinguistic: paralinguistic as ParalinguisticMode,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
245
425
|
outputTap() {
|
|
246
426
|
const cfg = this.cfg;
|
|
247
427
|
if (!cfg.autoSpeak) return undefined;
|
|
@@ -252,7 +432,6 @@ export class VoiceWorld implements World {
|
|
|
252
432
|
async start(host: WorldHost): Promise<void> {
|
|
253
433
|
this.host = host;
|
|
254
434
|
this.started = true;
|
|
255
|
-
this.registerCatalogOptions();
|
|
256
435
|
this.log.info('语音 World 启动', {
|
|
257
436
|
service: serviceHost(this.cfg.serviceUrl),
|
|
258
437
|
playback: this.cfg.playback,
|
|
@@ -271,7 +450,6 @@ export class VoiceWorld implements World {
|
|
|
271
450
|
|
|
272
451
|
async stop(): Promise<void> {
|
|
273
452
|
this.started = false;
|
|
274
|
-
this.unregisterCatalogOptions();
|
|
275
453
|
if (this.healthTimer) clearInterval(this.healthTimer);
|
|
276
454
|
this.healthTimer = null;
|
|
277
455
|
this.poller?.stop();
|
|
@@ -302,7 +480,7 @@ export class VoiceWorld implements World {
|
|
|
302
480
|
const cfg = this.cfg;
|
|
303
481
|
if (!cfg.autoSpeak) return;
|
|
304
482
|
|
|
305
|
-
const filtered = filterSpeech(raw,
|
|
483
|
+
const filtered = filterSpeech(raw, this.styled);
|
|
306
484
|
if (!filtered.text) return;
|
|
307
485
|
|
|
308
486
|
const userText = cfg.explicitTrigger
|
|
@@ -343,7 +521,7 @@ export class VoiceWorld implements World {
|
|
|
343
521
|
// ———————— 工具 ————————
|
|
344
522
|
|
|
345
523
|
private async handleSpeak(args: Record<string, unknown>): Promise<ToolOutcome> {
|
|
346
|
-
const filtered = filterSpeech(asString(args.text), this.
|
|
524
|
+
const filtered = filterSpeech(asString(args.text), this.styled);
|
|
347
525
|
if (!filtered.text) return { text: 'voice_speak failed: there is nothing to say.', failed: true };
|
|
348
526
|
const silent = args.silent === true || this.cfg.playback === 'none';
|
|
349
527
|
const share = this.cfg.shareAudio || silent;
|
|
@@ -394,27 +572,47 @@ export class VoiceWorld implements World {
|
|
|
394
572
|
|
|
395
573
|
private async handleStyle(args: Record<string, unknown>): Promise<ToolOutcome> {
|
|
396
574
|
const has = (key: string): boolean => Object.prototype.hasOwnProperty.call(args, key);
|
|
397
|
-
const
|
|
398
|
-
if (
|
|
399
|
-
|
|
400
|
-
|
|
575
|
+
const model = this.cfg.model.trim();
|
|
576
|
+
if (!model) {
|
|
577
|
+
// 还没选模型时写到全局回落值;模型页仍可之后覆盖。
|
|
578
|
+
const patch: Partial<VoiceConfigSection> = {};
|
|
579
|
+
if (has('emotion')) patch.defaultEmotion = asString(args.emotion).slice(0, 40);
|
|
580
|
+
if (has('instruct')) patch.defaultInstruct = asString(args.instruct).slice(0, 500);
|
|
581
|
+
if (has('voice')) patch.voice = asString(args.voice).slice(0, 200);
|
|
582
|
+
if (has('speed')) {
|
|
583
|
+
const speed = asNumber(args.speed);
|
|
584
|
+
if (speed !== undefined) patch.speed = Math.min(2, Math.max(0.5, speed));
|
|
585
|
+
}
|
|
586
|
+
if (Object.keys(patch).length > 0) {
|
|
587
|
+
this.opts.persist(patch);
|
|
588
|
+
return { text: `Voice style updated. ${this.styleSummary()}` };
|
|
589
|
+
}
|
|
590
|
+
return { text: this.styleSummary() };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const current = modelStyle(this.cfg, model);
|
|
594
|
+
const next: VoiceModelStyle = { ...current };
|
|
595
|
+
if (has('emotion')) next.defaultEmotion = asString(args.emotion).slice(0, 40);
|
|
596
|
+
if (has('instruct')) next.defaultInstruct = asString(args.instruct).slice(0, 500);
|
|
597
|
+
if (has('voice')) next.voice = asString(args.voice).slice(0, 200);
|
|
401
598
|
if (has('speed')) {
|
|
402
599
|
const speed = asNumber(args.speed);
|
|
403
|
-
if (speed !== undefined)
|
|
600
|
+
if (speed !== undefined) next.speed = Math.min(2, Math.max(0.5, speed));
|
|
404
601
|
}
|
|
405
|
-
if (Object.keys(
|
|
406
|
-
this.
|
|
407
|
-
|
|
602
|
+
if (Object.keys(next).some((key) => next[key as keyof VoiceModelStyle] !== current[key as keyof VoiceModelStyle])) {
|
|
603
|
+
const modelStyles: VoiceConfigSection['modelStyles'] = { ...(this.cfg.modelStyles ?? {}), [model]: next };
|
|
604
|
+
this.cfg.modelStyles = modelStyles;
|
|
605
|
+
this.opts.persist({ modelStyles });
|
|
408
606
|
return { text: `Voice style updated. ${this.styleSummary()}` };
|
|
409
607
|
}
|
|
410
608
|
return { text: this.styleSummary() };
|
|
411
609
|
}
|
|
412
610
|
|
|
413
611
|
private styleSummary(): string {
|
|
414
|
-
const cfg = this.
|
|
612
|
+
const cfg = this.styled;
|
|
415
613
|
return `Voice: ${cfg.voice || 'default'}; emotion: ${cfg.defaultEmotion || 'neutral'}; `
|
|
416
614
|
+ `direction: ${cfg.defaultInstruct || 'none'}; speed: ${cfg.speed}; `
|
|
417
|
-
+ `automatic: ${cfg.autoSpeak ? `${Math.round(cfg.autoProbability * 100)}%` : 'off'}.`;
|
|
615
|
+
+ `automatic: ${this.cfg.autoSpeak ? `${Math.round(this.cfg.autoProbability * 100)}%` : 'off'}.`;
|
|
418
616
|
}
|
|
419
617
|
|
|
420
618
|
private spokenOutcome(audio: TtsAudio | undefined, seconds: number, silent: boolean): ToolOutcome {
|
|
@@ -448,7 +646,7 @@ export class VoiceWorld implements World {
|
|
|
448
646
|
style: { emotion?: string; instruct?: string; voice?: string; speed?: number },
|
|
449
647
|
signal: AbortSignal,
|
|
450
648
|
): Promise<TtsAudio> {
|
|
451
|
-
const cfg = this.
|
|
649
|
+
const cfg = this.styled;
|
|
452
650
|
const resolved = resolveStyle(cfg, style);
|
|
453
651
|
const request: TtsRequest = {
|
|
454
652
|
text,
|
|
@@ -494,7 +692,7 @@ export class VoiceWorld implements World {
|
|
|
494
692
|
origin: 'internal',
|
|
495
693
|
senderKey: this.id,
|
|
496
694
|
text: `Spoken line: ${chunk.slice(0, 100)}`,
|
|
497
|
-
meta: { source: job.source, voice: this.
|
|
695
|
+
meta: { source: job.source, voice: this.styled.voice, emotion: this.styled.defaultEmotion },
|
|
498
696
|
blobs: [{
|
|
499
697
|
bytes: audio.bytes,
|
|
500
698
|
mime: audio.mime,
|
|
@@ -527,7 +725,7 @@ export class VoiceWorld implements World {
|
|
|
527
725
|
} catch { /* 只归档,失败就只留日志 */ }
|
|
528
726
|
}
|
|
529
727
|
|
|
530
|
-
// ————————
|
|
728
|
+
// ———————— 状态 ————————
|
|
531
729
|
|
|
532
730
|
private markService(state: Pick<ServiceState, 'ok' | 'detail'>): void {
|
|
533
731
|
const previous = this.serviceState;
|
|
@@ -540,8 +738,6 @@ export class VoiceWorld implements World {
|
|
|
540
738
|
|
|
541
739
|
private async probe(): Promise<void> {
|
|
542
740
|
if (this.probing) return;
|
|
543
|
-
// 探活会刷新目录;每次探活都重新登记,停用后重新启用的实例也能接上。
|
|
544
|
-
this.registerCatalogOptions();
|
|
545
741
|
this.probing = true;
|
|
546
742
|
try {
|
|
547
743
|
const health: ServiceHealth = await this.service.health();
|
|
@@ -554,9 +750,6 @@ export class VoiceWorld implements World {
|
|
|
554
750
|
} finally {
|
|
555
751
|
this.probing = false;
|
|
556
752
|
}
|
|
557
|
-
// 探活顺带刷新目录;读不到时 refresh 会保留上一次的表。
|
|
558
|
-
await this.refreshVoices(true);
|
|
559
|
-
await this.refreshModels(true);
|
|
560
753
|
}
|
|
561
754
|
|
|
562
755
|
private maybeRefresh(): void {
|
|
@@ -565,56 +758,6 @@ export class VoiceWorld implements World {
|
|
|
565
758
|
if (!this.probing && (this.serviceState === null || now - this.serviceState.at > HEALTH_TTL_MS)) {
|
|
566
759
|
void this.probe();
|
|
567
760
|
}
|
|
568
|
-
if (now - this.voicesAt > CATALOG_TTL_MS) void this.refreshVoices();
|
|
569
|
-
if (now - this.modelsAt > CATALOG_TTL_MS) void this.refreshModels();
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
private async refreshVoices(force = false): Promise<void> {
|
|
573
|
-
const cfg = this.cfg;
|
|
574
|
-
const signature = `${cfg.serviceUrl.trim()}|${cfg.model.trim()}`;
|
|
575
|
-
if (this.voicesLoading && !force) return;
|
|
576
|
-
if (!force && signature === this.voicesSignature && this.deps.now() - this.voicesAt < CATALOG_TTL_MS) return;
|
|
577
|
-
this.voicesLoading = true;
|
|
578
|
-
try {
|
|
579
|
-
const list = await this.service.voices(cfg.model);
|
|
580
|
-
if (signature !== `${this.cfg.serviceUrl.trim()}|${this.cfg.model.trim()}`) return;
|
|
581
|
-
this.voices = list;
|
|
582
|
-
this.voicesSignature = signature;
|
|
583
|
-
this.voicesAt = this.deps.now();
|
|
584
|
-
} catch (error) {
|
|
585
|
-
this.log.debug('音色表读取失败', { err: error instanceof Error ? error.message : String(error) });
|
|
586
|
-
} finally {
|
|
587
|
-
this.voicesLoading = false;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
private async refreshModels(force = false): Promise<void> {
|
|
592
|
-
const signature = this.cfg.serviceUrl.trim();
|
|
593
|
-
if (this.modelsLoading && !force) return;
|
|
594
|
-
if (!force && signature === this.modelsSignature && this.deps.now() - this.modelsAt < CATALOG_TTL_MS) return;
|
|
595
|
-
this.modelsLoading = true;
|
|
596
|
-
try {
|
|
597
|
-
const list = await this.service.models();
|
|
598
|
-
if (signature !== this.cfg.serviceUrl.trim()) return;
|
|
599
|
-
this.models = list;
|
|
600
|
-
this.modelsSignature = signature;
|
|
601
|
-
this.modelsAt = this.deps.now();
|
|
602
|
-
} catch (error) {
|
|
603
|
-
this.log.debug('模型表读取失败', { err: error instanceof Error ? error.message : String(error) });
|
|
604
|
-
} finally {
|
|
605
|
-
this.modelsLoading = false;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/** 下拉读的是缓存表;服务读不到时保留上一次的结果。当前值由框架补入。 */
|
|
610
|
-
private registerCatalogOptions(): void {
|
|
611
|
-
registerOptions(VOICE_MODELS_KIND, this.modelOptions);
|
|
612
|
-
registerOptions(VOICE_OPTIONS_KIND, this.voiceOptions);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
private unregisterCatalogOptions(): void {
|
|
616
|
-
unregisterOptions(VOICE_MODELS_KIND, this.modelOptions);
|
|
617
|
-
unregisterOptions(VOICE_OPTIONS_KIND, this.voiceOptions);
|
|
618
761
|
}
|
|
619
762
|
|
|
620
763
|
private syncPoller(): void {
|
|
@@ -655,4 +798,4 @@ export class VoiceWorld implements World {
|
|
|
655
798
|
}
|
|
656
799
|
}
|
|
657
800
|
|
|
658
|
-
export { VOICE_DEFAULTS, VOICE_ID
|
|
801
|
+
export { VOICE_DEFAULTS, VOICE_ID };
|
package/src/options.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 配置页的模型与音色下拉要读运行中实例从服务探到的目录,但
|
|
3
|
-
* `WorldDefinition.configOptions` 是静态签名。实例启动或探活时把读取函数注册进来;
|
|
4
|
-
* 只支持 `voice.models` 与 `voice.voices` 两种 kind,未知 kind 返回空。
|
|
5
|
-
* 读的是已经缓存的表,不在配置页里发请求;服务不可达时保留上次的表,没有就是空表。
|
|
6
|
-
* 当前值不在表里时由框架补入,这里不重复做。
|
|
7
|
-
*/
|
|
8
|
-
import { VOICE_MODELS_KIND, VOICE_OPTIONS_KIND } from './config.ts';
|
|
9
|
-
import type { SelectOption } from './types.ts';
|
|
10
|
-
|
|
11
|
-
export const OPTION_KINDS = [VOICE_MODELS_KIND, VOICE_OPTIONS_KIND] as const;
|
|
12
|
-
export type OptionKind = (typeof OPTION_KINDS)[number];
|
|
13
|
-
|
|
14
|
-
type Provider = () => readonly SelectOption[];
|
|
15
|
-
|
|
16
|
-
const providers = new Map<OptionKind, Provider>();
|
|
17
|
-
|
|
18
|
-
function isOptionKind(kind: string): kind is OptionKind {
|
|
19
|
-
return kind === VOICE_MODELS_KIND || kind === VOICE_OPTIONS_KIND;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** 实例启动或探活时登记;同一 kind 后登记的覆盖先前的。 */
|
|
23
|
-
export function registerOptions(kind: OptionKind, provider: Provider): void {
|
|
24
|
-
providers.set(kind, provider);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** 实例停止时撤销自己登记的那一项;已有更新实例登记时不动它。 */
|
|
28
|
-
export function unregisterOptions(kind: OptionKind, provider: Provider): void {
|
|
29
|
-
if (providers.get(kind) === provider) providers.delete(kind);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** 未知 kind 返回空数组,框架继续查询其他 World。 */
|
|
33
|
-
export function currentOptions(kind: string): SelectOption[] {
|
|
34
|
-
if (!isOptionKind(kind)) return [];
|
|
35
|
-
return [...(providers.get(kind)?.() ?? [])];
|
|
36
|
-
}
|