Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/Concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ Global playback modes use a different timing model:

- Global `autoMode` starts its delay after the current line is completed.
- In practice, completion is driven by Route Graphics `renderComplete`, so text reveal and other tracked render work finish first.
- Its delay is the persisted `autoForwardDelay` minimum plus length-derived reading time adjusted by `autoForwardSpeed`. The speed setting ranges from `0` (half speed) through `50` (normal) to `100` (double speed). Reading time starts at 60ms per weighted grapheme in the current line's resolved dialogue text. Han, Hiragana, Katakana, and Hangul graphemes have a weight of 3, whitespace has a weight of 0.5, and other graphemes have a weight of 1. The result is capped at 20 seconds unless the configured minimum is already higher.
- Its delay is the persisted `autoForwardDelay` minimum plus length-derived reading time adjusted by `autoForwardSpeed`. The speed setting ranges from `0` (half speed) through `50` (normal) to `100` (double speed). Reading time starts at 60ms per weighted grapheme in the current line's resolved dialogue text, counted with the bundled Unicode 17.0 grapheme rules and script data on every host. Han, Hiragana, Katakana, and Hangul graphemes have a weight of 3, whitespace has a weight of 0.5, and other graphemes have a weight of 1. The result is capped at 20 seconds unless the configured minimum is already higher.
- ADV append and NVL timing only measure visible dialogue introduced by the current line; prior accumulated dialogue, character names, and content discarded by `dialogue.clear` are not counted.
- Global `skipMode` does not wait for completion; it advances aggressively on its own short timer.
- `nextLineConfig.auto` is the only built-in auto-like behavior that can intentionally start from line start via `trigger: "fromStart"`.
Expand Down
11 changes: 11 additions & 0 deletions docs/RouteEngine.md
Original file line number Diff line number Diff line change
Expand Up @@ -2001,6 +2001,17 @@ double-speed range and applies only to the length-derived reading time. The
persisted `runtime.autoForwardDelay` minimum is still added unchanged, subject
to the auto-mode delay cap.

The speed multiplier is `2 ** x`, where `x = (speed - 50) / 50` is evaluated in
ordinary double arithmetic and 2 raised to that double `x` is then rounded to
the nearest double (correctly rounded). It is the same on every JavaScript host;
the host's `Math.pow` differs in the last bit between engines and C libraries.

Reading units always use the bundled Unicode 17.0 grapheme segmenter and Unicode
17.0 Han, Hiragana, Katakana, and Hangul code point ranges, rather than the
host's `Intl.Segmenter` and regular-expression script data, whose Unicode
version depends on the browser or runtime. The text is not normalized first,
because `String.prototype.normalize` also uses the host's Unicode data.

### Dialogue Append Reveal

In ADV mode, `dialogue.append: true` appends the line content to the current
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"jempl": "1.1.2",
"js-yaml": "^4.1.0",
"puty": "^0.1.1",
"unicode-segmenter": "^0.17.0"
"unicode-segmenter": "0.17.0"
},
"devDependencies": {
"@vitest/coverage-v8": "4.0.16",
Expand Down
216 changes: 209 additions & 7 deletions spec/autoForwardTiming.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,123 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
AUTO_FORWARD_MAX_DELAY_MS,
estimateAutoForwardDelay,
getAutoForwardSpeedMultiplier,
getAutoForwardReadingUnits,
} from "../src/autoForwardTiming.js";

// 2 ** ((speed - 50) / 50) rounded to the nearest binary64, indexed by speed.
const INTEGER_SPEED_MULTIPLIERS = [
0.5, 0.5069797398950145, 0.5140569133280333, 0.5212328804205607,
0.5285090202806901, 0.5358867312681466, 0.543367431263029, 0.5509525579383053,
0.55864356903611, 0.5664419426478994, 0.5743491774985174, 0.5823667932342279,
0.5904963307147651, 0.5987393523094643, 0.6070974421975235,
0.6155722066724582, 0.6241652744508059, 0.63287829698514, 0.6417129487814521,
0.6506709277209668, 0.6597539553864471, 0.668963777393056, 0.6783021637238359,
0.6877709090698718, 0.6973718331752027, 0.7071067811865476,
0.7169776240079136, 0.7269862586601553, 0.7371346086455506,
0.7474246243174693, 0.757858283255199, 0.7684375906440062, 0.7791645796604999,
0.7900413118633771, 0.8010698775896221, 0.8122523963562356,
0.8235910172675731, 0.8350879194283694, 0.8467453123625271,
0.8585654364377537, 0.8705505632961241, 0.8827029962906549,
0.8950250709279725, 0.9075191553171609, 0.9201876506248751,
0.9330329915368074, 0.9460576467255959, 0.9592641193252643,
0.9726549474122855, 0.9862327044933592, 1, 1.013959479790029,
1.0281138266560665, 1.0424657608411214, 1.0570180405613803,
1.0717734625362931, 1.086734862526058, 1.1019051158766107, 1.11728713807222,
1.1328838852957985, 1.148698354997035, 1.1647335864684558, 1.1809926614295303,
1.1974787046189286, 1.214194884395047, 1.2311444133449163, 1.2483305489016119,
1.26575659397028, 1.2834258975629043, 1.3013418554419336, 1.3195079107728942,
1.337927554786112, 1.3566043274476718, 1.3755418181397439, 1.3947436663504054,
1.4142135623730951, 1.4339552480158273, 1.4539725173203106,
1.4742692172911012, 1.4948492486349383, 1.515716566510398, 1.5368751812880124,
1.5583291593209998, 1.5800826237267545, 1.6021397551792442, 1.624504792712471,
1.6471820345351462, 1.6701758388567387, 1.6934906247250543,
1.7171308728755075, 1.7411011265922482, 1.7654059925813097, 1.790050141855945,
1.8150383106343217, 1.8403753012497501, 1.8660659830736148,
1.8921152934511918, 1.9185282386505287, 1.945309894824571, 1.9724654089867184,
2,
];

// Fractional speeds where glibc 2.43 pow (which V8 calls on Linux) returns
// the neighbouring binary64 instead of the nearest one.
const FRACTIONAL_SPEED_MULTIPLIERS = [
[5.39, 0.5387918749305093],
[44.62, 0.9281307732922005],
[55.39, 1.0775837498610186],
[61.09, 1.1661876906400275],
[76.42, 1.4423287144604011],
[1.6218108357861638, 0.5113688606410209],
[11.278214515186846, 0.5846172445536842],
[15.85592566115398, 0.6229198767484339],
[23.260451527312398, 0.6902586802976548],
[32.64301389745219, 0.7861411522977813],
[41.55971207655966, 0.8895787565530576],
[48.62368886824697, 0.9811011421990845],
[56.852721643664715, 1.0996576386064774],
[77.03163006808609, 1.4546102034938393],
[79.53354422934353, 1.5059468826790383],
[89.62236507454348, 1.732010050185167],
[93.5848910594359, 1.829815033321451],
[96.70571486931294, 1.9107172431722372],
];

// Reading units under the bundled Unicode 17.0 grapheme rules and script
// ranges. Rows marked "Unicode 17" counted differently on Unicode 16 hosts,
// through Intl.Segmenter or \p{Script=...}. Text with joiners, variation
// selectors, tags, regional indicators or combining marks is escaped so every
// code point shows in review.
const READING_UNIT_CORPUS = [
["Latin", "Hello, world.", 12.5],
["combining marks", "cafe\u0301 A\u030Angstro\u0308m", 12.5],
["stacked combining marks", "Z\u0351\u036B\u0343\u036A\u0302a", 2],
["Han and kana", "漢字とカタカナ", 21],
["Hangul syllables", "한국어", 9],
["Hangul conjoining jamo", "\u1100\u1161\u11A8\u1112\u1161\u11AB", 6],
["CJK Extension J (Unicode 17)", "\u{323B0}\u{33479}", 6],
["Unicode whitespace", "\u3000\u00A0\t\u2028", 2],
[
"emoji ZWJ family",
"\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}",
1,
],
["emoji ZWJ with skin tone", "\u{1F9D1}\u{1F3FF}\u200D\u{1F9B1}", 1],
[
"regional indicator flags",
"\u{1F1EF}\u{1F1F5}\u{1F1F0}\u{1F1F7}\u{1F1FA}\u{1F1F3}\u{1F1F3}",
4,
],
[
"emoji tag sequence",
"\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}",
1,
],
["emoji keycaps", "1\uFE0F\u20E3#\uFE0F\u20E3", 2],
[
"Thai tone marks and sara am",
"\u0E20\u0E32\u0E29\u0E32\u0E44\u0E17\u0E22 \u0E19\u0E49\u0E33 \u0E01\u0E33\u0E25\u0E31\u0E07",
12,
],
[
"Devanagari conjuncts",
"\u0915\u094D\u0937\u0924\u094D\u0930\u093F\u092F \u0936\u094D\u0930\u0940",
4.5,
],
[
"Khmer conjuncts (Unicode 17)",
"\u1797\u17B6\u179F\u17B6\u1781\u17D2\u1798\u17C2\u179A",
4,
],
["Myanmar kinzi (Unicode 17)", "\u1004\u103A\u1039\u1011\u1039\u1011", 1],
["pictograph ZWJ pictograph (Unicode 17)", "\u2701\u200D\u2701", 2],
["CRLF", "a\r\nb", 2.5],
[
"mixed dialogue",
"Hi \u4E16\u754C e\u0301 \u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466} \u0E2A\u0E27\u0E31\u0E2A\u0E14\u0E35 \u{1F1EF}\u{1F1F5}!",
18.5,
],
];

describe("auto-forward timing", () => {
it("weights Latin text and whitespace", () => {
expect(getAutoForwardReadingUnits("abcd")).toBe(4);
Expand All @@ -28,24 +140,71 @@ describe("auto-forward timing", () => {

it("counts composed characters and emoji sequences as graphemes", () => {
expect(getAutoForwardReadingUnits("e\u0301")).toBe(1);
expect(getAutoForwardReadingUnits("👨‍👩‍👧‍👦")).toBe(1);
expect(
getAutoForwardReadingUnits(
"\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}",
),
).toBe(1);
});

it("keeps emoji sequences intact without Intl.Segmenter", () => {
it("counts composed and decomposed text alike without normalizing it", () => {
for (const [composed, decomposed, units] of [
["\u00E9", "e\u0301", 1],
["\uAC01", "\u1100\u1161\u11A8", 3],
["\u0929\u094D\u0915", "\u0928\u093C\u094D\u0915", 1],
["\u1EC7", "e\u0323\u0302", 1],
]) {
expect(getAutoForwardReadingUnits(composed)).toBe(units);
expect(getAutoForwardReadingUnits(decomposed)).toBe(units);
}
});

it.each(READING_UNIT_CORPUS)(
"counts %s with the bundled Unicode 17 grapheme rules",
(_label, text, units) => {
expect(getAutoForwardReadingUnits(text)).toBe(units);
},
);

it("ignores the host Intl.Segmenter and String.prototype.normalize", async () => {
const segmenterDescriptor = Object.getOwnPropertyDescriptor(
Intl,
"Segmenter",
);
const normalizeSpy = vi.spyOn(String.prototype, "normalize");
let hostSegmenterCount = 0;
// Stands in for a host with outdated grapheme data: one code point each.
class OutdatedHostSegmenter {
constructor() {
hostSegmenterCount += 1;
}

segment(text) {
return Array.from(text, (segment) => ({ segment }));
}
}
Object.defineProperty(Intl, "Segmenter", {
configurable: true,
value: undefined,
value: OutdatedHostSegmenter,
});

try {
expect(getAutoForwardReadingUnits("👨‍👩‍👧‍👦")).toBe(1);
expect(getAutoForwardReadingUnits("🇨🇳")).toBe(1);
vi.resetModules();
const timing = await import("../src/autoForwardTiming.js");
for (const [, text, units] of READING_UNIT_CORPUS) {
expect(timing.getAutoForwardReadingUnits(text)).toBe(units);
}
expect(hostSegmenterCount).toBe(0);
expect(normalizeSpy).not.toHaveBeenCalled();
} finally {
Object.defineProperty(Intl, "Segmenter", segmenterDescriptor);
normalizeSpy.mockRestore();
// Hosts without Intl.Segmenter have no descriptor to restore.
if (segmenterDescriptor) {
Object.defineProperty(Intl, "Segmenter", segmenterDescriptor);
} else {
delete Intl.Segmenter;
}
vi.resetModules();
}
});

Expand All @@ -64,6 +223,49 @@ describe("auto-forward timing", () => {
expect(getAutoForwardSpeedMultiplier(100)).toBe(2);
});

it("returns the correctly rounded multiplier for every integer speed", () => {
expect(
Array.from({ length: 101 }, (_, speed) =>
getAutoForwardSpeedMultiplier(speed),
),
).toEqual(INTEGER_SPEED_MULTIPLIERS);
// JavaScriptCore and fdlibm-based pow implementations return
// 0.7071067811865475 here.
expect(getAutoForwardSpeedMultiplier(25)).toBe(Math.SQRT1_2);
});

it.each(FRACTIONAL_SPEED_MULTIPLIERS)(
"returns the correctly rounded multiplier for speed %s",
(speed, multiplier) => {
expect(getAutoForwardSpeedMultiplier(speed)).toBe(multiplier);
},
);

it("keeps fractional speeds monotonic between half and double speed", () => {
let previous = getAutoForwardSpeedMultiplier(0);
for (let step = 1; step <= 10_000; step += 1) {
const multiplier = getAutoForwardSpeedMultiplier(step / 100);
expect(multiplier).toBeGreaterThanOrEqual(previous);
previous = multiplier;
}
expect(previous).toBe(2);
});

it("derives the reading delay from the exact multiplier", () => {
const text =
"Hi \u4E16\u754C e\u0301 \u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}";
expect(getAutoForwardReadingUnits(text)).toBe(11.5);
expect(estimateAutoForwardDelay({ text, baseDelay: 0, speed: 25 })).toBe(
975.8073580374355,
);
expect(estimateAutoForwardDelay({ text, baseDelay: 0, speed: 55.39 })).toBe(
640.3214600155142,
);
expect(
estimateAutoForwardDelay({ text, baseDelay: 0, speed: 93.5848910594359 }),
).toBe(377.08729430838866);
});

it("scales only length-derived reading time with the speed setting", () => {
expect(
estimateAutoForwardDelay({
Expand Down
Loading
Loading