Compare commits
4
Commits
feat/cw-fldigi
...
v4.5.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c42e1d7b4e | ||
|
|
09ebf1f39a | ||
|
|
ec40f29f28 | ||
|
|
5dd7a35a23 |
No files matched your search
@@ -47,6 +47,12 @@ class CwFldigiDecoder(
|
||||
const val CW_QUERY = 3
|
||||
const val CW_SUCCESS = 0
|
||||
const val CW_ERROR = -1
|
||||
|
||||
// Auto-tune (spectral peak tracking, mirrors the old channelTracker)
|
||||
const val TUNE_FFT_SIZE = 512
|
||||
const val TUNE_MIN_FREQ = 300.0
|
||||
const val TUNE_MAX_FREQ = 1500.0
|
||||
const val TUNE_ENERGY_FRACTION = 0.35
|
||||
}
|
||||
|
||||
private enum class CwRxState { IDLE, IN_TONE, AFTER_TONE }
|
||||
@@ -64,6 +70,16 @@ class CwFldigiDecoder(
|
||||
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
|
||||
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
|
||||
|
||||
// --- auto-tune state (spectral peak tracking) ---
|
||||
private var tuneFreq = frequency
|
||||
private val tuneBuffer = DoubleArray(TUNE_FFT_SIZE)
|
||||
private var tuneIdx = 0
|
||||
private var tuneFft = CwGfft(TUNE_FFT_SIZE)
|
||||
private var tunePeakFreq = frequency
|
||||
private var tuneHasPeak = false
|
||||
private var tuneLocked = false
|
||||
private val tuneWindow = DoubleArray(TUNE_FFT_SIZE)
|
||||
|
||||
// --- fldigi cw state ---
|
||||
private var phaseacc = 0.0
|
||||
private var FFTphase = 0.0
|
||||
@@ -128,17 +144,99 @@ class CwFldigiDecoder(
|
||||
/** Main entry: feed PCM samples. */
|
||||
fun processBuffer(buffer: FloatArray) {
|
||||
for (sample in buffer) {
|
||||
feedTuner(sample.toDouble())
|
||||
rxSample(sample.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spectral peak tracking (auto-tune). Collects raw samples, runs an FFT
|
||||
* every TUNE_FFT_SIZE samples, finds the strongest peak in the CW range
|
||||
* and smoothly steers tuneFreq toward it — mirroring the old
|
||||
* channelTracker behaviour so off-tune signals still decode.
|
||||
*/
|
||||
private fun feedTuner(sample: Double) {
|
||||
tuneBuffer[tuneIdx++] = sample
|
||||
if (tuneIdx < TUNE_FFT_SIZE) return
|
||||
tuneIdx = 0
|
||||
|
||||
// Hann window
|
||||
for (i in 0 until TUNE_FFT_SIZE) {
|
||||
val w = 0.5 - 0.5 * kotlin.math.cos(2.0 * Math.PI * i / (TUNE_FFT_SIZE - 1))
|
||||
tuneWindow[i] = tuneBuffer[i] * w
|
||||
}
|
||||
val data = Array(TUNE_FFT_SIZE) { CwComplex(tuneWindow[it], 0.0) }
|
||||
tuneFft.forward(data)
|
||||
|
||||
// Find the strongest bin in the CW range (300..1500 Hz)
|
||||
val binMin = (TUNE_MIN_FREQ * TUNE_FFT_SIZE / sampleRate).toInt()
|
||||
val binMax = (TUNE_MAX_FREQ * TUNE_FFT_SIZE / sampleRate).toInt()
|
||||
var bestBin = -1
|
||||
var bestMag = 0.0
|
||||
var totalMag = 0.0
|
||||
for (b in binMin..binMax) {
|
||||
val mag = data[b].abs()
|
||||
totalMag += mag
|
||||
if (mag > bestMag) {
|
||||
bestMag = mag
|
||||
bestBin = b
|
||||
}
|
||||
}
|
||||
if (bestBin < 0) return
|
||||
val meanMag = totalMag / (binMax - binMin + 1)
|
||||
if (bestMag < meanMag * 2.0) return // no clear tone
|
||||
// Absolute floor: silence or weak noise must not steer the NCO.
|
||||
// A 0.6-amplitude tone in a 512-pt Hann FFT yields peak ≈ 150;
|
||||
// anything below ~30 is noise/DC leakage.
|
||||
if (bestMag < 30.0) return
|
||||
|
||||
val peakFreq = bestBin * sampleRate.toDouble() / TUNE_FFT_SIZE
|
||||
tunePeakFreq = peakFreq
|
||||
tuneHasPeak = true
|
||||
_estimatedPitch.value = peakFreq.toFloat()
|
||||
|
||||
// Lock fast: a strong peak on the very first frame is reliable enough
|
||||
// (CW tones are narrow and dominate the band). Steer immediately so
|
||||
// the first character still decodes. Do NOT reset the RX state on the
|
||||
// first lock — the AGC adapts in a few frames and a reset would wipe
|
||||
// the element that triggered the tune.
|
||||
if (!tuneLocked) {
|
||||
tuneFreq = peakFreq
|
||||
tuneLocked = true
|
||||
_estimatedPitch.value = peakFreq.toFloat()
|
||||
} else {
|
||||
// Smooth tracking; retune instantly on big jumps (signal switched freq)
|
||||
val diff = peakFreq - tuneFreq
|
||||
if (kotlin.math.abs(diff) > 120.0) {
|
||||
tuneFreq = peakFreq
|
||||
resetRxState()
|
||||
} else {
|
||||
tuneFreq += diff * 0.2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset only the fldigi RX state machine (keep decoded text). */
|
||||
private fun resetRxState() {
|
||||
cw_receive_state = CwRxState.IDLE
|
||||
old_cw_receive_state = CwRxState.IDLE
|
||||
smpl_ctr = 0
|
||||
cw_ptr = 0
|
||||
rx_rep_buf.clear()
|
||||
last_element = 0
|
||||
space_sent = true
|
||||
FFTphase = 0.0
|
||||
phaseacc = 0.0
|
||||
}
|
||||
|
||||
private fun rxSample(value: Double) {
|
||||
// NCO down-conversion (fldigi rx_FFTprocess)
|
||||
// NCO down-conversion (fldigi rx_FFTprocess). tuneFreq tracks the
|
||||
// strongest spectral peak so off-tune signals still decode.
|
||||
val z = CwComplex(
|
||||
value * kotlin.math.cos(FFTphase),
|
||||
value * kotlin.math.sin(FFTphase)
|
||||
)
|
||||
FFTphase += 2.0 * Math.PI * frequency / sampleRate
|
||||
FFTphase += 2.0 * Math.PI * tuneFreq / sampleRate
|
||||
if (FFTphase > 2.0 * Math.PI) FFTphase -= 2.0 * Math.PI
|
||||
|
||||
val out = cw_FFT_filter.run(z) ?: return
|
||||
@@ -422,5 +520,11 @@ class CwFldigiDecoder(
|
||||
_estimatedSpeed.value = null
|
||||
FFTphase = 0.0
|
||||
phaseacc = 0.0
|
||||
// reset auto-tune
|
||||
tuneIdx = 0
|
||||
tunePeakFreq = frequency
|
||||
tuneHasPeak = false
|
||||
tuneLocked = false
|
||||
tuneFreq = frequency
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,51 @@ object WaveLogApi {
|
||||
WavelogResult.Success("")
|
||||
}
|
||||
|
||||
/**
|
||||
* ADIF band code from a frequency in Hz. "SAT" is NOT a legal ADIF band
|
||||
* value (the Band enumeration is 160M/80M/.../2M/70CM/23CM...); a logger
|
||||
* that fails to parse an illegal band falls back to a default such as
|
||||
* 160m. Satellite QSOs must carry the real band of the TX frequency.
|
||||
*/
|
||||
fun bandFromHz(freqHz: Long): String = when {
|
||||
freqHz >= 1240_000_000 -> "23CM"
|
||||
freqHz >= 902_000_000 -> "33CM"
|
||||
freqHz >= 420_000_000 -> "70CM"
|
||||
freqHz >= 222_000_000 -> "1.25M"
|
||||
freqHz >= 144_000_000 -> "2M"
|
||||
freqHz >= 50_000_000 -> "6M"
|
||||
freqHz >= 28_000_000 -> "10M"
|
||||
freqHz >= 24_890_000 -> "12M"
|
||||
freqHz >= 21_000_000 -> "15M"
|
||||
freqHz >= 18_068_000 -> "17M"
|
||||
freqHz >= 14_000_000 -> "20M"
|
||||
freqHz >= 10_000_000 -> "30M"
|
||||
freqHz >= 7_000_000 -> "40M"
|
||||
freqHz >= 5_102_000 -> "60M"
|
||||
freqHz >= 3_500_000 -> "80M"
|
||||
freqHz >= 1_800_000 -> "160M"
|
||||
else -> "160M"
|
||||
}
|
||||
|
||||
/** Band class letter for satellite mode derivation: VHF=V, UHF=U, SHF=S. */
|
||||
private fun bandLetter(freqHz: Long): String = when {
|
||||
freqHz >= 1_240_000_000 -> "S"
|
||||
freqHz >= 420_000_000 -> "U"
|
||||
freqHz >= 144_000_000 -> "V"
|
||||
else -> "V"
|
||||
}
|
||||
|
||||
/**
|
||||
* ADIF SAT_MODE (free text, satellite convention): "V/U" = VHF up /
|
||||
* UHF down, "U/V", "V/S", "U/S"... Derived from the actual TX/RX bands.
|
||||
*/
|
||||
fun satModeFrom(txFreqHz: Long, rxFreqHz: Long): String {
|
||||
if (rxFreqHz <= 0) return ""
|
||||
val up = bandLetter(txFreqHz)
|
||||
val down = bandLetter(rxFreqHz)
|
||||
return if (up == down) "" else "$up/$down"
|
||||
}
|
||||
|
||||
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
|
||||
fun normalizeSatName(raw: String): String {
|
||||
val main = raw.substringBefore('(').trim()
|
||||
@@ -142,10 +187,11 @@ object WaveLogApi {
|
||||
val satName = normalizeSatName(qso.satName)
|
||||
|
||||
// v2: POST /index.php/api/v2/qso (JSON fields)
|
||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||
val v2Body = JSONObject().apply {
|
||||
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
|
||||
put("call", qso.call)
|
||||
put("band", "SAT")
|
||||
put("band", bandFromHz(qso.freqTxHz))
|
||||
put("mode", qso.mode)
|
||||
put("qso_date", utcDate(qso.timeUtcMs))
|
||||
put("time_on", utcTime(qso.timeUtcMs))
|
||||
@@ -155,6 +201,7 @@ object WaveLogApi {
|
||||
put("rst_sent", "59")
|
||||
put("rst_rcvd", "59")
|
||||
put("sat_name", satName)
|
||||
if (satMode.isNotBlank()) put("sat_mode", satMode)
|
||||
}
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
||||
@@ -183,9 +230,10 @@ object WaveLogApi {
|
||||
val bytes = value.toByteArray(Charsets.UTF_8).size
|
||||
return "<$name:$bytes>$value"
|
||||
}
|
||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||
return buildString {
|
||||
append(field("call", qso.call))
|
||||
append(field("band", "SAT"))
|
||||
append(field("band", bandFromHz(qso.freqTxHz)))
|
||||
append(field("mode", qso.mode))
|
||||
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
|
||||
if (qso.freqRxHz > 0) {
|
||||
@@ -198,6 +246,7 @@ object WaveLogApi {
|
||||
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
|
||||
if (satName.isNotBlank()) {
|
||||
append(field("sat_name", satName))
|
||||
if (satMode.isNotBlank()) append(field("sat_mode", satMode))
|
||||
append(field("prop_mode", "SAT"))
|
||||
}
|
||||
append("<eor>")
|
||||
|
||||
@@ -146,4 +146,35 @@ class CwFldigiDecoderTest {
|
||||
val maxVal = out.maxOrNull() ?: 0.0
|
||||
assertTrue("expected signal to pass through lowpass, max=$maxVal", maxVal > 0.05)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoTune_decodesOffFreqSignal() {
|
||||
// Signal at 900 Hz but decoder initialized at 600 Hz: auto-tune must
|
||||
// steer the NCO to the real tone, otherwise nothing decodes.
|
||||
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = 600.0)
|
||||
val dotLen = CwFldigiConstants.KWPM / 18
|
||||
val synth = { ch: Char ->
|
||||
val len = if (ch == '.') dotLen else 3 * dotLen
|
||||
FloatArray(len) { i ->
|
||||
val t = i.toDouble() / sampleRate
|
||||
(0.6 * sin(2.0 * PI * 900.0 * t)).toFloat()
|
||||
}
|
||||
}
|
||||
// "CQ" at 900 Hz: -.-. --.-
|
||||
val text = StringBuilder()
|
||||
val seq = listOf(listOf('-', '.', '-', '.'), listOf('-', '-', '.', '-'))
|
||||
for ((ci, word) in seq.withIndex()) {
|
||||
for ((i, ch) in word.withIndex()) {
|
||||
decoder.processBuffer(synth(ch))
|
||||
if (i < word.size - 1) decoder.processBuffer(FloatArray(dotLen))
|
||||
}
|
||||
if (ci < seq.size - 1) decoder.processBuffer(FloatArray(3 * dotLen))
|
||||
}
|
||||
decoder.processBuffer(FloatArray(6 * dotLen))
|
||||
val result = decoder.decodedTextFlow.value
|
||||
assertTrue("expected CQ from 900 Hz signal (auto-tune), got: $result", result.contains("C") && result.contains("Q"))
|
||||
// pitch should have moved toward 900 Hz
|
||||
val pitch = decoder.estimatedPitch.value ?: 0f
|
||||
assertTrue("expected pitch near 900, got $pitch", pitch > 800f && pitch < 1000f)
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Verifies the WaveLog upload payload frequency/band fields.
|
||||
*
|
||||
* Regression: user reported QSOs landing in the 160m band. The v2 JSON
|
||||
* envelope must carry freq as a MHz string with an "M" suffix so WaveLog's
|
||||
* parse_frequency() reads it as Hz internally; a bare integer or bare MHz
|
||||
* value corrupts band derivation.
|
||||
*/
|
||||
class WaveLogApiPayloadTest {
|
||||
|
||||
// SO-50: uplink 145.850 MHz, downlink 436.795 MHz
|
||||
private val uplinkHz = 145_850_000L
|
||||
private val downlinkHz = 436_795_000L
|
||||
|
||||
@Test
|
||||
fun v2_freq_usesMhzStringWithMSuffix() {
|
||||
val freq = String.format(Locale.ENGLISH, "%.6fM", uplinkHz / 1_000_000.0)
|
||||
val freqRx = String.format(Locale.ENGLISH, "%.6fM", downlinkHz / 1_000_000.0)
|
||||
assertEquals("145.850000M", freq)
|
||||
assertEquals("436.795000M", freqRx)
|
||||
// WaveLog parse_frequency: "145.850000M" -> 145850000 Hz
|
||||
val parsedHz = parseLikeWaveLog(freq)
|
||||
assertEquals(uplinkHz, parsedHz)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v1_adif_freq_isBareMhzNumber() {
|
||||
// v1 ADIF <FREQ> is a bare MHz number per ADIF spec (no unit suffix)
|
||||
val freq = String.format(Locale.ENGLISH, "%.6f", uplinkHz / 1_000_000.0)
|
||||
assertEquals("145.850000", freq)
|
||||
val adifFreq = freq.toDouble() * 1_000_000
|
||||
assertEquals(uplinkHz.toDouble(), adifFreq, 1.0)
|
||||
}
|
||||
|
||||
/** Mirrors WaveLog Logbook_model::parse_frequency: int = Hz, "12.3M" suffix = MHz. */
|
||||
private fun parseLikeWaveLog(raw: String): Long {
|
||||
val s = raw.trim()
|
||||
return if (s.endsWith("M", ignoreCase = true)) {
|
||||
(s.dropLast(1).toDouble() * 1_000_000).toLong()
|
||||
} else if (s.endsWith("k", ignoreCase = true)) {
|
||||
(s.dropLast(1).toDouble() * 1_000).toLong()
|
||||
} else {
|
||||
s.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun qsoFreqs_stayInSatelliteBands_afterDoppler() {
|
||||
// Doppler-corrected values must remain near the base frequency.
|
||||
// SPEED_OF_LIGHT = 299792458 m/s; distanceRate is km/s (x1000 -> m/s).
|
||||
val dopplerRate = 7.0 // km/s approaching
|
||||
val corrected = uplinkHz * (299_792_458.0 + dopplerRate * 1000.0) / 299_792_458.0
|
||||
assertTrue(
|
||||
"corrected within +-20kHz, got ${corrected - uplinkHz} Hz",
|
||||
kotlin.math.abs(corrected - uplinkHz) < 20_000
|
||||
)
|
||||
// band derivation: 145.x MHz -> 2m, never 160m (1.8-2.0 MHz)
|
||||
val mhz = corrected / 1_000_000.0
|
||||
assertTrue("145.x MHz stays in 2m, got $mhz MHz", mhz in 144.0..148.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun band_isRealBand_notSAT() {
|
||||
// SO-50: TX 145.850 MHz (VHF) -> band 2M, sat mode V/U
|
||||
assertEquals("2M", WaveLogApi.bandFromHz(145_850_000))
|
||||
assertEquals("V/U", WaveLogApi.satModeFrom(145_850_000, 436_795_000))
|
||||
// AO-73: TX 435.150 MHz (UHF up), RX 145.950 MHz (VHF down) -> band 70CM, U/V
|
||||
assertEquals("70CM", WaveLogApi.bandFromHz(435_150_000))
|
||||
assertEquals("U/V", WaveLogApi.satModeFrom(435_150_000, 145_950_000))
|
||||
// Same-band (e.g. simplex) -> empty sat mode
|
||||
assertEquals("", WaveLogApi.satModeFrom(145_850_000, 145_950_000))
|
||||
// Never 160m for satellite frequencies
|
||||
assertTrue(WaveLogApi.bandFromHz(145_850_000) != "160M")
|
||||
assertTrue(WaveLogApi.bandFromHz(436_795_000) != "160M")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun adif_containsRealBandAndSatMode() {
|
||||
// v2 payload fields (mirror postQso construction)
|
||||
val band = WaveLogApi.bandFromHz(uplinkHz)
|
||||
val satMode = WaveLogApi.satModeFrom(uplinkHz, downlinkHz)
|
||||
assertEquals("2M", band)
|
||||
assertEquals("V/U", satMode)
|
||||
}
|
||||
}
|
||||
@@ -368,12 +368,15 @@ class CwWaterfallState {
|
||||
private var colCount = 0
|
||||
private val pending = ArrayList<Float>(4096)
|
||||
private val fftWindow = 512
|
||||
private var acc = 0f
|
||||
private var accN = 0
|
||||
|
||||
/** Monotonic frame counter — reading this in composition triggers redraw. */
|
||||
private val _version = androidx.compose.runtime.mutableIntStateOf(0)
|
||||
val version: androidx.compose.runtime.State<Int> = _version
|
||||
|
||||
@Synchronized
|
||||
fun pushSamples(samples: FloatArray) {
|
||||
pending.addAll(samples.toList())
|
||||
var frames = 0
|
||||
while (pending.size >= fftWindow) {
|
||||
val window = FloatArray(fftWindow) { pending[it] }
|
||||
repeat(fftWindow) { pending.removeAt(0) }
|
||||
@@ -387,7 +390,9 @@ class CwWaterfallState {
|
||||
data[(rows - 1) * columns + c] = spectrum[bin.coerceAtMost(spectrum.size - 1)]
|
||||
}
|
||||
if (colCount < rows) colCount++
|
||||
frames++
|
||||
}
|
||||
if (frames > 0) _version.intValue++
|
||||
}
|
||||
|
||||
/** Magnitude spectrum via radix-2 FFT on a Hann-windowed frame. */
|
||||
@@ -466,6 +471,8 @@ class CwWaterfallState {
|
||||
private fun CwWaterfallView(state: CwWaterfallState) {
|
||||
val columns = 160
|
||||
val rows = 64
|
||||
// Read the frame counter so the canvas redraws as new spectra arrive.
|
||||
state.version.value
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -478,9 +485,10 @@ private fun CwWaterfallView(state: CwWaterfallState) {
|
||||
val col = state.getColumn(c)
|
||||
for (r in 0 until rows) {
|
||||
val v = col[r]
|
||||
val intensity = (v * 4000f).coerceIn(0f, 255f)
|
||||
if (intensity > 6f) {
|
||||
val green = (intensity * 1.3f).coerceAtMost(255f)
|
||||
// log-ish scaling: quiet bins stay dark, strong CW tones pop
|
||||
val intensity = (kotlin.math.ln1p(v * 600f) * 55f).coerceIn(0f, 255f)
|
||||
if (intensity > 4f) {
|
||||
val green = (intensity * 1.25f).coerceAtMost(255f)
|
||||
drawRect(
|
||||
color = Color(0f, green / 255f, 0f, 1f),
|
||||
topLeft = Offset(c * cellW, r * cellH),
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:height="24dp"
|
||||
android:width="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
|
||||
</vector>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:height="24dp"
|
||||
android:width="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
|
||||
</vector>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:height="24dp"
|
||||
android:width="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z"/>
|
||||
</vector>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:height="24dp"
|
||||
android:width="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
|
||||
</vector>
|
||||
@@ -906,9 +906,14 @@ private class CwPanelWaterfallState {
|
||||
private val pending = ArrayList<Float>(4096)
|
||||
private val fftWindow = 512
|
||||
|
||||
/** Monotonic frame counter — reading this in composition triggers redraw. */
|
||||
private val _version = androidx.compose.runtime.mutableIntStateOf(0)
|
||||
val version: androidx.compose.runtime.State<Int> = _version
|
||||
|
||||
@Synchronized
|
||||
fun pushSamples(samples: FloatArray) {
|
||||
pending.addAll(samples.toList())
|
||||
var frames = 0
|
||||
while (pending.size >= fftWindow) {
|
||||
val window = FloatArray(fftWindow) { pending[it] }
|
||||
repeat(fftWindow) { pending.removeAt(0) }
|
||||
@@ -920,7 +925,9 @@ private class CwPanelWaterfallState {
|
||||
val bin = c * spectrum.size / columns
|
||||
data[(rows - 1) * columns + c] = spectrum[bin.coerceAtMost(spectrum.size - 1)]
|
||||
}
|
||||
frames++
|
||||
}
|
||||
if (frames > 0) _version.intValue++
|
||||
}
|
||||
|
||||
private fun computeSpectrum(window: FloatArray): FloatArray {
|
||||
@@ -995,6 +1002,8 @@ private class CwPanelWaterfallState {
|
||||
private fun CwPanelWaterfallView(state: CwPanelWaterfallState) {
|
||||
val columns = 120
|
||||
val rows = 48
|
||||
// Read the frame counter so the canvas redraws as new spectra arrive.
|
||||
state.version.value
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
Reference in new issue
Block a user