Two corrections to 10c415fa and 0889a3bd, keeping what those got right and
undoing what they cost.
The transcript now follows new text through an explicit follow flag rather than
comparing scroll position against maxValue. maxValue is written during layout,
after the composition that would read it, so the comparison tested the previous
frame's height: following fell progressively short of the true bottom and, once
the gap passed the slack, latched the operator out of follow-mode until they hit
the exact end. Scrolling away still stops it, which is the point.
The AMSAT day cell goes back to 28 dp. Raising it to 48 dp for the minimum touch
target measured a 71% increase in row pitch - 14 satellites per screen down to 8
on a 6.1" phone - and comparing many satellites at a glance is what that page is
for. Compose cannot extend a touch target past the layout bounds, so this is a
choice rather than a fix; 28 dp is also what shipped before, so the regression
was mine. The contentDescription added alongside it stays, since it costs nothing.
The mixer runs above unity for any ordinary input - the Hilbert kernel's L1 gain
is 2.51, so amplitude 0.7 peaks at about 1.76 - and the output was hard clipped
to fit. Clipping squares the waveform off and generates odd harmonics, which the
widened waterfall would now put on screen.
Measured, the harmonics happen to be harmless today: TARGET_HZ is a quarter of
the sample rate, so 3f, 5f, 7f and 9f all fold back onto the tone itself and
out-of-band energy stayed at 0.00%. That is a coincidence between two constants,
not a property of the design. At a 700 Hz target the third harmonic folds to
1100 Hz - inside the analysis window, where no filter may remove it and the model
would read it as a second tone.
So two changes, because neither alone is enough. A peak-following gain scales the
mixer output to fit rather than clipping it: measured 0 of 3200 samples on the
rail, against a clipped waveform parking there for much of every cycle. And a
95-tap windowed-sinc band-pass over the model's window removes whatever the mix
leaves outside it - images, harmonics, the far sideband - measured at 58-60 dB
rejection with 0.09 dB of passband ripple and out-of-band energy down to 0.0002%.
The gain is shared across chunks so it cannot step at a boundary, and the filter
carries tap history for the same reason the Hilbert filter already did.
The band-pass adds 47 samples of linear-phase group delay, 14.7 ms, which delays
the keying envelope without distorting it - 4% of a dot at 40 WPM.
CwToneShifterStreamingTest's boundary criterion was wrong, and the band-pass
exposed it: distanceToBoundary measured only forward, so the first samples of a
chunk came out 320 away from "the" boundary and counted as interior when they are
the far side of the same seam. Both filters need samples ahead of the output they
are producing - 32 for the Hilbert transform, 47 for the band-pass - and with the
distance measured to the nearest boundary either way, interior divergence is
0.000116 against a 0.01 budget.
Also: the CW transcript now follows the newest text, but only while the operator
is already at the bottom, so scrolling back to read earlier traffic is not undone
by the next decoded character.
The waterfall showed only the model's 400-1200 Hz window, so a tone outside it
was absent from the picture entirely. Measured on keyed audio, the brightest
column in that narrow view swings 1.01x between key-down and key-up against
13.76x for a tone in range - it carries no keying at all, so the operator could
not tell a signal was present, let alone where it was. Markers alone could not
fix that: they pointed at a frequency with nothing drawn there.
compute() now takes an optional bin range, defaulting to the model's own, so the
decoder path is byte-identical and the golden-vector test still holds. The
display asks for DC to Nyquist, 129 bins against 65. The FFT already computed
every bin - this only changes which are kept - so the cost is a wider copy.
The decoder window is framed and faintly lifted, since half the picture is now
outside what the model reads and nothing said which half.
Marker fixes found while reviewing the render: the tone marker was orange, which
is a colour the inferno ramp itself passes through, so a marker sitting on the
trace it pointed at was indistinguishable from the keying gaps in that trace -
invisible in exactly the case it existed for. It is cyan now, and both markers
are pips in a gutter above the spectrum rather than lines across it.
Also from the release audit:
- compute()'s bin-count guard was written as a three-term disjunction, which any
custom range satisfies regardless of bin count, leaving the model invariant
unenforced for the caller most able to break it. Rewritten as an implication,
with a Nyquist bound so no range can index past the FFT output.
- signalStrength was gated on a confirmed out-of-window tone, which is false when
detection fails - and it fails for a slow fist, measured at prominence 2.5
against a 4.5 threshold for 15% duty. So the meter still read half scale beside
an empty transcript. It now requires a tone confirmed decodable: 11 flow
combinations, 3 wrong before, 0 wrong after.
- detectedToneHz never expired, so after retuning into the band the hint kept
naming the frequency the operator had left, indefinitely. It now clears after
10 s without a tone, which is clear of any real gap - the longest being 1.7 s
between words at 5 WPM.
- The waterfall label read estimatedPitch while the hint read detectedToneHz, two
numbers up to 800 Hz apart both claiming to be the tone. Both read the latter.
- Removed a redundant toFloat() that the compiler warned about.
Accessibility, untouched until now: the waterfall was a bare Canvas and the AMSAT
day cells bare Boxes, so both announced nothing at all - on the status page that
is the entire content of the screen. Both now carry a contentDescription naming
the tone or the day's worst status and report count. The AMSAT tap target goes
from 28 dp to 48 dp with the coloured tile still 28 dp, so the grid keeps its
density. Strings in all nine locales for both modules.
With tone shift off and the operator tuned outside 400-1200 Hz, the page did not
go quiet - it went confidently wrong. Three measurements, all reproduced against
the real spectrogram path:
estimatedPitch is (32 + loudestBin) * 12.5 - shiftHz with the bin confined to
0..64, so with no shift applied it can only ever report 400-1200 Hz. It cannot
express 1500 Hz, and it does not try: it publishes whichever window edge the
leakage piles against. For a 1500 Hz tone that is 1200 Hz.
That leakage is not faint. The waterfall normalises to the loudest value on
screen, so 50 of 65 bins clear the 0.06 draw threshold and the picture shows a
keyed-looking column pinned to the right edge - the 1200 Hz column runs 25 times
the 400 Hz one.
signalStrength is prominence over the window mean, so the same leakage scores
0.78 and paints the meter to 78% of full width.
So the operator got a strong-signal bar, a plausible 1200 Hz readout, a picture
that looked like a signal, and an empty transcript, with nothing saying why.
The scan that can see past the window now runs whether or not shifting is
enabled - it is the only measurement that can - and publishes through a new
detectedToneHz flow kept separate from estimatedPitch. Overloading the latter is
what let the 1200 Hz claim out in the first place, so the two meanings stay in
two flows. The shift decision still only happens when the setting is on. Cost is
one 121-bin scan every 2 s.
The meter now reads zero when a tone is out of range and not being shifted in: it
is a claim that something decodable is present, and in that state nothing is.
A line under the waterfall says which case the operator is in - the tone was
moved in, or it is out of range and tone shift is off, naming the frequency and
the remedy. Strings in all nine locales; feature:cw only had five, so values-es,
values-ru, values-si and values-uk are new, with the Turkish apostrophe escaped.
CwToneShifterTest pins the premise the hint rests on: that the scan reports tones
the model window excludes, at 120, 250, 1400 and 1500 Hz.
The guard suppressed every marker, the target line included, whenever the
reported pitch was not positive. Shifting a low tone UP makes that routine:
pitch is (loudestBin * 12.5 - shiftHz), so with a 100 Hz tone shifted +700 Hz it
goes negative for 25 of the 65 bins, down to -300 Hz, and updateSignalMetrics
applies no prominence test so mains hum in a key-up gap is enough to park the
argmax down there. 77 reachable (tone, bin) pairs across 100-350 Hz produce it.
The result was the display showing nothing at all while the shift was active -
exactly what the previous commit set out to fix.
The target line is now drawn on the strength of the shift alone, since a shift
being applied is the fact worth showing and it does not depend on the pitch. A
non-positive pitch marks the low edge, which is where such a tone actually is,
and only the numeric label is suppressed because the number itself is nonsense.
A NaN pitch previously slipped past all three comparisons and rendered the HIGH
edge marker labelled "0 Hz"; it now draws the target line only.
TONE_SHIFT_TARGET_HZ reads CwToneShifter.TARGET_HZ instead of recomputing the
window midpoint. The two are equal today by coincidence, not construction:
retuning either would leave the green line marking a frequency nothing is
delivered to, silently. CwToneShifterTest now pins TARGET_HZ inside the window
and clear of its edges, which is the one part of this the JVM suite can hold.
The label side now tips at the target rather than the window maximum, so a pitch
sitting on the upper edge gets its text on the same side as its line.
The edge marker was drawn outward from the canvas edge, so every one of its
three line segments fell outside the clip and nothing rendered. Measured at a
typical 320 px width: 0 of 3 segments visible on either side. That is the one
case the marker exists for - an out-of-window tone is absent from this picture
by definition, so with the marker clipped away the operator has no signal at all
that a shift is happening. Which is what was reported.
It is now a solid bar along the edge the tone lies beyond, plus a chevron whose
arms open inward from it, so the whole marker sits inside the clip while still
reading as pointing off-picture.
Three further defects in the same code:
The frequency label was pinned to TopStart while its background rect tracked the
tone's frequency, so at 1500 Hz the rect sat at x=278 and the text at x=11. The
rect is gone and the label now sits on whichever side the marker is on.
Markers were drawn after two early returns that fire on an empty or silent
spectrum. A shift is deliberately held through key-up gaps, so the markers were
blinking out during the very silences the shift survives. They now draw
unconditionally, after the spectrum so it cannot bury them.
dashCount floored, leaving up to 8 px of the column undrawn at the bottom.
Also extracts the marker drawing into a DrawScope extension, hoists the shared
colours and the target frequency to file-level constants, and rounds the label
instead of truncating it.
The Canvas marker was fixed to draw at estimatedPitch, but the overlay Text
still computed its label from estimatedPitch + toneShiftHz, which showed the
shifted position (800 Hz) instead of the original tone (e.g. 1500 Hz).
estimatedPitch is already corrected back to the original tone frequency
(the spectrogram computes from shifted audio, and updateSignalMetrics undoes
the shift), so adding toneShiftHz to it again placed the orange marker at the
shifted position - right on top of the green target line, making them
indistinguishable.
The orange marker now goes directly on estimatedPitch. When the original pitch
is outside the visible 400-1200 Hz band, an arrow at the nearest edge points
toward it instead.
When the tone-shift feature moves a tone into the model's 400-1200 Hz window,
the waterfall now shows two visual markers so the operator can see what is
happening: a green dashed line at the target (800 Hz) and an orange frequency
label at the top-left showing the original pitch.
The waterfall draws the RAW audio, not the shifted audio, so a 1500 Hz tone was
always invisible regardless of the shift setting. The markers close the gap:
the operator can now see that a tone was detected and where it was moved, even
when the original pitch is outside the visible band.
activeShiftHz is now a StateFlow exposed through ICwDecoder so the UI can
observe it without polling.
Two races shared the same cause: pushSamples runs on the audio capture thread
while clear() runs on the Compose main thread.
1. Lost redraw notifications
Both paths did `_revision.value += 1`. That expands to get -> add -> set and is
not atomic. A controlled two-thread probe (20k increments each, five runs)
lost up to 6,402 increments / 16%; using StateFlow.update lost zero. Since
revision is the Canvas's only redraw signal, every lost update can leave the
waterfall showing stale rows. If both writes land on the same number, StateFlow
sees no value change and notifies nobody.
Use `_revision.update { it + 1 }` in both paths.
2. Clear resurrected pre-clear audio
pushSamples copies pending audio under the lock, deliberately performs FFT
outside it, then reacquires the lock to append rows. The exact interleaving:
audio thread: take old audio, start FFT
main thread: user taps Clear -> rows/pending empty
audio thread: old FFT completes -> appends old rows again
The display becomes empty then immediately redraws the audio the user cleared.
A deterministic thread probe reproduced old rows after clear. Add a generation
counter protected by the same lock: pushSamples records it before FFT and drops
the computed rows when clear incremented it meanwhile. Fixed probe remains empty.
Verification: :feature:cw:compileReleaseKotlin + full :core:domain:test BUILD
SUCCESSFUL; grep confirms no non-atomic revision increments remain.
All Chinese comments (//, /* */, KDoc) across core/app/feature/build-
logic translated to English (550 lines, 73 files after FT8 rollback).
Code logic untouched - comment text only. Verified: all modules
compileDebugKotlin BUILD SUCCESSFUL.
aapt2 treats in/b+in as the same locale config as id -> Duplicate
resources build failure. in and id are equivalent in Android's
resource matcher (both normalize to id), so values-in + values-id
is sufficient coverage for Indonesian devices.
values-in-rID did not survive aapt2 linking (normalized away).
Switch to values-b+in (BCP-47) which compiles cleanly and keeps an
explicit "in" language config alongside values-id. Verified: aapt2
compile of values-in/values-id/values-b+in OK.
Root cause: aapt2 merges values-in into values-id (in is the legacy
alias of id), so the APK only carried the (id) config. New devices
report "id" and match; older devices report "in" and find no (in)
config -> fall back to English (friend's report: only system date
showed Indonesian).
Fixes:
- Add values-in-rID (core/presentation + feature/cw) so the APK
keeps a real "in" language config; values-in and values-id both
get translatable="false" on the 27 entries that English marks
(aapt2 rejects those with multiple %-substitutions otherwise)
- Verified: aapt2 compile of values/values-in/values-id/values-in-rID
OK; :core:presentation:mergeDebugResources + :feature:cw:mergeDebugResources
BUILD SUCCESSFUL
Friend's device (system language Bahasa Indonesia) fell back to
English even though values-in exists. Modern Android devices report
the Indonesian locale as "id" (ISO-639-1 current code; "in" is the
legacy alias) and resource matching is strict. Added values-id as a
copy of values-in in core/presentation and feature/cw (both language
directories ship the same translations).
Verified: check_strings OK (9 files incl. values-id);
:app:compileDebugKotlin BUILD SUCCESSFUL. Not released (batch with
pending fixes).
Background: 8 bottom-nav items squeeze long English labels on narrow
screens. 4.5.1 introduces the 5+N pattern: 5 main tabs plus a fixed
6th "More" button that pops a second-level menu (spring bounce) with
the remaining pages.
Changes:
- MainScreen: nav split into main (<=5, screenOrder-driven) + more
(subMenuOrder); More button with popup panel + spring animation;
BackHandler closes the menu before navigating back
- MoreMenuPopup: bottom-end card, current page highlighted, scrim
click to dismiss
- UI Settings: page order card now has two zones (main menu, max 5,
Settings locked last with no drag handle / more menu); move buttons
between zones, drag-to-reorder within zones; new subMenuOrder pref
- Defaults: main = Satellites/Passes/Radar/Map/Settings,
more = Mutual/Roaming/CwDecode; old screenOrder migrates by
classifying pages against the default sub menu
- Hard-coded UI strings localized (Tracking/Lat/Lon/Qth/Connect/
Track/Stop/CW permission prompts) into EN/ZH/TR
- NEW Indonesian locale (values-in, 181 strings + cw module strings)
- user rule: every future release must update EN/ZH/TR/IN
- Version 4.5.1 (451)
Verified: check_strings.py OK (8 files, no bare apostrophes);
:app:compileDebugKotlin BUILD SUCCESSFUL locally.
Background: the transponder panel CW decoder (added by the upstream
fork author) used a lightweight Kotlin Bayesian engine (core/domain/cw,
kept untouched as a fallback). This change makes the panel use the
Morse Expert engine ported in 4.5.0, so both CW entry points share the
same decoder with a live waterfall.
Changes:
- New mini layout cw_panel_main.xml (waterfall 80dp + decoded text,
status line hidden but ID kept for controller lookup)
- MainActivity.onCreate overload with applyImmersive flag; panel binds
with false so the host window system bars are not touched
- CwDecoderPanel now embeds the mini layout via AndroidView and drives
the MainActivity controller: start/stop/reset map to the engine,
lifecycle follows panel expand (start) / collapse (release mic)
- radar module now depends on feature:cw (+ constraintlayout 2.2.1,
same as cw) for layout + controller reuse
- What's new rewritten in EN/TR/ZH for this release only
Verified: :feature:radar:compileDebugKotlin and :app:compileDebugKotlin
BUILD SUCCESSFUL locally; check_strings.py OK (7 files, no bare
apostrophes).
The waterfall showed mirrored/upside-down garbage because the FFT
never produced a valid spectrum:
1. g3.c.f() (high-precision sin) had its quadrant-0 case mangled by
jadx into a nested-if that returned NaN for small angles - the
twiddle factor table ended up with 329/1024 NaN entries.
Restored the smali switch: case0->g(), case1->c(), case2->-g(),
case3->-c().
2. i3.d.k() routed the runtime FFT (a5==0, single-thread path) into
the else of if(a5!=1) instead of if(a5!=0), so the FFT never ran
and the output stayed in the time domain (peak at bin 357 for a
669Hz tone instead of bin 86).
Verified with a JVM harness (static-block tables + pure-tone inputs):
200Hz->bin26, 400Hz->bin51, 669Hz->bin86, 1000Hz->bin128, all exact.
Twiddle table NaN count: 329 -> 0.
Decode page showed stats but empty waterfall and no decoded text:
the ported i3/d.k() FFT dispatch was broken by a jadx structure
misplacement - the runtime path (k=1 -> a5=0 -> cond_22 single-thread
FFT) was replaced by a hallucinated `throw null` else-branch while the
real cond_22 code sat in a dead else. Verified against smali
(7030-7263): j3.c.q forward FFT + post-processing loop + tail + small-
array branch now live in the a5==0 branch.
Also:
- UiSettingsCard now lists CwDecode (toggle + drag-reorder) between
Roaming and Map
- unknown screenIds in persisted screenOrder fall back to
defaultScreenOrder position (CwDecode lands between Roaming and Map
for existing users instead of trailing after Settings)
Verified: :feature:cw + :feature:settings + :app compileDebugKotlin
BUILD SUCCESSFUL.
Three release-breaking issues found in the v4.5.0 APK (app crashed on
launch, label showed "Morse Expert", dex shrank to 282KB vs 3.5MB):
1. app_name: the ported app_values.xml shipped a "Morse Expert"
app_name string which overrode Look4Sat Pro's label during resource
merging - removed (no other string collisions).
2. R8 stripped nearly all code: the in-app sun.misc.Unsafe/Cleaner
stubs clashed with android.jar library classes. Moved stubs to
com.rtbishop.look4sat.feature.cw.suncompat and updated k3.d/s/r
imports (k3.r keeps the reflective Class.forName("sun.misc.Unsafe")
string, which returns null on Android hidden-API limits).
3. proguard-rules.pro added (AGP 9 variant-level
CanProduceConsumerProguardFiles): keep pas.** (JNI RegisterNatives
resolves by class name) plus all ported CW classes.
4. app-level ndk abiFilters forced to armeabi-v7a: the ported
libnativedecoderjni.so is v7a-only, so a multi-ABI APK would crash
with UnsatisfiedLinkError on arm64 devices.
Verified: :feature:cw:compileDebugKotlin BUILD SUCCESSFUL.
Compose integration of the ported CW decoder engine:
- CwDecodeScreen: AndroidView embedding the ported activity_main.xml,
lifecycle delegated to the ported MainActivity controller (onCreate ->
onResume, onDispose -> onPause/onDestroy), RECORD_AUDIO runtime
permission flow (with permanent-denial -> app settings), original
options_menu actions as a top button row (pause/clear/save/record/
settings), double-back-to-exit preserved
- CwSettingsDialog: message_type (general_text/ham_radio_qso),
text_font_size (7-99), and the 9 color keys (bg_color/text_color/...)
reading/writing the same prefs keys as the original app
(getPackageName()+"_preferences"), colors sourced from I2.b tables
- Navigation: Screen.CwDecode ("CwDecode") placed between Roaming and
Map in the default order; defaultScreenOrder updated; ic_cw morse icon;
nav_cw strings (en/zh/tr); app depends on :feature:cw
Verified: :feature:cw:compileDebugKotlin + :app:compileDebugKotlin
BUILD SUCCESSFUL (first pass, no errors).