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
75
app.go
75
app.go
|
|
@ -7,9 +7,11 @@ import (
|
|||
)
|
||||
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
config Config
|
||||
configPath string
|
||||
ctx context.Context
|
||||
config Config
|
||||
configPath string
|
||||
scoreSets ScoreSetsData
|
||||
scoreSetsPath string
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
|
|
@ -19,35 +21,62 @@ func NewApp() *App {
|
|||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
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() {
|
||||
exe, _ := os.Executable()
|
||||
dir := filepath.Dir(exe)
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(dir, "config.json"),
|
||||
"config.json",
|
||||
}
|
||||
|
||||
for _, path := range candidates {
|
||||
cfg, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
a.config = cfg
|
||||
a.configPath = path
|
||||
return
|
||||
a.configPath = filepath.Join(dataDir(), "config.json")
|
||||
cfg, err := LoadConfig(a.configPath)
|
||||
if err == nil {
|
||||
if cfg.RangeDown == 0 && cfg.RangeUp == 0 {
|
||||
cfg.RangeDown = 7
|
||||
cfg.RangeUp = 7
|
||||
}
|
||||
a.config = cfg
|
||||
return
|
||||
}
|
||||
|
||||
a.configPath = "config.json"
|
||||
a.config = Config{
|
||||
Instrument: "guitar",
|
||||
Tuning: []string{"E", "A", "D", "G", "B", "E"},
|
||||
Frets: 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 {
|
||||
return a.config
|
||||
}
|
||||
|
|
@ -96,3 +125,11 @@ func (a *App) GetDefaultShapes() []ShapeDefinition {
|
|||
func (a *App) FindShapeTunings(query ShapeQuery, companions []ShapeDefinition) ([]TuningCandidate, error) {
|
||||
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
config.go
11
config.go
|
|
@ -7,10 +7,13 @@ import (
|
|||
)
|
||||
|
||||
type Config struct {
|
||||
Instrument string `json:"instrument"`
|
||||
Tuning []string `json:"tuning"`
|
||||
Frets int `json:"frets"`
|
||||
MaxFingers int `json:"max_fingers"`
|
||||
Instrument string `json:"instrument"`
|
||||
Tuning []string `json:"tuning"`
|
||||
Frets int `json:"frets"`
|
||||
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) {
|
||||
|
|
|
|||
42
debug.go
42
debug.go
|
|
@ -2,9 +2,45 @@
|
|||
|
||||
package main
|
||||
|
||||
import "log"
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
var debugLogger *log.Logger
|
||||
|
||||
func init() {
|
||||
log.SetFlags(log.Ltime | log.Lshortfile)
|
||||
log.Println("[debug] debug build active")
|
||||
home, _ := os.UserHomeDir()
|
||||
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
|
||||
set -euo pipefail
|
||||
set -e
|
||||
pkill -f "web-tuner" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
|
||||
export CC=/usr/bin/clang
|
||||
export CXX=/usr/bin/clang++
|
||||
export SDKROOT=$(xcrun --show-sdk-path)
|
||||
export CGO_ENABLED=1
|
||||
export PATH="$HOME/go/bin:$PATH"
|
||||
|
||||
echo "Starting dev build with debug tag..."
|
||||
wails dev -tags debug -loglevel debug
|
||||
WAILS=$(command -v wails || echo "$HOME/go/bin/wails")
|
||||
|
||||
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] {
|
||||
continue
|
||||
}
|
||||
if countFingers(alt) > maxFingers {
|
||||
if countEffectiveFingers(alt, len(alt)) > maxFingers {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
|
|
@ -279,81 +279,35 @@ func isValidMuteConfig(fingering []string) bool {
|
|||
}
|
||||
|
||||
func countEffectiveFingers(fingering []string, numStrings int) int {
|
||||
type fretInfo struct {
|
||||
fret int
|
||||
strings []int
|
||||
}
|
||||
|
||||
frets := make(map[int][]int)
|
||||
fretStrings := make(map[int][]int)
|
||||
for i, f := range fingering {
|
||||
if f == "x" || f == "0" {
|
||||
continue
|
||||
}
|
||||
fv := atoi(f)
|
||||
frets[fv] = append(frets[fv], i)
|
||||
}
|
||||
|
||||
if len(frets) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type fingerKey struct {
|
||||
fret int
|
||||
barre bool
|
||||
}
|
||||
used := make(map[fingerKey]bool)
|
||||
|
||||
for fret, strings := range frets {
|
||||
if len(strings) >= 2 {
|
||||
start := strings[0]
|
||||
end := strings[len(strings)-1]
|
||||
for _, s := range strings {
|
||||
if s < start {
|
||||
start = s
|
||||
}
|
||||
if s > end {
|
||||
end = s
|
||||
}
|
||||
}
|
||||
if end-start <= 4 {
|
||||
valid := true
|
||||
for i := start; i <= end; i++ {
|
||||
if fingering[i] == "x" {
|
||||
continue
|
||||
}
|
||||
fv := atoi(fingering[i])
|
||||
if fv < fret {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
used[fingerKey{fret, true}] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for fret := range frets {
|
||||
found := false
|
||||
for k := range used {
|
||||
if k.fret == fret {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
used[fingerKey{fret, false}] = true
|
||||
}
|
||||
fretStrings[fv] = append(fretStrings[fv], i)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for k := range used {
|
||||
if k.barre {
|
||||
count += 2
|
||||
} else {
|
||||
for fret, strings := range fretStrings {
|
||||
if len(strings) == 1 {
|
||||
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
|
||||
}
|
||||
|
|
@ -439,7 +393,7 @@ func generateMutedVariations(primary []string, tuning []string, intervals map[in
|
|||
if !isValidMuteConfig(test) {
|
||||
return
|
||||
}
|
||||
if countFingers(test) > maxFingers {
|
||||
if countEffectiveFingers(test, len(test)) > maxFingers {
|
||||
return
|
||||
}
|
||||
if isSameChord(test, tuning, intervals) {
|
||||
|
|
@ -486,16 +440,6 @@ func isOpenChord(fingering []string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
func countFingers(fingering []string) int {
|
||||
count := 0
|
||||
for _, f := range fingering {
|
||||
if f != "x" && f != "0" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func fingeringKey(f []string) string {
|
||||
return strings.Join(f, ",")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
const navLinks = document.querySelectorAll('.nav-link');
|
||||
const views = document.querySelectorAll('.view');
|
||||
|
|
@ -12,6 +22,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const tuningGrid = document.getElementById('tuning-grid');
|
||||
const fretsInput = document.getElementById('frets-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 allNotes = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
|
||||
|
|
@ -35,21 +48,149 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
window.currentTuningMIDI = standardMIDI.slice();
|
||||
|
||||
let polySynth = null;
|
||||
window.playChord = function(midiNotes) {
|
||||
Tone.start();
|
||||
if (!polySynth) {
|
||||
polySynth = new Tone.PolySynth(Tone.Synth, {maxPolyphony: 8}).toDestination();
|
||||
polySynth.set({
|
||||
oscillator: {type: 'triangle'},
|
||||
envelope: {attack: 0.01, decay: 0.3, sustain: 0.4, release: 1.0}
|
||||
let audioFilter = null;
|
||||
let audioGain = null;
|
||||
let audioProfile = 15;
|
||||
|
||||
const volSlider = document.getElementById('audio-vol');
|
||||
const profileSlider = document.getElementById('audio-profile');
|
||||
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();
|
||||
const now = Tone.now();
|
||||
midiNotes.forEach((m, i) => {
|
||||
const freq = 440 * Math.pow(2, (m - 69) / 12);
|
||||
polySynth.triggerAttackRelease(freq, '1.5s', now + i * 0.03);
|
||||
const 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();
|
||||
|
||||
if (engine === 'pluck') {
|
||||
if (!pluckSynths) buildPluck();
|
||||
midiNotes.forEach((m, i) => {
|
||||
const freq = 440 * Math.pow(2, (m - 69) / 12);
|
||||
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 = {
|
||||
|
|
@ -69,25 +210,30 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
let currentConfig = null;
|
||||
let chordsLoaded = false;
|
||||
let shapesInited = false;
|
||||
let lastBaselineShift = 0;
|
||||
|
||||
// --- Navigation ---
|
||||
window.switchToView = function(target) {
|
||||
navLinks.forEach(l => l.classList.remove('active'));
|
||||
views.forEach(v => v.classList.remove('active'));
|
||||
const navLink = document.querySelector('.nav-link[data-view="' + target + '"]');
|
||||
if (navLink) navLink.classList.add('active');
|
||||
document.getElementById('view-' + target).classList.add('active');
|
||||
filterSection.style.display = target === 'chords' ? '' : 'none';
|
||||
shapesSection.style.display = (target === 'shapes') ? '' : 'none';
|
||||
|
||||
if (target === 'chords' && !chordsLoaded) {
|
||||
loadChords();
|
||||
}
|
||||
if (target === 'shapes' && !shapesInited) {
|
||||
shapesInited = true;
|
||||
if (window.initShapeExplorer) window.initShapeExplorer();
|
||||
}
|
||||
};
|
||||
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
const target = link.dataset.view;
|
||||
navLinks.forEach(l => l.classList.remove('active'));
|
||||
views.forEach(v => v.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
document.getElementById('view-' + target).classList.add('active');
|
||||
filterSection.style.display = target === 'chords' ? '' : 'none';
|
||||
shapesSection.style.display = target === 'shapes' ? '' : 'none';
|
||||
|
||||
if (target === 'chords' && !chordsLoaded) {
|
||||
loadChords();
|
||||
}
|
||||
if (target === 'shapes' && !shapesInited) {
|
||||
shapesInited = true;
|
||||
if (window.initShapeExplorer) window.initShapeExplorer();
|
||||
}
|
||||
window.switchToView(link.dataset.view);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -135,6 +281,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
function syncConfigPanel(cfg) {
|
||||
fretsInput.value = cfg.frets;
|
||||
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);
|
||||
|
||||
let matched = false;
|
||||
|
|
@ -156,14 +306,35 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
});
|
||||
|
||||
btnApply.addEventListener('click', () => {
|
||||
if (!window.go) return;
|
||||
const cfg = {
|
||||
baselineShiftInput.addEventListener('input', () => {
|
||||
const newShift = parseInt(baselineShiftInput.value) || 0;
|
||||
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',
|
||||
tuning: readTuningFromGrid(),
|
||||
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.textContent = 'Regenerating chords...';
|
||||
|
||||
|
|
@ -182,7 +353,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
btnSave.addEventListener('click', () => {
|
||||
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';
|
||||
setTimeout(() => { btnSave.textContent = 'Save'; }, 1500);
|
||||
}).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 ---
|
||||
function loadChords() {
|
||||
if (!window.go) {
|
||||
|
|
@ -223,6 +409,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
populatePresets();
|
||||
|
||||
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 => {
|
||||
currentConfig = cfg;
|
||||
window.currentTuningMIDI = tuningNamesToMIDI(cfg.tuning);
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@
|
|||
|
||||
.fret {
|
||||
border: .5px solid var(--border, #444746);
|
||||
border-left: 2px solid transparent;
|
||||
border-right: 2px;
|
||||
border-left: 2px;
|
||||
border-right: 2px solid transparent;
|
||||
background: var(--bg-overlay, #282a2c);
|
||||
color: transparent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@
|
|||
|
||||
.fret {
|
||||
border: .5px solid #0002;
|
||||
border-left: 2px solid #fff;
|
||||
border-right: 2px solid #0001;
|
||||
border-left: 2px solid #0001;
|
||||
border-right: 2px solid #fff;
|
||||
background: #fff;
|
||||
color: #fff;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@
|
|||
<div class="header">
|
||||
<h1>Web Tuner</h1>
|
||||
<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>
|
||||
<select id="theme-select">
|
||||
<option value="light">Light</option>
|
||||
|
|
@ -41,7 +54,10 @@
|
|||
</div>
|
||||
|
||||
<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 class="shape-editor-actions">
|
||||
<button id="btn-add-shape" class="btn-small">Add Shape</button>
|
||||
|
|
@ -90,6 +106,20 @@
|
|||
<label for="fingers-input">Max Fingers</label>
|
||||
<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">
|
||||
<button class="btn-apply" id="btn-apply">Apply</button>
|
||||
<button id="btn-save">Save</button>
|
||||
|
|
@ -147,13 +177,29 @@
|
|||
<div id="shapes-results"></div>
|
||||
<div id="shapes-loading" class="loading" style="display:none;"></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>
|
||||
|
||||
<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="tuner.js"></script>
|
||||
<script src="chords.js"></script>
|
||||
<script src="shapes.js"></script>
|
||||
<script src="sets-editor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,48 @@ body {
|
|||
--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 button {
|
||||
font: inherit;
|
||||
|
|
@ -441,6 +483,22 @@ body {
|
|||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -704,6 +762,25 @@ body {
|
|||
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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
|
@ -751,6 +828,318 @@ body {
|
|||
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 --- */
|
||||
@media print {
|
||||
.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 resultsContainer = document.getElementById('shapes-results');
|
||||
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'];
|
||||
let scoreSetsData = { sets: [], selected: 0 };
|
||||
let shapes = [];
|
||||
let selectedIndex = 0;
|
||||
let editingIndex = -1;
|
||||
|
||||
function activeSet() {
|
||||
return scoreSetsData.sets[scoreSetsData.selected];
|
||||
}
|
||||
|
||||
function syncShapesRef() {
|
||||
const set = activeSet();
|
||||
shapes = set ? set.shapes : [];
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!window.go || !window.go.main || !window.go.main.App) return;
|
||||
|
||||
window.go.main.App.GetDefaultShapes().then(defaults => {
|
||||
shapes = defaults;
|
||||
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 => {
|
||||
scoreSetsData.sets[0].shapes = defaults;
|
||||
syncShapesRef();
|
||||
renderSetSelector();
|
||||
renderShapeList();
|
||||
buildVoicingInputs();
|
||||
});
|
||||
return;
|
||||
}
|
||||
syncShapesRef();
|
||||
selectedIndex = 0;
|
||||
renderSetSelector();
|
||||
renderShapeList();
|
||||
buildVoicingInputs();
|
||||
});
|
||||
|
|
@ -55,6 +79,17 @@
|
|||
btnEditorSave.addEventListener('click', saveEditor);
|
||||
btnEditorCancel.addEventListener('click', closeEditor);
|
||||
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) => {
|
||||
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() {
|
||||
shapeList.innerHTML = '';
|
||||
shapes.forEach((s, i) => {
|
||||
|
|
@ -80,6 +158,38 @@
|
|||
const actions = document.createElement('span');
|
||||
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');
|
||||
editBtn.textContent = 'edit';
|
||||
editBtn.addEventListener('click', e => { e.stopPropagation(); openEditor(i); });
|
||||
|
|
@ -92,6 +202,7 @@
|
|||
if (selectedIndex >= shapes.length) selectedIndex = Math.max(0, shapes.length - 1);
|
||||
renderShapeList();
|
||||
buildVoicingInputs();
|
||||
persistSets();
|
||||
});
|
||||
|
||||
actions.appendChild(editBtn);
|
||||
|
|
@ -191,27 +302,20 @@
|
|||
closeEditor();
|
||||
renderShapeList();
|
||||
buildVoicingInputs();
|
||||
persistSets();
|
||||
}
|
||||
|
||||
function restoreDefaults() {
|
||||
if (!window.go) return;
|
||||
const currentSearch = shapes[selectedIndex];
|
||||
window.go.main.App.GetDefaultShapes().then(defaults => {
|
||||
shapes = defaults;
|
||||
// preserve search shape if custom
|
||||
if (currentSearch) {
|
||||
const exists = shapes.some(s => s.name === currentSearch.name);
|
||||
if (!exists) {
|
||||
shapes.push(currentSearch);
|
||||
selectedIndex = shapes.length - 1;
|
||||
} else {
|
||||
selectedIndex = shapes.findIndex(s => s.name === currentSearch.name);
|
||||
}
|
||||
} else {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
shapes.length = 0;
|
||||
defaults.forEach(s => shapes.push(s));
|
||||
const set = activeSet();
|
||||
if (set) set.shapes = shapes;
|
||||
selectedIndex = 0;
|
||||
renderShapeList();
|
||||
buildVoicingInputs();
|
||||
persistSets();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -230,21 +334,36 @@
|
|||
shape: shape,
|
||||
target_quality: qualitySelect.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.textContent = 'Searching tunings...';
|
||||
resultsContainer.innerHTML = '';
|
||||
|
||||
window.go.main.App.FindShapeTunings(query, shapes).then(results => {
|
||||
loadingEl.style.display = 'none';
|
||||
renderResults(results || [], shape.name);
|
||||
}).catch(err => {
|
||||
loadingEl.style.display = 'none';
|
||||
loadingEl.style.display = '';
|
||||
loadingEl.textContent = 'Error: ' + err;
|
||||
});
|
||||
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 => {
|
||||
loadingEl.style.display = 'none';
|
||||
renderResults(results || [], shape.name);
|
||||
}).catch(err => {
|
||||
loadingEl.style.display = 'none';
|
||||
loadingEl.style.display = '';
|
||||
loadingEl.textContent = 'Error: ' + err;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
|
@ -391,6 +510,170 @@
|
|||
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(body);
|
||||
return card;
|
||||
|
|
@ -502,4 +785,14 @@
|
|||
}
|
||||
|
||||
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"`
|
||||
TargetRoot int `json:"target_root"` // 0-11 or -1 for any
|
||||
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 {
|
||||
|
|
@ -112,10 +116,9 @@ func parsePitchInput(s string) (midi int, pc int, err error) {
|
|||
return midi, sem, nil
|
||||
}
|
||||
|
||||
func closestMIDIInRange(pc, standard int) (int, bool) {
|
||||
lo := standard - 7
|
||||
hi := standard + 7
|
||||
// find MIDI note with pitch class pc closest to standard within range
|
||||
func closestMIDIInRange(pc, standard, down, up int) (int, bool) {
|
||||
lo := standard - down
|
||||
hi := standard + up
|
||||
best := -1
|
||||
bestDist := 999
|
||||
for m := lo; m <= hi; m++ {
|
||||
|
|
@ -136,14 +139,26 @@ func closestMIDIInRange(pc, standard int) (int, bool) {
|
|||
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
|
||||
nStrings := len(shape)
|
||||
if nStrings != 6 {
|
||||
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
|
||||
defs := GetChordDefinitions()
|
||||
found := false
|
||||
|
|
@ -158,7 +173,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
|||
return nil, fmt.Errorf("unknown quality: %s", query.TargetQuality)
|
||||
}
|
||||
|
||||
// parse voicing pins
|
||||
type pinInfo struct {
|
||||
midi int
|
||||
pc int
|
||||
|
|
@ -174,7 +188,7 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
|||
continue
|
||||
}
|
||||
if shape[i] == -1 {
|
||||
continue // muted string, ignore voicing
|
||||
continue
|
||||
}
|
||||
m, p, err := parsePitchInput(v)
|
||||
if err != nil {
|
||||
|
|
@ -184,7 +198,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
|||
}
|
||||
}
|
||||
|
||||
// determine candidate roots
|
||||
var roots []int
|
||||
if query.TargetRoot >= 0 && query.TargetRoot < 12 {
|
||||
roots = []int{query.TargetRoot}
|
||||
|
|
@ -195,13 +208,7 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
|||
}
|
||||
}
|
||||
|
||||
// chord pitch classes for each root
|
||||
type tuningResult struct {
|
||||
tuningPCs []int
|
||||
tuningMIDI []int
|
||||
root int
|
||||
}
|
||||
var results []tuningResult
|
||||
var results []candidateTuning
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, root := range roots {
|
||||
|
|
@ -210,7 +217,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
|||
chordPCs[(root+iv)%12] = true
|
||||
}
|
||||
|
||||
// for each non-muted string, compute candidate open note PCs
|
||||
type stringCandidates struct {
|
||||
pcs []int
|
||||
}
|
||||
|
|
@ -220,12 +226,11 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
|||
for s := 0; s < nStrings; s++ {
|
||||
if shape[s] == -1 {
|
||||
muted[s] = true
|
||||
candidates[s] = stringCandidates{[]int{standardMIDI[s] % 12}}
|
||||
candidates[s] = stringCandidates{[]int{baseMIDI[s] % 12}}
|
||||
continue
|
||||
}
|
||||
|
||||
if pins[s].pc >= 0 {
|
||||
// pinned: open note = pinned_midi - shape_fret
|
||||
openPC := (pins[s].pc - shape[s] + 120) % 12
|
||||
candidates[s] = stringCandidates{[]int{openPC}}
|
||||
continue
|
||||
|
|
@ -239,7 +244,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
|||
candidates[s] = stringCandidates{cands}
|
||||
}
|
||||
|
||||
// cartesian product
|
||||
indices := make([]int, nStrings)
|
||||
sizes := make([]int, nStrings)
|
||||
total := 1
|
||||
|
|
@ -260,7 +264,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
|||
tuningPCs[s] = candidates[s].pcs[indices[s]]
|
||||
}
|
||||
|
||||
// check all chord tones present in voicing (non-muted strings)
|
||||
voicedPCs := make(map[int]bool)
|
||||
for s := 0; s < nStrings; s++ {
|
||||
if !muted[s] {
|
||||
|
|
@ -279,7 +282,6 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
|||
continue
|
||||
}
|
||||
|
||||
// resolve MIDI pitches with tension check
|
||||
tuningMIDI := make([]int, nStrings)
|
||||
valid := true
|
||||
for s := 0; s < nStrings; s++ {
|
||||
|
|
@ -293,18 +295,16 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
|||
valid = false
|
||||
break
|
||||
}
|
||||
std := standardMIDI[s]
|
||||
diff := openMIDI - std
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 7 {
|
||||
base := baseMIDI[s]
|
||||
diff := openMIDI - base
|
||||
if diff < -rangeDown || diff > rangeUp {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
tuningMIDI[s] = openMIDI
|
||||
} else {
|
||||
m, ok := closestMIDIInRange(tuningPCs[s], standardMIDI[s])
|
||||
base := baseMIDI[s]
|
||||
m, ok := closestMIDIInRange(tuningPCs[s], base, rangeDown, rangeUp)
|
||||
if !ok {
|
||||
valid = false
|
||||
break
|
||||
|
|
@ -319,16 +319,23 @@ func findTuningsForShape(query ShapeQuery, allShapes []ShapeDefinition) ([]Tunin
|
|||
key := fmt.Sprint(tuningMIDI)
|
||||
if !seen[key] {
|
||||
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
|
||||
for _, r := range deduped {
|
||||
for _, r := range candidates {
|
||||
tuningNames := make([]string, nStrings)
|
||||
for s := 0; s < nStrings; s++ {
|
||||
tuningNames[s] = midiToNoteName(r.tuningMIDI[s])
|
||||
|
|
@ -516,6 +523,84 @@ func identifyCompanions(tuningMIDI []int, shapes []ShapeDefinition, searchShapeN
|
|||
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 {
|
||||
pc := midi % 12
|
||||
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