feat(nestsClient): MoQ ANNOUNCE family + Opus encoder + AudioRecord capture

Phase 4 (codec + audio capture slice). Adds the publisher-side MoQ
control messages and the Opus encode + microphone capture pieces a
speaker needs. The host-grants-speaker UI flow is deferred — that's
multi-screen UX that should be designed before being implemented.

commonMain (nestsclient.moq):
- 5 new MoqMessageType entries with codec round-trip + tests:
  * Announce (0x06): publisher offers a track namespace.
  * AnnounceOk (0x07): subscriber acknowledges.
  * AnnounceError (0x08): subscriber rejects with error code +
    reason phrase.
  * Unannounce (0x09): publisher withdraws a previously-announced
    namespace.
  * SubscribeDone (0x0B): publisher tells the subscriber no more
    objects are coming for this subscription, with stream count and
    reason.

commonMain (nestsclient.audio):
- New `OpusEncoder` interface — symmetric to `OpusDecoder`, one
  instance per outgoing track since Opus state is per-stream.
- New `AudioCapture` interface — `start()`, `readFrame()` returns a
  PCM frame or null when stopped, `stop()` releases the mic.

androidMain (nestsclient.audio):
- `MediaCodecOpusEncoder` — wraps `MediaCodec("audio/opus")` encoder
  variant (API 29+). 48 kHz mono in, 32 kbit/s VBR Opus out, 20 ms
  frames. Drains output queue per encode call.
- `AudioRecordCapture` — wraps `AudioRecord` from
  `MediaRecorder.AudioSource.VOICE_COMMUNICATION` so the platform's
  echo-cancellation + noise-suppression filters apply when available.
  Reads exactly one PCM frame per readFrame() call, retries on
  underrun, throws AudioException(DeviceUnavailable) on permission/
  resource failures.

commonTest:
- `AnnounceCodecTest` — 7 cases covering each message round-trip,
  concatenated decode in sequence, and a guard against accidentally
  reordering MoqMessageType enum codes.

Permission already declared:
- `RECORD_AUDIO` is already in amethyst/AndroidManifest.xml — no
  manifest change needed.

What this does NOT include:
- A publisher loop class (analogous to AudioRoomPlayer for publish
  direction) — can be added when the speak-button wiring lands.
- The host-grants-speaker UI — needs design input.
- STREAM_HEADER_SUBGROUP for stream-based object delivery — datagrams
  cover the listener happy-path; streams add reliability that nests
  may or may not require.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
This commit is contained in:
Claude
2026-04-22 07:33:55 +00:00
parent 64b3367472
commit b62e3dd0ec
6 changed files with 535 additions and 0 deletions
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.moq
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
class AnnounceCodecTest {
@Test
fun announce_round_trip_with_parameters() {
val msg =
Announce(
namespace = TrackNamespace.of("nests", "test-room"),
parameters = listOf(SetupParameter(0x10L, byteArrayOf(0x01, 0x02))),
)
val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message
assertEquals(msg, assertIs<Announce>(decoded))
}
@Test
fun announce_ok_round_trip() {
val msg = AnnounceOk(namespace = TrackNamespace.of("ns"))
val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message
assertEquals(msg, assertIs<AnnounceOk>(decoded))
}
@Test
fun announce_error_round_trip() {
val msg =
AnnounceError(
namespace = TrackNamespace.of("nests", "denied-room"),
errorCode = 403,
reasonPhrase = "namespace already taken",
)
val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message
assertEquals(msg, assertIs<AnnounceError>(decoded))
}
@Test
fun unannounce_round_trip() {
val msg = Unannounce(namespace = TrackNamespace.of("nests", "test-room"))
val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message
assertEquals(msg, assertIs<Unannounce>(decoded))
}
@Test
fun subscribe_done_round_trip() {
val msg =
SubscribeDone(
subscribeId = 42,
statusCode = 0,
streamCount = 17,
reasonPhrase = "subscription expired",
)
val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message
assertEquals(msg, assertIs<SubscribeDone>(decoded))
}
@Test
fun announce_then_announce_ok_decode_in_sequence() {
val ns = TrackNamespace.of("nests", "abc")
val ann = Announce(ns)
val ok = AnnounceOk(ns)
val bytes = MoqCodec.encode(ann) + MoqCodec.encode(ok)
val first = MoqCodec.decode(bytes, offset = 0)!!
val second = MoqCodec.decode(bytes, offset = first.bytesConsumed)!!
assertIs<Announce>(first.message)
assertIs<AnnounceOk>(second.message)
assertEquals(bytes.size, first.bytesConsumed + second.bytesConsumed)
}
@Test
fun every_publisher_message_type_has_a_known_code() {
// Guards against accidentally reordering MoqMessageType enum entries.
assertEquals(0x06L, MoqMessageType.Announce.code)
assertEquals(0x07L, MoqMessageType.AnnounceOk.code)
assertEquals(0x08L, MoqMessageType.AnnounceError.code)
assertEquals(0x09L, MoqMessageType.Unannounce.code)
assertEquals(0x0BL, MoqMessageType.SubscribeDone.code)
}
}