No description
  • Rust 94.3%
  • Nix 5.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Julien 78bca544de
watch: see unplugs; drain self-cycle events; retry MIDI open after re-enum
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>
2026-08-07 18:26:25 +02:00
src watch: see unplugs; drain self-cycle events; retry MIDI open after re-enum 2026-08-07 18:26:25 +02:00
.gitignore tp7-sync: hands-free recording sync for TE field recorders 2026-08-07 14:53:08 +02:00
70-tp7-sync.rules Detect the vendor-class MTP interface these recorders actually use 2026-08-07 15:09:20 +02:00
Cargo.lock tp7-sync: hands-free recording sync for TE field recorders 2026-08-07 14:53:08 +02:00
Cargo.toml Detect the vendor-class MTP interface these recorders actually use 2026-08-07 15:09:20 +02:00
config.example.toml tp7-sync: hands-free recording sync for TE field recorders 2026-08-07 14:53:08 +02:00
flake.lock mtp: retry transient claim failures after hotplug 2026-08-07 14:57:55 +02:00
flake.nix nixos: drop PrivateNetwork, it silently breaks hotplug detection 2026-08-07 16:54:53 +02:00
LICENSE tp7-sync: hands-free recording sync for TE field recorders 2026-08-07 14:53:08 +02:00
README.md nixos: support headless machines with a system service 2026-08-07 15:20:25 +02:00
shell.nix tp7-sync: hands-free recording sync for TE field recorders 2026-08-07 14:53:08 +02:00

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.