@kmchai/cortico-world-voice 0.2.0 → 0.2.1
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 +8 -0
- package/README.md +8 -4
- package/describe.json +1 -1
- package/dist/webui.css +1 -0
- package/dist/webui.js +1 -0
- package/package.json +9 -3
- package/src/ENV_PROMPT.md +1 -1
- package/src/config.ts +19 -14
- package/src/definition.ts +3 -5
- package/src/models.ts +66 -0
- package/src/panel.ts +34 -0
- package/src/service.ts +86 -5
- package/src/speech/tags.ts +8 -3
- package/src/speech/text.ts +4 -1
- package/src/types.ts +32 -1
- package/src/world.ts +124 -74
- package/src/options.ts +0 -36
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 0.2.1
|
|
2
|
+
|
|
3
|
+
- 新增 World 页的「模型」面板:模型与音色在面板里选,不再占系统 Config tab 的字段。
|
|
4
|
+
- 按服务 `/v1/models` 返回的参数元数据渲染字段:开关、枚举、数字与文本;IndexTTS2 的参数带中文名。
|
|
5
|
+
- 模型专属参数存进 `worlds.voice.modelOptions[model]`,请求时作为 `options` 发给服务;空对象删掉该模型的条目。
|
|
6
|
+
- 面板显示当前模型的提示词片段;目录缓存在进程内,服务不可达时保留上一次结果并报错。
|
|
7
|
+
- 新增 `scripts/build-webui.mjs`(esbuild)与 `dist/webui.js` / `dist/webui.css`。
|
|
8
|
+
|
|
1
9
|
## 0.2.0
|
|
2
10
|
|
|
3
11
|
分层:协议、进程托管与 HTTP 网关移到 `@kmchai/cortico-world-service`;语音只保留工具、概率朗读、文本过滤、情绪与标签语义、队列和本机播放,经 HTTP 调服务。
|
package/README.md
CHANGED
|
@@ -45,6 +45,10 @@ corepack pnpm add "D:/path/to/cortico-world-voice" --ignore-workspace
|
|
|
45
45
|
|
|
46
46
|
`serviceUrl` 默认为 `http://127.0.0.1:9883`。`serviceTokenSecret` 填部署 `.env` 里的变量名,值只在请求时读取;服务监听非回环地址时必须配令牌。后端条目、模型 profile 与上游密钥都配在服务 World 里,见[服务包 README](https://github.com/Kmch-AI/cortico-world-service#readme)。
|
|
47
47
|
|
|
48
|
+
## 模型面板
|
|
49
|
+
|
|
50
|
+
World 页的「模型」tab 选模型与音色,并按服务 `/v1/models` 返回的参数元数据渲染字段。保存写进 `worlds.voice.modelOptions[model]`,合成时作为 `options` 发给服务;空参数表删掉该模型的条目。模型专属的文本与情绪写法显示在同页的「模型提示词」折叠区。系统 Config tab 只留服务地址、风格、触发、过滤与输出。
|
|
51
|
+
|
|
48
52
|
## 工具
|
|
49
53
|
|
|
50
54
|
| 工具 | 作用 |
|
|
@@ -95,7 +99,7 @@ OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求
|
|
|
95
99
|
- 情绪来源:工具参数 > 正文里的 `[emotion:开心]` 标记 > `defaultEmotion`。
|
|
96
100
|
- `voice_style` 读写长期风格(情绪、指令、音色、语速),写回 `worlds.voice`。
|
|
97
101
|
- 副语言标签:`[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`
|
|
102
|
+
`paralinguistic: auto` 只在当前模型支持标签(Ming 3.0 系)时保留,IndexTTS2 与通用模型剥掉;`keep` 一律保留,`strip` 一律删除。表外 `[...]` 会从朗读文本里剥掉,防止被念出来。
|
|
99
103
|
|
|
100
104
|
## 文本过滤
|
|
101
105
|
|
|
@@ -121,8 +125,8 @@ OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求
|
|
|
121
125
|
|---|---|
|
|
122
126
|
| `POST /v1/audio/speech` | 合成音频;体 `{model?, input, voice?, response_format, speed?, instructions?, emotion?}`。 |
|
|
123
127
|
| `GET /health` | 探活;World 页的服务灯每 15 秒刷新。 |
|
|
124
|
-
| `GET /v1/models` |
|
|
125
|
-
| `GET /v1/audio/voices?model=` |
|
|
128
|
+
| `GET /v1/models` | 模型、音色与请求参数元数据。 |
|
|
129
|
+
| `GET /v1/audio/voices?model=` | 当前模型的音色表。 |
|
|
126
130
|
|
|
127
131
|
服务不可达时:模型与音色下拉保留上一次读到的表,服务灯变红,`voice_speak` 回失败。语音 0.2.0 不保留直连后端的回退路径。服务不重试上游,语音也不重试。
|
|
128
132
|
|
|
@@ -149,7 +153,7 @@ pnpm smoke:player out.wav # 本机播放器,音量 0
|
|
|
149
153
|
pnpm smoke:chain http://127.0.0.1:9883 # 触发→过滤→经服务合成→tts.voice 全链路
|
|
150
154
|
```
|
|
151
155
|
|
|
152
|
-
装进 Cortico 后应看到:World 页多一张「Kmch AI · 语音」卡,服务灯、队列灯与 `service` / `model` / `voice`
|
|
156
|
+
装进 Cortico 后应看到:World 页多一张「Kmch AI · 语音」卡,服务灯、队列灯与 `service` / `model` / `voice` 徽标;「模型」tab 能选模型与音色、按服务参数渲染字段并保存;服务在线时灯亮;让 bot 调 `voice_speak` 能听见;说「念给我听」会绕过概率;`playback: event` 时时间线出现 `tts.voice`。
|
|
153
157
|
|
|
154
158
|
## 许可
|
|
155
159
|
|
package/describe.json
CHANGED
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"key": "worlds.voice.envPrompt",
|
|
127
127
|
"title": "语音 · 环境提示词",
|
|
128
128
|
"role": "envPrompt",
|
|
129
|
-
"content": "语音由 Voice 提供:它只把文字变成声音,从对方的扬声器念出来,不负责把回复发到聊天渠道;可见的回复仍然走渠道自己的发送工具。\n\n自动朗读现在是 {{voice.auto}}(音色 {{voice.voice}},情绪 {{voice.emotion}},指令 {{voice.instruct}})。自动朗读开着时,普通回复不必调用语音工具;关着时,想出声就调用 voice_speak。\n\n需要出声的时候:\n- 对方明确要语音、要你念出来、要听你的声音时,调用 voice_speak,把要念的正文放进 text。它会绕过概率,必定出声。\n- 这一句需要特定情绪、语气、音色或语速时,调用 voice_speak 并填对应参数。想让同样的语气之后一直生效,调用 voice_style;它影响之后的自动朗读。\n- 某一句只用于别的渠道(例如语音条),不要在本机播放:voice_speak 带 silent: true,音频随回执交给渠道。\n- 对方让你安静、停下时调用 voice_hush。\n\n文本规则:\n- text 只放真正要念的正文,不要放动作描写、Markdown、代码、网址或解释。\n-
|
|
129
|
+
"content": "语音由 Voice 提供:它只把文字变成声音,从对方的扬声器念出来,不负责把回复发到聊天渠道;可见的回复仍然走渠道自己的发送工具。\n\n自动朗读现在是 {{voice.auto}}(音色 {{voice.voice}},情绪 {{voice.emotion}},指令 {{voice.instruct}})。自动朗读开着时,普通回复不必调用语音工具;关着时,想出声就调用 voice_speak。\n\n需要出声的时候:\n- 对方明确要语音、要你念出来、要听你的声音时,调用 voice_speak,把要念的正文放进 text。它会绕过概率,必定出声。\n- 这一句需要特定情绪、语气、音色或语速时,调用 voice_speak 并填对应参数。想让同样的语气之后一直生效,调用 voice_style;它影响之后的自动朗读。\n- 某一句只用于别的渠道(例如语音条),不要在本机播放:voice_speak 带 silent: true,音频随回执交给渠道。\n- 对方让你安静、停下时调用 voice_hush。\n\n文本规则:\n- text 只放真正要念的正文,不要放动作描写、Markdown、代码、网址或解释。\n- {{voice.modelPrompt}}\n- 不要把控制标记写进可见的回复正文;语音参数只放在工具调用里。\n"
|
|
130
130
|
}
|
|
131
131
|
]
|
|
132
132
|
}
|
package/dist/webui.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.vx-top{display:flex;flex-wrap:wrap;align-items:end;gap:10px}.vx-pick{display:flex;flex:1 1 160px;min-width:0;flex-direction:column;gap:4px}.vx-pick-label{font:var(--text-detail)/1.2 var(--sans);color:var(--ink-soft)}.vx-pick select.field{width:100%;box-sizing:border-box}.vx-top>.btn{margin-left:auto;align-self:end}.vx-params{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px 18px;margin-top:14px}.vx-params:empty{display:none}.vx-field{display:flex;min-width:0;flex-direction:column;gap:5px}.vx-head{display:flex;align-items:baseline;gap:7px}.vx-label{font:13px/1.3 var(--sans);color:var(--ink)}.vx-name{font:11px/1.3 var(--mono);color:var(--ink-soft)}.vx-field>.field,.vx-field>label.check{width:100%;box-sizing:border-box}.vx-desc{font:12px/1.4 var(--sans);color:var(--ink-soft)}.vx-params+.actions{margin-top:14px}.vx-prompt{margin:0;padding:10px 12px;max-height:260px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.55 var(--mono);color:var(--ink-soft);background:var(--sheet-2);border:1px solid var(--line);border-radius:8px}
|
package/dist/webui.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var 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};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kmchai/cortico-world-voice",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Cortico World: voice tools, probabilistic reading, emotion and paralinguistic tags, text filtering and local playback; speech is synthesized by the Kmch AI service over HTTP",
|
|
6
6
|
"author": "kmchai",
|
|
@@ -49,23 +49,29 @@
|
|
|
49
49
|
"requires-extension",
|
|
50
50
|
"experimental"
|
|
51
51
|
],
|
|
52
|
-
"describe": "describe.json"
|
|
52
|
+
"describe": "describe.json",
|
|
53
|
+
"webUiClient": "dist/webui.js",
|
|
54
|
+
"webUiStyle": "dist/webui.css"
|
|
53
55
|
},
|
|
54
56
|
"files": [
|
|
55
57
|
"src",
|
|
58
|
+
"dist/webui.js",
|
|
59
|
+
"dist/webui.css",
|
|
56
60
|
"README.md",
|
|
57
61
|
"CHANGELOG.md",
|
|
58
62
|
"describe.json"
|
|
59
63
|
],
|
|
60
64
|
"scripts": {
|
|
61
65
|
"test": "vitest run",
|
|
62
|
-
"typecheck": "tsc --noEmit",
|
|
66
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.webui.json",
|
|
67
|
+
"build:webui": "node scripts/build-webui.mjs",
|
|
63
68
|
"smoke:player": "tsx scripts/smoke-player.ts",
|
|
64
69
|
"smoke:chain": "tsx scripts/smoke-chain.ts"
|
|
65
70
|
},
|
|
66
71
|
"devDependencies": {
|
|
67
72
|
"@types/node": "^22.10.0",
|
|
68
73
|
"cortico": "^0.2.12",
|
|
74
|
+
"esbuild": "^0.28.2",
|
|
69
75
|
"tsx": "^4.19.2",
|
|
70
76
|
"typescript": "^5.7.2",
|
|
71
77
|
"vitest": "^2.1.8"
|
package/src/ENV_PROMPT.md
CHANGED
package/src/config.ts
CHANGED
|
@@ -4,10 +4,8 @@ import { pick, type Language, type LanguageTable } from 'cortico/core/language.t
|
|
|
4
4
|
import type { VoiceConfigSection } from './types.ts';
|
|
5
5
|
|
|
6
6
|
export const VOICE_ID = 'voice';
|
|
7
|
-
/**
|
|
8
|
-
export const
|
|
9
|
-
/** 配置里 `x-options` 用的动态模型表。 */
|
|
10
|
-
export const VOICE_MODELS_KIND = 'voice.models';
|
|
7
|
+
/** World 定义、WebUI 与配置页共用同一个显示名。 */
|
|
8
|
+
export const VOICE_LABEL = 'Kmch AI · 语音';
|
|
11
9
|
|
|
12
10
|
export const VOICE_DEFAULTS: VoiceConfigSection = {
|
|
13
11
|
enabled: false,
|
|
@@ -16,6 +14,7 @@ export const VOICE_DEFAULTS: VoiceConfigSection = {
|
|
|
16
14
|
serviceTokenSecret: '',
|
|
17
15
|
model: '',
|
|
18
16
|
voice: '',
|
|
17
|
+
modelOptions: {},
|
|
19
18
|
format: 'wav',
|
|
20
19
|
timeoutMs: 180_000,
|
|
21
20
|
|
|
@@ -52,19 +51,23 @@ export const VOICE_DEFAULTS: VoiceConfigSection = {
|
|
|
52
51
|
|
|
53
52
|
const TEXT = {
|
|
54
53
|
zh: {
|
|
55
|
-
label: '语音',
|
|
54
|
+
label: 'Kmch AI · 语音',
|
|
56
55
|
envTitle: '语音 · 环境提示词',
|
|
57
56
|
lampService: '语音服务',
|
|
58
57
|
lampQueue: '朗读队列',
|
|
59
58
|
servicePage: '服务设置',
|
|
59
|
+
modelPanel: '模型',
|
|
60
|
+
modelSaveShape: '保存失败:参数格式不对。',
|
|
61
|
+
modelSaveModel: '保存失败:模型 id 不能为空。',
|
|
62
|
+
modelSaveVoice: '保存失败:音色必须是字符串。',
|
|
63
|
+
modelSaveOptions: '保存失败:模型参数必须是对象。',
|
|
64
|
+
modelUnknownMethod: '未知的面板方法。',
|
|
60
65
|
connection: '连接',
|
|
61
66
|
connectionDesc: '语音服务的 HTTP 地址与令牌。',
|
|
62
67
|
serviceUrl: '服务地址',
|
|
63
68
|
serviceUrlDesc: 'Kmch AI · 服务 的网关地址。',
|
|
64
69
|
serviceTokenSecret: '服务令牌变量名',
|
|
65
70
|
serviceTokenSecretDesc: '部署 .env 里的变量名,留空表示服务不校验。',
|
|
66
|
-
model: '模型',
|
|
67
|
-
voice: '音色',
|
|
68
71
|
format: '音频格式',
|
|
69
72
|
timeoutMs: '超时(毫秒)',
|
|
70
73
|
style: '音色与情绪',
|
|
@@ -75,7 +78,7 @@ const TEXT = {
|
|
|
75
78
|
defaultInstruct: '默认表演指令',
|
|
76
79
|
speed: '语速',
|
|
77
80
|
paralinguistic: '副语言标签',
|
|
78
|
-
paralinguisticDesc: 'auto
|
|
81
|
+
paralinguisticDesc: 'auto 只在当前模型支持标签时保留;keep 一律保留;strip 一律从朗读文本里删掉。',
|
|
79
82
|
triggers: '触发',
|
|
80
83
|
triggersDesc: '',
|
|
81
84
|
autoSpeak: '自动朗读',
|
|
@@ -111,19 +114,23 @@ const TEXT = {
|
|
|
111
114
|
shareAudio: '回执带音频附件',
|
|
112
115
|
},
|
|
113
116
|
en: {
|
|
114
|
-
label: '
|
|
117
|
+
label: 'Kmch AI · 语音',
|
|
115
118
|
envTitle: 'Voice · environment prompt',
|
|
116
119
|
lampService: 'Speech service',
|
|
117
120
|
lampQueue: 'Speech queue',
|
|
118
121
|
servicePage: 'Service settings',
|
|
122
|
+
modelPanel: '模型',
|
|
123
|
+
modelSaveShape: 'Save failed: invalid arguments.',
|
|
124
|
+
modelSaveModel: 'Save failed: the model id must not be empty.',
|
|
125
|
+
modelSaveVoice: 'Save failed: voice must be a string.',
|
|
126
|
+
modelSaveOptions: 'Save failed: model options must be an object.',
|
|
127
|
+
modelUnknownMethod: 'Unknown panel method.',
|
|
119
128
|
connection: 'Connection',
|
|
120
129
|
connectionDesc: 'Where the speech service is and which token it expects.',
|
|
121
130
|
serviceUrl: 'Service URL',
|
|
122
131
|
serviceUrlDesc: 'Gateway address of Kmch AI · Service.',
|
|
123
132
|
serviceTokenSecret: 'Service token variable',
|
|
124
133
|
serviceTokenSecretDesc: 'Name in the deployment .env; empty means the service does not check.',
|
|
125
|
-
model: 'Model',
|
|
126
|
-
voice: 'Voice',
|
|
127
134
|
format: 'Audio format',
|
|
128
135
|
timeoutMs: 'Timeout (ms)',
|
|
129
136
|
style: 'Voice and emotion',
|
|
@@ -134,7 +141,7 @@ const TEXT = {
|
|
|
134
141
|
defaultInstruct: 'Default direction',
|
|
135
142
|
speed: 'Speed',
|
|
136
143
|
paralinguistic: 'Paralinguistic tags',
|
|
137
|
-
paralinguisticDesc: 'auto keeps them
|
|
144
|
+
paralinguisticDesc: 'auto keeps them only when the current model supports them; keep always keeps; strip removes them from the spoken text.',
|
|
138
145
|
triggers: 'Triggers',
|
|
139
146
|
triggersDesc: '',
|
|
140
147
|
autoSpeak: 'Automatic speech',
|
|
@@ -192,8 +199,6 @@ export function voiceConfigGroups(language: Language): ConfigGroup[] {
|
|
|
192
199
|
properties: {
|
|
193
200
|
[`${p}serviceUrl`]: { type: 'string', title: t.serviceUrl, description: t.serviceUrlDesc, 'x-hot': true },
|
|
194
201
|
[`${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
202
|
[`${p}format`]: { type: 'string', title: t.format, enum: ['wav', 'mp3'], 'x-hot': true },
|
|
198
203
|
[`${p}timeoutMs`]: { type: 'integer', title: t.timeoutMs, minimum: 1000, maximum: 600_000, 'x-hot': true },
|
|
199
204
|
},
|
package/src/definition.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { describeTools, type WorldDefinition } from 'cortico/world.ts';
|
|
2
|
-
import { VOICE_DEFAULTS, VOICE_ID } from './config.ts';
|
|
3
|
-
import { currentOptions } from './options.ts';
|
|
2
|
+
import { VOICE_DEFAULTS, VOICE_ID, VOICE_LABEL } from './config.ts';
|
|
4
3
|
import { VOICE_TOOL_DECLS } from './tools.ts';
|
|
5
4
|
import type { VoiceConfigSection } from './types.ts';
|
|
6
5
|
import { VoiceWorld } from './world.ts';
|
|
7
6
|
|
|
8
7
|
export const VOICE: WorldDefinition<VoiceConfigSection> = {
|
|
9
8
|
id: VOICE_ID,
|
|
10
|
-
label:
|
|
11
|
-
defaults: () => ({ ...VOICE_DEFAULTS }),
|
|
12
|
-
configOptions: (kind) => currentOptions(kind),
|
|
9
|
+
label: VOICE_LABEL,
|
|
10
|
+
defaults: () => ({ ...VOICE_DEFAULTS, modelOptions: {} }),
|
|
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,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「模型」面板的数据形状。服务端经由 `World.webUi().invoke` 构造,浏览器端只
|
|
3
|
+
* `import type` 这些声明;面板不接触服务地址与令牌。
|
|
4
|
+
*/
|
|
5
|
+
import type { VoiceModel } from './types.ts';
|
|
6
|
+
|
|
7
|
+
export interface PanelModelState {
|
|
8
|
+
/** `worlds.voice.model`。 */
|
|
9
|
+
model: string;
|
|
10
|
+
/** `worlds.voice.voice`。 */
|
|
11
|
+
voice: string;
|
|
12
|
+
/** 当前模型的环境提示词片段,来自 `models.ts`。 */
|
|
13
|
+
prompt: string;
|
|
14
|
+
/** 当前模型已保存的请求参数。 */
|
|
15
|
+
options: Record<string, unknown>;
|
|
16
|
+
/** 服务目录;服务不可达时是上一次可用的结果。 */
|
|
17
|
+
models: VoiceModel[];
|
|
18
|
+
/** 最近一次读取服务目录失败的原因。 */
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `invoke('model','save', [save])` 的请求体。 */
|
|
23
|
+
export interface PanelModelSave {
|
|
24
|
+
model: string;
|
|
25
|
+
voice: string;
|
|
26
|
+
options: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 只认普通对象:数组、null 与类对象都不算。 */
|
|
30
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
32
|
+
const proto = Object.getPrototypeOf(value);
|
|
33
|
+
return proto === Object.prototype || proto === null;
|
|
34
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* 令牌只从 secret() 现读,不写日志、不留副本。
|
|
6
6
|
*/
|
|
7
7
|
import type {
|
|
8
|
-
AudioFormat,
|
|
8
|
+
AudioFormat, ModelParameter, ModelParameterType, ServiceHealth, TtsAudio, TtsRequest,
|
|
9
|
+
VoiceConfigSection, VoiceModel, VoiceOption,
|
|
9
10
|
} from './types.ts';
|
|
10
11
|
|
|
11
12
|
const PROBE_TIMEOUT_MS = 8000;
|
|
@@ -37,6 +38,9 @@ export class ServiceClient implements SpeechService {
|
|
|
37
38
|
};
|
|
38
39
|
const model = cfg.model.trim();
|
|
39
40
|
if (model) body.model = model;
|
|
41
|
+
// 只带当前模型自己的参数;其他模型的配置不混进这次请求。
|
|
42
|
+
const options = cfg.modelOptions?.[model];
|
|
43
|
+
if (options && Object.keys(options).length > 0) body.options = options;
|
|
40
44
|
const voice = (req.voice ?? '').trim() || cfg.voice.trim();
|
|
41
45
|
if (voice) body.voice = voice;
|
|
42
46
|
const instruct = (req.instruct ?? '').trim();
|
|
@@ -96,7 +100,7 @@ export class ServiceClient implements SpeechService {
|
|
|
96
100
|
}
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
async models(): Promise<
|
|
103
|
+
async models(): Promise<VoiceModel[]> {
|
|
100
104
|
const payload = await this.request(
|
|
101
105
|
this.endpoint('/v1/models'),
|
|
102
106
|
{ method: 'GET', headers: this.headers() },
|
|
@@ -105,9 +109,9 @@ export class ServiceClient implements SpeechService {
|
|
|
105
109
|
(res) => this.readJson(res),
|
|
106
110
|
) as { data?: unknown } | unknown[] | null;
|
|
107
111
|
const raw = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : [];
|
|
108
|
-
return raw.flatMap((item):
|
|
109
|
-
const
|
|
110
|
-
return
|
|
112
|
+
return raw.flatMap((item): VoiceModel[] => {
|
|
113
|
+
const model = modelOf(item);
|
|
114
|
+
return model ? [model] : [];
|
|
111
115
|
});
|
|
112
116
|
}
|
|
113
117
|
|
|
@@ -234,6 +238,83 @@ function optionOf(item: unknown): { value: string; label: string } | null {
|
|
|
234
238
|
return { value, label };
|
|
235
239
|
}
|
|
236
240
|
|
|
241
|
+
const PARAMETER_TYPES = new Set<ModelParameterType>([
|
|
242
|
+
'string', 'enum', 'bool', 'int', 'float', 'string_list', 'path', 'path_list',
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
function firstString(...values: unknown[]): string {
|
|
246
|
+
for (const value of values) {
|
|
247
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
248
|
+
}
|
|
249
|
+
return '';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 目录里的音色/取值可能写成字符串,也可能写成对象;只留去重后的字符串。 */
|
|
253
|
+
function stringItems(value: unknown): string[] {
|
|
254
|
+
if (!Array.isArray(value)) return [];
|
|
255
|
+
const out: string[] = [];
|
|
256
|
+
for (const item of value) {
|
|
257
|
+
const text = typeof item === 'string'
|
|
258
|
+
? item.trim()
|
|
259
|
+
: item && typeof item === 'object' && !Array.isArray(item)
|
|
260
|
+
? firstString(
|
|
261
|
+
(item as Record<string, unknown>).value,
|
|
262
|
+
(item as Record<string, unknown>).id,
|
|
263
|
+
(item as Record<string, unknown>).name,
|
|
264
|
+
(item as Record<string, unknown>).label,
|
|
265
|
+
)
|
|
266
|
+
: '';
|
|
267
|
+
if (text && !out.includes(text)) out.push(text);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
273
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parameterOf(item: unknown): ModelParameter | null {
|
|
277
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
278
|
+
const o = item as Record<string, unknown>;
|
|
279
|
+
const name = firstString(o.name);
|
|
280
|
+
const type = firstString(o.type) as ModelParameterType;
|
|
281
|
+
if (!name || !PARAMETER_TYPES.has(type)) return null;
|
|
282
|
+
const description = firstString(o.description);
|
|
283
|
+
const values = stringItems(o.values);
|
|
284
|
+
const min = finiteNumber(o.min);
|
|
285
|
+
const max = finiteNumber(o.max);
|
|
286
|
+
return {
|
|
287
|
+
name,
|
|
288
|
+
type,
|
|
289
|
+
...(description ? { description } : {}),
|
|
290
|
+
...(values.length ? { values } : {}),
|
|
291
|
+
...(o.default !== undefined ? { default: o.default } : {}),
|
|
292
|
+
...(min !== undefined ? { min } : {}),
|
|
293
|
+
...(max !== undefined ? { max } : {}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 服务目录的一项:模型 id、显示名、音色与请求参数;不认识的字段不带走。 */
|
|
298
|
+
function modelOf(item: unknown): VoiceModel | null {
|
|
299
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
300
|
+
const o = item as Record<string, unknown>;
|
|
301
|
+
const id = firstString(o.id, o.value, o.name);
|
|
302
|
+
if (!id) return null;
|
|
303
|
+
const label = firstString(o.display_name, o.displayName, o.label);
|
|
304
|
+
const parameters = Array.isArray(o.parameters)
|
|
305
|
+
? o.parameters.flatMap((entry): ModelParameter[] => {
|
|
306
|
+
const parameter = parameterOf(entry);
|
|
307
|
+
return parameter ? [parameter] : [];
|
|
308
|
+
})
|
|
309
|
+
: [];
|
|
310
|
+
return {
|
|
311
|
+
id,
|
|
312
|
+
...(label ? { label } : {}),
|
|
313
|
+
voices: stringItems(o.voices),
|
|
314
|
+
...(parameters.length ? { parameters } : {}),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
237
318
|
/** 音频回应只接受 wav / mpeg;octet-stream 按请求的格式归一。 */
|
|
238
319
|
function normalizeMime(raw: string | null, format: AudioFormat): string | null {
|
|
239
320
|
const type = (raw ?? '').split(';')[0]!.trim().toLowerCase();
|
package/src/speech/tags.ts
CHANGED
|
@@ -57,10 +57,15 @@ export function stripUnknownTags(text: string): string {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* auto
|
|
60
|
+
* auto 只在当前模型支持标签时保留;keep 一律保留;strip 一律删掉。
|
|
61
61
|
*/
|
|
62
|
-
export function keepsParalinguistic(
|
|
63
|
-
|
|
62
|
+
export function keepsParalinguistic(
|
|
63
|
+
mode: 'auto' | 'keep' | 'strip',
|
|
64
|
+
tagsSupported: boolean,
|
|
65
|
+
): boolean {
|
|
66
|
+
if (mode === 'strip') return false;
|
|
67
|
+
if (mode === 'keep') return true;
|
|
68
|
+
return tagsSupported;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
const EMOTION_MARKERS: ReadonlyArray<RegExp> = [
|
package/src/speech/text.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** 把一段回复变成能念的正文:取 `<tts>`、去代码/网址/Markdown/动作标记/符号。 */
|
|
2
|
+
import { modelSupportsTags } from '../models.ts';
|
|
2
3
|
import type { VoiceConfigSection } from '../types.ts';
|
|
3
4
|
import {
|
|
4
5
|
canonicalizeTags,
|
|
@@ -86,7 +87,9 @@ export function filterSpeech(raw: string, cfg: VoiceConfigSection): FilteredSpee
|
|
|
86
87
|
|
|
87
88
|
text = canonicalizeTags(text);
|
|
88
89
|
text = stripUnknownTags(text);
|
|
89
|
-
if (!keepsParalinguistic(cfg.paralinguistic))
|
|
90
|
+
if (!keepsParalinguistic(cfg.paralinguistic, modelSupportsTags(cfg.model))) {
|
|
91
|
+
text = stripParalinguisticTags(text);
|
|
92
|
+
}
|
|
90
93
|
|
|
91
94
|
if (cfg.filterEmoji) text = text.replace(EMOJI_RE, '').replace(SYMBOL_RE, '');
|
|
92
95
|
|
package/src/types.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface VoiceConfigSection {
|
|
|
18
18
|
serviceTokenSecret: string;
|
|
19
19
|
model: string;
|
|
20
20
|
voice: string;
|
|
21
|
+
/** 模型专属的 request 参数,按模型 id 分组;由「模型」面板写回,不进 ConfigGroup。 */
|
|
22
|
+
modelOptions: Record<string, Record<string, unknown>>;
|
|
21
23
|
format: AudioFormat;
|
|
22
24
|
timeoutMs: number;
|
|
23
25
|
|
|
@@ -90,7 +92,36 @@ export interface SelectOption {
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
export type VoiceOption = SelectOption;
|
|
93
|
-
|
|
95
|
+
|
|
96
|
+
/** 服务 `GET /v1/models` 里 `parameters[].type` 的取值。 */
|
|
97
|
+
export type ModelParameterType =
|
|
98
|
+
| 'string'
|
|
99
|
+
| 'enum'
|
|
100
|
+
| 'bool'
|
|
101
|
+
| 'int'
|
|
102
|
+
| 'float'
|
|
103
|
+
| 'string_list'
|
|
104
|
+
| 'path'
|
|
105
|
+
| 'path_list';
|
|
106
|
+
|
|
107
|
+
/** 模型请求参数的元数据;服务按模型给出,面板据此渲染字段。 */
|
|
108
|
+
export interface ModelParameter {
|
|
109
|
+
name: string;
|
|
110
|
+
type: ModelParameterType;
|
|
111
|
+
description?: string;
|
|
112
|
+
values?: string[];
|
|
113
|
+
default?: unknown;
|
|
114
|
+
min?: number;
|
|
115
|
+
max?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 服务目录里的一项模型;音色与请求参数元数据随模型走。 */
|
|
119
|
+
export interface VoiceModel {
|
|
120
|
+
id: string;
|
|
121
|
+
label?: string;
|
|
122
|
+
voices: string[];
|
|
123
|
+
parameters?: ModelParameter[];
|
|
124
|
+
}
|
|
94
125
|
|
|
95
126
|
/** `GET /health` 的摘要;`ok` 只表示网关有回应,`ready` 表示至少有一个服务能合成。 */
|
|
96
127
|
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,
|
|
17
|
+
import { VOICE_DEFAULTS, VOICE_ID, voiceConfigGroups, voiceText } from './config.ts';
|
|
18
18
|
import type { AudioPlayer } from './audio/player.ts';
|
|
19
19
|
import { createPlayer } from './audio/player.ts';
|
|
20
20
|
import { SpeechQueue, type SpeechJob } from './audio/queue.ts';
|
|
21
|
-
import {
|
|
21
|
+
import { modelPrompt } from './models.ts';
|
|
22
|
+
import { isPlainObject, type 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,13 @@ import { filterSpeech } from './speech/text.ts';
|
|
|
26
27
|
import { EventPoller, eventTypes, hasKeyword, latestUserText, splitList, VoiceTap } from './triggers.ts';
|
|
27
28
|
import { VOICE_TOOL_DECLS } from './tools.ts';
|
|
28
29
|
import type {
|
|
29
|
-
|
|
30
|
+
ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection, VoiceModel,
|
|
30
31
|
} from './types.ts';
|
|
31
32
|
|
|
32
33
|
const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
|
|
33
34
|
const DEDUPE_WINDOW_MS = 5000;
|
|
34
35
|
const HEALTH_INTERVAL_MS = 15_000;
|
|
35
36
|
const HEALTH_TTL_MS = 15_000;
|
|
36
|
-
const CATALOG_TTL_MS = 10 * 60_000;
|
|
37
37
|
|
|
38
38
|
export interface VoiceWorldDeps {
|
|
39
39
|
fetch: typeof fetch;
|
|
@@ -130,18 +130,11 @@ export class VoiceWorld implements World {
|
|
|
130
130
|
private tempSeq = 0;
|
|
131
131
|
private serviceState: ServiceState | null = null;
|
|
132
132
|
private probing = false;
|
|
133
|
-
private
|
|
134
|
-
private
|
|
135
|
-
private
|
|
136
|
-
private voicesLoading = false;
|
|
137
|
-
private models: ModelOption[] = [];
|
|
138
|
-
private modelsAt = 0;
|
|
139
|
-
private modelsSignature = '';
|
|
140
|
-
private modelsLoading = false;
|
|
133
|
+
private catalog: VoiceModel[] | null = null;
|
|
134
|
+
private catalogError: string | undefined;
|
|
135
|
+
private catalogLoad: Promise<void> | null = null;
|
|
141
136
|
private readonly lastSpoken = new Map<string, number>();
|
|
142
137
|
private lastFailureAt = 0;
|
|
143
|
-
private readonly modelOptions = (): readonly ModelOption[] => this.models;
|
|
144
|
-
private readonly voiceOptions = (): readonly VoiceOption[] => this.voices;
|
|
145
138
|
|
|
146
139
|
constructor(private readonly opts: VoiceWorldOptions) {
|
|
147
140
|
this.log = opts.log ?? nullLogger();
|
|
@@ -180,6 +173,7 @@ export class VoiceWorld implements World {
|
|
|
180
173
|
'voice.voice': cfg.voice || 'default',
|
|
181
174
|
'voice.emotion': cfg.defaultEmotion || 'neutral',
|
|
182
175
|
'voice.instruct': cfg.defaultInstruct || 'none',
|
|
176
|
+
'voice.modelPrompt': modelPrompt(cfg.model),
|
|
183
177
|
};
|
|
184
178
|
}
|
|
185
179
|
|
|
@@ -224,8 +218,22 @@ export class VoiceWorld implements World {
|
|
|
224
218
|
{ label: 'model', value: cfg.model || '—', tone: 'plain' },
|
|
225
219
|
{ label: 'voice', value: cfg.voice || '—', tone: 'plain' },
|
|
226
220
|
],
|
|
221
|
+
panels: [{ id: 'model', title: t.modelPanel, getMethods: ['state'] }],
|
|
227
222
|
config: voiceConfigGroups(language),
|
|
228
223
|
links: [{ label: t.servicePage, href: '#/provider/world:service' }],
|
|
224
|
+
invoke: async (panel, method, args) => {
|
|
225
|
+
if (panel !== 'model') throw new Error(t.modelUnknownMethod);
|
|
226
|
+
switch (method) {
|
|
227
|
+
case 'state':
|
|
228
|
+
return this.modelPanelState();
|
|
229
|
+
case 'refresh':
|
|
230
|
+
return this.refreshModelPanel();
|
|
231
|
+
case 'save':
|
|
232
|
+
return this.saveModelPanel(language, args);
|
|
233
|
+
default:
|
|
234
|
+
throw new Error(t.modelUnknownMethod);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
229
237
|
promptDocs: [{
|
|
230
238
|
key: 'worlds.voice.envPrompt',
|
|
231
239
|
title: t.envTitle,
|
|
@@ -237,11 +245,111 @@ export class VoiceWorld implements World {
|
|
|
237
245
|
{ name: 'voice.voice', description: 'Current voice id.' },
|
|
238
246
|
{ name: 'voice.emotion', description: 'Standing emotion.' },
|
|
239
247
|
{ name: 'voice.instruct', description: 'Standing direction.' },
|
|
248
|
+
{ name: 'voice.modelPrompt', description: 'How to write text and emotion for the current model.' },
|
|
240
249
|
],
|
|
241
250
|
}],
|
|
242
251
|
};
|
|
243
252
|
}
|
|
244
253
|
|
|
254
|
+
// ———————— 模型面板 ————————
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 第一次读面板时拉一次目录;之后直接回缓存。刷新失败保留上一次可用的目录,
|
|
258
|
+
* 只把错误一起交回面板。
|
|
259
|
+
*/
|
|
260
|
+
private async modelPanelState(): Promise<PanelModelState> {
|
|
261
|
+
if (!this.catalog) await this.loadCatalog(this.cfg.model);
|
|
262
|
+
return this.panelSnapshot();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private async refreshModelPanel(): Promise<PanelModelState> {
|
|
266
|
+
if (this.catalogLoad) await this.catalogLoad;
|
|
267
|
+
await this.loadCatalog(this.cfg.model);
|
|
268
|
+
return this.panelSnapshot();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private panelSnapshot(): PanelModelState {
|
|
272
|
+
const cfg = this.cfg;
|
|
273
|
+
const saved = cfg.modelOptions?.[cfg.model];
|
|
274
|
+
return {
|
|
275
|
+
model: cfg.model,
|
|
276
|
+
voice: cfg.voice,
|
|
277
|
+
prompt: modelPrompt(cfg.model),
|
|
278
|
+
options: isPlainObject(saved) ? { ...saved } : {},
|
|
279
|
+
models: this.catalog ?? [],
|
|
280
|
+
...(this.catalogError ? { error: this.catalogError } : {}),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private loadCatalog(selected: string): Promise<void> {
|
|
285
|
+
if (this.catalogLoad) return this.catalogLoad;
|
|
286
|
+
this.catalogLoad = this.fetchCatalog(selected).finally(() => {
|
|
287
|
+
this.catalogLoad = null;
|
|
288
|
+
});
|
|
289
|
+
return this.catalogLoad;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 先取模型表,再尽力为当前模型补一次更细的音色表。模型表是权威来源,已经带
|
|
294
|
+
* 音色与参数;补音色失败只损失一次补全,不能让整份目录跟着作废。
|
|
295
|
+
*/
|
|
296
|
+
private async fetchCatalog(selected: string): Promise<void> {
|
|
297
|
+
let models: VoiceModel[];
|
|
298
|
+
try {
|
|
299
|
+
models = await this.service.models();
|
|
300
|
+
} catch (error) {
|
|
301
|
+
this.catalogError = this.catalogFailure(error);
|
|
302
|
+
this.markService({ ok: false, detail: this.catalogError });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const id = selected.trim() || this.cfg.model.trim() || models[0]?.id || '';
|
|
307
|
+
let catalog = models;
|
|
308
|
+
if (id) {
|
|
309
|
+
try {
|
|
310
|
+
const voices = await this.service.voices(id);
|
|
311
|
+
const names = [...new Set(voices.map((voice) => voice.value.trim()).filter(Boolean))];
|
|
312
|
+
if (names.length > 0) {
|
|
313
|
+
catalog = models.map((model) => (model.id === id ? { ...model, voices: names } : model));
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
this.log.debug('音色表读取失败,保留模型表里的音色', {
|
|
317
|
+
err: error instanceof Error ? error.message : String(error),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.catalog = catalog;
|
|
323
|
+
this.catalogError = undefined;
|
|
324
|
+
this.markService({ ok: true });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 错误要回面板,但不能把配置里的服务地址(可能带令牌)一起带出去。 */
|
|
328
|
+
private catalogFailure(error: unknown): string {
|
|
329
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
330
|
+
const url = this.cfg.serviceUrl.trim();
|
|
331
|
+
return url && message.includes(url) ? message.split(url).join('[service]') : message;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private saveModelPanel(language: Language, args: unknown[]): PanelModelState {
|
|
335
|
+
const t = voiceText(language);
|
|
336
|
+
const raw = args[0];
|
|
337
|
+
if (!isPlainObject(raw)) throw new Error(t.modelSaveShape);
|
|
338
|
+
const model = typeof raw.model === 'string' ? raw.model.trim() : '';
|
|
339
|
+
if (!model) throw new Error(t.modelSaveModel);
|
|
340
|
+
if (typeof raw.voice !== 'string') throw new Error(t.modelSaveVoice);
|
|
341
|
+
if (!isPlainObject(raw.options)) throw new Error(t.modelSaveOptions);
|
|
342
|
+
|
|
343
|
+
const next: VoiceConfigSection['modelOptions'] = { ...(this.cfg.modelOptions ?? {}) };
|
|
344
|
+
// 空对象表示删掉这个模型的条目,回到服务的默认参数。
|
|
345
|
+
if (Object.keys(raw.options).length === 0) delete next[model];
|
|
346
|
+
else next[model] = { ...raw.options };
|
|
347
|
+
// 配置层对对象做深合并,删掉的键不会随 patch 传播;先让活引用指向新对象再写盘。
|
|
348
|
+
this.cfg.modelOptions = next;
|
|
349
|
+
this.opts.persist({ model, voice: raw.voice.trim(), modelOptions: next });
|
|
350
|
+
return this.panelSnapshot();
|
|
351
|
+
}
|
|
352
|
+
|
|
245
353
|
outputTap() {
|
|
246
354
|
const cfg = this.cfg;
|
|
247
355
|
if (!cfg.autoSpeak) return undefined;
|
|
@@ -252,7 +360,6 @@ export class VoiceWorld implements World {
|
|
|
252
360
|
async start(host: WorldHost): Promise<void> {
|
|
253
361
|
this.host = host;
|
|
254
362
|
this.started = true;
|
|
255
|
-
this.registerCatalogOptions();
|
|
256
363
|
this.log.info('语音 World 启动', {
|
|
257
364
|
service: serviceHost(this.cfg.serviceUrl),
|
|
258
365
|
playback: this.cfg.playback,
|
|
@@ -271,7 +378,6 @@ export class VoiceWorld implements World {
|
|
|
271
378
|
|
|
272
379
|
async stop(): Promise<void> {
|
|
273
380
|
this.started = false;
|
|
274
|
-
this.unregisterCatalogOptions();
|
|
275
381
|
if (this.healthTimer) clearInterval(this.healthTimer);
|
|
276
382
|
this.healthTimer = null;
|
|
277
383
|
this.poller?.stop();
|
|
@@ -404,7 +510,6 @@ export class VoiceWorld implements World {
|
|
|
404
510
|
}
|
|
405
511
|
if (Object.keys(patch).length > 0) {
|
|
406
512
|
this.opts.persist(patch);
|
|
407
|
-
if (patch.voice !== undefined) void this.refreshVoices(true);
|
|
408
513
|
return { text: `Voice style updated. ${this.styleSummary()}` };
|
|
409
514
|
}
|
|
410
515
|
return { text: this.styleSummary() };
|
|
@@ -527,7 +632,7 @@ export class VoiceWorld implements World {
|
|
|
527
632
|
} catch { /* 只归档,失败就只留日志 */ }
|
|
528
633
|
}
|
|
529
634
|
|
|
530
|
-
// ————————
|
|
635
|
+
// ———————— 状态 ————————
|
|
531
636
|
|
|
532
637
|
private markService(state: Pick<ServiceState, 'ok' | 'detail'>): void {
|
|
533
638
|
const previous = this.serviceState;
|
|
@@ -540,8 +645,6 @@ export class VoiceWorld implements World {
|
|
|
540
645
|
|
|
541
646
|
private async probe(): Promise<void> {
|
|
542
647
|
if (this.probing) return;
|
|
543
|
-
// 探活会刷新目录;每次探活都重新登记,停用后重新启用的实例也能接上。
|
|
544
|
-
this.registerCatalogOptions();
|
|
545
648
|
this.probing = true;
|
|
546
649
|
try {
|
|
547
650
|
const health: ServiceHealth = await this.service.health();
|
|
@@ -554,9 +657,6 @@ export class VoiceWorld implements World {
|
|
|
554
657
|
} finally {
|
|
555
658
|
this.probing = false;
|
|
556
659
|
}
|
|
557
|
-
// 探活顺带刷新目录;读不到时 refresh 会保留上一次的表。
|
|
558
|
-
await this.refreshVoices(true);
|
|
559
|
-
await this.refreshModels(true);
|
|
560
660
|
}
|
|
561
661
|
|
|
562
662
|
private maybeRefresh(): void {
|
|
@@ -565,56 +665,6 @@ export class VoiceWorld implements World {
|
|
|
565
665
|
if (!this.probing && (this.serviceState === null || now - this.serviceState.at > HEALTH_TTL_MS)) {
|
|
566
666
|
void this.probe();
|
|
567
667
|
}
|
|
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
668
|
}
|
|
619
669
|
|
|
620
670
|
private syncPoller(): void {
|
|
@@ -655,4 +705,4 @@ export class VoiceWorld implements World {
|
|
|
655
705
|
}
|
|
656
706
|
}
|
|
657
707
|
|
|
658
|
-
export { VOICE_DEFAULTS, VOICE_ID
|
|
708
|
+
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
|
-
}
|