Skip to content

Loudnorm per transmission#1142

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

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

Conversation

@TheGreatCodeholio

Copy link
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 7.6×, 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 ~2× 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
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.

1 participant