Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 45 additions & 49 deletions eegnb/experiments/Experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
from typing import Callable
from eegnb.devices.eeg import EEG
from eegnb.devices.vr import VR
from psychopy import prefs, visual, event, core

import gc
from psychopy import prefs, visual, event
from time import time

from eegnb.experiments.realtime import high_priority_section
import random
import json

Expand Down Expand Up @@ -292,45 +292,49 @@ def _run_trial_loop(self, start_time, duration):
def iti_with_jitter():
return self.iti + np.random.rand() * self.jitter

# Initialize trial variables
current_trial = trial_end_time = -1
trial_start_time = None
rendering_trial = -1
has_soa_override = type(self).present_soa is not BaseExperiment.present_soa

# Clear/reset user input buffer
self._clear_user_input()

# Run the trial loop
while (time() - start_time) < duration:
elapsed_time = time() - start_time

# Do not present stimulus until current trial begins(Adhere to inter-trial interval).
if elapsed_time > trial_end_time:
current_trial += 1

# Calculate timing for this trial
trial_start_time = elapsed_time + iti_with_jitter()
trial_end_time = trial_start_time + self.soa

# Do not present stimulus after trial has ended(stimulus on arrival interval).
if elapsed_time >= trial_start_time:
# if current trial number changed present new stimulus.
if current_trial > rendering_trial:
# Stimulus presentation overwritten by specific experiment
self._draw(lambda: self.present_stimulus(current_trial))
rendering_trial = current_trial
elif has_soa_override:
# Keep submitting frames during SOA wait — VR compositor
# drops to lower framerate if we stall between reversals.
self._draw(lambda: self.present_soa(current_trial))
else:
self._draw(lambda: self.present_iti())
with high_priority_section():
if self.use_vr:
self.vr.sync_vr_clock()

if self._user_input('cancel'):
return False
# Initialize trial variables
current_trial = trial_end_time = -1
trial_start_time = None
rendering_trial = -1
has_soa_override = type(self).present_soa is not BaseExperiment.present_soa

# Clear/reset user input buffer
self._clear_user_input()

# Run the trial loop
while (time() - start_time) < duration:
elapsed_time = time() - start_time

# Do not present stimulus until current trial begins(Adhere to inter-trial interval).
if elapsed_time > trial_end_time:
current_trial += 1

# Calculate timing for this trial
trial_start_time = elapsed_time + iti_with_jitter()
trial_end_time = trial_start_time + self.soa

# Do not present stimulus after trial has ended(stimulus on arrival interval).
if elapsed_time >= trial_start_time:
# if current trial number changed present new stimulus.
if current_trial > rendering_trial:
# Stimulus presentation overwritten by specific experiment
self._draw(lambda: self.present_stimulus(current_trial))
rendering_trial = current_trial
elif has_soa_override:
# Keep submitting frames during SOA wait — VR compositor
# drops to lower framerate if we stall between reversals.
self._draw(lambda: self.present_soa(current_trial))
else:
self._draw(lambda: self.present_iti())

if self._user_input('cancel'):
return False

return True
return True

def run(self, instructions=True):
""" Run the experiment """
Expand All @@ -348,15 +352,7 @@ def run(self, instructions=True):
# Record experiment until a key is pressed or duration has expired.
record_start_time = time()

core.rush(True)
gc.disable()
try:
if self.use_vr:
self.vr.sync_vr_clock()
self._run_trial_loop(record_start_time, self.duration)
finally:
gc.enable()
core.rush(False)
self._run_trial_loop(record_start_time, self.duration)

# Clearing the screen for the next trial
event.clearEvents()
Expand Down
97 changes: 97 additions & 0 deletions eegnb/experiments/realtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Process-priority / OS-scheduler helpers for time-critical sections.

Three independent settings affect frame pacing; ``high_priority_section`` raises
all three on entry and restores them on exit. The scheduler-tick setting is
Windows-only (Linux/macOS already run a ≤1 ms tick); process priority and GC
suspension apply on all platforms:

- Scheduler tick: Windows' default is 15.625 ms, and every ``time.sleep``
(including libovr's ``waitToBeginFrame``) rounds up to it, mathematically
locking a 120 Hz / 8.33 ms render loop to half-rate and dropping every
other frame. ``timeBeginPeriod(1)``
drops it to 1 ms; ``timeEndPeriod(1)`` restores it so the change stays
scoped to the section rather than held process-wide.
- Process priority: ``core.rush(True)`` → SetPriorityClass(HIGH_PRIORITY_CLASS).
- Python GC: ``gc.disable()`` suspends the generational collector.

"""

from __future__ import annotations

import gc
import logging
import sys
from contextlib import contextmanager

from psychopy import core

logger = logging.getLogger(__name__)


def force_high_res_timer() -> bool:
"""Raise the Windows scheduler tick to 1 ms via ``timeBeginPeriod(1)`` (see
module docstring for why). No-op off Windows.

Must be paired with ``end_high_res_timer`` to release the tick;
``high_priority_section`` does this for you. Returns ``True`` if the tick was
raised (caller owes a matching ``end_high_res_timer``), ``False`` otherwise.
"""
if sys.platform != 'win32':
return False
try:
import ctypes
ctypes.windll.winmm.timeBeginPeriod(1)
logger.info("[timer] timeBeginPeriod(1)")
return True
except Exception as e:
logger.warning("[timer] timeBeginPeriod failed: %s", e)
return False


def end_high_res_timer() -> None:
"""Release a 1 ms tick raised by ``force_high_res_timer`` via
``timeEndPeriod(1)``. No-op off Windows."""
if sys.platform != 'win32':
return
try:
import ctypes
ctypes.windll.winmm.timeEndPeriod(1)
except Exception as e:
logger.warning("[timer] timeEndPeriod failed: %s", e)


def query_timer_resolution_ms():
"""Return current system timer resolution in ms (None on non-Windows
or query failure). Resolution is reported in 100-ns units by
``NtQueryTimerResolution``."""
if sys.platform != 'win32':
return None
try:
import ctypes
from ctypes import wintypes
ntdll = ctypes.windll.ntdll
_min = wintypes.ULONG(); _max = wintypes.ULONG(); _cur = wintypes.ULONG()
ntdll.NtQueryTimerResolution(
ctypes.byref(_min), ctypes.byref(_max), ctypes.byref(_cur)
)
return _cur.value / 10000.0 # 100-ns → ms
except Exception:
return None


@contextmanager
def high_priority_section():
"""Time-critical mode for the wrapped block: raises the three settings described
in the module docstring on entry and restores them on exit. Use around any
precision-timed section — the trial loop, frame-rate validation, etc.
"""
raised = force_high_res_timer()
core.rush(True)
gc.disable()
try:
yield
finally:
gc.enable()
core.rush(False)
if raised:
end_high_res_timer()
Loading