Skip to content

Commit 0a8d0dd

Browse files
committed
fix: discover healthy whisper servers by port
1 parent 90e01a5 commit 0a8d0dd

5 files changed

Lines changed: 234 additions & 21 deletions

File tree

app/src/main/java/com/whispertranscriber/data/SettingsStore.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,33 @@ import android.content.Context
44
import androidx.datastore.core.DataStore
55
import androidx.datastore.preferences.core.Preferences
66
import androidx.datastore.preferences.core.edit
7+
import androidx.datastore.preferences.core.intPreferencesKey
78
import androidx.datastore.preferences.core.stringPreferencesKey
89
import androidx.datastore.preferences.preferencesDataStore
910
import kotlinx.coroutines.flow.Flow
1011
import kotlinx.coroutines.flow.map
12+
import com.whispertranscriber.network.WhisperServerDiscovery
1113

1214
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
1315

1416
data class AppSettings(
1517
val whisperServerUrl: String = "",
18+
val whisperServerPort: Int = WhisperServerDiscovery.DEFAULT_PORT,
1619
val audioQuality: String = "medium"
1720
)
1821

1922
class SettingsStore(private val context: Context) {
2023

2124
companion object {
2225
private val KEY_SERVER_URL = stringPreferencesKey("whisper_server_url")
26+
private val KEY_SERVER_PORT = intPreferencesKey("whisper_server_port")
2327
private val KEY_AUDIO_QUALITY = stringPreferencesKey("audio_quality")
2428
}
2529

2630
val settings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
2731
AppSettings(
2832
whisperServerUrl = prefs[KEY_SERVER_URL] ?: AppSettings().whisperServerUrl,
33+
whisperServerPort = prefs[KEY_SERVER_PORT] ?: AppSettings().whisperServerPort,
2934
audioQuality = prefs[KEY_AUDIO_QUALITY] ?: AppSettings().audioQuality
3035
)
3136
}
@@ -34,6 +39,10 @@ class SettingsStore(private val context: Context) {
3439
context.dataStore.edit { it[KEY_SERVER_URL] = url }
3540
}
3641

42+
suspend fun updateServerPort(port: Int) {
43+
context.dataStore.edit { it[KEY_SERVER_PORT] = port }
44+
}
45+
3746
suspend fun updateAudioQuality(quality: String) {
3847
context.dataStore.edit { it[KEY_AUDIO_QUALITY] = quality }
3948
}

app/src/main/java/com/whispertranscriber/network/WhisperServerDiscovery.kt

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.whispertranscriber.network
22

3+
import com.google.gson.JsonParser
34
import kotlinx.coroutines.Dispatchers
45
import kotlinx.coroutines.async
56
import kotlinx.coroutines.awaitAll
@@ -9,29 +10,52 @@ import kotlinx.coroutines.sync.withPermit
910
import kotlinx.coroutines.withContext
1011
import java.net.HttpURLConnection
1112
import java.net.Inet4Address
13+
import java.net.InetSocketAddress
1214
import java.net.NetworkInterface
15+
import java.net.Socket
1316
import java.net.URL
1417

1518
object WhisperServerDiscovery {
16-
private const val DEFAULT_PORT = 8090
19+
const val DEFAULT_PORT = 8090
1720
private const val DEFAULT_TIMEOUT_MS = 350
1821
private const val MAX_PARALLEL_PROBES = 32
1922

2023
suspend fun discover(
2124
port: Int = DEFAULT_PORT,
2225
timeoutMs: Int = DEFAULT_TIMEOUT_MS
23-
): DiscoveredWhisperServer? = withContext(Dispatchers.IO) {
24-
val hosts = candidateHosts(localIpv4Addresses())
26+
): DiscoveredWhisperServer? = discoverAll(port, timeoutMs).firstOrNull()
27+
28+
suspend fun discoverAll(
29+
port: Int = DEFAULT_PORT,
30+
timeoutMs: Int = DEFAULT_TIMEOUT_MS
31+
): List<DiscoveredWhisperServer> {
32+
return discoverFromHosts(
33+
hosts = candidateHosts(localIpv4Addresses()),
34+
ports = listOf(port),
35+
timeoutMs = timeoutMs
36+
)
37+
}
38+
39+
suspend fun discoverFromHosts(
40+
hosts: List<String>,
41+
ports: List<Int>,
42+
timeoutMs: Int = DEFAULT_TIMEOUT_MS
43+
): List<DiscoveredWhisperServer> = withContext(Dispatchers.IO) {
44+
val candidates = hosts.distinct().flatMap { host ->
45+
ports.distinct().mapNotNull { port ->
46+
if (port in 1..65535) host to port else null
47+
}
48+
}
2549
val semaphore = Semaphore(MAX_PARALLEL_PROBES)
2650

2751
coroutineScope {
28-
hosts.map { host ->
52+
candidates.map { (host, port) ->
2953
async {
3054
semaphore.withPermit {
3155
probe(host, port, timeoutMs)
3256
}
3357
}
34-
}.awaitAll().filterNotNull().firstOrNull()
58+
}.awaitAll().filterNotNull().distinctBy { it.url }
3559
}
3660
}
3761

@@ -63,6 +87,8 @@ object WhisperServerDiscovery {
6387
}
6488

6589
private fun probe(host: String, port: Int, timeoutMs: Int): DiscoveredWhisperServer? {
90+
if (!isPortOpen(host, port, timeoutMs)) return null
91+
6692
val url = "http://$host:$port"
6793
val connection = (URL("$url/health").openConnection() as HttpURLConnection).apply {
6894
connectTimeout = timeoutMs
@@ -74,7 +100,7 @@ object WhisperServerDiscovery {
74100
return try {
75101
if (connection.responseCode != 200) return null
76102
val body = connection.inputStream.bufferedReader().use { it.readText() }
77-
if (!body.contains("\"ready\":true") && !body.contains("\"status\":\"ok\"")) return null
103+
if (!isHealthy(body)) return null
78104
DiscoveredWhisperServer(url)
79105
} catch (_: Exception) {
80106
null
@@ -83,6 +109,29 @@ object WhisperServerDiscovery {
83109
}
84110
}
85111

112+
private fun isPortOpen(host: String, port: Int, timeoutMs: Int): Boolean {
113+
return try {
114+
Socket().use { socket ->
115+
socket.connect(InetSocketAddress(host, port), timeoutMs)
116+
}
117+
true
118+
} catch (_: Exception) {
119+
false
120+
}
121+
}
122+
123+
private fun isHealthy(body: String): Boolean {
124+
return try {
125+
val json = JsonParser.parseString(body).asJsonObject
126+
val readyElement = json.get("ready")?.takeUnless { it.isJsonNull }
127+
val ready = readyElement?.asBoolean
128+
val status = json.get("status")?.takeUnless { it.isJsonNull }?.asString.orEmpty()
129+
ready ?: (status.equals("ok", ignoreCase = true) || status.equals("healthy", ignoreCase = true))
130+
} catch (_: Exception) {
131+
false
132+
}
133+
}
134+
86135
private fun parseIpv4(address: String): IntArray? {
87136
val parts = address.split(".")
88137
if (parts.size != 4) return null

app/src/main/java/com/whispertranscriber/service/FloatingOverlayService.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ class FloatingOverlayService : Service() {
189189
updateExpandedViewText()
190190
realtimeInsertionActive = TranscriberAccessibilityService.beginRealtimeText()
191191
val serverUrl = try {
192-
resolveServerUrl(settings.whisperServerUrl)
192+
resolveServerUrl(settings.whisperServerUrl, settings.whisperServerPort)
193193
} catch (e: Exception) {
194194
isRecording = false
195195
activeRecordIcon = null
@@ -255,7 +255,7 @@ class FloatingOverlayService : Service() {
255255
val settings = settingsStore.settings.first()
256256
val startTime = System.currentTimeMillis()
257257
try {
258-
val serverUrl = resolveServerUrl(settings.whisperServerUrl)
258+
val serverUrl = resolveServerUrl(settings.whisperServerUrl, settings.whisperServerPort)
259259
val session = liveKitSession
260260
liveKitSession = null
261261
val result = liveResult?.let {
@@ -342,10 +342,10 @@ class FloatingOverlayService : Service() {
342342
updateExpandedViewText()
343343
}
344344

345-
private suspend fun resolveServerUrl(configuredUrl: String): String {
345+
private suspend fun resolveServerUrl(configuredUrl: String, discoveryPort: Int): String {
346346
if (configuredUrl.isNotBlank()) return configuredUrl
347-
val discovered = WhisperServerDiscovery.discover()
348-
?: throw IllegalStateException("No WhisperLiveKit server found on local networks or Tailscale port 8090")
347+
val discovered = WhisperServerDiscovery.discover(port = discoveryPort)
348+
?: throw IllegalStateException("No WhisperLiveKit server found on local networks or Tailscale port $discoveryPort")
349349
settingsStore.updateServerUrl(discovered.url)
350350
return discovered.url
351351
}

app/src/main/java/com/whispertranscriber/ui/SettingsScreen.kt

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ import androidx.compose.runtime.remember
3232
import androidx.compose.runtime.rememberCoroutineScope
3333
import androidx.compose.runtime.setValue
3434
import androidx.compose.ui.Modifier
35+
import androidx.compose.ui.text.input.KeyboardType
3536
import androidx.compose.ui.unit.dp
3637
import com.whispertranscriber.data.AppSettings
3738
import com.whispertranscriber.data.SettingsStore
3839
import com.whispertranscriber.network.WhisperServerDiscovery
40+
import androidx.compose.foundation.text.KeyboardOptions
3941
import kotlinx.coroutines.Job
4042
import kotlinx.coroutines.delay
4143
import kotlinx.coroutines.launch
@@ -51,18 +53,33 @@ fun SettingsScreen(
5153

5254
// Local state for the text field, seeded once from persisted value
5355
var serverUrl by remember { mutableStateOf<String?>(null) }
56+
var serverPortText by remember { mutableStateOf<String?>(null) }
5457
var debounceJob by remember { mutableStateOf<Job?>(null) }
55-
var discoveryStatus by remember { mutableStateOf("Leave blank to auto-discover port 8090.") }
58+
var portDebounceJob by remember { mutableStateOf<Job?>(null) }
59+
var discoveryStatus by remember { mutableStateOf("Leave blank to auto-discover healthy WhisperLiveKit servers.") }
5660
var discovering by remember { mutableStateOf(false) }
61+
var discoveredServers by remember { mutableStateOf<List<String>>(emptyList()) }
62+
var serverDropdownExpanded by remember { mutableStateOf(false) }
5763

5864
// Seed local state from DataStore only on first real emission
5965
LaunchedEffect(settings.whisperServerUrl) {
6066
if (serverUrl == null) {
6167
serverUrl = settings.whisperServerUrl
6268
}
6369
}
70+
LaunchedEffect(settings.whisperServerPort) {
71+
if (serverPortText == null) {
72+
serverPortText = settings.whisperServerPort.toString()
73+
}
74+
}
6475

6576
val displayUrl = serverUrl ?: settings.whisperServerUrl
77+
val displayPort = serverPortText ?: settings.whisperServerPort.toString()
78+
val selectedServerOption = if (displayUrl.isNotBlank() && displayUrl in discoveredServers) {
79+
displayUrl
80+
} else {
81+
"Custom"
82+
}
6683

6784
Scaffold(
6885
topBar = {
@@ -88,6 +105,48 @@ fun SettingsScreen(
88105
Text("WhisperLiveKit Server", style = MaterialTheme.typography.titleMedium)
89106
Spacer(Modifier.height(8.dp))
90107

108+
ExposedDropdownMenuBox(
109+
expanded = serverDropdownExpanded,
110+
onExpandedChange = { serverDropdownExpanded = it }
111+
) {
112+
OutlinedTextField(
113+
value = selectedServerOption,
114+
onValueChange = {},
115+
readOnly = true,
116+
label = { Text("Discovered Servers") },
117+
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = serverDropdownExpanded) },
118+
modifier = Modifier
119+
.fillMaxWidth()
120+
.menuAnchor()
121+
)
122+
ExposedDropdownMenu(
123+
expanded = serverDropdownExpanded,
124+
onDismissRequest = { serverDropdownExpanded = false }
125+
) {
126+
DropdownMenuItem(
127+
text = { Text("Custom") },
128+
onClick = {
129+
if (selectedServerOption != "Custom") {
130+
serverUrl = ""
131+
scope.launch { settingsStore.updateServerUrl("") }
132+
}
133+
serverDropdownExpanded = false
134+
}
135+
)
136+
discoveredServers.forEach { discoveredUrl ->
137+
DropdownMenuItem(
138+
text = { Text(discoveredUrl) },
139+
onClick = {
140+
serverUrl = discoveredUrl
141+
scope.launch { settingsStore.updateServerUrl(discoveredUrl) }
142+
serverDropdownExpanded = false
143+
}
144+
)
145+
}
146+
}
147+
}
148+
149+
Spacer(Modifier.height(8.dp))
91150
OutlinedTextField(
92151
value = displayUrl,
93152
onValueChange = { newValue ->
@@ -98,25 +157,52 @@ fun SettingsScreen(
98157
settingsStore.updateServerUrl(newValue)
99158
}
100159
},
101-
label = { Text("Server URL") },
102-
placeholder = { Text("Auto-discover http://*:8090") },
160+
label = { Text("Custom Server URL") },
161+
placeholder = { Text("Leave blank to auto-discover") },
162+
modifier = Modifier.fillMaxWidth(),
163+
singleLine = true
164+
)
165+
166+
Spacer(Modifier.height(8.dp))
167+
OutlinedTextField(
168+
value = displayPort,
169+
onValueChange = { rawValue ->
170+
val newValue = rawValue.filter { it.isDigit() }.take(5)
171+
serverPortText = newValue
172+
portDebounceJob?.cancel()
173+
val port = newValue.toIntOrNull()
174+
if (port != null && port in 1..65535) {
175+
portDebounceJob = scope.launch {
176+
delay(500)
177+
settingsStore.updateServerPort(port)
178+
}
179+
}
180+
},
181+
label = { Text("Discovery Port") },
182+
placeholder = { Text(WhisperServerDiscovery.DEFAULT_PORT.toString()) },
103183
modifier = Modifier.fillMaxWidth(),
184+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
104185
singleLine = true
105186
)
106187

107188
Spacer(Modifier.height(8.dp))
108189
Button(
109190
onClick = {
191+
val port = displayPort.toIntOrNull()?.takeIf { it in 1..65535 }
192+
?: WhisperServerDiscovery.DEFAULT_PORT
110193
discovering = true
111-
discoveryStatus = "Scanning local networks and Tailscale..."
194+
discoveryStatus = "Scanning local networks and Tailscale on port $port..."
112195
scope.launch {
113-
val discovered = WhisperServerDiscovery.discover()
114-
if (discovered == null) {
115-
discoveryStatus = "No WhisperLiveKit server found on port 8090."
196+
settingsStore.updateServerPort(port)
197+
val discovered = WhisperServerDiscovery.discoverAll(port = port)
198+
discoveredServers = discovered.map { it.url }
199+
if (discoveredServers.isEmpty()) {
200+
discoveryStatus = "No healthy WhisperLiveKit server found on port $port."
116201
} else {
117-
serverUrl = discovered.url
118-
settingsStore.updateServerUrl(discovered.url)
119-
discoveryStatus = "Found ${discovered.url}"
202+
val selectedUrl = discoveredServers.first()
203+
serverUrl = selectedUrl
204+
settingsStore.updateServerUrl(selectedUrl)
205+
discoveryStatus = "Found ${discoveredServers.size} server(s)."
120206
}
121207
discovering = false
122208
}

0 commit comments

Comments
 (0)