Add Docker support, fix stale docs, translate UI to English

- Dockerfile + docker-compose.yml: two services (recorder + web) sharing
  ./recordings bind mount; recorder maps /dev/snd for ALSA soundcard access
- requirements.txt: requests, numpy, soundfile
- .dockerignore, updated .gitignore (add __pycache__, .pytest_cache)
- isr.py: add SIGTERM handler for clean Docker shutdown; fix stale error
  message that referenced removed PulseAudio/PipeWire/PortAudio backends
- web.py: translate all German UI strings to English
- config.example.ini: remove PipeWire/PulseAudio/PortAudio backend refs,
  simplify soundcard tips to ALSA only
- README.md: full rewrite as user guide (quick start, config reference,
  Docker notes, how it works)
- CLAUDE.md: update architecture section to reflect ALSA-only backend
- Delete changelog.txt and guide.md (internal session notes)
This commit is contained in:
2026-04-26 10:56:55 +02:00
parent da5197d96d
commit 8254ccde86
12 changed files with 2729 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
.git/
.gitignore
.claude/
tests/
config.ini
recordings/
*.log
changelog.txt
guide.md
+9
View File
@@ -0,0 +1,9 @@
recordings/
recorder.log
config.ini
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.claude/
nul
+69
View File
@@ -0,0 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
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).
## Commands
```bash
# Run the recorder
python isr.py # uses config.ini
python isr.py myconfig.ini # custom config file
python isr.py --list-devices # list available ALSA devices
# Run the web UI
python web.py # http://localhost:8080
python web.py --dir recordings # custom recordings directory
# 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
### Audio Backend System
- **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
- **BaseRecorder** (ABC) — Common settings, `get_next_split_time()`, `generate_filename()`, `record()` interface
- **StreamRecorder(BaseRecorder)** — Records HTTP/Icecast streams with format auto-detection and OGG/FLAC header injection
- **SoundcardRecorder(BaseRecorder)** — Records from ALSA devices; outputs WAV or FLAC via `_AudioFileWriter`
- **_AudioFileWriter** — Unified write/close interface for wave (WAV) and soundfile (FLAC)
- **RecorderManager** — Loads config, creates recorders, manages threads, handles shutdown
### Key Implementation Details
- ALSA backend spawns `arecord` as a subprocess; raw PCM is read in 100 ms chunks via a reader thread
- Device selection: `default`, `monitor` (loopback), partial name match, or exact `hw:X,Y` ID
- Thread-safe audio buffering with `threading.Lock()`
- 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
## 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
In Docker, set `output_directory = /recordings` and `log_file = /recordings/recorder.log`.
## Docker
Two services share a `./recordings` bind mount:
- `recorder` — runs `isr.py`, maps `/dev/snd` for ALSA access
- `web` — runs `web.py`, read-only access to recordings, exposes port 8080
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
alsa-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY isr.py web.py ./
RUN mkdir -p /recordings
CMD ["python", "isr.py", "/app/config.ini"]
+188 -1
View File
@@ -1,2 +1,189 @@
# ISR # ISR — Audio Recorder
> AI-generated code. Run at your own risk. MIT licence.
Records from multiple simultaneous sources — Icecast/HTTP streams and ALSA soundcards — with time-based file splitting.
## Features
- Multiple sources recorded in parallel (each in its own thread)
- **Stream recording** — HTTP/Icecast, auto-detects MP3 / OGG / AAC / FLAC / Opus from `Content-Type`
- **Soundcard recording** — ALSA (`arecord`), works on any Linux / Raspberry Pi
- Time-aligned file splits (e.g. every hour, on the hour)
- OGG / Opus / FLAC header injection so every split file is independently playable
- Auto-reconnect on stream drops or device errors
- WAV and FLAC output for soundcard sources
- Web UI to browse, download, and analyse recordings
---
## Quick start — bare-metal
```bash
pip install requests # stream recording
pip install numpy soundfile # FLAC output + web waveform analysis (optional)
cp config.example.ini config.ini
# edit config.ini to add your sources
python isr.py # start recorder (Ctrl+C to stop)
python web.py # start web UI at http://localhost:8080
```
## Quick start — Docker
```bash
cp config.example.ini config.ini
# In config.ini set: output_directory = /recordings
# Optionally set: log_file = /recordings/recorder.log
docker compose up -d
# recorder starts immediately; web UI at http://<host>:8080
docker compose logs -f # tail logs from both services
docker compose down # graceful stop
```
Recordings land in `./recordings/` on the host (bind-mounted into both containers).
If you only record streams (no soundcard), comment out the `devices` block in `docker-compose.yml`.
---
## Configuration
`config.ini` uses standard INI format. `[general]` provides defaults; every other section is a recording source. Source sections inherit all general settings and can override any of them.
### `[general]`
| Key | Default | Description |
|-----|---------|-------------|
| `output_directory` | `recordings` | Output path. Use `/recordings` in Docker. |
| `split_minutes` | `60` | Split into a new file every N minutes, aligned to clock boundaries (e.g. 60 → files start at :00, 30 → at :00 and :30). |
| `filename_pattern` | `%Y%m%d_%H%M%S` | strftime pattern; file extension is appended automatically. |
| `max_retries` | `10` | Give up after this many consecutive failures per source. |
| `retry_delay_seconds` | `5` | Wait between retries. |
| `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL` |
| `log_file` | `recorder.log` | Log file path. Use `/recordings/recorder.log` in Docker. |
### `type = stream`
```ini
[my_stream]
type = stream
url = http://icecast.example.com:8000/live
username = # leave blank for public streams
password =
format = auto # auto | mp3 | ogg | aac | flac | opus
```
`format = auto` detects from the `Content-Type` response header. For OGG/Opus/FLAC the first ~16 KB of each connection is buffered to extract codec headers, which are then prepended to every split file — all files are independently playable.
A new file is always opened on (re)connect so gaps between connections are never silently merged.
### `type = soundcard`
```ini
[mic_in]
type = soundcard
device = default # see device selection below
sample_rate = 44100
channels = 2
format = wav # wav | flac
```
**Device selection:**
| Value | Behaviour |
|-------|-----------|
| `default` | System default input |
| `monitor` | First loopback/monitor source (capture system audio) |
| `<partial name>` | Case-insensitive substring match against device name |
| `hw:X,Y` | Exact ALSA hardware ID |
Run `python isr.py --list-devices` (or `arecord -l`) to see available devices and their IDs.
FLAC output requires `pip install soundfile numpy`.
### Multiple sources
Every section except `[general]` is a source — they all record simultaneously:
```ini
[general]
output_directory = recordings
split_minutes = 60
[radio1]
type = stream
url = http://radio.example.com:8000/stream1
filename_pattern = radio1_%Y%m%d_%H%M%S
[system_audio]
type = soundcard
device = hw:0,0
filename_pattern = system_%Y%m%d_%H%M%S
```
---
## Filename patterns
strftime codes are substituted at split time. The file extension is added automatically.
| Pattern | Example |
|---------|---------|
| `%Y%m%d_%H%M%S` | `20241225_143000.mp3` |
| `radio_%Y-%m-%d_%H%M` | `radio_2024-12-25_1430.mp3` |
| `%Y/%m/%d/rec_%H%M%S` | `2024/12/25/rec_143000.mp3` *(subdirs created automatically)* |
---
## Web UI (`web.py`)
```bash
python web.py # serves ./recordings on port 8080
python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold 01 (default 0.05)
```
Shows a table of all recordings sorted newest-first with file size, duration (WAV only), and a waveform analysis button. Analysis computes RMS per 100 ms window and highlights contiguous sections above the loudness threshold.
Waveform analysis is WAV-only; numpy speeds it up significantly (pure-Python fallback available without it).
---
## How it works
**Streams:** Connect via HTTP → detect format from `Content-Type` → buffer first ~16 KB to extract OGG/FLAC codec headers → stream raw bytes to disk → at each split boundary open a new file and prepend the saved headers. No transcoding, no decoding — raw bytes in, raw bytes out.
**Soundcard:** Spawn `arecord` as a subprocess (raw PCM output) → read 100 ms chunks via a thread → write 16-bit PCM to WAV or FLAC → split at configured boundaries.
Both recorder types run in separate threads and retry independently up to `max_retries`.
---
## Docker notes
**ALSA device access:** The `recorder` container needs `/dev/snd` mapped. The container runs as root, so no group configuration is needed — the device mapping alone is sufficient.
If ALSA still fails to find the device inside the container, verify the device exists on the host:
```bash
arecord -l # list capture hardware
ls -la /dev/snd # check device nodes
```
**Stream-only deployments:** If you don't use soundcard recording, remove the `devices` block from `docker-compose.yml` — the image works fine without it.
**Log file in Docker:** Set `log_file = /recordings/recorder.log` in `config.ini` so logs survive container restarts. Alternatively, use `docker compose logs` (the recorder always logs to stdout as well).
**File retention:** ISR never deletes recordings. Add a cron job on the host if needed:
```bash
# Delete recordings older than 30 days
find recordings/ -type f -mtime +30 -delete
```
---
## Licence
MIT
+146
View File
@@ -0,0 +1,146 @@
# ISR - Audio Recorder Configuration
# Supports multiple recording sources: Icecast streams and soundcards
#
# Configuration sections:
# [general] - Shared settings for all sources (optional)
# [sourcename] - One section per recording source (name is your choice)
#
# Each source section must have 'type' set to either 'stream' or 'soundcard'
# =============================================================================
# GENERAL SETTINGS (optional - can be overridden per source)
# =============================================================================
[general]
# Output directory for recordings (will be created if it doesn't exist)
output_directory = recordings
# Duration in minutes after which to split into a new file
split_minutes = 60
# Filename pattern with strftime format codes
# Examples:
# %Y%m%d_%H%M%S -> 20241216_143000.ext
# recording_%Y-%m-%d_%H%M -> recording_2024-12-16_1430.ext
# %Y/%m/%d/audio_%H%M%S -> 2024/12/16/audio_143000.ext (creates subdirs)
# Common codes: %Y=year, %m=month, %d=day, %H=hour, %M=minute, %S=second
filename_pattern = %Y%m%d_%H%M%S
# Maximum number of connection/recording retry attempts before giving up
max_retries = 10
# Delay in seconds between retry attempts
retry_delay_seconds = 5
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
log_level = INFO
# Log file location
log_file = recorder.log
# =============================================================================
# EXAMPLE: ICECAST STREAM SOURCE
# =============================================================================
[mystream]
# Source type (required): stream or soundcard
type = stream
# Stream URL (required for stream type)
url = http://example.com:8000/stream
# Authentication (optional - leave empty for public streams)
username =
password =
# Audio format: auto (detect from stream), mp3, ogg, aac, flac, opus
format = auto
# Override general settings for this source (optional):
# output_directory = recordings/streams
# split_minutes = 30
# filename_pattern = mystream_%Y%m%d_%H%M%S
# =============================================================================
# EXAMPLE: SOUNDCARD SOURCE (Linux)
# =============================================================================
# Uncomment and configure to record from a soundcard
#
# [stereo_mix]
# type = soundcard
#
# # Device selection (use one of these methods):
# # device = default - Use system default input device
# # device = monitor - Auto-select first monitor/loopback source (for system audio)
# # device = <name> - Match device by partial name (e.g., "Stereo Mix")
# # device = <id> - Use exact device ID from --list-devices
# #
# # To list available devices, run: python isr.py --list-devices
# device = default
#
# # Audio backend (only 'alsa' is supported):
# # backend = alsa
#
# # Sample rate in Hz (common: 44100, 48000, 96000)
# sample_rate = 44100
#
# # Number of audio channels (1 = mono, 2 = stereo)
# channels = 2
#
# # Output format: wav, flac
# format = wav
#
# # Override general settings for this source (optional):
# # output_directory = recordings/soundcard
# # split_minutes = 60
# # filename_pattern = soundcard_%Y%m%d_%H%M%S
# =============================================================================
# MULTIPLE SOURCES EXAMPLE
# =============================================================================
# You can define as many sources as you want. Each will record simultaneously.
#
# [radio_station_1]
# type = stream
# url = http://radio1.example.com:8000/live
# format = auto
# filename_pattern = radio1_%Y%m%d_%H%M%S
#
# [radio_station_2]
# type = stream
# url = http://radio2.example.com:8000/live
# format = auto
# filename_pattern = radio2_%Y%m%d_%H%M%S
#
# [system_audio]
# type = soundcard
# device = Stereo Mix
# sample_rate = 48000
# channels = 2
# format = flac
# filename_pattern = system_%Y%m%d_%H%M%S
# =============================================================================
# SOUNDCARD TIPS (Linux / Raspberry Pi)
# =============================================================================
# ISR uses ALSA (arecord) for soundcard recording. arecord is pre-installed
# on Raspberry Pi OS and most Linux distros (package: alsa-utils).
#
# Run: python isr.py --list-devices (or: arecord -l) to list devices.
#
# EASY: Use the "monitor" keyword to capture system/loopback audio:
# [system_audio]
# type = soundcard
# device = monitor
# format = wav
#
# MANUAL: Specify a device by ALSA hardware ID from --list-devices:
# [usb_mic]
# type = soundcard
# device = hw:1,0
# backend = alsa
# sample_rate = 44100
# channels = 2
# format = flac
+19
View File
@@ -0,0 +1,19 @@
services:
recorder:
build: .
volumes:
- ./config.ini:/app/config.ini:ro
- ./recordings:/recordings
# Soundcard (ALSA) access — comment out if you only record streams
devices:
- /dev/snd:/dev/snd
restart: unless-stopped
web:
build: .
volumes:
- ./recordings:/recordings:ro
ports:
- "8080:8080"
restart: unless-stopped
command: ["python", "web.py", "--dir", "/recordings"]
+1023
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
requests
numpy
soundfile
View File
+726
View File
@@ -0,0 +1,726 @@
"""
Tests for isr.py
Run with: pytest tests/
"""
import io
import logging
import struct
import wave
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from typing import List
from unittest.mock import MagicMock, patch, call
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_logger():
logger = logging.getLogger("test")
logger.setLevel(logging.CRITICAL) # suppress noise during tests
return logger
def fixed_clock(dt: datetime):
"""Return a zero-argument callable that always returns *dt*."""
return lambda: dt
# ---------------------------------------------------------------------------
# Import the module under test
# ---------------------------------------------------------------------------
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import isr # noqa: E402 (must come after sys.path manipulation)
# ===========================================================================
# AudioDevice / AudioSystem
# ===========================================================================
class TestAudioSystemFindDevice:
"""Tests for AudioSystem.find_device()."""
def _make_system(self, devices: List[isr.AudioDevice]) -> isr.AudioSystem:
system = isr.AudioSystem.__new__(isr.AudioSystem)
system.logger = make_logger()
system._backends = {}
system.list_all_devices = lambda: devices
return system
def _dev(self, id_, name, *, is_default=False, is_monitor=False, backend="portaudio"):
return isr.AudioDevice(
id=id_, name=name, channels=2, sample_rate=44100,
backend=backend, is_default=is_default, is_monitor=is_monitor,
)
def test_default_returns_default_device(self):
devices = [
self._dev("0", "Microphone"),
self._dev("1", "Stereo Mix", is_default=True),
]
system = self._make_system(devices)
result = system.find_device("default")
assert result.id == "1"
def test_default_falls_back_to_first_when_none_marked(self):
devices = [self._dev("0", "Mic"), self._dev("1", "Line In")]
system = self._make_system(devices)
result = system.find_device("default")
assert result.id == "0"
def test_monitor_returns_monitor_device(self):
devices = [
self._dev("0", "Microphone"),
self._dev("1", "Monitor of Built-in Audio", is_monitor=True),
]
system = self._make_system(devices)
result = system.find_device("monitor")
assert result.id == "1"
def test_monitor_returns_none_when_no_monitor(self):
devices = [self._dev("0", "Mic")]
system = self._make_system(devices)
assert system.find_device("monitor") is None
def test_exact_id_match(self):
devices = [self._dev("42", "Device 42"), self._dev("7", "Device 7")]
system = self._make_system(devices)
assert system.find_device("42").name == "Device 42"
def test_exact_name_match_case_insensitive(self):
devices = [self._dev("0", "Stereo Mix")]
system = self._make_system(devices)
assert system.find_device("stereo mix").id == "0"
def test_partial_name_match(self):
devices = [self._dev("0", "Realtek Stereo Mix")]
system = self._make_system(devices)
assert system.find_device("stereo").id == "0"
def test_returns_none_for_unknown_spec(self):
devices = [self._dev("0", "Mic")]
system = self._make_system(devices)
assert system.find_device("nonexistent_device_xyz") is None
def test_preferred_backend_filter_on_default(self):
devices = [
self._dev("0", "Mic", is_default=True, backend="pulseaudio"),
self._dev("1", "Mic", is_default=True, backend="portaudio"),
]
system = self._make_system(devices)
result = system.find_device("default", preferred_backend="portaudio")
assert result.backend == "portaudio"
# ===========================================================================
# BaseRecorder helpers
# ===========================================================================
class TestGetNextSplitTime:
"""Tests for BaseRecorder.get_next_split_time()."""
def _recorder(self, split_minutes: int, now: datetime) -> isr.BaseRecorder:
"""Create a concrete BaseRecorder subclass with an injected clock."""
class _Rec(isr.BaseRecorder):
def record(self): pass
cfg = {"split_minutes": split_minutes, "output_directory": "/tmp/isr_test"}
r = _Rec.__new__(_Rec)
r.name = "test"
r.config = cfg
r.logger = make_logger()
r.running = False
r.current_file = None
r.current_filename = None
r._clock = fixed_clock(now)
r.split_duration = split_minutes
r.output_dir = cfg["output_directory"]
r.filename_pattern = "%Y%m%d_%H%M%S"
r.max_retries = 3
r.retry_delay = 1
r.file_format = "auto"
return r
def test_30min_splits_at_next_half_hour(self):
now = datetime(2024, 1, 1, 14, 10, 30)
r = self._recorder(30, now)
nxt = r.get_next_split_time()
assert nxt == datetime(2024, 1, 1, 14, 30, 0)
def test_60min_splits_at_next_hour(self):
now = datetime(2024, 1, 1, 14, 45, 0)
r = self._recorder(60, now)
nxt = r.get_next_split_time()
assert nxt == datetime(2024, 1, 1, 15, 0, 0)
def test_exactly_on_boundary_schedules_next_period(self):
# At exactly 14:00 with 60-min splits → next split is 15:00
now = datetime(2024, 1, 1, 14, 0, 0)
r = self._recorder(60, now)
nxt = r.get_next_split_time()
assert nxt == datetime(2024, 1, 1, 15, 0, 0)
def test_120min_split_crosses_hour_boundary(self):
# 15:30 with 120-min splits → boundaries at 00:00, 02:00, …, 16:00
now = datetime(2024, 1, 1, 15, 30, 0)
r = self._recorder(120, now)
nxt = r.get_next_split_time()
assert nxt == datetime(2024, 1, 1, 16, 0, 0)
def test_seconds_are_zeroed(self):
now = datetime(2024, 1, 1, 12, 5, 45)
r = self._recorder(30, now)
assert r.get_next_split_time().second == 0
assert r.get_next_split_time().microsecond == 0
class TestGenerateFilename:
"""Tests for BaseRecorder.generate_filename()."""
def _recorder(self, pattern: str, now: datetime, output_dir: str) -> isr.BaseRecorder:
class _Rec(isr.BaseRecorder):
def record(self): pass
r = _Rec.__new__(_Rec)
r.name = "test"
r.config = {}
r.logger = make_logger()
r.running = False
r.current_file = None
r.current_filename = None
r._clock = fixed_clock(now)
r.split_duration = 60
r.output_dir = output_dir
r.filename_pattern = pattern
r.max_retries = 3
r.retry_delay = 1
r.file_format = "auto"
return r
def test_basic_pattern(self, tmp_path):
now = datetime(2024, 12, 25, 14, 30, 0)
r = self._recorder("%Y%m%d_%H%M%S", now, str(tmp_path))
name = r.generate_filename("mp3")
assert name.endswith("20241225_143000.mp3")
def test_subdirectory_created(self, tmp_path):
now = datetime(2024, 12, 25, 14, 30, 0)
r = self._recorder("%Y/%m/%d/rec_%H%M%S", now, str(tmp_path))
name = r.generate_filename("ogg")
parent = Path(name).parent
assert parent.exists()
def test_output_dir_prefix(self, tmp_path):
now = datetime(2024, 1, 1, 0, 0, 0)
r = self._recorder("%Y%m%d", now, str(tmp_path))
name = r.generate_filename("wav")
assert name.startswith(str(tmp_path))
# ===========================================================================
# StreamRecorder — format detection
# ===========================================================================
class TestDetectFormat:
"""Tests for StreamRecorder.detect_format()."""
def _recorder(self) -> isr.StreamRecorder:
cfg = {
"url": "http://example.com/stream",
"output_directory": "/tmp",
"split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1,
"retry_delay_seconds": 0,
"format": "auto",
}
with patch.object(isr, "REQUESTS_AVAILABLE", True):
r = isr.StreamRecorder.__new__(isr.StreamRecorder)
r.name = "test"
r.config = cfg
r.logger = make_logger()
r.running = False
r.current_file = None
r.current_filename = None
r._clock = datetime.now
r.split_duration = 60
r.output_dir = "/tmp"
r.filename_pattern = "%Y%m%d_%H%M%S"
r.max_retries = 1
r.retry_delay = 0
r.file_format = "auto"
r.stream_url = "http://example.com/stream"
r.username = None
r.password = None
r.stream_headers = None
r.header_capture_complete = False
r.detected_format = None
return r
def _response(self, content_type: str) -> MagicMock:
resp = MagicMock()
resp.headers = {"Content-Type": content_type}
return resp
@pytest.mark.parametrize("ct,expected", [
("audio/mpeg", "mp3"),
("audio/mp3", "mp3"),
("audio/ogg", "ogg"),
("application/ogg", "ogg"),
("audio/aac", "aac"),
("audio/aacp", "aac"),
("audio/flac", "flac"),
("audio/opus", "opus"),
])
def test_known_content_types(self, ct, expected):
r = self._recorder()
assert r.detect_format(self._response(ct)) == expected
def test_unknown_content_type_defaults_to_mp3(self):
r = self._recorder()
assert r.detect_format(self._response("application/octet-stream")) == "mp3"
def test_needs_header_per_file_ogg(self):
r = self._recorder()
r.detected_format = "ogg"
assert r.needs_header_per_file() is True
def test_needs_header_per_file_mp3(self):
r = self._recorder()
r.detected_format = "mp3"
assert r.needs_header_per_file() is False
# ===========================================================================
# StreamRecorder — OGG page parsing
# ===========================================================================
def _build_ogg_page(granule_pos: int = 0, is_bos: bool = False,
payload: bytes = b"\x00" * 10) -> bytes:
"""Build a minimal valid OGG page for testing."""
header_type = 0x02 if is_bos else 0x00
# Single segment containing the entire payload
num_segments = 1
segment_table = bytes([len(payload)])
# Granule position (8 bytes little-endian)
granule_bytes = struct.pack("<Q", granule_pos)
# serial number, page sequence number, checksum (all zeroed for simplicity)
header = (
b"OggS"
+ bytes([0x00]) # version
+ bytes([header_type]) # header type
+ granule_bytes # granule position
+ b"\x00" * 4 # serial number
+ b"\x00" * 4 # page sequence number
+ b"\x00" * 4 # checksum
+ bytes([num_segments]) # number of segments
+ segment_table # lacing values
+ payload
)
return header
class TestParseOggPage:
def _recorder(self) -> isr.StreamRecorder:
r = isr.StreamRecorder.__new__(isr.StreamRecorder)
r.name = "test"
r.logger = make_logger()
return r
def test_parses_valid_page(self):
page = _build_ogg_page(granule_pos=0, is_bos=True, payload=b"X" * 10)
r = self._recorder()
result = r.parse_ogg_page(page, 0)
assert result is not None
page_bytes, is_header, next_offset = result
assert page_bytes == page
assert next_offset == len(page)
def test_returns_none_for_non_ogg_data(self):
r = self._recorder()
assert r.parse_ogg_page(b"ID3\x00" + b"\x00" * 50, 0) is None
def test_returns_none_when_data_too_short(self):
r = self._recorder()
assert r.parse_ogg_page(b"OggS\x00\x02", 0) is None
def test_bos_flag_detected(self):
page = _build_ogg_page(granule_pos=0, is_bos=True, payload=b"\x01" * 5)
r = self._recorder()
_, is_header, _ = r.parse_ogg_page(page, 0)
assert is_header is True
def test_non_zero_granule_is_not_header(self):
page = _build_ogg_page(granule_pos=1000, is_bos=False, payload=b"\x00" * 5)
r = self._recorder()
_, is_header, _ = r.parse_ogg_page(page, 0)
assert is_header is False
class TestExtractOggHeaders:
def _recorder(self) -> isr.StreamRecorder:
r = isr.StreamRecorder.__new__(isr.StreamRecorder)
r.name = "test"
r.logger = make_logger()
return r
def test_extracts_bos_pages(self):
header1 = _build_ogg_page(granule_pos=0, is_bos=True, payload=b"H1" * 5)
header2 = _build_ogg_page(granule_pos=0, is_bos=False, payload=b"H2" * 5)
audio = _build_ogg_page(granule_pos=500, is_bos=False, payload=b"audio" * 3)
data = header1 + header2 + audio
r = self._recorder()
headers, remaining = r.extract_ogg_headers(data)
# Both header pages (granule_pos == 0) should be captured
assert header1 in headers
assert header2 in headers
def test_non_ogg_data_returns_empty_headers(self):
r = self._recorder()
headers, remaining = r.extract_ogg_headers(b"ID3\x00garbage")
assert headers == b""
def test_remaining_data_preserved(self):
header = _build_ogg_page(granule_pos=0, is_bos=True, payload=b"hdr")
audio = _build_ogg_page(granule_pos=100, payload=b"aud")
r = self._recorder()
headers, remaining = r.extract_ogg_headers(header + audio)
assert len(headers) > 0
# The audio page should be in remaining
assert audio in remaining or len(remaining) >= len(audio) - 5
# ===========================================================================
# _AudioFileWriter
# ===========================================================================
class TestAudioFileWriter:
def test_wav_writes_valid_file(self, tmp_path):
path = str(tmp_path / "out.wav")
writer = isr._AudioFileWriter(path, channels=2, sample_rate=44100, fmt="wav")
# 1 frame of stereo int16 silence
writer.write(b"\x00" * 4)
writer.close()
with wave.open(path, "rb") as wf:
assert wf.getnchannels() == 2
assert wf.getsampwidth() == 2
assert wf.getframerate() == 44100
def test_flac_raises_when_soundfile_unavailable(self, tmp_path):
path = str(tmp_path / "out.flac")
with patch.object(isr, "SOUNDFILE_AVAILABLE", False):
with pytest.raises(ImportError, match="soundfile"):
isr._AudioFileWriter(path, channels=2, sample_rate=44100, fmt="flac")
@pytest.mark.skipif(not isr.SOUNDFILE_AVAILABLE, reason="soundfile not installed")
def test_flac_writes_valid_file(self, tmp_path):
import soundfile as sf
path = str(tmp_path / "out.flac")
writer = isr._AudioFileWriter(path, channels=2, sample_rate=44100, fmt="flac")
# 100ms of stereo silence at 44100 Hz → 44100 * 0.1 * 2 channels * 2 bytes = 17640 bytes
writer.write(b"\x00" * 17640)
writer.close()
info = sf.info(path)
assert info.channels == 2
assert info.samplerate == 44100
# ===========================================================================
# RecorderManager — config loading
# ===========================================================================
class TestRecorderManagerLoadConfig:
"""Verify that config.ini settings are correctly parsed and applied."""
def _minimal_config(self, tmp_path) -> str:
log_file = str(tmp_path / "test.log")
return f"""
[general]
output_directory = {str(tmp_path / "recordings")}
split_minutes = 30
filename_pattern = test_%Y%m%d
max_retries = 3
retry_delay_seconds = 2
log_level = WARNING
log_file = {log_file}
[my_stream]
type = stream
url = http://stream.example.com:8000/live
format = ogg
"""
def test_stream_recorder_created(self, tmp_path):
cfg_path = tmp_path / "config.ini"
cfg_path.write_text(self._minimal_config(tmp_path))
# Patch AudioSystem so it doesn't probe real hardware
with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available_backends = []
mgr = isr.RecorderManager(str(cfg_path))
assert len(mgr.recorders) == 1
rec = mgr.recorders[0]
assert isinstance(rec, isr.StreamRecorder)
assert rec.name == "my_stream"
assert rec.stream_url == "http://stream.example.com:8000/live"
def test_general_settings_inherited_by_source(self, tmp_path):
cfg_path = tmp_path / "config.ini"
cfg_path.write_text(self._minimal_config(tmp_path))
with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available_backends = []
mgr = isr.RecorderManager(str(cfg_path))
rec = mgr.recorders[0]
assert rec.split_duration == 30
assert rec.max_retries == 3
def test_source_overrides_general(self, tmp_path):
log_file = str(tmp_path / "test.log")
config_text = f"""
[general]
output_directory = {str(tmp_path / "recordings")}
split_minutes = 60
filename_pattern = %Y%m%d_%H%M%S
max_retries = 10
retry_delay_seconds = 5
log_level = WARNING
log_file = {log_file}
[overriding_stream]
type = stream
url = http://example.com/stream
split_minutes = 15
filename_pattern = custom_%Y%m%d
"""
cfg_path = tmp_path / "config.ini"
cfg_path.write_text(config_text)
with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available_backends = []
mgr = isr.RecorderManager(str(cfg_path))
rec = mgr.recorders[0]
assert rec.split_duration == 15
assert rec.filename_pattern == "custom_%Y%m%d"
def test_unknown_type_is_skipped(self, tmp_path):
log_file = str(tmp_path / "test.log")
config_text = f"""
[general]
log_level = WARNING
log_file = {log_file}
[bad_source]
type = unknown_type
url = http://x.com/s
[good_source]
type = stream
url = http://x.com/s2
"""
cfg_path = tmp_path / "config.ini"
cfg_path.write_text(config_text)
with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available_backends = []
mgr = isr.RecorderManager(str(cfg_path))
assert len(mgr.recorders) == 1
assert mgr.recorders[0].name == "good_source"
def test_missing_config_file_exits(self, tmp_path):
with pytest.raises(SystemExit):
# main() calls sys.exit(1) when the file is missing
with patch("sys.argv", ["isr.py", str(tmp_path / "nonexistent.ini")]):
isr.main()
# ===========================================================================
# StreamRecorder — record() integration (mocked HTTP)
# ===========================================================================
class TestStreamRecorderRecord:
"""Integration-level tests with a mocked requests session."""
def _recorder(self, fmt="mp3", clock=None) -> isr.StreamRecorder:
cfg = {
"url": "http://example.com/stream",
"output_directory": "", # overridden per-test with tmp_path
"split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 2,
"retry_delay_seconds": 0,
"format": fmt,
}
with patch.object(isr, "REQUESTS_AVAILABLE", True):
r = isr.StreamRecorder(
"test_stream", cfg, make_logger(), clock=clock or datetime.now
)
return r
def test_mp3_chunks_written_to_file(self, tmp_path):
chunks = [b"A" * 512, b"B" * 512, b"C" * 512]
rec = self._recorder(fmt="mp3")
rec.output_dir = str(tmp_path)
mock_resp = MagicMock()
mock_resp.headers = {"Content-Type": "audio/mpeg"}
mock_resp.iter_content.return_value = iter(chunks)
def _stop_after_connect(*args, **kwargs):
rec.running = True
return mock_resp
with patch.object(rec, "connect_stream", side_effect=[mock_resp, None]):
# connect_stream returns the mocked response on the first call;
# we stop the loop via a side-effectful iter_content
original_iter = mock_resp.iter_content.return_value
def _chunks_then_stop(chunk_size=8192):
for c in chunks:
yield c
rec.running = False # stop the outer while loop
mock_resp.iter_content.side_effect = _chunks_then_stop
mock_resp.headers = {"Content-Type": "audio/mpeg"}
rec.detected_format = "mp3"
rec.running = True
rec.header_capture_complete = True
rec.stream_headers = None
# Open a file manually so we can verify writes
filename = rec.generate_filename("mp3")
rec.current_file = open(filename, "wb")
rec.current_filename = filename
# Simulate one inner loop iteration
for chunk in _chunks_then_stop():
if chunk:
rec.current_file.write(chunk)
rec.close_current_file()
written = Path(filename).read_bytes()
assert written == b"A" * 512 + b"B" * 512 + b"C" * 512
def test_connection_failure_retries(self, tmp_path):
rec = self._recorder()
rec.output_dir = str(tmp_path)
rec.max_retries = 2
with patch.object(rec, "connect_stream", return_value=None) as mock_connect:
# connect_stream always fails → recorder gives up after max_retries
rec.record()
assert mock_connect.call_count == rec.max_retries
# ===========================================================================
# SoundcardRecorder — unit tests
# ===========================================================================
class TestSoundcardRecorder:
def _make_device(self, backend="portaudio") -> isr.AudioDevice:
return isr.AudioDevice(
id="0", name="Test Device", channels=2,
sample_rate=44100, backend=backend,
)
def _make_audio_system(self, device: isr.AudioDevice) -> isr.AudioSystem:
system = MagicMock(spec=isr.AudioSystem)
system.available_backends = [device.backend]
system.find_device.return_value = device
return system
def test_wav_file_written_correctly(self, tmp_path):
device = self._make_device()
audio_system = self._make_audio_system(device)
audio_system.get_backend.return_value = MagicMock()
cfg = {
"device": "default",
"sample_rate": 44100,
"channels": 2,
"format": "wav",
"output_directory": str(tmp_path),
"split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1,
"retry_delay_seconds": 0,
}
rec = isr.SoundcardRecorder("sc_test", cfg, make_logger(), audio_system=audio_system)
rec._open_output_file()
# Simulate audio callback delivering one chunk
silence = b"\x00" * (44100 * 2 * 2 // 10) # 100ms of stereo int16 silence
rec._audio_callback(silence)
rec.close_current_file()
out_files = list(tmp_path.glob("*.wav"))
assert len(out_files) == 1
with wave.open(str(out_files[0]), "rb") as wf:
assert wf.getnchannels() == 2
assert wf.getframerate() == 44100
def test_flac_raises_when_soundfile_unavailable(self, tmp_path):
device = self._make_device()
audio_system = self._make_audio_system(device)
cfg = {
"device": "default",
"sample_rate": 44100,
"channels": 2,
"format": "flac",
"output_directory": str(tmp_path),
"split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1,
"retry_delay_seconds": 0,
}
with patch.object(isr, "SOUNDFILE_AVAILABLE", False):
rec = isr.SoundcardRecorder("sc_test", cfg, make_logger(), audio_system=audio_system)
with pytest.raises(ImportError, match="soundfile"):
rec._open_output_file()
@pytest.mark.skipif(not isr.SOUNDFILE_AVAILABLE, reason="soundfile not installed")
def test_flac_file_written_correctly(self, tmp_path):
import soundfile as sf
device = self._make_device()
audio_system = self._make_audio_system(device)
cfg = {
"device": "default",
"sample_rate": 44100,
"channels": 2,
"format": "flac",
"output_directory": str(tmp_path),
"split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1,
"retry_delay_seconds": 0,
}
rec = isr.SoundcardRecorder("sc_test", cfg, make_logger(), audio_system=audio_system)
rec._open_output_file()
silence = b"\x00" * (44100 * 2 * 2 // 10)
rec._audio_callback(silence)
rec.close_current_file()
out_files = list(tmp_path.glob("*.flac"))
assert len(out_files) == 1
info = sf.info(str(out_files[0]))
assert info.channels == 2
assert info.samplerate == 44100
+518
View File
@@ -0,0 +1,518 @@
#!/usr/bin/env python3
"""
ISR Web — Browse and download recorded audio files.
Shows a chronological table of all recordings, allows download,
and analyses WAV files for loud sections using RMS.
Usage:
python web.py # serves recordings/ on port 8080
python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port
python web.py --threshold 0.03 # loudness threshold (0-1, default 0.05)
"""
import argparse
import json
import math
import os
import struct
import wave
from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
try:
import numpy as np
NUMPY_AVAILABLE = True
except ImportError:
NUMPY_AVAILABLE = False
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
LOUD_THRESHOLD = 0.05 # RMS 01 scale; sections above this are "interesting"
MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this
# ---------------------------------------------------------------------------
# Audio helpers
# ---------------------------------------------------------------------------
def _get_wav_info(path: Path):
"""Return (duration_seconds, sample_rate, channels) or (None, None, None)."""
try:
with wave.open(str(path), 'rb') as wf:
return wf.getnframes() / wf.getframerate(), wf.getframerate(), wf.getnchannels()
except Exception:
return None, None, None
def _compute_rms_windows(wf, channels: int, sampwidth: int, framerate: int,
window_samples: int):
"""Yield (time_seconds, rms_0_to_1) for every window in the open wave file."""
frame_pos = 0
while True:
raw = wf.readframes(window_samples)
if not raw:
break
n_samp = len(raw) // (sampwidth * channels)
if n_samp == 0:
break
if NUMPY_AVAILABLE:
arr = np.frombuffer(raw[:n_samp * sampwidth * channels], dtype='<i2')
if channels > 1:
arr = arr.reshape(-1, channels).mean(axis=1)
rms = float(np.sqrt(np.mean(arr.astype(np.float64) ** 2))) / 32768.0
else:
fmt = f'<{n_samp * channels}h'
samples = struct.unpack(fmt, raw[:n_samp * sampwidth * channels])
mono = samples[::channels] if channels > 1 else samples
rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0
yield frame_pos / framerate, round(rms, 5)
frame_pos += window_samples
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
threshold: float = LOUD_THRESHOLD):
"""
Analyse a WAV file.
Returns a dict with:
rms — full list of RMS values (one per window)
rms_display — downsampled to ≤800 points for the sparkline
sections — list of {start, end} dicts for loud passages
duration — total seconds
window — window duration in seconds
"""
try:
with wave.open(str(path), 'rb') as wf:
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
rms_values = [rms for _, rms in
_compute_rms_windows(wf, channels, sampwidth, framerate, window_samples)]
except Exception as e:
return {'error': str(e)}
window_dur = window_samples / framerate
duration = n_frames / framerate
# Downsample for the sparkline (max 800 bars)
if len(rms_values) > 800:
step = len(rms_values) / 800
rms_display = [rms_values[int(i * step)] for i in range(800)]
else:
rms_display = rms_values
# Find loud sections
sections: list = []
start_t = None
last_loud_t = None
for i, rms in enumerate(rms_values):
t = i * window_dur
if rms >= threshold:
if start_t is None:
start_t = t
last_loud_t = t
else:
if start_t is not None:
gap = t - last_loud_t
if gap > MIN_GAP_SECONDS:
sections.append({'start': round(start_t, 1),
'end': round(last_loud_t + window_dur, 1)})
start_t = None
last_loud_t = None
if start_t is not None:
sections.append({'start': round(start_t, 1), 'end': round(duration, 1)})
return {
'rms': rms_values,
'rms_display': rms_display,
'sections': sections,
'duration': round(duration, 2),
'window': round(window_dur, 4),
}
# ---------------------------------------------------------------------------
# File listing
# ---------------------------------------------------------------------------
def list_files(recordings_dir: str):
"""Return list of audio file metadata dicts, sorted newest first."""
base = Path(recordings_dir)
if not base.exists():
return []
files = []
for path in base.rglob('*'):
if path.suffix.lower() not in AUDIO_EXTENSIONS:
continue
stat = path.stat()
rel = path.relative_to(base)
if path.suffix.lower() == '.wav':
duration, _, _ = _get_wav_info(path)
else:
duration = None
files.append({
'name': str(rel).replace('\\', '/'),
'size': stat.st_size,
'mtime': stat.st_mtime,
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
'duration': duration,
'ext': path.suffix.lower().lstrip('.'),
})
files.sort(key=lambda f: f['mtime'], reverse=True)
return files
# ---------------------------------------------------------------------------
# HTTP handler
# ---------------------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
recordings_dir: str = 'recordings'
threshold: float = LOUD_THRESHOLD
# ---- routing -----------------------------------------------------------
def do_GET(self):
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
p = parsed.path
if p == '/':
self._html()
elif p == '/api/files':
self._api_files()
elif p == '/api/analyze':
self._api_analyze(qs)
elif p.startswith('/download/'):
self._download(unquote(p[len('/download/'):]))
else:
self._send(404, b'Not found', 'text/plain')
# ---- endpoints ---------------------------------------------------------
def _html(self):
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
def _api_files(self):
data = json.dumps(list_files(self.recordings_dir)).encode('utf-8')
self._send(200, data, 'application/json')
def _api_analyze(self, qs):
filename = qs.get('file', [None])[0]
if not filename:
self._json_err(400, 'missing file parameter')
return
path = self._safe_path(filename)
if path is None:
return
if path.suffix.lower() != '.wav':
self._json_err(400, 'loudness analysis is only available for WAV files')
return
result = analyze_wav(path, threshold=self.threshold)
data = json.dumps(result).encode('utf-8')
self._send(200, data, 'application/json')
def _download(self, filename: str):
path = self._safe_path(filename)
if path is None:
return
size = path.stat().st_size
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
self.send_header('Content-Disposition', f'attachment; filename="{path.name}"')
self.send_header('Content-Length', str(size))
self.end_headers()
with open(path, 'rb') as fh:
while True:
chunk = fh.read(65536)
if not chunk:
break
self.wfile.write(chunk)
# ---- helpers -----------------------------------------------------------
def _safe_path(self, filename: str):
"""Resolve filename within recordings_dir; return None and send error if invalid."""
base = Path(self.recordings_dir).resolve()
try:
path = (base / filename).resolve()
path.relative_to(base) # raises ValueError if outside base
except (ValueError, Exception):
self._send(403, b'Forbidden', 'text/plain')
return None
if not path.exists():
self._send(404, b'Not found', 'text/plain')
return None
return path
def _send(self, code: int, body: bytes, content_type: str):
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json_err(self, code: int, msg: str):
self._send(code, json.dumps({'error': msg}).encode('utf-8'), 'application/json')
def log_message(self, fmt, *args):
pass # suppress default access log noise
# ---------------------------------------------------------------------------
# Embedded HTML/CSS/JS
# ---------------------------------------------------------------------------
_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ISR Archive</title>
<style>
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#272a38;--txt:#e2e8f0;--muted:#6b7491;
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,sans-serif}
header{padding:20px 28px;border-bottom:1px solid var(--brd);display:flex;align-items:baseline;gap:12px}
header h1{font-size:18px;font-weight:600}
#subtitle{color:var(--muted);font-size:13px}
.wrap{padding:20px 28px}
table{width:100%;border-collapse:collapse}
th{text-align:left;padding:9px 10px;color:var(--muted);font-weight:500;font-size:12px;
text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--brd);white-space:nowrap}
td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:var(--surf)}
.fn{font-family:ui-monospace,monospace;font-size:12px;color:var(--txt)}
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
font-weight:700;text-transform:uppercase;margin-right:5px;border:1px solid}
.badge-wav{color:var(--green);border-color:#166534;background:#052e16}
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)}
.muted{color:var(--muted)}
btn,button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);
color:var(--txt);padding:4px 11px;border-radius:5px;font-size:12px;white-space:nowrap}
button:hover{background:var(--brd)}
a.dl{color:var(--accent);text-decoration:none;font-size:13px}
a.dl:hover{text-decoration:underline}
/* waveform row */
.wrow td{padding:0 10px 14px;background:var(--bg)}
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
svg.wave{display:block;width:100%;height:56px}
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
.empty{text-align:center;padding:60px;color:var(--muted)}
</style>
</head>
<body>
<header>
<h1>ISR Archive</h1>
<span id="subtitle">Loading…</span>
</header>
<div class="wrap">
<table>
<thead>
<tr>
<th>File</th>
<th>Date</th>
<th>Duration</th>
<th>Size</th>
<th>Waveform / Loud sections</th>
<th></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty" class="empty" style="display:none">No recordings found.</div>
</div>
<script>
const fmtDur = s => {
if (s == null) return '';
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = Math.floor(s%60);
return h ? `${h}:${p(m)}:${p(sec)}` : `${m}:${p(sec)}`;
};
const fmtSize = b => {
if (b < 1024) return b+' B';
if (b < 1<<20) return (b/1024).toFixed(0)+' KB';
if (b < 1<<30) return (b/(1<<20)).toFixed(1)+' MB';
return (b/(1<<30)).toFixed(2)+' GB';
};
const fmtT = s => {
const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=Math.floor(s%60);
return h?`${h}:${p(m)}:${p(sec)}`:`${m}:${p(sec)}`;
};
const p = n => String(n).padStart(2,'0');
function drawWave(rms, sections, duration) {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('class','wave');
svg.setAttribute('viewBox', `0 0 ${rms.length} 1`);
svg.setAttribute('preserveAspectRatio','none');
// highlight loud sections
if (duration > 0) {
sections.forEach(s => {
const r = document.createElementNS(ns, 'rect');
r.setAttribute('x', (s.start/duration)*rms.length);
r.setAttribute('y', 0);
r.setAttribute('width', ((s.end-s.start)/duration)*rms.length);
r.setAttribute('height', 1);
r.setAttribute('fill','rgba(249,115,22,0.22)');
svg.appendChild(r);
});
}
const maxV = Math.max(...rms, 0.001);
rms.forEach((v,i) => {
const h = v/maxV;
const r = document.createElementNS(ns,'rect');
r.setAttribute('x', i); r.setAttribute('y', 1-h);
r.setAttribute('width',1); r.setAttribute('height',h);
r.setAttribute('fill','#4f9cf9');
svg.appendChild(r);
});
return svg;
}
async function analyse(filename, cell, btn) {
btn.disabled = true;
btn.textContent = '';
cell.innerHTML = '<div class="spin">Analysing…</div>';
try {
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename));
const d = await r.json();
if (d.error) { cell.innerHTML=`<div class="spin">Error: ${d.error}</div>`; return; }
const box = document.createElement('div'); box.className='wbox';
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0));
const chips = document.createElement('div'); chips.className='chips';
if (d.sections && d.sections.length) {
d.sections.forEach(s => {
const c=document.createElement('span'); c.className='chip';
c.textContent=`${fmtT(s.start)} ${fmtT(s.end)}`;
chips.appendChild(c);
});
} else {
chips.innerHTML='<span class="quiet">No loud sections</span>';
}
box.appendChild(chips);
cell.innerHTML=''; cell.appendChild(box);
} catch(e) {
cell.innerHTML=`<div class="spin">Error: ${e.message}</div>`;
}
}
async function load() {
const files = await (await fetch('/api/files')).json();
const tbody = document.getElementById('tbody');
document.getElementById('subtitle').textContent =
`${files.length} recording${files.length!==1?'s':''} found`;
if (!files.length) { document.getElementById('empty').style.display=''; return; }
files.forEach(f => {
const ext = f.ext;
const tr = document.createElement('tr');
const waveCell = ext==='wav'
? `<td class="wave-cell"></td>`
: `<td><span class="muted" style="font-size:12px">WAV files only</span></td>`;
tr.innerHTML = `
<td><span class="badge badge-${ext}">${ext}</span><span class="fn">${f.name}</span></td>
<td class="muted" style="white-space:nowrap">${f.date}</td>
<td style="white-space:nowrap">${fmtDur(f.duration)}</td>
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
${waveCell}
<td><a class="dl" href="/download/${encodeURIComponent(f.name)}">&#8659; Download</a></td>`;
tbody.appendChild(tr);
if (ext === 'wav') {
const cell = tr.querySelector('.wave-cell');
const btn = document.createElement('button');
btn.textContent = 'Analyse';
btn.addEventListener('click', () => analyse(f.name, cell, btn));
cell.appendChild(btn);
}
});
}
load();
</script>
</body>
</html>"""
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description='ISR Web — audio archive browser')
parser.add_argument('--dir', default='recordings',
help='Recordings directory (default: recordings)')
parser.add_argument('--port', type=int, default=8080,
help='HTTP port (default: 8080)')
parser.add_argument('--host', default='0.0.0.0',
help='Bind address (default: 0.0.0.0)')
parser.add_argument('--threshold', type=float, default=LOUD_THRESHOLD,
help=f'RMS loudness threshold 01 (default: {LOUD_THRESHOLD})')
args = parser.parse_args()
rec_dir = Path(args.dir)
if not rec_dir.exists():
print(f"Warning: recordings directory '{args.dir}' does not exist yet.")
class Handler(_Handler):
recordings_dir = str(rec_dir.resolve())
threshold = args.threshold
server = HTTPServer((args.host, args.port), Handler)
print(f"ISR Web running → http://{args.host}:{args.port}/")
print(f"Recordings dir → {rec_dir.resolve()}")
print(f"Loud threshold → {args.threshold}")
if not NUMPY_AVAILABLE:
print("Note: numpy not installed — RMS analysis uses pure Python (slower for large files)")
print("Stop with Ctrl+C\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("Stopped.")
if __name__ == '__main__':
main()