Skip to content

Commit 3288c1f

Browse files
perf(web): cut RAF handler from 62ms to single-frame budget
"[Violation] requestAnimationFrame handler took 62ms" was Timeline's paintPlayhead reading clientWidth + scrollLeft on every frame — forced-layout reads inside the RAF body — plus an unnecessary textContent write to the elapsed-time counter at 60fps. Timeline: - Cache clientWidth + scrollLeft in scrollMetricsRef. The view-emit effect already reads them once per scroll/resize inside its own RAF; paintPlayhead now uses the cached values and only writes scrollLeft when the playhead crosses the auto-follow trigger. No layout thrashing per frame. ComposePage: - Throttle the elapsed-counter to ~10 fps. Sub-100ms changes aren't readable by a human, but every textContent assignment forces a string allocation and a DOM mutation; doing it 60× a second was a measurable slice of the per-frame budget. Playheads still paint at full 60fps for smoothness. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2bdd602 commit 3288c1f

6 files changed

Lines changed: 142 additions & 17 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ src/
2626
_persist.ts # localStorage volume + mute persistence
2727
_render.ts # OfflineAudioContext render-to-WAV
2828
_analyser.ts # AnalyserNode tap on master (waveform / spectrum)
29-
presets/ # One file per built-in preset (36 total)
29+
presets/ # One file per built-in preset
3030
_meta.ts # PresetEntry type + asHandle/callGain/noiseBurst helpers
3131
_template.ts # Copy-paste starter for new presets
3232
CONTRIBUTING.md # 30-line guide for adding presets
@@ -135,6 +135,6 @@ pnpm release # pnpm test && build && bundle-budget && bumpp --commit --ta
135135
- ⬜ v0.1.0 — `createSeslen` + synthesised presets + Vite/Tailwind playground
136136
- ⬜ v0.2.0 — full Web Audio surface: buses, voices, ducking, throttle, jitter,
137137
fades, pan, sprites, scheduling (`when`), reduced-motion, persistence,
138-
OfflineAudioContext render-to-WAV, AnalyserNode tap, latency reporting; 36
138+
OfflineAudioContext render-to-WAV, AnalyserNode tap, latency reporting;
139139
built-in presets across UI / game / ambient categories.
140140
- ⬜ v0.3.0 — framework adapters (react, vue, svelte) + theme/preset packs.

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,15 @@
1212
<a href="https://www.npmjs.com/package/seslen"><img src="https://img.shields.io/npm/v/seslen?color=42b883" alt="npm"></a>
1313
<a href="https://bundlephobia.com/package/seslen"><img src="https://img.shields.io/bundlephobia/minzip/seslen?color=7073e8" alt="bundle size"></a>
1414
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/seslen?color=ff8a65" alt="license"></a>
15+
<a href="https://seslen.productdevbook.com"><img src="https://img.shields.io/badge/playground-seslen.productdevbook.com-2563eb" alt="playground"></a>
1516
</p>
1617

17-
> [!WARNING]
18-
> `seslen` is under active development. The API may break between 0.x releases.
18+
<p align="center">
19+
<strong>🎧 Live playground:</strong> <a href="https://seslen.productdevbook.com">seslen.productdevbook.com</a> — preview every preset, build patterns in the composer, and copy the exact code.
20+
</p>
21+
22+
> [!IMPORTANT]
23+
> **Got a sound in your head? Send it our way.** `seslen` is community-built — every preset starts as a one-file PR. The biggest contribution you can make right now is a new preset: open [`src/presets/`](./src/presets/), copy [`_template.ts`](./src/presets/_template.ts), and ship it. We'll help land it.
1924
2025
## Why seslen?
2126

@@ -37,7 +42,7 @@ handle?.stop()
3742
## Features
3843

3944
- 🪶 **Zero dependencies**, pure ESM, tree-shakeable
40-
- 🎹 **36 synthesised presets** — every play generated fresh on `AudioContext`
45+
- 🎹 **Synthesised UI presets** — every play generated fresh on `AudioContext`
4146
-**Lazy AudioContext** — created only on the first `play()`
4247
- 🔓 **Auto-unlock** — resumes the context on the first user gesture
4348
- ♿️ **Respects `prefers-reduced-motion`** — auto-mutes by default

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "seslen",
33
"version": "0.0.1",
4-
"description": "High-DX Web Audio: zero-dep, tree-shakeable library with 36 synthesised UI sound presets, named buses + ducking, polyphony cap, throttle, jitter, fades, pan, sprites, sample-accurate scheduling, OfflineAudioContext render-to-WAV, AnalyserNode tap, prefers-reduced-motion auto-mute, localStorage persistence and an SSR-safe stub. Strict TypeScript.",
4+
"description": "High-DX Web Audio: zero-dep, tree-shakeable library with synthesised UI sound presets, named buses + ducking, polyphony cap, throttle, jitter, fades, pan, sprites, sample-accurate scheduling, OfflineAudioContext render-to-WAV, AnalyserNode tap, prefers-reduced-motion auto-mute, localStorage persistence and an SSR-safe stub. Strict TypeScript.",
55
"keywords": [
66
"analyser",
77
"audio",

web/src/components/Timeline.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ export const Timeline = forwardRef<TimelineHandle, Props>(function Timeline(
8181
const userScrolledRef = useRef(false)
8282
const followRef = useRef(followPlayhead)
8383
followRef.current = followPlayhead
84+
// Cache scroll metrics so paintPlayhead() doesn't read clientWidth /
85+
// scrollLeft every frame — those are forced-layout reads and were
86+
// pushing the RAF handler over the 16 ms budget.
87+
const scrollMetricsRef = useRef<{ clientWidth: number; scrollLeft: number }>({
88+
clientWidth: 0,
89+
scrollLeft: 0,
90+
})
8491

8592
const contentW = Math.max(400, Math.round(totalMs * pxPerMs))
8693

@@ -154,16 +161,21 @@ export const Timeline = forwardRef<TimelineHandle, Props>(function Timeline(
154161
el.style.left = `${LABEL_W + elapsedMs * pxPerMs}px`
155162

156163
if (followRef.current && !userScrolledRef.current && root) {
157-
const visiblePx = Math.max(0, root.clientWidth - LABEL_W)
164+
// Use cached metrics. The scroll listener / ResizeObserver below
165+
// keep them in sync; reading them here would force a layout
166+
// every frame.
167+
const { clientWidth, scrollLeft } = scrollMetricsRef.current
168+
const visiblePx = Math.max(0, clientWidth - LABEL_W)
158169
if (visiblePx > 0) {
159170
const playheadX = elapsedMs * pxPerMs
160-
const visStart = root.scrollLeft
161-
const trigger = visStart + visiblePx * 0.8
162-
if (playheadX > trigger || playheadX < visStart) {
171+
const trigger = scrollLeft + visiblePx * 0.8
172+
if (playheadX > trigger || playheadX < scrollLeft) {
163173
const target = playheadX - visiblePx * 0.2
164174
const newContentW = Math.max(400, Math.round(totalMs * pxPerMs))
165175
const maxScroll = Math.max(0, newContentW - visiblePx)
166-
root.scrollLeft = Math.max(0, Math.min(maxScroll, target))
176+
const next = Math.max(0, Math.min(maxScroll, target))
177+
root.scrollLeft = next
178+
scrollMetricsRef.current.scrollLeft = next
167179
}
168180
}
169181
}
@@ -188,8 +200,13 @@ export const Timeline = forwardRef<TimelineHandle, Props>(function Timeline(
188200
raf = 0
189201
const root = scrollRef.current
190202
if (!root) return
191-
const visiblePx = Math.max(0, root.clientWidth - LABEL_W)
192-
const startMs = root.scrollLeft / pxPerMs
203+
const clientWidth = root.clientWidth
204+
const scrollLeft = root.scrollLeft
205+
// Batch the layout reads here once per scroll/resize so the
206+
// playhead RAF can use cached values.
207+
scrollMetricsRef.current = { clientWidth, scrollLeft }
208+
const visiblePx = Math.max(0, clientWidth - LABEL_W)
209+
const startMs = scrollLeft / pxPerMs
193210
const endMs = startMs + visiblePx / pxPerMs
194211
onViewChange({
195212
startMs: Math.max(0, startMs),

web/src/routes/compose.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { HugeiconsIcon } from "@hugeicons/react"
66
import {
77
ArrowLeft01Icon,
88
ArrowDown01Icon,
9+
Github01Icon,
910
UndoIcon,
1011
RedoIcon,
1112
PauseIcon,
@@ -226,8 +227,12 @@ function ComposePage(): React.ReactElement {
226227
// Single RAF drives Timeline's playhead, PreviewPanel's playhead and
227228
// the transport bar's elapsed counter — all from the same
228229
// performance.now() sample, so nothing can drift relative to anything
229-
// else.
230+
// else. Playheads paint every frame; the elapsed counter only
231+
// refreshes at ~10 fps because no human reads sub-100ms changes and
232+
// String allocation + DOM textContent writes were a measurable slice
233+
// of the per-frame budget.
230234
const startedAt = performance.now()
235+
let lastLabelMs = -1
231236
timelineRef.current?.paintPlayhead(0)
232237
previewRef.current?.paintPlayhead(0)
233238
if (elapsedLabelRef.current) elapsedLabelRef.current.textContent = formatTime(0)
@@ -240,7 +245,10 @@ function ComposePage(): React.ReactElement {
240245
}
241246
timelineRef.current?.paintPlayhead(t)
242247
previewRef.current?.paintPlayhead(t)
243-
if (elapsedLabelRef.current) elapsedLabelRef.current.textContent = formatTime(t)
248+
if (elapsedLabelRef.current && t - lastLabelMs >= 100) {
249+
elapsedLabelRef.current.textContent = formatTime(t)
250+
lastLabelMs = t
251+
}
244252
elapsedRafRef.current = requestAnimationFrame(tick)
245253
}
246254
elapsedRafRef.current = requestAnimationFrame(tick)
@@ -558,6 +566,23 @@ function ComposePage(): React.ReactElement {
558566
</span>
559567

560568
<div className="ml-auto flex items-center gap-2">
569+
<Tooltip>
570+
<TooltipTrigger
571+
render={
572+
<a
573+
href="https://github.com/productdevbook/seslen"
574+
target="_blank"
575+
rel="noreferrer"
576+
className="inline-flex h-8 items-center justify-center rounded-md border border-input bg-background px-2.5 gap-1.5 text-[12px] font-medium hover:bg-muted transition"
577+
>
578+
<HugeiconsIcon icon={Github01Icon} strokeWidth={2} className="size-4" />
579+
<span className="hidden md:inline">GitHub</span>
580+
</a>
581+
}
582+
/>
583+
<TooltipContent>Open repo · contribute a preset</TooltipContent>
584+
</Tooltip>
585+
561586
<Popover open={demosOpen} onOpenChange={setDemosOpen}>
562587
<PopoverTrigger
563588
render={

web/src/routes/index.tsx

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import {
77
ArrowRight01Icon,
88
ArrowUp01Icon,
99
Copy01Icon,
10+
FavouriteIcon,
11+
Github01Icon,
1012
RepeatIcon,
1113
PlayCircleIcon,
1214
Search01Icon,
15+
StarIcon,
1316
UnfoldMoreIcon,
1417
} from "@hugeicons/core-free-icons"
1518
import { Badge } from "@/components/ui/badge"
@@ -240,6 +243,26 @@ function IndexPage(): React.ReactElement {
240243

241244
return (
242245
<main className="mx-auto max-w-6xl px-4 py-8 sm:py-10 flex flex-col gap-6">
246+
<a
247+
href="https://github.com/productdevbook/seslen"
248+
target="_blank"
249+
rel="noreferrer"
250+
className="group flex items-center gap-3 rounded-lg border border-border bg-muted/40 hover:bg-muted px-4 py-2.5 text-[12px] transition"
251+
>
252+
<HugeiconsIcon icon={Github01Icon} strokeWidth={2} className="size-4 shrink-0" />
253+
<span className="flex-1 leading-snug">
254+
<strong className="font-semibold">Got a sound in your head?</strong>{" "}
255+
<span className="text-muted-foreground">
256+
Contribute a preset on GitHub — it's a single self-contained file under{" "}
257+
<code className="font-mono text-[11px]">src/presets/</code>.
258+
</span>
259+
</span>
260+
<span className="hidden sm:inline-flex items-center gap-1 font-mono text-[11px] uppercase tracking-wider text-muted-foreground group-hover:text-foreground transition">
261+
Open repo
262+
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} className="size-3.5" />
263+
</span>
264+
</a>
265+
243266
<header className="flex items-end justify-between gap-4">
244267
<div className="flex flex-col gap-2">
245268
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
@@ -317,8 +340,63 @@ function IndexPage(): React.ReactElement {
317340
emptyState={`No presets match "${query}".`}
318341
/>
319342

320-
<footer className="pt-4 pb-2 text-center text-[11px] font-mono text-muted-foreground">
321-
github.com/productdevbook/seslen — contribute presets in src/presets/
343+
<footer className="pt-6 pb-4 flex flex-col sm:flex-row items-center justify-between gap-3 text-[11px] font-mono text-muted-foreground border-t mt-2">
344+
<span>
345+
MIT ©{" "}
346+
<a
347+
href="https://github.com/productdevbook"
348+
target="_blank"
349+
rel="noreferrer"
350+
className="hover:text-foreground transition"
351+
>
352+
productdevbook
353+
</a>
354+
</span>
355+
<nav className="flex items-center gap-3 flex-wrap justify-center">
356+
<a
357+
href="https://github.com/productdevbook/seslen"
358+
target="_blank"
359+
rel="noreferrer"
360+
className="inline-flex items-center gap-1.5 hover:text-foreground transition"
361+
>
362+
<HugeiconsIcon icon={Github01Icon} strokeWidth={2} className="size-3.5" />
363+
Repository
364+
</a>
365+
<a
366+
href="https://github.com/productdevbook/seslen/tree/main/src/presets"
367+
target="_blank"
368+
rel="noreferrer"
369+
className="inline-flex items-center gap-1.5 hover:text-foreground transition"
370+
>
371+
<HugeiconsIcon icon={StarIcon} strokeWidth={2} className="size-3.5" />
372+
Contribute a preset
373+
</a>
374+
<a
375+
href="https://www.npmjs.com/package/seslen"
376+
target="_blank"
377+
rel="noreferrer"
378+
className="hover:text-foreground transition"
379+
>
380+
npm
381+
</a>
382+
<a
383+
href="https://github.com/productdevbook/seslen/issues"
384+
target="_blank"
385+
rel="noreferrer"
386+
className="hover:text-foreground transition"
387+
>
388+
Issues
389+
</a>
390+
<a
391+
href="https://github.com/sponsors/productdevbook"
392+
target="_blank"
393+
rel="noreferrer"
394+
className="inline-flex items-center gap-1.5 hover:text-foreground transition"
395+
>
396+
<HugeiconsIcon icon={FavouriteIcon} strokeWidth={2} className="size-3.5" />
397+
Sponsor
398+
</a>
399+
</nav>
322400
</footer>
323401
</main>
324402
)

0 commit comments

Comments
 (0)