fix(audio): stop the Script Director from splitting off punctuation-only cards

Sentence splitting cut after every '…', so "嗯……" produced a card holding only
"…"; stacked marks ("?!") and closing quotes ("!」") were split off the same way.

- split only at 。!?!? and a lone '.' before whitespace; ellipses never split
- stacked end marks and trailing quotes/brackets stay with their sentence
- punctuation-only pieces (either split mode) are attached to a neighbour
- new ✂ button splits a card at the text cursor, complementing merge

Verified: tests/audio_script_rules.mjs (ellipsis, quotes, stacked marks,
punctuation-only lines, caret split) and a jsdom walkthrough of split/merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
This commit is contained in:
DemonGatanjieu
2026-09-23 09:11:51 +00:00
co-authored by Claude Opus 5.5
parent a8e81beeb0
commit f04c5d8f8c
3 changed files with 73 additions and 8 deletions
+54 -7
View File
@@ -19,16 +19,63 @@ export function comboValueForPath(widget, relativePath) {
return values.find(value => normalisePath(value) === wanted) ?? null;
}
const SENTENCE_END = new Set(['。', '!', '?', '!', '?']);
// Closing quotes/brackets and trailing marks that belong to the sentence they follow.
const TRAILING = new Set(['。', '!', '?', '!', '?', '」', '』', '”', '’', '"', "'", ')', ')', '】', '》', '〉', '~', '~']);
const SPEAKABLE = /[\p{L}\p{N}]/u;
function splitSentences(line) {
const chars = [...line];
const segments = [];
let current = '';
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
current += char;
// A single '.' ends an English sentence only before whitespace; '3.5', '...' and '……' do not split.
const isPeriod = char === '.' && chars[i - 1] !== '.' && chars[i + 1] !== '.' && (i + 1 >= chars.length || /\s/.test(chars[i + 1]));
if (!SENTENCE_END.has(char) && !isPeriod) continue;
while (i + 1 < chars.length && TRAILING.has(chars[i + 1])) current += chars[++i];
segments.push(current);
current = '';
}
segments.push(current);
return segments;
}
/** Punctuation-only pieces ("……", "!」") cannot be spoken on their own: attach them to a neighbour. */
function attachUnspeakable(segments) {
const result = [];
let pending = '';
for (const segment of segments) {
if (SPEAKABLE.test(segment)) {
result.push(pending + segment);
pending = '';
} else if (result.length) {
result[result.length - 1] += segment;
} else {
pending += segment;
}
}
if (pending) result.push(pending);
return result;
}
/**
* Split a script into segments. `sentence`: sentence punctuation and newlines
* ('.' only before whitespace, so 3.5 stays whole). `line`: one segment per line.
* Split a script into segments. `sentence`: at 。!?!? (with trailing quotes or
* brackets) and newlines; ellipses never split. `line`: one segment per line.
*/
export function splitScriptLines(text, mode = 'sentence') {
const pattern = mode === 'line' ? /\n+/ : /\n+|(?<=[。!?!?…])|(?<=\.)(?=\s)/;
return String(text || '')
.split(pattern)
.map(line => line.trim())
.filter(Boolean);
const lines = String(text || '').split(/\n+/).map(line => line.trim()).filter(Boolean);
const segments = mode === 'line' ? lines : lines.flatMap(splitSentences);
return attachUnspeakable(segments.map(segment => segment.trim()).filter(Boolean));
}
/** Split one segment at a caret position; null when either side would be empty. */
export function splitSegmentAt(text, position) {
const value = String(text || '');
const before = value.slice(0, position).trim();
const after = value.slice(position).trim();
return before && after ? [before, after] : null;
}
/** Join two segments: a space only between Latin words/digits, none for CJK text. */
+4
View File
@@ -1344,6 +1344,8 @@ export const i18n = {
scriptDirectorSplitLine: "按换行分段(每行一张卡片)",
scriptDirectorMergeNext: "与下一段合并(保留这张卡片的情绪)",
scriptDirectorAddLine: "+ 添加一段",
scriptDirectorSplitAtCaret: "在光标处拆成两段",
scriptDirectorSplitHint: "先在这段文字里点一下要断开的位置,再点 ✂",
scriptDirectorEmpty: "还没有台词,请先粘贴剧本并解析分句",
audioTagUnusable: "这个音色暂时不能用于多音色标签:需要同目录下的主音色(角色.wav)和各自的 .txt 台词,且格式与主音色一致",
audioMainMissing: "缺少主音色 {file}。F5-TTS 的情绪变体必须和主音色放在一起才能用 {标签} 调用。",
@@ -2697,6 +2699,8 @@ export const i18n = {
scriptDirectorSplitLine: "Split by line (one card per line)",
scriptDirectorMergeNext: "Merge with the next segment (keeps this card's emotion)",
scriptDirectorAddLine: "+ Add segment",
scriptDirectorSplitAtCaret: "Split into two at the cursor",
scriptDirectorSplitHint: "Click where the text should break, then press ✂",
scriptDirectorEmpty: "No lines yet. Paste a script and parse it first.",
audioTagUnusable: "This voice cannot be used as a multi-voice tag yet: it needs the main voice (Character.wav) in the same folder, .txt transcripts for both, and the same format as the main voice",
audioMainMissing: "Main voice {file} is missing. F5-TTS only finds emotion variants next to their main voice.",
+15 -1
View File
@@ -1,7 +1,7 @@
import { app } from '../../../scripts/app.js';
import { t } from './interface_settings.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { buildScriptPackage, comboValueForPath, joinSegments, splitScriptLines, usableEmotions } from './audio_script.js';
import { buildScriptPackage, comboValueForPath, joinSegments, splitScriptLines, splitSegmentAt, usableEmotions } from './audio_script.js';
import { bindMaterialDrag } from './material_drag.js';
/**
@@ -250,6 +250,20 @@ function renderLineCard(line, index, emotions, slices) {
head.append(
el('span', 'anomalous-sd-line-no', `#${index + 1}`),
previewBtn,
// Split at the text caret (the textarea keeps its caret after losing focus to this button).
button('anomalous-sd-icon-btn', '✂', async () => {
const parts = splitSegmentAt(line.text, text.selectionStart);
if (!parts) {
await anomalousAlert(t('scriptDirectorSplitHint'));
text.focus();
return;
}
line.text = parts[0];
state.lines.splice(index + 1, 0, { text: parts[1], emotion: line.emotion });
state.focusIndex = index + 1;
stopScriptDirectorPreview();
renderAll();
}, t('scriptDirectorSplitAtCaret')),
);
if (index < state.lines.length - 1) {
// Merge keeps this card's emotion: one card = one segment spoken with one voice.