In July I talked myself out of moving the Song Analyzer to a cloud VM. The reasoning held up: YouTube weighs the reputation of the requesting IP, a residential address sails through where a datacenter address gets treated as presumptively automated, and the analyzer's home connection was therefore a functional capability rather than a budget compromise. The machine's greatest weakness — being a cheap PC in a house — was inseparable from its greatest strength: being in a house.
What I quietly took from that was "leave it alone." Which is not what the argument said. The argument was about the address, not the machine. It said keep it on a residential connection. It never said keep it on an Intel N100 with 8 GB of RAM running Linux inside Windows inside a virtual switch.
There was a Dell XPS 8930 sitting in storage — i7-9700K, 32 GB, an RTX 2060, a 2 TB drive doing nothing. Same house. Same router. Same residential IP. Every advantage the July piece was protecting, on hardware that isn't the weakest thing in the building.
Before any of that, the honest alternative: sell it. I'd already sold a Dell Precision 7760 — Xeon, RTX A3000, like-new, with the charger — for $600. That's a real number and it made keeping a seven-year-old tower feel sentimental.
Except the $600 was the wrong comparison, in two ways. The Precision was a newer machine with a better GPU and it was a complete laptop — screen, battery, keyboard. The XPS is older, slower, and a bare tower. It should fetch less, and the honest range was $300–450.
The number that actually mattered was the other one: $600 gross, $420 net. A 30% haircut to fees and shipping — on a laptop, which ships in a small box. A desktop tower ships worse. Realistically the XPS puts about $280 in your hand.
You can't buy back in for what you'd sell it for. Netting $280 for a GPU box means paying $600–900 to get an equivalent one later.
That reframes the question entirely. It stops being "is this worth $350" and becomes "is a local GPU machine worth $350 to me, given I already own it." If the answer is a maybe-someday, sell it. If you'd actually use it, the resale price is not the relevant figure.
The one genuine cost of the swap is electricity. A mini PC of the N100 class idles around 7–12 W. A desktop with a discrete GPU, 32 GB of RAM and a spinning disk does not.
My first estimate was 60–90 W idle. That was wrong — closer to light load than genuine idle. The 2060 idles around 7–10 W, the CPU drops low in C-states, and the real figure is 45–65 W. Not nothing, but I'd inflated it.
The reason it matters at all is that this service can't be switched off between uses. It's an API; cloudflared has to hold the tunnel open continuously, because that outbound connection is what makes the hostname resolve. There's no wake-on-request path — the request has to reach the machine to wake it, and it can't. So the idle figure is a 24/7 figure, and that's the entire cost.
Then the second error, which wasn't mine. I said roughly $60 a year. It was read as $60 a month, and on that basis the whole plan was abandoned — reasonably, because $720 a year to make a hobby tool faster is absurd. The decision reversed the moment the units were straightened out. Five dollars a month is not a reason to leave a machine in a cupboard.
Two lessons sitting next to each other: state the unit, and don't let a caveat you raised for completeness get mistaken for a recommendation.
Before touching anything I wrote down what the old host actually does — every dependency, environment variable, service, timer and data path. Three things fell out of it that no amount of planning-from-memory would have caught.
CACHE_DIR is hardcoded. It's computed relative to the source file, with no environment override. So "put the audio cache on the 2 TB drive" is not a configuration change — it needs a symlink, a bind mount, or a patch. Discovering that during the build is a five-minute detour; discovering it during a cutover is not.
The service logs to a file rather than to journald, and that's deliberate. yt-dlp and its ffmpeg subprocesses write progress to stderr; when that's a pipe to journald, journald's rate limiting makes write() block once the pipe fills, which freezes the download until the worker times out and gets killed. A regular file never blocks. It would have been very easy to "tidy that up" on the new host and reintroduce a bug someone had already paid for.
And three of the old box's operational hazards turned out to be WSL2 artifacts rather than problems: WSL2 doesn't auto-start when Windows boots, Windows sleep is indistinguishable from powered-off from the outside, and systemctl doesn't work at all without systemd=true in /etc/wsl.conf. All three simply cease to exist on bare metal. That, more than the CPU, is the real argument for wiping Windows rather than carrying WSL2 onto newer hardware.
Worth saying plainly, because it's the most attractive part of the new box and it is currently useless. Every heavy step in the analysis is CPU-bound NumPy and SciPy inside librosa — loading, onset strength, beat tracking, the constant-Q chroma transform. None of it touches CUDA, and none of it would without a rewrite. ffmpeg's NVENC is video encoding and irrelevant to extracting audio.
Where the card would earn its keep is separating stems before the chroma step, and replacing template matching with a proper sequence model — which the code's own comments already flag as future work, and which is why sus, diminished and augmented chords are deliberately disabled today. That's a project. It is not migration work, and doing both at once would make a migration failure and a model failure indistinguishable.
The new box runs Ubuntu 26.04, which ships Python 3.14. The old one doesn't. I expected that to be the problem — librosa depends on numba, and numba has historically lagged new Python releases by a year or more, which usually means pip tries to compile LLVM bindings from source and fails.
It didn't. numba and llvmlite both installed as wheels. The real surprise was one line up in the output: requirements.txt pins librosa>=0.10, and pip happily resolved librosa 1.0.0 — a major version bump from what the code was written against, plus numpy 2.x.
Installing cleanly says nothing about running cleanly. The app calls nine librosa functions; two of them are proven, because the startup routine that warms numba's JIT happens to call them. The other seven are only reachable by actually analyzing a song. A loose version pin is a bet that nothing important changed, and a major release is exactly when it does.
Not the things I planned for. Two failures ate most of the build, and both were environmental rather than anything to do with the analyzer.
The first looked like a hang. Every attempt to analyze a file sat there forever — TCP connection established, no CPU, nothing in the log. A stack dump showed the server idle in serve_forever, as if the request had never arrived. It had; there was simply nobody left to handle it. Gunicorn uses SIGTTIN and SIGTTOU as its worker-scaling signals, and the kernel sends SIGTTOU to a background process that touches its controlling terminal. Started with a trailing & from an interactive shell, gunicorn was quietly being told to shed a worker, over and over, until none were left. Running it under systemd — where there is no controlling terminal — made the problem cease to exist. Which is the general lesson: testing a service in a way the service will never actually run is its own source of bugs.
The second was a static IP that wouldn't stay static. Setting it through NetworkManager appeared to work, then reverted to DHCP on every boot, leaving the box holding two addresses at once. The cause was three netplan files being merged: the installer's config said dhcp4: true, and the NetworkManager-generated file added a static address without ever turning DHCP off. Both were applied. Editing the source rather than the symptom fixed it in one line.
I had written a careful cutover plan — bring the new host up in parallel, test it in isolation, stop the old connector, start the new one, keep the old box as rollback for a few days. Sensible, and entirely unnecessary, because the old machine was already switched off in another building. A request to the API returned Cloudflare's 530: the hostname resolved to a tunnel with no connector behind it. The feature had been dark and nobody had noticed.
So it stopped being a migration and became a restoration, which is strictly easier — nothing to coordinate, no rollback to preserve, no window to keep short. The tunnel itself turned out to be even more portable than advertised: the old credentials were stranded on the offline box, but a tunnel's credentials can be reissued from the account, so the existing tunnel simply reattached to the new host. No DNS record was touched. No certificate was reissued. The front end never knew.
The numbers, finally, on the machine rather than in an estimate: 9.3 seconds to analyze a 3:47 track, against 15–25 on the N100. Almost exactly 2×, not the 3–5× I had guessed — worth writing down, because the guess was the optimistic one and the measurement is the honest one. And the health endpoint reports "youtube":"ok", which is the sentence the whole July argument was about: the residential address came with the house, not with the machine.
The first thing is that a conclusion has a scope, and it's easy to apply it wider than it earns. "Don't move to the cloud" quietly became "don't move," and that cost a couple of months on the weakest machine in the house for no reason.
The second is about where the risk actually lives. I spent the planning worrying about the hardware and the cutover — both of which are genuinely easy, because a Cloudflare Tunnel is identified by a credentials file rather than an IP, so the hostname simply follows whichever box runs the connector. No DNS change, no certificate work, no front-end edit, and rollback is starting the old connector again. The thing that will actually bite is a dependency that resolved one minor version differently on a machine with a newer Python.
And the third, learned the hard way twice in one evening: test a service the way it will actually run. Both real failures came from running things in a shell — a background job catching terminal signals, a network setting applied through the wrong layer. Neither could have happened under systemd and netplan, which is where the service lives.
Migrations aren't usually lost on the move. They're lost on the things that were never written down, the versions nobody pinned, and the difference between how you tested it and how it runs.