Skip to content

Navigation Menu

Sign in
Sign up

Loudnorm per transmission - #1142

Open
TheGreatCodeholio wants to merge 2 commits into
TrunkRecorder:master from
TheGreatCodeholio:loudnorm-per-transmission
Open

Loudnorm per transmission #1142
TheGreatCodeholio wants to merge 2 commits into
TrunkRecorder:master from
TheGreatCodeholio:loudnorm-per-transmission

Conversation

@TheGreatCodeholio

@TheGreatCodeholio TheGreatCodeholio commented May 26, 2026

Copy link
×ばつ, the peak no longer clips, and listeners no longer hear "whisper then shout" within a single call. Quiet transmissions get pulled up to the loudness target; loud ones get tamed. The two-pass loudnorm pipeline also cost ×ばつ the CPU because it ran an analysis pass and then a render pass. The new pipeline is a single ffmpeg invocation, equivalent in cost to the old single-pass mode and roughly half the cost of the old default (two-pass). ## What changed ### Audio post-processing pipeline - New per-transmission filter graph: each input gets `cleanup → loudnorm → limiter`, then all per-tx chains feed `concat`. Single ffmpeg invocation via `-filter_complex`. - Removed the two-pass analysis path entirely (`analyze_loudnorm_from_concat`, `LoudnormMeasured`, `build_loudnorm_analysis_filter`, `build_loudnorm_render_filter`, and the `run_process_capture_combined_output` plumbing they used). - Removed `loudnorm_two_pass` config field. It was also a latent bug — it was never copied from the system into call_info, so the user's config never took effect anyway. - Added `final_limiter` config (default `true`). Ceiling derived from `loudnorm_tp + 0.5 dB`, attack 1 ms, release 50 ms. - New voice-tuned defaults: `loudnorm_tp` `-0.1 → -1.5`, `loudnorm_lra` `11.0 → 7.0`. ### `enabled` as master switch - `audio_postprocess.enabled=false` now skips the entire pipeline (cleanup + loudnorm + limiter), not just cleanup. - `loudnorm` and `final_limiter` only take effect when `enabled=true`. - Config parser logs a `WARN` at startup if a user has explicitly set `enabled=false` in their config so the semantic change can't surprise anyone silently. Migration note added to `CONFIGURE.md`. ### Analog GR chain - Deleted the `high_f` / `low_f` FIR blocks, their taps, and their connections from `analog_recorder.{h,cc}`. Chain is now `decim_audio → squelch_two → levels → converter → wav_sink`. - `decoder_sink` already tapped at `decim_audio` (pre-bandpass), so CTCSS/MDC/etc. decoder behavior is unchanged. - Defaults flipped so existing analog systems sound essentially the same out of the box: `audio_postprocess.enabled=true`, `highpass_hz=300`, `lowpass_hz=3000`. ### transmissionArchive - When `audio_postprocess.enabled=true` and `transmissionArchive=true`, per-tx archive files are written into the capture directory **already processed** (cleanup + loudnorm + limiter applied). They're produced in the same ffmpeg invocation as the main render via `asplit` on each per-tx pad, so no extra encode pass. - When `enabled=false`, archived per-tx files are copies of the recorder's raw WAVs (existing behavior — and they're now meaningfully more raw for analog systems). - Render only bothers writing archives when they'll actually be kept (gated on `audio_archive || archive_files_on_failure`), avoiding wasted disk writes. ### Custom `ffmpeg_filter` - Still works the same way — replaces the structured cleanup when `enabled=true`. - Now applies **per transmission** rather than once on the concatenated stream. For chains that contain `loudnorm`, the user's loudnorm runs per-tx (and the built-in loudnorm is skipped, same rule as before). ## Behavior changes / migration Most users will see no behavior change beyond uniform speech loudness. The cases that *do* change: | Scenario | Before | After | |---|---|---| | Default config | GR-FIR 300/3000 bandpass, single per-call loudnorm | ffmpeg biquad 300/3000 bandpass, per-tx loudnorm + limiter (uniform speech) | | `audio_postprocess.enabled=false` set explicitly | Cleanup skipped, loudnorm still ran | Cleanup, loudnorm, **and** limiter all skipped — main file is raw concat. Migration WARN logged at startup. | | `transmissionArchive=true` (with `audio_postprocess.enabled=true`) | Raw recorder WAVs copied to capture dir | Per-tx files in capture dir are fully processed and playable standalone | | Custom `ffmpeg_filter` set | Applied once to concatenated stream | Applied per transmission — for cleanup chains this is correct/better; for chains containing loudnorm, the loudnorm now runs per-tx (improves uniformity) | | `outputRawAudio=true` | `.raw.wav` was bandpass-filtered | `.raw.wav` is true for analog that makes it post-demod / post-deemph discriminator audio for digital post vocoder audio |" data-view-component="true"> Copy Markdown
Contributor

Per-transmission audio post-processing pipeline

Summary

Two related changes that together rework how call audio is filtered and normalized:

  1. Per-transmission loudnorm + final limiter — the old two-pass linear loudnorm applied a single static gain to the entire concatenated call, so a quiet capture and a loud capture in the same call stayed quiet and loud
    relative to each other. Now each transmission is independently cleaned, normalized, and limited before being concatenated, so speech sits at the same target loudness across every transmission in a call.

  2. Analog bandpass moves from GNURadio into ffmpeg post-processing — the hardcoded 300/3000 Hz FIR bandpass that lived inside analog_recorder is now audio_postprocess.highpass_hz / lowpass_hz (defaulting to those
    same values). This makes the bandpass user-tunable, drops two realtime FIR filters from every analog recorder, and means the original transmission WAVs are now real post-demod / post-deemph discriminator audio — so
    outputRawAudio and transmissionArchive finally deliver what they advertise.

Along with that, audio_postprocess.enabled becomes the master switch for the whole pipeline. audio_postprocess.enabled=false skips cleanup, loudnorm, and the limiter; the call's main file becomes a stream-copy concat of the raw
transmission WAVs (the same shape outputRawAudio produces as a side file).

Why this matters

Measured on a real multi-transmission call (3 transmissions, ~20 s):

Before After
Integrated loudness -17.1 LUFS -15.8 LUFS
Loudness range (LRA) 25.0 LU 3.3 LU
True peak +0.3 dBFS (clipping) -0.5 dBFS (safe)
Spread between quietest & loudest tx ~28 dB ~5 dB

LRA dropped ×ばつ, the peak no longer clips, and listeners no longer hear "whisper then shout" within a single call. Quiet transmissions get pulled up to the loudness target; loud ones get tamed.

The two-pass loudnorm pipeline also cost ×ばつ the CPU because it ran an analysis pass and then a render pass. The new pipeline is a single ffmpeg invocation, equivalent in cost to the old single-pass mode and roughly half the
cost of the old default (two-pass).

What changed

Audio post-processing pipeline

  • New per-transmission filter graph: each input gets cleanup → loudnorm → limiter, then all per-tx chains feed concat. Single ffmpeg invocation via -filter_complex.
  • Removed the two-pass analysis path entirely (analyze_loudnorm_from_concat, LoudnormMeasured, build_loudnorm_analysis_filter, build_loudnorm_render_filter, and the run_process_capture_combined_output plumbing they
    used).
  • Removed loudnorm_two_pass config field. It was also a latent bug — it was never copied from the system into call_info, so the user's config never took effect anyway.
  • Added final_limiter config (default true). Ceiling derived from loudnorm_tp + 0.5 dB, attack 1 ms, release 50 ms.
  • New voice-tuned defaults: loudnorm_tp -0.1 → -1.5, loudnorm_lra 11.0 → 7.0.

enabled as master switch

  • audio_postprocess.enabled=false now skips the entire pipeline (cleanup + loudnorm + limiter), not just cleanup.
  • loudnorm and final_limiter only take effect when enabled=true.
  • Config parser logs a WARN at startup if a user has explicitly set enabled=false in their config so the semantic change can't surprise anyone silently. Migration note added to CONFIGURE.md.

Analog GR chain

  • Deleted the high_f / low_f FIR blocks, their taps, and their connections from analog_recorder.{h,cc}. Chain is now decim_audio → squelch_two → levels → converter → wav_sink.
  • decoder_sink already tapped at decim_audio (pre-bandpass), so CTCSS/MDC/etc. decoder behavior is unchanged.
  • Defaults flipped so existing analog systems sound essentially the same out of the box: audio_postprocess.enabled=true, highpass_hz=300, lowpass_hz=3000.

transmissionArchive

  • When audio_postprocess.enabled=true and transmissionArchive=true, per-tx archive files are written into the capture directory already processed (cleanup + loudnorm + limiter applied). They're produced in the same ffmpeg invocation as
    the main render via asplit on each per-tx pad, so no extra encode pass.
  • When enabled=false, archived per-tx files are copies of the recorder's raw WAVs (existing behavior — and they're now meaningfully more raw for analog systems).
  • Render only bothers writing archives when they'll actually be kept (gated on audio_archive || archive_files_on_failure), avoiding wasted disk writes.

Custom ffmpeg_filter

  • Still works the same way — replaces the structured cleanup when enabled=true.
  • Now applies per transmission rather than once on the concatenated stream. For chains that contain loudnorm, the user's loudnorm runs per-tx (and the built-in loudnorm is skipped, same rule as before).

Behavior changes / migration

Most users will see no behavior change beyond uniform speech loudness. The cases that do change:

Scenario Before After
Default config GR-FIR 300/3000 bandpass, single per-call loudnorm ffmpeg biquad 300/3000 bandpass, per-tx loudnorm + limiter (uniform speech)
audio_postprocess.enabled=false set explicitly Cleanup skipped, loudnorm still ran Cleanup, loudnorm, and limiter all skipped — main file is raw concat. Migration WARN logged at startup.
transmissionArchive=true (with audio_postprocess.enabled=true) Raw recorder WAVs copied to capture dir Per-tx files in capture dir are fully processed and playable standalone
Custom ffmpeg_filter set Applied once to concatenated stream Applied per transmission — for cleanup chains this is correct/better; for chains containing loudnorm, the loudnorm now runs per-tx (improves uniformity)
outputRawAudio=true .raw.wav was bandpass-filtered .raw.wav is true for analog that makes it post-demod / post-deemph discriminator audio for digital post vocoder audio

...alls
The old two-pass linear loudnorm computed one gain offset for the entire concatenated call and applied it as a single static gain. Inter-transmission loudness variation was preserved by design — a quiet capture and a loud capture in the same call stayed quiet and loud relative to each other.
New pipeline runs a single ffmpeg invocation with -filter_complex each transmission gets its own cleanup+loudnorm chain, all chains feed the concat filter, then a brick-wall alimiter as the final stage. Each transmission lands at the same target loudness regardless of capture level.
CPU drops ~40-50% vs. the old two-pass default (no separate analysis pass), roughly equivalent to the old single-pass mode.
Changes:
 - Remove analyze_loudnorm_from_concat + LoudnormMeasured + analysis-pass
 helpers (~150 lines of now-dead code).
 - Remove loudnorm_two_pass config field. The new architecture has no
 two-pass mode. The field was also a latent bug — it was never copied
 from the system into call_info, so user config never took effect.
 - New voice-tuned defaults: loudnorm_tp -0.1 -> -1.5, loudnorm_lra 11 -> 7.
 - Add final_limiter config (default true). Ceiling = loudnorm_tp + 0.5 dB,
 attack 1 ms, release 50 ms.
 - Custom ffmpeg_filter now runs per-transmission instead of per-call.
...er switch for all audio processing
The analog recorder's hardcoded 300/3000 Hz FIR bandpass moves out of the GNURadio chain into audio_postprocess as configurable highpass_hz/lowpass_hz defaults. This makes the bandpass user-tunable per system, drops two realtime FIR filters from every analog recorder, and means the original transmission WAV files now contain real post-demod/post-deemph discriminator audio. outputRawAudio and transmissionArchive in raw mode actually deliver what they advertise.
To match the existing analog sound out of the box, audio_postprocess defaults shift: enabled true (was false), highpass_hz 300 (was 0), lowpass_hz 3000 (was 0). The biquad rolloff differs slightly from the GR-chain FIR but the difference should be unnoticeable.
audio_postprocess.enabled is also redefined as the master switch for the entire post-processing pipeline. enabled=false now skips cleanup, loudnorm, and the final limiter — the call's main file becomes a stream-copy concat of the raw transmission WAVs. Loudnorm and final_limiter only take effect
 when enabled=true. Config parser logs a WARNING when the user explicitly sets enabled=false so the semantic change can't surprise anyone silently.
 Updated CONFIGURE.md including a migration note

wx-jc commented Sep 5, 2026

Copy link
Copy Markdown

On my local P25 Trunked system, some fire pages are all done in one transmission...the tones at the beginning, followed by the dispatch audio without unkeying. So, this fix would not help in that case right?

The attenuation of the dispatch voice after the tones is a significant issue...

bburkett52 reacted with thumbs up emoji

Copy link
Copy Markdown

On my local P25 Trunked system, some fire pages are all done in one transmission...the tones at the beginning, followed by the dispatch audio without unkeying. So, this fix would not help in that case right?

The attenuation of the dispatch voice after the tones is a significant issue...

I have the same issue and have tried many "solitions" over several years to no avail
~Bob

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

AltStyle によって変換されたページ (->オリジナル) /