Skip to content

Compensate metadata.loudness when _ScaleOutputHook scales head_scale#668

Merged
sdatkinson merged 1 commit into
sdatkinson:mainfrom
tone-3000:fix/export-loudness-reflects-compensated-head-scale
May 22, 2026
Merged

Compensate metadata.loudness when _ScaleOutputHook scales head_scale#668
sdatkinson merged 1 commit into
sdatkinson:mainfrom
tone-3000:fix/export-loudness-reflects-compensated-head-scale

Conversation

@woodybury

Copy link
Copy Markdown
Contributor

Summary

When dataset output RMS normalization is used (e.g. `nam.data.normalize_joint_dataset_output` in the data config), `Dataset._ScaleOutputHook` scales the exported `config.head_scale` (and the duplicated `weights[-1]`) so the `.nam` produces the original capture level at inference. Until now `metadata.loudness` was not adjusted, so its value described the pre-compensation in-memory model instead of the file a plugin actually loads. This PR fixes that.

Root cause

`metadata.loudness` is set inside `Exportable._get_export_dict()` before `_apply_export_model_dict_post_hooks` runs. The `_ScaleOutputHook` then mutates `config.head_scale` on the way out, but the loudness number is never refreshed.

Fix

Co-locate the loudness adjustment with the head_scale mutation inside the hook. Output of WaveNet (no top-level head) and SlimmableContainer is linear in `head_scale`, so the dB adjustment is exact and closed-form:

```
loudness_new = loudness_old + 20 * log10(self._scale)
```

`metadata.gain` is a normalized compression heuristic that is invariant under uniform output scaling and is left alone.

Tests

  • `tests/test_nam/test_data.py::TestScaleOutputHookLoudnessCompensation` — focused unit tests against the hook:
    • WaveNet loudness shifts by `20 * log10(scale)`; gain unchanged.
    • SlimmableContainer shifts container loudness and both submodels by the same dB amount.
    • No-op when metadata is absent.
  • `tests/test_nam/test_models/test_packed_wavenet.py::test_packed_export_refreshes_loudness_after_head_scale_compensation` — end-to-end with a real `PackedWaveNet` and the real hook; verifies the exported container and each submodel metadata match `pre + 20 * log10(scale)`.

Test plan

  • `py tests/test_nam/test_data.py::TestScaleOutputHookLoudnessCompensation`
  • `pytest tests/test_nam/test_models/test_packed_wavenet.py`
  • Full `tests/test_nam/test_data.py tests/test_nam/test_models/ tests/test_nam/test_train/test_full_packed.py` (272 passed)

Compatibility

Hooks that don't attach (no normalization used) produce identical output. No change to the dict hook contract or to model state.

_ScaleOutputHook undoes a dataset y_scale on export by scaling
config.head_scale and the duplicated weights[-1]. Until now the
exported metadata.loudness still described the pre-compensation model,
so the loudness in the .nam file disagreed with what a plugin actually
loads when output RMS normalization was used during training.
WaveNet (no top-level head) and SlimmableContainer outputs are linear
in head_scale, so the dB adjustment is exact and closed-form:
    loudness_new = loudness_old + 20 * log10(self._scale)
metadata.gain is a normalized compression heuristic that is invariant
under uniform output scaling and is left alone.
The adjustment lives next to the head_scale mutation that creates the
need for it, so the hook's full effect is visible in one place. No
changes to the dict hook contract or to model state. Hooks that don't
attach (no normalization used) produce identical output.

@sdatkinson sdatkinson left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple nits. I can pick them up on a follow-up PR.

Thanks for getting this!

)
# head_scale was actually compensated on disk
assert entry["model"]["config"]["head_scale"] == _pytest.approx(
0.25 * scale

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where's the 0.25 come from? Line 26 I think?

Chan you make this a const in this file _DEFAULT_HEAD_SCALE or pass it as an argument to packed_config so that it's not a literal?

scale = 0.5
container = {
"architecture": "SlimmableContainer",
"metadata": {"loudness": -18.0, "gain": 0.4},

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eek, these aren't supposed to be the averages of the values in the submodels 😅

It's just a test, but I think I'd prefer for these to track the values of the highest-quality submodel. If that's not happening already, then that's a bug that should also be squashed.

Ideally, there'd be validation (i.e. Pydantic) to enforce this.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not the end of the world if it's already happening elsewhere though--I have to admit I'm not sure I know the answer off the top of my head.

@sdatkinson sdatkinson merged commit 331b31a into sdatkinson:main May 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants