@kmchai/cortico-world-voice 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/service.ts ADDED
@@ -0,0 +1,371 @@
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, ModelParameter, ModelParameterType, ServiceHealth, TtsAudio, TtsRequest,
9
+ VoiceConfigSection, VoiceModel, VoiceOption,
10
+ } from './types.ts';
11
+
12
+ const PROBE_TIMEOUT_MS = 8000;
13
+ const LIST_TIMEOUT_MS = 8000;
14
+
15
+ export interface ServiceClientDeps {
16
+ fetch: typeof fetch;
17
+ /** 从部署 .env 读密钥;名字为空时返回空串。 */
18
+ secret(name: string): string;
19
+ /** `worlds.voice` 的活引用。 */
20
+ cfg(): VoiceConfigSection;
21
+ }
22
+
23
+ /** World 只需要这些方法;测试用假客户端也实现它们。 */
24
+ export type SpeechService = Pick<ServiceClient, 'synthesize' | 'health' | 'models' | 'voices'>;
25
+
26
+ export class ServiceClient implements SpeechService {
27
+ constructor(private readonly deps: ServiceClientDeps) {}
28
+
29
+ private get cfg(): VoiceConfigSection {
30
+ return this.deps.cfg();
31
+ }
32
+
33
+ async synthesize(req: TtsRequest, signal?: AbortSignal): Promise<TtsAudio> {
34
+ const cfg = this.cfg;
35
+ const body: Record<string, unknown> = {
36
+ input: req.text,
37
+ response_format: req.format,
38
+ };
39
+ const model = cfg.model.trim();
40
+ if (model) body.model = model;
41
+ // 只带当前模型自己的参数;其他模型的配置不混进这次请求。
42
+ const options = cfg.modelOptions?.[model];
43
+ if (options && Object.keys(options).length > 0) body.options = options;
44
+ const voice = (req.voice ?? '').trim() || cfg.voice.trim();
45
+ if (voice) body.voice = voice;
46
+ const instruct = (req.instruct ?? '').trim();
47
+ if (instruct) body.instructions = instruct;
48
+ const emotion = (req.emotion ?? '').trim();
49
+ if (emotion) body.emotion = emotion;
50
+ if (req.speed !== undefined && Number.isFinite(req.speed)) body.speed = req.speed;
51
+
52
+ return this.request(this.endpoint('/v1/audio/speech'), {
53
+ method: 'POST',
54
+ headers: this.headers(true),
55
+ body: JSON.stringify(body),
56
+ }, cfg.timeoutMs, signal ?? req.signal, async (res) => {
57
+ if (!res.ok) throw new Error(await errorText(res));
58
+ const declared = res.headers.get('content-type');
59
+ const mime = normalizeMime(declared, req.format);
60
+ if (!mime) {
61
+ throw new Error(`voice service returned ${declared?.split(';')[0]?.trim() || 'no content-type'} instead of audio`);
62
+ }
63
+ const bytes = new Uint8Array(await res.arrayBuffer());
64
+ if (bytes.length === 0) throw new Error('voice service returned an empty audio body');
65
+ return { bytes, mime, seconds: mime === 'audio/wav' ? wavSeconds(bytes) : undefined };
66
+ });
67
+ }
68
+
69
+ /** 探活;连不上或回应不合法时返回 `ok: false`,不抛错。 */
70
+ async health(): Promise<ServiceHealth> {
71
+ try {
72
+ return await this.request(
73
+ this.endpoint('/health'),
74
+ { method: 'GET', headers: this.headers() },
75
+ Math.min(this.cfg.timeoutMs || PROBE_TIMEOUT_MS, PROBE_TIMEOUT_MS),
76
+ undefined,
77
+ async (res): Promise<ServiceHealth> => {
78
+ if (!res.ok) {
79
+ return { ok: false, ready: false, status: `HTTP ${res.status}`, detail: await errorText(res) };
80
+ }
81
+ const data = await res.json().catch(() => null) as {
82
+ status?: unknown; ready?: unknown; models?: unknown; services?: unknown;
83
+ } | null;
84
+ const status = typeof data?.status === 'string' && data.status.trim() ? data.status.trim() : 'ok';
85
+ const ready = data?.ready === true;
86
+ const models = typeof data?.models === 'number'
87
+ ? data.models
88
+ : Array.isArray(data?.services) ? data.services.length : undefined;
89
+ return {
90
+ ok: true,
91
+ ready,
92
+ status,
93
+ ...(models !== undefined ? { models } : {}),
94
+ detail: `${status}${models !== undefined ? ` · ${models} models` : ''}`,
95
+ };
96
+ },
97
+ );
98
+ } catch (error) {
99
+ return { ok: false, ready: false, status: 'unreachable', detail: messageOf(error) };
100
+ }
101
+ }
102
+
103
+ async models(): Promise<VoiceModel[]> {
104
+ const payload = await this.request(
105
+ this.endpoint('/v1/models'),
106
+ { method: 'GET', headers: this.headers() },
107
+ this.listTimeout(),
108
+ undefined,
109
+ (res) => this.readJson(res),
110
+ ) as { data?: unknown } | unknown[] | null;
111
+ const raw = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : [];
112
+ return raw.flatMap((item): VoiceModel[] => {
113
+ const model = modelOf(item);
114
+ return model ? [model] : [];
115
+ });
116
+ }
117
+
118
+ async voices(model?: string): Promise<VoiceOption[]> {
119
+ const url = new URL(this.endpoint('/v1/audio/voices'));
120
+ const wanted = (model ?? this.cfg.model).trim();
121
+ if (wanted) url.searchParams.set('model', wanted);
122
+ const payload = await this.request(
123
+ url.toString(),
124
+ { method: 'GET', headers: this.headers() },
125
+ this.listTimeout(),
126
+ undefined,
127
+ (res) => this.readJson(res),
128
+ ) as { data?: unknown; voices?: unknown } | unknown[] | null;
129
+ const raw = Array.isArray(payload)
130
+ ? payload
131
+ : Array.isArray(payload?.data) ? payload.data
132
+ : Array.isArray(payload?.voices) ? payload.voices
133
+ : [];
134
+ return raw.flatMap((item): VoiceOption[] => {
135
+ const option = optionOf(item);
136
+ return option ? [option] : [];
137
+ });
138
+ }
139
+
140
+ // ———————— 内部 ————————
141
+
142
+ /** 地址可以带 `/v1`,也可以不带;路径统一从网关根算起。 */
143
+ private endpoint(path: string): string {
144
+ const raw = this.cfg.serviceUrl.trim();
145
+ if (!raw) throw new Error('serviceUrl is empty');
146
+ let url: URL;
147
+ try {
148
+ url = new URL(raw);
149
+ } catch {
150
+ throw new Error(`serviceUrl is not a valid URL: ${raw}`);
151
+ }
152
+ url.search = '';
153
+ url.hash = '';
154
+ if (!url.pathname.endsWith('/')) url.pathname += '/';
155
+ const base = url.pathname.replace(/\/+$/, '').replace(/\/v1$/, '');
156
+ url.pathname = `${base}${path}`;
157
+ return url.toString();
158
+ }
159
+
160
+ private headers(json = false): Record<string, string> {
161
+ const out: Record<string, string> = {};
162
+ if (json) out['Content-Type'] = 'application/json';
163
+ const name = this.cfg.serviceTokenSecret.trim();
164
+ const token = name ? this.deps.secret(name).trim() : '';
165
+ if (token) out.Authorization = `Bearer ${token}`;
166
+ return out;
167
+ }
168
+
169
+ private listTimeout(): number {
170
+ return Math.min(this.cfg.timeoutMs || LIST_TIMEOUT_MS, LIST_TIMEOUT_MS);
171
+ }
172
+
173
+ private async readJson(res: Response): Promise<unknown> {
174
+ if (!res.ok) throw new Error(await errorText(res));
175
+ return res.json().catch(() => null);
176
+ }
177
+
178
+ /**
179
+ * 超时覆盖到回应体读完,不只是等到响应头;外部 signal 的取消也持续到读完。
180
+ * `read` 自己抛的错原样保留,超时统一报同一个句子。
181
+ */
182
+ private async request<T>(
183
+ url: string,
184
+ init: RequestInit,
185
+ timeoutMs: number,
186
+ signal: AbortSignal | undefined,
187
+ read: (res: Response) => Promise<T>,
188
+ ): Promise<T> {
189
+ const ctrl = new AbortController();
190
+ const ms = Math.max(1, timeoutMs);
191
+ let timedOut = false;
192
+ const timeoutError = (): Error => new Error(`voice service timed out after ${ms} ms`);
193
+ let rejectDeadline: (error: Error) => void = () => {};
194
+ const deadline = new Promise<never>((_resolve, reject) => { rejectDeadline = reject; });
195
+ const timer = setTimeout(() => {
196
+ timedOut = true;
197
+ const error = timeoutError();
198
+ ctrl.abort(error);
199
+ rejectDeadline(error);
200
+ }, ms);
201
+ const forward = (): void => ctrl.abort(signal?.reason);
202
+ if (signal) {
203
+ if (signal.aborted) forward();
204
+ else signal.addEventListener('abort', forward, { once: true });
205
+ }
206
+ try {
207
+ const res = await Promise.race([
208
+ this.deps.fetch(url, { ...init, signal: ctrl.signal }),
209
+ deadline,
210
+ ]);
211
+ return await Promise.race([read(res), deadline]);
212
+ } catch (error) {
213
+ if (timedOut) throw timeoutError();
214
+ throw error;
215
+ } finally {
216
+ clearTimeout(timer);
217
+ signal?.removeEventListener('abort', forward);
218
+ }
219
+ }
220
+ }
221
+
222
+ function messageOf(error: unknown): string {
223
+ return error instanceof Error ? error.message : String(error);
224
+ }
225
+
226
+ function optionOf(item: unknown): { value: string; label: string } | null {
227
+ if (typeof item === 'string') {
228
+ const value = item.trim();
229
+ return value ? { value, label: value } : null;
230
+ }
231
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
232
+ const o = item as Record<string, unknown>;
233
+ const value = [o.value, o.id, o.name]
234
+ .find((v): v is string => typeof v === 'string' && v.trim() !== '')?.trim();
235
+ if (!value) return null;
236
+ const label = [o.label, o.display_name, o.displayName, o.name]
237
+ .find((v): v is string => typeof v === 'string' && v.trim() !== '')?.trim() ?? value;
238
+ return { value, label };
239
+ }
240
+
241
+ const PARAMETER_TYPES = new Set<ModelParameterType>([
242
+ 'string', 'enum', 'bool', 'int', 'float', 'string_list', 'path', 'path_list',
243
+ ]);
244
+
245
+ function firstString(...values: unknown[]): string {
246
+ for (const value of values) {
247
+ if (typeof value === 'string' && value.trim()) return value.trim();
248
+ }
249
+ return '';
250
+ }
251
+
252
+ /** 目录里的音色/取值可能写成字符串,也可能写成对象;只留去重后的字符串。 */
253
+ function stringItems(value: unknown): string[] {
254
+ if (!Array.isArray(value)) return [];
255
+ const out: string[] = [];
256
+ for (const item of value) {
257
+ const text = typeof item === 'string'
258
+ ? item.trim()
259
+ : item && typeof item === 'object' && !Array.isArray(item)
260
+ ? firstString(
261
+ (item as Record<string, unknown>).value,
262
+ (item as Record<string, unknown>).id,
263
+ (item as Record<string, unknown>).name,
264
+ (item as Record<string, unknown>).label,
265
+ )
266
+ : '';
267
+ if (text && !out.includes(text)) out.push(text);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function finiteNumber(value: unknown): number | undefined {
273
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
274
+ }
275
+
276
+ function parameterOf(item: unknown): ModelParameter | null {
277
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
278
+ const o = item as Record<string, unknown>;
279
+ const name = firstString(o.name);
280
+ const type = firstString(o.type) as ModelParameterType;
281
+ if (!name || !PARAMETER_TYPES.has(type)) return null;
282
+ const description = firstString(o.description);
283
+ const values = stringItems(o.values);
284
+ const min = finiteNumber(o.min);
285
+ const max = finiteNumber(o.max);
286
+ return {
287
+ name,
288
+ type,
289
+ ...(description ? { description } : {}),
290
+ ...(values.length ? { values } : {}),
291
+ ...(o.default !== undefined ? { default: o.default } : {}),
292
+ ...(min !== undefined ? { min } : {}),
293
+ ...(max !== undefined ? { max } : {}),
294
+ };
295
+ }
296
+
297
+ /** 服务目录的一项:模型 id、显示名、音色与请求参数;不认识的字段不带走。 */
298
+ function modelOf(item: unknown): VoiceModel | null {
299
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
300
+ const o = item as Record<string, unknown>;
301
+ const id = firstString(o.id, o.value, o.name);
302
+ if (!id) return null;
303
+ const label = firstString(o.display_name, o.displayName, o.label);
304
+ const parameters = Array.isArray(o.parameters)
305
+ ? o.parameters.flatMap((entry): ModelParameter[] => {
306
+ const parameter = parameterOf(entry);
307
+ return parameter ? [parameter] : [];
308
+ })
309
+ : [];
310
+ return {
311
+ id,
312
+ ...(label ? { label } : {}),
313
+ voices: stringItems(o.voices),
314
+ ...(parameters.length ? { parameters } : {}),
315
+ };
316
+ }
317
+
318
+ /** 音频回应只接受 wav / mpeg;octet-stream 按请求的格式归一。 */
319
+ function normalizeMime(raw: string | null, format: AudioFormat): string | null {
320
+ const type = (raw ?? '').split(';')[0]!.trim().toLowerCase();
321
+ if (!type || type === 'application/octet-stream') return format === 'mp3' ? 'audio/mpeg' : 'audio/wav';
322
+ if (type === 'audio/wav' || type === 'audio/wave' || type === 'audio/x-wav') return 'audio/wav';
323
+ if (type === 'audio/mpeg' || type === 'audio/mp3' || type === 'audio/mpeg3' || type === 'audio/x-mpeg') return 'audio/mpeg';
324
+ return null;
325
+ }
326
+
327
+ /** 压成一行:状态行加服务返回的错误正文(JSON 只取 message/code/param)。 */
328
+ async function errorText(res: Response): Promise<string> {
329
+ let raw = '';
330
+ try { raw = await res.text(); } catch { /* 读不到就只用状态 */ }
331
+ let detail = raw.replace(/\s+/g, ' ').trim();
332
+ if (detail) {
333
+ try {
334
+ const parsed = JSON.parse(raw) as { error?: unknown };
335
+ const error = parsed?.error;
336
+ if (typeof error === 'string') {
337
+ detail = error.trim() || detail;
338
+ } else if (error && typeof error === 'object' && !Array.isArray(error)) {
339
+ const e = error as Record<string, unknown>;
340
+ const parts: string[] = [];
341
+ if (typeof e.message === 'string' && e.message.trim()) parts.push(e.message.trim());
342
+ if (typeof e.code === 'string' && e.code.trim()) parts.push(e.code.trim());
343
+ if (typeof e.param === 'string' && e.param.trim()) parts.push(`param ${e.param.trim()}`);
344
+ if (parts.length) detail = parts.join(' · ');
345
+ }
346
+ } catch { /* 非 JSON,保留原文 */ }
347
+ }
348
+ const status = `voice service ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`;
349
+ return `${status}${detail ? `: ${detail.slice(0, 500)}` : ''}`;
350
+ }
351
+
352
+ /** PCM16 WAV 的时长;不是 RIFF/WAVE 时返回 undefined。 */
353
+ export function wavSeconds(bytes: Uint8Array): number | undefined {
354
+ if (bytes.length < 44) return undefined;
355
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
356
+ if (view.getUint32(0, false) !== 0x52494646 || view.getUint32(8, false) !== 0x57415645) return undefined;
357
+ let offset = 12;
358
+ let byteRate = 0;
359
+ while (offset + 8 <= bytes.length) {
360
+ const id = view.getUint32(offset, false);
361
+ const size = view.getUint32(offset + 4, true);
362
+ const body = offset + 8;
363
+ if (id === 0x666d7420 && body + 16 <= bytes.length) byteRate = view.getUint32(body + 8, true);
364
+ if (id === 0x64617461) {
365
+ if (byteRate > 0 && size > 0) return size / byteRate;
366
+ return undefined;
367
+ }
368
+ offset = body + size + (size % 2);
369
+ }
370
+ return undefined;
371
+ }
@@ -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
- }
@@ -5,7 +5,7 @@
5
5
  * 情绪标记只是控制注释,合成前从文本里删掉。
6
6
  */
7
7
 
8
- /** Ming 3.0 / audiocpp 支持的副语言标签。 */
8
+ /** Ming 3.0 系模型支持的副语言标签。 */
9
9
  export const PARALINGUISTIC_TAGS: readonly string[] = [
10
10
  'Breathing',
11
11
  'Cough',
@@ -57,13 +57,15 @@ export function stripUnknownTags(text: string): string {
57
57
  }
58
58
 
59
59
  /**
60
- * 自动模式只在支持标签的后端保留标签。`audiocpp` 的 Ming 3.0 支持;
61
- * OpenAI 兼容服务把它们当正文念,所以删掉。
60
+ * auto 只在当前模型支持标签时保留;keep 一律保留;strip 一律删掉。
62
61
  */
63
- export function keepsParalinguistic(backend: string, mode: 'auto' | 'keep' | 'strip'): boolean {
64
- if (mode === 'keep') return true;
62
+ export function keepsParalinguistic(
63
+ mode: 'auto' | 'keep' | 'strip',
64
+ tagsSupported: boolean,
65
+ ): boolean {
65
66
  if (mode === 'strip') return false;
66
- return backend === 'audiocpp';
67
+ if (mode === 'keep') return true;
68
+ return tagsSupported;
67
69
  }
68
70
 
69
71
  const EMOTION_MARKERS: ReadonlyArray<RegExp> = [
@@ -1,4 +1,5 @@
1
1
  /** 把一段回复变成能念的正文:取 `<tts>`、去代码/网址/Markdown/动作标记/符号。 */
2
+ import { modelSupportsTags } from '../models.ts';
2
3
  import type { VoiceConfigSection } from '../types.ts';
3
4
  import {
4
5
  canonicalizeTags,
@@ -86,7 +87,9 @@ export function filterSpeech(raw: string, cfg: VoiceConfigSection): FilteredSpee
86
87
 
87
88
  text = canonicalizeTags(text);
88
89
  text = stripUnknownTags(text);
89
- if (!keepsParalinguistic(cfg.backend, cfg.paralinguistic)) text = stripParalinguisticTags(text);
90
+ if (!keepsParalinguistic(cfg.paralinguistic, modelSupportsTags(cfg.model))) {
91
+ text = stripParalinguisticTags(text);
92
+ }
90
93
 
91
94
  if (cfg.filterEmoji) text = text.replace(EMOJI_RE, '').replace(SYMBOL_RE, '');
92
95
 
package/src/tools.ts CHANGED
@@ -8,7 +8,7 @@ export const VOICE_TOOL_DECLS: readonly ToolSpec[] = [
8
8
  'Say a line aloud in your voice through the person\'s speakers, or only synthesize it when silent is true. '
9
9
  + 'Use it when the person asks for voice, when this line needs a specific emotion or voice, or when automatic reading is off. '
10
10
  + 'It only makes sound; the visible reply still goes through the channel\'s own send tool. '
11
- + 'When the backend supports paralinguistic tags, keep them inline in the text: [Laughter], [Sigh], [Breathing], [Uhm], [Question-ei] and the like are performed, not read out.',
11
+ + 'When the speech service supports paralinguistic tags, keep them inline in the text: [Laughter], [Sigh], [Breathing], [Uhm], [Question-ei] and the like are performed, not read out.',
12
12
  tags: ['speak'],
13
13
  parameters: {
14
14
  type: 'object',
@@ -24,21 +24,6 @@ export const VOICE_TOOL_DECLS: readonly ToolSpec[] = [
24
24
  required: ['text'],
25
25
  },
26
26
  },
27
- {
28
- name: 'voice_sing',
29
- description:
30
- 'Sing a lyric line through the configured singing plugin. It only makes sound; the visible reply still goes through the channel\'s own send tool. '
31
- + 'If no singing plugin is configured, or it is not ready, the call fails instead of speaking the lyrics.',
32
- tags: ['speak'],
33
- parameters: {
34
- type: 'object',
35
- additionalProperties: false,
36
- properties: {
37
- lyrics: { type: 'string', maxLength: 2000, description: 'The lyric line to sing.' },
38
- },
39
- required: ['lyrics'],
40
- },
41
- },
42
27
  {
43
28
  name: 'voice_hush',
44
29
  description: 'Stop the voice now: the line playing is cut and queued lines are dropped. It does not affect the visible reply.',
package/src/types.ts CHANGED
@@ -1,9 +1,7 @@
1
- /** 共享类型:配置段、合成请求与音频、协议适配器接口。 */
2
- import type { Logger } from 'cortico/core/types.ts';
1
+ /** 共享类型:配置段、合成请求与音频、服务目录与健康。 */
3
2
 
4
- export type BackendId = 'audiocpp' | 'openai' | 'custom';
5
3
  export type AudioFormat = 'wav' | 'mp3';
6
- /** 情绪怎么发给后端。 */
4
+ /** 情绪怎么发给服务。 */
7
5
  export type EmotionMode = 'instruct' | 'categorical' | 'basic' | 'tags' | 'none';
8
6
  /** 副语言标签(`[Laughter]` 等)怎么处理。 */
9
7
  export type ParalinguisticMode = 'auto' | 'keep' | 'strip';
@@ -13,13 +11,15 @@ export type PlaybackMode = 'local' | 'event' | 'none';
13
11
  export interface VoiceConfigSection {
14
12
  enabled: boolean;
15
13
 
16
- // 连接与协议
17
- backend: BackendId;
18
- baseUrl: string;
14
+ // 连接
15
+ /** Kmch AI · 服务 的网关地址。 */
16
+ serviceUrl: string;
17
+ /** 服务令牌的环境变量名;空表示服务不校验。 */
18
+ serviceTokenSecret: string;
19
19
  model: string;
20
20
  voice: string;
21
- /** 密钥的环境变量名;空表示不需要。 */
22
- apiKeySecret: string;
21
+ /** 模型专属的 request 参数,按模型 id 分组;由「模型」面板写回,不进 ConfigGroup。 */
22
+ modelOptions: Record<string, Record<string, unknown>>;
23
23
  format: AudioFormat;
24
24
  timeoutMs: number;
25
25
 
@@ -65,28 +65,6 @@ export interface VoiceConfigSection {
65
65
  gapMs: number;
66
66
  /** 工具回执带音频附件,渠道工具可用 `[blob …]` 句柄发送。 */
67
67
  shareAudio: boolean;
68
-
69
- // 本地语音接口
70
- httpApi: boolean;
71
- httpPort: number;
72
- httpToken: string;
73
- /** 经 HTTP 接口合成时是否也在本机播放。 */
74
- httpPlay: boolean;
75
-
76
- // 唱歌
77
- singUrl: string;
78
- singToken: string;
79
-
80
- // custom 协议
81
- customUrl: string;
82
- customMethod: 'POST' | 'GET';
83
- /** JSON 对象字符串,值里的 `${KEY}` 换成 apiKeySecret 读到的密钥。 */
84
- customHeaders: string;
85
- /** JSON 模板;占位符见 README。 */
86
- customBody: string;
87
- customResponse: 'audio' | 'base64' | 'url';
88
- /** base64/url 回应里音频字段的点路径。 */
89
- customAudioPath: string;
90
68
  }
91
69
 
92
70
  /** 一次合成。`text` 已经过过滤,不再含控制标记。 */
@@ -107,30 +85,49 @@ export interface TtsAudio {
107
85
  seconds?: number;
108
86
  }
109
87
 
110
- export interface VoiceOption {
88
+ /** 配置下拉的一项;`value` 写回配置,`label` 显示。 */
89
+ export interface SelectOption {
111
90
  value: string;
112
91
  label: string;
113
92
  }
114
93
 
115
- export interface BackendHealth {
116
- ok: boolean;
117
- detail?: string;
94
+ export type VoiceOption = SelectOption;
95
+
96
+ /** 服务 `GET /v1/models` 里 `parameters[].type` 的取值。 */
97
+ export type ModelParameterType =
98
+ | 'string'
99
+ | 'enum'
100
+ | 'bool'
101
+ | 'int'
102
+ | 'float'
103
+ | 'string_list'
104
+ | 'path'
105
+ | 'path_list';
106
+
107
+ /** 模型请求参数的元数据;服务按模型给出,面板据此渲染字段。 */
108
+ export interface ModelParameter {
109
+ name: string;
110
+ type: ModelParameterType;
111
+ description?: string;
112
+ values?: string[];
113
+ default?: unknown;
114
+ min?: number;
115
+ max?: number;
118
116
  }
119
117
 
120
- /** 适配器运行时可用的东西。 */
121
- export interface BackendContext {
122
- cfg: VoiceConfigSection;
123
- secret(name: string): string;
124
- fetch: typeof fetch;
125
- log: Logger;
118
+ /** 服务目录里的一项模型;音色与请求参数元数据随模型走。 */
119
+ export interface VoiceModel {
120
+ id: string;
121
+ label?: string;
122
+ voices: string[];
123
+ parameters?: ModelParameter[];
126
124
  }
127
125
 
128
- /** 一种语音合成协议。新增协议 = 实现这个接口,再在 registry 里登记。 */
129
- export interface TtsBackend {
130
- readonly id: BackendId;
131
- readonly label: string;
132
- synthesize(req: TtsRequest, ctx: BackendContext): Promise<TtsAudio>;
133
- /** 后端能列出的音色;不支持时返回空数组。 */
134
- listVoices(ctx: BackendContext): Promise<VoiceOption[]>;
135
- health(ctx: BackendContext): Promise<BackendHealth>;
126
+ /** `GET /health` 的摘要;`ok` 只表示网关有回应,`ready` 表示至少有一个服务能合成。 */
127
+ export interface ServiceHealth {
128
+ ok: boolean;
129
+ ready: boolean;
130
+ status: string;
131
+ detail?: string;
132
+ models?: number;
136
133
  }