@plotdb/lotion 0.1.0
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 +27 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/audio/beats.py +93 -0
- package/audio/bgm.py +181 -0
- package/audio/common.py +30 -0
- package/audio/mix.py +51 -0
- package/audio/sfx.py +93 -0
- package/block/player.js +16 -0
- package/cli.js +390 -0
- package/index.css +191 -0
- package/index.js +437 -0
- package/index.min.css +1 -0
- package/index.min.js +1 -0
- package/package.json +1 -0
- package/prompt/explainer.md +71 -0
- package/prompt/ui-loop.md +497 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
## v0.1.0
|
|
2
|
+
|
|
3
|
+
- features:
|
|
4
|
+
- player: show a loading screen until `start()` ( `loading` option: `true` / custom html / `false` );
|
|
5
|
+
the stage is hidden, the control bar disabled, and `seek` / `play` ignored meanwhile
|
|
6
|
+
- player: `player.ready`, a promise resolved by `start()`
|
|
7
|
+
- block loader: wait for the interface's `ready` before exposing the render protocol
|
|
8
|
+
- tweaks:
|
|
9
|
+
- player: under `?render`, expose `window.seek` / `window.DURATION` at `start()` instead of construction,
|
|
10
|
+
so the cli never renders a scene still being prepared
|
|
11
|
+
- demo: simulate asynchronous preparation ( `?loading=<ms>`, default 1500 ) to show the loading screen
|
|
12
|
+
- cli: locate `audio/` both next to `cli.js` ( published by `fedep publish`, dist flattened ) and one level up
|
|
13
|
+
( in the repo ), so audio commands work in either layout
|
|
14
|
+
- docs:
|
|
15
|
+
- document the loading behavior in README and `prompt/explainer.md`
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## v0.0.1
|
|
19
|
+
|
|
20
|
+
- init release
|
|
21
|
+
- runtime: spring, track, vis, bump, typing, color and DOM helpers
|
|
22
|
+
- runtime: player with timeline, chapters, keyboard control, fullscreen and render protocol
|
|
23
|
+
- block player: `@plotdb/block` runtime bundled with a loader, supporting registry and packed modes
|
|
24
|
+
- cli: `frames`, `sheet`, `video` ( optional motion blur ), `cues` and `bundle`
|
|
25
|
+
- audio: `bgm` ( bassline synthesizer ), `beats`, `sfx` and `mix`
|
|
26
|
+
- prompts: `ui-loop.md` and `explainer.md`
|
|
27
|
+
- demo pages in `web/`
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zbryikt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# lotion
|
|
2
|
+
|
|
3
|
+
Deterministic motion toolkit: write animations as a pure function of time, `seek(t)`, then
|
|
4
|
+
play, scrub, embed, or render them frame by frame into video.
|
|
5
|
+
|
|
6
|
+
- runtime ( `dist/index.js` ): springs in closed form, keyframe tracks, fade windows, small DOM helpers,
|
|
7
|
+
and a player with scrubbing / chapters / fullscreen.
|
|
8
|
+
- block player ( `dist/block/player.js` ): package an animation as a `@plotdb/block` and embed it anywhere.
|
|
9
|
+
- cli ( `lotion` ): render any page following the render protocol into png frames, contact sheets or mp4,
|
|
10
|
+
and bundle blocks.
|
|
11
|
+
- audio tools ( `audio/` ): beat detection, ui sound effects, a simple bassline bgm synthesizer and a cue mixer.
|
|
12
|
+
- prompts ( `prompt/` ): workflow notes for producing motion designs and explainer animations with an AI agent.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
npm install --save @plotdb/lotion
|
|
18
|
+
|
|
19
|
+
The cli additionally needs `playwright` ( with chromium installed, `npx playwright install chromium` ) and
|
|
20
|
+
`ffmpeg` in `PATH`. Audio tools need `python3` with `numpy`.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Runtime
|
|
24
|
+
|
|
25
|
+
include the script and css:
|
|
26
|
+
|
|
27
|
+
<link rel="stylesheet" href="index.min.css">
|
|
28
|
+
<script src="index.min.js"></script>
|
|
29
|
+
|
|
30
|
+
create a player, build your scene into `player.stage`, then call `start()`:
|
|
31
|
+
|
|
32
|
+
p = new lotion.player do
|
|
33
|
+
root: '#demo'
|
|
34
|
+
duration: 10
|
|
35
|
+
chapters: [[0, 'intro'], [4, 'detail']]
|
|
36
|
+
seek: (t) -> seek t
|
|
37
|
+
x = lotion.track [[0, 100], [1, 800], [3, 100]]
|
|
38
|
+
dot = lotion.mk 'dot', '', p.stage
|
|
39
|
+
seek = (t) -> lotion.put dot, x(t), 300, 1, lotion.vis(t, 0.2)
|
|
40
|
+
p.start!
|
|
41
|
+
|
|
42
|
+
player options:
|
|
43
|
+
|
|
44
|
+
- `root`: container element or selector.
|
|
45
|
+
- `width`, `height`: design size of the stage. default `1920` x `1080`. The stage scales to fit the container.
|
|
46
|
+
- `duration`: total length in seconds.
|
|
47
|
+
- `seek(t)`: render the frame at time `t`. must depend on `t` only.
|
|
48
|
+
- `chapters`: optional `[[t, name], ...]`, shown as ticks on the timeline and used by `←` / `→`.
|
|
49
|
+
- `start`: initial time. `?t=<sec>` in the url takes precedence.
|
|
50
|
+
- `autoplay`: default `false`.
|
|
51
|
+
- `cues`: optional function returning sound cues `[{t, ...}]`, exported by `lotion cues`.
|
|
52
|
+
- `loading`: what to show before `start()`. default `true` ( a spinner ); a string is used as custom html;
|
|
53
|
+
`false` shows nothing.
|
|
54
|
+
|
|
55
|
+
player methods: `start()`, `seek(t)`, `play(go = true)`, `pause()`, `toggle()`, `fullscreen()`.
|
|
56
|
+
`player.ready` is a promise resolved by `start()`.
|
|
57
|
+
|
|
58
|
+
Until `start()` is called, the stage is hidden behind the loading screen, the control bar is disabled, and
|
|
59
|
+
`seek` / `play` do nothing. Scenes often need asynchronous preparation before the first frame is right
|
|
60
|
+
( web fonts, measuring the layout to place things ), and a half-built stage should not be seen or scrubbed.
|
|
61
|
+
So build the scene, finish whatever it waits for, then call `start()`:
|
|
62
|
+
|
|
63
|
+
Promise.all([document.fonts.ready, prepare!]).then -> p.start!
|
|
64
|
+
Keys ( when the player is focused ): `space` play / pause, `←` / `→` previous / next chapter, `f` fullscreen.
|
|
65
|
+
|
|
66
|
+
helpers:
|
|
67
|
+
|
|
68
|
+
- `spring(t, w = 14, z = 0.8)`: step response of a damped spring, `0 -> 1`.
|
|
69
|
+
- `track(keys, opt = 'std')`: `keys` is `[[t, value, preset?], ...]`. returns `(t) -> value`, summing one
|
|
70
|
+
spring response per change of target. `opt` / `preset` is `{w, z}` or a name in `presets`
|
|
71
|
+
( `fast`, `std`, `soft`, `move`, `ease` ).
|
|
72
|
+
- `vis(t, t0, t1, din, dout)`: fade window. `0 -> 1` from `t0`, back to `0` from `t1`.
|
|
73
|
+
- `bump(t, t0, d)`: a `0 -> 1 -> 0` pulse.
|
|
74
|
+
- `typing(t, t0, text, dt)`: the part of `text` typed by time `t`.
|
|
75
|
+
- `hex(str)`, `mixc(a, b, f)`, `rgb(c, alpha)`: color helpers working on `[r, g, b]`.
|
|
76
|
+
- `mk(cls, html, parent, prefix)`: create a div.
|
|
77
|
+
- `put(el, x, y, s, o, {hide, blur})`: set transform / opacity. hides the element by `visibility` when
|
|
78
|
+
transparent; pass `hide: false` for nested elements, since a visible child overrides a hidden parent.
|
|
79
|
+
- `txt(el, html)`: set innerHTML only when changed.
|
|
80
|
+
- `svg(tag, attrs, parent)`: create an svg element.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
## Render protocol
|
|
84
|
+
|
|
85
|
+
With `?render` in the url, a page should cover the viewport with its stage and expose:
|
|
86
|
+
|
|
87
|
+
- `window.seek(t)`: render the frame at `t`.
|
|
88
|
+
- `window.DURATION`: total length in seconds.
|
|
89
|
+
- `window.cues()`: optional, list of sound cues.
|
|
90
|
+
|
|
91
|
+
`lotion.player` does this automatically, and only when `start()` is called: the cli waits for `window.seek` to
|
|
92
|
+
exist before rendering, so it never captures a scene that is still being prepared. Any other page can follow
|
|
93
|
+
the protocol by itself; likewise, define `window.seek` only when the first frame can be rendered correctly.
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
## Block Player
|
|
97
|
+
|
|
98
|
+
An animation can be packaged as a [@plotdb/block](https://github.com/plotdb/block): HTML, scoped CSS and JS in
|
|
99
|
+
one file, with libraries ( including lotion itself ) declared as dependencies and injected by `rescope` /
|
|
100
|
+
`csscope`. `dist/block/player.js` bundles `@plotdb/block` with its dependencies ( semver, proxise, csscope,
|
|
101
|
+
rescope ) and a loader, about 47KB minified.
|
|
102
|
+
|
|
103
|
+
<div data-block="my-animation"></div>
|
|
104
|
+
<script src="player.js" data-block-root="/block" data-lib-root="/assets/lib"></script>
|
|
105
|
+
|
|
106
|
+
- `data-block`: block name. optional `data-version`, `data-path`.
|
|
107
|
+
- `data-bundle`: optional url of a packed block ( see `lotion bundle` ). With it, nothing is fetched from
|
|
108
|
+
the registry, so `player.js` plus the bundle file can be copied to any site.
|
|
109
|
+
- script attributes: `data-block-root` ( default `/block`, blocks at `<root>/<name>/index.html` ),
|
|
110
|
+
`data-lib-root` ( default `/assets/lib`, libraries at `<root>/<name>/<version>/<path>` ), `data-manual`
|
|
111
|
+
( don't mount automatically; use `lotionBlock.mount(el)` ).
|
|
112
|
+
- `window.lotionBlock`: `{manager, mount, ready}`.
|
|
113
|
+
- If a mounted block's interface provides `seek(t)` and `duration`, they are exposed per the render protocol
|
|
114
|
+
under `?render`. If it also provides `ready` ( a promise, e.g. `player.ready` ), they are exposed after it
|
|
115
|
+
resolves. A block using `lotion.player` should pass `ready: player.ready` in its interface.
|
|
116
|
+
|
|
117
|
+
Note that csscope scopes a block's style to the *descendants* of its root, so put the element your style
|
|
118
|
+
targets under the root ( or use `:scope` ). A block sample is in `web/src/pug/block/lotion-demo`.
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
## CLI
|
|
122
|
+
|
|
123
|
+
lotion frames <src> <outdir> <t...> one png per time
|
|
124
|
+
lotion sheet <src> <out.png> <t...> the same, tiled into one contact sheet
|
|
125
|
+
lotion video <src> <out.mp4> render to mp4
|
|
126
|
+
lotion cues <src> <out.json> dump window.cues()
|
|
127
|
+
lotion bundle <base-url> <block> <out> pack a block and its dependencies into one file
|
|
128
|
+
lotion bgm | sfx | mix | beats ... audio tools, see below
|
|
129
|
+
|
|
130
|
+
`<src>` is an http(s) url, or a local html file / directory served by a built-in static server.
|
|
131
|
+
|
|
132
|
+
options:
|
|
133
|
+
|
|
134
|
+
- `--width 1920 --height 1080`: viewport size.
|
|
135
|
+
- `--fps 60`, `--sub 1`, `--shutter 0.5`: video settings. with `--sub` greater than 1, each frame averages
|
|
136
|
+
`sub` sub-samples spread over `shutter` of the frame interval ( motion blur, via ffmpeg `tmix` ).
|
|
137
|
+
- `--from`, `--to`: render only a range, in seconds.
|
|
138
|
+
- `--crf 16`: x264 quality.
|
|
139
|
+
- `--cols 3`, `--tile 640`: contact sheet layout.
|
|
140
|
+
- `--query`: extra url parameters, such as `--query bar=1`.
|
|
141
|
+
- `--block-root /block`, `--lib-root /assets/lib`: registry paths for `bundle`, relative to `base-url`.
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
## Audio
|
|
145
|
+
|
|
146
|
+
Each command forwards its arguments to the python script of the same name in `audio/`; pass `-h` for all options.
|
|
147
|
+
All times are aligned so that `t = 0` matches the start of the animation.
|
|
148
|
+
|
|
149
|
+
- `lotion bgm out.wav --bpm 120 --bars 16 --key A --scale minor --prog 1,6,3,7 --bass drive --drums four --pad`:
|
|
150
|
+
synthesize a track around a repeating bassline. bass presets: `pulse`, `octave`, `house`, `drive`, `walk`,
|
|
151
|
+
or 16 custom steps ( semitones from the chord root, `.` rest, `-` hold ). drum presets: `four`, `half`, `broken`,
|
|
152
|
+
`none`. `--loop` makes it loop seamlessly; `--grid grid.json` writes its beat grid.
|
|
153
|
+
- `lotion beats music.mp3 --start 64.11 --dur 30 --out grid.json`: estimate bpm and the beat grid
|
|
154
|
+
( `{bpm, offset, beats, downbeats}` ). with an off-beat bassline and syncopated kicks the grid may be half a beat
|
|
155
|
+
off; correct it with `--shift 0.5`.
|
|
156
|
+
- `lotion sfx sfx/`: synthesize ui sound effects ( click, tick, scrub, pop, toggle, success, notify, type, whoosh )
|
|
157
|
+
and measure each transient peak into `sfx/peaks.json`.
|
|
158
|
+
- `lotion mix cues.json out.wav --dur 30 --music bgm.wav --sfx sfx/ [--loop | --fade 2]`: mix music with sound
|
|
159
|
+
effects placed at cue times ( `[{t, sfx, gain}]`, e.g. from `lotion cues` ), aligning each effect's measured
|
|
160
|
+
peak rather than its file start.
|
|
161
|
+
|
|
162
|
+
A typical flow:
|
|
163
|
+
|
|
164
|
+
lotion bgm bgm.wav --bpm 120 --bars 16 --grid grid.json # time the animation on this grid
|
|
165
|
+
lotion sfx sfx/
|
|
166
|
+
lotion cues page.html cues.json
|
|
167
|
+
lotion mix cues.json audio.wav --dur 32 --music bgm.wav --sfx sfx/ --fade 2
|
|
168
|
+
lotion video page.html silent.mp4
|
|
169
|
+
ffmpeg -i silent.mp4 -i audio.wav -c:v copy -c:a aac -shortest out.mp4
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
## Prompts
|
|
173
|
+
|
|
174
|
+
- `prompt/ui-loop.md`: a phased workflow for short, looping, single-object UI motion with music.
|
|
175
|
+
- `prompt/explainer.md`: practices for longer explainer animations, embedding them into pages, and common pitfalls.
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
npm start # dev server for the demo in web/
|
|
181
|
+
./build # src -> dist, and copy to web/static/assets/lib/lotion/dev
|
|
182
|
+
|
|
183
|
+
the packed block demo is generated from the running dev server:
|
|
184
|
+
|
|
185
|
+
node dist/cli.js bundle http://localhost:<port> lotion-demo web/static/block/lotion-demo.bundle.html
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|
package/audio/beats.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# 節拍分析: 估計 BPM 與第一拍位置, 輸出節拍格線 ( 整理自 0926-motion 的 bpm / grid / refine ).
|
|
2
|
+
# python audio/beats.py <music> [--start s] [--dur s] [--min 70] [--max 180] [--out grid.json]
|
|
3
|
+
# - BPM: onset 包絡的自相關, 同時考慮 1 / 2 / 4 倍拍長, 再以相位一致性細調到 0.005 BPM
|
|
4
|
+
# - 第一拍: 在一個週期內掃描起點, 取節拍位置上包絡總和最大者 ( 以低頻為主 ); 並補償分析窗的延遲
|
|
5
|
+
# - 小節起點: 比較 4 個相位上低頻 ( kick ) 的強度, 取最強者
|
|
6
|
+
# - 限制: bassline 落在反拍、kick 又是切分時, 拍點可能偏半拍; 以 --shift 0.5 ( 單位: 拍 ) 校正
|
|
7
|
+
# - 輸出: {bpm, offset, beats, downbeats}; 時間皆相對於 --start
|
|
8
|
+
import numpy as np, argparse, json
|
|
9
|
+
from common import load
|
|
10
|
+
|
|
11
|
+
SR = 22050
|
|
12
|
+
HOP = 256
|
|
13
|
+
FPS = SR / HOP
|
|
14
|
+
|
|
15
|
+
def frames(x, n=1024):
|
|
16
|
+
m = (len(x) - n) // HOP
|
|
17
|
+
return np.lib.stride_tricks.as_strided(x, (m, n), (x.strides[0] * HOP, x.strides[0])) * np.hanning(n)
|
|
18
|
+
|
|
19
|
+
def onset_env(x):
|
|
20
|
+
S = np.log1p(100 * np.abs(np.fft.rfft(frames(x), axis=1)))
|
|
21
|
+
fl = np.maximum(0, np.diff(S, axis=0)).sum(1)
|
|
22
|
+
fl = fl - np.convolve(fl, np.ones(32) / 32, 'same')
|
|
23
|
+
return np.maximum(fl, 0)
|
|
24
|
+
|
|
25
|
+
def low_env(x, cut=150):
|
|
26
|
+
S = np.abs(np.fft.rfft(frames(x), axis=1))
|
|
27
|
+
f = np.fft.rfftfreq(1024, 1 / SR)
|
|
28
|
+
return np.maximum(0, np.diff(np.log1p(100 * S[:, f < cut]).sum(1)))
|
|
29
|
+
|
|
30
|
+
def coarse_tempo(env, lo, hi):
|
|
31
|
+
e = env - env.mean()
|
|
32
|
+
ac = np.correlate(e, e, 'full')[len(e) - 1:]
|
|
33
|
+
bpms = np.arange(lo, hi + 0.01, 0.1)
|
|
34
|
+
def score(b):
|
|
35
|
+
lag, s = 60 * FPS / b, 0
|
|
36
|
+
for k in (1, 2, 4):
|
|
37
|
+
L = lag * k; i = int(L); f = L - i
|
|
38
|
+
if i + 1 < len(ac): s += (ac[i] * (1 - f) + ac[i + 1] * f) / k
|
|
39
|
+
return s
|
|
40
|
+
sc = np.array([score(b) for b in bpms])
|
|
41
|
+
return bpms[sc.argmax()], sc.max() / ac[0]
|
|
42
|
+
|
|
43
|
+
def fine_tempo(env, b0):
|
|
44
|
+
t = np.arange(len(env)) / FPS
|
|
45
|
+
bs = np.arange(b0 - 1, b0 + 1, 0.005)
|
|
46
|
+
sc = [abs(sum(np.sum(env * np.exp(-2j * np.pi * (b / 60) * k * t)) for k in (1, 2, 4))) for b in bs]
|
|
47
|
+
return bs[int(np.argmax(sc))]
|
|
48
|
+
|
|
49
|
+
# 分析窗的延遲: diff 後第 i 個值反映的是第 i + 1 個窗, 窗的中心再晚半個窗長
|
|
50
|
+
LAG = (HOP + 512) / SR
|
|
51
|
+
|
|
52
|
+
def norm(v): return v / (v.max() + 1e-9)
|
|
53
|
+
|
|
54
|
+
def grid(x, bpm):
|
|
55
|
+
env = onset_env(x); low = low_env(x)
|
|
56
|
+
m = min(len(env), len(low))
|
|
57
|
+
# 拍點以低頻 ( kick / bass 起音 ) 為主, 避免被反拍的 hat 或 16 分音符拉偏
|
|
58
|
+
e = norm(low[:m]) * 2 + norm(env[:m])
|
|
59
|
+
t = np.arange(m) / FPS
|
|
60
|
+
period = 60 / bpm
|
|
61
|
+
idx = np.arange(m)
|
|
62
|
+
def score(o): return np.interp(np.arange(o, t[-1] - 0.1, period) * FPS, idx, e).sum()
|
|
63
|
+
cands = np.arange(0, period, 0.001)
|
|
64
|
+
off = cands[int(np.argmax([score(o) for o in cands]))]
|
|
65
|
+
beats = np.arange(off, t[-1] - 0.1, period)
|
|
66
|
+
lb = np.interp(beats * FPS, np.arange(len(low)), low)
|
|
67
|
+
phase = int(np.argmax([lb[i::4].mean() for i in range(4)]))
|
|
68
|
+
beats = beats + LAG
|
|
69
|
+
return beats, beats[phase::4]
|
|
70
|
+
|
|
71
|
+
if __name__ == '__main__':
|
|
72
|
+
ap = argparse.ArgumentParser(description='estimate bpm and beat grid of a music file')
|
|
73
|
+
ap.add_argument('music')
|
|
74
|
+
ap.add_argument('--start', type=float, default=0)
|
|
75
|
+
ap.add_argument('--dur', type=float, default=None)
|
|
76
|
+
ap.add_argument('--min', type=float, default=70)
|
|
77
|
+
ap.add_argument('--max', type=float, default=180)
|
|
78
|
+
ap.add_argument('--out', default=None, help='write grid json here')
|
|
79
|
+
ap.add_argument('--shift', type=float, default=0, help='shift the grid by this many beats')
|
|
80
|
+
a = ap.parse_args()
|
|
81
|
+
x = load(a.music, sr=SR, start=a.start, dur=a.dur)
|
|
82
|
+
b0, conf = coarse_tempo(onset_env(x), a.min, a.max)
|
|
83
|
+
bpm = fine_tempo(onset_env(x), b0)
|
|
84
|
+
beats, downbeats = grid(x, bpm)
|
|
85
|
+
if a.shift:
|
|
86
|
+
d = a.shift * 60 / bpm
|
|
87
|
+
beats, downbeats = beats + d, downbeats + d
|
|
88
|
+
print('bpm %.3f ( coarse %.1f, confidence %.2f ) first beat %.4fs first downbeat %.4fs beats %d'
|
|
89
|
+
% (bpm, b0, conf, beats[0], downbeats[0], len(beats)))
|
|
90
|
+
if a.out:
|
|
91
|
+
json.dump({'bpm': round(float(bpm), 3), 'offset': round(float(beats[0]), 4),
|
|
92
|
+
'beats': [round(float(v), 4) for v in beats], 'downbeats': [round(float(v), 4) for v in downbeats]},
|
|
93
|
+
open(a.out, 'w'), indent=1)
|
package/audio/bgm.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# 簡易背景音樂合成: 以重複的 bassline 為骨幹, 加上鼓組與和弦墊音. 只依賴 numpy.
|
|
2
|
+
# python audio/bgm.py <out.wav> [--bpm 120] [--bars 16] [--key A] [--scale minor] [--prog 1,6,3,7]
|
|
3
|
+
# [--bass pulse] [--drums four] [--pad] [--intro 2] [--outro 2] [--loop] [--grid grid.json]
|
|
4
|
+
#
|
|
5
|
+
# 設計:
|
|
6
|
+
# - 和弦進行以音階級數表示 ( --prog 1,6,3,7 ), 每小節一個和弦, 循環使用
|
|
7
|
+
# - bassline 是一小節 16 格的樣式 ( --bass ), 每格為相對於和弦根音的半音數, '.' 為休止; 每小節重複, 隨和弦移調
|
|
8
|
+
# - 鼓組樣式 ( --drums ): kick / hat / clap 各 16 格
|
|
9
|
+
# - 編排: 前 --intro 小節只有 bass 與 hat, 最後 --outro 小節逐漸收掉; --loop 時不做 intro / outro,
|
|
10
|
+
# 並把超出尾端的音尾繞回開頭, 可無縫循環
|
|
11
|
+
# - t = 0 即第一小節的第一拍, 方便與動畫時間軸對齊; --grid 會輸出節拍格線 ( 與 beats.py 格式相同 )
|
|
12
|
+
import numpy as np, argparse, json
|
|
13
|
+
from common import SR, write, normalize
|
|
14
|
+
|
|
15
|
+
NOTE = {'C': 0, 'C#': 1, 'Db': 1, 'D': 2, 'D#': 3, 'Eb': 3, 'E': 4, 'F': 5, 'F#': 6, 'Gb': 6,
|
|
16
|
+
'G': 7, 'G#': 8, 'Ab': 8, 'A': 9, 'A#': 10, 'Bb': 10, 'B': 11}
|
|
17
|
+
SCALE = {
|
|
18
|
+
'major': [0, 2, 4, 5, 7, 9, 11], 'minor': [0, 2, 3, 5, 7, 8, 10],
|
|
19
|
+
'dorian': [0, 2, 3, 5, 7, 9, 10], 'mixolydian': [0, 2, 4, 5, 7, 9, 10]
|
|
20
|
+
}
|
|
21
|
+
# bassline 樣式: 16 格, 數字為相對和弦根音的半音數, '.' 休止, '-' 延續前一音
|
|
22
|
+
BASS = {
|
|
23
|
+
'pulse': '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0',
|
|
24
|
+
'octave': '0 . 12 . 0 . 12 . 0 . 12 . 0 . 12 .',
|
|
25
|
+
'house': '. . 0 . . . 0 . . . 0 . . . 0 12',
|
|
26
|
+
'drive': '0 . 0 12 . 0 7 . 0 . 0 12 . 7 5 .',
|
|
27
|
+
'walk': '0 - - - 7 - - - 12 - - - 7 - 5 -',
|
|
28
|
+
}
|
|
29
|
+
DRUMS = {
|
|
30
|
+
'four': {'kick': 'x...x...x...x...', 'hat': '..x...x...x...x.', 'clap': '....x.......x...'},
|
|
31
|
+
'half': {'kick': 'x.......x.......', 'hat': 'x.x.x.x.x.x.x.x.', 'clap': '........x.......'},
|
|
32
|
+
'broken': {'kick': 'x.....x...x.....', 'hat': '..x...x...x...xx', 'clap': '....x.......x..x'},
|
|
33
|
+
'none': {'kick': '................', 'hat': '................', 'clap': '................'},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
rng = np.random.default_rng(3)
|
|
37
|
+
|
|
38
|
+
def hz(midi): return 440 * 2 ** ((midi - 69) / 12)
|
|
39
|
+
|
|
40
|
+
def env(n, a, d, sustain=0.0, r=0.0):
|
|
41
|
+
"""attack / 指數衰減至 sustain; 結尾 r 秒線性釋放"""
|
|
42
|
+
t = np.arange(n) / SR
|
|
43
|
+
e = (1 - np.exp(-t / max(a, 1e-4))) * (sustain + (1 - sustain) * np.exp(-t / d))
|
|
44
|
+
if r > 0:
|
|
45
|
+
k = min(n, int(r * SR)); e[n - k:] *= np.linspace(1, 0, k)
|
|
46
|
+
return e
|
|
47
|
+
|
|
48
|
+
def saw(f, n, detune=0.0):
|
|
49
|
+
t = np.arange(n) / SR
|
|
50
|
+
x = 2 * ((f * t) % 1) - 1
|
|
51
|
+
if detune: x = 0.5 * x + 0.5 * (2 * ((f * (1 + detune) * t + 0.37) % 1) - 1)
|
|
52
|
+
return x
|
|
53
|
+
|
|
54
|
+
def lowpass(x, cutoff, q=0.9):
|
|
55
|
+
"""時變截止頻率的 state variable filter. cutoff 可為陣列 ( 每個 sample 一個值 )"""
|
|
56
|
+
cutoff = np.broadcast_to(cutoff, x.shape)
|
|
57
|
+
f = 2 * np.sin(np.pi * np.clip(cutoff, 20, SR / 6) / SR)
|
|
58
|
+
low = band = 0.0
|
|
59
|
+
y = np.empty_like(x)
|
|
60
|
+
damp = 1 / q
|
|
61
|
+
for i in range(len(x)):
|
|
62
|
+
low += f[i] * band
|
|
63
|
+
high = x[i] - low - damp * band
|
|
64
|
+
band += f[i] * high
|
|
65
|
+
y[i] = low
|
|
66
|
+
return y
|
|
67
|
+
|
|
68
|
+
def bass_note(midi, dur, accent=1.0):
|
|
69
|
+
n = int(dur * SR)
|
|
70
|
+
x = saw(hz(midi), n, detune=0.004) + 0.5 * np.sin(2 * np.pi * hz(midi - 12) * np.arange(n) / SR)
|
|
71
|
+
cut = 180 + 1400 * accent * np.exp(-np.arange(n) / SR / 0.09)
|
|
72
|
+
return lowpass(x, cut, q=1.4) * env(n, 0.003, 0.25, 0.55, 0.02) * 0.5
|
|
73
|
+
|
|
74
|
+
def kick():
|
|
75
|
+
n = int(0.35 * SR); t = np.arange(n) / SR
|
|
76
|
+
f = 48 + 110 * np.exp(-t / 0.035)
|
|
77
|
+
return np.sin(2 * np.pi * np.cumsum(f) / SR) * env(n, 0.001, 0.16) * 0.95
|
|
78
|
+
|
|
79
|
+
def noise_hp(n, cut):
|
|
80
|
+
X = np.fft.rfft(rng.standard_normal(n)); fq = np.fft.rfftfreq(n, 1 / SR)
|
|
81
|
+
X[fq < cut] = 0
|
|
82
|
+
y = np.fft.irfft(X, n); return y / (np.abs(y).max() + 1e-9)
|
|
83
|
+
|
|
84
|
+
def hat(open_=False):
|
|
85
|
+
n = int((0.18 if open_ else 0.05) * SR)
|
|
86
|
+
return noise_hp(n, 7000) * env(n, 0.0005, 0.06 if open_ else 0.012) * 0.22
|
|
87
|
+
|
|
88
|
+
def clap():
|
|
89
|
+
n = int(0.22 * SR)
|
|
90
|
+
x = noise_hp(n, 1200) * env(n, 0.001, 0.06)
|
|
91
|
+
for d in (0.011, 0.022): # 拍手的多重起音
|
|
92
|
+
k = int(d * SR); x[k:] += noise_hp(n - k, 1200) * env(n - k, 0.001, 0.012) * 0.6
|
|
93
|
+
return x * 0.28
|
|
94
|
+
|
|
95
|
+
def pad_chord(midis, dur):
|
|
96
|
+
n = int(dur * SR)
|
|
97
|
+
x = sum(saw(hz(m), n, detune=0.006) for m in midis) / len(midis)
|
|
98
|
+
return lowpass(x, 900, q=0.7) * env(n, 0.35, 10, 0.9, 0.4) * 0.16
|
|
99
|
+
|
|
100
|
+
def parse_steps(s):
|
|
101
|
+
tok = s.split() if ' ' in s.strip() else list(s)
|
|
102
|
+
if len(tok) != 16: raise SystemExit('pattern must have 16 steps: %r' % s)
|
|
103
|
+
return tok
|
|
104
|
+
|
|
105
|
+
if __name__ == '__main__':
|
|
106
|
+
ap = argparse.ArgumentParser(description='synthesize a simple looping bassline track')
|
|
107
|
+
ap.add_argument('out')
|
|
108
|
+
ap.add_argument('--bpm', type=float, default=120)
|
|
109
|
+
ap.add_argument('--bars', type=int, default=16)
|
|
110
|
+
ap.add_argument('--key', default='A', help='tonic, e.g. A, C#, Eb')
|
|
111
|
+
ap.add_argument('--octave', type=int, default=2, help='octave of the bass tonic')
|
|
112
|
+
ap.add_argument('--scale', default='minor', choices=list(SCALE))
|
|
113
|
+
ap.add_argument('--prog', default='1,6,3,7', help='chord degrees, one per bar, cycled')
|
|
114
|
+
ap.add_argument('--bass', default='drive', help='preset (%s) or 16 custom steps' % ', '.join(BASS))
|
|
115
|
+
ap.add_argument('--drums', default='four', choices=list(DRUMS))
|
|
116
|
+
ap.add_argument('--pad', action='store_true', help='add sustained chord pad')
|
|
117
|
+
ap.add_argument('--intro', type=int, default=2, help='bars with bass and hat only')
|
|
118
|
+
ap.add_argument('--outro', type=int, default=2, help='bars fading out at the end')
|
|
119
|
+
ap.add_argument('--loop', action='store_true', help='seamless loop: no intro / outro, tails wrap around')
|
|
120
|
+
ap.add_argument('--swing', type=float, default=0, help='0 ~ 0.3, delays every second 16th')
|
|
121
|
+
ap.add_argument('--grid', default=None, help='write beat grid json here')
|
|
122
|
+
a = ap.parse_args()
|
|
123
|
+
|
|
124
|
+
scale = SCALE[a.scale]
|
|
125
|
+
tonic = 12 * (a.octave + 1) + NOTE[a.key]
|
|
126
|
+
prog = [int(v) for v in a.prog.split(',')]
|
|
127
|
+
bass = parse_steps(BASS.get(a.bass, a.bass))
|
|
128
|
+
drums = {k: parse_steps(v) for k, v in DRUMS[a.drums].items()}
|
|
129
|
+
intro, outro = (0, 0) if a.loop else (a.intro, a.outro)
|
|
130
|
+
|
|
131
|
+
step = 60 / a.bpm / 4
|
|
132
|
+
total = a.bars * 16 * step
|
|
133
|
+
n = int(round(total * SR))
|
|
134
|
+
out = np.zeros((n + int(SR * 2), 2), np.float32) # 多留 2 秒放音尾
|
|
135
|
+
|
|
136
|
+
def place(x, t, gain=1.0, pan=0.0):
|
|
137
|
+
s = int(round(t * SR)); e = s + len(x)
|
|
138
|
+
l, r = np.cos((pan + 1) * np.pi / 4), np.sin((pan + 1) * np.pi / 4)
|
|
139
|
+
out[s:e, 0] += x * gain * l * 1.414
|
|
140
|
+
out[s:e, 1] += x * gain * r * 1.414
|
|
141
|
+
|
|
142
|
+
def degree(d, shift=0):
|
|
143
|
+
i = d - 1 + shift
|
|
144
|
+
return scale[i % 7] + 12 * (i // 7)
|
|
145
|
+
|
|
146
|
+
K, C = kick(), clap()
|
|
147
|
+
for bar in range(a.bars):
|
|
148
|
+
root = tonic + degree(prog[bar % len(prog)])
|
|
149
|
+
t0 = bar * 16 * step
|
|
150
|
+
# 編排: intro 只有 bass + hat; outro 逐小節降低音量
|
|
151
|
+
level = 1.0 if bar < a.bars - outro else (a.bars - bar) / (outro + 1)
|
|
152
|
+
full = bar >= intro
|
|
153
|
+
if a.pad and full:
|
|
154
|
+
d = prog[bar % len(prog)]
|
|
155
|
+
chord = [tonic + 12 + degree(d), tonic + 12 + degree(d, 2), tonic + 12 + degree(d, 4)]
|
|
156
|
+
place(pad_chord(chord, 16 * step), t0, level)
|
|
157
|
+
for i in range(16):
|
|
158
|
+
t = t0 + i * step + (a.swing * step if i % 2 else 0)
|
|
159
|
+
s = bass[i]
|
|
160
|
+
if s not in ('.', '-'):
|
|
161
|
+
length = 1
|
|
162
|
+
while i + length < 16 and bass[i + length] == '-': length += 1
|
|
163
|
+
place(bass_note(root + int(s), length * step * 0.92, 1.0 if i % 4 == 0 else 0.6), t, level)
|
|
164
|
+
if full and drums['kick'][i] == 'x': place(K, t, level)
|
|
165
|
+
if full and drums['clap'][i] == 'x': place(C, t, level * 0.9, 0.15)
|
|
166
|
+
if drums['hat'][i] == 'x': place(hat(), t, level * (1 if full else 0.7), -0.25)
|
|
167
|
+
|
|
168
|
+
if a.loop:
|
|
169
|
+
tail = out[n:].copy()
|
|
170
|
+
out = out[:n]
|
|
171
|
+
k = min(len(tail), n)
|
|
172
|
+
out[:k] += tail[:k]
|
|
173
|
+
else:
|
|
174
|
+
tail = int(SR * 1.5)
|
|
175
|
+
out = out[:n + tail]
|
|
176
|
+
out[n:] *= np.linspace(1, 0, tail)[:, None]
|
|
177
|
+
write(a.out, normalize(out * 0.9, 0.95))
|
|
178
|
+
beats = [round(i * 4 * step, 4) for i in range(a.bars * 4)]
|
|
179
|
+
print('%s %.1f bpm %d bars %.2fs key %s %s prog %s' % (a.out, a.bpm, a.bars, total, a.key, a.scale, a.prog))
|
|
180
|
+
if a.grid:
|
|
181
|
+
json.dump({'bpm': a.bpm, 'offset': 0, 'beats': beats, 'downbeats': beats[::4]}, open(a.grid, 'w'), indent=1)
|
package/audio/common.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# 音訊工具共用: 讀檔 ( 經 ffmpeg ) 與寫 wav. 只依賴 numpy.
|
|
2
|
+
import numpy as np, subprocess, wave
|
|
3
|
+
|
|
4
|
+
SR = 48000
|
|
5
|
+
|
|
6
|
+
def load(path, sr=SR, start=0, dur=None, channels=1):
|
|
7
|
+
"""以 ffmpeg 解碼任意音訊檔, 回傳 float32 陣列 ( channels > 1 時為 (n, channels) )"""
|
|
8
|
+
cmd = ['ffmpeg', '-v', 'quiet', '-ss', str(start)] + (['-t', str(dur)] if dur else []) + \
|
|
9
|
+
['-i', path, '-ac', str(channels), '-ar', str(sr), '-f', 'f32le', '-']
|
|
10
|
+
x = np.frombuffer(subprocess.run(cmd, capture_output=True, check=True).stdout, dtype=np.float32)
|
|
11
|
+
return x.reshape(-1, channels).copy() if channels > 1 else x.copy()
|
|
12
|
+
|
|
13
|
+
def write(path, x, sr=SR):
|
|
14
|
+
"""寫入 16-bit wav. x 為 (n,) 或 (n, channels), 數值範圍 -1 ~ 1"""
|
|
15
|
+
x = np.clip(np.asarray(x, dtype=np.float32), -1, 1)
|
|
16
|
+
ch = 1 if x.ndim == 1 else x.shape[1]
|
|
17
|
+
with wave.open(path, 'wb') as w:
|
|
18
|
+
w.setnchannels(ch); w.setsampwidth(2); w.setframerate(sr)
|
|
19
|
+
w.writeframes((x * 32767).astype('<i2').tobytes())
|
|
20
|
+
|
|
21
|
+
def read_wav(path):
|
|
22
|
+
"""讀取 16-bit wav, 回傳 (資料, sr). 多聲道時為 (n, channels)"""
|
|
23
|
+
with wave.open(path) as w:
|
|
24
|
+
ch, sr = w.getnchannels(), w.getframerate()
|
|
25
|
+
x = np.frombuffer(w.readframes(w.getnframes()), '<i2').astype(np.float32) / 32767
|
|
26
|
+
return (x.reshape(-1, ch) if ch > 1 else x), sr
|
|
27
|
+
|
|
28
|
+
def normalize(x, peak=0.98):
|
|
29
|
+
m = np.abs(x).max()
|
|
30
|
+
return x * (peak / m) if m > peak else x
|
package/audio/mix.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# 混音: 背景音樂 + 依 cue 放置的音效 ( 整理自 0926-motion ).
|
|
2
|
+
# python audio/mix.py <cues.json> <out.wav> --dur s [--music f] [--start s] [--sfx dir] [--fade s] [--loop]
|
|
3
|
+
# - cues.json: [{t, sfx, gain?}, ...], 通常由 `lotion cues` 從頁面匯出
|
|
4
|
+
# - 音效以 sfx 目錄裡 peaks.json 量到的 transient peak 對齊 cue 時間, 而非檔案開頭 ( 見 sfx.py )
|
|
5
|
+
# - --music: 任意音訊檔 ( 例如 bgm.py 的輸出 ); --start 為音樂中對齊 t = 0 的位置
|
|
6
|
+
# - --loop: 超出尾端的音效回繞到開頭, 音訊可無縫循環; 否則尾端以 --fade 秒淡出
|
|
7
|
+
import numpy as np, json, os, argparse
|
|
8
|
+
from common import SR, load, read_wav, write, normalize
|
|
9
|
+
|
|
10
|
+
ap = argparse.ArgumentParser(description='mix background music and cue-aligned sound effects')
|
|
11
|
+
ap.add_argument('cues')
|
|
12
|
+
ap.add_argument('out')
|
|
13
|
+
ap.add_argument('--dur', type=float, required=True, help='length of the output in seconds')
|
|
14
|
+
ap.add_argument('--music', default=None)
|
|
15
|
+
ap.add_argument('--start', type=float, default=0, help='position in music aligned to t = 0')
|
|
16
|
+
ap.add_argument('--sfx', default='sfx', help='directory of sfx wav files and peaks.json')
|
|
17
|
+
ap.add_argument('--fade', type=float, default=0)
|
|
18
|
+
ap.add_argument('--loop', action='store_true')
|
|
19
|
+
ap.add_argument('--sfx-gain', type=float, default=0.55)
|
|
20
|
+
ap.add_argument('--music-gain', type=float, default=0.8)
|
|
21
|
+
a = ap.parse_args()
|
|
22
|
+
|
|
23
|
+
n = int(SR * a.dur)
|
|
24
|
+
mix = np.zeros((n, 2), np.float32)
|
|
25
|
+
if a.music:
|
|
26
|
+
music = load(a.music, start=a.start, dur=a.dur, channels=2)[:n]
|
|
27
|
+
mix[:len(music)] = music * a.music_gain
|
|
28
|
+
|
|
29
|
+
cues = json.load(open(a.cues))
|
|
30
|
+
peaks = json.load(open(os.path.join(a.sfx, 'peaks.json'))) if cues else {}
|
|
31
|
+
cache = {}
|
|
32
|
+
for c in cues:
|
|
33
|
+
name = c['sfx']
|
|
34
|
+
if name not in cache:
|
|
35
|
+
x, _ = read_wav(os.path.join(a.sfx, name + '.wav'))
|
|
36
|
+
cache[name] = x if x.ndim == 1 else x.mean(1)
|
|
37
|
+
x = cache[name] * c.get('gain', 1) * a.sfx_gain
|
|
38
|
+
s = int(round((c['t'] - peaks.get(name, 0)) * SR))
|
|
39
|
+
idx = s + np.arange(len(x))
|
|
40
|
+
if a.loop: idx %= n
|
|
41
|
+
else:
|
|
42
|
+
m = (idx >= 0) & (idx < n); idx, x = idx[m], x[m]
|
|
43
|
+
np.add.at(mix[:, 0], idx, x)
|
|
44
|
+
np.add.at(mix[:, 1], idx, x)
|
|
45
|
+
|
|
46
|
+
if a.fade > 0 and not a.loop:
|
|
47
|
+
f = int(SR * a.fade)
|
|
48
|
+
mix[n - f:] *= np.linspace(1, 0, f)[:, None]
|
|
49
|
+
pk = float(np.abs(mix).max())
|
|
50
|
+
write(a.out, normalize(mix))
|
|
51
|
+
print('%s cues %d peak %.3f' % (a.out, len(cues), pk))
|