diff --git a/AGENTS.md b/AGENTS.md index 0144bf6..052f8a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Phone (Hub) AAudio in ─► CaptureHandle ─► tx thread (Opus 64k, fan-out PC (Server) WASAPI render "CABLE Input" ◄─ PlayoutHandle (jitter buffer, decode in fill) ◄─ RxFeeder ◄──────────────┘ ``` - **Core (`audiobridge_core`)** is platform-neutral. Platforms touch audio only through `CaptureHandle::push` and `PlayoutHandle::fill`: 48 kHz f32 interleaved. Capture goes through an rtrb SPSC ring to the sender thread; received packets go through an rtrb SPSC packet queue into a jitter buffer that `fill` decodes on demand (NetEQ-lite: PLC/expand, p98 delay target, drift PI + Hermite resampler, WSOLA-style frame drops). Neither call allocates or locks (`tests/rt_alloc.rs` enforces this). - - **Real-time rule:** `push`, `fill`, `Playout::fill` and `Mixer::fill` must never allocate, lock or block. Mixer slots are preallocated; peers attach and detach via atomics; the sender is woken with `Thread::unpark`. + - **Real-time rule:** `push`, `fill`, `Playout::fill` and `Mixer::fill` must never allocate, lock or block. Mixer slots are preallocated; peers attach, detach and are muted via atomics; the sender is woken with `Thread::unpark`. - **Roles:** `session::Server` is the PC side; it serves one active phone and a new connection replaces the old one (close codes 1 = replaced, 2 = rejected, 3 = shutdown). `session::Hub` is the phone side: one iroh endpoint, one task per PC, `MAX_PEERS = 8`, `SLOTS = 16`. - **Dialing:** the phone always dials the PC, which works through NAT and double NAT. - **Control:** a single bi-stream carrying `ControlMsg` frames (`len u16 | tag | body`): Hello{secret} → Welcome/Reject, Toggles, MicDemand, MicAllowed. @@ -26,6 +26,7 @@ PC (Server) WASAPI render "CABLE Input" ◄─ PlayoutHandle (jitter buffer, de - Target buffer: p98 of an exponentially forgotten arrival-delay histogram, clamped 20–500 ms. Excess buffer is drained by WSOLA-style frame drops. - Clock drift: PI-controlled Hermite resampling, ±1 %. - **Hub backoff:** an offline PC is redialed 1 s → 30 s; `network_changed` retries immediately. +- **Phone-side mute:** `Hub::set_muted(ids)` (Kotlin `NativeBridge.setMuted`, persisted as `muted` per entry in `paired_pcs`). A muted PC stays connected, keeps the mic and keeps streaming. Its feeder still tracks activity but stops queueing audio, so a closed (unread) output never builds a stale backlog; one Silence marker ends the stream, and the mixer ramps it out over 10 ms. Unmuting resets the feeder: fresh stream, no replayed audio. A muted PC doesn't count toward `pc_audio_active`, so the output closes and the wake locks are released, and it gets no Pace packets. It's local to the phone: no wire/protocol change, so it works with any PC version. - **Status:** `tokio::sync::watch` (`Status` / `HubStatus`) via `StatusCell::update` → `send_if_modified`. Stats refresh at 2 Hz with change thresholds, so only real changes wake watchers. Keep this "notify on change" pattern; don't add polling. - **Desktop audio supervisor:** - Loopback runs only when `connected && pc_audio_enabled` and the default device is not a VB-CABLE device. @@ -86,7 +87,7 @@ cargo ndk -t arm64-v8a clippy -p audiobridge-android --all-targets -- -D warning - Core `Server` API ↔ `crates/desktop`. - **Persisted state:** - PC: `%APPDATA%\AudioBridge` holds `server.key`, `pairing.secret`, `server.port`, `settings.json` and `pairing.txt`. Writes go through tmp+rename; corrupt files are regenerated. Changing `server.key` or `pairing.secret` breaks every existing pairing. - - Phone: `/audiobridge/client.key`, plus SharedPreferences `audiobridge` (`paired_pcs` JSON list, `mic_enabled`, `autostart_done`). + - Phone: `/audiobridge/client.key`, plus SharedPreferences `audiobridge` (`paired_pcs` JSON list of `{id, name, uri, muted}`, `mic_enabled`, `autostart_done`). - **Default UDP port:** 47130, falling back to a random port. - **Screenshots and releases:** the pairing QR contains the PC's secret. Never publish it; promo shots replace it with a QR of the repo URL. GitHub Releases ship `AudioBridge.exe` (the release build) and `AudioBridge.apk` (the debug-signed APK from this machine's `~/.android/debug.keystore`; a different key can't update an installed app). @@ -126,7 +127,7 @@ cargo ndk -t arm64-v8a clippy -p audiobridge-android --all-targets -- -D warning - Core unit tests: proto, pairing, codec, tx gate, rx, the mixer limiter and persisted identity. - Playout simulations on a simulated clock (`audio/sim.rs`): jitter, power-save bursts, 2 % loss and ±0.15 % skew. They assert no exact-zero output runs and no skipped content. - `tests/rt_alloc.rs`: counts allocations and requires zero inside `push` and `fill`. - - `crates/core/tests/integration.rs`: a real Server + Hub in-process over `NetOptions::local_only()`, covering both directions plus latency < 60 ms, mixing from two PCs with mic routing, removing a PC, a wrong secret, reconnect, and uplink pacing. These tests are real-time and timing-sensitive; don't run them under heavy parallel load to judge flakiness. + - `crates/core/tests/integration.rs`: a real Server + Hub in-process over `NetOptions::local_only()`, covering both directions plus latency < 60 ms, mixing from two PCs with mic routing, muting PCs on the phone, removing a PC, a wrong secret, reconnect, and uplink pacing. These tests are real-time and timing-sensitive; don't run them under heavy parallel load to judge flakiness. - **On-device glitch check:** the phone logs `pc audio glitch: … underruns=… lost=…` and the PC logs `mic glitch: …` whenever counters grow. Compare screen-on vs screen-off with music playing. - android-native: host tests for the statusJson v2 schema, `NotifyGate` debounce, policies and peer parsing. - Android: JVM tests `BridgeStatusTest` and `PairedPcTest` (`isReturnDefaultValues = false`); there is no instrumented test. diff --git a/Cargo.lock b/Cargo.lock index 5686c80..963deeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,7 +196,7 @@ dependencies = [ [[package]] name = "audiobridge-android" -version = "0.1.0" +version = "0.2.0" dependencies = [ "android_logger", "audiobridge-core", @@ -213,7 +213,7 @@ dependencies = [ [[package]] name = "audiobridge-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "audiobridge-opus-sys", @@ -233,7 +233,7 @@ dependencies = [ [[package]] name = "audiobridge-desktop" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "audiobridge-core", @@ -252,7 +252,7 @@ dependencies = [ [[package]] name = "audiobridge-opus-sys" -version = "0.1.0" +version = "0.2.0" dependencies = [ "cc", ] diff --git a/Cargo.toml b/Cargo.toml index 51f439c..f399a60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/core", "crates/opus-sys", "crates/desktop", "crates/android-native"] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" repository = "https://github.com/Lyten02/AudioBridge" diff --git a/README.md b/README.md index afb35a9..c3df83c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Pair once by scanning a QR code. After that both apps run in the background, sta - **Phone mic → PC.** Shows up as `CABLE Output` (VB-CABLE). The phone only sends the mic while some app on the PC is actually recording. - **One-scan pairing.** The PC shows a QR code, the phone scans it, and that's it. - **Works across networks.** Peer-to-peer over [iroh](https://iroh.computer) QUIC: LAN, NAT hole punching, Tailscale, or an encrypted relay as a last resort. The phone and the PC don't need to share a Wi-Fi network. -- **Several PCs at once.** Pair up to 8 computers (desktop, laptop, …). Their audio is mixed, and the mic goes only to the ones that use it. +- **Several PCs at once.** Pair up to 8 computers (desktop, laptop, …). Their audio is mixed, and the mic goes only to the ones that use it. Mute one PC, or all of them with a single button, right from the phone; they stay connected. - **Background and autostart** on both sides. Low CPU: about 1–2 % of one core on the PC, 4–5 % on the phone while playing. - **End-to-end encrypted** (QUIC/TLS) and authenticated by the pairing secret from the QR code. diff --git a/README.ru.md b/README.ru.md index c553926..6024e64 100644 --- a/README.ru.md +++ b/README.ru.md @@ -26,7 +26,7 @@ AudioBridge передаёт весь звук компьютера на Android - **Микрофон телефона → ПК.** Виден как `CABLE Output` (VB-CABLE). Телефон отправляет микрофон, только когда какая-то программа на ПК действительно записывает. - **Сопряжение одним сканом QR-кода.** - **Работает между разными сетями.** P2P через [iroh](https://iroh.computer) QUIC: локальная сеть, пробой NAT, Tailscale, а в крайнем случае зашифрованный relay. Телефону и ПК не обязательно быть в одном Wi-Fi. -- **Несколько компьютеров сразу.** До 8 ПК (стационарный, ноутбук, …): их звук смешивается, а микрофон уходит только туда, где он нужен. +- **Несколько компьютеров сразу.** До 8 ПК (стационарный, ноутбук, …): их звук смешивается, а микрофон уходит только туда, где он нужен. Звук отдельного ПК или всех сразу можно выключить одной кнопкой прямо с телефона, связь при этом не рвётся. - **Фон и автозапуск** на обеих сторонах. Нагрузка небольшая: на ПК 1–2 % одного ядра, на телефоне 4–5 % во время воспроизведения. - **Сквозное шифрование** (QUIC/TLS) и проверка по секрету из QR-кода. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ec16985..c41428e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "app.audiobridge" minSdk = 26 targetSdk = 35 - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "0.2.0" ndk { abiFilters += "arm64-v8a" } diff --git a/android/app/src/main/java/app/audiobridge/AppState.kt b/android/app/src/main/java/app/audiobridge/AppState.kt index 4bb6201..8fe6289 100644 --- a/android/app/src/main/java/app/audiobridge/AppState.kt +++ b/android/app/src/main/java/app/audiobridge/AppState.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -/** Persistent user state (paired PCs, mic toggle, dismissed setup steps), observable from the UI. */ +/** Persistent user state (paired PCs and their mute flags, mic toggle, dismissed setup steps), observable from the UI. */ class Prefs private constructor(private val sp: SharedPreferences) { private val _pcs = MutableStateFlow(readPcs()) val pcs: StateFlow> = _pcs.asStateFlow() @@ -41,13 +41,25 @@ class Prefs private constructor(private val sp: SharedPreferences) { _pcs.value = pcs } - /** Adds a PC, or replaces the one with the same id. */ + /** Adds a PC, or replaces the one with the same id (keeping its mute setting). */ @Synchronized - fun addPc(pc: PairedPc) = storePcs(PairedPc.upsert(_pcs.value, pc)) + fun addPc(pc: PairedPc) { + val muted = _pcs.value.firstOrNull { it.id == pc.id }?.muted ?: pc.muted + storePcs(PairedPc.upsert(_pcs.value, pc.copy(muted = muted))) + } @Synchronized fun removePc(id: String) = storePcs(_pcs.value.filterNot { it.id == id }) + /** Mutes or unmutes the audio of one PC on the phone. */ + @Synchronized + fun setMuted(id: String, muted: Boolean) = + storePcs(_pcs.value.map { if (it.id == id) it.copy(muted = muted) else it }) + + /** Mutes or unmutes the audio of every paired PC on the phone. */ + @Synchronized + fun setAllMuted(muted: Boolean) = storePcs(_pcs.value.map { it.copy(muted = muted) }) + fun setMicEnabled(on: Boolean) { sp.edit { putBoolean(KEY_MIC, on) } _micEnabled.value = on diff --git a/android/app/src/main/java/app/audiobridge/BridgeService.kt b/android/app/src/main/java/app/audiobridge/BridgeService.kt index 491de44..af64d6e 100644 --- a/android/app/src/main/java/app/audiobridge/BridgeService.kt +++ b/android/app/src/main/java/app/audiobridge/BridgeService.kt @@ -39,6 +39,9 @@ class BridgeService : Service() { /** Last JSON array handed to [NativeBridge.setPeers]. */ private var appliedPeers: String? = null + + /** Last JSON array handed to [NativeBridge.setMuted]. */ + private var appliedMuted: String? = null private var fgsType = NOT_FOREGROUND private var shownNotificationKey: String? = null @@ -75,6 +78,8 @@ class BridgeService : Service() { when (intent?.action) { ACTION_ENABLE_MIC -> prefs.setMicEnabled(true) ACTION_DISABLE_MIC -> prefs.setMicEnabled(false) + ACTION_MUTE_ALL -> prefs.setAllMuted(true) + ACTION_UNMUTE_ALL -> prefs.setAllMuted(false) } val promote = intent?.action == ACTION_FOREGROUND || intent?.action == ACTION_ENABLE_MIC if (!updateForeground(promote)) { @@ -87,6 +92,13 @@ class BridgeService : Service() { stopSelf() return START_NOT_STICKY } + // Mutes first, so a newly added muted PC never plays. + val muted = PairedPc.mutedIdsJson(pcs) + if (muted != appliedMuted) { + NativeBridge.setMuted(muted) + appliedMuted = muted + refreshNotification(force = false) + } val uris = PairedPc.urisJson(pcs) if (uris != appliedPeers) { NativeBridge.setPeers(uris) @@ -100,6 +112,7 @@ class BridgeService : Service() { NativeBridge.setMicAllowed(false) NativeBridge.setPeers("[]") appliedPeers = null + appliedMuted = null networkCallback?.let { getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(it) } networkCallback = null main.removeCallbacksAndMessages(null) @@ -267,7 +280,9 @@ class BridgeService : Service() { } private fun notificationKey(status: BridgeStatus, micReady: Boolean): String = - "${notificationText(status)}|$micReady|${status.micCapturing}|${prefs.micEnabled.value}|${hasRecordAudio()}" + "${notificationText(status)}|$micReady|${status.micCapturing}|${prefs.micEnabled.value}|${hasRecordAudio()}|${allMuted()}" + + private fun allMuted(): Boolean = prefs.pcs.value.let { pcs -> pcs.isNotEmpty() && pcs.all { it.muted } } private fun refreshNotification(force: Boolean) { val status = StatusHub.status.value @@ -323,6 +338,11 @@ class BridgeService : Service() { else -> Unit } + if (allMuted()) { + builder.addAction(R.drawable.ic_volume_up, getString(R.string.notif_action_unmute_all), servicePendingIntent(ACTION_UNMUTE_ALL, 5)) + } else { + builder.addAction(R.drawable.ic_volume_off, getString(R.string.notif_action_mute_all), servicePendingIntent(ACTION_MUTE_ALL, 4)) + } if (micReady) { builder.addAction( R.drawable.ic_mic, @@ -368,6 +388,8 @@ class BridgeService : Service() { const val ACTION_FOREGROUND = "app.audiobridge.action.FOREGROUND" const val ACTION_ENABLE_MIC = "app.audiobridge.action.ENABLE_MIC" const val ACTION_DISABLE_MIC = "app.audiobridge.action.DISABLE_MIC" + const val ACTION_MUTE_ALL = "app.audiobridge.action.MUTE_ALL" + const val ACTION_UNMUTE_ALL = "app.audiobridge.action.UNMUTE_ALL" private val BASE_TYPE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE else 0 diff --git a/android/app/src/main/java/app/audiobridge/BridgeStatus.kt b/android/app/src/main/java/app/audiobridge/BridgeStatus.kt index 7463f76..cd653e0 100644 --- a/android/app/src/main/java/app/audiobridge/BridgeStatus.kt +++ b/android/app/src/main/java/app/audiobridge/BridgeStatus.kt @@ -129,8 +129,11 @@ data class BridgeStatus( } } -/** A paired PC as persisted on the phone. [id] is the stable peer id from the pairing QR. */ -data class PairedPc(val id: String, val name: String, val uri: String) { +/** + * A paired PC as persisted on the phone. [id] is the stable peer id from the pairing QR; [muted] means its audio is + * not played on the phone (the connection and the mic keep working). + */ +data class PairedPc(val id: String, val name: String, val uri: String, val muted: Boolean = false) { companion object { /** Builds a PC from a scanned [uri] and the JSON returned by [NativeBridge.parsePairing]; null if invalid. */ fun fromParseResult(uri: String, parseResult: String?): PairedPc? { @@ -145,7 +148,9 @@ data class PairedPc(val id: String, val name: String, val uri: String) { } fun encodeList(pcs: List): String = JSONArray().apply { - pcs.forEach { put(JSONObject().put("id", it.id).put("name", it.name).put("uri", it.uri)) } + pcs.forEach { + put(JSONObject().put("id", it.id).put("name", it.name).put("uri", it.uri).put("muted", it.muted)) + } }.toString() /** Decodes a stored list; malformed entries are skipped and duplicates by id collapse to the last one. */ @@ -162,7 +167,7 @@ data class PairedPc(val id: String, val name: String, val uri: String) { val id = o.optString("id") val uri = o.optString("uri") if (id.isEmpty() || uri.isEmpty()) continue - result = upsert(result, PairedPc(id, o.optString("name"), uri)) + result = upsert(result, PairedPc(id, o.optString("name"), uri, o.optBoolean("muted"))) } return result } @@ -175,6 +180,10 @@ data class PairedPc(val id: String, val name: String, val uri: String) { /** The JSON array of URIs passed to [NativeBridge.setPeers]. */ fun urisJson(pcs: List): String = JSONArray().apply { pcs.forEach { put(it.uri) } }.toString() + + /** The JSON array of muted PC ids passed to [NativeBridge.setMuted]. */ + fun mutedIdsJson(pcs: List): String = + JSONArray().apply { pcs.filter { it.muted }.forEach { put(it.id) } }.toString() } } diff --git a/android/app/src/main/java/app/audiobridge/MainActivity.kt b/android/app/src/main/java/app/audiobridge/MainActivity.kt index 38ab596..96aec65 100644 --- a/android/app/src/main/java/app/audiobridge/MainActivity.kt +++ b/android/app/src/main/java/app/audiobridge/MainActivity.kt @@ -98,6 +98,8 @@ class MainActivity : ComponentActivity() { snackbarHostState = snackbar, onAddPc = ::scan, onRemovePc = ::removePc, + onMutePc = ::setPcMuted, + onMuteAll = ::setAllMuted, onMicToggle = ::setMic, ) } @@ -166,6 +168,16 @@ class MainActivity : ComponentActivity() { if (prefs.pcs.value.isEmpty()) BridgeService.stop(this) else startBridge() } + private fun setPcMuted(id: String, muted: Boolean) { + prefs.setMuted(id, muted) + startBridge() + } + + private fun setAllMuted(muted: Boolean) { + prefs.setAllMuted(muted) + startBridge() + } + /** (Re)starts the service so it picks up the current PC list and, being visible, may add the mic FGS type. */ private fun startBridge() { if (prefs.pcs.value.isEmpty()) return diff --git a/android/app/src/main/java/app/audiobridge/NativeBridge.kt b/android/app/src/main/java/app/audiobridge/NativeBridge.kt index b2525e5..8082ef7 100644 --- a/android/app/src/main/java/app/audiobridge/NativeBridge.kt +++ b/android/app/src/main/java/app/audiobridge/NativeBridge.kt @@ -15,6 +15,9 @@ object NativeBridge { /** Sets the full set of PCs to stay connected to (JSON array of pairing URIs); `[]` disconnects all. */ external fun setPeers(urisJson: String) + /** Sets the full set of PC ids whose audio is muted on the phone (JSON array of ids); `[]` unmutes all. */ + external fun setMuted(idsJson: String) + /** True only when RECORD_AUDIO is granted, the service holds FGS type microphone and the user toggle is on. */ external fun setMicAllowed(allowed: Boolean) diff --git a/android/app/src/main/java/app/audiobridge/ui/MainScreen.kt b/android/app/src/main/java/app/audiobridge/ui/MainScreen.kt index 7d58c2f..82163ae 100644 --- a/android/app/src/main/java/app/audiobridge/ui/MainScreen.kt +++ b/android/app/src/main/java/app/audiobridge/ui/MainScreen.kt @@ -111,6 +111,8 @@ fun MainScreen( snackbarHostState: SnackbarHostState, onAddPc: () -> Unit, onRemovePc: (String) -> Unit, + onMutePc: (id: String, muted: Boolean) -> Unit, + onMuteAll: (muted: Boolean) -> Unit, onMicToggle: (Boolean) -> Unit, ) { var removeId by rememberSaveable { mutableStateOf(null) } @@ -137,10 +139,18 @@ fun MainScreen( if (rows.isEmpty()) { Onboarding(onAddPc) } else { - OverviewCard(rows, status) + OverviewCard(rows, status, onMuteAll) MicCard(rows, status, mic, onMicToggle) SectionHeader(stringResource(R.string.pcs_title), rows.size) - rows.forEach { row -> key(row.pc.id) { PcCard(row, onRemove = { removeId = row.pc.id }) } } + rows.forEach { row -> + key(row.pc.id) { + PcCard( + row, + onRemove = { removeId = row.pc.id }, + onMute = { onMutePc(row.pc.id, !row.pc.muted) }, + ) + } + } AddPcButton(onAddPc) AnimatedVisibility(visible = setupItems.isNotEmpty()) { SetupCard(setupItems) @@ -228,9 +238,9 @@ private fun reachabilityText(reach: Reachability): Int = when (reach) { Reachability.Offline -> R.string.state_offline } -/** Big summary: how many PCs are connected and whether audio is playing. */ +/** Big summary: how many PCs are connected and whether audio is playing, plus one button that mutes every PC. */ @Composable -private fun OverviewCard(rows: List, status: BridgeStatus) { +private fun OverviewCard(rows: List, status: BridgeStatus, onMuteAll: (Boolean) -> Unit) { val connected = rows.count { it.reachability == Reachability.Connected } val connecting = rows.any { it.reachability == Reachability.Connecting } val overall = when { @@ -244,12 +254,19 @@ private fun OverviewCard(rows: List, status: BridgeStatus) { connecting -> stringResource(R.string.overview_connecting) else -> stringResource(R.string.overview_offline) } + val allMuted = rows.all { it.pc.muted } val playing = connected > 0 && status.pcAudioActive val subtitle = when { + allMuted -> R.string.overview_muted playing -> R.string.overview_audio_playing overall == Reachability.Connected -> R.string.overview_audio_idle else -> R.string.overview_offline_hint } + val muteLabel = when { + rows.size == 1 -> if (allMuted) R.string.unmute else R.string.mute + allMuted -> R.string.unmute_all + else -> R.string.mute_all + } val accent by animateColorAsState(reachabilityColor(overall), label = "overviewColor") val scheme = MaterialTheme.colorScheme @@ -264,39 +281,55 @@ private fun OverviewCard(rows: List, status: BridgeStatus) { .background(Brush.linearGradient(listOf(scheme.primaryContainer, scheme.surfaceContainerHigh))) .padding(24.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.size(64.dp).clip(CircleShape).background(scheme.surface.copy(alpha = 0.35f)), - contentAlignment = Alignment.Center, - ) { - if (playing) { - Equalizer(scheme.onPrimaryContainer) - } else { - Icon( - painterResource(R.drawable.ic_headphones), - contentDescription = null, - tint = scheme.onPrimaryContainer, - modifier = Modifier.size(30.dp), - ) + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(64.dp).clip(CircleShape).background(scheme.surface.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center, + ) { + if (playing) { + Equalizer(scheme.onPrimaryContainer) + } else { + Icon( + painterResource(if (allMuted) R.drawable.ic_volume_off else R.drawable.ic_headphones), + contentDescription = null, + tint = scheme.onPrimaryContainer, + modifier = Modifier.size(30.dp), + ) + } } - } - Spacer(Modifier.width(18.dp)) - Column(Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - StatusDot(accent, pulsing = overall == Reachability.Connecting, size = 10) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(18.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + StatusDot(accent, pulsing = overall == Reachability.Connecting, size = 10) + Spacer(Modifier.width(8.dp)) + Text( + title, + style = MaterialTheme.typography.headlineSmall, + color = scheme.onPrimaryContainer, + ) + } + Spacer(Modifier.height(4.dp)) Text( - title, - style = MaterialTheme.typography.headlineSmall, - color = scheme.onPrimaryContainer, + stringResource(subtitle), + style = MaterialTheme.typography.bodyMedium, + color = scheme.onPrimaryContainer.copy(alpha = 0.8f), ) } - Spacer(Modifier.height(4.dp)) - Text( - stringResource(subtitle), - style = MaterialTheme.typography.bodyMedium, - color = scheme.onPrimaryContainer.copy(alpha = 0.8f), + } + Spacer(Modifier.height(18.dp)) + FilledTonalButton( + onClick = { onMuteAll(!allMuted) }, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(18.dp), + ) { + Icon( + painterResource(if (allMuted) R.drawable.ic_volume_up else R.drawable.ic_volume_off), + contentDescription = null, + modifier = Modifier.size(20.dp), ) + Spacer(Modifier.width(10.dp)) + Text(stringResource(muteLabel), style = MaterialTheme.typography.labelLarge) } } } @@ -352,7 +385,7 @@ private fun SectionHeader(title: String, count: Int) { @OptIn(ExperimentalLayoutApi::class) @Composable -private fun PcCard(row: PcRow, onRemove: () -> Unit) { +private fun PcCard(row: PcRow, onRemove: () -> Unit, onMute: () -> Unit) { val reach = row.reachability val color by animateColorAsState(reachabilityColor(reach), label = "pcColor") val peer = row.peer @@ -397,6 +430,13 @@ private fun PcCard(row: PcRow, onRemove: () -> Unit) { ) } } + IconButton(onClick = onMute) { + Icon( + painterResource(if (row.pc.muted) R.drawable.ic_volume_off else R.drawable.ic_volume_up), + contentDescription = stringResource(if (row.pc.muted) R.string.unmute else R.string.mute), + tint = if (row.pc.muted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } IconButton(onClick = onRemove) { Icon( painterResource(R.drawable.ic_delete), @@ -415,6 +455,7 @@ private fun PcCard(row: PcRow, onRemove: () -> Unit) { ) { peer.rttMs?.let { Chip(stringResource(R.string.stat_ping, it.roundToInt())) } when { + row.pc.muted -> Chip(stringResource(R.string.stat_muted), emphasized = true) !peer.pcAudioEnabled -> Chip(stringResource(R.string.stat_audio_off)) peer.pcAudio.active -> { Chip(stringResource(R.string.stat_audio), emphasized = true) diff --git a/android/app/src/main/res/drawable/ic_volume_off.xml b/android/app/src/main/res/drawable/ic_volume_off.xml new file mode 100644 index 0000000..9bbb75e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_volume_off.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_volume_up.xml b/android/app/src/main/res/drawable/ic_volume_up.xml new file mode 100644 index 0000000..5bd9def --- /dev/null +++ b/android/app/src/main/res/drawable/ic_volume_up.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9e82445..2dbc06f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -13,6 +13,8 @@ Микрофон включён Включить микрофон Выключить микрофон + Выключить звук + Включить звук Локальная сеть @@ -38,6 +40,11 @@ Звук с ПК играет в наушниках Звук появится, как только ПК начнёт играть Подключимся автоматически, когда компьютер будет в сети + Звук с ПК выключен на телефоне + Выключить звук + Включить звук + Выключить звук всех ПК + Включить звук всех ПК Компьютеры @@ -50,6 +57,7 @@ Буфер %1$d мс Звук Звук выключен на ПК + Звук выключен на телефоне микрофон используется Удалить Удалить %1$s? diff --git a/android/app/src/test/java/app/audiobridge/PairedPcTest.kt b/android/app/src/test/java/app/audiobridge/PairedPcTest.kt index d8ae631..f06ae47 100644 --- a/android/app/src/test/java/app/audiobridge/PairedPcTest.kt +++ b/android/app/src/test/java/app/audiobridge/PairedPcTest.kt @@ -51,4 +51,19 @@ class PairedPcTest { assertEquals(listOf(lyten.uri, laptop.uri), List(array.length()) { array.getString(it) }) assertEquals("[]", PairedPc.urisJson(emptyList())) } + + @Test + fun mutedFlagRoundTripsAndDefaultsToUnmuted() { + val list = listOf(lyten.copy(muted = true), laptop) + assertEquals(list, PairedPc.decodeList(PairedPc.encodeList(list))) + // Lists stored before muting existed have no "muted" key. + assertEquals(listOf(lyten), PairedPc.decodeList("""[{"id":"id-1","name":"LYTEN","uri":"${lyten.uri}"}]""")) + } + + @Test + fun mutedIdsJsonListsOnlyMutedPcs() { + val array = JSONArray(PairedPc.mutedIdsJson(listOf(lyten, laptop.copy(muted = true)))) + assertEquals(listOf(laptop.id), List(array.length()) { array.getString(it) }) + assertEquals("[]", PairedPc.mutedIdsJson(listOf(lyten, laptop))) + } } diff --git a/crates/android-native/src/android/engine.rs b/crates/android-native/src/android/engine.rs index 88aaccb..531056e 100644 --- a/crates/android-native/src/android/engine.rs +++ b/crates/android-native/src/android/engine.rs @@ -24,6 +24,8 @@ use crate::status::StatusView; pub enum Cmd { /// The full set of paired PCs (already deduplicated by peer id); empty = disconnect all. SetPeers(Vec), + /// Peer ids whose audio is muted on the phone (the full set; empty = none). + SetMuted(Vec), MicAllowed(bool), NetworkChanged, } @@ -78,6 +80,7 @@ pub fn init(vm: JavaVM, files_dir: &str, device_name: &str) -> Result<(), String listener: listener.clone(), published: StatusView::idle(), peers: Vec::new(), + muted: Vec::new(), hub: None, status_rx: None, status_closed: false, @@ -126,6 +129,8 @@ struct Manager { listener: Listener, published: StatusView, peers: Vec, + /// Muted peer ids, re-applied whenever a hub starts. + muted: Vec, hub: Option, status_rx: Option>, status_closed: bool, @@ -179,6 +184,13 @@ impl Manager { self.try_start().await; } } + Cmd::SetMuted(ids) => { + log::info!("muted PCs: {ids:?}"); + self.muted = ids; + if let Some(hub) = &self.hub { + hub.set_muted(self.muted.clone()); + } + } Cmd::MicAllowed(allowed) => { self.mic_allowed = allowed; if let Some(hub) = &self.hub { @@ -208,6 +220,7 @@ impl Manager { match Hub::start(cfg).await { Ok(hub) => { hub.set_mic_enabled(self.mic_allowed); + hub.set_muted(self.muted.clone()); hub.set_peers(self.peer_infos()); let _ = self.audio.send(Event::Attach { playout: hub.pc_audio_playout(), capture: hub.mic_capture() }); self.audio_demand = None; diff --git a/crates/android-native/src/android/jni_api.rs b/crates/android-native/src/android/jni_api.rs index 3b2921d..c096a3b 100644 --- a/crates/android-native/src/android/jni_api.rs +++ b/crates/android-native/src/android/jni_api.rs @@ -145,7 +145,7 @@ pub extern "system" fn Java_app_audiobridge_NativeBridge_setPeers<'l>( guard("setPeers", (), || { let Some(engine) = engine_or_log("setPeers") else { return }; let Some(json) = read_string(&mut env, &uris_json) else { return }; - let uris = match peers::parse_uri_list(&json) { + let uris = match peers::parse_string_list(&json) { Ok(uris) => uris, Err(e) => { log::error!("setPeers: {e}"); @@ -163,6 +163,24 @@ pub extern "system" fn Java_app_audiobridge_NativeBridge_setPeers<'l>( }) } +/// `idsJson`: JSON array with the full set of muted peer ids; `[]` unmutes all PCs. A malformed array leaves the +/// current set unchanged. +#[no_mangle] +pub extern "system" fn Java_app_audiobridge_NativeBridge_setMuted<'l>( + mut env: JNIEnv<'l>, + _this: JObject<'l>, + ids_json: JString<'l>, +) { + guard("setMuted", (), || { + let Some(engine) = engine_or_log("setMuted") else { return }; + let Some(json) = read_string(&mut env, &ids_json) else { return }; + match peers::parse_string_list(&json) { + Ok(ids) => engine.send(Cmd::SetMuted(ids)), + Err(e) => log::error!("setMuted: {e}"), + } + }) +} + #[no_mangle] pub extern "system" fn Java_app_audiobridge_NativeBridge_setMicAllowed<'l>( _env: JNIEnv<'l>, diff --git a/crates/android-native/src/peers.rs b/crates/android-native/src/peers.rs index 9c86df7..adf69c7 100644 --- a/crates/android-native/src/peers.rs +++ b/crates/android-native/src/peers.rs @@ -1,9 +1,9 @@ -//! Pure handling of the `setPeers(urisJson)` argument. +//! Pure handling of the `setPeers(urisJson)` / `setMuted(idsJson)` arguments. pub use audiobridge_core::session::MAX_PEERS; -/// Parses the JSON array of pairing URIs sent by Kotlin. -pub fn parse_uri_list(json: &str) -> Result, String> { +/// Parses a JSON array of strings sent by Kotlin (pairing URIs or peer ids). +pub fn parse_string_list(json: &str) -> Result, String> { serde_json::from_str::>(json).map_err(|e| format!("expected a JSON array of strings: {e}")) } @@ -28,11 +28,11 @@ mod tests { #[test] fn parses_array_and_rejects_other_json() { - assert_eq!(parse_uri_list(r#"["a","b"]"#).unwrap(), ["a", "b"]); - assert!(parse_uri_list("[]").unwrap().is_empty()); - assert!(parse_uri_list(r#"{"a":1}"#).is_err()); - assert!(parse_uri_list(r#"["a",1]"#).is_err()); - assert!(parse_uri_list("").is_err()); + assert_eq!(parse_string_list(r#"["a","b"]"#).unwrap(), ["a", "b"]); + assert!(parse_string_list("[]").unwrap().is_empty()); + assert!(parse_string_list(r#"{"a":1}"#).is_err()); + assert!(parse_string_list(r#"["a",1]"#).is_err()); + assert!(parse_string_list("").is_err()); } #[test] diff --git a/crates/android-native/src/status.rs b/crates/android-native/src/status.rs index a1820d5..2c129c4 100644 --- a/crates/android-native/src/status.rs +++ b/crates/android-native/src/status.rs @@ -313,7 +313,7 @@ mod tests { fn hub(peers: Vec<(&str, Status)>) -> HubStatus { HubStatus { - peers: peers.into_iter().map(|(id, status)| PeerStatus { id: id.into(), status }).collect(), + peers: peers.into_iter().map(|(id, status)| PeerStatus { id: id.into(), status, muted: false }).collect(), mic_enabled: true, mic_wanted: false, pc_audio_active: true, diff --git a/crates/core/src/audio/mixer.rs b/crates/core/src/audio/mixer.rs index bad49d2..1bdae8a 100644 --- a/crates/core/src/audio/mixer.rs +++ b/crates/core/src/audio/mixer.rs @@ -1,6 +1,6 @@ //! Real-time mixer over a fixed set of per-peer playouts (each with its own jitter buffer and -//! drift compensation). Slots are allocated once; peers are attached/detached through atomics, -//! so `fill` never allocates, locks or frees. +//! drift compensation). Slots are allocated once; peers are attached/detached and muted through +//! atomics, so `fill` never allocates, locks or frees. use super::playout::Playout; @@ -8,9 +8,13 @@ use super::playout::Playout; const SCRATCH_SAMPLES: usize = 2048; /// Soft limiter knee: below it the signal passes untouched. const KNEE: f32 = 0.8; +/// Gain change per frame when a peer is muted or unmuted: a 10 ms linear ramp, no clicks. +const RAMP_STEP: f32 = 1.0 / 480.0; pub(crate) struct Mixer { playouts: Vec, + /// Current gain of each playout: 1 = playing, 0 = muted, ramping in between. + gains: Vec, scratch: Vec, channels: usize, } @@ -19,6 +23,7 @@ impl Mixer { pub(crate) fn new(channels: usize, playouts: Vec) -> Self { debug_assert!(playouts.iter().all(|p| p.channels() == channels)); Self { + gains: vec![1.0; playouts.len()], playouts, scratch: vec![0.0; SCRATCH_SAMPLES], channels, @@ -38,13 +43,36 @@ impl Mixer { chunk.fill(0.0); let scratch = &mut self.scratch[..chunk.len()]; let mut sources = 0; - for p in self.playouts.iter_mut().filter(|p| p.needs_service()) { + for (p, gain) in self.playouts.iter_mut().zip(self.gains.iter_mut()) { + if !p.needs_service() { + continue; + } + // A muted playout keeps running so it drains to silence (its feeder stops queueing). p.fill_at(scratch, now); - if p.is_audible() || scratch.iter().any(|s| *s != 0.0) { - sources += 1; + let target = if p.is_muted() { 0.0 } else { 1.0 }; + if !(p.is_audible() || scratch.iter().any(|s| *s != 0.0)) { + *gain = target; + continue; + } + if *gain == 0.0 && target == 0.0 { + continue; + } + sources += 1; + if *gain == target { for (o, s) in chunk.iter_mut().zip(scratch.iter()) { *o += *s; } + } else { + for (o, s) in chunk.chunks_exact_mut(ch).zip(scratch.chunks_exact(ch)) { + *gain = if target > *gain { + (*gain + RAMP_STEP).min(target) + } else { + (*gain - RAMP_STEP).max(target) + }; + for (o, s) in o.iter_mut().zip(s) { + *o += *s * *gain; + } + } } } if sources > 1 { diff --git a/crates/core/src/audio/playout.rs b/crates/core/src/audio/playout.rs index 69e7051..a53891f 100644 --- a/crates/core/src/audio/playout.rs +++ b/crates/core/src/audio/playout.rs @@ -256,6 +256,11 @@ impl Playout { || !self.queue.is_empty() } + /// The phone user muted this peer (the mixer drops its output). + pub(crate) fn is_muted(&self) -> bool { + self.shared.muted.load(Ordering::Relaxed) + } + pub(crate) fn fill(&mut self, out: &mut [f32]) { self.fill_at(out, Instant::now()); } diff --git a/crates/core/src/audio/rx.rs b/crates/core/src/audio/rx.rs index 31ceff4..a9028e0 100644 --- a/crates/core/src/audio/rx.rs +++ b/crates/core/src/audio/rx.rs @@ -60,6 +60,9 @@ pub(crate) struct RxShared { pub(crate) flush: AtomicBool, /// Mixer slot assigned to a peer (hub only; unassigned slots are skipped once silent). pub(crate) in_use: AtomicBool, + /// The phone user muted this peer: the feeder stops queueing its audio and the mixer leaves + /// it out (with a ramp). Changed only under the feeder lock. + pub(crate) muted: AtomicBool, } impl RxShared { @@ -75,6 +78,7 @@ impl RxShared { pace: AtomicU64::new(0), flush: AtomicBool::new(false), in_use: AtomicBool::new(false), + muted: AtomicBool::new(false), } } @@ -101,6 +105,8 @@ pub(crate) struct RxFeeder { last_audio: Option, silenced: bool, active: bool, + /// Muted: the Silence marker that ends the stream for the playout has been queued. + mute_marked: bool, } impl RxFeeder { @@ -111,20 +117,26 @@ impl RxFeeder { last_audio: None, silenced: true, active: false, + mute_marked: false, } } - /// Starts a fresh stream (new connection or new owner). + /// Starts a fresh stream (new connection, new owner, or unmuted). pub(crate) fn reset(&mut self) { self.last_audio = None; self.silenced = true; self.active = false; + self.mute_marked = false; if !self.push(PktKind::Reset, 0, Instant::now(), &[]) { self.shared.flush.store(true, Ordering::Release); } } /// Handles one parsed audio/silence datagram. Returns `true` if `active` flipped to `true`. + /// + /// While muted, audio still counts for activity but is not queued: the output may be closed, + /// and an unread queue would replay stale audio after unmuting. One Silence marker ends the + /// stream cleanly for the playout; unmuting resets the feeder. pub(crate) fn on_packet(&mut self, hdr: DatagramHeader, payload: &[u8], now: Instant) -> bool { match hdr.kind { PacketKind::Audio => { @@ -132,7 +144,11 @@ impl RxFeeder { (payload.len() + DATAGRAM_HEADER_LEN) as u64, Ordering::Relaxed, ); - self.push(PktKind::Audio, hdr.seq, now, payload); + if !self.shared.muted.load(Ordering::Relaxed) { + self.push(PktKind::Audio, hdr.seq, now, payload); + } else if !self.mute_marked { + self.mute_marked = self.push(PktKind::Silence, hdr.seq, now, &[]); + } self.last_audio = Some(now); self.silenced = false; let became_active = !self.active; @@ -140,7 +156,9 @@ impl RxFeeder { became_active } PacketKind::Silence => { - self.push(PktKind::Silence, hdr.seq, now, &[]); + if !self.shared.muted.load(Ordering::Relaxed) { + self.push(PktKind::Silence, hdr.seq, now, &[]); + } self.silenced = true; false } diff --git a/crates/core/src/session/hub.rs b/crates/core/src/session/hub.rs index 4517a09..38cc789 100644 --- a/crates/core/src/session/hub.rs +++ b/crates/core/src/session/hub.rs @@ -55,6 +55,8 @@ struct Shared { pc_audio: IncomingStream, mic: OutgoingStream, slot_busy: Mutex<[bool; SLOTS]>, + /// Peer ids muted on the phone (see [`Hub::set_muted`]); may name PCs not added yet. + muted: Mutex>, cancel: CancellationToken, runtime: tokio::runtime::Handle, } @@ -117,6 +119,7 @@ impl Hub { pc_audio: IncomingStream::new_mixed(StreamId::PcAudio, SLOTS)?, mic: OutgoingStream::new(StreamId::Mic, MIC_BITRATE, MIC_COMPLEXITY)?, slot_busy: Mutex::new([false; SLOTS]), + muted: Mutex::new(Vec::new()), cancel: CancellationToken::new(), runtime: tokio::runtime::Handle::current(), }); @@ -210,16 +213,53 @@ impl Hub { peer_name: Some(e.peer.info.lock().pc_name().to_owned()), ..Status::new() }, + muted: false, }, }) .collect(); h.recompute(); }); + self.apply_muted(¤t); for e in current.iter() { e.peer.refresh_toggles(); } } + /// Phone-side mute: the PCs whose peer id is in `ids` stay connected (mic included) but their + /// audio is left out of the mix, with a short ramp. Replaces the previous set; ids of PCs that + /// are not paired yet apply once they are added. + pub fn set_muted(&self, ids: Vec) { + *self.shared.muted.lock() = ids; + self.apply_muted(&self.peers.lock()); + } + + /// Pushes the muted set to the peers' mixer slots and status entries. Unmuting restarts the + /// PC's stream, so the playout never replays audio queued before or during the mute. + fn apply_muted(&self, current: &[PeerEntry]) { + let muted = self.shared.muted.lock(); + for e in current { + let on = muted.contains(&e.id); + let rx = e.peer.rx(); + // Under the feeder lock, so no packet sees a half-applied change. + let mut feeder = rx.feeder.lock(); + let was = rx.shared.muted.swap(on, std::sync::atomic::Ordering::Relaxed); + if was && !on { + feeder.reset(); + } + } + self.shared.status.send_if_modified(|h| { + let mut changed = false; + for p in &mut h.peers { + let on = muted.contains(&p.id); + changed |= std::mem::replace(&mut p.muted, on) != on; + } + if changed { + h.recompute(); + } + changed + }); + } + fn claim_slot(&self) -> Option { let mut busy = self.shared.slot_busy.lock(); let slot = busy.iter().position(|b| !b)?; @@ -228,6 +268,7 @@ impl Hub { rx.feeder.lock().reset(); rx.shared.clear_stats(); rx.shared.in_use.store(true, std::sync::atomic::Ordering::Release); + rx.shared.muted.store(false, std::sync::atomic::Ordering::Relaxed); Some(slot) } @@ -486,8 +527,9 @@ impl Peer { } /// Keeps this phone's Wi-Fi out of power save while the PC's audio arrives: a tiny datagram - /// every 20 ms, but only while audio is streaming and the PC is not already receiving our - /// mic (which is uplink traffic itself). Sleeps (no timer) while there is no audio. + /// every 20 ms, but only while audio is streaming, is not muted on the phone and the PC is not + /// already receiving our mic (which is uplink traffic itself). Sleeps (no timer) while there + /// is no audio. async fn pace_loop(&self, conn: &Connection) { let mut pkt = [0u8; DATAGRAM_HEADER_LEN]; DatagramHeader { @@ -509,7 +551,9 @@ impl Peer { if !self.rx().feeder.lock().is_streaming(Instant::now()) { break; } - if self.mic_stats.active.load(std::sync::atomic::Ordering::Relaxed) { + if self.mic_stats.active.load(std::sync::atomic::Ordering::Relaxed) + || self.rx().shared.muted.load(std::sync::atomic::Ordering::Relaxed) + { continue; } if conn.send_datagram(pkt.clone()).is_ok() { diff --git a/crates/core/src/session/mod.rs b/crates/core/src/session/mod.rs index b48f9ba..059a826 100644 --- a/crates/core/src/session/mod.rs +++ b/crates/core/src/session/mod.rs @@ -103,6 +103,8 @@ pub struct PeerStatus { /// [`crate::pairing::PairingInfo::peer_id`]. pub id: String, pub status: Status, + /// Muted on the phone ([`Hub::set_muted`]): stays connected, left out of the mix. + pub muted: bool, } /// Phone-side status: one entry per paired PC (in `set_peers` order) plus aggregates. @@ -113,7 +115,7 @@ pub struct HubStatus { pub mic_enabled: bool, /// Some connected PC has the mic enabled and demanded: the phone should record. pub mic_wanted: bool, - /// Some PC's audio is arriving: the phone output should be open. + /// Some unmuted PC's audio is arriving: the phone output should be open. pub pc_audio_active: bool, } @@ -122,7 +124,10 @@ impl HubStatus { self.mic_wanted = self.peers.iter().any(|p| { p.status.state == ConnState::Connected && p.status.mic_enabled && p.status.mic_demanded }); - self.pc_audio_active = self.peers.iter().any(|p| p.status.pc_audio.active); + self.pc_audio_active = self + .peers + .iter() + .any(|p| p.status.pc_audio.active && !p.muted); } } diff --git a/crates/core/tests/integration.rs b/crates/core/tests/integration.rs index 42e77af..fbab080 100644 --- a/crates/core/tests/integration.rs +++ b/crates/core/tests/integration.rs @@ -364,6 +364,118 @@ async fn two_pcs_are_mixed_and_mic_goes_only_where_demanded() { b.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn muted_pc_stays_connected_but_is_left_out_of_the_mix() { + init_logs(); + let (da, db, dh) = ( + tempfile::tempdir().unwrap(), + tempfile::tempdir().unwrap(), + tempfile::tempdir().unwrap(), + ); + let a = start_server(da.path(), "PC-A", NetOptions::local_only()).await; + let b = start_server(db.path(), "PC-B", NetOptions::local_only()).await; + let (pa, pb) = (pairing_of(&a), pairing_of(&b)); + let (ida, idb) = (pa.peer_id(), pb.peer_id()); + let hub = start_hub(dh.path(), Vec::new()).await; + // Muting before the PC is added applies once it is. + hub.set_muted(vec![idb.clone()]); + hub.set_peers(vec![pa, pb]); + let mut hs = hub.status(); + wait_for(&mut hs, Duration::from_secs(10), "both connected, B muted", |h| { + connected(h, &ida) + && connected(h, &idb) + && h.peers.iter().any(|p| p.id == idb && p.muted) + && h.peers.iter().any(|p| p.id == ida && !p.muted) + }) + .await; + + // Phase 1 (4 s): B muted, A plays. Phase 2 (2 s): everything muted. + let cap_a = capture_clock(a.pc_audio_capture(), 1000.0, 0.4, 600, None); + let cap_b = capture_clock(b.pc_audio_capture(), 1500.0, 0.4, 600, None); + let out = playout_clock(hub.pc_audio_playout(), 600, 2.0); + tokio::time::sleep(Duration::from_secs(3)).await; + let h = wait_for(&mut hs, Duration::from_secs(2), "both streams active", |h| { + h.peers.iter().all(|p| p.status.pc_audio.active) + }) + .await; + assert!(h.pc_audio_active, "unmuted A keeps the output open"); + tokio::time::sleep(Duration::from_millis(1000)).await; + hub.set_muted(vec![ida.clone(), idb.clone()]); + let h = hub.status().borrow().clone(); + assert!( + !h.pc_audio_active && h.peers.iter().all(|p| p.status.pc_audio.active), + "audio still arrives but nothing needs the output: {h:?}" + ); + + join(cap_a).await; + join(cap_b).await; + let mixed = join(out).await; + + // 2.0–3.5 s (first channel, 48 000 samples/s): only A is heard. + let first = &mixed.samples[2 * 48_000..7 * 24_000]; + let (a1000, b1500) = (tone_amplitude(first, 1000.0), tone_amplitude(first, 1500.0)); + println!("B muted: 1000 Hz {a1000:.3}, 1500 Hz {b1500:.3}"); + assert!((a1000 - 0.4).abs() < 0.08, "PC-A tone amplitude {a1000}"); + assert!(b1500 < 0.02, "muted PC-B must not be heard ({b1500})"); + // Last second: silence, while both PCs keep streaming. + let last = &mixed.samples[mixed.samples.len() - 48_000..]; + println!("all muted: rms {:.5}", rms(last)); + assert_eq!(rms(last), 0.0, "all PCs muted"); + let h = hub.status().borrow().clone(); + assert!(connected(&h, &ida) && connected(&h, &idb), "muting keeps the connections"); + + // Unmuting brings the audio back without reconnecting. + hub.set_muted(Vec::new()); + let cap_b = capture_clock(b.pc_audio_capture(), 1500.0, 0.4, 150, None); + let out = playout_clock(hub.pc_audio_playout(), 150, 2.0); + join(cap_b).await; + let rx = join(out).await; + let tail = &rx.samples[rx.samples.len() - 48_000..rx.samples.len() - 4_800]; + let b1500 = tone_amplitude(tail, 1500.0); + println!("unmuted: 1500 Hz {b1500:.3}"); + assert!((b1500 - 0.4).abs() < 0.08, "PC-B after unmute {b1500}"); + assert!(hub.status().borrow().pc_audio_active); + + hub.shutdown().await; + a.shutdown().await; + b.shutdown().await; +} + +/// Android closes the output while every streaming PC is muted, so nobody pulls the playout +/// (here: never, since the hub started). Unmuting must play fresh audio: no stale backlog, no +/// added latency, no packets counted as lost. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn unmuting_after_the_output_was_closed_plays_fresh_audio() { + init_logs(); + let (da, dh) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let a = start_server(da.path(), "PC-A", NetOptions::local_only()).await; + let pa = pairing_of(&a); + let ida = pa.peer_id(); + let hub = start_hub(dh.path(), Vec::new()).await; + hub.set_muted(vec![ida.clone()]); + hub.set_peers(vec![pa]); + let mut hs = hub.status(); + wait_for(&mut hs, Duration::from_secs(10), "connected", |h| connected(h, &ida)).await; + + let cap = capture_clock(a.pc_audio_capture(), 1000.0, 0.5, 350, Some(280)); + tokio::time::sleep(Duration::from_secs(2)).await; + hub.set_muted(Vec::new()); + let out = playout_clock(hub.pc_audio_playout(), 150, 0.25); + let onset = join(cap).await.expect("loud onset pushed"); + let rx = join(out).await; + tokio::time::sleep(Duration::from_millis(600)).await; + let s = hub.status().borrow().peers[0].status.pc_audio.clone(); + let latency = rx.first_loud.map(|t| t.saturating_duration_since(onset)); + println!("after unmute: latency {latency:?}, {s:?}"); + let latency = latency.expect("loud tone heard after unmute"); + assert!(latency > Duration::ZERO && latency < Duration::from_millis(150), "latency {latency:?}"); + assert!(s.lost_packets < 10, "muting must not count as loss: {s:?}"); + assert!(s.buffer_ms < 150.0, "stale backlog: {s:?}"); + + hub.shutdown().await; + a.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn removing_a_pc_keeps_the_other_connection() { init_logs();