This shows you the differences between two versions of the page.
| Both sides previous revisionPrevious revisionNext revision | Previous revision | ||
| music:perfect [2026/01/16 09:09] – fixed urlencode issue wikarai | music:perfect [2026/03/24 22:28] (current) – external edit A User Not Logged in | ||
|---|---|---|---|
| Line 1: | Line 1: | ||
| < | < | ||
| + | < | ||
| <script src=" | <script src=" | ||
| <script src=" | <script src=" | ||
| + | <script src=" | ||
| + | </ | ||
| + | < | ||
| < | < | ||
| - | .app-wrapper { color: #e0e0e0; background: #1a1a1a; padding: 25px; border-radius: | + | .app-wrapper { color: #e0e0e0; background: #1a1a1a; padding: 25px; border-radius: |
| - | .piano-container { display: flex; justify-content: | + | |
| - | .key { position: relative; float: left; cursor: pointer; border-radius: | + | |
| - | .white-key { width: 35px; height: 150px; background: #ddd; border: 1px solid #999; z-index: 1; } | + | |
| - | .white-key.active { background: #4a90e2; box-shadow: inset 0 0 15px rgba(0, | + | |
| - | .black-key { width: 22px; height: 90px; background: #000; margin-left: | + | |
| - | .black-key.active { background: #1d4ed8; } | + | |
| | | ||
| + | .piano-container { display: flex; flex-direction: | ||
| + | .piano-keys { display: flex; justify-content: | ||
| + | .key { position: relative; float: left; cursor: pointer; border-radius: | ||
| + | .white-key { width: 35px; height: 150px; background: linear-gradient(to bottom, #f8f8f8, #ddd); border: 1px solid #999; z-index: 1; } | ||
| + | .white-key.active { background: linear-gradient(to bottom, #4a90e2, #3070c2); box-shadow: inset 0 0 15px rgba(0, | ||
| + | .black-key { width: 22px; height: 90px; background: linear-gradient(to bottom, #333, #000); margin-left: | ||
| + | .black-key.active { background: linear-gradient(to bottom, #1d4ed8, #0d2e98); } | ||
| + | | ||
| + | .key-label-top { position: absolute; top: 10px; font-size: 10px; font-weight: | ||
| + | .key-label-bottom { position: absolute; bottom: 8px; font-size: 11px; font-weight: | ||
| + | .white-key .key-label-top, | ||
| + | .black-key .key-label-top, | ||
| + | .black-key .key-label-top { top: 5px; font-size: 9px; } | ||
| + | .black-key .key-label-bottom { bottom: 5px; font-size: 10px; } | ||
| + | |||
| + | .chord-suggestions { margin-top: 8px; padding: 0; border-radius: | ||
| + | .chord-suggestion-buttons { display: flex; flex-wrap: wrap; gap: 8px; justify-content: | ||
| + | .chord-suggestion-btn { font-size: 0.75em; padding: 6px 10px; border: 1px solid #444; border-radius: | ||
| + | .chord-suggestion-btn: | ||
| + | |||
| .dashboard { text-align: center; } | .dashboard { text-align: center; } | ||
| - | .chord-display { font-size: | + | .chord-display { font-size: |
| - | .chord-subtext | + | .chord-alt { font-size: 0.6em; color: #888; display: block; margin-top: 5px; } |
| - | .btn-grid { display: grid; grid-template-columns: | + | |
| - | .btn { padding: 12px; border: 1px solid #444; border-radius: | + | .collapse-bar { display: flex; justify-content: |
| - | .btn:hover { background: #444; } | + | .collapse-indicator { color: #666; font-weight: |
| + | .section-stack { display: flex; flex-direction: | ||
| + | .section-wrapper { position: relative; } | ||
| + | .section-toolbar { display: flex; justify-content: | ||
| + | .section-title { color: #82ffad; font-weight: | ||
| + | .section-move-buttons { display: flex; gap: 6px; } | ||
| + | .move-btn { font-size: 0.7em; padding: 5px 8px; border-radius: | ||
| + | .move-btn: | ||
| + | .section-anchor { position: relative; top: -6px; } | ||
| + | .section-label { font-size: 0.85em; color: #888; text-transform: | ||
| + | | ||
| + | .btn { padding: 12px; border: 1px solid #444; border-radius: | ||
| + | .btn:hover { background: #3a3a3a; border-color: | ||
| .btn-perf { color: #82ffad; border-color: | .btn-perf { color: #82ffad; border-color: | ||
| .btn-comp { color: #74b9ff; border-color: | .btn-comp { color: #74b9ff; border-color: | ||
| - | .action-row { display: flex; justify-content: | + | .analysis-container, |
| - | | + | .analysis-table, .info-table { width: 100%; border-collapse: |
| - | + | .analysis-table th, .analysis-table td, .info-table th, .info-table td { border: 1px solid #444; padding: 10px; text-align: center; } | |
| - | .analysis-container { margin-top: | + | .analysis-table th, .info-table th { background: #2a2a2a; color: #4a90e2; } |
| - | .analysis-table { width: 100%; border-collapse: | + | |
| - | .analysis-table th, .analysis-table td { border: 1px solid #444; padding: 10px; text-align: center; } | + | |
| - | .analysis-table th { background: #2a2a2a; color: #4a90e2; } | + | |
| - | | + | |
| .drift-neg { color: #ff6b6b; font-weight: | .drift-neg { color: #ff6b6b; font-weight: | ||
| .drift-pos { color: #51cf66; font-weight: | .drift-pos { color: #51cf66; font-weight: | ||
| + | |||
| + | .tuning-desc-box { background: #252525; padding: 12px 14px; border-radius: | ||
| + | .tuning-title { color: #82ffad; font-weight: | ||
| + | .tuning-text { color: #bbb; font-size: 0.9em; line-height: | ||
| + | .help { cursor: help; color: #888; font-weight: | ||
| + | |||
| + | .viz-section { background: #1a1a1a; border: 1px solid #333; border-radius: | ||
| + | .viz-buttons-row { display: flex; flex-wrap: wrap; gap: 8px; justify-content: | ||
| + | .viz-button { font-size: 0.8em; padding: 7px 12px; border-radius: | ||
| + | .viz-button: | ||
| + | .viz-button.active { background: #82ffad; color: #0d1b14; border-color: | ||
| + | .viz-controls-row { display: flex; justify-content: | ||
| | | ||
| + | .viz-canvas-wrapper { width: 100%; min-height: 400px; display: flex; justify-content: | ||
| + | #viz-canvas { width: 100%; height: 400px; } | ||
| + | | ||
| + | .tuning-group-label { text-align: left; font-size: 0.7em; color: #666; text-transform: | ||
| + | .tuning-buttons-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: | ||
| + | # | ||
| + | .t-btn { font-size: 0.8em; padding: 8px 14px; border: 1px solid #444; background: #222; color: #777; border-radius: | ||
| + | .t-btn: | ||
| + | .t-btn.active { background: #2d5a3c; color: #82ffad; border-color: | ||
| + | |||
| + | .action-row { display: flex; justify-content: | ||
| + | .btn-small { font-size: 0.8em; padding: 6px 12px; background: transparent; | ||
| + | .btn-small: | ||
| .credits { margin-top: 30px; border-top: 1px solid #333; padding-top: | .credits { margin-top: 30px; border-top: 1px solid #333; padding-top: | ||
| - | .credits a { color: #777; text-decoration: | ||
| </ | </ | ||
| <div class=" | <div class=" | ||
| <div class=" | <div class=" | ||
| + | < | ||
| + | <p style=" | ||
| <div class=" | <div class=" | ||
| - | <button class=" | + | <button class=" |
| - | <div id=" | + | <div id=" |
| - | <button class=" | + | <button class=" |
| </ | </ | ||
| - | <div class=" | + | <div class=" |
| + | <div id=" | ||
| + | <div id=" | ||
| + | <div class=" | ||
| + | </ | ||
| + | | ||
| | | ||
| - | <div class=" | + | <div class=" |
| - | | + | |
| - | <span id=" | + | |
| - | | + | |
| - | <div class=" | + | |
| - | <button class=" | + | <div class=" |
| - | <button class=" | + | <div class=" |
| - | <button class=" | + | <span class=" |
| - | </ | + | <div class=" |
| + | <button class=" | ||
| + | <button class=" | ||
| + | </ | ||
| + | </ | ||
| + | <div class=" | ||
| + | <div id=" | ||
| + | <div class=" | ||
| + | | ||
| + | < | ||
| + | | ||
| + | <button class=" | ||
| + | <button class=" | ||
| + | <button class=" | ||
| + | <button class=" | ||
| + | </ | ||
| + | </ | ||
| + | </ | ||
| + | | ||
| - | | + | |
| - | <button class=" | + | < |
| - | <button class=" | + | <span class=" |
| - | <button | + | <div class=" |
| - | </ | + | |
| + | <button class=" | ||
| + | </div> | ||
| + | </ | ||
| + | <div id=" | ||
| + | <div class=" | ||
| + | < | ||
| + | <table class=" | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | </ | ||
| + | </ | ||
| + | <tbody id=" | ||
| + | </ | ||
| + | </ | ||
| + | </ | ||
| + | | ||
| - | | + | |
| - | <strong | + | < |
| - | <span style=" | + | <span class=" |
| - | <table class=" | + | <div class=" |
| - | <thead> | + | <button class=" |
| - | <tr> | + | <button class=" |
| - | <th>Note</th> | + | <button class=" |
| - | <th>Interval</th> | + | </ |
| - | <th>Harmonic Ratio</th> | + | </ |
| - | <th>TET Freq</th> | + | <div class=" |
| - | | + | <div id=" |
| - | | + | <div class=" |
| - | | + | <div class=" |
| - | | + | <button class=" |
| - | <tbody id="analysis-body"></ | + | </ |
| - | </ | + | <div id=" |
| + | <div class=" | ||
| + | <canvas id=" | ||
| + | </ | ||
| + | <div id=" | ||
| + | </ | ||
| + | </ | ||
| + | |||
| + | <div class=" | ||
| + | <div class=" | ||
| + | <span class=" | ||
| + | <div class=" | ||
| + | <button class=" | ||
| + | <button class=" | ||
| + | </ | ||
| + | </ | ||
| + | <div class=" | ||
| + | | ||
| + | < | ||
| + | <div id=" | ||
| + | </ | ||
| + | <div id=" | ||
| + | </div> | ||
| + | </div> | ||
| + | |||
| + | < | ||
| + | <div class=" | ||
| + | <span class=" | ||
| + | <div class=" | ||
| + | <button class=" | ||
| + | <button class=" | ||
| + | </div> | ||
| + | | ||
| + | <div class=" | ||
| + | <h3 style=" | ||
| + | <table class=" | ||
| + | | ||
| + | <tbody id="reference-body"></ | ||
| + | </table> | ||
| + | </ | ||
| + | </div> | ||
| </ | </ | ||
| <div class=" | <div class=" | ||
| - | < | + | < |
| - | < | + | < |
| + | < | ||
| </ | </ | ||
| </ | </ | ||
| Line 90: | Line 223: | ||
| < | < | ||
| - | const startOctave = 3, endOctave = 5; | + | (function() { |
| - | | + | |
| | | ||
| - | | + | |
| - | { r: 1/1, n: " | + | var NOTE_NAMES |
| - | | + | var NOTE_NAMES_TONAL = [" |
| - | | + | var INTERVAL_NAMES = [" |
| - | | + | |
| - | | + | var KEY_MAP = { ' |
| - | | + | var INV_KEY_MAP = {}; |
| - | { r: 45/32, n: " | + | for (var k in KEY_MAP) INV_KEY_MAP[KEY_MAP[k]] = k.toUpperCase(); |
| - | { r: 3/2, n: " | + | |
| - | { r: 8/5, n: " | + | var PHI = (1 + Math.sqrt(5)) / 2; |
| - | { r: 5/3, n: " | + | var PI = Math.PI; |
| - | { r: 9/5, n: " | + | var TAU = 2 * PI; |
| - | { r: 15/8, n: " | + | |
| + | var TUNING_CATEGORIES = { | ||
| + | | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | }; | ||
| + | |||
| + | var TUNING_SYSTEMS = { | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "The ' | ||
| + | ratios: [{r: | ||
| + | | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "Pure Renaissance harmony. Major triads ring like a single bell with no beating.", | ||
| + | ratios: [{r: | ||
| + | | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "Pure stacked fifths (3:2). Brilliant melodies, but thirds are sharp. Ancient Greek.", | ||
| + | ratios: [{r: | ||
| + | | ||
| + | ' | ||
| + | name: " | ||
| + | desc: " | ||
| + | ratios: [{r:1, | ||
| + | | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "The 13th harmonic adds rich, unusual intervals. Complex and exotic.", | ||
| + | ratios: [{r: | ||
| + | | ||
| + | ' | ||
| + | name: " | ||
| + | desc: " | ||
| + | ratios: [{r: | ||
| + | | ||
| + | ' | ||
| + | name: " | ||
| + | desc: " | ||
| + | ratios: [{r: | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: " | ||
| + | ratios: [{r: | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: " | ||
| + | ratios: [{r: | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: " | ||
| + | ratios: [{r: | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "Prime factor lattice system. Mathematically elegant pitch relationships.", | ||
| + | ratios: [{r: | ||
| + | }, | ||
| + | ' | ||
| + | name: "Lucy Tuning", | ||
| + | desc: "Based on π. The fifth-to-octave ratio equals π/(2π-2). Dreamy and irrational.", | ||
| + | ratios: (function() { | ||
| + | var L = 1200 / (2 * PI), s = (1200 - 5*L) / 2; | ||
| + | var steps = [0, s, 2*s, L+2*s, 2*L+2*s, 2*L+3*s, 3*L+3*s, 3*L+4*s, 4*L+4*s, 4*L+5*s, 5*L+5*s, 5*L+6*s]; | ||
| + | return steps.map(function(c, | ||
| + | })() | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "Scale from powers of phi (1.618...). Nature' | ||
| + | ratios: (function() { | ||
| + | var cents = [0]; | ||
| + | for (var i = 1; i < 12; i++) cents.push((1200 * Math.log2(Math.pow(PHI, | ||
| + | cents.sort(function(a, | ||
| + | return cents.map(function(c, | ||
| + | })() | ||
| + | }, | ||
| + | ' | ||
| + | name: "Pi Tuning", | ||
| + | desc: " | ||
| + | ratios: (function() { | ||
| + | var base = [0, 100*PI/3, 200*PI/3, 300, 400*PI/3, 500, 600*PI/3, 700, 800*PI/3, 900, 1000*PI/3, 1100]; | ||
| + | return base.map(function(c, | ||
| + | })() | ||
| + | }, | ||
| + | ' | ||
| + | name: "√2 Tuning", | ||
| + | desc: "Based on √2—the tritone' | ||
| + | ratios: (function() { | ||
| + | var cents = [0]; | ||
| + | for (var i = 1; i < 12; i++) cents.push((100 * i * Math.log2(Math.SQRT2) * 2) % 1200); | ||
| + | cents.sort(function(a, | ||
| + | return cents.map(function(c, | ||
| + | })() | ||
| + | }, | ||
| + | ' | ||
| + | name: "Young Well", | ||
| + | desc: " | ||
| + | ratios: [{r: | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "19 equal divisions. Better thirds than 12-TET, Renaissance ideal realized.", | ||
| + | ratios: (function() { | ||
| + | var steps = [0, 2, 3, 5, 6, 8, 9, 11, 13, 14, 16, 17]; | ||
| + | return steps.map(function(s) { var c = (s/ | ||
| + | })() | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "22 equal divisions. Approximates 7-limit ratios. Indian classical connection.", | ||
| + | ratios: (function() { | ||
| + | var steps = [0, 2, 4, 5, 7, 9, 11, 13, 14, 16, 18, 20]; | ||
| + | return steps.map(function(s) { var c = (s/ | ||
| + | })() | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "31 equal divisions. Nearly pure thirds and good fifths. Meantone perfected.", | ||
| + | ratios: (function() { | ||
| + | var steps = [0, 3, 5, 8, 10, 13, 15, 18, 21, 23, 26, 28]; | ||
| + | return steps.map(function(s) { var c = (s/ | ||
| + | })() | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "53 equal divisions. Nearly indistinguishable from Pythagorean and 5-limit JI.", | ||
| + | ratios: (function() { | ||
| + | var steps = [0, 5, 9, 14, 17, 22, 26, 31, 36, 39, 44, 48]; | ||
| + | return steps.map(function(s) { var c = (s/ | ||
| + | })() | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: " | ||
| + | ratios: (function() { | ||
| + | return [0, | ||
| + | var c = (i * 1200 * Math.log2(3) / 13) % 1200; | ||
| + | return {r: Math.pow(2, c/1200), n: c.toFixed(0)+" | ||
| + | }); | ||
| + | })() | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "Wendy Carlos' | ||
| + | ratios: (function() { | ||
| + | return [0, | ||
| + | var c = (i * 78) % 1200; | ||
| + | return {r: Math.pow(2, c/1200), n: c.toFixed(0)+" | ||
| + | }); | ||
| + | })() | ||
| + | }, | ||
| + | ' | ||
| + | name: " | ||
| + | desc: "Wendy Carlos' | ||
| + | ratios: (function() { | ||
| + | return [0, | ||
| + | var c = (i * 63.8) % 1200; | ||
| + | return {r: Math.pow(2, c/1200), n: c.toFixed(0)+" | ||
| + | }); | ||
| + | })() | ||
| + | } | ||
| + | }; | ||
| + | |||
| + | var VISUALIZATIONS = [ | ||
| + | { id: ' | ||
| + | { id: ' | ||
| + | { id: ' | ||
| + | { id: ' | ||
| + | { id: ' | ||
| + | { id: ' | ||
| + | { id: ' | ||
| + | { id: ' | ||
| + | { id: ' | ||
| + | { id: ' | ||
| ]; | ]; | ||
| + | var CHORD_SUGGESTIONS = [ | ||
| + | { label: ' | ||
| + | { label: ' | ||
| + | { label: ' | ||
| + | { label: ' | ||
| + | { label: ' | ||
| + | { label: ' | ||
| + | { label: ' | ||
| + | { label: ' | ||
| + | { label: ' | ||
| + | { label: ' | ||
| + | { label: ' | ||
| + | { label: ' | ||
| + | ]; | ||
| + | |||
| + | var DEFAULT_TUNING_KEY = ' | ||
| + | var DEFAULT_VIZ = ' | ||
| + | var CURRENT_TUNING = TUNING_SYSTEMS[DEFAULT_TUNING_KEY]; | ||
| + | var CURRENT_TUNING_KEY = DEFAULT_TUNING_KEY; | ||
| + | var CURRENT_VIZ = DEFAULT_VIZ; | ||
| + | var selectedNotes = new Set(); | ||
| + | var synth = null; | ||
| + | var audioStarted = false; | ||
| + | var latestMetrics = []; | ||
| + | var canvas, ctx; | ||
| + | |||
| + | function initApp() { | ||
| + | canvas = document.getElementById(' | ||
| + | ctx = canvas.getContext(' | ||
| + | resizeCanvas(); | ||
| + | window.addEventListener(' | ||
| + | | ||
| + | buildPiano(); | ||
| + | buildChordSuggestionButtons(); | ||
| + | buildTuningButtons(); | ||
| + | buildVizButtons(); | ||
| + | selectTuning(DEFAULT_TUNING_KEY); | ||
| + | checkUrlParams(); | ||
| + | initCollapsibles(); | ||
| + | } | ||
| + | |||
| + | function resizeCanvas() { | ||
| + | var wrapper = canvas.parentElement; | ||
| + | var dpr = window.devicePixelRatio || 1; | ||
| + | var rect = wrapper.getBoundingClientRect(); | ||
| + | var width = Math.max(300, | ||
| + | var height = 400; | ||
| + | canvas.width = width * dpr; | ||
| + | canvas.height = height * dpr; | ||
| + | canvas.style.width = width + ' | ||
| + | canvas.style.height = height + ' | ||
| + | ctx.setTransform(1, | ||
| + | ctx.scale(dpr, | ||
| + | if (latestMetrics.length > 0) renderVisualization(CURRENT_VIZ, | ||
| + | } | ||
| + | |||
| + | function checkUrlParams() { | ||
| + | try { | ||
| + | var params = new URLSearchParams(window.location.search); | ||
| + | var vizParam = params.get(' | ||
| + | var tunParam = params.get(' | ||
| + | var notesParam = params.get(' | ||
| + | |||
| + | if (vizParam && VISUALIZATIONS.some(function(v) { return v.id === vizParam; })) { | ||
| + | switchVisualization(vizParam); | ||
| + | } | ||
| + | |||
| + | if (tunParam && TUNING_SYSTEMS[tunParam]) { | ||
| + | selectTuning(tunParam); | ||
| + | } | ||
| + | |||
| + | if (notesParam) { | ||
| + | selectedNotes.clear(); | ||
| + | notesParam.split(',' | ||
| + | if (n && n.trim()) { | ||
| + | var note = n.trim(); | ||
| + | selectedNotes.add(note); | ||
| + | var el = document.getElementById(" | ||
| + | if (el) el.classList.add(' | ||
| + | } | ||
| + | }); | ||
| + | analyze(); | ||
| + | } | ||
| + | } catch(e) {} | ||
| + | } | ||
| + | |||
| + | function buildTuningButtons() { | ||
| + | var container = document.getElementById(' | ||
| + | if (!container) return; | ||
| + | container.innerHTML = ""; | ||
| + | for (var cat in TUNING_CATEGORIES) { | ||
| + | var keys = TUNING_CATEGORIES[cat]; | ||
| + | var label = document.createElement(' | ||
| + | label.className = " | ||
| + | label.innerText = cat; | ||
| + | container.appendChild(label); | ||
| + | var row = document.createElement(' | ||
| + | row.className = " | ||
| + | keys.forEach(function(key) { | ||
| + | var sys = TUNING_SYSTEMS[key]; | ||
| + | if (!sys) return; | ||
| + | var btn = document.createElement(' | ||
| + | btn.className = " | ||
| + | btn.innerText = sys.name.split(" | ||
| + | btn.id = " | ||
| + | (function(k) { btn.onclick = function() { selectTuning(k); | ||
| + | row.appendChild(btn); | ||
| + | }); | ||
| + | container.appendChild(row); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | function buildVizButtons() { | ||
| + | var container = document.getElementById(' | ||
| + | if (!container) return; | ||
| + | container.innerHTML = ""; | ||
| + | VISUALIZATIONS.forEach(function(viz) { | ||
| + | var btn = document.createElement(' | ||
| + | btn.className = " | ||
| + | btn.innerText = viz.label; | ||
| + | btn.dataset.viz = viz.id; | ||
| + | (function(v) { btn.onclick = function() { switchVisualization(v.id); | ||
| + | container.appendChild(btn); | ||
| + | }); | ||
| + | switchVisualization(CURRENT_VIZ); | ||
| + | } | ||
| + | |||
| + | function switchVisualization(id) { | ||
| + | CURRENT_VIZ = id; | ||
| + | var viz = VISUALIZATIONS.find(function(v) { return v.id === id; }); | ||
| + | var descEl = document.getElementById(' | ||
| + | var explainEl = document.getElementById(' | ||
| + | if (descEl && viz) descEl.innerText = viz.desc; | ||
| + | if (explainEl && viz) explainEl.innerText = viz.explain || ''; | ||
| + | document.querySelectorAll(' | ||
| + | btn.classList.toggle(' | ||
| + | }); | ||
| + | renderVisualization(id, | ||
| + | } | ||
| + | |||
| + | function selectTuning(key) { | ||
| + | if (!TUNING_SYSTEMS[key]) return; | ||
| + | CURRENT_TUNING_KEY = key; | ||
| + | CURRENT_TUNING = TUNING_SYSTEMS[key]; | ||
| + | document.getElementById(' | ||
| + | document.getElementById(' | ||
| + | document.querySelectorAll(' | ||
| + | var btn = document.getElementById(" | ||
| + | if (btn) btn.classList.add(' | ||
| + | renderReferenceTable(); | ||
| + | analyze(); | ||
| + | } | ||
| + | |||
| + | function renderReferenceTable() { | ||
| + | document.getElementById(' | ||
| + | var tbody = document.getElementById(' | ||
| + | tbody.innerHTML = ""; | ||
| + | CURRENT_TUNING.ratios.forEach(function(item, | ||
| + | var tr = document.createElement(' | ||
| + | tr.innerHTML = '< | ||
| + | tbody.appendChild(tr); | ||
| + | }); | ||
| + | } | ||
| + | |||
| + | // ========== VISUALIZATION RENDERERS ========== | ||
| + | |||
| + | function renderVisualization(id, | ||
| + | latestMetrics = metrics || []; | ||
| + | var w = canvas.width / (window.devicePixelRatio || 1); | ||
| + | var h = canvas.height / (window.devicePixelRatio || 1); | ||
| + | | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.fillRect(0, | ||
| + | | ||
| + | if (!metrics || metrics.length === 0) { | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = '16px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.fillText(' | ||
| + | return; | ||
| + | } | ||
| + | | ||
| + | var renderers = { | ||
| + | spiral: renderSpiral, | ||
| + | chladni: renderChladni, | ||
| + | waveform: renderWaveform, | ||
| + | fifths: renderFifths, | ||
| + | spectrum: renderSpectrum, | ||
| + | network: renderNetwork, | ||
| + | tonnetz: renderTonnetz, | ||
| + | constellation: | ||
| + | helix: renderHelix, | ||
| + | lissajous: renderLissajous, | ||
| + | tree: renderTree | ||
| + | }; | ||
| + | | ||
| + | if (renderers[id]) renderers[id](ctx, | ||
| + | } | ||
| + | |||
| + | function getSortedSelectedNotes() { | ||
| + | return Array.from(selectedNotes).sort(function(a, | ||
| + | return Tonal.Note.midi(a) - Tonal.Note.midi(b); | ||
| + | }); | ||
| + | } | ||
| + | |||
| + | function buildChordSuggestionButtons() { | ||
| + | var container = document.getElementById(' | ||
| + | if (!container) return; | ||
| + | container.innerHTML = ''; | ||
| + | CHORD_SUGGESTIONS.forEach(function(entry) { | ||
| + | var btn = document.createElement(' | ||
| + | btn.className = ' | ||
| + | btn.innerText = entry.label; | ||
| + | btn.dataset.type = entry.type; | ||
| + | btn.title = entry.type; | ||
| + | btn.onclick = function() { applyChordSuggestion(entry.type); | ||
| + | container.appendChild(btn); | ||
| + | }); | ||
| + | } | ||
| + | |||
| + | function syncKeyHighlights() { | ||
| + | document.querySelectorAll('# | ||
| + | var note = keyEl.id.replace(' | ||
| + | keyEl.classList.toggle(' | ||
| + | }); | ||
| + | } | ||
| + | |||
| + | function updateChordSuggestionsVisibility(rootNote) { | ||
| + | var container = document.getElementById(' | ||
| + | if (!container) return; | ||
| + | var visible = selectedNotes.size > 0; | ||
| + | container.style.display = visible ? ' | ||
| + | } | ||
| + | |||
| + | function applyChordSuggestion(type) { | ||
| + | var root = getSortedSelectedNotes()[0]; | ||
| + | if (!root) return; | ||
| + | var chord = Tonal.Chord.getChord(type, | ||
| + | if (!chord || !Array.isArray(chord.intervals) || chord.intervals.length === 0) return; | ||
| + | var chordNotes = chord.intervals.map(function(interval) { | ||
| + | return Tonal.Note.transpose(root, | ||
| + | }); | ||
| + | selectedNotes.clear(); | ||
| + | syncKeyHighlights(); | ||
| + | chordNotes.forEach(function(note) { | ||
| + | var candidates = [note, Tonal.Note.enharmonic(note)].filter(Boolean); | ||
| + | var applied = false; | ||
| + | candidates.forEach(function(candidate) { | ||
| + | if (applied) return; | ||
| + | var keyEl = document.getElementById(' | ||
| + | if (!keyEl) return; | ||
| + | selectedNotes.add(candidate); | ||
| + | keyEl.classList.add(' | ||
| + | applied = true; | ||
| + | }); | ||
| + | }); | ||
| + | analyze(); | ||
| + | if (selectedNotes.size > 0) { | ||
| + | initAudio(); | ||
| + | if (synth) synth.triggerAttackRelease(Array.from(selectedNotes), | ||
| + | } | ||
| + | } | ||
| + | |||
| + | function roundRectPath(ctx, | ||
| + | var radius = Math.max(0, Math.min(r, Math.min(w, h) / 2)); | ||
| + | ctx.beginPath(); | ||
| + | ctx.moveTo(x + radius, y); | ||
| + | ctx.arcTo(x + w, y, x + w, y + h, radius); | ||
| + | ctx.arcTo(x + w, y + h, x, y + h, radius); | ||
| + | ctx.arcTo(x, | ||
| + | ctx.arcTo(x, | ||
| + | ctx.closePath(); | ||
| + | } | ||
| + | |||
| + | function driftColor(drift) { | ||
| + | var t = Math.max(-20, | ||
| + | if (t < 0) { | ||
| + | return ' | ||
| + | } else { | ||
| + | return ' | ||
| + | } | ||
| + | } | ||
| + | |||
| + | function renderSpiral(ctx, | ||
| + | var cx = w / 2, cy = h / 2; | ||
| + | var count = metrics.length; | ||
| + | var a = Math.max(40, | ||
| + | var maxRadius = Math.min(w, h) / 2 - 40; | ||
| + | var tMax = TAU * 2; | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 2; | ||
| + | ctx.beginPath(); | ||
| + | for (var t = 0; t <= tMax; t += 0.05) { | ||
| + | var r = a * Math.pow(PHI, | ||
| + | var x = cx + Math.min(maxRadius, | ||
| + | var y = cy + Math.min(maxRadius, | ||
| + | if (t === 0) ctx.moveTo(x, | ||
| + | else ctx.lineTo(x, | ||
| + | } | ||
| + | ctx.stroke(); | ||
| + | |||
| + | if (count === 0) return; | ||
| + | var baseFreq = metrics[0].perfFreq || 440; | ||
| + | var prevFreq = baseFreq; | ||
| + | metrics.forEach(function(m, | ||
| + | var freq = m.perfFreq || baseFreq; | ||
| + | var logRatio = Math.log(freq / baseFreq) / Math.log(PHI); | ||
| + | var t = logRatio * TAU; | ||
| + | var goldenRadius = a * Math.pow(PHI, | ||
| + | var ratioToPrev = i === 0 ? PHI : freq / prevFreq; | ||
| + | var deviation = Math.log(ratioToPrev / PHI) / Math.log(PHI); | ||
| + | var offset = deviation * 50; | ||
| + | var radius = Math.max(30, | ||
| + | var x = cx + radius * Math.cos(t); | ||
| + | var y = cy + radius * Math.sin(t); | ||
| + | prevFreq = freq; | ||
| + | |||
| + | ctx.beginPath(); | ||
| + | ctx.arc(x, y, 26, 0, TAU); | ||
| + | ctx.fillStyle = driftColor(m.drift); | ||
| + | ctx.fill(); | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 2; | ||
| + | ctx.stroke(); | ||
| + | |||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = 'bold 13px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.textBaseline = ' | ||
| + | ctx.fillText(formatNote(m.note), | ||
| + | ctx.font = '10px sans-serif'; | ||
| + | ctx.fillText(m.drift.toFixed(1) + ' | ||
| + | }); | ||
| + | |||
| + | var avgDrift = metrics.reduce(function(s, | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = 'bold 14px sans-serif'; | ||
| + | ctx.fillText(' | ||
| + | } | ||
| + | |||
| + | function renderTonnetz(ctx, | ||
| + | var noteSet = new Set(metrics.map(function(m) { return Tonal.Note.pitchClass(m.note); | ||
| + | var enharmonic = {' | ||
| + | | ||
| + | // Tonnetz layout: fifths horizontally, | ||
| + | var fifths = [' | ||
| + | var cellW = 70, cellH = 55; | ||
| + | var startX = (w - cellW * 6) / 2; | ||
| + | var startY = 60; | ||
| + | | ||
| + | for (var row = 0; row < 5; row++) { | ||
| + | for (var col = 0; col < 6; col++) { | ||
| + | // Calculate note based on Tonnetz relationships | ||
| + | var baseIdx = (col + row * 4) % 12; | ||
| + | var fifthsIdx = (col - 2 + 7) % 7; | ||
| + | var note = fifths[fifthsIdx]; | ||
| + | | ||
| + | // Adjust for row (each row shifts by major third = 4 semitones) | ||
| + | var semitoneShift = row * 4; | ||
| + | var noteIdx = (NOTE_NAMES_TONAL.indexOf(note) + semitoneShift) % 12; | ||
| + | var displayNote = NOTE_NAMES_TONAL[noteIdx]; | ||
| + | | ||
| + | var x = startX + col * cellW + (row % 2) * (cellW / 2); | ||
| + | var y = startY + row * cellH; | ||
| + | | ||
| + | var isActive = noteSet.has(displayNote); | ||
| + | if (!isActive && enharmonic[displayNote]) isActive = noteSet.has(enharmonic[displayNote]); | ||
| + | if (!isActive) { | ||
| + | for (var flat in enharmonic) { | ||
| + | if (enharmonic[flat] === displayNote && noteSet.has(flat)) isActive = true; | ||
| + | } | ||
| + | } | ||
| + | | ||
| + | // Draw hexagon-ish rectangle | ||
| + | roundRectPath(ctx, | ||
| + | ctx.fillStyle = isActive ? '# | ||
| + | ctx.fill(); | ||
| + | ctx.strokeStyle = isActive ? '# | ||
| + | ctx.lineWidth = isActive ? 3 : 1; | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | ctx.fillStyle = isActive ? '# | ||
| + | ctx.font = (isActive ? 'bold ' : '' | ||
| + | ctx.textAlign = ' | ||
| + | ctx.textBaseline = ' | ||
| + | ctx.fillText(displayNote.replace('#', | ||
| + | } | ||
| + | } | ||
| + | | ||
| + | // Draw relationship lines | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 1; | ||
| + | // Horizontal lines (fifths) | ||
| + | for (var r = 0; r < 5; r++) { | ||
| + | for (var c = 0; c < 5; c++) { | ||
| + | var x1 = startX + c * cellW + (r % 2) * (cellW / 2) + 28; | ||
| + | var y1 = startY + r * cellH; | ||
| + | var x2 = startX + (c + 1) * cellW + (r % 2) * (cellW / 2) - 28; | ||
| + | ctx.beginPath(); | ||
| + | ctx.moveTo(x1, | ||
| + | ctx.lineTo(x2, | ||
| + | ctx.stroke(); | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | |||
| + | function renderWaveform(ctx, | ||
| + | var midY = h / 2; | ||
| + | var amp = h / 3; | ||
| + | var baseFreq = metrics[0] ? metrics[0].perfFreq : 440; | ||
| + | | ||
| + | // Draw grid | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 1; | ||
| + | for (var i = 0; i < 10; i++) { | ||
| + | var y = (i / 10) * h; | ||
| + | ctx.beginPath(); | ||
| + | ctx.moveTo(0, | ||
| + | ctx.lineTo(w, | ||
| + | ctx.stroke(); | ||
| + | } | ||
| + | | ||
| + | // Draw composite wave | ||
| + | ctx.beginPath(); | ||
| + | ctx.moveTo(0, | ||
| + | | ||
| + | for (var x = 0; x < w; x++) { | ||
| + | var t = (x / w) * 8 * PI; | ||
| + | var y = 0; | ||
| + | metrics.forEach(function(m, | ||
| + | var freqRatio = m.perfFreq / baseFreq; | ||
| + | var amplitude = 1 / (idx + 1); | ||
| + | y += amplitude * Math.sin(t * freqRatio); | ||
| + | }); | ||
| + | y = midY + y * amp / metrics.length; | ||
| + | if (x === 0) ctx.moveTo(x, | ||
| + | else ctx.lineTo(x, | ||
| + | } | ||
| + | | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 2; | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | // Draw individual waves faintly | ||
| + | metrics.forEach(function(m, | ||
| + | ctx.beginPath(); | ||
| + | var freqRatio = m.perfFreq / baseFreq; | ||
| + | for (var x = 0; x < w; x++) { | ||
| + | var t = (x / w) * 8 * PI; | ||
| + | var y = midY + (amp * 0.3) * Math.sin(t * freqRatio); | ||
| + | if (x === 0) ctx.moveTo(x, | ||
| + | else ctx.lineTo(x, | ||
| + | } | ||
| + | ctx.strokeStyle = driftColor(m.drift); | ||
| + | ctx.globalAlpha = 0.3; | ||
| + | ctx.lineWidth = 1; | ||
| + | ctx.stroke(); | ||
| + | ctx.globalAlpha = 1; | ||
| + | }); | ||
| + | | ||
| + | // Legend | ||
| + | ctx.font = '12px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | metrics.forEach(function(m, | ||
| + | ctx.fillStyle = driftColor(m.drift); | ||
| + | ctx.fillRect(10, | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.fillText(formatNote(m.note) + ' (' + m.perfFreq.toFixed(1) + ' Hz)', 28, 25 + i * 20); | ||
| + | }); | ||
| + | } | ||
| + | |||
| + | function renderSpectrum(ctx, | ||
| + | var barH = Math.min(40, | ||
| + | var startY = 40; | ||
| + | var maxFreq = Math.max.apply(null, | ||
| + | var maxBeat = Math.max.apply(null, | ||
| + | | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = '12px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.fillText(' | ||
| + | | ||
| + | metrics.forEach(function(m, | ||
| + | var y = startY + i * (barH + 10); | ||
| + | | ||
| + | // Note label | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = 'bold 13px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.fillText(formatNote(m.note), | ||
| + | | ||
| + | // Frequency bar | ||
| + | var freqW = (m.perfFreq / maxFreq) * (w - 180); | ||
| + | ctx.fillStyle = driftColor(m.drift); | ||
| + | ctx.fillRect(70, | ||
| + | | ||
| + | // Beat bar (separate band) | ||
| + | var beatW = (m.beat / maxBeat) * (w - 180) * 0.5; | ||
| + | var beatY = y + barH * 0.68; | ||
| + | ctx.fillStyle = ' | ||
| + | ctx.fillRect(70, | ||
| + | | ||
| + | // Values | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = '11px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.fillText(m.perfFreq.toFixed(1) + ' Hz', 75 + freqW, y + barH * 0.4); | ||
| + | ctx.fillStyle = '# | ||
| + | var beatLabelX = 70 + beatW + 8; | ||
| + | if (beatLabelX < 140) beatLabelX = 140; | ||
| + | ctx.fillText(' | ||
| + | }); | ||
| + | | ||
| + | // Legend | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.fillRect(w - 150, h - 40, 15, 10); | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = '10px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.fillText(' | ||
| + | | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.fillRect(w - 150, h - 25, 15, 10); | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.fillText(' | ||
| + | } | ||
| + | |||
| + | function renderNetwork(ctx, | ||
| + | if (metrics.length < 2) { | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = '14px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.fillText(' | ||
| + | return; | ||
| + | } | ||
| + | | ||
| + | var cx = w / 2, cy = h / 2; | ||
| + | var radius = Math.min(w, h) / 2 - 80; | ||
| + | | ||
| + | // Position nodes in a circle | ||
| + | var nodes = metrics.map(function(m, | ||
| + | var angle = (i / metrics.length) * TAU - PI / 2; | ||
| + | return { | ||
| + | x: cx + radius * Math.cos(angle), | ||
| + | y: cy + radius * Math.sin(angle), | ||
| + | note: m.note, | ||
| + | drift: m.drift | ||
| + | }; | ||
| + | }); | ||
| + | | ||
| + | // Draw edges (interval relationships) | ||
| + | for (var i = 0; i < nodes.length; | ||
| + | for (var j = i + 1; j < nodes.length; | ||
| + | var driftDiff = Math.abs(nodes[i].drift - nodes[j].drift); | ||
| + | var opacity = Math.max(0.1, | ||
| + | var lineWidth = Math.max(1, 4 - driftDiff / 10); | ||
| + | | ||
| + | ctx.beginPath(); | ||
| + | ctx.moveTo(nodes[i].x, | ||
| + | ctx.lineTo(nodes[j].x, | ||
| + | ctx.strokeStyle = ' | ||
| + | ctx.lineWidth = lineWidth; | ||
| + | ctx.stroke(); | ||
| + | } | ||
| + | } | ||
| + | | ||
| + | // Draw nodes | ||
| + | nodes.forEach(function(node) { | ||
| + | ctx.beginPath(); | ||
| + | ctx.arc(node.x, | ||
| + | ctx.fillStyle = driftColor(node.drift); | ||
| + | ctx.fill(); | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 3; | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = 'bold 14px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.textBaseline = ' | ||
| + | ctx.fillText(formatNote(node.note), | ||
| + | ctx.font = '10px sans-serif'; | ||
| + | ctx.fillText(node.drift.toFixed(1) + ' | ||
| + | }); | ||
| + | } | ||
| + | |||
| + | function renderFifths(ctx, | ||
| + | var cx = w / 2, cy = h / 2; | ||
| + | var r = Math.min(w, h) / 2 - 60; | ||
| + | var fifthsOrder = [' | ||
| + | var enharmonic = {' | ||
| + | | ||
| + | var noteSet = new Set(metrics.map(function(m) { | ||
| + | var pc = Tonal.Note.pitchClass(m.note); | ||
| + | return enharmonic[pc] || pc; | ||
| + | })); | ||
| + | | ||
| + | // Draw outer circle | ||
| + | ctx.beginPath(); | ||
| + | ctx.arc(cx, cy, r + 10, 0, TAU); | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 3; | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | // Draw connecting lines for active notes | ||
| + | var activeIndices = []; | ||
| + | fifthsOrder.forEach(function(note, | ||
| + | if (noteSet.has(note) || noteSet.has(note.replace(' | ||
| + | activeIndices.push(i); | ||
| + | } | ||
| + | }); | ||
| + | | ||
| + | if (activeIndices.length > 1) { | ||
| + | ctx.beginPath(); | ||
| + | activeIndices.forEach(function(idx, | ||
| + | var angle = (idx * 30 - 90) * PI / 180; | ||
| + | var x = cx + (r - 30) * Math.cos(angle); | ||
| + | var y = cy + (r - 30) * Math.sin(angle); | ||
| + | if (i === 0) ctx.moveTo(x, | ||
| + | else ctx.lineTo(x, | ||
| + | }); | ||
| + | ctx.closePath(); | ||
| + | ctx.fillStyle = ' | ||
| + | ctx.fill(); | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 2; | ||
| + | ctx.stroke(); | ||
| + | } | ||
| + | | ||
| + | // Draw notes | ||
| + | fifthsOrder.forEach(function(note, | ||
| + | var angle = (i * 30 - 90) * PI / 180; | ||
| + | var x = cx + r * Math.cos(angle); | ||
| + | var y = cy + r * Math.sin(angle); | ||
| + | var isActive = noteSet.has(note) || noteSet.has(note.replace(' | ||
| + | | ||
| + | ctx.beginPath(); | ||
| + | ctx.arc(x, y, 26, 0, TAU); | ||
| + | ctx.fillStyle = isActive ? '# | ||
| + | ctx.fill(); | ||
| + | ctx.strokeStyle = isActive ? '# | ||
| + | ctx.lineWidth = isActive ? 3 : 2; | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | ctx.fillStyle = isActive ? '# | ||
| + | ctx.font = (isActive ? 'bold ' : '' | ||
| + | ctx.textAlign = ' | ||
| + | ctx.textBaseline = ' | ||
| + | ctx.fillText(note.replace('#', | ||
| + | }); | ||
| + | | ||
| + | // Center label | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = 'bold 14px sans-serif'; | ||
| + | ctx.fillText(' | ||
| + | ctx.fillText(' | ||
| + | } | ||
| + | |||
| + | function renderConstellation(ctx, | ||
| + | var cx = w / 2, cy = h / 2; | ||
| + | | ||
| + | // Star background | ||
| + | for (var i = 0; i < 80; i++) { | ||
| + | var x = Math.random() * w; | ||
| + | var y = Math.random() * h; | ||
| + | var size = Math.random() * 2 + 0.5; | ||
| + | ctx.beginPath(); | ||
| + | ctx.arc(x, y, size, 0, TAU); | ||
| + | ctx.fillStyle = ' | ||
| + | ctx.fill(); | ||
| + | } | ||
| + | | ||
| + | // Position notes based on frequency ratios | ||
| + | var baseFreq = metrics[0] ? metrics[0].perfFreq : 440; | ||
| + | var points = metrics.map(function(m, | ||
| + | var freqRatio = m.perfFreq / baseFreq; | ||
| + | var angle = (freqRatio - 1) * 3 * PI + (i * 0.8); | ||
| + | var dist = 50 + Math.abs(m.drift) * 3 + i * 40; | ||
| + | dist = Math.min(dist, | ||
| + | return { | ||
| + | x: cx + dist * Math.cos(angle), | ||
| + | y: cy + dist * Math.sin(angle), | ||
| + | note: m.note, | ||
| + | drift: m.drift | ||
| + | }; | ||
| + | }); | ||
| + | | ||
| + | // Draw constellation lines | ||
| + | ctx.strokeStyle = ' | ||
| + | ctx.lineWidth = 2; | ||
| + | ctx.beginPath(); | ||
| + | points.forEach(function(p, | ||
| + | if (i === 0) ctx.moveTo(p.x, | ||
| + | else ctx.lineTo(p.x, | ||
| + | }); | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | // Draw stars (notes) | ||
| + | points.forEach(function(p) { | ||
| + | // Glow | ||
| + | var gradient = ctx.createRadialGradient(p.x, | ||
| + | gradient.addColorStop(0, | ||
| + | gradient.addColorStop(1, | ||
| + | ctx.fillStyle = gradient; | ||
| + | ctx.fillRect(p.x - 30, p.y - 30, 60, 60); | ||
| + | | ||
| + | // Star | ||
| + | ctx.beginPath(); | ||
| + | ctx.arc(p.x, | ||
| + | ctx.fillStyle = driftColor(p.drift); | ||
| + | ctx.fill(); | ||
| + | | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = 'bold 11px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.textBaseline = ' | ||
| + | ctx.fillText(formatNote(p.note), | ||
| + | }); | ||
| + | } | ||
| + | |||
| + | function renderHelix(ctx, | ||
| + | var cx = w / 2; | ||
| + | var helixW = 100; | ||
| + | var turns = 2; | ||
| + | var points = 80; | ||
| + | | ||
| + | // Draw helix strands | ||
| + | var strand1 = [], strand2 = []; | ||
| + | for (var i = 0; i <= points; i++) { | ||
| + | var t = (i / points) * turns * TAU; | ||
| + | var y = 30 + (i / points) * (h - 60); | ||
| + | var x1 = cx + helixW * Math.cos(t); | ||
| + | var x2 = cx + helixW * Math.cos(t + PI); | ||
| + | strand1.push({x: | ||
| + | strand2.push({x: | ||
| + | } | ||
| + | | ||
| + | ctx.lineWidth = 4; | ||
| + | ctx.lineCap = ' | ||
| + | | ||
| + | // Draw strand 1 | ||
| + | ctx.beginPath(); | ||
| + | strand1.forEach(function(p, | ||
| + | if (i === 0) ctx.moveTo(p.x, | ||
| + | else ctx.lineTo(p.x, | ||
| + | }); | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | // Draw strand 2 | ||
| + | ctx.beginPath(); | ||
| + | strand2.forEach(function(p, | ||
| + | if (i === 0) ctx.moveTo(p.x, | ||
| + | else ctx.lineTo(p.x, | ||
| + | }); | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | // Place notes as "base pairs" | ||
| + | metrics.forEach(function(m, | ||
| + | var progress = (i + 0.5) / metrics.length; | ||
| + | var t = progress * turns * TAU; | ||
| + | var y = 30 + progress * (h - 60); | ||
| + | var x1 = cx + helixW * Math.cos(t); | ||
| + | var x2 = cx + helixW * Math.cos(t + PI); | ||
| + | | ||
| + | // Connecting bar | ||
| + | ctx.beginPath(); | ||
| + | ctx.moveTo(x1, | ||
| + | ctx.lineTo(x2, | ||
| + | ctx.strokeStyle = driftColor(m.drift); | ||
| + | ctx.lineWidth = 3; | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | // Note bubble | ||
| + | ctx.beginPath(); | ||
| + | ctx.arc(x1, y, 20, 0, TAU); | ||
| + | ctx.fillStyle = driftColor(m.drift); | ||
| + | ctx.fill(); | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 2; | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = 'bold 11px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.textBaseline = ' | ||
| + | ctx.fillText(formatNote(m.note), | ||
| + | | ||
| + | // Drift label on other side | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = '10px sans-serif'; | ||
| + | ctx.fillText(m.drift.toFixed(1) + ' | ||
| + | }); | ||
| + | } | ||
| + | |||
| + | function renderLissajous(ctx, | ||
| + | if (metrics.length < 2) { | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = '14px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.fillText(' | ||
| + | return; | ||
| + | } | ||
| + | | ||
| + | var cx = w / 2, cy = h / 2; | ||
| + | var amp = Math.min(w, h) / 2 - 50; | ||
| + | | ||
| + | // Draw multiple curves for all note pairs | ||
| + | var colors = ['# | ||
| + | var colorIdx = 0; | ||
| + | | ||
| + | for (var i = 0; i < metrics.length; | ||
| + | for (var j = i + 1; j < metrics.length; | ||
| + | var freqA = metrics[i].perfFreq; | ||
| + | var freqB = metrics[j].perfFreq; | ||
| + | var ratio = freqB / freqA; | ||
| + | var phase = (j - i) * PI / 8; | ||
| + | | ||
| + | ctx.beginPath(); | ||
| + | for (var t = 0; t < 6 * PI; t += 0.02) { | ||
| + | var x = cx + amp * 0.8 * Math.sin(t); | ||
| + | var y = cy + amp * 0.8 * Math.sin(t * ratio + phase); | ||
| + | if (t === 0) ctx.moveTo(x, | ||
| + | else ctx.lineTo(x, | ||
| + | } | ||
| + | | ||
| + | ctx.strokeStyle = colors[colorIdx % colors.length]; | ||
| + | ctx.globalAlpha = 0.6; | ||
| + | ctx.lineWidth = 2; | ||
| + | ctx.stroke(); | ||
| + | ctx.globalAlpha = 1; | ||
| + | colorIdx++; | ||
| + | } | ||
| + | } | ||
| + | | ||
| + | // Legend | ||
| + | ctx.font = '11px sans-serif'; | ||
| + | colorIdx = 0; | ||
| + | var legendY = 20; | ||
| + | for (var i = 0; i < metrics.length; | ||
| + | for (var j = i + 1; j < metrics.length; | ||
| + | ctx.fillStyle = colors[colorIdx % colors.length]; | ||
| + | ctx.fillRect(10, | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.textAlign = ' | ||
| + | ctx.fillText(formatNote(metrics[i].note) + ':' | ||
| + | legendY += 18; | ||
| + | colorIdx++; | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | |||
| + | function renderChladni(ctx, | ||
| + | var size = Math.min(w, h) - 60; | ||
| + | var startX = (w - size) / 2; | ||
| + | var startY = (h - size) / 2; | ||
| + | | ||
| + | // Use frequencies to determine mode numbers | ||
| + | var baseFreq = metrics[0] ? metrics[0].perfFreq : 440; | ||
| + | var n = Math.round(2 + metrics.length * 1.5); | ||
| + | var m = Math.round(2 + (metrics.length > 1 ? metrics[1].perfFreq / baseFreq : 1) * 2); | ||
| + | | ||
| + | // Draw plate border | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 3; | ||
| + | ctx.strokeRect(startX, | ||
| + | | ||
| + | // Calculate Chladni pattern | ||
| + | var resolution = 150; | ||
| + | var cellSize = size / resolution; | ||
| + | | ||
| + | for (var px = 0; px < resolution; px++) { | ||
| + | for (var py = 0; py < resolution; py++) { | ||
| + | var x = (px / resolution - 0.5) * PI * n; | ||
| + | var y = (py / resolution - 0.5) * PI * m; | ||
| + | | ||
| + | // Chladni equation: cos(nx)cos(my) - cos(mx)cos(ny) = 0 | ||
| + | var value = Math.cos(x) * Math.cos(y * m / n) - Math.cos(y) * Math.cos(x * m / n); | ||
| + | | ||
| + | // Add harmonics based on other notes | ||
| + | metrics.slice(1).forEach(function(met, | ||
| + | var freq = met.perfFreq / baseFreq; | ||
| + | value += 0.3 * Math.sin(x * freq) * Math.sin(y * freq); | ||
| + | }); | ||
| + | | ||
| + | // Draw nodal lines (where value ≈ 0) | ||
| + | if (Math.abs(value) < 0.2) { | ||
| + | var brightness = 1 - Math.abs(value) / 0.2; | ||
| + | ctx.fillStyle = ' | ||
| + | ctx.fillRect(startX + px * cellSize, startY + py * cellSize, cellSize + 0.5, cellSize + 0.5); | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | | ||
| + | // Label | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = '12px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.fillText(' | ||
| + | } | ||
| + | |||
| + | function renderTree(ctx, | ||
| + | if (metrics.length === 0) return; | ||
| + | | ||
| + | var root = metrics[0]; | ||
| + | var rootX = w / 2, rootY = h - 60; | ||
| + | | ||
| + | // Draw trunk | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 8; | ||
| + | ctx.lineCap = ' | ||
| + | ctx.beginPath(); | ||
| + | ctx.moveTo(rootX, | ||
| + | ctx.lineTo(rootX, | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | // Root node | ||
| + | ctx.beginPath(); | ||
| + | ctx.arc(rootX, | ||
| + | ctx.fillStyle = driftColor(root.drift); | ||
| + | ctx.fill(); | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 3; | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = 'bold 14px sans-serif'; | ||
| + | ctx.textAlign = ' | ||
| + | ctx.textBaseline = ' | ||
| + | ctx.fillText(formatNote(root.note), | ||
| + | ctx.font = '10px sans-serif'; | ||
| + | ctx.fillText(root.drift.toFixed(1) + ' | ||
| + | | ||
| + | // Branch nodes | ||
| + | var branchY = [rootY - 120, rootY - 200, rootY - 270]; | ||
| + | var branchNotes = metrics.slice(1); | ||
| + | | ||
| + | branchNotes.forEach(function(m, | ||
| + | var level = Math.min(Math.floor(i / 2), 2); | ||
| + | var spread = (i % 2 === 0 ? -1 : 1) * (60 + Math.floor(i / 2) * 40); | ||
| + | var x = rootX + spread; | ||
| + | var y = branchY[level]; | ||
| + | | ||
| + | // Branch line | ||
| + | var parentY = level === 0 ? rootY - 60 : branchY[level - 1]; | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = Math.max(2, 6 - level * 2); | ||
| + | ctx.beginPath(); | ||
| + | ctx.moveTo(rootX + (level === 0 ? 0 : spread * 0.3), parentY); | ||
| + | ctx.quadraticCurveTo(x, | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | // Node | ||
| + | ctx.beginPath(); | ||
| + | ctx.arc(x, y, 25 - level * 3, 0, TAU); | ||
| + | ctx.fillStyle = driftColor(m.drift); | ||
| + | ctx.fill(); | ||
| + | ctx.strokeStyle = '# | ||
| + | ctx.lineWidth = 2; | ||
| + | ctx.stroke(); | ||
| + | | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = 'bold ' + (13 - level) + 'px sans-serif'; | ||
| + | ctx.fillText(formatNote(m.note), | ||
| + | ctx.font = (10 - level) + 'px sans-serif'; | ||
| + | ctx.fillText(m.drift.toFixed(1) + ' | ||
| + | }); | ||
| + | | ||
| + | // Title | ||
| + | ctx.fillStyle = '# | ||
| + | ctx.font = '12px sans-serif'; | ||
| + | ctx.fillText(' | ||
| + | } | ||
| + | |||
| + | function shadeColor(color, | ||
| + | // Simple color shading | ||
| + | var match = color.match(/ | ||
| + | if (!match) return color; | ||
| + | var r = Math.max(0, Math.min(255, | ||
| + | var g = Math.max(0, Math.min(255, | ||
| + | var b = Math.max(0, Math.min(255, | ||
| + | return ' | ||
| + | } | ||
| - | | + | |
| function initAudio() { | function initAudio() { | ||
| Line 119: | Line 1417: | ||
| synth.volume.value = -12; | synth.volume.value = -12; | ||
| audioStarted = true; | audioStarted = true; | ||
| - | document.getElementById(' | ||
| } | } | ||
| } | } | ||
| function buildPiano() { | function buildPiano() { | ||
| - | | + | |
| - | for (let oct = startOctave; | + | if (!piano) return; |
| - | | + | |
| - | | + | for (var oct = startOctave; |
| - | | + | |
| - | key.className | + | |
| + | var noteDisplay = NOTE_NAMES[idx]; | ||
| + | var key = document.createElement(' | ||
| + | | ||
| + | key.className = isBlack | ||
| key.id = " | key.id = " | ||
| - | key.onclick = () => toggleNote(noteId); | + | |
| + | |||
| + | var topLabel = document.createElement(' | ||
| + | topLabel.className = ' | ||
| + | topLabel.innerText = INV_KEY_MAP[noteId] || ""; | ||
| + | key.appendChild(topLabel); | ||
| + | |||
| + | var bottomLabel = document.createElement(' | ||
| + | bottomLabel.className = ' | ||
| + | bottomLabel.innerText = noteDisplay; | ||
| + | key.appendChild(bottomLabel); | ||
| + | | ||
| piano.appendChild(key); | piano.appendChild(key); | ||
| }); | }); | ||
| } | } | ||
| } | } | ||
| + | |||
| + | function toggleSection(id) { | ||
| + | var target = document.getElementById(id); | ||
| + | var indicator = document.getElementById(id + ' | ||
| + | if (!target || !indicator) return; | ||
| + | var collapsed = target.getAttribute(' | ||
| + | collapsed = !collapsed; | ||
| + | target.setAttribute(' | ||
| + | target.style.display = collapsed ? ' | ||
| + | indicator.innerText = collapsed ? ' | ||
| + | } | ||
| + | |||
| + | function initCollapsibles() { | ||
| + | [' | ||
| + | var el = document.getElementById(id); | ||
| + | var ind = document.getElementById(id + ' | ||
| + | if (!el || !ind) return; | ||
| + | el.style.display = ' | ||
| + | el.setAttribute(' | ||
| + | ind.innerText = ' | ||
| + | }); | ||
| + | } | ||
| + | |||
| + | function scrollToPiano() { | ||
| + | var piano = document.getElementById(' | ||
| + | if (!piano) return; | ||
| + | piano.scrollIntoView({ behavior: ' | ||
| + | } | ||
| + | |||
| + | function moveSection(id, | ||
| + | var stack = document.getElementById(' | ||
| + | if (!stack) return; | ||
| + | var section = stack.querySelector(' | ||
| + | if (!section) return; | ||
| + | var children = Array.from(stack.children); | ||
| + | var idx = children.indexOf(section); | ||
| + | if (idx === -1) return; | ||
| + | if (direction === ' | ||
| + | if (idx > 0) stack.insertBefore(section, | ||
| + | return; | ||
| + | } | ||
| + | if (direction === ' | ||
| + | stack.insertBefore(section, | ||
| + | } else if (direction === ' | ||
| + | stack.insertBefore(children[idx + 1], section); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | window.addEventListener(' | ||
| + | var note = KEY_MAP[e.key.toLowerCase()]; | ||
| + | if (note && !e.repeat) toggleNote(note); | ||
| + | }); | ||
| function toggleNote(note) { | function toggleNote(note) { | ||
| initAudio(); | initAudio(); | ||
| - | | + | |
| if (selectedNotes.has(note)) { | if (selectedNotes.has(note)) { | ||
| selectedNotes.delete(note); | selectedNotes.delete(note); | ||
| - | el.classList.remove(' | + | |
| } else { | } else { | ||
| selectedNotes.add(note); | selectedNotes.add(note); | ||
| - | el.classList.add(' | + | |
| - | synth.triggerAttackRelease(note, | + | |
| } | } | ||
| analyze(); | analyze(); | ||
| } | } | ||
| - | function | + | function |
| - | | + | |
| - | selectedNotes.clear(); | + | return note.replace(/([A-G])#/g, '$1♯').replace(/([A-G])b/g, ' |
| - | analyze(); | + | |
| } | } | ||
| function analyze() { | function analyze() { | ||
| - | | + | |
| - | | + | |
| + | var analysisSection = document.getElementById(' | ||
| + | var vizSection | ||
| + | var analysisWrap = document.querySelector(' | ||
| + | | ||
| + | var chordNameEl = document.getElementById(' | ||
| + | updateChordSuggestionsVisibility(notes[0]); | ||
| + | | ||
| if (notes.length === 0) { | if (notes.length === 0) { | ||
| - | | + | |
| - | | + | |
| + | if (vizSection) vizSection.style.display = " | ||
| + | if (analysisWrap) analysisWrap.style.display = " | ||
| + | if (vizWrap) vizWrap.style.display = " | ||
| + | latestMetrics = []; | ||
| + | renderVisualization(CURRENT_VIZ, | ||
| return; | return; | ||
| } | } | ||
| - | + | if (analysisWrap) analysisWrap.style.display | |
| - | const detected = Tonal.Chord.detect(notes); | + | |
| - | document.getElementById(' | + | |
| - | + | ||
| - | | + | |
| - | const rootFreq = Tonal.Note.freq(rootNote); | + | |
| - | document.getElementById(' | + | |
| | | ||
| - | | + | |
| + | var detected = Tonal.Chord.detect(notes); | ||
| + | if (detected.length > 0) { | ||
| + | var primary = formatNote(detected[0]); | ||
| + | var alts = detected.slice(1, | ||
| + | chordNameEl.innerHTML = primary + (alts ? '< | ||
| + | } else { | ||
| + | chordNameEl.innerHTML = " | ||
| + | } | ||
| + | |||
| + | var rootFreq = Tonal.Note.freq(notes[0]); | ||
| + | var tbody = document.getElementById(' | ||
| tbody.innerHTML = ""; | tbody.innerHTML = ""; | ||
| - | + | | |
| - | notes.forEach(note => { | + | |
| - | | + | |
| - | | + | |
| - | const semiTotal = Tonal.Interval.semitones(interval); | + | |
| - | | + | |
| - | const octaveShift | + | |
| - | | + | |
| - | | + | |
| - | const ratioObj | + | var beat = Math.abs(perfFreq - tetFreq); |
| - | | + | return { note: note, tetFreq: tetFreq, perfFreq: perfFreq, drift: drift, beat: beat, idx: idx, oct: oct }; |
| - | | + | }); |
| - | const drift = 1200 * Math.log2(perfectFreq | + | |
| - | + | noteMetrics.forEach(function(m) { | |
| - | | + | var driftClass = Math.abs(m.drift) < 2 ? '' |
| - | tr.innerHTML = ` | + | |
| - | | + | tr.innerHTML = '<td>' + formatNote(m.note) + '</ |
| - | | + | |
| - | < | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | `; | + | |
| tbody.appendChild(tr); | tbody.appendChild(tr); | ||
| }); | }); | ||
| - | | + | |
| + | if (analysisSection) analysisSection.style.display = " | ||
| + | if (vizSection) vizSection.style.display = " | ||
| + | resizeCanvas(); | ||
| + | |||
| + | latestMetrics = noteMetrics; | ||
| + | renderVisualization(CURRENT_VIZ, | ||
| } | } | ||
| - | | + | |
| initAudio(); | initAudio(); | ||
| - | | + | |
| - | if (notes.length === 0) return; | + | return |
| - | + | }); | |
| - | | + | if (notes.length === 0 || !synth) return; |
| + | |||
| + | | ||
| if (type === ' | if (type === ' | ||
| - | | + | |
| - | | + | |
| + | var idx = ((semi % 12) + 12) % 12; | ||
| + | var ratio = CURRENT_TUNING.ratios[idx]; | ||
| + | return Tonal.Note.freq(notes[0]) * (ratio ? ratio.r : 1) * Math.pow(2, | ||
| }); | }); | ||
| + | | ||
| if (mode === ' | if (mode === ' | ||
| synth.triggerAttackRelease(freqs, | synth.triggerAttackRelease(freqs, | ||
| } else { | } else { | ||
| - | | + | |
| - | freqs.forEach((f, | + | freqs.forEach(function(f, i) { |
| + | | ||
| + | }); | ||
| } | } | ||
| - | } | + | }; |
| - | | + | |
| playCurrent(mode, | playCurrent(mode, | ||
| - | | + | |
| - | setTimeout(() | + | setTimeout(function() { playCurrent(mode, |
| - | } | + | }; |
| + | |||
| + | window.clearAll = function() { | ||
| + | selectedNotes.clear(); | ||
| + | syncKeyHighlights(); | ||
| + | analyze(); | ||
| + | }; | ||
| - | function | + | function |
| - | | + | |
| - | | + | if (selectedNotes.size > 0) params.set(' |
| - | | + | |
| - | | + | |
| - | | + | var base = window.location.origin + window.location.pathname; |
| - | | + | |
| + | | ||
| + | | ||
| + | return url; | ||
| } | } | ||
| - | | + | |
| - | | + | |
| - | | + | |
| - | const n = params.get(' | + | |
| - | if (n) { | + | |
| - | | + | |
| - | decodeURIComponent(n).split(',' | + | |
| - | selectedNotes.add(note); | + | |
| - | document.getElementById(" | + | |
| - | }); | + | |
| - | analyze(); | + | |
| } | } | ||
| + | var statusEl = document.getElementById(' | ||
| + | if (statusEl) { | ||
| + | statusEl.innerText = statusText; | ||
| + | setTimeout(function() { statusEl.innerText = " | ||
| + | } | ||
| + | return url; | ||
| + | } | ||
| + | |||
| + | window.generateLink = function() { | ||
| + | copyShareLink('', | ||
| }; | }; | ||
| + | |||
| + | window.shareVisualization = function() { | ||
| + | copyShareLink('# | ||
| + | }; | ||
| + | |||
| + | window.toggleSection = toggleSection; | ||
| + | window.moveSection = moveSection; | ||
| + | |||
| + | window.onload = initApp; | ||
| + | })(); | ||
| </ | </ | ||
| + | </ | ||
| </ | </ | ||
| + | |||