@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/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
- backend: 'audiocpp',
14
- baseUrl: 'http://127.0.0.1:8000',
15
- model: 'ming3-tts',
16
- voice: '4016',
17
- apiKeySecret: '',
15
+ serviceUrl: 'http://127.0.0.1:9883',
16
+ serviceTokenSecret: '',
17
+ model: '',
18
+ voice: '',
18
19
  format: 'wav',
19
- timeoutMs: 120_000,
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
- lampBackend: '语音服务',
57
+ lampService: '语音服务',
72
58
  lampQueue: '朗读队列',
73
- lampApi: '本地接口',
74
- lampSinger: '唱歌',
59
+ servicePage: '服务设置',
75
60
  connection: '连接',
76
- connectionDesc: '语音服务地址与协议。',
77
- backend: '协议',
78
- baseUrl: '服务地址',
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 折成自然语言;categorical 只发情绪标签;basic 只发语速/音调/音量;tags 只用正文标签;none 不发。',
73
+ emotionModeDesc: 'instruct 折成自然语言指令;categorical 只发情绪标签;basic 只发语速;tags 只用正文标签;none 不发。',
89
74
  defaultEmotion: '默认情绪',
90
75
  defaultInstruct: '默认表演指令',
91
76
  speed: '语速',
92
77
  paralinguistic: '副语言标签',
93
- paralinguisticDesc: 'auto 只在支持标签的后端保留;strip 一律从朗读文本里删掉。',
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
- lampBackend: 'Speech service',
116
+ lampService: 'Speech service',
148
117
  lampQueue: 'Speech queue',
149
- lampApi: 'Local API',
150
- lampSinger: 'Singing',
118
+ servicePage: 'Service settings',
151
119
  connection: 'Connection',
152
- connectionDesc: 'Where the speech service is and which protocol it speaks.',
153
- backend: 'Protocol',
154
- baseUrl: 'Base URL',
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/pitch/volume; tags uses inline tags only; none sends nothing.',
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 only on backends that support them; strip removes them from the spoken text.',
170
- triggers: 'Triggers and filters',
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 and API',
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}backend`]: { type: 'string', title: t.backend, enum: ['audiocpp', 'openai', 'custom'], 'x-hot': true },
242
- [`${p}baseUrl`]: { type: 'string', title: t.baseUrl, 'x-hot': true },
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, VOICE_OPTIONS_KIND } from './config.ts';
3
- import { currentVoiceOptions } from './options.ts';
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 === VOICE_OPTIONS_KIND ? currentVoiceOptions() : []),
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
- * 配置页的音色下拉要读运行中实例探到的音色,但 `WorldDefinition.configOptions` 是静态签名。
3
- * 实例启动时把「读当前音色表」的函数注册进来。
2
+ * 配置页的模型与音色下拉要读运行中实例从服务探到的目录,但
3
+ * `WorldDefinition.configOptions` 是静态签名。实例启动或探活时把读取函数注册进来;
4
+ * 只支持 `voice.models` 与 `voice.voices` 两种 kind,未知 kind 返回空。
5
+ * 读的是已经缓存的表,不在配置页里发请求;服务不可达时保留上次的表,没有就是空表。
6
+ * 当前值不在表里时由框架补入,这里不重复做。
4
7
  */
5
- import type { VoiceOption } from './types.ts';
8
+ import { VOICE_MODELS_KIND, VOICE_OPTIONS_KIND } from './config.ts';
9
+ import type { SelectOption } from './types.ts';
6
10
 
7
- let provider: (() => VoiceOption[]) | null = null;
11
+ export const OPTION_KINDS = [VOICE_MODELS_KIND, VOICE_OPTIONS_KIND] as const;
12
+ export type OptionKind = (typeof OPTION_KINDS)[number];
8
13
 
9
- export function registerVoiceOptions(fn: () => VoiceOption[]): void {
10
- provider = fn;
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
- export function currentVoiceOptions(): VoiceOption[] {
14
- return provider?.() ?? [];
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
+ }
@@ -1,5 +1,5 @@
1
1
  /** 情绪、语速与表演指令的归一。 */
2
- import type { EmotionMode, VoiceConfigSection } from '../types.ts';
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
- * 自然语言表演指令。audiocpp 的 freeform 模板与 OpenAI 的 instructions 都用它。
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
- }