Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16dd7cbe51 | |||
| fa055fc80a | |||
| 8e496ec2c4 | |||
| c445eb3e04 | |||
| 907fd90a5e | |||
| 2e3945dfa0 | |||
| b9089f9c18 | |||
| 792f2b1fd5 |
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+1021
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user