DMR voice quality (AMBE+2)

Conventional DMR now decodes end to end, but the decoded voice — female voices especially — can sound rough. This page records what the decode chain does, a mbelib reconciliation review (what was ruled out and the one genuine divergence found), the operator levers you can pull today, and the calibration-gated path to a verified fix.

The short version: GopherTrunk’s default DMR decoder (internal/voice/ambe2) is a clean-room Go re-implementation of mbelib’s ambe3600x2450 path. mbelib is a reverse-engineered, approximate AMBE+2 decoder — it is known to sound worse than DVSI silicon, and worst on high-pitched (female) voices. So a large part of what you hear is inherent to the algorithm being mirrored, not a discrete bug. That said, one genuine divergence from the reference was found (below) — the prime suspect for the female-voice-specific roughness — and the corrected, spec-faithful form now ships enabled by default behind the tri-state recordings.spec_amplitude_enhance config key (unset = on; set false to get the legacy envelope back). The calibration harness below remains the gate for its final quality sign-off.

Why female voices are the hard case

A female voice has a higher fundamental ω₀, hence fewer harmonics L (internal/voice/ambe2/tables2450.go: L ranges 9…56, and the low-L rows are the high-pitch ones). Everything in the model that is indexed by harmonic position l relative to L therefore behaves differently for small L — most of the band sits in the “high band” (8l > L) where the spectral-amplitude enhancement acts, and the voiced/unvoiced and phase-dispersion decisions cover proportionally more of the voice. So a mis-tuned or mis-transcribed high-band step hits female voices hardest.

Reconciliation review (mbelib ambe3600x2450 + TIA-102.BABA §6.2)

Ruled OUT — these faithfully match the reference

  • Pitch / L / w₀ tables (tables2450.go) — generated from mbelib’s ambe3600x2450_const.h; the b0→(w₀, L) lookup matches.
  • Voiced/unvoiced decode (params2450.go:60-72). The jl = int(l·16·f0) index and the dmrVuv[b1][jl] lookup match mbelib. The clamp to [0,7] is a defined-behaviour guard; across the real f0 range jl spans the full 0…7 of the table (e.g. at the highest f0≈0.05, jl = 0.8·l reaches 7 only at l≈9), so there is no pathological V/UV collapse for female frames.
  • §6.3 voiced-phase de-buzz (decoder.go:synthFrame, ~627-664). mbelib does PHIl = PSIl for l ≤ L/4 and PHIl = PSIl + numUv·rand_phase()/L above it, with rand_phase() ∈ [-π, π]. GT draws (-1..1)·π · (numUv/L) for l > L/4 voiced harmonics — the same distribution and scaling. Faithful.

Found — the one genuine divergence (§6.2 spectral-amplitude enhancement), now fixed

internal/voice/mbe/enhance.go EnhanceAmplitudes is on the default decode path (for both AMBE+2/DMR and IMBE/P25). Its legacy per-harmonic weight ξ dropped a factor that is present in both references the code cites (TIA-102.BABA §6.2 and mbelib’s mbe_spectralAmpEnhance):

mbelib / spec:   ξ = 0.96·π·num / ( ω0 · R_M0 · (R_M0² − R_M1²) )
legacy GT:       ξ = 0.96·num   / (       R_M0 · (R_M0² − R_M1²) )
                                   └─ missing the  π / ω0  factor ─┘
        num = R_M0² + R_M1² − 2·R_M0·R_M1·cos(ω0·l),   W_l = ξ^0.25, clamped [0.5, 1.2]

For DMR, ω₀ ∈ [~0.05, ~0.31] rad, so π/ω0 ∈ [~10, ~63]. The legacy ξ is therefore 10–63× smaller than the spec’s; after the ^0.25, the high-band weights are ~1.8–2.8× lower, so they land on the 0.5 (attenuate) clamp where the spec would push toward the 1.2 (boost) clamp. Because the frame is then energy-renormalised (γ = √(R_M0/Σ)), the relative spectral tilt shifts energy out of the high band into the low band — a duller timbre, and, since small-L (female) frames have most of their harmonics in that high band, the effect is strongest on female voices. This matches the reported symptom.

Both missing factors are now restored and ship enabled by default. EnhanceAmplitudes(p, M, specFaithful) carries a specFaithful flag: the spec-faithful branch restores the π/ω₀ factor and mbelib’s √M_l amplitude weight (which makes the clamp gate scale-invariant — without it a pure gain change reshapes the enhanced envelope). The recorder/live path enables it per call for both DMR AMBE+2 and P25 IMBE, governed by the tri-state recordings.spec_amplitude_enhance key (unset = on; false restores the legacy envelope, which stays byte-identical to prior releases so unit-test goldens hold). See internal/voice/mbe/enhance.go and the CHANGELOG entry that shipped it.

Per this repo’s #764/#771 discipline, the final quality sign-off is still calibration-gated: matching the spec’s (or mbelib’s) formula is not by itself proof of better perceived audio, and EnhanceAmplitudes is shared with the P25 IMBE path. The calibrate harness below is that gate — with a real reference WAV in place, the TestCompareAMBE2 cross-correlation will rise if the spec-faithful form helps and fall if it hurts.

Levers you can pull today (no code change)

These are perceptual, not spec-exact, but cost nothing to try and can meaningfully warm up / brighten DMR audio while the calibration above is pending:

  • recordings.enhance — the opt-in output chain (EnhanceConfig). A high-shelf and a louder AGC target help intelligibility:

    recordings:
      write_raw: true          # also needed for calibration below
      enhance:
        enabled: true
        hpf_hz: 200            # trim rumble
        lpf_hz: 3400           # telephone band
        shelf_hz: 1600         # gentle presence
        shelf_db: 2            # dB of cut above shelf_hz (positive; <=0 disables)
        agc_target: 22000      # louder playback
    
  • ambe2-dmr-warm — the warm DMR vocoder variant (a high-shelf ToneTilt), selected by recordings.warm_dmr_audio: true (WarmDMRAudio). Softens the synthetic “edge” without touching the model decode.

Verified-fix path (calibration-first)

You already have the input side: every DMR call the daemon records with recordings.write_raw: true writes a <UTC>_<id>.raw sidecar of the packed AMBE+2 frames (7 bytes/frame) next to the .wav. Pick a female-voice call — the hard case — and:

  1. Reference-decode the same frames through an independent AMBE+2 decoder. DSD-FME’s -r reads its own cookie-headed .amb container, not the flat .raw — enable recordings.mbe_files: true so the daemon writes the .amb next to the recording, then:

    dsd-fme -fs -w reference12k.wav -r <call>.amb
    # DSD-FME's -w stamps an 8 kHz header on 12 kHz synthesis (see
    # docs/vocoders.md) — fix the rate before comparing:
    tail -c +45 reference12k.wav > ref12k.pcm
    sox -t raw -r 12000 -e signed -b 16 -c 1 ref12k.pcm -r 8000 reference.wav
    
  2. Drop both files in and run the A/B (the test skips until the reference lands, so CI stays green today):

    cp <call>.raw    internal/voice/ambe2/testdata/dmr-voice.raw
    cp reference.wav internal/voice/ambe2/testdata/dmr-voice-dsdfme.wav
    go test ./internal/voice/calibrate/ -v -run TestCompareAMBE2
    

    Acceptance: |RMSRatioDb| < 3.0 (loudness) and PeakXcorr > 0.85 (waveform similarity). A clean RMS but low xcorr is the synthesis signature — i.e. the §6.2 enhancement above, not a gain knob.

  3. A/B the §6.2 correction. The spec-faithful form is the default, so the A/B is a config toggle, not a code change: run once as-is, once with recordings.spec_amplitude_enhance: false (the legacy envelope), and compare PeakXcorr. If the spec-faithful run scores higher (and DMR + P25 both hold or improve), the default is confirmed; if not, report it — that reference pair is exactly the evidence the sign-off needs.

See Voice calibration for the full harness reference (the cmd/voice-calibrate CLI, the IMBE path, and the AGC TargetPeak knob for the loudness half).

Do not commit real over-the-air public-safety recordings as fixtures. The calibrate testdata dirs are for local, operator-provided captures — the tests t.Skip when the files are absent, which is the intended state in the repo.