Repository navigation
RFC: Split FluidVoice into a shared engine and per-platform shells #1244
BenjaminPrice
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Summary
Split FluidVoice into one cross-platform engine and thin native shells. The engine takes audio and context in and returns transcripts and enhanced text. Each shell owns everything that touches the user's session: hotkeys, mic capture, the overlay and text insertion.
The engine is a Swift library first and a daemon second. macOS and iOS link it in-process, with no behavior change. Linux runs it as a per-user service behind a Qt shell, and any client can point at a remote engine on a GPU box, which is what #615 asks for.
The migration is six steps, each mergeable on its own. The first three are pure refactors that also make the iOS app cheaper to build.
Questions for the maintainers
These answers decide whether this lands upstream or lives elsewhere, so I'm asking before writing code.
windows-mainbranch says the Windows source isn't published, and thewindows-v0.0.xreleases mention CUDA and Vulkan paths. Is that app Swift? Should it share an open engine, or stay out of scope here?Motivation
Three requests need the same engine, and today that engine is tangled with the macOS UI.
TranscriptionProviderruns on the client today. The local API already hasPOST /v1/transcribeand/v1/postprocess, but it binds to127.0.0.1and the app can't consume a remote endpoint.Files importing SwiftUI or AppKit hold 58% of the 175,064 lines in
Sources/Fluid.ASRService(8,453 lines) imports AppKit, CoreAudio and FluidAudio.SettingsStore(6,542 lines) imports SwiftUI, AppKit and ServiceManagement. 44 types are CombineObservableObjects.The portable core already exists but has no boundary. About 43k lines import only Foundation or modules with direct cross-platform replacements. That includes
LLMClient, the spoken-punctuation and dictation-literal formatting,PronunciationDictionaryStoreandfv-serve. Full measurements are in the appendix.Goals and non-goals
Goals
Non-goals
Design overview
The cut sits at "audio and context in, events out". Anything that touches the user's session stays in the shell.
macOS and iOS link the engine as a library. The Linux shell and remote clients reach the same engine as a daemon over a socket.
Why capture stays in the shell
Meetings. The shell captures the microphone and system audio as two tracks. Echo cancellation, transcription and summaries run in the engine.
State ownership
Engine delivery
The engine ships as a library first and a daemon second, because iOS can't run a separate engine process and iOS comes first.
EngineClient: the protocol every shell codes against.InProcessEngine: links the library directly. The default on macOS and iOS.RemoteEngine: speaks the wire protocol to a daemon. Used by the Linux shell, LAN clients and [✨ FEATURE] Remote transcription backend with the ability to run the speech-to-text model on a separate machine #615.On macOS, a "serve other devices" setting starts the network host inside the app process. The app keeps a single identity, so there are no new permission prompts.
Inference backends
Apple platforms keep CoreML and FluidAudio, which is where FluidVoice's Parakeet speed comes from. Everywhere else, the engine uses transcribe.cpp, which the app already links for Whisper. Both sit behind the existing
TranscriptionProviderprotocol.transcribe.cpp covers the models FluidVoice ships today: Parakeet TDT v2 and v3, Nemotron 3.5 streaming, Cohere Transcribe and Whisper. It runs on Metal, Vulkan, CUDA, ROCm or CPU, and ships Silero VAD in the same library. Apple Speech stays Apple-only.
One change is needed. Its Swift package is an xcframework
binaryTarget, and upstream marks Linux Swift out of scope. Off Apple platforms, the engine links it as asystemLibrarytarget through its C API, which keeps Swift's C++ interop out of those builds.Protocol and transports
One protobuf schema, one HTTP and WebSocket host, two kinds of listener.
Schema. Messages are defined in protobuf and generated with swift-protobuf for the engine and prost for the Rust shell. One schema keeps the two sides from drifting.
Surfaces
POST /v1/audio/transcriptions, for batch. This is the shape [✨ FEATURE] Remote transcription backend with the ability to run the speech-to-text model on a separate machine #615 asked for. FluidVoice can call other servers that speak it, and other tools can call the FluidVoice engine./v1/health,/v1/transcribe,/v1/postprocess, dictionary, history and meetings move to the engine host unchanged.Context. Session requests carry app identity, window title, selected text and the active prompt set. Per-app prompts key on macOS bundle IDs today. Linux uses desktop file IDs and Windows uses executable names, so per-app config gets an identity layer with per-OS aliases.
Versioning. A handshake exchanges the protocol version and capabilities: available models, streaming support and enhancement providers.
Listeners
$XDG_RUNTIME_DIR(AF_UNIX on Windows).SO_PEERCREDidentifies the caller.Speed isn't the reason for Unix sockets. Round trips for a 640-byte frame (20 ms of 16 kHz mono PCM) and a 64-byte reply, 50,000 each (method in the appendix):
Dictation sends 50 frames a second, so even the slowest option costs under 1 ms per second of speech. WebSocket framing adds about 5 µs, so one protocol stack on every listener is the better trade.
Engine host and platform notes
The host is built directly on SwiftNIO: NIOPosix, NIOHTTP1 and NIOWebSocket. SwiftNIO runs its unit tests on Windows with Swift 6.3, 6.4 and nightlies. Hummingbird, hummingbird-websocket, swift-nio-ssl and AsyncHTTPClient run no Windows CI, so the host avoids them. The routing logic in
LocalAPIRouterports over.Linux (Swift 6.4, released September 2026)
ObservableObjects move to Observation, which ships with Swift on Linux.URLSessionhas nobytes(for:).LLMClientstreams with it in two places, so streaming moves to a delegate-based reader.zeppelin-embedlinks with-force_load, an Apple linker flag. Linux needs--whole-archive.Windows
StreamChannelTeston Windows, so the engine's own suite covers half-close and backpressure over TCP and Unix sockets.Frontends
macOS and iOS keep SwiftUI and the in-process engine. Nothing changes for users.
Linux gets a Qt/QML shell written in Rust with CXX-Qt (KDAB, v0.10.0). The shell talks to the engine only through the protocol, so it never touches Swift.
#[qobject],#[qproperty],#[qinvokable],#[qsignal]) that QML binds to directly. A thread handle queues engine events onto the Qt thread.The same Rust shell builds on Windows if you ever want a public Windows frontend.
Wayland limits. Some macOS features change or drop on Linux:
xdg-openand shell commandsPackaging and distribution
Each platform bundles the engine and a shell, and the engine also ships alone for people who host it elsewhere.
A Flatpak shell must use portal-based text insertion. ydotool needs
/dev/uinput, which the sandbox doesn't grant by default.Migration plan
Six steps, each mergeable on its own, with no user-visible change until step 5.
FluidEngine. A SwiftPM target with no AppKit or SwiftUI dependency, so the compiler enforces the boundary. Move in formatting, dictionary,LLMClient, prompt routing, history and theTranscriptionProviderprotocol.EngineClientandInProcessEngine, and routeASRServicethrough them. State at the boundary moves from Combine to Observation.FluidEnginewith the transcribe.cpp backend on both. This is the step that keeps the engine cross-platform.POST /v1/audio/transcriptions.RemoteEngineto the macOS app. This closes [✨ FEATURE] Remote transcription backend with the ability to run the speech-to-text model on a separate machine #615 and gives Fluid Link a base.Steps 1 to 3 are refactors that also cut the cost of the iOS app.
Risks and open questions
B/*branches). Refactor PRs must stay small and land fast, or they rot.Alternatives considered
run()callsQGuiApplication::exec()with nothing draining Swift's main queue on Linux, so@MainActorcode stalls afterawaitunless patchedAppendix: measurements and references
Codebase.
Sources/Fluidonmain, October 2026, with each file bucketed by its imports:About 63k lines of the first bucket are pure view code (
UI/,Views/,Theme/and the app root). The rest are services and state that import AppKit for a few calls.Transport benchmark. Rust with tokio and tokio-tungstenite on a 2-vCPU cloud VM. Server and client ran on separate OS threads with separate runtimes over real kernel sockets: 5,000 warm-up round trips, then 50,000 measured. A second run matched within about 4 µs. Desktop hardware gives lower absolute numbers with the same ordering.
References
All reactions