This might be the final version, it works perfectly as far as I can tell at this point.

This commit is contained in:
pszsh 2026-03-04 04:45:29 -08:00
parent ad1d10f266
commit 10962dca23
20 changed files with 3317 additions and 202 deletions

65
app.go
View File

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

View File

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

View File

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

View File

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

249
density.go Normal file
View 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
}
}

View File

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

229
frontend/dist/app.js vendored
View File

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

View File

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

View File

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

View File

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

View File

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

403
frontend/dist/sets-editor.js vendored Normal file
View File

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

View File

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

1222
frontend/dist/vendor/WebAudioFontPlayer.js vendored Normal file

File diff suppressed because one or more lines are too long

46
frontend/dist/vendor/guitar_sample.js vendored Normal file

File diff suppressed because one or more lines are too long

11
release.go Normal file
View File

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

54
scoresets.go Normal file
View File

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

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

89
shift_test.go Normal file
View File

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