mirror of
https://github.com/raphaelsalaja/userinterface-wiki.git
synced 2026-09-14 20:06:29 +08:00
517 lines
14 KiB
TypeScript
517 lines
14 KiB
TypeScript
import { Buffer } from "node:buffer";
|
|
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type { PutBlobResult } from "@vercel/blob";
|
|
import { head, list, put } from "@vercel/blob";
|
|
import { toString as mdastToString } from "mdast-util-to-string";
|
|
import remarkFrontmatter from "remark-frontmatter";
|
|
import remarkMdx from "remark-mdx";
|
|
import remarkParse from "remark-parse";
|
|
import { unified } from "unified";
|
|
import { visit } from "unist-util-visit";
|
|
import { ArticleNotFoundError, isEnoent, isNotFound } from "./errors";
|
|
|
|
/** Character-level alignment data from ElevenLabs with-timestamps API */
|
|
export interface Alignment {
|
|
characters: string[];
|
|
character_start_times_seconds: number[];
|
|
character_end_times_seconds: number[];
|
|
}
|
|
|
|
/** Result from speech synthesis with timestamps */
|
|
export interface SpeechResult {
|
|
audioBuffer: Buffer;
|
|
alignment: Alignment;
|
|
}
|
|
|
|
const CONTENT_DIR = path.join(process.cwd(), "content");
|
|
const CACHE_PREFIX = "audio-transcripts";
|
|
const MODEL_ID = "eleven_turbo_v2_5";
|
|
const OUTPUT_FORMAT = "mp3_22050_32";
|
|
const MIN_PARAGRAPH_LENGTH = 100;
|
|
const MAX_PARAGRAPH_LENGTH = 2500;
|
|
const TARGET_BATCH_LENGTH = 1000;
|
|
|
|
/** Extracts plain speakable text from an article given its slug segments. */
|
|
export async function getPlainArticleText(slugSegments: string[]) {
|
|
const articlePath = resolveArticlePath(slugSegments);
|
|
const raw = await readFile(articlePath, "utf8").catch((error) => {
|
|
if (isEnoent(error)) throw new ArticleNotFoundError();
|
|
throw error;
|
|
});
|
|
|
|
return mdxToSpeakableText(raw);
|
|
}
|
|
|
|
const DROP_TYPES = new Set([
|
|
"mdxjsEsm",
|
|
"mdxJsxFlowElement",
|
|
"mdxJsxTextElement",
|
|
"mdxFlowExpression",
|
|
"mdxTextExpression",
|
|
"yaml",
|
|
"toml",
|
|
"code",
|
|
"inlineCode",
|
|
"html",
|
|
"image",
|
|
"imageReference",
|
|
"footnoteDefinition",
|
|
"footnoteReference",
|
|
"thematicBreak",
|
|
]);
|
|
|
|
/** Strips non-speakable nodes from an AST tree using filter. */
|
|
// biome-ignore lint/suspicious/noExplicitAny: AST nodes have dynamic structure
|
|
function stripNonSpeakable(tree: any): void {
|
|
visit(tree, (node) => {
|
|
if (!node.children || !Array.isArray(node.children)) return;
|
|
node.children = node.children.filter(
|
|
// biome-ignore lint/suspicious/noExplicitAny: AST child nodes have dynamic structure
|
|
(child: any) => !DROP_TYPES.has(child.type),
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Converts MDX to speakable plain text using proper AST parsing. */
|
|
function mdxToSpeakableText(mdx: string): string {
|
|
const tree = unified()
|
|
.use(remarkParse)
|
|
.use(remarkMdx)
|
|
.use(remarkFrontmatter, ["yaml", "toml"])
|
|
.parse(mdx);
|
|
|
|
stripNonSpeakable(tree);
|
|
|
|
return mdastToString(tree)
|
|
.replace(/\r\n/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.replace(/[ \t]+\n/g, "\n")
|
|
.replace(/[ \t]+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
/** Splits article text into paragraphs for incremental caching, merging small paragraphs to reduce API call overhead. */
|
|
function splitIntoParagraphs(text: string): string[] {
|
|
const parts = text
|
|
.split(/\n{2,}/)
|
|
.map((p) => optimizeTextForTTS(p.trim()))
|
|
.filter(Boolean);
|
|
|
|
const out: string[] = [];
|
|
let buf = "";
|
|
|
|
const flush = () => {
|
|
if (buf.trim().length >= MIN_PARAGRAPH_LENGTH) out.push(buf.trim());
|
|
buf = "";
|
|
};
|
|
|
|
for (const p of parts) {
|
|
// Hard-split oversized paragraphs
|
|
if (p.length > MAX_PARAGRAPH_LENGTH) {
|
|
flush();
|
|
for (let i = 0; i < p.length; i += MAX_PARAGRAPH_LENGTH) {
|
|
const chunk = p.slice(i, i + MAX_PARAGRAPH_LENGTH).trim();
|
|
if (chunk) out.push(chunk);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!buf) {
|
|
buf = p;
|
|
continue;
|
|
}
|
|
|
|
const candidate = `${buf} ${p}`;
|
|
if (candidate.length <= TARGET_BATCH_LENGTH) {
|
|
buf = candidate;
|
|
continue;
|
|
}
|
|
|
|
flush();
|
|
buf = p;
|
|
}
|
|
|
|
flush();
|
|
return out;
|
|
}
|
|
|
|
/** Returns paragraphs with their hashes for cache checking. */
|
|
export interface ParagraphInfo {
|
|
index: number;
|
|
text: string;
|
|
hash: string;
|
|
characters: number;
|
|
}
|
|
|
|
export function analyzeParagraphs(text: string): ParagraphInfo[] {
|
|
const paragraphs = splitIntoParagraphs(text);
|
|
return paragraphs.map((text, index) => ({
|
|
index,
|
|
text,
|
|
hash: hashContent(text),
|
|
characters: text.length,
|
|
}));
|
|
}
|
|
|
|
function resolveArticlePath(slugSegments: string[]) {
|
|
const slug = slugSegments.join("/");
|
|
const absolute = path.join(CONTENT_DIR, slug, "index.mdx");
|
|
const rel = path.relative(CONTENT_DIR, absolute);
|
|
|
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
throw new Error("Invalid slug path");
|
|
}
|
|
|
|
return absolute;
|
|
}
|
|
|
|
/** Optimizes text to reduce character count without changing speech output. */
|
|
function optimizeTextForTTS(text: string): string {
|
|
return text
|
|
.replace(/[ \t]+/g, " ")
|
|
.replace(/[\u00AD\u200B-\u200D\uFEFF]/g, "")
|
|
.replace(/[""]/g, '"')
|
|
.replace(/['']/g, "'")
|
|
.replace(/[—–]/g, "-")
|
|
.replace(/\.{2,}/g, ".")
|
|
.replace(/!{2,}/g, "!")
|
|
.replace(/\?{2,}/g, "?")
|
|
.replace(/\s*\([^)]{0,20}\)\s*/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
export interface ParagraphCacheKey {
|
|
slug: string;
|
|
paragraphHash: string;
|
|
audioPath: string;
|
|
alignmentPath: string;
|
|
}
|
|
|
|
/** Builds cache key for a single paragraph. */
|
|
export function buildParagraphCacheKey(
|
|
slugSegments: string[],
|
|
paragraphHash: string,
|
|
): ParagraphCacheKey {
|
|
const slug = slugSegments.join("__");
|
|
const base = `${CACHE_PREFIX}/${slug}/p_${paragraphHash}`;
|
|
return {
|
|
slug,
|
|
paragraphHash,
|
|
audioPath: `${base}.mp3`,
|
|
alignmentPath: `${base}.json`,
|
|
};
|
|
}
|
|
|
|
/** Builds manifest cache key for a document (stores paragraph order). */
|
|
function buildManifestCacheKey(
|
|
slugSegments: string[],
|
|
paragraphHashes: string[],
|
|
): { base: string; hash: string } {
|
|
const slug = slugSegments.join("__");
|
|
const manifestHash = hashContent(paragraphHashes.join("|"));
|
|
return {
|
|
base: `${CACHE_PREFIX}/${slug}/manifest_${manifestHash}`,
|
|
hash: manifestHash,
|
|
};
|
|
}
|
|
|
|
/** Finds a blob by prefix (handles legacy blobs with random suffixes). */
|
|
async function findBlobByPrefix(
|
|
prefix: string,
|
|
extension: string,
|
|
): Promise<{ url: string; pathname: string } | null> {
|
|
// First try exact path (new format without random suffix)
|
|
const exactPath = `${prefix}${extension}`;
|
|
const exactMeta = await safeHead(exactPath);
|
|
if (exactMeta) {
|
|
return { url: exactMeta.url, pathname: exactMeta.pathname };
|
|
}
|
|
|
|
// Fall back to prefix search (legacy format with random suffix)
|
|
try {
|
|
const result = await list({ prefix });
|
|
const matching = result.blobs.find((b) => b.pathname.endsWith(extension));
|
|
if (matching) {
|
|
return { url: matching.url, pathname: matching.pathname };
|
|
}
|
|
} catch {
|
|
// Ignore list errors
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Checks if a paragraph is cached (both audio and alignment). */
|
|
export async function isParagraphCached(
|
|
key: ParagraphCacheKey,
|
|
): Promise<boolean> {
|
|
// Extract base path without extension
|
|
const audioBase = key.audioPath.replace(/\.mp3$/, "");
|
|
const alignmentBase = key.alignmentPath.replace(/\.json$/, "");
|
|
|
|
const [audioMeta, alignmentMeta] = await Promise.all([
|
|
findBlobByPrefix(audioBase, ".mp3"),
|
|
findBlobByPrefix(alignmentBase, ".json"),
|
|
]);
|
|
return !!audioMeta && !!alignmentMeta;
|
|
}
|
|
|
|
/** Reads a single paragraph from cache (audio and alignment). */
|
|
async function readParagraphFromCache(
|
|
key: ParagraphCacheKey,
|
|
): Promise<{ audioBuffer: Buffer; alignment: Alignment } | null> {
|
|
// Extract base path without extension
|
|
const audioBase = key.audioPath.replace(/\.mp3$/, "");
|
|
const alignmentBase = key.alignmentPath.replace(/\.json$/, "");
|
|
|
|
const [audioMeta, alignmentMeta] = await Promise.all([
|
|
findBlobByPrefix(audioBase, ".mp3"),
|
|
findBlobByPrefix(alignmentBase, ".json"),
|
|
]);
|
|
if (!audioMeta || !alignmentMeta) return null;
|
|
|
|
const [audioResponse, alignmentResponse] = await Promise.all([
|
|
fetch(audioMeta.url),
|
|
fetch(alignmentMeta.url),
|
|
]);
|
|
|
|
const [audioBuffer, alignment] = await Promise.all([
|
|
audioResponse.arrayBuffer().then((ab) => Buffer.from(ab)),
|
|
alignmentResponse.json() as Promise<Alignment>,
|
|
]);
|
|
|
|
return { audioBuffer, alignment };
|
|
}
|
|
|
|
/** Writes a single paragraph to cache (audio and alignment). */
|
|
export async function writeParagraphToCache(
|
|
key: ParagraphCacheKey,
|
|
audioBuffer: Buffer,
|
|
alignment: Alignment,
|
|
): Promise<void> {
|
|
await Promise.all([
|
|
uploadBinary(key.audioPath, audioBuffer, "audio/mpeg", true),
|
|
uploadJson(key.alignmentPath, alignment, true),
|
|
]);
|
|
}
|
|
|
|
/** Reads full document from paragraph cache (combines all paragraphs). */
|
|
export async function readDocumentFromCache(
|
|
slugSegments: string[],
|
|
paragraphs: ParagraphInfo[],
|
|
): Promise<{ audioUrl: string; alignment: Alignment } | null> {
|
|
// Check if all paragraphs are cached
|
|
const cacheKeys = paragraphs.map((p) =>
|
|
buildParagraphCacheKey(slugSegments, p.hash),
|
|
);
|
|
|
|
const cachedFlags = await Promise.all(cacheKeys.map(isParagraphCached));
|
|
if (!cachedFlags.every(Boolean)) return null;
|
|
|
|
// All cached - read and combine
|
|
const cachedParagraphs = await Promise.all(
|
|
cacheKeys.map(readParagraphFromCache),
|
|
);
|
|
|
|
// Filter out any nulls (shouldn't happen since we checked, but be safe)
|
|
const validParagraphs = cachedParagraphs.filter(
|
|
(p): p is { audioBuffer: Buffer; alignment: Alignment } => p !== null,
|
|
);
|
|
if (validParagraphs.length !== cachedParagraphs.length) return null;
|
|
|
|
// Combine audio buffers
|
|
const combinedAudio = Buffer.concat(
|
|
validParagraphs.map((p) => p.audioBuffer),
|
|
);
|
|
|
|
// Combine alignments (adjust timestamps for each paragraph)
|
|
const combinedAlignment = combineAlignments(
|
|
validParagraphs.map((p) => p.alignment),
|
|
);
|
|
|
|
// Upload combined result (allow overwrite since manifest hash is content-based)
|
|
const manifestKey = buildManifestCacheKey(
|
|
slugSegments,
|
|
paragraphs.map((p) => p.hash),
|
|
);
|
|
|
|
const [audioUrl] = await Promise.all([
|
|
uploadBinary(`${manifestKey.base}.mp3`, combinedAudio, "audio/mpeg", true),
|
|
uploadJson(`${manifestKey.base}.json`, combinedAlignment, true),
|
|
]);
|
|
|
|
return { audioUrl, alignment: combinedAlignment };
|
|
}
|
|
|
|
/** Combines multiple alignment objects, adjusting timestamps sequentially. */
|
|
function combineAlignments(alignments: Alignment[]): Alignment {
|
|
const combined: Alignment = {
|
|
characters: [],
|
|
character_start_times_seconds: [],
|
|
character_end_times_seconds: [],
|
|
};
|
|
|
|
let timeOffset = 0;
|
|
|
|
for (const alignment of alignments) {
|
|
// Add characters
|
|
combined.characters.push(...alignment.characters);
|
|
|
|
// Add timestamps with offset
|
|
for (const startTime of alignment.character_start_times_seconds) {
|
|
combined.character_start_times_seconds.push(startTime + timeOffset);
|
|
}
|
|
for (const endTime of alignment.character_end_times_seconds) {
|
|
combined.character_end_times_seconds.push(endTime + timeOffset);
|
|
}
|
|
|
|
// Update offset for next paragraph (use last end time)
|
|
if (alignment.character_end_times_seconds.length > 0) {
|
|
const lastEndTime =
|
|
alignment.character_end_times_seconds[
|
|
alignment.character_end_times_seconds.length - 1
|
|
];
|
|
timeOffset += lastEndTime;
|
|
}
|
|
}
|
|
|
|
return combined;
|
|
}
|
|
|
|
function hashContent(value: string) {
|
|
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
async function safeHead(pathname: string) {
|
|
try {
|
|
return await head(pathname);
|
|
} catch (error) {
|
|
if (isNotFound(error)) return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function uploadBinary(
|
|
pathname: string,
|
|
data: Buffer,
|
|
contentType: string,
|
|
allowOverwrite = false,
|
|
) {
|
|
const result = (await put(pathname, data, {
|
|
access: "public",
|
|
contentType,
|
|
addRandomSuffix: !allowOverwrite,
|
|
allowOverwrite,
|
|
})) as PutBlobResult;
|
|
return result.url;
|
|
}
|
|
|
|
async function uploadJson(
|
|
pathname: string,
|
|
data: unknown,
|
|
allowOverwrite = false,
|
|
) {
|
|
const json = JSON.stringify(data);
|
|
const result = (await put(pathname, json, {
|
|
access: "public",
|
|
contentType: "application/json",
|
|
addRandomSuffix: !allowOverwrite,
|
|
allowOverwrite,
|
|
})) as PutBlobResult;
|
|
return result.url;
|
|
}
|
|
|
|
/** Synthesizes speech with character-level timestamps using ElevenLabs REST API. */
|
|
export async function synthesizeSpeech(text: string): Promise<SpeechResult> {
|
|
const apiKey = process.env.ELEVENLABS_API_KEY;
|
|
if (!apiKey) {
|
|
throw new Error("ELEVENLABS_API_KEY environment variable is not set");
|
|
}
|
|
|
|
const voiceId = process.env.ELEVENLABS_VOICE_ID;
|
|
if (!voiceId) {
|
|
throw new Error("ELEVENLABS_VOICE_ID environment variable is not set");
|
|
}
|
|
|
|
const response = await fetch(
|
|
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/with-timestamps`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"xi-api-key": apiKey,
|
|
},
|
|
body: JSON.stringify({
|
|
text,
|
|
model_id: MODEL_ID,
|
|
output_format: OUTPUT_FORMAT,
|
|
voice_settings: {
|
|
stability: 0.5,
|
|
similarity_boost: 0.75,
|
|
style: 0.0,
|
|
use_speaker_boost: true,
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`ElevenLabs TTS failed: ${response.status} - ${errorText}`);
|
|
}
|
|
|
|
const data = (await response.json()) as {
|
|
audio_base64: string;
|
|
alignment: Alignment;
|
|
};
|
|
|
|
const audioBuffer = Buffer.from(data.audio_base64, "base64");
|
|
|
|
return {
|
|
audioBuffer,
|
|
alignment: data.alignment,
|
|
};
|
|
}
|
|
|
|
/** Gets available quota information from ElevenLabs. */
|
|
export async function getQuotaInfo(): Promise<{
|
|
characterCount: number;
|
|
characterLimit: number;
|
|
remainingCharacters: number;
|
|
}> {
|
|
const apiKey = process.env.ELEVENLABS_API_KEY;
|
|
if (!apiKey) {
|
|
throw new Error("ELEVENLABS_API_KEY environment variable is not set");
|
|
}
|
|
|
|
const response = await fetch(
|
|
"https://api.elevenlabs.io/v1/user/subscription",
|
|
{
|
|
headers: {
|
|
"xi-api-key": apiKey,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(
|
|
`ElevenLabs subscription check failed: ${response.status} - ${errorText}`,
|
|
);
|
|
}
|
|
|
|
const subscription = (await response.json()) as {
|
|
character_count: number;
|
|
character_limit: number;
|
|
};
|
|
|
|
return {
|
|
characterCount: subscription.character_count,
|
|
characterLimit: subscription.character_limit,
|
|
remainingCharacters:
|
|
subscription.character_limit - subscription.character_count,
|
|
};
|
|
}
|