Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,28 @@ Whisper supports up to 99 languages, depending on the model size you choose.

---

## Local API (for on-device agents)

FluidVoice can expose a **loopback-only** HTTP API so other apps and AI agents running **on the same Mac** can reuse its transcription and post-processing — instead of bundling and loading their own ASR model. One model stays warm in FluidVoice; agents get FluidVoice-quality transcription with no extra memory footprint.

**Enable it:** `Settings → Local API (on-device agents)`. It is **off by default** and **refuses all non-loopback connections** — any non-local peer is rejected at accept time, before it can send a request — so the API is usable only from processes on this Mac.

- Base URL: `http://127.0.0.1:47733` (port configurable via the `LocalAPIPort` preference)
- `GET /v1/health` — liveness check
- `POST /v1/transcribe` — transcribe an audio file → `{ text, confidence, sampleCount, provider }`
- `POST /v1/postprocess` — run text post-processing/enhancement

```bash
curl -s http://127.0.0.1:47733/v1/transcribe \
-H 'Content-Type: application/json' \
-d '{"path": "/absolute/path/to/audio.ogg"}'
# → {"text":"…","confidence":0.97,"sampleCount":…,"provider":"Parakeet TDT v3 (Multilingual)"}
```

Audio can also be sent inline as `{"audioBase64": "…", "filename": "clip.wav"}`. Limits: **25 MB** request body and **300 s (5 min)** of audio per transcription (independent caps). Note: `/v1/transcribe` runs on-device, but `/v1/postprocess` uses your configured AI provider (Settings → AI Enhancement), which may be remote. Full reference: [docs/local-api.md](docs/local-api.md).

---

## Requirements

- macOS 15.0 (Sequoia) or later
Expand Down
10 changes: 10 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1799,6 +1799,15 @@ final class SettingsStore: ObservableObject {
set { self.defaults.set(newValue, forKey: Keys.copyTranscriptionToClipboard) }
}

/// When enabled, the loopback-only Local API server (`LocalAPIServer`) is started so
/// on-device agents can reuse FluidVoice's transcription (POST /v1/transcribe) instead
/// of shipping their own ASR model. Off by default. Uses the same "LocalAPIEnabled"
/// key that `LocalAPI.Configuration` reads.
var localAPIEnabled: Bool {
get { self.defaults.bool(forKey: Keys.localAPIEnabled) }
set { self.defaults.set(newValue, forKey: Keys.localAPIEnabled) }
}

var preferredInputDeviceUID: String? {
get { self.defaults.string(forKey: Keys.preferredInputDeviceUID) }
set { self.defaults.set(newValue, forKey: Keys.preferredInputDeviceUID) }
Expand Down Expand Up @@ -4915,6 +4924,7 @@ private extension SettingsStore {
static let experimentalDirectAudioCaptureForcedOff = "ExperimentalDirectAudioCaptureForcedOff"
static let directAudioCaptureConsecutiveFailures = "DirectAudioCaptureConsecutiveFailures"
static let copyTranscriptionToClipboard = "CopyTranscriptionToClipboard"
static let localAPIEnabled = "LocalAPIEnabled"
static let textInsertionMode = "TextInsertionMode"
static let autoUpdateCheckEnabled = "AutoUpdateCheckEnabled"
static let betaReleasesEnabled = "BetaReleasesEnabled"
Expand Down
18 changes: 17 additions & 1 deletion Sources/Fluid/Services/LocalAPI/LocalAPIServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,13 @@ final class LocalAPIServer {
handler.start(on: self.queue)
}
}
listener.stateUpdateHandler = { state in
listener.stateUpdateHandler = { [weak self, weak listener] state in
Task { @MainActor in
// Ignore callbacks from a superseded listener. A rapid off→on toggle
// (now user-reachable via refresh()) can install a new listener before
// an old one's async .cancelled/.failed callback reaches the main actor;
// without this guard that stale callback would clear/cancel the new one.
guard let self, let listener, self.listener === listener else { return }
self.handleState(state)
}
}
Expand All @@ -79,6 +84,17 @@ final class LocalAPIServer {
self.listener = nil
}

/// Start or stop the server to match the current `LocalAPIEnabled` setting.
/// Call this after the user toggles the Local API preference so the change takes
/// effect immediately, without restarting the app.
func refresh() {
if LocalAPI.Configuration.current.enabled {
self.start()
} else {
self.stop()
Comment on lines +93 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel connections queued before disabling the API

When a connection has passed newConnectionHandler but its Task { @MainActor ... } has not executed yet, this call to stop() cannot see it in activeConnections. The queued task then creates and starts the handler without checking the enabled setting or its originating listener, allowing that connection to continue serving requests after the user switches the API off. Associate connection callbacks with the listener generation and cancel them when that listener is no longer current.

Useful? React with 👍 / 👎.

}
Comment on lines +90 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore callbacks from superseded listeners

If the toggle is switched off and then back on before the cancelled listener's asynchronous state callback reaches the main actor, start() installs a new listener but the old listener's .cancelled callback subsequently executes handleState and clears self.listener. The restarted listener is then orphaned, so a later off toggle cannot cancel it and another on toggle may attempt to open a duplicate port. Associate state callbacks with their originating listener and only clear the property when they still match.

Useful? React with 👍 / 👎.

Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

private func handleState(_ state: NWListener.State) {
switch state {
case .ready:
Expand Down
15 changes: 15 additions & 0 deletions Sources/Fluid/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,21 @@ struct SettingsView: View {
)
Divider().opacity(0.2)

// Local API (on-device agents)
self.settingsToggleRow(
title: "Local API (on-device agents)",
description: "Loopback-only HTTP API for on-device agents to reuse transcription (POST /v1/transcribe).",
footnote: "127.0.0.1:47733, off by default. /v1/postprocess may use a remote AI provider.",
isOn: Binding(
get: { SettingsStore.shared.localAPIEnabled },
set: { newValue in
SettingsStore.shared.localAPIEnabled = newValue
LocalAPIServer.shared.refresh()
}
)
)
Divider().opacity(0.2)

// Accent Color
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center) {
Expand Down
104 changes: 104 additions & 0 deletions docs/local-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Local API

FluidVoice ships a small, **loopback-only** HTTP API that lets other apps and AI agents
running **on the same Mac** reuse its speech-to-text and text post-processing. This avoids
each agent bundling and loading its own ASR model — FluidVoice keeps one model warm and
serves transcription requests locally.

## Enabling

The API is **off by default**. Turn it on in `Settings → Local API (on-device agents)`.

You can also toggle it via `defaults`:

```bash
defaults write com.FluidApp.app LocalAPIEnabled -bool true
Comment on lines +12 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge State that defaults changes require a relaunch

When FluidVoice is already running, this command only changes the preference: LocalAPIServer.refresh() is invoked exclusively by the Settings toggle, and the lifecycle calls start() only at launch. Consequently, enabling via defaults does not start the listener, and disabling via defaults leaves the API serving until the app is relaunched. Either observe these preference changes or tell command-line users to restart FluidVoice.

Useful? React with 👍 / 👎.

# optional custom port (default 47733):
defaults write com.FluidApp.app LocalAPIPort -int 47733
```

## Security

- The listener **refuses all non-loopback connections** — any non-local peer is rejected at
accept time, before it can send a request — so the API is usable only from processes on this Mac.
- It is opt-in and off by default.
- Limits: request body **25 MB**; transcription audio **300 s (5 min)** per request (longer audio is
rejected). These caps are independent — compressed audio can satisfy the byte limit yet still hit the
duration limit.
Comment on lines +25 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject overlong inline audio before promising a hard limit

For the documented audioBase64 input (and raw audio bodies), decodeAudioSamples reaches LocalAPIAudioDecoder.samples(from:), which reads min(file.length, maxFrames) and silently transcribes only the first five minutes. Only the path-based flow calls validateDurationWithinLimit and rejects overlong files. Clients relying on this statement can therefore receive a successful but incomplete transcript; either validate inline inputs too or document that they are truncated.

Useful? React with 👍 / 👎.

- **Locality caveat:** `/v1/transcribe` runs fully on-device. `/v1/postprocess` runs your configured
post-processing provider (Settings → AI Enhancement); if that provider is a remote API
(OpenAI/Groq/Anthropic/…), the submitted text is sent there. Only the HTTP listener and transcription
are guaranteed local.

## Endpoints

Base URL: `http://127.0.0.1:47733`

### `GET /v1/health`

Liveness probe. Returns `200 OK` when the server is running.

### `POST /v1/transcribe`

Transcribe an audio file with the currently selected speech model (reuses the already-loaded
model — no second instance).

Request body (JSON), one of:

```jsonc
{ "path": "/absolute/path/to/audio.ogg" } // transcribe a file by path
{ "audioBase64": "<base64>", "filename": "clip.wav" } // or inline audio (extension hint)
```

Supported inputs include WAV, MP3, M4A, OGG and other common formats (decoded internally).

Response:

```json
{
"text": "the transcribed text",
"confidence": 0.97,
"sampleCount": 1402136,
"provider": "Parakeet TDT v3 (Multilingual)"
}
```

### `POST /v1/postprocess`

Run FluidVoice's text post-processing/enhancement on a string.

```json
{ "text": "raw text to clean up" }
```

Response:

```json
{ "text": "cleaned text", "provider": "…", "model": "…" }
```

## Example integrations

Shell:

```bash
curl -s http://127.0.0.1:47733/v1/transcribe \
-H 'Content-Type: application/json' \
-d '{"path": "'"$PWD"'/memo.ogg"}' | jq -r .text
```

Python:

```python
import requests
r = requests.post("http://127.0.0.1:47733/v1/transcribe",
json={"path": "/tmp/memo.ogg"}, timeout=90)
print(r.json()["text"])
```

## Notes

- If the selected model is not resident yet, the first request loads it; subsequent requests
are fast while it stays warm.
- Other endpoints exist for history and the custom dictionary (`GET /v1/history`,
`GET|POST /v1/dictionary/*`); this document focuses on the ones most useful to agents.
Loading