Compare commits

...

8 Commits

Author SHA1 Message Date
admin 16dd7cbe51 feat: duration and seeking for in-progress FLAC recordings
FLAC duration cannot be derived from byte size (variable compression),
so unlike WAV the header cannot be patched from st_size alone. Instead,
every FLAC frame header carries its own frame/sample number: read the
last 64 KB of the growing file, scan backwards for a frame sync,
CRC-8-verify the header to reject false matches in compressed data,
and compute the exact samples recorded so far. STREAMINFO
total_samples (36 bits at a fixed offset) is rewritten in the served
bytes only - the on-disk file is never touched.

Overhead: one tail read per /stream request, active files only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:37:55 +02:00
admin fa055fc80a perf: do not hold the audio buffer lock during disk writes
_flush_buffer_to_file wrote (and for FLAC, encoded) every chunk while
holding buffer_lock, blocking the capture callback for the duration of
each disk flush. Swap the buffer out under the lock and write outside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:29:21 +02:00
admin 8e496ec2c4 perf: faster page loads, live-recording playback and seeking fixes
Server (web.py):
- /api/analyze no longer returns the full per-window RMS array (~45x
  larger than the rms_display the UI actually renders); old caches are
  stripped on read
- /api/files reads only the first 256 bytes of each analysis cache to
  get threshold/min_gap instead of parsing the whole JSON
- durations cached by (mtime, size) instead of re-opening every audio
  header per request; stat() race with deleted files guarded
- /api/storage no longer walks the recordings tree (used bytes now
  computed client-side from the file list)
- HTTP/1.1 keep-alive enabled; short writes force-close the connection;
  client-disconnect tracebacks from aborted seeks silenced
- all file copies bounded by the advertised Content-Length so files
  growing during a response cannot desync the connection

Live recording playback:
- /stream/ patches in-progress WAV headers to the current file size so
  browsers show real duration and can seek (on-disk header says 0
  frames until the recorder closes the file)
- active files served with Cache-Control: no-store
- reopening the player for a recording file reloads the source to pick
  up newly captured audio

UI loading:
- analyses lazy-load only for expanded day groups; collapsed days defer
  fetching until opened, and auto-load only when cached parameters
  match the current controls (no surprise mass recompute)
- client-side analysis cache shared by file rows and day highlights, so
  re-renders and filters never refetch
- filename filter debounced (200 ms)
- file list auto-refreshes when the active recording set changes,
  unless audio is playing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:29:13 +02:00
admin c445eb3e04 docs: slim CLAUDE.md down to rules, file map, and non-obvious internals
Drop class-by-class architecture listings that are derivable from the
code; keep only constraints a model cannot infer (status.json coupling,
header injection, read-only mount, shutdown deadline, dsnoop/ipc).
Add webui.html to the file map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:50:09 +02:00
admin 907fd90a5e refactor: extract web UI page into webui.html
web.py was 1700 lines, more than half of it a single embedded HTML
string. The page now lives in webui.html (loaded once at startup),
so the frontend gets real syntax highlighting and web.py is pure
Python. Dockerfile copies the new file alongside web.py.

Also: ASCII startup banner (the arrow glyph crashed web.py on Windows
when stdout was redirected to a cp1252 file), and README fixes —
document the ALSA PCM-name device fallback and drop the monitor
device row, which the ALSA backend never supported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:49:35 +02:00
admin 2e3945dfa0 refactor: deduplicate web UI JS and improve screen reader support
- Remove fmtT (identical duplicate of fmtDur)
- Extract closePlayer() shared by togglePlayer, day collapse, and
  cross-file day section jumps
- Reuse seekToSection from jumpToDaySection instead of a copied
  open/seek/announce block
- Fix chip semantics: role=listitem on <button> overrode the button
  role for assistive tech; use role=group on the container instead
- Drop redundant aria-hidden toggling on the REC badge (hidden already
  removes it from the accessibility tree)
- Respect prefers-reduced-motion for the REC pulse animation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:44:30 +02:00
admin b9089f9c18 refactor: deduplicate web.py server code
- Extract _is_active() helper for the three duplicated status.json
  active-recording checks (analyze, delete, cut)
- Extract _copy_to_response() for the four duplicated 64 KB chunk
  copy loops (download, stream full/range, cut)
- Inline _get_wav_info into _get_audio_duration (sample rate and
  channels were never used)
- Remove unused HTTPServer import, dead frame_pos counter, and the
  redundant (ValueError, Exception) catch in _safe_path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:42:45 +02:00
admin 792f2b1fd5 refactor: remove dead code from isr.py
- Drop unused struct import, AudioDevice.extra/description fields
- Remove unused get_preferred_backend() and the backend priority
  machinery (only one backend exists)
- Deduplicate SoundcardRecorder.close_current_file via super()
- Remove duplicate config-exists check in main()
- Simplify --list-devices output: drop dead monitor grouping and the
  nonexistent pipewire backend example

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:41:37 +02:00
6 changed files with 1372 additions and 1242 deletions
+28 -78
View File
@@ -1,93 +1,43 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Guidance for Claude Code when working in this repository.
## Rules ## Rules
- **Always update `README.md`** whenever user-facing behaviour changes (new flags, new endpoints, changed Docker setup, new features). The README is the primary external reference; CLAUDE.md documents internals. - **Always update `README.md`** when user-facing behaviour changes (flags, endpoints, Docker setup, features), and **commit it in the same commit** as the code change. README is the external reference; CLAUDE.md documents internals.
- **Always commit `README.md`** in the same commit as the code changes it documents — never let the README fall behind. - Run `python -m pytest tests/` after changing `isr.py` (tests cover the recorder only).
## Project Overview ## Files
ISR is a Python audio recording application that captures from multiple simultaneous sources (Icecast/HTTP streams and ALSA soundcard devices) with time-based file splitting. All application code is in two files: `isr.py` (recorder) and `web.py` (archive browser UI). | File | Purpose |
|------|---------|
| `isr.py` | Recorder: streams (Icecast/HTTP) + ALSA soundcards, time-aligned file splits |
| `web.py` | Archive browser: HTTP server, file listing, RMS loudness analysis, cut/delete |
| `webui.html` | Single-page UI (HTML/CSS/JS), loaded by `web.py` at startup — must sit next to `web.py` and be copied in the Dockerfile |
| `config.ini` | Recording sources; copy from `config.example.ini`. `[general]` gives defaults, every other section is a source (`type = stream` or `type = soundcard`) |
| `asound.conf` | dsnoop device `shared_mic` so ISR and other ALSA apps can share a soundcard |
## Commands ## Commands
```bash ```bash
# Run the recorder python isr.py [config.ini] # recorder; --list-devices to list ALSA inputs
python isr.py # uses config.ini python web.py # web UI on :8080 (--dir, --port, --threshold, --min-gap, --analyses-dir)
python isr.py myconfig.ini # custom config file python -m pytest tests/ # test suite
python isr.py --list-devices # list available ALSA devices docker compose up -d / down # web UI mapped to host port 8050
# Run the web UI
python web.py # http://localhost:8080
python web.py --dir recordings # custom recordings directory
python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold (0-1, default 0.05)
# Stop: Ctrl+C (or docker compose down)
# Install dependencies
pip install requests # for stream recording
pip install numpy soundfile # for FLAC output and web waveform analysis (optional)
# Docker
docker compose up -d
docker compose logs -f
docker compose down
``` ```
## Architecture Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC/waveform analysis — both optional, code degrades gracefully).
### Audio Backend System ## Non-obvious internals
- **AudioDevice** — Dataclass: id, name, channels, sample_rate, backend type
- **AudioBackend** (ABC) — Abstract base for audio capture backends
- **ALSABackend** — Native ALSA support via `arecord` subprocess (the only backend)
- **ALSAStream** — Context manager that wraps an `arecord` subprocess and reads PCM in a thread
- **AudioSystem** — Discovers available backends, lists devices, resolves device specs
### Recorder Classes - **Recorder/web coupling is one file:** `RecorderManager` atomically writes `recordings/status.json` every 2 s listing in-progress files; deleted on clean shutdown. `web.py` reads it to show REC badges and to refuse analyse/cut/delete on active files. In-progress WAV/FLAC headers are unfinalized, so durations are not read for active files.
- **BaseRecorder** (ABC) — Common settings, `get_next_split_time()`, `generate_filename()`, `record()` interface - **Stream splits:** OGG/Opus/FLAC codec headers are extracted from the first ~16 KB of each connection and prepended to every split file so each file plays standalone. A new file is always opened on reconnect (gap in stream). MP3/AAC need no headers.
- **StreamRecorder(BaseRecorder)** — Records HTTP/Icecast streams with format auto-detection and OGG/FLAC header injection - **Split timing:** files split at clock-aligned boundaries (`get_next_split_time()`), e.g. `split_minutes = 60` → on the hour.
- **SoundcardRecorder(BaseRecorder)** — Records from ALSA devices; outputs WAV or FLAC via `_AudioFileWriter` - **ALSA:** capture spawns `arecord` as a subprocess, raw PCM read in 100 ms chunks by a thread. Device spec resolution: `default` → exact `hw:X,Y` → partial name → fallback to any literal ALSA PCM name (so `shared_mic` from asound.conf works without appearing in `arecord -l`).
- **_AudioFileWriter** — Unified write/close interface for wave (WAV) and soundfile (FLAC) - **Shutdown:** SIGTERM is converted to KeyboardInterrupt in `main()`; `RecorderManager.stop()` joins all threads against a single shared 25 s deadline to stay inside Docker's `stop_grace_period: 30s`.
- **RecorderManager** — Loads config, creates recorders, manages threads, handles shutdown - **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by threshold+min_gap; orphans pruned at web startup. In Docker the recordings mount is **read-only** for the web container, so the cache uses a separate `./analyses` bind mount. The `threshold` and `min_gap` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result.
- **Analyze responses:** `/api/analyze` returns `rms_display` (~800 points), never the full per-window RMS list — the UI doesn't use it and it is ~45x larger.
### Key Implementation Details - **HTTP/1.1 keep-alive:** `_Handler.protocol_version = 'HTTP/1.1'`; every response path must set an accurate `Content-Length`. `_copy_to_response()` force-closes the connection if it under-delivers (file truncated mid-serve).
- ALSA backend spawns `arecord` as a subprocess; raw PCM is read in 100 ms chunks via a reader thread - **Live playback:** for files listed in status.json, `/stream/` patches the header on the fly so the browser sees the duration recorded so far and can seek; responses get `Cache-Control: no-store`. WAV: `_live_wav_header` derives sizes from the byte count. FLAC: `_live_flac_header` parses the sample count out of the last frame header in the file tail (CRC-8-verified to reject false sync matches) and rewrites STREAMINFO total_samples — duration is NOT derivable from byte size for FLAC.
- Device selection: `default`, `monitor` (loopback), partial name match, or exact `hw:X,Y` ID - **Path safety:** every file parameter in `web.py` goes through `_safe_path()`, which resolves and verifies the path stays inside the recordings dir.
- Thread-safe audio buffering with `threading.Lock()` - **dsnoop in Docker:** sharing the soundcard requires `asound.conf` on the host *and* `ipc: host` in docker-compose (dsnoop uses shared memory across the container boundary).
- OGG/Opus/FLAC headers captured from first ~16 KB of stream and prepended to each split file
- File splits aligned to time period boundaries (`get_next_split_time()`)
- SIGTERM handled in `main()` so Docker `docker compose down` shuts down cleanly
- `RecorderManager._write_status()` atomically writes `recordings/status.json` every 2 s while running; deleted on clean shutdown so the web UI shows no stale active-recording badges
### Web UI (web.py)
- **`GET /`** — Single-page archive table; lists all recordings sorted newest first
- **`GET /api/files`** — JSON list of file metadata (name, size, date, duration, ext, recording flag)
- **`GET /api/analyze?file=<path>`** — RMS loudness analysis for WAV and FLAC files; returns waveform data, loud sections, and duration. Requires `numpy` and `soundfile` for FLAC.
- **`GET /api/status`** — Returns `{"active": [...]}` from `status.json`; used by the UI to animate the REC badge on in-progress files (polled every 5 s)
- **`GET /stream/<path>`** — Serves audio for inline `<audio>` playback with full HTTP Range support (seekable). Responds 206 Partial Content for range requests. Files are served with `Content-Disposition: inline`.
- **`GET /download/<path>`** — Serves audio as a file download (`Content-Disposition: attachment`)
- All paths are validated against the recordings directory to prevent path traversal.
## Configuration
Copy `config.example.ini` to `config.ini`. Each section defines a recording source:
- `type = stream` — HTTP/Icecast stream recording
- `type = soundcard` — ALSA device recording
The `output_directory` value is used as-is: a relative path like `recordings` resolves to `recordings/` next to `isr.py`. No Docker-specific config change is needed — the docker-compose.yml mounts `./recordings` at `/app/recordings` to match this default.
## Docker
Two services share a `./recordings` bind mount:
- `recorder` — runs `isr.py`; volume at `/app/recordings`; mounts `asound.conf` as `/etc/asound.conf`; maps `/dev/snd`; `ipc: host` for dsnoop shared memory; `stop_grace_period: 30s`
- `web` — runs `web.py`; same `./recordings` read-only at `/recordings`; exposes port 8080 internally (mapped to 8050 on the host)
**Sharing the soundcard with darkice (or any other ALSA app):**
ALSA `hw:` devices are exclusive. `asound.conf` defines a `dsnoop` virtual device `shared_mic` that both processes use instead:
1. `sudo cp asound.conf /etc/asound.conf` on the host
2. Change darkice config to `device = shared_mic`
3. Set `device = shared_mic` in `config.ini`
4. `ipc: host` in `docker-compose.yml` is already set — required for dsnoop shared memory to cross the container boundary
+1 -1
View File
@@ -10,7 +10,7 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY isr.py web.py ./ COPY isr.py web.py webui.html ./
RUN mkdir -p /recordings RUN mkdir -p /recordings
+6 -2
View File
@@ -102,9 +102,9 @@ format = wav # wav | flac
| Value | Behaviour | | Value | Behaviour |
|-------|-----------| |-------|-----------|
| `default` | System default input | | `default` | System default input |
| `monitor` | First loopback/monitor source (capture system audio) |
| `<partial name>` | Case-insensitive substring match against device name | | `<partial name>` | Case-insensitive substring match against device name |
| `hw:X,Y` | Exact ALSA hardware ID | | `hw:X,Y` | Exact ALSA hardware ID |
| `<pcm name>` | Any ALSA PCM defined in `asound.conf` (e.g. the `shared_mic` dsnoop device), even if it doesn't appear in `arecord -l` |
Run `python isr.py --list-devices` (or `arecord -l`) to see available devices and their IDs. Run `python isr.py --list-devices` (or `arecord -l`) to see available devices and their IDs.
@@ -155,6 +155,8 @@ python web.py --min-gap 15 # grace period in seconds for merging
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses) python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
``` ```
The browser UI (HTML/CSS/JS) lives in `webui.html`, which `web.py` loads at startup — keep the two files together.
Shows recordings grouped by day with collapsible sections. Features: Shows recordings grouped by day with collapsible sections. Features:
- **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes. - **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes.
@@ -166,7 +168,9 @@ Shows recordings grouped by day with collapsible sections. Features:
- **Cut & download** — `✂ Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **↓ Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image). - **Cut & download** — `✂ Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **↓ Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image).
- **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active. - **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active.
- **Delete** — `✕ Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table. - **Delete** — `✕ Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table.
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. Duration for in-progress files shows `—` (header is unfinalized until recording stops). - **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. Duration for in-progress files shows `—` in the table (header is unfinalized until recording stops). The file list refreshes automatically when a recording starts, stops, or rolls over to a new split file (unless audio is playing).
- **Listen while recording** — in-progress files are playable and seekable. For WAV and FLAC the server patches the (still unfinalized) header on the fly so the browser sees the real duration-so-far — for FLAC the exact sample count is parsed from the last frame header in the file tail. Reopening the player reloads the source to pick up newly recorded audio. Live responses are sent with `Cache-Control: no-store`.
- **Fast loading** — analysis results are cached server-side on disk and client-side per session; cached waveforms load only for expanded day groups, and collapsed days fetch nothing until opened.
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms. - **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms.
--- ---
+17 -69
View File
@@ -10,7 +10,6 @@ import os
import sys import sys
import time import time
import wave import wave
import struct
import signal import signal
import logging import logging
import threading import threading
@@ -18,7 +17,7 @@ import configparser
import subprocess import subprocess
import shutil import shutil
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List, Callable from typing import Optional, Dict, Any, List, Callable
@@ -57,8 +56,6 @@ class AudioDevice:
backend: str # Backend name ('alsa') backend: str # Backend name ('alsa')
is_default: bool = False # Is system default is_default: bool = False # Is system default
is_monitor: bool = False # Is a monitor/loopback source is_monitor: bool = False # Is a monitor/loopback source
description: str = "" # Extended description
extra: Dict[str, Any] = field(default_factory=dict)
def __str__(self): def __str__(self):
flags = [] flags = []
@@ -74,7 +71,6 @@ class AudioBackend(ABC):
"""Abstract base for audio capture backends.""" """Abstract base for audio capture backends."""
name: str = "base" name: str = "base"
priority: int = 0 # Higher = preferred
@classmethod @classmethod
@abstractmethod @abstractmethod
@@ -99,7 +95,6 @@ class ALSABackend(AudioBackend):
"""ALSA backend using arecord (raw PCM output, no sound server required).""" """ALSA backend using arecord (raw PCM output, no sound server required)."""
name = "alsa" name = "alsa"
priority = 5 # Lowest priority — direct hardware access, use when no sound server runs
@classmethod @classmethod
def is_available(cls) -> bool: def is_available(cls) -> bool:
@@ -251,19 +246,12 @@ class AudioSystem:
def get_backend(self, name: str) -> Optional[AudioBackend]: def get_backend(self, name: str) -> Optional[AudioBackend]:
return self._backends.get(name) return self._backends.get(name)
def get_preferred_backend(self) -> Optional[AudioBackend]:
"""Get the highest priority available backend."""
if not self._backends:
return None
return max(self._backends.values(), key=lambda b: b.__class__.priority)
def list_all_devices(self) -> List[AudioDevice]: def list_all_devices(self) -> List[AudioDevice]:
"""List devices from all available backends.""" """List devices from all available backends."""
all_devices = [] all_devices = []
seen_names = set() seen_names = set()
# Get devices from backends in priority order for cls in self._backend_classes:
for cls in sorted(self._backend_classes, key=lambda c: -c.priority):
if cls.name in self._backends: if cls.name in self._backends:
for dev in cls.list_devices(): for dev in cls.list_devices():
# Deduplicate by name (same device may appear in multiple backends) # Deduplicate by name (same device may appear in multiple backends)
@@ -726,23 +714,17 @@ class SoundcardRecorder(BaseRecorder):
if self.current_file is None: if self.current_file is None:
return return
# Swap the buffer under the lock, write outside it — disk writes (and
# FLAC encoding) must not block the capture callback.
with self.buffer_lock: with self.buffer_lock:
if self.audio_buffer: buffered, self.audio_buffer = self.audio_buffer, []
for data in self.audio_buffer: for data in buffered:
self.current_file.write(data) self.current_file.write(data)
self.audio_buffer.clear()
def close_current_file(self): def close_current_file(self):
"""Close current recording file.""" """Flush any buffered audio, then close the current recording file."""
self._flush_buffer_to_file() self._flush_buffer_to_file()
if self.current_file: super().close_current_file()
try:
self.current_file.close()
self.logger.info(f"[{self.name}] Closed: {self.current_filename}")
except Exception as e:
self.logger.error(f"[{self.name}] Error closing file: {e}")
self.current_file = None
self.current_filename = None
def record(self): def record(self):
"""Main recording loop.""" """Main recording loop."""
@@ -990,22 +972,15 @@ def list_audio_devices():
print(" ISR Audio Device Discovery") print(" ISR Audio Device Discovery")
print("=" * 70) print("=" * 70)
# Check available backends if not ALSABackend.is_available():
available_backends = []
if ALSABackend.is_available():
available_backends.append(('alsa', 'ALSA (arecord)', 5))
if not available_backends:
print("\n No audio backends available!") print("\n No audio backends available!")
print("\n Install one of:") print("\n Install ALSA utilities:")
print(" sudo apt install alsa-utils (ALSA, always available on Linux)") print(" sudo apt install alsa-utils")
print() print()
return return
print("\n Available Backends:") print("\n Available Backends:")
for name, label, priority in sorted(available_backends, key=lambda x: -x[2]): print(" - ALSA (arecord)")
marker = " (preferred)" if priority == max(b[2] for b in available_backends) else ""
print(f" - {label}{marker}")
# Initialize audio system and list devices # Initialize audio system and list devices
audio_system = AudioSystem(logger) audio_system = AudioSystem(logger)
@@ -1016,30 +991,10 @@ def list_audio_devices():
print() print()
return return
# Group by type
monitors = [d for d in devices if d.is_monitor]
inputs = [d for d in devices if not d.is_monitor]
if inputs:
print("\n Input Devices:") print("\n Input Devices:")
print(" " + "-" * 68) print(" " + "-" * 68)
for dev in inputs: for dev in devices:
flags = [] flag_str = " [DEFAULT]" if dev.is_default else ""
if dev.is_default:
flags.append("DEFAULT")
flag_str = f" [{', '.join(flags)}]" if flags else ""
print(f"\n {dev.name}{flag_str}")
print(f" ID: {dev.id} | Backend: {dev.backend}")
print(f" Channels: {dev.channels} | Sample Rate: {dev.sample_rate} Hz")
if monitors:
print("\n Monitor/Loopback Sources:")
print(" " + "-" * 68)
for dev in monitors:
flags = ["MONITOR"]
if dev.is_default:
flags.append("DEFAULT")
flag_str = f" [{', '.join(flags)}]"
print(f"\n {dev.name}{flag_str}") print(f"\n {dev.name}{flag_str}")
print(f" ID: {dev.id} | Backend: {dev.backend}") print(f" ID: {dev.id} | Backend: {dev.backend}")
print(f" Channels: {dev.channels} | Sample Rate: {dev.sample_rate} Hz") print(f" Channels: {dev.channels} | Sample Rate: {dev.sample_rate} Hz")
@@ -1048,10 +1003,9 @@ def list_audio_devices():
print(" Configuration Examples:") print(" Configuration Examples:")
print("-" * 70) print("-" * 70)
print(" device = default # Use system default input") print(" device = default # Use system default input")
print(" device = monitor # Use first monitor/loopback source")
print(" device = <name> # Match by partial name") print(" device = <name> # Match by partial name")
print(" device = <id> # Use exact backend ID") print(" device = <id> # Use exact ALSA ID, e.g. hw:0,0")
print(" backend = pipewire # Force specific backend") print(" device = <pcm> # Any ALSA PCM name from asound.conf, e.g. shared_mic")
print("=" * 70 + "\n") print("=" * 70 + "\n")
@@ -1061,17 +1015,11 @@ def main():
list_audio_devices() list_audio_devices()
return return
# Get config file # Get config file (RecorderManager exits with a usage message if it's missing)
config_file = 'config.ini' config_file = 'config.ini'
if len(sys.argv) > 1: if len(sys.argv) > 1:
config_file = sys.argv[1] config_file = sys.argv[1]
if not os.path.exists(config_file):
print(f"Error: Configuration file '{config_file}' not found!")
print("Usage: python ISR.py [config.ini]")
print(" python ISR.py --list-devices")
sys.exit(1)
# Docker sends SIGTERM before SIGKILL — treat it the same as Ctrl+C # Docker sends SIGTERM before SIGKILL — treat it the same as Ctrl+C
def _sigterm(sig, frame): def _sigterm(sig, frame):
raise KeyboardInterrupt() raise KeyboardInterrupt()
+285 -1078
View File
File diff suppressed because it is too large Load Diff
+1021
View File
File diff suppressed because it is too large Load Diff