Select the types of activity you want to include in your feed.
Standardize and align Markdown tables
Add Markdown table formatting guidelines to CLAUDE.md and reformat tables across documentation. Align pipes, pad cells to column widths, and normalize separator rows.
···41414242Determines how the left and right channels of a stereo signal are routed to the output.
43434444-| Value | Behaviour |
4545-|---|---|
4646-| **Stereo** | Leave the audio signal unmodified. |
4747-| **Mono** | Combine both channels; send the result to both outputs (monophonic). |
4848-| **Custom** | Apply the stereo width specified by the **Stereo Width** setting. |
4949-| **Mono Left** | Play the left channel in both stereo channels. |
5050-| **Mono Right** | Play the right channel in both stereo channels. |
5151-| **Karaoke** | Remove all sound common to both channels. Since vocals are typically equally present in both channels, this often (but not always) removes the voice track. May have other undesirable effects. |
5252-| **Swap Left & Right** | Play the left channel on the right output and vice versa. |
4444+| Value | Behaviour |
4545+| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
4646+| **Stereo** | Leave the audio signal unmodified. |
4747+| **Mono** | Combine both channels; send the result to both outputs (monophonic). |
4848+| **Custom** | Apply the stereo width specified by the **Stereo Width** setting. |
4949+| **Mono Left** | Play the left channel in both stereo channels. |
5050+| **Mono Right** | Play the right channel in both stereo channels. |
5151+| **Karaoke** | Remove all sound common to both channels. Since vocals are typically equally present in both channels, this often (but not always) removes the voice track. May have other undesirable effects. |
5252+| **Swap Left & Right** | Play the left channel on the right output and vice versa. |
53535454Applied via `sound_set_channels()` in `firmware/sound.c`.
5555···78787979> **Warning:** crossfeed can cause output distortion if its settings result in a combined level that is too high.
80808181-| Setting | Storage field | Range | Default | Description |
8282-|---|---|---|---|---|
8383-| **Type** | `global_settings.crossfeed` | off, meier, custom | off | `meier` uses fixed sensible defaults; `custom` exposes the four parameters below |
8484-| **Direct Gain** | `global_settings.crossfeed_direct_gain` | −60..0 dB (step 5) | −15 dB | How much to decrease the level of the signal travelling the direct path from a speaker to the corresponding ear |
8585-| **Cross Gain** | `global_settings.crossfeed_cross_gain` | −120..−30 dB (step 5) | −60 dB | How much to decrease the level of the signal travelling the cross path from a speaker to the opposite ear |
8686-| **HF Attenuation** | `global_settings.crossfeed_hf_attenuation` | −240..−60 dB (step 5) | −160 dB | How much the upper frequencies of the cross-path signal are dampened. Total high-frequency level is a combination of this setting and Cross Gain |
8787-| **HF Cutoff** | `global_settings.crossfeed_hf_cutoff` | 500..2000 Hz (step 100) | 700 Hz | Frequency at which the cross-path signal begins to be cut by the HF Attenuation amount |
8181+| Setting | Storage field | Range | Default | Description |
8282+| ------------------ | ------------------------------------------ | ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
8383+| **Type** | `global_settings.crossfeed` | off, meier, custom | off | `meier` uses fixed sensible defaults; `custom` exposes the four parameters below |
8484+| **Direct Gain** | `global_settings.crossfeed_direct_gain` | −60..0 dB (step 5) | −15 dB | How much to decrease the level of the signal travelling the direct path from a speaker to the corresponding ear |
8585+| **Cross Gain** | `global_settings.crossfeed_cross_gain` | −120..−30 dB (step 5) | −60 dB | How much to decrease the level of the signal travelling the cross path from a speaker to the opposite ear |
8686+| **HF Attenuation** | `global_settings.crossfeed_hf_attenuation` | −240..−60 dB (step 5) | −160 dB | How much the upper frequencies of the cross-path signal are dampened. Total high-frequency level is a combination of this setting and Cross Gain |
8787+| **HF Cutoff** | `global_settings.crossfeed_hf_cutoff` | 500..2000 Hz (step 100) | 700 Hz | Frequency at which the cross-path signal begins to be cut by the HF Attenuation amount |
88888989Applied via `dsp_set_crossfeed_type()` and `dsp_set_crossfeed_cross_params()` in `lib/rbcodec/dsp/crossfeed.h`.
9090···100100101101### EQ bands
102102103103-| Band | Filter type | Default centre / cutoff | Q recommendation |
104104-|---|---|---|---|
105105-| Band 0 | Low shelf filter | 32 Hz | 0.7 (higher values add an unwanted boost near cutoff) |
106106-| Bands 1–8 | Peaking (bell) filters | 64 / 125 / 250 / 500 / 1k / 2k / 4k / 8k Hz | Higher Q = narrower range; lower Q = wider range |
107107-| Band 9 | High shelf filter | 16 000 Hz | 0.7 |
103103+| Band | Filter type | Default centre / cutoff | Q recommendation |
104104+| --------- | ---------------------- | ------------------------------------------- | ----------------------------------------------------- |
105105+| Band 0 | Low shelf filter | 32 Hz | 0.7 (higher values add an unwanted boost near cutoff) |
106106+| Bands 1–8 | Peaking (bell) filters | 64 / 125 / 250 / 500 / 1k / 2k / 4k / 8k Hz | Higher Q = narrower range; lower Q = wider range |
107107+| Band 9 | High shelf filter | 16 000 Hz | 0.7 |
108108109109**Band parameters (per band):**
110110- **Cutoff / Centre frequency** — where the shelving starts (shelf bands) or the centre of the affected range (peak bands).
···113113114114### EQ sub-settings
115115116116-| Setting | Storage field | Type | Description |
117117-|---|---|---|---|
118118-| **Enable EQ** | `global_settings.eq_enabled` | bool | Master on/off switch for the software EQ |
119119-| **Precut** | `global_settings.eq_precut` | 0..24.0 dB | Global negative gain applied to decoded audio before the EQ. Prevents distortion when boosting bands. Can also be used as a volume cap. Not applied when EQ is disabled |
120120-| **Graphical EQ** | — | screen | Graphical interface for adjusting gain, centre frequency, and Q for each band |
121121-| **Simple EQ** | — | screen | Simplified view: only gain is adjustable per band |
122122-| **Advanced EQ** | — | submenu | Same parameters as Graphical EQ, via text menus |
123123-| **Save EQ Preset** | — | action | Saves current EQ configuration to a `.cfg` file |
124124-| **Browse EQ Presets** | — | screen | Lists built-in presets and any saved configurations |
116116+| Setting | Storage field | Type | Description |
117117+| --------------------- | ---------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
118118+| **Enable EQ** | `global_settings.eq_enabled` | bool | Master on/off switch for the software EQ |
119119+| **Precut** | `global_settings.eq_precut` | 0..24.0 dB | Global negative gain applied to decoded audio before the EQ. Prevents distortion when boosting bands. Can also be used as a volume cap. Not applied when EQ is disabled |
120120+| **Graphical EQ** | — | screen | Graphical interface for adjusting gain, centre frequency, and Q for each band |
121121+| **Simple EQ** | — | screen | Simplified view: only gain is adjustable per band |
122122+| **Advanced EQ** | — | submenu | Same parameters as Graphical EQ, via text menus |
123123+| **Save EQ Preset** | — | action | Saves current EQ configuration to a `.cfg` file |
124124+| **Browse EQ Presets** | — | screen | Lists built-in presets and any saved configurations |
125125126126Applied via `dsp_set_eq_precut()` and `dsp_set_eq_coefs()` in `lib/rbcodec/dsp/eq.h`.
127127···131131132132**Storage:** `global_settings.replaygain_settings`
133133134134-| Setting | Storage field | Values / Range | Default | Description |
135135-|---|---|---|---|---|
136136-| **Type** | `.type` | track, album, track shuffle, off | shuffle | Which RG tag to read for normalization |
137137-| **No-Clip** | `.noclip` | bool | false | Scale down if RG adjustment would cause clipping |
138138-| **Preamp** | `.preamp` | −120..+120 dB (step 5) | 0 dB | Additional gain applied on top of the RG value |
134134+| Setting | Storage field | Values / Range | Default | Description |
135135+| ----------- | ------------- | -------------------------------- | ------- | ------------------------------------------------ |
136136+| **Type** | `.type` | track, album, track shuffle, off | shuffle | Which RG tag to read for normalization |
137137+| **No-Clip** | `.noclip` | bool | false | Scale down if RG adjustment would cause clipping |
138138+| **Preamp** | `.preamp` | −120..+120 dB (step 5) | 0 dB | Additional gain applied on top of the RG value |
139139140140Applied via `dsp_replaygain_set_settings()` in `lib/rbcodec/dsp/dsp_misc.h`.
141141···147147148148Overlaps the end of one track with the beginning of the next.
149149150150-| Setting | Storage field | Range | Default | Description |
151151-|---|---|---|---|---|
152152-| **Mode** | `global_settings.crossfade` | off, auto track change, manual skip, shuffle, shuffle+manual, always | off | When crossfading is triggered |
153153-| **Fade-In Delay** | `global_settings.crossfade_fade_in_delay` | 0..7 s | 0 s | Silence before the fade-in begins |
154154-| **Fade-Out Delay** | `global_settings.crossfade_fade_out_delay` | 0..7 s | 0 s | Silence before the fade-out begins |
155155-| **Fade-In Duration** | `global_settings.crossfade_fade_in_duration` | 0..15 s | 2 s | Length of the fade-in ramp |
156156-| **Fade-Out Duration** | `global_settings.crossfade_fade_out_duration` | 0..15 s | 2 s | Length of the fade-out ramp |
157157-| **Fade-Out Mode** | `global_settings.crossfade_fade_out_mixmode` | crossfade, mix | crossfade | Whether the outgoing track fades out or mixes at a flat level |
150150+| Setting | Storage field | Range | Default | Description |
151151+| --------------------- | --------------------------------------------- | -------------------------------------------------------------------- | --------- | ------------------------------------------------------------- |
152152+| **Mode** | `global_settings.crossfade` | off, auto track change, manual skip, shuffle, shuffle+manual, always | off | When crossfading is triggered |
153153+| **Fade-In Delay** | `global_settings.crossfade_fade_in_delay` | 0..7 s | 0 s | Silence before the fade-in begins |
154154+| **Fade-Out Delay** | `global_settings.crossfade_fade_out_delay` | 0..7 s | 0 s | Silence before the fade-out begins |
155155+| **Fade-In Duration** | `global_settings.crossfade_fade_in_duration` | 0..15 s | 2 s | Length of the fade-in ramp |
156156+| **Fade-Out Duration** | `global_settings.crossfade_fade_out_duration` | 0..15 s | 2 s | Length of the fade-out ramp |
157157+| **Fade-Out Mode** | `global_settings.crossfade_fade_out_mixmode` | crossfade, mix | crossfade | Whether the outgoing track fades out or mixes at a flat level |
158158159159---
160160···183183184184Enabling Timestretch allows playback speed to be changed independently of pitch. Intended primarily for speech playback — may noticeably degrade the listening experience with complex music.
185185186186-| Setting | Storage field | Range | Default | Description |
187187-|---|---|---|---|---|
188188-| **Pitch** | `global_status.resume_pitch` | ~50..200 % | 100 % | Pitch shift without changing tempo |
189189-| **Speed** | `global_status.resume_speed` | ~35..250 % | 100 % | Playback speed without changing pitch |
190190-| **Timestretch Enable** | `global_settings.timestretch_enabled` | bool | false | Enables the TDHS time-domain algorithm; accessible via the Pitch Screen after reboot |
186186+| Setting | Storage field | Range | Default | Description |
187187+| ---------------------- | ------------------------------------- | ---------- | ------- | ------------------------------------------------------------------------------------ |
188188+| **Pitch** | `global_status.resume_pitch` | ~50..200 % | 100 % | Pitch shift without changing tempo |
189189+| **Speed** | `global_status.resume_speed` | ~35..250 % | 100 % | Playback speed without changing pitch |
190190+| **Timestretch Enable** | `global_settings.timestretch_enabled` | bool | false | Enables the TDHS time-domain algorithm; accessible via the Pitch Screen after reboot |
191191192192Applied via `sound_set_pitch()` and `dsp_timestretch_enable()` in `lib/rbcodec/dsp/tdspeed.h`.
193193···197197198198**Storage:** `global_settings.play_frequency` | Condition: `HAVE_PLAY_FREQ`
199199200200-| Setting | Values | Description |
201201-|---|---|---|
200200+| Setting | Values | Description |
201201+| ------------------ | ---------------------------------------- | ---------------------------------------------------------------- |
202202| **Play Frequency** | auto, 44.1 kHz, 48 kHz, 88.2 kHz, 96 kHz | Output sample rate. `auto` matches the source file's native rate |
203203204204---
···209209210210Implements the **Haas effect** with an adjustable delay time to enhance the stereo image. A full-range Haas effect creates the impression that sound starts from one channel and ends in the other. Four additional controls move the perceived stage back toward the centre:
211211212212-| Setting | Storage field | Range | Default | Description |
213213-|---|---|---|---|---|
214214-| **Enable** | `global_settings.surround_enabled` | 0, 5, 8, 10, 15, 30 ms | 0 (off) | Delay time for the Haas effect; 0 disables it |
215215-| **Balance** | `global_settings.surround_balance` | 0..99 % | 35 % | Left/right channel output ratio to re-centre the stage |
216216-| **f(x1) — HF Cutoff** | `global_settings.surround_fx1` | 600..8000 Hz (step 200) | 3400 Hz | Upper boundary of a bypass band for frequencies (mostly vocals) that are not affected by the surround processing |
217217-| **f(x2) — LF Cutoff** | `global_settings.surround_fx2` | 40..400 Hz (step 40) | 320 Hz | Lower boundary of the bypass band |
218218-| **Side Only** | `global_settings.surround_method2` | bool | false | Uses mid-side processing to apply the effect to the side channel only, leaving the centre image unmodified |
219219-| **Dry/Wet Mix** | `global_settings.surround_mix` | 0..100 % (step 5) | 50 % | Proportion of original (dry) vs effected (wet) signal in the final output |
212212+| Setting | Storage field | Range | Default | Description |
213213+| --------------------- | ---------------------------------- | ----------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- |
214214+| **Enable** | `global_settings.surround_enabled` | 0, 5, 8, 10, 15, 30 ms | 0 (off) | Delay time for the Haas effect; 0 disables it |
215215+| **Balance** | `global_settings.surround_balance` | 0..99 % | 35 % | Left/right channel output ratio to re-centre the stage |
216216+| **f(x1) — HF Cutoff** | `global_settings.surround_fx1` | 600..8000 Hz (step 200) | 3400 Hz | Upper boundary of a bypass band for frequencies (mostly vocals) that are not affected by the surround processing |
217217+| **f(x2) — LF Cutoff** | `global_settings.surround_fx2` | 40..400 Hz (step 40) | 320 Hz | Lower boundary of the bypass band |
218218+| **Side Only** | `global_settings.surround_method2` | bool | false | Uses mid-side processing to apply the effect to the side channel only, leaving the centre image unmodified |
219219+| **Dry/Wet Mix** | `global_settings.surround_mix` | 0..100 % (step 5) | 50 % | Proportion of original (dry) vs effected (wet) signal in the final output |
220220221221Applied via `dsp_surround_enable()` and related functions in `lib/rbcodec/dsp/surround.h`.
222222···228228229229Implements a group delay correction and an additional biophonic EQ to boost bass perception.
230230231231-| Setting | Storage field | Range | Default | Description |
232232-|---|---|---|---|---|
233233-| **PBE** | `global_settings.pbe` | 0..100 % (step 25) | 0 % (off) | Strength of the bass enhancement effect |
234234-| **PBE Precut** | `global_settings.pbe_precut` | −4.5..0 dB (step 0.1) | −2.5 dB | Negative overall gain applied to prevent audio distortion caused by the EQ gain. Stacks with any other EQ applied |
231231+| Setting | Storage field | Range | Default | Description |
232232+| -------------- | ---------------------------- | --------------------- | --------- | ----------------------------------------------------------------------------------------------------------------- |
233233+| **PBE** | `global_settings.pbe` | 0..100 % (step 25) | 0 % (off) | Strength of the bass enhancement effect |
234234+| **PBE Precut** | `global_settings.pbe_precut` | −4.5..0 dB (step 0.1) | −2.5 dB | Negative overall gain applied to prevent audio distortion caused by the EQ gain. Stacks with any other EQ applied |
235235236236Applied via `dsp_pbe_enable()` and `dsp_pbe_precut()` in `lib/rbcodec/dsp/pbe.h`.
237237···243243244244Human hearing is more sensitive to certain frequency bands. AFR applies additional equalization and bi-shelf filtering to reduce energy in those bands, minimising the risk of temporary threshold shift (auditory fatigue) during extended listening sessions.
245245246246-| Setting | Values | Default |
247247-|---|---|---|
248248-| **AFR Enable** | off, weak, moderate, strong | off |
246246+| Setting | Values | Default |
247247+| -------------- | --------------------------- | ------- |
248248+| **AFR Enable** | off, weak, moderate, strong | off |
249249250250Applied via `dsp_afr_enable()` in `lib/rbcodec/dsp/afr.h`.
251251···257257258258The compressor reduces the dynamic range of the audio signal by progressively reducing the gain of louder signals. When the compressed signal is subsequently amplified (via makeup gain), the quiet sections become louder while the loud sections stay below clipping. This is useful for listening to dynamic material in noisy environments.
259259260260-| Setting | Storage field | Values / Range | Default | Description |
261261-|---|---|---|---|---|
262262-| **Threshold** | `.threshold` | off, −3, −6, −9, −12, −15, −18, −21, −24 dB | off | Input level above which compression begins. The maximum compression (minimum operating level) is −24 dB |
263263-| **Makeup Gain** | `.makeup_gain` | off, auto | auto | **Off:** no re-amplification after compression. **Auto:** amplifies so the loudest post-compression signal is just below clipping, restoring perceived loudness |
264264-| **Ratio** | `.ratio` | 2:1, 4:1, 6:1, 10:1, limit | 2:1 | For every N dB above threshold, the output rises by only 1 dB. **Limit** = ∞:1 — the output cannot exceed the threshold at all |
265265-| **Knee** | `.knee` | hard, soft | soft | **Hard knee:** transition occurs precisely at the threshold. **Soft knee:** transition is smoothed over ±3 dB around the threshold |
266266-| **Attack Time** | `.attack_time` | 0..30 ms (step 5) | 5 ms | Delay between the input signal exceeding the threshold and the compressor acting on it |
267267-| **Release Time** | `.release_time` | 100..1000 ms (step 100) | 500 ms | Time for the gain to recover by 10 dB after the signal drops below the threshold. A longer release time reduces "pumping" artefacts |
260260+| Setting | Storage field | Values / Range | Default | Description |
261261+| ---------------- | --------------- | ------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
262262+| **Threshold** | `.threshold` | off, −3, −6, −9, −12, −15, −18, −21, −24 dB | off | Input level above which compression begins. The maximum compression (minimum operating level) is −24 dB |
263263+| **Makeup Gain** | `.makeup_gain` | off, auto | auto | **Off:** no re-amplification after compression. **Auto:** amplifies so the loudest post-compression signal is just below clipping, restoring perceived loudness |
264264+| **Ratio** | `.ratio` | 2:1, 4:1, 6:1, 10:1, limit | 2:1 | For every N dB above threshold, the output rises by only 1 dB. **Limit** = ∞:1 — the output cannot exceed the threshold at all |
265265+| **Knee** | `.knee` | hard, soft | soft | **Hard knee:** transition occurs precisely at the threshold. **Soft knee:** transition is smoothed over ±3 dB around the threshold |
266266+| **Attack Time** | `.attack_time` | 0..30 ms (step 5) | 5 ms | Delay between the input signal exceeding the threshold and the compressor acting on it |
267267+| **Release Time** | `.release_time` | 100..1000 ms (step 100) | 500 ms | Time for the gain to recover by 10 dB after the signal drops below the threshold. A longer release time reduces "pumping" artefacts |
268268269269Applied via `dsp_set_compressor()` in `lib/rbcodec/dsp/compressor.h`.
270270···272272273273## UI Audio Feedback
274274275275-| Setting | Storage field | Values | Default | Description |
276276-|---|---|---|---|---|
277277-| **Beep** | `global_settings.beep` | off, weak, moderate, strong | off | Audible tone on track change or key events |
278278-| **Keyclick** | `global_settings.keyclick` | off, weak, moderate, strong | off | Audio click on every key press |
279279-| **Keyclick Repeats** | `global_settings.keyclick_repeats` | bool | false | Whether held keys also produce a click |
275275+| Setting | Storage field | Values | Default | Description |
276276+| -------------------- | ---------------------------------- | --------------------------- | ------- | ------------------------------------------ |
277277+| **Beep** | `global_settings.beep` | off, weak, moderate, strong | off | Audible tone on track change or key events |
278278+| **Keyclick** | `global_settings.keyclick` | off, weak, moderate, strong | off | Audio click on every key press |
279279+| **Keyclick Repeats** | `global_settings.keyclick_repeats` | bool | false | Whether held keys also produce a click |
280280281281---
282282283283## Summary
284284285285-| Category | Setting count |
286286-|---|---|
287287-| Volume & limit | 2 |
288288-| Channel / stereo | 3 |
289289-| Crossfeed | 5 |
290290-| Software EQ (10 bands) | 12 |
291291-| ReplayGain | 3 |
292292-| Crossfade | 6 |
293293-| Dithering | 1 |
294294-| Pitch / Time-Stretch | 3 |
295295-| Output sample rate | 1 |
296296-| Haas Surround | 6 |
297297-| PBE | 2 |
298298-| AFR | 1 |
299299-| Compressor | 6 |
300300-| UI audio feedback | 3 |
301301-| **Total** | **~54** |
285285+| Category | Setting count |
286286+| ---------------------- | ------------- |
287287+| Volume & limit | 2 |
288288+| Channel / stereo | 3 |
289289+| Crossfeed | 5 |
290290+| Software EQ (10 bands) | 12 |
291291+| ReplayGain | 3 |
292292+| Crossfade | 6 |
293293+| Dithering | 1 |
294294+| Pitch / Time-Stretch | 3 |
295295+| Output sample rate | 1 |
296296+| Haas Surround | 6 |
297297+| PBE | 2 |
298298+| AFR | 1 |
299299+| Compressor | 6 |
300300+| UI audio feedback | 3 |
301301+| **Total** | **~54** |
+24-6
CLAUDE.md
···11# CLAUDE.md — Rockbox Zig
2233+## Markdown formatting
44+55+### Tables
66+77+Always align markdown tables so that column pipes line up in raw text. Every cell in a column must be padded with trailing spaces to the width of the widest cell in that column. The separator row must use the same number of dashes as the column width. Example:
88+99+```markdown
1010+| Name | Role | Notes |
1111+| ------- | --------- | ---------------------------- |
1212+| Alice | Engineer | Owns the firmware layer |
1313+| Bob | Designer | Works on the mobile UI |
1414+| Charlie | QA | Runs integration test suites |
1515+```
1616+1717+When editing an existing table, re-align the whole table (not just the changed row). When adding a new table, align it before committing.
1818+1919+---
2020+321## Project overview
422523Rockbox Zig is a modern wrapper around the [Rockbox](https://www.rockbox.org) open-source audio player firmware. It adds Rust/Zig services on top of the C firmware to expose gRPC, GraphQL, HTTP, and MPD APIs, a Typesense-backed search engine, Chromecast/AirPlay/Snapcast/Squeezelite output sinks, and a desktop/web UI.
···114132115133The audio output abstraction lives in `firmware/export/pcm_sink.h`. Each sink implements `struct pcm_sink_ops` (init / postinit / set_freq / lock / unlock / play / stop).
116134117117-| Enum constant | Value | Implementation file |
118118-|--------------------|-------|---------------------------------------------|
119119-| `PCM_SINK_BUILTIN` | 0 | `firmware/target/hosted/sdl/pcm-sdl.c` |
120120-| `PCM_SINK_FIFO` | 1 | `firmware/target/hosted/pcm-fifo.c` |
121121-| `PCM_SINK_AIRPLAY` | 2 | `firmware/target/hosted/pcm-airplay.c` |
122122-| `PCM_SINK_SQUEEZELITE` | 3 | `firmware/target/hosted/pcm-squeezelite.c` |
135135+| Enum constant | Value | Implementation file |
136136+| ---------------------- | ----- | ------------------------------------------ |
137137+| `PCM_SINK_BUILTIN` | 0 | `firmware/target/hosted/sdl/pcm-sdl.c` |
138138+| `PCM_SINK_FIFO` | 1 | `firmware/target/hosted/pcm-fifo.c` |
139139+| `PCM_SINK_AIRPLAY` | 2 | `firmware/target/hosted/pcm-airplay.c` |
140140+| `PCM_SINK_SQUEEZELITE` | 3 | `firmware/target/hosted/pcm-squeezelite.c` |
123141124142`crates/settings/src/lib.rs:load_settings()` reads `audio_output` and calls `pcm::switch_sink()`.
125143
+32-32
HEADLESS.md
···45454646Target 206 is `headlesshost`. `tools/configure` sets:
47474848-| Variable | Value | Why |
4949-|---|---|---|
5050-| `TARGET` | `-DHEADLESSHOST` | Selects headless firmware path |
5151-| `APP_TYPE` | `headless_host` | Headless Make rules |
5252-| `CODECS_STATIC` | `1` | Static codec linking (see below) |
5353-| `EXTRA_DEFINES` | `-DCODECS_STATIC -DZIG_APP -DAPPLICATION` | Propagated into C flags |
5454-| `OC` | `llvm-objcopy` (from llvm@21) | Safe Mach-O symbol renaming (see below) |
4848+| Variable | Value | Why |
4949+| --------------- | ----------------------------------------- | --------------------------------------- |
5050+| `TARGET` | `-DHEADLESSHOST` | Selects headless firmware path |
5151+| `APP_TYPE` | `headless_host` | Headless Make rules |
5252+| `CODECS_STATIC` | `1` | Static codec linking (see below) |
5353+| `EXTRA_DEFINES` | `-DCODECS_STATIC -DZIG_APP -DAPPLICATION` | Propagated into C flags |
5454+| `OC` | `llvm-objcopy` (from llvm@21) | Safe Mach-O symbol renaming (see below) |
55555656The configure script searches for `llvm@21` first, then falls back to the generic `llvm` formula. **It explicitly avoids `llvm@22`** due to a crash described below.
5757···258258259259**Fix** (`lib/rbcodec/codecs/libm4a/demux.c`):
260260261261-| Old pattern | Replacement | Why it works |
262262-|---|---|---|
263263-| `stream_read(stream, 4, char filetype[4])` — result unused | `stream_read_uint32(stream)` — result stored in a `uint32_t` variable | Return value is a live read; optimizer cannot eliminate it |
264264-| 4 discarded `stream_read_uint8/int32` calls in `read_chunk_esds` (13 bytes) | `stream_skip(stream, 1/4/4/4)` | `stream_skip` calls `ci->advance_buffer` directly — no destination buffer, no dead-write to eliminate |
265265-| `stream_read(stream, codecdata_len, codecdata)` bulk read | Byte-by-byte loop: `codecdata[i] = stream_read_uint8(stream)` | Each `stream_read_uint8` return value is stored into the array element that IS used by the decoder |
261261+| Old pattern | Replacement | Why it works |
262262+| --------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
263263+| `stream_read(stream, 4, char filetype[4])` — result unused | `stream_read_uint32(stream)` — result stored in a `uint32_t` variable | Return value is a live read; optimizer cannot eliminate it |
264264+| 4 discarded `stream_read_uint8/int32` calls in `read_chunk_esds` (13 bytes) | `stream_skip(stream, 1/4/4/4)` | `stream_skip` calls `ci->advance_buffer` directly — no destination buffer, no dead-write to eliminate |
265265+| `stream_read(stream, codecdata_len, codecdata)` bulk read | Byte-by-byte loop: `codecdata[i] = stream_read_uint8(stream)` | Each `stream_read_uint8` return value is stored into the array element that IS used by the decoder |
266266267267**Rule going forward**: in CODECS_STATIC (or any no-`-fPIC`) build, never call `stream_read(stream, N, buf)` where `buf` is a local variable not subsequently read by the caller. Use `stream_read_uint8/uint16/uint32` (live return values) or `stream_skip` (no buffer at all) for all bytes you intend to discard.
268268···294294295295## File Map
296296297297-| File | Purpose |
298298-|---|---|
299299-| `scripts/build-headless.sh` | Full build script — configure, make, cargo, zig |
300300-| `tools/configure` | Rockbox configure script; `headlesshostcc()` sets up the headless target and finds `llvm@21` |
301301-| `firmware/export/config/headlesshost.h` | C config header for the headless target |
302302-| `firmware/target/hosted/headless/` | Headless-specific C sources (PCM sink, codec loader, etc.) |
303303-| `firmware/target/hosted/headless/lc-headless.c` | `lc_static_table[]` — maps codec names to `__header_*` pointers |
304304-| `firmware/target/hosted/headless/pcm-cpal.c` | C side of the cpal PCM sink; calls `pcm_cpal_push()` / `pcm_cpal_set_sample_rate()` |
305305-| `lib/rbcodec/codecs/codecs.make` | Per-codec build rules; `CODECS_STATIC` block (line 273+) handles symbol renaming |
306306-| `lib/rbcodec/codecs/lib/codeclib.c` | `codec_init`, `codec_malloc`, `bs_clz_tab`, etc. → compiled into `libcodec.a` |
307307-| `crates/cpal-sink/src/lib.rs` | Rust cpal backend — ring buffer, resampler, stream negotiation |
308308-| `zig/build.zig` | Zig linker script — `headless` block lists all `.o` files and `.a` archives |
297297+| File | Purpose |
298298+| ----------------------------------------------- | -------------------------------------------------------------------------------------------- |
299299+| `scripts/build-headless.sh` | Full build script — configure, make, cargo, zig |
300300+| `tools/configure` | Rockbox configure script; `headlesshostcc()` sets up the headless target and finds `llvm@21` |
301301+| `firmware/export/config/headlesshost.h` | C config header for the headless target |
302302+| `firmware/target/hosted/headless/` | Headless-specific C sources (PCM sink, codec loader, etc.) |
303303+| `firmware/target/hosted/headless/lc-headless.c` | `lc_static_table[]` — maps codec names to `__header_*` pointers |
304304+| `firmware/target/hosted/headless/pcm-cpal.c` | C side of the cpal PCM sink; calls `pcm_cpal_push()` / `pcm_cpal_set_sample_rate()` |
305305+| `lib/rbcodec/codecs/codecs.make` | Per-codec build rules; `CODECS_STATIC` block (line 273+) handles symbol renaming |
306306+| `lib/rbcodec/codecs/lib/codeclib.c` | `codec_init`, `codec_malloc`, `bs_clz_tab`, etc. → compiled into `libcodec.a` |
307307+| `crates/cpal-sink/src/lib.rs` | Rust cpal backend — ring buffer, resampler, stream negotiation |
308308+| `zig/build.zig` | Zig linker script — `headless` block lists all `.o` files and `.a` archives |
309309310310---
311311312312## Rebuild After Changes
313313314314-| Changed | Command |
315315-|---|---|
316316-| Any C firmware file | `cd build-headless && make lib OC=...` then `cd zig && zig build -Dheadless=true` |
317317-| `lc-headless.c` or codec C files | Same as above |
318318-| `crates/cpal-sink/` | `cargo build --release --features cpal-sink -p rockbox-cli` then `zig build` |
319319-| Any other Rust crate | `cargo build --release -p rockbox-cli -p rockbox-server` then `zig build` |
320320-| `zig/build.zig` | `cd zig && zig build -Dheadless=true` |
321321-| Everything | `bash scripts/build-headless.sh` |
314314+| Changed | Command |
315315+| -------------------------------- | --------------------------------------------------------------------------------- |
316316+| Any C firmware file | `cd build-headless && make lib OC=...` then `cd zig && zig build -Dheadless=true` |
317317+| `lc-headless.c` or codec C files | Same as above |
318318+| `crates/cpal-sink/` | `cargo build --release --features cpal-sink -p rockbox-cli` then `zig build` |
319319+| Any other Rust crate | `cargo build --release -p rockbox-cli -p rockbox-server` then `zig build` |
320320+| `zig/build.zig` | `cd zig && zig build -Dheadless=true` |
321321+| Everything | `bash scripts/build-headless.sh` |
322322323323> **Stale binary pitfall**: `zig build` only re-links if the `.a` files are newer than the binary. Always rebuild Make/Cargo before running `zig build` after changing C or Rust code.
+23-23
README.md
···174174175175## 🔌 Ports
176176177177-| Service | Default port | Protocol |
178178-|---------------------------------------|--------------|-----------------|
179179-| gRPC | 6061 | gRPC / gRPC-Web |
180180-| GraphQL + Web UI | 6062 | HTTP |
181181-| HTTP REST API | 6063 | HTTP |
182182-| MPD server | 6600 | MPD protocol |
183183-| Slim Protocol (squeezelite) | 3483 | TCP |
184184-| HTTP PCM stream (squeezelite) | 9999 | HTTP |
185185-| Chromecast WAV stream | 7881 | HTTP |
186186-| UPnP Media Server (ContentDirectory) | 7878 | HTTP / SSDP |
187187-| UPnP WAV broadcast (PCM sink) | 7879 | HTTP |
188188-| UPnP MediaRenderer (AVTransport) | 7880 | HTTP / SSDP |
177177+| Service | Default port | Protocol |
178178+| ------------------------------------ | ------------ | --------------- |
179179+| gRPC | 6061 | gRPC / gRPC-Web |
180180+| GraphQL + Web UI | 6062 | HTTP |
181181+| HTTP REST API | 6063 | HTTP |
182182+| MPD server | 6600 | MPD protocol |
183183+| Slim Protocol (squeezelite) | 3483 | TCP |
184184+| HTTP PCM stream (squeezelite) | 9999 | HTTP |
185185+| Chromecast WAV stream | 7881 | HTTP |
186186+| UPnP Media Server (ContentDirectory) | 7878 | HTTP / SSDP |
187187+| UPnP WAV broadcast (PCM sink) | 7879 | HTTP |
188188+| UPnP MediaRenderer (AVTransport) | 7880 | HTTP / SSDP |
189189190190---
191191···412412413413#### All UPnP settings
414414415415-| Key | Default | Description |
416416-|----------------------------|--------------|------------------------------------------------|
417417-| `audio_output = "upnp"` | — | Enable the PCM → WAV streaming sink |
418418-| `upnp_renderer_url` | — | AVTransport controlURL of the target renderer |
419419-| `upnp_http_port` | `7879` | WAV broadcast HTTP port |
420420-| `upnp_server_enabled` | `false` | Start the ContentDirectory media server |
421421-| `upnp_server_port` | `7878` | Media server HTTP port |
422422-| `upnp_renderer_enabled` | `false` | Start the MediaRenderer endpoint |
423423-| `upnp_renderer_port` | `7880` | MediaRenderer HTTP port |
424424-| `upnp_friendly_name` | `"Rockbox"` | Display name shown to control points |
415415+| Key | Default | Description |
416416+| ----------------------- | ----------- | --------------------------------------------- |
417417+| `audio_output = "upnp"` | — | Enable the PCM → WAV streaming sink |
418418+| `upnp_renderer_url` | — | AVTransport controlURL of the target renderer |
419419+| `upnp_http_port` | `7879` | WAV broadcast HTTP port |
420420+| `upnp_server_enabled` | `false` | Start the ContentDirectory media server |
421421+| `upnp_server_port` | `7878` | Media server HTTP port |
422422+| `upnp_renderer_enabled` | `false` | Start the MediaRenderer endpoint |
423423+| `upnp_renderer_port` | `7880` | MediaRenderer HTTP port |
424424+| `upnp_friendly_name` | `"Rockbox"` | Display name shown to control points |
425425426426---
427427···473473[Releases page](https://github.com/tsirysndr/rockbox-zig/releases/latest).
474474475475| Platform | Architecture | Package |
476476-|----------|-------------------------|-----------|
476476+| -------- | ----------------------- | --------- |
477477| Linux | x86_64 | `.tar.gz` |
478478| Linux | aarch64 | `.tar.gz` |
479479| macOS | x86_64 | `.pkg` |
+28-28
SNAPCAST.md
···5566Two complementary sinks are available:
7788-| Sink | Setting value | Transport | Snapserver source type |
99-|------|--------------|-----------|------------------------|
1010-| FIFO / pipe | `audio_output = "fifo"` | Named FIFO or stdout | `pipe://` |
1111-| TCP (direct) | `audio_output = "snapcast_tcp"` | TCP socket | `tcp://` |
88+| Sink | Setting value | Transport | Snapserver source type |
99+| ------------ | ------------------------------- | -------------------- | ---------------------- |
1010+| FIFO / pipe | `audio_output = "fifo"` | Named FIFO or stdout | `pipe://` |
1111+| TCP (direct) | `audio_output = "snapcast_tcp"` | TCP socket | `tcp://` |
12121313The **FIFO sink** is the traditional approach: rockboxd writes to a named pipe
1414that snapserver reads. The **TCP sink** connects directly to snapserver's TCP
···54545555## Choosing FIFO vs TCP
56565757-| | FIFO sink | TCP sink |
5858-|---|---|---|
5959-| Filesystem entry required | Yes (`/tmp/snapfifo`) | No |
6060-| Snapserver source type | `pipe://` | `tcp://` |
6161-| Startup order sensitive | Yes — rockboxd first | Yes — snapserver first |
6262-| Reconnect on snapserver restart | No (FIFO stays open) | Yes (auto on next play) |
6363-| Auto-discovered in UI | No (static virtual device) | Yes (mDNS `_snapcast._tcp.local.`) |
6464-| stdout pipe support | Yes (`fifo_path = "-"`) | No |
6565-| Config | `fifo_path` | `snapcast_tcp_host` + `snapcast_tcp_port` |
5757+| | FIFO sink | TCP sink |
5858+| ------------------------------- | -------------------------- | ----------------------------------------- |
5959+| Filesystem entry required | Yes (`/tmp/snapfifo`) | No |
6060+| Snapserver source type | `pipe://` | `tcp://` |
6161+| Startup order sensitive | Yes — rockboxd first | Yes — snapserver first |
6262+| Reconnect on snapserver restart | No (FIFO stays open) | Yes (auto on next play) |
6363+| Auto-discovered in UI | No (static virtual device) | Yes (mDNS `_snapcast._tcp.local.`) |
6464+| stdout pipe support | Yes (`fifo_path = "-"`) | No |
6565+| Config | `fifo_path` | `snapcast_tcp_host` + `snapcast_tcp_port` |
66666767**Use FIFO** when you want stdout piping or prefer the traditional pipe model.
6868···106106`firmware/target/hosted/pcm-fifo.c` implements `struct pcm_sink`:
107107108108| Op | Implementation |
109109-|-------------------|-------------------------------------------------------------|
109109+| ----------------- | ----------------------------------------------------------- |
110110| `init` | `pthread_mutex_init` (recursive) |
111111| `postinit` | no-op |
112112| `set_freq` | no-op (output is always 44100 Hz; snapserver must match) |
···239239240240`firmware/target/hosted/pcm-tcp.c` implements `struct pcm_sink`:
241241242242-| Op | Implementation |
243243-|-------------------|-------------------------------------------------------------|
244244-| `init` | `pthread_mutex_init` (recursive) |
245245-| `postinit` | no-op |
246246-| `set_freq` | no-op (output is always 44100 Hz; snapserver must match) |
247247-| `lock` / `unlock` | `pthread_mutex_lock/unlock` |
248248-| `play` | `sink_dma_start` — connects if needed, spawns `tcp_thread` |
249249-| `stop` | `sink_dma_stop` — signals thread, joins; keeps socket open |
242242+| Op | Implementation |
243243+| ----------------- | ---------------------------------------------------------- |
244244+| `init` | `pthread_mutex_init` (recursive) |
245245+| `postinit` | no-op |
246246+| `set_freq` | no-op (output is always 44100 Hz; snapserver must match) |
247247+| `lock` / `unlock` | `pthread_mutex_lock/unlock` |
248248+| `play` | `sink_dma_start` — connects if needed, spawns `tcp_thread` |
249249+| `stop` | `sink_dma_stop` — signals thread, joins; keeps socket open |
250250251251`tcp_pcm_sink` is registered at index `PCM_SINK_SNAPCAST_TCP = 6` in
252252`firmware/pcm.c`.
···395395396396### All Snapcast settings keys
397397398398-| Key | Type | Default | Sink | Description |
399399-|----------------------|--------|-----------------------|-------|------------------------------------------|
400400-| `audio_output` | string | `"builtin"` | both | `"fifo"` or `"snapcast_tcp"` |
401401-| `fifo_path` | string | `"/tmp/rockbox.fifo"` | FIFO | FIFO path, or `"-"` for stdout |
402402-| `snapcast_tcp_host` | string | — | TCP | IP / hostname of the snapserver machine |
403403-| `snapcast_tcp_port` | u16 | `4953` | TCP | snapserver TCP source port |
398398+| Key | Type | Default | Sink | Description |
399399+| ------------------- | ------ | --------------------- | ---- | --------------------------------------- |
400400+| `audio_output` | string | `"builtin"` | both | `"fifo"` or `"snapcast_tcp"` |
401401+| `fifo_path` | string | `"/tmp/rockbox.fifo"` | FIFO | FIFO path, or `"-"` for stdout |
402402+| `snapcast_tcp_host` | string | — | TCP | IP / hostname of the snapserver machine |
403403+| `snapcast_tcp_port` | u16 | `4953` | TCP | snapserver TCP source port |
404404405405---
406406
+203-24
THREADING.md
···991010Understanding the boundary between these two classes is critical. Crossing it incorrectly (e.g. doing a plain OS-level block inside a Rockbox kernel thread) will silently starve every other Rockbox kernel thread.
11111212+The scheduler implementation differs between the **SDL hosted target** (desktop, macOS/Linux) and the **headless Android cdylib** target. Both share the same C-level `create_thread` / kernel-thread API, but the underlying concurrency primitive is different.
1313+1214---
13151414-## The SDL Cooperative Scheduler
1616+## The SDL Cooperative Scheduler (desktop — macOS / Linux)
15171616-On the `sdlapp` hosted target (macOS / Linux desktop), each Rockbox kernel thread is backed by a real SDL OS thread (`SDL_CreateThread`). However, Rockbox layers a **cooperative scheduler** on top of those OS threads using a single global SDL mutex:
1818+On the `sdlapp` hosted target, each Rockbox kernel thread is backed by a real SDL OS thread (`SDL_CreateThread`). However, Rockbox layers a **cooperative scheduler** on top of those OS threads using a single global SDL mutex:
17191820```c
1921// firmware/target/hosted/sdl/thread-sdl.c
···55575658---
57596060+## The Headless Scheduler (Android cdylib)
6161+6262+The Android cdylib target (`firmware/target/hosted/android/cdylib/`) replaces SDL entirely with plain pthreads and POSIX clock. There is **no SDL mutex**, no event loop, no LCD, no button polling.
6363+6464+### `system-android.c` vs `system-sdl.c`
6565+6666+| Concern | SDL hosted | Android cdylib |
6767+| ----------------- | ----------------------------------- | ------------------------------------------------------- |
6868+| Cooperative token | `SDL_mutex *m` (single global) | `__cores[0].running` (global current-thread pointer) |
6969+| Thread creation | `SDL_CreateThread` | `pthread_create` |
7070+| Scheduler yield | `SDL_UnlockMutex` / `SDL_LockMutex` | Rockbox kernel pthread mutex round-trip |
7171+| Boot path | SDL event thread initialises audio | `rb_daemon_start` (JNI) spawns `rockbox-engine` pthread |
7272+| Power-off | SDL_QUIT event loop | `exit(0)` via `power_off()` |
7373+| stdio | terminal / controlled | piped to logcat via `redirect_stdio_to_logcat` |
7474+7575+### `__cores[0].running` — the critical invariant
7676+7777+On the headless pthread target, the Rockbox kernel tracks the currently-executing kernel thread via the global `__cores[0].running` pointer. **Any firmware function that calls `queue_send`, `wakeup_thread`, `pcmbuf_*`, or any kernel primitive reads this pointer to find its own thread entry.**
7878+7979+If such a function is called from a non-Rockbox pthread (e.g. an actix worker or a tonic gRPC handler) the pointer resolves to the wrong thread entry. Consequences:
8080+- `wakeup_thread_` dereferences a stale or null function pointer → **SIGSEGV at PC=0**
8181+- Kernel scheduler state is silently corrupted
8282+- Symptoms may appear seconds later, during a track switch or settings change
8383+8484+This is why **all firmware-mutating calls from Rust handlers must go through the firmware-command bus** (see below).
8585+8686+### Daemon boot sequence (Android)
8787+8888+`rb_daemon_start(configDir, musicDir, deviceName)` in `crates/expo/src/daemon.rs`:
8989+9090+1. Atomically transitions `STATE`: `STOPPED → STARTING` (returns `-114` if already running).
9191+2. Installs the tracing-android logcat subscriber (idempotent).
9292+3. Sets env vars: `HOME`, `ROCKBOX_LIBRARY`, `TMPDIR`, `ROCKBOX_PORT`, `ROCKBOX_GRAPHQL_PORT`, `ROCKBOX_TCP_PORT`, `ROCKBOX_MPD_PORT`.
9393+4. Spawns `rockbox-engine` pthread (2 MB stack) that calls `main_c()` wrapped in `catch_unwind`.
9494+5. Polls TCP `127.0.0.1:<port>` every 50 ms, up to 30 s, waiting for gRPC to bind.
9595+6. On success: stores port in `LOCAL_PORT`, transitions to `RUNNING`, sets `SERVER_URL` if not already overridden by JS, spawns `rockbox-library-scan` thread.
9696+7. Returns the bound gRPC port (positive) or a negative error code (`-110` = timeout, `-114` = already running).
9797+9898+---
9999+100100+## The Firmware-Command Bus (`crates/server/src/fw_bus.rs`)
101101+102102+### Problem
103103+104104+gRPC / HTTP handlers run on actix workers or tonic tasks — plain Rust OS threads. Calling firmware FFI directly from these threads violates the `__cores[0].running` invariant and causes the SIGSEGV / scheduler corruption described above. This affects both the Android cdylib and any future hosted-pthread build.
105105+106106+### Solution
107107+108108+All kernel-mutating calls are serialised through a single `std::sync::mpsc` channel. **Only the broker thread drains this channel.** The broker IS a real Rockbox kernel thread (spawned by `apps/broker_thread.c::create_thread`), so its firmware calls always run with a valid `__running_self_entry()`.
109109+110110+```
111111+actix worker / tonic task
112112+ → fw_bus::send(FwCmd::Play { elapsed, offset, reply })
113113+ → mpsc channel
114114+ → broker thread (real Rockbox kernel thread)
115115+ → rb::playback::play(elapsed, offset)
116116+ → reply_tx.send(())
117117+ ← fw_bus::send_and_wait blocks until reply arrives (≤ 30 s)
118118+```
119119+120120+### API
121121+122122+| Function | Use case |
123123+| ------------------------------ | -------------------------------------------------------------------- |
124124+| `fw_bus::init()` | Call once at startup, before broker thread spawns |
125125+| `fw_bus::send(cmd)` | Fire-and-forget (no reply needed) |
126126+| `fw_bus::send_and_wait(make)` | Send + block until broker confirms (for actix `web::block` handlers) |
127127+| `fw_bus::run_on_broker(f)` | Run arbitrary closure on broker; returns `T::default()` on timeout |
128128+| `fw_bus::try_run_on_broker(f)` | Same but returns `Option<T>` — `None` on timeout |
129129+| `fw_bus::drain(rx)` | Called once per broker iteration to execute pending commands |
130130+131131+### `FwCmd` variants
132132+133133+`Play`, `Pause`, `Resume`, `Next`, `Prev`, `Stop`, `FfRewind`, `FlushAndReloadTracks`, `SetCrossfade`, and `Custom(Box<dyn FnOnce() + Send>)` (escape hatch for anything not enumerated).
134134+135135+### Timeout behaviour
136136+137137+The broker tick period is ~10 ms at idle (the `rb::system::sleep(HZ/10)` in the broker loop). `BROKER_TIMEOUT` is 30 s — generous because building a large playlist can take several seconds. On timeout `run_on_broker` logs a warning and returns `T::default()` rather than panicking; `try_run_on_broker` returns `None`.
138138+139139+### Where fw_bus is NOT needed
140140+141141+- Read-only status queries (`rb::playback::status()`, `rb::playback::current_track()`) — these only read atomics or copy structs; no kernel primitive is called.
142142+- Anything running inside the broker thread itself (it already owns `__running_self_entry()`).
143143+- Desktop SDL builds — the SDL mutex serialises everything anyway. The bus is still compiled in and works correctly; it just adds one channel hop of latency.
144144+145145+---
146146+58147## Thread Map
5914860149### Rockbox kernel threads (must yield)
611506262-| Thread | C entry point | What it does |
6363-|--------|---------------|--------------|
6464-| `server_thread` | `server_thread.c` → `start_server()` | Spawns the actix HTTP server in a Rust OS thread, then loops yielding to the Rockbox scheduler |
6565-| `broker_thread` | `broker_thread.c` → `start_broker()` | Event loop: publishes playback state to GraphQL subscriptions, scrobbles tracks, restores playlist |
151151+| Thread | C entry point | What it does |
152152+| --------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
153153+| `server_thread` | `server_thread.c` → `start_server()` | Spawns the actix HTTP server in a Rust OS thread, then loops yielding to the Rockbox scheduler |
154154+| `broker_thread` | `broker_thread.c` → `start_broker()` | Event loop: drains `fw_bus`, publishes playback state to GraphQL subscriptions, scrobbles tracks, restores playlist |
661556767-Both threads must call `rb::system::sleep(rb::HZ)` on every loop iteration.
156156+Both threads must call `rb::system::sleep(rb::HZ)` (desktop) or equivalent on every loop iteration.
6815769158**`server_thread` pattern** (`crates/server/src/lib.rs`):
70159```rust
···89178**`broker_thread` pattern** (`crates/server/src/lib.rs`):
90179```rust
91180pub extern "C" fn start_broker() {
9292- // ... setup ...
181181+ let fw_rx = fw_bus::take_receiver();
93182 loop {
9494- // ... do work (check playback, publish events) ...
183183+ // Drain firmware commands from actix/gRPC handlers first.
184184+ if let Some(rx) = &fw_rx {
185185+ fw_bus::drain(rx);
186186+ }
187187+ // ... publish events, scrobble, restore playlist ...
95188 thread::sleep(std::time::Duration::from_millis(100));
9696- rb::system::sleep(rb::HZ); // yield the Rockbox mutex
189189+ rb::system::sleep(rb::HZ);
97190 }
98191}
99192```
100193101194### Rust OS threads (no Rockbox scheduler involvement)
102195103103-These are spawned with `std::thread::spawn` — they are pure OS threads and never interact with the Rockbox mutex. They are free to block indefinitely.
196196+These are spawned with `std::thread::spawn` — they are pure OS threads and never interact with the Rockbox scheduler token or `__cores[0].running`.
197197+198198+| Thread / component | Spawned from | Runtime |
199199+| ---------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------- |
200200+| **HTTP server** (actix-web, port 6063) | `start_server()` via `thread::spawn` | `actix_rt::System` (single-thread + LocalSet per arbiter) |
201201+| **gRPC server** (tonic, port 6061) | `start_servers()` via `thread::spawn` | `tokio::Builder::new_current_thread` |
202202+| **GraphQL server** (async-graphql, port 6062) | `start_servers()` via `thread::spawn` | `tokio::Builder::new_current_thread` |
203203+| **MPD server** (port 6600) | `start_servers()` via `thread::spawn` | `tokio::Builder::new_current_thread` |
204204+| **MPRIS server** (Linux, D-Bus) | `start_servers()` via `thread::spawn` | `async_std` task |
205205+| **UPnP runtime** | `rockbox_upnp::init()` (static `OnceLock`) | `tokio::runtime::Runtime::new()` (multi-thread) |
206206+| **Device scanners** (Chromecast, AirPlay, Snapcast, UPnP, Squeezelite) | `run_http_server()` via `thread::spawn` | each creates its own `tokio::runtime::Runtime::new()` |
207207+| **Player event listener** | `run_http_server()` via `thread::spawn` | `tokio::runtime::Runtime::new()` (multi-thread) |
208208+| **Command relay** | `start_servers()` via `thread::spawn` | `reqwest::blocking` (creates its own tokio internally) |
209209+210210+### Android-only Rust OS threads
104211105105-| Thread / component | Spawned from | Runtime |
106106-|--------------------|--------------|---------|
107107-| **HTTP server** (actix-web, port 6063) | `start_server()` via `thread::spawn` | `actix_rt::System` (single-thread + LocalSet per arbiter) |
108108-| **gRPC server** (tonic, port 6061) | `start_servers()` via `thread::spawn` | `tokio::Builder::new_current_thread` |
109109-| **GraphQL server** (async-graphql, port 6062) | `start_servers()` via `thread::spawn` | `tokio::Builder::new_current_thread` |
110110-| **MPD server** (port 6600) | `start_servers()` via `thread::spawn` | `tokio::Builder::new_current_thread` |
111111-| **MPRIS server** (Linux, D-Bus) | `start_servers()` via `thread::spawn` | `async_std` task |
112112-| **UPnP runtime** | `rockbox_upnp::init()` (static `OnceLock`) | `tokio::runtime::Runtime::new()` (multi-thread) |
113113-| **Device scanners** (Chromecast, AirPlay, Snapcast, UPnP, Squeezelite) | `run_http_server()` via `thread::spawn` | each creates its own `tokio::runtime::Runtime::new()` |
114114-| **Player event listener** | `run_http_server()` via `thread::spawn` | `tokio::runtime::Runtime::new()` (multi-thread) |
115115-| **Command relay** | `start_servers()` via `thread::spawn` | `reqwest::blocking` (creates its own tokio internally) |
212212+| Thread | Spawned from | Purpose |
213213+| ------------------------------------ | --------------------------------------------- | ------------------------------------------------------------------------------- |
214214+| **`rockbox-engine`** (2 MB stack) | `rb_daemon_start` | Calls `main_c()`; owns all Rockbox kernel threads and the cooperative scheduler |
215215+| **`rockbox-library-scan`** | `spawn_library_scan()` after gRPC binds | SQLite audio scan + Rocksky enrichment |
216216+| **`stdio-logcat-reader`** (detached) | `redirect_stdio_to_logcat()` at `system_init` | Reads the stdout/stderr pipe and writes lines to logcat tag `Rockbox` |
217217+| **`rockbox-rpc`** (× 2 workers) | `RT` (`Lazy<Runtime>`) in `crates/expo` | Tokio multi-thread runtime for all tonic gRPC client calls |
116218117219---
118220119119-## Startup Sequence
221221+## Startup Sequences
222222+223223+### Desktop (SDL hosted)
120224121225```
122226main.c: server_init()
···131235├── sleep(HZ) ← Rockbox scheduler sleep on main thread (~1 s)
132236│
133237└── start_servers() ← called on main C thread after 1 s
238238+ ├── fw_bus::init() ← create mpsc channel before broker spawns
134239 ├── thread::spawn(gRPC server) ← Rust OS thread, new_current_thread runtime
135240 ├── thread::spawn(GraphQL server) ← Rust OS thread, new_current_thread runtime
136241 ├── thread::spawn(MPD server) ← Rust OS thread, new_current_thread runtime
···141246│
142247└── create_thread(broker_thread) ← Rockbox kernel thread
143248 └── start_broker()
144144- └── loop { work + sleep + rb::system::sleep(HZ) }
249249+ ├── fw_bus::take_receiver() ← claim the mpsc Receiver
250250+ └── loop { fw_bus::drain + work + sleep + rb::system::sleep(HZ) }
251251+```
252252+253253+### Android cdylib
254254+255255+```
256256+JNI: RockboxRpcModule.OnCreate
257257+│
258258+└── rb_daemon_start(configDir, musicDir, deviceName) ← crates/expo/src/daemon.rs
259259+ ├── STATE: STOPPED → STARTING
260260+ ├── install_logcat_subscriber()
261261+ ├── configure_environment() ← sets HOME, ROCKBOX_LIBRARY, TMPDIR, ports
262262+ │
263263+ ├── thread::spawn("rockbox-engine", stack=2MB)
264264+ │ └── main_c() ← apps/main.c
265265+ │ ├── system_init() ← redirect_stdio_to_logcat + stackbegin
266266+ │ ├── server_init() ← create_thread(server_thread)
267267+ │ │ └── start_server()
268268+ │ │ ├── load_settings()
269269+ │ │ ├── fw_bus::init()
270270+ │ │ ├── thread::spawn(actix HTTP server)
271271+ │ │ └── loop { sleep + rb::system::sleep(HZ) }
272272+ │ ├── sleep(HZ)
273273+ │ ├── start_servers()
274274+ │ │ ├── thread::spawn(gRPC server)
275275+ │ │ ├── thread::spawn(GraphQL server)
276276+ │ │ ├── thread::spawn(MPD server)
277277+ │ │ └── thread::spawn(command relay)
278278+ │ └── broker_init() ← create_thread(broker_thread)
279279+ │ └── start_broker()
280280+ │ ├── fw_bus::take_receiver()
281281+ │ └── loop { fw_bus::drain + work + sleep + rb::system::sleep(HZ) }
282282+ │
283283+ ├── wait_for_grpc(:6061, 30s) ← polls TCP connect every 50 ms
284284+ ├── STATE: STARTING → RUNNING
285285+ ├── rb_set_server_url("http://127.0.0.1:6061") ← if JS hasn't overridden
286286+ └── spawn_library_scan(force=false) ← own OS thread + current_thread tokio runtime
145287```
146288147289---
···149291## Tokio Runtime Layout
150292151293Multiple independent tokio runtimes coexist; they do not share thread pools or event loops.
294294+295295+### Desktop
152296153297```
154298┌─────────────────────────────────────────────────────┐
···183327└─────────────────────────────────────────────────────┘
184328```
185329330330+### Android (additional runtimes)
331331+332332+```
333333+┌─────────────────────────────────────────────────────┐
334334+│ rockbox-rpc Runtime (multi-thread, 2 workers) │
335335+│ Lazy<Runtime> in crates/expo/src/lib.rs │
336336+│ Owns: all outbound tonic gRPC client calls │
337337+│ (rb_play, rb_pause, rb_status_json, streams, …) │
338338+└─────────────────────────────────────────────────────┘
339339+340340+┌─────────────────────────────────────────────────────┐
341341+│ library-scan Runtime (current-thread, ephemeral) │
342342+│ Created per spawn_library_scan() call │
343343+│ Owns: SQLite connection pool, audio_scan, │
344344+│ save_audio_metadata, Rocksky enrichment │
345345+└─────────────────────────────────────────────────────┘
346346+347347+┌─────────────────────────────────────────────────────┐
348348+│ save_remote_track_metadata Runtime (current-thread)│
349349+│ Created per call from C firmware (streamfd.c) │
350350+│ Safe: called from a Rockbox kernel thread, which │
351351+│ is never inside an existing async context │
352352+└─────────────────────────────────────────────────────┘
353353+```
354354+186355All runtimes share a single **SQLite database** (`~/.config/rockbox.org/rockbox-library.db`). The connection pool is configured with:
187356- WAL journal mode (concurrent readers + one writer)
188357- `busy_timeout = 30 s` (serialize concurrent writers instead of failing)
···240409### Rule 5: `SimpleBroker` is runtime-agnostic
241410242411`rockbox_graphql::simplebroker::SimpleBroker` uses `futures_channel::mpsc::UnboundedSender` for pub/sub. `publish()` is a synchronous call — it does not require or interact with any tokio runtime. It is safe to call from any thread, including Rockbox kernel threads and scanner threads with their own runtimes.
412412+413413+### Rule 6: All firmware-mutating FFI from Rust handlers must go through `fw_bus`
414414+415415+On the headless pthread target (Android cdylib) calling `rb::playback::play`, `rb::sound::set_volume`, or any function that reaches `queue_send` / `wakeup_thread` from a non-Rockbox pthread corrupts `__cores[0].running` and causes a SIGSEGV. Route every such call through `fw_bus::run_on_broker(|| ...)` from inside a `web::block(...)` closure.
416416+417417+Read-only queries (status, current track) that only touch atomics or copy structs are safe to call directly.
418418+419419+### Rule 7: `fw_bus::init()` must be called before any handler sends a command
420420+421421+`fw_bus::send()` silently drops commands if the bus hasn't been initialised. `init()` is called inside `start_servers()`, which runs before the HTTP/gRPC servers start accepting requests. On Android, the boot sequence guarantees `start_servers()` (inside `main_c()`) completes before `rb_daemon_start` returns.
+15-15
crates/airplay/README.md
···111111following vtable:
112112113113| Op | Implementation |
114114-|-------------------|---------------------------------------------------------------------|
114114+| ----------------- | ------------------------------------------------------------------- |
115115| `init` | `pthread_mutex_init` (recursive) |
116116| `postinit` | no-op |
117117| `set_freq` | no-op (sample rate is fixed at 44100 Hz) |
118118| `lock` / `unlock` | `pthread_mutex_lock/unlock` |
119119-| `play` | `sink_dma_start` — connects all receivers, spawns `airplay_thread` |
119119+| `play` | `sink_dma_start` — connects all receivers, spawns `airplay_thread` |
120120| `stop` | `sink_dma_stop` — signals thread, joins, calls `pcm_airplay_stop()` |
121121122122`airplay_pcm_sink` is registered at index `PCM_SINK_AIRPLAY = 2` in the
···148148149149`crates/airplay/src/lib.rs` exports these `#[no_mangle] extern "C"` functions:
150150151151-| C symbol | Purpose |
152152-|-----------------------------|----------------------------------------------------------|
153153-| `pcm_airplay_set_host` | Set a single receiver (clears any previous list) |
154154-| `pcm_airplay_add_receiver` | Append one receiver to the multi-room list |
155155-| `pcm_airplay_clear_receivers` | Clear the receiver list before re-configuring |
156156-| `pcm_airplay_connect` | Open RTSP + RTP sessions for all configured receivers |
157157-| `pcm_airplay_write` | Buffer PCM, encode ALAC once, fan out to every receiver |
158158-| `pcm_airplay_stop` | Send TEARDOWN to all, clear session |
159159-| `pcm_airplay_close` | Same as stop (called on sink switch) |
151151+| C symbol | Purpose |
152152+| ----------------------------- | ------------------------------------------------------- |
153153+| `pcm_airplay_set_host` | Set a single receiver (clears any previous list) |
154154+| `pcm_airplay_add_receiver` | Append one receiver to the multi-room list |
155155+| `pcm_airplay_clear_receivers` | Clear the receiver list before re-configuring |
156156+| `pcm_airplay_connect` | Open RTSP + RTP sessions for all configured receivers |
157157+| `pcm_airplay_write` | Buffer PCM, encode ALAC once, fan out to every receiver |
158158+| `pcm_airplay_stop` | Send TEARDOWN to all, clear session |
159159+| `pcm_airplay_close` | Same as stop (called on sink switch) |
160160161161`SESSION` is a `Mutex<Option<AirPlaySession>>`. `CONFIG` is a
162162`Mutex<AirPlayConfig>` holding `receivers: Vec<(String, u16)>`.
···327327328328Owns the two UDP sockets for one AirPlay endpoint:
329329330330-| Socket | Direction | Purpose |
331331-|--------------|-------------------------|---------------------|
332332-| `audio_sock` | → receiver audio port | RTP audio frames |
333333-| `ctrl_sock` | ↔ receiver control port | RTCP sync packets |
330330+| Socket | Direction | Purpose |
331331+| ------------ | ----------------------- | ----------------- |
332332+| `audio_sock` | → receiver audio port | RTP audio frames |
333333+| `ctrl_sock` | ↔ receiver control port | RTCP sync packets |
334334335335Also holds `ssrc` (random per receiver) and `seqnum` (wrapping u16).
336336
+21-21
crates/chromecast/README.md
···53535454## Module map
55555656-| File | Responsibility |
5757-|---------------|-----------------------------------------------------------------------------------------|
5656+| File | Responsibility |
5757+| ------------- | -------------------------------------------------------------------------------------- |
5858| `src/pcm.rs` | Primary: HTTP WAV server; `BroadcastBuffer`; `cast_loop`; full C FFI surface |
5959| `src/lib.rs` | Secondary: `Player` trait impl; Cast command dispatch (retained, not called by server) |
6060-| `src/main.rs` | Example binary (connects to a hardcoded IP for manual testing) |
6060+| `src/main.rs` | Example binary (connects to a hardcoded IP for manual testing) |
61616262---
6363···198198If you need to drive the Cast protocol directly (e.g. from a test binary or a
199199future multi-session feature), `lib.rs` provides:
200200201201-| Player method | Cast action |
202202-|----------------|--------------------------------------|
203203-| `play()` | `media.play()` |
204204-| `pause()` | `media.pause()` |
205205-| `resume()` | `media.play()` |
206206-| `stop()` | no-op |
207207-| `disconnect()` | `receiver.stop_app(session_id)` |
201201+| Player method | Cast action |
202202+| -------------- | ------------------------------- |
203203+| `play()` | `media.play()` |
204204+| `pause()` | `media.pause()` |
205205+| `resume()` | `media.play()` |
206206+| `stop()` | no-op |
207207+| `disconnect()` | `receiver.stop_app(session_id)` |
208208209209Next / previous are **not** routed through `lib.rs` — the server always calls
210210`rb::playback::next()` / `rb::playback::prev()` directly, and the `cast_loop`
···286286### Port summary
287287288288| Port | Protocol | Purpose |
289289-|------|-----------|-----------------------------------------------------|
289289+| ---- | --------- | --------------------------------------------------- |
290290| 8009 | TCP / TLS | Cast control channel (Protobuf) |
291291| 7881 | HTTP | WAV audio stream + album art served **by rockboxd** |
292292···297297298298## Known limitations
299299300300-| Feature | Status |
301301-|---------------------------------------|---------------------------------------------|
302302-| Play / pause / resume | ✅ Implemented |
303303-| Next / previous track | ✅ Via `rb::playback::next/prev` + cast_loop |
304304-| Track metadata + album art display | ✅ Implemented |
305305-| Reconnect after output switch | ✅ Via teardown + fresh cast_loop |
306306-| Volume control | ⏳ Not yet implemented |
307307-| Seek within track | ⏳ Not yet implemented |
308308-| Multi-device fan-out | ⏳ Not yet implemented (single device only) |
300300+| Feature | Status |
301301+| ---------------------------------- | ------------------------------------------- |
302302+| Play / pause / resume | ✅ Implemented |
303303+| Next / previous track | ✅ Via `rb::playback::next/prev` + cast_loop |
304304+| Track metadata + album art display | ✅ Implemented |
305305+| Reconnect after output switch | ✅ Via teardown + fresh cast_loop |
306306+| Volume control | ⏳ Not yet implemented |
307307+| Seek within track | ⏳ Not yet implemented |
308308+| Multi-device fan-out | ⏳ Not yet implemented (single device only) |
309309310310---
311311312312## Dependencies
313313314314| Crate | Version | Purpose |
315315-|-------------------|-----------|-----------------------------------------------------------|
315315+| ----------------- | --------- | --------------------------------------------------------- |
316316| `chromecast` | 0.18.2 | Cast protocol client (Protobuf/TLS) |
317317| `tokio` | workspace | Async runtime for Cast background task |
318318| `async-trait` | workspace | `Player` trait with async methods |
+56-56
crates/expo/README.md
···2233The mobile-side Rust crate. Two builds in one workspace:
4455-| Build | Output | Size | Purpose |
66-|---|---|---|---|
77-| **Default** (`cargo build -p rockbox-expo`) | `staticlib` + `cdylib` | ~6 MB | Thin tonic gRPC client — controls a remote rockboxd over LAN. iOS, web, and "remote-only" Android variants use this. |
88-| **`--features embedded-daemon`** (Android only) | `cdylib` | ~48 MB | Full in-process rockboxd: C firmware + 44 statically-linked codecs + all Rust sink crates + gRPC/HTTP/GraphQL/MPD servers + AAudio sink + mDNS discovery. The phone becomes a symmetric peer of any LAN rockboxd. |
55+| Build | Output | Size | Purpose |
66+| ----------------------------------------------- | ---------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
77+| **Default** (`cargo build -p rockbox-expo`) | `staticlib` + `cdylib` | ~6 MB | Thin tonic gRPC client — controls a remote rockboxd over LAN. iOS, web, and "remote-only" Android variants use this. |
88+| **`--features embedded-daemon`** (Android only) | `cdylib` | ~48 MB | Full in-process rockboxd: C firmware + 44 statically-linked codecs + all Rust sink crates + gRPC/HTTP/GraphQL/MPD servers + AAudio sink + mDNS discovery. The phone becomes a symmetric peer of any LAN rockboxd. |
991010The Expo Modules wrapper at [`expo/modules/rockbox-rpc/`](../../expo/modules/rockbox-rpc/)
1111loads the resulting library and forwards calls through React Native.
···73737474## Surface map
75757676-| Group | Examples |
7777-|-------|----------|
7878-| Init | `rb_set_server_url`, `rb_set_http_url`, `rb_ping` |
7979-| Playback | `rb_play / pause / play_pause / next / prev`, `rb_seek`, `rb_play_album / play_artist_tracks / play_track / play_directory` |
8080-| Queue | `rb_jump_to_queue_position`, `rb_insert_tracks`, `rb_insert_track_next / last`, `rb_remove_from_queue`, `rb_shuffle_playlist`, `rb_get_playlist_current_json` |
8181-| Library | `rb_get_tracks_json`, `rb_get_artists_json`, `rb_get_album_json`, `rb_search_json`, `rb_like_track / unlike_track`, `rb_get_liked_tracks_json` |
8282-| Sound / Settings | `rb_adjust_volume`, `rb_sound_current_json`, `rb_save_shuffle / save_repeat`, `rb_get_global_settings_json`, `rb_get_global_status_json` |
8383-| Browse | `rb_tree_get_entries_json` |
8484-| Saved playlists | `rb_get_saved_playlists_json`, `rb_create_saved_playlist`, `rb_update_saved_playlist`, `rb_delete_saved_playlist`, `rb_add_track_to_playlist`, `rb_remove_track_from_playlist`, `rb_get_saved_playlist_tracks_json`, `rb_play_saved_playlist` |
8585-| Smart playlists | `rb_get_smart_playlists_json`, `rb_get_smart_playlist_tracks_json`, `rb_play_smart_playlist` |
8686-| Bluetooth | `rb_bluetooth_available`, `rb_get_bluetooth_devices_json`, `rb_connect_bluetooth`, `rb_disconnect_bluetooth` |
8787-| Server-streaming | `rb_subscribe_status`, `rb_subscribe_current_track`, `rb_subscribe_playlist`, `rb_subscribe_library`, `rb_subscribe_discovery(serviceName)` |
8888-| Stream pump | `rb_poll_event(subId, timeoutMs)`, `rb_unsubscribe(subId)` |
8989-| Discovery constants | `rb_rockbox_service_name`, `rb_chromecast_service_name` |
9090-| Memory | `rb_free_string` |
9191-| **Embedded daemon** | `rb_daemon_start(configDir, musicDir, deviceName)`, `rb_daemon_port`, `rb_daemon_state`, `rb_rescan_library` |
7676+| Group | Examples |
7777+| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7878+| Init | `rb_set_server_url`, `rb_set_http_url`, `rb_ping` |
7979+| Playback | `rb_play / pause / play_pause / next / prev`, `rb_seek`, `rb_play_album / play_artist_tracks / play_track / play_directory` |
8080+| Queue | `rb_jump_to_queue_position`, `rb_insert_tracks`, `rb_insert_track_next / last`, `rb_remove_from_queue`, `rb_shuffle_playlist`, `rb_get_playlist_current_json` |
8181+| Library | `rb_get_tracks_json`, `rb_get_artists_json`, `rb_get_album_json`, `rb_search_json`, `rb_like_track / unlike_track`, `rb_get_liked_tracks_json` |
8282+| Sound / Settings | `rb_adjust_volume`, `rb_sound_current_json`, `rb_save_shuffle / save_repeat`, `rb_get_global_settings_json`, `rb_get_global_status_json` |
8383+| Browse | `rb_tree_get_entries_json` |
8484+| Saved playlists | `rb_get_saved_playlists_json`, `rb_create_saved_playlist`, `rb_update_saved_playlist`, `rb_delete_saved_playlist`, `rb_add_track_to_playlist`, `rb_remove_track_from_playlist`, `rb_get_saved_playlist_tracks_json`, `rb_play_saved_playlist` |
8585+| Smart playlists | `rb_get_smart_playlists_json`, `rb_get_smart_playlist_tracks_json`, `rb_play_smart_playlist` |
8686+| Bluetooth | `rb_bluetooth_available`, `rb_get_bluetooth_devices_json`, `rb_connect_bluetooth`, `rb_disconnect_bluetooth` |
8787+| Server-streaming | `rb_subscribe_status`, `rb_subscribe_current_track`, `rb_subscribe_playlist`, `rb_subscribe_library`, `rb_subscribe_discovery(serviceName)` |
8888+| Stream pump | `rb_poll_event(subId, timeoutMs)`, `rb_unsubscribe(subId)` |
8989+| Discovery constants | `rb_rockbox_service_name`, `rb_chromecast_service_name` |
9090+| Memory | `rb_free_string` |
9191+| **Embedded daemon** | `rb_daemon_start(configDir, musicDir, deviceName)`, `rb_daemon_port`, `rb_daemon_state`, `rb_rescan_library` |
92929393---
9494···292292The cdylib-specific firmware sources live under
293293`firmware/target/hosted/android/cdylib/`:
294294295295-| File | Role |
296296-|---|---|
297297-| `system-android.c` | Headless system_init + power_off + stdout/stderr→logcat shim |
298298-| `pcm-aaudio.c` | AAudio PCM sink (replaces SDL audio) |
299299-| `lc-android.c` | `lc_open()` / `lc_get_header()` over the static `lc_static_table[]` |
300300-| `rb_zig_compat.c` | C compat layer for the 18 `rb_*` symbols `crates/sys` would otherwise pull from `zig/src/main.zig` |
301301-| `lcd-noop.c`, `button-noop.c`, `cpuinfo-noop.c`, `audiohw-noop.c` | Stubs — UI lives in React Native, not on the device LCD |
295295+| File | Role |
296296+| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
297297+| `system-android.c` | Headless system_init + power_off + stdout/stderr→logcat shim |
298298+| `pcm-aaudio.c` | AAudio PCM sink (replaces SDL audio) |
299299+| `lc-android.c` | `lc_open()` / `lc_get_header()` over the static `lc_static_table[]` |
300300+| `rb_zig_compat.c` | C compat layer for the 18 `rb_*` symbols `crates/sys` would otherwise pull from `zig/src/main.zig` |
301301+| `lcd-noop.c`, `button-noop.c`, `cpuinfo-noop.c`, `audiohw-noop.c` | Stubs — UI lives in React Native, not on the device LCD |
302302303303---
304304···371371The configure script reads two env vars when it sees the androidcdylib
372372target — set them before running if you need non-default values:
373373374374-| Var | Default | Purpose |
375375-|---|---|---|
376376-| `ANDROID_NDK_HOME` | _(none — required)_ | Path to NDK install root |
377377-| `ANDROID_TARGET_ABI` | `arm64-v8a` | One of `arm64-v8a` / `armeabi-v7a` / `x86_64` |
378378-| `ANDROID_API_LEVEL` | `26` | Minimum SDK; **don't go below 26** (AAudio requires it) |
374374+| Var | Default | Purpose |
375375+| -------------------- | ------------------- | ------------------------------------------------------- |
376376+| `ANDROID_NDK_HOME` | _(none — required)_ | Path to NDK install root |
377377+| `ANDROID_TARGET_ABI` | `arm64-v8a` | One of `arm64-v8a` / `armeabi-v7a` / `x86_64` |
378378+| `ANDROID_API_LEVEL` | `26` | Minimum SDK; **don't go below 26** (AAudio requires it) |
379379380380For a 32-bit ARM build, e.g.:
381381···536536537537Tag map:
538538539539-| logcat tag | Source |
540540-|---|---|
541541-| `rockbox` | Rust `tracing::*` calls (default level: per-crate `debug`, see `daemon.rs::install_logcat_subscriber`) |
542542-| `Rockbox` | C firmware `printf`/`fprintf` and `DEBUGF`/`logf`/`panicf` (routed via `debug-android.c` and the stdout/stderr pipe in `system-android.c`) |
543543-| `rockbox-engine` | `system-android.c` boot diagnostics (cgroup/SELinux denials, etc.) |
544544-| `rb-system-android`, `rb-pcm-aaudio` | other cdylib C tags |
545545-| `RockboxRpc` | Kotlin Log calls in `RockboxRpcModule.kt` |
546546-| `RockboxNowPlaying` | Kotlin Log calls in `NowPlayingService.kt` |
539539+| logcat tag | Source |
540540+| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
541541+| `rockbox` | Rust `tracing::*` calls (default level: per-crate `debug`, see `daemon.rs::install_logcat_subscriber`) |
542542+| `Rockbox` | C firmware `printf`/`fprintf` and `DEBUGF`/`logf`/`panicf` (routed via `debug-android.c` and the stdout/stderr pipe in `system-android.c`) |
543543+| `rockbox-engine` | `system-android.c` boot diagnostics (cgroup/SELinux denials, etc.) |
544544+| `rb-system-android`, `rb-pcm-aaudio` | other cdylib C tags |
545545+| `RockboxRpc` | Kotlin Log calls in `RockboxRpcModule.kt` |
546546+| `RockboxNowPlaying` | Kotlin Log calls in `NowPlayingService.kt` |
547547548548Quick capture recipe:
549549···560560561561## Known pitfalls
562562563563-| Symptom | Cause | Fix |
564564-|---|---|---|
565565-| `embedded daemon not built into this .so (remote-only mode)` | Build script ran without `--features embedded-daemon` | Use `expo/modules/rockbox-rpc/scripts/build-android.sh` (defaults to enabled) |
566566-| `dlopen failed: cannot locate symbol "server_init"` | `CONFIG_SERVER` not defined → `apps/SOURCES` skips `server_thread.c` compilation | Set both `ROCKBOX_SERVER` and `CONFIG_SERVER` in `androidcdylib.h` |
567567-| `Codec: cannot read file` for every track | Codec naming uses Java-shell `libNAME.so` convention but `lc_static_table[]` has bare `<name>.codec` entries | Gate the `libNAME.so` override in `lib/rbcodec/metadata/metadata.h` on `!CODECS_STATIC` |
568568-| SIGSEGV in `init_mad` (or any codec init) at small fault address | `ci` symbol collision: 264-byte struct (codecs.c) merged into 8-byte pointer storage (codec_crt0.c) | Firmware-side rename: `firmware_ci` for the struct, `ci` for the pointer (both 8 bytes, same type) |
569569-| Audio plays at chipmunk speed (~9 % too fast) | `pcm_sink::set_freq` receives an INDEX into `hw_freq_sampr[]`, not Hz; AAudio gets opened at "4 Hz", silently falls back to 48 kHz | `pcm-aaudio.c::sink_set_freq` looks up `hw_freq_sampr[freq_index]` first |
570570-| `ForegroundServiceStartNotAllowedException` on play | Android 14+ blocks `startForegroundService` from background process state (`uidState: SVC`) even with `mediaPlayback` type | `RockboxNowPlayingModule.startServiceCompat` and `NowPlayingService.refreshNotification` check `ActivityManager.getMyMemoryState().importance` before promoting |
571571-| ENOENT when the GraphQL `treeGetEntries` resolver browses `Music` | Daemon set `ROCKBOX_MUSIC_DIR` but the resolvers read `ROCKBOX_LIBRARY` | Set `ROCKBOX_LIBRARY` in `daemon.rs::configure_environment` |
572572-| Library DB stays empty even after browsing works | Embedded daemon doesn't run the desktop CLI's startup scan | `daemon.rs::spawn_library_scan` runs after gRPC binds; force re-scan via `RockboxClient.rescanLibrary()` |
573573-| `Permission denied` reading `/storage/emulated/0/Music` on API 33+ | `READ_EXTERNAL_STORAGE` is ignored on `targetSdk=33+`; `READ_MEDIA_AUDIO` only grants MediaStore queries | `MANAGE_EXTERNAL_STORAGE` in manifest + `useAllFilesAccessPrompt()` opens system Settings |
574574-| Daemon dies after the app backgrounds for a few minutes | App process killed for memory; daemon dies with it | NowPlayingService is a foreground service — keep it running via `RockboxNowPlaying.start()` at app launch (called from `_layout.tsx`) |
575575-| SIGSEGV at PC=0 in `wakeup_thread_` / `queue_send` on track switches, settings updates, anything that crosses `audio_thread` ↔ `codec_thread` | Rockbox kernel uses `__cores[0].running` as global "current thread" — no TLS. Calling firmware FFI from a non-Rockbox pthread (actix worker handling a gRPC request) corrupts kernel-thread state. Same root cause as the older "stale blocker" / "pcmbuf race" symptoms — they were all surfaces of this | **Firmware-command bus** in `crates/server/src/fw_bus.rs`. Every kernel-mutating handler in `crates/server/src/handlers/{player,playlists,saved_playlists,smart_playlists,settings}.rs` wraps its FFI block in `crate::fw_bus::run_on_broker(move \|\| …)` so the calls run on the broker (a real Rockbox kernel thread) and `__running_self_entry()` resolves correctly. Read-only handlers stay direct |
576576-| pcmbuf rebuild race (separate, narrower window) | `pcmbuf_init` rewrites the chunk descriptor ring while the AAudio writer pthread may be mid-`pcmbuf_pcm_callback` | `apps/pcmbuf.c::pcmbuf_init` wrapped in `pcm_play_lock()` / `pcm_play_unlock()` — routes to our `aa_mtx` and blocks `aa_thread` for the few ms of rebuild. Kept as defence-in-depth on top of the bus fix |
577577-| Track-switch hiccup race (separate) | `codec_stop` from `halt_decoding_track` could race the codec_thread before it parked | `apps/playback.c::halt_decoding_track` does `sleep(HZ/10)` (CODECS_STATIC only) before `codec_stop()` to let the runqueue drain. Kept as defence-in-depth |
578578-| Probe / cache writes fail with `ENOENT /tmp/...` on Android | App sandbox has no writable `/tmp` | `daemon.rs::configure_environment` sets `TMPDIR=$HOME/tmp` (and `mkdir -p`s it) so `std::env::temp_dir()` resolves into the sandbox |
563563+| Symptom | Cause | Fix | | |
564564+| --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - | ------------------------------------------------------------------------------------------------------------------------------------------------- |
565565+| `embedded daemon not built into this .so (remote-only mode)` | Build script ran without `--features embedded-daemon` | Use `expo/modules/rockbox-rpc/scripts/build-android.sh` (defaults to enabled) | | |
566566+| `dlopen failed: cannot locate symbol "server_init"` | `CONFIG_SERVER` not defined → `apps/SOURCES` skips `server_thread.c` compilation | Set both `ROCKBOX_SERVER` and `CONFIG_SERVER` in `androidcdylib.h` | | |
567567+| `Codec: cannot read file` for every track | Codec naming uses Java-shell `libNAME.so` convention but `lc_static_table[]` has bare `<name>.codec` entries | Gate the `libNAME.so` override in `lib/rbcodec/metadata/metadata.h` on `!CODECS_STATIC` | | |
568568+| SIGSEGV in `init_mad` (or any codec init) at small fault address | `ci` symbol collision: 264-byte struct (codecs.c) merged into 8-byte pointer storage (codec_crt0.c) | Firmware-side rename: `firmware_ci` for the struct, `ci` for the pointer (both 8 bytes, same type) | | |
569569+| Audio plays at chipmunk speed (~9 % too fast) | `pcm_sink::set_freq` receives an INDEX into `hw_freq_sampr[]`, not Hz; AAudio gets opened at "4 Hz", silently falls back to 48 kHz | `pcm-aaudio.c::sink_set_freq` looks up `hw_freq_sampr[freq_index]` first | | |
570570+| `ForegroundServiceStartNotAllowedException` on play | Android 14+ blocks `startForegroundService` from background process state (`uidState: SVC`) even with `mediaPlayback` type | `RockboxNowPlayingModule.startServiceCompat` and `NowPlayingService.refreshNotification` check `ActivityManager.getMyMemoryState().importance` before promoting | | |
571571+| ENOENT when the GraphQL `treeGetEntries` resolver browses `Music` | Daemon set `ROCKBOX_MUSIC_DIR` but the resolvers read `ROCKBOX_LIBRARY` | Set `ROCKBOX_LIBRARY` in `daemon.rs::configure_environment` | | |
572572+| Library DB stays empty even after browsing works | Embedded daemon doesn't run the desktop CLI's startup scan | `daemon.rs::spawn_library_scan` runs after gRPC binds; force re-scan via `RockboxClient.rescanLibrary()` | | |
573573+| `Permission denied` reading `/storage/emulated/0/Music` on API 33+ | `READ_EXTERNAL_STORAGE` is ignored on `targetSdk=33+`; `READ_MEDIA_AUDIO` only grants MediaStore queries | `MANAGE_EXTERNAL_STORAGE` in manifest + `useAllFilesAccessPrompt()` opens system Settings | | |
574574+| Daemon dies after the app backgrounds for a few minutes | App process killed for memory; daemon dies with it | NowPlayingService is a foreground service — keep it running via `RockboxNowPlaying.start()` at app launch (called from `_layout.tsx`) | | |
575575+| SIGSEGV at PC=0 in `wakeup_thread_` / `queue_send` on track switches, settings updates, anything that crosses `audio_thread` ↔ `codec_thread` | Rockbox kernel uses `__cores[0].running` as global "current thread" — no TLS. Calling firmware FFI from a non-Rockbox pthread (actix worker handling a gRPC request) corrupts kernel-thread state. Same root cause as the older "stale blocker" / "pcmbuf race" symptoms — they were all surfaces of this | **Firmware-command bus** in `crates/server/src/fw_bus.rs`. Every kernel-mutating handler in `crates/server/src/handlers/{player,playlists,saved_playlists,smart_playlists,settings}.rs` wraps its FFI block in `crate::fw_bus::run_on_broker(move \ | \ | …)` so the calls run on the broker (a real Rockbox kernel thread) and `__running_self_entry()` resolves correctly. Read-only handlers stay direct |
576576+| pcmbuf rebuild race (separate, narrower window) | `pcmbuf_init` rewrites the chunk descriptor ring while the AAudio writer pthread may be mid-`pcmbuf_pcm_callback` | `apps/pcmbuf.c::pcmbuf_init` wrapped in `pcm_play_lock()` / `pcm_play_unlock()` — routes to our `aa_mtx` and blocks `aa_thread` for the few ms of rebuild. Kept as defence-in-depth on top of the bus fix | | |
577577+| Track-switch hiccup race (separate) | `codec_stop` from `halt_decoding_track` could race the codec_thread before it parked | `apps/playback.c::halt_decoding_track` does `sleep(HZ/10)` (CODECS_STATIC only) before `codec_stop()` to let the runqueue drain. Kept as defence-in-depth | | |
578578+| Probe / cache writes fail with `ENOENT /tmp/...` on Android | App sandbox has no writable `/tmp` | `daemon.rs::configure_environment` sets `TMPDIR=$HOME/tmp` (and `mkdir -p`s it) so `std::env::temp_dir()` resolves into the sandbox | | |
579579580580---
581581
+3-3
crates/slim/README.md
···209209`crates/sys/src/sound/pcm.rs`:
210210211211| C symbol | Rust wrapper |
212212-|--------------------------------------|------------------------------------------|
212212+| ------------------------------------ | ---------------------------------------- |
213213| `pcm_squeezelite_set_slim_port(u16)` | `pcm::squeezelite_set_slim_port(u16)` |
214214| `pcm_squeezelite_set_http_port(u16)` | `pcm::squeezelite_set_http_port(u16)` |
215215| `pcm_switch_sink(i32)` | `pcm::switch_sink(PCM_SINK_SQUEEZELITE)` |
···358358The Slim Protocol uses **asymmetric framing**:
359359360360| Direction | Wire format |
361361-|-----------------|-----------------------------------------------------|
361361+| --------------- | --------------------------------------------------- |
362362| Client → Server | `opcode[4]` + `u32 length BE` + `payload[length]` |
363363| Server → Client | `u16 length BE` + `opcode[4]` + `payload[length-4]` |
364364···550550squeezelite handles the audio pipeline in three internal threads:
551551552552| Thread | Role |
553553-|--------------------------------------|---------------------------------------------------------------|
553553+| ------------------------------------ | ------------------------------------------------------------- |
554554| `stream_thread` | Reads HTTP stream into `streambuf` (2 MB ring buffer) |
555555| `decode_thread` | PCM "decoder": memcpy from `streambuf` → `outputbuf` (3.5 MB) |
556556| `output_thread` / PortAudio callback | Reads `outputbuf`, sends to audio device |
+30-30
crates/upnp/README.md
···33UPnP/DLNA support for Rockbox Zig. This crate provides three independent but
44complementary features:
5566-| Feature | What it does |
77-|--------------------------------------|-----------------------------------------------------------------------------------------------------|
88-| **Media Server** (ContentDirectory) | Exposes the music library so UPnP control points (BubbleUPnP, Kodi, etc.) can browse and pull tracks |
99-| **MediaRenderer** | Lets control points push media to Rockbox (Rockbox becomes the speaker) |
1010-| **PCM sink / WAV output** | Streams live PCM audio as WAV-over-HTTP to an external UPnP renderer (Kodi, etc.) |
66+| Feature | What it does |
77+| ----------------------------------- | ---------------------------------------------------------------------------------------------------- |
88+| **Media Server** (ContentDirectory) | Exposes the music library so UPnP control points (BubbleUPnP, Kodi, etc.) can browse and pull tracks |
99+| **MediaRenderer** | Lets control points push media to Rockbox (Rockbox becomes the speaker) |
1010+| **PCM sink / WAV output** | Streams live PCM audio as WAV-over-HTTP to an external UPnP renderer (Kodi, etc.) |
11111212---
1313···40404141### UPnP service roles
42424343-| UPnP term | Rockbox role | Description |
4444-|---------------------------------|----------------|----------------------------------------|
4545-| MediaServer / ContentDirectory | **server** | Hosts the music library for browsing |
4646-| MediaRenderer / AVTransport | **renderer** | Receives push-play commands |
4747-| Control Point | *external app* | BubbleUPnP, Kodi, Foobar2000, … |
4343+| UPnP term | Rockbox role | Description |
4444+| ------------------------------ | -------------- | ------------------------------------ |
4545+| MediaServer / ContentDirectory | **server** | Hosts the music library for browsing |
4646+| MediaRenderer / AVTransport | **renderer** | Receives push-play commands |
4747+| Control Point | *external app* | BubbleUPnP, Kodi, Foobar2000, … |
48484949---
5050···123123124124### Supported AVTransport actions
125125126126-| Action | Behaviour |
127127-|-----------------------|------------------------------------------------------------------------------------|
128128-| `SetAVTransportURI` | Store URI + parse DIDL-Lite metadata; open the stream in the Rockbox audio engine |
129129-| `Play` | Start or resume playback |
130130-| `Pause` | Pause/resume toggle |
131131-| `Stop` | Stop playback; clear stored metadata |
132132-| `Seek` | Seek to absolute time (REL_TIME target unit) |
133133-| `GetTransportInfo` | Return current transport state (PLAYING / PAUSED_PLAYBACK / STOPPED) |
134134-| `GetPositionInfo` | Return track URI, DIDL-Lite metadata, duration, elapsed time |
135135-| `GetMediaInfo` | Return current URI and DIDL-Lite metadata |
126126+| Action | Behaviour |
127127+| ------------------- | --------------------------------------------------------------------------------- |
128128+| `SetAVTransportURI` | Store URI + parse DIDL-Lite metadata; open the stream in the Rockbox audio engine |
129129+| `Play` | Start or resume playback |
130130+| `Pause` | Pause/resume toggle |
131131+| `Stop` | Stop playback; clear stored metadata |
132132+| `Seek` | Seek to absolute time (REL_TIME target unit) |
133133+| `GetTransportInfo` | Return current transport state (PLAYING / PAUSED_PLAYBACK / STOPPED) |
134134+| `GetPositionInfo` | Return track URI, DIDL-Lite metadata, duration, elapsed time |
135135+| `GetMediaInfo` | Return current URI and DIDL-Lite metadata |
136136137137---
138138···225225226226### All UPnP settings at a glance
227227228228-| Key | Type | Default | Description |
229229-|--------------------------|-----------|--------------|------------------------------------------------|
230230-| `audio_output` | string | `"builtin"` | Set to `"upnp"` to use the PCM sink |
231231-| `upnp_renderer_url` | string | — | AVTransport controlURL of the target renderer |
232232-| `upnp_http_port` | integer | `7879` | Port for the WAV broadcast HTTP server |
233233-| `upnp_server_enabled` | bool | `false` | Start the ContentDirectory media server |
234234-| `upnp_server_port` | integer | `7878` | HTTP port for the media server |
235235-| `upnp_renderer_enabled` | bool | `false` | Start the MediaRenderer |
236236-| `upnp_renderer_port` | integer | `7880` | HTTP port for the renderer |
237237-| `upnp_friendly_name` | string | `"Rockbox"` | Display name shown to control points |
228228+| Key | Type | Default | Description |
229229+| ----------------------- | ------- | ----------- | --------------------------------------------- |
230230+| `audio_output` | string | `"builtin"` | Set to `"upnp"` to use the PCM sink |
231231+| `upnp_renderer_url` | string | — | AVTransport controlURL of the target renderer |
232232+| `upnp_http_port` | integer | `7879` | Port for the WAV broadcast HTTP server |
233233+| `upnp_server_enabled` | bool | `false` | Start the ContentDirectory media server |
234234+| `upnp_server_port` | integer | `7878` | HTTP port for the media server |
235235+| `upnp_renderer_enabled` | bool | `false` | Start the MediaRenderer |
236236+| `upnp_renderer_port` | integer | `7880` | HTTP port for the renderer |
237237+| `upnp_friendly_name` | string | `"Rockbox"` | Display name shown to control points |
238238239239---
240240
+39-39
docs/pcm-normalization.md
···58585959The coefficient `α` controls how quickly the estimate tracks changes. Crucially, **two different coefficients** are used depending on the direction of change:
60606161-| Signal direction | Coefficient | Behaviour |
6262-|---|---|---|
6363-| `chunk_rms > rms_estimate` (getting louder) | `RMS_ATTACK = 0.3` | Tracks loud transients in 2–3 chunks (< 150 ms) |
6464-| `chunk_rms < rms_estimate` (getting quieter) | `RMS_RELEASE = 0.99` | Takes ~7 s to settle on a quieter signal |
6161+| Signal direction | Coefficient | Behaviour |
6262+| -------------------------------------------- | -------------------- | ----------------------------------------------- |
6363+| `chunk_rms > rms_estimate` (getting louder) | `RMS_ATTACK = 0.3` | Tracks loud transients in 2–3 chunks (< 150 ms) |
6464+| `chunk_rms < rms_estimate` (getting quieter) | `RMS_RELEASE = 0.99` | Takes ~7 s to settle on a quieter signal |
65656666This asymmetry is essential. A fast attack means the estimate rises quickly when a loud section begins — preventing the normalizer from over-boosting and causing clipping. A slow release means the estimate falls slowly after a loud section ends — preventing the gain from shooting up during a brief quiet passage (the "pumping" or "breathing" artefact).
6767···8888gain = β × gain + (1 − β) × desired_gain
8989```
90909191-| Direction | Coefficient | Convergence |
9292-|---|---|---|
9393-| Gain decreasing (signal too loud) | `GAIN_ATTACK = 0.3` | Reaches target in ~3 chunks (< 150 ms) |
9494-| Gain increasing (signal too quiet) | `GAIN_RELEASE = 0.98` | Reaches target in ~3 seconds |
9191+| Direction | Coefficient | Convergence |
9292+| ---------------------------------- | --------------------- | -------------------------------------- |
9393+| Gain decreasing (signal too loud) | `GAIN_ATTACK = 0.3` | Reaches target in ~3 chunks (< 150 ms) |
9494+| Gain increasing (signal too quiet) | `GAIN_RELEASE = 0.98` | Reaches target in ~3 seconds |
95959696The fast gain attack prevents over-shoot and clipping when a loud track suddenly follows a quiet one. The slow gain release prevents the loudness from rising abruptly during a quiet moment.
9797···165165166166All parameters are compile-time constants in `firmware/pcm_normalizer.c`.
167167168168-| Constant | Value | dB equivalent | Description |
169169-|---|---|---|---|
170170-| `TARGET_RMS` | `0.35` | −9 dBFS | Target RMS loudness. Higher = louder output. |
171171-| `RMS_ATTACK` | `0.3` | — | IIR coefficient for RMS rising (loud signal). Lower = faster. |
172172-| `RMS_RELEASE` | `0.99` | — | IIR coefficient for RMS falling (quiet signal). Higher = slower. |
173173-| `GAIN_ATTACK` | `0.3` | — | IIR coefficient for gain decreasing. Lower = faster. |
174174-| `GAIN_RELEASE` | `0.98` | — | IIR coefficient for gain increasing. Higher = slower. |
175175-| `MAX_GAIN` | `10.0` | +20 dB | Maximum boost applied to quiet tracks. |
176176-| `MIN_GAIN` | `0.1` | −20 dB | Maximum cut applied to loud tracks. |
177177-| `GATE_THRESH` | `0.001` | −60 dBFS | RMS below this → treat chunk as silence. |
168168+| Constant | Value | dB equivalent | Description |
169169+| -------------- | ------- | ------------- | ---------------------------------------------------------------- |
170170+| `TARGET_RMS` | `0.35` | −9 dBFS | Target RMS loudness. Higher = louder output. |
171171+| `RMS_ATTACK` | `0.3` | — | IIR coefficient for RMS rising (loud signal). Lower = faster. |
172172+| `RMS_RELEASE` | `0.99` | — | IIR coefficient for RMS falling (quiet signal). Higher = slower. |
173173+| `GAIN_ATTACK` | `0.3` | — | IIR coefficient for gain decreasing. Lower = faster. |
174174+| `GAIN_RELEASE` | `0.98` | — | IIR coefficient for gain increasing. Higher = slower. |
175175+| `MAX_GAIN` | `10.0` | +20 dB | Maximum boost applied to quiet tracks. |
176176+| `MIN_GAIN` | `0.1` | −20 dB | Maximum cut applied to loud tracks. |
177177+| `GATE_THRESH` | `0.001` | −60 dBFS | RMS below this → treat chunk as silence. |
178178179179### Choosing TARGET_RMS
180180181181`TARGET_RMS` is the most impactful parameter. A few reference points:
182182183183-| Value | dBFS | Character |
184184-|---|---|---|
185185-| `0.071` | −23 dBFS | EBU R128 broadcast standard (very conservative) |
186186-| `0.178` | −15 dBFS | Apple Music / AES streaming recommendation |
187187-| `0.200` | −14 dBFS | Spotify / YouTube streaming target |
188188-| `0.350` | −9 dBFS | **Current default** — loud and punchy |
189189-| `0.500` | −6 dBFS | Very loud; risk of clipping on loud source material |
183183+| Value | dBFS | Character |
184184+| ------- | -------- | --------------------------------------------------- |
185185+| `0.071` | −23 dBFS | EBU R128 broadcast standard (very conservative) |
186186+| `0.178` | −15 dBFS | Apple Music / AES streaming recommendation |
187187+| `0.200` | −14 dBFS | Spotify / YouTube streaming target |
188188+| `0.350` | −9 dBFS | **Current default** — loud and punchy |
189189+| `0.500` | −6 dBFS | Very loud; risk of clipping on loud source material |
190190191191### Convergence Time Reference
192192193193IIR convergence depends on the chunk size. For a typical 4 096-byte chunk at 44 100 Hz stereo (46 ms per chunk):
194194195195-| Parameter | Coefficient | ~Time to move 63% of the way to target |
196196-|---|---|---|
197197-| `RMS_ATTACK` | 0.3 | 1 chunk ≈ 46 ms |
198198-| `RMS_RELEASE` | 0.99 | 100 chunks ≈ 4.6 s |
199199-| `GAIN_ATTACK` | 0.3 | 1 chunk ≈ 46 ms |
200200-| `GAIN_RELEASE` | 0.98 | 50 chunks ≈ 2.3 s |
195195+| Parameter | Coefficient | ~Time to move 63% of the way to target |
196196+| -------------- | ----------- | -------------------------------------- |
197197+| `RMS_ATTACK` | 0.3 | 1 chunk ≈ 46 ms |
198198+| `RMS_RELEASE` | 0.99 | 100 chunks ≈ 4.6 s |
199199+| `GAIN_ATTACK` | 0.3 | 1 chunk ≈ 46 ms |
200200+| `GAIN_RELEASE` | 0.98 | 50 chunks ≈ 2.3 s |
201201202202Time constant τ = `−chunk_duration / ln(α)`. For `α = 0.98` and chunk = 46 ms: τ = −46 ms / ln(0.98) ≈ 2.3 s.
203203···257257258258Rockbox also supports ReplayGain, which is a pre-computed per-track gain stored in file tags. The two approaches are complementary:
259259260260-| | ReplayGain | PCM Normalizer |
261261-|---|---|---|
262262-| **Requires track analysis** | Yes (offline scan) | No |
263263-| **Works on streams / radio** | No | Yes |
264264-| **Accuracy** | Very high (full-track analysis) | Moderate (real-time estimate) |
265265-| **Artefacts** | None | Slight pumping on highly dynamic content |
266266-| **Target** | Configurable per standard | `TARGET_RMS` compile constant |
267267-| **Processing cost** | Zero at runtime | ~1–2% CPU (RMS + gain loop) |
260260+| | ReplayGain | PCM Normalizer |
261261+| ---------------------------- | ------------------------------- | ---------------------------------------- |
262262+| **Requires track analysis** | Yes (offline scan) | No |
263263+| **Works on streams / radio** | No | Yes |
264264+| **Accuracy** | Very high (full-track analysis) | Moderate (real-time estimate) |
265265+| **Artefacts** | None | Slight pumping on highly dynamic content |
266266+| **Target** | Configurable per standard | `TARGET_RMS` compile constant |
267267+| **Processing cost** | Zero at runtime | ~1–2% CPU (RMS + gain loop) |
268268269269For local music libraries, ReplayGain is generally preferred when tags are available. The PCM normalizer is the practical choice for streaming sources or when ReplayGain tags are missing.
270270
···132132 (rb/with-headers {:x-trace-id "req-123"})))
133133```
134134135135-| Option | Default | Description |
136136-|---------------|-----------------------------------|-------------------------------------|
137137-| `:host` | `"localhost"` | rockboxd hostname / IP |
138138-| `:port` | `6062` | GraphQL HTTP/WS port |
139139-| `:http-url` | `http://{host}:{port}/graphql` | Override the full HTTP URL |
140140-| `:ws-url` | `ws://{host}:{port}/graphql` | Override the full WS URL |
141141-| `:timeout-ms` | `15000` | Per-request timeout |
142142-| `:headers` | `{}` | Extra HTTP headers map |
143143-| `:http-client`| (auto) | Reuse a `java.net.http.HttpClient` |
135135+| Option | Default | Description |
136136+| -------------- | ------------------------------ | ---------------------------------- |
137137+| `:host` | `"localhost"` | rockboxd hostname / IP |
138138+| `:port` | `6062` | GraphQL HTTP/WS port |
139139+| `:http-url` | `http://{host}:{port}/graphql` | Override the full HTTP URL |
140140+| `:ws-url` | `ws://{host}:{port}/graphql` | Override the full WS URL |
141141+| `:timeout-ms` | `15000` | Per-request timeout |
142142+| `:headers` | `{}` | Extra HTTP headers map |
143143+| `:http-client` | (auto) | Reuse a `java.net.http.HttpClient` |
144144145145---
146146···252252```
253253254254| `insert-position` keyword | Effect |
255255-|---------------------------|----------------------------------------|
255255+| ------------------------- | -------------------------------------- |
256256| `:next` | After the currently playing track |
257257| `:after-current` | After the last manually inserted track |
258258| `:last` | At the end of the queue |
···491491492492### Event map
493493494494-| Event | Payload | Description |
495495-|---------------------|-------------|--------------------------------------|
496496-| `:track-changed` | track map | Currently playing track changed |
497497-| `:status-changed` | int | Playback status (0=stopped, 1=playing, 3=paused) |
498498-| `:playlist-changed` | playlist | Active queue was modified |
499499-| `:ws-open` | `nil` | WebSocket connection established |
500500-| `:ws-close` | `nil` | WebSocket connection closed |
501501-| `:ws-error` | Throwable | WebSocket / subscription error |
494494+| Event | Payload | Description |
495495+| ------------------- | --------- | ------------------------------------------------ |
496496+| `:track-changed` | track map | Currently playing track changed |
497497+| `:status-changed` | int | Playback status (0=stopped, 1=playing, 3=paused) |
498498+| `:playlist-changed` | playlist | Active queue was modified |
499499+| `:ws-open` | `nil` | WebSocket connection established |
500500+| `:ws-close` | `nil` | WebSocket connection closed |
501501+| `:ws-error` | Throwable | WebSocket / subscription error |
502502503503---
504504···603603(err/graphql-error? e)
604604```
605605606606-| `:type` | When thrown |
607607-|--------------------|-----------------------------------------------------------------|
608608-| `:rockbox/network` | Cannot reach rockboxd, or HTTP returned a non-2xx status |
609609-| `:rockbox/graphql` | Server returned `{errors: [...]}` in the response body |
610610-| `:rockbox/config` | Client constructed with bad config or required input missing |
606606+| `:type` | When thrown |
607607+| ------------------ | ------------------------------------------------------------ |
608608+| `:rockbox/network` | Cannot reach rockboxd, or HTTP returned a non-2xx status |
609609+| `:rockbox/graphql` | Server returned `{errors: [...]}` in the response body |
610610+| `:rockbox/config` | Client constructed with bad config or required input missing |
611611612612---
613613
+12-12
sdk/clojure/examples/README.md
···1010ROCKBOX_HOST=192.168.1.42 clj -M:examples -m ex02-now-playing
1111```
12121313-| File | What it shows |
1414-|-----------------------------------|-------------------------------------------------------|
1515-| `ex01_basic_playback.clj` | Pause / seek / resume in one threading-macro chain |
1616-| `ex02_now_playing.clj` | Pretty-print the currently playing track |
1717-| `ex03_library_search.clj` | Search → play first matching album shuffled |
1818-| `ex04_queue_management.clj` | Inspect and modify the live queue |
1919-| `ex05_realtime_events.clj` | WebSocket events with the callback API |
2020-| `ex06_core_async_events.clj` | Same events, consumed via `core.async` channels |
2121-| `ex07_volume_eq.clj` | Adjust volume + write a 5-band EQ preset |
2222-| `ex08_browse_filesystem.clj` | Walk `music_dir` (directories vs files) |
2323-| `ex09_plugin_scrobbler.clj` | Toy "scrobbler" plugin via `use-plugin` / event hook |
2424-| `ex10_smart_playlist.clj` | Create a smart playlist from a Clojure data rule-set |
1313+| File | What it shows |
1414+| ---------------------------- | ---------------------------------------------------- |
1515+| `ex01_basic_playback.clj` | Pause / seek / resume in one threading-macro chain |
1616+| `ex02_now_playing.clj` | Pretty-print the currently playing track |
1717+| `ex03_library_search.clj` | Search → play first matching album shuffled |
1818+| `ex04_queue_management.clj` | Inspect and modify the live queue |
1919+| `ex05_realtime_events.clj` | WebSocket events with the callback API |
2020+| `ex06_core_async_events.clj` | Same events, consumed via `core.async` channels |
2121+| `ex07_volume_eq.clj` | Adjust volume + write a 5-band EQ preset |
2222+| `ex08_browse_filesystem.clj` | Walk `music_dir` (directories vs files) |
2323+| `ex09_plugin_scrobbler.clj` | Toy "scrobbler" plugin via `use-plugin` / event hook |
2424+| `ex10_smart_playlist.clj` | Create a smart playlist from a Clojure data rule-set |
25252626`rockboxd` must be running locally (or specify `ROCKBOX_HOST`) before the
2727examples can connect.
···9494});
9595```
96969797-| Option | Type | Default | Description |
9898-|-----------|----------|--------------------------------|-----------------------------------------------------|
9999-| `host` | `string` | `"localhost"` | Hostname or IP of rockboxd |
100100-| `port` | `number` | `6062` | GraphQL port (env: `ROCKBOX_GRAPHQL_PORT`) |
101101-| `httpUrl` | `string` | `http://{host}:{port}/graphql` | Override the full HTTP URL |
102102-| `wsUrl` | `string` | `ws://{host}:{port}/graphql` | Override the full WebSocket URL |
9797+| Option | Type | Default | Description |
9898+| --------- | -------- | ------------------------------ | ------------------------------------------ |
9999+| `host` | `string` | `"localhost"` | Hostname or IP of rockboxd |
100100+| `port` | `number` | `6062` | GraphQL port (env: `ROCKBOX_GRAPHQL_PORT`) |
101101+| `httpUrl` | `string` | `http://{host}:{port}/graphql` | Override the full HTTP URL |
102102+| `wsUrl` | `string` | `ws://{host}:{port}/graphql` | Override the full WebSocket URL |
103103104104---
105105···321321await client.playlist.resume();
322322```
323323324324-| `InsertPosition` | Value | Effect |
325325-|------------------|-------|------------------------------------------|
326326-| `Next` | `0` | After the currently playing track |
327327-| `AfterCurrent` | `1` | After the last manually inserted track |
328328-| `Last` | `2` | At the end of the queue |
329329-| `First` | `3` | Replace the entire queue |
324324+| `InsertPosition` | Value | Effect |
325325+| ---------------- | ----- | -------------------------------------- |
326326+| `Next` | `0` | After the currently playing track |
327327+| `AfterCurrent` | `1` | After the last manually inserted track |
328328+| `Last` | `2` | At the end of the queue |
329329+| `First` | `3` | Replace the entire queue |
330330331331---
332332···669669670670### Event map
671671672672-| Event | Payload | Description |
673673-|--------------------|------------|---------------------------------------|
674674-| `track:changed` | `Track` | Currently playing track changed |
675675-| `status:changed` | `number` | Playback status changed (0 / 1 / 2) |
676676-| `playlist:changed` | `Playlist` | Active queue was modified |
677677-| `ws:error` | `Error` | WebSocket or subscription error |
678678-| `ws:open` | — | WebSocket connection established |
679679-| `ws:close` | — | WebSocket connection closed |
672672+| Event | Payload | Description |
673673+| ------------------ | ---------- | ----------------------------------- |
674674+| `track:changed` | `Track` | Currently playing track changed |
675675+| `status:changed` | `number` | Playback status changed (0 / 1 / 2) |
676676+| `playlist:changed` | `Playlist` | Active queue was modified |
677677+| `ws:error` | `Error` | WebSocket or subscription error |
678678+| `ws:open` | — | WebSocket connection established |
679679+| `ws:close` | — | WebSocket connection closed |
680680681681---
682682···846846}
847847```
848848849849-| Class | When thrown |
850850-|------------------------|----------------------------------------------------------|
851851-| `RockboxNetworkError` | `fetch` rejects or HTTP status is not 2xx |
852852-| `RockboxGraphQLError` | Server returns `{ errors: [...] }` in the response body |
853853-| `RockboxError` | Base class — catch to handle all SDK errors |
849849+| Class | When thrown |
850850+| --------------------- | ------------------------------------------------------- |
851851+| `RockboxNetworkError` | `fetch` rejects or HTTP status is not 2xx |
852852+| `RockboxGraphQLError` | Server returns `{ errors: [...] }` in the response body |
853853+| `RockboxError` | Base class — catch to handle all SDK errors |
854854855855---
856856
+17-17
sdk/typescript/examples/README.md
···30303131## Index
32323333-| File | Demonstrates |
3434-|---------------------------------------|---------------------------------------------------------|
3535-| `01-basic-playback.ts` | Status, transport controls, current track |
3636-| `02-now-playing.ts` | Real-time WebSocket subscriptions |
3737-| `03-library-search.ts` | Search the library and play results |
3838-| `04-queue-management.ts` | Inspect and manipulate the playback queue |
3939-| `05-saved-playlists.ts` | Create, edit, and play saved playlists |
4040-| `06-smart-playlist.ts` | Build smart playlists from rule sets |
4141-| `07-volume-control.ts` | Read `VolumeInfo` and adjust relative volume |
4242-| `08-eq-config.ts` | Configure the equalizer and replaygain |
4343-| `09-browse-filesystem.ts` | Walk `music_dir` like a tree |
4444-| `10-browse-upnp.ts` | Discover and browse UPnP media servers |
4545-| `11-bluetooth.ts` | Scan, connect, and disconnect Bluetooth devices (Linux) |
4646-| `12-devices.ts` | List and switch Chromecast / AirPlay output sinks |
4747-| `13-plugin-sleep-timer.ts` | Plugin: stop playback after N minutes |
4848-| `14-plugin-scrobbler.ts` | Plugin: log every fully-played track |
4949-| `15-cli-remote.ts` | Tiny interactive remote control in the terminal |
3333+| File | Demonstrates |
3434+| -------------------------- | ------------------------------------------------------- |
3535+| `01-basic-playback.ts` | Status, transport controls, current track |
3636+| `02-now-playing.ts` | Real-time WebSocket subscriptions |
3737+| `03-library-search.ts` | Search the library and play results |
3838+| `04-queue-management.ts` | Inspect and manipulate the playback queue |
3939+| `05-saved-playlists.ts` | Create, edit, and play saved playlists |
4040+| `06-smart-playlist.ts` | Build smart playlists from rule sets |
4141+| `07-volume-control.ts` | Read `VolumeInfo` and adjust relative volume |
4242+| `08-eq-config.ts` | Configure the equalizer and replaygain |
4343+| `09-browse-filesystem.ts` | Walk `music_dir` like a tree |
4444+| `10-browse-upnp.ts` | Discover and browse UPnP media servers |
4545+| `11-bluetooth.ts` | Scan, connect, and disconnect Bluetooth devices (Linux) |
4646+| `12-devices.ts` | List and switch Chromecast / AirPlay output sinks |
4747+| `13-plugin-sleep-timer.ts` | Plugin: stop playback after N minutes |
4848+| `14-plugin-scrobbler.ts` | Plugin: log every fully-played track |
4949+| `15-cli-remote.ts` | Tiny interactive remote control in the terminal |
50505151Each example is self-contained — pick the one closest to what you need, copy
5252it into your project, and adapt.