Appendix · story

Work log: RT5640 capture and thermal policy

The internal microphone finally produced audible samples; TSADC soft policy, QSEL, and comparator IRQs found their safe boundary.

2026-05-06 — capture stopped wedging

The morning state was misleading. The RT5640 driver exposed pcm0: <rt5640-sound> (play/rec), but a raw read from /dev/dsp0 either blocked, returned all-zero samples, or wedged on close before the bench could collect post-capture counters. The first real fix was teardown ordering: RK I2S RX had to stop before RT5640 ADC power dropped, otherwise the controller could still be consuming clocks from a codec path that had just been powered off.

Once teardown stopped being the visible failure, the next tests moved down to clocks and trigger semantics. 5852817 rt5640: expose DMIC capture tuning knobs exposed the RT5640 soft knobs that made the bench falsifiable:

dev.rt5640.0.dmic_clk_div
dev.rt5640.0.dmic_edge
dev.rt5640.0.adc_dig_volume
dev.rt5640.0.dmic1_asrc
dev.rt5640.0.adc_asrc
dev.rt5640.0.i2s1_ref

Those knobs ruled out the obvious codec-only guesses. PostmarketOS’ ADC Capture Volume = 80, Megi/Linux divider index 4, both DMIC rising-edge bits, and the DMIC/ADC/I2S ASRC switches could all be written and read back, but they did not turn the capture into intelligible audio.

The key Linux delta was in the Rockchip controller. Linux starts TX and RX together for capture because the RK3399 I2S block is a shared serial engine. FreeBSD was starting only RX for a capture-only stream. 636743a rk_i2s: start TX with capture like Linux changed rk_i2s_dai_trigger() to start both directions for one-way streams and keep the shared engine running until neither playback nor capture is active.

That produced nonzero DMIC samples, but it exposed a second kernel bug: rt5640_dai_trigger() was still doing sleeping I2C reads while the sound channel held a non-sleepable lock. The serial console showed the exact path:

dsp_close -> chn_abort -> audio_soc_chan_trigger -> rt5640_dai_trigger

174d61f rt5640: avoid sleeping I2C in trigger made the RT5640 trigger callback sleep-free and left capture-route power in the init path. That is not the final battery policy, but it is the correct lock boundary: future start/stop power gating has to run from a taskqueue, not directly from PCM trigger context.

◐ partial At this point the kernel could record nonzero samples and close cleanly, but full-duplex monitor tests still tripped the next RK I2S sequencing issue.

2026-05-06 — thermal caps found a bad cpufreq-priority path

The first TSADC soft governor was intentionally conservative: sample from a callout, run policy on taskqueue_thread, cap Panfrost through dev.panfrost.0.gpu_max_auto_mhz, and cap CPU by writing dev.cpu.0.freq / dev.cpu.4.freq. It worked well enough to keep a glmark2 run alive, but it left one bad ownership boundary: powerd could immediately raise the CPU frequency again because both paths were ordinary user-priority cpufreq requests.

The local guard closed that race by stopping powerd during warm/hot policy. That was acceptable as a bench harness, but not as the final phone posture. The FreeBSD cpufreq core appears to have the right interface: thermal drivers can call CPUFREQ_SET(..., CPUFREQ_PRIO_KERN) and later restore with CPUFREQ_SET(..., NULL, CPUFREQ_PRIO_KERN). Kernel #191 tried that path in rk_tsadc.

◐ partial The soft policy shape is now the one we want for the phone, but the first implementation was unsafe. #191 reached root mount, then sdhci_rockchip0-slot0 repeatedly timed out reading ufs/FreeBSD_Install. Restoring kernel.prev (#190) booted the same eMMC cleanly. The cpufreq-priority integration is therefore backed out; the shipping path remains GPU max-auto caps plus dev.cpu.N.freq caps with phone_thermal_guard yielding powerd. Hardware comparator IRQs and TSHUT are still off by default until the native-q alarm semantics are proven on a recovery-attached bench.

The rollback kernel (#192) booted cleanly after the cpufreq-priority path was removed. Its first mise run thermal:phone -- soft-test also caught a userland guard bug: the guard sampled the GPU max-auto baseline after the kernel had already applied the warm 400 MHz cap, so cooldown left Panfrost capped. The guard now refreshes its baseline only while policy is normal, marks active thermal episodes explicitly, and the soft-test checks that GPU max-auto returns to the pre-test value.

2026-05-07 — TSADC comparator IRQs needed hardware QSEL

The thermal-policy gap after the cpufreq rollback was not the soft governor; it was whether the RK3399 comparator could be trusted before arming any interrupt-backed policy. 0497253 thermal: add runtime tsadc irq bench added a runtime bench instead of another boot-time trap: choose one channel, program one trip, clear pending state, enable one comparator bit, and auto-disarm on the first matching IRQ. It also refuses to run while TSHUT is armed.

Kernel #193 made the failure precise. In the old software q-select mode, code-domain trips did not fire, while native-q trips had the wrong high-temperature sense. That matched the earlier storm/no-fire evidence without leaving the phone in a bad state.

e4017e7 thermal: enable tsadc hardware qsel then enabled RK3399 hardware Q-select (TSADCV3_AUTO_Q_SEL_EN) by default. Kernel #195 booted with q_sel_enabled=1, auto_con=51, tshut_enabled=0, and clean temperature readout. a856481 thermal: tighten tsadc irq bench accounting tightened the bench so only the armed channel counts as a bench fire and native-q cases are treated as diagnostic.

◐ partial mise run thermal:phone -- irq-bench now passes the guarded comparator predicate: code-domain nofire/fire cases work on both CPU and GPU channels, the native-q cases remain the negative control, and the final state is INT_EN=0, irq_enabled=0, tshut_enabled=0, irq_bench_last_error=0. That closes comparator IRQ semantics for a guarded software path. Hardware TSHUT still needs a separate recovery-attached proof before it can be enabled at boot.

2026-05-06 — full-duplex monitor stopped wedging

The speakerphone-style test was intentionally simple:

dd if=/dev/dsp0 | dd of=/dev/dsp0

That found a controller bug independent of the RT5640 route. Once FreeBSD started TX and RX together like Linux, the old START path could clear one FIFO while the shared serial engine was already running. The symptom was I2S_CLR_TXC stuck at 1; playback returned EIO; the phone needed another reboot before the next test.

Linux does not clear FIFOs on START in this mode. It drops I2S_XFER, waits briefly, then clears both FIFOs after the last stream stops. 7367f66 rk_i2s: clear FIFOs after shared engine stop moved the FreeBSD overlay to that rule.

Kernel #189 proved the controller side:

playback-only:  exit 0
capture-only:   exit 0
full-duplex:    TX/RX frame counters advanced
I2S_XFER:       0
I2S_CLR:        0
clear_timeouts: 0

It also proved the board-level loudspeaker path again. The PinePhone Pro loudspeaker is not RT5640 SPO; it is HPOL/HPOR through the external AW8737/simple-audio-amplifier path. With mixer vol raised and mixer speaker=0, the user heard the generated tone through the phone’s speaker and dev.simpleamp.0.enabled followed playback start/stop.

● working RK I2S playback, capture, and full-duplex PCM transport now return cleanly. The remaining audio work is route policy, gain, headset, earpiece, and modem call integration.

2026-05-06 — the microphone was physically off

The last false negative was not in the kernel. Repeated mic-to-speaker tests produced feedback or static but no voice. The RT5640 and I2S counters were live, which made it look like another codec routing mistake. The actual board condition was simpler: PinePhone Pro privacy switch #3 was off, which physically disables the onboard microphones.

After enabling that switch, the bench swept the remaining live knobs. The setting that produced the first intelligible internal-mic playback was:

dev.rt5640.0.dmic_clk_div: 3
dev.rt5640.0.dmic_edge: 3
dev.rt5640.0.adc_dig_volume: 32
dev.rt5640.0.regs.dmic: 47200          # 0xb860
dev.rt5640.0.regs.adc_dig_vol: 8224    # 0x2020

0624070 rt5640: default DMIC to verified internal mic mode made that the boot default and documented the privacy-switch trap. Kernel #190 then rebooted with the new default and confirmed it without manual sysctls:

FreeBSD PinePhonePro-FreeBSD 15.0-STABLE #190
dev.rt5640.0.dmic_clk_div: 3
dev.rt5640.0.dmic_edge: 3
dev.rt5640.0.adc_dig_volume: 32
dev.rt5640.0.regs.dmic: 47200
dev.rt5640.0.regs.adc_dig_vol: 8224

The final bounded proof muted the speaker during capture, recorded 962,560 bytes from /dev/dsp0, then played the raw sample back through the loudspeaker. The kernel returned to idle:

dev.i2s.0.regs.xfer: 0
dev.i2s.0.regs.clr: 0
dev.i2s.0.tx_stats.clear_timeouts: 0
dev.i2s.0.rx_stats.clear_timeouts: 0

● working Internal DMIC capture is confirmed on the PinePhone Pro. It is not a finished phone audio stack yet: headset mic, HP detect, earpiece, echo/gain policy, and EG25-G call routing still need their own receipts.