This document describes the implemented architecture of the SessionPrep library. It serves as the authoritative reference for the codebase.
The goal is to extract all detection, analysis, and processing logic from the
monolithic sessionprep.py into a reusable library (sessionpreplib) that can
be consumed by a CLI app, a GUI app, or any other frontend without code
duplication.
Note: The
ptsl/folder is not part of this architecture and should be disregarded.
- Development Setup
- Building & Distribution
- Package Layout
- Core Models
- Audio Utilities
- Detectors
- Audio Processors
- DAW Processors
- Pipeline
- Session Queue
- Configuration & Presets
- Event System
- Rendering
- Schema Versioning
- Error Isolation Strategy
- Validation Layer
- CLI App (
sessionprep.py) - GUI App (
sessionprep-gui.py/sessionprepgui/) - Migration Notes
This project uses uv for dependency management,
virtual environments, and Python version management. All configuration lives in
pyproject.toml (PEP 621).
- Python >= 3.12 (uv can install this for you)
- uv — install via:
# Windows (PowerShell) powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # macOS / Linux curl -LsSf https://astral.sh/uv/install.sh | sh
# Clone the repository
git clone <repo-url>
cd sessionprep
# Create virtual environment and install all dependencies (core + dev)
uv syncuv sync reads pyproject.toml, creates .venv/, installs all dependencies
(including dev group: pytest, pytest-cov, pyinstaller), and generates uv.lock
for reproducible installs.
# Via uv (recommended during development)
uv run python sessionprep.py <directory> [options]
# Or activate the venv and run directly
# Windows:
.venv\Scripts\activate
python sessionprep.py <directory> [options]
# macOS/Linux:
source .venv/bin/activate
python sessionprep.py <directory> [options]uv run pytest
uv run pytest --cov=sessionpreplib # with coverage# Add a runtime dependency
uv add <package>
# Add a dev-only dependency
uv add --group dev <package>This updates pyproject.toml and uv.lock automatically.
# Re-resolve all dependencies to their latest compatible versions
uv lock --upgrade
# Install the updated versions into .venv/
uv syncuv self update# Install a specific Python version
uv python install 3.12
# Pin the project to a version
uv python pin 3.12SessionPrep uses Python's standard logging module with a rotating log file
and optional stderr output. Logging is configured once at startup via
sessionpreplib.logging_setup.setup_logging().
Log file location — sessionprep.log in the OS-specific config directory:
| Platform | Path |
|---|---|
| Windows | %APPDATA%\sessionprep\sessionprep.log |
| macOS | ~/Library/Application Support/sessionprep/sessionprep.log |
| Linux | $XDG_CONFIG_HOME/sessionprep/sessionprep.log (default ~/.config/) |
The log file uses a RotatingFileHandler (5 MB max, 3 backups) and always
appends across sessions. A stderr handler is added automatically when a
terminal is attached.
Log level is controlled via the SP_LOG_LEVEL environment variable:
| Value | Effect |
|---|---|
DEBUG |
Verbose output (timings, internals) |
INFO |
Normal operation (default) |
WARNING |
Warnings and errors only |
ERROR |
Errors only |
CRITICAL |
Critical errors only |
NONE |
Disable logging entirely |
Example:
# Enable debug logging for a single run
SP_LOG_LEVEL=DEBUG uv run python sessionprep-gui.py
# Windows (PowerShell)
$env:SP_LOG_LEVEL = "DEBUG"; uv run python sessionprep-gui.pySessionPrep supports two build engines for creating standalone executables:
PyInstaller (fast, standard) and Nuitka (high-performance, compiled).
Both engines share a centralized configuration in build_conf.py.
All build metadata—target entry points, asset paths, platform-specific names,
and library exclusion rules—is defined in build_conf.py. This ensures that
both build engines produce consistent results and maintain strict dependency
hygiene (e.g., ensuring rich is never bundled with the GUI).
The build_pyinstaller.py script automates PyInstaller builds. It produces
executables that are relatively quick to build but have slightly slower
startup (~2-3s) because they unpack to a temporary directory.
# Build both CLI and GUI (onedir, default)
uv run python build_pyinstaller.py
# Build both as single executables
uv run python build_pyinstaller.py --onefile
# Build CLI only
uv run python build_pyinstaller.py cli
# Build GUI only
uv run python build_pyinstaller.py gui
# Clean previous build artifacts first
uv run python build_pyinstaller.py --clean guiOutput goes to dist_pyinstaller/.
The build_nuitka.py script uses Nuitka to transpile the Python code into C
and compile it to a native machine-code binary. This results in faster startup
times and better performance, at the cost of significantly longer compilation
times.
# Build both CLI and GUI
uv run python build_nuitka.py all
# Build CLI only
uv run python build_nuitka.py cli
# Build GUI only
uv run python build_nuitka.py gui
# Clean cache before building
uv run python build_nuitka.py --clean allOutput goes to dist_nuitka/.
Each executable name includes a platform and architecture suffix generated
automatically by build_conf.py:
| Platform | CLI output filename | GUI output filename |
|---|---|---|
| Windows x64 | sessionprep-win-x64.exe |
sessionprep-gui-win-x64.exe |
| macOS ARM | sessionprep-macos-arm64 |
sessionprep-gui-macos-arm64 |
| macOS Intel | sessionprep-macos-x64 |
sessionprep-gui-macos-x64 |
| Linux x64 | sessionprep-linux-x64 |
sessionprep-gui-linux-x64 |
| Linux ARM64 | sessionprep-linux-arm64 |
sessionprep-gui-linux-arm64 |
Note on macOS: GUI builds always use onedir mode (producing a .app
bundle) because --onefile + --windowed is deprecated in both engines for
macOS GUI apps. The scripts automatically zip the .app bundle for
distribution.
Prerequisites for GUI builds: GUI dependencies must be installed:
uv sync --extra gui.
The project remains installable as a standard Python package:
# Install from local source
pip install .
# Build a wheel
uv buildAfter installation, the CLI is available as sessionprep.
The version number lives in a single file:
sessionpreplib/_version.py → __version__ = "0.1.0"
Everything else reads from this one source:
| Consumer | How it reads the version |
|---|---|
pyproject.toml |
dynamic = ["version"] + [tool.hatch.version] path |
sessionpreplib |
from ._version import __version__ (re-exported in __init__.py) |
CLI (sessionprep.py) |
from sessionpreplib import __version__ (powers --version flag) |
| GUI About dialog | from sessionpreplib import __version__ |
| PyInstaller builds | Bundled automatically via --collect-all sessionpreplib |
To bump the version, edit only sessionpreplib/_version.py.
To build on Linux (especially with Nuitka), you need a few system libraries installed:
# Debian / Ubuntu
sudo apt install gcc patchelf ccache libatomic1
# Fedora
sudo dnf install gcc patchelf ccache libatomic-staticgcc: The C compiler.patchelf: Required by Nuitka to modify RPATHs in standalone binaries.ccache: (Optional) Speeds up recompilation significantly.libatomic: Required for linking NumPy extensions statically on some architectures/compilers.
| File | Purpose |
|---|---|
pyproject.toml |
Package metadata, dependencies, build config, entry points |
uv.lock |
Lockfile for reproducible dependency resolution |
build_conf.py |
Shared build metadata and isolation rules (Source of Truth) |
build_pyinstaller.py |
PyInstaller automation (standard builds) |
build_nuitka.py |
Nuitka automation (optimized builds) |
sessionprep.py |
Thin CLI entry point |
sessionprep-gui.py |
Thin GUI entry point |
| Package | Type | Used by |
|---|---|---|
numpy |
Runtime | sessionpreplib (DSP, array ops) |
soundfile |
Runtime | sessionpreplib/audio.py (WAV I/O, bundles libsndfile) |
scipy |
Runtime | sessionpreplib/audio.py (subsonic STFT analysis), sessionprepgui/waveform/compute.py (mel spectrogram) |
rich |
Runtime | sessionprep.py (CLI rendering: tables, panels, progress) |
PySide6 |
Optional (gui) | sessionprepgui (Qt widgets, main window, waveform) |
sounddevice |
Optional (gui) | sessionprepgui/detail/playback.py (audio playback via PortAudio) |
py-ptsl |
Optional (gui) | sessionpreplib/daw_processors/protools.py (Pro Tools Scripting SDK gRPC client) |
dawproject |
Optional (gui) | sessionpreplib/daw_processors/dawproject.py (DAWproject file format library) |
pytest |
Dev | Test runner |
pytest-cov |
Dev | Coverage reporting |
pyinstaller |
Dev | Standalone executable builds |
Pillow |
Dev | Icon format conversion for PyInstaller (macOS .png → .icns) |
Core runtime dependencies (numpy, soundfile, scipy) are declared in
[project].dependencies. GUI-only dependencies (PySide6, sounddevice)
are declared as optional under [project.optional-dependencies].gui.
Install with pip install .[gui] or uv sync (which installs all groups
by default).
sessionpreplib/
__init__.py # Public API surface (re-exports __version__)
_version.py # Single source of truth for version number
models.py # All dataclasses + enums (incl. IssueLocation)
config.py # Preset load/save/merge + config validation
audio.py # Audio I/O + cached DSP utilities
chunks.py # WAV/AIFF chunk I/O (read, write, remove, identify)
utils.py # Keyword matching, group assignment, sort key
events.py # EventBus
detector.py # TrackDetector / SessionDetector ABCs
processor.py # AudioProcessor ABC + priority bands
pipeline.py # 3-phase orchestrator + validation
logging_setup.py # Centralized logging config (RotatingFileHandler, SP_LOG_LEVEL)
queue.py # SessionQueue / SessionJob
rendering.py # Diagnostic summary builder + PlainText renderer
topology.py # TopologyMapping, TopologyEntry, TopologySource, ChannelRoute,
# build_default_topology(), rebuild_transfer_manifest(), validate_topology()
detectors/
__init__.py # Exports all detectors; provides default_detectors()
silence.py # SilenceDetector
clipping.py # ClippingDetector
dc_offset.py # DCOffsetDetector
stereo_compat.py # StereoCompatDetector (correlation + mono folddown)
dual_mono.py # DualMonoDetector
one_sided_silence.py # OneSidedSilenceDetector
subsonic.py # SubsonicDetector
audio_classifier.py # AudioClassifierDetector
tail_exceedance.py # TailExceedanceDetector
format_consistency.py # FormatConsistencyDetector (session-level)
length_consistency.py # LengthConsistencyDetector (session-level)
processors/
__init__.py # Exports all processors; provides default_processors()
bimodal_normalize.py # BimodalNormalizeProcessor
mono_downmix.py # MonoDownmixProcessor (stub)
daw_processors/
__init__.py # Exports all DAW processors; provides default_daw_processors()
protools.py # ProToolsDawProcessor — Pro Tools integration via py-ptsl gRPC SDK
dawproject.py # DawProjectDawProcessor — .dawproject file generation from templates
sessionprepgui/ # GUI package (PySide6)
__init__.py # Exports main()
log.py # Convenience dbg() wrapper over standard logging
res/ # Application icons (SVG, PNG, ICO)
settings.py # Persistent config (load/save/validate, OS paths)
theme.py # Colors, FILE_COLOR_* constants, dark theme
helpers.py # esc(), track_analysis_label() (severity counts), fmt_time(), severity maps
mainwindow.py # SessionPrepWindow (QMainWindow) + main() — thin orchestrator composing all mixins
analysis/
mixin.py # AnalysisMixin — open/save session, analyze, prepare, session Config tab
worker.py # QThread workers: AnalyzeWorker, BatchReanalyzeWorker, PrepareWorker,
# DawCheckWorker, DawFetchWorker, DawTransferWorker
daw/
mixin.py # DawMixin — DAW processor selection, fetch, transfer, folder tree, assignments
detail/
mixin.py # DetailMixin — file detail view, waveform display, overlays, playback
playback.py # PlaybackController (sounddevice lifecycle + signals)
report.py # HTML rendering: render_summary_html(), render_fader_table_html(),
# render_track_detail_html()
prefs/
__init__.py # Exports PreferencesDialog, PathPicker, PathPickerMode, build_config_pages
param_form.py # Portable widget factory: ParamSpec protocol, PathPickerMode, PathPicker,
# _build_widget, _build_param_page, tooltip builders, sanitize_output_folder
preset_panel.py # Portable NamedPresetPanel — reusable named-preset CRUD widget
config_pages.py # SessionPrep-specific: GroupsTableWidget, DawProjectTemplatesWidget,
# build/load/read_config_pages
page_general.py # GeneralPage — app settings (widget_hint drives PathPicker for dir field)
page_colors.py # ColorsPage — editable color palette table
page_groups.py # GroupsPage — named group presets via NamedPresetPanel
dialog.py # PreferencesDialog — thin ~270-line orchestrator
param_widgets.py # Backward-compatible re-export shim → param_form + config_pages
topology/
mixin.py # TopologyMixin — Phase 1 Channel Topology UI, Apply worker, waveform preview
input_tree.py # InputTree — QTreeWidget for source tracks with drag-out support
output_tree.py # OutputTree — QTreeWidget for output tracks with drag-in, reorder,
# cross-file channel moves, insert-position indicator
operations.py # Topology mutation functions: add/remove/reorder/move/wire channels,
# rename/remove outputs, move_channel (cross-file)
session/
io.py # Session save/load (.spsession JSON) — serialises analysis state
tracks/
columns_mixin.py # TrackColumnsMixin — track table column setup and sorting
groups_mixin.py # GroupsMixin — group assignment UI and color rendering
table_widgets.py # Track table widget classes
waveform/
__init__.py # Re-exports WaveformWidget, WaveformLoadWorker, SPECTROGRAM_COLORMAPS
compute.py # Colormaps, mel math, spectrogram computation, QThread workers
# (WaveformLoadWorker, SpectrogramWorker, PeakBuildWorker)
peakcache.py # Pre-computed peak mipmap cache: build, save, load, query
# Binary .peaks format for fast waveform rendering
renderer.py # WaveformRenderer — peaks, waveform drawing, RMS overlay, dB scale, markers
spectrogram.py # SpectrogramRenderer — mel image, frequency scale, freq zoom/pan
overlay.py # Stateless overlay drawing functions (issue overlays, time scale)
panel.py # WaveformPanel — composite widget: toolbar + WaveformWidget + transport bar
widget.py # WaveformWidget — thin orchestrator coordinating WaveformRenderer
# and SpectrogramRenderer
sessionprep.py # Thin CLI: argparse + Rich rendering + glue
sessionprep-gui.py # Thin GUI entry point (delegates to sessionprepgui)
sessionprep_original.py # Backup of the monolithic original
All data models live in sessionpreplib/models.py.
class Severity(Enum):
CLEAN = "clean"
INFO = "info"
ATTENTION = "attention"
PROBLEM = "problem"Maps 1:1 to the diagnostic categories (CLEAN / INFORMATION / ATTENTION / PROBLEMS).
Represents a detected issue at a specific position or region in the waveform. Used by the GUI to render issue overlays and tooltips on the waveform widget.
@dataclass
class IssueLocation:
sample_start: int # Start sample position (inclusive)
sample_end: int | None # End sample position (inclusive), None for point issues
channel: int | None # Channel index (0=L, 1=R, …), None = all channels
severity: Severity
label: str # Machine-readable tag, e.g. "clipping"
description: str # Human-readable text for tooltips / overlays
freq_min_hz: float | None = None # Optional frequency lower bound (Hz)
freq_max_hz: float | None = None # Optional frequency upper bound (Hz)When freq_min_hz and freq_max_hz are set, the GUI renders frequency-bounded
rectangles in spectrogram mode (mapped via mel scale). Without frequency bounds
the overlay spans the full frequency range.
Universal output of every detector. Serves three audiences:
- Machine readers (processors, JSON export) read
data. - Human readers (CLI, GUI, text report) read
summary,detail_lines,hint. - Categorization logic (diagnostic summary) reads
severity. - GUI waveform reads
issuesfor visual overlays.
@dataclass
class DetectorResult:
detector_id: str
severity: Severity
summary: str
data: dict[str, Any]
detail_lines: list[str] = field(default_factory=list)
hint: str | None = None
error: str | None = None
issues: list[IssueLocation] = field(default_factory=list)Each detector documents its data keys. Processors that depend on a detector
reference these keys explicitly. See Section 6 for per-detector
data schemas.
Detectors populate issues with IssueLocation objects to mark specific
positions or regions in the waveform. Per-channel issues (e.g., clipping on
channel 0) set channel to the channel index; whole-file issues (e.g., DC
offset) set channel to None.
Declarative specification for a configuration parameter. Lives in
config.py. Used by detectors, processors, and the shared analysis /
session sections to describe their parameters.
@dataclass(frozen=True)
class ParamSpec:
key: str # config key name
type: type | tuple # expected Python type(s)
default: Any
label: str # short UI label
description: str = "" # tooltip / help text
min: float | int | None = None # inclusive lower bound
max: float | int | None = None # inclusive upper bound
min_exclusive: bool = False
max_exclusive: bool = False
choices: list | None = None # allowed string values
item_type: type | None = None # element type for list fields
nullable: bool = False # True if None is valid
presentation_only: bool = False # True → changing this key never requires re-analysis
widget_hint: str | None = None # rendering hint for the GUI widget factory (never read by the library)presentation_only marks keys whose changes never affect analysis results (e.g.
report_as), allowing the GUI to skip re-analysis on save.
widget_hint is an optional rendering hint consumed exclusively by
sessionprepgui/prefs/param_form.py's _build_widget(). The library itself
never reads it. Supported values:
| Value | Widget produced |
|---|---|
"path_picker_folder" |
PathPicker(mode=FOLDER) — line edit + Browse dir dialog |
"path_picker_file" |
PathPicker(mode=OPEN_FILE) — line edit + Open file dialog |
"path_picker_save" |
PathPicker(mode=SAVE_FILE) — line edit + Save file dialog |
None (default) |
Standard widget derived from type and choices |
Every detector and processor exposes its parameters via a
config_params() classmethod that returns list[ParamSpec]. Shared
analysis parameters and global processing defaults are in
ANALYSIS_PARAMS (in config.py).
@dataclass
class ProcessorResult:
processor_id: str
gain_db: float
classification: str
method: str
data: dict[str, Any] = field(default_factory=dict)
error: str | None = NonePlain data objects representing DAW operations and their outcomes.
DawCommand is created by a DawProcessor, executed by the same processor
via internal dispatch (Option B — dumb data, smart processor).
@dataclass
class DawCommand:
"""A single operation to perform against a DAW.
Plain data object — the DawProcessor that created it is responsible
for execution. undo_params captures the state needed to reverse
the operation (e.g. the previous fader value).
"""
command_type: str # e.g. "set_clip_gain", "set_fader", "set_color"
target: str # e.g. track name, folder path
params: dict[str, Any] = field(default_factory=dict)
source: str = "" # processor id that produced this
undo_params: dict[str, Any] | None = None # for future rollback
@dataclass
class DawCommandResult:
"""Outcome of executing a single DawCommand."""
command: DawCommand
success: bool
error: str | None = None
timestamp: datetime = field(default_factory=datetime.now)Per-file state object. Created once per track, passed through all phases.
@dataclass
class TrackContext:
filename: str
filepath: str
audio_data: np.ndarray | None
samplerate: int
channels: int
total_samples: int
bitdepth: str
subtype: str
duration_sec: float
status: str = "OK"
detector_results: dict[str, DetectorResult] = field(default_factory=dict)
processor_results: dict[str, ProcessorResult] = field(default_factory=dict)
group: str | None = None
classification_override: str | None = None
rms_anchor_override: str | None = None
chunk_ids: list[str] = field(default_factory=list)
_cache: dict[str, Any] = field(default_factory=dict, repr=False)
# File-based processing pipeline
processed_filepath: str | None = None
applied_processors: list[str] = field(default_factory=list)
processor_skip: set[str] = field(default_factory=set)filename— when recursive folder scanning is active, stores a forward-slash–separated relative path (e.g."drums/01_Kick.wav") instead of a bare filename. This relative key propagates through topology entries, transfer manifest, and all UI tables.filepathalways remains the absolute path on disk.processed_filepath— absolute path to the processed output file (set byPipeline.prepare()), orNoneif not yet prepared.applied_processors— list of processor IDs that were applied during the lastprepare()run.processor_skip— set of processor IDs to skip for this track. Populated in two ways: (1) automatically at the end ofplan()for any processor wheredefault=False; (2) manually when the user toggles a processor in the Processing column. "Default" in the UI means the current selection matches each processor's configureddefaultvalue (not necessarily "all active").
@dataclass
class SessionContext:
tracks: list[TrackContext]
config: dict[str, Any]
groups: dict[str, str] = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
detectors: list = field(default_factory=list)
processors: list = field(default_factory=list)
daw_state: dict[str, Any] = field(default_factory=dict)
daw_command_log: list[DawCommandResult] = field(default_factory=list)
prepare_state: str = "none"
topology: Any = None # TopologyMapping | None
output_tracks: list[TrackContext] = field(default_factory=list)
transfer_manifest: list[TransferEntry] = field(default_factory=list)daw_state— namespaced per DAW processor id. Each processor stores fetched data and last-transfer snapshots here (e.g.session.daw_state["protools"]["folders"]).daw_command_log— flat, append-only list of all executed DAW commands across all transfer/sync/execute_commands calls.prepare_state— tracks the state of the file-based Prepare step. Values:"none"(never prepared),"ready"(prepared and up-to-date),"stale"(prepared but invalidated by changes to gain, classification, RMS anchor, processor selection, or re-analysis).topology— the channel topology mapping (TopologyMapping), orNoneif not yet configured. Defines how input tracks are routed to output files.output_tracks—TrackContextobjects for the topology-resolved output files (populated after Phase 1 Apply).transfer_manifest— list ofTransferEntryobjects representing logical DAW tracks. Multiple entries can reference the same output file (for duplication scenarios). Rebuilt from the topology on Apply.
Represents a logical DAW track in the transfer manifest. Multiple entries can reference the same output file, enabling N:1 mapping from DAW tracks to physical output files (e.g. guitar doubling with the same clip on multiple tracks).
@dataclass
class TransferEntry:
entry_id: str # unique identifier (output_filename for originals, filename:uuid for duplicates)
output_filename: str # references a TopologyEntry.output_filename
daw_track_name: str # name shown in DAW (defaults to output filename)
group: str | None = None # group for DAW folder assignment (inherits from source)entry_id— primary key for assignments and drag-drop. For original entries it equalsoutput_filename; for user-created duplicates it isoutput_filename:hex8.daw_track_name— the name used for track creation in the DAW. Always the basename stem (e.g."01_Kick"even whenoutput_filenameis"drums/01_Kick.wav"). Editable inline in the Phase 3 setup table (Track Name column). On duplication, the original is renamed tostem-[1]and the new entry getsstem-[2]; subsequent duplications increment the suffix.- Both DAW processors (
protools.py,dawproject.py) useentry.daw_track_name(stem, without extension) as the track name.
Used by the queue layer.
@dataclass
class SessionResult:
session: SessionContext
daw_commands: list[DawCommand] = field(default_factory=list)
diagnostic_summary: dict[str, Any] = field(default_factory=dict)
class JobStatus(Enum):
PENDING / RUNNING / COMPLETED / FAILED / CANCELLED
@dataclass
class SessionJob:
job_id: str
source_dir: str
config: dict[str, Any]
status: JobStatus = JobStatus.PENDING
priority: int = 0
result: SessionResult | None = None
error: str | None = None
created_at: datetime = field(default_factory=datetime.now)
completed_at: datetime | None = NoneContains audio I/O, cached DSP helpers, and stateless DSP functions.
Cached helpers (store results on TrackContext._cache):
get_peak(track) -> float— peak linear amplitudeget_peak_db(track) -> floatis_silent(track) -> bool— peak == 0get_rms_window_means(track, window_ms, stereo_mode) -> ndarrayget_window_samples(track, window_ms) -> intget_gated_rms_data(track, window_ms, stereo_mode, gate_relative_db) -> dictReturns{"active_means", "active_mask", "all_means", "max_mean", "rms_max_db"}get_stereo_channels_subsampled(track) -> tuple[ndarray, ndarray] | Noneget_stereo_channels_dc_removed(track) -> tuple[ndarray, ndarray] | Noneget_stereo_rms(track) -> dict | NoneReturns{"l_rms_lin", "r_rms_lin", "l_rms_db", "r_rms_db"}
Stateless DSP functions:
detect_clipping_ranges(data, threshold_count, max_ranges) -> (int, list[tuple[int, int, int|None]])— ranges are(start, end, channel)subsonic_stft_analysis(signal, samplerate, cutoff_hz, *, window_ms, hop_ms, abs_gate_db, silence_rms) -> (float, list[tuple[int, int, float]])— single-pass STFT subsonic analysis on a 1-D signal; returns(whole_file_ratio_db, per_window_ratios). Usesscipy.signal.stftwith Hann windowing, vectorised band/total power computation, and silence + absolute power gates.windowed_stereo_correlation(left, right, samplerate, window_ms, silence_rms) -> (float, float, list[tuple[int, int, float, float]])— windowed Pearson correlation + mono folddown loss for stereo L/R; returns(whole_corr, whole_mono_loss_db, per_window_results). Vectorised numpy with per-window DC removal, silence gating, and whole-file aggregation from cumulative dot products.linear_to_db(linear) -> floatdb_to_linear(db) -> floatformat_duration(samples, samplerate) -> str
File discovery:
discover_track(filepath) -> TrackContext— reads audio file metadata (channels, samplerate, bitdepth, duration) without loading audio data. Setsfilename = os.path.basename(filepath)— callers override with relative paths when recursive scanning is active.discover_audio_files(root_dir, recursive=False, skip_folders=None) -> list[str]— returns a sorted list of audio file paths relative to root_dir. Whenrecursive=False, returns bare filenames (flatos.listdir). WhenTrue, walks subdirectories viaos.walk(followlinks=False)and returns forward-slash–separated relative paths (e.g."drums/01_Kick.wav"). Directories whose name appears inskip_foldersare pruned (e.g.{"sp_01_tracklayout", "sp_02_prepared", "sp_peaks"}). Results sorted byprotools_sort_key.
File I/O:
load_track(filepath) -> TrackContext— reads WAV via soundfile, returns populated TrackContext. Setsfilename = os.path.basename(filepath)— callers override with relative paths when recursive scanning is active.write_track(track, output_path) -> None— writes audio_data preserving original subtype
protools_sort_key(filename) -> str— sort key matching Pro Tools behaviormatches_keywords(filename, keywords) -> bool— substring, glob, exact ($suffix) matchingparse_group_specs(group_args) -> list[dict]— parsesName:pattern1,pattern2syntax (mandatoryName:prefix; raisesValueErroron invalid input)assign_groups(filenames, group_specs) -> (dict, list[str])— first-match-wins assignment; returns(filename→group_name, warnings)
Defined in sessionpreplib/detector.py.
class TrackDetector(ABC):
id: str
name: str
depends_on: list[str] = []
def configure(self, config: dict[str, Any]) -> None: ...
@abstractmethod
def analyze(self, track: TrackContext) -> DetectorResult: ...
def clean_message(self) -> str | None: ...
class SessionDetector(ABC):
id: str
name: str
def configure(self, config: dict[str, Any]) -> None: ...
@abstractmethod
def analyze(self, session: SessionContext) -> list[DetectorResult]: ...
def clean_message(self) -> str | None: ...Detectors declare depends_on — a list of detector IDs that must run first.
The pipeline topologically sorts track detectors by this field (Kahn's
algorithm). Validates at startup that all dependencies exist and there are no
cycles.
- ID:
silence| Depends on: (none) - Data:
{"is_silent": bool, "topology_action": "drop"} - Issues: Whole-file
IssueLocation(all channels) when track is silent - Severity:
ATTENTIONif silent,CLEANotherwise - Clean message:
"No silent files detected"
- ID:
clipping| Depends on:["silence"] - Config:
clip_consecutive,clip_report_max_ranges - Data:
{"is_clipped": bool, "runs": int, "ranges": list[tuple[int, int, int|None]]}— each range is(start, end, channel) - Issues: Per-channel
IssueLocationfor each clipping range - Severity:
PROBLEMif clipped,CLEANotherwise - Hint:
"request reprint / check limiting"
- ID:
dc_offset| Depends on:["silence"] - Config:
dc_offset_warn_db - Data:
{"dc_db": float, "dc_warn": bool} - Issues: Whole-file
IssueLocation(all channels) when DC offset detected - Severity:
ATTENTIONif exceeds threshold,CLEANotherwise - Hint:
"consider DC removal"
- ID:
stereo_compat| Depends on:["silence"] - Config:
corr_warn,mono_loss_warn_db,corr_windowed,corr_window_ms(default 250 ms),corr_max_regions - Data:
{"lr_corr": float | None, "mono_loss_db": float | None, "corr_warn": bool, "mono_warn": bool, "windowed_regions": list} - Severity:
ATTENTIONif windowed regions exceed threshold,INFOif whole-file only,CLEANotherwise - Windowed analysis: per-window Pearson correlation + mono folddown loss, merged into regions with
IssueLocationoverlays
- ID:
dual_mono| Depends on:["silence"] - Config:
dual_mono_eps - Data:
{"dual_mono": bool, "topology_action": "extract_channel", "topology_channel": 0} - Severity:
INFOif dual-mono,CLEANotherwise
- ID:
one_sided_silence| Depends on:["silence"] - Config:
one_sided_silence_db - Data:
{"one_sided_silence": bool, "one_sided_silence_side": str | None, "l_rms_db": float, "r_rms_db": float, "topology_action": "extract_channel", "topology_channel": int | None} - Issues: Per-channel
IssueLocationspanning the entire file for the silent channel - Severity:
ATTENTIONif detected,CLEANotherwise - Hint:
"check stereo export / channel routing"
- ID:
subsonic| Depends on:["silence"] - Config:
subsonic_hz,subsonic_warn_ratio_db,subsonic_windowed(defaultTrue),subsonic_window_ms,subsonic_max_regions - Data:
{"subsonic_ratio_db": float, "subsonic_warn": bool, "per_channel": {ch: {"ratio_db", "warn"}}, "windowed_regions"?: list[dict]} - Per-channel analysis (always active for stereo+): Each channel is analyzed independently via
subsonic_stft_analysis(onescipy.signal.stftcall per channel). The combined ratio is the maximum (worst) of all per-channel ratios — more conservative than the previous mono-downmix approach, which could mask subsonic content through phase cancellation. If only one channel triggers the warning, the issue is reported per-channel withchannelset to the offending channel index; if all channels trigger, a whole-file issue is reported. - Windowed analysis (default on via
subsonic_windowed): Per-window ratios are derived from the same STFT pass (vectorised, no Python loop). Contiguous exceeding windows are merged into regions reported asIssueLocationobjects with precisesample_start/sample_endand frequency bounds (freq_min_hz=0,freq_max_hz=cutoff). Capped bysubsonic_max_regions. Safeguards:- Absolute subsonic power gate: Each window's absolute subsonic energy level is checked (
window_rms_db + ratio_db). Windows where this is below −40 dBFS are suppressed — their subsonic content is too quiet to matter regardless of the ratio. Prevents amp hum/noise in quiet gaps from producing false positives. - Threshold relaxation: The windowed threshold is relaxed by 6 dB below the configured threshold. Short windows have less frequency resolution, so borderline subsonic content that triggers the whole-file check may not reach the same threshold per-window.
- Active-signal fallback: If no windows exceed the relaxed threshold, the detector falls back to marking windows that have significant signal (RMS within 20 dB of the loudest window, vectorised via NumPy reshape +
np.diffcontiguous merging). Since the whole-file analysis already confirmed subsonic content, the active-signal windows are where it lives. - Whole-file fallback: If even the active-signal approach produces no regions, a whole-file
IssueLocationis emitted so ATTENTION always has at least one visible overlay.
- Absolute subsonic power gate: Each window's absolute subsonic energy level is checked (
- Issues: Per-region
IssueLocation(with frequency bounds) in windowed mode; falls back to active-signal regions or whole-file span - Severity:
ATTENTIONif exceeds threshold (whole-file or any channel),CLEANotherwise - Hint:
"consider HPF ~{cutoff_hz} Hz"
- ID:
audio_classifier| Depends on:["silence"] - Config:
window,stereo_mode,rms_anchor,rms_percentile,gate_relative_db,crest_threshold,decay_lookahead_ms,decay_db_threshold,sparse_density_threshold,force_transient,force_sustained - Data:
{"peak_db", "rms_max_db", "rms_anchor_db", "rms_anchor_mean", "crest", "decay_db", "density", "classification", "is_transient", "near_threshold"} - Classification logic: Three-metric system — crest factor (peak-to-RMS ratio), envelope decay rate (energy drop after loudest moment), and density (fraction of active RMS windows). Sparse tracks with at least one agreeing dynamic metric are classified as Transient (catches toms, crashes, FX hits). For non-sparse tracks, crest and decay vote together with decay as tiebreaker: high crest + slow decay → Sustained (plucked/piano); low crest + fast decay → Transient (compressed drums).
- Severity: Always
INFO
- ID:
tail_exceedance| Depends on:["silence", "audio_classifier"] - Config:
window,stereo_mode,rms_anchor,rms_percentile,gate_relative_db,tail_min_exceed_db,tail_max_regions,tail_hop_ms - Data:
{"tail_regions": list[dict], "tail_summary": {"regions", "total_duration_sec", "max_exceed_db", "anchor_db"}} - Issues: Per-region
IssueLocation(all channels) for each exceedance region - Severity:
ATTENTIONif regions found,CLEANotherwise
- Type: SessionDetector | ID:
format_consistency - Data (per mismatch):
{"filename", "expected_sr", "expected_bd", "actual_sr", "actual_bd", "mismatch_reasons"} - Stores
_most_common_srand_most_common_bdonsession.config. - Severity:
PROBLEMper mismatched file
- Type: SessionDetector | ID:
length_consistency - Data (per mismatch):
{"filename", "expected_samples", "expected_duration_fmt", "actual_samples", "actual_duration_fmt"} - Stores
_most_common_lenand_most_common_len_fmtonsession.config. - Severity:
PROBLEMper mismatched file
def default_detectors() -> list[TrackDetector | SessionDetector]:
return [
SilenceDetector(), ClippingDetector(), DCOffsetDetector(),
StereoCompatDetector(), DualMonoDetector(),
OneSidedSilenceDetector(), SubsonicDetector(), AudioClassifierDetector(),
TailExceedanceDetector(), FormatConsistencyDetector(), LengthConsistencyDetector(),
]Defined in sessionpreplib/processor.py.
PRIORITY_CLEANUP = 0
PRIORITY_NORMALIZE = 100
PRIORITY_POST = 200
PRIORITY_FINALIZE = 900
class AudioProcessor(ABC):
id: str
name: str
shorthand: str # compact abbreviation for UI labels, e.g. "BN"
priority: int
def config_params(cls) -> list[ParamSpec]: ... # base: {id}_enabled + {id}_default toggles
def configure(self, config: dict[str, Any]) -> None: ...
@property
def enabled(self) -> bool: ...
@property
def default(self) -> bool: ... # whether pre-selected when a folder opens
@abstractmethod
def process(self, track: TrackContext) -> ProcessorResult: ...
@abstractmethod
def apply(self, track: TrackContext, result: ProcessorResult) -> np.ndarray: ...The base config_params() returns two ParamSpec entries:
{id}_enabled(bool, defaultTrue) — whether the processor is available at all. Disabled processors are excluded from the pipeline entirely.{id}_default(bool, defaultTrue,presentation_only=True) — whether the processor is pre-selected for each track when a folder is opened. WhenFalse, the processor's ID is added totrack.processor_skipautomatically at the end ofplan(), but users can still enable it per-track in the Processing column.
Subclasses extend via super().config_params() + [...]. The base configure()
reads self._enabled and self._default from the config. The Pipeline
configures all processors first, then filters to only enabled ones before
sorting by priority.
process()— pure analysis, decides gain/classification/method. Runs in both dry-run and execute mode. GUI can call this to preview without modifying audio.apply()— performs the audio transformation. Only called in execute mode.
- ID:
bimodal_normalize| Shorthand:BN| Priority:PRIORITY_NORMALIZE(100) - Reads:
silence.data["is_silent"],audio_classifier.data["peak_db", "rms_anchor_db", "classification", "is_transient"] - Config:
target_rms,target_peak - Logic:
- Silent -> gain 0, classification "Silent"
- Transient -> gain = target_peak - peak
- Sustained -> gain = min(target_rms - rms, target_peak - peak)
- ID:
mono_downmix| Shorthand:MD| Priority:PRIORITY_POST(200) - Status: Stub —
apply()returns audio unchanged. A real implementation would sum/average channels and return a mono array. - Logic:
- Mono files → classification "Mono", method "pass-through (already mono)"
- Multi-channel files → classification "Stereo", method "downmix Nch → 1ch (stub)"
- Gain is always 0.0 dB (no-op)
Implemented as a post-processing step in Pipeline._apply_group_levels().
After all processors run, grouped tracks receive the minimum gain of the group.
Implemented in Pipeline._compute_fader_offsets(). Calculates inverse of gain,
applies headroom rebalancing and optional anchor adjustment. Stored in
ProcessorResult.data["fader_offset"].
Headroom rebalancing: Ensures no fader exceeds ceiling - headroom.
The ceiling is set per-DAW processor via _fader_ceiling_db (e.g. +6 dB for
DAWproject, +12 dB for Pro Tools). The headroom margin is configured via
fader_headroom_db (default 8 dB). If the highest fader offset exceeds
ceiling - headroom, all fader offsets are shifted down uniformly by the
excess amount. The shift is stored in ProcessorResult.data["fader_rebalance_shift"]
and logged via dbg() when SP_LOG_LEVEL=DEBUG is active.
def default_processors() -> list[AudioProcessor]:
return [BimodalNormalizeProcessor(), MonoDownmixProcessor()]Defined in sessionpreplib/daw_processor.py. DAW processors handle integration
with external DAWs (Digital Audio Workstations). Each concrete subclass handles
one DAW — e.g. ProToolsProcessor (PTSL), DAWProjectProcessor (.dawproject
files).
Key design decisions:
- One processor per DAW — not split per-function. A single
ProToolsProcessorhandles faders, routing, colors, etc. internally. - One active at a time — selected via GUI toolbar dropdown.
- Outside the Pipeline — GUI/CLI orchestrates DAW operations directly.
The Pipeline stays
analyze → plan → executefor audio processing only. - Command model (Option B) —
DawCommandis a plain dataclass (just data). The concreteDawProcessorowns all execution logic — it builds commands, dispatches them internally, and returnsDawCommandResultobjects.
class DawProcessor(ABC):
id: str
name: str
def config_params(cls) -> list[ParamSpec]: ... # base: enabled toggle
def configure(self, config) -> None: ...
@property
def enabled(self) -> bool: ...
# Lifecycle
def check_connectivity(self) -> tuple[bool, str]: ...
def fetch(self, session) -> SessionContext: ...
def transfer(self, session) -> list[DawCommandResult]: ...
def sync(self, session) -> list[DawCommandResult]: ...
# Ad-hoc commands (GUI tools)
def execute_commands(self, session, commands) -> list[DawCommandResult]: ...Called by the GUI/CLI, not by the Pipeline:
configure(config)— readParamSpecvalues (includingenabled)check_connectivity()— verify the DAW is reachable. Returns(ok, message). Socket-based for Pro Tools PTSL, path validation for file-based processors.fetch(session)— pull external state intosession.daw_state[self.id](routing folders, track list, colors, etc.)transfer(session)— initial full push to the DAW. Internally buildsDawCommandobjects, executes each via processor-private dispatch, appends results tosession.daw_command_log, and snapshots the transferred state for futuresync()diffs.sync(session)— incremental delta push. Compares current session state against the snapshot stored bytransfer()and sends only the changes.
execute_commands(session, commands) accepts externally-built DawCommand
objects — e.g. from a GUI color picker or rename tool — and routes them through
the same internal dispatch as transfer()/sync(). Results are appended to
session.daw_command_log.
Commands are plain data; the processor is the executor.
transfer(session):
commands = self._build_commands(session) # list[DawCommand]
results = []
for cmd in commands:
result = self._execute_command(cmd) # processor-internal dispatch
results.append(result)
session.daw_command_log.append(result)
return results
_execute_command(cmd):
match cmd.command_type:
case "set_clip_gain": ... # e.g. PTSL call
case "set_fader": ...
case "set_color": ...
→ DawCommandResult(cmd, success, error, timestamp)
_build_commands and _execute_command are processor-private — the ABC does
not define them. Each concrete processor implements its own dispatch.
Each DawCommand carries an undo_params field (e.g. {"previous_value": -3.2})
capturing the state needed to reverse the operation. Undo = processor replays the
command log in reverse using undo_params. Implementation deferred; data model
ready from day one.
Base class provides an {id}_enabled ParamSpec (bool, default True).
Subclasses extend via super().config_params() + [...]. The daw_processors
section in the structured config follows the same pattern as detectors and
processors:
{
"daw_processors": {
"protools": { "protools_enabled": true, ... },
"dawproject": { "dawproject_enabled": true, ... }
}
}def default_daw_processors() -> list[DawProcessor]:
return [ProToolsDawProcessor()]
# DawProjectDawProcessor instances are created dynamically
# via create_instances() based on configured templates.Concrete DawProcessor for Avid Pro Tools, communicating via the
Pro Tools Scripting SDK (PTSL) gRPC interface
through the py-ptsl Python client.
- ID:
protools - Config:
protools_enabled(bool, defaultTrue),protools_command_delay(float, default1.0s — delay between Pro Tools commands to allow the DAW to settle)
Lifecycle implementation:
| Method | Behaviour |
|---|---|
check_connectivity() |
Opens a ptsl.Engine, calls ptsl.open(), returns success/failure + PTSL protocol version. On failure, the GUI shows a QMessageBox.warning dialog with the error and keeps the toolbar functional. |
fetch(session) |
Retrieves the folder track hierarchy and stores it in session.daw_state["protools"]["folders"]. Populates the GUI folder tree for drag-and-drop track assignment. |
transfer(session) |
Wrapped in a PTSL batch job for modal progress + user-lock. Phases: (1) batch import all audio files in one call, (2) per-track create + spot clip (parallel, 6 workers), (3) batch colorize by group, (4) set fader offsets (when bimodal normalization is enabled and processed files are used). Accepts a progress_callback(step, total, message) for GUI progress. Results appended to session.daw_command_log. |
sync(session) |
Not yet implemented (raises NotImplementedError). |
Batch import optimisation: All audio files are imported to the Pro Tools
clip list in a single CId_ImportData call (instead of one per track),
significantly reducing round-trip overhead on large sessions.
Automatic fader adjustment: When bimodal normalization is enabled and
processed files are used (_use_processed), fader offsets from the processor
results are applied to newly created tracks via CId_SetTrackVolume.
Color matching:
The transfer() method assigns colors to newly imported tracks based on their
group. The matching pipeline is:
- Fetch the Pro Tools color palette via
CId_GetColorPalette. - For each session group, resolve the group's configured color name to an
ARGB hex string (from
session.config["gui"]). - Convert both the group ARGB and each palette entry to CIE L*a*b* color space (via linearised sRGB → XYZ D65 → L*a*b*).
- Find the palette index with the smallest Euclidean distance in L*a*b* (perceptual matching).
- Apply
CId_SetTrackColorwith the matched palette index.
Tracks with no group assignment skip colorization.
File-based DawProcessor that generates .dawproject interchange files from
a template. Unlike Pro Tools, this processor does not communicate with a
running DAW — it reads/writes .dawproject ZIP archives directly.
- ID:
dawproject(base);dawproject_0,dawproject_1, … (per-template instances) - Config:
dawproject_templates— list of template dicts, each withname,template_path, and optionalfader_ceiling_db(default 6.0 dB)
Template-based instantiation: The session Config tab includes a Mix
Templates widget where users configure one or more .dawproject template
files. Each template produces a separate DawProjectDawProcessor instance
(via create_instances()), which appears as its own entry in the DAW
processor dropdown (e.g. "DAWproject – MyTemplate").
Lifecycle implementation:
| Method | Behaviour |
|---|---|
check_connectivity() |
Validates template file exists and is a valid ZIP; returns (True, "Template OK") or failure message. |
fetch(session) |
Loads the template's folder track hierarchy into session.daw_state. Populates the GUI folder tree for drag-and-drop assignment. |
transfer(session) |
Loads template project, creates new audio tracks with clips in the arrangement, sets fader volumes and group colors, writes the result to <output_folder>/<template_name>.dawproject. When bimodal normalization is enabled and files are not processed, expression gain (clip gain) is written as automation Points nested inside a Lanes within the Clip (sibling of the Audio element). |
Gain application logic:
fader_db→ mapped to track channel volume (linear)clip_gain_db→ expression gain automation (PointswithExpressionType.GAIN)- Both are only set when bimodal normalization is enabled, the track is not
silent/skipped, and the processor is not in
track.processor_skip clip_gain_dbis set only when the track's actual audio file is the original (not a processed/baked file)- When
clip_gain_db != 0,Clip.contentis set to aLanes([Audio, Points])instead ofAudiodirectly, matching the structure Bitwig writes for per-clip gain automation
Config refresh: Both _do_daw_transfer and _on_prepare in the GUI
refresh session.config from the live session config widgets before starting
the worker, ensuring that processor enabled/disabled changes made after
analysis are reflected.
Defined in sessionpreplib/pipeline.py. Four implemented phases:
analyze() -> Run all detectors (track-level + session-level)
plan() -> Run audio processors + group equalization + fader offsets
+ populate processor_skip for default=False processors
prepare() -> Apply processors per track, write processed files to output dir
execute() -> Apply gains, backup originals, write processed files (CLI legacy)
| Mode | Phases executed |
|---|---|
| Dry-run (default) | analyze → plan |
| GUI with Prepare | analyze → plan → prepare |
| Execute (CLI legacy) | analyze → plan → execute |
class Pipeline:
def __init__(self, detectors, audio_processors, config, event_bus,
max_workers=None): ...
def analyze(self, session: SessionContext) -> SessionContext: ...
def plan(self, session: SessionContext) -> SessionContext: ...
def prepare(self, session, output_dir, progress_cb=None) -> SessionContext: ...
def execute(self, session, output_dir, backup_dir, is_overwriting) -> SessionContext: ...prepare() generates processed audio files into a dedicated output folder,
enabling the GUI to offer processed files for DAW transfer without overwriting
originals.
Behaviour:
- Remove stale audio files from
output_dirrecursively (viaos.walk), preserving non-audio artefacts (e.g..dawprojectfiles written by DAW transfer). Empty subdirectories are pruned afterwards. - For each OK track, determine applicable processors: all enabled processors
whose ID is not in
track.processor_skip. - Filter to processors with a valid (non-error)
ProcessorResult. - Deep-copy
audio_data, chainapply()calls in priority order. - Write the processed audio to
output_dir/filename(preserving original subtype viawrite_track()). - Update
track.processed_filepathandtrack.applied_processors. - Set
session.prepare_state = "ready".
Tracks with no applicable processors are skipped (no file written,
processed_filepath set to None).
Staleness: The GUI transitions prepare_state from "ready" to "stale"
whenever gain, classification, RMS anchor, per-track processor selection, or
re-analysis changes occur. The Prepare button and Use Processed toggle reflect
the current state.
When the SP_LOG_LEVEL environment variable is set to DEBUG, the pipeline
emits per-component timing via dbg() (from sessionprepgui/log.py).
Output goes to stderr with timestamps and caller class names.
Instrumented phases:
- analyze: per-detector per-track timing, per-track total, phase total with per-track average
- plan: per-processor per-track timing, per-track total, phase total with per-track average, group levelling time, fader offset time (including rebalance shift if applied)
- transfer (DAWproject): per-track gain decision trace (
bn_enabled,actually_processed,has_pr,classification,skip,gain_db,fader_offset→ finalfader_db,clip_gain_db)
Example output:
[22:29:05.304 Pipeline] detector audio_classifier on 01_KickTrigger.wav: 577.3 ms
[22:29:05.304 Pipeline] all detectors on 01_KickTrigger.wav: 1203.4 ms
[22:29:05.304 Pipeline] analyze phase (track detectors): 27 tracks in 8170.2 ms (302.6 ms/track avg)
All three parallelizable stages use concurrent.futures.ThreadPoolExecutor:
- File loading (
load_session): WAV files are read from disk in parallel. I/O-bound — threading gives significant speedup. - Track analysis (
analyze): Per-track detector chains run in parallel across files. Within a single track, detectors still run sequentially (topological order fromdepends_on). Session-level detectors run after all tracks complete (barrier). - Track planning (
plan): Per-track processor chains run in parallel. Group equalization and fader offsets run after all tracks complete.
Why threads, not processes: Detector/processor compute is dominated by numpy FFT/RMS operations which release the GIL, giving real parallelism without the serialization overhead of multiprocessing (audio data arrays are large).
Thread safety:
EventBusis protected bythreading.Lock(safe to emit from pool threads).- Detector/processor instances are shared across threads but are read-only
after
configure()—analyze()/process()methods only readselfand write to the per-trackTrackContext(unique per thread). - Progress counter in
AnalyzeWorkerusesthreading.Lockfor atomic increment + emit.
Worker count: max_workers defaults to min(os.cpu_count(), 8), capped
at the number of tracks.
plan() internally calls:
_apply_group_levels()— grouped tracks get minimum gain_compute_fader_offsets()— inverse gain + anchor adjustment
def load_session(source_dir, config, event_bus=None, recursive=False) -> SessionContextLoads all audio files from source_dir in parallel using
discover_audio_files(), assigns groups (named, first-match-wins),
appends overlap warnings to session.warnings. When recursive=True,
subdirectories are scanned and TrackContext.filename stores
forward-slash–separated relative paths (e.g. "drums/01_Kick.wav").
_topo_sort_detectors() uses Kahn's algorithm. Raises ConfigError on
cycles or missing dependencies.
Defined in sessionpreplib/queue.py. Manages multiple SessionJob instances,
processes them sequentially in priority order.
class SessionQueue:
def add(self, source_dir, config, priority, label) -> SessionJob
def remove(self, job_id) -> bool
def reorder(self, job_id, new_priority) -> None
def cancel(self, job_id) -> None
def pending() -> list[SessionJob]
def completed() -> list[SessionJob]
def all_jobs() -> list[SessionJob]
def run_next(self, pipeline_factory, event_bus) -> SessionJob | None
def run_all(self, pipeline_factory, event_bus, on_complete) -> list[SessionJob]run_next() creates a fresh pipeline per job via pipeline_factory(config),
loads the session, runs analyze -> plan -> (optionally execute), stores
SessionResult. Emits job.start and job.complete events.
Defined in sessionpreplib/config.py.
built-in defaults -> preset file -> CLI args / GUI overrides
default_config() -> dict— all built-in defaultsmerge_configs(*configs) -> dict— left-to-right merge; list keys (force_transient,force_sustained,group) are concatenatedvalidate_config(config) -> None— raisesConfigErroron invalid valuesload_preset(path) -> dict— loads a JSON preset, stripsschema_versionand_descriptionmetadatasave_preset(config, path, *, description=None) -> None— saves non-default, non-internal keys withschema_version
Internal/CLI-only keys (execute, overwrite, output_folder, backup,
report, json) are excluded from presets automatically.
JSON with schema_version. Only non-default values are saved.
Example metal_session.json:
{
"schema_version": "1.0",
"_description": "Metal session preset",
"target_rms": -16.0,
"target_peak": -3.0,
"crest_threshold": 15.0,
"force_transient": ["kick", "snare", "tom"]
}{
"target_rms": -18.0, "target_peak": -6.0,
"crest_threshold": 12.0, "clip_consecutive": 3,
"clip_report_max_ranges": 10, "dc_offset_warn_db": -40.0,
"corr_warn": -0.3, "dual_mono_eps": 1e-5,
"mono_loss_warn_db": 6.0, "one_sided_silence_db": -80.0,
"subsonic_hz": 30.0, "subsonic_warn_ratio_db": -20.0,
"window": 400, "stereo_mode": "avg",
"rms_anchor": "percentile", "rms_percentile": 95.0,
"gate_relative_db": 40.0, "tail_max_regions": 20,
"tail_min_exceed_db": 3.0, "tail_hop_ms": 10,
"force_transient": [], "force_sustained": [],
"group": [],
"anchor": None, "normalize_faders": False,
"execute": False, "overwrite": False,
"output_folder": "processed", "backup": "_originals",
"report": "sessionprep.txt", "json": "sessionprep.json",
}Defined in sessionpreplib/events.py. Lightweight publish/subscribe bus.
class EventBus:
def subscribe(self, event_type, handler) -> None
def unsubscribe(self, event_type, handler) -> None
def emit(self, event_type, **data) -> None| Event | Emitted by | Data |
|---|---|---|
track.load |
load_session |
filename, index, total |
track.analyze_start |
Pipeline | filename, index, total |
track.analyze_complete |
Pipeline | filename, index, total |
detector.start |
Pipeline | detector_id, filename |
detector.complete |
Pipeline | detector_id, filename, severity |
session_detector.start |
Pipeline | detector_id |
session_detector.complete |
Pipeline | detector_id |
track.plan_start |
Pipeline | filename, index, total |
processor.start |
Pipeline | processor_id, filename |
processor.complete |
Pipeline | processor_id, filename |
track.plan_complete |
Pipeline | filename, index, total |
track.write_start |
Pipeline | filename, index, total |
track.write_complete |
Pipeline | filename, index, total |
prepare.start |
Pipeline | filename |
prepare.complete |
Pipeline | filename |
prepare.error |
Pipeline | filename, error |
job.start |
Queue | job_id |
job.complete |
Queue | job_id, status |
No EventBus = no overhead. All emissions are guarded with if self.event_bus.
Defined in sessionpreplib/rendering.py. Two standalone functions (not wrapped
in an ABC — see TODO.md for future Renderer abstraction).
Aggregates detector results into the four-category summary structure:
{
"problems": [{"title", "hint", "items", "standalone"}, ...],
"attention": [...],
"information": [...],
"clean": [...],
"clean_count": int,
"total_ok": int,
"overview": {...},
}Handles: file errors, format/length mismatches, clipping, DC offset, stereo compatibility (correlation + mono folddown combined), dual-mono, silence, one-sided silence, subsonic, tail exceedance, grouping overlaps, clean summaries, and overview statistics.
Renders the summary dict as plain text with emoji category headers. Used for
both console output (via sessionprep.py) and the sessionprep.txt report.
Rich-based rendering (progress bars, tables, panels) lives in sessionprep.py,
not the library. rich is a CLI-only dependency.
All serialized outputs include a version field:
{
"schema_version": "1.0",
...
}Currently applies to:
sessionprep.json(the analysis/automation export)- Preset files (via
save_preset())
- Partial results are always better than a crash.
- Errors are isolated per-track, per-detector, and per-processor.
- Configuration errors abort early (before any work starts).
| Failure | Policy |
|---|---|
| One detector throws on one track | Store DetectorResult with Severity.PROBLEM, error field set; continue |
| One track file cannot be read | Mark TrackContext.status = "Error: ...", skip all detectors/processors; continue |
| An audio processor throws on one track | Store ProcessorResult with error field set; skip audio write; continue |
| Config validation fails at startup | Abort with descriptive error |
| Cyclic detector dependency | Abort at pipeline construction |
Every analyze(), process(), and apply() call is wrapped in try/except
by the pipeline. Components do not need to handle unexpected exceptions.
Called at pipeline construction:
- All
depends_onreferences point to existing detector IDs - No circular dependencies among detectors
- No duplicate IDs across detectors and processors
Validation is ParamSpec-driven: each detector and processor declares
its own parameters via config_params() -> list[ParamSpec]. Shared
analysis parameters live in ANALYSIS_PARAMS, session-level parameters
in SESSION_PARAMS (both in config.py). See Section 4.4
for the ParamSpec dataclass.
Validation entry points:
| Function | Input | Output | Use case |
|---|---|---|---|
validate_param_values(params, values) |
list[ParamSpec], flat dict |
list[ConfigFieldError] |
Validate any subset of params |
validate_config_fields(config) |
flat dict | list[ConfigFieldError] |
Validate all known params (auto-collects from components) |
validate_structured_config(structured) |
structured dict | list[ConfigFieldError] |
Validate the GUI config file section by section |
validate_config(config) |
flat dict | raises ConfigError |
Backward-compatible wrapper (CLI) |
Checks performed per field (in order):
- Null check — rejects
Noneunlessnullableis set - Type check — validates Python type (with
bool ⊄ intenforcement) - Enum check — validates value against
choiceslist - Range check — validates numeric bounds (inclusive/exclusive)
- List item check — validates element types in list fields
Errors from validate_structured_config prefix the key with the section
path (e.g. detectors.clipping.clip_consecutive) for UI disambiguation.
A thin shell (~770 lines) that imports everything from sessionpreplib:
- argparse — defines all CLI arguments (same flags as original script)
- Config conversion —
vars(args)merged withdefault_config() - Pipeline construction —
default_detectors()+default_processors() - Rich rendering — progress bars, panels, diagnostic summary, fader table
- Report/JSON writing —
generate_report()andsave_json()for file outputs - Backup handling — backs up originals when overwriting
The CLI contains zero analysis, detection, processing, or DSP logic.
The GUI is a PySide6 application split across the sessionprepgui/ package.
sessionprep-gui.py is a thin entry point that delegates to the package.
uv run python sessionprep-gui.pyRequires PySide6 and sounddevice (installed via the gui optional dependency
group).
| Module | Responsibility |
|---|---|
__init__.py |
Exports main() |
settings.py |
load_config(), save_config(), config_path() — persistent GUI preferences |
theme.py |
COLORS dict, FILE_COLOR_* constants, dark palette + stylesheet |
helpers.py |
esc(), track_analysis_label(track, detectors=None) (filters via is_relevant()), fmt_time(), severity maps |
widgets.py |
BatchEditTableWidget, BatchComboBox — reusable batch-edit base classes preserving multi-row selection across cell-widget clicks (zero app imports) |
log.py |
dbg(msg) — lightweight debug logging to stderr, gated by SP_LOG_LEVEL=DEBUG. Timestamped output with caller class name. Used by pipeline.py, dawproject.py, and other modules via conditional import. |
analysis/mixin.py |
AnalysisMixin — open/save/load session, analyze, prepare, session Config tab wiring |
analysis/worker.py |
QThread workers: AnalyzeWorker (pipeline in background, thread-safe progress, per-track signals), BatchReanalyzeWorker (subset re-analysis after batch overrides), PrepareWorker (runs Pipeline.prepare() in background with progress), DawCheckWorker (connectivity check), DawFetchWorker (folder fetch), DawTransferWorker (transfer with progress + progress_value signals) |
daw/mixin.py |
DawMixin — DAW processor selection, check/fetch/transfer/sync, folder tree, drag-and-drop track assignment, Track Name inline editing, duplication with -[N] naming |
topology/mixin.py |
TopologyMixin — Phase 1 Channel Topology UI, "Scan subfolders" checkbox (recursive discovery), Apply worker, collapsible waveform preview |
topology/input_tree.py |
InputTree — QTreeWidget for source tracks, drag-out of channels via custom MIME |
topology/output_tree.py |
OutputTree — QTreeWidget for output tracks, drag-in from input tree, internal channel reorder with insert-position line, cross-file channel moves |
topology/operations.py |
Topology mutation functions: add/remove/reorder/move/wire channels, rename/remove outputs |
detail/mixin.py |
DetailMixin — file detail view, waveform display, detector overlay panel, playback toolbar wiring |
detail/playback.py |
PlaybackController — sounddevice OutputStream lifecycle, QTimer cursor updates, signal-based API |
detail/report.py |
HTML rendering: render_summary_html(), render_fader_table_html(), render_track_detail_html() |
session/io.py |
Session save/load — serialises full analysis state (detector + processor results, user edits, recursive_scan flag) to .spsession JSON without re-running analysis. Versioned format (v4) with forward-compatible migrations. |
tracks/columns_mixin.py |
TrackColumnsMixin — track table column definitions, cell rendering, sorting |
tracks/groups_mixin.py |
GroupsMixin — group assignment UI, color rendering in track table |
tracks/table_widgets.py |
Track table widget classes (custom cell widgets, batch-edit base classes) |
waveform/__init__.py |
Re-exports WaveformWidget, WaveformLoadWorker, SPECTROGRAM_COLORMAPS |
waveform/compute.py |
Colormaps (magma/viridis/grayscale LUTs), mel math, spectrogram computation, WaveformLoadWorker QThread, PeakBuildWorker (batch peak cache builder using thread pool) |
waveform/peakcache.py |
Pre-computed peak mipmap cache: PeakData, MipLevel, build_peaks(), save_peaks(), load_peaks(), query_peaks_fast(). Binary .peaks file format (SPK1) with staleness detection via source file mtime |
waveform/renderer.py |
WaveformRenderer — vectorised NumPy peak/RMS downsampling, waveform drawing, RMS L/R and AVG envelopes, dB scale, peak/RMS markers. Uses mip-level peak queries when PeakData is available for near-instant rendering |
waveform/spectrogram.py |
SpectrogramRenderer — mel spectrogram QImage (256 mel bins via scipy.signal.stft), frequency scale, freq zoom/pan, background recompute worker |
waveform/overlay.py |
Stateless overlay drawing functions — detector issue overlays (with optional frequency bounds), horizontal time scale |
waveform/panel.py |
WaveformPanel — composite widget: waveform toolbar + WaveformWidget + transport bar. Reused in Phase 1 (topology preview) and Phase 2 (file detail) |
waveform/widget.py |
WaveformWidget — thin orchestrator coordinating WaveformRenderer and SpectrogramRenderer; paintEvent, mouse/keyboard event handlers, zoom/pan API, public setters |
prefs/param_form.py |
Portable generic widget factory — ParamSpec protocol, PathPickerMode, PathPicker, _build_widget, _build_param_page, _set_widget_value, _read_widget, tooltip/subtext builders, sanitize_output_folder. Zero sessionpreplib dependency; copy to any PySide6 project. |
prefs/preset_panel.py |
Portable NamedPresetPanel — reusable CRUD widget for named presets with add/duplicate/rename/delete signals. |
prefs/config_pages.py |
SessionPrep-specific builders: GroupsTableWidget, DawProjectTemplatesWidget, build_config_pages, load_config_widgets, read_config_widgets. |
prefs/page_general.py |
GeneralPage — app-level settings (_APP_PARAMS list drives _build_param_page; widget_hint="path_picker_folder" on default_project_dir auto-generates a PathPicker). |
prefs/page_colors.py |
ColorsPage — editable color palette table with load/commit/color_provider interface. |
prefs/page_groups.py |
GroupsPage — named group presets using NamedPresetPanel + GroupsTableWidget with load/commit interface. |
prefs/dialog.py |
PreferencesDialog — thin ~270-line orchestrator wiring all pages, managing config presets via NamedPresetPanel, and handling save with validation. |
prefs/param_widgets.py |
Backward-compatible re-export shim — all public names forward to param_form.py / config_pages.py. |
mainwindow.py |
SessionPrepWindow (QMainWindow) — orchestrator, UI layout, slot handlers, toolbar config/group preset combos, session Config tab |
settings (leaf) <-- mainwindow
theme (leaf) <-- helpers <-- detail/report <-- mainwindow
widgets (leaf) <-- tracks/ <---------------------------+
|
waveform/ <----------------------------+
detail/ <----------------------------+
analysis/ <----------------------------+
daw/ <----------------------------+
session/ <----------------------------+
prefs/ <----------------------------+
No circular imports. settings, theme, helpers, and widgets are pure
leaves. prefs/ reads ParamSpec metadata from detectors and processors.
mainwindow composes all subpackage mixins.
- BatchEditTableWidget (
widgets.py) is a genericQTableWidgetsubclass that preserves multi-row selection when a persistent-editor cell widget (e.g. aQComboBox) receives focus. The core mechanism is aselectionCommand()override that returnsNoUpdatewhen Qt's internalcheckPersistentEditorFocus()would otherwiseClearAndSelect. This replicates the behaviour found in Pro Tools, where Shift-selecting multiple tracks and Alt-clicking a control (send, insert, etc.) applies the change to all selected tracks. The modifier key combination is Alt+Shift.BatchComboBoxdetects this modifier onmousePressEventand sets abatch_modeflag that the changed-slot inspects. Combo signals should usetextActivated(notcurrentTextChanged) so that re-selecting the same value still triggers the batch path.batch_selected_keys(key_column)andrestore_selection(keys, key_column)accept a configurable identifier column (default 0) so the pattern works regardless of table layout.BatchReanalyzeWorkerruns the re-analysis asynchronously with progress signals; its custom signal is namedbatch_finishedto avoid collision withQThread.finished. App-level integration uses a single generic method_batch_apply_combo(combo, column, value, prepare_fn, run_detectors)that any dropdown batch-change can delegate to. - PlaybackController encapsulates all
sounddevicestate with a signal-based API (cursor_updated,playback_finished,error).mainwindow.pyhas zero directsdusage. - WaveformWidget supports two display modes: waveform (default) and
spectrogram. Issue overlays from
IssueLocationobjects are rendered in both modes. Per-channel regions are drawn in the corresponding channel lane; whole-file issues span all channels. Issues withfreq_min_hz/freq_max_hzrender as frequency-bounded rectangles in spectrogram mode (mapped via mel scale); without frequency bounds the overlay spans the full frequency range. Tooltips use a 5-pixel hit tolerance for narrow markers. Overlays and tooltips are filtered by_enabled_overlays— only detectors checked in the Detector Overlays dropdown are painted/hoverable. Additional features:- Waveform toolbar — layout:
[▾ Waveform/Spectrogram] [▾ Display] [▾ Detector Overlays] [Peak / RMS Max] [RMS L/R] [RMS AVG] ... [Fit] [+] [−] [↑] [↓]The Display Mode dropdown (leftmost) uses aQActionGroupfor mutual exclusivity between waveform and spectrogram. The Display dropdown contains spectrogram-specific settings (FFT Size, Window, Color Theme, dB Floor with presets −120..−20, dB Ceiling with presets −30..0) and is only visible in spectrogram mode. Waveform-only controls (Peak/RMS Max, RMS L/R, RMS AVG) are hidden in spectrogram mode. The Detector Overlays dropdown is aQToolButtonwith aQMenuof checkable actions, one per detector that produced issues for the current track (visible in both modes). All unchecked by default. Detectors suppressed byis_relevant()are excluded. The button label shows a count when items are checked (e.g. "Detector Overlays (2)"). Menu is rebuilt on track selection and on classification override changes. - Spectrogram mode — mel spectrogram (256 mel bins) computed via
scipy.signal.stftin a background thread (WaveformLoadWorker). Configurable FFT size, window type, dB floor/ceiling, and colormap. Colormap registry (SPECTROGRAM_COLORMAPS): magma, viridis, grayscale (256-entry RGBA LUTs). Frequency axis with mel-spaced ticks at 50, 100, 200, 500, 1k, 2k, 5k, 10k, 20k Hz. CachedQImagekeyed by view params for efficient repainting. - Horizontal time scale — time axis at the bottom of the waveform area.
- dB measurement scale (waveform mode) — left/right margins (30 px)
with dBFS tick labels (0, −3, −6, −12, −18, −24, −36, −48, −60), tick
marks, and faint connector lines spanning the waveform area. Adaptive
spacing (min 18 px between ticks, lane-edge padding to prevent
cross-channel overlap). Scale adjusts dynamically with vertical resize
and
_vscale. - Peak marker ("P") — dark violet solid vertical line at the sample with the highest absolute amplitude across all channels. A small horizontal crosshair is drawn at the peak amplitude on the owning channel only. Hovering shows "Peak: X.X dBFS" tooltip. Controlled by the "Peak / RMS Max" toggle (default: on).
- Max RMS marker ("R") — dark teal-blue solid vertical line at the centre of the loudest momentary RMS window (combined across channels). Horizontal crosshair on the positive side of each channel lane. Hovering shows "Max RMS: X.X dBFS" tooltip. Controlled by the same "Peak / RMS Max" toggle.
- RMS L/R overlay — per-channel RMS envelope curve (yellow), toggled via the "RMS L/R" toolbar button.
- RMS AVG overlay — combined (average) RMS envelope curve (orange), toggled via the "RMS AVG" toolbar button.
- Mouse guide — a thin grey dashed crosshair follows the mouse cursor. In waveform mode, the corresponding dBFS value is shown at the scale margins. In spectrogram mode, the corresponding frequency (Hz) is shown. The guide disappears when the mouse leaves the widget.
- Mouse navigation — Ctrl+wheel for horizontal zoom, Ctrl+Shift+wheel for vertical zoom (amplitude in waveform, frequency range in spectrogram), Shift+Alt+wheel for frequency panning (spectrogram only), Shift+wheel for horizontal scroll.
- Waveform toolbar — layout:
detail/report.pycontains pure HTML-building functions (no widget references), making them independently testable.prefs/subpackage implements a layered, portable preferences framework:- Portable layer (
param_form.py,preset_panel.py) has zero sessionpreplib dependency and can be copied to any PySide6 project. - App-specific layer (
config_pages.py,page_*.py,dialog.py) contains SessionPrep-specific builders, page classes, and the orchestrator dialog. - Widget factory (
_build_widget) resolves widgets in priority order: (1)widget_hintoverride →PathPickeror future custom widgets, (2)choices→QComboBox, (3)type→ spinbox/checkbox/line-edit. Adding a new custom widget type requires only a new branch in_build_widgetand awidget_hint=on the relevantParamSpec. PathPickeris a self-contained folder/file picker widget (label +QLineEdit+ Browse + reset button + optional recursive checkbox). Activated bywidget_hint="path_picker_folder"/"path_picker_file"/"path_picker_save".- Page interface — every page class (
GeneralPage,ColorsPage,GroupsPage) implementsload(config),commit(config), andvalidate() → str | None. All read/write goes through_set_widget_value/_read_widget, which dispatch uniformly across all widget types includingPathPicker. PreferencesDialogis a ~270-line thin orchestrator. Two-tab layout:- Global tab — General, Colors, Groups pages in a tree + stacked widget.
- Config Presets tab —
NamedPresetPanelCRUD + Analysis, Detectors, Processors, DAW Processors pages.
- Each parameter gets a visible description subtext, a rich tooltip (key
name, default, range), and a reset-to-default button (
SP_BrowserReload).
- Portable layer (
- Toolbar config preset chooser — the analysis toolbar includes a "Config:" combo that shows available config presets. Switching presets with an active session warns the user, then resets session config and triggers re-analysis while preserving group assignments.
- Session Config tab — a per-session config override editor (tree +
stacked widget) that mirrors the Config Presets layout. Initialised from
the active global config preset on first analysis. Edits take effect
immediately via
_flat_config()without needing to save. A "Reset to Preset Defaults" button restores the global preset values. - HiDPI scaling is applied via
QT_SCALE_FACTORenvironment variable, read directly from the JSON config file beforeQApplicationis created (bypassing the validate-and-merge path to avoid side effects). - File-based processing pipeline — an opt-in, non-destructive workflow
for generating processed audio files. The pipeline is controlled by three
UI elements:
- Prepare button (analysis toolbar, right-aligned) — triggers
PrepareWorker→Pipeline.prepare(). Text reflects staleness state: "Prepare" (never run), "Prepare ✓" (ready), "Prepare (!)" (stale). Enabled after analysis completes. - Processing column (analysis table, column 7) — per-track multiselect
QToolButtonwith a checkableQMenulisting all enabledAudioProcessorinstances. Label shows "Default" (current selection matches each processor's configureddefaultvalue), "None" (all skipped), or comma-separated shorthands (e.g.BN, MD) for a non-default partial selection. Tooltip always uses full names. When no processors are enabled globally, the button is disabled and shows "None". Toggling a processor adds/removes its ID fromtrack.processor_skipand marks the Prepare state as stale. Editable only in the analysis phase. - Use Processed toggle (setup toolbar) — checkable
QActionthat setssession.config["_use_processed"]. Label shows "Use Processed: On/Off" with "(!) " appended whenprepare_state == "stale". Enabled only whenprepare_stateis"ready"or"stale". When on,ProToolsDawProcessor.transfer()usestrack.processed_filepathinstead oftrack.filepathfor each track that has a processed file. - Staleness triggers — changing gain, classification, RMS anchor,
per-track processor selection, or re-analyzing transitions
prepare_statefrom"ready"to"stale", updating both the Prepare button and Use Processed toggle labels. - Output directory — resolved from
config["app"]["output_folder"](default:"processed"), relative to the session source directory.
- Prepare button (analysis toolbar, right-aligned) — triggers
- Recursive folder scanning — the Phase 1 toolbar includes a "Scan
subfolders" checkbox (
QCheckBox) that enables recursive audio file discovery viadiscover_audio_files(). When enabled:- Subdirectories are walked via
os.walk(followlinks=False). - Configured output folder names (
phase1_output_folder,phase2_output_folder) are skipped during scanning. TrackContext.filenamestores forward-slash–separated relative paths (e.g."drums/01_Kick.wav") instead of bare filenames.- Output directories mirror the subfolder structure (
os.makedirsbefore writing). - Stale file cleanup walks the output tree recursively and prunes empty subdirectories afterwards.
daw_track_nameinTransferEntryalways uses the basename stem (not the full relative path).- The setting is persisted in
config["app"]["recursive_scan"]and saved/restored in.spsessionfiles. AnalyzeWorkerandload_session()both accept arecursiveparameter so Phase 2 analysis also discovers files in subfolders.
- Subdirectories are walked via
- DAW integration — the Phase 3 (DAW Transfer) tab provides a toolbar with
Connect/Check, Fetch, Auto-Assign, Transfer, and Sync actions. A combo box
selects the active DAW processor. The left side shows a setup table with
columns: checkmark (assigned?), File, Track Name (inline-editable),
Ch, Clip Gain, Fader Gain, Group. The Track Name column displays
TransferEntry.daw_track_nameand is editable via double-click; changes are committed to the model and reflected in the folder tree. The right side shows a folder tree (from_FolderDropTree) that supports drag-and-drop assignment of tracks to folders. Assigned tracks display both thedaw_track_nameandoutput_filename(as"track_name ← filename") when they differ. The "Duplicate for DAW" context menu action creates new manifest entries sharing the same output file with-[N]suffixed track names (first duplication renames the original to-[1], new entry gets-[2], subsequent duplications increment). Transfer runs asynchronously viaDawTransferWorker, with a progress panel (label +QProgressBar) below the tree that auto-hides 2 seconds after completion. - Waveform worker cancellation —
WaveformLoadWorkerandSpectrogramRecomputeWorkercarry athreading.Eventcancellation flag. When the user switches tracks, the old worker is cancelled (flag set) before a new one starts. Therun()method checks the flag between every expensive phase (channel split, peak finding, per-channel RMS cumsum, spectrogram) and exits early if set, preventing CPU pileup from stacked background threads. - Auto-Group — the analysis toolbar includes an "Auto-Group" button
(right-aligned, next to Prepare). When clicked, it reassigns all OK tracks
to groups based on the active group preset's keyword matching rules (using
matches_keywords()fromutils.py). Tracks are matched against each group's patterns in order; unmatched tracks are unassigned. A confirmation dialog shows the track count before proceeding. After assignment, the analysis table, setup table, and group combos are refreshed. The fader table in the report is also rebuilt to reflect the new groupings. - DAW connection error handling — when
check_connectivity()fails (e.g. Pro Tools not running), the GUI shows aQMessageBox.warningdialog with the error message, constrains the status label width to prevent toolbar overflow, and keeps all toolbar buttons functional for retry. - Config refresh before transfer/prepare — both
_do_daw_transferand_on_preparecallself._session.config.update(self._flat_config())before starting the worker, ensuring that processor enabled/disabled changes made in the session Config tab after analysis take effect in the Pipeline (which re-configures and re-filters processors fromsession.configin its constructor). - The GUI contains zero analysis, detection, processing, or DSP logic —
all analysis runs through
sessionpreplibviaAnalyzeWorker.
The GUI stores all settings in a JSON config file in the OS-specific user preferences directory:
| OS | Path |
|---|---|
| Windows | %APPDATA%\sessionprep\sessionprep.config.json |
| macOS | ~/Library/Application Support/sessionprep/sessionprep.config.json |
| Linux | $XDG_CONFIG_HOME/sessionprep/sessionprep.config.json (defaults to ~/.config/) |
Four-section format — separates global settings from named presets:
{
"app": {
"scale_factor": 1.0,
"report_verbosity": "normal",
"output_folder": "",
"phase1_output_folder": "sp_01_tracklayout",
"phase2_output_folder": "sp_02_prepared",
"recursive_scan": false,
"spectrogram_colormap": "magma",
"invert_scroll": "default",
"default_project_dir": "",
"active_config_preset": "Default",
"active_group_preset": "Default"
},
"colors": [
{ "name": "Guardsman Red", "argb": "#ffcc0000" },
{ "name": "Dodger Blue Light", "argb": "#ff3399ff" },
"..."
],
"config_presets": {
"Default": {
"analysis": { "window": 400, "stereo_mode": "avg", "..." : "..." },
"detectors": {
"clipping": { "clip_consecutive": 3 },
"dc_offset": { "dc_offset_warn_db": -40.0 },
"...": "..."
},
"processors": {
"bimodal_normalize": { "target_rms": -18.0, "target_peak": -6.0 }
},
"daw_processors": {
"protools": { "protools_enabled": true },
"...": "..."
},
"presentation": {
"show_clean_detectors": false
}
}
},
"group_presets": {
"Default": [
{ "name": "Kick", "color": "Guardsman Red", "gain_linked": true, "daw_target": "Kick" },
{ "name": "Snare", "color": "Dodger Blue Light", "gain_linked": true, "daw_target": "Snare" },
"..."
]
}
}app— global application settings (HiDPI scale, colormap, scroll direction, default project directory, active preset names, Phase 1/2 output folder names, recursive scan toggle); not consumed by the analysis pipelinecolors— global color palette (name + ARGB hex pairs), referenced by group presetsconfig_presets— named config presets, each containing five sub-sections:analysis,detectors,processors,daw_processors,presentation. The active preset is identified byapp.active_config_preset. Managed via the Preferences dialog (Config Presets tab) and the toolbar "Config:" combo.group_presets— named group presets (lists of track group dicts with name, color, gain-linked, daw-target). Managed via the Preferences dialog (Global → Groups) and the toolbar "Group:" combo.
Session-specific values (force_transient, force_sustained, group,
anchor) are not stored in the config file — they are per-session
and will be saved in session project files in the future.
Config presets vs. session config:
Config presets define the global defaults for analysis parameters, detector
thresholds, processor targets, and DAW processor settings. When analysis
starts, the active config preset is snapshot into the session's Config tab
(_session_config). The user can then tweak per-session overrides without
affecting the global preset. _flat_config() reads from the session config
widgets when available, falling back to the global preset otherwise.
Lifecycle:
- On first launch,
build_defaults()creates the file with all built-in defaults from every component'sconfig_params(). A "Default" config preset and "Default" group preset are created. - On subsequent launches, the file is deep-merged with current defaults
(forward-compatible for new keys/detectors). Legacy flat-format configs
are migrated to the four-section structure by
_migrate_legacy_config(). - If the file is corrupt, it is backed up as
*.bakand reset to defaults. resolve_config_preset(config, name)retrieves a named preset, merged with defaults.flatten_structured_config()converts a preset into a flat key-value dict for the pipeline andAnalyzeWorker.- The Preferences dialog provides a two-tab UI for editing all settings. On save, changes are written to the config file. If analysis-relevant parameters changed and a session is loaded, re-analysis is triggered automatically; presentation-only changes trigger a lightweight refresh.
The CLI is not affected by this file — it continues to use its own
default_config() + command-line arguments.
Waveform rendering uses a pre-computed peak mipmap cache (similar to
Cubase/Reaper .peaks files) to avoid expensive raw-sample downsampling on
every paint. The cache is built eagerly in the background and persisted to disk
as binary .peaks files in the sp_peaks/ directory.
Architecture:
graph LR
A[Folder Open] --> B["_on_phase1_done()"]
B --> C["PeakBuildWorker (bg thread pool)"]
C --> D["sp_peaks/*.peaks files"]
C --> E["In-memory _peak_cache dict"]
E --> F["WaveformRenderer.set_peak_data()"]
F --> G["query_peaks_fast() → instant paint"]
File format (SPK1):
| Field | Size | Description |
|---|---|---|
| Magic | 4 bytes | SPK1 |
| Version | 2 bytes | Format version (currently 1) |
| Channels | 2 bytes | Number of audio channels |
| Samplerate | 4 bytes | Source file sample rate |
| Total samples | 8 bytes | Source file total sample count |
| Source mtime | 8 bytes | Source file modification time (epoch) |
| Num levels | 2 bytes | Number of mip levels |
| (per level) | ||
| Samples/bin | 4 bytes | Resolution of this mip level |
| Num bins | 4 bytes | Number of bins in this level |
| Data | varies | float32[n_bins × channels × 2] (min/max) |
Four mip levels at 256, 1024, 4096, and 16384 samples/bin. A 5-minute stereo 48 kHz file produces ~1.2 MB of peak data total.
Integration points:
settings.py—peak_cache_folderdefault ("sp_peaks"), added toskip_foldersso the cache directory is not scanned as audio content.analysis/mixin.py—_start_peak_build()triggered from_on_phase1_done()and_on_analyze_done(). Maintains_peak_cachedict (filename →PeakData). Worker cleanup in_clear_workspace().topology/mixin.py— injectsPeakDatainto the Phase 1 waveform display viaset_peak_data()afterset_precomputed(). Passespeaks_dirtoTopologyApplyWorkerso output files get cached during Apply.detail/mixin.py— injectsPeakDatainto the Phase 2 waveform display afterset_precomputed().analysis/worker.py—TopologyApplyWorkerbuilds and saves.peaksfiles immediately after writing each output file.waveform/renderer.py—_build_peaks()usesquery_peaks_fast()whenPeakDatais available, falling back to raw-sample downsampling otherwise.
Staleness: Each .peaks file stores the source file's mtime. On load,
load_peaks() compares the stored mtime against the current source file; stale
caches return None and are silently rebuilt.
Instant Preview Mode (set_preview_mode):
To eliminate visual latency caused by heavy audio file disk I/O, the UI supports
an Instant Preview Mode. If a track is selected and its .peaks cache exists,
the GUI initializes the WaveformWidget with dummy empty channels and applies the
peak cache immediately. This renders the perfect waveform visualization instantaneously
(zero disk I/O, zero CPU). Asynchronously, AudioLoadWorker and WaveformLoadWorker
load the full audio data in the background. When they complete, they seamlessly replace
the dummy channels to enable the Mel Spectrogram, RMS envelope, and audio playback.
If a peak cache finishes building while the UI is blocked loading audio, the UI
aggressively pushes the new peak cache to instantly resolve the loading state.
Original (sessionprep.py) |
Library location |
|---|---|
parse_arguments() |
sessionprep.py (CLI only) |
analyze_audio() |
audio.py (DSP) + individual detectors |
check_clipping() / detect_clipping_ranges() |
audio.py + detectors/clipping.py |
subsonic_ratio_db() |
audio.py (subsonic_stft_analysis) + detectors/subsonic.py |
calculate_gain() |
processors/bimodal_normalize.py |
matches_keywords() |
utils.py |
parse_group_specs() / assign_groups() |
utils.py |
build_session_overview() |
Session-level detectors + rendering.py |
build_diagnostic_summary() |
rendering.py |
render_diagnostic_summary_text() |
rendering.py |
print_diagnostic_summary() |
sessionprep.py (Rich) |
generate_report() / save_json() |
sessionprep.py |
process_files() |
pipeline.py + sessionprep.py |
protools_sort_key() |
utils.py |
db_to_linear() / linear_to_db() / format_duration() |
audio.py |
| Package | Used by |
|---|---|
numpy |
sessionpreplib (core dependency) |
soundfile |
sessionpreplib/audio.py (audio I/O) |
scipy |
sessionpreplib/audio.py (subsonic STFT), sessionprepgui/waveform/compute.py (mel spectrogram) — core dependency |
rich |
sessionprep.py only — not a library dependency |
PySide6 |
sessionprepgui only — optional GUI dependency |
sounddevice |
sessionprepgui/detail/playback.py only — optional GUI dependency |
SessionQueue manages N jobs, priority-ordered
+-- SessionJob one session dir + config + status
+-- Pipeline configured per-job via factory
|-- analyze() -> TrackDetectors (topo-sorted)
| -> SessionDetectors
|-- plan() -> AudioProcessors (priority-sorted)
| -> Group levelling + fader offsets
|-- prepare() -> Apply per-track processors, write to output dir
| -> Respects processor_skip, sets prepare_state
+-- execute() -> AudioProcessors.apply() + file write (CLI legacy)
DawProcessor (orchestrated by GUI/CLI, outside Pipeline)
|-- check_connectivity() -> verify DAW is reachable
|-- fetch(session) -> pull DAW state into session.daw_state
|-- transfer(session) -> full push (builds + executes DawCommands)
| -> uses processed files when _use_processed is on
|-- sync(session) -> incremental delta push
+-- execute_commands(session, cmds) -> ad-hoc commands from GUI tools