接入边缘浏览器语音合成并做本地缓存,听书段落可直接朗读。

依赖随锁文件一并落下,避免环境缺包。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 23:48:43 +08:00
co-authored by Cursor
parent 1cc4ae557a
commit 820397e578
7 changed files with 708 additions and 8 deletions
+42
View File
@@ -0,0 +1,42 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { getUserDataDir } from "../config/user-data-dir.js";
function cacheDir(): string {
const dir = path.join(getUserDataDir(), "tts-cache");
mkdirSync(dir, { recursive: true });
return dir;
}
export function ttsCacheKey(parts: {
provider: string;
voice: string;
text: string;
extra?: string;
}): string {
const h = createHash("sha256");
h.update(parts.provider);
h.update("\0");
h.update(parts.voice);
h.update("\0");
h.update(parts.extra ?? "");
h.update("\0");
h.update(parts.text);
return h.digest("hex").slice(0, 40);
}
export function readTtsCache(key: string, ext: string): Buffer | null {
const file = path.join(cacheDir(), `${key}.${ext}`);
if (!existsSync(file)) return null;
try {
return readFileSync(file);
} catch {
return null;
}
}
export function writeTtsCache(key: string, ext: string, data: Buffer): void {
const file = path.join(cacheDir(), `${key}.${ext}`);
writeFileSync(file, data);
}
+109
View File
@@ -0,0 +1,109 @@
import { MsEdgeTTS, OUTPUT_FORMAT } from "msedge-tts";
import { readTtsCache, ttsCacheKey, writeTtsCache } from "./cache.js";
import {
DEFAULT_EDGE_VOICE,
type TtsSynthesizeParams,
type TtsSynthesizeResult,
} from "./types.js";
/** Edge SSML 单段不宜过长;按句切到约此字数 */
const CHUNK_TARGET = 1200;
function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function resolveVoice(explicit?: string): string {
const fromEnv = process.env.WRITING_AGENT_TTS_VOICE?.trim();
return (explicit?.trim() || fromEnv || DEFAULT_EDGE_VOICE).trim();
}
function splitChunks(text: string): string[] {
const cleaned = text.replace(/\r\n/g, "\n").trim();
if (!cleaned) return [];
if (cleaned.length <= CHUNK_TARGET) return [cleaned];
const parts: string[] = [];
let buf = "";
const pushBuf = () => {
const t = buf.trim();
if (t) parts.push(t);
buf = "";
};
const sentences = cleaned.split(/(?<=[。!?!?;;\n])/u);
for (const s of sentences) {
if (!s) continue;
if (buf.length + s.length > CHUNK_TARGET && buf.length > 0) {
pushBuf();
}
buf += s;
if (buf.length >= CHUNK_TARGET) pushBuf();
}
pushBuf();
return parts.length ? parts : [cleaned.slice(0, CHUNK_TARGET)];
}
async function streamToBuffer(
audioStream: NodeJS.ReadableStream,
): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of audioStream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
async function synthesizeChunk(voice: string, text: string): Promise<Buffer> {
const tts = new MsEdgeTTS();
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3);
const { audioStream } = tts.toStream(escapeXml(text));
return streamToBuffer(audioStream);
}
/**
* Edge 在线神经 TTS(微软朗读通道)。文本会发往微软服务端。
*/
export async function synthesizeWithEdge(
params: TtsSynthesizeParams,
): Promise<TtsSynthesizeResult> {
const text = params.text.trim();
if (!text) throw new Error("TTS 文本为空");
const voice = resolveVoice(params.voice);
const key = ttsCacheKey({
provider: "edge",
voice,
text,
extra: params.cacheKey,
});
const hit = readTtsCache(key, "mp3");
if (hit) {
return {
audio: hit,
contentType: "audio/mpeg",
provider: "edge",
voice,
cached: true,
};
}
const chunks = splitChunks(text);
const buffers: Buffer[] = [];
for (const chunk of chunks) {
buffers.push(await synthesizeChunk(voice, chunk));
}
const audio = Buffer.concat(buffers);
writeTtsCache(key, "mp3", audio);
return {
audio,
contentType: "audio/mpeg",
provider: "edge",
voice,
cached: false,
};
}
+42
View File
@@ -0,0 +1,42 @@
import { synthesizeWithEdge } from "./edge.js";
import {
DEFAULT_TTS_PROVIDER,
type TtsProviderId,
type TtsSynthesizeParams,
type TtsSynthesizeResult,
} from "./types.js";
export {
DEFAULT_EDGE_VOICE,
DEFAULT_TTS_PROVIDER,
type TtsProviderId,
type TtsSynthesizeParams,
type TtsSynthesizeResult,
} from "./types.js";
export function resolveTtsProvider(): TtsProviderId {
const raw = (process.env.WRITING_AGENT_TTS_PROVIDER ?? DEFAULT_TTS_PROVIDER)
.trim()
.toLowerCase();
if (raw === "local" || raw === "tencent" || raw === "edge") return raw;
return DEFAULT_TTS_PROVIDER;
}
/**
* 统一合成入口。当前仅实现 edge;local / tencent 占位。
*/
export async function synthesizeSpeech(
params: TtsSynthesizeParams,
): Promise<TtsSynthesizeResult> {
const provider = resolveTtsProvider();
if (provider === "edge") {
return synthesizeWithEdge(params);
}
if (provider === "local") {
throw new Error("本地 TTS 尚未接入;请将 WRITING_AGENT_TTS_PROVIDER=edge");
}
if (provider === "tencent") {
throw new Error("腾讯云 TTS 尚未接入;请将 WRITING_AGENT_TTS_PROVIDER=edge");
}
return synthesizeWithEdge(params);
}
+22
View File
@@ -0,0 +1,22 @@
/** TTS 提供方(默认 edge;本地/腾讯后接) */
export type TtsProviderId = "edge" | "local" | "tencent";
export type TtsSynthesizeParams = {
text: string;
/** 缓存键额外盐(如 sectionId),便于按节失效 */
cacheKey?: string;
voice?: string;
};
export type TtsSynthesizeResult = {
audio: Buffer;
contentType: string;
provider: TtsProviderId;
voice: string;
cached: boolean;
};
export const DEFAULT_TTS_PROVIDER: TtsProviderId = "edge";
/** 听书默认中文声线 */
export const DEFAULT_EDGE_VOICE = "zh-CN-XiaoxiaoNeural";