fix(pipelines-audio): resolve narrative stripping edge cases and add comprehensive tests (#1708)
This commit is contained in:
@@ -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 = ''
|
||||
|
||||
Reference in New Issue
Block a user