<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en_US"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://zackdesign.biz/feed.xml" rel="self" type="application/atom+xml" /><link href="https://zackdesign.biz/" rel="alternate" type="text/html" hreflang="en_US" /><updated>2026-04-22T03:09:37+00:00</updated><id>https://zackdesign.biz/feed.xml</id><title type="html">Zack Design</title><subtitle>Software engineering, web development, and digital solutions by industry experts</subtitle><author><name>Isaac Rowntree</name><email>isaac@zackdesign.biz</email></author><entry><title type="html">Shutterdrop — wireless tethered phone camera for your Mac</title><link href="https://zackdesign.biz/shutterdrop/" rel="alternate" type="text/html" title="Shutterdrop — wireless tethered phone camera for your Mac" /><published>2026-04-22T00:00:00+00:00</published><updated>2026-04-22T00:00:00+00:00</updated><id>https://zackdesign.biz/shutterdrop</id><content type="html" xml:base="https://zackdesign.biz/shutterdrop/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/shutterdrop"><code class="language-plaintext highlighter-rouge">shutterdrop</code></a> — a wireless tethered camera that turns the phone in your pocket into a wifi shutter for your Mac. Tap the screen on your phone, the photo lands in a watched folder on your Mac a moment later. Like Capture One tether, but over wifi from your iPhone or Android instead of a USB DSLR. No cable, no cloud, no account.</p>

<!-- more -->

<h2 id="why-this-exists">Why this exists</h2>

<p>I take a lot of product photos for eBay listings — bike parts, electronics, miscellaneous resale. The iPhone in my pocket has a vastly better camera than my MacBook’s built-in webcam, but the friction of “shoot on phone → AirDrop → import to listing tool” was killing the throughput. Existing wireless tether tools either want a subscription, push photos through someone else’s cloud, or are tied to a specific desktop app I don’t use.</p>

<p>Shutterdrop is the smallest possible thing that solves the problem: tap shutter, file shows up. That’s it. The receiver writes straight to a watched folder, so whatever workflow you already have (Finder smart folder, Hazel rule, Lightroom auto-import, eBay listing CLI) just sees new files appear.</p>

<h2 id="what-its-like-to-use">What it’s like to use</h2>

<ol>
  <li>Start the receiver on your Mac. It prints a 6-digit pairing code in the terminal.</li>
  <li>Open the Shutterdrop app on your phone. It finds your Mac on the wifi automatically and asks for the code.</li>
  <li>Type the code once. You’re paired forever — your phone remembers your Mac.</li>
  <li>Frame the shot, tap anywhere on the camera preview, and a moment later the photo appears in <code class="language-plaintext highlighter-rouge">~/Pictures/Shutterdrop/</code> on your Mac. Drag it straight into your eBay listing, your Lightroom catalogue, or wherever you already work.</li>
</ol>

<p>If you walk out of wifi range mid-shoot, captures queue up on the phone and flush as soon as you’re back online. Nothing gets lost.</p>

<h2 id="whats-in-it">What’s in it</h2>

<ul>
  <li><strong>iPhone app</strong> for iOS 17+, with a manual lens picker on Pro phones (0.5× / 1× / 3×) and a built-in torch toggle. Photos are HEIC at full quality.</li>
  <li><strong>Android app</strong> for Android 8 and up. JPEG capture, edge-to-edge layout, accessible to TalkBack screen readers.</li>
  <li><strong>A small Mac receiver</strong> written in Python. It runs in the background, advertises itself on the local network, and drops every incoming photo into a folder of your choice. Linux works too.</li>
  <li><strong>Pairing is private.</strong> A one-time 6-digit code shown on your Mac, with rate limits and a 5-minute window so nobody on the same wifi can guess their way in. The shared key lives in your phone’s secure storage (iOS Keychain or Android EncryptedSharedPreferences).</li>
</ul>

<h2 id="status">Status</h2>

<p>Working end-to-end on both iPhone and Android, with an automated test suite for the Mac receiver that runs on every push. The wire protocol between phone and Mac is small enough that you could write your own receiver — drop incoming photos into S3, pipe them through <code class="language-plaintext highlighter-rouge">pngcrush</code>, auto-import to Lightroom, whatever you want. MIT-licensed, source on <a href="https://github.com/isaacrowntree/shutterdrop">GitHub</a>.</p>

<h2 id="under-the-hood-for-the-curious">Under the hood (for the curious)</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Phone (iOS or Android)              Mac (or Linux)
┌───────────────────────┐           ┌────────────────────────┐
│ Camera preview        │  HTTP     │ receiver.py            │   drop
│ Tap-to-capture (HEIC  ├──over────▶│ (Python stdlib +       ├──────▶  ~/Pictures/Shutterdrop/
│  on iOS / JPEG on     │  LAN +    │  zeroconf)             │
│  Android)             │  Bonjour  │ advertises             │
│ Offline outbox        │           │ _shutterdrop._tcp      │
│ Bonjour discovery     │           └────────────────────────┘
└───────────────────────┘
</code></pre></div></div>

<p>Three endpoints, that’s the whole protocol:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET  /health  → {"ok":true}                          unauthenticated
POST /pair    → {"code":"123456","peerName":"…"}     returns {"secret","peer"}
POST /submit  → multipart/form-data, "photo" part, Bearer auth required
</code></pre></div></div>

<p>Build details and architecture notes are in the <a href="https://github.com/isaacrowntree/shutterdrop">README</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="swift" /><category term="kotlin" /><category term="python" /><category term="ios" /><category term="android" /><category term="mac" /><category term="photography" /><category term="bonjour" /><category term="open-source" /><summary type="html"><![CDATA[An iOS + Android + Python receiver that turns your phone into a tap-and-drop wireless tether for your Mac. LAN + Bonjour, no cable, no cloud.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/shutterdrop.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/shutterdrop.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">bezant — typed IBKR access from Rust, HTTP, CLI, MCP, and TypeScript</title><link href="https://zackdesign.biz/bezant/" rel="alternate" type="text/html" title="bezant — typed IBKR access from Rust, HTTP, CLI, MCP, and TypeScript" /><published>2026-04-20T00:00:00+00:00</published><updated>2026-04-20T00:00:00+00:00</updated><id>https://zackdesign.biz/bezant</id><content type="html" xml:base="https://zackdesign.biz/bezant/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/bezant"><strong>bezant</strong></a> — a typed async Rust client for the Interactive Brokers Client Portal Web API, with HTTP, CLI, MCP, and TypeScript surfaces all generated from the same vendored OpenAPI spec. It’s the first Rust project we’ve open-sourced, and it exists because trading against IBKR from modern code shouldn’t mean hand-rolling 155 HTTP endpoints from a PDF.</p>

<!-- more -->

<h2 id="why-it-exists">Why it exists</h2>

<p>Interactive Brokers’ Client Portal Web API (CPAPI) is the gateway most retail-adjacent trading tools reach for — it covers accounts, positions, orders, market data, watchlists, scanners, PnL, and more. The surface area is <strong>155 paths, 167 methods, 1030 types</strong>. The official docs ship an OpenAPI spec, but the spec has real-world quirks: missing or duplicate <code class="language-plaintext highlighter-rouge">operationId</code>s, malformed <code class="language-plaintext highlighter-rouge">security[]</code> blocks, integer fields with floating-point example values, and a few other gremlins that break naive code generators.</p>

<p>Bezant vendors that spec, normalises it through a 13-step pipeline, and regenerates every client surface from a single command. When IBKR revises the spec, one <code class="language-plaintext highlighter-rouge">./scripts/codegen.sh</code> re-runs the whole thing and every language/runtime updates together.</p>

<h2 id="five-surfaces-one-spec">Five surfaces, one spec</h2>

<table>
  <thead>
    <tr>
      <th>Crate / package</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-core</code></strong></td>
      <td>Ergonomic async Rust facade — <code class="language-plaintext highlighter-rouge">Client</code>, session keepalive, health, WebSocket streaming, pagination, symbol cache, typed errors</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-api</code></strong></td>
      <td>Auto-generated Rust client covering every CPAPI endpoint</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-server</code></strong></td>
      <td>HTTP sidecar — exposes CPAPI as plain REST+JSON so any language can consume it</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-cli</code></strong></td>
      <td><code class="language-plaintext highlighter-rouge">bezant</code> CLI — <code class="language-plaintext highlighter-rouge">bezant health</code>, <code class="language-plaintext highlighter-rouge">bezant positions DU123456</code>, <code class="language-plaintext highlighter-rouge">bezant conid AAPL</code></td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-mcp</code></strong></td>
      <td>Model Context Protocol server — exposes IBKR as MCP tools for Claude Code, Cursor, Continue</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-client</code></strong></td>
      <td>TypeScript client for Node / Deno / browser</td>
    </tr>
  </tbody>
</table>

<p>The MCP surface is the one that surprised us the most. Once it was there, driving IBKR from a conversation — <em>“what are my open positions in my paper account?”</em> — became a single <code class="language-plaintext highlighter-rouge">/plugin install</code> away. The same spec drove the typed Rust client, the CLI, and the TypeScript package. Zero duplication.</p>

<h2 id="rust-because-it-earns-the-weight">Rust, because it earns the weight</h2>

<p>Rust is new to our open-source lineup. We chose it for bezant specifically because:</p>

<ul>
  <li><strong>A long-running trading session wants to not crash.</strong> Rust’s memory safety and absence of GC pauses feel right for code that holds an authenticated session, streams over a WebSocket, and needs to keepalive a 5-minute-expiring cookie cleanly.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">reqwest</code> + <code class="language-plaintext highlighter-rouge">tokio</code> is genuinely pleasant.</strong> Strong types end-to-end, async streaming via <code class="language-plaintext highlighter-rouge">tokio-tungstenite</code>, and error handling via <code class="language-plaintext highlighter-rouge">thiserror</code> — the Rust HTTP/WebSocket story in 2026 is excellent.</li>
  <li><strong>Codegen is unforgiving.</strong> When you regenerate a client from a noisy third-party spec, the compiler catches mismatches the moment you rebuild. Dynamic languages find out at runtime.</li>
</ul>

<p>The ergonomic facade in <code class="language-plaintext highlighter-rouge">bezant-core</code> sits on top of the raw generated client so callers don’t have to think about method-naming quirks or type re-wrapping:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">use</span> <span class="nn">std</span><span class="p">::</span><span class="nn">time</span><span class="p">::</span><span class="n">Duration</span><span class="p">;</span>

<span class="nd">#[tokio::main]</span>
<span class="k">async</span> <span class="k">fn</span> <span class="nf">main</span><span class="p">()</span> <span class="k">-&gt;</span> <span class="nn">bezant</span><span class="p">::</span><span class="nb">Result</span><span class="o">&lt;</span><span class="p">()</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">client</span> <span class="o">=</span> <span class="nn">bezant</span><span class="p">::</span><span class="nn">Client</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="s">"https://localhost:5000/v1/api"</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">_keepalive</span> <span class="o">=</span> <span class="n">client</span><span class="nf">.spawn_keepalive</span><span class="p">(</span><span class="nn">Duration</span><span class="p">::</span><span class="nf">from_secs</span><span class="p">(</span><span class="mi">60</span><span class="p">));</span>
    <span class="n">client</span><span class="nf">.health</span><span class="p">()</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>

    <span class="k">let</span> <span class="n">positions</span> <span class="o">=</span> <span class="n">client</span><span class="nf">.all_positions</span><span class="p">(</span><span class="s">"DU123456"</span><span class="p">)</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">aapl</span> <span class="o">=</span> <span class="nn">bezant</span><span class="p">::</span><span class="nn">SymbolCache</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="n">client</span><span class="p">)</span><span class="nf">.conid_for</span><span class="p">(</span><span class="s">"AAPL"</span><span class="p">)</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>
    <span class="nd">println!</span><span class="p">(</span><span class="s">"{} positions; AAPL = conid {aapl}"</span><span class="p">,</span> <span class="n">positions</span><span class="nf">.len</span><span class="p">());</span>
    <span class="nf">Ok</span><span class="p">(())</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="the-spec-normalisation-pipeline">The spec-normalisation pipeline</h2>

<p>The most unexpectedly interesting piece of this project is the 13-step spec-normalisation pipeline. IBKR’s published OpenAPI isn’t wrong — it’s <em>realistic</em>. Real specs have duplicate operation IDs, missing required fields, and security definitions that don’t validate.</p>

<p>Rather than patch-forward into our codegen, bezant normalises the spec <em>before</em> codegen runs. Each step is idempotent and documented:</p>

<ol>
  <li>Add missing <code class="language-plaintext highlighter-rouge">operationId</code>s deterministically from path + method</li>
  <li>De-duplicate the operationIds that IBKR repeats</li>
  <li>Repair malformed <code class="language-plaintext highlighter-rouge">security[]</code> blocks</li>
  <li>Coerce integer fields with float example values</li>
  <li>Upgrade OAS 3.0 → 3.1 where it matters for our generator</li>
  <li>…and eight more</li>
</ol>

<p>The output is a clean, modern OpenAPI 3.1 document that <strong>every</strong> downstream generator can consume without complaint. The full pipeline is documented at <a href="https://isaacrowntree.github.io/bezant/internals/normalisation.html">Spec normalisation</a> — if you have your own fights with a gnarly third-party spec, the pattern is worth stealing.</p>

<h2 id="testing-against-reality">Testing against reality</h2>

<p>34 tests across the workspace, all green in CI:</p>

<ul>
  <li><strong>Unit</strong> for the facade and the CLI</li>
  <li><strong>Snapshot tests</strong> keyed to real IBKR example payloads — catches upstream spec drift before users feel it</li>
  <li><strong>Integration</strong> against <code class="language-plaintext highlighter-rouge">wiremock</code> for fault-injection (session expiry, 5xx retries)</li>
  <li><strong>End-to-end</strong> through Docker Compose against a mocked Gateway</li>
</ul>

<p>The Docker Compose quickstart is one command: <code class="language-plaintext highlighter-rouge">docker compose up</code>, log in to the IBKR Gateway once in a browser, and the HTTP sidecar is live on <code class="language-plaintext highlighter-rouge">http://localhost:8080</code>.</p>

<h2 id="mcp-ibkr-as-a-tool-for-claude">MCP: IBKR as a tool for Claude</h2>

<p>One of the weirder, more fun surfaces is <code class="language-plaintext highlighter-rouge">bezant-mcp</code> — a Model Context Protocol server that exposes IBKR endpoints as MCP tools. Drop it into Claude Code or Cursor, and you can ask <em>“show me the PnL on my paper account this week”</em> and the model drives the actual CPAPI to answer. The MCP tools are generated from the same spec, so new IBKR endpoints become new MCP tools automatically.</p>

<h2 id="status-and-licensing">Status and licensing</h2>

<ul>
  <li><strong>Alpha — v0.1.</strong> Works end-to-end against IBKR paper accounts; API surface will evolve until v1.0</li>
  <li><strong>Dual-licensed MIT / Apache-2.0</strong> following Rust ecosystem convention</li>
  <li><strong>Not affiliated with Interactive Brokers</strong> — the vendored spec is IBKR’s IP, included under fair-use for interoperability</li>
  <li><strong>Docs:</strong> <a href="https://isaacrowntree.github.io/bezant/">isaacrowntree.github.io/bezant</a></li>
  <li><strong>Source:</strong> <a href="https://github.com/isaacrowntree/bezant">github.com/isaacrowntree/bezant</a></li>
</ul>

<p>If you’re building a trading bot, an analytics tool, an AI-assistant-with-broker-access, or anything that wants typed access to IBKR without the PDF-reading phase — bezant is waiting. Contributions welcome, especially on the spec-normaliser and on new client languages.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="rust" /><category term="ibkr" /><category term="trading" /><category term="openapi" /><category term="mcp" /><category term="typescript" /><category term="interactive-brokers" /><category term="client-portal" /><summary type="html"><![CDATA[A Rust-first async client for the Interactive Brokers Client Portal Web API — with HTTP, CLI, MCP, and TypeScript surfaces auto-generated from one vendored OpenAPI spec.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/bezant.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/bezant.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Introducing SessionHQ — our flagship SaaS</title><link href="https://zackdesign.biz/sessionhq-launch/" rel="alternate" type="text/html" title="Introducing SessionHQ — our flagship SaaS" /><published>2026-04-17T00:00:00+00:00</published><updated>2026-04-17T00:00:00+00:00</updated><id>https://zackdesign.biz/sessionhq-launch</id><content type="html" xml:base="https://zackdesign.biz/sessionhq-launch/"><![CDATA[<p>After months of design, engineering, and iteration with real studio operators, Zack Design is proud to launch <strong><a href="https://sessionhq.org">SessionHQ</a></strong> — the modern check-in platform for class-based studios. It is the most ambitious product we have ever shipped, and it now runs nightly check-ins at our founding partner <a href="https://www.havanahastingsdance.com.au/">Havana on the Hastings</a> in Port Macquarie.</p>

<!-- more -->

<h2 id="what-sessionhq-does">What SessionHQ does</h2>

<p>SessionHQ replaces the spreadsheets, paper sign-in sheets, and duct-taped Mindbody workarounds that most small studios tolerate because the alternatives are too expensive, too clunky, or too generic. We built it by sitting at the front desk on a Tuesday night and asking, <em>“what actually needs to happen here?”</em></p>

<p>The answer, it turns out, is:</p>

<ul>
  <li><strong>Members walk in and check in fast.</strong> PIN pad, NFC wristband tap, or QR scan from their phone. No app install required. No “where’s my card.”</li>
  <li><strong>Passes just work.</strong> Class packs, casual rates, unlimited passes. Credits deduct automatically on check-in. Cards-on-file auto-renew the moment a pack runs out.</li>
  <li><strong>Payments happen where the student is.</strong> Square integration handles card payments inline. PCI-compliant. No raw card numbers ever touch our servers.</li>
  <li><strong>Admins see the truth.</strong> Tonight’s attendance, revenue, unpaid check-ins, LTV, retention cohorts — all updating in real time.</li>
</ul>

<p>No per-member fees. No transaction surcharges on top of Square. One flat monthly subscription.</p>

<h2 id="the-technology-behind-it">The technology behind it</h2>

<p>SessionHQ is a serious piece of software infrastructure. A quick tour of the stack:</p>

<ul>
  <li><strong>Next.js 16 &amp; React 19</strong> on the frontend, with Tailwind 4 and a custom 19-primitive design system (not shadcn — we wanted the ownership).</li>
  <li><strong>Cloudflare Workers</strong> via OpenNext for the runtime. Global edge deployment, sub-100ms cold starts, one Worker cron handling pass-lifecycle, database backup, prune, and retention sweeps.</li>
  <li><strong>Supabase</strong> for auth, Postgres, realtime, and row-level security. Every tenant-owned table enforces <code class="language-plaintext highlighter-rouge">auth_tenant_id()</code> at the database layer — a studio <em>cannot</em> see another studio’s data, period.</li>
  <li><strong>Square</strong> for payments, with Supabase Vault for token storage and PCI-safe tokenisation.</li>
  <li><strong>Resend</strong> for lifecycle email, <strong>Sentry</strong> for observability, <strong>R2</strong> for storage, <strong>Playwright</strong> and <strong>Vitest</strong> for 800+ tests across unit, integration, and E2E.</li>
</ul>

<p>Multi-tenancy, GDPR-readiness (consent capture, data export, right-to-erasure, full audit trail), idempotency, rate limiting, feature flags — all in from day one, not bolted on later.</p>

<h2 id="why-we-built-it">Why we built it</h2>

<p>We have spent 20+ years building software for other people. SessionHQ is different: <strong>it is our product.</strong> We own the roadmap, the pricing, the customer relationship. We decide which features matter. We eat the bug reports.</p>

<p>It is also a proof point. We believe small businesses deserve software that is as thoughtfully engineered as anything the enterprise market gets — without the enterprise price tag, the 12-month implementation, or the 400-page MSA. SessionHQ is our demonstration that a small, focused team can ship serious SaaS.</p>

<h2 id="founding-partner-havana-on-the-hastings">Founding partner: Havana on the Hastings</h2>

<p>SessionHQ did not launch in a vacuum. It launched with a customer.</p>

<p><a href="https://www.havanahastingsdance.com.au/">Havana on the Hastings</a> is Port Macquarie’s Latin dance community — Cuban salsa, bachata, urban kiz, and rueda (the dance that brought founders Mike and Kellie together). They run on passes, practicas, and real connection, with the warmth of a studio where “everyone starts somewhere” is not just a slogan but a weekly reality.</p>

<p>They were already operating on the pass system that SessionHQ is built around. Partnering with them meant we did not have to guess what studio operators needed — we had one telling us, in real time, what worked and what did not. Every feature in SessionHQ has been stress-tested at their front desk on a Tuesday night.</p>

<p>If you are in Port Macquarie and want to dance, <a href="https://www.havanahastingsdance.com.au/classes">drop in</a>. Absolute beginners are welcome every week.</p>

<h2 id="whats-next">What’s next</h2>

<p>SessionHQ is onboarding new studios now. If you run a dance studio, gym, yoga or pilates studio, martial arts school, or climbing gym — or if you know someone who does — we would love to talk.</p>

<ul>
  <li><strong>Visit</strong> <a href="https://sessionhq.org">sessionhq.org</a> to see the product.</li>
  <li><strong>Request access</strong> on the site, or <strong>book a 15-minute demo</strong> via <code class="language-plaintext highlighter-rouge">info@sessionhq.org</code>.</li>
  <li><strong>Founding-studio pricing is locked in</strong> for the studios who sign on before general availability.</li>
</ul>

<p>This is the start of something we are going to spend years building on. Thanks for being here for the beginning.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="product" /><category term="sessionhq" /><category term="saas" /><category term="nextjs" /><category term="supabase" /><category term="cloudflare" /><category term="square" /><category term="product-launch" /><summary type="html"><![CDATA[SessionHQ — our modern multi-tenant check-in platform for dance studios, gyms, and martial arts schools — is live, with founding partner Havana on the Hastings.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/sessionhq-launch.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/sessionhq-launch.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Stamp Scanner — iPhone + Mac + SAM 3 for cataloguing stamp collections</title><link href="https://zackdesign.biz/stamp-scanner/" rel="alternate" type="text/html" title="Stamp Scanner — iPhone + Mac + SAM 3 for cataloguing stamp collections" /><published>2026-04-16T00:00:00+00:00</published><updated>2026-04-16T00:00:00+00:00</updated><id>https://zackdesign.biz/stamp-scanner</id><content type="html" xml:base="https://zackdesign.biz/stamp-scanner/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/stamp-scanner"><code class="language-plaintext highlighter-rouge">stamp-scanner</code></a> — a two-device workflow for cataloguing stamp collections. The iPhone acts as a tethered macro scanner. The Mac runs SAM 3 segmentation, perceptual-hash deduplication, rotation correction, and a local Qwen3-VL for identification. Everything lives in a queryable SQLite library you can point external tools at.</p>

<!-- more -->

<h2 id="the-architecture-in-ascii">The architecture, in ASCII</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iPhone (ios-app/)                    Mac (mac-app/)                 Python (tools/)
┌───────────────────┐   HTTP over    ┌───────────────────┐   file   ┌─────────────────┐
│ Capture (HEIC)    ├───LAN+Bonjour─▶│ PhoneIngestServer ├──drop───▶│ sam_worker.py   │
│ MotionGate        │                │ (accepts uploads) │          │ SAM 3 + dedup   │
│ Lens picker       │                └───────────────────┘          │ + white balance │
└───────────────────┘                         │                     └────────┬────────┘
                                              │                              │ writes
                                              ▼                              ▼
                                     ┌────────────────────┐         ┌─────────────────────┐
                                     │ SwiftUI library UI │◀──GRDB──│ library.sqlite      │
                                     │ grid · detail      │         │ (~/Library/App Sup) │
                                     │ rotate · identify  │         └──────────▲──────────┘
                                     │ colnect lookup     │                    │ writes
                                     └────────────────────┘                    │
                                                │ spawns                       │
                                                ▼                              │
                                     ┌────────────────────┐                    │
                                     │ orientation_worker │───── Ollama ───────┤
                                     │   (Qwen3-VL)       │                    │
                                     │ colnect_lookup.py  │───── HTTP ─────────┘
                                     └────────────────────┘
</code></pre></div></div>

<h2 id="the-data-flow">The data flow</h2>

<ol>
  <li><strong>iPhone captures HEIC.</strong> <code class="language-plaintext highlighter-rouge">MotionGate</code> waits for the phone to be steady (accelerometer settled) before taking the shot, the lens picker selects the macro-capable camera, and the captured HEIC is uploaded over Bonjour/LAN to the paired Mac.</li>
  <li><strong>Mac receives it.</strong> <code class="language-plaintext highlighter-rouge">PhoneIngestServer</code> — a SwiftUI app wrapping a tiny HTTP listener — drops the file into <code class="language-plaintext highlighter-rouge">.run/sam_inbox/</code>.</li>
  <li><strong>SAM 3 segments the stamp.</strong> <code class="language-plaintext highlighter-rouge">sam_worker.py</code> runs the Segment Anything 3 model to cut the stamp out of the page, perceptual-hashes it to detect duplicates already in the library, warps it square, and white-balances against the untouched corners of the page.</li>
  <li><strong>SQLite writes.</strong> The segmented, deduplicated, white-balanced stamp lands in <code class="language-plaintext highlighter-rouge">library.sqlite</code> via a GRDB schema.</li>
  <li><strong>SwiftUI UI renders.</strong> The Mac app exposes a grid, a detail view, rotation tools, and “identify” / “Colnect lookup” buttons.</li>
  <li><strong>Identification is VLM-driven.</strong> Hitting “identify” spawns <code class="language-plaintext highlighter-rouge">orientation_worker</code> against a local Ollama-hosted Qwen3-VL instance. Hitting “Colnect lookup” queries the Colnect catalogue API for an official ID match.</li>
</ol>

<h2 id="why-two-devices">Why two devices</h2>

<p>Because an iPhone’s macro camera + image signal processor is genuinely excellent at stamp-sized subjects — better than a flatbed scanner at 1200 dpi for small dense subjects, and much faster. A Mac, meanwhile, is the right place for the heavy lifting: SAM 3 wants a GPU, the local VLM wants 20 GB of unified memory, and GRDB + SwiftUI want a real filesystem and a large screen. Splitting capture from processing plays to each device’s strengths.</p>

<h2 id="why-local">Why local</h2>

<p>A stamp collection is personal. You do not want to upload it to a third-party cataloguing service that might vanish in two years or quietly start charging a subscription. Local models, local SQLite, local UI. The only optional outbound call is the Colnect catalogue API, and that is a lookup against their public IDs — no collection data leaves your Mac.</p>

<h2 id="status">Status</h2>

<p>Working end-to-end for single-subject captures, deduplication, rotation, and VLM-based identification. Full architecture and build instructions in the <a href="https://github.com/isaacrowntree/stamp-scanner">README</a>. If you have a collection that deserves better than a spreadsheet, this is a solid starting point.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="ai" /><category term="swift" /><category term="python" /><category term="ios" /><category term="mac" /><category term="sam" /><category term="vlm" /><category term="philately" /><category term="local-ai" /><category term="open-source" /><summary type="html"><![CDATA[A two-device workflow that turns an iPhone into a macro scanner and a Mac into a SAM-3 segmentation, deduplication, and VLM identification pipeline for philately.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/stamp-scanner.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/stamp-scanner.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">bike-shock-planner — test-driven MTB shock fitment modelling</title><link href="https://zackdesign.biz/bike-shock-planner/" rel="alternate" type="text/html" title="bike-shock-planner — test-driven MTB shock fitment modelling" /><published>2026-04-12T00:00:00+00:00</published><updated>2026-04-12T00:00:00+00:00</updated><id>https://zackdesign.biz/bike-shock-planner</id><content type="html" xml:base="https://zackdesign.biz/bike-shock-planner/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/bike-shock-planner"><code class="language-plaintext highlighter-rouge">bike-shock-planner</code></a> — a <strong>test-driven, code-as-data</strong> planner for mountain bike rear shock replacements, coil conversions, and ebike suspension builds. It began as “can I fit a coil shock to a 2013 Trek Fuel EX 5 ebike conversion?” and grew into a reusable framework that models rear suspension geometry, shock fitment, spring rates, frame clearance, conversion hardware, and global sourcing paths for <em>any</em> bike.</p>

<!-- more -->

<h2 id="it-is-not-a-bike-specific-script">It is not a bike-specific script</h2>

<p>The 2013 Fuel EX 5 is the first “recipe” — a self-contained config describing one bike, one rider, and a set of candidate parts. Everything is written so you can drop in a new recipe for your own frame and the same fit-check and spring-rate logic runs against it. That is the whole point of the project: a single, testable model of rear-shock dimensions and fitment rules, with as many recipes layered on top as people are willing to contribute.</p>

<h2 id="who-it-is-for">Who it is for</h2>

<ul>
  <li><strong>DIY mechanics</strong> restoring an old MTB frame and trying to work out whether a modern shock will bolt up.</li>
  <li><strong>Ebike converters</strong> putting a mid-drive motor on a non-ebike frame and needing to recalculate spring rates for the extra mass and torque.</li>
  <li><strong>Frame hunters</strong> cross-checking a secondhand frame’s shock spec against catalog reality before buying.</li>
  <li><strong>Bike shops</strong> who want a reusable, forkable model of rear-shock dimensions — the catalog is just TypeScript, extend it for whatever you stock and rerun the tests to lint your inventory against real frames.</li>
  <li><strong>Anyone</strong> who has spent hours in a Trek fitment PDF trying to work out whether a shock advertised as “7.25×2.0 imperial” fits their old DRCV mount. (Spoiler: only via a conversion kit.)</li>
</ul>

<h2 id="what-it-does">What it does</h2>

<ul>
  <li><strong>Bike model.</strong> Eye-to-eye, stroke, mount styles, eyelet widths, bolt sizes, leverage ratio, progression, and the frame clearance envelope — all captured in code.</li>
  <li><strong>Shock catalog.</strong> Aftermarket shocks modelled as code, with body dimensions, piggyback status, coil spring rate range, Australian sourcing notes, and verified product URLs.</li>
  <li><strong>Fit check.</strong> Frame slot × candidate shock returns each dimensional mismatch separately — eye-to-eye, stroke, upper/lower eyelet width, bolt sizes, mount styles, body length, body diameter, reservoir clearance. No yes/no black boxes.</li>
  <li><strong>Conversion kits.</strong> Kits that rewrite a shock’s mounting hardware are modelled as functions that transform a candidate. So you can ask “does this imperial shock fit if I use the Shockcraft Deaktiv kit?” and get a real answer.</li>
  <li><strong>Spring-rate calculator.</strong> A <em>practical</em> formula that accounts for rear weight distribution — not the theoretical Fox “quick formula” that overshoots real-world spring picks by 40%.</li>
  <li><strong>Ebike load correction.</strong> Weights 40% of battery + motor mass onto the rear shock and adds a high-torque correction for ≥100 Nm motors.</li>
  <li><strong>Progression flag.</strong> Warns when a frame’s linkage does not really want a coil — e.g. Trek’s Full Floater is only ~13% progressive and is tuned for a DRCV air spring, so a linear coil will bottom harshly.</li>
  <li><strong>Documented-build flag.</strong> If no published build exists for the exact frame generation, every candidate gets an <em>experimental</em> warning.</li>
  <li><strong>Research library.</strong> Verified references to conversion kits, manufacturer product pages, global retailers, used-market venues, forum threads, and vendor email contacts — with tests enforcing that every link is HTTPS and every group is populated.</li>
  <li><strong>Pivot hardware model.</strong> OEM bearing/bolt spec plus a four-step health check so you can decide whether a full frame rebuild is required alongside the shock swap.</li>
</ul>

<h2 id="status-today">Status today</h2>

<p>Primarily a <strong>2013 Trek Fuel EX 5</strong> model. The coil catalog includes Push ElevenSix (the only currently-buildable imperial 7.25×2.0 coil in April 2026), plus Marzocchi Bomber CR, Fox DHX2, DVO Jade X, MRP Hazzard Coil, and Cane Creek DB Coil IL entries marked used-market-only. The air catalog includes Fox Float X2, RockShox Super Deluxe Ultimate, and Marzocchi Bomber Air. Real VALT Progressive sizes are captured with the 45 mm stroke that fits inside a 50 mm shock; Sprindex 55 mm is flagged as not-fitting. The conversion kit catalog covers Offset Bushings, Shockcraft Deaktiv, an unpublished custom-machine path for Huber Bushings, plus a speculative metric-to-Trek kit flagged <code class="language-plaintext highlighter-rouge">publishedSku: false</code> so the test suite warns on it.</p>

<h2 id="why-code-as-data">Why code-as-data</h2>

<p>Because every existing shock “compatibility chart” is a PDF, and PDFs cannot be run against a test suite. If you model the data in TypeScript, the test suite can assert things like “no reservoir clash on any frame in the catalog”, “every link in the research library is reachable”, and “every catalog entry has a spring rate range if it is a coil”. That turns a messy research task into something a contributor can submit a pull request against. Source on <a href="https://github.com/isaacrowntree/bike-shock-planner">GitHub</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="typescript" /><category term="bikes" /><category term="mtb" /><category term="suspension" /><category term="testing" /><category term="open-source" /><summary type="html"><![CDATA[A TypeScript framework for modelling rear-shock fitment, coil conversions, ebike spring rates, and global parts sourcing for any mountain bike — starting with a 2013 Trek Fuel EX 5.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/bike-shock-planner.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/bike-shock-planner.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Clean Backdrop — free, GPU-accelerated studio backdrop cleanup</title><link href="https://zackdesign.biz/clean-backdrop/" rel="alternate" type="text/html" title="Clean Backdrop — free, GPU-accelerated studio backdrop cleanup" /><published>2026-03-25T00:00:00+00:00</published><updated>2026-03-25T00:00:00+00:00</updated><id>https://zackdesign.biz/clean-backdrop</id><content type="html" xml:base="https://zackdesign.biz/clean-backdrop/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/clean-backdrop"><code class="language-plaintext highlighter-rouge">clean-backdrop</code></a> — a free, open-source tool for cleaning up studio portrait backdrops. Shadow lift plus frequency separation on a high-quality portrait segmentation mask, running on a CUDA GPU, no AI inpainting artifacts anywhere. It is a clean-math alternative to paid tools like Retouch4me Clean Backdrop.</p>

<!-- more -->

<h2 id="the-problem">The problem</h2>

<p>Studio paper backdrops are never as clean as they look before the shoot. A six-hour session leaves scuff marks, footprints, seam shadows where the paper meets the floor, uneven lighting where the key light rolled off, and the occasional crease from the roll dispenser. Fixing those by hand in Photoshop — with the healing brush, dodge/burn layers, and a feathered mask around the subject — is a real job. For a shoot with two hundred keepers, it is unreasonable.</p>

<p>The commercial tools that automate this are excellent and expensive. <code class="language-plaintext highlighter-rouge">clean-backdrop</code> is the free alternative.</p>

<h2 id="how-it-works">How it works</h2>

<p>Two complementary techniques, run on GPU:</p>

<ol>
  <li><strong>Shadow Lift.</strong> Samples a patch of clean wall, then blends cast shadows toward that reference. Preserves the natural wall gradient (studios are not lit perfectly flat and should not be rendered that way). Adjustable 0–100%.</li>
  <li><strong>Texture Smoothing (Frequency Separation).</strong> Splits the image into a low-frequency lighting gradient and a high-frequency detail layer. Smooths the detail layer — where marks, scuffs, and paper texture live — while leaving the gradient untouched. No smudging, no false positives on the subject’s hair.</li>
</ol>

<p>Both passes run on a <strong><a href="https://github.com/ZhengPeng7/BiRefNet">BiRefNet-Portrait</a></strong> subject segmentation mask with distance-based feathering, so the boundary between subject and cleaned background has no visible “bar” artifact at any crop size.</p>

<h2 id="smart-edge-handling">Smart edge handling</h2>

<ul>
  <li><strong>Smooth subject masking.</strong> Feathering scales with image size, so a 24 MP portrait has the same clean transition as a 45 MP headshot.</li>
  <li><strong>Automatic floor detection.</strong> Real floors (wood, tile, concrete) are distinguished from wall shadow/vignetting by a colour-analysis heuristic. Real floors stay; darkening on walls gets cleaned.</li>
  <li><strong>Vertical floor transition.</strong> The wall-to-floor boundary uses a row-based ramp so the floor texture and contact shadows around the subject’s feet are never disturbed.</li>
</ul>

<h2 id="running-it">Running it</h2>

<p>There are two ways to use it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Web UI — drag-and-drop with live sliders</span>
pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt
python app.py
<span class="c"># open http://localhost:5000</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Batch — directory in, directory out</span>
python batch.py <span class="s2">"D:</span><span class="se">\P</span><span class="s2">hotos</span><span class="se">\E</span><span class="s2">xport</span><span class="se">\M</span><span class="s2">y Shoot"</span> <span class="nt">--lift</span> 70 <span class="nt">--texture</span> 50
</code></pre></div></div>

<p>The web UI shows four tabs — Original, Shadows, Texture, Preview — so you can dial shadow lift and texture smoothing independently and watch the separation happen. Outputs are saved next to the original with a <code class="language-plaintext highlighter-rouge">_clean</code> suffix, ICC colour profiles and EXIF metadata preserved.</p>

<h2 id="why-open-source">Why open-source</h2>

<p>Because the underlying math is not exotic — shadow lift and frequency separation have been in the photo-retouching toolkit for twenty years — and because a high-quality portrait segmentation model exists under a permissive licence. The commercial offerings are polished, but the core workflow does not need to be proprietary. If you shoot regularly against studio paper, <a href="https://github.com/isaacrowntree/clean-backdrop">clone the repo</a> and stop paying a per-seat fee for a batch operation.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="python" /><category term="photography" /><category term="cuda" /><category term="birefnet" /><category term="image-processing" /><category term="open-source" /><summary type="html"><![CDATA[An open-source alternative to Retouch4me Clean Backdrop — shadow lift plus frequency separation on a BiRefNet-Portrait mask, run on CUDA. No AI inpainting artifacts.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/clean-backdrop.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/clean-backdrop.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Ledger — Australian personal finance ETL and ATO tax dashboard</title><link href="https://zackdesign.biz/ledger/" rel="alternate" type="text/html" title="Ledger — Australian personal finance ETL and ATO tax dashboard" /><published>2026-03-20T00:00:00+00:00</published><updated>2026-03-20T00:00:00+00:00</updated><id>https://zackdesign.biz/ledger</id><content type="html" xml:base="https://zackdesign.biz/ledger/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/ledger"><code class="language-plaintext highlighter-rouge">ledger</code></a> — a terminal-first personal finance tool that ingests bank statements from multiple Australian banks, categorises transactions with regex-based rules, and renders an ATO-ready tax return view plus a net worth dashboard. Local-first, SQLite under the hood, no cloud dependency.</p>

<!-- more -->

<h2 id="the-itch">The itch</h2>

<p>Every mid-year I rebuild the same Excel spreadsheet: paste in ING transactions, paste in PayPal, paste in the Bankwest credit card, then hand-categorise everything, then try to remember which expense was for which business, then double-count a $200 transaction that appeared on both the credit card and the bank account it was paid from. Then I hand it to my accountant and we do it all over again. Ledger is the version I should have built five years ago.</p>

<h2 id="sources-supported-today">Sources supported today</h2>

<table>
  <thead>
    <tr>
      <th>Source</th>
      <th>Formats</th>
      <th>Parser</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ING Australia</td>
      <td>PDF statements, CSV export</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/ing_pdf.py</code>, <code class="language-plaintext highlighter-rouge">ing_csv.py</code></td>
    </tr>
    <tr>
      <td>PayPal</td>
      <td>CSV activity download</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/paypal_csv.py</code></td>
    </tr>
    <tr>
      <td>Bankwest</td>
      <td>PDF eStatements, CSV</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/bankwest_pdf.py</code>, <code class="language-plaintext highlighter-rouge">bankwest_csv.py</code></td>
    </tr>
    <tr>
      <td>HSBC</td>
      <td>PDF statements</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/hsbc_pdf.py</code></td>
    </tr>
    <tr>
      <td>Coles Mastercard</td>
      <td>PDF statements</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/coles_pdf.py</code></td>
    </tr>
    <tr>
      <td>Amex</td>
      <td>CSV download</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/amex_csv.py</code></td>
    </tr>
  </tbody>
</table>

<p>Drop a statement into <code class="language-plaintext highlighter-rouge">staging/&lt;source&gt;/</code>, run <code class="language-plaintext highlighter-rouge">ledger ingest</code>, and the right parser picks it up. PDF parsing is per-bank because every Australian bank has a different statement layout and none of them offer a clean machine-readable export.</p>

<h2 id="what-it-does">What it does</h2>

<ul>
  <li><strong>Multi-source ingestion.</strong> The above parsers, with dedup rules to prevent double-counting when a transaction appears on both a bank account and a credit card.</li>
  <li><strong>Auto-categorisation.</strong> Regex-based merchant rules assign categories automatically and learn from manual overrides.</li>
  <li><strong>Business splits.</strong> A percentage of any expense can be allocated to a business — essential for anyone running a sole-trader side or a company with home-office overlap.</li>
  <li><strong>ATO tax return view.</strong> Output structured to match the sections of an Australian individual tax return: salary, rental schedule, business schedule, deductions.</li>
  <li><strong>Financial year view.</strong> Outgoing / incoming / rental / work-trip sub-tabs replacing the Excel sheet I had been rebuilding by hand every year.</li>
  <li><strong>Net worth dashboard.</strong> Accounts, credit cards, property, vehicles — balances pulled from the same statements.</li>
  <li><strong>Tags.</strong> Orthogonal to categories. A transaction can be in category “Travel” and tagged <code class="language-plaintext highlighter-rouge">flight</code>, <code class="language-plaintext highlighter-rouge">biz-hosting</code>, <code class="language-plaintext highlighter-rouge">rental-income</code> for finer reporting without having to invent a deeper category tree.</li>
</ul>

<h2 id="why-local-first">Why local-first</h2>

<p>Because my financial data is mine. No cloud dependency, no third-party aggregator pulling read-only access to my bank accounts, no “we are deprecating the Xero integration” email six months from now. SQLite sits in a folder, the dashboard runs on <code class="language-plaintext highlighter-rouge">localhost</code>, and if I want to back it all up I copy a single file.</p>

<h2 id="quick-start">Quick start</h2>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/isaacrowntree/ledger.git
<span class="nb">cd </span>ledger
python3 <span class="nt">-m</span> venv .venv <span class="o">&amp;&amp;</span> <span class="nb">source</span> .venv/bin/activate
pip <span class="nb">install</span> <span class="nt">-e</span> <span class="nb">.</span>

<span class="nb">cp </span>config/accounts.yaml.example config/accounts.yaml
<span class="nb">cp </span>config/categories.yaml.example config/categories.yaml
<span class="nb">cp </span>config/tax.yaml.example config/tax.yaml

ledger init
<span class="nb">mkdir</span> <span class="nt">-p</span> staging/ing staging/paypal
<span class="c"># Drop PDFs/CSVs into those folders</span>
ledger ingest
python <span class="nt">-m</span> api
<span class="c"># Open http://localhost:5050</span>
</code></pre></div></div>

<h2 id="who-it-is-for">Who it is for</h2>

<p>Anyone in Australia with more than one bank account, a side business or two, and an accountant who currently gets a hand-assembled spreadsheet every July. Source on <a href="https://github.com/isaacrowntree/ledger">GitHub</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="python" /><category term="etl" /><category term="finance" /><category term="tax" /><category term="ato" /><category term="sqlite" /><category term="open-source" /><category term="local-first" /><summary type="html"><![CDATA[A terminal-first personal finance tool that ingests ING, Bankwest, HSBC, PayPal, Amex, and Coles statements, categorises transactions, and renders an ATO-ready tax view.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/ledger.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/ledger.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">local-llm-coding-guide — Qwen, Gemma, and llama.cpp as a coding assistant</title><link href="https://zackdesign.biz/local-llm-coding-guide/" rel="alternate" type="text/html" title="local-llm-coding-guide — Qwen, Gemma, and llama.cpp as a coding assistant" /><published>2026-03-14T00:00:00+00:00</published><updated>2026-03-14T00:00:00+00:00</updated><id>https://zackdesign.biz/local-llm-coding-guide</id><content type="html" xml:base="https://zackdesign.biz/local-llm-coding-guide/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/local-llm-coding-guide"><code class="language-plaintext highlighter-rouge">local-llm-coding-guide</code></a> — a no-fluff, benchmark-driven guide to running a genuinely useful local LLM as a coding assistant on consumer hardware. It covers Qwen3.5 and Gemma 4 across llama.cpp, Ollama (with MLX), and vllm-mlx, with real tokens-per-second numbers from three real machines.</p>

<!-- more -->

<h2 id="why-local">Why local</h2>

<p>Cloud LLMs are wonderful until you are on a flight, behind a client VPN, editing code with sensitive data, or burning through a monthly token budget faster than is reasonable. The quality gap between the best frontier models and the best <em>local-runnable</em> models has narrowed dramatically — a quantised 9B Qwen model on a modest NVIDIA card is now perfectly capable of the “reformat this function, add a docstring, write a test” type of work that makes up most of a coding assistant’s day.</p>

<h2 id="the-benchmarks">The benchmarks</h2>

<p>Measured on release builds, real completions, real contexts:</p>

<table>
  <thead>
    <tr>
      <th>GPU</th>
      <th>Model</th>
      <th>Tok/s</th>
      <th>Context</th>
      <th>Memory</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>RTX 4070 Ti 12GB</td>
      <td>Nemotron 3 Nano 4B Q4_K_M</td>
      <td>TBD</td>
      <td>262K</td>
      <td>~5GB</td>
    </tr>
    <tr>
      <td>RTX 4070 Ti 12GB</td>
      <td>Qwen3.5-9B Q4_K_M</td>
      <td>~65</td>
      <td>131K</td>
      <td>7.8GB</td>
    </tr>
    <tr>
      <td>RTX 3060 12GB</td>
      <td>Qwen3.5-9B Q4_K_M</td>
      <td>~43</td>
      <td>128K</td>
      <td>~7.8GB</td>
    </tr>
    <tr>
      <td>RTX 3090 24GB</td>
      <td>Qwen3.5-27B Q4_K_M</td>
      <td>~30</td>
      <td>262K</td>
      <td>~18GB</td>
    </tr>
    <tr>
      <td>M3 Pro 36GB</td>
      <td><strong>Qwen3.5-35B-A3B Q4_K_M</strong></td>
      <td><strong>~29</strong></td>
      <td>131K</td>
      <td><strong>~22GB</strong></td>
    </tr>
    <tr>
      <td>M3 Pro 36GB</td>
      <td>Qwen3.5-9B Q4_K_M</td>
      <td>~20</td>
      <td>131K</td>
      <td>~7GB</td>
    </tr>
    <tr>
      <td>M3 Pro 36GB</td>
      <td>Qwen3.5-27B Q4_K_M</td>
      <td>~9*</td>
      <td>131K</td>
      <td>~18GB</td>
    </tr>
    <tr>
      <td>M3 Pro 36GB</td>
      <td><strong>Gemma 4 26B-A4B Q4_K_M (Ollama MLX)</strong></td>
      <td><strong>~31</strong></td>
      <td>256K</td>
      <td><strong>~17GB</strong></td>
    </tr>
  </tbody>
</table>

<p>*The dense 27B is slower than the 35B-A3B MoE on 36 GB machines — see “Why MoE?” in the repo for the full story.</p>

<h2 id="why-moe-wins-on-apple-silicon">Why MoE wins on Apple Silicon</h2>

<p>Apple’s unified memory is generous but its memory <em>bandwidth</em> is not as high as a discrete NVIDIA card’s. A dense 27B model saturates that bandwidth on every token. A mixture-of-experts model like Qwen3.5-35B-A3B only activates 3B parameters per token, which means each token reads a fraction of the weights — and the model runs faster <em>and</em> smarter than the dense option it replaces. The guide walks through the tradeoff properly.</p>

<h2 id="test-machines">Test machines</h2>

<ul>
  <li><strong>Windows/WSL2:</strong> RTX 4070 Ti (12 GB), Intel Core Ultra 9 285K, 48 GB DDR5</li>
  <li><strong>macOS:</strong> M3 MacBook Pro, 36 GB unified memory</li>
</ul>

<h2 id="quick-start">Quick start</h2>

<p>The guide walks through llama.cpp from source (with <code class="language-plaintext highlighter-rouge">-DGGML_CUDA=ON</code> or <code class="language-plaintext highlighter-rouge">-DGGML_METAL=ON</code>), the <code class="language-plaintext highlighter-rouge">llama-server</code> binary, wiring it into VS Code via the Continue extension, and wiring it into Claude Code as a local endpoint. Ollama + MLX is covered as the one-command alternative for Apple Silicon.</p>

<h2 id="who-it-is-for">Who it is for</h2>

<p>Developers who want a serious coding assistant that runs on their own hardware, without a subscription, without a round-trip to a cloud inference endpoint, and without hand-tuning flags for six hours. Read it on <a href="https://github.com/isaacrowntree/local-llm-coding-guide">GitHub</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="ai" /><category term="guides" /><category term="llm" /><category term="llama-cpp" /><category term="ollama" /><category term="qwen" /><category term="gemma" /><category term="local-ai" /><category term="coding-assistant" /><category term="guides" /><summary type="html"><![CDATA[A benchmarks-first guide to running Qwen3.5 and Gemma 4 locally as a coding assistant — on a 4070 Ti, a 3090, and an M3 Pro MacBook — with real tok/s numbers.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/local-llm-coding-guide.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/local-llm-coding-guide.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">audio-analysis-and-recut — reconstructing a live set from the studio master</title><link href="https://zackdesign.biz/audio-analysis-and-recut/" rel="alternate" type="text/html" title="audio-analysis-and-recut — reconstructing a live set from the studio master" /><published>2026-03-13T00:00:00+00:00</published><updated>2026-03-13T00:00:00+00:00</updated><id>https://zackdesign.biz/audio-analysis-and-recut</id><content type="html" xml:base="https://zackdesign.biz/audio-analysis-and-recut/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/audio-analysis-and-recut"><code class="language-plaintext highlighter-rouge">audio-analysis-and-recut</code></a> — a small Python + FFmpeg pipeline that takes a noisy live performance recording, works out exactly which sections of the original studio track the band played, and generates a high-fidelity recut that follows the live arrangement.</p>

<!-- more -->

<h2 id="the-problem">The problem</h2>

<p>You have two audio files:</p>

<ul>
  <li><strong>Original</strong> — the studio recording. High fidelity, the version on Spotify, the one you actually want to listen to.</li>
  <li><strong>Performance</strong> — a live recording of the same song. Great arrangement, maybe some improvisation, but also crowd noise, ambient PA colouration, and a phone mic’s idea of bass response.</li>
</ul>

<p>The live arrangement is <em>better</em> — it is the one the band actually performed — but the audio fidelity is <em>worse</em>. What you want is: the live arrangement, with studio fidelity. That is what this tool does.</p>

<h2 id="how-it-works">How it works</h2>

<ol>
  <li><strong>Band-pass filter</strong> to 200–4000 Hz on both tracks. Vocals live in that range, crowd noise and room rumble largely do not. This is what makes matching robust to a noisy live environment.</li>
  <li><strong>Sliding-window cross-correlation</strong> between chunks of the performance and the full studio track. Each chunk’s best match pins down where in the original it came from.</li>
  <li><strong>Segment detection</strong> by grouping matches with consistent time offsets. A 30-second verse played live will produce 30 seconds of chunks that all agree on the same studio offset.</li>
  <li><strong>FFmpeg concatenation</strong> of the identified original segments, in the order the live performance used them, into a clean output file.</li>
</ol>

<h2 id="example-output">Example output</h2>

<p>For “Ya Te Olvide” by Los 4 ft Laritza Bacallao (4:40 studio original → 1:56 live performance):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SEGMENT MAP:
  1  0:00-0:19  |  Orig 0:12-0:30  |  18.5s  (intro/verse start)
  2  0:19-1:04  |  Orig 0:50-1:35  |  44.5s  (verse/chorus)
  3  1:04-1:36  |  Orig 2:44-3:15  |  31.5s  (montuno section)
  4  1:36-1:46  |  Orig 4:13-4:23  |   9.5s  (ending)
  5  1:46-1:48  |  Orig 4:33-4:35  |   2.0s  (final tag)

Skipped from original:
  0:00-0:12  (11.8s) - pre-intro
  0:30-0:50  (20.1s) - transition/repeat
  1:35-2:44  (69.1s) - repeated verse section
  3:15-4:13  (57.9s) - extended montuno/breakdown
  4:23-4:33  (10.3s) - outro padding
</code></pre></div></div>

<p>The segment map reads like a director’s cut list: the band skipped the pre-intro, compressed the long breakdown, and landed on a different ending. Feeding that back into FFmpeg reconstructs the performance from clean studio audio.</p>

<h2 id="why-bother">Why bother</h2>

<p>Latin dance classes like <a href="https://www.havanahastingsdance.com.au/">Havana on the Hastings</a> often rehearse to studio recordings but perform to the band’s own arrangement — which means choreographies that work in rehearsal do not always align with the live record they are showcased against. A recut reconciles the two: same arrangement the dancers know, but clean enough to cue on.</p>

<h2 id="usage">Usage</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cp </span>original_song.mp3 staging/original.mp3
<span class="nb">cp </span>performance_recording.mp3 staging/performance.mp3
python3 analyze.py
<span class="c"># Output: output/ya_te_olvide_recut.mp3</span>
</code></pre></div></div>

<p>Dependencies are Python 3 with NumPy, plus a working FFmpeg on <code class="language-plaintext highlighter-rouge">$PATH</code>. Source on <a href="https://github.com/isaacrowntree/audio-analysis-and-recut">GitHub</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="audio" /><category term="dsp" /><category term="python" /><category term="ffmpeg" /><category term="cross-correlation" /><category term="open-source" /><summary type="html"><![CDATA[A Python tool that cross-correlates a noisy live performance recording against the original studio track and rebuilds a high-fidelity recut following the live arrangement.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/audio-analysis-and-recut.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/audio-analysis-and-recut.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">react-native-nitro-unzip — a fast, Nitro-powered unzip module</title><link href="https://zackdesign.biz/react-native-nitro-unzip/" rel="alternate" type="text/html" title="react-native-nitro-unzip — a fast, Nitro-powered unzip module" /><published>2026-02-27T00:00:00+00:00</published><updated>2026-02-27T00:00:00+00:00</updated><id>https://zackdesign.biz/react-native-nitro-unzip</id><content type="html" xml:base="https://zackdesign.biz/react-native-nitro-unzip/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/react-native-nitro-unzip"><code class="language-plaintext highlighter-rouge">react-native-nitro-unzip</code></a> — a high-performance ZIP module for React Native, built on <a href="https://nitro.margelo.com/">Nitro Modules</a>. It extracts and creates password-protected archives on iOS and Android faster than the existing community options, with real progress callbacks and cancellation delivered directly over JSI rather than the old bridge.</p>

<!-- more -->

<h2 id="benchmarks-up-front">Benchmarks, up front</h2>

<p>On a 350 MB archive containing 10,000 files:</p>

<ul>
  <li><strong>iOS:</strong> ~500 files/sec</li>
  <li><strong>Android:</strong> ~474 files/sec</li>
</ul>

<p>Those numbers are measured with release builds, not debug — the bridge-free JSI path is a large part of why the difference shows up at all.</p>

<h2 id="feature-surface">Feature surface</h2>

<ul>
  <li><strong>Extraction</strong> with per-file progress (bytes extracted, files remaining, percentage complete) delivered synchronously via JSI — no bridge serialisation, no frame drops.</li>
  <li><strong>Synchronous cancellation.</strong> A cancel is honoured on the next file boundary, not at the next JS tick.</li>
  <li><strong>Password-protected archives.</strong> AES-256 encryption supported on both platforms for extraction <em>and</em> creation.</li>
  <li><strong>Zip creation.</strong> Compress a directory into an archive, optionally with a password.</li>
  <li><strong>Concurrent operations.</strong> Multiple tasks run independently without locking each other.</li>
  <li><strong>Background execution on iOS</strong> via proper <code class="language-plaintext highlighter-rouge">UIApplication</code> background task management, so a 500 MB archive can finish extracting even if the user backgrounds the app.</li>
</ul>

<h2 id="quick-example">Quick example</h2>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">getUnzip</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react-native-nitro-unzip</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">unzip</span> <span class="o">=</span> <span class="nx">getUnzip</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">task</span> <span class="o">=</span> <span class="nx">unzip</span><span class="p">.</span><span class="nx">extract</span><span class="p">(</span><span class="dl">'</span><span class="s1">/path/to/archive.zip</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">/path/to/output</span><span class="dl">'</span><span class="p">);</span>

<span class="nx">task</span><span class="p">.</span><span class="nx">onProgress</span><span class="p">((</span><span class="nx">p</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`</span><span class="p">${(</span><span class="nx">p</span><span class="p">.</span><span class="nx">progress</span> <span class="o">*</span> <span class="mi">100</span><span class="p">).</span><span class="nx">toFixed</span><span class="p">(</span><span class="mi">0</span><span class="p">)}</span><span class="s2">% — </span><span class="p">${</span><span class="nx">p</span><span class="p">.</span><span class="nx">extractedFiles</span><span class="p">}</span><span class="s2">/</span><span class="p">${</span><span class="nx">p</span><span class="p">.</span><span class="nx">totalFiles</span><span class="p">}</span><span class="s2"> files`</span><span class="p">);</span>
<span class="p">});</span>

<span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">task</span><span class="p">.</span><span class="k">await</span><span class="p">();</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`Extracted </span><span class="p">${</span><span class="nx">result</span><span class="p">.</span><span class="nx">extractedFiles</span><span class="p">}</span><span class="s2"> files in </span><span class="p">${</span><span class="nx">result</span><span class="p">.</span><span class="nx">duration</span><span class="p">}</span><span class="s2">ms`</span><span class="p">);</span>
</code></pre></div></div>

<p>Progress callbacks fire on every file, and because they ride JSI they never queue up behind the bridge.</p>

<h2 id="why-it-exists">Why it exists</h2>

<p>React Native has had ZIP libraries for years. Most of them predate Nitro and therefore predate modern JSI — which means every progress tick had to serialise across the bridge, every cancellation had to round-trip through an async message queue, and every archive operation paid the bridge tax proportional to the number of files. For the kind of app that extracts a single 2 MB archive at install time, none of that matters. For the kind of app that handles large user-uploaded archives, downloads payload bundles from a server, or packages up content for offline use, it matters a lot.</p>

<p>Internally the native side leans on battle-tested libraries — <code class="language-plaintext highlighter-rouge">SSZipArchive</code> on iOS, an optimised <code class="language-plaintext highlighter-rouge">ZipInputStream</code> path on Android — rather than reinventing the compression format. The contribution is the JSI layer, the cancellation machinery, and the progress plumbing that sits on top.</p>

<h2 id="installation-and-docs">Installation and docs</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install </span>react-native-nitro-unzip react-native-nitro-modules
<span class="nb">cd </span>ios <span class="o">&amp;&amp;</span> pod <span class="nb">install</span>
</code></pre></div></div>

<p>Requires React Native 0.75+, Nitro Modules 0.34+, iOS 15.5+, and Java 17 on Android. Full docs — including extraction, compression, password handling, cancellation semantics, and the API reference auto-generated from TypeScript — live at <a href="https://isaacrowntree.github.io/react-native-nitro-unzip/">isaacrowntree.github.io/react-native-nitro-unzip</a>.</p>

<p>Source on <a href="https://github.com/isaacrowntree/react-native-nitro-unzip">GitHub</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="react-native" /><category term="nitro" /><category term="ios" /><category term="android" /><category term="typescript" /><category term="performance" /><category term="open-source" /><summary type="html"><![CDATA[A React Native ZIP library built on Nitro Modules — ~500 files/sec on iOS, ~474 on Android, AES-256 support, cancellation, and zero bridge serialisation.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/react-native-nitro-unzip.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/react-native-nitro-unzip.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>