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