Revert "fix(cw): scale and band-pass the shifted audio instead of clipping it"

This reverts commit 0889a3bd88.
This commit is contained in:
mckero committed 2026-08-23 09:42:45 +00:00
1 parent 0889a3bd88
commit b6753a4fa6
4 files changed
+7 -244

No files matched your search

@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.sin
@@ -96,40 +95,6 @@ object CwToneShifter {
/** Group delay of [hilbertKernel], applied to the real path to keep them aligned. */
private const val HILBERT_DELAY = HILBERT_TAPS / 2
/** Taps in the band-pass that follows the mixer. Odd, for a symmetric linear phase. */
private const val BANDPASS_TAPS = 95
private const val BANDPASS_DELAY = BANDPASS_TAPS / 2
/**
* Windowed-sinc band-pass over the model's analysis window.
*
* Applied after the shift, and the reason a clipped sample can no longer scatter energy
* across the display: the mixer runs above unity for any ordinary input, and while
* [TARGET_HZ] happens to sit at a quarter of the sample rate today - so odd harmonics
* of a clipped tone fold back onto the tone itself - that is a coincidence of two
* constants, not a property of the design. This also removes whatever image the
* single-sideband mix leaves behind.
*
* Difference of two low-pass sincs, Hamming-windowed to hold the sidelobes down.
* Measured: 58-60 dB rejection at 300 Hz and 1500 Hz, 0.09 dB ripple over 450-1150 Hz,
* and a 47-sample linear-phase group delay that delays the keying without distorting it.
*/
private val bandpassKernel: FloatArray = run {
val lo = CwDeepSpectrogram.MIN_FREQ_HZ / CwDeepSpectrogram.SAMPLE_RATE
val hi = CwDeepSpectrogram.MAX_FREQ_HZ / CwDeepSpectrogram.SAMPLE_RATE
FloatArray(BANDPASS_TAPS) { i ->
val n = i - BANDPASS_DELAY
val ideal = if (n == 0) {
2.0 * (hi - lo)
} else {
(sin(2.0 * PI * hi * n) - sin(2.0 * PI * lo * n)) / (PI * n)
}
val window = 0.54 - 0.46 * cos(2.0 * PI * i / (BANDPASS_TAPS - 1))
(ideal * window).toFloat()
}
}
/** Outcome of inspecting a chunk of audio. */
data class Analysis(
/** Detected tone in Hz, or null when the audio is noise. */
@@ -238,25 +203,7 @@ object CwToneShifter {
val step = 2.0 * PI * shiftHz / sampleRate
for (i in audio.indices) {
val phase = step * i
out[i] = (audio[i] * cos(phase) - quadrature[i] * sin(phase)).toFloat()
}
// Same chain as Streaming, so the two paths stay comparable: scale to fit rather
// than clip, then filter to the window. Clipping would generate harmonics, and a
// third harmonic can fall inside the window where the filter cannot remove it.
Normaliser().applyTo(out)
return bandPassWhole(out)
}
/** Band-pass an entire buffer; the streaming path keeps tap history instead. */
private fun bandPassWhole(audio: FloatArray): FloatArray {
val out = FloatArray(audio.size)
for (i in audio.indices) {
var sum = 0f
for (k in bandpassKernel.indices) {
val j = i - k + BANDPASS_DELAY
if (j >= 0 && j < audio.size) sum += bandpassKernel[k] * audio[j]
}
out[i] = clampToUnit(sum.toDouble())
out[i] = clampToUnit(audio[i] * cos(phase) - quadrature[i] * sin(phase))
}
return out
}
@@ -289,8 +236,6 @@ object CwToneShifter {
private val history = FloatArray(HILBERT_TAPS - 1)
private var phase = 0.0
private val bandHistory = FloatArray(BANDPASS_TAPS - 1)
private val normaliser = Normaliser()
/** Shift one chunk, continuing the filter and oscillator state. */
fun process(chunk: FloatArray, shiftHz: Float, sampleRate: Int): FloatArray {
@@ -317,50 +262,12 @@ object CwToneShifter {
}
val currentPhase = phase + step * i
val mixed = combined[centre] * cos(currentPhase) - quadrature * sin(currentPhase)
out[i] = mixed.toFloat()
out[i] = clampToUnit(mixed)
}
// Scale to fit before filtering, rather than clipping after. Clipping generates
// odd harmonics and the band-pass can only remove the ones that land outside
// the window - a third harmonic can land inside it, where the filter has no
// business touching it and the model would read it as a second tone.
normaliser.applyTo(out)
val filtered = bandPass(out)
// Keep the phase bounded; letting it grow loses float precision.
phase = (phase + step * chunk.size) % (2.0 * PI)
pushHistory(chunk)
return filtered
}
/**
* Band-pass [chunk] to the model's window, carrying tap history across calls.
*
* Same reason the Hilbert filter keeps history: without the previous chunk's tail
* the first outputs convolve against zeros and every boundary gets a transient.
*/
private fun bandPass(chunk: FloatArray): FloatArray {
val combined = FloatArray(bandHistory.size + chunk.size)
bandHistory.copyInto(combined)
chunk.copyInto(combined, bandHistory.size)
val out = FloatArray(chunk.size)
for (i in chunk.indices) {
val centre = bandHistory.size + i
var sum = 0f
for (k in bandpassKernel.indices) {
val j = centre - k + BANDPASS_DELAY
if (j >= 0 && j < combined.size) sum += bandpassKernel[k] * combined[j]
}
out[i] = clampToUnit(sum.toDouble())
}
// Keep the newest BANDPASS_TAPS - 1 samples of the pre-filter signal.
val keep = minOf(bandHistory.size, chunk.size)
if (keep < bandHistory.size) {
bandHistory.copyInto(bandHistory, 0, keep, bandHistory.size)
}
chunk.copyInto(bandHistory, bandHistory.size - keep, chunk.size - keep, chunk.size)
return out
}
@@ -368,8 +275,6 @@ object CwToneShifter {
fun reset() {
history.fill(0f)
phase = 0.0
bandHistory.fill(0f)
normaliser.reset()
}
/** Keep the most recent [history] samples of the stream. */
@@ -398,52 +303,6 @@ object CwToneShifter {
return shift(audio, analysis.shiftHz, sampleRate) to analysis
}
/**
* Peak-following gain, so a shifted tone is scaled to fit rather than clipped flat.
*
* The Hilbert kernel has an L1 gain of 2.51, so an input of normal strength leaves the
* mixer above unity and hard clipping squared the waveform off. That distortion is
* mostly hidden today by a coincidence - [TARGET_HZ] is a quarter of the sample rate,
* so every odd harmonic of a clipped tone folds back onto the tone itself - but the
* keying envelope is still flattened, and a different target would scatter harmonics
* across the band instead. Scaling avoids both.
*
* The gain decays slowly and recovers slowly so it does not pump within a keyed
* element, and it is shared across chunks because a per-chunk gain would step at
* every boundary.
*/
internal class Normaliser {
private var gain = 1.0
/** Fit [samples] inside +-1 in place, adjusting the shared gain as needed. */
fun applyTo(samples: FloatArray) {
var peak = 0.0
for (v in samples) {
val a = abs(v.toDouble())
if (a > peak) peak = a
}
// A peak needing less gain than we have takes effect at once: overshoot is
// what clipping used to destroy, so it is corrected without waiting.
val needed = if (peak > 1e-9) 1.0 / peak else 1.0
gain = if (needed < gain) needed else gain + (needed - gain) * RECOVERY
gain = gain.coerceAtMost(1.0)
for (i in samples.indices) {
// Still bounded: the gain is derived from this chunk's peak, but the
// recovery ramp can leave a later sample slightly over.
samples[i] = clampToUnit(samples[i] * gain)
}
}
fun reset() {
gain = 1.0
}
private companion object {
/** Per-chunk fraction of the way back towards unity gain. */
const val RECOVERY = 0.05
}
}
/**
* Keep a mixed sample inside the +/-1.0 range the spectrogram assumes.
*
@@ -103,21 +103,12 @@ class CwToneShifterStreamingTest {
val whole = CwToneShifter.shift(audio, shiftHz, sampleRate)
val streamed = processInChunks(audio, shiftHz)
// Both filters in the chain need samples ahead of the output they are producing,
// and a stream does not have them yet: 32 for the Hilbert transform, 47 for the
// band-pass that now follows it. Measured, the divergence is confined to those
// trailing samples and clears immediately after each boundary.
val lookahead = 48
val skip = 256 // start-up transient of both filters
val lookahead = 32 // HILBERT_TAPS / 2, rounded up
val skip = 128 // filter start-up transient
var worstInterior = 0.0
var worstTail = 0.0
for (i in skip until audio.size) {
// Distance to the nearest boundary in either direction. Measuring only forward
// put the first samples of a chunk 320 away from "the" boundary and so counted
// them as interior, when they are the other side of the same seam - the tail
// of the previous chunk, mislabelled.
val intoChunk = i % chunkSize
val distanceToBoundary = minOf(intoChunk + 1, chunkSize - intoChunk)
val distanceToBoundary = chunkSize - (i % chunkSize)
val delta = abs(whole[i] - streamed[i]).toDouble()
if (distanceToBoundary <= lookahead) {
worstTail = maxOf(worstTail, delta)
@@ -94,76 +94,6 @@ class CwToneShifterTest {
}
}
/**
* A shifted tone must not be clipped, because clipping generates odd harmonics and
* the third one can land inside the window where the band-pass cannot remove it.
*
* The Hilbert kernel's L1 gain is 2.51, so an ordinary input drives the mixer well
* past unity: at amplitude 0.7 the pre-scaling peak is about 1.76.
*/
@Test
fun `a shifted tone is scaled to fit rather than clipped`() {
val audio = FloatArray(3200) { (0.7 * sin(2.0 * PI * 1500.0 * it / 3200.0)).toFloat() }
val (shifted, _) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
var atRail = 0
for (v in shifted) if (abs(v) >= 0.999f) atRail++
// A handful of samples may sit on the rail through rounding; a clipped waveform
// parks there for a large share of every cycle.
assertTrue("$atRail samples clipped, expected almost none", atRail < shifted.size / 100)
val peak = shifted.maxOf { abs(it) }
assertTrue("output must stay bounded, peak was $peak", peak <= 1.0f)
assertTrue("output must not be scaled into silence, peak was $peak", peak > 0.1f)
}
/**
* Energy outside the model's window must be gone after a shift, whatever the mixer
* left behind - images, harmonics, or the far sideband.
*/
@Test
fun `shifting leaves no energy outside the model window`() {
val audio = FloatArray(3200) { (0.7 * sin(2.0 * PI * 1500.0 * it / 3200.0)).toFloat() }
val (shifted, _) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
val binHz = sampleRate.toDouble() / CwDeepSpectrogram.FFT_LENGTH
var inBand = 0.0
var outOfBand = 0.0
for (bin in 0..CwDeepSpectrogram.FFT_LENGTH / 2) {
val hz = bin * binHz
val power = magnitudeAt(shifted, hz).let { it * it }
if (hz >= CwDeepSpectrogram.MIN_FREQ_HZ && hz <= CwDeepSpectrogram.MAX_FREQ_HZ) {
inBand += power
} else {
outOfBand += power
}
}
val leakage = outOfBand / (inBand + outOfBand) * 100.0
assertTrue("%.2f%% of the energy is outside the window".format(leakage), leakage < 1.0)
}
/**
* The band-pass removes harmonics that fall outside the window, but a third harmonic
* can fall inside it - at a 700 Hz target it folds to 1100 Hz, squarely in range. So
* the target must stay where its harmonics are harmless, and this pins that: at a
* quarter of the sample rate every odd harmonic folds back onto the tone itself.
*/
@Test
fun `the shift target keeps clipping harmonics off the band`() {
assertEquals(
"TARGET_HZ must stay at a quarter of the sample rate",
sampleRate / 4.0, CwToneShifter.TARGET_HZ, 0.001
)
for (harmonic in listOf(3, 5, 7, 9)) {
var folded = CwToneShifter.TARGET_HZ * harmonic % sampleRate
if (folded > sampleRate / 2.0) folded = sampleRate - folded
assertEquals(
"harmonic $harmonic folds to $folded Hz, not back onto the tone",
CwToneShifter.TARGET_HZ, folded, 0.001
)
}
}
/**
* The decoder runs this scan even with shifting switched off, purely to tell the
* operator why nothing is decoding. That only works if the scan reaches past the
@@ -68,14 +68,6 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import kotlin.math.roundToInt
import com.rtbishop.look4sat.core.presentation.R as CoreR
/**
* Scroll slack, in pixels, within which the transcript is treated as "at the bottom".
*
* Not zero: the scroll position lands a pixel or two short of the maximum after an
* animated scroll, and an exact comparison would then stop following the newest text.
*/
private const val AUTOSCROLL_SLACK_PX = 4
/**
* Full-page CW decoder backed by DeepCW.
*
@@ -230,20 +222,11 @@ fun CwDecodeScreen() {
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
) {
val transcript = (historyText + decodedText).ifEmpty { "…" }
val scroll = rememberScrollState()
// Follow the newest text, but only while the operator is already at the
// bottom. Scrolling up is how they read back over earlier traffic, and
// yanking them to the end on every decoded character would undo that.
val atBottom = scroll.value >= scroll.maxValue - AUTOSCROLL_SLACK_PX
LaunchedEffect(transcript) {
if (atBottom) scroll.animateScrollTo(scroll.maxValue)
}
Text(
text = transcript,
text = (historyText + decodedText).ifEmpty { "…" },
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.verticalScroll(rememberScrollState())
.padding(8.dp),
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,