@bycrux/editor 0.11.2 → 1.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/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,455 @@
1
+ /**
2
+ * SP4 T5 — `EngineSourceHost.retain`/`build`'s session lifecycle.
3
+ *
4
+ * `engine.test.ts` deliberately never reaches this surface — its own header
5
+ * explains why: a canvas project has no track-0 video, so nothing there ever
6
+ * touches `demux`/`frame-server`/WebCodecs, and the whole suite runs against a
7
+ * real `EngineSourceHost` for exactly that reason. This file is the one place
8
+ * that DOES reach it, over a real `createEngine` + `updateProject` (the class
9
+ * itself is not exported — there is no other way in), with `demux`,
10
+ * `frame-server` and `audio-clock`'s `createMasterClock` module-mocked in the
11
+ * `useEnginePlayback.test.tsx` style: spy-wrapped fakes reached via
12
+ * `importOriginal`, hoisted with `vi.hoisted` so the mock factories (which run
13
+ * before the rest of this file's top-level code) can close over them.
14
+ *
15
+ * Regression coverage for two bugs the final SP4 review caught in `retain()`:
16
+ * - a mid-session TRIM (start or inPoint change, same src) left the master
17
+ * clock on its pre-trim timebase, because `retain` only rebuilt a session
18
+ * when `src` itself changed;
19
+ * - `volume`/`muted` edits on a live clip never reached the session at all
20
+ * (`MasterClock.setVolume` had zero call sites anywhere in the engine).
21
+ */
22
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
23
+ import type { EditorProject as Project, VisualItem } from '../../schema'
24
+
25
+ interface FakeServerState {
26
+ disposed: boolean
27
+ }
28
+ interface FakeClockState {
29
+ volume: number
30
+ /** What `createMasterClock` was asked for — a muted clip gets a wall clock, not an audio one. */
31
+ muted: boolean
32
+ disposed: boolean
33
+ setVolume: ReturnType<typeof vi.fn>
34
+ }
35
+
36
+ const demuxCalls = vi.hoisted(() => [] as string[])
37
+ const demuxSignals = vi.hoisted(() => [] as AbortSignal[])
38
+ const serverInstances = vi.hoisted(() => [] as FakeServerState[])
39
+ const clockInstances = vi.hoisted(() => [] as FakeClockState[])
40
+ const chunkSourceStub = vi.hoisted(() => () => ({
41
+ kind: 'video' as const,
42
+ codec: 'av01.0.05M.08',
43
+ fps: 30,
44
+ durationS: 10,
45
+ coded: { width: 1280, height: 720 },
46
+ samples: [] as unknown[],
47
+ presIndex: [] as number[],
48
+ firstPresentationTsUs: 0,
49
+ }))
50
+
51
+ vi.mock('../demux', async (importOriginal) => {
52
+ const actual = await importOriginal<typeof import('../demux')>()
53
+ return {
54
+ ...actual,
55
+ demux: vi.fn(async (src: string, _fileUrl: unknown, opts?: { signal?: AbortSignal }) => {
56
+ demuxCalls.push(src)
57
+ if (opts?.signal) demuxSignals.push(opts.signal)
58
+ return { src, video: chunkSourceStub(), audio: null }
59
+ }),
60
+ }
61
+ })
62
+
63
+ vi.mock('../frame-server', () => ({
64
+ createFrameServer: vi.fn((options: { source: { src: string } }) => {
65
+ const state: FakeServerState = { disposed: false }
66
+ serverInstances.push(state)
67
+ return {
68
+ src: options.source.src,
69
+ video: chunkSourceStub(),
70
+ decodeAheadFrames: 8,
71
+ seek: () => ({ reqId: 1, frame: Promise.resolve(null) }),
72
+ startStream: () => 1,
73
+ stopStream: () => {},
74
+ nextFrameFor: () => ({ frame: null, dropped: 0 }),
75
+ stats: () => ({
76
+ buffered: 0,
77
+ inFlightFrames: 0,
78
+ inFlightBatches: 0,
79
+ received: 0,
80
+ dropped: 0,
81
+ atEndOfSource: false,
82
+ drained: false,
83
+ lastError: null,
84
+ }),
85
+ dispose: () => {
86
+ state.disposed = true
87
+ },
88
+ }
89
+ }),
90
+ }))
91
+
92
+ vi.mock('../audio-clock', async (importOriginal) => {
93
+ const actual = await importOriginal<typeof import('../audio-clock')>()
94
+ return {
95
+ ...actual,
96
+ createMasterClock: vi.fn(async (opts: { volume?: number; muted?: boolean; startProjectS: number }) => {
97
+ const state: FakeClockState = {
98
+ volume: opts.muted ? 0 : (opts.volume ?? 1),
99
+ muted: !!opts.muted,
100
+ disposed: false,
101
+ setVolume: vi.fn((v: number) => {
102
+ state.volume = v
103
+ }),
104
+ }
105
+ clockInstances.push(state)
106
+ let playing = false
107
+ let t = opts.startProjectS
108
+ return {
109
+ kind: 'fallback' as const,
110
+ get playing() {
111
+ return playing
112
+ },
113
+ now: () => t,
114
+ play: () => {
115
+ playing = true
116
+ },
117
+ pause: () => {
118
+ playing = false
119
+ },
120
+ seek: (next: number) => {
121
+ t = next
122
+ },
123
+ setVolume: state.setVolume,
124
+ setTransportRate: () => {},
125
+ stats: () => ({
126
+ kind: 'fallback' as const,
127
+ playing,
128
+ samplesConsumed: 0,
129
+ underrunFrames: 0,
130
+ queuedFrames: 0,
131
+ queuedSeconds: 0,
132
+ }),
133
+ dispose: () => {
134
+ state.disposed = true
135
+ },
136
+ }
137
+ }),
138
+ }
139
+ })
140
+
141
+ // Imported AFTER the mock declarations for readability only — vi.mock is hoisted.
142
+ import { createEngine } from '../index'
143
+ import { createMasterClock } from '../audio-clock'
144
+
145
+ function videoItem(overrides: Partial<VisualItem> = {}): VisualItem {
146
+ return {
147
+ id: 'a',
148
+ type: 'video',
149
+ src: '/media/a.mov',
150
+ proxySrc: '/proxies/a_proxy.mp4',
151
+ start: 0,
152
+ end: 5,
153
+ inPoint: 0,
154
+ outPoint: 5,
155
+ ...overrides,
156
+ } as VisualItem
157
+ }
158
+
159
+ /** `track` carries the TRACK's own settings — absent on every project nobody has touched. */
160
+ function videoProject(item: VisualItem, track: { volume?: number; muted?: boolean } = {}): Project {
161
+ return {
162
+ id: 'p1',
163
+ status: 'draft',
164
+ settings: { resolution: [1080, 1920], fps: 30 },
165
+ tracks: [{ id: 'trk-0', items: [item], ...track }],
166
+ } as Project
167
+ }
168
+
169
+ /** Drain the microtask queue past `build()`'s two sequential awaits (demux, then createMasterClock). */
170
+ async function flush() {
171
+ for (let i = 0; i < 8; i++) await Promise.resolve()
172
+ }
173
+
174
+ beforeEach(() => {
175
+ demuxCalls.length = 0
176
+ demuxSignals.length = 0
177
+ serverInstances.length = 0
178
+ clockInstances.length = 0
179
+ })
180
+
181
+ describe('EngineSourceHost.retain — trim, mute and volume edits', () => {
182
+ it('rebuilds the session when inPoint changes on the same src (a trim)', async () => {
183
+ const item = videoItem()
184
+ const engine = createEngine(videoProject(item), { fileUrl: (p) => p, nowMs: () => 0 })
185
+ engine.seek(0) // kicks off the first retain/build — the constructor alone does not
186
+ await flush()
187
+ expect(clockInstances).toHaveLength(1)
188
+ expect(serverInstances).toHaveLength(1)
189
+
190
+ engine.updateProject(videoProject({ ...item, inPoint: 1 }))
191
+ await flush()
192
+
193
+ // The old session's clock and worker are torn down, and a fresh pair is
194
+ // built — proof the stale pre-trim timebase was not just left running.
195
+ expect(clockInstances).toHaveLength(2)
196
+ expect(clockInstances[0].disposed).toBe(true)
197
+ expect(serverInstances).toHaveLength(2)
198
+ expect(serverInstances[0].disposed).toBe(true)
199
+ // The demux cache is keyed by src, not by the trim — the parsed bytes are
200
+ // shared across the rebuild rather than re-fetched.
201
+ expect(demuxCalls).toEqual(['/proxies/a_proxy.mp4'])
202
+
203
+ engine.dispose()
204
+ })
205
+
206
+ it('pushes a volume-only change to the live clock instead of rebuilding', async () => {
207
+ const item = videoItem({ volume: 1 })
208
+ const engine = createEngine(videoProject(item), { fileUrl: (p) => p, nowMs: () => 0 })
209
+ engine.seek(0)
210
+ await flush()
211
+ expect(clockInstances).toHaveLength(1)
212
+
213
+ engine.updateProject(videoProject({ ...item, volume: 0.4 }))
214
+ await flush()
215
+
216
+ expect(clockInstances).toHaveLength(1) // no rebuild
217
+ expect(clockInstances[0].disposed).toBe(false)
218
+ expect(clockInstances[0].setVolume).toHaveBeenCalledWith(0.4)
219
+ expect(clockInstances[0].volume).toBe(0.4)
220
+
221
+ engine.dispose()
222
+ })
223
+
224
+ it('rebuilds the session when muted toggles (the clock kind depends on it)', async () => {
225
+ const item = videoItem({ muted: false })
226
+ const engine = createEngine(videoProject(item), { fileUrl: (p) => p, nowMs: () => 0 })
227
+ engine.seek(0)
228
+ await flush()
229
+ expect(clockInstances).toHaveLength(1)
230
+
231
+ engine.updateProject(videoProject({ ...item, muted: true }))
232
+ await flush()
233
+
234
+ expect(clockInstances).toHaveLength(2)
235
+ expect(clockInstances[0].disposed).toBe(true)
236
+
237
+ engine.dispose()
238
+ })
239
+ })
240
+
241
+ describe('EngineSourceHost.retain — the TRACK\'s volume and mute', () => {
242
+ // The host never learns that tracks exist: the scheduler folds each clip's
243
+ // track into the request before it is built, so everything below is the
244
+ // SAME two branches the clip-level cases above exercise — a live `setVolume`
245
+ // for a volume change, a respawn for a mute change.
246
+
247
+ it('builds the clock at the PRODUCT of the track and clip volumes', async () => {
248
+ // Multiplying, not replacing: a clip mixed down to 0.8 under a track at
249
+ // half stays at 0.4, not at 0.5.
250
+ const engine = createEngine(videoProject(videoItem({ volume: 0.8 }), { volume: 0.5 }), {
251
+ fileUrl: (p) => p,
252
+ nowMs: () => 0,
253
+ })
254
+ engine.seek(0)
255
+ await flush()
256
+ expect(clockInstances[0].volume).toBeCloseTo(0.4, 10)
257
+ engine.dispose()
258
+ })
259
+
260
+ it('keeps the mix between two clips when the track is pulled down', async () => {
261
+ // The property that makes the product rule right: both clips move by the
262
+ // same factor, so the 2:1 balance the editor set survives the track fader.
263
+ const a = videoItem({ id: 'a', start: 0, end: 3, outPoint: 3, volume: 0.8 })
264
+ const b = videoItem({ id: 'b', src: '/media/b.mov', proxySrc: '/proxies/b_proxy.mp4', start: 3, end: 6, inPoint: 0, outPoint: 3, volume: 0.4 })
265
+ const project: Project = {
266
+ id: 'p1',
267
+ status: 'draft',
268
+ settings: { resolution: [1080, 1920], fps: 30 },
269
+ tracks: [{ id: 'trk-0', items: [a, b], volume: 0.5 }],
270
+ } as Project
271
+ const engine = createEngine(project, { fileUrl: (p) => p, nowMs: () => 0 })
272
+
273
+ engine.seek(2) // 'a' active, 'b' inside the prewarm lead — both sessions build
274
+ await flush()
275
+
276
+ expect(clockInstances).toHaveLength(2)
277
+ expect(clockInstances[0].volume).toBeCloseTo(0.4, 10)
278
+ expect(clockInstances[1].volume).toBeCloseTo(0.2, 10)
279
+ expect(clockInstances[0].volume / clockInstances[1].volume).toBeCloseTo(2, 10)
280
+
281
+ engine.dispose()
282
+ })
283
+
284
+ it('leaves the clip volume alone when the track carries no settings', async () => {
285
+ const engine = createEngine(videoProject(videoItem({ volume: 0.8 })), { fileUrl: (p) => p, nowMs: () => 0 })
286
+ engine.seek(0)
287
+ await flush()
288
+ expect(clockInstances[0].volume).toBeCloseTo(0.8, 10)
289
+ engine.dispose()
290
+ })
291
+
292
+ it('mutes a clip on a muted track whatever its own volume says', async () => {
293
+ // `muted` is a construction-time decision (a muted clip runs on a wall
294
+ // clock, not an audio one), so this has to reach `createMasterClock`
295
+ // itself — a volume of 0 would not be the same thing.
296
+ const engine = createEngine(videoProject(videoItem({ volume: 2 }), { muted: true }), {
297
+ fileUrl: (p) => p,
298
+ nowMs: () => 0,
299
+ })
300
+ engine.seek(0)
301
+ await flush()
302
+ expect(clockInstances[0].muted).toBe(true)
303
+ expect(clockInstances[0].volume).toBe(0)
304
+ engine.dispose()
305
+ })
306
+
307
+ it('keeps a muted clip muted on an unmuted track', async () => {
308
+ const engine = createEngine(videoProject(videoItem({ muted: true }), { volume: 1 }), {
309
+ fileUrl: (p) => p,
310
+ nowMs: () => 0,
311
+ })
312
+ engine.seek(0)
313
+ await flush()
314
+ expect(clockInstances[0].muted).toBe(true)
315
+ engine.dispose()
316
+ })
317
+
318
+ it('rebuilds the session when the TRACK mute toggles, exactly as a clip mute does', async () => {
319
+ const item = videoItem({ muted: false })
320
+ const engine = createEngine(videoProject(item), { fileUrl: (p) => p, nowMs: () => 0 })
321
+ engine.seek(0)
322
+ await flush()
323
+ expect(clockInstances).toHaveLength(1)
324
+ expect(clockInstances[0].muted).toBe(false)
325
+
326
+ // Same clip, muted track. Compare with 'rebuilds the session when muted
327
+ // toggles' above: identical outcome, which is the whole point of folding
328
+ // before the request is built rather than teaching `retain` about tracks.
329
+ engine.updateProject(videoProject(item, { muted: true }))
330
+ await flush()
331
+
332
+ expect(clockInstances).toHaveLength(2)
333
+ expect(clockInstances[0].disposed).toBe(true)
334
+ expect(clockInstances[1].muted).toBe(true)
335
+
336
+ engine.dispose()
337
+ })
338
+
339
+ it('pushes a TRACK volume change to the live clock instead of rebuilding', async () => {
340
+ const item = videoItem({ volume: 0.8 })
341
+ const engine = createEngine(videoProject(item, { volume: 1 }), { fileUrl: (p) => p, nowMs: () => 0 })
342
+ engine.seek(0)
343
+ await flush()
344
+ expect(clockInstances).toHaveLength(1)
345
+
346
+ engine.updateProject(videoProject(item, { volume: 0.5 }))
347
+ await flush()
348
+
349
+ expect(clockInstances).toHaveLength(1) // no rebuild — a fader move must not restart the decoder
350
+ expect(clockInstances[0].setVolume).toHaveBeenCalledWith(0.4)
351
+ expect(clockInstances[0].volume).toBeCloseTo(0.4, 10)
352
+
353
+ engine.dispose()
354
+ })
355
+ })
356
+
357
+ describe('EngineSourceHost.abandonDemux — every waiter must abandon before the fetch aborts', () => {
358
+ it('aborts only once the SECOND of two clips sharing an in-flight demux drops (not the first)', () => {
359
+ // Two clips cut from the SAME proxy — the doc's own "fifty clips off one
360
+ // proxy" case — both prewarmed together (b.start - t <= PREWARM_LEAD_S)
361
+ // so a single `retain()` starts both sessions against the SAME in-flight
362
+ // demux fetch. Everything below runs with NO `await` in between: the
363
+ // mocked `demux()` resolves on the microtask queue, so as long as nothing
364
+ // yields, `waiters` stays exactly what both `startSession` calls left it
365
+ // at when both clips are dropped.
366
+ const a = videoItem({ id: 'a', start: 0, end: 3, outPoint: 3 })
367
+ const b = videoItem({ id: 'b', start: 3, end: 6, inPoint: 0, outPoint: 3 })
368
+ const project: Project = {
369
+ id: 'p1',
370
+ status: 'draft',
371
+ settings: { resolution: [1080, 1920], fps: 30 },
372
+ tracks: [{ id: 'trk-0', items: [a, b] }],
373
+ } as Project
374
+ const engine = createEngine(project, { fileUrl: (p) => p, nowMs: () => 0 })
375
+
376
+ engine.seek(2) // clip 'a' active, clip 'b' one second out — inside PREWARM_LEAD_S
377
+ expect(demuxSignals).toHaveLength(1) // one shared in-flight fetch, not two
378
+
379
+ // Drop both clips before the shared demux ever resolves.
380
+ engine.updateProject({ ...project, tracks: [{ id: 'trk-0', items: [] }] } as Project)
381
+
382
+ expect(demuxSignals[0].aborted).toBe(true)
383
+
384
+ engine.dispose()
385
+ })
386
+ })
387
+
388
+ describe('EngineSourceHost.build — server-ref release on a throw after acquireServer', () => {
389
+ it('releases the frame server when createMasterClock throws, instead of leaking the ref', async () => {
390
+ vi.mocked(createMasterClock).mockImplementationOnce(async () => {
391
+ throw new Error('boom')
392
+ })
393
+ const item = videoItem()
394
+ const engine = createEngine(videoProject(item), { fileUrl: (p) => p, nowMs: () => 0 })
395
+ engine.seek(0)
396
+ await flush()
397
+
398
+ // The server was acquired (a worker was actually spun up) before the
399
+ // clock construction failed, and the catch path must release that ref
400
+ // itself — nothing else in the failure path ever gets a chance to,
401
+ // because `session.source` was never assigned.
402
+ expect(serverInstances).toHaveLength(1)
403
+ expect(serverInstances[0].disposed).toBe(true)
404
+
405
+ engine.dispose()
406
+ })
407
+ })
408
+
409
+ // ── T4: shared demux LRU / pinned acquireDemux ──────────────────────────────
410
+ describe('EngineSourceHost.acquirePinnedDemux — shared demux LRU', () => {
411
+ it('coalesces concurrent acquires on the same src (one demux call, one dispose per pin)', async () => {
412
+ const engine = createEngine(videoProject(videoItem()), { fileUrl: (p) => p, nowMs: () => 0 })
413
+ const [a, b] = await Promise.all([
414
+ engine.acquireDemux('/proxies/z.mp4'),
415
+ engine.acquireDemux('/proxies/z.mp4'),
416
+ ])
417
+ // Both pins reference the SAME cached source — the whole point of sharing.
418
+ expect(a.source).toBe(b.source)
419
+ expect(demuxCalls.filter((s) => s === '/proxies/z.mp4')).toHaveLength(1)
420
+
421
+ a.release()
422
+ // Second release is idempotent; the counter drops to zero here.
423
+ b.release()
424
+ b.release()
425
+
426
+ engine.dispose()
427
+ })
428
+
429
+ it('keeps a pinned src warm across a re-acquire even after the pin is released', async () => {
430
+ const engine = createEngine(videoProject(videoItem()), { fileUrl: (p) => p, nowMs: () => 0 })
431
+ const a = await engine.acquireDemux('/proxies/z.mp4')
432
+ a.release()
433
+ // Cache hit on the second acquire — no additional demux.
434
+ const b = await engine.acquireDemux('/proxies/z.mp4')
435
+ expect(demuxCalls.filter((s) => s === '/proxies/z.mp4')).toHaveLength(1)
436
+ b.release()
437
+
438
+ engine.dispose()
439
+ })
440
+
441
+ it('a scheduler session pins the src too — the scrubber releasing does not evict a live server', async () => {
442
+ const item = videoItem()
443
+ const engine = createEngine(videoProject(item), { fileUrl: (p) => p, nowMs: () => 0 })
444
+ engine.seek(0)
445
+ await flush()
446
+ // Take a pin ON TOP of the scheduler's own use.
447
+ const pin = await engine.acquireDemux('/proxies/a_proxy.mp4')
448
+ pin.release()
449
+ // Scheduler session still holds the server, so nothing torn down.
450
+ expect(serverInstances).toHaveLength(1)
451
+ expect(serverInstances[0].disposed).toBe(false)
452
+
453
+ engine.dispose()
454
+ })
455
+ })
@@ -0,0 +1,182 @@
1
+ /**
2
+ * WSOLA time-stretch unit tests.
3
+ *
4
+ * This is a pure array-math primitive with no browser dependency, so unlike the
5
+ * rest of the engine suite it exercises the REAL thing end to end — no mocks.
6
+ * The load-bearing property is pitch preservation: a stretch must change
7
+ * duration WITHOUT changing frequency, which is what separates it from a
8
+ * resample. We measure that with the zero-crossing rate of a pure tone (∝
9
+ * frequency for a sine), asserting the rate survives the stretch rather than
10
+ * scaling with it.
11
+ */
12
+ import { describe, it, expect } from 'vitest'
13
+ import { timeStretch } from '../time-stretch'
14
+
15
+ // ── Fixtures ─────────────────────────────────────────────────────────────────
16
+
17
+ const RATE = 48000
18
+
19
+ /** Mono sine of `freq` Hz for `seconds`, amplitude 0.5. */
20
+ function sine(freq: number, seconds: number, rate = RATE, amp = 0.5): Float32Array {
21
+ const n = Math.round(seconds * rate)
22
+ const out = new Float32Array(n)
23
+ for (let i = 0; i < n; i++) out[i] = amp * Math.sin((2 * Math.PI * freq * i) / rate)
24
+ return out
25
+ }
26
+
27
+ /** Interleave two equal-length mono channels into L,R,L,R,… */
28
+ function interleaveStereo(left: Float32Array, right: Float32Array): Float32Array {
29
+ const n = Math.min(left.length, right.length)
30
+ const out = new Float32Array(n * 2)
31
+ for (let i = 0; i < n; i++) {
32
+ out[i * 2] = left[i]
33
+ out[i * 2 + 1] = right[i]
34
+ }
35
+ return out
36
+ }
37
+
38
+ /** Pull one channel out of an interleaved buffer. */
39
+ function deinterleave(input: Float32Array, channels: number, ch: number): Float32Array {
40
+ const n = Math.floor(input.length / channels)
41
+ const out = new Float32Array(n)
42
+ for (let i = 0; i < n; i++) out[i] = input[i * channels + ch]
43
+ return out
44
+ }
45
+
46
+ /**
47
+ * Zero crossings per sample. For a sine this is proportional to frequency (two
48
+ * crossings per period), so it is our proxy for pitch. Skips the first and last
49
+ * frame's worth of samples, where the window's own ramp lives.
50
+ */
51
+ function zeroCrossingRate(samples: Float32Array): number {
52
+ const margin = 1024
53
+ let crossings = 0
54
+ let counted = 0
55
+ for (let i = margin + 1; i < samples.length - margin; i++) {
56
+ const a = samples[i - 1]
57
+ const b = samples[i]
58
+ if ((a < 0 && b >= 0) || (a >= 0 && b < 0)) crossings++
59
+ counted++
60
+ }
61
+ return counted > 0 ? crossings / counted : 0
62
+ }
63
+
64
+ function maxAbs(samples: Float32Array): number {
65
+ let m = 0
66
+ for (let i = 0; i < samples.length; i++) m = Math.max(m, Math.abs(samples[i]))
67
+ return m
68
+ }
69
+
70
+ function allFinite(samples: Float32Array): boolean {
71
+ for (let i = 0; i < samples.length; i++) if (!Number.isFinite(samples[i])) return false
72
+ return true
73
+ }
74
+
75
+ // ── Tests ────────────────────────────────────────────────────────────────────
76
+
77
+ describe('timeStretch', () => {
78
+ it('returns a faithful copy at factor 1 (identity), not the same buffer', () => {
79
+ const input = sine(440, 0.25)
80
+ const out = timeStretch(input, 1, 1)
81
+ expect(out).not.toBe(input) // a copy, so the caller's buffer is never aliased
82
+ expect(out.length).toBe(input.length)
83
+ for (let i = 0; i < input.length; i++) expect(out[i]).toBe(input[i])
84
+ })
85
+
86
+ it('scales output length by the factor', () => {
87
+ const input = sine(440, 1)
88
+ // Length is exact to the rounding of `inFrames * factor` (a fraction of one
89
+ // sample), so a tight tolerance of one frame (1024) is generous.
90
+ const half = timeStretch(input, 1, 0.5)
91
+ const dbl = timeStretch(input, 1, 2)
92
+ expect(Math.abs(half.length - input.length * 0.5)).toBeLessThan(1024)
93
+ expect(Math.abs(dbl.length - input.length * 2)).toBeLessThan(1024)
94
+ })
95
+
96
+ it('turns silence into silence at both 0.5 and 2.0', () => {
97
+ const silence = new Float32Array(RATE) // 1s of zeros, mono
98
+ for (const factor of [0.5, 2]) {
99
+ const out = timeStretch(silence, 1, factor)
100
+ expect(maxAbs(out)).toBeLessThan(1e-6)
101
+ expect(allFinite(out)).toBe(true)
102
+ }
103
+ })
104
+
105
+ it('preserves pitch (zero-crossing rate unchanged, NOT scaled) when slowed 2x', () => {
106
+ const input = sine(440, 1)
107
+ const out = timeStretch(input, 1, 2)
108
+ const inRate = zeroCrossingRate(input)
109
+ const outRate = zeroCrossingRate(out)
110
+ // Preserved pitch → same per-sample crossing rate. A pitch bug would scale
111
+ // it by the factor (here halve it), which 15% cannot hide.
112
+ expect(Math.abs(outRate - inRate) / inRate).toBeLessThan(0.15)
113
+ })
114
+
115
+ it('preserves pitch (zero-crossing rate unchanged, NOT scaled) when sped up 2x', () => {
116
+ const input = sine(440, 1)
117
+ const out = timeStretch(input, 1, 0.5)
118
+ const inRate = zeroCrossingRate(input)
119
+ const outRate = zeroCrossingRate(out)
120
+ expect(Math.abs(outRate - inRate) / inRate).toBeLessThan(0.15)
121
+ })
122
+
123
+ it('keeps stereo channels separate — L stays L, no bleed into a silent R', () => {
124
+ const left = sine(440, 1)
125
+ const right = new Float32Array(left.length) // silent R
126
+ const input = interleaveStereo(left, right)
127
+
128
+ const out = timeStretch(input, 2, 2)
129
+ expect(out.length).toBeCloseTo(input.length * 2, -3) // ~2x, interleaved
130
+ expect(allFinite(out)).toBe(true)
131
+
132
+ const outL = deinterleave(out, 2, 0)
133
+ const outR = deinterleave(out, 2, 1)
134
+ // L still carries the tone at its original pitch…
135
+ expect(Math.abs(zeroCrossingRate(outL) - zeroCrossingRate(left)) / zeroCrossingRate(left)).toBeLessThan(0.15)
136
+ expect(maxAbs(outL)).toBeGreaterThan(0.3)
137
+ // …and none of it bled into the silent R channel.
138
+ expect(maxAbs(outR)).toBeLessThan(1e-6)
139
+ })
140
+
141
+ it('keeps distinct L/R content on its own channel (no cross-channel bleed)', () => {
142
+ const left = sine(300, 1)
143
+ const right = sine(900, 1)
144
+ const input = interleaveStereo(left, right)
145
+ const out = timeStretch(input, 2, 1.5)
146
+
147
+ const outL = deinterleave(out, 2, 0)
148
+ const outR = deinterleave(out, 2, 1)
149
+ // Each channel keeps its own pitch — a bleed would drag the rates together.
150
+ expect(Math.abs(zeroCrossingRate(outL) - zeroCrossingRate(left)) / zeroCrossingRate(left)).toBeLessThan(0.15)
151
+ expect(Math.abs(zeroCrossingRate(outR) - zeroCrossingRate(right)) / zeroCrossingRate(right)).toBeLessThan(0.15)
152
+ })
153
+
154
+ it('never emits NaN or Infinity across the supported factor range', () => {
155
+ const input = sine(440, 0.5)
156
+ for (const factor of [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4]) {
157
+ const out = timeStretch(input, 1, factor)
158
+ expect(allFinite(out)).toBe(true)
159
+ }
160
+ })
161
+
162
+ it('never emits NaN or Infinity for extreme (clamped) factors', () => {
163
+ const input = sine(440, 0.5)
164
+ for (const factor of [0.05, 8, 32, 1000]) {
165
+ const out = timeStretch(input, 1, factor)
166
+ expect(allFinite(out)).toBe(true)
167
+ }
168
+ })
169
+
170
+ it('returns empty for empty input', () => {
171
+ expect(timeStretch(new Float32Array(0), 1, 2).length).toBe(0)
172
+ expect(timeStretch(new Float32Array(0), 2, 0.5).length).toBe(0)
173
+ })
174
+
175
+ it('throws on a non-finite or non-positive factor', () => {
176
+ const input = sine(440, 0.1)
177
+ expect(() => timeStretch(input, 1, 0)).toThrow()
178
+ expect(() => timeStretch(input, 1, -1)).toThrow()
179
+ expect(() => timeStretch(input, 1, Infinity)).toThrow()
180
+ expect(() => timeStretch(input, 1, NaN)).toThrow()
181
+ })
182
+ })