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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user