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:
@@ -0,0 +1,12 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.claude/
|
||||||
|
tests/
|
||||||
|
config.ini
|
||||||
|
recordings/
|
||||||
|
*.log
|
||||||
|
changelog.txt
|
||||||
|
guide.md
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
recordings/
|
||||||
|
recorder.log
|
||||||
|
config.ini
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache/
|
||||||
|
.claude/
|
||||||
|
nul
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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 0–1 (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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
requests
|
||||||
|
numpy
|
||||||
|
soundfile
|
||||||
@@ -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
|
||||||
@@ -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 0–1 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)}">⇓ 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 0–1 (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()
|
||||||
Reference in New Issue
Block a user