@kmchai/cortico-world-voice 0.2.1 → 0.3.2

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 CHANGED
@@ -1,3 +1,20 @@
1
+ ## 0.3.2
2
+
3
+ - IndexTTS2 的模型提示词明确:`emotion` / `instruct` 传自然语言,由服务映射到 `emotion_text`;不要在正文写 `emotion_text` 或方括号标签。服务 0.4.2 起这两个 options 不再由模型页覆盖。
4
+
5
+ ## 0.3.1
6
+
7
+ - 自动朗读不再把 `end_turn` 当成实质工具调用。模型说完正文再调 `end_turn` 结束这一轮时,正文照常按概率朗读;仍会压掉与 `web_search` 等实际工具同轮出现的中间正文。
8
+ - 网关 0.1.1 已把 World 的 `outputTap` 扇进主 session;此前网关 bot 里概率朗读拿不到正文。
9
+
10
+ ## 0.3.0
11
+
12
+ - 系统 Config tab 移除「音色与情绪」组;音色、情绪控制、默认情绪、默认表演指令、语速与副语言标签移到「模型」页。
13
+ - 朗读风格按模型保存进 `worlds.voice.modelStyles[model]`;切换模型时面板读回该模型的音色、风格与参数,不用先保存。旧全局字段保留为没有模型设置时的回落。
14
+ - `voice_style` 在选定模型后只写当前模型的风格;没有模型时仍写全局回落值。
15
+ - 服务返回的 Ming 3.0 模型参数(模板、语言、指令语言、音高、音量、生成与分块参数)在模型页显示;IndexTTS2 的中文参数名继续保留。
16
+ - 文本过滤、合成与 `tts.voice` 事件都使用当前模型生效的风格。
17
+
1
18
  ## 0.2.1
2
19
 
3
20
  - 新增 World 页的「模型」面板:模型与音色在面板里选,不再占系统 Config tab 的字段。
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
@@ -47,7 +46,7 @@ corepack pnpm add "D:/path/to/cortico-world-voice" --ignore-workspace
47
46
 
48
47
  ## 模型面板
49
48
 
50
- World 页的「模型」tab 选模型与音色,并按服务 `/v1/models` 返回的参数元数据渲染字段。保存写进 `worlds.voice.modelOptions[model]`,合成时作为 `options` 发给服务;空参数表删掉该模型的条目。模型专属的文本与情绪写法显示在同页的「模型提示词」折叠区。系统 Config tab 只留服务地址、风格、触发、过滤与输出。
49
+ World 页的「模型」tab 选模型与音色,并按服务 `/v1/models` 返回的参数元数据渲染字段。同一页的「声音与情绪」按模型保存情绪控制方式、默认情绪、默认表演指令、语速与副语言标签策略;切换模型时读回该模型自己的设置。模型设置存 `worlds.voice.modelStyles[model]`,模型参数存 `worlds.voice.modelOptions[model]`;合成时作为 `options` 发给服务。模型专属的文本与情绪写法显示在同页的「模型提示词」折叠区。系统 Config tab 只留服务地址、触发、过滤与输出。
51
50
 
52
51
  ## 工具
53
52
 
@@ -78,11 +77,11 @@ World 页的「模型」tab 选模型与音色,并按服务 `/v1/models` 返回
78
77
 
79
78
  只想响应工具与显式请求、不要随机朗读:保留 `autoSpeak: true`,把 `autoProbability` 设为 `0`。把 `autoSpeak` 整个关掉会让 tap 不再挂载,显式关键词也就看不到正文了;这时只保留 `voice_speak` 工具触发。
80
79
 
81
- 自动朗读依赖 Persona 把已挂载 World 的 `outputTap()` 扇进主 session;仓内 `cormini` 与 Coopanion 都这样做。只想要工具触发时,把 `autoSpeak` 关掉即可。
80
+ 自动朗读依赖 Persona 把已挂载 World 的 `outputTap()` 扇进主 session;`cormini`、Coopanion 与 `Kmch AI · 网关` 0.1.1 起都这样做。只想要工具触发时,把 `autoSpeak` 关掉即可。模型说完正文再调 `end_turn` 不影响朗读;与搜索等实际工具同轮的中间正文仍不念。
82
81
 
83
82
  ## 情绪、语速与标签
84
83
 
85
- 语音只把语义字段发给服务,由服务的模型 profile 决定怎么落到具体模型:
84
+ 情绪与语速设置按模型保存在「模型」页,不再出现在系统 Config tab。语音只把语义字段发给服务,由服务的模型 profile 决定怎么落到具体模型:
86
85
 
87
86
  | `emotionMode` | 语音发送 | `audiocpp-ming3` profile 的落点 |
88
87
  |---|---|---|
@@ -92,12 +91,12 @@ World 页的「模型」tab 选模型与音色,并按服务 `/v1/models` 返回
92
91
  | `tags` | 不发情绪参数,只用正文标签。 | 正文标签。 |
93
92
  | `none` | 都不发。 | — |
94
93
 
95
- OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求。其他模型可在服务面板里覆盖 profile 的字段映射,语音不用改配置。
94
+ OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求。IndexTTS2 把 `instructions` 或 `emotion` 映射成 `use_emotion_text: true` 与 `emotion_text`;这两个参数由语音的「默认情绪 / 默认表演指令」或单句 `emotion` / `instruct` 驱动,不在模型参数面板里单独出现。其他模型可在服务面板里覆盖 profile 的字段映射,语音不用改配置。
96
95
 
97
96
  `response_format` 每次都带;`model` 与 `voice` 配了才带,工具参数里的音色优先于配置默认值。
98
97
 
99
- - 情绪来源:工具参数 > 正文里的 `[emotion:开心]` 标记 > `defaultEmotion`。
100
- - `voice_style` 读写长期风格(情绪、指令、音色、语速),写回 `worlds.voice`。
98
+ - 情绪来源:工具参数 > 正文里的 `[emotion:开心]` 标记 > 当前模型的 `defaultEmotion`。
99
+ - `voice_style` 读写当前模型的长期风格(情绪、指令、音色、语速),写进 `modelStyles[model]`;还没选模型时写到全局回落值。
101
100
  - 副语言标签:`[Breathing] [Cough] [Sigh] [Laughter] [Surprise-ah] [Surprise-oh] [Dissatisfaction-hnn] [Uhm] [Confirmation-en] [Question-ei] [Question-ah] [Question-en] [Question-oh] [Question-huh]`。
102
101
  `paralinguistic: auto` 只在当前模型支持标签(Ming 3.0 系)时保留,IndexTTS2 与通用模型剥掉;`keep` 一律保留,`strip` 一律删除。表外 `[...]` 会从朗读文本里剥掉,防止被念出来。
103
102
 
@@ -141,7 +140,7 @@ OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求
141
140
  | `httpApi` / `httpPort` / `httpToken` / `httpPlay` | 删除;服务的网关取代本地语音接口。 |
142
141
  | `singUrl` / `singToken`、`voice_sing`、`【唱】` | 删除;唱歌由服务的 `/v1/audio/sing` 与网关接入。 |
143
142
  | `model` / `voice` / `format` / `timeoutMs` | 保留,含义不变。 |
144
- | `emotionMode` / `defaultEmotion` / `defaultInstruct` / `speed` / `paralinguistic` | 保留;语音只发语义字段,由服务的模型 profile 映射到具体模型。 |
143
+ | `emotionMode` / `defaultEmotion` / `defaultInstruct` / `speed` / `paralinguistic` | 移到「模型」页,按模型保存;旧全局值仍作为没有模型设置时的回落。 |
145
144
  | 触发、过滤、输出配置 | 保留。 |
146
145
 
147
146
  ## 验证
@@ -153,7 +152,7 @@ pnpm smoke:player out.wav # 本机播放器,音量 0
153
152
  pnpm smoke:chain http://127.0.0.1:9883 # 触发→过滤→经服务合成→tts.voice 全链路
154
153
  ```
155
154
 
156
- 装进 Cortico 后应看到:World 页多一张「Kmch AI · 语音」卡,服务灯、队列灯与 `service` / `model` / `voice` 徽标;「模型」tab 能选模型与音色、按服务参数渲染字段并保存;服务在线时灯亮;让 bot 调 `voice_speak` 能听见;说「念给我听」会绕过概率;`playback: event` 时时间线出现 `tts.voice`。
155
+ 装进 Cortico 后应看到:World 页多一张「Kmch AI · 语音」卡,服务灯、队列灯与 `service` / `model` / `voice` 徽标;「模型」tab 能选模型与音色,按模型调整声音与情绪,并按服务参数渲染字段、保存;服务在线时灯亮;让 bot 调 `voice_speak` 能听见;说「念给我听」会绕过概率;`playback: event` 时时间线出现 `tts.voice`。
157
156
 
158
157
  ## 许可
159
158
 
package/dist/webui.js CHANGED
@@ -1 +1 @@
1
- var W={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"},en:{title:"\u6A21\u578B",model:"Model",voice:"Voice",refresh:"Refresh",save:"Save",saved:"Saved",loading:"Loading\u2026",promptTitle:"Model prompt"}};function O(e){return e.toLowerCase().startsWith("zh")?W.zh:W.en}var U={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"};function j(e,t){return Object.prototype.hasOwnProperty.call(t,e.name)?t[e.name]:e.default}function C(e){return e==null?"":Array.isArray(e)?e.map(t=>String(t)).join(", "):String(e)}function H(e){return typeof e=="number"&&Number.isFinite(e)?String(e):typeof e=="string"&&e.trim()&&Number.isFinite(Number(e))?e.trim():""}function I(e,t,s){let i=e.ui,r=i.h("div","vx-field"),m=i.h("div","vx-head"),p=U[t.name];p&&p!==t.name?m.append(i.h("span","vx-label",p),i.h("span","vx-name",t.name)):m.append(i.h("span","vx-label",t.name)),r.append(m);let f=j(t,s),u;switch(t.type){case"bool":{let a=i.checkbox("",{checked:f===!0});r.append(a.el),u=()=>a.checked;break}case"enum":{let a=t.values??[],d=C(f),c=[{value:"",label:"\u2014"},...a.map(k=>({value:k,label:k}))];d&&!a.includes(d)&&c.push({value:d,label:d});let b=i.select({options:c,value:d});r.append(b),u=()=>b.value||void 0;break}case"int":case"float":{let a=i.input({type:"number",value:H(f)});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",r.append(a),u=()=>{let d=a.value.trim();if(!d)return;let c=Number(d);if(Number.isFinite(c))return t.type==="int"?Math.trunc(c):c};break}case"string_list":case"path_list":{let a=i.input({type:"text",value:C(f)});r.append(a),u=()=>{let d=a.value.split(/[,,]/).map(c=>c.trim()).filter(Boolean);return d.length>0?d:void 0};break}default:{let a=i.input({type:"text",value:C(f)});r.append(a),u=()=>a.value.trim()||void 0;break}}return t.description&&r.append(i.h("div","vx-desc",t.description)),{param:t,el:r,read:u}}function X(e){let t=O(e.language),s=e.ui,i=null,r="",m=[],p=!1,f=s.sheet({title:t.title}),u=s.h("div","vx-top"),a=s.select({onChange:()=>B(a.value)}),d=s.select({}),c=s.h("label","vx-pick");c.append(s.h("span","vx-pick-label",t.model),a);let b=s.h("label","vx-pick");b.append(s.h("span","vx-pick-label",t.voice),d);let k=s.button(t.refresh,{size:"sm",onClick:()=>{E("refresh")}});u.append(c,b,k);let y=s.h("div","vx-params"),S=s.button(t.save,{variant:"primary",size:"sm",onClick:()=>{F()}}),w=s.msgline(),T=s.actions();T.append(S),f.body.append(u,y,T,w),e.root.append(f.el);let _=s.foldSheet("voice-model-prompt",{title:t.promptTitle,defaultOpen:!1}),z=s.h("pre","vx-prompt");_.body.append(z),e.root.append(_.el);function x(n,o=n){let l=s.h("option");return l.value=n,l.textContent=o,l}function h(n,o){w.textContent=n,w.classList.toggle("bad",o)}function v(){let n=i!==null&&i.models.length>0;a.disabled=p||!n,d.disabled=p||!n||!r,S.disabled=p||!n||!r,k.disabled=p}function L(){let o=i?.models.find(M=>M.id===r)?.voices??[],l=i?.model===r?i.voice:"",g=[...o];l&&!g.includes(l)&&g.unshift(l);let P=g.map(M=>x(M));l||P.unshift(x("","\u2014")),d.replaceChildren(...P.length>0?P:[x("","\u2014")]),d.value=l}function R(){let n=i?.models.find(l=>l.id===r),o=i&&i.model===r?i.options:{};m=[],y.replaceChildren();for(let l of n?.parameters??[]){let g=I(e,l,o);m.push(g),y.append(g.el)}}function B(n){r=n,L(),R(),v()}function A(n){i=n,r=n.model,N(),R(),z.textContent=n.prompt,_.note.textContent=n.model||"\u2014",v(),h(n.error??"",n.error!==void 0)}function N(){let n=i?.models??[],o=n.map(l=>x(l.id,l.label||l.id));!r&&n.length>0?o.unshift(x("","\u2014")):r&&!n.some(l=>l.id===r)&&o.unshift(x(r,r)),a.replaceChildren(...o),a.value=r,L()}function V(){let n={};for(let o of m){let l=o.read();l!==void 0&&(n[o.param.name]=l)}return n}async function E(n){if(!p){p=!0,v(),h(t.loading,!1);try{let o=await e.invoke(n,[]);e.signal.aborted||A(o)}catch(o){e.signal.aborted||h(o instanceof Error?o.message:String(o),!0)}finally{p=!1,e.signal.aborted||v()}}}async function F(){if(p||!i)return;let n={model:r,voice:d.value,options:V()};p=!0,v(),h("",!1);try{let o=await e.invoke("save",[n]);e.signal.aborted||(A(o),h(t.saved,!1))}catch(o){e.signal.aborted||h(o instanceof Error?o.message:String(o),!0)}finally{p=!1,e.signal.aborted||v()}}v(),E("state")}var q={panels:{model:{mount:X}}},Y=q;export{Y as default};
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.2.1",
3
+ "version": "0.3.2",
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",
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
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
7
  /** World 定义、WebUI 与配置页共用同一个显示名。 */
@@ -15,6 +15,7 @@ export const VOICE_DEFAULTS: VoiceConfigSection = {
15
15
  model: '',
16
16
  voice: '',
17
17
  modelOptions: {},
18
+ modelStyles: {},
18
19
  format: 'wav',
19
20
  timeoutMs: 180_000,
20
21
 
@@ -49,6 +50,27 @@ export const VOICE_DEFAULTS: VoiceConfigSection = {
49
50
  shareAudio: false,
50
51
  };
51
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
+
52
74
  const TEXT = {
53
75
  zh: {
54
76
  label: 'Kmch AI · 语音',
@@ -60,6 +82,7 @@ const TEXT = {
60
82
  modelSaveShape: '保存失败:参数格式不对。',
61
83
  modelSaveModel: '保存失败:模型 id 不能为空。',
62
84
  modelSaveVoice: '保存失败:音色必须是字符串。',
85
+ modelSaveStyle: '保存失败:朗读风格格式不对。',
63
86
  modelSaveOptions: '保存失败:模型参数必须是对象。',
64
87
  modelUnknownMethod: '未知的面板方法。',
65
88
  connection: '连接',
@@ -70,15 +93,6 @@ const TEXT = {
70
93
  serviceTokenSecretDesc: '部署 .env 里的变量名,留空表示服务不校验。',
71
94
  format: '音频格式',
72
95
  timeoutMs: '超时(毫秒)',
73
- style: '音色与情绪',
74
- styleDesc: '',
75
- emotionMode: '情绪控制',
76
- emotionModeDesc: 'instruct 折成自然语言指令;categorical 只发情绪标签;basic 只发语速;tags 只用正文标签;none 不发。',
77
- defaultEmotion: '默认情绪',
78
- defaultInstruct: '默认表演指令',
79
- speed: '语速',
80
- paralinguistic: '副语言标签',
81
- paralinguisticDesc: 'auto 只在当前模型支持标签时保留;keep 一律保留;strip 一律从朗读文本里删掉。',
82
96
  triggers: '触发',
83
97
  triggersDesc: '',
84
98
  autoSpeak: '自动朗读',
@@ -123,6 +137,7 @@ const TEXT = {
123
137
  modelSaveShape: 'Save failed: invalid arguments.',
124
138
  modelSaveModel: 'Save failed: the model id must not be empty.',
125
139
  modelSaveVoice: 'Save failed: voice must be a string.',
140
+ modelSaveStyle: 'Save failed: invalid voice style.',
126
141
  modelSaveOptions: 'Save failed: model options must be an object.',
127
142
  modelUnknownMethod: 'Unknown panel method.',
128
143
  connection: 'Connection',
@@ -133,15 +148,6 @@ const TEXT = {
133
148
  serviceTokenSecretDesc: 'Name in the deployment .env; empty means the service does not check.',
134
149
  format: 'Audio format',
135
150
  timeoutMs: 'Timeout (ms)',
136
- style: 'Voice and emotion',
137
- styleDesc: '',
138
- emotionMode: 'Emotion control',
139
- emotionModeDesc: 'instruct turns it into natural language; categorical sends a label; basic sends speed; tags uses inline tags only; none sends nothing.',
140
- defaultEmotion: 'Default emotion',
141
- defaultInstruct: 'Default direction',
142
- speed: 'Speed',
143
- paralinguistic: 'Paralinguistic tags',
144
- paralinguisticDesc: 'auto keeps them only when the current model supports them; keep always keeps; strip removes them from the spoken text.',
145
151
  triggers: 'Triggers',
146
152
  triggersDesc: '',
147
153
  autoSpeak: 'Automatic speech',
@@ -204,21 +210,6 @@ export function voiceConfigGroups(language: Language): ConfigGroup[] {
204
210
  },
205
211
  },
206
212
  },
207
- {
208
- id: 'world:voice-style',
209
- owner: 'world:voice',
210
- schema: {
211
- type: 'object',
212
- title: t.style,
213
- properties: {
214
- [`${p}emotionMode`]: { type: 'string', title: t.emotionMode, description: t.emotionModeDesc, enum: ['instruct', 'categorical', 'basic', 'tags', 'none'], 'x-hot': true },
215
- [`${p}defaultEmotion`]: { type: 'string', title: t.defaultEmotion, 'x-hot': true },
216
- [`${p}defaultInstruct`]: { type: 'string', title: t.defaultInstruct, 'x-hot': true },
217
- [`${p}speed`]: { type: 'number', title: t.speed, minimum: 0.5, maximum: 2, multipleOf: 0.05, 'x-hot': true },
218
- [`${p}paralinguistic`]: { type: 'string', title: t.paralinguistic, description: t.paralinguisticDesc, enum: ['auto', 'keep', 'strip'], 'x-hot': true },
219
- },
220
- },
221
- },
222
213
  {
223
214
  id: 'world:voice-triggers',
224
215
  owner: 'world:voice',
package/src/definition.ts CHANGED
@@ -7,7 +7,7 @@ import { VoiceWorld } from './world.ts';
7
7
  export const VOICE: WorldDefinition<VoiceConfigSection> = {
8
8
  id: VOICE_ID,
9
9
  label: VOICE_LABEL,
10
- defaults: () => ({ ...VOICE_DEFAULTS, modelOptions: {} }),
10
+ defaults: () => ({ ...VOICE_DEFAULTS, modelOptions: {}, modelStyles: {} }),
11
11
  create: (ctx) => new VoiceWorld({
12
12
  cfg: ctx.cfg,
13
13
  timezone: ctx.timezone,
package/src/models.ts CHANGED
@@ -28,7 +28,7 @@ const INDEXTTS2: ModelInfo = {
28
28
  match: ['index-tts2', 'index_tts2', 'index-tts2.5', 'index_tts2_5', '2.5'],
29
29
  label: 'IndexTTS2',
30
30
  tags: false,
31
- prompt: '情绪用自然语言(emotion_text),不要写 [Laughter] 这类方括号标签;采样/时长参数在语音模型页配置,不要写进正文.',
31
+ prompt: '情绪用 voice_speak 的 emotion 或 instruct 传自然语言,服务会映射到 emotion_text;不要在正文写 emotion_text 或 [Laughter] 这类方括号标签。采样/时长参数在语音模型页配置,不要写进正文.',
32
32
  };
33
33
 
34
34
  const GENERIC: ModelInfo = {
package/src/panel.ts CHANGED
@@ -2,19 +2,32 @@
2
2
  * 「模型」面板的数据形状。服务端经由 `World.webUi().invoke` 构造,浏览器端只
3
3
  * `import type` 这些声明;面板不接触服务地址与令牌。
4
4
  */
5
- import type { VoiceModel } from './types.ts';
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
+ }
6
13
 
7
14
  export interface PanelModelState {
8
15
  /** `worlds.voice.model`。 */
9
16
  model: string;
10
- /** `worlds.voice.voice`。 */
17
+ /** 当前模型的音色。 */
11
18
  voice: string;
12
19
  /** 当前模型的环境提示词片段,来自 `models.ts`。 */
13
20
  prompt: string;
21
+ /** 每个模型的环境提示词片段,切换模型时立即更新。 */
22
+ prompts: Record<string, string>;
14
23
  /** 当前模型已保存的请求参数。 */
15
24
  options: Record<string, unknown>;
25
+ /** 当前模型生效的朗读风格;没有专属设置时回落到全局值。 */
26
+ style: VoiceModelStyle;
16
27
  /** 服务目录;服务不可达时是上一次可用的结果。 */
17
28
  models: VoiceModel[];
29
+ /** 每个模型的音色、风格与请求参数;面板切模型时不用等保存。 */
30
+ settings: Record<string, PanelModelSetting>;
18
31
  /** 最近一次读取服务目录失败的原因。 */
19
32
  error?: string;
20
33
  }
@@ -23,6 +36,7 @@ export interface PanelModelState {
23
36
  export interface PanelModelSave {
24
37
  model: string;
25
38
  voice: string;
39
+ style?: Partial<VoiceModelStyle>;
26
40
  options: Record<string, unknown>;
27
41
  }
28
42
 
package/src/triggers.ts CHANGED
@@ -8,6 +8,8 @@ import type { VoiceConfigSection } from './types.ts';
8
8
 
9
9
  /** 工具参数里可能放正文的字段,按顺序取第一个字符串。 */
10
10
  const TEXT_FIELDS = ['script', 'question', 'text', 'content', 'message', 'body'] as const;
11
+ /** 只结束这一轮的控制工具,不算「这一轮做了别的事」。 */
12
+ const FLOW_TOOLS = new Set(['end_turn']);
11
13
 
12
14
  export function splitList(raw: string): string[] {
13
15
  return raw
@@ -60,15 +62,27 @@ interface StreamLike {
60
62
 
61
63
  export class VoiceTap implements OutputTap {
62
64
  private text = '';
63
- private calls = 0;
64
- private readonly names = new Map<number, string>();
65
+ /** 这一轮出现过的函数调用:output_index -> name;名字未知时存空串。 */
66
+ private readonly callNames = new Map<number, string>();
67
+ /** 没有 output_index 的函数调用;无法判断是不是 end_turn,按实质调用处理。 */
68
+ private orphanCall = false;
65
69
  private readonly emitted = new Set<number>();
66
70
 
67
71
  constructor(private readonly opts: TapOptions) {}
68
72
 
73
+ private noteCall(index: number, name: string): void {
74
+ if (index < 0) {
75
+ if (name && FLOW_TOOLS.has(name)) return;
76
+ this.orphanCall = true;
77
+ return;
78
+ }
79
+ if (name) this.callNames.set(index, name);
80
+ else if (!this.callNames.has(index)) this.callNames.set(index, '');
81
+ }
82
+
69
83
  /** 工具调用完成:同一个 output_index 只交出去一次。 */
70
84
  private takeCall(index: number, name: string, args: string): void {
71
- if (!name || name.startsWith('voice_')) return;
85
+ if (!name || name.startsWith('voice_') || FLOW_TOOLS.has(name)) return;
72
86
  if (this.emitted.has(index)) return;
73
87
  if (!toolNames(this.opts.cfg()).has(name)) return;
74
88
  const text = textOfToolArgs(args);
@@ -85,38 +99,39 @@ export class VoiceTap implements OutputTap {
85
99
  return;
86
100
  }
87
101
  if (e.type === 'response.output_item.added' && e.item?.type === 'function_call') {
88
- this.calls += 1;
89
- if (e.item.name) this.names.set(index, e.item.name);
102
+ this.noteCall(index, e.item.name ?? '');
90
103
  return;
91
104
  }
92
105
  // 有的传输只给参数结束事件,不带完整 item;名字从 added 里取。
93
106
  if (e.type === 'response.function_call_arguments.done') {
94
- const name = this.names.get(index) ?? '';
107
+ const name = this.callNames.get(index) ?? '';
108
+ this.noteCall(index, name);
95
109
  if (name) this.takeCall(index, name, e.arguments ?? '');
96
110
  return;
97
111
  }
98
112
  if (e.type === 'response.output_item.done' && e.item?.type === 'function_call') {
99
- this.calls += 1;
100
- const name = e.item.name ?? this.names.get(index) ?? '';
113
+ const name = e.item.name ?? this.callNames.get(index) ?? '';
114
+ this.noteCall(index, name);
101
115
  if (name) this.takeCall(index, name, e.item.arguments ?? '');
102
- this.names.delete(index);
103
116
  }
104
117
  }
105
118
 
106
119
  onRoundEnd(): void {
107
120
  const text = this.text;
108
121
  this.text = '';
109
- const pureText = this.calls === 0;
110
- this.calls = 0;
111
- this.names.clear();
122
+ // 只有结束轮次的调用不算实质动作;纯文本回复照样可以自动朗读。
123
+ const nonFlow = this.orphanCall
124
+ || [...this.callNames.values()].some((name) => name === '' || !FLOW_TOOLS.has(name));
125
+ this.callNames.clear();
126
+ this.orphanCall = false;
112
127
  this.emitted.clear();
113
- if (pureText && text.trim()) this.opts.onText(text, 'text');
128
+ if (!nonFlow && text.trim()) this.opts.onText(text, 'text');
114
129
  }
115
130
 
116
131
  onAbort(): void {
117
132
  this.text = '';
118
- this.calls = 0;
119
- this.names.clear();
133
+ this.callNames.clear();
134
+ this.orphanCall = false;
120
135
  this.emitted.clear();
121
136
  this.opts.onAbort?.();
122
137
  }
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,13 +28,16 @@ export interface VoiceConfigSection {
17
28
  /** 服务令牌的环境变量名;空表示服务不校验。 */
18
29
  serviceTokenSecret: string;
19
30
  model: string;
31
+ /** 旧的全局音色;只作为没有模型专属设置时的回落值。 */
20
32
  voice: string;
21
33
  /** 模型专属的 request 参数,按模型 id 分组;由「模型」面板写回,不进 ConfigGroup。 */
22
34
  modelOptions: Record<string, Record<string, unknown>>;
35
+ /** 模型专属的朗读风格;由「模型」面板写回,不进 ConfigGroup。 */
36
+ modelStyles: Record<string, Partial<VoiceModelStyle>>;
23
37
  format: AudioFormat;
24
38
  timeoutMs: number;
25
39
 
26
- // 音色与情绪
40
+ // 朗读风格的全局回落值(旧配置仍可用;界面上已移到模型页)
27
41
  emotionMode: EmotionMode;
28
42
  defaultEmotion: string;
29
43
  defaultInstruct: string;
package/src/world.ts CHANGED
@@ -14,12 +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 { VOICE_DEFAULTS, VOICE_ID, voiceConfigGroups, voiceText } from './config.ts';
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
21
  import { modelPrompt } from './models.ts';
22
- import { isPlainObject, type PanelModelState } from './panel.ts';
22
+ import { isPlainObject, type PanelModelSetting, type PanelModelState } from './panel.ts';
23
23
  import { ServiceClient, type SpeechService } from './service.ts';
24
24
  import { splitSpeech, truncateSpeech } from './speech/chunk.ts';
25
25
  import { buildInstruct, resolveStyle } from './speech/style.ts';
@@ -27,7 +27,8 @@ import { filterSpeech } from './speech/text.ts';
27
27
  import { EventPoller, eventTypes, hasKeyword, latestUserText, splitList, VoiceTap } from './triggers.ts';
28
28
  import { VOICE_TOOL_DECLS } from './tools.ts';
29
29
  import type {
30
- ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection, VoiceModel,
30
+ EmotionMode, ParalinguisticMode, ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection,
31
+ VoiceModel, VoiceModelStyle,
31
32
  } from './types.ts';
32
33
 
33
34
  const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
@@ -142,7 +143,7 @@ export class VoiceWorld implements World {
142
143
  this.service = this.deps.service ?? new ServiceClient({
143
144
  fetch: this.deps.fetch,
144
145
  secret: (name) => this.opts.secret(name),
145
- cfg: () => this.cfg,
146
+ cfg: () => this.styled,
146
147
  });
147
148
  this.player = this.deps.createPlayer(this.log);
148
149
  this.queue = new SpeechQueue({
@@ -166,14 +167,19 @@ export class VoiceWorld implements World {
166
167
  return this.opts.cfg;
167
168
  }
168
169
 
170
+ /** 当前模型的配置视图:模型专属的朗读风格盖在全局值上。 */
171
+ private get styled(): VoiceConfigSection {
172
+ return styledConfig(this.cfg, this.cfg.model);
173
+ }
174
+
169
175
  envPromptVars(): Record<string, string> {
170
- const cfg = this.cfg;
176
+ const cfg = this.styled;
171
177
  return {
172
- 'voice.auto': cfg.autoSpeak ? `${Math.round(cfg.autoProbability * 100)}%` : 'off',
178
+ 'voice.auto': this.cfg.autoSpeak ? `${Math.round(this.cfg.autoProbability * 100)}%` : 'off',
173
179
  'voice.voice': cfg.voice || 'default',
174
180
  'voice.emotion': cfg.defaultEmotion || 'neutral',
175
181
  'voice.instruct': cfg.defaultInstruct || 'none',
176
- 'voice.modelPrompt': modelPrompt(cfg.model),
182
+ 'voice.modelPrompt': modelPrompt(this.cfg.model),
177
183
  };
178
184
  }
179
185
 
@@ -196,7 +202,7 @@ export class VoiceWorld implements World {
196
202
  webUi(language: Language = 'zh'): WorldWebUiDecl {
197
203
  const t = voiceText(language);
198
204
  this.maybeRefresh();
199
- const cfg = this.cfg;
205
+ const cfg = this.styled;
200
206
  const stats = this.queue.stats();
201
207
  const serviceLamp: WorldLamp = this.serviceState === null
202
208
  ? { label: t.lampService, state: this.started ? 'loading' : 'offline' }
@@ -270,17 +276,41 @@ export class VoiceWorld implements World {
270
276
 
271
277
  private panelSnapshot(): PanelModelState {
272
278
  const cfg = this.cfg;
273
- const saved = cfg.modelOptions?.[cfg.model];
279
+ const settings = this.panelSettings();
280
+ const current = settings[cfg.model];
281
+ const style = current?.style ?? modelStyle(cfg, cfg.model);
274
282
  return {
275
283
  model: cfg.model,
276
- voice: cfg.voice,
284
+ voice: style.voice,
277
285
  prompt: modelPrompt(cfg.model),
278
- options: isPlainObject(saved) ? { ...saved } : {},
286
+ prompts: Object.fromEntries(Object.keys(settings).map((id) => [id, modelPrompt(id)])),
287
+ options: current?.options ?? {},
288
+ style,
279
289
  models: this.catalog ?? [],
290
+ settings,
280
291
  ...(this.catalogError ? { error: this.catalogError } : {}),
281
292
  };
282
293
  }
283
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
+
284
314
  private loadCatalog(selected: string): Promise<void> {
285
315
  if (this.catalogLoad) return this.catalogLoad;
286
316
  this.catalogLoad = this.fetchCatalog(selected).finally(() => {
@@ -338,18 +368,60 @@ export class VoiceWorld implements World {
338
368
  const model = typeof raw.model === 'string' ? raw.model.trim() : '';
339
369
  if (!model) throw new Error(t.modelSaveModel);
340
370
  if (typeof raw.voice !== 'string') throw new Error(t.modelSaveVoice);
371
+ if (raw.style !== undefined && !isPlainObject(raw.style)) throw new Error(t.modelSaveStyle);
341
372
  if (!isPlainObject(raw.options)) throw new Error(t.modelSaveOptions);
342
373
 
343
- const next: VoiceConfigSection['modelOptions'] = { ...(this.cfg.modelOptions ?? {}) };
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 ?? {}) };
344
379
  // 空对象表示删掉这个模型的条目,回到服务的默认参数。
345
- if (Object.keys(raw.options).length === 0) delete next[model];
346
- else next[model] = { ...raw.options };
380
+ if (Object.keys(raw.options).length === 0) delete nextOptions[model];
381
+ else nextOptions[model] = { ...raw.options };
347
382
  // 配置层对对象做深合并,删掉的键不会随 patch 传播;先让活引用指向新对象再写盘。
348
- this.cfg.modelOptions = next;
349
- this.opts.persist({ model, voice: raw.voice.trim(), modelOptions: next });
383
+ this.cfg.modelStyles = nextStyles;
384
+ this.cfg.modelOptions = nextOptions;
385
+ this.opts.persist({ model, modelStyles: nextStyles, modelOptions: nextOptions });
350
386
  return this.panelSnapshot();
351
387
  }
352
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
+
353
425
  outputTap() {
354
426
  const cfg = this.cfg;
355
427
  if (!cfg.autoSpeak) return undefined;
@@ -408,7 +480,7 @@ export class VoiceWorld implements World {
408
480
  const cfg = this.cfg;
409
481
  if (!cfg.autoSpeak) return;
410
482
 
411
- const filtered = filterSpeech(raw, cfg);
483
+ const filtered = filterSpeech(raw, this.styled);
412
484
  if (!filtered.text) return;
413
485
 
414
486
  const userText = cfg.explicitTrigger
@@ -449,7 +521,7 @@ export class VoiceWorld implements World {
449
521
  // ———————— 工具 ————————
450
522
 
451
523
  private async handleSpeak(args: Record<string, unknown>): Promise<ToolOutcome> {
452
- const filtered = filterSpeech(asString(args.text), this.cfg);
524
+ const filtered = filterSpeech(asString(args.text), this.styled);
453
525
  if (!filtered.text) return { text: 'voice_speak failed: there is nothing to say.', failed: true };
454
526
  const silent = args.silent === true || this.cfg.playback === 'none';
455
527
  const share = this.cfg.shareAudio || silent;
@@ -500,26 +572,47 @@ export class VoiceWorld implements World {
500
572
 
501
573
  private async handleStyle(args: Record<string, unknown>): Promise<ToolOutcome> {
502
574
  const has = (key: string): boolean => Object.prototype.hasOwnProperty.call(args, key);
503
- const patch: Partial<VoiceConfigSection> = {};
504
- if (has('emotion')) patch.defaultEmotion = asString(args.emotion).slice(0, 40);
505
- if (has('instruct')) patch.defaultInstruct = asString(args.instruct).slice(0, 500);
506
- if (has('voice')) patch.voice = asString(args.voice).slice(0, 200);
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);
507
598
  if (has('speed')) {
508
599
  const speed = asNumber(args.speed);
509
- if (speed !== undefined) patch.speed = Math.min(2, Math.max(0.5, speed));
600
+ if (speed !== undefined) next.speed = Math.min(2, Math.max(0.5, speed));
510
601
  }
511
- if (Object.keys(patch).length > 0) {
512
- this.opts.persist(patch);
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 });
513
606
  return { text: `Voice style updated. ${this.styleSummary()}` };
514
607
  }
515
608
  return { text: this.styleSummary() };
516
609
  }
517
610
 
518
611
  private styleSummary(): string {
519
- const cfg = this.cfg;
612
+ const cfg = this.styled;
520
613
  return `Voice: ${cfg.voice || 'default'}; emotion: ${cfg.defaultEmotion || 'neutral'}; `
521
614
  + `direction: ${cfg.defaultInstruct || 'none'}; speed: ${cfg.speed}; `
522
- + `automatic: ${cfg.autoSpeak ? `${Math.round(cfg.autoProbability * 100)}%` : 'off'}.`;
615
+ + `automatic: ${this.cfg.autoSpeak ? `${Math.round(this.cfg.autoProbability * 100)}%` : 'off'}.`;
523
616
  }
524
617
 
525
618
  private spokenOutcome(audio: TtsAudio | undefined, seconds: number, silent: boolean): ToolOutcome {
@@ -553,7 +646,7 @@ export class VoiceWorld implements World {
553
646
  style: { emotion?: string; instruct?: string; voice?: string; speed?: number },
554
647
  signal: AbortSignal,
555
648
  ): Promise<TtsAudio> {
556
- const cfg = this.cfg;
649
+ const cfg = this.styled;
557
650
  const resolved = resolveStyle(cfg, style);
558
651
  const request: TtsRequest = {
559
652
  text,
@@ -599,7 +692,7 @@ export class VoiceWorld implements World {
599
692
  origin: 'internal',
600
693
  senderKey: this.id,
601
694
  text: `Spoken line: ${chunk.slice(0, 100)}`,
602
- meta: { source: job.source, voice: this.cfg.voice, emotion: this.cfg.defaultEmotion },
695
+ meta: { source: job.source, voice: this.styled.voice, emotion: this.styled.defaultEmotion },
603
696
  blobs: [{
604
697
  bytes: audio.bytes,
605
698
  mime: audio.mime,