Files
TaroTime/app/ui-audio-circle.js

408 lines
14 KiB
JavaScript
Raw Permalink Normal View History

2026-03-14 00:45:15 -07:00
(() => {
"use strict";
const CIRCLE_ENTRIES = [
{ id: "c", major: "C", relativeMinor: "Am", pitchClass: 0, frequencyLabel: "C4" },
{ id: "g", major: "G", relativeMinor: "Em", pitchClass: 7, frequencyLabel: "G4" },
{ id: "d", major: "D", relativeMinor: "Bm", pitchClass: 2, frequencyLabel: "D4" },
{ id: "a", major: "A", relativeMinor: "F#m", pitchClass: 9, frequencyLabel: "A4" },
{ id: "e", major: "E", relativeMinor: "C#m", pitchClass: 4, frequencyLabel: "E4" },
{ id: "b", major: "B", relativeMinor: "G#m", pitchClass: 11, frequencyLabel: "B4" },
{ id: "f-sharp-g-flat", major: "F#/Gb", relativeMinor: "D#m/Ebm", pitchClass: 6, frequencyLabel: "F#/Gb4" },
{ id: "d-flat", major: "Db", relativeMinor: "Bbm", pitchClass: 1, frequencyLabel: "Db4" },
{ id: "a-flat", major: "Ab", relativeMinor: "Fm", pitchClass: 8, frequencyLabel: "Ab4" },
{ id: "e-flat", major: "Eb", relativeMinor: "Cm", pitchClass: 3, frequencyLabel: "Eb4" },
{ id: "b-flat", major: "Bb", relativeMinor: "Gm", pitchClass: 10, frequencyLabel: "Bb4" },
{ id: "f", major: "F", relativeMinor: "Dm", pitchClass: 5, frequencyLabel: "F4" }
];
const state = {
initialized: false,
selectedId: "c",
audioContext: null,
oscillatorNode: null,
gainNode: null,
stopTimerId: 0,
activePlaybackKey: ""
};
function getElements() {
return {
stageEl: document.getElementById("audio-circle-stage"),
detailNameEl: document.getElementById("audio-circle-detail-name"),
detailSubEl: document.getElementById("audio-circle-detail-sub"),
detailSummaryEl: document.getElementById("audio-circle-detail-summary"),
detailBodyEl: document.getElementById("audio-circle-detail-body")
};
}
function getMidiNumber(pitchClass, octave) {
return ((octave + 1) * 12) + pitchClass;
}
function getFrequencyHz(pitchClass, octave) {
const midiNumber = getMidiNumber(pitchClass, octave);
return 440 * Math.pow(2, (midiNumber - 69) / 12);
}
function formatFrequencyHz(value) {
return `${value.toFixed(2)} Hz`;
}
function findEntryById(id) {
return CIRCLE_ENTRIES.find((entry) => entry.id === id) || null;
}
function getSelectedEntry() {
return findEntryById(state.selectedId) || CIRCLE_ENTRIES[0];
}
function getAudioContext() {
if (state.audioContext) {
return state.audioContext;
}
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
if (typeof AudioContextCtor !== "function") {
return null;
}
state.audioContext = new AudioContextCtor();
return state.audioContext;
}
function clearStopTimer() {
if (state.stopTimerId) {
window.clearTimeout(state.stopTimerId);
state.stopTimerId = 0;
}
}
function clearActivePlayback(shouldRender = true) {
state.oscillatorNode = null;
state.gainNode = null;
state.activePlaybackKey = "";
clearStopTimer();
if (shouldRender) {
render();
}
}
function stopPlayback(shouldRender = true) {
clearStopTimer();
const oscillatorNode = state.oscillatorNode;
const gainNode = state.gainNode;
const audioContext = state.audioContext;
state.oscillatorNode = null;
state.gainNode = null;
state.activePlaybackKey = "";
if (oscillatorNode && gainNode && audioContext) {
const now = audioContext.currentTime;
gainNode.gain.cancelScheduledValues(now);
gainNode.gain.setValueAtTime(Math.max(gainNode.gain.value, 0.0001), now);
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 0.06);
try {
oscillatorNode.stop(now + 0.07);
} catch (_error) {
}
}
if (shouldRender) {
render();
}
}
async function playFrequency(frequencyHz, playbackKey) {
const audioContext = getAudioContext();
if (!audioContext) {
return;
}
if (state.activePlaybackKey === playbackKey) {
stopPlayback(true);
return;
}
if (audioContext.state === "suspended") {
await audioContext.resume();
}
stopPlayback(false);
const oscillatorNode = audioContext.createOscillator();
const gainNode = audioContext.createGain();
const now = audioContext.currentTime;
oscillatorNode.type = "sine";
oscillatorNode.frequency.setValueAtTime(frequencyHz, now);
gainNode.gain.setValueAtTime(0.0001, now);
gainNode.gain.exponentialRampToValueAtTime(0.16, now + 0.03);
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 1.1);
oscillatorNode.connect(gainNode);
gainNode.connect(audioContext.destination);
state.oscillatorNode = oscillatorNode;
state.gainNode = gainNode;
state.activePlaybackKey = playbackKey;
oscillatorNode.onended = () => {
if (state.oscillatorNode === oscillatorNode || state.activePlaybackKey === playbackKey) {
clearActivePlayback(true);
}
};
oscillatorNode.start(now);
oscillatorNode.stop(now + 1.12);
state.stopTimerId = window.setTimeout(() => {
clearActivePlayback(true);
}, 1250);
render();
}
function createMetaCard(title) {
const card = document.createElement("div");
card.className = "planet-meta-card";
const heading = document.createElement("strong");
heading.textContent = title;
card.appendChild(heading);
return card;
}
function renderCircleStage(elements) {
if (!elements.stageEl) {
return;
}
const shell = document.createElement("div");
shell.className = "audio-circle-shell";
const center = document.createElement("div");
center.className = "audio-circle-center";
const selectedEntry = getSelectedEntry();
center.innerHTML = `
<div>
<div class="audio-circle-center-label">${selectedEntry.major}</div>
<div class="audio-circle-center-sub">Relative minor ${selectedEntry.relativeMinor}<br>Circle of Fifths</div>
</div>
`;
shell.appendChild(center);
const radius = 41;
CIRCLE_ENTRIES.forEach((entry, index) => {
const angleDegrees = -90 + (index * 30);
const angleRadians = (angleDegrees * Math.PI) / 180;
const x = 50 + (Math.cos(angleRadians) * radius);
const y = 50 + (Math.sin(angleRadians) * radius);
const playbackKey = `circle:${entry.id}`;
const isSelected = entry.id === state.selectedId;
const isPlaying = state.activePlaybackKey === playbackKey;
const button = document.createElement("button");
button.type = "button";
button.className = `audio-circle-key${isSelected || isPlaying ? " is-selected" : ""}`;
button.dataset.entryId = entry.id;
button.dataset.action = "play-entry";
button.dataset.playbackKey = playbackKey;
button.dataset.frequencyHz = String(getFrequencyHz(entry.pitchClass, 4));
button.style.left = `${x}%`;
button.style.top = `${y}%`;
button.style.transform = "translate(-50%, -50%)";
button.setAttribute("aria-pressed", isPlaying ? "true" : "false");
button.setAttribute("aria-label", `${isPlaying ? "Stop" : "Play"} ${entry.major}`);
const major = document.createElement("div");
major.className = "audio-circle-key-major";
major.textContent = entry.major;
const minor = document.createElement("div");
minor.className = "audio-circle-key-minor";
minor.textContent = entry.relativeMinor;
const meta = document.createElement("div");
meta.className = "audio-circle-key-meta";
meta.textContent = isPlaying ? "Playing" : "Tap";
button.append(major, minor, meta);
shell.appendChild(button);
});
elements.stageEl.replaceChildren(shell);
}
function renderDetail(elements) {
const entry = getSelectedEntry();
if (!elements.detailNameEl || !elements.detailSubEl || !elements.detailSummaryEl || !elements.detailBodyEl) {
return;
}
const entryIndex = CIRCLE_ENTRIES.findIndex((item) => item.id === entry.id);
const clockwiseEntry = CIRCLE_ENTRIES[(entryIndex + 1) % CIRCLE_ENTRIES.length];
const counterClockwiseEntry = CIRCLE_ENTRIES[(entryIndex - 1 + CIRCLE_ENTRIES.length) % CIRCLE_ENTRIES.length];
const frequencyHz = getFrequencyHz(entry.pitchClass, 4);
elements.detailNameEl.textContent = `${entry.major} Major`;
elements.detailSubEl.textContent = `${entry.relativeMinor} relative minor · tonic ${entry.frequencyLabel}`;
elements.detailSummaryEl.textContent = `${entry.major} sits between ${counterClockwiseEntry.major} and ${clockwiseEntry.major} on the Circle of Fifths.`;
const grid = document.createElement("div");
grid.className = "planet-meta-grid";
const overviewCard = createMetaCard("Key Overview");
overviewCard.innerHTML += `
<dl class="alpha-dl">
<dt>Major key</dt><dd>${entry.major}</dd>
<dt>Relative minor</dt><dd>${entry.relativeMinor}</dd>
<dt>Tonic pitch</dt><dd>${entry.frequencyLabel}</dd>
<dt>Tonic frequency</dt><dd>${formatFrequencyHz(frequencyHz)}</dd>
<dt>Clockwise neighbor</dt><dd>${clockwiseEntry.major}</dd>
<dt>Counter-clockwise neighbor</dt><dd>${counterClockwiseEntry.major}</dd>
</dl>
`;
const chipRow = document.createElement("div");
chipRow.className = "audio-circle-chip-row";
[entry.major, entry.relativeMinor, clockwiseEntry.major, counterClockwiseEntry.major].forEach((label, index) => {
const chip = document.createElement("span");
chip.className = `audio-circle-chip${index === 0 ? " audio-circle-chip--active" : ""}`;
chip.textContent = label;
chipRow.appendChild(chip);
});
overviewCard.appendChild(chipRow);
const explanationCard = createMetaCard("How To Read The Circle");
const explanation = document.createElement("div");
explanation.className = "audio-circle-copy";
explanation.textContent = "Moving clockwise adds one sharp and raises the tonic by a perfect fifth. Moving counter-clockwise adds one flat and returns by a perfect fourth. The inner minor labels show the relative minor that shares the same key signature.";
explanationCard.appendChild(explanation);
const playbackCard = createMetaCard("Tone Preview");
const playbackCopy = document.createElement("div");
playbackCopy.className = "audio-circle-copy";
playbackCopy.textContent = `Tap any key on the circle to hear its tonic. ${entry.major} is currently centered at ${formatFrequencyHz(frequencyHz)}.`;
const playbackActions = document.createElement("div");
playbackActions.className = "audio-note-playback-actions";
const playButton = document.createElement("button");
playButton.type = "button";
playButton.className = "alpha-nav-btn audio-note-playback-btn";
playButton.dataset.action = "play-entry";
playButton.dataset.entryId = entry.id;
playButton.dataset.playbackKey = `circle:${entry.id}`;
playButton.dataset.frequencyHz = String(frequencyHz);
playButton.textContent = `Play ${entry.major}`;
const stopButton = document.createElement("button");
stopButton.type = "button";
stopButton.className = "alpha-nav-btn alpha-nav-btn--ghost audio-note-playback-btn";
stopButton.dataset.action = "stop-playback";
stopButton.textContent = "Stop";
playbackActions.append(playButton, stopButton);
playbackCard.append(playbackCopy, playbackActions);
const navCard = createMetaCard("Neighbors");
const navGrid = document.createElement("div");
navGrid.className = "audio-circle-nav-grid";
[
{ label: "Counter-clockwise", entry: counterClockwiseEntry },
{ label: "Clockwise", entry: clockwiseEntry }
].forEach(({ label, entry: neighbor }) => {
const card = document.createElement("div");
card.className = "audio-circle-nav-card";
card.innerHTML = `
<div class="audio-circle-nav-label">${label}</div>
<div class="audio-circle-nav-value">${neighbor.major}</div>
<div class="audio-circle-nav-sub">Relative minor ${neighbor.relativeMinor}</div>
`;
navGrid.appendChild(card);
});
navCard.appendChild(navGrid);
grid.append(overviewCard, explanationCard, playbackCard, navCard);
elements.detailBodyEl.replaceChildren(grid);
}
function render() {
const elements = getElements();
renderCircleStage(elements);
renderDetail(elements);
}
function selectEntry(id, shouldRender = true) {
if (!findEntryById(id)) {
return;
}
state.selectedId = id;
if (shouldRender) {
render();
}
}
function bindEvents() {
const elements = getElements();
if (!elements.stageEl || !elements.detailBodyEl) {
return;
}
const clickHandler = (event) => {
const button = event.target instanceof Element ? event.target.closest("button[data-action]") : null;
if (!(button instanceof HTMLButtonElement)) {
return;
}
const action = button.dataset.action || "";
if (action === "stop-playback") {
stopPlayback(true);
return;
}
if (action === "play-entry") {
const entryId = String(button.dataset.entryId || "").trim();
const playbackKey = String(button.dataset.playbackKey || "").trim();
const frequencyHz = Number(button.dataset.frequencyHz);
if (!entryId || !playbackKey || !Number.isFinite(frequencyHz)) {
return;
}
selectEntry(entryId, false);
void playFrequency(frequencyHz, playbackKey);
}
};
elements.stageEl.addEventListener("click", clickHandler);
elements.detailBodyEl.addEventListener("click", clickHandler);
window.addEventListener("beforeunload", () => {
stopPlayback(false);
});
}
function init() {
if (state.initialized) {
render();
return;
}
bindEvents();
render();
state.initialized = true;
}
function ensureAudioCircleSection() {
init();
}
window.AudioCircleUi = {
...(window.AudioCircleUi || {}),
init,
ensureAudioCircleSection,
selectEntry
};
})();