capture_helper package
Submodules
- capture_helper.api module
- capture_helper.camera module
- capture_helper.cli_argparse module
- capture_helper.cli_click module
- capture_helper.gui module
- capture_helper.mcp module
- capture_helper.mic module
- capture_helper.preview module
- capture_helper.scene module
- capture_helper.sources module
Module contents
Capture Helper
Local-first, library-shaped camera / microphone capture layer for the AI Helpers stack. It ships the INPUT layer: cross-platform device enumeration + selection (cameras / microphones) and a pair of iterators that bridge those devices to the rest of the suite’s contracts — plus a live multi-source scene configurator (a browser GUI served by the FastAPI app) that turns a visual layout of live sources into a reusable JSON scene artifact the CLI / API can replay headless.
The capture layer
SourceKind— literal"camera"|"microphone".Source— typed dict describing one device.MicFrame— typed dict for one PCM frame (mirrorspodcast_helper.streaming.PcmFrame).list_sources()— best-effort cross-platform device enumeration viaffmpeg -list_devices(avfoundation / v4l2 / dshow / pulse / alsa). Returns[]rather than raising on unsupported platforms.pick_source()— pick the first device of a given kind matching optionalname_substring/indexfilters.iter_camera_frames()— synchronous generator yielding(H, W, 3)BGR uint8 numpy arrays — same shape and dtype asvideo_helper.extract_frames().iter_mic_audio()— async generator yieldingMicFrame— same shape aspodcast_helper.extract_audio_stream().
The scene configurator (additive, does not touch the iterators)
Scene/SceneSource— a named canvas of placed sources with per-source capture parameters.new_scene()/add_source()— build a scene in code.save_scene()/load_scene()/validate_scene()— persist and reload the visual design as JSON.resolve_scene_sources()/scene_from_available_devices()— map a scene onto the current machine’s live devices.snapshot_jpeg()/iter_camera_jpeg()/mic_level()— live-preview primitives powering the browser GUI (camera JPEG / MJPEG, microphone level meters).
This is an early-stage project. The public iterator contracts above are stable; the scene / preview surface is new and additive.
Usage example
>>> import asyncio, capture_helper as ch
>>> cam = ch.pick_source("camera")
>>> for frame in ch.iter_camera_frames(cam, output_width=640, output_height=360,
... fps=30, max_frames=300):
... # frame.shape == (360, 640, 3), dtype uint8, BGR.
... do_something(frame)
>>>
>>> async def listen():
... mic = ch.pick_source("microphone")
... async for f in ch.iter_mic_audio(mic, target_sample_rate=16000, frame_ms=20):
... # f["pcm"].shape == (320,) — 20ms @ 16kHz mono.
... await asr.feed(f["pcm"])
>>> asyncio.run(listen())
- class capture_helper.MicFrame[source]
Bases:
TypedDictOne PCM frame from a live microphone, in absolute capture time.
Structurally identical to
podcast_helper.streaming.PcmFrameso downstream consumers (VAD, ASR, dB-meter, …) don’t have to branch on the source type.Keys
- t_abs_sfloat
Seconds since ffmpeg latched onto the device. Monotonic.
- pcmNDArray[np.float32]
Audio samples in
[-1.0, 1.0],float32.Shape
(n_samples,)whento_mono=True(default).Shape
(n_samples, n_channels)whento_mono=Falseand the source has more than one channel.
- voicedbool | None
Always
Nonehere — VAD downstream fills it in if used.
- class capture_helper.ResolvedSceneSource[source]
Bases:
TypedDictA
SceneSourcepaired with the live device it resolved to.Keys
- scene_sourceSceneSource
The original recipe from the scene.
- resolvedSource | None
The live
capture_helper.Sourcematched on this machine, orNonewhen no device satisfied the recipe’s selectors.- errorstr | None
Human-readable reason the recipe could not be resolved, or
Noneon success.
- scene_source: SceneSource
- class capture_helper.Scene[source]
Bases:
TypedDictA named, serialisable multi-source composition.
Keys
- format_versionint
On-disk schema version (
SCENE_FORMAT_VERSION).- namestr
Human name for the scene (also the suggested filename stem).
- width, heightint
Canvas size in pixels. Source tiles are positioned within this box.
- sourceslist[SceneSource]
Ordered list of placed sources (draw order for cameras is by
z, ties broken by list order).
- sources: list[SceneSource]
- class capture_helper.SceneSource[source]
Bases:
TypedDictOne source placed on a scene canvas.
A
SceneSourceis a recipe, not a live handle: it records how to re-select the device (kind+ optionalname_substring/index), where it sits on the canvas (x/y/w/h/z), and the capture parameters to pass to the iterators when the scene is run.Keys
- idstr
Stable, GUI-generated identifier (uuid4 hex) so the front-end can address a placed source across drag / resize / delete without relying on list order.
- kind
"camera"|"microphone" Which iterator this source drives.
- labelstr
Human-friendly name shown in the GUI (e.g.
"host cam"). Free text.- name_substringstr | None
Case-insensitive substring used to re-resolve the device via
capture_helper.pick_source().None= no name constraint.- indexint | None
Exact device index used to re-resolve the device.
None= no index constraint. When both areNonethe first device ofkindis used.- x, yint
Top-left position of the source’s tile on the canvas, in pixels.
- w, hint
Tile size in pixels. For microphones (no visual) these may be
0.- zint
Stacking order — higher draws on top. Cameras only.
- paramsdict
Free-form capture parameters forwarded to the iterator. For cameras:
fps,output_width,output_height,pad_color. For microphones:target_sample_rate,frame_ms,to_mono. Unknown keys are ignored by the runner, so the GUI can round-trip extras safely.
- class capture_helper.Source[source]
Bases:
TypedDictOne capture device, normalised across platforms.
Keys
- kind
"camera"|"microphone" Audio / video distinction.
- namestr
Human-readable name as reported by the OS driver. Use this when passing to a future
iter_camera_frames/iter_mic_audio— the per-OS ffmpeg input string is built internally.- indexint
Numeric device index in ffmpeg’s listing (0-based). Some OS backends only accept the index; others accept name OR index.
- platformstr
"darwin"/"linux"/"windows"— useful for callers that branch on the underlying driver (avfoundation / v4l2 / dshow / pulse).- driverstr
ffmpeg input format flag —
"avfoundation"/"v4l2"/"dshow"/"pulse"/"alsa".
- kind
- capture_helper.add_source(scene, *, kind, name_substring=None, index=None, label=None, x=0, y=0, w=0, h=0, z=0, params=None, source_id=None)[source]
Return a copy of
scenewith one additional placed source.The input scene is not mutated — a shallow copy with a new
sourceslist is returned, so callers holding the old scene keep it intact (matches the immutable-update convention used across the suite’s config helpers).- Parameters:
scene (Scene) – The scene to extend.
kind (
"camera"|"microphone") – Source kind.name_substring (str, optional) – Case-insensitive device-name filter recorded for later resolution.
index (int, optional) – Exact device-index filter recorded for later resolution.
label (str, optional) – Display name. Defaults to a generated
"<kind> N"label.x (int) – Canvas placement (see
SceneSource).y (int) – Canvas placement (see
SceneSource).w (int) – Canvas placement (see
SceneSource).h (int) – Canvas placement (see
SceneSource).z (int) – Canvas placement (see
SceneSource).params (dict, optional) – Capture parameters forwarded to the iterator at run time.
source_id (str, optional) – Explicit id (uuid4 hex). Generated when omitted — the GUI usually supplies its own so the round-trip is stable.
- Returns:
A new scene including the added source.
- Return type:
Examples
>>> s = new_scene("demo") >>> s2 = add_source(s, kind="camera", label="cam", w=1280, h=720) >>> len(s["sources"]), len(s2["sources"]) (0, 1)
- capture_helper.ffmpeg_input_args(source)[source]
Build the ffmpeg
-f <driver> -i <spec>argument pair forsource.Encapsulates the per-OS quirks so
capture_helper.cameraandcapture_helper.micdon’t have to know about them:avfoundation(macOS) addresses devices as"<video_idx>:<audio_idx>"where"none"opts out of the other track.v4l2(Linux) uses the device path (/dev/videoN) reported bylist_sources().pulse(Linux) andalsa(Linux) take the source/device name.dshow(Windows) prefixes the device name withvideo=oraudio=.
- Parameters:
source (Source) – A device dict returned by
list_sources()/pick_source().- Returns:
Two elements:
["-f", "<driver>"]followed by["-i", "<spec>"]— ready to splice into an ffmpeg command line.- Return type:
- Raises:
ValueError – If the source’s driver is unknown to this helper.
- capture_helper.frame_to_jpeg(frame, *, quality=5)[source]
Encode a single BGR frame to a JPEG byte string via ffmpeg.
- Parameters:
frame (numpy.ndarray) – A
(H, W, 3)uint8array in OpenCV BGR channel order — exactly whatcapture_helper.iter_camera_frames()yields.quality (int, default 5) – ffmpeg
-q:vvalue (2best …31worst). Lower = larger file, better image.5is a good preview trade-off.
- Returns:
The JPEG-encoded image.
- Return type:
- Raises:
ValueError – If
frameis not a(H, W, 3)uint8 array.FileNotFoundError – If ffmpeg is not on PATH.
RuntimeError – If ffmpeg fails to encode the frame.
Examples
>>> import numpy as np >>> jpg = frame_to_jpeg(np.zeros((16, 16, 3), dtype=np.uint8)) >>> jpg[:2] == b"\xff\xd8" # JPEG SOI marker True
- capture_helper.iter_camera_frames(source, *, width=None, height=None, fps=None, output_width=None, output_height=None, pad_color='black', max_frames=None)[source]
Yield live camera frames as numpy BGR uint8 arrays.
Wraps ffmpeg with the per-OS input driver picked up from
source["driver"]and emits(H, W, 3)uint8arrays in OpenCV’s BGR channel order — the same shape and dtypevideo_helper.extract_frames()yields. Consumers built for the file-based path therefore plug in unchanged.- Parameters:
source (Source) – Device dict returned by
pick_source()/list_sources(). Itskindmust be"camera".width (int, optional) – Capture-side resolution request. Passed to ffmpeg as
-video_size WxHbefore the input — the OS driver picks the closest supported mode if the exact value isn’t available. LeaveNoneto use the driver’s default.height (int, optional) – Capture-side resolution request. Passed to ffmpeg as
-video_size WxHbefore the input — the OS driver picks the closest supported mode if the exact value isn’t available. LeaveNoneto use the driver’s default.fps (float, optional) – Capture-side frame rate request. Same caveat as
width / height. LeaveNonefor the driver default.output_width (int, optional) –
Post-decode output size. Behaviour mirrors
video_helper.extract_frames():both set → scale-fit (aspect preserved) then pad with
pad_colorto exactlyoutput_width × output_height;one set → scale that axis, preserve aspect on the other;
neither set → native camera frame size.
output_height (int, optional) –
Post-decode output size. Behaviour mirrors
video_helper.extract_frames():both set → scale-fit (aspect preserved) then pad with
pad_colorto exactlyoutput_width × output_height;one set → scale that axis, preserve aspect on the other;
neither set → native camera frame size.
pad_color (str, optional) – Colour name (
"black"/"white"/"#RRGGBB"/ …) used when scale-fit-and-pad applies. Default"black".max_frames (int, optional) – Stop after yielding this many frames.
None(default) = unbounded — runs until the source disconnects or the consumer breaks.
- Yields:
numpy.ndarray – Successive frames as
(H, W, 3)BGRuint8arrays. Same convention as OpenCV andvideo_helper.extract_frames().- Raises:
ValueError – If
source["kind"]isn’t"camera".FileNotFoundError – If
ffmpegisn’t on PATH.RuntimeError – If ffmpeg exits before the first frame is decoded (typical when the OS denied camera permission or the device is in use by another process).
- Return type:
Iterator[ndarray]
Examples
>>> cam = ch.pick_source("camera") >>> for frame in ch.iter_camera_frames(cam, ... output_width=224, output_height=224, ... max_frames=10): ... # frame.shape == (224, 224, 3), dtype uint8, BGR. ... model(frame)
- capture_helper.iter_camera_jpeg(source, *, output_width=480, output_height=270, fps=10.0, quality=5, max_frames=None)[source]
Yield a stream of JPEG-encoded frames from a live camera.
Each yielded item is a complete JPEG suitable for concatenation into an
multipart/x-mixed-replaceMJPEG HTTP response (seecapture_helper.api()).- Parameters:
source (Source) – A
"camera"device.output_width (int) – Preview size per frame.
output_height (int) – Preview size per frame.
fps (float, default 10.0) – Capture-side frame rate — kept low so the preview is cheap.
quality (int, default 5) – JPEG quality per frame.
max_frames (int, optional) – Stop after this many frames.
None= until the consumer disconnects.
- Yields:
bytes – Successive JPEG images.
- Raises:
ValueError – If
sourceis not a camera.- Return type:
Examples
>>> cam = ... >>> for jpg in iter_camera_jpeg(cam, max_frames=3): ... handle(jpg)
- async capture_helper.iter_mic_audio(source, *, target_sample_rate=16000, to_mono=True, frame_ms=20, max_frames=None)[source]
Yield PCM frames from a live microphone.
- Parameters:
source (Source) – Device dict returned by
pick_source()/list_sources(). Itskindmust be"microphone".target_sample_rate (int, default 16000) – Exact output sample rate in Hz. ffmpeg resamples via libswresample with an anti-aliasing low-pass at the new Nyquist — Shannon-correct and more than enough for ASR / VAD / ML pipelines.
to_mono (bool, default True) –
True: ffmpeg standard downmix to one channel;
pcm.shape == (n_samples,).False: preserve the source’s native channel count;
pcm.shape == (n_samples, n_channels)(only set when the source actually has more than one channel).
frame_ms (int, default 20) – Frame duration in milliseconds.
20matches Silero VAD’s native frame size, avoiding a downstream re-buffer.max_frames (int, optional) – Stop after yielding this many frames.
None(default) = unbounded — runs until the source disconnects or the consumer breaks theasync for.
- Yields:
MicFrame – Successive PCM frames in absolute capture time.
- Raises:
ValueError – If
source["kind"]isn’t"microphone", or ifframe_ms/target_sample_rateis non-positive.FileNotFoundError – Raised by ffmpeg / asyncio if
ffmpegisn’t on PATH.
- Return type:
Examples
>>> import asyncio, capture_helper as ch >>> async def main(): ... mic = ch.pick_source("microphone") ... async for frame in ch.iter_mic_audio(mic): ... # frame["pcm"]: np.float32 (320,) for 20ms @ 16kHz mono ... pass >>> asyncio.run(main())
- capture_helper.list_sources(kind=None)[source]
Enumerate available capture devices on the current OS.
- Parameters:
kind (
"camera"|"microphone"| None) – Filter to one kind.None(default) returns both.- Returns:
Possibly empty if ffmpeg is missing or the OS driver returns nothing parseable. Never raises.
- Return type:
Examples
>>> from capture_helper import list_sources >>> for s in list_sources("microphone"): ... print(f"[{s['index']}] {s['name']} (driver={s['driver']})")
- capture_helper.load_scene(path)[source]
Load and validate a scene from a JSON file.
- Parameters:
path (str or Path) – Path to a scene JSON file.
- Returns:
The parsed, validated scene.
- Return type:
- Raises:
FileNotFoundError – If
pathdoes not exist.ValueError – If the file is not valid JSON or not a well-formed scene.
Examples
>>> import tempfile, os >>> p = os.path.join(tempfile.mkdtemp(), "demo.scene.json") >>> _ = save_scene(new_scene("demo"), p) >>> load_scene(p)["name"] 'demo'
- async capture_helper.mic_level(source, *, target_sample_rate=16000, frame_ms=20, window_ms=200)[source]
Capture a short slice of live audio and reduce it to a level reading.
Drives
capture_helper.iter_mic_audio()for roughlywindow_msof audio, then returns RMS / peak levels — the GUI polls this to animate a VU meter without holding a long-lived stream open.- Parameters:
- Returns:
{"rms": …, "rms_dbfs": …, "peak": …, "peak_dbfs": …}.- Return type:
- Raises:
ValueError – If
sourceis not a microphone.
Examples
>>> import asyncio >>> mic = ... >>> asyncio.run(mic_level(mic))
- capture_helper.new_scene(name='untitled', *, width=1280, height=720)[source]
Create an empty scene with a named canvas.
- Parameters:
- Returns:
A scene with no sources yet.
- Return type:
- Raises:
ValueError – If
widthorheightis not a positive integer.
Examples
>>> s = new_scene("demo", width=640, height=360) >>> s["width"], s["height"], s["sources"] (640, 360, [])
- capture_helper.pick_source(kind, *, name_substring=None, index=None)[source]
Pick a single capture device matching the given constraints.
Runs
list_sources()forkindand applies each non-None filter as a hard predicate. Returns the first remaining candidate (OS-listing order — usually “built-in first, peripherals after”). RaisesValueErrorif nothing matches.- Parameters:
kind (
"camera"|"microphone") – Which kind of device to pick.name_substring (str, optional) – Case-insensitive substring filter against
Source["name"]. Useful to disambiguate when several devices of the same kind are present ("BlackHole","USB","FaceTime", …).index (int, optional) – Exact match against
Source["index"]. When the OS reports stable indices (avfoundation, dshow), this picks a specific device unambiguously.
- Returns:
The chosen device.
- Return type:
- Raises:
ValueError – If no device matches (either the catalog is empty, or every candidate is filtered out by the constraints).
Examples
>>> from capture_helper import pick_source >>> cam = pick_source("camera") # first available camera >>> mic = pick_source("microphone", name_substring="BlackHole") >>> usb_cam = pick_source("camera", index=1)
- capture_helper.resolve_scene_sources(scene)[source]
Match every scene source against the current machine’s live devices.
A scene authored elsewhere (or before a device was plugged in) may not fully resolve here. Rather than raising on the first miss, this returns one
ResolvedSceneSourceper recipe so the GUI / CLI can report exactly which tiles are live and which are dangling.- Parameters:
scene (Scene) – The scene to resolve.
- Returns:
One entry per source in
scene["sources"], in the same order.- Return type:
Examples
>>> res = resolve_scene_sources(new_scene("empty")) >>> res []
- capture_helper.rms_dbfs(pcm)[source]
Compute linear RMS and its dBFS equivalent for a PCM block.
- Parameters:
pcm (numpy.ndarray) – Float32 samples in
[-1, 1](any shape; flattened internally).- Returns:
(rms_linear, rms_dbfs).rms_dbfsis clamped at-120.0for digital silence to avoid-inf.- Return type:
Examples
>>> import numpy as np >>> lin, db = rms_dbfs(np.zeros(100, dtype=np.float32)) >>> lin == 0.0 and db == -120.0 True
- capture_helper.save_scene(scene, path)[source]
Serialise a scene to a JSON file on disk.
- Parameters:
- Returns:
The absolute path the scene was written to.
- Return type:
- Raises:
ValueError – If
scenefailsvalidate_scene().
Examples
>>> import tempfile, os >>> p = os.path.join(tempfile.mkdtemp(), "demo.scene.json") >>> _ = save_scene(new_scene("demo"), p) >>> os.path.exists(p) True
- capture_helper.scene_from_available_devices(name='auto')[source]
Build a starter scene from whatever devices this machine reports.
Convenience used by the GUI’s “auto-populate” button and by the docs: grabs the first camera (full-canvas) and the first microphone (if any) so the user has something live to arrange instead of a blank canvas.
- Parameters:
name (str, default
"auto") – Name for the generated scene.- Returns:
A scene seeded with up to one camera and one microphone. Empty of sources when the host reports no devices (e.g. headless CI).
- Return type:
Examples
>>> isinstance(scene_from_available_devices(), dict) True
- capture_helper.snapshot_jpeg(source, *, output_width=480, output_height=270, quality=5)[source]
Grab one live frame from a camera and return it as JPEG bytes.
- Parameters:
source (Source) – A
"camera"device fromcapture_helper.pick_source()/list_sources().output_width (int) – Preview size (aspect-preserving fit-and-pad, per the camera iterator).
output_height (int) – Preview size (aspect-preserving fit-and-pad, per the camera iterator).
quality (int, default 5) – JPEG quality passed to
frame_to_jpeg().
- Returns:
A single JPEG image.
- Return type:
- Raises:
ValueError – If
sourceis not a camera.RuntimeError – If no frame could be captured (permission denied / device busy).
Examples
>>> cam = ... >>> jpg = snapshot_jpeg(cam)
- capture_helper.validate_scene(scene)[source]
Validate an in-memory object as a well-formed
Scene.Cheap structural checks only — device availability is NOT verified here (that is
resolve_scene_sources()’ job, and depends on the host). This guards the save/load boundary so a corrupt or hand-edited file surfaces a clear error instead of an obscureKeyErrordeep in the runner.- Parameters:
scene (Any) – Candidate object (typically freshly parsed JSON).
- Returns:
The same object, once every invariant holds (returned for chaining).
- Return type:
- Raises:
ValueError – If any required key is missing or has the wrong type / value.
Examples
>>> validate_scene(new_scene("ok"))["name"] 'ok'