Files
DemonGatanjieuandClaude Opus 5.5 2a7cfe5075 Voice-over: three layers, whole script / character / line, and a draft
The side panel changes one layer at a time under a path (Whole script ›
character › line); each layer has the same rows (speed, tone, language),
each saying where its value comes from, with ↺ to follow the layer above.
The more particular wins: line, character, whole script, the character's
usual settings. The whole script's values go into the node's inputs and
only lines that differ get tags. Lasting settings are links at the foot.
The script is kept as a draft in this browser and comes back after a
reload; editing the text keeps unchanged lines' own settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 04:21:00 +08:00

83 lines
5.2 KiB
JavaScript

// How each voice-over line is read: its own values over its character's, and the tags for them
// (web/modules/script_tune.js), written into the script and read back (audio_script.js).
import assert from 'node:assert/strict';
import { buildScriptPackage, parseTaggedSpeech } from '../web/modules/audio_script.js';
import {
characterLayer, clearLineTune, hasOwnTune, lineLayer, lineValues, readTuneTag, scriptLayer, setLineTune, sharedValue, tuneTags,
} from '../web/modules/script_tune.js';
const character = { language: 'auto', speed: 1, preset: '' };
const stored = { language: 'auto', speed: 1, preset: '' };
// A line follows its character until it sets something itself; setting undefined follows again.
const line = { text: 'a' };
assert.deepEqual(lineValues(line, character), character);
setLineTune([line], 'speed', 1.2);
setLineTune([line], 'preset', 'lively');
assert.deepEqual(lineValues(line, character), { language: 'auto', speed: 1.2, preset: 'lively' });
assert.equal(hasOwnTune(line), true);
setLineTune([line], 'speed', undefined);
assert.deepEqual(line.tune, { preset: 'lively' });
setLineTune([line], 'preset', undefined);
assert.equal('tune' in line, false);
setLineTune([line], 'language', 'ja');
clearLineTune([line]);
assert.equal(hasOwnTune(line), false);
// Tags: what differs from what the node gives by itself; back to auto is written as auto.
assert.equal(tuneTags(character, stored), '');
assert.equal(tuneTags({ language: 'ja', speed: 1.15000001, preset: 'stable' }, stored), '[lang:ja][speed:1.15][preset:stable]');
assert.equal(tuneTags({ language: 'auto', speed: 1.1, preset: '' }, { speed: 1.1 }), '');
assert.equal(tuneTags({ language: 'auto', speed: 1, preset: '' }, { language: 'zh', speed: 1, preset: 'lively' }), '[lang:auto][preset:auto]');
// Setting a line to what its character reads it with lets it follow again.
const same = { text: 'x' };
setLineTune([same], 'speed', 1.3, () => 1.3);
assert.equal(hasOwnTune(same), false);
// Three layers, the most particular first, and where each value comes from.
const base = { language: 'auto', speed: 1, preset: '' };
assert.deepEqual(scriptLayer({ speed: 0.9 }, base), { values: { language: 'auto', speed: 0.9, preset: '' }, from: { language: 'default', speed: 'script', preset: 'default' } });
const arisLayer = characterLayer({ preset: 'lively' }, { speed: 0.9, preset: 'stable' }, { language: 'ja', speed: 1.1, preset: '' }, base);
assert.deepEqual(arisLayer.values, { language: 'ja', speed: 0.9, preset: 'lively' });
assert.deepEqual(arisLayer.from, { language: 'usual', speed: 'script', preset: 'character' });
const own = lineLayer({ text: 'a', tune: { speed: 1.2 } }, arisLayer);
assert.deepEqual([own.values.speed, own.from.speed, own.from.language], [1.2, 'line', 'usual']);
// One value over several lines: mixed, all set by the lines, or some.
const lines = [{ text: 'a', tune: { speed: 1.2 } }, { text: 'b' }];
assert.deepEqual(sharedValue(lines, 'speed', l => lineValues(l, character)), { value: 1.2, mixed: true, own: false, some: true });
assert.deepEqual(sharedValue(lines, 'language', l => lineValues(l, character)), { value: 'auto', mixed: false, own: false, some: false });
// The tag reader: settings only, a name stays a name.
assert.deepEqual(readTuneTag('speed:1.2'), { field: 'speed', value: 1.2 });
assert.deepEqual(readTuneTag('语言:中文'), { field: 'language', value: 'zh' });
assert.deepEqual(readTuneTag('lang:fr'), { field: 'language', value: undefined });
assert.deepEqual(readTuneTag('lang:JA'), { field: 'language', value: 'ja' });
assert.deepEqual(readTuneTag('预设:custom_a1'), { field: 'preset', value: 'custom_a1' });
assert.deepEqual(readTuneTag('preset:auto'), { field: 'preset', value: '' });
assert.deepEqual(readTuneTag('lang:auto'), { field: 'language', value: 'auto' });
assert.equal(readTuneTag('普拉娜'), null);
// Written into a bundle (before [take:N], after the speaker and emotion) and read back for "voice it again".
const slice = emotion => ({ emotion });
const arona = { group: 'gpt_sovits:Arona', node_value: 'Arona', has_main: true, slices: [slice('main'), slice('happy')] };
const aris = { group: 'gpt_sovits:Aris', node_value: 'Aris', has_main: true, slices: [slice('main')] };
const script = [
{ text: '早上好', emotion: 'happy', take: 2, tune: { speed: 0.9 } },
{ text: '你好', emotion: 'main', speaker: 'gpt_sovits:Aris', tune: { language: 'ja', preset: 'lively' } },
{ text: '再见', emotion: 'main', speaker: 'gpt_sovits:Aris' },
];
const tagsOf = (l, speaker) => tuneTags(lineValues(l, character), speaker === aris ? { speed: 1 } : stored);
const pkg = buildScriptPackage(script, arona, { takes: true, groups: [arona, aris], tagsOf });
assert.equal(pkg.speech, '{happy} [speed:0.9][take:2]早上好\n[Aris]{main} [lang:ja][preset:lively]你好\n{main} 再见');
assert.deepEqual(parseTaggedSpeech(pkg.speech), [
{ emotion: 'happy', text: '早上好', tune: { speed: 0.9 } },
{ emotion: 'main', text: '你好', speaker: 'Aris', tune: { language: 'ja', preset: 'lively' } },
{ emotion: 'main', text: '再见', speaker: 'Aris' },
]);
// Without tagsOf nothing changes.
assert.equal(buildScriptPackage(script, arona, { groups: [arona, aris] }).speech, '{happy} 早上好\n[Aris]{main} 你好\n{main} 再见');
console.log('script_tune ok');