Skip to content

feat: capture orchestration framework redesign - #1

Merged
styu12 merged 23 commits into
mainfrom
feature/capture-orchestration
Apr 9, 2026
Merged

feat: capture orchestration framework redesign#1
styu12 merged 23 commits into
mainfrom
feature/capture-orchestration

Conversation

@styu12

@styu12 styu12 commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replaces the old SyncSession capture API with a pluggable SessionOrchestrator framework built around a Stream SPI, so new modalities can be added as adapters instead of patched into a monolith.
  • Adds core primitives (SessionClock, SessionLogWriter, sync-tone chirp player) and three reference adapters: UVCWebcamStream, BLEImuGenericStream, JSONLFileStream.
  • Ships an end-to-end round-trip integration test plus a standalone scripts/e2e_chirp_check.py for verifying the audio chirp path on real hardware.

Changes

  • SPI / types (types.py, stream.py, clock.py): Stream protocol + StreamBase, StreamCapabilities, SessionState, HealthEvent, SampleEvent, FinalizationReport, SessionReport, ChirpSpec, SessionClock.
  • Orchestrator (orchestrator.py): add(), atomic start() with rollback on partial failure, stop() finalization sequence, crash-safe session log, stream health event routing, integrated audio sync chirp.
  • Tone (tone.py): stdlib-only linear FM chirp generator, 16-bit PCM WAV writer, ChirpPlayer protocol with sounddevice and silent backends, SyncToneConfig with egonaut defaults. Playback now hands sounddevice a numpy buffer.
  • Writer (writer.py): extended sync_point + new SessionLogWriter.
  • Adapters (adapters/): UVCWebcamStream, BLEImuGenericStream, JSONLFileStream reference implementations with their own unit tests.
  • Testing utilities (testing.py): FakeStream used by orchestrator unit tests.
  • Public API (__init__.py): re-exports the new framework surface; old SyncSession/capture.py removed.
  • Tests: restructured into tests/unit/ + tests/integration/, added round-trip E2E coverage, clock/tone/writer/stream/orchestrator/public-API unit tests.
  • Docs: refreshed orchestrator module and stop() docstrings.
  • Deps (pyproject.toml, uv.lock): adds runtime deps required by the new adapters/tone path.

Test Plan

  • uv run pytest tests/unit
  • uv run pytest tests/integration/test_round_trip.py
  • uv run python scripts/e2e_chirp_check.py on a machine with audio output (manual hardware check)
  • CI: .github/workflows/test.yml passes on this branch

🤖 Generated with Claude Code

styu12 and others added 23 commits April 9, 2026 01:41
Delete obsolete SyncSession capture.py and its tests. Reorganize tests into
unit/ and integration/ subdirectories. Create adapters/ package. Update
pyproject.toml with optional extras for audio/uvc/ble backends.

Breaking change: SyncSession is removed. New SessionOrchestrator API
replaces it in subsequent tasks.
…Event, SampleEvent, FinalizationReport, ChirpSpec, SessionReport

Add new dataclasses and enums for the SessionOrchestrator + Stream SPI.
Existing SyncPoint/FrameTimestamp/SensorSample types are preserved
unchanged to keep output file compatibility with the sync core.
Immutable clock handle distributed to Streams at session.start(). Wraps
the session's SyncPoint and provides now_ns / elapsed_ns helpers so
streams don't import time directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- write_sync_point accepts optional chirp_start_ns / chirp_stop_ns / chirp_spec
  and omits fields when None (clean output for silent/single-host sessions)
- SessionLogWriter writes orchestrator-level events (state transitions, health,
  rollbacks) to session_log.jsonl with per-write flush for crash safety
- write_manifest stores per-stream capabilities via the existing streams dict

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
generate_chirp_samples produces mono float PCM for a linear FM sweep with
cosine envelope, matching the egonaut SoundFeedbackModule design (400↔2500Hz,
500ms, 15ms envelope). Pure Python math — no numpy dependency — so the core
SDK stays lightweight.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Used by playback backends (sounddevice or system audio) and for debugging
chirp signals. Clamps floats to [-1, 1] before int16 conversion so amplitude
overflows never corrupt output.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Frozen dataclass with default() and silent() factory methods. Defaults
match the egonaut production chirp spec (400↔2500Hz, 500ms, 0.8 amplitude,
15ms envelope, 200ms stabilization/tail margins).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- ChirpPlayer is a runtime_checkable Protocol
- SoundDeviceChirpPlayer wraps the optional sounddevice library (non-blocking)
- SilentChirpPlayer is a no-op fallback for headless machines
- create_default_player chooses the right backend at runtime, logging at
  INFO when sounddevice is unavailable (never raises)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Stream is a runtime_checkable Protocol describing the SPI contract
- StreamBase provides callback registration and health buffering so
  concrete adapters only implement prepare/start/stop
- Sample and health events flow to all registered callbacks; health
  events also accumulate in an internal buffer used by FinalizationReport

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Programmable in-memory Stream with lifecycle tracking, sample/health
injection, and failure-injection flags. Ships in syncfield.testing so
third-party adapter authors can reuse it for their own tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Class definition, state enum, add() with duplicate-id rejection, output
directory creation on construction. Thread-safe via internal RLock;
start/stop lifecycle methods added in subsequent tasks.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Captures a SyncPoint and builds a shared SessionClock
- Calls prepare() then start() on every registered stream sequentially
- On any failure: rolls back previously-started streams in reverse order
  (best-effort stop), returns state to IDLE, re-raises the original exception
- The failed stream itself is NOT rolled back since it never reached a
  successfully-started state

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Calls stop() on each stream (best-effort — a broken stream becomes a
failed FinalizationReport instead of blocking the others), writes
sync_point.json and manifest.json with per-stream capabilities, and
transitions through STOPPING to STOPPED. Returns an aggregated
SessionReport.

Logic is split into _finalize_streams and _persist_session_artifacts
helpers to keep stop() itself readable as a state-machine overview.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Chirp eligibility is host-level: requires at least one registered stream
  with provides_audio_track=True. When no audio stream exists, log INFO
  and skip the chirp. Single-host sessions are unaffected.
- Start chirp plays after all streams start, following post_start_stabilization_ms.
- Stop chirp plays BEFORE stream.stop() so it is captured in recording audio
  tracks; orchestrator then sleeps duration + pre_stop_tail_margin_ms.
- chirp_start_ns / chirp_stop_ns / chirp_spec flow to sync_point.json and
  to the returned SessionReport when a chirp was played.
- Split into _maybe_play_start_chirp / _maybe_play_stop_chirp_and_wait so
  start() and stop() read as clean state-machine overviews.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Opens SessionLogWriter during start() BEFORE any state change so failures
  are still recorded on disk
- Every state transition flows through _transition() which logs immediately
  and flushes per-write, giving a recoverable timeline even if the process
  dies mid-recording
- Rollbacks are logged with the failing exception message and the number of
  streams that had to be torn down

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Every stream registered via add() has its on_health callback wired to
  the orchestrator's session log writer
- Drops, reconnects, warnings, and errors flow into session_log.jsonl so
  the core can enrich sync_report.json with observability data
- Health events emitted before start() are still buffered by StreamBase
  and surface through the FinalizationReport, so nothing is lost even in
  the unusual case of early emission

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Wraps a customer-owned JSONL file as a Stream. No I/O of its own during
recording — just tracks lifecycle and counts lines on stop(). Missing file
yields status='partial'. Useful for customers who already have their own
sample-writing code and just want orchestrator coordination.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
OpenCV-based adapter for USB/UVC webcams. Background capture thread reads
frames, timestamps each read with time.monotonic_ns(), emits a SampleEvent,
and writes MP4 via cv2.VideoWriter. Declares produces_file=True and
provides_audio_track=False (OpenCV has no audio path).

Gated behind optional 'uvc' extra — raises a clear ImportError with the
install hint when opencv-python is missing, so users know exactly how to
resolve it.

Internals are split into prepare/start/stop/_capture_loop plus small helper
methods (_resolve_frame_geometry, _release_cv2_resources) so the lifecycle
reads cleanly and the capture loop stays focused on its tight inner path.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Generic BLE IMU adapter using bleak. Bridges async bleak into the sync
orchestrator by running an asyncio event loop on an internal background
thread. Subscribes to a notify characteristic and decodes payloads with
a configurable struct format and configurable channel names.

- Connection/disconnection errors become ERROR health events
- Payload decode errors (malformed notifications) become WARNING health
  events — never tear down the stream
- Channel-name / format-length mismatches raise ValueError at construction
- Payload decoding logic is split into _handle_payload so it is unit
  testable via a synchronous _dispatch_notification_for_test hook with no
  asyncio required
- Gated behind optional 'ble' extra with clear install hint

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Top-level exports: SessionOrchestrator, Stream, StreamBase, types,
SyncToneConfig, ChirpSpec, SessionClock. Adapters subpackage always
re-exports JSONLFileStream; UVC and BLE are re-exported lazily so an
uninstalled extra does not break 'import syncfield.adapters'.

Users needing a specific optional adapter can always import it directly
from its module — that path raises a clear ImportError with an install
hint when the dependency is missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Drives a two-stream session through the full lifecycle using FakeStream,
asserts sync_point.json / manifest.json / session_log.jsonl match the
schema the sync core expects. Covers: happy path with audio-capable
stream, silent-mode session (no chirp fields), and audio-less single-host
session (chirp skipped, session still completes).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The module docstring and stop() docstring still referenced "added in
subsequent tasks" from the incremental development. Replace them with
accurate overviews that reflect the final file organization and the
fully-integrated stop sequence including chirp playback.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
sounddevice.play() internally requires numpy even when the caller passes a
Python list, so SoundDeviceChirpPlayer now prepares a float32 ndarray
explicitly. numpy is imported once at module load time (guarded by try/except)
instead of inline inside play(), because doing it inline triggered numpy's
C-extension one-time-init failure when unit tests patched sys.modules
inside with-blocks.

- pyproject.toml: syncfield[audio] now depends on numpy>=1.21 as well as
  sounddevice>=0.4.6. The [all] extra is kept in sync.
- tone.py: module-level `try: import numpy as _np` once; play() uses _np
  to build the buffer, falling back to passing the raw list on machines
  that somehow have sounddevice but not numpy (real-world impossible,
  but the fallback keeps the contract honest).
- scripts/e2e_chirp_check.py: standalone verification script that runs a
  real session against sounddevice on a headful machine. Checks:
  1. create_default_player() picks SoundDeviceChirpPlayer
  2. sync_point.json carries chirp_{start,stop}_ns and chirp_spec
  3. stop_chirp_ns > start_chirp_ns with plausible spacing
  4. (best-effort) a parallel sounddevice InputStream cross-correlates
     against the reference chirp — WARNs on quiet rooms rather than
     failing so the script stays usable in varied environments
  5. audio-less sessions cleanly skip the chirp path

Verified on macOS with MacBook Pro built-in speakers: all 5 checks pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@styu12
styu12 merged commit 31323e5 into main Apr 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant