Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,14 @@ Command-line utility and jazz music theory engine to analyze jazz standards, tra

### 1. Terminal Harmonic Analysis (`jazz-tools analyze`)

Analyze any MusicXML file or iReal Pro URL/HTML export to inspect chord holds, voice-led guide tones, and tonal journey:
Analyze any MusicXML file, iReal Pro URL, or search directly by song title from the built-in catalog of **1,400 Jazz Standards**:

```bash
# Direct title lookup from built-in 1,400 standards library:
jazz-tools analyze "Autumn Leaves"
jazz-tools analyze "Giant Steps"

# Or analyze a local file or iReal URL:
jazz-tools analyze Blue_Bossa.musicxml
```

Expand Down Expand Up @@ -112,6 +117,8 @@ jazz-tools serve --port 8080
```

Open [http://localhost:8080](http://localhost:8080) in your browser:

- **Instant Search across 1,400 Jazz Standards**: Search by song name or composer (e.g. *Giant Steps*, *Autumn Leaves*, *Bill Evans*) with real-time autocompletion and one-click loading.
- **Interactive In-Browser Notation**: Renders companion scores directly in SVG with OpenSheetMusicDisplay.
- **Harmonic Modulation Trajectory**: Visual colored timeline of key changes and Berklee harmonic devices.
- **Multi-Source Ingestion**: One-click demo tunes, paste `irealb://` links, or upload `.musicxml` files.
Expand Down
1 change: 1 addition & 0 deletions internal/jazz/jazz1400.txt

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion internal/jazz/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ func LoadTune(target string) (*Tune, error) {

// Check if file exists
if _, err := os.Stat(target); err != nil {
// Check if target matches a built-in jazz standard title
if std, ok := FindStandardByExactTitle(target); ok {
return LoadTune(std.URL)
}
if matches := SearchStandards(target, 2); len(matches) == 1 {
return LoadTune(matches[0].URL)
}
return nil, fmt.Errorf("file not found: %s", target)
}

Expand Down Expand Up @@ -82,5 +89,12 @@ func ParseTuneData(data string) (*Tune, error) {
return ParseMusicXML(strings.NewReader(trimmed))
}

return nil, fmt.Errorf("unrecognized format: expected iReal URL or MusicXML content")
if std, ok := FindStandardByExactTitle(trimmed); ok {
return LoadTune(std.URL)
}
if matches := SearchStandards(trimmed, 2); len(matches) == 1 {
return LoadTune(matches[0].URL)
}

return nil, fmt.Errorf("unrecognized format: expected iReal URL, standard song name, or MusicXML content")
}
128 changes: 128 additions & 0 deletions internal/jazz/standards.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package jazz

import (
_ "embed"
"fmt"
"net/url"
"strings"
"sync"
)

//go:embed jazz1400.txt
var jazz1400Data string

// StandardTune represents an entry in the built-in standards catalog.
type StandardTune struct {
ID string `json:"id"`
Title string `json:"title"`
Composer string `json:"composer"`
Style string `json:"style"`
Key string `json:"key"`
URL string `json:"url"`
}

var (
standardsCache []StandardTune
standardsOnce sync.Once
)

// GetAllStandards parses and returns all built-in jazz standards.
func GetAllStandards() []StandardTune {
standardsOnce.Do(func() {
raw := strings.TrimSpace(jazz1400Data)
if idx := strings.Index(raw, "irealb://"); idx != -1 {
raw = raw[idx+len("irealb://"):]
}

chunks := strings.Split(raw, "===")
var list []StandardTune
for i, chunk := range chunks {
chunk = strings.TrimSpace(chunk)
if chunk == "" || strings.Contains(chunk, "Jazz 1400") || strings.Contains(chunk, "%4A%61%7A%7A%20%31%34%30%30") {
continue
}

decoded, err := url.QueryUnescape(chunk)
if err != nil {
continue
}

parts := strings.Split(decoded, "=")
if len(parts) >= 7 {
title := strings.TrimSpace(parts[0])
composer := strings.TrimSpace(parts[1])
style := ""
if len(parts) > 3 {
style = strings.TrimSpace(parts[3])
}
key := ""
if len(parts) > 4 {
key = strings.TrimSpace(parts[4])
}
music := strings.TrimSpace(parts[6])

if title != "" && music != "" {
singleURL := "irealb://" + chunk + "==="
list = append(list, StandardTune{
ID: fmt.Sprintf("std-%d", i+1),
Title: title,
Composer: composer,
Style: style,
Key: key,
URL: singleURL,
})
}
}
}
standardsCache = list
})
return standardsCache
}

// SearchStandards searches standards by title or composer.
func SearchStandards(query string, limit int) []StandardTune {
all := GetAllStandards()
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
if limit > 0 && len(all) > limit {
return all[:limit]
}
return all
}

var exactMatches []StandardTune
var prefixMatches []StandardTune
var substringMatches []StandardTune

for _, s := range all {
tLower := strings.ToLower(s.Title)
cLower := strings.ToLower(s.Composer)

if tLower == q {
exactMatches = append(exactMatches, s)
} else if strings.HasPrefix(tLower, q) {
prefixMatches = append(prefixMatches, s)
} else if strings.Contains(tLower, q) || strings.Contains(cLower, q) {
substringMatches = append(substringMatches, s)
}
}

results := append(exactMatches, prefixMatches...)
results = append(results, substringMatches...)

if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results
}

// FindStandardByExactTitle searches for an exact title match (case-insensitive).
func FindStandardByExactTitle(title string) (*StandardTune, bool) {
q := strings.ToLower(strings.TrimSpace(title))
for _, s := range GetAllStandards() {
if strings.ToLower(s.Title) == q {
return &s, true
}
}
return nil, false
}
46 changes: 46 additions & 0 deletions internal/jazz/standards_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package jazz

import (
"testing"
)

func TestGetAllStandards(t *testing.T) {
standards := GetAllStandards()
if len(standards) < 1300 {
t.Fatalf("expected at least 1300 standards, got %d", len(standards))
}
}

func TestSearchStandards(t *testing.T) {
results := SearchStandards("Autumn", 10)
if len(results) == 0 {
t.Fatalf("expected search results for 'Autumn', got 0")
}

foundLeaves := false
for _, s := range results {
if s.Title == "Autumn Leaves" {
foundLeaves = true
if s.Key != "G-" {
t.Errorf("expected Autumn Leaves key G-, got %s", s.Key)
}
break
}
}
if !foundLeaves {
t.Errorf("Autumn Leaves not found in search results for 'Autumn'")
}
}

func TestLoadTuneByStandardTitle(t *testing.T) {
tune, err := LoadTune("Autumn Leaves")
if err != nil {
t.Fatalf("LoadTune by title failed: %v", err)
}
if tune.Title != "Autumn Leaves" {
t.Errorf("expected title 'Autumn Leaves', got '%s'", tune.Title)
}
if len(tune.Measures) == 0 {
t.Errorf("expected measures in Autumn Leaves, got 0")
}
}
33 changes: 30 additions & 3 deletions internal/server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"

"jazz-tools/internal/jazz"
Expand Down Expand Up @@ -77,6 +78,10 @@ func (s *Server) parseTuneFromRequest(r *http.Request) (*jazz.Tune, error) {
return nil, fmt.Errorf("failed to parse multipart form: %w", err)
}

if standard := r.FormValue("standard"); standard != "" {
return jazz.LoadTune(standard)
}

if sampleID := r.FormValue("sample"); sampleID != "" {
return s.loadSampleTune(sampleID)
}
Expand All @@ -99,11 +104,15 @@ func (s *Server) parseTuneFromRequest(r *http.Request) (*jazz.Tune, error) {
// 2. JSON Body
if strings.HasPrefix(contentType, "application/json") || r.Body != nil {
var req struct {
Content string `json:"content"`
Sample string `json:"sample"`
Content string `json:"content"`
Sample string `json:"sample"`
Standard string `json:"standard"`
}
dec := json.NewDecoder(r.Body)
if err := dec.Decode(&req); err == nil {
if req.Standard != "" {
return jazz.LoadTune(req.Standard)
}
if req.Sample != "" {
return s.loadSampleTune(req.Sample)
}
Expand All @@ -114,11 +123,14 @@ func (s *Server) parseTuneFromRequest(r *http.Request) (*jazz.Tune, error) {
}

// 3. Fallback to query params (useful for GET / test queries)
if standard := r.URL.Query().Get("standard"); standard != "" {
return jazz.LoadTune(standard)
}
if sampleID := r.URL.Query().Get("sample"); sampleID != "" {
return s.loadSampleTune(sampleID)
}

return nil, fmt.Errorf("no tune content provided (provide 'content', upload a 'file', or select a 'sample')")
return nil, fmt.Errorf("no tune content provided (provide 'content', upload a 'file', select a 'sample', or search a 'standard')")
}

func (s *Server) loadSampleTune(id string) (*jazz.Tune, error) {
Expand All @@ -140,6 +152,21 @@ func (s *Server) handleSamples(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(sampleTunes)
}

func (s *Server) handleStandards(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
limitStr := r.URL.Query().Get("limit")
limit := 30
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}

results := jazz.SearchStandards(q, limit)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(results)
}

func (s *Server) handleAnalyze(w http.ResponseWriter, r *http.Request) {
tune, err := s.parseTuneFromRequest(r)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func (s *Server) registerRoutes() {
_, _ = w.Write([]byte(`{"status":"ok","version":"1.0.0"}`))
})
s.mux.HandleFunc("GET /api/samples", s.handleSamples)
s.mux.HandleFunc("GET /api/standards", s.handleStandards)
s.mux.HandleFunc("POST /api/analyze", s.handleAnalyze)
s.mux.HandleFunc("POST /api/companion/xml", s.handleCompanionXML)
s.mux.HandleFunc("POST /api/companion/pdf", s.handleCompanionPDF)
Expand Down
60 changes: 60 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"net/http/httptest"
"strings"
"testing"

"jazz-tools/internal/jazz"
)

func newTestServer(t *testing.T) *Server {
Expand Down Expand Up @@ -98,3 +100,61 @@ func TestCompanionXMLEndpoint(t *testing.T) {
t.Errorf("expected valid MusicXML score-partwise, got: %s", xmlStr)
}
}

func TestStandardsEndpoint(t *testing.T) {
s := newTestServer(t)
req := httptest.NewRequest("GET", "/api/standards?q=Autumn", nil)
rec := httptest.NewRecorder()

s.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}

var results []jazz.StandardTune
if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil {
t.Fatalf("failed to decode standards: %v", err)
}

if len(results) == 0 {
t.Fatalf("expected at least 1 match for 'Autumn', got 0")
}

found := false
for _, tune := range results {
if tune.Title == "Autumn Leaves" {
found = true
break
}
}
if !found {
t.Errorf("expected to find Autumn Leaves in search results")
}
}

func TestAnalyzeEndpoint_StandardAutumnLeaves(t *testing.T) {
s := newTestServer(t)
body := strings.NewReader(`{"standard":"Autumn Leaves"}`)
req := httptest.NewRequest("POST", "/api/analyze", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()

s.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}

var resp AnalyzeResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal analyze response: %v", err)
}

if resp.Title != "Autumn Leaves" {
t.Errorf("expected Autumn Leaves, got %s", resp.Title)
}
if len(resp.Measures) == 0 {
t.Errorf("expected measures in Autumn Leaves, got 0")
}
}
Loading
Loading