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.
- package/CHANGELOG.md +36 -0
- package/README.md +2 -2
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/curves.py +741 -0
- package/mcp_server/server.py +1 -0
- package/mcp_server/tools/automation.py +431 -0
- package/package.json +2 -2
- package/plugin/agents/livepilot-producer/AGENT.md +32 -2
- package/plugin/plugin.json +2 -2
- package/plugin/skills/livepilot-core/SKILL.md +29 -5
- package/plugin/skills/livepilot-core/references/automation-atlas.md +272 -0
- package/plugin/skills/livepilot-core/references/overview.md +20 -3
- package/plugin/skills/livepilot-release/SKILL.md +101 -0
- package/remote_script/LivePilot/__init__.py +3 -2
- package/remote_script/LivePilot/clip_automation.py +220 -0
- package/remote_script/LivePilot/server.py +3 -0
|
@@ -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
|
+
}
|