ai_co_discuss 0.3.26 → 0.3.28

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 CHANGED
@@ -667,7 +667,8 @@ def list_rooms(connection, params=None):
667
667
  identifier = submission_value(params["submission_id"]) if "submission_id" in params else None
668
668
  where = "WHERE EXISTS (SELECT 1 FROM room_creations WHERE room_id=rooms.id AND submission_id=?)" if identifier else ""
669
669
  rows = connection.execute(f"""SELECT rooms.*,
670
- COALESCE(stats.message_count,0) AS message_count,COALESCE(stats.latest_id,0) AS latest_id
670
+ COALESCE(stats.message_count,0) AS message_count,COALESCE(stats.latest_id,0) AS latest_id,
671
+ (SELECT created_at FROM messages WHERE id=stats.latest_id) AS latest_at
671
672
  FROM rooms LEFT JOIN room_message_stats stats ON stats.room_id=rooms.id {where}
672
673
  GROUP BY rooms.id ORDER BY rooms.created_at, rooms.id""", (identifier,) if identifier else ())
673
674
  directory = {row["room_id"]: dict(row) for row in connection.execute("SELECT * FROM room_directory")}
@@ -1311,6 +1312,10 @@ def message_filters(room_id, params):
1311
1312
  raise ValueError("发言职责无效")
1312
1313
  clauses.append("COALESCE(details.kind,'opinion')=?")
1313
1314
  values.append(params["kind"])
1315
+ if params.get("host") == "1":
1316
+ # Host view: moderation notes plus whatever the current host said.
1317
+ clauses.append("(COALESCE(details.kind,'opinion')='moderation' OR messages.author=(SELECT host FROM topic_settings WHERE room_id=?))")
1318
+ values.append(room_id)
1314
1319
  if "submission_id" in params:
1315
1320
  clauses.append("submission_id=?")
1316
1321
  values.append(submission_value(params["submission_id"]))
@@ -1721,7 +1726,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1721
1726
  site = self.local_request_site(method)
1722
1727
  route = parts[2:]
1723
1728
  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"]):
1729
+ ["scheduler-check"], ["tts-settings"], ["tts"], ["stt"], ["pick-directory"], ["projects"], ["presets"], ["machine-link"], ["admin-account"]):
1725
1730
  return self.respond({"ok": True})
1726
1731
  if route == ["settings"] and method in ("GET", "POST"):
1727
1732
  command = "settings"
@@ -1739,7 +1744,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1739
1744
  command = "api-check"
1740
1745
  elif route == ["scheduler-check"] and method == "POST":
1741
1746
  command = "scheduler-check"
1742
- elif route in (["tts-settings"], ["tts"]) and method == "POST":
1747
+ elif route in (["tts-settings"], ["tts"], ["stt"]) and method == "POST":
1743
1748
  command = route[0]
1744
1749
  elif route in (["room-member"], ["room-member-access"], ["room-member-allow"], ["room-member-adapter"], ["room-member-model"]) and method == "POST":
1745
1750
  command = route[0]
package/docs/runner.md CHANGED
@@ -88,9 +88,14 @@ API 成员可以担任专家或主持人。房间指定了项目目录时,它
88
88
 
89
89
  翻开成员卡片,“提示”一行可以给这位专家单独写几句要求,例如“回答先给结论”。它只发给这位专家,不改变权限;部分软件带有预设,可随时恢复。卡片背面的书签图标把这张卡片的描述、提示词、项目权限、模型与强度存为专家预设,之后在“添加”行一键加入,见 [使用指南](workflow.md#成员管理)。
90
90
 
91
- ## 朗读(可选)
91
+ ## 朗读与语音输入(可选)
92
92
 
93
- 在网页“设置”的“朗读”中填写火山引擎 API Key,选择音色、语速,可写一句语气提示(如“像朋友聊天一样自然地读”)。使用豆包语音合成 2.0,需先在火山引擎控制台开通该服务和所选音色,费用按你的账号计费;密钥只保存在本机。每条消息的工具栏有“朗读”,在消息里选中文字后点“听当前”从选中处开始;读完这条会接着读后面的消息直到最新,正在读的句子会高亮,代码块跳过。朗读由本机服务合成,手机等其他设备经已配对电脑使用,电脑关机时不可用。
93
+ 在火山引擎控制台开通「语音合成2.0」(朗读)和「录音文件识别2.0」(语音输入),复制 API Key,填进网页“设置”的“语音”。朗读可设一个通用音色,并按 AI 平台(Claude、Codex、Gemini 等)分别配音色,没配的平台、你自己的消息和程序消息用通用音色;还可选语速、改语气提示(默认是“像给朋友讲故事一样”的自然语气)。密钥只保存在本机,费用按你的账号计费。
94
+
95
+ - 朗读:每条消息的工具栏有“朗读”(手机上在“…”菜单里),在消息里选中文字后点“听当前”从选中处开始;读完这条会接着读后面的消息直到最新,点输入框右下角的播报按钮开启语音播报:之后的新回复会自动朗读回答摘要(没写摘要的短回答读全文,长回答读第一段),主持人的发言会报“主持人”;讨论中还会提示新一轮的发言顺序和轮到谁,执行失败、操作待你允许和讨论结束也会播一句。同时到达的回复依次播报,积压较多时只播最新 3 条。锁屏或切到其他 App 时继续播放,锁屏界面和耳机按键可以暂停、继续、跳到下一条;读过的段落会缓存在电脑上,重听不再计费。正在读的句子会高亮并自动滚到视野内,点一下高亮的句子暂停,再点继续;代码块跳过。
96
+ - 语音输入:点输入框右下角的麦克风开始说话,再点一次转成文字插入输入框,单次最长 5 分钟。浏览器会先请求麦克风权限。
97
+
98
+ 两者都由本机服务调用火山引擎,手机等其他设备经已配对电脑使用,电脑关机时不可用。
94
99
 
95
100
  ## 权限
96
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -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) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  /** Read-aloud: synthesize short text with Volcengine Doubao TTS 2.0; the key stays in the local private store. */
2
- import { randomUUID } from 'node:crypto';
3
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
 
6
6
  import { read_saved_api_key } from './discussion_api.mjs';
@@ -11,13 +11,48 @@ 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 one sentence at a time; this bounds a single relayed answer well under the relay frame size.
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
- export const TTS_DEFAULTS = { speaker: 'zh_female_vv_uranus_bigtts', speech_rate: 0, context: '' };
18
+ const SPEAKER_PATTERN = /^[\w.-]{1,128}$/;
19
+ // Trailing silence the page may ask for at a paragraph or message end; it is part of the audio, so it also plays in the background.
20
+ const MAX_SILENCE_MS = 3000;
21
+ // Synthesized paragraphs are kept on this computer, so hearing the same text again is instant and not billed again.
22
+ const CACHE_DIR = 'tts-cache';
23
+ const CACHE_LIMIT = 400;
24
+ const CACHE_PRUNE = 100;
25
+ const MAX_MEMBER_VOICES = 50;
26
+ // A default tone instruction (context_texts) so reading sounds told rather than recited; users may change or clear it.
27
+ export const TTS_DEFAULT_CONTEXT = '请用自然放松的语气朗读,像给朋友讲故事一样,有轻重缓急,句子之间自然停顿。';
28
+ export const TTS_DEFAULTS = { speaker: 'zh_female_vv_uranus_bigtts', speech_rate: 0, context: TTS_DEFAULT_CONTEXT };
19
29
  // 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 = [{ id: 'zh_female_vv_uranus_bigtts', label: 'Vivi(通用女声)' }];
30
+ export const TTS_VOICES = [
31
+ { id: 'zh_female_vv_uranus_bigtts', label: 'Vivi · 通用女声' },
32
+ { id: 'zh_female_xiaohe_uranus_bigtts', label: '小何 · 通用女声' },
33
+ { id: 'zh_male_m191_uranus_bigtts', label: '云舟 · 通用男声' },
34
+ { id: 'zh_male_taocheng_uranus_bigtts', label: '小天 · 通用男声' },
35
+ { id: 'zh_male_liufei_uranus_bigtts', label: '刘飞 · 通用男声' },
36
+ { id: 'zh_female_sophie_uranus_bigtts', label: '魅力苏菲 · 女声' },
37
+ { id: 'zh_female_qingxinnvsheng_uranus_bigtts', label: '清新女声' },
38
+ { id: 'zh_female_cancan_uranus_bigtts', label: '知性灿灿 · 女声' },
39
+ { id: 'zh_female_zhixingnv_uranus_bigtts', label: '知性女声' },
40
+ { id: 'zh_female_wenroushunv_uranus_bigtts', label: '温柔淑女 · 女声' },
41
+ { id: 'zh_female_linjianvhai_uranus_bigtts', label: '邻家女孩' },
42
+ { id: 'zh_female_shuangkuaisisi_uranus_bigtts', label: '爽快思思 · 女声' },
43
+ { id: 'zh_female_gaolengyujie_uranus_bigtts', label: '高冷御姐 · 女声' },
44
+ { id: 'zh_female_xiaoxue_uranus_bigtts', label: '儿童绘本 · 女声' },
45
+ { id: 'zh_male_ruyaqingnian_uranus_bigtts', label: '儒雅青年 · 男声' },
46
+ { id: 'zh_male_yangguangqingnian_uranus_bigtts', label: '阳光青年 · 男声' },
47
+ { id: 'zh_male_yuanboxiaoshu_uranus_bigtts', label: '渊博小叔 · 男声' },
48
+ { id: 'zh_male_cixingjieshuonan_uranus_bigtts', label: '磁性解说 · 男声' },
49
+ { id: 'zh_male_shenyeboke_uranus_bigtts', label: '深夜播客 · 男声' },
50
+ { id: 'zh_male_xuanyijieshuo_uranus_bigtts', label: '悬疑解说 · 男声' },
51
+ { id: 'zh_male_baqiqingshu_uranus_bigtts', label: '霸气青叔 · 有声书男声' },
52
+ { id: 'zh_male_yizhipiannan_uranus_bigtts', label: '译制片男声' },
53
+ { id: 'zh_male_jieshuoxiaoming_uranus_bigtts', label: '解说小明 · 男声' },
54
+ { id: 'zh_male_wennuanahu_uranus_bigtts', label: '温暖阿虎 · 男声' },
55
+ ];
21
56
  const STATUS_MESSAGES = {
22
57
  40402003: '文本超出单次合成长度上限',
23
58
  45000000: '音色不可用:请确认已在火山引擎控制台开通该豆包语音合成 2.0 音色,并核对音色 ID',
@@ -28,20 +63,27 @@ const STATUS_MESSAGES = {
28
63
  export function tts_settings(home) {
29
64
  const path = join(home, SETTINGS_FILE);
30
65
  const saved = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
31
- return { ...TTS_DEFAULTS, ...saved, voices: TTS_VOICES, key_base_url: TTS_BASE_URL };
66
+ return { ...TTS_DEFAULTS, member_voices: {}, ...saved, voices: TTS_VOICES, key_base_url: TTS_BASE_URL };
32
67
  }
33
68
 
34
69
  /** Validate and save the read-aloud settings the page edits. */
35
70
  export function save_tts_settings(home, input) {
36
71
  const speaker = String(input.speaker ?? TTS_DEFAULTS.speaker).trim();
37
- if (!/^[\w.-]{1,128}$/.test(speaker)) throw new Error('请填写有效的音色 ID');
72
+ if (!SPEAKER_PATTERN.test(speaker)) throw new Error('请填写有效的音色 ID');
38
73
  const speech_rate = Number(input.speech_rate ?? 0);
39
74
  if (!Number.isInteger(speech_rate) || speech_rate < -50 || speech_rate > 100) throw new Error('语速须为 -50 到 100 的整数');
40
75
  const context = String(input.context ?? '').trim();
41
76
  if (context.length > MAX_CONTEXT) throw new Error(`语气提示不超过 ${MAX_CONTEXT} 字`);
42
77
  const api_key_ref = input.api_key_ref || undefined;
43
78
  if (api_key_ref && !/^[a-f0-9-]{36}$/i.test(api_key_ref)) throw new Error('API Key 引用无效,请重新填写');
44
- const settings = { speaker, speech_rate, context, ...(api_key_ref ? { api_key_ref } : {}) };
79
+ // Voices chosen per AI platform; members of other platforms, the user and program messages use the default voice.
80
+ const member_voices = {};
81
+ for (const [name, voice] of Object.entries(input.member_voices || {}).slice(0, MAX_MEMBER_VOICES)) {
82
+ if (!voice) continue;
83
+ if (String(name).length > 40 || !SPEAKER_PATTERN.test(voice)) throw new Error('成员音色无效,请重新选择');
84
+ member_voices[name] = voice;
85
+ }
86
+ const settings = { speaker, speech_rate, context, member_voices, ...(api_key_ref ? { api_key_ref } : {}) };
45
87
  mkdirSync(home, { recursive: true, mode: 0o700 });
46
88
  atomic_json(join(home, SETTINGS_FILE), settings);
47
89
  return tts_settings(home);
@@ -69,18 +111,50 @@ function tts_error(code, message) {
69
111
  return new Error(`语音合成失败(${code}):${STATUS_MESSAGES[code] || message || '请核对 API Key 与音色'}`);
70
112
  }
71
113
 
72
- /** Synthesize one piece of text and return it as base64 MP3. */
114
+ /** Flatten the per-word timings the service returns with subtitles, as { w: word, t: start in ms }. */
115
+ function word_marks(objects) {
116
+ return objects.flatMap(item => item.sentence?.words || []).map(word => ({ w: String(word.word || ''), t: Math.round(Number(word.startTime || 0) * 1000) }));
117
+ }
118
+
119
+ /** Read a cached synthesis, refreshing its time so the most recently heard stay; null when absent or unreadable. */
120
+ function cache_read(path) {
121
+ try { const value = JSON.parse(readFileSync(path, 'utf8')); atomic_json(path, value); return value; } catch { return null; }
122
+ }
123
+
124
+ /** Keep the cache bounded: past the limit, drop the least recently heard entries. */
125
+ function cache_write(directory, path, value) {
126
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
127
+ atomic_json(path, value);
128
+ const names = readdirSync(directory).filter(name => name.endsWith('.json'));
129
+ if (names.length <= CACHE_LIMIT) return;
130
+ const aged = names.map(name => ({ name, time: statSync(join(directory, name)).mtimeMs })).sort((a, b) => a.time - b.time);
131
+ for (const { name } of aged.slice(0, CACHE_PRUNE)) rmSync(join(directory, name), { force: true });
132
+ }
133
+
134
+ /**
135
+ * Synthesize one paragraph and return it as base64 MP3 with word timings for highlighting.
136
+ * `speaker` may name another voice for this paragraph (a member's own voice); `silence_ms` appends trailing silence.
137
+ * Requests of one reading pass share `section_id`, so the service keeps the earlier paragraphs as context.
138
+ */
73
139
  export async function tts_speak(home, input, request = fetch) {
74
140
  const text = typeof input?.text === 'string' ? input.text.trim() : '';
75
141
  if (!text) throw new Error('朗读文本为空');
76
142
  if (text.length > TTS_MAX_TEXT) throw new Error(`单次朗读不超过 ${TTS_MAX_TEXT} 字`);
77
143
  const settings = tts_settings(home);
78
- if (!settings.api_key_ref) throw new Error('尚未配置朗读:请在设置的“朗读”中填写火山引擎 API Key');
144
+ if (!settings.api_key_ref) throw new Error('尚未配置语音:请在设置的“语音”中填写火山引擎 API Key');
145
+ const speaker = SPEAKER_PATTERN.test(input.speaker || '') ? input.speaker : settings.speaker;
146
+ const silence = Math.max(0, Math.min(MAX_SILENCE_MS, Math.round(Number(input.silence_ms) || 0)));
147
+ const directory = join(home, CACHE_DIR);
148
+ const cache_path = join(directory, `${createHash('sha256').update(JSON.stringify([text, speaker, settings.speech_rate, settings.context, silence])).digest('hex')}.json`);
149
+ const cached = existsSync(cache_path) && cache_read(cache_path);
150
+ if (cached?.audio) return { ...cached, cached: true };
79
151
  const key = read_saved_api_key(home, TTS_BASE_URL, settings.api_key_ref);
80
- const additions = settings.context ? { context_texts: [settings.context] } : null;
81
- 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) } : {}) } };
152
+ const section_id = /^[a-f0-9-]{36}$/i.test(input.section_id || '') ? input.section_id : undefined;
153
+ const additions = { ...(settings.context ? { context_texts: [settings.context] } : {}), ...(section_id ? { section_id } : {}),
154
+ ...(silence ? { silence_duration: silence } : {}) };
155
+ const body = { user: { uid: 'codiscuss' }, req_params: { text, speaker,
156
+ audio_params: { format: 'mp3', sample_rate: 24000, speech_rate: settings.speech_rate, enable_subtitle: true },
157
+ ...(Object.keys(additions).length ? { additions: JSON.stringify(additions) } : {}) } };
84
158
  let response;
85
159
  try {
86
160
  response = await request(TTS_ENDPOINT, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(TTS_TIMEOUT_MS),
@@ -91,7 +165,11 @@ export async function tts_speak(home, input, request = fetch) {
91
165
  const objects = split_json_objects(await response.text());
92
166
  const chunks = [];
93
167
  for (const item of objects) {
94
- if (item.code === TTS_DONE_CODE) return { audio: Buffer.concat(chunks).toString('base64'), format: 'mp3' };
168
+ if (item.code === TTS_DONE_CODE) {
169
+ const result = { audio: Buffer.concat(chunks).toString('base64'), format: 'mp3', marks: word_marks(objects) };
170
+ cache_write(directory, cache_path, result);
171
+ return result;
172
+ }
95
173
  if (item.code !== 0) throw tts_error(item.code, item.message);
96
174
  if (item.data) chunks.push(Buffer.from(item.data, 'base64'));
97
175
  }