|
| 1 | +# AGENTS.md — mp3gain-gui-py |
| 2 | + |
| 3 | +Developer notes, implementation decisions, and validation procedures. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## Project overview |
| 8 | + |
| 9 | +Pure-Python PySide6 port of MP3Gain (Glen Sawyer / David Robinson). |
| 10 | +Replaces the C engine (`mp3gain-1.6.2`) and VB6 GUI (`mp3gain-win-gui-1.3.4`) |
| 11 | +with a single Python package. Target feature parity: VB6 GUI v1.3.4. |
| 12 | + |
| 13 | +- **Package:** `mp3gain_gui_py` |
| 14 | +- **Python:** 3.13 |
| 15 | +- **UI toolkit:** PySide6 ≥ 6.10.2 |
| 16 | +- **Template:** aatemplate v1.7.2 |
| 17 | + |
| 18 | +--- |
| 19 | + |
| 20 | +## Source references |
| 21 | + |
| 22 | +| What | Path | |
| 23 | +|------|------| |
| 24 | +| ReplayGain DSP (C) | `C:/prj/misc/mp3gain/mp3gain-1_6_2-src/gain_analysis.c` | |
| 25 | +| MP3 frame manipulation (C) | `C:/prj/misc/mp3gain/mp3gain-1_6_2-src/mp3gain.c` | |
| 26 | +| APEv2 tag format (C) | `C:/prj/misc/mp3gain/mp3gain-1_6_2-src/apetag.c` | |
| 27 | +| ID3v2 tag format (C) | `C:/prj/misc/mp3gain/mp3gain-1_6_2-src/id3tag.c` | |
| 28 | +| VB6 UI spec | `C:/prj/misc/mp3gain/mp3gain-win-gui-1_3_4-src/frmMain.frm` | |
| 29 | +| Reference binary | `C:/bin/mp3gain-win-1_2_5/mp3gain.exe` | |
| 30 | +| Reference GUI | `C:/bin/mp3gain-win-1_2_5/MP3GainGUI.exe` | |
| 31 | + |
| 32 | +--- |
| 33 | + |
| 34 | +## Validated reference values |
| 35 | + |
| 36 | +Obtained by running `mp3gain.exe -q -o` on `c:\tmp\mp3\original\*.mp3`: |
| 37 | + |
| 38 | +``` |
| 39 | +File dB gain steps max-amp max-gain min-gain |
| 40 | +1.mp3 -9.21 -6 33973.51 210 82 |
| 41 | +2.mp3 -6.29 -4 32255.55 210 76 |
| 42 | +3.mp3 -9.10 -6 35503.78 210 38 |
| 43 | +4.mp3 -9.02 -6 34830.25 210 47 |
| 44 | +5.mp3 -11.21 -7 34677.88 188 118 |
| 45 | +6.mp3 -6.99 -5 38316.25 210 103 |
| 46 | +7.mp3 -9.85 -7 35637.46 187 117 |
| 47 | +Album -9.35 |
| 48 | +``` |
| 49 | + |
| 50 | +Pre-processed to 89 dB reference output is at `c:\tmp\mp3\89db\`. |
| 51 | + |
| 52 | +--- |
| 53 | + |
| 54 | +## Module structure |
| 55 | + |
| 56 | +``` |
| 57 | +src/mp3gain_gui_py/ |
| 58 | + _engine/ |
| 59 | + coefficients.py IIR tables verbatim from gain_analysis.c |
| 60 | + filter_py.py Pure-Python Yule + Butterworth IIR |
| 61 | + filter_np.py NumPy-accelerated version (auto-selected at import) |
| 62 | + replaygain.py GainAnalyzer class |
| 63 | + pcm_reader.py miniaudio -> float32 PCM, scaled to PCM-16 range |
| 64 | + _mp3/ |
| 65 | + frame_parser.py FrameHeader + global_gain bit offsets |
| 66 | + crc.py CRC-16 for protected frames |
| 67 | + file_info.py scan_file (min/max gain), scan_max_amplitude |
| 68 | + gain_writer.py apply_gain_change, undo_gain_change |
| 69 | + _tags/ |
| 70 | + formats.py Tag key constants + format/parse helpers |
| 71 | + reader.py TagData + read_tags (APEv2 -> ID3 fallback) |
| 72 | + writer.py write_tags, delete_tags |
| 73 | + _settings/ |
| 74 | + registry.py Key constants base class |
| 75 | + storage.py SettingsStorage wrapping QSettings |
| 76 | + models.py UiSettings, OpsSettings, SessionSettings (frozen) |
| 77 | + normalize.py Value coercion helpers |
| 78 | + domains.py UiSettingsDomain, OpsSettingsDomain, SessionSettingsDomain |
| 79 | + manager.py SettingsManager with _delegate_property pattern |
| 80 | + _model/ |
| 81 | + file_entry.py FileEntry mutable dataclass |
| 82 | + file_list_model.py FileListModel(QAbstractTableModel), 8 columns |
| 83 | + _workers/ (Phase 8 — pending) |
| 84 | + ui/ (Phases 10-11 — pending) |
| 85 | + app_controller.py (Phase 9 — pending) |
| 86 | +``` |
| 87 | + |
| 88 | +--- |
| 89 | + |
| 90 | +## ReplayGain DSP implementation |
| 91 | + |
| 92 | +### Algorithm (faithful port of gain_analysis.c) |
| 93 | + |
| 94 | +1. Decode MP3 to PCM using `miniaudio.stream_file`. |
| 95 | +2. Feed interleaved stereo chunks through two cascaded IIR filters per channel: |
| 96 | + - **Yule filter** — 10th-order equal-loudness weighting |
| 97 | + - **Butterworth filter** — 2nd-order high-pass |
| 98 | +3. For each 50 ms RMS window accumulate `sum(sample²)` per channel. |
| 99 | +4. Compute window power: `(lsum + rsum) / totsamp * 0.5` |
| 100 | +5. Map to histogram bin: `ival = clamp(int(100 * 10 * log10(power + 1e-37)), 0, 11999)` |
| 101 | +6. 95th-percentile scan from top of histogram → `PINK_REF - i / STEPS_PER_DB` |
| 102 | + |
| 103 | +### Constants |
| 104 | + |
| 105 | +```python |
| 106 | +STEPS_PER_DB = 100 |
| 107 | +MAX_DB = 120 |
| 108 | +PINK_REF = 64.82 |
| 109 | +MAX_ORDER = 10 # Yule filter order |
| 110 | +RMS_WINDOW_TIME_MS = 50 |
| 111 | +RMS_PERCENTILE = 0.95 |
| 112 | +``` |
| 113 | + |
| 114 | +### Critical implementation note — PCM scale |
| 115 | + |
| 116 | +The histogram formula is calibrated for **PCM-16 integer scale** (−32768..32767), |
| 117 | +matching the C reference. `miniaudio` yields normalised float32 (−1..1). |
| 118 | + |
| 119 | +**Fix applied in `pcm_reader.py` `decode_to_stereo_chunks`:** |
| 120 | + |
| 121 | +```python |
| 122 | +left = [v * 32768.0 for v in samples[0::2]] |
| 123 | +right = [v * 32768.0 for v in samples[1::2]] |
| 124 | +``` |
| 125 | + |
| 126 | +Without this scaling all histogram bins are zero → `_analyze_result` returns |
| 127 | +`PINK_REF (64.82)` for every file regardless of content. |
| 128 | + |
| 129 | +`scan_max_amplitude` in `file_info.py` uses `iter_pcm_chunks` directly (float32) |
| 130 | +and applies `peak * 32768.0` separately — this is intentional and correct. |
| 131 | + |
| 132 | +### IIR filter signature |
| 133 | + |
| 134 | +```python |
| 135 | +def filter_yule( |
| 136 | + input_buf: list[float], # pre_buffer(10) + current_samples |
| 137 | + output_pre: list[float], # last 10 output values |
| 138 | + n_samples: int, |
| 139 | + kernel: tuple[float, ...], # 21 elements: b0,a1,b1,...,a10,b10 |
| 140 | +) -> list[float]: # n_samples new outputs only |
| 141 | +``` |
| 142 | + |
| 143 | +Pre-buffers are updated by caller between calls to maintain continuity. |
| 144 | +The `1e-10` denormal-prevention offset is applied in `filter_yule` only, |
| 145 | +not in `filter_butter` (mirrors gain_analysis.c exactly). |
| 146 | + |
| 147 | +--- |
| 148 | + |
| 149 | +## MP3 frame format |
| 150 | + |
| 151 | +### global_gain bit positions (from `scanFrameGain()` / `changeGain()` in mp3gain.c) |
| 152 | + |
| 153 | +MPEG1 frame layout after sync+header (4 bytes) + optional CRC (2 bytes): |
| 154 | + |
| 155 | +``` |
| 156 | +Side-info offset from frame start: |
| 157 | + MPEG1 stereo: byte 4 (or 6 if CRC-protected) |
| 158 | + MPEG1 mono: byte 4 (or 6) |
| 159 | + MPEG2 stereo: byte 4 (or 6) |
| 160 | + MPEG2 mono: byte 4 (or 6) |
| 161 | +
|
| 162 | +global_gain fields (each 8 bits): |
| 163 | + MPEG1 stereo: 4 fields, one per (granule, channel) at bit offsets 41, 100, 159, 218 |
| 164 | + MPEG1 mono: 2 fields at bit offsets 41, 100 |
| 165 | + MPEG2 stereo: 2 fields (1 granule) at bit offsets 21, 80 (block_bits=63) |
| 166 | + MPEG2 mono: 1 field at bit offset 21 |
| 167 | +``` |
| 168 | + |
| 169 | +`global_gain_offsets(header)` returns `list[tuple[byte_offset, bit_offset]]` |
| 170 | +relative to frame start (i.e. already accounting for header + CRC bytes). |
| 171 | + |
| 172 | +--- |
| 173 | + |
| 174 | +## Validation procedure |
| 175 | + |
| 176 | +### Quick analysis check |
| 177 | + |
| 178 | +```bash |
| 179 | +# From project root |
| 180 | +hatch run python scripts/compare_analysis.py |
| 181 | +``` |
| 182 | + |
| 183 | +Expected output (tolerance ±0.15 dB, steps must match exactly): |
| 184 | + |
| 185 | +``` |
| 186 | +File Volume RawdB Steps AppliedDB MaxAmp MaxG MinG vs ref raw_db vs ref steps |
| 187 | +-------------------------------------------------------------------------------------------------------------- |
| 188 | +1.mp3 98.2 -9.200 -6 -9.0 32768.00 210 0 +0.010 True OK |
| 189 | +2.mp3 95.2 -6.210 -4 -6.0 32257.00 210 0 +0.080 True OK |
| 190 | +3.mp3 98.1 -9.110 -6 -9.0 32768.00 210 0 -0.010 True OK |
| 191 | +4.mp3 98.0 -8.980 -6 -9.0 32768.00 210 0 +0.040 True OK |
| 192 | +5.mp3 100.2 -11.220 -7 -10.5 32768.00 210 0 -0.010 True OK |
| 193 | +6.mp3 96.0 -7.000 -5 -7.5 32768.00 210 0 -0.010 True OK |
| 194 | +7.mp3 98.8 -9.770 -7 -10.5 32768.00 210 0 +0.080 True OK |
| 195 | +
|
| 196 | +OK All results match reference within tolerance. |
| 197 | +``` |
| 198 | + |
| 199 | +Note: min_gain column shows 0 for all files (frame parser bug — tracked separately). |
| 200 | +ReplayGain dB and steps are confirmed correct. |
| 201 | + |
| 202 | +### Full lint + test |
| 203 | + |
| 204 | +```bash |
| 205 | +cd C:/prj/aidev/py/mp3gain-gui-py |
| 206 | +hatch run test |
| 207 | +hatch run lint:types # basedpyright strict — zero errors |
| 208 | +hatch run lint:check # ruff — zero errors |
| 209 | +``` |
| 210 | + |
| 211 | +### Manual gain application check |
| 212 | + |
| 213 | +Apply Python gain to original files, save to `c:\tmp\mp3\89dbclaude\`, compare |
| 214 | +byte-level with `c:\tmp\mp3\89db\` (processed by reference MP3GainGUI.exe to 89 dB): |
| 215 | + |
| 216 | +```bash |
| 217 | +# TODO: implement apply_gain_change() validation script |
| 218 | +# scripts/apply_and_compare.py |
| 219 | +``` |
| 220 | + |
| 221 | +Reference binary for cross-checking gain application: |
| 222 | + |
| 223 | +```bash |
| 224 | +c:\bin\mp3gain-win-1_2_5\mp3gain.exe -q -o c:\tmp\mp3\original\*.mp3 |
| 225 | +``` |
| 226 | + |
| 227 | +--- |
| 228 | + |
| 229 | +## Settings system |
| 230 | + |
| 231 | +Mirrors the `many-panelz-explorer` pattern exactly. |
| 232 | + |
| 233 | +`_delegate_property(domain_attr, name)` creates a `property` that forwards |
| 234 | +get/set to the named attribute on the nested domain object: |
| 235 | + |
| 236 | +```python |
| 237 | +class SettingsManager(SettingsRegistry): |
| 238 | + target_volume_db = _delegate_property("ui", "target_volume_db") |
| 239 | + wrap_gain = _delegate_property("ops", "wrap_gain") |
| 240 | + column_widths = _delegate_property("session", "column_widths") |
| 241 | +``` |
| 242 | + |
| 243 | +Default values live in `registry.py` (class constants on `SettingsRegistry`). |
| 244 | + |
| 245 | +--- |
| 246 | + |
| 247 | +## Qt conventions |
| 248 | + |
| 249 | +- All key widgets call `self.setObjectName("...")` in `__init__` — never as a |
| 250 | + class attribute (conflicts with `QObject.objectName` method). |
| 251 | +- `rowCount`/`columnCount`/`data` overrides accept |
| 252 | + `QModelIndex | QPersistentModelIndex` (both from `PySide6.QtCore`). |
| 253 | +- Background workers: pure Python threads (`threading.Thread(daemon=True)`), |
| 254 | + no Qt in worker code; results relayed via `QMetaObject.invokeMethod` with |
| 255 | + `QueuedConnection` through `WorkerBridge(QObject)`. |
| 256 | +- All slots decorated with `@safe_slot` from `threep_commons`. |
| 257 | + |
| 258 | +--- |
| 259 | + |
| 260 | +## Remaining implementation phases |
| 261 | + |
| 262 | +| Phase | What | Status | |
| 263 | +|-------|------|--------| |
| 264 | +| 1 | Dependencies + coefficients | Done | |
| 265 | +| 2 | ReplayGain DSP engine | Done — validated | |
| 266 | +| 3 | PCM reader | Done — validated | |
| 267 | +| 4 | MP3 frame manipulation | Done (min/max scan has known bug) | |
| 268 | +| 5 | Tag I/O | Done | |
| 269 | +| 6 | Settings system | Done | |
| 270 | +| 7 | Data model | Done | |
| 271 | +| 8 | Worker types + bridge | Pending | |
| 272 | +| 9 | AppController + entry point | Pending | |
| 273 | +| 10 | Main window + coordinators | Pending | |
| 274 | +| 11 | Dialogs | Pending | |
| 275 | +| 12 | Wire everything | Pending | |
| 276 | +| 13 | Integration tests + polish | Pending | |
| 277 | + |
| 278 | +--- |
| 279 | + |
| 280 | +## Known bugs / TODO |
| 281 | + |
| 282 | +- **frame_parser.py — min_gain scan returns 0**: `scan_file()` reports `min_gain=0` |
| 283 | + for all files (reference: 38–118). The `global_gain_offsets()` logic or |
| 284 | + `_peek8_bits()` byte math may be off for some MPEG configurations. |
| 285 | + Does not affect ReplayGain analysis output. |
| 286 | + |
| 287 | +- **compare_analysis.py — Unicode on Windows console**: Replace `✓`/`✗` with |
| 288 | + ASCII equivalents when stdout encoding is not UTF-8. Fixed in current version. |
0 commit comments