Skip to content

(Experimental) Add Left Only / Right Only channel modes - #235

Open
W-Floyd wants to merge 10 commits into
CarlosDerSeher:developfrom
W-Floyd:feat/stereo-split-experimental
Open

(Experimental) Add Left Only / Right Only channel modes#235
W-Floyd wants to merge 10 commits into
CarlosDerSeher:developfrom
W-Floyd:feat/stereo-split-experimental

Conversation

@W-Floyd

@W-Floyd W-Floyd commented Jun 8, 2026

Copy link
Copy Markdown

Alternative to #230, implementing the second suggestion in #230 (comment)

In theory this reduces memory usage a lot (50% of buffer data), would love to quantify exactly how useful this might be.

If/when this is good, I can remove support for run-time changes to help simplify (like #230)


Adds a Channel Mode setting (Stereo / Left Only / Right Only) that routes a single input channel to both outputs.

When a non-stereo mode is active, I2S is configured in MONO slot mode with slot_mask=BOTH, and the selected channel is decimated from each decoded PCM chunk in-place before it enters the playback queue. This halves queue memory consumption for mono modes.

Mode changes take effect immediately without restart via i2s_channel_reconfig_std_slot — the player task detects the change each loop iteration, briefly mutes, drains stale buffered audio, reconfigures the slot, and resumes.

W-Floyd and others added 7 commits June 5, 2026 20:28
Adds a runtime-settable channel routing mode (Stereo / Left Only / Right
Only) that duplicates one input channel to both outputs before DSP
processing. Persisted to NVS and exposed in the web UI as a dropdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the DSP sample-duplication approach with I2S SLOT_MODE_MONO:
- Remove the channel routing loop from dsp_processor_worker
- Add make_slot_cfg() helper consolidating MSB/Philips/32-bit slot config
- Add decimate_stereo_inplace() which compacts L or R samples in-place and
  heap_caps_realloc()s each fragment to half size, returning unused bytes to
  the heap immediately before the chunk enters the queue
- Add player_apply_channel_mode() called in main.c after DSP and before
  insert_pcm_chunk for all three codecs (OPUS, FLAC, PCM)
- player_setup_i2s() selects MONO or STEREO slot mode from channel mode
- Player task detects channel mode changes at runtime and calls
  i2s_channel_reconfig_std_slot() with mute/disable/enable bracketing
- framesToBytes uses eff_ch=1 in mono mode so byte-to-frame timing is correct

In mono mode the I2S driver sends the single decimated sample to both L and R
slots. DMA buffers and queued chunk payloads are halved in size (~85 KB heap
+ ~4.5 KB IRAM saved at 44100 Hz / 1000 ms buffer).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- dsp_processor.c: move s_channel_mode + getter/setter outside
  #if CONFIG_USE_DSP_PROCESSOR so they always link; protect with a
  portMUX spinlock so HTTP and player tasks can't race on the value

- dsp_types.h: add DSP_CH_MODE_MAX sentinel to the enum

- dsp_processor_settings.c: reject out-of-range channel_mode values
  from both JSON (HTTP POST) and NVS to prevent invalid enums being
  stored persistently and surviving reboots

- player.c: mark s_i2s_mode volatile; make player_apply_channel_mode
  read s_i2s_mode directly so the decimation decision is always
  consistent with the framesToBytes calculation in player_task,
  eliminating the TOCTOU sync drift on mode switches; also fix the
  dead #else branch which still had ch+bps (addition) instead of
  ch*bps (multiplication) and lacked the eff_ch mono adjustment

- main.c: restructure all three player_apply_channel_mode call sites
  so the call is outside #if CONFIG_USE_DSP_PROCESSOR — I2S slot
  reconfiguration is unconditional so decimation must be too, otherwise
  a non-DSP build sends stereo PCM to a mono-configured DMA slot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d expand

Replace mono I2S slot approach with: decimate to mono on decode (halves each
queued chunk's fragment allocation), then expand back to stereo just before
the I2S write (L=R=the selected channel). I2S stays in SLOT_MODE_STEREO so
both physical outputs always receive signal, regardless of DAC behaviour.

Memory profile: chunks in the PCM queue use ~50% of their normal allocation;
only the one chunk currently being played briefly holds a full stereo buffer
during expand_mono_to_stereo_inplace. The expand is a single backwards in-place
realloc+loop, so no separate bounce buffer is needed.

Add bool mono to pcm_chunk_message_t (calloc-zeroed, so existing alloc paths
default to false). player_apply_channel_mode sets it after decimation;
player_task clears it after expanding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The root cause of the original issue: I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG
with MONO mode defaults slot_mask to LEFT, so only the left output pin
carries the decimated sample — the right is silent.

Fix: add cfg.slot_mask = I2S_STD_SLOT_BOTH in make_slot_cfg. With MONO+BOTH
the I2S hardware duplicates the single DMA sample to both L and R outputs,
which is exactly the desired behaviour for left-only / right-only modes.

Also reverts the intermediate decimate+expand approach (no longer needed):
removes expand_mono_to_stereo_inplace and the bool mono chunk field, and
restores the MONO slot switching + s_i2s_mode tracking that was correct
in concept but missing the slot_mask override.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dsp_processor.c: move dsp_types.h and esp_log.h above the
#if CONFIG_USE_DSP_PROCESSOR guard so the channel mode getter/setter
(which live outside the guard) can see dsp_channel_mode_t and ESP_LOGW.
Drop the now-duplicate esp_log.h from inside the guard.

lightsnapcast/CMakeLists.txt: add dsp_processor to REQUIRES so that
player.c can include dsp_processor.h.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Channel routing is an I2S hardware concern, not a DSP processing
parameter. Remove it from dsp_processor entirely and consolidate in
settings_manager alongside other general device settings:

- Define dsp_channel_mode_t in player.h (its natural owner)
- Remove it from dsp_types.h; drop getter/setter/spinlock from dsp_processor
- Remove dsp_settings_save/load_channel_mode and JSON handling from
  dsp_processor_settings
- Add settings_get/set_channel_mode (NVS-backed) to settings_manager;
  include channel_mode in settings_get/set_from_json
- player.c reads via settings_get_channel_mode through a local helper;
  lightsnapcast now depends on settings_manager instead of dsp_processor
- ui_http_server calls settings_set_channel_mode directly instead of
  routing through a DSP JSON wrapper
- Drain the PCM queue before updating s_i2s_mode on slot-mode switch
  to prevent stereo chunks from being played with mono framesToBytes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
if (p_payload != NULL) {
#if 1
do {
size_t framesToBytes = (scSet.ch + (scSet.bits >> 3));

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to have been a bug prior?

}
#endif
int64_t alreadyWrittenTime_us = 0;
size_t framesToBytes = (scSet.ch + (scSet.bits >> 3));

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here too?

@W-Floyd
W-Floyd marked this pull request as ready for review June 8, 2026 17:02
Adds a fourth channel mode that averages left and right input samples
into a single mono signal routed to both outputs. Implementation follows
the same I2S MONO+BOTH-mask path as left/right-only modes.

mix_stereo_to_mono_inplace() handles 16-bit (int32 accumulator) and
32-bit (int64 accumulator) samples; falls back to left channel for other
bit depths. Validation in settings_set_channel_mode updated to accept
values 0-3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@W-Floyd

W-Floyd commented Jun 8, 2026

Copy link
Copy Markdown
Author

For mono speakers, we could also add a MONO mode that mixes to mono before buffering. So CPU overhead but reduced memory usage - thoughts?

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@W-Floyd
W-Floyd force-pushed the feat/stereo-split-experimental branch from 2c0fca9 to 2a2b6da Compare June 8, 2026 17:36
@luar123

luar123 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

I think mono mixdown is already available in dsp processor.

@CarlosDerSeher

Copy link
Copy Markdown
Owner

Yes I think I've done this in the past already.

@W-Floyd

W-Floyd commented Jun 8, 2026

Copy link
Copy Markdown
Author

Ah yeah, CONFIG_SNAPCLIENT_MIX_LR_TO_MONO kconfig.

This differs by mixing down before buffering, rather than after all other DSP.

…H_MONO

The compile-time mono mixdown is redundant now that the runtime channel_mode
setting (DSP_CH_MONO) covers the same behavior with more flexibility and
correct I2S slot mode reconfiguration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants