@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.
- 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 +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- 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 +601 -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 +137 -0
- 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 +2229 -243
- 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/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -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,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where in the timeline an audible scrub position lands.
|
|
3
|
+
*
|
|
4
|
+
* The scrubber ({@link ./scrub-source.ts `createScrubSource`}) must composite
|
|
5
|
+
* over the engine's own decode plan without re-deriving it — same
|
|
6
|
+
* `resolveAt(..., {variant:'preview'})`, same earliest-start-wins tiebreak on
|
|
7
|
+
* track-0 video items, same proxy-only src gate. Duplicating any of that risks
|
|
8
|
+
* drift the moment either side changes, so this builder reuses the exact
|
|
9
|
+
* helpers `scheduler.planTick` calls:
|
|
10
|
+
* • `resolveAt` (timeline-core) — the scene the resolver hands the engine
|
|
11
|
+
* (scheduler.ts:543).
|
|
12
|
+
* • `engineSrcFor` (scheduler.ts:527) — the "engine-decodable proxy" gate,
|
|
13
|
+
* which BOTH catches a missing `proxySrc` AND catches a higher-precedence
|
|
14
|
+
* preview src the engine cannot decode (e.g. `nobg_preview_src`, VP9
|
|
15
|
+
* WebM-with-alpha — SP4 T1). That second half is why the `<video>`
|
|
16
|
+
* fallback is excluded here for free: a fallback project's clips either
|
|
17
|
+
* lack `proxySrc` outright or resolve to a src the engine can't open.
|
|
18
|
+
* • `placeInSource` (scheduler.ts:324) — project-time → source-media
|
|
19
|
+
* seconds, including per-clip speed and loop wrap.
|
|
20
|
+
*
|
|
21
|
+
* Returned `null` (the scrubber stays silent) covers the four cases the plan
|
|
22
|
+
* names: gap between clips, canvas project (no track-0 video items), a clip
|
|
23
|
+
* whose `engineSrcFor` is blocked, and by extension a `<video>`-fallback
|
|
24
|
+
* project.
|
|
25
|
+
*
|
|
26
|
+
* The builder takes a project GETTER, not a bare project. `createScrubSource`
|
|
27
|
+
* has no `setResolve`, so the resolver must stay stable across edits; a
|
|
28
|
+
* closure that reads the latest project on each call keeps the same scrub
|
|
29
|
+
* source instance live as the timeline mutates.
|
|
30
|
+
*/
|
|
31
|
+
import { resolveAt } from '@bycrux/timeline-core'
|
|
32
|
+
import type { EditorProject as Project, VisualItem } from '../schema'
|
|
33
|
+
import { withEnabledItemTracks } from '../video/timeline/timeline-model'
|
|
34
|
+
import { engineSrcFor, placeInSource } from './scheduler'
|
|
35
|
+
import type { ScrubTarget } from './scrub-source'
|
|
36
|
+
|
|
37
|
+
export type ScrubResolver = (projectS: number) => ScrubTarget | null
|
|
38
|
+
|
|
39
|
+
export function createScrubResolver(getProject: () => Project | null | undefined): ScrubResolver {
|
|
40
|
+
return (projectS) => {
|
|
41
|
+
const project = getProject()
|
|
42
|
+
if (!project) return null
|
|
43
|
+
const scene = resolveAt(withEnabledItemTracks(project), projectS, { variant: 'preview' })
|
|
44
|
+
|
|
45
|
+
// Mirror `planTick`'s active-clip loop (scheduler.ts:566-587): filter to
|
|
46
|
+
// track-0 video items with a resolved window, then keep the
|
|
47
|
+
// earliest-start on overlap. `resolveAt` returns items in document order,
|
|
48
|
+
// so the tiebreak has to be explicit.
|
|
49
|
+
let bestItem: VisualItem | null = null
|
|
50
|
+
let bestWindow: NonNullable<(typeof scene.items)[number]['window']> | null = null
|
|
51
|
+
for (const resolved of scene.items) {
|
|
52
|
+
if (resolved.trackIdx !== 0 || resolved.kind !== 'video' || !resolved.window) continue
|
|
53
|
+
const item = resolved.item as unknown as VisualItem
|
|
54
|
+
if (bestItem && (bestItem.start ?? 0) <= (item.start ?? 0)) continue
|
|
55
|
+
bestItem = item
|
|
56
|
+
bestWindow = resolved.window
|
|
57
|
+
}
|
|
58
|
+
if (!bestItem || !bestWindow) return null
|
|
59
|
+
|
|
60
|
+
const usable = engineSrcFor(bestItem, bestWindow)
|
|
61
|
+
if (usable.blocked) return null
|
|
62
|
+
|
|
63
|
+
const placement = placeInSource(bestItem, bestWindow, projectS)
|
|
64
|
+
return { src: usable.src, mediaS: placement.mediaS }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audible drag-scrub source, grain-per-move.
|
|
3
|
+
*
|
|
4
|
+
* Dragging the playhead (the yellow hover cursor, tracked in
|
|
5
|
+
* `../video/hover-scrub.ts`) makes sound, the way a tape jog-wheel does. This
|
|
6
|
+
* module composites over the engine's own graph without disturbing it — it
|
|
7
|
+
* never pushes into the worklet ring.
|
|
8
|
+
*
|
|
9
|
+
* ── Why this does NOT drive the engine's worklet ring ───────────────────────
|
|
10
|
+
* The master clock's audible fast-forward (`audio-clock.ts`) posts stretched
|
|
11
|
+
* PCM into an `AudioWorklet` FIFO kept `RING_SECONDS` (=2s) deep, and that ring
|
|
12
|
+
* only drains while the transport is PLAYING. A drag-scrub happens while the
|
|
13
|
+
* transport is PAUSED, and re-using that ring would mean flushing up to 2s of
|
|
14
|
+
* already-buffered audio on every pointer move — the worst possible latency for
|
|
15
|
+
* a gesture that must feel instant. So each grain plays through a throwaway
|
|
16
|
+
* `AudioBufferSourceNode` on the SAME shared `AudioContext`: a `start()`
|
|
17
|
+
* reaches the speakers in `baseLatency + outputLatency`, the tightest the
|
|
18
|
+
* platform allows.
|
|
19
|
+
*
|
|
20
|
+
* ── Grain-per-move, not rate-follow ─────────────────────────────────────────
|
|
21
|
+
* On each new scrub position we fire ONE short Hann-windowed grain of the
|
|
22
|
+
* source audio at that position, played at natural pitch. Fast drags overlap
|
|
23
|
+
* grains (they simply sum on the shared context); a stationary hover fires
|
|
24
|
+
* nothing. This needs no WSOLA time-stretch — grains are natural-rate windows
|
|
25
|
+
* — so it skips the streaming stretcher's ~43ms block-fill latency entirely,
|
|
26
|
+
* which is exactly why it is tighter than driving the variable-rate path.
|
|
27
|
+
*
|
|
28
|
+
* ── Click-free ──────────────────────────────────────────────────────────────
|
|
29
|
+
* Every grain is multiplied by a full Hann window before playback, so it fades
|
|
30
|
+
* in from and out to zero — no edge discontinuity, no click, whatever position
|
|
31
|
+
* it starts at. A per-grain `GainNode` lets stop-on-release ramp to silence.
|
|
32
|
+
*
|
|
33
|
+
* ── Shared demux LRU ────────────────────────────────────────────────────────
|
|
34
|
+
* Demuxing is expensive; the engine already keeps a per-`src` LRU behind
|
|
35
|
+
* `Engine.acquireDemux` (`./index.ts`). The scrubber holds AT MOST ONE pin at
|
|
36
|
+
* a time — the current src — and swaps it via release-then-acquire when the
|
|
37
|
+
* hover crosses a cut. A src the scheduler is also using is a warm cache hit
|
|
38
|
+
* on both sides; a src only the scrubber touches stays pinned until the next
|
|
39
|
+
* cut releases it, then falls out of the LRU on its own.
|
|
40
|
+
*/
|
|
41
|
+
import { getSharedAudioContext, latencySeconds } from '../video/preview/audio-context'
|
|
42
|
+
import { sampleAtOrBefore, type ChunkSource } from './demux'
|
|
43
|
+
import { normalizeAudioCodec, audioTrackIsDecodable } from './audio-clock'
|
|
44
|
+
import type { AcquiredDemux } from './index'
|
|
45
|
+
import type { HoverScrub } from '../video/hover-scrub'
|
|
46
|
+
|
|
47
|
+
// ── Tuning ───────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** Grain length in source-audio packets (~20ms each for libopus) → ~80ms grain. */
|
|
50
|
+
const GRAIN_PACKETS = 4
|
|
51
|
+
/** Don't fire grains faster than this; a fast drag overlaps them instead. Exported for scrub-source.test.ts. */
|
|
52
|
+
export const THROTTLE_MS = 32
|
|
53
|
+
/** Ignore a scrub move smaller than this (seconds) — a stationary hover is silent. Exported for scrub-source.test.ts. */
|
|
54
|
+
export const MOVE_EPSILON_S = 0.004
|
|
55
|
+
/** Release fade (seconds) so stop-on-release never clicks. Exported for scrub-source.test.ts. */
|
|
56
|
+
export const RELEASE_FADE_S = 0.02
|
|
57
|
+
/** Fallbacks when a container omits its audio params (Opus is 48k by definition). */
|
|
58
|
+
const DEFAULT_RATE = 48000
|
|
59
|
+
const DEFAULT_CHANNELS = 2
|
|
60
|
+
/**
|
|
61
|
+
* Bluetooth output commonly reports 100-300ms of combined output+base
|
|
62
|
+
* latency (see `latencySeconds` in `audio-context.ts`) — a physical wall, not
|
|
63
|
+
* something software can close. Past ~80-100ms a grain reaches the ear so
|
|
64
|
+
* long after the pointer moved that "scrub by ear" no longer reads as
|
|
65
|
+
* instant, so auto-disable rather than ship a version of the feature that
|
|
66
|
+
* feels broken over Bluetooth.
|
|
67
|
+
*/
|
|
68
|
+
const SCRUB_LATENCY_THRESHOLD_S = 0.08
|
|
69
|
+
/**
|
|
70
|
+
* Above this drag speed (media-seconds crossed per wall-clock-second),
|
|
71
|
+
* consecutive grains stop sharing source content: the ~80ms grain
|
|
72
|
+
* (GRAIN_PACKETS) fired every THROTTLE_MS only overlaps its predecessor up to
|
|
73
|
+
* GRAIN_MS/THROTTLE_MS ≈ 80/32 = 2.5x; faster than that each grain samples
|
|
74
|
+
* unrelated media, which is what reads as buzz on a fast drag.
|
|
75
|
+
*/
|
|
76
|
+
const FAST_DRAG_VELOCITY_S_PER_S = 2.5
|
|
77
|
+
/** Never widen the gap past this — even the fastest drag should still jog audibly. */
|
|
78
|
+
const MAX_THROTTLE_MS = 96
|
|
79
|
+
|
|
80
|
+
/** Where in the timeline a scrub position lands, resolved by the host wiring. */
|
|
81
|
+
export interface ScrubTarget {
|
|
82
|
+
/** The clip's editing proxy — exactly what the engine would decode (`item.proxySrc`). */
|
|
83
|
+
src: string
|
|
84
|
+
/** Position inside that source's own timeline, seconds (the `<video>.currentTime` analog). */
|
|
85
|
+
mediaS: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ScrubSourceOptions {
|
|
89
|
+
/**
|
|
90
|
+
* Acquire a shared, pinned demuxed source from the engine's demux LRU. The
|
|
91
|
+
* scrubber releases the returned handle exactly once (on src change or
|
|
92
|
+
* `dispose`), which lets the LRU evict it. Same shape as the scheduler's
|
|
93
|
+
* own reader: a hover across a cut hits a warm cache instead of re-demuxing.
|
|
94
|
+
*/
|
|
95
|
+
acquireDemux: (src: string) => Promise<AcquiredDemux>
|
|
96
|
+
/**
|
|
97
|
+
* Map a project-time scrub position to a decodable source + media position, or
|
|
98
|
+
* `null` when nothing audible is there (a gap, a canvas project, a clip with no
|
|
99
|
+
* engine-decodable proxy — this is where the `<video>` fallback is EXCLUDED).
|
|
100
|
+
* The wiring builds this from timeline-core's `resolveAt`/`sourceWindow`,
|
|
101
|
+
* mirroring `scheduler.ts`'s `planTick` — see `./scrub-resolve.ts`.
|
|
102
|
+
*/
|
|
103
|
+
resolve: (projectS: number) => ScrubTarget | null
|
|
104
|
+
/** Advisory errors (decode/fetch). None stop the scrubber. */
|
|
105
|
+
onError?: (message: string) => void
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ScrubSource {
|
|
109
|
+
/** Turn audible scrubbing on/off. Off by default; caller toggles it. */
|
|
110
|
+
setEnabled(on: boolean): void
|
|
111
|
+
enabled(): boolean
|
|
112
|
+
/** Subscribe to a hover-scrub store; returns an unsubscribe. */
|
|
113
|
+
attach(hover: HoverScrub): () => void
|
|
114
|
+
/** Silence any ringing grains immediately. */
|
|
115
|
+
stop(): void
|
|
116
|
+
dispose(): void
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One demuxed proxy's audio track, plus its shared-demux pin. */
|
|
120
|
+
interface CachedAudio {
|
|
121
|
+
src: string
|
|
122
|
+
pin: AcquiredDemux
|
|
123
|
+
audio: ChunkSource
|
|
124
|
+
decoder: AudioDecoder
|
|
125
|
+
rate: number
|
|
126
|
+
channels: number
|
|
127
|
+
/**
|
|
128
|
+
* Mirrors audio-clock's `feedFailed`: set once this src's decoder throws or
|
|
129
|
+
* reports an error, so a wedged decoder can't hang subsequent grains on the
|
|
130
|
+
* same src — cleared implicitly by `ensureAudio` building a fresh entry
|
|
131
|
+
* (and its own decoder) rather than by resetting the flag in place.
|
|
132
|
+
*/
|
|
133
|
+
decodeFailed: boolean
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Accumulator for the grain currently being decoded — guarded by `seq`. */
|
|
137
|
+
interface PendingGrain {
|
|
138
|
+
seq: number
|
|
139
|
+
planes: Float32Array[][] // per-AudioData: array of channel planes
|
|
140
|
+
frames: number
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Hann window, one full cycle across `n` samples: 0 at the leading edge, 1 at
|
|
145
|
+
* the midpoint. This is the PERIODIC form (`/n`, not `/(n-1)`), so the last
|
|
146
|
+
* sample is NOT zero — it lands wherever `w[1]` does. Module-level and
|
|
147
|
+
* exported so scrub-source.test.ts can assert on it directly without a
|
|
148
|
+
* WebCodecs decode pipeline.
|
|
149
|
+
*/
|
|
150
|
+
export function hannWindow(n: number): Float32Array {
|
|
151
|
+
const w = new Float32Array(n)
|
|
152
|
+
for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / n))
|
|
153
|
+
return w
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Ramp `gain` to zero over `RELEASE_FADE_S` from `now` and schedule `src` to
|
|
158
|
+
* stop at the ramp's end. Pulled out of `stop()`'s loop body and exported so
|
|
159
|
+
* scrub-source.test.ts can assert the release-ramp math on spy nodes without
|
|
160
|
+
* needing a real in-flight grain (which requires a WebCodecs decode).
|
|
161
|
+
*/
|
|
162
|
+
export function releaseGrain(
|
|
163
|
+
src: Pick<AudioBufferSourceNode, 'stop'>,
|
|
164
|
+
gain: Pick<GainNode, 'gain'>,
|
|
165
|
+
now: number,
|
|
166
|
+
): void {
|
|
167
|
+
gain.gain.setValueAtTime(gain.gain.value, now)
|
|
168
|
+
gain.gain.linearRampToValueAtTime(0, now + RELEASE_FADE_S)
|
|
169
|
+
src.stop(now + RELEASE_FADE_S)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function createScrubSource(options: ScrubSourceOptions): ScrubSource {
|
|
173
|
+
const { acquireDemux, resolve, onError } = options
|
|
174
|
+
const ctx = getSharedAudioContext()
|
|
175
|
+
|
|
176
|
+
let on = false
|
|
177
|
+
let disposed = false
|
|
178
|
+
let lastFireMs = 0
|
|
179
|
+
let lastFiredMediaS = Number.NEGATIVE_INFINITY
|
|
180
|
+
let seq = 0
|
|
181
|
+
/** So the Bluetooth-latency guard emits `onError` once per violation, not once per grain. */
|
|
182
|
+
let latencyWarned = false
|
|
183
|
+
|
|
184
|
+
/** The one src whose demux we currently hold. `null` between swaps. */
|
|
185
|
+
let entry: CachedAudio | null = null
|
|
186
|
+
/**
|
|
187
|
+
* An in-flight `acquireDemux` we haven't decided the outcome of yet, plus
|
|
188
|
+
* the src it was for. If a later `ensureAudio(other)` supersedes it, we
|
|
189
|
+
* release the resolved handle rather than swapping it in. Coalesces
|
|
190
|
+
* concurrent grain fires on the same src into one acquire.
|
|
191
|
+
*/
|
|
192
|
+
let pendingLoad: Promise<CachedAudio | null> | null = null
|
|
193
|
+
let pendingSrc: string | null = null
|
|
194
|
+
|
|
195
|
+
/** Grains currently ringing, so `stop()`/release can fade them. */
|
|
196
|
+
const live = new Set<{ src: AudioBufferSourceNode; gain: GainNode }>()
|
|
197
|
+
|
|
198
|
+
function retire(cur: CachedAudio): void {
|
|
199
|
+
try {
|
|
200
|
+
cur.decoder.close()
|
|
201
|
+
} catch {
|
|
202
|
+
/* already closed */
|
|
203
|
+
}
|
|
204
|
+
cur.pin.release()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function ensureAudio(src: string): Promise<CachedAudio | null> {
|
|
208
|
+
if (entry && entry.src === src) return entry
|
|
209
|
+
if (pendingLoad && pendingSrc === src) return pendingLoad
|
|
210
|
+
|
|
211
|
+
pendingSrc = src
|
|
212
|
+
const build = (async (): Promise<CachedAudio | null> => {
|
|
213
|
+
let acquired: AcquiredDemux | null = null
|
|
214
|
+
try {
|
|
215
|
+
acquired = await acquireDemux(src)
|
|
216
|
+
// Superseded (or torn down) while we awaited: give the pin straight back.
|
|
217
|
+
if (disposed || pendingSrc !== src) {
|
|
218
|
+
acquired.release()
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
const audio = acquired.source.audio
|
|
222
|
+
if (!audio || audio.samples.length === 0 || !audioTrackIsDecodable(audio)) {
|
|
223
|
+
acquired.release()
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
const rate = audio.audio?.sampleRate || DEFAULT_RATE
|
|
227
|
+
const channels = audio.audio?.channelCount || DEFAULT_CHANNELS
|
|
228
|
+
const built: CachedAudio = {
|
|
229
|
+
src,
|
|
230
|
+
pin: acquired,
|
|
231
|
+
audio,
|
|
232
|
+
rate,
|
|
233
|
+
channels,
|
|
234
|
+
decoder: null as unknown as AudioDecoder,
|
|
235
|
+
decodeFailed: false,
|
|
236
|
+
}
|
|
237
|
+
const decoder = new AudioDecoder({
|
|
238
|
+
output: (frame) => onDecoded(built, frame),
|
|
239
|
+
error: (err) => {
|
|
240
|
+
// Same wedge signal as a synchronous decode() throw below — a
|
|
241
|
+
// decoder that reports an error mid-flush can't be trusted for
|
|
242
|
+
// the next grain either.
|
|
243
|
+
built.decodeFailed = true
|
|
244
|
+
onError?.(`scrub-source decode: ${err instanceof Error ? err.message : String(err)}`)
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
decoder.configure({
|
|
248
|
+
codec: normalizeAudioCodec(audio.codec),
|
|
249
|
+
description: audio.description,
|
|
250
|
+
sampleRate: rate,
|
|
251
|
+
numberOfChannels: channels,
|
|
252
|
+
})
|
|
253
|
+
built.decoder = decoder
|
|
254
|
+
// Swap in: the old entry's src is no longer the one we want, so its
|
|
255
|
+
// pin has to go before another acquire could evict from the LRU.
|
|
256
|
+
if (entry) retire(entry)
|
|
257
|
+
entry = built
|
|
258
|
+
return built
|
|
259
|
+
} catch (err) {
|
|
260
|
+
if (acquired) acquired.release()
|
|
261
|
+
onError?.(`scrub-source demux: ${err instanceof Error ? err.message : String(err)}`)
|
|
262
|
+
return null
|
|
263
|
+
} finally {
|
|
264
|
+
if (pendingSrc === src) {
|
|
265
|
+
pendingLoad = null
|
|
266
|
+
pendingSrc = null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
})()
|
|
270
|
+
pendingLoad = build
|
|
271
|
+
return build
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The grain currently being filled by decoder output, or null between grains. */
|
|
275
|
+
let pending: PendingGrain | null = null
|
|
276
|
+
|
|
277
|
+
function onDecoded(cur: CachedAudio, frame: AudioData): void {
|
|
278
|
+
try {
|
|
279
|
+
if (!pending) return
|
|
280
|
+
// A late frame from an already-retired decoder must not land in the
|
|
281
|
+
// grain of a newer src.
|
|
282
|
+
if (cur !== entry) return
|
|
283
|
+
const planeCount = frame.numberOfChannels
|
|
284
|
+
const frames = frame.numberOfFrames
|
|
285
|
+
const planes: Float32Array[] = []
|
|
286
|
+
for (let ch = 0; ch < planeCount; ch++) {
|
|
287
|
+
const plane = new Float32Array(frames)
|
|
288
|
+
frame.copyTo(plane, { planeIndex: ch, format: 'f32-planar' })
|
|
289
|
+
planes.push(plane)
|
|
290
|
+
}
|
|
291
|
+
pending.planes.push(planes)
|
|
292
|
+
pending.frames += frames
|
|
293
|
+
} finally {
|
|
294
|
+
frame.close()
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function fireGrain(target: ScrubTarget): Promise<void> {
|
|
299
|
+
const cur = await ensureAudio(target.src)
|
|
300
|
+
if (disposed || !on || !cur) return
|
|
301
|
+
// Src may have moved on while we awaited the acquire.
|
|
302
|
+
if (cur !== entry) return
|
|
303
|
+
// Wedged decoder for this src — bail rather than pile another decode/
|
|
304
|
+
// flush cycle onto it (see `CachedAudio.decodeFailed`).
|
|
305
|
+
if (cur.decodeFailed) return
|
|
306
|
+
|
|
307
|
+
const { audio, decoder, rate, channels } = cur
|
|
308
|
+
const mediaTsUs = audio.firstPresentationTsUs + target.mediaS * 1_000_000
|
|
309
|
+
const startIdx = sampleAtOrBefore(audio, mediaTsUs)
|
|
310
|
+
const endIdx = Math.min(startIdx + GRAIN_PACKETS, audio.samples.length)
|
|
311
|
+
if (endIdx <= startIdx) return
|
|
312
|
+
|
|
313
|
+
// Make the packet bytes resident (no-op on the whole-file path).
|
|
314
|
+
const ready = audio.ensure?.(startIdx, endIdx)
|
|
315
|
+
if (ready) {
|
|
316
|
+
try {
|
|
317
|
+
await ready
|
|
318
|
+
} catch (err) {
|
|
319
|
+
onError?.(`scrub-source fetch: ${err instanceof Error ? err.message : String(err)}`)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
if (disposed || !on || cur !== entry) return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const mySeq = ++seq
|
|
326
|
+
pending = { seq: mySeq, planes: [], frames: 0 }
|
|
327
|
+
try {
|
|
328
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
329
|
+
const s = audio.samples[i]
|
|
330
|
+
decoder.decode(
|
|
331
|
+
new EncodedAudioChunk({ type: 'key', timestamp: s.tsUs, duration: s.durUs, data: s.data }),
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
await decoder.flush()
|
|
335
|
+
} catch (err) {
|
|
336
|
+
cur.decodeFailed = true
|
|
337
|
+
onError?.(`scrub-source decode: ${err instanceof Error ? err.message : String(err)}`)
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
// Superseded by a newer grain while we awaited flush, or turned off.
|
|
341
|
+
if (disposed || !on || !pending || pending.seq !== mySeq) return
|
|
342
|
+
// Or the src changed under us (decoder we flushed is now retired).
|
|
343
|
+
if (cur !== entry) return
|
|
344
|
+
const grain = pending
|
|
345
|
+
pending = null
|
|
346
|
+
if (grain.frames === 0) return
|
|
347
|
+
|
|
348
|
+
play(grain, channels, rate)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function play(grain: PendingGrain, channels: number, rate: number): void {
|
|
352
|
+
const total = grain.frames
|
|
353
|
+
const buffer = ctx.createBuffer(channels, total, rate)
|
|
354
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
355
|
+
const dst = buffer.getChannelData(ch)
|
|
356
|
+
let at = 0
|
|
357
|
+
for (const planes of grain.planes) {
|
|
358
|
+
const plane = planes[Math.min(ch, planes.length - 1)]
|
|
359
|
+
dst.set(plane, at)
|
|
360
|
+
at += plane.length
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Hann across the whole grain → fades in and out to zero, click-free.
|
|
364
|
+
const win = hannWindow(total)
|
|
365
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
366
|
+
const d = buffer.getChannelData(ch)
|
|
367
|
+
for (let i = 0; i < total; i++) d[i] *= win[i]
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const src = ctx.createBufferSource()
|
|
371
|
+
src.buffer = buffer
|
|
372
|
+
const gain = ctx.createGain()
|
|
373
|
+
src.connect(gain)
|
|
374
|
+
gain.connect(ctx.destination)
|
|
375
|
+
const handle = { src, gain }
|
|
376
|
+
live.add(handle)
|
|
377
|
+
src.onended = () => {
|
|
378
|
+
try {
|
|
379
|
+
gain.disconnect()
|
|
380
|
+
} catch {
|
|
381
|
+
/* already gone */
|
|
382
|
+
}
|
|
383
|
+
live.delete(handle)
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
src.start()
|
|
387
|
+
} catch {
|
|
388
|
+
live.delete(handle)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Bluetooth/high-latency guard: read live (a device can change mid-session)
|
|
394
|
+
* rather than only at `setEnabled(true)` time, since a per-grain read is
|
|
395
|
+
* cheap. Warns once per violation via `onError` rather than once per grain.
|
|
396
|
+
*/
|
|
397
|
+
function latencyOk(): boolean {
|
|
398
|
+
const ok = latencySeconds(ctx) <= SCRUB_LATENCY_THRESHOLD_S
|
|
399
|
+
if (ok) {
|
|
400
|
+
latencyWarned = false
|
|
401
|
+
} else if (!latencyWarned) {
|
|
402
|
+
latencyWarned = true
|
|
403
|
+
onError?.('scrub-source: output latency too high, scrub audio disabled (Bluetooth?)')
|
|
404
|
+
}
|
|
405
|
+
return ok
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function onScrub(t: number | null): void {
|
|
409
|
+
if (!on || disposed) return
|
|
410
|
+
if (t === null) {
|
|
411
|
+
stop()
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
if (!latencyOk()) return
|
|
415
|
+
const nowMs = performance.now()
|
|
416
|
+
const dtMs = nowMs - lastFireMs
|
|
417
|
+
if (dtMs < THROTTLE_MS) return
|
|
418
|
+
const moveS = Math.abs(t - lastFiredMediaS)
|
|
419
|
+
if (moveS < MOVE_EPSILON_S) return
|
|
420
|
+
// Velocity-scale the throttle: a fast drag crossing many media-seconds
|
|
421
|
+
// per wall-clock-second widens the gap so fewer, better-spaced grains
|
|
422
|
+
// fire instead of stacking decorrelated ones into buzz (derivation on
|
|
423
|
+
// `FAST_DRAG_VELOCITY_S_PER_S` above).
|
|
424
|
+
const velocity = moveS / (dtMs / 1000)
|
|
425
|
+
if (velocity > FAST_DRAG_VELOCITY_S_PER_S) {
|
|
426
|
+
const scaledThrottle = Math.min(
|
|
427
|
+
MAX_THROTTLE_MS,
|
|
428
|
+
THROTTLE_MS * (velocity / FAST_DRAG_VELOCITY_S_PER_S),
|
|
429
|
+
)
|
|
430
|
+
if (dtMs < scaledThrottle) return
|
|
431
|
+
}
|
|
432
|
+
const target = resolve(t)
|
|
433
|
+
if (!target) return
|
|
434
|
+
lastFireMs = nowMs
|
|
435
|
+
lastFiredMediaS = t
|
|
436
|
+
void fireGrain(target)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function stop(): void {
|
|
440
|
+
const now = ctx.currentTime
|
|
441
|
+
for (const { src, gain } of live) {
|
|
442
|
+
try {
|
|
443
|
+
releaseGrain(src, gain, now)
|
|
444
|
+
} catch {
|
|
445
|
+
/* already stopped */
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
lastFiredMediaS = Number.NEGATIVE_INFINITY
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
setEnabled(next: boolean) {
|
|
453
|
+
on = next
|
|
454
|
+
if (!next) {
|
|
455
|
+
stop()
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
// Surface the Bluetooth/high-latency hint immediately on enable rather
|
|
459
|
+
// than waiting for the first scrub move to discover it.
|
|
460
|
+
latencyOk()
|
|
461
|
+
},
|
|
462
|
+
enabled: () => on,
|
|
463
|
+
attach(hover: HoverScrub) {
|
|
464
|
+
return hover.subscribe(() => onScrub(hover.get()))
|
|
465
|
+
},
|
|
466
|
+
stop,
|
|
467
|
+
dispose() {
|
|
468
|
+
if (disposed) return
|
|
469
|
+
disposed = true
|
|
470
|
+
on = false
|
|
471
|
+
stop()
|
|
472
|
+
if (entry) {
|
|
473
|
+
retire(entry)
|
|
474
|
+
entry = null
|
|
475
|
+
}
|
|
476
|
+
// A load in flight will land in `build`'s superseded branch and
|
|
477
|
+
// release its own pin; nothing else to do here.
|
|
478
|
+
pendingLoad = null
|
|
479
|
+
pendingSrc = null
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Devtools bootstrap. Once the wiring has called this, the user can toggle
|
|
486
|
+
* audible scrubbing live from the console:
|
|
487
|
+
*
|
|
488
|
+
* window.__montajScrubSource.setEnabled(true)
|
|
489
|
+
* window.__montajScrubSource.setEnabled(false)
|
|
490
|
+
*
|
|
491
|
+
* Kept separate from `createScrubSource` so the factory stays testable without
|
|
492
|
+
* a global.
|
|
493
|
+
*/
|
|
494
|
+
export function installScrubSource(source: ScrubSource): void {
|
|
495
|
+
;(window as unknown as { __montajScrubSource?: ScrubSource }).__montajScrubSource = source
|
|
496
|
+
}
|