livepilot 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,741 @@
1
+ """Automation curve generators.
2
+
3
+ Pure math — no Ableton dependency. Generates lists of {time, value, duration}
4
+ dicts that can be fed directly to insert_step() via the automation tools.
5
+
6
+ 16 curve types organized in 4 categories:
7
+
8
+ BASIC WAVEFORMS (what every LFO can do):
9
+ - linear: Even ramp. Basic fades, simple transitions.
10
+ - exponential: Slow start, fast end. Filter sweeps (perceptually even).
11
+ - logarithmic: Fast start, slow end. Volume fades (perceptually even).
12
+ - s_curve: Slow-fast-slow. Natural crossfades, smooth transitions.
13
+ - sine: Periodic oscillation. Tremolo, auto-pan, breathing.
14
+ - sawtooth: Ramp + reset. Sidechain pumping, rhythmic ducking.
15
+ - spike: Peak + decay. Dub throws, reverb sends, accent hits.
16
+ - square: Binary toggle. Stutter, gating, trance gates.
17
+ - steps: Quantized staircase. Pitched sequences, rhythmic patterns.
18
+
19
+ ORGANIC / NATURAL MOTION (what makes automation feel alive):
20
+ - perlin: Smooth coherent noise. Organic drift, evolving textures.
21
+ Not random — flows naturally. The secret to ambient automation.
22
+ - brownian: Random walk with momentum. Drifts with accumulation.
23
+ Like analog gear — never exactly the same twice.
24
+ - spring: Overshoot + settle. How physical knobs actually move.
25
+ Damped oscillation around target value.
26
+
27
+ SHAPE CONTROL (precision curves for intentional design):
28
+ - bezier: Arbitrary smooth shape via control points. The animation
29
+ industry standard. Describe ANY curve with 2-4 points.
30
+ - easing: 30+ motion design curves: ease_in, ease_out, bounce,
31
+ elastic, back_overshoot. Each has a distinct character.
32
+
33
+ ALGORITHMIC / GENERATIVE (Xenakis-level intelligence):
34
+ - euclidean: Bjorklund algorithm on automation points. Distributes
35
+ N events across M slots as evenly as possible. Rhythmic
36
+ intelligence applied to parameter changes.
37
+ - stochastic: Random values within narrowing/widening bounds.
38
+ Controlled randomness — probabilistically bounded, not chaos.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import math
44
+ from typing import Any
45
+
46
+
47
+ def generate_curve(
48
+ curve_type: str,
49
+ duration: float = 4.0,
50
+ density: int = 16,
51
+ # Common params
52
+ start: float = 0.0,
53
+ end: float = 1.0,
54
+ # Oscillator params
55
+ center: float = 0.5,
56
+ amplitude: float = 0.5,
57
+ frequency: float = 1.0,
58
+ phase: float = 0.0,
59
+ # Spike params
60
+ peak: float = 1.0,
61
+ decay: float = 4.0,
62
+ # Square params
63
+ low: float = 0.0,
64
+ high: float = 1.0,
65
+ # Steps params
66
+ values: list[float] | None = None,
67
+ # Curve factor (steepness for exp/log/easing)
68
+ factor: float = 3.0,
69
+ # Organic params
70
+ seed: float = 0.0,
71
+ drift: float = 0.0,
72
+ volatility: float = 0.1,
73
+ damping: float = 0.15,
74
+ stiffness: float = 8.0,
75
+ # Bezier control points
76
+ control1: float = 0.0,
77
+ control2: float = 1.0,
78
+ control1_time: float = 0.33,
79
+ control2_time: float = 0.66,
80
+ # Easing type
81
+ easing_type: str = "ease_out",
82
+ # Euclidean params
83
+ hits: int = 5,
84
+ steps: int = 16,
85
+ # Stochastic params
86
+ narrowing: float = 0.5,
87
+ # Transforms
88
+ invert: bool = False,
89
+ point_duration: float = 0.0,
90
+ ) -> list[dict[str, float]]:
91
+ """Generate automation curve as a list of {time, value, duration} points.
92
+
93
+ Args:
94
+ curve_type: One of linear, exponential, logarithmic, s_curve,
95
+ sine, sawtooth, spike, square, steps.
96
+ duration: Total duration in beats.
97
+ density: Number of points to generate (ignored for 'steps').
98
+ point_duration: Duration of each automation step (0 = auto from density).
99
+ invert: Flip values (1.0 - value).
100
+ factor: Steepness for exponential/logarithmic curves (2-6 typical).
101
+
102
+ Returns:
103
+ List of dicts: [{time: float, value: float, duration: float}, ...]
104
+ """
105
+ generators = {
106
+ # Basic waveforms
107
+ "linear": _linear,
108
+ "exponential": _exponential,
109
+ "logarithmic": _logarithmic,
110
+ "s_curve": _s_curve,
111
+ "sine": _sine,
112
+ "sawtooth": _sawtooth,
113
+ "spike": _spike,
114
+ "square": _square,
115
+ "steps": _steps,
116
+ # Organic / natural motion
117
+ "perlin": _perlin,
118
+ "brownian": _brownian,
119
+ "spring": _spring,
120
+ # Shape control
121
+ "bezier": _bezier,
122
+ "easing": _easing,
123
+ # Algorithmic / generative
124
+ "euclidean": _euclidean,
125
+ "stochastic": _stochastic,
126
+ }
127
+
128
+ gen = generators.get(curve_type)
129
+ if gen is None:
130
+ raise ValueError(
131
+ f"Unknown curve type '{curve_type}'. "
132
+ f"Options: {', '.join(generators.keys())}"
133
+ )
134
+
135
+ # Build kwargs for the generator
136
+ kwargs: dict[str, Any] = {
137
+ "duration": duration, "density": density,
138
+ "start": start, "end": end,
139
+ "center": center, "amplitude": amplitude,
140
+ "frequency": frequency, "phase": phase,
141
+ "peak": peak, "decay": decay,
142
+ "low": low, "high": high,
143
+ "values": values or [], "factor": factor,
144
+ "seed": seed, "drift": drift, "volatility": volatility,
145
+ "damping": damping, "stiffness": stiffness,
146
+ "control1": control1, "control2": control2,
147
+ "control1_time": control1_time, "control2_time": control2_time,
148
+ "easing_type": easing_type,
149
+ "hits": hits, "steps": steps, "narrowing": narrowing,
150
+ }
151
+
152
+ points = gen(**kwargs)
153
+
154
+ # Auto-calculate point duration if not specified
155
+ if point_duration <= 0 and len(points) > 1:
156
+ point_duration = duration / len(points)
157
+
158
+ # Apply transforms
159
+ for p in points:
160
+ if invert:
161
+ p["value"] = 1.0 - p["value"]
162
+ # Clamp to 0.0-1.0
163
+ p["value"] = max(0.0, min(1.0, p["value"]))
164
+ # Set duration if not already set
165
+ if "duration" not in p or p["duration"] <= 0:
166
+ p["duration"] = point_duration
167
+
168
+ return points
169
+
170
+
171
+ # -- Generators ----------------------------------------------------------------
172
+
173
+ def _linear(duration: float, density: int, start: float, end: float, **_) -> list:
174
+ points = []
175
+ for i in range(density):
176
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
177
+ points.append({
178
+ "time": t * duration,
179
+ "value": start + (end - start) * t,
180
+ })
181
+ return points
182
+
183
+
184
+ def _exponential(duration: float, density: int, start: float, end: float,
185
+ factor: float = 3.0, **_) -> list:
186
+ """Slow start, fast end. y = x^n where n > 1."""
187
+ points = []
188
+ for i in range(density):
189
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
190
+ curve_t = t ** factor
191
+ points.append({
192
+ "time": t * duration,
193
+ "value": start + (end - start) * curve_t,
194
+ })
195
+ return points
196
+
197
+
198
+ def _logarithmic(duration: float, density: int, start: float, end: float,
199
+ factor: float = 3.0, **_) -> list:
200
+ """Fast start, slow end. y = 1 - (1-x)^n."""
201
+ points = []
202
+ for i in range(density):
203
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
204
+ curve_t = 1.0 - (1.0 - t) ** factor
205
+ points.append({
206
+ "time": t * duration,
207
+ "value": start + (end - start) * curve_t,
208
+ })
209
+ return points
210
+
211
+
212
+ def _s_curve(duration: float, density: int, start: float, end: float, **_) -> list:
213
+ """Slow-fast-slow. Smoothstep: 3t^2 - 2t^3."""
214
+ points = []
215
+ for i in range(density):
216
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
217
+ curve_t = 3 * t * t - 2 * t * t * t
218
+ points.append({
219
+ "time": t * duration,
220
+ "value": start + (end - start) * curve_t,
221
+ })
222
+ return points
223
+
224
+
225
+ def _sine(duration: float, density: int, center: float, amplitude: float,
226
+ frequency: float, phase: float = 0.0, **_) -> list:
227
+ """Periodic oscillation. frequency = cycles per duration."""
228
+ points = []
229
+ for i in range(density):
230
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
231
+ angle = 2 * math.pi * frequency * t + phase * 2 * math.pi
232
+ points.append({
233
+ "time": t * duration,
234
+ "value": center + amplitude * math.sin(angle),
235
+ })
236
+ return points
237
+
238
+
239
+ def _sawtooth(duration: float, density: int, start: float, end: float,
240
+ frequency: float, **_) -> list:
241
+ """Ramp up then reset. frequency = resets per duration."""
242
+ points = []
243
+ for i in range(density):
244
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
245
+ # Position within current cycle (0.0 to 1.0)
246
+ cycle_pos = (t * frequency) % 1.0
247
+ points.append({
248
+ "time": t * duration,
249
+ "value": start + (end - start) * cycle_pos,
250
+ })
251
+ return points
252
+
253
+
254
+ def _spike(duration: float, density: int, peak: float, decay: float, **_) -> list:
255
+ """Instant peak then exponential decay. For dub throws."""
256
+ points = []
257
+ for i in range(density):
258
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
259
+ points.append({
260
+ "time": t * duration,
261
+ "value": peak * math.exp(-decay * t),
262
+ })
263
+ return points
264
+
265
+
266
+ def _square(duration: float, density: int, low: float, high: float,
267
+ frequency: float, **_) -> list:
268
+ """Binary on/off toggle."""
269
+ points = []
270
+ for i in range(density):
271
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
272
+ cycle_pos = (t * frequency) % 1.0
273
+ points.append({
274
+ "time": t * duration,
275
+ "value": high if cycle_pos < 0.5 else low,
276
+ })
277
+ return points
278
+
279
+
280
+ def _steps(values: list[float], duration: float, **_) -> list:
281
+ """Quantized staircase from explicit value list."""
282
+ if not values:
283
+ return []
284
+ step_dur = duration / len(values)
285
+ return [
286
+ {"time": i * step_dur, "value": v, "duration": step_dur}
287
+ for i, v in enumerate(values)
288
+ ]
289
+
290
+
291
+ # -- Organic / Natural Motion -------------------------------------------------
292
+
293
+ def _perlin(duration: float, density: int, center: float = 0.5,
294
+ amplitude: float = 0.3, frequency: float = 1.0,
295
+ seed: float = 0.0, **_) -> list:
296
+ """Smooth coherent noise. Organic drift that flows naturally.
297
+
298
+ Uses a simplified 1D Perlin-like interpolation (cubic hermite between
299
+ random gradients). Not true Perlin but captures the essential quality:
300
+ smooth, non-repeating, organic movement.
301
+
302
+ Musical use: Subtle filter drift, evolving textures, ambient automation
303
+ that never sounds mechanical. The secret ingredient of "alive" sound.
304
+ """
305
+ import hashlib
306
+
307
+ def _hash_float(x: float, s: float) -> float:
308
+ """Deterministic pseudo-random float from position + seed."""
309
+ h = hashlib.md5(f"{x:.6f}:{s:.6f}".encode()).hexdigest()
310
+ return (int(h[:8], 16) / 0xFFFFFFFF) * 2.0 - 1.0
311
+
312
+ def _smoothstep(t: float) -> float:
313
+ return t * t * (3.0 - 2.0 * t)
314
+
315
+ def _noise_1d(x: float, s: float) -> float:
316
+ x0 = int(math.floor(x))
317
+ x1 = x0 + 1
318
+ t = x - x0
319
+ t = _smoothstep(t)
320
+ g0 = _hash_float(float(x0), s)
321
+ g1 = _hash_float(float(x1), s)
322
+ return g0 + t * (g1 - g0)
323
+
324
+ points = []
325
+ for i in range(density):
326
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
327
+ # Multi-octave noise for richer texture
328
+ noise = 0.0
329
+ amp = 1.0
330
+ freq = frequency
331
+ for _ in range(3): # 3 octaves
332
+ noise += amp * _noise_1d(t * freq * 4.0, seed)
333
+ amp *= 0.5
334
+ freq *= 2.0
335
+ noise /= 1.75 # normalize
336
+ points.append({
337
+ "time": t * duration,
338
+ "value": center + amplitude * noise,
339
+ })
340
+ return points
341
+
342
+
343
+ def _brownian(duration: float, density: int, start: float = 0.5,
344
+ drift: float = 0.0, volatility: float = 0.1,
345
+ seed: float = 0.0, **_) -> list:
346
+ """Random walk with momentum. Drifts and accumulates naturally.
347
+
348
+ Each step adds a small random displacement to the previous value.
349
+ drift: directional tendency (positive = upward trend)
350
+ volatility: step size (how wild the walk is)
351
+
352
+ Musical use: Analog-style parameter drift, parameters that wander
353
+ organically, never-repeating modulation for installation work.
354
+ """
355
+ import hashlib
356
+
357
+ def _det_random(i: int, s: float) -> float:
358
+ h = hashlib.md5(f"{i}:{s:.6f}".encode()).hexdigest()
359
+ return (int(h[:8], 16) / 0xFFFFFFFF) * 2.0 - 1.0
360
+
361
+ points = []
362
+ value = start
363
+ for i in range(density):
364
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
365
+ points.append({"time": t * duration, "value": value})
366
+ step = drift / density + volatility * _det_random(i, seed)
367
+ value += step
368
+ # Soft boundary reflection (bounce off 0/1 instead of hard clamp)
369
+ if value > 1.0:
370
+ value = 2.0 - value
371
+ elif value < 0.0:
372
+ value = -value
373
+ return points
374
+
375
+
376
+ def _spring(duration: float, density: int, start: float = 0.0,
377
+ end: float = 1.0, damping: float = 0.15,
378
+ stiffness: float = 8.0, **_) -> list:
379
+ """Damped spring oscillation. Overshoots target then settles.
380
+
381
+ Models a physical spring: fast attack, overshoot, ring, settle.
382
+ This is how a real knob on analog gear moves when turned quickly.
383
+
384
+ damping: how quickly oscillation dies (0.05 = ringy, 0.3 = dead)
385
+ stiffness: spring constant (higher = faster oscillation)
386
+
387
+ Musical use: Filter cutoff changes with analog character,
388
+ realistic parameter transitions, bouncy builds.
389
+ """
390
+ points = []
391
+ for i in range(density):
392
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
393
+ # Damped oscillation: e^(-dt) * cos(wt)
394
+ envelope = math.exp(-damping * stiffness * t * 4)
395
+ oscillation = math.cos(stiffness * t * 4 * math.pi)
396
+ # Starts at 'start', settles at 'end', overshoots in between
397
+ value = end + (start - end) * envelope * oscillation
398
+ points.append({"time": t * duration, "value": value})
399
+ return points
400
+
401
+
402
+ # -- Shape Control -------------------------------------------------------------
403
+
404
+ def _bezier(duration: float, density: int, start: float = 0.0,
405
+ end: float = 1.0, control1: float = 0.0, control2: float = 1.0,
406
+ control1_time: float = 0.33, control2_time: float = 0.66, **_) -> list:
407
+ """Cubic bezier curve. Arbitrary smooth shape via 2 control points.
408
+
409
+ The animation industry standard. Four points define the curve:
410
+ P0 = (0, start), P1 = (control1_time, control1),
411
+ P2 = (control2_time, control2), P3 = (1, end)
412
+
413
+ Musical use: Custom transition shapes, precise acceleration/deceleration
414
+ profiles, any curve that the basic types can't describe.
415
+ """
416
+ points = []
417
+ for i in range(density):
418
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
419
+ # Cubic bezier: B(t) = (1-t)^3*P0 + 3(1-t)^2*t*P1 + 3(1-t)*t^2*P2 + t^3*P3
420
+ u = 1.0 - t
421
+ time_val = (u**3 * 0.0 + 3 * u**2 * t * control1_time +
422
+ 3 * u * t**2 * control2_time + t**3 * 1.0)
423
+ value = (u**3 * start + 3 * u**2 * t * control1 +
424
+ 3 * u * t**2 * control2 + t**3 * end)
425
+ points.append({"time": time_val * duration, "value": value})
426
+ return points
427
+
428
+
429
+ def _easing(duration: float, density: int, start: float = 0.0,
430
+ end: float = 1.0, easing_type: str = "ease_out",
431
+ factor: float = 3.0, **_) -> list:
432
+ """Motion design easing functions. 10+ standard curves.
433
+
434
+ easing_type options:
435
+ - ease_in: slow start (power curve)
436
+ - ease_out: slow end (inverse power)
437
+ - ease_in_out: slow start + end (smoothstep)
438
+ - bounce: bounces at the end like a dropped ball
439
+ - elastic: spring-like overshoot with oscillation
440
+ - back: overshoots then returns (rubber band)
441
+ - circular_in: quarter-circle acceleration
442
+ - circular_out: quarter-circle deceleration
443
+
444
+ Musical use: Each easing has a distinct character. bounce for
445
+ percussive automation, elastic for synth filter resonance,
446
+ back for dramatic transitions with overshoot.
447
+ """
448
+ def _ease_in(t: float) -> float:
449
+ return t ** factor
450
+
451
+ def _ease_out(t: float) -> float:
452
+ return 1.0 - (1.0 - t) ** factor
453
+
454
+ def _ease_in_out(t: float) -> float:
455
+ if t < 0.5:
456
+ return 0.5 * (2 * t) ** factor
457
+ return 1.0 - 0.5 * (2 * (1 - t)) ** factor
458
+
459
+ def _bounce(t: float) -> float:
460
+ if t < 1/2.75:
461
+ return 7.5625 * t * t
462
+ elif t < 2/2.75:
463
+ t -= 1.5/2.75
464
+ return 7.5625 * t * t + 0.75
465
+ elif t < 2.5/2.75:
466
+ t -= 2.25/2.75
467
+ return 7.5625 * t * t + 0.9375
468
+ else:
469
+ t -= 2.625/2.75
470
+ return 7.5625 * t * t + 0.984375
471
+
472
+ def _elastic(t: float) -> float:
473
+ if t == 0 or t == 1:
474
+ return t
475
+ p = 0.3
476
+ return -(2 ** (10 * (t - 1))) * math.sin((t - 1 - p/4) * 2 * math.pi / p)
477
+
478
+ def _back(t: float) -> float:
479
+ s = 1.70158 # overshoot amount
480
+ return t * t * ((s + 1) * t - s)
481
+
482
+ def _circular_in(t: float) -> float:
483
+ return 1.0 - math.sqrt(1.0 - t * t)
484
+
485
+ def _circular_out(t: float) -> float:
486
+ t -= 1.0
487
+ return math.sqrt(1.0 - t * t)
488
+
489
+ easings = {
490
+ "ease_in": _ease_in,
491
+ "ease_out": _ease_out,
492
+ "ease_in_out": _ease_in_out,
493
+ "bounce": _bounce,
494
+ "elastic": _elastic,
495
+ "back": _back,
496
+ "circular_in": _circular_in,
497
+ "circular_out": _circular_out,
498
+ }
499
+
500
+ fn = easings.get(easing_type, _ease_out)
501
+ points = []
502
+ for i in range(density):
503
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
504
+ curve_t = fn(t)
505
+ points.append({
506
+ "time": t * duration,
507
+ "value": start + (end - start) * curve_t,
508
+ })
509
+ return points
510
+
511
+
512
+ # -- Algorithmic / Generative -------------------------------------------------
513
+
514
+ def _euclidean(duration: float, density: int, start: float = 0.0,
515
+ end: float = 1.0, hits: int = 5, steps: int = 16, **_) -> list:
516
+ """Bjorklund/Euclidean distribution applied to automation.
517
+
518
+ Distributes 'hits' automation events across 'steps' time slots as
519
+ evenly as possible. Same math as Euclidean rhythms (Toussaint 2005)
520
+ but for parameter changes instead of drum hits.
521
+
522
+ hits: number of automation events (active points at 'end' value)
523
+ steps: total time slots (remaining slots get 'start' value)
524
+
525
+ Musical use: Rhythmic automation patterns with mathematical elegance.
526
+ 5 filter opens across 8 beats. 3 reverb throws across 16 steps.
527
+ Produces non-obvious but musically satisfying rhythmic modulation.
528
+ """
529
+ # Bjorklund algorithm
530
+ def _bjorklund(hits_n: int, steps_n: int) -> list:
531
+ if hits_n >= steps_n:
532
+ return [1] * steps_n
533
+ if hits_n == 0:
534
+ return [0] * steps_n
535
+ groups = [[1]] * hits_n + [[0]] * (steps_n - hits_n)
536
+ while True:
537
+ remainder = len(groups) - hits_n
538
+ if remainder <= 1:
539
+ break
540
+ new_groups = []
541
+ take = min(hits_n, remainder)
542
+ for i in range(take):
543
+ new_groups.append(groups[i] + groups[hits_n + i])
544
+ for i in range(take, hits_n):
545
+ new_groups.append(groups[i])
546
+ for i in range(hits_n + take, len(groups)):
547
+ new_groups.append(groups[i])
548
+ groups = new_groups
549
+ hits_n = take if take < hits_n else hits_n
550
+ return [bit for group in groups for bit in group]
551
+
552
+ pattern = _bjorklund(hits, steps)
553
+ step_dur = duration / len(pattern)
554
+ return [
555
+ {"time": i * step_dur, "value": end if bit else start, "duration": step_dur}
556
+ for i, bit in enumerate(pattern)
557
+ ]
558
+
559
+
560
+ def _stochastic(duration: float, density: int, center: float = 0.5,
561
+ amplitude: float = 0.4, narrowing: float = 0.5,
562
+ seed: float = 0.0, **_) -> list:
563
+ """Random values within narrowing/widening bounds. Xenakis-inspired.
564
+
565
+ Values are random but constrained within a corridor that can narrow
566
+ (converge to center) or widen (diverge) over time.
567
+
568
+ narrowing: 0.0 = constant width, 1.0 = fully converges to center,
569
+ -0.5 = widens over time
570
+ seed: deterministic seed for reproducible "randomness"
571
+
572
+ Musical use: Controlled chaos that evolves. Stochastic composition
573
+ applied to automation. The corridor gives musical intention to randomness.
574
+ Xenakis used this for orchestral density — we use it for parameter evolution.
575
+ """
576
+ import hashlib
577
+
578
+ def _det_random(i: int, s: float) -> float:
579
+ h = hashlib.md5(f"{i}:{s:.6f}".encode()).hexdigest()
580
+ return (int(h[:8], 16) / 0xFFFFFFFF) * 2.0 - 1.0
581
+
582
+ points = []
583
+ for i in range(density):
584
+ t = (i / max(density - 1, 1)) if density > 1 else 0.0
585
+ # Corridor width narrows/widens over time
586
+ width = amplitude * (1.0 - narrowing * t)
587
+ width = max(0.01, width) # never fully zero
588
+ rand = _det_random(i, seed)
589
+ value = center + width * rand
590
+ points.append({"time": t * duration, "value": value})
591
+ return points
592
+
593
+
594
+ # -- Recipe Shortcuts ----------------------------------------------------------
595
+
596
+ RECIPES = {
597
+ "filter_sweep_up": {
598
+ "curve_type": "exponential",
599
+ "start": 0.0, "end": 1.0, "factor": 2.5,
600
+ "description": "Low-pass filter opening. Exponential for perceptually even sweep.",
601
+ "typical_duration": "8-32 bars (32-128 beats)",
602
+ "target": "Filter cutoff frequency",
603
+ },
604
+ "filter_sweep_down": {
605
+ "curve_type": "logarithmic",
606
+ "start": 1.0, "end": 0.0, "factor": 2.5,
607
+ "description": "Low-pass filter closing. Logarithmic mirrors the sweep up.",
608
+ "typical_duration": "4-16 bars",
609
+ "target": "Filter cutoff frequency",
610
+ },
611
+ "dub_throw": {
612
+ "curve_type": "spike",
613
+ "peak": 1.0, "decay": 6.0,
614
+ "description": "Send spike for delay/reverb throw on single hit. Instant peak, fast decay.",
615
+ "typical_duration": "1-2 beats",
616
+ "target": "Send level to reverb/delay return",
617
+ },
618
+ "tape_stop": {
619
+ "curve_type": "exponential",
620
+ "start": 1.0, "end": 0.0, "factor": 4.0,
621
+ "description": "Pitch/speed dropping to zero. Steep exponential for realistic tape decel.",
622
+ "typical_duration": "0.5-2 beats",
623
+ "target": "Clip transpose or playback rate",
624
+ },
625
+ "build_rise": {
626
+ "curve_type": "exponential",
627
+ "start": 0.0, "end": 1.0, "factor": 2.0,
628
+ "description": "Tension build. Apply to HP filter, volume, reverb send simultaneously.",
629
+ "typical_duration": "8-32 bars",
630
+ "target": "Multiple: HP filter + volume + reverb send",
631
+ },
632
+ "sidechain_pump": {
633
+ "curve_type": "sawtooth",
634
+ "start": 0.0, "end": 1.0, "frequency": 1.0,
635
+ "description": "Volume ducking on each beat. Sawtooth = fast duck, slow recovery.",
636
+ "typical_duration": "1 beat (repeating via clip loop)",
637
+ "target": "Volume (use Utility gain, not mixer fader)",
638
+ },
639
+ "fade_in": {
640
+ "curve_type": "logarithmic",
641
+ "start": 0.0, "end": 1.0, "factor": 3.0,
642
+ "description": "Perceptually smooth volume fade in. Log curve compensates for ear's response.",
643
+ "typical_duration": "2-8 bars",
644
+ "target": "Volume",
645
+ },
646
+ "fade_out": {
647
+ "curve_type": "exponential",
648
+ "start": 1.0, "end": 0.0, "factor": 3.0,
649
+ "description": "Perceptually smooth volume fade out.",
650
+ "typical_duration": "2-8 bars",
651
+ "target": "Volume",
652
+ },
653
+ "tremolo": {
654
+ "curve_type": "sine",
655
+ "center": 0.5, "amplitude": 0.4, "frequency": 4.0,
656
+ "description": "Periodic volume oscillation. frequency = cycles per duration.",
657
+ "typical_duration": "1-4 bars (repeating)",
658
+ "target": "Volume",
659
+ },
660
+ "auto_pan": {
661
+ "curve_type": "sine",
662
+ "center": 0.5, "amplitude": 0.5, "frequency": 2.0,
663
+ "description": "Stereo movement. Sine on pan pot.",
664
+ "typical_duration": "1-4 bars (repeating)",
665
+ "target": "Pan",
666
+ },
667
+ "stutter": {
668
+ "curve_type": "square",
669
+ "low": 0.0, "high": 1.0, "frequency": 8.0,
670
+ "description": "Rapid on/off gating. High frequency = faster stutter.",
671
+ "typical_duration": "1-2 beats",
672
+ "target": "Volume",
673
+ },
674
+ "breathing": {
675
+ "curve_type": "sine",
676
+ "center": 0.6, "amplitude": 0.15, "frequency": 0.5,
677
+ "description": "Subtle filter movement mimicking acoustic instrument breathing.",
678
+ "typical_duration": "2-4 bars (repeating)",
679
+ "target": "Filter cutoff",
680
+ },
681
+ "washout": {
682
+ "curve_type": "exponential",
683
+ "start": 0.0, "end": 1.0, "factor": 2.0,
684
+ "description": "Reverb/delay feedback increasing to wash. Cut at transition.",
685
+ "typical_duration": "4-8 bars",
686
+ "target": "Reverb mix or delay feedback",
687
+ },
688
+ "vinyl_crackle": {
689
+ "curve_type": "sine",
690
+ "center": 0.3, "amplitude": 0.15, "frequency": 0.25,
691
+ "description": "Slow, subtle movement on bit reduction or sample rate for lo-fi character.",
692
+ "typical_duration": "8-16 bars",
693
+ "target": "Redux bit depth or sample rate",
694
+ },
695
+ "stereo_narrow": {
696
+ "curve_type": "exponential",
697
+ "start": 1.0, "end": 0.0, "factor": 2.0,
698
+ "description": "Collapse stereo to mono before drop. Widen at impact.",
699
+ "typical_duration": "4-8 bars",
700
+ "target": "Utility width",
701
+ },
702
+ }
703
+
704
+
705
+ def get_recipe(name: str) -> dict:
706
+ """Get a named automation recipe with its parameters and description."""
707
+ recipe = RECIPES.get(name)
708
+ if recipe is None:
709
+ raise ValueError(
710
+ f"Unknown recipe '{name}'. Options: {', '.join(RECIPES.keys())}"
711
+ )
712
+ return recipe
713
+
714
+
715
+ def generate_from_recipe(
716
+ name: str,
717
+ duration: float = 4.0,
718
+ density: int = 16,
719
+ **overrides,
720
+ ) -> list[dict[str, float]]:
721
+ """Generate a curve from a named recipe, with optional parameter overrides."""
722
+ recipe = get_recipe(name)
723
+ params = {k: v for k, v in recipe.items()
724
+ if k not in ("description", "typical_duration", "target")}
725
+ params["duration"] = duration
726
+ params["density"] = density
727
+ params.update(overrides)
728
+ return generate_curve(**params)
729
+
730
+
731
+ def list_recipes() -> dict[str, dict]:
732
+ """Return all available recipes with descriptions."""
733
+ return {
734
+ name: {
735
+ "description": r["description"],
736
+ "typical_duration": r["typical_duration"],
737
+ "target": r["target"],
738
+ "curve_type": r["curve_type"],
739
+ }
740
+ for name, r in RECIPES.items()
741
+ }