Review audit · firmware & RF
16 September 2026
DM-1701 · HR-C6000 · AT1846S · STM32F405 · P25

UNIONALPHA’s P25 review, graded

A hidden model was asked to find why this firmware struggles to decode P25 Phase 1 and Phase 2 voice on the radio’s HR-C6000, and what to do about it. I checked every claim against the code, reran its evidence, and modelled what it left out through the firmware’s own demodulator.

Overall grade
C−
65 / 100 weighted

Accurate to the line number. Silent on what stops the radio decoding.

Everything UNIONALPHA checked, it checked well. Its line references, test results, byte counts and reproductions all hold up. The problem is what it chose to check. Its four “confirmed findings” cover a diagnostic export, a Python parser, a telemetry counter and the linker map, and all four already sat, in the same order, in the repository’s Sep 10 review. None of them explains why the radio would fail to decode P25.

The configuration that most plausibly does is never mentioned. For a P25 session the monitor switches the receiver into analog FM mode: the AT1846S high- and low-pass filters on, low-frequency audio blocked, 25 kHz channel bandwidth, and HR-C6000 de-emphasis enabled. Modelled through the firmware’s demodulator, any audio high-pass at 75 Hz or above leaves zero decodable voice frames. And even with a perfect signal, emulated instruction counts show the IMBE vocoder needs 11 to 16 times the radio’s 72 MHz CPU.

Claims that check out
21 / 23
2 overstated · 0 false
Confirmed findings already on record
4 / 4
F1–F4 of the Sep 10 review, uncited
Decode-critical issues missed
4
plus 2 moderate issues
Voice frames with a ≥ 75 Hz high-pass
0 / 180
firmware demodulator, noise-free model
Headline findings

Six things to know about this review

Holds up

What it checked is correct

The host suite passes with byte-identical output (104 lines). The capture-parser defect reproduces exactly. SRAM reservations end on 0x20020000 with CCM 1,160 bytes short of full. The Golay Gray-code argument and the mismatch between a ±10% health gate and a ±1% timing clamp are both right.

tests/p25/run.py · tools/p25_capture.py · build/MDUV380_FW.elf · p25_fec.c:65–70 · p25_4fsk.c:240–241

Critical miss

It never mentions the analog-FM receive chain

p25mon.c:724 calls trxSetModeAndBandwidth(RADIO_MODE_ANALOG, true). That loads the AT1846S FM set: filters on (0x58=0xBC85), the “low freq audio” bit cleared (0x40=0x0030), AF gain 80%, 25 kHz. HR-C6000 de-emphasis and a 3 kHz filter come on too. OpenGD77’s DMR set, built for the same 4800-baud 4FSK, does the opposite.

AT1846S.c:191–199 vs 204–217 · HR-C6000.c:2846–2854 · manual §6.2

Major gap

It treats the microphone as a coin flip

It calls the RF route unproven “either direction”, but the evidence leans. OpenGD77 reads the identical I2S slot for transmit audio and labels it “Mic Audio”. Bit 6 of 0xE0, set in 0xC9, enables mic → LineIn1. The manual says the I2S frame clock “must be 8KHz”, yet the monitor runs it at 24 kHz.

sound.c:444 · HR-C6000.c:2849, 2872, 2964 · manual line 1218

Not original

Its four confirmed findings were already in the repo

F1 capture stride, F2 parser, F3 starvation counter and F4 memory map match F1–F4 of P25-PROJECT-REVIEW-2026-09-10.md in order and substance, down to the samples=66 fixture. Its C3 is that review’s F5. None of it is cited, while that review’s analog-filter, test-oracle and 72 MHz points were dropped.

P25-PROJECT-REVIEW-2026-09-10.md §4 · file dated 10 Sep, six days earlier

Moderate

It accepted a circular test oracle

It cites “C4FM channel vectors”, but the generator uses the same RRC filter as the receiver. Against a standard C4FM waveform (raised cosine × inverse sinc), the noise-free eye closes from 0.93 to 0.37, costing about 2 dB. A band-limited integrate-and-dump FIR restores it to 0.98.

p25_4fsk.c:84–124 · p25_selftest.c:258 · TIA-102.BAAA C4FM shaping

Critical miss

The vocoder cannot run in real time, and it never says so

Its tick arithmetic stops at the sample ring. This port clocks the STM32F405 at 72 MHz (PLLN=72). In an emulated Cortex-M4F with the firmware’s own compiler and newlib, the demodulator uses about 40% of that core, but mbelib’s IMBE synthesis needs 11–16 times all of it. A loud 20 ms frame costs roughly 430 ms of CPU, because unwrapped harmonic phases push cosf onto newlib’s slow large-argument path.

mbelib.c:306, 333–388 · libm cosf → __kernel_rem_pio2f (linked in MDUV380_FW.elf) · Core/Src/main.c:271–273

Scorecard

How the 65 breaks down

Weights reflect the question asked: find what stops P25 decoding and say how to fix it. Accuracy carries the most weight because a wrong review does harm; coverage and root cause together outweigh it because an accurate review of the wrong things does not help.

Dimension scores

Hover or focus a bar for the reasoning. Weighted total 65, grade C−.

0255075100Score out of 100Accuracy & evidenceweight 30%90Coverage of decode problemsweight 25%45Root cause & prioritisationweight 15%55Fix plan & acceptance gatesweight 15%74Originality & attributionweight 10%40Clarity & calibrationweight 5%76
Rubric table
DimensionWeightScorePointsWhy
Accuracy & evidence30%9027.0Every checked line reference, test result and byte count holds; the evidence bundle reproduces. Two overstatements: RRC test vectors called “C4FM channel vectors”, trellis coverage described as independently referenced.
Coverage of decode problems25%4511.2Misses the analog-FM receive chain, the microphone evidence, the manual’s 8 kHz frame-clock limit and the circular RRC test oracle, and never quantifies that the IMBE vocoder needs 11–16× the 72 MHz CPU. Its confirmed findings are about tooling, telemetry and the linker map.
Root cause & prioritisation15%558.2Fixing the instruments first is sound, but two software-fixable blockers (the analog-FM settings and a vocoder an order of magnitude over budget) are absent, and the RF-route question is framed as unproven “either direction” despite evidence that leans to the microphone.
Fix plan & acceptance gates15%7411.1Clear phases with measurable gates. Missing the cheapest high-value step (a flat receive configuration before any RF test), a concrete receive-filter change, and the tap precedent the repo already documents.
Originality & attribution10%404.0F1–F4 match F1–F4 of the repo’s Sep 10 review in order and substance, C3 matches its F5, with the same parser fixture; none of it is cited. Genuinely new: the gate-versus-clamp mismatch, the Golay check, a fuller Phase 2 section.
Clarity & calibration5%763.8Scrupulous about what it does not claim. Over-hedged where evidence leans one way, and dense enough to slow a decision-maker.
Weighted total100%65Grade scale: A ≥ 90 · B 80–89 · C+ 75–79 · C 70–74 · C− 65–69 · D 50–64 · F < 50
Receive chain

Where the problems sit

The monitor’s intended path runs from RF to speaker; the capture export branches off at the DMA interrupt for diagnostics. UNIONALPHA’s issues cluster on that diagnostic branch. The issues it missed sit on the path the voice actually takes.

RADIO · ANALOG FRONT ENDSTM32F405 · 72 MHz · -OsP25 C4FM carrier12.5 kHz channel±1.8 kHz peak deviationAT1846S receiveranalog FM register setHPF + LPF on · LF audio off · 25 kHzHR-C6000 codec ADCE0 = 0xC9 · 0x34 de-emphasis onno documented RF input to I2SI2S to the MCULRCK 24 kHz (manual: 8 kHz)left slot, 16-bitMicrophoneLineIn1 · enabled by E0 bit 6RFaudio?ADCDMA half-buffer ISRp25monI2sHalfBuffer160 frames per callbackSample ring512 samples ≈ 21 ms≤128 drained per tickResampler ×1.624 → 38.4 kHz, nominalhealth gate ±10 %RRC FIR + timing81 taps · 768/512 windowperiod clamp ±1 %p25rx framingsync · NID · LDU · FECfail-closed mutembelib IMBE≈16 M instructions/framecosf slow pathPCM ring → I2S TXradioSetAudioPath() per frame→ speaker amplifierCapture export ‘c’ISR stages [j][0..79]framed hex over USBUSB CDC consoleshared with 1 Hz reportp25_capture.pyparser on the hostdiagnostic tapG1C1F4C2F3F1C3F2M1M2M3M4M5M6
F1 raised by UNIONALPHAM1 missed by UNIONALPHADashed edge: no documented connection
G1RF-to-I2S route unproven
M1Analog FM receive set: HPF/LPF, LF audio off, 25 kHz bandwidth, de-emphasis
F1Capture export stages the wrong halfwords
M2Evidence that the codec ADC samples the microphone
F2Parser certifies incomplete captures
M3Manual: I2S frame clock must be 8 kHz
F3pcm_starve never increments
M4RRC receive filter validated only against itself
F4Zero static RAM margin
M5IMBE vocoder needs 11–16× the 72 MHz CPU
C1Tick budget unmeasured
M6Two SPI writes per decoded voice frame
C2±10% gate versus ±1% clamp
C3Capture epochs missing
The missed blocker, measured

C4FM does not survive a voice audio path

P25 Phase 1 is four-level FSK at 4800 symbols per second, and its baseband reaches almost down to DC. Runs of equal symbols, such as the frame sync’s five +3s, carry energy that any audio high-pass removes, and the filter’s phase shift smears neighbouring symbols. OpenGD77 already knows this: its DMR mode sets the AT1846S “magic” low-frequency bit and bypasses its filters for the same symbol rate. The P25 monitor selects the FM set instead.

Register state for a P25 session

What trxSetModeAndBandwidth(RADIO_MODE_ANALOG, true) leaves in place, next to OpenGD77’s own settings for 4800-baud 4FSK.

RegisterMonitor today (analog FM)OpenGD77 DMR modeEffect on C4FM
AT1846S 0x400x0030, low-frequency bit cleared: “so it should be cleared to receive FM”0x0031, “allows low freq audio”Removes the near-DC content that runs of equal symbols depend on
AT1846S 0x580xBC85, “enable some filters for FM e.g. high and low pass filters”0x9CDD, “disable all filters in DMR mode”High- and low-pass shaping of the symbol stream
AT1846S 0x440x06CC, AF gain 80%0x07FF, 100%; the DM-1701 “needs higher AF gain” to decode 4FSKLower discriminator level into the next stage
AT1846S bandwidth25 kHz12.5 kHz, forced for digitalWider noise bandwidth and deviation scaling set for ±5 kHz FM
HR-C6000 0x340x3C, “compressor off, de-emph on, 3 kHz audio filter”not written; the FM value persists−6 dB/octave from 300 Hz and a 3 kHz low-pass on the FM receive path (manual §6.2)
Sources: AT1846S.c:191–217, HR-C6000.c:2846–2854 and 2858–2875, trx.c:228–279, p25mon.c:724. Register comments are OpenGD77’s; the manual translation documents 0x34.

Voice frames decoded versus audio high-pass corner

Standard C4FM through a 2nd-order Butterworth high-pass, then the firmware’s live demodulator and framer. 20 back-to-back LDUs: 180 IMBE frames and 20 NIDs possible.

  • Noise-free
  • 20 dB SNR
  • Typical voice high-pass range
VOICE HIGH-PASS RANGE0%25%50%75%100%10203550751001502003002nd-order high-pass corner (Hz, log scale)IMBE frames decodednoise-free20 dB SNR
Decoding holds up to 20 Hz, loses a quarter of its frames at 35 Hz, and fails completely from 75 Hz. Filters that reject CTCSS tones (67–254 Hz) sit well past that point, so the exact AT1846S corner, which is undocumented, doesn’t change the conclusion.
Data table
Corner (Hz)Noise-free frames · NIDsNoise-free SER20 dB frames · NIDs20 dB SER
off100% · 20/200.00%100% · 20/200.00%
10100% · 20/200.00%100% · 20/200.01%
20100% · 20/200.13%100% · 20/200.23%
3575% · 15/202.31%60% · 12/202.80%
5010% · 2/207.04%20% · 4/207.45%
750% · 0/2015.17%0% · 0/2015.37%
1000% · 0/2021.29%0% · 0/2021.70%
1500% · 0/2030.21%0% · 0/2030.58%
2000% · 0/2036.59%0% · 0/2036.19%
3000% · 0/2044.46%0% · 0/2044.67%

Eye diagrams at the slicer

Matched-filter output folded over two symbols at the ideal sampling instant, normalised so the four levels sit at ±1 and ±3. Horizontal hairlines mark levels and decision thresholds.

Test model

RRC transmit → firmware RRC

opening 0.93180/180 frames · 20/20 NIDs

Standard C4FM

→ firmware RRC (as shipped)

opening 0.36180/180 frames · 20/20 NIDs

Standard C4FM

→ band-limited integrate-and-dump FIR

opening 0.98180/180 frames · 20/20 NIDs

+ 150 Hz high-pass

C4FM → firmware RRC

eye closed0/180 frames · 0/20 NIDs

+ analog FM chain

HPF 300 Hz + LPF 3 kHz + de-emphasis

eye closed0/180 frames · 0/20 NIDs
The first panel is what the test suite validates. The last two are what a voice audio path would feed the same code. “Opening” is the worst-case gap between neighbouring levels across 17,200 symbols; ≤ 0 means at least one decision is wrong even without noise.
All eight modelled cases
CaseSignal → receive filterEyeNoise-free frames · NIDsNoise-free SER20 dB frames · SER14 dB frames · SER
S0Project test model: RRC transmit -> firmware RRC+0.93180/180 · 20/200.00%180/180 · 0.00%180/180 · 0.00%
S1Standard C4FM -> firmware RRC (as shipped)+0.36180/180 · 20/200.00%180/180 · 0.00%180/180 · 0.05%
S2Standard C4FM -> integrate-and-dump (TIA reference)+0.96180/180 · 20/200.00%180/180 · 0.04%180/180 · 0.38%
S2bStandard C4FM -> band-limited integrate-and-dump FIR (81 taps)+0.98180/180 · 20/200.00%180/180 · 0.00%180/180 · 0.05%
S3C4FM + 150 Hz high-pass -> RRC-2.010/180 · 0/2030.21%0/180 · 30.40%0/180 · 30.97%
S4C4FM + 300 Hz high-pass -> RRC-2.500/180 · 0/2044.46%0/180 · 44.33%0/180 · 44.12%
S5C4FM + 300 Hz HPF + 3 kHz LPF -> RRC-2.530/180 · 0/2048.28%0/180 · 48.08%0/180 · 47.99%
S6C4FM + 300 Hz HPF + 3 kHz LPF + de-emphasis -> RRC-3.620/180 · 0/2046.72%0/180 · 46.77%0/180 · 47.05%
S7C4FM + 300 Hz HPF + 3 kHz LPF + de-emphasis -> integrate-and-dump-3.950/180 · 0/2049.12%0/180 · 49.18%0/180 · 49.38%
Test oracle

The tests only prove the receiver agrees with itself

The self-test and timing sweeps synthesise the transmitter with the receiver’s own 81-tap root-raised-cosine filter, at 40 dB SNR in the timing sweeps. Standard C4FM uses a full raised cosine with inverse-sinc compensation, and the reference receiver integrates and dumps. The shipped filter still decodes clean signals, but the tests hide its margin loss.

Symbol error rate versus SNR

Firmware demodulator and framer, white noise at the discriminator. Hollow markers sit on the floor where no errors were seen in 51,600 symbols.

  • Test model → shipped RRC
  • Standard C4FM → shipped RRC
  • Standard C4FM → band-limited integrate-and-dump FIR
0.001%0.01%0.1%1%10%68101214161820In-band SNR at the discriminator (dB)Symbol error rate (log)test modelstandard C4FM
At 0.2% symbol errors the test model needs 10 dB and standard C4FM needs 12 dB. A band-limited integrate-and-dump FIR removes the inter-symbol interference (eye 0.98) and matches the RRC under white noise, so the fix is mostly a better oracle plus the correct filter, not a rescue.
Data table
SNR (dB)Test model → RRCC4FM → RRCC4FM → band-limited I&DC4FM → 8-sample I&D
64.238% · 1746.496% · 1297.008% · 15011.880% · 90
81.279% · 1802.802% · 1773.306% · 1716.246% · 153
100.182% · 1800.897% · 1770.975% · 1802.709% · 168
120.012% · 1800.223% · 1800.194% · 1800.955% · 174
140 · 1800.045% · 1800.027% · 1800.576% · 174
160 · 1800.006% · 1800 · 1800.184% · 177
200 · 1800 · 1800 · 1800.054% · 180

Each cell: symbol error rate · mean IMBE frames decoded of 180.

Why: the filters side by side

Magnitude responses. The C4FM transmitter boosts toward 2.88 kHz to pre-compensate the integrate-and-dump receiver; the voice-path model cuts the low end and rolls off above 300 Hz.

  • C4FM transmit shaping (RC × inverse sinc)
  • Firmware RRC receive filter
  • Band-limited integrate-and-dump FIR
  • Analog-FM path model (HPF + LPF + de-emphasis)
  • C4FM occupied baseband
C4FM OCCUPIED BASEBAND-40-30-20-100+101003001k2.88k5kFrequency (Hz, log scale)Magnitude (dB)
The analog-FM model assumes a 300 Hz 2nd-order high-pass, a 3 kHz 4th-order low-pass and first-order 300 Hz de-emphasis, matching the manual’s described slope. The real AT1846S corners are not documented; the high-pass sweep above shows the conclusion does not hinge on them.
Data table (dB)
HzC4FM transmit shaping (RC × inverse sinc)Firmware RRC receive filterBand-limited integrate-and-dump FIRAnalog-FM path model (HPF + LPF + de-emphasis)
100+0.0-0.0-0.0-19.6
199+0.0-0.0-0.0-9.5
301+0.1-0.0-0.1-6.0
601+0.2-0.0-0.2-7.3
998+0.6+0.0-0.6-10.9
1902+2.4+0.0-2.4-16.3
2394-1.9-3.0-3.9-18.8
2878< −40-22.0-6.7-22.2
3973< −40< −40< −40-33.5
CPU and memory

The vocoder needs an order of magnitude more CPU

UNIONALPHA’s memory finding is exactly right: static reservations fill SRAM to the last byte. Its real-time discussion stops at ring arithmetic, which is where the budget holds. This port runs the Cortex-M4F at 72 MHz rather than the part’s 168 MHz, and even a 168 MHz clock would leave the vocoder several times over budget.

Where one second of decoding goes

The firmware’s streaming loop, compiled with its own GCC flags and newlib and run in an emulated Cortex-M4F over 2.2 s of input (12 LDUs, 108 IMBE frames, all decoded). Instruction counts become time by assuming one clock cycle per instruction, the optimistic end.

0%200%400%600%800%1000%1200%Share of one CPU second at 72 MHz (-Os, 1.0 cycles per instruction)Resampler ×1.61.4 M instructions/s1.9%RRC FIR, timing and level fit28.0 M instructions/s39%Framing, NID and FEC0.7 M instructions/s1.0%mbelib IMBE decode and synthesis781.9 M instructions/s1,086%whole CPU
The demodulator fits: about 40% of the core, 60% at 1.5 cycles per instruction. The vocoder needs roughly 11 times the whole CPU, 16 times at 1.5. Building with -O2 changes these by under 1%, because the time is spent inside newlib’s prebuilt libm.
Per-stage table
Stage-Os, M instr/sShare at 1.0 · 1.5 c/i-O2, M instr/s
Resampler ×1.61.401.9% · 2.9%1.32
RRC FIR, timing and level fit28.0038.9% · 58.3%27.70
Framing, NID and FEC0.741.0% · 1.5%0.73
mbelib IMBE decode and synthesis781.901,086.0% · 1,629.0%781.60
Total812.11,128% · 1,692%811.4

CPU time for each 20 ms of speech

One call to mbelib’s IMBE decoder per frame, uvquality 1, FEC-valid codewords with chosen pitch and voicing, mean over 100 consecutive frames.

0 ms100 ms200 ms300 ms400 ms500 msCPU time per decoded 20 ms frame at 72 MHz (mean, 1.0 cycles per instruction)L=20, all bands unvoiced3.0 M instructions42 msL=20, typical (15 of 20 voiced)9.7 M instructions134 msL=30, typical (21 of 30 voiced)14.0 M instructions194 msRandom pitch and voicing15.9 M instructions220 msL=56, all bands voiced31.2 M instructions434 ms20 ms deadline
Every frame type misses its 20 ms deadline, from about 2× for the simplest frames to more than 20× for loud voiced ones. The cause is mbelib’s per-harmonic phase, which accumulates without wrapping (mbelib.c:306). Once a call has run for about half a second, nearly every voiced-band cosf argument exceeds about 201 radians, and newlib’s cosf takes its __kernel_rem_pio2f path at roughly 1,700 instructions per call instead of 40 to 100. The real firmware links that same path.
Per-frame table
Frame typeMean, M instrWorst, M instrMean ms at 1.0 · 1.5 c/i
L=20, all bands unvoiced3.005.0242 · 62
L=20, typical (15 of 20 voiced)9.6810.23134 · 202
L=30, typical (21 of 30 voiced)13.9714.76194 · 291
Random pitch and voicing15.8626.17220 · 330
L=56, all bands voiced31.2532.97434 · 651

Static memory after linker reservations

Fresh ELF section table: _ebss = 0x2001F9FC, end = 0x2001FA00, plus 0x600 of heap and stack reservations = 0x20020000.

  • .data / .ccmram
  • .bss
  • newlib heap + alignment
  • main stack
0%25%50%75%100%Share of region (%)SRAM128 KiB0 bytes freeCCM RAM64 KiB1,160 bytes free
Link success is not runtime headroom: task stacks come from the 20,480-byte FreeRTOS heap inside .bss, and interrupts share the 1 KiB main stack.
Claim check

23 claims, checked one by one

Twenty-one hold as written, some with omissions noted. Two are overstated. None is false.

ItemWhat UNIONALPHA claimsWhereResultNotes
F1Live decoder reads even (left-slot) halfwords; the capture export stages the contiguous first 80 of each row, so 158 of 160 positions differp25mon.c:432–467, 443, 464–465HoldsCode matches; OpenGD77’s own mic path (sound.c:444) reads the same [j][i*2] slot.
F2Parser anchors to the first observed sequence number and never reconciles the footer’s samples= count; missing chunk 0 exits 0 with complete=Truetools/p25_capture.py:104–190, 218–222HoldsReproduced. A lost final chunk passes too (not mentioned, but covered by the proposed fix).
F3p25monAudioUnderruns (pcm_starve) is declared and printed but never incrementedp25mon.c:123, 517HoldsTwo references firmware-wide; STAGE3-BRINGUP.md documents the counter as working.
F4SRAM .data 17,000 + .bss 112,532 + heap/stack 1,540 = 131,072 bytes, ending at 0x20020000; CCM 64,376 of 65,536build/MDUV380_FW.elfHolds_ebss 0x2001F9FC, end 0x2001FA00, 0x600 reserved; ucHeap 20,480 in .bss from heap_4.o.
G1Monitor writes 0xE0=0xC9 and the codec refresh set; the source itself tags the route UNVERIFIEDp25mon.c:642–656, 648, 733, 519, 699–700, 1345HoldsThe refresh list omits 0x06=0x21, which is harmless.
G1Manual §4.5 and §6.2 document no discriminator-to-I2S tapHR_C6000_user_manual.txtIncompleteTrue, but §4.6 also says the I2S frame clock “must be 8KHz”, and §6.2 puts de-emphasis before the codec.
G1HR-C6000.c input labels are community reverse-engineering, not datasheet proofHR-C6000.c:2778, 2872, 2964UnderstatedThose labels say mic → LineIn1 via E0 bit 6, and sound.c:444 calls the same I2S slot “Mic Audio”.
G1ref-sources/openrtx_hrc6000.cpp contains no 0xE0 writesref-sources/openrtx_hrc6000.cppHoldsgrep finds none.
C1Drain ≤ 4×32 = 128 samples per tick, needing 187.5 calls/s at 24 kS/s against a 512-sample ring in a 1 ms super-loop; synthesis load unmeasuredp25mon.c:95, 570, 583–591; applicationMain.c:1451–1456UnderstatedArithmetic is right, but emulated counts put IMBE synthesis alone at 11–16× the 72 MHz CPU, a hard blocker rather than a tight budget.
C2Resampler and playback use the nominal rate; a ±10% health gate accepts rates the ±1% symbol-period clamp cannot trackp25mon.c:398, 574–577, 896–897, 1066–1067; p25_4fsk.c:240–241HoldsA real mismatch, and new relative to the repo’s earlier reviews.
C3Retunes, clock changes and restarts are not stamped into capture sessionsp25mon.c (capture start/stop, tune)HoldsAlso, the header hard-codes measured=0.
P1DUIDs match the standard tablep25_tables.h:85–92HoldsHDU 0x0, TDU 0x3, LDU1 0x5, TSBK 0x7, LDU2 0xA, PDU 0xC, TDULC 0xF.
P1NID/status handling corrected: 35 data + 1 status per 36 dibits; LDU = 784 payload + 24 sync + 32 NID, 864 on airp25rx.c:347–355HoldsChecked the full LDU1/LDU2 layout, HDU/TDULC lengths and status placement.
P1Golay decoder’s i ^ (i >> 1) is correct Gray-code enumeration, not a bugp25_fec.c:65–70Holdsg(i) ⊕ g(i−1) = 1 ≪ ctz(i), and the code is linear.
P1Golay, Hamming and RS are checked against independent reference encoderstests/p25/fec_reference.cpp; signalling.py:12–40HoldsDSD’s Golay24, Hamming and ReedSolomon classes.
P1Trellis, CRC and trunk coverage, acceptance plus rejection, listed under “independently referenced”signalling.py:78–124OverstatedThe trellis test re-implements the production transition table, and trunk tests live in trunk.py.
P1No confirmed new Phase 1 protocol bugp25rx.c, p25_fec.c, p25_nid.c, p25_trunk.cAgreeAn independent read found none; non-standard MFID link control mutes audio by design.
V1Host suite exits 0 with a 104-line log, selftest=PASS tests=111111python3 tests/p25/run.pyHoldsRerun is byte-identical apart from debug lines.
V1Timing sweeps −1000…+1000 ppm, 0/6480 raw dibit errors; described as “C4FM channel vectors”tests/p25/run.py, p25_selftest.cMislabelledNumbers are right, but the generator is RRC-shaped, the same filter the receiver uses, at 40 dB SNR.
V1Incremental relink produced an 810,252-byte imagebuild logHoldsFile size and timestamp match.
P2Phase 2 needs H-DQPSK at 6000 sps, TDMA and ISCH, WACN/SYSID/NAC descrambling and AMBE+2 half-rate voice§6HoldsMisses that the radio already runs an AMBE+2 decoder for DMR.
P2mbelib path is IMBE-only; TDMA identifier update 0x33 is rejectedp25rx.c:105; p25_trunk.c:26HoldsExact lines.
P2Wider RS configurations are needed beyond the current bounded FECp25_fec.c:77, 104 (n ≤ 36)HoldsPhase 2 uses codes up to n = 63 over GF(64).
Provenance

What was already known

Two earlier reviews sit in the same repository. UNIONALPHA plainly read the second: it corrects that review’s memory figure and reuses its parser fixture. What it kept, added and dropped:

IssueP25-HARDWARE-REVIEW
Sep 8
P25-PROJECT-REVIEW
Sep 10
UNIONALPHA
Sep 16
Capture export stages the wrong I2S halfwordsabsentraised
F1
raised
F1
Capture parser certifies an incomplete stream (samples=66 fixture)absentraised
F2
raised
F2
pcm_starve never incrementspartly
asks for underrun counts
raised
F3
raised
F3
No static RAM marginpartly
648 bytes then
raised
F4, 0 in workspace map
raised
F4, 0 bytes
Capture sessions lack epochsabsentraised
F5
raised
C3
Ring and tick real-time budgetraisedraisedraised
C1
RF-to-I2S route unproven; OpenGD77 calls it mic/LineIn1raised
cites sound.c
raisedpartly
“either direction”
Analog FM mode: high/low-pass, de-emphasis, 25 kHz IF droppedraisedraisedabsent
Test waveform shares the receiver’s RRC filter droppedraisedraised
F6
absent
MCU runs at 72 MHz, not 168 MHz droppedraisedraisedabsent
Direct discriminator-to-ADC tap (M17 mod precedent)raised
with pins
raisedpartly
no precedent, no pins
±10% health gate versus ±1% symbol-period clampabsentabsentraised
new
Golay Gray-code enumeration verified correctabsentabsentraised
new
Phase 2 requirementspartly
scope note
partly
scope note
raised
detailed
Manual: I2S frame clock “must be 8KHz”absentabsentabsent
Modelled decode loss from audio-path high-pass filteringabsentabsentabsent
Two unverified SPI writes per decoded 20 ms frameabsentabsentabsent
Vocoder cost measured: 11–16× the 72 MHz CPUabsentpartly
cycles per frame, unmeasured
partly
“synthesis load” to measure

The last four rows are new in this audit. Dropped rows were raised by both earlier reviews in the same repository and are absent from UNIONALPHA.

What to do instead

A fix plan that starts with the signal path

UNIONALPHA’s phases are a good skeleton. This version keeps its instrument fixes and gates, then does everything that can be verified in software before the RF experiments, so a negative result on the radio actually means something.

  1. Agrees with UNIONALPHA phase 0

    Make the instruments trustworthy

    Stage i2s_Rx_Buffer[bufNum][j][i*2] for i < 80 in the capture export, exactly what the decoder consumes. Make p25_capture.py strict: sequence origin 0, footer samples= reconciliation, non-zero exit. Increment pcm_starve only during an active clear call.

    GateA ramp through the real DMA adapter matches 160/160. Missing-first-chunk and missing-last-chunk fixtures fail. A forced dry ring counts, and intentional silence does not.

  2. New

    Give P25 a flat receive path before any RF test

    For the P25 session, send the AT1846S the DMR register set over I2C: 0x40 low-frequency bit set, 0x58=0x9CDD, 0x44=0x07FF, 12.5 kHz bandwidth. Leave the HR-C6000 codec state the bring-up notes found fragile alone. Call radioSetAudioPath(false) once at start instead of on every decoded frame.

    GateAT1846S register readback in the 1 Hz report. No new codec-clock dips over a 10-minute run.

  3. New

    Make the vocoder real-time

    Replace mbelib’s per-sample cosf calls with a per-harmonic phase accumulator and a cosine table, and wrap phases every frame. Wrapping alone is not enough: even newlib’s fast path, at 40–100 instructions per call, leaves a loud frame near the whole core. The demodulator’s 81-tap FIR, at about 40%, is the next target; its symmetric taps halve the multiplies. A 168 MHz clock, after checking the USB and I2S clock trees, adds headroom but does not close an 11–16× gap.

    GateOn host and emulator, decoded audio matches unmodified mbelib within a small tolerance. On the radio, DWT shows the worst voiced frame under 10 ms and zero ring overruns across a 10-minute voice replay.

  4. New

    Replace the self-referential oracle

    Generate standard TIA C4FM vectors (raised cosine α = 0.2 × inverse sinc), add real recordings, and keep high-pass and DC-offset sweeps as regressions. Offer a band-limited integrate-and-dump FIR next to the RRC.

    GateSymbol error rate on independent vectors within 1 dB of the ideal slicer. The high-pass regression fails loudly if someone reintroduces a voice filter.

  5. Sharper than UNIONALPHA phase 1

    Test the microphone hypothesis first, and time-box it

    With the flat path and fixed capture, compare keyed RF against mic taps on both I2S slots. Also check the 24 kHz stream is really 24 kHz: energy above 4 kHz and no repeated samples, since the manual says the frame clock must be 8 kHz. Modulate a 20–30 Hz tone to confirm the low end survives.

    GateRF-correlated samples with mic coupling at least 30 dB down and a flat 20 Hz–3 kHz response. Otherwise stop register experiments after two days.

  6. More concrete than UNIONALPHA’s fallback

    Build the discriminator tap the repo already points to

    The repo’s own analysis cites the M17 DM-1701 modification: AT1846S pin 9 to MCU pin 18. For receive-only P25, keep the path DC-coupled or give it a single-digit-hertz corner, since the model loses frames by 35 Hz. Add anti-alias filtering, then timer-triggered ADC + DMA into SRAM (not CCM) at 24 or 48 kS/s. Keep the HR-C6000 as the speaker DAC only.

    GateAn independently recorded clear P25 signal decodes with the expected NAC and talkgroup, repeatedly, with audio keeping pace.

  7. Agrees with UNIONALPHA ordering

    Only then: conventional calls, trunking, and Phase 2 as its own project

    Qualify late entry, encrypted muting and terminators, then trunk following. Consider holding an established clear call through a single failed RS decode rather than muting nine frames. Treat Phase 2 separately, as below.

    GateIntelligible known speech against a trusted receiver, then passive grant following on one in-band system.

Phase 2

Mostly right, one useful fact missing

UNIONALPHA’s Phase 2 section is accurate. Downlink H-DQPSK at 6000 symbols per second is a different modulation. TDMA slots, ISCH, descrambling seeded from WACN, System ID and NAC, FACCH/SACCH, and Reed–Solomon codes up to n = 63 are all absent. p25rx.c:105 is IMBE-only, and p25_trunk.c:26 invalidates TDMA identifier updates.

What it misses is that the vocoder is not the hard part. The radio already runs a licensed AMBE+2 half-rate decoder for DMR. DSD-FME, bundled in ref-sources/, sends P25 Phase 2 voice frames through the same 3600×2450 AMBE+2 path it uses for DMR (dsd_mbe.c, “DMR and P25p2” handling). The work is the demodulator, framing and descrambling.

A discriminator tap can, in principle, recover H-DQPSK phase steps by integrating frequency over each symbol, but with less margin than an IQ receiver. Plan the tap in step 4 to be wide and DC-coupled, and budget for IQ if Phase 2 matters.

Who wrote it?

A hunch, from the prose alone

The model’s name was hidden. I didn’t open any session logs, so this is a reading of the document’s style, not a lookup.

Best guess: an OpenAI GPT-5 family model, probably a Codex variant

Subjective probabilities, not a classifier score.

0%25%50%75%100%Subjective probabilityOpenAI GPT-5 family (Codex variant)55%Anthropic Claude (Opus class)25%Google Gemini10%Another model10%
  • Negation as rigour. Non-claims appear throughout: No actual stack overflow is asserted, No pin prescription is offered here, with more than a dozen “X, not Y” antitheses. That anti-overclaiming register is characteristic of recent GPT-5 models.
  • Telegraphic compression. Noun-phrase fragments (Tagged lower priority next to F1/F2.), semicolon chains and a line reference in nearly every sentence. No tables and no first person in 2,200 words.
  • Workspace boilerplate. A dirty-tree preamble, “Scope: documentation only”, and a bold closing statement listing everything left untouched. That is the house style of Codex CLI runs.
  • Orchestration fingerprints. “Parallel source reviews”, “disputed findings were checked”, and an evidence index mentioning a brief and a tracker. A verifier loop that keeps only host-reproducible claims would also explain why hardware-configuration issues vanished.
  • Against the alternatives. Heavy em-dash use (38) is the one Claude-like trait. A Claude review of this length would usually carry summary tables and more explanatory prose, and a Gemini review would usually propose code patches; this one proposes none.
Method and limits

How this was checked

I read every P25 receiver source file in the integrated OpenGD77 tree (mbelib only in part), plus the OpenGD77 radio drivers they depend on: AT1846S, HR-C6000, trx, sound, I2S and application main. I also read the HR-C6000 manual translation, the stock-firmware analysis and the bring-up notes, and checked both earlier reviews. Each UNIONALPHA claim was checked at its cited lines.

The simulations compile the firmware’s own p25_4fsk.c, p25rx.c, NID, FEC and mbelib sources for the host exactly as tests/p25/run.py does. They drive them with 20 back-to-back LDUs built by that suite’s independent framing helpers. Only the waveform, the audio-path filters and, in scratch copies, the receive-filter taps were changed.

The CPU budget replays the firmware’s streaming loop in the Unicorn emulator. It uses the firmware’s compiler, flags, newlib and libm; instruction counts were cross-checked with Capstone, and 108 of 108 IMBE frames decoded inside the emulator.

  • Filter corners for the AT1846S and HR-C6000 are assumptions; the sweep shows the conclusion holds across them. A model is not a measurement.
  • Noise is white Gaussian at the discriminator. A real FM discriminator’s noise rises with frequency, and the 24 kHz ADC and resampler are not modelled.
  • CPU figures come from instruction counts in an emulator, not cycle-accurate timing on the radio.
  • No firmware was edited or flashed, and no hardware was touched.
python3 tests/p25/run.py
# 104 lines, byte-identical to UNIONALPHA's evidence

python3 tools/p25_capture.py parse \
  analysis/unionalpha-review/capture_missing_first_chunk.txt
# sample_count=2 complete=True, exit 0

arm-none-eabi-size -A build/MDUV380_FW.elf
arm-none-eabi-nm build/MDUV380_FW.elf | grep -E ' (_ebss|end)$'
# .bss ends 0x2001F9FC; reservations end 0x20020000

Simulation source: c4fm_sim.py and metrics.json. Run from the repository root with DM1701_REPO set; it needs numpy and a C compiler.