Bluetooth A2DP plays music to a speaker (essay 12). That’s audio leaving the phone over the air. This essay is about audio leaving the phone through the phone — earpiece, loudspeaker, headphone jack — all three wired to the same Realtek RT5640 codec on i2c1, taking I2S samples from the SoC’s I2S0 controller. RT5640 → speaker amp → cone. That’s the whole topology and it isn’t working.
The user has been consistent about this. Mid-session: “should it be out the speakers? because no.” And later, after hours of reading sound subsystem code: “no audio can we fix the display by chance?” That’s the honest tone here. The chip hardware is configured correctly. The data pipeline isn’t delivering samples. The speaker is silent.
What works
Almost every layer below “samples in the FIFO” is verified.
- RT5640 attaches at I2C 0x38, chip ID 0x6231 (
71fe3bfaudio: add RT5640 codec driver for PinePhone Pro ,147cd75audio: enable i2c1 bus and add rt5640 + sound to kernel config ). - All RT5640 registers configure correctly, verified by readback: right power state, right routing (DAC → output mixer → HP amp), right ADDA_CLK1 (
5abd6f5rt5640: fix ADDA_CLK1 (0x0000 for 256fs MCLK), set HP_VOL to 0dB ). - MCLK enabled.
b7a0829rt5640: enable MCLK (SCLK_I2S_8CH_OUT) clock gate — was gated off, codec had no clock turned theclk_i2soutgate on;0632d48clk_gate: implement get_gate (fixes "unimplemented" sysctl) rk_i2s: skip clock disable/set/enable if already at correct frequency added the missingget_gatemethod sosysctlcould report it. - BCLK and LRCK toggle on the scope. CKR is MDIV=4, TSD=64, RSD=64 → BCLK 3.072 MHz, LRCK 48 kHz. Exactly what 16-bit stereo at 48 kHz wants.
- I2S0’s TX interrupt fires, the ISR reads from
play_buf, writes 32-bit words into I2S_TXDR. 124 bytes per interrupt.
Codec configured, clocks running, frame sync ticking, ISR firing, FIFO being written. By any chip-side measurement this should produce audio.
What’s broken
The samples being written to the FIFO are zero. Every one of them.
rk_i2s TX[0]: ready=2048 level=0 readyptr=0 play_ptr=0 bufsize=192000 has_nonzero=0 (checked 64 bytes)
rk_i2s TX[0]: wrote 124 bytes, last=0x00000000 first_nonzero=0x00000000 found_nz=0
rk_i2s TX WARNING: 50 consecutive all-zero FIFO writes! Buffer data is lost somewhere.
That instrumentation is from src/sys/arm64/rockchip/rk_i2s.c:410. 7eff5a0 audio: add debug logging to trace zero-buffer issue added it. The ISR scans the first 64 bytes of the ready area for any non-zero sample and finds none. sndbuf_getready() reports 2048 bytes ready — the PCM framework believes there is data to send — but every byte in the ready area is 0x00.
The ISR isn’t broken. The FIFO write path isn’t broken. The buffer bookkeeping says we should have audio. The contents say we don’t. Something between userspace write(/dev/dsp0, …) and the hardware play buffer is silently filling with zeros.
What we tried
[WAR STORY]
Convince ourselves the chip side is fine
▸ symptom
mpg123 /tmp/song.mp3 runs, mixer levels look right, /dev/dsp0 accepts writes. Speaker silent.
▸ hypothesis 1
RT5640 misconfigured. Dumped every register, compared byte-for-byte against the Linux ALSA RT5640 init. Match. 5abd6f5 rt5640: fix ADDA_CLK1 (0x0000 for 256fs MCLK), set HP_VOL to 0dB fixed the one bit we had wrong (ADDA_CLK1); dfa399d rt5640: use MCLK directly (U-Boot configures it), remove PLL-from-BCLK ripped out the PLL-from-BCLK path because U-Boot already configures MCLK pass-through.
▸ hypothesis 2
MCLK isn’t reaching the codec. Scope says 12.288 MHz at the MCLK pin. Hardware is alive.
▸ hypothesis 3
The I2S driver isn’t writing to the FIFO. Logging added in ecc7e55 rk_i2s: add data flow logging to TX interrupt and d7cb545 rk_i2s: add debug logging to trigger, interrupt handler, and setup_intr shows the ISR fires, play_ptr advances 124 bytes per interrupt. The chip-side data path is doing its job.
▸ hypothesis 4
The codec can’t play what we’re sending — wrong format. Forced test data into the FIFO from the trigger: sine ( 7d9ddc7 rk_i2s: TEST — write sine wave directly to FIFO in trigger to test hardware path ), sawtooth ( 700acd0 rk_i2s: TEST — integer sawtooth pattern to FIFO ), 500 ms square wave ( 832e067 rk_i2s: TEST — 500ms square wave directly to FIFO to test hardware path ). None produced audio. 65fb052 rk_i2s: remove FIFO test (crashes in trigger context) reverted the test infrastructure (trigger-context writes crashed under edge cases). Inconclusive — either the speaker amp is off, or codec routing is broken in a way that survives register-dump comparison. Unproven.
That closes off the chip, clocks, I2S driver, and codec config to the limit of what we can check without a logic analyzer on the I2S data line. What’s left is the layer above: why are the bytes zero by the time the ISR sees them?
What we thought was wrong
Before any of the chip-side fixes landed, the live theory was that FreeBSD’s PCM vchan (virtual-channel) mixer was not advancing data from its input clients into the hardware channel’s play_buf.
The other problems lived in the codec analog stage and on the board. The next section is what the chip-side instrumentation was hiding.
Update: the buffer wasn’t the problem
The vchan-feeds-zeros theory was wrong — or rather, only one symptom of a deeper problem. The samples did eventually flow. They flowed into a codec whose analog output stage had no power. They flowed through the wrong output mixer. And once the analog rails were up and the right pins were unmuted, fixing the chip-side path momentarily broke the very transport this essay is being written over.
[WAR STORY]
No analog rail, wrong output, stale DTB
▸ symptom
After untold register-comparison sessions, the codec init reads back byte-for-byte identical to Linux’s rt5640.c after snd_soc_component_init. The I2S data path is verified — TX FIFO holds non-zero samples. Speaker is still silent.
▸ hypothesis 1
The earlier vchan-feeds-zeros theory. Spent days reading sys/dev/sound/pcm/vchan.c, adding prints to the mix loop, walking the channel hand-off. Vchan was fine; once the rk_i2s ISR was reading from the correct ready-pointer ( 09c23b1 rk_i2s: reset play_ptr + flush TX FIFO on PCMTRIG_START reset play_ptr on PCMTRIG_START; 4a3b61d rk_i2s: align slot timing with VDW_16 / S16_LE caps aligned slot timing with VDW_16), real samples reached the FIFO. The vchan path was carrying audio. The speaker was still silent.
▸ hypothesis 2
Speaker output path muted. Walked the SPK_L_MIXER, SPK_R_MIXER, SPO_L_MIXER, SPO_R_MIXER bits — found inverted mute polarity in the initial config ( 57d58de rt5640: fix speaker register bits — DAC_L1 and SPKVOL routes were inverted : 0x0078/0xe800/0x6800 had bit 3 set in SPK_MIXER muting DAC_L1, and bit 12 set in SPO_MIXER muting SPKVOL → SPO; flipped to 0x0036/0xd800/0x5800). After reflash: still silent. The SPO path was now correctly routed and unmuted, and the speaker was still silent.
▸ hypothesis 3
HPO_MIXER muting DAC1. The HP output mixer was being initialized to 0x4000. Mute polarity is 1=mute; bit 14 is M_DAC1_HM; 0x4000 keeps DAC1 muted into the HP output. Linux’s DAPM walk is {"HPO MIX L", "HPO MIX DAC1 Switch", "DAC L1"} — DAC1 has to reach the HP mixer for any HP-output-driven path to make sound. Changed to 0xa000 — DAC1 unmuted, DAC2 + HPVOL muted. d51515f rt5640: unmute DAC1 in HPO_MIXER (was muted = zero signal to speaker amp) Still silent, but now the question was: why is the HP mixer interesting if the speaker is on the SPO path?
▸ breakthrough
The PinePhone Pro’s speaker is not wired to SPOL/SPOR. It’s wired to HPOL/HPOR through an external simple-audio-amplifier on gpio0/PB3. SPOL/SPOR feed the earpiece. The DT confirms it: the simple-audio-amplifier node’s input rail is the codec’s HPO pins, and the pin-controlled enable goes to the speaker amp. Every mute-bit fix on the SPO path was correct in isolation and irrelevant to the symptom. The speaker amp was being fed by HPOL/HPOR, which were silent because (a) DAC1 was muted into HPO_MIXER (fixed by d51515f rt5640: unmute DAC1 in HPO_MIXER (was muted = zero signal to speaker amp) ) and (b) the HP amp itself was in a bad bias state because the prior init wrote PWR_HA|HP_L|HP_R in one shot and ran a depop sequence that left DEPOP_MAN engaged and never re-enabled DEPOP_AUTO. Replaced the whole HP power-on with the Linux mainline hp_amp_power_on + pmu_depop ordering. c5e384c rt5640: full HP power-on sequence per Linux mainline
That should have been the moment audio appeared. It wasn’t. Registers all read back correct. HPO_MIX = 0xa000. DEPOP_M2 = 0x0040 (DEPOP_AUTO). PWR_ANLG1 has HP_L|HP_R|HA|FV1|FV2 set. The codec believes it’s driving the HP pins. Scope on the HPOL/HPOR pins: nothing. No bias, no signal. The pins were dead.
Last layer: the codec’s analog supplies. vcca3v0_codec (LDO_REG1 on the RK818 PMIC) and vcca1v8_codec (LDO_REG3) feed the codec’s HP analog output stage. Linux’s rt5640 driver enables these via codec-supply DT links that don’t exist on our DT — Linux’s PMIC framework happens to bring them up at boot anyway. FreeBSD’s regulator framework is stricter: nothing references those LDOs, so they stay off. Marked both regulator-always-on / regulator-boot-on in rk3399-pinephone-pro.dts. 862b66f dts: mark vcca1v8_codec + vcca3v0_codec as always-on Reflash. Reboot.
ssh pinephone does not respond.
▸ hypothesis 4
Codec rail change broke the I2C bus, or the always-on regulators conflict with some other consumer. The phone is alive — serial shows it booted — but USB-Ethernet is gone. Spent an hour suspecting the regulator change, reverting it, the phone came back, audio went silent again. Reverting again brought USB back. So the codec rails were definitely doing it. Except they had nothing to do with USB.
▸ breakthrough
Reflashing the DTB picked up an unrelated change that had been sitting in the source tree, deployed nowhere. Commit 0eed4db had switched &usbdrd_dwc3_0’s dr_mode from peripheral to host weeks ago for a hub-enumeration test. The phone had been running on a pre-0eed4db DTB the entire time — every flash since then had been kernel-only, never DTB. Building a new DTB to ship the codec rail fix dragged the stale dr_mode = "host" along with it. DWC3 came up in host mode. There’s no host on the other end of the USB-C port; it’s a PC. CDC-Ethernet gadget never attached. SSH was gone for the cleanest possible reason: the controller wasn’t a gadget any more.
Flipped dr_mode back to peripheral in the canonical DTS. a7b237f dts: flip dr_mode back to peripheral (CDC Ethernet over USB-C) Reflash. SSH back. Audio… plays.
▸ fix
Three changes, all of them necessary, none of them visible from the chip-side instrumentation:
/* DT: codec analog rails must come up at boot */
vcca3v0_codec: LDO_REG1 {
regulator-always-on;
regulator-boot-on;
/* …existing min/max… */
};
vcca1v8_codec: LDO_REG3 {
regulator-always-on;
regulator-boot-on;
/* …existing min/max… */
};/* rt5640: route DAC1 → HP, not the muted reset default */
rt5640_write(sc, RT5640_HPO_MIXER, 0xa000);/* rt5640: full Linux-mainline HP power-on sequence,
* not the one-shot PWR_HA|HP_L|HP_R that left the amp biased badly */
/* depop pre-charge → drop FV1/FV2 → PWR_HA → settle 15ms →
* restore FV1/FV2 → power HP_L|HP_R → DAPM HPO → pmu_depop */And, separately, the DTB-deployment hazard:
&usbdrd_dwc3_0 {
dr_mode = "peripheral"; /* was "host" — broke ssh on next DTB flash */
}; ▸ lesson
Three lessons, in increasing order of generality.
One: The board topology is part of the driver. The codec datasheet has SPO and HPO pins; the phone schematic says the speaker amp is wired to HPO. If the symptom is “speaker silent” and the chip-side instrumentation is on the SPO path, you are debugging the wrong pin. Read the schematic before the datasheet.
Two: FreeBSD’s regulator framework will not enable a regulator that has no consumer. Linux often gets away with implicit always-on PMIC defaults; FreeBSD enforces the consumer link. If a chip on your board has analog rails, something in the DT has to claim them — either an explicit xxx-supply property on the consumer or regulator-always-on on the rail.
Three: DT changes that aren’t deployed are not changes; they’re tripwires. The dr_mode = "host" commit had been sitting in the tree since 0eed4db and produced no symptom because the phone was running on an older DTB. The next time anyone — for any reason — built and flashed a new DTB, that change shipped. The fix is procedural, not technical: when a DT change is committed that affects a critical-path interface like USB gadget mode, either deploy it immediately or revert it before checking in. Don’t let it accrete.
Postscript: the TLV that lied
The previous section closed on a7b237f — dr_mode flipped back to peripheral, SSH restored, “audio… plays.” It didn’t. SSH was back; the speaker was still silent. Every chip-side, DT-side, board-side fix from the last three weeks was in place and correct, and there was no sound from either the loudspeaker or the earpiece.
What followed was two more days of register-by-register comparison against the upstream Linux sound/soc/codecs/rt5640.c. Then an agent doing an exhaustive walk through that file found the bug. It was one register. It had been wrong since the very first init in 71fe3bf.
[WAR STORY]
Postscript: the TLV that lied
▸ symptom
Every readback is correct. PWR_ANLG1, PWR_ANLG2, PWR_DIG1, HPO_MIXER, OUT_L1/R1_MIXER, SPO_L/R_MIXER, DEPOP_M2, the depop sequence completes, the speaker amp’s PB3 enable is HIGH, MCLK/BCLK/LRCK on the scope. The TX FIFO holds non-zero samples. Both the HPO-fed loudspeaker and the SPO-fed earpiece are silent.
▸ hypothesis 1
Bias level transition. Linux’s rt5640_set_bias_level walks SND_SOC_BIAS_OFF → STANDBY → PREPARE → ON with specific PWR_ANLG1 writes (PWR_VREF1|PWR_MB|PWR_BG|PWR_VREF2|PWR_FV1|PWR_FV2) at each step. We were jumping straight to ON. Reproduced the staged sequence. Still silent.
▸ hypothesis 2
PLL required even though MCLK is direct. Linux configures RT5640_PLL_S_MCLK only when mclk_freq != sysclk_freq; ours match (12.288 MHz both ways) so PLL should be unused. Confirmed: PWR_PLL correctly off, GLB_CLK selects MCLK directly. Not it.
▸ hypothesis 3
Internal jack-detect muting the outputs. The codec has a JD_HP_AUTO feature where an internal comparator can auto-mute HPOL/HPOR when the jack-detect pin reads “no plug.” Read the datasheet: RT5640_JD_CTRL is at 0xbb, bit 7 enables auto-mute. Wrote 0 to bit 7, recompiled. Still silent. Then noticed I’d written to 0xb0 (EQ_CTRL1) by mistake — confused the address in my head. Reverted (5c36c05 is the correct disable; an earlier intermediate hit EQ_CTRL1 and was reverted before commit). Still silent after the actual JD disable.
▸ hypothesis 4
DAPM routing on the digital path is incomplete. The AD_DA_MIXER and OUT_MIXL/R topology bits looked off on a second read of the Linux driver. 79bbc43 opened the digital path through AD_DA_MIXER and corrected OUT_L1/R1_MIXER addresses to OUT_L3/R3_MIXER (0x4f/0x52) — the actual DAC mute switches; 0x4d/0x50 are 3-bit gain selectors and we’d been writing mute bits into a gain field. Genuine bug, fixed. Still silent.
▸ breakthrough
An agent walked sound/soc/codecs/rt5640.c end-to-end looking for anything the FreeBSD driver wasn’t doing. Found this at line 341:
static const DECLARE_TLV_DB_MINMAX(dac_vol_tlv, -6562, 0);RT5640_DAC1_DIG_VOL (register 0x19) is a TLV control with range 0..175, not inverted. Register value 0x00 = -65.62 dB. Register value 0xaf (175) = 0 dB. The reset default in the codec’s rt5640_reg[] table is 0xafaf — left and right channel both at 0 dB.
Our driver’s first action after reset was:
rt5640_write(sc, RT5640_DAC1_DIG_VOL, 0x0000);Believing 0 = “max volume, no attenuation.” It was 65.62 dB of attenuation, applied to both channels, upstream of every analog stage. Both speakers were silent for the same reason: DAC1 is upstream of all analog routing, so attenuating it 65 dB silences anything downstream — HPO path and SPO path, loudspeaker and earpiece.
That single-line bug had been masking everything else. Every fix from the prior war story — codec rails, depop sequence, HPO_MIXER unmute, SPO mute polarity — was real and necessary. None of them could ever produce sound while DAC1 was attenuated to floor.
▸ fix
The actual 4ce7a60 diff: one register value flip, one address fix, one bogus write removed.
/* rt5640: DAC1_DIG_VOL — 0 is -65.6 dB, not 0 dB. Default is 0xafaf. */
-rt5640_write(sc, RT5640_DAC1_DIG_VOL, 0x0000);
+rt5640_write(sc, RT5640_DAC1_DIG_VOL, 0xafaf);
/* rt5640: SPO mixer routing — clear bit 11 not bit 13 to route SV → SPO. */
-rt5640_write(sc, RT5640_SPO_L_MIXER, 0xd800);
+rt5640_write(sc, RT5640_SPO_L_MIXER, 0xe800);
-rt5640_write(sc, RT5640_SPO_R_MIXER, 0x5800);
+rt5640_write(sc, RT5640_SPO_R_MIXER, 0x2800);
/* rt5640: PWR_DIG2 bit 15 is PWR_ADC_SF, not a DAC bit. Drop it. */
-rt5640_update_bits(sc, RT5640_PWR_DIG2, 0x8000, 0x8000);Confirmed at 16:56 local on 2026-04-23: cat /tmp/sine.raw > /dev/dsp0 produces an audible 440 Hz tone through the loudspeaker. 4ce7a60 rt5640: fix DAC1_DIG_VOL polarity (0x0000 was -65.6dB) + OUT MIX regs
▸ lesson
When every readback is correct and every analog rail is up and there is still no sound, check TLV polarity before checking anything else. “0” means different things in different controls:
- Linear-with-mute: 0 = mute, max = max.
- Linear-no-mute: 0 = min, max = max.
- Attenuation-from-max: 0 = max, max = most attenuated.
- TLV dB scale (this case): 0 =
min_dB,n_steps= 0 dB.
The right answer lives in the upstream Linux driver’s TLV declaration — DECLARE_TLV_DB_MINMAX, DECLARE_TLV_DB_SCALE, SOC_SINGLE_TLV macros. Find those before assuming the register name is the whole story. The RT5640 datasheet calls register 0x19 “DAC1 Digital Volume” and gives a bit-field layout; it does not say “0 = -65.62 dB.” Linux’s TLV macro does.
The corollary: a single digital-path bug upstream of all analog routing will mask every correct analog fix you make. The HPO_MIXER unmute (d51515f), the codec rails (862b66f), the Linux-mainline HP power-on sequence (c5e384c) — every one of them was necessary, every one was right, and not one could produce sound while DAC1 was at floor. The fix-and-verify loop relies on each fix being independently testable. When it isn’t, you accumulate a stack of correct work invisibly behind one incorrect line, and the only signal is “still silent.” There’s no engineering escape from that — only a careful reading of the upstream driver, in full, looking for the thing you didn’t know to look for.
Postscript: volume that lied
The next bug was less dramatic but more user-visible: music was recognizable, but the hardware volume buttons did not really attenuate the speaker.
[WAR STORY]
The speaker volume bypass
▸ symptom
mixer vol=+5% changes vol.volume, and omfreebdy’s volume OSD moves, but
the loudspeaker does not track the setting. The phone also exposes only one
obvious OSS output device, so the UI hides the board-level difference between
the HP and SPO paths.
▸ breakthrough
The HPO mixer still had both paths open: direct DAC1 → HPO MIX and
DAC1 → OUT MIX → HP_VOL → HPO MIX. On PinePhone Pro, the loudspeaker amp is
fed from HPOL/HPOR, so the direct DAC1 path bypassed the very HP_VOL register
that the FreeBSD vol mixer controls. That made the OSD honest about software
state and dishonest about what the cone heard.
▸ fix
615a8a5 Route RT5640 speaker volume through HP mixer changes RT5640_HPO_MIXER from 0x8000 to
0xc000: mute DAC1 direct, leave the HP-volume route open. The driver also
exposes SOUND_MIXER_SPEAKER for the RT5640 SPO path, with the explicit caveat
that on this board SPO is the earpiece route; the audible loudspeaker remains
the HPO/vol path through the external amplifier.
▸ lesson
The mixer names are codec names, not board names. On this board, vol controls
the loudspeaker because the loudspeaker amplifier hangs off HPOL/HPOR. A
separate speaker mixer is only meaningful as the RT5640’s SPO/earpiece path.
Postscript: the amplifier that wasn’t enabled
After the TLV fix, the loudspeaker played — but only because the simple-audio-amplifier’s enable GPIO happened to drift HIGH during boot. A clean reboot would land it LOW and audio would silently fall back to inaudible. The DT had the amp node; the FreeBSD simple_amplifier driver attached; nothing was driving the GPIO on PCMTRIG.
▸ fix
3b23d2f audio: drive simple amplifier enable GPIO teaches simple_amplifier(4) to assert its enable GPIO on SOUND_MIXER_PCMTRIG_START and de-assert on _STOP — same lifecycle the codec already uses for its analog rails. be6f2f1 audio: include GPIO definitions for simple amp adds the missing <dev/gpio/gpiobusvar.h> include the new code needs. 34d27be audio: default to loudspeaker route only defaults the codec routing to “loudspeaker only” so no other path silently competes for the HPO mixer; 94c18d7 audio: isolate loudspeaker path and add RT5640 gain tuning isolates the loudspeaker path and tunes RT5640 gains; 593f456 audio: restore audible RT5640 speaker route restores the now-audible speaker route after the route-isolation refactor.
Speakers play on every boot.
What’s still open
- The proximity sensor and headphone-jack detect aren’t wired up.
- We don’t switch routing between speaker and headphones — the HPO path is on regardless of jack state.
- The earpiece (SPO) routing is now correct but the actual transduction hasn’t been validated end-to-end.
- Microphone capture isn’t wired to a DAPM walk yet.
rt5640_dai_initstill takes the codec mutex inside the I2C bus’s Giant path. Lock-order reversal, doesn’t crash, real.