From ea2bcde74b830495589d85684f3b0b6521e5cb00 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 21 Jun 2026 22:17:24 -0700 Subject: [PATCH] feat: reimplement WAV extraction in Go (replaces extract_wavs.sh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract.go replaces the bash script with idiomatic Go: - akaiutil process communication via stdin/stdout pipes - partition enumeration (df output parsing) - volume enumeration (dir output parsing per partition) - sample2wavall per volume with WAV count aggregation runAkaiutil is a package-level var — tests stub it with canned output to verify parsing without a real akaiutil binary or ISO. Removed: findScript(), -tool flag on extract, os/exec from main.go Kept: extract_wavs.sh as a standalone utility (no longer called) --- extract.go | 202 ++++++++++++++++++++++++++++++++++++++++++++++++ extract_test.go | 117 ++++++++++++++++++++++++++++ main.go | 75 +----------------- serve.go | 4 +- 4 files changed, 323 insertions(+), 75 deletions(-) create mode 100644 extract.go create mode 100644 extract_test.go diff --git a/extract.go b/extract.go new file mode 100644 index 0000000..ad89edc --- /dev/null +++ b/extract.go @@ -0,0 +1,202 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +func findAkaiutil() (string, error) { + if env := os.Getenv("AKAIUTIL"); env != "" { + if fi, err := os.Stat(env); err == nil && !fi.IsDir() { + return env, nil + } + } + + candidates := []string{ + filepath.Join(filepath.Dir(os.Args[0]), "akaiutil"), + filepath.Join(filepath.Dir(os.Args[0]), "third_party", "akaiutil", "akaiutil"), + filepath.Join("third_party", "akaiutil", "akaiutil"), + "akaiutil", + } + for _, p := range candidates { + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { + return p, nil + } + } + return "", fmt.Errorf("akaiutil not found — set AKAIUTIL env var or run: make akaiutil") +} + +var runAkaiutil = func(isoPath, commands string) (string, error) { + bin, err := findAkaiutil() + if err != nil { + return "", err + } + + cmd := exec.Command(bin, "-r", isoPath) + cmd.Stderr = nil + + stdin, err := cmd.StdinPipe() + if err != nil { + return "", fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", fmt.Errorf("stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("start akaiutil: %w", err) + } + + stdin.Write([]byte(commands)) + stdin.Close() + + output, _ := io.ReadAll(stdout) + cmd.Wait() + + return string(output), nil +} + +type volInfo struct { + Part string + Name string +} + +func listPartitions(isoPath string) ([]string, error) { + out, err := runAkaiutil(isoPath, "df\nq\n") + if err != nil { + return nil, err + } + + re := regexp.MustCompile(`^\s+([A-Z])\s+`) + var parts []string + for _, line := range strings.Split(out, "\n") { + if m := re.FindStringSubmatch(line); m != nil { + parts = append(parts, m[1]) + } + } + if len(parts) == 0 { + return []string{"A"}, nil + } + return parts, nil +} + +func listVolumes(isoPath string, partitions []string) ([]volInfo, error) { + var cmds strings.Builder + for _, p := range partitions { + fmt.Fprintf(&cmds, "cd /disk0/%s\n", p) + fmt.Fprintf(&cmds, "dir\n") + } + cmds.WriteString("q\n") + + out, err := runAkaiutil(isoPath, cmds.String()) + if err != nil { + return nil, err + } + + rePrompt := regexp.MustCompile(`/disk0/([A-Z])\s*>`) + reEntry := regexp.MustCompile(`^\s+\d+\s+(.+?)\s+-\s+`) + + var vols []volInfo + currentPart := "" + + for _, line := range strings.Split(out, "\n") { + if m := rePrompt.FindStringSubmatch(line); m != nil { + currentPart = m[1] + continue + } + if m := reEntry.FindStringSubmatch(line); m != nil { + name := strings.TrimRight(m[1], " ") + if currentPart != "" && name != "" { + vols = append(vols, volInfo{Part: currentPart, Name: name}) + } + } + } + return vols, nil +} + +func runExtraction(isoPath, baseDir string, vols []volInfo) (int, error) { + var cmds strings.Builder + for _, v := range vols { + safe := sanitiseISOName(v.Name) + if safe == "" { + safe = "volume" + } + volDir := filepath.Join(baseDir, safe) + os.MkdirAll(volDir, 0755) + + nav := strings.ReplaceAll(v.Name, " ", "_") + fmt.Fprintf(&cmds, "lcd %s\n", volDir) + fmt.Fprintf(&cmds, "cd /disk0/%s/%s\n", v.Part, nav) + fmt.Fprintf(&cmds, "sample2wavall\n") + } + fmt.Fprintf(&cmds, "lcd /\nq\n") + + out, err := runAkaiutil(isoPath, cmds.String()) + if err != nil { + return 0, err + } + + re := regexp.MustCompile(`exported\s+(\d+)\s+file`) + total := 0 + for _, line := range strings.Split(out, "\n") { + if m := re.FindStringSubmatch(line); m != nil { + n, _ := strconv.Atoi(m[1]) + total += n + } + } + return total, nil +} + +func extractWAVs(isoPath, outDir string) (int, error) { + parts, err := listPartitions(isoPath) + if err != nil { + return 0, fmt.Errorf("enumerate partitions: %w", err) + } + + vols, err := listVolumes(isoPath, parts) + if err != nil { + return 0, fmt.Errorf("enumerate volumes: %w", err) + } + if len(vols) == 0 { + return 0, nil + } + + isoBase := sanitiseISOName(filepath.Base(isoPath)) + baseDir := filepath.Join(outDir, isoBase) + os.MkdirAll(baseDir, 0755) + + return runExtraction(isoPath, baseDir, vols) +} + +func extractISO(isoPath, outDir string) (string, error) { + isoOutDir := filepath.Join(outDir, sanitiseISOName(filepath.Base(isoPath))) + + if fi, err := os.Stat(isoOutDir); err == nil && fi.IsDir() { + var wavCount int + filepath.WalkDir(isoOutDir, func(p string, d os.DirEntry, err error) error { + if err == nil && !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".wav") { + wavCount++ + } + return nil + }) + if wavCount > 0 { + return fmt.Sprintf("skipped (%d WAVs exist)", wavCount), nil + } + } + + start := time.Now() + total, err := extractWAVs(isoPath, outDir) + elapsed := time.Since(start).Round(time.Second) + if err != nil { + return "", err + } + return fmt.Sprintf("Total WAV files: %d (%s)", total, elapsed), nil +} diff --git a/extract_test.go b/extract_test.go new file mode 100644 index 0000000..f0d34f9 --- /dev/null +++ b/extract_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "testing" +) + +func TestListPartitions(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + runAkaiutil = func(isoPath, commands string) (string, error) { + if commands != "df\nq\n" { + t.Fatalf("unexpected commands: %q", commands) + } + return ` +Disk /disk0 +Total blocks: 12288 (8 KB each) 98304 KB +Partition table: + A - S1000 HD (3840 blocks) + B - S1000 HD (5120 blocks) + C - S1000 HD (3328 blocks) + `, nil + } + + parts, err := listPartitions("/fake/disk.iso") + if err != nil { + t.Fatal(err) + } + if len(parts) != 3 { + t.Fatalf("expected 3 partitions, got %d: %v", len(parts), parts) + } + if parts[0] != "A" || parts[1] != "B" || parts[2] != "C" { + t.Fatalf("unexpected partitions: %v", parts) + } +} + +func TestListPartitionsEmpty(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + runAkaiutil = func(isoPath, commands string) (string, error) { + return "No partitions found\n", nil + } + + parts, err := listPartitions("/fake/disk.iso") + if err != nil { + t.Fatal(err) + } + if len(parts) != 1 || parts[0] != "A" { + t.Fatalf("expected fallback to [A], got %v", parts) + } +} + +func TestListVolumes(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + runAkaiutil = func(isoPath, commands string) (string, error) { + return ` +/disk0/A > cd /disk0/A +/disk0/A > dir + 1 DRUM KIT 1 - VOLUME (10 files) + 2 BASS SAMPLES - VOLUME (5 files) +/disk0/A > cd /disk0/B +/disk0/B > dir + 3 SYNTH PADS - VOLUME (20 files) +/disk0/B > + `, nil + } + + vols, err := listVolumes("/fake/disk.iso", []string{"A", "B"}) + if err != nil { + t.Fatal(err) + } + if len(vols) != 3 { + t.Fatalf("expected 3 volumes, got %d: %v", len(vols), vols) + } + if vols[0].Part != "A" || vols[0].Name != "DRUM KIT 1" { + t.Fatalf("unexpected vol 0: %+v", vols[0]) + } + if vols[1].Part != "A" || vols[1].Name != "BASS SAMPLES" { + t.Fatalf("unexpected vol 1: %+v", vols[1]) + } + if vols[2].Part != "B" || vols[2].Name != "SYNTH PADS" { + t.Fatalf("unexpected vol 2: %+v", vols[2]) + } +} + +func TestRunExtractionCount(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + calls := 0 + runAkaiutil = func(isoPath, commands string) (string, error) { + calls++ + return ` + exported 10 file(s) + exported 5 file(s) + exported 0 file(s) + `, nil + } + + vols := []volInfo{ + {Part: "A", Name: "DRUMS"}, + {Part: "A", Name: "BASS"}, + } + total, err := runExtraction("/fake/disk.iso", "/tmp/out", vols) + if err != nil { + t.Fatal(err) + } + if total != 15 { + t.Fatalf("expected 15 total WAVs, got %d", total) + } + if calls != 1 { + t.Fatalf("expected 1 akaiutil call, got %d", calls) + } +} diff --git a/main.go b/main.go index 3f874d9..b3c77f7 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,6 @@ import ( "net/http" "net/url" "os" - "os/exec" "path/filepath" "sort" "strconv" @@ -100,10 +99,9 @@ Download flags: -dir "./isos" output directory (default: current dir) -jobs 2 concurrent downloads (default: 2) -Extract flags: + Extract flags: -dir "./isos" directory containing ISO files -out "./wavs" output directory for WAV files - -tool "./extract_wavs.sh" path to extraction script -jobs 2 concurrent extractions (default: 2) Pipeline flags: @@ -382,15 +380,9 @@ func cmdExtract(args []string) { fs := flag.NewFlagSet("extract", flag.ExitOnError) isoDir := fs.String("dir", ".", "directory with ISO files") outDir := fs.String("out", "./wavs", "output directory for WAVs") - toolFlag := fs.String("tool", "", "path to extraction script (default: auto-detect)") jobs := fs.Int("jobs", 2, "concurrent extractions") _ = fs.Parse(args) - toolPath := *toolFlag - if toolPath == "" { - toolPath = findScript("extract_wavs.sh") - } - os.MkdirAll(*outDir, 0755) entries, err := os.ReadDir(*isoDir) @@ -414,8 +406,6 @@ func cmdExtract(args []string) { sem := make(chan struct{}, *jobs) var wg sync.WaitGroup - var mu sync.Mutex - results := map[string]string{} for _, iso := range isos { wg.Add(1) @@ -425,10 +415,7 @@ func cmdExtract(args []string) { defer func() { <-sem }() out := *outDir - result, err := extractISO(toolPath, isoPath, out) - mu.Lock() - results[filepath.Base(isoPath)] = result - mu.Unlock() + result, err := extractISO(isoPath, out) if err != nil { fmt.Printf(" FAIL %-40s %v\n", filepath.Base(isoPath), err) } else { @@ -439,46 +426,6 @@ func cmdExtract(args []string) { wg.Wait() } -func extractISO(toolPath, isoPath, outDir string) (string, error) { - absTool, err := filepath.Abs(toolPath) - if err != nil { - return "", err - } - if _, err := os.Stat(absTool); os.IsNotExist(err) { - return "", fmt.Errorf("script not found: %s", absTool) - } - - isoOutDir := filepath.Join(outDir, sanitiseISOName(filepath.Base(isoPath))) - - if _, err := os.Stat(isoOutDir); err == nil { - var wavCount int - filepath.WalkDir(isoOutDir, func(p string, d os.DirEntry, err error) error { - if err == nil && !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".wav") { - wavCount++ - } - return nil - }) - if wavCount > 0 { - return fmt.Sprintf("skipped (%d WAVs exist)", wavCount), nil - } - } - - cmd := exec.Command("bash", absTool, isoPath, outDir) - cmd.Stderr = nil - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("extract: %w", err) - } - - lines := strings.Split(string(output), "\n") - for _, line := range lines { - if strings.Contains(line, "Total WAV files:") { - return strings.TrimSpace(line), nil - } - } - return "extracted (check output)", nil -} - func sanitiseISOName(path string) string { name := path if ext := filepath.Ext(name); ext != "" { @@ -520,7 +467,6 @@ func cmdPipeline(args []string) { isoDir := filepath.Join(*baseDir, "isos") wavDir := filepath.Join(*baseDir, "wavs") - toolPath := findScript("extract_wavs.sh") // Remaining args after flags are treated as explicit identifiers identifiers := fs.Args() @@ -566,28 +512,13 @@ func cmdPipeline(args []string) { cmdDownload([]string{"-f", idFile, "-dir", isoDir, "-jobs", fmt.Sprintf("%d", *dlJobs)}) fmt.Println("\n=== Phase 3: Extract ===") - cmdExtract([]string{"-dir", isoDir, "-out", wavDir, "-tool", toolPath, "-jobs", fmt.Sprintf("%d", *extJobs)}) + cmdExtract([]string{"-dir", isoDir, "-out", wavDir, "-jobs", fmt.Sprintf("%d", *extJobs)}) fmt.Printf("\n=== Pipeline Complete ===\n") fmt.Printf(" ISOs: %s\n", isoDir) fmt.Printf(" WAVs: %s\n", wavDir) } -func findScript(name string) string { - candidates := []string{ - filepath.Join(filepath.Dir(os.Args[0]), name), - filepath.Join(filepath.Dir(os.Args[0]), "scripts", name), - name, - filepath.Join("scripts", name), - } - for _, p := range candidates { - if _, err := os.Stat(p); err == nil { - return p - } - } - return name -} - // ─── Helpers ─────────────────────────────────────────────────────────── func fatal(format string, args ...any) { diff --git a/serve.go b/serve.go index 5d0aced..63f1593 100644 --- a/serve.go +++ b/serve.go @@ -289,8 +289,6 @@ func handleAPIExtract(w http.ResponseWriter, r *http.Request) { os.MkdirAll(outDir, 0755) - toolPath := findScript("extract_wavs.sh") - entries, err := os.ReadDir(isoDir) if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) @@ -311,7 +309,7 @@ func handleAPIExtract(w http.ResponseWriter, r *http.Request) { var messages []string for _, isoPath := range isos { - result, err := extractISO(toolPath, isoPath, outDir) + result, err := extractISO(isoPath, outDir) if err != nil { messages = append(messages, fmt.Sprintf("FAIL %s: %v", filepath.Base(isoPath), err)) } else {