Media — Multi-Movie Playback with Hardware-Verified Display Onsets¶
This document describes the media/ package added to goxpyriment for
multi-movie playback synchronised to a shared master clock, with
hardware-verified display-onset events suitable for wiring to external
EEG / MEG / TMS triggers. It complements the package-internal design
notes in media/Plan.md and the per-package
guidance in media/CLAUDE.md.
Table of Contents¶
- Scope and design
- Quick start
- Architecture in one diagram
- API reference (package
media) - API reference (package
media/present) - Multi-movie synchronisation: what is guaranteed
- External-trigger synchronisation with frames on screen
- Platform support (Stage 5)
- Mapping to PsyScope movie commands
- References
1. Scope and design¶
What media/ is¶
A new top-level package alongside stimuli/ that adds multi-movie
playback with shared timing. It is the home for everything related to
synchronising several video streams against one another and to external
hardware (EEG / MEG / TMS triggers, photodiodes).
It imports the semantics of PsyScope Tahoe's movie commands —
MovieDo[ PLAY/PAUSE/STOP/SET/GET ] actions and Movie[Done/At/AtDisplay]
+ Display[Onset/Offset] conditions — adapted to a Go API.
Hard scope choices¶
.gvmovies only. Decoding is delegated to the existing pure-Gofunatsufumiya/go-gv-videolibrary (LZ4-compressed RGBA frames, random access). No ffmpeg, no MPEG-1, no H.264.- Silent. Movies have no audio. The package does not load, decode, mix, or schedule audio of any kind.
- Pure Go, no cgo. SDL3 is loaded via
purego(already used bygo-sdl3); the macOS Stage 5 backend uses purego for CoreVideo / libSystem; the Linux Stage 5 backend uses the standardsyscallpackage. - Coexists with
stimuli.stimuli.GvVideoandstimuli.PlayGvremain the single-movie convenience path.media.Movieis added alongside for the multi-movie / sync use cases.
What problems it solves¶
| Problem | Solution |
|---|---|
| Multiple AVPlayer-style movies drift apart over hundreds of pause/resume cycles | One MasterClock shared by every Movie; positions computed from a single clock reading per draw cycle. Zero cumulative drift by construction. |
Three sequential Pause calls reading the clock at slightly different times |
BeginBurst() / EndBurst() freezes MasterClock.Now so all commands inside the burst observe the same value. |
Movie[At "f:N"] needs to fire ~one vsync before the target frame is displayed so an overlay can land on the same vsync |
OnAt(Target, fn) evaluates from inside the per-frame decode loop, before FlipTS. |
External EEG trigger must be aligned to actual first-pixel-visible time, not to when our Present call returned |
Stage 5 media/present package; on macOS uses CVDisplayLink, on Linux uses DRM_IOCTL_WAIT_VBLANK, both report OS-measured vsync. |
| Need to know post-hoc which exact vsync each trigger landed on | Onset.TimestampNS is in the same nanosecond reference as sdl.TicksNS() and SDL keyboard event timestamps; subtract directly to compute reaction times. |
2. Quick start¶
A complete two-movie experiment in ~25 lines, including hardware trigger wiring (silently no-ops if no DLP-IO8 is plugged in):
package main
import (
"log"
"time"
"github.com/funatsufumiya/go-gv-video/gvvideo"
"github.com/chrplr/goxpyriment/control"
"github.com/chrplr/goxpyriment/media"
"github.com/chrplr/goxpyriment/triggers"
)
func main() {
exp := control.NewExperimentFromFlags("Demo", control.Black, control.White, 32)
defer exp.End()
gvL, _ := gvvideo.LoadGVVideo("clipL.gv")
gvR, _ := gvvideo.LoadGVVideo("clipR.gv")
mgr := media.NewMovieManager(exp.Screen)
defer mgr.Close()
left, _ := media.NewMovie(mgr, gvL, media.WithTag("L"), media.WithRepeat(-1))
right, _ := media.NewMovie(mgr, gvR, media.WithTag("R"), media.WithRepeat(-1))
defer left.Close()
defer right.Close()
ttl, _, _ := triggers.AutoDetectDLPIO8()
defer ttl.Close()
left.OnAtDisplay(media.Frame(60), func(o media.Onset) {
log.Printf("frame 60 displayed @ %d (%s)", o.TimestampNS, o.Source)
_ = ttl.Pulse(0, 5*time.Millisecond)
})
mgr.BeginBurst(); left.Play(); right.Play(); mgr.EndBurst()
exp.Run(func() error {
_, err := mgr.Draw()
return err
})
}
A more comprehensive walkthrough with composited overlays, burst pause,
seek-back, and Display[Onset/Offset] events lives in
examples/sync_two_gv_movies/.
3. Architecture in one diagram¶
┌─────────────────────────────────┐
│ media.MovieManager │
│ │
│ ┌────────────────────────┐ │
│ │ media.MasterClock │ │
│ │ (BeginBurst / Now / … │ │
│ └────────────────────────┘ │
│ │
│ ┌────────────────────────┐ │
│ │ []*media.Movie │ │
│ │ (per-movie state, │ │
│ │ gvvideo.GVVideo, │ │
│ │ conditions, …) │ │
│ └────────────────────────┘ │
│ │
│ ┌────────────────────────┐ │
│ │ present.Timer │ ◄──── auto-detected:
│ │ (RecordFlip / │ │ darwin → CVDisplayLink
│ │ OnsetForFlip) │ │ linux → DRM ioctl
│ └────────────────────────┘ │ other → Fallback
│ │
│ pendingFlips queue │
│ (deferred OnAtDisplay, │
│ Display[Onset/Offset]) │
└─────────────────────────────────┘
│
│ uses
▼
apparatus.Screen (SDL_Renderer)
Per-frame call site (mixed compositing form):
exp.Screen.Clear() // background
mgr.DrawWithoutFlip() // decode + render all movies, fire OnAt look-ahead
otherStim.Draw(exp.Screen) // overlay text / fixation cross / …
ts, _ := exp.Screen.FlipTS() // present to display
mgr.NotifyFlipped(ts) // fire OnAtDisplay + Display[Onset/Offset]
For movie-only frames the all-in-one mgr.Draw() collapses these into
one call.
4. API reference (package media)¶
4.1 MovieManager¶
The per-experiment owner of the master clock and the registered movies.
Constructor. Auto-detects the best present.Timer for the platform
unless overridden via WithPresentTimer. Logs the chosen backend at
construction:
media: present backend: macOS CVDisplayLink (CoreVideo, hardware-verified) (precision=hardware-verified)
Override the auto-detected timer. Pass present.NewFallback() to force
vsync-estimated mode for testing or to silence the macOS CVDisplayLink
background thread.
Lifecycle¶
| Method | Purpose |
|---|---|
(*MovieManager).Close() error |
Stops the macOS CVDisplayLink thread / closes the Linux DRM fd. Idempotent. Defer this before exp.End(). |
(*MovieManager).Clock() *MasterClock |
Returns the shared master clock (call Reset() at trial boundaries if needed). |
(*MovieManager).LogicalSize() (float32, float32) |
Returns the renderer's coordinate-space size (HiDPI-correct). Use for layout instead of Screen.Size(). |
(*MovieManager).LastFlipTS() uint64 |
Most recent FlipTS passed to NotifyFlipped. |
Movie set¶
| Method | Purpose |
|---|---|
Add(m *Movie) |
Implicitly called by NewMovie. |
Remove(m *Movie) |
Deregister a movie. |
Movies() []*Movie |
Snapshot of registered movies in registration order. |
Burst (atomic command groups)¶
| Method | Purpose |
|---|---|
BeginBurst() |
Freeze MasterClock.Now at its current value (nestable). |
EndBurst() |
Decrement freeze counter; clock thaws when it reaches zero. |
MovieManager.Draw / DrawWithoutFlip already wrap their per-frame
body in an internal burst, so all movies on a frame share one
MasterClock.Now. Use the public BeginBurst / EndBurst to make a
sequence of script-style command calls atomic across multiple movies.
Per-frame¶
| Method | Purpose |
|---|---|
Draw() (uint64, error) |
All-in-one: clear → decode → render → flip → notify. Returns the post-flip SDL ticks. Movie-only frames. |
DrawWithoutFlip() error |
Decode + render, no clear, no Present. Caller composites other stimuli, then calls Screen.FlipTS() and NotifyFlipped(ts). |
NotifyFlipped(ts uint64) |
Publish post-vsync events: advance each movie's currentFrameIndex, fire OnAtDisplay, fire Display[Onset/Offset]. Defers callbacks until the matching hardware-verified vsync timestamp arrives (see §7.4). |
Display[Onset/Offset]¶
| Method | Purpose |
|---|---|
RegisterStimulusName(name string, visible func() bool) func() |
Declare a non-movie stimulus by name. Visibility predicate is sampled in DrawWithoutFlip. Returns an unregister function. (Movies are auto-registered with their tag.) |
OnDisplayOnset(name string, fn func(Onset)) func() |
Fires when name transitions from not-visible to visible across two flips. Returns unsubscribe. |
OnDisplayOffset(name string, fn func(Onset)) func() |
Fires when name transitions from visible to not-visible. Returns unsubscribe. |
4.2 Movie¶
Per-movie state, mirrors PSMovieDecoder in PsyScope Tahoe.
Wraps an already-loaded gvvideo.GVVideo and registers it with the
manager. The caller retains ownership of gv and is responsible for
closing the underlying file (matches stimuli.GvVideo.Unload).
Construction options¶
type Option func(*Movie)
WithTag(tag string) // identifier; required for Display[Onset/Offset]
WithRate(r float64) // playback rate; 1.0 = normal, must be > 0
WithRepeat(n int) // -1 = infinite, 1 = play once (default)
WithFromTime(d time.Duration) // initial playhead within movie media-time
WithScale(s ScaleMode) // ScaleNone | ScaleFit | ScaleFill
WithSize(w, h float32) // explicit destination size; overrides WithScale
WithPosition(p sdl.FPoint) // center-relative draw position
WithFadeIn(d time.Duration) // linear opacity 0→1 ramp over d effective time
WithPersistent(b bool) // last frame stays on screen after Done
Playback control¶
| Method | Effect |
|---|---|
Play() |
Start (first call) or resume (after Pause). Fresh start anchors playhead at WithFromTime. Idempotent. |
Pause() |
Freeze at current frame. |
PauseWithLoop(window time.Duration) |
Pause and bounce within ± |
Stop() |
Halt, release GPU texture, reset state. After Stop the movie can be Play-ed again from WithFromTime. |
Close() |
Release GPU resources. Does not close the underlying gvvideo.GVVideo. |
Property access¶
| Method | Returns |
|---|---|
Time() time.Duration |
Effective media time (rate-scaled, cumulative across loops). |
Frame() int |
Cumulative 1-based displayed-frame index (advanced by NotifyFlipped). 0 before first frame. |
FPS() float64 |
Frames per second from the .gv header. |
FrameCount() int |
Frames per loop iteration. |
LoopCounter() int |
0-based loop iteration currently playing. |
Repeat() int |
Configured loop count (-1 = infinite). |
Width(), Height() float32 |
Native frame dimensions. |
IsActive(), IsPaused(), IsDone() bool |
State queries. |
Property mutation¶
| Method | Effect |
|---|---|
SetRate(r float64) error |
Change playback rate; preserves current effective time. r ≤ 0 returns an error. |
SeekTime(d time.Duration) error |
Jump to elapsed media time d. Resets condition fired-flags so re-pass over the same target re-fires. |
SeekFrame(n int) error |
Jump to cumulative 1-based frame n. |
Conditions / callbacks¶
// Sealed Target type — only these three constructors:
type Frame int // 1-based cumulative target
type AtTime time.Duration // elapsed media time target
type Done struct{} // end-of-file target
// Look-ahead (fires from DrawWithoutFlip, BEFORE the target frame is presented):
OnAt(target Target, fn func(Onset)) func() // returns unsubscribe
OnDone(fn func(Onset)) func() // sugar for OnAt(Done{}, ...)
DoneCh() <-chan Onset // channel form for OnDone
// Hardware-verified (fires from NotifyFlipped, AFTER the target frame appears on screen):
OnAtDisplay(target Target, fn func(Onset)) func()
The look-ahead vs hardware-verified distinction is critical for trigger alignment — see §7.
4.3 MasterClock¶
Monotonic media clock with freeze-during-burst support.
func NewMasterClock() *MasterClock
(*MasterClock).Now() time.Duration // monotonic since last Reset / construction
(*MasterClock).Reset() // back to zero
(*MasterClock).BeginBurst() // freeze Now at current value (nestable)
(*MasterClock).EndBurst() // unfreeze when freeze count hits zero
(*MasterClock).Frozen() bool // currently inside a burst?
4.4 Onset events¶
type Onset struct {
TimestampNS uint64 // SDL ticks; same reference as sdl.TicksNS / FlipTS
Frame int // cumulative 1-based; 0 if not applicable
Source OnsetSource // precision class
Movie *Movie // nil for generic Display events of non-movie stimuli
Name string // movie tag for movies; user name for others
}
type OnsetSource int
const (
VsyncEstimated OnsetSource = iota // post-Present FlipTS, vsync-period precision
HardwareVerified // OS-measured first-pixel-visible (sub-ms)
LookAhead // pre-vsync, fired from decode loop
)
4.5 Time-spec helpers¶
For users translating PsyScope scripts to Go:
ParseTimeSpec("s:3,ms:100") // → 3.1 * time.Second, nil
ParseFrameSpec("f:186") // → 186, nil
ParseFrameSpec("frame:1") // → 1, nil
Native API uses time.Duration and int directly — these helpers are a
convenience for porting existing scripts.
5. API reference (package media/present)¶
The presentation-timer subpackage. Each backend reports OS-measured
vsync timestamps that pair with Screen.FlipTS() values to give
hardware-verified display-onset times.
type Timer interface {
RecordFlip(flipTS uint64)
OnsetForFlip(flipTS uint64) (timestamp uint64, source OnsetSource, ok bool)
Precision() OnsetSource
Close() error
Description() string
}
func AutoDetect(screen *apparatus.Screen) Timer // platform-best, never nil
func NewFallback() Timer // always-available no-op
MovieManager calls RecordFlip(ts) immediately after each FlipTS,
then OnsetForFlip(ts) to read back the matching hardware-verified
vsync timestamp. If ok=false, the manager defers and retries on the
next NotifyFlipped (the OS may publish vsync timestamps
asynchronously).
Most users never construct a Timer directly — NewMovieManager
handles auto-detection. Use WithPresentTimer(present.NewFallback())
in tests if you need deterministic vsync-estimated behaviour.
6. Multi-movie synchronisation: what is guaranteed¶
6.1 Mechanism¶
Every per-frame body opens an internal burst, reads MasterClock.Now
exactly once, and computes each movie's effective media time from that
single value:
// Inside MovieManager.DrawWithoutFlip:
mgr.clock.BeginBurst()
defer mgr.clock.EndBurst()
now := mgr.clock.Now() // ← single read for this frame
for _, m := range movies {
eff := m.effectiveLocked(now) // each movie computes its own offset
target := int(eff.Seconds() * m.fps) + 1
// … decode + render …
}
Because every movie computes from the same now, the inter-movie
spread within one frame is zero by construction. There is no rounding
error to accumulate. Pause/resume cycles update each movie's
mediaTimeOffset independently but always by the same delta when
issued inside the same burst.
6.2 What that means in numbers¶
| Measurement | Result | Notes |
|---|---|---|
Inter-movie spread at Play |
0 ms | All movies anchored to one MasterClock.Now reading inside BeginBurst/EndBurst. |
| Inter-movie spread at first frame on screen | 0 ms | All movies render in one Renderer.Present call → one vsync → one display refresh. |
| Cumulative drift after 500 pause/resume cycles | 0 ms | Mathematical: pause/resume only changes per-movie mediaTimeOffset; drift would require a per-movie clock, which the design forbids. |
| Same-vsync rendering | All visible movies share the same vsync | Single Renderer.Present call → atomic page flip. |
This matches PsyScope Tahoe's measured numbers (see
MultiMovieSyncStrategy.md §8 in that repo).
6.3 The atomic-burst pattern¶
Use mgr.BeginBurst() / mgr.EndBurst() around any sequence of script-
style commands that should observe the same clock:
mgr.BeginBurst()
left.Pause() // captures MasterClock.Now at burst-start time T
right.Pause() // captures the same T
center.Pause() // captures the same T
mgr.EndBurst()
Without the burst, three sequential Pause calls would each read
MasterClock.Now a few microseconds apart. The burst ensures all three
movies pause at the exact same media time, regardless of scheduling
jitter between the calls.
6.4 What is not synchronised¶
- Rendering completion across multiple Renderers. This package uses
one renderer (the
apparatus.Screen.Renderer), so this is moot. If you build a multi-window experiment, each window has its ownMovieManagerand they synchronise only as well as the OS schedules their flips. - Decode latency variation. Different movies may take different
times to LZ4-decompress a frame. The decode happens before the
shared
Renderer.Presentcall, so a slow decode would push the entire frame past its target vsync. Prefer movies of similar resolution and bitrate; avoid per-frame allocations (the decode buffer is reused). - Audio. Audio is out of scope (see §1).
7. External-trigger synchronisation with frames on screen¶
This section is the most important one for EEG / MEG / TMS experiments and the question most users will have. Read it carefully.
7.1 The two timestamps that matter¶
For a callback registered via mov.OnAtDisplay(media.Frame(N), fn),
two distinct timestamps are involved:
| Quantity | What it is | Source | Precision |
|---|---|---|---|
Onset.TimestampNS |
When frame N's first pixel begins scanning out of the display controller | OS vsync API (CVDisplayLink / DRM_IOCTL_WAIT_VBLANK) | Sub-ms vs scanout start (Stage 5 platforms) |
| Wire-arrival of TTL pulse | When the EEG box sees the trigger line go HIGH | DLP-IO8 USB / parallel port / MEGTTLBox + your callback latency | TimestampNS + 0.5–5 ms (constant + small jitter) |
Onset.TimestampNS is what you want for post-hoc analysis: it is
in the same nanosecond reference frame as sdl.TicksNS() and SDL3
keyboard event timestamps, so reaction times computed as
keyEvent.Timestamp - onset.TimestampNS are sub-ms accurate.
The wire-arrival is what arrives at your EEG amplifier's TTL input. It
lags TimestampNS by a small chain of latencies.
7.2 The wire-arrival delay¶
Breakdown of the ~0.5–5 ms gap between TimestampNS and the TTL line
going HIGH:
| Stage | Typical | Notes |
|---|---|---|
| OS publishes vsync timestamp | 0 µs (Linux DRM, sync ioctl) to ~1 ms (macOS, async CV callback may defer) | See §7.4 |
| Manager dispatches the callback | ~1–100 µs | Mutex + closure invocation |
ttl.Pulse writes to the device |
0.5–2 ms (DLP-IO8 USB-CDC); <1 µs (parallel port); ~1 ms (MEGTTLBox 115200 baud) | Bus + firmware |
| Total | ~0.5 ms (parallel) to ~5 ms (USB) | Stable / calibratable |
The delay is constant and stable. Calibrate once with a photodiode + TTL recording (see §7.5) and subtract the measured offset in your analysis pipeline.
7.3 LookAhead vs HardwareVerified vs VsyncEstimated¶
The three OnsetSource values describe the precision class of
Onset.TimestampNS:
| Source | Where it fires | TimestampNS meaning | Use case |
|---|---|---|---|
LookAhead |
Inside DrawWithoutFlip, before Present |
sdl.TicksNS() at fire time (NOT a vsync) |
Set state for the same-vsync compositor (e.g., "show overlay this frame") |
VsyncEstimated |
After Present, in NotifyFlipped |
Post-Present FlipTS value |
Vsync-period precision (~16 ms at 60 Hz). Default fallback when no Stage 5 backend is available. |
HardwareVerified |
After Present, in NotifyFlipped, possibly deferred to next flip |
OS-measured first-pixel-visible time | Sub-ms precision vs scanout start. Available on macOS + Linux. |
| Callback | Source on Stage 5 platforms | Source on fallback platforms |
|---|---|---|
mov.OnAt(t, fn) |
LookAhead |
LookAhead |
mov.OnDone(fn) |
LookAhead |
LookAhead |
mov.OnAtDisplay(t, fn) |
HardwareVerified |
VsyncEstimated |
mgr.OnDisplayOnset(name, fn) |
HardwareVerified |
VsyncEstimated |
mgr.OnDisplayOffset(name, fn) |
HardwareVerified |
VsyncEstimated |
When the timer is VsyncEstimated, the manager logs a one-time warning
on the first display callback registration. When the timer is
HardwareVerified, the warning is suppressed.
7.4 The deferral pattern (macOS specifically)¶
On macOS, the CVDisplayLink callback fires on a background thread
asynchronously to Present. In the rare race where the callback hasn't
yet published a vsync timestamp by the time NotifyFlipped(ts) runs,
the manager:
- Stashes this flip's callbacks in
pendingFlips. - Continues normally (no error).
- On the next
NotifyFlipped(one vsync later, ~16 ms wall-clock), triesOnsetForFlip(stashed_ts)again. The CVDisplayLink callback has surely fired by now, so the lookup succeeds and the callbacks fire — with the correct hardware-verified timestamp for the stashed flip.
The trigger pulse therefore arrives ~16 ms after the visual onset in
this race case (the wire is one vsync late), but the
Onset.TimestampNS you receive is still sub-ms accurate for frame N's
actual visual onset. Post-hoc analysis is unaffected; only the
real-time wire timing is delayed by one vsync.
If a stash sits in the queue for more than ~3 × frame period
(~50 ms at 60 Hz), the manager gives up waiting and fires the callbacks
with (FlipTS, VsyncEstimated) as a fallback, logging a single warning.
This handles compositor stalls and display-link issues without
silently inaccurate timestamps.
On Linux this race does not happen: RecordFlip queries DRM
synchronously right after FlipTS, the kernel returns the matching
vblank's count and timestamp before OnsetForFlip is called, and the
callback fires immediately on the same NotifyFlipped.
7.5 Calibration¶
For sub-ms wire-side alignment to visual onset, you must measure the constant delay of your specific hardware:
- Display a flashing white square in the corner of the screen.
- Place a photodiode on the square; route both the photodiode signal and the TTL line to a recording amplifier with sub-ms sampling (most EEG amplifiers can do this directly).
- Measure
delta = wire_HIGH_time − photodiode_first_lightover many trials. Take the median. - In your data analysis, subtract
deltafrom every TTL marker timestamp.
After calibration, wire-side alignment to visual onset is bounded by
hardware jitter (typically tens of µs) plus your measurement
uncertainty. The tests/Timing-Tests/ framework in this repository can
drive this calibration measurement automatically.
7.6 What is not measured¶
The Stage 5 backends report when the display controller starts scanning out, not when LCD photons actually emit. Two layers of photon-side latency are invisible to any software-only path on these platforms:
- Panel response — 1–10 ms typical for IPS / VA / OLED panels at 60 Hz, longer for cheap monitors with image processing turned on ("game mode" usually disables this). Fixed offset per monitor.
- Scanout phase — at 60 Hz, the top of the screen receives its new pixels at vsync; the bottom receives them ~16 ms later (full scanout = one frame period). The reported timestamp is vsync = start-of-scanout, so a stimulus at the bottom of the screen has up to a frame period of additional latency before its photons emit. Place timing-critical stimuli near the top, or use a photodiode at their actual location.
These limits also apply to Apple's addPresentedHandler and Vulkan's
VK_EXT_present_timing — they are intrinsic to the display pipeline,
not specific to this implementation. Photon-precise onset still
requires a photodiode.
7.7 Recommended pattern¶
// At experiment start:
mov.OnAtDisplay(media.Frame(N), func(o media.Onset) {
// Always log the hardware-verified timestamp, even if you also pulse a wire:
exp.Data.Add(trialID, "stim_onset_ns", o.TimestampNS, o.Source.String())
// Pulse the trigger immediately. The pulse arrives ~0.5–5 ms after o.TimestampNS;
// calibrate the constant and subtract in analysis.
_ = ttl.Pulse(0, 5*time.Millisecond)
})
Two records per event: the timestamp (hardware-verified, sub-ms) goes into the data file; the wire pulse goes into the EEG amplifier. Align post-hoc by subtracting the calibrated wire-arrival delay.
8. Platform support (Stage 5)¶
| Platform | Backend | Precision | Status | Prerequisites |
|---|---|---|---|---|
| macOS (Apple Silicon + Intel) | CVDisplayLink via purego (CoreVideo + libSystem) |
HardwareVerified, sub-ms |
Shipped | None — frameworks are part of the OS |
| Linux (X11 + Wayland) | DRM_IOCTL_WAIT_VBLANK via syscall on /dev/dri/cardN |
HardwareVerified, sub-ms |
Shipped | User must be in video group (default on most distros). At least one DRM device. |
| Windows | (none yet) | VsyncEstimated only |
Not yet shipped | — |
| FreeBSD / OpenBSD / others | (none) | VsyncEstimated only |
Falls back to Fallback |
— |
The startup log line confirms the chosen backend:
media: present backend: macOS CVDisplayLink (CoreVideo, hardware-verified) (precision=hardware-verified)
media: present backend: Linux DRM vblank (DRM_IOCTL_WAIT_VBLANK, hardware-verified) (precision=hardware-verified)
media: present backend: vsync-estimated (post-Present FlipTS, no OS integration) (precision=vsync-estimated)
If auto-detection fails (e.g., Linux without video group, missing
/dev/dri/cardN), the manager logs the failure once and falls back
gracefully:
present: Linux DRM vblank unavailable (open /dev/dri/cardN: permission denied); falling back to vsync-estimated
media: present backend: vsync-estimated (post-Present FlipTS, no OS integration) (precision=vsync-estimated)
8.1 What Windows users get today¶
Windows builds compile cleanly (the media/present/autodetect_other.go
file matches !darwin && !linux) and use the VsyncEstimated
fallback. This means:
- All multi-movie synchronisation works (§6 guarantees are independent of Stage 5).
- All
Movie[At]look-ahead callbacks work (LookAhead is software-only, always available). - All
OnAtDisplay/Display[Onset/Offset]callbacks fire — but withSource: VsyncEstimated, accurate to vsync-period (~16 ms at 60 Hz) rather than sub-ms. - A one-time warning is logged at the first display-callback registration:
media: display-onset precision is vsync-period (~16.666ms at this refresh rate). Install a sub-ms presentation backend (Stage 5: Vulkan VK_EXT_present_timing or Metal addPresentedHandler) for hardware-verified timing.
Workaround for Windows users today: photodiode calibration (§7.5) using
the VsyncEstimated timestamp as a reference. The constant offset
includes one extra source of jitter (the ~vsync-period uncertainty),
but for low-trial-rate studies this is often acceptable.
8.2 What needs to be done for Windows¶
To bring Windows up to parity with macOS / Linux, a Stage 5 backend
needs to be added at media/present/dxgi_windows.go (next to the
existing cvdisplaylink_darwin.go and drm_linux.go). The most
promising APIs:
| API | Pros | Cons |
|---|---|---|
IDXGISwapChain::GetFrameStatistics (DXGI 1.0+) |
Available on every Windows since Vista. Returns PresentRefreshCount + PresentQPCTime. |
DXGI swapchain owned by SDL_Renderer. We can't construct our own without taking over rendering. |
| DwmGetCompositionTimingInfo | Returns qpcCompose and qpcVBlank for the desktop window manager. Doesn't require swapchain ownership. |
DWM-specific (composition mode); behaviour with fullscreen exclusive may differ. |
D3DKMTWaitForVerticalBlankEvent + D3DKMTGetScanLine (gdi32) |
Direct kernel-mode access; very precise. | Undocumented headers, requires libloaderapi workaround for the kernel32 entrypoint. |
Recommended path: DwmGetCompositionTimingInfo via purego on
dwmapi.dll. It's the cleanest API that doesn't require swapchain
ownership and works alongside SDL_Renderer.
Skeleton:
//go:build windows
package present
// Use purego.Dlopen("dwmapi.dll") to get a handle, then bind:
// DwmGetCompositionTimingInfo(HWND, *DWM_TIMING_INFO) HRESULT
// where DWM_TIMING_INFO contains qpcVBlank (QueryPerformanceCounter
// units) for the most recent vblank. Convert QPC ticks to SDL ticks
// via QueryPerformanceFrequency at startup.
func newWindowsBackend(screen *apparatus.Screen) (Timer, error) { ... }
Estimated effort: ~250 lines of Go (similar shape to the existing macOS backend), plus ~100 lines of tests (purego symbol-loading, QPC → SDL-ticks conversion, struct layout pinning).
Until that lands, Windows experiments needing sub-ms wire alignment should rely on photodiode calibration (§7.5).
9. Mapping to PsyScope movie commands¶
Users porting PsyScript-based experiments will recognise this table. Volume-related rows are explicitly unsupported (audio is out of scope — see §1).
| PsyScope construct | goxpyriment Go API |
|---|---|
EventType Movie, Stimulus "clip.gv", MovieTag M1 |
gv, _ := gvvideo.LoadGVVideo("clip.gv"); media.NewMovie(mgr, gv, media.WithTag("M1")) |
MovieRate 1.0 |
media.WithRate(1.0) / mov.SetRate(...) |
Repeat 3 / Repeat -1 |
media.WithRepeat(3) / WithRepeat(-1) |
FromTime "s:5" |
media.WithFromTime(5*time.Second) |
Flags Scale_X Scale_Y |
media.WithScale(media.ScaleFit) |
FadeIn 1000 |
media.WithFadeIn(1*time.Second) |
ClearType NO_CLEAR |
media.WithPersistent(true) |
MovieDo[ PLAY ] |
mov.Play() |
MovieDo[ PAUSE ] |
mov.Pause() |
MovieDo[ PAUSE ... LoopRegion=ms:N ] |
mov.PauseWithLoop(±N*time.Millisecond) |
MovieDo[ STOP ] |
mov.Stop() |
MovieDo[ SET Rate=R ] |
mov.SetRate(R) |
MovieDo[ SET Time=... ] |
mov.SeekTime(...) |
MovieDo[ SET Frame=N ] |
mov.SeekFrame(N) |
MovieDo[ GET Time/TimeMs/Frame/FrameCount/FPS/Counter/Repeat ] |
mov.Time(), Frame(), FPS(), FrameCount(), LoopCounter(), Repeat() |
MovieDo[ SET Volume=... ], GET Volume |
Not supported (audio out of scope) |
Movie[ Done ] |
mov.OnDone(fn) or <-mov.DoneCh() |
Movie[ At THISMOVIE "f:N" ] |
mov.OnAt(media.Frame(N), fn) |
Movie[ At THISMOVIE "s:T,ms:U" ] |
mov.OnAt(media.AtTime(d), fn) |
Movie[ AtDisplay THISMOVIE "f:N" ] |
mov.OnAtDisplay(media.Frame(N), fn) |
Display[ Onset ] / Display[ Offset ] |
mgr.OnDisplayOnset(name, fn) / OnDisplayOffset(name, fn) |
SerialOut[ "0xFF" ] action |
call inside the OnDisplay/OnAtDisplay callback into triggers.OutputTTLDevice |
THISMOVIE keyword |
irrelevant — the closure already captures mov |
Instances: "-1" |
irrelevant — Go calls execute once |
Burst-pause atomicity (the goxpyriment equivalent of three sequential
MovieDo[ PAUSE ... ] script lines hitting the same draw cycle):
10. References¶
In this repository:
media/Plan.md— design rationale, stage history, deferred decisionsmedia/CLAUDE.md— package notes for the next contributorexamples/sync_two_gv_movies/— comprehensive walkthrough exampletests/Timing-Tests/— empirical timing-measurement framework
External / upstream:
- PsyScope Tahoe
MultiMovieSyncStrategy.md— describes the original Apple-side architecture this package mirrors (CMTimebase, AVAssetReader, Metal addPresentedHandler). - PsyScope Tahoe
CrossPlatformAnalysis.md— feasibility study that led to the presentmedia/package. VK_EXT_present_timingVulkan spec — the future direct-Vulkan path (not yet implemented; would replaceSDL_Rendererentirely).- Apple
CVDisplayLinkreference — the API the macOS backend uses. - Linux DRM uAPI
drm_wait_vblank— the API the Linux backend uses.