feat(radio): improve built in mic recording clarity and UI (#7704)
This commit is contained in:
@@ -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();
|
||||
|
||||
+148
-25
@@ -23,6 +23,7 @@
|
||||
|
||||
#if defined(PDM_CLOCK)
|
||||
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "os/task.h"
|
||||
@@ -109,6 +110,13 @@ bool PdmWavRecorder::tickLocked()
|
||||
if (n > remaining) n = remaining;
|
||||
}
|
||||
|
||||
uint16_t peak = peakSinceRead;
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
const int32_t a = pcm[i] < 0 ? -(int32_t)pcm[i] : (int32_t)pcm[i];
|
||||
if (a > (int32_t)peak) peak = (uint16_t)a;
|
||||
}
|
||||
peakSinceRead = peak;
|
||||
|
||||
UINT w = 0;
|
||||
FRESULT res = f_write(&file, pcm, n * sizeof(int16_t), &w);
|
||||
if (res != FR_OK || w != n * sizeof(int16_t)) {
|
||||
@@ -155,8 +163,66 @@ FRESULT PdmWavRecorder::stop()
|
||||
return f_close(&file);
|
||||
}
|
||||
|
||||
FRESULT PdmWavRecorder::trimSilence(const char* path)
|
||||
uint8_t PdmWavRecorder::takePeakLevel()
|
||||
{
|
||||
const uint16_t peak = peakSinceRead;
|
||||
peakSinceRead = 0;
|
||||
return (uint8_t)(peak >> 7);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-recording clean-up.
|
||||
//
|
||||
// The CIC decimator leaves two artefacts worth fixing offline, where cost does
|
||||
// not matter: a residual DC/rumble term (the mic's ones-density is never
|
||||
// exactly 50%), and sinc^3 passband droop -- about -2.7 dB at 4 kHz and
|
||||
// -11.8 dB at 8 kHz for R=100, N=3, which is what makes takes sound muffled.
|
||||
// Level is then set from the measured peak instead of the fixed capture gain.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// One-pole high-pass, -3 dB at ~80 Hz for a 16 kHz rate: exp(-2*pi*80/16000).
|
||||
static constexpr float PP_HPF_A = 0.969f;
|
||||
|
||||
// Symmetric 3-tap droop compensator (-a, 1+2a, -a): +3.5 dB at 4 kHz,
|
||||
// +6 dB at 8 kHz, unity at DC.
|
||||
static constexpr float PP_COMP_A = 0.25f;
|
||||
|
||||
static constexpr float PP_NORM_TARGET = 29500.0f; // ~-0.9 dBFS
|
||||
static constexpr float PP_MAX_GAIN = 8.0f;
|
||||
static constexpr uint32_t PP_FADE_SAMPLES = PdmWavRecorder::DST_RATE / 125; // 8 ms
|
||||
|
||||
namespace {
|
||||
struct PostFilter
|
||||
{
|
||||
float hpX = 0.0f, hpY = 0.0f;
|
||||
float d1 = 0.0f, d2 = 0.0f;
|
||||
bool primed = false;
|
||||
|
||||
float process(int16_t sample)
|
||||
{
|
||||
const float x = (float)sample;
|
||||
if (!primed) { hpX = x; primed = true; } // no step at the first sample
|
||||
const float y = PP_HPF_A * (hpY + x - hpX);
|
||||
hpX = x;
|
||||
hpY = y;
|
||||
const float out = (1.0f + 2.0f * PP_COMP_A) * d1 - PP_COMP_A * (y + d2);
|
||||
d2 = d1;
|
||||
d1 = y;
|
||||
return out;
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
FRESULT PdmWavRecorder::finalise(const char* path, uint8_t* env, uint16_t cols,
|
||||
uint32_t* totalSamples,
|
||||
uint32_t* clippedPermille)
|
||||
{
|
||||
// 4 KB of static buffer rather than a stack one: a pass over the card costs
|
||||
// far more in transfers than in bytes, and everything that needs to see
|
||||
// every sample shares these two passes.
|
||||
static int16_t ioBuf[2048];
|
||||
static constexpr uint32_t CHUNK = sizeof(ioBuf) / sizeof(ioBuf[0]);
|
||||
|
||||
// Threshold tracks the post-CIC gain so trim stays calibrated when
|
||||
// PDM_POST_GAIN_SHIFT is changed. Baseline 4000 was tuned at shift=4.
|
||||
static constexpr int32_t _silenceRaw =
|
||||
@@ -164,7 +230,10 @@ FRESULT PdmWavRecorder::trimSilence(const char* path)
|
||||
static constexpr int16_t SILENCE_THRESHOLD =
|
||||
_silenceRaw > 32767 ? (int16_t)32767 : (int16_t)_silenceRaw;
|
||||
static constexpr uint32_t PAD_SAMPLES = DST_RATE / 100; // 10 ms
|
||||
static constexpr uint32_t CHUNK = 256;
|
||||
|
||||
if (env && cols) memset(env, 0, cols);
|
||||
if (totalSamples) *totalSamples = 0;
|
||||
if (clippedPermille) *clippedPermille = 0;
|
||||
|
||||
FIL f;
|
||||
FRESULT res = f_open(&f, path, FA_READ | FA_WRITE);
|
||||
@@ -181,47 +250,101 @@ FRESULT PdmWavRecorder::trimSilence(const char* path)
|
||||
const uint32_t total = dataBytes / 2;
|
||||
if (total == 0) { f_close(&f); return FR_OK; }
|
||||
|
||||
int16_t buf[CHUNK];
|
||||
// Pass 1 (sequential read): silence bounds, capture clipping, filtered peak.
|
||||
PostFilter flt;
|
||||
uint32_t firstActive = UINT32_MAX;
|
||||
uint32_t lastActive = 0;
|
||||
uint32_t clipped = 0;
|
||||
float peak = 0.0f;
|
||||
|
||||
for (uint32_t base = 0; base < total; base += CHUNK) {
|
||||
const uint32_t n = (base + CHUNK <= total) ? CHUNK : total - base;
|
||||
f_lseek(&f, 44 + base * 2);
|
||||
res = f_read(&f, buf, n * 2, &br);
|
||||
res = f_read(&f, ioBuf, n * 2, &br);
|
||||
if (res != FR_OK) { f_close(&f); return res; }
|
||||
const uint32_t got = br / 2;
|
||||
for (uint32_t i = 0; i < got; i++) {
|
||||
const int16_t abs = buf[i] < 0 ? (int16_t)-buf[i] : buf[i];
|
||||
if (abs > SILENCE_THRESHOLD) {
|
||||
const int16_t s = ioBuf[i];
|
||||
const int32_t a = s < 0 ? -(int32_t)s : (int32_t)s;
|
||||
if (a >= 32767) clipped++;
|
||||
if (a > SILENCE_THRESHOLD) {
|
||||
if (firstActive == UINT32_MAX) firstActive = base + i;
|
||||
lastActive = base + i;
|
||||
}
|
||||
const float v = fabsf(flt.process(s));
|
||||
if (v > peak) peak = v;
|
||||
}
|
||||
if (got < n) break;
|
||||
}
|
||||
|
||||
if (firstActive == UINT32_MAX) { f_close(&f); return FR_OK; } // all silent
|
||||
uint32_t trimStart = 0, trimEnd = total - 1;
|
||||
if (firstActive != UINT32_MAX) {
|
||||
trimStart = firstActive > PAD_SAMPLES ? firstActive - PAD_SAMPLES : 0;
|
||||
trimEnd = (lastActive + PAD_SAMPLES < total) ? lastActive + PAD_SAMPLES
|
||||
: total - 1;
|
||||
}
|
||||
const uint32_t newTotal = trimEnd - trimStart + 1;
|
||||
|
||||
const uint32_t trimStart = firstActive > PAD_SAMPLES ? firstActive - PAD_SAMPLES : 0;
|
||||
const uint32_t trimEnd = (lastActive + PAD_SAMPLES < total) ? lastActive + PAD_SAMPLES : total - 1;
|
||||
const uint32_t newTotal = trimEnd - trimStart + 1;
|
||||
if (clippedPermille) *clippedPermille = (clipped * 1000U) / total;
|
||||
if (totalSamples) *totalSamples = newTotal;
|
||||
|
||||
if (trimStart == 0 && trimEnd == total - 1) { f_close(&f); return FR_OK; }
|
||||
const bool doFilter = newTotal >= 2 * PP_FADE_SAMPLES;
|
||||
|
||||
if (trimStart > 0) {
|
||||
uint32_t src = trimStart, dst = 0;
|
||||
while (src <= trimEnd) {
|
||||
const uint32_t n = (src + CHUNK - 1 <= trimEnd) ? CHUNK : trimEnd - src + 1;
|
||||
f_lseek(&f, 44 + src * 2);
|
||||
res = f_read(&f, buf, n * 2, &br);
|
||||
if (res != FR_OK) { f_close(&f); return res; }
|
||||
UINT bw = 0;
|
||||
f_lseek(&f, 44 + dst * 2);
|
||||
res = f_write(&f, buf, br, &bw);
|
||||
if (res != FR_OK) { f_close(&f); return res; }
|
||||
src += n;
|
||||
dst += n;
|
||||
float gain = 1.0f;
|
||||
if (doFilter && peak > 1.0f) {
|
||||
gain = PP_NORM_TARGET / peak;
|
||||
if (gain > PP_MAX_GAIN) gain = PP_MAX_GAIN;
|
||||
}
|
||||
|
||||
// Pass 2: shift to the front, filter, normalise, fade, fill the envelope.
|
||||
flt = PostFilter();
|
||||
uint32_t src = trimStart, dst = 0;
|
||||
|
||||
while (src <= trimEnd) {
|
||||
const uint32_t n = (src + CHUNK - 1 <= trimEnd) ? CHUNK : trimEnd - src + 1;
|
||||
f_lseek(&f, 44 + src * 2);
|
||||
res = f_read(&f, ioBuf, n * 2, &br);
|
||||
if (res != FR_OK) { f_close(&f); return res; }
|
||||
const uint32_t got = br / 2;
|
||||
|
||||
for (uint32_t i = 0; i < got; i++) {
|
||||
const uint32_t idx = dst + i;
|
||||
int32_t out;
|
||||
|
||||
if (doFilter) {
|
||||
float v = flt.process(ioBuf[i]) * gain;
|
||||
if (idx < PP_FADE_SAMPLES) {
|
||||
v *= (float)idx / (float)PP_FADE_SAMPLES;
|
||||
} else if (idx >= newTotal - PP_FADE_SAMPLES) {
|
||||
v *= (float)(newTotal - idx) / (float)PP_FADE_SAMPLES;
|
||||
}
|
||||
if (v > 32767.0f) v = 32767.0f;
|
||||
if (v < -32768.0f) v = -32768.0f;
|
||||
out = (int32_t)lrintf(v);
|
||||
} else {
|
||||
out = ioBuf[i];
|
||||
}
|
||||
ioBuf[i] = (int16_t)out;
|
||||
|
||||
if (env && cols) {
|
||||
int32_t a = out < 0 ? -out : out;
|
||||
if (a > 32767) a = 32767;
|
||||
const uint16_t c = (uint16_t)(((uint64_t)idx * cols) / newTotal);
|
||||
const uint8_t lvl = (uint8_t)(a >> 7);
|
||||
if (lvl > env[c]) env[c] = lvl;
|
||||
}
|
||||
}
|
||||
|
||||
UINT bw = 0;
|
||||
f_lseek(&f, 44 + dst * 2);
|
||||
res = f_write(&f, ioBuf, got * 2, &bw);
|
||||
if (res != FR_OK || bw != got * 2) {
|
||||
f_close(&f);
|
||||
return res != FR_OK ? res : FR_DISK_ERR;
|
||||
}
|
||||
|
||||
src += got;
|
||||
dst += got;
|
||||
if (got < n) break;
|
||||
}
|
||||
|
||||
f_lseek(&f, 44 + newTotal * 2);
|
||||
|
||||
@@ -43,8 +43,18 @@ class PdmWavRecorder
|
||||
// Called from the audio task every ~4 ms.
|
||||
static void audioTick();
|
||||
|
||||
// Trim leading/trailing silence in-place and patch the WAV header.
|
||||
static FRESULT trimSilence(const char* path);
|
||||
// Finish a take in one go, in two passes over the card: trim leading and
|
||||
// trailing silence, high-pass, compensate CIC droop, normalise, fade the
|
||||
// edges, patch the WAV header, and report the peak envelope (one 0..255
|
||||
// level per column), the final length and how much of the capture hit
|
||||
// int16 saturation (a high value means PDM_POST_GAIN_SHIFT is too hot).
|
||||
static FRESULT finalise(const char* path, uint8_t* env, uint16_t cols,
|
||||
uint32_t* totalSamples = nullptr,
|
||||
uint32_t* clippedPermille = nullptr);
|
||||
|
||||
// Peak of everything written since the previous call, 0..255 of full scale.
|
||||
// Feeds the live waveform; a lost update just costs one display column.
|
||||
uint8_t takePeakLevel();
|
||||
|
||||
bool isRecording() const { return recording; }
|
||||
uint32_t getSamplesWritten() const { return samplesWritten; }
|
||||
@@ -62,6 +72,7 @@ class PdmWavRecorder
|
||||
volatile uint32_t samplesWritten = 0;
|
||||
uint32_t maxSamples = 0; // 0 = open-ended
|
||||
volatile bool recording = false;
|
||||
volatile uint16_t peakSinceRead = 0;
|
||||
};
|
||||
|
||||
#endif // PDM_CLOCK
|
||||
|
||||
Reference in New Issue
Block a user