You can edit the below JavaScript code to customize the image tool.
Apply Changes
function processImage(originalImg, gridColumns = "8", gridRows = "8", musicalScale = "pentatonic") {
const cols = parseInt(gridColumns, 10) || 8;
const rws = parseInt(gridRows, 10) || 8;
const scaleStr = (musicalScale || 'pentatonic').toLowerCase();
// Setup wrapper div
const wrapper = document.createElement('div');
wrapper.style.fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
wrapper.style.textAlign = 'center';
wrapper.style.backgroundColor = '#1a1a1a';
wrapper.style.color = '#fff';
wrapper.style.padding = '20px';
wrapper.style.borderRadius = '12px';
wrapper.style.boxShadow = '0 8px 32px rgba(0,0,0,0.4)';
wrapper.style.display = 'inline-block';
wrapper.style.maxWidth = '100%';
// Title & Instructions
const title = document.createElement('h2');
title.textContent = 'Image Virtual Musical Instrument';
title.style.margin = '0 0 8px 0';
title.style.fontSize = '24px';
wrapper.appendChild(title);
const instructions = document.createElement('p');
instructions.textContent = 'Touch, click, or drag across the grid to play. Pitch = Hue, Octave = Brightness, Timbre = Saturation';
instructions.style.fontSize = '13px';
instructions.style.color = '#aaa';
instructions.style.margin = '0 0 20px 0';
wrapper.appendChild(instructions);
// Canvas setup
const canvasWrap = document.createElement('div');
canvasWrap.style.position = 'relative';
canvasWrap.style.display = 'inline-block';
canvasWrap.style.touchAction = 'none'; // Prevent scrolling on mobile during interaction
canvasWrap.style.borderRadius = '8px';
canvasWrap.style.overflow = 'hidden';
canvasWrap.style.boxShadow = '0 4px 15px rgba(0,0,0,0.5)';
const canvas = document.createElement('canvas');
canvas.style.display = 'block';
const ctx = canvas.getContext('2d');
// Scale image reasonably
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
let width = originalImg.naturalWidth || originalImg.width;
let height = originalImg.naturalHeight || originalImg.height;
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height);
width = Math.floor(width * ratio);
height = Math.floor(height * ratio);
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(originalImg, 0, 0, width, height);
// Read pixel data
let imgData;
try {
imgData = ctx.getImageData(0, 0, width, height).data;
} catch (e) {
// Fallback if CORS prevents pixel reading
console.warn("CORS issue: generating random pixel data.");
imgData = new Uint8ClampedArray(width * height * 4);
for(let i = 0; i < imgData.length; i += 4) {
imgData[i] = Math.random() * 255;
imgData[i+1] = Math.random() * 255;
imgData[i+2] = Math.random() * 255;
imgData[i+3] = 255;
}
}
canvasWrap.appendChild(canvas);
wrapper.appendChild(canvasWrap);
// Music & Scale Math
const baseOctaveC = 130.81; // C3
const scaleRatios = {
pentatonic: [1, 9/8, 5/4, 3/2, 5/3], // C, D, E, G, A
major: [1, 9/8, 5/4, 4/3, 3/2, 5/3, 15/8], // C, D, E, F, G, A, B
minor: [1, 9/8, 6/5, 4/3, 3/2, 8/5, 9/5], // C, D, Eb, F, G, Ab, Bb
chromatic: [1, 1.059, 1.122, 1.189, 1.260, 1.335, 1.414, 1.498, 1.587, 1.682, 1.782, 1.888]
};
const ratios = scaleRatios[scaleStr] || scaleRatios.pentatonic;
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
let max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if(max !== min) {
let d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch(max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h, s, l];
}
// Grid interpretation
const pads = [];
const padW = width / cols;
const padH = height / rws;
for (let r = 0; r < rws; r++) {
for (let c = 0; c < cols; c++) {
const px1 = Math.floor(c * padW);
const py1 = Math.floor(r * padH);
let rSum = 0, gSum = 0, bSum = 0, count = 0;
// Sub-sample pad area for efficiency
const stepX = Math.max(1, Math.floor(padW / 8));
const stepY = Math.max(1, Math.floor(padH / 8));
for(let y = 0; y < padH && (py1 + y) < height; y += stepY) {
for(let x = 0; x < padW && (px1 + x) < width; x += stepX) {
const idx = ((py1 + y) * width + (px1 + x)) * 4;
rSum += imgData[idx];
gSum += imgData[idx+1];
bSum += imgData[idx+2];
count++;
}
}
const avgR = rSum / (count || 1);
const avgG = gSum / (count || 1);
const avgB = bSum / (count || 1);
const [hue, sat, lum] = rgbToHsl(avgR, avgG, avgB);
// Mapping hue to note
const hueIdx = Math.min(Math.floor(hue * ratios.length), ratios.length - 1);
const noteRatio = ratios[hueIdx];
// Mapping lightness to octave
let octave = 3;
if (lum < 0.2) octave = 1; // very dark = sub
else if (lum < 0.4) octave = 2; // dark = low
else if (lum < 0.6) octave = 3; // mid = normal
else if (lum < 0.8) octave = 4; // light = high
else octave = 5; // very bright = top
// Frequency
const baseFreq = baseOctaveC * noteRatio * Math.pow(2, octave - 3);
// Mapping saturation to waveform
let waveType = 'sine';
if (sat > 0.7) waveType = 'sawtooth';
else if (sat > 0.4) waveType = 'square';
else if (sat > 0.2) waveType = 'triangle';
pads.push({
x: px1, y: py1, w: Math.ceil(padW), h: Math.ceil(padH),
freq: baseFreq,
wave: waveType,
active: false,
osc: null,
gain: null
});
}
}
// Draw initial grid
function redrawAllPads() {
ctx.drawImage(originalImg, 0, 0, width, height);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
ctx.lineWidth = 1;
ctx.beginPath();
for(let r = 1; r < rws; r++) {
ctx.moveTo(0, r * padH);
ctx.lineTo(width, r * padH);
}
for(let c = 1; c < cols; c++) {
ctx.moveTo(c * padW, 0);
ctx.lineTo(c * padW, height);
}
ctx.stroke();
for (let p of pads) {
if (p.active) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
ctx.fillRect(p.x, p.y, p.w, p.h);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.strokeRect(p.x + 1, p.y + 1, p.w - 2, p.h - 2);
}
}
}
redrawAllPads();
// Audio Setup
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
const PADDING_GAIN = 0.3; // Overall volume reduction to prevent clipping
function playPad(pad) {
pad.active = true;
initAudio();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = pad.wave;
osc.frequency.value = pad.freq;
osc.connect(gain);
gain.connect(audioCtx.destination);
const now = audioCtx.currentTime;
gain.gain.setValueAtTime(0, now);
// ADSR Envelope
gain.gain.linearRampToValueAtTime(1.0 * PADDING_GAIN, now + 0.05); // Attack
gain.gain.exponentialRampToValueAtTime(0.4 * PADDING_GAIN, now + 0.3); // Decay & Sustain
osc.start(now);
pad.osc = osc;
pad.gain = gain;
redrawAllPads();
}
function stopPad(pad) {
pad.active = false;
if (pad.osc && pad.gain) {
const now = audioCtx.currentTime;
pad.gain.gain.cancelScheduledValues(now);
pad.gain.gain.setValueAtTime(pad.gain.gain.value, now);
// Release envelope
pad.gain.gain.setTargetAtTime(0, now, 0.05);
pad.osc.stop(now + 0.3);
pad.osc = null;
pad.gain = null;
}
redrawAllPads();
}
// Interaction Map (Multi-touch support)
const activePointers = new Map(); // pointerId -> pad
function getPadAtPos(e) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if(x < 0 || x >= rect.width || y < 0 || y >= rect.height) return null;
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const canvasX = x * scaleX;
const canvasY = y * scaleY;
return pads.find(p => canvasX >= p.x && canvasX < p.x + p.w && canvasY >= p.y && canvasY < p.y + p.h);
}
canvas.addEventListener('pointerdown', (e) => {
initAudio();
canvas.setPointerCapture(e.pointerId);
const p = getPadAtPos(e);
if (p) {
activePointers.set(e.pointerId, p);
if (!p.active) playPad(p);
}
});
canvas.addEventListener('pointermove', (e) => {
if (activePointers.has(e.pointerId)) {
const currentP = activePointers.get(e.pointerId);
const newP = getPadAtPos(e);
if (newP !== currentP) {
// Check if old pad is held by other pointer
let anotherHoldsOld = false;
for (const [id, pad] of activePointers.entries()) {
if (id !== e.pointerId && pad === currentP) {
anotherHoldsOld = true;
break;
}
}
if (!anotherHoldsOld && currentP) {
stopPad(currentP);
}
if (newP) {
activePointers.set(e.pointerId, newP);
if (!newP.active) playPad(newP);
} else {
activePointers.delete(e.pointerId);
}
}
}
});
function handlePointerEnd(e) {
const currentP = activePointers.get(e.pointerId);
if (currentP) {
activePointers.delete(e.pointerId);
let anotherHoldsOld = false;
for (const [id, pad] of activePointers.entries()) {
if (pad === currentP) anotherHoldsOld = true;
}
if (!anotherHoldsOld) {
stopPad(currentP);
}
}
}
canvas.addEventListener('pointerup', handlePointerEnd);
canvas.addEventListener('pointercancel', handlePointerEnd);
return wrapper;
}
Apply Changes