From f04c5d8f8cab88f1d146e5be42e5a4e7ba6718c0 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Wed, 23 Sep 2026 09:11:51 +0000 Subject: [PATCH] fix(audio): stop the Script Director from splitting off punctuation-only cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5 --- web/modules/audio_script.js | 61 +++++++++++++++++++++++++++---- web/modules/locales.js | 4 ++ web/modules/ui_script_director.js | 16 +++++++- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/web/modules/audio_script.js b/web/modules/audio_script.js index 9c0acc2..6cb8621 100644 --- a/web/modules/audio_script.js +++ b/web/modules/audio_script.js @@ -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. */ diff --git a/web/modules/locales.js b/web/modules/locales.js index e84b05b..d784444 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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.", diff --git a/web/modules/ui_script_director.js b/web/modules/ui_script_director.js index f0c861b..3088919 100644 --- a/web/modules/ui_script_director.js +++ b/web/modules/ui_script_director.js @@ -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.