diff --git a/.DS_Store b/.DS_Store index a387ba1..afb5ccb 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 72b6d5d..60df2f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,2 @@ -config2.json -output.txt -generated_data/ -venv/ -__pycache__/ -www/chords.html +build/ +frontend/wailsjs/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0d575c2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "static/vectors"] + path = static/vectors + url = https://git.else-if.org/jess/web-tuner-vectors.git diff --git a/app.go b/app.go new file mode 100644 index 0000000..feb38f7 --- /dev/null +++ b/app.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "os" + "path/filepath" +) + +type App struct { + ctx context.Context + config Config + configPath string +} + +func NewApp() *App { + return &App{} +} + +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + a.loadConfig() +} + +func (a *App) loadConfig() { + exe, _ := os.Executable() + dir := filepath.Dir(exe) + + candidates := []string{ + filepath.Join(dir, "config.json"), + "config.json", + } + + for _, path := range candidates { + cfg, err := LoadConfig(path) + if err == nil { + a.config = cfg + a.configPath = path + return + } + } + + a.configPath = "config.json" + a.config = Config{ + Instrument: "guitar", + Tuning: []string{"E", "A", "D", "G", "B", "E"}, + Frets: 4, + MaxFingers: 4, + } +} + +func (a *App) GetConfig() Config { + return a.config +} + +func (a *App) FindChordFingerings() []ChordResult { + return findChordFingerings(a.config) +} + +func (a *App) GetChordDefinitions() map[string]ChordCategory { + return GetChordDefinitions() +} + +func (a *App) GenerateIntervalPairs() []IntervalData { + return generateIntervalPairs(a.config) +} + +func (a *App) UpdateConfig(cfg Config) ([]ChordResult, error) { + if err := ValidateConfig(cfg); err != nil { + return nil, err + } + a.config = cfg + return findChordFingerings(a.config), nil +} + +func (a *App) SaveConfig() error { + return SaveConfig(a.configPath, a.config) +} + +func (a *App) ResetConfig() Config { + a.loadConfig() + return a.config +} + +func (a *App) GetChordAliases() map[string]string { + return GetChordAliases() +} + +func (a *App) ResolveChordName(name string) string { + return ResolveChordName(name) +} + +func (a *App) GetDefaultShapes() []ShapeDefinition { + return DefaultShapes() +} + +func (a *App) FindShapeTunings(query ShapeQuery, companions []ShapeDefinition) ([]TuningCandidate, error) { + return findTuningsForShape(query, companions) +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..b662e63 --- /dev/null +++ b/build.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -euo pipefail + +ICON_SVG="static/vectors/icon.svg" +BUILD_DIR="build/bin" +ICONSET_DIR="build/icons.iconset" +MASTER_PNG="build/icon_master.png" +INKSCAPE="/Applications/Inkscape.app/Contents/MacOS/inkscape" + +export CC=/usr/bin/clang +export CXX=/usr/bin/clang++ +export SDKROOT=$(xcrun --show-sdk-path) +export PATH="$HOME/go/bin:$PATH" + +if [ ! -f "$ICON_SVG" ]; then + echo "Warning: $ICON_SVG not found, skipping icon generation" +else + # Rasterize SVG faithfully via Inkscape + "$INKSCAPE" "$ICON_SVG" --export-type=png --export-filename="$MASTER_PNG" -w 1024 -h 1024 2>/dev/null + + cp "$MASTER_PNG" build/appicon.png + + # macOS .icns + rm -rf "$ICONSET_DIR" + mkdir -p "$ICONSET_DIR" + for size in 16 32 64 128 256 512; do + sips -z $size $size "$MASTER_PNG" --out "$ICONSET_DIR/icon_${size}x${size}.png" >/dev/null + double=$((size * 2)) + sips -z $double $double "$MASTER_PNG" --out "$ICONSET_DIR/icon_${size}x${size}@2x.png" >/dev/null + done + iconutil -c icns "$ICONSET_DIR" -o build/appicon.icns + rm -rf "$ICONSET_DIR" + mkdir -p build/darwin + cp build/appicon.icns build/darwin/appicon.icns + + # Windows .ico + mkdir -p build/windows + magick "$MASTER_PNG" \ + \( +clone -resize 16x16 \) \ + \( +clone -resize 32x32 \) \ + \( +clone -resize 48x48 \) \ + \( +clone -resize 64x64 \) \ + \( +clone -resize 128x128 \) \ + \( +clone -resize 256x256 \) \ + -delete 0 build/windows/icon.ico + + rm -f "$MASTER_PNG" +fi + +echo "Building macOS (darwin/arm64)..." +wails build -clean -platform darwin/arm64 + +echo "Cross-compiling Windows (windows/amd64)..." +wails build -platform windows/amd64 -skipbindings + +echo "Done. Outputs in $BUILD_DIR/" +ls -lh "$BUILD_DIR/" diff --git a/chords.go b/chords.go new file mode 100644 index 0000000..e9650e6 --- /dev/null +++ b/chords.go @@ -0,0 +1,138 @@ +package main + +import "strings" + +type ChordCategory map[string][]int + +func GetChordDefinitions() map[string]ChordCategory { + return map[string]ChordCategory{ + "triads": { + "major": {0, 4, 7}, + "minor": {0, 3, 7}, + "dim": {0, 3, 6}, + "aug": {0, 4, 8}, + "sus2": {0, 2, 7}, + "sus4": {0, 5, 7}, + "5": {0, 7}, + }, + "sixths": { + "6": {0, 4, 7, 9}, + "m6": {0, 3, 7, 9}, + "6/9": {0, 2, 4, 7, 9}, + "m6/9": {0, 2, 3, 7, 9}, + "b6": {0, 4, 7, 8}, + "mb6": {0, 3, 7, 8}, + "b6/9": {0, 2, 4, 7, 8}, + }, + "sevenths": { + "maj7": {0, 4, 7, 11}, + "7": {0, 4, 7, 10}, + "m7": {0, 3, 7, 10}, + "mmaj7": {0, 3, 7, 11}, + "dim7": {0, 3, 6, 9}, + "m7b5": {0, 3, 6, 10}, + "aug7": {0, 4, 8, 10}, + "augmaj7": {0, 4, 8, 11}, + "7sus4": {0, 5, 7, 10}, + "7sus2": {0, 2, 7, 10}, + "7b5": {0, 4, 6, 10}, + "maj7b5": {0, 4, 6, 11}, + "maj7sus2": {0, 2, 7, 11}, + "maj7sus4": {0, 5, 7, 11}, + }, + "ninths": { + "9": {0, 2, 4, 7, 10}, + "maj9": {0, 2, 4, 7, 11}, + "m9": {0, 2, 3, 7, 10}, + "mmaj9": {0, 2, 3, 7, 11}, + "7b9": {0, 1, 4, 7, 10}, + "7#9": {0, 3, 4, 7, 10}, + "add9": {0, 2, 4, 7}, + "madd9": {0, 2, 3, 7}, + "9sus4": {0, 2, 5, 7, 10}, + "9b5": {0, 2, 4, 6, 10}, + "7b9b5": {0, 1, 4, 6, 10}, + "7#9b5": {0, 3, 4, 6, 10}, + "7b9#5": {0, 1, 4, 8, 10}, + "7#9#5": {0, 3, 4, 8, 10}, + }, + "elevenths": { + "11": {0, 2, 4, 5, 7, 10}, + "maj11": {0, 2, 4, 5, 7, 11}, + "m11": {0, 2, 3, 5, 7, 10}, + "7#11": {0, 4, 6, 7, 10}, + "maj7#11": {0, 4, 6, 7, 11}, + "add11": {0, 4, 5, 7}, + "madd11": {0, 3, 5, 7}, + }, + "thirteenths": { + "13": {0, 2, 4, 7, 9, 10}, + "maj13": {0, 2, 4, 7, 9, 11}, + "m13": {0, 2, 3, 7, 9, 10}, + "7b13": {0, 4, 7, 8, 10}, + }, + } +} + +func GetChordAliases() map[string]string { + return map[string]string{ + "maj": "major", "M": "major", + "min": "minor", "m": "minor", "-": "minor", + "diminished": "dim", "\u00b0": "dim", + "augmented": "aug", "+": "aug", + "power": "5", + + "major6": "6", "maj6": "6", + "minor6": "m6", "min6": "m6", "-6": "m6", + "6_9": "6/9", + "flat6": "b6", + "mflat6": "mb6", "minb6": "mb6", "-b6": "mb6", + "flat6/9": "b6/9", + + "major7": "maj7", "M7": "maj7", "\u03947": "maj7", "\u0394": "maj7", + "dom7": "7", "dominant7": "7", "dominant": "7", + "min7": "m7", "minor7": "m7", "-7": "m7", + "m(maj7)": "mmaj7", "min(maj7)": "mmaj7", "mM7": "mmaj7", + "minmaj7": "mmaj7", "-M7": "mmaj7", "minor-major7": "mmaj7", + "\u00b07": "dim7", "diminished7": "dim7", + "\u00f8": "m7b5", "\u00f87": "m7b5", "half-dim": "m7b5", + "half-dim7": "m7b5", "halfdim": "m7b5", "halfdim7": "m7b5", + "+7": "aug7", "augmented7": "aug7", + "+M7": "augmaj7", "+maj7": "augmaj7", + "maj7#5": "augmaj7", "M7#5": "augmaj7", + "7sus": "7sus4", + "majmin7": "7", + + "dom9": "9", "dominant9": "9", + "min9": "m9", "minor9": "m9", "-9": "m9", + "m(maj9)": "mmaj9", "mM9": "mmaj9", "minmaj9": "mmaj9", + "7flat9": "7b9", + "7sharp9": "7#9", + "add2": "add9", + "madd2": "madd9", "minadd9": "madd9", + + "dom11": "11", "dominant11": "11", + "min11": "m11", "minor11": "m11", "-11": "m11", + "add4": "add11", + "madd4": "madd11", + + "dom13": "13", "dominant13": "13", + "min13": "m13", "minor13": "m13", "-13": "m13", + "7flat13": "7b13", + } +} + +func ResolveChordName(name string) string { + name = strings.TrimSpace(name) + aliases := GetChordAliases() + if canonical, ok := aliases[name]; ok { + return canonical + } + lower := strings.ToLower(name) + for alias, canonical := range aliases { + if strings.ToLower(alias) == lower { + return canonical + } + } + return name +} diff --git a/chords.py b/chords.py deleted file mode 100644 index d8f14be..0000000 --- a/chords.py +++ /dev/null @@ -1,59 +0,0 @@ -import json -import os - -def load_config(path="config.json"): - """Load configuration from a JSON file.""" - with open(path, "r") as f: - return json.load(f) - -OUTPUT_DIR = "generated_data" -os.makedirs(OUTPUT_DIR, exist_ok=True) - -def export_json(data, name): - """Helper function to export a dictionary to a JSON file.""" - path = os.path.join(OUTPUT_DIR, f"{name}.json") - with open(path, "w") as f: - json.dump(data, f, indent=2) - print(f"Exported: {path}") - -def generate_chord_definitions(config=None): - """Generate chord definitions for triads, 7ths, 6ths, and extended chords.""" - chords = { - "triads": { - "major": [0, 4, 7], - "minor": [0, 3, 7], - "diminished": [0, 3, 6] - }, - "sevenths": { - "maj7": [0, 4, 7, 11], - "min7": [0, 3, 7, 10], - "dom7": [0, 4, 7, 10], - "m7b5": [0, 3, 6, 10] - }, - "sixths": { - "major6": [0, 4, 7, 9], - "minor6": [0, 3, 7, 9], - "dim": [0, 3, 6, 9], - "6_9": [0, 2, 4, 7, 9] - }, - "ext": { - "maj9": [0, 2, 4, 7, 11], - "sus2": [0, 2, 7], - "sus4": [0, 5, 7], - "majmin7": [0, 4, 7, 10], - "augmented": [0, 4, 8], - "dim7": [0, 3, 6, 9], - "#11": [0, 4, 6, 11], - "5maj9": [0, 2, 7], - "5maj7_9": [0, 2, 7, 11] - } - } - export_json(chords, "chord_definitions") - return chords - -def main(): - config = load_config() - generate_chord_definitions(config) - -if __name__ == "__main__": - main() diff --git a/config.go b/config.go new file mode 100644 index 0000000..8e47034 --- /dev/null +++ b/config.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" +) + +type Config struct { + Instrument string `json:"instrument"` + Tuning []string `json:"tuning"` + Frets int `json:"frets"` + MaxFingers int `json:"max_fingers"` +} + +func LoadConfig(path string) (Config, error) { + var cfg Config + data, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + err = json.Unmarshal(data, &cfg) + return cfg, err +} + +func SaveConfig(path string, cfg Config) error { + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +func ValidateConfig(cfg Config) error { + if len(cfg.Tuning) == 0 { + return fmt.Errorf("tuning must have at least one string") + } + for _, note := range cfg.Tuning { + if _, ok := NoteToSemitone[note]; !ok { + return fmt.Errorf("invalid note: %s", note) + } + } + if cfg.Frets < 1 || cfg.Frets > 24 { + return fmt.Errorf("frets must be between 1 and 24") + } + if cfg.MaxFingers < 1 || cfg.MaxFingers > 6 { + return fmt.Errorf("max fingers must be between 1 and 6") + } + return nil +} diff --git a/config.json b/config.json index ced5c1d..7a6c7dd 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "instrument": "guitar", - "tuning": ["C#", "F#", "B", "G#", "B", "D#"], - "frets": 7, + "tuning": ["B", "F#", "B", "F#", "A", "C#"], + "frets": 5, "max_fingers": 4 } diff --git a/debug.go b/debug.go new file mode 100644 index 0000000..2b85983 --- /dev/null +++ b/debug.go @@ -0,0 +1,10 @@ +//go:build debug + +package main + +import "log" + +func init() { + log.SetFlags(log.Ltime | log.Lshortfile) + log.Println("[debug] debug build active") +} diff --git a/debug.sh b/debug.sh new file mode 100755 index 0000000..4e260e7 --- /dev/null +++ b/debug.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail + +export CC=/usr/bin/clang +export CXX=/usr/bin/clang++ +export SDKROOT=$(xcrun --show-sdk-path) +export PATH="$HOME/go/bin:$PATH" + +echo "Starting dev build with debug tag..." +wails dev -tags debug -loglevel debug diff --git a/fingerings.go b/fingerings.go new file mode 100644 index 0000000..a0818fb --- /dev/null +++ b/fingerings.go @@ -0,0 +1,532 @@ +package main + +import ( + "fmt" + "strings" + "sync" +) + +type ChordResult struct { + Chord string `json:"chord"` + Root string `json:"root"` + Quality string `json:"quality"` + Category string `json:"category"` + Fingering []string `json:"fingering"` + Alternatives [][]string `json:"alternatives"` +} + +type chordSpec struct { + fullName string + quality string + category string + intervals []int +} + +func findChordFingerings(cfg Config) []ChordResult { + tuning := cfg.Tuning + numStrings := len(tuning) + maxFret := cfg.Frets + maxFingers := cfg.MaxFingers + + defs := GetChordDefinitions() + + var specs []chordSpec + for category, group := range defs { + for name, intervals := range group { + label := titleCase(category) + if len(label) > 1 { + label = label[:len(label)-1] + } + full := fmt.Sprintf("%s %s", titleCase(name), label) + specs = append(specs, chordSpec{ + fullName: full, + quality: name, + category: category, + intervals: intervals, + }) + } + } + + optCount := maxFret + 2 // 0..maxFret + "x" + totalCombinations := 1 + for i := 0; i < numStrings; i++ { + totalCombinations *= optCount + } + + type intermediateResult struct { + chord string + root string + quality string + category string + fingering []string + intervalSet map[int]bool + } + + var mu sync.Mutex + var allResults []intermediateResult + generatedFingerings := make(map[string]bool) + + var wg sync.WaitGroup + for _, spec := range specs { + wg.Add(1) + go func(sp chordSpec) { + defer wg.Done() + + intervalSet := make(map[int]bool, len(sp.intervals)) + for _, iv := range sp.intervals { + intervalSet[iv] = true + } + + var localResults []intermediateResult + + fingering := make([]string, numStrings) + for combo := 0; combo < totalCombinations; combo++ { + tmp := combo + for s := numStrings - 1; s >= 0; s-- { + val := tmp % optCount + tmp /= optCount + if val == maxFret+1 { + fingering[s] = "x" + } else { + fingering[s] = fmt.Sprintf("%d", val) + } + } + + if !isValidMuteConfig(fingering) { + continue + } + if countEffectiveFingers(fingering, numStrings) > maxFingers { + continue + } + + frettedSemitones := make([]int, 0, numStrings) + for i, f := range fingering { + if f == "x" { + continue + } + fretNum := atoi(f) + sem := (NoteToSemitone[strings.TrimSpace(tuning[i])] + fretNum) % 12 + frettedSemitones = append(frettedSemitones, sem) + } + + uniqueNotes := uniqueInts(frettedSemitones) + if len(uniqueNotes) < len(sp.intervals) { + continue + } + + for _, root := range uniqueNotes { + match := true + intervalsFound := make(map[int]bool, len(frettedSemitones)) + for _, sem := range frettedSemitones { + intervalsFound[(sem-root+12)%12] = true + } + if len(intervalsFound) != len(intervalSet) { + match = false + } else { + for iv := range intervalSet { + if !intervalsFound[iv] { + match = false + break + } + } + } + if !match { + continue + } + + key := fingeringKey(fingering) + mu.Lock() + if generatedFingerings[key] { + mu.Unlock() + continue + } + generatedFingerings[key] = true + mu.Unlock() + + rootName := SemitoneToNote[root] + fCopy := make([]string, numStrings) + copy(fCopy, fingering) + + localResults = append(localResults, intermediateResult{ + chord: fmt.Sprintf("%s %s", rootName, sp.fullName), + root: rootName, + quality: sp.quality, + category: sp.category, + fingering: fCopy, + intervalSet: intervalSet, + }) + break + } + } + + mu.Lock() + allResults = append(allResults, localResults...) + mu.Unlock() + }(spec) + } + wg.Wait() + + // Group by chord name + grouped := make(map[string][]intermediateResult) + for _, r := range allResults { + key := r.chord + grouped[key] = append(grouped[key], r) + } + + var finalResults []ChordResult + for chordName, fingerings := range grouped { + first := fingerings[0] + checked := make(map[string]bool) + var primary []string + var alternatives [][]string + hasFrettedPrimary := false + + for _, r := range fingerings { + key := fingeringKey(r.fingering) + if checked[key] { + continue + } + checked[key] = true + + isOpen := isOpenChord(r.fingering) + isFretted := !isOpen + isExact := isSameChord(r.fingering, tuning, r.intervalSet) + + if isExact { + if isFretted { + if !hasFrettedPrimary { + primary = r.fingering + hasFrettedPrimary = true + } else { + alternatives = append(alternatives, r.fingering) + } + } else if !hasFrettedPrimary { + if primary == nil { + primary = r.fingering + } else { + alternatives = append(alternatives, r.fingering) + } + } else { + alternatives = append(alternatives, r.fingering) + } + } else { + if primary != nil { + alternatives = append(alternatives, r.fingering) + } else if primary == nil && len(alternatives) == 0 { + primary = r.fingering + } else { + alternatives = append(alternatives, r.fingering) + } + } + + // Generate muted variations of primary + if primary != nil && fingeringKey(r.fingering) == fingeringKey(primary) { + muteAlts := generateMutedVariations(primary, tuning, r.intervalSet, maxFingers) + alternatives = append(alternatives, muteAlts...) + } + } + + if primary == nil && len(alternatives) > 0 { + primary = alternatives[0] + alternatives = alternatives[1:] + } + + if primary == nil { + continue + } + + cleanName := chordName + for _, suffix := range []string{" Triad", " Sixth", " Seventh", " Ninth", " Eleventh", " Thirteenth"} { + cleanName = strings.ReplaceAll(cleanName, suffix, "") + } + + // Deduplicate alternatives + seen := make(map[string]bool) + seen[fingeringKey(primary)] = true + var dedupAlts [][]string + for _, alt := range alternatives { + k := fingeringKey(alt) + if seen[k] { + continue + } + if countFingers(alt) > maxFingers { + continue + } + seen[k] = true + dedupAlts = append(dedupAlts, alt) + } + + finalResults = append(finalResults, ChordResult{ + Chord: cleanName, + Root: first.root, + Quality: first.quality, + Category: first.category, + Fingering: primary, + Alternatives: dedupAlts, + }) + } + + return finalResults +} + +func isValidMuteConfig(fingering []string) bool { + for i, f := range fingering { + if f == "x" && i != 0 && i != len(fingering)-1 { + return false + } + } + return true +} + +func countEffectiveFingers(fingering []string, numStrings int) int { + type fretInfo struct { + fret int + strings []int + } + + frets := make(map[int][]int) + for i, f := range fingering { + if f == "x" || f == "0" { + continue + } + fv := atoi(f) + frets[fv] = append(frets[fv], i) + } + + if len(frets) == 0 { + return 0 + } + + type fingerKey struct { + fret int + barre bool + } + used := make(map[fingerKey]bool) + + for fret, strings := range frets { + if len(strings) >= 2 { + start := strings[0] + end := strings[len(strings)-1] + for _, s := range strings { + if s < start { + start = s + } + if s > end { + end = s + } + } + if end-start <= 4 { + valid := true + for i := start; i <= end; i++ { + if fingering[i] == "x" { + continue + } + fv := atoi(fingering[i]) + if fv < fret { + valid = false + break + } + } + if valid { + used[fingerKey{fret, true}] = true + } + } + } + } + + for fret := range frets { + found := false + for k := range used { + if k.fret == fret { + found = true + break + } + } + if !found { + used[fingerKey{fret, false}] = true + } + } + + count := 0 + for k := range used { + if k.barre { + count += 2 + } else { + count++ + } + } + return count +} + +func detectBarres(fingering []string, numStrings int) []map[string]interface{} { + frets := make(map[int][]int) + for i, f := range fingering { + if f == "x" || f == "0" { + continue + } + frets[atoi(f)] = append(frets[atoi(f)], i) + } + + var barres []map[string]interface{} + for fret, strings := range frets { + if len(strings) < 2 { + continue + } + valid := true + for i := 0; i < numStrings; i++ { + if fingering[i] == "x" { + continue + } + fv := atoi(fingering[i]) + if fv < fret { + valid = false + break + } + } + if valid { + barres = append(barres, map[string]interface{}{ + "fret": fret, + "strings": strings, + }) + } + } + return barres +} + +func isSameChord(fingering []string, tuning []string, intervals map[int]bool) bool { + var fretted []int + for i, f := range fingering { + if f == "x" { + continue + } + note := (NoteToSemitone[strings.TrimSpace(tuning[i])] + atoi(f)) % 12 + fretted = append(fretted, note) + } + + roots := uniqueInts(fretted) + for _, root := range roots { + iSet := make(map[int]bool) + for _, note := range fretted { + iSet[(note-root+12)%12] = true + } + if len(iSet) == len(intervals) { + match := true + for iv := range intervals { + if !iSet[iv] { + match = false + break + } + } + if match { + return true + } + } + } + return false +} + +func generateMutedVariations(primary []string, tuning []string, intervals map[int]bool, maxFingers int) [][]string { + n := len(primary) + var results [][]string + + for numMute := 1; numMute < n; numMute++ { + combinations(n, numMute, func(idxs []int) { + test := make([]string, n) + copy(test, primary) + for _, i := range idxs { + test[i] = "x" + } + if !isValidMuteConfig(test) { + return + } + if countFingers(test) > maxFingers { + return + } + if isSameChord(test, tuning, intervals) { + cp := make([]string, n) + copy(cp, test) + results = append(results, cp) + } + }) + } + return results +} + +func combinations(n, k int, fn func([]int)) { + idxs := make([]int, k) + for i := range idxs { + idxs[i] = i + } + + for { + cp := make([]int, k) + copy(cp, idxs) + fn(cp) + + i := k - 1 + for i >= 0 && idxs[i] == i+n-k { + i-- + } + if i < 0 { + break + } + idxs[i]++ + for j := i + 1; j < k; j++ { + idxs[j] = idxs[j-1] + 1 + } + } +} + +func isOpenChord(fingering []string) bool { + for _, f := range fingering { + if f != "0" && f != "x" { + return false + } + } + return true +} + +func countFingers(fingering []string) int { + count := 0 + for _, f := range fingering { + if f != "x" && f != "0" { + count++ + } + } + return count +} + +func fingeringKey(f []string) string { + return strings.Join(f, ",") +} + +func uniqueInts(s []int) []int { + seen := make(map[int]bool, len(s)) + var result []int + for _, v := range s { + if !seen[v] { + seen[v] = true + result = append(result, v) + } + } + return result +} + +func atoi(s string) int { + n := 0 + for _, c := range s { + n = n*10 + int(c-'0') + } + return n +} + +func titleCase(s string) string { + if len(s) == 0 { + return s + } + b := []byte(s) + if b[0] >= 'a' && b[0] <= 'z' { + b[0] -= 32 + } + return string(b) +} diff --git a/frontend/dist/app.js b/frontend/dist/app.js new file mode 100644 index 0000000..55fcee9 --- /dev/null +++ b/frontend/dist/app.js @@ -0,0 +1,234 @@ +document.addEventListener('DOMContentLoaded', () => { + const navLinks = document.querySelectorAll('.nav-link'); + const views = document.querySelectorAll('.view'); + const filterSection = document.querySelector('.filter-section'); + const themeSelect = document.getElementById('theme-select'); + const themeLink = document.getElementById('theme-stylesheet'); + const btnExport = document.getElementById('btn-export'); + const btnApply = document.getElementById('btn-apply'); + const btnSave = document.getElementById('btn-save'); + const btnReset = document.getElementById('btn-reset'); + const presetSelect = document.getElementById('preset-select'); + const tuningGrid = document.getElementById('tuning-grid'); + const fretsInput = document.getElementById('frets-input'); + const fingersInput = document.getElementById('fingers-input'); + const loading = document.getElementById('chord-loading'); + + const allNotes = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; + + // --- Audio --- + const noteToSemitone = {C:0,'C#':1,D:2,'D#':3,E:4,F:5,'F#':6,G:7,'G#':8,A:9,'A#':10,B:11}; + const standardMIDI = [40, 45, 50, 55, 59, 64]; + + function tuningNamesToMIDI(names) { + return names.map((n, i) => { + const pc = noteToSemitone[n]; + if (pc === undefined) return standardMIDI[i] || 40; + const std = standardMIDI[i] || 40; + for (let m = std - 6; m <= std + 6; m++) { + if (((m % 12) + 12) % 12 === pc) return m; + } + return std; + }); + } + + window.currentTuningMIDI = standardMIDI.slice(); + + let polySynth = null; + window.playChord = function(midiNotes) { + Tone.start(); + if (!polySynth) { + polySynth = new Tone.PolySynth(Tone.Synth, {maxPolyphony: 8}).toDestination(); + polySynth.set({ + oscillator: {type: 'triangle'}, + envelope: {attack: 0.01, decay: 0.3, sustain: 0.4, release: 1.0} + }); + } + polySynth.releaseAll(); + const now = Tone.now(); + midiNotes.forEach((m, i) => { + const freq = 440 * Math.pow(2, (m - 69) / 12); + polySynth.triggerAttackRelease(freq, '1.5s', now + i * 0.03); + }); + }; + + const presets = { + 'Standard': ['E','A','D','G','B','E'], + 'Drop D': ['D','A','D','G','B','E'], + 'DADGAD': ['D','A','D','G','A','D'], + 'Open G': ['D','G','B','D','G','B'], + 'Open D': ['D','A','D','F#','A','D'], + 'Open C': ['C','G','C','G','C','E'], + 'Half Step Down': ['D#','G#','C#','F#','A#','D#'], + 'Full Step Down': ['D','G','C','F','A','D'], + 'Custom': null + }; + + const shapesSection = document.getElementById('shapes-section'); + + let currentConfig = null; + let chordsLoaded = false; + let shapesInited = false; + + // --- Navigation --- + navLinks.forEach(link => { + link.addEventListener('click', () => { + const target = link.dataset.view; + navLinks.forEach(l => l.classList.remove('active')); + views.forEach(v => v.classList.remove('active')); + link.classList.add('active'); + document.getElementById('view-' + target).classList.add('active'); + filterSection.style.display = target === 'chords' ? '' : 'none'; + shapesSection.style.display = target === 'shapes' ? '' : 'none'; + + if (target === 'chords' && !chordsLoaded) { + loadChords(); + } + if (target === 'shapes' && !shapesInited) { + shapesInited = true; + if (window.initShapeExplorer) window.initShapeExplorer(); + } + }); + }); + + // --- Theme --- + themeSelect.addEventListener('change', () => { + themeLink.href = 'chords-' + themeSelect.value + '.css'; + }); + + // --- PDF Export --- + btnExport.addEventListener('click', () => window.print()); + + // --- Config Panel --- + function populatePresets() { + presetSelect.innerHTML = ''; + for (const name of Object.keys(presets)) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + presetSelect.appendChild(opt); + } + } + + function buildTuningGrid(tuning) { + tuningGrid.innerHTML = ''; + tuning.forEach((note, i) => { + const sel = document.createElement('select'); + allNotes.forEach(n => { + const opt = document.createElement('option'); + opt.value = n; + opt.textContent = n; + if (n === note) opt.selected = true; + sel.appendChild(opt); + }); + sel.addEventListener('change', () => { + presetSelect.value = 'Custom'; + }); + tuningGrid.appendChild(sel); + }); + } + + function readTuningFromGrid() { + return Array.from(tuningGrid.querySelectorAll('select')).map(s => s.value); + } + + function syncConfigPanel(cfg) { + fretsInput.value = cfg.frets; + fingersInput.value = cfg.max_fingers; + buildTuningGrid(cfg.tuning); + + let matched = false; + for (const [name, notes] of Object.entries(presets)) { + if (notes && notes.length === cfg.tuning.length && + notes.every((n, i) => n === cfg.tuning[i])) { + presetSelect.value = name; + matched = true; + break; + } + } + if (!matched) presetSelect.value = 'Custom'; + } + + presetSelect.addEventListener('change', () => { + const notes = presets[presetSelect.value]; + if (notes) { + buildTuningGrid(notes); + } + }); + + btnApply.addEventListener('click', () => { + if (!window.go) return; + const cfg = { + instrument: currentConfig ? currentConfig.instrument : 'guitar', + tuning: readTuningFromGrid(), + frets: parseInt(fretsInput.value) || 4, + max_fingers: parseInt(fingersInput.value) || 4 + }; + loading.style.display = ''; + loading.textContent = 'Regenerating chords...'; + + window.go.main.App.UpdateConfig(cfg).then(chords => { + currentConfig = cfg; + window.currentTuningMIDI = tuningNamesToMIDI(cfg.tuning); + loading.style.display = 'none'; + chordsLoaded = true; + if (window.buildChordCards) { + window.buildChordCards(chords || [], cfg.frets, cfg.tuning.length); + } + }).catch(err => { + loading.textContent = 'Error: ' + err; + }); + }); + + btnSave.addEventListener('click', () => { + if (!window.go) return; + window.go.main.App.SaveConfig().then(() => { + btnSave.textContent = 'Saved'; + setTimeout(() => { btnSave.textContent = 'Save'; }, 1500); + }).catch(err => { + btnSave.textContent = 'Error'; + setTimeout(() => { btnSave.textContent = 'Save'; }, 1500); + }); + }); + + btnReset.addEventListener('click', () => { + if (!window.go) return; + window.go.main.App.ResetConfig().then(cfg => { + currentConfig = cfg; + window.currentTuningMIDI = tuningNamesToMIDI(cfg.tuning); + syncConfigPanel(cfg); + loadChords(); + }); + }); + + // --- Load Chords --- + function loadChords() { + if (!window.go) { + loading.textContent = 'Wails runtime not available.'; + return; + } + loading.style.display = ''; + loading.textContent = 'Loading chord fingerings...'; + + window.go.main.App.FindChordFingerings().then(chords => { + loading.style.display = 'none'; + chordsLoaded = true; + if (window.buildChordCards) { + window.buildChordCards(chords || [], currentConfig.frets, currentConfig.tuning.length); + } + }); + } + + // --- Init --- + populatePresets(); + + if (window.go && window.go.main && window.go.main.App) { + window.go.main.App.GetConfig().then(cfg => { + currentConfig = cfg; + window.currentTuningMIDI = tuningNamesToMIDI(cfg.tuning); + syncConfigPanel(cfg); + }); + } else { + loading.textContent = 'Wails runtime not available.'; + } +}); diff --git a/frontend/dist/chords-darcula.css b/frontend/dist/chords-darcula.css new file mode 100644 index 0000000..55d7c97 --- /dev/null +++ b/frontend/dist/chords-darcula.css @@ -0,0 +1,48 @@ +@import url('chords-light.css'); + +.chord-card { + border-color: #555; + background: #2b2b2b; +} + +.chord-card h2 { + color: #cdd3de; +} + +.fretboard { + border: .5px; + background: #515151; +} + +.fretboard .fret { + background: #333 !important; +} + +.fret { + border: .5px solid #666; + border-left: 2px solid #555; + border-right: 2px solid #555; + background: #454545; + color: transparent; +} + +.fret[data-dot]::after { + background: rgba(242, 119, 122, 0.7); + border: 1px solid #d95a5a; +} + +.fret:empty { + background: #454545; +} + +.alternatives h3 { + color: #a9b7c6; +} + +.barre-line { + background: #f2777a; +} + +.fret[muted] { + color: #a9b7c6; +} diff --git a/frontend/dist/chords-default.css b/frontend/dist/chords-default.css new file mode 100644 index 0000000..a876b7b --- /dev/null +++ b/frontend/dist/chords-default.css @@ -0,0 +1,49 @@ +@import url('chords-light.css'); + +.chord-card { + border-color: var(--border-light, #333638); + background: var(--bg-surface, #1e1f20); +} + +.chord-card h2 { + color: var(--text-primary, #e3e3e3); +} + +.fretboard { + border: .5px; + background: #2d3142; +} + +.fretboard .fret { + background: var(--bg-base, #131314) !important; +} + +.fret { + border: .5px solid var(--border, #444746); + border-left: 2px solid transparent; + border-right: 2px; + background: var(--bg-overlay, #282a2c); + color: transparent; +} + +.fret[data-dot]::after { + background: rgba(138, 180, 248, 0.6); + border: 1px solid #8ab4f8; +} + +.fret:empty { + background: var(--bg-overlay, #282a2c); +} + +.alternatives h3 { + color: var(--text-secondary, #c4c7c5); +} + +.barre-line { + background: #8ab4f8; + color: #131314; +} + +.fret[muted] { + color: var(--text-secondary, #c4c7c5); +} diff --git a/frontend/dist/chords-light.css b/frontend/dist/chords-light.css new file mode 100644 index 0000000..1caf030 --- /dev/null +++ b/frontend/dist/chords-light.css @@ -0,0 +1,59 @@ +.chord-card { + border: 1px solid #d8d8d8; + border-radius: var(--radius, 12px); + padding: 1rem; + background: #fff; + width: max-content; +} + +.chord-card h2 { + margin-top: 0; + font-size: 1.2rem; + font-weight: 500; + color: #1a1a1a; +} + +.fretboard { + border: 1px solid #0001; + background: #c53737d1; +} + +.fretboard .fret { + background: #fff !important; +} + +.fret { + border: .5px solid #0002; + border-left: 2px solid #fff; + border-right: 2px solid #0001; + background: #fff; + color: #fff; +} + +.fret[data-dot]::after { + background: #8ab4f8; + border: 1px solid #4a7bd4; +} + +.fret:empty { + background: #fff; +} + +.alternatives h3 { + color: #666; +} + +.fret.barre::after { + background: transparent; + border: 1px solid #4a7bd4; + z-index: 1; +} + +.barre-line { + background: #1a1a1a; + color: white; +} + +.fret[muted] { + color: #1a1a1a; +} diff --git a/frontend/dist/chords-solarized.css b/frontend/dist/chords-solarized.css new file mode 100644 index 0000000..1bc4102 --- /dev/null +++ b/frontend/dist/chords-solarized.css @@ -0,0 +1,48 @@ +@import url('chords-light.css'); + +.chord-card { + border-color: #073642; + background: #002b36; +} + +.chord-card h2 { + color: #b58900; +} + +.fretboard { + border: .5px; + background: #073642; +} + +.fretboard .fret { + background: #002b36 !important; +} + +.fret { + border: .5px solid #586e75; + border-left: 2px solid #073642; + border-right: 2px solid #073642; + background: #073642; + color: transparent; +} + +.fret[data-dot]::after { + background: rgba(220, 50, 47, 0.7); + border: 1px solid #cb4b16; +} + +.fret:empty { + background: #073642; +} + +.alternatives h3 { + color: #839496; +} + +.barre-line { + background: #dc322f; +} + +.fret[muted] { + color: #839496; +} diff --git a/frontend/dist/chords-tomorrow-amoled.css b/frontend/dist/chords-tomorrow-amoled.css new file mode 100644 index 0000000..336c630 --- /dev/null +++ b/frontend/dist/chords-tomorrow-amoled.css @@ -0,0 +1,49 @@ +@import url('chords-light.css'); + +.chord-card { + border-color: #2a2a29; + background: #111; +} + +.chord-card h2 { + color: #fff; +} + +.fretboard { + border: .5px; + background: #222; +} + +.fretboard .fret { + background: #111 !important; +} + +.fret { + border: .5px solid #333; + border-left: 2px solid #222; + border-right: 2px solid #222; + background: #2a2a2a; + color: transparent; +} + +.fret[data-dot]::after { + background: rgba(255, 205, 86, 0.7); + border: 1px solid #f7bb33; +} + +.fret:empty { + background: #2a2a2a; +} + +.alternatives h3 { + color: #fff; +} + +.barre-line { + background: #ffcd56; + color: #000; +} + +.fret[muted] { + color: #fff; +} diff --git a/frontend/dist/chords.js b/frontend/dist/chords.js new file mode 100644 index 0000000..acf8dca --- /dev/null +++ b/frontend/dist/chords.js @@ -0,0 +1,248 @@ +(() => { + const container = document.getElementById('chord-container'); + const rootFilters = document.getElementById('root-filters'); + const qualityFilters = document.getElementById('quality-filters'); + + const roots = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; + let activeRoots = new Set(); + let activeQualities = new Set(); + let allChords = []; + let maxFret = 4; + let numStrings = 6; + + function initFilters() { + roots.forEach(r => { + const pill = document.createElement('button'); + pill.className = 'filter-pill'; + pill.textContent = r; + pill.addEventListener('click', () => { + if (activeRoots.has(r)) { + activeRoots.delete(r); + pill.classList.remove('active'); + } else { + activeRoots.add(r); + pill.classList.add('active'); + } + applyFilters(); + }); + rootFilters.appendChild(pill); + }); + + if (window.go && window.go.main && window.go.main.App) { + window.go.main.App.GetChordDefinitions().then(defs => { + const qualities = new Set(); + for (const cat of Object.keys(defs)) { + for (const q of Object.keys(defs[cat])) { + qualities.add(q); + } + } + Array.from(qualities).sort().forEach(q => { + const pill = document.createElement('button'); + pill.className = 'filter-pill'; + pill.textContent = q; + pill.addEventListener('click', () => { + if (activeQualities.has(q)) { + activeQualities.delete(q); + pill.classList.remove('active'); + } else { + activeQualities.add(q); + pill.classList.add('active'); + } + applyFilters(); + }); + qualityFilters.appendChild(pill); + }); + }); + } + } + + function applyFilters() { + const cards = container.querySelectorAll('.chord-card'); + cards.forEach((card, i) => { + const chord = allChords[i]; + if (!chord) return; + + let show = true; + if (activeRoots.size > 0 && !activeRoots.has(chord.root)) { + show = false; + } + if (activeQualities.size > 0 && !activeQualities.has(chord.quality)) { + show = false; + } + card.style.display = show ? '' : 'none'; + }); + } + + function buildChordCards(chords, mf, ns) { + allChords = chords; + maxFret = mf || maxFret; + numStrings = ns || numStrings; + container.innerHTML = ''; + + chords.forEach(match => { + const card = document.createElement('div'); + card.className = 'chord-card'; + + const h2 = document.createElement('h2'); + h2.textContent = match.chord; + card.appendChild(h2); + + const fb = document.createElement('div'); + fb.className = 'fretboard'; + fb.dataset.fingering = JSON.stringify(match.fingering); + card.appendChild(fb); + + if (match.alternatives && match.alternatives.length > 0) { + const altSection = document.createElement('div'); + altSection.className = 'alternatives'; + const h3 = document.createElement('h3'); + h3.textContent = 'Alternatives:'; + altSection.appendChild(h3); + + const altContainer = document.createElement('div'); + altContainer.className = 'alternatives-container'; + match.alternatives.forEach(alt => { + const altFb = document.createElement('div'); + altFb.className = 'fretboard alternative-fretboard'; + altFb.dataset.fingering = JSON.stringify(alt); + altContainer.appendChild(altFb); + }); + altSection.appendChild(altContainer); + card.appendChild(altSection); + } + + container.appendChild(card); + }); + + renderFretboards(); + applyFilters(); + } + + function renderFretboards() { + const fretboards = document.querySelectorAll('.fretboard'); + fretboards.forEach(fb => { + const fingering = JSON.parse(fb.dataset.fingering); + const wrapper = document.createElement('div'); + wrapper.className = 'fretboard'; + + fb.innerHTML = ''; + fb.style.display = 'inline-block'; + fb.style.marginBottom = '1rem'; + + const fretCounts = {}; + fingering.forEach(f => { + if (!isNaN(f)) fretCounts[f] = (fretCounts[f] || 0) + 1; + }); + + const entries = Object.entries(fretCounts) + .filter(([, count]) => count >= 2) + .map(([f]) => parseInt(f)); + + let barreFretNum = null; + for (const f of entries.sort((a, b) => a - b)) { + if (fingering.every(x => x === 'x' || isNaN(x) || parseInt(x) >= f)) { + barreFretNum = f; + break; + } + } + + const fretMatrix = []; + for (let s = 0; s < numStrings; s++) { + const stringRow = []; + for (let f = 1; f <= maxFret; f++) { + const fret = document.createElement('div'); + fret.className = 'fret'; + fret.dataset.row = s; + fret.dataset.col = f; + + const fretValue = fingering[s]; + const numericFret = parseInt(fretValue, 10); + if (fretValue === 'x' && f === 1) { + fret.setAttribute('muted', ''); + fret.textContent = 'x'; + } else if (fretValue !== 'x' && numericFret === f) { + fret.dataset.dot = 'true'; + if (barreFretNum !== null && numericFret === barreFretNum) { + fret.classList.add('barre'); + } + } + stringRow.push(fret); + } + fretMatrix.push(stringRow); + } + + const barreCols = []; + if (barreFretNum !== null) { + for (let s = 0; s < numStrings; s++) { + if (parseInt(fingering[s]) === barreFretNum) barreCols.push(s); + } + } + + for (let s = numStrings - 1; s >= 0; s--) { + const stringRow = document.createElement('div'); + stringRow.className = 'fret-row'; + for (let f = 1; f <= maxFret; f++) { + stringRow.appendChild(fretMatrix[s][f - 1]); + } + wrapper.appendChild(stringRow); + } + + fb.appendChild(wrapper); + + if (barreFretNum !== null && barreCols.length >= 2) { + const start = Math.min(...barreCols); + const end = Math.max(...barreCols); + const line = document.createElement('div'); + line.className = 'barre-line'; + + requestAnimationFrame(() => { + let totalDotCenter = 0; + let dotCount = 0; + + for (let s = start; s <= end; s++) { + const dotFret = fretMatrix[s][barreFretNum - 1]; + if (dotFret) { + const rect = dotFret.getBoundingClientRect(); + totalDotCenter += (rect.left + rect.right) / 2; + dotCount++; + } + } + const avgDotCenter = totalDotCenter / dotCount; + const parentRect = wrapper.getBoundingClientRect(); + const dotCenter = avgDotCenter - parentRect.left; + + const firstDot = fretMatrix[start][barreFretNum - 1]; + const lastDot = fretMatrix[end][barreFretNum - 1]; + const rect1 = firstDot.getBoundingClientRect(); + const rect2 = lastDot.getBoundingClientRect(); + + const top = Math.min(rect1.top, rect2.top) - parentRect.top; + const bottom = Math.max(rect1.bottom, rect2.bottom) - parentRect.top; + const height = bottom - top; + + line.style.top = Math.round(top) + 'px'; + line.style.height = Math.round(height) + 'px'; + line.style.left = Math.round(dotCenter) + 'px'; + line.textContent = '|'; + + wrapper.appendChild(line); + }); + } + }); + } + + container.addEventListener('click', (e) => { + const fb = e.target.closest('[data-fingering]'); + if (!fb || !window.playChord || !window.currentTuningMIDI) return; + const fing = JSON.parse(fb.dataset.fingering); + const midi = []; + fing.forEach((f, i) => { + if (f === 'x') return; + midi.push(window.currentTuningMIDI[i] + parseInt(f)); + }); + if (midi.length) window.playChord(midi); + }); + + window.buildChordCards = buildChordCards; + initFilters(); +})(); diff --git a/frontend/dist/index.html b/frontend/dist/index.html new file mode 100644 index 0000000..27e48c4 --- /dev/null +++ b/frontend/dist/index.html @@ -0,0 +1,159 @@ + + +
+ + +