Design notes — Part IV — Phase 5: Packaging, Release, Web & Documentation (steps 23–33, formerly `DECISIONS_4.md`)
Ambiguities resolved during Phase 5 — the desktop harness becoming the
product, the release spine and its five lanes, the WebGPU seam and the marble
web, the documentation site, and the Step-33 feedback batches on every
platform (the scoped unfreeze: core 0.5 → 0.9). Prior history: Parts I–III
above (references written as DECISIONS_3 #n mean Part III). Entries here are
referenced elsewhere as DECISIONS_4 #n. Where an entry says PHASE5 §n it
means the Phase-5 spec as it stood during the phase — now folded into
PROJECT_SPEC.md §9 (PHASE5 §n ≈ §9.n); ROADMAP_4 means ROADMAP.md
Part 4. Where these entries and the spec conflict, the later entry is the
record of what shipped. Entries #73–#77 were written on the Linux box while
#78–#80 were written on the Mac (the two machines numbered in parallel; the
Mac’s three were renumbered at the fold).
Step 23 — Desktop productization (macOS)
Section titled “Step 23 — Desktop productization (macOS)”-
Product naming — one name, already in use everywhere:
midi-sink. The app is what the repo, the iOS App Store Connect record and the Linux desktop entry already call it; only the core library carries thesuminame (libsumi,sumi_core.h). Recorded once so every later manifest references it:- Display name / executable / window title:
midi-sink. - macOS bundle id:
com.vibetuned.midi-sink(the iOS app’s id, one Team, one identity family; Android stayscom.vibetuned.midisink— Java package rules forbid the hyphen, and that record exists). - Homebrew cask token:
midi-sink. winget id:Vibetuned.MidiSink. Debian package:midi-sink. Linuxapp_id/WM_CLASS/.desktopbasename:midi-sink(unchanged, DECISIONS_2 #39). - Config directory name on every desktop:
midi-sink(#4 below). Renaming was considered (“Sumi”, “Suminagashi”) and declined: the store records, the tap and the apt repo are the author’s existing infrastructure (working rule), and a name change would orphan them for no user benefit.
- Display name / executable / window title:
-
The shared settings UI is Dear ImGui in a SECOND GLFW window with its own OpenGL context — one implementation for macOS, Windows and Linux. The constraints that force it: the harness may not include sokol headers (working rule — sokol lives behind
renderer.cpp), sosokol_imguidrawing into the core’s swapchain is out; the core OWNS the main window’s device/drawable (§5.1), so a second renderer cannot share that surface without a core change (frozen); native toolkits would be three implementations of one window (the “authored once” rule). A separate window withimgui_impl_glfw+imgui_impl_opengl3(its self-contained loader — no GLEW/GLAD) needs nothing from the core and is identical on all three platforms. GL 3.2 core /#version 150everywhere (the macOS ceiling for a forward-compatible context; deprecated there but present through macOS 26 — a settings panel is the right size of bet). On Linux, where the MAIN window’s context is the core’s (§5.1 GL exception), the settings frame makes its own context current and restores the main one before the nextsumi_update— the core never sees a foreign context. Dear ImGuiv1.92.9b, pinned via FetchContent like every dependency (§7). The window opens beside the canvas at launch; closing it hides it; ⌘ , / Ctrl , reopens it (the platform’s own “Preferences” chord — the one keyboard binding a release build keeps, and the first-run hint names it). -
Version strings:
SUMI_APP_VERSIONis a CMake cache variable the release spine injects from the tag; locally it defaults togit describe --tags --always --dirty. No version is hand-edited anywhere (working rule).sumi_version()(0.4.0, the ABI) is a DIFFERENT number and stays so: About shows app version, commit and engine version side by side. Info.plist’sCFBundleShortVersionString/CFBundleVersionmust be numeric, so the build extractsX.Y.Zfrom the tag and falls back to0.0.0for untagged dev builds (the describe string still appears in About and in a customSumiBuildDescribekey). Until the first tag exists About reads e.g.dev 5ba722f. -
Settings persist in a plain INI in the platform config directory:
~/Library/Application Support/midi-sink/settings.ini(macOS),%APPDATA%\midi-sink\settings.ini(Windows),$XDG_CONFIG_HOME/midi-sink/settings.ini(Linux,~/.configfallback). Everything the settings window shows is stored — params mirror, CC map, ripple CC values, print folder, whether the settings window was open, andfirst_run_dismissed(the spec’s one dismissible hint needs exactly one persisted bit). Written on every change (the file is a few hundred bytes); a missing or malformed file yields defaults, never a failure. -
--devscope — the lab bench is a flag, never a build variant. Without--devthe harness accepts only--dev,--help,--version; every debug key (1–9, L, B, S, V, K, C, P, J, W/E, M, O, R/T, F/G, X), every scripted flag (--field-dump, the step-19/20 test battery,--drop-test, demos,--dip-*,--cycle-visuals,--resize-test,--exit-after,--map-cc,--layout,--sim-scale,--print-out) and the raw-MIDI log toggle are refused with one line pointing at--dev. With--deveverything behaves as before, and the settings window gains a “Lab bench” section (key legend, ripple live/bake override, raw MIDI log, the swirl test voice). Release builds keep the flag so support can say “run with —dev”. The §4.6 field regression is therefore invoked asmidi-sink --dev --field-dump …from now on.S(save print) lost its key and became the “Save last print as PNG” button. -
macOS is a real
.app; the runtime Dock-tile hack retires.MACOSX_BUNDLEtarget withpackaging/macos/Info.plist.in(LSMinimumSystemVersion 12.0,NSHighResolutionCapable, music category),packaging/macos/midi-sink.icnsgenerated bytools/gen_icons.py(Pillow’s ICNS writer — noiconutildependency, so the generator stays cross-platform; the generator gained--onlyso a macOS step regenerates only macOS assets, per the one-platform rule), hardened-runtime entitlements (packaging/macos/entitlements.plist, deliberately empty: CoreMIDI needs no entitlement, the app loads no plugins), and an ad-hoccodesign --options runtimepost-build step (SUMI_CODESIGN_IDENTITY, default-; Step 27 sets the Developer ID).sumi_macos_set_dock_iconand the generatedapp_icon_macos.hare deleted — the bundle’sCFBundleIconFileis the Dock tile now. The binary path on macOS becomesbuild/desktop/midi-sink.app/Contents/MacOS/midi-sink; Windows and Linux keep the bare executable (the Linux install component is untouched). -
The CC-map editor works on a HOST-SIDE MIRROR because the core has no map readback and stays frozen. The mirror is seeded with the core’s
install_default_cc_maptable plus the harness’s two ripple routes (CC 102 →RIPPLE_AMP, CC 103 →RIPPLE_FREQ, DECISIONS_3 #32) and applied assumi_clear_cc_map+ onesumi_map_ccper route — so the core’s state is always exactly the mirror. Consequence: “Restore defaults” restores the documented README table, and the ripple sliders in the settings window drive whatever CC is routed to the ripple dims (they grey out if the user removes that route — the sliders are MIDI, not a private channel into the core). -
MIDI port list = the harness’s open-input snapshot + rescan age. The harness already rescans every second (DECISIONS #25); it now exposes a copied name list, seconds since the last rescan, a “rescan now” and the raw-log toggle, all taken under its existing mutex, so the settings window shows exactly what the byte path is connected to. No new MIDI code path: the list is the truth the rescan already had.
-
Three macOS shell facts learned while making the bundle real (Step 23 evidence). (a) GLFW’s Cocoa backend
chdir()s a bundled app intoContents/Resourcesby default (GLFW_COCOA_CHDIR_RESOURCES), so the first--dev --field-dump out.binwrote INTO the bundle and every relative print path would have too; the hint is nowGLFW_FALSEbeforeglfwInit. (b) A position set on a still-hidden window is replaced by macOS’s own frame on first show, and aglfwFocusWindow(canvas)issued after the settings window appeared buried it behind the canvas on a display too narrow for both side by side (the visible strip past the canvas’s edge looked exactly like a screen-edge clip and cost three captures to diagnose). The settings window’s placement is clamped to the monitor work area (right of the canvas, else left, else flush right), re-applied once on first show, and the canvas is not refocused after it appears;Cmd ,raises it any time. (c) Dear ImGui’s default font covers Latin-1 only —⌘and—render as?; UI strings sayCmd ,and use ASCII dashes. Also: settings are written on FIRST RUN, not only on change/exit, so a session that ends by force still leaves the file.
Step 24 — Release orchestration spine
Section titled “Step 24 — Release orchestration spine”-
One tag-triggered workflow,
release.yml, whose spine has zero platform lanes:version→gates(matrix) →publish.versionis the single source of the version string —${GITHUB_REF_NAME#v}on a tag push, the dispatch input (or0.0.0-dry.<run>) otherwise — validated asX.Y.Z[-pre]; every downstream job builds with-DSUMI_APP_VERSION=${{ needs.version.outputs.version }}and the gate asserts the binary’s--versioncarries it (a version that CI injects but the binary does not show is the bug the working rule exists to catch).dry_runis a first-class output: on a manual dispatch it defaults to true, the gates and the notes run in full, andpublishis skipped (if: dry_run != 'true'); a tag push is never a dry run. Following battuta / midi-stroke: the release is created as a DRAFT (softprops/action-gh-release, notes as body, prerelease when the version has a-), a human publishes it, and the channel bumps (cask / winget / apt) are separaterelease: publishedworkflows in Steps 27/29/30 — a draft has no public asset URLs, so a bump inside the spine would always point at nothing. iOS and Android have no lane job, by the author’s decision (roadmap revision during Step 24): store builds are manual procedures (Steps 28/31 — Xcode archive → TestFlight, Android Studio bundle → Play internal, from aRELEASING.mdchecklist against a tagged checkout with the CI-injected version). Store credentials never enter CI; push CI keeps mobile build-only compile checks so a tag never surprises the archive. -
The §4.6 field regression is the packaging gate, run through the REAL renderer on every desktop runner, and it carries its own negative control.
tools/field_gate.pydumps the canonical field withmidi-sink --dev --field-dump, compares it to the committed Metal fixture with the backend’s tolerance (desktop backends: the comparator defaults 1e-2 / 1e-4 — D3D11 and GL both matched the fixture within them in Steps 11/12), and then compares the dump to a DELIBERATELY CORRUPTED copy of the fixture (+0.5 on a 32×32 block of u) and requires the comparator to FAIL it — “proven red before it is trusted green”, every run, not once. Distinct exit codes name the failure class: 1 the field regressed, 3 the gate cannot go red, 4 no renderer on this machine (infrastructure, not a regression).publishand every laneneeds: gates, so any red blocks packaging by construction. Linux runners have no display: the GL gate runs underxvfb-runwith Mesa llvmpipe (LIBGL_ALWAYS_SOFTWARE=1). Flagged, not fixed here: the D3D11 swapchain creates aD3D_DRIVER_TYPE_HARDWAREdevice only; a GPU-less Windows runner may return exit 4. If the first dry run does, the WARP fallback belongs to the Windows lane step (29) — aswapchain_*seam change like the Step-11/12/14 pattern, not a core change — and until then the Windows gate is honestly red, never quietly skipped. -
Release notes ARE the changelog section.
tools/release_notes.pyextracts## v<X.Y.Z>fromCHANGELOG.mdverbatim under a title line (the heading’s subtitle becomes the italic deck) and appends the generating commit. No section yet → the latest section is used and the draft is marked DRAFT (a dry run on a test tag still yields a versioned notes draft, as the DONE asks); on a REAL tag the spine runs--strict, so a tag cut before the changelog carries its section fails inversion— the release cannot exist without its notes. No third format, ever (the condensed-evidence practice continues). -
The lane interface is a contract written in the workflow file, for exactly four CI lanes. A lane is one job:
needs: [version, gates]; every upload guarded bydry_run != 'true'; exactly one artifactdist-<lane>(web, macos, windows, linux) whose files are namedmidi-sink-<version>-<platform>[-<variant>].<ext>; lanes never create releases —publishmergesdist-*and drafts one. No mobile artifact names exist in the contract (see #10). Written at the top ofrelease.ymlso Steps 25/27/29/30 read it where they will edit. -
build.yml(push CI) stays compile + headless suites. The field gate is deliberately NOT promoted to every push until a dry run has shown which runners can render (D3D11 on a VM, Mesa on Ubuntu). Once it has, promoting it is one step copied fromrelease.yml. Recorded so nobody reads its absence from push CI as an oversight.
Step 25 — WebGPU backend & marble web
Section titled “Step 25 — WebGPU backend & marble web”-
The WebGPU seam: the HOST creates the device, the CORE owns the surface. A browser can only create adapter and device asynchronously, and
sumi_createis synchronous by contract — so the page doesrequestAdapter/requestDevice, imports the device into the wasm (Module.WebGPU.importJsDevice, emdawnwebgpu) and passes it in a newsumi_webgpu_surface_t {device, canvas_selector, color_format}as thenative_surface_handlefor the newSUMI_BACKEND_WEBGPU(= 4). The core then creates the surface from the CSS selector, configures it in the canvas’s preferred format (getPreferredCanvasFormat, passed in — no adapter needed core-side), acquires the frame texture, reconfigures on resize and releases everything at destroy. The browser presents. This keeps §5.1’s “core owns the swapchain” everywhere the platform allows it and moves exactly the one async step to the host. Additive ABI →sumi_version()0.5.0;abi_c_compileexercises the struct in C11. ASYNCIFY/JSPI (to makesumi_createblock on device creation) was rejected: whole-module instrumentation for one call, and JSPI is not in every target browser yet. -
Readback on WebGPU: injected CopySrc textures + copy + mapAsync, and the field read split into begin/poll. sokol’s WebGPU backend never sets
CopySrcon the textures it creates, socopyTextureToBufferfrom the field/print targets would fail validation. Rather than patch a dependency or write a raw-WebGPU blit, the swapchain contract gained a prepare/release pair: the renderer callssumi_swapchain_prepare_imageright beforesg_make_imagefor the two readback-bound targets (field pair, print) andrelease_imagebeforesg_destroy_image; on WebGPU prepare creates the texture with RenderAttachment|TextureBinding|CopySrc and injects it viasg_image_desc.wgpu_texture, on the other three TUs both are no-ops.mapAsynccompletes only when control returns to the event loop, sosumi_swapchain_yieldis a no-op on the web and the syncsumi_renderer_read_fieldwas split intoread_field_begin/read_field_poll(the sync form is now those two plus the old bounded yield loop);sumi_debug.hexposes the pair and the web host polls the §4.6 dump across frames. The print path was already async and needed nothing. Rows come back 256-byte padded and are de-padded in poll. -
The wasm export surface is the C-ABI itself, plus a struct-building shim. Every
SUMI_APIfunction is inEXPORTED_FUNCTIONSand called throughcwrap;web/sumi_web.cppadds only what JS cannot do without byte-level struct layouts —sumi_web_create(buildssumi_config_t+ the surface struct), flatget/set_param(id)accessors oversumi_params_t, and the field-dump hooks (internal, static-link-only; the wasm IS a static link).-sMODULARIZE -sEXPORT_ES6(createSumi()),-sENVIRONMENT=web, memory growth on. hostmpe is not built for the web (Play mode is web-deferred, PHASE5 §5/§7). -
WGSL joins the shader dialect list for EVERY build (
SUMI_SHDC_SLANGgains:wgsl): the generated headers carry all dialects and each backend picks its own atsg_make_shader, exactly how Metal/HLSL/GLSL coexist today. The desktop binaries grow by the WGSL text — negligible — and there is one shader header, not a web-specific one. The §4.6 orientation story needs nothing new: WebGPU’s texture origin is top-left like Metal and D3D11, so theflip_vert_yGLSL-only option stays GLSL-only. -
The scene/embed API is a page contract, not a second engine.
?scene=<name>selects an operator scene — drop, tine, vortex, rankine, wake, pinch, ripple, lamb_oseen, scroll — each a deterministic script on a fresh sheet whose sliders are the FORMULA’S SYMBOLS (r; α z; A R; ω R; a d; k θ n v; A k φ b; Γ v t; bpm ρ) and whose values can be preset through query parameters;&embed=1strips the chrome. The docs (Step 26) embed these URLs, so their live examples are the release wasm and nothing else drifts.?fielddump=1is the §4.6 web tier: fixed 512×512, the canonical script, the non-blocking readback, and a download of the same.binformat the desktop harness writes (half→float in JS), sofield_dump_compareneeds no web-specific code. Pages layout: the marble app lives under/marble/; the docs site owns the root from Step 26 (a redirect stands in until then). The web lane is the first lane on the Step-24 spine:dist-web=midi-sink-<version>-web.tar.gz, Pages deploy guarded bydry_run. Emscripten pinned at 4.0.15 in CI (mymindstorm/setup-emsdk); locally Homebrew’s 6.0.9 — the wasm is reproducible per pin, and the pin moves deliberately. -
The web tier’s §4.6 tolerance is the DESKTOP default (max ≤ 1e-2, mean ≤ 1e-4) — measured, not provisioned. First WebGPU dump (Chrome 152 headless, Apple Metal-3 adapter, Dawn) against the committed Metal fixture: max|Δ| 9.77e-4 (ink), 4.88e-4 (u, v), 0 (aux); mean 7.9e-9 — an order of magnitude inside the desktop budget and far from the mobile tier (2.5e-2 / 1e-3) the plan had reserved for it. Recorded so the gate stays honest:
tools/web_gate.mjsdefaults to 1e-2 / 1e-4. Three things the first headless runs taught, kept in the code:- The device bridge is
Module.preinitializedWebGPUDevice+emscripten_webgpu_get_device(), notModule.WebGPU.importJsDevice— the port’sWebGPUlibrary object is never exported onto the module. The accessor is marked deprecated-in-name in the port’s JS (“TODO: remove once fully deprecated in users”); if it goes, the replacement is exporting the library’s import function explicitly. The C-ABI contract (sumi_webgpu_surface_t.device) is unaffected either way — only the export glue fetches the handle. - Map callbacks must be
AllowSpontaneous. WithAllowProcessEventsthe completion is queued on the instance that owns the device’s event manager — the host-imported device’s, not the instance the TU creates for the surface — and pumping ours never delivered it (polled 1,700 frames without a completion). Spontaneous delivery comes straight from the browser’s promise resolution. - Copying the host page is its own always-run target
(
sumi_web_site), not a POST_BUILD of the wasm: a JS-only change never relinks the module, so the copy silently did not happen. Also: the gate tool (tools/web_gate.mjs) serves the build, drives headless Chrome and receives the dump + the page console over POST — the page cannot download in a headless run and a headed tab throttles its frames when the display sleeps, which cost an hour before the tool existed. It is the release lane’s future evidence hook as well. - Secure context (found by the user on first launch):
navigator.gpuexists only onhttps://orlocalhost; the LAN URL over plain http reads as “no WebGPU”. The overlay now names the real cause and the fix, andtools/web_serve.pyserves the build over HTTPS with a self-signed certificate (SANs = the Mac’s IPs) for iPad/LAN testing. Same rule applies to WebMIDI, and the Pages deployment is HTTPS by nature — the constraint is a dev-serving one only. (The first cut of that server was Node; the author’s macOS firewall has an explicit “block incoming” rule for Homebrew’s node binary, which reset every LAN connection while localhost worked — Python, which the firewall allows, is the server now. Recorded because the symptom looks exactly like a TLS or routing bug.) - The “no WebGPU” overlay showed over a WORKING canvas (user report,
confirmed: the scene rendered behind it). Cause:
.overlay { display: grid }— an authordisplayrule outranks the browser’s[hidden] { display: none }, so the card was painted whatever the attribute said; every earlier “no WebGPU” report on this Mac and the phone was this bug, not a WebGPU problem. Fixed with.overlay[hidden] { display: none }. Lesson kept: never give ahidden-toggled element an unconditionaldisplay.
- The device bridge is
-
The web page gets the desktop settings window, as lil-gui (user request: “the Mac version has its options”).
lil-gui0.21.0 is VENDORED (web/site/vendor/, MIT notice beside it), never CDN-loaded: the Pages site must work with no third-party origin and reproducibly per pin. The panel mirrorsdesktop/src/settings_ui.cppsection for section where marble mode has the concept — Layout & look (six layouts, three palettes, viscosity / ink feed / roughness, full-resolution, tempo and roll speed), Expression routing (per-note bend, channel pressure, CC 74, pinch style, vortex profile), Ripple (amount / wavelength through the routed CC 102/103 exactly like the desktop’s sliders, angle), Canvas (paper dip, save last print), About (engine version, WebMIDI inputs, live frame stats, reset). No CC-map editor and no MIDI-port rescan (no ports to open on the web — WebMIDI hands them to us). Settings persist per browser inlocalStorage(sumi-web-settings, the INI’s role) and are applied before the first frame; a?scene=page does NOT apply them — a scene owns its parameters so embeds are deterministic;embed=1hides the panel; it starts collapsed under 720 px. The old info card and topbar buttons folded into the panel. Placement (user): the panel sits at the TOP LEFT and there is no top bar — the panel’s title is the brand; the scene card moved to the top right; a status pill at the top left shows only the messages that arrive before the panel exists (loading, no adapter, insecure origin). lil-gui’s owntop: 0; right: 15pxauto-place rule is injected after the page stylesheet, so the override carries a third class (.lil-gui.lil-root.lil-auto-place) — 0.21 renamedroot/titletolil-root/lil-title.
Step 26 — Documentation site
Section titled “Step 26 — Documentation site”-
The site is Astro Starlight at
site/, deployed from the release tag, and three URLs are frozen. The author’s other documentation sites (battuta, Midi Stroke) are Starlight; this one follows them — same package layout, same relative-link discipline, sameDOCS_BASEproject-site escape hatch. Domain:https://midi-sink.vibetuned.com/(the<project>.vibetuned.comconvention of the sibling sites; the custom domain is set in the repo’s Pages settings with a DNS CNAME tovibetuned.github.io, both human actions). The frozen URLs every later lane and store listing hardcodes: homepage/, privacy policy/privacy/, support/support/, and the marble web app/marble/. Changing any of them after Step 27 means editing a cask, two store records and the beta guide — so they do not change. Deploy path: the docs build joined the release workflow’swebjob (Node 22,npm ci,npm run build, composesite/distat the root andbuild-web/web-distunder/marble/), so the site can only ever document the release it ships with — the same tag builds every artifact and the site, one deploy. There is deliberately no push-to-main Pages deploy: it would publish a site whose/marble/and version line disagree with the released artifacts.build.ymlgained adocsjob (build + drift check- chart check) so PRs cannot break the site.
-
Formulae are KaTeX through the
@astrojs/markdown-remarkbridge. Astro 7’s default Markdown processor no longer accepts remark/rehype plugins directly;remark-math+rehype-katexneed that package installed and the (deprecated-but-working)markdown.remarkPluginsform. Recorded so the warning in the build log is not chased. -
Design notes and changelog are GENERATED, verbatim.
scripts/ build-notes.mjsrendersCHANGELOG.md, the four parts ofDECISIONS.md(split on the# Partheadings) and, while Phase 5 is in flight,DECISIONS_4.mdas Part IV, intonotes/pages that are gitignored and rebuilt before every build. The only edits are mechanical: frontmatter, the H1 dropped, repository path prefixes (_work/,docs/) trimmed to bare file names. Not one entry is reworded — the spec’s “lightly edited … entries kept verbatim otherwise”, taken literally so the record cannot drift from the file. -
“No second implementation” is a build failure, not a convention. Every operator demo is
<Operator scene=…>, an<iframe>of/marble/?scene=…&embed=1— the release wasm through the Step-25 scene API.scripts/check.mjsruns post-build and fails on: any.wasm,sumi.jsorsumi-host.jsinside the docs output; any WebGPU or engine call in a docs page; any iframe not pointing atPUBLIC_MARBLE_URLwith a knownscene=andembed=1; any of the nine scenes never embedded. Plus dead internal links, the three frozen URLs, the gallery manifest’s caption fields, and unrendered$$. Authoring locally pointsPUBLIC_MARBLE_URLattools/web_serve.py. -
The MIDI chart is data, and the data is checked against the byte logs.
site/src/data/midi-chart.jsonholds every row;<Chart>renders it;tools/chart_check.pyreplays the Play-mode byte logs (docs/evidence/step26/bytelogs/, the Step-22 sessions restored from git history: one per source — fingers, stylus, strip + pen pedal, session config) and asserts everypresentrow is observed for that source and channel class, everyabsentrow is not, and every observed message is described by a row (an undocumented output fails), plus the constants (zone size 15, RPN 0 = 48 on 15 distinct members, first finger bend = centre, strip on the master). Input-mode sections are the normalizer’s contract and cite the headless suite. The CIdocsjob runs the check. First run caught one bug in the checker itself: a session that re-syncs repeats RPN 0 on all members, so the count is of distinct channels, not rows. -
The gallery is a runtime manifest; the tribute piece is Ali Paşa.
public/gallery/gallery.jsonis fetched by the page, so a new recording is an entry plus a file (schema inpublic/gallery/README.md); entries without media render as “recording pending”, and the step ships with the three planned performances so marked (videos are an author input). Tribute identification, from the credit frames of Jaffer’s own videos (downloaded from his CSAIL page; title cards read with ffmpeg): Bouquet →voluntocracy.org/Music/Kendime.abc, Latte →AliPasa.abc, Wave →RampiRampi.abc— his page says only “Turkish songs popular for international folk-dancing”, the videos name them. Chosen: Ali Paşa, the tune of Latte, the one animation drawn with a single stylus rather than a rake — midi-sink’s pen. Public-domain check: the ABC header saysC: Trad.,O: Turkey; the Society of Folk Dance Historians records the song as the anonymous lament for Ali Pasha of Van, Turkish Folk Music Archive no. 398 (collected by M. Sarısözen); the DANCE was set by Bora Özkök, and is not what is performed. The alternates are also traditional (Rampi Rampi = Çadırımın Üstüne, 9/8, credited Traditional, first recorded 1946; Kendime, presented by Özkök). The optional Turkish-moiré scripted scene is NOT taken (it would need a new scene in the web host — Step 25 code — for an optional bridge). -
Privacy and support pages say what is true and nothing more. The privacy policy states “collects nothing” and enumerates every place data actually touches (MIDI in memory, settings on device, prints on request, evidence logs in the app’s own folder), the permissions and why, the Apple “Data Not Collected” and Play “no data collected / shared” statements, and GitHub Pages’ own request logging. The support contact is the public
info@vibetuned.com(the address the author’s other sites publish) and the issue tracker — never a personal address. -
Operator scenes are paced and two-sited, and the pressure feed got its own scene (user review of the operator book). The first cut showed each operator’s RESULT; the book is about the mathematics, so every scene now (a) takes a
paceslider — frames between steps, default 2, 0 = instant — and applies the exactly-composing operators as a run of small passes (tine z/n × n, vortex A/n × n, pinch and wake sub-steps one per step, the ripple amount ramped in), which is also the composition invariant shown live; and (b) works on two ring clusters, A top-left (0.30, 0.30) and B bottom-right (0.70, 0.70), so one operator shows two orientations or two signs at once — horizontal vs vertical tine, +A/−A vortices, fold axes θ and θ + 90°. The swirl is the exception (user review): stirring a ring cluster about its own centre shows little, so one voice sits at the centre and four ring pools in the corners are carried by the 1/r² far field over a long stir — the picture is the far field. New scenefeed: A re-strikes a note (separate drops, rings) while B holds channel pressure on one note (one band, boundary growth) — the drop page shows the two side by side. Voice-driven scenes (swirl, feed) place their notes with the LAYOUT PROBE, now exposed to the page assumi_web_probe(shim only — the probe is existing ABI, no core change): the chromatic grid’s cell at A and at B, so the picture lands where the clusters are on any aspect. The headless sweep runs withpace=0(it tests completion, not pacing). Two things the first captures taught: (i) band parity follows the GLOBAL drop counter, so two sites fed A B A B … get one parity each — a solid disk and a hollow one; the order is A B | B A | … so each site alternates; (ii) a rotation of concentric rings about their own centre is invisible, so the vortex and Rankine scenes sit each vortex (0.07, 0.05) off its cluster — the exponential then visibly shears the rings and the Rankine visibly carries a rigid piece of them. Vortex defaults were then raised (A 6, ω 5 over 48 passes; sliders to ±12.6) so the demos show a FORMED vortex, not the start of one; the swirl’s centre voice is struck softly and covered with a CLEAR drop (sumi_add_droplayer 1) so the core is unmarked water — the per-note strike cannot be suppressed from the host, which is the gesture gap #30 records; the author has since scoped Step 33 to unfreeze the core for such fixes. -
A Marble-mode gesture for the feed and the swirl — NOT taken in Phase 5, recorded for Phase 6. The user proposed Shift + left-drag (mouse) and a long press (tablets). Two facts block it here: (1) it needs the core — neither the pressure feed (boundary growth on an existing drop’s own band) nor the Lamb–Oseen swirl has a gesture entry point in the ABI; both exist only as voice dimensions, and in Marble mode a touch point has no note (the probe answers only on the playable lattices), so they cannot be synthesized as MIDI either — a
sumi_add_swirl/sumi_feed_droppair is a core ABI addition, and the core is frozen this phase (working rule, Step 30’s seam excepted); (2) Shift + left-drag is already the desktop PINCH (README, DECISIONS_3 #34). Recommended mapping when the core reopens: desktop Option/Alt + left-drag (up = feed, down = swirl, the Play-mode Y axis with the mouse); tablets long press (~250 ms without travel) that turns the touch into the Play-mode bipolar Y without a note — push away = feed, pull back = stir — so the gesture vocabulary matches what fingers already do in Play mode. Sits with the Phase-6 layouts as the next core change.
Step 27 — macOS release lane
Section titled “Step 27 — macOS release lane”-
The macOS lane is one job plus one script, and the script is the same one you run locally.
packaging/macos/release.sh <app> <version> <out>does the whole release path — verify universal,codesignwith a secure timestamp and the hardened runtime, notarize the app, staple the app, build the DMG around the stapled app (hdiutilUDZO,/Applicationssymlink), sign the DMG, notarize the DMG, staple the DMG,spctlassess both, sha256 — driven entirely by environment variables, so without any credentials it produces an ad-hoc DMG (fork, local dry run: the mechanics are provable on any Mac) and with them the real thing. Stapling only the DMG was rejected: a dragged-out.appwould carry no ticket, and a first launch offline would be refused — two notarizations cost minutes and buy Gatekeeper-clean everywhere. The universal build is CMake’sCMAKE_OSX_ARCHITECTURES="arm64;x86_64"with deployment target 12.0 (the Info.plist’sLSMinimumSystemVersion); every FetchContent dependency builds fat, the shader compiler runs on the host arch. Tests are OFF in the lane (the arm64 gate already ran them); the lane re-checks--versionagainst the tag. On a real tag with a certificate present, notarization is REQUIRED (REQUIRE_NOTARIZATION=1): an unnotarized DMG is never attached. Dry runs sign but skip the notary round trip. -
Credentials are the organization secrets the author’s other apps already use — Apple ID + app-specific password, not the roadmap’s “notary API key”. battuta and Midi Stroke sign and notarize with
APPLE_CERTIFICATE(base64 .p12),APPLE_CERTIFICATE_PASSWORD,APPLE_SIGNING_IDENTITY,APPLE_ID,APPLE_PASSWORD,APPLE_TEAM_ID, andTAP_PUSH_TOKEN; the working rule says the author’s infrastructure is an input, so the lane consumes exactly those names. FLAG: ROADMAP_4 Step 27 lists a “notary API key” — the script also accepts an App Store Connect key (NOTARY_KEY_P8/NOTARY_KEY_ID/NOTARY_ISSUER) should the author switch, but nothing requires it. The certificate is imported into a throwaway keychain on the runner (security import, partition list for codesign) and the p12 file deleted at once. -
The cask bump opens a PULL REQUEST on the tap; the siblings push to main. Same file conventions as
Casks/battuta.rb(#{version}URL,livecheck :github_latest,depends_on macos,zap trash), same trigger (release: published— a draft has no public asset URL), same token. The deviation is the branchmidi-sink-<version>+ PR, because Step 27’s DONE demands that a TEST tag exercise the whole path down tobrew install --cask— and a release-candidate cask must never land on the tap’s main. Merging the PR is the human’s release act, exactly like publishing the draft.TAP_PUSH_TOKENtherefore needspull-requests: writebesidescontents: write; if it lacks it the branch is still pushed and the job prints the compare URL.homepageis the frozen Step-26 URL (#22);depends_on macos: ">= :monterey"mirrorsLSMinimumSystemVersion 12.0. -
“How do we test this?” — a release-candidate tag, published as a pre-release.
v0.5.0-rc.1runs the spine like any tag; theversionjob already marks anything with a-as a pre-release, so the draft is a pre-release draft. The human publishes it (still a pre-release),publish-caskopens a[pre-release]PR, and the DONE checks run against it: a fresh macOS user account downloads the DMG from the published pre-release (Gatekeeper-clean, About shows0.5.0-rc.1), andbrew install --cask ./Casks/midi-sink.rbfrom the PR branch installs. The PR is then closed unmerged; a later real tag repeats the path and is merged. Nothing about the RC leaks to Homebrew users. Store submissions remain human (working rule) — the lane stops at “uploaded”. -
Gate calibration on the first real three-runner run (tag
v0.5.0-rc.1): the CI software rasterizers run the SECOND tolerance tier. #11 set the desktop gate at 1e-2 / 1e-4 from hardware evidence (D3D11 and GL bitwise-identical to the Metal fixture on real GPUs, Steps 11/12); the GitHub runners have no GPU — D3D11 comes up on WARP and GL on Mesa llvmpipe 25.2 — and both landed at mean 6.0e-4 from the Apple fixture (max 3.4e-3 on u/v, 9.3e-3 / 1.6e-2 on the ink phase at one band-edge texel), so both gates went red while the Metal gate was green. The evidence that this is rounding, not a regression: the two runners agree with each other to mean 1.8e-5 (33× tighter than either is to Metal); the coordinate maxima are exactly 7 and 3 half-float quanta (2^-11) — accumulated RGBA16F rounding over seven ping-pong passes, where an orientation or math error would be O(0.1); and the local Metal gate stays bitwise. The Apple GPU is the outlier among the three, not the two software rasterizers. Decision: the gate matrix carries per-runner tiers — reference tier 1e-2 / 1e-4 on macOS (the fixture’s own hardware family), second tier 2.5e-2 / 1e-3 on the Windows and Linux runners (the tier #20 had reserved for non-reference hardware; the web tier, Dawn on the same Apple GPU, stays at the reference tier as measured). What the second tier still catches: a one-texel shift anywhere is a 2e-3 mean (fails); a flipped pass is O(0.1) (fails); the negative control (+0.5 block) fails on every tier — re-verified. What it does not: sub-texel drifts below 1e-3 mean on CI only, which the author’s real GPUs (bitwise) and the Metal gate (1e-4) still see. The measured headroom is 1.6–1.7× on both dimensions; if a runner-image update moves it, the numbers here are the reference for re-calibrating, and per-backend committed fixtures (a bitwise self-check per rasterizer) are the next step up, declined for now to keep 8 MB of binaries out of the tree. Also learned: Windows runners DO create a hardware-type D3D11 device (the earlier WARP-fallback worry was wrong, and no core change is needed). -
The
github-pagesenvironment needs av*TAG deployment policy — a repository setting, recorded so it survives. GitHub auto-creates the environment with “deployment branches: main only”; a tag-triggered deploy (deploy-pagesfrom the releasewebjob) is refused with “Tag … is not allowed to deploy to github-pages due to environment protection rules”. Added through the API (POST /environments/github-pages/deployment-branch- policies {name: "v*", type: "tag"}) alongside the existingmainbranch rule; a fresh fork or a re-created environment needs it again. Thewebjob also runsactions/configure-pageswith enablement on, so enabling Pages itself is no longer a human step; the custom domain and its DNS CNAME still are (#22).
Step 28 — iOS release procedure (manual by design)
Section titled “Step 28 — iOS release procedure (manual by design)”-
The iPad app’s version comes from the tag through xcodegen’s environment substitution;
ios/prepare_release.shis the one entry point.project.ymlreadsMARKETING_VERSION,CURRENT_PROJECT_VERSIONandSumiBuildDescribefrom the environment; the script derives them fromgit describe --tags --always --dirtyexactly as the desktop build and the spine do (#3), rebuildslibsumi.a/libhostmpe.afor iOS withSUMI_APP_VERSIONinjected (the app links the PREBUILT archives — a stale core has shipped to the iPad before), then runs xcodegen. Two App Store Connect facts shape the mapping:CFBundleShortVersionStringmust be numericX.Y.Z, so a release-candidate tag’s-rc.Ncannot appear in it — it lives inSumiBuildDescribeand in the About sheet; and build numbers must strictly increase per version, soCFBundleVersionis the commit count of HEAD (monotonic onmain, unique per commit, no file to edit). The settings sheet gained an About section showingmidi-sink X.Y.Z (build) · describeandlibsumi a.b.c— the DONE check “installed build is the tag” is read there.ITSAppUsesNonExemptEncryption = falsein the plist (CoreMIDI/BLE/RTP-MIDI, no custom cryptography) answers the export-compliance prompt once for every upload. -
No iOS job in Actions at all — not a lane, not a compile check. The roadmap’s Step 28 text asks PR CI to keep “a build-only compile check for iOS”; the author decided otherwise during the step (“the iOS build is only made locally, never in an action”), so
build.ymlcarries no iOS (or Android) job — FLAGGED as a deliberate deviation from ROADMAP_4. What guards the tag instead is the procedure itself:ios/prepare_release.sh(core rebuilt with the tag’s version, project regenerated, values echoed for a human to read) before every archive, and the About check on the iPad after. The procedure isios/RELEASING.md(checkout tag → prepare → Archive → Distribute/Upload with Xcode’s build-number management OFF → ASC TestFlight internal, then external for the beta wave → About on the iPad reads the tag); the listing text, URLs (the frozen Step-26 pages), review notes and screenshot plan are staged inios/metadata/. Screenshots are author input from real sessions.
Step 29 — Windows release lane
Section titled “Step 29 — Windows release lane”-
The installer is Inno Setup, hand-authored, per-user. The roadmap left the technology open (Inno and NSIS are both on the runners; the siblings’ NSIS is Tauri-GENERATED, so it is no precedent for hand-authoring).
packaging/windows/midi-sink.issis ~90 readable lines: stable AppId GUID,PrivilegesRequired=lowest(installs to%LOCALAPPDATA%\Programs\midi-sink— no UAC, the scope winget expects for user packages), Start-menu entry, uninstaller + registry record, optional desktop icon (unchecked). Settings survive uninstall unless the user opts in: the uninstaller asks once, interactively only — a silent/winget uninstall never touches%APPDATA%\midi-sink. Verified end-to-end locally: silent install → installed--version→ silent uninstall → app and Start-menu gone, settings intact. -
Windows builds use the STATIC CRT, and the exe carries VERSIONINFO from the tag. Found packaging the first installer: /MD linked MSVCP140/VCRUNTIME140, which a clean Windows VM does not have — and a per-user/no-UAC installer cannot install the redistributable, so the DONE check (“runs on a clean VM”) would fail by construction.
CMAKE_MSVC_RUNTIME_LIBRARY = MultiThreadedmakes the exe self-contained (dumpbin: system DLLs only; +~0.9 MB); the whole tree links statically, so one flag covers it.desktop/midi-sink.rcgained a VERSIONINFO block (Explorer Properties / SmartScreen / winget metadata) fed fromSUMI_APP_VERSION— the numericX,Y,Z,0comes from the semver part the root CMakeLists already extracts, so no number is hand-edited (#3). -
Windows signing consumes
WINDOWS_CERTIFICATE(base64 .pfx) +WINDOWS_CERTIFICATE_PASSWORD— proposed names, FLAGGED for the author. The handoff says a certificate was never mentioned, so the lane is unsigned-first per the spec: without the secrets it prints a notice and ships, and the SmartScreen consequence (“More info → Run anyway”, once per new version) is documented in the README and the install page, not fought. With the secrets, signtool signs midi-sink.exe (before ISCC) and the setup exe (after), RFC-3161 timestamped, and verifies both. The names mirrorAPPLE_CERTIFICATE; if the author’s cert lands under other names, only thewindowsjob’s env block changes. -
winget: pre-release tags are SKIPPED by
publish-winget.yml; RCs are tested from an in-tree manifest instead. winget-pkgs takes releases only, and unlike the cask flow (a PR that can be tested and closed) there is no safe pre-release path. The conventions live inpackaging/windows/winget/Vibetuned.MidiSink/(validated:winget validatepasses) withstage.ps1to fill version + sha256 from any PUBLISHED tag, so the RC DONE check iswinget install --manifest packaging\windows\winget\Vibetuned.MidiSink. Two facts recorded so they are not re-learned: winget parses EVERY file in the directory it is pointed at (a README beside the manifests breaks validation — hence the subdirectory), and the first winget-pkgs submission stays human (wingetcreate new, moderation wants a human on the initial PR) — until it merges the workflow detects the missing manifest and skips. -
HiDPI on Windows was real and is fixed in the shared settings window (the step owns Windows-motivated fixes; the change is a no-op on the other platforms by construction). Step 23’s
scale_was captured but never applied, so at 125 % the window rendered 560×760 physical pixels with ~13 px fonts. The fix uses the backend’s own helper (ImGui_ImplGlfw_GetContentScaleForMonitor— returns 1.0 on Apple, where Retina lives in FramebufferScale, and on Wayland, where the compositor scales): window created at 560×760 × scale,style.ScaleAllSizes(scale),FontScaleMain = 1.15 × scale. Verified at 125 %: 700×950 window, crisp correctly-sized text. macOS/Linux lanes should re-verify visually but the helper’s platform table is exactly why it was chosen. Also fixed on the way: the first-run log line carried a double-encoded em-dash (mojibake in a cp1252 console) — ASCII now, matching #9c’s ASCII-UI rule.
Step 30 — Linux release lane
Section titled “Step 30 — Linux release lane”-
The Linux package is a CPack DEB of the
desktop-integrationcomponent, built onubuntu-22.04; the Debian version maps-to~while the file name keeps the tag.cmake/LinuxPackaging.cmake(included by the root list on Linux only) setsCPACK_DEB_COMPONENT_INSTALLwithALL_COMPONENTS_IN_ONEgrouping so the package carries exactly the ten files Step 14 installs — the binary, the absolute-Exec.desktop, seven hicolor icons, andcopyright— and none of the ~190 GLFW / libremidi headers a monolithiccpackswept in on the first try. The Step-14 install rules were made DESTDIR-safe (the generated.desktopgoes under$ENV{DESTDIR}, the icon-cache/desktop-database refresh is skipped when staging; dpkg’s file triggers do that job on a real install, so there are no maintainer scripts). Version:0.5.0-rc.5becomes package version0.5.0~rc.5(tilde sorts before the release, soaptupgrades an RC to the release and never the reverse), the asset staysmidi-sink_0.5.0-rc.5_amd64.debper the LANE INTERFACE. Dependencies: shlibdeps sees only what is linked (libc6, libgcc-s1, libstdc++6, libopengl0) — GLFW and libremididlopenX11, Wayland, EGL, xkbcommon, libdecor and ALSA at run time, so those are listed by hand (libasound2t64 | libasound2covers 24.04’s t64 rename and 22.04). Runner:ubuntu-22.04for glibc 2.34 reach (a 24.04 build needs 2.38); 22.04’s own CMake 3.22 and gcc 11 are too old for this tree, so the lane takes CMake from Kitware’s jammy repository andgcc-12(probed in aubuntu:22.04container: builds,--versionprints the injected tag). The lane also shipsmidi-sink-<v>-linux-x64.tar.gz— the bare binary, LICENSE and a README.txt — as the portable-zip sibling; it then installs the deb into a clean 22.04 container, runs--version, checks the.desktop/icon and removes it, before anything is attached. FLAGGED: the handoff states the local RTX gate “holds bitwise”; measured here on GL 4.1 it is mean 6.85e-6 / max 3.9e-3 — green at the reference tier (1e-2 / 1e-4 on the mean), not bitwise. Nothing was loosened. -
The apt repository is rebuilt from every published release by
publish-apt.ymlonrelease: published, with astablesuite for releases and anrcsuite for pre-releases. The Pages tree is produced at tag time by thewebjob, when the draft has no public asset URLs, so the repository cannot be composed there.publish-aptchecks out the tag, rebuildssite/withSITE_VERSION= the tag, unpacks the release’smidi-sink-<v>-web.tar.gzunderpages/marble/(no emsdk), downloads the*_amd64.debof every non-draft release intopool/<suite>/, writesPackages(.gz)/Release/Release.gpg/InReleaseper suite (apt-ftparchive,gpgwithAPT_GPG_PRIVATE_KEY), exports the public key asapt/midi-sink.asc, and redeploys Pages (concurrency: pages, the environment already allowsv*tags, #36). Without the secret every step prints a notice and the job ends green, like the siblings. Two departures from battuta’s pattern, recorded: the suite split (a user onstable mainnever receives an RC; testers addrc main), and rebuilding from ALL releases each time, so the pool is the release list and an accidental double publish is idempotent. Users install with a keyring under/etc/apt/keyrings/midi-sink.ascanddeb [signed-by=…] https://midi-sink.vibetuned.com/apt stable main(install page + README). Verified locally end to end with a throwaway key: the same commands produce a repository a cleanubuntu:24.04container adds,apt updates without a signature warning and installsmidi-sinkfrom (docs/evidence/step30/aptlocal.log). -
Flatpak: spike, not a channel.
packaging/linux/flatpak/ com.vibetuned.midi-sink.ymlis the manifest (freedesktop 24.08 runtime, cmake-ninja module,--component desktop-integrationinstall into/app,rename-desktop-file/rename-iconfor the reverse-DNS id). What the spike established before building: (a) there is no portal for/dev/snd— ALSA raw/sequencer MIDI inside the sandbox needs--device=all, which surrenders the device isolation that is flatpak’s point, and PipeWire’s MIDI bridge is not a substitute for an app that opens ALSA sequencer ports through libremidi; (b) the FetchContent dependencies need--share=networkat build time, which Flathub forbids (they would have to become vendored sources); (c).desktopand icon names must be renamed to the app id, so a flatpak install and the deb would present two different desktop entries. Verdict: closed — the deb, the apt repository and the tarball are the Linux channels; no publish hook. The spike was then BUILT and RUN (the author installedflatpak-builder): with--share=networkat build time the module compiles against the freedesktop 24.08 SDK (no-debuginfobecause the box lacks elfutils), installs ascom.vibetuned.midi-sinkwith the renamed desktop entry and icon,--versionprints the injected version, and the app renders on the host’s NVIDIA GL through--device=dri. ALSA: with--device=allthe sandboxed app opens every sequencer port the host has (Midi Through and the tablet’s USB port at the time of the run — the ROLI had been unplugged after the capture); with--nodevice=allit opens none — (a) confirmed, the verdict stands. The manifest stays in the tree as the record; nothing publishes it.
Step 31 — Android release procedure (manual by design)
Section titled “Step 31 — Android release procedure (manual by design)”-
Android versions come from git in
build.gradle.kts; there is no Android CI job (author’s decision, confirmed, mirroring #38).versionName= the numericX.Y.Zofgit describe --tags(Play wants a plain string, as ASC does),versionCode=git rev-list --count HEAD(monotonic, unique per commit — Play requires strictly increasing codes across tracks), the full describe goes intoBuildConfig.BUILD_DESCRIBEand into-DSUMI_APP_VERSIONfor the core;SUMI_APP_VERSIONin the environment overrides the describe. The settings sheet’s About row readsmidi-sink X.Y.Z (versionCode) · describeandlibsumi a.b.c(a new JNInativeCoreVersionreturnssumi_version()), the on-device DONE check.android/prepare_release.shprints the triple, warns on a dirty tree or off-tag checkout, and exits non-zero if Gradle’s values differ from git’s. Unlike iOS there is no stale-archive trap: Gradle compiles the core from source on every build. FLAGGED against ROADMAP_4 Step 31’s “PR CI keeps a build-only compile check for Android”: asked, the author chose “no Android job, like iOS” —build.yml’s comment already states it; the tablet apps are built from a tagged checkout in Android Studio only.android/RELEASING.mdis the checklist (signed bundle with the author’s upload keystore → Play internal track → About on the Galaxy Tab → USB-MIDI to this box);android/metadata/holds the listing, Data safety (nothing collected or shared), the frozen privacy/support URLs and the screenshot plan. -
Android paper dip = two buttons in the settings sheet, and a saved print is a PNG in the user’s gallery (
Pictures/midi-sink, MediaStore). Author’s request during Step 31: the tablet hadnativeTriggerDipbut no button. “Paper dip — save the print” dips, waits for the core’s async readback (§5.3) on the render thread, hands the RGBA print to Kotlin as ARGB and writes it throughMediaStore.Images(no storage permission on API 29+, our minSdk;IS_PENDINGuntil the PNG is complete); “Paper dip — discard (fresh sheet, no print)” dips and frees the buffer. Both READ the print: the core keeps two print buffers andsumi_trigger_paper_dipREFUSES while both are busy, and onlysumi_read_printfrees one — so a dip nobody reads leaks a buffer, and the third dip is silently refused (a warning in the log, no visible effect). The Android shell therefore drains unread prints before every dip. FLAGGED for the iOS owner: the iPad’s “Paper dip (fresh sheet)” (SumiApp.swift, #67) never reads the print, so it stops working after two dips per launch — same fix (read-and-drop after the readback lands) or the same two buttons. Not a core change: the core’s contract is as specified (§5.3 double-buffered prints, host consumes); the shells had not been consuming.
Step 33 — Feedback incorporation (first batch, while the beta runs)
Section titled “Step 33 — Feedback incorporation (first batch, while the beta runs)”-
The two pressure operators became Marble-mode gestures through two additive enum values, not new functions.
sumi_add_vortexgained the profileSUMI_VORTEX_LAMB_OSEEN(2): it pushes the §4.3(7) swirl pass the voice mapper already emits, the host supplyingstrength= Γ·Δt (signed) andradius= r_c.sumi_add_dropgained the layerSUMI_DROP_FEED(2): the drop shader’s interior branch copies the CENTRE texel (band, aux and pre-image) instead of writing a new phase, so a pass of radius sqrt((R+ΔR)² − R²) widens the band already there — the §3.4/§4.4 boundary growth with the host tracking R; the drop counter is untouched. ABIsumi_version()0.5.0 → 0.6.0, additive (the struct is unchanged; a host passing layer 2 before this got a clear drop). The §4.6 field script does not use either value, so every fixture stands; the regression test for the new passes is the desktop--pressure-test. The gesture (identical constants on desktop, web, iOS, Android): a long press (250 ms, no travel; Shift + right button with a mouse) lays an ink drop and becomes Play mode’s bipolar Y axis without a note — hold or push UP = feed (ΔR = 0.12·(0.35 + up)·dt canvas heights/s, so a still hold grows slowly and a push fast), pull DOWN = swirl (Γ·Δt = 3.0·down·dt·2π·R², r_c = R, i.e. 3 rad/s of core rotation at full pull; feeding pauses while stirring); travel 0.15 canvas heights for full effect. The drop stays where it was pressed; the finger only modulates. Shift + LEFT drag stays the pinch (the user’s first suggestion clashed with it, #30). Android is written to the same design but not compiled on this machine — the Linux box verifies it. Spec flag: PROJECT_SPEC §5.3 carries the header verbatim and §8.1 lists the Marble gestures; both are stale until the author folds. -
The Airwave default CC map is the measured one. The capture (
docs/evidence/airwave-mapping) showed twelve CCs 20–31 in left/right pairs — Grasp 20/21, Slide 22/23, Glide 24/25, Raise 26/27, Tilt 28/29, Flex 30/31 — against a default table that imagined 20 = Raise, 21/22 = Glide, 23 = Tilt, 24/25 = Flex and left 26–31 dead. New defaults (install_default_cc_map, the desktop mirror, README, the devices page, the chart): left hand = the water — 26 Raise L → vortex strength, 24 Glide L → centre X, 22 Slide L → centre Y, 30 Flex L → paper roughness, 28 Tilt L → ripple wavelength; right hand = the material and the waves — 29 Tilt R → viscosity, 31 Flex R → palette morph, 27 Raise R → ripple amount; 20/21 Grasp, 23 Slide R, 25 Glide R free for the editor.INK_FLOWwas not given an Airwave CC: it only acts in wind mode. This is a taste assignment (Step 33: UX-feel needs the author’s sign-off) — every row is a one-line remap in the settings window if the author prefers another hand. The normalizer goldens that pinned CC 20/21/23 as defaults moved to 26/24/29, and the “unmapped” examples to 20/21. -
A dip never silently fails: the core recycles the older unread print.
sumi_read_printis a SYNCHRONOUS copy, so no host ever holds a core buffer between calls; the only unsafe overwrite is a readback still in flight.dip_ready()therefore refuses only whilepending_idx >= 0(a few frames), andsnapshot_printreuses the lower-buf_seqREADY buffer when both are unread (INFO log). This is the core-side fix for #48’s finding (iOS’s dip stopped after two per launch; desktop and web had the same latent stall, and the web page’s Replay button dips on every replay — the third replay would have been refused) and it needs no host drain. iOS gained Android’s two buttons — save the print (RGBA8 → CGImage →UIImageWriteToSavedPhotosAlbum, waited for on the display link;NSPhotoLibraryAddUsageDescriptionadded) and discard. The header comment forsumi_trigger_paper_dipstates the new contract; the mapper’sdip_allowedpath is unchanged and its test still holds. -
Jaffer’s three remaining patterns, read from the papers, and where each belongs (design, not yet built). Oseen Flow in Paint Marbling: the stroke’s velocity field is closed-form — F_x = U(rL − y²)/(rL·e^{r/L}), F_y = U·xy/(rL·e^{r/L}) in the stroke frame (L = the viscous length, U the speed) — but Jaffer shows the DISPLACEMENT is not (§6–7: “unlikely… to be expressible in closed form”); he applies the velocity field in ⌈λ/L⌉ Euler steps and accepts imperfect reversibility (his Fig. 13). Plan: a sub-stepped pass
P_src = P − d·F̂(P)per step with d ≤ L/2 (our wake’s discipline), the viscous sibling of the inviscid wake: bands compressed ahead, spread perpendicular, the trailing V. Gesture: the pen with a “viscous” tip setting, or a second stylus mode. Honest label: exact velocity field, approximate map — the first operator in the book that is not exact. Pigment Transport: the Spanish wave is a TRANSFER-TIME mapping, not a paint deformation — the paper moves sinusoidally while being laid: parallel term f(a) = a + (A/2)·sin(2πa/λ) (monotonic iff |πA/λ| < 1, inverted by his power-law seed g(a) + one Newton step, eqs 5–6), perpendicular term −(B/2)·cos(2πa/λ), and the tint: pigment thins as 1/f’(a), colour^γ with γ = f’(a) (eq 7) — which is the 3-D shading that makes the pattern. Our live ripple IS the B term without shading; the Spanish wave therefore extends the composite/print path: parallel A, the γ shading, and — by physics — it BELONGS in the print (the paper moving while touching the water), unlike the live ripple. Turkish moiré = Spanish wave with curved shading contours and the tint reversed for dark paper (eq 9, two branches by paper vs paint tone); it needs the paper-tone parameter and a curveda. Drop shading (eq 8): pigment thickness exp(−ζa²/r²) normalised — our ink channel carries the radial coordinate, so it is a cheap composite term with one ζ. Order proposed: (1) Spanish wave + tint in the composite (print-time, parameters A/B/λ/θ/Ω + shading on/off), (2) drop shading ζ, (3) Turkish moiré as the dark-paper branch with a curved axis, (4) the Oseen stroke as a new sub-stepped pass. Each is a core change under Step 33’s unfreeze with its own regression fixture; the §4.6 script grows only for (4) (the others do not touch the field). -
The viscous stylus stroke is the 2-D unsteady Stokeslet displacement of an impulse spread over the tip — closed form, verified, ABI 0.7.0. The author proposed the impulsive point force in unsteady Oseen/Stokes flow as an exact operator (Galilean reduction to the comoving frame; displacement = time integral of the Green’s tensor). Three corrections shaped it: (a) the layer is 2-D, so the kernel is the 2-D one (E₁ and Gaussians, not the 3-D erf/r form); (b) a point impulse has infinite displacement at the point (log-singular in 2-D), so the force is a Gaussian blob of the tip radius a — done exactly as the difference of two point kernels, at diffusion times t+t₀ and t₀ with a² = 4νt₀, which keeps the field exactly divergence-free; (c) it is the LINEARIZED (Eulerian) displacement, area- preserving to first order per pass, hence sub-stepped like the wake — not an exact homeomorphism, and neither is Jaffer’s Oseen stroke, which he iterates. Derivation: ψ = (F/ρ)·y·(1−e^{−s})/(2πr²), s = r²/4ντ; u = (∂_yψ, −∂_xψ); ∫₀ᵗ u dτ with S = r²/4νt gives, for D₀ = F/(ρν), d_x = (D₀/8π)[χ(S)+E₁(S)] − (D₀/4π)(y²/r²)χ(S), d_y = +(D₀/4π)(xy/r²)χ(S), χ = (1−e^{−S})/S. Blob: S₀ = r²/a², S₁ = r²/ℓ², ℓ² = a²+4νt; normalising the centre displacement to the tip’s motion d gives D₀ = 4πd/ln(ℓ/a) and the kernel in the header comment of
sumi_deform_stokeslet_t. Verified (docs/evidence/step33/stokeslet_verify.py): E₁ to 1e-10; closed form = numerical ∫u dτ in both components at three points (the first draft had d_y’s sign wrong — caught by exactly this check); blob kernel divergence 1e-11; mirror-symmetric; d(0) = d; far field d/ln(ℓ/a)·(ℓ²−a²)/(2r²) — a doublet tail; max|∇d| per (d/a) = 0.98 at ℓ/a = 1.5 falling to 0.42 at 8, so the wake’s d ≤ a/4 sub-step keeps |∇d| ≤ 0.25 for every ℓ/a ≥ 1.5 (fold-free needs d < 1.0 a at worst). Core:SUMI_DEFORM_STOKESLETpass (stokeslet_fs: E₁ by series below 1 with the two logs cancelled analytically near the centre, A&S 5.1.56 rational above; χ by series below 1e-3),sumi_add_wakepicks it whenparams.wake_profile == 1,params.wake_spread= ℓ/a clamped [1.5, 12] (default 3); the struct grew →sumi_version()0.7.0, hosts rebuild. The §4.6 script is untouched (fixtures stand); the pass has its own--stokeslet-test: tip texel moves by d (0.0100 = d), mirror to a half-float ULP (the two rows sit in different exponent bands), one a/4 pass has pre-image det ≥ 0.75 and mean 1.00012, a 10a stroke in one call is fold-free outside the swept corridor (inside it, bilinear resampling of the strongly compressed pre-image aliases and finite differences stop measuring the map — the same exclusion the wake’s flick test makes). Picture: bands ahead of the tip compress into a point and spread perpendicular, a sharp V trails — Jaffer’s tank observation (Oseen §4), where the doublet threads rings around a rigid hole. Surfaced as “Stylus wake: inviscid doublet / viscous stroke” + spread on desktop, the web panel and the iOS sheet; the Android bridge exists, its sheet row is the Linux box’s. The docs’viscousscene exposes a, ℓ/a, d. Supersedes the Oseen-field plan in #52; the Spanish wave stays deferred (print-time UI). Next, agreed in principle: the Airwave right hand as a hand in the water — Glide R / Slide R its position, its motion delivering these impulses (force = hand velocity, so a still hand does nothing), Grasp the delta-driven pinch there — needs global dimensions for a moving force point and a pinch delta; not in this batch. -
The stylus draws its wake in Marble mode too — it never did on the tablets. Spec §8.7: “the dipolar wake rides every stroke segment in both modes”. Both shells implemented the pen only inside the Play overlay, which Marble mode hides and makes interaction-inert, so a Pencil or S-Pen in Marble mode fell through to the finger handlers: a tine on drag, a drop on tap, and since #49 a pressure long press. Fix, host-side on both: iOS — the Marble recognizers (tap, pan, twist, pinch, long press) accept DIRECT touches only (
allowedTouchTypes), andSumiCanvasViewhandles.penciltouches itself in Marble mode withsumi_add_wakeand the overlay’s tip mapping (a = 0.006 + 0.030·force, force in UIKit units clamped like the overlay); Android —SumiSurfaceView.onTouchEventroutesTOOL_TYPE_STYLUS/_ERASERpointers tonativeAddWakewith the overlay’s0.006 + 0.030·pressure, cancels the long-press timer, and lets fingers keep their path. No hostmpe, no MIDI: the wake is physical (§8.7). Desktop (middle-drag) and web (pointerType ‘pen’) already did this. The Android side is written, not compiled here (handoff). -
iOS shows its MIDI inputs, and rescans at 1 Hz as well as on notification. Feedback: “the iPad sends MIDI but does not receive it from USB”. The code connects every CoreMIDI source (wired, IDAM host, network, Bluetooth) to one input port and pushes every MIDI 1.0 word to
sumi_push_midi, so the failure is somewhere the shell could not show: CoreMIDI never listing the device, the connect failing silently, no bytes arriving, or the bytes being consumed by the #66 echo filter. Fix, host-only: the Settings “MIDI” section gains the desktop’s inputs list — every connected source by display name, a live “received N messages · last SS D1 D2” counter, a “skipped” count when CoreMIDI lists more sources than we connected, and “Rescan now”; everyMIDIPortConnectSourcestatus is logged with the driver owner (the MidiOutputs rule). Hotplug also polls at 1 Hz like the desktop harness (DECISIONS #25): CoreMIDI delivers setup notifications on the run loop current at the process’s FIRSTMIDIClientCreate, and a notification path is a single point of failure for a device plugged in after launch. The iPad is a USB HOST: a class-compliant controller on the USB-C port appears as a source and needs no entitlement; a Mac tethered over USB appears as the IDAM host source, and what the Mac sends to its “iPad” port arrives there. Android is not symmetric: with the system USB mode flipped to MIDI (the gadget, §5.4(b) primary sink) the tablet is a peripheral and cannot host a controller at the same time; the peripheral port is bidirectional andMidiInputsopens it, so a host’s stream does reach the canvas. Spec §5.4 iOS list (“forwards CoreMIDI packets”) is unchanged; not a core change. -
The same settings on every platform. Feedback from both tablets: “we cannot change the colour”, and other rows missing. PHASE5_SPEC (Step 23) named the iOS sheet the contents spec including palettes and the CC map editor, yet the sheet never had them — the desktop window (#2) and the web panel (#21) grew past it. Rule from here: the desktop settings window (
desktop/src/settings_ui.cpp) is the contents spec; every shell shows every row where the platform has the concept. Added to iOS and Android: Layout & look — palette (Sumi black / Indigo / Ochre), viscosity, ink feed, paper roughness, and tempo + roll speed on the two piano rolls, the desktop’s ranges; Vortex profile (Exponential / Rankine), which the two-finger twist now stirs with too, as the desktop’s right drag does (both shells had EXPONENTIAL hard-wired; the guide’s “Rankine by default” line was wrong and is corrected); Stylus wake row on Android (the iOS row shipped with #53); Ripple — amount and wavelength sent as the routed CCs (102/103 by default) through the MIDI path exactly like the desktop’s sliders (iOS: on the serial midiQueue as source 2; Android: aplay_send_cchop onto the MIDI thread, logged as session config, loopback only, and replayed at instance creation becausepush_mididrops without an instance), plus the frame angle; CC map — the desktop’s table (any CC, channel or “any”, nine dimensions; add, remove, restore defaults), persisted asch:cc:target;…in UserDefaults / SharedPreferences with "" = the default map (the core’sinstall_default_cc_map+ the 102/103 handles, the desktop’sapp_settings_default_routesverbatim); restoring re-maps that list explicitly sincesumi_clear_cc_mapdoes not reinstall the core’s map. Android’s Compose is foundation-only (no Material), so continuous values are «‹ value ›» step rows at the desktop sliders’ useful grain rather than sliders. Deliberately NOT mirrored: the web panel’s CC map (#21: Web MIDI hands the browser its ports) and Android’s full-resolution toggle (the thermal listener owns sim_scale, DECISIONS #31). Host-only; the core is untouched. Android is written, not compiled here (handoff). -
--window <w>x<h>is a public desktop flag. Support needs to reproduce reports “only at this resolution”; the window was hard-coded to 1280×720 and no lab-bench flag changed it. Accepted without--dev(a user is asked to type it, like--version), in screen points, clamped to 320×240 … 16384×16384; the window stays resizable and the framebuffer is scale × size on HiDPI. Host-only. -
Desktop gets “Hide the title bar” and “Fullscreen” (Settings › Window), with
--no-titlebar/--fullscreenflags and the platform fullscreen chord. The canvas is a GLFW window, so its title bar is GLFW’s decoration — there is no SwiftUI WindowGroup or hand-built NSWindow to style. Two mechanisms, chosen per platform for what a user means by “no title bar”: macOS gets SwiftUI’s.hiddenTitleBarlook through the existing Cocoa glue (NSWindowStyleMaskFullSizeContentView+ transparent, title-less title bar: traffic lights, top-strip dragging and edge resizing all survive —metal_layer_glue.mmis the one Objective-C file and already holds the NSWindow); Windows and Linux flipGLFW_DECORATEDat runtime (borderless, so no frame to drag or size by —--windowand fullscreen cover placement, and Ctrl , still opens the settings, which is why the settings window keeps its own frame). Fullscreen isglfwSetWindowMonitoron the monitor holding the canvas’s centre (work-area test), at that monitor’s current video mode, with the windowed geometry remembered and restored on the way back; the framebuffer callback resizes the core as for any resize, so the field survives the switch. Chord: Control+Command+F on macOS, F11 elsewhere — the second key binding a release build keeps beside the settings chord. Both flags SET the persisted settings (a user who launches--fullscreenonce expects the next launch to match the checkbox they see). Verified on the Mac:--fullscreenfills the LG ULTRAGEAR+ at 3008×1269 with the windowed 1280×720 restored on exit;--no-titlebarpersists and reloads. Host-only; the core is untouched. -
No title bar is the default, not a setting. Supersedes the “Hide the title bar” checkbox, INI key and
--no-titlebarflag of #58 (user: “the no titlebar should be the default not an option”). The canvas always opens without one: macOS through the #58 Cocoa path at window creation (edge to edge, traffic lights kept, drags by its top strip, resizes at its edges); Windows and Linux withGLFW_DECORATEDoff as a creation hint — a borderless window with no frame to drag or size by, moved with the Win / Super + arrow keys and sized with--windowor fullscreen. The settings window keeps its frame, and Ctrl , / ⌘ , still opens it, so nothing becomes unreachable. Fullscreen (#58) stays a setting with its flag and chord. An oldhide_titlebar=key in settings.ini is ignored. Flagged for the Windows and Linux verification: if a borderless canvas turns out unmanageable on a desktop there, the fallback is a per-platform default, not a setting. -
The input dialect is a setting — MPE by default — and MPE / Wind each absorb what made people ask for the other two. Feedback: the §2.5 auto-detection should be a parameter (“MPE as default, classic or wind as option”), users see no difference in Classic, and Wind should accept MPE because IMU-equipped wind controllers add an expression layer. Shipped, all four shells: an Input row (MPE · Classic keyboard · Wind), persisted (INI
input_mode, localStorage, UserDefaults, SharedPreferences), applied withsumi_set_input_mode; no shell ever selectsSUMI_INPUT_AUTO— the heuristic stays in the core for the ABI and its tests, unused. Two mapper changes (core, Step 33 unfreeze; ABI unchanged, 0.7.1) make the default livable: (a) in MPE mode a note on a NON-member channel — the master, or a plain keyboard sharing the bath — is a classic per-(channel, note) voice, so chords on channel 1 no longer collapse onto one channel voice; member channels keep newest-steals identity, the master bend stays the global shear and CC 64 stays musical (#67 of Part III). Classic therefore remains for two things only: a keyboard sending on a channel inside the member zone (2–16), and the sustain-pedal paper dip. (b) Wind mode reads the expression layer on its single brush (slot 0): CC 74 → slide, poly pressure → swirl, and a bend on a MEMBER channel (an MPE wind controller — Sylphyo-class) → glide with the ±48 member range; a single-channel bend stays the global shear; breath (CC 2/7/11 or channel pressure) keeps owning the width, and legato keeps migrating.memberis now(mpe || wind) && in_zone. Tests:test_mpe_master_channel_keyboard,test_wind_expression_layer(ctest 4/4). Spec §2.5 (“auto-detection … overridable”) is stale: the default is the MPE setting; §2.3/§2.4 gain the layer described here. -
Palette morph travels the whole ring. Feedback: “black to blue only? or can we get ochre?” The composite blended the active palette toward the next only, so CC 31 from Sumi reached Indigo and never Ochre. Now morph 0..1 covers two transitions from the active palette: 0 = active, ½ = the next, 1 = the third (Sumi → Indigo → Ochre; from Indigo: Indigo → Ochre → Sumi). Monotonic (a full sweep ends on the third palette rather than returning home), every palette reachable from one controller, the palette picker still chooses where the ring starts. Shader-only (
composite.glsl); the §4.6 field fixtures are deformation fields and are untouched. Core 0.7.1 together with #60. -
CC 64 never dips the paper. Feedback: “remove the paper dip CC 64, it does not make sense there.” Supersedes §2.4 / DECISIONS_3 #67’s classic-only mapping: the sustain pedal is the synth’s in every input mode, and a fresh sheet is only ever the host’s deliberate action (
sumi_trigger_paper_dip, the settings’ Paper dip). In the mapper CC 64 now falls through to the CC map like any controller (unmapped by default, routable).SUMI_VEV_PAPER_DIPstays as the ABI path’s event; the tests that used CC 64 to reach it build the event directly (test_sustain_never_dipscovers all three modes). Spec §2.4 and §3.3 (“CC64 rising edge (classic mode only)”) are stale. -
Wind mode is MPE plus a wake between notes; the wandering brush is retired. Feedback: the brush “is not visually pleasant … breaks with long plays”, wind should “work exactly as the MPE mode but with a wake from the previous note to the new one”, using only operators, and the breath drops were too weak. Now: one voice (slot 0 whatever the channel), every note a strike drop of the MPE radius; breath (CC 2/7/11 via the INK_FLOW route, or channel pressure) is the UNBOUNDED §4.4 feed like MPE press (the width clamp
WIND_WIDTH_*, the thin touch-down and the migrate tine are gone); channel pressure honourspress_modeas MPE does. On a legato change the mapper emits aVOICE_MIGRATE— now “wake the voice’s drop to (x, y)”: the §4.3.4 wake with the drop’s current radius as the rigid tip (floor 0.006, the tablets’ lightest pen), profile and spread fromwake_profile/wake_spread, sub-stepped ≤ a/4 and capped at 256 steps, mirroringsumi_add_wake; the event’s ax/ay carry the aspect-corrected displacement because the lowering has no aspect and every echo of a note moves alike — then a silentVOICE_ENDand the new note’sVOICE_BEGIN. So a phrase is a chain of drops threaded by their wakes: nothing but operators, no per-mode geometry. Tests:test_wind_mode_wake_legato(radius, ≤ a/4 sub-steps summing to the move, tip = old radius, new band, unbounded growth past 0.056, press_mode),test_wind_expression_layerupdated. Core 0.7.2 (ABI unchanged). Spec §2.3 (“wandering ink brush”, “width”) and §4.4 (“Wind mode is the exception to unbounded growth”) are stale; the operators’ index lists the wind legato under Wake, not Tine. -
Two more piano rolls: from the right and from the bottom. User request.
sumi_layout_tgrows additively —SUMI_LAYOUT_ROLL_H_RIGHT = 6(pitch → y, now-line at x = 0.88, the sheet drifts −x) andSUMI_LAYOUT_ROLL_V_BOTTOM = 7(pitch → x, now-line at y = 0.88, the sheet rises) — the mirrors of 3 and 4, same inset, same speed, the field motion always away from the now-line; the shader’s ingress branch is direction-free so nothing else changes. The semitone axis comes from the generic neighbour rule. Names everywhere become “Piano roll (left / top / right / bottom)” — the now-line’s edge, which is what a performer asks for. ABI 0.8.0 (enum addition only; the params struct is unchanged, so 0.7 hosts load fine and the core falls back to fifths for ids it does not know, as before). Golden positions + a drift-direction test for all four. Spec §3.4’s layout list is stale (six → eight). -
“(playable)” on the tablets’ layout names. Chromatic grid, Jankó and Piano grid read “(playable)” in the iOS and Android pickers — the three lattices Play mode accepts, which the Mode row’s footnote used to be the only hint of. Not on desktop or the web: they have no Play mode, and the label would promise one. The guide’s layouts page already marks them.
-
The bend-driven ripple is four times more sensitive. Feedback: the ripple “is too weak with the MPE input; 3 to 4 times stronger”. The v0.4 law saturated the amplitude at |±6| semitones, so a real ±0.5-semitone vibrato breathed ~8%. Now |±1.5| saturates (a ±0.5 vibrato breathes a third); the ripple’s ceiling (
SUMI_RIPPLE_AMP_MAX, shared with the CC 102 slider) is unchanged, so this is the MPE path only. The stilling property (A → 0 when the note re-centres) is a group property and survives;--ripple-group-test/--ripple-permanence-testunaffected.
Step 33 — Windows verification
Section titled “Step 33 — Windows verification”-
The Windows canvas keeps its title bar — #59’s per-platform fallback, taken with the concrete failure it asked for. Measured on the Windows 11 box: a
GLFW_DECORATED-off window carries neitherWS_CAPTION(no mouse drag, no Alt+Space → Move) norWS_THICKFRAME, and Windows Snap requires the latter — Win + arrow keys do NOTHING (verified with a framed control window snapping fine in the same session), so #59’s “moved with the Win + arrow keys” assumption does not hold on Windows. What still worked: Win+Shift+arrows (whole-monitor hops), taskbar minimize/restore, Alt+F4, Ctrl , and fullscreen — but a window that can never be freely placed or snapped is unmanageable on a desktop. The creation hint is now Linux-only; Windows creates the canvas decorated (drag, snap, Alt+Space, minimize buttons all back — Win+Right verified snapping after the change). macOS (#59’s Cocoa path) and Linux are untouched; README and guide/desktop.md state the difference. A borderless- with-snap window is possible on Win32 (keep WS_THICKFRAME, subclass the wndproc for WM_NCCALCSIZE) but is a hack UNDER GLFW’s own style tracking — declined for a settings-window product; revisit only if the author wants the borderless look back on Windows. -
Layouts 6 and 7 did not survive an INI reload — the settings loader still clamped
layout % 6(fixed to% 8). Found by the Step-33 checklist item 5:layout=6(Piano roll, right) reloaded as 0 (fifths) andlayout=7(bottom) as 1 (chromatic grid) on EVERY desktop platform — batch 10 (#64) grew the picker and the core enum but missed the one modulus inapp_settings.cpp. The Mac verified 6/7 in-session through the picker, which never crosses the loader; a restart was the missing test. Shell-only, one constant; after the fix both rolls reload and their drift away from the now-line is the Step-33 Windows evidence (roll_layout_6/7.png). The macOS and Linux lanes inherit the fix on their next pull — nothing platform-specific in it. -
The Airwave map is symmetric hands, by the author’s direction after playing it (“they are hard to use”): each hand stirs its own water, ABI 0.9.0. The #50 layout scattered unrelated dimensions across the hands; the author’s redesign makes both hands the same instrument — Raise = strength, Glide = centre X, Slide = centre Y — the left driving the exponential/Rankine vortex, the right a new Lamb-Oseen swirl control (
SUMI_CTL_SWIRL_STRENGTH/X/Y: the same dt-scaled agitation as the vortex, emitting the §4.3(7) swirl pass at the #49 gesture’s full-pull rate, core r_c 0.15). Both centre-Y dims are REVERSED at emit (CC up = up on screen — texture y grows down, and a raised hand lowering the stir read wrong). Grasp = the pinch (SUMI_CTL_PINCH_SADDLEleft at the vortex centre,SUMI_CTL_PINCH_CROSSright at the swirl centre), delta-driven exactly like the CC 74 route so a squeeze-and-release nets out and the un-retraced residue bakes in. Tilt = the ripple (wavelength L 28, amount R 29). Flex 30/31 is deliberately FREE — the author: it cannot be played without activating the others. Viscosity, roughness and palette morph lose their Airwave routes (settings sliders; the editor rebinds them). sumi_ctl_t grew additively (9-13, COUNT 14) → 0.9.0; new mapper test (test_global_ctl_swirl_and_pinches: swirl trio with reversed Y, delta-in/delta-out pinch, crossed pair) plus the updated goldens; suites 18 455 checks green, §4.6 field gate bit-stable (ctl dims never enter the fixture script). Shell mirrors, README, devices/settings pages and the chart JSON updated (chart_check 32/32); iOS/Android name tables + defaults updated but NOT compiled on this box (the standing handoff pattern). Swirl/pinch rates are first-cut constants (SWIRL_CTL_RATE 3 rad/s, PINCH_K_SCALE shared with CC 74) — tune on the author’s report. -
The canvas keeps its title bar on every platform. Supersedes #59’s borderless default and #67’s Windows-only fallback. After the Windows verification the author settled it for all three desktops: people want to move the window around, and Fullscreen (#58: Settings › Window, F11 / ⌃⌘F,
--fullscreen) is what they use for the display. The macOS hidden-title-bar call and the LinuxGLFW_DECORATEDhint are gone; the Cocoa gluesumi_macos_set_titlebar_hiddenstays inmetal_layer_glue.mm, unused, should a per-platform look ever come back. The Linux handoff no longer asks the box to judge a borderless canvas. Host-only; README, guide/desktop.md and the settings reference say so. -
A persisted CC map that is the stock map of an older version follows the redesign. The desktop INI, iOS UserDefaults and Android SharedPreferences persist the CC map whole, so #69’s symmetric-hands layout never reached an installation that had run before it — the Windows evidence INI (
settings_ini_after_tests.ini) still carried the #50 routes after the change, and so did the author’s Mac. Fix on all three shells: on load, a map equal AS A SET to one of the earlier default maps (the pre-#50 imagined numbering; the #50 measured layout) is replaced by today’s defaults; anything that differs is the user’s and is kept untouched. The INI additionally gainsccmap_version=3(absent reads as 1) so a future redesign has a cheap gate; the tablets compare sets on every decode, which is what "" already meant. Logged on the desktop as[settings] CC map was the stock map of an older version - upgraded …. Android written, not compiled here. -
release.shaccepts a single-architecture bundle for a*-localdry run. The README’s documented local command failed on the author’s Mac with “not universal — missing x86_64”: the plainbuild/is arm64 only, and the script hard-required both slices. The requirement is right for a release DMG (the lane configuresCMAKE_OSX_ARCHITECTURES="arm64;x86_64"), wrong for the dry run whose whole point is proving the DMG mechanics on any Mac. Now: universal →…-macos-universal.dmgas before; a*-localversion with one slice → a warning and…-macos-<arch>.dmg; any other version without both slices → the error, with the configure line to fix it. Nothing in the lane changes.
Step 33 — Linux verification
Section titled “Step 33 — Linux verification”- Leaving fullscreen on X11 re-asserts the windowed geometry for a
second — mutter hands it back one title bar lower and shorter.
Checklist item 2 measured, on Ubuntu 25.10’s GNOME (Xwayland session
of the canvas): F11 filled the 5120×2160 monitor holding the canvas and
the INI toggled
fullscreen=live, butglfwSetWindowMonitor(nullptr, x, y, w, h)brought a 1280×720 canvas at y=755 back as 1280×683 at y=792 (through 1280×757 and 1280×720 on the way — the WM re-applies its 37 px frame extents to the client geometry GLFW asks for, then to its own result). macOS and Windows restore exactly (Step 33 evidence); the mechanism in #58 is right, the platform disagrees. Shell fix indesktop/src/main.cpp(settle_window_geometry): after leaving fullscreen, for one second, whenever the window’s size or (X11 only — Wayland has no positions) position differs from the remembered client geometry it is set again; verified twice in a row, exact restore, no fight with the WM once it agrees. Not a core change. Wayland’s F11 round trip could not be driven from a script (no input injection reaches a Wayland surface); the flag path (--fullscreen) resizes the core to the monitor there, and the size-only settle applies.
Step 33 — Android verification (Linux box)
Section titled “Step 33 — Android verification (Linux box)”-
The Android native library is linked for 16 KB pages (
-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON). The first run on a Pixel 9 Pro (Android 17) opened a system warning: “not compatible with 16 KB pages —lib/arm64-v8a/libsumi-shell.so: LOAD segment not aligned”. NDK r27 still emits 4 KB-aligned LOAD segments unless the toolchain switch is set (r28+ defaults to 16 KB);readelf -lWconfirmed0x1000before and0x4000after, and the warning is gone. Google Play requires 16 KB support for anything targeting Android 15+ since November 2025, so this is a release blocker fixed, not a polish. One line inapp/build.gradle.kts(the CMake arguments); the Compose runtime’slibandroidx.graphics.path.sowas already aligned. AGP 9.x packages the libraries uncompressed and page-aligned on its own. Not a core change. -
The Android control strip is a setting, hidden by default on phones, shown by default on tablets. Author’s request on the Pixel 9 Pro: the floating strip (300×86 dp, top-left) covers a fifth of a phone’s lattice in Play mode, “not like tablets”.
showStrip(SharedPreferences) defaults tosmallestScreenWidthDp >= 600; the CONTROL STRIP section gains “Show the control strip” above the sustain-latch row, and the strip’sAndroidViewis GONE unless Play mode is effective AND the setting is on. Hidden, the S-Pen barrel button still holds the pedal (the strip’s sustain engine runs whether the view is attached or not) and the wheels’ CCs keep their last values — the strip is a display of state, not its owner. Verified on the phone: chromatic grid in Play mode comes up without the strip; the toggle shows it (Pitch / Mod / CC 23 / CC 24 / Sus) and hides it again. iOS has no phone target, nothing to mirror. -
Wayland: the canvas and the settings window overlap at launch, and that is accepted (author’s call, option b). GLFW cannot position or focus a Wayland toplevel, so GNOME centres both windows and the settings window — created second — lands over the middle of the canvas; the author’s “clicks do nothing” on the Linux box were clicks landing in the settings window (
docs/evidence/step33, Wayland section: the injected click moved the Ripple Amount slider, the--devmouse log saw no canvas button). The alternative — re-mapping the canvas after launch so it is stacked last — would hide the settings window behind the canvas instead, with no way to raise it from the app (Wayland refuses focus requests as well). Left as is: a click on the exposed canvas raises it, the settings window is one Alt+Tab away, X11/macOS/Windows keep the side-by-side placement.--devnow logs every canvas mouse button. -
Android re-sends the persisted ripple sliders at startup. #56’s design: the ripple amount/wavelength ride the routed CCs through the MIDI path, Kotlin sends them when the rows move, and the JNI replays the last value per CC when the core instance comes up (
cc_replay), becausepush_mididrops without an instance. The replay list is filled only by what Kotlin SENT in the running process — andonCreatenever sent the persisted values, so every cold start showed the sheet’s numbers with none of them in the water (the Pixel’smidi_log.csvafter a relaunch had no CC 29/28 as source 2; the Android Step-33 handoff line “everything persists and the ripple values are re-sent” was the check that caught it). One line:sendRipple()afternativeSetCcMapinonCreate, before the surface exists, so the values land incc_replayand go out with the session config at instance creation. The desktop has no such gap (its INI values are applied byapp_settings_applyon the live instance); iOS is the Mac session’s to re-check for the same pattern. Also from this session, at the author’s request: the CANVAS section (the two paper-dip buttons) leads the Android sheet — “the most used feature” — with LAYOUT & LOOK second; iOS keeps its order until its owner mirrors it. -
Canvas (paper dip, save print) is the first settings section on every platform. The author, closing the app’s Step 33 list: the dip is the control a performer reaches for most, so it heads the desktop window, the web panel and the iOS sheet; Android already had it first (the Linux box, same reasoning). Order only; nothing else moves.
-
The web’s icons are the desktop’s rounded square. The docs site’s favicon, touch icon, header logo and social card, and the marble page (which had no icon at all), were the plain square of
images/midi-sink.jpgwhile every other platform shows the softened corners — the user: “round this, we should do the same for the web”.tools/gen_icons.pygains asitetarget: favicon-32/180 and the 512 logo with the desktop’sCORNER_RADIUS_FRACTIONand alpha corners,web/site/favicon-180.pnglinked from the marble page’s head, andog.pngat the 1200×630 ratio share previews expect with the rounded art composited on the washi cream (previews drop alpha to black, so the card cannot keep transparent corners). Regenerable with--only site. -
The gallery lists the five real performances. Author-supplied captions replace the three “recording pending” placeholders: Everything In Its Right Place (Windows D3D11 + ROLI Piano + Airwave, Airwave Player Pedal Board Rhodes, piano grid), Autumn Leaves (Android + Travel Sax + joystick, GarageBand saxophone, chromatic grid, wind mode), La Guaracha (iPad, GarageBand harp tuned as an arpa chiquitana, Jankó), Canon in D (iPad, Bösendorfer 280 SF2, piano roll), Ali Paşa (iPad, EMRE bağlama SF2, circle of fifths — the Jaffer tribute). The manifest gains
synth(a tag) andbased(label + URL, rendered “Based on …” — the piece each performance follows). All five embed their videos (the author supplied the three remaining ids in order: Autumn Leaves CJXT1IwcI-A, La Guaracha wD_wSZv09-s, Canon in D DWGBWi4C98o). Ali Paşa keeps its “Jaffer tribute” title and note — the Latte animation’s tune, the one Jaffer set a single stylus to — and the gallery page’s tribute section. -
The version is injected under bash on every runner, and CMake refuses a mangled one. The first
v1.0.0run failed at the Windows gate’s “About must read the tag” check: the binary printedmidi-sink 1, and the configure log shows CMake sawSUMI_APP_VERSION=1(plist 0.0.0). The step ran under the Windows runner’s default shell, PowerShell, with-DSUMI_APP_VERSION=1.0.0unquoted; every release-candidate tag before it (0.5.0-rc.N, a-rc.Nsuffix) had passed the same step untouched, so the mangling bites exactly on a bareX.Y.Z— the shape of every real release. Two fixes: the configure steps of the gates matrix and the Windows lane run undershell: bashwith the version quoted from an environment variable (the other lanes already run bash), and the root CMake fails configure when an injectedSUMI_APP_VERSIONis notvX.Y.Z[-pre], printing the value it received — the failure moves from the version check after a full build to the first line of configure. No code change; the tag has to be re-cut after the workflow fix (a release is the promotion of a build that passed every gate). -
The documentation site deploys from
main; the web app on it from the newest stable tag. Post-1.0 the author asked to “untangle the docs from the release and update the docs with the main”, so the store links — the App Store and Google Play listings exist only now that they are public — land on the live site without a tag. #22 refused a push-to-main deploy because it “would publish a site whose/marble/and version line disagree with the released artifacts”; that objection is met by pinning both to the tag rather than to the push.pages.ymlis the ONLY workflow that deploys Pages and composes one tree:/from the checked-out main (SITE_VERSION= the newest stable tag, so the footer still names the release users can install);/marble/REBUILT from the newestvX.Y.Ztag (git -c versionsort.suffix=- tag --sort=-v:refname, so a release candidate never outranks its release) with the release web lane’s emsdk pin and the quotedSUMI_APP_VERSIONof #81, cached per tag withactions/cacheso a docs push costs no wasm build — rebuilt rather than unpacked from the release asset because the token of a push workflow cannot see a DRAFT release’s assets andv1.0.0sat unpublished for eleven days while its web build was live;/marble/rc/from the newest pre-release tag while one is newer than the stable tag, so RC web builds stay previewable without replacing/marble/(the Phase-5 tag deploy DID replace it — thercapt suite’s discipline now applies to the web too);/apt/from every published release, the whole ofpublish-apt.ymlmoved here and that file deleted — one deployer, or two workflows overwrite each other’s tree. Triggers: a push to main that touchessite/or the notes it renders;workflow_runon a successfulreleaserun of a tag (a new tag reaches/marble/the moment its gates and lanes are green — dry runs and failed runs change nothing);release: published;workflow_dispatch. The releaseweblane keeps only thedist-webasset and the spine loses itspages/id-tokenpermissions. Flagged against PHASE5 §9.5 (“built by the same release workflow”) and #22’s deploy path, which this supersedes; the frozen URLs of #22 are untouched, and/marble/rc/is a new one. Consequence the author accepted by asking: a guide page merged to main ahead of the tag it describes disagrees with the demos and the downloads until that tag exists. Found while doing it: thev1.0.0GitHub release is still a DRAFT, sopublish-cask,publish-wingetand the apt deploy have never fired — the tap has no cask, winget-pkgs has no manifest and/apt/midi-sink.ascis a 404 while the README and the install page advertise all three; publishing the release is the human act that opens them (Step 34).
midi-sink 2.0.0-rc.5 · AGPL-3.0 · source
