This file provides full project context for Claude Code or any AI assistant working on this codebase. Read this before making any changes.
A WiFi-connected audio recorder system consisting of three components:
audio_recorder_server.py — Python/Flask server running on a Windows PC.
Handles all audio capture, encoding, file management, and serves a web UI.
src/launcher.py — Windows GUI launcher. Starts the Flask server as a
background thread, shows a system tray icon, and opens the browser on startup.
Built into a standalone AudioRecorder.exe via PyInstaller.
AudioRecorderController.ino — Arduino sketch for an ESP32-S3 with an
LCD Keypad Shield (LCD1602). Sends HTTP commands to the PC server over WiFi,
displays status and a live audio level bar on the LCD.
[ESP32-S3 + LCD Keypad Shield]
| WiFi HTTP GET
v
[AudioRecorder.exe (Windows PC)]
└── launcher.py (system tray, browser opener)
└── audio_recorder_server.py (Flask on port 5000)
├── sounddevice (audio capture)
├── soundfile (WAV/FLAC/OGG encoding)
├── lameenc (MP3 encoding)
└── /recordings/ (output directory)
Communication is plain HTTP on the local network. The ESP32 calls the PC server directly. The PC server also hosts its own web UI accessible from any browser on the LAN.
AudioRecorder_Build/
├── audio_recorder_server.py # Main server — edit this for recording logic
├── src/
│ ├── launcher.py # Windows tray launcher — edit for UI/startup
│ └── audio_recorder_server.py # Copy kept in sync with root version
├── AudioRecorder.spec # PyInstaller build config
├── BUILD.bat # One-click Windows build script
├── debug_test.py # Standalone diagnostic tool
├── build_tools/
│ └── version_info.txt # Windows EXE metadata
├── CLAUDE.md # This file
└── VERSION.md # Changelog
Arduino sketch (separate):
AudioRecorderController.ino # ESP32-S3 firmware
init_from_args() at startup)| Variable | Default | Description |
|---|---|---|
OUTPUT_DIR |
./recordings |
Where recordings are saved |
SAMPLE_RATE |
44100 |
Hz — overridden by device resolver |
CHANNELS |
2 |
1=mono, 2=stereo — clamped to device max |
DEVICE |
None |
sounddevice input index, None=system default |
FILE_FORMAT |
WAV |
WAV / FLAC / OGG / MP3 |
PORT |
5000 |
Flask HTTP port |
MP3_AVAILABLE |
auto | True if lameenc is installed |
init_from_args() must be called before app.run() — argparse is deferred
so the module can be safely imported by launcher.py without consuming its argv.signal.signal() is inside if __name__ == "__main__": only — signals
cannot be registered from a non-main thread; the launcher imports the module
from a background thread so signal registration must be guarded.app.run() must use use_reloader=False — Flask's reloader spawns a child
watcher process which causes infinite restart loops in frozen PyInstaller EXEs.render_template_string() —
Jinja2 misinterprets CSS/JS curly braces as template variables and throws 500
errors. Always use: return html, 200, {"Content-Type": "text/html; charset=utf-8"}build_ui_html() uses concatenated string literals — triple-quoted strings
containing CSS/JS caused Jinja2 issues in earlier versions. The current
implementation builds HTML via Python string concatenation to avoid any
template engine involvement.| Method | Route | Description |
|---|---|---|
| GET | / |
Web UI (full recorder interface) |
| GET | /api/start |
Start recording |
| GET | /api/stop |
Stop recording (data held in memory) |
| GET | /api/pause |
Pause if recording, resume if paused |
| GET | /api/resume |
Resume explicitly |
| GET | /api/save |
Stop (if needed) + encode + write to disk |
| GET | /api/status |
Full state dict incl. file list + device name |
| GET | /api/level |
Lightweight RMS level poll {level, peak, state} |
| GET | /api/devices |
List input devices with capabilities |
| POST | /api/setdevice |
Set device + channels + samplerate + format |
| POST | /api/rename |
Rename a recording file {old, new} |
| GET | /recordings/<file> |
Download a recording |
float32 PCM blocks in _audio_callback_actual_rate and _actual_ch are stored on the instance after stream opens
(may differ from globals if resolve_device_settings() clamped them)save() runs in a daemon thread so it doesn't block the Flask serverlameenc at 192 kbps, quality=2. Input must be int16 PCM.resolve_device_settings)Called before every recording. Queries the device's actual max_input_channels
and default_samplerate, clamps channels, then tries sample rates in order:
[wanted, device_default, 48000, 44100, 22050, 16000] using
sd.check_input_settings() to validate each before committing.
This prevents PortAudio error -9996 (invalid device config).
Default format: YYYYMMDD_DAY_HHMM.ext
Example: 20260304_TUE_0947.wav
Set in _make_filename(). Extension comes from FILE_FORMAT.
multiprocessing.freeze_support() must be the very first line — before any
other imports. On Windows, PyInstaller re-executes the EXE entry point when
spawning subprocesses. Without freeze_support, this creates infinite launcher
copies that crash the machine.threading.Thread, never subprocess.Popen — subprocess
spawning in a frozen EXE triggers the infinite restart loop described above.sys._MEIPASS for bundled files — PyInstaller
extracts bundled data files to a temp directory at sys._MEIPASS. User files
(recordings, config) are saved next to the EXE at Path(sys.executable).parent.pystray + PIL — tkinter was removed because it causes
_tkinter import errors in frozen PyInstaller builds on Python 3.14.recorder_config.json sits next to the EXE. Keys:
port, device, outdir, samplerate, channels, format, auto_open_browser
All startup events written to recorder_launcher.log next to the EXE.
Also available via tray menu → "Show Log". Useful for diagnosing startup failures
in the frozen EXE where there is no console.
| Shield | ESP32-S3 GPIO | Function |
|---|---|---|
| RS | 19 | LCD Register Select |
| EN | 18 | LCD Enable |
| D4–D7 | 17, 16, 15, 7 | LCD data |
| A0 | 1 (ADC) | Button resistor ladder |
| Backlight | 2 (PWM) | LCD backlight |
| Button | Action | ADC < |
|---|---|---|
| SELECT | Start recording | 3000 |
| LEFT | Pause / Resume | 2100 |
| DOWN | Stop recording | 1100 |
| RIGHT | Save last recording | 600 (UP) / see code |
| UP | Show IP address | 600 |
* REC 00:00:42 during recording/api/level every 120ms during recordingHTTPClient GET requestsfetch() calls direct to the PC server (URL embedded at render time)PreferencesAudioRecorder
— connect and browse to 192.168.4.1 to configureflask
sounddevice
soundfile
numpy
pillow
pystray
lameenc
pyinstaller # build only
LiquidCrystal (built-in)
WiFi (ESP32 core)
WebServer (ESP32 core)
HTTPClient (ESP32 core)
Preferences (ESP32 core)
ArduinoJson (Library Manager: Benoit Blanchon)
BUILD.bat
This installs all Python dependencies, copies audio_recorder_server.py into
src/, and runs PyInstaller with AudioRecorder.spec.
Output: dist/AudioRecorder.exe (~80 MB, fully self-contained)
Key PyInstaller settings in the spec:
console=False — no black console windowupx=False — avoids false-positive AV flagshiddenimports includes all sounddevice, pystray, lameenc, flask internalsAllow port 5000 through Windows Firewall (run once as admin):
netsh advfirewall firewall add rule name="AudioRecorder" dir=in action=allow protocol=TCP localport=5000
| Error | Cause | Fix |
|---|---|---|
| Infinite EXE spawn loop | subprocess.Popen in frozen app + missing freeze_support() |
Server runs as thread; freeze_support() is first line |
_tkinter missing |
tkinter doesn't bundle reliably with PyInstaller on Python 3.14 | Replaced with pystray |
audio_recorder_server.py not found |
sys._MEIPASS path set too late |
Path resolution at module top before any imports |
| Internal Server Error (500) on web UI | Jinja2 parses CSS/JS {} as template variables |
Raw string response, no render_template_string() |
ValueError: signal only works in main thread |
signal.signal() at module level, called from launcher thread |
Moved inside if __name__ == "__main__": |
PortAudio error -9996 |
Device doesn't support requested sample rate / channel count | resolve_device_settings() probes and clamps before opening stream |
| Remote visualiser not working | getUserMedia blocked on non-localhost HTTP origins |
Server-side RMS via /api/level; getUserMedia only on localhost |
The web UI (build_ui_html()) is a single-page app with three tabs:
Recorder tab
/api/level poll)Files tab
Settings tab
/api/devices)lameenc not foundPOST /api/setdevice — takes effect immediatelydebug_test.py — run directly with Python (not as EXE) to diagnose issues:
python debug_test.py
Checks: package imports, port availability, module import, audio devices, Flask startup, local IP. Prints pass/fail for each and pauses on errors.