diff --git a/serve.go b/serve.go index 8672db2..5cdb4b5 100644 --- a/serve.go +++ b/serve.go @@ -54,10 +54,20 @@ func cmdServe(args []string) { mux.HandleFunc("/api/search", handleSearch) mux.HandleFunc("/api/list", handleList) + mux.HandleFunc("/api/list-wavs", handleListWavs) 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 +264,30 @@ 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 + } + + entries, err := os.ReadDir(dir) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + + var files []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".wav") { + files = append(files, e.Name()) + } + } + writeJSON(w, files) +} + func handleProgress(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") diff --git a/serve_test.go b/serve_test.go index eed25e2..20e3ffc 100644 --- a/serve_test.go +++ b/serve_test.go @@ -442,4 +442,68 @@ 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_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 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") + } } \ No newline at end of file diff --git a/util_test.go b/util_test.go index 44a36f1..c17d8a2 100644 --- a/util_test.go +++ b/util_test.go @@ -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() }() diff --git a/web/ui/app.js b/web/ui/app.js index b5f92e9..7f7e980 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -183,6 +183,129 @@ async function doExtract() { } } +// ─── WAV Browser ────────────────────────────────────────────────── + +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); + +async function browseWavs() { + const dir = document.getElementById('wav-dir').value; + const list = document.getElementById('wav-list'); + list.innerHTML = 'Loading...'; + + try { + const resp = await fetch(`${API}/list-wavs?dir=${encodeURIComponent(dir)}`); + const data = await resp.json(); + if (data.error) { + list.innerHTML = 'Error: ' + data.error + ''; + return; + } + if (!Array.isArray(data) || data.length === 0) { + list.innerHTML = 'No WAV files found'; + return; + } + list.innerHTML = data.map(f => ` +
+ ${esc(f)} + +
+ `).join(''); + } catch (e) { + list.innerHTML = 'Failed: ' + e.message; + } +} + +async function playWav(filename) { + if (!audioCtx) { + audioCtx = new AudioContext(); + } + + if (currentSource) { + currentSource.stop(); + currentSource = null; + } + if (animationFrame) { + cancelAnimationFrame(animationFrame); + animationFrame = null; + } + + const nowPlaying = document.getElementById('wav-now-playing'); + nowPlaying.textContent = 'Loading: ' + filename; + + 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(); + } + }; + + startTime = audioCtx.currentTime - pauseOffset; + currentSource.start(0, pauseOffset); + isPlaying = true; + pauseOffset = 0; + + nowPlaying.textContent = '▶ ' + filename; + 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 formatTime(secs) { + const m = Math.floor(secs / 60); + const s = Math.floor(secs % 60); + return m + ':' + s.toString().padStart(2, '0'); +} + +function setActiveItem(filename) { + document.querySelectorAll('.wav-item').forEach(el => { + el.classList.toggle('playing', el.dataset.file === filename); + }); +} + +function clearActiveItem() { + document.querySelectorAll('.wav-item').forEach(el => { + el.classList.remove('playing'); + }); +} + // ─── Helpers ────────────────────────────────────────────────────── function esc(s) { diff --git a/web/ui/index.html b/web/ui/index.html index c0a6431..3a6e677 100644 --- a/web/ui/index.html +++ b/web/ui/index.html @@ -39,6 +39,22 @@
+
+

WAV Browser

+
+ + +
+
+
+
+
+ 0:00 +
+
+
+
+

Results

diff --git a/web/ui/style.css b/web/ui/style.css index 82bdc98..0262c08 100644 --- a/web/ui/style.css +++ b/web/ui/style.css @@ -205,4 +205,109 @@ 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; +} + +.wav-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; +} + +.wav-item { + background: var(--bg); + border: 1px solid var(--bg3); + border-radius: var(--radius); + padding: 10px 12px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} +.wav-item:hover { + border-color: var(--accent); +} +.wav-item.playing { + border-color: var(--green); + background: rgba(78, 204, 163, 0.1); +} +.wav-item .wav-name { + flex: 1; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wav-item .wav-play-btn { + background: var(--accent2); + color: white; + border: none; + border-radius: 4px; + padding: 4px 10px; + font-size: 11px; + cursor: pointer; } \ No newline at end of file