CHORD HARMONIC EXPLORER v1.0

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/7a4d117ecc00e924/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
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>