feat(android-voice): add speaker toggle in voice tab

This commit is contained in:
Ayaan Zaidi
2026-02-28 19:28:05 +05:30
committed by Ayaan Zaidi
parent fb92a91ef7
commit 72e135083a
4 changed files with 176 additions and 194 deletions

View File

@@ -54,6 +54,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtime.micConversation
val micInputLevel: StateFlow<Float> = runtime.micInputLevel
val micIsSending: StateFlow<Boolean> = runtime.micIsSending
val speakerEnabled: StateFlow<Boolean> = runtime.speakerEnabled
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
@@ -133,6 +134,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setMicEnabled(enabled)
}
fun setSpeakerEnabled(enabled: Boolean) {
runtime.setSpeakerEnabled(enabled)
}
fun refreshGatewayConnection() {
runtime.refreshGatewayConnection()
}

View File

@@ -349,7 +349,9 @@ class NodeRuntime(context: Context) {
parseChatSendRunId(response) ?: idempotencyKey
},
speakAssistantReply = { text ->
voiceReplySpeaker.speakAssistantReply(text)
if (prefs.speakerEnabled.value) {
voiceReplySpeaker.speakAssistantReply(text)
}
},
)
}
@@ -634,6 +636,13 @@ class NodeRuntime(context: Context) {
externalAudioCaptureActive.value = value
}
val speakerEnabled: StateFlow<Boolean>
get() = prefs.speakerEnabled
fun setSpeakerEnabled(value: Boolean) {
prefs.setSpeakerEnabled(value)
}
fun refreshGatewayConnection() {
val endpoint =
connectedEndpoint ?: run {

View File

@@ -99,6 +99,9 @@ class SecurePrefs(context: Context) {
private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
val talkEnabled: StateFlow<Boolean> = _talkEnabled
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
@@ -270,6 +273,11 @@ class SecurePrefs(context: Context) {
_talkEnabled.value = value
}
fun setSpeakerEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("voice.speakerEnabled", value) }
_speakerEnabled.value = value
}
private fun loadVoiceWakeMode(): VoiceWakeMode {
val raw = plainPrefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw)

View File

@@ -10,12 +10,6 @@ import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -27,14 +21,11 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -44,9 +35,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -74,9 +69,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.android.MainViewModel
import ai.openclaw.android.voice.VoiceConversationEntry
import ai.openclaw.android.voice.VoiceConversationRole
import kotlin.math.PI
import kotlin.math.max
import kotlin.math.sin
@Composable
fun VoiceTabScreen(viewModel: MainViewModel) {
@@ -85,10 +78,9 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
val activity = remember(context) { context.findActivity() }
val listState = rememberLazyListState()
val isConnected by viewModel.isConnected.collectAsState()
val gatewayStatus by viewModel.statusText.collectAsState()
val micEnabled by viewModel.micEnabled.collectAsState()
val micStatusText by viewModel.micStatusText.collectAsState()
val speakerEnabled by viewModel.speakerEnabled.collectAsState()
val micLiveTranscript by viewModel.micLiveTranscript.collectAsState()
val micQueuedMessages by viewModel.micQueuedMessages.collectAsState()
val micConversation by viewModel.micConversation.collectAsState()
@@ -138,33 +130,6 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"VOICE",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
Text("Voice mode", style = mobileTitle2, color = mobileText)
}
Surface(
shape = RoundedCornerShape(999.dp),
color = if (isConnected) mobileAccentSoft else mobileSurfaceStrong,
border = BorderStroke(1.dp, if (isConnected) mobileAccent.copy(alpha = 0.25f) else mobileBorderStrong),
) {
Text(
if (isConnected) "Connected" else "Offline",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = mobileCaption1,
color = if (isConnected) mobileAccent else mobileTextSecondary,
)
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f),
@@ -173,15 +138,31 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
) {
if (micConversation.isEmpty() && !showThinkingBubble) {
item {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
Box(
modifier = Modifier.fillParentMaxHeight().fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(
"Tap the mic and speak. Each pause sends a turn automatically.",
style = mobileCallout,
color = mobileTextSecondary,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = mobileTextTertiary,
)
Text(
"Tap the mic to start",
style = mobileHeadline,
color = mobileTextSecondary,
)
Text(
"Each pause sends a turn automatically.",
style = mobileCallout,
color = mobileTextTertiary,
)
}
}
}
}
@@ -197,122 +178,139 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
}
}
Surface(
Column(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = Color.White,
border = BorderStroke(1.dp, mobileBorder),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (!micLiveTranscript.isNullOrBlank()) {
Surface(
shape = RoundedCornerShape(999.dp),
color = mobileSurface,
border = BorderStroke(1.dp, mobileBorder),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileAccentSoft,
border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)),
) {
val queueCount = micQueuedMessages.size
val stateText =
when {
queueCount > 0 -> "$queueCount queued"
micIsSending -> "Sending"
micEnabled -> "Listening"
else -> "Mic off"
}
Text(
"$gatewayStatus · $stateText",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
style = mobileCaption1,
color = mobileTextSecondary,
micLiveTranscript!!.trim(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
style = mobileCallout,
color = mobileText,
)
}
}
// Mic button with input-reactive ring + speaker toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
// Speaker toggle
IconButton(
onClick = { viewModel.setSpeakerEnabled(!speakerEnabled) },
modifier = Modifier.size(48.dp),
colors =
IconButtonDefaults.iconButtonColors(
containerColor = if (speakerEnabled) mobileSurface else mobileDangerSoft,
),
) {
Icon(
imageVector = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
modifier = Modifier.size(22.dp),
tint = if (speakerEnabled) mobileTextSecondary else mobileDanger,
)
}
if (!micLiveTranscript.isNullOrBlank()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = mobileAccentSoft,
border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)),
// Ring size = 68dp base + up to 22dp driven by mic input level.
// The outer Box is fixed at 90dp (max ring size) so the ring never shifts the button.
Box(
modifier = Modifier.padding(horizontal = 16.dp).size(90.dp),
contentAlignment = Alignment.Center,
) {
if (micEnabled) {
val ringLevel = micInputLevel.coerceIn(0f, 1f)
val ringSize = 68.dp + (22.dp * max(ringLevel, 0.05f))
Box(
modifier =
Modifier
.size(ringSize)
.background(mobileAccent.copy(alpha = 0.12f + 0.14f * ringLevel), CircleShape),
)
}
Button(
onClick = {
if (micEnabled) {
viewModel.setMicEnabled(false)
return@Button
}
if (hasMicPermission) {
viewModel.setMicEnabled(true)
} else {
pendingMicEnable = true
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
}
},
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(60.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = if (micEnabled) mobileDanger else mobileAccent,
contentColor = Color.White,
),
) {
Text(
micLiveTranscript!!.trim(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
style = mobileCallout,
color = mobileText,
Icon(
imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic,
contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on",
modifier = Modifier.size(24.dp),
)
}
}
MicWaveform(level = micInputLevel, active = micEnabled)
// Invisible spacer to balance the row (same size as speaker button)
Box(modifier = Modifier.size(48.dp))
}
Button(
onClick = {
if (micEnabled) {
viewModel.setMicEnabled(false)
return@Button
}
if (hasMicPermission) {
viewModel.setMicEnabled(true)
} else {
pendingMicEnable = true
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
}
},
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(86.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = if (micEnabled) mobileDanger else mobileAccent,
contentColor = Color.White,
),
) {
Icon(
imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic,
contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on",
modifier = Modifier.size(30.dp),
)
// Status + labels
val queueCount = micQueuedMessages.size
val stateText =
when {
queueCount > 0 -> "$queueCount queued"
micIsSending -> "Sending"
micEnabled -> "Listening"
else -> "Mic off"
}
Text(
"$gatewayStatus · $stateText",
style = mobileCaption1,
color = mobileTextSecondary,
)
Text(
if (micEnabled) "Tap to stop" else "Tap to speak",
style = mobileCallout,
color = mobileTextSecondary,
)
if (!hasMicPermission) {
val showRationale =
if (activity == null) {
false
} else {
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
}
Text(
if (showRationale) {
"Microphone permission is required for voice mode."
} else {
"Microphone blocked. Open app settings to enable it."
},
style = mobileCaption1,
color = mobileWarning,
textAlign = TextAlign.Center,
)
Button(
onClick = { openAppSettings(context) },
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText),
) {
Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold))
if (!hasMicPermission) {
val showRationale =
if (activity == null) {
false
} else {
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
}
}
Text(
micStatusText,
if (showRationale) {
"Microphone permission is required for voice mode."
} else {
"Microphone blocked. Open app settings to enable it."
},
style = mobileCaption1,
color = mobileTextTertiary,
color = mobileWarning,
textAlign = TextAlign.Center,
)
Button(
onClick = { openAppSettings(context) },
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText),
) {
Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold))
}
}
}
}
@@ -327,18 +325,18 @@ private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
) {
Surface(
modifier = Modifier.fillMaxWidth(0.90f),
shape = RoundedCornerShape(14.dp),
color = if (isUser) mobileAccentSoft else mobileSurface,
border = BorderStroke(1.dp, if (isUser) mobileAccent.copy(alpha = 0.2f) else mobileBorder),
shape = RoundedCornerShape(12.dp),
color = if (isUser) mobileAccentSoft else Color.White,
border = BorderStroke(1.dp, if (isUser) mobileAccent else mobileBorderStrong),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 11.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
if (isUser) "You" else "OpenClaw",
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
color = mobileTextSecondary,
style = mobileCaption2.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp),
color = if (isUser) mobileAccent else mobileTextSecondary,
)
Text(
if (entry.isStreaming && entry.text.isBlank()) "Listening response…" else entry.text,
@@ -355,12 +353,12 @@ private fun VoiceThinkingBubble() {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
modifier = Modifier.fillMaxWidth(0.68f),
shape = RoundedCornerShape(14.dp),
color = mobileSurface,
border = BorderStroke(1.dp, mobileBorder),
shape = RoundedCornerShape(12.dp),
color = Color.White,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -389,44 +387,6 @@ private fun ThinkingDot(alpha: Float, color: Color) {
) {}
}
@Composable
private fun MicWaveform(level: Float, active: Boolean) {
val transition = rememberInfiniteTransition(label = "voiceWave")
val phase by
transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(animation = tween(1_000, easing = LinearEasing), repeatMode = RepeatMode.Restart),
label = "voiceWavePhase",
)
val effective = if (active) level.coerceIn(0f, 1f) else 0f
val base = max(effective, if (active) 0.05f else 0f)
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(16) { index ->
val pulse =
if (!active) {
0f
} else {
((sin(((phase * 2f * PI) + (index * 0.55f)).toDouble()) + 1.0) * 0.5).toFloat()
}
val barHeight = 6.dp + (24.dp * (base * pulse))
Box(
modifier =
Modifier
.width(5.dp)
.height(barHeight)
.background(if (active) mobileAccent else mobileBorderStrong, RoundedCornerShape(999.dp)),
)
}
}
}
private fun Context.hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==