Skip to content

Commit cfdbdb0

Browse files
author
Threepwood-7
committed
Refactor'd the MP3 gain house, and set forth engine, model, and UI anew
1 parent 1e99778 commit cfdbdb0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+4067
-3
lines changed

AGENTS.md

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
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.

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,15 @@ dependencies = [
2323

2424
"PySide6>=6.10.2",
2525

26+
"mutagen>=1.47",
27+
28+
"miniaudio>=1.60",
29+
2630
]
2731

32+
[project.optional-dependencies]
33+
fast = ["numpy>=1.26"]
34+
2835
[project.urls]
2936
Homepage = "https://github.com/Threepwood-7/mp3gain-gui-py"
3037
Repository = "https://github.com/Threepwood-7/mp3gain-gui-py"

0 commit comments

Comments
 (0)