@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/CHANGELOG.md +31 -0
- package/README.md +83 -70
- package/describe.json +2 -27
- package/dist/webui.css +1 -0
- package/dist/webui.js +1 -0
- package/package.json +33 -6
- package/src/ENV_PROMPT.md +6 -7
- package/src/audio/queue.ts +1 -1
- package/src/config.ts +60 -117
- package/src/definition.ts +3 -6
- package/src/models.ts +66 -0
- package/src/panel.ts +34 -0
- package/src/service.ts +371 -0
- package/src/speech/style.ts +2 -14
- package/src/speech/tags.ts +8 -6
- package/src/speech/text.ts +4 -1
- package/src/tools.ts +1 -16
- package/src/types.ts +46 -49
- package/src/world.ts +219 -267
- 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/options.ts +0 -15
- package/src/sing.ts +0 -73
package/src/backends/audiocpp.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* audiocpp 的 `/v1/audio/speech`。兼容 OpenAI 的外形,另有一个 `options` 对象:
|
|
3
|
-
* Ming 3.0 用 `template_name` 选 basic / freeform / emotion 三种控制模板。
|
|
4
|
-
*/
|
|
5
|
-
import type { BackendContext, BackendHealth, TtsBackend, TtsRequest, VoiceOption } from '../types.ts';
|
|
6
|
-
import { buildInstruct, classifySpeed, resolveStyle } from '../speech/style.ts';
|
|
7
|
-
import { apiUrl, authHeaders, readAudio, timedFetch } from './http.ts';
|
|
8
|
-
|
|
9
|
-
function optionsOf(req: TtsRequest, cfg: BackendContext['cfg']): Record<string, unknown> | undefined {
|
|
10
|
-
const style = resolveStyle(cfg, req);
|
|
11
|
-
switch (cfg.emotionMode) {
|
|
12
|
-
case 'instruct': {
|
|
13
|
-
const instruct = buildInstruct(style);
|
|
14
|
-
return instruct ? { template_name: 'freeform', instruct } : undefined;
|
|
15
|
-
}
|
|
16
|
-
case 'categorical':
|
|
17
|
-
return { template_name: 'emotion', emotion: style.emotion || '平静' };
|
|
18
|
-
case 'basic':
|
|
19
|
-
return {
|
|
20
|
-
template_name: 'basic',
|
|
21
|
-
speed: classifySpeed(style.speed),
|
|
22
|
-
pitch: '中',
|
|
23
|
-
volume: '中',
|
|
24
|
-
};
|
|
25
|
-
default:
|
|
26
|
-
return undefined;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export const audiocppBackend: TtsBackend = {
|
|
31
|
-
id: 'audiocpp',
|
|
32
|
-
label: 'audiocpp',
|
|
33
|
-
|
|
34
|
-
async synthesize(req: TtsRequest, ctx: BackendContext) {
|
|
35
|
-
const { cfg } = ctx;
|
|
36
|
-
const body: Record<string, unknown> = {
|
|
37
|
-
model: cfg.model.trim() || undefined,
|
|
38
|
-
voice: (req.voice ?? '').trim() || cfg.voice.trim() || undefined,
|
|
39
|
-
input: req.text,
|
|
40
|
-
response_format: req.format,
|
|
41
|
-
};
|
|
42
|
-
const options = optionsOf(req, cfg);
|
|
43
|
-
if (options) body.options = options;
|
|
44
|
-
for (const key of Object.keys(body)) if (body[key] === undefined) delete body[key];
|
|
45
|
-
const res = await timedFetch(ctx.fetch, apiUrl(cfg.baseUrl, '/audio/speech'), {
|
|
46
|
-
method: 'POST',
|
|
47
|
-
headers: { 'Content-Type': 'application/json', ...authHeaders(ctx.secret(cfg.apiKeySecret)) },
|
|
48
|
-
body: JSON.stringify(body),
|
|
49
|
-
}, cfg.timeoutMs, req.signal);
|
|
50
|
-
return readAudio(res, req.format);
|
|
51
|
-
},
|
|
52
|
-
|
|
53
|
-
async listVoices(ctx: BackendContext): Promise<VoiceOption[]> {
|
|
54
|
-
const { cfg } = ctx;
|
|
55
|
-
const url = new URL(apiUrl(cfg.baseUrl, '/audio/voices'));
|
|
56
|
-
if (cfg.model.trim()) url.searchParams.set('model', cfg.model.trim());
|
|
57
|
-
try {
|
|
58
|
-
const res = await timedFetch(ctx.fetch, url.toString(), {
|
|
59
|
-
method: 'GET',
|
|
60
|
-
headers: authHeaders(ctx.secret(cfg.apiKeySecret)),
|
|
61
|
-
}, Math.min(cfg.timeoutMs, 8000));
|
|
62
|
-
if (!res.ok) return [];
|
|
63
|
-
const data = await res.json() as { voices?: unknown };
|
|
64
|
-
const raw = Array.isArray(data?.voices) ? data.voices : Array.isArray(data) ? data : [];
|
|
65
|
-
return raw.flatMap((item): VoiceOption[] => {
|
|
66
|
-
if (typeof item === 'string' && item.trim()) return [{ value: item, label: item }];
|
|
67
|
-
if (item && typeof item === 'object') {
|
|
68
|
-
const o = item as Record<string, unknown>;
|
|
69
|
-
const value = typeof o.id === 'string' ? o.id : typeof o.name === 'string' ? o.name : '';
|
|
70
|
-
if (!value) return [];
|
|
71
|
-
const label = typeof o.label === 'string' ? o.label
|
|
72
|
-
: typeof o.displayName === 'string' ? o.displayName : value;
|
|
73
|
-
return [{ value, label }];
|
|
74
|
-
}
|
|
75
|
-
return [];
|
|
76
|
-
});
|
|
77
|
-
} catch {
|
|
78
|
-
return [];
|
|
79
|
-
}
|
|
80
|
-
},
|
|
81
|
-
|
|
82
|
-
async health(ctx: BackendContext): Promise<BackendHealth> {
|
|
83
|
-
const { cfg } = ctx;
|
|
84
|
-
const root = cfg.baseUrl.trim().replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
85
|
-
try {
|
|
86
|
-
const res = await timedFetch(ctx.fetch, `${root}/health`, {
|
|
87
|
-
method: 'GET',
|
|
88
|
-
headers: authHeaders(ctx.secret(cfg.apiKeySecret)),
|
|
89
|
-
}, Math.min(cfg.timeoutMs, 8000));
|
|
90
|
-
if (!res.ok) return { ok: false, detail: `HTTP ${res.status}` };
|
|
91
|
-
const data = await res.json().catch(() => null) as { models?: number; backend?: string } | null;
|
|
92
|
-
const detail = data ? `${data.backend ?? 'unknown'} · ${data.models ?? 0} models` : undefined;
|
|
93
|
-
return { ok: true, ...(detail ? { detail } : {}) };
|
|
94
|
-
} catch (error) {
|
|
95
|
-
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
};
|
package/src/backends/custom.ts
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 自定义 JSON 协议:请求头与请求体都是配置里的模板,回应可以是音频字节、
|
|
3
|
-
* base64 字段或一个音频地址。给没有专门适配器的服务留的出口。
|
|
4
|
-
*/
|
|
5
|
-
import type { BackendContext, BackendHealth, TtsBackend, TtsAudio, TtsRequest, VoiceOption } from '../types.ts';
|
|
6
|
-
import { buildInstruct, resolveStyle } from '../speech/style.ts';
|
|
7
|
-
import { audioOf, errorSnippet, mimeOf, timedFetch } from './http.ts';
|
|
8
|
-
|
|
9
|
-
const DEFAULT_BODY = '{"text":"{{text}}","voice":"{{voice}}"}';
|
|
10
|
-
|
|
11
|
-
/** JSON 字符串里的内容转义(不带引号)。 */
|
|
12
|
-
function quote(value: string): string {
|
|
13
|
-
return JSON.stringify(value).slice(1, -1);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function valuesOf(req: TtsRequest, ctx: BackendContext): Record<string, string> {
|
|
17
|
-
const style = resolveStyle(ctx.cfg, req);
|
|
18
|
-
return {
|
|
19
|
-
text: req.text,
|
|
20
|
-
voice: (req.voice ?? '').trim() || ctx.cfg.voice.trim(),
|
|
21
|
-
emotion: style.emotion,
|
|
22
|
-
instruct: buildInstruct(style),
|
|
23
|
-
model: ctx.cfg.model.trim(),
|
|
24
|
-
format: req.format,
|
|
25
|
-
speed: Number.isFinite(style.speed) ? String(style.speed) : '1',
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function bodyOf(template: string, values: Record<string, string>): string {
|
|
30
|
-
return (template.trim() || DEFAULT_BODY).replace(/\{\{\s*([a-zA-Z]+)\s*\}\}/g, (whole, key: string) => {
|
|
31
|
-
const value = values[key.toLowerCase()];
|
|
32
|
-
if (value === undefined) return whole;
|
|
33
|
-
// speed 是数字,原样放;其余按 JSON 字符串内容转义。
|
|
34
|
-
return key.toLowerCase() === 'speed' ? value : quote(value);
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function headersOf(raw: string, secret: string): Record<string, string> {
|
|
39
|
-
let parsed: unknown = {};
|
|
40
|
-
if (raw.trim()) {
|
|
41
|
-
try { parsed = JSON.parse(raw); } catch { throw new Error('customHeaders is not valid JSON'); }
|
|
42
|
-
}
|
|
43
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('customHeaders must be a JSON object');
|
|
44
|
-
const out: Record<string, string> = {};
|
|
45
|
-
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
46
|
-
if (typeof value !== 'string') throw new Error(`customHeaders.${key} must be a string`);
|
|
47
|
-
out[key] = value.split('${KEY}').join(secret);
|
|
48
|
-
}
|
|
49
|
-
if (secret && !Object.keys(out).some((k) => k.toLowerCase() === 'authorization')) {
|
|
50
|
-
out.Authorization = `Bearer ${secret}`;
|
|
51
|
-
}
|
|
52
|
-
return out;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function pickPath(data: unknown, path: string): unknown {
|
|
56
|
-
let cur = data;
|
|
57
|
-
for (const seg of (path || 'audio').split('.').filter(Boolean)) {
|
|
58
|
-
if (!cur || typeof cur !== 'object') return undefined;
|
|
59
|
-
cur = (cur as Record<string, unknown>)[seg];
|
|
60
|
-
}
|
|
61
|
-
return cur;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function decodeBase64(raw: string): Uint8Array {
|
|
65
|
-
const comma = raw.indexOf(',');
|
|
66
|
-
const body = raw.startsWith('data:') && comma >= 0 ? raw.slice(comma + 1) : raw;
|
|
67
|
-
const bytes = Buffer.from(body, 'base64');
|
|
68
|
-
if (!bytes.length) throw new Error('customResponse base64 field is empty');
|
|
69
|
-
return new Uint8Array(bytes);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export const customBackend: TtsBackend = {
|
|
73
|
-
id: 'custom',
|
|
74
|
-
label: 'custom JSON',
|
|
75
|
-
|
|
76
|
-
async synthesize(req: TtsRequest, ctx: BackendContext): Promise<TtsAudio> {
|
|
77
|
-
const { cfg } = ctx;
|
|
78
|
-
const url = cfg.customUrl.trim();
|
|
79
|
-
if (!url) throw new Error('customUrl is empty');
|
|
80
|
-
const secret = ctx.secret(cfg.apiKeySecret);
|
|
81
|
-
const headers = headersOf(cfg.customHeaders, secret);
|
|
82
|
-
const values = valuesOf(req, ctx);
|
|
83
|
-
|
|
84
|
-
let target = url;
|
|
85
|
-
const init: RequestInit = { method: cfg.customMethod, headers };
|
|
86
|
-
if (cfg.customMethod === 'GET') {
|
|
87
|
-
const u = new URL(url);
|
|
88
|
-
for (const [key, value] of Object.entries(values)) if (value) u.searchParams.set(key, value);
|
|
89
|
-
target = u.toString();
|
|
90
|
-
} else {
|
|
91
|
-
const body = bodyOf(cfg.customBody, values);
|
|
92
|
-
try { JSON.parse(body); } catch { throw new Error('customBody is not valid JSON after placeholders were filled'); }
|
|
93
|
-
headers['Content-Type'] ??= 'application/json';
|
|
94
|
-
init.body = body;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const res = await timedFetch(ctx.fetch, target, init, cfg.timeoutMs, req.signal);
|
|
98
|
-
if (!res.ok) throw new Error(await errorSnippet(res));
|
|
99
|
-
const type = (res.headers.get('content-type') ?? '').toLowerCase();
|
|
100
|
-
if (type.startsWith('audio/') || type.includes('octet-stream')) {
|
|
101
|
-
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
102
|
-
if (!bytes.length) throw new Error('custom API returned an empty audio body');
|
|
103
|
-
return audioOf(bytes, req.format);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (cfg.customResponse === 'audio') throw new Error(await errorSnippet(res));
|
|
107
|
-
const data = await res.json();
|
|
108
|
-
const value = pickPath(data, cfg.customAudioPath);
|
|
109
|
-
if (typeof value !== 'string' || !value.trim()) {
|
|
110
|
-
throw new Error(`custom response has no string at ${cfg.customAudioPath || 'audio'}`);
|
|
111
|
-
}
|
|
112
|
-
if (cfg.customResponse === 'base64') {
|
|
113
|
-
return { bytes: decodeBase64(value), mime: mimeOf(req.format) };
|
|
114
|
-
}
|
|
115
|
-
const audioUrl = new URL(value, url).toString();
|
|
116
|
-
const audioRes = await timedFetch(ctx.fetch, audioUrl, { method: 'GET', headers }, cfg.timeoutMs, req.signal);
|
|
117
|
-
if (!audioRes.ok) throw new Error(await errorSnippet(audioRes));
|
|
118
|
-
const bytes = new Uint8Array(await audioRes.arrayBuffer());
|
|
119
|
-
if (!bytes.length) throw new Error('custom audio URL returned an empty body');
|
|
120
|
-
return { bytes, mime: (audioRes.headers.get('content-type') ?? '').split(';')[0] || mimeOf(req.format) };
|
|
121
|
-
},
|
|
122
|
-
|
|
123
|
-
async listVoices(): Promise<VoiceOption[]> {
|
|
124
|
-
return [];
|
|
125
|
-
},
|
|
126
|
-
|
|
127
|
-
async health(ctx: BackendContext): Promise<BackendHealth> {
|
|
128
|
-
return ctx.cfg.customUrl.trim()
|
|
129
|
-
? { ok: true, detail: 'not probed' }
|
|
130
|
-
: { ok: false, detail: 'customUrl is empty' };
|
|
131
|
-
},
|
|
132
|
-
};
|
package/src/backends/http.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/** 适配器共用的 HTTP 小工具:地址拼接、超时、错误正文、音频回应。 */
|
|
2
|
-
import type { AudioFormat, TtsAudio } from '../types.ts';
|
|
3
|
-
|
|
4
|
-
/** 基础地址可以带 `/v1`,也可以不带;两种都拼出同一个接口。 */
|
|
5
|
-
export function apiUrl(base: string, path: string): string {
|
|
6
|
-
const trimmed = (base || '').trim().replace(/\/+$/, '');
|
|
7
|
-
if (!trimmed) throw new Error('baseUrl is empty');
|
|
8
|
-
const suffix = path.startsWith('/') ? path : `/${path}`;
|
|
9
|
-
return /\/v1$/.test(trimmed) ? `${trimmed}${suffix}` : `${trimmed}/v1${suffix}`;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function mimeOf(format: AudioFormat): string {
|
|
13
|
-
return format === 'mp3' ? 'audio/mpeg' : 'audio/wav';
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function authHeaders(secret: string | undefined): Record<string, string> {
|
|
17
|
-
return secret ? { Authorization: `Bearer ${secret}` } : {};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** 读一小段错误正文,压成一行。 */
|
|
21
|
-
export async function errorSnippet(res: Response, limit = 600): Promise<string> {
|
|
22
|
-
let body = '';
|
|
23
|
-
try { body = await res.text(); } catch { /* 读不到就用状态行 */ }
|
|
24
|
-
const flat = body.replace(/\s+/g, ' ').trim();
|
|
25
|
-
return `TTS API ${res.status}${res.statusText ? ` ${res.statusText}` : ''}${flat ? `: ${flat.slice(0, limit)}` : ''}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function audioOf(bytes: Uint8Array, format: AudioFormat): TtsAudio {
|
|
29
|
-
return { bytes, mime: mimeOf(format), seconds: wavSeconds(bytes) };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** 带超时与外部取消的 fetch。 */
|
|
33
|
-
export async function timedFetch(
|
|
34
|
-
fetchImpl: typeof fetch,
|
|
35
|
-
url: string,
|
|
36
|
-
init: RequestInit,
|
|
37
|
-
timeoutMs: number,
|
|
38
|
-
signal?: AbortSignal,
|
|
39
|
-
): Promise<Response> {
|
|
40
|
-
const ctrl = new AbortController();
|
|
41
|
-
const timer = setTimeout(() => ctrl.abort(new Error(`timed out after ${timeoutMs} ms`)), Math.max(1, timeoutMs));
|
|
42
|
-
const forward = (): void => ctrl.abort(signal?.reason);
|
|
43
|
-
if (signal) {
|
|
44
|
-
if (signal.aborted) forward();
|
|
45
|
-
else signal.addEventListener('abort', forward, { once: true });
|
|
46
|
-
}
|
|
47
|
-
try {
|
|
48
|
-
return await fetchImpl(url, { ...init, signal: ctrl.signal });
|
|
49
|
-
} catch (error) {
|
|
50
|
-
if (ctrl.signal.aborted && !signal?.aborted) {
|
|
51
|
-
throw new Error(`TTS request timed out after ${timeoutMs} ms`);
|
|
52
|
-
}
|
|
53
|
-
throw error;
|
|
54
|
-
} finally {
|
|
55
|
-
clearTimeout(timer);
|
|
56
|
-
signal?.removeEventListener('abort', forward);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** 回应必须是音频;不是时报错并带正文。 */
|
|
61
|
-
export async function readAudio(res: Response, format: AudioFormat): Promise<TtsAudio> {
|
|
62
|
-
if (!res.ok) throw new Error(await errorSnippet(res));
|
|
63
|
-
const type = (res.headers.get('content-type') ?? '').toLowerCase();
|
|
64
|
-
if (!type.startsWith('audio/') && !type.includes('octet-stream')) {
|
|
65
|
-
throw new Error(await errorSnippet(res));
|
|
66
|
-
}
|
|
67
|
-
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
68
|
-
if (bytes.length === 0) throw new Error('TTS API returned an empty audio body');
|
|
69
|
-
return audioOf(bytes, format);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** PCM16 WAV 的时长;不是 RIFF/WAVE 时返回 undefined。 */
|
|
73
|
-
export function wavSeconds(bytes: Uint8Array): number | undefined {
|
|
74
|
-
if (bytes.length < 44) return undefined;
|
|
75
|
-
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
76
|
-
if (view.getUint32(0, false) !== 0x52494646 || view.getUint32(8, false) !== 0x57415645) return undefined;
|
|
77
|
-
let offset = 12;
|
|
78
|
-
let byteRate = 0;
|
|
79
|
-
while (offset + 8 <= bytes.length) {
|
|
80
|
-
const id = view.getUint32(offset, false);
|
|
81
|
-
const size = view.getUint32(offset + 4, true);
|
|
82
|
-
const body = offset + 8;
|
|
83
|
-
if (id === 0x666d7420 && body + 16 <= bytes.length) byteRate = view.getUint32(body + 8, true);
|
|
84
|
-
if (id === 0x64617461) {
|
|
85
|
-
if (byteRate > 0 && size > 0) return size / byteRate;
|
|
86
|
-
return undefined;
|
|
87
|
-
}
|
|
88
|
-
offset = body + size + (size % 2);
|
|
89
|
-
}
|
|
90
|
-
return undefined;
|
|
91
|
-
}
|
package/src/backends/index.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/** 协议适配器登记表。新增一种方言:实现 TtsBackend,再放进这张表。 */
|
|
2
|
-
import type { BackendId, TtsBackend } from '../types.ts';
|
|
3
|
-
import { audiocppBackend } from './audiocpp.ts';
|
|
4
|
-
import { customBackend } from './custom.ts';
|
|
5
|
-
import { openaiBackend } from './openai.ts';
|
|
6
|
-
|
|
7
|
-
const BACKENDS: Readonly<Record<BackendId, TtsBackend>> = {
|
|
8
|
-
audiocpp: audiocppBackend,
|
|
9
|
-
openai: openaiBackend,
|
|
10
|
-
custom: customBackend,
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export function backendOf(id: string): TtsBackend {
|
|
14
|
-
return BACKENDS[id as BackendId] ?? BACKENDS.audiocpp;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function backendIds(): BackendId[] {
|
|
18
|
-
return Object.keys(BACKENDS) as BackendId[];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export { audiocppBackend, customBackend, openaiBackend };
|
package/src/backends/openai.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/** OpenAI 兼容的 `POST /v1/audio/speech`。 */
|
|
2
|
-
import type { BackendContext, BackendHealth, TtsBackend, TtsRequest, VoiceOption } from '../types.ts';
|
|
3
|
-
import { buildInstruct, resolveStyle } from '../speech/style.ts';
|
|
4
|
-
import { apiUrl, authHeaders, readAudio, timedFetch } from './http.ts';
|
|
5
|
-
|
|
6
|
-
const DEFAULT_MODEL = 'tts-1';
|
|
7
|
-
const DEFAULT_VOICE = 'alloy';
|
|
8
|
-
|
|
9
|
-
export const openaiBackend: TtsBackend = {
|
|
10
|
-
id: 'openai',
|
|
11
|
-
label: 'OpenAI 兼容 /v1/audio/speech',
|
|
12
|
-
|
|
13
|
-
async synthesize(req: TtsRequest, ctx: BackendContext) {
|
|
14
|
-
const { cfg } = ctx;
|
|
15
|
-
const style = resolveStyle(cfg, req);
|
|
16
|
-
const body: Record<string, unknown> = {
|
|
17
|
-
model: cfg.model.trim() || DEFAULT_MODEL,
|
|
18
|
-
input: req.text,
|
|
19
|
-
voice: (req.voice ?? '').trim() || cfg.voice.trim() || DEFAULT_VOICE,
|
|
20
|
-
response_format: req.format,
|
|
21
|
-
};
|
|
22
|
-
if (Number.isFinite(style.speed) && style.speed !== 1) {
|
|
23
|
-
body.speed = Math.min(4, Math.max(0.25, style.speed));
|
|
24
|
-
}
|
|
25
|
-
const instructions = buildInstruct(style);
|
|
26
|
-
if (instructions) body.instructions = instructions;
|
|
27
|
-
const res = await timedFetch(ctx.fetch, apiUrl(cfg.baseUrl, '/audio/speech'), {
|
|
28
|
-
method: 'POST',
|
|
29
|
-
headers: { 'Content-Type': 'application/json', ...authHeaders(ctx.secret(cfg.apiKeySecret)) },
|
|
30
|
-
body: JSON.stringify(body),
|
|
31
|
-
}, cfg.timeoutMs, req.signal);
|
|
32
|
-
return readAudio(res, req.format);
|
|
33
|
-
},
|
|
34
|
-
|
|
35
|
-
async listVoices(): Promise<VoiceOption[]> {
|
|
36
|
-
return [];
|
|
37
|
-
},
|
|
38
|
-
|
|
39
|
-
async health(ctx: BackendContext): Promise<BackendHealth> {
|
|
40
|
-
const { cfg } = ctx;
|
|
41
|
-
try {
|
|
42
|
-
const res = await timedFetch(ctx.fetch, apiUrl(cfg.baseUrl, '/models'), {
|
|
43
|
-
method: 'GET',
|
|
44
|
-
headers: authHeaders(ctx.secret(cfg.apiKeySecret)),
|
|
45
|
-
}, Math.min(cfg.timeoutMs, 8000));
|
|
46
|
-
return res.ok ? { ok: true } : { ok: false, detail: `HTTP ${res.status}` };
|
|
47
|
-
} catch (error) {
|
|
48
|
-
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
};
|
package/src/http.ts
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 本地语音接口。给 QQ 插件、唱歌插件、脚本用;只监听 127.0.0.1。
|
|
3
|
-
* 形状尽量贴近 OpenAI:`/v1/audio/speech`、`/v1/models`、`/v1/audio/voices`。
|
|
4
|
-
*/
|
|
5
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
6
|
-
import type { Logger } from 'cortico/core/types.ts';
|
|
7
|
-
import type { AudioFormat, TtsAudio, TtsRequest, VoiceOption } from './types.ts';
|
|
8
|
-
|
|
9
|
-
const BODY_MAX = 1024 * 1024;
|
|
10
|
-
|
|
11
|
-
export interface VoiceHttpStatus {
|
|
12
|
-
ready: boolean;
|
|
13
|
-
state: string;
|
|
14
|
-
backend: string;
|
|
15
|
-
model: string;
|
|
16
|
-
voice: string;
|
|
17
|
-
queue: number;
|
|
18
|
-
sing: boolean;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface VoiceHttpSpeech {
|
|
22
|
-
request: TtsRequest;
|
|
23
|
-
/** 请求方要求本机播放。 */
|
|
24
|
-
play: boolean;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface VoiceHttpDeps {
|
|
28
|
-
token(): string;
|
|
29
|
-
status(): VoiceHttpStatus;
|
|
30
|
-
synthesize(req: VoiceHttpSpeech, signal: AbortSignal): Promise<TtsAudio>;
|
|
31
|
-
sing(lyrics: string, signal: AbortSignal): Promise<TtsAudio>;
|
|
32
|
-
voices(): Promise<VoiceOption[]>;
|
|
33
|
-
log: Logger;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface VoiceHttpHandle {
|
|
37
|
-
port: number;
|
|
38
|
-
close(): Promise<void>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
class HttpError extends Error {
|
|
42
|
-
constructor(readonly status: number, message: string) { super(message); }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
46
|
-
return new Promise((resolve, reject) => {
|
|
47
|
-
const parts: Buffer[] = [];
|
|
48
|
-
let size = 0;
|
|
49
|
-
req.on('data', (b: Buffer) => {
|
|
50
|
-
size += b.length;
|
|
51
|
-
if (size > BODY_MAX) { reject(new HttpError(413, 'body too large')); req.destroy(); return; }
|
|
52
|
-
parts.push(b);
|
|
53
|
-
});
|
|
54
|
-
req.on('end', () => {
|
|
55
|
-
if (parts.length === 0) { resolve({}); return; }
|
|
56
|
-
try {
|
|
57
|
-
const parsed = JSON.parse(Buffer.concat(parts).toString('utf8'));
|
|
58
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
59
|
-
reject(new HttpError(400, 'body must be a JSON object'));
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
resolve(parsed as Record<string, unknown>);
|
|
63
|
-
} catch {
|
|
64
|
-
reject(new HttpError(400, 'body is not JSON'));
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
req.on('error', reject);
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const asText = (v: unknown, max = 8000): string =>
|
|
72
|
-
typeof v === 'string' ? v.trim().slice(0, max) : '';
|
|
73
|
-
|
|
74
|
-
const asNumber = (v: unknown): number | undefined => {
|
|
75
|
-
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
|
|
76
|
-
return Number.isFinite(n) ? n : undefined;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
function formatOf(v: unknown): AudioFormat {
|
|
80
|
-
return v === 'mp3' ? 'mp3' : 'wav';
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
84
|
-
const text = JSON.stringify(body);
|
|
85
|
-
res.writeHead(status, {
|
|
86
|
-
'content-type': 'application/json; charset=utf-8',
|
|
87
|
-
'content-length': Buffer.byteLength(text),
|
|
88
|
-
});
|
|
89
|
-
res.end(text);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function writeAudio(res: ServerResponse, audio: TtsAudio): void {
|
|
93
|
-
res.writeHead(200, {
|
|
94
|
-
'content-type': audio.mime,
|
|
95
|
-
'content-length': audio.bytes.length,
|
|
96
|
-
...(audio.seconds ? { 'x-voice-seconds': audio.seconds.toFixed(2) } : {}),
|
|
97
|
-
});
|
|
98
|
-
res.end(Buffer.from(audio.bytes));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** 起接口;`port` 为 0 时由系统分配。 */
|
|
102
|
-
export function startVoiceHttp(opts: { port: number; host?: string; deps: VoiceHttpDeps }): Promise<VoiceHttpHandle> {
|
|
103
|
-
const { deps } = opts;
|
|
104
|
-
|
|
105
|
-
const authorized = (req: IncomingMessage, url: URL): boolean => {
|
|
106
|
-
const token = deps.token().trim();
|
|
107
|
-
if (!token) return true;
|
|
108
|
-
if ((req.headers.authorization ?? '') === `Bearer ${token}`) return true;
|
|
109
|
-
return url.searchParams.get('token') === token;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const speech = async (res: ServerResponse, body: Record<string, unknown>): Promise<void> => {
|
|
113
|
-
const text = asText(body.input) || asText(body.text);
|
|
114
|
-
if (!text) throw new HttpError(400, 'missing input');
|
|
115
|
-
const request: TtsRequest = {
|
|
116
|
-
text,
|
|
117
|
-
format: formatOf(body.response_format ?? body.format),
|
|
118
|
-
...(asText(body.voice, 200) ? { voice: asText(body.voice, 200) } : {}),
|
|
119
|
-
...(asText(body.emotion, 200) ? { emotion: asText(body.emotion, 200) } : {}),
|
|
120
|
-
...((asText(body.instruct, 2000) || asText(body.instructions, 2000))
|
|
121
|
-
? { instruct: asText(body.instruct, 2000) || asText(body.instructions, 2000) }
|
|
122
|
-
: {}),
|
|
123
|
-
...(asNumber(body.speed) !== undefined ? { speed: asNumber(body.speed) } : {}),
|
|
124
|
-
};
|
|
125
|
-
const ctrl = new AbortController();
|
|
126
|
-
const timer = setTimeout(() => ctrl.abort(new Error('timeout')), 180_000);
|
|
127
|
-
try {
|
|
128
|
-
const audio = await deps.synthesize({ request, play: body.play === true }, ctrl.signal);
|
|
129
|
-
writeAudio(res, audio);
|
|
130
|
-
} finally {
|
|
131
|
-
clearTimeout(timer);
|
|
132
|
-
}
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
const handler = (req: IncomingMessage, res: ServerResponse): void => {
|
|
136
|
-
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
137
|
-
if (!authorized(req, url)) { writeJson(res, 401, { error: 'bad token' }); return; }
|
|
138
|
-
void (async () => {
|
|
139
|
-
if (req.method === 'GET' && (url.pathname === '/health' || url.pathname === '/healthz')) {
|
|
140
|
-
writeJson(res, 200, deps.status());
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
if (req.method === 'GET' && /\/models\/?$/.test(url.pathname)) {
|
|
144
|
-
const status = deps.status();
|
|
145
|
-
writeJson(res, 200, {
|
|
146
|
-
object: 'list',
|
|
147
|
-
data: status.model ? [{ id: status.model, object: 'model', owned_by: 'cortico-world-voice' }] : [],
|
|
148
|
-
});
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
if (req.method === 'GET' && /\/audio\/voices\/?$/.test(url.pathname)) {
|
|
152
|
-
const voices = await deps.voices();
|
|
153
|
-
writeJson(res, 200, { voices: voices.map((v) => v.value), data: voices });
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
if (req.method === 'POST' && /\/audio\/speech\/?$/.test(url.pathname)) {
|
|
157
|
-
await speech(res, await readJson(req));
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
if (req.method === 'POST' && /\/audio\/sing\/?$/.test(url.pathname)) {
|
|
161
|
-
if (!deps.status().sing) throw new HttpError(503, 'no singing service configured');
|
|
162
|
-
const body = await readJson(req);
|
|
163
|
-
const lyrics = asText(body.lyrics, 2000);
|
|
164
|
-
if (!lyrics) throw new HttpError(400, 'missing lyrics');
|
|
165
|
-
const ctrl = new AbortController();
|
|
166
|
-
const timer = setTimeout(() => ctrl.abort(new Error('timeout')), 300_000);
|
|
167
|
-
try {
|
|
168
|
-
writeAudio(res, await deps.sing(lyrics, ctrl.signal));
|
|
169
|
-
} finally {
|
|
170
|
-
clearTimeout(timer);
|
|
171
|
-
}
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
writeJson(res, 404, { error: `no such route: ${req.method} ${url.pathname}` });
|
|
175
|
-
})().catch((error: unknown) => {
|
|
176
|
-
const status = error instanceof HttpError ? error.status : 502;
|
|
177
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
178
|
-
deps.log.warn('本地语音接口失败', { path: url.pathname, status, err: message });
|
|
179
|
-
if (!res.headersSent) writeJson(res, status, { error: message });
|
|
180
|
-
else res.end();
|
|
181
|
-
});
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
return new Promise<VoiceHttpHandle>((resolve, reject) => {
|
|
185
|
-
let server: Server;
|
|
186
|
-
try {
|
|
187
|
-
server = createServer(handler);
|
|
188
|
-
} catch (error) {
|
|
189
|
-
reject(error);
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
server.on('error', reject);
|
|
193
|
-
server.listen(opts.port, opts.host ?? '127.0.0.1', () => {
|
|
194
|
-
const addr = server.address();
|
|
195
|
-
const port = addr && typeof addr === 'object' ? addr.port : opts.port;
|
|
196
|
-
resolve({
|
|
197
|
-
port,
|
|
198
|
-
close: () => new Promise<void>((done) => {
|
|
199
|
-
server.close(() => done());
|
|
200
|
-
server.closeAllConnections();
|
|
201
|
-
}),
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
}
|
package/src/options.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 配置页的音色下拉要读运行中实例探到的音色,但 `WorldDefinition.configOptions` 是静态签名。
|
|
3
|
-
* 实例启动时把「读当前音色表」的函数注册进来。
|
|
4
|
-
*/
|
|
5
|
-
import type { VoiceOption } from './types.ts';
|
|
6
|
-
|
|
7
|
-
let provider: (() => VoiceOption[]) | null = null;
|
|
8
|
-
|
|
9
|
-
export function registerVoiceOptions(fn: () => VoiceOption[]): void {
|
|
10
|
-
provider = fn;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function currentVoiceOptions(): VoiceOption[] {
|
|
14
|
-
return provider?.() ?? [];
|
|
15
|
-
}
|