From bc5f071fe3d8e7f850a5370a53df0b186d2f9c09 Mon Sep 17 00:00:00 2001 From: doodlecomms Date: Wed, 14 Oct 2026 11:28:11 -0400 Subject: [PATCH 1/2] android: Add SBC codec for UV-Pro audio The BTECH UV-Pro carries its audio over Bluetooth as 32 kHz mono SBC, and Android has no public SBC codec. Add a mono 8-subband encoder and decoder, ported from HTCommander's Apache-2.0 SBC code (which follows libsbc), with unit tests. Not used yet. Co-Authored-By: Claude Opus 5.5 --- .../com/js8call/example/service/SbcCodec.kt | 403 ++++++++++++++++++ .../js8call/example/service/SbcCodecTest.kt | 94 ++++ 2 files changed, 497 insertions(+) create mode 100644 android/app/src/main/java/com/js8call/example/service/SbcCodec.kt create mode 100644 android/app/src/test/java/com/js8call/example/service/SbcCodecTest.kt diff --git a/android/app/src/main/java/com/js8call/example/service/SbcCodec.kt b/android/app/src/main/java/com/js8call/example/service/SbcCodec.kt new file mode 100644 index 00000000..e7711535 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/service/SbcCodec.kt @@ -0,0 +1,403 @@ +/* + * SBC (Bluetooth low-complexity subband codec), mono 8-subband frames only. + * The BTECH UV-Pro carries its audio as SBC over a Bluetooth RFCOMM channel. + * + * Ported from HTCommander (https://github.com/Ylianst/HTCommander, src/lib/sbc), + * which follows Google's libsbc. + * Copyright 2026 Ylian Saint-Hilaire + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 + * Modified for JS8Call: translated from Dart to Kotlin and reduced to mono, + * 8 subbands, 32 kHz encoding. + */ +package com.js8call.example.service + +/** Encodes 32 kHz mono PCM into SBC frames of [blocks] x 8 samples. */ +class SbcEncoder( + private val blocks: Int = 16, + private val bitpool: Int = 40, + private val snrAllocation: Boolean = true +) { + private val x = Array(2) { Array(Sbc.SUBBANDS) { IntArray(5) } } + private val carry = LongArray(4) + private var index = 0 + + init { + require(blocks == 4 || blocks == 8 || blocks == 12 || blocks == 16) + require(bitpool in 2..Sbc.maxBitpool(blocks)) + } + + val samplesPerFrame: Int = blocks * Sbc.SUBBANDS + val frameSize: Int = Sbc.frameSize(blocks, bitpool) + + /** Encodes [samplesPerFrame] samples starting at [offset] into one frame. */ + fun encode(pcm: ShortArray, offset: Int = 0): ByteArray { + require(offset >= 0 && offset + samplesPerFrame <= pcm.size) + val sb = IntArray(samplesPerFrame) + for (blk in 0 until blocks) { + analyze(pcm, offset + blk * Sbc.SUBBANDS, sb, blk * Sbc.SUBBANDS) + } + + val scf = IntArray(Sbc.SUBBANDS) + for (s in 0 until Sbc.SUBBANDS) { + var m = 0 + for (blk in 0 until blocks) m = m or kotlin.math.abs(sb[blk * Sbc.SUBBANDS + s]) + scf[s] = if (m != 0) 31 - Integer.numberOfLeadingZeros(m) else 0 + } + val nbits = Sbc.allocateBits(scf, bitpool, snrAllocation, Sbc.FREQ_32K) + + val out = ByteArray(frameSize) + val w = BitWriter(out) + w.put(Sbc.SYNCWORD, 8) + w.put(Sbc.FREQ_32K, 2) + w.put(blocks / 4 - 1, 2) + w.put(0, 2) // mono + w.put(if (snrAllocation) 1 else 0, 1) + w.put(1, 1) // 8 subbands + w.put(bitpool, 8) + w.put(0, 8) // CRC, filled in below + for (s in 0 until Sbc.SUBBANDS) w.put(scf[s], 4) + for (blk in 0 until blocks) { + for (s in 0 until Sbc.SUBBANDS) { + val nbit = nbits[s] + if (nbit == 0) continue + val range = (1L shl nbit) - 1 + val sample = sb[blk * Sbc.SUBBANDS + s].toLong() + w.put(((((sample * range) shr (scf[s] + 1)) + range) shr 1).toInt(), nbit) + } + } + out[3] = Sbc.crc(out, 0).toByte() + return out + } + + private fun analyze(pcm: ShortArray, inOffset: Int, out: IntArray, outOffset: Int) { + val idx = index shr 1 + val odd = index and 1 + val inIdx = if (idx != 0) 5 - idx else 0 + val xs = x[odd] + for (i in 0 until Sbc.SUBBANDS) xs[i][inIdx] = pcm[inOffset + PCM_ORDER[i]].toInt() + + val t = LongArray(Sbc.SUBBANDS) + for (i in 0 until Sbc.SUBBANDS) { + var s = 0L + for (j in 0 until 5) s += xs[i][j].toLong() * ANALYSIS_W0[i][idx + j] + t[i] = s + } + val y = longArrayOf( + t[0] + carry[0], t[2] + t[3], t[4] + t[5], t[6] + t[7], + carry[1], carry[2], carry[3], t[1] + ) + + carry.fill(0) + for (j in 0 until 5) { + val w = idx + j + carry[0] += xs[0][j].toLong() * ANALYSIS_W1[0][w] + carry[1] += xs[2][j].toLong() * ANALYSIS_W1[2][w] - xs[3][j].toLong() * ANALYSIS_W1[3][w] + carry[2] += xs[4][j].toLong() * ANALYSIS_W1[4][w] - xs[5][j].toLong() * ANALYSIS_W1[5][w] + carry[3] += xs[6][j].toLong() * ANALYSIS_W1[6][w] - xs[7][j].toLong() * ANALYSIS_W1[7][w] + } + + val ys = IntArray(Sbc.SUBBANDS) { Sbc.saturate16((y[it] + (1 shl 14)) shr 15) } + index = if (index < 9) index + 1 else 0 + + for (i in 0 until Sbc.SUBBANDS) { + var s = 0L + for (j in 0 until Sbc.SUBBANDS) s += ys[j].toLong() * COS_MATRIX[i][j] + out[outOffset + i] = Sbc.saturate16((s + (1 shl 12)) shr 13) + } + } + + private class BitWriter(private val buf: ByteArray) { + private var pos = 0 + + fun put(value: Int, bits: Int) { + for (i in bits - 1 downTo 0) { + if ((value shr i) and 1 != 0) { + buf[pos shr 3] = (buf[pos shr 3].toInt() or (0x80 ushr (pos and 7))).toByte() + } + pos++ + } + } + } + + private companion object { + val PCM_ORDER = intArrayOf(7, 3, 6, 0, 5, 1, 4, 2) + + // libsbc analyze_8 window[0] and window[1] (fixed 2.13), period 5, doubled. + val ANALYSIS_W0 = Sbc.doubled( + intArrayOf(0, 185, 2228, -2228, -185), + intArrayOf(27, 480, 4039, -480, 30), + intArrayOf(5, 263, 2719, -1743, -115), + intArrayOf(58, 502, 4764, 290, 69), + intArrayOf(11, 343, 3197, -1280, -54), + intArrayOf(48, 532, 4612, 96, 65), + intArrayOf(18, 418, 3644, -856, -6), + intArrayOf(37, 521, 4367, -161, 53) + ) + val ANALYSIS_W1 = Sbc.doubled( + intArrayOf(66, 424, 4815, 424, 66), + intArrayOf(30, -480, 4039, 480, 27), + intArrayOf(69, 290, 4764, 502, 58), + intArrayOf(-115, -1743, 2719, 263, 5), + intArrayOf(65, 96, 4612, 532, 48), + intArrayOf(-54, -1280, 3197, 343, 11), + intArrayOf(53, -161, 4367, 521, 37), + intArrayOf(-6, -856, 3644, 418, 18) + ) + val COS_MATRIX = arrayOf( + intArrayOf(5793, 6811, 7568, 8035, 4551, 3135, 1598, 8192), + intArrayOf(-5793, -1598, 3135, 6811, -8035, -7568, -4551, 8192), + intArrayOf(-5793, -8035, -3135, 4551, 1598, 7568, 6811, 8192), + intArrayOf(5793, -4551, -7568, 1598, 6811, -3135, -8035, 8192), + intArrayOf(5793, 4551, -7568, -1598, -6811, -3135, 8035, 8192), + intArrayOf(-5793, 8035, -3135, -4551, -1598, 7568, -6811, 8192), + intArrayOf(-5793, 1598, 3135, -6811, 8035, -7568, 4551, 8192), + intArrayOf(5793, -6811, 7568, -8035, -4551, 3135, -1598, 8192) + ) + } +} + +/** Decodes mono 8-subband SBC frames to PCM at the frame's own sample rate. */ +class SbcDecoder { + private val v = Array(2) { Array(Sbc.SUBBANDS) { IntArray(10) } } + private var index = 0 + + /** + * Decodes the frame at [offset]. Returns its PCM, or null when the bytes + * there are not a valid mono 8-subband frame. Use [Sbc.frameLength] to step + * to the next frame. + */ + fun decode(data: ByteArray, offset: Int, length: Int): ShortArray? { + val size = Sbc.frameLength(data, offset, length) + if (size < 0) return null + val h1 = data[offset + 1].toInt() and 0xFF + val freq = (h1 shr 6) and 3 + val blocks = (((h1 shr 4) and 3) + 1) * 4 + val snr = (h1 shr 1) and 1 == 1 + val bitpool = data[offset + 2].toInt() and 0xFF + + val r = BitReader(data, offset + 4) + val scf = IntArray(Sbc.SUBBANDS) { r.get(4) } + val nbits = Sbc.allocateBits(scf, bitpool, snr, freq) + val maxScf = scf.max() + val scale = (15 - maxScf) - 1 + + val sb = IntArray(blocks * Sbc.SUBBANDS) + for (blk in 0 until blocks) { + for (s in 0 until Sbc.SUBBANDS) { + val nbit = nbits[s] + if (nbit == 0) continue + val sample = ((r.get(nbit) shl 1) or 1) * RANGE_SCALE[nbit - 1] + sb[blk * Sbc.SUBBANDS + s] = + ((sample - (1 shl 28)) shr (28 - ((scf[s] + 1) + scale))).toShort().toInt() + } + } + + val out = ShortArray(blocks * Sbc.SUBBANDS) + for (blk in 0 until blocks) synthesize(sb, blk * Sbc.SUBBANDS, scale, out, blk * Sbc.SUBBANDS) + return out + } + + private fun synthesize(input: IntArray, inOffset: Int, scale: Int, out: ShortArray, outOffset: Int) { + val dctIdx = if (index != 0) 10 - index else 0 + val odd = dctIdx and 1 + dct8(input, inOffset, scale, v[odd], v[1 - odd], dctIdx) + for (i in 0 until Sbc.SUBBANDS) { + var s = 0L + for (j in 0 until 10) s += v[odd][i][j].toLong() * SYNTHESIS_WINDOW[i][index + j] + out[outOffset + i] = Sbc.saturate16((s + (1 shl 12)) shr 13).toShort() + } + index = if (index < 9) index + 1 else 0 + } + + private fun dct8( + input: IntArray, o: Int, scale: Int, + out0: Array, out1: Array, idx: Int + ) { + val c = COS16 + val s07 = ((input[o] + input[o + 7]) shr 1).toLong() + val d07 = ((input[o] - input[o + 7]) shr 1).toLong() + val s16 = ((input[o + 1] + input[o + 6]) shr 1).toLong() + val d16 = ((input[o + 1] - input[o + 6]) shr 1).toLong() + val s25 = ((input[o + 2] + input[o + 5]) shr 1).toLong() + val d25 = ((input[o + 2] - input[o + 5]) shr 1).toLong() + val s34 = ((input[o + 3] + input[o + 4]) shr 1).toLong() + val d34 = ((input[o + 3] - input[o + 4]) shr 1).toLong() + + val shr = 12 + scale + fun round(value: Long) = (value + (1L shl (shr - 1))) shr shr + val a0 = round(((s07 + s34) - (s25 + s16)) * c[4]) + val b3 = round((-(s07 + s34) - (s25 + s16)) shl 13) + val a2 = round((s07 - s34) * c[6] + (s25 - s16) * c[2]) + val b1 = round((s34 - s07) * c[2] + (s25 - s16) * c[6]) + val a1 = round(d07 * c[5] - d16 * c[1] + d25 * c[7] + d34 * c[3]) + val b2 = round(-d07 * c[1] - d16 * c[3] - d25 * c[5] - d34 * c[7]) + val a3 = round(d07 * c[7] - d16 * c[5] + d25 * c[3] - d34 * c[1]) + val b0 = round(-d07 * c[3] + d16 * c[7] + d25 * c[1] + d34 * c[5]) + + fun sat(value: Long) = Sbc.saturate16(value) + out0[0][idx] = sat(a0); out0[7][idx] = sat(-a1); out0[1][idx] = sat(a1); out0[6][idx] = sat(-a2) + out0[2][idx] = sat(a2); out0[5][idx] = sat(-a3); out0[3][idx] = sat(a3); out0[4][idx] = 0 + out1[0][idx] = sat(-a0); out1[7][idx] = sat(b0); out1[1][idx] = sat(b0); out1[6][idx] = sat(b1) + out1[2][idx] = sat(b1); out1[5][idx] = sat(b2); out1[3][idx] = sat(b2); out1[4][idx] = sat(b3) + } + + private class BitReader(private val buf: ByteArray, start: Int) { + private var pos = start * 8 + + fun get(bits: Int): Int { + var value = 0 + repeat(bits) { + value = (value shl 1) or ((buf[pos shr 3].toInt() shr (7 - (pos and 7))) and 1) + pos++ + } + return value + } + } + + private companion object { + // (1 << 28) / ((1 << (n + 1)) - 1), fixed 1.28, as rounded in libsbc. + val RANGE_SCALE = intArrayOf( + 0x0FFFFFFF, 0x05555556, 0x02492492, 0x01111111, + 0x00842108, 0x00410410, 0x00204081, 0x00101010, + 0x00080402, 0x00040100, 0x00020040, 0x00010010, + 0x00008004, 0x00004001, 0x00002000, 0x00001000 + ) + + // cos(i * pi / 16), fixed 0.13. + val COS16 = longArrayOf(8192, 8035, 7568, 6811, 5793, 4551, 3135, 1598) + + // libsbc synthesis window for 8 subbands (fixed 2.13), period 10, doubled. + val SYNTHESIS_WINDOW = Sbc.doubled( + intArrayOf(0, -132, -371, -848, -4456, -9631, 4456, -848, 371, -132), + intArrayOf(-10, -138, -526, -580, -5438, -9528, 3486, -1004, 229, -117), + intArrayOf(-22, -131, -685, -192, -6395, -9224, 2561, -1063, 108, -97), + intArrayOf(-36, -106, -835, 322, -7287, -8734, 1711, -1042, 12, -75), + intArrayOf(-54, -59, -960, 959, -8078, -8078, 959, -960, -59, -54), + intArrayOf(-75, 12, -1042, 1711, -8734, -7287, 322, -835, -106, -36), + intArrayOf(-97, 108, -1063, 2561, -9224, -6395, -192, -685, -131, -22), + intArrayOf(-117, 229, -1004, 3486, -9528, -5438, -580, -526, -138, -10) + ) + } +} + +/** Frame layout and bit allocation shared by [SbcEncoder] and [SbcDecoder]. */ +object Sbc { + const val SUBBANDS = 8 + const val SYNCWORD = 0x9C + const val FREQ_32K = 1 + const val HEADER_SIZE = 4 + + private val SAMPLE_RATES = intArrayOf(16000, 32000, 44100, 48000) + + // Loudness offsets for 8 subbands, by sampling-frequency index. + private val LOUDNESS_OFFSET = arrayOf( + intArrayOf(-2, 0, 0, 0, 0, 0, 0, 1), + intArrayOf(-3, 0, 0, 0, 0, 0, 1, 2), + intArrayOf(-4, 0, 0, 0, 0, 0, 1, 2), + intArrayOf(-4, 0, 0, 0, 0, 0, 1, 2) + ) + + // CRC-8, polynomial x^8 + x^4 + x^3 + x^2 + 1. + private val CRC_TABLE = IntArray(256) { i -> + var c = i + repeat(8) { c = if (c and 0x80 != 0) ((c shl 1) xor 0x1D) and 0xFF else (c shl 1) and 0xFF } + c + } + + fun frameSize(blocks: Int, bitpool: Int): Int = + HEADER_SIZE + (4 * SUBBANDS + blocks * bitpool + 7) / 8 + + fun maxBitpool(blocks: Int): Int = + minOf((16 * SUBBANDS * blocks - HEADER_SIZE * 8 - 4 * SUBBANDS) / blocks, 16 * SUBBANDS) + + /** Sample rate of the frame at [offset], in Hz. */ + fun sampleRateHz(data: ByteArray, offset: Int): Int = + SAMPLE_RATES[((data[offset + 1].toInt() and 0xFF) shr 6) and 3] + + /** + * Length in bytes of the mono 8-subband frame at [offset], or -1 if the + * bytes there are not one (bad sync, other layout, truncated or bad CRC). + */ + fun frameLength(data: ByteArray, offset: Int, length: Int): Int { + if (length < HEADER_SIZE || (data[offset].toInt() and 0xFF) != SYNCWORD) return -1 + val h1 = data[offset + 1].toInt() and 0xFF + val blocks = (((h1 shr 4) and 3) + 1) * 4 + val mono = (h1 shr 2) and 3 == 0 + val eightSubbands = h1 and 1 == 1 + val bitpool = data[offset + 2].toInt() and 0xFF + if (!mono || !eightSubbands || bitpool > maxBitpool(blocks)) return -1 + val size = frameSize(blocks, bitpool) + if (length < size) return -1 + if (crc(data, offset) != (data[offset + 3].toInt() and 0xFF)) return -1 + return size + } + + /** CRC over the header and scale factors of the mono 8-subband frame at [offset]. */ + fun crc(data: ByteArray, offset: Int): Int { + var crc = 0x0F + crc = CRC_TABLE[crc xor (data[offset + 1].toInt() and 0xFF)] + crc = CRC_TABLE[crc xor (data[offset + 2].toInt() and 0xFF)] + for (i in 4 until 4 + SUBBANDS / 2) crc = CRC_TABLE[crc xor (data[offset + i].toInt() and 0xFF)] + return crc + } + + /** Bits per subband for one mono channel, per the SBC allocation rules. */ + fun allocateBits(scf: IntArray, bitpool: Int, snr: Boolean, freq: Int): IntArray { + val offsets = LOUDNESS_OFFSET[freq] + val bitneed = IntArray(SUBBANDS) + var maxBitneed = 0 + for (sb in 0 until SUBBANDS) { + var need = if (snr) { + scf[sb] + } else { + if (scf[sb] != 0) scf[sb] - offsets[sb] else -5 + } + if (!snr && need > 0) need = need shr 1 + if (need > maxBitneed) maxBitneed = need + bitneed[sb] = need + } + + var bitcount = 0 + var bitslice = maxBitneed + 1 + var bc = 0 + while (bc < bitpool) { + val bs = bitslice-- + bitcount = bc + if (bitcount == bitpool) break + for (sb in 0 until SUBBANDS) { + val bn = bitneed[sb] + bc += (if (bn >= bs && bn < bs + 15) 1 else 0) + (if (bn == bs) 1 else 0) + } + } + + val nbits = IntArray(SUBBANDS) { sb -> + val n = bitneed[sb] - bitslice + if (n < 2) 0 else minOf(n, 16) + } + var sb = 0 + while (sb < SUBBANDS && bitcount < bitpool) { + val n = when { + nbits[sb] in 1..15 -> 1 + bitneed[sb] == bitslice + 1 && bitpool > bitcount + 1 -> 2 + else -> 0 + } + nbits[sb] += n + bitcount += n + sb++ + } + sb = 0 + while (sb < SUBBANDS && bitcount < bitpool) { + if (nbits[sb] < 16) { + nbits[sb]++ + bitcount++ + } + sb++ + } + return nbits + } + + internal fun saturate16(value: Long): Int = value.coerceIn(-32768L, 32767L).toInt() + + internal fun doubled(vararg rows: IntArray): Array = Array(rows.size) { rows[it] + rows[it] } +} diff --git a/android/app/src/test/java/com/js8call/example/service/SbcCodecTest.kt b/android/app/src/test/java/com/js8call/example/service/SbcCodecTest.kt new file mode 100644 index 00000000..f095ea2a --- /dev/null +++ b/android/app/src/test/java/com/js8call/example/service/SbcCodecTest.kt @@ -0,0 +1,94 @@ +package com.js8call.example.service + +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.log10 +import kotlin.math.sin +import kotlin.math.sqrt +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SbcCodecTest { + + @Test + fun writesThe32kMonoHeaderTheUvProExpects() { + val snr = SbcEncoder(bitpool = 40, snrAllocation = true).encode(ShortArray(128)) + assertEquals(0x9C, snr[0].toInt() and 0xFF) + assertEquals(0x73, snr[1].toInt() and 0xFF) + assertEquals(40, snr[2].toInt() and 0xFF) + assertEquals(88, snr.size) + + val loudness = SbcEncoder(bitpool = 18, snrAllocation = false).encode(ShortArray(128)) + assertEquals(0x71, loudness[1].toInt() and 0xFF) + assertEquals(44, loudness.size) + } + + @Test + fun frameLengthAcceptsOwnFramesAndRejectsCorruptOnes() { + val frame = SbcEncoder().encode(tone(listOf(1000.0), 128)) + assertEquals(frame.size, Sbc.frameLength(frame, 0, frame.size)) + assertEquals(32000, Sbc.sampleRateHz(frame, 0)) + assertEquals(-1, Sbc.frameLength(frame, 0, frame.size - 1)) + + val badCrc = frame.copyOf().also { it[3] = (it[3].toInt() xor 0x01).toByte() } + assertEquals(-1, Sbc.frameLength(badCrc, 0, badCrc.size)) + assertNull(SbcDecoder().decode(badCrc, 0, badCrc.size)) + } + + @Test + fun roundTripKeepsASingleTone() { + assertRoundTrip(listOf(1500.0), bitpool = 40, snr = true, minSnrDb = 30.0) + assertRoundTrip(listOf(1500.0), bitpool = 18, snr = false, minSnrDb = 20.0) + } + + @Test + fun roundTripKeepsJs8LikeTonesApart() { + // JS8 Normal uses 8 tones 6.25 Hz apart; neighbours must stay distinct. + assertRoundTrip(listOf(1000.0, 1006.25, 1043.75), bitpool = 40, snr = true, minSnrDb = 25.0) + } + + private fun assertRoundTrip(freqs: List, bitpool: Int, snr: Boolean, minSnrDb: Double) { + val encoder = SbcEncoder(bitpool = bitpool, snrAllocation = snr) + val decoder = SbcDecoder() + val input = tone(freqs, 128 * 270) + val output = ShortArray(input.size) + for (frame in 0 until input.size / 128) { + val bytes = encoder.encode(input, frame * 128) + val pcm = decoder.decode(bytes, 0, bytes.size)!! + pcm.copyInto(output, frame * 128) + } + + // Skip the start-up transient and compare energy at the input tones + // with everything else, over 0.96 s so every tone fits whole cycles. + val settled = output.copyOfRange(3840, output.size) + val total = settled.sumOf { it.toDouble() * it } + val wanted = freqs.sumOf { power(settled, it) } + val snrDb = 10 * log10(wanted / (total - wanted)) + assertTrue("SNR $snrDb dB below $minSnrDb dB", snrDb >= minSnrDb) + + val inRms = rms(input.copyOfRange(3840, input.size)) + val outRms = rms(settled) + assertEquals("level changed", 1.0, outRms / inRms, 0.1) + } + + private fun tone(freqs: List, n: Int): ShortArray = ShortArray(n) { i -> + (freqs.sumOf { sin(2 * PI * it * i / 32000) } * 8000 / freqs.size).toInt().toShort() + } + + // Energy at one frequency (Goertzel), scaled to match sum of squares. + private fun power(x: ShortArray, freq: Double): Double { + val coeff = 2 * cos(2 * PI * freq / 32000) + var s1 = 0.0 + var s2 = 0.0 + for (v in x) { + val s = v + coeff * s1 - s2 + s2 = s1 + s1 = s + } + return 2 * (s1 * s1 + s2 * s2 - coeff * s1 * s2) / x.size + } + + private fun rms(x: ShortArray) = sqrt(x.sumOf { it.toDouble() * it } / x.size) +} From 07af19790ff87fd2847d09b5d9cfa67edd865afb Mon Sep 17 00:00:00 2001 From: doodlecomms Date: Wed, 14 Oct 2026 11:28:11 -0400 Subject: [PATCH 2/2] android: Add UV-Pro Bluetooth audio session Connect to the radio's "BS AOC" RFCOMM audio service, decode received SBC audio, and send transmit audio. The radio only streams audio while an app is also connected on its control channel, so a device-info request is sent there first. It has no PTT command: it keys on incoming audio and unkeys on an end-of-audio frame. Transmit audio is dropped unless PTT is armed, and a 45 s watchdog releases it. Protocol details from HTCommander and benlink. Not wired in yet. Co-Authored-By: Claude Opus 5.5 --- .../js8call/example/service/UvProSession.kt | 475 ++++++++++++++++++ .../example/service/UvProSessionTest.kt | 48 ++ 2 files changed, 523 insertions(+) create mode 100644 android/app/src/main/java/com/js8call/example/service/UvProSession.kt create mode 100644 android/app/src/test/java/com/js8call/example/service/UvProSessionTest.kt diff --git a/android/app/src/main/java/com/js8call/example/service/UvProSession.kt b/android/app/src/main/java/com/js8call/example/service/UvProSession.kt new file mode 100644 index 00000000..d5e552d0 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/service/UvProSession.kt @@ -0,0 +1,475 @@ +package com.js8call.example.service + +import android.Manifest +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothSocket +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Audio link to a BTECH UV-Pro over the radio's own Bluetooth audio channel. + * + * The radio carries 32 kHz mono SBC audio on a vendor RFCOMM service ("BS AOC"), + * framed as 7E 7E with 7D escaping. It has no PTT command: it + * keys when audio frames arrive and unkeys on an end-of-audio frame, and it + * only streams audio while an app is also connected on its control channel. These + * details come from HTCommander (https://github.com/Ylianst/HTCommander) and + * benlink (https://github.com/khusmann/benlink). + */ +class UvProSession( + private val context: Context, + private val listener: Listener +) { + interface Listener { + fun onRxAudio(samples: ShortArray, sampleRateHz: Int) + fun onTxTimeout() + fun onIoError(message: String) + } + + private val running = AtomicBoolean(false) + private val failed = AtomicBoolean(false) + private val writeLock = Any() + private val rxLock = Any() + @Volatile private var connected = false + @Volatile private var socket: BluetoothSocket? = null + @Volatile private var controlSocket: BluetoothSocket? = null + @Volatile private var output: OutputStream? = null + @Volatile private var lastError: String? = null + private var readerThread: Thread? = null + private var controlThread: Thread? = null + private var clock: ScheduledExecutorService? = null + + private val decoder = SbcDecoder() + @Volatile private var lastRxAudioNs = 0L + private var rxFilledUntilNs = 0L + private val rxPending = ShortArray(RX_CHUNK_SAMPLES) + private var rxPendingCount = 0 + private var rxPendingRate = 0 + + private val encoder = SbcEncoder(bitpool = TX_BITPOOL, snrAllocation = true) + private val txPending = ShortArray(encoder.samplesPerFrame) + private var txPendingCount = 0 + @Volatile private var txArmed = false + @Volatile private var txAudioStarted = false + private var txArmedAtNs = 0L + private var txSilenceUntilNs = 0L + + fun getLastError(): String? = lastError + + /** Connects to the first paired UV-Pro. Blocks for the RFCOMM connect. */ + fun start(): Boolean { + lastError = null + if (!hasBluetoothPermission()) return fail("Allow Nearby devices for JS8Call in Android Settings") + val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter + if (adapter == null || !adapter.isEnabled) return fail("Bluetooth is off") + val device = adapter.bondedDevices?.firstOrNull { isUvPro(it) } + ?: return fail("Pair the UV-Pro in Android Bluetooth settings first") + + val control = connect(device, CONTROL_UUID, "control") ?: return false + controlSocket = control + if (!handshake(control)) { + closeControl() + return false + } + // Opening audio can fail just after the control channel connects. + var audio = connect(device, AUDIO_UUID, "audio") + var attempts = 1 + while (audio == null && attempts < AUDIO_CONNECT_ATTEMPTS) { + Thread.sleep(AUDIO_RETRY_MS) + audio = connect(device, AUDIO_UUID, "audio") + attempts++ + } + val sock = audio ?: run { + closeControl() + return false + } + val input = try { + output = sock.outputStream + sock.inputStream + } catch (e: Exception) { + closeQuietly(sock) + closeControl() + return fail("UV-Pro audio channel unavailable: ${e.message}") + } + socket = sock + failed.set(false) + running.set(true) + connected = true + lastRxAudioNs = System.nanoTime() + rxFilledUntilNs = lastRxAudioNs + + readerThread = Thread({ readLoop(input) }, "UvProRx").also { it.start() } + clock = Executors.newSingleThreadScheduledExecutor { Thread(it, "UvProClock") }.also { + it.scheduleAtFixedRate({ tick() }, CLOCK_MS, CLOCK_MS, TimeUnit.MILLISECONDS) + } + Log.i(TAG, "Connected to ${device.name ?: device.address}") + return true + } + + /** + * Arms or releases transmit. Arming sends silence until the engine's audio + * starts, which keys the radio; releasing sends the end-of-audio frame. + */ + fun setPtt(enabled: Boolean): Boolean { + if (!connected) return !enabled + return synchronized(writeLock) { + if (enabled) { + if (txArmed) return@synchronized true + val now = System.nanoTime() + txArmed = true + txAudioStarted = false + txArmedAtNs = now + txSilenceUntilNs = now + txPendingCount = 0 + sendSilenceLocked(now + SILENCE_LEAD_NS) + } else { + if (!txArmed) return@synchronized true + txArmed = false + val flushed = flushPendingLocked() + writeLocked(END_FRAME) && flushed + } + } + } + + /** Sends 32 kHz PCM to the radio. Dropped unless transmit is armed. */ + fun sendTxAudio(samples: ShortArray): Boolean { + if (!connected) return false + return synchronized(writeLock) { + if (!txArmed) return@synchronized false + txAudioStarted = true + val packet = ByteArrayOutputStream() + var ok = true + for (s in samples) { + txPending[txPendingCount++] = s + if (txPendingCount == txPending.size) { + packet.write(encoder.encode(txPending)) + txPendingCount = 0 + if (packet.size() >= MAX_PACKET_PAYLOAD) { + ok = writeLocked(frame(CMD_AUDIO, packet.toByteArray())) && ok + packet.reset() + } + } + } + if (packet.size() > 0) ok = writeLocked(frame(CMD_AUDIO, packet.toByteArray())) && ok + ok + } + } + + fun stop() { + if (txArmed) setPtt(false) + running.set(false) + connected = false + clock?.shutdownNow() + clock = null + socket?.let { closeQuietly(it) } + socket = null + output = null + readerThread?.let { + it.interrupt() + it.join(1000) + } + readerThread = null + closeControl() + } + + /** Asks for device info and waits for any reply, as the radio's own apps do. */ + private fun handshake(control: BluetoothSocket): Boolean { + val reply = CountDownLatch(1) + return try { + val input = control.inputStream + val out = control.outputStream + controlThread = Thread({ controlLoop(input, reply) }, "UvProControl").also { it.start() } + repeat(HANDSHAKE_ATTEMPTS) { + out.write(GET_DEV_INFO) + out.flush() + if (reply.await(HANDSHAKE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) return true + } + fail("UV-Pro did not answer on its control channel") + } catch (e: Exception) { + fail("UV-Pro control channel failed: ${e.message}") + } + } + + // Replies and notifications are not needed yet, so they are read and dropped. + private fun controlLoop(input: InputStream, reply: CountDownLatch) { + val buf = ByteArray(256) + try { + while (true) { + val n = input.read(buf) + if (n < 0) break + if (n > 0) reply.countDown() + } + } catch (_: Exception) { + } + if (running.get()) ioError("UV-Pro control channel closed") + } + + private fun closeControl() { + controlSocket?.let { closeQuietly(it) } + controlSocket = null + controlThread?.let { + it.interrupt() + it.join(1000) + } + controlThread = null + } + + private fun connect(device: BluetoothDevice, uuid: UUID, label: String): BluetoothSocket? { + // The radio expects no authentication, so the insecure socket is tried first. + val failures = ArrayList() + val attempts = listOf BluetoothSocket>>( + "insecure" to { device.createInsecureRfcommSocketToServiceRecord(uuid) }, + "secure" to { device.createRfcommSocketToServiceRecord(uuid) } + ) + for ((kind, create) in attempts) { + var sock: BluetoothSocket? = null + try { + sock = create() + sock.connect() + return sock + } catch (e: Exception) { + failures.add("$kind: ${e.message}") + sock?.let { closeQuietly(it) } + } + } + fail("UV-Pro $label connect failed (${failures.joinToString("; ")})") + return null + } + + private fun readLoop(input: InputStream) { + val splitter = FrameSplitter() + val buf = ByteArray(1024) + try { + while (running.get()) { + val n = input.read(buf) + if (n < 0) break + splitter.push(buf, n) { cmd, payload -> + if (cmd == CMD_AUDIO || cmd == CMD_AUDIO_ALT) decodeAudio(payload) + } + } + } catch (e: Exception) { + if (running.get()) ioError("UV-Pro Bluetooth read failed: ${e.message}") + return + } + if (running.get()) ioError("UV-Pro Bluetooth link closed") + } + + private fun decodeAudio(payload: ByteArray) { + var off = 0 + while (off < payload.size) { + val size = Sbc.frameLength(payload, off, payload.size - off) + if (size < 0) { + off++ + continue + } + val pcm = decoder.decode(payload, off, size) + if (pcm != null) { + val rate = Sbc.sampleRateHz(payload, off) + synchronized(rxLock) { + lastRxAudioNs = System.nanoTime() + rxFilledUntilNs = lastRxAudioNs + if (rate != rxPendingRate || rxPendingCount + pcm.size > rxPending.size) flushRxLocked() + rxPendingRate = rate + pcm.copyInto(rxPending, rxPendingCount) + rxPendingCount += pcm.size + if (rxPendingCount == rxPending.size) flushRxLocked() + } + } + off += size + } + } + + private fun tick() { + if (!running.get()) return + val now = System.nanoTime() + + // The radio sends nothing while squelched or transmitting; feed silence + // so the decoder's sample clock keeps pace with real time. + synchronized(rxLock) { + if (now - lastRxAudioNs > RX_GAP_NS) { + flushRxLocked() + val samples = ((now - rxFilledUntilNs) * SAMPLE_RATE_HZ / 1_000_000_000L).toInt() + if (samples > 0) { + listener.onRxAudio(ShortArray(samples), SAMPLE_RATE_HZ) + rxFilledUntilNs += samples * 1_000_000_000L / SAMPLE_RATE_HZ + } + } + } + + var timedOut = false + synchronized(writeLock) { + if (!txArmed) return@synchronized + if (now - txArmedAtNs > TX_TIMEOUT_NS) { + Log.w(TAG, "TX timeout, releasing") + txArmed = false + writeLocked(END_FRAME) + timedOut = true + } else if (!txAudioStarted) { + sendSilenceLocked(now + SILENCE_LEAD_NS) + } + } + if (timedOut) listener.onTxTimeout() + } + + // The engine draws each waterfall line from a single submit and skips short + // ones, so received frames are passed on in 100 ms chunks, like a microphone. + private fun flushRxLocked() { + if (rxPendingCount == 0) return + listener.onRxAudio(rxPending.copyOf(rxPendingCount), rxPendingRate) + rxPendingCount = 0 + } + + /** Sends silent frames until [untilNs] of audio has been queued. */ + private fun sendSilenceLocked(untilNs: Long): Boolean { + val frameNs = encoder.samplesPerFrame * 1_000_000_000L / SAMPLE_RATE_HZ + val packet = ByteArrayOutputStream() + val silence = ShortArray(encoder.samplesPerFrame) + while (txSilenceUntilNs < untilNs) { + packet.write(encoder.encode(silence)) + txSilenceUntilNs += frameNs + } + return packet.size() == 0 || writeLocked(frame(CMD_AUDIO, packet.toByteArray())) + } + + private fun flushPendingLocked(): Boolean { + if (txPendingCount == 0) return true + txPending.fill(0, txPendingCount) + txPendingCount = 0 + return writeLocked(frame(CMD_AUDIO, encoder.encode(txPending))) + } + + private fun writeLocked(bytes: ByteArray): Boolean { + val out = output ?: return false + return try { + out.write(bytes) + out.flush() + true + } catch (e: Exception) { + ioError("UV-Pro Bluetooth write failed: ${e.message}") + false + } + } + + private fun ioError(message: String) { + connected = false + txArmed = false + if (failed.compareAndSet(false, true)) { + Log.w(TAG, message) + lastError = message + listener.onIoError(message) + } + } + + private fun fail(message: String): Boolean { + lastError = message + Log.w(TAG, message) + return false + } + + private fun isUvPro(device: BluetoothDevice): Boolean = + device.uuids?.any { it.uuid == AUDIO_UUID } == true || + device.name?.contains("UV-PRO", ignoreCase = true) == true + + private fun hasBluetoothPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true + return context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == + PackageManager.PERMISSION_GRANTED + } + + private fun closeQuietly(sock: BluetoothSocket) { + try { + sock.close() + } catch (_: Exception) { + } + } + + /** Collects 7E-delimited frames from the byte stream and unescapes them. */ + internal class FrameSplitter { + private val current = ByteArrayOutputStream() + private var escaped = false + + fun push(data: ByteArray, length: Int, onFrame: (cmd: Int, payload: ByteArray) -> Unit) { + for (i in 0 until length) { + val b = data[i].toInt() and 0xFF + when { + b == FLAG -> { + if (current.size() > 0) { + val bytes = current.toByteArray() + onFrame(bytes[0].toInt() and 0xFF, bytes.copyOfRange(1, bytes.size)) + } + current.reset() + escaped = false + } + b == ESCAPE -> escaped = true + escaped -> { + current.write(b xor 0x20) + escaped = false + } + else -> current.write(b) + } + } + } + } + + companion object { + private const val TAG = "UvProSession" + val AUDIO_UUID: UUID = UUID.fromString("39144315-32FA-40DB-85ED-FBFEBA2D86E6") + private val CONTROL_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") + + // GAIA frame: FF 01, flags 00, payload length 01, group 0002 (basic), + // command 0004 (get device info), payload 03. + private val GET_DEV_INFO = byteArrayOf(0xFF.toByte(), 0x01, 0x00, 0x01, 0x00, 0x02, 0x00, 0x04, 0x03) + private const val HANDSHAKE_ATTEMPTS = 3 + private const val HANDSHAKE_TIMEOUT_MS = 2000L + private const val AUDIO_CONNECT_ATTEMPTS = 3 + private const val AUDIO_RETRY_MS = 700L + + const val CMD_AUDIO = 0x00 + const val CMD_AUDIO_ALT = 0x03 + private const val FLAG = 0x7E + private const val ESCAPE = 0x7D + + // End of audio: tells the radio to unkey. + val END_FRAME = byteArrayOf(0x7E, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7E) + + // HTCommander found bitpool 40 with SNR allocation works over the air for data. + private const val TX_BITPOOL = 40 + private const val MAX_PACKET_PAYLOAD = 256 + private const val CLOCK_MS = 20L + private const val RX_GAP_NS = 100_000_000L + const val SAMPLE_RATE_HZ = 32000 + private const val RX_CHUNK_SAMPLES = 3200 + private const val SILENCE_LEAD_NS = 60_000_000L + private const val TX_TIMEOUT_NS = 45_000_000_000L + + /** Builds one 7E-delimited frame with [payload] escaped. */ + fun frame(cmd: Int, payload: ByteArray): ByteArray { + val out = ByteArrayOutputStream(payload.size + 8) + out.write(FLAG) + out.write(cmd) + for (byte in payload) { + val b = byte.toInt() and 0xFF + if (b == FLAG || b == ESCAPE) { + out.write(ESCAPE) + out.write(b xor 0x20) + } else { + out.write(b) + } + } + out.write(FLAG) + return out.toByteArray() + } + } +} diff --git a/android/app/src/test/java/com/js8call/example/service/UvProSessionTest.kt b/android/app/src/test/java/com/js8call/example/service/UvProSessionTest.kt new file mode 100644 index 00000000..f2f6e84b --- /dev/null +++ b/android/app/src/test/java/com/js8call/example/service/UvProSessionTest.kt @@ -0,0 +1,48 @@ +package com.js8call.example.service + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class UvProSessionTest { + + @Test + fun escapesFlagAndEscapeBytes() { + val payload = byteArrayOf(0x01, 0x7E, 0x02, 0x7D, 0x03) + val expected = byteArrayOf(0x7E, 0x00, 0x01, 0x7D, 0x5E, 0x02, 0x7D, 0x5D, 0x03, 0x7E) + assertArrayEquals(expected, UvProSession.frame(UvProSession.CMD_AUDIO, payload)) + } + + @Test + fun splitterRecoversFramesAcrossReads() { + val first = UvProSession.frame(UvProSession.CMD_AUDIO, byteArrayOf(0x7E, 0x10)) + val second = UvProSession.frame(UvProSession.CMD_AUDIO_ALT, byteArrayOf(0x7D, 0x20)) + val stream = first + second + val frames = ArrayList>>() + val splitter = UvProSession.FrameSplitter() + // Feed one byte at a time so frames and escapes straddle reads. + for (b in stream) splitter.push(byteArrayOf(b), 1) { cmd, payload -> frames.add(cmd to payload.toList()) } + + assertEquals(2, frames.size) + assertEquals(UvProSession.CMD_AUDIO to listOf(0x7E, 0x10), frames[0]) + assertEquals(UvProSession.CMD_AUDIO_ALT to listOf(0x7D, 0x20), frames[1]) + } + + @Test + fun endFrameIsTheRadiosUnkeyMessage() { + val splitter = UvProSession.FrameSplitter() + var cmd = -1 + splitter.push(UvProSession.END_FRAME, UvProSession.END_FRAME.size) { c, _ -> cmd = c } + assertEquals(0x01, cmd) + } + + @Test + fun encodedAudioSurvivesFraming() { + val sbc = SbcEncoder().encode(ShortArray(128) { (it * 200).toShort() }) + val framed = UvProSession.frame(UvProSession.CMD_AUDIO, sbc) + var payload = ByteArray(0) + UvProSession.FrameSplitter().push(framed, framed.size) { _, p -> payload = p } + assertArrayEquals(sbc, payload) + assertEquals(sbc.size, Sbc.frameLength(payload, 0, payload.size)) + } +}