#!/usr/bin/env python3 """ linn9000edit - browser kit builder for the Linn 9000, on port 8003. ./linn9000edit.py [root-folder] open http://localhost:8003 Lays out the machine's 18 voices as the real panel does, three rows of six. Drop a WAV on a pad, press Build, and you get a .img plus a .hfe ready for the Gotek. NOTE ON PADS: the disk directory stores only 64 named samples - there is no pad-assignment field anywhere in the format. The Linn assigns sounds to pads from its own front panel. So this grid is an organiser: it names each sample after the voice you drop it on, so the machine's list reads like a kit instead of like a folder dump. It cannot pre-assign the pads for you. """ import http.server, json, os, socketserver, subprocess, sys, urllib.parse, glob, io import struct import linnfmt as L import linn9000build as B import hfe2img PORT = 8003 # Two independent roots: WAV source material, and disk images. They are # rarely the same folder, and each has its own picker in the page. LIB_ROOT = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else ".") DISK_ROOT = os.path.abspath(sys.argv[2] if len(sys.argv) > 2 else LIB_ROOT) # 18 voices, three rows of six, in the panel's own order (left to right, # top to bottom) - note bass and hi-hat sit on the BOTTOM row. VOICES = [ ("TOM1", "Tom 1"), ("TOM2", "Tom 2"), ("RIDE1", "Ride 1"), ("RIDE2", "Ride 2"), ("HICONG", "Hi Conga"), ("LOCONG", "Lo Conga"), ("SSTK", "Sidestick"), ("SNARE", "Snare"), ("TOM3", "Tom 3"), ("CRASH1", "Crash 1"), ("COWBEL", "Cowbell"), ("CLAPS", "Claps"), ("HIHAT", "Hi Hat"), ("BASS", "Bass"), ("TOM4", "Tom 4"), ("CRASH2", "Crash 2"), ("TAMBR", "Tambourine"), ("CABASA", "Cabasa"), ] def choose_folder(start): """Open the OS folder picker. -> path, or None if cancelled/unavailable.""" if sys.platform == "darwin": scr = ('tell application "Finder" to activate\n' 'set f to choose folder with prompt "Choose the sample library"' f' default location POSIX file "{start}"\n' 'POSIX path of f') r = subprocess.run(["osascript", "-e", scr], capture_output=True, text=True) return r.stdout.strip() or None # non-zero = user cancelled for cmd in (["zenity", "--file-selection", "--directory"], ["kdialog", "--getexistingdirectory", start]): try: r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode == 0 and r.stdout.strip(): return r.stdout.strip() except FileNotFoundError: continue return None def load_disk(path): """Read a .img or .hfe and return the raw 737280-byte image.""" d = open(path, "rb").read() if d[:8] != b"HXCPICFE": return d ntrack, nside = d[9], d[10] lut = struct.unpack_from(" {voice: sample name}. Every (voice, sample) pair is scored by how good the evidence is: an an exact stem match beats a prefix match, which beats the stem merely appearing inside the name, and an earlier entry in the voice's alias list beats a later one since those lists are written most-canonical first. Assign greedily from best to worst, so SNARE1 wins SNARE over F16SN2, and nothing gets used twice. The substring tier is what makes prefixed banks work: an 808 disk names things 808KIK and 808SNR, which no prefix rule would ever catch. """ cands = [] for k, _ in VOICES: for ai, stem in enumerate(ALIAS.get(k, [k])): for n in names: nn = _norm(n) if nn == stem: cands.append((0, ai, k, n)) elif nn.startswith(stem): cands.append((1, ai, k, n)) elif len(stem) >= 3 and stem in nn: # catches prefixed banks: 808KIK, 08SNR2, 909BAS cands.append((2, ai, k, n)) cands.sort() out, taken = {}, set() for _, _, k, n in cands: if k not in out and n not in taken: out[k] = n taken.add(n) return out PAGE = """Linn 9000 kit builder

Linn 9000 kit builder

18 voices, three rows of six, as on the panel · 2.05 s per sample · 716 KB per disk
WAV library
Load an existing disk

WAV files
Click a file to hear it. With a file selected, click a pad to assign. Click a filled pad to preview it, or the × to clear.
""" class H(http.server.BaseHTTPRequestHandler): def log_message(self, *a): pass def _send(self, code, ctype, body): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): u = urllib.parse.urlparse(self.path) q = urllib.parse.parse_qs(u.query) if u.path == "/": page = PAGE.replace("VOICES.map", "%s.map" % json.dumps(VOICES)) return self._send(200, "text/html; charset=utf-8", page.encode()) if u.path == "/api/files": fs = [] for dp, _, fn in os.walk(LIB_ROOT): if os.path.basename(dp).startswith("."): continue for f in sorted(fn): if f.lower().endswith(".wav"): fs.append(os.path.relpath(os.path.join(dp, f), LIB_ROOT)) return self._send(200, "application/json", json.dumps({"files": sorted(fs)[:4000], "root": LIB_ROOT}).encode()) if u.path == "/api/disks": ds = [] for dp, _, fn in os.walk(DISK_ROOT): for f in sorted(fn): if f.lower().endswith((".img", ".hfe")): ds.append(os.path.relpath(os.path.join(dp, f), DISK_ROOT)) return self._send(200, "application/json", json.dumps({"disks": sorted(ds), "root": DISK_ROOT}).encode()) if u.path == "/api/disk": rel = q["p"][0] try: img = load_disk(os.path.join(DISK_ROOT, rel)) es = L.entries(img) except Exception as e: return self._send(200, "application/json", json.dumps({"err": str(e)}).encode()) samples = [{"name": n, "bytes": t - L.HDR, "sec": (t - L.HDR) / L.RATE} for n, t, b, _ in es] return self._send(200, "application/json", json.dumps( {"samples": samples, "match": automatch([x["name"] for x in samples])}).encode()) if u.path == "/api/dsample": img = load_disk(os.path.join(DISK_ROOT, q["p"][0])) want = q["n"][0] for n, t, b, _ in L.entries(img): if n == want: return self._send(200, "audio/wav", L.wav_bytes(L.read_audio(img, b, t))) return self._send(404, "text/plain", b"no such sample") if u.path == "/api/wav": try: x, sr = B.load_wav(os.path.join(LIB_ROOT, q["p"][0])) au = B.encode(B.resample(x, sr, L.RATE)) except SystemExit as e: return self._send(404, "text/plain", str(e).encode()) return self._send(200, "audio/wav", L.wav_bytes(au)) if u.path == "/api/probe": p = os.path.join(LIB_ROOT, q["p"][0]) try: x, sr = B.load_wav(p) x = B.resample(x, sr, L.RATE) n = len(B.encode(x)) except SystemExit as e: return self._send(200, "application/json", json.dumps({"err": str(e)}).encode()) return self._send(200, "application/json", json.dumps({"bytes": n, "sec": n / L.RATE}).encode()) return self._send(404, "text/plain", b"no") def do_POST(self): global LIB_ROOT, DISK_ROOT n = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(n) if self.path == "/api/browse": which = json.loads(body or b"{}").get("which", "lib") p = choose_folder(LIB_ROOT if which == "lib" else DISK_ROOT) if not p: return self._send(200, "application/json", json.dumps({"cancelled": True}).encode()) p = os.path.abspath(p.rstrip("/")) or "/" if not os.path.isdir(p): return self._send(200, "application/json", json.dumps({"err": f"not a folder: {p}"}).encode()) if which == "lib": LIB_ROOT = p else: DISK_ROOT = p return self._send(200, "application/json", json.dumps({"which": which, "root": p}).encode()) if self.path == "/api/build": d = json.loads(body) items, log = [], [] for key, label in VOICES: p = d["kit"].get(key) if not p: continue if p.startswith("disk:"): # straight from another disk dp, _, sn = p[5:].rpartition("#") img = load_disk(os.path.join(DISK_ROOT, dp)) au = b"" for n, t, b, _ in L.entries(img): if n == sn: au = L.read_audio(img, b, t) break else: x, sr = B.load_wav(os.path.join(LIB_ROOT, p)) x = B.resample(x, sr, L.RATE) au = B.encode(x) if len(au) > L.MAX_SAMPLE - L.HDR: au = au[:L.MAX_SAMPLE - L.HDR] log.append(f" {key}: cut to 2.05 s") items.append((L.clean_name(key), au)) if not items: return self._send(200, "application/json", json.dumps({"log": " nothing assigned"}).encode()) img, placed = B.build(items) base = "".join(c for c in d.get("name", "MYKIT") if c.isalnum() or c in "-_") ip = os.path.join(DISK_ROOT, base + ".img") open(ip, "wb").write(img) blocks = sum(len(b) for _, _, b in placed) log.append(f" {len(placed)} samples, {blocks}/179 blocks -> {ip}") hp = os.path.join(DISK_ROOT, base + ".hfe") rc = os.system(f'"{sys.executable}" "{os.path.join(os.path.dirname(os.path.abspath(__file__)),"img2hfe.py")}" "{ip}" -o "{hp}" >/dev/null 2>&1') log.append(f" -> {hp}" if rc == 0 else " HFE step failed; run img2hfe.py by hand") return self._send(200, "application/json", json.dumps({"log": "\n".join(log)}).encode()) return self._send(404, "text/plain", b"no") if __name__ == "__main__": socketserver.TCPServer.allow_reuse_address = True with socketserver.TCPServer(("127.0.0.1", PORT), H) as s: print(f" Linn 9000 kit builder on http://localhost:{PORT}") print(f" WAV library {LIB_ROOT}") print(f" disk images {DISK_ROOT}") s.serve_forever()