@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 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)
@@ -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))