Skip to content

Commit 942c02f

Browse files
DatafyingTechclaude
andcommitted
Pre-warm the cleanup model when the phone starts recording
The PC unloads its cleanup model after idling to free the GPU, and reloading takes five to nine seconds. The desktop hides that behind the hotkey key-down, but the phone gave the PC no signal until the audio arrived, so the first dictation after a pause paid the whole reload. The phone API gains POST /v1/warm. It asks Ollama whether the model is resident rather than trusting the idle timer, and starts the reload in the background if it is not. The Android app calls it the moment recording starts and, rate-limited, when a text field gains focus. Measured: a dictation sent four seconds after warming waited about one second instead of seven. The default keep-alive rises from 10 to 30 minutes. LocalFlow 0.2.1; Android 0.1.3 (versionCode 4). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 61c4329 commit 942c02f

15 files changed

Lines changed: 241 additions & 10 deletions

File tree

.github/ISSUE_TEMPLATE/bug_report.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ body:
8888
attributes:
8989
label: LocalFlow version
9090
description: "`python -m localflow --version`, or the commit you are on."
91-
placeholder: "0.2.0"
91+
placeholder: "0.2.1"
9292
validations:
9393
required: true
9494

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to LocalFlow are documented here. The format follows
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
55
[Semantic Versioning](https://semver.org/).
66

7+
## [0.2.1] - 2026-09-12
8+
9+
### Added
10+
11+
- `POST /v1/warm` on the phone API. The PC unloads its cleanup model after idling and reloading
12+
it takes several seconds; the desktop hides that behind the hotkey key-down, but a phone gave
13+
the PC no such signal, so the first dictation after a pause waited 4 to 9 s. The phone app
14+
(0.1.3) now calls this the moment recording starts and when a text field gains focus, so the
15+
reload overlaps with speaking. Contract in `docs/API.md`.
16+
717
## [0.2.0] - 2026-09-11
818

919
### Added

android/app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ android {
2727
applicationId = "tech.datafying.localflow"
2828
minSdk = 26
2929
targetSdk = 35
30-
versionCode = 3
31-
versionName = "0.1.2"
30+
versionCode = 4
31+
versionName = "0.1.3"
3232
}
3333

3434
signingConfigs {

android/app/src/main/kotlin/tech/datafying/localflow/ApiClient.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class ApiClient(private val settings: Settings) {
5252
private const val CONNECT_S = 5L
5353
private const val READ_PTT_S = 30L
5454
private const val READ_HANDSFREE_S = 120L
55+
private const val WARM_CONNECT_S = 2L
56+
private const val WARM_READ_S = 3L
5557
private val WAV = "audio/wav".toMediaType()
5658

5759
const val MSG_URL_BLANK = "No PC address set. Paste the setup line from your PC."
@@ -144,6 +146,32 @@ class ApiClient(private val settings: Settings) {
144146
)
145147
}
146148

149+
/**
150+
* POST /v1/warm: ask the PC to start reloading its cleanup model now, so the reload hides
151+
* behind the user speaking instead of delaying the first result after an idle spell.
152+
* Fire-and-forget: short timeouts, and every failure (including a missing endpoint on an
153+
* older PC build) is swallowed. Never throws, never changes any state.
154+
*/
155+
fun warm() {
156+
try {
157+
val url = baseUrl().newBuilder().addPathSegments("v1/warm").build()
158+
val req = Request.Builder()
159+
.url(url)
160+
.header("Authorization", "Bearer ${settings.token}")
161+
.header("User-Agent", userAgent())
162+
.post(ByteArray(0).toRequestBody(null))
163+
.build()
164+
val c = client.newBuilder()
165+
.connectTimeout(WARM_CONNECT_S, TimeUnit.SECONDS)
166+
.readTimeout(WARM_READ_S, TimeUnit.SECONDS)
167+
.writeTimeout(WARM_READ_S, TimeUnit.SECONDS)
168+
.build()
169+
c.newCall(req).execute().close()
170+
} catch (ignored: Exception) {
171+
// Best effort only; the dictation call reports real problems.
172+
}
173+
}
174+
147175
private fun <T> execute(c: OkHttpClient, req: Request, handle: (Int, String) -> T): T {
148176
try {
149177
c.newCall(req).execute().use { resp ->

android/app/src/main/kotlin/tech/datafying/localflow/OverlayService.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import android.os.Build
2222
import android.os.Handler
2323
import android.os.IBinder
2424
import android.os.Looper
25+
import android.os.SystemClock
2526
import android.os.VibrationEffect
2627
import android.os.Vibrator
2728
import android.os.VibratorManager
@@ -62,6 +63,8 @@ class OverlayService : Service(), View.OnTouchListener {
6263
private const val ERROR_MS = 2500L
6364
/** Focus hops between fields (and keyboard show/hide) within this window do not blink the dot. */
6465
private const val HIDE_DEBOUNCE_MS = 350L
66+
/** Minimum gap between focus-triggered /v1/warm calls (recording start is not limited). */
67+
private const val FOCUS_WARM_INTERVAL_MS = 60_000L
6568

6669
@Volatile
6770
var isRunning: Boolean = false
@@ -345,8 +348,30 @@ class OverlayService : Service(), View.OnTouchListener {
345348

346349
// ------------------------------------------------------------------ visibility
347350
private fun onCanType(v: Boolean) {
351+
val gainedFocus = v && !canType
348352
canType = v
349353
reevaluateVisibility()
354+
// A text field just gained focus: the user is likely about to dictate, so nudge the PC
355+
// to reload its cleanup model now (rate-limited; harmless when already loaded).
356+
if (gainedFocus) warmPc(force = false)
357+
}
358+
359+
// ------------------------------------------------------------------ pre-warm
360+
private var lastFocusWarmMs = 0L
361+
362+
/**
363+
* Fire-and-forget POST /v1/warm on the network thread. [force] skips the 60 s rate limit
364+
* (used when recording actually starts). Silent when the app is not connected yet.
365+
*/
366+
private fun warmPc(force: Boolean) {
367+
if (settings.serverUrl.isBlank() || settings.token.isBlank()) return
368+
if (!force) {
369+
val now = SystemClock.elapsedRealtime()
370+
if (now - lastFocusWarmMs < FOCUS_WARM_INTERVAL_MS) return
371+
lastFocusWarmMs = now
372+
}
373+
if (!::api.isInitialized) return
374+
worker.execute { api.warm() }
350375
}
351376

352377
private fun wantVisible(): Boolean = DotVisibility.decide(
@@ -475,6 +500,7 @@ class OverlayService : Service(), View.OnTouchListener {
475500
)
476501
return
477502
}
503+
warmPc(force = true) // hide the PC's model reload behind the user speaking
478504
ensureMicType()
479505
val rec = Recorder(
480506
maxMillis = HANDSFREE_MAX_MS,

config.example.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ llm:
140140
segment_words: 400 # a long text is cleaned in sentence-aligned segments of about this
141141
# many words, then joined. Lower it if the model is short on
142142
# context; a segment that fails only affects itself.
143-
keep_alive: 600 # seconds the model stays in VRAM after a call (-1 = forever)
143+
keep_alive: 1800 # seconds the model stays in VRAM after a call (-1 = forever)
144144
temperature: 0
145145

146146
# ---------------------------------------------------------------- how text is inserted

docs/ANDROID.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ dot only when a text field is active* in the app; the change applies immediately
150150
Every state change gives a small haptic tick (switch it off in the app). Sounds are off by
151151
default.
152152

153+
**First dictation after a pause.** The PC unloads its cleanup model after about 10 idle minutes
154+
to free the GPU, so the first dictation after a pause can take a few extra seconds while it
155+
reloads. The app now pre-warms the PC when you tap into a text field and again the moment you
156+
start recording, so you will rarely notice. If it still happens and the ring turns red, hold to
157+
talk again while the ring is red and it retries.
158+
153159
Between two hands-free results the phone inserts a single space unless the text already ends in
154160
a space or a newline. After push-to-talk it never adds trailing whitespace, so you can keep typing.
155161

docs/API.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Both are built against it. Change it here first.
1616
| Reached at | `http://<pc-name>.<tailnet>.ts.net` via `tailscale serve`, which proxies port 80 to 8770 |
1717
| Example | `http://localflow-pc.tail1234.ts.net` (your own tailnet name; `tailscale status` on the PC shows it) |
1818
| Enabled by | `server.enabled: true` in `config.yaml` (off by default) |
19-
| Auth | `Authorization: Bearer <server.token>` on every `/v1/*` call except `/v1/health` |
19+
| Auth | `Authorization: Bearer <server.token>` on `/v1/dictate` and `/v1/warm`; `/v1/health` is open |
2020

2121
The token is generated on first enable, stored in `config.yaml`, and shown in the tray menu under
2222
**Phone setup**. Use the MagicDNS name, not the raw 100.x IP: `tailscale serve` routes by
@@ -45,6 +45,20 @@ No auth. For the phone's "Test connection" button and for the status page.
4545

4646
`ready` is false while models are still loading; the phone should show "PC is warming up" and retry.
4747

48+
### `POST /v1/warm`
49+
Auth required, empty body. Call it the moment recording starts. The PC unloads its cleanup model
50+
after idling (`llm.keep_alive`, 10 minutes by default) and reloading takes several seconds; this
51+
starts that reload in the background so it overlaps with the user speaking, exactly what the
52+
desktop hotkey does on key-down. Returns immediately. Cheap and harmless when the model is already
53+
loaded, so the phone may also call it when a text field gains focus.
54+
55+
```json
56+
{ "warming": true, "llm_loaded": false, "ready": true }
57+
```
58+
59+
`llm_loaded` is the state before this call. Errors: 401 bad token; 503 while models are loading
60+
or the PC is paused.
61+
4862
### `POST /v1/dictate`
4963
Auth required. Send audio, get text.
5064

localflow/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""LocalFlow: fully local push-to-talk dictation for Windows 11."""
22

3-
__version__ = "0.2.0"
3+
__version__ = "0.2.1"

localflow/__main__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,8 @@ def start_server(self) -> None:
759759
self._ensure_token()
760760
try:
761761
self.server = DictationServer(
762-
scfg, pipeline=self.dictate_remote, status=self.server_status, lock=self._pipe_lock,
762+
scfg, pipeline=self.dictate_remote, status=self.server_status, warm=self.warm_remote,
763+
lock=self._pipe_lock,
763764
max_seconds=float(self.cfg["audio"].get("max_seconds", 1200)),
764765
)
765766
self.server.start()
@@ -768,6 +769,23 @@ def start_server(self) -> None:
768769
self.server = None
769770
self.tray.notify(f"Phone access could not start: {e}", "LocalFlow")
770771

772+
def warm_remote(self) -> dict:
773+
"""The phone started recording: reload the cleanup model in the background if it was
774+
unloaded after idling, exactly as the desktop does on hotkey key-down."""
775+
loaded = True
776+
if self.llm is not None and self.llm_ok:
777+
try:
778+
level = self.cfg["cleanup"].get("handsfree_level", "high")
779+
resident = self.llm.is_loaded() # ask Ollama; the idle timer can be wrong
780+
loaded = bool(resident) if resident is not None else not self.llm.needs_warm()
781+
if not loaded:
782+
self.llm.warm_now(level=level)
783+
else:
784+
self.llm.warm_if_idle(level=level)
785+
except Exception as e: # noqa: BLE001
786+
log.debug("warm_remote: %s", e)
787+
return {"llm_loaded": loaded}
788+
771789
def stop_server(self) -> None:
772790
srv, self.server = self.server, None
773791
if srv is not None:

0 commit comments

Comments
 (0)