feat(radio): improve built in mic recording clarity and UI (#7704)

This commit is contained in:
3djc
2026-08-25 08:20:43 +10:00
committed by GitHub
parent a5ac9bf06b
commit da6f73abcf
4 changed files with 493 additions and 39 deletions
@@ -25,6 +25,7 @@
#include <stdio.h>
#include "audio.h"
#include "button.h"
#include "dialog.h"
#include "edgetx.h"
@@ -33,9 +34,164 @@
#include "static.h"
#include "timers_driver.h"
static constexpr coord_t MIC_BTN_W = 260;
static constexpr coord_t MIC_BTN_H = EdgeTxStyles::UI_ELEMENT_HEIGHT * 2 + PAD_LARGE;
// Mirrored peak envelope, drawn as two polylines around a centre line. Fed one
// column at a time while recording, then re-filled from the finished file.
class WaveformView : public Window
{
public:
static constexpr uint16_t MAX_COLS = 400;
WaveformView(Window* parent, const rect_t& rect) : Window(parent, rect)
{
padAll(PAD_ZERO);
// Widen the columns rather than leaving the trace short of the edge:
// 800 px screens need more than MAX_COLS columns at 2 px.
colStep = (coord_t)((width() + MAX_COLS - 1) / MAX_COLS);
if (colStep < 2) colStep = 2;
maxCols = (uint16_t)((width() - 1) / colStep + 1);
if (maxCols > MAX_COLS) maxCols = MAX_COLS;
traceW = (coord_t)((maxCols - 1) * colStep);
const lv_coord_t mid = height() / 2;
basePts[0] = {0, mid};
basePts[1] = {traceW, mid};
baseLine = lv_line_create(lvobj);
etx_obj_add_style(baseLine, styles->graph_dashed, LV_PART_MAIN);
lv_line_set_points(baseLine, basePts, 2);
topLine = lv_line_create(lvobj);
etx_obj_add_style(topLine, styles->graph_line, LV_PART_MAIN);
etx_obj_add_style(topLine, styles->line_color[COLOR_THEME_SECONDARY1_INDEX],
LV_PART_MAIN);
botLine = lv_line_create(lvobj);
etx_obj_add_style(botLine, styles->graph_line, LV_PART_MAIN);
etx_obj_add_style(botLine, styles->line_color[COLOR_THEME_SECONDARY1_INDEX],
LV_PART_MAIN);
cursorLine = lv_line_create(lvobj);
etx_obj_add_style(cursorLine, styles->graph_line, LV_PART_MAIN);
etx_obj_add_style(cursorLine, styles->line_color[COLOR_THEME_WARNING_INDEX],
LV_PART_MAIN);
lv_obj_add_flag(cursorLine, LV_OBJ_FLAG_HIDDEN);
clear();
}
uint16_t columns() const { return maxCols; }
void clear()
{
count = 0;
merge = 1;
acc = 0;
accCount = 0;
setCursor(-1);
redraw();
}
// Playback position, 0..1000 across the view. Negative hides the cursor.
void setCursor(int32_t permille)
{
if (permille < 0) {
if (cursorAt >= 0) lv_obj_add_flag(cursorLine, LV_OBJ_FLAG_HIDDEN);
cursorAt = -1;
return;
}
if (permille > 1000) permille = 1000;
const lv_coord_t x = (lv_coord_t)((permille * traceW) / 1000);
if (cursorAt >= 0 && x == cursorPts[0].x) return;
cursorPts[0] = {x, 0};
cursorPts[1] = {x, (lv_coord_t)(height() - 1)};
lv_line_set_points(cursorLine, cursorPts, 2);
lv_obj_clear_flag(cursorLine, LV_OBJ_FLAG_HIDDEN);
cursorAt = permille;
}
// Live feed. Once the view is full, columns are merged pairwise so the whole
// take stays visible at half the time resolution.
void push(uint8_t level)
{
if (level > acc) acc = level;
if (++accCount < merge) return;
if (count >= maxCols) compact();
cols[count++] = acc;
acc = 0;
accCount = 0;
redraw();
}
void setEnvelope(const uint8_t* levels, uint16_t n)
{
if (n > maxCols) n = maxCols;
memcpy(cols, levels, n);
count = n;
redraw();
}
protected:
coord_t colStep = 2;
coord_t traceW = 0;
uint16_t maxCols = 0;
uint16_t count = 0;
uint16_t merge = 1;
uint16_t accCount = 0;
uint8_t acc = 0;
uint8_t cols[MAX_COLS] = {0};
lv_point_t topPts[MAX_COLS];
lv_point_t botPts[MAX_COLS];
lv_point_t basePts[2];
lv_point_t cursorPts[2];
int32_t cursorAt = -1;
lv_obj_t* cursorLine = nullptr;
lv_obj_t* topLine = nullptr;
lv_obj_t* botLine = nullptr;
lv_obj_t* baseLine = nullptr;
void compact()
{
const uint16_t n = count / 2;
for (uint16_t i = 0; i < n; i++) {
const uint8_t a = cols[2 * i], b = cols[2 * i + 1];
cols[i] = a > b ? a : b;
}
count = n;
merge *= 2;
}
void redraw()
{
if (count < 2) {
lv_obj_add_flag(topLine, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(botLine, LV_OBJ_FLAG_HIDDEN);
return;
}
const lv_coord_t mid = height() / 2;
const int32_t half = height() / 2 - 1;
for (uint16_t i = 0; i < count; i++) {
const lv_coord_t x = (lv_coord_t)(i * colStep);
const lv_coord_t a = (lv_coord_t)((cols[i] * half) / 255);
topPts[i] = {x, (lv_coord_t)(mid - a)};
botPts[i] = {x, (lv_coord_t)(mid + a)};
}
lv_line_set_points(topLine, topPts, count);
lv_line_set_points(botLine, botPts, count);
lv_obj_clear_flag(topLine, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(botLine, LV_OBJ_FLAG_HIDDEN);
}
};
RadioMicRecorder::RadioMicRecorder() :
Page(ICON_RADIO_TOOLS)
{
@@ -55,6 +211,7 @@ RadioMicRecorder::~RadioMicRecorder()
// on a destroyed instance.
lv_async_call_cancel(&RadioMicRecorder::asyncProcessPendingRename, this);
if (recorder.isRecording()) recorder.stop();
if (isPlayingTake()) audioQueue.stopAll();
pdmStop();
}
@@ -76,28 +233,102 @@ void RadioMicRecorder::buildBody(Window* window)
const coord_t w = window->width();
const coord_t h = window->height();
const coord_t btnY = h - MIC_BTN_H - PAD_LARGE * 2;
const coord_t bigH = EdgeTxStyles::UI_ELEMENT_HEIGHT + PAD_MEDIUM;
const coord_t infoH = EdgeTxStyles::STD_FONT_HEIGHT;
const coord_t infoY = btnY - infoH - PAD_SMALL;
const coord_t waveY = PAD_SMALL + bigH + PAD_SMALL;
bigLabel = new StaticText(
window,
{0, h / 3 - 20, w, 40},
{0, PAD_SMALL, w, bigH},
"", COLOR_THEME_PRIMARY1_INDEX, FONT(XL));
lv_obj_set_style_text_align(bigLabel->getLvObj(), LV_TEXT_ALIGN_CENTER, 0);
waveform = new WaveformView(
window, {PAD_LARGE, waveY, w - PAD_LARGE * 2, infoY - waveY - PAD_SMALL});
waveform->hide();
infoLabel = new StaticText(
window,
{0, h / 3 + 30, w, 24},
{0, infoY, w, infoH},
"", COLOR_THEME_SECONDARY1_INDEX, FONT(STD));
lv_obj_set_style_text_align(infoLabel->getLvObj(), LV_TEXT_ALIGN_CENTER, 0);
actionButton = new TextButton(
window,
{(w - MIC_BTN_W) / 2,
h - MIC_BTN_H - PAD_LARGE * 2,
MIC_BTN_W, MIC_BTN_H},
{(w - MIC_BTN_W) / 2, btnY, MIC_BTN_W, MIC_BTN_H},
"", [this]() {
onActionPressed();
return 0;
});
actionButton->setFont(FONT_XL_INDEX);
const coord_t revW = (w - PAD_LARGE * 4) / 3;
playButton = new TextButton(
window, {PAD_LARGE, btnY, revW, MIC_BTN_H}, STR_PLAY_FILE, [this]() {
onPlayPressed();
return 0;
});
playButton->setWrap();
saveButton = new TextButton(
window, {PAD_LARGE * 2 + revW, btnY, revW, MIC_BTN_H}, STR_SAVE_AS,
[this]() {
askSaveAs();
return 0;
});
saveButton->setWrap();
redoButton = new TextButton(
window, {PAD_LARGE * 3 + revW * 2, btnY, revW, MIC_BTN_H}, STR_RECORD,
[this]() {
discardTake();
enterCountdown();
return 0;
});
redoButton->setWrap();
playButton->hide();
saveButton->hide();
redoButton->hide();
}
void RadioMicRecorder::showReviewButtons(bool reviewing)
{
actionButton->show(!reviewing);
playButton->show(reviewing);
saveButton->show(reviewing);
redoButton->show(reviewing);
lv_group_focus_obj(reviewing ? playButton->getLvObj()
: actionButton->getLvObj());
}
bool RadioMicRecorder::isPlayingTake() const
{
return audioQueue.isPlaying(ID_PLAY_FROM_SD_MANAGER);
}
void RadioMicRecorder::onPlayPressed()
{
if (isPlayingTake()) {
audioQueue.stopAll();
} else {
audioQueue.stopAll();
audioQueue.playFile(filename, 0, ID_PLAY_FROM_SD_MANAGER);
playStart = get_tmr10ms();
}
playingShown = isPlayingTake();
if (!playingShown) waveform->setCursor(-1);
refreshUI();
}
// Drops the take the user just rejected, unless it was already saved under a
// name of its own.
void RadioMicRecorder::discardTake()
{
if (!takeSaved && filename[0]) f_unlink(filename);
}
void RadioMicRecorder::onEvent(event_t event)
@@ -105,6 +336,7 @@ void RadioMicRecorder::onEvent(event_t event)
if (event == EVT_KEY_LONG(KEY_EXIT)) {
killEvents(event);
if (recorder.isRecording()) recorder.stop();
if (isPlayingTake()) audioQueue.stopAll();
onCancel();
}
}
@@ -115,12 +347,15 @@ void RadioMicRecorder::onActionPressed()
case State::IDLE: enterCountdown(); break;
case State::COUNTDOWN: enterIdle(); break; // cancel
case State::RECORDING: stopRecording(); break;
case State::REVIEW: break; // handled by the review buttons
}
}
void RadioMicRecorder::enterIdle()
{
if (recorder.isRecording()) recorder.stop();
showReviewButtons(false);
waveform->hide();
state = State::IDLE;
stateStart = get_tmr10ms();
refreshUI();
@@ -128,6 +363,9 @@ void RadioMicRecorder::enterIdle()
void RadioMicRecorder::enterCountdown()
{
if (isPlayingTake()) audioQueue.stopAll();
showReviewButtons(false);
waveform->hide();
state = State::COUNTDOWN;
stateStart = get_tmr10ms();
refreshUI();
@@ -135,6 +373,7 @@ void RadioMicRecorder::enterCountdown()
void RadioMicRecorder::enterRecording()
{
takeSaved = false;
pickNextFilename();
FRESULT res = recorder.start(filename, 0);
if (res != FR_OK) {
@@ -146,6 +385,11 @@ void RadioMicRecorder::enterRecording()
actionButton->setText(STR_RECORD);
return;
}
waveform->clear();
waveform->show();
lastWaveSamples = 0;
takeSamples = 0;
(void)recorder.takePeakLevel(); // drop the level left over from the last take
state = State::RECORDING;
stateStart = get_tmr10ms();
refreshUI();
@@ -154,10 +398,30 @@ void RadioMicRecorder::enterRecording()
void RadioMicRecorder::stopRecording()
{
recorder.stop();
PdmWavRecorder::trimSilence(filename);
state = State::IDLE;
uint32_t clipped = 0;
const uint16_t cols = waveform->columns();
uint8_t env[WaveformView::MAX_COLS];
if (PdmWavRecorder::finalise(filename, env, cols, &takeSamples, &clipped) == FR_OK)
waveform->setEnvelope(env, cols);
TRACE("mic: %u/1000 samples clipped at capture", (unsigned)clipped);
enterReview();
}
void RadioMicRecorder::enterReview()
{
playingShown = false;
showReviewButtons(true);
waveform->show();
state = State::REVIEW;
stateStart = get_tmr10ms();
refreshUI();
}
void RadioMicRecorder::askSaveAs()
{
if (isPlayingTake()) audioQueue.stopAll();
const char* base = strrchr(filename, '/');
base = base ? base + 1 : filename;
@@ -173,7 +437,7 @@ void RadioMicRecorder::stopRecording()
strcpy(dir, SOUNDS_PATH "/");
strncpy(dir + SOUNDS_PATH_LNG_OFS, currentLanguagePack->id, 2);
snprintf(pendingRename, sizeof(pendingRename), "%s%s.wav", dir, newName.c_str());
if (strcmp(pendingRename, filename) == 0) { refreshUI(); return; }
if (strcmp(pendingRename, filename) == 0) { takeSaved = true; refreshUI(); return; }
// Defer the overwrite check until LabelDialog has finished closing —
// creating a modal now would leave us stacked on top of LabelDialog,
@@ -201,6 +465,7 @@ void RadioMicRecorder::applyRename()
f_rename(filename, pendingRename);
strncpy(filename, pendingRename, sizeof(filename) - 1);
filename[sizeof(filename) - 1] = '\0';
takeSaved = true;
refreshUI();
}
@@ -242,6 +507,21 @@ void RadioMicRecorder::refreshUI()
actionButton->setText(STR_STOP);
break;
}
case State::REVIEW: {
const uint32_t s = takeSamples / PdmWavRecorder::DST_RATE;
snprintf(buf, sizeof(buf), "%02u:%02u", (unsigned)(s / 60U),
(unsigned)(s % 60U));
bigLabel->setText(buf);
if (takeSaved) {
snprintf(buf, sizeof(buf), "%s %s", STR_SAVED, filename);
infoLabel->setText(buf);
} else {
infoLabel->setText(filename);
}
playButton->setText(isPlayingTake() ? STR_STOP : STR_PLAY_FILE);
break;
}
}
}
@@ -258,10 +538,33 @@ void RadioMicRecorder::checkEvents()
}
} else if (state == State::RECORDING) {
if (!recorder.isRecording()) {
state = State::IDLE;
stateStart = get_tmr10ms();
stopRecording(); // recorder ended on its own (write error / limit)
} else {
const uint32_t written = recorder.getSamplesWritten();
if (written != lastWaveSamples) {
lastWaveSamples = written;
waveform->push(recorder.takePeakLevel());
}
refreshUI();
}
} else if (state == State::REVIEW) {
const bool playing = isPlayingTake();
// The audio queue does not expose a play position, so the cursor runs off
// the wall clock: playback is sample-accurate once started, so the only
// error is the queue's own start latency.
if (playing && takeSamples) {
const uint32_t elapsed10 = (uint32_t)(get_tmr10ms() - playStart);
const uint32_t total10 = takeSamples / (PdmWavRecorder::DST_RATE / 100U);
waveform->setCursor(total10 ? (int32_t)((elapsed10 * 1000U) / total10)
: 1000);
}
if (playing != playingShown) {
playingShown = playing;
if (!playing) waveform->setCursor(-1);
refreshUI();
}
refreshUI();
}
}
@@ -29,6 +29,7 @@
class TextButton;
class StaticText;
class WaveformView;
class RadioMicRecorder : public Page
{
@@ -37,7 +38,7 @@ class RadioMicRecorder : public Page
~RadioMicRecorder() override;
protected:
enum class State : uint8_t { IDLE, COUNTDOWN, RECORDING };
enum class State : uint8_t { IDLE, COUNTDOWN, RECORDING, REVIEW };
static constexpr uint32_t COUNTDOWN_SECONDS = 5;
static constexpr int PATH_MAX_LEN = sizeof(SOUNDS_PATH) + 14; // "/lang/rec_00.wav\0"
@@ -48,9 +49,19 @@ class RadioMicRecorder : public Page
char pendingRename[PATH_MAX_LEN] = {0};
PdmWavRecorder recorder;
bool takeSaved = false;
bool playingShown = false;
uint32_t lastWaveSamples = 0;
uint32_t takeSamples = 0;
tmr10ms_t playStart = 0;
StaticText* bigLabel = nullptr;
StaticText* infoLabel = nullptr;
TextButton* actionButton = nullptr;
TextButton* playButton = nullptr;
TextButton* saveButton = nullptr;
TextButton* redoButton = nullptr;
WaveformView* waveform = nullptr;
void buildHeader(Window* window);
void buildBody(Window* window);
@@ -58,10 +69,16 @@ class RadioMicRecorder : public Page
void onEvent(event_t event) override;
void onActionPressed();
void onPlayPressed();
void enterIdle();
void enterCountdown();
void enterRecording();
void enterReview();
void stopRecording();
void discardTake();
void askSaveAs();
void showReviewButtons(bool reviewing);
bool isPlayingTake() const;
void processPendingRename();
void applyRename();
void refreshUI();