|
|
|
|
@ -312,6 +312,84 @@
|
|
|
|
|
color: var(--ink-soft); |
|
|
|
|
margin-top: 12px; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.chord-controls { |
|
|
|
|
display: flex; |
|
|
|
|
justify-content: center; |
|
|
|
|
gap: 12px; |
|
|
|
|
flex-wrap: wrap; |
|
|
|
|
margin-bottom: 18px; |
|
|
|
|
} |
|
|
|
|
.chord-controls label { |
|
|
|
|
display: flex; |
|
|
|
|
flex-direction: column; |
|
|
|
|
gap: 4px; |
|
|
|
|
font-size: 0.78rem; |
|
|
|
|
color: var(--ink-soft); |
|
|
|
|
} |
|
|
|
|
.chord-controls select { |
|
|
|
|
font-family: "Work Sans"; |
|
|
|
|
font-size: 0.9rem; |
|
|
|
|
color: var(--ink); |
|
|
|
|
background: var(--panel); |
|
|
|
|
border: 1px solid var(--rule); |
|
|
|
|
border-radius: 4px; |
|
|
|
|
padding: 6px 8px; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.chord-block-label { |
|
|
|
|
font-size: 0.78rem; |
|
|
|
|
color: var(--ink-soft); |
|
|
|
|
margin: 0 0 8px; |
|
|
|
|
text-align: center; |
|
|
|
|
} |
|
|
|
|
.chord-row { |
|
|
|
|
display: grid; |
|
|
|
|
grid-template-columns: repeat(7, 1fr); |
|
|
|
|
gap: 6px; |
|
|
|
|
margin-bottom: 8px; |
|
|
|
|
} |
|
|
|
|
.chord-box { |
|
|
|
|
display: flex; |
|
|
|
|
flex-direction: column; |
|
|
|
|
align-items: center; |
|
|
|
|
gap: 2px; |
|
|
|
|
background: var(--panel); |
|
|
|
|
border: 1px solid var(--rule); |
|
|
|
|
border-radius: 6px; |
|
|
|
|
padding: 10px 2px; |
|
|
|
|
} |
|
|
|
|
.chord-box .chord-name { |
|
|
|
|
font-family: "Fraunces", serif; |
|
|
|
|
font-weight: 600; |
|
|
|
|
font-size: 1.05rem; |
|
|
|
|
color: var(--walnut); |
|
|
|
|
} |
|
|
|
|
.chord-box .chord-roman { |
|
|
|
|
font-size: 0.68rem; |
|
|
|
|
color: var(--ink-soft); |
|
|
|
|
} |
|
|
|
|
.chord-row.transposed .chord-box { |
|
|
|
|
background: var(--brass-light); |
|
|
|
|
border-color: var(--brass); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.transpose-controls { |
|
|
|
|
display: flex; |
|
|
|
|
align-items: center; |
|
|
|
|
justify-content: center; |
|
|
|
|
gap: 12px; |
|
|
|
|
margin: 18px 0 20px; |
|
|
|
|
} |
|
|
|
|
.transpose-amount { |
|
|
|
|
font-family: "Fraunces", serif; |
|
|
|
|
font-weight: 600; |
|
|
|
|
font-size: 1.1rem; |
|
|
|
|
color: var(--walnut); |
|
|
|
|
min-width: 72px; |
|
|
|
|
text-align: center; |
|
|
|
|
font-variant-numeric: tabular-nums; |
|
|
|
|
} |
|
|
|
|
</style> |
|
|
|
|
</head> |
|
|
|
|
<body> |
|
|
|
|
@ -361,6 +439,40 @@
|
|
|
|
|
|
|
|
|
|
<hr class="divider" /> |
|
|
|
|
|
|
|
|
|
<h2>Chords</h2> |
|
|
|
|
|
|
|
|
|
<div class="chord-controls"> |
|
|
|
|
<label |
|
|
|
|
>Key |
|
|
|
|
<select id="keyRoot"></select> |
|
|
|
|
</label> |
|
|
|
|
<label |
|
|
|
|
>Scale |
|
|
|
|
<select id="scaleType"></select> |
|
|
|
|
</label> |
|
|
|
|
<label |
|
|
|
|
>Spelling |
|
|
|
|
<select id="spellingMode"> |
|
|
|
|
<option value="sharp">Sharps (♯)</option> |
|
|
|
|
<option value="flat">Flats (♭)</option> |
|
|
|
|
</select> |
|
|
|
|
</label> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<p class="chord-block-label" id="originalKeyLabel"></p> |
|
|
|
|
<div class="chord-row" id="originalChordRow"></div> |
|
|
|
|
|
|
|
|
|
<div class="transpose-controls"> |
|
|
|
|
<button class="stepper" id="transposeDown">–</button> |
|
|
|
|
<div class="transpose-amount" id="transposeAmount">0 st</div> |
|
|
|
|
<button class="stepper" id="transposeUp">+</button> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<p class="chord-block-label" id="transposedKeyLabel"></p> |
|
|
|
|
<div class="chord-row transposed" id="transposedChordRow"></div> |
|
|
|
|
|
|
|
|
|
<hr class="divider" /> |
|
|
|
|
|
|
|
|
|
<h2>Your tunes</h2> |
|
|
|
|
<div class="tune-list" id="tuneList"> |
|
|
|
|
<div class="empty" id="emptyMsg">Loading…</div> |
|
|
|
|
@ -583,6 +695,207 @@
|
|
|
|
|
tunerGrid.appendChild(btn); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
// ---------- Diatonic chords / transposer ---------- |
|
|
|
|
const NOTE_NAMES = [ |
|
|
|
|
"C", |
|
|
|
|
"C#", |
|
|
|
|
"D", |
|
|
|
|
"D#", |
|
|
|
|
"E", |
|
|
|
|
"F", |
|
|
|
|
"F#", |
|
|
|
|
"G", |
|
|
|
|
"G#", |
|
|
|
|
"A", |
|
|
|
|
"A#", |
|
|
|
|
"B", |
|
|
|
|
]; |
|
|
|
|
const FLAT_NAMES = [ |
|
|
|
|
"C", |
|
|
|
|
"Db", |
|
|
|
|
"D", |
|
|
|
|
"Eb", |
|
|
|
|
"E", |
|
|
|
|
"F", |
|
|
|
|
"Gb", |
|
|
|
|
"G", |
|
|
|
|
"Ab", |
|
|
|
|
"A", |
|
|
|
|
"Bb", |
|
|
|
|
"B", |
|
|
|
|
]; |
|
|
|
|
|
|
|
|
|
const SCALES = { |
|
|
|
|
Ionian: { label: "Major (Ionian)", steps: [0, 2, 4, 5, 7, 9, 11] }, |
|
|
|
|
Dorian: { label: "Dorian", steps: [0, 2, 3, 5, 7, 9, 10] }, |
|
|
|
|
Phrygian: { label: "Phrygian", steps: [0, 1, 3, 5, 7, 8, 10] }, |
|
|
|
|
Lydian: { label: "Lydian", steps: [0, 2, 4, 6, 7, 9, 11] }, |
|
|
|
|
Mixolydian: { label: "Mixolydian", steps: [0, 2, 4, 5, 7, 9, 10] }, |
|
|
|
|
Aeolian: { label: "Minor (Aeolian)", steps: [0, 2, 3, 5, 7, 8, 10] }, |
|
|
|
|
Locrian: { label: "Locrian", steps: [0, 1, 3, 5, 6, 8, 10] }, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
let spellingMode = "sharp"; |
|
|
|
|
|
|
|
|
|
function noteName(index) { |
|
|
|
|
const table = spellingMode === "flat" ? FLAT_NAMES : NOTE_NAMES; |
|
|
|
|
return table[((index % 12) + 12) % 12]; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Build the 7 diatonic triads for a given root index (0-11) and scale key. |
|
|
|
|
function buildDiatonicChords(rootIndex, scaleKey) { |
|
|
|
|
const steps = SCALES[scaleKey].steps; |
|
|
|
|
const scaleNotes = steps.map((s) => (rootIndex + s) % 12); |
|
|
|
|
|
|
|
|
|
return scaleNotes.map((deg, i) => { |
|
|
|
|
const root = scaleNotes[i]; |
|
|
|
|
const third = scaleNotes[(i + 2) % 7]; |
|
|
|
|
const fifth = scaleNotes[(i + 4) % 7]; |
|
|
|
|
|
|
|
|
|
const rootToThird = (third - root + 12) % 12; |
|
|
|
|
const thirdToFifth = (fifth - third + 12) % 12; |
|
|
|
|
|
|
|
|
|
let quality, romanBase; |
|
|
|
|
if (rootToThird === 4 && thirdToFifth === 3) { |
|
|
|
|
quality = ""; |
|
|
|
|
romanBase = "maj"; |
|
|
|
|
} else if (rootToThird === 3 && thirdToFifth === 4) { |
|
|
|
|
quality = "m"; |
|
|
|
|
romanBase = "min"; |
|
|
|
|
} else if (rootToThird === 3 && thirdToFifth === 3) { |
|
|
|
|
quality = "dim"; |
|
|
|
|
romanBase = "dim"; |
|
|
|
|
} else if (rootToThird === 4 && thirdToFifth === 4) { |
|
|
|
|
quality = "aug"; |
|
|
|
|
romanBase = "aug"; |
|
|
|
|
} else { |
|
|
|
|
quality = ""; |
|
|
|
|
romanBase = "maj"; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const numerals = ["I", "II", "III", "IV", "V", "VI", "VII"]; |
|
|
|
|
let roman = numerals[i]; |
|
|
|
|
if (romanBase === "min") roman = roman.toLowerCase(); |
|
|
|
|
if (romanBase === "dim") roman = roman.toLowerCase() + "°"; |
|
|
|
|
if (romanBase === "aug") roman = roman + "+"; |
|
|
|
|
|
|
|
|
|
return { |
|
|
|
|
name: noteName(root) + quality, |
|
|
|
|
roman: roman, |
|
|
|
|
}; |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const keyRootSelect = document.getElementById("keyRoot"); |
|
|
|
|
const scaleTypeSelect = document.getElementById("scaleType"); |
|
|
|
|
const spellingModeSelect = document.getElementById("spellingMode"); |
|
|
|
|
const originalChordRow = document.getElementById("originalChordRow"); |
|
|
|
|
const transposedChordRow = document.getElementById("transposedChordRow"); |
|
|
|
|
const originalKeyLabel = document.getElementById("originalKeyLabel"); |
|
|
|
|
const transposedKeyLabel = document.getElementById("transposedKeyLabel"); |
|
|
|
|
const transposeAmountEl = document.getElementById("transposeAmount"); |
|
|
|
|
const transposeDownBtn = document.getElementById("transposeDown"); |
|
|
|
|
const transposeUpBtn = document.getElementById("transposeUp"); |
|
|
|
|
|
|
|
|
|
function populateKeyOptions() { |
|
|
|
|
const prevValue = keyRootSelect.value; |
|
|
|
|
keyRootSelect.innerHTML = ""; |
|
|
|
|
NOTE_NAMES.forEach((_, i) => { |
|
|
|
|
const opt = document.createElement("option"); |
|
|
|
|
opt.value = i; |
|
|
|
|
opt.textContent = noteName(i); |
|
|
|
|
keyRootSelect.appendChild(opt); |
|
|
|
|
}); |
|
|
|
|
if (prevValue !== "") keyRootSelect.value = prevValue; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
populateKeyOptions(); |
|
|
|
|
|
|
|
|
|
Object.keys(SCALES).forEach((key) => { |
|
|
|
|
const opt = document.createElement("option"); |
|
|
|
|
opt.value = key; |
|
|
|
|
opt.textContent = SCALES[key].label; |
|
|
|
|
scaleTypeSelect.appendChild(opt); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
let chordRootIndex = 0; // C |
|
|
|
|
let chordScaleKey = "Ionian"; |
|
|
|
|
let transposeSemitones = 0; |
|
|
|
|
|
|
|
|
|
function renderChordRow(container, chords) { |
|
|
|
|
container.innerHTML = ""; |
|
|
|
|
chords.forEach((c) => { |
|
|
|
|
const box = document.createElement("div"); |
|
|
|
|
box.className = "chord-box"; |
|
|
|
|
const name = document.createElement("div"); |
|
|
|
|
name.className = "chord-name"; |
|
|
|
|
name.textContent = c.name; |
|
|
|
|
const roman = document.createElement("div"); |
|
|
|
|
roman.className = "chord-roman"; |
|
|
|
|
roman.textContent = c.roman; |
|
|
|
|
box.appendChild(name); |
|
|
|
|
box.appendChild(roman); |
|
|
|
|
container.appendChild(box); |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderChords() { |
|
|
|
|
const scaleLabel = SCALES[chordScaleKey].label; |
|
|
|
|
|
|
|
|
|
const originalChords = buildDiatonicChords( |
|
|
|
|
chordRootIndex, |
|
|
|
|
chordScaleKey, |
|
|
|
|
); |
|
|
|
|
originalKeyLabel.textContent = |
|
|
|
|
noteName(chordRootIndex) + " " + scaleLabel; |
|
|
|
|
renderChordRow(originalChordRow, originalChords); |
|
|
|
|
|
|
|
|
|
const transposedRoot = chordRootIndex + transposeSemitones; |
|
|
|
|
const transposedChords = buildDiatonicChords( |
|
|
|
|
transposedRoot, |
|
|
|
|
chordScaleKey, |
|
|
|
|
); |
|
|
|
|
transposedKeyLabel.textContent = |
|
|
|
|
noteName(transposedRoot) + |
|
|
|
|
" " + |
|
|
|
|
scaleLabel + |
|
|
|
|
" (" + |
|
|
|
|
(transposeSemitones > 0 ? "+" : "") + |
|
|
|
|
transposeSemitones + |
|
|
|
|
" semitones)"; |
|
|
|
|
renderChordRow(transposedChordRow, transposedChords); |
|
|
|
|
|
|
|
|
|
transposeAmountEl.textContent = |
|
|
|
|
(transposeSemitones > 0 ? "+" : "") + transposeSemitones + " st"; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
keyRootSelect.addEventListener("change", (e) => { |
|
|
|
|
chordRootIndex = parseInt(e.target.value, 10); |
|
|
|
|
renderChords(); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
scaleTypeSelect.addEventListener("change", (e) => { |
|
|
|
|
chordScaleKey = e.target.value; |
|
|
|
|
renderChords(); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
spellingModeSelect.addEventListener("change", (e) => { |
|
|
|
|
spellingMode = e.target.value; |
|
|
|
|
populateKeyOptions(); |
|
|
|
|
keyRootSelect.value = chordRootIndex; |
|
|
|
|
renderChords(); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
transposeDownBtn.addEventListener("click", () => { |
|
|
|
|
transposeSemitones = Math.max(-24, transposeSemitones - 1); |
|
|
|
|
renderChords(); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
transposeUpBtn.addEventListener("click", () => { |
|
|
|
|
transposeSemitones = Math.min(24, transposeSemitones + 1); |
|
|
|
|
renderChords(); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
// ---------- Tune storage (browser-local cache + JSON export/import) ---------- |
|
|
|
|
const tuneList = document.getElementById("tuneList"); |
|
|
|
|
const emptyMsg = document.getElementById("emptyMsg"); |
|
|
|
|
@ -737,6 +1050,7 @@
|
|
|
|
|
// init |
|
|
|
|
setBpm(120); |
|
|
|
|
loadTunes(); |
|
|
|
|
renderChords(); |
|
|
|
|
</script> |
|
|
|
|
</body> |
|
|
|
|
</html> |
|
|
|
|
|