English · 繁體中文
Measure a turntable's speed and wow & flutter with an iPhone, targeting 0.1% accuracy. Put the phone on the spinning platter — no strobe disc, no extra hardware.
It started with a Thorens TD 235 EV that had sat idle for 20 years and ran slow. Diagnosing that needed a tool accurate to 0.1%.
- Mean speed and error % — whether the platter runs fast or slow, and by how much
- Wow & flutter, IEC 386 / DIN 45507 weighted WRMS, directly comparable to the factory spec
- Spectral peak interpretation — the main thing that sets this apart from other RPM apps. Integer multiples of platter rotation mean something turning with the platter (eccentricity, an out-of-round platter); non-integer ones mean a part of the drive chain running at a different speed (motor, pulley). Enter your drive-chain dimensions and it will point at a peak and say that one is the motor.
- Polar heatmap — which part of the platter the error concentrates on
- History and trend chart — compare directly before and after an adjustment
- Raw data export — per-sample JSON, for use with
tools/analyze_export.py
While measuring, the screen counter-rotates, so the content looks stationary as the platter turns and you can read it without picking the phone up.
Analysis · History & trend · Phone placement.
Simulator screenshots; the data is a synthetic signal for demonstration.
The phone turns with the platter, so what it measures is the platter's speed. Pitch variation caused by an off-center record hole is completely invisible to this method — and in practice that is often the largest part of the wow you actually hear.
Until you calibrate, the "error %" cannot be used to adjust your deck. It is the turntable's error multiplied by the gyroscope's error, and the two cannot be separated. Calibration uses a stopwatch — once per phone is enough.
- iPhone, iOS 17 or later (iPad is not supported)
- Interface languages: English / 繁體中文 / 日本語 / Deutsch (follows your system setting)
- A platter large enough for the phone; 16⅔ / 33⅓ / 45 / 78 RPM are all supported
- Development needs macOS, Xcode 16+, and XcodeGen
Most reliable, and you don't have to trust a third-party tool. See Building below.
Releases has TurntableRPM-unsigned.ipa.
It is unsigned and cannot be installed directly. iOS only runs signed apps, and I do not have a paid developer account, so there is no TestFlight build and no ready-to-install version. You have to sign it with your own Apple ID; the usual tools are AltStore or SideStore:
- Install AltServer on a Mac or Windows PC
- Connect the iPhone and use AltServer to install AltStore on it
- Download the
.ipaabove to the iPhone - Open AltStore ▸ My Apps ▸ + (top left) ▸ pick the
.ipa - Enter your Apple ID (a separate one is recommended — don't use your main account)
Limits of a free Apple ID:
- The app expires after 7 days and has to be re-signed (AltStore renews it automatically on the same Wi-Fi)
- At most 3 apps installed this way at a time
With a paid developer account ($99/year) signatures last a year. Once I have one this will move to TestFlight, and installing will be a single link.
brew install xcodegen
git clone https://github.com/RogerH0711/TurntableRPM.git
cd TurntableRPM
make setup
make openIn Xcode, select the TurntableRPM target ▸ Signing & Capabilities ▸ Team, choose your Apple
ID, then put the Team ID in Config/Local.xcconfig (that file is not in version control,
so make generate will not overwrite it).
Enable Developer Mode on the iPhone first: Settings ▸ Privacy & Security ▸ Developer Mode. A free Apple ID's signature expires after 7 days — just ⌘R again when it does.
| Command | What it does |
|---|---|
make test |
Algorithm tests (99 of them, about 4 s; no simulator or device needed) |
make generate |
Required after adding or deleting files under App/ |
make open |
Generate the project and open Xcode |
make doctor |
Environment self-check |
make reference |
Run the Python reference implementation, regenerating golden vectors |
make android-test |
Kotlin core tests on the JVM (no phone needed) |
make android-apk |
Build the debug APK |
Packages/TurntableCore/ Algorithm core. Plain Swift + Foundation
Sources/ imports neither UIKit nor CoreMotion
Tests/ 99 tests
Reference/ Python reference implementation — source of the golden values
App/ The only layer that touches CoreMotion / SwiftUI / SwiftData
Localizable.xcstrings 306 strings in four languages
android/ Kotlin port (shares the same golden vectors)
core/ plain Kotlin, JVM tests, no Android framework
app/ the only layer that touches SensorManager
tools/analyze_export.py Analyzes exported measurement JSON
docs/spec.md Technical specification
Separating the core from the platform is deliberate: the Simulator has no gyroscope, so anything sensor-related has to be tested on a real device. Extracting the algorithms into plain Swift with no iOS framework dependencies lets the tests run natively on macOS and in a Linux container — which is what makes CI meaningful.
Golden values come from the independent Python implementation in Reference/, not from
Swift's own output. Changing an algorithm means changing the Python first, confirming the
maths, then porting to Swift. CI blocks the two from drifting apart.
.xcodeproj is generated by XcodeGen from project.yml and is not in version control,
which avoids merge conflicts in the project file.
android/ holds a Kotlin port that shares the same golden vectors as the Swift core.
android/core is plain Kotlin with no Android framework dependency, and its tests read
Packages/TurntableCore/Reference/golden.json directly — all nine golden entries are
covered. make android-test runs them on the JVM; no phone needed.
Android's SensorManager sampling rate is only a hint — the real rate is decided by the
vendor's HAL. Measured on a Sony Xperia XZ Premium (G8142, Android 9,
STMicroelectronics LSM6DSM gyroscope), phone stationary:
| Requested | Actual | Median interval | σ | Jitter | Long gaps | Worst gap | Sensor clock ÷ wall clock |
|---|---|---|---|---|---|---|---|
| 50 Hz | 53.96 Hz | 18.524 ms | 0.014 ms | 0.075% | 0 | 1.01× | 0.99995 |
| 100 Hz | 107.92 Hz | 9.277 ms | 0.015 ms | 0.160% | 0 | 1.00× | 0.99995 |
| 200 Hz | 215.74 Hz | 4.639 ms | 0.076 ms | 1.641% | 4 | 1.56× | 1.00051 |
| iPhone 15 Pro Max @100 Hz | 100.13 Hz | 9.990 ms | 0.005 ms | 0.05% | 0 | 1.00× | — |
The consistent +7.9% is two effects stacked, not noise. The LSM6DSM's ODR ladder is 12.5/26/52/104/208 Hz — not 50/100/200. The HAL rounds up to the ladder, then a fixed oscillator deviation multiplies on top:
53.96 ÷ 52 = 1.03769 107.92 ÷ 104 = 1.03769 215.74 ÷ 208 = 1.03721
The first two agree to five decimal places.
What matters is whether the timestamps are honest, not whether the rate matches the
request. If the timestamp clock ran 7.9% fast, every frequency-domain result would shift by
the same amount and the "which part is at fault" diagnosis — the most valuable thing this
app does — would be wrong. Mean speed looks identical either way (ω is a physical quantity,
independent of the sampling clock), so speed alone cannot tell you. Comparing against
SystemClock.elapsedRealtimeNanos() settles it: the ratio is 0.99995, so the
timestamps are honest and the app is unaffected — because it always integrates with real
timestamps and never assumes a fixed rate.
200 Hz degrades clearly (jitter 1.641%, four long gaps, worst gap 1.56×) and this app does
not need it — above 50 Hz only 0.72% of the weighted energy remains. So it uses the 100 Hz
setting, which also sidesteps Android 12's HIGH_SAMPLING_RATE_SENSORS permission entirely.
This is a constraint the iOS version never had to state — every iPhone has one. On Android,
mid-range devices often omit it. Measured on a Sony Xperia XA2 Ultra (H4233): a BMA255
accelerometer and an AK09916C magnetometer, and that's it. Gravity, Linear Acceleration
and Rotation Vector are all virtual sensors Qualcomm derives from those two — there is
no real angular-rate source, so the app cannot measure on that phone at all and says so
plainly on launch.
Deriving rotation from the magnetometer's heading instead is not a workaround: the iOS side
proved that path is swamped by once-per-revolution spatial field distortion (see
CLAUDE.md pitfalls 13–15), and that device's magnetometer tops out at 50 Hz.
Check before you install: any spec sheet listing "gyroscope", or an app like Sensor Box.
Synthetic golden vectors check that the maths matches the spec. This checks something stronger: that two independent implementations reach the same conclusion about the same physical recording. Feeding a 20,377-sample export from the iOS app (203 s on a Thorens TD 235 EV) through the Kotlin core:
| Kotlin | Swift | Difference | |
|---|---|---|---|
| Mean speed | 32.05861 RPM | 32.05861 RPM | 0.0000% |
| Rotation frequency | 0.53431 Hz | 0.53431 Hz | 0.0000% |
| Weighted WRMS | 0.07436% | 0.07436% | 0.0000% |
| DIN 2σ peak | 0.14126% | 0.14125% | 0.0018% |
| 1×/rev component | 0.27238% | 0.27238% | 0.0001% |
The Kotlin port also reproduces the deck's fingerprint: eccentricity at 1×, the belt at 0.908×, and the motor at 35.28×.
Exports are not in version control (~2 MB each), so this is a manual tool rather than an automatic test:
make android-crosscheck FILE=TurntableRPM-20260901-155337.jsonCLAUDE.md records 40 pitfalls hit along the way, including several that took
a long time to find (the file is in Traditional Chinese):
CMDeviceMotion.attitude.yawis a fusion result — calibrating the gyroscope against it is a tautology- Moving averages belong only on the display path; used in the wow & flutter calculation they carve out the weighted peak at 4 Hz entirely
- A phone sitting off-center on the platter slows it down and amplifies the wobble — it must be counterbalanced
- A recorded measurement can itself be wrong; when independent evidence contradicts it, the recorded value is what needs checking
docs/td235ev-maintenance.md is the service log for that
particular turntable.
- Remove magnetic cases and MagSafe accessories — a magnet near an MC cartridge can damage it permanently
- Lock the tonearm in its rest; never leave the cartridge hanging over the platter
- Use the platter's own mat (felt, non-woven or rubber all work; no need to add a record) and never press the phone onto the bare platter
- Balance the phone about the spindle. Easiest is to rest it across a record weight so its centre of mass sits on the axis; otherwise put something of equal weight opposite it. Off-centre, the platter runs about 0.3% slow and the once-per-revolution wobble is a third larger. Measured on this deck, most of the reported 1×/rev came from the phone, not the turntable — to find out how much, measure, rotate the phone 180°, and measure again
- At 78 RPM keep the phone nearer the center: centrifugal force from off-center placement is 5.5× what it is at 33
The app ships in Traditional Chinese (the source language), English, Japanese and German. The Japanese and German translations have not yet been reviewed by a native speaker — corrections are very welcome.
MIT. See LICENSE.


