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.assets/image-20250714030733295.png +0 -0
- package/README.md +52 -0
- package/client.js +8 -0
- package/index.js +9 -0
- package/package.json +30 -0
- package/src/Chiptune.vue +184 -0
- package/src/neoloop.js +620 -0
|
Binary file
|
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
|
+

|
|
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
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
|
+
}
|
package/src/Chiptune.vue
ADDED
|
@@ -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
|
+
}
|