UNPKG

xunfei-tts

Version:

借助“讯飞在线语音合成API”实现浏览器端“文本转语音

256 lines (226 loc) 8.29 kB
import type { Token } from './type' import { TokenTypeEnum } from './type' /** * 文本流分段器 - 用于将连续文本按语言规则智能分割成有意义的段落和片段 * 支持中文、英文混合文本处理,正确识别单词、标点符号和数字,避免小数点等特殊符号导致的错误分段 */ class TextStreamSplitter { // 中文字符 private readonly CHINESE_CHAR_REGEX = /[\u4E00-\u9FA5]/ // 中文标点符号 private readonly CHINESE_PUNCTUATION_REGEX = /[。?!,;:“”‘’()—…、·]/ // // 中文段落结束符号(句末标点) private readonly CHINESE_PARAGRAPH_END_REGEX = /[。?!;]/ // 英文单词起始字符 private readonly ENGLISH_WORD_START_REGEX = /[a-z]/i // 英文单词延续字符(含缩写和连字符) private readonly ENGLISH_WORD_CONTINUE_REGEX = /[a-z'’-]/i // 英文标点符号 private readonly ENGLISH_PUNCTUATION_REGEX = /[.,!?;:'"()-]/ // 英文段落结束符号(句末标点) private readonly ENGLISH_PARAGRAPH_END_REGEX = /[.!?;]/ // 空白字符(统一转为单个空格) private readonly SPACE_CHAR_REGEX = /\s/ // 最小段落长度 private readonly MIN_PARAGRAPH_LENGTH = 10 // 待处理的剩余文本标记 private pendingTokens: Token[] = [] /** * 将输入文本转换为标记流。 * @param text 原始文本内容。 * @returns 标记化后的文本标记数组。 */ private tokenizeText(text: string): Token[] { const tokens: Token[] = [] let currentIndex = 0 while (currentIndex < text.length) { const char = text[currentIndex] // 识别中文字符 if (this.CHINESE_CHAR_REGEX.test(char)) { tokens.push({ type: TokenTypeEnum.CHINESE_CHAR, value: char, }) } // 识别英文单词 else if (this.ENGLISH_WORD_START_REGEX.test(char)) { const startIndex = currentIndex let endIndex = currentIndex + 1 // 提取完整英文单词 while (endIndex < text.length && this.ENGLISH_WORD_CONTINUE_REGEX.test(text[endIndex])) { endIndex++ } const word = text.substring(startIndex, endIndex) tokens.push({ type: TokenTypeEnum.ENGLISH_WORD, value: word, }) // 跳过已处理的字符 currentIndex += word.length - 1 } // 识别中文标点符号 else if (this.CHINESE_PUNCTUATION_REGEX.test(char)) { tokens.push({ type: TokenTypeEnum.CHINESE_PUNCTUATION, value: char, }) } // 识别英文标点符号 else if (this.ENGLISH_PUNCTUATION_REGEX.test(char)) { tokens.push({ type: TokenTypeEnum.ENGLISH_PUNCTUATION, value: char, }) } // 识别空白字符 else if (this.SPACE_CHAR_REGEX.test(char)) { tokens.push({ type: TokenTypeEnum.SPACE_CHAR, value: char, }) } // 未知字符类型 else { tokens.push({ type: TokenTypeEnum.UNKNOWN_CHAR, value: char, }) } currentIndex++ } return tokens } /** * 文本净化预处理 - 去除无效字符和格式。 * @param text 原始文本。 * @returns 净化后的文本。 */ private sanitizeText(text: string): string { return text .replace(/\s+/g, ' ') // 将连续空白字符转为单个空格 .replace(/(\d+)\s+/g, '$1') // 去除数字后的空白 .replace(/\*+/g, '') // 移除星号 .replace(/[\u{1F000}-\u{1FAFF}]/gu, '') // 移除表情符号 } /** * 段落输出之前的整理逻辑。 * @param paragraphs 段落数组。 * @returns 整理后的段落数组。 */ private sanitizeParagraphs(paragraphs: string[]): string[] { return paragraphs .map(paragraph => paragraph.trim()) .filter(paragraph => paragraph.length > 0) } /** * 判断标记是否为段落结束符号。 * @param token 文本标记。 * @returns 是否为段落结束符号。 */ private isParagraphEndToken(token: Token): boolean { return ( this.CHINESE_PARAGRAPH_END_REGEX.test(token.value) || this.ENGLISH_PARAGRAPH_END_REGEX.test(token.value) ) } /** * 将标记数组转换回文本。 * @param tokens 文本标记数组。 * @returns 组合后的文本。 */ private tokensToString(tokens: Token[]): string { return tokens.map(token => token.value).join('') } /** * 将标记流分割成多个段落。 * @param tokens 文本标记数组。 * @returns 分段后的标记数组集合。 */ private splitToParagraphs(tokens: Token[]): Token[][] { const paragraphs: Token[][] = [] let currentParagraph: Token[] = [] for (const token of tokens) { currentParagraph.push(token) // 满足最小长度且遇到段落结束符号时分割段落 if ( currentParagraph.length >= this.MIN_PARAGRAPH_LENGTH && this.isParagraphEndToken(token) ) { paragraphs.push(currentParagraph) currentParagraph = [] } } // 添加剩余的文本 if (currentParagraph.length > 0) { paragraphs.push(currentParagraph) } return paragraphs } /** * 合并因小数点导致的错误分段。 * @param paragraphs 分段后的标记数组集合。 * @returns 合并后的标记数组集合。 */ private mergeDecimalParagraphs(paragraphs: Token[][]): Token[][] { return paragraphs.reduce((mergedParagraphs: Token[][], currentParagraph: Token[]) => { if (mergedParagraphs.length === 0) { mergedParagraphs.push(currentParagraph) } else { const lastParagraph = mergedParagraphs[mergedParagraphs.length - 1] const lastParagraphText = this.tokensToString(lastParagraph) const currentParagraphText = this.tokensToString(currentParagraph) // 如果上一段以数字加小数点结尾,且当前段以数字开头,则合并两段 if (/\d+\.$/.test(lastParagraphText) && /^\d+/.test(currentParagraphText)) { mergedParagraphs[mergedParagraphs.length - 1] = [...lastParagraph, ...currentParagraph] } else { mergedParagraphs.push(currentParagraph) } } return mergedParagraphs }, []) } /** * 处理输入文本,返回分段后的文本数组。 * @param text 输入文本内容。 * @param includeRemaining 是否包含未完成的段落。 * @returns 分段后的文本数组。 */ public processText(text: string, includeRemaining: boolean = false): string[] { // 净化并标记化文本 const tokens = this.tokenizeText(this.sanitizeText(text)) // 合并历史遗留的未完成段落 const combinedTokens = [...this.pendingTokens, ...tokens] // 分段并处理小数点问题 const paragraphs = this.mergeDecimalParagraphs(this.splitToParagraphs(combinedTokens)) // 处理未完成的段落 if (paragraphs.length > 0) { const lastParagraph = paragraphs[paragraphs.length - 1] const lastToken = lastParagraph[lastParagraph.length - 1] if (includeRemaining) { // 返回所有内容,清空待处理标记 this.pendingTokens = [] } else { // 检查最后一段是否完整 if (this.isParagraphEndToken(lastToken)) { // 完整段落但以数字加小数点结尾,视为不完整 if (/\d+\.$/.test(this.tokensToString(lastParagraph))) { this.pendingTokens = paragraphs.pop()! } else { this.pendingTokens = [] } } else { // 不完整段落,保留待处理 this.pendingTokens = paragraphs.pop()! } } } // 返回分段后的文本 return this.sanitizeParagraphs(paragraphs.map(paragraph => this.tokensToString(paragraph))) } } export default TextStreamSplitter