Initial Commit

This commit is contained in:
2026-07-10 12:44:14 +05:30
commit 24030e7d9b
7 changed files with 2950 additions and 0 deletions

59
Makefile Normal file
View File

@@ -0,0 +1,59 @@
PORT ?= 8080
HOST ?= 127.0.0.1
TUNNEL_NAME ?= guitar-tabs
TUNNEL_HOST ?= tabs.bhakat.dev
TUNNEL_ORIGIN ?= http://127.0.0.1:$(PORT)
TUNNEL_EDGE_IP_VERSION ?= auto
.DEFAULT_GOAL := serve
.PHONY: help serve start tunnel-setup tunnel
help:
@printf '%s\n' \
'Guitar tabs sample' \
'' \
'Targets:' \
' make Start the local server' \
' make serve Start the local server' \
' make start Alias for make serve' \
' make tunnel-setup Create the Cloudflare Tunnel and DNS route (run once)' \
' make tunnel Serve locally and run the Cloudflare Tunnel in the foreground' \
' make help Show this help' \
'' \
'Options:' \
' PORT=8080 Server port' \
' HOST=127.0.0.1 Bind address' \
' TUNNEL_NAME=guitar-tabs Cloudflare Tunnel name' \
' TUNNEL_HOST=tabs.bhakat.dev Public hostname' \
' TUNNEL_ORIGIN=http://127.0.0.1:8080 Local URL served through the tunnel' \
' TUNNEL_EDGE_IP_VERSION=auto Cloudflare edge IP version (auto, 4, or 6)' \
'' \
'Examples:' \
' make serve PORT=9000 HOST=0.0.0.0' \
' make tunnel-setup' \
' make tunnel TUNNEL_EDGE_IP_VERSION=4'
serve:
uv run python -m http.server $(PORT) --bind $(HOST)
start: serve
tunnel-setup:
cloudflared tunnel create "$(TUNNEL_NAME)"
cloudflared tunnel route dns "$(TUNNEL_NAME)" "$(TUNNEL_HOST)"
tunnel:
@set -e; \
server_pid=''; \
cleanup() { \
if [ -n "$$server_pid" ]; then \
kill "$$server_pid" 2>/dev/null || true; \
wait "$$server_pid" 2>/dev/null || true; \
fi; \
}; \
trap cleanup EXIT; \
trap 'exit 130' INT TERM; \
uv run python -m http.server "$(PORT)" --bind "$(HOST)" & \
server_pid=$$!; \
printf '%s\n' "Serving $(TUNNEL_ORIGIN) at https://$(TUNNEL_HOST)"; \
cloudflared tunnel --edge-ip-version "$(TUNNEL_EDGE_IP_VERSION)" run --url "$(TUNNEL_ORIGIN)" "$(TUNNEL_NAME)"

118
README.md Normal file
View File

@@ -0,0 +1,118 @@
# Interactive guitar tab sample
This is a dependency-free proof of concept: a reusable browser renderer reads a versioned JSON tab file and turns it into an interactive practice sheet.
The provided file is named `kesariya.pdf`, but the title printed inside it is **Haseen (Talwinder)**. The sample follows the title and music shown in the document. All eight bars are kept in one continuous array, in source order. The license watermark email is intentionally not copied into the web data.
## Files
- `index.html` - the embeddable page surface
- `styles.css` - responsive presentation
- `app.js` - generic renderer, JSON loader, bar navigation, practice cursor, metronome, zoom, and keyboard controls
- `haseen.tab.json` - the complete source data transcribed from the PDF
- `guitar-tab.schema.json` - the reusable `guitar-tab/v1` JSON Schema
## Run the sample
The page fetches its JSON, so serve this folder instead of opening `index.html` with a `file://` URL:
```sh
make
```
This runs `uv run python -m http.server` on `127.0.0.1:8080`. Override either value when needed, for example `make PORT=9000 HOST=0.0.0.0`. `make serve` and `make start` are equivalent aliases.
Run `make help` to see all targets and options.
Then open `http://localhost:8080`.
For the compact iframe view, open `http://localhost:8080/?embed=1`.
To point the viewer at a hosted compatible file, add an encoded `src` parameter, for example `?embed=1&src=https%3A%2F%2Fexample.com%2Fsong.tab.json`. The JSON host must allow the browser request.
## Share through Cloudflare Tunnel
The Makefile defaults to the public hostname `tabs.bhakat.dev` and a named Cloudflare Tunnel called `guitar-tabs`. Ensure that `bhakat.dev` is active in the Cloudflare account used by `cloudflared`.
Create the tunnel and its DNS route once:
```sh
make tunnel-setup
```
Then, for each sharing session, run:
```sh
make tunnel
```
This starts the local static server and leaves `cloudflared` in the foreground. Press `Ctrl+C` to stop both processes. The first command stores the tunnel credential file under `~/.cloudflared`; it is not part of this repository.
If IPv6 connections to the Cloudflare edge are unreliable, force IPv4 for the tunnel:
```sh
make tunnel TUNNEL_EDGE_IP_VERSION=4
```
Use the same overrides with both commands to choose another tunnel name or subdomain:
```sh
make tunnel-setup TUNNEL_NAME=my-tabs TUNNEL_HOST=guitar-tabs.bhakat.dev
make tunnel TUNNEL_NAME=my-tabs TUNNEL_HOST=guitar-tabs.bhakat.dev
```
## Embed it
```html
<iframe
src="https://your-site.example/tabs/?embed=1"
title="Interactive guitar tab"
loading="lazy"
style="width: 100%; height: 900px; border: 0"
allow="autoplay"
></iframe>
```
The “Copy embed snippet” button generates the equivalent snippet for the current URL.
## The standard format
JSON is the canonical interchange format because browsers can load it directly and JSON Schema can validate it. YAML or Markdown can still be used as an authoring format, but should be converted to this JSON shape before rendering.
The hierarchy is:
```text
song
├── metadata + instrument + timing + legend
└── arrangements
└── sections
└── blocks
├── tab → bars → rows
└── chordLyrics → lines → spans
```
Each tab bar declares `columnCount` and owns one equal-width ASCII row per string. This keeps barlines, line wrapping, and responsive rendering deterministic. Optional column-positioned markers preserve strum arrows and similar annotations without inserting them into the tab grid. Stable IDs and optional beat events provide anchors for future audio sync or note-level highlighting.
Important conventions:
- Tuning pitches describe open strings before the capo.
- Fret numbers remain relative to the capo, matching ordinary tab notation.
- `rows[].text` contains the inside of the bar; the renderer supplies barlines.
- Every row in a bar must have the same Unicode-code-point width as `columnCount`.
- `markers[].atColumn` is zero-based within that bar-local row.
- `timeSignature`, bar beat counts, note events, repeats, and chord/lyric blocks are optional when the source does not provide them.
- `extensions` is the escape hatch for namespaced vendor data.
The semantic checks in `app.js` are a normative second validation pass for rules JSON Schema cannot express across sibling values. They enforce unique IDs, `columnCount` and row-width agreement, tuning/string consistency, legend references, and in-range text, event, note, and marker positions. A production ingestion pipeline should run the JSON Schema first and equivalent semantic checks second.
## Interaction included in the sample
- Read all eight source bars as one continuous tab
- Click a bar or use arrow keys to focus it
- Start a BPM-controlled 4-count practice cursor and optional metronome
- Loop the current tab
- Resize the tab without losing alignment
- Load another compatible `.json` file from the browser
- Use keyboard shortcuts: `Space`, `←`, `→`, `M`, and `L`
The source PDF states 82 BPM but does not state a time signature or exact beat mapping. The sample labels its 4-count cursor as a practice aid rather than presenting it as transcribed rhythm.

906
app.js Normal file
View 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;
}
}
})();

345
guitar-tab.schema.json Normal file
View File

@@ -0,0 +1,345 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Guitar Tab Interchange Format",
"description": "A portable, renderer-friendly format for guitar tablature, chord/lyric sheets, repeats, annotations, and beat-level playback anchors.",
"type": "object",
"additionalProperties": false,
"required": ["format", "id", "metadata", "instrument", "arrangements"],
"properties": {
"$schema": { "type": "string" },
"format": { "const": "guitar-tab/v1" },
"id": { "$ref": "#/$defs/id" },
"metadata": { "$ref": "#/$defs/metadata" },
"instrument": { "$ref": "#/$defs/instrument" },
"timing": { "$ref": "#/$defs/timing" },
"legend": {
"type": "array",
"items": { "$ref": "#/$defs/legendItem" },
"default": []
},
"arrangements": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/arrangement" }
},
"extensions": { "$ref": "#/$defs/extensions" }
},
"$defs": {
"id": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"extensions": {
"type": "object",
"description": "Namespaced vendor data. Keys should use reverse-domain notation.",
"additionalProperties": true,
"default": {}
},
"metadata": {
"type": "object",
"additionalProperties": false,
"required": ["title", "artists"],
"properties": {
"title": { "type": "string", "minLength": 1 },
"artists": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
},
"arranger": { "type": "string" },
"album": { "type": "string" },
"source": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": { "type": "string" },
"file": { "type": "string" },
"url": { "type": "string", "format": "uri" },
"pages": {
"type": "array",
"items": { "type": "integer", "minimum": 1 },
"uniqueItems": true
}
}
},
"links": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["label", "url"],
"properties": {
"label": { "type": "string", "minLength": 1 },
"url": { "type": "string", "format": "uri" }
}
}
}
}
},
"instrument": {
"type": "object",
"additionalProperties": false,
"required": ["type", "strings", "tuning", "capo"],
"properties": {
"type": { "const": "guitar" },
"strings": { "type": "integer", "minimum": 1, "maximum": 18 },
"capo": { "type": "integer", "minimum": 0, "maximum": 24 },
"tuningName": { "type": "string" },
"tuning": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["string", "label", "pitch"],
"properties": {
"string": { "type": "integer", "minimum": 1 },
"label": { "type": "string", "minLength": 1, "maxLength": 3 },
"pitch": { "type": "string", "pattern": "^[A-G](?:#|b)?-?[0-9]+$" }
}
}
}
}
},
"timing": {
"type": "object",
"additionalProperties": false,
"properties": {
"tempoBpm": { "type": "number", "exclusiveMinimum": 0, "maximum": 240 },
"timeSignature": {
"type": "object",
"additionalProperties": false,
"required": ["beats", "beatValue"],
"properties": {
"beats": { "type": "integer", "minimum": 1, "maximum": 32 },
"beatValue": { "type": "integer", "enum": [1, 2, 4, 8, 16, 32] }
}
}
}
},
"legendItem": {
"type": "object",
"additionalProperties": false,
"required": ["token", "label"],
"properties": {
"token": { "type": "string", "minLength": 1 },
"label": { "type": "string", "minLength": 1 },
"kind": {
"type": "string",
"enum": ["technique", "stroke", "rhythm", "other"]
}
}
},
"arrangement": {
"type": "object",
"additionalProperties": false,
"required": ["id", "title", "sections"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"title": { "type": "string", "minLength": 1 },
"description": { "type": "string" },
"difficulty": { "type": "string" },
"sections": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/section" }
},
"extensions": { "$ref": "#/$defs/extensions" }
}
},
"section": {
"type": "object",
"additionalProperties": false,
"required": ["id", "title", "blocks"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"title": { "type": "string", "minLength": 1 },
"repeat": { "$ref": "#/$defs/repeat" },
"blocks": {
"type": "array",
"minItems": 1,
"items": {
"oneOf": [
{ "$ref": "#/$defs/tabBlock" },
{ "$ref": "#/$defs/chordLyricsBlock" }
]
}
},
"extensions": { "$ref": "#/$defs/extensions" }
}
},
"repeat": {
"type": "object",
"additionalProperties": false,
"required": ["times"],
"properties": {
"times": { "type": "integer", "minimum": 2 }
}
},
"tabBlock": {
"type": "object",
"additionalProperties": false,
"required": ["id", "type", "bars"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"type": { "const": "tab" },
"label": { "type": "string" },
"bars": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/bar" }
},
"repeat": { "$ref": "#/$defs/repeat" },
"extensions": { "$ref": "#/$defs/extensions" }
}
},
"bar": {
"type": "object",
"additionalProperties": false,
"required": ["id", "columnCount", "rows"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"columnCount": { "type": "integer", "minimum": 1, "maximum": 4096 },
"beats": { "type": "number", "exclusiveMinimum": 0, "maximum": 32 },
"barline": {
"type": "object",
"additionalProperties": false,
"properties": {
"left": { "$ref": "#/$defs/barline" },
"right": { "$ref": "#/$defs/barline" }
}
},
"rows": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/tabRow" }
},
"lyrics": {
"type": "array",
"items": { "$ref": "#/$defs/positionedText" }
},
"chords": {
"type": "array",
"items": { "$ref": "#/$defs/positionedText" }
},
"markers": {
"type": "array",
"items": { "$ref": "#/$defs/marker" }
},
"events": {
"type": "array",
"items": { "$ref": "#/$defs/event" }
},
"notes": { "type": "string" },
"extensions": { "$ref": "#/$defs/extensions" }
}
},
"barline": {
"type": "string",
"enum": ["none", "single", "double", "repeatStart", "repeatEnd", "final"]
},
"tabRow": {
"type": "object",
"additionalProperties": false,
"required": ["string", "text"],
"properties": {
"string": { "type": "integer", "minimum": 1 },
"text": { "type": "string", "minLength": 1 }
}
},
"positionedText": {
"type": "object",
"additionalProperties": false,
"required": ["text"],
"properties": {
"text": { "type": "string", "minLength": 1 },
"atColumn": { "type": "integer", "minimum": 0 }
}
},
"marker": {
"type": "object",
"additionalProperties": false,
"required": ["atColumn", "token"],
"properties": {
"atColumn": { "type": "integer", "minimum": 0 },
"token": { "type": "string", "minLength": 1 },
"label": { "type": "string" }
}
},
"event": {
"type": "object",
"additionalProperties": false,
"required": ["id", "atBeat", "durationBeats", "notes"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"atBeat": { "type": "number", "minimum": 0 },
"durationBeats": { "type": "number", "exclusiveMinimum": 0 },
"notes": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/note" }
},
"label": { "type": "string" }
}
},
"note": {
"type": "object",
"additionalProperties": false,
"required": ["string", "from", "to"],
"properties": {
"string": { "type": "integer", "minimum": 1 },
"fret": { "type": "integer", "minimum": 0 },
"muted": { "type": "boolean" },
"from": { "type": "integer", "minimum": 0 },
"to": { "type": "integer", "minimum": 1 },
"techniques": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
},
"anyOf": [
{ "required": ["fret"] },
{ "properties": { "muted": { "const": true } }, "required": ["muted"] }
]
},
"chordLyricsBlock": {
"type": "object",
"additionalProperties": false,
"required": ["id", "type", "lines"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"type": { "const": "chordLyrics" },
"label": { "type": "string" },
"lines": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "spans"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"spans": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "text"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"text": { "type": "string" },
"chord": { "type": "string" }
}
}
}
}
}
},
"repeat": { "$ref": "#/$defs/repeat" },
"extensions": { "$ref": "#/$defs/extensions" }
}
}
}
}

191
haseen.tab.json Normal file
View File

@@ -0,0 +1,191 @@
{
"$schema": "./guitar-tab.schema.json",
"format": "guitar-tab/v1",
"id": "haseen-talwinder-arun-kashyap",
"metadata": {
"title": "Haseen",
"artists": ["Talwinder"],
"arranger": "Arun Kashyap",
"source": {
"label": "User-provided PDF",
"file": "kesariya.pdf",
"pages": [1, 2]
},
"links": [
{
"label": "Arun Kashyap on YouTube",
"url": "https://www.youtube.com/@arunkashyap5631"
},
{
"label": "@arunplaysguitar",
"url": "https://www.instagram.com/arunplaysguitar/"
}
]
},
"instrument": {
"type": "guitar",
"strings": 6,
"capo": 4,
"tuningName": "Standard",
"tuning": [
{ "string": 1, "label": "e", "pitch": "E4" },
{ "string": 2, "label": "B", "pitch": "B3" },
{ "string": 3, "label": "G", "pitch": "G3" },
{ "string": 4, "label": "D", "pitch": "D3" },
{ "string": 5, "label": "A", "pitch": "A2" },
{ "string": 6, "label": "E", "pitch": "E2" }
]
},
"timing": {
"tempoBpm": 82
},
"legend": [
{ "token": "h", "label": "Hammer-on", "kind": "technique" },
{ "token": "p", "label": "Pull-off", "kind": "technique" },
{ "token": "/", "label": "Slide up", "kind": "technique" },
{ "token": "\\", "label": "Slide down", "kind": "technique" },
{ "token": "x", "label": "Slap", "kind": "technique" },
{ "token": "R", "label": "Finger roll", "kind": "technique" },
{ "token": "↑", "label": "Down strum / stroke", "kind": "stroke" },
{ "token": "↓", "label": "Up strum / stroke", "kind": "stroke" }
],
"arrangements": [
{
"id": "complete-tab",
"title": "Complete tab",
"description": "All eight bars in the same order as the source document.",
"sections": [
{
"id": "all-bars",
"title": "Bars 1-8",
"blocks": [
{
"id": "all-bars-tab",
"type": "tab",
"bars": [
{
"id": "bar-01",
"columnCount": 29,
"barline": { "left": "single", "right": "single" },
"rows": [
{ "string": 1, "text": "---0-0-0-0h1-0h1h3-1-1p0-0---" },
{ "string": 2, "text": "-----1---------------------3-" },
{ "string": 3, "text": "-2---------------------------" },
{ "string": 4, "text": "-3-----------3---------------" },
{ "string": 5, "text": "-----------------------------" },
{ "string": 6, "text": "-----------------------------" }
],
"lyrics": [{ "text": "Tere ishq da jaam haseen ae", "atColumn": 0 }]
},
{
"id": "bar-02",
"columnCount": 29,
"barline": { "left": "none", "right": "single" },
"rows": [
{ "string": 1, "text": "---------0h1-0h1p0-----------" },
{ "string": 2, "text": "---3-3-3-----------3-3-3p1---" },
{ "string": 3, "text": "---1-------------------------" },
{ "string": 4, "text": "-2-----------2---------------" },
{ "string": 5, "text": "-----------------------------" },
{ "string": 6, "text": "-----------------------------" }
],
"lyrics": [{ "text": "Subah haseen meri shaam haseen ae", "atColumn": 0 }]
},
{
"id": "bar-03",
"columnCount": 29,
"barline": { "left": "single", "right": "single" },
"rows": [
{ "string": 1, "text": "-----------------------------" },
{ "string": 2, "text": "---1-1-------1-1-1-3/5-------" },
{ "string": 3, "text": "---2-2-----2-----------------" },
{ "string": 4, "text": "---------2-------------------" },
{ "string": 5, "text": "-0-----0---------------------" },
{ "string": 6, "text": "-----------------------------" }
],
"lyrics": [{ "text": "Eh be-matlabi zindagi", "atColumn": 0 }]
},
{
"id": "bar-04",
"columnCount": 29,
"barline": { "left": "none", "right": "single" },
"rows": [
{ "string": 1, "text": "-----------------------------" },
{ "string": 2, "text": "-0-0-0-0---0h1-0h1p0---------" },
{ "string": 3, "text": "---------2-----------2-2-2---" },
{ "string": 4, "text": "-----------------------------" },
{ "string": 5, "text": "-----------------------------" },
{ "string": 6, "text": "-3-------------3-------------" }
],
"lyrics": [{ "text": "Jado di tere naam haseen ae", "atColumn": 0 }]
},
{
"id": "bar-05",
"columnCount": 29,
"barline": { "left": "single", "right": "single" },
"rows": [
{ "string": 1, "text": "---0-0---0-0h1-0h1h3-1-0-0---" },
{ "string": 2, "text": "-----1-----------------0---3-" },
{ "string": 3, "text": "-2---------------------2-----" },
{ "string": 4, "text": "-----------------------------" },
{ "string": 5, "text": "-------x---------------x-----" },
{ "string": 6, "text": "-1-----x-------1-------x-----" }
],
"markers": [{ "atColumn": 23, "token": "↑", "label": "Down strum / stroke" }],
"lyrics": [{ "text": "Tere ishq da jaam haseen ae", "atColumn": 0 }]
},
{
"id": "bar-06",
"columnCount": 29,
"barline": { "left": "none", "right": "single" },
"rows": [
{ "string": 1, "text": "---------0h1-0h1p0---0-------" },
{ "string": 2, "text": "---3-3-3-----------3-3-3p1---" },
{ "string": 3, "text": "---1-1-1---------------------" },
{ "string": 4, "text": "-------2---------------------" },
{ "string": 5, "text": "-------x-------------x-------" },
{ "string": 6, "text": "-0-----x-----0-------x-------" }
],
"markers": [
{ "atColumn": 7, "token": "↑", "label": "Down strum / stroke" },
{ "atColumn": 21, "token": "↑", "label": "Down strum / stroke" }
],
"lyrics": [{ "text": "Subah haseen meri shaam haseen ae", "atColumn": 0 }]
},
{
"id": "bar-07",
"columnCount": 29,
"barline": { "left": "single", "right": "single" },
"rows": [
{ "string": 1, "text": "-----------------------------" },
{ "string": 2, "text": "---1-1-------1-1---1-3/5-----" },
{ "string": 3, "text": "---2-2-----------------------" },
{ "string": 4, "text": "-----------2-----------------" },
{ "string": 5, "text": "-0-----x-0-------x-----------" },
{ "string": 6, "text": "-------x---------x-----------" }
],
"lyrics": [{ "text": "Eh be-matlabi zindagi", "atColumn": 0 }]
},
{
"id": "bar-08",
"columnCount": 29,
"barline": { "left": "none", "right": "single" },
"rows": [
{ "string": 1, "text": "-----------------------------" },
{ "string": 2, "text": "-0-0-0-0-0h1-0h1p0-----------" },
{ "string": 3, "text": "-------2---------------------" },
{ "string": 4, "text": "-----------------------------" },
{ "string": 5, "text": "-------x---------------------" },
{ "string": 6, "text": "-3-----x-----3---------------" }
],
"markers": [{ "atColumn": 7, "token": "↑", "label": "Down strum / stroke" }],
"lyrics": [{ "text": "Jado di tere naam.......", "atColumn": 0 }]
}
]
}
]
}
]
}
]
}

169
index.html Normal file
View File

@@ -0,0 +1,169 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#173f37" />
<link rel="icon" href="data:," />
<meta
name="description"
content="A data-driven, interactive guitar tab viewer sample."
/>
<title>Fretform — Interactive guitar tabs</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="page-shell">
<header class="topbar" aria-label="Site header">
<a class="brand" href="./" aria-label="Fretform home">
<span class="brand-mark" aria-hidden="true">FF</span>
<span>Fretform</span>
</a>
<div class="topbar-actions">
<span class="format-pill">guitar-tab/v1</span>
<button class="button button-quiet file-button" id="load-tab-button" type="button">
Load tab JSON
</button>
<input id="tab-file" type="file" accept="application/json,.json" hidden />
<a class="button button-dark" id="view-source-link" href="haseen.tab.json" target="_blank" rel="noreferrer">
View source
</a>
</div>
</header>
<main>
<section class="hero" aria-labelledby="song-title">
<div class="hero-copy">
<p class="eyebrow"><span>Interactive tab sheet</span><span id="source-label">Loading JSON…</span></p>
<h1 id="song-title">Loading tab…</h1>
<p class="artist-line" id="artist-line"></p>
<div class="metadata-row" id="metadata-row" aria-label="Song setup"></div>
</div>
<div class="arrangement-panel">
<div class="panel-kicker">Choose an arrangement</div>
<div class="segmented-control" id="arrangement-picker" role="tablist" aria-label="Arrangement"></div>
<p id="arrangement-description" class="arrangement-description"></p>
</div>
</section>
<section class="practice-player" aria-label="Practice guide">
<div class="transport">
<button class="play-button" id="play-button" type="button" aria-label="Start practice guide">
<span class="play-icon" aria-hidden="true"></span>
</button>
<div class="transport-copy">
<span class="transport-label">Practice guide</span>
<strong id="now-playing">Select a bar to begin</strong>
</div>
<div class="transport-buttons" aria-label="Bar navigation">
<button class="icon-button" id="previous-button" type="button" aria-label="Previous bar"></button>
<button class="icon-button" id="next-button" type="button" aria-label="Next bar"></button>
</div>
</div>
<div class="beat-and-progress">
<div class="progress-copy">
<span id="bar-position">Bar 1 of 1</span>
<span id="beat-position">Ready</span>
</div>
<div class="progress-track" aria-hidden="true"><span id="progress-fill"></span></div>
<div class="beat-dots" id="beat-dots" aria-label="Practice count"></div>
</div>
<div class="practice-settings">
<label class="tempo-control" for="tempo-range">
<span>Tempo</span>
<strong><output id="tempo-output">82</output> BPM</strong>
<input id="tempo-range" type="range" min="30" max="240" step="1" value="82" />
</label>
<label class="toggle-row" for="metronome-toggle">
<input id="metronome-toggle" type="checkbox" checked />
<span class="toggle" aria-hidden="true"></span>
<span>Metronome</span>
</label>
<label class="toggle-row" for="loop-toggle">
<input id="loop-toggle" type="checkbox" checked />
<span class="toggle" aria-hidden="true"></span>
<span>Loop tab</span>
</label>
</div>
</section>
<p class="practice-note" id="practice-note"></p>
<div class="content-grid">
<section class="sheet" aria-labelledby="sheet-title">
<div class="sheet-header">
<div>
<p class="section-index" id="section-index">01 / Tab</p>
<h2 id="sheet-title">Tablature</h2>
</div>
<div class="zoom-control" aria-label="Tab size">
<span>Tab size</span>
<button class="icon-button small" id="zoom-out" type="button" aria-label="Make tab smaller"></button>
<button class="icon-button small" id="zoom-in" type="button" aria-label="Make tab larger">+</button>
</div>
</div>
<div id="tab-sheet" class="tab-sheet"></div>
<p class="sr-only" id="viewer-status" role="status" aria-live="polite"></p>
</section>
<aside class="sidebar" aria-label="Tab details">
<section class="side-card legend-card">
<p class="panel-kicker">Notation key</p>
<h2>How to read it</h2>
<dl class="legend-list" id="legend-list"></dl>
</section>
<section class="side-card data-card">
<p class="panel-kicker">Built from data</p>
<h2>One renderer, any song.</h2>
<p>
The page reads bars, strings, lyrics, techniques, and practice settings from a versioned JSON file.
</p>
<div class="data-stats">
<div><strong id="stat-arrangements">0</strong><span>arrangement</span></div>
<div><strong id="stat-bars">0</strong><span>bars</span></div>
<div><strong id="stat-strings">6</strong><span>strings</span></div>
</div>
<a href="guitar-tab.schema.json" target="_blank" class="text-link">Open the JSON Schema <span aria-hidden="true"></span></a>
</section>
<section class="side-card shortcuts-card">
<p class="panel-kicker">Keyboard</p>
<ul>
<li><kbd>Space</kbd><span>Start / pause</span></li>
<li><kbd></kbd><kbd></kbd><span>Move by bar</span></li>
<li><kbd>M</kbd><span>Metronome</span></li>
<li><kbd>L</kbd><span>Loop tab</span></li>
</ul>
</section>
</aside>
</div>
<section class="embed-strip" aria-labelledby="embed-title">
<div>
<p class="panel-kicker">For the team</p>
<h2 id="embed-title">Drop it into an existing page.</h2>
<p>Use the full viewer or add <code>?embed=1</code> for a compact, iframe-friendly surface.</p>
</div>
<button class="button button-light" id="copy-embed" type="button">Copy embed snippet</button>
</section>
</main>
<footer>
<span>Interactive sample • source data stays separate from the viewer</span>
<span id="attribution"></span>
</footer>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div class="error-panel" id="error-panel" role="alert" hidden>
<strong>Couldnt load this tab.</strong>
<span id="error-message"></span>
</div>
<script src="app.js" defer></script>
</body>
</html>

1162
styles.css Normal file

File diff suppressed because it is too large Load Diff