ai_co_discuss 0.3.25 → 0.3.27
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/backend/server.py +2 -2
- package/docs/en/runner.md +1 -1
- package/docs/runner.md +9 -4
- package/package.json +1 -1
- package/runner/discussion_launch.mjs +4 -0
- package/runner/remote_launch.mjs +1 -1
- package/runner/stt.mjs +101 -0
- package/runner/tts.mjs +45 -9
- package/web/modern.css +1 -1
- package/web/modern.js +4601 -3262
package/backend/server.py
CHANGED
|
@@ -1721,7 +1721,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1721
1721
|
site = self.local_request_site(method)
|
|
1722
1722
|
route = parts[2:]
|
|
1723
1723
|
if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-member"], ["room-member-access"], ["room-member-allow"], ["room-member-adapter"], ["room-member-model"], ["room-profiles"], ["room-restore"], ["room-fork"], ["models"], ["api-key"], ["api-check"],
|
|
1724
|
-
["scheduler-check"], ["tts-settings"], ["tts"], ["pick-directory"], ["projects"], ["presets"], ["machine-link"], ["admin-account"]):
|
|
1724
|
+
["scheduler-check"], ["tts-settings"], ["tts"], ["stt"], ["pick-directory"], ["projects"], ["presets"], ["machine-link"], ["admin-account"]):
|
|
1725
1725
|
return self.respond({"ok": True})
|
|
1726
1726
|
if route == ["settings"] and method in ("GET", "POST"):
|
|
1727
1727
|
command = "settings"
|
|
@@ -1739,7 +1739,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1739
1739
|
command = "api-check"
|
|
1740
1740
|
elif route == ["scheduler-check"] and method == "POST":
|
|
1741
1741
|
command = "scheduler-check"
|
|
1742
|
-
elif route in (["tts-settings"], ["tts"]) and method == "POST":
|
|
1742
|
+
elif route in (["tts-settings"], ["tts"], ["stt"]) and method == "POST":
|
|
1743
1743
|
command = route[0]
|
|
1744
1744
|
elif route in (["room-member"], ["room-member-access"], ["room-member-allow"], ["room-member-adapter"], ["room-member-model"]) and method == "POST":
|
|
1745
1745
|
command = route[0]
|
package/docs/en/runner.md
CHANGED
|
@@ -53,7 +53,7 @@ Then sign in as the site administrator in this computer's browser to pair it; th
|
|
|
53
53
|
|
|
54
54
|
### From Other Devices
|
|
55
55
|
|
|
56
|
-
Once paired, sign in with the same account on a phone or tablet. The directory lists the rooms on the computer: send messages and attachments, manage members, export backups, with live updates. Conversations stay on the computer; the website only passes encrypted content. Project paths and keys never leave the computer.
|
|
56
|
+
Once paired, sign in with the same account on a phone or tablet. The "协作房间" directory lists the rooms on the computer, the same as on the computer itself: send messages and attachments, manage members, export backups, with live updates. Conversations stay on the computer; the website only passes encrypted content. Project paths and keys never leave the computer.
|
|
57
57
|
|
|
58
58
|
- Nothing is listed while the computer is off or codiscuss is not running; the directory heading says the computer is not connected or offline, and the page connects on its own once the computer is back.
|
|
59
59
|
- When the computer does not answer in time, the page reports the result as unconfirmed and never resends on its own; check the room before retrying.
|
package/docs/runner.md
CHANGED
|
@@ -55,9 +55,9 @@ codiscuss local connect https://你的站点
|
|
|
55
55
|
|
|
56
56
|
配对后在手机或平板上登录同一账号,只要这台电脑开机并运行 codiscuss,就能像在电脑上一样操作:建房、邀请与请回成员、翻开卡片设置软件、模型和权限、录入 API 密钥、管理常用项目等。这些操作由网站转交给电脑执行,内容端到端加密,网站只经手密文。项目目录在平板上直接输入电脑上的完整路径。
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
登录后目录“协作房间”直接列出电脑上的房间,与在电脑上看到的一致,可以发消息、上传附件、管理成员、导出备份,并实时更新。对话只保存在电脑上,网站只转交加密内容。
|
|
59
59
|
|
|
60
|
-
- 电脑关机或 codiscuss
|
|
60
|
+
- 电脑关机或 codiscuss 未运行时看不到房间,目录标题提示“电脑未连接”或“电脑离线”;电脑上线后页面自动连上。
|
|
61
61
|
- 电脑没有及时答复时,页面提示“结果待确认”,不会自动重发;请先查看房间确认是否已生效。
|
|
62
62
|
|
|
63
63
|
### 外出时经 Cloudflare Tunnel 打开本机网站
|
|
@@ -88,9 +88,14 @@ API 成员可以担任专家或主持人。房间指定了项目目录时,它
|
|
|
88
88
|
|
|
89
89
|
翻开成员卡片,“提示”一行可以给这位专家单独写几句要求,例如“回答先给结论”。它只发给这位专家,不改变权限;部分软件带有预设,可随时恢复。卡片背面的书签图标把这张卡片的描述、提示词、项目权限、模型与强度存为专家预设,之后在“添加”行一键加入,见 [使用指南](workflow.md#成员管理)。
|
|
90
90
|
|
|
91
|
-
##
|
|
91
|
+
## 朗读与语音输入(可选)
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
在火山引擎控制台开通「语音合成2.0」(朗读)和「录音文件识别2.0」(语音输入),复制 API Key,填进网页“设置”的“语音”。朗读还可选音色、语速,改语气提示(默认是“像给朋友讲故事一样”的自然语气)。密钥只保存在本机,费用按你的账号计费。
|
|
94
|
+
|
|
95
|
+
- 朗读:每条消息的工具栏有“朗读”(手机上在“…”菜单里),在消息里选中文字后点“听当前”从选中处开始;读完这条会接着读后面的消息直到最新,正在读的句子会高亮并自动滚到视野内,点一下高亮的句子暂停,再点继续;代码块跳过。
|
|
96
|
+
- 语音输入:点输入框右下角的麦克风开始说话,再点一次转成文字插入输入框,单次最长 5 分钟。浏览器会先请求麦克风权限。
|
|
97
|
+
|
|
98
|
+
两者都由本机服务调用火山引擎,手机等其他设备经已配对电脑使用,电脑关机时不可用。
|
|
94
99
|
|
|
95
100
|
## 权限
|
|
96
101
|
|
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ import { ADAPTER_IDS, ADAPTER_PROFILES, adapter_profile, platform_adapters, same
|
|
|
16
16
|
import { API_PROVIDERS, api_check, api_models, save_api_key, saved_api_keys, validate_api_profile } from './discussion_api.mjs';
|
|
17
17
|
import { DEFAULT_SCHEDULER_MODE, SCHEDULER_MODES, SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
|
|
18
18
|
import { save_tts_settings, tts_settings, tts_speak } from './tts.mjs';
|
|
19
|
+
import { stt_part } from './stt.mjs';
|
|
19
20
|
import { access_levels, BUILTIN_EXPERT_PRESETS, MAX_EXPERT_PROMPT, PROJECT_ACCESS } from './expert_prompts.mjs';
|
|
20
21
|
import { acquire_lock, api, DiscussionStore, service_drafts, validate_config } from './discussion_runner.mjs';
|
|
21
22
|
import { FreeRoomRunner, free_task_files } from './free_runner.mjs';
|
|
@@ -59,6 +60,7 @@ const HELP = `用法:codiscuss launch settings [--file FILE]
|
|
|
59
60
|
codiscuss launch scheduler-check --file FILE
|
|
60
61
|
codiscuss launch tts-settings [--file FILE]
|
|
61
62
|
codiscuss launch tts --file FILE
|
|
63
|
+
codiscuss launch stt --file FILE
|
|
62
64
|
codiscuss launch room-member --file FILE
|
|
63
65
|
codiscuss launch room-member-access --file FILE
|
|
64
66
|
codiscuss launch room-member-allow --file FILE
|
|
@@ -83,6 +85,7 @@ api-check 用 API profile 向模型发送一次极短请求,核对地址、密
|
|
|
83
85
|
scheduler-check 用智能调度配置(provider、model、api_key_ref)向决策模型发送一道是非题,核对供应商、模型与密钥是否可用。
|
|
84
86
|
tts-settings 读取朗读设置;内容非空时保存(音色 speaker、语速 speech_rate、语气提示 context、api_key_ref)。
|
|
85
87
|
tts 用朗读设置把 text(不超过 300 字)合成为 MP3,返回 base64。
|
|
88
|
+
stt 按序接收录音片段(upload_id、index、base64 PCM 16 kHz 单声道、final);最后一段时识别整段录音并返回 text。
|
|
86
89
|
pick-directory 在本机弹出系统目录选择窗口,返回所选路径;取消时不返回路径。
|
|
87
90
|
projects 读取本机已配置的项目(名称与绝对路径);提供 JSON(projects 列表)时整体替换保存。
|
|
88
91
|
presets 读取本机的专家预设(含内置预设)与各平台默认描述;提供 JSON(presets 列表和/或 description_templates)时替换对应部分。
|
|
@@ -1303,6 +1306,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
|
|
|
1303
1306
|
else if (positionals[0] === 'scheduler-check' && input) result = await scheduler_check(home, input);
|
|
1304
1307
|
else if (positionals[0] === 'tts-settings') result = input && Object.keys(input).length ? save_tts_settings(home, input) : tts_settings(home);
|
|
1305
1308
|
else if (positionals[0] === 'tts' && input) result = await tts_speak(home, input);
|
|
1309
|
+
else if (positionals[0] === 'stt' && input) result = await stt_part(home, input);
|
|
1306
1310
|
else if (positionals[0] === 'room-models' && input) result = await room_models(home, input);
|
|
1307
1311
|
else if (positionals[0] === 'models' && adapter_profile(input?.adapter)?.kind === 'api') result = await api_models(home, input);
|
|
1308
1312
|
else if (positionals[0] === 'models' && input) {
|
package/runner/remote_launch.mjs
CHANGED
|
@@ -27,7 +27,7 @@ const RELAY_CIPHER_INFO = 'codiscuss-relay-v1';
|
|
|
27
27
|
const LAUNCH_SCRIPT = fileURLToPath(new URL('./discussion_launch.mjs', import.meta.url));
|
|
28
28
|
// What a connected browser may ask the local bridge for, minus the folder dialog nobody would see and pairing itself.
|
|
29
29
|
const RELAYED_ACTIONS = new Set(['settings', 'launch', 'room-models', 'room-member', 'room-member-access', 'room-member-allow', 'room-member-adapter',
|
|
30
|
-
'room-member-model', 'room-profiles', 'room-restore', 'room-fork', 'models', 'api-key', 'api-check', 'scheduler-check', 'tts-settings', 'tts', 'projects', 'presets', 'admin-account']);
|
|
30
|
+
'room-member-model', 'room-profiles', 'room-restore', 'room-fork', 'models', 'api-key', 'api-check', 'scheduler-check', 'tts-settings', 'tts', 'stt', 'projects', 'presets', 'admin-account']);
|
|
31
31
|
export const ROOM_ACTIONS = new Set(['launch', 'room-models', 'room-member', 'room-member-access', 'room-member-allow', 'room-member-adapter',
|
|
32
32
|
'room-member-model', 'room-profiles', 'room-restore', 'room-fork']);
|
|
33
33
|
|
package/runner/stt.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice input: the page records 16 kHz mono PCM and sends it in small parts (each fits one relayed request);
|
|
3
|
+
* the last part assembles a WAV file and Volcengine's flash recognizer turns it into text. The key stays local.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { read_saved_api_key } from './discussion_api.mjs';
|
|
10
|
+
import { TTS_BASE_URL, tts_settings } from './tts.mjs';
|
|
11
|
+
|
|
12
|
+
const STT_ENDPOINT = `${TTS_BASE_URL}/api/v3/auc/bigmodel/recognize/flash`;
|
|
13
|
+
const STT_RESOURCE_ID = 'volc.bigasr.auc_turbo';
|
|
14
|
+
const STT_DONE_CODE = '20000000';
|
|
15
|
+
const STT_SILENT_CODE = '20000003';
|
|
16
|
+
const STT_TIMEOUT_MS = 50_000;
|
|
17
|
+
const SAMPLE_RATE = 16000;
|
|
18
|
+
// About 5 minutes of 16-bit mono audio at 16 kHz.
|
|
19
|
+
export const STT_MAX_BYTES = 10 * 1024 * 1024;
|
|
20
|
+
const PART_MAX_BYTES = 256 * 1024;
|
|
21
|
+
const STALE_MS = 60 * 60 * 1000;
|
|
22
|
+
const STATUS_MESSAGES = {
|
|
23
|
+
45000001: '请求参数有误',
|
|
24
|
+
45000002: '没有录到声音',
|
|
25
|
+
45000151: '音频格式不受支持',
|
|
26
|
+
55000031: '火山引擎语音识别繁忙,请稍后再试',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Prefix raw 16-bit mono PCM with a WAV header. */
|
|
30
|
+
export function pcm_to_wav(pcm) {
|
|
31
|
+
const header = Buffer.alloc(44);
|
|
32
|
+
header.write('RIFF', 0); header.writeUInt32LE(36 + pcm.length, 4); header.write('WAVE', 8);
|
|
33
|
+
header.write('fmt ', 12); header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22);
|
|
34
|
+
header.writeUInt32LE(SAMPLE_RATE, 24); header.writeUInt32LE(SAMPLE_RATE * 2, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34);
|
|
35
|
+
header.write('data', 36); header.writeUInt32LE(pcm.length, 40);
|
|
36
|
+
return Buffer.concat([header, pcm]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Drop recordings abandoned mid-way (closed page, lost connection). */
|
|
40
|
+
function clean_stale(directory) {
|
|
41
|
+
if (!existsSync(directory)) return;
|
|
42
|
+
for (const name of readdirSync(directory)) {
|
|
43
|
+
const path = join(directory, name);
|
|
44
|
+
try { if (Date.now() - statSync(path).mtimeMs > STALE_MS) rmSync(path, { recursive: true, force: true }); } catch { /* Gone already. */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Send a finished recording to the recognizer and return its text. */
|
|
49
|
+
async function recognize(key, wav, request) {
|
|
50
|
+
let response;
|
|
51
|
+
try {
|
|
52
|
+
response = await request(STT_ENDPOINT, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(STT_TIMEOUT_MS),
|
|
53
|
+
headers: { 'Content-Type': 'application/json', 'X-Api-Key': key, 'X-Api-Resource-Id': STT_RESOURCE_ID,
|
|
54
|
+
'X-Api-Request-Id': randomUUID(), 'X-Api-Sequence': '-1' },
|
|
55
|
+
body: JSON.stringify({ user: { uid: 'codiscuss' }, audio: { data: wav.toString('base64') }, request: { model_name: 'bigmodel', enable_punc: true, enable_itn: true } }) });
|
|
56
|
+
} catch { throw new Error('语音识别请求超时或连接失败,请检查网络后重试'); }
|
|
57
|
+
if (response.status === 401 || response.status === 403) throw new Error('火山引擎拒绝了请求:API Key 无效或未开通录音文件识别 2.0');
|
|
58
|
+
const code = response.headers.get('X-Api-Status-Code');
|
|
59
|
+
if (code === STT_SILENT_CODE) return '';
|
|
60
|
+
if (code !== STT_DONE_CODE) {
|
|
61
|
+
const message = STATUS_MESSAGES[code] || response.headers.get('X-Api-Message') || `HTTP ${response.status}`;
|
|
62
|
+
throw new Error(`语音识别失败(${code || response.status}):${message}`);
|
|
63
|
+
}
|
|
64
|
+
const data = await response.json();
|
|
65
|
+
return String(data?.result?.text || '').trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Store one part of a recording; the final part recognizes the whole recording and returns its text.
|
|
70
|
+
* @param {string} home Local service directory.
|
|
71
|
+
* @param {{ upload_id: string, index: number, data: string, final?: boolean }} input Base64 PCM part in order.
|
|
72
|
+
* @returns {Promise<{ received: number } | { text: string }>}
|
|
73
|
+
*/
|
|
74
|
+
export async function stt_part(home, input, request = fetch) {
|
|
75
|
+
const { upload_id, index, data, final } = input || {};
|
|
76
|
+
if (!/^[a-f0-9-]{36}$/i.test(upload_id || '') || !Number.isInteger(index) || index < 0 || typeof data !== 'string') throw new Error('录音片段无效');
|
|
77
|
+
const settings = tts_settings(home);
|
|
78
|
+
if (!settings.api_key_ref) throw new Error('尚未配置语音:请在设置的“语音”中填写火山引擎 API Key');
|
|
79
|
+
const part = Buffer.from(data, 'base64');
|
|
80
|
+
if (part.length > PART_MAX_BYTES || part.length % 2) throw new Error('录音片段无效');
|
|
81
|
+
const root = join(home, 'stt');
|
|
82
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
83
|
+
clean_stale(root);
|
|
84
|
+
const directory = join(root, upload_id.toLowerCase());
|
|
85
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
86
|
+
writeFileSync(join(directory, `${index}.pcm`), part, { mode: 0o600 });
|
|
87
|
+
if (!final) return { received: index };
|
|
88
|
+
try {
|
|
89
|
+
const parts = [];
|
|
90
|
+
for (let position = 0; position <= index; position += 1) {
|
|
91
|
+
const path = join(directory, `${position}.pcm`);
|
|
92
|
+
if (!existsSync(path)) throw new Error('录音片段缺失,请重新录制');
|
|
93
|
+
parts.push(readFileSync(path));
|
|
94
|
+
}
|
|
95
|
+
const pcm = Buffer.concat(parts);
|
|
96
|
+
if (pcm.length > STT_MAX_BYTES) throw new Error('录音超过 5 分钟,请分段录制');
|
|
97
|
+
if (!pcm.length) return { text: '' };
|
|
98
|
+
const key = read_saved_api_key(home, TTS_BASE_URL, settings.api_key_ref);
|
|
99
|
+
return { text: await recognize(key, pcm_to_wav(pcm), request) };
|
|
100
|
+
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
101
|
+
}
|
package/runner/tts.mjs
CHANGED
|
@@ -11,13 +11,40 @@ const TTS_ENDPOINT = `${TTS_BASE_URL}/api/v3/tts/unidirectional`;
|
|
|
11
11
|
const TTS_RESOURCE_ID = 'seed-tts-2.0';
|
|
12
12
|
const TTS_DONE_CODE = 20000000;
|
|
13
13
|
const TTS_TIMEOUT_MS = 45_000;
|
|
14
|
-
// The page sends
|
|
14
|
+
// The page sends a paragraph of up to 200 characters; at about 8 KB of MP3 per second this keeps one relayed answer under 1 MiB.
|
|
15
15
|
export const TTS_MAX_TEXT = 300;
|
|
16
16
|
const MAX_CONTEXT = 200;
|
|
17
17
|
const SETTINGS_FILE = 'tts-settings.json';
|
|
18
|
-
|
|
18
|
+
// A default tone instruction (context_texts) so reading sounds told rather than recited; users may change or clear it.
|
|
19
|
+
export const TTS_DEFAULT_CONTEXT = '请用自然放松的语气朗读,像给朋友讲故事一样,有轻重缓急,句子之间自然停顿。';
|
|
20
|
+
export const TTS_DEFAULTS = { speaker: 'zh_female_vv_uranus_bigtts', speech_rate: 0, context: TTS_DEFAULT_CONTEXT };
|
|
19
21
|
// Voices a user can pick without looking up IDs; any other 2.0 voice ID enabled in the console may be typed.
|
|
20
|
-
export const TTS_VOICES = [
|
|
22
|
+
export const TTS_VOICES = [
|
|
23
|
+
{ id: 'zh_female_vv_uranus_bigtts', label: 'Vivi · 通用女声' },
|
|
24
|
+
{ id: 'zh_female_xiaohe_uranus_bigtts', label: '小何 · 通用女声' },
|
|
25
|
+
{ id: 'zh_male_m191_uranus_bigtts', label: '云舟 · 通用男声' },
|
|
26
|
+
{ id: 'zh_male_taocheng_uranus_bigtts', label: '小天 · 通用男声' },
|
|
27
|
+
{ id: 'zh_male_liufei_uranus_bigtts', label: '刘飞 · 通用男声' },
|
|
28
|
+
{ id: 'zh_female_sophie_uranus_bigtts', label: '魅力苏菲 · 女声' },
|
|
29
|
+
{ id: 'zh_female_qingxinnvsheng_uranus_bigtts', label: '清新女声' },
|
|
30
|
+
{ id: 'zh_female_cancan_uranus_bigtts', label: '知性灿灿 · 女声' },
|
|
31
|
+
{ id: 'zh_female_zhixingnv_uranus_bigtts', label: '知性女声' },
|
|
32
|
+
{ id: 'zh_female_wenroushunv_uranus_bigtts', label: '温柔淑女 · 女声' },
|
|
33
|
+
{ id: 'zh_female_linjianvhai_uranus_bigtts', label: '邻家女孩' },
|
|
34
|
+
{ id: 'zh_female_shuangkuaisisi_uranus_bigtts', label: '爽快思思 · 女声' },
|
|
35
|
+
{ id: 'zh_female_gaolengyujie_uranus_bigtts', label: '高冷御姐 · 女声' },
|
|
36
|
+
{ id: 'zh_female_xiaoxue_uranus_bigtts', label: '儿童绘本 · 女声' },
|
|
37
|
+
{ id: 'zh_male_ruyaqingnian_uranus_bigtts', label: '儒雅青年 · 男声' },
|
|
38
|
+
{ id: 'zh_male_yangguangqingnian_uranus_bigtts', label: '阳光青年 · 男声' },
|
|
39
|
+
{ id: 'zh_male_yuanboxiaoshu_uranus_bigtts', label: '渊博小叔 · 男声' },
|
|
40
|
+
{ id: 'zh_male_cixingjieshuonan_uranus_bigtts', label: '磁性解说 · 男声' },
|
|
41
|
+
{ id: 'zh_male_shenyeboke_uranus_bigtts', label: '深夜播客 · 男声' },
|
|
42
|
+
{ id: 'zh_male_xuanyijieshuo_uranus_bigtts', label: '悬疑解说 · 男声' },
|
|
43
|
+
{ id: 'zh_male_baqiqingshu_uranus_bigtts', label: '霸气青叔 · 有声书男声' },
|
|
44
|
+
{ id: 'zh_male_yizhipiannan_uranus_bigtts', label: '译制片男声' },
|
|
45
|
+
{ id: 'zh_male_jieshuoxiaoming_uranus_bigtts', label: '解说小明 · 男声' },
|
|
46
|
+
{ id: 'zh_male_wennuanahu_uranus_bigtts', label: '温暖阿虎 · 男声' },
|
|
47
|
+
];
|
|
21
48
|
const STATUS_MESSAGES = {
|
|
22
49
|
40402003: '文本超出单次合成长度上限',
|
|
23
50
|
45000000: '音色不可用:请确认已在火山引擎控制台开通该豆包语音合成 2.0 音色,并核对音色 ID',
|
|
@@ -69,18 +96,27 @@ function tts_error(code, message) {
|
|
|
69
96
|
return new Error(`语音合成失败(${code}):${STATUS_MESSAGES[code] || message || '请核对 API Key 与音色'}`);
|
|
70
97
|
}
|
|
71
98
|
|
|
72
|
-
/**
|
|
99
|
+
/** Flatten the per-word timings the service returns with subtitles, as { w: word, t: start in ms }. */
|
|
100
|
+
function word_marks(objects) {
|
|
101
|
+
return objects.flatMap(item => item.sentence?.words || []).map(word => ({ w: String(word.word || ''), t: Math.round(Number(word.startTime || 0) * 1000) }));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Synthesize one paragraph and return it as base64 MP3 with word timings for highlighting.
|
|
106
|
+
* Requests of one reading pass share `section_id`, so the service keeps the earlier paragraphs as context.
|
|
107
|
+
*/
|
|
73
108
|
export async function tts_speak(home, input, request = fetch) {
|
|
74
109
|
const text = typeof input?.text === 'string' ? input.text.trim() : '';
|
|
75
110
|
if (!text) throw new Error('朗读文本为空');
|
|
76
111
|
if (text.length > TTS_MAX_TEXT) throw new Error(`单次朗读不超过 ${TTS_MAX_TEXT} 字`);
|
|
77
112
|
const settings = tts_settings(home);
|
|
78
|
-
if (!settings.api_key_ref) throw new Error('
|
|
113
|
+
if (!settings.api_key_ref) throw new Error('尚未配置语音:请在设置的“语音”中填写火山引擎 API Key');
|
|
79
114
|
const key = read_saved_api_key(home, TTS_BASE_URL, settings.api_key_ref);
|
|
80
|
-
const
|
|
115
|
+
const section_id = /^[a-f0-9-]{36}$/i.test(input.section_id || '') ? input.section_id : undefined;
|
|
116
|
+
const additions = { ...(settings.context ? { context_texts: [settings.context] } : {}), ...(section_id ? { section_id } : {}) };
|
|
81
117
|
const body = { user: { uid: 'codiscuss' }, req_params: { text, speaker: settings.speaker,
|
|
82
|
-
audio_params: { format: 'mp3', sample_rate: 24000, speech_rate: settings.speech_rate },
|
|
83
|
-
...(additions ? { additions: JSON.stringify(additions) } : {}) } };
|
|
118
|
+
audio_params: { format: 'mp3', sample_rate: 24000, speech_rate: settings.speech_rate, enable_subtitle: true },
|
|
119
|
+
...(Object.keys(additions).length ? { additions: JSON.stringify(additions) } : {}) } };
|
|
84
120
|
let response;
|
|
85
121
|
try {
|
|
86
122
|
response = await request(TTS_ENDPOINT, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(TTS_TIMEOUT_MS),
|
|
@@ -91,7 +127,7 @@ export async function tts_speak(home, input, request = fetch) {
|
|
|
91
127
|
const objects = split_json_objects(await response.text());
|
|
92
128
|
const chunks = [];
|
|
93
129
|
for (const item of objects) {
|
|
94
|
-
if (item.code === TTS_DONE_CODE) return { audio: Buffer.concat(chunks).toString('base64'), format: 'mp3' };
|
|
130
|
+
if (item.code === TTS_DONE_CODE) return { audio: Buffer.concat(chunks).toString('base64'), format: 'mp3', marks: word_marks(objects) };
|
|
95
131
|
if (item.code !== 0) throw tts_error(item.code, item.message);
|
|
96
132
|
if (item.data) chunks.push(Buffer.from(item.data, 'base64'));
|
|
97
133
|
}
|