Initial Commit
This commit is contained in:
906
app.js
Normal file
906
app.js
Normal file
@@ -0,0 +1,906 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_TAB_URL = "haseen.tab.json";
|
||||
const DEFAULT_PRACTICE_COUNTS = 4;
|
||||
|
||||
const state = {
|
||||
data: null,
|
||||
arrangementIndex: 0,
|
||||
barIndex: 0,
|
||||
beatIndex: 0,
|
||||
tempo: 82,
|
||||
playing: false,
|
||||
timer: null,
|
||||
audioContext: null,
|
||||
zoomStep: 0,
|
||||
sourceName: DEFAULT_TAB_URL,
|
||||
sourceKind: "default",
|
||||
sourceUrl: null,
|
||||
};
|
||||
|
||||
const dom = {};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
async function init() {
|
||||
cacheDom();
|
||||
bindEvents();
|
||||
|
||||
if (new URLSearchParams(window.location.search).get("embed") === "1") {
|
||||
document.body.classList.add("embed");
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const requestedSource = params.get("src");
|
||||
const sourceUrl = requestedSource ? safeHttpUrl(requestedSource) : null;
|
||||
if (requestedSource && !sourceUrl) {
|
||||
throw new Error("The src parameter must be an http(s) URL");
|
||||
}
|
||||
const fetchUrl = sourceUrl || DEFAULT_TAB_URL;
|
||||
const response = await fetch(fetchUrl);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
loadTabData(data, requestedSource || DEFAULT_TAB_URL, requestedSource ? "url" : "default", sourceUrl);
|
||||
} catch (error) {
|
||||
showError(
|
||||
`The sample JSON could not be fetched (${error.message}). Open this folder through a small local web server, or use “Load tab JSON” to choose haseen.tab.json directly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function cacheDom() {
|
||||
const ids = [
|
||||
"source-label",
|
||||
"song-title",
|
||||
"artist-line",
|
||||
"metadata-row",
|
||||
"arrangement-picker",
|
||||
"arrangement-description",
|
||||
"play-button",
|
||||
"now-playing",
|
||||
"previous-button",
|
||||
"next-button",
|
||||
"bar-position",
|
||||
"beat-position",
|
||||
"progress-fill",
|
||||
"beat-dots",
|
||||
"tempo-range",
|
||||
"tempo-output",
|
||||
"metronome-toggle",
|
||||
"loop-toggle",
|
||||
"practice-note",
|
||||
"section-index",
|
||||
"sheet-title",
|
||||
"zoom-out",
|
||||
"zoom-in",
|
||||
"tab-sheet",
|
||||
"legend-list",
|
||||
"stat-arrangements",
|
||||
"stat-bars",
|
||||
"stat-strings",
|
||||
"copy-embed",
|
||||
"attribution",
|
||||
"tab-file",
|
||||
"load-tab-button",
|
||||
"view-source-link",
|
||||
"viewer-status",
|
||||
"toast",
|
||||
"error-panel",
|
||||
"error-message",
|
||||
];
|
||||
|
||||
ids.forEach((id) => {
|
||||
dom[toCamelCase(id)] = document.getElementById(id);
|
||||
});
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
dom.playButton.addEventListener("click", togglePractice);
|
||||
dom.previousButton.addEventListener("click", () => moveBar(-1, true));
|
||||
dom.nextButton.addEventListener("click", () => moveBar(1, true));
|
||||
dom.tempoRange.addEventListener("input", handleTempoChange);
|
||||
dom.zoomOut.addEventListener("click", () => changeZoom(-1));
|
||||
dom.zoomIn.addEventListener("click", () => changeZoom(1));
|
||||
dom.loadTabButton.addEventListener("click", () => dom.tabFile.click());
|
||||
dom.tabFile.addEventListener("change", handleFileLoad);
|
||||
dom.copyEmbed.addEventListener("click", copyEmbedSnippet);
|
||||
document.addEventListener("keydown", handleKeyboard);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden && state.playing) stopPractice();
|
||||
});
|
||||
}
|
||||
|
||||
function loadTabData(data, sourceName, sourceKind = "file", sourceUrl = null) {
|
||||
const validation = validateTabData(data);
|
||||
if (validation.errors.length) {
|
||||
showError(validation.errors.join(" "));
|
||||
return false;
|
||||
}
|
||||
|
||||
hideError();
|
||||
stopPractice();
|
||||
state.data = data;
|
||||
state.sourceName = sourceName;
|
||||
state.sourceKind = sourceKind;
|
||||
state.sourceUrl = sourceUrl;
|
||||
state.arrangementIndex = 0;
|
||||
state.barIndex = 0;
|
||||
state.beatIndex = 0;
|
||||
state.tempo = clamp(Math.round(data.timing?.tempoBpm || 82), 30, 240);
|
||||
dom.tempoRange.value = String(state.tempo);
|
||||
dom.tempoOutput.value = String(state.tempo);
|
||||
|
||||
renderAll();
|
||||
updateSourceLink();
|
||||
dom.viewerStatus.textContent = `${data.metadata.title} loaded with ${flattenBars(activeArrangement()).length} bars.`;
|
||||
if (validation.warnings.length) showToast(validation.warnings[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateTabData(data) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const ids = new Set();
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
return { errors: ["The root value must be a JSON object."], warnings };
|
||||
}
|
||||
if (data.format !== "guitar-tab/v1") errors.push("Expected format \"guitar-tab/v1\".");
|
||||
if (!data.metadata?.title) errors.push("metadata.title is required.");
|
||||
if (!Array.isArray(data.metadata?.artists) || !data.metadata.artists.length) {
|
||||
errors.push("metadata.artists must contain at least one artist.");
|
||||
}
|
||||
const stringCount = data.instrument?.strings;
|
||||
if (!Number.isInteger(stringCount) || stringCount < 1 || stringCount > 18) {
|
||||
errors.push("instrument.strings must be a positive integer.");
|
||||
}
|
||||
const tuning = Array.isArray(data.instrument?.tuning) ? data.instrument.tuning : [];
|
||||
if (!tuning.length) {
|
||||
errors.push("instrument.tuning is required.");
|
||||
} else if (Number.isInteger(stringCount)) {
|
||||
const tuningNumbers = tuning.map((item) => item?.string);
|
||||
const expected = Array.from({ length: stringCount }, (_, index) => index + 1);
|
||||
if (
|
||||
tuning.length !== stringCount ||
|
||||
new Set(tuningNumbers).size !== tuningNumbers.length ||
|
||||
tuningNumbers.some((number, index) => number !== expected[index])
|
||||
) {
|
||||
errors.push(`instrument.tuning must list strings 1 through ${stringCount} exactly once, in order.`);
|
||||
}
|
||||
}
|
||||
if (data.timing !== undefined && (!data.timing || typeof data.timing !== "object")) {
|
||||
errors.push("timing must be an object when present.");
|
||||
}
|
||||
if (
|
||||
data.timing?.tempoBpm !== undefined &&
|
||||
(!Number.isFinite(data.timing.tempoBpm) || data.timing.tempoBpm <= 0 || data.timing.tempoBpm > 240)
|
||||
) {
|
||||
errors.push("timing.tempoBpm must be a positive number no greater than 240 when present.");
|
||||
}
|
||||
if (
|
||||
data.timing?.timeSignature &&
|
||||
(!Number.isInteger(data.timing.timeSignature.beats) ||
|
||||
data.timing.timeSignature.beats < 1 ||
|
||||
data.timing.timeSignature.beats > 32)
|
||||
) {
|
||||
errors.push("timing.timeSignature.beats must be an integer from 1 to 32.");
|
||||
}
|
||||
if (!Array.isArray(data.arrangements) || !data.arrangements.length) {
|
||||
errors.push("arrangements must contain at least one arrangement.");
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
if (data.legend !== undefined && !Array.isArray(data.legend)) {
|
||||
errors.push("legend must be an array when present.");
|
||||
}
|
||||
if (data.metadata?.links !== undefined && !Array.isArray(data.metadata.links)) {
|
||||
errors.push("metadata.links must be an array when present.");
|
||||
}
|
||||
const legendTokens = new Set((Array.isArray(data.legend) ? data.legend : []).map((item) => item?.token));
|
||||
|
||||
const registerId = (id, label) => {
|
||||
if (!id) {
|
||||
errors.push(`${label} is missing an id.`);
|
||||
} else if (ids.has(id)) {
|
||||
errors.push(`Duplicate id \"${id}\".`);
|
||||
} else {
|
||||
ids.add(id);
|
||||
}
|
||||
};
|
||||
|
||||
registerId(data.id, "Document");
|
||||
data.arrangements.forEach((arrangement, arrangementIndex) => {
|
||||
registerId(arrangement.id, `Arrangement ${arrangementIndex + 1}`);
|
||||
if (!arrangement.title) errors.push(`${arrangement.id || "An arrangement"} is missing a title.`);
|
||||
if (!Array.isArray(arrangement.sections) || !arrangement.sections.length) {
|
||||
errors.push(`${arrangement.id || "An arrangement"} must contain at least one section.`);
|
||||
return;
|
||||
}
|
||||
arrangement.sections.forEach((section, sectionIndex) => {
|
||||
registerId(section.id, `Section ${sectionIndex + 1}`);
|
||||
if (!Array.isArray(section.blocks) || !section.blocks.length) {
|
||||
errors.push(`${section.id || "A section"} must contain at least one block.`);
|
||||
return;
|
||||
}
|
||||
section.blocks.forEach((block, blockIndex) => {
|
||||
registerId(block.id, `Block ${blockIndex + 1}`);
|
||||
if (block.type === "tab") {
|
||||
if (!Array.isArray(block.bars) || !block.bars.length) {
|
||||
errors.push(`${block.id || "A tab block"} must contain at least one bar.`);
|
||||
return;
|
||||
}
|
||||
block.bars.forEach((bar, barIndex) => {
|
||||
registerId(bar.id, `Bar ${barIndex + 1}`);
|
||||
if (!Number.isInteger(bar.columnCount) || bar.columnCount < 1 || bar.columnCount > 4096) {
|
||||
errors.push(`${bar.id || "A bar"} needs columnCount between 1 and 4096.`);
|
||||
}
|
||||
if (bar.beats !== undefined && (!Number.isFinite(bar.beats) || bar.beats <= 0 || bar.beats > 32)) {
|
||||
errors.push(`${bar.id || "A bar"}.beats must be greater than 0 and no more than 32.`);
|
||||
}
|
||||
if (!Array.isArray(bar.rows) || !bar.rows.length) {
|
||||
errors.push(`${bar.id || "A bar"} has no tab rows.`);
|
||||
return;
|
||||
}
|
||||
if (bar.rows.length !== stringCount) {
|
||||
errors.push(
|
||||
`${bar.id} has ${bar.rows.length} rows; instrument.strings says ${stringCount}.`,
|
||||
);
|
||||
}
|
||||
const rowNumbers = bar.rows.map((row) => row?.string);
|
||||
const expectedRows = Array.from({ length: stringCount || 0 }, (_, index) => index + 1);
|
||||
if (
|
||||
Number.isInteger(stringCount) &&
|
||||
(new Set(rowNumbers).size !== rowNumbers.length ||
|
||||
rowNumbers.some((number, index) => number !== expectedRows[index]))
|
||||
) {
|
||||
errors.push(`${bar.id} must contain strings 1 through ${stringCount} exactly once, in order.`);
|
||||
}
|
||||
const rowTextsValid = bar.rows.every((row) => typeof row?.text === "string" && row.text.length > 0);
|
||||
if (!rowTextsValid) errors.push(`${bar.id} has a row without text.`);
|
||||
const widths = new Set(
|
||||
bar.rows.map((row) => (typeof row?.text === "string" ? [...row.text].length : -1)),
|
||||
);
|
||||
if (widths.size !== 1 || !widths.has(bar.columnCount)) {
|
||||
errors.push(`${bar.id} rows must all match columnCount ${bar.columnCount}.`);
|
||||
}
|
||||
const width = [...widths][0] || 0;
|
||||
if (bar.markers !== undefined && !Array.isArray(bar.markers)) {
|
||||
errors.push(`${bar.id}.markers must be an array.`);
|
||||
}
|
||||
["lyrics", "chords"].forEach((field) => {
|
||||
if (bar[field] !== undefined && !Array.isArray(bar[field])) {
|
||||
errors.push(`${bar.id}.${field} must be an array.`);
|
||||
return;
|
||||
}
|
||||
(Array.isArray(bar[field]) ? bar[field] : []).forEach((item) => {
|
||||
if (typeof item?.text !== "string" || !item.text.length) {
|
||||
errors.push(`${bar.id}.${field} contains an item without text.`);
|
||||
}
|
||||
if (
|
||||
item.atColumn !== undefined &&
|
||||
(!Number.isInteger(item.atColumn) || item.atColumn < 0 || item.atColumn >= width)
|
||||
) {
|
||||
errors.push(`${bar.id}.${field} contains an item outside its ${width}-column grid.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
(Array.isArray(bar.markers) ? bar.markers : []).forEach((marker) => {
|
||||
if (!Number.isInteger(marker.atColumn) || marker.atColumn < 0 || marker.atColumn >= width) {
|
||||
errors.push(`${bar.id} has a marker outside its ${width}-column grid.`);
|
||||
}
|
||||
if (!legendTokens.has(marker.token)) {
|
||||
errors.push(`${bar.id} uses marker token "${marker.token}" without a matching legend entry.`);
|
||||
}
|
||||
});
|
||||
if (bar.events !== undefined && !Array.isArray(bar.events)) {
|
||||
errors.push(`${bar.id}.events must be an array.`);
|
||||
}
|
||||
(Array.isArray(bar.events) ? bar.events : []).forEach((event) => {
|
||||
registerId(event.id, `Event in ${bar.id}`);
|
||||
if (!Number.isFinite(event.atBeat) || event.atBeat < 0) {
|
||||
errors.push(`${event.id || "An event"}.atBeat must be zero or greater.`);
|
||||
}
|
||||
if (!Number.isFinite(event.durationBeats) || event.durationBeats <= 0) {
|
||||
errors.push(`${event.id || "An event"}.durationBeats must be greater than zero.`);
|
||||
}
|
||||
if (
|
||||
bar.beats !== undefined &&
|
||||
Number.isFinite(event.atBeat) &&
|
||||
Number.isFinite(event.durationBeats) &&
|
||||
event.atBeat + event.durationBeats > bar.beats
|
||||
) {
|
||||
errors.push(`${event.id || "An event"} extends beyond ${bar.id}.beats.`);
|
||||
}
|
||||
if (!Array.isArray(event.notes) || !event.notes.length) {
|
||||
errors.push(`${event.id || "An event"} must contain at least one note.`);
|
||||
return;
|
||||
}
|
||||
event.notes.forEach((note) => {
|
||||
if (!Number.isInteger(note.string) || note.string < 1 || note.string > stringCount) {
|
||||
errors.push(`${event.id} contains a note on an invalid string.`);
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(note.from) ||
|
||||
!Number.isInteger(note.to) ||
|
||||
note.from < 0 ||
|
||||
note.to <= note.from ||
|
||||
note.to > width
|
||||
) {
|
||||
errors.push(`${event.id} contains an invalid note column range.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else if (block.type === "chordLyrics") {
|
||||
if (!Array.isArray(block.lines) || !block.lines.length) {
|
||||
errors.push(`${block.id || "A chord/lyric block"} must contain at least one line.`);
|
||||
return;
|
||||
}
|
||||
block.lines.forEach((line) => {
|
||||
registerId(line.id, "Chord/lyric line");
|
||||
if (!Array.isArray(line.spans) || !line.spans.length) {
|
||||
errors.push(`${line.id || "A chord/lyric line"} must contain at least one span.`);
|
||||
return;
|
||||
}
|
||||
line.spans.forEach((span) => registerId(span.id, "Chord/lyric span"));
|
||||
});
|
||||
} else {
|
||||
errors.push(`${block.id || "A block"} has unsupported type \"${block.type}\".`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (!data.timing?.timeSignature) {
|
||||
warnings.push("Source meter is unspecified; the player uses a clearly labelled 4-count practice guide.");
|
||||
}
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderMetadata();
|
||||
renderArrangementPicker();
|
||||
renderLegend();
|
||||
renderSheet();
|
||||
renderStats();
|
||||
renderPracticeState();
|
||||
}
|
||||
|
||||
function renderMetadata() {
|
||||
const { metadata, instrument, format } = state.data;
|
||||
const timing = state.data.timing || {};
|
||||
const source = metadata.source;
|
||||
dom.songTitle.textContent = metadata.title;
|
||||
document.title = `${metadata.title} — Interactive guitar tabs`;
|
||||
dom.sourceLabel.textContent = source?.file ? `from ${source.file}` : state.sourceName;
|
||||
|
||||
dom.artistLine.replaceChildren();
|
||||
dom.artistLine.append("by ");
|
||||
dom.artistLine.append(element("strong", "", metadata.artists.join(" & ")));
|
||||
if (metadata.arranger) dom.artistLine.append(` · arranged by ${metadata.arranger}`);
|
||||
|
||||
dom.metadataRow.replaceChildren(
|
||||
metadataPill("Tuning", instrument.tuningName || tuningLabel(instrument.tuning)),
|
||||
metadataPill("Capo", instrument.capo ? `fret ${instrument.capo}` : "none"),
|
||||
metadataPill("Tempo", timing.tempoBpm ? `${timing.tempoBpm} BPM` : "not stated"),
|
||||
metadataPill(
|
||||
"Meter",
|
||||
timing.timeSignature
|
||||
? `${timing.timeSignature.beats}/${timing.timeSignature.beatValue}`
|
||||
: "not stated",
|
||||
),
|
||||
);
|
||||
|
||||
const formatPill = document.querySelector(".format-pill");
|
||||
if (formatPill) formatPill.textContent = format;
|
||||
|
||||
dom.attribution.replaceChildren();
|
||||
if (metadata.arranger) {
|
||||
dom.attribution.append(`Arrangement: ${metadata.arranger}`);
|
||||
const preferredLink = metadata.links?.[0];
|
||||
const preferredUrl = preferredLink ? safeHttpUrl(preferredLink.url) : null;
|
||||
if (preferredLink && preferredUrl) {
|
||||
dom.attribution.append(" · ");
|
||||
const link = element("a", "", preferredLink.label);
|
||||
link.href = preferredUrl;
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
dom.attribution.append(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderArrangementPicker() {
|
||||
const arrangements = state.data.arrangements;
|
||||
document.body.classList.toggle("single-arrangement", arrangements.length === 1);
|
||||
dom.arrangementPicker.style.setProperty("--arrangement-count", arrangements.length);
|
||||
dom.arrangementPicker.replaceChildren();
|
||||
|
||||
arrangements.forEach((arrangement, index) => {
|
||||
const button = element("button", "segment-button", arrangement.title);
|
||||
button.type = "button";
|
||||
button.role = "tab";
|
||||
button.id = `arrangement-tab-${arrangement.id}`;
|
||||
button.setAttribute("aria-selected", String(index === state.arrangementIndex));
|
||||
button.tabIndex = index === state.arrangementIndex ? 0 : -1;
|
||||
button.addEventListener("click", () => selectArrangement(index));
|
||||
button.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowRight" ? 1 : -1;
|
||||
const next = (index + delta + arrangements.length) % arrangements.length;
|
||||
selectArrangement(next);
|
||||
dom.arrangementPicker.children[next]?.focus();
|
||||
});
|
||||
dom.arrangementPicker.append(button);
|
||||
});
|
||||
|
||||
const activeArrangement = arrangements[state.arrangementIndex];
|
||||
dom.arrangementDescription.textContent = activeArrangement.description || "";
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
dom.legendList.replaceChildren();
|
||||
(Array.isArray(state.data.legend) ? state.data.legend : []).forEach((item) => {
|
||||
dom.legendList.append(element("dt", "", item.token), element("dd", "", item.label));
|
||||
});
|
||||
}
|
||||
|
||||
function renderSheet() {
|
||||
const arrangement = activeArrangement();
|
||||
const barEntries = flattenBars(arrangement);
|
||||
const barIndexById = new Map(barEntries.map((entry, index) => [entry.bar.id, index]));
|
||||
dom.tabSheet.replaceChildren();
|
||||
|
||||
const sectionNames = arrangement.sections.map((section) => section.title).filter(Boolean);
|
||||
dom.sheetTitle.textContent = sectionNames.length === 1 ? sectionNames[0] : arrangement.title;
|
||||
dom.sectionIndex.textContent = `${String(state.arrangementIndex + 1).padStart(2, "0")} / ${arrangement.title}`;
|
||||
|
||||
arrangement.sections.forEach((section) => {
|
||||
const sectionElement = element("section", "tab-section");
|
||||
sectionElement.setAttribute("aria-label", section.title || "Untitled section");
|
||||
const sectionHeader = element("div", "tab-section-header");
|
||||
sectionHeader.append(element("h3", "", section.title || "Untitled section"));
|
||||
sectionElement.append(sectionHeader);
|
||||
|
||||
section.blocks.forEach((block) => {
|
||||
if (block.type === "tab") {
|
||||
const barGrid = element("div", "tab-system");
|
||||
block.bars.forEach((bar) => {
|
||||
barGrid.append(renderMeasure(bar, barIndexById.get(bar.id)));
|
||||
});
|
||||
sectionElement.append(barGrid);
|
||||
} else if (block.type === "chordLyrics") {
|
||||
sectionElement.append(renderChordLyricsBlock(block));
|
||||
}
|
||||
});
|
||||
|
||||
dom.tabSheet.append(sectionElement);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMeasure(bar, globalIndex) {
|
||||
const measure = element("article", "measure");
|
||||
measure.dataset.barIndex = String(globalIndex);
|
||||
measure.tabIndex = 0;
|
||||
measure.setAttribute("role", "button");
|
||||
measure.setAttribute("aria-label", `Select bar ${globalIndex + 1}`);
|
||||
if (globalIndex === state.barIndex) measure.classList.add("active");
|
||||
|
||||
const head = element("div", "measure-head");
|
||||
head.append(element("span", "measure-number", `Bar ${String(globalIndex + 1).padStart(2, "0")}`));
|
||||
const markerCount = bar.markers?.length || 0;
|
||||
head.append(
|
||||
element(
|
||||
"span",
|
||||
"bar-hint",
|
||||
markerCount ? `${markerCount} stroke${markerCount === 1 ? "" : "s"}` : "Tap to focus",
|
||||
),
|
||||
);
|
||||
measure.append(head);
|
||||
|
||||
const tabLines = element("div", "tab-lines");
|
||||
const width = bar.columnCount;
|
||||
if (bar.markers?.length) {
|
||||
const markerRow = element("div", "marker-row");
|
||||
markerRow.append(element("span", "string-label", "·"));
|
||||
const markerText = Array(width).fill(" ");
|
||||
bar.markers.forEach((marker) => {
|
||||
markerText[marker.atColumn] = marker.token;
|
||||
});
|
||||
markerRow.append(element("span", "tab-text", ` ${markerText.join("")} `));
|
||||
tabLines.append(markerRow);
|
||||
}
|
||||
|
||||
bar.rows.forEach((row) => {
|
||||
const line = element("div", "tab-row");
|
||||
const tuning = state.data.instrument.tuning.find((item) => item.string === row.string);
|
||||
line.append(element("span", "string-label", tuning?.label || String(row.string)));
|
||||
line.append(
|
||||
element(
|
||||
"span",
|
||||
"tab-text",
|
||||
`${barlineToken(bar.barline?.left, "left")}${row.text}${barlineToken(bar.barline?.right, "right")}`,
|
||||
),
|
||||
);
|
||||
tabLines.append(line);
|
||||
});
|
||||
measure.append(tabLines);
|
||||
|
||||
const lyricText = (Array.isArray(bar.lyrics) ? bar.lyrics : []).map((lyric) => lyric.text).join(" ");
|
||||
measure.append(element("p", "lyrics-line", lyricText || " "));
|
||||
|
||||
measure.addEventListener("click", () => selectBar(globalIndex, false));
|
||||
measure.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
selectBar(globalIndex, false);
|
||||
}
|
||||
});
|
||||
return measure;
|
||||
}
|
||||
|
||||
function renderChordLyricsBlock(block) {
|
||||
const container = element("div", "chord-lyrics-block");
|
||||
block.lines.forEach((line) => {
|
||||
const lineElement = element("div", "chord-line");
|
||||
line.spans.forEach((span) => {
|
||||
const spanElement = element("span", "chord-span");
|
||||
if (span.chord) spanElement.append(element("strong", "", span.chord));
|
||||
spanElement.append(element("span", "", span.text));
|
||||
lineElement.append(spanElement);
|
||||
});
|
||||
container.append(lineElement);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const arrangements = state.data.arrangements;
|
||||
dom.statArrangements.textContent = String(arrangements.length);
|
||||
dom.statBars.textContent = String(
|
||||
arrangements.reduce((total, arrangement) => total + flattenBars(arrangement).length, 0),
|
||||
);
|
||||
dom.statStrings.textContent = String(state.data.instrument.strings);
|
||||
}
|
||||
|
||||
function renderPracticeState({ scroll = false } = {}) {
|
||||
if (!state.data) return;
|
||||
const bars = flattenBars(activeArrangement());
|
||||
const hasBars = bars.length > 0;
|
||||
dom.playButton.disabled = !hasBars;
|
||||
dom.previousButton.disabled = !hasBars;
|
||||
dom.nextButton.disabled = !hasBars;
|
||||
|
||||
if (!hasBars) {
|
||||
dom.nowPlaying.textContent = "No tab bars in this arrangement";
|
||||
dom.barPosition.textContent = "No bars";
|
||||
dom.progressFill.style.width = "0%";
|
||||
dom.beatDots.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
state.barIndex = clamp(state.barIndex, 0, bars.length - 1);
|
||||
const current = bars[state.barIndex];
|
||||
const counts = practiceCounts(current.bar);
|
||||
state.beatIndex = clamp(state.beatIndex, 0, counts - 1);
|
||||
const lyrics = (Array.isArray(current.bar.lyrics) ? current.bar.lyrics : [])
|
||||
.map((line) => line.text)
|
||||
.join(" ");
|
||||
dom.nowPlaying.textContent = lyrics || `${current.section.title || "Section"} · Bar ${state.barIndex + 1}`;
|
||||
dom.barPosition.textContent = `Bar ${state.barIndex + 1} of ${bars.length}`;
|
||||
dom.beatPosition.textContent = state.playing ? `Count ${state.beatIndex + 1}` : "Ready";
|
||||
const progress = ((state.barIndex + (state.playing ? (state.beatIndex + 1) / counts : 0)) / bars.length) * 100;
|
||||
dom.progressFill.style.width = `${progress}%`;
|
||||
|
||||
dom.beatDots.replaceChildren();
|
||||
for (let index = 0; index < counts; index += 1) {
|
||||
const dot = element("span", "beat-dot");
|
||||
if (state.playing && index === state.beatIndex) dot.classList.add("active");
|
||||
dom.beatDots.append(dot);
|
||||
}
|
||||
|
||||
dom.playButton.querySelector(".play-icon").textContent = state.playing ? "Ⅱ" : "▶";
|
||||
dom.playButton.setAttribute("aria-label", state.playing ? "Pause practice guide" : "Start practice guide");
|
||||
|
||||
document.querySelectorAll(".measure").forEach((measure) => {
|
||||
const active = Number(measure.dataset.barIndex) === state.barIndex;
|
||||
measure.classList.toggle("active", active);
|
||||
measure.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
|
||||
const inferred = !state.data.timing?.timeSignature || bars.some(({ bar }) => !bar.beats);
|
||||
const sourceTempo = state.data.timing?.tempoBpm;
|
||||
dom.practiceNote.textContent = inferred
|
||||
? `${sourceTempo ? `The source states ${sourceTempo} BPM` : "The source does not state a tempo"}, but it does not state meter or exact beat mapping. The 4-count cursor is an optional practice aid, not transcribed notation.`
|
||||
: "The practice cursor follows the beat values encoded in the tab JSON.";
|
||||
|
||||
if (scroll) {
|
||||
document.querySelector(`.measure[data-bar-index="${state.barIndex}"]`)?.scrollIntoView({
|
||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function selectArrangement(index) {
|
||||
if (index === state.arrangementIndex || !state.data.arrangements[index]) return;
|
||||
stopPractice();
|
||||
state.arrangementIndex = index;
|
||||
state.barIndex = 0;
|
||||
state.beatIndex = 0;
|
||||
renderArrangementPicker();
|
||||
renderSheet();
|
||||
renderPracticeState();
|
||||
dom.viewerStatus.textContent = `${activeArrangement().title} selected.`;
|
||||
}
|
||||
|
||||
function selectBar(index, scroll) {
|
||||
const bars = flattenBars(activeArrangement());
|
||||
if (!bars.length) return;
|
||||
state.barIndex = clamp(index, 0, bars.length - 1);
|
||||
state.beatIndex = 0;
|
||||
renderPracticeState({ scroll });
|
||||
dom.viewerStatus.textContent = `Bar ${state.barIndex + 1} selected.`;
|
||||
}
|
||||
|
||||
function moveBar(delta, fromUser = false) {
|
||||
if (!state.data) return;
|
||||
const bars = flattenBars(activeArrangement());
|
||||
if (!bars.length) return;
|
||||
let nextIndex = state.barIndex + delta;
|
||||
|
||||
if (nextIndex >= bars.length) {
|
||||
if (dom.loopToggle.checked) nextIndex = 0;
|
||||
else {
|
||||
state.barIndex = bars.length - 1;
|
||||
stopPractice();
|
||||
renderPracticeState();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (nextIndex < 0) nextIndex = dom.loopToggle.checked ? bars.length - 1 : 0;
|
||||
|
||||
state.barIndex = nextIndex;
|
||||
state.beatIndex = 0;
|
||||
renderPracticeState({ scroll: fromUser || state.playing });
|
||||
}
|
||||
|
||||
function togglePractice() {
|
||||
if (state.playing) stopPractice();
|
||||
else startPractice();
|
||||
}
|
||||
|
||||
function startPractice() {
|
||||
if (!state.data || !flattenBars(activeArrangement()).length) return;
|
||||
state.playing = true;
|
||||
state.beatIndex = 0;
|
||||
playMetronomeTick(true);
|
||||
renderPracticeState();
|
||||
startPracticeTimer();
|
||||
}
|
||||
|
||||
function stopPractice() {
|
||||
state.playing = false;
|
||||
if (state.timer) window.clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
if (state.data) renderPracticeState();
|
||||
}
|
||||
|
||||
function startPracticeTimer() {
|
||||
if (state.timer) window.clearInterval(state.timer);
|
||||
const interval = 60000 / state.tempo;
|
||||
state.timer = window.setInterval(() => {
|
||||
const bars = flattenBars(activeArrangement());
|
||||
if (!bars.length) return stopPractice();
|
||||
const counts = practiceCounts(bars[state.barIndex].bar);
|
||||
state.beatIndex += 1;
|
||||
if (state.beatIndex >= counts) {
|
||||
state.beatIndex = 0;
|
||||
const previousIndex = state.barIndex;
|
||||
moveBar(1);
|
||||
if (!state.playing || (previousIndex === state.barIndex && !dom.loopToggle.checked)) return;
|
||||
}
|
||||
playMetronomeTick(state.beatIndex === 0);
|
||||
renderPracticeState();
|
||||
}, interval);
|
||||
}
|
||||
|
||||
function playMetronomeTick(accent) {
|
||||
if (!dom.metronomeToggle.checked) return;
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContext) return;
|
||||
state.audioContext ||= new AudioContext();
|
||||
if (state.audioContext.state === "suspended") state.audioContext.resume();
|
||||
|
||||
const oscillator = state.audioContext.createOscillator();
|
||||
const gain = state.audioContext.createGain();
|
||||
const now = state.audioContext.currentTime;
|
||||
oscillator.frequency.setValueAtTime(accent ? 1120 : 820, now);
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(accent ? 0.13 : 0.075, now + 0.003);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.045);
|
||||
oscillator.connect(gain);
|
||||
gain.connect(state.audioContext.destination);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + 0.05);
|
||||
} catch (_error) {
|
||||
dom.metronomeToggle.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTempoChange(event) {
|
||||
state.tempo = Number(event.target.value);
|
||||
dom.tempoOutput.value = String(state.tempo);
|
||||
if (state.playing) startPracticeTimer();
|
||||
}
|
||||
|
||||
function changeZoom(delta) {
|
||||
state.zoomStep = clamp(state.zoomStep + delta, -2, 4);
|
||||
const size = 0.96 + state.zoomStep * 0.09;
|
||||
document.documentElement.style.setProperty("--tab-size", `${size.toFixed(2)}rem`);
|
||||
showToast(`Tab size ${state.zoomStep > 0 ? "+" : ""}${state.zoomStep}`);
|
||||
}
|
||||
|
||||
async function handleFileLoad(event) {
|
||||
const [file] = event.target.files;
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
if (loadTabData(data, file.name, "file")) showToast(`Loaded ${file.name}`);
|
||||
} catch (error) {
|
||||
showError(`Could not parse ${file.name}: ${error.message}`);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function copyEmbedSnippet() {
|
||||
if (state.sourceKind === "file") {
|
||||
showToast("Upload the JSON to a URL first, then pass it with ?src=<url>.");
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
url.searchParams.set("embed", "1");
|
||||
if (state.sourceKind === "url" && state.sourceUrl) url.searchParams.set("src", state.sourceUrl);
|
||||
const snippet = `<iframe src="${url.href}" title="Interactive guitar tab" loading="lazy" style="width:100%;height:900px;border:0" allow="autoplay"></iframe>`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
} catch (_error) {
|
||||
const textarea = element("textarea");
|
||||
textarea.value = snippet;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
}
|
||||
showToast("Embed snippet copied");
|
||||
}
|
||||
|
||||
function updateSourceLink() {
|
||||
if (state.sourceKind === "file") {
|
||||
dom.viewSourceLink.hidden = true;
|
||||
return;
|
||||
}
|
||||
dom.viewSourceLink.hidden = false;
|
||||
dom.viewSourceLink.href = state.sourceKind === "url" && state.sourceUrl ? state.sourceUrl : DEFAULT_TAB_URL;
|
||||
}
|
||||
|
||||
function handleKeyboard(event) {
|
||||
if (!state.data || isInteractiveTarget(event.target)) return;
|
||||
if (event.code === "Space") {
|
||||
event.preventDefault();
|
||||
togglePractice();
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
moveBar(-1, true);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
moveBar(1, true);
|
||||
} else if (event.key.toLowerCase() === "m") {
|
||||
dom.metronomeToggle.checked = !dom.metronomeToggle.checked;
|
||||
showToast(`Metronome ${dom.metronomeToggle.checked ? "on" : "off"}`);
|
||||
} else if (event.key.toLowerCase() === "l") {
|
||||
dom.loopToggle.checked = !dom.loopToggle.checked;
|
||||
showToast(`Loop ${dom.loopToggle.checked ? "on" : "off"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function activeArrangement() {
|
||||
return state.data.arrangements[state.arrangementIndex];
|
||||
}
|
||||
|
||||
function flattenBars(arrangement) {
|
||||
const result = [];
|
||||
(arrangement?.sections || []).forEach((section) => {
|
||||
(section.blocks || []).forEach((block) => {
|
||||
if (block.type !== "tab") return;
|
||||
(block.bars || []).forEach((bar) => result.push({ arrangement, section, block, bar }));
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function practiceCounts(bar) {
|
||||
return clamp(
|
||||
Math.round(bar.beats || state.data.timing?.timeSignature?.beats || DEFAULT_PRACTICE_COUNTS),
|
||||
1,
|
||||
32,
|
||||
);
|
||||
}
|
||||
|
||||
function barlineToken(type, side) {
|
||||
const tokens = {
|
||||
single: "|",
|
||||
double: "||",
|
||||
repeatStart: side === "left" ? "|:" : ":|",
|
||||
repeatEnd: side === "right" ? ":|" : "|:",
|
||||
final: "||",
|
||||
none: "|",
|
||||
};
|
||||
return tokens[type] || "|";
|
||||
}
|
||||
|
||||
function metadataPill(label, value) {
|
||||
const pill = element("span", "metadata-pill");
|
||||
pill.append(`${label} `, element("strong", "", value));
|
||||
return pill;
|
||||
}
|
||||
|
||||
function tuningLabel(tuning) {
|
||||
return tuning.map((item) => item.label).join(" ");
|
||||
}
|
||||
|
||||
function element(tag, className = "", text = null) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== null && text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
dom.errorMessage.textContent = message;
|
||||
dom.errorPanel.hidden = false;
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
dom.errorPanel.hidden = true;
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function showToast(message) {
|
||||
window.clearTimeout(toastTimer);
|
||||
dom.toast.textContent = message;
|
||||
dom.toast.classList.add("show");
|
||||
toastTimer = window.setTimeout(() => dom.toast.classList.remove("show"), 2600);
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function toCamelCase(value) {
|
||||
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function isInteractiveTarget(target) {
|
||||
return Boolean(target.closest("input, button, a, select, textarea, [role='button'], [contenteditable='true']"));
|
||||
}
|
||||
|
||||
function safeHttpUrl(value) {
|
||||
try {
|
||||
const url = new URL(value, window.location.href);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user