Ethscriptions commited on
Commit
bdfa818
·
1 Parent(s): 5049ed6

solana-clock: 程序化配乐 + 音效 + 旁白混音

Browse files
README.md CHANGED
@@ -11,7 +11,7 @@ pinned: false
11
  <!-- solana-clock:start -->
12
  # ⏱️ 项目五(全新制作):SOLANA —— 给区块链装上时钟的人(solana-clock/)
13
 
14
- > 用一支 **约 6.5 分钟** 的网页动画视频,讲完 Solana / SOL 从 2017 年一个深夜的灵感,到 2026 年的完整发展历程。
15
  > **全新制作**:文案、分镜、画面代码、音乐、音效、旁白全部从零编写,**不参考**仓库中任何旧目录(包括旧的 `solana/`)。
16
  > 新作品全部位于 **`solana-clock/`** 目录,打开 `solana-clock/index.html` 即可播放。
17
 
@@ -22,8 +22,8 @@ pinned: false
22
  | 1 | 写计划 / 大纲 / 步骤(本节) | ✅ 完成 |
23
  | 2 | 旁白文案定稿(15 场景 / 72 句)+ TTS 配音(纯语音 370 秒)+ 时长清单 + SOL 真实价格数据(2372 个日线点) | ✅ 完成 |
24
  | 3 | 动画引擎 + 全部场景(横屏 16:9 / 竖屏 9:16 双布局 + 烧录字幕 + 安全区)+ 播放器页面 + 字体子集;无头浏览器 90 帧截图 + 逐 0.5 秒排版检查通过 | ✅ 完成 |
25
- | 4 | 程序化音乐 + 合成音效 + 旁白闪避混音 | ⏳ 进行中 |
26
- | 5 | 播放器 UI + 浏览器本地 MP4 导出(24 / 30 / 60 fps,横屏 / 竖屏) | ⬜ 待做 |
27
  | 6 | 抖音 / B站 发布标题、简介、标签(一键复制) | ⬜ 待做 |
28
  | 7 | 横屏 1920×1080 / 竖屏 1080×1920 封面图 | ⬜ 待做 |
29
  | 8 | 无头浏览器自动化测试 + 修复 + 最终推送 | ⬜ 待做 |
@@ -39,7 +39,7 @@ pinned: false
39
  视觉风格:**深空霓虹 + 数据可视化**。Solana 品牌渐变(紫 #9945FF → 青绿 #14F195),
40
  发光粒子、哈希光链、等距节点网络、真实价格曲线动画、故障风(宕机)、碎裂坍塌(FTX)、凤凰式重生(2023)、烟花(ATH)。
41
 
42
- ## 2. 分镜大纲(15 个场景,约 6.5 分钟)
43
 
44
  | # | 场景 ID | 时间点 | 内容 | 关键画面 | 音乐情绪 |
45
  |---|---|---|---|---|---|
 
11
  <!-- solana-clock:start -->
12
  # ⏱️ 项目五(全新制作):SOLANA —— 给区块链装上时钟的人(solana-clock/)
13
 
14
+ > 用一支 **约 7 分钟** 的网页动画视频,讲完 Solana / SOL 从 2017 年一个深夜的灵感,到 2026 年的完整发展历程。
15
  > **全新制作**:文案、分镜、画面代码、音乐、音效、旁白全部从零编写,**不参考**仓库中任何旧目录(包括旧的 `solana/`)。
16
  > 新作品全部位于 **`solana-clock/`** 目录,打开 `solana-clock/index.html` 即可播放。
17
 
 
22
  | 1 | 写计划 / 大纲 / 步骤(本节) | ✅ 完成 |
23
  | 2 | 旁白文案定稿(15 场景 / 72 句)+ TTS 配音(纯语音 370 秒)+ 时长清单 + SOL 真实价格数据(2372 个日线点) | ✅ 完成 |
24
  | 3 | 动画引擎 + 全部场景(横屏 16:9 / 竖屏 9:16 双布局 + 烧录字幕 + 安全区)+ 播放器页面 + 字体子集;无头浏览器 90 帧截图 + 逐 0.5 秒排版检查通过 | ✅ 完成 |
25
+ | 4 | 程序化音乐(15 段编曲:和弦垫 / 贝斯 / 琶音 / 鼓 / 时钟滴答)+ 57 种合成音效(258 个触发点)+ 旁白闪避混音 + 混响 + 限幅;按场景分段渲染,约 3 秒即可出声 | ✅ 完成 |
26
+ | 5 | 播放器 UI + 浏览器本地 MP4 导出(24 / 30 / 60 fps,横屏 / 竖屏) | ⏳ 进行中 |
27
  | 6 | 抖音 / B站 发布标题、简介、标签(一键复制) | ⬜ 待做 |
28
  | 7 | 横屏 1920×1080 / 竖屏 1080×1920 封面图 | ⬜ 待做 |
29
  | 8 | 无头浏览器自动化测试 + 修复 + 最终推送 | ⬜ 待做 |
 
39
  视觉风格:**深空霓虹 + 数据可视化**。Solana 品牌渐变(紫 #9945FF → 青绿 #14F195),
40
  发光粒子、哈希光链、等距节点网络、真实价格曲线动画、故障风(宕机)、碎裂坍塌(FTX)、凤凰式重生(2023)、烟花(ATH)。
41
 
42
+ ## 2. 分镜大纲(15 个场景,约 7 分钟)
43
 
44
  | # | 场景 ID | 时间点 | 内容 | 关键画面 | 音乐情绪 |
45
  |---|---|---|---|---|---|
index.html CHANGED
@@ -12,7 +12,7 @@ a.card div{padding:14px 16px}a.card b{font-size:19px}a.card span{display:block;o
12
  </style></head><body>
13
  <h1>知识动画工作室</h1><p>网页即播 · 浏览器本地导出 MP4 · 横屏 B站 / 竖屏抖音</p>
14
  <div class="grid">
15
- <a class="card" href="solana-clock/index.html"><img src="solana-clock/covers/cover-landscape.png" alt="SOLANA 给区块链装上时钟的人" onerror="this.style.visibility='hidden'"><div><b>⏱️ SOLANA:给区块链装上时钟的人</b><span>2017 → 2026 · 全新制作 · 时间之链霓虹风 · 约 6.5 分钟</span></div></a>
16
  <a class="card" href="ethereum/index.html"><img src="ethereum/covers/cover-landscape.png" alt="以太坊简史" onerror="this.style.visibility='hidden'"><div><b>⟠ 以太坊简史:从一份白皮书到世界计算机</b><span>2013 → 2026 · 深空光之晶体风格 · 约 7 分钟</span></div></a>
17
  <a class="card" href="guozu/index.html"><img src="guozu/covers/cover-landscape.png" alt="国足投注模拟器" onerror="this.style.visibility='hidden'"><div><b>⚽ 国足投注模拟器:每场押 ¥100,23 年后还剩多少?</b><span>2002 → 2026.10 · 149 场正式比赛 + 8 场友谊赛彩蛋 · 8-bit 像素街机风 · 约 5 分 50 秒 · 数据模拟,远离赌博</span></div></a>
18
  <a class="card" href="shouhouzi/index.html"><img src="shouhouzi/covers/cover-landscape.png" alt="收猴子的故事"><div><b>🐒 收猴子的故事</b><span>经济学寓言 · 手绘涂鸦风格 · 6 分 22 秒</span></div></a>
 
12
  </style></head><body>
13
  <h1>知识动画工作室</h1><p>网页即播 · 浏览器本地导出 MP4 · 横屏 B站 / 竖屏抖音</p>
14
  <div class="grid">
15
+ <a class="card" href="solana-clock/index.html"><img src="solana-clock/covers/cover-landscape.png" alt="SOLANA 给区块链装上时钟的人" onerror="this.style.visibility='hidden'"><div><b>⏱️ SOLANA:给区块链装上时钟的人</b><span>2017 → 2026 · 全新制作 · 时间之链霓虹风 · 约 7 分钟</span></div></a>
16
  <a class="card" href="ethereum/index.html"><img src="ethereum/covers/cover-landscape.png" alt="以太坊简史" onerror="this.style.visibility='hidden'"><div><b>⟠ 以太坊简史:从一份白皮书到世界计算机</b><span>2013 → 2026 · 深空光之晶体风格 · 约 7 分钟</span></div></a>
17
  <a class="card" href="guozu/index.html"><img src="guozu/covers/cover-landscape.png" alt="国足投注模拟器" onerror="this.style.visibility='hidden'"><div><b>⚽ 国足投注模拟器:每场押 ¥100,23 年后还剩多少?</b><span>2002 → 2026.10 · 149 场正式比赛 + 8 场友谊赛彩蛋 · 8-bit 像素街机风 · 约 5 分 50 秒 · 数据模拟,远离赌博</span></div></a>
18
  <a class="card" href="shouhouzi/index.html"><img src="shouhouzi/covers/cover-landscape.png" alt="收猴子的故事"><div><b>🐒 收猴子的故事</b><span>经济学寓言 · 手绘涂鸦风格 · 6 分 22 秒</span></div></a>
solana-clock/index.html CHANGED
@@ -15,7 +15,7 @@
15
  <span class="logo" aria-hidden="true"><i></i><i></i><i></i></span>
16
  <div>
17
  <h1>SOLANA:给区块链装上时钟的人</h1>
18
- <p>一部约 6 分半钟的动画史 · 旁白 / 配乐 / 音效 · 全部在你的浏览器里实时生成</p>
19
  </div>
20
  </div>
21
  <a class="back" href="../index.html">← 返回动画工作室</a>
 
15
  <span class="logo" aria-hidden="true"><i></i><i></i><i></i></span>
16
  <div>
17
  <h1>SOLANA:给区块链装上时钟的人</h1>
18
+ <p>一部约 7 分钟的动画史 · 旁白 / 配乐 / 音效 · 全部在你的浏览器里实时生成</p>
19
  </div>
20
  </div>
21
  <a class="back" href="../index.html">← 返回动画工作室</a>
solana-clock/js/audio.js ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Procedural score + SFX + narration, rendered once into a single AudioBuffer (used for playback AND MP4 export).
2
+ import { TL, allSfx } from './engine.js';
3
+ import { helpers, FX } from './sfx.js';
4
+
5
+ export const SR = 48000;
6
+ const BPM = 96, BEAT = 60 / BPM, BAR = BEAT * 4;
7
+ const CH = { // chord voicings (midi), A-minor family
8
+ Am: [57, 60, 64], F: [53, 57, 60], C: [55, 60, 64], G: [55, 59, 62], Dm: [57, 62, 65], E: [56, 59, 64], Em: [55, 59, 64], Fmaj7: [57, 60, 64, 65], Am9: [57, 60, 64, 71],
9
+ };
10
+ const ROOT = { Am: 45, F: 41, C: 48, G: 43, Dm: 50, E: 40, Em: 40, Fmaj7: 41, Am9: 45 };
11
+ const P = {
12
+ epic: ['Am', 'F', 'C', 'G'], dark: ['Am', 'Dm', 'Am', 'E'], bright: ['C', 'G', 'Am', 'F'],
13
+ myst: ['Am9', 'Fmaj7', 'Am9', 'Em'], sad: ['Am', 'F', 'Dm', 'E'], hope: ['F', 'G', 'Am', 'C'],
14
+ };
15
+ // per-scene arrangement: pad / bass / arp / drums (0 none, 1 light, 2 full) / tick (clock) / cutoff / arp wave / reverb send
16
+ const ARR = {
17
+ hook: { prog: 'epic', pad: .9, bass: .8, arp: .5, drums: 0, tick: 1, cut: 1400, boom: true },
18
+ night: { prog: 'myst', pad: .8, bass: .4, arp: .25, drums: 0, tick: 0, cut: 900 },
19
+ poh: { prog: 'epic', pad: .6, bass: .7, arp: .8, drums: 1, tick: 1, cut: 2000 },
20
+ name: { prog: 'bright', pad: .8, bass: .5, arp: .4, drums: 0, tick: 0, cut: 1600 },
21
+ build: { prog: 'epic', pad: .6, bass: .8, arp: .8, drums: 1, tick: .4, cut: 2400 },
22
+ mainnet: { prog: 'dark', pad: .8, bass: .8, arp: .3, drums: 1, tick: 0, cut: 1100 },
23
+ rise: { prog: 'bright', pad: .6, bass: 1, arp: 1, drums: 2, tick: 0, cut: 3200 },
24
+ outage: { prog: 'dark', pad: .7, bass: .6, arp: 0, drums: 0, tick: .5, cut: 700 },
25
+ ftx: { prog: 'sad', pad: .7, bass: .3, arp: .2, drums: 0, tick: 0, cut: 600, piano: true },
26
+ rebuild: { prog: 'hope', pad: .7, bass: .7, arp: .6, drums: 1, tick: 0, cut: 1800 },
27
+ meme: { prog: 'bright', pad: .4, bass: 1, arp: .9, drums: 2, tick: 0, cut: 3600, square: true },
28
+ ath: { prog: 'epic', pad: .8, bass: 1, arp: 1, drums: 2, tick: 0, cut: 4000, boom: true },
29
+ wall: { prog: 'hope', pad: .7, bass: .6, arp: .5, drums: 1, tick: .3, cut: 1600 },
30
+ now: { prog: 'epic', pad: .7, bass: .7, arp: .7, drums: 1, tick: .6, cut: 2200 },
31
+ outro: { prog: 'hope', pad: 1, bass: .6, arp: .4, drums: 0, tick: .6, cut: 1500, final: true },
32
+ };
33
+
34
+ function impulse(ac, sec = 2.6) {
35
+ const n = Math.floor(ac.sampleRate * sec), b = ac.createBuffer(2, n, ac.sampleRate);
36
+ for (let c = 0; c < 2; c++) { const d = b.getChannelData(c); let s = 99 + c; for (let i = 0; i < n; i++) { s = (s * 1664525 + 1013904223) >>> 0; d[i] = (s / 2147483648 - 1) * Math.pow(1 - i / n, 3.2); } }
37
+ return b;
38
+ }
39
+
40
+ /** level of a scene's music at absolute time T (fade in/out around scene boundaries) */
41
+ function sceneEnv(sc, T) {
42
+ const a = Math.min(1, Math.max(0, (T - (sc.start - .8)) / 1.2));
43
+ const b = Math.min(1, Math.max(0, ((sc.end + .6) - T) / 1.2));
44
+ return Math.min(a, b);
45
+ }
46
+
47
+ // ---- schedule music for bar starting at time tb (absolute) ----
48
+ function scheduleBar(A, bus, sc, arr, tb, barIdx, off) {
49
+ const ac = A.ac, t = tb - off;
50
+ const k = Math.min(sceneEnv(sc, tb), sceneEnv(sc, tb + BAR));
51
+ const k0 = sceneEnv(sc, tb);
52
+ if (k0 <= 0 && sceneEnv(sc, tb + BAR) <= 0) return;
53
+ const lvl = (Math.max(k0, sceneEnv(sc, tb + BAR)));
54
+ const prog = P[arr.prog];
55
+ let cname = prog[barIdx % prog.length];
56
+ if (arr.final && tb > sc.end - BAR * 2.2) cname = 'C';
57
+ const chord = CH[cname], root = ROOT[cname];
58
+ // PAD: detuned saws through a lowpass, slow swell
59
+ if (arr.pad) {
60
+ const f = A.filt('lowpass', arr.cut, .7, bus.pad);
61
+ f.frequency.setValueAtTime(arr.cut * .6, t); f.frequency.linearRampToValueAtTime(arr.cut, t + BAR * .5); f.frequency.linearRampToValueAtTime(arr.cut * .7, t + BAR);
62
+ const g = A.gain(0, f);
63
+ const v = .045 * arr.pad * lvl;
64
+ g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(v, t + .5); g.gain.setValueAtTime(v, t + BAR - .1); g.gain.linearRampToValueAtTime(0, t + BAR + .9);
65
+ for (const m of [...chord, chord[0] + 12]) for (const dt of [-8, 7]) {
66
+ const o = ac.createOscillator(); o.type = 'sawtooth'; o.frequency.value = A.mtof(m); o.detune.value = dt;
67
+ o.connect(g); o.start(t); o.stop(t + BAR + 1);
68
+ }
69
+ }
70
+ // BASS
71
+ if (arr.bass) {
72
+ const v = .22 * arr.bass * lvl, f = A.mtof(root);
73
+ if (arr.drums >= 1) {
74
+ for (let i = 0; i < 8; i++) { if (arr.drums === 1 && i % 2) continue; A.tone(bus.bass, t + i * BEAT / 2, { type: 'triangle', f: i === 7 && arr.drums === 2 ? f * 1.5 : f, a: .008, d: BEAT * .45, v, curve: 'lin' }); }
75
+ A.tone(bus.bass, t, { f: f / 2, a: .01, d: BAR * .9, v: v * .6, curve: 'lin' });
76
+ } else {
77
+ A.tone(bus.bass, t, { type: 'triangle', f, a: .3, d: BAR, v: v * .9, curve: 'lin' });
78
+ A.tone(bus.bass, t, { f: f / 2, a: .3, d: BAR, v: v * .7, curve: 'lin' });
79
+ }
80
+ }
81
+ // ARP: 16ths over chord tones
82
+ if (arr.arp) {
83
+ const notes = [...chord, chord[0] + 12, chord[1] + 12, chord[2] + 12];
84
+ const pat = arr.square ? [0, 2, 4, 2, 5, 3, 1, 3] : [0, 1, 2, 3, 4, 3, 2, 1];
85
+ const step = BEAT / 4, n = arr.drums ? 16 : 8, len = arr.drums ? step : step * 2;
86
+ for (let i = 0; i < n; i++) {
87
+ const m = notes[pat[i % pat.length]] + 12;
88
+ const p = A.pan(Math.sin(i * .9) * .45, bus.arp);
89
+ A.tone(p, t + i * len, { type: arr.square ? 'square' : 'triangle', f: A.mtof(m), a: .004, d: len * 1.6, v: (arr.square ? .03 : .06) * arr.arp * lvl * (i % 4 === 0 ? 1 : .7) });
90
+ }
91
+ }
92
+ // sad piano (ftx)
93
+ if (arr.piano) {
94
+ const mel = [chord[2] + 12, chord[1] + 12, chord[0] + 12, chord[1] + 12];
95
+ mel.forEach((m, i) => A.bell(bus.arp, t + i * BEAT, A.mtof(m), .07 * lvl, 2.2));
96
+ }
97
+ // DRUMS
98
+ if (arr.drums) {
99
+ const v = lvl;
100
+ for (let b = 0; b < 4; b++) {
101
+ const tt = t + b * BEAT;
102
+ if (arr.drums === 2 || b % 2 === 0) A.tone(bus.drums, tt, { f: 150, f2: 45, glide: .12, a: .002, d: .35, v: .75 * v });
103
+ if (b % 2 === 1) { A.noise(bus.drums, tt, { type: 'bandpass', f: 1800, q: .9, a: .002, d: .18, v: (arr.drums === 2 ? .32 : .16) * v }); A.tone(bus.drums, tt, { type: 'triangle', f: 220, f2: 160, d: .1, v: .15 * v }); }
104
+ for (let h = 0; h < 2; h++) A.noise(bus.drums, tt + h * BEAT / 2, { type: 'highpass', f: 8000, a: .001, d: h ? .05 : .03, v: (arr.drums === 2 ? .12 : .07) * v });
105
+ }
106
+ }
107
+ // clock tick motif on every beat (tick-tock pitch alternation)
108
+ if (arr.tick) {
109
+ for (let b = 0; b < 4; b++) {
110
+ const tt = t + b * BEAT;
111
+ A.tone(bus.tick, tt, { type: 'sine', f: b % 2 ? 1500 : 2000, a: .001, d: .05, v: .1 * arr.tick * lvl });
112
+ A.noise(bus.tick, tt, { type: 'bandpass', f: b % 2 ? 2500 : 3400, q: 8, a: .001, d: .03, v: .35 * arr.tick * lvl });
113
+ }
114
+ }
115
+ if (arr.boom && barIdx % 4 === 0) A.tone(bus.bass, t, { f: 70, f2: 32, glide: .6, d: 2, v: .4 * lvl });
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------------------------
119
+ // Rendering is split per scene: each scene gets its own OfflineAudioContext covering
120
+ // [start - PRE, end + POST] with only that scene's music bars, SFX and narration. Segments are
121
+ // summed (they only overlap in quiet tails), so playback can begin as soon as the first scene is ready.
122
+ const PRE = BAR + 1.5, POST = 4;
123
+ let narr = null;
124
+ async function decodeNarration(onP) {
125
+ if (narr) return narr;
126
+ const dec = new OfflineAudioContext(1, SR, SR);
127
+ const base = new URL('../audio/', import.meta.url);
128
+ let done = 0;
129
+ narr = Promise.all(TL.lines.map(async l => {
130
+ const ab = await (await fetch(new URL(l.key + '.mp3', base))).arrayBuffer();
131
+ const b = await dec.decodeAudioData(ab);
132
+ onP?.(++done / TL.lines.length);
133
+ return b;
134
+ }));
135
+ return narr;
136
+ }
137
+
138
+ let SFXEV = null;
139
+ async function renderScene(si, bufs) {
140
+ const sc = TL.scenes[si];
141
+ const t0 = Math.max(0, sc.start - PRE), t1 = Math.min(TL.total + .5, sc.end + POST);
142
+ const ac = new OfflineAudioContext(2, Math.ceil((t1 - t0) * SR), SR);
143
+ const A = helpers(ac);
144
+ const master = A.gain(1.2);
145
+ const comp = ac.createDynamicsCompressor();
146
+ comp.threshold.value = -12; comp.knee.value = 6; comp.ratio.value = 6; comp.attack.value = .004; comp.release.value = .2;
147
+ master.connect(comp); comp.connect(ac.destination);
148
+ const verb = ac.createConvolver(); verb.buffer = impulse(ac); verb.connect(A.gain(.32, master));
149
+ const duck = A.gain(1, A.gain(1, master));
150
+ const send = (g, s) => { g.connect(A.gain(s, verb)); return g; };
151
+ const bus = {
152
+ pad: send(A.gain(1, duck), .5), bass: A.gain(1, duck), arp: send(A.gain(1, duck), .35),
153
+ drums: send(A.gain(.9, duck), .08), tick: send(A.gain(1, duck), .25),
154
+ };
155
+ const sfx = send(A.gain(.7, master), .22);
156
+ const voice = A.filt('highpass', 70, .7, A.gain(1.0, master));
157
+ // ducking (local time)
158
+ const MUS = .55, DUCK = .2, L = sc.lines;
159
+ duck.gain.setValueAtTime(MUS, 0);
160
+ L.forEach((l, i) => {
161
+ const prev = L[i - 1], nx = L[i + 1];
162
+ if (!prev || l.start - prev.end > .9) duck.gain.setTargetAtTime(DUCK, Math.max(0, l.start - .25 - t0), .08);
163
+ if (!nx || nx.start - l.end > .9) duck.gain.setTargetAtTime(MUS, l.end + .15 - t0, .35);
164
+ });
165
+ // narration
166
+ L.forEach(l => { const s = ac.createBufferSource(); s.buffer = bufs[TL.lines.indexOf(l)]; s.connect(voice); s.start(l.start - t0); });
167
+ // music
168
+ const arr = ARR[sc.id] || ARR.wall;
169
+ for (let b = Math.ceil(t0 / BAR); b * BAR < sc.end + 1; b++) { const tb = b * BAR; if (tb > sc.start - BAR - 1) scheduleBar(A, bus, sc, arr, tb, b, t0); }
170
+ // sfx belonging to this scene (incl. the transition whoosh just before it)
171
+ for (const e of SFXEV) {
172
+ if (e.si !== si) continue;
173
+ const f = FX[e.k] || FX.blip;
174
+ try { f(A, sfx, Math.max(.001, e.t - t0), { ...e, v: e.v ?? 1 }); } catch (err) { console.warn('sfx', e.k, err); }
175
+ }
176
+ const out = await ac.startRendering();
177
+ return { si, t0, t1, buf: out };
178
+ }
179
+
180
+ /** Progressive mix. on('segment') fires as each scene finishes; full() resolves the summed AudioBuffer. */
181
+ export function createMix() {
182
+ const segs = new Array(TL.scenes.length).fill(null);
183
+ const listeners = new Set();
184
+ let order = TL.scenes.map((_, i) => i), running = null, fullBuf = null, progress = 0;
185
+ const api = {
186
+ segs, get progress() { return progress; },
187
+ onSegment(cb) { listeners.add(cb); segs.forEach(s => s && cb(s)); return () => listeners.delete(cb); },
188
+ /** render starting from the scene that contains T (so seeking far ahead gets sound sooner) */
189
+ prioritize(T) {
190
+ const k = TL.scenes.findIndex(s => T < s.end);
191
+ const i0 = k < 0 ? 0 : k;
192
+ order = [...TL.scenes.keys()].filter(i => i >= i0).concat([...TL.scenes.keys()].filter(i => i < i0));
193
+ },
194
+ run(onP) {
195
+ if (running) return running;
196
+ running = (async () => {
197
+ const bufs = await decodeNarration(p => { progress = p * .15; onP?.(progress); });
198
+ if (!SFXEV) {
199
+ SFXEV = allSfx().map(e => { let si = 0; TL.scenes.forEach((s, i) => { if (e.t >= s.start - .5) si = i; }); return { ...e, si }; });
200
+ }
201
+ const work = async () => {
202
+ for (; ;) {
203
+ const i = order.find(i => segs[i] === null); if (i === undefined) return;
204
+ segs[i] = 'busy';
205
+ segs[i] = await renderScene(i, bufs);
206
+ const n = segs.filter(s => s && s !== 'busy').length;
207
+ progress = .15 + .85 * n / segs.length; onP?.(progress);
208
+ listeners.forEach(cb => cb(segs[i]));
209
+ }
210
+ };
211
+ await Promise.all([work(), work()]); // two scenes in parallel (separate audio threads)
212
+ })();
213
+ return running;
214
+ },
215
+ async full(onP) {
216
+ if (fullBuf) return fullBuf;
217
+ await api.run(onP);
218
+ const len = Math.ceil(TL.total * SR);
219
+ const out = new AudioBuffer({ numberOfChannels: 2, length: len, sampleRate: SR });
220
+ for (let c = 0; c < 2; c++) {
221
+ const d = out.getChannelData(c);
222
+ for (const s of segs) {
223
+ const src = s.buf.getChannelData(c), o = Math.round(s.t0 * SR);
224
+ const n = Math.min(src.length, len - o);
225
+ for (let i = 0; i < n; i++) d[o + i] += src[i];
226
+ }
227
+ for (let i = 0; i < len; i++) { const x = d[i]; if (x > .95 || x < -.95) d[i] = Math.tanh(x * 1.2) / Math.tanh(1.2); }
228
+ }
229
+ fullBuf = out;
230
+ return out;
231
+ },
232
+ };
233
+ return api;
234
+ }
235
+
236
+ let MIX = null;
237
+ export const getMix = () => (MIX ||= createMix());
238
+ /** compatibility: full mix in one call */
239
+ export const buildMix = (onP) => getMix().full(onP);
solana-clock/js/main.js CHANGED
@@ -31,34 +31,44 @@ function draw() {
31
  renderFrame(ctx, state.orient, state.T, { subs: state.subs, safe: state.safe });
32
  }
33
 
34
- // ---------- audio (lazy) ----------
35
- let audioMod = null, mixBuf = null, actx = null, srcNode = null, gainNode = null, clock0 = 0, mixPromise = null;
36
- async function ensureMix() {
37
- if (mixBuf) return mixBuf;
38
- if (!mixPromise) {
39
- mixPromise = (async () => {
40
- audioMod = await import('./audio.js');
41
- setLoading('正在合成配乐、音效与旁白…');
42
- mixBuf = await audioMod.buildMix(p => setLoading(`正在合成声音… ${Math.round(p * 100)}%`));
43
- setLoading(null);
44
- return mixBuf;
45
- })().catch(e => { console.error(e); setLoading(null); toast('声音加载失败,将静音播放'); mixPromise = null; return null; });
46
- }
47
- return mixPromise;
48
  }
49
- export const getMix = ensureMix;
50
-
51
- function stopSrc() { if (srcNode) { try { srcNode.onended = null; srcNode.stop(); } catch { } srcNode.disconnect(); srcNode = null; } }
52
- function startSrc() {
53
- stopSrc();
54
- if (!mixBuf || !actx) return;
55
- srcNode = actx.createBufferSource(); srcNode.buffer = mixBuf;
56
- srcNode.connect(gainNode);
57
- srcNode.start(0, Math.min(state.T, mixBuf.duration - .01));
58
- clock0 = actx.currentTime - state.T;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  }
60
 
61
- let wall0 = 0;
62
  async function play() {
63
  if (state.T >= TL.total - .05) state.T = 0;
64
  $('#bigPlay').classList.add('hide');
@@ -68,26 +78,22 @@ async function play() {
68
  gainNode = actx.createGain(); gainNode.gain.value = state.muted ? 0 : 1; gainNode.connect(actx.destination);
69
  }
70
  await actx.resume().catch(() => { });
 
71
  state.playing = true; setPlayIcon();
72
- wall0 = performance.now() / 1000 - state.T;
73
- if (!mixBuf) {
74
- // run silently until the mix is ready, then attach audio at the current position
75
- ensureMix().then(b => { if (b && state.playing) startSrc(); });
76
- } else startSrc();
77
  }
78
- function pause() { state.playing = false; stopSrc(); setPlayIcon(); }
79
  export function seek(T) {
80
  state.T = Math.max(0, Math.min(TL.total - .01, T)); state.dirty = true;
81
- wall0 = performance.now() / 1000 - state.T;
82
- if (state.playing && mixBuf) startSrc();
83
  }
84
  function toggle() { state.playing ? pause() : play(); }
85
  export const pausePlayer = pause;
86
 
87
- function now() {
88
- if (srcNode && actx) return actx.currentTime - clock0;
89
- return performance.now() / 1000 - wall0;
90
- }
91
 
92
  function setPlayIcon() {
93
  $('#icoPlay').innerHTML = state.playing ? '<path d="M7 5h4v14H7zm6 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
@@ -177,7 +183,7 @@ async function openTab(name) {
177
  document.querySelectorAll('.panel').forEach(p => p.classList.toggle('on', p.dataset.panel === name));
178
  if (loaded[name]) return; loaded[name] = true;
179
  try {
180
- if (name === 'export') (await import('./exporter.js')).initExportPanel({ state, getMix: ensureMix, pausePlayer: pause, toast });
181
  if (name === 'publish') (await import('./publish.js')).initPublishPanel({ toast });
182
  if (name === 'cover') (await import('./cover.js')).initCoverPanel({ toast });
183
  } catch (e) { console.error(e); loaded[name] = false; }
@@ -198,7 +204,7 @@ async function boot() {
198
  state.dirty = true;
199
  window.__ready = true;
200
  // warm the audio mix in the background so playback starts with sound
201
- if (!qs.has('nomix')) setTimeout(() => ensureMix(), 400);
202
  }
203
  boot();
204
  requestAnimationFrame(loop);
 
31
  renderFrame(ctx, state.orient, state.T, { subs: state.subs, safe: state.safe });
32
  }
33
 
34
+ // ---------- audio (progressive, per-scene segments) ----------
35
+ let mix = null, actx = null, gainNode = null, clock0 = 0, live = new Set();
36
+ async function ensureMixObj() {
37
+ if (mix) return mix;
38
+ const m = await import('./audio.js');
39
+ mix = m.getMix();
40
+ mix.prioritize(state.T);
41
+ mix.onSegment(seg => { if (state.playing) playSeg(seg); updateAudioBadge(); });
42
+ mix.run(p => updateAudioBadge(p)).catch(e => { console.error(e); toast('声音合成失败,将静音播放'); });
43
+ return mix;
 
 
 
 
44
  }
45
+ /** full AudioBuffer (for export) */
46
+ export async function getMix(onP) { const m = await ensureMixObj(); return m.full(onP); }
47
+ function updateAudioBadge(p) {
48
+ if (!mix) return;
49
+ const n = mix.segs.filter(s => s && s !== 'busy').length;
50
+ const cur = sceneAt(state.T).si, ready = mix.segs[cur] && mix.segs[cur] !== 'busy';
51
+ if (state.playing && !ready) setLoading(`正在合成本章声音… ${Math.round((p ?? mix.progress) * 100)}%`);
52
+ else setLoading(null);
53
+ if (n === mix.segs.length) setLoading(null);
54
+ }
55
+ function playSeg(seg) {
56
+ if (!actx) return;
57
+ const T = actx.currentTime - clock0; // current timeline position
58
+ const end = seg.t0 + seg.buf.duration;
59
+ if (end <= T + .02) return;
60
+ const src = actx.createBufferSource(); src.buffer = seg.buf; src.connect(gainNode);
61
+ if (seg.t0 >= T) src.start(clock0 + seg.t0); else src.start(0, T - seg.t0);
62
+ live.add(src); src.onended = () => live.delete(src);
63
+ }
64
+ function stopAll() { for (const s of live) { try { s.onended = null; s.stop(); s.disconnect(); } catch { } } live.clear(); }
65
+ function startAudio() {
66
+ stopAll();
67
+ if (!actx) return;
68
+ clock0 = actx.currentTime + .03 - state.T;
69
+ if (mix) mix.segs.forEach(s => s && s !== 'busy' && playSeg(s));
70
  }
71
 
 
72
  async function play() {
73
  if (state.T >= TL.total - .05) state.T = 0;
74
  $('#bigPlay').classList.add('hide');
 
78
  gainNode = actx.createGain(); gainNode.gain.value = state.muted ? 0 : 1; gainNode.connect(actx.destination);
79
  }
80
  await actx.resume().catch(() => { });
81
+ clock0 = actx.currentTime - state.T;
82
  state.playing = true; setPlayIcon();
83
+ await ensureMixObj();
84
+ startAudio();
85
+ updateAudioBadge();
 
 
86
  }
87
+ function pause() { state.playing = false; stopAll(); setPlayIcon(); }
88
  export function seek(T) {
89
  state.T = Math.max(0, Math.min(TL.total - .01, T)); state.dirty = true;
90
+ if (mix) mix.prioritize(state.T);
91
+ if (state.playing) startAudio();
92
  }
93
  function toggle() { state.playing ? pause() : play(); }
94
  export const pausePlayer = pause;
95
 
96
+ function now() { return actx ? actx.currentTime - clock0 : state.T; }
 
 
 
97
 
98
  function setPlayIcon() {
99
  $('#icoPlay').innerHTML = state.playing ? '<path d="M7 5h4v14H7zm6 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
 
183
  document.querySelectorAll('.panel').forEach(p => p.classList.toggle('on', p.dataset.panel === name));
184
  if (loaded[name]) return; loaded[name] = true;
185
  try {
186
+ if (name === 'export') (await import('./exporter.js')).initExportPanel({ state, getMix, pausePlayer: pause, toast });
187
  if (name === 'publish') (await import('./publish.js')).initPublishPanel({ toast });
188
  if (name === 'cover') (await import('./cover.js')).initCoverPanel({ toast });
189
  } catch (e) { console.error(e); loaded[name] = false; }
 
204
  state.dirty = true;
205
  window.__ready = true;
206
  // warm the audio mix in the background so playback starts with sound
207
+ if (!qs.has('nomix')) setTimeout(() => ensureMixObj(), 400);
208
  }
209
  boot();
210
  requestAnimationFrame(loop);
solana-clock/js/sfx.js ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Synthesized sound-effects library. Every effect: fx[k](A, out, t, e) where A = helpers bound to an (Offline)AudioContext.
2
+ // t is context time (>= currentTime), e = {v, d, p} from the scene's sfx() list.
3
+
4
+ const mtof = m => 440 * Math.pow(2, (m - 69) / 12);
5
+ const STEP = [0, 2, 4, 7, 9, 12, 14];
6
+
7
+ export function helpers(ac) {
8
+ // shared noise buffer (3 s, stereo-decorrelated)
9
+ const nb = ac.createBuffer(2, ac.sampleRate * 3, ac.sampleRate);
10
+ for (let c = 0; c < 2; c++) { const d = nb.getChannelData(c); let s = 1234567 + c * 777; for (let i = 0; i < d.length; i++) { s = (s * 1664525 + 1013904223) >>> 0; d[i] = s / 2147483648 - 1; } }
11
+ const A = {
12
+ ac, mtof,
13
+ gain(v = 1, dst) { const g = ac.createGain(); g.gain.value = v; if (dst) g.connect(dst); return g; },
14
+ filt(type, f, q = 1, dst) { const b = ac.createBiquadFilter(); b.type = type; b.frequency.value = f; b.Q.value = q; if (dst) b.connect(dst); return b; },
15
+ pan(p, dst) { const s = ac.createStereoPanner(); s.pan.value = p; if (dst) s.connect(dst); return s; },
16
+ /** oscillator with attack/decay envelope. returns {o, g} */
17
+ tone(dst, t, { type = 'sine', f = 440, f2, glide, a = .005, d = .3, v = .5, curve = 'exp', detune = 0 } = {}) {
18
+ const o = ac.createOscillator(); o.type = type; o.frequency.setValueAtTime(f, t); o.detune.value = detune;
19
+ if (f2) o.frequency.exponentialRampToValueAtTime(Math.max(1, f2), t + (glide ?? d));
20
+ const g = ac.createGain(); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(v, t + a);
21
+ if (curve === 'exp') g.gain.setTargetAtTime(0, t + a, d / 4); else g.gain.linearRampToValueAtTime(0, t + a + d);
22
+ o.connect(g); g.connect(dst); o.start(t); o.stop(t + a + d * 1.6 + .05);
23
+ return { o, g };
24
+ },
25
+ /** filtered noise burst. */
26
+ noise(dst, t, { type = 'bandpass', f = 1000, f2, q = 1, a = .005, d = .3, v = .5, curve = 'exp', sustain = 0 } = {}) {
27
+ const s = ac.createBufferSource(); s.buffer = nb; s.loop = true;
28
+ const b = ac.createBiquadFilter(); b.type = type; b.Q.value = q; b.frequency.setValueAtTime(f, t);
29
+ if (f2) b.frequency.exponentialRampToValueAtTime(f2, t + a + sustain + d);
30
+ const g = ac.createGain(); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(v, t + a);
31
+ if (sustain) g.gain.setValueAtTime(v, t + a + sustain);
32
+ if (curve === 'exp') g.gain.setTargetAtTime(0, t + a + sustain, d / 4); else g.gain.linearRampToValueAtTime(0, t + a + sustain + d);
33
+ s.connect(b); b.connect(g); g.connect(dst);
34
+ s.start(t, (t * 7.31) % 2.5); s.stop(t + a + sustain + d * 1.6 + .05);
35
+ return { s, b, g };
36
+ },
37
+ bell(dst, t, f, v = .3, d = 1.6) {
38
+ [[1, 1], [2.76, .45], [5.4, .25], [8.93, .12]].forEach(([r, l]) => { if (f * r < 16000) A.tone(dst, t, { f: f * r, v: v * l, d: d / Math.sqrt(r), a: .002 }); });
39
+ },
40
+ };
41
+ return A;
42
+ }
43
+
44
+ const R = (n) => { const x = Math.sin(n * 91.7 + 13.1) * 43758.5453; return x - Math.floor(x); };
45
+
46
+ export const FX = {
47
+ tick(A, o, t, e) { A.noise(o, t, { type: 'highpass', f: 3500, d: .03, v: .5 * e.v }); A.tone(o, t, { f: 2100, d: .05, v: .18 * e.v }); },
48
+ hashtick(A, o, t, e) { const f = A.mtof(84 + STEP[(e.p ?? 0) % 7]); A.tone(o, t, { type: 'triangle', f, d: .09, v: .22 * e.v }); A.noise(o, t, { type: 'highpass', f: 6000, d: .02, v: .2 * e.v }); },
49
+ pop(A, o, t, e) { A.tone(o, t, { f: 520, f2: 1250, glide: .05, d: .09, v: .45 * e.v }); },
50
+ blip(A, o, t, e) { A.tone(o, t, { type: 'square', f: A.mtof(81 + STEP[(e.p ?? 0) % 7]), d: .07, v: .12 * e.v }); },
51
+ coin(A, o, t, e) { const b = A.mtof(83 + STEP[(e.p ?? 0) % 7]); A.tone(o, t, { type: 'square', f: b, d: .06, v: .1 * e.v, curve: 'lin' }); A.tone(o, t + .07, { type: 'square', f: b * 1.335, d: .32, v: .1 * e.v }); },
52
+ whoosh(A, o, t, e) { A.noise(o, t, { type: 'bandpass', f: 300, f2: 2600, q: 1.4, a: .22, d: .45, v: .55 * e.v, curve: 'lin' }); A.noise(o, t + .2, { type: 'lowpass', f: 900, f2: 200, a: .05, d: .5, v: .25 * e.v }); },
53
+ hit(A, o, t, e) { A.tone(o, t, { f: 150, f2: 42, glide: .25, d: .7, v: .9 * e.v }); A.noise(o, t, { type: 'lowpass', f: 2500, f2: 300, d: .35, v: .4 * e.v }); },
54
+ impact(A, o, t, e) { A.tone(o, t, { f: 110, f2: 32, glide: .5, d: 1.6, v: 1 * e.v }); A.noise(o, t, { type: 'lowpass', f: 4000, f2: 150, d: 1.2, v: .45 * e.v }); A.tone(o, t, { type: 'triangle', f: 55, d: 2.2, v: .4 * e.v }); },
55
+ drop(A, o, t, e) { A.tone(o, t, { type: 'triangle', f: 950, f2: 160, glide: .35, d: .4, v: .35 * e.v }); A.tone(o, t + .3, { f: 80, f2: 40, d: .5, v: .5 * e.v }); },
56
+ rise(A, o, t, e) { const d = e.d ?? 1.6; A.noise(o, t, { type: 'bandpass', f: 250, f2: 5000, q: 2, a: d, d: .15, v: .35 * (e.v ?? 1), curve: 'lin' }); A.tone(o, t, { type: 'sawtooth', f: 110, f2: 440, glide: d, a: d, d: .1, v: .06 * (e.v ?? 1), curve: 'lin' }); },
57
+ riser(A, o, t, e) { const d = e.d ?? 2.2; A.noise(o, t, { type: 'bandpass', f: 180, f2: 7000, q: 2.5, a: d, d: .2, v: .45 * (e.v ?? 1), curve: 'lin' }); [0, 7].forEach(s => A.tone(o, t, { type: 'sawtooth', f: A.mtof(45 + s), f2: A.mtof(69 + s), glide: d, a: d, d: .15, v: .05 * (e.v ?? 1), curve: 'lin' })); },
58
+ shimmer(A, o, t, e) { [88, 91, 95, 100, 103].forEach((m, i) => A.tone(o, t + i * .05, { f: A.mtof(m), d: 1.4, a: .03, v: .07 * (e.v ?? 1) })); },
59
+ clink(A, o, t, e) { A.bell(o, t, 2400, .15 * e.v, .5); A.bell(o, t + .09, 3150, .1 * e.v, .4); },
60
+ idea(A, o, t, e) { [76, 81, 88].forEach((m, i) => A.bell(o, t + i * .09, A.mtof(m), .14 * e.v, 1.4)); },
61
+ question(A, o, t, e) { A.tone(o, t, { type: 'triangle', f: 500, f2: 700, glide: .15, d: .2, v: .2 }); A.tone(o, t + .22, { type: 'triangle', f: 600, f2: 900, glide: .2, d: .3, v: .2 }); },
62
+ chatter(A, o, t, e) { const d = e.d ?? 3; for (let i = 0; i < d * 7; i++) { const tt = t + i / 7 + R(i) * .1; A.noise(o, tt, { type: 'bandpass', f: 500 + R(i + 9) * 1400, q: 6, d: .1 + R(i + 3) * .08, v: .06 * (e.v ?? 1) }); } },
63
+ power(A, o, t, e) { A.tone(o, t, { type: 'sawtooth', f: 50, f2: 220, glide: .8, a: .7, d: .5, v: .12 * e.v, curve: 'lin' }); A.noise(o, t + .7, { type: 'lowpass', f: 1800, d: .4, v: .3 * e.v }); A.tone(o, t + .7, { f: 60, d: 1, v: .35 * e.v }); },
64
+ stamp(A, o, t, e) { A.tone(o, t, { f: 180, f2: 55, glide: .12, d: .3, v: .8 * e.v }); A.noise(o, t, { type: 'lowpass', f: 1600, d: .14, v: .5 * e.v }); },
65
+ waves(A, o, t, e) { const d = e.d ?? 8; for (let i = 0; i < d / 3.4; i++) A.noise(o, t + i * 3.4, { type: 'lowpass', f: 400, f2: 1400, a: 1.4, sustain: .3, d: 1.8, v: .16, curve: 'lin' }); },
66
+ scramble(A, o, t, e) { const d = e.d ?? 1; for (let i = 0; i < d * 24; i++) A.tone(o, t + i / 24, { type: 'square', f: 600 + R(i) * 1800, d: .03, v: .045 }); },
67
+ card(A, o, t) { A.noise(o, t, { type: 'bandpass', f: 1800, f2: 4000, q: .8, a: .03, d: .14, v: .22 }); A.tone(o, t + .1, { f: 700, d: .08, v: .1 }); },
68
+ coins(A, o, t, e) { const d = e.d ?? 1.5; for (let i = 0; i < d * 9; i++) A.bell(o, t + i / 9 + R(i) * .06, 2000 + R(i + 4) * 2200, .07, .35); },
69
+ cash(A, o, t) { A.noise(o, t, { type: 'highpass', f: 2000, d: .1, v: .25 }); A.bell(o, t + .12, 1760, .2, 1.4); A.bell(o, t + .12, 2637, .12, 1.2); },
70
+ rumble(A, o, t, e) { const d = e.d ?? 2; A.noise(o, t, { type: 'lowpass', f: 120, q: 2, a: .3, sustain: Math.max(0, d - .8), d: .8, v: .9 * (e.v ?? 1), curve: 'lin' }); },
71
+ rainloop(A, o, t, e) { const d = e.d ?? 10; A.noise(o, t, { type: 'bandpass', f: 5200, q: .4, a: 1.2, sustain: Math.max(0, d - 2.7), d: 1.5, v: .1, curve: 'lin' }); A.noise(o, t, { type: 'lowpass', f: 600, a: 1.2, sustain: Math.max(0, d - 2.7), d: 1.5, v: .07, curve: 'lin' }); },
72
+ thunder(A, o, t, e) { A.noise(o, t, { type: 'highpass', f: 1500, d: .12, v: .5 * e.v }); A.noise(o, t + .05, { type: 'lowpass', f: 700, f2: 90, a: .08, d: 2.8, v: .9 * e.v }); A.tone(o, t + .05, { f: 50, f2: 30, d: 2.2, v: .5 * e.v }); },
73
+ flip(A, o, t) { A.noise(o, t, { type: 'bandpass', f: 2500, q: 1.5, d: .07, v: .25 }); A.noise(o, t + .05, { type: 'bandpass', f: 1500, q: 1.5, d: .06, v: .18 }); },
74
+ rocket(A, o, t, e) { const d = e.d ?? 3; A.noise(o, t, { type: 'lowpass', f: 200, f2: 2400, q: 1, a: .4, sustain: d - .4, d: 1.4, v: .6, curve: 'lin' }); A.tone(o, t, { type: 'sawtooth', f: 40, f2: 90, glide: d, a: .3, d: d, v: .08, curve: 'lin' }); },
75
+ bubble(A, o, t) { A.tone(o, t, { f: 300, f2: 900, glide: .08, d: .12, v: .3 }); },
76
+ swarm(A, o, t, e) { const d = e.d ?? 2; for (let i = 0; i < d * 16; i++) A.tone(o, t + i / 16 + R(i) * .04, { type: 'triangle', f: A.mtof(76 + STEP[Math.floor(R(i + 2) * 7)]), d: .08, v: .07 }); },
77
+ climb(A, o, t, e) { const d = e.d ?? 4, n = Math.floor(d * 4); for (let i = 0; i < n; i++) A.tone(o, t + i * d / n, { type: 'triangle', f: A.mtof(64 + STEP[i % 5] + 12 * Math.floor(i / 5) * .5), d: .18, v: .06 + .06 * i / n }); },
78
+ hum(A, o, t, e) { const d = e.d ?? 4; A.tone(o, t, { type: 'sawtooth', f: 60, a: .5, d: d, v: .06, curve: 'lin' }); A.tone(o, t, { type: 'sine', f: 120, a: .5, d: d, v: .05, curve: 'lin' }); },
79
+ glitchloop(A, o, t, e) { const d = e.d ?? 4; for (let i = 0; i < d * 10; i++) if (R(i) > .45) A.tone(o, t + i / 10, { type: 'square', f: 80 + R(i + 1) * 1800, d: .04 + R(i + 2) * .06, v: .05, curve: 'lin' }); },
80
+ powerdown(A, o, t, e) { A.tone(o, t, { type: 'sawtooth', f: 420, f2: 28, glide: 1.4, a: .01, d: 1.5, v: .16 * e.v, curve: 'lin' }); A.tone(o, t, { f: 220, f2: 20, glide: 1.4, d: 1.5, v: .3 * e.v, curve: 'lin' }); },
81
+ alarm(A, o, t, e) { const d = e.d ?? 2; for (let i = 0; i < d / .25; i++) A.tone(o, t + i * .25, { type: 'square', f: i % 2 ? 660 : 880, a: .01, d: .2, v: .06, curve: 'lin' }); },
82
+ error(A, o, t, e) { A.tone(o, t, { type: 'square', f: 330, d: .1, v: .1 * e.v, curve: 'lin' }); A.tone(o, t + .12, { type: 'square', f: 220, d: .18, v: .1 * e.v, curve: 'lin' }); },
83
+ glitch(A, o, t, e) { for (let i = 0; i < 8; i++) A.tone(o, t + i * .035, { type: 'square', f: 100 + R(i + 40) * 2400, d: .03, v: .08 * e.v, curve: 'lin' }); A.noise(o, t, { type: 'highpass', f: 3000, d: .2, v: .2 * e.v }); },
84
+ wind(A, o, t, e) { const d = e.d ?? 6; const n = A.noise(o, t, { type: 'bandpass', f: 500, q: 3, a: 2, sustain: Math.max(0, d - 4.5), d: 2.5, v: .16, curve: 'lin' }); for (let k = 0; k < d / 3; k++) n.b.frequency.setTargetAtTime(350 + R(k) * 700, t + k * 3, 1.2); },
85
+ news(A, o, t, e) { [[76, 0], [79, .14], [84, .28], [88, .42]].forEach(([m, dt]) => A.bell(o, t + dt, A.mtof(m), .12 * e.v, 1.2)); A.tone(o, t, { type: 'sawtooth', f: A.mtof(40), d: .6, v: .08 }); },
86
+ crack(A, o, t, e) { A.noise(o, t, { type: 'highpass', f: 2500, a: .001, d: .08, v: .7 * e.v }); A.noise(o, t + .03, { type: 'bandpass', f: 900, q: 2, d: .3, v: .3 * e.v }); },
87
+ collapse(A, o, t, e) { for (let i = 0; i < 6; i++) FX.stamp(A, o, t + i * .22 + R(i) * .08, { v: .5 + R(i + 1) * .4 }); A.noise(o, t, { type: 'lowpass', f: 400, a: .2, sustain: 1, d: 1.5, v: .7, curve: 'lin' }); },
88
+ crash(A, o, t, e) { A.noise(o, t, { type: 'highpass', f: 800, a: .002, d: 1.4, v: .55 * e.v }); A.tone(o, t, { f: 90, f2: 30, d: 1.2, v: .8 * e.v }); },
89
+ bell(A, o, t, e) { A.bell(o, t, A.mtof(45), .45 * (e.v ?? 1), 4.5); A.bell(o, t, A.mtof(57), .2 * (e.v ?? 1), 3); },
90
+ phoenix(A, o, t, e) { FX.riser(A, o, t, { d: 1.6, v: .8 }); FX.shimmer(A, o, t + 1.6, { v: 1.5 }); A.tone(o, t + 1.6, { f: 110, f2: 40, d: 1.4, v: .8 }); },
91
+ jingle(A, o, t, e) { const d = e.d ?? 4, mel = [72, 76, 79, 84, 79, 76, 77, 81, 84, 81, 79, 76]; for (let i = 0; i < d / .25; i++) A.tone(o, t + i * .25, { type: 'square', f: A.mtof(mel[i % mel.length]), d: .16, v: .035, curve: 'lin' }); },
92
+ bark(A, o, t) { [0, .22].forEach(dt => { const b = A.filt('bandpass', 900, 3, o); A.tone(b, t + dt, { type: 'sawtooth', f: 520, f2: 300, glide: .09, a: .005, d: .11, v: .7 }); }); },
93
+ fire(A, o, t, e) { const d = e.d ?? 4; A.noise(o, t, { type: 'lowpass', f: 900, a: .6, sustain: Math.max(0, d - 1.4), d: .8, v: .18, curve: 'lin' }); for (let i = 0; i < d * 14; i++) if (R(i + 7) > .3) A.noise(o, t + i / 14 + R(i) * .05, { type: 'highpass', f: 2500, a: .001, d: .015, v: .25 * R(i + 3) }); },
94
+ party(A, o, t, e) { [60, 64, 67, 72].forEach(m => A.tone(o, t, { type: 'sawtooth', f: A.mtof(m), a: .02, d: .7, v: .05 * e.v, curve: 'lin' })); A.noise(o, t, { type: 'bandpass', f: 3000, f2: 6000, d: .5, v: .2 * e.v }); FX.coins(A, o, t + .1, { d: .8 }); },
95
+ machine(A, o, t, e) { const d = e.d ?? 4; for (let i = 0; i < d / .3; i++) { A.tone(o, t + i * .3, { f: 90, f2: 50, d: .12, v: .35 }); A.noise(o, t + i * .3 + .15, { type: 'bandpass', f: 2200, q: 4, d: .05, v: .2 }); } },
96
+ boing(A, o, t) { const r = A.tone(o, t, { f: 180, f2: 520, glide: .25, d: .45, v: .3 }); const l = A.ac.createOscillator(); l.frequency.value = 18; const lg = A.gain(30); l.connect(lg); lg.connect(r.o.frequency); l.start(t); l.stop(t + .7); },
97
+ cheer(A, o, t, e) { const d = e.d ?? 3; const n = A.noise(o, t, { type: 'bandpass', f: 1200, q: .8, a: .5, sustain: Math.max(0, d - 1.7), d: 1.2, v: .3, curve: 'lin' }); for (let i = 0; i < d * 3; i++) FX.pop(A, o, t + .3 + i / 3 + R(i) * .2, { v: .25 }); },
98
+ firework(A, o, t, e) { const v = e.v ?? 1; A.tone(o, t, { type: 'sine', f: 600 + (e.p ?? 0) * 80, f2: 2200, glide: .45, a: .02, d: .45, v: .05 * v, curve: 'lin' }); A.noise(o, t + .5, { type: 'lowpass', f: 3500, f2: 300, a: .002, d: .9, v: .6 * v }); FX.coins(A, o, t + .55, { d: .6 }); },
99
+ paper(A, o, t) { A.noise(o, t, { type: 'bandpass', f: 3000, q: .7, a: .05, d: .25, v: .2 }); },
100
+ flipboard(A, o, t, e) { const d = e.d ?? 1; for (let i = 0; i < d * 30; i++) A.noise(o, t + i / 30, { type: 'bandpass', f: 2600 + R(i) * 800, q: 3, a: .001, d: .02, v: .25 }); },
101
+ zip(A, o, t, e) { const d = Math.min(e.d ?? 1, 2); A.noise(o, t, { type: 'bandpass', f: 600, f2: 4000, q: 3, a: d * .9, d: .1, v: .2, curve: 'lin' }); },
102
+ chime(A, o, t) { A.bell(o, t, A.mtof(88), .12, 1.6); A.bell(o, t + .16, A.mtof(93), .12, 2); },
103
+ coaster(A, o, t, e) { const d = e.d ?? 8; for (let i = 0; i < d * 6; i++) { const tt = t + i / 6; A.noise(o, tt, { type: 'bandpass', f: 1500 + 1500 * Math.sin(i * .4), q: 5, a: .001, d: .03, v: .12 }); } A.noise(o, t, { type: 'lowpass', f: 300, f2: 1600, a: d * .5, sustain: 0, d: d * .5, v: .12, curve: 'lin' }); },
104
+ };