vuepress-plugin-chiptune 1.0.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/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # vuepress-plugin-chiptune
2
+
3
+ > 一个用于芯片音乐(Chiptune)的 VuePress 插件。
4
+
5
+ 芯片音乐(英语:Chiptune),也被称为8比特音乐(8-bit music),是一种电子音乐形式,形成于1980年代。它利用老式电脑,视频游戏机和街机等的音乐芯片,或者使用仿真器制作[1]。 芯片音乐一般包括基本波形,如方波,锯齿波或三角波和基本的打击乐器。
6
+
7
+ 这个插件提供了一个交互式组件 `<Chiptune />`,允许用户在您的 VuePress 网站中创建、修改和下载芯片音乐。
8
+
9
+
10
+
11
+ ![image-20250714030733295](./README.assets/image-20250714030733295.png)
12
+
13
+ ## 安装
14
+
15
+ ```bash
16
+ npm install vuepress-plugin-chiptune
17
+ # 或者
18
+ yarn add vuepress-plugin-chiptune
19
+ ```
20
+
21
+ ## 用法
22
+
23
+ 将插件添加到您的 VuePress 配置中 (`.vuepress/config.js`):
24
+
25
+ ```javascript
26
+ module.exports = {
27
+ plugins: [
28
+ ['chiptune']
29
+ ]
30
+ }
31
+ ```
32
+
33
+ 然后,您可以在任何 Markdown 文件中使用该组件:
34
+
35
+ ```markdown
36
+ <Chiptune />
37
+ ```
38
+
39
+ ## 功能
40
+
41
+ `<Chiptune />` 组件提供了一个丰富的用户界面来控制音乐生成过程:
42
+
43
+ * **种子 (Seed)**: 用于程序生成的随机种子。
44
+ * **混沌 (Chaos)**: 控制旋律和节奏的随机性。
45
+ * **音阶 (Scale)**: 从多种音阶中选择,如大调、小调、五声音阶等。
46
+ * **风格 (Style)**: 应用不同的声音预设和风格。
47
+ * **BPM**: 调整每分钟节拍数。
48
+ * **循环长度 (Loop Length)**: 设置循环的长度。
49
+ * **乐器轨道 (Instrument Tracks)**: 启用或禁用主音、贝斯、打击乐和特效。
50
+ * **效果 (Effects)**: 添加混响、延迟和合唱效果。
51
+ * **钢琴卷帘 (Piano Roll)**: 可视化生成的音符。
52
+ * **下载 (Download)**: 将循环导出为 WAV 或 MIDI 文件。
package/client.js ADDED
@@ -0,0 +1,8 @@
1
+ import { defineClientConfig } from '@vuepress/client'
2
+ import Chiptune from './src/Chiptune.vue'
3
+
4
+ export default defineClientConfig({
5
+ enhance({ app }) {
6
+ app.component('Chiptune', Chiptune)
7
+ },
8
+ })
package/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import { path } from '@vuepress/utils'
2
+ import { fileURLToPath } from 'url'
3
+
4
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
5
+
6
+ export const chiptune = {
7
+ name: 'vuepress-plugin-chiptune',
8
+ clientConfigFile: path.resolve(__dirname, './client.js'),
9
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "vuepress-plugin-chiptune",
3
+ "version": "1.0.0",
4
+ "description": "A VuePress plugin for Chiptune music generation.",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "keywords": [
8
+ "vuepress",
9
+ "vuepress-plugin",
10
+ "chiptune",
11
+ "music"
12
+ ],
13
+ "author": "Paper-Dragon",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Paper-Dragon/vuepress-plugin-chiptune.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/Paper-Dragon/vuepress-plugin-chiptune/issues"
21
+ },
22
+ "homepage": "https://github.com/Paper-Dragon/vuepress-plugin-chiptune#readme",
23
+ "dependencies": {
24
+ "@vuepress/client": "2.0.0-rc.24",
25
+ "@vuepress/utils": "2.0.0-rc.24"
26
+ },
27
+ "scripts": {
28
+ "release": "pnpm publish"
29
+ }
30
+ }
@@ -0,0 +1,184 @@
1
+ <template>
2
+ <div class="chiptune-container">
3
+ <h1>🎧 Chiptune — 芯片音乐编辑器</h1>
4
+
5
+ <div class="top-controls">
6
+ <fieldset class="control-group">
7
+ <legend>生成</legend>
8
+ <input id="seedInput" placeholder="输入种子..." value="143eil">
9
+ <button @click="triggerRandom">🎲 随机</button>
10
+ <label for="chaos">混沌: <span id="chaosValue">6</span></label>
11
+ <input id="chaos" type="range" min="0" max="100" value="6">
12
+ </fieldset>
13
+
14
+ <fieldset class="control-group">
15
+ <legend>音色</legend>
16
+ <label for="scaleSelect">音阶</label>
17
+ <select id="scaleSelect">
18
+ <option value="major">大调</option>
19
+ <option value="minor">小调</option>
20
+ <option value="dorian">多里安</option>
21
+ <option value="phrygian" selected>弗里吉安</option>
22
+ <option value="mixolydian">混合利底安</option>
23
+ <option value="pentatonic">五声音阶</option>
24
+ </select>
25
+ <label for="styleSelect">风格</label>
26
+ <select id="styleSelect">
27
+ <option value="pulsewave">脉冲波</option>
28
+ <option value="dreamchip">梦幻芯片</option>
29
+ <option value="darkgrid">黑暗网格</option>
30
+ <option value="arcadegen">街机生成</option>
31
+ <option value="crystal" selected>水晶</option>
32
+ </select>
33
+ </fieldset>
34
+
35
+ <fieldset class="control-group">
36
+ <legend>节奏</legend>
37
+ <label for="bpmSlider">BPM: <span id="bpmOutput">107</span></label>
38
+ <input id="bpmSlider" type="range" min="60" max="240" value="107" step="1">
39
+ <label for="lengthSelect">循环长度</label>
40
+ <select id="lengthSelect">
41
+ <option value="8">8x</option>
42
+ <option value="16">16x</option>
43
+ <option value="32">32x</option>
44
+ <option value="64" selected>64x</option>
45
+ </select>
46
+ </fieldset>
47
+
48
+ <fieldset class="control-group">
49
+ <legend>乐器</legend>
50
+ <div class="checkbox-group">
51
+ <label><input type="checkbox" id="useLead" checked> 主音</label>
52
+ <label><input type="checkbox" id="useLead2" checked> 主音 2</label>
53
+ <label><input type="checkbox" id="useBass" checked> 贝斯</label>
54
+ <label><input type="checkbox" id="useDrums" checked> 打击乐</label>
55
+ <label><input type="checkbox" id="useFx" checked> 特效</label>
56
+ <label><input type="checkbox" id="useReverb"> 混响</label>
57
+ <label><input type="checkbox" id="useDelay"> 延迟</label>
58
+ <label><input type="checkbox" id="useChorus"> 合唱</label>
59
+ </div>
60
+ </fieldset>
61
+ </div>
62
+
63
+ <div class="main-actions">
64
+ <button @click="generateLoop">生成</button>
65
+ <button @click="modifySounds">修改</button>
66
+ <button @click="remixLoop">重混</button>
67
+ <button id="togglePlay" @click="togglePlayPause">播放/暂停</button>
68
+ <button @click="downloadWav">⬇️ 下载 WAV</button>
69
+ <button @click="exportMIDI">🎼 导出 MIDI</button>
70
+ </div>
71
+
72
+ <canvas id="pianoRoll" width="800" height="150"></canvas>
73
+ </div>
74
+ </template>
75
+
76
+ <script>
77
+ import { init, generateLoop, modifySounds, remixLoop, togglePlayPause, downloadWav, exportMIDI, triggerRandom } from './neoloop.js';
78
+
79
+ export default {
80
+ name: 'Chiptune',
81
+ mounted() {
82
+ init();
83
+ },
84
+ methods: {
85
+ generateLoop,
86
+ modifySounds,
87
+ remixLoop,
88
+ togglePlayPause,
89
+ downloadWav,
90
+ exportMIDI,
91
+ triggerRandom
92
+ }
93
+ }
94
+ </script>
95
+
96
+ <style scoped>
97
+ .chiptune-container {
98
+ font-family: inherit;
99
+ text-align: center;
100
+ padding: 0.5rem;
101
+ border-radius: 6px;
102
+ border: 1px solid var(--c-border, #ccc);
103
+ max-width: 840px;
104
+ margin: auto;
105
+ }
106
+
107
+ h1 {
108
+ margin-bottom: 0.8rem;
109
+ font-size: 1.2rem;
110
+ }
111
+
112
+ .top-controls {
113
+ display: flex;
114
+ gap: 0.5rem;
115
+ margin-bottom: 0.5rem;
116
+ }
117
+
118
+ .control-group {
119
+ flex: 1;
120
+ border: 1px solid var(--c-border, #ccc);
121
+ border-radius: 4px;
122
+ padding: 0.6rem;
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: 0.5rem;
126
+ }
127
+
128
+ .control-group legend {
129
+ padding: 0 0.3rem;
130
+ font-size: 0.8rem;
131
+ font-weight: bold;
132
+ }
133
+
134
+ input,
135
+ button,
136
+ select {
137
+ font-family: inherit;
138
+ font-size: 0.75rem;
139
+ border: 1px solid var(--c-border, #ccc);
140
+ border-radius: 3px;
141
+ padding: 0.3rem;
142
+ width: 100%;
143
+ box-sizing: border-box;
144
+ }
145
+
146
+ button {
147
+ cursor: pointer;
148
+ transition: background-color 0.2s;
149
+ }
150
+
151
+ .checkbox-group {
152
+ display: flex;
153
+ flex-direction: column;
154
+ align-items: flex-start;
155
+ gap: 0.2rem;
156
+ padding-left: 0.5rem;
157
+ }
158
+
159
+ .checkbox-group label {
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 0.3rem;
163
+ flex-direction: row;
164
+ white-space: nowrap;
165
+ font-size: 0.7rem;
166
+ }
167
+
168
+ .main-actions {
169
+ display: flex;
170
+ justify-content: center;
171
+ gap: 0.5rem;
172
+ margin-bottom: 0.5rem;
173
+ }
174
+
175
+ canvas {
176
+ border: 1px solid var(--c-border, #ccc);
177
+ border-radius: 4px;
178
+ width: 100%;
179
+ box-sizing: border-box;
180
+ background: var(--c-bg-light, #f9f9f9);
181
+ margin-bottom: 0.5rem;
182
+ }
183
+
184
+ </style>
package/src/neoloop.js ADDED
@@ -0,0 +1,620 @@
1
+ let isPlaying = false;
2
+ let context, buffer, source;
3
+ let baseSeed = "", currentParams = {}, midiData = [];
4
+ let originalMotif = [], visualStep = 0;
5
+ let canvas, ctx2d;
6
+ let seedInput, scaleSelect, lengthSelect, chaos, chaosValue, bpmSlider, bpmOutput, togglePlay;
7
+ let useLead, useLead2, useBass, useDrums, useFx, useReverb, useDelay, useChorus;
8
+
9
+ function initDOMReferences() {
10
+ canvas = document.getElementById("pianoRoll");
11
+ if (!canvas) {
12
+ console.error("Piano roll canvas not found!");
13
+ return false;
14
+ }
15
+ ctx2d = canvas.getContext("2d");
16
+ seedInput = document.getElementById("seedInput");
17
+ scaleSelect = document.getElementById("scaleSelect");
18
+ lengthSelect = document.getElementById("lengthSelect");
19
+ chaos = document.getElementById("chaos");
20
+ chaosValue = document.getElementById("chaosValue");
21
+ bpmSlider = document.getElementById("bpmSlider");
22
+ bpmOutput = document.getElementById("bpmOutput");
23
+ togglePlay = document.getElementById("togglePlay");
24
+ useLead = document.getElementById("useLead");
25
+ useLead2 = document.getElementById("useLead2");
26
+ useBass = document.getElementById("useBass");
27
+ useDrums = document.getElementById("useDrums");
28
+ useFx = document.getElementById("useFx");
29
+ useReverb = document.getElementById("useReverb");
30
+ useDelay = document.getElementById("useDelay");
31
+ useChorus = document.getElementById("useChorus");
32
+ return true;
33
+ }
34
+
35
+
36
+ function fullSeed() {
37
+ return (
38
+ (seedInput.value || "random") +
39
+ "_" + scaleSelect.value +
40
+ "_" + lengthSelect.value
41
+ );
42
+ }
43
+
44
+ function clearRoll() {
45
+ ctx2d.fillStyle = "#111";
46
+ ctx2d.fillRect(0, 0, canvas.width, canvas.height);
47
+ }
48
+
49
+ function drawRoll(motif) {
50
+ clearRoll();
51
+ const steps = parseInt(lengthSelect.value);
52
+ motif.forEach((step, i) => {
53
+ if (step.note) {
54
+ const x = (i / steps) * canvas.width;
55
+ const y = canvas.height - (Math.log2(step.note) - 5) * 30;
56
+ ctx2d.fillStyle = "#00ffcc";
57
+ ctx2d.fillRect(x, y, step.dur * 20, 6);
58
+ }
59
+ });
60
+ }
61
+
62
+ function updateRollPlayback(stepIndex) {
63
+ visualStep = stepIndex;
64
+ drawRoll(originalMotif);
65
+ const steps = parseInt(lengthSelect.value);
66
+ const x = (stepIndex / steps) * canvas.width;
67
+ ctx2d.fillStyle = "#ff3366";
68
+ ctx2d.fillRect(x, 0, 2, canvas.height);
69
+ }
70
+
71
+ function hashSeed(seed) {
72
+ let hash = 0;
73
+ for (let i = 0; i < seed.length; i++)
74
+ hash = seed.charCodeAt(i) + ((hash << 5) - hash);
75
+ return hash >>> 0;
76
+ }
77
+
78
+ function pseudoRandom(seed) {
79
+ let value = hashSeed(seed);
80
+ return () => {
81
+ value ^= value << 13;
82
+ value ^= value >> 17;
83
+ value ^= value << 5;
84
+ return (value >>> 0) / 4294967296;
85
+ };
86
+ }
87
+
88
+ function generateMotif(scale, rand, complexity, steps = 32) {
89
+ const motif = [];
90
+ let note = scale[Math.floor(rand() * scale.length)];
91
+ while (motif.length < steps) {
92
+ const shift = Math.floor(rand() * 5) - 2;
93
+ const newIndex = Math.max(0, Math.min(scale.length - 1, scale.indexOf(note) + shift));
94
+ note = scale[newIndex];
95
+ const dur = rand() < 0.3 ? 2 : 1;
96
+ motif.push({ note, dur });
97
+ if (rand() < 0.05 * complexity) motif.push({ note: null, dur: 1 }); // dropout
98
+ }
99
+ return motif;
100
+ }
101
+
102
+ export function triggerRandom() {
103
+ const randomSeed = Math.floor(Math.random() * 1e9).toString(36);
104
+ seedInput.value = randomSeed;
105
+ const scaleOptions = scaleSelect.options;
106
+ const styleOptions = document.getElementById("styleSelect").options;
107
+ scaleOptions.selectedIndex = Math.floor(Math.random() * scaleOptions.length);
108
+ styleOptions.selectedIndex = Math.floor(Math.random() * styleOptions.length);
109
+ chaos.value = Math.floor(Math.random() * 101);
110
+ chaosValue.textContent = chaos.value;
111
+ bpmSlider.value = 80 + Math.floor(Math.random() * 100);
112
+ bpmOutput.textContent = bpmSlider.value;
113
+ generateLoop();
114
+ }
115
+
116
+ function getScale(root = 261.63, mode = "major") {
117
+ const modes = {
118
+ major: [0, 2, 4, 5, 7, 9, 11],
119
+ minor: [0, 2, 3, 5, 7, 8, 10],
120
+ dorian: [0, 2, 3, 5, 7, 9, 10],
121
+ phrygian: [0, 1, 3, 5, 7, 8, 10],
122
+ mixolydian: [0, 2, 4, 5, 7, 9, 10],
123
+ pentatonic: [0, 2, 4, 7, 9]
124
+ };
125
+ return (modes[mode] || modes.major).map(i => root * Math.pow(2, i / 12));
126
+ }
127
+
128
+ function getStyleParams(style, rand, modulated = false) {
129
+ const chaosVal = parseInt(chaos.value) / 100;
130
+
131
+ function commonParams(base) {
132
+ if (!modulated) {
133
+ return {
134
+ ...base,
135
+ detune: 0,
136
+ attack: 0.01,
137
+ panOffset: 0
138
+ };
139
+ }
140
+ return {
141
+ ...base,
142
+ detune: (rand() - 0.5) * 30,
143
+ attack: 0.01 + rand() * 0.04,
144
+ panOffset: (rand() - 0.5) * 0.6
145
+ };
146
+ }
147
+
148
+ switch (style) {
149
+ case "pulsewave":
150
+ return {
151
+ lead: commonParams({ type: "square", volume: 0.06, decay: 0.3 }),
152
+ lead2: commonParams({ type: "square", volume: 0.04 }),
153
+ bass: commonParams({ type: "square", volume: 0.05, decay: 0.4 }),
154
+ filterHz: 2500
155
+ };
156
+ case "dreamchip":
157
+ return {
158
+ lead: commonParams({ type: "triangle", volume: 0.05, decay: 0.4 }),
159
+ lead2: commonParams({ type: "sine", volume: 0.03 }),
160
+ bass: commonParams({ type: "triangle", volume: 0.03, decay: 0.5 }),
161
+ filterHz: 2000
162
+ };
163
+ case "darkgrid":
164
+ return {
165
+ lead: commonParams({ type: "sawtooth", volume: 0.07, decay: 0.25 }),
166
+ lead2: commonParams({ type: "square", volume: 0.035 }),
167
+ bass: commonParams({ type: "triangle", volume: 0.04, decay: 0.3 }),
168
+ filterHz: 1500
169
+ };
170
+ case "arcadegen":
171
+ return {
172
+ lead: commonParams({ type: "square", volume: 0.065, decay: 0.25 }),
173
+ lead2: commonParams({ type: "triangle", volume: 0.035 }),
174
+ bass: commonParams({ type: "sawtooth", volume: 0.05, decay: 0.45 }),
175
+ filterHz: 2800
176
+ };
177
+ case "crystal":
178
+ return {
179
+ lead: commonParams({ type: "sine", volume: 0.045, decay: 0.45 }),
180
+ lead2: commonParams({ type: "sine", volume: 0.03 }),
181
+ bass: commonParams({ type: "triangle", volume: 0.035, decay: 0.6 }),
182
+ filterHz: 2200
183
+ };
184
+ default:
185
+ return {
186
+ lead: commonParams({ type: "square", volume: 0.06, decay: 0.3 }),
187
+ lead2: commonParams({ type: "square", volume: 0.03 }),
188
+ bass: commonParams({ type: "triangle", volume: 0.04, decay: 0.4 }),
189
+ filterHz: 2400
190
+ };
191
+ }
192
+ }
193
+
194
+ function getChord(root, mode = "major") {
195
+ const intervals = (mode === "minor") ? [0, 3, 7] : [0, 4, 7];
196
+ return intervals.map(i => root * Math.pow(2, i / 12));
197
+ }
198
+
199
+ export async function generateLoop() {
200
+ stopMusic();
201
+ const full = fullSeed();
202
+ baseSeed = full;
203
+ const chaosVal = parseInt(chaos.value);
204
+ const randParams = pseudoRandom(full + "_params_" + chaosVal);
205
+ const randMelody = pseudoRandom(full + "_melody");
206
+ const style = document.getElementById("styleSelect").value;
207
+ const scale = scaleSelect.value;
208
+ const steps = parseInt(lengthSelect.value);
209
+
210
+ const scaleObj = getScale(261.63, scale);
211
+ currentParams = getStyleParams(style, randParams, false);
212
+ originalMotif = generateMotif(scaleObj, randMelody, 1.25, steps);
213
+
214
+ drawRoll(originalMotif);
215
+ buffer = await renderAudio(full, scale, style, currentParams, originalMotif, steps);
216
+ playMusic();
217
+ }
218
+
219
+ export function modifySounds() {
220
+ const style = document.getElementById("styleSelect").value;
221
+ const scale = scaleSelect.value;
222
+ const steps = parseInt(lengthSelect.value);
223
+ const chaosVal = parseInt(chaos.value);
224
+ const modSeed = baseSeed + "_mod_" + Date.now();
225
+ const rand = pseudoRandom(modSeed + "_params_" + chaosVal);
226
+ currentParams = getStyleParams(style, rand, true);
227
+ renderAudio(modSeed, scale, style, currentParams, originalMotif, steps).then(b => {
228
+ buffer = b;
229
+ stopMusic();
230
+ playMusic();
231
+ });
232
+ }
233
+
234
+ export function remixLoop() {
235
+ const base = fullSeed() + "_remix_" + Date.now();
236
+ const randMelody = pseudoRandom(base + "_melody");
237
+ const scale = scaleSelect.value;
238
+ const style = document.getElementById("styleSelect").value;
239
+ const steps = parseInt(lengthSelect.value);
240
+ const scaleObj = getScale(261.63, scale);
241
+ originalMotif = generateMotif(scaleObj, randMelody, 1.25, steps);
242
+ drawRoll(originalMotif);
243
+ const randParams = pseudoRandom(base + "_params");
244
+ currentParams = getStyleParams(style, randParams);
245
+ renderAudio(base, scale, style, currentParams, originalMotif, steps).then(b => {
246
+ buffer = b;
247
+ stopMusic();
248
+ playMusic();
249
+ });
250
+ }
251
+
252
+ function fxTypeForScale(scaleMode) {
253
+ const mapping = {
254
+ major: [0, 1, 3, 5, 6],
255
+ minor: [2, 3, 4, 5, 6],
256
+ dorian: [0, 1, 3, 6],
257
+ phrygian: [2, 4, 5],
258
+ mixolydian: [0, 1, 3, 6],
259
+ pentatonic: [1, 3, 5, 6]
260
+ };
261
+ return mapping[scaleMode] || [0, 1, 2, 3, 4, 5, 6];
262
+ }
263
+
264
+ function injectFX(ctx, t, rand, scale, scaleMode, master) {
265
+ const fxList = fxTypeForScale(scaleMode);
266
+ const fxType = fxList[Math.floor(rand() * fxList.length)];
267
+
268
+ switch (fxType) {
269
+ case 0: // Laser ping
270
+ for (let j = 0; j < 4; j++) {
271
+ playOsc(ctx, t + j * 0.05, 1200 - j * 100 + rand() * 30, 0.08, "square", 0.04, 0.3 - j * 0.2, master, {
272
+ attack: 0.01, detune: rand() * 20, panOffset: rand() - 0.5
273
+ });
274
+ }
275
+ break;
276
+ case 1: // Glissando Rise
277
+ for (let j = 0; j < 5; j++) {
278
+ playOsc(ctx, t + j * 0.06, 300 + j * 100, 0.1, "triangle", 0.03, -0.4 + j * 0.2, master, {
279
+ attack: 0.01, detune: 0, panOffset: 0
280
+ });
281
+ }
282
+ break;
283
+ case 2: // Bass Drop chord
284
+ const dropChord = getChord(scale[0], scaleMode);
285
+ for (let j = 0; j < dropChord.length; j++) {
286
+ playOsc(ctx, t + j * 0.1, dropChord[j] / 2, 0.5, "sawtooth", 0.05, -0.3 + j * 0.3, master, {
287
+ attack: 0.02, detune: 0, panOffset: 0
288
+ });
289
+ }
290
+ break;
291
+ case 3: // Arpeggio
292
+ for (let j = 0; j < 6; j++) {
293
+ const n = scale[j % scale.length];
294
+ playOsc(ctx, t + j * 0.06, n, 0.08, "triangle", 0.04, -0.2 + j * 0.1, master, {
295
+ attack: 0.01, detune: 0, panOffset: 0
296
+ });
297
+ }
298
+ break;
299
+ case 4: // Distorted noise burst
300
+ playNoise(ctx, t, 0.2, 0.1, master);
301
+ break;
302
+ case 5: // Echo bell
303
+ for (let j = 0; j < 4; j++) {
304
+ playOsc(ctx, t + j * 0.15, 800 + j * 60, 0.12, "sine", 0.03, j % 2 === 0 ? -0.5 : 0.5, master, {
305
+ attack: 0.01, detune: 0, panOffset: 0
306
+ });
307
+ }
308
+ break;
309
+ case 6: // Chord pulse
310
+ const chordPulse = getChord(scale[3 % scale.length], scaleMode);
311
+ chordPulse.forEach((freq, idx) => {
312
+ playOsc(ctx, t + idx * 0.04, freq, 0.1, "square", 0.04, -0.3 + idx * 0.3, master, {
313
+ attack: 0.01, detune: 0, panOffset: 0
314
+ });
315
+ });
316
+ break;
317
+ }
318
+ }
319
+
320
+ async function renderAudio(seed, scaleMode, style, params, motif, steps) {
321
+ const rand = pseudoRandom(seed);
322
+ const bpm = parseInt(bpmSlider.value);
323
+ const beat = 60 / bpm / 2;
324
+ const ctx = new OfflineAudioContext(1, 44100 * steps * beat, 44100);
325
+
326
+ const useLeadChecked = useLead.checked;
327
+ const useLead2Checked = useLead2.checked;
328
+ const useBassChecked = useBass.checked;
329
+ const useDrumsChecked = useDrums.checked;
330
+ const useFxChecked = useFx.checked;
331
+ const useReverbChecked = useReverb.checked;
332
+ const useDelayChecked = useDelay.checked;
333
+ const useChorusChecked = useChorus.checked;
334
+
335
+ let output = ctx.createGain();
336
+ const master = ctx.createGain();
337
+
338
+ if (useReverbChecked) {
339
+ const convolver = ctx.createConvolver();
340
+ const impulse = ctx.createBuffer(1, 1.0 * ctx.sampleRate, ctx.sampleRate);
341
+ const data = impulse.getChannelData(0);
342
+ for (let i = 0; i < data.length; i++) {
343
+ data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
344
+ }
345
+ convolver.buffer = impulse;
346
+ master.connect(convolver).connect(output);
347
+ }
348
+
349
+ if (useDelayChecked) {
350
+ const delay = ctx.createDelay(1.0);
351
+ delay.delayTime.value = 0.25 + (bpm - 60) * (0.25 / 60);
352
+ const feedback = ctx.createGain();
353
+ feedback.gain.value = 0.2;
354
+ delay.connect(feedback).connect(delay);
355
+ master.connect(delay).connect(output);
356
+ }
357
+
358
+ if (useChorusChecked) {
359
+ const chorus = ctx.createDelay();
360
+ chorus.delayTime.value = 0.025;
361
+ master.connect(chorus).connect(output);
362
+ }
363
+
364
+ master.connect(output);
365
+ output.connect(ctx.destination);
366
+
367
+ const scale = getScale(261.63, scaleMode);
368
+ const chords = [scale[0], scale[3], scale[4], scale[0]].map(root => getChord(root, scaleMode));
369
+ midiData = [];
370
+
371
+ let t = 0;
372
+ let motifIndex = 0;
373
+
374
+ for (let i = 0; i < steps;) {
375
+ const chord = chords[Math.floor(i / 8) % chords.length];
376
+ const step = motif[motifIndex % motif.length];
377
+ updateRollPlayback(i);
378
+ const dur = step.dur * beat;
379
+
380
+ if (step.note) {
381
+ if (useLeadChecked) {
382
+ playOsc(ctx, t, step.note, dur, params.lead.type, params.lead.volume, -0.3, master, params.lead);
383
+ }
384
+ if (useLead2Checked) {
385
+ playOsc(ctx, t + 0.04, step.note, dur * 0.9, params.lead2.type, params.lead2.volume, 0.3, master, params.lead2);
386
+ }
387
+ midiData.push({ t, note: step.note, dur: step.dur, type: "lead" });
388
+ }
389
+
390
+ if (i % 4 === 0 && useBassChecked) {
391
+ const bass = chord[0] / 2;
392
+ playOsc(ctx, t, bass, params.bass.decay, params.bass.type, params.bass.volume, 0, master, params.bass);
393
+ midiData.push({ t, note: bass, dur: 1, type: "bass" });
394
+ }
395
+
396
+ if (useDrumsChecked) {
397
+ const drumMultiplier = style === "crystal" || "dreamchip" ? 0.4 : 1.0;
398
+ if (i % 8 === 0) playNoise(ctx, t, 0.05, 0.1 * drumMultiplier, master);
399
+ if (i % 8 === 4) playNoise(ctx, t, 0.04, 0.08 * drumMultiplier, master);
400
+ if (i % 2 === 0) playNoise(ctx, t, 0.03, 0.04 * drumMultiplier, master);
401
+ }
402
+ if (useFxChecked && i % 8 === 0 && rand() < 0.5) {
403
+ injectFX(ctx, t, rand, scale, scaleMode, master);
404
+ }
405
+
406
+ t += step.dur * beat;
407
+ i += step.dur;
408
+ motifIndex++;
409
+ }
410
+
411
+ return ctx.startRendering();
412
+ }
413
+
414
+ function playOsc(ctx, time, freq, dur, type, gainVal, pan, dest, options = {}) {
415
+ const osc = ctx.createOscillator();
416
+ const gain = ctx.createGain();
417
+ const panner = ctx.createStereoPanner();
418
+
419
+ const attack = options.attack || 0.01;
420
+ const decay = dur;
421
+ const detune = options.detune || 0;
422
+ const panOffset = options.panOffset || 0;
423
+
424
+ osc.type = type;
425
+ osc.frequency.setValueAtTime(freq, time);
426
+ osc.detune.setValueAtTime(detune, time);
427
+
428
+ gain.gain.setValueAtTime(0.0001, time);
429
+ gain.gain.linearRampToValueAtTime(gainVal, time + attack);
430
+ gain.gain.linearRampToValueAtTime(0.0001, time + decay);
431
+
432
+ panner.pan.setValueAtTime(pan + panOffset, time);
433
+
434
+ osc.connect(gain).connect(panner).connect(dest);
435
+ osc.start(time);
436
+ osc.stop(time + dur);
437
+ }
438
+
439
+ function playNoise(ctx, time, dur, gainVal, dest) {
440
+ const bufferSize = ctx.sampleRate * dur;
441
+ const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
442
+ const data = buffer.getChannelData(0);
443
+ for (let i = 0; i < bufferSize; i++) {
444
+ data[i] = Math.random() * 2 - 1;
445
+ }
446
+
447
+ const src = ctx.createBufferSource();
448
+ src.buffer = buffer;
449
+
450
+ const gain = ctx.createGain();
451
+ gain.gain.setValueAtTime(gainVal, time);
452
+
453
+ src.connect(gain).connect(dest);
454
+ src.start(time);
455
+ }
456
+
457
+ function playMusic() {
458
+ context = new AudioContext();
459
+ source = context.createBufferSource();
460
+ source.buffer = buffer;
461
+ source.loop = true;
462
+ source.connect(context.destination);
463
+ source.start();
464
+
465
+ const bpm = parseInt(bpmSlider.value);
466
+ const beat = 60 / bpm / 2;
467
+ const steps = parseInt(lengthSelect.value);
468
+ let index = 0;
469
+ const interval = setInterval(() => {
470
+ updateRollPlayback(index);
471
+ index = (index + 1) % steps;
472
+ }, beat * 1000);
473
+
474
+ source.onended = () => clearInterval(interval);
475
+ source._interval = interval;
476
+
477
+ isPlaying = true;
478
+ togglePlay.textContent = "Stop";
479
+ }
480
+
481
+ function stopMusic() {
482
+ if (source) {
483
+ source.stop();
484
+ if (source._interval) clearInterval(source._interval);
485
+ }
486
+ if (context) context.close();
487
+ source = null;
488
+ context = null;
489
+ isPlaying = false;
490
+ if (togglePlay) togglePlay.textContent = "Play";
491
+ }
492
+
493
+ export function togglePlayPause() {
494
+ if (isPlaying) {
495
+ stopMusic();
496
+ } else if (buffer) {
497
+ playMusic();
498
+ }
499
+ }
500
+
501
+ export function downloadWav() {
502
+ if (!buffer) return;
503
+
504
+ const length = buffer.length * 2 + 44;
505
+ const arrayBuffer = new ArrayBuffer(length);
506
+ const view = new DataView(arrayBuffer);
507
+
508
+ function writeStr(offset, str) {
509
+ for (let i = 0; i < str.length; i++) {
510
+ view.setUint8(offset + i, str.charCodeAt(i));
511
+ }
512
+ }
513
+
514
+ let offset = 0;
515
+ writeStr(offset, 'RIFF'); offset += 4;
516
+ view.setUint32(offset, length - 8, true); offset += 4;
517
+ writeStr(offset, 'WAVE'); offset += 4;
518
+ writeStr(offset, 'fmt '); offset += 4;
519
+ view.setUint32(offset, 16, true); offset += 4;
520
+ view.setUint16(offset, 1, true); offset += 2;
521
+ view.setUint16(offset, 1, true); offset += 2;
522
+ view.setUint32(offset, 44100, true); offset += 4;
523
+ view.setUint32(offset, 44100 * 2, true); offset += 4;
524
+ view.setUint16(offset, 2, true); offset += 2;
525
+ view.setUint16(offset, 16, true); offset += 2;
526
+ writeStr(offset, 'data'); offset += 4;
527
+ view.setUint32(offset, buffer.length * 2, true); offset += 4;
528
+
529
+ const samples = buffer.getChannelData(0);
530
+ for (let i = 0; i < samples.length; i++) {
531
+ const s = Math.max(-1, Math.min(1, samples[i]));
532
+ view.setInt16(offset, s * 0x7FFF, true);
533
+ offset += 2;
534
+ }
535
+
536
+ const blob = new Blob([view], { type: 'audio/wav' });
537
+ const url = URL.createObjectURL(blob);
538
+ const a = document.createElement('a');
539
+ a.href = url;
540
+ a.download = 'loop.wav';
541
+ a.click();
542
+ URL.revokeObjectURL(url);
543
+ }
544
+
545
+ export function exportMIDI() {
546
+ const header = [
547
+ 0x4d, 0x54, 0x68, 0x64, // "MThd"
548
+ 0x00, 0x00, 0x00, 0x06, // header length
549
+ 0x00, 0x00, // format type
550
+ 0x00, 0x01, // one track
551
+ 0x01, 0xe0 // 480 ticks per quarter note
552
+ ];
553
+
554
+ const track = [0x4d, 0x54, 0x72, 0x6b]; // "MTrk"
555
+ const events = [];
556
+
557
+ midiData.forEach(({ t, note, dur }) => {
558
+ const midiNote = Math.floor(69 + 12 * Math.log2(note / 440));
559
+ const startTick = Math.floor(t * 480);
560
+ const endTick = Math.floor((t + dur) * 480);
561
+
562
+ events.push([startTick, 0x90, midiNote, 100]); // Note on
563
+ events.push([endTick, 0x80, midiNote, 0]); // Note off
564
+ });
565
+
566
+ events.sort((a, b) => a[0] - b[0]); // Sort by time
567
+
568
+ let lastTick = 0;
569
+ const trackData = [];
570
+
571
+ for (const [tick, cmd, note, vel] of events) {
572
+ const delta = tick - lastTick;
573
+ lastTick = tick;
574
+
575
+ let bytes = [];
576
+ let value = delta;
577
+ do {
578
+ let byte = value & 0x7F;
579
+ value >>= 7;
580
+ if (value > 0) byte |= 0x80;
581
+ bytes.unshift(byte);
582
+ } while (value > 0);
583
+
584
+ trackData.push(...bytes, cmd, note, vel);
585
+ }
586
+
587
+ trackData.push(0x00, 0xFF, 0x2F, 0x00);
588
+
589
+ const trackLength = trackData.length;
590
+ const lengthBytes = [
591
+ (trackLength >> 24) & 0xFF,
592
+ (trackLength >> 16) & 0xFF,
593
+ (trackLength >> 8) & 0xFF,
594
+ trackLength & 0xFF
595
+ ];
596
+
597
+ const midiBytes = new Uint8Array([
598
+ ...header,
599
+ ...track,
600
+ ...lengthBytes,
601
+ ...trackData
602
+ ]);
603
+
604
+ const blob = new Blob([midiBytes], { type: 'audio/midi' });
605
+ const url = URL.createObjectURL(blob);
606
+
607
+ const a = document.createElement('a');
608
+ a.href = url;
609
+ a.download = 'loop.mid';
610
+ a.click();
611
+ URL.revokeObjectURL(url);
612
+ }
613
+
614
+ export function init() {
615
+ if (initDOMReferences()) {
616
+ // Add event listeners
617
+ chaos.addEventListener('input', () => chaosValue.textContent = chaos.value);
618
+ bpmSlider.addEventListener('input', () => bpmOutput.textContent = bpmSlider.value);
619
+ }
620
+ }