Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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: `<filesDir>/audiobridge/client.key`, plus SharedPreferences `audiobridge` (`paired_pcs` JSON list, `mic_enabled`, `autostart_done`).
- Phone: `<filesDir>/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).

Expand Down Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-кода.

Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
18 changes: 15 additions & 3 deletions android/app/src/main/java/app/audiobridge/AppState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<PairedPc>> = _pcs.asStateFlow()
Expand Down Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion android/app/src/main/java/app/audiobridge/BridgeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)) {
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions android/app/src/main/java/app/audiobridge/BridgeStatus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -145,7 +148,9 @@ data class PairedPc(val id: String, val name: String, val uri: String) {
}

fun encodeList(pcs: List<PairedPc>): 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. */
Expand All @@ -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
}
Expand All @@ -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<PairedPc>): String = JSONArray().apply { pcs.forEach { put(it.uri) } }.toString()

/** The JSON array of muted PC ids passed to [NativeBridge.setMuted]. */
fun mutedIdsJson(pcs: List<PairedPc>): String =
JSONArray().apply { pcs.filter { it.muted }.forEach { put(it.id) } }.toString()
}
}

Expand Down
12 changes: 12 additions & 0 deletions android/app/src/main/java/app/audiobridge/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ class MainActivity : ComponentActivity() {
snackbarHostState = snackbar,
onAddPc = ::scan,
onRemovePc = ::removePc,
onMutePc = ::setPcMuted,
onMuteAll = ::setAllMuted,
onMicToggle = ::setMic,
)
}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions android/app/src/main/java/app/audiobridge/NativeBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading