index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Harmonic Minimal | Chord Discoverer</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&family=JetBrains+Mono&display=swap');
body {
background-color: #050505;
color: #ededed;
font-family: 'Inter', sans-serif;
overflow: hidden;
}
.glass {
background: rgba(255, 255, 255, 0.02);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
/* Radial Background Grid */
#grid-overlay {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.15;
background-image:
radial-gradient(circle at center, transparent 0, transparent 1px, rgba(255,255,255,0.1) 1px),
radial-gradient(circle at center, transparent 0, transparent 100px, rgba(255,255,255,0.05) 101px, transparent 102px),
radial-gradient(circle at center, transparent 0, transparent 200px, rgba(255,255,255,0.05) 201px, transparent 202px),
radial-gradient(circle at center, transparent 0, transparent 300px, rgba(255,255,255,0.05) 301px, transparent 302px),
radial-gradient(circle at center, transparent 0, transparent 400px, rgba(255,255,255,0.05) 401px, transparent 402px);
}
.spoke {
position: absolute;
top: 50%;
left: 50%;
width: 800px;
height: 1px;
background: linear-gradient(90deg, rgba(255,255,255,0.06) 0%, transparent 70%);
transform-origin: left center;
}
.piano-key {
height: 60px;
width: 24px;
border: 1px solid #222;
cursor: pointer;
position: relative;
transition: all 0.1s ease;
}
.piano-key.white { background: #111; z-index: 1; }
.piano-key.black {
background: #000;
height: 38px;
width: 16px;
margin-left: -8px;
margin-right: -8px;
z-index: 2;
}
.piano-key.selected {
background: #ffffff !important;
box-shadow: 0 0 15px rgba(255, 255, 255, 0.3);
border-color: #fff;
}
.piano-key.black.selected { background: #e2e2e2 !important; }
.piano-key.playing { background: #888 !important; }
#harmonic-map {
width: 100%;
height: 650px;
position: relative;
user-select: none;
overflow: hidden;
}
.chord-node {
position: absolute;
transform: translate(-50%, -50%);
transition: all 0.7s cubic-bezier(0.22, 1, 0.36, 1);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(10,10,10,0.92);
box-shadow: 0 10px 30px rgba(0,0,0,0.7);
}
.chord-node:hover {
border-color: rgba(255,255,255,0.4);
background: rgba(45,45,45,0.95);
transform: translate(-50%, -50%) scale(1.05);
z-index: 50;
}
.chord-node.center-node {
background: #fff;
color: #000;
border-color: #fff;
box-shadow: 0 0 50px rgba(255,255,255,0.1);
z-index: 100;
}
.chord-node.center-node span { color: #000 !important; }
select {
background: #111;
border: 1px solid #333;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 10px;
}
input[type=range] {
accent-color: #ffffff;
background: rgba(255,255,255,0.1);
height: 2px;
appearance: none;
}
.led { width: 5px; height: 5px; border-radius: 50%; background: #222; }
.led.active { background: #fff; box-shadow: 0 0 8px #fff; }
.note-tag {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
padding: 4px 8px;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-7xl w-full space-y-4">
<!-- Header -->
<div class="flex justify-between items-center border-b border-white/5 pb-3">
<div class="flex items-baseline gap-3">
<h1 class="text-sm font-light tracking-[0.3em]">HARMONIC <span class="font-bold">MINIMAL</span></h1>
<div id="audio-status" class="flex items-center gap-2 text-[8px] text-white/20 tracking-widest uppercase">
<div class="led" id="status-led"></div>
<span id="status-text">Standby</span>
</div>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<span class="text-[8px] text-white/20 uppercase font-bold">Volume</span>
<input type="range" id="master-vol" min="-40" max="0" value="-12" class="w-20">
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Map (Radial Grid) -->
<div class="lg:col-span-8 glass rounded-3xl overflow-hidden relative">
<div id="harmonic-map">
<div id="grid-overlay"></div>
<div id="spoke-container"></div>
<div id="map-inner" class="absolute inset-0"></div>
<div class="absolute top-6 left-6 flex flex-col pointer-events-none">
<span class="text-[10px] text-white/40 uppercase tracking-[0.2em] font-bold">Harmonic Neighborhood</span>
<span class="text-[7px] text-white/10 uppercase mt-1">Right-click a node to set as core selection</span>
</div>
</div>
</div>
<!-- Controls & Piano -->
<div class="lg:col-span-4 space-y-4">
<div class="glass p-6 rounded-3xl space-y-6">
<div class="flex justify-between items-start">
<div class="space-y-1">
<label class="text-[8px] text-white/40 uppercase font-bold tracking-widest">Active Analysis</label>
<div class="flex items-center gap-3">
<h2 id="active-chord-name" class="text-4xl font-light tracking-tighter text-white uppercase">SELECT</h2>
<button id="force-detect" title="Force Re-scan" class="p-1.5 hover:bg-white/10 rounded border border-white/5 opacity-40 hover:opacity-100 transition-all">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/><path d="M9 12l2 2 4-4"/></svg>
</button>
</div>
<div id="intervals-display" class="text-[10px] font-mono text-white/30 tracking-widest">---</div>
</div>
<button id="clear-notes" class="text-[9px] text-white/40 hover:text-white underline underline-offset-4 uppercase">Clear</button>
</div>
<div id="piano-container" class="flex justify-center select-none py-6 border-y border-white/5">
<!-- Keys -->
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<label class="text-[8px] text-white/20 uppercase tracking-tighter">Instrument</label>
<select id="sound-select" class="w-full">
<option value="silk_air">Silk Air</option>
<option value="silk_deep">Silk Deep</option>
<option value="silk_shimmer">Silk Shimmer</option>
<option value="warm_grand">Warm Grand</option>
</select>
</div>
<div class="space-y-1">
<label class="text-[8px] text-white/20 uppercase tracking-tighter">Register</label>
<div class="flex items-center justify-between bg-black/40 rounded px-2 py-1.5 border border-white/5">
<button id="octave-down" class="px-2 opacity-50 hover:opacity-100 font-bold">-</button>
<span id="octave-val" class="text-xs font-mono">3</span>
<button id="octave-up" class="px-2 opacity-50 hover:opacity-100 font-bold">+</button>
</div>
</div>
</div>
</div>
<div class="glass p-6 rounded-3xl min-h-[200px]">
<label class="text-[8px] text-white/40 uppercase font-bold tracking-widest mb-4 block">Active Tones</label>
<div id="tones-list" class="space-y-2">
<!-- Tones will be populated here -->
<div class="text-[10px] text-white/10 uppercase tracking-widest text-center mt-8">No tones active</div>
</div>
</div>
</div>
</div>
</div>
<script>
const NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const FIFTHS = ["C", "G", "D", "A", "E", "B", "F#", "C#", "G#", "D#", "A#", "F"];
const KEY_MAP = { 'a': 'C', 'w': 'C#', 's': 'D', 'e': 'D#', 'd': 'E', 'f': 'F', 't': 'F#', 'g': 'G', 'y': 'G#', 'h': 'A', 'u': 'A#', 'j': 'B' };
const CHORD_TEMPLATES = [
{ intervals: [0, 4, 7], suffix: "" },
{ intervals: [0, 3, 7], suffix: "m" },
{ intervals: [0, 3, 6], suffix: "dim" },
{ intervals: [0, 4, 7, 11], suffix: "maj7" },
{ intervals: [0, 4, 7, 10], suffix: "7" },
{ intervals: [0, 3, 7, 10], suffix: "m7" },
{ intervals: [0, 2, 7], suffix: "sus2" },
{ intervals: [0, 5, 7], suffix: "sus4" },
{ intervals: [0, 4, 8], suffix: "aug" },
{ intervals: [0, 4, 7, 11, 14], suffix: "maj9" },
{ intervals: [0, 3, 7, 10, 14], suffix: "m9" },
{ intervals: [0, 3, 6, 10], suffix: "m7b5" }
];
let currentOctave = 3;
let isStarted = false;
let selectedNotes = new Set();
let currentSynth;
const limiter = new Tone.Limiter(-1).toDestination();
const mainVol = new Tone.Volume(-12).connect(limiter);
const reverb = new Tone.Reverb({ decay: 5, wet: 0.35 }).connect(mainVol);
const globalFilter = new Tone.Filter(2200, "lowpass").connect(reverb);
const synths = {
silk_air: () => new Tone.PolySynth(Tone.Synth, { oscillator: { type: "fatsawtooth", count: 3, spread: 20 }, envelope: { attack: 0.15, release: 2 }, volume: -8 }).connect(globalFilter),
silk_deep: () => new Tone.PolySynth(Tone.Synth, { oscillator: { type: "fatsawtooth", count: 6, spread: 40 }, envelope: { attack: 0.4, release: 3 }, volume: -10 }).connect(new Tone.Filter(600, "lowpass").connect(reverb)),
silk_shimmer: () => new Tone.PolySynth(Tone.Synth, { oscillator: { type: "sine", count: 3, spread: 15 }, envelope: { attack: 0.1, release: 1.5 }, volume: -6 }).connect(new Tone.Filter(3500, "highpass").connect(reverb)),
warm_grand: () => new Tone.PolySynth(Tone.Synth, { oscillator: { type: "triangle" }, envelope: { attack: 0.01, release: 0.8 }, volume: -6 }).connect(new Tone.Filter(1400, "lowpass").connect(reverb))
};
function initAudio() {
if (!isStarted) {
Tone.start();
isStarted = true;
document.getElementById('status-led').classList.add('active');
document.getElementById('status-text').innerText = "Engine Live";
}
}
function setSound(type) { if (currentSynth) currentSynth.releaseAll(); currentSynth = synths[type](); }
function createGridSpokes() {
const container = document.getElementById('spoke-container');
container.innerHTML = '';
for(let i=0; i<12; i++) {
const spoke = document.createElement('div');
spoke.className = 'spoke';
spoke.style.transform = `rotate(${i * 30 - 90}deg)`;
container.appendChild(spoke);
}
}
function buildPiano() {
const container = document.getElementById('piano-container');
container.innerHTML = '';
NOTES.forEach(noteName => {
const isBlack = noteName.includes('#');
const key = document.createElement('div');
key.className = `piano-key ${isBlack ? 'black' : 'white'}`;
key.dataset.note = noteName;
key.onmousedown = (e) => { e.preventDefault(); initAudio(); toggleNoteSelection(noteName); playNote(noteName); };
key.onmouseup = () => stopNote(noteName);
key.onmouseleave = () => stopNote(noteName);
container.appendChild(key);
});
}
function toggleNoteSelection(note) {
if (selectedNotes.has(note)) selectedNotes.delete(note);
else selectedNotes.add(note);
updatePianoUI();
updateHarmonicMap();
}
function updatePianoUI() {
document.querySelectorAll('.piano-key').forEach(key => {
key.classList.toggle('selected', selectedNotes.has(key.dataset.note));
});
}
function playNote(noteName) {
if (!currentSynth) return;
currentSynth.triggerAttack(noteName + currentOctave);
document.querySelectorAll(`[data-note="${noteName}"]`).forEach(el => el.classList.add('playing'));
}
function stopNote(noteName) {
if (!currentSynth) return;
currentSynth.triggerRelease(noteName + currentOctave);
document.querySelectorAll(`[data-note="${noteName}"]`).forEach(el => el.classList.remove('playing'));
}
function getChordInfo(noteSet) {
const indices = Array.from(noteSet).map(n => NOTES.indexOf(n)).sort((a,b)=>a-b);
if (indices.length === 0) return { name: "SELECT", intervals: [], root: "", noteIndices: [] };
for (let rootIdx = 0; rootIdx < 12; rootIdx++) {
for (let tmpl of CHORD_TEMPLATES) {
const tmplIndices = tmpl.intervals.map(i => (rootIdx + i) % 12).sort((a,b)=>a-b);
const uniqueIndices = Array.from(new Set(indices.map(i => i % 12))).sort((a,b)=>a-b);
const uniqueTmpl = Array.from(new Set(tmplIndices.map(i => i % 12))).sort((a,b)=>a-b);
if (uniqueIndices.length === uniqueTmpl.length && uniqueIndices.every((v, i) => v === uniqueTmpl[i])) {
return { name: NOTES[rootIdx] + tmpl.suffix, root: NOTES[rootIdx], intervals: tmpl.intervals, noteIndices: uniqueIndices };
}
}
}
for (let rootIdx = 0; rootIdx < 12; rootIdx++) {
if (indices.includes(rootIdx)) {
const hasThird = indices.includes((rootIdx + 4) % 12) || indices.includes((rootIdx + 3) % 12);
const hasFifth = indices.includes((rootIdx + 7) % 12);
if (hasThird && !hasFifth) return { name: NOTES[rootIdx] + "(no 5)", root: NOTES[rootIdx], intervals: [0, 3], noteIndices: indices };
if (!hasThird && hasFifth) return { name: NOTES[rootIdx] + "5", root: NOTES[rootIdx], intervals: [0, 7], noteIndices: indices };
}
}
return { name: "Cluster", root: NOTES[indices[0]], intervals: [], noteIndices: indices };
}
function calculateSimilarity(indicesA, indicesB) {
const setA = new Set(indicesA);
const setB = new Set(indicesB);
let shared = 0;
setA.forEach(val => { if (setB.has(val)) shared++; });
return shared / Math.max(setA.size, setB.size);
}
function updateHarmonicMap() {
const mapInner = document.getElementById('map-inner');
mapInner.innerHTML = '';
const activeChord = getChordInfo(selectedNotes);
document.getElementById('active-chord-name').innerText = activeChord.name;
document.getElementById('intervals-display').innerText = Array.from(selectedNotes).length > 0 ? Array.from(selectedNotes).join(' ') : "---";
if (selectedNotes.size === 0) {
updateActiveTones([]);
return;
}
const centerX = mapInner.offsetWidth / 2;
const centerY = mapInner.offsetHeight / 2;
const candidates = [];
NOTES.forEach(root => {
CHORD_TEMPLATES.forEach(tmpl => {
const noteIndices = tmpl.intervals.map(i => (NOTES.indexOf(root) + i) % 12);
const similarity = calculateSimilarity(activeChord.noteIndices, noteIndices);
if (similarity > 0.35 || root === activeChord.root) {
candidates.push({
name: root + tmpl.suffix,
root: root,
intervals: tmpl.intervals,
indices: noteIndices,
similarity: similarity
});
}
});
});
candidates.sort((a,b) => b.similarity - a.similarity);
const displaySet = candidates.slice(0, 32);
displaySet.forEach(chord => {
const node = document.createElement('div');
const isCenter = chord.name === activeChord.name;
node.className = `chord-node ${isCenter ? 'center-node' : ''}`;
const fifthsIndex = FIFTHS.indexOf(chord.root);
const angle = (fifthsIndex / 12) * Math.PI * 2 - Math.PI / 2;
let dist = isCenter ? 0 : (1.15 - chord.similarity) * 280;
if (!isCenter && chord.root === activeChord.root) dist = 100;
const x = centerX + Math.cos(angle) * dist;
const y = centerY + Math.sin(angle) * dist;
const size = isCenter ? 86 : 60;
node.style.width = `${size}px`;
node.style.height = `${size}px`;
node.style.left = `${x}px`;
node.style.top = `${y}px`;
node.innerHTML = `
<span class="text-[8px] opacity-40 uppercase font-bold tracking-tighter">${chord.root}</span>
<span class="text-[12px] font-bold tracking-tighter leading-none mt-0.5">${chord.name}</span>
`;
node.onmousedown = (e) => {
if (e.button === 2) return;
initAudio();
const playable = chord.intervals.map(int => {
const idx = NOTES.indexOf(chord.root) + int;
return NOTES[idx % 12] + (currentOctave + Math.floor(idx/12));
});
currentSynth.triggerAttack(playable);
updateActiveTones(playable);
};
node.onmouseup = () => {
currentSynth.releaseAll();
updateActiveTones(Array.from(selectedNotes).map(n => n + currentOctave));
};
node.onmouseleave = () => {
currentSynth.releaseAll();
updateActiveTones(Array.from(selectedNotes).map(n => n + currentOctave));
};
node.oncontextmenu = (e) => {
e.preventDefault();
selectedNotes.clear();
chord.indices.forEach(idx => selectedNotes.add(NOTES[idx]));
updatePianoUI();
updateHarmonicMap();
};
mapInner.appendChild(node);
});
updateActiveTones(Array.from(selectedNotes).map(n => n + currentOctave));
}
function updateActiveTones(fullNoteNames) {
const container = document.getElementById('tones-list');
container.innerHTML = '';
if (fullNoteNames.length === 0) {
container.innerHTML = '<div class="text-[10px] text-white/10 uppercase tracking-widest text-center mt-8">No tones active</div>';
return;
}
fullNoteNames.forEach(fullNote => {
const tag = document.createElement('div');
tag.className = 'note-tag';
// Extract note name and octave for visual clarity
const name = fullNote.replace(/[0-9]/g, '');
const octave = fullNote.replace(/[^0-9]/g, '');
tag.innerHTML = `
<span class="text-xs font-bold text-white/80">${name}</span>
<span class="text-[9px] text-white/20 uppercase tracking-tighter">Octave ${octave}</span>
`;
container.appendChild(tag);
});
}
// Global Listeners
window.addEventListener('keydown', (e) => {
if (e.repeat) return;
const note = KEY_MAP[e.key.toLowerCase()];
if (note) {
initAudio();
playNote(note);
updateActiveTones(Array.from(selectedNotes).map(n => n + currentOctave).concat(note + currentOctave));
}
});
window.addEventListener('keyup', (e) => {
const note = KEY_MAP[e.key.toLowerCase()];
if (note) {
stopNote(note);
updateActiveTones(Array.from(selectedNotes).map(n => n + currentOctave));
}
});
document.getElementById('clear-notes').onclick = () => { selectedNotes.clear(); updatePianoUI(); updateHarmonicMap(); };
document.getElementById('force-detect').onclick = () => updateHarmonicMap();
document.getElementById('master-vol').oninput = (e) => { mainVol.volume.value = parseFloat(e.target.value); };
document.getElementById('sound-select').onchange = (e) => setSound(e.target.value);
document.getElementById('octave-up').onclick = () => { if (currentOctave < 6) currentOctave++; document.getElementById('octave-val').innerText = currentOctave; updateHarmonicMap(); };
document.getElementById('octave-down').onclick = () => { if (currentOctave > 1) currentOctave--; document.getElementById('octave-val').innerText = currentOctave; updateHarmonicMap(); };
// Initialization
createGridSpokes();
buildPiano();
setSound('silk_air');
</script>
</body>
</html>