Skip to content

Make entrainment subtle and track rotation seamless - #10

Open
cubny wants to merge 1 commit into
mainfrom
feat/subtle-entrainment-seamless-rotation
Open

Make entrainment subtle and track rotation seamless#10
cubny wants to merge 1 commit into
mainfrom
feat/subtle-entrainment-seamless-rotation

Conversation

@cubny

@cubny cubny commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

Two listening issues in continuous mode, addressed together:

  1. The entrainment modulation overshadowed the music. It was a full-spectrum amplitude modulation at 0.35-0.40 depth, i.e. a 60-100% volume swing across the whole mix ~15-20x/sec (an obvious tremolo).
  2. Tracks cut out abruptly before the next one. Session rotation tore down the WebSocket and reconnected; the output prebuffer drained during the multi-second gap, and the previous crossfade only triggered on a single >=3s chunk (which never happens with small streamed chunks), so it effectively never ran.

Neural entrainment

  • Band-limit the amplitude modulation to a low band (default <500 Hz): only that band pulses, so the melody/mids/highs pass through steady and the effect is felt as gentle rhythmic energy rather than an audible throb.
  • Stateful Butterworth split (scipy, guarded with a full-spectrum fallback) threaded through ModulationState so the band split stays click-free across chunk boundaries.
  • Lower the default depth (0.3 -> 0.15) and roughly halve every profile's depth.
  • Add modulation_band_hz to FocusProfile and a --band CLI override (0 = full-spectrum).

Session rotation

  • Replace teardown/reconnect with an overlapping dual-session crossfade: since the 10-minute cap is per-WebSocket, the next session is opened early and warmed up while the current one keeps playing, then the two live streams are blended sample-accurately, so there is no audible gap.
  • Asymmetric crossfade curve: the outgoing track fades to silence by 70% of the window (and the incoming rises with an equal-power sine over the full window). This removes the faint "ghost" of the old track and the two-basslines-stacking mud right before the switch completes.
  • New _LiveSession helper runs each session's reader in a background task feeding an asyncio.Queue, so two sessions can stream concurrently. Retry/backoff and the synth fallback are preserved.

Testing

  • ruff check ., ruff format --check ., and pytest all pass (105 tests).
  • Added coverage for band-limited modulation (full-spectrum stays attenuation-only; band-limited stays bounded; high frequencies preserved) and for the crossfade gain curve + chunk buffer.
  • Verified the rotation/crossfade end-to-end against an in-memory fake session: multiple rotations with no silent gap.

Neural entrainment:
- Band-limit amplitude modulation to a low band (default <500 Hz) so the
  melody/mids/highs pass through untouched while the entrainment pulse works
  in the background instead of the whole mix audibly throbbing.
- Use a stateful Butterworth split (scipy, guarded with a full-spectrum
  fallback) threaded through ModulationState for click-free chunk boundaries.
- Lower default modulation depth (0.3 -> 0.15) and halve per-profile depths.
- Add modulation_band_hz to FocusProfile and a --band CLI override.

Session rotation:
- Replace the gap-prone teardown/reconnect with an overlapping dual-session
  crossfade: the next session is opened early, warmed up, then the two live
  streams are blended sample-accurately, eliminating the audible cutout.
- Use an asymmetric crossfade curve that pulls the outgoing track to silence
  by 70% of the window, removing the bass-stacking mud right before the switch.
- Add _LiveSession (background reader + queue) so two sessions stream at once.

Tests: cover band-limited behavior and the crossfade/buffer helpers.
@cubny
cubny force-pushed the feat/subtle-entrainment-seamless-rotation branch from c0c5282 to c1e1e52 Compare July 29, 2026 17:57
@cubny
cubny requested a review from Copilot July 29, 2026 17:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR improves continuous playback quality by (1) making neural entrainment less obtrusive via band-limited modulation and lower default depths, and (2) making session rotation seamless by overlapping two live WebSocket sessions and crossfading between them to avoid audible gaps.

Changes:

  • Add band-limited entrainment (default <500 Hz) with stateful filtering for click-free chunk boundaries, plus CLI/profile support for configuring/disabling the band.
  • Replace teardown/reconnect rotation with overlapping dual-session streaming and an asymmetric equal-power crossfade curve.
  • Add/update tests for entrainment band-limiting behavior and for crossfade gain curves + chunk-buffer correctness.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/test_rotation.py Adds coverage for new crossfade gain curve and sample-accurate chunk buffering.
tests/test_entrainment.py Extends coverage for band-limited vs full-spectrum entrainment behavior.
src/focus/profiles.py Introduces modulation_band_hz and reduces default modulation depths across profiles.
src/focus/generation/lyria_client.py Implements overlapping dual-session rotation with crossfade, plus new helper classes/utilities.
src/focus/dsp/entrainment.py Implements band-limited modulation using a stateful Butterworth split with graceful fallback.
src/focus/cli.py Adds --band override and wires it into profile overrides.
src/focus/audio/pipeline.py Passes profile band cutoff through to apply_entrainment().

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_rotation.py
Comment on lines +21 to +24
# End of the crossfade: no old, full new.
fade_out, fade_in = _crossfade_gains(1000, 1, 1000)
assert abs(fade_out[0]) < 1e-9
assert abs(fade_in[0] - 1.0) < 1e-9
Comment on lines +192 to +195
finally:
# Sentinel so consumers waiting on get() always wake up.
with contextlib.suppress(Exception):
self._queue.put_nowait(None)
Comment on lines +401 to +407
chunk = await current.get()
if chunk is None: # session ended (naturally or via error)
break
if current.elapsed >= rotate_at:
rotate = True
break
yield chunk
Comment thread tests/test_rotation.py
Comment on lines +28 to +34
total = 1000
fade_out, _ = _crossfade_gains(0, total, total)
end_idx = int(CROSSFADE_OUT_END_FRAC * total)
# Outgoing has reached (near) silence by the cutover point...
assert fade_out[end_idx] < 1e-6
# ...and stays there for the rest of the crossfade.
assert np.all(fade_out[end_idx:] < 1e-6)
Comment on lines +53 to +58
idx = np.arange(start, start + length, dtype=np.float64)
frac = np.clip(idx / max(total, 1), 0.0, 1.0)
fade_in = np.sin(frac * (0.5 * np.pi))
out_frac = np.clip(frac / max(out_end_frac, 1e-6), 0.0, 1.0)
fade_out = np.cos(out_frac * (0.5 * np.pi))
return fade_out, fade_in
Comment thread src/focus/cli.py
Comment on lines +247 to +251
if frequency or depth or band is not None or prompt:
if band is None:
band_hz = focus_profile.modulation_band_hz
else:
# 0 (or negative) disables band-limiting -> full-spectrum modulation
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.

2 participants