diff --git a/scripts/build_site.py b/scripts/build_site.py new file mode 100644 index 0000000..4aeb36f --- /dev/null +++ b/scripts/build_site.py @@ -0,0 +1,1202 @@ +#!/usr/bin/env python3 +"""Build the static documentation site for danzig. + +The Markdown under docs/ is the single source of truth. This script reads it and +writes finished HTML and CSS into site/, which a webhook deploys verbatim. There +is no build step on the server, so the output is committed alongside this file. + +Python 3 standard library only. Running it twice produces byte-identical output. + + python3 scripts/build_site.py +""" + +from __future__ import annotations + +import html +import os +import posixpath +import re +import sys + +# ------------------------------------------------------------------ site config + +REPO_NAME = "danzig" +GITHUB = "https://github.com/godofecht/danzig" +TAGLINE = "VST3 plugin framework in pure Zig" + +NAV_FOOTER = ( + ("GitHub", GITHUB), + ("Zaza", "https://github.com/godofecht/zaza"), + ("Azazel", "https://github.com/godofecht/azazel"), +) + +# Markdown inputs. "base" is the directory relative links inside that file are +# resolved against. +SOURCES = { + "wiki": {"path": "docs/WIKI.md", "base": "docs"}, +} + +# Sections deliberately left out of the site. The wiki's own table of contents is +# replaced by the sidebar. +SKIP_SECTIONS = { + "wiki": ("contents",), +} + +# Each page is one HTML file. "parts" names the source sections it carries, in +# order. "__preamble__" is everything before the first level-two heading. +PAGES = ( + { + "slug": "index", + "src": "wiki", + "title": "danzig", + "nav": "Overview", + "group": "Guide", + "hero": True, + "description": "A VST3 plugin framework written in pure Zig. " + "No JUCE. No Steinberg SDK. No C++ at all in the core.", + "parts": ("__preamble__", "what-danzig-is", "current-state"), + }, + { + "slug": "architecture", + "src": "wiki", + "title": "Architecture", + "nav": "Architecture", + "group": "Guide", + "subtitle": "COM in Zig, how a plugin is registered, and the audio " + "callback path.", + "description": "How danzig expresses the VST3 COM ABI as Zig extern " + "structs, how a plugin registers itself, and what the " + "audio callback path looks like.", + "parts": ("architecture",), + }, + { + "slug": "getting-started", + "src": "wiki", + "title": "Getting Started", + "nav": "Getting Started", + "group": "Guide", + "subtitle": "What you need installed, then five minutes from clone to " + "an installed bundle.", + "description": "danzig prerequisites and a seven-step quickstart: " + "clone, build, test, package the VST3 bundle, install " + "it, and hear the DSP.", + "parts": ("prerequisites", "quickstart"), + }, + { + "slug": "parameters", + "src": "wiki", + "title": "The Parameter System", + "nav": "Parameters", + "group": "Reference", + "description": "AtomicParam and ParamStore: lock-free parameters " + "written by the UI thread and read by the audio thread, " + "one cache line each.", + "parts": ("the-parameter-system",), + }, + { + "slug": "audio-helpers", + "src": "wiki", + "title": "The Audio Helpers", + "nav": "Audio Helpers", + "group": "Reference", + "description": "dBToLinear, linearTodB, GainProcessor, SimpleRamp, and " + "AudioBuffer, the small dependency-free DSP helpers in " + "src/audio.zig.", + "parts": ("the-audio-helpers",), + }, + { + "slug": "vst3-bundle", + "src": "wiki", + "title": "Building the Universal VST3 Bundle", + "nav": "VST3 Bundle", + "group": "Reference", + "description": "How zig build vst3 compiles both macOS architectures, " + "merges them with lipo, and lays out the .vst3 bundle.", + "parts": ("building-the-universal-vst3-bundle",), + }, + { + "slug": "testing", + "src": "wiki", + "title": "Testing", + "nav": "Testing", + "group": "Project", + "description": "The 35 unit tests, the VST3 ABI integration harness " + "that drives the built plugin through the raw C ABI, " + "and the CI matrix.", + "parts": ("testing",), + }, + { + "slug": "examples", + "src": "wiki", + "title": "Examples", + "nav": "Examples", + "group": "Project", + "description": "The six example directories in the danzig repository, " + "what each one shows, and the command that runs it.", + "parts": ("examples",), + }, + { + "slug": "troubleshooting", + "src": "wiki", + "title": "Troubleshooting", + "nav": "Troubleshooting", + "group": "Project", + "description": "Fixes for link errors, single-architecture bundles, " + "DAW scan failures, the web UI and GUI examples, and " + "parameter clicks.", + "parts": ("troubleshooting",), + }, + { + "slug": "licensing", + "src": "wiki", + "title": "Licensing and Trademarks", + "nav": "Licensing", + "group": "Project", + "description": "danzig is MIT licensed and vendors no Steinberg SDK " + "code. VST is a Steinberg trademark with its own terms.", + "parts": ("licensing-and-trademarks",), + }, +) + +# ------------------------------------------------------------------ stylesheet +# +# The first block is azazel's site/style.css, copied unchanged so the three +# project sites read as one family. The block after the marker adds only what +# this generator's markup needs: a language label on fenced code, a scroll +# container for wide tables, blockquotes, and rules. + +STYLE_CSS = """\ +:root { + --bg: #0a0e14; + --surface: #12161e; + --surface-raised: #181d27; + --border: #252d3a; + --border-accent: #2a4a7f; + --text: #e6edf3; + --text-secondary: #c5cdd8; + --text-muted: #7d8a9a; + --accent: #58a6ff; + --accent-hover: #79b8ff; + --accent-dim: #1a3a5c; + --code-bg: #0f1319; + --green: #3fb950; + --orange: #d29922; + --red: #f85149; + --purple: #bc8cff; + --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Cascadia Code', monospace; + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + --radius: 8px; + --radius-lg: 12px; + --shadow: 0 2px 8px rgba(0,0,0,0.3); + --shadow-lg: 0 4px 24px rgba(0,0,0,0.4); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.7; + display: flex; + min-height: 100vh; +} + +/* ── Sidebar ── */ +nav { + width: 280px; + min-width: 280px; + background: var(--surface); + border-right: 1px solid var(--border); + padding: 2rem 1.5rem; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +nav .logo { + font-family: var(--font-mono); + font-size: 1.5rem; + font-weight: 800; + color: var(--accent); + text-decoration: none; + display: block; + margin-bottom: 0.15rem; + letter-spacing: -0.02em; +} + +nav .tagline { + font-size: 0.72rem; + color: var(--text-muted); + margin-bottom: 0.5rem; + line-height: 1.4; + letter-spacing: 0.01em; +} + +nav .version { + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--text-muted); + background: var(--surface-raised); + border: 1px solid var(--border); + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 99px; + margin-bottom: 2rem; +} + +nav .section-label { + font-size: 0.65rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 1.25rem 0.75rem 0.4rem; +} + +nav a { + display: block; + color: var(--text-muted); + text-decoration: none; + padding: 0.45rem 0.75rem; + border-radius: 6px; + font-size: 0.88rem; + transition: all 0.15s; + border-left: 2px solid transparent; +} + +nav a:hover { + color: var(--text); + background: rgba(88,166,255,0.06); +} + +nav a.active { + color: var(--accent); + background: rgba(88,166,255,0.08); + border-left-color: var(--accent); +} + +nav .spacer { flex: 1; } + +nav .nav-footer { + border-top: 1px solid var(--border); + padding-top: 1rem; + margin-top: 1rem; +} + +nav .nav-footer a { + font-size: 0.8rem; + padding: 0.35rem 0.75rem; +} + +/* ── Main Content ── */ +main { + flex: 1; + max-width: 860px; + padding: 3.5rem 4.5rem 6rem; +} + +h1 { + font-size: 2.2rem; + font-weight: 800; + margin-bottom: 0.5rem; + letter-spacing: -0.025em; + color: var(--text); +} + +.page-subtitle { + font-size: 1.05rem; + color: var(--text-muted); + margin-bottom: 2.5rem; + line-height: 1.5; + border-bottom: 1px solid var(--border); + padding-bottom: 1.5rem; +} + +h2 { + font-size: 1.4rem; + font-weight: 700; + margin-top: 3rem; + margin-bottom: 1rem; + color: var(--text); + letter-spacing: -0.01em; +} + +h3 { + font-size: 1.1rem; + font-weight: 600; + margin-top: 2rem; + margin-bottom: 0.6rem; + color: var(--text-secondary); +} + +p { margin-bottom: 1rem; color: var(--text-secondary); } + +a { color: var(--accent); text-decoration: none; transition: color 0.15s; } +a:hover { color: var(--accent-hover); text-decoration: underline; } + +strong { color: var(--text); font-weight: 600; } + +/* ── Code ── */ +code { + font-family: var(--font-mono); + font-size: 0.84em; + background: var(--code-bg); + padding: 0.2em 0.45em; + border-radius: 4px; + color: var(--green); + border: 1px solid var(--border); +} + +pre { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.4rem 1.6rem; + overflow-x: auto; + margin-bottom: 1.5rem; + position: relative; +} + +pre code { + background: none; + padding: 0; + color: var(--text); + font-size: 0.84rem; + line-height: 1.6; + border: none; +} + +/* ── Tables ── */ +table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1.5rem; + font-size: 0.9rem; + border-radius: var(--radius); + overflow: hidden; + border: 1px solid var(--border); +} + +th, td { + text-align: left; + padding: 0.7rem 1rem; + border-bottom: 1px solid var(--border); +} + +th { + background: var(--surface-raised); + font-weight: 600; + color: var(--text-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +td { color: var(--text-secondary); } +tr:last-child td { border-bottom: none; } + +/* ── Lists ── */ +ul, ol { margin-bottom: 1rem; padding-left: 1.5rem; } +li { margin-bottom: 0.4rem; color: var(--text-secondary); } + +/* ── Diagram Container ── */ +.diagram { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 2rem; + margin: 1.5rem 0 2rem; + text-align: center; + box-shadow: var(--shadow); +} + +.diagram svg { max-width: 100%; height: auto; } + +/* ── Cards ── */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.4rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.card:hover { + border-color: var(--border-accent); + box-shadow: var(--shadow); +} + +.card h4 { + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 0.4rem; + color: var(--text); +} + +.card p { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 0; + line-height: 1.5; +} + +.card .card-icon { + font-size: 1.5rem; + margin-bottom: 0.6rem; + display: block; +} + +/* ── Callouts ── */ +.callout { + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + padding: 1rem 1.25rem; + margin: 1.5rem 0; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.callout.warning { border-left-color: var(--orange); } +.callout.success { border-left-color: var(--green); } + +.callout strong { display: block; margin-bottom: 0.3rem; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; } +.callout.warning strong { color: var(--orange); } +.callout.success strong { color: var(--green); } +.callout strong { color: var(--accent); } + +/* ── Badge ── */ +.badge { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.7rem; + padding: 0.15rem 0.5rem; + border-radius: 99px; + border: 1px solid var(--border); + color: var(--text-muted); + vertical-align: middle; + margin-left: 0.4rem; +} + +/* ── Hero Section ── */ +.hero { + text-align: center; + padding: 1rem 0 2rem; + border-bottom: 1px solid var(--border); + margin-bottom: 2.5rem; +} + +.hero h1 { + font-size: 2.8rem; + font-weight: 800; + letter-spacing: -0.03em; + margin-bottom: 0.75rem; +} + +.hero .hero-sub { + font-size: 1.15rem; + color: var(--text-muted); + max-width: 520px; + margin: 0 auto 1.5rem; + line-height: 1.6; +} + +.hero .hero-badges { + display: flex; + gap: 0.6rem; + justify-content: center; + flex-wrap: wrap; +} + +.hero .hero-badge { + font-family: var(--font-mono); + font-size: 0.75rem; + padding: 0.3rem 0.8rem; + border-radius: 99px; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-muted); +} + +/* ── Link Buttons ── */ +.link-row { + display: flex; + gap: 0.75rem; + margin: 1.5rem 0; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.88rem; + font-weight: 500; + padding: 0.55rem 1.2rem; + border-radius: 6px; + text-decoration: none; + transition: all 0.15s; +} + +.btn-primary { + background: var(--accent); + color: #0a0e14; +} + +.btn-primary:hover { + background: var(--accent-hover); + text-decoration: none; + color: #0a0e14; +} + +.btn-secondary { + background: var(--surface); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + border-color: var(--accent); + color: var(--text); + text-decoration: none; +} + +/* ── Footer ── */ +.page-footer { + margin-top: 4rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + display: flex; + justify-content: space-between; + font-size: 0.85rem; +} + +.page-footer a { + color: var(--text-muted); +} + +.page-footer a:hover { color: var(--accent); } + +/* ── Responsive ── */ +@media (max-width: 900px) { + body { flex-direction: column; } + nav { + width: 100%; + min-width: unset; + height: auto; + position: relative; + padding: 1rem 1.25rem; + flex-direction: row; + flex-wrap: wrap; + gap: 0.2rem; + align-items: center; + } + nav .logo { margin-right: 1rem; margin-bottom: 0; } + nav .tagline, nav .version, nav .section-label, nav .spacer, nav .nav-footer { display: none; } + main { padding: 2rem 1.5rem 4rem; } + .hero h1 { font-size: 2rem; } + .card-grid { grid-template-columns: 1fr; } +} + +/* ── Additions for generated documentation pages ── */ + +main { min-width: 0; overflow-wrap: break-word; } + +pre[data-lang] { padding-top: 2rem; } + +pre[data-lang]::before { + content: attr(data-lang); + position: absolute; + top: 0.55rem; + right: 1rem; + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + pointer-events: none; +} + +.table-scroll { + overflow-x: auto; + margin-bottom: 1.5rem; + border-radius: var(--radius); +} + +.table-scroll table { margin-bottom: 0; } + +blockquote { + margin: 1.5rem 0; + padding: 0.75rem 1.25rem; + background: var(--surface); + border-left: 3px solid var(--border-accent); + border-radius: 0 var(--radius) var(--radius) 0; +} + +blockquote > :last-child { margin-bottom: 0; } + +hr { + border: none; + border-top: 1px solid var(--border); + margin: 2.5rem 0; +} + +h2, h3, h4 { scroll-margin-top: 1.5rem; } + +@media (max-width: 900px) { + main { padding-left: 1.25rem; padding-right: 1.25rem; } + table { font-size: 0.82rem; } +} +""" + +# ------------------------------------------------------------------- markdown + +FENCE_RE = re.compile(r"^(`{3,}|~{3,})\s*([A-Za-z0-9_+#.-]*)\s*$") +HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") +HR_RE = re.compile(r"^\s{0,3}(-{3,}|\*{3,}|_{3,})\s*$") +ULI_RE = re.compile(r"^(\s*)([-*+])\s+(.*)$") +OLI_RE = re.compile(r"^(\s*)(\d+)[.)]\s+(.*)$") +QUOTE_RE = re.compile(r"^\s{0,3}>\s?(.*)$") +TABLE_DELIM_RE = re.compile(r"^\s*\|?(\s*:?-{2,}:?\s*\|)+\s*:?-{2,}:?\s*\|?\s*$") + +INLINE_RE = re.compile( + r"(?P`+)(?P.+?)(?P=ticks)" + r"|\[(?P[^\]]*)\]\((?P[^)\s]*)\)", + re.S, +) + + +def slugify(text): + """GitHub-style anchor slug for a heading.""" + t = re.sub(r"`([^`]*)`", r"\1", text) + t = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", t) + t = t.replace("**", "").replace("*", "") + t = t.strip().lower().replace(" ", "-") + return re.sub(r"[^a-z0-9\-_]", "", t) + + +def esc(text): + return html.escape(text, quote=False) + + +def attr(text): + return html.escape(text, quote=True) + + +def emphasis(s): + s = re.sub(r"\*\*(?=\S)(.+?)(?<=\S)\*\*", r"\1", s, flags=re.S) + s = re.sub(r"(?\1", s, flags=re.S) + s = re.sub(r"(?\1", s, flags=re.S) + return s + + +def split_row(line): + """Split a table row into cells, ignoring pipes inside code spans.""" + line = line.strip() + if line.startswith("|"): + line = line[1:] + if line.endswith("|") and not line.endswith("\\|"): + line = line[:-1] + cells, buf, i = [], [], 0 + while i < len(line): + c = line[i] + if c == "\\" and i + 1 < len(line) and line[i + 1] == "|": + buf.append("|") + i += 2 + continue + if c == "`": + j = i + while j < len(line) and line[j] == "`": + j += 1 + run = line[i:j] + k = line.find(run, j) + if k != -1: + buf.append(line[i:k + len(run)]) + i = k + len(run) + continue + if c == "|": + cells.append("".join(buf).strip()) + buf = [] + i += 1 + continue + buf.append(c) + i += 1 + cells.append("".join(buf).strip()) + # A pipe escaped as \| is a literal pipe wherever it sits, including inside + # a code span, which the loop above copies through verbatim. + return [c.replace("\\|", "|") for c in cells] + + +def starts_block(line): + return bool( + FENCE_RE.match(line) + or HEADING_RE.match(line) + or HR_RE.match(line) + or QUOTE_RE.match(line) + or ULI_RE.match(line) + or OLI_RE.match(line) + ) + + +def parse_list(lines, i, ordered): + rx = OLI_RE if ordered else ULI_RE + items, cur, cur_indent = [], None, 0 + n = len(lines) + while i < n: + line = lines[i] + m = rx.match(line) + if m and not m.group(1): + marker = (m.group(2) + ". ") if ordered else (m.group(2) + " ") + cur_indent = len(marker) + cur = [m.group(3)] + items.append(cur) + i += 1 + continue + if not line.strip(): + j = i + 1 + while j < n and not lines[j].strip(): + j += 1 + nxt = lines[j] if j < n else None + cont = False + if nxt is not None: + mm = rx.match(nxt) + if mm and not mm.group(1): + cont = True + elif nxt[:cur_indent].strip() == "" and nxt.startswith(" "): + cont = True + if cont: + if cur is not None: + cur.append("") + i = j + continue + break + if cur is not None and line.startswith(" "): + pad = len(line) - len(line.lstrip(" ")) + cur.append(line[min(pad, cur_indent):]) + i += 1 + continue + if cur is not None and not starts_block(line): + cur.append(line.strip()) + i += 1 + continue + break + return i, [parse_blocks(item) for item in items] + + +def parse_blocks(lines): + blocks = [] + i, n = 0, len(lines) + while i < n: + line = lines[i] + if not line.strip(): + i += 1 + continue + + m = FENCE_RE.match(line) + if m: + fence, lang = m.group(1), m.group(2) + close = re.compile(r"^\s*" + re.escape(fence[0]) + "{%d,}\\s*$" % len(fence)) + i += 1 + body = [] + while i < n and not close.match(lines[i]): + body.append(lines[i]) + i += 1 + i += 1 + blocks.append(("code", lang, body)) + continue + + if HR_RE.match(line): + blocks.append(("hr",)) + i += 1 + continue + + m = HEADING_RE.match(line) + if m: + blocks.append(["heading", len(m.group(1)), m.group(2), ""]) + i += 1 + continue + + if QUOTE_RE.match(line): + inner = [] + while i < n and (QUOTE_RE.match(lines[i]) or (lines[i].strip() and not starts_block(lines[i]))): + q = QUOTE_RE.match(lines[i]) + inner.append(q.group(1) if q else lines[i].strip()) + i += 1 + blocks.append(("quote", parse_blocks(inner))) + continue + + if line.lstrip().startswith("|") and i + 1 < n and TABLE_DELIM_RE.match(lines[i + 1]): + header = split_row(line) + i += 2 + rows = [] + while i < n and lines[i].lstrip().startswith("|"): + rows.append(split_row(lines[i])) + i += 1 + blocks.append(("table", header, rows)) + continue + + mo, mu = OLI_RE.match(line), ULI_RE.match(line) + if (mo and not mo.group(1)) or (mu and not mu.group(1)): + ordered = bool(mo and not mo.group(1)) + i, items = parse_list(lines, i, ordered) + blocks.append(("list", ordered, items)) + continue + + para = [] + while i < n and lines[i].strip() and not starts_block(lines[i]): + if lines[i].lstrip().startswith("|") and i + 1 < n and TABLE_DELIM_RE.match(lines[i + 1]): + break + para.append(lines[i].strip()) + i += 1 + if para: + blocks.append(("para", "\n".join(para))) + else: + i += 1 + return blocks + + +# -------------------------------------------------------------------- linking + + +class Ctx: + def __init__(self, root, base, anchors, pathmap): + self.root = root + self.base = base + self.anchors = anchors + self.pathmap = pathmap + + +def resolve(url, ctx): + url = url.strip() + if not url: + return url + if url.startswith(("http://", "https://", "mailto:", "//", "#")): + if url.startswith("#"): + slug = url[1:] + page = ctx.anchors.get(slug) + if page is None: + raise SystemExit("build_site: unresolved anchor link %r" % url) + return "%s.html#%s" % (page, slug) + return url + path, _, frag = url.partition("#") + norm = posixpath.normpath(posixpath.join(ctx.base, path)) if path else "" + suffix = ("#" + frag) if frag else "" + if norm in ctx.pathmap: + return "%s.html%s" % (ctx.pathmap[norm], suffix) + kind = "tree" if os.path.isdir(os.path.join(ctx.root, norm)) else "blob" + return "%s/%s/main/%s%s" % (GITHUB, kind, norm, suffix) + + +def inline(text, ctx): + """Render inline Markdown. + + Code spans and links are rendered first and parked behind placeholders, so + emphasis is applied to the whole string afterwards. That is what lets + ``**a `b` c**`` become one strong span rather than two stray asterisk pairs. + """ + parked, out, pos = [], [], 0 + for m in INLINE_RE.finditer(text): + out.append(esc(text[pos:m.start()])) + if m.group("code") is not None: + body = m.group("code") + if len(body) > 1 and body.startswith(" ") and body.endswith(" "): + body = body[1:-1] + rendered = "%s" % esc(body) + else: + href = resolve(m.group("url"), ctx) + rendered = '%s' % (attr(href), inline(m.group("text"), ctx)) + out.append("\x00%d\x01" % len(parked)) + parked.append(rendered) + pos = m.end() + out.append(esc(text[pos:])) + s = emphasis("".join(out)) + return re.sub(r"\x00(\d+)\x01", lambda mm: parked[int(mm.group(1))], s) + + +# ------------------------------------------------------------------ rendering + + +def render_blocks(blocks, ctx): + out = [] + for b in blocks: + kind = b[0] + if kind == "heading": + lvl = min(max(b[1], 2), 4) + out.append('%s' % (lvl, attr(b[3]), inline(b[2], ctx), lvl)) + elif kind == "para": + out.append("

%s

" % inline(b[1], ctx)) + elif kind == "code": + body = esc("\n".join(b[2])) + if b[1]: + out.append('
%s
' + % (attr(b[1]), attr(b[1]), body)) + else: + out.append("
%s
" % body) + elif kind == "hr": + out.append("
") + elif kind == "quote": + out.append("
\n%s\n
" % "\n".join(render_blocks(b[1], ctx))) + elif kind == "table": + rows = ['
', ""] + if b[1]: + rows.append("%s" + % "".join("" % inline(c, ctx) for c in b[1])) + rows.append("") + for r in b[2]: + rows.append("%s" % "".join("" % inline(c, ctx) for c in r)) + rows.append("") + rows.append("
%s
%s
") + rows.append("
") + out.append("\n".join(rows)) + elif kind == "list": + tag = "ol" if b[1] else "ul" + rows = ["<%s>" % tag] + for item in b[2]: + if len(item) == 1 and item[0][0] == "para": + rows.append("
  • %s
  • " % inline(item[0][1], ctx)) + else: + rows.append("
  • \n%s\n
  • " % "\n".join(render_blocks(item, ctx))) + rows.append("" % tag) + out.append("\n".join(rows)) + else: + raise SystemExit("build_site: unknown block %r" % (kind,)) + return out + + +def build_nav(active): + lines = ["") + return "\n".join(lines) + + +def page_document(page, body, prev_page, next_page): + title = page["title"] if page["slug"] == "index" else "%s · %s" % (page["title"], REPO_NAME) + if page["slug"] == "index": + title = "%s · %s" % (REPO_NAME, TAGLINE) + footer = ['") + + return "\n".join([ + "", + '', + "", + '', + '', + "%s" % esc(title), + '' % attr(page["description"]), + '', + "", + "", + build_nav(page["slug"]), + "
    ", + "", + body, + "", + "\n".join(footer), + "", + "
    ", + "", + "", + "", + ]) + + +# ---------------------------------------------------------------------- input + + +def read_version(): + path = os.path.join(repo_root(), "build.zig.zon") + with open(path, encoding="utf-8") as fh: + m = re.search(r'\.version\s*=\s*"([^"]+)"', fh.read()) + if not m: + raise SystemExit("build_site: no .version in build.zig.zon") + return m.group(1) + + +def repo_root(): + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def split_sections(text): + """Split Markdown on level-two headings. Returns (preamble, {slug: lines}).""" + preamble, sections, cur = [], {}, None + in_fence = False + for line in text.split("\n"): + if FENCE_RE.match(line): + in_fence = not in_fence + if not in_fence and line.startswith("## "): + slug = slugify(line[3:]) + if slug in sections: + raise SystemExit("build_site: duplicate section slug %r" % slug) + cur = [line] + sections[slug] = cur + continue + (cur if cur is not None else preamble).append(line) + return preamble, sections + + +def trim_rules(blocks): + while blocks and blocks[0][0] == "hr": + blocks.pop(0) + while blocks and blocks[-1][0] == "hr": + blocks.pop() + return blocks + + +def main(): + root = repo_root() + out_dir = os.path.join(root, "site") + + loaded = {} + for name, spec in SOURCES.items(): + with open(os.path.join(root, spec["path"]), encoding="utf-8") as fh: + text = fh.read() + pre, secs = split_sections(text) + loaded[name] = {"preamble": pre, "sections": secs, "base": spec["base"]} + + # Every section must be either placed on a page or explicitly skipped. + used = {name: set(SKIP_SECTIONS.get(name, ())) for name in SOURCES} + for page in PAGES: + for part in page["parts"]: + if part != "__preamble__": + used[page["src"]].add(part) + for name, data in loaded.items(): + missing = [s for s in data["sections"] if s not in used[name]] + if missing: + raise SystemExit("build_site: sections not placed on any page: %s" % ", ".join(missing)) + unknown = [s for s in used[name] if s not in data["sections"]] + if unknown: + raise SystemExit("build_site: unknown sections referenced: %s" % ", ".join(unknown)) + + pathmap = {} + for name, spec in SOURCES.items(): + for page in PAGES: + if page["src"] == name: + pathmap[spec["path"]] = page["slug"] + break + + # Pass one: parse each page, assign heading ids, collect the anchor map. + anchors = {} + parsed = [] + for page in PAGES: + data = loaded[page["src"]] + page_slug = slugify(page["title"]) + blocks, hero_sub = [], None + seen = {} + + def take_id(raw): + base = slugify(raw) or "section" + if base in seen: + seen[base] += 1 + base = "%s-%d" % (base, seen[base]) + else: + seen[base] = 0 + anchors.setdefault(base, page["slug"]) + return base + + anchors.setdefault(page_slug, page["slug"]) + seen[page_slug] = 0 + + for part in page["parts"]: + if part == "__preamble__": + part_blocks = parse_blocks(list(data["preamble"])) + if part_blocks and part_blocks[0][0] == "heading" and part_blocks[0][1] == 1: + part_blocks.pop(0) + if page.get("hero") and part_blocks and part_blocks[0][0] == "para": + hero_sub = part_blocks.pop(0)[1] + else: + lines = list(data["sections"][part]) + if slugify(lines[0][3:]) == page_slug: + lines.pop(0) + anchors.setdefault(part, page["slug"]) + part_blocks = parse_blocks(lines) + blocks.extend(trim_rules(part_blocks)) + + for b in blocks: + if b[0] == "heading": + b[3] = take_id(b[2]) + parsed.append({"page": page, "blocks": blocks, "hero_sub": hero_sub, "h1_id": page_slug}) + + # Nested headings inside list items and blockquotes do not occur in these + # sources; the anchor map above covers every heading reachable from a page. + + # Pass two: render. + os.makedirs(out_dir, exist_ok=True) + written = [] + for idx, item in enumerate(parsed): + page = item["page"] + ctx = Ctx(root, SOURCES[page["src"]]["base"], anchors, pathmap) + parts = [] + if page.get("hero"): + hero = ['
    ', + '

    %s

    ' % (attr(item["h1_id"]), esc(page["title"]))] + if item["hero_sub"]: + hero.append('

    %s

    ' % inline(item["hero_sub"], ctx)) + hero.append("
    ") + parts.append("\n".join(hero)) + else: + parts.append('

    %s

    ' % (attr(item["h1_id"]), esc(page["title"]))) + if page.get("subtitle"): + parts.append('

    %s

    ' % inline(page["subtitle"], ctx)) + parts.extend(render_blocks(item["blocks"], ctx)) + body = "\n\n".join(parts) + + prev_page = parsed[idx - 1]["page"] if idx > 0 else None + next_page = parsed[idx + 1]["page"] if idx + 1 < len(parsed) else None + doc = page_document(page, body, prev_page, next_page) + + path = os.path.join(out_dir, page["slug"] + ".html") + with open(path, "w", encoding="utf-8", newline="\n") as fh: + fh.write(doc) + written.append(path) + + css_path = os.path.join(out_dir, "style.css") + with open(css_path, "w", encoding="utf-8", newline="\n") as fh: + fh.write(STYLE_CSS) + written.append(css_path) + + for path in written: + print("wrote %s" % os.path.relpath(path, root)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/architecture.html b/site/architecture.html new file mode 100644 index 0000000..901ef70 --- /dev/null +++ b/site/architecture.html @@ -0,0 +1,161 @@ + + + + + +Architecture · danzig + + + + + +
    + +

    Architecture

    + +

    COM in Zig, how a plugin is registered, and the audio callback path.

    + +

    COM in Zig

    + +

    A C++ object with virtual functions is a pointer to a vtable followed by the +object's fields. A COM interface is that, plus the convention that the first +three vtable slots are queryInterface, addRef, and release.

    + +

    src/vst3.zig writes this out as plain Zig:

    + +
    pub const IUnknown = extern struct {
    +    queryInterface: *const fn (?*IUnknown, *const IID, ?*[*]?*anyopaque) callconv(.c) TResult = undefined,
    +    addRef: *const fn (?*IUnknown) callconv(.c) u32 = undefined,
    +    release: *const fn (?*IUnknown) callconv(.c) u32 = undefined,
    +};
    + +

    Three things make this work.

    + +

    extern struct guarantees C layout: fields in declaration order, C alignment +rules, no reordering. This is the whole reason the trick is safe.

    + +

    callconv(.c) gives each function pointer the platform C calling convention, +so arguments land in the registers the host expects.

    + +

    Interface inheritance becomes struct embedding. IComponent starts with an +IPluginBase field, which starts with an IUnknown field. Because extern +struct puts fields at ascending offsets with the first at offset zero, a +*IComponent is bit-identical to a *IPluginBase and to a *IUnknown. That +is exactly what single inheritance produces in C++.

    + +
    pub const IComponent = extern struct {
    +    pluginBase: IPluginBase,      // offset 0, itself starting with IUnknown
    +    getControllerClassId: *const fn (?*IComponent, ?*CUID) callconv(.c) TResult = undefined,
    +    setIoMode: ...
    +};
    + +

    The rest of vst3.zig is the data the ABI passes around: ProcessData, +AudioBusBuffers, ProcessSetup, ParameterInfo, BusInfo, plus the +TResult constants and bus and media type enums. All extern struct, all +laid out to match the SDK headers.

    + +

    How a plugin is registered

    + +

    A VST3 binary exports one symbol. That is the entire registration mechanism.

    + +
    export fn GetPluginFactory() ?*anyopaque {
    +    gFactory.vtbl = @ptrCast(&factoryVtable);
    +    return @ptrCast(&gFactory);
    +}
    + +

    gFactory is a static whose first field is a pointer to a static vtable. The +host receives the address of gFactory, reads the first word to get +&factoryVtable, and calls through it. Nothing is allocated. Nothing is +registered anywhere else. There is no plugin database, no manifest, and no +macro.

    + +

    From there the host does:

    + +
      +
    1. countClasses() to learn how many classes the binary exports.
    2. +
    3. getClassInfo(i, &info) for each, reading the class ID, category, and name.
    4. +
    5. createInstance(class_id, iid, &out) to get an object implementing the +requested interface.
    6. +
    + +

    examples/danzig-test performs exactly steps 1 through 3 against the built +plugin, going through the C function pointers rather than through Zig types, so +a layout change that would break a real host breaks the test first.

    + +

    The audio callback path

    + +

    The host owns the buffers. It hands you a ProcessData describing them and +expects you to be finished by the time the callback returns.

    + +
    host audio thread
    +  |
    +  +-- IAudioProcessor.setupProcessing(&setup)   once, before playback
    +  |      sample rate, max block size, 32- or 64-bit samples
    +  |
    +  +-- IAudioProcessor.setProcessing(true)       transport starts
    +  |
    +  +-- IAudioProcessor.process(&data)            every block, on the audio thread
    +  |      data.numSamples
    +  |      data.inputs[bus].channelBuffers32[ch]
    +  |      data.outputs[bus].channelBuffers32[ch]
    +  |
    +  +-- IAudioProcessor.setProcessing(false)      transport stops
    + +

    Inside process the rules are the usual real-time rules. No allocation, no +locks, no file or network access, no logging that touches a mutex. danzig's +contribution is that the parameter path obeys them by construction: the host's +UI thread writes a normalized f32 with an atomic store, and the audio thread +reads it with an atomic load. There is nothing between the two that can block.

    + +

    A minimal per-sample loop looks like this, from +examples/danzig-minimal/root.zig:

    + +
    pub fn process(
    +    self: *MinimalPlugin,
    +    input: []const []const f32,
    +    output: []const []f32,
    +    frames: usize,
    +) void {
    +    for (0..frames) |i| {
    +        const gain = danzig.dBToLinear(self.params.tick(ParamIndex.trim));
    +        for (input, output) |in_ch, out_ch| {
    +            out_ch[i] = in_ch[i] * gain;
    +        }
    +    }
    +}
    + +

    tick advances the smoother by one sample and returns the plain value, so a +parameter change becomes a ramp rather than a step. That is what stops a slider +drag from producing clicks.

    + + + +
    + + diff --git a/site/audio-helpers.html b/site/audio-helpers.html new file mode 100644 index 0000000..a280d30 --- /dev/null +++ b/site/audio-helpers.html @@ -0,0 +1,110 @@ + + + + + +The Audio Helpers · danzig + + + + + +
    + +

    The Audio Helpers

    + +

    src/audio.zig. Small, dependency-free, and covered by the unit tests.

    + +

    dBToLinear and linearTodB

    + +
    pub fn dBToLinear(dB: f32) f32 {
    +    return @exp(dB * 0.11512925464970229);   // ln(10)/20
    +}
    +
    +pub fn linearTodB(linear: f32) f32 {
    +    if (linear <= 0.0) return -80.0;
    +    return @log(linear) * 8.6858896380650365; // 20/ln(10)
    +}
    + +

    Both avoid pow and log10 in favour of a single exp or log and a +multiply. linearTodB floors at -80 dB for non-positive input, so silence +returns a finite number rather than negative infinity.

    + +

    The constants are worth a test of their own, and they have one. A previous copy +of this file carried a stray factor of ten in the exponent, which turned +dBToLinear(6) into 1000.0 instead of 1.9953. src/tests.zig now checks unity +at 0 dB, the factor of two at +6 dB, the factor of ten at +20 dB, and a full +round trip across -48 to +24 dB.

    + +

    GainProcessor

    + +

    A gain stage with a built-in ramp.

    + +
    var g = danzig.GainProcessor{};
    +g.setGain(6.0);                            // dB
    +g.process(&inputs, &outputs, channels, frames);
    + +

    setGain converts to a linear target. process interpolates the current gain +toward the target by a fixed 0.001 per sample, so a change takes roughly a +thousand samples to substantially complete. setNormalizedGain maps [0, 1] +onto -48 to +48 dB, which is the range the example plugin exposes.

    + +

    The interpolation coefficient is fixed and not sample-rate aware. For a +rate-independent ramp, use AtomicParam with a millisecond time constant +instead.

    + +

    SimpleRamp

    + +

    A linear ramp over a sample count, for anything that is not a gain.

    + +
    var r = danzig.SimpleRamp.init(0.0, 8);   // start value, ramp length in samples
    +r.setTarget(1.0);
    +for (0..8) |_| _ = r.next();
    +// r.getValue() == 1.0
    + +

    setTarget restarts the ramp from the current value. A ramp length of zero or +one is treated as instant. The final sample is snapped to the target exactly, so +the ramp does not leave a residue.

    + +

    AudioBuffer

    + +

    An owned multi-channel buffer, for offline work and tests. It allocates, so keep +it off the audio thread.

    + +
    var buf = try danzig.AudioBuffer.init(allocator, 2, 512, 48000.0);
    +defer buf.deinit(allocator);
    +buf.clear();
    + +

    init zeroes every channel. clear and its alias silence re-zero.

    + + + +
    + + diff --git a/site/examples.html b/site/examples.html new file mode 100644 index 0000000..5c8c2b0 --- /dev/null +++ b/site/examples.html @@ -0,0 +1,62 @@ + + + + + +Examples · danzig + + + + + +
    + +

    Examples

    + +

    Each directory has its own README with the exact commands.

    + +
    + + + + + + + + + + +
    ExampleWhat it showsRun it
    examples/danzig-minimalThe smallest complete plugin. Start here.zig build run-minimal
    examples/danzig-gainA fuller plugin: Plugin, ParameterMap, GainProcessor, and a factory vtable.Built into the .vst3 bundle
    examples/danzig-testDriving the plugin through the raw VST3 C ABI.zig build test-integration
    examples/danzig-gain-standaloneOffline WAV processing with the DSP core.zig build run-standalone
    examples/danzig-webuiA pure-std.net HTTP server serving the web UI../zig-out/bin/danzig-webui
    examples/danzig-gain-uiA native macOS window: WebView UI plus CoreAudio device enumeration.zig build run-gui
    +
    + + + +
    + + diff --git a/site/getting-started.html b/site/getting-started.html new file mode 100644 index 0000000..c826cf4 --- /dev/null +++ b/site/getting-started.html @@ -0,0 +1,146 @@ + + + + + +Getting Started · danzig + + + + + +
    + +

    Getting Started

    + +

    What you need installed, then five minutes from clone to an installed bundle.

    + +

    Prerequisites

    + +
      +
    • Zig 0.14.1 or 0.15.2. Both are tested in CI on every push. The sources +use spellings valid in both: the root_module build API, callconv(.c), +and net.Stream.read. Other versions may work and are unsupported.
    • +
    • macOS for the VST3 bundle, the install-vst3 step, and the GUI example.
    • +
    • Xcode command line tools, for lipo and the macOS SDK.
    • +
    • A VST3 host if you want to scan the bundle.
    • +
    + +

    Nothing else. There is no CMake, no vendored SDK, and one optional Zig +dependency (webview) that is fetched lazily and only when you build the GUI +example.

    + +

    Install Zig with Homebrew or from ziglang.org:

    + +
    brew install zig                # currently 0.15.2
    +# or download 0.14.1 / 0.15.2 from https://ziglang.org/download/
    + +

    Quickstart

    + +

    Five minutes, from clone to an installed bundle.

    + +

    1. Clone and run setup

    + +
    git clone https://github.com/godofecht/danzig
    +cd danzig
    +./setup.sh
    + +

    setup.sh checks your Zig version, builds, runs the tests, builds the universal +bundle, and prints where it landed. It exits non-zero if any of that fails, and +it is safe to run repeatedly. Add --release for a ReleaseFast build.

    + +

    If you prefer to do it by hand, the four commands are below.

    + +

    2. Build

    + +
    zig build
    + +
    Build Summary: 29/29 steps succeeded
    + +

    3. Test

    + +
    zig build test --summary all
    + +
    Build Summary: 9/9 steps succeeded; 35/35 tests passed
    + +

    4. Package the bundle

    + +
    zig build vst3
    +lipo -info zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain
    + +
    Architectures in the fat file: zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain are: x86_64 arm64
    + +

    5. Install it

    + +
    zig build install-vst3
    + +

    This removes any previous copy and writes a fresh one to +~/Library/Audio/Plug-Ins/VST3/DanzigGain.vst3. Verify it:

    + +
    lipo -info ~/Library/Audio/Plug-Ins/VST3/DanzigGain.vst3/Contents/MacOS/DanzigGain
    + +
    Architectures in the fat file: /Users/you/Library/Audio/Plug-Ins/VST3/DanzigGain.vst3/Contents/MacOS/DanzigGain are: x86_64 arm64
    + +

    6. Load it in a DAW

    + +

    Restart your DAW so it rescans the plugin folder. As of today the scan finds the +bundle and the entry point but reports no instantiable classes, for the reason +described under Current state. The bundle structure, the +universal binary, the Info.plist, and the ad-hoc signature are all correct and +verifiable:

    + +
    codesign -dvv zig-out/DanzigGain.vst3
    + +
    Executable=.../zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain
    +Identifier=libDanzigGain_arm64.dylib
    +Format=bundle with Mach-O universal (x86_64 arm64)
    +CodeDirectory v=20400 size=242 flags=0x20002(adhoc,linker-signed) hashes=4+0 location=embedded
    +Signature=adhoc
    + +

    7. Hear the DSP without a DAW

    + +
    zig build run-minimal
    + +
    danzig-minimal: one parameter, one line of DSP
    +
    +Trim range is -24 to +24 dB, 20 ms smoothing, 48 kHz.
    +
    +  full cut     normalized 0.00  ->   -24.00 dB  (output 0.0631)
    +  unity        normalized 0.50  ->     0.00 dB  (output 1.0000)
    +  full boost   normalized 1.00  ->    24.00 dB  (output 15.8473)
    +
    +Copy examples/danzig-minimal/root.zig to start your own plugin.
    + +

    That file is the template. Copy it and start editing process.

    + + + +
    + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..d8dba66 --- /dev/null +++ b/site/index.html @@ -0,0 +1,147 @@ + + + + + +danzig · VST3 plugin framework in pure Zig + + + + + +
    + +
    +

    danzig

    +

    A VST3 plugin framework written in pure Zig. No JUCE. No Steinberg SDK. No C++ +at all in the core.

    +
    + +

    Source: github.com/godofecht/danzig

    + +

    What danzig is

    + +

    VST3 is a C ABI dressed up as COM. A plugin is a shared library exporting one +symbol, GetPluginFactory. The host calls it, reads the first machine word of +the returned pointer as a vtable pointer, and calls through that vtable to +discover classes, create objects, and push audio buffers.

    + +

    That contract is small. It is also the only part of Steinberg's SDK a plugin +strictly needs. Everything else in the SDK is C++ scaffolding around it.

    + +

    danzig implements the contract directly in Zig. src/vst3.zig declares the +interfaces as extern structs of callconv(.c) function pointers, which is +exactly what a C++ vtable is at the machine level. A plugin fills in the +function pointers and returns a pointer to the struct. The host cannot tell the +difference.

    + +

    The reasons to do this rather than use JUCE:

    + +

    Fast builds. A clean build of the library, five example binaries, and both +architectures of the plugin takes about 5.6 seconds on an M-series Mac. A +no-change rebuild takes 0.6 seconds, and packaging the universal bundle on top +of a warm cache takes 0.5 seconds. There is no CMake step and no dependency +tree.

    + +

    One binary format decision, made explicitly. The bundle layout, the +Info.plist, and the lipo invocation are twenty lines of build.zig you can +read. Nothing is hidden behind a framework's packaging step.

    + +

    Allocation is visible. Zig has no hidden allocations and no destructors that +run at surprising times. On the audio thread that matters. The parameter store +in src/params.zig is a fixed array of atomics with no heap involvement at all, +which you can verify by reading 160 lines.

    + +

    Cross-compilation is free. Zig builds x86_64-macos from an arm64 machine +with no extra toolchain. That is what makes the universal bundle a build step +rather than a CI matrix: build.zig compiles the plugin for both architectures +and merges them with lipo, on whichever machine you happen to be on.

    + +

    macOS is the only supported platform today. The VST3 bundle layout, the +install-vst3 step, and the GUI example are all macOS-specific. The library, +the unit tests, and the command-line examples are portable Zig and should build +anywhere Zig runs, though only macOS is tested.

    + +

    Current state

    + +

    Honest summary, because the difference matters if you are choosing a framework.

    + +

    Working and tested.

    + +
      +
    • The core library: vst3.zig, plugin.zig, audio.zig, params.zig.
    • +
    • 35 unit tests covering dB conversion, ramps, buffers, and the atomic +parameter store.
    • +
    • An integration harness that links the built plugin, calls its exported +GetPluginFactory, and drives the returned object through the raw C ABI.
    • +
    • A universal arm64 + x86_64 .vst3 bundle that installs into the macOS plugin +folder and is ad-hoc signed by the linker.
    • +
    • Three runnable non-plugin examples: an offline WAV processor, an HTTP server +serving the web UI, and a native window with an embedded WebView and +CoreAudio device enumeration.
    • +
    + +

    Not finished.

    + +

    The factory in examples/danzig-gain is a stub. countClasses returns 1, but +getClassInfo writes nothing into the host's buffer and createInstance +returns without producing an object. A host therefore scans the bundle, finds +the entry point, and reports zero usable classes:

    + +
    /Applications/pluginval.app/Contents/MacOS/pluginval \
    +  --validate zig-out/DanzigGain.vst3 --strictness-level 5 --timeout-ms 20000
    + +
    Started validating: .../danzig/zig-out/DanzigGain.vst3
    +Random seed: 0x6afa9d8
    +Validation started
    +Strictness level: 5
    +-----------------------------------------------------------------
    +Starting tests in: pluginval / Scan for plugins located in: .../DanzigGain.vst3...
    +Num plugins found: 0
    +!!! Test 1 failed: No types found. This usually means the plugin binary is missing
    +or damaged, an incompatible format or that it is an AU that isn't found by macOS
    +so can't be created.
    +FAILED!!  1 test failed, out of a total of 1
    +FAILURE
    +*** FAILED
    + +

    Completing it means filling in getClassInfo with a populated PClassInfo +(class ID, cardinality, category string, name) and having createInstance +return objects implementing IComponent, IAudioProcessor, and +IEditController. The interface declarations for all three already exist in +src/vst3.zig. The wiring does not.

    + +

    So: use danzig today as a DSP and parameter library with a working VST3 build +pipeline. The last mile into a DAW is the open work.

    + + + +
    + + diff --git a/site/licensing.html b/site/licensing.html new file mode 100644 index 0000000..83ebf54 --- /dev/null +++ b/site/licensing.html @@ -0,0 +1,61 @@ + + + + + +Licensing and Trademarks · danzig + + + + + +
    + +

    Licensing and Trademarks

    + +

    danzig is MIT licensed. See LICENSE.

    + +

    danzig vendors no Steinberg SDK code. src/vst3.zig is a hand-written Zig +description of the VST3 C ABI, derived from the published interface layouts.

    + +

    VST is a trademark of Steinberg Media Technologies GmbH, registered in Europe +and other countries. Distributing plugins in VST3 format is governed by +Steinberg's own licensing terms, which apply to you independently of danzig's +MIT license. Read them before you ship anything.

    + +
    + +

    Source, issues, and the CI matrix: +github.com/godofecht/danzig

    + + + +
    + + diff --git a/site/parameters.html b/site/parameters.html new file mode 100644 index 0000000..f43ff14 --- /dev/null +++ b/site/parameters.html @@ -0,0 +1,166 @@ + + + + + +The Parameter System · danzig + + + + + +
    + +

    The Parameter System

    + +

    src/params.zig. Two types: AtomicParam for one value, ParamStore(N) for a +fixed set of them.

    + +

    The problem

    + +

    A parameter is written by one thread and read by another. The UI or the host +automation lane writes; the audio callback reads. The audio callback cannot +block, so a mutex is not available. It cannot allocate, so a queue that grows is +not available either.

    + +

    The value is a single f32. A lock-free atomic is sufficient and is the whole +solution.

    + +

    AtomicParam

    + +
    pub const AtomicParam = extern struct {
    +    raw: std.atomic.Value(u32) = ...,   // normalized [0, 1], bit-cast from f32
    +    smoothed: f32 = 0.0,                // audio thread only
    +    min: f32 = 0.0,
    +    max: f32 = 1.0,
    +    default_normalized: f32 = 0.5,
    +    smooth_coeff: f32 = 0.0,
    +    _pad: [40]u8 = undefined,           // pad to 64 bytes
    +};
    + +

    The writer side:

    + +
    pub fn setNormalized(self: *Self, value: f32) void {
    +    const clamped = std.math.clamp(value, 0.0, 1.0);
    +    self.raw.store(@bitCast(clamped), .release);
    +}
    + +

    One clamp and one release store. Wait-free. The f32 is bit-cast to u32 +because std.atomic.Value wants an integer, and a bit-cast of a clamped finite +float is exact.

    + +

    The reader side runs once per sample:

    + +
    pub fn tick(self: *Self) f32 {
    +    const target = self.getTargetPlain();
    +    if (self.smooth_coeff <= 0.0) {
    +        self.smoothed = target;
    +    } else {
    +        self.smoothed += (target - self.smoothed) * (1.0 - self.smooth_coeff);
    +    }
    +    return self.smoothed;
    +}
    + +

    getTargetPlain does an acquire load, then denormalizes into [min, max]. The +one-pole filter that follows turns a step into an exponential approach. The +coefficient comes from a time constant in milliseconds:

    + +
    self.smooth_coeff = @exp(-1000.0 / (ms * sample_rate));
    + +

    smoothed is deliberately non-atomic. Only the audio thread touches it.

    + +

    Use snap() to jump smoothed to the target with no ramp. That is what you +want on preset load or transport relocation, where a ramp would be a glide.

    + +

    Why exactly one cache line

    + +

    AtomicParam is padded to 64 bytes and the size is enforced at compile time:

    + +
    comptime {
    +    if (@sizeOf(AtomicParam) != 64) {
    +        @compileError("AtomicParam must be 64 bytes for cache line alignment");
    +    }
    +}
    + +

    Without the padding, several parameters would share a cache line. When the UI +thread stores to parameter 0, the cache coherence protocol invalidates the whole +line on every other core. The audio thread reading parameter 1, which nobody +wrote, would still take a coherence miss. This is false sharing, and it shows up +as jitter in the audio callback rather than as a wrong answer, which makes it +unpleasant to find.

    + +

    64 bytes is the line size on x86_64 and on Apple Silicon's L1 data cache. One +parameter per line means a store to one parameter never disturbs the read of +another. The cost is 40 wasted bytes per parameter. For 64 parameters that is +2.5 KB of padding, which is nothing against the price of one stalled audio +callback.

    + +

    The compile-time check exists so that adding a field silently breaks the build +instead of silently reintroducing false sharing.

    + +

    There is a second, smaller reason for the fixed size. extern struct with a +known size means ParamStore(N) is a flat [N]AtomicParam array, so parameter +i is at a computable offset with no indirection.

    + +

    ParamStore

    + +
    var store = danzig.ParamStore(4){};
    +const gain = store.add(-48.0, 48.0, 0.5, 20.0, 48000.0);
    +//                     min    max   default  smooth_ms  sample_rate
    + +

    add returns the index and asserts you have not exceeded N. Call it during +init only.

    + +
    + + + + + + + + + + + +
    CallThreadNotes
    add(min, max, default, ms, sr)initReturns the index. Asserts on overflow.
    setNormalized(i, v)host / UIIgnores an out-of-range index rather than trapping.
    getNormalized(i)anyReturns 0.0 for an out-of-range index.
    tick(i)audioAdvances one sample, returns the plain value.
    tickAll()audioAdvances every registered parameter.
    getSmoothed(i)audioReads the last ticked value. Call after tick.
    snapAll()audioJumps every smoothed value to its target.
    +
    + +

    The out-of-range behaviour is deliberate. A host sending a stale parameter index +during a preset change should not take down the audio thread.

    + +

    One gap to know about: setSampleRate is currently a no-op. If the sample rate +changes, re-run setSmoothingMs(ms, new_rate) on each parameter, or rebuild the +store in setupProcessing.

    + + + +
    + + diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..92f32df --- /dev/null +++ b/site/style.css @@ -0,0 +1,496 @@ +:root { + --bg: #0a0e14; + --surface: #12161e; + --surface-raised: #181d27; + --border: #252d3a; + --border-accent: #2a4a7f; + --text: #e6edf3; + --text-secondary: #c5cdd8; + --text-muted: #7d8a9a; + --accent: #58a6ff; + --accent-hover: #79b8ff; + --accent-dim: #1a3a5c; + --code-bg: #0f1319; + --green: #3fb950; + --orange: #d29922; + --red: #f85149; + --purple: #bc8cff; + --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Cascadia Code', monospace; + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + --radius: 8px; + --radius-lg: 12px; + --shadow: 0 2px 8px rgba(0,0,0,0.3); + --shadow-lg: 0 4px 24px rgba(0,0,0,0.4); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.7; + display: flex; + min-height: 100vh; +} + +/* ── Sidebar ── */ +nav { + width: 280px; + min-width: 280px; + background: var(--surface); + border-right: 1px solid var(--border); + padding: 2rem 1.5rem; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +nav .logo { + font-family: var(--font-mono); + font-size: 1.5rem; + font-weight: 800; + color: var(--accent); + text-decoration: none; + display: block; + margin-bottom: 0.15rem; + letter-spacing: -0.02em; +} + +nav .tagline { + font-size: 0.72rem; + color: var(--text-muted); + margin-bottom: 0.5rem; + line-height: 1.4; + letter-spacing: 0.01em; +} + +nav .version { + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--text-muted); + background: var(--surface-raised); + border: 1px solid var(--border); + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 99px; + margin-bottom: 2rem; +} + +nav .section-label { + font-size: 0.65rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 1.25rem 0.75rem 0.4rem; +} + +nav a { + display: block; + color: var(--text-muted); + text-decoration: none; + padding: 0.45rem 0.75rem; + border-radius: 6px; + font-size: 0.88rem; + transition: all 0.15s; + border-left: 2px solid transparent; +} + +nav a:hover { + color: var(--text); + background: rgba(88,166,255,0.06); +} + +nav a.active { + color: var(--accent); + background: rgba(88,166,255,0.08); + border-left-color: var(--accent); +} + +nav .spacer { flex: 1; } + +nav .nav-footer { + border-top: 1px solid var(--border); + padding-top: 1rem; + margin-top: 1rem; +} + +nav .nav-footer a { + font-size: 0.8rem; + padding: 0.35rem 0.75rem; +} + +/* ── Main Content ── */ +main { + flex: 1; + max-width: 860px; + padding: 3.5rem 4.5rem 6rem; +} + +h1 { + font-size: 2.2rem; + font-weight: 800; + margin-bottom: 0.5rem; + letter-spacing: -0.025em; + color: var(--text); +} + +.page-subtitle { + font-size: 1.05rem; + color: var(--text-muted); + margin-bottom: 2.5rem; + line-height: 1.5; + border-bottom: 1px solid var(--border); + padding-bottom: 1.5rem; +} + +h2 { + font-size: 1.4rem; + font-weight: 700; + margin-top: 3rem; + margin-bottom: 1rem; + color: var(--text); + letter-spacing: -0.01em; +} + +h3 { + font-size: 1.1rem; + font-weight: 600; + margin-top: 2rem; + margin-bottom: 0.6rem; + color: var(--text-secondary); +} + +p { margin-bottom: 1rem; color: var(--text-secondary); } + +a { color: var(--accent); text-decoration: none; transition: color 0.15s; } +a:hover { color: var(--accent-hover); text-decoration: underline; } + +strong { color: var(--text); font-weight: 600; } + +/* ── Code ── */ +code { + font-family: var(--font-mono); + font-size: 0.84em; + background: var(--code-bg); + padding: 0.2em 0.45em; + border-radius: 4px; + color: var(--green); + border: 1px solid var(--border); +} + +pre { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.4rem 1.6rem; + overflow-x: auto; + margin-bottom: 1.5rem; + position: relative; +} + +pre code { + background: none; + padding: 0; + color: var(--text); + font-size: 0.84rem; + line-height: 1.6; + border: none; +} + +/* ── Tables ── */ +table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1.5rem; + font-size: 0.9rem; + border-radius: var(--radius); + overflow: hidden; + border: 1px solid var(--border); +} + +th, td { + text-align: left; + padding: 0.7rem 1rem; + border-bottom: 1px solid var(--border); +} + +th { + background: var(--surface-raised); + font-weight: 600; + color: var(--text-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +td { color: var(--text-secondary); } +tr:last-child td { border-bottom: none; } + +/* ── Lists ── */ +ul, ol { margin-bottom: 1rem; padding-left: 1.5rem; } +li { margin-bottom: 0.4rem; color: var(--text-secondary); } + +/* ── Diagram Container ── */ +.diagram { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 2rem; + margin: 1.5rem 0 2rem; + text-align: center; + box-shadow: var(--shadow); +} + +.diagram svg { max-width: 100%; height: auto; } + +/* ── Cards ── */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.4rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.card:hover { + border-color: var(--border-accent); + box-shadow: var(--shadow); +} + +.card h4 { + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 0.4rem; + color: var(--text); +} + +.card p { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 0; + line-height: 1.5; +} + +.card .card-icon { + font-size: 1.5rem; + margin-bottom: 0.6rem; + display: block; +} + +/* ── Callouts ── */ +.callout { + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + padding: 1rem 1.25rem; + margin: 1.5rem 0; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.callout.warning { border-left-color: var(--orange); } +.callout.success { border-left-color: var(--green); } + +.callout strong { display: block; margin-bottom: 0.3rem; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; } +.callout.warning strong { color: var(--orange); } +.callout.success strong { color: var(--green); } +.callout strong { color: var(--accent); } + +/* ── Badge ── */ +.badge { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.7rem; + padding: 0.15rem 0.5rem; + border-radius: 99px; + border: 1px solid var(--border); + color: var(--text-muted); + vertical-align: middle; + margin-left: 0.4rem; +} + +/* ── Hero Section ── */ +.hero { + text-align: center; + padding: 1rem 0 2rem; + border-bottom: 1px solid var(--border); + margin-bottom: 2.5rem; +} + +.hero h1 { + font-size: 2.8rem; + font-weight: 800; + letter-spacing: -0.03em; + margin-bottom: 0.75rem; +} + +.hero .hero-sub { + font-size: 1.15rem; + color: var(--text-muted); + max-width: 520px; + margin: 0 auto 1.5rem; + line-height: 1.6; +} + +.hero .hero-badges { + display: flex; + gap: 0.6rem; + justify-content: center; + flex-wrap: wrap; +} + +.hero .hero-badge { + font-family: var(--font-mono); + font-size: 0.75rem; + padding: 0.3rem 0.8rem; + border-radius: 99px; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-muted); +} + +/* ── Link Buttons ── */ +.link-row { + display: flex; + gap: 0.75rem; + margin: 1.5rem 0; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.88rem; + font-weight: 500; + padding: 0.55rem 1.2rem; + border-radius: 6px; + text-decoration: none; + transition: all 0.15s; +} + +.btn-primary { + background: var(--accent); + color: #0a0e14; +} + +.btn-primary:hover { + background: var(--accent-hover); + text-decoration: none; + color: #0a0e14; +} + +.btn-secondary { + background: var(--surface); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + border-color: var(--accent); + color: var(--text); + text-decoration: none; +} + +/* ── Footer ── */ +.page-footer { + margin-top: 4rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + display: flex; + justify-content: space-between; + font-size: 0.85rem; +} + +.page-footer a { + color: var(--text-muted); +} + +.page-footer a:hover { color: var(--accent); } + +/* ── Responsive ── */ +@media (max-width: 900px) { + body { flex-direction: column; } + nav { + width: 100%; + min-width: unset; + height: auto; + position: relative; + padding: 1rem 1.25rem; + flex-direction: row; + flex-wrap: wrap; + gap: 0.2rem; + align-items: center; + } + nav .logo { margin-right: 1rem; margin-bottom: 0; } + nav .tagline, nav .version, nav .section-label, nav .spacer, nav .nav-footer { display: none; } + main { padding: 2rem 1.5rem 4rem; } + .hero h1 { font-size: 2rem; } + .card-grid { grid-template-columns: 1fr; } +} + +/* ── Additions for generated documentation pages ── */ + +main { min-width: 0; overflow-wrap: break-word; } + +pre[data-lang] { padding-top: 2rem; } + +pre[data-lang]::before { + content: attr(data-lang); + position: absolute; + top: 0.55rem; + right: 1rem; + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + pointer-events: none; +} + +.table-scroll { + overflow-x: auto; + margin-bottom: 1.5rem; + border-radius: var(--radius); +} + +.table-scroll table { margin-bottom: 0; } + +blockquote { + margin: 1.5rem 0; + padding: 0.75rem 1.25rem; + background: var(--surface); + border-left: 3px solid var(--border-accent); + border-radius: 0 var(--radius) var(--radius) 0; +} + +blockquote > :last-child { margin-bottom: 0; } + +hr { + border: none; + border-top: 1px solid var(--border); + margin: 2.5rem 0; +} + +h2, h3, h4 { scroll-margin-top: 1.5rem; } + +@media (max-width: 900px) { + main { padding-left: 1.25rem; padding-right: 1.25rem; } + table { font-size: 0.82rem; } +} diff --git a/site/testing.html b/site/testing.html new file mode 100644 index 0000000..8b8e475 --- /dev/null +++ b/site/testing.html @@ -0,0 +1,111 @@ + + + + + +Testing · danzig + + + + + +
    + +

    Testing

    + +

    Two suites, one command.

    + +
    zig build test --summary all
    + +
    Build Summary: 9/9 steps succeeded; 35/35 tests passed
    + +

    Unit tests

    + +

    src/tests.zig, 35 tests, run with zig build test-unit. No artifact and no +host required. They cover:

    + +
      +
    • dB and linear conversion in both directions, including the round trip and the +-80 dB floor.
    • +
    • linearInterpolate and clamp at endpoints and midpoints.
    • +
    • AudioBuffer init, zeroing, and clear.
    • +
    • GainProcessor dB conversion, normalized mapping, clamping, and that +process ramps rather than jumping.
    • +
    • SimpleRamp instant mode, arrival at target, and restart on setTarget.
    • +
    • normalize and denormalize, including the degenerate zero-width range.
    • +
    • AtomicParam: the 64-byte size assertion, clamping, denormalization, +instant mode, monotone non-overshooting smoothing, snap, and +setSmoothingMs with non-positive input.
    • +
    • ParamStore: index allocation, round trip, out-of-range tolerance, +tickAll, and snapAll.
    • +
    + +

    VST3 ABI integration harness

    + +

    examples/danzig-test, run with zig build test-integration. This one links +the built DanzigGain plugin and calls into it the way a host does.

    + +
    danzig integration harness
    +
    +VST3 factory ABI
    +  ok    GetPluginFactory returns a non-null object
    +  ok    countClasses reports one exported class
    +  ok    addRef/release move the count by exactly one
    +  ok    queryInterface for an unknown IID reports failure
    +  ok    getFactoryInfo returns kResultOk
    +  ok    getClassInfo(0) returns kResultOk
    +
    +danzig static library
    +  ok    AudioBuffer reports its geometry
    +  ok    dBToLinear(0 dB) is unity
    +  ok    dBToLinear(+6 dB) is ~1.995
    +  ok    ParamStore reaches +48 dB at full scale
    +
    +all integration checks passed
    + +

    The harness declares its own copy of the IPluginFactory vtable rather than +importing the plugin's Zig types. It reads the first word of the returned +pointer as a vtable pointer and calls through the C function pointers. If the +object layout ever stops matching what a host expects, the dereference fails +here before it fails in a DAW.

    + +

    It returns a non-zero exit code on failure, so zig build test fails with it.

    + +

    CI

    + +

    .github/workflows/ci.yml runs zig build and zig build test on macos-15 +against both 0.14.1 and 0.15.2. The runner is pinned to macos-15 rather than +macos-latest, because macos-latest now ships an Xcode whose SDK Zig 0.14.1 +cannot link against.

    + + + +
    + + diff --git a/site/troubleshooting.html b/site/troubleshooting.html new file mode 100644 index 0000000..4896764 --- /dev/null +++ b/site/troubleshooting.html @@ -0,0 +1,140 @@ + + + + + +Troubleshooting · danzig + + + + + +
    + +

    Troubleshooting

    + +

    zig build fails with undefined libc symbols

    + +

    Zig 0.14.1 cannot link against the SDK shipped with Xcode 26. Either use Zig +0.15.2 or install an older SDK. Setting SDKROOT does not help, because the SDK +itself is the incompatibility.

    + +

    Check which SDK you have:

    + +
    xcodebuild -version
    +xcrun --show-sdk-path
    + +

    lipo -info reports only one architecture

    + +

    You looked at zig-out/lib/libDanzigGain.dylib, which is the native-only build. +The universal binary is inside the bundle:

    + +
    lipo -info zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain
    + +

    If the bundle itself is single-architecture, zig build vst3 did not run. +zig build alone does not produce the bundle.

    + +

    The DAW does not list the plugin

    + +

    Expected today. See Current state. The factory's +getClassInfo and createInstance are stubs, so a host finds zero classes. +Confirm the bundle is otherwise sound:

    + +
    nm -gU zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain | grep -i factory
    + +
    00000000000004c8 T _GetPluginFactory
    + +

    danzig-webui starts but the browser shows nothing

    + +

    The server binds 127.0.0.1:3000. If something else already holds that port you +will reach the other service instead. Check with:

    + +
    lsof -nP -iTCP:3000 -sTCP:LISTEN
    + +

    The port is a constant in examples/danzig-webui/root.zig. Change it and +rebuild.

    + +

    danzig-webui exits with an error about ui/index.html

    + +

    It reads the UI from a relative path at startup, so it has to be run from the +repository root:

    + +
    cd /path/to/danzig
    +./zig-out/bin/danzig-webui
    + +

    danzig-gain-standalone rejects the input file

    + +

    It handles 32-bit float PCM WAV only, with a canonical 44-byte header. Anything +else gives Only 32-bit float PCM WAV files are supported. To make a test file +without extra tools:

    + +
    python3 - <<'PY'
    +import struct, math
    +sr, n, ch = 48000, 48000, 1
    +data = b''.join(struct.pack('<f', 0.5 * math.sin(2 * math.pi * 440 * i / sr)) for i in range(n))
    +hdr = struct.pack('<4sI4s4sIHHIIHH4sI', b'RIFF', 36 + len(data), b'WAVE', b'fmt ',
    +                  16, 1, ch, sr, sr * ch * 4, ch * 4, 32, b'data', len(data))
    +open('sine.wav', 'wb').write(hdr + data)
    +PY
    + +

    The GUI example does not build

    + +

    It needs the webview dependency, which Zig fetches lazily. Run zig build +once with a network connection. On macOS it also links CoreAudio and +CoreFoundation, so the command line tools must be installed.

    + +

    zig build -Dtarget=... fails on webviewStatic

    + +
    error: unable to find framework 'WebKit'. searched paths:  none
    + +

    The GUI example's webview dependency is C++ and links WebKit, which does not +cross-compile. The Zig code does cross-compile fine, which is why +zig build vst3 produces both architectures. Build the library and the +non-GUI examples for another target directly, or build natively.

    + +

    AtomicParam must be 64 bytes for cache line alignment

    + +

    You added or resized a field in AtomicParam without adjusting _pad. Shrink +_pad by the number of bytes you added. The check is there on purpose. See +Why exactly one cache line.

    + +

    A parameter change clicks

    + +

    The parameter has no smoothing. Pass a non-zero smooth_ms to add:

    + +
    _ = store.add(-24.0, 24.0, 0.5, 20.0, sample_rate);
    +//                                ^^^^ 20 ms one-pole ramp
    + +

    Then read it with tick inside the per-sample loop rather than once per block.

    + + + +
    + + diff --git a/site/vst3-bundle.html b/site/vst3-bundle.html new file mode 100644 index 0000000..6a9cd8e --- /dev/null +++ b/site/vst3-bundle.html @@ -0,0 +1,113 @@ + + + + + +Building the Universal VST3 Bundle · danzig + + + + + +
    + +

    Building the Universal VST3 Bundle

    + +

    macOS ships on two architectures. A plugin bundle holds one universal binary so +that hosts of either architecture load the same file.

    + +
    zig build vst3
    + +

    That step, in build.zig, does four things.

    + +

    1. Compiles the plugin twice. Once for aarch64-macos and once for +x86_64-macos, via b.resolveTargetQuery. Zig cross-compiles both from +whichever machine you are on, so no second toolchain is needed.

    + +
    const arches = [_]std.Target.Cpu.Arch{ .aarch64, .x86_64 };
    +const suffixes = [_][]const u8{ "arm64", "x86" };
    +
    +inline for (arches, suffixes) |arch, suffix| {
    +    const arch_target = b.resolveTargetQuery(.{ .cpu_arch = arch, .os_tag = .macos });
    +    // ... build danzig_<suffix> and DanzigGain_<suffix>
    +    lipo.addArtifactArg(plugin);
    +}
    + +

    2. Merges them with lipo. b.addSystemCommand(&.{ "lipo", "-create" }) +collects both artifacts and writes one fat Mach-O into the build cache. The +output path is a build-graph node, so the merge reruns only when an input +changes.

    + +

    3. Lays out the bundle. b.addWriteFiles() builds the directory:

    + +
    DanzigGain.vst3/
    +  Contents/
    +    Info.plist          generated from a template in build.zig
    +    PkgInfo             the 8 bytes "BNDL????"
    +    MacOS/
    +      DanzigGain        the universal binary, no file extension
    + +

    The executable carries no extension. That is a bundle requirement, and it is why +the lipo output is copied rather than installed under its library name.

    + +

    4. Installs into zig-out/. The result is +zig-out/DanzigGain.vst3.

    + +

    Then:

    + +
    zig build install-vst3
    + +

    removes any existing copy and copies the bundle to +$HOME/Library/Audio/Plug-Ins/VST3/DanzigGain.vst3, which is where macOS hosts +scan.

    + +

    The bundle is kept behind its own step rather than the default install because +it doubles the compile work and only applies to macOS.

    + +

    Sizes, from a ReleaseFast build:

    + +
    + + + + + + + + +
    ArtifactSize
    zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain84 KB (universal)
    zig-out/lib/libDanzigGain.dylib52 KB (arm64)
    zig-out/lib/libDanzigMinimal.dylib52 KB (arm64)
    zig-out/bin/danzig-minimal168 KB
    +
    + +

    The same artifacts in the default Debug build run about 1 to 2 MB each.

    + + + +
    + +