@kmchai/cortico-world-voice 0.1.0 → 0.2.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 +23 -0
- package/README.md +79 -70
- package/describe.json +2 -27
- package/package.json +25 -4
- package/src/ENV_PROMPT.md +6 -7
- package/src/audio/queue.ts +1 -1
- package/src/config.ts +46 -108
- package/src/definition.ts +3 -4
- package/src/options.ts +29 -8
- package/src/service.ts +290 -0
- package/src/speech/style.ts +2 -14
- package/src/speech/tags.ts +4 -7
- package/src/speech/text.ts +1 -1
- package/src/tools.ts +1 -16
- package/src/types.ts +17 -51
- package/src/world.ts +144 -242
- package/src/backends/audiocpp.ts +0 -98
- package/src/backends/custom.ts +0 -132
- package/src/backends/http.ts +0 -91
- package/src/backends/index.ts +0 -21
- package/src/backends/openai.ts +0 -51
- package/src/http.ts +0 -205
- package/src/sing.ts +0 -73
package/src/config.ts
CHANGED
|
@@ -6,17 +6,18 @@ import type { VoiceConfigSection } from './types.ts';
|
|
|
6
6
|
export const VOICE_ID = 'voice';
|
|
7
7
|
/** 配置里 `x-options` 用的动态音色表。 */
|
|
8
8
|
export const VOICE_OPTIONS_KIND = 'voice.voices';
|
|
9
|
+
/** 配置里 `x-options` 用的动态模型表。 */
|
|
10
|
+
export const VOICE_MODELS_KIND = 'voice.models';
|
|
9
11
|
|
|
10
12
|
export const VOICE_DEFAULTS: VoiceConfigSection = {
|
|
11
13
|
enabled: false,
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
model: '
|
|
16
|
-
voice: '
|
|
17
|
-
apiKeySecret: '',
|
|
15
|
+
serviceUrl: 'http://127.0.0.1:9883',
|
|
16
|
+
serviceTokenSecret: '',
|
|
17
|
+
model: '',
|
|
18
|
+
voice: '',
|
|
18
19
|
format: 'wav',
|
|
19
|
-
timeoutMs:
|
|
20
|
+
timeoutMs: 180_000,
|
|
20
21
|
|
|
21
22
|
emotionMode: 'instruct',
|
|
22
23
|
defaultEmotion: '',
|
|
@@ -47,51 +48,35 @@ export const VOICE_DEFAULTS: VoiceConfigSection = {
|
|
|
47
48
|
chunkMax: 120,
|
|
48
49
|
gapMs: 120,
|
|
49
50
|
shareAudio: false,
|
|
50
|
-
|
|
51
|
-
httpApi: false,
|
|
52
|
-
httpPort: 9883,
|
|
53
|
-
httpToken: '',
|
|
54
|
-
httpPlay: false,
|
|
55
|
-
|
|
56
|
-
singUrl: '',
|
|
57
|
-
singToken: '',
|
|
58
|
-
|
|
59
|
-
customUrl: '',
|
|
60
|
-
customMethod: 'POST',
|
|
61
|
-
customHeaders: '',
|
|
62
|
-
customBody: '',
|
|
63
|
-
customResponse: 'audio',
|
|
64
|
-
customAudioPath: 'audio',
|
|
65
51
|
};
|
|
66
52
|
|
|
67
53
|
const TEXT = {
|
|
68
54
|
zh: {
|
|
69
55
|
label: '语音',
|
|
70
56
|
envTitle: '语音 · 环境提示词',
|
|
71
|
-
|
|
57
|
+
lampService: '语音服务',
|
|
72
58
|
lampQueue: '朗读队列',
|
|
73
|
-
|
|
74
|
-
lampSinger: '唱歌',
|
|
59
|
+
servicePage: '服务设置',
|
|
75
60
|
connection: '连接',
|
|
76
|
-
connectionDesc: '
|
|
77
|
-
|
|
78
|
-
|
|
61
|
+
connectionDesc: '语音服务的 HTTP 地址与令牌。',
|
|
62
|
+
serviceUrl: '服务地址',
|
|
63
|
+
serviceUrlDesc: 'Kmch AI · 服务 的网关地址。',
|
|
64
|
+
serviceTokenSecret: '服务令牌变量名',
|
|
65
|
+
serviceTokenSecretDesc: '部署 .env 里的变量名,留空表示服务不校验。',
|
|
79
66
|
model: '模型',
|
|
80
67
|
voice: '音色',
|
|
81
|
-
apiKeySecret: '密钥变量名',
|
|
82
|
-
apiKeySecretDesc: '部署 .env 里的变量名,留空表示不需要。',
|
|
83
68
|
format: '音频格式',
|
|
84
69
|
timeoutMs: '超时(毫秒)',
|
|
85
70
|
style: '音色与情绪',
|
|
86
71
|
styleDesc: '',
|
|
87
72
|
emotionMode: '情绪控制',
|
|
88
|
-
emotionModeDesc: 'instruct
|
|
73
|
+
emotionModeDesc: 'instruct 折成自然语言指令;categorical 只发情绪标签;basic 只发语速;tags 只用正文标签;none 不发。',
|
|
89
74
|
defaultEmotion: '默认情绪',
|
|
90
75
|
defaultInstruct: '默认表演指令',
|
|
91
76
|
speed: '语速',
|
|
92
77
|
paralinguistic: '副语言标签',
|
|
93
|
-
paralinguisticDesc: 'auto
|
|
94
|
-
triggers: '
|
|
78
|
+
paralinguisticDesc: 'auto 保留,由服务与模型决定;strip 一律从朗读文本里删掉。',
|
|
79
|
+
triggers: '触发',
|
|
95
80
|
triggersDesc: '',
|
|
96
81
|
autoSpeak: '自动朗读',
|
|
97
82
|
autoProbability: '自动朗读概率',
|
|
@@ -106,6 +91,8 @@ const TEXT = {
|
|
|
106
91
|
explicitWindowSec: '回看秒数',
|
|
107
92
|
minChars: '最短字数',
|
|
108
93
|
maxChars: '最长字数',
|
|
94
|
+
filter: '文本过滤',
|
|
95
|
+
filterDesc: '',
|
|
109
96
|
filterCode: '过滤代码块',
|
|
110
97
|
filterUrls: '过滤网址',
|
|
111
98
|
filterMarkdown: '过滤 Markdown',
|
|
@@ -114,7 +101,7 @@ const TEXT = {
|
|
|
114
101
|
filterEmoji: '过滤表情与符号',
|
|
115
102
|
filterSymbols: '额外过滤字符',
|
|
116
103
|
filterSymbolsDesc: '按字面删除,可用逗号/分号/换行分隔。',
|
|
117
|
-
output: '
|
|
104
|
+
output: '输出',
|
|
118
105
|
outputDesc: '',
|
|
119
106
|
playback: '播放方式',
|
|
120
107
|
playbackDesc: 'local 用本机播放器;event 发 tts.voice 事件交给别的插件;none 只合成。',
|
|
@@ -122,52 +109,33 @@ const TEXT = {
|
|
|
122
109
|
chunkMax: '单块字数',
|
|
123
110
|
gapMs: '块间停顿(毫秒)',
|
|
124
111
|
shareAudio: '回执带音频附件',
|
|
125
|
-
httpApi: '开放本地语音接口',
|
|
126
|
-
httpPort: '接口端口',
|
|
127
|
-
httpToken: '接口令牌',
|
|
128
|
-
httpTokenDesc: '留空不校验;只监听 127.0.0.1。',
|
|
129
|
-
httpPlay: '接口合成也本机播放',
|
|
130
|
-
sing: '唱歌',
|
|
131
|
-
singDesc: '接到唱歌插件的 HTTP 接口;留空则 voice_sing 回失败。',
|
|
132
|
-
singUrl: '唱歌服务地址',
|
|
133
|
-
singToken: '唱歌令牌',
|
|
134
|
-
custom: 'custom 协议',
|
|
135
|
-
customDesc: '自定义 JSON 请求;占位符 {{text}} {{voice}} {{emotion}} {{instruct}} {{model}} {{speed}} {{format}}。',
|
|
136
|
-
customUrl: '完整地址',
|
|
137
|
-
customMethod: '方法',
|
|
138
|
-
customHeaders: '请求头(JSON)',
|
|
139
|
-
customHeadersDesc: '值里的 ${KEY} 换成 apiKeySecret 读到的密钥。',
|
|
140
|
-
customBody: '请求体模板(JSON)',
|
|
141
|
-
customResponse: '音频位置',
|
|
142
|
-
customAudioPath: '音频字段路径',
|
|
143
112
|
},
|
|
144
113
|
en: {
|
|
145
114
|
label: 'Voice',
|
|
146
115
|
envTitle: 'Voice · environment prompt',
|
|
147
|
-
|
|
116
|
+
lampService: 'Speech service',
|
|
148
117
|
lampQueue: 'Speech queue',
|
|
149
|
-
|
|
150
|
-
lampSinger: 'Singing',
|
|
118
|
+
servicePage: 'Service settings',
|
|
151
119
|
connection: 'Connection',
|
|
152
|
-
connectionDesc: 'Where the speech service is and which
|
|
153
|
-
|
|
154
|
-
|
|
120
|
+
connectionDesc: 'Where the speech service is and which token it expects.',
|
|
121
|
+
serviceUrl: 'Service URL',
|
|
122
|
+
serviceUrlDesc: 'Gateway address of Kmch AI · Service.',
|
|
123
|
+
serviceTokenSecret: 'Service token variable',
|
|
124
|
+
serviceTokenSecretDesc: 'Name in the deployment .env; empty means the service does not check.',
|
|
155
125
|
model: 'Model',
|
|
156
126
|
voice: 'Voice',
|
|
157
|
-
apiKeySecret: 'Secret variable',
|
|
158
|
-
apiKeySecretDesc: 'Name in the deployment .env; empty means none.',
|
|
159
127
|
format: 'Audio format',
|
|
160
128
|
timeoutMs: 'Timeout (ms)',
|
|
161
129
|
style: 'Voice and emotion',
|
|
162
130
|
styleDesc: '',
|
|
163
131
|
emotionMode: 'Emotion control',
|
|
164
|
-
emotionModeDesc: 'instruct turns it into natural language; categorical sends a label; basic sends speed
|
|
132
|
+
emotionModeDesc: 'instruct turns it into natural language; categorical sends a label; basic sends speed; tags uses inline tags only; none sends nothing.',
|
|
165
133
|
defaultEmotion: 'Default emotion',
|
|
166
134
|
defaultInstruct: 'Default direction',
|
|
167
135
|
speed: 'Speed',
|
|
168
136
|
paralinguistic: 'Paralinguistic tags',
|
|
169
|
-
paralinguisticDesc: 'auto keeps them
|
|
170
|
-
triggers: 'Triggers
|
|
137
|
+
paralinguisticDesc: 'auto keeps them and lets the service and model decide; strip removes them from the spoken text.',
|
|
138
|
+
triggers: 'Triggers',
|
|
171
139
|
triggersDesc: '',
|
|
172
140
|
autoSpeak: 'Automatic speech',
|
|
173
141
|
autoProbability: 'Automatic probability',
|
|
@@ -182,6 +150,8 @@ const TEXT = {
|
|
|
182
150
|
explicitWindowSec: 'Look-back seconds',
|
|
183
151
|
minChars: 'Minimum characters',
|
|
184
152
|
maxChars: 'Maximum characters',
|
|
153
|
+
filter: 'Text filter',
|
|
154
|
+
filterDesc: '',
|
|
185
155
|
filterCode: 'Filter code blocks',
|
|
186
156
|
filterUrls: 'Filter URLs',
|
|
187
157
|
filterMarkdown: 'Filter Markdown',
|
|
@@ -190,7 +160,7 @@ const TEXT = {
|
|
|
190
160
|
filterEmoji: 'Filter emoji and symbols',
|
|
191
161
|
filterSymbols: 'Extra filtered characters',
|
|
192
162
|
filterSymbolsDesc: 'Removed literally; comma/semicolon/newline separated.',
|
|
193
|
-
output: 'Output
|
|
163
|
+
output: 'Output',
|
|
194
164
|
outputDesc: '',
|
|
195
165
|
playback: 'Playback',
|
|
196
166
|
playbackDesc: 'local uses the built-in player; event emits tts.voice for another plugin; none only synthesizes.',
|
|
@@ -198,24 +168,6 @@ const TEXT = {
|
|
|
198
168
|
chunkMax: 'Characters per chunk',
|
|
199
169
|
gapMs: 'Gap between chunks (ms)',
|
|
200
170
|
shareAudio: 'Attach audio to tool results',
|
|
201
|
-
httpApi: 'Serve the local speech API',
|
|
202
|
-
httpPort: 'API port',
|
|
203
|
-
httpToken: 'API token',
|
|
204
|
-
httpTokenDesc: 'Empty means no check; loopback only.',
|
|
205
|
-
httpPlay: 'Play API requests locally too',
|
|
206
|
-
sing: 'Singing',
|
|
207
|
-
singDesc: 'HTTP endpoint of a singing plugin; empty makes voice_sing fail.',
|
|
208
|
-
singUrl: 'Singing service URL',
|
|
209
|
-
singToken: 'Singing token',
|
|
210
|
-
custom: 'custom protocol',
|
|
211
|
-
customDesc: 'Custom JSON request; placeholders {{text}} {{voice}} {{emotion}} {{instruct}} {{model}} {{speed}} {{format}}.',
|
|
212
|
-
customUrl: 'Full URL',
|
|
213
|
-
customMethod: 'Method',
|
|
214
|
-
customHeaders: 'Headers (JSON)',
|
|
215
|
-
customHeadersDesc: '${KEY} in a value is replaced with the secret named by apiKeySecret.',
|
|
216
|
-
customBody: 'Body template (JSON)',
|
|
217
|
-
customResponse: 'Audio location',
|
|
218
|
-
customAudioPath: 'Audio field path',
|
|
219
171
|
},
|
|
220
172
|
} as const;
|
|
221
173
|
|
|
@@ -238,11 +190,10 @@ export function voiceConfigGroups(language: Language): ConfigGroup[] {
|
|
|
238
190
|
title: t.connection,
|
|
239
191
|
description: t.connectionDesc,
|
|
240
192
|
properties: {
|
|
241
|
-
[`${p}
|
|
242
|
-
[`${p}
|
|
243
|
-
[`${p}model`]: { type: 'string', title: t.model, 'x-hot': true },
|
|
193
|
+
[`${p}serviceUrl`]: { type: 'string', title: t.serviceUrl, description: t.serviceUrlDesc, 'x-hot': true },
|
|
194
|
+
[`${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 },
|
|
244
196
|
[`${p}voice`]: { type: 'string', title: t.voice, 'x-options': VOICE_OPTIONS_KIND, 'x-hot': true },
|
|
245
|
-
[`${p}apiKeySecret`]: { type: 'string', title: t.apiKeySecret, description: t.apiKeySecretDesc, 'x-hot': false },
|
|
246
197
|
[`${p}format`]: { type: 'string', title: t.format, enum: ['wav', 'mp3'], 'x-hot': true },
|
|
247
198
|
[`${p}timeoutMs`]: { type: 'integer', title: t.timeoutMs, minimum: 1000, maximum: 600_000, 'x-hot': true },
|
|
248
199
|
},
|
|
@@ -280,6 +231,16 @@ export function voiceConfigGroups(language: Language): ConfigGroup[] {
|
|
|
280
231
|
[`${p}explicitWindowSec`]: { type: 'integer', title: t.explicitWindowSec, minimum: 0, maximum: 3600, 'x-hot': true },
|
|
281
232
|
[`${p}minChars`]: { type: 'integer', title: t.minChars, minimum: 1, maximum: 100, 'x-hot': true },
|
|
282
233
|
[`${p}maxChars`]: { type: 'integer', title: t.maxChars, minimum: 20, maximum: 8000, 'x-hot': true },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: 'world:voice-filter',
|
|
239
|
+
owner: 'world:voice',
|
|
240
|
+
schema: {
|
|
241
|
+
type: 'object',
|
|
242
|
+
title: t.filter,
|
|
243
|
+
properties: {
|
|
283
244
|
[`${p}filterCode`]: { type: 'boolean', title: t.filterCode, 'x-hot': true },
|
|
284
245
|
[`${p}filterUrls`]: { type: 'boolean', title: t.filterUrls, 'x-hot': true },
|
|
285
246
|
[`${p}filterMarkdown`]: { type: 'boolean', title: t.filterMarkdown, 'x-hot': true },
|
|
@@ -301,29 +262,6 @@ export function voiceConfigGroups(language: Language): ConfigGroup[] {
|
|
|
301
262
|
[`${p}chunkMax`]: { type: 'integer', title: t.chunkMax, minimum: 20, maximum: 500, 'x-hot': true },
|
|
302
263
|
[`${p}gapMs`]: { type: 'integer', title: t.gapMs, minimum: 0, maximum: 5000, 'x-hot': true },
|
|
303
264
|
[`${p}shareAudio`]: { type: 'boolean', title: t.shareAudio, 'x-hot': true },
|
|
304
|
-
[`${p}httpApi`]: { type: 'boolean', title: t.httpApi, 'x-hot': false },
|
|
305
|
-
[`${p}httpPort`]: { type: 'integer', title: t.httpPort, minimum: 1, maximum: 65535, 'x-hot': false },
|
|
306
|
-
[`${p}httpToken`]: { type: 'string', title: t.httpToken, description: t.httpTokenDesc, 'x-hot': false },
|
|
307
|
-
[`${p}httpPlay`]: { type: 'boolean', title: t.httpPlay, 'x-hot': true },
|
|
308
|
-
[`${p}singUrl`]: { type: 'string', title: t.singUrl, description: t.singDesc, 'x-hot': true },
|
|
309
|
-
[`${p}singToken`]: { type: 'string', title: t.singToken, 'x-hot': true },
|
|
310
|
-
},
|
|
311
|
-
},
|
|
312
|
-
},
|
|
313
|
-
{
|
|
314
|
-
id: 'world:voice-custom',
|
|
315
|
-
owner: 'world:voice',
|
|
316
|
-
schema: {
|
|
317
|
-
type: 'object',
|
|
318
|
-
title: t.custom,
|
|
319
|
-
description: t.customDesc,
|
|
320
|
-
properties: {
|
|
321
|
-
[`${p}customUrl`]: { type: 'string', title: t.customUrl, 'x-hot': true },
|
|
322
|
-
[`${p}customMethod`]: { type: 'string', title: t.customMethod, enum: ['POST', 'GET'], 'x-hot': true },
|
|
323
|
-
[`${p}customHeaders`]: { type: 'string', title: t.customHeaders, description: t.customHeadersDesc, 'x-hot': true },
|
|
324
|
-
[`${p}customBody`]: { type: 'string', title: t.customBody, 'x-hot': true },
|
|
325
|
-
[`${p}customResponse`]: { type: 'string', title: t.customResponse, enum: ['audio', 'base64', 'url'], 'x-hot': true },
|
|
326
|
-
[`${p}customAudioPath`]: { type: 'string', title: t.customAudioPath, 'x-hot': true },
|
|
327
265
|
},
|
|
328
266
|
},
|
|
329
267
|
},
|
package/src/definition.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describeTools, type WorldDefinition } from 'cortico/world.ts';
|
|
2
|
-
import { VOICE_DEFAULTS, VOICE_ID
|
|
3
|
-
import {
|
|
2
|
+
import { VOICE_DEFAULTS, VOICE_ID } from './config.ts';
|
|
3
|
+
import { currentOptions } from './options.ts';
|
|
4
4
|
import { VOICE_TOOL_DECLS } from './tools.ts';
|
|
5
5
|
import type { VoiceConfigSection } from './types.ts';
|
|
6
6
|
import { VoiceWorld } from './world.ts';
|
|
@@ -9,7 +9,7 @@ export const VOICE: WorldDefinition<VoiceConfigSection> = {
|
|
|
9
9
|
id: VOICE_ID,
|
|
10
10
|
label: 'Voice',
|
|
11
11
|
defaults: () => ({ ...VOICE_DEFAULTS }),
|
|
12
|
-
configOptions: (kind) => (kind
|
|
12
|
+
configOptions: (kind) => currentOptions(kind),
|
|
13
13
|
create: (ctx) => new VoiceWorld({
|
|
14
14
|
cfg: ctx.cfg,
|
|
15
15
|
timezone: ctx.timezone,
|
|
@@ -20,7 +20,6 @@ export const VOICE: WorldDefinition<VoiceConfigSection> = {
|
|
|
20
20
|
describe: {
|
|
21
21
|
tools: describeTools(VOICE_TOOL_DECLS, {
|
|
22
22
|
voice_speak: { zh: '现在念一句;可指定情绪、表演指令、音色与语速,也可以只合成不出声。', en: 'Say one line now, optionally with an emotion, direction, voice and speed, or synthesize it silently.' },
|
|
23
|
-
voice_sing: { zh: '把一句歌词交给唱歌插件唱出来。', en: 'Sing one lyric line through the configured singing service.' },
|
|
24
23
|
voice_hush: { zh: '立刻停声并清空排队。', en: 'Stop the voice and drop queued lines.' },
|
|
25
24
|
voice_style: { zh: '读或改自动朗读使用的长期音色与情绪。', en: 'Read or change the standing voice style used by automatic reading.' },
|
|
26
25
|
}),
|
package/src/options.ts
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* 配置页的模型与音色下拉要读运行中实例从服务探到的目录,但
|
|
3
|
+
* `WorldDefinition.configOptions` 是静态签名。实例启动或探活时把读取函数注册进来;
|
|
4
|
+
* 只支持 `voice.models` 与 `voice.voices` 两种 kind,未知 kind 返回空。
|
|
5
|
+
* 读的是已经缓存的表,不在配置页里发请求;服务不可达时保留上次的表,没有就是空表。
|
|
6
|
+
* 当前值不在表里时由框架补入,这里不重复做。
|
|
4
7
|
*/
|
|
5
|
-
import
|
|
8
|
+
import { VOICE_MODELS_KIND, VOICE_OPTIONS_KIND } from './config.ts';
|
|
9
|
+
import type { SelectOption } from './types.ts';
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
export const OPTION_KINDS = [VOICE_MODELS_KIND, VOICE_OPTIONS_KIND] as const;
|
|
12
|
+
export type OptionKind = (typeof OPTION_KINDS)[number];
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
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);
|
|
11
30
|
}
|
|
12
31
|
|
|
13
|
-
|
|
14
|
-
|
|
32
|
+
/** 未知 kind 返回空数组,框架继续查询其他 World。 */
|
|
33
|
+
export function currentOptions(kind: string): SelectOption[] {
|
|
34
|
+
if (!isOptionKind(kind)) return [];
|
|
35
|
+
return [...(providers.get(kind)?.() ?? [])];
|
|
15
36
|
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kmch AI · 服务 的 HTTP 客户端。语音只认这一条协议:
|
|
3
|
+
* `POST /v1/audio/speech` 取音频,`GET /health` 探活,
|
|
4
|
+
* `GET /v1/models` 与 `GET /v1/audio/voices` 取目录。
|
|
5
|
+
* 令牌只从 secret() 现读,不写日志、不留副本。
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
AudioFormat, ModelOption, ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection, VoiceOption,
|
|
9
|
+
} from './types.ts';
|
|
10
|
+
|
|
11
|
+
const PROBE_TIMEOUT_MS = 8000;
|
|
12
|
+
const LIST_TIMEOUT_MS = 8000;
|
|
13
|
+
|
|
14
|
+
export interface ServiceClientDeps {
|
|
15
|
+
fetch: typeof fetch;
|
|
16
|
+
/** 从部署 .env 读密钥;名字为空时返回空串。 */
|
|
17
|
+
secret(name: string): string;
|
|
18
|
+
/** `worlds.voice` 的活引用。 */
|
|
19
|
+
cfg(): VoiceConfigSection;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** World 只需要这些方法;测试用假客户端也实现它们。 */
|
|
23
|
+
export type SpeechService = Pick<ServiceClient, 'synthesize' | 'health' | 'models' | 'voices'>;
|
|
24
|
+
|
|
25
|
+
export class ServiceClient implements SpeechService {
|
|
26
|
+
constructor(private readonly deps: ServiceClientDeps) {}
|
|
27
|
+
|
|
28
|
+
private get cfg(): VoiceConfigSection {
|
|
29
|
+
return this.deps.cfg();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async synthesize(req: TtsRequest, signal?: AbortSignal): Promise<TtsAudio> {
|
|
33
|
+
const cfg = this.cfg;
|
|
34
|
+
const body: Record<string, unknown> = {
|
|
35
|
+
input: req.text,
|
|
36
|
+
response_format: req.format,
|
|
37
|
+
};
|
|
38
|
+
const model = cfg.model.trim();
|
|
39
|
+
if (model) body.model = model;
|
|
40
|
+
const voice = (req.voice ?? '').trim() || cfg.voice.trim();
|
|
41
|
+
if (voice) body.voice = voice;
|
|
42
|
+
const instruct = (req.instruct ?? '').trim();
|
|
43
|
+
if (instruct) body.instructions = instruct;
|
|
44
|
+
const emotion = (req.emotion ?? '').trim();
|
|
45
|
+
if (emotion) body.emotion = emotion;
|
|
46
|
+
if (req.speed !== undefined && Number.isFinite(req.speed)) body.speed = req.speed;
|
|
47
|
+
|
|
48
|
+
return this.request(this.endpoint('/v1/audio/speech'), {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: this.headers(true),
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
}, cfg.timeoutMs, signal ?? req.signal, async (res) => {
|
|
53
|
+
if (!res.ok) throw new Error(await errorText(res));
|
|
54
|
+
const declared = res.headers.get('content-type');
|
|
55
|
+
const mime = normalizeMime(declared, req.format);
|
|
56
|
+
if (!mime) {
|
|
57
|
+
throw new Error(`voice service returned ${declared?.split(';')[0]?.trim() || 'no content-type'} instead of audio`);
|
|
58
|
+
}
|
|
59
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
60
|
+
if (bytes.length === 0) throw new Error('voice service returned an empty audio body');
|
|
61
|
+
return { bytes, mime, seconds: mime === 'audio/wav' ? wavSeconds(bytes) : undefined };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 探活;连不上或回应不合法时返回 `ok: false`,不抛错。 */
|
|
66
|
+
async health(): Promise<ServiceHealth> {
|
|
67
|
+
try {
|
|
68
|
+
return await this.request(
|
|
69
|
+
this.endpoint('/health'),
|
|
70
|
+
{ method: 'GET', headers: this.headers() },
|
|
71
|
+
Math.min(this.cfg.timeoutMs || PROBE_TIMEOUT_MS, PROBE_TIMEOUT_MS),
|
|
72
|
+
undefined,
|
|
73
|
+
async (res): Promise<ServiceHealth> => {
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
return { ok: false, ready: false, status: `HTTP ${res.status}`, detail: await errorText(res) };
|
|
76
|
+
}
|
|
77
|
+
const data = await res.json().catch(() => null) as {
|
|
78
|
+
status?: unknown; ready?: unknown; models?: unknown; services?: unknown;
|
|
79
|
+
} | null;
|
|
80
|
+
const status = typeof data?.status === 'string' && data.status.trim() ? data.status.trim() : 'ok';
|
|
81
|
+
const ready = data?.ready === true;
|
|
82
|
+
const models = typeof data?.models === 'number'
|
|
83
|
+
? data.models
|
|
84
|
+
: Array.isArray(data?.services) ? data.services.length : undefined;
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
ready,
|
|
88
|
+
status,
|
|
89
|
+
...(models !== undefined ? { models } : {}),
|
|
90
|
+
detail: `${status}${models !== undefined ? ` · ${models} models` : ''}`,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return { ok: false, ready: false, status: 'unreachable', detail: messageOf(error) };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async models(): Promise<ModelOption[]> {
|
|
100
|
+
const payload = await this.request(
|
|
101
|
+
this.endpoint('/v1/models'),
|
|
102
|
+
{ method: 'GET', headers: this.headers() },
|
|
103
|
+
this.listTimeout(),
|
|
104
|
+
undefined,
|
|
105
|
+
(res) => this.readJson(res),
|
|
106
|
+
) as { data?: unknown } | unknown[] | null;
|
|
107
|
+
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] : [];
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async voices(model?: string): Promise<VoiceOption[]> {
|
|
115
|
+
const url = new URL(this.endpoint('/v1/audio/voices'));
|
|
116
|
+
const wanted = (model ?? this.cfg.model).trim();
|
|
117
|
+
if (wanted) url.searchParams.set('model', wanted);
|
|
118
|
+
const payload = await this.request(
|
|
119
|
+
url.toString(),
|
|
120
|
+
{ method: 'GET', headers: this.headers() },
|
|
121
|
+
this.listTimeout(),
|
|
122
|
+
undefined,
|
|
123
|
+
(res) => this.readJson(res),
|
|
124
|
+
) as { data?: unknown; voices?: unknown } | unknown[] | null;
|
|
125
|
+
const raw = Array.isArray(payload)
|
|
126
|
+
? payload
|
|
127
|
+
: Array.isArray(payload?.data) ? payload.data
|
|
128
|
+
: Array.isArray(payload?.voices) ? payload.voices
|
|
129
|
+
: [];
|
|
130
|
+
return raw.flatMap((item): VoiceOption[] => {
|
|
131
|
+
const option = optionOf(item);
|
|
132
|
+
return option ? [option] : [];
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ———————— 内部 ————————
|
|
137
|
+
|
|
138
|
+
/** 地址可以带 `/v1`,也可以不带;路径统一从网关根算起。 */
|
|
139
|
+
private endpoint(path: string): string {
|
|
140
|
+
const raw = this.cfg.serviceUrl.trim();
|
|
141
|
+
if (!raw) throw new Error('serviceUrl is empty');
|
|
142
|
+
let url: URL;
|
|
143
|
+
try {
|
|
144
|
+
url = new URL(raw);
|
|
145
|
+
} catch {
|
|
146
|
+
throw new Error(`serviceUrl is not a valid URL: ${raw}`);
|
|
147
|
+
}
|
|
148
|
+
url.search = '';
|
|
149
|
+
url.hash = '';
|
|
150
|
+
if (!url.pathname.endsWith('/')) url.pathname += '/';
|
|
151
|
+
const base = url.pathname.replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
152
|
+
url.pathname = `${base}${path}`;
|
|
153
|
+
return url.toString();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private headers(json = false): Record<string, string> {
|
|
157
|
+
const out: Record<string, string> = {};
|
|
158
|
+
if (json) out['Content-Type'] = 'application/json';
|
|
159
|
+
const name = this.cfg.serviceTokenSecret.trim();
|
|
160
|
+
const token = name ? this.deps.secret(name).trim() : '';
|
|
161
|
+
if (token) out.Authorization = `Bearer ${token}`;
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private listTimeout(): number {
|
|
166
|
+
return Math.min(this.cfg.timeoutMs || LIST_TIMEOUT_MS, LIST_TIMEOUT_MS);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async readJson(res: Response): Promise<unknown> {
|
|
170
|
+
if (!res.ok) throw new Error(await errorText(res));
|
|
171
|
+
return res.json().catch(() => null);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 超时覆盖到回应体读完,不只是等到响应头;外部 signal 的取消也持续到读完。
|
|
176
|
+
* `read` 自己抛的错原样保留,超时统一报同一个句子。
|
|
177
|
+
*/
|
|
178
|
+
private async request<T>(
|
|
179
|
+
url: string,
|
|
180
|
+
init: RequestInit,
|
|
181
|
+
timeoutMs: number,
|
|
182
|
+
signal: AbortSignal | undefined,
|
|
183
|
+
read: (res: Response) => Promise<T>,
|
|
184
|
+
): Promise<T> {
|
|
185
|
+
const ctrl = new AbortController();
|
|
186
|
+
const ms = Math.max(1, timeoutMs);
|
|
187
|
+
let timedOut = false;
|
|
188
|
+
const timeoutError = (): Error => new Error(`voice service timed out after ${ms} ms`);
|
|
189
|
+
let rejectDeadline: (error: Error) => void = () => {};
|
|
190
|
+
const deadline = new Promise<never>((_resolve, reject) => { rejectDeadline = reject; });
|
|
191
|
+
const timer = setTimeout(() => {
|
|
192
|
+
timedOut = true;
|
|
193
|
+
const error = timeoutError();
|
|
194
|
+
ctrl.abort(error);
|
|
195
|
+
rejectDeadline(error);
|
|
196
|
+
}, ms);
|
|
197
|
+
const forward = (): void => ctrl.abort(signal?.reason);
|
|
198
|
+
if (signal) {
|
|
199
|
+
if (signal.aborted) forward();
|
|
200
|
+
else signal.addEventListener('abort', forward, { once: true });
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const res = await Promise.race([
|
|
204
|
+
this.deps.fetch(url, { ...init, signal: ctrl.signal }),
|
|
205
|
+
deadline,
|
|
206
|
+
]);
|
|
207
|
+
return await Promise.race([read(res), deadline]);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (timedOut) throw timeoutError();
|
|
210
|
+
throw error;
|
|
211
|
+
} finally {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
signal?.removeEventListener('abort', forward);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function messageOf(error: unknown): string {
|
|
219
|
+
return error instanceof Error ? error.message : String(error);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function optionOf(item: unknown): { value: string; label: string } | null {
|
|
223
|
+
if (typeof item === 'string') {
|
|
224
|
+
const value = item.trim();
|
|
225
|
+
return value ? { value, label: value } : null;
|
|
226
|
+
}
|
|
227
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
228
|
+
const o = item as Record<string, unknown>;
|
|
229
|
+
const value = [o.value, o.id, o.name]
|
|
230
|
+
.find((v): v is string => typeof v === 'string' && v.trim() !== '')?.trim();
|
|
231
|
+
if (!value) return null;
|
|
232
|
+
const label = [o.label, o.display_name, o.displayName, o.name]
|
|
233
|
+
.find((v): v is string => typeof v === 'string' && v.trim() !== '')?.trim() ?? value;
|
|
234
|
+
return { value, label };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 音频回应只接受 wav / mpeg;octet-stream 按请求的格式归一。 */
|
|
238
|
+
function normalizeMime(raw: string | null, format: AudioFormat): string | null {
|
|
239
|
+
const type = (raw ?? '').split(';')[0]!.trim().toLowerCase();
|
|
240
|
+
if (!type || type === 'application/octet-stream') return format === 'mp3' ? 'audio/mpeg' : 'audio/wav';
|
|
241
|
+
if (type === 'audio/wav' || type === 'audio/wave' || type === 'audio/x-wav') return 'audio/wav';
|
|
242
|
+
if (type === 'audio/mpeg' || type === 'audio/mp3' || type === 'audio/mpeg3' || type === 'audio/x-mpeg') return 'audio/mpeg';
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 压成一行:状态行加服务返回的错误正文(JSON 只取 message/code/param)。 */
|
|
247
|
+
async function errorText(res: Response): Promise<string> {
|
|
248
|
+
let raw = '';
|
|
249
|
+
try { raw = await res.text(); } catch { /* 读不到就只用状态 */ }
|
|
250
|
+
let detail = raw.replace(/\s+/g, ' ').trim();
|
|
251
|
+
if (detail) {
|
|
252
|
+
try {
|
|
253
|
+
const parsed = JSON.parse(raw) as { error?: unknown };
|
|
254
|
+
const error = parsed?.error;
|
|
255
|
+
if (typeof error === 'string') {
|
|
256
|
+
detail = error.trim() || detail;
|
|
257
|
+
} else if (error && typeof error === 'object' && !Array.isArray(error)) {
|
|
258
|
+
const e = error as Record<string, unknown>;
|
|
259
|
+
const parts: string[] = [];
|
|
260
|
+
if (typeof e.message === 'string' && e.message.trim()) parts.push(e.message.trim());
|
|
261
|
+
if (typeof e.code === 'string' && e.code.trim()) parts.push(e.code.trim());
|
|
262
|
+
if (typeof e.param === 'string' && e.param.trim()) parts.push(`param ${e.param.trim()}`);
|
|
263
|
+
if (parts.length) detail = parts.join(' · ');
|
|
264
|
+
}
|
|
265
|
+
} catch { /* 非 JSON,保留原文 */ }
|
|
266
|
+
}
|
|
267
|
+
const status = `voice service ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`;
|
|
268
|
+
return `${status}${detail ? `: ${detail.slice(0, 500)}` : ''}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** PCM16 WAV 的时长;不是 RIFF/WAVE 时返回 undefined。 */
|
|
272
|
+
export function wavSeconds(bytes: Uint8Array): number | undefined {
|
|
273
|
+
if (bytes.length < 44) return undefined;
|
|
274
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
275
|
+
if (view.getUint32(0, false) !== 0x52494646 || view.getUint32(8, false) !== 0x57415645) return undefined;
|
|
276
|
+
let offset = 12;
|
|
277
|
+
let byteRate = 0;
|
|
278
|
+
while (offset + 8 <= bytes.length) {
|
|
279
|
+
const id = view.getUint32(offset, false);
|
|
280
|
+
const size = view.getUint32(offset + 4, true);
|
|
281
|
+
const body = offset + 8;
|
|
282
|
+
if (id === 0x666d7420 && body + 16 <= bytes.length) byteRate = view.getUint32(body + 8, true);
|
|
283
|
+
if (id === 0x64617461) {
|
|
284
|
+
if (byteRate > 0 && size > 0) return size / byteRate;
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
offset = body + size + (size % 2);
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
package/src/speech/style.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** 情绪、语速与表演指令的归一。 */
|
|
2
|
-
import type {
|
|
2
|
+
import type { VoiceConfigSection } from '../types.ts';
|
|
3
3
|
|
|
4
4
|
export interface StyleOverride {
|
|
5
5
|
emotion?: string;
|
|
@@ -20,13 +20,6 @@ export function classifySpeed(speed: number): '慢速' | '中速' | '快速' {
|
|
|
20
20
|
return '中速';
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function classifyLevel(value: number): '低' | '中' | '高' {
|
|
24
|
-
if (!Number.isFinite(value)) return '中';
|
|
25
|
-
if (value <= 0.34) return '低';
|
|
26
|
-
if (value >= 0.67) return '高';
|
|
27
|
-
return '中';
|
|
28
|
-
}
|
|
29
|
-
|
|
30
23
|
/** 参数 > 配置默认值。空串表示没给。 */
|
|
31
24
|
export function resolveStyle(cfg: VoiceConfigSection, override: StyleOverride = {}): ResolvedStyle {
|
|
32
25
|
return {
|
|
@@ -37,7 +30,7 @@ export function resolveStyle(cfg: VoiceConfigSection, override: StyleOverride =
|
|
|
37
30
|
}
|
|
38
31
|
|
|
39
32
|
/**
|
|
40
|
-
*
|
|
33
|
+
* 自然语言表演指令,作为 `instructions` 发给服务。
|
|
41
34
|
* 有 instruct 时以它为主,情绪与语速续在后面。
|
|
42
35
|
*/
|
|
43
36
|
export function buildInstruct(style: ResolvedStyle): string {
|
|
@@ -52,8 +45,3 @@ export function buildInstruct(style: ResolvedStyle): string {
|
|
|
52
45
|
}
|
|
53
46
|
return parts.length ? `用${parts.join(',')}的语气朗读。` : '';
|
|
54
47
|
}
|
|
55
|
-
|
|
56
|
-
/** 某种情绪模式需不需要单独的情绪字段。 */
|
|
57
|
-
export function sendsEmotion(mode: EmotionMode): boolean {
|
|
58
|
-
return mode === 'categorical';
|
|
59
|
-
}
|