Compare commits

...

19 Commits

Author SHA1 Message Date
david 7e9419e119 fix: validate ISO path is a file, not a directory; clear default value 2026-06-22 05:14:18 -07:00
david ebce8fded8 fix: return empty arrays instead of null in listPartitions, listVolumes, handleDiskVolumes 2026-06-22 04:52:55 -07:00
david 190c79487f fix: return empty array instead of null for tagless volumes 2026-06-22 04:47:29 -07:00
david a489d523e3 fix: add null/HTTP checks to tag viewer API calls
- browseISO: resp.ok + data null check before accessing data.error
- selectPartition: resp.ok + data null check
- viewVolumeTags: resp.ok + data null check
- Fixed v.Name → v.name (JSON keys are lowercase from volInfo tags)

Prevents 'Cannot read properties of null (reading error)'
2026-06-22 04:20:01 -07:00
david 568691542a feat: Sample Tag Viewer — read-only tag browser for AKAI disk images
Backend:
- extract.go: listTags() navigates to volume, runs akaiutil lstags
- extract.go: parseTags() parses tag name/index from lstags output
- serve.go: GET /api/disk/partitions — list partitions on an ISO
- serve.go: GET /api/disk/volumes — list volumes on a partition
- serve.go: GET /api/volume/tags — list tags on a volume

Web UI:
- Tag Viewer section with ISO path input and Browse button
- Partition selector buttons
- Volume grid with click-to-select
- Tag chips with color-coded dots (20-color palette)

Tests:
- 6 parseTags tests (basic, spaces, defaults, no tags, empty, no-type)
- 1 listTags integration test
- 4 API endpoint tests (partitions, missing-ISO, missing-params, tags)

Karpathy: read-only view first — no write operations yet (settagi, clrtagi)
2026-06-22 03:21:54 -07:00
david f804ad8a41 feat: expanded toolchain check + install-all task
check-toolchain.sh now covers:
- golangci-lint
- npm
- web/ui node_modules (JS deps)
- electron node_modules (electron deps)

New install-all task (scripts/install-deps.sh):
- mise install (go, node, golangci-lint)
- cd web/ui && npm install
- cd electron && npm install

Usage:
  mise run check-toolchain   # audit everything
  mise run install-all       # install all deps from scratch
2026-06-22 03:08:40 -07:00
david 669d3abe27 feat: collapsible section and libraries, compact card layout
- Sample Library section now collapsible via header click
- Library cards collapsible via header click (toggle icon ▾)
- Collapse All button to batch-collapse all libraries
- Compact sample cards: 28px height, 10px font, 70px min grid column
- Tighter spacing throughout (8px lib gap, 4px sample grid gap)
- Play icon removed from cards (card itself is clickable)
2026-06-22 02:52:24 -07:00
david 7124b16fc1 docs: update AGENTS.md and README for new features, add JS tests
Docs:
- AGENTS.md: updated toolchain section with js-lint/js-test tasks,
  expanded web/ui/ directory listing, added event delegation and
  Web Audio API to key patterns, updated testing section
- README: marked issue #9 (Audio Preview Player) as done
- .gitignore: added akai-fetch.test and cover.out

Tests:
- Moved groupWavs and cleanName to util.js (pure functions)
- Added 10 new JS tests: 6 groupWavs, 4 cleanName
- Total JS tests: 29 (up from 19)
2026-06-22 02:46:34 -07:00
david e0bad5e5ac feat: card-based Sample Library layout with grouping
Redesigned WAV Browser as a structured Sample Library with card layout:
- WAVs grouped by library (ISO name) and program (volume name)
- Library cards with collapsible headers showing program/sample counts
- Program sections within each library
- Sample cards in a compact grid with play-on-click

Updated playWav to show a clean sample name in the player bar,
hide the player when playback ends, and use sample-card selectors
for active-state highlighting.

Clean display names (underscores → spaces, stripped .wav extensions)
2026-06-22 02:35:55 -07:00
david b5650c53ba test: add JS test framework using Node built-in test runner
Extracted pure utility functions (esc, sanitize, formatBytes, formatTime)
into web/ui/util.js with CommonJS export for Node testing compatibility.
Added 19 tests across 4 suites using node:test + node:assert (zero deps).

- util.js: browser-compatible utility functions
- util.test.js: 19 tests (5 esc, 3 sanitize, 6 formatBytes, 5 formatTime)
- package.json: 'test' script → node --test util.test.js
- mise.toml: js-test task
- app.js: removed duplicate helper functions (now in util.js)
- index.html: load util.js before app.js
2026-06-22 02:23:24 -07:00
david d7a17eb054 fix: show extract API errors in UI instead of false 'Extraction complete'
doExtract() only checked data.message, so JSON responses with
error key (e.g. 'no ISO files found') would show green success.
Now checks data.error first and displays it in red.
2026-06-22 01:10:40 -07:00
david fe3e92ce33 feat: recursive WAV browsing in subdirectories
Handle ListWavs now uses filepath.WalkDir to find .wav files
in all subdirectories. Returns relative paths (e.g. Kicks/kick.wav).
Serves them correctly via /wavs/ prefix.

Fixed: WalkDir swallows root errors differently than ReadDir —
now detects and reports non-existent root directory errors.

Added test for recursive subdirectory traversal.
2026-06-22 01:05:44 -07:00
david 371feaf0cb fix: initialize files as empty slice instead of nil var
var files []string marshals to JSON null, breaking the frontend
which expects an array. Using files := []string{} ensures [].
2026-06-22 00:46:30 -07:00
david b69e1d81ab debug: add console logging to browseWavs and playWav 2026-06-22 00:42:18 -07:00
david cd8484de0c fix: pin electron to exact version 33.4.11
electron-builder requires an exact version (not a range like ^33.0.0)
because it downloads platform-specific binaries for a specific release.
2026-06-22 00:22:19 -07:00
david d194a25bf8 chore: pin node to 22.12 to fix electron-build engine requirements
@electron/rebuild and node-abi require node >=22.12.0.
System node (22.9.0) caused EBADENGINE errors during electron-build.
2026-06-22 00:16:42 -07:00
david 4f03524668 chore: add JS/ESLint toolchain, refactor onclick to event delegation
- Add ESLint config for web/ui (flat config, golangci-lint-compatible)
- Add js-lint task to mise.toml (npm run lint in web/ui)
- Add ESLint check to check-toolchain.sh
- Add .golangci.yml for golangci-lint v2 with correct exclusions
- Refactor app.js: replace inline onclick with data-action + event delegation
- Add null checks and HTTP status checks to browseWavs
- Add web/ui/node_modules/ to .gitignore
2026-06-22 00:10:49 -07:00
david f716902580 fix: handle null data in browseWavs, add HTTP status check
Avoid 'Cannot read properties of null (reading error)' when server
returns null or non-JSON. Also check resp.ok before parsing JSON.
2026-06-22 00:02:32 -07:00
david cf53912a33 feat: Audio Preview Player — browse and play WAV samples via Web Audio API
Issue: #9

Backend:
- GET /api/list-wavs?dir=<path> — lists .wav files in a directory
- GET /wavs/<file> — static file serving from ./output/wavs

Web UI:
- WAV Browser section with path input + Browse button
- AudioContext-based playback (zero deps) — progress bar, time display
- WAV file grid with Play buttons, active item highlighting

Tests:
- 3 new tests for handleListWavs (wav files, empty dir, nonexistent dir)
- Fix TestFindAkaiutil: add t.Chdir(tmp) so relative path lookups don't
  accidentally find the repo's third_party/akaiutil/akaiutil binary
2026-06-22 00:01:15 -07:00
21 changed files with 2745 additions and 31 deletions
+5
View File
@@ -25,5 +25,10 @@ wavs
.DS_Store
Thumbs.db
# JS dependencies
web/ui/node_modules/
# Temp
/tmp/
akai-fetch.test
cover.out
+14
View File
@@ -0,0 +1,14 @@
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- staticcheck
- unused
exclusions:
paths:
- web/ui
- electron
- third_party/akaiutil/
+20 -7
View File
@@ -16,7 +16,9 @@ mise run serve # → starts HTTP server, opens browser
mise run electron-deps # installs electron + electron-builder
mise run electron-dev # runs electron in dev mode
mise run docker-up # docker compose up --build
mise run lint # go vet ./...
mise run lint # golangci-lint run (Go)
mise run js-lint # eslint (JS)
mise run js-test # node --test (JS)
mise run check-all # verify gcc, brew, mise, go, node, docker, colima
```
@@ -55,7 +57,14 @@ Single binary (`fetch`) with subcommands. No external Go dependencies — pure s
```
main.go CLI entry, commands (search/list/download/extract/pipeline/serve)
serve.go HTTP server + REST API + embedded web UI (embed.FS)
web/ui/ Static frontend (index.html, style.css, app.js)
web/ui/ Static frontend
├── index.html HTML layout
├── style.css CSS custom properties theme
├── util.js shared utility functions
├── app.js application logic, event delegation
├── util.test.js JS tests (node --test)
├── eslint.config.js ESLint flat config
└── package.json ESLint + test scripts
electron/ Electron wrapper shell
scripts/ Bash helpers called via exec
├── extract_wavs.sh batch WAV extraction via akaiutil
@@ -70,8 +79,9 @@ third_party/ Vendored akaiutil C binary (GPLv2)
- **Flag parsing**: each command creates its own `flag.NewFlagSet` — avoids global flag state
- **Concurrency**: semaphore channel pattern (`sem := make(chan struct{}, N)`) for bounded parallelism
- **SSE progress**: `serve.go:handleProgress` — 500ms ticker, EventSource stream, auto-closes when all done
- **Event delegation**: `app.js` — single click listener on document, dispatches by `[data-action]` attribute (download, list, play)
- **akaiutil wrapper**: `extract.go` — persistent stdin/stdout pipes, no bash intermediary
- **Zero-deps web UI**: vanilla JS, no bundler, no framework. API calls use `fetch()` + `EventSource`
- **Zero-deps web UI**: vanilla JS, no bundler, no framework. API calls use `fetch()` + `EventSource`. Web Audio API for WAV playback.
- **Embed directive**: `//go:embed web/ui/*` in `serve.go` — frontend baked into the binary
- **Bind address handling**: `serve.go:37``--host` flag defaults to `127.0.0.1` for local dev; set `--host 0.0.0.0` for Docker. When binding to `0.0.0.0`, prints both `localhost` and `<host-ip>` URLs for clarity.
@@ -124,12 +134,15 @@ All akaiutil calls use `-r` (read-only) flag. Write operations (S900 compress, p
## Testing
No test framework yet. Manual verification:
```bash
go build -o /dev/null . # compile check
# Go
go test -cover # unit tests (extract, serve, http, download, cli, util)
go vet ./... # static analysis
golangci-lint run # comprehensive lint
./akai-fetch serve # manual smoke test
golangci-lint run # comprehensive lint (uses .golangci.yml)
# JavaScript
mise run js-test # node --test (19 tests: esc, sanitize, formatBytes, formatTime)
mise run js-lint # eslint (flat config)
```
## Secrets & Tokens
+1 -1
View File
@@ -110,7 +110,7 @@ See [open issues](https://git.notsosm.art/david/akai-utils/issues).
| #6 | Direct-to-Disk Take Extraction | todo |
| #7 | TAR Bulk Import/Export | todo |
| #8 | Sample Tag Editor | todo |
| #9 | Audio Preview Player | todo |
| #9 | Audio Preview Player | done |
## License
+1 -1
View File
@@ -60,7 +60,7 @@
"build:linux": "electron-builder --linux"
},
"devDependencies": {
"electron": "^33.0.0",
"electron": "33.4.11",
"electron-builder": "^25.0.0"
}
}
+55 -4
View File
@@ -71,8 +71,8 @@ var runAkaiutil = func(isoPath, commands string) (string, error) {
}
type volInfo struct {
Part string
Name string
Part string `json:"part"`
Name string `json:"name"`
}
func listPartitions(isoPath string) ([]string, error) {
@@ -82,7 +82,7 @@ func listPartitions(isoPath string) ([]string, error) {
}
re := regexp.MustCompile(`^\s+([A-Z])\s+`)
var parts []string
parts := []string{}
for _, line := range strings.Split(out, "\n") {
if m := re.FindStringSubmatch(line); m != nil {
parts = append(parts, m[1])
@@ -110,7 +110,7 @@ func listVolumes(isoPath string, partitions []string) ([]volInfo, error) {
rePrompt := regexp.MustCompile(`/disk0/([A-Z])\s*>`)
reEntry := regexp.MustCompile(`^\s+\d+\s+(.+?)\s+-\s+`)
var vols []volInfo
vols := []volInfo{}
currentPart := ""
for _, line := range strings.Split(out, "\n") {
@@ -210,3 +210,54 @@ var extractISO = func(isoPath, outDir string) (string, error) {
}
return fmt.Sprintf("Total WAV files: %d (%s)", total, elapsed), nil
}
type TagInfo struct {
Index int `json:"index"`
Name string `json:"name"`
}
func listTags(isoPath, part, volName string) ([]TagInfo, error) {
var cmds strings.Builder
fmt.Fprintf(&cmds, "df\n")
fmt.Fprintf(&cmds, "cd /disk0/%s\n", part)
nav := strings.ReplaceAll(volName, " ", "_")
fmt.Fprintf(&cmds, "cd /disk0/%s/%s\n", part, nav)
fmt.Fprintf(&cmds, "lstags\n")
cmds.WriteString("q\n")
out, err := runAkaiutil(isoPath, cmds.String())
if err != nil {
return nil, fmt.Errorf("list tags: %w", err)
}
return parseTags(out), nil
}
func parseTags(output string) []TagInfo {
reTag := regexp.MustCompile(`^\s*(\d+)\s+(\S.*)`)
tags := []TagInfo{}
inTags := false
for _, line := range strings.Split(output, "\n") {
trimmed := strings.TrimRight(line, " \r")
if strings.Contains(trimmed, "tag name") {
inTags = true
continue
}
if !inTags {
continue
}
if strings.HasPrefix(trimmed, "---") && len(tags) > 0 {
break
}
if m := reTag.FindStringSubmatch(trimmed); m != nil {
idx, _ := strconv.Atoi(m[1])
name := strings.TrimSpace(m[2])
if name != "" {
tags = append(tags, TagInfo{Index: idx, Name: name})
}
}
}
return tags
}
+113
View File
@@ -368,3 +368,116 @@ func TestRunExtraction_CreatesVolumeDirs(t *testing.T) {
t.Error("expected lcd / to be in commands")
}
}
func TestParseTags(t *testing.T) {
t.Run("parses basic tag list", func(t *testing.T) {
out := `
tag name
-----------------
01 Drums
02 Bass
03 FX
04 Vox
05 Percussion
-----------------
`
tags := parseTags(out)
if len(tags) != 5 {
t.Fatalf("expected 5 tags, got %d", len(tags))
}
if tags[0].Index != 1 || tags[0].Name != "Drums" {
t.Errorf("tag 0: got {%d %s}, want {1 Drums}", tags[0].Index, tags[0].Name)
}
if tags[4].Index != 5 || tags[4].Name != "Percussion" {
t.Errorf("tag 4: got {%d %s}, want {5 Percussion}", tags[4].Index, tags[4].Name)
}
})
t.Run("parses tags with spaces in names", func(t *testing.T) {
out := `
tag name
-----------------
01 Kick Drum
02 Hi Hat
03 Snare Drum
-----------------
`
tags := parseTags(out)
if len(tags) != 3 {
t.Fatalf("expected 3 tags, got %d", len(tags))
}
if tags[0].Name != "Kick Drum" {
t.Errorf("got %q, want 'Kick Drum'", tags[0].Name)
}
})
t.Run("handles default tag names from akaiutil", func(t *testing.T) {
out := `
tag name
-----------------
01 TAG A
02 TAG B
03 MyTag
-----------------
`
tags := parseTags(out)
if len(tags) != 3 {
t.Fatalf("expected 3 tags, got %d", len(tags))
}
})
t.Run("no tags present message returns empty", func(t *testing.T) {
out := "no tags present\n"
tags := parseTags(out)
if len(tags) != 0 {
t.Errorf("expected 0 tags, got %d", len(tags))
}
})
t.Run("handles empty output", func(t *testing.T) {
tags := parseTags("")
if len(tags) != 0 {
t.Errorf("expected 0 tags, got %d", len(tags))
}
})
t.Run("handles no tags in partition type", func(t *testing.T) {
out := "no tags in this partition type\n"
tags := parseTags(out)
if len(tags) != 0 {
t.Errorf("expected 0 tags, got %d", len(tags))
}
})
}
func TestListTags_Integration(t *testing.T) {
origRun := runAkaiutil
defer func() { runAkaiutil = origRun }()
runAkaiutil = func(isoPath, commands string) (string, error) {
return `
/disk0/A > lstags
tag name
-----------------
01 Drums
02 Bass
03 FX
-----------------
`, nil
}
tags, err := listTags("fake.iso", "A", "SomeVolume")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tags) != 3 {
t.Errorf("expected 3 tags, got %d", len(tags))
}
if tags[0].Name != "Drums" {
t.Errorf("expected Drums, got %s", tags[0].Name)
}
if tags[2].Index != 3 || tags[2].Name != "FX" {
t.Errorf("tag 2: got {%d %s}, want {3 FX}", tags[2].Index, tags[2].Name)
}
}
+5 -2
View File
@@ -1,7 +1,7 @@
[tools]
go = "1.26"
golangci-lint = "latest"
node = "22"
node = "22.12"
[env]
CGO_ENABLED = "0"
@@ -11,7 +11,9 @@ build = "go build -buildvcs=false -o fetch ."
akaiutil = "make -C third_party/akaiutil"
all = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil" }
serve = { run = "go build -buildvcs=false -o fetch . && ./fetch serve" }
lint = "go vet ./..."
lint = "golangci-lint run"
js-lint = "cd web/ui && npm run lint"
js-test = "cd web/ui && npm test"
electron-deps = "cd electron && npm install"
electron-dev = { run = "go build -buildvcs=false -o fetch . && cd electron && npx electron ." }
electron-build = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil && cd electron && npx electron-builder --dir" }
@@ -20,6 +22,7 @@ init = "mkdir -p output/isos output/wavs"
docker-up = "mkdir -p output/isos output/wavs && docker compose up --build"
docker-build = "docker build -t akai-fetch ."
check-toolchain = "bash scripts/check-toolchain.sh"
install-all = "bash scripts/install-deps.sh"
check-docker-toolchain = "bash scripts/check-docker-toolchain.sh"
check-all = { run = "bash scripts/check-toolchain.sh && echo '' && bash scripts/check-docker-toolchain.sh" }
gopls = "go install golang.org/x/tools/gopls@latest"
+59
View File
@@ -123,5 +123,64 @@ else
fi
fi
echo ""
# ─── golangci-lint ──────────────────────────────────────────────────────
echo "golangci-lint:"
if command -v golangci-lint &>/dev/null; then
gcl_ver=$(golangci-lint --version 2>/dev/null)
ok "golangci-lint found: $gcl_ver"
else
warn "golangci-lint not found"
echo " Install: mise install golangci-lint"
fi
echo ""
echo "=== JS / Node ==="
echo ""
# ─── npm ────────────────────────────────────────────────────────────────
echo "npm:"
if command -v npm &>/dev/null; then
npm_ver=$(npm --version 2>/dev/null)
ok "npm found: v$npm_ver"
else
warn "npm not found"
echo " Install: mise install node"
fi
echo ""
# ─── web/ui deps (eslint, globals) ──────────────────────────────────────
echo "web/ui dependencies:"
if [ -d web/ui/node_modules ] && [ -f web/ui/node_modules/.bin/eslint ]; then
ok "web/ui/node_modules installed"
else
warn "web/ui/node_modules not installed"
echo " Install: cd web/ui && npm install"
fi
echo ""
# ─── electron deps ──────────────────────────────────────────────────────
echo "electron dependencies:"
if [ -d electron/node_modules ] && [ -f electron/node_modules/.bin/electron ]; then
ok "electron/node_modules installed"
else
warn "electron/node_modules not installed"
echo " Install: cd electron && npm install"
fi
echo ""
echo "=== JS Linting (ESLint) ==="
if [ -f web/ui/node_modules/.bin/eslint ]; then
ok "ESLint installed at web/ui/node_modules/.bin/eslint"
eslint_ver=$(cd web/ui && ./node_modules/.bin/eslint --version 2>/dev/null)
ok "ESLint: $eslint_ver"
else
warn "ESLint not installed (needed for web UI linting)"
echo " Install: cd web/ui && npm install"
fi
echo ""
echo "=== Core toolchain check complete ==="
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Install all project dependencies — run after check-toolchain.sh for a plan.
# Usage: ./scripts/install-deps.sh
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
cmd() { echo -e " ${YELLOW}${NC} $*"; "$@"; }
skip() { echo -e " ${GREEN}${NC} $1 (already installed)"; }
fail() { echo -e " ${RED}${NC} $1"; exit 1; }
echo "=== Installing Dependencies ==="
echo ""
# ─── mise tools ─────────────────────────────────────────────────────────
echo "mise tools (go, node, golangci-lint):"
if command -v mise &>/dev/null; then
cmd mise install
else
fail "mise not found — run: curl https://mise.run | sh"
fi
echo ""
# ─── web/ui JS deps ─────────────────────────────────────────────────────
echo "web/ui npm deps (eslint):"
if [ -f web/ui/package.json ]; then
cmd cd web/ui && npm install
else
skip "web/ui npm deps"
fi
echo ""
# ─── electron deps ──────────────────────────────────────────────────────
echo "electron npm deps:"
if [ -f electron/package.json ]; then
cmd cd electron && npm install
else
skip "electron npm deps"
fi
echo ""
echo "=== Install complete ==="
+119
View File
@@ -54,10 +54,23 @@ func cmdServe(args []string) {
mux.HandleFunc("/api/search", handleSearch)
mux.HandleFunc("/api/list", handleList)
mux.HandleFunc("/api/list-wavs", handleListWavs)
mux.HandleFunc("/api/disk/partitions", handleDiskPartitions)
mux.HandleFunc("/api/disk/volumes", handleDiskVolumes)
mux.HandleFunc("/api/volume/tags", handleVolumeTags)
mux.HandleFunc("/api/download", handleAPIDownload)
mux.HandleFunc("/api/extract", handleAPIExtract)
mux.HandleFunc("/api/progress", handleProgress)
wavsBase := "./output/wavs"
if abs, err := filepath.Abs(wavsBase); err == nil {
wavsBase = abs
}
if err := os.MkdirAll(wavsBase, 0755); err != nil {
fatal("create wavs dir: %v", err)
}
mux.Handle("/wavs/", http.StripPrefix("/wavs/", http.FileServer(http.Dir(wavsBase))))
addr := fmt.Sprintf("%s:%d", host, port)
listener, err := net.Listen("tcp", addr)
if err != nil {
@@ -254,6 +267,112 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"jobs": jobIDs})
}
func handleListWavs(w http.ResponseWriter, r *http.Request) {
dir := r.URL.Query().Get("dir")
if dir == "" {
dir = "./output/wavs"
}
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
files := []string{}
var walkErr error
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
if path == dir {
walkErr = err
return filepath.SkipAll
}
return nil
}
if !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".wav") {
rel, _ := filepath.Rel(dir, path)
files = append(files, rel)
}
return nil
})
if walkErr != nil {
writeJSON(w, map[string]any{"error": walkErr.Error()})
return
}
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
writeJSON(w, files)
}
func handleDiskPartitions(w http.ResponseWriter, r *http.Request) {
iso := r.URL.Query().Get("iso")
if iso == "" {
writeJSON(w, map[string]any{"error": "missing iso parameter"})
return
}
fi, err := os.Stat(iso)
if err != nil {
writeJSON(w, map[string]any{"error": fmt.Sprintf("cannot access %q: %v", iso, err)})
return
}
if fi.IsDir() {
writeJSON(w, map[string]any{"error": fmt.Sprintf("%q is a directory, not an ISO file", iso)})
return
}
if !strings.HasSuffix(strings.ToLower(fi.Name()), ".iso") {
writeJSON(w, map[string]any{"error": fmt.Sprintf("%q is not an ISO file", iso)})
return
}
parts, err := listPartitions(iso)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
writeJSON(w, parts)
}
func handleDiskVolumes(w http.ResponseWriter, r *http.Request) {
iso := r.URL.Query().Get("iso")
part := r.URL.Query().Get("part")
if iso == "" || part == "" {
writeJSON(w, map[string]any{"error": "missing iso or part parameter"})
return
}
parts, err := listPartitions(iso)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
vols, err := listVolumes(iso, parts)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
filtered := []volInfo{}
for _, v := range vols {
if v.Part == part {
filtered = append(filtered, v)
}
}
writeJSON(w, filtered)
}
func handleVolumeTags(w http.ResponseWriter, r *http.Request) {
iso := r.URL.Query().Get("iso")
part := r.URL.Query().Get("part")
vol := r.URL.Query().Get("vol")
if iso == "" || part == "" || vol == "" {
writeJSON(w, map[string]any{"error": "missing iso, part, or vol parameter"})
return
}
tags, err := listTags(iso, part, vol)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
writeJSON(w, tags)
}
func handleProgress(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
+191
View File
@@ -442,4 +442,195 @@ func TestHandleAPIExtract_ExtractISOError(t *testing.T) {
if !strings.Contains(msg, "FAIL bad.iso") {
t.Errorf("expected FAIL message, got: %s", msg)
}
}
func TestHandleListWavs_ReturnsWavFiles(t *testing.T) {
tmp := t.TempDir()
wavDir := filepath.Join(tmp, "wavs")
if err := os.MkdirAll(wavDir, 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"kick.wav", "snare.wav", "hihat.wav"} {
fd, _ := os.Create(filepath.Join(wavDir, name))
_ = fd.Close()
}
fd, _ := os.Create(filepath.Join(wavDir, "readme.txt"))
_ = fd.Close()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+wavDir, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(files) != 3 {
t.Errorf("expected 3 wav files, got %d: %v", len(files), files)
}
}
func TestHandleListWavs_RecursiveSubdirs(t *testing.T) {
tmp := t.TempDir()
wavDir := filepath.Join(tmp, "wavs")
subDir := filepath.Join(wavDir, "Kicks")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(wavDir, "root.wav"))
_ = fd.Close()
fd2, _ := os.Create(filepath.Join(subDir, "kick1.wav"))
_ = fd2.Close()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+wavDir, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(files) != 2 {
t.Errorf("expected 2 wav files, got %d: %v", len(files), files)
}
found := make(map[string]bool)
for _, f := range files {
found[f] = true
}
if !found["root.wav"] {
t.Errorf("missing root.wav, got %v", files)
}
if !found[filepath.Join("Kicks", "kick1.wav")] {
t.Errorf("missing Kicks/kick1.wav, got %v", files)
}
}
func TestHandleListWavs_EmptyDir(t *testing.T) {
tmp := t.TempDir()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+tmp, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if files == nil {
t.Fatal("expected empty array [], got null")
}
if len(files) != 0 {
t.Errorf("expected 0 files, got %d", len(files))
}
}
func TestHandleListWavs_NonExistentDir(t *testing.T) {
req := httptest.NewRequest("GET", "/api/list-wavs?dir=/nonexistent/wavs", nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["error"]; !ok {
t.Error("expected error for nonexistent dir")
}
}
func TestHandleDiskPartitions_ReturnsPartitions(t *testing.T) {
origRun := runAkaiutil
defer func() { runAkaiutil = origRun }()
runAkaiutil = func(isoPath, commands string) (string, error) {
return `
Disk /disk0
Total blocks: 0 (0 KB each) 0 KB
Partition table:
A - S1000 HD (0 blocks)
B - S1000 HD (0 blocks)
`, nil
}
req := httptest.NewRequest("GET", "/api/disk/partitions?iso=test.iso", nil)
rr := httptest.NewRecorder()
handleDiskPartitions(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var parts []string
if err := json.Unmarshal(rr.Body.Bytes(), &parts); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(parts) != 2 {
t.Errorf("expected 2 partitions, got %d: %v", len(parts), parts)
}
}
func TestHandleDiskPartitions_MissingISO(t *testing.T) {
req := httptest.NewRequest("GET", "/api/disk/partitions", nil)
rr := httptest.NewRecorder()
handleDiskPartitions(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
if _, ok := resp["error"]; !ok {
t.Error("expected error for missing iso")
}
}
func TestHandleVolumeTags_MissingParams(t *testing.T) {
req := httptest.NewRequest("GET", "/api/volume/tags", nil)
rr := httptest.NewRecorder()
handleVolumeTags(rr, req)
var resp map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
if resp["error"] == nil {
t.Error("expected error for missing params")
}
}
func TestHandleVolumeTags_ReturnsTags(t *testing.T) {
origRun := runAkaiutil
defer func() { runAkaiutil = origRun }()
runAkaiutil = func(isoPath, commands string) (string, error) {
return `
/disk0/A/SomeVol > lstags
tag name
-----------------
01 Drums
02 Bass
-----------------
`, nil
}
req := httptest.NewRequest("GET", "/api/volume/tags?iso=test.iso&part=A&vol=SomeVol", nil)
rr := httptest.NewRecorder()
handleVolumeTags(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var tags []TagInfo
if err := json.Unmarshal(rr.Body.Bytes(), &tags); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(tags) != 2 {
t.Errorf("expected 2 tags, got %d", len(tags))
}
}
+7
View File
@@ -99,6 +99,8 @@ func TestFindAkaiutil(t *testing.T) {
}()
t.Run("env var set to missing file", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
_ = os.Setenv("AKAIUTIL", "/nonexistent/akaiutil")
os.Args[0] = "/some/binary"
_, err := findAkaiutil()
@@ -109,6 +111,7 @@ func TestFindAkaiutil(t *testing.T) {
t.Run("env var set to existing file", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
f := tmp + "/myutil"
fd, _ := os.Create(f)
defer func() { _ = fd.Close() }()
@@ -124,6 +127,7 @@ func TestFindAkaiutil(t *testing.T) {
t.Run("finds binary next to argv[0]", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
exe := tmp + "/mybinary"
fd, _ := os.Create(exe)
defer func() { _ = fd.Close() }()
@@ -143,6 +147,7 @@ func TestFindAkaiutil(t *testing.T) {
t.Run("finds binary in third_party relative to argv[0]", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
exe := tmp + "/somebin"
fd, _ := os.Create(exe)
defer func() { _ = fd.Close() }()
@@ -166,6 +171,7 @@ func TestFindAkaiutil(t *testing.T) {
t.Run("env var takes priority over path candidates", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
envFile := tmp + "/envutil"
pathFile := tmp + "/pathutil"
fd1, _ := os.Create(envFile)
@@ -185,6 +191,7 @@ func TestFindAkaiutil(t *testing.T) {
t.Run("no akaiutil found returns error", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
exe := tmp + "/nobinaryhere"
fd, _ := os.Create(exe)
defer func() { _ = fd.Close() }()
+328 -15
View File
@@ -46,8 +46,8 @@ function renderResults(items) {
<span>${r.downloads.toLocaleString()} downloads</span>
</div>
<div class="actions">
<button class="btn-dl" onclick="downloadItem('${esc(r.identifier)}')">Download ISOs</button>
<button class="btn-list" onclick="listItem('${esc(r.identifier)}')">List Files</button>
<button class="btn-dl" data-action="download" data-identifier="${esc(r.identifier)}">Download ISOs</button>
<button class="btn-list" data-action="list" data-identifier="${esc(r.identifier)}">List Files</button>
</div>
<div id="list-${sanitize(r.identifier)}" class="file-list"></div>
</div>
@@ -175,6 +175,11 @@ async function doExtract() {
try {
const resp = await fetch(`${API}/extract?dir=${encodeURIComponent(isoDir)}&out=${encodeURIComponent(wavDir)}`);
const data = await resp.json();
if (data.error) {
status.textContent = data.error;
status.style.color = 'var(--accent)';
return;
}
status.textContent = data.message || 'Extraction complete';
status.style.color = 'var(--green)';
} catch (e) {
@@ -183,25 +188,333 @@ async function doExtract() {
}
}
// ─── Helpers ──────────────────────────────────────────────────────
// ─── WAV Browser ──────────────────────────────────────────────────
function esc(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
let audioCtx = null;
let currentSource = null;
let currentAudioBuffer = null;
let startTime = 0;
let pauseOffset = 0;
let isPlaying = false;
let animationFrame = null;
document.getElementById('wav-browse-btn').addEventListener('click', browseWavs);
function renderSampleCards(samples) {
return samples.map(s => `
<div class="sample-card" data-action="play" data-wav="${esc(s.fullPath)}">
<span class="sample-name" title="${esc(s.name)}">${esc(s.name)}</span>
</div>
`).join('');
}
function sanitize(s) {
return s.replace(/[^a-zA-Z0-9_-]/g, '_');
function renderPrograms(programs) {
const progNames = Object.keys(programs).sort();
return progNames.map(prog => {
const samples = programs[prog];
return `
<div class="program-card">
<div class="program-header">
<h4>${esc(cleanName(prog))}</h4>
<span class="prog-meta">${samples.length} samples</span>
</div>
<div class="program-samples">
${renderSampleCards(samples)}
</div>
</div>`;
}).join('');
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
function renderLibraries(libs) {
const libNames = Object.keys(libs).sort();
return libNames.map(lib => {
const programs = libs[lib];
const progCount = Object.keys(programs).length;
const sampleCount = Object.values(programs).reduce((sum, s) => sum + s.length, 0);
return `
<div class="library-card">
<div class="library-header" onclick="this.parentNode.classList.toggle('collapsed')">
<h3>${esc(cleanName(lib))}<span class="lib-toggle"></span></h3>
<span class="lib-meta">${progCount} programs · ${sampleCount} samples</span>
</div>
<div class="library-programs">
${renderPrograms(programs)}
</div>
</div>`;
}).join('');
}
async function browseWavs() {
const dir = document.getElementById('wav-dir').value;
const results = document.getElementById('wav-results');
const summary = document.getElementById('wav-summary');
const list = document.getElementById('wav-list');
list.innerHTML = 'Loading...';
try {
const url = `${API}/list-wavs?dir=${encodeURIComponent(dir)}`;
const resp = await fetch(url);
if (!resp.ok) {
list.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>';
results.style.display = 'block';
return;
}
const data = await resp.json();
if (!data || data.error) {
list.innerHTML = '<span class="no-files">Error: ' + (data && data.error ? data.error : 'server error') + '</span>';
results.style.display = 'block';
return;
}
if (!Array.isArray(data) || data.length === 0) {
list.innerHTML = '<span class="no-files">No WAV files found</span>';
results.style.display = 'block';
return;
}
const libs = groupWavs(data);
const libCount = Object.keys(libs).length;
summary.textContent = libCount + ' librar' + (libCount === 1 ? 'y' : 'ies') + ' · ' + data.length + ' samples';
list.innerHTML = renderLibraries(libs);
results.style.display = 'block';
} catch (e) {
list.innerHTML = 'Failed: ' + e.message;
results.style.display = 'block';
}
}
async function playWav(filename) {
console.log('playWav:', filename);
if (!audioCtx) {
audioCtx = new AudioContext();
console.log('playWav: created AudioContext');
}
if (currentSource) {
currentSource.stop();
currentSource = null;
}
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
const name = filename.split('/').pop().replace(/\.wav$/i, '');
const player = document.getElementById('wav-player');
const nowPlaying = document.getElementById('wav-now-playing');
player.style.display = 'block';
nowPlaying.textContent = 'Loading: ' + name;
try {
const resp = await fetch(`/wavs/${encodeURIComponent(filename)}`);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const arrayBuffer = await resp.arrayBuffer();
currentAudioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
currentSource = audioCtx.createBufferSource();
currentSource.buffer = currentAudioBuffer;
currentSource.connect(audioCtx.destination);
currentSource.onended = () => {
if (isPlaying) {
isPlaying = false;
pauseOffset = 0;
nowPlaying.textContent = '';
document.getElementById('wav-progress-fill').style.width = '0%';
document.getElementById('wav-time').textContent = formatTime(0);
clearActiveItem();
player.style.display = 'none';
}
};
startTime = audioCtx.currentTime - pauseOffset;
currentSource.start(0, pauseOffset);
isPlaying = true;
pauseOffset = 0;
nowPlaying.textContent = '▶ ' + name;
setActiveItem(filename);
updateProgress();
} catch (e) {
nowPlaying.textContent = 'Error: ' + e.message;
}
}
function updateProgress() {
if (!isPlaying || !currentAudioBuffer) return;
const elapsed = audioCtx.currentTime - startTime;
const duration = currentAudioBuffer.duration;
const pct = Math.min((elapsed / duration) * 100, 100);
document.getElementById('wav-progress-fill').style.width = pct + '%';
document.getElementById('wav-time').textContent = formatTime(elapsed);
if (elapsed < duration) {
animationFrame = requestAnimationFrame(updateProgress);
}
}
function setActiveItem(filename) {
document.querySelectorAll('.sample-card').forEach(el => {
el.classList.toggle('playing', el.dataset.wav === filename);
});
}
function clearActiveItem() {
document.querySelectorAll('.sample-card').forEach(el => {
el.classList.remove('playing');
});
}
// Initial load
doSearch();
// Event delegation for dynamically generated buttons
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'download') {
downloadItem(btn.dataset.identifier);
} else if (action === 'list') {
listItem(btn.dataset.identifier);
} else if (action === 'play') {
playWav(btn.dataset.wav);
} else if (action === 'tag-part') {
selectPartition(btn.dataset.iso, btn.dataset.part);
} else if (action === 'tag-vol') {
viewVolumeTags(btn.dataset.iso, btn.dataset.part, btn.dataset.vol);
}
});
// Section collapse toggle
document.querySelectorAll('.section-toggle').forEach(h => {
h.addEventListener('click', function () {
this.classList.toggle('collapsed');
const target = document.getElementById(this.dataset.section);
target.style.display = target.style.display === 'none' ? '' : 'none';
});
});
// Collapse all libraries
document.getElementById('wav-collapse-all').addEventListener('click', function () {
document.querySelectorAll('.library-card').forEach(c => c.classList.add('collapsed'));
});
// ─── Tag Viewer ─────────────────────────────────────────────────────
const TAG_COLORS = [
'#e94560', '#4ecca3', '#f0c040', '#7c3aed', '#06b6d4',
'#f97316', '#84cc16', '#ec4899', '#3b82f6', '#14b8a6',
'#a855f7', '#ef4444', '#22c55e', '#eab308', '#6366f1',
'#10b981', '#f43f5e', '#8b5cf6', '#0ea5e9', '#d946ef'
]
document.getElementById('tag-browse-btn').addEventListener('click', browseISO)
async function browseISO() {
const iso = document.getElementById('tag-iso-path').value
const results = document.getElementById('tag-results')
const status = document.getElementById('tag-status')
const partsEl = document.getElementById('tag-partitions')
const volsEl = document.getElementById('tag-volumes')
const detailEl = document.getElementById('tag-detail')
partsEl.innerHTML = ''
volsEl.innerHTML = ''
detailEl.innerHTML = ''
status.textContent = 'Loading partitions...'
results.style.display = 'block'
try {
const resp = await fetch(`${API}/disk/partitions?iso=${encodeURIComponent(iso)}`)
if (!resp.ok) {
status.textContent = 'HTTP ' + resp.status
return
}
const data = await resp.json()
if (!data || data.error) {
status.textContent = (data && data.error) || 'server error'
return
}
status.textContent = data.length + ' partition' + (data.length === 1 ? '' : 's')
partsEl.innerHTML = data.map(p => `
<button class="tag-part-btn" data-action="tag-part" data-iso="${esc(iso)}" data-part="${esc(p)}">Partition ${esc(p)}</button>
`).join('')
} catch (e) {
status.textContent = 'Failed: ' + e.message
}
}
async function selectPartition(iso, part) {
const volsEl = document.getElementById('tag-volumes')
const detailEl = document.getElementById('tag-detail')
document.querySelectorAll('.tag-part-btn').forEach(b => {
b.classList.toggle('active', b.dataset.part === part)
})
volsEl.innerHTML = 'Loading volumes...'
detailEl.innerHTML = ''
try {
const resp = await fetch(`${API}/disk/volumes?iso=${encodeURIComponent(iso)}&part=${encodeURIComponent(part)}`)
if (!resp.ok) {
volsEl.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>'
return
}
const data = await resp.json()
if (!data || data.error) {
volsEl.innerHTML = '<span class="no-files">' + (data && data.error ? data.error : 'server error') + '</span>'
return
}
if (!Array.isArray(data) || data.length === 0) {
volsEl.innerHTML = '<span class="no-files">No volumes on Partition ' + part + '</span>'
return
}
volsEl.innerHTML = data.map(v => `
<div class="vol-item" data-action="tag-vol" data-iso="${esc(iso)}" data-part="${esc(part)}" data-vol="${esc(v.name)}">${esc(v.name)}</div>
`).join('')
} catch (e) {
volsEl.innerHTML = 'Failed: ' + e.message
}
}
async function viewVolumeTags(iso, part, vol) {
const detailEl = document.getElementById('tag-detail')
document.querySelectorAll('.vol-item').forEach(el => {
el.classList.toggle('selected', el.textContent === vol)
})
detailEl.innerHTML = 'Loading tags...'
try {
const resp = await fetch(`${API}/volume/tags?iso=${encodeURIComponent(iso)}&part=${encodeURIComponent(part)}&vol=${encodeURIComponent(vol)}`)
if (!resp.ok) {
detailEl.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>'
return
}
const data = await resp.json()
if (!data || data.error) {
detailEl.innerHTML = '<span class="no-files">' + (data && data.error ? data.error : 'server error') + '</span>'
return
}
if (!Array.isArray(data) || data.length === 0) {
detailEl.innerHTML = '<span class="no-files">No tags on this volume</span>'
return
}
const colorKeys = TAG_COLORS
detailEl.innerHTML = `
<h4>Tags: ${esc(vol)}</h4>
<div class="tag-list">
${data.map(t => {
const color = colorKeys[(t.index - 1) % colorKeys.length]
return `<span class="tag-chip">
<span class="tag-dot" style="background:${color}"></span>
${esc(t.name)}
</span>`
}).join('')}
</div>`
} catch (e) {
detailEl.innerHTML = 'Failed: ' + e.message
}
}
+45
View File
@@ -0,0 +1,45 @@
const globals = require('globals');
module.exports = [
{
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'script',
globals: {
...globals.browser,
AudioContext: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly',
fetch: 'readonly',
EventSource: 'readonly',
document: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
Math: 'readonly',
JSON: 'readonly',
location: 'readonly',
encodeURIComponent: 'readonly',
decodeURIComponent: 'readonly',
parseInt: 'readonly',
Number: 'readonly',
Promise: 'readonly',
Object: 'readonly',
Array: 'readonly',
Map: 'readonly',
process: 'readonly',
__dirname: 'readonly',
require: 'readonly',
module: 'readonly',
exports: 'readonly'
}
},
rules: {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"no-redeclare": "warn",
"no-unreachable": "warn",
"eqeqeq": ["error", "always"],
"prefer-const": "warn"
}
}
];
+42 -1
View File
@@ -39,11 +39,52 @@
<div id="extract-status"></div>
</section>
<section id="tag-section">
<h2 class="section-toggle" data-section="tag-body">Tag Viewer <span class="toggle-icon"></span></h2>
<div id="tag-body">
<div class="tag-controls">
<input type="text" id="tag-iso-path" placeholder="./output/isos/YOUR_ISO.iso" value="">
<button id="tag-browse-btn">Browse</button>
</div>
<div id="tag-results" style="display:none">
<div id="tag-status"></div>
<div id="tag-partitions" class="tag-partitions"></div>
<div id="tag-volumes" class="tag-volumes"></div>
<div id="tag-detail" class="tag-detail"></div>
</div>
</div>
</section>
<section id="wav-section">
<h2 class="section-toggle" data-section="wav-body">Sample Library <span class="toggle-icon"></span></h2>
<div id="wav-body">
<div class="wav-controls">
<input type="text" id="wav-dir" placeholder="WAV directory" value="./output/wavs">
<button id="wav-browse-btn">Browse</button>
</div>
<div id="wav-results" style="display:none">
<div class="wav-summary-row">
<div id="wav-summary" class="wav-summary"></div>
<button id="wav-collapse-all" class="collapse-all-btn">Collapse All</button>
</div>
<div id="wav-player" class="wav-player" style="display:none">
<div id="wav-now-playing" class="now-playing"></div>
<div class="audio-bar">
<div class="audio-progress"><div id="wav-progress-fill" class="audio-progress-fill"></div></div>
<span id="wav-time">0:00</span>
</div>
</div>
<div id="wav-list" class="wav-library"></div>
</div>
</div>
</section>
<section id="results-section">
<h2>Results <span id="result-count"></span></h2>
<div id="search-results"></div>
</section>
</div>
<script src="app.js"></script>
<script src="util.js"></script>
<script src="app.js"></script>
</body>
</html>
+1098
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "akai-utils-web-ui",
"version": "1.0.0",
"description": "Web UI for AKAI Utils",
"scripts": {
"lint": "eslint *.js",
"test": "node --test util.test.js"
},
"devDependencies": {
"eslint": "^9.0.0"
}
}
+372
View File
@@ -50,6 +50,23 @@ section {
margin-bottom: 20px;
}
/* Section collapse toggle */
.section-toggle {
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
}
.toggle-icon {
font-size: 12px;
transition: transform 0.2s;
display: inline-block;
}
.section-toggle.collapsed .toggle-icon {
transform: rotate(-90deg);
}
h2 {
font-size: 16px;
color: var(--text2);
@@ -205,4 +222,359 @@ h2 {
margin-top: 10px;
font-size: 13px;
color: var(--text2);
}
.wav-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.wav-controls input {
flex: 1;
min-width: 200px;
padding: 10px 14px;
border: 1px solid var(--bg3);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-size: 14px;
}
.wav-controls button {
padding: 10px 20px;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: white;
font-size: 14px;
cursor: pointer;
}
.wav-player {
margin-top: 14px;
}
.now-playing {
font-size: 13px;
color: var(--green);
margin-bottom: 6px;
min-height: 18px;
}
.audio-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.audio-progress {
flex: 1;
height: 6px;
background: var(--bg3);
border-radius: 3px;
overflow: hidden;
}
.audio-progress-fill {
height: 100%;
background: var(--green);
border-radius: 3px;
width: 0%;
transition: width 0.1s;
}
#wav-time {
font-size: 12px;
color: var(--text2);
min-width: 40px;
}
.no-files {
color: var(--text2);
font-size: 13px;
padding: 12px 0;
}
.wav-summary {
font-size: 13px;
color: var(--text2);
margin-top: 12px;
margin-bottom: 12px;
min-height: 18px;
}
/* Library cards */
.wav-library {
display: flex;
flex-direction: column;
gap: 12px;
}
.library-card {
background: var(--bg2);
border: 1px solid var(--bg3);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 8px;
}
.library-header {
padding: 10px 16px;
cursor: pointer;
user-select: none;
background: var(--bg);
border-bottom: 1px solid var(--bg3);
transition: border-color 0.15s;
display: flex;
align-items: center;
gap: 12px;
}
.library-header:hover {
border-color: var(--accent);
}
.library-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--text);
margin: 0;
text-transform: none;
letter-spacing: 0;
display: flex;
align-items: center;
gap: 4px;
}
.library-header .lib-meta {
font-size: 11px;
color: var(--text2);
margin-left: auto;
}
.library-programs {
padding: 8px 16px 12px;
}
/* Program cards */
.program-card {
margin-top: 10px;
}
.program-card:first-child {
margin-top: 0;
}
.program-header {
cursor: default;
user-select: none;
margin-bottom: 6px;
}
.program-header h4 {
font-size: 12px;
font-weight: 600;
color: var(--accent);
margin: 0;
text-transform: none;
letter-spacing: 0;
display: inline;
}
.program-header .prog-meta {
font-size: 10px;
color: var(--text2);
margin-left: 6px;
}
.program-samples {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 4px;
}
/* Tag viewer */
.tag-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag-controls input {
flex: 1;
min-width: 200px;
padding: 10px 14px;
border: 1px solid var(--bg3);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-size: 14px;
}
.tag-controls button {
padding: 10px 20px;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: white;
font-size: 14px;
cursor: pointer;
}
.tag-controls button:hover { opacity: 0.9; }
.tag-status {
font-size: 13px;
color: var(--text2);
margin-top: 10px;
margin-bottom: 8px;
}
.tag-partitions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.tag-part-btn {
padding: 6px 14px;
border: 1px solid var(--bg3);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: border-color 0.15s;
}
.tag-part-btn:hover {
border-color: var(--accent);
}
.tag-part-btn.active {
border-color: var(--accent);
background: var(--accent);
color: white;
cursor: default;
}
.tag-volumes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 6px;
margin-bottom: 12px;
}
.vol-item {
padding: 8px 12px;
border: 1px solid var(--bg3);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vol-item:hover {
border-color: var(--accent);
}
.vol-item.selected {
border-color: var(--green);
background: rgba(78, 204, 163, 0.1);
}
.tag-detail {
margin-top: 8px;
}
.tag-detail h4 {
font-size: 14px;
color: var(--text);
margin: 0 0 8px;
}
.tag-list {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
background: var(--bg3);
color: var(--text);
}
.tag-chip .tag-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* Sample cards */
.sample-card {
background: var(--bg);
border: 1px solid var(--bg3);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-height: 28px;
transition: border-color 0.15s, background 0.15s;
}
.sample-card:hover {
border-color: var(--accent);
}
.sample-card.playing {
border-color: var(--green);
background: rgba(78, 204, 163, 0.15);
}
.sample-card .sample-name {
font-size: 10px;
color: var(--text);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.sample-card .sample-play-icon {
display: none;
}
/* Collapse all button */
.wav-summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.collapse-all-btn {
background: var(--bg3);
color: var(--text2);
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
}
.collapse-all-btn:hover {
color: var(--text);
}
/* Library collapse state */
.library-card.collapsed .library-programs {
display: none;
}
.library-header .lib-toggle {
font-size: 11px;
transition: transform 0.2s;
margin-left: 8px;
color: var(--text2);
}
.library-card.collapsed .lib-toggle {
transform: rotate(-90deg);
}
+52
View File
@@ -0,0 +1,52 @@
// AKAI Utils — shared utility functions
function esc(s) {
if (!s) return ''
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
function sanitize(s) {
return s.replace(/[^a-zA-Z0-9_-]/g, '_')
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
function formatTime(secs) {
const m = Math.floor(secs / 60)
const s = Math.floor(secs % 60)
return m + ':' + s.toString().padStart(2, '0')
}
function groupWavs(paths) {
const libs = {}
for (const p of paths) {
const parts = p.split('/')
let lib, prog, file
if (parts.length === 1) {
lib = ''; prog = parts[0]; file = parts[0]
} else if (parts.length === 2) {
lib = parts[0]; prog = parts[0]; file = parts[1]
} else {
lib = parts[0]; prog = parts[1]; file = parts[parts.length - 1]
}
if (!libs[lib]) libs[lib] = {}
if (!libs[lib][prog]) libs[lib][prog] = []
libs[lib][prog].push({ fullPath: p, name: file.replace(/\.wav$/i, '') })
}
return libs
}
function cleanName(name) {
return name.replace(/_+/g, ' ').trim()
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { esc, sanitize, formatBytes, formatTime, groupWavs, cleanName }
}
+161
View File
@@ -0,0 +1,161 @@
const { describe, it } = require('node:test')
const assert = require('node:assert/strict')
const { esc, sanitize, formatBytes, formatTime, groupWavs, cleanName } = require('./util.js')
describe('esc', () => {
it('escapes HTML entities', () => {
assert.strictEqual(esc('<script>alert("xss")</script>'),
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')
})
it('handles ampersands', () => {
assert.strictEqual(esc('a & b'), 'a &amp; b')
})
it('handles single quotes', () => {
assert.strictEqual(esc("it's"), 'it&#39;s')
})
it('returns empty string for falsy input', () => {
assert.strictEqual(esc(''), '')
assert.strictEqual(esc(null), '')
assert.strictEqual(esc(undefined), '')
})
it('passes through safe strings', () => {
assert.strictEqual(esc('Hello World'), 'Hello World')
})
})
describe('sanitize', () => {
it('replaces special characters with underscores', () => {
assert.strictEqual(sanitize('hello world!'), 'hello_world_')
})
it('preserves alphanumeric and dashes', () => {
assert.strictEqual(sanitize('test-123_abc'), 'test-123_abc')
})
it('sanitizes dots and slashes', () => {
assert.strictEqual(sanitize('path/to/file.name'),
'path_to_file_name')
})
})
describe('formatBytes', () => {
it('formats zero', () => {
assert.strictEqual(formatBytes(0), '0 B')
})
it('formats bytes', () => {
assert.strictEqual(formatBytes(500), '500 B')
})
it('formats KB', () => {
assert.strictEqual(formatBytes(1024), '1 KB')
})
it('formats MB', () => {
assert.strictEqual(formatBytes(1048576), '1 MB')
})
it('formats GB', () => {
assert.strictEqual(formatBytes(1073741824), '1 GB')
})
it('formats fractional', () => {
assert.strictEqual(formatBytes(1536), '1.5 KB')
})
})
describe('formatTime', () => {
it('formats zero', () => {
assert.strictEqual(formatTime(0), '0:00')
})
it('formats seconds', () => {
assert.strictEqual(formatTime(5), '0:05')
})
it('formats minutes and seconds', () => {
assert.strictEqual(formatTime(65), '1:05')
})
it('formats double-digit seconds', () => {
assert.strictEqual(formatTime(75), '1:15')
})
it('formats large times', () => {
assert.strictEqual(formatTime(3661), '61:01')
})
})
describe('groupWavs', () => {
it('groups by library and program', () => {
const paths = [
'LibA/Prog1/kick.wav',
'LibA/Prog1/snare.wav',
'LibA/Prog2/hat.wav'
]
const result = groupWavs(paths)
assert.strictEqual(Object.keys(result).length, 1)
assert.strictEqual(Object.keys(result.LibA).length, 2)
assert.strictEqual(result.LibA.Prog1.length, 2)
assert.strictEqual(result.LibA.Prog2.length, 1)
})
it('handles multiple libraries', () => {
const paths = [
'LibA/kick.wav',
'LibB/snare.wav'
]
const result = groupWavs(paths)
assert.strictEqual(Object.keys(result).length, 2)
assert.strictEqual(result.LibA.LibA[0].name, 'kick')
assert.strictEqual(result.LibB.LibB[0].name, 'snare')
})
it('strips .wav extension case-insensitively', () => {
const paths = ['Lib/Prog/KICK.WAV']
const result = groupWavs(paths)
assert.strictEqual(result.Lib.Prog[0].name, 'KICK')
})
it('handles deeply nested paths', () => {
const paths = ['A/B/C/D/file.wav']
const result = groupWavs(paths)
assert.strictEqual(result.A.B.length, 1)
assert.strictEqual(result.A.B[0].name, 'file')
assert.strictEqual(result.A.B[0].fullPath, 'A/B/C/D/file.wav')
})
it('handles flat paths with no directory', () => {
const paths = ['sound.wav']
const result = groupWavs(paths)
assert.strictEqual(Object.keys(result).length, 1)
assert.strictEqual(Object.keys(result['']).length, 1)
})
it('returns empty object for empty input', () => {
const result = groupWavs([])
assert.strictEqual(Object.keys(result).length, 0)
})
})
describe('cleanName', () => {
it('replaces underscores with spaces', () => {
assert.strictEqual(cleanName('hello_world'), 'hello world')
})
it('collapses multiple underscores', () => {
assert.strictEqual(cleanName('AMG_-_Black__II'), 'AMG - Black II')
})
it('trims leading/trailing spaces from underscores', () => {
assert.strictEqual(cleanName('_padded_'), 'padded')
})
it('preserves regular text', () => {
assert.strictEqual(cleanName('Kick Drum'), 'Kick Drum')
})
})