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:
3djc
2026-08-26 10:42:23 +10:00
committed by GitHub
parent 94d5a89e33
commit ccc838739b
26 changed files with 890 additions and 68 deletions
@@ -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
View File
@@ -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;
}
+35 -7
View File
@@ -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.
+3
View File
@@ -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 "正在停止..."
+3
View File
@@ -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..."
+3
View File
@@ -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..."
+3
View File
@@ -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..."
+3
View File
@@ -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..."
+3
View File
@@ -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..."
+3
View File
@@ -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..."
+3
View File
@@ -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..."
+3
View File
@@ -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 "...עוצר"
+3
View File
@@ -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..."
+3
View File
@@ -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 "停止中..."
+3
View File
@@ -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 "수신기 전원 끄기"
+3
View File
@@ -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..."
+3
View File
@@ -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..."
+3
View File
@@ -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..."
+3
View File
@@ -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 "Остановка..."
+3
View File
@@ -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..."
+3
View File
@@ -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 "正在停止..."
+3
View File
@@ -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 "Зупинка..."
+3
View File
@@ -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
+3
View File
@@ -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