feat: melody assignment per channel/DM, dual ringtone slots, volume cleanup

Add second ringtone slot (ringtone2_*) to NodePrefs and DataStore.
Add per-channel and per-DM melody assignment (built-in / melody 1 / 2)
stored in NodePrefs bitmasks and DmMelodyEntry table.
Settings screen exposes DM/CH sound items; RingtoneEditorScreen supports
switching between slots. notify() uses buildMelodyFromPrefs() to select
the correct RTTTL string per contact.

Remove broken nRF52 volume control from buzzer.cpp: tone() uses
NRF_PWM2 with DECODER.MODE=Auto so TASKS_NEXTSTEP has no effect and
duty cannot be changed before the first DMA read without patching the
Arduino core. applyVolume() is now a no-op on nRF52.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-15 11:43:38 +02:00
parent 39731bb7fa
commit b3c6416874
6 changed files with 234 additions and 26 deletions

View File

@@ -270,6 +270,22 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
file.read((uint8_t *)_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields));
if (file.available()) {
file.read((uint8_t *)&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec));
if (file.available()) {
file.read((uint8_t *)&_prefs.ringtone2_bpm_idx, sizeof(_prefs.ringtone2_bpm_idx));
file.read((uint8_t *)&_prefs.ringtone2_len, sizeof(_prefs.ringtone2_len));
file.read((uint8_t *)_prefs.ringtone2_notes, sizeof(_prefs.ringtone2_notes));
if (file.available()) {
file.read((uint8_t *)&_prefs.notif_melody_dm, sizeof(_prefs.notif_melody_dm));
file.read((uint8_t *)&_prefs.notif_melody_ch, sizeof(_prefs.notif_melody_ch));
if (file.available()) {
file.read((uint8_t *)&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set));
file.read((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2));
if (file.available()) {
file.read((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody));
}
}
}
}
}
}
}
@@ -346,6 +362,14 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif));
file.write((uint8_t *)_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields));
file.write((uint8_t *)&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec));
file.write((uint8_t *)&_prefs.ringtone2_bpm_idx, sizeof(_prefs.ringtone2_bpm_idx));
file.write((uint8_t *)&_prefs.ringtone2_len, sizeof(_prefs.ringtone2_len));
file.write((uint8_t *)_prefs.ringtone2_notes, sizeof(_prefs.ringtone2_notes));
file.write((uint8_t *)&_prefs.notif_melody_dm, sizeof(_prefs.notif_melody_dm));
file.write((uint8_t *)&_prefs.notif_melody_ch, sizeof(_prefs.notif_melody_ch));
file.write((uint8_t *)&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set));
file.write((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2));
file.write((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody));
file.close();
}

View File

@@ -62,4 +62,18 @@ struct NodePrefs { // persisted to file
DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes
uint8_t dashboard_fields[3]; // 0=None,1=Batt,2=Temp,3=Hum,4=Pres,5=GPS,6=Alt,7=Lux,8=CO2,9=Nodes
uint32_t advert_auto_interval_sec; // periodic 0-hop advert with GPS: 0=off, else seconds
// Second melody slot (same packing as ringtone_*)
uint8_t ringtone2_bpm_idx;
uint8_t ringtone2_len;
uint8_t ringtone2_notes[32];
// Global melody for notifications: 0=built-in, 1=melody1, 2=melody2
uint8_t notif_melody_dm;
uint8_t notif_melody_ch;
// Per-channel melody override (2 bitmasks, 1 bit per channel)
uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody
uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set)
// Per-DM melody table
struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2
static const int DM_MELODY_TABLE_MAX = 16;
DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX];
};

View File

@@ -21,6 +21,7 @@ class RingtoneEditorScreen : public UIScreen {
uint8_t _notes[MAX_NOTES];
uint8_t _len;
uint8_t _bpm_idx;
int _slot; // 0=melody1, 1=melody2
int _cursor;
int _scroll;
bool _menu_open;
@@ -29,7 +30,7 @@ class RingtoneEditorScreen : public UIScreen {
char _play_buf[220]; // persistent RTTTL buffer — library holds a pointer into it
enum MenuOpt {
M_PLAY_STOP, M_DURATION, M_BPM_UP, M_BPM_DOWN,
M_PLAY_STOP, M_SWITCH, M_DURATION, M_BPM_UP, M_BPM_DOWN,
M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT
};
static const char* MENU_LABELS[M_COUNT];
@@ -71,12 +72,16 @@ class RingtoneEditorScreen : public UIScreen {
}
public:
RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs), _slot(0) {}
void enter() {
_bpm_idx = (_prefs && _prefs->ringtone_bpm_idx < 5) ? _prefs->ringtone_bpm_idx : 2;
_len = (_prefs && _prefs->ringtone_len <= (uint8_t)MAX_NOTES) ? _prefs->ringtone_len : 0;
if (_prefs) memcpy(_notes, _prefs->ringtone_notes, sizeof(_notes));
void enter(int slot = 0) {
_slot = (slot == 1) ? 1 : 0;
bool s2 = (_slot == 1);
_bpm_idx = (_prefs && (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) < 5)
? (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) : 2;
uint8_t rlen = _prefs ? (s2 ? _prefs->ringtone2_len : _prefs->ringtone_len) : 0;
_len = (rlen <= (uint8_t)MAX_NOTES) ? rlen : 0;
if (_prefs) memcpy(_notes, s2 ? _prefs->ringtone2_notes : _prefs->ringtone_notes, sizeof(_notes));
_cursor = 0;
_scroll = 0;
_menu_open = false;
@@ -89,7 +94,7 @@ public:
display.setColor(DisplayDriver::LIGHT);
char hdr[32];
snprintf(hdr, sizeof(hdr), "BPM:%u %d/%d", BPM_OPTS[_bpm_idx], _len, MAX_NOTES);
snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, BPM_OPTS[_bpm_idx], _len, MAX_NOTES);
display.setCursor(0, 0);
display.print(hdr);
display.fillRect(0, 10, display.width(), 1);
@@ -171,6 +176,8 @@ public:
display.setCursor(mx + 4, iy);
if (item == M_PLAY_STOP)
display.print(_task->isMelodyPlaying() ? "Stop" : "Play");
else if (item == M_SWITCH)
display.print(_slot == 0 ? "Edit Melody 2" : "Edit Melody 1");
else
display.print(MENU_LABELS[item]);
display.setColor(DisplayDriver::LIGHT);
@@ -215,6 +222,10 @@ public:
_task->playMelody(_play_buf);
}
break;
case M_SWITCH:
_task->stopMelody();
this->enter(1 - _slot);
break;
case M_DURATION:
if (_cursor < _len) {
uint8_t p = notePitch(_notes[_cursor]);
@@ -246,9 +257,15 @@ public:
break;
case M_SAVE:
if (_prefs) {
_prefs->ringtone_bpm_idx = _bpm_idx;
_prefs->ringtone_len = _len;
memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes));
if (_slot == 1) {
_prefs->ringtone2_bpm_idx = _bpm_idx;
_prefs->ringtone2_len = _len;
memcpy(_prefs->ringtone2_notes, _notes, sizeof(_notes));
} else {
_prefs->ringtone_bpm_idx = _bpm_idx;
_prefs->ringtone_len = _len;
memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes));
}
the_mesh.savePrefs();
}
_task->stopMelody();
@@ -313,5 +330,5 @@ const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 };
const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" };
const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' };
const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = {
"Play/Stop", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard"
"Play/Stop", "Switch Melody", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard"
};

View File

@@ -115,6 +115,8 @@ class SettingsScreen : public UIScreen {
SECTION_SOUND,
BUZZER,
BUZZER_VOLUME,
DM_MELODY,
CH_MELODY,
// Home pages section
SECTION_HOME_PAGES,
HOME_CLOCK, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT,
@@ -349,6 +351,18 @@ class SettingsScreen : public UIScreen {
display.setCursor(VAL_X, y);
display.print("N/A");
#endif
} else if (item == DM_MELODY) {
display.print("DM sound");
display.setCursor(VAL_X, y);
{ static const char* L[] = { "built-in", "M1", "M2" };
uint8_t v = p ? p->notif_melody_dm : 0;
display.print(L[v < 3 ? v : 0]); }
} else if (item == CH_MELODY) {
display.print("Ch sound");
display.setCursor(VAL_X, y);
{ static const char* L[] = { "built-in", "M1", "M2" };
uint8_t v = p ? p->notif_melody_ch : 0;
display.print(L[v < 3 ? v : 0]); }
} else if (isHomePage(item)) {
display.print(homePageLabel(item));
display.setCursor(VAL_X, y);
@@ -513,6 +527,14 @@ public:
#endif
return right || left;
}
if (_selected == DM_MELODY && p && (left || right || enter)) {
p->notif_melody_dm = (p->notif_melody_dm + (left ? 2 : 1)) % 3;
_dirty = true; return true;
}
if (_selected == CH_MELODY && p && (left || right || enter)) {
p->notif_melody_ch = (p->notif_melody_ch + (left ? 2 : 1)) % 3;
_dirty = true; return true;
}
if (isHomePage(_selected) && p && (left || right || enter)) {
p->home_pages_mask ^= homePageBit(_selected);
_dirty = true;
@@ -636,6 +658,7 @@ class QuickMsgScreen : public UIScreen {
PopupMenu _ctx_menu;
bool _ctx_dirty;
char _ctx_notif_item[22];
char _ctx_melody_item[20];
struct ChHistEntry { uint8_t ch_idx; char text[140]; };
ChHistEntry _hist[CH_HIST_MAX];
@@ -884,6 +907,61 @@ class QuickMsgScreen : public UIScreen {
p->dm_notif[0].state = state;
}
uint8_t chNotifMelody(uint8_t ch_idx) const {
NodePrefs* p = _task->getNodePrefs();
if (!p) return 0;
uint64_t mask = 1ULL << ch_idx;
if (!(p->ch_notif_melody_set & mask)) return 0;
return (p->ch_notif_melody_2 & mask) ? 2 : 1;
}
void setChNotifMelody(uint8_t ch_idx, uint8_t slot) {
NodePrefs* p = _task->getNodePrefs();
if (!p) return;
uint64_t mask = 1ULL << ch_idx;
if (slot == 0) {
p->ch_notif_melody_set &= ~mask;
p->ch_notif_melody_2 &= ~mask;
} else if (slot == 1) {
p->ch_notif_melody_set |= mask;
p->ch_notif_melody_2 &= ~mask;
} else {
p->ch_notif_melody_set |= mask;
p->ch_notif_melody_2 |= mask;
}
}
uint8_t dmMelodySlot(const uint8_t* pub_key) const {
NodePrefs* p = _task->getNodePrefs();
if (!p) return 0;
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++)
if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0)
return p->dm_melody[i].slot;
return 0;
}
void setDmMelody(const uint8_t* pub_key, uint8_t slot) {
NodePrefs* p = _task->getNodePrefs();
if (!p) return;
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) {
if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) {
if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i]));
else p->dm_melody[i].slot = slot;
return;
}
}
if (slot == 0) return;
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) {
if (p->dm_melody[i].slot == 0) {
memcpy(p->dm_melody[i].prefix, pub_key, 4);
p->dm_melody[i].slot = slot;
return;
}
}
memcpy(p->dm_melody[0].prefix, pub_key, 4);
p->dm_melody[0].slot = slot;
}
public:
QuickMsgScreen(UITask* task)
: _task(task), _phase(MODE_SELECT), _mode_sel(0),
@@ -1355,10 +1433,14 @@ public:
if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) {
if (_ctx_menu.selectedIndex() == 0) {
_task->clearDMUnread(ci.id.pub_key);
} else {
} else if (_ctx_menu.selectedIndex() == 1) {
uint8_t nstate = dmNotifState(ci.id.pub_key);
setDmNotifState(ci.id.pub_key, (nstate + 1) % 3);
_ctx_dirty = true;
} else {
uint8_t slot = dmMelodySlot(ci.id.pub_key);
setDmMelody(ci.id.pub_key, (slot + 1) % 3);
_ctx_dirty = true;
}
}
}
@@ -1394,9 +1476,13 @@ public:
the_mesh.getContactByIdx(_sorted[_contact_sel], ci);
snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s",
NOTIF_LABELS[dmNotifState(ci.id.pub_key)]);
_ctx_menu.begin("Contact options", 2);
{ static const char* ML[] = { "global", "M1", "M2" };
snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s",
ML[dmMelodySlot(ci.id.pub_key)]); }
_ctx_menu.begin("Contact options", 3);
_ctx_menu.addItem("Mark as read");
_ctx_menu.addItem(_ctx_notif_item);
_ctx_menu.addItem(_ctx_melody_item);
_ctx_dirty = false;
return true;
}
@@ -1409,10 +1495,14 @@ public:
uint8_t ch_idx = _channel_indices[_channel_sel];
if (_ctx_menu.selectedIndex() == 0) {
_ch_unread[ch_idx] = 0;
} else {
} else if (_ctx_menu.selectedIndex() == 1) {
uint8_t nstate = chNotifState(ch_idx);
setChNotifState(ch_idx, (nstate + 1) % 3);
_ctx_dirty = true;
} else {
uint8_t slot = chNotifMelody(ch_idx);
setChNotifMelody(ch_idx, (slot + 1) % 3);
_ctx_dirty = true;
}
}
if (res != PopupMenu::NONE && _ctx_dirty) {
@@ -1447,9 +1537,13 @@ public:
static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" };
snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s",
NOTIF_LABELS[chNotifState(ch_idx)]);
_ctx_menu.begin("Channel options", 2);
{ static const char* ML[] = { "global", "M1", "M2" };
snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s",
ML[chNotifMelody(ch_idx)]); }
_ctx_menu.begin("Channel options", 3);
_ctx_menu.addItem("Mark all read");
_ctx_menu.addItem(_ctx_notif_item);
_ctx_menu.addItem(_ctx_melody_item);
_ctx_dirty = false;
return true;
}
@@ -2290,8 +2384,8 @@ void UITask::gotoToolsScreen() {
setCurrScreen(tools_screen);
}
void UITask::gotoRingtoneEditor() {
((RingtoneEditorScreen*)ringtone_edit)->enter();
void UITask::gotoRingtoneEditor(int slot) {
((RingtoneEditorScreen*)ringtone_edit)->enter(slot);
setCurrScreen(ringtone_edit);
}
@@ -2365,6 +2459,27 @@ void UITask::showAlert(const char* text, int duration_millis) {
_alert_expiry = millis() + duration_millis;
}
static void buildMelodyFromPrefs(const NodePrefs* p, int slot, char* buf, int size) {
static const uint16_t BPM_OPTS[] = { 60, 90, 120, 150, 180 };
static const uint8_t DUR_VALS[] = { 4, 8, 16, 32 };
static const char PITCHES[] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' };
const uint8_t* notes = (slot == 2) ? p->ringtone2_notes : p->ringtone_notes;
uint8_t len = (slot == 2) ? p->ringtone2_len : p->ringtone_len;
uint8_t bpm_i = (slot == 2) ? p->ringtone2_bpm_idx : p->ringtone_bpm_idx;
if (len == 0) { buf[0] = '\0'; return; }
uint16_t bpm = BPM_OPTS[bpm_i < 5 ? bpm_i : 2];
int pos = snprintf(buf, size, "Ring:d=8,o=5,b=%u:", bpm);
for (int i = 0; i < len && pos < size - 8; i++) {
if (i > 0 && pos < size - 1) buf[pos++] = ',';
uint8_t pitch = notes[i] & 0x07;
uint8_t octave = ((notes[i] >> 3) & 0x03) + 4;
uint8_t dur_val = DUR_VALS[(notes[i] >> 5) & 0x03];
if (pitch == 0) pos += snprintf(buf + pos, size - pos, "%dp", dur_val);
else pos += snprintf(buf + pos, size - pos, "%d%c%d", dur_val, PITCHES[pitch], octave);
}
if (pos < size) buf[pos] = '\0';
}
void UITask::notify(UIEventType t) {
#if defined(PIN_BUZZER)
switch(t){
@@ -2387,8 +2502,25 @@ switch(t){
}
_last_notif_dm_valid = false;
if (play) {
if (force) buzzer.playForced("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
else buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
int slot = _node_prefs ? (int)_node_prefs->notif_melody_dm : 0;
if (_node_prefs) {
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++)
if (_node_prefs->dm_melody[i].slot &&
memcmp(_node_prefs->dm_melody[i].prefix, _last_notif_dm_prefix, 4) == 0)
{ slot = _node_prefs->dm_melody[i].slot; break; }
}
bool custom_played = false;
if (slot > 0 && _node_prefs) {
buildMelodyFromPrefs(_node_prefs, slot, _notif_mel_buf, sizeof(_notif_mel_buf));
if (_notif_mel_buf[0]) {
if (force) buzzer.playForced(_notif_mel_buf); else buzzer.play(_notif_mel_buf);
custom_played = true;
}
}
if (!custom_played) {
if (force) buzzer.playForced("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
else buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
}
}
break;
}
@@ -2398,19 +2530,34 @@ switch(t){
if (_last_notif_ch_idx >= 0 && _node_prefs) {
uint64_t mask = 1ULL << _last_notif_ch_idx;
if (_node_prefs->ch_notif_override & mask) {
if (!(_node_prefs->ch_notif_muted & mask)) { play = true; force = true; } // state 2: force-on
// state 1: muted — play stays false
if (!(_node_prefs->ch_notif_muted & mask)) { play = true; force = true; }
} else {
play = !buzzer.isQuiet(); // state 0: follow global
play = !buzzer.isQuiet();
}
} else {
play = !buzzer.isQuiet();
}
_last_notif_ch_idx = -1;
if (play) {
if (force) buzzer.playForced("kerplop:d=16,o=6,b=120:32g#,32c#");
else buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#");
int slot = _node_prefs ? (int)_node_prefs->notif_melody_ch : 0;
if (_last_notif_ch_idx >= 0 && _node_prefs) {
uint64_t mask = 1ULL << _last_notif_ch_idx;
if (_node_prefs->ch_notif_melody_set & mask)
slot = (_node_prefs->ch_notif_melody_2 & mask) ? 2 : 1;
}
bool custom_played = false;
if (slot > 0 && _node_prefs) {
buildMelodyFromPrefs(_node_prefs, slot, _notif_mel_buf, sizeof(_notif_mel_buf));
if (_notif_mel_buf[0]) {
if (force) buzzer.playForced(_notif_mel_buf); else buzzer.play(_notif_mel_buf);
custom_played = true;
}
}
if (!custom_played) {
if (force) buzzer.playForced("kerplop:d=16,o=6,b=120:32g#,32c#");
else buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#");
}
}
_last_notif_ch_idx = -1;
break;
}
case UIEventType::ack:

View File

@@ -34,6 +34,7 @@ class UITask : public AbstractUITask {
unsigned long _next_refresh, _auto_off;
NodePrefs* _node_prefs;
char _alert[80];
char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies
unsigned long _alert_expiry;
int _msgcount;
int _room_unread;
@@ -99,7 +100,7 @@ public:
void gotoSettingsScreen();
void gotoQuickMsgScreen();
void gotoToolsScreen();
void gotoRingtoneEditor();
void gotoRingtoneEditor(int slot = 0);
void gotoBotScreen();
void gotoNearbyScreen();
void gotoDashboardConfig();

View File

@@ -32,11 +32,16 @@ bool genericBuzzer::isPlaying() {
}
void genericBuzzer::applyVolume() {
#if !defined(NRF52_PLATFORM)
// After tone() sets 50% duty, analogWrite overrides duty cycle on the same PWM channel.
// Level 4 = 50% (leave tone as-is), lower levels reduce duty = quieter output.
static const uint8_t duty[] = { 6, 20, 50, 90, 128 };
uint8_t d = duty[_volume_level < 5 ? _volume_level : 4];
if (d < 128) analogWrite(PIN_BUZZER, d);
#endif
// On nRF52, tone() uses NRF_PWM2 with DECODER.MODE=Auto. The DMA reads duty at SEQSTART
// before software can write, and TASKS_NEXTSTEP only works in NextStep mode — so duty
// cannot be changed mid-note without patching the Arduino core. Volume has no effect.
}
void genericBuzzer::setVolume(uint8_t level) {