Skip to content

Commit fa580e3

Browse files
committed
refactor: split android talk voice resolution
1 parent 371c53b commit fa580e3

File tree

3 files changed

+236
-79
lines changed

3 files changed

+236
-79
lines changed

apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt

Lines changed: 26 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -813,7 +813,7 @@ class TalkModeManager(
813813
_lastAssistantText.value = cleaned
814814

815815
val requestedVoice = directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() }
816-
val resolvedVoice = resolveVoiceAlias(requestedVoice)
816+
val resolvedVoice = TalkModeVoiceResolver.resolveVoiceAlias(requestedVoice, voiceAliases)
817817
if (requestedVoice != null && resolvedVoice == null) {
818818
Log.w(tag, "unknown voice alias: $requestedVoice")
819819
}
@@ -836,12 +836,35 @@ class TalkModeManager(
836836
apiKey?.trim()?.takeIf { it.isNotEmpty() }
837837
?: System.getenv("ELEVENLABS_API_KEY")?.trim()
838838
val preferredVoice = resolvedVoice ?: currentVoiceId ?: defaultVoiceId
839-
val voiceId =
839+
val resolvedPlaybackVoice =
840840
if (!apiKey.isNullOrEmpty()) {
841-
resolveVoiceId(preferredVoice, apiKey)
841+
try {
842+
TalkModeVoiceResolver.resolveVoiceId(
843+
preferred = preferredVoice,
844+
fallbackVoiceId = fallbackVoiceId,
845+
defaultVoiceId = defaultVoiceId,
846+
currentVoiceId = currentVoiceId,
847+
voiceOverrideActive = voiceOverrideActive,
848+
listVoices = { TalkModeVoiceResolver.listVoices(apiKey, json) },
849+
)
850+
} catch (err: Throwable) {
851+
Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}")
852+
null
853+
}
842854
} else {
843855
null
844856
}
857+
resolvedPlaybackVoice?.let { resolved ->
858+
fallbackVoiceId = resolved.fallbackVoiceId
859+
defaultVoiceId = resolved.defaultVoiceId
860+
currentVoiceId = resolved.currentVoiceId
861+
resolved.selectedVoiceName?.let { name ->
862+
resolved.voiceId?.let { voiceId ->
863+
Log.d(tag, "default voice selected $name ($voiceId)")
864+
}
865+
}
866+
}
867+
val voiceId = resolvedPlaybackVoice?.voiceId
845868

846869
_statusText.value = "Speaking…"
847870
_isSpeaking.value = true
@@ -1703,82 +1726,6 @@ class TalkModeManager(
17031726
}
17041727
}
17051728

1706-
private fun resolveVoiceAlias(value: String?): String? {
1707-
val trimmed = value?.trim().orEmpty()
1708-
if (trimmed.isEmpty()) return null
1709-
val normalized = normalizeAliasKey(trimmed)
1710-
voiceAliases[normalized]?.let { return it }
1711-
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
1712-
return if (isLikelyVoiceId(trimmed)) trimmed else null
1713-
}
1714-
1715-
private suspend fun resolveVoiceId(preferred: String?, apiKey: String): String? {
1716-
val trimmed = preferred?.trim().orEmpty()
1717-
if (trimmed.isNotEmpty()) {
1718-
val resolved = resolveVoiceAlias(trimmed)
1719-
// If it resolves as an alias, use the alias target.
1720-
// Otherwise treat it as a direct voice ID (e.g. "21m00Tcm4TlvDq8ikWAM").
1721-
return resolved ?: trimmed
1722-
}
1723-
fallbackVoiceId?.let { return it }
1724-
1725-
return try {
1726-
val voices = listVoices(apiKey)
1727-
val first = voices.firstOrNull() ?: return null
1728-
fallbackVoiceId = first.voiceId
1729-
if (defaultVoiceId.isNullOrBlank()) {
1730-
defaultVoiceId = first.voiceId
1731-
}
1732-
if (!voiceOverrideActive) {
1733-
currentVoiceId = first.voiceId
1734-
}
1735-
val name = first.name ?: "unknown"
1736-
Log.d(tag, "default voice selected $name (${first.voiceId})")
1737-
first.voiceId
1738-
} catch (err: Throwable) {
1739-
Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}")
1740-
null
1741-
}
1742-
}
1743-
1744-
private suspend fun listVoices(apiKey: String): List<ElevenLabsVoice> {
1745-
return withContext(Dispatchers.IO) {
1746-
val url = URL("https://api.elevenlabs.io/v1/voices")
1747-
val conn = url.openConnection() as HttpURLConnection
1748-
conn.requestMethod = "GET"
1749-
conn.connectTimeout = 15_000
1750-
conn.readTimeout = 15_000
1751-
conn.setRequestProperty("xi-api-key", apiKey)
1752-
1753-
val code = conn.responseCode
1754-
val stream = if (code >= 400) conn.errorStream else conn.inputStream
1755-
val data = stream.readBytes()
1756-
if (code >= 400) {
1757-
val message = data.toString(Charsets.UTF_8)
1758-
throw IllegalStateException("ElevenLabs voices failed: $code $message")
1759-
}
1760-
1761-
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
1762-
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
1763-
voices.mapNotNull { entry ->
1764-
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
1765-
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
1766-
val name = obj["name"].asStringOrNull()
1767-
ElevenLabsVoice(voiceId, name)
1768-
}
1769-
}
1770-
}
1771-
1772-
private fun isLikelyVoiceId(value: String): Boolean {
1773-
if (value.length < 10) return false
1774-
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
1775-
}
1776-
1777-
private fun normalizeAliasKey(value: String): String =
1778-
value.trim().lowercase()
1779-
1780-
private data class ElevenLabsVoice(val voiceId: String, val name: String?)
1781-
17821729
private val listener =
17831730
object : RecognitionListener {
17841731
override fun onReadyForSpeech(params: Bundle?) {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package ai.openclaw.app.voice
2+
3+
import java.net.HttpURLConnection
4+
import java.net.URL
5+
import kotlinx.coroutines.Dispatchers
6+
import kotlinx.coroutines.withContext
7+
import kotlinx.serialization.json.Json
8+
import kotlinx.serialization.json.JsonArray
9+
import kotlinx.serialization.json.JsonElement
10+
import kotlinx.serialization.json.JsonObject
11+
import kotlinx.serialization.json.JsonPrimitive
12+
13+
internal data class ElevenLabsVoice(val voiceId: String, val name: String?)
14+
15+
internal data class TalkModeResolvedVoice(
16+
val voiceId: String?,
17+
val fallbackVoiceId: String?,
18+
val defaultVoiceId: String?,
19+
val currentVoiceId: String?,
20+
val selectedVoiceName: String? = null,
21+
)
22+
23+
internal object TalkModeVoiceResolver {
24+
fun resolveVoiceAlias(value: String?, voiceAliases: Map<String, String>): String? {
25+
val trimmed = value?.trim().orEmpty()
26+
if (trimmed.isEmpty()) return null
27+
val normalized = normalizeAliasKey(trimmed)
28+
voiceAliases[normalized]?.let { return it }
29+
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
30+
return if (isLikelyVoiceId(trimmed)) trimmed else null
31+
}
32+
33+
suspend fun resolveVoiceId(
34+
preferred: String?,
35+
fallbackVoiceId: String?,
36+
defaultVoiceId: String?,
37+
currentVoiceId: String?,
38+
voiceOverrideActive: Boolean,
39+
listVoices: suspend () -> List<ElevenLabsVoice>,
40+
): TalkModeResolvedVoice {
41+
val trimmed = preferred?.trim().orEmpty()
42+
if (trimmed.isNotEmpty()) {
43+
return TalkModeResolvedVoice(
44+
voiceId = trimmed,
45+
fallbackVoiceId = fallbackVoiceId,
46+
defaultVoiceId = defaultVoiceId,
47+
currentVoiceId = currentVoiceId,
48+
)
49+
}
50+
if (!fallbackVoiceId.isNullOrBlank()) {
51+
return TalkModeResolvedVoice(
52+
voiceId = fallbackVoiceId,
53+
fallbackVoiceId = fallbackVoiceId,
54+
defaultVoiceId = defaultVoiceId,
55+
currentVoiceId = currentVoiceId,
56+
)
57+
}
58+
59+
val first = listVoices().firstOrNull()
60+
if (first == null) {
61+
return TalkModeResolvedVoice(
62+
voiceId = null,
63+
fallbackVoiceId = fallbackVoiceId,
64+
defaultVoiceId = defaultVoiceId,
65+
currentVoiceId = currentVoiceId,
66+
)
67+
}
68+
69+
return TalkModeResolvedVoice(
70+
voiceId = first.voiceId,
71+
fallbackVoiceId = first.voiceId,
72+
defaultVoiceId = if (defaultVoiceId.isNullOrBlank()) first.voiceId else defaultVoiceId,
73+
currentVoiceId = if (voiceOverrideActive) currentVoiceId else first.voiceId,
74+
selectedVoiceName = first.name,
75+
)
76+
}
77+
78+
suspend fun listVoices(apiKey: String, json: Json): List<ElevenLabsVoice> {
79+
return withContext(Dispatchers.IO) {
80+
val url = URL("https://api.elevenlabs.io/v1/voices")
81+
val conn = url.openConnection() as HttpURLConnection
82+
conn.requestMethod = "GET"
83+
conn.connectTimeout = 15_000
84+
conn.readTimeout = 15_000
85+
conn.setRequestProperty("xi-api-key", apiKey)
86+
87+
val code = conn.responseCode
88+
val stream = if (code >= 400) conn.errorStream else conn.inputStream
89+
val data = stream.readBytes()
90+
if (code >= 400) {
91+
val message = data.toString(Charsets.UTF_8)
92+
throw IllegalStateException("ElevenLabs voices failed: $code $message")
93+
}
94+
95+
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
96+
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
97+
voices.mapNotNull { entry ->
98+
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
99+
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
100+
val name = obj["name"].asStringOrNull()
101+
ElevenLabsVoice(voiceId, name)
102+
}
103+
}
104+
}
105+
106+
private fun isLikelyVoiceId(value: String): Boolean {
107+
if (value.length < 10) return false
108+
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
109+
}
110+
111+
private fun normalizeAliasKey(value: String): String =
112+
value.trim().lowercase()
113+
}
114+
115+
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
116+
117+
private fun JsonElement?.asStringOrNull(): String? =
118+
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package ai.openclaw.app.voice
2+
3+
import kotlinx.coroutines.runBlocking
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Assert.assertNull
6+
import org.junit.Test
7+
8+
class TalkModeVoiceResolverTest {
9+
@Test
10+
fun resolvesVoiceAliasCaseInsensitively() {
11+
val resolved =
12+
TalkModeVoiceResolver.resolveVoiceAlias(
13+
" Clawd ",
14+
mapOf("clawd" to "voice-123"),
15+
)
16+
17+
assertEquals("voice-123", resolved)
18+
}
19+
20+
@Test
21+
fun acceptsDirectVoiceIds() {
22+
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("21m00Tcm4TlvDq8ikWAM", emptyMap())
23+
24+
assertEquals("21m00Tcm4TlvDq8ikWAM", resolved)
25+
}
26+
27+
@Test
28+
fun rejectsUnknownAliases() {
29+
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("nickname", emptyMap())
30+
31+
assertNull(resolved)
32+
}
33+
34+
@Test
35+
fun reusesCachedFallbackVoiceBeforeFetchingCatalog() =
36+
runBlocking {
37+
var fetchCount = 0
38+
39+
val resolved =
40+
TalkModeVoiceResolver.resolveVoiceId(
41+
preferred = null,
42+
fallbackVoiceId = "cached-voice",
43+
defaultVoiceId = null,
44+
currentVoiceId = null,
45+
voiceOverrideActive = false,
46+
listVoices = {
47+
fetchCount += 1
48+
emptyList()
49+
},
50+
)
51+
52+
assertEquals("cached-voice", resolved.voiceId)
53+
assertEquals(0, fetchCount)
54+
}
55+
56+
@Test
57+
fun seedsDefaultVoiceFromCatalogWhenNeeded() =
58+
runBlocking {
59+
val resolved =
60+
TalkModeVoiceResolver.resolveVoiceId(
61+
preferred = null,
62+
fallbackVoiceId = null,
63+
defaultVoiceId = null,
64+
currentVoiceId = null,
65+
voiceOverrideActive = false,
66+
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
67+
)
68+
69+
assertEquals("voice-1", resolved.voiceId)
70+
assertEquals("voice-1", resolved.fallbackVoiceId)
71+
assertEquals("voice-1", resolved.defaultVoiceId)
72+
assertEquals("voice-1", resolved.currentVoiceId)
73+
assertEquals("First", resolved.selectedVoiceName)
74+
}
75+
76+
@Test
77+
fun preservesCurrentVoiceWhenOverrideIsActive() =
78+
runBlocking {
79+
val resolved =
80+
TalkModeVoiceResolver.resolveVoiceId(
81+
preferred = null,
82+
fallbackVoiceId = null,
83+
defaultVoiceId = null,
84+
currentVoiceId = null,
85+
voiceOverrideActive = true,
86+
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
87+
)
88+
89+
assertEquals("voice-1", resolved.voiceId)
90+
assertNull(resolved.currentVoiceId)
91+
}
92+
}

0 commit comments

Comments
 (0)