Visual Click Track

Mobile apps: embedding the player

Research, October 2026. No code yet; the tracker entry is PROJECT.md §28, which holds the status, the open issues (MOB-…) and what's left to do.

architecture.md draws the player as it is today — the packages, the clocks and threads, the backing track. This is what would have to change to put it on a phone.

The goal set for this: native desktop and mobile apps are thin shells that embed one reusable player and bridge it to the system. The two questions asked were what the best approach is, and whether the existing wasm code can be repurposed for it (and if not, whether to delete it).

Short answers: compile the player natively for each platform, with SDL3 supplying the window, input and audio on mobile; and no, wasm is not the way in, but keep it anyway — it's the web version, and it's the reason the mobile port is cheap.


1. What already ports

Measured on odin dev-2026-05, not guessed.

Odin has first-class mobile subtargets: -target:darwin_arm64 -subtarget:iphone (and iphonesimulator), and -target:linux_arm64 -subtarget:android.

Every package in the repo that doesn't import raylib type-checks for iOS unchanged — vct, player, align, score, sheet, chordpro, stagesync, qr and lsp — and so does cmd/player. Android needs nothing but ODIN_ANDROID_NDK set.

A static library holding the parser, timeline builder, engine and driver builds for iOS today:

odin build <pkg> -target:darwin_arm64 -subtarget:iphone \
	-build-mode:static -no-entry-point -o:speed -out:libplayer.a

That gives a 324 KB arm64 Mach-O archive — LC_BUILD_VERSION platform 2, minos 17.4 — exporting an @(export) C-ABI symbol, ready to link into an Xcode target. -subtarget:iphonesimulator gives platform 7. So feature 12's "export the core with a C ABI" is a formality rather than a port.

The themes and clock-lock work (features 9 and 11) didn't change that: player/theme.odin, player/look.odin, player/ttf.odin and player/lock.odin are all platform-free — no raylib — and check for iOS with the rest. config_dir (os.user_config_dir, where settings.json5 lives) resolves inside the app container on iOS, as documents_dir does.

The audio-thread mixer (feature 11) helps more than anything else since: every stem now goes out through one stream whose callback (backing_mix, a proc "c" that takes no lock and allocates nothing) sums them from a frame cursor, and player/lock.odin counts the frames that mixer is asked for. A single pull callback plus a frame count is exactly the shape SDL3's audio callback wants, so the mobile backend inherits the mixer and the clock lock rather than reimplementing them.

Two other pieces land better than expected:

Why it ports: the wasm build did the hard part

None of this is luck. The web version (feature 20) is what got core:os out of the shared code — files and paths behind player/file.odin vs player/file_js.odin, player.Arena instead of core:mem/virtual directly — and what split the shared startup and per-frame step (cmd/player/main.odin) away from the platform entry point (main_desktop.odin, main_web.odin).

A mobile shell wants exactly that shape: a third entry point beside those two. mise run vet type-checking the wasm target is what keeps it true.


2. The blocker is raylib, not the player

raylib has no iOS backend. There is no rcore_ios.c, and the request for one was closed in 2018. The only public working iOS raylib (jlt-commons/raylib-ios, raylib 6.0 + SDL 2.32.10) reaches iOS through raylib's SDL platform backend with GRAPHICS_API_OPENGL_ES2 — and turns raylib's audio off, because enabling it pulls Objective-C headers into C translation units. It also notes the iOS simulator hasn't displayed OpenGL ES since 17.5. Audio is not optional for this app, so that path is out.

Odin's vendor:raylib couldn't link a mobile raylib as it stands anyway: the ODIN_OS == .Darwin branch hard-codes macos/libraylib.a plus the Cocoa, OpenGL and IOKit frameworks, and iOS is Darwin, so the system:raylib fallback branch is unreachable. Prebuilt libraries ship for macOS, Linux, Windows and wasm only.

raylib's Android support is official (PLATFORM_ANDROID, NativeActivity, GLES2), but the same binding problem applies, and standing on it would mean two different mobile backends.

Backends compared

iOS Android Web In vendor: Zero-install
raylib (today) ✗ no backend; unofficial SDL route, audio off ✓ official ✓ (in use) ✓ ✓ prebuilt libs
SDL3 ✓ official, xcframework + Metal ✓ official, Gradle/NDK ✓ official ✓ vendor:sdl3 ✗ system:SDL3
sokol ✓ in the C headers ✓ in the C headers ✓ ✗ third-party sokol-odin ✗ desktop clib scripts only

SDL3 is the one to stand on for mobile. It's already in vendor:sdl3 (video, gpu, render, audio, main, plus ttf, mixer and image), both Apple and Google platforms are officially supported, it exposes UIKit and Android window properties (PROP_WINDOW_UIKIT_WINDOW_POINTER, PROP_WINDOW_ANDROID_WINDOW_POINTER, PROP_WINDOW_CREATE_EXTERNAL_GRAPHICS_CONTEXT_BOOLEAN) so it can sit inside a host-owned view, and it supports Emscripten as well — so one backend could eventually cover the web too.

It is not zero-install the way vendor:raylib is: the bindings link system:SDL3, so each platform supplies its own build. That's the documented path on iOS (SDL3.xcframework) and Android (Gradle), but it would be a new requirement on desktop. Hence: raylib stays on desktop and web, SDL3 is added for mobile only.

sokol was the other serious candidate. The C headers do support iOS and Android, but floooh/sokol-odin is third-party rather than in vendor:, and ships clib build scripts for Windows, macOS and Linux only — mobile means writing those yourself.


3. Why wasm is not the way in

In a WebView (WKWebView, Android WebView)

In a native wasm runtime (wasmtime, WAMR, wasm3)

iOS allows no JIT, so interpretation or AOT only — and the shell would have to expose graphics, input and audio across the wasm boundary, i.e. reimplement raylib's backend as host calls, in order to run code that already compiles to native arm64. Strictly more work, strictly slower, no upside.

So should the wasm code go?

No. Keep it, for reasons that have nothing to do with mobile:

  1. It is the web version — a shipped way to run the whole player (library, stage, editors) with no install, on a Chromebook or a borrowed laptop.
  2. It is why the shared code is portable enough for iOS to type-check today (§1). Deleting it removes the only continuous check that core:os and desktop assumptions stay out of player/ and the shared half of cmd/player/.
  3. mise run vet already type-checks the wasm target, so that check costs nothing to keep.

Its one real cost is WEB-I1: mise run build needs emcc on PATH, so a machine without emsdk fails at the build:web step. That's worth fixing by making the step skippable — not by deleting the target.


            ┌─────────────────────────────────────────────┐
            │  vct/  player/  align/ score/ sheet/ ...    │  pure Odin, no I/O
            │  parser · timeline · engine · driver · view │  compiles for iOS today
            └─────────────────────────────────────────────┘
                              ▲
            ┌─────────────────┴───────────────────────────┐
            │  gfx seam (~40 procs, from ui.odin)         │  draw · input · audio · files
            └──────┬───────────────────────┬──────────────┘
          gfx_raylib                   gfx_sdl3
                │                           │
   ┌────────────┴──────┐        ┌───────────┴───────────┐
   │ macOS/Win/Linux   │        │  iOS shell  │ Android │  thin: lifecycle, pickers,
   │ web (Emscripten)  │        │  (UIKit)    │ (Kotlin)│  MIDI, audio session, paths
   └───────────────────┘        └─────────────┴─────────┘

The shells stay thin because the player draws its own UI; what they bridge is the list in §6: file pickers, MIDI, the audio session, the sandbox paths, the idle timer and the lifecycle events.

Port the stage, not the app

raylib call sites cluster in the desktop-only screens — edit.odin 149, set_edit.odin 99, home.odin 83 — while the stage path is 48 (stage.odin) plus 83 (ui.odin's primitives) plus 12 (cmd/player/look.odin): 143 of 758 in total. Porting the stage and the pre-play list first is under a fifth of the work, and it's what a musician actually needs on a phone or tablet.

The editor should stay desktop-first regardless: it's driven by Enter, Tab, E, N, Ctrl+T/S/Z/D and friends, with no touch design at all.


5. Feature-by-feature compatibility

How each existing feature stands on iOS and Android under the recommended architecture.

Feature iOS Android Notes
Parse, timeline, engine, driver ✓ ✓ Builds today (§1)
Stage display, layouts, lyrics/karaoke ✓ ✓ Needs the gfx seam; layout already scales to any screen shape
Themes and settings ✓ ~ theme.odin, look.odin, ttf.odin are platform-free; Android needs a config path (MOB-Q2)
Metronome mode ✓ ✓ Draws from the same view state
Operator controls ~ ~ Keyboard-only today; Start/Next need touch targets (MOB-Q1)
Backing tracks, mixer ~ ~ Works, but decode is whole-file in memory (MOB-I1)
Stage clock locked to the track ✓ ✓ player/lock.odin counts frames from our own mixer, so it ports with it (MOB-I2)
Audio while backgrounded / screen locked ~ ~ Needs the playback session and audio background mode (MOB-I5)
MIDI pads (Launchkey) ✓ ~ iOS: same CoreMIDI C API. Android: AMidi + JNI (MOB-I4)
Library, sets, pre-play ✓ ~ iOS paths already right; Android needs a path from the shell (MOB-Q2)
Import (ChordPro, sheets, MusicXML, MuseScore, MIDI) ✓ ✓ Importers are pure Odin and already compile; pickers need a bridge (MOB-I7)
Export ✓ ✓ Same: share sheet / SAF behind the existing seam
Song and set editors ✗ ✗ Keyboard-driven; desktop-first by choice (MOB-Q1)
Stage sync server + web client ✓ ✓ TCP listening is unrestricted on iOS
vct-sync broadcast discovery ~ ✓ iOS UDP broadcast needs an Apple entitlement (MOB-I3); QR code already covers it
Phone link / QR ✓ ✓
Extra stage displays, a layout each ✗ ✗ SDL3 on iOS is single-window; use AirPlay or a synced screen (MOB-I6)
--render to video ✗ ✗ Shells out to ffmpeg (MOB-I7)
CLI tools, LSP ✗ ✗ Desktop only by nature; they do compile for iOS

✓ works · ~ works with the noted work · ✗ not going to mobile


6. What the shells have to bridge


7. The cheap alternative, for the record

A native sync client — a phone app that follows a desktop player — needs no player port at all: the protocol is specified in sync.md and a web client already exists. It's a genuinely useful app, but it requires a desktop player on the network, so it complements an embedded player rather than replacing one.


8. Sources

Platform claims above come from these, checked October 2026.