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