Revert "fix(cw): scale and band-pass the shifted audio instead of clipping it"
This reverts commit 0889a3bd88.
This commit is contained in:
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.
|
||||
*
|
||||
|
||||
+3
-12
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user