@bendyline/squisq 1.5.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/README.md +63 -10
  2. package/dist/{Doc-BBVGq1_9.d.ts → Doc-ylqWKDc_.d.ts} +248 -37
  3. package/dist/{ImageEditDoc-FuyGtt6o.d.ts → ImageEditDoc-D828IXJ1.d.ts} +1 -1
  4. package/dist/annotationCoercion-Dr_DY9Hx.d.ts +86 -0
  5. package/dist/{chunk-T5UK6YOB.js → chunk-2PWOO4ZE.js} +60 -17
  6. package/dist/chunk-2PWOO4ZE.js.map +1 -0
  7. package/dist/{chunk-VWUFZ6ZG.js → chunk-3E5F2XMR.js} +2 -29
  8. package/dist/chunk-3E5F2XMR.js.map +1 -0
  9. package/dist/chunk-4X3JQXNM.js +35 -0
  10. package/dist/chunk-4X3JQXNM.js.map +1 -0
  11. package/dist/{chunk-P3K7NLHF.js → chunk-5DKBFXPY.js} +31 -121
  12. package/dist/chunk-5DKBFXPY.js.map +1 -0
  13. package/dist/{chunk-DLXZMS5K.js → chunk-5J4HKRJF.js} +51 -3
  14. package/dist/chunk-5J4HKRJF.js.map +1 -0
  15. package/dist/chunk-5UYI3RJB.js +2503 -0
  16. package/dist/chunk-5UYI3RJB.js.map +1 -0
  17. package/dist/{chunk-BEQODJWV.js → chunk-66QO7XFB.js} +11 -291
  18. package/dist/chunk-66QO7XFB.js.map +1 -0
  19. package/dist/{chunk-HWVFJAAH.js → chunk-AMIR72JP.js} +28 -10
  20. package/dist/chunk-AMIR72JP.js.map +1 -0
  21. package/dist/chunk-CY2R2VPR.js +884 -0
  22. package/dist/chunk-CY2R2VPR.js.map +1 -0
  23. package/dist/chunk-FVJUUTEM.js +74 -0
  24. package/dist/chunk-FVJUUTEM.js.map +1 -0
  25. package/dist/{chunk-Q3ROPT5H.js → chunk-GZY66SDN.js} +1 -1
  26. package/dist/{chunk-Q3ROPT5H.js.map → chunk-GZY66SDN.js.map} +1 -1
  27. package/dist/chunk-JOQYPRDZ.js +127 -0
  28. package/dist/chunk-JOQYPRDZ.js.map +1 -0
  29. package/dist/{chunk-A4O7GIWE.js → chunk-MMPBOUK7.js} +10 -73
  30. package/dist/chunk-MMPBOUK7.js.map +1 -0
  31. package/dist/{chunk-LDQ2HJIX.js → chunk-PXLVXTLW.js} +252 -54
  32. package/dist/chunk-PXLVXTLW.js.map +1 -0
  33. package/dist/{chunk-TQWLI6S2.js → chunk-Q7LROV2H.js} +4 -2
  34. package/dist/{chunk-TQWLI6S2.js.map → chunk-Q7LROV2H.js.map} +1 -1
  35. package/dist/{chunk-GPLTCSXO.js → chunk-R2GC6RLD.js} +7 -7
  36. package/dist/chunk-R2GC6RLD.js.map +1 -0
  37. package/dist/chunk-R2YZTCLO.js +292 -0
  38. package/dist/chunk-R2YZTCLO.js.map +1 -0
  39. package/dist/chunk-S7A5D7ZJ.js +174 -0
  40. package/dist/chunk-S7A5D7ZJ.js.map +1 -0
  41. package/dist/chunk-TFJVXUHH.js +2358 -0
  42. package/dist/chunk-TFJVXUHH.js.map +1 -0
  43. package/dist/{chunk-LTN6I7QW.js → chunk-TUWMK62B.js} +55 -4
  44. package/dist/chunk-TUWMK62B.js.map +1 -0
  45. package/dist/{chunk-IMSCRLLV.js → chunk-VR7UPJ7C.js} +35 -25
  46. package/dist/chunk-VR7UPJ7C.js.map +1 -0
  47. package/dist/{chunk-KKNUBQ6Y.js → chunk-W4O2TX5I.js} +157 -92
  48. package/dist/chunk-W4O2TX5I.js.map +1 -0
  49. package/dist/{chunk-5TYS5V3G.js → chunk-X6DUONGT.js} +4204 -3560
  50. package/dist/chunk-X6DUONGT.js.map +1 -0
  51. package/dist/doc/index.d.ts +2334 -5
  52. package/dist/doc/index.js +127 -33
  53. package/dist/generate/index.d.ts +7 -2
  54. package/dist/generate/index.js +1 -1
  55. package/dist/imageEdit/index.d.ts +9 -6
  56. package/dist/imageEdit/index.js +3 -3
  57. package/dist/index.d.ts +12 -10
  58. package/dist/index.js +246 -80
  59. package/dist/jsonForm/index.d.ts +7 -3
  60. package/dist/jsonForm/index.js +9 -4
  61. package/dist/markdown/index.d.ts +6 -70
  62. package/dist/markdown/index.js +13 -9
  63. package/dist/narration/index.d.ts +618 -0
  64. package/dist/narration/index.js +82 -0
  65. package/dist/recommend/index.d.ts +15 -1
  66. package/dist/recommend/index.js +2 -2
  67. package/dist/schemas/index.d.ts +28 -6
  68. package/dist/schemas/index.js +15 -13
  69. package/dist/spatial/index.d.ts +0 -1
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.js +1 -1
  72. package/dist/{themeLibrary-CehcJhzz.d.ts → themeLibrary-RjVgLpUi.d.ts} +10 -11
  73. package/dist/timing/index.js +5 -3
  74. package/dist/transform/index.d.ts +35 -24
  75. package/dist/transform/index.js +9 -10
  76. package/dist/{types-DlAZ7MW4.d.ts → types-BwMPl2CB.d.ts} +1 -1
  77. package/dist/{types-D98li1gV.d.ts → types-C4kiRn_k.d.ts} +3 -1
  78. package/dist/versions/index.d.ts +2 -1
  79. package/dist/versions/index.js +2 -2
  80. package/package.json +6 -6
  81. package/src/__tests__/applyNarrationTiming.test.ts +190 -0
  82. package/src/__tests__/applyRenderStyle.test.ts +3 -3
  83. package/src/__tests__/asciiDiagramBattle.test.ts +246 -0
  84. package/src/__tests__/asciiDiagramDetect.test.ts +162 -0
  85. package/src/__tests__/asciiDiagramMapping.test.ts +38 -0
  86. package/src/__tests__/asciiDiagramParse.test.ts +233 -0
  87. package/src/__tests__/asciiDiagramPipeline.test.ts +273 -0
  88. package/src/__tests__/asciiDiagramRender.test.ts +162 -0
  89. package/src/__tests__/asciiDiagramRepair.test.ts +84 -0
  90. package/src/__tests__/asciiTimelineDetect.test.ts +157 -0
  91. package/src/__tests__/asciiTimelineParse.test.ts +205 -0
  92. package/src/__tests__/asciiTimelinePipeline.test.ts +349 -0
  93. package/src/__tests__/asciiTimelineRender.test.ts +376 -0
  94. package/src/__tests__/attrTokens.test.ts +5 -0
  95. package/src/__tests__/contentContainer.test.ts +43 -0
  96. package/src/__tests__/customTemplatesFrontmatter.test.ts +0 -15
  97. package/src/__tests__/customThemesFrontmatter.test.ts +1 -0
  98. package/src/__tests__/dataTable.test.ts +25 -15
  99. package/src/__tests__/diagramBlock.test.ts +3 -3
  100. package/src/__tests__/diagramText.test.ts +26 -0
  101. package/src/__tests__/docToMarkdownMedia.test.ts +83 -0
  102. package/src/__tests__/drawingBlock.test.ts +3 -3
  103. package/src/__tests__/fixtures/asciiDiagramBattle.ts +512 -0
  104. package/src/__tests__/fixtures/asciiDiagrams.ts +287 -0
  105. package/src/__tests__/fixtures/asciiTimelines.ts +35 -0
  106. package/src/__tests__/fixtures/treeviewFixtures.ts +243 -0
  107. package/src/__tests__/getLayers.test.ts +32 -18
  108. package/src/__tests__/getLayersFallback.test.ts +8 -8
  109. package/src/__tests__/imageEditVersions.test.ts +2 -0
  110. package/src/__tests__/jsonForm.pointer.test.ts +47 -1
  111. package/src/__tests__/layoutBlock.test.ts +3 -3
  112. package/src/__tests__/majorApiTypes.test.ts +18 -0
  113. package/src/__tests__/markdown.test.ts +54 -4
  114. package/src/__tests__/markdownSanitize.test.ts +7 -5
  115. package/src/__tests__/matchFontFamily.test.ts +114 -0
  116. package/src/__tests__/materializeBlockLayers.test.ts +135 -0
  117. package/src/__tests__/materializeTestUtils.ts +17 -0
  118. package/src/__tests__/narrationAlign.test.ts +150 -0
  119. package/src/__tests__/narrationFeatures.test.ts +71 -0
  120. package/src/__tests__/narrationNuclei.test.ts +45 -0
  121. package/src/__tests__/narrationPacing.test.ts +189 -0
  122. package/src/__tests__/narrationScript.test.ts +149 -0
  123. package/src/__tests__/narrationSessionRepro.test.ts +62 -0
  124. package/src/__tests__/narrationSidecar.test.ts +88 -0
  125. package/src/__tests__/narrationSyllables.test.ts +65 -0
  126. package/src/__tests__/narrationTestSignals.ts +167 -0
  127. package/src/__tests__/narrationVad.test.ts +66 -0
  128. package/src/__tests__/resolveDocTheme.test.ts +16 -2
  129. package/src/__tests__/rootExports.test.ts +18 -0
  130. package/src/__tests__/scopedContentContainer.test.ts +20 -0
  131. package/src/__tests__/shapeGeometry.test.ts +12 -0
  132. package/src/__tests__/templateAnnotationParse.test.ts +10 -0
  133. package/src/__tests__/templateInputs.test.ts +69 -0
  134. package/src/__tests__/templateMetadata.test.ts +2 -1
  135. package/src/__tests__/templates.test.ts +143 -17
  136. package/src/__tests__/themeCompile.test.ts +49 -22
  137. package/src/__tests__/transformProvenance.test.ts +160 -0
  138. package/src/__tests__/transformV2.test.ts +40 -14
  139. package/src/__tests__/treeviewBattle.test.ts +76 -0
  140. package/src/__tests__/treeviewDetect.test.ts +168 -0
  141. package/src/__tests__/treeviewParse.test.ts +118 -0
  142. package/src/__tests__/treeviewPipeline.test.ts +183 -0
  143. package/src/__tests__/treeviewRender.test.ts +91 -0
  144. package/src/__tests__/versions.test.ts +44 -0
  145. package/src/doc/applyNarrationTiming.ts +244 -0
  146. package/src/doc/asciiDiagram/chars.ts +210 -0
  147. package/src/doc/asciiDiagram/detect.ts +175 -0
  148. package/src/doc/asciiDiagram/index.ts +38 -0
  149. package/src/doc/asciiDiagram/mapping.ts +225 -0
  150. package/src/doc/asciiDiagram/parse.ts +1095 -0
  151. package/src/doc/asciiDiagram/render.ts +963 -0
  152. package/src/doc/asciiDiagram/repair.ts +400 -0
  153. package/src/doc/asciiDiagram/types.ts +65 -0
  154. package/src/doc/asciiTimeline/detect.ts +138 -0
  155. package/src/doc/asciiTimeline/index.ts +26 -0
  156. package/src/doc/asciiTimeline/mapping.ts +43 -0
  157. package/src/doc/asciiTimeline/parse.ts +870 -0
  158. package/src/doc/asciiTimeline/render.ts +427 -0
  159. package/src/doc/asciiTimeline/types.ts +73 -0
  160. package/src/doc/asciiTimeline/wrappedFlow.ts +427 -0
  161. package/src/doc/audioMapping.ts +60 -46
  162. package/src/doc/customTemplatesFrontmatter.ts +0 -41
  163. package/src/doc/docToMarkdown.ts +70 -2
  164. package/src/doc/index.ts +76 -4
  165. package/src/doc/markdownToDoc.ts +138 -2
  166. package/src/doc/materializeBlockLayers.ts +304 -0
  167. package/src/doc/mediaAnnotations.ts +20 -0
  168. package/src/doc/resolveDocTheme.ts +8 -7
  169. package/src/doc/templateInputs.ts +185 -2
  170. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +22 -8
  171. package/src/doc/templates/__tests__/listBlock.test.ts +64 -0
  172. package/src/doc/templates/diagramBlock.ts +237 -38
  173. package/src/doc/templates/fallbackBlock.ts +1 -1
  174. package/src/doc/templates/index.ts +134 -279
  175. package/src/doc/templates/inputDescriptors.ts +20 -1
  176. package/src/doc/templates/listBlock.ts +88 -30
  177. package/src/doc/templates/metadata.ts +10 -0
  178. package/src/doc/templates/persistentLayers.ts +12 -126
  179. package/src/doc/templates/registry.ts +88 -0
  180. package/src/doc/templates/templateNames.ts +22 -0
  181. package/src/doc/templates/timelineBlock.ts +613 -0
  182. package/src/doc/templates/treeBlock.ts +131 -0
  183. package/src/doc/treeview/chars.ts +72 -0
  184. package/src/doc/treeview/detect.ts +145 -0
  185. package/src/doc/treeview/index.ts +30 -0
  186. package/src/doc/treeview/mapping.ts +95 -0
  187. package/src/doc/treeview/parse.ts +201 -0
  188. package/src/doc/treeview/render.ts +41 -0
  189. package/src/doc/treeview/types.ts +43 -0
  190. package/src/doc/utils/applyRenderStyle.ts +2 -9
  191. package/src/doc/utils/diagramText.ts +78 -0
  192. package/src/doc/utils/shapeGeometry.ts +67 -5
  193. package/src/doc/validate.ts +1 -1
  194. package/src/generate/templateMapper.ts +7 -0
  195. package/src/imageEdit/export.ts +6 -5
  196. package/src/imageEdit/versionPaths.ts +13 -26
  197. package/src/imageEdit/versions.ts +21 -100
  198. package/src/index.ts +1 -0
  199. package/src/internal/immutable.ts +58 -0
  200. package/src/jsonForm/index.ts +9 -1
  201. package/src/jsonForm/pointer.ts +68 -23
  202. package/src/markdown/attrTokens.ts +7 -5
  203. package/src/markdown/index.ts +1 -0
  204. package/src/markdown/stringify.ts +4 -9
  205. package/src/markdown/utils.ts +36 -14
  206. package/src/narration/align.ts +514 -0
  207. package/src/narration/features.ts +231 -0
  208. package/src/narration/index.ts +86 -0
  209. package/src/narration/nuclei.ts +118 -0
  210. package/src/narration/pacing.ts +211 -0
  211. package/src/narration/script.ts +239 -0
  212. package/src/narration/session.ts +100 -0
  213. package/src/narration/sidecar.ts +187 -0
  214. package/src/narration/syllables.ts +85 -0
  215. package/src/narration/trace.ts +50 -0
  216. package/src/narration/types.ts +299 -0
  217. package/src/narration/vad.ts +131 -0
  218. package/src/recommend/numberHighlight.ts +25 -0
  219. package/src/recommend/templates.ts +108 -12
  220. package/src/schemas/BlockTemplates.ts +156 -7
  221. package/src/schemas/Doc.ts +72 -9
  222. package/src/schemas/Media.ts +12 -2
  223. package/src/schemas/Theme.ts +45 -25
  224. package/src/schemas/Viewport.ts +2 -2
  225. package/src/schemas/fontStacks.ts +79 -0
  226. package/src/schemas/themeCompile.ts +15 -0
  227. package/src/schemas/themeConstants.ts +2 -0
  228. package/src/schemas/themeLibrary.ts +25 -19
  229. package/src/schemas/themeValidator.ts +1 -1
  230. package/src/schemas/themes/gezellig.json +1 -1
  231. package/src/spatial/Geohash.ts +0 -3
  232. package/src/storage/ContentContainer.ts +17 -9
  233. package/src/storage/ScopedContentContainer.ts +45 -15
  234. package/src/transform/applyTransform.ts +5 -5
  235. package/src/transform/index.ts +3 -1
  236. package/src/transform/registry.ts +169 -49
  237. package/src/transform/templateSelector.ts +21 -2
  238. package/src/transform/timingAllocator.ts +152 -21
  239. package/src/transform/types.ts +14 -1
  240. package/src/versions/gitignore.ts +34 -0
  241. package/src/versions/index.ts +2 -0
  242. package/src/versions/operations.ts +18 -104
  243. package/src/versions/paths.ts +13 -28
  244. package/src/versions/snapshotStore.ts +202 -0
  245. package/src/versions/types.ts +3 -1
  246. package/dist/chunk-5TYS5V3G.js.map +0 -1
  247. package/dist/chunk-A4O7GIWE.js.map +0 -1
  248. package/dist/chunk-BEQODJWV.js.map +0 -1
  249. package/dist/chunk-DLXZMS5K.js.map +0 -1
  250. package/dist/chunk-GPLTCSXO.js.map +0 -1
  251. package/dist/chunk-HWVFJAAH.js.map +0 -1
  252. package/dist/chunk-IMSCRLLV.js.map +0 -1
  253. package/dist/chunk-JELIRVDP.js +0 -24
  254. package/dist/chunk-JELIRVDP.js.map +0 -1
  255. package/dist/chunk-KKNUBQ6Y.js.map +0 -1
  256. package/dist/chunk-LDQ2HJIX.js.map +0 -1
  257. package/dist/chunk-LTN6I7QW.js.map +0 -1
  258. package/dist/chunk-P3K7NLHF.js.map +0 -1
  259. package/dist/chunk-T5UK6YOB.js.map +0 -1
  260. package/dist/chunk-UY7KGQ5R.js +0 -192
  261. package/dist/chunk-UY7KGQ5R.js.map +0 -1
  262. package/dist/chunk-VLJJHDAC.js +0 -218
  263. package/dist/chunk-VLJJHDAC.js.map +0 -1
  264. package/dist/chunk-VWUFZ6ZG.js.map +0 -1
  265. package/dist/story/index.d.ts +0 -1801
  266. package/dist/story/index.js +0 -279
  267. package/src/doc/getLayers.ts +0 -195
  268. /package/dist/{story → narration}/index.js.map +0 -0
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { DEFAULT_THEME, expandDocBlocks } from '../doc/templates/index.js';
3
+ import { materializeBlockLayers } from '../doc/materializeBlockLayers.js';
4
+ import type { CustomTemplateDefinition } from '../schemas/CustomTemplates.js';
5
+ import type { Block, Layer, TextLayer } from '../schemas/Doc.js';
6
+ import type { TemplateBlock } from '../schemas/BlockTemplates.js';
7
+
8
+ const titleBlock: TemplateBlock = {
9
+ template: 'title',
10
+ id: 'title-1',
11
+ duration: 5,
12
+ audioSegment: 0,
13
+ title: 'A canonical materializer',
14
+ };
15
+
16
+ describe('materializeBlockLayers', () => {
17
+ it('returns owned layers and metadata for built-in and authored blocks', () => {
18
+ const templated = materializeBlockLayers(titleBlock, { persistentLayers: false });
19
+ expect(templated.source).toBe('template');
20
+ expect(templated.diagnostic).toBeUndefined();
21
+ expect(templated.layers.length).toBeGreaterThan(0);
22
+
23
+ const authoredLayer: Layer = {
24
+ id: 'authored',
25
+ type: 'shape',
26
+ content: { shape: 'rect', fill: '#123456' },
27
+ position: { x: 0, y: 0, width: 10, height: 10 },
28
+ };
29
+ const authored: Block = {
30
+ id: 'raw',
31
+ startTime: 0,
32
+ duration: 5,
33
+ audioSegment: 0,
34
+ layers: [authoredLayer],
35
+ };
36
+ const materialized = materializeBlockLayers(authored, { persistentLayers: false });
37
+ expect(materialized.source).toBe('authored');
38
+ expect(materialized.layers).toEqual([authoredLayer]);
39
+ expect(materialized.layers[0]).not.toBe(authoredLayer);
40
+ });
41
+
42
+ it('resolves document-scoped custom templates in the on-demand API', () => {
43
+ const customTemplate: CustomTemplateDefinition = {
44
+ name: 'hero',
45
+ label: 'Hero',
46
+ viewport: { width: 1920, height: 1080 },
47
+ layers: [
48
+ {
49
+ id: 'hero-title',
50
+ type: 'text',
51
+ position: { x: '5%', y: '10%', width: '90%' },
52
+ content: { text: '{title}', style: { fontSize: 64, color: '#000000' } },
53
+ },
54
+ ],
55
+ };
56
+ const block = {
57
+ ...titleBlock,
58
+ template: 'hero',
59
+ title: 'Doc-scoped',
60
+ } as unknown as TemplateBlock;
61
+
62
+ const result = materializeBlockLayers(block, {
63
+ customTemplates: [customTemplate],
64
+ persistentLayers: false,
65
+ });
66
+
67
+ expect(result.source).toBe('template');
68
+ expect((result.layers[0] as TextLayer).content.text).toBe('Doc-scoped');
69
+ });
70
+
71
+ it('returns structured diagnostics and a visible fallback without logging', () => {
72
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
73
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
74
+ const unknown = { ...titleBlock, template: 'not-registered' } as unknown as TemplateBlock;
75
+
76
+ const result = materializeBlockLayers(unknown, { persistentLayers: false });
77
+
78
+ expect(result.source).toBe('fallback');
79
+ expect(result.diagnostic).toEqual({
80
+ code: 'unknown-template',
81
+ template: 'not-registered',
82
+ message: 'Unknown template "not-registered"',
83
+ });
84
+ expect(result.layers.some((layer) => layer.id === 'fallback-notice')).toBe(true);
85
+ expect(warn).not.toHaveBeenCalled();
86
+ expect(error).not.toHaveBeenCalled();
87
+ warn.mockRestore();
88
+ error.mockRestore();
89
+ });
90
+
91
+ it('supports an explicit empty failure policy', () => {
92
+ const unknown = { ...titleBlock, template: 'not-registered' } as unknown as TemplateBlock;
93
+ const result = materializeBlockLayers(unknown, {
94
+ failureMode: 'empty',
95
+ persistentLayers: false,
96
+ });
97
+
98
+ expect(result.source).toBe('empty');
99
+ expect(result.layers).toEqual([]);
100
+ expect(result.diagnostic?.code).toBe('unknown-template');
101
+ });
102
+
103
+ it('inherits theme persistent layers by default and has an explicit opt-out', () => {
104
+ const theme = {
105
+ ...DEFAULT_THEME,
106
+ persistentLayers: {
107
+ bottomLayers: [
108
+ {
109
+ template: 'solidBackground' as const,
110
+ config: { type: 'solidBackground' as const, color: '#123456' },
111
+ },
112
+ ],
113
+ },
114
+ };
115
+
116
+ const inherited = materializeBlockLayers(titleBlock, { theme });
117
+ const disabled = materializeBlockLayers(titleBlock, { theme, persistentLayers: false });
118
+
119
+ expect(inherited.layers[0]?.id).toContain('solid');
120
+ expect(disabled.layers[0]?.id).not.toContain('solid');
121
+ });
122
+
123
+ it('is the only materialization path used by timed expansion', () => {
124
+ const diagnostics: string[] = [];
125
+ const unknown = { ...titleBlock, template: 'not-registered' } as unknown as TemplateBlock;
126
+ const direct = materializeBlockLayers(unknown, { persistentLayers: false });
127
+ const [scheduled] = expandDocBlocks([unknown], {
128
+ persistentLayers: false,
129
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic.code),
130
+ });
131
+
132
+ expect(scheduled.layers).toEqual(direct.layers);
133
+ expect(diagnostics).toEqual(['unknown-template']);
134
+ });
135
+ });
@@ -0,0 +1,17 @@
1
+ import type { Layer } from '../schemas/Doc.js';
2
+ import type { DocBlock } from '../schemas/BlockTemplates.js';
3
+ import {
4
+ materializeBlockLayers,
5
+ type MaterializeBlockLayersOptions,
6
+ } from '../doc/materializeBlockLayers.js';
7
+
8
+ /** Materialize isolated test fixtures without inheriting theme atmosphere. */
9
+ export function materializeLayers(
10
+ block: DocBlock,
11
+ options: MaterializeBlockLayersOptions = {},
12
+ ): Layer[] {
13
+ return materializeBlockLayers(block, {
14
+ persistentLayers: false,
15
+ ...options,
16
+ }).layers;
17
+ }
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { alignNarration } from '../narration/align';
3
+ import type { NarrationAlignment, NarrationScript } from '../narration/types';
4
+ import type { NarrationTrace } from '../narration/trace';
5
+ import { scriptFromMarkdown, takeFromScript, type SyntheticTake } from './narrationTestSignals';
6
+
7
+ const SR = 48000;
8
+
9
+ const MD = `# The River Walk
10
+
11
+ Every morning we follow the path along the river and count the boats heading out.
12
+ The bakery opens early and the smell of bread carries across the water to the far bank.
13
+
14
+ By noon the light changes and the whole town seems to slow down for an hour or two.
15
+
16
+ ## The Market
17
+
18
+ Stalls line the square with fruit and flowers and a man who fixes watches while you wait.
19
+ We always stop for coffee before carrying everything home up the long hill past the church.
20
+
21
+ ## Evening
22
+
23
+ The lamps come on one street at a time and the river turns the color of old coins.
24
+ `;
25
+
26
+ const script = scriptFromMarkdown(MD);
27
+
28
+ /** Burst onsets are at word start; envelope peaks ~half a burst later. */
29
+ const PEAK_OFFSET = 0.08;
30
+
31
+ function timingErrors(alignment: NarrationAlignment, take: SyntheticTake): number[] {
32
+ return alignment.words.map((w, k) => Math.abs(w.tSec - (take.trueWordTimes[k] + PEAK_OFFSET)));
33
+ }
34
+
35
+ function median(values: number[]): number {
36
+ const sorted = [...values].sort((a, b) => a - b);
37
+ return sorted[Math.floor(sorted.length / 2)];
38
+ }
39
+
40
+ function p95(values: number[]): number {
41
+ const sorted = [...values].sort((a, b) => a - b);
42
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
43
+ }
44
+
45
+ /** A live trace with a deterministic ±wobble, as a recorded prompter would produce. */
46
+ function traceFromTake(
47
+ take: SyntheticTake,
48
+ scriptRef: NarrationScript,
49
+ wobbleSec: number,
50
+ ): NarrationTrace {
51
+ const samples: { tMs: number; wordPos: number }[] = [];
52
+ for (let t = 0; t <= take.durationSec; t += 0.25) {
53
+ const shifted = t + wobbleSec * Math.sin(t * 1.3);
54
+ // wordPos convention: floor(wordPos) is the ACTIVE token — the word
55
+ // currently being spoken — not the count of words started.
56
+ let started = 0;
57
+ while (started < take.trueWordTimes.length && take.trueWordTimes[started] <= shifted) started++;
58
+ const wordPos = Math.max(0, started - 1);
59
+ samples.push({ tMs: t * 1000, wordPos: Math.min(wordPos, scriptRef.tokens.length) });
60
+ }
61
+ return { samples };
62
+ }
63
+
64
+ describe('alignNarration', () => {
65
+ const take = takeFromScript(script, 150, SR, 21);
66
+
67
+ it('clean take without a trace: median error < 0.25 s, p95 < 1 s', () => {
68
+ const alignment = alignNarration({ pcm: take.pcm, sampleRate: SR, script });
69
+ expect(alignment.words.length).toBe(script.tokens.length);
70
+ const errors = timingErrors(alignment, take);
71
+ expect(median(errors)).toBeLessThan(0.25);
72
+ expect(p95(errors)).toBeLessThan(1.0);
73
+ expect(alignment.detectedSyllables).toBeGreaterThan(script.totalSyllables * 0.7);
74
+ });
75
+
76
+ it('clean take with a wobbly trace: median error < 0.15 s, p95 < 0.5 s', () => {
77
+ const trace = traceFromTake(take, script, 0.8);
78
+ const alignment = alignNarration({ pcm: take.pcm, sampleRate: SR, script, trace });
79
+ const errors = timingErrors(alignment, take);
80
+ expect(median(errors)).toBeLessThan(0.15);
81
+ expect(p95(errors)).toBeLessThan(0.5);
82
+ });
83
+
84
+ it('survives filler insertions and a dropped syllable', () => {
85
+ const messy = takeFromScript(script, 150, SR, 22, {
86
+ extraOnsets: [4.1, 12.4],
87
+ dropTokenSyllable: [8 * 1000 + 0],
88
+ });
89
+ const trace = traceFromTake(messy, script, 0.5);
90
+ const alignment = alignNarration({ pcm: messy.pcm, sampleRate: SR, script, trace });
91
+ const errors = timingErrors(alignment, messy);
92
+ expect(median(errors)).toBeLessThan(0.25);
93
+ });
94
+
95
+ it('block ranges are contiguous, monotonic, and near the true block starts', () => {
96
+ const trace = traceFromTake(take, script, 0.4);
97
+ const alignment = alignNarration({ pcm: take.pcm, sampleRate: SR, script, trace });
98
+ const blocks = alignment.blocks;
99
+ expect(blocks.length).toBe(script.blocks.length);
100
+ expect(blocks[0].startSec).toBe(0);
101
+ for (let b = 0; b < blocks.length - 1; b++) {
102
+ expect(blocks[b].endSec).toBeCloseTo(blocks[b + 1].startSec, 9);
103
+ expect(blocks[b].endSec).toBeGreaterThanOrEqual(blocks[b].startSec);
104
+ }
105
+ const last = blocks[blocks.length - 1];
106
+ expect(last.endSec).toBeGreaterThan(last.startSec);
107
+ expect(last.endSec).toBeLessThanOrEqual(take.durationSec + 1e-6);
108
+ for (let b = 1; b < blocks.length; b++) {
109
+ expect(Math.abs(blocks[b].startSec - take.trueBlockStarts[b])).toBeLessThan(0.7);
110
+ }
111
+ });
112
+
113
+ it('word times are monotonic', () => {
114
+ const alignment = alignNarration({ pcm: take.pcm, sampleRate: SR, script });
115
+ for (let i = 1; i < alignment.words.length; i++) {
116
+ expect(alignment.words[i].tSec).toBeGreaterThanOrEqual(alignment.words[i - 1].tSec);
117
+ }
118
+ });
119
+
120
+ it('falls back proportionally when nothing is detected', () => {
121
+ const quiet = new Float32Array(SR * 5);
122
+ const alignment = alignNarration({ pcm: quiet, sampleRate: SR, script });
123
+ expect(alignment.detectedSyllables).toBe(0);
124
+ expect(alignment.words.length).toBe(script.tokens.length);
125
+ expect(alignment.words.every((w) => w.interpolated)).toBe(true);
126
+ expect(alignment.blocks.length).toBe(script.blocks.length);
127
+ });
128
+
129
+ it('handles an empty script', () => {
130
+ const alignment = alignNarration({
131
+ pcm: new Float32Array(SR),
132
+ sampleRate: SR,
133
+ script: {
134
+ sourceText: '',
135
+ tokens: [],
136
+ blocks: [],
137
+ totalSyllables: 0,
138
+ cumulativeSyllables: [0],
139
+ },
140
+ });
141
+ expect(alignment.words).toEqual([]);
142
+ expect(alignment.blocks).toEqual([]);
143
+ });
144
+
145
+ it('is deterministic', () => {
146
+ const a = alignNarration({ pcm: take.pcm, sampleRate: SR, script });
147
+ const b = alignNarration({ pcm: take.pcm, sampleRate: SR, script });
148
+ expect(a).toEqual(b);
149
+ });
150
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createFeatureState, extractFrameFeatures, featureStep } from '../narration/features';
3
+ import { tone, noise } from './narrationTestSignals';
4
+
5
+ const RATES = [48000, 44100];
6
+
7
+ describe('extractFrameFeatures', () => {
8
+ it.each(RATES)('band energy passes speech band and rejects hum/hiss @ %d Hz', (sr) => {
9
+ const inBand = extractFrameFeatures(tone(1000, 0.5, sr), sr);
10
+ const hum = extractFrameFeatures(tone(60, 0.5, sr), sr);
11
+ const hiss = extractFrameFeatures(tone(8000, 0.5, sr), sr);
12
+
13
+ // Skip filter warm-up frames; compare steady-state medians.
14
+ const median = (frames: { bandEnergy: number }[]) => {
15
+ const vals = frames
16
+ .slice(5)
17
+ .map((f) => f.bandEnergy)
18
+ .sort((a, b) => a - b);
19
+ return vals[Math.floor(vals.length / 2)];
20
+ };
21
+ expect(median(inBand)).toBeGreaterThan(median(hum) * 10);
22
+ expect(median(inBand)).toBeGreaterThan(median(hiss) * 10);
23
+ });
24
+
25
+ it.each(RATES)('zcr orders noise above tone @ %d Hz', (sr) => {
26
+ const noisy = extractFrameFeatures(noise(0.3, sr, 0.2, 7), sr);
27
+ const tonal = extractFrameFeatures(tone(300, 0.3, sr), sr);
28
+ expect(noisy[5].zcr).toBeGreaterThan(tonal[5].zcr);
29
+ });
30
+
31
+ it('rms measures a known-amplitude tone (~amp/√2)', () => {
32
+ const sr = 48000;
33
+ const frames = extractFrameFeatures(tone(440, 0.3, sr, 0.5), sr);
34
+ expect(frames[5].rms).toBeGreaterThan(0.3);
35
+ expect(frames[5].rms).toBeLessThan(0.4);
36
+ });
37
+
38
+ it.each(RATES)('streaming chunks produce the same frames as one batch @ %d Hz', (sr) => {
39
+ const pcm = noise(0.5, sr, 0.1, 42);
40
+ const batch = extractFrameFeatures(pcm, sr);
41
+
42
+ let state = createFeatureState(sr);
43
+ const streamed: ReturnType<typeof extractFrameFeatures> = [];
44
+ // Uneven chunk sizes exercise the carry logic.
45
+ const chunks = [100, 1024, 333, 4096, 57];
46
+ let offset = 0;
47
+ let pick = 0;
48
+ while (offset < pcm.length) {
49
+ const size = Math.min(chunks[pick++ % chunks.length], pcm.length - offset);
50
+ const step = featureStep(state, pcm.subarray(offset, offset + size));
51
+ state = step.state;
52
+ streamed.push(...step.frames);
53
+ offset += size;
54
+ }
55
+
56
+ expect(streamed.length).toBe(batch.length);
57
+ for (let i = 0; i < batch.length; i++) {
58
+ expect(streamed[i].tSec).toBeCloseTo(batch[i].tSec, 9);
59
+ expect(streamed[i].rms).toBeCloseTo(batch[i].rms, 9);
60
+ expect(streamed[i].bandEnergy).toBeCloseTo(batch[i].bandEnergy, 9);
61
+ expect(streamed[i].zcr).toBeCloseTo(batch[i].zcr, 9);
62
+ }
63
+ });
64
+
65
+ it('frame centers advance by the hop', () => {
66
+ const sr = 48000;
67
+ const frames = extractFrameFeatures(noise(0.3, sr, 0.1, 3), sr, { hopSec: 0.02 });
68
+ expect(frames.length).toBeGreaterThan(5);
69
+ expect(frames[1].tSec - frames[0].tSec).toBeCloseTo(0.02, 3);
70
+ });
71
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractFrameFeatures } from '../narration/features';
3
+ import { createVadState, vadStep } from '../narration/vad';
4
+ import { detectSyllableOnsets } from '../narration/nuclei';
5
+ import { syllableTrain } from './narrationTestSignals';
6
+
7
+ const SR = 48000;
8
+
9
+ function detect(pcm: Float32Array): number[] {
10
+ const frames = extractFrameFeatures(pcm, SR, { hopSec: 0.01 });
11
+ let vad = createVadState();
12
+ const flags = frames.map((f) => {
13
+ vad = vadStep(vad, f);
14
+ return vad.speaking;
15
+ });
16
+ return detectSyllableOnsets(frames, flags);
17
+ }
18
+
19
+ describe('detectSyllableOnsets', () => {
20
+ it('finds each burst in a syllable train (±1 count, ±60 ms timing)', () => {
21
+ const truth = [0.6, 0.9, 1.2, 1.5, 2.2, 2.5, 2.8, 3.6, 3.9, 4.2, 4.5, 4.8];
22
+ const onsets = detect(syllableTrain(truth, SR, { seed: 11 }));
23
+ expect(Math.abs(onsets.length - truth.length)).toBeLessThanOrEqual(1);
24
+
25
+ // Each detected onset lies near some true burst (peak ≈ burst center,
26
+ // so allow the 80 ms half-burst plus 60 ms tolerance).
27
+ for (const t of onsets) {
28
+ const nearest = Math.min(...truth.map((x) => Math.abs(t - (x + 0.08))));
29
+ expect(nearest).toBeLessThanOrEqual(0.06 + 0.04);
30
+ }
31
+ });
32
+
33
+ it('suppresses double-bursts closer than the minimum spacing', () => {
34
+ // Two bursts 60 ms apart → their envelopes merge / spacing gate fires:
35
+ // at most one onset in that neighborhood.
36
+ const onsets = detect(syllableTrain([1.0, 1.06, 2.0], SR, { seed: 12 }));
37
+ const nearOne = onsets.filter((t) => t > 0.8 && t < 1.5);
38
+ expect(nearOne.length).toBeLessThanOrEqual(1);
39
+ });
40
+
41
+ it('reports nothing on silence/noise', () => {
42
+ const onsets = detect(syllableTrain([], SR, { totalSec: 2, seed: 13 }));
43
+ expect(onsets.length).toBe(0);
44
+ });
45
+ });
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ createPacingState,
4
+ pacingStep,
5
+ reanchorPacing,
6
+ type PacingState,
7
+ type PacingTick,
8
+ } from '../narration/pacing';
9
+ import { expectedSyllablesAt } from '../narration/script';
10
+ import { DEFAULT_PACING_CONFIG, type PacingConfig } from '../narration/types';
11
+ import { scriptFromMarkdown } from './narrationTestSignals';
12
+
13
+ const MD = `# Cadence
14
+
15
+ The quick brown fox jumps over the lazy dog while we keep a steady pace for this test.
16
+ It continues with more plain words that carry roughly one or two syllables each one.
17
+
18
+ Another paragraph starts after a pause and keeps going with the same easy words for a while.
19
+
20
+ ## Second Section
21
+
22
+ More text lives here so the script is long enough that the prompter never runs off the end.
23
+ The prompter should track the reader through all of it without drifting away from the voice.
24
+ `;
25
+
26
+ const script = scriptFromMarkdown(MD);
27
+ const BASE_WPS = DEFAULT_PACING_CONFIG.baseWpm / 60;
28
+ const DT = 0.02;
29
+
30
+ /** Drive the controller with speaking ticks emitting onsets at `sylRate` (syl/s). */
31
+ function drive(
32
+ state: PacingState,
33
+ fromSec: number,
34
+ durSec: number,
35
+ opts: { speaking: boolean; sylRate?: number },
36
+ config?: Partial<PacingConfig>,
37
+ ): PacingState {
38
+ let acc = 0;
39
+ let next = state;
40
+ for (let t = fromSec; t < fromSec + durSec; t += DT) {
41
+ let onsets = 0;
42
+ if (opts.speaking && opts.sylRate) {
43
+ acc += opts.sylRate * DT;
44
+ while (acc >= 1) {
45
+ onsets += 1;
46
+ acc -= 1;
47
+ }
48
+ }
49
+ const tick: PacingTick = { tSec: t, speaking: opts.speaking, onsets };
50
+ next = pacingStep(next, tick, script, config);
51
+ }
52
+ return next;
53
+ }
54
+
55
+ const meanSylPerWord = script.totalSyllables / script.tokens.length;
56
+
57
+ describe('pacingStep', () => {
58
+ it('halts within 250 ms of silence', () => {
59
+ let state = drive(createPacingState(), 0, 3, {
60
+ speaking: true,
61
+ sylRate: BASE_WPS * meanSylPerWord,
62
+ });
63
+ expect(state.velocityWps).toBeGreaterThan(BASE_WPS * 0.5);
64
+ const velocityAtSilence = state.velocityWps;
65
+ state = drive(state, 3, 0.25, { speaking: false });
66
+ expect(state.velocityWps).toBeLessThan(velocityAtSilence * 0.05);
67
+ state = drive(state, 3.25, 0.3, { speaking: false });
68
+ expect(state.velocityWps).toBe(0);
69
+ expect(state.halted).toBe(true);
70
+ });
71
+
72
+ it('tracks the reader rate monotonically', () => {
73
+ // Faster reading → faster prompter is the whole point of voice pacing.
74
+ let prevSlope = 0;
75
+ for (const mult of [0.6, 1.0, 1.8]) {
76
+ const sylRate = mult * BASE_WPS * meanSylPerWord;
77
+ const state = drive(createPacingState(), 0, 8, { speaking: true, sylRate });
78
+ const slope = state.wordPos / 8;
79
+ expect(slope).toBeGreaterThan(prevSlope);
80
+ prevSlope = slope;
81
+ }
82
+ });
83
+
84
+ it('stays word-aligned when the reader matches the set pace', () => {
85
+ const sylRate = BASE_WPS * meanSylPerWord;
86
+ const state = drive(createPacingState(), 0, 8, { speaking: true, sylRate });
87
+ const drift =
88
+ expectedSyllablesAt(script, state.wordPos) - (state.anchorExpectedSyl + state.cumDetectedSyl);
89
+ expect(Math.abs(drift)).toBeLessThan(6);
90
+ });
91
+
92
+ it('the WPM setting is a live lever: higher baseWpm → faster cruise', () => {
93
+ // Same modest voice rate, different set pace. Because the set pace is a
94
+ // real feedforward term (not cancelled), doubling baseWpm must visibly
95
+ // speed the prompter up — the bug the user reported.
96
+ const sylRate = 0.8 * BASE_WPS * meanSylPerWord;
97
+ const slow = drive(createPacingState(), 0, 8, { speaking: true, sylRate });
98
+ const fast = drive(createPacingState(), 0, 8, { speaking: true, sylRate }, { baseWpm: 260 });
99
+ expect(fast.wordPos).toBeGreaterThan(slow.wordPos * 1.3);
100
+ });
101
+
102
+ it('never crawls below the set pace floor while speaking', () => {
103
+ // A detector that reports almost nothing must still cruise near the set
104
+ // pace (blend keeps baseWps in play) — so the prompter can't stall out
105
+ // when syllable detection under-counts.
106
+ const state = drive(createPacingState(), 0, 6, { speaking: true, sylRate: 0.05 });
107
+ const slope = state.wordPos / 6;
108
+ expect(slope).toBeGreaterThan(BASE_WPS * DEFAULT_PACING_CONFIG.minRateMult * 0.8);
109
+ });
110
+
111
+ it('clamps velocity at the cruise-band bounds', () => {
112
+ // Absurdly fast reader: velocity respects the maxRateMult × set-pace
113
+ // clamp (plus the bounded PI correction).
114
+ let state = createPacingState();
115
+ let acc = 0;
116
+ const sylRate = 6 * BASE_WPS * meanSylPerWord;
117
+ for (let t = 0; t < 5; t += DT) {
118
+ acc += sylRate * DT;
119
+ let onsets = 0;
120
+ while (acc >= 1) {
121
+ onsets += 1;
122
+ acc -= 1;
123
+ }
124
+ state = pacingStep(state, { tSec: t, speaking: true, onsets }, script);
125
+ expect(state.velocityWps).toBeLessThanOrEqual(
126
+ BASE_WPS * DEFAULT_PACING_CONFIG.maxRateMult * (1 + DEFAULT_PACING_CONFIG.maxCorrection) +
127
+ 1e-9,
128
+ );
129
+ }
130
+ });
131
+
132
+ it('silence halts the prompter and never drags it backward', () => {
133
+ // Park mid-script ahead of the detected syllables, then go silent. An
134
+ // under-counting detector makes err > 0 ("prompter ahead"); the
135
+ // forward-only correction must NOT slow or rewind — the prompter simply
136
+ // holds position (velocity halts) rather than snapping back.
137
+ const startPos = Math.min(20, script.tokens.length - 1);
138
+ const anchored = reanchorPacing(createPacingState(), startPos, script);
139
+ const state = drive({ ...anchored, wordPos: startPos + 3 }, 0, 2.5, { speaking: false });
140
+ expect(state.velocityWps).toBe(0);
141
+ expect(state.wordPos).toBeGreaterThanOrEqual(startPos + 3 - 1e-9);
142
+ });
143
+
144
+ it('catches up forward when the reader is ahead of the prompter', () => {
145
+ // Feed a strong syllable stream while the prompter starts behind: the
146
+ // forward-only boost (and, past the threshold, a forward resync) must
147
+ // pull the highlight forward to meet the voice.
148
+ const state = drive(createPacingState(), 0, 6, {
149
+ speaking: true,
150
+ sylRate: 2 * BASE_WPS * meanSylPerWord,
151
+ });
152
+ const detected = state.cumDetectedSyl;
153
+ // The prompter should have advanced to roughly where those syllables land.
154
+ const expectedPos = expectedSyllablesAt(script, state.wordPos);
155
+ expect(expectedPos).toBeGreaterThan(detected * 0.6);
156
+ });
157
+
158
+ it('reanchorPacing resets error tracking but keeps the learned rate', () => {
159
+ const state = drive(createPacingState(), 0, 4, {
160
+ speaking: true,
161
+ sylRate: BASE_WPS * meanSylPerWord,
162
+ });
163
+ const ema = state.emaSylPerSec;
164
+ expect(ema).toBeGreaterThan(0);
165
+ const anchored = reanchorPacing(state, 5, script);
166
+ expect(anchored.wordPos).toBe(5);
167
+ expect(anchored.cumDetectedSyl).toBe(0);
168
+ expect(anchored.errIntegral).toBe(0);
169
+ expect(anchored.emaSylPerSec).toBe(ema);
170
+ });
171
+
172
+ it('is pure: identical tick sequences yield identical states', () => {
173
+ const run = () => {
174
+ let state = createPacingState();
175
+ state = drive(state, 0, 2, { speaking: true, sylRate: 4 });
176
+ state = drive(state, 2, 1, { speaking: false });
177
+ state = drive(state, 3, 2, { speaking: true, sylRate: 6 });
178
+ return state;
179
+ };
180
+ expect(JSON.stringify(run())).toBe(JSON.stringify(run()));
181
+ });
182
+
183
+ it('never leaves the script bounds', () => {
184
+ let state = createPacingState();
185
+ state = drive(state, 0, 60, { speaking: true, sylRate: 40 });
186
+ expect(state.wordPos).toBeLessThanOrEqual(script.tokens.length);
187
+ expect(state.wordPos).toBeGreaterThanOrEqual(0);
188
+ });
189
+ });