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

UNIONALPHA’s P25 review, graded

A hidden model got the same brief as every P25 audit on bench: 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. It worked in a clean room, with a fresh copy of the repository, no earlier reviews and no network. It built the firmware, wrote two probes and patched one bug. I checked its claims against the code, the linker map and the manual, reran both probes, and used the firmware-in-the-loop model and CPU emulation from the earlier clean-room audits for what it left open.

Overall grade
C
74 / 100 weighted

Accurate to the byte. Hesitant about what stops decoding.

UNIONALPHA is the most accurate clean-room audit so far: 33 of 35 claims hold and none is wrong. Both of its probes reproduce exactly, its memory figures match the linker map to the byte, and its one code change, a fix for the capture export, is the fix the evidence calls for. It also finds something no other audit has: with squelch forced open, the stock squelch path re-arms every time the monitor switches the speaker amplifier off and rewrites the codec to FM mode, 0x10=0x80, the value the bring-up notes say kills the codec clock.

Its weakness is calibration on the receive path. It names the AT1846S voice filters and the HR-C6000 de-emphasis at the right lines, then calls their effect “unmeasured”, though de-emphasis alone leaves 0 of 180 frames in the firmware model. It never says the samples are most likely the microphone, and it counts the demodulator’s filter but never the vocoder, which needs 11–16 times the 72 MHz CPU. It finds one of the four decode-critical issues and half-finds the other three. That puts it at C, first of the five clean-room audits on accuracy, coverage and originality, and last on root cause.

Claims that check out
33 / 35
2 overstated or doubtful · 0 wrong
Decode-critical issues found
1 of 4
filters, microphone and CPU only half-found
New issues it verified
4
squelch re-arm, stale call state, untested callback, capture across heals
CPU the vocoder needs
11–16×
of 72 MHz; it counts only the filter

The run, not graded: 36 min · 83 agent steps · 95 tool calls · 63K output tokens · 10.0M tokens re-read · OpenRouter API, one-shot · third attempt; the first two stopped on provider rate limits

Headline findings

Six things to know about this review

Holds up

Everything it checked holds up

33 of 35 claims hold and none is wrong. Its call-state probe and capture harness reproduce exactly when rerun, its SRAM and CCM figures match the linker map to the byte, and its build numbers match its logs. It also avoids the traps other audits fell into: the 1 ms loop is “a minimum iteration interval, not a maximum latency guarantee”, the heap and stack reservation is not free RAM, and an I/Q path is “preferred”, not required, for Phase 2.

review-artifacts/call_state_test.py · capture_callback_test.c · build-review/MDUV380_FW.map · applicationMain.c:1451–1455

New finding

It finds a squelch path that undoes the monitor’s design

The monitor forces squelch open on the assumption that the stock path “can never re-arm”. UNIONALPHA shows it can: trx.c re-arms whenever the RF amplifier is off, and the monitor turns it off at start, on call changes and after 300 ms of silence. The stock path then writes 0x36=0x02 and 0x10=0x80 and switches the amplifier back on. The bring-up notes say 0x10=0x80 kills the codec clock in this state, and the monitor’s 5 s refresh restores 0x36 but never 0x10. Whether it fires on the radio is untested.

trx.c:517–539, 572–584 · p25mon.c:277, 711–716, 810–811, 1076–1077 · HR-C6000.c:2967–2977 · STAGE3-BRINGUP.md:356

Fixed and verified

It fixes the capture and shows why the tests missed it

The decoder reads the left channel, [j][i*2], but the capture exported 80 contiguous words per row, so 158 of 160 positions differed. UNIONALPHA patched the export to stage the decoder’s samples and proved it with a byte-exact harness, and it found why the suite never caught the bug: tests/p25/run.py compiles the capture module but never the I2S callback. A second probe shows stale clear-call state releasing 1,120 samples, seven frames, of a new encrypted call when a header is missed.

p25mon.c:443, 460–475 · tests/p25/run.py:264–270 · p25rx.c:155–170, 290–292

Critical miss

It calls the filter chain “unmeasured”

It cites the AT1846S voice filters and low-frequency bit, and the HR-C6000’s de-emphasis, at the right lines, then says which of them matter “is unmeasured”. They aren’t open questions: in the firmware-in-the-loop model de-emphasis alone leaves 0 of 180 voice frames, and a voice-band high-pass stops every frame from 75 Hz. Its plan characterises the path “before selecting filter bypass/equalization values” instead of clearing 0x34 bit 5 and loading the DMR register set first.

AT1846S.c:191–217 · HR-C6000.c:2851 · manual 2803–2808 · UNIONALPHA_CLEAN_REVIEW.md F5

Critical miss

It counts the filter but never the vocoder

It works out that the 81-tap filter costs about 4.18 million multiply-adds a second and notes that IMBE synthesis runs even for muted frames, then stops. Emulated instruction counts put mbelib’s synthesis at 11–16 times the radio’s 72 MHz CPU: one voiced 20 ms frame takes 134–458 ms, so the decoder falls behind on every voiced frame, whatever the front end does. Its plan says only to “optimize measured bottlenecks”.

UNIONALPHA_CLEAN_REVIEW.md F7 · p25rx.c:105–123 · mbelib.c:306 · Core/Src/main.c:269–274

Major gap

It won’t say where the samples come from

Its evidence all points one way: 0xE0=0xC9 is the “Line in 1” and “Mic_p” setting, HRC6000SetMic() toggles the same bit, and the stock microphone path reads the same slot. It calls the source a “blocking uncertainty” with a sensible stop rule, but never says the stream is most likely the microphone. Its first priority is codec ownership and receive-only enforcement, and it misses the capture parser that certifies incomplete streams and the ±10% gate against the ±1% timing clamp.

HR-C6000.c:2778, 2872, 2962–2964 · sound.c:444 · tools/p25_capture.py · p25mon.c:574–577 · p25_4fsk.c:240–241

Scorecard

How the 74 breaks down

Same rubric, weights and scale as every P25 audit on bench, so scores compare directly. UNIONALPHA leads the clean-room audits on accuracy, coverage and originality, and trails all of them on root cause.

Dimension scores

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

0255075100Score out of 100Accuracy & evidenceweight 30%90Coverage of decode problemsweight 25%65Root cause & prioritisationweight 15%60Fix plan & acceptance gatesweight 15%70Originality & attributionweight 10%76Clarity & calibrationweight 5%80
Rubric table
DimensionWeightScorePointsWhy
Accuracy & evidence30%9027.033 of 35 claims hold and none is wrong: both probes reproduce exactly, the memory figures match the map to the byte and its capture fix is correct. Two are qualified: “independent” tests that include a re-implemented trellis table, and a log it elsewhere calls superseded.
Coverage of decode problems25%6516.2Finds the 8 kHz rule, the capture, starvation, RAM, epoch and tick-budget issues, the circular oracle, 72 MHz and the discriminator tap, with the most accurate Phase 2 section and four new defects. The filter chain, the microphone question and the vocoder are only half-found, and it misses the parser, the gate/clamp mismatch and both muting policies.
Root cause & prioritisation15%609.0Makes the sample source the decisive question and refuses to read silence as absence of traffic, but leads with codec ownership and receive-only enforcement, calls the de-emphasis and voice filters unmeasured rather than broken, and never names the vocoder CPU wall.
Fix plan & acceptance gates15%7010.5Ordered and gated with nothing harmful: instruments first (its capture fix already applied), independent C4FM fixtures, an RF-versus-microphone test with a stop rule and fallbacks. It has no flat receive path, no clearing of 0x34 bit 5, and no vocoder rework.
Originality & attribution10%767.6Adds verified defects nothing in the repository mentions: the squelch path that undoes the monitor’s design, stale clear-call state that releases a new call’s first frames, a test suite that never compiles the capture callback, and 8 kHz samples under a 24 kHz capture header.
Clarity & calibration5%804.0Crisp structure with severity labels, impact and resolution for each finding, and honest non-claims about what its probes can’t show. Its confidence runs low where the evidence is strong: “unmeasured” for de-emphasis, “unproven” for the source it has evidence on.
Weighted total100%74Grade scale: A ≥ 90 · B 80–89 · C+ 75–79 · C 70–74 · C− 65–69 · D 50–64 · F < 50
Receive chain

Where its findings sit

UNIONALPHA’s findings cluster on the MCU side: the capture, the ring and tick budget, the test waveform, call state and the audio path the squelch rewrites, plus the manual’s 8 kHz rule. The red tags sit in the analog front end, on the microphone question, the vocoder and two tooling checks.

RADIO · ANALOG FRONT ENDSTM32F405 · 72 MHz · -OsMicrophoneLineIn1 · enabled by E0 bit 6P25 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-bitRFaudio?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 tapF3F1F4F5F7F8N1N2N3N4N5N6
F1 raised by UNIONALPHAN1 missed or left openDashed edge: no documented connection
F1Stock squelch re-arms FM audio and writes 0x10=0x80
N1AT1846S voice filters called unmeasured
F3The capture stages the wrong halfwords (fixed)
N2HR-C6000 de-emphasis called unmeasured
F4Manual: the I2S frame clock must be 8 kHz
N3Never says the stream is most likely the microphone
F5The test waveform shares the receiver’s RRC
N4mbelib needs 11–16× the 72 MHz CPU
F7pcm_starve never counts; the 1 ms tick is a minimum
N5The parser certifies incomplete captures
F8Stale clear-call state releases a new call’s first frames
N6±10% decode gate versus ±1% timing clamp
Its best finding

The squelch path the monitor thought it had closed

Forcing squelch open was meant to fire the stock FM path once, before the clock configuration, and never again. UNIONALPHA found the re-arm the design missed, and every step checks out.

How the stock squelch takes the codec back

The path UNIONALPHA traced, checked line by line. It runs on every pass of the main loop while the monitor is active.

StepCodeWhat happens
1. The monitor forces squelch openp25mon.c:660–676Channel squelch set to 1, a noise threshold of 70: any reading below it counts as a signal
2. The monitor turns the amplifier offp25mon.c:811, 277, 1077At start, on every call change and after 300 ms without audio
3. The main loop checks squelch firstapplicationMain.c:966–968Runs before p25monTick(), with no check for the monitor
4. The squelch re-armstrx.c:517“or if the audio amp was turned off by something else”
5. The FM audio path comes backtrx.c:572–584HR-C6000.c:2969–2972Writes 0x36=0x02 and 0x10=0x80, and switches the amplifier on
6. What restores the monitor’s statep25mon.c:642–656, 490The 5 s refresh rewrites 0x36 but not 0x10; only a decoded frame writes 0x10=0x6E back
The monitor’s own comment (p25mon.c:711–716) assumes step 4 can’t happen, and the bring-up notes say 0x10=0x80 “KILLS the codec clock” in this state (STAGE3-BRINGUP.md:356). A strong carrier keeps the noise reading low, so the path is most likely to fire during real traffic, which the bring-up sessions never had. They did camp on analog carriers near −80 dBm without reporting clock loss, so the damage is unproven until someone watches the rate report with a keyed carrier.
The front end, checked

The stages it called unmeasured

UNIONALPHA cites every register here correctly and leaves their effect as an open question. The model answers it: de-emphasis is fatal on its own, and the AT1846S set the monitor loads is the opposite of what OpenGD77 uses for 4800-baud 4FSK.

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.

What the inherited 0x34=0x3C does to C4FM

Standard C4FM through each HR-C6000 receive stage alone, then the firmware’s live demodulator and framer. 20 LDUs: 180 IMBE frames possible.

04590135180IMBE frames decoded, noise-free (of 180)no audio filtering180 / 1803 kHz low-pass only180 / 180de-emphasis only0 / 180de-emphasis + 3 kHz low-pass (0x34=0x3C)0 / 180
The 3 kHz low-pass is harmless because C4FM sits below 2.88 kHz; de-emphasis, −6 dB per octave from 300 Hz, leaves no frames on its own. Clearing 0x34 bit 5 removes it, a one-register change to try before any characterisation campaign.
Data table
Audio stageNoise-free frames · NIDsSymbol errors20 dB frames · NIDs
no audio filtering180/180 · 20/200.0%180/180 · 20/20
3 kHz low-pass only180/180 · 20/200.0%180/180 · 20/20
de-emphasis only0/180 · 0/2051.3%0/180 · 0/20
de-emphasis + 3 kHz low-pass (0x34=0x3C)0/180 · 0/2049.8%0/180 · 0/20
The CPU budget

The cost it didn’t estimate

UNIONALPHA budgets the demodulator’s filter and says the deadlines are unproven. The emulated budget shows where one second of decoding goes.

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
Claim check

35 claims, checked one by one

Thirty-three hold, two of them with gaps that matter: the filter chain called unmeasured and the CPU left uncosted. Two are qualified, and none is wrong, the best record of the clean-room audits.

ItemWhat UNIONALPHA claimsWhereResultNotes
ChecksThe host suite passes, and every clock offset from −1000 to +1000 ppm recovers 90 frames with 0/6480 raw dibit errors in the long-stream testtests/p25/run.py; review-artifacts/native-tests.logHolds104-line log, 78 PASS lines, the same as the baseline. It calls these synthetic results, not radio evidence.
ChecksThe suite runs “independent BCH/framing, signalling FEC, CRC, trunk-following, capture-module and synthetic timing tests”tests/p25/run.py; signalling.pyOverstatedThe BCH generator and DSD’s Golay, Hamming and RS classes are independent, but the trellis test re-implements the production transition table (signalling.py:84) and the trunk tests live in trunk.py.
Checkstests/p25/run.py:266–269 compiles p25mon_capture.c, not p25mon.c or the real I2S callbacktests/p25/run.py:264–270HoldsNew, and it explains why the capture’s wrong-halfword staging passed every capture test.
ChecksThe integrated firmware builds to an 810,252-byte image; linker size text 694,400, data 81,376, bss 114,072review-artifacts/firmware-build-local.log:1160HoldsIts first build log shows exactly these; the rebuild with its fix adds 24 bytes of text.
ChecksAn absolute BUILD_DIR outside the firmware tree fails because codec_bin.S:29 uses a relative .incbin pathcodec_bin.S:29Holds
ProbeCapture harness: before the fix, 158 of 160 exported samples per DMA half differ from the decoder’s left channel; after it, the export is the exact left-channel sequence, 320 samples, signed extremes intactreview-artifacts/capture_callback_test.c; cc_test-run.logHoldsRerun: PASS, exit 0, identical output. It mocks the callback rather than linking the firmware, as the review says.
F3“A regression compiled from the updated production callback passes both DMA halves with 0/160 mismatches (callback-after.log)”review-artifacts/callback-after.logDoubtfulThe review’s own probe notes call that log superseded, and its final message says it came from a harness whose frame parser read the byte pairs big-endian. The byte-exact cc_test result is the one that supports the fix.
ProbeCall-state probe: 1,120 samples (seven 20 ms frames) are emitted before a changed LDU1’s encrypted LC is processed, and a same-talkgroup source change fires no call-change callbackreview-artifacts/call_state_test.py; call-state.logHoldsRerun in a scratch tree: identical output. It drives the production receiver with DSD’s reference FEC encoders.
F1p25mon.c:724 leaves the radio in ANALOG mode, and applicationMain.c:966–968 keeps calling trxCheckAnalogSquelch() on every loop before the monitor tickp25mon.c:724; applicationMain.c:966–968, 1451HoldsNothing in the main loop checks whether the monitor is running.
F1Forcing squelch open doesn’t stop analog routing: trx.c re-arms whenever the RF amp is off and then calls radioSetAudioPath(true), which writes 0x36=0x02 and 0x10=0x80 and enables the amp; the monitor turns the amp off at start, on call changes and after idletrx.c:517–539, 572–584; HR-C6000.c:2967–2977; p25mon.c:277, 810–811, 1076–1077HoldsNew. The monitor forces squelch to sql 1 (threshold 70) and its comment (p25mon.c:711–716) assumes the path can never re-arm, but trx.c:517 re-arms “if the audio amp was turned off by something else”. So the stock path writes 0x10=0x80, the value the bring-up notes say kills the codec clock, and the 5 s refresh restores 0x36 but never 0x10. The effect on a live clock hasn’t been measured.
F2Receive-only isn’t enforced: PTT or VOX can push UI_TX_SCREEN, whose code calls trxSetTX(), and starting the monitor sets no transmit inhibitapplicationMain.c:607, 723–726, 1046–1051; uiTxScreen.c:152, 179HoldsThe monitor starts itself at boot and the PTT conditions never check it. Real, though it has nothing to do with decoding.
F3The decoder reads [j][i*2] but the capture stages 80 contiguous words per row through p25monCaptureSamples(), so it exports right-channel words and drops the second half of the left channelp25mon.c:443, 362–364, 462–465; p25mon_capture.c:466–487Holds158 of 160 staged positions differ from what the decoder consumed.
F3The stride-two layout is independently supported: Philips 16-bit-extended slave RX, halfword DMA, and stock sound.c:444 reads [i*2] and calls the right channel a duplicateCore/Src/main.c:464–471; stm32f4xx_hal_msp.c:404–411; sound.c:444Holds
F3 fixApplied fix: export the same left-channel samples the decoder consumes; staging is little-endian by construction, so no byte swap belongs in the callbackp25mon.c:460–475 (workspace diff); p25mon_capture.c:486–487Holdsp25monPcmSample is an identity cast (p25mon_utils.h:26), so the patch is exactly the first instrument fix the key calls for. The 160-byte local it adds to the DMA ISR stack is flagged in the review.
F4The monitor writes 0xE0=0xC9 and 0x26=0xFD; driver comments tie 0xC9 to Line in 1 and Mic_p, and HRC6000SetMic() toggles E0 bit 6p25mon.c:732–734; HR-C6000.c:2778, 2872, 2962–2964Holds
F4The manual’s I2S section is a codec/vocoder interface that requires an 8 kHz frame clock; the monitor’s 24 kHz setting is outside itHR_C6000_user_manual.txt:1190–1223Holds“Must be 8KHz” is at 1217–1219. No repository document mentions it.
F4The one-layer modem is worth investigating, but the BER example exports DMR-structured 36-byte frames with sync, slot and CACH fields, not a continuous ungated P25 dibit stream, and the manual can’t prove impossibility eithermanual 1350–1368, 2555–2585HoldsThe right caveat: whether one-layer mode emits frames without DMR sync is untested.
F5The AT1846S analog set enables high- and low-pass filters and clears the low-frequency bit, unlike the DMR set, and the C6000 FM setup writes de-emphasis with a 3 kHz filter; which of these matter to the I2S route is “unmeasured”AT1846S.c:191–199, 204–215; HR-C6000.c:2851UnderstatedExact lines, but these are not open questions. In the firmware-in-the-loop model de-emphasis alone leaves 0 of 180 frames and a voice high-pass stops every frame from 75 Hz, so clearing 0x34 bit 5 and loading the DMR set belong at the front of the plan.
F5p25_4fsk.c uses the same RRC as the self-test transmit pulse and the receive matched filter, so the long-stream tests exercise a favourable modelp25_4fsk.c:4–6HoldsThe circular oracle: standard C4FM through the shipped RRC gives up about 2 dB. Not quantified here.
F5Linear interpolation to 38.4 kHz cannot recover bandwidth already removed upstreamp25_resample.c:13–56HoldsTrue, and it stops short of blaming the resampler, which costs no symbols in the model.
F5A scalar FM-derived slicer may work for non-simulcast C4FM but is not a robust CQPSK/LSM receiver; a complex I/Q path is preferred for simulcast and Phase 2F5Holds“Preferred”, not “required”: the calibration the other audits missed.
F6SRAM .data 17,000 + .bss 112,532 = 129,532 of 131,072 bytes; alignment and heap/stack reservations 1,540; 0 unallocated; CCM 64,376 of 65,536 (1,160 free)build-review/MDUV380_FW.map:36666, 36941, 36965, 38287–38300HoldsMap lines check out, and it counts the 0x600 reservation as used, not free.
F6The 20 KiB FreeRTOS heap is inside .bss; the linker reserves only 512 bytes of libc heap and 1 KiB of main stackMDUV380_FW.map; STM32F405VGTX_FLASH.ldHolds
F6Stack frames from the .su files: process_imbe 640, mbe_synthesizeSpeechf 640, p25monTick 232 bytesbuild-review .su filesHolds
F7511 usable capture samples is about 21.3 ms at 24 kHz; four 32-sample passes per tick drain 128 samples, 5.33 ms of input; the 1 ms loop is a minimum interval, not a latency boundp25mon.c:93–101, 565–615; applicationMain.c:1451–1455HoldsIt avoids the “fine on a 1 ms tick” trap that caught Grok 4.6.
F7The 81-tap filter computes 688 outputs per 768-sample window every 512 samples, about 4.18 M multiply-adds per second; IMBE synthesis runs in the same path even for muted frames; the CPU runs at 72 MHzp25rx.c:105–123; Core/Src/main.c:269–274IncompleteAll true, and mbelib does synthesize muted frames. But it never estimates synthesis: emulated, mbelib IMBE needs 11–16× the 72 MHz CPU, so the filter it counts is the small part.
F7p25monAudioUnderruns is initialised and reported but never incremented; p25monFillAudioHalf() plays zeros without countingp25mon.c:123, 517, 394–430Holds
F8LDU NIDs keep the existing clear/encrypted state; if a boundary is missed, stale clear state releases a new call’s first seven frames, and decode_link_control() checks the group but not the sourcep25rx.c:290–292, 155–170, 339HoldsNew, and verified by the rerun. The one-second idle timeout is 4,800 dibits; a NAC change does end the call (p25rx.c:279), which is why the review specifies same-NAC.
F9p25monApplyClockCfg() publishes the new config before three unchecked writes; HRC6000SetPageRegByte drops the SPI status, SPI returns −1 when busy, the verified writer reports no final failure, and the refresh rewrites ten registers every 5 sp25mon.c:541–555, 624–656, 1115–1120; HR-C6000.c:2981–2983; spi.c:53–73HoldsThe unverified clock writes, found independently. A failing verified write can retry 200 × 1 ms, far past the 21 ms ring.
F9A capture stores one nominal rate and the footer repeats it; retunes, clock changes and heals don’t mark the session, so after a heal stock 8 kHz samples can be exported under a 24 kHz headerp25mon_capture.c:418–425, 266–294; p25mon.c:1108–1128HoldsA heal re-acquires on the stock clock for at least five 1 Hz reports before re-applying 24 kHz, and nothing stops the capture.
F9In active mode a closing capture drain never releases the writer: another c releases it and a further c starts a new capturep25mon.c:374–380, 1337–1371HoldsOnly the stopped-monitor branch releases it (p25mon.c:1046–1051).
Phase 2The parser is Phase 1 only and calls IMBE 7200×4400; the follower invalidates TDMA identifiers; Phase 2 needs 6,000 sym/s H-DQPSK/H-CPM, TDMA and ISCH, WACN/System ID/NAC descrambling, SACCH/FACCH and AMBE+2p25rx.c:1, 105–108; p25_trunk.c:26Holds
Phase 2DSD-FME’s p25p2_frame.c shows AMBE frame storage, descrambling and ISCH handling; neither mbelib’s AMBE routines nor the stock DMR codec establishes Phase 2 frame handling, licensing or resourcesref-sources/dsd-fme/src/p25p2_frame.c:62–80, 115–140, 362–400HoldsExact citations, and it avoids the mbelib-AMBE+2 trap. It doesn’t note that the radio’s DVSI decoder already runs AMBE+2 in real time.
Phase 2trx.c’s tuning ranges are software limits, not RF guarantees, and exclude 700/800 MHztrx.c:62–82HoldsVHF 127–178, 190–282 and 380–564 MHz.
DocsSTAGE3-BRINGUP.md’s “proven end-to-end” and waiting-only-for-traffic conclusions are not established by the source or testsSTAGE3-BRINGUP.md:388–426HoldsRight: that section calls the chain proven because analog carriers didn’t fire P25 sync, which says nothing about where the samples come from.
Provenance

What was already known, and what it added

Every clean-room audit could see only the repository: its notes, code comments and the HR-C6000 manual, summarised in the first column. UNIONALPHA raises more of the tooling and major issues than any other audit, and adds the last two rows, but only partly finds the four decode-critical issues flagged in red.

IssueProject docs
in the repository
Grok 4.6
Sep 17
DeepSeek V4.1 Flash
Sep 17
Muse Spark 1.3 Contributor
Sep 17
GLM 5.3 Flash
Sep 17
UNIONALPHA
Sep 17
Capture export stages the wrong I2S halfwordsabsentraised
§1, first code change
absent
its first experiment uses that capture
absent
trusts the capture
absent
relies on the capture
raised
F3, fixed and verified
Capture parser certifies an incomplete streamabsentabsentabsentabsent
trusts the parser
absent
relies on the parser
absent
pcm_starve never incrementsabsent
documented as working
absentabsentabsent
relies on it
absentraised
F7
No static RAM marginpartly
margins still to measure
absentraised
M6, 0 bytes free
partly
“nearly full”, from the docs
raised
F5, byte-exact map audit
raised
F6, byte-exact
The I2S stream is most likely microphone audio only partly foundpartly
open, leaning sceptical
raised
§1, 0x89 versus 0xC9
raised
B1, four-value test
raised
P1-1
partly
open, leans towards RF
partly
blocking, but never says microphone
HR-C6000 de-emphasis on the capture path only partly foundabsentraised
§2, bit 5 of 0x34
absent
calls it benign
raised
P1-2, 0x34=0x3C
raised
F2, closes the eye
partly
cited, called unmeasured
AT1846S FM filters, low-frequency bit, 25 kHz only partly foundpartly
“require characterization”
raised
§2, register level
raised
B2, filter register
raised
P1-2, 0x58 filters
partly
“voice filtering”; wrong bandwidth premise
partly
cited, called unmeasured
0x10=0x6E hybrid state; 0x36 dual rolepartly
bring-up clock rules
partly
misses 0x6E and the 0x36 clock gate
partly
“undocumented hybrid state”
partly
quiet-chip registers
raised
F4, 0x10=0x80 kills the clock
partly
F1, 0x6E against 0x80
Manual: I2S frame clock “must be 8KHz”absentraised
§3
absent
quotes the paragraph, not the rule
absentabsentraised
F4
One-layer 4FSK test mode as a P25 tappartly
stock BER-test block only
raised
Gate D
raised
B5, exact recipe
absent
dismissed
absentpartly
worth a bounded test
±10% health gate versus ±1% timing clampabsentabsentraised
B3, impact overstated
raised
P1-3
raised
F3
absent
Fail-closed muting at LDU cadenceabsentabsent
late-entry mute only
absentabsent
calls it an asset
absentabsent
Non-standard MFID mutes clear callsabsentabsentabsentabsentabsentabsent
Test waveform shares the receiver’s RRC filterpartly
“synthetic RRC/AWGN” caveat
absentpartly
tested it, says not to fix
partly
synthetic only, wants recordings
absent
would extend that model
raised
F5, unquantified
MCU runs at 72 MHzraisedraised
in passing
absentraised
P1-5
absentraised
F7
Vocoder needs 11–16× the 72 MHz CPU only partly foundpartly
decode timing unmeasured
absent
“fine on a 1 ms tick”
absent
“vocoder question settled”
partly
unmeasured; fix order backwards
absent
“in good shape”
partly
deadlines “unproven”
Direct discriminator tap (M17 mod)raisedraised
uncredited
raised
pins, timer ADC, 48 kS/s
raised
fallback, pin 9
raised
fallback
raised
fallback
Phase 2 architecture and scopepartly
not implemented
partly
misplaces the AMBE+2 decoder
partly
voice via mbelib AMBE+2
raised
with RF band limits
partly
says mbelib has no AMBE+2
raised
most accurate section
Two unverified SPI writes per decoded 20 ms frameabsentabsentabsentabsentabsent
treats them as protection
absent
Capture sessions lack epochsabsentabsentpartly
measured=0 only
absentabsentraised
F9, 8 kHz under a 24 kHz header
Ring and tick real-time budgetpartly
deadlines unproven
partly
calls it fine
absentpartly
overruns look like weak RF
absentraised
F7, 1 ms is a minimum
Clock config 3 assumes 12,288 Hz; the codec formula gives 12,000absentabsentraised
B3, clock model
absentpartly
“guessed semantics”
absent
Clock-config writes bypass the verified SPI writerpartly
SPI retry note
absentraised
M1
absentabsentraised
F9
Stock squelch re-arms FM audio (0x10=0x80) during monitoringabsent
assumes it can’t re-arm
absentabsentabsentpartly
names squelch logic as a risk
raised
F1, new
Stale clear-call state releases a new call’s first framesabsentabsentabsentabsentabsentraised
F8, probe

Project docs: README.md, CAPABILITY-REPORT.md, STAGE3-BRINGUP.md, STAGE4-TX-FEASIBILITY.md, analysis/stock-p25/REPORT.md, analysis/capture-milestone-REPORT.md, dm1701-p25-demo/README.md and source comments. The clock-config rows were first raised by DeepSeek V4.1 Flash; the last two rows are new in this audit.

Its plan

What to keep, change and add

UNIONALPHA’s six stages are ordered and gated, and none would break anything. What’s missing is the short path: the register changes and the vocoder work that decide whether any P25 voice can play.

Keep

Worth doing as written

  • Fix the instruments first: its capture patch, a counted pcm_starve, capture sessions closed or marked on every retune, clock change and heal.
  • Monitor ownership of the codec, with the integration test it proposes: switch the amplifier off with a signal present and check that no later tick selects FM feedthrough.
  • The RF-versus-microphone test with a stop rule, then a discriminator tap or one-layer probe if RF isn’t there, and never reading zero sync as an empty channel.
  • Independent C4FM fixtures in place of the self-test waveform that shares the receiver’s RRC.
Change

Would cause new problems

  • “Characterize the actual receive path before selecting filter bypass”: clear HR-C6000 0x34 bit 5 and load the AT1846S DMR set (0x58=0x9CDD, 0x40=0x0031) first; they cost one register write each.
  • “Optimize only measured bottlenecks”: the bottleneck is already known. Emulated, mbelib synthesis is 11–16× the CPU and the filter under 60%.
  • Receive-only enforcement in the first stage: worth doing, but it doesn’t affect decoding and shouldn’t queue ahead of the front end.
Add

Missing from its plan

  • A real-time vocoder: a wrapped per-harmonic phase and a cosine table in mbelib. Without it no voice plays, whatever the front end does.
  • A strict capture parser that rejects streams missing their first or last chunk, before any capture is trusted.
  • The ±10% decode gate tightened to about ±2%, since the timing loop can only track ±1%.

The one-layer path, and what it would take

Manual Table 5.6 (verified line by line), with the questions that decide whether it can carry P25.

RegisterValueManual description
0x01xxxx 0000IF receive mode
0x07 0x08 0x090x0B 0xD9 0x5424-bit IF word, 455 kHz by default (OpenGD77 boots at 450 kHz)
0x100x02One-layer mode, continuous receive; bit 5 for slot receive
0x400x40Receive enable, test mode
0x410x41Receive test enable
  • Output: 36 demodulated bytes per frame in RX RAM from 0x30, frame type in 0x51, system interrupt bit 0 when ready.
  • Rate fits exactly: 36 bytes every 30 ms is 9,600 bit/s, which is 4,800 symbols per second. Contiguous frames would lose nothing.
  • Open: Figure 5.15 is DMR burst geometry, so the mode may only emit frames after DMR sync. That’s the first thing to test.
  • Also needed: the AT1846S DMR register set, since the demodulator is fed by the AT1846S. And the vocoder still has to run in real time.
Phase 2

The most accurate section of any audit

UNIONALPHA’s Phase 2 inventory is right line by line: the parser is Phase 1 only and calls IMBE (p25rx.c:1, 105–108), the trunk follower drops TDMA identifier updates (p25_trunk.c:26), and Phase 2 needs a 6,000 symbol per second H-DQPSK/H-CPM receive path, TDMA framing with ISCH, descrambling seeded from WACN, System ID and NAC, SACCH/FACCH signalling and AMBE+2 voice. Its DSD-FME references are exact.

It avoids both traps the other audits fell into. It doesn’t treat mbelib’s AMBE+2 routines as a ready decoder, and it calls an I/Q path “preferred” rather than required. The one thing it leaves out is that the radio already runs a licensed AMBE+2 decoder for DMR in real time, so the vocoder is not the hard part of Phase 2; the demodulator, framing and descrambling are.

Who wrote it?

A hunch, from the prose alone

The model’s name was hidden. I didn’t open any session logs or transcripts, 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)60%Anthropic Claude20%Google Gemini10%Another model10%
  • Negation as rigour. Nineteen “X, not Y” antitheses and a dozen explicit non-claims: This does not validate real I2S electrical framing or ISR deadlines, This is noise/ciphertext interpreted as voice, not decrypted audio.
  • Workspace boilerplate. A dirty-tree preamble (Existing modifications were retained), a path-abbreviation key, and a note that line numbers describe the reviewed source, not generated test copies. That is the house style of Codex CLI runs.
  • Patch and prove. One minimal production patch with a byte-exact C harness, exit codes and a “reproduce with native GCC” line, rather than a list of suggested edits.
  • Register. Bold verdict lines (“Confirmed source-level integration defect.”), an impact and resolution for every finding, one table, first person only twice, and no em dashes outside headings.
  • Against the alternatives. A Claude review of this length would usually carry more tables and explanatory prose, and a Gemini review would usually state its conclusions more firmly. The withdrawn UNIONALPHA review read the same way, but subagents from another model did much of that run.
Against the other clean-room audits

Most accurate, least decisive

All five clean-room audits miss the vocoder CPU wall. UNIONALPHA makes no wrong claims where Grok 4.6, DeepSeek V4.1 Flash and Muse Spark 1.3 Contributor make two or three each, and it covers the most issues, but it fully finds one decode-critical issue where Grok 4.6 finds three.

Grok 4.6 clean-room review, graded C (70) →

Scores by dimension

Same rubric and weights. Hover or focus a bar for the score.

  • Grok 4.6
  • Muse Spark 1.3 Contributor
  • DeepSeek V4.1 Flash
  • UNIONALPHA
0255075100Score out of 100Accuracy & evidence74747390Coverage of decode problems59544965Root cause & prioritisation70726660Fix plan & acceptance gates75717070Originality & attribution72707476Clarity & calibration78807480Weighted total70686674
Method and limits

How this was checked

I read UNIONALPHA_CLEAN_REVIEW.md and its evidence folder, then checked each claim at its cited lines in the firmware sources, the OpenGD77 radio drivers, the linker map, the translated HR-C6000 manual and the repository’s notes. I reran its call-state probe in a scratch copy of the repository and recompiled its capture harness; both reproduced exactly.

Before grading I audited the run: one model for all 83 calls, no network, no skills or memory, and nothing read outside its copy of the repository. The audit’s one “git network” hit was the review’s own sentence saying it hadn’t used git fetch or pull. The run gave the model extra retries on rate-limit errors, which changes how long the harness waits, not what the model sees. It replaces the withdrawn UNIONALPHA review of 16 September, a run in which subagents from another model did much of the work. The de-emphasis and CPU results come from the model and emulator used for the Grok 4.6 audit.

  • Filter responses are modelled, not measured.
  • CPU figures come from instruction counts in an emulator, not timing on the radio.
  • The squelch path is traced in source; nobody has watched it on a radio.
  • No firmware was flashed, and no radio was touched.
python3 review-artifacts/call_state_test.py
# samples emitted before encrypted LC = 1120

gcc -O2 -o cc_test capture_callback_test.c && ./cc_test
# PASS: exact left-channel sequence, 320 samples

grep -n "turned off by something else" \
  opengd77-rt3s-experiments/MDUV380_firmware/application/source/functions/trx.c
# the re-arm the monitor's comment rules out

Scripts: deemph_check.py, c4fm_sim.py and metrics.json. They need numpy and gcc.