From ccc838739baff94bfddc21763e6efc88ef0feb8f Mon Sep 17 00:00:00 2001 From: 3djc <5167938+3djc@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:42:23 +0200 Subject: [PATCH] feat(mic): add start/end trim, auto trim and clip last bit of noise when tapping or clicking stop (#7706) --- .../src/boards/rm-h750/pdm_software_driver.h | 7 +- .../gui/colorlcd/radio/radio_mic_recorder.cpp | 393 ++++++++++++++-- .../gui/colorlcd/radio/radio_mic_recorder.h | 19 +- radio/src/pdm_wav_recorder.cpp | 434 ++++++++++++++++-- radio/src/pdm_wav_recorder.h | 42 +- radio/src/translations/i18n/cn.h | 3 + radio/src/translations/i18n/cz.h | 3 + radio/src/translations/i18n/da.h | 3 + radio/src/translations/i18n/de.h | 3 + radio/src/translations/i18n/en.h | 3 + radio/src/translations/i18n/es.h | 3 + radio/src/translations/i18n/fi.h | 3 + radio/src/translations/i18n/fr.h | 3 + radio/src/translations/i18n/he.h | 3 + radio/src/translations/i18n/it.h | 3 + radio/src/translations/i18n/jp.h | 3 + radio/src/translations/i18n/ko.h | 3 + radio/src/translations/i18n/nl.h | 3 + radio/src/translations/i18n/pl.h | 3 + radio/src/translations/i18n/pt.h | 3 + radio/src/translations/i18n/ru.h | 3 + radio/src/translations/i18n/se.h | 3 + radio/src/translations/i18n/tw.h | 3 + radio/src/translations/i18n/ua.h | 3 + radio/src/translations/sim_string_list.h | 3 + radio/src/translations/string_list.h | 3 + 26 files changed, 890 insertions(+), 68 deletions(-) diff --git a/radio/src/boards/rm-h750/pdm_software_driver.h b/radio/src/boards/rm-h750/pdm_software_driver.h index d7833470d..32780124f 100644 --- a/radio/src/boards/rm-h750/pdm_software_driver.h +++ b/radio/src/boards/rm-h750/pdm_software_driver.h @@ -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 diff --git a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp index b71fea527..587aae128 100644 --- a/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp +++ b/radio/src/gui/colorlcd/radio/radio_mic_recorder.cpp @@ -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 onMove, std::function onConfirm, + std::function 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 moveHandler; + std::function confirmHandler; + std::function 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)); diff --git a/radio/src/gui/colorlcd/radio/radio_mic_recorder.h b/radio/src/gui/colorlcd/radio/radio_mic_recorder.h index 6ea703f50..d27c9ed69 100644 --- a/radio/src/gui/colorlcd/radio/radio_mic_recorder.h +++ b/radio/src/gui/colorlcd/radio/radio_mic_recorder.h @@ -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(); diff --git a/radio/src/pdm_wav_recorder.cpp b/radio/src/pdm_wav_recorder.cpp index 6bebe66cc..4677744e1 100644 --- a/radio/src/pdm_wav_recorder.cpp +++ b/radio/src/pdm_wav_recorder.cpp @@ -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; } diff --git a/radio/src/pdm_wav_recorder.h b/radio/src/pdm_wav_recorder.h index bca5e8eb4..645ff8016 100644 --- a/radio/src/pdm_wav_recorder.h +++ b/radio/src/pdm_wav_recorder.h @@ -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. diff --git a/radio/src/translations/i18n/cn.h b/radio/src/translations/i18n/cn.h index 4a1aac51c..c3155682b 100644 --- a/radio/src/translations/i18n/cn.h +++ b/radio/src/translations/i18n/cn.h @@ -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 "正在停止..." diff --git a/radio/src/translations/i18n/cz.h b/radio/src/translations/i18n/cz.h index 98cb8e042..c8568b963 100644 --- a/radio/src/translations/i18n/cz.h +++ b/radio/src/translations/i18n/cz.h @@ -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..." diff --git a/radio/src/translations/i18n/da.h b/radio/src/translations/i18n/da.h index 4b1f5584f..4a76c9013 100644 --- a/radio/src/translations/i18n/da.h +++ b/radio/src/translations/i18n/da.h @@ -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..." diff --git a/radio/src/translations/i18n/de.h b/radio/src/translations/i18n/de.h index e92705f94..c6561d223 100644 --- a/radio/src/translations/i18n/de.h +++ b/radio/src/translations/i18n/de.h @@ -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..." diff --git a/radio/src/translations/i18n/en.h b/radio/src/translations/i18n/en.h index f56588896..73859259c 100644 --- a/radio/src/translations/i18n/en.h +++ b/radio/src/translations/i18n/en.h @@ -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..." diff --git a/radio/src/translations/i18n/es.h b/radio/src/translations/i18n/es.h index 9067d48c4..20ec8559a 100644 --- a/radio/src/translations/i18n/es.h +++ b/radio/src/translations/i18n/es.h @@ -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..." diff --git a/radio/src/translations/i18n/fi.h b/radio/src/translations/i18n/fi.h index e7efbe88f..b04e5503f 100644 --- a/radio/src/translations/i18n/fi.h +++ b/radio/src/translations/i18n/fi.h @@ -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..." diff --git a/radio/src/translations/i18n/fr.h b/radio/src/translations/i18n/fr.h index b233ca8e4..e95990ab4 100644 --- a/radio/src/translations/i18n/fr.h +++ b/radio/src/translations/i18n/fr.h @@ -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..." diff --git a/radio/src/translations/i18n/he.h b/radio/src/translations/i18n/he.h index c20b76919..25606372d 100644 --- a/radio/src/translations/i18n/he.h +++ b/radio/src/translations/i18n/he.h @@ -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 "...עוצר" diff --git a/radio/src/translations/i18n/it.h b/radio/src/translations/i18n/it.h index 44858f36d..000f24d54 100644 --- a/radio/src/translations/i18n/it.h +++ b/radio/src/translations/i18n/it.h @@ -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..." diff --git a/radio/src/translations/i18n/jp.h b/radio/src/translations/i18n/jp.h index 91b093aa6..b3b672669 100644 --- a/radio/src/translations/i18n/jp.h +++ b/radio/src/translations/i18n/jp.h @@ -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 "停止中..." diff --git a/radio/src/translations/i18n/ko.h b/radio/src/translations/i18n/ko.h index 16037e5ba..eeb06853b 100644 --- a/radio/src/translations/i18n/ko.h +++ b/radio/src/translations/i18n/ko.h @@ -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 "수신기 전원 끄기" diff --git a/radio/src/translations/i18n/nl.h b/radio/src/translations/i18n/nl.h index ae88cad81..f06b33181 100644 --- a/radio/src/translations/i18n/nl.h +++ b/radio/src/translations/i18n/nl.h @@ -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..." diff --git a/radio/src/translations/i18n/pl.h b/radio/src/translations/i18n/pl.h index 25e534bd2..2c2df9612 100644 --- a/radio/src/translations/i18n/pl.h +++ b/radio/src/translations/i18n/pl.h @@ -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..." diff --git a/radio/src/translations/i18n/pt.h b/radio/src/translations/i18n/pt.h index 17a2ac8c4..4d86a38a2 100644 --- a/radio/src/translations/i18n/pt.h +++ b/radio/src/translations/i18n/pt.h @@ -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..." diff --git a/radio/src/translations/i18n/ru.h b/radio/src/translations/i18n/ru.h index 9d5e6d523..c803ea784 100644 --- a/radio/src/translations/i18n/ru.h +++ b/radio/src/translations/i18n/ru.h @@ -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 "Остановка..." diff --git a/radio/src/translations/i18n/se.h b/radio/src/translations/i18n/se.h index 2cde513ca..040e895dc 100644 --- a/radio/src/translations/i18n/se.h +++ b/radio/src/translations/i18n/se.h @@ -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..." diff --git a/radio/src/translations/i18n/tw.h b/radio/src/translations/i18n/tw.h index 682645fc3..b1c51779c 100644 --- a/radio/src/translations/i18n/tw.h +++ b/radio/src/translations/i18n/tw.h @@ -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 "正在停止..." diff --git a/radio/src/translations/i18n/ua.h b/radio/src/translations/i18n/ua.h index 0f4bdf3a5..3eb15cb1b 100644 --- a/radio/src/translations/i18n/ua.h +++ b/radio/src/translations/i18n/ua.h @@ -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 "Зупинка..." diff --git a/radio/src/translations/sim_string_list.h b/radio/src/translations/sim_string_list.h index 56d7f022d..1173e651e 100644 --- a/radio/src/translations/sim_string_list.h +++ b/radio/src/translations/sim_string_list.h @@ -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 diff --git a/radio/src/translations/string_list.h b/radio/src/translations/string_list.h index 8f6a242b6..ec4030a36 100644 --- a/radio/src/translations/string_list.h +++ b/radio/src/translations/string_list.h @@ -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