fix(pipelines-audio): resolve narrative stripping edge cases and add comprehensive tests (#1708)

This commit is contained in:
0xSelenicDove
2026-04-25 04:06:30 +08:00
committed by GitHub
parent 4b133b169f
commit 3545c71b3a
2 changed files with 234 additions and 31 deletions
@@ -0,0 +1,92 @@
// packages/pipelines-audio/src/processors/tts-chunker.test.ts
import { describe, expect, it } from 'vitest'
import { isProbablyAngleTag, processNarrative } from './tts-chunker'
describe('tTS Chunker Logic Cleanup', () => {
describe('isProbablyAngleTag Heuristics', () => {
it('should identify narrative tags', () => {
expect(isProbablyAngleTag(0, '<sigh>')).toBe(true)
})
it('should skip code patterns like generics', () => {
expect(isProbablyAngleTag(4, 'List<String>')).toBe(false)
expect(isProbablyAngleTag(1, 'x<y')).toBe(false)
})
})
describe('processNarrative Function', () => {
const options = { stripNarrative: true }
it('should strip standard bracketed narrative', () => {
expect(processNarrative('Hello [sighs] world', options)).toBe('Hello world')
expect(processNarrative('<<tag>>', options)).toBe('')
})
it('should restore stripping for CJK brackets', () => {
expect(processNarrative('你好(叹气)世界', options)).toBe('你好世界')
expect(processNarrative('【动作】你好', options)).toBe('你好')
})
it('should fix asterisk bullet leakage', () => {
expect(processNarrative('* item 1', options)).toBe('* item 1')
expect(processNarrative('*bold text*', options)).toBe('')
expect(processNarrative('a*b', options)).toBe('a*b')
})
it('should handle complex nesting correctly', () => {
expect(processNarrative('Normal (nested [action]) text', options)).toBe('Normal text')
})
it('should handle open bracket correctly', () => {
expect(processNarrative('Version (beta', options)).toBe('Version (beta')
})
it('should handle valid narrative tag', () => {
expect(processNarrative('Hello,<laugh>', options)).toBe('Hello,')
expect(processNarrative('Hello<laugh>', options)).toBe('Hello')
expect(processNarrative('<laughs>Hello', options)).toBe('Hello')
expect(processNarrative('Hello<laughs>', options)).toBe('Hello')
expect(processNarrative('你好<laughs>', options)).toBe('你好')
expect(processNarrative('List<T>', options)).toBe('List<T>')
})
it('should preserve code literals in keepNarrativeText mode', () => {
const keepOptions = { stripNarrative: true, keepNarrativeText: true }
expect(processNarrative('Value is List<String> [action]', keepOptions)).toContain('List<String>')
expect(processNarrative('x < y (sigh)', keepOptions)).toContain('x < y')
expect(processNarrative('price<limit', keepOptions)).toContain('price<limit')
})
it('should be case-insensitive for narrative tags', () => {
const options = { stripNarrative: true }
expect(processNarrative('Hello<LAUGHs>', options)).toBe('Hello')
expect(processNarrative('abc<Action>', options)).toBe('abc')
expect(processNarrative('List<String>', options)).toBe('List<String>')
})
})
describe('isProbablyAngleTag Stream Prefix Handling', () => {
it('should identify partial prefixes of narrative keywords', () => {
expect(isProbablyAngleTag(5, 'hello<sm')).toBe(true)
expect(isProbablyAngleTag(5, 'hello<la')).toBe(true) // laugh 的前缀
})
it('should not identify non-narrative prefixes as tags', () => {
expect(isProbablyAngleTag(4, 'List<Str')).toBe(false)
})
})
describe('edge Cases test', () => {
it('should not treat single-letter operands as narrative prefixes', () => {
expect(isProbablyAngleTag(1, 'a<b')).toBe(false)
expect(isProbablyAngleTag(1, 'x<s')).toBe(false)
})
it('should support non-CJK Unicode letters as tag context', () => {
expect(isProbablyAngleTag(4, 'café<laugh>')).toBe(true)
expect(isProbablyAngleTag(6, 'привет<sigh>')).toBe(true)
})
})
})
@@ -241,21 +241,147 @@ export async function chunkEmitter(
}
}
export function processNarrative(text: string, options?: TtsInputChunkOptions) {
const BRACKET_MAP: Record<string, string> = {
'[': ']',
'(': ')',
'': '',
'【': '】',
'<': '>',
}
const OPENERS = Object.keys(BRACKET_MAP)
const CLOSERS = Object.values(BRACKET_MAP)
const isUnicodeLetter = (char: string) => /\p{L}/u.test(char)
const NARRATIVE_KEYWORDS = [
'laugh',
'sigh',
'action',
'note',
'breath',
'giggle',
'whisper',
'cry',
'smile',
'thought',
]
export function isProbablyAngleTag(index: number, text: string): boolean {
if (text[index] !== '<')
return false
if (text[index + 1] === '/')
return true
const remainder = text.slice(index + 1).toLowerCase()
const nextChar = remainder[0]
const prevChar = index > 0 ? text[index - 1] : ''
// 1. 闭合标签 </... 永远判定为标签
if (nextChar === '/')
return true
// Lookahead: if followed by num, space or equals, not a label
if (nextChar && /[0-9\s=]/.test(nextChar))
return false
if (prevChar && (isUnicodeLetter(prevChar) || /\d/.test(prevChar))) {
// fix: check whether remainder is piefix with any keywords, or contains the whole keyword
const isLikelyNarrative = NARRATIVE_KEYWORDS.some(kw =>
(remainder.length > 1 && kw.startsWith(remainder)) || remainder.startsWith(kw),
)
return isLikelyNarrative
}
// Lookbehind: if before is non-empty/non-bracket character, then determine as code or any instead of a label
if (prevChar && /[^\s([{(【<\])}>)】.,!?;:,。!?;:'"\-_]/.test(prevChar))
return false
return true
}
export function processNarrative(text: string, options?: TtsInputChunkOptions): string {
if (!options?.stripNarrative)
return text
const regex = /\*(.*?)\*|\[(.*?)\]|\((.*?)\)|(.*?)|【(.*?)】|<([^>0-9\s][^>]*)>/g
const rangesToRemove: [number, number][] = []
const charsToRemove = new Set<number>()
return text.replace(regex, (match, g1, g2, g3, g4, g5, g6) => {
if (options?.keepNarrativeText) {
const innerWord = g1 || g2 || g3 || g4 || g5 || g6 || ''
return innerWord
const stack: { char: string, index: number }[] = []
let starOpenIndex = -1
for (let i = 0; i < text.length; i++) {
const char = text[i]
if (char === '*') {
if (starOpenIndex !== -1) {
if (options?.keepNarrativeText) {
charsToRemove.add(starOpenIndex)
charsToRemove.add(i)
}
else {
rangesToRemove.push([starOpenIndex, i])
}
starOpenIndex = -1
}
else {
if (!/\s/.test(text[i + 1] || '')) {
starOpenIndex = i
}
}
continue
}
return ''
})
if (OPENERS.includes(char)) {
if (char === '<' && !isProbablyAngleTag(i, text))
continue
stack.push({ char, index: i })
continue
}
if (CLOSERS.includes(char)) {
const last = stack[stack.length - 1]
if (last && BRACKET_MAP[last.char] === char) {
stack.pop()
if (options?.keepNarrativeText) {
charsToRemove.add(last.index)
charsToRemove.add(i)
}
else {
rangesToRemove.push([last.index, i])
}
}
}
}
let result = ''
for (let i = 0; i < text.length; i++) {
if (options?.keepNarrativeText) {
if (!charsToRemove.has(i)) {
result += text[i]
}
}
else {
let inRange = false
for (const [start, end] of rangesToRemove) {
if (i >= start && i <= end) {
inRange = true
break
}
}
if (!inRange) {
result += text[i]
}
}
}
return result
}
// ------------------------------------------------------------------
// Data flow processor
// ------------------------------------------------------------------
export function createTtsSegmentStream(
tokens: ReadableStream<TextToken>,
meta: { streamId: string, intentId: string },
@@ -287,47 +413,32 @@ export function createTtsSegmentStream(
pendingText += value.value
const stack: string[] = []
const pairs: Record<string, string> = {
'[': ']',
'(': ')',
'': '',
'【': '】',
'<': '>',
}
const openers = Object.keys(pairs)
const closers = Object.values(pairs)
for (let i = 0; i < pendingText.length; i++) {
const char = pendingText[i]
if (openers.includes(char)) {
// 尖括号启发式过滤:如果是 <3 或 1 < 2,不入栈
if (char === '<') {
const nextChar = pendingText[i + 1]
if (nextChar && /[0-9\s]/.test(nextChar))
continue
if (OPENERS.includes(char)) {
if (char === '<' && !isProbablyAngleTag(i, pendingText)) {
continue
}
stack.push(char)
}
else if (closers.includes(char)) {
// 尝试匹配并弹出栈顶
else if (CLOSERS.includes(char)) {
const lastOpen = stack[stack.length - 1]
if (pairs[lastOpen] === char) {
if (lastOpen && BRACKET_MAP[lastOpen] === char) {
stack.pop()
}
}
}
// 括号是否未闭合:看栈里是否还有剩
const bracketsUnclosed = stack.length > 0
// 星号奇偶校验(保留你之前写好的启发式逻辑)
const starMatch = pendingText.match(/\*([^*]*)$/)
const starsUnclosed = (pendingText.match(/\*/g) || []).length % 2 !== 0
&& starMatch !== null && !starMatch[1].startsWith(' ')
const hasUnclosed = bracketsUnclosed || starsUnclosed
const hasNarrativeUnclosed = stack.some(char => ['[', '【', '<', ''].includes(char))
const fallbackLimit = (options?.stripNarrative && hasNarrativeUnclosed) ? 800 : 200
if (!hasUnclosed || pendingText.length > 200) {
if (!hasUnclosed || pendingText.length > fallbackLimit) {
const textToEmit = processNarrative(pendingText, options)
writeBytes(encoder.encode(textToEmit))
pendingText = ''