Go git number 1. Muuuch more better.

This commit is contained in:
pszsh 2026-03-01 00:14:00 -08:00
parent 3fb782c52b
commit ad1d10f266
47 changed files with 4166 additions and 1633 deletions

BIN
.DS_Store vendored

Binary file not shown.

8
.gitignore vendored
View File

@ -1,6 +1,2 @@
config2.json build/
output.txt frontend/wailsjs/
generated_data/
venv/
__pycache__/
www/chords.html

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "static/vectors"]
path = static/vectors
url = https://git.else-if.org/jess/web-tuner-vectors.git

98
app.go Normal file
View File

@ -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)
}

57
build.sh Executable file
View File

@ -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/"

138
chords.go Normal file
View File

@ -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
}

View File

@ -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()

50
config.go Normal file
View File

@ -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
}

View File

@ -1,6 +1,6 @@
{ {
"instrument": "guitar", "instrument": "guitar",
"tuning": ["C#", "F#", "B", "G#", "B", "D#"], "tuning": ["B", "F#", "B", "F#", "A", "C#"],
"frets": 7, "frets": 5,
"max_fingers": 4 "max_fingers": 4
} }

10
debug.go Normal file
View File

@ -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")
}

10
debug.sh Executable file
View File

@ -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

532
fingerings.go Normal file
View File

@ -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)
}

234
frontend/dist/app.js vendored Normal file
View File

@ -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.';
}
});

48
frontend/dist/chords-darcula.css vendored Normal file
View File

@ -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;
}

49
frontend/dist/chords-default.css vendored Normal file
View File

@ -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);
}

59
frontend/dist/chords-light.css vendored Normal file
View File

@ -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;
}

48
frontend/dist/chords-solarized.css vendored Normal file
View File

@ -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;
}

View File

@ -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;
}

248
frontend/dist/chords.js vendored Normal file
View File

@ -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();
})();

159
frontend/dist/index.html vendored Normal file
View File

@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Tuner</title>
<link href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="layout.css">
<link rel="stylesheet" href="tuner.css">
<link id="theme-stylesheet" rel="stylesheet" href="chords-light.css">
</head>
<body>
<div class="header">
<h1>Web Tuner</h1>
<div class="header-actions">
<button id="btn-export">Export PDF</button>
<select id="theme-select">
<option value="light">Light</option>
<option value="default">Purple</option>
<option value="solarized">Solarized</option>
<option value="tomorrow-amoled">Tomorrow AMOLED</option>
<option value="darcula">Darcula</option>
</select>
</div>
</div>
<div class="app-shell">
<div class="sidebar">
<div class="nav-links">
<button class="nav-link active" data-view="tuner">Tuner</button>
<button class="nav-link" data-view="chords">Chords</button>
<button class="nav-link" data-view="shapes">Explorer</button>
</div>
<div class="sidebar-section filter-section" style="display:none;">
<h3>Filter by Root</h3>
<div class="filter-row" id="root-filters"></div>
<h3 style="margin-top:0.5rem;">Filter by Quality</h3>
<div class="filter-row" id="quality-filters"></div>
</div>
<div class="sidebar-section shapes-section" id="shapes-section" style="display:none;">
<h3>Shapes</h3>
<div id="shape-list" class="shape-list"></div>
<div class="shape-editor-actions">
<button id="btn-add-shape" class="btn-small">Add Shape</button>
<button id="btn-restore-shapes" class="btn-small">Restore Defaults</button>
</div>
<div id="shape-editor" class="shape-editor" style="display:none;">
<label for="shape-name-input">Name</label>
<input type="text" id="shape-name-input" placeholder="Shape name">
<label>Frets (low→high, -1 = mute)</label>
<div class="shape-fret-inputs" id="shape-fret-inputs"></div>
<div class="shape-editor-btns">
<button id="btn-shape-save" class="btn-small btn-apply">Save</button>
<button id="btn-shape-cancel" class="btn-small">Cancel</button>
</div>
</div>
<h3 style="margin-top:0.75rem;">Target Chord</h3>
<label for="shape-quality-select">Quality</label>
<select id="shape-quality-select"></select>
<label for="shape-root-select">Root</label>
<select id="shape-root-select">
<option value="-1">Any</option>
</select>
<h3 style="margin-top:0.75rem;">Pin Voicing</h3>
<div class="voicing-row" id="voicing-row"></div>
<div class="config-actions" style="margin-top:0.5rem;">
<button class="btn-apply" id="btn-shape-search">Search</button>
</div>
</div>
<div class="sidebar-section config-panel">
<h3>Configuration</h3>
<label for="preset-select">Preset</label>
<select id="preset-select"></select>
<label>Tuning</label>
<div class="tuning-grid" id="tuning-grid"></div>
<label for="frets-input">Frets</label>
<input type="number" id="frets-input" min="1" max="24" value="4">
<label for="fingers-input">Max Fingers</label>
<input type="number" id="fingers-input" min="1" max="6" value="4">
<div class="config-actions">
<button class="btn-apply" id="btn-apply">Apply</button>
<button id="btn-save">Save</button>
<button id="btn-reset">Reset</button>
</div>
</div>
</div>
<div class="main-content">
<div id="view-tuner" class="view active">
<div style="display:flex; justify-content:center; align-items:center; flex:1;">
<div class="container">
<h1 id="instrument-label">Guitar Tuner</h1>
<div class="instrument-select">
<label for="instrument">Select Instrument:</label>
<select id="instrument">
<option value="ukulele">Ukulele</option>
<option value="guitar" selected>Guitar</option>
</select>
</div>
<div class="tuning-controls">
<div class="a440-control">
<label for="a440">A4 Reference (Hz):</label>
<input type="number" id="a440" value="440">
</div>
<div class="transpose-control">
<label for="transpose">Transpose (Semitones):</label>
<input type="number" id="transpose" value="0">
</div>
<div class="tuning-mode-select">
<label for="tuning-mode">Tuning Mode:</label>
<select id="tuning-mode">
<option value="equal">Equal Temperament</option>
<option value="harmonic">Harmonic Tuning</option>
</select>
</div>
<div class="tuning-select">
<label for="tuning">Select Tuning:</label>
<select id="tuning"></select>
</div>
</div>
<div id="strings"></div>
<button id="play-all">Play All Strings</button>
<div id="output"></div>
</div>
</div>
</div>
<div id="view-chords" class="view">
<div id="chord-container"></div>
<div id="chord-loading" class="loading">Loading chord fingerings...</div>
</div>
<div id="view-shapes" class="view">
<div id="shapes-results"></div>
<div id="shapes-loading" class="loading" style="display:none;"></div>
</div>
</div>
</div>
<script src="vendor/tone.min.js"></script>
<script src="app.js"></script>
<script src="tuner.js"></script>
<script src="chords.js"></script>
<script src="shapes.js"></script>
</body>
</html>

789
frontend/dist/layout.css vendored Normal file
View File

@ -0,0 +1,789 @@
:root {
--bg-base: #131314;
--bg-surface: #1e1f20;
--bg-overlay: #282a2c;
--bg-input: #1e1f20;
--text-primary: #e3e3e3;
--text-secondary: #c4c7c5;
--text-subtle: #8e918f;
--accent: #8ab4f8;
--accent-hover: #aecbfa;
--accent-dim: rgba(138, 180, 248, 0.12);
--border: #444746;
--border-light: #333638;
--radius: 0.75rem;
--radius-sm: 0.5rem;
--transition: 150ms ease;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-base);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* --- Header (titlebar drag region) --- */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem 0 5rem;
background: var(--bg-base);
border-bottom: 1px solid var(--border-light);
height: 2.75rem;
--wails-draggable: drag;
-webkit-user-select: none;
user-select: none;
}
.header h1 {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
--wails-draggable: no-drag;
}
.header-actions select,
.header-actions button {
font: inherit;
font-size: 0.8125rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition);
-webkit-appearance: none;
appearance: none;
}
.header-actions select {
padding-right: 1.75rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238e918f'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.625rem center;
}
.header-actions button:hover,
.header-actions select:hover {
background: var(--bg-overlay);
border-color: var(--text-subtle);
}
/* --- App Shell --- */
.app-shell {
display: flex;
height: calc(100vh - 2.75rem);
}
/* --- Sidebar --- */
.sidebar {
width: 20%;
min-width: 17rem;
background: var(--bg-base);
border-right: 1px solid var(--border-light);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.nav-links {
display: flex;
flex-direction: column;
padding: 0.5rem 0;
}
.nav-link {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-subtle);
cursor: pointer;
border: none;
background: none;
text-align: left;
font-family: inherit;
border-left: 0.1875rem solid transparent;
transition: all var(--transition);
}
.nav-link:hover {
color: var(--text-secondary);
background: var(--bg-surface);
}
.nav-link.active {
color: var(--accent);
border-left-color: var(--accent);
background: var(--bg-surface);
}
/* --- Sidebar Sections --- */
.sidebar-section {
padding: 1rem 1.25rem;
border-top: 1px solid var(--border-light);
}
.sidebar-section h3 {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-subtle);
margin-bottom: 0.625rem;
}
/* --- Config Panel --- */
.config-panel label {
display: block;
font-size: 0.75rem;
color: var(--text-subtle);
margin-bottom: 0.1875rem;
margin-top: 0.5rem;
}
.config-panel label:first-of-type {
margin-top: 0;
}
.config-panel select,
.config-panel input[type="number"] {
width: 100%;
font-family: inherit;
font-size: 0.8125rem;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text-primary);
outline: none;
transition: all var(--transition);
-webkit-appearance: none;
appearance: none;
}
.config-panel select {
padding-right: 1.75rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238e918f'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
}
.config-panel select:focus,
.config-panel input[type="number"]:focus {
border-color: var(--text-secondary);
}
.tuning-grid {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.25rem;
}
.tuning-grid select {
width: 3rem;
font-size: 0.75rem;
padding: 0.25rem;
text-align: center;
padding-right: 0.25rem;
background-image: none;
}
.config-actions {
display: flex;
gap: 0.375rem;
margin-top: 0.625rem;
}
.config-actions button {
flex: 1;
font-family: inherit;
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 0;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition);
}
.config-actions button:hover {
background: var(--bg-overlay);
border-color: var(--text-subtle);
}
.config-actions .btn-apply {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
}
.config-actions .btn-apply:hover {
background: rgba(138, 180, 248, 0.2);
}
/* --- Main Content --- */
.main-content {
flex: 1;
overflow: hidden;
padding: 1rem;
}
.view {
display: none;
height: 100%;
}
.view.active {
display: flex;
flex-direction: column;
}
#view-tuner {
overflow: auto;
}
#view-chords,
#view-shapes {
overflow-y: auto;
}
/* --- Filter Bar --- */
.filter-bar {
margin-bottom: 1rem;
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-bottom: 0.375rem;
}
.filter-pill {
font-family: inherit;
font-size: 0.75rem;
padding: 0.25rem 0.625rem;
border-radius: 0.875rem;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-subtle);
cursor: pointer;
transition: all var(--transition);
}
.filter-pill:hover {
border-color: var(--text-subtle);
color: var(--text-secondary);
}
.filter-pill.active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
}
/* --- Chord Grid --- */
#chord-container {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
/* --- Fretboard (structural) --- */
.fretboard {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0;
margin-left: 0.5rem;
position: relative;
width: fit-content;
}
.fret-row {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
gap: 0;
}
.fret {
width: 2.1rem;
height: 1.6rem;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
text-align: center;
line-height: 1.6rem;
font-weight: bold;
position: relative;
}
.fret[data-dot]::after {
content: '';
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.fret[muted] {
font-weight: bold;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.alternatives {
margin-top: 1rem;
}
.alternatives h3 {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.alternatives-container {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.alternative-fretboard {
margin-inline: 1.8rem;
}
.alternative-fretboard .fret {
width: 1.8rem;
height: 1.3rem;
}
.barre-line {
position: absolute;
border-radius: 5rem;
pointer-events: none;
z-index: 2;
width: 1rem;
left: 0;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
font-size: 0.7rem;
font-family: sans-serif;
font-weight: bold;
transform: translateX(calc(50% - 1.1rem));
}
/* --- Shape Explorer --- */
.shapes-section label {
display: block;
font-size: 0.75rem;
color: var(--text-subtle);
margin-bottom: 0.25rem;
margin-top: 0.5rem;
}
.shapes-section select,
.shapes-section input[type="text"] {
width: 100%;
font-family: inherit;
font-size: 0.8125rem;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text-primary);
outline: none;
transition: all var(--transition);
-webkit-appearance: none;
appearance: none;
}
.shapes-section select {
padding-right: 1.75rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238e918f'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
}
.shapes-section select:focus,
.shapes-section input[type="text"]:focus {
border-color: var(--text-secondary);
}
.shape-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 16rem;
overflow-y: auto;
margin-bottom: 0.5rem;
}
.shape-item {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
cursor: pointer;
border: 1px solid transparent;
transition: all var(--transition);
}
.shape-item:hover {
background: var(--bg-surface);
}
.shape-item.selected {
background: var(--accent-dim);
border-color: rgba(138, 180, 248, 0.25);
}
.shape-item .shape-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-secondary);
}
.shape-item .shape-frets {
font-size: 0.6875rem;
color: var(--text-subtle);
flex-shrink: 0;
}
.shape-item .shape-actions {
display: flex;
gap: 0.125rem;
flex-shrink: 0;
}
.shape-item .shape-actions button {
font-family: inherit;
font-size: 0.6875rem;
padding: 0.1875rem 0.5rem;
border-radius: 0.25rem;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-subtle);
cursor: pointer;
transition: all var(--transition);
}
.shape-item .shape-actions button:hover {
background: var(--bg-overlay);
color: var(--text-primary);
}
.shape-editor-actions {
display: flex;
gap: 0.375rem;
}
.btn-small {
font-family: inherit;
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 0.625rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
flex: 1;
transition: all var(--transition);
}
.btn-small:hover {
background: var(--bg-overlay);
border-color: var(--text-subtle);
}
.shape-editor {
margin-top: 0.375rem;
padding: 0.625rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
background: var(--bg-surface);
}
.shape-fret-inputs {
display: flex;
gap: 0.3125rem;
margin-top: 0.375rem;
}
.shape-fret-inputs input {
flex: 1;
min-width: 0;
font-family: inherit;
font-size: 0.75rem;
padding: 0.375rem;
text-align: center;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text-primary);
outline: none;
transition: all var(--transition);
}
.shape-fret-inputs input:focus {
border-color: var(--text-secondary);
}
.shape-editor-btns {
display: flex;
gap: 0.25rem;
margin-top: 0.375rem;
}
.voicing-row {
display: flex;
gap: 0.3125rem;
}
.voicing-row input {
flex: 1;
min-width: 0;
font-family: inherit;
font-size: 0.75rem;
padding: 0.375rem;
text-align: center;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text-primary);
outline: none;
transition: all var(--transition);
}
.voicing-row input:focus {
border-color: var(--text-secondary);
}
.voicing-row input:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.tuning-card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin-bottom: 0.625rem;
}
.tuning-card-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.tuning-card-header h3 {
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.compat-star {
color: #f5c542;
margin-left: 0.375rem;
font-size: 1rem;
}
.tuning-card.high-compat {
border-color: rgba(245, 197, 66, 0.35);
}
.tuning-card-header .tuning-stats {
font-size: 0.75rem;
color: var(--text-subtle);
white-space: nowrap;
}
.tuning-card-header .tuning-notes {
font-size: 0.8125rem;
color: var(--text-subtle);
}
.tuning-card-header .expand-icon {
font-size: 0.75rem;
color: var(--text-subtle);
transition: transform var(--transition);
}
.tuning-card.expanded .expand-icon {
transform: rotate(90deg);
}
.tuning-card-body {
display: none;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-light);
}
.tuning-card.expanded .tuning-card-body {
display: block;
}
.companion-grid {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
}
.companion-item {
text-align: center;
cursor: pointer;
transition: background var(--transition);
}
.companion-item:hover {
background: var(--bg-overlay);
border-radius: var(--radius-sm);
}
[data-fingering] {
cursor: pointer;
}
.companion-item .companion-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.companion-item .companion-chord {
font-size: 0.6875rem;
color: var(--text-subtle);
margin-bottom: 0.375rem;
}
.companion-item .companion-notes {
font-size: 0.625rem;
color: var(--text-subtle);
}
.btn-load-more {
display: block;
width: 100%;
padding: 0.625rem;
margin-top: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
font-family: inherit;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition);
}
.btn-load-more:hover {
background: var(--bg-overlay);
color: var(--text-primary);
}
/* --- Loading --- */
.loading {
color: var(--text-subtle);
padding: 2rem;
text-align: center;
font-size: 0.875rem;
}
/* --- Scrollbar --- */
::-webkit-scrollbar {
width: 0.5rem;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-light);
border-radius: 0.25rem;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border);
}
/* --- Print --- */
@media print {
.header, .sidebar {
display: none !important;
}
.app-shell {
display: block;
height: auto;
}
.main-content {
overflow: visible;
padding: 0;
}
.filter-bar {
display: none;
}
#chord-container {
gap: 0.75rem;
}
.chord-card {
break-inside: avoid;
page-break-inside: avoid;
}
body {
overflow: visible;
height: auto;
background: #fff;
color: #000;
}
}

505
frontend/dist/shapes.js vendored Normal file
View File

@ -0,0 +1,505 @@
(() => {
const shapeList = document.getElementById('shape-list');
const btnAdd = document.getElementById('btn-add-shape');
const btnRestore = document.getElementById('btn-restore-shapes');
const editor = document.getElementById('shape-editor');
const nameInput = document.getElementById('shape-name-input');
const fretInputs = document.getElementById('shape-fret-inputs');
const btnEditorSave = document.getElementById('btn-shape-save');
const btnEditorCancel = document.getElementById('btn-shape-cancel');
const qualitySelect = document.getElementById('shape-quality-select');
const rootSelect = document.getElementById('shape-root-select');
const voicingRow = document.getElementById('voicing-row');
const btnSearch = document.getElementById('btn-shape-search');
const resultsContainer = document.getElementById('shapes-results');
const loadingEl = document.getElementById('shapes-loading');
const roots = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
let shapes = [];
let selectedIndex = 0;
let editingIndex = -1;
function init() {
if (!window.go || !window.go.main || !window.go.main.App) return;
window.go.main.App.GetDefaultShapes().then(defaults => {
shapes = defaults;
renderShapeList();
buildVoicingInputs();
});
roots.forEach((r, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = r;
rootSelect.appendChild(opt);
});
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 opt = document.createElement('option');
opt.value = q;
opt.textContent = q;
qualitySelect.appendChild(opt);
});
});
btnAdd.addEventListener('click', () => openEditor(-1));
btnRestore.addEventListener('click', restoreDefaults);
btnEditorSave.addEventListener('click', saveEditor);
btnEditorCancel.addEventListener('click', closeEditor);
btnSearch.addEventListener('click', doSearch);
resultsContainer.addEventListener('click', (e) => {
const item = e.target.closest('[data-chord-midi]');
if (!item || !window.playChord) return;
window.playChord(JSON.parse(item.dataset.chordMidi));
});
}
function renderShapeList() {
shapeList.innerHTML = '';
shapes.forEach((s, i) => {
const item = document.createElement('div');
item.className = 'shape-item' + (i === selectedIndex ? ' selected' : '');
const name = document.createElement('span');
name.className = 'shape-name';
name.textContent = s.name;
const frets = document.createElement('span');
frets.className = 'shape-frets';
frets.textContent = s.frets.map(f => f === -1 ? 'x' : f).join(' ');
const actions = document.createElement('span');
actions.className = 'shape-actions';
const editBtn = document.createElement('button');
editBtn.textContent = 'edit';
editBtn.addEventListener('click', e => { e.stopPropagation(); openEditor(i); });
const delBtn = document.createElement('button');
delBtn.textContent = 'del';
delBtn.addEventListener('click', e => {
e.stopPropagation();
shapes.splice(i, 1);
if (selectedIndex >= shapes.length) selectedIndex = Math.max(0, shapes.length - 1);
renderShapeList();
buildVoicingInputs();
});
actions.appendChild(editBtn);
actions.appendChild(delBtn);
item.appendChild(name);
item.appendChild(frets);
item.appendChild(actions);
item.addEventListener('click', () => {
selectedIndex = i;
renderShapeList();
buildVoicingInputs();
});
shapeList.appendChild(item);
});
}
function buildVoicingInputs() {
voicingRow.innerHTML = '';
const shape = shapes[selectedIndex];
if (!shape) return;
const labels = ['1','2','3','4','5','6'];
for (let i = 0; i < 6; i++) {
const inp = document.createElement('input');
inp.type = 'text';
inp.placeholder = labels[i];
inp.dataset.string = i;
if (shape.frets[i] === -1) {
inp.disabled = true;
inp.value = '';
inp.placeholder = 'x';
}
voicingRow.appendChild(inp);
}
}
function openEditor(idx) {
editingIndex = idx;
editor.style.display = '';
fretInputs.innerHTML = '';
if (idx >= 0) {
nameInput.value = shapes[idx].name;
for (let i = 0; i < 6; i++) {
const inp = document.createElement('input');
inp.type = 'text';
inp.value = shapes[idx].frets[i] === -1 ? 'x' : shapes[idx].frets[i];
fretInputs.appendChild(inp);
}
} else {
nameInput.value = '';
for (let i = 0; i < 6; i++) {
const inp = document.createElement('input');
inp.type = 'text';
inp.value = '0';
fretInputs.appendChild(inp);
}
}
}
function closeEditor() {
editor.style.display = 'none';
editingIndex = -1;
}
function saveEditor() {
const name = nameInput.value.trim();
if (!name) return;
const inputs = fretInputs.querySelectorAll('input');
const frets = [];
for (const inp of inputs) {
const v = inp.value.trim().toLowerCase();
if (v === 'x' || v === '-1') {
frets.push(-1);
} else {
const n = parseInt(v);
if (isNaN(n) || n < 0) {
frets.push(0);
} else {
frets.push(n);
}
}
}
const shape = { name: name, frets: frets };
if (editingIndex >= 0) {
shapes[editingIndex] = shape;
} else {
shapes.push(shape);
selectedIndex = shapes.length - 1;
}
closeEditor();
renderShapeList();
buildVoicingInputs();
}
function restoreDefaults() {
if (!window.go) return;
const currentSearch = shapes[selectedIndex];
window.go.main.App.GetDefaultShapes().then(defaults => {
shapes = defaults;
// preserve search shape if custom
if (currentSearch) {
const exists = shapes.some(s => s.name === currentSearch.name);
if (!exists) {
shapes.push(currentSearch);
selectedIndex = shapes.length - 1;
} else {
selectedIndex = shapes.findIndex(s => s.name === currentSearch.name);
}
} else {
selectedIndex = 0;
}
renderShapeList();
buildVoicingInputs();
});
}
function doSearch() {
if (!window.go) return;
const shape = shapes[selectedIndex];
if (!shape) return;
const voicingInputs = voicingRow.querySelectorAll('input');
const voicing = [];
voicingInputs.forEach(inp => {
voicing.push(inp.disabled ? '' : inp.value.trim());
});
const query = {
shape: shape,
target_quality: qualitySelect.value,
target_root: parseInt(rootSelect.value),
voicing: voicing
};
loadingEl.style.display = '';
loadingEl.textContent = 'Searching tunings...';
resultsContainer.innerHTML = '';
window.go.main.App.FindShapeTunings(query, shapes).then(results => {
loadingEl.style.display = 'none';
renderResults(results || [], shape.name);
}).catch(err => {
loadingEl.style.display = 'none';
loadingEl.style.display = '';
loadingEl.textContent = 'Error: ' + err;
});
}
const PAGE_SIZE = 10;
function renderResults(results, searchShapeName) {
resultsContainer.innerHTML = '';
if (results.length === 0) {
const msg = document.createElement('div');
msg.className = 'loading';
msg.textContent = 'No tunings found.';
resultsContainer.appendChild(msg);
return;
}
const compat = results.filter(r => r.high_compat).length;
const summary = document.createElement('div');
summary.style.cssText = 'font-size:0.8rem;color:#888;margin-bottom:0.75rem;';
summary.textContent = results.length.toLocaleString() + ' possible tuning' + (results.length !== 1 ? 's' : '') + ' found'
+ (compat ? ' \u2014 ' + compat.toLocaleString() + ' highly compatible' : '');
resultsContainer.appendChild(summary);
const listEl = document.createElement('div');
resultsContainer.appendChild(listEl);
let shown = 0;
let loadMoreBtn = null;
function renderPage() {
const end = Math.min(shown + PAGE_SIZE, results.length);
for (let idx = shown; idx < end; idx++) {
listEl.appendChild(buildTuningCard(results[idx], searchShapeName));
}
shown = end;
if (shown < results.length) {
if (!loadMoreBtn) {
loadMoreBtn = document.createElement('button');
loadMoreBtn.className = 'btn-load-more';
loadMoreBtn.addEventListener('click', renderPage);
resultsContainer.appendChild(loadMoreBtn);
}
loadMoreBtn.textContent = 'Load more (' + (results.length - shown) + ' remaining)';
} else if (loadMoreBtn) {
loadMoreBtn.remove();
loadMoreBtn = null;
}
}
renderPage();
}
function buildTuningCard(tc, searchShapeName) {
const card = document.createElement('div');
card.className = 'tuning-card';
const header = document.createElement('div');
header.className = 'tuning-card-header';
const h3 = document.createElement('h3');
h3.textContent = tc.chord;
if (tc.high_compat) {
card.classList.add('high-compat');
const star = document.createElement('span');
star.className = 'compat-star';
star.textContent = '\u2605';
star.title = 'Highly compatible: ' + tc.maj_min_count + ' major/minor triads';
h3.appendChild(star);
}
const stats = document.createElement('span');
stats.className = 'tuning-stats';
stats.textContent = tc.valid_chords + '/' + (tc.companions ? tc.companions.length : 0) + ' chords';
const notes = document.createElement('span');
notes.className = 'tuning-notes';
notes.textContent = tc.tuning.join(' ');
const arrow = document.createElement('span');
arrow.className = 'expand-icon';
arrow.textContent = '\u25B6';
header.appendChild(h3);
header.appendChild(stats);
header.appendChild(notes);
header.appendChild(arrow);
header.addEventListener('click', () => {
card.classList.toggle('expanded');
});
const body = document.createElement('div');
body.className = 'tuning-card-body';
if (tc.companions && tc.companions.length > 0) {
const grid = document.createElement('div');
grid.className = 'companion-grid';
tc.companions.forEach(comp => {
const item = document.createElement('div');
item.className = 'companion-item';
const label = document.createElement('div');
label.className = 'companion-label';
label.textContent = comp.shape;
if (comp.shape === searchShapeName) {
label.style.color = '#1dc9fe';
}
const chord = document.createElement('div');
chord.className = 'companion-chord';
chord.textContent = comp.chord;
item.appendChild(label);
item.appendChild(chord);
const shapeDef = shapes.find(s => s.name === comp.shape);
if (shapeDef) {
const fingering = shapeDef.frets.map(f => f === -1 ? 'x' : String(f));
const maxFret = Math.max(...shapeDef.frets.filter(f => f >= 0), 1);
const fb = document.createElement('div');
fb.className = 'fretboard alternative-fretboard';
renderSingleFretboard(fb, fingering, Math.max(maxFret, 4));
item.appendChild(fb);
const midi = [];
for (let si = 0; si < 6; si++) {
if (shapeDef.frets[si] !== -1 && tc.tuning_midi && tc.tuning_midi[si] != null) {
midi.push(tc.tuning_midi[si] + shapeDef.frets[si]);
}
}
if (midi.length) item.dataset.chordMidi = JSON.stringify(midi);
}
const notesDiv = document.createElement('div');
notesDiv.className = 'companion-notes';
notesDiv.textContent = (comp.notes || []).join(' ');
item.appendChild(notesDiv);
grid.appendChild(item);
});
body.appendChild(grid);
}
card.appendChild(header);
card.appendChild(body);
return card;
}
function renderSingleFretboard(fb, fingering, maxFret) {
const numStrings = fingering.length;
fb.innerHTML = '';
fb.style.display = 'inline-block';
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';
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 wrapper = document.createElement('div');
wrapper.className = 'fretboard';
for (let s = numStrings - 1; s >= 0; s--) {
const row = document.createElement('div');
row.className = 'fret-row';
for (let f = 1; f <= maxFret; f++) {
row.appendChild(fretMatrix[s][f - 1]);
}
wrapper.appendChild(row);
}
fb.appendChild(wrapper);
if (barreFretNum !== null) {
const barreCols = [];
for (let s = 0; s < numStrings; s++) {
if (parseInt(fingering[s]) === barreFretNum) barreCols.push(s);
}
if (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++;
}
}
if (!dotCount) return;
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);
});
}
}
}
window.initShapeExplorer = init;
})();

171
frontend/dist/tuner.css vendored Normal file
View File

@ -0,0 +1,171 @@
.container {
background: var(--bg-surface);
border: 1px solid var(--border-light);
padding: 2rem;
border-radius: var(--radius);
text-align: center;
color: var(--text-primary);
max-width: 100%;
}
.container h1 {
margin-bottom: 1.25rem;
font-weight: 500;
}
.tuning-controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.25rem;
justify-content: center;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
}
.tuning-controls > div {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.tuning-controls label {
margin-bottom: 0.375rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.tuning-controls input,
.tuning-controls select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font: inherit;
font-size: 0.8125rem;
outline: none;
transition: all var(--transition);
-webkit-appearance: none;
appearance: none;
}
.tuning-controls select {
padding-right: 1.75rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238e918f'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.625rem center;
}
.tuning-controls input:focus,
.tuning-controls select:focus {
border-color: var(--text-secondary);
}
.string-button {
padding: 0.75rem 1.5rem;
margin: 0.375rem;
font-size: 1.1em;
font-weight: 500;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-primary);
cursor: pointer;
transition: all var(--transition);
}
.string-button:hover {
background: var(--bg-overlay);
border-color: var(--text-subtle);
}
#play-all {
padding: 0.625rem 1.25rem;
font-size: 0.8125rem;
font-weight: 500;
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
background: var(--accent-dim);
color: var(--accent);
cursor: pointer;
transition: all var(--transition);
font-family: inherit;
}
#play-all:hover {
background: rgba(138, 180, 248, 0.2);
}
#strings {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
#output {
margin-top: 1.25rem;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-secondary);
}
.instrument-select {
margin: 1rem;
}
label[for=instrument] {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.8125rem;
}
#instrument {
padding: 0.5rem 1.75rem 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
border: 1px solid var(--border);
color: var(--text-primary);
border-radius: var(--radius-sm);
background: var(--bg-input);
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238e918f'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.625rem center;
outline: none;
transition: all var(--transition);
font-family: inherit;
}
#instrument:focus {
border-color: var(--text-secondary);
}
.a440-control,
.transpose-control,
.tuning-mode-select,
.tuning-select {
padding: 0.75rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
border: 1px solid var(--border-light);
background: var(--bg-base);
color: var(--text-primary);
border-radius: var(--radius-sm);
}
#tuning, #tuning-mode, #transpose, #a440 {
background: var(--bg-input);
color: var(--text-primary);
-webkit-appearance: none;
appearance: none;
}
#tuning, #tuning-mode {
padding-right: 1.75rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238e918f'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.625rem center;
}

View File

@ -15,65 +15,55 @@ document.addEventListener('DOMContentLoaded', () => {
const instrumentTunings = { const instrumentTunings = {
"ukulele": { "ukulele": {
"standard": [67 + 12, 60 + 12, 64 + 12, 69 + 12], // G5, C5, E5, A5 "standard": [67 + 12, 60 + 12, 64 + 12, 69 + 12],
"low-g": [55 + 12, 60 + 12, 64 + 12, 69 + 12], // G4, C5, E5, A5 "low-g": [55 + 12, 60 + 12, 64 + 12, 69 + 12],
"harmonic-minor": [67 + 12, 58 + 12, 62 + 12, 67 + 12], // G5, Bb5, D5, G5 "harmonic-minor": [67 + 12, 58 + 12, 62 + 12, 67 + 12],
"suspended-fourth": [67 + 12, 60 + 12, 53 + 12, 60 + 12], // G5, C5, F5, C5 "suspended-fourth": [67 + 12, 60 + 12, 53 + 12, 60 + 12],
"lydian": [67 + 12, 60 + 12, 64 + 12, 66 + 12], // G5, C5, E5, F#5 "lydian": [67 + 12, 60 + 12, 64 + 12, 66 + 12],
"diminished": [67 + 12, 59 + 12, 62 + 12, 65 + 12], // G5, B5, D5, F5 "diminished": [67 + 12, 59 + 12, 62 + 12, 65 + 12],
"augmented": [67 + 12, 61 + 12, 64 + 12, 68 + 12], // G5, C#5, E5, G#5 "augmented": [67 + 12, 61 + 12, 64 + 12, 68 + 12],
"open-fifths": [67 + 12, 62 + 12, 69 + 12, 62 + 12], // G5, D5, A5, D5 "open-fifths": [67 + 12, 62 + 12, 69 + 12, 62 + 12],
"double-unison": [67 + 12, 67 + 12, 60 + 12, 60 + 12], // G5, G5, C5, C5 "double-unison": [67 + 12, 67 + 12, 60 + 12, 60 + 12],
"ionian": [67 + 12, 60 + 12, 64 + 12, 69 + 12], // G C E A "ionian": [67 + 12, 60 + 12, 64 + 12, 69 + 12],
"dorian": [67 + 12, 58 + 12, 62 + 12, 69 + 12], // G Bb D A "dorian": [67 + 12, 58 + 12, 62 + 12, 69 + 12],
"mixo-dorian": [65 + 12, 58 + 12, 67 + 12, 69 + 12], // F A# G A "mixo-dorian": [65 + 12, 58 + 12, 67 + 12, 69 + 12],
"phrygian": [67 + 12, 56 + 12, 62 + 12, 69 + 12], // G Ab D A "phrygian": [67 + 12, 56 + 12, 62 + 12, 69 + 12],
"mixolydian": [67 + 12, 60 + 12, 62 + 12, 69 + 12], // G C D A "mixolydian": [67 + 12, 60 + 12, 62 + 12, 69 + 12],
"aeolian": [67 + 12, 58 + 12, 62 + 12, 67 + 12], // G Bb D G "aeolian": [67 + 12, 58 + 12, 62 + 12, 67 + 12],
"locrian": [67 + 12, 56 + 12, 60 + 12, 67 + 12] // G Ab C G "locrian": [67 + 12, 56 + 12, 60 + 12, 67 + 12]
}, },
"guitar": { "guitar": {
"standard": [40, 45, 50, 55, 59, 64], // EADGBE "standard": [40, 45, 50, 55, 59, 64],
"drop-d": [38, 45, 50, 55, 59, 64], // DADGBE "drop-d": [38, 45, 50, 55, 59, 64],
"dadgad": [38, 45, 50, 55, 57, 64], // DADGAD "dadgad": [38, 45, 50, 55, 57, 64],
"open-g": [38, 43, 47, 50, 55, 59], // DGDGBD "open-g": [38, 43, 47, 50, 55, 59],
"open-d": [38, 43, 50, 54, 57, 64], // DADF#AD "open-d": [38, 43, 50, 54, 57, 64],
"open-c": [36, 40, 43, 48, 52, 57], // CGCGCE "open-c": [36, 40, 43, 48, 52, 57],
"half-step-down": [39, 43, 48, 52, 55, 60], // Eb Ab Db Gb Bb Eb "half-step-down": [39, 43, 48, 52, 55, 60],
"full-step-down": [38, 43, 48, 53, 57, 62], // D G C F A D "full-step-down": [38, 43, 48, 53, 57, 62],
"double-drop-d": [38, 43, 48, 50, 55, 59], // DADGBD "double-drop-d": [38, 43, 48, 50, 55, 59],
"new-standard": [36, 40, 45, 50, 54, 59], // CGDAEG "new-standard": [36, 40, 45, 50, 54, 59],
"nashville-high-strung": [40, 45, 50, 55, 59, 64], // EADGBE but with lighter strings "nashville-high-strung": [40, 45, 50, 55, 59, 64],
"orkney": [36, 40, 43, 36, 40, 43], // CGDGCD "orkney": [36, 40, 43, 36, 40, 43],
"modal-tuning-1": [40, 45, 39, 50, 45, 64], // CGDGBE "modal-tuning-1": [40, 45, 39, 50, 45, 64],
"modal-tuning-2": [40, 45, 37, 50, 45, 64], // EAEAC#E "modal-tuning-2": [40, 45, 37, 50, 45, 64],
"db-custom": [49, 54, 59, 56, 71, 63] // Db Gb B Ab B (oct) Eb "db-custom": [49, 54, 59, 56, 71, 63]
} }
}; };
let currentTuning = []; let currentTuning = [];
let currentA440 = 440; let currentA440 = 440;
let currentTranspose = 0; let currentTranspose = 0;
let tuningMode = "equal"; // Default tuning mode let tuningMode = "equal";
const harmonicFrequencyRatios = { const harmonicFrequencyRatios = {
"C": 1.0, "C": 1.0, "Db": 17/16, "D": 9/8, "Eb": 19/16, "E": 5/4,
"Db": 17/16, "F": 21/16, "Gb": 11/8, "G": 3/2, "Ab": 13/8, "A": 5/3,
"D": 9/8, "Bb": 7/4, "B": 15/8, "C_octave": 2.0
"Eb": 19/16,
"E": 5/4,
"F": 21/16,
"Gb": 11/8,
"G": 3/2,
"Ab": 13/8,
"A": 5/3,
"Bb": 7/4,
"B": 15/8,
"C_octave": 2.0
}; };
function updateInstrument() { function updateInstrument() {
const selectedInstrument = instrumentSelect.value; const selectedInstrument = instrumentSelect.value;
tuningSelect.innerHTML = ""; // Clear previous tuning options tuningSelect.innerHTML = "";
let inst = instrumentSelect.value; let inst = instrumentSelect.value;
instlabel.innerText = inst.charAt(0).toUpperCase() + inst.slice(1) + " Tuner"; instlabel.innerText = inst.charAt(0).toUpperCase() + inst.slice(1) + " Tuner";
Object.keys(instrumentTunings[selectedInstrument]).forEach(tuning => { Object.keys(instrumentTunings[selectedInstrument]).forEach(tuning => {
@ -82,18 +72,18 @@ document.addEventListener('DOMContentLoaded', () => {
option.textContent = tuning.replace(/-/g, " ").toUpperCase(); option.textContent = tuning.replace(/-/g, " ").toUpperCase();
tuningSelect.appendChild(option); tuningSelect.appendChild(option);
}); });
updateTuning(); // Apply default tuning for new instrument updateTuning();
} }
function updateTuning() { function updateTuning() {
const selectedInstrument = instrumentSelect.value; const selectedInstrument = instrumentSelect.value;
const selectedTuning = tuningSelect.value; const selectedTuning = tuningSelect.value;
currentTuning = instrumentTunings[selectedInstrument][selectedTuning]; currentTuning = instrumentTunings[selectedInstrument][selectedTuning];
updateStringButtons(); // Update button labels when tuning changes updateStringButtons();
} }
function updateStringButtons() { function updateStringButtons() {
stringsDiv.innerHTML = ""; // Clear existing buttons stringsDiv.innerHTML = "";
currentTuning.forEach((midiNote, index) => { currentTuning.forEach((midiNote, index) => {
let button = document.createElement("button"); let button = document.createElement("button");
button.classList.add("string-button"); button.classList.add("string-button");
@ -121,12 +111,8 @@ document.addEventListener('DOMContentLoaded', () => {
const a440Value = parseFloat(a440Input.value); const a440Value = parseFloat(a440Input.value);
const transposeValue = parseInt(transposeInput.value); const transposeValue = parseInt(transposeInput.value);
if (!isNaN(a440Value)) { if (!isNaN(a440Value)) currentA440 = a440Value;
currentA440 = a440Value; if (!isNaN(transposeValue)) currentTranspose = transposeValue;
}
if (!isNaN(transposeValue)) {
currentTranspose = transposeValue;
}
const adjustedMidiNote = midiNote + currentTranspose; const adjustedMidiNote = midiNote + currentTranspose;
const frequency = calculateFrequency(adjustedMidiNote, tuningMode); const frequency = calculateFrequency(adjustedMidiNote, tuningMode);
@ -143,14 +129,14 @@ document.addEventListener('DOMContentLoaded', () => {
playAllButton.addEventListener('click', () => { playAllButton.addEventListener('click', () => {
let delay = 0; let delay = 0;
Tone.Transport.stop(); Tone.Transport.stop();
Tone.Transport.cancel(); // Clear all scheduled events Tone.Transport.cancel();
currentTuning.forEach((midiNote, index) => { currentTuning.forEach((midiNote) => {
Tone.Transport.scheduleOnce(time => { Tone.Transport.scheduleOnce(() => {
playNote(midiNote); playNote(midiNote);
}, `+${delay}`); // Small delay between notes }, `+${delay}`);
delay += 0.2; // Increase delay for each note delay += 0.2;
}); });
Tone.Transport.start(); // Start Tone.Transport Tone.Transport.start();
}); });
instrumentSelect.addEventListener('change', updateInstrument); instrumentSelect.addEventListener('change', updateInstrument);
@ -158,7 +144,7 @@ document.addEventListener('DOMContentLoaded', () => {
tuningModeSelect.addEventListener('change', () => { tuningModeSelect.addEventListener('change', () => {
tuningMode = tuningModeSelect.value; tuningMode = tuningModeSelect.value;
}); });
updateInstrument(); // Initialize instrument and tuning on page load updateInstrument();
a440Input.addEventListener('change', () => { a440Input.addEventListener('change', () => {
outputDiv.textContent = `A4 Reference set to ${a440Input.value} Hz`; outputDiv.textContent = `A4 Reference set to ${a440Input.value} Hz`;

28
frontend/dist/vendor/tone.min.js vendored Normal file

File diff suppressed because one or more lines are too long

35
go.mod Normal file
View File

@ -0,0 +1,35 @@
module web-tuner
go 1.25.7
require github.com/wailsapp/wails/v2 v2.11.0
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

81
go.sum Normal file
View File

@ -0,0 +1,81 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

101
intervals.go Normal file
View File

@ -0,0 +1,101 @@
package main
import "strings"
type IntervalPair struct {
Strings [2]int `json:"strings"`
Name string `json:"name"`
Semitones int `json:"semitones"`
}
type IntervalData struct {
StringGroup []int `json:"string_group"`
FretPositions []int `json:"fret_positions"`
Intervals []IntervalPair `json:"intervals"`
}
var intervalNames = [12]string{
"P1", "m2", "M2", "m3", "M3", "P4",
"TT", "P5", "m6", "M6", "m7", "M7",
}
func intervalName(semitones int) string {
s := semitones % 12
if s < 0 {
s += 12
}
return intervalNames[s]
}
func generateIntervalPairs(cfg Config) []IntervalData {
tuning := cfg.Tuning
numStrings := len(tuning)
var result []IntervalData
for size := 2; size <= numStrings; size++ {
stringGroups := combinationsOf(numStrings, size)
for _, group := range stringGroups {
fretProduct := cartesianFrets(size, 6)
for _, frets := range fretProduct {
var pairs []IntervalPair
for i := 0; i < size; i++ {
for j := i + 1; j < size; j++ {
si, sj := group[i], group[j]
fi, fj := frets[i], frets[j]
sem := (NoteToSemitone[strings.TrimSpace(tuning[sj])] + fj -
NoteToSemitone[strings.TrimSpace(tuning[si])] - fi + 120) % 12
pairs = append(pairs, IntervalPair{
Strings: [2]int{si, sj},
Name: intervalName(sem),
Semitones: sem,
})
}
}
result = append(result, IntervalData{
StringGroup: group,
FretPositions: frets,
Intervals: pairs,
})
}
}
}
return result
}
func combinationsOf(n, k int) [][]int {
var results [][]int
combo := make([]int, k)
var gen func(start, depth int)
gen = func(start, depth int) {
if depth == k {
cp := make([]int, k)
copy(cp, combo)
results = append(results, cp)
return
}
for i := start; i < n; i++ {
combo[depth] = i
gen(i+1, depth+1)
}
}
gen(0, 0)
return results
}
func cartesianFrets(size, maxFret int) [][]int {
total := 1
for i := 0; i < size; i++ {
total *= maxFret
}
results := make([][]int, total)
for i := 0; i < total; i++ {
combo := make([]int, size)
tmp := i
for s := size - 1; s >= 0; s-- {
combo[s] = tmp % maxFret
tmp /= maxFret
}
results[i] = combo
}
return results
}

View File

@ -1,68 +0,0 @@
import os
import json
from itertools import combinations, product
from triad import build_note_map
NOTE_INDEX, _ = build_note_map()
def load_config(path="config.json"):
with open(path, "r") as f:
return json.load(f)
def interval_name(semitones):
interval_map = {
0: "P1", 1: "m2", 2: "M2", 3: "m3", 4: "M3", 5: "P4",
6: "TT", 7: "P5", 8: "m6", 9: "M6", 10: "m7", 11: "M7"
}
return interval_map.get(semitones % 12, f"+{semitones}")
def export_json(data, name):
output_dir = "generated_data"
os.makedirs(output_dir, exist_ok=True)
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_interval_pairs(config):
tuning = config["tuning"]
max_frets = config.get("frets")
num_strings = len(tuning)
interval_data = []
pair_count_before_filter = 0
for size in range(2, num_strings + 1):
for string_group in combinations(range(num_strings), size):
for fret_group in product(range(6), repeat=size):
pair_count_before_filter += 1
pairwise_intervals = []
for i in range(size):
for j in range(i + 1, size):
s_i, s_j = string_group[i], string_group[j]
f_i, f_j = fret_group[i], fret_group[j]
semitones = (NOTE_INDEX[tuning[s_j]] + f_j - NOTE_INDEX[tuning[s_i]] - f_i) % 12
pairwise_intervals.append({
"strings": [s_i, s_j],
"name": interval_name(semitones),
"semitones": semitones
})
interval_data.append({
"string_group": list(string_group),
"fret_positions": list(fret_group),
"intervals": pairwise_intervals
})
export_json(interval_data, "interval_triads")
print(f"Generated {len(interval_data)} interval triads (max_frets={max_frets}, strings={num_strings}).")
print(f"Total pairs before filtering: {pair_count_before_filter}")
return interval_data
def main():
config = load_config()
generate_interval_pairs(config)
if __name__ == "__main__":
main()

37
main.go Normal file
View File

@ -0,0 +1,37 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
app := NewApp()
err := wails.Run(&options.App{
Title: "Web Tuner",
Width: 1200,
Height: 800,
AssetServer: &assetserver.Options{
Assets: assets,
},
Mac: &mac.Options{
TitleBar: mac.TitleBarHiddenInset(),
WebviewIsTransparent: true,
},
OnStartup: app.startup,
Bind: []interface{}{
app,
},
})
if err != nil {
panic(err)
}
}

45
main.py
View File

@ -1,45 +0,0 @@
import chords as chord
import intervals as interval
import triad as triads
from utils import load_config
import json
from jinja2 import Template
import os
def render_chords_html():
config = load_config()
max_fret = config.get("frets", 4)
num_strings = len(config.get("tuning", ["G", "C", "E", "A"]))
with open("generated_data/triad_chords.json") as f:
matched_chords = json.load(f)
filtered_chords = []
for match in matched_chords:
if "fingering" in match:
match["fret_positions"] = match["fingering"]
filtered_chords.append(match)
else:
print(f"Warning: Skipped match without fret_positions: {match}")
with open("template.html") as f:
template = Template(f.read())
html = template.render(chords=filtered_chords, max_fret=max_fret, num_strings=num_strings, config=config)
theme_link = '<link id="theme-stylesheet" rel="stylesheet" href="chords-default.css">'
html = html.replace('<head>', f'<head>{theme_link}')
os.makedirs("www", exist_ok=True)
with open("www/chords.html", "w") as f:
f.write(html)
print("Rendered HTML report to www/chords.html")
def main():
chord.main()
interval.main()
triads.main()
render_chords_html()
if __name__ == "__main__":
main()

36
notes.go Normal file
View File

@ -0,0 +1,36 @@
package main
var NoteToSemitone map[string]int
var SemitoneToNote map[int]string
func init() {
baseNotes := []string{"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"}
NoteToSemitone = make(map[string]int, 26)
SemitoneToNote = make(map[int]string, 12)
for i, note := range baseNotes {
NoteToSemitone[note] = i
SemitoneToNote[i] = note
}
enharmonics := map[string]string{
"Cb": "B",
"B#": "C",
"Db": "C#",
"C##": "D",
"Eb": "D#",
"D##": "E",
"Fb": "E",
"E#": "F",
"Gb": "F#",
"F##": "G",
"Ab": "G#",
"G##": "A",
"Bb": "A#",
"A##": "B",
}
for enh, actual := range enharmonics {
NoteToSemitone[enh] = NoteToSemitone[actual]
}
}

View File

523
shapes.go Normal file
View File

@ -0,0 +1,523 @@
package main
import (
"fmt"
"sort"
"strconv"
"strings"
"unicode"
)
type ShapeDefinition struct {
Name string `json:"name"`
Frets []int `json:"frets"` // -1 = muted
}
type ShapeQuery struct {
Shape ShapeDefinition `json:"shape"`
TargetQuality string `json:"target_quality"`
TargetRoot int `json:"target_root"` // 0-11 or -1 for any
Voicing []string `json:"voicing"` // per-string "" or "C4"/"60"
}
type CompanionChord struct {
Shape string `json:"shape"`
Chord string `json:"chord"`
Root string `json:"root"`
Quality string `json:"quality"`
Notes []string `json:"notes"`
}
type TuningCandidate struct {
Tuning []string `json:"tuning"`
TuningMIDI []int `json:"tuning_midi"`
Root string `json:"root"`
Chord string `json:"chord"`
Companions []CompanionChord `json:"companions"`
ValidChords int `json:"valid_chords"`
MajMinCount int `json:"maj_min_count"`
HighCompat bool `json:"high_compat"`
}
var standardMIDI = []int{40, 45, 50, 55, 59, 64} // E2 A2 D3 G3 B3 E4
func DefaultShapes() []ShapeDefinition {
return []ShapeDefinition{
{"E major", []int{0, 2, 2, 1, 0, 0}},
{"E minor", []int{0, 2, 2, 0, 0, 0}},
{"A major", []int{-1, 0, 2, 2, 2, 0}},
{"A minor", []int{-1, 0, 2, 2, 1, 0}},
{"D major", []int{-1, -1, 0, 2, 3, 2}},
{"D minor", []int{-1, -1, 0, 2, 3, 1}},
{"E7 (Dom7)", []int{0, 2, 0, 1, 0, 0}},
{"Em7 (Min7)", []int{0, 2, 0, 0, 0, 0}},
{"A7 (Dom7)", []int{-1, 0, 2, 0, 2, 0}},
{"Am7 (Min7)", []int{-1, 0, 2, 0, 1, 0}},
{"Lazy barre", []int{0, 0, 2, 2, 2, 2}},
}
}
func parsePitchInput(s string) (midi int, pc int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return -1, -1, fmt.Errorf("empty input")
}
if n, e := strconv.Atoi(s); e == nil {
if n < 0 || n > 127 {
return 0, 0, fmt.Errorf("MIDI %d out of range", n)
}
return n, n % 12, nil
}
// Note name: C4, Bb3, F#5, etc.
i := 0
if i >= len(s) {
return 0, 0, fmt.Errorf("invalid note: %s", s)
}
notePart := string(unicode.ToUpper(rune(s[i])))
i++
for i < len(s) && (s[i] == '#' || s[i] == 'b') {
notePart += string(s[i])
i++
}
sem, ok := NoteToSemitone[notePart]
if !ok {
return 0, 0, fmt.Errorf("unknown note: %s", notePart)
}
if i >= len(s) {
return 0, 0, fmt.Errorf("missing octave in: %s", s)
}
octStr := s[i:]
neg := false
if octStr[0] == '-' {
neg = true
octStr = octStr[1:]
}
oct, e := strconv.Atoi(octStr)
if e != nil {
return 0, 0, fmt.Errorf("invalid octave in: %s", s)
}
if neg {
oct = -oct
}
midi = (oct+1)*12 + sem
if midi < 0 || midi > 127 {
return 0, 0, fmt.Errorf("MIDI %d out of range for %s", midi, s)
}
return midi, sem, nil
}
func closestMIDIInRange(pc, standard int) (int, bool) {
lo := standard - 7
hi := standard + 7
// find MIDI note with pitch class pc closest to standard within range
best := -1
bestDist := 999
for m := lo; m <= hi; m++ {
if m < 0 || m > 127 {
continue
}
if m%12 == pc {
d := m - standard
if d < 0 {
d = -d
}
if d < bestDist {
bestDist = d
best = m
}
}
}
return best, best >= 0
}
func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]TuningCandidate, error) {
shape := query.Shape.Frets
nStrings := len(shape)
if nStrings != 6 {
return nil, fmt.Errorf("shape must have 6 strings")
}
// resolve target intervals
var targetIntervals []int
defs := GetChordDefinitions()
found := false
for _, cat := range defs {
if ivs, ok := cat[query.TargetQuality]; ok {
targetIntervals = ivs
found = true
break
}
}
if !found {
return nil, fmt.Errorf("unknown quality: %s", query.TargetQuality)
}
// parse voicing pins
type pinInfo struct {
midi int
pc int
}
pins := make([]pinInfo, nStrings)
for i := range pins {
pins[i] = pinInfo{-1, -1}
}
if len(query.Voicing) > 0 {
for i := 0; i < nStrings && i < len(query.Voicing); i++ {
v := strings.TrimSpace(query.Voicing[i])
if v == "" {
continue
}
if shape[i] == -1 {
continue // muted string, ignore voicing
}
m, p, err := parsePitchInput(v)
if err != nil {
return nil, fmt.Errorf("string %d voicing: %v", i+1, err)
}
pins[i] = pinInfo{m, p}
}
}
// determine candidate roots
var roots []int
if query.TargetRoot >= 0 && query.TargetRoot < 12 {
roots = []int{query.TargetRoot}
} else {
roots = make([]int, 12)
for i := 0; i < 12; i++ {
roots[i] = i
}
}
// chord pitch classes for each root
type tuningResult struct {
tuningPCs []int
tuningMIDI []int
root int
}
var results []tuningResult
seen := make(map[string]bool)
for _, root := range roots {
chordPCs := make(map[int]bool, len(targetIntervals))
for _, iv := range targetIntervals {
chordPCs[(root+iv)%12] = true
}
// for each non-muted string, compute candidate open note PCs
type stringCandidates struct {
pcs []int
}
candidates := make([]stringCandidates, nStrings)
muted := make([]bool, nStrings)
for s := 0; s < nStrings; s++ {
if shape[s] == -1 {
muted[s] = true
candidates[s] = stringCandidates{[]int{standardMIDI[s] % 12}}
continue
}
if pins[s].pc >= 0 {
// pinned: open note = pinned_midi - shape_fret
openPC := (pins[s].pc - shape[s] + 120) % 12
candidates[s] = stringCandidates{[]int{openPC}}
continue
}
var cands []int
for ct := range chordPCs {
openPC := (ct - shape[s] + 12) % 12
cands = append(cands, openPC)
}
candidates[s] = stringCandidates{cands}
}
// cartesian product
indices := make([]int, nStrings)
sizes := make([]int, nStrings)
total := 1
for s := 0; s < nStrings; s++ {
sizes[s] = len(candidates[s].pcs)
total *= sizes[s]
}
for combo := 0; combo < total; combo++ {
tmp := combo
for s := nStrings - 1; s >= 0; s-- {
indices[s] = tmp % sizes[s]
tmp /= sizes[s]
}
tuningPCs := make([]int, nStrings)
for s := 0; s < nStrings; s++ {
tuningPCs[s] = candidates[s].pcs[indices[s]]
}
// check all chord tones present in voicing (non-muted strings)
voicedPCs := make(map[int]bool)
for s := 0; s < nStrings; s++ {
if !muted[s] {
sounded := (tuningPCs[s] + shape[s]) % 12
voicedPCs[sounded] = true
}
}
allPresent := true
for ct := range chordPCs {
if !voicedPCs[ct] {
allPresent = false
break
}
}
if !allPresent {
continue
}
// resolve MIDI pitches with tension check
tuningMIDI := make([]int, nStrings)
valid := true
for s := 0; s < nStrings; s++ {
if pins[s].midi >= 0 && !muted[s] {
openMIDI := pins[s].midi - shape[s]
if openMIDI < 0 || openMIDI > 127 {
valid = false
break
}
if openMIDI%12 != tuningPCs[s] {
valid = false
break
}
std := standardMIDI[s]
diff := openMIDI - std
if diff < 0 {
diff = -diff
}
if diff > 7 {
valid = false
break
}
tuningMIDI[s] = openMIDI
} else {
m, ok := closestMIDIInRange(tuningPCs[s], standardMIDI[s])
if !ok {
valid = false
break
}
tuningMIDI[s] = m
}
}
if !valid {
continue
}
key := fmt.Sprint(tuningMIDI)
if !seen[key] {
seen[key] = true
results = append(results, tuningResult{tuningPCs, tuningMIDI, root})
}
}
}
deduped := results
// build output
var output []TuningCandidate
for _, r := range deduped {
tuningNames := make([]string, nStrings)
for s := 0; s < nStrings; s++ {
tuningNames[s] = midiToNoteName(r.tuningMIDI[s])
}
companions := identifyCompanions(r.tuningMIDI, allShapes, query.Shape.Name, r.root, query.TargetQuality)
validChords := 0
majMinCount := 0
for _, c := range companions {
if c.Quality != "" && c.Chord != "?" && c.Chord != "—" {
validChords++
}
if c.Quality == "major" || c.Quality == "minor" {
majMinCount++
}
}
totalShapes := len(companions)
threshold := totalShapes / 2
if threshold < 5 {
threshold = 5
}
output = append(output, TuningCandidate{
Tuning: tuningNames,
TuningMIDI: r.tuningMIDI,
Root: SemitoneToNote[r.root],
Chord: SemitoneToNote[r.root] + " " + query.TargetQuality,
Companions: companions,
ValidChords: validChords,
MajMinCount: majMinCount,
HighCompat: majMinCount > threshold,
})
}
sort.Slice(output, func(i, j int) bool {
if output[i].ValidChords != output[j].ValidChords {
return output[i].ValidChords > output[j].ValidChords
}
if output[i].MajMinCount != output[j].MajMinCount {
return output[i].MajMinCount > output[j].MajMinCount
}
return false
})
return output, nil
}
func identifyCompanions(tuningMIDI []int, shapes []ShapeDefinition, searchShapeName string, targetRoot int, targetQuality string) []CompanionChord {
defs := GetChordDefinitions()
nStrings := len(tuningMIDI)
type chordDef struct {
quality string
intervals []int
}
var allDefs []chordDef
for _, cat := range defs {
for name, ivs := range cat {
allDefs = append(allDefs, chordDef{name, ivs})
}
}
// put search shape first
ordered := make([]ShapeDefinition, 0, len(shapes))
searchIdx := -1
for i, s := range shapes {
if s.Name == searchShapeName {
searchIdx = i
break
}
}
if searchIdx >= 0 {
ordered = append(ordered, shapes[searchIdx])
for i, s := range shapes {
if i != searchIdx {
ordered = append(ordered, s)
}
}
} else {
ordered = shapes
}
var companions []CompanionChord
for _, shape := range ordered {
frets := shape.Frets
if len(frets) != nStrings {
continue
}
var soundedPCs []int
notes := make([]string, nStrings)
allMuted := true
for s := 0; s < nStrings; s++ {
if frets[s] == -1 {
notes[s] = "x"
continue
}
allMuted = false
midi := tuningMIDI[s] + frets[s]
pc := midi % 12
soundedPCs = append(soundedPCs, pc)
notes[s] = midiToNoteName(midi)
}
if allMuted || len(soundedPCs) == 0 {
companions = append(companions, CompanionChord{
Shape: shape.Name,
Chord: "—",
Notes: notes,
})
continue
}
// search shape: we already know the target chord
if shape.Name == searchShapeName && targetRoot >= 0 {
rn := SemitoneToNote[targetRoot]
companions = append(companions, CompanionChord{
Shape: shape.Name,
Chord: rn + " " + targetQuality,
Root: rn,
Quality: targetQuality,
Notes: notes,
})
continue
}
uniquePCs := uniqueInts(soundedPCs)
// collect all valid interpretations, pick the simplest
type candidate struct {
root int
quality string
size int
bassIdx int
}
var candidates []candidate
for ri, root := range uniquePCs {
ivSet := make(map[int]bool)
for _, pc := range soundedPCs {
ivSet[(pc-root+12)%12] = true
}
for _, cd := range allDefs {
if len(ivSet) != len(cd.intervals) {
continue
}
ok := true
for _, iv := range cd.intervals {
if !ivSet[iv] {
ok = false
break
}
}
if ok {
candidates = append(candidates, candidate{root, cd.quality, len(cd.intervals), ri})
}
}
}
bestChord := "?"
bestRoot := ""
bestQuality := ""
if len(candidates) > 0 {
best := candidates[0]
for _, c := range candidates[1:] {
if c.size < best.size || (c.size == best.size && c.bassIdx < best.bassIdx) {
best = c
}
}
rn := SemitoneToNote[best.root]
bestChord = rn + " " + best.quality
bestRoot = rn
bestQuality = best.quality
}
companions = append(companions, CompanionChord{
Shape: shape.Name,
Chord: bestChord,
Root: bestRoot,
Quality: bestQuality,
Notes: notes,
})
}
return companions
}
func midiToNoteName(midi int) string {
pc := midi % 12
octave := midi/12 - 1
return fmt.Sprintf("%s%d", SemitoneToNote[pc], octave)
}

1
static/vectors Submodule

@ -0,0 +1 @@
Subproject commit ca6d52252307de75ce9278bb828296780fcfafdc

View File

@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Chord Fingering Matches</title>
<link href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;500;700&display=swap" rel="stylesheet">
</head>
<body>
<h1>Matched Chord Positions</h1>
<div id="chord-container" data-max-fret="{{ max_fret }}" data-num-strings="{{ num_strings }}" data-tuning='{{ tuning_data_json | safe }}'>
{% for match in chords %}
<div class="chord-card">
<h2>{{ match.chord }}</h2>
<div class="fretboard" data-chord="{{ match.chord }}" data-fingering='{{ match.fingering | tojson }}'></div>
{% if match.alternatives %}
<div class="alternatives">
<h3>Alternatives:</h3>
<div class="alternatives-container">
{% for alt_fingering in match.alternatives %}
<div class="fretboard alternative-fretboard" data-chord="{{ match.chord }} (Alternative)" data-fingering='{{ alt_fingering | tojson }}'></div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
<script src="chords.js"></script>
<script src="http://unpkg.com/tone"></script>
</body>
</html>

271
triad.py
View File

@ -1,271 +0,0 @@
import os
import json
from itertools import product, combinations
from utils import load_config, export_json
def build_note_map():
base_notes = ['C', 'C#', 'D', 'D#', 'E', 'F',
'F#', 'G', 'G#', 'A', 'A#', 'B']
enharmonic_keys = ['Cb', 'B#', 'Db', 'C##', 'Eb', 'D##', 'Fb', 'E#', 'Gb', 'F##', 'Ab', 'G##', 'Bb', 'A##']
enharmonic_vals = ['B', 'C', 'C#', 'D', 'D#', 'E', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
note_map = {}
reverse_note_map = {}
for i, note in enumerate(base_notes):
note_map[note] = i
reverse_note_map[i] = note
for enh, actual in zip(enharmonic_keys, enharmonic_vals):
note_map[enh] = note_map[actual]
return note_map, reverse_note_map
def load_json(name):
path = os.path.join("generated_data", f"{name}.json")
with open(path, "r") as f:
return json.load(f)
def count_effective_fingers(fingering, num_strings):
fretted = [(i, int(f)) for i, f in enumerate(fingering) if f not in ("x", "X", "0")]
if not fretted:
return 0
fingers_used = set()
frets = {}
for idx, fret in fretted:
if fret not in frets:
frets[fret] = []
frets[fret].append(idx)
for fret, strings in frets.items():
if len(strings) >= 2:
start = min(strings)
end = max(strings)
if end - start <= 4:
valid = True
for i in range(start, end + 1):
val = fingering[i]
if val not in ("x", "X"):
try:
if int(val) < fret:
valid = False
break
except ValueError:
valid = False
break
if valid:
fingers_used.add((fret, "barre"))
for fret, strings in frets.items():
if fret not in fingers_used:
fingers_used.add(fret)
return sum(2 if isinstance(f, tuple) and f[1] == "barre" else 1 for f in fingers_used)
def find_chord_fingerings(config):
chords = load_json("chord_definitions")
results = []
generated_fingerings = set()
string_tunings = config.get("tuning", ["G", "C", "E", "A"])
NUM_STRINGS = len(string_tunings)
MAX_FRET = config.get("frets", 4)
MAX_FINGERS = config.get("max_fingers", 3)
all_chords = {}
for chord_type, chord_group in chords.items():
for chord_name_in_group, intervals in chord_group.items():
full_chord_name = f"{chord_name_in_group.capitalize()} {chord_type.capitalize()[:-1]}"
all_chords[full_chord_name] = {
"intervals": intervals,
"type": chord_type,
"name": chord_name_in_group
}
note_map, reverse_note_map = build_note_map()
fret_options_no_x = [str(fret) for fret in range(MAX_FRET + 1)] + ["x"]
for chord_name, chord_data in all_chords.items():
intervals = chord_data["intervals"]
interval_set = set(intervals)
for test_fingering_tuple in product(fret_options_no_x, repeat=NUM_STRINGS):
test_fingering = list(test_fingering_tuple)
fretted_notes_semitones = []
for i, fret in enumerate(test_fingering):
if fret not in ("x", "X"):
tuning_note = string_tunings[i].strip()
note_semitone = (note_map[tuning_note] + int(fret)) % 12
fretted_notes_semitones.append(note_semitone)
def is_valid_mute_config(fingering):
for i, f in enumerate(fingering):
if f in ("x", "X") and i not in (0, len(fingering) - 1):
return False
return True
if not is_valid_mute_config(test_fingering) or count_effective_fingers(test_fingering, NUM_STRINGS) > MAX_FINGERS:
continue
unique_fretted_notes = sorted(list(set(fretted_notes_semitones)))
if len(unique_fretted_notes) < len(intervals):
continue
for potential_root_semitone in unique_fretted_notes:
intervals_in_fingering = set()
for note_semitone in fretted_notes_semitones:
interval = (note_semitone - potential_root_semitone) % 12
intervals_in_fingering.add(interval)
if intervals_in_fingering == interval_set:
fingering_tuple = tuple(test_fingering)
if fingering_tuple not in generated_fingerings:
root_note_name = reverse_note_map.get(potential_root_semitone, str(potential_root_semitone))
result_chord_name = f"{root_note_name} {chord_name}"
result = {
"chord": result_chord_name,
"fingering": test_fingering,
"intervals": list(interval_set),
"interval_set": interval_set
}
def detect_barres(fingering):
fretted = [(i, int(f)) for i, f in enumerate(fingering) if f not in ("x", "X", "0")]
if not fretted:
return []
frets = {}
for idx, fret in fretted:
frets.setdefault(fret, []).append(idx)
barres = []
for fret, strings in frets.items():
if len(strings) < 2:
continue
if all(
all(fingering[j] in ("x", "X") or int(fingering[j]) >= fret for j in range(NUM_STRINGS))
for j in strings
):
barres.append({"fret": fret, "strings": strings})
return barres
result["barres"] = detect_barres(test_fingering)
results.append(result)
generated_fingerings.add(fingering_tuple)
break
def count_fingers(fingering):
return sum(1 for f in fingering if f not in ("x", "X", "0"))
def is_same_chord(fingering, chord_name, string_tunings, note_map, intervals):
fretted = []
for i, f in enumerate(fingering):
if f not in ("x", "X"):
tuning_note = string_tunings[i].strip()
note = (note_map[tuning_note] + int(f)) % 12
fretted.append(note)
for root in set(fretted):
interval_set_fingering = set()
for note in fretted:
interval_set_fingering.add((note - root) % 12)
if interval_set_fingering == intervals:
return True
return False
def is_open_chord(fingering):
return all(f in ("0", "x", "X") for f in fingering)
# Filter valid configurations
results = [
r for r in results
if is_valid_mute_config(r["fingering"]) and count_effective_fingers(r["fingering"], NUM_STRINGS) <= MAX_FINGERS
]
# Group fingerings by chord name
grouped = {}
for r in results:
chord_key = r["chord"].replace(" (alt)", "")
grouped.setdefault(chord_key, []).append(r["fingering"])
final_results = []
for chord_name, fingerings in grouped.items():
checked = set()
primary = None
alternatives = []
has_fretted_primary = False
for fingering in fingerings:
key = tuple(fingering)
if key in checked:
continue
checked.add(key)
intervals = next((r["intervals"] for r in results if r["fingering"] == list(fingering) and r["chord"] == chord_name), [])
intervals = set(intervals)
is_exact = is_same_chord(fingering, chord_name, string_tunings, note_map, intervals)
is_open = is_open_chord(fingering)
is_fretted = not is_open
if is_exact:
if is_fretted:
if not has_fretted_primary:
primary = fingering
has_fretted_primary = True
elif not has_fretted_primary:
if primary is None:
primary = fingering
else:
alternatives.append(fingering)
else:
if primary is not None:
alternatives.append(fingering)
elif primary is None and not alternatives:
primary = fingering
else:
alternatives.append(fingering)
# Find simpler muted variations of the primary
if primary is not None and fingering == primary:
for n in range(1, len(fingering)):
for idxs in combinations(range(len(fingering)), n):
test = fingering[:]
for i in idxs:
test[i] = "x"
if is_valid_mute_config(test) and is_same_chord(test, chord_name, string_tunings, note_map, intervals):
alternatives.append(list(test))
if primary is None and alternatives:
primary = alternatives.pop(0)
if primary is not None:
final_results.append({
"chord": chord_name.replace(" Triad", "").replace(" Seventh", "").replace(" Sixth", "").replace(" Ext.", ""),
"fingering": primary,
"alternatives": [alt for alt in set(map(tuple, alternatives)) if list(alt) != primary and count_fingers(list(alt)) <= MAX_FINGERS]
})
return final_results
def generate_chord_positions():
return find_chord_fingerings(load_config())
def convert_to_tab(chord_results):
chord_tabs = []
for chord in chord_results:
fretted_notes = chord.get('fingering', [])
chord_name = chord.get('chord', 'Unknown')
tab = list(fretted_notes)
chord_tabs.append({
"chord": chord_name,
"tab": tab
})
def main():
config = load_config()
fingerings = find_chord_fingerings(config)
if not fingerings:
print("No fingerings found.")
else:
print(f"Found {len(fingerings)} fingerings.")
export_json(fingerings, "triad_chords")
print(json.dumps(fingerings, indent=2))
if __name__ == "__main__":
main()

View File

@ -1,13 +0,0 @@
import json
import os
def load_config(path="config.json"):
with open(path) as f:
return json.load(f)
def export_json(data, name, output_dir="generated_data"):
os.makedirs(output_dir, exist_ok=True)
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}")

14
wails.json Normal file
View File

@ -0,0 +1,14 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "web-tuner",
"outputfilename": "web-tuner",
"frontend:dir": "frontend",
"frontend:install": "",
"frontend:build": "",
"frontend:dev:watcher": "",
"frontend:dev:serverUrl": "",
"author": {
"name": "",
"email": ""
}
}

View File

@ -1,115 +0,0 @@
@import url('chords-light.css');
body {
font-family: "Work Sans", sans-serif;
background-color: #383838; /* Darcula background */
color: #a9b7c6; /* Darcula text color */
padding: 1rem;
line-height: 1.5;
}
#chord-container {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
.chord-card {
border: 1px solid #555; /* Darcula card border */
border-radius: 8px;
padding: 1rem;
background-color: #2b2b2b; /* Darker card background */
width: max-content;
}
.chord-card h2 {
margin-top: 0;
font-size: 1.2rem;
color: #cdd3de; /* Darcula heading color */
}
.fretboard {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-left: .5rem;
border: .5px;
background-color: #515151; /* Darcula fretboard background */
}
.fretboard .fret {
background-color: #333!important; /* Darcula fret color */
}
.fret-row {
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
gap: 2px;
}
.fret {
width: 2.1rem;
height: 1.6rem;
box-sizing: border-box;
border: .5px solid #666; /* Darcula fret border */
border-left: 2px solid #555;
border-right: 2px solid #555;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
text-align: center;
line-height: 1.6rem;
font-weight: bold;
background-color: #454545; /* Darcula fret */
position: relative;
color: transparent;
}
.fret[data-dot]::after {
content: '';
width: 0.55rem;
height: 0.55rem;
background-color: #f2777a; /* Darcula red accent */
border: 1px solid #d95a5a;
border-radius: 50%;
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(242, 119, 122, 0.7);
border-color: #d95a5a;
}
.fret:empty {
background-color: #454545; /* Darcula empty fret */
}
.alternatives {
margin-top: 1rem;
}
.alternatives h3 {
color: #a9b7c6; /* Darcula alternative heading */
font-size: 1rem;
margin-bottom: 0.5rem;
}
.alternatives-container {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.alternative-fretboard {
margin-inline: 1.8rem;
}
.alternative-fretboard .fret {
width: 1.8rem;
height: 1.3rem;
}

View File

@ -1,116 +0,0 @@
@import url('chords-light.css');
body {
font-family: "Work Sans", sans-serif;
background-color: #121212;
color: #eec4e7;
padding: 1rem;
line-height: 1.5;
}
#chord-container {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
.chord-card {
border: 1px solid rgb(151, 16, 137);
border-radius: 8px;
padding: 1rem;
background-color: #1a1a1a;
width: max-content;
}
.chord-card h2 {
margin-top: 0;
font-size: 1.2rem;
color: #ffd9f0;
}
.fretboard {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-left: .5rem;
border: .5px;
background-color: #56208bbe;
}
.fretboard .fret {
background-color: #130404!important;
}
.fret-row {
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
gap: 2px;
}
.fret {
width: 2.1rem;
height: 1.6rem;
box-sizing: border-box;
border: .5px solid #48b9debd;
border-left: 2px solid #55555500;
border-right: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
text-align: center;
line-height: 1.6rem;
font-weight: bold;
background-color: #1e1e1e;
position: relative;
color: transparent;
}
.fret[data-dot]::after {
content: '';
width: 0.55rem;
height: 0.55rem;
background-color: #ff6b6b;
border: 1px solid white;
border-radius: 50%;
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color:rgba(255, 0, 0, 0.346);
border-color:rgb(255, 6, 230)
}
.fret:empty {
background-color: #1e1e1e;
}
.alternatives {
margin-top: 1rem;
}
.alternatives h3 {
color: #ffffff;
font-size: 1rem;
margin-bottom: 0.5rem;
}
.alternatives-container {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.alternative-fretboard {
margin-inline: 1.8rem;
}
.alternative-fretboard .fret {
width: 1.8rem;
height: 1.3rem;
}

View File

@ -1,156 +0,0 @@
.body {
font-family: "Work Sans", sans-serif;
background-color: #f9f9f9; /* Light background */
color: #333; /* Dark text */
padding: 1rem;
line-height: 1.5;
}
#chord-container {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
.chord-card {
border: 1px solid #ccc; /* Light border */
border-radius: 8px;
padding: 1rem;
background-color: #fff; /* White card background */
width: max-content;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); /* Subtle shadow for depth */
}
.chord-card h2 {
margin-top: 0;
font-size: 1.2rem;
color: #222; /* Darker heading text */
}
.fretboard {
display: flex;
flex-direction: column;
align-items: flex-start; /* Align items to the start to prevent extra width */
gap: 0px;
margin-left: .5rem;
border: .1px solid #000000; /* Light fretboard border */
background-color: #c53737d1; /* Light fretboard background */
position: relative;
width: fit-content; /* Fit content to prevent extra width */
}
.fretboard .fret {
background-color: #ffffff!important; /* Light fret background */
}
.fret-row {
display: flex;
flex-direction: row;
justify-content: flex-start; /* Align frets to the start */
align-items: stretch;
gap: 0px;
}
.fret {
width: 2.1rem;
height: 1.6rem;
box-sizing: border-box;
border: .5px solid #000000;
border-left: 2px solid #ffffff;
border-right: 2px solid #000000;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
text-align: center;
line-height: 1.6rem;
font-weight: bold;
background-color: #fff;
color: #fff; /* Set to same as background for invisibility */
position: relative; /* Needed for dot positioning */
}
.fret[data-dot]::after {
content: '';
width: 0.55rem;
height: 0.55rem;
background-color: #1dc9fe;
border: 1px solid #0a02a4;
border-radius: 50%;
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.fret:empty {
background-color: #fff; /* White empty fret */
}
.alternatives {
margin-top: 1rem;
}
.alternatives h3 {
color: #555; /* Slightly darker alternative heading */
font-size: 1rem;
margin-bottom: 0.5rem;
}
.alternatives-container {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.alternative-fretboard {
margin-inline: 1.8rem;
}
.alternative-fretboard .fret {
width: 1.8rem;
height: 1.3rem;
}
.fret.barre {
/* remove background and border color overrides */
}
.fret.barre::before {
content: none; /* remove overlay effect */
}
.fret.barre::after {
background-color: transparent;
border: 1px solid #360148;
z-index: 1;
}
.barre-line {
position: absolute;
background-color: #000;
border-radius: 5rem;
pointer-events: none;
z-index: 2;
width: 1rem;
left: 0;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
font-size: 0.7rem;
color: white;
font-family: sans-serif;
font-weight: bold;
transform: translateX(calc(50% - 1.1rem)); /* Nudge barre line to center, adjust as needed */
}
.fret[muted] {
color: #000;
font-weight: bold;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
}

View File

@ -1,115 +0,0 @@
@import url('chords-light.css');
body {
font-family: "Work Sans", sans-serif;
background-color: #002b36; /* Solarized Dark base03 */
color: #839496; /* Solarized Dark base0 */
padding: 1rem;
line-height: 1.5;
}
#chord-container {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
.chord-card {
border: 1px solid #073642; /* Solarized Dark base02 */
border-radius: 8px;
padding: 1rem;
background-color: #002b36; /* Solarized Dark base03 - same as body for a subtle card */
width: max-content;
}
.chord-card h2 {
margin-top: 0;
font-size: 1.2rem;
color: #b58900; /* Solarized Dark yellow */
}
.fretboard {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-left: .5rem;
border: .5px;
background-color: #073642; /* Solarized Dark base02 */
}
.fretboard .fret {
background-color: #002b36!important; /* Solarized Dark base03 - same as body */
}
.fret-row {
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
gap: 2px;
}
.fret {
width: 2.1rem;
height: 1.6rem;
box-sizing: border-box;
border: .5px solid #586e75; /* Solarized Dark base01 */
border-left: 2px solid #073642; /* Solarized Dark base02 */
border-right: 2px solid #073642; /* Solarized Dark base02 */
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
text-align: center;
line-height: 1.6rem;
font-weight: bold;
background-color: #073642; /* Solarized Dark base02 */
position: relative;
color: transparent;
}
.fret[data-dot]::after {
content: '';
width: 0.55rem;
height: 0.55rem;
background-color: #dc322f; /* Solarized Dark red */
border: 1px solid #cb4b16; /* Solarized Dark orange border for red dot */
border-radius: 50%;
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(220, 50, 47, 0.7);
border-color: #cb4b16;
}
.fret:empty {
background-color: #073642; /* Solarized Dark base02 */
}
.alternatives {
margin-top: 1rem;
}
.alternatives h3 {
color: #839496; /* Solarized Dark base0 */
font-size: 1rem;
margin-bottom: 0.5rem;
}
.alternatives-container {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.alternative-fretboard {
margin-inline: 1.8rem;
}
.alternative-fretboard .fret {
width: 1.8rem;
height: 1.3rem;
}

View File

@ -1,115 +0,0 @@
@import url('chords-light.css');
body {
font-family: "Work Sans", sans-serif;
background-color: #000000; /* Tomorrow AMOLED background - pure black */
color: #ffffff; /* White text for contrast */
padding: 1rem;
line-height: 1.5;
}
#chord-container {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
.chord-card {
border: 1px solid #2a2a29; /* Very dark border, almost invisible */
border-radius: 8px;
padding: 1rem;
background-color: #111111; /* Very dark card background */
width: max-content;
}
.chord-card h2 {
margin-top: 0;
font-size: 1.2rem;
color: #ffffff; /* White heading */
}
.fretboard {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-left: .5rem;
border: .5px;
background-color: #222222; /* Dark fretboard background */
}
.fretboard .fret {
background-color: #111111!important; /* Very dark fret */
}
.fret-row {
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
gap: 2px;
}
.fret {
width: 2.1rem;
height: 1.6rem;
box-sizing: border-box;
border: .5px solid #333333; /* Dark fret border */
border-left: 2px solid #222222;
border-right: 2px solid #222222;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
text-align: center;
line-height: 1.6rem;
font-weight: bold;
background-color: #2a2a2a; /* Darker fret */
position: relative;
color: transparent;
}
.fret[data-dot]::after {
content: '';
width: 0.55rem;
height: 0.55rem;
background-color: #ffcd56; /* Tomorrow Yellow - bright accent */
border: 1px solid #f7bb33;
border-radius: 50%;
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(255, 205, 86, 0.7);
border-color: #f7bb33;
}
.fret:empty {
background-color: #2a2a2a; /* Darker empty fret */
}
.alternatives {
margin-top: 1rem;
}
.alternatives h3 {
color: #ffffff; /* White alternative heading */
font-size: 1rem;
margin-bottom: 0.5rem;
}
.alternatives-container {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.alternative-fretboard {
margin-inline: 1.8rem;
}
.alternative-fretboard .fret {
width: 1.8rem;
height: 1.3rem;
}

View File

@ -1,230 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("chord-container");
// Create controls
const controls = document.createElement("div");
controls.style.marginBottom = "1rem";
const sortRoot = document.createElement("select");
sortRoot.innerHTML = `
<option value="">Sort by Root</option>
<option value="asc">Root A-Z</option>
<option value="desc">Root Z-A</option>
`;
const sortType = document.createElement("select");
sortType.innerHTML = `
<option value="">Sort by Type</option>
<option value="asc">Type A-Z</option>
<option value="desc">Type Z-A</option>
`;
const themeSelect = document.createElement("select");
themeSelect.innerHTML = `
<option value="light">Light</option>
<option value="default">Purple Theme</option>
<option value="solarized">Solarized</option>
<option value="tomorrow-amoled">Tomorrow AMOLED</option>
<option value="darcula">Darcula</option>
`;
controls.appendChild(themeSelect);
// Create theme <link> tag if not exists
let themeLink = document.getElementById("theme-stylesheet");
if (!themeLink) {
themeLink = document.createElement("link");
themeLink.rel = "stylesheet";
themeLink.id = "theme-stylesheet";
document.head.appendChild(themeLink);
}
themeSelect.addEventListener("change", () => {
const theme = themeSelect.value;
themeLink.href = `chords-${theme}.css`;
});
// Load default
themeLink.href = "chords-light.css";
controls.appendChild(sortRoot);
controls.appendChild(sortType);
document.body.insertBefore(controls, container);
function getChordElements() {
return Array.from(container.getElementsByClassName("chord-card"));
}
function extractChordInfo(el) {
const title = el.querySelector("h2").textContent.trim();
const [root, ...typeParts] = title.split(" ");
return {
root,
type: typeParts.join(" "),
element: el
};
}
function sortAndRender(by = "root", order = "asc") {
const chords = getChordElements().map(extractChordInfo);
chords.sort((a, b) => {
const valA = by === "root" ? a.root : a.type;
const valB = by === "root" ? b.root : b.type;
return order === "asc"
? valA.localeCompare(valB)
: valB.localeCompare(valA);
});
chords.forEach(({ element }) => {
element.style.transition = 'transform 0.2s ease, opacity 0.3s ease';
element.style.opacity = '0.9';
element.style.transform = 'scale(1.01)';
container.appendChild(element);
setTimeout(() => {
element.style.transform = 'scale(1.0)';
element.style.opacity = '1.0';
}, 300);
});
}
sortRoot.addEventListener("change", () => {
if (sortRoot.value) sortAndRender("root", sortRoot.value);
});
sortType.addEventListener("change", () => {
if (sortType.value) sortAndRender("type", sortType.value);
});
function renderFretboards() {
const fretboards = document.querySelectorAll(".fretboard");
fretboards.forEach(fb => {
const fingering = JSON.parse(fb.dataset.fingering);
const maxFret = parseInt(document.getElementById("chord-container").dataset.maxFret, 10);
const numStrings = parseInt(document.getElementById("chord-container").dataset.numStrings, 10);
const wrapper = document.createElement("div");
wrapper.className = "fretboard";
fb.innerHTML = ""; // Clear existing content
fb.style.display = "inline-block";
fb.style.marginBottom = "1rem";
const fretMatrix = [];
const fretCounts = {};
fingering.forEach(f => {
if (!isNaN(f)) {
fretCounts[f] = (fretCounts[f] || 0) + 1;
}
});
const entries = Object.entries(fretCounts)
.filter(([fret, count]) => count >= 2)
.map(([f, c]) => parseInt(f));
let barreFretNum = null;
for (const f of entries.sort((a, b) => a - b)) {
const allBefore = fingering.every(x => x === "x" || isNaN(x) || parseInt(x) >= f);
if (allBefore) {
barreFretNum = f;
break;
}
}
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++) {
const fret = fretMatrix[s][f - 1];
if (fret) {
stringRow.appendChild(fret);
}
}
wrapper.appendChild(stringRow);
}
fb.appendChild(wrapper); // Append before computing barre line
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++) { // Iterate through all barre strings
const dotFret = fretMatrix[s][barreFretNum - 1];
if (dotFret) { // Ensure fret exists (should always exist in barre scenario)
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`;
// Add finger number (1)
line.textContent = "|";
wrapper.appendChild(line);
});
}
});
}
renderFretboards();
});

View File

@ -1,134 +0,0 @@
body {
font-family: sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background-color: #000000;
}
.container {
background-color: #ffffff0c;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
text-align: center;
color: #dfdfdf;
}
h1 {
margin-bottom: 20px;
}
.tuning-controls {
display: flex;
gap: 20px;
margin-bottom: 20px;
justify-content: center;
}
.tuning-controls > div {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.tuning-controls label {
margin-bottom: 5px;
}
.tuning-controls input, .tuning-controls select {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
.strings {
display: flex;
gap: 10px;
margin: 1rem;
justify-content: center;
}
.string-button {
padding: 15px 25px;
margin: .5rem;
font-size: 1.2em;
border: none;
border-radius: 5px;
background-color: #00000000; /* Green */
color: #dfdfdf;
cursor: pointer;
transition: background-color 0.3s;
}
.string-button:hover {
background-color: #45a049;
}
#play-all {
padding: 12px 20px;
font-size: 1em;
border: none;
border-radius: 5px;
background-color: #008CBA; /* Blue */
color: #dfdfdf;
cursor: pointer;
transition: background-color 0.3s;
}
#play-all:hover {
background-color: #0077B3;
}
#output {
margin-top: 20px;
font-weight: bold;
}
.instrument-select {
margin: 1rem;
}
label[for=instrument] {
color: #dfdfdf;
font-weight: 600;
}
#instrument {
padding: .5rem;
padding-right: 1rem;
padding-left: 1rem;
font-size: 12pt;
font-weight: bold;
border: 2px solid rgb(67, 66, 66);
color: #dfdfdf;
border-radius: 5px;
}
.tuning-controls {
padding: .5rem;
padding-right: 1rem;
padding-left: 1rem;
font-size: 12pt;
font-weight: bold;
color: #dfdfdf;
}
.a440-control, .transpose-control, .tuning-mode-select, .tuning-select{
padding: .5rem;
padding-right: 1rem;
padding-left: 1rem;
font-size: 12pt;
font-weight: bold;
border: 2px solid rgb(67, 66, 66);
background-color: black;
color: #dfdfdf;
border-radius: 5px;
}
#tuning, #tuning-mode, #transpose, #a440, #instrument {
background-color: black;
color: #dfdfdf;
}

View File

@ -1,92 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Stringed Instrument Tuner</title>
<link rel="stylesheet" href="tuner.css">
</head>
<body>
<div class="container">
<h1 id="instrument-label">Ukulele Tuner</h1>
<div class="instrument-select">
<label for="instrument">Select Instrument:</label>
<select id="instrument">
<option value="ukulele">Ukulele</option>
<option value="guitar">Guitar</option>
</select>
</div>
<div class="tuning-controls">
<div class="a440-control">
<label for="a440">A4 Reference (Hz):</label>
<input type="number" id="a440" value="440">
</div>
<div class="transpose-control">
<label for="transpose">Transpose (Semitones):</label>
<input type="number" id="transpose" value="0">
</div>
<div class="tuning-mode-select">
<label for="tuning-mode">Tuning Mode:</label>
<select id="tuning-mode">
<option value="equal">Equal Temperament</option>
<option value="harmonic">Harmonic Tuning</option>
</select>
</div>
<div class="tuning-select">
<label for="tuning">Select Tuning:</label>
<select id="tuning">
<!-- Ukulele Tunings -->
<optgroup label="Ukulele Tunings">
<option value="standard">Standard (GCEA)</option>
<option value="slack-key">Slack Key (GCEG)</option>
<option value="low-g">Low G (GCEA - Low G)</option>
<option value="harmonic-minor">Harmonic Minor (G Bb D G)</option>
<option value="suspended-fourth">Suspended Fourth (G C F C)</option>
<option value="lydian">Lydian (G C E F#)</option>
<option value="diminished">Diminished (G B D F)</option>
<option value="augmented">Augmented (G C# E G#)</option>
<option value="open-fifths">Open Fifths (G D A D)</option>
<option value="double-unison">Double Unison (G G C C)</option>
<option value="ionian">Ionian (Major) (G C E A)</option>
<option value="dorian">Dorian (G Bb D A)</option>
<option value="mixo-dorian">Mixo-Dorian (F A# G A)</option>
<option value="phrygian">Phrygian (G Ab D A)</option>
<option value="mixolydian">Mixolydian (G C D A)</option>
<option value="aeolian">Aeolian (Natural Minor) (G Bb D G)</option>
<option value="locrian">Locrian (G Ab C G)</option>
</optgroup>
<!-- Guitar Tunings -->
<optgroup label="Guitar Tunings">
<option value="standard">Standard (EADGBE)</option>
<option value="drop-d">Drop D (DADGBE)</option>
<option value="dadgad">DADGAD</option>
<option value="open-g">Open G (DGDGBD)</option>
<option value="open-d">Open D (DADF#AD)</option>
<option value="open-c">Open C (CGCGCE)</option>
<option value="half-step-down">Half Step Down (Eb Ab Db Gb Bb Eb)</option>
<option value="full-step-down">Full Step Down (D G C F A D)</option>
<option value="double-drop-d">Double Drop D (DADGBD)</option>
<option value="new-standard">New Standard (CGDAEG)</option>
<option value="nashville-high-strung">Nashville High-Strung (EADGBE but high-strung)</option>
<option value="orkney">Orkney (CGDGCD)</option>
<option value="modal-tuning-1">Modal 1 (CGDGBE)</option>
<option value="modal-tuning-2">Modal 2 (EAEAC#E)</option>
<option value="db-custom">Db Custom (Db Gb B Ab B Eb)</option>
</optgroup>
</select>
</div>
</div>
<div id="strings"></div>
<button id="play-all">Play All Strings</button>
<div id="output"></div>
</div>
<script src="http://unpkg.com/tone"></script>
<script src="tuner.js"></script>
</body>
</html>