vuepress-plugin-chiptune
Version:
A VuePress plugin for Chiptune music generation.
620 lines (545 loc) • 19.4 kB
JavaScript
let isPlaying = false;
let context, buffer, source;
let baseSeed = "", currentParams = {}, midiData = [];
let originalMotif = [], visualStep = 0;
let canvas, ctx2d;
let seedInput, scaleSelect, lengthSelect, chaos, chaosValue, bpmSlider, bpmOutput, togglePlay;
let useLead, useLead2, useBass, useDrums, useFx, useReverb, useDelay, useChorus;
function initDOMReferences() {
canvas = document.getElementById("pianoRoll");
if (!canvas) {
console.error("Piano roll canvas not found!");
return false;
}
ctx2d = canvas.getContext("2d");
seedInput = document.getElementById("seedInput");
scaleSelect = document.getElementById("scaleSelect");
lengthSelect = document.getElementById("lengthSelect");
chaos = document.getElementById("chaos");
chaosValue = document.getElementById("chaosValue");
bpmSlider = document.getElementById("bpmSlider");
bpmOutput = document.getElementById("bpmOutput");
togglePlay = document.getElementById("togglePlay");
useLead = document.getElementById("useLead");
useLead2 = document.getElementById("useLead2");
useBass = document.getElementById("useBass");
useDrums = document.getElementById("useDrums");
useFx = document.getElementById("useFx");
useReverb = document.getElementById("useReverb");
useDelay = document.getElementById("useDelay");
useChorus = document.getElementById("useChorus");
return true;
}
function fullSeed() {
return (
(seedInput.value || "random") +
"_" + scaleSelect.value +
"_" + lengthSelect.value
);
}
function clearRoll() {
ctx2d.fillStyle = "#111";
ctx2d.fillRect(0, 0, canvas.width, canvas.height);
}
function drawRoll(motif) {
clearRoll();
const steps = parseInt(lengthSelect.value);
motif.forEach((step, i) => {
if (step.note) {
const x = (i / steps) * canvas.width;
const y = canvas.height - (Math.log2(step.note) - 5) * 30;
ctx2d.fillStyle = "#00ffcc";
ctx2d.fillRect(x, y, step.dur * 20, 6);
}
});
}
function updateRollPlayback(stepIndex) {
visualStep = stepIndex;
drawRoll(originalMotif);
const steps = parseInt(lengthSelect.value);
const x = (stepIndex / steps) * canvas.width;
ctx2d.fillStyle = "#ff3366";
ctx2d.fillRect(x, 0, 2, canvas.height);
}
function hashSeed(seed) {
let hash = 0;
for (let i = 0; i < seed.length; i++)
hash = seed.charCodeAt(i) + ((hash << 5) - hash);
return hash >>> 0;
}
function pseudoRandom(seed) {
let value = hashSeed(seed);
return () => {
value ^= value << 13;
value ^= value >> 17;
value ^= value << 5;
return (value >>> 0) / 4294967296;
};
}
function generateMotif(scale, rand, complexity, steps = 32) {
const motif = [];
let note = scale[Math.floor(rand() * scale.length)];
while (motif.length < steps) {
const shift = Math.floor(rand() * 5) - 2;
const newIndex = Math.max(0, Math.min(scale.length - 1, scale.indexOf(note) + shift));
note = scale[newIndex];
const dur = rand() < 0.3 ? 2 : 1;
motif.push({ note, dur });
if (rand() < 0.05 * complexity) motif.push({ note: null, dur: 1 }); // dropout
}
return motif;
}
export function triggerRandom() {
const randomSeed = Math.floor(Math.random() * 1e9).toString(36);
seedInput.value = randomSeed;
const scaleOptions = scaleSelect.options;
const styleOptions = document.getElementById("styleSelect").options;
scaleOptions.selectedIndex = Math.floor(Math.random() * scaleOptions.length);
styleOptions.selectedIndex = Math.floor(Math.random() * styleOptions.length);
chaos.value = Math.floor(Math.random() * 101);
chaosValue.textContent = chaos.value;
bpmSlider.value = 80 + Math.floor(Math.random() * 100);
bpmOutput.textContent = bpmSlider.value;
generateLoop();
}
function getScale(root = 261.63, mode = "major") {
const modes = {
major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10],
dorian: [0, 2, 3, 5, 7, 9, 10],
phrygian: [0, 1, 3, 5, 7, 8, 10],
mixolydian: [0, 2, 4, 5, 7, 9, 10],
pentatonic: [0, 2, 4, 7, 9]
};
return (modes[mode] || modes.major).map(i => root * Math.pow(2, i / 12));
}
function getStyleParams(style, rand, modulated = false) {
const chaosVal = parseInt(chaos.value) / 100;
function commonParams(base) {
if (!modulated) {
return {
...base,
detune: 0,
attack: 0.01,
panOffset: 0
};
}
return {
...base,
detune: (rand() - 0.5) * 30,
attack: 0.01 + rand() * 0.04,
panOffset: (rand() - 0.5) * 0.6
};
}
switch (style) {
case "pulsewave":
return {
lead: commonParams({ type: "square", volume: 0.06, decay: 0.3 }),
lead2: commonParams({ type: "square", volume: 0.04 }),
bass: commonParams({ type: "square", volume: 0.05, decay: 0.4 }),
filterHz: 2500
};
case "dreamchip":
return {
lead: commonParams({ type: "triangle", volume: 0.05, decay: 0.4 }),
lead2: commonParams({ type: "sine", volume: 0.03 }),
bass: commonParams({ type: "triangle", volume: 0.03, decay: 0.5 }),
filterHz: 2000
};
case "darkgrid":
return {
lead: commonParams({ type: "sawtooth", volume: 0.07, decay: 0.25 }),
lead2: commonParams({ type: "square", volume: 0.035 }),
bass: commonParams({ type: "triangle", volume: 0.04, decay: 0.3 }),
filterHz: 1500
};
case "arcadegen":
return {
lead: commonParams({ type: "square", volume: 0.065, decay: 0.25 }),
lead2: commonParams({ type: "triangle", volume: 0.035 }),
bass: commonParams({ type: "sawtooth", volume: 0.05, decay: 0.45 }),
filterHz: 2800
};
case "crystal":
return {
lead: commonParams({ type: "sine", volume: 0.045, decay: 0.45 }),
lead2: commonParams({ type: "sine", volume: 0.03 }),
bass: commonParams({ type: "triangle", volume: 0.035, decay: 0.6 }),
filterHz: 2200
};
default:
return {
lead: commonParams({ type: "square", volume: 0.06, decay: 0.3 }),
lead2: commonParams({ type: "square", volume: 0.03 }),
bass: commonParams({ type: "triangle", volume: 0.04, decay: 0.4 }),
filterHz: 2400
};
}
}
function getChord(root, mode = "major") {
const intervals = (mode === "minor") ? [0, 3, 7] : [0, 4, 7];
return intervals.map(i => root * Math.pow(2, i / 12));
}
export async function generateLoop() {
stopMusic();
const full = fullSeed();
baseSeed = full;
const chaosVal = parseInt(chaos.value);
const randParams = pseudoRandom(full + "_params_" + chaosVal);
const randMelody = pseudoRandom(full + "_melody");
const style = document.getElementById("styleSelect").value;
const scale = scaleSelect.value;
const steps = parseInt(lengthSelect.value);
const scaleObj = getScale(261.63, scale);
currentParams = getStyleParams(style, randParams, false);
originalMotif = generateMotif(scaleObj, randMelody, 1.25, steps);
drawRoll(originalMotif);
buffer = await renderAudio(full, scale, style, currentParams, originalMotif, steps);
playMusic();
}
export function modifySounds() {
const style = document.getElementById("styleSelect").value;
const scale = scaleSelect.value;
const steps = parseInt(lengthSelect.value);
const chaosVal = parseInt(chaos.value);
const modSeed = baseSeed + "_mod_" + Date.now();
const rand = pseudoRandom(modSeed + "_params_" + chaosVal);
currentParams = getStyleParams(style, rand, true);
renderAudio(modSeed, scale, style, currentParams, originalMotif, steps).then(b => {
buffer = b;
stopMusic();
playMusic();
});
}
export function remixLoop() {
const base = fullSeed() + "_remix_" + Date.now();
const randMelody = pseudoRandom(base + "_melody");
const scale = scaleSelect.value;
const style = document.getElementById("styleSelect").value;
const steps = parseInt(lengthSelect.value);
const scaleObj = getScale(261.63, scale);
originalMotif = generateMotif(scaleObj, randMelody, 1.25, steps);
drawRoll(originalMotif);
const randParams = pseudoRandom(base + "_params");
currentParams = getStyleParams(style, randParams);
renderAudio(base, scale, style, currentParams, originalMotif, steps).then(b => {
buffer = b;
stopMusic();
playMusic();
});
}
function fxTypeForScale(scaleMode) {
const mapping = {
major: [0, 1, 3, 5, 6],
minor: [2, 3, 4, 5, 6],
dorian: [0, 1, 3, 6],
phrygian: [2, 4, 5],
mixolydian: [0, 1, 3, 6],
pentatonic: [1, 3, 5, 6]
};
return mapping[scaleMode] || [0, 1, 2, 3, 4, 5, 6];
}
function injectFX(ctx, t, rand, scale, scaleMode, master) {
const fxList = fxTypeForScale(scaleMode);
const fxType = fxList[Math.floor(rand() * fxList.length)];
switch (fxType) {
case 0: // Laser ping
for (let j = 0; j < 4; j++) {
playOsc(ctx, t + j * 0.05, 1200 - j * 100 + rand() * 30, 0.08, "square", 0.04, 0.3 - j * 0.2, master, {
attack: 0.01, detune: rand() * 20, panOffset: rand() - 0.5
});
}
break;
case 1: // Glissando Rise
for (let j = 0; j < 5; j++) {
playOsc(ctx, t + j * 0.06, 300 + j * 100, 0.1, "triangle", 0.03, -0.4 + j * 0.2, master, {
attack: 0.01, detune: 0, panOffset: 0
});
}
break;
case 2: // Bass Drop chord
const dropChord = getChord(scale[0], scaleMode);
for (let j = 0; j < dropChord.length; j++) {
playOsc(ctx, t + j * 0.1, dropChord[j] / 2, 0.5, "sawtooth", 0.05, -0.3 + j * 0.3, master, {
attack: 0.02, detune: 0, panOffset: 0
});
}
break;
case 3: // Arpeggio
for (let j = 0; j < 6; j++) {
const n = scale[j % scale.length];
playOsc(ctx, t + j * 0.06, n, 0.08, "triangle", 0.04, -0.2 + j * 0.1, master, {
attack: 0.01, detune: 0, panOffset: 0
});
}
break;
case 4: // Distorted noise burst
playNoise(ctx, t, 0.2, 0.1, master);
break;
case 5: // Echo bell
for (let j = 0; j < 4; j++) {
playOsc(ctx, t + j * 0.15, 800 + j * 60, 0.12, "sine", 0.03, j % 2 === 0 ? -0.5 : 0.5, master, {
attack: 0.01, detune: 0, panOffset: 0
});
}
break;
case 6: // Chord pulse
const chordPulse = getChord(scale[3 % scale.length], scaleMode);
chordPulse.forEach((freq, idx) => {
playOsc(ctx, t + idx * 0.04, freq, 0.1, "square", 0.04, -0.3 + idx * 0.3, master, {
attack: 0.01, detune: 0, panOffset: 0
});
});
break;
}
}
async function renderAudio(seed, scaleMode, style, params, motif, steps) {
const rand = pseudoRandom(seed);
const bpm = parseInt(bpmSlider.value);
const beat = 60 / bpm / 2;
const ctx = new OfflineAudioContext(1, 44100 * steps * beat, 44100);
const useLeadChecked = useLead.checked;
const useLead2Checked = useLead2.checked;
const useBassChecked = useBass.checked;
const useDrumsChecked = useDrums.checked;
const useFxChecked = useFx.checked;
const useReverbChecked = useReverb.checked;
const useDelayChecked = useDelay.checked;
const useChorusChecked = useChorus.checked;
let output = ctx.createGain();
const master = ctx.createGain();
if (useReverbChecked) {
const convolver = ctx.createConvolver();
const impulse = ctx.createBuffer(1, 1.0 * ctx.sampleRate, ctx.sampleRate);
const data = impulse.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
}
convolver.buffer = impulse;
master.connect(convolver).connect(output);
}
if (useDelayChecked) {
const delay = ctx.createDelay(1.0);
delay.delayTime.value = 0.25 + (bpm - 60) * (0.25 / 60);
const feedback = ctx.createGain();
feedback.gain.value = 0.2;
delay.connect(feedback).connect(delay);
master.connect(delay).connect(output);
}
if (useChorusChecked) {
const chorus = ctx.createDelay();
chorus.delayTime.value = 0.025;
master.connect(chorus).connect(output);
}
master.connect(output);
output.connect(ctx.destination);
const scale = getScale(261.63, scaleMode);
const chords = [scale[0], scale[3], scale[4], scale[0]].map(root => getChord(root, scaleMode));
midiData = [];
let t = 0;
let motifIndex = 0;
for (let i = 0; i < steps;) {
const chord = chords[Math.floor(i / 8) % chords.length];
const step = motif[motifIndex % motif.length];
updateRollPlayback(i);
const dur = step.dur * beat;
if (step.note) {
if (useLeadChecked) {
playOsc(ctx, t, step.note, dur, params.lead.type, params.lead.volume, -0.3, master, params.lead);
}
if (useLead2Checked) {
playOsc(ctx, t + 0.04, step.note, dur * 0.9, params.lead2.type, params.lead2.volume, 0.3, master, params.lead2);
}
midiData.push({ t, note: step.note, dur: step.dur, type: "lead" });
}
if (i % 4 === 0 && useBassChecked) {
const bass = chord[0] / 2;
playOsc(ctx, t, bass, params.bass.decay, params.bass.type, params.bass.volume, 0, master, params.bass);
midiData.push({ t, note: bass, dur: 1, type: "bass" });
}
if (useDrumsChecked) {
const drumMultiplier = style === "crystal" || "dreamchip" ? 0.4 : 1.0;
if (i % 8 === 0) playNoise(ctx, t, 0.05, 0.1 * drumMultiplier, master);
if (i % 8 === 4) playNoise(ctx, t, 0.04, 0.08 * drumMultiplier, master);
if (i % 2 === 0) playNoise(ctx, t, 0.03, 0.04 * drumMultiplier, master);
}
if (useFxChecked && i % 8 === 0 && rand() < 0.5) {
injectFX(ctx, t, rand, scale, scaleMode, master);
}
t += step.dur * beat;
i += step.dur;
motifIndex++;
}
return ctx.startRendering();
}
function playOsc(ctx, time, freq, dur, type, gainVal, pan, dest, options = {}) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const panner = ctx.createStereoPanner();
const attack = options.attack || 0.01;
const decay = dur;
const detune = options.detune || 0;
const panOffset = options.panOffset || 0;
osc.type = type;
osc.frequency.setValueAtTime(freq, time);
osc.detune.setValueAtTime(detune, time);
gain.gain.setValueAtTime(0.0001, time);
gain.gain.linearRampToValueAtTime(gainVal, time + attack);
gain.gain.linearRampToValueAtTime(0.0001, time + decay);
panner.pan.setValueAtTime(pan + panOffset, time);
osc.connect(gain).connect(panner).connect(dest);
osc.start(time);
osc.stop(time + dur);
}
function playNoise(ctx, time, dur, gainVal, dest) {
const bufferSize = ctx.sampleRate * dur;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const src = ctx.createBufferSource();
src.buffer = buffer;
const gain = ctx.createGain();
gain.gain.setValueAtTime(gainVal, time);
src.connect(gain).connect(dest);
src.start(time);
}
function playMusic() {
context = new AudioContext();
source = context.createBufferSource();
source.buffer = buffer;
source.loop = true;
source.connect(context.destination);
source.start();
const bpm = parseInt(bpmSlider.value);
const beat = 60 / bpm / 2;
const steps = parseInt(lengthSelect.value);
let index = 0;
const interval = setInterval(() => {
updateRollPlayback(index);
index = (index + 1) % steps;
}, beat * 1000);
source.onended = () => clearInterval(interval);
source._interval = interval;
isPlaying = true;
togglePlay.textContent = "Stop";
}
function stopMusic() {
if (source) {
source.stop();
if (source._interval) clearInterval(source._interval);
}
if (context) context.close();
source = null;
context = null;
isPlaying = false;
if (togglePlay) togglePlay.textContent = "Play";
}
export function togglePlayPause() {
if (isPlaying) {
stopMusic();
} else if (buffer) {
playMusic();
}
}
export function downloadWav() {
if (!buffer) return;
const length = buffer.length * 2 + 44;
const arrayBuffer = new ArrayBuffer(length);
const view = new DataView(arrayBuffer);
function writeStr(offset, str) {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
}
let offset = 0;
writeStr(offset, 'RIFF'); offset += 4;
view.setUint32(offset, length - 8, true); offset += 4;
writeStr(offset, 'WAVE'); offset += 4;
writeStr(offset, 'fmt '); offset += 4;
view.setUint32(offset, 16, true); offset += 4;
view.setUint16(offset, 1, true); offset += 2;
view.setUint16(offset, 1, true); offset += 2;
view.setUint32(offset, 44100, true); offset += 4;
view.setUint32(offset, 44100 * 2, true); offset += 4;
view.setUint16(offset, 2, true); offset += 2;
view.setUint16(offset, 16, true); offset += 2;
writeStr(offset, 'data'); offset += 4;
view.setUint32(offset, buffer.length * 2, true); offset += 4;
const samples = buffer.getChannelData(0);
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(offset, s * 0x7FFF, true);
offset += 2;
}
const blob = new Blob([view], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'loop.wav';
a.click();
URL.revokeObjectURL(url);
}
export function exportMIDI() {
const header = [
0x4d, 0x54, 0x68, 0x64, // "MThd"
0x00, 0x00, 0x00, 0x06, // header length
0x00, 0x00, // format type
0x00, 0x01, // one track
0x01, 0xe0 // 480 ticks per quarter note
];
const track = [0x4d, 0x54, 0x72, 0x6b]; // "MTrk"
const events = [];
midiData.forEach(({ t, note, dur }) => {
const midiNote = Math.floor(69 + 12 * Math.log2(note / 440));
const startTick = Math.floor(t * 480);
const endTick = Math.floor((t + dur) * 480);
events.push([startTick, 0x90, midiNote, 100]); // Note on
events.push([endTick, 0x80, midiNote, 0]); // Note off
});
events.sort((a, b) => a[0] - b[0]); // Sort by time
let lastTick = 0;
const trackData = [];
for (const [tick, cmd, note, vel] of events) {
const delta = tick - lastTick;
lastTick = tick;
let bytes = [];
let value = delta;
do {
let byte = value & 0x7F;
value >>= 7;
if (value > 0) byte |= 0x80;
bytes.unshift(byte);
} while (value > 0);
trackData.push(...bytes, cmd, note, vel);
}
trackData.push(0x00, 0xFF, 0x2F, 0x00);
const trackLength = trackData.length;
const lengthBytes = [
(trackLength >> 24) & 0xFF,
(trackLength >> 16) & 0xFF,
(trackLength >> 8) & 0xFF,
trackLength & 0xFF
];
const midiBytes = new Uint8Array([
...header,
...track,
...lengthBytes,
...trackData
]);
const blob = new Blob([midiBytes], { type: 'audio/midi' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'loop.mid';
a.click();
URL.revokeObjectURL(url);
}
export function init() {
if (initDOMReferences()) {
// Add event listeners
chaos.addEventListener('input', () => chaosValue.textContent = chaos.value);
bpmSlider.addEventListener('input', () => bpmOutput.textContent = bpmSlider.value);
}
}