feat(nests): align moq-lite catalog with kixelated/hang shape

Switch RoomSpeakerCatalog to the canonical hang catalog format
(camelCase audio.renditions[<track>] with container.kind), wrap
audio frames in the legacy varint(timestamp_us) prefix on publish,
and strip it on subscribe. Also reply to inbound Probe/Fetch/Session
bidis with a bitrate hint or clean FIN instead of hanging.
This commit is contained in:
Claude
2026-05-06 03:16:01 +00:00
parent 5389b32e52
commit 5b7e347ac0
7 changed files with 281 additions and 65 deletions
@@ -22,35 +22,67 @@ package com.vitorpamplona.amethyst.commons.viewmodels
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* Decoded shape of a moq-lite `catalog.json` track for a single * Decoded shape of a moq-lite `catalog.json` track for a single
* audio-room speaker. Each broadcast advertises one or more tracks * audio-room speaker. Mirrors the kixelated/moq `hang` reference
* (one per codec / quality tier) — for nests audio rooms there's * catalog format — the canonical shape that `@kixelated/hang`'s
* exactly one Opus track today, but the model carries a list for * browser watcher and the `moq-rs` Rust hang crate both produce and
* forward compatibility. * consume — so Amethyst publishers are visible to the moq-lite browser
* reference, and Amethyst listeners parse standards-aligned catalogs
* from non-Amethyst publishers.
* *
* Best-effort schema: nostrnests' upstream moq-lite catalog spec * Shape (verbatim from `kixelated/moq/rs/hang/src/catalog/`):
* (kixelated/moq) has evolved across revisions, and not every *
* publisher fills in every field. The parser tolerates missing * {
* keys via `ignoreUnknownKeys = true` on [JsonMapper] and the * "audio": {
* `?`-marked properties. * "renditions": {
* "<trackName>": {
* "codec": "opus",
* "container": { "kind": "legacy" },
* "sampleRate": 48000,
* "numberOfChannels": 1,
* "bitrate": 32000 // optional
* }
* }
* }
* }
*
* Keys are camelCase per the upstream serde `rename_all = "camelCase"`.
* Every field except the rendition-key string is optional in the parser
* — older / partial publishers are tolerated. Field semantics:
*
* - rendition key: the moq-lite `track` name a subscriber should
* subscribe to for that audio rendition's frames (commonly the same
* string for single-rendition broadcasts). Subscribers pick a
* rendition (e.g. by codec / bitrate) and use this key as the
* Subscribe.track string.
* - `codec`: codec mimetype string (`"opus"`, `"mp4a.40.2"` for AAC).
* - `container.kind`: frame wrapper. `"legacy"` = each frame is
* `varint(timestamp_us) + raw_codec_payload` inside the moq-lite
* group. `"cmaf"` = MOOF/MDAT-fragmented MP4. We only emit/parse
* `legacy` today; unknown kinds are ignored at parse time.
* - `sampleRate` / `numberOfChannels`: PCM source params.
*/ */
@Immutable @Immutable
@Serializable @Serializable
data class RoomSpeakerCatalog( data class RoomSpeakerCatalog(
val version: Int? = null, val audio: Audio? = null,
val audio: List<AudioTrack> = emptyList(),
) { ) {
@Immutable @Immutable
@Serializable @Serializable
data class AudioTrack( data class Audio(
val track: String? = null, val renditions: Map<String, AudioConfig> = emptyMap(),
)
@Immutable
@Serializable
data class AudioConfig(
val codec: String? = null, val codec: String? = null,
@SerialName("sample_rate") val sampleRate: Int? = null, val container: Container? = null,
@SerialName("channel_count") val channelCount: Int? = null, val sampleRate: Int? = null,
val numberOfChannels: Int? = null,
val bitrate: Int? = null, val bitrate: Int? = null,
) { ) {
/** /**
@@ -64,14 +96,25 @@ data class RoomSpeakerCatalog(
buildList { buildList {
codec?.takeIf { it.isNotBlank() }?.let { add(it.uppercase()) } codec?.takeIf { it.isNotBlank() }?.let { add(it.uppercase()) }
sampleRate?.let { add("${it / 1000}kHz") } sampleRate?.let { add("${it / 1000}kHz") }
channelCount?.let { add(if (it == 1) "mono" else "${it}ch") } numberOfChannels?.let { add(if (it == 1) "mono" else "${it}ch") }
} }
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
} }
} }
/** First audio track, if any. The current single-Opus reality. */ @Immutable
fun primaryAudio(): AudioTrack? = audio.firstOrNull() @Serializable
data class Container(
val kind: String? = null,
)
/**
* First audio rendition, if any. The current single-Opus reality.
* Map iteration order is the JSON insertion order (kotlinx.serialization
* uses LinkedHashMap), so this picks the first rendition the
* publisher declared rather than an arbitrary one.
*/
fun primaryAudio(): AudioConfig? = audio?.renditions?.values?.firstOrNull()
companion object { companion object {
/** /**
@@ -27,69 +27,72 @@ import kotlin.test.assertNull
class RoomSpeakerCatalogTest { class RoomSpeakerCatalogTest {
@Test @Test
fun parsesNostrnestsShape() { fun parsesKixelatedHangShape() {
// Verbatim shape produced by the canonical kixelated/moq `hang`
// crate (`rs/hang/src/catalog/`). Verifies that an Amethyst
// listener picks up a standards-aligned publisher's catalog.
val json = val json =
""" """
{ {
"version": 1, "audio": {
"audio": [ "renditions": {
{ "audio/data": {
"track": "audio/data", "codec": "opus",
"codec": "opus", "container": { "kind": "legacy" },
"sample_rate": 48000, "sampleRate": 48000,
"channel_count": 2, "numberOfChannels": 2,
"bitrate": 64000 "bitrate": 64000
}
} }
] }
} }
""".trimIndent() """.trimIndent()
val catalog = RoomSpeakerCatalog.parseOrNull(json.encodeToByteArray()) val catalog = RoomSpeakerCatalog.parseOrNull(json.encodeToByteArray())
assertNotNull(catalog) assertNotNull(catalog)
assertEquals(1, catalog.version) assertEquals(1, catalog.audio?.renditions?.size)
assertEquals(1, catalog.audio.size) val rendition = catalog.primaryAudio()
val track = catalog.primaryAudio() assertNotNull(rendition)
assertNotNull(track) assertEquals("opus", rendition.codec)
assertEquals("audio/data", track.track) assertEquals("legacy", rendition.container?.kind)
assertEquals("opus", track.codec) assertEquals(48_000, rendition.sampleRate)
assertEquals(48_000, track.sampleRate) assertEquals(2, rendition.numberOfChannels)
assertEquals(2, track.channelCount) assertEquals(64_000, rendition.bitrate)
assertEquals(64_000, track.bitrate)
} }
@Test @Test
fun describeFormatsHumanReadable() { fun describeFormatsHumanReadable() {
val track = val rendition =
RoomSpeakerCatalog.AudioTrack( RoomSpeakerCatalog.AudioConfig(
codec = "opus", codec = "opus",
sampleRate = 48_000, sampleRate = 48_000,
channelCount = 2, numberOfChannels = 2,
) )
// Codec uppercased, kHz / channel count short-formed — // Codec uppercased, kHz / channel count short-formed —
// intended for a single-line tooltip in the participant // intended for a single-line tooltip in the participant
// sheet, not a verbose codec dump. // sheet, not a verbose codec dump.
assertEquals("OPUS · 48kHz · 2ch", track.describe()) assertEquals("OPUS · 48kHz · 2ch", rendition.describe())
} }
@Test @Test
fun describeMonoIsLabelled() { fun describeMonoIsLabelled() {
val track = RoomSpeakerCatalog.AudioTrack(codec = "opus", channelCount = 1) val rendition = RoomSpeakerCatalog.AudioConfig(codec = "opus", numberOfChannels = 1)
assertEquals("OPUS · mono", track.describe()) assertEquals("OPUS · mono", rendition.describe())
} }
@Test @Test
fun describeAllNullReturnsNull() { fun describeAllNullReturnsNull() {
val track = RoomSpeakerCatalog.AudioTrack() val rendition = RoomSpeakerCatalog.AudioConfig()
// Empty catalog → caller doesn't render a "unknown · unknown" // Empty catalog → caller doesn't render a "unknown · unknown"
// line; describe() returns null so the UI can omit cleanly. // line; describe() returns null so the UI can omit cleanly.
assertNull(track.describe()) assertNull(rendition.describe())
} }
@Test @Test
fun toleratesUnknownKeys() { fun toleratesUnknownKeys() {
// Forward-compat: future moq-lite catalog revisions can add // Forward-compat: future hang catalog revisions can add
// fields without breaking older clients. // fields without breaking older clients.
val json = val json =
"""{"version":2,"audio":[{"track":"audio/data","codec":"opus","extra":"future-only"}],"new_top_level":true}""" """{"audio":{"renditions":{"audio/data":{"codec":"opus","extra":"future-only"}}},"video":{},"newTopLevel":true}"""
val catalog = RoomSpeakerCatalog.parseOrNull(json.encodeToByteArray()) val catalog = RoomSpeakerCatalog.parseOrNull(json.encodeToByteArray())
assertNotNull(catalog) assertNotNull(catalog)
assertEquals("opus", catalog.primaryAudio()?.codec) assertEquals("opus", catalog.primaryAudio()?.codec)
@@ -104,12 +107,43 @@ class RoomSpeakerCatalogTest {
} }
@Test @Test
fun emptyAudioListIsAllowed() { fun emptyAudioRenditionsIsAllowed() {
// A publisher might emit an "advertising" catalog with no // A publisher might emit an "advertising" catalog with no
// tracks yet (e.g. between codec switches). Accept the shape // renditions yet (e.g. between codec switches). Accept the
// and let primaryAudio() return null. // shape and let primaryAudio() return null.
val catalog = RoomSpeakerCatalog.parseOrNull("""{"version":1,"audio":[]}""".encodeToByteArray()) val catalog = RoomSpeakerCatalog.parseOrNull("""{"audio":{"renditions":{}}}""".encodeToByteArray())
assertNotNull(catalog) assertNotNull(catalog)
assertNull(catalog.primaryAudio()) assertNull(catalog.primaryAudio())
} }
@Test
fun missingAudioReturnsNullPrimary() {
// hang catalogs declaring only video MUST still parse without
// throwing — primaryAudio() returns null.
val catalog = RoomSpeakerCatalog.parseOrNull("""{}""".encodeToByteArray())
assertNotNull(catalog)
assertNull(catalog.primaryAudio())
}
@Test
fun stripPrefixRoundTripsCanonicalCatalog() {
// The catalog payload [com.vitorpamplona.nestsclient.MoqLiteNestsSpeaker]
// emits MUST round-trip through the parser — guards against
// either side drifting from the kixelated/hang shape.
// SPEAKER_CATALOG_JSON lives in nestsClient and isn't
// accessible from commons; keep an inline literal that mirrors
// it verbatim so a desync triggers this assertion.
val emitted =
"{\"audio\":{\"renditions\":{\"audio/data\":{" +
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":1}}}}"
val catalog = RoomSpeakerCatalog.parseOrNull(emitted.encodeToByteArray())
assertNotNull(catalog)
val rendition = catalog.primaryAudio()
assertNotNull(rendition)
assertEquals("opus", rendition.codec)
assertEquals("legacy", rendition.container?.kind)
assertEquals(48_000, rendition.sampleRate)
assertEquals(1, rendition.numberOfChannels)
}
} }
@@ -82,14 +82,36 @@ class MoqLiteNestsListener internal constructor(
override suspend fun subscribeSpeaker( override suspend fun subscribeSpeaker(
speakerPubkeyHex: String, speakerPubkeyHex: String,
maxLatencyMs: Long, maxLatencyMs: Long,
): SubscribeHandle = wrapSubscription(broadcast = speakerPubkeyHex, track = AUDIO_TRACK, maxLatencyMs = maxLatencyMs) ): SubscribeHandle =
wrapSubscription(
broadcast = speakerPubkeyHex,
track = AUDIO_TRACK,
maxLatencyMs = maxLatencyMs,
// Audio frames arrive in kixelated/moq `hang` "legacy"
// container layout: each moq-lite frame is
// `varint(timestamp_us) + raw_opus_packet`. Strip the
// leading varint so downstream decoders see a pristine
// Opus packet exactly as if it came from
// [com.vitorpamplona.nestsclient.audio.OpusEncoder].
stripLegacyTimestamp = true,
)
override suspend fun subscribeCatalog(speakerPubkeyHex: String): SubscribeHandle = wrapSubscription(broadcast = speakerPubkeyHex, track = CATALOG_TRACK, maxLatencyMs = 0L) override suspend fun subscribeCatalog(speakerPubkeyHex: String): SubscribeHandle =
wrapSubscription(
broadcast = speakerPubkeyHex,
track = CATALOG_TRACK,
maxLatencyMs = 0L,
// Catalog frames carry raw JSON bytes — no container
// wrapping (the catalog itself is what tells you which
// container the audio track uses).
stripLegacyTimestamp = false,
)
private suspend fun wrapSubscription( private suspend fun wrapSubscription(
broadcast: String, broadcast: String,
track: String, track: String,
maxLatencyMs: Long, maxLatencyMs: Long,
stripLegacyTimestamp: Boolean,
): SubscribeHandle { ): SubscribeHandle {
check(state.value is NestsListenerState.Connected) { check(state.value is NestsListenerState.Connected) {
"NestsListener.subscribe requires Connected state, was ${state.value}" "NestsListener.subscribe requires Connected state, was ${state.value}"
@@ -112,12 +134,13 @@ class MoqLiteNestsListener internal constructor(
val objectIdSeq = AtomicLong(0L) val objectIdSeq = AtomicLong(0L)
val mapped = val mapped =
handle.frames.map { frame -> handle.frames.map { frame ->
val payload = if (stripLegacyTimestamp) stripLegacyTimestampPrefix(frame.payload) else frame.payload
MoqObject( MoqObject(
trackAlias = handle.id, trackAlias = handle.id,
groupId = frame.groupSequence, groupId = frame.groupSequence,
objectId = objectIdSeq.getAndIncrement(), objectId = objectIdSeq.getAndIncrement(),
publisherPriority = MoqLiteSession.DEFAULT_PRIORITY, publisherPriority = MoqLiteSession.DEFAULT_PRIORITY,
payload = frame.payload, payload = payload,
) )
} }
@@ -242,5 +265,23 @@ class MoqLiteNestsListener internal constructor(
* [subscribeCatalog]. * [subscribeCatalog].
*/ */
const val CATALOG_TRACK: String = "catalog.json" const val CATALOG_TRACK: String = "catalog.json"
/**
* Strip the leading `varint(timestamp_us)` prefix from a
* kixelated/moq `hang` "legacy" container frame, returning
* the bare codec payload (e.g. an Opus packet) for downstream
* decoders. The varint length is encoded in the top 2 bits of
* the first byte per RFC 9000 §16: `00`→1, `01`→2, `10`→4,
* `11`→8 bytes. Returns the payload unchanged when the
* varint header would overrun the payload (malformed frame —
* surface upstream rather than silently mask).
*/
internal fun stripLegacyTimestampPrefix(payload: ByteArray): ByteArray {
if (payload.isEmpty()) return payload
val tag = (payload[0].toInt() ushr 6) and 0x3
val varintLen = 1 shl tag
if (payload.size < varintLen) return payload
return payload.copyOfRange(varintLen, payload.size)
}
} }
} }
@@ -203,17 +203,29 @@ class MoqLiteNestsSpeaker internal constructor(
companion object { companion object {
/** /**
* moq-lite catalog manifest broadcast on the * moq-lite catalog manifest broadcast on the
* [MoqLiteNestsListener.CATALOG_TRACK] track. The shape mirrors * [MoqLiteNestsListener.CATALOG_TRACK] track. Verbatim
* what the kixelated/moq browser watcher parses * canonical kixelated/moq `hang` shape (camelCase, nested
* ([com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog]): * `audio.renditions[<trackName>]`, `container.kind = "legacy"`).
* a versioned envelope with an `audio[]` of track descriptors. * The rendition key MUST equal the moq-lite track we publish
* audio frames on ([MoqLiteNestsListener.AUDIO_TRACK]) so the
* watcher's "subscribe to this rendition" path resolves to our
* actual audio stream.
*
* `container.kind = "legacy"` declares the wire layout the
* watcher must expect inside each moq-lite frame: a single
* `varint(timestamp_us)` prefix followed by the raw codec
* payload (no MOOF/MDAT wrapping). The publisher side honours
* this contract in
* [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster].
*
* Hard-coded because the speaker's encoder is fixed at * Hard-coded because the speaker's encoder is fixed at
* 48 kHz mono Opus today; if [OpusEncoder] becomes parameterised * 48 kHz mono Opus today; if [OpusEncoder] becomes parameterised
* this should be derived from the encoder config instead. * this should be derived from the encoder config instead.
*/ */
const val SPEAKER_CATALOG_JSON: String = const val SPEAKER_CATALOG_JSON: String =
"{\"version\":1,\"audio\":[{\"track\":\"audio/data\"," + "{\"audio\":{\"renditions\":{\"" + MoqLiteNestsListener.AUDIO_TRACK + "\":{" +
"\"codec\":\"opus\",\"sample_rate\":48000,\"channel_count\":1}]}" "\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":1}}}}"
/** /**
* How often the catalog group is re-emitted. The relay drops * How often the catalog group is re-emitted. The relay drops
@@ -21,11 +21,14 @@
package com.vitorpamplona.nestsclient.audio package com.vitorpamplona.nestsclient.audio
import com.vitorpamplona.nestsclient.moq.lite.MoqLitePublisherHandle import com.vitorpamplona.nestsclient.moq.lite.MoqLitePublisherHandle
import com.vitorpamplona.quic.Varint
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.TimeMark
import kotlin.time.TimeSource
/** /**
* Mirror of [NestBroadcaster] but driving a moq-lite * Mirror of [NestBroadcaster] but driving a moq-lite
@@ -195,6 +198,21 @@ class NestMoqLiteBroadcaster(
// and the relay would see two unrelated uni streams under // and the relay would see two unrelated uni streams under
// the same logical group. // the same logical group.
var lastPublisher: MoqLitePublisherHandle = publisher var lastPublisher: MoqLitePublisherHandle = publisher
// kixelated/moq `hang` "legacy" container wire format:
// every frame inside a moq-lite group is
// varint(timestamp_us) + raw_codec_payload
// (`rs/hang/src/container/frame.rs`,
// Timescale<1_000_000>). Watchers that read our
// catalog's `container.kind = "legacy"` declaration
// will skip the leading varint as a microsecond
// timestamp; they MUST receive a real timestamp or
// their decoder picks up garbage bytes ahead of the
// Opus packet. We use a monotonic mark captured at
// capture-loop start so timestamps are robust to mute
// gaps and survive publisher hot-swap (the wire is
// single-broadcast, single-encoder; timestamps are
// codec-payload metadata, not stream-position).
val frameStartMark: TimeMark = TimeSource.Monotonic.markNow()
try { try {
while (true) { while (true) {
val pcm = capture.readFrame() ?: break val pcm = capture.readFrame() ?: break
@@ -233,7 +251,11 @@ class NestMoqLiteBroadcaster(
// for the production cliff this works around. // for the production cliff this works around.
val sendOutcome = val sendOutcome =
runCatching { runCatching {
val accepted = current.send(opus) val tsBytes = Varint.encode(frameStartMark.elapsedNow().inWholeMicroseconds)
val payload = ByteArray(tsBytes.size + opus.size)
tsBytes.copyInto(payload, 0)
opus.copyInto(payload, tsBytes.size)
val accepted = current.send(payload)
framesInCurrentGroup += 1 framesInCurrentGroup += 1
if (framesInCurrentGroup >= framesPerGroup) { if (framesInCurrentGroup >= framesPerGroup) {
current.endGroup() current.endGroup()
@@ -251,6 +251,20 @@ object MoqLiteCodec {
return MoqLiteProbe(bitrate = bitrate) return MoqLiteProbe(bitrate = bitrate)
} }
/**
* Encode a single Lite-03 Probe message body
* (`lite/probe.rs` — `bitrate: u62` only; `rtt` is Lite-04+).
* The publisher writes these size-prefixed onto a Probe bidi the
* subscriber opened, advertising the publisher's expected
* bandwidth. Wrapping (size prefix) is the caller's responsibility,
* matching [encodeAnnouncePlease] / [encodeAnnounce].
*/
fun encodeProbe(probe: MoqLiteProbe): ByteArray {
val body = MoqWriter()
body.writeVarint(probe.bitrate)
return wrapSizePrefixed(body)
}
// ---------------- internals ---------------- // ---------------- internals ----------------
/** /**
@@ -883,9 +883,50 @@ class MoqLiteSession internal constructor(
dispatched = true dispatched = true
} }
else -> { MoqLiteControlType.Probe -> {
// Lite-03 treats Session/Fetch/Probe as // Subscriber-opened bidi asking us (the
// separate flows; we don't implement them. // publisher) for a bitrate hint. Per
// `lite/probe.rs:Lite03+`, the publisher
// writes one or more size-prefixed
// `Probe { bitrate: u62 }` messages on
// this bidi. We're a fixed-rate Opus
// publisher, so emit a single hint and
// FIN our write side — the peer treats
// FIN as "no further updates" rather than
// an error. Better than the old behaviour
// of FINing without writing anything,
// which left the subscriber's ABR
// estimator with no signal.
runCatching {
bidi.write(MoqLiteCodec.encodeProbe(MoqLiteProbe(bitrate = NESTS_AUDIO_BITRATE_HINT_BPS)))
bidi.finish()
}
dispatched = true
}
MoqLiteControlType.Fetch -> {
// Subscriber-opened bidi requesting a
// historical group (`lite/fetch.rs`).
// Audio rooms are live-only — we have no
// group history to serve. FINing the
// write side without any reply is the
// spec-clean way to signal "no groups
// available"; the subscriber's wait on
// its receive side resolves to
// end-of-stream and it falls back to a
// live Subscribe. Ignoring inbound bytes
// (the request body) is fine: we don't
// need to know which group was requested
// because we couldn't serve any of them.
runCatching { bidi.finish() }
dispatched = true
}
MoqLiteControlType.Session -> {
// ControlType=0 was the Lite-01/02 setup
// exchange. Lite-03 doesn't use it; if a
// legacy peer sends one, FIN cleanly so
// their bidi resolves rather than hanging.
runCatching { bidi.finish() } runCatching { bidi.finish() }
dispatched = true dispatched = true
} }
@@ -1194,6 +1235,15 @@ class MoqLiteSession internal constructor(
/** moq-lite priority byte midpoint — neutral default. */ /** moq-lite priority byte midpoint — neutral default. */
const val DEFAULT_PRIORITY: Int = 0x80 const val DEFAULT_PRIORITY: Int = 0x80
/**
* Bitrate hint (bits/sec) we report on inbound moq-lite Probe
* bidis as a publisher. Mirrors the upper-bound of an Opus
* voice profile at 48 kHz mono, ≈32 kbps. Subscriber-side ABR
* estimators use this to size their forward queue; we emit the
* single hint and FIN since our encoder runs at a fixed bitrate.
*/
const val NESTS_AUDIO_BITRATE_HINT_BPS: Long = 32_000L
// Diagnostic: log "send returned false" once every N invocations. // Diagnostic: log "send returned false" once every N invocations.
// At 50 fps and N=50 → ≤ 1 log/sec for a sustained no-sub window. // At 50 fps and N=50 → ≤ 1 log/sec for a sustained no-sub window.
private const val SEND_LOG_THROTTLE: Long = 50L private const val SEND_LOG_THROTTLE: Long = 50L