中間発表の精度実験用スクリプトに,ブロック間オートインターバルとインターバル開始時の音声再生機能を実装

This commit is contained in:
agomizi1cm committed 2026-10-05 15:18:16 +09:00
1 parent 202df4a9eb
commit 717bc8fe19
1 file changed
+97 -9
+97 -9
View File
@@ -17,7 +17,8 @@ the live pipeline is not touched.
Per trial: baseline (look straight ahead) -> cue -> turn the head to the target and Per trial: baseline (look straight ahead) -> cue -> turn the head to the target and
hold -> cue -> return. Prompt keys: Enter=start, r=redo previous, x=discard previous, hold -> cue -> return. Prompt keys: Enter=start, r=redo previous, x=discard previous,
s=skip this condition, q=quit. ``--auto-interval 6`` removes the Enter key between s=skip this condition, q=quit. ``--auto-interval 6`` removes the Enter key between
trials (single-person operation); a distance change always waits for Enter. trials (single-person operation); a distance change waits for Enter, then optionally
uses ``--block-interval`` to give the subject time to move before the next trial.
Re-run with ``--resume --trials <same file>`` to continue an interrupted session. Re-run with ``--resume --trials <same file>`` to continue an interrupted session.
The sign of ``true_yaw_deg`` is whatever you define on the floor sheet; the report The sign of ``true_yaw_deg`` is whatever you define on the floor sheet; the report
@@ -28,6 +29,8 @@ from __future__ import annotations
import argparse import argparse
import csv import csv
import importlib
import logging
import random import random
import sys import sys
import time import time
@@ -37,6 +40,11 @@ from pathlib import Path
from typing import Callable, Sequence from typing import Callable, Sequence
DEFAULT_DIRECTORY = Path("data/analysis/accuracy") DEFAULT_DIRECTORY = Path("data/analysis/accuracy")
INTERVAL_AUDIO = {
"block": Path("assets/audio/block.wav"),
"auto": Path("assets/audio/auto_interval.wav"),
}
LOGGER = logging.getLogger(__name__)
DEFAULT_ANGLES = (0.0, 5.0, -5.0, 10.0, -10.0, 15.0, -15.0, 20.0, -20.0, 30.0, -30.0) DEFAULT_ANGLES = (0.0, 5.0, -5.0, 10.0, -10.0, 15.0, -15.0, 20.0, -20.0, 30.0, -30.0)
PLAN_FIELDS = ["seq", "block", "condition_id", "distance_m", "true_yaw_deg", "pitch_deg", "repeat", "subject"] PLAN_FIELDS = ["seq", "block", "condition_id", "distance_m", "true_yaw_deg", "pitch_deg", "repeat", "subject"]
TRIAL_FIELDS = ["session_id", "seq", "block", "condition_id", "distance_m", "true_yaw_deg", "pitch_deg", "repeat", TRIAL_FIELDS = ["session_id", "seq", "block", "condition_id", "distance_m", "true_yaw_deg", "pitch_deg", "repeat",
@@ -128,37 +136,109 @@ def _sign(value: float) -> str:
return f"{value + 0.0:+g}" return f"{value + 0.0:+g}"
class IntervalAudioPlayer:
"""Minimal pygame-backed audio player for accuracy-session interval cues."""
def __init__(self, backend: object | None = None) -> None:
self._backend = backend
self._sounds: dict[str, object] = {}
self._initialized = False
self._closed = False
if self._backend is None:
try:
self._backend = importlib.import_module("pygame")
except ImportError:
LOGGER.warning("pygame is not available; interval audio cues are disabled")
return
self._load_sounds()
def _load_sounds(self) -> None:
if self._backend is None or self._closed:
return
try:
mixer = self._backend.mixer # type: ignore[attr-defined]
if not mixer.get_init():
mixer.init()
self._initialized = True
for kind, path in INTERVAL_AUDIO.items():
if not path.is_file():
LOGGER.warning("Interval audio file does not exist: %s", path)
continue
try:
self._sounds[kind] = mixer.Sound(str(path))
except Exception as exc:
LOGGER.error("Failed to load interval audio %s: %s", path, exc)
except Exception as exc:
LOGGER.error("Failed to initialize pygame mixer for interval audio: %s", exc)
self._initialized = False
self._sounds.clear()
def play(self, kind: str) -> bool:
if self._closed or kind not in self._sounds:
return False
try:
sound = self._sounds[kind]
sound.play() # type: ignore[attr-defined]
return True
except Exception as exc:
LOGGER.error("Failed to play interval audio %s: %s", kind, exc)
return False
def close(self) -> None:
if self._closed:
return
if self._backend is not None and self._initialized:
try:
mixer = self._backend.mixer # type: ignore[attr-defined]
if mixer.get_init():
mixer.quit()
except Exception as exc:
LOGGER.warning("Failed to close pygame mixer for interval audio: %s", exc)
self._closed = True
self._sounds.clear()
class SessionRunner: class SessionRunner:
"""Interactive loop; all I/O and time sources are injectable for tests.""" """Interactive loop; all I/O and time sources are injectable for tests."""
def __init__( def __init__(
self, plan: Sequence[PlanRow], trials_path: Path, session_id: str, *, baseline_sec: float = 3.0, self, plan: Sequence[PlanRow], trials_path: Path, session_id: str, *, baseline_sec: float = 3.0,
hold_sec: float = 5.0, auto_interval: float = 0.0, input_fn: Callable[[str], str] = input, hold_sec: float = 5.0, auto_interval: float = 0.0, block_interval: float = 0.0, input_fn: Callable[[str], str] = input,
out: Callable[[str], object] = print, sleep: Callable[[float], object] = time.sleep, out: Callable[[str], object] = print, sleep: Callable[[float], object] = time.sleep,
clock: Callable[[], str] = _now_iso, beep: Callable[[str], object] = _beep, clock: Callable[[], str] = _now_iso, beep: Callable[[str], object] = _beep,
interval_audio: IntervalAudioPlayer | None = None,
) -> None: ) -> None:
self.plan, self.trials_path, self.session_id = list(plan), trials_path, session_id self.plan, self.trials_path, self.session_id = list(plan), trials_path, session_id
self.baseline_sec, self.hold_sec, self.auto_interval = baseline_sec, hold_sec, auto_interval self.baseline_sec, self.hold_sec = baseline_sec, hold_sec
self.auto_interval, self.block_interval = auto_interval, block_interval
self.input_fn, self.out, self.sleep, self.clock, self.beep = input_fn, out, sleep, clock, beep self.input_fn, self.out, self.sleep, self.clock, self.beep = input_fn, out, sleep, clock, beep
self.interval_audio = interval_audio or IntervalAudioPlayer()
def run(self, resume: bool = False) -> int: def run(self, resume: bool = False) -> int:
path = self.trials_path path = self.trials_path
exists = path.is_file() and path.stat().st_size > 0 exists = path.is_file() and path.stat().st_size > 0
if exists and not resume: if exists and not resume:
self.out(f"trials file already exists: {path} (use --resume or another --trials)") self.out(f"trials file already exists: {path} (use --resume or another --trials)")
self.interval_audio.close()
return 2 return 2
pending = [row for row in self.plan if row.seq not in (load_done(path) if exists else set())] pending = [row for row in self.plan if row.seq not in (load_done(path) if exists else set())]
if not pending: if not pending:
self.out("nothing to do: every planned condition is already finished") self.out("nothing to do: every planned condition is already finished")
self.interval_audio.close()
return 0 return 0
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
estimate = len(pending) * (self.baseline_sec + self.hold_sec + 4.0) / 60.0 estimate = len(pending) * (self.baseline_sec + self.hold_sec + 4.0) / 60.0
self.out(f"{len(pending)} trials pending (rough duration {estimate:.0f} min). session={self.session_id}") self.out(f"{len(pending)} trials pending (rough duration {estimate:.0f} min). session={self.session_id}")
try:
with path.open("a", encoding="utf-8-sig", newline="") as stream: with path.open("a", encoding="utf-8-sig", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=TRIAL_FIELDS) writer = csv.DictWriter(stream, fieldnames=TRIAL_FIELDS)
if not exists: if not exists:
writer.writeheader() writer.writeheader()
return self._loop(pending, writer, stream) return self._loop(pending, writer, stream)
finally:
self.interval_audio.close()
def _record(self, row: PlanRow, status: str, t0: str = "", t1: str = "", t2: str = "") -> dict[str, object]: def _record(self, row: PlanRow, status: str, t0: str = "", t1: str = "", t2: str = "") -> dict[str, object]:
return {"session_id": self.session_id, "seq": row.seq, "block": row.block, "condition_id": row.condition_id, return {"session_id": self.session_id, "seq": row.seq, "block": row.block, "condition_id": row.condition_id,
@@ -175,6 +255,7 @@ class SessionRunner:
while index < len(pending): while index < len(pending):
row = pending[index] row = pending[index]
new_block = row.block != current_block new_block = row.block != current_block
block_change = current_block is not None and new_block
if new_block: if new_block:
current_block = row.block current_block = row.block
self.out(f"\n=== block {row.block}: set the subject at {row.distance_m:g} m, then continue ===") self.out(f"\n=== block {row.block}: set the subject at {row.distance_m:g} m, then continue ===")
@@ -182,7 +263,8 @@ class SessionRunner:
f"yaw {_sign(row.true_yaw_deg)} deg pitch {_sign(row.pitch_deg)} deg (repeat {row.repeat})") f"yaw {_sign(row.true_yaw_deg)} deg pitch {_sign(row.pitch_deg)} deg (repeat {row.repeat})")
try: try:
if self.auto_interval > 0 and not new_block: if self.auto_interval > 0 and not new_block:
self._countdown() self.interval_audio.play("auto")
self._countdown(self.auto_interval)
command = "" command = ""
else: else:
command = self.input_fn("Enter=start r=redo-prev x=discard-prev s=skip q=quit > ").strip().lower() command = self.input_fn("Enter=start r=redo-prev x=discard-prev s=skip q=quit > ").strip().lower()
@@ -205,6 +287,9 @@ class SessionRunner:
log(row, "skipped") log(row, "skipped")
index, last_index = index + 1, None index, last_index = index + 1, None
continue continue
if block_change and self.block_interval > 0:
self.interval_audio.play("block")
self._countdown(self.block_interval, label="moving to next block")
try: try:
stamps = self._execute(row) stamps = self._execute(row)
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -217,10 +302,10 @@ class SessionRunner:
self.out(f"session ended: {finished} trials recorded in this run -> {self.trials_path}") self.out(f"session ended: {finished} trials recorded in this run -> {self.trials_path}")
return 0 return 0
def _countdown(self) -> None: def _countdown(self, seconds: float, *, label: str = "starting") -> None:
remaining = self.auto_interval remaining = seconds
while remaining > 0: while remaining > 0:
self.out(f" starting in {remaining:.0f} s ...") self.out(f" {label}: {remaining:.0f} s ...")
self.sleep(min(1.0, remaining)) self.sleep(min(1.0, remaining))
remaining -= 1.0 remaining -= 1.0
@@ -258,7 +343,8 @@ def _build_parser() -> argparse.ArgumentParser:
run.add_argument("--session-id", default=None) run.add_argument("--session-id", default=None)
run.add_argument("--baseline-sec", type=float, default=3.0) run.add_argument("--baseline-sec", type=float, default=3.0)
run.add_argument("--hold-sec", type=float, default=5.0) run.add_argument("--hold-sec", type=float, default=5.0)
run.add_argument("--auto-interval", type=float, default=0.0, help="seconds between trials; 0 = wait for Enter") run.add_argument("--auto-interval", type=float, default=0.0, help="seconds between trials within a block; 0 = wait for Enter")
run.add_argument("--block-interval", type=float, default=0.0, help="seconds to allow moving after Enter at a block change; 0 = no extra wait")
run.add_argument("--resume", action="store_true") run.add_argument("--resume", action="store_true")
return parser return parser
@@ -282,9 +368,11 @@ def main(argv: Sequence[str] | None = None, out: Callable[[str], object] = print
print("--resume needs --trials <existing file>", file=sys.stderr) print("--resume needs --trials <existing file>", file=sys.stderr)
return 2 return 2
runner = SessionRunner(read_plan(args.plan), trials, session_id, baseline_sec=args.baseline_sec, runner = SessionRunner(read_plan(args.plan), trials, session_id, baseline_sec=args.baseline_sec,
hold_sec=args.hold_sec, auto_interval=args.auto_interval) hold_sec=args.hold_sec, auto_interval=args.auto_interval,
block_interval=args.block_interval)
return runner.run(resume=args.resume) return runner.run(resume=args.resume)
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())