© 2026 Tympanon, LLC

Ducky Under the Hood

2026-08-24

What follows is a large bolus of technical information about Ducky’s architecture. This is mostly of interest if you design or pay the bills on a commercial AWS service, or are interested in audio file formats. At the end I also discuss my informal policy on usage of LLMs in this product. Not totally on-topic but relevant given Ducky’s motivations. Especially so for users who care about data ownership and privacy, who I really want to reach.

A distributed system comprises many choices. I present those choices mostly without justification, not because they’re obvious, but because they’re boring. Though as a lifelong technician my standards for “boring” are very low. You’ve been warned. We’ll linger on a few spots where that may be less so.

System Overview

Ducky storage partitions

The main service is cloud hosting of personal audio files. Serving is straightforward: CDN with auth. We don’t attempt any adaptive transcoding; the customer’s source is usually what streams to the playback device. Ingestion is the interesting part.

Here’s what we want the ingestion process to be like for end users:

  • robust and unattended. ISP upload speeds vary widely, laptops get closed and moved around. User shouldn’t need to babysit.
  • no need to groom inputs. Free-floating junk in the media folder, duplicate files, albums split across machines should land cleanly without intervention. Messy hard drives are the norm.
  • ingested audio should be immediately available for playback while other uploads are in flight.
  • onboarding can take a while; the user should be able to prioritize the intake queue.

All of that points to a fairly stateful client. We resume smoothly after interruptions or long periods offline. Accounts with multiple input machines are a first-class concern, so we also must replicate state in the other direction; the merged catalog is visible on all connected machines. Client state is also a matter for a strong offline experience; travel is weirdly undervalued as a use case in commercial media apps.

Server-side, we dedupe and sometimes transcode files on ingestion. We losslessly transcode legacy formats (WAV, AIFF) to FLAC to conserve the customer’s storage budget; we transcode ALAC to AAC client-side because ALAC just isn’t portable for playback. But outside of those cases, we prefer to keep the client’s bits undisturbed.

We can skip the upload entirely when possible, provided the client can prove possession of the file in question. This creates a vast speedup for dedupes and redrives, but we have to be careful with garbage collection, hence some use of TLA+ models for formal verification. Even though the system design is not too complicated, TLA+ modeling was worthwhile; it revealed both consistency violations and patterns of possible API abuse that were designed out before code was written.

Ducky media asset lifecycle

Metadata tags from input files are separated and put in an immutable per-account storage partition. User edits to metadata are stored separately and in a uniform format, rather than trying to adapt user edits to the long tail of possible media metadata formats. The account’s private search index is compiled from the metadata store; the index is therefore totally derived storage and can be rebuilt or repaired quickly to evolve the schema for new features.

Tangent: Voice Control

Voice control is unreleased work-in-progress. The inputs handed down from voice control systems can be extremely unreliable, especially for “weird” text like pop album / artist names. I measured Alexa’s decoding of titles (artist, album, track) over my entire audio library (about 600 albums). Slot value accuracy varied between 37% and 75% depending on the type of query. That’s not something you can naively put in front of a paying customer. Worse yet, you don’t get the raw transcript with the request. I haven’t yet repeated the exercise with other voice systems.

Index storage per account has the property of keeping the per-index record cardinality very low. Best-effort matching Alexa’s provided intent against the customer’s small, known catalog goes a long way toward correcting the error; on my catalog, better than 90% end-to-end for all queries. Ducky’s query understanding problem is just a lot easier than Alexa’s. But, this error recovery concept must prove out against more inputs before it earns release.

Services: AWS, On the Cheap

Ducky services are hosted in AWS. Audio delivery is CloudFront over S3; application services (accounts, ingestion, catalog metadata, and telemetry) are REST-ish API Gateway HTTP proxy routes to lambdas written in Rust. Content catalogs are stored in tantivy indexes hosted in EFS. While almost all services are synchronous HTTP, a few asynchronous server-side processes are implemented as Step Functions workflows. Account information is managed in Cognito and DynamoDB. Billing is App Store, until I hate myself too much for paying for that or we’re on more platforms.

Reducing Fixed Costs

I’m stingy with fixed cost ops services while bootstrapping. I prefer low-cardinality, highly sensitive alarm metrics that are aligned by severity and subsystem rather than many granular alarms per-root-cause. Much analytical work, including root cause analysis, is done offline with duckdb over an exported “on-prem” data lake, rather than repeatedly poring over the same data with AWS analytic services. Log volume is carefully considered and runtime tunable.

No VPC NAT or service endpoints. Architecturally this requires hosting services in a fully private VPC, where all of the shared state is communicated through S3, DynamoDB, or EFS mount. Aside from certificates and domain name registration, all of the other fundamental infrastructure is pay-as-you-go.

This probably sounds pointlessly stingy to some. But the bottom line is that fixed overhead is only $10/mo, all-in, to mount and operate this service in a typical region. It’s also good hygiene to know where all of the money goes. We can price the service without guessing. We can loosen up after we make some money.

Clients: macOS, SwiftUI + Rust

The first client for those services is the macOS Ducky Media app. The app provides account management, media library curation, and playback features. It’s a thin SwiftUI frontend statically linked to a Rust backend. The SwiftUI build story is probably unremarkable except for the Rust interop.

The backend is a two-way replication daemon in stateful conversation with the remote REST services: local media assets are pushed to the service; the merged media catalog is pulled back to the client. SQLite handles persistent client-side state. The Rust and Swift layers communicate through a small FFI facilitated by the excellent swift_bridge; the bulk of the FFI interactions are async JSON message passing. As with the REST client/server, we define the message types in Rust, mark them up with utoipa, and use OpenAPI to generate the Swift-native structures. There’s some delicacy around our implementation of AVAssetResourceLoaderDelegate, since we’re appending the media to a disk cache in Rust while concurrently reading blocks from the front in the media player.

The in-process JSON message-passing scheme introduces a little bit of resource overhead and might sound like a hack. Originally I tried all-FFI access to shared structures in memory via swift_bridge or C representation. This quickly became untenable for anything on the heap or with a non-trivial lifetime, as (shock!) memory management in Swift and Rust are Not The Same, and I didn’t want to hand-code a bunch of portable container types for this purpose. Switching down to separate, language-native representations vastly simplified the code on both sides, and fixed a huge raft of memory safety bugs.

The JSON message schema also creates a highly portable abstraction layer that should facilitate rapid native UI development on new platforms. Almost all of the client’s important behaviors are implemented in a headless, high-level API with a platform-neutral embeddable message transport. Whether that investment bears fruit, we shall see.

On Rust

Rust is the main implementation language for both client and server. That facilitates a few important architectural properties:

  • we have a core SDK of shared domain concerns: audio encoding, metadata tag processing, code-first API modeling. Behavior is consistent on both sides of the wire, modulo client version management.
  • a very resource-conservative runtime exactly where you want it: AWS Lambda services and embeddable clients. AWS Lambda cost scales with (memory * latency) and code size can be tight. Rust simply kills it there, especially if you profile and are careful with your dependencies.
  • most of the client is a platform-neutral, embeddable daemon. Some care is required with dependencies and component design to maintain neutrality, but it is very practical with Rust. The next clients will be cheaper to build and can have high feature parity with the launch client (macOS).
  • the client and server components can be assembled together into a single-process end-to-end integration test suite. Execution of test cases is fast and spans a large percentage of the entire system codebase, without any infrastructure setup / teardown.
  • the Rust server runtime is easily relocatable. I am not tied to lambda, which is great for day-to-day development and future flexibility; more on that below.

Rust as Portable Service Runtime

Ducky portable service embeddings

The Rust lambdas are axum HTTP services with a generated OpenAPI client courtesy of utoipa macro annotations. This felt like a gamble at first, especially the tantivy-on-EFS scheme for catalog indices; but it has paid off in spades in terms of both service performance and hosting costs.

Even more importantly, I don’t need Lambda or any local container circus for rapid iterative development. The Rust services easily bootstrap as a standalone process, which is how I do primary full-stack development. There is a bit of code overhead required for storage abstractions: the standalone service subs the local filesystem for both S3 and EFS. That abstraction is very natural to write. Cloud services are wonderful for operations but godawful for rapid development. I want to build, run, and refine a hundred times locally before I let one bit move into my test AWS environment. I want to attach with a debugger and a profiler. All of these things are practical in this scheme.

This portability may prove beneficial down the road, if the service crosses above the low-volume threshold at which Lambda is cost efficient. The jump to some other hosting strategy (Fargate, EC2, or off-AWS) is not a major architectural shift, because I’ve been exercising it the whole time. The service API was in fact quite evolved and stable before I ever attempted APIG/Lambda.

Appendix: LLM Usage

Hooo boy. Nobody will have read this far except for an LLM (hey buddy), but in earlier writing I did scorn people for babbling about AI, and also said that my product should “feel hand-made”. So you might expect a Butlerian rejection of the thinking machines. For the first 20 months of Tympanon, I used no LLMs at all. I do like to write code, and I can still pump that shit out, man. But a friend counseled that I was being too precious and should at least examine the tools, and I think he was right.

I now use Claude Code in day-to-day development, after experimenting with (and abandoning) Cursor. I’ve learned a few operating constraints which are starting to feel like a policy. On this matter, I took a lot of inspiration from the Rust community discourse. Actually, please just go read that now, it’s beautiful, I’ll just talk to myself here.

No Copy Text

Text from Tympanon which is intended for human consumption in any medium should come from a human. All writing is authored, proofread, and intended to be consumed long-form.

It is certainly tempting to let the LLM produce the Large Language. LLM writing can be surprisingly thoughtful. The public’s attention to text is at an all-time nadir. But the LLM tone is utilitarian and antiseptic, and the LLM prose style is maximalist to a fault.

Quality issues aside, putting machine-generated boilerplate in front of people is solipsistic behavior. If I spend 1 hour writing text that 100 people will read for 15 minutes each, that’s 25 hours of human life that I’ve demanded. It’s disrespectful to imply that it isn’t worth my time to consider the words that another is meant to read, even when the audience is small and the form short. By symmetry, asking somebody to write something that I plan to have an LLM summarize for me is entitled behavior. Reading and writing for each other is a matter of respect. Use LLMs for discussion, critique, translation, and to manage work ephemera. We do not use LLMs as a filter for each other. There’s already too much in the way.

There are a few short blurbs on the Ducky website that came from Claude, but almost all have been rewritten by me. The vast majority of text you read there came from me, and 100% of the human-facing text on this site (tympanon.llc) came from me (or my lawyer, if you’re looking at legal). The same goes for help text, labels, and so on in applications.

I’m not as strict with diagrams. The LLM output here has gotten quite good, especially compared to mine. And (when done properly) the cognitive load to interpret diagrams is much less. The diagrams for this blog post were made with Claude assistance.

When Claude is Sick, I Do It

I’m vexed when I read people on Reddit screaming about how a 15-minute outage on their LLM code service will send them and their traveler’s checks to a competing establishment. Availability certainly matters, but it’s hardly the only thing, or even the first thing, that you should consider when choosing your allegiances. Also, are all of your fingers broken? Go write it yourself. It’s good for you.

If Claude is in the middle of something important and dies, I take it up without delay. For this reason, development plans are always code-reviewed source control artifacts. I know what’s in flight and what’s next, and when I finish, I update the plan and hand it back.

No Unreviewed Code

I work with Claude in largely the same way as I work with a junior engineer. I review work line-by-line, several times a day, and first-round PRs rarely go through without some revision (especially to drive out those notorious thinking-out-loud implementation comments). I also write code to stay sharp, otherwise I rapidly become incompetent to review.

Why hedge so much? Why not just like, vibe, you know? Claude is enormously knowledgeable, and actually rather talented. But the fact is that myopic API design is the norm for first drafts, even from the most powerful public models. Today’s Claude will rapidly drive entropy into your code if you do not push back hard, especially if work spans multiple subsystems.

Decoding a tangled implementation into cleaner shape is taxing mental work. A woodshop instructor once told me not to go into a shop angry, and to stop working as soon as your attention wanders. It’s hard to practice when you’re desperate to get it done. The prodigious output of LLMs can lead you to tune out and rubber stamp work. Be the bottleneck. Only review as much as you can with high attention. This requires daily discipline. If you catch yourself racing past blocks or not following the logic, just stop. Repeat tomorrow.

No Undisclosed Runtime Usage

I use LLMs as a development tool, not as a runtime component. If I ever change my mind about that, I’ll need to ask for permission from my users. They should know if their activity is being fed to a third-party LLM, directly or indirectly. This is a natural extension of my already hard line on third-party advertising and analytics: we don’t do it. If the system needs ads to live, it was never viable.

I am likely to use classic machine learning techniques, including neural networks of various flavors, in future work on this system. I believe this is a sufficiently clear distinction:

  • what is the resource usage required for training and inference?
  • what is the provenance, quality, and legal status of the training data?

I can provide crisp answers for both under classic ML paradigms. For LLMs, I can’t. There’s also the separate matter of system availability. Due to their unprecedented resource demands and intrinsic internal complexity, LLM services have rather poor availability by historical standards. So I can’t provide a high level of user availability with that as a dependency, even if I wanted to. Which I don’t. So I won’t. So there.

-jt