@kmchai/cortico-world-voice 0.2.1 → 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 +8 -0
- package/README.md +6 -7
- package/dist/webui.js +1 -1
- package/package.json +1 -1
- package/src/config.ts +25 -34
- package/src/definition.ts +1 -1
- package/src/panel.ts +16 -2
- package/src/types.ts +15 -1
- package/src/world.ts +122 -29
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
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
|
+
|
|
1
9
|
## 0.2.1
|
|
2
10
|
|
|
3
11
|
- 新增 World 页的「模型」面板:模型与音色在面板里选,不再占系统 Config tab 的字段。
|
package/README.md
CHANGED
|
@@ -34,7 +34,6 @@ corepack pnpm add "D:/path/to/cortico-world-voice" --ignore-workspace
|
|
|
34
34
|
"serviceUrl": "http://127.0.0.1:9883",
|
|
35
35
|
"serviceTokenSecret": "",
|
|
36
36
|
"model": "ming3-tts",
|
|
37
|
-
"voice": "4016",
|
|
38
37
|
"playback": "local",
|
|
39
38
|
"autoSpeak": true,
|
|
40
39
|
"autoProbability": 0.3
|
|
@@ -47,7 +46,7 @@ corepack pnpm add "D:/path/to/cortico-world-voice" --ignore-workspace
|
|
|
47
46
|
|
|
48
47
|
## 模型面板
|
|
49
48
|
|
|
50
|
-
World 页的「模型」tab 选模型与音色,并按服务 `/v1/models`
|
|
49
|
+
World 页的「模型」tab 选模型与音色,并按服务 `/v1/models` 返回的参数元数据渲染字段。同一页的「声音与情绪」按模型保存情绪控制方式、默认情绪、默认表演指令、语速与副语言标签策略;切换模型时读回该模型自己的设置。模型设置存 `worlds.voice.modelStyles[model]`,模型参数存 `worlds.voice.modelOptions[model]`;合成时作为 `options` 发给服务。模型专属的文本与情绪写法显示在同页的「模型提示词」折叠区。系统 Config tab 只留服务地址、触发、过滤与输出。
|
|
51
50
|
|
|
52
51
|
## 工具
|
|
53
52
|
|
|
@@ -82,7 +81,7 @@ World 页的「模型」tab 选模型与音色,并按服务 `/v1/models` 返回
|
|
|
82
81
|
|
|
83
82
|
## 情绪、语速与标签
|
|
84
83
|
|
|
85
|
-
|
|
84
|
+
情绪与语速设置按模型保存在「模型」页,不再出现在系统 Config tab。语音只把语义字段发给服务,由服务的模型 profile 决定怎么落到具体模型:
|
|
86
85
|
|
|
87
86
|
| `emotionMode` | 语音发送 | `audiocpp-ming3` profile 的落点 |
|
|
88
87
|
|---|---|---|
|
|
@@ -96,8 +95,8 @@ OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求
|
|
|
96
95
|
|
|
97
96
|
`response_format` 每次都带;`model` 与 `voice` 配了才带,工具参数里的音色优先于配置默认值。
|
|
98
97
|
|
|
99
|
-
- 情绪来源:工具参数 > 正文里的 `[emotion:开心]` 标记 > `defaultEmotion`。
|
|
100
|
-
- `voice_style`
|
|
98
|
+
- 情绪来源:工具参数 > 正文里的 `[emotion:开心]` 标记 > 当前模型的 `defaultEmotion`。
|
|
99
|
+
- `voice_style` 读写当前模型的长期风格(情绪、指令、音色、语速),写进 `modelStyles[model]`;还没选模型时写到全局回落值。
|
|
101
100
|
- 副语言标签:`[Breathing] [Cough] [Sigh] [Laughter] [Surprise-ah] [Surprise-oh] [Dissatisfaction-hnn] [Uhm] [Confirmation-en] [Question-ei] [Question-ah] [Question-en] [Question-oh] [Question-huh]`。
|
|
102
101
|
`paralinguistic: auto` 只在当前模型支持标签(Ming 3.0 系)时保留,IndexTTS2 与通用模型剥掉;`keep` 一律保留,`strip` 一律删除。表外 `[...]` 会从朗读文本里剥掉,防止被念出来。
|
|
103
102
|
|
|
@@ -141,7 +140,7 @@ OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求
|
|
|
141
140
|
| `httpApi` / `httpPort` / `httpToken` / `httpPlay` | 删除;服务的网关取代本地语音接口。 |
|
|
142
141
|
| `singUrl` / `singToken`、`voice_sing`、`【唱】` | 删除;唱歌由服务的 `/v1/audio/sing` 与网关接入。 |
|
|
143
142
|
| `model` / `voice` / `format` / `timeoutMs` | 保留,含义不变。 |
|
|
144
|
-
| `emotionMode` / `defaultEmotion` / `defaultInstruct` / `speed` / `paralinguistic` |
|
|
143
|
+
| `emotionMode` / `defaultEmotion` / `defaultInstruct` / `speed` / `paralinguistic` | 移到「模型」页,按模型保存;旧全局值仍作为没有模型设置时的回落。 |
|
|
145
144
|
| 触发、过滤、输出配置 | 保留。 |
|
|
146
145
|
|
|
147
146
|
## 验证
|
|
@@ -153,7 +152,7 @@ pnpm smoke:player out.wav # 本机播放器,音量 0
|
|
|
153
152
|
pnpm smoke:chain http://127.0.0.1:9883 # 触发→过滤→经服务合成→tts.voice 全链路
|
|
154
153
|
```
|
|
155
154
|
|
|
156
|
-
装进 Cortico 后应看到:World 页多一张「Kmch AI · 语音」卡,服务灯、队列灯与 `service` / `model` / `voice` 徽标;「模型」tab
|
|
155
|
+
装进 Cortico 后应看到:World 页多一张「Kmch AI · 语音」卡,服务灯、队列灯与 `service` / `model` / `voice` 徽标;「模型」tab 能选模型与音色,按模型调整声音与情绪,并按服务参数渲染字段、保存;服务在线时灯亮;让 bot 调 `voice_speak` 能听见;说「念给我听」会绕过概率;`playback: event` 时时间线出现 `tts.voice`。
|
|
157
156
|
|
|
158
157
|
## 许可
|
|
159
158
|
|
package/dist/webui.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var B={zh:{title:"\u6A21\u578B",model:"\u6A21\u578B",voice:"\u97F3\u8272",refresh:"\u5237\u65B0",save:"\u4FDD\u5B58",saved:"\u5DF2\u4FDD\u5B58",loading:"\u8BFB\u53D6\u4E2D\u2026",promptTitle:"\u6A21\u578B\u63D0\u793A\u8BCD",styleTitle:"\u58F0\u97F3\u4E0E\u60C5\u7EEA",paramsTitle:"\u6A21\u578B\u53C2\u6570",emotionMode:"\u60C5\u7EEA\u63A7\u5236",defaultEmotion:"\u9ED8\u8BA4\u60C5\u7EEA",defaultInstruct:"\u9ED8\u8BA4\u8868\u6F14\u6307\u4EE4",speed:"\u8BED\u901F",paralinguistic:"\u526F\u8BED\u8A00\u6807\u7B7E",emotionModeOptions:[{value:"instruct",label:"\u81EA\u7136\u8BED\u8A00"},{value:"categorical",label:"\u60C5\u7EEA\u6807\u7B7E"},{value:"basic",label:"\u8BED\u901F"},{value:"tags",label:"\u6B63\u6587\u6807\u7B7E"},{value:"none",label:"\u4E0D\u53D1\u9001"}],paralinguisticOptions:[{value:"auto",label:"\u81EA\u52A8"},{value:"keep",label:"\u4FDD\u7559"},{value:"strip",label:"\u5220\u9664"}]},en:{title:"\u6A21\u578B",model:"Model",voice:"Voice",refresh:"Refresh",save:"Save",saved:"Saved",loading:"Loading\u2026",promptTitle:"Model prompt",styleTitle:"Voice and emotion",paramsTitle:"Model parameters",emotionMode:"Emotion control",defaultEmotion:"Default emotion",defaultInstruct:"Default direction",speed:"Speed",paralinguistic:"Paralinguistic tags",emotionModeOptions:[{value:"instruct",label:"Natural language"},{value:"categorical",label:"Emotion label"},{value:"basic",label:"Speed"},{value:"tags",label:"Inline tags"},{value:"none",label:"Send nothing"}],paralinguisticOptions:[{value:"auto",label:"Auto"},{value:"keep",label:"Keep"},{value:"strip",label:"Strip"}]}};function j(e){return e.toLowerCase().startsWith("zh")?B.zh:B.en}var D={template_name:"\u6A21\u677F",language:"\u8BED\u8A00",instruction_schema:"\u6307\u4EE4\u8BED\u8A00",pitch:"\u97F3\u9AD8",volume:"\u97F3\u91CF",guidance_scale:"\u5F15\u5BFC\u5F3A\u5EA6",sigma:"\u566A\u58F0\u5C3A\u5EA6",raw_text:"\u6574\u6BB5\u53D1\u9001",trim_silence:"\u4FEE\u526A\u9759\u97F3",emotion_alpha:"\u60C5\u7EEA\u5F3A\u5EA6",emotion_text:"\u60C5\u7EEA\u63CF\u8FF0",use_emotion_text:"\u4F7F\u7528\u6587\u5B57\u60C5\u7EEA",use_random_emotion:"\u968F\u673A\u60C5\u7EEA",duration_scale:"\u65F6\u957F\u7F29\u653E",interval_silence_ms:"\u5206\u6BB5\u9759\u97F3",do_sample:"\u91C7\u6837",top_p:"top_p",top_k:"top_k",temperature:"\u6E29\u5EA6",length_penalty:"\u957F\u5EA6\u60E9\u7F5A",num_beams:"beam \u6570",repetition_penalty:"\u91CD\u590D\u60E9\u7F5A",max_tokens:"\u6700\u5927 token",seed:"\u79CD\u5B50",text_chunk_size:"\u6587\u672C\u5206\u5757\u5927\u5C0F",text_chunk_mode:"\u5206\u5757\u6A21\u5F0F"};var Q={voice:"",emotionMode:"instruct",defaultEmotion:"",defaultInstruct:"",speed:1,paralinguistic:"auto"};function Z(e,t){return Object.prototype.hasOwnProperty.call(t,e.name)?t[e.name]:e.default}function O(e){return e==null?"":Array.isArray(e)?e.map(t=>String(t)).join(", "):String(e)}function $(e){return typeof e=="number"&&Number.isFinite(e)?String(e):typeof e=="string"&&e.trim()&&Number.isFinite(Number(e))?e.trim():""}function ee(e,t,l){let n=e.ui,i=n.h("div","vx-field"),f=n.h("div","vx-head"),p=D[t.name];p&&p!==t.name?f.append(n.h("span","vx-label",p),n.h("span","vx-name",t.name)):f.append(n.h("span","vx-label",t.name)),i.append(f);let m=Z(t,l),c;switch(t.type){case"bool":{let a=n.checkbox("",{checked:m===!0});i.append(a.el),c=()=>a.checked;break}case"enum":{let a=t.values??[],r=O(m),u=[{value:"",label:"\u2014"},...a.map(y=>({value:y,label:y}))];r&&!a.includes(r)&&u.push({value:r,label:r});let x=n.select({options:u,value:r});i.append(x),c=()=>x.value||void 0;break}case"int":case"float":{let a=n.input({type:"number",value:$(m)});t.min!==void 0&&(a.min=String(t.min)),t.max!==void 0&&(a.max=String(t.max)),a.step=t.type==="int"?"1":"any",i.append(a),c=()=>{let r=a.value.trim();if(!r)return;let u=Number(r);if(Number.isFinite(u))return t.type==="int"?Math.trunc(u):u};break}case"string_list":case"path_list":{let a=n.input({type:"text",value:O(m)});i.append(a),c=()=>{let r=a.value.split(/[,,]/).map(u=>u.trim()).filter(Boolean);return r.length>0?r:void 0};break}default:{let a=n.input({type:"text",value:O(m)});i.append(a),c=()=>a.value.trim()||void 0;break}}return t.description&&i.append(n.h("div","vx-desc",t.description)),{param:t,el:i,read:c}}function M(e,t,l){let n=e.ui.h("div","vx-field"),i=e.ui.h("div","vx-head");return i.append(e.ui.h("span","vx-label",t)),n.append(i,l),n}function te(e,t,l){let n=e.ui,i=n.h("div","vx-params"),f=n.select({options:t.emotionModeOptions.map(({value:r,label:u})=>({value:r,label:u})),value:l.emotionMode}),p=n.input({type:"text",value:l.defaultEmotion}),m=n.input({type:"text",value:l.defaultInstruct}),c=n.input({type:"number",value:String(l.speed)});c.min="0.5",c.max="2",c.step="0.05";let a=n.select({options:t.paralinguisticOptions.map(({value:r,label:u})=>({value:r,label:u})),value:l.paralinguistic});return i.append(M(e,t.emotionMode,f),M(e,t.defaultEmotion,p),M(e,t.defaultInstruct,m),M(e,t.speed,c),M(e,t.paralinguistic,a)),{el:i,read:()=>{let r=Number(c.value);return{voice:l.voice,emotionMode:f.value,defaultEmotion:p.value.trim(),defaultInstruct:m.value.trim(),speed:Number.isFinite(r)?Math.min(2,Math.max(.5,r)):l.speed,paralinguistic:a.value}}}}function ne(e){let t=j(e.language),l=e.ui,n=null,i="",f=[],p=!1,m=l.sheet({title:t.title}),c=l.h("div","vx-top"),a=l.select({onChange:()=>K(a.value)}),r=l.select({}),u=l.h("label","vx-pick");u.append(l.h("span","vx-pick-label",t.model),a);let x=l.h("label","vx-pick");x.append(l.h("span","vx-pick-label",t.voice),r);let y=l.button(t.refresh,{size:"sm",onClick:()=>{H("refresh")}});c.append(u,x,y);let Y=l.section(t.styleTitle),V=l.h("div","vx-section"),k=l.section(t.paramsTitle);k.hidden=!0;let S=l.h("div","vx-params"),L=l.button(t.save,{variant:"primary",size:"sm",onClick:()=>{J()}}),P=l.msgline(),F=l.actions();F.append(L),m.body.append(c,Y,V,k,S,F,P),e.root.append(m.el);let _=l.foldSheet("voice-model-prompt",{title:t.promptTitle,defaultOpen:!1}),I=l.h("pre","vx-prompt");_.body.append(I),e.root.append(_.el);let T=null;function g(o,s=o){let d=l.h("option");return d.value=o,d.textContent=s,d}function b(o,s){P.textContent=o,P.classList.toggle("bad",s)}function v(){let o=n!==null&&n.models.length>0;a.disabled=p||!o,r.disabled=p||!o||!i,L.disabled=p||!o||!i,y.disabled=p}function w(o){return n?.settings?.[o]}function z(o){return w(o)?.style??(n?.model===o?n.style:void 0)??Q}function A(){let s=n?.models.find(E=>E.id===i)?.voices??[],d=w(i)?.voice??(n?.model===i?n.voice:""),h=[...s];d&&!h.includes(d)&&h.unshift(d);let C=h.map(E=>g(E));d||C.unshift(g("","\u2014")),r.replaceChildren(...C.length>0?C:[g("","\u2014")]),r.value=d}function R(){T=te(e,t,z(i)),V.replaceChildren(T.el)}function W(){let o=n?.models.find(d=>d.id===i),s=w(i)?.options??(n?.model===i?n.options:{});f=[],S.replaceChildren();for(let d of o?.parameters??[]){let h=ee(e,d,s);f.push(h),S.append(h.el)}k.hidden=(o?.parameters?.length??0)===0}function N(){let o=n?.prompts?.[i]??(n?.model===i?n.prompt:"");I.textContent=o,_.note.textContent=i||"\u2014"}function K(o){i=o,A(),R(),W(),N(),v()}function U(o){n=o,i=o.model,X(),R(),W(),N(),v(),b(o.error??"",o.error!==void 0)}function X(){let o=n?.models??[],s=o.map(d=>g(d.id,d.label||d.id));!i&&o.length>0?s.unshift(g("","\u2014")):i&&!o.some(d=>d.id===i)&&s.unshift(g(i,i)),a.replaceChildren(...s),a.value=i,A()}function q(){let o={};for(let s of f){let d=s.read();d!==void 0&&(o[s.param.name]=d)}return o}function G(){return{...T?.read()??z(i),voice:r.value}}async function H(o){if(!p){p=!0,v(),b(t.loading,!1);try{let s=await e.invoke(o,[]);e.signal.aborted||U(s)}catch(s){e.signal.aborted||b(s instanceof Error?s.message:String(s),!0)}finally{p=!1,e.signal.aborted||v()}}}async function J(){if(p||!n)return;let o={model:i,voice:r.value,style:G(),options:q()};p=!0,v(),b("",!1);try{let s=await e.invoke("save",[o]);e.signal.aborted||(U(s),b(t.saved,!1))}catch(s){e.signal.aborted||b(s instanceof Error?s.message:String(s),!0)}finally{p=!1,e.signal.aborted||v()}}v(),H("state")}var ie={panels:{model:{mount:ne}}},de=ie;export{de as default};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kmchai/cortico-world-voice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Cortico World: voice tools, probabilistic reading, emotion and paralinguistic tags, text filtering and local playback; speech is synthesized by the Kmch AI service over HTTP",
|
|
6
6
|
"author": "kmchai",
|
package/src/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** `worlds.voice` 的默认值、配置组与界面文案。 */
|
|
2
2
|
import type { ConfigGroup } from 'cortico/core/types.ts';
|
|
3
3
|
import { pick, type Language, type LanguageTable } from 'cortico/core/language.ts';
|
|
4
|
-
import type { VoiceConfigSection } from './types.ts';
|
|
4
|
+
import type { VoiceConfigSection, VoiceModelStyle } from './types.ts';
|
|
5
5
|
|
|
6
6
|
export const VOICE_ID = 'voice';
|
|
7
7
|
/** World 定义、WebUI 与配置页共用同一个显示名。 */
|
|
@@ -15,6 +15,7 @@ export const VOICE_DEFAULTS: VoiceConfigSection = {
|
|
|
15
15
|
model: '',
|
|
16
16
|
voice: '',
|
|
17
17
|
modelOptions: {},
|
|
18
|
+
modelStyles: {},
|
|
18
19
|
format: 'wav',
|
|
19
20
|
timeoutMs: 180_000,
|
|
20
21
|
|
|
@@ -49,6 +50,27 @@ export const VOICE_DEFAULTS: VoiceConfigSection = {
|
|
|
49
50
|
shareAudio: false,
|
|
50
51
|
};
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 当前模型的朗读风格。模型页保存的值优先,缺省字段回落到旧的全局值,
|
|
55
|
+
* 这样升级前已有的配置仍然有效。
|
|
56
|
+
*/
|
|
57
|
+
export function modelStyle(cfg: VoiceConfigSection, model = cfg.model): VoiceModelStyle {
|
|
58
|
+
const saved = cfg.modelStyles?.[model] ?? {};
|
|
59
|
+
return {
|
|
60
|
+
voice: saved.voice ?? cfg.voice,
|
|
61
|
+
emotionMode: saved.emotionMode ?? cfg.emotionMode,
|
|
62
|
+
defaultEmotion: saved.defaultEmotion ?? cfg.defaultEmotion,
|
|
63
|
+
defaultInstruct: saved.defaultInstruct ?? cfg.defaultInstruct,
|
|
64
|
+
speed: saved.speed ?? cfg.speed,
|
|
65
|
+
paralinguistic: saved.paralinguistic ?? cfg.paralinguistic,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 把模型风格盖到配置上,给过滤、合成与提示词读取。 */
|
|
70
|
+
export function styledConfig(cfg: VoiceConfigSection, model = cfg.model): VoiceConfigSection {
|
|
71
|
+
return { ...cfg, ...modelStyle(cfg, model) };
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
const TEXT = {
|
|
53
75
|
zh: {
|
|
54
76
|
label: 'Kmch AI · 语音',
|
|
@@ -60,6 +82,7 @@ const TEXT = {
|
|
|
60
82
|
modelSaveShape: '保存失败:参数格式不对。',
|
|
61
83
|
modelSaveModel: '保存失败:模型 id 不能为空。',
|
|
62
84
|
modelSaveVoice: '保存失败:音色必须是字符串。',
|
|
85
|
+
modelSaveStyle: '保存失败:朗读风格格式不对。',
|
|
63
86
|
modelSaveOptions: '保存失败:模型参数必须是对象。',
|
|
64
87
|
modelUnknownMethod: '未知的面板方法。',
|
|
65
88
|
connection: '连接',
|
|
@@ -70,15 +93,6 @@ const TEXT = {
|
|
|
70
93
|
serviceTokenSecretDesc: '部署 .env 里的变量名,留空表示服务不校验。',
|
|
71
94
|
format: '音频格式',
|
|
72
95
|
timeoutMs: '超时(毫秒)',
|
|
73
|
-
style: '音色与情绪',
|
|
74
|
-
styleDesc: '',
|
|
75
|
-
emotionMode: '情绪控制',
|
|
76
|
-
emotionModeDesc: 'instruct 折成自然语言指令;categorical 只发情绪标签;basic 只发语速;tags 只用正文标签;none 不发。',
|
|
77
|
-
defaultEmotion: '默认情绪',
|
|
78
|
-
defaultInstruct: '默认表演指令',
|
|
79
|
-
speed: '语速',
|
|
80
|
-
paralinguistic: '副语言标签',
|
|
81
|
-
paralinguisticDesc: 'auto 只在当前模型支持标签时保留;keep 一律保留;strip 一律从朗读文本里删掉。',
|
|
82
96
|
triggers: '触发',
|
|
83
97
|
triggersDesc: '',
|
|
84
98
|
autoSpeak: '自动朗读',
|
|
@@ -123,6 +137,7 @@ const TEXT = {
|
|
|
123
137
|
modelSaveShape: 'Save failed: invalid arguments.',
|
|
124
138
|
modelSaveModel: 'Save failed: the model id must not be empty.',
|
|
125
139
|
modelSaveVoice: 'Save failed: voice must be a string.',
|
|
140
|
+
modelSaveStyle: 'Save failed: invalid voice style.',
|
|
126
141
|
modelSaveOptions: 'Save failed: model options must be an object.',
|
|
127
142
|
modelUnknownMethod: 'Unknown panel method.',
|
|
128
143
|
connection: 'Connection',
|
|
@@ -133,15 +148,6 @@ const TEXT = {
|
|
|
133
148
|
serviceTokenSecretDesc: 'Name in the deployment .env; empty means the service does not check.',
|
|
134
149
|
format: 'Audio format',
|
|
135
150
|
timeoutMs: 'Timeout (ms)',
|
|
136
|
-
style: 'Voice and emotion',
|
|
137
|
-
styleDesc: '',
|
|
138
|
-
emotionMode: 'Emotion control',
|
|
139
|
-
emotionModeDesc: 'instruct turns it into natural language; categorical sends a label; basic sends speed; tags uses inline tags only; none sends nothing.',
|
|
140
|
-
defaultEmotion: 'Default emotion',
|
|
141
|
-
defaultInstruct: 'Default direction',
|
|
142
|
-
speed: 'Speed',
|
|
143
|
-
paralinguistic: 'Paralinguistic tags',
|
|
144
|
-
paralinguisticDesc: 'auto keeps them only when the current model supports them; keep always keeps; strip removes them from the spoken text.',
|
|
145
151
|
triggers: 'Triggers',
|
|
146
152
|
triggersDesc: '',
|
|
147
153
|
autoSpeak: 'Automatic speech',
|
|
@@ -204,21 +210,6 @@ export function voiceConfigGroups(language: Language): ConfigGroup[] {
|
|
|
204
210
|
},
|
|
205
211
|
},
|
|
206
212
|
},
|
|
207
|
-
{
|
|
208
|
-
id: 'world:voice-style',
|
|
209
|
-
owner: 'world:voice',
|
|
210
|
-
schema: {
|
|
211
|
-
type: 'object',
|
|
212
|
-
title: t.style,
|
|
213
|
-
properties: {
|
|
214
|
-
[`${p}emotionMode`]: { type: 'string', title: t.emotionMode, description: t.emotionModeDesc, enum: ['instruct', 'categorical', 'basic', 'tags', 'none'], 'x-hot': true },
|
|
215
|
-
[`${p}defaultEmotion`]: { type: 'string', title: t.defaultEmotion, 'x-hot': true },
|
|
216
|
-
[`${p}defaultInstruct`]: { type: 'string', title: t.defaultInstruct, 'x-hot': true },
|
|
217
|
-
[`${p}speed`]: { type: 'number', title: t.speed, minimum: 0.5, maximum: 2, multipleOf: 0.05, 'x-hot': true },
|
|
218
|
-
[`${p}paralinguistic`]: { type: 'string', title: t.paralinguistic, description: t.paralinguisticDesc, enum: ['auto', 'keep', 'strip'], 'x-hot': true },
|
|
219
|
-
},
|
|
220
|
-
},
|
|
221
|
-
},
|
|
222
213
|
{
|
|
223
214
|
id: 'world:voice-triggers',
|
|
224
215
|
owner: 'world:voice',
|
package/src/definition.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { VoiceWorld } from './world.ts';
|
|
|
7
7
|
export const VOICE: WorldDefinition<VoiceConfigSection> = {
|
|
8
8
|
id: VOICE_ID,
|
|
9
9
|
label: VOICE_LABEL,
|
|
10
|
-
defaults: () => ({ ...VOICE_DEFAULTS, modelOptions: {} }),
|
|
10
|
+
defaults: () => ({ ...VOICE_DEFAULTS, modelOptions: {}, modelStyles: {} }),
|
|
11
11
|
create: (ctx) => new VoiceWorld({
|
|
12
12
|
cfg: ctx.cfg,
|
|
13
13
|
timezone: ctx.timezone,
|
package/src/panel.ts
CHANGED
|
@@ -2,19 +2,32 @@
|
|
|
2
2
|
* 「模型」面板的数据形状。服务端经由 `World.webUi().invoke` 构造,浏览器端只
|
|
3
3
|
* `import type` 这些声明;面板不接触服务地址与令牌。
|
|
4
4
|
*/
|
|
5
|
-
import type { VoiceModel } from './types.ts';
|
|
5
|
+
import type { VoiceModel, VoiceModelStyle } from './types.ts';
|
|
6
|
+
|
|
7
|
+
/** 一个模型在面板里的已保存状态。 */
|
|
8
|
+
export interface PanelModelSetting {
|
|
9
|
+
voice: string;
|
|
10
|
+
style: VoiceModelStyle;
|
|
11
|
+
options: Record<string, unknown>;
|
|
12
|
+
}
|
|
6
13
|
|
|
7
14
|
export interface PanelModelState {
|
|
8
15
|
/** `worlds.voice.model`。 */
|
|
9
16
|
model: string;
|
|
10
|
-
/**
|
|
17
|
+
/** 当前模型的音色。 */
|
|
11
18
|
voice: string;
|
|
12
19
|
/** 当前模型的环境提示词片段,来自 `models.ts`。 */
|
|
13
20
|
prompt: string;
|
|
21
|
+
/** 每个模型的环境提示词片段,切换模型时立即更新。 */
|
|
22
|
+
prompts: Record<string, string>;
|
|
14
23
|
/** 当前模型已保存的请求参数。 */
|
|
15
24
|
options: Record<string, unknown>;
|
|
25
|
+
/** 当前模型生效的朗读风格;没有专属设置时回落到全局值。 */
|
|
26
|
+
style: VoiceModelStyle;
|
|
16
27
|
/** 服务目录;服务不可达时是上一次可用的结果。 */
|
|
17
28
|
models: VoiceModel[];
|
|
29
|
+
/** 每个模型的音色、风格与请求参数;面板切模型时不用等保存。 */
|
|
30
|
+
settings: Record<string, PanelModelSetting>;
|
|
18
31
|
/** 最近一次读取服务目录失败的原因。 */
|
|
19
32
|
error?: string;
|
|
20
33
|
}
|
|
@@ -23,6 +36,7 @@ export interface PanelModelState {
|
|
|
23
36
|
export interface PanelModelSave {
|
|
24
37
|
model: string;
|
|
25
38
|
voice: string;
|
|
39
|
+
style?: Partial<VoiceModelStyle>;
|
|
26
40
|
options: Record<string, unknown>;
|
|
27
41
|
}
|
|
28
42
|
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,17 @@ export type EmotionMode = 'instruct' | 'categorical' | 'basic' | 'tags' | 'none'
|
|
|
7
7
|
export type ParalinguisticMode = 'auto' | 'keep' | 'strip';
|
|
8
8
|
export type PlaybackMode = 'local' | 'event' | 'none';
|
|
9
9
|
|
|
10
|
+
/** 单个模型的朗读风格;缺省字段回落到 `worlds.voice` 上的全局同名值。 */
|
|
11
|
+
export interface VoiceModelStyle {
|
|
12
|
+
voice: string;
|
|
13
|
+
emotionMode: EmotionMode;
|
|
14
|
+
defaultEmotion: string;
|
|
15
|
+
defaultInstruct: string;
|
|
16
|
+
/** 语速倍率;1 为原速。 */
|
|
17
|
+
speed: number;
|
|
18
|
+
paralinguistic: ParalinguisticMode;
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
/** `config.json` 的 `worlds.voice` 段。 */
|
|
11
22
|
export interface VoiceConfigSection {
|
|
12
23
|
enabled: boolean;
|
|
@@ -17,13 +28,16 @@ export interface VoiceConfigSection {
|
|
|
17
28
|
/** 服务令牌的环境变量名;空表示服务不校验。 */
|
|
18
29
|
serviceTokenSecret: string;
|
|
19
30
|
model: string;
|
|
31
|
+
/** 旧的全局音色;只作为没有模型专属设置时的回落值。 */
|
|
20
32
|
voice: string;
|
|
21
33
|
/** 模型专属的 request 参数,按模型 id 分组;由「模型」面板写回,不进 ConfigGroup。 */
|
|
22
34
|
modelOptions: Record<string, Record<string, unknown>>;
|
|
35
|
+
/** 模型专属的朗读风格;由「模型」面板写回,不进 ConfigGroup。 */
|
|
36
|
+
modelStyles: Record<string, Partial<VoiceModelStyle>>;
|
|
23
37
|
format: AudioFormat;
|
|
24
38
|
timeoutMs: number;
|
|
25
39
|
|
|
26
|
-
//
|
|
40
|
+
// 朗读风格的全局回落值(旧配置仍可用;界面上已移到模型页)
|
|
27
41
|
emotionMode: EmotionMode;
|
|
28
42
|
defaultEmotion: string;
|
|
29
43
|
defaultInstruct: string;
|
package/src/world.ts
CHANGED
|
@@ -14,12 +14,12 @@ import type {
|
|
|
14
14
|
Logger, ToolDef, ToolOutcome, World, WorldHost, WorldLamp, WorldWebUiDecl,
|
|
15
15
|
} from 'cortico/core/types.ts';
|
|
16
16
|
import { nullLogger, nowIso } from 'cortico/core/util.ts';
|
|
17
|
-
import { VOICE_DEFAULTS, VOICE_ID, voiceConfigGroups, voiceText } from './config.ts';
|
|
17
|
+
import { modelStyle, styledConfig, VOICE_DEFAULTS, VOICE_ID, voiceConfigGroups, voiceText } from './config.ts';
|
|
18
18
|
import type { AudioPlayer } from './audio/player.ts';
|
|
19
19
|
import { createPlayer } from './audio/player.ts';
|
|
20
20
|
import { SpeechQueue, type SpeechJob } from './audio/queue.ts';
|
|
21
21
|
import { modelPrompt } from './models.ts';
|
|
22
|
-
import { isPlainObject, type PanelModelState } from './panel.ts';
|
|
22
|
+
import { isPlainObject, type PanelModelSetting, type PanelModelState } from './panel.ts';
|
|
23
23
|
import { ServiceClient, type SpeechService } from './service.ts';
|
|
24
24
|
import { splitSpeech, truncateSpeech } from './speech/chunk.ts';
|
|
25
25
|
import { buildInstruct, resolveStyle } from './speech/style.ts';
|
|
@@ -27,7 +27,8 @@ import { filterSpeech } from './speech/text.ts';
|
|
|
27
27
|
import { EventPoller, eventTypes, hasKeyword, latestUserText, splitList, VoiceTap } from './triggers.ts';
|
|
28
28
|
import { VOICE_TOOL_DECLS } from './tools.ts';
|
|
29
29
|
import type {
|
|
30
|
-
ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection,
|
|
30
|
+
EmotionMode, ParalinguisticMode, ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection,
|
|
31
|
+
VoiceModel, VoiceModelStyle,
|
|
31
32
|
} from './types.ts';
|
|
32
33
|
|
|
33
34
|
const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
|
|
@@ -142,7 +143,7 @@ export class VoiceWorld implements World {
|
|
|
142
143
|
this.service = this.deps.service ?? new ServiceClient({
|
|
143
144
|
fetch: this.deps.fetch,
|
|
144
145
|
secret: (name) => this.opts.secret(name),
|
|
145
|
-
cfg: () => this.
|
|
146
|
+
cfg: () => this.styled,
|
|
146
147
|
});
|
|
147
148
|
this.player = this.deps.createPlayer(this.log);
|
|
148
149
|
this.queue = new SpeechQueue({
|
|
@@ -166,14 +167,19 @@ export class VoiceWorld implements World {
|
|
|
166
167
|
return this.opts.cfg;
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
/** 当前模型的配置视图:模型专属的朗读风格盖在全局值上。 */
|
|
171
|
+
private get styled(): VoiceConfigSection {
|
|
172
|
+
return styledConfig(this.cfg, this.cfg.model);
|
|
173
|
+
}
|
|
174
|
+
|
|
169
175
|
envPromptVars(): Record<string, string> {
|
|
170
|
-
const cfg = this.
|
|
176
|
+
const cfg = this.styled;
|
|
171
177
|
return {
|
|
172
|
-
'voice.auto': cfg.autoSpeak ? `${Math.round(cfg.autoProbability * 100)}%` : 'off',
|
|
178
|
+
'voice.auto': this.cfg.autoSpeak ? `${Math.round(this.cfg.autoProbability * 100)}%` : 'off',
|
|
173
179
|
'voice.voice': cfg.voice || 'default',
|
|
174
180
|
'voice.emotion': cfg.defaultEmotion || 'neutral',
|
|
175
181
|
'voice.instruct': cfg.defaultInstruct || 'none',
|
|
176
|
-
'voice.modelPrompt': modelPrompt(cfg.model),
|
|
182
|
+
'voice.modelPrompt': modelPrompt(this.cfg.model),
|
|
177
183
|
};
|
|
178
184
|
}
|
|
179
185
|
|
|
@@ -196,7 +202,7 @@ export class VoiceWorld implements World {
|
|
|
196
202
|
webUi(language: Language = 'zh'): WorldWebUiDecl {
|
|
197
203
|
const t = voiceText(language);
|
|
198
204
|
this.maybeRefresh();
|
|
199
|
-
const cfg = this.
|
|
205
|
+
const cfg = this.styled;
|
|
200
206
|
const stats = this.queue.stats();
|
|
201
207
|
const serviceLamp: WorldLamp = this.serviceState === null
|
|
202
208
|
? { label: t.lampService, state: this.started ? 'loading' : 'offline' }
|
|
@@ -270,17 +276,41 @@ export class VoiceWorld implements World {
|
|
|
270
276
|
|
|
271
277
|
private panelSnapshot(): PanelModelState {
|
|
272
278
|
const cfg = this.cfg;
|
|
273
|
-
const
|
|
279
|
+
const settings = this.panelSettings();
|
|
280
|
+
const current = settings[cfg.model];
|
|
281
|
+
const style = current?.style ?? modelStyle(cfg, cfg.model);
|
|
274
282
|
return {
|
|
275
283
|
model: cfg.model,
|
|
276
|
-
voice:
|
|
284
|
+
voice: style.voice,
|
|
277
285
|
prompt: modelPrompt(cfg.model),
|
|
278
|
-
|
|
286
|
+
prompts: Object.fromEntries(Object.keys(settings).map((id) => [id, modelPrompt(id)])),
|
|
287
|
+
options: current?.options ?? {},
|
|
288
|
+
style,
|
|
279
289
|
models: this.catalog ?? [],
|
|
290
|
+
settings,
|
|
280
291
|
...(this.catalogError ? { error: this.catalogError } : {}),
|
|
281
292
|
};
|
|
282
293
|
}
|
|
283
294
|
|
|
295
|
+
/** 目录里每个模型的音色、风格与参数;当前模型与已保存过设置的模型也各带一条。 */
|
|
296
|
+
private panelSettings(): Record<string, PanelModelSetting> {
|
|
297
|
+
const ids = new Set((this.catalog ?? []).map((model) => model.id));
|
|
298
|
+
if (this.cfg.model.trim()) ids.add(this.cfg.model.trim());
|
|
299
|
+
for (const id of Object.keys(this.cfg.modelStyles ?? {})) if (id.trim()) ids.add(id);
|
|
300
|
+
for (const id of Object.keys(this.cfg.modelOptions ?? {})) if (id.trim()) ids.add(id);
|
|
301
|
+
const out: Record<string, PanelModelSetting> = {};
|
|
302
|
+
for (const id of ids) {
|
|
303
|
+
const style = modelStyle(this.cfg, id);
|
|
304
|
+
const saved = this.cfg.modelOptions?.[id];
|
|
305
|
+
out[id] = {
|
|
306
|
+
voice: style.voice,
|
|
307
|
+
style,
|
|
308
|
+
options: isPlainObject(saved) ? { ...saved } : {},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
284
314
|
private loadCatalog(selected: string): Promise<void> {
|
|
285
315
|
if (this.catalogLoad) return this.catalogLoad;
|
|
286
316
|
this.catalogLoad = this.fetchCatalog(selected).finally(() => {
|
|
@@ -338,18 +368,60 @@ export class VoiceWorld implements World {
|
|
|
338
368
|
const model = typeof raw.model === 'string' ? raw.model.trim() : '';
|
|
339
369
|
if (!model) throw new Error(t.modelSaveModel);
|
|
340
370
|
if (typeof raw.voice !== 'string') throw new Error(t.modelSaveVoice);
|
|
371
|
+
if (raw.style !== undefined && !isPlainObject(raw.style)) throw new Error(t.modelSaveStyle);
|
|
341
372
|
if (!isPlainObject(raw.options)) throw new Error(t.modelSaveOptions);
|
|
342
373
|
|
|
343
|
-
const
|
|
374
|
+
const style = this.readModelStyle(raw.style, model, raw.voice.trim(), t.modelSaveStyle);
|
|
375
|
+
const nextStyles: VoiceConfigSection['modelStyles'] = { ...(this.cfg.modelStyles ?? {}) };
|
|
376
|
+
nextStyles[model] = style;
|
|
377
|
+
|
|
378
|
+
const nextOptions: VoiceConfigSection['modelOptions'] = { ...(this.cfg.modelOptions ?? {}) };
|
|
344
379
|
// 空对象表示删掉这个模型的条目,回到服务的默认参数。
|
|
345
|
-
if (Object.keys(raw.options).length === 0) delete
|
|
346
|
-
else
|
|
380
|
+
if (Object.keys(raw.options).length === 0) delete nextOptions[model];
|
|
381
|
+
else nextOptions[model] = { ...raw.options };
|
|
347
382
|
// 配置层对对象做深合并,删掉的键不会随 patch 传播;先让活引用指向新对象再写盘。
|
|
348
|
-
this.cfg.
|
|
349
|
-
this.
|
|
383
|
+
this.cfg.modelStyles = nextStyles;
|
|
384
|
+
this.cfg.modelOptions = nextOptions;
|
|
385
|
+
this.opts.persist({ model, modelStyles: nextStyles, modelOptions: nextOptions });
|
|
350
386
|
return this.panelSnapshot();
|
|
351
387
|
}
|
|
352
388
|
|
|
389
|
+
/** 面板传来的风格字段逐个校验;没给的字段沿用这个模型当前生效的值。 */
|
|
390
|
+
private readModelStyle(
|
|
391
|
+
raw: Record<string, unknown> | undefined,
|
|
392
|
+
model: string,
|
|
393
|
+
voice: string,
|
|
394
|
+
shapeError: string,
|
|
395
|
+
): VoiceModelStyle {
|
|
396
|
+
const current = modelStyle(this.cfg, model);
|
|
397
|
+
if (!raw) return { ...current, voice };
|
|
398
|
+
|
|
399
|
+
const emotionModes: readonly EmotionMode[] = ['instruct', 'categorical', 'basic', 'tags', 'none'];
|
|
400
|
+
const paralinguisticModes: readonly ParalinguisticMode[] = ['auto', 'keep', 'strip'];
|
|
401
|
+
const emotionMode = raw.emotionMode ?? current.emotionMode;
|
|
402
|
+
if (typeof emotionMode !== 'string' || !emotionModes.includes(emotionMode as EmotionMode)) {
|
|
403
|
+
throw new Error(shapeError);
|
|
404
|
+
}
|
|
405
|
+
const paralinguistic = raw.paralinguistic ?? current.paralinguistic;
|
|
406
|
+
if (typeof paralinguistic !== 'string' || !paralinguisticModes.includes(paralinguistic as ParalinguisticMode)) {
|
|
407
|
+
throw new Error(shapeError);
|
|
408
|
+
}
|
|
409
|
+
const defaultEmotion = raw.defaultEmotion ?? current.defaultEmotion;
|
|
410
|
+
if (typeof defaultEmotion !== 'string') throw new Error(shapeError);
|
|
411
|
+
const defaultInstruct = raw.defaultInstruct ?? current.defaultInstruct;
|
|
412
|
+
if (typeof defaultInstruct !== 'string') throw new Error(shapeError);
|
|
413
|
+
const rawSpeed = raw.speed ?? current.speed;
|
|
414
|
+
if (typeof rawSpeed !== 'number' || !Number.isFinite(rawSpeed)) throw new Error(shapeError);
|
|
415
|
+
return {
|
|
416
|
+
voice,
|
|
417
|
+
emotionMode: emotionMode as EmotionMode,
|
|
418
|
+
defaultEmotion: defaultEmotion.trim().slice(0, 40),
|
|
419
|
+
defaultInstruct: defaultInstruct.trim().slice(0, 500),
|
|
420
|
+
speed: Math.min(2, Math.max(0.5, rawSpeed)),
|
|
421
|
+
paralinguistic: paralinguistic as ParalinguisticMode,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
353
425
|
outputTap() {
|
|
354
426
|
const cfg = this.cfg;
|
|
355
427
|
if (!cfg.autoSpeak) return undefined;
|
|
@@ -408,7 +480,7 @@ export class VoiceWorld implements World {
|
|
|
408
480
|
const cfg = this.cfg;
|
|
409
481
|
if (!cfg.autoSpeak) return;
|
|
410
482
|
|
|
411
|
-
const filtered = filterSpeech(raw,
|
|
483
|
+
const filtered = filterSpeech(raw, this.styled);
|
|
412
484
|
if (!filtered.text) return;
|
|
413
485
|
|
|
414
486
|
const userText = cfg.explicitTrigger
|
|
@@ -449,7 +521,7 @@ export class VoiceWorld implements World {
|
|
|
449
521
|
// ———————— 工具 ————————
|
|
450
522
|
|
|
451
523
|
private async handleSpeak(args: Record<string, unknown>): Promise<ToolOutcome> {
|
|
452
|
-
const filtered = filterSpeech(asString(args.text), this.
|
|
524
|
+
const filtered = filterSpeech(asString(args.text), this.styled);
|
|
453
525
|
if (!filtered.text) return { text: 'voice_speak failed: there is nothing to say.', failed: true };
|
|
454
526
|
const silent = args.silent === true || this.cfg.playback === 'none';
|
|
455
527
|
const share = this.cfg.shareAudio || silent;
|
|
@@ -500,26 +572,47 @@ export class VoiceWorld implements World {
|
|
|
500
572
|
|
|
501
573
|
private async handleStyle(args: Record<string, unknown>): Promise<ToolOutcome> {
|
|
502
574
|
const has = (key: string): boolean => Object.prototype.hasOwnProperty.call(args, key);
|
|
503
|
-
const
|
|
504
|
-
if (
|
|
505
|
-
|
|
506
|
-
|
|
575
|
+
const model = this.cfg.model.trim();
|
|
576
|
+
if (!model) {
|
|
577
|
+
// 还没选模型时写到全局回落值;模型页仍可之后覆盖。
|
|
578
|
+
const patch: Partial<VoiceConfigSection> = {};
|
|
579
|
+
if (has('emotion')) patch.defaultEmotion = asString(args.emotion).slice(0, 40);
|
|
580
|
+
if (has('instruct')) patch.defaultInstruct = asString(args.instruct).slice(0, 500);
|
|
581
|
+
if (has('voice')) patch.voice = asString(args.voice).slice(0, 200);
|
|
582
|
+
if (has('speed')) {
|
|
583
|
+
const speed = asNumber(args.speed);
|
|
584
|
+
if (speed !== undefined) patch.speed = Math.min(2, Math.max(0.5, speed));
|
|
585
|
+
}
|
|
586
|
+
if (Object.keys(patch).length > 0) {
|
|
587
|
+
this.opts.persist(patch);
|
|
588
|
+
return { text: `Voice style updated. ${this.styleSummary()}` };
|
|
589
|
+
}
|
|
590
|
+
return { text: this.styleSummary() };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const current = modelStyle(this.cfg, model);
|
|
594
|
+
const next: VoiceModelStyle = { ...current };
|
|
595
|
+
if (has('emotion')) next.defaultEmotion = asString(args.emotion).slice(0, 40);
|
|
596
|
+
if (has('instruct')) next.defaultInstruct = asString(args.instruct).slice(0, 500);
|
|
597
|
+
if (has('voice')) next.voice = asString(args.voice).slice(0, 200);
|
|
507
598
|
if (has('speed')) {
|
|
508
599
|
const speed = asNumber(args.speed);
|
|
509
|
-
if (speed !== undefined)
|
|
600
|
+
if (speed !== undefined) next.speed = Math.min(2, Math.max(0.5, speed));
|
|
510
601
|
}
|
|
511
|
-
if (Object.keys(
|
|
512
|
-
this.
|
|
602
|
+
if (Object.keys(next).some((key) => next[key as keyof VoiceModelStyle] !== current[key as keyof VoiceModelStyle])) {
|
|
603
|
+
const modelStyles: VoiceConfigSection['modelStyles'] = { ...(this.cfg.modelStyles ?? {}), [model]: next };
|
|
604
|
+
this.cfg.modelStyles = modelStyles;
|
|
605
|
+
this.opts.persist({ modelStyles });
|
|
513
606
|
return { text: `Voice style updated. ${this.styleSummary()}` };
|
|
514
607
|
}
|
|
515
608
|
return { text: this.styleSummary() };
|
|
516
609
|
}
|
|
517
610
|
|
|
518
611
|
private styleSummary(): string {
|
|
519
|
-
const cfg = this.
|
|
612
|
+
const cfg = this.styled;
|
|
520
613
|
return `Voice: ${cfg.voice || 'default'}; emotion: ${cfg.defaultEmotion || 'neutral'}; `
|
|
521
614
|
+ `direction: ${cfg.defaultInstruct || 'none'}; speed: ${cfg.speed}; `
|
|
522
|
-
+ `automatic: ${cfg.autoSpeak ? `${Math.round(cfg.autoProbability * 100)}%` : 'off'}.`;
|
|
615
|
+
+ `automatic: ${this.cfg.autoSpeak ? `${Math.round(this.cfg.autoProbability * 100)}%` : 'off'}.`;
|
|
523
616
|
}
|
|
524
617
|
|
|
525
618
|
private spokenOutcome(audio: TtsAudio | undefined, seconds: number, silent: boolean): ToolOutcome {
|
|
@@ -553,7 +646,7 @@ export class VoiceWorld implements World {
|
|
|
553
646
|
style: { emotion?: string; instruct?: string; voice?: string; speed?: number },
|
|
554
647
|
signal: AbortSignal,
|
|
555
648
|
): Promise<TtsAudio> {
|
|
556
|
-
const cfg = this.
|
|
649
|
+
const cfg = this.styled;
|
|
557
650
|
const resolved = resolveStyle(cfg, style);
|
|
558
651
|
const request: TtsRequest = {
|
|
559
652
|
text,
|
|
@@ -599,7 +692,7 @@ export class VoiceWorld implements World {
|
|
|
599
692
|
origin: 'internal',
|
|
600
693
|
senderKey: this.id,
|
|
601
694
|
text: `Spoken line: ${chunk.slice(0, 100)}`,
|
|
602
|
-
meta: { source: job.source, voice: this.
|
|
695
|
+
meta: { source: job.source, voice: this.styled.voice, emotion: this.styled.defaultEmotion },
|
|
603
696
|
blobs: [{
|
|
604
697
|
bytes: audio.bytes,
|
|
605
698
|
mime: audio.mime,
|