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:
co-authored by
Claude Opus 5.5
parent
a8e81beeb0
commit
f04c5d8f8c
@@ -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. */
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user