@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 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
- 语音只把语义字段发给服务,由服务的模型 profile 决定怎么落到具体模型:
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` 读写长期风格(情绪、指令、音色、语速),写回 `worlds.voice`。
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` 保留标签,由服务里的模型决定演不演;`keep` 一律保留,`strip` 一律删除。表外 `[...]` 会从朗读文本里剥掉,防止被念出来。
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` | 保留;语音只发语义字段,由服务的模型 profile 映射到具体模型。 |
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` 徽标;服务在线时灯亮;让 bot 调 `voice_speak` 能听见;说「念给我听」会绕过概率;`playback: event` 时时间线出现 `tts.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- 可以在正文里放 [Laughter]、[Sigh]、[Breathing]、[Uhm]、[Question-ei] 这类副语言标签;模型支持时会被演出来,不会被念出字面。不确定模型是否支持时不要加,免得被原样念出来。\n- 不要把控制标记写进可见的回复正文;语音参数只放在工具调用里。\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.2.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
@@ -10,5 +10,5 @@
10
10
 
11
11
  文本规则:
12
12
  - text 只放真正要念的正文,不要放动作描写、Markdown、代码、网址或解释。
13
- - 可以在正文里放 [Laughter]、[Sigh]、[Breathing]、[Uhm]、[Question-ei] 这类副语言标签;模型支持时会被演出来,不会被念出字面。不确定模型是否支持时不要加,免得被原样念出来。
13
+ - {{voice.modelPrompt}}
14
14
  - 不要把控制标记写进可见的回复正文;语音参数只放在工具调用里。
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
- /** 配置里 `x-options` 用的动态音色表。 */
8
- export const VOICE_OPTIONS_KIND = 'voice.voices';
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: 'Voice',
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: 'Voice',
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, ModelOption, ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection, VoiceOption,
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<ModelOption[]> {
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): ModelOption[] => {
109
- const option = optionOf(item);
110
- return option ? [option] : [];
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();
@@ -57,10 +57,15 @@ export function stripUnknownTags(text: string): string {
57
57
  }
58
58
 
59
59
  /**
60
- * auto 保留标签,由服务里的模型决定演不演;keep 一律保留;strip 一律删掉。
60
+ * auto 只在当前模型支持标签时保留;keep 一律保留;strip 一律删掉。
61
61
  */
62
- export function keepsParalinguistic(mode: 'auto' | 'keep' | 'strip'): boolean {
63
- return mode !== 'strip';
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> = [
@@ -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)) text = stripParalinguisticTags(text);
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
- export type ModelOption = SelectOption;
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 { VOICE_DEFAULTS, VOICE_ID, VOICE_MODELS_KIND, VOICE_OPTIONS_KIND, 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
- import { registerOptions, unregisterOptions } from './options.ts';
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
- ModelOption, ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection, VoiceOption,
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 voices: VoiceOption[] = [];
134
- private voicesAt = 0;
135
- private voicesSignature = '';
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.cfg,
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.cfg;
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.cfg;
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, cfg);
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.cfg);
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 patch: Partial<VoiceConfigSection> = {};
398
- if (has('emotion')) patch.defaultEmotion = asString(args.emotion).slice(0, 40);
399
- if (has('instruct')) patch.defaultInstruct = asString(args.instruct).slice(0, 500);
400
- 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);
401
598
  if (has('speed')) {
402
599
  const speed = asNumber(args.speed);
403
- 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));
404
601
  }
405
- if (Object.keys(patch).length > 0) {
406
- this.opts.persist(patch);
407
- if (patch.voice !== undefined) void this.refreshVoices(true);
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.cfg;
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.cfg;
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.cfg.voice, emotion: this.cfg.defaultEmotion },
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, VOICE_MODELS_KIND, VOICE_OPTIONS_KIND };
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
- }