Initial commit - Church Music Database

This commit is contained in:
2026-01-27 18:04:50 -06:00
commit d367261867
336 changed files with 103545 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
import axios from "axios";
const api = axios.create({
baseURL: "/api",
timeout: 30000, // 30 seconds for slow operations like saving large lyrics
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem("authToken");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error),
);
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("authToken");
window.location.href = "/login";
}
return Promise.reject(error);
},
);
export default api;

View File

@@ -0,0 +1,214 @@
/**
* Biometric Authentication Utility
* Supports Face ID, Touch ID, Windows Hello, and Android Biometric
*/
// Check if biometric authentication is available
export const isBiometricAvailable = async () => {
// Check if browser supports Web Authentication API
if (!window.PublicKeyCredential) {
return false;
}
try {
// Check if platform authenticator is available (Face ID, Touch ID, etc.)
const available =
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
return available;
} catch (error) {
// Silently fail for production
return false;
}
};
// Get biometric type description
export const getBiometricType = () => {
const ua = navigator.userAgent;
if (/iPhone|iPad|iPod/.test(ua)) {
// iOS devices - could be Face ID or Touch ID
return "Biometric Authentication";
} else if (/Android/.test(ua)) {
return "Biometric Authentication";
} else if (/Windows/.test(ua)) {
return "Biometric Authentication";
} else if (/Mac/.test(ua)) {
return "Biometric Authentication";
}
return "Biometric Authentication";
};
// Register biometric credential
export const registerBiometric = async (username, userId) => {
try {
// Generate challenge from server (in production, get this from server)
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const publicKeyCredentialCreationOptions = {
challenge,
rp: {
name: "HOP Worship Platform",
id: window.location.hostname,
},
user: {
id: new TextEncoder().encode(userId.toString()),
name: username,
displayName: username,
},
pubKeyCredParams: [
{ alg: -7, type: "public-key" }, // ES256
{ alg: -257, type: "public-key" }, // RS256
],
authenticatorSelection: {
authenticatorAttachment: "platform", // Use platform authenticator (Face ID, Touch ID, etc.)
userVerification: "required",
requireResidentKey: false,
},
timeout: 60000,
attestation: "none",
};
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions,
});
// Convert credential to base64 for storage
const credentialData = {
id: credential.id,
rawId: arrayBufferToBase64(credential.rawId),
type: credential.type,
response: {
attestationObject: arrayBufferToBase64(
credential.response.attestationObject,
),
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
},
};
return credentialData;
} catch (error) {
console.error("Biometric registration error:", error);
throw new Error(getBiometricErrorMessage(error));
}
};
// Authenticate using biometric
export const authenticateWithBiometric = async (credentialId) => {
try {
// Generate challenge
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const publicKeyCredentialRequestOptions = {
challenge,
allowCredentials: credentialId
? [
{
id: base64ToArrayBuffer(credentialId),
type: "public-key",
transports: ["internal"],
},
]
: [],
userVerification: "required",
timeout: 60000,
};
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions,
});
// Convert assertion to base64
const assertionData = {
id: assertion.id,
rawId: arrayBufferToBase64(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: arrayBufferToBase64(
assertion.response.authenticatorData,
),
clientDataJSON: arrayBufferToBase64(assertion.response.clientDataJSON),
signature: arrayBufferToBase64(assertion.response.signature),
userHandle: assertion.response.userHandle
? arrayBufferToBase64(assertion.response.userHandle)
: null,
},
};
return assertionData;
} catch (error) {
console.error("Biometric authentication error:", error);
throw new Error(getBiometricErrorMessage(error));
}
};
// Helper: Convert ArrayBuffer to Base64
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Helper: Convert Base64 to ArrayBuffer
function base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
// Get user-friendly error message
function getBiometricErrorMessage(error) {
if (error.name === "NotAllowedError") {
return "Biometric authentication was cancelled";
} else if (error.name === "NotSupportedError") {
return "Biometric authentication is not supported on this device";
} else if (error.name === "SecurityError") {
return "Biometric authentication failed due to security restrictions";
} else if (error.name === "AbortError") {
return "Biometric authentication was aborted";
} else if (error.name === "InvalidStateError") {
return "Biometric credential already registered";
} else if (error.name === "NotReadableError") {
return "Biometric sensor is not readable";
}
return error.message || "Biometric authentication failed";
}
// Store biometric credential ID locally
export const storeBiometricCredential = (username, credentialId) => {
const credentials = getBiometricCredentials();
credentials[username] = credentialId;
localStorage.setItem("biometric_credentials", JSON.stringify(credentials));
};
// Get biometric credential ID for user
export const getBiometricCredential = (username) => {
const credentials = getBiometricCredentials();
return credentials[username] || null;
};
// Get all stored biometric credentials
export const getBiometricCredentials = () => {
const stored = localStorage.getItem("biometric_credentials");
return stored ? JSON.parse(stored) : {};
};
// Remove biometric credential
export const removeBiometricCredential = (username) => {
const credentials = getBiometricCredentials();
delete credentials[username];
localStorage.setItem("biometric_credentials", JSON.stringify(credentials));
};
// Check if user has biometric registered
export const hasBiometricRegistered = (username) => {
return getBiometricCredential(username) !== null;
};

View File

@@ -0,0 +1,275 @@
// Chord Engine - Industry-standard chord transposition and parsing
const NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const FLAT_NOTES = [
"C",
"Db",
"D",
"Eb",
"E",
"F",
"Gb",
"G",
"Ab",
"A",
"Bb",
"B",
];
// Keywords to ignore when parsing chords (section markers)
const SECTION_KEYWORDS = [
"verse",
"chorus",
"pre-chorus",
"prechorus",
"bridge",
"intro",
"outro",
"tag",
"coda",
"interlude",
"instrumental",
"hook",
"refrain",
"vamp",
"ending",
"turnaround",
"solo",
"break",
];
// Chord quality patterns
const CHORD_QUALITIES = {
major: "",
minor: "m",
diminished: "dim",
augmented: "aug",
suspended2: "sus2",
suspended4: "sus4",
dominant7: "7",
major7: "maj7",
minor7: "m7",
diminished7: "dim7",
augmented7: "aug7",
add9: "add9",
add11: "add11",
6: "6",
m6: "m6",
9: "9",
m9: "m9",
11: "11",
13: "13",
};
// Regex to match chord patterns
const CHORD_REGEX =
/^([A-G][#b]?)(m|min|maj|dim|aug|sus[24]?|add[0-9]+|[0-9]+)?([0-9]*)?(\/[A-G][#b]?)?$/i;
/**
* Parse a potential chord string
* @param {string} text - Text that might be a chord
* @returns {object|null} Parsed chord object or null if not a valid chord
*/
export function parseChord(text) {
const trimmed = text.trim();
// Check if it's a section keyword
if (
SECTION_KEYWORDS.some((keyword) => trimmed.toLowerCase().includes(keyword))
) {
return null;
}
const match = trimmed.match(CHORD_REGEX);
if (!match) return null;
const [, root, quality = "", extension = "", bass = ""] = match;
return {
root: normalizeNote(root),
quality,
extension,
bass: bass ? normalizeNote(bass.slice(1)) : null,
original: trimmed,
};
}
/**
* Normalize note name (handle enharmonics)
* @param {string} note - Note name
* @returns {string} Normalized note name
*/
function normalizeNote(note) {
const upper = note.charAt(0).toUpperCase() + note.slice(1);
// Convert flats to sharps for internal processing
const flatIndex = FLAT_NOTES.indexOf(upper);
if (flatIndex !== -1) {
return NOTES[flatIndex];
}
return upper;
}
/**
* Get the semitone index of a note
* @param {string} note - Note name
* @returns {number} Semitone index (0-11)
*/
function getNoteIndex(note) {
const normalized = normalizeNote(note);
const index = NOTES.indexOf(normalized);
return index !== -1 ? index : FLAT_NOTES.indexOf(normalized);
}
/**
* Transpose a single chord by a number of semitones
* @param {string} chord - Chord string
* @param {number} semitones - Number of semitones to transpose
* @param {boolean} useFlats - Whether to use flats instead of sharps
* @returns {string} Transposed chord string
*/
export function transposeChord(chord, semitones, useFlats = false) {
const parsed = parseChord(chord);
if (!parsed) return chord;
const noteArray = useFlats ? FLAT_NOTES : NOTES;
const rootIndex = getNoteIndex(parsed.root);
const newRootIndex = (rootIndex + semitones + 12) % 12;
const newRoot = noteArray[newRootIndex];
let result = newRoot + parsed.quality + parsed.extension;
if (parsed.bass) {
const bassIndex = getNoteIndex(parsed.bass);
const newBassIndex = (bassIndex + semitones + 12) % 12;
result += "/" + noteArray[newBassIndex];
}
return result;
}
/**
* Get semitones between two keys
* @param {string} fromKey - Starting key
* @param {string} toKey - Target key
* @returns {number} Number of semitones
*/
export function getSemitonesBetween(fromKey, toKey) {
const fromIndex = getNoteIndex(fromKey);
const toIndex = getNoteIndex(toKey);
return (toIndex - fromIndex + 12) % 12;
}
/**
* Transpose all chords in a lyrics string
* @param {string} lyrics - Lyrics with chords in brackets [C] or above words
* @param {number} semitones - Number of semitones to transpose
* @param {boolean} useFlats - Whether to use flats
* @returns {string} Transposed lyrics
*/
export function transposeLyrics(lyrics, semitones, useFlats = false) {
// Handle chords in brackets [C]
const bracketPattern = /\[([^\]]+)\]/g;
return lyrics.replace(bracketPattern, (match, chord) => {
const transposed = transposeChord(chord, semitones, useFlats);
return `[${transposed}]`;
});
}
/**
* Extract all unique chords from lyrics
* @param {string} lyrics - Lyrics with chords
* @returns {string[]} Array of unique chords
*/
export function extractChords(lyrics) {
const bracketPattern = /\[([^\]]+)\]/g;
const chords = new Set();
let match;
while ((match = bracketPattern.exec(lyrics)) !== null) {
const chord = match[1];
if (parseChord(chord)) {
chords.add(chord);
}
}
return Array.from(chords);
}
/**
* Detect the likely key of a song based on its chords
* @param {string[]} chords - Array of chords
* @returns {string} Detected key
*/
export function detectKey(chords) {
if (chords.length === 0) return "C";
// Simple heuristic: first and last chords often indicate the key
const firstChord = parseChord(chords[0]);
const lastChord = parseChord(chords[chords.length - 1]);
// If they match, high confidence
if (firstChord && lastChord && firstChord.root === lastChord.root) {
return firstChord.root + (firstChord.quality.includes("m") ? "m" : "");
}
// Otherwise use first chord
if (firstChord) {
return firstChord.root + (firstChord.quality.includes("m") ? "m" : "");
}
return "C";
}
/**
* Format chord for display (convert internal format to display format)
* @param {string} chord - Chord in internal format
* @param {object} options - Display options
* @returns {string} Formatted chord
*/
export function formatChord(chord, options = {}) {
const { useFlats = false, useSuperscript = false } = options;
let formatted = chord;
if (useFlats) {
NOTES.forEach((sharp, i) => {
if (sharp.includes("#")) {
formatted = formatted.replace(sharp, FLAT_NOTES[i]);
}
});
}
return formatted;
}
/**
* Get all keys for key selector
* @param {boolean} useFlats - Whether to use flats
* @returns {string[]} Array of keys
*/
export function getAllKeys(useFlats = false) {
const notes = useFlats ? FLAT_NOTES : NOTES;
const keys = [];
notes.forEach((note) => {
keys.push(note); // Major
keys.push(note + "m"); // Minor
});
return keys;
}
export default {
parseChord,
transposeChord,
transposeLyrics,
extractChords,
detectKey,
formatChord,
getAllKeys,
getSemitonesBetween,
NOTES,
FLAT_NOTES,
SECTION_KEYWORDS,
};

View File

@@ -0,0 +1,778 @@
/**
* Comprehensive Chord Sheet Utility
* Uses ChordSheetJS for parsing/rendering and Tonal for music theory
*/
import ChordSheetJS from "chordsheetjs";
import { Note, Scale, Chord, Progression } from "tonal";
// ============================================
// COMPLETE CHORD TYPE DEFINITIONS
// ============================================
// All root notes (chromatic scale)
export const ROOT_NOTES = [
"C",
"C#",
"Db",
"D",
"D#",
"Eb",
"E",
"F",
"F#",
"Gb",
"G",
"G#",
"Ab",
"A",
"A#",
"Bb",
"B",
];
// All chord qualities/types
export const CHORD_QUALITIES = [
{ symbol: "", name: "Major" },
{ symbol: "m", name: "Minor" },
{ symbol: "dim", name: "Diminished" },
{ symbol: "aug", name: "Augmented" },
{ symbol: "7", name: "Dominant 7th" },
{ symbol: "maj7", name: "Major 7th" },
{ symbol: "m7", name: "Minor 7th" },
{ symbol: "dim7", name: "Diminished 7th" },
{ symbol: "m7b5", name: "Half-Diminished" },
{ symbol: "aug7", name: "Augmented 7th" },
{ symbol: "6", name: "Major 6th" },
{ symbol: "m6", name: "Minor 6th" },
{ symbol: "9", name: "Dominant 9th" },
{ symbol: "maj9", name: "Major 9th" },
{ symbol: "m9", name: "Minor 9th" },
{ symbol: "11", name: "Dominant 11th" },
{ symbol: "13", name: "Dominant 13th" },
{ symbol: "sus2", name: "Suspended 2nd" },
{ symbol: "sus4", name: "Suspended 4th" },
{ symbol: "7sus4", name: "7th Suspended 4th" },
{ symbol: "add9", name: "Add 9" },
{ symbol: "add11", name: "Add 11" },
{ symbol: "5", name: "Power Chord" },
{ symbol: "2", name: "Add 2" },
{ symbol: "4", name: "Add 4" },
];
// Generate ALL possible chords (root + quality combinations)
export const ALL_CHORDS = [];
ROOT_NOTES.forEach((root) => {
CHORD_QUALITIES.forEach((quality) => {
ALL_CHORDS.push({
chord: root + quality.symbol,
root: root,
quality: quality.symbol,
name: `${root} ${quality.name}`,
});
});
});
// ============================================
// KEY SIGNATURES WITH DIATONIC PROGRESSIONS
// ============================================
// Major key diatonic chords (I, ii, iii, IV, V, vi, vii°)
export const MAJOR_KEY_PROGRESSIONS = {
C: ["C", "Dm", "Em", "F", "G", "Am", "Bdim"],
"C#": ["C#", "D#m", "E#m", "F#", "G#", "A#m", "B#dim"],
Db: ["Db", "Ebm", "Fm", "Gb", "Ab", "Bbm", "Cdim"],
D: ["D", "Em", "F#m", "G", "A", "Bm", "C#dim"],
"D#": ["D#", "E#m", "F##m", "G#", "A#", "B#m", "C##dim"],
Eb: ["Eb", "Fm", "Gm", "Ab", "Bb", "Cm", "Ddim"],
E: ["E", "F#m", "G#m", "A", "B", "C#m", "D#dim"],
F: ["F", "Gm", "Am", "Bb", "C", "Dm", "Edim"],
"F#": ["F#", "G#m", "A#m", "B", "C#", "D#m", "E#dim"],
Gb: ["Gb", "Abm", "Bbm", "Cb", "Db", "Ebm", "Fdim"],
G: ["G", "Am", "Bm", "C", "D", "Em", "F#dim"],
"G#": ["G#", "A#m", "B#m", "C#", "D#", "E#m", "F##dim"],
Ab: ["Ab", "Bbm", "Cm", "Db", "Eb", "Fm", "Gdim"],
A: ["A", "Bm", "C#m", "D", "E", "F#m", "G#dim"],
"A#": ["A#", "B#m", "C##m", "D#", "E#", "F##m", "G##dim"],
Bb: ["Bb", "Cm", "Dm", "Eb", "F", "Gm", "Adim"],
B: ["B", "C#m", "D#m", "E", "F#", "G#m", "A#dim"],
};
// Minor key diatonic chords (i, ii°, III, iv, v, VI, VII)
export const MINOR_KEY_PROGRESSIONS = {
Am: ["Am", "Bdim", "C", "Dm", "Em", "F", "G"],
"A#m": ["A#m", "B#dim", "C#", "D#m", "E#m", "F#", "G#"],
Bbm: ["Bbm", "Cdim", "Db", "Ebm", "Fm", "Gb", "Ab"],
Bm: ["Bm", "C#dim", "D", "Em", "F#m", "G", "A"],
Cm: ["Cm", "Ddim", "Eb", "Fm", "Gm", "Ab", "Bb"],
"C#m": ["C#m", "D#dim", "E", "F#m", "G#m", "A", "B"],
Dm: ["Dm", "Edim", "F", "Gm", "Am", "Bb", "C"],
"D#m": ["D#m", "E#dim", "F#", "G#m", "A#m", "B", "C#"],
Ebm: ["Ebm", "Fdim", "Gb", "Abm", "Bbm", "Cb", "Db"],
Em: ["Em", "F#dim", "G", "Am", "Bm", "C", "D"],
Fm: ["Fm", "Gdim", "Ab", "Bbm", "Cm", "Db", "Eb"],
"F#m": ["F#m", "G#dim", "A", "Bm", "C#m", "D", "E"],
Gm: ["Gm", "Adim", "Bb", "Cm", "Dm", "Eb", "F"],
"G#m": ["G#m", "A#dim", "B", "C#m", "D#m", "E", "F#"],
};
// All keys for dropdown
export const ALL_KEYS = [
// Major keys
{ value: "C", label: "C Major", type: "major" },
{ value: "C#", label: "C# Major", type: "major" },
{ value: "Db", label: "Db Major", type: "major" },
{ value: "D", label: "D Major", type: "major" },
{ value: "Eb", label: "Eb Major", type: "major" },
{ value: "E", label: "E Major", type: "major" },
{ value: "F", label: "F Major", type: "major" },
{ value: "F#", label: "F# Major", type: "major" },
{ value: "Gb", label: "Gb Major", type: "major" },
{ value: "G", label: "G Major", type: "major" },
{ value: "Ab", label: "Ab Major", type: "major" },
{ value: "A", label: "A Major", type: "major" },
{ value: "Bb", label: "Bb Major", type: "major" },
{ value: "B", label: "B Major", type: "major" },
// Minor keys
{ value: "Am", label: "A Minor", type: "minor" },
{ value: "A#m", label: "A# Minor", type: "minor" },
{ value: "Bbm", label: "Bb Minor", type: "minor" },
{ value: "Bm", label: "B Minor", type: "minor" },
{ value: "Cm", label: "C Minor", type: "minor" },
{ value: "C#m", label: "C# Minor", type: "minor" },
{ value: "Dm", label: "D Minor", type: "minor" },
{ value: "D#m", label: "D# Minor", type: "minor" },
{ value: "Ebm", label: "Eb Minor", type: "minor" },
{ value: "Em", label: "E Minor", type: "minor" },
{ value: "Fm", label: "F Minor", type: "minor" },
{ value: "F#m", label: "F# Minor", type: "minor" },
{ value: "Gm", label: "G Minor", type: "minor" },
{ value: "G#m", label: "G# Minor", type: "minor" },
];
// ============================================
// CHORD SHEET PARSING & RENDERING
// ============================================
/**
* Parse ChordPro format to structured song object
* ChordPro format: [C]Amazing [G]grace how [Am]sweet
*/
export function parseChordPro(content) {
const parser = new ChordSheetJS.ChordProParser();
try {
return parser.parse(content);
} catch (e) {
return null;
}
}
/**
* Parse Ultimate Guitar / plain text format
* Format with chords on separate lines above lyrics
*/
export function parsePlainText(content) {
const parser = new ChordSheetJS.ChordsOverWordsParser();
try {
return parser.parse(content);
} catch (e) {
return null;
}
}
/**
* Render song to ChordPro format
*/
export function renderToChordPro(song) {
const formatter = new ChordSheetJS.ChordProFormatter();
return formatter.format(song);
}
/**
* Render song to HTML with chords ABOVE lyrics
*/
export function renderToHtml(song) {
const formatter = new ChordSheetJS.HtmlTableFormatter();
return formatter.format(song);
}
/**
* Render song to plain text with chords above lyrics
*/
export function renderToText(song) {
const formatter = new ChordSheetJS.TextFormatter();
return formatter.format(song);
}
// ============================================
// TRANSPOSITION USING CHORDSHEETJS
// ============================================
/**
* Transpose a song by semitones
*/
export function transposeSong(song, semitones) {
if (!song || semitones === 0) return song;
return song.transpose(semitones);
}
/**
* Transpose from one key to another
*/
export function transposeToKey(song, fromKey, toKey) {
const semitones = getSemitonesBetweenKeys(fromKey, toKey);
return transposeSong(song, semitones);
}
/**
* Calculate semitones between two keys
*/
export function getSemitonesBetweenKeys(fromKey, toKey) {
const chromatic = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
const flatToSharp = {
Db: "C#",
Eb: "D#",
Fb: "E",
Gb: "F#",
Ab: "G#",
Bb: "A#",
Cb: "B",
};
// Extract root note (remove minor suffix if present)
const fromRoot = fromKey.replace(/m$/, "");
const toRoot = toKey.replace(/m$/, "");
// Normalize to sharp notation
const fromNorm = flatToSharp[fromRoot] || fromRoot;
const toNorm = flatToSharp[toRoot] || toRoot;
const fromIdx = chromatic.indexOf(fromNorm);
const toIdx = chromatic.indexOf(toNorm);
if (fromIdx === -1 || toIdx === -1) return 0;
return (toIdx - fromIdx + 12) % 12;
}
// ============================================
// CHORD PROGRESSION GENERATION
// ============================================
/**
* Get diatonic chords for a key
*/
export function getDiatonicChords(key) {
const isMinor = key.endsWith("m");
if (isMinor) {
return MINOR_KEY_PROGRESSIONS[key] || MINOR_KEY_PROGRESSIONS["Am"];
}
return MAJOR_KEY_PROGRESSIONS[key] || MAJOR_KEY_PROGRESSIONS["C"];
}
/**
* Get common chord progressions for a key
*/
export function getCommonProgressions(key) {
const chords = getDiatonicChords(key);
const isMinor = key.endsWith("m");
// Roman numeral positions
// Major: I=0, ii=1, iii=2, IV=3, V=4, vi=5, vii°=6
// Minor: i=0, ii°=1, III=2, iv=3, v=4, VI=5, VII=6
return {
"I-IV-V-I": [chords[0], chords[3], chords[4], chords[0]],
"I-V-vi-IV": [chords[0], chords[4], chords[5], chords[3]],
"I-vi-IV-V": [chords[0], chords[5], chords[3], chords[4]],
"ii-V-I": [chords[1], chords[4], chords[0]],
"I-IV-vi-V": [chords[0], chords[3], chords[5], chords[4]],
"vi-IV-I-V": [chords[5], chords[3], chords[0], chords[4]],
"I-ii-IV-V": [chords[0], chords[1], chords[3], chords[4]],
"I-iii-IV-V": [chords[0], chords[2], chords[3], chords[4]],
};
}
// ============================================
// LYRICS + CHORD POSITIONING
// ============================================
/**
* Parse lyrics with embedded [Chord] markers OR chords-above-lyrics format
* for rendering chords ABOVE lyrics
*
* Input format: [C]Amazing [G]grace how [Am]sweet
* OR:
* Am F G
* Lord prepare me
*
* Output: Array of Array of { chord: string|null, text: string }
*/
export function parseLyricsWithChords(lyrics) {
if (!lyrics) return [];
const lines = lyrics.split("\n");
const result = [];
// Pattern to detect if a line is ONLY chords (chords-above-lyrics format)
const chordOnlyPattern =
/^[\sA-G#b/()m\d]*[A-G][#b]?(?:m|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*[\sA-G#b/()m\d]*$/;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const nextLine = lines[i + 1];
// Check if this line is a chord line (contains only chords and spaces)
const isChordLine =
line.trim().length > 0 &&
chordOnlyPattern.test(line.trim()) &&
nextLine !== undefined &&
!/^[A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*[\s]*$/.test(
nextLine.trim(),
);
if (isChordLine && nextLine) {
// This is chords-above-lyrics format
// Parse chord positions and merge with lyrics
const segments = [];
const chordRegex = /([A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*)/g;
const chords = [];
let match;
while ((match = chordRegex.exec(line)) !== null) {
chords.push({
chord: match[1],
position: match.index,
});
}
if (chords.length > 0) {
let lastPos = 0;
chords.forEach((chordObj, idx) => {
const { chord, position } = chordObj;
const textBefore = nextLine.substring(lastPos, position);
if (textBefore) {
segments.push({ chord: null, text: textBefore });
}
// Get text after this chord until next chord or end
const nextChordPos = chords[idx + 1]
? chords[idx + 1].position
: nextLine.length;
const textAfter = nextLine.substring(position, nextChordPos);
segments.push({
chord: chord,
text: textAfter || " ",
});
lastPos = nextChordPos;
});
// Add any remaining text
if (lastPos < nextLine.length) {
segments.push({ chord: null, text: nextLine.substring(lastPos) });
}
result.push(segments);
i++; // Skip the lyrics line since we processed it
continue;
}
}
// Check for embedded [Chord] format
const hasEmbeddedChords =
/\[([A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*(?:\/[A-Ga-g][#b]?)?)\]/.test(
line,
);
if (hasEmbeddedChords) {
// Parse embedded [Chord] markers
const segments = [];
const regex =
/\[([A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*(?:\/[A-Ga-g][#b]?)?)\]/g;
let lastIndex = 0;
let chordMatch;
while ((chordMatch = regex.exec(line)) !== null) {
// Text before this chord (no chord above it)
if (chordMatch.index > lastIndex) {
const textBefore = line.substring(lastIndex, chordMatch.index);
if (textBefore) {
segments.push({ chord: null, text: textBefore });
}
}
// Find text after chord until next chord or end
const afterChordIdx = chordMatch.index + chordMatch[0].length;
const nextMatch = regex.exec(line);
const nextIdx = nextMatch ? nextMatch.index : line.length;
regex.lastIndex = afterChordIdx; // Reset to continue from after chord
const textAfter = line.substring(afterChordIdx, nextIdx);
segments.push({
chord: chordMatch[1],
text: textAfter || " ",
});
lastIndex = nextIdx;
// If we peeked ahead, go back
if (nextMatch) {
regex.lastIndex = nextMatch.index;
}
}
// Remaining text
if (lastIndex < line.length) {
segments.push({ chord: null, text: line.substring(lastIndex) });
}
// If no chords found, whole line is plain text
if (segments.length === 0) {
segments.push({ chord: null, text: line });
}
result.push(segments);
} else {
// Plain text line (no chords)
result.push([{ chord: null, text: line }]);
}
}
return result;
}
/**
* Transpose chord markers in lyrics text - handles BOTH formats:
* 1. Embedded [Chord] format
* 2. Chords-above-lyrics format (chord-only lines)
*/
export function transposeLyricsText(lyrics, fromKey, toKey) {
if (!lyrics || fromKey === toKey) return lyrics;
const semitones = getSemitonesBetweenKeys(fromKey, toKey);
if (semitones === 0) return lyrics;
// Use flat notation for flat keys
const flatKeys = [
"F",
"Bb",
"Eb",
"Ab",
"Db",
"Gb",
"Dm",
"Gm",
"Cm",
"Fm",
"Bbm",
"Ebm",
];
const useFlats = flatKeys.includes(toKey);
// Split into lines to handle both formats
const lines = lyrics.split("\n");
const chordOnlyPattern =
/^[\sA-G#b/()m\d]*[A-G][#b]?(?:m|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*[\sA-G#b/()m\d]*$/;
const transposedLines = lines.map((line) => {
// Check if this is a chord-only line (chords-above-lyrics format)
if (line.trim().length > 0 && chordOnlyPattern.test(line.trim())) {
// Transpose all standalone chords in this line
return line.replace(
/([A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*)/g,
(match) => {
return transposeChordName(match, semitones, useFlats);
},
);
}
// Otherwise, transpose embedded [Chord] patterns
return line.replace(
/\[([A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*(?:\/[A-Ga-g][#b]?)?)\]/g,
(match, chord) => {
const transposed = transposeChordName(chord, semitones, useFlats);
return `[${transposed}]`;
},
);
});
return transposedLines.join("\n");
}
/**
* Transpose a single chord name by semitones
*/
export function transposeChordName(chord, semitones, useFlats = false) {
if (!chord || semitones === 0) return chord;
// Handle slash chords (e.g., C/G)
if (chord.includes("/")) {
const [main, bass] = chord.split("/");
return (
transposeChordName(main, semitones, useFlats) +
"/" +
transposeChordName(bass, semitones, useFlats)
);
}
// Parse root and quality
const match = chord.match(/^([A-Ga-g][#b]?)(.*)$/);
if (!match) return chord;
const [, root, quality] = match;
const sharpScale = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
const flatScale = [
"C",
"Db",
"D",
"Eb",
"E",
"F",
"Gb",
"G",
"Ab",
"A",
"Bb",
"B",
];
const flatToSharp = {
Db: "C#",
Eb: "D#",
Fb: "E",
Gb: "F#",
Ab: "G#",
Bb: "A#",
Cb: "B",
};
// Normalize root to sharp
const normRoot = root[0].toUpperCase() + (root.slice(1) || "");
const sharpRoot = flatToSharp[normRoot] || normRoot;
// Find index
let idx = sharpScale.indexOf(sharpRoot);
if (idx === -1) return chord;
// Transpose
const newIdx = (idx + semitones + 12) % 12;
const scale = useFlats ? flatScale : sharpScale;
const newRoot = scale[newIdx];
return newRoot + quality;
}
/**
* Detect the original key from lyrics content
*/
export function detectKeyFromLyrics(lyrics) {
if (!lyrics) return "C";
// Find all chords in the lyrics
const chordMatches = lyrics.match(
/\[([A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*)\]/g,
);
if (!chordMatches || chordMatches.length === 0) return "C";
// Extract just the chord names
const chords = chordMatches.map((m) => m.replace(/[\[\]]/g, ""));
// The first chord is often the key
const firstChord = chords[0];
// Check if it's minor
if (firstChord.includes("m") && !firstChord.includes("maj")) {
return firstChord.match(/^[A-Ga-g][#b]?m/)?.[0] || "Am";
}
// Return root as major key
return firstChord.match(/^[A-Ga-g][#b]?/)?.[0] || "C";
}
/**
* Insert chord markers into plain lyrics at specified positions
*/
export function insertChordsIntoLyrics(lyrics, chordPositions) {
// chordPositions: Array of { line: number, position: number, chord: string }
if (!lyrics || !chordPositions || chordPositions.length === 0) return lyrics;
const lines = lyrics.split("\n");
// Group by line
const byLine = {};
chordPositions.forEach((cp) => {
if (!byLine[cp.line]) byLine[cp.line] = [];
byLine[cp.line].push(cp);
});
// Process each line
return lines
.map((line, lineIdx) => {
const lineChords = byLine[lineIdx];
if (!lineChords || lineChords.length === 0) return line;
// Sort by position descending to insert from end
lineChords.sort((a, b) => b.position - a.position);
let result = line;
lineChords.forEach((cp) => {
const pos = Math.min(cp.position, result.length);
result = result.slice(0, pos) + `[${cp.chord}]` + result.slice(pos);
});
return result;
})
.join("\n");
}
/**
* Convert ChordsOverWords format to ChordPro inline format
*/
export function convertChordsOverWordsToInline(content) {
if (!content) return content;
const lines = content.split("\n");
const result = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const nextLine = lines[i + 1];
// Check if current line is mostly chords
if (isChordLine(line) && nextLine && !isChordLine(nextLine)) {
// Merge chord line with lyrics line
const merged = mergeChordLineWithLyrics(line, nextLine);
result.push(merged);
i++; // Skip the next line
} else if (!isChordLine(line)) {
result.push(line);
}
}
return result.join("\n");
}
/**
* Check if a line is primarily chord notation
*/
function isChordLine(line) {
if (!line || line.trim().length === 0) return false;
// Remove chord patterns and see what's left
const withoutChords = line.replace(
/[A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*/g,
"",
);
const originalLength = line.replace(/\s/g, "").length;
const remainingLength = withoutChords.replace(/\s/g, "").length;
// If most content was chords, it's a chord line
return remainingLength < originalLength * 0.3;
}
/**
* Merge a chord line with the lyrics line below it
*/
function mergeChordLineWithLyrics(chordLine, lyricsLine) {
const chordPositions = [];
// Find all chords and their positions
const regex =
/([A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*(?:\/[A-Ga-g][#b]?)?)/g;
let match;
while ((match = regex.exec(chordLine)) !== null) {
chordPositions.push({
chord: match[1],
position: match.index,
});
}
// Sort by position
chordPositions.sort((a, b) => a.position - b.position);
// Insert chords into lyrics at corresponding positions
let result = "";
let lastPos = 0;
for (const { chord, position } of chordPositions) {
const lyricsPos = Math.min(position, lyricsLine.length);
result += lyricsLine.substring(lastPos, lyricsPos) + `[${chord}]`;
lastPos = lyricsPos;
}
result += lyricsLine.substring(lastPos);
return result;
}
// ============================================
// EXPORT DEFAULT
// ============================================
export default {
// Constants
ROOT_NOTES,
CHORD_QUALITIES,
ALL_CHORDS,
ALL_KEYS,
MAJOR_KEY_PROGRESSIONS,
MINOR_KEY_PROGRESSIONS,
// Parsing
parseChordPro,
parsePlainText,
parseLyricsWithChords,
// Rendering
renderToChordPro,
renderToHtml,
renderToText,
// Transposition
transposeSong,
transposeToKey,
transposeLyricsText,
transposeChordName,
getSemitonesBetweenKeys,
// Progressions
getDiatonicChords,
getCommonProgressions,
// Utilities
detectKeyFromLyrics,
insertChordsIntoLyrics,
convertChordsOverWordsToInline,
};

View File

@@ -0,0 +1,371 @@
/**
* Chord Transposition Utility
* Uses tonal.js for accurate music theory-based transposition
*/
import { Note, Interval, Chord } from "tonal";
// All chromatic notes for transposition
const CHROMATIC_SCALE = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
const CHROMATIC_FLATS = [
"C",
"Db",
"D",
"Eb",
"E",
"F",
"Gb",
"G",
"Ab",
"A",
"Bb",
"B",
];
// Common key signatures (for UI display)
export const KEY_OPTIONS = [
{ value: "C", label: "C Major" },
{ value: "C#", label: "C# / Db Major" },
{ value: "D", label: "D Major" },
{ value: "D#", label: "D# / Eb Major" },
{ value: "E", label: "E Major" },
{ value: "F", label: "F Major" },
{ value: "F#", label: "F# / Gb Major" },
{ value: "G", label: "G Major" },
{ value: "G#", label: "G# / Ab Major" },
{ value: "A", label: "A Major" },
{ value: "A#", label: "A# / Bb Major" },
{ value: "B", label: "B Major" },
{ value: "Cm", label: "C Minor" },
{ value: "Dm", label: "D Minor" },
{ value: "Em", label: "E Minor" },
{ value: "Fm", label: "F Minor" },
{ value: "Gm", label: "G Minor" },
{ value: "Am", label: "A Minor" },
{ value: "Bm", label: "B Minor" },
];
/**
* Get the semitone value of a note (0-11)
*/
function getNoteIndex(note) {
const normalized = note.replace(/b/g, "").replace(/#/g, "");
let index = CHROMATIC_SCALE.indexOf(normalized.toUpperCase());
// Handle sharps
if (note.includes("#")) {
index = (index + 1) % 12;
}
// Handle flats
if (note.includes("b") || note.includes("♭")) {
index = (index - 1 + 12) % 12;
}
return index;
}
/**
* Calculate semitone difference between two keys
*/
export function getSemitoneDistance(fromKey, toKey) {
const fromRoot = fromKey.replace(/m$/, ""); // Remove minor suffix
const toRoot = toKey.replace(/m$/, "");
const fromIndex = getNoteIndex(fromRoot);
const toIndex = getNoteIndex(toRoot);
return (toIndex - fromIndex + 12) % 12;
}
/**
* Transpose a single chord by semitones
* @param {string} chord - The chord to transpose (e.g., "Am7", "F#m", "Cmaj7")
* @param {number} semitones - Number of semitones to transpose
* @param {boolean} useFlats - Whether to use flats instead of sharps
* @returns {string} - The transposed chord
*/
export function transposeChord(chord, semitones, useFlats = false) {
if (!chord || semitones === 0) return chord;
// Parse the chord to extract root and quality
const match = chord.match(/^([A-Ga-g][#b♯♭]?)(.*)$/);
if (!match) return chord;
const [, root, quality] = match;
// Get current note index
const currentIndex = getNoteIndex(root);
// Calculate new index
const newIndex = (currentIndex + semitones + 12) % 12;
// Get new root note
const scale = useFlats ? CHROMATIC_FLATS : CHROMATIC_SCALE;
const newRoot = scale[newIndex];
// Preserve original case
const finalRoot =
root === root.toLowerCase() ? newRoot.toLowerCase() : newRoot;
return finalRoot + quality;
}
/**
* Transpose all chords in a string
* @param {string} text - Text containing chords in brackets or standalone
* @param {number} semitones - Semitones to transpose
* @param {boolean} useFlats - Use flats instead of sharps
*/
export function transposeText(text, semitones, useFlats = false) {
if (!text || semitones === 0) return text;
// Match chords in various formats: [Am7], (Cmaj7), or standalone chord patterns
const chordPattern =
/(\[?)([A-G][#b♯♭]?(?:m|maj|min|dim|aug|sus|add)?[0-9]?(?:\/[A-G][#b♯♭]?)?)(\]?)/g;
return text.replace(chordPattern, (match, open, chord, close) => {
// Handle slash chords (e.g., C/G)
if (chord.includes("/")) {
const [main, bass] = chord.split("/");
const transposedMain = transposeChord(main, semitones, useFlats);
const transposedBass = transposeChord(bass, semitones, useFlats);
return `${open}${transposedMain}/${transposedBass}${close}`;
}
return `${open}${transposeChord(chord, semitones, useFlats)}${close}`;
});
}
/**
* Parse lyrics with inline chords
* Chord format: [Chord] before the word/syllable it applies to
* Example: "[Am]Amazing [G]grace how [D]sweet the [Am]sound"
*
* @param {string} lyrics - Raw lyrics with chords
* @returns {Array} - Array of lines, each containing segments with chord/text pairs
*/
export function parseLyricsWithChords(lyrics) {
if (!lyrics) return [];
const lines = lyrics.split("\n");
return lines.map((line) => {
const segments = [];
let currentPosition = 0;
// Match chord patterns like [Am], [G7], [F#m], etc.
const chordRegex =
/\[([A-G][#b♯♭]?(?:m|maj|min|dim|aug|sus|add|M)?[0-9]*(?:\/[A-G][#b♯♭]?)?)\]/g;
let match;
while ((match = chordRegex.exec(line)) !== null) {
// Add text before this chord (if any)
if (match.index > currentPosition) {
const textBefore = line.substring(currentPosition, match.index);
if (textBefore) {
// This text has no chord above it
segments.push({ chord: null, text: textBefore });
}
}
// Find the text after the chord until the next chord or end of line
const chordEnd = match.index + match[0].length;
const nextChordMatch = line.substring(chordEnd).match(/\[[A-G]/);
const nextChordIndex = nextChordMatch
? chordEnd + nextChordMatch.index
: line.length;
const textAfter = line.substring(chordEnd, nextChordIndex);
segments.push({
chord: match[1],
text: textAfter || " ", // At least a space for positioning
});
currentPosition = nextChordIndex;
}
// Add remaining text without chord
if (currentPosition < line.length) {
const remaining = line.substring(currentPosition);
if (remaining) {
segments.push({ chord: null, text: remaining });
}
}
// If no chords found, the whole line is text
if (segments.length === 0) {
segments.push({ chord: null, text: line });
}
return segments;
});
}
/**
* Convert plain lyrics with chord lines to inline format
* Handles the common format where chords are on their own line above lyrics
*
* Example input:
* Am G D
* Amazing grace how sweet
*
* Output:
* [Am]Amazing [G]grace how [D]sweet
*/
export function convertChordLinestoInline(lyrics) {
if (!lyrics) return lyrics;
const lines = lyrics.split("\n");
const result = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const nextLine = lines[i + 1];
// Check if this line is a chord line (contains mostly chord patterns)
if (isChordLine(line) && nextLine && !isChordLine(nextLine)) {
// Merge chord line with lyric line below
const merged = mergeChordAndLyricLines(line, nextLine);
result.push(merged);
i++; // Skip the lyric line since we merged it
} else if (!isChordLine(line)) {
// Regular lyric or section header
result.push(line);
}
// Skip standalone chord lines without lyrics below
}
return result.join("\n");
}
/**
* Check if a line contains primarily chords
*/
function isChordLine(line) {
if (!line || line.trim().length === 0) return false;
// Remove all chord patterns and see what's left
const withoutChords = line
.replace(
/[A-G][#b♯♭]?(?:m|maj|min|dim|aug|sus|add|M)?[0-9]*(?:\/[A-G][#b♯♭]?)?/g,
"",
)
.trim();
const originalContent = line.replace(/\s+/g, "");
// If removing chords leaves very little, it's a chord line
return withoutChords.length < originalContent.length * 0.3;
}
/**
* Merge a chord line with the lyric line below it
*/
function mergeChordAndLyricLines(chordLine, lyricLine) {
const chordPositions = [];
// Find all chords and their positions
const chordRegex =
/([A-G][#b♯♭]?(?:m|maj|min|dim|aug|sus|add|M)?[0-9]*(?:\/[A-G][#b♯♭]?)?)/g;
let match;
while ((match = chordRegex.exec(chordLine)) !== null) {
chordPositions.push({
chord: match[1],
position: match.index,
});
}
// Sort by position (in case regex finds them out of order)
chordPositions.sort((a, b) => a.position - b.position);
// Build the merged line by inserting chords into the lyric
let result = "";
let lastPos = 0;
for (const { chord, position } of chordPositions) {
// Add lyrics up to this chord position
const lyricsBefore = lyricLine.substring(
lastPos,
Math.min(position, lyricLine.length),
);
result += lyricsBefore + `[${chord}]`;
lastPos = Math.min(position, lyricLine.length);
}
// Add remaining lyrics
result += lyricLine.substring(lastPos);
return result;
}
/**
* Extract the original key from song data
*/
export function extractOriginalKey(song) {
if (!song) return "C";
// Check various possible fields
if (song.key_chord)
return song.key_chord.replace(/\s+/g, "").split(/[,\/]/)[0];
if (song.chords) return song.chords.replace(/\s+/g, "").split(/[,\/\s]/)[0];
if (song.original_key) return song.original_key;
if (song.key) return song.key;
// Try to detect from lyrics
const lyrics = song.lyrics || "";
const firstChord = lyrics.match(/\[([A-G][#b♯♭]?m?)\]/);
if (firstChord) return firstChord[1];
return "C"; // Default
}
/**
* Transpose entire lyrics to a new key
*/
export function transposeLyrics(lyrics, fromKey, toKey) {
if (!lyrics || fromKey === toKey) return lyrics;
const semitones = getSemitoneDistance(fromKey, toKey);
// Determine if we should use flats based on the target key
const flatKeys = [
"F",
"Bb",
"Eb",
"Ab",
"Db",
"Gb",
"Dm",
"Gm",
"Cm",
"Fm",
"Bbm",
"Ebm",
];
const useFlats = flatKeys.includes(toKey);
return transposeText(lyrics, semitones, useFlats);
}
export default {
KEY_OPTIONS,
transposeChord,
transposeText,
parseLyricsWithChords,
convertChordLinestoInline,
getSemitoneDistance,
transposeLyrics,
extractOriginalKey,
};

View File

@@ -0,0 +1,29 @@
/**
* Creates a debounced function that delays invoking func until after wait milliseconds
* have elapsed since the last time the debounced function was invoked.
*/
export function debounce(func, wait = 300) {
let timeoutId = null;
const debounced = (...args) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func.apply(this, args);
timeoutId = null;
}, wait);
};
debounced.cancel = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
return debounced;
}
export default debounce;

View File

@@ -0,0 +1,165 @@
/**
* Parse chord sheet from plain text
* Detects chords above lyrics and section headers
*/
export function parseChordSheet(text) {
const lines = text.split("\n");
const result = {
title: "",
artist: "",
key: "",
sections: [],
chords: new Set(),
lyrics: "",
};
let currentSection = { type: "", lines: [] };
let lyricsLines = [];
// Common section headers
const sectionPattern =
/^\s*[\[\(]?(verse|chorus|bridge|pre-?chorus|intro|outro|interlude|hook|tag|ending|v\d|c\d|ch\d)[\d\s]*[\]\)]?\s*:?\s*$/i;
// Chord pattern - detects chords on a line
const chordLinePattern = /^[\s\w#b/]+$/;
const chordPattern = /([A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*)/g;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Skip empty lines
if (!trimmed) {
lyricsLines.push("");
continue;
}
// Check for section headers
if (sectionPattern.test(trimmed)) {
if (currentSection.lines.length > 0) {
result.sections.push({ ...currentSection });
}
currentSection = {
type: trimmed.replace(/[\[\]\(\):]/g, "").trim(),
lines: [],
};
lyricsLines.push(trimmed);
continue;
}
// Check if line contains only chords
const nextLine = i + 1 < lines.length ? lines[i + 1] : "";
const isChordLine =
chordLinePattern.test(trimmed) &&
trimmed.match(chordPattern) &&
nextLine.trim() &&
!chordLinePattern.test(nextLine.trim());
if (isChordLine) {
// Extract chords from this line
const chords = trimmed.match(chordPattern);
if (chords) {
chords.forEach((chord) => result.chords.add(chord));
}
// Map chords to positions in the next line
const lyricLine = nextLine;
let chordedLine = "";
let lastPos = 0;
// Find chord positions
const chordPositions = [];
let tempLine = trimmed;
let pos = 0;
for (const chord of trimmed.split(/\s+/).filter((c) => c.trim())) {
const chordMatch = chord.match(chordPattern);
if (chordMatch) {
const chordPos = trimmed.indexOf(chord, pos);
chordPositions.push({ chord, position: chordPos });
pos = chordPos + chord.length;
}
}
// Build lyrics with embedded chords
chordPositions.forEach((item, idx) => {
const { chord, position } = item;
const nextPos = chordPositions[idx + 1]?.position || lyricLine.length;
const lyricPart = lyricLine.substring(position, nextPos).trim();
if (lyricPart) {
chordedLine += `[${chord}]${lyricPart} `;
}
});
lyricsLines.push(chordedLine.trim() || `[${chords[0]}]`);
currentSection.lines.push(chordedLine.trim());
// Skip the next line since we processed it
i++;
continue;
}
// Regular lyric line
lyricsLines.push(line);
currentSection.lines.push(line);
}
if (currentSection.lines.length > 0) {
result.sections.push(currentSection);
}
result.lyrics = lyricsLines.join("\n");
result.chords = Array.from(result.chords).join(" ");
// Try to detect key from first chord
if (result.chords) {
const firstChord = result.chords.split(" ")[0];
result.key = firstChord;
}
return result;
}
/**
* Parse Word document (.docx) using mammoth
*/
export async function parseWordDocument(file) {
// For now, read as text and parse
const text = await file.text();
return parseChordSheet(text);
}
/**
* Parse PDF document
*/
export async function parsePDFDocument(file) {
// For now, read as text and parse
const text = await file.text();
return parseChordSheet(text);
}
/**
* Auto-detect file type and parse
*/
export async function parseDocument(file) {
const fileType = file.type;
const fileName = file.name.toLowerCase();
if (fileType === "application/pdf" || fileName.endsWith(".pdf")) {
return parsePDFDocument(file);
} else if (
fileType ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
fileName.endsWith(".docx")
) {
return parseWordDocument(file);
} else if (fileType === "text/plain" || fileName.endsWith(".txt")) {
const text = await file.text();
return parseChordSheet(text);
} else {
throw new Error(
"Unsupported file type. Please upload PDF, Word, or TXT files.",
);
}
}