<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Silent Spice]]></title><description><![CDATA[Notes from a one-person iOS studio. Spice baked in. Noise left out.]]></description><link>https://silentspice.substack.com</link><image><url>https://substackcdn.com/image/fetch/$s_!IqE7!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48b9abf5-6911-4f43-9513-f555b93b0824_175x175.png</url><title>Silent Spice</title><link>https://silentspice.substack.com</link></image><generator>Substack</generator><lastBuildDate>Sat, 12 Sep 2026 17:52:04 GMT</lastBuildDate><atom:link href="https://silentspice.substack.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Lismond Bernard]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[silentspice@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[silentspice@substack.com]]></itunes:email><itunes:name><![CDATA[Lismond Bernard]]></itunes:name></itunes:owner><itunes:author><![CDATA[Lismond Bernard]]></itunes:author><googleplay:owner><![CDATA[silentspice@substack.com]]></googleplay:owner><googleplay:email><![CDATA[silentspice@substack.com]]></googleplay:email><googleplay:author><![CDATA[Lismond Bernard]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Why <c e g> is harder than it looks]]></title><description><![CDATA[The bug that taught my tokenizer to stop splitting on spaces, and the regression test that keeps it honest.]]></description><link>https://silentspice.substack.com/p/why-c-e-g-is-harder-than-it-looks</link><guid isPermaLink="false">https://silentspice.substack.com/p/why-c-e-g-is-harder-than-it-looks</guid><dc:creator><![CDATA[Lismond Bernard]]></dc:creator><pubDate>Tue, 08 Sep 2026 17:32:09 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/31e011cd-1367-42ab-a19f-4e7789b5dc2c_2400x1260.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The launch post promised a tour of the tokenizer, and this is it. It is a short tour, because the whole lesson fits in one line of LilyPond: <code>&lt;c e g&gt;</code>. Three characters of music, two of punctuation, and it broke the prototype.</p><h2>A thirty-second music sidebar</h2><p>If you don&#8217;t read music, here is everything this post needs. LilyPond is the plain-text notation format behind the Mutopia Project&#8217;s public-domain scores. Pitches are lowercase letters: <code>c</code>, <code>e</code>, <code>g</code>. Angle brackets group notes that sound at the same time, so <code>&lt;c e g&gt;</code> is the three notes C, E and G struck together, which is a C major chord, the first chord anyone learns. A number after a note or chord is its written length: <code>2</code> is a half note, <code>4</code> a quarter, and a trailing dot adds half again, so <code>&lt;c e g&gt;2.</code> is a dotted half-note chord. Doubled brackets, <code>&lt;&lt; &#8230; &gt;&gt;</code>, mean something different: two independent voices playing at once, each with its own rhythm, which is how a piano score writes the left hand against the right.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://silentspice.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading Silent Spice! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p>I took piano lessons as a kid and played recitals, so I can read a score. That turned out to be exactly enough knowledge to believe this format would be easy to parse, and not enough to know why it isn&#8217;t.</p><h2>What does LilyPond actually write?</h2><p>A chord is one unit: an opening bracket, some pitches, a closing bracket, and the duration hangs off the close. The tokenizer in &#201;tude produces exactly that shape, and the unit test says so in five lines:</p><pre><code><code>#expect(try sut.tokenize("&lt;c e g&gt;") == [
    .chordStart,
    .note(NoteToken(name: "c")),
    .note(NoteToken(name: "e")),
    .note(NoteToken(name: "g")),
    .chordEnd(duration: nil),
])</code></code></pre><p>Nothing clever. The point of this post is how the prototype got something this simple wrong, and what the fix teaches about tokenizers in general.</p><h2>What did the prototype do with it?</h2><p>The Python prototype that proved the pipeline on six pieces before the Swift rewrite tokenized like most quick scripts do. It split the text on whitespace, then noticed that chords had been torn apart and tried to glue them back together:</p><pre><code><code>raw = text.split(); toks = []; i = 0
while i &lt; len(raw):
    t = raw[i]
    if t.startswith('&lt;') and '&gt;' not in t:
        while '&gt;' not in t and i+1 &lt; len(raw): i += 1; t += ' ' + raw[i]
    toks.append(t); i += 1</code></code></pre><p>Read the fourth line again. A token that starts with <code>&lt;</code> and has no <code>&gt;</code> in it must be the front of a chord, so keep swallowing words until one contains a <code>&gt;</code>. For <code>&lt;c e g&gt;</code> that works: <code>&lt;c</code>, then <code>e</code>, then <code>g&gt;</code>, glued back into one string, which a regex later splits on spaces a second time to find the pitches.</p><p>The symptom in the bug catalog is blunt: chords lost notes or corrupted whatever followed. Structure was an afterthought bolted onto whitespace.</p><h2>Why is fixing the repair loop the wrong answer?</h2><p>Because the assumption fails again at the very next construct, one layer up. Satie&#8217;s first Gnossienne contains this fragment, verbatim:</p><pre><code><code>&lt;&lt;af2 \new Voice{\voiceOne \once \hideNotes af4 }&gt;&gt; af4</code></code></pre><p>That is a parallel-music opener glued straight onto a pitch. No space between <code>&lt;&lt;</code> and <code>af2</code>. Feed it to the repair loop: the token <code>&lt;&lt;af2</code> starts with <code>&lt;</code> and contains no <code>&gt;</code>, so the loop starts gluing. It swallows <code>\new</code>, <code>Voice{\voiceOne</code>, <code>\once</code>, <code>\hideNotes</code>, <code>af4</code>, and only stops at <code>}&gt;&gt;</code>, the bracket that closes the whole passage. An entire voice has just become one giant &#8220;chord&#8221; token, and everything downstream of it is wrong.</p><p>That is bug 005 in the catalog, and it is the same bug as 001 wearing a different hat. You could special-case <code>&lt;&lt;</code>. Then you would special-case the next thing. If a construct can contain the delimiter you split on, splitting was the wrong first move.</p><h2>What does structural tokenizing look like?</h2><p>It looks like reading the characters in order and letting the grammar decide. The Swift tokenizer treats <code>&lt;</code> and <code>&gt;</code> as tokens in their own right, and the pitches between them tokenize exactly like pitches anywhere else:</p><pre><code><code>if c == "&lt;" {
    // `&lt;&lt;` opens simultaneous music; a single `&lt;` opens a chord.
    if i + 1 &lt; chars.count, chars[i + 1] == "&lt;", !inChord {
        tokens.append(.parallelStart)
        i += 2
    } else {
        tokens.append(.chordStart)
        inChord = true
        i += 1
    }
    continue
}</code></code></pre><p>Whitespace never participates. <code>&lt;&lt;af2</code> is a parallel opener followed by a pitch, because that is what the characters say, whether or not there is a space between them.</p><p>This landed as three commits on the same day, and their subject lines are the changelog:</p><pre><code><code>09d0891 Tokenizes chords structurally, never by whitespace split (BUG-001)
51608b1 Tokenizes the chord-repeat mark with an optional duration
958b2f5 Distinguishes parallel markers from chord delimiters</code></code></pre><p>The first one added twelve lines to the tokenizer and two test files. Run <code>git log --oneline --reverse</code> in the repo and they are lines 14 through 16. That is the &#8220;replayable history&#8221; claim from the launch post, in the small.</p><h2>How do you keep the bug from coming back?</h2><p>With a regression test that carries its own story. Here is the one for bug 001, lightly trimmed:</p><pre><code><code>/// **BUG-001 &#8212; Chord tokens split on whitespace.**
///
/// *Symptom:* in the prototype, `&lt;c e g&gt;` came out as garbage ...
/// *Root cause:* whitespace is not a token boundary inside `&lt;&#8230;&gt;` ...
/// *Guard:* the tokenizer scans structurally ...
@Suite("BUG-001: chords tokenize structurally, never by whitespace split")
struct BUG001_ChordTokensSplitOnWhitespace {
    @Test("chord with every duration and ornament suffix", .tags(.regression), arguments: [
        ("&lt;fis d b&gt;2", DurationToken(2), [Token]()),
        ("&lt;c e g&gt;", nil, []),
        ("&lt;d a fis d&gt;2.", DurationToken(2, dots: 1), []),
        ("&lt;d a&gt;4", DurationToken(4), []),
        ("&lt;c e g&gt;2~", DurationToken(2), [.tie]),
        ("&lt;c e g&gt;4(", DurationToken(4), [.slurOpen]),
    ] as [(String, DurationToken?, [Token])])
    func chordSuffixes(source: String, duration: DurationToken?, trailing: [Token]) throws {
        let sut = makeSUT()
        let tokens = try sut.tokenize(source)
        #expect(tokens.first == .chordStart)
        #expect(tokens.dropLast(trailing.count).last == .chordEnd(duration: duration))
        #expect(Array(tokens.suffix(trailing.count)) == trailing)
        // Every token between the delimiters is a pitch &#8212; nothing was torn apart.
        let inner = tokens.dropFirst().prefix { $0 != .chordEnd(duration: duration) }
        #expect(inner.allSatisfy { if case .note = $0 { true } else { false } })
        #expect(!inner.isEmpty)
    }
}</code></code></pre><p>Three things to steal from it.</p><p><strong>The doc comment is the bug report.</strong> Symptom, root cause, guard. Someone who opens this file in two years learns what went wrong without running <code>git blame</code>. The same three headings appear in <code>docs/lessons/bug-001-chords-split-on-whitespace.md</code>, and every lesson in that folder is also a suite like this one. Seven lessons, five with their own regression suite, two more guarded in the acceptance layer.</p><p><strong>The arguments are the corpus, not my imagination.</strong> The shapes in that list, the tie, the slur, the dotted half, are the ones the shipped pieces use. The test pins what the music actually writes, not what I imagined it might.</p><p><strong>The last assertion names the bug.</strong> &#8220;Every token between the delimiters is a pitch, nothing was torn apart.&#8221; Compare that with a test called <code>testChords2</code> that checks one expected array. Both pass today. Only one of them tells the next person what it is protecting.</p><h2>Why test with the exact hostile fragment?</h2><p>Bug 005&#8217;s regression test does not use a tidy example. It tokenizes the Gnossienne line above, backslashes and all, and asserts the full token list, fourteen tokens long. The lesson written at the top of that file is the one I would put on the wall: synthetic examples are tidy; real sources glue things together. A parser test built from a real file will catch the bug that a parser test built from your mental model of the format never will.</p><p>That principle scales up. It is the reason the acceptance layer builds whole pieces from their real Mutopia sources, and it is the reason the Clair de Lune story exists at all. That one is two posts of its own, and we are getting there.</p><h2>Read, then try it</h2><p>The repo is public: <strong><a href="https://github.com/lismondbernard/etude">github.com/lismondbernard/etude</a></strong>.</p><p>Read <code>Tests/EtudeKitTests/Regression/BUG001_ChordTokensSplitOnWhitespace.swift</code>first, then the lesson it points to in <code>docs/lessons/</code>.</p><p>Try it yourself: add <code>("&lt;c e g&gt;8.", DurationToken(8, dots: 1), [])</code> to the argument list, a dotted eighth, and run the suite. It passes without touching the tokenizer, which is the point. Then find one construct in a parser you own that can contain the delimiter you split on, and write this test for it.</p><p><em>Next post: stop 1 of the course map. A unit suite worth imitating: </em><code>makeSUT()</code><em>, sample builders, and one musical rule per test.</em></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://silentspice.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading Silent Spice! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Étude: a repo you can replay]]></title><description><![CDATA[The git history is a deliverable. One behavior per commit, no squash, mistakes still in the log.]]></description><link>https://silentspice.substack.com/p/etude-a-repo-you-can-replay</link><guid isPermaLink="false">https://silentspice.substack.com/p/etude-a-repo-you-can-replay</guid><dc:creator><![CDATA[Lismond Bernard]]></dc:creator><pubDate>Thu, 27 Aug 2026 22:42:22 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/5e9a7f9b-9ebc-4213-80ab-b2d0d55651dc_2400x1260.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I&#8217;ve just made something public, and I want to show you how to read it. The interesting part isn&#8217;t the app; it&#8217;s the git log.</p><p>It&#8217;s called <strong>&#201;tude</strong>: an open-source iOS app that turns classical sheet music into MIDI files you can play, retempo, and export to GarageBand or a game engine. Under the app is a small Swift engine that parses <a href="https://lilypond.org/">LilyPond</a>, the plain-text notation format the Mutopia Project uses to typeset public-domain scores, then validates the music and emits standard Type-1 MIDI. Seven pieces ship in the corpus: Bach, Petzold, Vivaldi, two Satie, and Debussy&#8217;s Clair de Lune, which has a story I&#8217;ll get to.</p><p>That&#8217;s the app. But the app isn&#8217;t really the point.</p><h2>What this actually is</h2><p>&#201;tude is a piece of working software <em>and</em> a course in how to test software, shipped as the same repository. Clone it, run <code>git log --oneline --reverse</code>, and the history replays as the course: one behavior per commit, each commit a red&#8594;green&#8594;refactor step with a subject line that names the behavior. &#8220;Resolves relative octaves within a fourth of the previous note,&#8221; not &#8220;Update Resolver.swift.&#8221;</p><p>I borrowed this discipline from the <a href="https://essentialdeveloper.com/">Essential Developer</a> curriculum I worked through years ago. The codebase you build there has a git history that reads like a TDD transcript, and &#201;tude&#8217;s history now reads the same way. I mean that as a checkable claim, not a boast: <strong>the git history is a deliverable</strong>, and it publishes exactly as it was written. No squashing, no cleanup rebase. There&#8217;s even an architecture decision record in the repo (ADR-0005) explaining why, including the three duplicated commit subjects that are still sitting in the log, documented as findings rather than laundered away. A testing course that quietly edited its own history would be teaching the wrong lesson.</p><p>So the tests aren&#8217;t scaffolding that got deleted before shipping. The tests <em>are</em> the product, as much as the app is: unit tests, hand-rolled property-based tests, golden-file comparisons, invariant validators, regression tests reconstructed from real bugs, and UI tests with page objects. Each one earned its place by catching something concrete, including a UI test that caught a real observability bug the unit tests couldn&#8217;t see, on the day it was written.</p><h2>Where to start reading</h2><p>The README has a course map: eight stops, each naming the one test file that best teaches a technique, in reading order. The phases of the build are tagged (<code>phase-1</code> through <code>phase-6</code>), so you can check out the repo as it stood at the end of any lesson. The <code>docs/lessons/</code> folder holds seven write-ups of real bugs (symptom, root cause, the guard now in place), and every one of them is also a named regression test in the suite.</p><h2>The piece that beat the prototype, and what closed it</h2><p>None of this was speculative. A Python prototype proved the pipeline on six pieces before the Swift rewrite. Getting there meant fixing a catalog of genuinely instructive bugs: chord tokens split on whitespace into nonsense, a bass line that spiraled into sub-audible octaves because relative pitch got threaded through a repeat, register drift that a defensive &#8220;just clamp it&#8221; fix quietly hid instead of surfacing.</p><p>And then there&#8217;s Debussy. <strong>Clair de Lune defeated the prototype outright.</strong> Its dense polyphony produced voices of 91, 46, 57, and 54 beats where they should have been equal. The Swift engine solved that alignment problem, but a residual defect remained: part of the left hand drifted below the piano&#8217;s range. Here&#8217;s the part I care about. Rather than fake a fix, <strong>v0.1.0 shipped with that defect loud</strong>: a permanently visible known-issue test, a public GitHub issue, and the app&#8217;s own diagnostics screen showing the drift honestly. That&#8217;s the project&#8217;s one non-negotiable rule at work: <em>never ship subtly wrong music. A correct excerpt beats an incorrect whole.</em></p><p>The issue is closed now, and the closing is the best lesson in the repo. Recovering the original Mutopia source of the score exposed <em>two</em> root causes: a hand-&#8221;correction&#8221; to the source that was itself wrong, and a resolver bug that the Swift engine had faithfully inherited from the prototype. Both engines agreed with each other, and both were wrong, which is exactly why agreement with your own prototype is worthless as evidence. Only an independent oracle, LilyPond&#8217;s own rendering of the original, broke the loop. That story (and the two other pieces it quietly corrected on the way) gets its own post; it&#8217;s the reason this series exists.</p><h2>What happens next</h2><p>Development continues in the open. Two issues are live right now. The first: in-app playback is silent on real devices, a lovely bug where every automated test passes because the test seam faked the hardware, and only a human with ears could catch it. The second: a feature to browse and download scores straight from Mutopia, which is where the parser meets the real world&#8217;s LilyPond instead of the corpus&#8217;s. The app is <a href="https://apps.apple.com/app/id6806620869">live on the App Store</a>, because a real product raises the stakes: it has to actually work, not just demo well.</p><p>The code is public under Apache-2.0 so it can be read, learned from, and contributed to; the name and icon stay mine as the practical guard against clone submissions. If you contribute, the same commit discipline applies. CONTRIBUTING.md spells it out, and it&#8217;s enforced on human and AI-assisted work alike. (&#201;tude was built with heavy use of Claude Code; the method, the architecture, and every recorded decision are mine, and the unedited history is the evidence of how the discipline held.)</p><h2>Follow along</h2><p>A word on who&#8217;s writing: I run <a href="https://silentspice.com/">Silent Spice</a>, a one-person studio shipping iOS apps: a <a href="https://silentspice.com/foreignwords/">picture-matching vocabulary</a> game in eight languages, an app for <a href="https://silentspice.com/tennisparent/">junior-tennis parents</a>. &#201;tude is me showing the working method behind those apps instead of just asserting it.  I apply the same method when I audit other people&#8217;s iOS apps.</p><p>If you write software and testing has always felt like a chore bolted on afterward, this series is for you. Over the coming posts I&#8217;ll walk the course map stop by stop: real code, real test failures, and the decisions I got wrong along the way, which are still in the log because that&#8217;s the point. If you just want to turn a Satie piece into a MIDI file and drop it into your game, the app is for you too.</p><p>The repo is public: <strong><a href="https://github.com/lismondbernard/etude">github.com/lismondbernard/etude</a></strong>. Start with the README&#8217;s course map, or just run <code>git log --oneline --reverse</code> and scroll.</p><p>&#8212; <em>Next post: the tokenizer, and why </em><code>&lt;c e g&gt;</code><em> is harder than it looks.</em></p>]]></content:encoded></item></channel></rss>