diff --git a/eegnb/experiments/Experiment.py b/eegnb/experiments/Experiment.py index 01427b87..ba80d8e2 100644 --- a/eegnb/experiments/Experiment.py +++ b/eegnb/experiments/Experiment.py @@ -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 @@ -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 """ @@ -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() diff --git a/eegnb/experiments/realtime.py b/eegnb/experiments/realtime.py new file mode 100644 index 00000000..953d4341 --- /dev/null +++ b/eegnb/experiments/realtime.py @@ -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()