接入边缘浏览器语音合成并做本地缓存,听书段落可直接朗读。
依赖随锁文件一并落下,避免环境缺包。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user