Go git number 1. Muuuch more better.
This commit is contained in:
parent
3fb782c52b
commit
ad1d10f266
|
|
@ -1,6 +1,2 @@
|
|||
config2.json
|
||||
output.txt
|
||||
generated_data/
|
||||
venv/
|
||||
__pycache__/
|
||||
www/chords.html
|
||||
build/
|
||||
frontend/wailsjs/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "static/vectors"]
|
||||
path = static/vectors
|
||||
url = https://git.else-if.org/jess/web-tuner-vectors.git
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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/"
|
||||
|
|
@ -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
|
||||
}
|
||||
59
chords.py
59
chords.py
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"instrument": "guitar",
|
||||
"tuning": ["C#", "F#", "B", "G#", "B", "D#"],
|
||||
"frets": 7,
|
||||
"tuning": ["B", "F#", "B", "F#", "A", "C#"],
|
||||
"frets": 5,
|
||||
"max_fingers": 4
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.';
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
})();
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
})();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -15,65 +15,55 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
const instrumentTunings = {
|
||||
"ukulele": {
|
||||
"standard": [67 + 12, 60 + 12, 64 + 12, 69 + 12], // G5, C5, E5, A5
|
||||
"low-g": [55 + 12, 60 + 12, 64 + 12, 69 + 12], // G4, C5, E5, A5
|
||||
"harmonic-minor": [67 + 12, 58 + 12, 62 + 12, 67 + 12], // G5, Bb5, D5, G5
|
||||
"suspended-fourth": [67 + 12, 60 + 12, 53 + 12, 60 + 12], // G5, C5, F5, C5
|
||||
"lydian": [67 + 12, 60 + 12, 64 + 12, 66 + 12], // G5, C5, E5, F#5
|
||||
"diminished": [67 + 12, 59 + 12, 62 + 12, 65 + 12], // G5, B5, D5, F5
|
||||
"augmented": [67 + 12, 61 + 12, 64 + 12, 68 + 12], // G5, C#5, E5, G#5
|
||||
"open-fifths": [67 + 12, 62 + 12, 69 + 12, 62 + 12], // G5, D5, A5, D5
|
||||
"double-unison": [67 + 12, 67 + 12, 60 + 12, 60 + 12], // G5, G5, C5, C5
|
||||
"ionian": [67 + 12, 60 + 12, 64 + 12, 69 + 12], // G C E A
|
||||
"dorian": [67 + 12, 58 + 12, 62 + 12, 69 + 12], // G Bb D A
|
||||
"mixo-dorian": [65 + 12, 58 + 12, 67 + 12, 69 + 12], // F A# G A
|
||||
"phrygian": [67 + 12, 56 + 12, 62 + 12, 69 + 12], // G Ab D A
|
||||
"mixolydian": [67 + 12, 60 + 12, 62 + 12, 69 + 12], // G C D A
|
||||
"aeolian": [67 + 12, 58 + 12, 62 + 12, 67 + 12], // G Bb D G
|
||||
"locrian": [67 + 12, 56 + 12, 60 + 12, 67 + 12] // G Ab C G
|
||||
"standard": [67 + 12, 60 + 12, 64 + 12, 69 + 12],
|
||||
"low-g": [55 + 12, 60 + 12, 64 + 12, 69 + 12],
|
||||
"harmonic-minor": [67 + 12, 58 + 12, 62 + 12, 67 + 12],
|
||||
"suspended-fourth": [67 + 12, 60 + 12, 53 + 12, 60 + 12],
|
||||
"lydian": [67 + 12, 60 + 12, 64 + 12, 66 + 12],
|
||||
"diminished": [67 + 12, 59 + 12, 62 + 12, 65 + 12],
|
||||
"augmented": [67 + 12, 61 + 12, 64 + 12, 68 + 12],
|
||||
"open-fifths": [67 + 12, 62 + 12, 69 + 12, 62 + 12],
|
||||
"double-unison": [67 + 12, 67 + 12, 60 + 12, 60 + 12],
|
||||
"ionian": [67 + 12, 60 + 12, 64 + 12, 69 + 12],
|
||||
"dorian": [67 + 12, 58 + 12, 62 + 12, 69 + 12],
|
||||
"mixo-dorian": [65 + 12, 58 + 12, 67 + 12, 69 + 12],
|
||||
"phrygian": [67 + 12, 56 + 12, 62 + 12, 69 + 12],
|
||||
"mixolydian": [67 + 12, 60 + 12, 62 + 12, 69 + 12],
|
||||
"aeolian": [67 + 12, 58 + 12, 62 + 12, 67 + 12],
|
||||
"locrian": [67 + 12, 56 + 12, 60 + 12, 67 + 12]
|
||||
},
|
||||
"guitar": {
|
||||
"standard": [40, 45, 50, 55, 59, 64], // EADGBE
|
||||
"drop-d": [38, 45, 50, 55, 59, 64], // DADGBE
|
||||
"dadgad": [38, 45, 50, 55, 57, 64], // DADGAD
|
||||
"open-g": [38, 43, 47, 50, 55, 59], // DGDGBD
|
||||
"open-d": [38, 43, 50, 54, 57, 64], // DADF#AD
|
||||
"open-c": [36, 40, 43, 48, 52, 57], // CGCGCE
|
||||
"half-step-down": [39, 43, 48, 52, 55, 60], // Eb Ab Db Gb Bb Eb
|
||||
"full-step-down": [38, 43, 48, 53, 57, 62], // D G C F A D
|
||||
"double-drop-d": [38, 43, 48, 50, 55, 59], // DADGBD
|
||||
"new-standard": [36, 40, 45, 50, 54, 59], // CGDAEG
|
||||
"nashville-high-strung": [40, 45, 50, 55, 59, 64], // EADGBE but with lighter strings
|
||||
"orkney": [36, 40, 43, 36, 40, 43], // CGDGCD
|
||||
"modal-tuning-1": [40, 45, 39, 50, 45, 64], // CGDGBE
|
||||
"modal-tuning-2": [40, 45, 37, 50, 45, 64], // EAEAC#E
|
||||
"db-custom": [49, 54, 59, 56, 71, 63] // Db Gb B Ab B (oct) Eb
|
||||
"standard": [40, 45, 50, 55, 59, 64],
|
||||
"drop-d": [38, 45, 50, 55, 59, 64],
|
||||
"dadgad": [38, 45, 50, 55, 57, 64],
|
||||
"open-g": [38, 43, 47, 50, 55, 59],
|
||||
"open-d": [38, 43, 50, 54, 57, 64],
|
||||
"open-c": [36, 40, 43, 48, 52, 57],
|
||||
"half-step-down": [39, 43, 48, 52, 55, 60],
|
||||
"full-step-down": [38, 43, 48, 53, 57, 62],
|
||||
"double-drop-d": [38, 43, 48, 50, 55, 59],
|
||||
"new-standard": [36, 40, 45, 50, 54, 59],
|
||||
"nashville-high-strung": [40, 45, 50, 55, 59, 64],
|
||||
"orkney": [36, 40, 43, 36, 40, 43],
|
||||
"modal-tuning-1": [40, 45, 39, 50, 45, 64],
|
||||
"modal-tuning-2": [40, 45, 37, 50, 45, 64],
|
||||
"db-custom": [49, 54, 59, 56, 71, 63]
|
||||
}
|
||||
};
|
||||
let currentTuning = [];
|
||||
let currentA440 = 440;
|
||||
let currentTranspose = 0;
|
||||
let tuningMode = "equal"; // Default tuning mode
|
||||
let tuningMode = "equal";
|
||||
|
||||
const harmonicFrequencyRatios = {
|
||||
"C": 1.0,
|
||||
"Db": 17/16,
|
||||
"D": 9/8,
|
||||
"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
|
||||
"C": 1.0, "Db": 17/16, "D": 9/8, "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() {
|
||||
const selectedInstrument = instrumentSelect.value;
|
||||
tuningSelect.innerHTML = ""; // Clear previous tuning options
|
||||
tuningSelect.innerHTML = "";
|
||||
let inst = instrumentSelect.value;
|
||||
instlabel.innerText = inst.charAt(0).toUpperCase() + inst.slice(1) + " Tuner";
|
||||
Object.keys(instrumentTunings[selectedInstrument]).forEach(tuning => {
|
||||
|
|
@ -82,18 +72,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
option.textContent = tuning.replace(/-/g, " ").toUpperCase();
|
||||
tuningSelect.appendChild(option);
|
||||
});
|
||||
updateTuning(); // Apply default tuning for new instrument
|
||||
updateTuning();
|
||||
}
|
||||
|
||||
function updateTuning() {
|
||||
const selectedInstrument = instrumentSelect.value;
|
||||
const selectedTuning = tuningSelect.value;
|
||||
currentTuning = instrumentTunings[selectedInstrument][selectedTuning];
|
||||
updateStringButtons(); // Update button labels when tuning changes
|
||||
updateStringButtons();
|
||||
}
|
||||
|
||||
function updateStringButtons() {
|
||||
stringsDiv.innerHTML = ""; // Clear existing buttons
|
||||
stringsDiv.innerHTML = "";
|
||||
currentTuning.forEach((midiNote, index) => {
|
||||
let button = document.createElement("button");
|
||||
button.classList.add("string-button");
|
||||
|
|
@ -121,12 +111,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const a440Value = parseFloat(a440Input.value);
|
||||
const transposeValue = parseInt(transposeInput.value);
|
||||
|
||||
if (!isNaN(a440Value)) {
|
||||
currentA440 = a440Value;
|
||||
}
|
||||
if (!isNaN(transposeValue)) {
|
||||
currentTranspose = transposeValue;
|
||||
}
|
||||
if (!isNaN(a440Value)) currentA440 = a440Value;
|
||||
if (!isNaN(transposeValue)) currentTranspose = transposeValue;
|
||||
|
||||
const adjustedMidiNote = midiNote + currentTranspose;
|
||||
const frequency = calculateFrequency(adjustedMidiNote, tuningMode);
|
||||
|
|
@ -143,14 +129,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
playAllButton.addEventListener('click', () => {
|
||||
let delay = 0;
|
||||
Tone.Transport.stop();
|
||||
Tone.Transport.cancel(); // Clear all scheduled events
|
||||
currentTuning.forEach((midiNote, index) => {
|
||||
Tone.Transport.scheduleOnce(time => {
|
||||
Tone.Transport.cancel();
|
||||
currentTuning.forEach((midiNote) => {
|
||||
Tone.Transport.scheduleOnce(() => {
|
||||
playNote(midiNote);
|
||||
}, `+${delay}`); // Small delay between notes
|
||||
delay += 0.2; // Increase delay for each note
|
||||
}, `+${delay}`);
|
||||
delay += 0.2;
|
||||
});
|
||||
Tone.Transport.start(); // Start Tone.Transport
|
||||
Tone.Transport.start();
|
||||
});
|
||||
|
||||
instrumentSelect.addEventListener('change', updateInstrument);
|
||||
|
|
@ -158,7 +144,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
tuningModeSelect.addEventListener('change', () => {
|
||||
tuningMode = tuningModeSelect.value;
|
||||
});
|
||||
updateInstrument(); // Initialize instrument and tuning on page load
|
||||
updateInstrument();
|
||||
|
||||
a440Input.addEventListener('change', () => {
|
||||
outputDiv.textContent = `A4 Reference set to ${a440Input.value} Hz`;
|
||||
|
|
@ -167,4 +153,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
transposeInput.addEventListener('change', () => {
|
||||
outputDiv.textContent = `Transpose set to ${transposeInput.value} semitones`;
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
}
|
||||
68
intervals.py
68
intervals.py
|
|
@ -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()
|
||||
|
|
@ -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
45
main.py
|
|
@ -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()
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit ca6d52252307de75ce9278bb828296780fcfafdc
|
||||
|
|
@ -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
271
triad.py
|
|
@ -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()
|
||||
13
utils.py
13
utils.py
|
|
@ -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}")
|
||||
|
|
@ -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": ""
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
230
www/chords.js
230
www/chords.js
|
|
@ -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();
|
||||
});
|
||||
134
www/tuner.css
134
www/tuner.css
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue