Make entrainment subtle and track rotation seamless - #10
Open
cubny wants to merge 1 commit into
Open
Conversation
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
force-pushed
the
feat/subtle-entrainment-seamless-rotation
branch
from
July 29, 2026 17:57
c0c5282 to
c1e1e52
Compare
There was a problem hiding this comment.
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 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 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 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two listening issues in continuous mode, addressed together:
Neural entrainment
<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.ModulationStateso the band split stays click-free across chunk boundaries.0.3 -> 0.15) and roughly halve every profile's depth.modulation_band_hztoFocusProfileand a--bandCLI override (0= full-spectrum).Session rotation
_LiveSessionhelper runs each session's reader in a background task feeding anasyncio.Queue, so two sessions can stream concurrently. Retry/backoff and the synth fallback are preserved.Testing
ruff check .,ruff format --check ., andpytestall pass (105 tests).