Is That A Piano In Your Pants?
Hey, that's weird...
I was in the middle of something else, like I often am. I had ended up trying to mount my iPhone as a volume to get my photos onto my Linux box. It was at that point I noticed something I didn't expect to see. ifuse exposed not just the plain old photo albums, but it had data exposed for ringtones.
Kids, ringtones were a very big deal when I was young. Look it up. A few years back I started thinking about a problem where every time a text message came across I felt compelled to pick up my phone and check it. Was it something useful? someone I cared about? Sometimes. But it was also an interruption. I decided I wanted an audible clue as to what I would find.
Gather round, children
iOS already has a setting for each contact for what tone should be played, so it should be straightforward. Well... iPhone has been around a long damn time and it's never been straightforward as to how to get a damned ringtone onto it.
In the olden days you'd use iTunes on your computer, clip an existing mp3 down to a specific length no greater than some number of seconds, convert it to a different file format. After all that, you had to change the file extension for seemingly no reason. Now you have a file... now how to get it on the damn phone.
This dance changed from time to time and you'd find yourself searching youtube for the latest incantation ("iphone ringtone maker 2018") or downloading some malware claiming to do it for you.
Welp, it's pretty easy now.
TL;DR
Goal: Distinct piano chords per person: inconspicuous enough to not bother most people in most situations, different enough so I know which is which.
Solution:
mktone.pytakes a chord symbol and writes an.m4r. fluidsynth plus the FluidR3 soundfont.instone.pyadds, removes, renames and lists tones on the mounted phone.- reboot the phone to update the loaded tones.
- Per contact assignment has to, unfortunately, be done manually
Part one: making two seconds sound like a pianist
A text tone is about two and a half seconds. That is no room for a melody, so I chose a single chord as the form to take. However... playing all notes at once, all of them present, no dynamics... it sounds robotic. Luckily my good friend Claude, a robot, helped me figure out four rules that fix it.
The root goes low and goes alone. Left hand around C3, nothing else down there competing with it.
Color tones stack tight from Bb3 up. The notes that make the chord what it is, packed close together instead of sprayed across two octaves.
Drop the fifth once the chord gets crowded. On a 13th there is no room for it and it was not saying anything anyway.
Roll the notes, do not strike them. Bottom to top over roughly 24 milliseconds. Fast enough that you do not hear an arpeggio, slow enough that you hear a hand.
That last one is most of the effect. 24 ms is the difference between a MIDI file and a person.
The tones I ended up installing:
- Wifey is Am11. Mid register, quartal, no third at all, so it never resolves. It asks a question and then stops.
- Nanny is Emaj9, a register higher, with a bright major third. That one resolves.
- Friendy is F13. Bluesy. I picked it over the recommendation from Claude, and I was absolutely right!
Here is the whole voicing step. Two magic numbers cover the first two rules: MIDI note 48 is C3, MIDI note 58 is Bb3.
def voice(root, intervals, bass_pc):
"""Return (bass_midi, [upper_midi...]).
Root in the left hand around C3, a compact rootless voicing in the right
starting at Bb3. Drops the 5th on dense chords so extensions stay audible.
"""
bass = 48 + bass_pc
upper_iv = [i for i in intervals if i != 0]
if len(upper_iv) > 4 and 7 in upper_iv:
upper_iv = [i for i in upper_iv if i != 7] # 5th is the expendable one
notes, cursor = [], 58
for iv in upper_iv:
pc = (root + iv) % 12
n = cursor + ((pc - cursor) % 12)
if n == cursor and notes:
n += 12
notes.append(n)
cursor = n + 1
return bass, notes
That % 12 walk is what keeps the right hand tight. Every note goes to the next free slot above the one before it, instead of wherever its interval would normally land.
The roll itself is over in the MIDI writer:
seq = [(bass, 62)] + [
(n, min(112, 74 + i * 5)) for i, n in enumerate(uppers)
]
for i, (note, vel) in enumerate(seq):
on = ms(i * roll_ms) # roll_ms = 24
off = ms(i * roll_ms + hold_ms)
Velocity climbs as the roll goes up, and the sustain pedal is down the whole time. Honestly, that ramp might be doing as much work as the 24ms is.
Making a tone is then one command:
python3 ~/Music/tones/mktone.py Am11
--audition glues every tone you asked for into a single mp3 so you can hear
them back to back, and --list-qualities prints every chord symbol it knows.
Both scripts are in a gist. Fair warning: The vibes are strong with this and I've not read the code.
Part two: getting it onto the phone
ifuse mounts the phone's media partition over USB. No flags, no app id, just a mountpoint:
ifuse ~/iphone
Everything to do with tones lives in one directory:
~/iphone/Purchases/
Ringtones.plist the manifest
import_4CE54437-0D28-4053-A5BE-9A77FCF69A12.m4r the audio
import_B2EC7624-8E1C-45BF-B6EA-6FB553A41B03.m4r
You do not get to pick those filenames. Purchases/ is a staging directory the sync protocol imports out of, and import_<GUID>.m4r is the shape it expects.
Installing a tone is one file and one manifest entry:
python3 ~/Music/tones/instone.py add Am11.m4r --name Wifey
One thing to know on the way out, since Arch does not ship fusermount:
fusermount3 -u ~/iphone
The bug that cost the most time
Ringtones.plist has to be a binary plist. Hand iOS an XML one and nothing happens. No error, no partial import, no log line, no help at all. The tone is just not in the picker.
The fix is one keyword argument:
plistlib.dump(data, fh, fmt=plistlib.FMT_BINARY)
Hours. That fix took hours to find...
iOS only looks at boot
With a correct binary manifest in place, the tones still did not show up. I worked through it by elimination.
| What I tried | What happened |
|---|---|
Posted the iTunes sync notifications with idevicenotificationproxy |
Nothing |
| Unmounted and remounted the FUSE mount | Nothing |
| Rebooted the phone | Every tone appeared |
So... reboot your phone. Maybe there's another way, but I didn't find it.
Part three: assigning the tone
I have the tones in my tone library, now I need to put them to work. Can I do it from the comfort of my terminal? NOPE!
Two separate walls.
AFC is chrooted. The protocol ifuse speaks only exposes
/var/mobile/Media. Contacts do not live there.
What about editing on iCloud?
iCloud has the contact but not the field. CardDAV against iCloud genuinely works, RFC 6352, app-specific password, all of it. You can read and write your contacts from Linux today. But the ringtone assignment is not in the vCard and not in CardDAV, because it does not sync to iCloud at all. It exists on the device and in device backups and nowhere else.
So... Roll up your sleeves kids, and go into each person you've made a text tone for and assign it yourself... As long as your next phone is restored from a backup, you should only have to do this one time.
Conclusion
I had fun with this one and now I have a lot less friction to add that new friend to the text tone'd group of contacts. It was a full year since I'd revisited this, and now it's basically a Claude convo and a phone reboot. I love making tools like this where everything can boil down to what you see in a shell session and terminal coding agents are making that easier all the time for me.