- Rust 94.3%
- Nix 5.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Deployed to hardware and it ignored every replug. Three linked faults: 1. Unplugs were invisible. is_relevant read idVendor from sysfs, which is already gone on a remove event, so the departure was filtered out and the daemon never observed the device absent — which is what arms sync-on-connect for the next plug. Read the vendor from the PRODUCT uevent property, which is present on both add and remove (confirmed with udevadm). 2. The daemon poisoned its own event stream: every sync switches the device to MTP and back, emitting USB events it then processed as if a user had replugged, churning and drowning out real events. Track presence explicitly and act on transitions, and drain the events our own mode switch produces. 3. After a re-enumeration the ALSA card lags the USB device: the control node's uaccess ACL is briefly unapplied (EACCES on snd_card_get_name) and the sequencer client is not yet registered. Retry MidiPort::open through it, as we already do for the USB claim. Verified on hardware: unplug -> 'device disconnected'; replug -> 'device connected' -> 'syncing on connect' -> sync -> normal, no errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
| src | ||
| .gitignore | ||
| 70-tp7-sync.rules | ||
| Cargo.lock | ||
| Cargo.toml | ||
| config.example.toml | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
| shell.nix | ||
tp7-sync
Copies recordings off a Teenage Engineering field recorder without you touching the device.
Leave the recorder plugged in. Record whenever. Each take shows up on your disk about half a minute after you stop, and anything recorded while the device was away is fetched the moment you plug it back in.
$ tp7-sync watch
INFO watching for devices
INFO device connected device=TP-7 (TPBYH136)
INFO take finished; sync scheduled settle=30s
INFO device is in MTP mode
INFO pulled recordings/2026-08-07_134357_000.wav bytes=5663308
INFO sync complete summary=1 new, 19 already local
Why this needs a daemon
The recorder normally presents itself as a USB audio/MIDI interface, and its storage is simply not reachable in that state. It exposes storage only after being asked — over a vendor SysEx message — to re-enumerate as an MTP device, and it returns to normal when the MTP session ends.
The catch: that switch interrupts whatever the device is doing. Ask for it
mid-recording and the take is cut short, saved as-is, and not resumed. The
device offers no way to ask whether it is recording — its Greet command
cheerfully reports mode:normal while the tape rolls.
So tp7-sync listens instead. The recorder emits MIDI realtime messages as its transport moves, and queues them while nothing is listening:
| On the wire | Meaning |
|---|---|
Start / Continue |
a take began or resumed |
Clock, ~43/s |
the transport is rolling right now |
clock stops, no Stop |
paused mid-take |
Stop |
the take ended |
That is enough to know when the device is safe to interrupt, and when a take has just finished and is worth fetching.
Install
Nix
# flake.nix
{
inputs.tp7-sync.url = "github:JulienMalka/tp7-sync";
# NixOS
outputs = { self, nixpkgs, tp7-sync, ... }: {
nixosConfigurations.yourhost = nixpkgs.lib.nixosSystem {
modules = [
tp7-sync.nixosModules.default
{
services.tp7-sync = {
enable = true;
settings.destination = "/home/you/Recordings/tp7";
};
}
];
};
};
}
The module installs the binary, the udev rules, and a user service.
No graphical session? Set services.tp7-sync.user and it installs a system
service instead, running as that user:
services.tp7-sync = {
enable = true;
user = "alice"; # system service instead of a session one
group = "audio"; # must include that user; grants device access
settings.destination = "/var/lib/tp7-sync/recordings";
};
This matters because the default user service only starts once a session does,
and the uaccess tag it relies on grants an ACL tied to an active seat — a
system service has neither, so it reaches the device through the group.
Cargo
$ cargo install --path .
$ sudo install -m444 70-tp7-sync.rules /etc/udev/rules.d/
$ sudo udevadm control --reload && sudo udevadm trigger
Build-time dependencies: alsa-lib, libusb1, libudev, pkg-config.
The udev rules matter, and the 70- prefix is load-bearing. They grant
your user access to the device — needed because these recorders present MTP as
vendor class ff/01/01 rather than the still-image class, so systemd's stock
uaccess rule never matches them — and they keep desktop storage daemons away.
When the recorder appears in MTP mode, gvfs claims the USB interface within
milliseconds; tp7-sync then cannot, and fails with "the USB interface is held
by another program". Nothing can preempt it, since the kernel will not evict
another usbfs client.
If the sync fails that way, the device is left in MTP mode and cannot record
until released. Unplug and reconnect it, or eject it if you have a file
manager. Once the rules are installed, tp7-sync ls also picks up a device
stranded that way and puts it back.
Use
$ tp7-sync status # what is attached, and what it is doing
$ tp7-sync sync # sync once, now, if the device is idle
$ tp7-sync ls # list what is on the device
$ tp7-sync watch # the daemon (this is the default)
sync and ls briefly cycle the device through MTP mode, so they refuse to run
while a take is under way. --force overrides that, and will truncate a
recording in progress.
Configure
Everything has a working default; a config file only overrides things. See
config.example.toml, and tp7-sync config to print
what is in effect.
destination = "~/Recordings/tp7"
settle = "30s" # quiet period after a take before syncing
sync_on_connect = true # fetch everything when the device is plugged in
remove_after_sync = false
Nothing is ever deleted from the device unless you ask for it, and then only after a copy whose byte count matches exactly what the device reported.
Device support
Verified end to end against a TP-7 (firmware 1.1.11): mode switch, listing, and transfer, with the pulled files byte-identical to copies made through gvfs.
Nothing is hardcoded to that device. The vendor SysEx framing is shared across
the product line, the device's SysEx id is discovered with a universal identity
request, and USB personalities are classified from interface descriptors rather
than a table of product ids — accepting both the standard still-image class and
the vendor-specific ff/01/01 that these recorders actually use. Folders are
whatever the device reports.
Other recorders in the family will probably work; reports welcome. If yours
does not, tp7-sync -vv status shows the exchange.
Limitations
- Docking mid-recording still truncates the take. The device's queued MIDI history does not survive being unplugged, so at the moment it arrives there is no way to know it is recording. Stop the take before plugging in.
- Playback looks exactly like recording. Both roll the transport with identical MIDI. Listening to a take on the device therefore triggers one sync cycle that finds nothing new — about six seconds of the device being briefly unavailable. Auditioning from the file browser instead is silent to MIDI and costs nothing.
- A paused take blocks syncing until you actually stop it. This is deliberate: a paused recording is still open, and finalising it behind your back would be worse.
- Only tested against a TP-7 so far.
How it is put together
te::sysex vendor SysEx framing and 7-bit packing pure, unit-tested
te::device USB enumeration and personality detection reads sysfs
transport the transport state machine pure, unit-tested
midi ALSA raw MIDI; demultiplexes realtime stream parser tested
mtp::ptp PTP containers, datasets, strings pure, unit-tested
mtp::session PTP over USB bulk; implements Storage verified on device
storage what the sync engine sees trait + fake
sync which files to copy, and copying safely unit-tested
recorder one device through a full cycle
watch the event loop: poll(2) on udev + ALSA
The daemon is a single process blocked in poll(2). There are no timers to
tune and no polling loops: when the desk is quiet, it costs nothing.
MTP is spoken directly rather than through GVFS, which removes the automount race, the mount-path discovery, and the unmount dance — closing the session is what returns the device to normal. It also means no desktop session is needed: the binary links only ALSA, libudev and libusb, so it runs headless, on any desktop, or on none.
Credits
The SysEx mode-switch sequence was worked out by totocaster/tp7, a macOS CLI for the same device; this crate reimplements it on ALSA. The broader vendor protocol is documented by the EP-133 community, notably ZacharySBrown/ep133-ppak.
Worth knowing if you go digging: the EP-133's File command (0x05), which
would allow listing and reading files over SysEx with no mode switch at all, is
not implemented on TP-7 firmware 1.1.11 — it answers unknown command,
identically to a made-up opcode. And the published firmware images are
encrypted, so that avenue is closed too.
License
MIT.