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>
975 lines
37 KiB
Python
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()
|