Files
ISR/isr.py
T
adminandClaude Opus 5 138f6db0a5 refactor: one Range-capable file sender; HEAD support; assorted hygiene
web.py
* _download and _stream each had their own copy loop; _download had no
  Range support at all, so an interrupted download of a multi-hundred-MB
  recording had to restart. Both now go through _serve_bytes(), which
  keeps _stream's live-header `prefix` trick (the patched bytes are the
  same length as the header they replace, so Range math is unaffected).
* do_HEAD answers / , /stream/ and /download/ as a body-less do_GET.
  /api/clip and /api/cut return 405 instead — generating a clip or
  shelling out to ffmpeg only to discard the result is worse than not
  answering.
* _api_delete drops the deleted file's _DURATION_CACHE entry, which was
  never evicted.
* WAV durations are rounded to 2dp like FLAC's already were.

isr.py
* _open_output_file flushed the buffer and then called
  close_current_file(), which is overridden to flush again.
* log_level/log_file were merged into the dict copied into every
  source's config; no recorder reads them. Read them where they are used.

webui.html
* scoreOrder() is memoised on the section array's identity — U/I
  otherwise re-sorted a whole day (thousands of sections) per keypress.

Verified with a byte-level harness: full/partial/open-ended/unsatisfiable
ranges byte-identical to disk, download Range, HEAD on all three
supported paths returning headers with an empty body, 405 on /api/clip,
and live-header patching still correct both whole-file and for a range
straddling the patched/disk boundary.

Not done from the audit: BaseRecorder.generate_filename's mkdir per file
open. It runs once per split (once an hour by default), so "once at
startup suffices" was not worth a behaviour change. `nul` stays in
.gitignore — it guards against a Windows shell artifact on a Windows box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:29:57 +02:00

975 lines
37 KiB
Python

#!/usr/bin/env python3
"""
ISR - Audio Recorder
Records from multiple sources: Icecast streams and soundcards.
Supports time-based file splitting and concurrent recording.
"""
import json
import os
import sys
import time
import wave
import signal
import logging
import threading
import configparser
import subprocess
import shutil
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Dict, Any, List, Callable
# Optional imports - check availability
try:
import requests
REQUESTS_AVAILABLE = True
except ImportError:
REQUESTS_AVAILABLE = False
try:
import numpy as np
NUMPY_AVAILABLE = True
except ImportError:
NUMPY_AVAILABLE = False
try:
import soundfile as sf
SOUNDFILE_AVAILABLE = True
except ImportError:
SOUNDFILE_AVAILABLE = False
# Fixed recording-filename timestamp format. This is the recording's *start*
# time and is the single source of truth for the clock: web.py parses it back
# out to derive the displayed date and to name cut clips with real wall-clock
# times. It is intentionally not configurable — both files must agree on it.
FILENAME_FORMAT = '%Y%m%d_%H%M%S'
# =============================================================================
# Audio Device Discovery (ALSA)
# =============================================================================
@dataclass
class AudioDevice:
"""An ALSA capture device.
Only the identity matters: capture format comes from config (sample_rate,
channels), not from the device, so this deliberately carries no
capability fields — reporting a card's channel count here would just be a
number that has no bearing on what actually gets recorded.
"""
id: str # ALSA device spec: 'hw:1,0', or a PCM name
name: str # Human-readable name from `arecord -l`
is_default: bool = False
def __str__(self):
return f"{self.name} ({self.id})" + (" [DEFAULT]" if self.is_default else "")
class ALSAStream:
"""Context manager for ALSA recording using arecord (raw PCM output)."""
# No audio for this long means capture is dead even though the process is
# still up (device yanked, dsnoop shm gone). Generous enough that normal
# scheduling hiccups never trip it.
SILENCE_TIMEOUT = 15.0
def __init__(self, device: AudioDevice, sample_rate: int, channels: int,
callback: Callable[[bytes], None], logger: logging.Logger):
self.device = device
self.sample_rate = sample_rate
self.channels = channels
self.callback = callback
self.logger = logger
self.received_audio = False
self._running = False
self._thread = None
self._stderr_thread = None
self._process = None
self._last_data = 0.0
def __enter__(self):
self._running = True
self._last_data = time.monotonic()
cmd = [
'arecord',
'-D', self.device.id,
'-f', 'S16_LE',
'-r', str(self.sample_rate),
'-c', str(self.channels),
'--file-type', 'raw',
'-',
]
try:
self._process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except FileNotFoundError:
raise RuntimeError("arecord not found - install alsa-utils: sudo apt install alsa-utils")
self._thread = threading.Thread(target=self._read_loop, daemon=True)
self._stderr_thread = threading.Thread(target=self._log_stderr, daemon=True)
self._thread.start()
self._stderr_thread.start()
return self
def __exit__(self, *args):
self._running = False
if self._process:
self._process.terminate()
try:
self._process.wait(timeout=2)
except subprocess.TimeoutExpired:
self._process.kill()
for thread in (self._thread, self._stderr_thread):
if thread:
thread.join(timeout=1)
def check_alive(self):
"""Raise if capture has stopped delivering audio.
Nothing else notices a dead arecord: _read_loop just returns, so
without this the recorder would keep rolling over to new split files
on schedule and write nothing into any of them — a failure that is
invisible from the outside because the files still appear on time.
"""
rc = self._process.poll()
if rc is not None:
raise RuntimeError(f"arecord exited with code {rc}")
idle = time.monotonic() - self._last_data
if idle > self.SILENCE_TIMEOUT:
raise RuntimeError(
f"no audio from '{self.device.id}' for {idle:.0f}s")
def _log_stderr(self):
for line in self._process.stderr:
line = line.decode('utf-8', errors='replace').rstrip()
if line:
self.logger.warning(f"arecord: {line}")
def _read_loop(self):
chunk_size = self.sample_rate * self.channels * 2 // 10 # 100 ms chunks
while self._running and self._process.poll() is None:
try:
data = self._process.stdout.read(chunk_size)
if not data:
break # EOF: arecord is on its way out, don't spin
self._last_data = time.monotonic()
self.received_audio = True
self.callback(data)
except Exception as e:
self.logger.error(f"ALSA read error: {e}")
break
class AudioSystem:
"""Discovers ALSA capture devices and opens capture streams.
ALSA (via `arecord`) is the only backend. This was once a plugin system
with a registry and an abstract base class; there has only ever been one
implementation, so it is a plain class now.
"""
def __init__(self, logger: logging.Logger):
self.logger = logger
self.available = shutil.which('arecord') is not None
def list_devices(self) -> List[AudioDevice]:
"""Hardware capture devices, parsed out of `arecord -l`.
Virtual PCMs defined in asound.conf (dsnoop, plug, ...) are NOT listed
by `arecord -l` and so never appear here — see find_device().
"""
devices = []
try:
result = subprocess.run(
['arecord', '-l'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
return devices
for line in result.stdout.split('\n'):
if not line.startswith('card '):
continue
try:
card_part, rest = line.split(':', 1)
card_num = card_part.replace('card', '').strip()
if ', device ' in rest:
dev_part = rest.split(', device ')[1]
dev_num = dev_part.split(':')[0].strip()
else:
dev_num = '0'
long_name = rest.split('[')[1].split(']')[0] if '[' in rest else rest.strip()
devices.append(AudioDevice(
id=f"hw:{card_num},{dev_num}",
name=long_name,
is_default=(card_num == '0' and dev_num == '0'),
))
except (IndexError, ValueError):
continue
except Exception:
pass
return devices
def find_device(self, spec: str) -> AudioDevice:
"""Resolve a config `device =` value to an ALSA device.
Never returns None. An unrecognised spec is passed to ALSA verbatim,
because a valid PCM name from asound.conf (`shared_mic`, `plughw:0`)
does not appear in `arecord -l` — and that must keep working even on a
host where no hardware card is enumerable at all, which is exactly the
case an earlier "no devices, give up" guard broke.
"""
devices = self.list_devices()
spec_lower = spec.lower()
if spec_lower == 'default':
for dev in devices:
if dev.is_default:
return dev
# 'default' is itself a valid ALSA PCM when no card is enumerable.
return devices[0] if devices else AudioDevice(id='default', name='default')
exact_id = [d for d in devices if d.id == spec]
exact_name = [d for d in devices if d.name.lower() == spec_lower]
partial = [d for d in devices if spec_lower in d.name.lower()]
for candidates in (exact_id, exact_name, partial):
if candidates:
return candidates[0]
self.logger.info(
f"Device '{spec}' is not in `arecord -l` — using it as an ALSA PCM name")
return AudioDevice(id=spec, name=spec)
def open_stream(self, device: AudioDevice, sample_rate: int, channels: int,
callback: Callable[[bytes], None]) -> 'ALSAStream':
"""Open a capture stream. Returns a context manager."""
return ALSAStream(device, sample_rate, channels, callback, self.logger)
class BaseRecorder(ABC):
"""Abstract base class for all recorder types."""
def __init__(self, name: str, config: Dict[str, Any], logger: logging.Logger,
clock: Callable[[], datetime] = None):
self.name = name
self.config = config
self.logger = logger
self.running = False
self.current_file = None
self.current_filename = None
self._clock = clock or datetime.now
# Common settings. split_duration is clamped: 0 would divide by zero
# in get_next_split_time(), negative would split every tick.
self.split_duration = max(1, config.get('split_minutes', 60))
self.output_dir = config.get('output_directory', 'recordings')
self.max_retries = config.get('max_retries', 10)
self.retry_delay = config.get('retry_delay_seconds', 5)
self.file_format = config.get('format', 'auto')
def get_next_split_time(self) -> datetime:
"""Calculate the next split time aligned to split_duration boundaries."""
now = self._clock()
total_minutes = now.hour * 60 + now.minute
minutes_to_next = self.split_duration - (total_minutes % self.split_duration)
next_split = now + timedelta(minutes=minutes_to_next)
return next_split.replace(second=0, microsecond=0)
def generate_filename(self, ext: str) -> str:
"""Generate filename from the fixed start-time format (see FILENAME_FORMAT)."""
now = self._clock()
filename = now.strftime(FILENAME_FORMAT) + f".{ext}"
full_path = os.path.join(self.output_dir, filename)
Path(full_path).parent.mkdir(parents=True, exist_ok=True)
return full_path
def close_current_file(self):
"""Close the current recording file if open."""
if self.current_file:
try:
self.current_file.close()
self.logger.info(f"[{self.name}] Closed file: {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 stop(self):
"""Signal the recorder to stop."""
self.running = False
@abstractmethod
def record(self):
"""Main recording loop - must be implemented by subclasses."""
pass
class StreamRecorder(BaseRecorder):
"""Records from Icecast/HTTP audio streams."""
def __init__(self, name: str, config: Dict[str, Any], logger: logging.Logger,
clock: Callable[[], datetime] = None):
super().__init__(name, config, logger, clock)
if not REQUESTS_AVAILABLE:
raise ImportError("The 'requests' library is required for stream recording. Install with: pip install requests")
self.stream_url = config.get('url')
if not self.stream_url:
raise ValueError(f"[{name}] Stream URL is required for stream type")
self.username = config.get('username') or None
self.password = config.get('password') or None
# Stream header storage for formats that require headers in each file
self.stream_headers = None
self.header_capture_complete = False
self.detected_format = None
def detect_format(self, response) -> str:
"""Detect stream format from HTTP headers."""
content_type = response.headers.get('Content-Type', '').lower()
format_map = {
'audio/mpeg': 'mp3',
'audio/mp3': 'mp3',
'audio/ogg': 'ogg',
'application/ogg': 'ogg',
'audio/aac': 'aac',
'audio/aacp': 'aac',
'audio/x-aac': 'aac',
'audio/flac': 'flac',
'audio/opus': 'opus'
}
for mime, fmt in format_map.items():
if mime in content_type:
return fmt
self.logger.warning(f"[{self.name}] Unknown content type: {content_type}, defaulting to mp3")
return 'mp3'
def parse_ogg_page(self, data: bytes, offset: int) -> Optional[tuple]:
"""Parse an OGG page starting at the given offset."""
if len(data) - offset < 27:
return None
if data[offset:offset+4] != b'OggS':
return None
header_type = data[offset + 5]
is_bos = (header_type & 0x02) != 0
num_segments = data[offset + 26]
if len(data) - offset < 27 + num_segments:
return None
segment_table = data[offset + 27:offset + 27 + num_segments]
page_data_size = sum(segment_table)
total_page_size = 27 + num_segments + page_data_size
if len(data) - offset < total_page_size:
return None
page_bytes = data[offset:offset + total_page_size]
granule_pos = int.from_bytes(data[offset + 6:offset + 14], 'little')
is_header = is_bos or granule_pos == 0
return (page_bytes, is_header, offset + total_page_size)
def extract_ogg_headers(self, data: bytes) -> tuple:
"""Extract OGG header pages from the beginning of stream data."""
headers = bytearray()
offset = 0
header_count = 0
while offset < len(data):
result = self.parse_ogg_page(data, offset)
if result is None:
break
page_bytes, is_header, next_offset = result
if is_header and header_count < 3:
headers.extend(page_bytes)
header_count += 1
offset = next_offset
else:
break
return bytes(headers), data[offset:]
def needs_header_per_file(self) -> bool:
"""Check if the detected format requires headers in each split file."""
return self.detected_format in ('ogg', 'opus', 'flac')
def open_new_file(self):
"""Open a new recording file."""
self.close_current_file()
ext = self.file_format if self.file_format != 'auto' else self.detected_format
self.current_filename = self.generate_filename(ext)
self.current_file = open(self.current_filename, 'wb')
self.logger.info(f"[{self.name}] Started recording to: {self.current_filename}")
if self.stream_headers and self.needs_header_per_file():
self.current_file.write(self.stream_headers)
self.logger.debug(f"[{self.name}] Wrote {len(self.stream_headers)} bytes of stream headers")
def connect_stream(self):
"""Connect to the stream."""
auth = None
if self.username and self.password:
auth = (self.username, self.password)
try:
self.logger.info(f"[{self.name}] Connecting to stream: {self.stream_url}")
response = requests.get(
self.stream_url,
auth=auth,
stream=True,
timeout=10
)
response.raise_for_status()
if self.file_format == 'auto':
self.detected_format = self.detect_format(response)
self.logger.info(f"[{self.name}] Detected format: {self.detected_format}")
else:
self.detected_format = self.file_format
self.logger.info(f"[{self.name}] Using manual format: {self.file_format}")
return response
except requests.exceptions.RequestException as e:
self.logger.error(f"[{self.name}] Connection failed: {e}")
return None
def record(self):
"""Main recording loop for streams."""
self.running = True
retry_count = 0
next_split_time = self.get_next_split_time()
self.logger.info(f"[{self.name}] Starting stream recorder - Split every {self.split_duration} minutes")
self.logger.info(f"[{self.name}] Next split at: {next_split_time.strftime('%Y-%m-%d %H:%M:%S')}")
while self.running:
response = self.connect_stream()
if response is None:
retry_count += 1
if retry_count >= self.max_retries:
self.logger.error(f"[{self.name}] Max retries ({self.max_retries}) reached. Stopping.")
break
self.logger.warning(f"[{self.name}] Retry {retry_count}/{self.max_retries} in {self.retry_delay} seconds...")
time.sleep(self.retry_delay)
continue
retry_count = 0
self.header_capture_complete = False
self.stream_headers = None
header_buffer = bytearray()
# Always open a new file on (re)connect — a reconnect means there is
# a gap in the stream. For OGG/FLAC this is mandatory (header pages
# must appear at the start of each file); for MP3/AAC it avoids
# writing audio from two separate connections into the same file.
self.open_new_file()
try:
for chunk in response.iter_content(chunk_size=8192):
if not self.running:
break
if chunk:
if self.needs_header_per_file() and not self.header_capture_complete:
# Buffer data until we have enough to extract OGG/FLAC headers.
# The chunk must NOT also be written directly — it is already in
# header_buffer and will be flushed once headers are captured.
header_buffer.extend(chunk)
if len(header_buffer) >= 16384:
self.stream_headers, _ = self.extract_ogg_headers(bytes(header_buffer))
if self.stream_headers:
self.logger.info(f"[{self.name}] Captured {len(self.stream_headers)} bytes of stream headers")
self.header_capture_complete = True
self.current_file.write(bytes(header_buffer))
self.current_file.flush()
header_buffer.clear()
# Chunk is in the buffer; do not fall through to the write below.
continue
self.current_file.write(chunk)
self.current_file.flush()
if self._clock() >= next_split_time:
self.open_new_file()
next_split_time = self.get_next_split_time()
self.logger.info(f"[{self.name}] Next split at: {next_split_time.strftime('%Y-%m-%d %H:%M:%S')}")
except requests.exceptions.RequestException as e:
self.logger.error(f"[{self.name}] Stream interrupted: {e}")
if self.running:
self.logger.info(f"[{self.name}] Attempting to reconnect...")
time.sleep(self.retry_delay)
continue
self.close_current_file()
self.logger.info(f"[{self.name}] Stream recorder stopped")
class _AudioFileWriter:
"""Unified writer for WAV and FLAC output files.
Wraps ``wave.Wave_write`` (WAV) and ``soundfile.SoundFile`` (FLAC) behind a
common interface so the rest of the recorder does not need to branch on format.
"""
def __init__(self, path: str, channels: int, sample_rate: int, fmt: str):
self.path = path
self.channels = channels
self.sample_rate = sample_rate
self.fmt = fmt
self._file = None
if fmt == 'flac':
if not NUMPY_AVAILABLE:
raise ImportError(
"numpy is required for FLAC output: pip install numpy"
)
if not SOUNDFILE_AVAILABLE:
raise ImportError(
"soundfile is required for FLAC output: pip install soundfile"
)
self._file = sf.SoundFile(
path, mode='w',
samplerate=sample_rate,
channels=channels,
subtype='PCM_16',
format='FLAC',
)
else:
self._file = wave.open(path, 'wb')
self._file.setnchannels(channels)
self._file.setsampwidth(2) # 16-bit
self._file.setframerate(sample_rate)
def write(self, data: bytes) -> None:
"""Write raw int16 PCM bytes to the output file."""
if self.fmt == 'flac':
arr = np.frombuffer(data, dtype='<i2')
if self.channels > 1:
arr = arr.reshape(-1, self.channels)
self._file.write(arr)
else:
self._file.writeframes(data)
def close(self) -> None:
if self._file is not None:
self._file.close()
self._file = None
class SoundcardRecorder(BaseRecorder):
"""Records from a soundcard/audio device via ALSA."""
def __init__(self, name: str, config: Dict[str, Any], logger: logging.Logger,
audio_system: 'AudioSystem' = None,
clock: Callable[[], datetime] = None):
super().__init__(name, config, logger, clock)
# Initialize audio system if not provided
self.audio_system = audio_system or AudioSystem(logger)
if not self.audio_system.available:
raise RuntimeError(
"arecord not found — soundcard capture is unavailable.\n"
"Install ALSA utilities: sudo apt install alsa-utils\n"
"(In Docker, also ensure /dev/snd is mapped into the container)"
)
# Audio settings
self.device_spec = config.get('device', 'default')
self.sample_rate = config.get('sample_rate', 44100)
self.channels = config.get('channels', 2)
# Validate format
if self.file_format not in ('wav', 'flac') and self.file_format != 'auto':
self.logger.warning(f"[{self.name}] Format '{self.file_format}' not supported, using 'wav'")
self.file_format = 'wav'
elif self.file_format == 'auto':
self.file_format = 'wav'
# Resolve device
self.device = self._resolve_device()
# Audio buffer
self.audio_buffer = []
self.buffer_lock = threading.Lock()
def _resolve_device(self) -> AudioDevice:
"""Resolve the configured device spec to an AudioDevice.
find_device() always succeeds — an unknown spec becomes a literal ALSA
PCM name — so a typo surfaces as an arecord failure at capture time
rather than here. That is the price of supporting asound.conf PCMs,
which are invisible to `arecord -l`.
"""
device = self.audio_system.find_device(self.device_spec)
self.logger.info(f"[{self.name}] Using device: {device}")
return device
def _audio_callback(self, data: bytes):
"""Callback for audio data from the capture thread."""
with self.buffer_lock:
self.audio_buffer.append(data)
def _open_output_file(self):
"""Open a new WAV or FLAC output file for recording."""
self.close_current_file() # overridden below to flush first
self.current_filename = self.generate_filename(self.file_format)
self.current_file = _AudioFileWriter(
self.current_filename, self.channels, self.sample_rate, self.file_format
)
self.logger.info(f"[{self.name}] Recording to: {self.current_filename}")
def _flush_buffer_to_file(self):
"""Write buffered audio data to file."""
if self.current_file is None:
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:
buffered, self.audio_buffer = self.audio_buffer, []
for data in buffered:
self.current_file.write(data)
def close_current_file(self):
"""Flush any buffered audio, then close the current recording file."""
self._flush_buffer_to_file()
super().close_current_file()
def record(self):
"""Main recording loop."""
self.running = True
retry_count = 0
next_split = self.get_next_split_time()
self.logger.info(f"[{self.name}] Starting recorder - Split every {self.split_duration} min")
self.logger.info(f"[{self.name}] Device: {self.device}")
self.logger.info(f"[{self.name}] Sample rate: {self.sample_rate}, Channels: {self.channels}")
self.logger.info(f"[{self.name}] Next split: {next_split.strftime('%H:%M:%S')}")
while self.running:
try:
if self.current_file is None:
self._open_output_file()
with self.audio_system.open_stream(
self.device, self.sample_rate, self.channels, self._audio_callback
) as stream:
self.logger.info(f"[{self.name}] Recording started")
while self.running:
time.sleep(0.5)
self._flush_buffer_to_file()
# Raises if capture died; the retry logic below then
# reopens the device instead of silently recording
# empty files forever.
stream.check_alive()
# Only a stream that actually delivered audio counts as
# progress. Resetting on every healthy tick would let a
# device that opens but never captures retry forever.
if stream.received_audio:
retry_count = 0
if self._clock() >= next_split:
self._open_output_file()
next_split = self.get_next_split_time()
self.logger.info(f"[{self.name}] Next split: {next_split.strftime('%H:%M:%S')}")
except Exception as e:
self.logger.error(f"[{self.name}] Error: {e}")
retry_count += 1
if retry_count >= self.max_retries:
self.logger.error(f"[{self.name}] Max retries reached. Stopping.")
break
self.logger.warning(f"[{self.name}] Retry {retry_count}/{self.max_retries} in {self.retry_delay}s...")
time.sleep(self.retry_delay)
self.close_current_file()
self.logger.info(f"[{self.name}] Recorder stopped")
class RecorderManager:
"""Manages multiple recorders running concurrently."""
def __init__(self, config_file: str = 'config.ini'):
self.config_file = config_file
self.recorders: List[BaseRecorder] = []
self.threads: List[threading.Thread] = []
self.logger = None
self.audio_system = None
self.output_dir = 'recordings'
self._status_running = False
self._load_config()
def _load_config(self):
"""Load and parse the configuration file."""
if not os.path.exists(self.config_file):
print(f"Error: Configuration file '{self.config_file}' not found!")
print("Usage: python isr.py [config.ini]")
print(" python isr.py --list-devices")
sys.exit(1)
config = configparser.ConfigParser(interpolation=None)
config.read(self.config_file)
# Get general settings (defaults)
# Defaults every source inherits. Logging is read separately — it is
# a process-wide setting, not something a recorder ever looks at.
general = {
'output_directory': config.get('general', 'output_directory', fallback='recordings'),
'split_minutes': config.getint('general', 'split_minutes', fallback=60),
'max_retries': config.getint('general', 'max_retries', fallback=10),
'retry_delay_seconds': config.getint('general', 'retry_delay_seconds', fallback=5),
}
self.output_dir = general['output_directory']
# Setup logging
self._setup_logging(
config.get('general', 'log_level', fallback='INFO').upper(),
config.get('general', 'log_file', fallback='recorder.log'))
# Initialize shared audio system for soundcard recorders
self.audio_system = AudioSystem(self.logger)
if self.audio_system.available:
self.logger.info("Audio capture available: ALSA (arecord)")
# Parse source sections
for section in config.sections():
if section == 'general':
continue
source_type = config.get(section, 'type', fallback=None)
if source_type is None:
self.logger.warning(f"Section [{section}] has no 'type', skipping")
continue
# Build the source config and create the recorder. Both are inside
# the try: a non-numeric split_minutes used to raise out here and
# abort the whole config load instead of skipping one section.
try:
source_config = general.copy()
for key, value in config.items(section):
if key == 'type':
continue
if key in ('split_minutes', 'max_retries', 'retry_delay_seconds', 'sample_rate', 'channels'):
source_config[key] = int(value)
else:
source_config[key] = value
if source_type == 'stream':
recorder = StreamRecorder(section, source_config, self.logger)
self.recorders.append(recorder)
self.logger.info(f"Configured stream: [{section}]")
elif source_type == 'soundcard':
recorder = SoundcardRecorder(section, source_config, self.logger, self.audio_system)
self.recorders.append(recorder)
self.logger.info(f"Configured soundcard: [{section}]")
else:
self.logger.warning(f"Unknown type '{source_type}' in [{section}], skipping")
except Exception as e:
self.logger.error(f"Failed to configure [{section}]: {e}")
if not self.recorders:
self.logger.error("No valid recording sources configured!")
def _setup_logging(self, level: str, log_file: str):
"""Setup logging configuration."""
numeric_level = getattr(logging, level, logging.INFO)
# Clear any existing handlers
root_logger = logging.getLogger()
root_logger.handlers.clear()
logging.basicConfig(
level=numeric_level,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_file),
logging.StreamHandler(sys.stdout)
]
)
self.logger = logging.getLogger(__name__)
def _write_status(self):
"""Write active filenames to status.json for the web UI to read."""
active = []
for r in self.recorders:
fn = r.current_filename
if fn:
try:
rel = os.path.relpath(fn, self.output_dir).replace('\\', '/')
active.append(rel)
except ValueError:
pass
try:
out = Path(self.output_dir)
out.mkdir(parents=True, exist_ok=True)
tmp = str(out / 'status.json.tmp')
with open(tmp, 'w') as fh:
json.dump({'active': active}, fh)
os.replace(tmp, str(out / 'status.json'))
except Exception:
pass
def _status_writer_loop(self):
while self._status_running:
self._write_status()
time.sleep(2)
def start(self):
"""Start all recorders in separate threads."""
if not self.recorders:
self.logger.error("No recorders to start!")
return
self.logger.info(f"Starting {len(self.recorders)} recorder(s)...")
for recorder in self.recorders:
thread = threading.Thread(target=recorder.record, name=f"Recorder-{recorder.name}")
thread.daemon = True
self.threads.append(thread)
thread.start()
self._status_running = True
st = threading.Thread(target=self._status_writer_loop, name='StatusWriter', daemon=True)
st.start()
self.logger.info("All recorders started")
# Wait for interrupt
try:
while True:
alive = [t for t in self.threads if t.is_alive()]
if not alive:
self.logger.info("All recorders have stopped")
break
time.sleep(1)
except KeyboardInterrupt:
self.logger.info("Received interrupt signal, stopping all recorders...")
self.stop()
def stop(self):
"""Stop all recorders gracefully."""
self._status_running = False
for recorder in self.recorders:
recorder.stop()
# Use a shared deadline so N recorders don't each burn 5 s sequentially,
# which would exceed Docker's stop_grace_period for more than 2 recorders.
deadline = time.time() + 25
for thread in self.threads:
remaining = max(0.1, deadline - time.time())
thread.join(timeout=remaining)
# Clear status file so the web UI shows no active recordings
try:
status_path = Path(self.output_dir) / 'status.json'
if status_path.exists():
status_path.unlink()
except Exception:
pass
self.logger.info("All recorders stopped")
def list_audio_devices():
"""Print the ALSA capture devices usable as `device =` in config.ini."""
# Create a minimal logger for device listing
logger = logging.getLogger('isr-devices')
logger.setLevel(logging.WARNING) # Suppress debug output
print("\n" + "=" * 70)
print(" ISR Audio Device Discovery")
print("=" * 70)
audio_system = AudioSystem(logger)
if not audio_system.available:
print("\n arecord not found!")
print("\n Install ALSA utilities:")
print(" sudo apt install alsa-utils")
print()
return
devices = audio_system.list_devices()
if devices:
print("\n Hardware Input Devices:")
print(" " + "-" * 68)
for dev in devices:
flag_str = " [DEFAULT]" if dev.is_default else ""
print(f"\n {dev.name}{flag_str}")
print(f" ID: {dev.id}")
else:
print("\n No hardware capture devices found.")
# This listing only ever shows hardware cards, so say so — a working
# asound.conf PCM will never appear here and users have gone looking.
print("\n" + "=" * 70)
print(" Configuration Examples:")
print("-" * 70)
print(" device = default # Use system default input")
print(" device = <name> # Match by partial name")
print(" device = <id> # Use exact ALSA ID, e.g. hw:0,0")
print(" device = <pcm> # Any ALSA PCM name from asound.conf, e.g.")
print(" # shared_mic — these are never listed above,")
print(" # but work regardless")
print("=" * 70 + "\n")
def main():
# Check for --list-devices flag
if len(sys.argv) > 1 and sys.argv[1] in ('--list-devices', '-l'):
list_audio_devices()
return
# Get config file (RecorderManager exits with a usage message if it's missing)
config_file = 'config.ini'
if len(sys.argv) > 1:
config_file = sys.argv[1]
# Docker sends SIGTERM before SIGKILL — treat it the same as Ctrl+C
def _sigterm(sig, frame):
raise KeyboardInterrupt()
signal.signal(signal.SIGTERM, _sigterm)
manager = RecorderManager(config_file)
manager.start()
if __name__ == '__main__':
main()