diff --git a/.gitignore b/.gitignore index 0ecb5b5..dc3a578 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ web/ui/node_modules/ # Temp /tmp/ +akai-fetch.test +cover.out diff --git a/AGENTS.md b/AGENTS.md index 23ce31c..977c4de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` 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 diff --git a/README.md b/README.md index 61e70b7..002e93b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/web/ui/app.js b/web/ui/app.js index 29d57f2..2e934d7 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -200,42 +200,85 @@ let animationFrame = null; document.getElementById('wav-browse-btn').addEventListener('click', browseWavs); +function renderSampleCards(samples) { + return samples.map(s => ` +
+ ${esc(s.name)} +
+ `).join(''); +} + +function renderPrograms(programs) { + const progNames = Object.keys(programs).sort(); + return progNames.map(prog => { + const samples = programs[prog]; + return ` +
+
+

${esc(cleanName(prog))}

+ ${samples.length} samples +
+
+ ${renderSampleCards(samples)} +
+
`; + }).join(''); +} + +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 ` +
+
+

${esc(cleanName(lib))}

+ ${progCount} programs · ${sampleCount} samples +
+
+ ${renderPrograms(programs)} +
+
`; + }).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...'; - console.log('browseWavs: dir =', dir); try { const url = `${API}/list-wavs?dir=${encodeURIComponent(dir)}`; - console.log('browseWavs: fetching', url); const resp = await fetch(url); - console.log('browseWavs: response status', resp.status, 'ok', resp.ok); if (!resp.ok) { list.innerHTML = 'HTTP ' + resp.status + ''; + results.style.display = 'block'; return; } const data = await resp.json(); - console.log('browseWavs: parsed data', data, 'type', typeof data, 'isArray', Array.isArray(data)); if (!data || data.error) { list.innerHTML = 'Error: ' + (data && data.error ? data.error : 'server error') + ''; + results.style.display = 'block'; return; } if (!Array.isArray(data) || data.length === 0) { list.innerHTML = 'No WAV files found'; + results.style.display = 'block'; return; } - console.log('browseWavs: found', data.length, 'wavs'); - list.innerHTML = data.map(f => ` -
- ${esc(f)} - -
- `).join(''); + 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) { - console.error('browseWavs: error', e); list.innerHTML = 'Failed: ' + e.message; + results.style.display = 'block'; } } @@ -255,8 +298,11 @@ async function playWav(filename) { animationFrame = null; } + const name = filename.split('/').pop().replace(/\.wav$/i, ''); + const player = document.getElementById('wav-player'); const nowPlaying = document.getElementById('wav-now-playing'); - nowPlaying.textContent = 'Loading: ' + filename; + player.style.display = 'block'; + nowPlaying.textContent = 'Loading: ' + name; try { const resp = await fetch(`/wavs/${encodeURIComponent(filename)}`); @@ -276,6 +322,7 @@ async function playWav(filename) { document.getElementById('wav-progress-fill').style.width = '0%'; document.getElementById('wav-time').textContent = formatTime(0); clearActiveItem(); + player.style.display = 'none'; } }; @@ -284,7 +331,7 @@ async function playWav(filename) { isPlaying = true; pauseOffset = 0; - nowPlaying.textContent = '▶ ' + filename; + nowPlaying.textContent = '▶ ' + name; setActiveItem(filename); updateProgress(); } catch (e) { @@ -308,13 +355,13 @@ function updateProgress() { } function setActiveItem(filename) { - document.querySelectorAll('.wav-item').forEach(el => { - el.classList.toggle('playing', el.dataset.file === filename); + document.querySelectorAll('.sample-card').forEach(el => { + el.classList.toggle('playing', el.dataset.wav === filename); }); } function clearActiveItem() { - document.querySelectorAll('.wav-item').forEach(el => { + document.querySelectorAll('.sample-card').forEach(el => { el.classList.remove('playing'); }); } @@ -335,3 +382,17 @@ document.addEventListener('click', function(e) { playWav(btn.dataset.wav); } }); + +// 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')); +}); diff --git a/web/ui/index.html b/web/ui/index.html index 1794e9b..3f462e6 100644 --- a/web/ui/index.html +++ b/web/ui/index.html @@ -40,18 +40,26 @@
-

WAV Browser

+

Sample Library

+
-
-
-
-
- 0:00 +
diff --git a/web/ui/style.css b/web/ui/style.css index 0262c08..217dc61 100644 --- a/web/ui/style.css +++ b/web/ui/style.css @@ -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); @@ -272,42 +289,168 @@ h2 { min-width: 40px; } -.wav-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 8px; +.no-files { + color: var(--text2); + font-size: 13px; + padding: 12px 0; } -.wav-item { - background: var(--bg); +.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); - padding: 10px 12px; + 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: 8px; - cursor: pointer; + gap: 12px; } -.wav-item:hover { +.library-header:hover { border-color: var(--accent); } -.wav-item.playing { - border-color: var(--green); - background: rgba(78, 204, 163, 0.1); +.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; } -.wav-item .wav-name { - flex: 1; - font-size: 13px; +.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; +} + +/* 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%; } -.wav-item .wav-play-btn { - background: var(--accent2); - color: white; +.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); } \ No newline at end of file diff --git a/web/ui/util.js b/web/ui/util.js index a40a83c..c81ce90 100644 --- a/web/ui/util.js +++ b/web/ui/util.js @@ -24,6 +24,29 @@ function formatTime(secs) { 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 } + module.exports = { esc, sanitize, formatBytes, formatTime, groupWavs, cleanName } } \ No newline at end of file diff --git a/web/ui/util.test.js b/web/ui/util.test.js index 26b461b..4f6f173 100644 --- a/web/ui/util.test.js +++ b/web/ui/util.test.js @@ -1,6 +1,6 @@ const { describe, it } = require('node:test') const assert = require('node:assert/strict') -const { esc, sanitize, formatBytes, formatTime } = require('./util.js') +const { esc, sanitize, formatBytes, formatTime, groupWavs, cleanName } = require('./util.js') describe('esc', () => { it('escapes HTML entities', () => { @@ -88,4 +88,74 @@ describe('formatTime', () => { 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') + }) }) \ No newline at end of file