This might be the final version, it works perfectly as far as I can tell at this point.
This commit is contained in:
parent
ad1d10f266
commit
10962dca23
65
app.go
65
app.go
|
|
@ -10,6 +10,8 @@ type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
config Config
|
config Config
|
||||||
configPath string
|
configPath string
|
||||||
|
scoreSets ScoreSetsData
|
||||||
|
scoreSetsPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
|
|
@ -19,35 +21,62 @@ func NewApp() *App {
|
||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
a.loadConfig()
|
a.loadConfig()
|
||||||
|
a.loadScoreSets()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataDir() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
dir := filepath.Join(home, "web-tuner")
|
||||||
|
os.MkdirAll(dir, 0755)
|
||||||
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) loadConfig() {
|
func (a *App) loadConfig() {
|
||||||
exe, _ := os.Executable()
|
a.configPath = filepath.Join(dataDir(), "config.json")
|
||||||
dir := filepath.Dir(exe)
|
cfg, err := LoadConfig(a.configPath)
|
||||||
|
|
||||||
candidates := []string{
|
|
||||||
filepath.Join(dir, "config.json"),
|
|
||||||
"config.json",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, path := range candidates {
|
|
||||||
cfg, err := LoadConfig(path)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
if cfg.RangeDown == 0 && cfg.RangeUp == 0 {
|
||||||
|
cfg.RangeDown = 7
|
||||||
|
cfg.RangeUp = 7
|
||||||
|
}
|
||||||
a.config = cfg
|
a.config = cfg
|
||||||
a.configPath = path
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
a.configPath = "config.json"
|
|
||||||
a.config = Config{
|
a.config = Config{
|
||||||
Instrument: "guitar",
|
Instrument: "guitar",
|
||||||
Tuning: []string{"E", "A", "D", "G", "B", "E"},
|
Tuning: []string{"E", "A", "D", "G", "B", "E"},
|
||||||
Frets: 4,
|
Frets: 4,
|
||||||
MaxFingers: 4,
|
MaxFingers: 4,
|
||||||
|
RangeDown: 7,
|
||||||
|
RangeUp: 7,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) loadScoreSets() {
|
||||||
|
a.scoreSetsPath = filepath.Join(dataDir(), "scoresets.json")
|
||||||
|
data, err := LoadScoreSets(a.scoreSetsPath)
|
||||||
|
if err == nil {
|
||||||
|
a.scoreSets = data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.scoreSets = DefaultScoreSetsData()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetScoreSets() ScoreSetsData {
|
||||||
|
return a.scoreSets
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveScoreSets(data ScoreSetsData) error {
|
||||||
|
if len(data.Sets) == 0 {
|
||||||
|
data = DefaultScoreSetsData()
|
||||||
|
}
|
||||||
|
if data.Selected < 0 || data.Selected >= len(data.Sets) {
|
||||||
|
data.Selected = 0
|
||||||
|
}
|
||||||
|
a.scoreSets = data
|
||||||
|
return SaveScoreSets(a.scoreSetsPath, data)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) GetConfig() Config {
|
func (a *App) GetConfig() Config {
|
||||||
return a.config
|
return a.config
|
||||||
}
|
}
|
||||||
|
|
@ -96,3 +125,11 @@ func (a *App) GetDefaultShapes() []ShapeDefinition {
|
||||||
func (a *App) FindShapeTunings(query ShapeQuery, companions []ShapeDefinition) ([]TuningCandidate, error) {
|
func (a *App) FindShapeTunings(query ShapeQuery, companions []ShapeDefinition) ([]TuningCandidate, error) {
|
||||||
return findTuningsForShape(query, companions)
|
return findTuningsForShape(query, companions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) IdentifyShape(frets []int) string {
|
||||||
|
return identifyShape(frets, a.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FindDensityTunings(query ShapeQuery) ([]DensityCandidate, error) {
|
||||||
|
return findDensityTunings(query, a.config.Frets, a.config.MaxFingers)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ type Config struct {
|
||||||
Tuning []string `json:"tuning"`
|
Tuning []string `json:"tuning"`
|
||||||
Frets int `json:"frets"`
|
Frets int `json:"frets"`
|
||||||
MaxFingers int `json:"max_fingers"`
|
MaxFingers int `json:"max_fingers"`
|
||||||
|
BaselineShift int `json:"baseline_shift"`
|
||||||
|
RangeDown int `json:"range_down"`
|
||||||
|
RangeUp int `json:"range_up"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig(path string) (Config, error) {
|
func LoadConfig(path string) (Config, error) {
|
||||||
|
|
|
||||||
42
debug.go
42
debug.go
|
|
@ -2,9 +2,45 @@
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "log"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var debugLogger *log.Logger
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
log.SetFlags(log.Ltime | log.Lshortfile)
|
home, _ := os.UserHomeDir()
|
||||||
log.Println("[debug] debug build active")
|
logDir := filepath.Join(home, "web-tuner")
|
||||||
|
os.MkdirAll(logDir, 0755)
|
||||||
|
logPath := filepath.Join(logDir, "debug.log")
|
||||||
|
|
||||||
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||||
|
if err != nil {
|
||||||
|
debugLogger = log.New(os.Stderr, "[DEBUG] ", log.Ltime|log.Lshortfile)
|
||||||
|
debugLogger.Printf("Could not open %s: %v — logging to stderr", logPath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLogger = log.New(f, "[DEBUG] ", log.Ltime|log.Lshortfile)
|
||||||
|
debugLogger.Printf("=== web-tuner debug session started %s ===", time.Now().Format(time.RFC3339))
|
||||||
|
debugLogger.Printf("Log file: %s", logPath)
|
||||||
|
fmt.Fprintf(os.Stderr, "[web-tuner] Debug logging to %s\n", logPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugLog(format string, args ...any) {
|
||||||
|
if debugLogger != nil {
|
||||||
|
debugLogger.Printf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) JSDebugLog(msg string) {
|
||||||
|
debugLog("[JS] %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IsDebug() bool {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
debug.sh
21
debug.sh
|
|
@ -1,10 +1,25 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -e
|
||||||
|
pkill -f "web-tuner" 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
export CC=/usr/bin/clang
|
export CC=/usr/bin/clang
|
||||||
export CXX=/usr/bin/clang++
|
export CXX=/usr/bin/clang++
|
||||||
export SDKROOT=$(xcrun --show-sdk-path)
|
export SDKROOT=$(xcrun --show-sdk-path)
|
||||||
|
export CGO_ENABLED=1
|
||||||
export PATH="$HOME/go/bin:$PATH"
|
export PATH="$HOME/go/bin:$PATH"
|
||||||
|
|
||||||
echo "Starting dev build with debug tag..."
|
WAILS=$(command -v wails || echo "$HOME/go/bin/wails")
|
||||||
wails dev -tags debug -loglevel debug
|
|
||||||
|
echo "Building web-tuner (DEBUG)..."
|
||||||
|
"$WAILS" build -skipbindings -tags debug
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Debug build complete. Logs will be written to ~/web-tuner/debug.log"
|
||||||
|
echo "Launching..."
|
||||||
|
open build/bin/web-tuner.app
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
echo ""
|
||||||
|
echo "=== Tailing ~/web-tuner/debug.log (Ctrl-C to stop) ==="
|
||||||
|
tail -f ~/web-tuner/debug.log 2>/dev/null || echo "Waiting for log file..."
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DensityChord struct {
|
||||||
|
Chord string `json:"chord"`
|
||||||
|
Root string `json:"root"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Frets []int `json:"frets"`
|
||||||
|
Fingers int `json:"fingers"`
|
||||||
|
Notes []string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DensityCandidate struct {
|
||||||
|
Tuning []string `json:"tuning"`
|
||||||
|
TuningMIDI []int `json:"tuning_midi"`
|
||||||
|
Root string `json:"root"`
|
||||||
|
Chord string `json:"chord"`
|
||||||
|
Chords []DensityChord `json:"chords"`
|
||||||
|
ValidChords int `json:"valid_chords"`
|
||||||
|
MajMinCount int `json:"maj_min_count"`
|
||||||
|
HighCompat bool `json:"high_compat"`
|
||||||
|
AvgFingers float64 `json:"avg_fingers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDensityTunings(query ShapeQuery, maxFret, maxFingers int) ([]DensityCandidate, error) {
|
||||||
|
tunings, err := findCandidateTunings(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nStrings := len(query.Shape.Frets)
|
||||||
|
results := make([]DensityCandidate, len(tunings))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for idx, ct := range tunings {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, t candidateTuning) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
chords := scoreTuningDensity(t.tuningMIDI, maxFret, maxFingers)
|
||||||
|
|
||||||
|
tuningNames := make([]string, nStrings)
|
||||||
|
for s := 0; s < nStrings; s++ {
|
||||||
|
tuningNames[s] = midiToNoteName(t.tuningMIDI[s])
|
||||||
|
}
|
||||||
|
|
||||||
|
majMin := 0
|
||||||
|
totalFingers := 0
|
||||||
|
for _, c := range chords {
|
||||||
|
if c.Quality == "major" || c.Quality == "minor" {
|
||||||
|
majMin++
|
||||||
|
}
|
||||||
|
totalFingers += c.Fingers
|
||||||
|
}
|
||||||
|
|
||||||
|
avg := 0.0
|
||||||
|
if len(chords) > 0 {
|
||||||
|
avg = float64(totalFingers) / float64(len(chords))
|
||||||
|
}
|
||||||
|
|
||||||
|
results[i] = DensityCandidate{
|
||||||
|
Tuning: tuningNames,
|
||||||
|
TuningMIDI: t.tuningMIDI,
|
||||||
|
Root: SemitoneToNote[t.root],
|
||||||
|
Chord: SemitoneToNote[t.root] + " " + query.TargetQuality,
|
||||||
|
Chords: chords,
|
||||||
|
ValidChords: len(chords),
|
||||||
|
MajMinCount: majMin,
|
||||||
|
HighCompat: majMin > 5,
|
||||||
|
AvgFingers: avg,
|
||||||
|
}
|
||||||
|
}(idx, ct)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
if results[i].ValidChords != results[j].ValidChords {
|
||||||
|
return results[i].ValidChords > results[j].ValidChords
|
||||||
|
}
|
||||||
|
return results[i].AvgFingers < results[j].AvgFingers
|
||||||
|
})
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreTuningDensity(tuningMIDI []int, maxFret, maxFingers int) []DensityChord {
|
||||||
|
nStrings := len(tuningMIDI)
|
||||||
|
defs := GetChordDefinitions()
|
||||||
|
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type chordKey struct {
|
||||||
|
root int
|
||||||
|
quality string
|
||||||
|
}
|
||||||
|
best := make(map[chordKey]DensityChord)
|
||||||
|
|
||||||
|
optCount := maxFret + 2
|
||||||
|
totalCombinations := 1
|
||||||
|
for i := 0; i < nStrings; i++ {
|
||||||
|
totalCombinations *= optCount
|
||||||
|
}
|
||||||
|
|
||||||
|
fingering := make([]string, nStrings)
|
||||||
|
for combo := 0; combo < totalCombinations; combo++ {
|
||||||
|
tmp := combo
|
||||||
|
for s := nStrings - 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
|
||||||
|
}
|
||||||
|
fingers := countEffectiveFingers(fingering, nStrings)
|
||||||
|
if fingers > maxFingers {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var soundedPCs []int
|
||||||
|
frets := make([]int, nStrings)
|
||||||
|
notes := make([]string, nStrings)
|
||||||
|
for s := 0; s < nStrings; s++ {
|
||||||
|
if fingering[s] == "x" {
|
||||||
|
frets[s] = -1
|
||||||
|
notes[s] = "x"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fn := atoi(fingering[s])
|
||||||
|
frets[s] = fn
|
||||||
|
midi := tuningMIDI[s] + fn
|
||||||
|
pc := midi % 12
|
||||||
|
soundedPCs = append(soundedPCs, pc)
|
||||||
|
notes[s] = midiToNoteName(midi)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(soundedPCs) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
uniquePCs := uniqueInts(soundedPCs)
|
||||||
|
|
||||||
|
// try each unique PC as root, match against chord dictionary
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick simplest: fewest intervals, bass preference
|
||||||
|
pick := candidates[0]
|
||||||
|
for _, c := range candidates[1:] {
|
||||||
|
if c.size < pick.size || (c.size == pick.size && c.bassIdx < pick.bassIdx) {
|
||||||
|
pick = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ck := chordKey{pick.root, pick.quality}
|
||||||
|
existing, exists := best[ck]
|
||||||
|
if !exists || fingers < existing.Fingers {
|
||||||
|
fretsCopy := make([]int, nStrings)
|
||||||
|
copy(fretsCopy, frets)
|
||||||
|
notesCopy := make([]string, nStrings)
|
||||||
|
copy(notesCopy, notes)
|
||||||
|
|
||||||
|
best[ck] = DensityChord{
|
||||||
|
Chord: SemitoneToNote[pick.root] + " " + pick.quality,
|
||||||
|
Root: SemitoneToNote[pick.root],
|
||||||
|
Quality: pick.quality,
|
||||||
|
Frets: fretsCopy,
|
||||||
|
Fingers: fingers,
|
||||||
|
Notes: notesCopy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]DensityChord, 0, len(best))
|
||||||
|
for _, dc := range best {
|
||||||
|
result = append(result, dc)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
ri := qualityRank(result[i].Quality)
|
||||||
|
rj := qualityRank(result[j].Quality)
|
||||||
|
if ri != rj {
|
||||||
|
return ri < rj
|
||||||
|
}
|
||||||
|
return result[i].Fingers < result[j].Fingers
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func qualityRank(q string) int {
|
||||||
|
switch q {
|
||||||
|
case "major":
|
||||||
|
return 0
|
||||||
|
case "minor":
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -249,7 +249,7 @@ func findChordFingerings(cfg Config) []ChordResult {
|
||||||
if seen[k] {
|
if seen[k] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if countFingers(alt) > maxFingers {
|
if countEffectiveFingers(alt, len(alt)) > maxFingers {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[k] = true
|
seen[k] = true
|
||||||
|
|
@ -279,81 +279,35 @@ func isValidMuteConfig(fingering []string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func countEffectiveFingers(fingering []string, numStrings int) int {
|
func countEffectiveFingers(fingering []string, numStrings int) int {
|
||||||
type fretInfo struct {
|
fretStrings := make(map[int][]int)
|
||||||
fret int
|
|
||||||
strings []int
|
|
||||||
}
|
|
||||||
|
|
||||||
frets := make(map[int][]int)
|
|
||||||
for i, f := range fingering {
|
for i, f := range fingering {
|
||||||
if f == "x" || f == "0" {
|
if f == "x" || f == "0" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fv := atoi(f)
|
fv := atoi(f)
|
||||||
frets[fv] = append(frets[fv], i)
|
fretStrings[fv] = append(fretStrings[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
|
count := 0
|
||||||
for k := range used {
|
for fret, strings := range fretStrings {
|
||||||
if k.barre {
|
if len(strings) == 1 {
|
||||||
count += 2
|
|
||||||
} else {
|
|
||||||
count++
|
count++
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
groups := 1
|
||||||
|
for i := 1; i < len(strings); i++ {
|
||||||
|
consecutive := true
|
||||||
|
for s := strings[i-1] + 1; s < strings[i]; s++ {
|
||||||
|
if fingering[s] == "x" || atoi(fingering[s]) < fret {
|
||||||
|
consecutive = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !consecutive {
|
||||||
|
groups++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count += groups
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
@ -439,7 +393,7 @@ func generateMutedVariations(primary []string, tuning []string, intervals map[in
|
||||||
if !isValidMuteConfig(test) {
|
if !isValidMuteConfig(test) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if countFingers(test) > maxFingers {
|
if countEffectiveFingers(test, len(test)) > maxFingers {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if isSameChord(test, tuning, intervals) {
|
if isSameChord(test, tuning, intervals) {
|
||||||
|
|
@ -486,16 +440,6 @@ func isOpenChord(fingering []string) bool {
|
||||||
return true
|
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 {
|
func fingeringKey(f []string) string {
|
||||||
return strings.Join(f, ",")
|
return strings.Join(f, ",")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,13 @@
|
||||||
|
let _dbgEnabled = false;
|
||||||
|
|
||||||
|
function dbg(...args) {
|
||||||
|
if (!_dbgEnabled) return;
|
||||||
|
const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
||||||
|
try { window.go.main.App.JSDebugLog(msg); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dbg = dbg;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const navLinks = document.querySelectorAll('.nav-link');
|
const navLinks = document.querySelectorAll('.nav-link');
|
||||||
const views = document.querySelectorAll('.view');
|
const views = document.querySelectorAll('.view');
|
||||||
|
|
@ -12,6 +22,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const tuningGrid = document.getElementById('tuning-grid');
|
const tuningGrid = document.getElementById('tuning-grid');
|
||||||
const fretsInput = document.getElementById('frets-input');
|
const fretsInput = document.getElementById('frets-input');
|
||||||
const fingersInput = document.getElementById('fingers-input');
|
const fingersInput = document.getElementById('fingers-input');
|
||||||
|
const baselineShiftInput = document.getElementById('baseline-shift');
|
||||||
|
const rangeDownInput = document.getElementById('range-down');
|
||||||
|
const rangeUpInput = document.getElementById('range-up');
|
||||||
const loading = document.getElementById('chord-loading');
|
const loading = document.getElementById('chord-loading');
|
||||||
|
|
||||||
const allNotes = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
|
const allNotes = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
|
||||||
|
|
@ -35,21 +48,149 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
window.currentTuningMIDI = standardMIDI.slice();
|
window.currentTuningMIDI = standardMIDI.slice();
|
||||||
|
|
||||||
let polySynth = null;
|
let polySynth = null;
|
||||||
window.playChord = function(midiNotes) {
|
let audioFilter = null;
|
||||||
Tone.start();
|
let audioGain = null;
|
||||||
if (!polySynth) {
|
let audioProfile = 15;
|
||||||
polySynth = new Tone.PolySynth(Tone.Synth, {maxPolyphony: 8}).toDestination();
|
|
||||||
polySynth.set({
|
const volSlider = document.getElementById('audio-vol');
|
||||||
oscillator: {type: 'triangle'},
|
const profileSlider = document.getElementById('audio-profile');
|
||||||
envelope: {attack: 0.01, decay: 0.3, sustain: 0.4, release: 1.0}
|
const strumSlider = document.getElementById('audio-strum');
|
||||||
|
const engineSelect = document.getElementById('audio-engine');
|
||||||
|
|
||||||
|
// --- PluckSynth engine (Karplus-Strong) ---
|
||||||
|
let pluckSynths = null;
|
||||||
|
let pluckFilter = null;
|
||||||
|
let pluckGain = null;
|
||||||
|
|
||||||
|
function buildPluck() {
|
||||||
|
if (!pluckFilter) {
|
||||||
|
pluckFilter = new Tone.Filter({ type: 'lowpass', rolloff: -12 }).toDestination();
|
||||||
|
pluckGain = new Tone.Gain(volSlider.value / 100).connect(pluckFilter);
|
||||||
|
}
|
||||||
|
if (!pluckSynths) {
|
||||||
|
pluckSynths = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
pluckSynths.push(new Tone.PluckSynth().connect(pluckGain));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const t = audioProfile / 100;
|
||||||
|
pluckFilter.frequency.value = 1000 + t * 6000;
|
||||||
|
pluckFilter.Q.value = 1.0;
|
||||||
|
pluckSynths.forEach(ps => {
|
||||||
|
ps.set({
|
||||||
|
attackNoise: 1,
|
||||||
|
dampening: 1000 + t * 6000,
|
||||||
|
resonance: 0.9 + t * 0.09
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- WebAudioFont engine ---
|
||||||
|
let wafPlayer = null;
|
||||||
|
let wafContext = null;
|
||||||
|
let wafGain = null;
|
||||||
|
let wafFilter = null;
|
||||||
|
let guitarPreset = null;
|
||||||
|
|
||||||
|
function initWAF() {
|
||||||
|
if (wafPlayer) return;
|
||||||
|
wafContext = Tone.context.rawContext;
|
||||||
|
wafGain = wafContext.createGain();
|
||||||
|
wafGain.gain.value = volSlider.value / 100;
|
||||||
|
wafFilter = wafContext.createBiquadFilter();
|
||||||
|
wafFilter.type = 'lowpass';
|
||||||
|
wafFilter.frequency.value = 5000;
|
||||||
|
wafGain.connect(wafFilter);
|
||||||
|
wafFilter.connect(wafContext.destination);
|
||||||
|
wafPlayer = new WebAudioFontPlayer();
|
||||||
|
guitarPreset = _tone_0250_SoundBlasterOld_sf2;
|
||||||
|
wafPlayer.adjustPreset(wafContext, guitarPreset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWAFTone() {
|
||||||
|
if (!wafFilter) return;
|
||||||
|
const t = audioProfile / 100;
|
||||||
|
wafFilter.frequency.value = 800 + t * 8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Synth engine (existing PolySynth) ---
|
||||||
|
function profilePartials(t) {
|
||||||
|
const p = [];
|
||||||
|
for (let n = 1; n <= 16; n++) {
|
||||||
|
const tri = (n % 2 === 1) ? 1 / (n * n) : 0;
|
||||||
|
const saw = 1 / n;
|
||||||
|
p.push(tri * (1 - t) + saw * t);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSynth() {
|
||||||
|
if (!audioFilter) {
|
||||||
|
audioFilter = new Tone.Filter({ type: 'lowpass', rolloff: -12 }).toDestination();
|
||||||
|
audioGain = new Tone.Gain(volSlider.value / 100).connect(audioFilter);
|
||||||
|
}
|
||||||
|
if (!polySynth) {
|
||||||
|
polySynth = new Tone.PolySynth(Tone.Synth, { maxPolyphony: 8 }).connect(audioGain);
|
||||||
|
}
|
||||||
polySynth.releaseAll();
|
polySynth.releaseAll();
|
||||||
|
const t = audioProfile / 100;
|
||||||
|
audioFilter.frequency.value = 600 + t * 9400;
|
||||||
|
audioFilter.Q.value = 1.5 - t * 1.0;
|
||||||
|
polySynth.set({
|
||||||
|
oscillator: { type: 'custom', partials: profilePartials(t) },
|
||||||
|
envelope: {
|
||||||
|
attack: 0.045 - t * 0.04,
|
||||||
|
decay: 0.5 - t * 0.3,
|
||||||
|
sustain: 0.25 + t * 0.25,
|
||||||
|
release: 1.5 - t * 0.7
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Volume slider: update all engines ---
|
||||||
|
volSlider.addEventListener('input', () => {
|
||||||
|
const v = volSlider.value / 100;
|
||||||
|
if (audioGain) audioGain.gain.value = v;
|
||||||
|
if (pluckGain) pluckGain.gain.value = v;
|
||||||
|
if (wafGain) wafGain.gain.value = v;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Tone slider: update active engine ---
|
||||||
|
profileSlider.addEventListener('input', () => {
|
||||||
|
audioProfile = parseInt(profileSlider.value);
|
||||||
|
const engine = engineSelect.value;
|
||||||
|
if (engine === 'pluck') buildPluck();
|
||||||
|
else if (engine === 'sample') applyWAFTone();
|
||||||
|
else buildSynth();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- playChord: multi-engine with strum ---
|
||||||
|
window.playChord = function(midiNotes) {
|
||||||
|
Tone.start();
|
||||||
|
const strum = parseInt(strumSlider.value) / 1000;
|
||||||
|
const engine = engineSelect.value;
|
||||||
const now = Tone.now();
|
const now = Tone.now();
|
||||||
|
|
||||||
|
if (engine === 'pluck') {
|
||||||
|
if (!pluckSynths) buildPluck();
|
||||||
midiNotes.forEach((m, i) => {
|
midiNotes.forEach((m, i) => {
|
||||||
const freq = 440 * Math.pow(2, (m - 69) / 12);
|
const freq = 440 * Math.pow(2, (m - 69) / 12);
|
||||||
polySynth.triggerAttackRelease(freq, '1.5s', now + i * 0.03);
|
pluckSynths[i % pluckSynths.length].triggerAttack(freq, now + i * strum);
|
||||||
});
|
});
|
||||||
|
} else if (engine === 'sample') {
|
||||||
|
initWAF();
|
||||||
|
midiNotes.forEach((m, i) => {
|
||||||
|
wafPlayer.queueWaveTable(wafContext, wafGain, guitarPreset,
|
||||||
|
wafContext.currentTime + i * strum, m, 2.0);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!polySynth) buildSynth();
|
||||||
|
polySynth.releaseAll();
|
||||||
|
midiNotes.forEach((m, i) => {
|
||||||
|
const freq = 440 * Math.pow(2, (m - 69) / 12);
|
||||||
|
polySynth.triggerAttackRelease(freq, '1.5s', now + i * strum);
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const presets = {
|
const presets = {
|
||||||
|
|
@ -69,17 +210,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
let currentConfig = null;
|
let currentConfig = null;
|
||||||
let chordsLoaded = false;
|
let chordsLoaded = false;
|
||||||
let shapesInited = false;
|
let shapesInited = false;
|
||||||
|
let lastBaselineShift = 0;
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
navLinks.forEach(link => {
|
window.switchToView = function(target) {
|
||||||
link.addEventListener('click', () => {
|
|
||||||
const target = link.dataset.view;
|
|
||||||
navLinks.forEach(l => l.classList.remove('active'));
|
navLinks.forEach(l => l.classList.remove('active'));
|
||||||
views.forEach(v => v.classList.remove('active'));
|
views.forEach(v => v.classList.remove('active'));
|
||||||
link.classList.add('active');
|
const navLink = document.querySelector('.nav-link[data-view="' + target + '"]');
|
||||||
|
if (navLink) navLink.classList.add('active');
|
||||||
document.getElementById('view-' + target).classList.add('active');
|
document.getElementById('view-' + target).classList.add('active');
|
||||||
filterSection.style.display = target === 'chords' ? '' : 'none';
|
filterSection.style.display = target === 'chords' ? '' : 'none';
|
||||||
shapesSection.style.display = target === 'shapes' ? '' : 'none';
|
shapesSection.style.display = (target === 'shapes') ? '' : 'none';
|
||||||
|
|
||||||
if (target === 'chords' && !chordsLoaded) {
|
if (target === 'chords' && !chordsLoaded) {
|
||||||
loadChords();
|
loadChords();
|
||||||
|
|
@ -88,6 +229,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
shapesInited = true;
|
shapesInited = true;
|
||||||
if (window.initShapeExplorer) window.initShapeExplorer();
|
if (window.initShapeExplorer) window.initShapeExplorer();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
navLinks.forEach(link => {
|
||||||
|
link.addEventListener('click', () => {
|
||||||
|
window.switchToView(link.dataset.view);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -135,6 +281,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
function syncConfigPanel(cfg) {
|
function syncConfigPanel(cfg) {
|
||||||
fretsInput.value = cfg.frets;
|
fretsInput.value = cfg.frets;
|
||||||
fingersInput.value = cfg.max_fingers;
|
fingersInput.value = cfg.max_fingers;
|
||||||
|
lastBaselineShift = cfg.baseline_shift || 0;
|
||||||
|
baselineShiftInput.value = lastBaselineShift;
|
||||||
|
rangeDownInput.value = cfg.range_down || 7;
|
||||||
|
rangeUpInput.value = cfg.range_up || 7;
|
||||||
buildTuningGrid(cfg.tuning);
|
buildTuningGrid(cfg.tuning);
|
||||||
|
|
||||||
let matched = false;
|
let matched = false;
|
||||||
|
|
@ -156,14 +306,35 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
btnApply.addEventListener('click', () => {
|
baselineShiftInput.addEventListener('input', () => {
|
||||||
if (!window.go) return;
|
const newShift = parseInt(baselineShiftInput.value) || 0;
|
||||||
const cfg = {
|
const delta = newShift - lastBaselineShift;
|
||||||
|
if (delta === 0) return;
|
||||||
|
lastBaselineShift = newShift;
|
||||||
|
const selects = tuningGrid.querySelectorAll('select');
|
||||||
|
selects.forEach(sel => {
|
||||||
|
const pc = allNotes.indexOf(sel.value);
|
||||||
|
if (pc < 0) return;
|
||||||
|
sel.value = allNotes[((pc + delta) % 12 + 12) % 12];
|
||||||
|
});
|
||||||
|
presetSelect.value = 'Custom';
|
||||||
|
});
|
||||||
|
|
||||||
|
function readConfigFromPanel() {
|
||||||
|
return {
|
||||||
instrument: currentConfig ? currentConfig.instrument : 'guitar',
|
instrument: currentConfig ? currentConfig.instrument : 'guitar',
|
||||||
tuning: readTuningFromGrid(),
|
tuning: readTuningFromGrid(),
|
||||||
frets: parseInt(fretsInput.value) || 4,
|
frets: parseInt(fretsInput.value) || 4,
|
||||||
max_fingers: parseInt(fingersInput.value) || 4
|
max_fingers: parseInt(fingersInput.value) || 4,
|
||||||
|
baseline_shift: parseInt(baselineShiftInput.value) || 0,
|
||||||
|
range_down: parseInt(rangeDownInput.value) || 0,
|
||||||
|
range_up: parseInt(rangeUpInput.value) || 0
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
btnApply.addEventListener('click', () => {
|
||||||
|
if (!window.go) return;
|
||||||
|
const cfg = readConfigFromPanel();
|
||||||
loading.style.display = '';
|
loading.style.display = '';
|
||||||
loading.textContent = 'Regenerating chords...';
|
loading.textContent = 'Regenerating chords...';
|
||||||
|
|
||||||
|
|
@ -182,7 +353,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
btnSave.addEventListener('click', () => {
|
btnSave.addEventListener('click', () => {
|
||||||
if (!window.go) return;
|
if (!window.go) return;
|
||||||
window.go.main.App.SaveConfig().then(() => {
|
const cfg = readConfigFromPanel();
|
||||||
|
window.go.main.App.UpdateConfig(cfg).then(chords => {
|
||||||
|
currentConfig = cfg;
|
||||||
|
window.currentTuningMIDI = tuningNamesToMIDI(cfg.tuning);
|
||||||
|
if (chordsLoaded && window.buildChordCards) {
|
||||||
|
window.buildChordCards(chords || [], cfg.frets, cfg.tuning.length);
|
||||||
|
}
|
||||||
|
return window.go.main.App.SaveConfig();
|
||||||
|
}).then(() => {
|
||||||
btnSave.textContent = 'Saved';
|
btnSave.textContent = 'Saved';
|
||||||
setTimeout(() => { btnSave.textContent = 'Save'; }, 1500);
|
setTimeout(() => { btnSave.textContent = 'Save'; }, 1500);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
|
|
@ -201,6 +380,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.setTuningFromExplorer = function(noteNames) {
|
||||||
|
const pcs = noteNames.map(n => n.replace(/[-]?\d+$/, ''));
|
||||||
|
buildTuningGrid(pcs);
|
||||||
|
presetSelect.value = 'Custom';
|
||||||
|
btnApply.click();
|
||||||
|
};
|
||||||
|
|
||||||
// --- Load Chords ---
|
// --- Load Chords ---
|
||||||
function loadChords() {
|
function loadChords() {
|
||||||
if (!window.go) {
|
if (!window.go) {
|
||||||
|
|
@ -223,6 +409,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
populatePresets();
|
populatePresets();
|
||||||
|
|
||||||
if (window.go && window.go.main && window.go.main.App) {
|
if (window.go && window.go.main && window.go.main.App) {
|
||||||
|
window.go.main.App.IsDebug().then(d => {
|
||||||
|
if (d) {
|
||||||
|
_dbgEnabled = true;
|
||||||
|
window.DEBUG = true;
|
||||||
|
dbg('=== Frontend debug logging active ===');
|
||||||
|
}
|
||||||
|
});
|
||||||
window.go.main.App.GetConfig().then(cfg => {
|
window.go.main.App.GetConfig().then(cfg => {
|
||||||
currentConfig = cfg;
|
currentConfig = cfg;
|
||||||
window.currentTuningMIDI = tuningNamesToMIDI(cfg.tuning);
|
window.currentTuningMIDI = tuningNamesToMIDI(cfg.tuning);
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@
|
||||||
|
|
||||||
.fret {
|
.fret {
|
||||||
border: .5px solid var(--border, #444746);
|
border: .5px solid var(--border, #444746);
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px;
|
||||||
border-right: 2px;
|
border-right: 2px solid transparent;
|
||||||
background: var(--bg-overlay, #282a2c);
|
background: var(--bg-overlay, #282a2c);
|
||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@
|
||||||
|
|
||||||
.fret {
|
.fret {
|
||||||
border: .5px solid #0002;
|
border: .5px solid #0002;
|
||||||
border-left: 2px solid #fff;
|
border-left: 2px solid #0001;
|
||||||
border-right: 2px solid #0001;
|
border-right: 2px solid #fff;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,19 @@
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Web Tuner</h1>
|
<h1>Web Tuner</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<div class="audio-sliders">
|
||||||
|
<label for="audio-vol">Vol</label>
|
||||||
|
<input type="range" id="audio-vol" min="0" max="100" value="70">
|
||||||
|
<label for="audio-profile">Tone</label>
|
||||||
|
<input type="range" id="audio-profile" min="0" max="100" value="15">
|
||||||
|
<label for="audio-strum">Strum</label>
|
||||||
|
<input type="range" id="audio-strum" min="0" max="150" value="60">
|
||||||
|
<select id="audio-engine">
|
||||||
|
<option value="pluck">Pluck</option>
|
||||||
|
<option value="sample">Sample</option>
|
||||||
|
<option value="synth">Synth</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button id="btn-export">Export PDF</button>
|
<button id="btn-export">Export PDF</button>
|
||||||
<select id="theme-select">
|
<select id="theme-select">
|
||||||
<option value="light">Light</option>
|
<option value="light">Light</option>
|
||||||
|
|
@ -41,7 +54,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section shapes-section" id="shapes-section" style="display:none;">
|
<div class="sidebar-section shapes-section" id="shapes-section" style="display:none;">
|
||||||
<h3>Shapes</h3>
|
<h3>Score Set</h3>
|
||||||
|
<div class="set-selector-row">
|
||||||
|
<select id="score-set-select"></select>
|
||||||
|
</div>
|
||||||
<div id="shape-list" class="shape-list"></div>
|
<div id="shape-list" class="shape-list"></div>
|
||||||
<div class="shape-editor-actions">
|
<div class="shape-editor-actions">
|
||||||
<button id="btn-add-shape" class="btn-small">Add Shape</button>
|
<button id="btn-add-shape" class="btn-small">Add Shape</button>
|
||||||
|
|
@ -90,6 +106,20 @@
|
||||||
<label for="fingers-input">Max Fingers</label>
|
<label for="fingers-input">Max Fingers</label>
|
||||||
<input type="number" id="fingers-input" min="1" max="6" value="4">
|
<input type="number" id="fingers-input" min="1" max="6" value="4">
|
||||||
|
|
||||||
|
<h3 style="margin-top:0.75rem;">Tuning Range</h3>
|
||||||
|
<label for="baseline-shift">Baseline Shift (semitones)</label>
|
||||||
|
<input type="number" id="baseline-shift" value="0" min="-24" max="24">
|
||||||
|
<div style="display:flex; gap:0.5rem;">
|
||||||
|
<div style="flex:1">
|
||||||
|
<label for="range-down">Range Down</label>
|
||||||
|
<input type="number" id="range-down" value="7" min="0" max="24">
|
||||||
|
</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<label for="range-up">Range Up</label>
|
||||||
|
<input type="number" id="range-up" value="7" min="0" max="24">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="config-actions">
|
<div class="config-actions">
|
||||||
<button class="btn-apply" id="btn-apply">Apply</button>
|
<button class="btn-apply" id="btn-apply">Apply</button>
|
||||||
<button id="btn-save">Save</button>
|
<button id="btn-save">Save</button>
|
||||||
|
|
@ -147,13 +177,29 @@
|
||||||
<div id="shapes-results"></div>
|
<div id="shapes-results"></div>
|
||||||
<div id="shapes-loading" class="loading" style="display:none;"></div>
|
<div id="shapes-loading" class="loading" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="view-sets-editor" class="view">
|
||||||
|
<div class="sets-editor">
|
||||||
|
<div class="sets-editor-header">
|
||||||
|
<button id="btn-back-explorer" class="btn-small">Back to Explorer</button>
|
||||||
|
<h2>Score Sets Editor</h2>
|
||||||
|
</div>
|
||||||
|
<div class="sets-editor-body">
|
||||||
|
<div class="sets-editor-list" id="sets-editor-list"></div>
|
||||||
|
<div class="sets-editor-detail" id="sets-editor-detail"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="vendor/tone.min.js"></script>
|
<script src="vendor/tone.min.js"></script>
|
||||||
|
<script src="vendor/WebAudioFontPlayer.js"></script>
|
||||||
|
<script src="vendor/guitar_sample.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
<script src="tuner.js"></script>
|
<script src="tuner.js"></script>
|
||||||
<script src="chords.js"></script>
|
<script src="chords.js"></script>
|
||||||
<script src="shapes.js"></script>
|
<script src="shapes.js"></script>
|
||||||
|
<script src="sets-editor.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,48 @@ body {
|
||||||
--wails-draggable: no-drag;
|
--wails-draggable: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audio-sliders {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-sliders label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-sliders input[type="range"] {
|
||||||
|
width: 4rem;
|
||||||
|
height: 0.25rem;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-sliders input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-sliders input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-sliders select {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
padding: 0.25rem 1.25rem 0.25rem 0.375rem;
|
||||||
|
background-position: right 0.375rem center;
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions select,
|
.header-actions select,
|
||||||
.header-actions button {
|
.header-actions button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
|
@ -441,6 +483,22 @@ body {
|
||||||
border-color: var(--text-secondary);
|
border-color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.set-selector-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-selector-row select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-selector-row .btn-small {
|
||||||
|
flex: 0;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.shape-list {
|
.shape-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -704,6 +762,25 @@ body {
|
||||||
color: var(--text-subtle);
|
color: var(--text-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.density-explanation {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finger-count {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.0625rem 0.3rem;
|
||||||
|
margin-left: 0.375rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-load-more {
|
.btn-load-more {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -751,6 +828,318 @@ body {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Sets Editor --- */
|
||||||
|
.sets-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sets-editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sets-editor-header h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sets-editor-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sets-editor-list {
|
||||||
|
width: 14rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sets-editor-detail {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-set-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-set-item:hover {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-set-item.selected {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: rgba(138, 180, 248, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-set-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-set-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-badge {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-badge-density {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-btn-new-set {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-detail-header h3 {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-mini-fb {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-name {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-name.se-auto-name {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-frets {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-actions .btn-small {
|
||||||
|
flex: 0;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-btn-danger {
|
||||||
|
border-color: #c0392b !important;
|
||||||
|
color: #e74c3c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-btn-danger:hover {
|
||||||
|
background: rgba(231, 76, 60, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-btn-add-shape {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* shape editor */
|
||||||
|
.se-editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-editor-header h3 {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-editor-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-editor-name-row label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-editor-name-input {
|
||||||
|
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;
|
||||||
|
width: 14rem;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-editor-name-input:focus {
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-auto-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* clickable fretboard grid */
|
||||||
|
.se-clickable-fb {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fb-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fb-header-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fb-fret-num {
|
||||||
|
width: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fb-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fb-label {
|
||||||
|
width: 4.5rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fb-cell {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background var(--transition);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fb-cell:hover {
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fb-nut {
|
||||||
|
border-right: 3px solid var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fb-cell.se-dot-active::after {
|
||||||
|
content: '';
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fb-cell.se-muted {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fb-cell.se-open {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Print --- */
|
/* --- Print --- */
|
||||||
@media print {
|
@media print {
|
||||||
.header, .sidebar {
|
.header, .sidebar {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,403 @@
|
||||||
|
let _seListEl = document.getElementById('sets-editor-list');
|
||||||
|
let _seDetailEl = document.getElementById('sets-editor-detail');
|
||||||
|
let _seSelectedIdx = 0;
|
||||||
|
let _seEditingIdx = -1;
|
||||||
|
let _seFrets = [];
|
||||||
|
let _seNameInput = null;
|
||||||
|
let _seAutoLabel = null;
|
||||||
|
|
||||||
|
function _seDbg() {
|
||||||
|
if (window.dbg) window.dbg.apply(null, ['[sets-editor]'].concat(Array.prototype.slice.call(arguments)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _seData() {
|
||||||
|
return window.getScoreSetsData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _seDensity(set) { return set && set.type === 'density'; }
|
||||||
|
|
||||||
|
document.getElementById('btn-back-explorer').addEventListener('click', function() {
|
||||||
|
_seEditingIdx = -1;
|
||||||
|
window.setScoreSetsData(_seData());
|
||||||
|
window.switchToView('shapes');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.initSetsEditor = function() {
|
||||||
|
_seDbg('init');
|
||||||
|
var data = _seData();
|
||||||
|
_seSelectedIdx = data.selected;
|
||||||
|
_seEditingIdx = -1;
|
||||||
|
seRenderList();
|
||||||
|
seRenderDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
function seSelectSet(i) {
|
||||||
|
_seDbg('selectSet', i);
|
||||||
|
_seSelectedIdx = i;
|
||||||
|
_seEditingIdx = -1;
|
||||||
|
seRenderList();
|
||||||
|
seRenderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seNewSet() {
|
||||||
|
_seDbg('newSet');
|
||||||
|
var name = prompt('New score set name:');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
var data = _seData();
|
||||||
|
data.sets.push({ name: name.trim(), type: '', shapes: [] });
|
||||||
|
_seSelectedIdx = data.sets.length - 1;
|
||||||
|
_seEditingIdx = -1;
|
||||||
|
window.persistSets();
|
||||||
|
seRenderList();
|
||||||
|
seRenderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seRenderList() {
|
||||||
|
_seListEl.innerHTML = '';
|
||||||
|
var data = _seData();
|
||||||
|
data.sets.forEach(function(set, i) {
|
||||||
|
var item = document.createElement('div');
|
||||||
|
item.className = 'se-set-item' + (i === _seSelectedIdx ? ' selected' : '');
|
||||||
|
var nm = document.createElement('span');
|
||||||
|
nm.className = 'se-set-name';
|
||||||
|
nm.textContent = set.name;
|
||||||
|
var badges = document.createElement('span');
|
||||||
|
badges.className = 'se-set-badges';
|
||||||
|
var ct = document.createElement('span');
|
||||||
|
ct.className = 'se-badge';
|
||||||
|
ct.textContent = set.shapes.length;
|
||||||
|
badges.appendChild(ct);
|
||||||
|
if (_seDensity(set)) {
|
||||||
|
var db = document.createElement('span');
|
||||||
|
db.className = 'se-badge se-badge-density';
|
||||||
|
db.textContent = 'density';
|
||||||
|
badges.appendChild(db);
|
||||||
|
}
|
||||||
|
item.appendChild(nm);
|
||||||
|
item.appendChild(badges);
|
||||||
|
item.setAttribute('onclick', 'seSelectSet(' + i + ')');
|
||||||
|
_seListEl.appendChild(item);
|
||||||
|
});
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.className = 'btn-small se-btn-new-set';
|
||||||
|
btn.textContent = 'New Set';
|
||||||
|
btn.setAttribute('onclick', 'seNewSet()');
|
||||||
|
_seListEl.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seRenameSet() {
|
||||||
|
_seDbg('rename');
|
||||||
|
var set = _seData().sets[_seSelectedIdx];
|
||||||
|
if (!set) return;
|
||||||
|
var name = prompt('Rename set:', set.name);
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
set.name = name.trim();
|
||||||
|
window.persistSets();
|
||||||
|
seRenderList();
|
||||||
|
seRenderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seDeleteSet() {
|
||||||
|
_seDbg('deleteSet');
|
||||||
|
var data = _seData();
|
||||||
|
var set = data.sets[_seSelectedIdx];
|
||||||
|
if (!set || !confirm('Delete "' + set.name + '"?')) return;
|
||||||
|
data.sets.splice(_seSelectedIdx, 1);
|
||||||
|
if (_seSelectedIdx >= data.sets.length) _seSelectedIdx = data.sets.length - 1;
|
||||||
|
data.selected = _seSelectedIdx;
|
||||||
|
window.persistSets();
|
||||||
|
seRenderList();
|
||||||
|
seRenderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seMoveShape(i, dir) {
|
||||||
|
_seDbg('move', i, dir);
|
||||||
|
var set = _seData().sets[_seSelectedIdx];
|
||||||
|
if (!set) return;
|
||||||
|
var j = i + dir;
|
||||||
|
if (j < 0 || j >= set.shapes.length) return;
|
||||||
|
var tmp = set.shapes[i];
|
||||||
|
set.shapes[i] = set.shapes[j];
|
||||||
|
set.shapes[j] = tmp;
|
||||||
|
window.persistSets();
|
||||||
|
seRenderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seEditShape(i) {
|
||||||
|
_seDbg('edit', i);
|
||||||
|
_seEditingIdx = i;
|
||||||
|
seRenderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seDupShape(i) {
|
||||||
|
_seDbg('dup', i);
|
||||||
|
var set = _seData().sets[_seSelectedIdx];
|
||||||
|
if (!set) return;
|
||||||
|
var orig = set.shapes[i];
|
||||||
|
set.shapes.splice(i + 1, 0, { name: '', frets: orig.frets.slice() });
|
||||||
|
window.persistSets();
|
||||||
|
seRenderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seDelShape(i) {
|
||||||
|
_seDbg('del', i);
|
||||||
|
var set = _seData().sets[_seSelectedIdx];
|
||||||
|
if (!set) return;
|
||||||
|
set.shapes.splice(i, 1);
|
||||||
|
window.persistSets();
|
||||||
|
seRenderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seAddShape() {
|
||||||
|
_seDbg('add');
|
||||||
|
var set = _seData().sets[_seSelectedIdx];
|
||||||
|
if (!set) return;
|
||||||
|
set.shapes.push({ name: '', frets: [0, 0, 0, 0, 0, 0] });
|
||||||
|
_seEditingIdx = set.shapes.length - 1;
|
||||||
|
window.persistSets();
|
||||||
|
seRenderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seRenderDetail() {
|
||||||
|
_seDetailEl.innerHTML = '';
|
||||||
|
var data = _seData();
|
||||||
|
var set = data.sets[_seSelectedIdx];
|
||||||
|
if (!set) return;
|
||||||
|
|
||||||
|
if (_seEditingIdx >= 0) {
|
||||||
|
seRenderShapeEditor(set);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var density = _seDensity(set);
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.className = 'se-detail-header';
|
||||||
|
var h3 = document.createElement('h3');
|
||||||
|
h3.textContent = set.name;
|
||||||
|
header.appendChild(h3);
|
||||||
|
|
||||||
|
if (!density) {
|
||||||
|
var btnR = document.createElement('button');
|
||||||
|
btnR.className = 'btn-small';
|
||||||
|
btnR.textContent = 'Rename';
|
||||||
|
btnR.setAttribute('onclick', 'seRenameSet()');
|
||||||
|
header.appendChild(btnR);
|
||||||
|
if (data.sets.length > 1) {
|
||||||
|
var btnD = document.createElement('button');
|
||||||
|
btnD.className = 'btn-small se-btn-danger';
|
||||||
|
btnD.textContent = 'Delete Set';
|
||||||
|
btnD.setAttribute('onclick', 'seDeleteSet()');
|
||||||
|
header.appendChild(btnD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_seDetailEl.appendChild(header);
|
||||||
|
|
||||||
|
var listEl = document.createElement('div');
|
||||||
|
listEl.className = 'se-shape-list';
|
||||||
|
|
||||||
|
set.shapes.forEach(function(shape, i) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'se-shape-row';
|
||||||
|
|
||||||
|
var fb = document.createElement('div');
|
||||||
|
fb.className = 'fretboard alternative-fretboard se-mini-fb';
|
||||||
|
var fingering = shape.frets.map(function(f) { return f === -1 ? 'x' : String(f); });
|
||||||
|
var mx = Math.max.apply(null, shape.frets.filter(function(f) { return f >= 0; }).concat([1]));
|
||||||
|
window.renderSingleFretboard(fb, fingering, Math.max(mx, 4));
|
||||||
|
row.appendChild(fb);
|
||||||
|
|
||||||
|
var info = document.createElement('div');
|
||||||
|
info.className = 'se-shape-info';
|
||||||
|
var nameEl = document.createElement('span');
|
||||||
|
nameEl.className = 'se-shape-name';
|
||||||
|
if (shape.name) {
|
||||||
|
nameEl.textContent = shape.name;
|
||||||
|
} else {
|
||||||
|
nameEl.classList.add('se-auto-name');
|
||||||
|
nameEl.textContent = 'detecting...';
|
||||||
|
_seAutoDetect(shape.frets).then(function(n) { nameEl.textContent = n || '(unknown)'; });
|
||||||
|
}
|
||||||
|
info.appendChild(nameEl);
|
||||||
|
var fretsEl = document.createElement('span');
|
||||||
|
fretsEl.className = 'se-shape-frets';
|
||||||
|
fretsEl.textContent = shape.frets.map(function(f) { return f === -1 ? 'x' : f; }).join(' ');
|
||||||
|
info.appendChild(fretsEl);
|
||||||
|
row.appendChild(info);
|
||||||
|
|
||||||
|
if (!density) {
|
||||||
|
var acts = document.createElement('div');
|
||||||
|
acts.className = 'se-shape-actions';
|
||||||
|
if (i > 0) {
|
||||||
|
var up = document.createElement('button');
|
||||||
|
up.className = 'btn-small';
|
||||||
|
up.textContent = '\u25B2';
|
||||||
|
up.title = 'Move up';
|
||||||
|
up.setAttribute('onclick', 'seMoveShape(' + i + ',-1)');
|
||||||
|
acts.appendChild(up);
|
||||||
|
}
|
||||||
|
if (i < set.shapes.length - 1) {
|
||||||
|
var dn = document.createElement('button');
|
||||||
|
dn.className = 'btn-small';
|
||||||
|
dn.textContent = '\u25BC';
|
||||||
|
dn.title = 'Move down';
|
||||||
|
dn.setAttribute('onclick', 'seMoveShape(' + i + ',1)');
|
||||||
|
acts.appendChild(dn);
|
||||||
|
}
|
||||||
|
var ed = document.createElement('button');
|
||||||
|
ed.className = 'btn-small';
|
||||||
|
ed.textContent = 'edit';
|
||||||
|
ed.setAttribute('onclick', 'seEditShape(' + i + ')');
|
||||||
|
acts.appendChild(ed);
|
||||||
|
var dp = document.createElement('button');
|
||||||
|
dp.className = 'btn-small';
|
||||||
|
dp.textContent = 'dup';
|
||||||
|
dp.title = 'Duplicate';
|
||||||
|
dp.setAttribute('onclick', 'seDupShape(' + i + ')');
|
||||||
|
acts.appendChild(dp);
|
||||||
|
var dl = document.createElement('button');
|
||||||
|
dl.className = 'btn-small se-btn-danger';
|
||||||
|
dl.textContent = 'del';
|
||||||
|
dl.setAttribute('onclick', 'seDelShape(' + i + ')');
|
||||||
|
acts.appendChild(dl);
|
||||||
|
row.appendChild(acts);
|
||||||
|
}
|
||||||
|
listEl.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
_seDetailEl.appendChild(listEl);
|
||||||
|
|
||||||
|
if (!density) {
|
||||||
|
var btnA = document.createElement('button');
|
||||||
|
btnA.className = 'btn-small se-btn-add-shape';
|
||||||
|
btnA.textContent = 'Add Shape';
|
||||||
|
btnA.setAttribute('onclick', 'seAddShape()');
|
||||||
|
_seDetailEl.appendChild(btnA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seShapeDone() {
|
||||||
|
_seDbg('done');
|
||||||
|
var set = _seData().sets[_seSelectedIdx];
|
||||||
|
if (!set) return;
|
||||||
|
var shape = set.shapes[_seEditingIdx];
|
||||||
|
if (!shape) return;
|
||||||
|
shape.frets = _seFrets.slice();
|
||||||
|
shape.name = _seNameInput ? _seNameInput.value.trim() : '';
|
||||||
|
_seEditingIdx = -1;
|
||||||
|
window.persistSets();
|
||||||
|
seRenderList();
|
||||||
|
seRenderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seFbCell(s, f) {
|
||||||
|
var cur = _seFrets[s];
|
||||||
|
if (f === 0) {
|
||||||
|
_seFrets[s] = (cur === -1) ? 0 : -1;
|
||||||
|
} else {
|
||||||
|
_seFrets[s] = (cur === f) ? 0 : f;
|
||||||
|
}
|
||||||
|
_seBuildGrid();
|
||||||
|
_seUpdateAutoName();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _seBuildGrid() {
|
||||||
|
var wrap = document.getElementById('se-fb-wrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
var nf = 12;
|
||||||
|
var table = document.createElement('div');
|
||||||
|
table.className = 'se-fb-grid';
|
||||||
|
|
||||||
|
var hdr = document.createElement('div');
|
||||||
|
hdr.className = 'se-fb-header-row';
|
||||||
|
var lbl = document.createElement('div');
|
||||||
|
lbl.className = 'se-fb-label';
|
||||||
|
hdr.appendChild(lbl);
|
||||||
|
for (var f = 0; f <= nf; f++) {
|
||||||
|
var c = document.createElement('div');
|
||||||
|
c.className = 'se-fb-fret-num';
|
||||||
|
c.textContent = f === 0 ? 'nut' : f;
|
||||||
|
hdr.appendChild(c);
|
||||||
|
}
|
||||||
|
table.appendChild(hdr);
|
||||||
|
|
||||||
|
var labels = ['1 (low)', '2', '3', '4', '5', '6 (high)'];
|
||||||
|
for (var s = 0; s < 6; s++) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'se-fb-row';
|
||||||
|
var sl = document.createElement('div');
|
||||||
|
sl.className = 'se-fb-label';
|
||||||
|
sl.textContent = labels[s];
|
||||||
|
row.appendChild(sl);
|
||||||
|
for (var f = 0; f <= nf; f++) {
|
||||||
|
var cell = document.createElement('div');
|
||||||
|
cell.className = 'se-fb-cell';
|
||||||
|
if (f === 0) cell.classList.add('se-fb-nut');
|
||||||
|
var cur = _seFrets[s];
|
||||||
|
if (f === 0) {
|
||||||
|
if (cur === -1) { cell.classList.add('se-muted'); cell.textContent = 'x'; }
|
||||||
|
else if (cur === 0) { cell.classList.add('se-open'); cell.textContent = 'o'; }
|
||||||
|
} else if (cur === f) {
|
||||||
|
cell.classList.add('se-dot-active');
|
||||||
|
}
|
||||||
|
cell.setAttribute('onclick', 'seFbCell(' + s + ',' + f + ')');
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
table.appendChild(row);
|
||||||
|
}
|
||||||
|
wrap.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _seUpdateAutoName() {
|
||||||
|
_seAutoDetect(_seFrets).then(function(n) {
|
||||||
|
if (_seAutoLabel) _seAutoLabel.textContent = n ? 'Detected: ' + n : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function seRenderShapeEditor(set) {
|
||||||
|
var shape = set.shapes[_seEditingIdx];
|
||||||
|
if (!shape) { _seEditingIdx = -1; seRenderDetail(); return; }
|
||||||
|
_seFrets = shape.frets.slice();
|
||||||
|
_seDetailEl.innerHTML = '';
|
||||||
|
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.className = 'se-editor-header';
|
||||||
|
var btnDone = document.createElement('button');
|
||||||
|
btnDone.className = 'btn-small btn-apply';
|
||||||
|
btnDone.textContent = 'Done';
|
||||||
|
btnDone.setAttribute('onclick', 'seShapeDone()');
|
||||||
|
header.appendChild(btnDone);
|
||||||
|
var title = document.createElement('h3');
|
||||||
|
title.textContent = 'Edit Shape';
|
||||||
|
header.appendChild(title);
|
||||||
|
_seDetailEl.appendChild(header);
|
||||||
|
|
||||||
|
var nameRow = document.createElement('div');
|
||||||
|
nameRow.className = 'se-editor-name-row';
|
||||||
|
var nl = document.createElement('label');
|
||||||
|
nl.textContent = 'Name';
|
||||||
|
_seNameInput = document.createElement('input');
|
||||||
|
_seNameInput.type = 'text';
|
||||||
|
_seNameInput.className = 'se-editor-name-input';
|
||||||
|
_seNameInput.placeholder = 'Auto-detect name';
|
||||||
|
_seNameInput.value = shape.name;
|
||||||
|
_seAutoLabel = document.createElement('span');
|
||||||
|
_seAutoLabel.className = 'se-auto-label';
|
||||||
|
nameRow.appendChild(nl);
|
||||||
|
nameRow.appendChild(_seNameInput);
|
||||||
|
nameRow.appendChild(_seAutoLabel);
|
||||||
|
_seDetailEl.appendChild(nameRow);
|
||||||
|
|
||||||
|
var fbWrap = document.createElement('div');
|
||||||
|
fbWrap.id = 'se-fb-wrap';
|
||||||
|
fbWrap.className = 'se-clickable-fb';
|
||||||
|
_seDetailEl.appendChild(fbWrap);
|
||||||
|
|
||||||
|
_seBuildGrid();
|
||||||
|
_seUpdateAutoName();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _seAutoDetect(frets) {
|
||||||
|
if (!window.go || !window.go.main || !window.go.main.App) return Promise.resolve('');
|
||||||
|
return window.go.main.App.IdentifyShape(frets).then(function(n) { return n || ''; }).catch(function() { return ''; });
|
||||||
|
}
|
||||||
|
|
@ -13,17 +13,41 @@
|
||||||
const btnSearch = document.getElementById('btn-shape-search');
|
const btnSearch = document.getElementById('btn-shape-search');
|
||||||
const resultsContainer = document.getElementById('shapes-results');
|
const resultsContainer = document.getElementById('shapes-results');
|
||||||
const loadingEl = document.getElementById('shapes-loading');
|
const loadingEl = document.getElementById('shapes-loading');
|
||||||
|
const setSelect = document.getElementById('score-set-select');
|
||||||
|
|
||||||
const roots = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
|
const roots = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
|
||||||
|
let scoreSetsData = { sets: [], selected: 0 };
|
||||||
let shapes = [];
|
let shapes = [];
|
||||||
let selectedIndex = 0;
|
let selectedIndex = 0;
|
||||||
let editingIndex = -1;
|
let editingIndex = -1;
|
||||||
|
|
||||||
|
function activeSet() {
|
||||||
|
return scoreSetsData.sets[scoreSetsData.selected];
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncShapesRef() {
|
||||||
|
const set = activeSet();
|
||||||
|
shapes = set ? set.shapes : [];
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
if (!window.go || !window.go.main || !window.go.main.App) return;
|
if (!window.go || !window.go.main || !window.go.main.App) return;
|
||||||
|
window.go.main.App.GetScoreSets().then(data => {
|
||||||
|
scoreSetsData = data;
|
||||||
|
if (!scoreSetsData.sets || scoreSetsData.sets.length === 0) {
|
||||||
|
scoreSetsData = { sets: [{ name: 'Standard Shapes', type: '', shapes: [] }], selected: 0 };
|
||||||
window.go.main.App.GetDefaultShapes().then(defaults => {
|
window.go.main.App.GetDefaultShapes().then(defaults => {
|
||||||
shapes = defaults;
|
scoreSetsData.sets[0].shapes = defaults;
|
||||||
|
syncShapesRef();
|
||||||
|
renderSetSelector();
|
||||||
|
renderShapeList();
|
||||||
|
buildVoicingInputs();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncShapesRef();
|
||||||
|
selectedIndex = 0;
|
||||||
|
renderSetSelector();
|
||||||
renderShapeList();
|
renderShapeList();
|
||||||
buildVoicingInputs();
|
buildVoicingInputs();
|
||||||
});
|
});
|
||||||
|
|
@ -55,6 +79,17 @@
|
||||||
btnEditorSave.addEventListener('click', saveEditor);
|
btnEditorSave.addEventListener('click', saveEditor);
|
||||||
btnEditorCancel.addEventListener('click', closeEditor);
|
btnEditorCancel.addEventListener('click', closeEditor);
|
||||||
btnSearch.addEventListener('click', doSearch);
|
btnSearch.addEventListener('click', doSearch);
|
||||||
|
setSelect.addEventListener('change', () => {
|
||||||
|
if (window.dbg) window.dbg('[shapes] setSelect change:', setSelect.value);
|
||||||
|
if (setSelect.value === '__editor__') {
|
||||||
|
setSelect.value = scoreSetsData.selected;
|
||||||
|
if (window.dbg) window.dbg('[shapes] opening editor, sets:', scoreSetsData.sets.length);
|
||||||
|
window.switchToView('sets-editor');
|
||||||
|
if (window.initSetsEditor) window.initSetsEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switchSet(parseInt(setSelect.value));
|
||||||
|
});
|
||||||
|
|
||||||
resultsContainer.addEventListener('click', (e) => {
|
resultsContainer.addEventListener('click', (e) => {
|
||||||
const item = e.target.closest('[data-chord-midi]');
|
const item = e.target.closest('[data-chord-midi]');
|
||||||
|
|
@ -63,6 +98,49 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDensitySet() {
|
||||||
|
const set = activeSet();
|
||||||
|
return set && set.type === 'density';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSetSelector() {
|
||||||
|
setSelect.innerHTML = '';
|
||||||
|
scoreSetsData.sets.forEach((set, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = i;
|
||||||
|
opt.textContent = set.name;
|
||||||
|
if (i === scoreSetsData.selected) opt.selected = true;
|
||||||
|
setSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
const sep = document.createElement('option');
|
||||||
|
sep.disabled = true;
|
||||||
|
sep.textContent = '───';
|
||||||
|
setSelect.appendChild(sep);
|
||||||
|
const editorOpt = document.createElement('option');
|
||||||
|
editorOpt.value = '__editor__';
|
||||||
|
editorOpt.textContent = 'Open Sets Editor';
|
||||||
|
setSelect.appendChild(editorOpt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchSet(i) {
|
||||||
|
if (i < 0 || i >= scoreSetsData.sets.length) return;
|
||||||
|
scoreSetsData.selected = i;
|
||||||
|
syncShapesRef();
|
||||||
|
selectedIndex = 0;
|
||||||
|
closeEditor();
|
||||||
|
renderSetSelector();
|
||||||
|
renderShapeList();
|
||||||
|
buildVoicingInputs();
|
||||||
|
persistSets();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSets() {
|
||||||
|
if (!window.go) return;
|
||||||
|
const set = activeSet();
|
||||||
|
if (set) set.shapes = shapes;
|
||||||
|
window.go.main.App.SaveScoreSets(scoreSetsData);
|
||||||
|
}
|
||||||
|
|
||||||
function renderShapeList() {
|
function renderShapeList() {
|
||||||
shapeList.innerHTML = '';
|
shapeList.innerHTML = '';
|
||||||
shapes.forEach((s, i) => {
|
shapes.forEach((s, i) => {
|
||||||
|
|
@ -80,6 +158,38 @@
|
||||||
const actions = document.createElement('span');
|
const actions = document.createElement('span');
|
||||||
actions.className = 'shape-actions';
|
actions.className = 'shape-actions';
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
const upBtn = document.createElement('button');
|
||||||
|
upBtn.textContent = '\u25B2';
|
||||||
|
upBtn.title = 'Move up';
|
||||||
|
upBtn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
[shapes[i - 1], shapes[i]] = [shapes[i], shapes[i - 1]];
|
||||||
|
if (selectedIndex === i) selectedIndex = i - 1;
|
||||||
|
else if (selectedIndex === i - 1) selectedIndex = i;
|
||||||
|
renderShapeList();
|
||||||
|
buildVoicingInputs();
|
||||||
|
persistSets();
|
||||||
|
});
|
||||||
|
actions.appendChild(upBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < shapes.length - 1) {
|
||||||
|
const downBtn = document.createElement('button');
|
||||||
|
downBtn.textContent = '\u25BC';
|
||||||
|
downBtn.title = 'Move down';
|
||||||
|
downBtn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
[shapes[i], shapes[i + 1]] = [shapes[i + 1], shapes[i]];
|
||||||
|
if (selectedIndex === i) selectedIndex = i + 1;
|
||||||
|
else if (selectedIndex === i + 1) selectedIndex = i;
|
||||||
|
renderShapeList();
|
||||||
|
buildVoicingInputs();
|
||||||
|
persistSets();
|
||||||
|
});
|
||||||
|
actions.appendChild(downBtn);
|
||||||
|
}
|
||||||
|
|
||||||
const editBtn = document.createElement('button');
|
const editBtn = document.createElement('button');
|
||||||
editBtn.textContent = 'edit';
|
editBtn.textContent = 'edit';
|
||||||
editBtn.addEventListener('click', e => { e.stopPropagation(); openEditor(i); });
|
editBtn.addEventListener('click', e => { e.stopPropagation(); openEditor(i); });
|
||||||
|
|
@ -92,6 +202,7 @@
|
||||||
if (selectedIndex >= shapes.length) selectedIndex = Math.max(0, shapes.length - 1);
|
if (selectedIndex >= shapes.length) selectedIndex = Math.max(0, shapes.length - 1);
|
||||||
renderShapeList();
|
renderShapeList();
|
||||||
buildVoicingInputs();
|
buildVoicingInputs();
|
||||||
|
persistSets();
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.appendChild(editBtn);
|
actions.appendChild(editBtn);
|
||||||
|
|
@ -191,27 +302,20 @@
|
||||||
closeEditor();
|
closeEditor();
|
||||||
renderShapeList();
|
renderShapeList();
|
||||||
buildVoicingInputs();
|
buildVoicingInputs();
|
||||||
|
persistSets();
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreDefaults() {
|
function restoreDefaults() {
|
||||||
if (!window.go) return;
|
if (!window.go) return;
|
||||||
const currentSearch = shapes[selectedIndex];
|
|
||||||
window.go.main.App.GetDefaultShapes().then(defaults => {
|
window.go.main.App.GetDefaultShapes().then(defaults => {
|
||||||
shapes = defaults;
|
shapes.length = 0;
|
||||||
// preserve search shape if custom
|
defaults.forEach(s => shapes.push(s));
|
||||||
if (currentSearch) {
|
const set = activeSet();
|
||||||
const exists = shapes.some(s => s.name === currentSearch.name);
|
if (set) set.shapes = shapes;
|
||||||
if (!exists) {
|
|
||||||
shapes.push(currentSearch);
|
|
||||||
selectedIndex = shapes.length - 1;
|
|
||||||
} else {
|
|
||||||
selectedIndex = shapes.findIndex(s => s.name === currentSearch.name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selectedIndex = 0;
|
selectedIndex = 0;
|
||||||
}
|
|
||||||
renderShapeList();
|
renderShapeList();
|
||||||
buildVoicingInputs();
|
buildVoicingInputs();
|
||||||
|
persistSets();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,13 +334,27 @@
|
||||||
shape: shape,
|
shape: shape,
|
||||||
target_quality: qualitySelect.value,
|
target_quality: qualitySelect.value,
|
||||||
target_root: parseInt(rootSelect.value),
|
target_root: parseInt(rootSelect.value),
|
||||||
voicing: voicing
|
voicing: voicing,
|
||||||
|
base_tuning: window.currentTuningMIDI ? window.currentTuningMIDI.slice() : [],
|
||||||
|
baseline_shift: parseInt(document.getElementById('baseline-shift').value) || 0,
|
||||||
|
range_down: parseInt(document.getElementById('range-down').value) || 0,
|
||||||
|
range_up: parseInt(document.getElementById('range-up').value) || 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
loadingEl.style.display = '';
|
loadingEl.style.display = '';
|
||||||
loadingEl.textContent = 'Searching tunings...';
|
loadingEl.textContent = 'Searching tunings...';
|
||||||
resultsContainer.innerHTML = '';
|
resultsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (isDensitySet()) {
|
||||||
|
window.go.main.App.FindDensityTunings(query).then(results => {
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
renderDensityResults(results || []);
|
||||||
|
}).catch(err => {
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
loadingEl.style.display = '';
|
||||||
|
loadingEl.textContent = 'Error: ' + err;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
window.go.main.App.FindShapeTunings(query, shapes).then(results => {
|
window.go.main.App.FindShapeTunings(query, shapes).then(results => {
|
||||||
loadingEl.style.display = 'none';
|
loadingEl.style.display = 'none';
|
||||||
renderResults(results || [], shape.name);
|
renderResults(results || [], shape.name);
|
||||||
|
|
@ -246,6 +364,7 @@
|
||||||
loadingEl.textContent = 'Error: ' + err;
|
loadingEl.textContent = 'Error: ' + err;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
|
@ -391,6 +510,170 @@
|
||||||
body.appendChild(grid);
|
body.appendChild(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setBtn = document.createElement('button');
|
||||||
|
setBtn.className = 'btn-small';
|
||||||
|
setBtn.textContent = 'Set to instrument tuning';
|
||||||
|
setBtn.style.marginTop = '0.75rem';
|
||||||
|
setBtn.addEventListener('click', () => {
|
||||||
|
if (window.setTuningFromExplorer) window.setTuningFromExplorer(tc.tuning);
|
||||||
|
});
|
||||||
|
body.appendChild(setBtn);
|
||||||
|
|
||||||
|
card.appendChild(header);
|
||||||
|
card.appendChild(body);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDensityResults(results) {
|
||||||
|
resultsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
const msg = document.createElement('div');
|
||||||
|
msg.className = 'loading';
|
||||||
|
msg.textContent = 'No tunings found.';
|
||||||
|
resultsContainer.appendChild(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explain = document.createElement('p');
|
||||||
|
explain.className = 'density-explanation';
|
||||||
|
explain.textContent = 'Tunings ranked by total playable chords within current fret/finger settings. Expand a card to see all identified chords with their simplest fingerings.';
|
||||||
|
resultsContainer.appendChild(explain);
|
||||||
|
|
||||||
|
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() + ' 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(buildDensityCard(results[idx]));
|
||||||
|
}
|
||||||
|
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 buildDensityCard(dc) {
|
||||||
|
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 = dc.chord;
|
||||||
|
|
||||||
|
if (dc.high_compat) {
|
||||||
|
card.classList.add('high-compat');
|
||||||
|
const star = document.createElement('span');
|
||||||
|
star.className = 'compat-star';
|
||||||
|
star.textContent = '\u2605';
|
||||||
|
star.title = dc.maj_min_count + ' major/minor triads';
|
||||||
|
h3.appendChild(star);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = document.createElement('span');
|
||||||
|
stats.className = 'tuning-stats';
|
||||||
|
stats.textContent = dc.valid_chords + ' chords, avg ' + dc.avg_fingers.toFixed(1) + ' fingers';
|
||||||
|
|
||||||
|
const notes = document.createElement('span');
|
||||||
|
notes.className = 'tuning-notes';
|
||||||
|
notes.textContent = dc.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 (dc.chords && dc.chords.length > 0) {
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'companion-grid';
|
||||||
|
|
||||||
|
dc.chords.forEach(ch => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'companion-item';
|
||||||
|
|
||||||
|
const chordLabel = document.createElement('div');
|
||||||
|
chordLabel.className = 'companion-chord';
|
||||||
|
chordLabel.textContent = ch.chord;
|
||||||
|
|
||||||
|
const fingerBadge = document.createElement('span');
|
||||||
|
fingerBadge.className = 'finger-count';
|
||||||
|
fingerBadge.textContent = ch.fingers + 'f';
|
||||||
|
chordLabel.appendChild(fingerBadge);
|
||||||
|
|
||||||
|
item.appendChild(chordLabel);
|
||||||
|
|
||||||
|
const fingering = ch.frets.map(f => f === -1 ? 'x' : String(f));
|
||||||
|
const maxFretVal = Math.max(...ch.frets.filter(f => f >= 0), 1);
|
||||||
|
const fb = document.createElement('div');
|
||||||
|
fb.className = 'fretboard alternative-fretboard';
|
||||||
|
renderSingleFretboard(fb, fingering, Math.max(maxFretVal, 4));
|
||||||
|
item.appendChild(fb);
|
||||||
|
|
||||||
|
const midi = [];
|
||||||
|
for (let si = 0; si < dc.tuning_midi.length; si++) {
|
||||||
|
if (ch.frets[si] !== -1 && dc.tuning_midi[si] != null) {
|
||||||
|
midi.push(dc.tuning_midi[si] + ch.frets[si]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (midi.length) item.dataset.chordMidi = JSON.stringify(midi);
|
||||||
|
|
||||||
|
const notesDiv = document.createElement('div');
|
||||||
|
notesDiv.className = 'companion-notes';
|
||||||
|
notesDiv.textContent = (ch.notes || []).join(' ');
|
||||||
|
item.appendChild(notesDiv);
|
||||||
|
|
||||||
|
grid.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
body.appendChild(grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setBtn = document.createElement('button');
|
||||||
|
setBtn.className = 'btn-small';
|
||||||
|
setBtn.textContent = 'Set to instrument tuning';
|
||||||
|
setBtn.style.marginTop = '0.75rem';
|
||||||
|
setBtn.addEventListener('click', () => {
|
||||||
|
if (window.setTuningFromExplorer) window.setTuningFromExplorer(dc.tuning);
|
||||||
|
});
|
||||||
|
body.appendChild(setBtn);
|
||||||
|
|
||||||
card.appendChild(header);
|
card.appendChild(header);
|
||||||
card.appendChild(body);
|
card.appendChild(body);
|
||||||
return card;
|
return card;
|
||||||
|
|
@ -502,4 +785,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
window.initShapeExplorer = init;
|
window.initShapeExplorer = init;
|
||||||
|
window.renderSingleFretboard = renderSingleFretboard;
|
||||||
|
window.getScoreSetsData = () => scoreSetsData;
|
||||||
|
window.setScoreSetsData = (data) => {
|
||||||
|
scoreSetsData = data;
|
||||||
|
syncShapesRef();
|
||||||
|
renderSetSelector();
|
||||||
|
renderShapeList();
|
||||||
|
buildVoicingInputs();
|
||||||
|
};
|
||||||
|
window.persistSets = persistSets;
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,11 @@
|
||||||
|
//go:build !debug
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func debugLog(string, ...any) {}
|
||||||
|
|
||||||
|
func (a *App) JSDebugLog(string) {}
|
||||||
|
|
||||||
|
func (a *App) IsDebug() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScoreSet struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Shapes []ShapeDefinition `json:"shapes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreSetsData struct {
|
||||||
|
Sets []ScoreSet `json:"sets"`
|
||||||
|
Selected int `json:"selected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultScoreSetsData() ScoreSetsData {
|
||||||
|
return ScoreSetsData{
|
||||||
|
Sets: []ScoreSet{
|
||||||
|
{Name: "Standard Shapes", Shapes: DefaultShapes()},
|
||||||
|
{Name: "Chord Density", Type: "density", Shapes: DefaultShapes()},
|
||||||
|
},
|
||||||
|
Selected: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadScoreSets(path string) (ScoreSetsData, error) {
|
||||||
|
var data ScoreSetsData
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(raw, &data)
|
||||||
|
if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
if len(data.Sets) == 0 {
|
||||||
|
return DefaultScoreSetsData(), nil
|
||||||
|
}
|
||||||
|
if data.Selected < 0 || data.Selected >= len(data.Sets) {
|
||||||
|
data.Selected = 0
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveScoreSets(path string, data ScoreSetsData) error {
|
||||||
|
raw, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, raw, 0644)
|
||||||
|
}
|
||||||
151
shapes.go
151
shapes.go
|
|
@ -18,6 +18,10 @@ type ShapeQuery struct {
|
||||||
TargetQuality string `json:"target_quality"`
|
TargetQuality string `json:"target_quality"`
|
||||||
TargetRoot int `json:"target_root"` // 0-11 or -1 for any
|
TargetRoot int `json:"target_root"` // 0-11 or -1 for any
|
||||||
Voicing []string `json:"voicing"` // per-string "" or "C4"/"60"
|
Voicing []string `json:"voicing"` // per-string "" or "C4"/"60"
|
||||||
|
BaseTuning []int `json:"base_tuning"` // per-string MIDI for range center
|
||||||
|
BaselineShift int `json:"baseline_shift"`
|
||||||
|
RangeDown int `json:"range_down"`
|
||||||
|
RangeUp int `json:"range_up"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CompanionChord struct {
|
type CompanionChord struct {
|
||||||
|
|
@ -112,10 +116,9 @@ func parsePitchInput(s string) (midi int, pc int, err error) {
|
||||||
return midi, sem, nil
|
return midi, sem, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func closestMIDIInRange(pc, standard int) (int, bool) {
|
func closestMIDIInRange(pc, standard, down, up int) (int, bool) {
|
||||||
lo := standard - 7
|
lo := standard - down
|
||||||
hi := standard + 7
|
hi := standard + up
|
||||||
// find MIDI note with pitch class pc closest to standard within range
|
|
||||||
best := -1
|
best := -1
|
||||||
bestDist := 999
|
bestDist := 999
|
||||||
for m := lo; m <= hi; m++ {
|
for m := lo; m <= hi; m++ {
|
||||||
|
|
@ -136,14 +139,26 @@ func closestMIDIInRange(pc, standard int) (int, bool) {
|
||||||
return best, best >= 0
|
return best, best >= 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]TuningCandidate, error) {
|
type candidateTuning struct {
|
||||||
|
tuningMIDI []int
|
||||||
|
root int
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCandidateTunings(query ShapeQuery) ([]candidateTuning, error) {
|
||||||
shape := query.Shape.Frets
|
shape := query.Shape.Frets
|
||||||
nStrings := len(shape)
|
nStrings := len(shape)
|
||||||
if nStrings != 6 {
|
if nStrings != 6 {
|
||||||
return nil, fmt.Errorf("shape must have 6 strings")
|
return nil, fmt.Errorf("shape must have 6 strings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolve target intervals
|
rangeDown := query.RangeDown
|
||||||
|
rangeUp := query.RangeUp
|
||||||
|
|
||||||
|
baseMIDI := standardMIDI
|
||||||
|
if len(query.BaseTuning) == nStrings {
|
||||||
|
baseMIDI = query.BaseTuning
|
||||||
|
}
|
||||||
|
|
||||||
var targetIntervals []int
|
var targetIntervals []int
|
||||||
defs := GetChordDefinitions()
|
defs := GetChordDefinitions()
|
||||||
found := false
|
found := false
|
||||||
|
|
@ -158,7 +173,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
||||||
return nil, fmt.Errorf("unknown quality: %s", query.TargetQuality)
|
return nil, fmt.Errorf("unknown quality: %s", query.TargetQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse voicing pins
|
|
||||||
type pinInfo struct {
|
type pinInfo struct {
|
||||||
midi int
|
midi int
|
||||||
pc int
|
pc int
|
||||||
|
|
@ -174,7 +188,7 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if shape[i] == -1 {
|
if shape[i] == -1 {
|
||||||
continue // muted string, ignore voicing
|
continue
|
||||||
}
|
}
|
||||||
m, p, err := parsePitchInput(v)
|
m, p, err := parsePitchInput(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -184,7 +198,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// determine candidate roots
|
|
||||||
var roots []int
|
var roots []int
|
||||||
if query.TargetRoot >= 0 && query.TargetRoot < 12 {
|
if query.TargetRoot >= 0 && query.TargetRoot < 12 {
|
||||||
roots = []int{query.TargetRoot}
|
roots = []int{query.TargetRoot}
|
||||||
|
|
@ -195,13 +208,7 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// chord pitch classes for each root
|
var results []candidateTuning
|
||||||
type tuningResult struct {
|
|
||||||
tuningPCs []int
|
|
||||||
tuningMIDI []int
|
|
||||||
root int
|
|
||||||
}
|
|
||||||
var results []tuningResult
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
for _, root := range roots {
|
for _, root := range roots {
|
||||||
|
|
@ -210,7 +217,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
||||||
chordPCs[(root+iv)%12] = true
|
chordPCs[(root+iv)%12] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// for each non-muted string, compute candidate open note PCs
|
|
||||||
type stringCandidates struct {
|
type stringCandidates struct {
|
||||||
pcs []int
|
pcs []int
|
||||||
}
|
}
|
||||||
|
|
@ -220,12 +226,11 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
||||||
for s := 0; s < nStrings; s++ {
|
for s := 0; s < nStrings; s++ {
|
||||||
if shape[s] == -1 {
|
if shape[s] == -1 {
|
||||||
muted[s] = true
|
muted[s] = true
|
||||||
candidates[s] = stringCandidates{[]int{standardMIDI[s] % 12}}
|
candidates[s] = stringCandidates{[]int{baseMIDI[s] % 12}}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if pins[s].pc >= 0 {
|
if pins[s].pc >= 0 {
|
||||||
// pinned: open note = pinned_midi - shape_fret
|
|
||||||
openPC := (pins[s].pc - shape[s] + 120) % 12
|
openPC := (pins[s].pc - shape[s] + 120) % 12
|
||||||
candidates[s] = stringCandidates{[]int{openPC}}
|
candidates[s] = stringCandidates{[]int{openPC}}
|
||||||
continue
|
continue
|
||||||
|
|
@ -239,7 +244,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
||||||
candidates[s] = stringCandidates{cands}
|
candidates[s] = stringCandidates{cands}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cartesian product
|
|
||||||
indices := make([]int, nStrings)
|
indices := make([]int, nStrings)
|
||||||
sizes := make([]int, nStrings)
|
sizes := make([]int, nStrings)
|
||||||
total := 1
|
total := 1
|
||||||
|
|
@ -260,7 +264,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
||||||
tuningPCs[s] = candidates[s].pcs[indices[s]]
|
tuningPCs[s] = candidates[s].pcs[indices[s]]
|
||||||
}
|
}
|
||||||
|
|
||||||
// check all chord tones present in voicing (non-muted strings)
|
|
||||||
voicedPCs := make(map[int]bool)
|
voicedPCs := make(map[int]bool)
|
||||||
for s := 0; s < nStrings; s++ {
|
for s := 0; s < nStrings; s++ {
|
||||||
if !muted[s] {
|
if !muted[s] {
|
||||||
|
|
@ -279,7 +282,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolve MIDI pitches with tension check
|
|
||||||
tuningMIDI := make([]int, nStrings)
|
tuningMIDI := make([]int, nStrings)
|
||||||
valid := true
|
valid := true
|
||||||
for s := 0; s < nStrings; s++ {
|
for s := 0; s < nStrings; s++ {
|
||||||
|
|
@ -293,18 +295,16 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
||||||
valid = false
|
valid = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
std := standardMIDI[s]
|
base := baseMIDI[s]
|
||||||
diff := openMIDI - std
|
diff := openMIDI - base
|
||||||
if diff < 0 {
|
if diff < -rangeDown || diff > rangeUp {
|
||||||
diff = -diff
|
|
||||||
}
|
|
||||||
if diff > 7 {
|
|
||||||
valid = false
|
valid = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
tuningMIDI[s] = openMIDI
|
tuningMIDI[s] = openMIDI
|
||||||
} else {
|
} else {
|
||||||
m, ok := closestMIDIInRange(tuningPCs[s], standardMIDI[s])
|
base := baseMIDI[s]
|
||||||
|
m, ok := closestMIDIInRange(tuningPCs[s], base, rangeDown, rangeUp)
|
||||||
if !ok {
|
if !ok {
|
||||||
valid = false
|
valid = false
|
||||||
break
|
break
|
||||||
|
|
@ -319,16 +319,23 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
||||||
key := fmt.Sprint(tuningMIDI)
|
key := fmt.Sprint(tuningMIDI)
|
||||||
if !seen[key] {
|
if !seen[key] {
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
results = append(results, tuningResult{tuningPCs, tuningMIDI, root})
|
results = append(results, candidateTuning{tuningMIDI, root})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deduped := results
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
// build output
|
func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]TuningCandidate, error) {
|
||||||
|
candidates, err := findCandidateTunings(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nStrings := len(query.Shape.Frets)
|
||||||
var output []TuningCandidate
|
var output []TuningCandidate
|
||||||
for _, r := range deduped {
|
for _, r := range candidates {
|
||||||
tuningNames := make([]string, nStrings)
|
tuningNames := make([]string, nStrings)
|
||||||
for s := 0; s < nStrings; s++ {
|
for s := 0; s < nStrings; s++ {
|
||||||
tuningNames[s] = midiToNoteName(r.tuningMIDI[s])
|
tuningNames[s] = midiToNoteName(r.tuningMIDI[s])
|
||||||
|
|
@ -516,6 +523,84 @@ func identifyCompanions(tuningMIDI []int, shapes []ShapeDefinition, searchShapeN
|
||||||
return companions
|
return companions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func identifyShape(frets []int, cfg Config) string {
|
||||||
|
nStrings := len(frets)
|
||||||
|
if nStrings != len(cfg.Tuning) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var soundedPCs []int
|
||||||
|
for s := 0; s < nStrings; s++ {
|
||||||
|
if frets[s] == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sem, ok := NoteToSemitone[cfg.Tuning[s]]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pc := (sem + frets[s]) % 12
|
||||||
|
soundedPCs = append(soundedPCs, pc)
|
||||||
|
}
|
||||||
|
if len(soundedPCs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
uniquePCs := uniqueInts(soundedPCs)
|
||||||
|
|
||||||
|
defs := GetChordDefinitions()
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
best := candidates[0]
|
||||||
|
for _, c := range candidates[1:] {
|
||||||
|
if c.size < best.size || (c.size == best.size && c.bassIdx < best.bassIdx) {
|
||||||
|
best = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SemitoneToNote[best.root] + " " + best.quality
|
||||||
|
}
|
||||||
|
|
||||||
func midiToNoteName(midi int) string {
|
func midiToNoteName(midi int) string {
|
||||||
pc := midi % 12
|
pc := midi % 12
|
||||||
octave := midi/12 - 1
|
octave := midi/12 - 1
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStandardTuningAppearsForEMajor(t *testing.T) {
|
||||||
|
query := ShapeQuery{
|
||||||
|
Shape: ShapeDefinition{"E major", []int{0, 2, 2, 1, 0, 0}},
|
||||||
|
TargetQuality: "major",
|
||||||
|
TargetRoot: -1,
|
||||||
|
RangeDown: 7,
|
||||||
|
RangeUp: 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := findCandidateTunings(query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
std := []int{40, 45, 50, 55, 59, 64}
|
||||||
|
found := false
|
||||||
|
for _, r := range results {
|
||||||
|
match := true
|
||||||
|
for s := 0; s < 6; s++ {
|
||||||
|
if r.tuningMIDI[s] != std[s] {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("Standard tuning not found for E major shape + major quality")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBStandardReturnsItself(t *testing.T) {
|
||||||
|
// B standard = 5 semitones down from E standard
|
||||||
|
bStd := []int{35, 40, 45, 50, 54, 59} // B1 E2 A2 D3 F#3 B3
|
||||||
|
|
||||||
|
query := ShapeQuery{
|
||||||
|
Shape: ShapeDefinition{"E major", []int{0, 2, 2, 1, 0, 0}},
|
||||||
|
TargetQuality: "major",
|
||||||
|
TargetRoot: -1,
|
||||||
|
BaseTuning: bStd,
|
||||||
|
RangeDown: 3,
|
||||||
|
RangeUp: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := findCandidateTunings(query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Total candidates: %d", len(results))
|
||||||
|
found := false
|
||||||
|
for _, r := range results {
|
||||||
|
match := true
|
||||||
|
for s := 0; s < 6; s++ {
|
||||||
|
if r.tuningMIDI[s] != bStd[s] {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
found = true
|
||||||
|
t.Logf("B standard found: root=%d midi=%v", r.root, r.tuningMIDI)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
for i, r := range results {
|
||||||
|
if i >= 10 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
shifts := make([]int, 6)
|
||||||
|
for s := 0; s < 6; s++ {
|
||||||
|
shifts[s] = r.tuningMIDI[s] - bStd[s]
|
||||||
|
}
|
||||||
|
t.Logf(" root=%d midi=%v shifts=%v", r.root, r.tuningMIDI, shifts)
|
||||||
|
}
|
||||||
|
t.Error("B standard tuning not found when BaseTuning=B standard, range ±3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit ca6d52252307de75ce9278bb828296780fcfafdc
|
Subproject commit 512c578c22b2cdc493aca89bb705806afc034359
|
||||||
Loading…
Reference in New Issue