Skip to content

Commit da5a49f

Browse files
Merge pull request #7 from SjulsonLab/dev2
adding new task framework and web UI
2 parents 71a53e7 + de24bf0 commit da5a49f

65 files changed

Lines changed: 7523 additions & 988 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,10 +214,11 @@ irig_decoding/data/
214214
.idea
215215
temp/
216216
tmp_task_runs/
217-
sample_tasks/
217+
tmp_operator_runs/
218218
box_runtime/audio/local_source_wavs/
219219
box_runtime/audio/local_sounds/
220220
*.asv
221+
.DS_Store
221222
*.m~
222223
*.mex*
223224
Thumbs.db

Codex.md

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -92,35 +92,35 @@ Hardware/runtime support for RPi4 behavior boxes, including strict head-fixed GP
9292
- `BehavBox.event_timestamp(event)`
9393
- `interact_list` entries now reuse the same detection timestamp used for queue event creation.
9494

95-
## Head-Fixed GPIO Mapping
96-
- `user_configurable`: 4
97-
- `treadmill_1_input`: 13
98-
- `treadmill_2_input`: 16
99-
- `reward_left`: 19
100-
- `reward_right`: 20
101-
- `reward_center`: 21
102-
- `pump4`: 7
103-
- `airpuff`: 8
104-
- `vacuum`: 25
105-
- `cue_led_1`: 22
106-
- `cue_led_2`: 18
107-
- `cue_led_3`: 17
108-
- `cue_led_4`: 14
109-
- `lick_1`: 26
110-
- `lick_2`: 27
111-
- `lick_3`: 15
112-
- Reserved / unused for BehavBox: 5, 6, 11, 12
113-
- Legacy sound-board GPIO pins remain present on some hardware drawings, but the
114-
supported runtime no longer owns them. Sound playback now uses the direct USB
115-
audio subsystem under `box_runtime/audio/`.
116-
- Supported user-expansion path:
117-
- `BehavBox.configure_user_output(label=...)` reserves GPIO4 as a user-controlled digital output.
118-
- `BehavBox.configure_user_input(label=..., pull_up=..., active_state=...)` reserves GPIO4 as a user-controlled digital input.
119-
- GPIO4 may only be configured once per `BehavBox` instance.
95+
## Profile-Aware GPIO Mapping
96+
- Active GPIO mapping is loaded from `unified_GPIO_pin_arrangement_v4.csv`, not a hard-coded dict.
97+
- Canonical profile key: `box_profile`
98+
- fallback: `input_profile` if `box_profile` is absent
99+
- Dedicated trigger lines:
100+
- `trigger_in`: GPIO23
101+
- `trigger_out`: GPIO24
102+
- Generic user-configurable line:
103+
- GPIO4, claimed later as input or output if requested
104+
- Head-fixed inputs:
105+
- `ir_lick_left/right/center`: GPIO5/6/12
106+
- `lick_left/right/center`: GPIO26/27/15
107+
- `treadmill_1/2`: GPIO13/16
108+
- Freely-moving inputs:
109+
- `poke_left/right/center`: GPIO5/6/12
110+
- `poke_extra1/2`: GPIO13/16
111+
- Shared outputs:
112+
- `reward_left/right/center`: GPIO19/20/21
113+
- `reward_4`: GPIO7
114+
- `vacuum`: GPIO25
115+
- `cue_led_1..6`: GPIO22/18/17/14/10/11
116+
- `trigger_out`: GPIO24
117+
- Profile-specific GPIO8:
118+
- `head_fixed`: `airpuff`
119+
- `freely_moving`: `reward_5`
120+
- User-facing manual-control surfaces show canonical names plus board aliases (for example `reward_left (pump1)`).
120121
- Reserved-pin guard:
121-
- `box_runtime/behavior/gpio_backend.py` raises `ReservedPinError` if active runtime code tries to claim GPIO11 through the supported GPIO device classes.
122-
- GPIO11 is reserved because it is the pin used by the IRIG timecode sender output.
123-
- This protects IRIG timecode sender use of GPIO11 from future BehavBox edits, but archived `old/` scripts are not covered unless they import through `gpio_backend.py`.
122+
- `box_runtime/behavior/gpio_backend.py` raises `ReservedPinError` for GPIO9.
123+
- GPIO9 is reserved for the IRIG sender output and should not be claimed by active BehavBox runtime code.
124124

125125
## Runtime Behavior (Pi vs Non-Pi)
126126
- Raspberry Pi: real `gpiozero` devices.

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ This repository contains only the hardware and low-level support components
44
from the original `RPi4_behavior_boxes` codebase.
55

66
Included directories:
7-
- `essential/` (device interfaces, camera, treadmill, pump, acquisition)
7+
- `box_runtime/` (active behavior, input, output, audio, mock-hardware, and camera runtime services)
88
- `debug/` (hardware test/debug scripts)
99
- `environment/` (environment specification files)
10-
- `video_acquisition/` (active HTTP camera service plus archived legacy camera scripts)
11-
- `HQ_camera/` (HQ camera support scripts from `matt-behavior`)
10+
- `docs/` (design notes and Sphinx docs)
11+
- `sample_tasks/` (reference task runner and example tasks)
1212

1313
Excluded from this split:
1414
- `task_protocol/` (task-specific experiment logic)
@@ -31,8 +31,12 @@ to support independent versioning of hardware code and task code.
3131

3232
## Head-Fixed GPIO + Mock UI
3333

34-
- BehavBox now uses a strict head-fixed GPIO arrangement hard-coded in:
35-
`essential/behavbox.py` (`HEAD_FIXED_GPIO`).
34+
- BehavBox now uses a profile-aware GPIO manifest loaded from
35+
`unified_GPIO_pin_arrangement_v4.csv`.
36+
- Canonical runtime code uses semantic names such as `reward_left`,
37+
`trigger_in`, and `cue_led_5`.
38+
- User-facing mock/web surfaces display semantic names plus board aliases such
39+
as `reward_left (pump1)` or `trigger_out (DIO2)`.
3640
- Hardware callbacks now enqueue structured `BehaviorEvent` objects with
3741
detection-time wall-clock timestamps (`name`, `timestamp`) instead of plain
3842
event-name strings.

box_runtime/audio/runtime.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44

55
from dataclasses import dataclass
66
import logging
7+
import os
78
from pathlib import Path
89
import threading
910
import time
10-
from typing import Protocol
11+
from typing import Callable, Optional, Protocol
1112

1213
import numpy as np
1314

@@ -83,6 +84,51 @@ def close(self) -> None:
8384
return None
8485

8586

87+
class RecordingPlaybackBackend:
88+
"""Mock playback backend that records submitted stereo buffers.
89+
90+
Args:
91+
sample_rate_hz: Playback sampling rate in hertz.
92+
chunk_frames: Number of stereo frames consumed per worker iteration.
93+
chunk_sleep_s: Optional per-chunk sleep used to emulate slower devices.
94+
"""
95+
96+
def __init__(self, sample_rate_hz: int, chunk_frames: int = 256, chunk_sleep_s: float = 0.0):
97+
self.sample_rate_hz = int(sample_rate_hz)
98+
self.chunk_frames = int(chunk_frames)
99+
self.chunk_sleep_s = float(chunk_sleep_s)
100+
self.play_calls: list[np.ndarray] = []
101+
102+
def write_frames(self, frames: np.ndarray, stop_requested) -> int:
103+
"""Record the played stereo frames.
104+
105+
Args:
106+
frames: Stereo ``int16`` array of shape ``(num_frames, 2)``.
107+
stop_requested: Zero-argument callable returning whether playback
108+
should stop early.
109+
110+
Returns:
111+
Number of stereo frames consumed.
112+
"""
113+
114+
consumed = 0
115+
chunks: list[np.ndarray] = []
116+
total_frames = int(frames.shape[0])
117+
while consumed < total_frames:
118+
if stop_requested():
119+
break
120+
stop = min(consumed + self.chunk_frames, total_frames)
121+
chunks.append(frames[consumed:stop].copy())
122+
consumed = stop
123+
if self.chunk_sleep_s > 0:
124+
time.sleep(self.chunk_sleep_s)
125+
self.play_calls.append(np.vstack(chunks) if chunks else np.empty((0, 2), dtype=np.int16))
126+
return consumed
127+
128+
def close(self) -> None:
129+
return None
130+
131+
86132
class PyAlsaAudioPlaybackBackend:
87133
"""Persistent ALSA playback backend for stereo PCM audio."""
88134

@@ -138,6 +184,9 @@ class SoundRuntime:
138184
ramp_duration_s: Ramp duration in seconds applied to all cue edges.
139185
reference_rms: Canonical RMS amplitude used for imports and built-in
140186
white noise generation.
187+
state_callback: Optional callback receiving JSON-serializable runtime
188+
state updates with keys such as ``active`` and
189+
``current_cue_name``.
141190
"""
142191

143192
def __init__(
@@ -149,6 +198,7 @@ def __init__(
149198
period_size_frames: int = 256,
150199
ramp_duration_s: float = 0.002,
151200
reference_rms: float = DEFAULT_REFERENCE_RMS,
201+
state_callback: Optional[Callable[[dict[str, object]], None]] = None,
152202
):
153203
self.paths = paths
154204
self.paths.ensure_directories()
@@ -163,6 +213,7 @@ def __init__(
163213
self.device_name = str(device_name)
164214
self.period_size_frames = int(period_size_frames)
165215
self.backend = backend or self._build_backend()
216+
self.state_callback = state_callback
166217
self.loaded_sounds: dict[str, LoadedSound] = {}
167218
self._condition = threading.Condition()
168219
self._pending_request: tuple[int, PlaybackRequest] | None = None
@@ -172,6 +223,7 @@ def __init__(
172223
self._shutdown = False
173224
self._worker = threading.Thread(target=self._worker_main, name="behavbox-sound", daemon=True)
174225
self._worker.start()
226+
self._emit_state(active=False, current_cue_name=None, last_cue_name=None)
175227

176228
def import_wav_file(
177229
self,
@@ -221,6 +273,33 @@ def clear_sounds(self) -> None:
221273

222274
self.loaded_sounds.clear()
223275

276+
def register_white_noise(self, name: str, duration_s: float, seed: int = 0) -> LoadedSound:
277+
"""Register a generated white-noise cue directly in memory.
278+
279+
Args:
280+
name: Cue identifier.
281+
duration_s: Cue duration in seconds.
282+
seed: Deterministic random seed for waveform generation.
283+
284+
Returns:
285+
LoadedSound prepared for playback.
286+
"""
287+
288+
waveform = generate_white_noise(
289+
duration_s=float(duration_s),
290+
sample_rate_hz=self.sample_rate_hz,
291+
rms=self.reference_rms,
292+
seed=int(seed),
293+
)
294+
loaded = build_loaded_sound(
295+
name=Path(name).stem,
296+
waveform_mono=waveform,
297+
sample_rate_hz=self.sample_rate_hz,
298+
ramp_duration_s=self.ramp_duration_s,
299+
)
300+
self.loaded_sounds[loaded.name] = loaded
301+
return loaded
302+
224303
def play_sound(
225304
self,
226305
name: str,
@@ -254,6 +333,7 @@ def play_sound(
254333
self._pending_request = (next_token, request)
255334
self._busy = True
256335
self._condition.notify_all()
336+
self._emit_state(active=True, current_cue_name=cue_key, last_cue_name=cue_key)
257337

258338
def stop_sound(self) -> None:
259339
"""Interrupt the currently playing cue, if any."""
@@ -262,6 +342,7 @@ def stop_sound(self) -> None:
262342
self._stop_token = max(self._stop_token, self._active_token + 1)
263343
self._active_token = max(self._active_token, self._stop_token)
264344
self._condition.notify_all()
345+
self._emit_state(active=False, current_cue_name=None)
265346

266347
def start_sound_calibration(self, side: str = "both", gain_db: float = 0.0) -> None:
267348
"""Start long-running white-noise playback for hardware calibration."""
@@ -368,8 +449,15 @@ def close(self) -> None:
368449
self._condition.notify_all()
369450
self._worker.join(timeout=2.0)
370451
self.backend.close()
452+
self._emit_state(active=False, current_cue_name=None)
371453

372454
def _build_backend(self) -> PlaybackBackend:
455+
if str(os.environ.get("BEHAVBOX_MOCK_AUDIO", "0")).strip().lower() in {"1", "true", "yes", "on"}:
456+
LOGGER.info("Using recording audio backend because BEHAVBOX_MOCK_AUDIO is enabled.")
457+
return RecordingPlaybackBackend(
458+
sample_rate_hz=self.sample_rate_hz,
459+
chunk_sleep_s=256.0 / float(self.sample_rate_hz),
460+
)
373461
if alsaaudio is None: # pragma: no cover - exercised on Raspberry Pi hardware
374462
LOGGER.warning("pyalsaaudio is unavailable; using null audio backend.")
375463
return NullPlaybackBackend(sample_rate_hz=self.sample_rate_hz)
@@ -402,6 +490,11 @@ def _worker_main(self) -> None:
402490
if self._pending_request is None:
403491
self._busy = False
404492
self._condition.notify_all()
493+
self._emit_state(
494+
active=False,
495+
current_cue_name=None,
496+
last_cue_name=request.name,
497+
)
405498

406499
def _interrupt_requested(self, token: int) -> bool:
407500
with self._condition:
@@ -425,3 +518,14 @@ def _log_clipping_if_needed(self, request: PlaybackRequest) -> None:
425518
clipped_percent,
426519
overshoot_db,
427520
)
521+
522+
def _emit_state(self, **payload: object) -> None:
523+
"""Forward one audio runtime-state update to the optional observer.
524+
525+
Args:
526+
payload: JSON-serializable audio runtime-state fields.
527+
"""
528+
529+
if self.state_callback is None:
530+
return
531+
self.state_callback(dict(payload))

0 commit comments

Comments
 (0)