112 lines
3.1 KiB
TypeScript
112 lines
3.1 KiB
TypeScript
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;
|
||
|
||
export type AgentSpeechState = "idle" | "loading" | "playing" | "paused";
|
||
|
||
export type AgentSpeakOptions = {
|
||
startOffset?: number;
|
||
};
|
||
|
||
const normalizeWithOffsetMap = (value: string) => {
|
||
let normalized = "";
|
||
const offsetMap: number[] = [];
|
||
let previousWasWhitespace = false;
|
||
|
||
Array.from(value).forEach((character, index) => {
|
||
if (/\s/u.test(character)) {
|
||
if (!previousWasWhitespace && normalized.length > 0) {
|
||
normalized += " ";
|
||
offsetMap.push(index);
|
||
}
|
||
previousWasWhitespace = true;
|
||
return;
|
||
}
|
||
|
||
normalized += character;
|
||
offsetMap.push(index);
|
||
previousWasWhitespace = false;
|
||
});
|
||
|
||
return { normalized: normalized.trimEnd(), offsetMap };
|
||
};
|
||
|
||
export function stripSpeechMarkdown(markdown: string) {
|
||
return markdown
|
||
.replace(/```[\s\S]*?```/g, "")
|
||
.replace(/`([^`]+)`/g, "$1")
|
||
.replace(/!\[.*?\]\(.*?\)/g, "")
|
||
.replace(/\[([^\]]+)\]\(.*?\)/g, "$1")
|
||
.replace(/#{1,6}\s+/g, "")
|
||
.replace(/\*{1,3}(.+?)\*{1,3}/g, "$1")
|
||
.replace(/~~(.+?)~~/g, "$1")
|
||
.replace(/^>\s+/gm, "")
|
||
.replace(/^[-*+]\s+/gm, "")
|
||
.replace(/^\d+\.\s+/gm, "")
|
||
.replace(/<[^>]+>/g, "")
|
||
.replace(/\n{2,}/g, "\n")
|
||
.trim();
|
||
}
|
||
|
||
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 = "";
|
||
};
|
||
|
||
for (const segment of sourceSegments) {
|
||
if (segment.length > MAX_SPEECH_CHUNK_LENGTH) {
|
||
flush();
|
||
for (let offset = 0; offset < segment.length; offset += MAX_SPEECH_CHUNK_LENGTH) {
|
||
chunks.push(segment.slice(offset, offset + MAX_SPEECH_CHUNK_LENGTH));
|
||
}
|
||
continue;
|
||
}
|
||
|
||
const candidate = currentChunk ? `${currentChunk}${segment}` : segment;
|
||
if (
|
||
currentChunk &&
|
||
candidate.length > MAX_SPEECH_CHUNK_LENGTH &&
|
||
currentChunk.length >= MIN_SPEECH_CHUNK_LENGTH
|
||
) {
|
||
flush();
|
||
currentChunk = segment;
|
||
continue;
|
||
}
|
||
|
||
currentChunk = candidate;
|
||
}
|
||
|
||
flush();
|
||
return chunks;
|
||
}
|