@bycrux/editor 0.12.0 → 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.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T4 — master-clock unit tests.
|
|
3
|
+
*
|
|
4
|
+
* What is testable here and what is not, stated plainly (plan: "no pretend-
|
|
5
|
+
* integration tests against mocks of the whole browser"): vitest/jsdom has no
|
|
6
|
+
* Web Audio and no WebCodecs, so the real AudioDecoder → ring → worklet
|
|
7
|
+
* pipeline cannot run in this suite at all. It is therefore built so that
|
|
8
|
+
* every DECISION it makes is a pure function tested directly — the project↔
|
|
9
|
+
* media mapping, the extrapolation (including its ceiling), the PCM volume
|
|
10
|
+
* scaling, the resampler's phase carry, the codec normalization — plus the
|
|
11
|
+
* one integration property that survives the absence of the browser: that
|
|
12
|
+
* `createMasterClock` NEVER throws and hands back a coherent wall-clock
|
|
13
|
+
* transport on every failure path. Real decode behavior belongs to T9's
|
|
14
|
+
* operator parity checklist.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, afterEach, vi } from 'vitest'
|
|
17
|
+
import { sourceWindow } from '@bycrux/timeline-core'
|
|
18
|
+
import {
|
|
19
|
+
MAX_EXTRAPOLATION_MS,
|
|
20
|
+
STRETCH_STEP_FRAMES,
|
|
21
|
+
audioTrackIsDecodable,
|
|
22
|
+
createMasterClock,
|
|
23
|
+
createStreamStretch,
|
|
24
|
+
extrapolateSamples,
|
|
25
|
+
mediaTsUsForProjectTime,
|
|
26
|
+
normalizeAudioCodec,
|
|
27
|
+
projectTimeForMediaTsUs,
|
|
28
|
+
projectTimeForSamples,
|
|
29
|
+
resampleInterleaved,
|
|
30
|
+
scaleAndInterleavePlanes,
|
|
31
|
+
type ClipTimebase,
|
|
32
|
+
} from '../audio-clock'
|
|
33
|
+
import type { ChunkSource, SampleRef } from '../demux'
|
|
34
|
+
|
|
35
|
+
// ── Fixtures ────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function packet(tsUs: number, durUs = 20_000): SampleRef {
|
|
38
|
+
return { tsUs, dtsUs: tsUs, durUs, isKey: true, data: new Uint8Array([1]) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function audioTrack(overrides: Partial<ChunkSource> = {}): ChunkSource {
|
|
42
|
+
const samples = [packet(0), packet(20_000), packet(40_000)]
|
|
43
|
+
return {
|
|
44
|
+
kind: 'audio',
|
|
45
|
+
// Verbatim from mp4box, capitalized — the casing IS the SP1 §7.5 bug.
|
|
46
|
+
codec: 'Opus',
|
|
47
|
+
fps: 50,
|
|
48
|
+
durationS: 0.06,
|
|
49
|
+
coded: { width: 0, height: 0 },
|
|
50
|
+
audio: { sampleRate: 48000, channelCount: 2 },
|
|
51
|
+
samples,
|
|
52
|
+
presIndex: samples.map((_, i) => i),
|
|
53
|
+
firstPresentationTsUs: 0,
|
|
54
|
+
...overrides,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const TIMEBASE: ClipTimebase = { start: 0, inPoint: 0, firstPresentationTsUs: 0 }
|
|
59
|
+
|
|
60
|
+
/** jsdom has neither; the guard branch in `createMasterClock` is the common real-world path too. */
|
|
61
|
+
function stubWebCodecsAudioPresent() {
|
|
62
|
+
;(globalThis as unknown as { AudioDecoder: unknown }).AudioDecoder = class {}
|
|
63
|
+
;(globalThis as unknown as { AudioWorkletNode: unknown }).AudioWorkletNode = class {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
delete (globalThis as unknown as { AudioDecoder?: unknown }).AudioDecoder
|
|
68
|
+
delete (globalThis as unknown as { AudioWorkletNode?: unknown }).AudioWorkletNode
|
|
69
|
+
vi.restoreAllMocks()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ── normalizeAudioCodec (SP1 §7.5) ──────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe('normalizeAudioCodec', () => {
|
|
75
|
+
it("lowercases mp4box's capitalized 'Opus' — the SP1 §7.5 bug", () => {
|
|
76
|
+
expect(normalizeAudioCodec('Opus')).toBe('opus')
|
|
77
|
+
// The exact comparison that silently sent every proxy to the wall clock.
|
|
78
|
+
expect('Opus'.startsWith('opus')).toBe(false)
|
|
79
|
+
expect(normalizeAudioCodec('Opus').startsWith('opus')).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('leaves an already-normalized codec alone', () => {
|
|
83
|
+
expect(normalizeAudioCodec('opus')).toBe('opus')
|
|
84
|
+
expect(normalizeAudioCodec('mp4a.40.2')).toBe('mp4a.40.2')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("expands a bare 'mp4a' to AAC-LC, the only form configure() accepts", () => {
|
|
88
|
+
expect(normalizeAudioCodec('mp4a')).toBe('mp4a.40.2')
|
|
89
|
+
expect(normalizeAudioCodec('MP4A')).toBe('mp4a.40.2')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('audioTrackIsDecodable', () => {
|
|
94
|
+
it('accepts any track that carries a decoder description', () => {
|
|
95
|
+
expect(audioTrackIsDecodable(audioTrack({ codec: 'mp4a.40.2', description: new Uint8Array([1, 2]) }))).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('accepts description-less Opus (in-band config) despite mp4box casing', () => {
|
|
99
|
+
expect(audioTrackIsDecodable(audioTrack({ codec: 'Opus', description: undefined }))).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('rejects description-less AAC — the QuickTime-v1 case configure() can never satisfy (SP1 §7.1)', () => {
|
|
103
|
+
expect(audioTrackIsDecodable(audioTrack({ codec: 'mp4a', description: undefined }))).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// ── Project ↔ media time mapping ────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
describe('project-time mapping', () => {
|
|
110
|
+
it('maps media µs through firstPresentationTsUs and clip start/inPoint', () => {
|
|
111
|
+
const tb: ClipTimebase = { start: 4, inPoint: 10, firstPresentationTsUs: 0 }
|
|
112
|
+
// 10s into the file is the clip's own in-point → the clip's first frame.
|
|
113
|
+
expect(projectTimeForMediaTsUs(tb, 10_000_000)).toBeCloseTo(4, 9)
|
|
114
|
+
// 2.5s further in → 2.5s into the clip on the timeline.
|
|
115
|
+
expect(projectTimeForMediaTsUs(tb, 12_500_000)).toBeCloseTo(6.5, 9)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('subtracts a non-zero container origin (media timestamps need not start at 0)', () => {
|
|
119
|
+
// 68333µs is the real first-frame cts the spike measured on its source.
|
|
120
|
+
const tb: ClipTimebase = { start: 0, inPoint: 0, firstPresentationTsUs: 68_333 }
|
|
121
|
+
expect(projectTimeForMediaTsUs(tb, 68_333)).toBeCloseTo(0, 9)
|
|
122
|
+
expect(projectTimeForMediaTsUs(tb, 1_068_333)).toBeCloseTo(1, 9)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('round-trips through mediaTsUsForProjectTime', () => {
|
|
126
|
+
const tb: ClipTimebase = { start: 3.25, inPoint: 7.5, firstPresentationTsUs: 68_333 }
|
|
127
|
+
for (const projectS of [3.25, 4, 9.125, 100]) {
|
|
128
|
+
expect(projectTimeForMediaTsUs(tb, mediaTsUsForProjectTime(tb, projectS))).toBeCloseTo(projectS, 9)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('scales the in-clip offset by per-clip speed (S>1 consumes more source per project-second)', () => {
|
|
133
|
+
const tb: ClipTimebase = { start: 4, inPoint: 10, firstPresentationTsUs: 0, speed: 2 }
|
|
134
|
+
// 1s of project time past the clip start consumes 2s of source at speed 2, so
|
|
135
|
+
// media 12s (2s past inPoint) maps back to 1s into the clip → project 5.
|
|
136
|
+
expect(projectTimeForMediaTsUs(tb, 12_000_000)).toBeCloseTo(5, 9)
|
|
137
|
+
// Forward: 1s into the clip → 2s past inPoint = media 12s.
|
|
138
|
+
expect(mediaTsUsForProjectTime(tb, 5)).toBeCloseTo(12_000_000, 3)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('round-trips at speed 1, 2 and 0.5 (mappers stay exact inverses under speed)', () => {
|
|
142
|
+
for (const speed of [1, 2, 0.5]) {
|
|
143
|
+
const tb: ClipTimebase = { start: 3.25, inPoint: 7.5, firstPresentationTsUs: 68_333, speed }
|
|
144
|
+
for (const projectS of [3.25, 4, 9.125, 100]) {
|
|
145
|
+
expect(projectTimeForMediaTsUs(tb, mediaTsUsForProjectTime(tb, projectS))).toBeCloseTo(projectS, 9)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('an absent speed is bit-identical to speed 1 (strict no-op default)', () => {
|
|
151
|
+
const without: ClipTimebase = { start: 2, inPoint: 5, firstPresentationTsUs: 100 }
|
|
152
|
+
const withOne: ClipTimebase = { ...without, speed: 1 }
|
|
153
|
+
for (const mediaTsUs of [100, 5_000_100, 12_345_678]) {
|
|
154
|
+
expect(projectTimeForMediaTsUs(without, mediaTsUs)).toBe(projectTimeForMediaTsUs(withOne, mediaTsUs))
|
|
155
|
+
}
|
|
156
|
+
for (const projectS of [2, 7.5, 42]) {
|
|
157
|
+
expect(mediaTsUsForProjectTime(without, projectS)).toBe(mediaTsUsForProjectTime(withOne, projectS))
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('reproduces useVideoPlayback\'s formula for a proxy clip (full-source src, no rebase)', () => {
|
|
162
|
+
// The legacy hook computes: projectT = clip.start + (video.currentTime - effectiveInPoint(clip)).
|
|
163
|
+
// `video.currentTime` is time in the LOADED file — which for the engine is
|
|
164
|
+
// (mediaTsUs - firstPresentationTsUs) / 1e6.
|
|
165
|
+
const clip = { start: 12, end: 20, inPoint: 30, src: '/orig.mov', proxySrc: '/orig_proxy_hable1.mp4' }
|
|
166
|
+
const win = sourceWindow(clip, 'preview')
|
|
167
|
+
expect(win.src).toBe('/orig_proxy_hable1.mp4')
|
|
168
|
+
expect(win.inPoint).toBe(30) // the proxy covers the FULL source — never rebased
|
|
169
|
+
|
|
170
|
+
const tb: ClipTimebase = { start: clip.start, inPoint: win.inPoint, firstPresentationTsUs: 0 }
|
|
171
|
+
const fileTimeS = 33.5
|
|
172
|
+
const legacy = clip.start + (fileTimeS - win.inPoint)
|
|
173
|
+
expect(projectTimeForMediaTsUs(tb, fileTimeS * 1e6)).toBeCloseTo(legacy, 9)
|
|
174
|
+
expect(legacy).toBeCloseTo(15.5, 9)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("reproduces the same formula for a normalizedSrc window cache (rebased in-point)", () => {
|
|
178
|
+
// The cache plays from its own 0, so effectiveInPoint rebases — using the
|
|
179
|
+
// raw clip.inPoint here would land every seek 496.92s off.
|
|
180
|
+
const clip = { start: 2, end: 6, inPoint: 496.92, normalizedSrc: '/cache/window.mp4' }
|
|
181
|
+
const win = sourceWindow(clip, 'preview')
|
|
182
|
+
expect(win.src).toBe('/cache/window.mp4')
|
|
183
|
+
expect(win.inPoint).toBe(0)
|
|
184
|
+
|
|
185
|
+
const tb: ClipTimebase = { start: clip.start, inPoint: win.inPoint, firstPresentationTsUs: 0 }
|
|
186
|
+
const fileTimeS = 1.5
|
|
187
|
+
expect(projectTimeForMediaTsUs(tb, fileTimeS * 1e6)).toBeCloseTo(clip.start + fileTimeS - win.inPoint, 9)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// ── Clock extrapolation ─────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
describe('extrapolateSamples', () => {
|
|
194
|
+
it('returns the reported count when no time has passed', () => {
|
|
195
|
+
expect(extrapolateSamples(4800, 0, 48000)).toBe(4800)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('advances at the sample rate between the worklet\'s ~10Hz reports', () => {
|
|
199
|
+
// 50ms into a 100ms report gap: half a report interval of samples.
|
|
200
|
+
expect(extrapolateSamples(4800, 50, 48000)).toBeCloseTo(4800 + 2400, 9)
|
|
201
|
+
expect(extrapolateSamples(0, 100, 48000)).toBeCloseTo(4800, 9)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('is a pure time→samples conversion at any sample rate', () => {
|
|
205
|
+
expect(extrapolateSamples(0, 100, 44100)).toBeCloseTo(4410, 9)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('clamps at the ceiling so a suspended context stalls instead of running away', () => {
|
|
209
|
+
const capped = extrapolateSamples(0, 60_000, 48000)
|
|
210
|
+
expect(capped).toBeCloseTo((MAX_EXTRAPOLATION_MS / 1000) * 48000, 9)
|
|
211
|
+
// One minute suspended must not read as one minute of playback.
|
|
212
|
+
expect(capped).toBeLessThan(48000)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('clamps a backwards clock to zero elapsed', () => {
|
|
216
|
+
expect(extrapolateSamples(4800, -500, 48000)).toBe(4800)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('projectTimeForSamples', () => {
|
|
221
|
+
it('converts rendered frames to project seconds off the ring anchor', () => {
|
|
222
|
+
expect(projectTimeForSamples(5, 48000, 48000)).toBeCloseTo(6, 9)
|
|
223
|
+
expect(projectTimeForSamples(0, 0, 48000)).toBe(0)
|
|
224
|
+
expect(projectTimeForSamples(2.5, 22050, 44100)).toBeCloseTo(3, 9)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('composes with extrapolateSamples into a continuous reading between reports', () => {
|
|
228
|
+
const rate = 48000
|
|
229
|
+
// Last report said 1s of audio had played; 50ms of wall time has passed.
|
|
230
|
+
const projected = extrapolateSamples(rate, 50, rate)
|
|
231
|
+
expect(projectTimeForSamples(10, projected, rate)).toBeCloseTo(11.05, 9)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('advances project time at the transport rate R (default 1)', () => {
|
|
235
|
+
// 1s of rendered audio is 2s of project time at R=2, 0.5s at R=0.5, 1s at R=1.
|
|
236
|
+
expect(projectTimeForSamples(0, 48000, 48000, 2)).toBeCloseTo(2, 9)
|
|
237
|
+
expect(projectTimeForSamples(0, 48000, 48000, 0.5)).toBeCloseTo(0.5, 9)
|
|
238
|
+
expect(projectTimeForSamples(0, 48000, 48000)).toBeCloseTo(1, 9)
|
|
239
|
+
// R scales only the sample term, off the anchor.
|
|
240
|
+
expect(projectTimeForSamples(10, 22050, 44100, 2)).toBeCloseTo(11, 9)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// ── PCM scaling ─────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
describe('scaleAndInterleavePlanes', () => {
|
|
247
|
+
const left = new Float32Array([0.1, 0.2, 0.3])
|
|
248
|
+
const right = new Float32Array([-0.1, -0.2, -0.3])
|
|
249
|
+
|
|
250
|
+
it('interleaves planes as [L0,R0,L1,R1,…]', () => {
|
|
251
|
+
expect(Array.from(scaleAndInterleavePlanes([left, right], 3, 1))).toEqual([
|
|
252
|
+
0.1, -0.1, 0.2, -0.2, 0.3, -0.3,
|
|
253
|
+
].map((v) => Math.fround(v)))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('volume 1.0 is a pure interleave (no level change)', () => {
|
|
257
|
+
const out = scaleAndInterleavePlanes([left], 3, 1)
|
|
258
|
+
expect(Array.from(out)).toEqual(Array.from(left))
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('volume 0 (a muted clip) produces silence, not a skipped chunk', () => {
|
|
262
|
+
const out = scaleAndInterleavePlanes([left, right], 3, 0)
|
|
263
|
+
expect(out.length).toBe(6)
|
|
264
|
+
// `-0` is a legitimate product of scaling a negative sample by 0 and is
|
|
265
|
+
// bit-identical silence to a device; compare by value, not by sign bit.
|
|
266
|
+
expect(Array.from(out).every((v) => v === 0)).toBe(true)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('attenuates below 1.0', () => {
|
|
270
|
+
const out = scaleAndInterleavePlanes([new Float32Array([0.5]), new Float32Array([1])], 1, 0.5)
|
|
271
|
+
expect(Array.from(out)).toEqual([Math.fround(0.25), Math.fround(0.5)])
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('amplifies above 1.0 and does NOT clamp — parity with the legacy GainNode', () => {
|
|
275
|
+
// The schema allows volume up to 2.0. A GainNode at 2.0 emits >1.0 samples
|
|
276
|
+
// too and lets AudioDestinationNode do the clipping; clamping here would
|
|
277
|
+
// make the engine quieter than the legacy player on amplified clips.
|
|
278
|
+
const out = scaleAndInterleavePlanes([new Float32Array([0.8, -0.9])], 2, 2)
|
|
279
|
+
expect(Array.from(out)).toEqual([Math.fround(1.6), Math.fround(-1.8)])
|
|
280
|
+
expect(out[0]).toBeGreaterThan(1)
|
|
281
|
+
expect(out[1]).toBeLessThan(-1)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('handles a mono source (one plane) without inventing a second channel', () => {
|
|
285
|
+
const out = scaleAndInterleavePlanes([new Float32Array([1, 2, 3])], 3, 1)
|
|
286
|
+
expect(out.length).toBe(3)
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// ── Resampling (shared-context rate mismatch) ───────────────────────────────
|
|
291
|
+
|
|
292
|
+
describe('resampleInterleaved', () => {
|
|
293
|
+
it('is a zero-cost identity when the context already runs at the decoded rate', () => {
|
|
294
|
+
const input = new Float32Array([1, 2, 3, 4])
|
|
295
|
+
const out = resampleInterleaved(input, 2, 1, 0)
|
|
296
|
+
expect(out.pcm).toBe(input) // same reference — no copy on the common path
|
|
297
|
+
expect(out.phase).toBe(0)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('halves the frame count when the render rate is half the decoded rate', () => {
|
|
301
|
+
const input = new Float32Array([0, 1, 2, 3, 4, 5, 6, 7])
|
|
302
|
+
const out = resampleInterleaved(input, 1, 2, 0)
|
|
303
|
+
expect(Array.from(out.pcm)).toEqual([0, 2, 4, 6])
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('interpolates between input frames rather than dropping to nearest', () => {
|
|
307
|
+
const input = new Float32Array([0, 1])
|
|
308
|
+
const out = resampleInterleaved(input, 1, 0.5, 0)
|
|
309
|
+
// positions 0, 0.5, 1(clamped tail), 1.5(clamped tail)
|
|
310
|
+
expect(out.pcm[0]).toBeCloseTo(0, 6)
|
|
311
|
+
expect(out.pcm[1]).toBeCloseTo(0.5, 6)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('carries phase across chunks so 48kHz→44.1kHz does not drift', () => {
|
|
315
|
+
const ratio = 48000 / 44100
|
|
316
|
+
const chunkFrames = 1024
|
|
317
|
+
const chunks = 40
|
|
318
|
+
let phase = 0
|
|
319
|
+
let outFrames = 0
|
|
320
|
+
for (let c = 0; c < chunks; c++) {
|
|
321
|
+
const out = resampleInterleaved(new Float32Array(chunkFrames * 2), 2, ratio, phase)
|
|
322
|
+
phase = out.phase
|
|
323
|
+
outFrames += out.pcm.length / 2
|
|
324
|
+
}
|
|
325
|
+
const expected = (chunks * chunkFrames * 44100) / 48000
|
|
326
|
+
// Without the carried phase this accumulates a whole frame of error per
|
|
327
|
+
// chunk (~40 frames here) instead of staying inside one.
|
|
328
|
+
expect(Math.abs(outFrames - expected)).toBeLessThanOrEqual(1)
|
|
329
|
+
expect(phase).toBeGreaterThanOrEqual(0)
|
|
330
|
+
expect(phase).toBeLessThan(ratio)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('returns an empty buffer and preserves phase for an empty chunk', () => {
|
|
334
|
+
const out = resampleInterleaved(new Float32Array(0), 2, 1.5, 0.25)
|
|
335
|
+
expect(out.pcm.length).toBe(0)
|
|
336
|
+
expect(out.phase).toBe(0.25)
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// ── createMasterClock: never throws, always coherent ────────────────────────
|
|
341
|
+
|
|
342
|
+
describe('createMasterClock — fallback selection', () => {
|
|
343
|
+
it('falls back for a canvas project (no audio track at all)', async () => {
|
|
344
|
+
const clock = await createMasterClock({ audio: null, timebase: TIMEBASE, startProjectS: 0 })
|
|
345
|
+
expect(clock.kind).toBe('fallback')
|
|
346
|
+
expect(clock.reason).toBe('no audio track')
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('falls back for a track that demuxed zero packets', async () => {
|
|
350
|
+
const clock = await createMasterClock({
|
|
351
|
+
audio: audioTrack({ samples: [], presIndex: [] }),
|
|
352
|
+
timebase: TIMEBASE,
|
|
353
|
+
startProjectS: 0,
|
|
354
|
+
})
|
|
355
|
+
expect(clock.kind).toBe('fallback')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('falls back for a muted clip — there is nothing to sync to', async () => {
|
|
359
|
+
stubWebCodecsAudioPresent()
|
|
360
|
+
const clock = await createMasterClock({
|
|
361
|
+
audio: audioTrack(),
|
|
362
|
+
timebase: TIMEBASE,
|
|
363
|
+
startProjectS: 0,
|
|
364
|
+
muted: true,
|
|
365
|
+
})
|
|
366
|
+
expect(clock.kind).toBe('fallback')
|
|
367
|
+
expect(clock.reason).toBe('clip is muted')
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('falls back when the browser has no WebCodecs audio (jsdom, and real browsers)', async () => {
|
|
371
|
+
const clock = await createMasterClock({ audio: audioTrack(), timebase: TIMEBASE, startProjectS: 0 })
|
|
372
|
+
expect(clock.kind).toBe('fallback')
|
|
373
|
+
expect(clock.reason).toMatch(/unavailable/)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('falls back for description-less AAC without ever calling configure() (SP1 §7.1)', async () => {
|
|
377
|
+
stubWebCodecsAudioPresent()
|
|
378
|
+
const clock = await createMasterClock({
|
|
379
|
+
audio: audioTrack({ codec: 'mp4a', description: undefined }),
|
|
380
|
+
timebase: TIMEBASE,
|
|
381
|
+
startProjectS: 0,
|
|
382
|
+
})
|
|
383
|
+
expect(clock.kind).toBe('fallback')
|
|
384
|
+
expect(clock.reason).toMatch(/no decoder description/)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('degrades to wall-clock (never rejects) when the audio graph itself fails to build', async () => {
|
|
388
|
+
// WebCodecs audio present but no Web Audio at all: getSharedAudioContext
|
|
389
|
+
// throws inside the audio path. The caller only ever awaits this function.
|
|
390
|
+
stubWebCodecsAudioPresent()
|
|
391
|
+
const onError = vi.fn()
|
|
392
|
+
const clock = await createMasterClock({
|
|
393
|
+
audio: audioTrack(),
|
|
394
|
+
timebase: TIMEBASE,
|
|
395
|
+
startProjectS: 0,
|
|
396
|
+
onError,
|
|
397
|
+
})
|
|
398
|
+
expect(clock.kind).toBe('fallback')
|
|
399
|
+
expect(clock.reason).toBeTruthy()
|
|
400
|
+
expect(onError).toHaveBeenCalledTimes(1)
|
|
401
|
+
expect(onError.mock.calls[0][0]).toMatch(/audio clock:/)
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
describe('createMasterClock — fallback transport coherence', () => {
|
|
406
|
+
async function fallbackClock(startProjectS: number, nowRef: { t: number }) {
|
|
407
|
+
const clock = await createMasterClock({
|
|
408
|
+
audio: null,
|
|
409
|
+
timebase: TIMEBASE,
|
|
410
|
+
startProjectS,
|
|
411
|
+
nowMs: () => nowRef.t,
|
|
412
|
+
})
|
|
413
|
+
expect(clock.kind).toBe('fallback')
|
|
414
|
+
return clock
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
it('starts parked at startProjectS and does not advance until play()', async () => {
|
|
418
|
+
const now = { t: 1000 }
|
|
419
|
+
const clock = await fallbackClock(2, now)
|
|
420
|
+
expect(clock.playing).toBe(false)
|
|
421
|
+
now.t += 5000
|
|
422
|
+
expect(clock.now()).toBe(2)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('advances in real time while playing', async () => {
|
|
426
|
+
const now = { t: 0 }
|
|
427
|
+
const clock = await fallbackClock(2, now)
|
|
428
|
+
clock.play()
|
|
429
|
+
expect(clock.playing).toBe(true)
|
|
430
|
+
now.t += 1500
|
|
431
|
+
expect(clock.now()).toBeCloseTo(3.5, 9)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('freezes where it read on pause and resumes from there', async () => {
|
|
435
|
+
const now = { t: 0 }
|
|
436
|
+
const clock = await fallbackClock(0, now)
|
|
437
|
+
clock.play()
|
|
438
|
+
now.t += 1000
|
|
439
|
+
clock.pause()
|
|
440
|
+
expect(clock.playing).toBe(false)
|
|
441
|
+
now.t += 10_000 // a long think
|
|
442
|
+
expect(clock.now()).toBeCloseTo(1, 9)
|
|
443
|
+
clock.play()
|
|
444
|
+
now.t += 500
|
|
445
|
+
expect(clock.now()).toBeCloseTo(1.5, 9)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('play() and pause() are idempotent', async () => {
|
|
449
|
+
const now = { t: 0 }
|
|
450
|
+
const clock = await fallbackClock(0, now)
|
|
451
|
+
clock.play()
|
|
452
|
+
now.t += 1000
|
|
453
|
+
clock.play() // must not re-base the start
|
|
454
|
+
expect(clock.now()).toBeCloseTo(1, 9)
|
|
455
|
+
clock.pause()
|
|
456
|
+
clock.pause()
|
|
457
|
+
now.t += 1000
|
|
458
|
+
expect(clock.now()).toBeCloseTo(1, 9)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('seeks while paused without starting playback', async () => {
|
|
462
|
+
const now = { t: 0 }
|
|
463
|
+
const clock = await fallbackClock(0, now)
|
|
464
|
+
clock.seek(12.25)
|
|
465
|
+
expect(clock.now()).toBe(12.25)
|
|
466
|
+
expect(clock.playing).toBe(false)
|
|
467
|
+
now.t += 3000
|
|
468
|
+
expect(clock.now()).toBe(12.25)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('seeks during playback and keeps playing from the target', async () => {
|
|
472
|
+
const now = { t: 0 }
|
|
473
|
+
const clock = await fallbackClock(0, now)
|
|
474
|
+
clock.play()
|
|
475
|
+
now.t += 2000
|
|
476
|
+
clock.seek(30)
|
|
477
|
+
expect(clock.playing).toBe(true)
|
|
478
|
+
expect(clock.now()).toBeCloseTo(30, 9)
|
|
479
|
+
now.t += 250
|
|
480
|
+
expect(clock.now()).toBeCloseTo(30.25, 9)
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('freezes the reading on dispose instead of snapping back to the anchor', async () => {
|
|
484
|
+
const now = { t: 0 }
|
|
485
|
+
const clock = await fallbackClock(0, now)
|
|
486
|
+
clock.play()
|
|
487
|
+
now.t += 4000
|
|
488
|
+
clock.dispose()
|
|
489
|
+
expect(clock.playing).toBe(false)
|
|
490
|
+
now.t += 4000
|
|
491
|
+
expect(clock.now()).toBeCloseTo(4, 9)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('changes transport rate mid-playback with no discontinuity, then advances at the new rate', async () => {
|
|
495
|
+
const now = { t: 0 }
|
|
496
|
+
const clock = await fallbackClock(0, now)
|
|
497
|
+
clock.play()
|
|
498
|
+
now.t += 1000 // 1s at 1× → project 1
|
|
499
|
+
expect(clock.now()).toBeCloseTo(1, 9)
|
|
500
|
+
clock.setTransportRate(2)
|
|
501
|
+
// Continuous: no jump at the instant of the change.
|
|
502
|
+
expect(clock.now()).toBeCloseTo(1, 9)
|
|
503
|
+
now.t += 1000 // 1s at 2× → +2 → project 3
|
|
504
|
+
expect(clock.now()).toBeCloseTo(3, 9)
|
|
505
|
+
clock.setTransportRate(0.5)
|
|
506
|
+
expect(clock.now()).toBeCloseTo(3, 9)
|
|
507
|
+
now.t += 1000 // 1s at 0.5× → +0.5 → project 3.5
|
|
508
|
+
expect(clock.now()).toBeCloseTo(3.5, 9)
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('a rate change while paused takes effect on resume, with no jump', async () => {
|
|
512
|
+
const now = { t: 0 }
|
|
513
|
+
const clock = await fallbackClock(5, now)
|
|
514
|
+
clock.setTransportRate(3) // paused: nothing moves yet
|
|
515
|
+
expect(clock.now()).toBe(5)
|
|
516
|
+
now.t += 10_000
|
|
517
|
+
expect(clock.now()).toBe(5)
|
|
518
|
+
clock.play()
|
|
519
|
+
now.t += 1000 // 1s at 3× → +3
|
|
520
|
+
expect(clock.now()).toBeCloseTo(8, 9)
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('seek keeps the current transport rate', async () => {
|
|
524
|
+
const now = { t: 0 }
|
|
525
|
+
const clock = await fallbackClock(0, now)
|
|
526
|
+
clock.setTransportRate(2)
|
|
527
|
+
clock.play()
|
|
528
|
+
now.t += 500 // +1 → 1
|
|
529
|
+
expect(clock.now()).toBeCloseTo(1, 9)
|
|
530
|
+
clock.seek(10)
|
|
531
|
+
expect(clock.now()).toBeCloseTo(10, 9)
|
|
532
|
+
now.t += 500 // still 2× → +1 → 11
|
|
533
|
+
expect(clock.now()).toBeCloseTo(11, 9)
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('reports fallback stats with no ring numbers to invent', async () => {
|
|
537
|
+
const now = { t: 0 }
|
|
538
|
+
const clock = await fallbackClock(0, now)
|
|
539
|
+
clock.play()
|
|
540
|
+
expect(clock.stats()).toEqual({
|
|
541
|
+
kind: 'fallback',
|
|
542
|
+
reason: 'no audio track',
|
|
543
|
+
playing: true,
|
|
544
|
+
samplesConsumed: 0,
|
|
545
|
+
underrunFrames: 0,
|
|
546
|
+
queuedFrames: 0,
|
|
547
|
+
queuedSeconds: 0,
|
|
548
|
+
})
|
|
549
|
+
})
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
// ── Streaming time-stretch (the createAudioClock output-callback wrapper) ─────
|
|
553
|
+
|
|
554
|
+
describe('createStreamStretch', () => {
|
|
555
|
+
const RATE = 48000
|
|
556
|
+
|
|
557
|
+
/** Mono sine of `freq` Hz, `frames` samples, amplitude 0.5. */
|
|
558
|
+
function sine(freq: number, frames: number, offset = 0): Float32Array {
|
|
559
|
+
const out = new Float32Array(frames)
|
|
560
|
+
for (let i = 0; i < frames; i++) out[i] = 0.5 * Math.sin((2 * Math.PI * freq * (i + offset)) / RATE)
|
|
561
|
+
return out
|
|
562
|
+
}
|
|
563
|
+
const finite = (s: Float32Array) => s.every((v) => Number.isFinite(v))
|
|
564
|
+
const maxAbs = (s: Float32Array) => s.reduce((m, v) => Math.max(m, Math.abs(v)), 0)
|
|
565
|
+
|
|
566
|
+
/** Feed a mono signal in `chunk`-frame pieces, concatenating everything emitted. */
|
|
567
|
+
function feedInChunks(freq: number, totalFrames: number, factor: number, chunk = 960): Float32Array {
|
|
568
|
+
const s = createStreamStretch(1, factor)
|
|
569
|
+
const pieces: Float32Array[] = []
|
|
570
|
+
let produced = 0
|
|
571
|
+
for (let off = 0; off < totalFrames; off += chunk) {
|
|
572
|
+
const n = Math.min(chunk, totalFrames - off)
|
|
573
|
+
const out = s.push(sine(freq, n, off))
|
|
574
|
+
if (out.length > 0) {
|
|
575
|
+
pieces.push(out)
|
|
576
|
+
produced += out.length
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const all = new Float32Array(produced)
|
|
580
|
+
let at = 0
|
|
581
|
+
for (const p of pieces) {
|
|
582
|
+
all.set(p, at)
|
|
583
|
+
at += p.length
|
|
584
|
+
}
|
|
585
|
+
return all
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
it('is a zero-copy identity at factor 1 — the strict-bypass guarantee', () => {
|
|
589
|
+
const s = createStreamStretch(1, 1)
|
|
590
|
+
const chunks = [sine(440, 960), sine(440, 960, 960), sine(440, 300, 1920)]
|
|
591
|
+
for (const c of chunks) {
|
|
592
|
+
const out = s.push(c)
|
|
593
|
+
// Same reference back: no buffering, no copy, no added latency.
|
|
594
|
+
expect(out).toBe(c)
|
|
595
|
+
}
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
it('total output length ≈ input · factor across rates (chunks smaller than one WSOLA frame)', () => {
|
|
599
|
+
// 960-frame chunks are smaller than the 1024 analysis frame — the whole
|
|
600
|
+
// reason a streaming wrapper is needed rather than a per-chunk stretch.
|
|
601
|
+
const total = 40 * STRETCH_STEP_FRAMES // large enough that block-edge loss is a small %
|
|
602
|
+
for (const factor of [0.25, 0.5, 2, 4]) {
|
|
603
|
+
const out = feedInChunks(440, total, factor)
|
|
604
|
+
const ratio = out.length / (total * factor)
|
|
605
|
+
// Short by up to ~(context + one leftover block)·factor, negligible at this size.
|
|
606
|
+
expect(ratio).toBeGreaterThan(0.9)
|
|
607
|
+
expect(ratio).toBeLessThan(1.05)
|
|
608
|
+
}
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('preserves amplitude and never emits NaN/Infinity when slowed or sped', () => {
|
|
612
|
+
for (const factor of [0.5, 2]) {
|
|
613
|
+
const out = feedInChunks(440, 20 * STRETCH_STEP_FRAMES, factor)
|
|
614
|
+
expect(finite(out)).toBe(true)
|
|
615
|
+
// Unity-gain overlap-add of an amplitude-0.5 tone stays near 0.5 — not
|
|
616
|
+
// collapsed to silence (a broken crossfade) nor blown past unity.
|
|
617
|
+
expect(maxAbs(out)).toBeGreaterThan(0.3)
|
|
618
|
+
expect(maxAbs(out)).toBeLessThan(0.9)
|
|
619
|
+
}
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('keeps stereo channels interleaved and finite', () => {
|
|
623
|
+
const s = createStreamStretch(2, 0.5)
|
|
624
|
+
// Interleave a 300Hz L with a silent R.
|
|
625
|
+
const frames = 8 * STRETCH_STEP_FRAMES
|
|
626
|
+
const inter = new Float32Array(frames * 2)
|
|
627
|
+
for (let i = 0; i < frames; i++) inter[i * 2] = 0.5 * Math.sin((2 * Math.PI * 300 * i) / RATE)
|
|
628
|
+
const out = s.push(inter)
|
|
629
|
+
expect(out.length % 2).toBe(0)
|
|
630
|
+
expect(finite(out)).toBe(true)
|
|
631
|
+
expect(out.length).toBeGreaterThan(0)
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it('reset() drops buffered input so a partial block never leaks across it', () => {
|
|
635
|
+
const s = createStreamStretch(1, 0.5)
|
|
636
|
+
// Under one block: buffered, nothing emitted yet.
|
|
637
|
+
expect(s.push(sine(440, 500)).length).toBe(0)
|
|
638
|
+
s.reset()
|
|
639
|
+
// With the pre-reset 500 dropped, this sub-block push must still buffer, not
|
|
640
|
+
// complete a block by combining with stale input.
|
|
641
|
+
const out = s.push(sine(440, STRETCH_STEP_FRAMES - 500))
|
|
642
|
+
expect(out.length).toBe(0)
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it('adopts a new factor mid-stream without crashing and stays finite', () => {
|
|
646
|
+
const s = createStreamStretch(1, 2)
|
|
647
|
+
const a = s.push(sine(440, 4 * STRETCH_STEP_FRAMES))
|
|
648
|
+
s.setFactor(0.5)
|
|
649
|
+
const b = s.push(sine(440, 4 * STRETCH_STEP_FRAMES, 4 * STRETCH_STEP_FRAMES))
|
|
650
|
+
expect(finite(a)).toBe(true)
|
|
651
|
+
expect(finite(b)).toBe(true)
|
|
652
|
+
expect(a.length).toBeGreaterThan(0)
|
|
653
|
+
expect(b.length).toBeGreaterThan(0)
|
|
654
|
+
})
|
|
655
|
+
})
|