Files
guitar-tabs/app.js
2026-07-10 12:44:14 +05:30

907 lines
34 KiB
JavaScript

(() => {
"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;
}
}
})();