feat(mic): add start/end trim, auto trim and clip last bit of noise when tapping or clicking stop (#7706)
This commit is contained in:
@@ -28,9 +28,10 @@
|
||||
#define PDM_PCM_DECIMATION 100
|
||||
|
||||
// Post-CIC gain (left shift before int16 saturation; +6 dB per step).
|
||||
// Default calibrated at 4. Override per target in hal.h before this header
|
||||
// is reached. trimSilence() in pdm_wav_recorder.cpp scales its threshold
|
||||
// from this same value, so changing it keeps trim in sync automatically.
|
||||
// Lowering this for headroom needs finalise()'s normaliser to be silence
|
||||
// aware first: a cooler capture needs more make-up gain, and on a take with
|
||||
// no speech that gain lands entirely on the room noise. The absolute levels
|
||||
// in pdm_wav_recorder.cpp scale off this value.
|
||||
#ifndef PDM_POST_GAIN_SHIFT
|
||||
#define PDM_POST_GAIN_SHIFT 5
|
||||
#endif
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
#include "dialog.h"
|
||||
#include "edgetx.h"
|
||||
#include "ff.h"
|
||||
#include "form.h"
|
||||
#include "hal/rotary_encoder.h"
|
||||
#include "sdcard.h"
|
||||
#include "static.h"
|
||||
#include "timers_driver.h"
|
||||
@@ -38,17 +40,46 @@
|
||||
static constexpr coord_t MIC_BTN_W = 260;
|
||||
static constexpr coord_t MIC_BTN_H = EdgeTxStyles::UI_ELEMENT_HEIGHT * 2 + PAD_LARGE;
|
||||
|
||||
// Review needs two rows in the space the single record button occupies.
|
||||
static constexpr coord_t REV_BTN_H = EdgeTxStyles::UI_ELEMENT_HEIGHT + PAD_MEDIUM;
|
||||
|
||||
// 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
|
||||
// It doubles as the trim editor: a marker line dragged straight over the trace,
|
||||
// with the part that would be cut greyed out.
|
||||
class WaveformView : public FormField
|
||||
{
|
||||
public:
|
||||
static constexpr uint16_t MAX_COLS = 400;
|
||||
static constexpr coord_t KNOB_W = PAD_LARGE * 2;
|
||||
static constexpr coord_t KNOB_H = PAD_MEDIUM;
|
||||
|
||||
WaveformView(Window* parent, const rect_t& rect) : Window(parent, rect)
|
||||
WaveformView(Window* parent, const rect_t& rect,
|
||||
std::function<void(int)> onMove, std::function<void()> onConfirm,
|
||||
std::function<void()> onDismiss) :
|
||||
FormField(parent, rect),
|
||||
moveHandler(std::move(onMove)),
|
||||
confirmHandler(std::move(onConfirm)),
|
||||
dismissHandler(std::move(onDismiss))
|
||||
{
|
||||
padAll(PAD_ZERO);
|
||||
|
||||
// No scrolling: a horizontal drag has to reach the marker, not pan the view.
|
||||
lv_obj_clear_flag(lvobj, LV_OBJ_FLAG_SCROLLABLE);
|
||||
// No click focus either: LVGL clears the group's edit mode on every focus
|
||||
// call, so a touch would otherwise leave the encoder stuck in navigation.
|
||||
lv_obj_clear_flag(lvobj, LV_OBJ_FLAG_CLICK_FOCUSABLE);
|
||||
lv_obj_add_flag(lvobj, LV_OBJ_FLAG_ENCODER_ACCEL);
|
||||
lv_obj_add_event_cb(lvobj, WaveformView::on_pressing, LV_EVENT_PRESSING, this);
|
||||
lv_obj_add_event_cb(lvobj, WaveformView::on_released, LV_EVENT_RELEASED, this);
|
||||
lv_obj_add_event_cb(lvobj, WaveformView::on_key, LV_EVENT_KEY, this);
|
||||
etx_obj_add_style(lvobj, styles->outline, LV_PART_MAIN | LV_STATE_EDITED);
|
||||
etx_obj_add_style(lvobj, styles->outline_color_edit,
|
||||
LV_PART_MAIN | LV_STATE_EDITED);
|
||||
setFocusHandler([this](bool focus) {
|
||||
if (!focus && isEditMode()) setEditMode(false);
|
||||
});
|
||||
|
||||
// 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);
|
||||
@@ -80,6 +111,32 @@ class WaveformView : public Window
|
||||
LV_PART_MAIN);
|
||||
lv_obj_add_flag(cursorLine, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
// Created last so they sit on top of the trace.
|
||||
cutShade = lv_obj_create(lvobj);
|
||||
lv_obj_clear_flag(cutShade, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_clear_flag(cutShade, LV_OBJ_FLAG_SCROLLABLE);
|
||||
etx_bg_color(cutShade, COLOR_THEME_DISABLED_INDEX, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(cutShade, LV_OPA_60, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(cutShade, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(cutShade, 0, LV_PART_MAIN);
|
||||
lv_obj_add_flag(cutShade, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
trimLine = lv_line_create(lvobj);
|
||||
etx_obj_add_style(trimLine, styles->graph_line, LV_PART_MAIN);
|
||||
etx_obj_add_style(trimLine, styles->line_color[COLOR_THEME_EDIT_INDEX],
|
||||
LV_PART_MAIN);
|
||||
lv_obj_set_style_line_width(trimLine, PAD_THREE, LV_PART_MAIN);
|
||||
lv_obj_add_flag(trimLine, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
trimKnob = lv_obj_create(lvobj);
|
||||
lv_obj_clear_flag(trimKnob, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_clear_flag(trimKnob, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_size(trimKnob, KNOB_W, KNOB_H);
|
||||
etx_solid_bg(trimKnob, COLOR_THEME_EDIT_INDEX);
|
||||
lv_obj_set_style_border_width(trimKnob, 0, LV_PART_MAIN);
|
||||
etx_obj_add_style(trimKnob, styles->rounded, LV_PART_MAIN);
|
||||
lv_obj_add_flag(trimKnob, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
clear();
|
||||
}
|
||||
|
||||
@@ -115,6 +172,31 @@ class WaveformView : public Window
|
||||
cursorAt = permille;
|
||||
}
|
||||
|
||||
// Trim editing: the marker takes the focus so the encoder drags it directly,
|
||||
// and a touch anywhere on the trace drops it at that point.
|
||||
void showTrim(int32_t permille, bool cutBefore)
|
||||
{
|
||||
trimShown = true;
|
||||
cutLeft = cutBefore;
|
||||
lv_obj_clear_flag(trimLine, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_clear_flag(trimKnob, LV_OBJ_FLAG_HIDDEN);
|
||||
setTrim(permille);
|
||||
lv_group_add_obj((lv_group_t*)lv_group_get_default(), lvobj);
|
||||
lv_group_focus_obj(lvobj);
|
||||
setEditMode(true);
|
||||
}
|
||||
|
||||
void hideTrim()
|
||||
{
|
||||
if (!trimShown) return;
|
||||
trimShown = false;
|
||||
if (isEditMode()) setEditMode(false);
|
||||
lv_group_remove_obj(lvobj);
|
||||
lv_obj_add_flag(trimLine, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_add_flag(trimKnob, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_add_flag(cutShade, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -150,11 +232,122 @@ class WaveformView : public Window
|
||||
lv_point_t botPts[MAX_COLS];
|
||||
lv_point_t basePts[2];
|
||||
lv_point_t cursorPts[2];
|
||||
lv_point_t trimPts[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;
|
||||
lv_obj_t* trimLine = nullptr;
|
||||
lv_obj_t* trimKnob = nullptr;
|
||||
lv_obj_t* cutShade = nullptr;
|
||||
|
||||
std::function<void(int)> moveHandler;
|
||||
std::function<void()> confirmHandler;
|
||||
std::function<void()> dismissHandler;
|
||||
bool trimShown = false;
|
||||
bool cutLeft = false;
|
||||
int32_t trimAt = -1;
|
||||
|
||||
void setTrim(int32_t permille)
|
||||
{
|
||||
trimAt = permille;
|
||||
|
||||
const lv_coord_t x = (lv_coord_t)((permille * traceW) / 1000);
|
||||
trimPts[0] = {x, 0};
|
||||
trimPts[1] = {x, (lv_coord_t)(height() - 1)};
|
||||
lv_line_set_points(trimLine, trimPts, 2);
|
||||
lv_coord_t knobX = x - KNOB_W / 2;
|
||||
if (knobX < 0) knobX = 0;
|
||||
if (knobX > traceW - KNOB_W) knobX = traceW - KNOB_W;
|
||||
lv_obj_set_pos(trimKnob, knobX, 0);
|
||||
|
||||
const lv_coord_t w = cutLeft ? x : (lv_coord_t)(traceW - x);
|
||||
if (w > 0) {
|
||||
lv_obj_set_pos(cutShade, cutLeft ? 0 : x, 0);
|
||||
lv_obj_set_size(cutShade, w, height());
|
||||
lv_obj_clear_flag(cutShade, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(cutShade, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
void moveTo(int32_t permille)
|
||||
{
|
||||
if (permille < 0) permille = 0;
|
||||
if (permille > 1000) permille = 1000;
|
||||
if (permille == trimAt) return;
|
||||
setTrim(permille);
|
||||
if (moveHandler) moveHandler(permille);
|
||||
}
|
||||
|
||||
static void on_pressing(lv_event_t* e)
|
||||
{
|
||||
auto wv = (WaveformView*)lv_event_get_user_data(e);
|
||||
if (!wv || wv->deleted() || !wv->trimShown || wv->traceW <= 0) return;
|
||||
|
||||
auto indev = (lv_indev_t*)lv_event_get_param(e);
|
||||
if (!indev || lv_indev_get_type(indev) != LV_INDEV_TYPE_POINTER) return;
|
||||
|
||||
lv_area_t coords;
|
||||
lv_obj_get_coords(lv_event_get_target(e), &coords);
|
||||
lv_point_t point;
|
||||
lv_indev_get_point(indev, &point);
|
||||
|
||||
const int32_t x = point.x - coords.x1;
|
||||
wv->moveTo((x * 1000) / wv->traceW);
|
||||
wv->rearmEdit();
|
||||
}
|
||||
|
||||
static void on_released(lv_event_t* e)
|
||||
{
|
||||
auto wv = (WaveformView*)lv_event_get_user_data(e);
|
||||
if (wv && !wv->deleted()) wv->rearmEdit();
|
||||
}
|
||||
|
||||
// Anything that focuses an object drops edit mode group wide, so take it back
|
||||
// after a touch: the encoder has to keep driving the marker.
|
||||
void rearmEdit()
|
||||
{
|
||||
if (!trimShown || isEditMode()) return;
|
||||
auto g = (lv_group_t*)lv_obj_get_group(lvobj);
|
||||
if (g && lv_group_get_focused(g) == lvobj) setEditMode(true);
|
||||
}
|
||||
|
||||
static void on_key(lv_event_t* e)
|
||||
{
|
||||
auto wv = (WaveformView*)lv_event_get_user_data(e);
|
||||
if (!wv || wv->deleted() || !wv->trimShown) return;
|
||||
|
||||
const uint32_t key = lv_event_get_key(e);
|
||||
if (key != LV_KEY_LEFT && key != LV_KEY_RIGHT) return;
|
||||
|
||||
// One detent moves the marker by one column, more when spun fast.
|
||||
int32_t step = wv->maxCols ? 1000 / wv->maxCols : 1;
|
||||
if (step < 1) step = 1;
|
||||
step += rotaryEncoderGetAccel() / 4;
|
||||
wv->moveTo(wv->trimAt + (key == LV_KEY_RIGHT ? step : -step));
|
||||
}
|
||||
|
||||
// A touch only drops the marker; ENTER is what confirms the trim.
|
||||
void onClicked() override
|
||||
{
|
||||
if (!trimShown) {
|
||||
FormField::onClicked();
|
||||
return;
|
||||
}
|
||||
if (lv_indev_get_type(lv_indev_get_act()) == LV_INDEV_TYPE_POINTER) return;
|
||||
if (confirmHandler) confirmHandler();
|
||||
}
|
||||
|
||||
void onCancel() override
|
||||
{
|
||||
if (trimShown && dismissHandler) {
|
||||
dismissHandler();
|
||||
return;
|
||||
}
|
||||
FormField::onCancel();
|
||||
}
|
||||
|
||||
void compact()
|
||||
{
|
||||
@@ -234,9 +427,11 @@ void RadioMicRecorder::buildBody(Window* window)
|
||||
const coord_t h = window->height();
|
||||
|
||||
const coord_t btnY = h - MIC_BTN_H - PAD_LARGE * 2;
|
||||
const coord_t rowY2 = h - REV_BTN_H - PAD_LARGE; // play / save / record
|
||||
const coord_t rowY1 = rowY2 - REV_BTN_H - PAD_SMALL; // the trim actions
|
||||
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 infoY = rowY1 - infoH - PAD_SMALL;
|
||||
const coord_t waveY = PAD_SMALL + bigH + PAD_SMALL;
|
||||
|
||||
bigLabel = new StaticText(
|
||||
@@ -246,7 +441,9 @@ void RadioMicRecorder::buildBody(Window* window)
|
||||
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});
|
||||
window, {PAD_LARGE, waveY, w - PAD_LARGE * 2, infoY - waveY - PAD_SMALL},
|
||||
[this](int v) { onTrimMoved(v); }, [this]() { onApplyTrim(); },
|
||||
[this]() { exitTrim(); });
|
||||
waveform->hide();
|
||||
|
||||
infoLabel = new StaticText(
|
||||
@@ -265,44 +462,159 @@ void RadioMicRecorder::buildBody(Window* window)
|
||||
actionButton->setFont(FONT_XL_INDEX);
|
||||
|
||||
const coord_t revW = (w - PAD_LARGE * 4) / 3;
|
||||
const coord_t revX[3] = {PAD_LARGE, (coord_t)(PAD_LARGE * 2 + revW),
|
||||
(coord_t)(PAD_LARGE * 3 + revW * 2)};
|
||||
|
||||
autoTrimButton = new TextButton(
|
||||
window, {revX[0], rowY1, revW, REV_BTN_H}, STR_AUTO_TRIM, [this]() {
|
||||
onAutoTrim();
|
||||
return 0;
|
||||
});
|
||||
|
||||
trimStartButton = new TextButton(
|
||||
window, {revX[1], rowY1, revW, REV_BTN_H}, STR_TRIM_START, [this]() {
|
||||
enterTrim(TrimMode::START);
|
||||
return 0;
|
||||
});
|
||||
|
||||
trimEndButton = new TextButton(
|
||||
window, {revX[2], rowY1, revW, REV_BTN_H}, STR_TRIM_END, [this]() {
|
||||
enterTrim(TrimMode::END);
|
||||
return 0;
|
||||
});
|
||||
|
||||
playButton = new TextButton(
|
||||
window, {PAD_LARGE, btnY, revW, MIC_BTN_H}, STR_PLAY_FILE, [this]() {
|
||||
window, {revX[0], rowY2, revW, REV_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]() {
|
||||
window, {revX[1], rowY2, revW, REV_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]() {
|
||||
window, {revX[2], rowY2, revW, REV_BTN_H}, STR_RECORD, [this]() {
|
||||
discardTake();
|
||||
enterCountdown();
|
||||
return 0;
|
||||
});
|
||||
redoButton->setWrap();
|
||||
|
||||
playButton->hide();
|
||||
saveButton->hide();
|
||||
redoButton->hide();
|
||||
applyButton = new TextButton(
|
||||
window, {revX[0], rowY2, revW, REV_BTN_H}, STR_OK, [this]() {
|
||||
onApplyTrim();
|
||||
return 0;
|
||||
});
|
||||
|
||||
cancelButton = new TextButton(
|
||||
window, {revX[1], rowY2, revW, REV_BTN_H}, STR_CANCEL, [this]() {
|
||||
exitTrim();
|
||||
return 0;
|
||||
});
|
||||
|
||||
updateButtons();
|
||||
built = true;
|
||||
}
|
||||
|
||||
void RadioMicRecorder::showReviewButtons(bool reviewing)
|
||||
// The marker runs 0..1000 over the take so its range never has to change as
|
||||
// the take gets shorter.
|
||||
uint32_t RadioMicRecorder::trimSample() const
|
||||
{
|
||||
actionButton->show(!reviewing);
|
||||
playButton->show(reviewing);
|
||||
saveButton->show(reviewing);
|
||||
redoButton->show(reviewing);
|
||||
lv_group_focus_obj(reviewing ? playButton->getLvObj()
|
||||
: actionButton->getLvObj());
|
||||
if (takeSamples == 0) return 0;
|
||||
return (uint32_t)(((uint64_t)trimPermille * (takeSamples - 1)) / 1000U);
|
||||
}
|
||||
|
||||
void RadioMicRecorder::enterTrim(TrimMode mode)
|
||||
{
|
||||
if (isPlayingTake()) audioQueue.stopAll();
|
||||
if (takeSamples < 2) return;
|
||||
|
||||
trimMode = mode;
|
||||
trimPermille = (mode == TrimMode::START) ? 0 : 1000;
|
||||
waveform->setCursor(-1);
|
||||
waveform->showTrim(trimPermille, mode == TrimMode::START);
|
||||
updateButtons();
|
||||
refreshUI();
|
||||
}
|
||||
|
||||
void RadioMicRecorder::exitTrim()
|
||||
{
|
||||
trimMode = TrimMode::NONE;
|
||||
waveform->hideTrim();
|
||||
waveform->setCursor(-1);
|
||||
updateButtons();
|
||||
refreshUI();
|
||||
}
|
||||
|
||||
void RadioMicRecorder::onTrimMoved(int value)
|
||||
{
|
||||
trimPermille = value;
|
||||
refreshUI();
|
||||
}
|
||||
|
||||
void RadioMicRecorder::onApplyTrim()
|
||||
{
|
||||
const uint32_t cut = trimSample();
|
||||
const TrimMode mode = trimMode;
|
||||
|
||||
exitTrim();
|
||||
|
||||
if (mode == TrimMode::START) {
|
||||
applyTrim(cut, takeSamples - 1);
|
||||
} else {
|
||||
applyTrim(0, cut);
|
||||
}
|
||||
}
|
||||
|
||||
// Trims are sample surgery on the finished file: cut, refade the new edge,
|
||||
// refresh the trace. Playback stops because the length just changed.
|
||||
void RadioMicRecorder::applyTrim(uint32_t from, uint32_t to)
|
||||
{
|
||||
if (isPlayingTake()) audioQueue.stopAll();
|
||||
waveform->hideTrim();
|
||||
waveform->setCursor(-1);
|
||||
|
||||
const uint16_t cols = waveform->columns();
|
||||
uint8_t env[WaveformView::MAX_COLS];
|
||||
if (PdmWavRecorder::cut(filename, from, to, env, cols, &takeSamples) == FR_OK)
|
||||
waveform->setEnvelope(env, cols);
|
||||
|
||||
playingShown = false;
|
||||
refreshUI();
|
||||
}
|
||||
|
||||
void RadioMicRecorder::onAutoTrim()
|
||||
{
|
||||
uint32_t from = 0, to = 0;
|
||||
if (PdmWavRecorder::silenceBounds(filename, &from, &to) == FR_OK)
|
||||
applyTrim(from, to);
|
||||
}
|
||||
|
||||
void RadioMicRecorder::updateButtons()
|
||||
{
|
||||
const bool review = (state == State::REVIEW);
|
||||
const bool trimming = review && trimMode != TrimMode::NONE;
|
||||
|
||||
actionButton->show(!review);
|
||||
autoTrimButton->show(review && !trimming);
|
||||
trimStartButton->show(review && !trimming);
|
||||
trimEndButton->show(review && !trimming);
|
||||
playButton->show(review && !trimming);
|
||||
saveButton->show(review && !trimming);
|
||||
redoButton->show(review && !trimming);
|
||||
applyButton->show(trimming);
|
||||
cancelButton->show(trimming);
|
||||
|
||||
if (!built) return;
|
||||
// While trimming the marker itself holds the focus.
|
||||
if (trimming)
|
||||
return;
|
||||
else if (review)
|
||||
lv_group_focus_obj(playButton->getLvObj());
|
||||
else
|
||||
lv_group_focus_obj(actionButton->getLvObj());
|
||||
}
|
||||
|
||||
bool RadioMicRecorder::isPlayingTake() const
|
||||
@@ -354,9 +666,11 @@ void RadioMicRecorder::onActionPressed()
|
||||
void RadioMicRecorder::enterIdle()
|
||||
{
|
||||
if (recorder.isRecording()) recorder.stop();
|
||||
showReviewButtons(false);
|
||||
waveform->hide();
|
||||
state = State::IDLE;
|
||||
trimMode = TrimMode::NONE;
|
||||
waveform->hideTrim();
|
||||
updateButtons();
|
||||
waveform->hide();
|
||||
stateStart = get_tmr10ms();
|
||||
refreshUI();
|
||||
}
|
||||
@@ -364,9 +678,11 @@ void RadioMicRecorder::enterIdle()
|
||||
void RadioMicRecorder::enterCountdown()
|
||||
{
|
||||
if (isPlayingTake()) audioQueue.stopAll();
|
||||
showReviewButtons(false);
|
||||
waveform->hide();
|
||||
state = State::COUNTDOWN;
|
||||
trimMode = TrimMode::NONE;
|
||||
waveform->hideTrim();
|
||||
updateButtons();
|
||||
waveform->hide();
|
||||
stateStart = get_tmr10ms();
|
||||
refreshUI();
|
||||
}
|
||||
@@ -402,7 +718,9 @@ void RadioMicRecorder::stopRecording()
|
||||
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)
|
||||
// Silence is left in place; Auto-trim is the deliberate action.
|
||||
if (PdmWavRecorder::finalise(filename, env, cols, false, &takeSamples,
|
||||
&clipped) == FR_OK)
|
||||
waveform->setEnvelope(env, cols);
|
||||
TRACE("mic: %u/1000 samples clipped at capture", (unsigned)clipped);
|
||||
|
||||
@@ -412,9 +730,11 @@ void RadioMicRecorder::stopRecording()
|
||||
void RadioMicRecorder::enterReview()
|
||||
{
|
||||
playingShown = false;
|
||||
showReviewButtons(true);
|
||||
waveform->show();
|
||||
state = State::REVIEW;
|
||||
trimMode = TrimMode::NONE;
|
||||
waveform->hideTrim();
|
||||
updateButtons();
|
||||
waveform->show();
|
||||
stateStart = get_tmr10ms();
|
||||
refreshUI();
|
||||
}
|
||||
@@ -509,6 +829,21 @@ void RadioMicRecorder::refreshUI()
|
||||
}
|
||||
|
||||
case State::REVIEW: {
|
||||
if (trimMode != TrimMode::NONE) {
|
||||
// Show what would be left, so the marker reads as a result not a cut.
|
||||
const uint32_t cut = trimSample();
|
||||
const uint32_t kept = (trimMode == TrimMode::START)
|
||||
? takeSamples - cut
|
||||
: cut + 1;
|
||||
const uint32_t k = kept / PdmWavRecorder::DST_RATE;
|
||||
snprintf(buf, sizeof(buf), "%02u:%02u", (unsigned)(k / 60U),
|
||||
(unsigned)(k % 60U));
|
||||
bigLabel->setText(buf);
|
||||
infoLabel->setText(trimMode == TrimMode::START ? STR_TRIM_START
|
||||
: STR_TRIM_END);
|
||||
break;
|
||||
}
|
||||
|
||||
const uint32_t s = takeSamples / PdmWavRecorder::DST_RATE;
|
||||
snprintf(buf, sizeof(buf), "%02u:%02u", (unsigned)(s / 60U),
|
||||
(unsigned)(s % 60U));
|
||||
|
||||
@@ -39,6 +39,7 @@ class RadioMicRecorder : public Page
|
||||
|
||||
protected:
|
||||
enum class State : uint8_t { IDLE, COUNTDOWN, RECORDING, REVIEW };
|
||||
enum class TrimMode : uint8_t { NONE, START, END };
|
||||
|
||||
static constexpr uint32_t COUNTDOWN_SECONDS = 5;
|
||||
static constexpr int PATH_MAX_LEN = sizeof(SOUNDS_PATH) + 14; // "/lang/rec_00.wav\0"
|
||||
@@ -61,6 +62,15 @@ class RadioMicRecorder : public Page
|
||||
TextButton* playButton = nullptr;
|
||||
TextButton* saveButton = nullptr;
|
||||
TextButton* redoButton = nullptr;
|
||||
TextButton* autoTrimButton = nullptr;
|
||||
TextButton* trimStartButton = nullptr;
|
||||
TextButton* trimEndButton = nullptr;
|
||||
TextButton* applyButton = nullptr;
|
||||
TextButton* cancelButton = nullptr;
|
||||
bool built = false;
|
||||
|
||||
TrimMode trimMode = TrimMode::NONE;
|
||||
int trimPermille = 0;
|
||||
WaveformView* waveform = nullptr;
|
||||
|
||||
void buildHeader(Window* window);
|
||||
@@ -70,6 +80,13 @@ class RadioMicRecorder : public Page
|
||||
|
||||
void onActionPressed();
|
||||
void onPlayPressed();
|
||||
void onAutoTrim();
|
||||
void enterTrim(TrimMode mode);
|
||||
void exitTrim();
|
||||
void onTrimMoved(int value);
|
||||
void onApplyTrim();
|
||||
void applyTrim(uint32_t from, uint32_t to);
|
||||
uint32_t trimSample() const;
|
||||
void enterIdle();
|
||||
void enterCountdown();
|
||||
void enterRecording();
|
||||
@@ -77,7 +94,7 @@ class RadioMicRecorder : public Page
|
||||
void stopRecording();
|
||||
void discardTake();
|
||||
void askSaveAs();
|
||||
void showReviewButtons(bool reviewing);
|
||||
void updateButtons();
|
||||
bool isPlayingTake() const;
|
||||
void processPendingRename();
|
||||
void applyRename();
|
||||
|
||||
+406
-28
@@ -187,8 +187,49 @@ static constexpr float PP_HPF_A = 0.969f;
|
||||
// +6 dB at 8 kHz, unity at DC.
|
||||
static constexpr float PP_COMP_A = 0.25f;
|
||||
|
||||
// 4 KB of static buffer rather than a stack one: a pass over the card costs
|
||||
// far more in transfers than in bytes.
|
||||
static int16_t ioBuf[2048];
|
||||
static constexpr uint32_t CHUNK = sizeof(ioBuf) / sizeof(ioBuf[0]);
|
||||
|
||||
// Silence is judged against the take's own voice peak, not an absolute level:
|
||||
// capture gain is fixed, so a quiet talker never crossed a fixed threshold and
|
||||
// the trim either did nothing or kept only the loudest syllables.
|
||||
// Levels below were calibrated at PDM_POST_GAIN_SHIFT == 5; scaling them off
|
||||
// it keeps the trim calibrated when the capture gain is changed.
|
||||
#define PDM_LEVEL(x) (((x) * (1 << PDM_POST_GAIN_SHIFT)) / 32)
|
||||
|
||||
static constexpr int32_t SILENCE_RATIO = 8; // -18 dB below the voice peak
|
||||
static constexpr int32_t SILENCE_FLOOR = PDM_LEVEL(200);
|
||||
static constexpr uint32_t COARSE_MAX = 512; // whole-file level map
|
||||
static uint16_t coarseLevel[COARSE_MAX];
|
||||
// Asymmetric on purpose: speech tails off below the threshold (trailing
|
||||
// consonants, breath), so cutting 10 ms past the last loud sample clips it.
|
||||
// Stop-press detection. The press is a short burst, much louder than the
|
||||
// speech before it, sitting at the very end of the take. Anything that is
|
||||
// long, or not near the end, is treated as speech and left alone.
|
||||
static constexpr uint32_t SUB_SAMPLES = 256; // 16 ms
|
||||
static constexpr uint32_t TAIL_RING = 128; // ~2.0 s
|
||||
static constexpr uint32_t CLICK_WINDOW_BLOCKS = 94; // ~1.5 s
|
||||
static constexpr uint32_t CLICK_MAX_BLOCKS = 25; // ~0.4 s
|
||||
static constexpr uint32_t CLICK_GUARD_BLOCKS = 2; // ~32 ms
|
||||
static constexpr uint32_t CLICK_LOOKBACK = 8; // ~128 ms
|
||||
static constexpr int32_t CLICK_MIN_LEVEL = PDM_LEVEL(300); // ~-40 dBFS at shift 5
|
||||
static constexpr uint32_t CLICK_GAP_BLOCKS = 38; // ~0.6 s hold
|
||||
static constexpr uint32_t CLICK_MAX_BURSTS = 4;
|
||||
static constexpr uint32_t CLICK_PAUSE_BLOCKS = 10; // ~0.16 s
|
||||
|
||||
static constexpr uint32_t PAD_HEAD_SAMPLES = PdmWavRecorder::DST_RATE / 10; // 100 ms
|
||||
static constexpr uint32_t PAD_TAIL_SAMPLES = PdmWavRecorder::DST_RATE / 5; // 200 ms
|
||||
|
||||
static constexpr float PP_NORM_TARGET = 29500.0f; // ~-0.9 dBFS
|
||||
static constexpr float PP_MAX_GAIN = 8.0f;
|
||||
static constexpr float PP_MAX_GAIN = 4.0f;
|
||||
|
||||
// Gain is also capped so it never lifts the take's own noise floor past this
|
||||
// RMS (~-38 dBFS). The stop click used to be the loudest thing in every file
|
||||
// and quietly held the gain near 1; with it gone, a quiet take would other-
|
||||
// wise be boosted until room tone and CIC hiss become the loudest thing.
|
||||
static constexpr float PP_NOISE_CEILING = 400.0f; // output level, not capture
|
||||
static constexpr uint32_t PP_FADE_SAMPLES = PdmWavRecorder::DST_RATE / 125; // 8 ms
|
||||
|
||||
namespace {
|
||||
@@ -213,27 +254,170 @@ struct PostFilter
|
||||
};
|
||||
} // namespace
|
||||
|
||||
FRESULT PdmWavRecorder::finalise(const char* path, uint8_t* env, uint16_t cols,
|
||||
uint32_t* totalSamples,
|
||||
uint32_t* clippedPermille)
|
||||
// Reads the 44-byte header and returns the sample count, or 0 on any problem.
|
||||
static uint32_t readWavTotal(FIL* f)
|
||||
{
|
||||
// 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]);
|
||||
uint8_t hdr[44];
|
||||
UINT br = 0;
|
||||
if (f_lseek(f, 0) != FR_OK) return 0;
|
||||
if (f_read(f, hdr, sizeof(hdr), &br) != FR_OK || br != sizeof(hdr)) return 0;
|
||||
const uint32_t dataBytes =
|
||||
(uint32_t)hdr[40] | ((uint32_t)hdr[41] << 8) |
|
||||
((uint32_t)hdr[42] << 16) | ((uint32_t)hdr[43] << 24);
|
||||
return dataBytes / 2;
|
||||
}
|
||||
|
||||
// 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 =
|
||||
((int32_t)4000 * (1 << PDM_POST_GAIN_SHIFT)) / 16;
|
||||
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 void patchWavHeader(FIL* f, uint32_t samples)
|
||||
{
|
||||
uint8_t tmp[4];
|
||||
UINT bw = 0;
|
||||
writeLE32(tmp, 36 + samples * 2);
|
||||
f_lseek(f, 4);
|
||||
f_write(f, tmp, 4, &bw);
|
||||
writeLE32(tmp, samples * 2);
|
||||
f_lseek(f, 40);
|
||||
f_write(f, tmp, 4, &bw);
|
||||
}
|
||||
|
||||
FRESULT PdmWavRecorder::silenceBounds(const char* path, uint32_t* from,
|
||||
uint32_t* to)
|
||||
{
|
||||
if (!from || !to) return FR_INVALID_PARAMETER;
|
||||
|
||||
FIL f;
|
||||
FRESULT res = f_open(&f, path, FA_READ);
|
||||
if (res != FR_OK) return res;
|
||||
|
||||
const uint32_t total = readWavTotal(&f);
|
||||
if (total == 0) { f_close(&f); return FR_DISK_ERR; }
|
||||
|
||||
uint32_t firstActive = UINT32_MAX, lastActive = 0;
|
||||
UINT br = 0;
|
||||
int32_t silence = SILENCE_FLOOR;
|
||||
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
int32_t peak = 0;
|
||||
f_lseek(&f, 44);
|
||||
for (uint32_t base = 0; base < total; base += CHUNK) {
|
||||
const uint32_t n = (base + CHUNK <= total) ? CHUNK : total - base;
|
||||
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 int32_t a = ioBuf[i] < 0 ? -(int32_t)ioBuf[i] : (int32_t)ioBuf[i];
|
||||
if (pass == 0) {
|
||||
if (a > peak) peak = a;
|
||||
} else if (a > silence) {
|
||||
if (firstActive == UINT32_MAX) firstActive = base + i;
|
||||
lastActive = base + i;
|
||||
}
|
||||
}
|
||||
if (got < n) break;
|
||||
}
|
||||
if (pass == 0) {
|
||||
silence = peak / SILENCE_RATIO;
|
||||
if (silence < SILENCE_FLOOR) silence = SILENCE_FLOOR;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstActive == UINT32_MAX) { // nothing above the threshold
|
||||
*from = 0;
|
||||
*to = total - 1;
|
||||
} else {
|
||||
*from = firstActive > PAD_HEAD_SAMPLES ? firstActive - PAD_HEAD_SAMPLES : 0;
|
||||
*to = (lastActive + PAD_TAIL_SAMPLES < total) ? lastActive + PAD_TAIL_SAMPLES
|
||||
: total - 1;
|
||||
}
|
||||
return f_close(&f);
|
||||
}
|
||||
|
||||
FRESULT PdmWavRecorder::cut(const char* path, uint32_t from, uint32_t to,
|
||||
uint8_t* env, uint16_t cols,
|
||||
uint32_t* totalSamples)
|
||||
{
|
||||
if (env && cols) memset(env, 0, cols);
|
||||
|
||||
FIL f;
|
||||
FRESULT res = f_open(&f, path, FA_READ | FA_WRITE);
|
||||
if (res != FR_OK) return res;
|
||||
|
||||
const uint32_t total = readWavTotal(&f);
|
||||
if (total == 0) { f_close(&f); return FR_DISK_ERR; }
|
||||
|
||||
if (to >= total) to = total - 1;
|
||||
if (to <= from) { f_close(&f); return FR_INVALID_PARAMETER; }
|
||||
|
||||
const uint32_t newTotal = to - from + 1;
|
||||
if (newTotal * 1000U < MIN_TAKE_MS * DST_RATE) {
|
||||
f_close(&f);
|
||||
return FR_INVALID_PARAMETER;
|
||||
}
|
||||
if (totalSamples) *totalSamples = newTotal;
|
||||
|
||||
const uint32_t fade =
|
||||
newTotal / 4 < PP_FADE_SAMPLES ? newTotal / 4 : PP_FADE_SAMPLES;
|
||||
|
||||
uint32_t src = from, dst = 0;
|
||||
UINT br = 0;
|
||||
|
||||
while (src <= to) {
|
||||
const uint32_t n = (src + CHUNK - 1 <= to) ? CHUNK : to - 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 v = ioBuf[i];
|
||||
|
||||
if (fade) {
|
||||
if (idx < fade) {
|
||||
v = (int32_t)((int64_t)v * (int32_t)idx / (int32_t)fade);
|
||||
} else if (idx >= newTotal - fade) {
|
||||
v = (int32_t)((int64_t)v * (int32_t)(newTotal - idx) / (int32_t)fade);
|
||||
}
|
||||
}
|
||||
ioBuf[i] = (int16_t)v;
|
||||
|
||||
if (env && cols) {
|
||||
int32_t a = v < 0 ? -v : v;
|
||||
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);
|
||||
f_truncate(&f);
|
||||
patchWavHeader(&f, newTotal);
|
||||
|
||||
return f_close(&f);
|
||||
}
|
||||
|
||||
FRESULT PdmWavRecorder::finalise(const char* path, uint8_t* env, uint16_t cols,
|
||||
bool trimSilence, uint32_t* totalSamples,
|
||||
uint32_t* clippedPermille,
|
||||
PressDiag* diag)
|
||||
{
|
||||
if (env && cols) memset(env, 0, cols);
|
||||
if (totalSamples) *totalSamples = 0;
|
||||
if (clippedPermille) *clippedPermille = 0;
|
||||
if (diag) *diag = PressDiag{0, 0, 0, 0};
|
||||
|
||||
FIL f;
|
||||
FRESULT res = f_open(&f, path, FA_READ | FA_WRITE);
|
||||
@@ -252,35 +436,215 @@ FRESULT PdmWavRecorder::finalise(const char* path, uint8_t* env, uint16_t cols,
|
||||
|
||||
// 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;
|
||||
float quietestRms = 0.0f;
|
||||
bool haveRms = false;
|
||||
|
||||
const uint32_t totalBlocks = (total + SUB_SAMPLES - 1) / SUB_SAMPLES;
|
||||
// A take shorter than the window is searched whole rather than skipped.
|
||||
const uint32_t firstTailBlock =
|
||||
totalBlocks > CLICK_WINDOW_BLOCKS ? totalBlocks - CLICK_WINDOW_BLOCKS : 0;
|
||||
|
||||
const uint32_t coarseSize =
|
||||
total / COARSE_MAX + 1 > SUB_SAMPLES ? total / COARSE_MAX + 1 : SUB_SAMPLES;
|
||||
const uint32_t coarseCount = (total + coarseSize - 1) / coarseSize;
|
||||
memset(coarseLevel, 0, sizeof(coarseLevel));
|
||||
int32_t coarsePeak = 0;
|
||||
|
||||
int32_t blockPeak[TAIL_RING] = {0};
|
||||
int32_t peakBeforeWindow = 0;
|
||||
int32_t roomLevel = INT32_MAX; // quietest block: the take's own noise floor
|
||||
int32_t subPeak = 0;
|
||||
|
||||
for (uint32_t base = 0; base < total; base += CHUNK) {
|
||||
const uint32_t n = (base + CHUNK <= total) ? CHUNK : total - base;
|
||||
res = f_read(&f, ioBuf, n * 2, &br);
|
||||
if (res != FR_OK) { f_close(&f); return res; }
|
||||
const uint32_t got = br / 2;
|
||||
float sumSq = 0.0f;
|
||||
for (uint32_t i = 0; i < got; i++) {
|
||||
const uint32_t idx = base + i;
|
||||
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;
|
||||
if (a > subPeak) subPeak = a;
|
||||
if ((idx + 1) % SUB_SAMPLES == 0 || idx + 1 == total) {
|
||||
const uint32_t b = idx / SUB_SAMPLES;
|
||||
blockPeak[b % TAIL_RING] = subPeak;
|
||||
if (b < firstTailBlock && subPeak > peakBeforeWindow)
|
||||
peakBeforeWindow = subPeak;
|
||||
if (subPeak < roomLevel) roomLevel = subPeak;
|
||||
subPeak = 0;
|
||||
}
|
||||
const float v = fabsf(flt.process(s));
|
||||
if (v > peak) peak = v;
|
||||
const float v = flt.process(s);
|
||||
const float m = fabsf(v);
|
||||
if (m > peak) peak = m;
|
||||
sumSq += v * v;
|
||||
|
||||
int32_t mi = (int32_t)m;
|
||||
if (mi > 32767) mi = 32767;
|
||||
if (mi > coarsePeak) coarsePeak = mi;
|
||||
if ((idx + 1) % coarseSize == 0 || idx + 1 == total) {
|
||||
coarseLevel[idx / coarseSize] = (uint16_t)coarsePeak;
|
||||
coarsePeak = 0;
|
||||
}
|
||||
}
|
||||
if (got == CHUNK) { // quietest full block is the noise floor
|
||||
const float rms = sqrtf(sumSq / (float)CHUNK);
|
||||
if (!haveRms || rms < quietestRms) { quietestRms = rms; haveRms = true; }
|
||||
}
|
||||
if (got < n) break;
|
||||
}
|
||||
|
||||
// Locate the stop press: the earliest block in the trailing window that
|
||||
// stands clear of everything before it, provided the burst it starts is
|
||||
// short enough to be a press and not the last word.
|
||||
// Pressing the screen makes two bursts, not one: the finger landing and
|
||||
// the finger lifting, with a decay dip between them. Walk back from the end
|
||||
// over as many adjacent bursts as qualify, judging each against the voice
|
||||
// level rather than against the other burst.
|
||||
uint32_t clickStart = 0;
|
||||
if (totalBlocks > CLICK_MAX_BLOCKS + CLICK_GUARD_BLOCKS + 2) {
|
||||
if (roomLevel == INT32_MAX) roomLevel = 0;
|
||||
const int32_t audible = roomLevel * 3 > CLICK_MIN_LEVEL ? roomLevel * 3
|
||||
: CLICK_MIN_LEVEL;
|
||||
const int32_t voiceLevel =
|
||||
peakBeforeWindow > audible ? peakBeforeWindow : audible;
|
||||
if (diag) diag->voiceLevel = voiceLevel;
|
||||
|
||||
uint32_t searchEnd = totalBlocks;
|
||||
uint32_t cutBlock = 0;
|
||||
int32_t burstPeak = 0;
|
||||
bool haveCut = false;
|
||||
|
||||
for (uint32_t iter = 0; iter < CLICK_MAX_BURSTS; iter++) {
|
||||
// Between the landing and the lift there is decay, not silence. Once a
|
||||
// burst is known, only something of comparable size counts as the next
|
||||
// one, so that decay is crossed as a gap instead of ending the walk.
|
||||
const int32_t seek =
|
||||
haveCut && burstPeak / 8 > audible ? burstPeak / 8 : audible;
|
||||
|
||||
uint32_t last = 0;
|
||||
bool found = false;
|
||||
for (uint32_t b = searchEnd; b-- > firstTailBlock;) {
|
||||
if (blockPeak[b % TAIL_RING] > seek) { last = b; found = true; break; }
|
||||
}
|
||||
if (!found) break;
|
||||
if (haveCut && cutBlock - last > CLICK_GAP_BLOCKS) break;
|
||||
|
||||
int32_t eventPeak = blockPeak[last % TAIL_RING];
|
||||
uint32_t onset = last;
|
||||
while (onset > firstTailBlock) {
|
||||
const int32_t half = eventPeak / 2;
|
||||
const int32_t limit = half > audible ? half : audible;
|
||||
const int32_t prev = blockPeak[(onset - 1) % TAIL_RING];
|
||||
if (prev <= limit) break;
|
||||
onset--;
|
||||
if (prev > eventPeak) eventPeak = prev;
|
||||
}
|
||||
|
||||
if (diag && iter == 0) {
|
||||
diag->burstPeak = eventPeak;
|
||||
diag->burstMs = ((last - onset + 1) * SUB_SAMPLES * 1000U) / DST_RATE;
|
||||
}
|
||||
|
||||
if (onset <= firstTailBlock) break; // start not in view
|
||||
if (last - onset + 1 > CLICK_MAX_BLOCKS) break; // too long to be a press
|
||||
// No loudness test here on purpose: it would depend on where the window
|
||||
// boundary happens to fall relative to the speech. Whether this really
|
||||
// is a press is decided once, below, against the voice measured ahead
|
||||
// of the whole run of bursts.
|
||||
|
||||
cutBlock = onset;
|
||||
if (eventPeak > burstPeak) burstPeak = eventPeak;
|
||||
haveCut = true;
|
||||
searchEnd = onset;
|
||||
}
|
||||
|
||||
if (haveCut) {
|
||||
// The window is wide enough to hold a slow press-and-hold, which means
|
||||
// it can also hold the tail of the voice. Re-measure the voice over
|
||||
// everything ahead of the press before accepting it.
|
||||
int32_t voiceCheck = peakBeforeWindow;
|
||||
for (uint32_t b = firstTailBlock; b < cutBlock; b++) {
|
||||
const int32_t l = blockPeak[b % TAIL_RING];
|
||||
if (l > voiceCheck) voiceCheck = l;
|
||||
}
|
||||
if (voiceCheck < audible) voiceCheck = audible;
|
||||
if (diag) diag->voiceLevel = voiceCheck;
|
||||
|
||||
// How much quiet sits ahead of the press.
|
||||
const int32_t quiet =
|
||||
voiceCheck / 8 > audible ? voiceCheck / 8 : audible;
|
||||
uint32_t gapBlocks = 0;
|
||||
for (uint32_t b = cutBlock; b-- > firstTailBlock;) {
|
||||
if (blockPeak[b % TAIL_RING] > quiet) break;
|
||||
gapBlocks++;
|
||||
}
|
||||
|
||||
// Twice the voice settles it outright. When capture clips, both the
|
||||
// press and the voice sit at full scale and no ratio can separate
|
||||
// them, so a burst merely as loud as the voice counts too - provided
|
||||
// it follows a pause, which a word carried on from speech does not.
|
||||
const bool louder = burstPeak >= voiceCheck * 2;
|
||||
// Comparable, not greater: when both saturate, which of them reads a
|
||||
// few counts higher is arbitrary.
|
||||
const bool afterPause =
|
||||
burstPeak * 4 >= voiceCheck * 3 && gapBlocks >= CLICK_PAUSE_BLOCKS;
|
||||
|
||||
if (louder || afterPause) {
|
||||
const uint32_t guard = cutBlock > firstTailBlock + CLICK_GUARD_BLOCKS
|
||||
? CLICK_GUARD_BLOCKS
|
||||
: 0;
|
||||
clickStart = (cutBlock - guard) * SUB_SAMPLES;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diag && clickStart > 0) diag->cutSamples = total - clickStart;
|
||||
|
||||
// Silence bounds, from the level map and a threshold set by the take's own
|
||||
// voice peak. The press is excluded from both, so neither the threshold nor
|
||||
// the padding is measured from it.
|
||||
const uint32_t pressBlock =
|
||||
clickStart > 0 ? clickStart / coarseSize : coarseCount;
|
||||
|
||||
int32_t voicePeak = 0;
|
||||
for (uint32_t b = 0; b < pressBlock && b < coarseCount; b++) {
|
||||
if (coarseLevel[b] > voicePeak) voicePeak = coarseLevel[b];
|
||||
}
|
||||
|
||||
int32_t silence = voicePeak / SILENCE_RATIO;
|
||||
if (silence < roomLevel * 4) silence = roomLevel * 4;
|
||||
if (silence < SILENCE_FLOOR) silence = SILENCE_FLOOR;
|
||||
|
||||
uint32_t firstActive = UINT32_MAX, lastActive = 0;
|
||||
if (trimSilence) {
|
||||
for (uint32_t b = 0; b < pressBlock && b < coarseCount; b++) {
|
||||
if (coarseLevel[b] <= silence) continue;
|
||||
if (firstActive == UINT32_MAX) firstActive = b * coarseSize;
|
||||
lastActive = (b + 1) * coarseSize - 1;
|
||||
}
|
||||
if (lastActive >= total) lastActive = total - 1;
|
||||
}
|
||||
|
||||
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;
|
||||
trimStart = firstActive > PAD_HEAD_SAMPLES ? firstActive - PAD_HEAD_SAMPLES : 0;
|
||||
trimEnd = (lastActive + PAD_TAIL_SAMPLES < total) ? lastActive + PAD_TAIL_SAMPLES
|
||||
: total - 1;
|
||||
}
|
||||
if (clickStart > 0 && trimEnd >= clickStart) trimEnd = clickStart - 1;
|
||||
|
||||
// If nothing cleared the threshold, the bounds can cross. Keep everything ahead of the press instead.
|
||||
if (trimEnd <= trimStart) {
|
||||
trimStart = 0;
|
||||
trimEnd = clickStart > 0 ? clickStart - 1 : total - 1;
|
||||
}
|
||||
if (trimEnd <= trimStart) {
|
||||
trimStart = 0;
|
||||
trimEnd = total - 1;
|
||||
}
|
||||
const uint32_t newTotal = trimEnd - trimStart + 1;
|
||||
|
||||
@@ -289,9 +653,23 @@ FRESULT PdmWavRecorder::finalise(const char* path, uint8_t* env, uint16_t cols,
|
||||
|
||||
const bool doFilter = newTotal >= 2 * PP_FADE_SAMPLES;
|
||||
|
||||
// Measured over what survives the trim: the press is about to go, and
|
||||
// normalising to it would leave the voice quiet.
|
||||
float keptPeak = 0.0f;
|
||||
for (uint32_t b = trimStart / coarseSize;
|
||||
b <= trimEnd / coarseSize && b < coarseCount; b++) {
|
||||
if ((float)coarseLevel[b] > keptPeak) keptPeak = (float)coarseLevel[b];
|
||||
}
|
||||
if (keptPeak <= 1.0f) keptPeak = peak;
|
||||
|
||||
float gain = 1.0f;
|
||||
if (doFilter && peak > 1.0f) {
|
||||
gain = PP_NORM_TARGET / peak;
|
||||
if (doFilter && keptPeak > 1.0f) {
|
||||
gain = PP_NORM_TARGET / keptPeak;
|
||||
if (haveRms && quietestRms > 1.0f) {
|
||||
float noiseLimit = PP_NOISE_CEILING / quietestRms;
|
||||
if (noiseLimit < 1.0f) noiseLimit = 1.0f; // never attenuate for noise
|
||||
if (gain > noiseLimit) gain = noiseLimit;
|
||||
}
|
||||
if (gain > PP_MAX_GAIN) gain = PP_MAX_GAIN;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,14 +43,42 @@ class PdmWavRecorder
|
||||
// Called from the audio task every ~4 ms.
|
||||
static void audioTick();
|
||||
|
||||
// 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).
|
||||
// The tap or ENT press that ends a take is audible at the end of it.
|
||||
// finalise() locates that burst and cuts from its onset, rather than
|
||||
// dropping a fixed slice that would eat speech on a prompt stop.
|
||||
|
||||
// Shortest take a trim is allowed to leave behind.
|
||||
static constexpr uint32_t MIN_TAKE_MS = 200;
|
||||
|
||||
// What the press detector saw. Reported so the review screen can show it.
|
||||
struct PressDiag {
|
||||
uint32_t cutSamples; // removed from the end as a press, 0 if none found
|
||||
int32_t voiceLevel; // level the burst had to beat
|
||||
int32_t burstPeak; // peak of the last loud event
|
||||
uint32_t burstMs; // how long that event ran
|
||||
};
|
||||
|
||||
// Finish a take in one go, in two passes over the card: remove the stop
|
||||
// press, 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).
|
||||
// trimSilence additionally cuts leading and trailing silence; left to the
|
||||
// Auto-trim button so the take is kept whole until asked otherwise.
|
||||
static FRESULT finalise(const char* path, uint8_t* env, uint16_t cols,
|
||||
uint32_t* totalSamples = nullptr,
|
||||
uint32_t* clippedPermille = nullptr);
|
||||
bool trimSilence, uint32_t* totalSamples = nullptr,
|
||||
uint32_t* clippedPermille = nullptr,
|
||||
PressDiag* diag = nullptr);
|
||||
|
||||
// Cut the take down to [from, to] in place, fading the new edges and
|
||||
// refreshing the envelope. Pure sample surgery — no filtering or
|
||||
// normalisation, so repeated trims never stack DSP on the same audio.
|
||||
static FRESULT cut(const char* path, uint32_t from, uint32_t to,
|
||||
uint8_t* env, uint16_t cols,
|
||||
uint32_t* totalSamples = nullptr);
|
||||
|
||||
// Silence bounds of the file as it stands, for the auto-trim button.
|
||||
static FRESULT silenceBounds(const char* path, uint32_t* from, uint32_t* to);
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -939,6 +939,9 @@
|
||||
#define TR_GET_READY "准备好..."
|
||||
#define TR_SAVED "已保存:"
|
||||
#define TR_SAVE_AS "另存为"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "打开失败"
|
||||
#define TR_TURN_OFF_RECEIVER "请关闭接收机"
|
||||
#define TR_STOPPING "正在停止..."
|
||||
|
||||
@@ -936,6 +936,9 @@
|
||||
#define TR_GET_READY "Připravit se..."
|
||||
#define TR_SAVED "Uloženo:"
|
||||
#define TR_SAVE_AS "Uložit jako"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "Chyba otevření"
|
||||
#define TR_TURN_OFF_RECEIVER "Vypněte přijímač"
|
||||
#define TR_STOPPING "Zastavuji..."
|
||||
|
||||
@@ -944,6 +944,9 @@
|
||||
#define TR_GET_READY "Gør dig klar..."
|
||||
#define TR_SAVED "Gemt:"
|
||||
#define TR_SAVE_AS "Gem som"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "Åbn fejl"
|
||||
#define TR_TURN_OFF_RECEIVER "Sluk modtager"
|
||||
#define TR_STOPPING "Standser..."
|
||||
|
||||
@@ -939,6 +939,9 @@
|
||||
#define TR_GET_READY "Bereit machen..."
|
||||
#define TR_SAVED "Gespeichert:"
|
||||
#define TR_SAVE_AS "Speichern als"
|
||||
#define TR_AUTO_TRIM "Auto-Schnitt"
|
||||
#define TR_TRIM_START "Anfang kürzen"
|
||||
#define TR_TRIM_END "Ende kürzen"
|
||||
#define TR_OPEN_ERROR "Öffnungsfehler"
|
||||
#define TR_TURN_OFF_RECEIVER "Empf. ausschalten"
|
||||
#define TR_STOPPING "Stoppe..."
|
||||
|
||||
@@ -940,6 +940,9 @@
|
||||
#define TR_GET_READY "Get ready..."
|
||||
#define TR_SAVED "Saved:"
|
||||
#define TR_SAVE_AS "Save as"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "Open error"
|
||||
#define TR_TURN_OFF_RECEIVER "Turn off receiver"
|
||||
#define TR_STOPPING "Stopping..."
|
||||
|
||||
@@ -932,6 +932,9 @@
|
||||
#define TR_GET_READY "Prepárate..."
|
||||
#define TR_SAVED "Guardado:"
|
||||
#define TR_SAVE_AS "Guardar como"
|
||||
#define TR_AUTO_TRIM "Auto-recorte"
|
||||
#define TR_TRIM_START "Recortar inicio"
|
||||
#define TR_TRIM_END "Recortar fin"
|
||||
#define TR_OPEN_ERROR "Error al abrir"
|
||||
#define TR_TURN_OFF_RECEIVER "Apaga el receptor"
|
||||
#define TR_STOPPING "Parando..."
|
||||
|
||||
@@ -932,6 +932,9 @@
|
||||
#define TR_GET_READY "Valmistaudu..."
|
||||
#define TR_SAVED "Tallennettu:"
|
||||
#define TR_SAVE_AS "Tallenna nimellä"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "Avausvirhe"
|
||||
#define TR_TURN_OFF_RECEIVER "Turn off receiver"
|
||||
#define TR_STOPPING "Stopping..."
|
||||
|
||||
@@ -936,6 +936,9 @@
|
||||
#define TR_GET_READY "Tenez-vous prêt..."
|
||||
#define TR_SAVED "Enregistré:"
|
||||
#define TR_SAVE_AS "Enregistrer sous"
|
||||
#define TR_AUTO_TRIM "Auto-rogner"
|
||||
#define TR_TRIM_START "Rogner début"
|
||||
#define TR_TRIM_END "Rogner fin"
|
||||
#define TR_OPEN_ERROR "Erreur ouverture"
|
||||
#define TR_TURN_OFF_RECEIVER "Éteindre Récepteur."
|
||||
#define TR_STOPPING "Arrêt..."
|
||||
|
||||
@@ -942,6 +942,9 @@
|
||||
#define TR_GET_READY "...היה מוכן"
|
||||
#define TR_SAVED ":נשמר"
|
||||
#define TR_SAVE_AS "שמור בשם"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "שגיאת פתיחה"
|
||||
#define TR_TURN_OFF_RECEIVER "כיבוי מקלט"
|
||||
#define TR_STOPPING "...עוצר"
|
||||
|
||||
@@ -939,6 +939,9 @@
|
||||
#define TR_GET_READY "Preparati..."
|
||||
#define TR_SAVED "Salvato:"
|
||||
#define TR_SAVE_AS "Salva come"
|
||||
#define TR_AUTO_TRIM "Auto-taglio"
|
||||
#define TR_TRIM_START "Taglia inizio"
|
||||
#define TR_TRIM_END "Taglia fine"
|
||||
#define TR_OPEN_ERROR "Errore apertura"
|
||||
#define TR_TURN_OFF_RECEIVER "Spegni la RX"
|
||||
#define TR_STOPPING "Fermando..."
|
||||
|
||||
@@ -938,6 +938,9 @@
|
||||
#define TR_GET_READY "Get ready..."
|
||||
#define TR_SAVED "Saved:"
|
||||
#define TR_SAVE_AS "Save as"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "Open error"
|
||||
#define TR_TURN_OFF_RECEIVER "受信機 電源OFF"
|
||||
#define TR_STOPPING "停止中..."
|
||||
|
||||
@@ -981,6 +981,9 @@
|
||||
#define TR_GET_READY "준비하세요..."
|
||||
#define TR_SAVED "저장됨:"
|
||||
#define TR_SAVE_AS "다른 이름으로 저장"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "열기 오류"
|
||||
|
||||
#define TR_TURN_OFF_RECEIVER "수신기 전원 끄기"
|
||||
|
||||
@@ -934,6 +934,9 @@
|
||||
#define TR_GET_READY "Maak je klaar..."
|
||||
#define TR_SAVED "Opgeslagen:"
|
||||
#define TR_SAVE_AS "Opslaan als"
|
||||
#define TR_AUTO_TRIM "Auto-trimmen"
|
||||
#define TR_TRIM_START "Begin trimmen"
|
||||
#define TR_TRIM_END "Einde trimmen"
|
||||
#define TR_OPEN_ERROR "Fout bij openen"
|
||||
#define TR_TURN_OFF_RECEIVER "Turn off receiver"
|
||||
#define TR_STOPPING "Stopping..."
|
||||
|
||||
@@ -931,6 +931,9 @@
|
||||
#define TR_GET_READY "Przygotuj się..."
|
||||
#define TR_SAVED "Zapisano:"
|
||||
#define TR_SAVE_AS "Zapisz jako"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "Błąd otwarcia"
|
||||
#define TR_TURN_OFF_RECEIVER "Wyłącz odbiornik"
|
||||
#define TR_STOPPING "Stopping..."
|
||||
|
||||
@@ -939,6 +939,9 @@
|
||||
#define TR_GET_READY "Prepare-se..."
|
||||
#define TR_SAVED "Guardado:"
|
||||
#define TR_SAVE_AS "Guardar como"
|
||||
#define TR_AUTO_TRIM "Auto-corte"
|
||||
#define TR_TRIM_START "Cortar início"
|
||||
#define TR_TRIM_END "Cortar fim"
|
||||
#define TR_OPEN_ERROR "Erro ao abrir"
|
||||
#define TR_TURN_OFF_RECEIVER "Turn off receiver"
|
||||
#define TR_STOPPING "Stopping..."
|
||||
|
||||
@@ -941,6 +941,9 @@
|
||||
#define TR_GET_READY "Приготовьтесь..."
|
||||
#define TR_SAVED "Сохранено:"
|
||||
#define TR_SAVE_AS "Сохранить как"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "Ошибка открытия"
|
||||
#define TR_TURN_OFF_RECEIVER "Выкл приемник"
|
||||
#define TR_STOPPING "Остановка..."
|
||||
|
||||
@@ -939,6 +939,9 @@
|
||||
#define TR_GET_READY "Gör dig redo..."
|
||||
#define TR_SAVED "Sparad:"
|
||||
#define TR_SAVE_AS "Spara som"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "Öppningsfel"
|
||||
#define TR_TURN_OFF_RECEIVER "Stäng av mottagare"
|
||||
#define TR_STOPPING "Stoppar..."
|
||||
|
||||
@@ -936,6 +936,9 @@
|
||||
#define TR_GET_READY "準備好..."
|
||||
#define TR_SAVED "已保存:"
|
||||
#define TR_SAVE_AS "另存為"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "打開失敗"
|
||||
#define TR_TURN_OFF_RECEIVER "請關閉接收機"
|
||||
#define TR_STOPPING "正在停止..."
|
||||
|
||||
@@ -940,6 +940,9 @@
|
||||
#define TR_GET_READY "Готуйтесь..."
|
||||
#define TR_SAVED "Збережено:"
|
||||
#define TR_SAVE_AS "Зберегти як"
|
||||
#define TR_AUTO_TRIM "Auto-trim"
|
||||
#define TR_TRIM_START "Trim start"
|
||||
#define TR_TRIM_END "Trim end"
|
||||
#define TR_OPEN_ERROR "Помилка відкриття"
|
||||
#define TR_TURN_OFF_RECEIVER "Вимкнути приймач"
|
||||
#define TR_STOPPING "Зупинка..."
|
||||
|
||||
@@ -236,6 +236,7 @@
|
||||
// Main Menu
|
||||
|
||||
#if defined(PDM_CLOCK)
|
||||
#define STR_AUTO_TRIM currentLangStrings->STR_AUTO_TRIM
|
||||
#define STR_GET_READY currentLangStrings->STR_GET_READY
|
||||
#define STR_MIC_RECORDER currentLangStrings->STR_MIC_RECORDER
|
||||
#define STR_OPEN_ERROR currentLangStrings->STR_OPEN_ERROR
|
||||
@@ -246,6 +247,8 @@
|
||||
#define STR_SAVED currentLangStrings->STR_SAVED
|
||||
#define STR_STARTING_IN currentLangStrings->STR_STARTING_IN
|
||||
#define STR_STOP currentLangStrings->STR_STOP
|
||||
#define STR_TRIM_END currentLangStrings->STR_TRIM_END
|
||||
#define STR_TRIM_START currentLangStrings->STR_TRIM_START
|
||||
#endif
|
||||
|
||||
#else // !COLORLCD
|
||||
|
||||
@@ -232,6 +232,7 @@ STR(QUICK_MENU_FAVORITES)
|
||||
// Main Menu
|
||||
|
||||
#if defined(PDM_CLOCK)
|
||||
STR(AUTO_TRIM)
|
||||
STR(GET_READY)
|
||||
STR(MIC_RECORDER)
|
||||
STR(OPEN_ERROR)
|
||||
@@ -242,6 +243,8 @@ STR(SAVE_AS)
|
||||
STR(SAVED)
|
||||
STR(STARTING_IN)
|
||||
STR(STOP)
|
||||
STR(TRIM_END)
|
||||
STR(TRIM_START)
|
||||
#endif
|
||||
|
||||
#else // !COLORLCD
|
||||
|
||||
Reference in New Issue
Block a user