An iPhone bridge from your record deck to Sonos speakers.
First Press turns a spare iPhone into a dedicated turntable companion. Put a record on the deck, and the phone streams it to the Sonos Rooms you've chosen. It also shows what's playing and keeps a history, and it can scrobble every Track to Last.fm. You don't have to touch the phone.
Now Playing on an iPhone in kiosk mode, with a Track and before one is identified; the start screen and Settings, from the simulator.
Status: a personal project, in daily use. The code is covered by about 200 tests, and the first full test pass on an iPhone with a USB deck and Sonos speakers is complete (#10). Expect the occasional rough edge.
- Starts by itself. Drop the needle and a Session starts. When the side runs out, First Press waits through the Flip. If no new Record starts, it stops the speakers after 45 s of silence, even with the stylus still in the run-out groove.
- Plays in your Sonos Rooms. Tick any number of Rooms, each with its own volume, and they play in sync. First Press takes them over even if they were playing something else. If someone takes a Room back in the Sonos app, First Press steps back and leaves it alone until the next Session.
- Plays on the phone too, if you like. "This iPhone" plays the Record on the phone's speaker, Bluetooth earphones or an AirPlay receiver, a few seconds ahead of the Rooms.
- Knows what's playing. ShazamKit identifies each Track. Now Playing shows the cover, title, artist and album. The Sonos app shows "Artist – Title" under the station name "First Press".
- Keeps a history. The Tracks you've played sit under Now Playing, newest first, each Track once. Tap one to look it up on Spotify.
- Scrobbles to Last.fm. It sends "now playing" and scrobbles each Track once it qualifies. Scrobbles queue up while offline.
- Behaves like an appliance. The screen never blanks while a Record plays. It blanks after a set time of silence to protect the OLED panel. Kiosk mode hides the status bar, and hides the on-screen icons until you touch the screen.
turntable ─► USB (deck with built-in preamp, or preamp + USB audio interface)
─► Lightning/USB-C camera adapter with charging ─► iPhone running First Press
│
┌────────────────────────────────────────────────┼─────────────────────┐
▼ ▼ ▼
live 320 kbps MP3 stream on the phone ShazamKit identifies Last.fm scrobbles
─► Sonos Rooms, controlled over the LAN the Track
- Detection. First Press watches the input level and detects Records, Gaps between tracks, Flips and Sessions. It uses a median that ignores the clicks of the run-out groove. The settings are described in Calibrate detection.
- Sonos. First Press controls the speakers over Sonos's local UPnP interface, not the cloud API (ADR 0001). It finds them with Bonjour (ADR 0003). Each Room plays the phone's own live MP3 stream (ADR 0002), encoded with LAME. A few seconds of Sonos buffering is expected.
- Vocabulary. Words like Record, Gap, Flip, Session, Track, Play, Room, Takeover and Step back have exact meanings, defined in GLOSSARY.md.
- An iPhone on iOS 17 or later that can stay beside the deck, plugged in. It was designed on an iPhone 12 mini.
- A turntable with USB audio. Either a deck with a USB output, such as the Numark TT USB, or a phono preamp feeding a class-compliant USB audio interface.
- A camera adapter with a charging port, so the phone charges while the deck is plugged in. On Lightning phones that's Apple's Lightning to USB 3 Camera Adapter.
- Sonos speakers on the same Wi-Fi network as the phone.
- A Mac with Xcode (current release) and XcodeGen.
- An Apple Developer Program membership. You install First Press from source onto your own phone, and the ShazamKit App Service is enabled per App ID in the developer portal. This guide assumes a paid membership.
- Optional: a Last.fm account, if you want scrobbling.
First Press isn't on the App Store. You build it from source and install it on your own phone.
-
Clone the repository and install XcodeGen.
git clone https://github.com/steffentchr/FirstPress.git cd FirstPress brew install xcodegen -
Set your bundle ID and team. Copy the example and fill in both values:
cp Config/Signing.xcconfig.example Config/Signing.xcconfig
FIRSTPRESS_BUNDLE_IDis a bundle ID you make up. It must be unique, and in reverse-domain form, for examplecom.yourname.firstpress.FIRSTPRESS_DEVELOPMENT_TEAMis your 10-character team ID. Find it in Xcode › Settings › Accounts, or under Membership details at developer.apple.com/account.
The copy is git-ignored.
-
Optional: add a Last.fm API account. This step is only for scrobbling.
-
Create an API account at last.fm/api/account/create. The callback URL can stay empty.
-
Copy the example config:
cp Config/LastFm.xcconfig.example Config/LastFm.xcconfig
-
Fill in the API key and shared secret from last.fm/api/accounts.
Without this file, the app still builds, and Settings says Last.fm isn't available. The secret ends up inside the built app, so keep your builds to your own devices.
-
-
Generate the Xcode project.
xcodegen generate
The first run also builds the MP3 encoder, which takes about a minute and needs the network once.
scripts/build-lame.shdownloads LAME 3.100, checks its SHA-256, and buildsVendor/LAME.xcframework, which is git-ignored. Runxcodegen generateagain whenever you pull changes, because the.xcodeprojisn't checked in. -
Register the App ID with ShazamKit. Without this step, nothing is identified.
- At developer.apple.com/account, open Certificates, IDs & Profiles, then Identifiers, and click +.
- Choose App IDs, then App.
- Enter an Explicit bundle ID: the one from step 2.
- On the App Services tab, tick ShazamKit and register.
Do this yourself. Xcode's automatic signing usually installs First Press under the team's wildcard profile, so it never creates an App ID you could add the service to. Apple's guide is Enable ShazamKit for an App ID.
-
Install on the phone.
- Open
FirstPress.xcodeproj, connect the iPhone and choose it as the run destination. - Run (⌘R).
- On first install, iOS asks you to turn on Developer Mode (Settings › Privacy & Security). Turn it on and run again.
- Open
-
First launch.
- Plug the deck into the phone.
- Choose the deck's input on the start screen, allowing the microphone when asked. First Press only ever listens to the input you choose.
- Allow local network access when asked. First Press then finds your Sonos Rooms.
- Tap the speaker icon and tick the Rooms that should play.
- Calibrate detection once for your deck.
- For a dedicated phone, tick Kiosk mode in Settings › Screen, and start Guided Access (Settings › Accessibility) so nobody can leave the app.
First Press shows one screen at a time:
- Start screen. It asks for the input until one is chosen, and again whenever the chosen input is unplugged. Once the input is ready, it reads "Put on a record to start." with the input's name under it. Tap the name to switch inputs.
- Now Playing. This appears by itself when a Record starts and stays through Gaps and Flips. It shows the current Track large. Until a Track is identified, the grey version of the app's artwork holds its place. History sits below, with the two latest Tracks at rest; scroll down for up to 50.
Two icons sit in the top right:
- The speaker opens the Rooms sheet. Tick a Room to switch it on at once, and a volume bar appears. The sheet also shows Rooms that can't be reached or that First Press stepped back from. "This iPhone" at the top plays on the phone's own output; once it's ticked, its AirPlay button chooses where.
- The gear opens Settings: the input, the appearance, screen blanking, kiosk mode and Last.fm, with Detection and Permissions in submenus.
First Press is dark unless you choose otherwise. Settings › Appearance offers System (follows iOS), Light and Dark, and applies to Now Playing, History, Settings and the Rooms sheet. The orange start screen and the blank screen look the same in every appearance.
First Press searches for the Track at the start of a Record, after each Gap, and every 30 s otherwise. Keep the deck's pitch at zero, or Tracks won't match.
Connect Last.fm in Settings. First Press opens Last.fm's own sign-in page, so it never sees your password, and keeps the session key in the Keychain. If the page doesn't close by itself after you approve First Press, close it, and First Press finishes connecting.
Rules for scrobbling:
- When a Track counts: it must be longer than 30 s and have played for half its length or 4 minutes, whichever is less. If Last.fm doesn't know the length, it counts at 4 minutes, or at its end if it ran at least 30 s.
- Offline: Plays waiting for Last.fm survive a restart and are retried as soon as the network returns.
- While not connected: Plays made while Last.fm isn't connected are never sent later.
Shazam doesn't give album names, so the album comes from Last.fm when it's connected.
A Record starts when the level stays above the threshold for the start delay. It ends with a Flip once the level stays below the threshold for the Flip threshold. Calibrate once per deck and gain setting:
- Set the deck's USB gain so loud passages peak well below clipping. On the Numark TT USB, the gain and phono/line switches are on the underside.
- Keep pitch at zero.
- Play a Record through to the run-out groove and leave the stylus there with the platter turning.
- In Settings › Detection, read the noise floor (white tick). Set the threshold (orange tick) clearly above it but below the quietest music.
Changing the gain means calibrating again. If the noise floor is high because of hum, try another charger or socket first, since the deck and the phone's charger can form a ground loop.
The defaults are a start threshold of −50 dBFS, a 1.5 s start delay, a 12 s Flip threshold, and a 45 s stop delay. The stop delay counts from when the silence began, and it must be longer than the Flip threshold.
First Press Menu is a menu-bar app for a Mac (macOS 26 or later) on the same network as the phone. It shows what First Press is playing without getting up. It finds First Press on the iPhone by itself over Bonjour and talks to it over a WebSocket, with no pairing (ADR 0004). The iPhone stays in charge: First Press Menu only shows First Press's state and sends the same intents as the phone's own controls. First Press must be running on the phone, in front or in kiosk mode.
- The menu-bar icon is a stylised record: a circle around a solid label. It gains a thin groove ring while a Record plays, and is dimmed while no phone is connected. Click it to open the panel.
- The panel shows the current Track large, with its cover on the left and the artist beneath and the album once Last.fm names it, or what First Press is doing, with the last 10 Tracks below. After a Flip it says "Between sides", with the last Track's title beneath, until the next Record starts or the Session ends. While no phone is connected, it says only "Looking for First Press on your iPhone…", small. If the link drops, the last Track and History stay on screen, greyed, with that line beneath the Track, while First Press Menu keeps reconnecting.
- Click a Track in the panel, in the header or in History, to search for it in Spotify or Apple Music, whichever Settings names. There is no menu.
- Notifications announce each new Track identified while connected, with its cover. Each replaces the one before and clears itself after about 8 seconds. When a side runs out during a Session, a "Flip the record" notification names the Record's last Track and stays until you dismiss it or the next Record starts; it stays when the Session ends by itself. Clicking one opens the panel. There's none for a Track or a Flip already under way when First Press Menu connects, and none while the panel is open. First Press Menu asks for the Alerts style so "Flip the record" stays on screen; macOS applies that only to new installs, so on an existing install switch First Press Menu to Alerts in System Settings › Notifications.
- The footer: the power button quits First Press Menu and leaves First Press on the phone alone. The gear opens Settings: Appearance, Launch at login, Notifications, and Find songs in. Both stay usable while no phone is connected. On the right, while a phone is connected, the Rooms summary shows where First Press plays, and opens the Rooms view in place of History: This iPhone and every Sonos Room, ticked on or off at once, with a volume slider for each Room that's on.
The artwork shows a vinyl record with an orange label, and a record-cleaning brush resting on it, against a warm orange gradient. The SVG sources are in Design/:
| Source | Used as |
|---|---|
Design/AppIcon.svg |
The app icon. |
Design/StartScreen.svg |
The start screen and the launch screen: the icon's artwork with the orange extended upwards and the record anchored at the bottom. It's sized at 450 × 972 pt, so it covers every iPhone. Its label, unlike the icon's, carries a faint catalogue number, "ROUGH 96 B-1U-1-1". The launch screen shows it at the same size and position, so the app opens without a jump. |
Design/NoTrackCover.svg |
The cover on Now Playing while no Track is identified: the icon in grey, with the start screen's catalogue number on its label. |
The PNGs in App/Resources/Assets.xcassets are rendered from these files and are opaque, as iOS requires. After editing an SVG, re-render with rsvg-convert and ImageMagick (brew install librsvg imagemagick):
rsvg-convert -w 1024 -h 1024 Design/AppIcon.svg | magick - -alpha remove -alpha off \
App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
rsvg-convert Design/StartScreen.svg | magick - -alpha remove -alpha off \
App/Resources/Assets.xcassets/StartScreen.imageset/StartScreen@3x.png
rsvg-convert -w 960 -h 960 Design/NoTrackCover.svg | magick - -alpha remove -alpha off \
App/Resources/Assets.xcassets/NoTrackCover.imageset/NoTrackCover@3x.pngDesign/StartScreen.svg and Design/NoTrackCover.svg are derived from the icon. If the icon changes, regenerate them too, and keep the catalogue number on their labels but off the icon's.
First Press Menu's artwork has its own sources, rendered into Menu/Resources/Assets.xcassets:
| Source | Used as |
|---|---|
Design/MenuAppIcon.svg |
First Press Menu's Mac app icon: the app icon inside the macOS icon grid, an 824 × 824 rounded square on a transparent 1024 canvas. It's derived from AppIcon.svg; the comment in it says how. |
Design/MenuBarIcon.svg |
The menu-bar icon: a stylised record (a one-line circle around a solid label about 40% of its diameter) as an 18 pt template image, black on transparent, which macOS tints. No grooves, no brush. While no phone is connected, First Press Menu shows it dimmed. |
Design/MenuBarIconPlaying.svg |
The menu-bar icon while a Record plays: the same, with one thin groove ring between the label and the edge. |
Mac icons keep their transparency. Re-render all of them with:
scripts/render-menu-artwork.shApp/ SwiftUI shell and the production adapters (AVAudioSession, Bonjour, ShazamKit, …)
Menu/ First Press Menu, the Mac menu-bar app: SwiftUI panel and Settings, and its adapters (Bonjour/WebSocket link, notifications, login item)
FirstPressCore/ All behaviour, and First Press Menu's model, as a Swift package with no UIKit or SwiftUI; see its README
Config/ xcconfig files: shared settings, plus examples for your signing and Last.fm keys
Design/ SVG sources for the icon and artwork
docs/adr/ Architecture decisions
docs/agents/ How coding agents use the issue tracker, labels and domain docs
scripts/ build-lame.sh, render-menu-artwork.sh
GLOSSARY.md The domain vocabulary
project.yml XcodeGen project definition
Run the tests. They run on macOS and need no simulator:
swift test --package-path FirstPressCoreBuild the app for the simulator:
xcodegen generate
xcodebuild -project FirstPress.xcodeproj -scheme FirstPress \
-destination 'generic/platform=iOS Simulator' build CODE_SIGNING_ALLOWED=NOBuild First Press Menu, the Mac app (macOS 26 and later). It doesn't use LAME:
xcodebuild -project FirstPress.xcodeproj -scheme FirstPressMenu \
-destination 'generic/platform=macOS' build CODE_SIGNING_ALLOWED=NOIts bundle ID is the iPhone app's with -menu appended, so com.steffentchr.pressing-dev-menu on the App Store.
Where tests go. Behaviour is tested through FirstPressCore's public interface, with fakes for every connection to the outside world: audio input, Sonos, recogniser, Last.fm, clock, storage, stream and phone output. The app's adapters and SwiftUI views are checked by hand on a device.
Specs and work. Specs and tickets are GitHub issues. The original spec is #1, and the first on-device checklist is #10; it's a good template for checking a new build on the phone.
Xcode Cloud can build First Press and send it to TestFlight. Because the .xcodeproj and both xcconfigs are git-ignored, ci_scripts/ci_post_clone.sh recreates them right after Xcode Cloud clones the repo. It writes the xcconfigs from the workflow's environment, installs XcodeGen and runs xcodegen generate, which also builds LAME. Set these in the workflow's Environment section:
| Variable | Value |
|---|---|
FIRSTPRESS_BUNDLE_ID |
The app's bundle ID, as registered in App Store Connect. The bundle ID keeps its original value, com.steffentchr.pressing-dev, from before the app was renamed First Press: App Store Connect knows the app by it, so don't change it. |
FIRSTPRESS_DEVELOPMENT_TEAM |
The 10-character team ID |
FIRSTPRESS_LASTFM_API_KEY |
Last.fm API key. Mark it Secret. Optional. |
FIRSTPRESS_LASTFM_API_SECRET |
Last.fm shared secret. Mark it Secret. Optional. |
First Press Menu ships on the Mac App Store, apart from the iPhone app. The two can be out of step, because the wire contract only ever adds fields (ADR 0004).
-
Sandbox.
Menu/FirstPressMenu.entitlementsturns on the App Sandbox with outgoing network connections only.Menu/Info.plistdeclares_firstpress._tcp, andproject.ymlsets the local network usage description. Launch at login usesSMAppService, which works in the sandbox. -
Version and build:
MARKETING_VERSIONis under theFirstPressMenutarget inproject.yml. The build number,CURRENT_PROJECT_VERSION, is one project-wide setting at the top ofproject.yml, shared with First Press so the two apps always carry the same build number. Raise it for every upload of either app. -
A local Release archive, unsigned, checks that it builds:
xcodegen generate xcodebuild -project FirstPress.xcodeproj -scheme FirstPressMenu -configuration Release \ -destination 'generic/platform=macOS' -archivePath build/FirstPressMenu.xcarchive \ archive CODE_SIGNING_ALLOWED=NO
Owner steps. These need the TwentyThree team's App Store Connect account:
- Create the App Store Connect record for First Press Menu, under the TwentyThree team. The name is First Press Menu and the subtitle is Remote control for Mac. Its bundle ID is
com.steffentchr.pressing-dev-menu, registered as a macOS App ID with the Team ID (not the account's older App ID prefix) as its prefix: macOS rejects any other prefix. - Write and host the privacy policy, and set its URL in App Store Connect. The About footer in Settings links to
AppStore/privacy-policy.md, which covers both the iPhone app and First Press Menu. ChangeSettingsView.privacyURLif the policy moves. - In Xcode, archive with signing (Product › Archive, scheme FirstPressMenu), then Validate App and Distribute App › App Store Connect from the Organizer.
- Add the Mac screenshots and store text from
AppStore/FirstPressMenu/, and submit for review. Its README lists the store fields that aren't in files and the decisions left before submitting.
- MP3 encoding uses LAME, which is licensed under the LGPL. It's downloaded and built by
scripts/build-lame.sh, not included in this repository. - The Sonos UPnP calls follow the work of SoCo and node-sonos-http-api.
- First Press isn't affiliated with or endorsed by Sonos, Apple, Shazam, Spotify or Last.fm. Their names are trademarks of their owners.
- No licence has been chosen for First Press's own code yet.




