From b62e3dd0ec4b4fb0fb5b8b7fa13869ae93f201b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 07:33:55 +0000 Subject: [PATCH] feat(nestsClient): MoQ ANNOUNCE family + Opus encoder + AudioRecord capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../nestsclient/audio/AudioRecordCapture.kt | 136 +++++++++++++++++ .../audio/MediaCodecOpusEncoder.kt | 139 ++++++++++++++++++ .../vitorpamplona/nestsclient/audio/Audio.kt | 36 +++++ .../vitorpamplona/nestsclient/moq/MoqCodec.kt | 67 +++++++++ .../nestsclient/moq/MoqMessage.kt | 55 +++++++ .../nestsclient/moq/AnnounceCodecTest.kt | 102 +++++++++++++ 6 files changed, 535 insertions(+) create mode 100644 nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt create mode 100644 nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/AnnounceCodecTest.kt diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt new file mode 100644 index 000000000..1f5cfa1a1 --- /dev/null +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt @@ -0,0 +1,136 @@ +/* + * 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.audio + +import android.media.AudioRecord +import android.media.MediaRecorder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import android.media.AudioFormat as AndroidAudioFormat + +/** + * [AudioCapture] backed by Android's [AudioRecord] from the + * VOICE_COMMUNICATION input source — same source LiveKit, WebRTC, and most + * voice-chat libraries use, so it gets the platform's echo-cancellation and + * noise-suppression filters when available. + * + * **Permission:** the caller is responsible for holding `RECORD_AUDIO` before + * calling [start]; this class will throw [AudioException.Kind.DeviceUnavailable] + * if the OS denies the resource. + */ +class AudioRecordCapture( + private val source: Int = MediaRecorder.AudioSource.VOICE_COMMUNICATION, +) : AudioCapture { + private var record: AudioRecord? = null + private var stopped = false + + override fun start() { + check(!stopped) { "capture already stopped" } + if (record != null) return + + val channelMask = + when (AudioFormat.CHANNELS) { + 1 -> AndroidAudioFormat.CHANNEL_IN_MONO + 2 -> AndroidAudioFormat.CHANNEL_IN_STEREO + else -> error("unsupported channel count ${AudioFormat.CHANNELS}") + } + + val minBuffer = + AudioRecord.getMinBufferSize( + AudioFormat.SAMPLE_RATE_HZ, + channelMask, + AndroidAudioFormat.ENCODING_PCM_16BIT, + ) + if (minBuffer <= 0) { + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "AudioRecord.getMinBufferSize returned $minBuffer for ${AudioFormat.SAMPLE_RATE_HZ} Hz", + ) + } + val bufferBytes = + maxOf(minBuffer, AudioFormat.FRAME_SIZE_SAMPLES * AudioFormat.BYTES_PER_SAMPLE * 4) + + val rec = + try { + @Suppress("MissingPermission") + AudioRecord(source, AudioFormat.SAMPLE_RATE_HZ, channelMask, AndroidAudioFormat.ENCODING_PCM_16BIT, bufferBytes) + } catch (t: Throwable) { + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "Failed to construct AudioRecord (RECORD_AUDIO permission?)", + t, + ) + } + if (rec.state != AudioRecord.STATE_INITIALIZED) { + runCatching { rec.release() } + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "AudioRecord state=${rec.state} after construction (expected INITIALIZED)", + ) + } + try { + rec.startRecording() + } catch (t: Throwable) { + runCatching { rec.release() } + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "AudioRecord.startRecording() failed", + t, + ) + } + record = rec + } + + override suspend fun readFrame(): ShortArray? { + val rec = record ?: return null + val frame = ShortArray(AudioFormat.FRAME_SIZE_SAMPLES) + return withContext(Dispatchers.IO) { + var read = 0 + while (read < frame.size) { + if (stopped) return@withContext null + val n = rec.read(frame, read, frame.size - read) + if (n < 0) { + throw AudioException( + AudioException.Kind.PlaybackFailed, + "AudioRecord.read returned error code $n", + ) + } + if (n == 0) { + // Underrun — wait briefly and retry. This avoids busy-waiting + // on a slow producer. + kotlinx.coroutines.delay(2) + continue + } + read += n + } + frame + } + } + + override fun stop() { + if (stopped) return + stopped = true + val rec = record ?: return + record = null + runCatching { rec.stop() } + runCatching { rec.release() } + } +} diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt new file mode 100644 index 000000000..e68424040 --- /dev/null +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt @@ -0,0 +1,139 @@ +/* + * 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.audio + +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import java.nio.ByteOrder + +/** + * [OpusEncoder] backed by Android's [MediaCodec] (`audio/opus`, encoder + * variant available on API 29+ — the decoder works back to API 21 but the + * encoder shipped later). One instance per outgoing track. + * + * Configuration: + * - 48 kHz mono input PCM 16-bit (matches [AudioFormat]). + * - Target bitrate ~32 kbit/s VBR — high-quality wideband speech. + * - 20 ms frames (the encoder requires the input buffer to hold one frame + * at a time for low latency). + */ +class MediaCodecOpusEncoder( + private val targetBitrate: Int = DEFAULT_BITRATE_BPS, +) : OpusEncoder { + private val codec: MediaCodec = + try { + MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).apply { + configure(buildFormat(targetBitrate), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + start() + } + } catch (t: Throwable) { + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "Failed to allocate MediaCodec audio/opus encoder", + t, + ) + } + + private val bufferInfo = MediaCodec.BufferInfo() + private var presentationTimeUs: Long = 0L + private var released = false + + override fun encode(pcm: ShortArray): ByteArray { + check(!released) { "encoder released" } + require(pcm.isNotEmpty()) { "PCM frame must not be empty" } + + val inputIndex = codec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US) + if (inputIndex < 0) return ByteArray(0) + val inputBuffer = + codec.getInputBuffer(inputIndex) + ?: throw AudioException( + AudioException.Kind.DecoderError, + "MediaCodec returned null input buffer at index $inputIndex", + ) + inputBuffer.clear() + inputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer().put(pcm) + val byteCount = pcm.size * AudioFormat.BYTES_PER_SAMPLE + codec.queueInputBuffer(inputIndex, 0, byteCount, presentationTimeUs, 0) + presentationTimeUs += FRAME_DURATION_US + + // One PCM frame produces one Opus packet (sometimes after one warmup + // round). Drain the output queue once. + while (true) { + val outputIndex = codec.dequeueOutputBuffer(bufferInfo, DEQUEUE_TIMEOUT_US) + when { + outputIndex >= 0 -> { + val outputBuffer = codec.getOutputBuffer(outputIndex) ?: continue + val opus = ByteArray(bufferInfo.size) + outputBuffer.position(bufferInfo.offset) + outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + outputBuffer.get(opus) + codec.releaseOutputBuffer(outputIndex, false) + if (opus.isNotEmpty()) return opus + } + + outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + continue + } + + outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> { + return ByteArray(0) + } + + else -> { + return ByteArray(0) + } + } + } + @Suppress("UNREACHABLE_CODE") + return ByteArray(0) + } + + override fun release() { + if (released) return + released = true + runCatching { codec.stop() } + runCatching { codec.release() } + } + + companion object { + const val DEFAULT_BITRATE_BPS: Int = 32_000 + private const val DEQUEUE_TIMEOUT_US = 10_000L + private const val FRAME_DURATION_US = 20_000L + + private fun buildFormat(bitrate: Int): MediaFormat = + MediaFormat + .createAudioFormat( + MediaFormat.MIMETYPE_AUDIO_OPUS, + AudioFormat.SAMPLE_RATE_HZ, + AudioFormat.CHANNELS, + ).apply { + setInteger(MediaFormat.KEY_BIT_RATE, bitrate) + setInteger(MediaFormat.KEY_PCM_ENCODING, android.media.AudioFormat.ENCODING_PCM_16BIT) + // Encoder-side AAC/Opus profile selection: SignalingDelaySamples + // is implicit; nothing else required for mono speech. + setInteger( + MediaFormat.KEY_AAC_PROFILE, + MediaCodecInfo.CodecProfileLevel.AACObjectLC, + ) + } + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt index 79c8496f4..61d00f59c 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt @@ -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. diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt index e5a02013f..c697753ea 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt @@ -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, diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt index 56a497bae..6a33f2a0a 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt @@ -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 = 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 +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/AnnounceCodecTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/AnnounceCodecTest.kt new file mode 100644 index 000000000..7aad57cda --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/AnnounceCodecTest.kt @@ -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(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(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(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(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(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(first.message) + assertIs(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) + } +}