diff --git a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp index 296c6b4c3..b71fea527 100644 --- a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp +++ b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp @@ -25,6 +25,7 @@ #include +#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(); } } diff --git a/radio/src/gui/colorlcd/radio/radio_mic_recorder.h b/radio/src/gui/colorlcd/radio/radio_mic_recorder.h index 53f870407..6ea703f50 100644 --- a/radio/src/gui/colorlcd/radio/radio_mic_recorder.h +++ b/radio/src/gui/colorlcd/radio/radio_mic_recorder.h @@ -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(); diff --git a/radio/src/pdm_wav_recorder.cpp b/radio/src/pdm_wav_recorder.cpp index 8b0d646a2..6bebe66cc 100644 --- a/radio/src/pdm_wav_recorder.cpp +++ b/radio/src/pdm_wav_recorder.cpp @@ -23,6 +23,7 @@ #if defined(PDM_CLOCK) +#include #include #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); diff --git a/radio/src/pdm_wav_recorder.h b/radio/src/pdm_wav_recorder.h index 5eec2ece5..bca5e8eb4 100644 --- a/radio/src/pdm_wav_recorder.h +++ b/radio/src/pdm_wav_recorder.h @@ -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