feat(chat): add Edge TTS playback
Build Push and Deploy / docker-image (push) Failing after 1m17s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s

This commit is contained in:
2026-07-08 18:39:49 +08:00
parent 0dea655f68
commit cf6386d209
10 changed files with 609 additions and 489 deletions
+98
View File
@@ -0,0 +1,98 @@
const compactWhitespace = (value: string) => value.replace(/\s+/g, " ").trim();
const MAX_SPEECH_CHUNK_LENGTH = 520;
const MIN_SPEECH_CHUNK_LENGTH = 180;
const SPEECH_SENTENCE_PATTERN = /[^。!?!?;;\n]+(?:[。!?!?;;]+|(?=\n|$))/g;
const normalizeWithOffsetMap = (value: string) => {
let normalized = "";
const offsetMap: number[] = [];
let isPreviousWhitespace = false;
Array.from(value).forEach((char, index) => {
if (/\s/u.test(char)) {
if (!isPreviousWhitespace && normalized.length > 0) {
normalized += " ";
offsetMap.push(index);
}
isPreviousWhitespace = true;
return;
}
normalized += char;
offsetMap.push(index);
isPreviousWhitespace = false;
});
return {
normalized: normalized.trimEnd(),
offsetMap,
};
};
export function findSpeechSelectionStartOffset(
text: string,
selectedText: string,
): number | null {
const needle = selectedText.trim();
if (!needle) return null;
const exactIndex = text.indexOf(needle);
if (exactIndex >= 0) return exactIndex;
const normalizedNeedle = compactWhitespace(needle);
if (!normalizedNeedle) return null;
const haystack = normalizeWithOffsetMap(text);
const normalizedIndex = haystack.normalized.indexOf(normalizedNeedle);
if (normalizedIndex < 0) return null;
return haystack.offsetMap[normalizedIndex] ?? null;
}
export function splitSpeechTextIntoChunks(text: string): string[] {
const normalizedText = text.trim();
if (!normalizedText) return [];
const segments = Array.from(normalizedText.matchAll(SPEECH_SENTENCE_PATTERN), (match) =>
compactWhitespace(match[0]),
).filter(Boolean);
const sourceSegments = segments.length > 0 ? segments : [normalizedText];
const chunks: string[] = [];
let currentChunk = "";
const flush = () => {
if (!currentChunk) return;
chunks.push(currentChunk);
currentChunk = "";
};
const pushLongSegment = (segment: string) => {
for (let offset = 0; offset < segment.length; offset += MAX_SPEECH_CHUNK_LENGTH) {
chunks.push(segment.slice(offset, offset + MAX_SPEECH_CHUNK_LENGTH));
}
};
sourceSegments.forEach((segment) => {
if (segment.length > MAX_SPEECH_CHUNK_LENGTH) {
flush();
pushLongSegment(segment);
return;
}
const candidate = currentChunk ? `${currentChunk}${segment}` : segment;
if (
currentChunk &&
candidate.length > MAX_SPEECH_CHUNK_LENGTH &&
currentChunk.length >= MIN_SPEECH_CHUNK_LENGTH
) {
flush();
currentChunk = segment;
return;
}
currentChunk = candidate;
});
flush();
return chunks;
}