Merge pull request #2750 from vitorpamplona/claude/debug-audio-dropout-n0g6Z

Add audio format negotiation and cliff detection for Nests
This commit is contained in:
Vitor Pamplona
2026-05-06 16:07:59 -04:00
committed by GitHub
40 changed files with 4578 additions and 578 deletions
@@ -43,6 +43,12 @@ class Amethyst : Application() {
if (isDebug) {
Logging.setup()
// Auto-enable the Nests session-trace recorder in debug
// builds so two-phone repros can be captured via
// adb logcat -s NestsTraceJsonl:D -v raw > nest-trace.jsonl
// without rebuilding to flip a flag. Off in release.
com.vitorpamplona.nestsclient.trace.NestsTrace
.setRecording(true)
}
instance.initiate(this)
@@ -53,8 +53,12 @@ internal class NestViewModelFactory(
// `httpClient` slot rather than as the first positional arg.
httpClient = OkHttpNestsClient(httpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForVideo),
transport = QuicWebTransportFactory(),
decoderFactory = { MediaCodecOpusDecoder() },
playerFactory = { AudioTrackPlayer() },
decoderFactory = { channelCount, sampleRate ->
MediaCodecOpusDecoder(channelCount = channelCount, sampleRate = sampleRate)
},
playerFactory = { channelCount, sampleRate ->
AudioTrackPlayer(channelCount = channelCount, sampleRate = sampleRate)
},
signer = signer,
room = room,
captureFactory = { AudioRecordCapture() },
@@ -50,6 +50,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
@@ -270,8 +271,11 @@ private fun OnStageControls(
is BroadcastUiState.Failed -> {
// Reason is shown in the status strip; this button retries.
// Same auto-muted semantics as the idle path: the retry
// should bring the publisher back up without opening the
// mic, then the user can unmute deliberately.
TalkButton(
onClick = { viewModel.startBroadcast(speakerPubkeyHex) },
onClick = { viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true) },
contentDescription = stringRes(R.string.nest_talk),
)
LeaveStageButton(onClick = { viewModel.setOnStage(false) })
@@ -289,17 +293,43 @@ private fun OnStageIdleControls(
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
permissionDenied = !granted
if (granted) viewModel.startBroadcast(speakerPubkeyHex)
// Permission grant on a Talk-button tap should start a
// *muted* broadcast — auto-start semantics. The host
// taps Talk to claim the on-stage slot; unmute is a
// separate, deliberate action below.
if (granted) viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true)
}
// The launcher callback never fires for Settings-deep-link grants, so
// re-check on every recomposition to auto-clear the warning.
val showDenialWarning =
permissionDenied && !context.hasMicPermission()
// Auto-start the muted broadcast as soon as the host (or any
// on-stage speaker) lands on this composable WITH mic permission
// already granted. Without this, a host who creates a room sees
// a "Connecting..." spinner over their avatar until they tap Talk
// — the publisher session only opens on tap, so listeners can't
// subscribe yet, and the `connectingSpeakers` overlay sticks. By
// pre-opening the publisher in muted state here, listeners get
// an immediate Active announce and the host's avatar leaves the
// spinner state on its own. Unmute is still a separate tap on
// the mic toggle that appears once we transition to
// `BroadcastUiState.Broadcasting(isMuted = true)`.
//
// Permission-denied case is intentionally left to the manual
// Talk-button tap below: we don't want a host to be auto-prompted
// for the mic the instant they enter the room. Tapping Talk is
// the consent gesture that triggers `permissionLauncher`.
LaunchedEffect(speakerPubkeyHex) {
if (context.hasMicPermission()) {
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true)
}
}
TalkButton(
onClick = {
if (context.hasMicPermission()) {
viewModel.startBroadcast(speakerPubkeyHex)
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true)
} else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
@@ -141,16 +141,19 @@ class SurgeDnsTest {
responses.get()
}
val syncRefresh = Executor { it.run() }
// 100ms TTL gives both branches enough headroom on slow CI: Thread.sleep(150) reliably
// expires the first entry, and the post-refresh entry can't expire between the two
// assertions below (microseconds apart on any sane runner).
val dns =
SurgeDns(
delegate = upstream,
positiveTtlMs = 1,
positiveTtlMs = 100,
positiveTtlJitterMs = 0,
refreshExecutor = syncRefresh,
)
assertEquals(listOf(ip("1.2.3.4")), dns.lookup("a.example"))
Thread.sleep(20)
Thread.sleep(150)
// Upstream now returns a new IP. The next lookup should still serve the OLD IP
// immediately, while the synchronous executor performs the refresh inline.