Spaces:
Running
Running
Commit ·
bdfa818
1
Parent(s): 5049ed6
solana-clock: 程序化配乐 + 音效 + 旁白混音
Browse files- README.md +4 -4
- index.html +1 -1
- solana-clock/index.html +1 -1
- solana-clock/js/audio.js +239 -0
- solana-clock/js/main.js +45 -39
- solana-clock/js/sfx.js +104 -0
README.md
CHANGED
|
@@ -11,7 +11,7 @@ pinned: false
|
|
| 11 |
<!-- solana-clock:start -->
|
| 12 |
# ⏱️ 项目五(全新制作):SOLANA —— 给区块链装上时钟的人(solana-clock/)
|
| 13 |
|
| 14 |
-
> 用一支 **约
|
| 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 个场景,约
|
| 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 · 全新制作 · 时间之链霓虹风 · 约
|
| 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>一部约
|
| 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 (
|
| 35 |
-
let
|
| 36 |
-
async function
|
| 37 |
-
if (
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
return mixBuf;
|
| 45 |
-
})().catch(e => { console.error(e); setLoading(null); toast('声音加载失败,将静音播放'); mixPromise = null; return null; });
|
| 46 |
-
}
|
| 47 |
-
return mixPromise;
|
| 48 |
}
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
function
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
ensureMix().then(b => { if (b && state.playing) startSrc(); });
|
| 76 |
-
} else startSrc();
|
| 77 |
}
|
| 78 |
-
function pause() { state.playing = false;
|
| 79 |
export function seek(T) {
|
| 80 |
state.T = Math.max(0, Math.min(TL.total - .01, T)); state.dirty = true;
|
| 81 |
-
|
| 82 |
-
if (state.playing
|
| 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
|
| 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(() =>
|
| 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 |
+
};
|