This document covers the architecture of the adjustable playhead
position system for developer onboarding and maintenance. For musical
context on why performers adjust the playhead, see playhead.md.
The playhead offset is a single ratio value that controls where on screen the playhead line appears. All downstream systems (cue collision, audio regions, synth regions, OSC control lanes) read the playhead's actual DOM position, so they follow automatically.
window.playheadOffsetRatio
│
▼
┌──────────────────────┐
│ scrollToPlayheadVisual│
│ (oscillaTransport) │
└──────┬───────────────┘
│
┌───────────┼───────────────┐
▼ ▼ ▼
score transform #playhead #playzone
(translate3d) (style.left) (style.left)
│
▼
┌─────────────┐
│ getPlayheadX│ ← reads DOM element position
│ (paths.js) │
└──────┬──────┘
│
┌──────────────┼──────────────────┐
▼ ▼ ▼
checkCueTriggers checkImpulse checkSynth
(cueDispatcher) Regions(audio) Regions(synth)
▼
oscCtrl tick
Key insight: getPlayheadX() reads the #playhead element's
getBoundingClientRect().left relative to the score container. It
does NOT hardcode screen centre. This means moving the DOM element
is sufficient -- no collision code changes are needed.
| File | Role |
|---|---|
js/system/playheadOffset.js |
Drag handle, lock toggle, persistence, public API |
css/playheadOffset.css |
Handle and lock icon styling |
js/transport/oscillaTransport.js |
scrollToPlayheadVisual() — reads offset ratio |
js/system/paths.js |
ensureWindowPlayheadX() — reads offset ratio |
js/system/paths.js |
getPlayheadX() — reads DOM position (unchanged) |
js/cues/cueDispatcher.js |
checkCueTriggers() — calls getPlayheadX() (unchanged) |
js/cues/audio.js |
checkImpulseRegions() — calls getPlayheadX() (unchanged) |
js/cues/synth.js |
checkSynthRegions() — calls getPlayheadX() (unchanged) |
js/cues/oscCtrl.js |
tick() — calls getPlayheadX() (unchanged) |
js/system/oscillaPreferences.js |
Per-project offset slider in Appearance section |
// Single source of truth — default 0.5 (centre)
window.playheadOffsetRatio = 0.5; // range: 0.10 – 0.90
Set by:
playheadOffset.js on init (from localStorage)setPlayheadOffset())Read by:
scrollToPlayheadVisual() in oscillaTransport.jsensureWindowPlayheadX() in paths.jsTwo persistence paths serve different use cases:
| Storage | Scope | Written by | Read on |
|---|---|---|---|
localStorage["oscilla_playheadOffsetRatio"] |
Per-device | Drag handle | Page load (init) |
preferences.json → playheadOffset |
Per-project | Preferences dialog | Project load |
localStorage takes priority. The rationale is that different performers on different screen sizes may want different offsets for the same score.
scrollToPlayheadVisual() is the core rendering function called every
animation frame. The offset change is a single substitution:
// BEFORE (hardcoded centre):
const halfViewport = viewportWidth / 2;
let translateX = halfViewport - worldPx;
let playheadScreenX = halfViewport;
// AFTER (configurable offset):
const offsetRatio = window.playheadOffsetRatio ?? 0.5;
const targetScreenX = viewportWidth * offsetRatio;
let translateX = targetScreenX - worldPx;
let playheadScreenX = targetScreenX;
The edge clamping logic is unchanged in structure — it just uses
targetScreenX instead of halfViewport as the reference point.
┌──── viewport ────────────────────────────┐
│ ▼ playhead at offset │
│ ┌─────────┤──────────────────────┐ │
│ │ score │ score continues... │ │
│ └─────────┤──────────────────────┘ │
└──────────────────────────────────────────┘
translateX = (viewportWidth * offsetRatio) - worldPx
playheadEl.style.left = `${offsetRatio * 100}%`
When translateX > 0, the score's left edge would go past the
screen's left edge. Instead, the score stays flush left and the
playhead moves from screen-left toward the offset position:
playheadScreenX = worldPx
translateX = 0
playheadEl.style.left = `${worldPx}px`
When translateX < maxShiftLeft, the score's right edge would go
past the screen's right edge. The score stays flush right and the
playhead moves from the offset toward screen-right:
playheadScreenX = viewportWidth - (localRenderedWidth - worldPx)
translateX = maxShiftLeft
playheadEl.style.left = `${playheadScreenX}px`
The cue collision chain requires no modifications:
// paths.js — reads actual DOM position
export function getPlayheadX() {
const playhead = document.getElementById("playhead");
const scoreContainer = window.scoreContainer;
if (!playhead || !scoreContainer) return null;
const containerRect = scoreContainer.getBoundingClientRect();
const playheadRect = playhead.getBoundingClientRect();
return playheadRect.left - containerRect.left;
}
All consumers call getPlayheadX() and compare against cue element
rects in the same coordinate space:
checkCueTriggers): edge-crossing detection,
OSC re-entrant triggering, repeat region logiccheckImpulseRegions): impulse region enter/exitcheckSynthRegions): synth region enter/exittick): continuous control lane Y-value samplingBecause all of these read the DOM element's actual left, they track
the playhead regardless of its screen offset.
The handle is injected into #playhead by playheadOffset.js at
init time. Structure:
<div id="playhead">
<div id="repeat-count-box" class="hidden">1</div>
<!-- injected by playheadOffset.js: -->
<div id="playhead-offset-handle" class="locked">
<div class="playhead-grip-dots">
<span></span><span></span><span></span>
</div>
<button id="playhead-lock-btn" class="playhead-offset-lock">
<svg class="lock-icon-locked">...</svg>
<svg class="lock-icon-unlocked">...</svg>
</button>
</div>
</div>
The handle is invisible by default (opacity: 0) and appears on
hover via CSS. A ::before pseudo-element on #playhead extends
the hover target to 30px wide around the 1px line.
pointer-events: auto on the handle overrides the pointer-events: none on #playhead, so the handle is interactive while the
playhead line itself doesn't intercept score clicks.
cursor: default, drag events are
ignored. Lock icon shows closed padlock.cursor: grab, horizontal drag updates
window.playheadOffsetRatio in real time. Lock icon shows open
padlock with accent colour.Horizontal only. On mousemove/touchmove:
const newRatio = clamp(clientX / viewportWidth, 0.10, 0.90);
window.playheadOffsetRatio = newRatio;
scrollToPlayheadVisual(); // live score repositioning
applyPlayzonePosition(); // playzone follows
On mouseup/touchend, the ratio is persisted to localStorage.
The playzone centres itself on the playhead offset:
function applyPlayzonePosition() {
const ratio = window.playheadOffsetRatio ?? 0.5;
const halfWidth = 40 / 2; // PLAYZONE_WIDTH_PERCENT / 2
const leftPercent = (ratio * 100) - halfWidth;
playzone.style.left = `${leftPercent}%`;
}
This is called on drag, on init, and on window resize. The playzone width (40%) is a constant matching the CSS definition.
Exposed on window for cross-module access:
window.playheadOffsetRatio // current ratio (0.10–0.90)
window.initPlayheadOffset() // call once after DOM ready
window.setPlayheadOffset(r) // set ratio, persist, update visual
window.getPlayheadOffset() // read current ratio
window.resetPlayheadOffset() // reset to 0.5 (centre)
window.applyPlayzonePosition()// reposition playzone to match offset
import { initPlayheadOffset } from './system/playheadOffset.js';
// In DOMContentLoaded, after initAnimationLoop:
initPlayheadOffset();
Field definition in Appearance section:
{ key: "playheadOffset", label: "Playhead Position %",
type: "range", default: 50, min: 10, max: 90, step: 1 }
Live application:
if (prefs.playheadOffset != null) {
const ratio = Number(prefs.playheadOffset) / 100;
window.setPlayheadOffset?.(ratio);
}
@import url("playheadOffset.css");
oscCtrl continuous lanes track the playhead correctlyaudio.js impulse regions fire at playhead intersection| Symptom | Cause | Fix |
|---|---|---|
| Cues fire at old centre position | getPlayheadX not reading DOM |
Verify #playhead element exists and has correct style.left |
| Playzone doesn't follow | applyPlayzonePosition not called |
Ensure it runs on drag, init, and resize |
| Offset resets on project load | Preferences overwriting localStorage | Check priority: localStorage should win |
| Handle doesn't appear | CSS not loaded | Verify @import url("playheadOffset.css") in styles.css |
| Handle intercepts score clicks | pointer-events conflict |
Only the handle div should have pointer-events: auto |
| Drag feels sluggish | Too many repaints | scrollToPlayheadVisual uses translate3d (GPU-accelerated) |