Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,30 @@ JAVA_HOME=/opt/homebrew/opt/openjdk ANDROID_HOME=~/Library/Android/sdk ./gradlew

Output: `android/app/build/outputs/apk/debug/`

## Run the tests

Unit tests run on the host. From the repo android folder:

```bash
JAVA_HOME=/opt/homebrew/opt/openjdk ANDROID_HOME=~/Library/Android/sdk ./gradlew :app:testDebugUnitTest
```

Report: `android/app/build/reports/tests/testDebugUnitTest/`

The engine tests are instrumented and need a device or emulator attached:

```bash
JAVA_HOME=/opt/homebrew/opt/openjdk ANDROID_HOME=~/Library/Android/sdk ./gradlew :js8core-lib:connectedDebugAndroidTest
```

Report: `android/js8core-lib/build/reports/androidTests/connected/`

To run one class:

```bash
JAVA_HOME=/opt/homebrew/opt/openjdk ANDROID_HOME=~/Library/Android/sdk ./gradlew :js8core-lib:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.js8call.core.JS8EngineLoopbackTest
```

## Build Release APK (signed)

Create a keystore once:
Expand Down
5 changes: 5 additions & 0 deletions android/js8core-lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ android {
"../../adapters/android/jni/java"
)
}
getByName("androidTest") {
// The desktop project's reference recordings, packaged into the
// test APK rather than copied into the tree.
assets.srcDirs("../../media/tests")
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.js8call.core

import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/**
* Transmits through the modulator, captures the waveform off the TX tap and
* decodes it. No speaker or microphone in the path, so a failure here is the
* transmitted audio itself rather than the device.
*
* The transmission is asked for from the middle of a period, which is the case
* the modulator has to defer to the next slot boundary on its own. Starting
* partway through a frame sends a partial waveform, and a partial waveform does
* not decode, so this fails if that alignment is lost.
*/
@RunWith(AndroidJUnit4::class)
class JS8EngineLoopbackTest {

@Test
fun ownTransmissionDecodes() {
val tx = TestEngine().use { it.transmitFromMidPeriod("CQ CQ CQ") }

// A whole frame is 12.6 s. Short means the modulator joined one in
// progress instead of waiting for the boundary.
assertTrue("captured ${tx.seconds}s, expected a whole frame", tx.seconds > 10)

// Submode A starts 500 ms into its 15 s period. The noise gives the
// decoder a floor to measure against instead of digital silence.
val period = tx.placedInPeriod(periodMs = 15_000, startDelayMs = 500)
.withNoise(rms = 300.0, seed = 1)

val decoded = TestEngine().use { it.decode(period) }
Log.i(TAG, "decoded ${decoded.size}: $decoded")
assertTrue("own transmission did not decode", decoded.any { "CQ" in it.text })
}

private companion object {
const val TAG = "LoopbackTest"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.js8call.core

import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/**
* Feeds the desktop project's reference recordings straight into the engine,
* so a failure is the decoder rather than the microphone or the radio. The
* files are named MODE_DEPTH_EXPECTEDDECODES.wav and come from media/tests,
* packaged as test assets by the androidTest source set.
*
* This is a smoke test, not desktop parity: the engine decodes a 13.6 second
* window at a fixed depth, where the desktop CLI reads the whole file at the
* depth in the name, so the counts run under them. The counts are logged for
* comparison. On the emulator the set gives 25 or 26 decodes across 6 of the
* 7 files against the desktop's 31; one marginal decode in A_2_5 comes and
* goes with how the ring lands, which is why the bounds below sit under that
* rather than on it.
*/
@RunWith(AndroidJUnit4::class)
class JS8EngineReferenceDecodeTest {

@Test
fun decodesReferenceRecordings() {
val results = REFERENCES.map { file ->
val decoded = TestEngine().use { it.decode(readWav(file)) }
Log.i(TAG, "$file: ${decoded.size} $decoded")
decoded
}

val total = results.sumOf { it.size }
val files = results.count { it.isNotEmpty() }
Log.i(TAG, "decoded $total messages across $files of ${results.size} files")
assertTrue("only $total decodes from the reference set", total >= MIN_DECODES)
assertTrue("only $files files produced anything", files >= MIN_FILES)
}

private companion object {
const val TAG = "RefDecodeTest"

// Every submode A recording in media/tests. The engine decodes at a
// fixed depth, so files that differ only in that digit come back the same.
val REFERENCES = listOf(
"A_1_4.wav", "A_2_1.wav", "A_2_3.wav", "A_2_5.wav",
"A_2_6.wav", "A_2_9.wav", "A_3_3.wav",
)

const val MIN_DECODES = 20
const val MIN_FILES = 5
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.js8call.core

import androidx.test.platform.app.InstrumentationRegistry
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Random

/** The engine's own rate. Everything here is 16-bit mono PCM at this rate. */
internal const val SAMPLE_RATE = 12_000

internal val ShortArray.seconds: Float
get() = size / SAMPLE_RATE.toFloat()

/** Drops the silence the TX tap delivers while the modulator waits for its slot. */
internal fun ShortArray.trimLeadingSilence(): ShortArray {
val first = indexOfFirst { it.toInt() != 0 }.coerceAtLeast(0)
return copyOfRange(first, size)
}

/** Lays the signal [startDelayMs] into an otherwise silent period of [periodMs]. */
internal fun ShortArray.placedInPeriod(periodMs: Int, startDelayMs: Int): ShortArray {
val period = ShortArray(SAMPLE_RATE * periodMs / 1000)
val at = SAMPLE_RATE * startDelayMs / 1000
copyInto(period, at, 0, minOf(size, period.size - at))
return period
}

/** Adds Gaussian noise for the decoder to measure against. Seeded, so runs repeat. */
internal fun ShortArray.withNoise(rms: Double, seed: Long): ShortArray {
val rng = Random(seed)
return ShortArray(size) { i ->
(this[i] + rng.nextGaussian() * rms).toInt()
.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
.toShort()
}
}

/** Linear interpolation: enough to bring the tap's rate back to the engine's. */
internal fun ShortArray.resampled(fromHz: Int, toHz: Int): ShortArray {
val out = ShortArray((size.toLong() * toHz / fromHz).toInt())
for (i in out.indices) {
val pos = i.toDouble() * fromHz / toHz
val j = pos.toInt()
val a = this[j].toDouble()
val b = if (j + 1 < size) this[j + 1].toDouble() else a
out[i] = (a + (b - a) * (pos - j)).toInt().toShort()
}
return out
}

internal fun List<ShortArray>.concatenated(): ShortArray {
val out = ShortArray(sumOf { it.size })
var at = 0
for (part in this) {
part.copyInto(out, at)
at += part.size
}
return out
}

/** A recording from the test assets. */
internal fun readWav(name: String): ShortArray {
val bytes = InstrumentationRegistry.getInstrumentation().context.assets
.open(name).use { it.readBytes() }
val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)

// Walk the RIFF chunks; the recordings carry more than the 44 byte header.
var pos = 12
while (pos + 8 <= bytes.size) {
val id = String(bytes, pos, 4, Charsets.US_ASCII)
val size = buf.getInt(pos + 4)
if (id == "data") {
val out = ShortArray(size / 2)
buf.position(pos + 8)
buf.asShortBuffer().get(out)
return out
}
pos += 8 + size + (size and 1)
}
throw IllegalStateException("no data chunk in $name")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.js8call.core

import android.util.Log
import org.junit.Assert.assertTrue
import java.util.concurrent.CopyOnWriteArrayList

/** One decode as the engine reported it. */
internal data class Decode(val text: String, val snr: Int, val dt: Float, val freq: Float) {
override fun toString() = String.format("'%s' dt=%+.2f f=%.0f snr=%d", text, dt, freq, snr)
}

/**
* A running engine for one transmit or one decode, closed by [use]. Submode A
* at the engine's own rate, TX tap on. Each job shifts the clock first,
* because the ring phase and the modulator both follow the wall clock.
*/
internal class TestEngine : AutoCloseable {
private val decodes = CopyOnWriteArrayList<Decode>()
private val tapped = ArrayList<ShortArray>()
private var tapRate = SAMPLE_RATE
@Volatile private var finished = false

private val engine = JS8Engine.create(
sampleRateHz = SAMPLE_RATE,
submodes = SUBMODE_A,
enableTxAudioTap = true,
callbackHandler = object : JS8Engine.CallbackHandler {
override fun onDecoded(
utc: Int, snr: Int, dt: Float, freq: Float, text: String,
type: Int, quality: Float, mode: Int, driftMs: Int
) {
decodes.add(Decode(text, snr, dt, freq))
}

override fun onDecodeFinished(count: Int) {
finished = true
}

override fun onTxAudio(samples: ShortArray, sampleRateHz: Int) {
synchronized(tapped) {
tapRate = sampleRateHz
tapped.add(samples)
}
}

override fun onError(message: String) {
Log.w(TAG, "engine error: $message")
}

override fun onSpectrum(
bins: FloatArray, binHz: Float, powerDb: Float, peakDb: Float
) = Unit
override fun onDecodeStarted(submodes: Int) = Unit
override fun onLog(level: Int, message: String) = Unit
}
)

init {
assertTrue("engine did not start", engine.start())
}

override fun close() = engine.close()

/** Feeds the whole clip and waits for the decode pass over it to finish. */
fun decode(samples: ShortArray): List<Decode> {
// Shift the clock to the top of a minute; the first submit re-aligns
// the ring to it before writing.
val now = System.currentTimeMillis()
engine.setTimeDriftMs((MINUTE_MS - now % MINUTE_MS) % MINUTE_MS)

// A buffer at a time, as a device would deliver it. The scheduler
// places the decode window from where a write ends, so one write
// spanning the whole period would put the window in the next one.
for (start in samples.indices step CHUNK) {
engine.submitAudio(samples.copyOfRange(start, minOf(start + CHUNK, samples.size)))
}

assertTrue("decode pass never finished", waitUntil(DECODE_TIMEOUT_MS) { finished })
Thread.sleep(SETTLE_MS) // decodes from that pass still arriving
return decodes.toList()
}

/**
* Asks for [text] with the clock 13 s into a period, so the modulator has
* to defer to the next boundary on its own, and returns what came off the
* TX tap: at the engine rate, leading silence trimmed.
*/
fun transmitFromMidPeriod(text: String): ShortArray {
engine.setTransmitReady(true)
val offset = System.currentTimeMillis() % PERIOD_MS
engine.setTimeDriftMs((ASK_AT_MS - offset + PERIOD_MS) % PERIOD_MS)
val accepted = engine.transmitMessage(
text = text,
myCall = "N5EKS",
myGrid = "EM12",
submode = 0,
audioFrequencyHz = 1500.0,
txDelaySec = 0.0
)
assertTrue("transmit refused", accepted)

val transmitting = { engine.isTransmittingAudio() }
assertTrue("modulator never started", waitUntil(TX_TIMEOUT_MS, transmitting))
assertTrue("modulator never stopped", waitUntil(TX_TIMEOUT_MS) { !transmitting() })
Thread.sleep(DRAIN_MS) // the last tap buffers are still in flight
engine.stopTransmit()

// The tap sits after the output resampler, so it runs at whatever rate
// the audio device negotiated, not the engine rate the decoder wants.
val (raw, rate) = synchronized(tapped) { tapped.concatenated() to tapRate }
val tx = (if (rate == SAMPLE_RATE) raw else raw.resampled(rate, SAMPLE_RATE))
.trimLeadingSilence()
Log.i(TAG, "tap at $rate Hz, ${raw.size} samples; ${tx.seconds}s of signal")
return tx
}

private fun waitUntil(timeoutMs: Long, condition: () -> Boolean): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (!condition()) {
if (System.currentTimeMillis() >= deadline) return false
Thread.sleep(POLL_MS)
}
return true
}

private companion object {
const val TAG = "TestEngine"
const val SUBMODE_A = 0x1
const val PERIOD_MS = 15_000L
const val ASK_AT_MS = 13_000L // inside the frame, late enough to keep the wait short
const val MINUTE_MS = 60_000L
const val CHUNK = 4_096
const val POLL_MS = 50L
const val SETTLE_MS = 500L
const val DRAIN_MS = 1_500L
const val TX_TIMEOUT_MS = 45_000L
const val DECODE_TIMEOUT_MS = 30_000L
}
}
Loading