From 4d4f1066f6b7bafc62899b86e0bedd9d9c63c5fa Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:18:28 -0700 Subject: [PATCH] Add opt-in beat-locked curve build cut and drop presentation --- roadscore/prototype/curve_build_drop.py | 119 ++++++++++++++++++ .../prototype/review_curve_build_drop.py | 56 +++++++++ roadscore/prototype/test_curve_build_drop.py | 67 ++++++++++ 3 files changed, 242 insertions(+) create mode 100644 roadscore/prototype/curve_build_drop.py create mode 100644 roadscore/prototype/review_curve_build_drop.py create mode 100644 roadscore/prototype/test_curve_build_drop.py diff --git a/roadscore/prototype/curve_build_drop.py b/roadscore/prototype/curve_build_drop.py new file mode 100644 index 0000000000..345e465aef --- /dev/null +++ b/roadscore/prototype/curve_build_drop.py @@ -0,0 +1,119 @@ +"""Opt-in presentation for one explicitly scheduled replay curve; no route inference.""" +import math + +import numpy as np +from scipy.signal import butter, sosfilt + +from signal_shaker import ShakerGrid, SignalShaker + + +class CurveBuildDrop: + def __init__(self, rate=48000, max_frames=4800, *, cut_ms=120., ramp_ms=10., low_boost_db=1.5): + self.rate=rate;self.max_frames=max_frames + self.cut_frames=round(cut_ms*rate/1000);self.ramp_frames=max(1,round(ramp_ms*rate/1000)) + self.cancel_frames=max(1,round(.03*rate));self.boost_frames=round(.22*rate) + self.low_boost=10**(min(3.,max(0.,low_boost_db))/20)-1. + # Reuse the signal shaker's unpitched voice; RNG/FFT/filter design happen once. + prepared=SignalShaker(ShakerGrid(128.,0.,1.,1.,True,'prepared voice only'),rate,peak=.08) + self.grain=prepared.grain/.08 + self.sos=butter(2,160.,fs=rate,output='sos').astype(np.float32) + self.zi=np.zeros((len(self.sos),2,2),np.float32) + sosfilt(self.sos,np.zeros((1,2),np.float32),axis=0,zi=self.zi) + self.indices=np.arange(max_frames,dtype=np.float64) + self.schedule=None;self.last_pulse=None;self.tail=np.zeros((0,2),np.float32) + self.strength=0.;self.actual_payoff=None;self.pulse_frames=[] + self.status=dict(rendered_stage='bypass',rendered_active=False,added_delay_samples=0) + + def process(self, pcm, start_frame, grid, build_start_frame, payoff_frame, *, enabled=False, + source_fresh=True, blocked=False): + """Same frames, no input mutation. Caller supplies and authorizes the schedule. + + Disabling a fully bypassed instance is bit exact; cancelling an active effect + returns smoothly to the original PCM within 30ms. Uncertain beat grids never + start a roll. Nothing in this class reads future route state or calls I/O. + """ + if not len(pcm):return pcm + finite=lambda n:isinstance(n,(int,float)) and not isinstance(n,bool) and math.isfinite(n) + valid=bool(enabled and source_fresh and not blocked and grid.usable + and finite(build_start_frame) and finite(payoff_frame) + and payoff_frame-build_start_frame>self.cut_frames+self.ramp_frames) + candidate=(round(build_start_frame),round(payoff_frame)) if valid else None + # Keep the old schedule only long enough to finish a cancellation ramp. + if candidate is not None and candidate!=self.schedule: + self.schedule=candidate;self.last_pulse=None;self.actual_payoff=None;self.tail=np.zeros((0,2),np.float32) + outputs=[];peaks=[];cut_min=1.;any_active=False;stage='bypass' + for offset in range(0,len(pcm),self.max_frames): + x=pcm[offset:offset+self.max_frames];n=len(x);first=start_frame+offset;end=first+n + low,self.zi=sosfilt(self.sos,x,axis=0,zi=self.zi) + if self.schedule is None: + outputs.append(x);continue + build,payoff=self.schedule;cut_start=payoff-self.cut_frames + requested=valid and end>build and firstself.strength else -1.)/self.cancel_frames, + min(self.strength,target),max(self.strength,target)).astype(np.float32) + self.strength=float(strength[-1]) + if not np.any(strength): + self.tail=np.zeros((0,2),np.float32);outputs.append(x);continue + frames=self.indices[:n]+first + roll=np.zeros_like(x);take=min(n,len(self.tail));roll[:take]+=self.tail[:take];self.tail=self.tail[take:].copy() + if requested and firstbuild: + beat=self.rate*60/grid.bpm;origin=grid.beat_phase*self.rate + # Eighth notes establish the motif; the final four beats use sixteenths. + boundary=max(build,payoff-4*beat) + for lo,hi,step in ((build,boundary,beat/2),(boundary,cut_start,beat/4)): + lo=max(first,lo);hi=min(end,hi) + if hi<=lo:continue + tick=math.ceil((lo-origin)/step-1e-10) + while round(origin+tick*step)=cut_start)&(frames=payoff)&(frames=0)&(elapsed0:self.actual_payoff=payoff + if not rendered:continue + if not valid:stage='cancelling' + elif end<=cut_start:stage='build' + elif first1 else outputs[0] + self.status=dict(enabled=bool(enabled),input_fresh=bool(source_fresh),blocked=bool(blocked),grid_usable=bool(grid.usable), + rendered_stage=stage,rendered_active=any_active,rendered_strength=self.strength,rendered_roll_peak=max(peaks,default=0.), + rendered_cut_gain_min=cut_min,planned_build_frame=None if self.schedule is None else self.schedule[0], + planned_payoff_frame=None if self.schedule is None else self.schedule[1],actual_payoff_frame=self.actual_payoff, + pulse_count=len(self.pulse_frames),cut_ms=1000*self.cut_frames/self.rate,ramp_ms=1000*self.ramp_frames/self.rate, + low_boost_db=20*math.log10(1+self.low_boost),added_delay_samples=0, + source='explicit replay presentation schedule; no composition or route lookup') + return result + + def snapshot(self):return dict(self.status) diff --git a/roadscore/prototype/review_curve_build_drop.py b/roadscore/prototype/review_curve_build_drop.py new file mode 100644 index 0000000000..578446bfce --- /dev/null +++ b/roadscore/prototype/review_curve_build_drop.py @@ -0,0 +1,56 @@ +"""Offline same-PCM build/cut/drop audition from an archived rhythm assessment.""" +import argparse +import hashlib +import json +from pathlib import Path +import time + +import numpy as np +import soundfile as sf + +from curve_build_drop import CurveBuildDrop +from signal_shaker import ShakerGrid + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('source',type=Path);p.add_argument('output',type=Path) + p.add_argument('--timeline',type=Path) + p.add_argument('--build-start',type=float,required=True);p.add_argument('--apex',type=float,required=True) + p.add_argument('--start',type=float);p.add_argument('--end',type=float) + a=p.parse_args();a.output.mkdir(parents=True,exist_ok=False) + wave,rate=sf.read(a.source,dtype='float32',always_2d=True) + timeline_path=a.timeline or a.source.with_name('rhythm_timeline.json') + timeline=json.loads(timeline_path.read_text());apex=round(a.apex*rate) + entry=next(e for e in reversed(timeline) if e['start_frame']<=apex1)),sha256=hashlib.sha256(target.read_bytes()).hexdigest()) + (a.output/'comparison.json').write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps({key:report[key] for key in ('clip_build_s','clip_drop_s','apex_quantization_offset_ms','work_mean_ms','work_max_ms','files')},indent=2)) + + +if __name__=='__main__':main() diff --git a/roadscore/prototype/test_curve_build_drop.py b/roadscore/prototype/test_curve_build_drop.py new file mode 100644 index 0000000000..d8fd93fb9e --- /dev/null +++ b/roadscore/prototype/test_curve_build_drop.py @@ -0,0 +1,67 @@ +import unittest + +import numpy as np + +from curve_build_drop import CurveBuildDrop +from signal_shaker import ShakerGrid + +GRID=ShakerGrid(120.,0.,.9,.9,True,'test grid') + + +class BuildDropTests(unittest.TestCase): + def test_disabled_uncertain_and_blocked_are_bit_exact(self): + x=np.random.default_rng(2).normal(0,.1,(4800,2)).astype(np.float32) + unknown=ShakerGrid(120.,0.,0.,0.,False,'uncertain') + for enabled,fresh,blocked,grid in ((False,True,False,GRID),(True,False,False,GRID), + (True,True,True,GRID),(True,True,False,unknown)): + before=x.copy();fx=CurveBuildDrop() + y=fx.process(x,0,grid,0,48000,enabled=enabled,source_fresh=fresh,blocked=blocked) + self.assertIs(y,x);np.testing.assert_array_equal(x,before) + + def test_grid_aligned_crescendo_and_exact_cut_payoff(self): + fx=CurveBuildDrop(low_boost_db=0);x=np.full((4*48000,2),.2,np.float32) + y=fx.process(x,0,GRID,0,3*48000,enabled=True) + self.assertEqual(y.shape,x.shape);self.assertTrue(np.isfinite(y).all()) + self.assertTrue(np.all(y[round(2.89*48000):3*48000]==0)) + self.assertGreater(y[3*48000,0],0) + np.testing.assert_array_equal(y[3*48000+480:],x[3*48000+480:]) + self.assertEqual(fx.snapshot()['actual_payoff_frame'],3*48000) + self.assertEqual(fx.snapshot()['added_delay_samples'],0) + for frame in fx.pulse_frames: + self.assertEqual(frame%6000,0) + self.assertGreater(max(abs(y[:2*48000]-x[:2*48000]).ravel()),.02) + # The final mute and return use 480-sample ramps, not a discontinuity. + smooth=CurveBuildDrop(low_boost_db=0);smooth.grain.fill(0) + ramped=smooth.process(x,0,GRID,0,3*48000,enabled=True) + self.assertLess(np.max(abs(np.diff(ramped[round(2.88*48000):round(3.02*48000),0]))),.001) + + def test_cancellation_during_cut_restores_core_without_hanging_mute(self): + for cancel in ({'source_fresh':False},{'blocked':True},{'enabled':False}): + fx=CurveBuildDrop(low_boost_db=0) + fx.process(np.full((round(2.95*48000),2),.2,np.float32),0,GRID,0,3*48000,enabled=True) + x=np.full((4800,2),.2,np.float32) + y=fx.process(x,round(2.95*48000),GRID,0,3*48000,**({'enabled':True}|cancel)) + self.assertGreater(y[0,0],0);self.assertLess(y[0,0],.001) + self.assertLess(np.max(abs(np.diff(y[:,0]))),.001) + np.testing.assert_array_equal(y[round(.031*48000):],x[round(.031*48000):]) + self.assertIsNone(fx.snapshot()['actual_payoff_frame']) + self.assertEqual(fx.strength,0.) + + def test_headroom_and_source_ownership(self): + fx=CurveBuildDrop();t=np.arange(4*48000)/48000 + x=(.97*np.sin(2*np.pi*80*t))[:,None]*np.ones((1,2),np.float32);x=x.astype(np.float32) + before=x.copy();y=fx.process(x,0,GRID,0,3*48000,enabled=True) + self.assertLessEqual(float(abs(y).max()),1.) + np.testing.assert_array_equal(x,before) + + def test_irregular_callback_blocks_match_whole_render(self): + x=np.random.default_rng(7).normal(0,.1,(4*48000,2)).astype(np.float32) + whole=CurveBuildDrop().process(x,0,GRID,0,3*48000,enabled=True) + fx=CurveBuildDrop();blocks=[];start=0;sizes=[37,800,13,9701,4800] + while start