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
@@ -60,6 +60,42 @@ interface OpusDecoder {
fun release()
}
/**
* Encoder for one PCM frame at a time.
*
* Mirror of [OpusDecoder] for the publish direction. Like the decoder, Opus
* encoder state is per-stream — one instance per outgoing track.
*/
interface OpusEncoder {
/**
* Encode one PCM frame (typically [AudioFormat.FRAME_SIZE_SAMPLES] samples
* of signed 16-bit mono at [AudioFormat.SAMPLE_RATE_HZ]) into one Opus
* packet. Returns an empty array if the encoder is still warming up
* (some pipelines need a few frames before producing output).
*/
fun encode(pcm: ShortArray): ByteArray
fun release()
}
/**
* Source for PCM audio capture. Implementations open the device's microphone
* and produce one frame at a time via [readFrame].
*/
interface AudioCapture {
/** Allocate the microphone resource and begin capturing. */
fun start()
/**
* Read one PCM frame ([AudioFormat.FRAME_SIZE_SAMPLES] samples). Suspends
* until enough samples are available. Returns null when [stop] is called.
*/
suspend fun readFrame(): ShortArray?
/** Stop capture and release the microphone. After this, [readFrame] returns null. */
fun stop()
}
/**
* Sink for PCM audio playback. Implementations buffer internally — [enqueue]
* may suspend if the device's playback buffer is full.
@@ -73,6 +73,11 @@ object MoqCodec {
MoqMessageType.SubscribeOk -> decodeSubscribeOk(reader)
MoqMessageType.SubscribeError -> decodeSubscribeError(reader)
MoqMessageType.Unsubscribe -> decodeUnsubscribe(reader)
MoqMessageType.SubscribeDone -> decodeSubscribeDone(reader)
MoqMessageType.Announce -> decodeAnnounce(reader)
MoqMessageType.AnnounceOk -> decodeAnnounceOk(reader)
MoqMessageType.AnnounceError -> decodeAnnounceError(reader)
MoqMessageType.Unannounce -> decodeUnannounce(reader)
}
if (reader.hasMore()) {
throw MoqCodecException(
@@ -90,6 +95,11 @@ object MoqCodec {
is SubscribeOk -> encodeSubscribeOk(message)
is SubscribeError -> encodeSubscribeError(message)
is Unsubscribe -> encodeUnsubscribe(message)
is SubscribeDone -> encodeSubscribeDone(message)
is Announce -> encodeAnnounce(message)
is AnnounceOk -> encodeAnnounceOk(message)
is AnnounceError -> encodeAnnounceError(message)
is Unannounce -> encodeUnannounce(message)
}
private fun encodeClientSetup(message: ClientSetup): ByteArray {
@@ -268,6 +278,63 @@ object MoqCodec {
private fun decodeUnsubscribe(r: MoqReader): Unsubscribe = Unsubscribe(r.readVarint())
private fun encodeSubscribeDone(m: SubscribeDone): ByteArray {
val w = MoqWriter()
w.writeVarint(m.subscribeId)
w.writeVarint(m.statusCode)
w.writeVarint(m.streamCount)
w.writeLengthPrefixedString(m.reasonPhrase)
return w.toByteArray()
}
private fun decodeSubscribeDone(r: MoqReader): SubscribeDone =
SubscribeDone(
subscribeId = r.readVarint(),
statusCode = r.readVarint(),
streamCount = r.readVarint(),
reasonPhrase = r.readLengthPrefixedString(),
)
private fun encodeAnnounce(m: Announce): ByteArray {
val w = MoqWriter()
encodeNamespace(w, m.namespace)
encodeParameters(w, m.parameters)
return w.toByteArray()
}
private fun decodeAnnounce(r: MoqReader): Announce = Announce(namespace = decodeNamespace(r), parameters = decodeParameters(r))
private fun encodeAnnounceOk(m: AnnounceOk): ByteArray {
val w = MoqWriter()
encodeNamespace(w, m.namespace)
return w.toByteArray()
}
private fun decodeAnnounceOk(r: MoqReader): AnnounceOk = AnnounceOk(decodeNamespace(r))
private fun encodeAnnounceError(m: AnnounceError): ByteArray {
val w = MoqWriter()
encodeNamespace(w, m.namespace)
w.writeVarint(m.errorCode)
w.writeLengthPrefixedString(m.reasonPhrase)
return w.toByteArray()
}
private fun decodeAnnounceError(r: MoqReader): AnnounceError =
AnnounceError(
namespace = decodeNamespace(r),
errorCode = r.readVarint(),
reasonPhrase = r.readLengthPrefixedString(),
)
private fun encodeUnannounce(m: Unannounce): ByteArray {
val w = MoqWriter()
encodeNamespace(w, m.namespace)
return w.toByteArray()
}
private fun decodeUnannounce(r: MoqReader): Unannounce = Unannounce(decodeNamespace(r))
data class DecodeResult(
val message: MoqMessage,
val bytesConsumed: Int,
@@ -44,7 +44,12 @@ enum class MoqMessageType(
Subscribe(0x03),
SubscribeOk(0x04),
SubscribeError(0x05),
Announce(0x06),
AnnounceOk(0x07),
AnnounceError(0x08),
Unannounce(0x09),
Unsubscribe(0x0A),
SubscribeDone(0x0B),
ClientSetup(0x40),
ServerSetup(0x41),
;
@@ -254,3 +259,53 @@ data class Unsubscribe(
) : MoqMessage() {
override val type: MoqMessageType = MoqMessageType.Unsubscribe
}
/**
* SUBSCRIBE_DONE (0x0B): publisher tells the subscriber that no more objects
* are coming for this subscription, optionally indicating the last group/object
* boundary. Sent on subscription expiry, publisher-side track closure, or
* after an UNSUBSCRIBE was acknowledged.
*/
data class SubscribeDone(
val subscribeId: Long,
val statusCode: Long,
val streamCount: Long,
val reasonPhrase: String,
) : MoqMessage() {
override val type: MoqMessageType = MoqMessageType.SubscribeDone
}
/**
* ANNOUNCE (0x06): publisher offers a track namespace. nests publishers send
* one ANNOUNCE per audio-room they host so subscribers know which namespace
* to subscribe under.
*/
data class Announce(
val namespace: TrackNamespace,
val parameters: List<SetupParameter> = emptyList(),
) : MoqMessage() {
override val type: MoqMessageType = MoqMessageType.Announce
}
/** ANNOUNCE_OK (0x07): subscriber acknowledges an ANNOUNCE. */
data class AnnounceOk(
val namespace: TrackNamespace,
) : MoqMessage() {
override val type: MoqMessageType = MoqMessageType.AnnounceOk
}
/** ANNOUNCE_ERROR (0x08): subscriber rejects an ANNOUNCE. */
data class AnnounceError(
val namespace: TrackNamespace,
val errorCode: Long,
val reasonPhrase: String,
) : MoqMessage() {
override val type: MoqMessageType = MoqMessageType.AnnounceError
}
/** UNANNOUNCE (0x09): publisher withdraws a previously-announced namespace. */
data class Unannounce(
val namespace: TrackNamespace,
) : MoqMessage() {
override val type: MoqMessageType = MoqMessageType.Unannounce
}