Add Spectrum3D app

This commit is contained in:
Armel FAUVEAU committed 2026-09-25 04:04:44 +02:00
1 parent 9d5b3af7a3
commit cc1439dcc2
4 files changed
+815

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
/* Spectrum3D overlay app: code, constants, data and BSS share the 4 KiB workspace. */
APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280;
APP_LENGTH = 0x1000;
ENTRY(app_main)
MEMORY {
APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH
}
SECTIONS {
.app APP_VMA : {
KEEP(*(.text.entry))
*(.text .text.*)
*(.rodata .rodata.*)
. = ALIGN(4);
*(.data .data.*)
. = ALIGN(4);
__app_bss_start = .;
*(.bss .bss.* COMMON)
. = ALIGN(4);
__app_bss_end = .;
} > APP
__app_end = .;
ASSERT(__app_end <= APP_VMA + APP_LENGTH,
"Spectrum3D overlay app overflows the 4 KiB overlay")
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) }
}
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
APP="$(basename "$PWD")"
APP_NAME="Spectrum3D"
APP_VER="1.0"
APP_API_MIN=2
APP_VMA=${APP_VMA:-0x20000280}
OUT="${APP_NAME// /}"
CC=/opt/toolchain/bin/arm-none-eabi-gcc
OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy
command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; }
command -v "$CC" >/dev/null 2>&1 || { echo "❌ ARM compiler not found: $CC"; exit 1; }
command -v "$OBJCOPY" >/dev/null 2>&1 || { echo "❌ ARM objcopy not found: $OBJCOPY"; exit 1; }
CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -std=gnu11 -ffreestanding -fno-builtin -fno-common \
-fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra"
LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} \
-Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments"
rm -f ./*.app ./*.elf ./*.bin
step() { printf '\r 🔨 %-13s [%d/4] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 assets ; python3 ./gen_assets.py "${APP}_assets.bin" "${APP}_assets.h"
step 2 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
step 3 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 4 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" \
--vma "${APP_VMA}" --screensaver --assets "${APP}_assets.bin" >/dev/null
trap - ERR
BYTES=$(wc -c < "${APP}.bin")
if [ "$BYTES" -gt 4096 ]; then
printf '\r 🚨 %-13s OVERFLOWS 4 KiB (%d B) \n' "$APP_NAME" "$BYTES"; exit 1
fi
printf '\r ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app \n' \
"$APP_NAME" "$BYTES" "$(( BYTES * 100 / 4096 ))" "$OUT"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
# Spectrum3D read-only assets: texts, bitmaps and the setting tables.
#
# ./gen_assets.py spectrum3d_assets.bin spectrum3d_assets.h
import math, os, struct, sys
sys.dont_write_bytecode = True
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from app_assets import Assets
# One record per span of the 64-point sweep. The app reads a record whole into
# its struct globals, from `step` on, so the fields must keep the order and
# sizes of that struct (the app checks the total with a static assert):
# step u16 point spacing (x10 Hz)
# rx_bw u16 REG_43 RX filter: the resident spectrum's scanStepBWRegValues
# for the same steps (App/app/spectrum.h)
# substeps u8 measurements per point, 1 or 2 (the app shifts, never
# divides): 25 kHz is the widest filter, so the 50 kHz step
# also measures halfway and keeps the maximum
# close_db u8 listening closes below the sweep floor plus this many dB:
# the audio uses the VFO's own filter (up to 25 kHz), whose
# noise sits higher than the narrower sweep filters (+6 dB
# over 6.25 kHz, +3 dB over 12.5 kHz, on top of 5 dB)
# label the span capsule: LABEL_LEN characters + NUL
SPANS = [("0.4M", 625, 0x4858, 1, 11), # 6.25 kHz filter
("0.8M", 1250, 0x7F08, 1, 8), # 12.5 kHz filter
("1.6M", 2500, 0x3628, 1, 5), # 25 kHz filter
("3.2M", 5000, 0x3628, 2, 5)] # 25 kHz filter, two points per step
LABEL_LEN = 4
if any(len(s[0]) != LABEL_LEN or s[3] not in (1, 2) for s in SPANS):
sys.exit("SPANS: labels must have LABEL_LEN characters and substeps be 1 or 2")
RECORDS = [struct.pack(f"<HHBB{LABEL_LEN + 1}s", step, bw, sub, close, label.encode("ascii"))
for label, step, bw, sub, close in SPANS]
# RX regions the sweep must stay inside (x10 Hz, inclusive): the RF path
# switches VHF/UHF at 280 MHz (BK4819_PickRXFilterPathBasedOnFrequency) and the
# chip covers 18..630 and 840..1300 MHz (App/frequencies.c, RX_freq_check).
REGIONS = [(1800000, 27999999), (28000000, 62999999), (84000000, 130000000)]
# Saved settings, in the order of the app's struct globals: magic, span,
# speed, yaw (signed), pitch. The defaults: 1.6 MHz, a line per sweep, front
# view tilted 25 degrees. 0x3E configs stored yaw + 9: the new magic resets them.
CFG_MAGIC = 0x3F
PITCH_DEF = 5 # 25 degrees
SPEED_COUNT = 3 # 1 << speed sweeps per landscape line: 1, 2, 4 (peak-held)
a = Assets("SPECTRUM3D")
a.text("T_TITLE", "SPECTRUM3D")
a.text("T_HOLD", "HOLD")
a.raw("SPAN_REC", b"".join(RECORDS))
a.const("SPAN_REC_SIZE", len(RECORDS[0]))
a.const("SPAN_LABEL_LEN", LABEL_LEN)
a.const("SPAN_COUNT", len(SPANS))
a.u32("REGION", [edge for region in REGIONS for edge in region])
a.const("REGION_COUNT", len(REGIONS))
a.u8("CFG_DEFAULT", [CFG_MAGIC, 2, 0, 0, PITCH_DEF])
a.const("CFG_MAGIC", CFG_MAGIC)
a.const("PITCH_DEF", PITCH_DEF)
a.const("SPEED_COUNT", SPEED_COUNT)
a.u8("BMP_F", [0x3e,0x7f,0x41,0x75,0x75,0x75,0x7d,0x7f,0x3e]) # F-armed icon, as Beacon
a.u8("BMP_SPEAKER", [0x1c,0x1c,0x3e,0x7f,0x00,0x22,0x1c,0x41,0x22,0x1c]) # as FoxHunt
# sin() in Q8 from -45 to 135 degrees by 5, 0 degrees at SINQ_ZERO: the yaw
# (-45..45), the pitch (10..60) and their cosines, sin(90 - angle), index it
# directly, with no sign handling in the app.
a.i16("SINQ", [round(math.sin(math.radians(d)) * 256) for d in range(-45, 136, 5)])
a.const("SINQ_ZERO", 9)
a.main()
+678
View File
@@ -0,0 +1,678 @@
/* Copyright 2026 Armel F4HWN
* https://github.com/armel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* Spectrum3D — overlay app: the band around the VFO as a ridge-line landscape
* ("Unknown Pleasures" style). Each sweep of 64 points becomes one line of a
* frequency x time grid; the newest line is nearest. The grid is rotated about
* its vertical axis (yaw) and tilted (pitch), projected in perspective, and
* drawn far to near with hidden-line removal: a line clears everything below
* it, so near "mountains" hide the lines behind them. Peaks are stations.
*
* The receiver runs at fixed gain so a strong signal does not pull the others
* down, and heights are compressed (soft saturation above a noise gate over a
* floor taken from the quiet points) so weak stations stay visible next to
* strong ones. Retuning goes through the radio registers. The window is kept
* on the VFO's side of the 280 MHz VHF/UHF RF-path switch and inside the
* chip's RX range (never in the 630-840 MHz hole), so the path chosen by the
* loader stays valid; the RX filter bandwidth follows the point spacing, as in
* the resident spectrum. The history lives on app_main's stack; texts, tables
* and bitmaps are read-only assets.
*
* Keys: 1 / F+1 centre up / down one point (the landscape slides) ·
* 3 / F+3 wider / narrower span (UP/DOWN too) · 4/6 rotate · 2/8 tilt ·
* 5 reset view · 0 listen to the strongest peak (FM, speaker icon; the
* landscape goes on with that station alone) · STAR speed · MENU hold ·
* EXIT quit.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#include "spectrum3d_assets.h" /* generated by gen_assets.py */
#define W 128u
#define FB_H 56u /* frame-buffer rows (below the status line) */
#define BINS 64u /* points per sweep, 2 px apart */
#define LINES 12u
#define ACC LINES /* history row of the next line */
/* Projection, tuned on a grid of X -63..63 (frequency) by Z -44..44 (time)
* with the frame around it: camera distance, horizontal and vertical focal
* lengths, screen centre row. */
#define CAM_D 200
#define FOCAL_X 140
#define FOCAL_Y 200
#define CENTRE_Y 26
/* The frame around the landscape, on the floor: half width, half depth. */
#define BOARD_X 67
#define BOARD_Z 48
#define HEIGHT_Q 64 /* tallest peak, quarter units */
#define COMPRESS 12 /* dB where a peak reaches half */
#define NOISE_GATE 4 /* dB above the floor ignored */
#define YAW_MAX 9 /* 45 degrees, 5-degree steps */
#define PITCH_MIN 2u /* 10 degrees (default: assets) */
#define PITCH_MAX 12u /* 60 degrees */
#define NO_Y 0x7FFF
/* Wait after a retune before reading RSSI: the RSSI register lags the new
* frequency, and 1 ms left each reading on the points already passed, which
* shifted every signal several points up the sweep (e.g. a station 5 points
* below the centre showed at the centre). The lag only matters next to a
* signal, so a quiet point is read after SETTLE_FAST_MS; the full SETTLE_MS
* is waited after a point above SIGNAL_DB (falling edge) and on a point
* that already rises above it (rising edge). */
#define SETTLE_MS 3u
#define SETTLE_FAST_MS 1u
/* Level above the floor that counts as a signal for the settle choice. The
* display's NOISE_GATE is too low for this: noise alone crosses it on many
* points and each crossing cost two full waits. */
#define SIGNAL_DB 10u
/* Listening to the strongest peak: audio path settle (as FoxHunt), RSSI check
* period (also a landscape line, as a sweep would be) and how many quiet
* checks in a row close it (about a second). */
#define AUDIO_SETTLE_MS 60u
#define LISTEN_TICK_MS 100u
#define LISTEN_HANG 8u
/* Centring on the station before listening: settle per measurement (well
* past the RSSI lag) and the finest offset of the search (2.5 kHz). */
#define REFINE_SETTLE_MS 10u
#define REFINE_MIN 250u
/* The chip's squelch, set up by the loader with the VFO's own thresholds, is
* what opens the audio in VFO mode: listening waits up to SQL_WAIT x 10 ms
* for it after tuning, and closes when it is lost. */
#define SQL_WAIT 15u
#define REG_FREQ_LO 0x38u
#define REG_FREQ_HI 0x39u
#define REG_CTRL 0x30u
#define REG_GLITCH 0x63u
#define REG_RSSI 0x67u
#define REG_RX_BW 0x43u
#define REG_STATUS 0x0Cu /* <1>: squelch result, 1 = link */
#define CTRL_AF_DAC (1u << 9) /* REG_30: AF DAC enable */
#define BUF_LEN 12u /* a text, or "1300.00000" */
/* History, one instance on app_main's stack (outside the overlay). Row ACC
* gathers the next line; its floor stays 0 (the flat edge of a slide). */
typedef struct {
uint8_t line[LINES + 1u][BINS]; /* dBm + 160, ring buffer + ACC */
uint8_t floor[LINES + 1u]; /* each line's quiet level */
} state_t;
/* All the state in one struct: Thumb-1 code then reaches every field from a
* single base address (one literal per function, instead of one per global),
* byte fields first so their offsets fit ldrb's 0..31 immediate. */
struct globals {
/* saved settings, in CFG_DEFAULT's order: loaded and saved whole */
uint8_t magic, span, speed;
int8_t yaw;
uint8_t pitch;
uint8_t head, sweeps, prev_key, gate, close_lvl, quiet, peak_bin;
bool running, hold, saver, abort_sweep, farm, listen, listening;
/* the span's SPAN_REC record, read whole: same order and sizes */
uint16_t step, scan_bw;
uint8_t substeps, close_db;
char label[SPAN_LABEL_LEN + 1u];
uint16_t saved_bw; /* the VFO's own RX filter */
/* words: Thumb-1 loads them with an immediate offset, while a signed
halfword needs a register offset or an extra sign extension */
int32_t ys, yc, ps, pc; /* view angles (Q8) for project() */
int32_t side[4]; /* frame: left x, y, right x, y */
const app_api_t *api;
state_t *st;
uint32_t centre, first, last_ok; /* requested centre, window start, region top */
uint32_t peak_f; /* strongest point of the sweep (at peak_bin), 0 = none */
};
static struct globals g;
#define A (g.api)
#define S (g.st)
_Static_assert(offsetof(struct globals, pitch) + 1u - offsetof(struct globals, magic)
== CFG_DEFAULT_LEN, "saved settings do not match CFG_DEFAULT");
_Static_assert(offsetof(struct globals, label) + SPAN_LABEL_LEN + 1u
- offsetof(struct globals, step) == SPAN_REC_SIZE,
"span fields do not match SPAN_REC");
_Static_assert(offsetof(struct globals, listening) < 32u, "byte fields out of ldrb range");
_Static_assert(TEXT_MAX <= BUF_LEN, "texts do not fit the text buffer");
/* GCC may lower aggregate clears to memset even for this freestanding blob. */
void *memset(void *dst, int value, size_t size)
{
uint8_t *p = dst;
while (size--)
*p++ = (uint8_t)value;
return dst;
}
static uint8_t slen(const char *s)
{
uint8_t n = 0;
while (s[n])
n++;
return n;
}
/* Frequency in 10 Hz units as MHz with 5 decimals. Signed arithmetic on
* purpose: every division in the app then uses __aeabi_idiv, and the
* unsigned one (~280 B of libgcc) stays out of the 4 KiB overlay. */
static char *put_freq(char *o, int32_t v)
{
char t[10];
uint8_t n = 0;
do { t[n++] = (char)('0' + v % 10); v /= 10; } while (v || n < 6u);
while (n) {
*o++ = t[--n];
if (n == 5u)
*o++ = '.';
}
return o;
}
/* ---- radio --------------------------------------------------------------- */
/* Same retune as the spectrum's SetF(), minus the filter path: the sweep
* stays in the VFO's band, whose path the loader already selected. */
static void tune(uint32_t f)
{
A->bk_write(REG_FREQ_LO, (uint16_t)f);
A->bk_write(REG_FREQ_HI, (uint16_t)(f >> 16));
const uint16_t ctrl = A->bk_read(REG_CTRL);
A->bk_write(REG_CTRL, 0);
A->bk_write(REG_CTRL, ctrl);
}
/* dBm + 160 after `ms` of settling (as the spectrum's GetRssi()). */
static uint8_t measure(uint8_t ms)
{
A->delay_ms(ms);
for (uint8_t guard = 50u; guard && (A->bk_read(REG_GLITCH) & 0xFFu) >= 200u; guard--)
;
A->bk_read(REG_RSSI); /* first read may still move */
const int16_t dbm = A->rssi_dbm();
return (uint8_t)(dbm < -160 ? 0 : dbm > 95 ? 255 : dbm + 160);
}
/* Place the 64-point window on `centre`, kept inside the centre's region
* (same RF path, valid RX range); `centre` then follows the window. */
static void place_window(void)
{
uint32_t edge[2u * REGION_COUNT]; /* inclusive (lo, hi) pairs */
A->asset_read(REGION, edge, sizeof(edge));
uint32_t lo = 0, hi = 0; /* in no region: all points flat */
for (uint8_t r = 0; r < 2u * REGION_COUNT; r += 2u)
if (g.centre >= edge[r] && g.centre <= edge[r + 1u]) {
lo = edge[r];
hi = edge[r + 1u];
}
/* A region is far wider than any span, so clamping both ends suffices;
* with no region, hi - width wraps high and leaves the window alone. */
const uint32_t width = (uint32_t)(BINS - 1u) * g.step;
g.first = g.centre - (uint32_t)(BINS / 2u) * g.step;
if (g.first < lo || g.first > g.centre) /* below the region (or wrapped) */
g.first = lo;
if (g.first > hi - width)
g.first = hi - width;
g.last_ok = hi;
g.centre = g.first + (uint32_t)(BINS / 2u) * g.step;
}
static void restart(void)
{
A->asset_read(SPAN_REC + g.span * SPAN_REC_SIZE, &g.step, SPAN_REC_SIZE);
A->bk_write(REG_RX_BW, g.scan_bw); /* filter matched to the spacing */
place_window();
memset(S, 0, sizeof(*S));
g.head = 0;
g.sweeps = 0;
g.abort_sweep = true;
}
/* Move the centre by one point: the history slides by one bin, and the bin
* entering at the edge starts flat (at the line's floor). */
static void move_centre(bool up)
{
const uint32_t old_first = g.first;
g.centre = up ? g.centre + g.step : g.centre - g.step;
place_window(); /* a region edge may refuse the move */
if (g.first == old_first)
return;
const int8_t d = g.first > old_first ? 1 : -1; /* 1: content moves to bin 0 */
for (uint8_t r = 0; r <= LINES; r++) { /* the lines and ACC */
uint8_t *p = S->line[r] + (d > 0 ? 0u : BINS - 1u);
for (uint8_t n = BINS - 1u; n; n--, p += d)
*p = p[d];
*p = S->floor[r];
}
g.abort_sweep = true; /* the sweep in progress used the old grid */
}
static void poll_keys(void);
/* Every 1 << speed sweeps (or listening checks), ACC becomes the newest line
* of the landscape and restarts at `fill`: 0 for the peak hold of a sweep, the
* floor while listening, when only the station's own point is measured. */
static void commit(uint8_t fill)
{
if (++g.sweeps < (1u << g.speed)) /* 1, 2 or 4 per line */
return;
g.sweeps = 0;
uint8_t *acc = S->line[ACC], *line = S->line[g.head];
/* Floor = mean of the points at or below the mean: a strong station
raises the plain mean, not this quiet level. */
uint16_t sum = 0, low = 0;
uint8_t n = 0;
for (uint8_t b = 0; b < BINS; b++)
sum += line[b] = acc[b];
const uint8_t mean = (uint8_t)(sum / BINS);
for (uint8_t b = 0; b < BINS; b++)
if (line[b] <= mean) {
low += line[b];
n++;
}
/* n >= 1: the lowest point is at or below the mean. Signed casts:
operands GCC knows to be non-negative may be divided with
__aeabi_uidiv, which is not linked (see put_freq). */
S->floor[g.head] = (uint8_t)((int16_t)low / (int8_t)n);
memset(acc, fill, BINS);
g.head = (uint8_t)(g.head + 1u < LINES ? g.head + 1u : 0u); /* now the oldest */
}
/* One 64-point sweep, peak-held into ACC, then committed. */
static void sweep(void)
{
uint8_t *acc = S->line[ACC];
g.abort_sweep = false;
/* signal level from the newest line's floor; before the first line the
* floor is 0 and every point gets the full wait */
g.gate = (uint8_t)(S->floor[g.head ? g.head - 1u : LINES - 1u] + SIGNAL_DB);
uint8_t prev = 255u; /* the jump back to the start lags too */
uint8_t peak_v = g.gate; /* only a signal can be listened to */
g.peak_f = 0;
/* substeps is 1 or 2: the spacing is a shift, not a division */
const uint32_t sub = (uint32_t)(g.step >> (g.substeps - 1u));
for (uint8_t b = 0; b < BINS; b++) {
if (!(b & 15u)) {
poll_keys();
if (g.abort_sweep || g.hold || !g.running) {
g.peak_f = 0; /* an unfinished sweep is not listened to */
return;
}
}
/* past the region's edge nothing is measured: the point stays flat */
uint32_t f = g.first + (uint32_t)b * g.step;
for (uint8_t k = 0; k < g.substeps && f <= g.last_ok; k++, f += sub) {
tune(f);
uint8_t m;
if (prev > g.gate) /* leaving a signal: full wait */
m = measure(SETTLE_MS);
else if ((m = measure(SETTLE_FAST_MS)) > g.gate)
m = measure(SETTLE_MS - SETTLE_FAST_MS); /* rising: finish here */
prev = m;
if (m > peak_v) {
peak_v = m;
g.peak_f = f;
g.peak_bin = b;
}
if (m > acc[b])
acc[b] = m;
}
}
commit(0);
}
/* ---- drawing ------------------------------------------------------------- */
/* Clear column x from row y down to the bottom: the hidden-line removal. */
static void clear_below(uint8_t x, int32_t y)
{
if (y < 0)
y = 0;
for (; y < (int32_t)FB_H; y = (y | 7) + 1)
A->fb[y >> 3][x] &= (uint8_t)((1u << (y & 7)) - 1u);
}
static void plot(uint8_t x, int32_t y)
{
if ((uint32_t)y < FB_H)
A->fb[y >> 3][x] |= (uint8_t)(1u << (y & 7));
}
/* 3x5 capsule on fb line 0. The frame starts at x - 2, so x >= 2 (x = 1
* wrapped to fb[0][255], i.e. a stray mark at fb[1][127]); the landscape
* under it is cleared first so the XOR inversion stays clean. */
static void capsule(const char *s, uint8_t x)
{
const uint8_t end = (uint8_t)(x + slen(s) * 4u);
for (uint8_t i = (uint8_t)(x - 2u); i <= end; i++)
A->fb[0][i] = 0;
A->print_inverse(s, x, 0, false, true, end);
}
/* Screen point p = {x, y} of the grid point (x, z) raised by h quarter units. */
static void project(int32_t x, int32_t z, int32_t h, int32_t *p)
{
const int32_t xr = (x * g.yc - z * g.ys) >> 8;
const int32_t zr = (x * g.ys + z * g.yc) >> 8;
const int32_t den = CAM_D + ((zr * g.pc) >> 8);
p[0] = 64 + xr * FOCAL_X / den;
p[1] = CENTRE_Y - (zr * g.ps * 4 + h * g.pc) * FOCAL_Y / (den << 10);
}
/* Solid or dotted line (every other step) between the points a and b, stepped
* along its longer axis with the division already linked. */
static void line(const int32_t *a, const int32_t *b, bool dotted)
{
const int32_t dx = b[0] - a[0], dy = b[1] - a[1];
int32_t n = dx < 0 ? -dx : dx;
const int32_t m = dy < 0 ? -dy : dy;
if (m > n)
n = m;
if (!n)
n = 1;
for (int32_t i = 0; i <= n; i += 1 + dotted) {
const int32_t x = a[0] + dx * i / n;
if ((uint32_t)x < W)
plot((uint8_t)x, a[1] + dy * i / n);
}
}
/* The frame's sides from the last depth down to z; the new corners are kept
* for the next stretch. Painter's order: each stretch goes before the line in
* front of it, which then hides what lies behind. */
static void frame_sides(int32_t z)
{
for (uint8_t s = 0; s < 4u; s += 2u) {
int32_t p[2];
project(s ? BOARD_X : -BOARD_X, z, 0, p);
line(g.side + s, p, false);
g.side[s] = p[0];
g.side[s + 1u] = p[1];
}
}
static void draw(void)
{
char buf[BUF_LEN];
/* status line: title (or HOLD), the F-armed and listen icons, the battery */
A->status_clear();
A->asset_read(g.hold ? T_HOLD : T_TITLE, buf, TEXT_MAX);
A->print_inverse(buf, 2, 0, true, true, (uint8_t)(2u + slen(buf) * 4u));
if (g.farm)
A->asset_read(BMP_F, A->status_line + 70, BMP_F_LEN);
if (g.listen)
A->asset_read(BMP_SPEAKER, A->status_line + 55, BMP_SPEAKER_LEN);
A->draw_battery();
A->display_clear();
int16_t sinq[SINQ_LEN / 2u]; /* sin() Q8, -45..135 deg by 5 deg */
A->asset_read(SINQ, sinq, sizeof(sinq));
g.ys = sinq[SINQ_ZERO + g.yaw];
g.yc = sinq[SINQ_ZERO + 18u - g.yaw]; /* cos = sin(90 - angle) */
g.ps = sinq[SINQ_ZERO + g.pitch];
g.pc = sinq[SINQ_ZERO + 18u - g.pitch];
/* Far to near: the back corners and edge, a stretch of the sides before
* each line, then the nearest stretch and the front edge. */
project(-BOARD_X, BOARD_Z, 0, g.side);
project(BOARD_X, BOARD_Z, 0, g.side + 2);
line(g.side, g.side + 2, true); /* back: dotted */
for (uint8_t i = 0; i < LINES; i++) { /* oldest (far) to newest (near) */
uint8_t row = (uint8_t)(g.head + i);
if (row >= LINES)
row -= LINES;
const int32_t z = ((int32_t)(LINES - 1u) - 2 * i) * 4;
frame_sides(z);
int32_t pt[BINS][2];
bool lit[BINS]; /* point above the noise gate */
for (uint8_t b = 0; b < BINS; b++) {
const int32_t d = (int32_t)S->line[row][b] - S->floor[row] - NOISE_GATE;
lit[b] = d > 0;
project(2 * b - 63, z, d > 0 ? HEIGHT_Q * d / (d + COMPRESS) : 0, pt[b]);
}
/* The line's top edge in every screen column it crosses, kept as
* 2y + 1 on the noise and 2y on a signal: the minimum is the topmost
* point, and its low bit tells dotted (noise) from solid (signal). */
int16_t top[W];
for (uint8_t x = 0; x < W; x++)
top[x] = NO_Y;
for (uint8_t b = 0; b + 1u < BINS; b++) {
const int32_t noise = !(lit[b] || lit[b + 1u]);
const int32_t *p0 = pt[b], *p1 = pt[b + 1u];
if (p1[0] < p0[0]) {
const int32_t *t = p0;
p0 = p1;
p1 = t;
}
for (int32_t x = p0[0] < 0 ? 0 : p0[0]; x <= p1[0] && x < (int32_t)W; x++) {
const int32_t y = p1[0] == p0[0] ? p0[1]
: p0[1] + (p1[1] - p0[1]) * (x - p0[0]) / (p1[0] - p0[0]);
const int32_t key = 2 * y + noise;
if (key < top[x])
top[x] = (int16_t)key;
}
}
/* hide what lies below, then draw the edge and close steep slopes:
* solid on signals, every other column on the noise floor */
int32_t prev = NO_Y;
for (uint8_t x = 0; x < W; x++) {
const int32_t key = top[x];
if (key == NO_Y) {
prev = NO_Y;
continue;
}
const int32_t y = key >> 1;
clear_below(x, y + 1);
if (!(key & 1) || !(x & 1u)) {
plot(x, y);
if (prev != NO_Y)
for (int32_t yy = (y < prev ? y : prev) + 1; yy < (y < prev ? prev : y); yy++)
plot(x, yy);
}
prev = y;
}
}
frame_sides(-BOARD_Z);
line(g.side, g.side + 2, false); /* front */
/* 3x5 capsules over the landscape: the frequency (centre, or the station
* listened to) left, the span right */
*put_freq(buf, (int32_t)(g.listening ? g.peak_f : g.centre)) = '\0';
capsule(buf, 2);
capsule(g.label, (uint8_t)(W - 2u - SPAN_LABEL_LEN * 4u));
A->blit_status();
A->blit_full();
}
/* ---- input --------------------------------------------------------------- */
static void key_press(uint8_t key)
{
int8_t dir = A->nav_dir(key); /* UP/DOWN: span */
const bool shifted = g.farm;
g.farm = key == APP_KEY_F && !shifted; /* F arms the next key */
if (key == APP_KEY_3)
dir = shifted ? -1 : 1; /* 3 wider, F+3 narrower */
if (key == APP_KEY_1) {
move_centre(!shifted);
} else if (key == APP_KEY_EXIT) {
g.running = false;
} else if (key == APP_KEY_4 && g.yaw > -YAW_MAX) {
g.yaw--;
} else if (key == APP_KEY_6 && g.yaw < YAW_MAX) {
g.yaw++;
} else if (key == APP_KEY_2 && g.pitch < PITCH_MAX) {
g.pitch++;
} else if (key == APP_KEY_8 && g.pitch > PITCH_MIN) {
g.pitch--;
} else if (key == APP_KEY_5) {
g.yaw = 0;
g.pitch = PITCH_DEF;
} else if (key == APP_KEY_0) {
g.listen = !g.listen;
} else if (key == APP_KEY_MENU) {
g.hold = !g.hold;
} else if (key == APP_KEY_STAR) {
g.speed = (uint8_t)(g.speed + 1u < SPEED_COUNT ? g.speed + 1u : 0u);
} else if (dir && (uint8_t)(g.span + dir) < SPAN_COUNT) {
g.span = (uint8_t)(g.span + dir);
restart(); /* keeps the centre */
}
}
static void poll_keys(void)
{
uint8_t key = A->get_key();
g.saver = key == APP_KEY_SAVER;
if (key == APP_KEY_WAKE || g.saver)
key = APP_KEY_INVALID;
if (key != g.prev_key && key != APP_KEY_INVALID)
key_press(key);
g.prev_key = key;
}
/* ---- listening ----------------------------------------------------------- */
/* Centre on the station: the sweep's peak can sit a point or two late (the
* RSSI lags the retune, and a weak station only crosses the fast-read gate
* after its own point) or between two points (coarse spans, 12.5 kHz
* rasters). Hill-climb around it through the listening filter with a full
* settle, halving the offset from two points down to REFINE_MIN. */
static void refine(void)
{
uint32_t f = g.peak_f;
tune(f);
uint8_t best = measure(REFINE_SETTLE_MS);
for (uint32_t d = 2u * g.step; d >= REFINE_MIN; d >>= 1) {
const uint32_t c = f;
for (int8_t s = -1; s <= 1; s += 2) {
const uint32_t t = s < 0 ? c - d : c + d;
tune(t);
const uint8_t m = measure(REFINE_SETTLE_MS);
if (m > best) {
best = m;
f = t;
}
}
}
g.peak_f = f; /* shown in the capsule too */
}
static bool squelch_open(void)
{
return (A->bk_read(REG_STATUS) & 2u) != 0;
}
/* Centre on the sweep's strongest point with the VFO's own RX filter and, if
* the chip's squelch opens there, play it as the resident spectrum does (AF
* DAC, AF path, FM demodulation); the main loop then checks it until it goes
* quiet or a key stops it. Listening and sweeping share the one receiver, so
* meanwhile the landscape goes on with that point alone. */
static void start_listen(void)
{
A->bk_write(REG_RX_BW, g.saved_bw);
refine();
tune(g.peak_f);
for (uint8_t t = SQL_WAIT; !squelch_open(); t--) {
if (!t) { /* no station there: back to the sweep */
A->bk_write(REG_RX_BW, g.scan_bw);
return;
}
A->delay_ms(10);
}
/* listening lines start flat at the floor: only the station is measured */
memset(S->line[ACC], g.gate - SIGNAL_DB, BINS);
g.close_lvl = (uint8_t)(g.close_db + g.gate - SIGNAL_DB);
g.quiet = 0;
g.listening = true;
A->bk_write(REG_CTRL, A->bk_read(REG_CTRL) | CTRL_AF_DAC);
A->audio_path(true);
A->delay_ms(AUDIO_SETTLE_MS);
A->set_af(APP_AF_FM);
}
static void stop_listen(void)
{
A->set_af(APP_AF_MUTE);
A->audio_path(false);
g.listening = false;
A->bk_write(REG_RX_BW, g.scan_bw);
}
__attribute__((section(".text.entry"), used))
void app_main(const app_api_t *api)
{
state_t state; /* ~0.8 KiB of history, on the stack */
A = api;
S = &state;
A->cfg_load(&g.magic, CFG_DEFAULT_LEN);
if (g.magic != CFG_MAGIC || g.span >= SPAN_COUNT || g.speed >= SPEED_COUNT ||
(uint8_t)(g.yaw + YAW_MAX) > 2u * YAW_MAX ||
(uint8_t)(g.pitch - PITCH_MIN) > PITCH_MAX - PITCH_MIN)
A->asset_read(CFG_DEFAULT, &g.magic, CFG_DEFAULT_LEN);
g.running = true; /* the rest of g starts zeroed (.bss) */
g.prev_key = A->get_key();
A->backlight_on();
A->audio_path(false);
A->set_af(APP_AF_MUTE);
A->set_agc(false); /* fixed gain: no pumping by a strong signal */
g.saved_bw = A->bk_read(REG_RX_BW);
g.centre = A->rx_freq();
restart();
while (g.running) {
if (g.listening) {
A->delay_ms(LISTEN_TICK_MS);
poll_keys();
/* quiet when the squelch is lost (as in VFO mode) or the level
falls to the close level (squelch 0, always open) */
const uint8_t m = measure(0);
g.quiet = squelch_open() && m > g.close_lvl ? 0u : (uint8_t)(g.quiet + 1u);
if (g.quiet >= LISTEN_HANG || !g.listen || g.hold || g.abort_sweep) {
stop_listen();
} else {
/* the landscape goes on with the one point still measured,
the station's own ridge; the rest stays flat at the floor */
uint8_t *peak = &S->line[ACC][g.peak_bin];
if (m > *peak)
*peak = m;
commit((uint8_t)(g.gate - SIGNAL_DB));
}
} else if (g.hold) {
poll_keys();
A->delay_ms(20);
} else {
sweep(); /* polls the keys as it goes */
/* a finished sweep's peak; not before the first line (floor 0) */
if (g.listen && g.peak_f && g.gate > SIGNAL_DB)
start_listen();
}
A->battery_sample(); /* keeps the status-bar level live */
if (!g.saver)
draw();
A->backlight_update();
}
stop_listen(); /* EXIT while listening: mute */
/* The loader's retune keeps its cached AGC state and would leave the fixed
gain in place: restore automatic gain explicitly. */
A->set_agc(true);
A->bk_write(REG_RX_BW, g.saved_bw); /* the VFO's own RX filter */
A->cfg_save(&g.magic, CFG_DEFAULT_LEN); /* magic set: loaded or default */
}