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/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/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) +} 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)) + } +}