Compare commits

..

50 Commits

Author SHA1 Message Date
yuquanjun 2d9f234a7d chore: refresh git_user_fw_ver for 0.2.12 build
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 22:10:36 +08:00
yuquanjun 61caeb7407 release: bump firmware version to 0.2.12
Include autoclose 关闭 glyph fix and recent normal-pick / HIL / flash-tool work in the release tag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 22:09:09 +08:00
yuquanjun eed22d4b8c fix(ui): add missing 闭 glyph for autoclose 关闭 label
Settings showed only 关 because GBK 闭 was absent from CHS font tables; append WenQuanYi bitmaps for all sizes and keep the patch helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 22:07:57 +08:00
yuquanjun c6be9fe88b chore: refresh git_user_fw_ver build id
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:49:37 +08:00
yuquanjun 8ac13ce6e9 tools: add MCU + ExtFlash ALL.res flash helper
Script flashes APP via J-Link then streams ALL.res over RTT flash all; include a minimal loadbin command file for APP-only updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:49:37 +08:00
yuquanjun cae4eb84a1 test: clearer BLE scan FAIL; RTT reset; longer UI ACK timeouts
Report missing Smart Guitar MIDI as an explicit FAIL instead of parse noise, reset target when opening RTT sessions, and give ui boot/charge more ACK time for LCD draws.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:49:37 +08:00
yuquanjun 4c6e2122ed fix: normal rhythm pick via piece; ACK UI before LCD; flash-busy power guard
Require fretboard in normal mode per manual; use SetPiecePlayMode for 1.bin. ACK ui boot/charge before full-screen draw, add RTT flash all and tone pick free, and skip soft-off while ExtFlash transfer is busy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:49:16 +08:00
yuquanjun d06e44bcb3 fix: keep power key alive on hang; refuse play when tone pack missing
TMR7 high-prio watchdog and fault-loop emergency cut/reset so soft-off still works if the main loop or AutoBand ISR dies; skip start when preset load fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 20:19:45 +08:00
yuquanjun f8aeb4fb7c release: 0.2.10 — tone pack 0914 (BIN3 after 2.bin), autoclose label 关闭
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 19:37:57 +08:00
yuquanjun 7fa2f5f1c4 release: bump firmware version to 0.2.9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 16:27:04 +08:00
yuquanjun 6da7d4d627 test: stabilize HIL suites — inter-suite reset, async tm1617 inject, ACK-first
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 16:16:11 +08:00
yuquanjun eb1e5cbe84 fix: map Finger bass timbre to MIDI PC 33 per tone table
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 15:59:01 +08:00
yuquanjun 28970fddf0 chore: drop committed __pycache__ and ignore Python bytecode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 15:05:49 +08:00
yuquanjun a495717883 test: unified K1 HIL harness + RTT inject hooks (tm1617/adc/bin2/sticky)
tools/k1_harness: one CLI over existing COM/BLE/RTT scripts covering
App SysEx 01-06, BLE smoke, TM1629/TM1617 key inject, 1/2/3.bin pack
checks with pick/outro regression, and UI nav (log status/TAP/DUMP).
Unified PASS/FAIL/SKIP/SENT markdown report to Doc/reports.

firmware hooks in app_log.c: 'tm1617 key N', 'adc key N on|off',
'tone bin2 [idx]', 'chord key N hold' (sticky inject survives physical
idle scan so normal-mode pick tests can hold a chord).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 15:05:17 +08:00
yuquanjun daee47a457 tools: add BLE/RTT/pick test and flash helper scripts
- ble_log_pull.py: pull field logs over BLE (midi/uart transport)
- rtt_flash_log_dump.py / log_decode.py: dump and decode RAM/flash logs via J-Link RTT
- _test_normal_pick.py: semi-automated normal-mode pick regression (RTT + physical pad)
- _ble_channel_matrix.py / _probe_ble_rtt.py / _usb_midi_sysex_probe.py: GATT/BLE/USB-MIDI diagnostics
- build_artist_kit.py: package artist tone kit
- flash_boot_app_v024.py: flash boot+app images

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 14:58:11 +08:00
yuquanjun f841d9e7b1 Normal mode: pick sounds held chord; release stops accompaniment.
Require left-hand pad held (PressFlag) before pick starts; skip the default I-chord fallback in normal mode. Release (key 0) stops AutoBand immediately instead of looping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 12:55:22 +08:00
yuquanjun 713a768733 Disable fretboard chord/stop side-effects in universal mode.
Ignore TM1629 keys 0-21 in universal so pad no longer changes KEY_ID or arms OutTime_Stop; keep tap-tempo and stop. Clear stale KEY_ID/PressFlag when entering universal tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 12:36:39 +08:00
yuquanjun 24261942d8 Map universal section-4 to Postamble outro and share AutoBand_StartOutro with ADC.
Exit piece mode before Postamble so universal chord loops can switch into the style outro stage once 3.bin has PostambleMIDI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 12:24:35 +08:00
yuquanjun 1cf6562236 Add bass/chord register mapping with RTT inject and bin1/3 probes.
Intercept AutoBand MIDI by channel (bass ch8, drum ch9), add chord-key RTT cmds, and ship scripts to verify 1.bin/3.bin pitch rules on device.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 21:21:11 +08:00
yuquanjun 91b270d856 Revert "Lower accompaniment by one octave for #IV and V-VII chords."
This reverts commit 439ccf439d.
2026-09-11 14:59:23 +08:00
yuquanjun 439ccf439d Lower accompaniment by one octave for #IV and V-VII chords.
AutoBand ignores input octave, so shift engine MIDI output by -12 for those chords (except drums) to keep the range from low #IV up to natural IV.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:08:09 +08:00
yuquanjun 2d62e1e41a Stop drawing status volume bar on mixer page to avoid erasing the back button.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 19:29:47 +08:00
yuquanjun 4aad989263 Reapply master volume at boot/mode-entry/play-start and sync volume bar on settings hub.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:58:24 +08:00
yuquanjun 295a2a6fa3 Fix song-name trailing ghost by clearing the full text row before redraw.
When switching from a longer title to a shorter one, the old gradient-only clear left uncovered glyph fragments outside the pill bounds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 10:46:11 +08:00
yuquanjun b65b762d92 Refresh git_user_fw_ver.h after 0.2.8 release rebuild.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 07:03:20 +08:00
yuquanjun 30c4ea9c0d Refresh git_user_fw_ver.h for 0.2.8 release commit.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 07:02:22 +08:00
yuquanjun 05ccf28a4b Release 0.2.8: harden BLE SysEx receive path for app stability.
Bump firmware version after BT recv stack/log fixes and pitch/chord bounds so Phoenix Atelier map/LED traffic no longer stack-smashes the MCU.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 07:02:22 +08:00
yuquanjun 59c11f3f93 Fix BT recv stack smash and harden pitch/chord SysEx paths.
Enlarge BT recv stack, move large handlers/log buffers off the stack, clamp key indexes, and yield in W25Q busy-wait so BLE protocol tests stay stable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 06:51:32 +08:00
yuquanjun 24ffe87527 Refresh bottom nav with 0909 enlarged icons and aligned label art.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 20:24:39 +08:00
yuquanjun 5bb403cd11 Add artist self-serve tone pack tool for USB IAP updates of 1/2/3.bin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 16:37:29 +08:00
yuquanjun a818065c2e Fix mixer knob outline at max volume by seating the knob on the track capsule ends.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 16:33:03 +08:00
yuquanjun c1b32c0351 Refresh git_user_fw_ver.h for 0.2.7 release commit.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 12:18:00 +08:00
yuquanjun bb6d9ae7f9 Release 0.2.7: refresh rhythm 1.bin pack with fixed BIN3 layout checks.
Update pack/publish to enforce 41KB song limit, verify 1/2/3.bin at fixed addresses, and emit .res aliases for the tone and ALL extflash images.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 12:17:42 +08:00
yuquanjun 99f7ed3e5a Pin BIN3 to fixed 0xA71AC and reject packs where 2.bin drift shifts it.
Variable 2.bin length previously moved 3.bin; firmware still read 0xA71AC and saw zeros. Pack now pads to the fixed slot and publish verifies DAB headers before shipping ALL.res.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 11:47:15 +08:00
yuquanjun cd21e4e45e Always enable loop on universal-mode pick so accompaniment can play.
Without SetLoopMode the piece path often produced no audible output when the chord pad was not held.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 11:35:14 +08:00
yuquanjun 92e5e32de3 Diagnose BIN3 LoadPreset failures and add flash bin3 tooling.
Log Flash magic on load failure, expose RTT flash bin3, and document the 0903 pack offset for the universal bank.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 11:32:40 +08:00
yuquanjun 015417dc3d Enlarge bottom nav touch zones to full-width 60x55 cells under the section buttons.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 11:01:40 +08:00
yuquanjun 6f2f7b6b75 Remove mode-select top stripe cover that left an extra line.
The ROW_BG full-width fill over the universal row top corners showed as a second separator under the status bar; source art no longer needs it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 08:00:06 +08:00
yuquanjun ba110565f1 Use chevron asset for factory-reset row instead of solid triangle.
Matches 0902-07: restore is a drill-in row with thin > (ICON_CHEVRON), not the prev/next filled triangle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 07:50:08 +08:00
yuquanjun ad0d990c55 Center indexed param/timbre labels as one block and nudge section digits.
Fixes the left-biased index+name layout, unifies index-name gap across rows, and shifts play-section numbers +2px per UI markup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 07:34:48 +08:00
yuquanjun f1f9520637 Fix settings BT/restore icons and soft-off BLE protocol plumbing.
Point system-settings rows at the correct Flash icons, keep Main/UI alive across soft-off, and expose git build id for 01 0C with matching BLE tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 07:03:52 +08:00
yuquanjun 57448b3da8 Harden BLE protocol test for Windows pairing and flaky notifies.
Default --unpair on Windows, probe/reconnect on first timeout, resync FrameParser on duplicate F0, and retry query once.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 22:14:18 +08:00
yuquanjun 0aedb45e6e Make BLE SysEx UID and fw-code MIDI-safe for BLE-MIDI.
Encode 01 07 UID as hex ASCII and use 01 0F (keep 01 FF alias) so high-bit bytes are not dropped as timestamps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 22:02:06 +08:00
yuquanjun 7b08f5e098 Start BLE UART4 App protocol receive on power-on.
TaskBTRecvThread previously started only after mode select, so GATT SysEx never reached the parser until then; verified BLE-MIDI 01 01 now replies with GATT notify.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 21:46:14 +08:00
yuquanjun f762348883 Merge branch 'main' into develop 2026-09-08 18:15:45 +08:00
yuquanjun 8febbd7742 Align BLE SysEx style/transpose replies with Doc protocol and add BLE test harness.
Drop ineffective Dream BT rename SysEx; fix 03 04 boot/list/total and 03 07 read length.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 18:05:04 +08:00
yuquanjun bd22246942 Use AT32 UID for unique PHON_ name and push it to Dream over SysEx.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 12:37:29 +08:00
yuquanjun 725ac495ba Merge main: bring Release 0.2.5 (2ss.bin tone pack) into develop.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 10:18:06 +08:00
yuquanjun 01bc2a592c Implement full BLE SysEx protocol handlers and app-sim test.
Wire missing 01/02/03/05 commands, fix chord-map reply and section/End dispatch, and add UART4 protocol regression script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 10:00:55 +08:00
yuquanjun a6259b5fca Release 0.2.4: package Boot with UI0902 flash-mode screen.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 05:51:58 +08:00
92 changed files with 9453 additions and 1307 deletions

4
.gitignore vendored
View File

@ -40,3 +40,7 @@ Desktop.ini
*.key
*.pem
credentials.json
# Python
__pycache__/
*.pyc

View File

@ -303,6 +303,14 @@ void app_adc_sync_volume(uint16_t val)
app_adc_volume_map_level(val);
}
/* 取当前主音量(复用扫描任务维护的最近一次有效值)。
* ADC
* vol_last0xFFFF */
uint8_t app_adc_get_volume(void)
{
return (vol_last > 127) ? 0 : (uint8_t)vol_last;
}
static void app_adc_volume_process(void)
{
uint16_t val = ADC_ReadChannel(ADC_CHANNEL_1)>>5;

View File

@ -27,4 +27,6 @@ void app_adc_in1_scan_part(uint8_t channel);
bool app_adc_usb_is_plugged(void);
/* 开机/主动采样后同步 vol_last 与 level避免扫描任务误触发或跳变 */
void app_adc_sync_volume(uint16_t val);
/* 取当前主音量0~127供 MasterVolume_Reapply 等重发场景使用 */
uint8_t app_adc_get_volume(void);
#endif

View File

@ -11,7 +11,7 @@ static uint32_t s_overflow;
static uint32_t s_boot_count;
static char s_ui_page[16] = "boot";
static char s_debug_level = 'D';
static char s_cmd_buf[48];
static char s_cmd_buf[64];
static uint8_t s_cmd_len;
/* RTT 二进制烧录:写到 W25Q128ui0902 或本地曲目) */
@ -136,17 +136,36 @@ void app_log_set_ui_page(const char *name)
void app_log_write(char level, const char *cat, const char *fmt, ...)
{
char line[APP_LOG_SLOT_SIZE];
/* 静态行缓冲TaskBTRecv 等小栈线程禁止再开 128B 局部数组 */
static char line[APP_LOG_SLOT_SIZE];
static rt_mutex_t s_log_mtx = RT_NULL;
va_list ap;
int n;
/* TMR6 1ms ISR 会经 AutoBand NoteOn 回调进来:禁止在中断里 take mutex / 打 RTT */
if (rt_interrupt_get_nest() != 0) {
return;
}
if (level == 'D' && s_debug_level != 'D') {
return;
}
if (s_log_mtx == RT_NULL) {
s_log_mtx = rt_mutex_create("applog", RT_IPC_FLAG_PRIO);
}
if (s_log_mtx != RT_NULL) {
if (rt_mutex_take(s_log_mtx, rt_tick_from_millisecond(20)) != RT_EOK) {
return;
}
}
n = snprintf(line, sizeof(line), "[%05u][%c][%-4s] ",
(unsigned)app_log_ms(), level, cat);
if (n < 0) {
if (s_log_mtx != RT_NULL) {
rt_mutex_release(s_log_mtx);
}
return;
}
if ((size_t)n >= sizeof(line)) {
@ -160,6 +179,10 @@ void app_log_write(char level, const char *cat, const char *fmt, ...)
app_log_slot_store(line);
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "\n");
if (s_log_mtx != RT_NULL) {
rt_mutex_release(s_log_mtx);
}
}
static void app_log_dump_ram(void)
@ -274,12 +297,24 @@ uint8_t app_log_try_command(const char *cmd)
app_log_flash_start(size);
return 1U;
}
/* flash all <size> → W25Q128 @ 0x0tone+UI 合成 ALL.res */
if (strncmp(cmd, "flash all ", 10) == 0) {
uint32_t size = (uint32_t)strtoul(cmd + 10, NULL, 10);
app_log_flash_start_at(0U, size, "FLASH_ALL_GO", "FLASH_ALL_OK\n");
return 1U;
}
if (strncmp(cmd, "flash haitian ", 14) == 0) {
uint32_t size = (uint32_t)strtoul(cmd + 14, NULL, 10);
app_log_flash_start_at(FLASH_ADDR_SONG_HAITIAN, size,
"FLASH_HAITIAN_GO", "FLASH_HAITIAN_OK\n");
return 1U;
}
if (strncmp(cmd, "flash bin3 ", 11) == 0) {
uint32_t size = (uint32_t)strtoul(cmd + 11, NULL, 10);
app_log_flash_start_at(EXTFLASH_BIN3_UNIVERSAL_ADDR, size,
"FLASH_BIN3_GO", "FLASH_BIN3_OK\n");
return 1U;
}
if (strncmp(cmd, "tone status", 11) == 0) {
uint8_t hdr[16];
uint8_t name[40];
@ -320,6 +355,16 @@ uint8_t app_log_try_command(const char *cmd)
(unsigned long)(cur_hdr[12] | (cur_hdr[13] << 8) |
(cur_hdr[14] << 16) | (cur_hdr[15] << 24)));
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
{
uint8_t b3[16];
W25Q128_Read(b3, EXTFLASH_BIN3_UNIVERSAL_ADDR, 16);
snprintf(line, sizeof(line),
"TONE @BIN3 magic=%02X%02X%02X%02X cnt=%lu\n",
b3[0], b3[1], b3[2], b3[3],
(unsigned long)(b3[12] | (b3[13] << 8) |
(b3[14] << 16) | (b3[15] << 24)));
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
}
snprintf(line, sizeof(line),
"TONE @BIN2 magic=%02X%02X%02X%02X max=%lu cnt=%lu name=%.16s\n",
hdr[0], hdr[1], hdr[2], hdr[3],
@ -353,7 +398,7 @@ uint8_t app_log_try_command(const char *cmd)
UI_ApplyToneAddress();
AutoBandTop1_Stop();
StartFlag = 0;
ret = AutoBandTop1_LoadPresetItemFromFlash(0);
ret = App_Auto_LoadPresetFromFlash(0);
snprintf(line, sizeof(line),
"TONE_LOCAL ret=%d addr=0x%08lX map=BIN2@0x%08lX name=%s count=%d %s\n",
ret, (unsigned long)ADDRESS,
@ -364,16 +409,203 @@ uint8_t app_log_try_command(const char *cmd)
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
return 1U;
}
/* tone bin1 [idx] → 1.bin 节奏(专业+节奏类型) */
if (strncmp(cmd, "tone bin1", 9) == 0) {
char line[160];
int ret;
unsigned idx = 0;
int count;
if (cmd[9] == ' ' && cmd[10] != '\0')
idx = (unsigned)strtoul(cmd + 10, NULL, 10);
mGuiData[GUI_TAB_INDEX].Current = 1; /* 专业 */
mGuiData[GUI_AUTOBAND_SW].Current = 0; /* 节奏类型→1.bin */
ParamGuiData[SONG_MODE_PARAM].Current = (uint8_t)idx;
UI_ApplyToneAddress();
AutoBandTop1_Stop();
StartFlag = 0;
count = AutoBandTop1_GetPresetItemCount();
if (count > 0 && (int)idx >= count)
idx = (unsigned)(count - 1);
ParamGuiData[SONG_MODE_PARAM].Current = (uint8_t)idx;
ret = App_Auto_LoadPresetFromFlash((int)idx);
snprintf(line, sizeof(line),
"TONE_BIN1 ret=%d idx=%u addr=0x%08lX map=BIN1@0x%08lX name=%s count=%d %s\n",
ret, idx, (unsigned long)ADDRESS,
(unsigned long)EXTFLASH_BIN1_RHYTHM_ADDR,
AutoBandTop1_GetPresetName() ? AutoBandTop1_GetPresetName() : "(null)",
AutoBandTop1_GetPresetItemCount(),
(ADDRESS == EXTFLASH_BIN1_RHYTHM_ADDR) ? "OK" : "MISMATCH");
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
return 1U;
}
/* tone bin2 [idx] → 2.bin 本地曲目(专业+本地曲目,带索引扫描) */
if (strncmp(cmd, "tone bin2", 9) == 0) {
char line[160];
int ret;
unsigned idx = 0;
int count;
if (cmd[9] == ' ' && cmd[10] != '\0')
idx = (unsigned)strtoul(cmd + 10, NULL, 10);
mGuiData[GUI_TAB_INDEX].Current = 1; /* 专业 */
mGuiData[GUI_AUTOBAND_SW].Current = 1; /* 本地曲目→2.bin */
ParamGuiData[EXPRESS_MODE_PARAM].Current = (uint8_t)idx;
UI_ApplyToneAddress();
AutoBandTop1_Stop();
StartFlag = 0;
count = AutoBandTop1_GetPresetItemCount();
if (count > 0 && (int)idx >= count)
idx = (unsigned)(count - 1);
ParamGuiData[EXPRESS_MODE_PARAM].Current = (uint8_t)idx;
ret = App_Auto_LoadPresetFromFlash((int)idx);
snprintf(line, sizeof(line),
"TONE_BIN2 ret=%d idx=%u addr=0x%08lX map=BIN2@0x%08lX name=%s count=%d %s\n",
ret, idx, (unsigned long)ADDRESS,
(unsigned long)EXTFLASH_BIN2_SONG_HAITIAN_ADDR,
AutoBandTop1_GetPresetName() ? AutoBandTop1_GetPresetName() : "(null)",
AutoBandTop1_GetPresetItemCount(),
(ADDRESS == EXTFLASH_BIN2_SONG_HAITIAN_ADDR) ? "OK" : "MISMATCH");
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
return 1U;
}
/* tone bin3 [idx] → 3.bin 万能和弦走向 */
if (strncmp(cmd, "tone bin3", 9) == 0) {
char line[160];
int ret;
unsigned idx = 0;
int count;
if (cmd[9] == ' ' && cmd[10] != '\0')
idx = (unsigned)strtoul(cmd + 10, NULL, 10);
mGuiData[GUI_TAB_INDEX].Current = 0; /* 万能 */
mGuiData[GUI_AUTOBAND_SW].Current = 0;
ParamGuiData[ALL_MODE_PARAM].Current = (uint8_t)idx;
UI_ApplyToneAddress();
AutoBandTop1_Stop();
StartFlag = 0;
count = AutoBandTop1_GetPresetItemCount();
if (count > 0 && (int)idx >= count)
idx = (unsigned)(count - 1);
ParamGuiData[ALL_MODE_PARAM].Current = (uint8_t)idx;
ret = App_Auto_LoadPresetFromFlash((int)idx);
snprintf(line, sizeof(line),
"TONE_BIN3 ret=%d idx=%u addr=0x%08lX map=BIN3@0x%08lX name=%s count=%d %s\n",
ret, idx, (unsigned long)ADDRESS,
(unsigned long)EXTFLASH_BIN3_UNIVERSAL_ADDR,
AutoBandTop1_GetPresetName() ? AutoBandTop1_GetPresetName() : "(null)",
AutoBandTop1_GetPresetItemCount(),
(ADDRESS == EXTFLASH_BIN3_UNIVERSAL_ADDR) ? "OK" : "MISMATCH");
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
return 1U;
}
/* tone start在当前已加载音色上拨片起奏不改 TAB/库) */
if (strncmp(cmd, "tone start", 10) == 0) {
StartFlag = 0;
if (!(cmd[10] == ' ' && (cmd[11] == 'k' || cmd[11] == 'K')))
KEY_ID_1629 = 0;
Pick_Handle();
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "TONE_START_DONE\n");
return 1U;
}
if (strncmp(cmd, "tone pick", 9) == 0) {
/* 模拟专业+本地曲目拨片(无和弦板) */
/* tone pick uni → 万能tone pick free → 普通+节奏;默认专业+本地曲目 */
if (cmd[9] == ' ' && (cmd[10] == 'u' || cmd[10] == 'U')) {
mGuiData[GUI_TAB_INDEX].Current = 0;
mGuiData[GUI_AUTOBAND_SW].Current = 0;
UI_ApplyToneAddress();
AutoBandTop1_Stop();
StartFlag = 0;
(void)App_Auto_LoadPresetFromFlash(ParamGuiData[ALL_MODE_PARAM].Current);
KEY_ID_1629 = 0;
PressFlag = 0;
Pick_Handle();
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "TONE_PICK_UNI_DONE\n");
return 1U;
}
if (cmd[9] == ' ' && (cmd[10] == 'f' || cmd[10] == 'F' || cmd[10] == 'n' || cmd[10] == 'N')) {
/* 普通模式 + 节奏类型(1.bin);模拟已按指板 */
unsigned idx = ParamGuiData[SONG_MODE_PARAM].Current;
mGuiData[GUI_TAB_INDEX].Current = 2;
mGuiData[GUI_AUTOBAND_SW].Current = 0;
UI_ApplyToneAddress();
AutoBandTop1_Stop();
StartFlag = 0;
(void)App_Auto_LoadPresetFromFlash((int)idx);
if (!(cmd[11] == ' ' && (cmd[12] == 'k' || cmd[12] == 'K')))
KEY_ID_1629 = 1;
PressFlag = 1;
Pick_Handle();
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "TONE_PICK_FREE_DONE\n");
return 1U;
}
/* 模拟专业+本地曲目拨片chord pick keep → 保留已注入的 KEY_ID */
mGuiData[GUI_TAB_INDEX].Current = 1;
mGuiData[GUI_AUTOBAND_SW].Current = 1;
KEY_ID_1629 = 0; /* 走默认和弦兜底 */
if (!(cmd[9] == ' ' && (cmd[10] == 'k' || cmd[10] == 'K'))) {
KEY_ID_1629 = 0; /* 默认走一级和弦兜底 */
}
StartFlag = 0;
Pick_Handle();
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "TONE_PICK_DONE\n");
return 1U;
}
/* 测试注入chord key N0释放 / 1~21和弦 / 22拍速 / 23停止 */
if (strncmp(cmd, "chord key ", 10) == 0 && cmd[10] != '\0') {
unsigned key = (unsigned)strtoul(cmd + 10, NULL, 10);
char line[48];
int hold = (strstr(cmd + 10, "hold") != NULL);
if (key > 23U) {
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "CHORD_KEY_BAD\n");
return 1U;
}
if (hold)
app_tm1629_inject_hold((uint8_t)key);
else
app_tm1629_inject_key((uint8_t)key);
snprintf(line, sizeof(line), "CHORD_KEY_OK key=%u%s\n", key, hold ? " hold" : "");
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
return 1U;
}
/* 测试注入tm1617 key N0~3=MAIN_D/C/B/A 段落或导航键4=释放)
* ACK Handle0~3 RTT ACK */
if (strncmp(cmd, "tm1617 key ", 11) == 0 && cmd[11] != '\0') {
unsigned key = (unsigned)strtoul(cmd + 11, NULL, 10);
char line[48];
if (key > 4U) {
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "TM1617_KEY_BAD\n");
return 1U;
}
snprintf(line, sizeof(line), "TM1617_KEY_OK key=%u\n", key);
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
app_tm1617_inject_key((uint8_t)key);
return 1U;
}
/* 测试注入adc key N on|off0~31=独立尾奏键与万能第4键同路径 */
if (strncmp(cmd, "adc key ", 8) == 0 && cmd[8] != '\0') {
unsigned idx = (unsigned)strtoul(cmd + 8, NULL, 10);
char line[48];
int on = (strstr(cmd + 8, "on") != NULL);
if (idx > 3U || (!on && strstr(cmd + 8, "off") == NULL)) {
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "ADC_KEY_BAD\n");
return 1U;
}
snprintf(line, sizeof(line), "ADC_KEY_OK idx=%u on=%d\n", idx, on);
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
ADC_IN1_KEY_Handle((uint8_t)idx, on ? true : false);
return 1U;
}
/* 测试注入chord xpose N0~11C=0 */
if (strncmp(cmd, "chord xpose ", 12) == 0 && cmd[12] != '\0') {
unsigned xp = (unsigned)strtoul(cmd + 12, NULL, 10);
char line[48];
if (xp > 11U) {
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "CHORD_XPOSE_BAD\n");
return 1U;
}
mGuiData[GUI_TRANSPOSE].Current = (uint8_t)xp;
snprintf(line, sizeof(line), "CHORD_XPOSE_OK xp=%u\n", xp);
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
LOG_I("REG", "xpose set %u", xp);
return 1U;
}
if (strncmp(cmd, "flash erase chip", 16) == 0) {
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "FLASH_ERASE_BEGIN\n");
LOG_I("FLASH", "W25Q128 chip erase start");
@ -383,24 +615,28 @@ uint8_t app_log_try_command(const char *cmd)
return 1U;
}
if (strncmp(cmd, "ui boot", 7) == 0) {
/* 先 ACK全屏 Logo 绘制很慢,避免 HIL/主机超时误判 */
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "UI_BOOT_OK\n");
LCD_BLK_Set();
LCD_WR_PIC_FROM_FLASH(0, 0, UI0902_BOOT_LOGO_W, UI0902_BOOT_LOGO_H,
UI0902_BOOT_LOGO_ADDR);
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "UI_BOOT_OK\n");
return 1U;
}
if (strncmp(cmd, "ui charge", 9) == 0) {
uint8_t peek[4];
W25Q128_Read(peek, UI0902_CHARGE_SCREEN_ADDR, 4);
/* 先 ACKpeek 很快);再铺色/画图,避免卡在 LCD SPI 时主机无响应 */
if (peek[0] != 0xFF || peek[1] != 0xFF)
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "UI_CHARGE_OK\n");
else
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "UI_CHARGE_EMPTY\n");
LCD_BLK_Set();
LCD_FillByColor(0, 0, 240, 320, UI0902_BG_COLOR);
W25Q128_Read(peek, UI0902_CHARGE_SCREEN_ADDR, 4);
if (peek[0] != 0xFF || peek[1] != 0xFF) {
LCD_WR_PIC_FROM_FLASH(0, 0, UI0902_CHARGE_SCREEN_W, UI0902_CHARGE_SCREEN_H,
UI0902_CHARGE_SCREEN_ADDR);
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "UI_CHARGE_OK\n");
} else {
Draw_Charging_Icon(120, 150, 2, 0x3D7F);
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "UI_CHARGE_EMPTY\n");
}
{
const char *s = "50";

View File

@ -84,3 +84,13 @@ uint8_t app_tm1617_scan_key(void)
}
return KEY_NULL;
}
void app_tm1617_inject_key(uint8_t key)
{
if (key >= KEY_NULL)
key = KEY_NULL;
key_last = key;
LOG_I("KEY", "tm1617 inject key=%u", (unsigned)key);
/* ???????? RTT poll ?????? UI/?????????? */
MainTask_Sendmsg(MSG_ID_KEY_1617, key, 0, 0);
}

View File

@ -11,4 +11,8 @@ void app_tm1617_auto_loop(void);
uint8_t app_tm1617_scan_key(void);
/* RTT/test: bypass physical scan and inject a key event directly.
* key: 0=KEY_MAIN_D 1=KEY_MAIN_C 2=KEY_MAIN_B 3=KEY_MAIN_A 4+=release(KEY_NULL) */
void app_tm1617_inject_key(uint8_t key);
#endif /* APP_TM1617_H */

View File

@ -1,9 +1,10 @@
#include "includes.h"
static uint8_t key_last = KEY_NONE;
static uint8_t key_hold_sticky = 0; /* 1 = ????????????????(KEY_NONE)????? */
/* LED 期望颜色缓存:索引 = LedNum_TypeDef0~7COLOR_OTHER 表示灭 */
/* LED ???????????????? = LedNum_TypeDef??0~7????COLOR_OTHER ????? */
//static LedColor_TypeDef led_cache[8] = {
// COLOR_OTHER, COLOR_OTHER, COLOR_OTHER, COLOR_OTHER,
// COLOR_OTHER, COLOR_OTHER, COLOR_OTHER, COLOR_OTHER
@ -28,6 +29,9 @@ uint8_t app_tm1629_Scan_Key(void)
key = TM1629D_GetKey();
rt_mutex_release(TM1629_Mutex);
if(key_hold_sticky && key == KEY_NONE)
return KEY_NONE; /* ??????§ľ??????????????????????? */
if(key != key_last)
{
key_last = key;
@ -38,6 +42,27 @@ uint8_t app_tm1629_Scan_Key(void)
return KEY_NONE;
}
void app_tm1629_inject_key(uint8_t key)
{
key_hold_sticky = 0;
key_last = key;
LOG_I("KEY", "inject key=%u", (unsigned)key);
TM1629_Handle(key);
}
void app_tm1629_inject_hold(uint8_t key)
{
if(key == KEY_NONE)
{
app_tm1629_inject_key(KEY_NONE);
return;
}
key_hold_sticky = 1;
key_last = key;
LOG_I("KEY", "inject hold key=%u", (unsigned)key);
TM1629_Handle(key);
}
static void app_tm1629_led_set(LedNum_TypeDef led, LedColor_TypeDef color, uint8_t on)
{

View File

@ -4,6 +4,8 @@
void app_tm1629_init(void);
uint8_t app_tm1629_Scan_Key(void);
void app_tm1629_inject_key(uint8_t key); /* RTT/测试:绕过扫描直接注入 */
void app_tm1629_inject_hold(uint8_t key); /* RTT/测试注入并保持物理空扫不释放chord key 0 解除) */
void app_tm1629_set_chord_led(uint8_t key_idx, LedColor_TypeDef color);
void app_tm1629_set_led(LedNum_TypeDef led, LedColor_TypeDef color);
void app_tm1629_all_off(void);

View File

@ -89,11 +89,11 @@ const Touch_AreaTypeDef Touch_Areas[] =
{ 126, 172, UI0902_SECTION_Y, UI0902_SECTION_Y + UI0902_SECTION_BTN_H - 1, 0, 2, GUI_PLAY_SECTION, NULL },
{ 183, 229, UI0902_SECTION_Y, UI0902_SECTION_Y + UI0902_SECTION_BTN_H - 1, 0, 3, GUI_PLAY_SECTION, NULL },
/* 底栏四键:万能 | 普通 | 专业 | 设置(触区中线对齐 cx 40/100/160/210 */
{ 0, 69, 280, 319, 0, UI0902_NAV_UNIVERSAL, GUI_NAV_BAR, NULL },
{ 70, 129, 280, 319, 0, UI0902_NAV_NORMAL, GUI_NAV_BAR, NULL },
{ 130, 184, 280, 319, 0, UI0902_NAV_EXPERT, GUI_NAV_BAR, NULL },
{ 185, 239, 280, 319, 0, UI0902_NAV_SETTING, GUI_NAV_BAR, NULL },
/* 底栏四键:万能 | 普通 | 专业 | 设置(60×55y265319 */
{ UI0902_NAV_X0(0), UI0902_NAV_X1(0), UI0902_NAV_Y0, UI0902_NAV_Y1, 0, UI0902_NAV_UNIVERSAL, GUI_NAV_BAR, NULL },
{ UI0902_NAV_X0(1), UI0902_NAV_X1(1), UI0902_NAV_Y0, UI0902_NAV_Y1, 0, UI0902_NAV_NORMAL, GUI_NAV_BAR, NULL },
{ UI0902_NAV_X0(2), UI0902_NAV_X1(2), UI0902_NAV_Y0, UI0902_NAV_Y1, 0, UI0902_NAV_EXPERT, GUI_NAV_BAR, NULL },
{ UI0902_NAV_X0(3), UI0902_NAV_X1(3), UI0902_NAV_Y0, UI0902_NAV_Y1, 0, UI0902_NAV_SETTING, GUI_NAV_BAR, NULL },
};
/* 推子 0~10 共 11 档档10 取 AW816 上限附近 */
@ -101,7 +101,7 @@ int16_t MIC_VOL_MAP[11]= {-9000,-7000,-5000,-3000,-1000,1000,3000,5000,7000,9000
uint8_t TOUCH_AREA_NUM = (sizeof(Touch_Areas)/sizeof(Touch_AreaTypeDef));
const uint8_t Version[3] = {0,2,6}; /* major.minor.patch — 0.2.6: local song 2.bin from Doc/音色文件/0908/2ss_2.bin */
const uint8_t Version[3] = {0,2,12}; /* major.minor.patch — 0.2.12: autoclose 关闭 glyph; normal pick piece; HIL/flash tools */
uint8_t Led = 8;
bool PressFlag = 0;
@ -161,6 +161,7 @@ void BLTask_Sendmsg(uint16_t ID, uint16_t ID2, uint16_t HiByte, uint16_t LoByte)
#define PWR_ON_HOLD_MS 3000 /* 关机态长按 3s → 开机 */
#define PWR_OFF_HOLD_MS 2000 /* 开机态长按 2s → 关机 */
#define PWR_OFF_GRACE_MS 1500 /* 请求优雅关机后,超时仍未切轨则紧急断电+复位 */
typedef enum
@ -176,14 +177,42 @@ static uint32_t pwr_press_tick = 0;
/* 当前开关机状态UI_Idle.c 等 extern 引用) */
bool powon = false;
/* 电源轨是否已切断优雅关机与紧急关机共用ISR 依此判断是否还需强切) */
static volatile uint8_t s_pwr_rails_cut = 0;
/* TMR7关机长按已触发请求后的紧急倒计时ms */
static volatile uint16_t s_pwr_off_grace_ms = 0;
/* ==================== 开机/关机动作 ==================== */
/* 仅 GPIO/背光:可在 ISR / HardFault 中调用,禁止 RTOS/Flash/日志 */
void PowerOff_CutRails(void)
{
s_pwr_rails_cut = 1u;
powon = false;
LCD_BLK_Clr();
BSP_MainPowerEnable(0);
BSP_DreamCorePowerEnable(0);
BSP_HT7178PowerEnable(0);
BSP_BlueToothPowerEnable(0);
}
static void PowerOff_EmergencyReset(void)
{
PowerOff_CutRails();
/* 主循环已死时无法画充电页:复位后按 USB 状态进充电/待机 */
nvic_system_reset();
}
void System_PowerOn(void)
{
LOG_I("PWR", "power_on begin");
s_pwr_rails_cut = 0u;
s_pwr_off_grace_ms = 0u;
/* 控制电源硬件;蓝牙按 NVM/系统设置开关恢复,勿强制常开 */
BSP_MainPowerEnable(1);
BSP_DreamCorePowerEnable(1);
BSP_HT7178PowerEnable(1);
/* 蓝牙按 NVM/系统设置开关恢复,勿强制常开 */
BSP_BlueToothPowerEnable((uint8_t)(mGuiData[GUI_BL_SW].Current ? 1 : 0));
powon = true; /* 更新开机标志 */
@ -196,7 +225,9 @@ void System_PowerOn(void)
app_tm1617_init();
XPT2046_Init();
App_Auto_Init();
LOG_I("PWR", "periph ready");
/* App 协议走 UART4须在开机后即解析不能等到触摸选模式才 StartTask */
StartTask();
LOG_I("PWR", "periph ready + BT UART recv started");
}
static void PowerOn(void)
@ -210,6 +241,7 @@ static void PowerOff(void)
LOG_I("PWR", "power_off begin");
powon = false; /* 更新关机标志 */
s_pwr_off_grace_ms = 0u; /* 主路径已接管,取消紧急倒计时 */
StopFullTask();
/* 关机前把当前调音台推子与蓝牙开关写入 NVM保证再次开机保持 */
@ -226,20 +258,19 @@ static void PowerOff(void)
LCD_FillByColor(0, 0, 240, 320, BLACK);
LCD_BLK_Clr();
LCD_WR_REG(0x28); /* Display OFF */
app_tm1629_all_off();
app_tm1617_off();
/* 关闭电源硬件MCU 保持运行以便长按开机 / 关机充电画面 */
BSP_MainPowerEnable(0);
BSP_DreamCorePowerEnable(0);
BSP_HT7178PowerEnable(0);
BSP_BlueToothPowerEnable(0);
/* BT 先留窗口转发已排队 ACK再切全部电源轨 */
rt_thread_mdelay(30);
PowerOff_CutRails();
CurrUIProcress = IdleProcess;
MainTask_Sendmsg(MSG_ID_POWER_OFF, 0, 0, 0);
LOG_I("PWR", "power_off done (soft-off, no reset)");
LOG_I("PWR", "power_off done (soft-off)");
/* 关机时若已插着 Type-C立刻进充电画面 */
/* Type-C 有供电:维持现有逻辑,显示 Charging ImagePOWOFF_CHARG */
{
uint8_t i;
for (i = 0; i < 4; i++)
@ -253,12 +284,109 @@ static void PowerOff(void)
{
usb_charging_state = 1;
usb_charging_laststate = 1;
LOG_I("PWR", "soft-off with USB → charge UI");
LOG_I("PWR", "soft-off with Type-C → charge UI");
MainTask_Sendmsg(MSG_ID_POWOFF_CHARG, percentage, 1, 0);
}
}
}
/* App remote power-off request (BLE cmd 05 00): executed in main-loop context,
so StopFullTask() never detaches the calling BT thread itself. */
static volatile uint8_t s_sys_poweroff_req = 0;
void System_RequestPowerOff(void)
{
s_sys_poweroff_req = 1;
/* 主循环若已卡死TMR7 紧急倒计时仍会强切电源 */
if (s_pwr_off_grace_ms == 0u && !s_pwr_rails_cut)
s_pwr_off_grace_ms = PWR_OFF_GRACE_MS;
}
void System_PowerOff_Poll(void)
{
if (s_sys_poweroff_req)
{
s_sys_poweroff_req = 0;
if (powon)
PowerOff();
}
}
/*
* TMR7 1ms TMR6
*
*/
void Power_Key_IsrTick(void)
{
static uint16_t isr_hold_ms = 0;
static uint8_t isr_fired = 0;
flag_status st;
/* RTT/USB 烧 ExtFlash 期间禁止软关机,避免长按/粘键打断 FLASH_ACK */
if (app_log_flash_busy())
return;
st = BSP_GetPowerKey();
if (st == KEY_PRESS_LEVEL)
{
if (isr_hold_ms < 60000u)
isr_hold_ms++;
}
else
{
isr_hold_ms = 0;
isr_fired = 0;
}
/* 仅处理「开机态关机」;开机仍由主循环负责(需完整外设初始化) */
if (powon && !isr_fired && isr_hold_ms >= PWR_OFF_HOLD_MS)
{
isr_fired = 1;
System_RequestPowerOff();
}
if (s_pwr_off_grace_ms > 0u)
{
s_pwr_off_grace_ms--;
if (s_pwr_off_grace_ms == 0u && !s_pwr_rails_cut)
PowerOff_EmergencyReset();
}
}
/* HardFault / MemManage / BusFault / UsageFault死循环中仍可长按关机 */
void Power_Key_FaultLoop(void)
{
uint32_t hold = 0;
for (;;)
{
if (BSP_GetPowerKey() == KEY_PRESS_LEVEL)
{
hold++;
if (hold >= PWR_OFF_HOLD_MS)
{
PowerOff_EmergencyReset();
}
}
else
{
hold = 0;
}
Delay_us(1000);
}
}
static rt_err_t Power_HardFault_Hook(void *context)
{
(void)context;
Power_Key_FaultLoop();
return -RT_ERROR; /* 不可达 */
}
void Power_Key_WatchdogInit(void)
{
rt_hw_exception_install(Power_HardFault_Hook);
wk_tmr7_pwrkey_init();
}
void Power_Key_Scan()
{
flag_status status = BSP_GetPowerKey();
@ -317,19 +445,13 @@ void Power_Key_Scan()
}
holdtick = now - pwr_press_tick;
/* 用 powon 区分:关机长按 3s 开机,开机长按 2s 关机 */
/* 关机长按 3s 开机;开机态关机改由 TMR7 ISR主循环卡死仍可关 */
if(powon == false && holdtick >= PWR_ON_HOLD_MS)
{
LOG_I("PWR", "key power_on hold=%u", (unsigned)holdtick);
PowerOn();
pwr_key_state = KEY_STATE_NONE;
}
else if(powon == true && holdtick >= PWR_OFF_HOLD_MS)
{
LOG_I("PWR", "key power_off hold=%u", (unsigned)holdtick);
PowerOff();
pwr_key_state = KEY_STATE_NONE;
}
break;
case KEY_STATE_NONE:
@ -437,6 +559,16 @@ void Send_volume(uint8_t vol)
SendMidiDataToDreamDSP(mMasterSysExVolume,12);
}
/* 重发当前主音量:开机补发 / 进模式主页 / 开始播放前调用。
* Dream SysEx
* "图标低音量、实际音量大" */
void MasterVolume_Reapply(void)
{
uint8_t vol = app_adc_get_volume();
app_adc_sync_volume(vol); /* 兜底刷新 level保证图标与下发值一致 */
Send_volume(vol);
}
void Accomp_UpdatePlayingChord(void)
{
uint8_t midi_note_buff[4] = {0,};
@ -455,6 +587,29 @@ void Accomp_UpdatePlayingChord(void)
AutoBandTop1_Note_On(midi_note_buff[i], 0x50);
}
/* 尾奏起奏Postamble(0) + 同步起奏 + 当前和弦 Note_On。
* ADC 4
* piece 退 piece Postamble
* MIDI 3.bin Doc/_万能3.bin尾奏_20260914.md */
void AutoBand_StartOutro(void)
{
uint8_t midi_note_buff[4] = {0,};
uint8_t note_cnt = 0;
/* 退出和弦走向 piece再进风格尾奏舞台 */
AutoBandTop1_SetPiecePlayMode(0, 0, 0);
AutoBandTop1_Postamble(0);
/* 尾奏起奏前补发主音量,保证与图标一致 */
MasterVolume_Reapply();
AutoBandTop1_SyncStart();
wk_delay_ms(50);
note_cnt = GetChordNotesByType(KEY_ID_1629,chord_type_index_map[KEY_ID_1629].type,midi_note_buff);
for(uint8_t i = 0;i < note_cnt;i++)
{
AutoBandTop1_Note_On(midi_note_buff[i],0x50);
}
}
void ADC_IN1_KEY_Handle(uint8_t key,bool on)
{
uint8_t midi_note_buff[4] = {0,};
@ -464,13 +619,21 @@ void ADC_IN1_KEY_Handle(uint8_t key,bool on)
if(key_toggle[key] == 1)
{
//ResetAutoPowerCount();
if(key == 1)
{
/* 尾奏与万能模式第4键共用同一起奏路径 */
AutoBand_StartOutro();
}
else
{
switch(key)
{
case 3: AutoBandTop1_Preamble(0); is_preamble = 1; break;
case 0: AutoBandTop1_Preamble(1); is_preamble = 1; break;
case 1: AutoBandTop1_Postamble(0); break;
case 2: AutoBandTop1_Postamble(1); break;
}
/* 前奏/断奏起奏前补发主音量,保证与图标一致 */
MasterVolume_Reapply();
//AutoBandTop1_Start();
AutoBandTop1_SyncStart();
wk_delay_ms(50);
@ -486,6 +649,7 @@ void ADC_IN1_KEY_Handle(uint8_t key,bool on)
AutoBandTop1_Note_On(midi_note_buff[i],0x50);
}
}
}
else
{
Stop_AutoBand();
@ -615,13 +779,29 @@ void TM1629_Handle(uint8_t key)
STRING_MIDI *MIDI;
const STRING_MIDI *ORGAN_MIDI;
static uint8_t Last_Tm1926D_Key = 0;
/* 万能:指板不改和弦、不启停伴奏;保留拍速/停止 */
if (mGuiData[GUI_TAB_INDEX].Current == 0)
{
if (key == 22) { Tap_to_Speed(); return; }
if (key == 23) { Stop_AutoBand(); return; }
return; /* 忽略 0~21不改 KEY_ID/USE_MIDI/PressFlag不启停 OutTime_Stop */
}
if(BP_Transpose(key)) return;
switch(key)
{
case 0:
PressFlag = 0;
if(/*StartFlag &&*/ mGuiData[GUI_TAB_INDEX].Current == 0)
if(mGuiData[GUI_TAB_INDEX].Current == 2)
{
/* 普通:松手立即结束当前和弦伴奏 */
KEY_ID_1629 = 0;
if (StartFlag)
Stop_AutoBand();
}
else if(mGuiData[GUI_TAB_INDEX].Current == 0)
{
//StartFlag = 0;
//tick_ever_positive = false;
@ -640,6 +820,11 @@ void TM1629_Handle(uint8_t key)
rt_timer_stop(OutTime_Stop_timer);
//osTimerStop(Delay_stop_timer);
}
else if(mGuiData[GUI_TAB_INDEX].Current == 2)
{
/* 普通:按住指板,等待拨片起奏当前和弦 */
PressFlag = 1;
}
uint8_t group = (key - 1) / 3; /* 1~3→0, 4~6→1, ..., 19~21→6 */
KEY_ID_1629 = key;
MIDI = (STRING_MIDI *)Chord_Midi_Table[group];
@ -728,8 +913,19 @@ void Pick_Handle()
uint8_t note_cnt = 0;
uint8_t chord_id = KEY_ID_1629;
/* 左手未触发和弦板(或落在拍速/停止键)时:拨片默认发当前调一级大和弦伴奏 */
if (chord_id == 0 || chord_id == 22 || chord_id == 23)
/* 空库/加载失败时禁止起奏:否则 TMR6 ISR 内 AutoBand 可能 HardFault
* LCD Power_Key_Scan RESET */
if (!App_Auto_IsPresetReady())
{
LOG_E("pick", "abort: preset not ready tab=%u addr=0x%08X",
(unsigned)mGuiData[GUI_TAB_INDEX].Current, (unsigned)ADDRESS);
return;
}
/* 左手未触发和弦板(或落在拍速/停止键)时:拨片默认发当前调一级大和弦伴奏;
* */
if (mGuiData[GUI_TAB_INDEX].Current != 2 &&
(chord_id == 0 || chord_id == 22 || chord_id == 23))
{
KEY_ID_1629 = 1;
chord_type_index_map[1].type = 0;
@ -738,6 +934,10 @@ void Pick_Handle()
note_cnt = GetChordNotesByType(chord_id, chord_type_index_map[chord_id].type, midi_note_buff);
Return_Light_buff(KEY_ID_1629);
/* 首次起奏前补发主音量:防开机 SysEx 丢失后"图标低、实际大" */
if (StartFlag == 0)
MasterVolume_Reapply();
switch(mGuiData[GUI_TAB_INDEX].Current)
{
case 3:
@ -760,36 +960,45 @@ void Pick_Handle()
AutoBandTop1_SetLoopMode(0,1);
break;
case 0:
if(PressFlag)
{
AutoBandTop1_SetLoopMode(0,1);
AutoBandTop1_StartWithNotes(midi_note_buff, note_cnt);
}else
{
/* 设置页内沿用万能:与主路径一致,始终 loop */
AutoBandTop1_SetPiecePlayMode(1, 0, 480);
AutoBandTop1_StartWithNotes(midi_note_buff, note_cnt);
}
AutoBandTop1_SetLoopMode(0, 1);
StartFlag = 1;
break;
}
break;
case 2:
/* 普通:节奏/本地曲目均用 StartWithNote切库后允许再次拨片起奏 */
if (StartFlag == 0 || mGuiData[GUI_AUTOBAND_SW].Current)
/* 普通:左手按住指板 + 拨片起奏;松手由 TM1629 停。
* piece StartWithNote */
if (PressFlag == 0 || chord_id == 0 || chord_id > 21)
{
LOG_I("pick", "normal skip press=%u chord=%u (need fretboard)",
(unsigned)PressFlag, (unsigned)chord_id);
break;
}
StartFlag = 1;
if (mGuiData[GUI_AUTOBAND_SW].Current)
{
AutoBandTop1_Stop();
AutoBandTop1_SetLoopMode(0,1);
AutoBandTop1_SetRunVar(mGuiData[GUI_PLAY_SECTION].Current);
/* 起调必须用 0x3C+移调;勿用和弦音区 MIDI如 48否则本地曲目起奏异常 */
midi = 0x3C + mGuiData[GUI_TRANSPOSE].Current;
AutoBandTop1_StartWithNote(midi);
AutoBandTop1_StartWithNotes(midi_note_buff, note_cnt);
if (mGuiData[GUI_AUTOBAND_SW].Current)
LOG_I("pick", "local/normal root=%u name=%s", midi,
AutoBandTop1_GetPresetName() ? AutoBandTop1_GetPresetName() : "?");
}
else
{
AutoBandTop1_SetPiecePlayMode(1, 0, 1920);
AutoBandTop1_StartWithNotes(midi_note_buff, note_cnt);
AutoBandTop1_SetLoopMode(0, 1);
LOG_I("pick", "normal/rhythm piece name=%s notes=%u chord=%u",
AutoBandTop1_GetPresetName() ? AutoBandTop1_GetPresetName() : "?",
(unsigned)note_cnt, (unsigned)chord_id);
}
break;
case 1:
@ -819,17 +1028,14 @@ void Pick_Handle()
}
break;
case 0:
/* 万能:和弦走向用 piece 模式;须 SetLoopMode否则无循环时常听不到伴奏 */
StartFlag = 1;
if(PressFlag)
{
AutoBandTop1_SetPiecePlayMode(1, 0, 480);
AutoBandTop1_StartWithNotes(midi_note_buff, note_cnt);
AutoBandTop1_SetLoopMode(0, 1);
}else
{
AutoBandTop1_SetPiecePlayMode(1,0,480);
AutoBandTop1_StartWithNotes(midi_note_buff, note_cnt);
}
LOG_I("pick", "universal piece name=%s press=%u notes=%u chord=%u",
AutoBandTop1_GetPresetName() ? AutoBandTop1_GetPresetName() : "?",
(unsigned)PressFlag, (unsigned)note_cnt, (unsigned)chord_id);
break;
}
}
@ -941,9 +1147,8 @@ void BL_Set_led(uint8_t led, uint8_t chord_type)
void BT_Pitch_offset_map(uint8_t pos_offset,uint8_t pitch_offset)
{
//char Info[10];
//sprintf (Info, "%d %d", pos_offset,pitch_offset);
//LCD_ShowString(2, 156+16, (const uint8_t*)Info, RED, WHITE, 16, 0);
if (pos_offset < 1u || pos_offset > 21u)
return;
chord_type_index_map[pos_offset].PitchOffset = pitch_offset;
}
@ -952,10 +1157,13 @@ void BT_Chord_offset_map(uint8_t pos_offset,uint8_t chord_offset)
//char Info[20];
STRING_MIDI *MIDI;
const STRING_MIDI *ORGAN_MIDI;
uint8_t group = (pos_offset - 1) / 3; /* 1~3→0, 4~6→1, ..., 19~21→6 */
uint8_t group;
uint8_t chord_APP_Local_offset = 0;
if (pos_offset < 1u || pos_offset > 21u)
return;
group = (pos_offset - 1) / 3; /* 1~3→0, 4~6→1, ..., 19~21→6 */
switch(chord_offset){
case 0x00:
chord_APP_Local_offset = 0;
@ -1280,9 +1488,10 @@ uint8_t GetChordNotesByType(uint8_t MIDI_Index,uint8_t type,uint8_t *buff)
root = buff[0];
for (i = 1; i < n; i++)
{
while (buff[i] >= (uint8_t)(root + 12))
uint8_t guard;
for (guard = 0; guard < 2 && buff[i] >= (uint8_t)(root + 12); guard++)
buff[i] -= 12;
while (buff[i] < root)
for (guard = 0; guard < 2 && buff[i] < root; guard++)
buff[i] += 12;
}
}

View File

@ -166,8 +166,14 @@ extern uint8_t KEY_ID_1629;
extern bool powon;
void Power_Key_Scan(void);
void Power_Key_IsrTick(void);
void Power_Key_FaultLoop(void);
void Power_Key_WatchdogInit(void);
void PowerOff_CutRails(void);
void AutoPowerOff_Scan(void);
void System_PowerOn(void);
void System_RequestPowerOff(void);
void System_PowerOff_Poll(void);
extern uint8_t Led;
extern bool PressFlag;
@ -211,6 +217,9 @@ uint8_t GUI_Item_AjustValue(GUI_SWITCH * Group, int8_t Dir);
void GUI_Item_AjustLoopValue(GUI_SWITCH * Group, int8_t Dir);
void Send_volume(uint8_t vol);
void MasterVolume_Reapply(void);
/* 尾奏起奏ADC 尾奏键与万能第4段落键共用 */
void AutoBand_StartOutro(void);
void ADC_IN1_KEY_Handle(uint8_t key,bool on);
void TM1617_Handle(uint8_t key);

View File

@ -19,10 +19,318 @@ static uint8_t pStoreBuffer[PRESET_BUFFER_SIZE];
//static uint8_t* pStoreBuffer;
#define DEFAULT_TEMPO 100
/* ======== Bass/和弦分通道八度(无 AutoBand 源码时的输出侧映射) ========
* 0 MIDI status 4 8=bass9==
* ch8=bassI/II1~6III+bass -12 E1
* -12 E2~#G4#F(6) -12
* Doc/.xlsx */
#define CHORD_REG_FIX_EN 1
#define BASS_CH 8 /* 日志 ch8 */
#define DRUM_CH 9 /* 日志 ch9 */
#define MIDI_E1 28
#define MIDI_E2 40
#define MIDI_GSHARP4 68
#define XPOSE_FS 6 /* C=0 时 #F=6 */
/* 映射策略指纹:变化时 CC123 清旧音,防挂音(不建大音符表) */
static uint8_t s_reg_fp = 0xFFu;
/* 每个指纹周期内 NoteOn 映射日志限额(和弦与 bass 分开,避免和弦占满看不到 bass */
static uint8_t s_pitch_log_left = 0;
static uint8_t s_bass_log_left = 0;
static uint16_t s_ch_ever_mask = 0; /* 会话内各通道是否出现过 NoteOn */
#define PITCH_LOG_PER_FP 24
#define BASS_LOG_PER_FP 16
static int App_Auto_ClampMidi(int n)
{
if (n < 0) return 0;
if (n > 127) return 127;
return n;
}
static int App_Auto_ChordDegree(void)
{
if (KEY_ID_1629 < 1 || KEY_ID_1629 > 21)
return 0;
return (int)((KEY_ID_1629 - 1) / 3) + 1; /* 1=I .. 7=VII */
}
static int App_Auto_NeedRegisterFix(void)
{
/* 键 1~6 = I/II不动7~21 = III~VII */
return (KEY_ID_1629 >= 7 && KEY_ID_1629 <= 21) ? 1 : 0;
}
static int App_Auto_XposeExtra(void)
{
return (mGuiData[GUI_TRANSPOSE].Current >= XPOSE_FS) ? 1 : 0;
}
static uint8_t App_Auto_RegFingerprint(void)
{
/* bit0=need_fix, bit1=xpose_extra, bit2.. = 粗粒度级数 (key/3) */
uint8_t fp = 0;
if (App_Auto_NeedRegisterFix()) fp |= 0x01u;
if (App_Auto_XposeExtra()) fp |= 0x02u;
if (KEY_ID_1629 >= 1 && KEY_ID_1629 <= 21)
fp |= (uint8_t)(((KEY_ID_1629 - 1) / 3) << 2);
return fp;
}
static void App_Auto_QueueAllNotesOffMelodic(void)
{
MidiFifoItem_t m;
int ch;
for (ch = 0; ch < 16; ch++)
{
if (ch == DRUM_CH) continue;
m.msg[0] = (uint8_t)(0xB0 | ch);
m.msg[1] = 123; /* All Notes Off */
m.msg[2] = 0;
m.msglen = 3;
mymidififo_InQueue(&m_MidiSendFifo, &m);
}
}
/* 超出 #G4在当前和弦内音GetChordNotesByType对照和弦表「和弦伴奏」列中就近 */
static int App_Auto_NearestChordTone(int target)
{
uint8_t buff[4] = {0, 0, 0, 0};
uint8_t n;
uint8_t pcs[4];
int i, oct, best = -1, best_dist = 9999;
uint8_t type;
if (KEY_ID_1629 < 1 || KEY_ID_1629 > 21)
return App_Auto_ClampMidi(target);
type = chord_type_index_map[KEY_ID_1629].type;
n = GetChordNotesByType(KEY_ID_1629, type, buff);
if (n == 0)
return App_Auto_ClampMidi(target > MIDI_GSHARP4 ? MIDI_GSHARP4 : target);
for (i = 0; i < (int)n; i++)
pcs[i] = (uint8_t)(buff[i] % 12);
for (i = 0; i < (int)n; i++)
{
for (oct = 0; oct < 11; oct++)
{
int cand = (int)pcs[i] + oct * 12;
int dist;
if (cand < MIDI_E2 || cand > MIDI_GSHARP4)
continue;
dist = cand - target;
if (dist < 0) dist = -dist;
/* 并列:优先较低者(不超过上限) */
if (dist < best_dist || (dist == best_dist && (best < 0 || cand < best)))
{
best_dist = dist;
best = cand;
}
}
}
if (best < 0)
return MIDI_GSHARP4;
return best;
}
static int App_Auto_MapBassNote(int key, uint8_t *flags)
{
int out = key - 12;
uint8_t f = 0x01u; /* bit0: -12 applied */
while (out < MIDI_E1)
{
out += 12;
f |= 0x02u; /* bit1: floored up to E1 */
}
if (App_Auto_XposeExtra())
{
out -= 12;
f |= 0x04u; /* bit2: xpose extra -12 */
while (out < MIDI_E1)
{
out += 12;
f |= 0x02u;
}
}
if (flags) *flags = f;
return App_Auto_ClampMidi(out);
}
static int App_Auto_MapChordNote(int key, uint8_t *flags)
{
int out = key - 12;
uint8_t f = 0x01u; /* bit0: -12 */
while (out < MIDI_E2)
{
out += 12;
f |= 0x08u; /* bit3: raised to E2 */
}
if (out > MIDI_GSHARP4)
{
out = App_Auto_NearestChordTone(out);
f |= 0x10u; /* bit4: nearest chord tone */
}
if (App_Auto_XposeExtra())
{
out -= 12;
f |= 0x04u;
while (out < MIDI_E2)
{
out += 12;
f |= 0x08u;
}
if (out > MIDI_GSHARP4)
{
out = App_Auto_NearestChordTone(out);
f |= 0x10u;
}
}
if (flags) *flags = f;
return App_Auto_ClampMidi(out);
}
static int App_Auto_MapNote(int channel, int key, uint8_t *flags)
{
int out = key;
if (flags) *flags = 0;
#if CHORD_REG_FIX_EN
if (channel == DRUM_CH || key < 0 || key > 127)
return key;
if (!App_Auto_NeedRegisterFix())
return key;
if (channel == BASS_CH)
out = App_Auto_MapBassNote(key, flags);
else
out = App_Auto_MapChordNote(key, flags);
#else
(void)channel;
#endif
return out;
}
static const char *App_Auto_RoleName(int channel)
{
if (channel == DRUM_CH) return "drum";
if (channel == BASS_CH) return "bass";
return "chord";
}
static void App_Auto_LogPitch(int channel, int key, int out, uint8_t flags, int is_on)
{
const char *role;
int deg;
int fix;
int xf;
int pass;
if (channel == DRUM_CH)
return;
if (channel == BASS_CH)
{
if (s_bass_log_left == 0)
return;
}
else if (s_pitch_log_left == 0)
{
return;
}
role = App_Auto_RoleName(channel);
deg = App_Auto_ChordDegree();
fix = App_Auto_NeedRegisterFix();
xf = App_Auto_XposeExtra();
pass = (!fix || channel == DRUM_CH) ? 1 : 0;
LOG_I("PITCH", "%s ch%u(%s) %d->%d d=%d deg=%d chord=%u xp=%u xf=%d fl=0x%02X %s%s%s%s",
is_on ? "on" : "off",
(unsigned)channel, role, /* 0 基,与 BASS_CH=8 一致 */
key, out, out - key,
deg, (unsigned)KEY_ID_1629,
(unsigned)mGuiData[GUI_TRANSPOSE].Current, xf,
(unsigned)flags,
pass ? "PASS " : "",
(flags & 0x10u) ? "NEAR " : "",
(flags & 0x02u) ? "E1UP " : "",
(flags & 0x08u) ? "E2UP " : "");
if (channel == BASS_CH)
{
if (s_bass_log_left > 0)
s_bass_log_left--;
}
else if (s_pitch_log_left > 0)
{
s_pitch_log_left--;
}
}
/* NoteOn 路径:策略变化时清旧音;返回映射后音高 */
static int App_Auto_MapNoteOn(int channel, int key)
{
#if CHORD_REG_FIX_EN
uint8_t fp = App_Auto_RegFingerprint();
uint8_t flags = 0;
int out;
if (fp != s_reg_fp)
{
if (s_reg_fp != 0xFFu)
{
LOG_I("REG", "fp %u->%u chord=%u deg=%d xp=%u xf=%d",
(unsigned)s_reg_fp, (unsigned)fp,
(unsigned)KEY_ID_1629,
App_Auto_ChordDegree(),
(unsigned)mGuiData[GUI_TRANSPOSE].Current,
App_Auto_XposeExtra());
App_Auto_QueueAllNotesOffMelodic();
}
else
{
LOG_I("REG", "fp init %u chord=%u deg=%d xp=%u",
(unsigned)fp, (unsigned)KEY_ID_1629,
App_Auto_ChordDegree(),
(unsigned)mGuiData[GUI_TRANSPOSE].Current);
}
s_reg_fp = fp;
s_pitch_log_left = PITCH_LOG_PER_FP;
s_bass_log_left = BASS_LOG_PER_FP;
s_ch_ever_mask = 0; /* 新策略周期重新报到通道 */
}
out = App_Auto_MapNote(channel, key, &flags);
if (channel >= 0 && channel < 16)
{
uint16_t bit = (uint16_t)(1u << channel);
if ((s_ch_ever_mask & bit) == 0u)
{
s_ch_ever_mask |= bit;
LOG_I("REG", "ch-seen ch%u role=%s key=%d",
(unsigned)channel, App_Auto_RoleName(channel), key);
}
}
App_Auto_LogPitch(channel, key, out, flags, 1);
return out;
#else
(void)channel;
return key;
#endif
}
static void Func_CallBack_ProgramChange(int channel, int program)
{
MidiFifoItem_t midimsg;
midimsg.msg[0] = 0xc0 | channel;
LOG_I("REG", "pc ch%u prog=%d", (unsigned)channel, program);
midimsg.msg[0] = 0xc0 | (channel & 0x0F);
midimsg.msg[1] = program;
midimsg.msg[2] = 0;
midimsg.msglen = 2;
@ -32,8 +340,10 @@ static void Func_CallBack_ProgramChange(int channel, int program)
static void Func_CallBack_NoteOff(int channel,int key)
{
MidiFifoItem_t midimsg;
midimsg.msg[0] = 0x80 | channel;
midimsg.msg[1] = key;
uint8_t flags = 0;
int out_key = App_Auto_MapNote(channel, key, &flags);
midimsg.msg[0] = 0x80 | (channel & 0x0F);
midimsg.msg[1] = (uint8_t)out_key;
midimsg.msg[2] = 0;
midimsg.msglen = 3;
mymidififo_InQueue(&m_MidiSendFifo,&midimsg);
@ -43,9 +353,19 @@ static void Func_CallBack_NoteOff(int channel,int key)
static void Func_CallBack_NoteOn(int channel,int key,int vel)
{
MidiFifoItem_t midimsg;
midimsg.msg[0] = 0x90 | channel;
midimsg.msg[1] = key;
midimsg.msg[2] = vel;
int out_key;
if (vel == 0)
{
uint8_t flags = 0;
out_key = App_Auto_MapNote(channel, key, &flags); /* 视同 NoteOff */
}
else
out_key = App_Auto_MapNoteOn(channel, key);
midimsg.msg[0] = 0x90 | (channel & 0x0F);
midimsg.msg[1] = (uint8_t)out_key;
midimsg.msg[2] = (uint8_t)vel;
midimsg.msglen = 3;
mymidififo_InQueue(&m_MidiSendFifo,&midimsg);
@ -96,6 +416,31 @@ static const char *ToneFlashMapBank(uint32_t abs)
static uint32_t s_flash_rd_last_base = 0xFFFFFFFFu;
static uint16_t s_flash_rd_log_cnt = 0;
/* 最近一次 LoadPreset 是否成功;失败时禁止拨片起奏(防空库 + TMR6 ISR 崩) */
static volatile uint8_t s_preset_ready = 0;
int App_Auto_LoadPresetFromFlash(int presetIndex)
{
int ret = AutoBandTop1_LoadPresetItemFromFlash(presetIndex);
if (ret == 0)
{
const char *name = AutoBandTop1_GetPresetName();
int count = AutoBandTop1_GetPresetItemCount();
s_preset_ready = (count > 0 && name != NULL && name[0] != '\0') ? 1u : 0u;
}
else
{
s_preset_ready = 0u;
AutoBandTop1_Stop();
StartFlag = 0;
}
return ret;
}
uint8_t App_Auto_IsPresetReady(void)
{
return s_preset_ready;
}
static int Func_CallBack_ReadFlash(int address,int length,uint8_t* pOutput)
{
@ -135,6 +480,9 @@ static int Func_CallBack_ReadFlash(int address,int length,uint8_t* pOutput)
//
void App_Auto_Init(void)
{
LOG_I("REG", "ch map bass=%d drum=%d fix=%d E1=%d E2=%d Gs4=%d",
BASS_CH, DRUM_CH, CHORD_REG_FIX_EN, MIDI_E1, MIDI_E2, MIDI_GSHARP4);
LOG_I("REG", "PITCH log: chN(role) in->out d=delta deg=I..VII xp=xpose xf=#Fextra fl=flags");
AutoBandTop1_SetPresetStoreBuffer(pStoreBuffer,PRESET_BUFFER_SIZE);
AutoBandTop1_Init();
AutoBandTop1_RegisterCallBack_NoteOn(Func_CallBack_NoteOn);
@ -145,11 +493,15 @@ void App_Auto_Init(void)
AutoBandTop1_RegisterReadFlashCallback(Func_CallBack_ReadFlash);
m_presetCount = AutoBandTop1_GetPresetItemCount();
int ret = AutoBandTop1_LoadPresetItemFromFlash(0);
int ret = App_Auto_LoadPresetFromFlash(0);
if (ret != 0)
{
LOG_E("AUTO", "LoadPreset fail ret=%d addr=0x%08X map=%s",
ret, (unsigned)ADDRESS, ToneFlashMapBank((uint32_t)ADDRESS));
uint8_t hdr[16];
W25Q128_Read(hdr, (uint32_t)ADDRESS, 16);
LOG_E("AUTO", "LoadPreset fail ret=%d addr=0x%08X map=%s magic=%02X%02X%02X%02X cnt=%lu",
ret, (unsigned)ADDRESS, ToneFlashMapBank((uint32_t)ADDRESS),
hdr[0], hdr[1], hdr[2], hdr[3],
(unsigned long)(hdr[12] | (hdr[13] << 8) | (hdr[14] << 16) | (hdr[15] << 24)));
}
else
{

View File

@ -8,9 +8,14 @@
#ifndef APP_AUTO_H_
#define APP_AUTO_H_
#include "mymidififo1.h"
#include <stdint.h>
extern void App_Auto_Init(void);
/* 统一加载入口:记录成功/失败,失败时 Stop避免空库起奏进 ISR 崩溃 */
int App_Auto_LoadPresetFromFlash(int presetIndex);
uint8_t App_Auto_IsPresetReady(void);
extern MidiFifoSeq_t m_MidiSendFifo;
#endif /* APP_AUTO_H_ */

View File

@ -43,12 +43,6 @@ static void UI_ModeSelect_DrawButtons(void)
LCD_WR_PIC_FROM_FLASH(UI0902_MODE_ROW_X, y,
UI0902_MODE_ROW_W, UI0902_MODE_ROW_H,
s_mode_row_addr[i]);
/* 万能模式素材顶部有残留近白横线(相对行内 y=7/8盖成行底色 */
if (i == 0) {
LCD_FillByColor(UI0902_MODE_ROW_X, (uint16_t)(y + 7),
(uint16_t)(UI0902_MODE_ROW_X + UI0902_MODE_ROW_W),
(uint16_t)(y + 9), UI0902_ROW_BG);
}
}
}
@ -169,18 +163,19 @@ void IdleProcess(DisplayTaskMessage_Type msg)
TM1629D_UpdateDisplay(0);
StartTouchTask();
StartScanTask();
/* Dream 上电稍后可能忽略首包 SysEx再发主音量 + 调音台(保持关机前设置) */
/* Dream 上电稍后可能忽略首包 SysEx再发主音量 + 调音台(保持关机前设置)。
* ADC
* MasterVolume_Reapply */
rt_thread_mdelay(400);
vol = ADC_ReadChannel(ADC_CHANNEL_1)>>5;
app_adc_sync_volume(vol);
Send_volume(vol);
MasterVolume_Reapply();
LoadConfig();
Draw_Volume_Bar(UI0902_VOL_X, UI0902_VOL_Y, level, 1, WHITE, GRAY);
break;
case MSG_ID_POWER_OFF:
/* 关机:熄屏 + 关背光 */
/* 无 Type-C整机关机熄屏有 Type-C 时随后 POWOFF_CHARG 会再亮充电图 */
LCD_BLK_Clr();
LCD_WR_REG(0x28); /* Display OFF */
LCD_FillByColor(0, 0, 240, 320, BLACK);
break;
@ -190,6 +185,8 @@ void IdleProcess(DisplayTaskMessage_Type msg)
case MSG_ID_TOUCH:
{
if (!powon)
break; /* 软关机已停触摸;兜底勿进模式选择 */
static uint32_t s_last_enter_ms;
uint32_t now = rt_tick_get() * 1000U / RT_TICK_PER_SECOND;
uint8_t hit = 0U;

View File

@ -53,8 +53,33 @@ void DrawAllFaders(void)
drv_nvm_save_to_flash();
}
/* 0902-06实心蓝色圆钮勿用同心空心圆会透出色条 */
#define MIXER_KNOB_R 14
/* 0902-06圆钮定位半径 = 色条半宽;填充略大以盖住柱端抗锯齿白边(夹紧不画出柱外) */
#define MIXER_KNOB_R 15
#define MIXER_KNOB_FILL_R 16
#define MIXER_TRACK_HALF_W 15
/* 与柱端半圆同形的实心圆钮:顶/底档位与色条圆角完美贴合,无虚线白边 */
static void DrawMixerKnob(uint16_t cx, uint16_t cy)
{
int16_t dy;
int32_t r2 = (int32_t)MIXER_KNOB_FILL_R * (int32_t)MIXER_KNOB_FILL_R;
for (dy = -(int16_t)MIXER_KNOB_R; dy <= (int16_t)MIXER_KNOB_R; dy++)
{
int32_t xx = r2 - (int32_t)dy * (int32_t)dy;
int16_t dx = 0;
int16_t half;
if (xx < 0)
continue;
while ((int32_t)(dx + 1) * (dx + 1) <= xx)
dx++;
half = dx;
if (half > (int16_t)MIXER_TRACK_HALF_W)
half = (int16_t)MIXER_TRACK_HALF_W;
LCD_FillByColor((uint16_t)(cx - half), (uint16_t)(cy + dy),
(uint16_t)(cx + half + 1), (uint16_t)(cy + dy + 1),
COLOR_GRAD_MID);
}
}
static void DrawFaderSlider(Fader_t *f, uint16_t slider_y)
{
@ -62,9 +87,9 @@ static void DrawFaderSlider(Fader_t *f, uint16_t slider_y)
uint16_t ly = f->y + f->h + 8;
uint16_t cy = (uint16_t)(slider_y + MIXER_KNOB_R);
char buf[8];
LCD_FillCircle(cx, cy, MIXER_KNOB_R, COLOR_GRAD_MID);
DrawMixerKnob(cx, cy);
sprintf(buf, "%d", f->val);
LCD_ShowString_AutoAlign((uint16_t)(cx - 8), (uint16_t)(cx + 8),
LCD_ShowString_AutoAlign((uint16_t)(cx - 10), (uint16_t)(cx + 10),
(uint16_t)(cy - 5),
(uint8_t*)buf, LCD_ALIGN_CENTER, WHITE, COLOR_GRAD_MID, 0, 11);
LCD_FillByColor(cx - 15, ly + 17, cx + 15, ly + 19, UI0902_BG_COLOR);
@ -149,17 +174,14 @@ void UpdateFaderByTouch(int idx, uint16_t y)
}
// ============================================================
// ??????Y????????val? 0~10??
// 滑块顶边 Yval0/10 时圆心落在色条底/顶半圆中心,两端对称贴合
// ============================================================
static uint16_t CalcSliderY(Fader_t *f)
{
uint16_t slider_h = (uint16_t)(MIXER_KNOB_R * 2);
uint16_t fill_h = (f->val * f->h) / 10;
if (fill_h > f->h) fill_h = f->h;
uint16_t slider_y = f->y + f->h - fill_h - (slider_h / 2);
if (slider_y < f->y) slider_y = f->y;
if (slider_y > f->y + f->h - slider_h) slider_y = f->y + f->h - slider_h;
return slider_y;
uint16_t top_cy = (uint16_t)(f->y + MIXER_KNOB_R);
uint16_t bot_cy = (uint16_t)(f->y + f->h - 1 - MIXER_KNOB_R);
uint16_t cy = (uint16_t)(bot_cy - ((uint32_t)(bot_cy - top_cy) * f->val) / 10);
return (uint16_t)(cy - MIXER_KNOB_R);
}
int CheckTouchFader(uint16_t x, uint16_t y)
@ -222,7 +244,7 @@ void UI_Mixer_Process(DisplayTaskMessage_Type msg)
break;
case MSG_ID_EC_VOL:
Draw_Volume_Bar(UI0902_VOL_X, UI0902_VOL_Y, msg.HiByte, 1, WHITE, GRAY);
/* 调音台子页无状态栏音量条(与返回键重叠);主音量已在 MainTask 下发 */
ResetAutoPowerCount();
break;
}

View File

@ -180,11 +180,11 @@ void Touch_Action(int8_t FLAG, uint8_t ID, uint8_t areaIndex,uint8_t value)
UI_ApplyToneAddress();
int ret = 0;
if (tab == 0)
ret = AutoBandTop1_LoadPresetItemFromFlash(ParamGuiData[ALL_MODE_PARAM].Current);
ret = App_Auto_LoadPresetFromFlash(ParamGuiData[ALL_MODE_PARAM].Current);
else if (mGuiData[GUI_AUTOBAND_SW].Current)
ret = AutoBandTop1_LoadPresetItemFromFlash(ParamGuiData[EXPRESS_MODE_PARAM].Current);
ret = App_Auto_LoadPresetFromFlash(ParamGuiData[EXPRESS_MODE_PARAM].Current);
else
ret = AutoBandTop1_LoadPresetItemFromFlash(ParamGuiData[SONG_MODE_PARAM].Current);
ret = App_Auto_LoadPresetFromFlash(ParamGuiData[SONG_MODE_PARAM].Current);
if (ret != 0)
LOG_E("tone", "load preset failed tab=%d sw=%d ret=%d addr=0x%08X",
tab, mGuiData[GUI_AUTOBAND_SW].Current, ret, (unsigned)ADDRESS);
@ -237,12 +237,24 @@ void Touch_Action(int8_t FLAG, uint8_t ID, uint8_t areaIndex,uint8_t value)
}
else if(ID==GUI_PLAY_SECTION)
{
/* 万能模式第4键改为尾奏播放与独立尾奏键同路径不走 main4 */
if (mGuiData[GUI_TAB_INDEX].Current == 0 && value == 3)
{
last_value = mGuiData[ID].Current;
mGuiData[ID].Current = value;
AutoBand_StartOutro();
if (!UI_SetFocus(GUI_PLAY_SECTION))
Refresh_SectionSelect(last_value, &UI_Label[ID], &mGuiData[ID]);
return;
}
if(mGuiData[ID].Current != value)
{
mGuiData[ID].Current = value;
AutoBandTop1_SetRunVar(mGuiData[ID].Current);
}else
{
/* 触摸起奏前补发主音量,保证实际音量与图标一致 */
MasterVolume_Reapply();
AutoBandTop1_FillinWithIndex(value);
AutoBandTop1_Start();
}
@ -268,6 +280,8 @@ void Touch_Action(int8_t FLAG, uint8_t ID, uint8_t areaIndex,uint8_t value)
if(mGuiData[GUI_TAB_LAST_INDEX].Current == mGuiData[ID].Current)
break;
StartFlag = 0;
KEY_ID_1629 = 0; /* 清专业等残留指板键,拨片按当前调走走向 */
PressFlag = 0;
AutoBandTop1_Stop();
UI_ReloadTonePreset(); /* 切换模式必须换库,避免专业仍播万能数据 */
break;

View File

@ -11,11 +11,11 @@ const Touch_AreaTypeDef Touch_SetingSelect_Areas[] = {
{ UI0902_HUB_CARD_X, (uint16_t)(UI0902_HUB_CARD_X + UI0902_HUB_CARD_W - 1),
UI0902_HUB_CARD_Y1, (uint16_t)(UI0902_HUB_CARD_Y1 + UI0902_HUB_CARD_H - 1),
0, 0, GUI_SETING_SELECT3, NULL },
/* 底栏四键 */
{ 0, 69, 280, 319, 0, UI0902_NAV_UNIVERSAL, GUI_NAV_BAR, NULL },
{ 70, 129, 280, 319, 0, UI0902_NAV_NORMAL, GUI_NAV_BAR, NULL },
{ 130, 184, 280, 319, 0, UI0902_NAV_EXPERT, GUI_NAV_BAR, NULL },
{ 185, 239, 280, 319, 0, UI0902_NAV_SETTING, GUI_NAV_BAR, NULL },
/* 底栏四键:与 Touch_Areas 共用 UI0902_NAV_* 几何 */
{ UI0902_NAV_X0(0), UI0902_NAV_X1(0), UI0902_NAV_Y0, UI0902_NAV_Y1, 0, UI0902_NAV_UNIVERSAL, GUI_NAV_BAR, NULL },
{ UI0902_NAV_X0(1), UI0902_NAV_X1(1), UI0902_NAV_Y0, UI0902_NAV_Y1, 0, UI0902_NAV_NORMAL, GUI_NAV_BAR, NULL },
{ UI0902_NAV_X0(2), UI0902_NAV_X1(2), UI0902_NAV_Y0, UI0902_NAV_Y1, 0, UI0902_NAV_EXPERT, GUI_NAV_BAR, NULL },
{ UI0902_NAV_X0(3), UI0902_NAV_X1(3), UI0902_NAV_Y0, UI0902_NAV_Y1, 0, UI0902_NAV_SETTING, GUI_NAV_BAR, NULL },
};
#define TOUCH_AREA_SETING_SELECT_NUM (sizeof(Touch_SetingSelect_Areas)/sizeof(Touch_AreaTypeDef))
@ -124,6 +124,11 @@ void UI_SystemSet_Process(DisplayTaskMessage_Type msg)
label_top();
break;
case MSG_ID_EC_VOL:
/* 主音量已在 MainTask 下发;此处只刷新状态栏音量条,保证与旋钮一致 */
Draw_Volume_Bar(UI0902_VOL_X, UI0902_VOL_Y, msg.HiByte, 1, WHITE, GRAY);
break;
default:
break;
}

View File

@ -54,6 +54,7 @@ void CallUI_Idle(void) {
void CallUI_SongMode(void) {
app_touch_suppress(500);
MasterVolume_Reapply(); /* 进模式补发主音量,防开机 SysEx 丢失后实际音量与图标不符 */
UI_SongMode_Init();
CurrUIProcress = UI_SongMode_Process;
app_log_set_ui_page("Song");
@ -62,6 +63,7 @@ void CallUI_SongMode(void) {
void CallUI_ExpertMode(void) {
app_touch_suppress(500);
MasterVolume_Reapply();
UI_ExpertMode_Init();
// CurrUIProcress = UI_ExpertMode_Process;
CurrUIProcress = UI_SongMode_Process;
@ -71,6 +73,7 @@ void CallUI_ExpertMode(void) {
void CallUI_FreeMode(void) {
app_touch_suppress(500);
MasterVolume_Reapply();
UI_FreeMode_Init();
// CurrUIProcress = UI_FreeMode_Process;
CurrUIProcress = UI_SongMode_Process;
@ -167,11 +170,24 @@ void UI_ReloadTonePreset(void)
/* 万能:和弦走向 */
if (mGuiData[GUI_TAB_INDEX].Current == 0)
{
int ret = AutoBandTop1_LoadPresetItemFromFlash(ParamGuiData[ALL_MODE_PARAM].Current);
int ret = App_Auto_LoadPresetFromFlash(ParamGuiData[ALL_MODE_PARAM].Current);
if (ret != 0)
{
uint8_t hdr[16];
W25Q128_Read(hdr, (uint32_t)ADDRESS, 16);
LOG_E("tone", "load universal idx=%d ret=%d magic=%02X%02X%02X%02X cnt=%lu addr=0x%08X",
ParamGuiData[ALL_MODE_PARAM].Current, ret,
hdr[0], hdr[1], hdr[2], hdr[3],
(unsigned long)(hdr[12] | (hdr[13] << 8) | (hdr[14] << 16) | (hdr[15] << 24)),
(unsigned)ADDRESS);
}
else
{
LOG_I("tone", "load universal idx=%d ret=%d name=%s addr=0x%08X map=BIN3@0x%08X",
ParamGuiData[ALL_MODE_PARAM].Current, ret,
AutoBandTop1_GetPresetName() ? AutoBandTop1_GetPresetName() : "?",
(unsigned)ADDRESS, (unsigned)EXTFLASH_BIN3_UNIVERSAL_ADDR);
}
return;
}
/* 普通/专业:本地曲目或节奏类型 */
@ -181,7 +197,7 @@ void UI_ReloadTonePreset(void)
local->Max = (LOCAL_SONG_COUNT > 0) ? (uint16_t)(LOCAL_SONG_COUNT - 1) : 0;
if (local->Current > local->Max)
local->Current = local->Max;
int ret = AutoBandTop1_LoadPresetItemFromFlash(local->Current);
int ret = App_Auto_LoadPresetFromFlash(local->Current);
if (ret != 0)
LOG_E("tone", "load local song idx=%d failed ret=%d addr=0x%08X map=BIN2@0x%08X",
local->Current, ret, (unsigned)ADDRESS,
@ -201,7 +217,7 @@ void UI_ReloadTonePreset(void)
return;
}
{
int ret = AutoBandTop1_LoadPresetItemFromFlash(ParamGuiData[SONG_MODE_PARAM].Current);
int ret = App_Auto_LoadPresetFromFlash(ParamGuiData[SONG_MODE_PARAM].Current);
LOG_I("tone", "load rhythm idx=%d ret=%d name=%s addr=0x%08X map=BIN1@0x%08X %s",
ParamGuiData[SONG_MODE_PARAM].Current, ret,
AutoBandTop1_GetPresetName() ? AutoBandTop1_GetPresetName() : "?",
@ -443,6 +459,58 @@ void Show_Battery_Icon(uint8_t flag)
}
/* 中英文混排像素宽(与 LCD_GetMixedStringWidth 规则一致CJK=sizeyASCII=UI0902_ASCII_W */
static uint16_t UI_MixedTextWidth(const uint8_t *str, uint8_t sizey)
{
uint16_t width = 0;
uint8_t ascii_w = UI0902_ASCII_W(sizey);
if (str == NULL)
return 0;
while (*str != '\0') {
if (*str > 0x80) {
width = (uint16_t)(width + sizey);
str += 2;
} else {
width = (uint16_t)(width + ascii_w);
str += 1;
}
}
return width;
}
/* 曲目/音色共用序号与名称间距与「1.名称」拼接一致,保证两行视觉间距相同) */
#define UI0902_INDEX_NAME_GAP 0
/* 在 [x1,x2] 内整体居中绘制「N.」+ 名称无渐变show_Param 用) */
static void UI_DrawIndexedName_Mixed(uint16_t x1, uint16_t x2, uint16_t text_y,
const char *num, const char *name,
uint16_t fc, uint16_t bc, uint8_t sizey)
{
char line[48] = {0};
snprintf(line, sizeof(line), "%s%s", num, name);
LCD_ShowMixedString(x1, x2, text_y, (uint8_t *)line, LCD_ALIGN_CENTER,
fc, bc, sizey, 1);
}
/* 在 [x1,x2] 内整体居中绘制「N.」+ 中文名(渐变底,音色行用);间隙与曲目行一致 */
static void UI_DrawIndexedName_Grad(uint16_t x1, uint16_t x2, uint16_t text_y,
const char *num, const uint8_t *name,
uint16_t fc, uint16_t c_top, uint16_t c_bot, uint8_t sizey)
{
uint16_t num_w = (uint16_t)(strlen(num) * UI0902_ASCII_W(sizey));
uint16_t name_w = UI_MixedTextWidth(name, sizey);
uint16_t total_w = (uint16_t)(num_w + UI0902_INDEX_NAME_GAP + name_w);
uint16_t area_w = (x2 > x1) ? (uint16_t)(x2 - x1) : 0;
uint16_t start_x = (total_w >= area_w) ? x1 : (uint16_t)(x1 + (area_w - total_w) / 2);
uint16_t name_x = (uint16_t)(start_x + num_w + UI0902_INDEX_NAME_GAP);
LCD_ShowString_AutoAlign_Gradient(start_x, (uint16_t)(start_x + num_w), text_y,
(const uint8_t *)num, LCD_ALIGN_LEFT, fc, c_top, c_bot, sizey);
LCD_ShowChinese_AutoAlign_Gradient(name_x, x2, text_y, name, LCD_ALIGN_LEFT,
fc, c_top, c_bot, sizey);
}
void show_Param(const LEBEL_UI *UI, GUI_SWITCH *Group)
{
char Textstr_new[40] = {0};
@ -453,9 +521,19 @@ void show_Param(const LEBEL_UI *UI, GUI_SWITCH *Group)
const uint16_t text_y = row_y + (row_h - UI->Text_size) / 2 + 1;
uint8_t idx = Group->Current + 1;
uint16_t c_top, c_bot;
uint16_t x1, x2;
UI_GetFocusGrad(GUI_MODE_PARAM, &c_top, &c_bot);
x1 = (uint16_t)(UI->x + 10);
x2 = (uint16_t)(UI->x + UI->w - 10);
/* 修复曲目名尾部残留旧名比新名长时只填渐变胶囊x+25 ~ x+w-40
x+10 ~ x+w-10
*/
LCD_FillByColor(x1, row_y, x2, (uint16_t)(row_y + row_h), c_top);
LCD_FillRoundRectGradient(
UI->x+25, row_y,
UI->x + UI->w-40-(UI->x+25),
@ -464,18 +542,21 @@ void show_Param(const LEBEL_UI *UI, GUI_SWITCH *Group)
c_top, c_bot
);
// 拼接序号 1. 2. 3.
/* 序号+名称整体居中约等于标注「右移7px」的视觉效果 */
sprintf(NumBuf, "%d.", idx);
uint16_t num_w = (uint16_t)(strlen(NumBuf) * UI0902_ASCII_W(UI->Text_size));
if (Group->Id == EXPRESS_MODE_PARAM && Group->Current < LOCAL_SONG_COUNT)
strcpy(Textstr_new, LocalSongNameGbk[Group->Current]);
else
GET_ParamNum_Str(Group->Current, 0, Textstr_new, Group->Id);
uint32_t string_start_x = LCD_ShowMixedString(UI->x+10, UI->x + UI->w, text_y, (uint8_t *)Textstr_new , LCD_ALIGN_CENTER, WHITE, BLACK, UI->Text_size, 1);
//序号X = 中文起点 - 数字宽度 - 5<EFBC8C>?像素
uint16_t num_x = string_start_x - num_w;
// 绘制左侧数字序号
LCD_ShowString(num_x, text_y, (const uint8_t *)NumBuf, UI->Text_fc, UI->Text_bc, UI->Text_size, 1);
UI_DrawIndexedName_Mixed(x1, x2, text_y, NumBuf, Textstr_new,
WHITE, BLACK, UI->Text_size);
/* 补画左右三角(几何与 label_ModeSelece_ui 一致) */
{
uint16_t row_cy = (uint16_t)(row_y + row_h / 2);
UI_DrawTriLeft((uint16_t)(UI->x + 5), row_cy);
UI_DrawTriRight((uint16_t)(UI->x + UI->w - 5 - UI0902_TRI_R_W), row_cy);
}
}
@ -803,12 +884,8 @@ void label_ModeSelece_ui(const LEBEL_UI *UI,GUI_SWITCH * Group)
paramCurData = &ParamGuiData[SONG_MODE_PARAM];
}
/* 左右三角由 show_Param 末尾统一补画(整行清除后必须重画) */
show_Param(&UI_Label[GUI_MODE_PARAM], paramCurData);
{
uint16_t row_cy = UI->y + UI0902_TAB_H + 4 + (UI->h - UI0902_TAB_H - 4) / 2;
UI_DrawTriLeft(UI->x + 5, row_cy);
UI_DrawTriRight((uint16_t)(UI->x + UI->w - 5 - UI0902_TRI_R_W), row_cy);
}
}
void label_TimbreSelece_ui(const LEBEL_UI *UI, GUI_SWITCH * Group)
@ -851,22 +928,17 @@ void label_TimbreSelece_ui(const LEBEL_UI *UI, GUI_SWITCH * Group)
c_top, c_bot
);
sprintf(NumBuf, "%d.", idx);
uint16_t num_w = (uint16_t)(strlen(NumBuf) * UI0902_ASCII_W(UI->Text_size));
GET_Num_Str(mGuiData[UI->Id].Current, 0, (char*)Textstr, UI->Id);
uint32_t chinese_start_x = LCD_ShowChinese_AutoAlign_Gradient(
UI->x + num_w,
UI->x + UI->w,
UI_DrawIndexedName_Grad(
(uint16_t)(UI->x + 10),
(uint16_t)(UI->x + UI->w - 10),
text_y,
(const uint8_t*)Textstr,
UI->Text_Align,
NumBuf,
Textstr,
UI->Text_fc,
c_top, c_bot,
UI->Text_size
);
uint16_t num_x = chinese_start_x - num_w - 5;
LCD_ShowString_AutoAlign_Gradient(num_x, num_x + num_w, text_y,
(const uint8_t *)NumBuf, LCD_ALIGN_LEFT, UI->Text_fc,
c_top, c_bot, UI->Text_size);
switch(Group->Id)
{
case GUI_TIMBRE_SELECT:
@ -907,37 +979,32 @@ void UI_DrawSubPageHeader(const uint8_t *title_gbk)
void Draw_Bottom_Bar(uint8_t sel, uint8_t first_back)
{
/* 需求稿四键:万能 | 普通 | 专业 | 设置first_back 时设置位仍高亮(返回用触控逻辑) */
/* 四键合成图(图标+原稿文字);统一 32 高,文字底边对齐 */
const uint8_t *icons[4];
uint8_t ws[4], hs[4];
/* 0904四键中心约 40 / 100 / 160 / 210 */
uint8_t ws[4];
const uint16_t cx[4] = { 40, 100, 160, 210 };
const uint8_t icon_h = 32;
const uint16_t icon_y = 282;
uint8_t i;
(void)first_back;
/* 从段落钮底边之下清屏,勿切掉按钮底圆角(钮 y=231..264 */
LCD_FillByColor(0, (uint16_t)(UI0902_SECTION_Y + UI0902_SECTION_BTN_H),
240, 320, UI0902_BG_COLOR);
/* mode.png段落钮与底栏之间横线分隔与顶栏同色同厚 */
LCD_FillByColor(0, UI0902_SEP_LINE_Y, 240,
(uint16_t)(UI0902_SEP_LINE_Y + UI0902_SEP_LINE_THICK),
UI0902_SEP_LINE_COLOR);
if (sel == UI0902_NAV_UNIVERSAL) { icons[0] = gImage_Nav_Universal_Sel; ws[0] = 36; hs[0] = 28; }
else { icons[0] = gImage_Nav_Universal_Not; ws[0] = 36; hs[0] = 28; }
if (sel == UI0902_NAV_NORMAL) { icons[1] = gImage_Nav_Normal_Sel; ws[1] = 35; hs[1] = 29; }
else { icons[1] = gImage_Nav_Normal_Not; ws[1] = 35; hs[1] = 28; }
if (sel == UI0902_NAV_EXPERT) { icons[2] = gImage_Nav_Expert_Sel; ws[2] = 35; hs[2] = 27; }
else { icons[2] = gImage_Nav_Expert_Not; ws[2] = 35; hs[2] = 27; }
if (sel == UI0902_NAV_SETTING) { icons[3] = gImage_Nav_Setting_Sel; ws[3] = 18; hs[3] = 28; }
else { icons[3] = gImage_Nav_Setting_Not; ws[3] = 18; hs[3] = 28; }
icons[0] = (sel == UI0902_NAV_UNIVERSAL) ? gImage_Nav_Universal_Sel : gImage_Nav_Universal_Not;
icons[1] = (sel == UI0902_NAV_NORMAL) ? gImage_Nav_Normal_Sel : gImage_Nav_Normal_Not;
icons[2] = (sel == UI0902_NAV_EXPERT) ? gImage_Nav_Expert_Sel : gImage_Nav_Expert_Not;
icons[3] = (sel == UI0902_NAV_SETTING) ? gImage_Nav_Setting_Sel : gImage_Nav_Setting_Not;
ws[0] = ws[1] = ws[2] = 40;
ws[3] = 22;
for (i = 0; i < 4; i++)
{
LCD_WR_PIC_Trans(cx[i] - ws[i] / 2, 283, ws[i], hs[i], icons[i], BLACK);
LCD_WR_PIC_Trans((uint16_t)(cx[i] - ws[i] / 2), icon_y,
ws[i], icon_h, icons[i], BLACK);
}
}
@ -988,7 +1055,7 @@ void Draw_Tab_Section_Menu(const LEBEL_UI *UI, TAB_SECTION_INDEX select_tab)
const uint8_t pitch = UI0902_SECTION_PITCH;
const uint16_t start_x = UI0902_MARGIN_X; /* 0902-0412/69/126/183 */
const uint16_t start_y = UI->y;
/* 0819演奏段落数字与标题同为 13按钮内垂直居中视觉重心 +1 */
/* 0819演奏段落数字与标题同为 13按钮内垂直居中视觉重心 +1;稿面数字整体右移 2px */
const uint16_t text_y = start_y + (tab_h - UI0902_FONT_BODY) / 2 + 1;
const char *nums[4] = {"1", "2", "3", "4"};
uint8_t i;
@ -999,7 +1066,7 @@ void Draw_Tab_Section_Menu(const LEBEL_UI *UI, TAB_SECTION_INDEX select_tab)
uint16_t bg = (select_tab == i) ? COLOR_GRAD_MID : UI0902_ROW_BG;
LCD_FillRoundRect(x, start_y, tab_w, tab_h, tab_r, bg);
LCD_ShowString_AutoAlign(
x, (uint16_t)(x + tab_w - 1), text_y,
(uint16_t)(x + 2), (uint16_t)(x + tab_w - 1 + 2), text_y,
(const uint8_t*)nums[i],
UI->Text_Align,
WHITE,
@ -1223,11 +1290,11 @@ void Draw_System_Item_List(const LEBEL_UI *UI, GUI_SWITCH *Group)
}
break;
case GUI_BLUETOOTH_SW:
/* 素材:系统设置图标-11白蓝牙符Flash 槽名 RESTORE尺寸 13x21 */
/* 素材:系统设置图标-11白蓝牙符→ ICON_SET_BLUETOOTH 13x21 */
LCD_WR_PIC_FROM_FLASH_Trans(UI->x + 12,
(uint16_t)(UI->y + (UI->h - UI0902_ICON_SYS_RESTORE_H) / 2),
UI0902_ICON_SYS_RESTORE_W, UI0902_ICON_SYS_RESTORE_H,
UI0902_ICON_SYS_RESTORE_ADDR);
(uint16_t)(UI->y + (UI->h - UI0902_ICON_SET_BLUETOOTH_H) / 2),
UI0902_ICON_SET_BLUETOOTH_W, UI0902_ICON_SET_BLUETOOTH_H,
UI0902_ICON_SET_BLUETOOTH_ADDR);
UI_DrawTriLeft(val_x1, chev_cy);
if(Group->Current)
LCD_ShowChinese_AutoAlign_Gradient(val_x1 + UI0902_TRI_L_W, val_x2 - UI0902_TRI_R_W, text_y,(const uint8_t*)"\xCA\xC7",LCD_ALIGN_CENTER, WHITE,c_top, c_bot,value_sz);
@ -1254,9 +1321,9 @@ void Draw_System_Item_List(const LEBEL_UI *UI, GUI_SWITCH *Group)
WHITE, c_top, c_bot, value_sz);
}else
{
/* 手册「不自动」;字库无「不」,界面用稿面「关机」 */
/* 手册「不自动」;字库无「不」,界面显示「关闭」(字库已补「闭」) */
LCD_ShowChinese_AutoAlign_Gradient(val_x1 + UI0902_TRI_L_W, val_x2 - UI0902_TRI_R_W, text_y,
(const uint8_t*)"\xB9\xD8\xBB\xFA",
(const uint8_t*)"\xB9\xD8\xB1\xD5",
LCD_ALIGN_CENTER, WHITE, c_top, c_bot, value_sz);
}
UI_DrawTriRight((uint16_t)(val_x2 - UI0902_TRI_R_W), chev_cy);
@ -1264,13 +1331,17 @@ void Draw_System_Item_List(const LEBEL_UI *UI, GUI_SWITCH *Group)
drv_nvm_save_to_flash();
break;
case GUI_RESTORE:
/* 逆时针箭头:当前在 VERSION 槽(19x18)RESTORE 槽曾误为蓝牙符 */
/* 素材:系统设置图标-13逆时针恢复→ ICON_SYS_RESTORE 19x18 */
LCD_WR_PIC_FROM_FLASH_Trans(UI->x + 12,
(uint16_t)(UI->y + (UI->h - UI0902_ICON_SYS_VERSION_H) / 2),
UI0902_ICON_SYS_VERSION_W, UI0902_ICON_SYS_VERSION_H,
UI0902_ICON_SYS_VERSION_ADDR);
/* 右三角与蓝牙/自动关机同一右缘val_x2 */
UI_DrawTriRight((uint16_t)(val_x2 - UI0902_TRI_R_W), chev_cy);
(uint16_t)(UI->y + (UI->h - UI0902_ICON_SYS_RESTORE_H) / 2),
UI0902_ICON_SYS_RESTORE_W, UI0902_ICON_SYS_RESTORE_H,
UI0902_ICON_SYS_RESTORE_ADDR);
/* 0902-07右侧为进入下级页的细 chevron设置图标-03勿用实心三角 */
LCD_WR_PIC_FROM_FLASH_Trans(
(uint16_t)(val_x2 - UI0902_ICON_CHEVRON_W),
(uint16_t)(UI->y + (UI->h - UI0902_ICON_CHEVRON_H) / 2),
UI0902_ICON_CHEVRON_W, UI0902_ICON_CHEVRON_H,
UI0902_ICON_CHEVRON_ADDR);
break;
default: break;
}

View File

@ -173,19 +173,25 @@ void CallUI_RestoreSelect(void);
#define UI0902_NAV_EXPERT 2 /* 底栏:专业模式 */
#define UI0902_NAV_SETTING 3 /* 底栏:设置 */
#define UI0902_NAV_NONE 0xFF
/* 底栏触区:段落钮底(y264)之下四等分全宽,各 60×55 */
#define UI0902_NAV_Y0 265
#define UI0902_NAV_Y1 319
#define UI0902_NAV_W 60
#define UI0902_NAV_X0(i) ((uint16_t)((i) * UI0902_NAV_W))
#define UI0902_NAV_X1(i) ((uint16_t)((i) * UI0902_NAV_W + UI0902_NAV_W - 1))
/* 兼容旧名(已废弃三键:设置|调音台|模式) */
#define UI0902_NAV_MIXER UI0902_NAV_NORMAL
#define UI0902_NAV_MODE UI0902_NAV_EXPERT
/* 底部图标实际内容(生成脚本曾把 万能/普通/专业/设置 误命名为 Setting/Mixer/Mode */
#define gImage_Nav_Universal_Not gImage_UI0902_TabSetting_Sel_36x28 /* 底部-10 白 */
#define gImage_Nav_Universal_Sel gImage_UI0902_TabMode_Sel_36x28 /* 底部-14 蓝 */
#define gImage_Nav_Normal_Not gImage_UI0902_TabSetting_Not_35x28 /* 底部-11 白 */
#define gImage_Nav_Normal_Sel gImage_UI0902_TabMode_Not_35x29 /* 底部-15 蓝 */
#define gImage_Nav_Expert_Not gImage_UI0902_TabMixer_Sel_35x27 /* 底部-12 白 */
#define gImage_Nav_Expert_Sel gImage_UI0902_TabBack_Sel_35x27 /* 底部-16 蓝 */
#define gImage_Nav_Setting_Not gImage_UI0902_TabMixer_Not_18x28 /* 底部-13 白 */
#define gImage_Nav_Setting_Sel gImage_UI0902_TabBack_Not_18x28 /* 底部-17 蓝 */
/* 底部图标0909 加大图标 + 原稿文字条(合成图);生成脚本曾误命名 Setting/Mixer/Mode */
#define gImage_Nav_Universal_Not gImage_UI0902_TabSetting_Sel_40x32 /* 底部-10 白 */
#define gImage_Nav_Universal_Sel gImage_UI0902_TabMode_Sel_40x32 /* 底部-14 蓝 */
#define gImage_Nav_Normal_Not gImage_UI0902_TabSetting_Not_40x32 /* 底部-11 白 */
#define gImage_Nav_Normal_Sel gImage_UI0902_TabMode_Not_40x32 /* 底部-15 蓝 */
#define gImage_Nav_Expert_Not gImage_UI0902_TabMixer_Sel_40x32 /* 底部-12 白 */
#define gImage_Nav_Expert_Sel gImage_UI0902_TabBack_Sel_40x32 /* 底部-16 蓝 */
#define gImage_Nav_Setting_Not gImage_UI0902_TabMixer_Not_22x32 /* 底部-13 白 */
#define gImage_Nav_Setting_Sel gImage_UI0902_TabBack_Not_22x32 /* 底部-17 蓝 */
/* 旧几何电池Draw_Battery_Icon最左起点0902 图电池用 Draw_Status_Battery_Icon */
#define UI_STATUS_BATTERY_X 150
#define UI_STATUS_BATTERY_H 11

View File

@ -59,6 +59,7 @@ const char CHS8Table[][2] = {
{0xC0,0xAB}, // 阔
{0xCC,0xEC}, // 天
{0xBF,0xD5}, // 空
{0xB1,0xD5}, // 闭
};
@ -125,6 +126,7 @@ const char CHS9Table[][2] = {
{0xC0,0xAB}, // 阔
{0xCC,0xEC}, // 天
{0xBF,0xD5}, // 空
{0xB1,0xD5}, // 闭
};
@ -191,6 +193,7 @@ const char CHS11Table[][2] = {
{0xC0,0xAB}, // 阔
{0xCC,0xEC}, // 天
{0xBF,0xD5}, // 空
{0xB1,0xD5}, // 闭
};
@ -256,6 +259,7 @@ const char CHS12Table[][2] = {
{0xC0,0xAB}, // 阔
{0xCC,0xEC}, // 天
{0xBF,0xD5}, // 空
{0xB1,0xD5}, // 闭
};
@ -449,6 +453,7 @@ const char CHS13Table[][2] = {
{0xC0,0xAB}, // 阔
{0xCC,0xEC}, // 天
{0xBF,0xD5}, // 空
{0xB1,0xD5}, // 闭
};
@ -641,6 +646,7 @@ const char CHS15Table[][2] = {
{0xC0,0xAB}, // 阔
{0xCC,0xEC}, // 天
{0xBF,0xD5}, // 空
{0xB1,0xD5}, // 闭
};
@ -837,6 +843,7 @@ const char CHS16Table[][2] = {
{0xC0,0xAB}, // 阔
{0xCC,0xEC}, // 天
{0xBF,0xD5}, // 空
{0xB1,0xD5}, // 闭
};
@ -871,6 +878,7 @@ const char CHS24Table[][2] = {
{0xB6, 0xA8},// 认
{0xB7, 0xD6},// 分
{0xD6, 0xD3},// 钟
{0xB1,0xD5}, // 闭
};
@ -930,6 +938,7 @@ const unsigned char Chinese_font_8[][8] = {
{0x1F,0x41,0x89,0x89,0xA9,0xDD,0xD7,0xC1},/*"阔",52*/
{0x7E,0x00,0x00,0xFF,0x18,0x00,0x00,0x81},/*"天",53*/
{0x18,0x41,0x00,0x00,0x3C,0x00,0x00,0x7F},/*"空",54*/
{0x4F,0x01,0x89,0xBD,0x99,0xA9,0x89,0x83},/*"闭",55*/
};
const unsigned char Chinese_font_12[][24] = {
@ -993,6 +1002,7 @@ const unsigned char Chinese_font_12[][24] = {
{0x4F,0xE0,0x20,0x20,0xA1,0xA0,0x96,0x20,0xC2,0x20,0xAF,0xA0,0x92,0x20,0xAF,0xA0,0xE8,0xA0,0xAF,0xA0,0xA0,0x60,0x00,0x00},/*"阔",57*/
{0x7F,0xC0,0x04,0x00,0x04,0x00,0x04,0x00,0xFF,0xE0,0x04,0x00,0x0A,0x00,0x0A,0x00,0x11,0x00,0x20,0x80,0xC0,0x60,0x00,0x00},/*"天",58*/
{0x04,0x00,0x7F,0xE0,0x40,0x20,0x89,0x00,0x10,0x80,0x60,0x40,0x1F,0x80,0x04,0x00,0x04,0x00,0x04,0x00,0x7F,0xE0,0x00,0x00},/*"空",59*/
{0x47,0xE0,0x20,0x20,0x82,0x20,0x82,0x20,0xBF,0xA0,0x86,0x20,0x8A,0x20,0x92,0x20,0xA2,0x20,0x86,0x20,0x80,0xE0,0x00,0x00},/*"闭",60*/
};
const unsigned char Chinese_font_9[][18] = {
@ -1058,6 +1068,7 @@ const unsigned char Chinese_font_9[][18] = {
{0x5F,0x80,0x00,0x80,0x84,0x80,0x84,0x80,0x9E,0x80,0xA4,0x80,0xDE,0x80,0xDE,0x80,0x81,0x80},/*"阔",59*/
{0x7F,0x00,0x08,0x00,0x08,0x00,0xFF,0x80,0x08,0x00,0x14,0x00,0x14,0x00,0x00,0x00,0xC1,0x80},/*"天",60*/
{0x08,0x00,0x7F,0x80,0x80,0x00,0x00,0x00,0x41,0x00,0x1E,0x00,0x08,0x00,0x08,0x00,0x7F,0x80},/*"空",61*/
{0x4F,0x80,0x00,0x80,0x84,0x80,0xBE,0x80,0x8C,0x80,0x94,0x80,0xA4,0x80,0x8C,0x80,0x81,0x80},/*"闭",62*/
};
const unsigned char Chinese_font_11[][22] = {
@ -1123,6 +1134,7 @@ const unsigned char Chinese_font_11[][22] = {
{0x4F,0xE0,0x20,0x20,0xA1,0xA0,0x96,0x20,0xC2,0x20,0xAF,0xA0,0x92,0x20,0xAF,0xA0,0xE8,0xA0,0xAF,0xA0,0xA0,0x60},/*"阔",59*/
{0x7F,0xC0,0x04,0x00,0x04,0x00,0x04,0x00,0xFF,0xE0,0x04,0x00,0x0A,0x00,0x0A,0x00,0x11,0x00,0x20,0x80,0xC0,0x60},/*"天",60*/
{0x04,0x00,0x7F,0xE0,0x40,0x20,0x89,0x00,0x10,0x80,0x60,0x40,0x1F,0x80,0x04,0x00,0x04,0x00,0x04,0x00,0x7F,0xE0},/*"空",61*/
{0x47,0xE0,0x20,0x20,0x82,0x20,0x82,0x20,0xBF,0xA0,0x86,0x20,0x8A,0x20,0x92,0x20,0xA2,0x20,0x86,0x20,0x80,0xE0},/*"闭",62*/
};
@ -1320,6 +1332,7 @@ const unsigned char Chinese_font_13[][26] = {
{0x4F,0xE0,0x20,0x20,0xA1,0xA0,0x96,0x20,0xC2,0x20,0xAF,0xA0,0x92,0x20,0xAF,0xA0,0xE8,0xA0,0xAF,0xA0,0xA8,0xA0,0x80,0x60,0x00,0x00},/*"阔",186*/
{0x7F,0xE0,0x04,0x00,0x04,0x00,0x04,0x00,0xFF,0xF0,0x04,0x00,0x0A,0x00,0x0A,0x00,0x11,0x00,0x10,0x80,0x20,0x40,0xC0,0x30,0x00,0x00},/*"天",187*/
{0x04,0x00,0xFF,0xE0,0x80,0x20,0x91,0x20,0x20,0x80,0xC0,0x40,0x3F,0x80,0x04,0x00,0x04,0x00,0x04,0x00,0x04,0x00,0xFF,0xE0,0x00,0x00},/*"空",188*/
{0x4F,0xE0,0x20,0x20,0x42,0x20,0x42,0x20,0x7F,0xA0,0x42,0x20,0x46,0x20,0x4A,0x20,0x52,0x20,0x62,0x20,0x46,0x20,0x40,0x60,0x00,0x00},/*"闭",189*/
};
@ -1514,6 +1527,7 @@ const unsigned char Chinese_font_15[][30] = {
{0x20,0x08,0x1B,0xFC,0x08,0x08,0x50,0x68,0x4B,0x88,0x40,0x88,0x57,0xF8,0x48,0x88,0x4B,0xE8,0x5A,0x28,0x4A,0x28,0x4B,0xE8,0x4A,0x28,0x40,0x18,0x00,0x00},/*"阔",184*/
{0x00,0x00,0x3F,0xF0,0x02,0x00,0x02,0x00,0x02,0x00,0x02,0x00,0xFF,0xFC,0x02,0x00,0x05,0x00,0x04,0x80,0x08,0x40,0x10,0x20,0x20,0x1C,0xC0,0x08,0x00,0x00},/*"天",185*/
{0x04,0x00,0x02,0x00,0x7F,0xF8,0x40,0x10,0x88,0x80,0x10,0x40,0x20,0x20,0x40,0x10,0x3F,0xE0,0x02,0x00,0x02,0x00,0x02,0x00,0x02,0x00,0xFF,0xF8,0x00,0x00},/*"空",186*/
{0x20,0x00,0x1B,0xF8,0x08,0x08,0x40,0x88,0x40,0x88,0x5F,0xE8,0x40,0x88,0x41,0x88,0x42,0x88,0x44,0x88,0x48,0x88,0x43,0x88,0x40,0x28,0x40,0x10,0x00,0x00},/*"闭",187*/
};
const unsigned char Chinese_font_16[][32] = {
@ -1707,6 +1721,7 @@ const unsigned char Chinese_font_16[][32] = {
{0x20,0x00,0x17,0xFC,0x00,0x04,0x48,0x34,0x45,0xC4,0x64,0x44,0x50,0x44,0x51,0xF4,0x44,0x44,0x48,0x44,0x79,0xF4,0x49,0x14,0x49,0x14,0x49,0xF4,0x40,0x04,0x40,0x0C},/*"阔",187*/
{0x00,0x00,0x3F,0xF8,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0xFF,0xFE,0x01,0x00,0x02,0x80,0x02,0x80,0x04,0x40,0x04,0x40,0x08,0x20,0x10,0x10,0x20,0x08,0xC0,0x06},/*"天",188*/
{0x02,0x00,0x01,0x00,0x7F,0xFE,0x40,0x02,0x88,0x24,0x10,0x10,0x20,0x08,0x00,0x00,0x1F,0xF0,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x7F,0xFC,0x00,0x00},/*"空",189*/
{0x20,0x00,0x17,0xFC,0x00,0x04,0x40,0x84,0x40,0x84,0x5F,0xF4,0x40,0x84,0x41,0x84,0x42,0x84,0x44,0x84,0x48,0x84,0x50,0x84,0x42,0x84,0x41,0x04,0x40,0x14,0x40,0x08},/*"闭",190*/
};
const unsigned char Chinese_font_24[][72] = {
@ -1739,6 +1754,7 @@ const unsigned char Chinese_font_24[][72] = {
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x18,0x00,0x00,0x3C,0x00,0x00,0x3C,0x00,0x00,0x28,0x00,0x00,0x38,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},/*"认",26*/
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x24,0x00,0x00,0x3C,0x00,0x00,0x14,0x00,0x00,0x2C,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},/*"分",27*/
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x20,0x00,0x00,0x3C,0x00,0x00,0x3C,0x00,0x00,0x3C,0x00,0x00,0x20,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},/*"钟",28*/
{0x18,0x00,0x00,0x18,0x00,0x00,0x04,0xFF,0xF8,0x00,0x00,0x08,0x00,0x00,0x08,0x20,0x0C,0x08,0x20,0x0C,0x08,0x20,0x0C,0x08,0x27,0xFF,0xC8,0x20,0x0C,0x08,0x20,0x0C,0x08,0x20,0x1C,0x08,0x20,0x6C,0x08,0x20,0x6C,0x08,0x20,0x8C,0x08,0x23,0x0C,0x08,0x23,0x0C,0x08,0x24,0x0C,0x08,0x20,0x6C,0x08,0x20,0x6C,0x08,0x20,0x10,0x08,0x20,0x00,0x48,0x20,0x00,0x48,0x20,0x00,0x30},/*"闭",29*/
};

File diff suppressed because it is too large Load Diff

View File

@ -7,15 +7,15 @@ extern const unsigned char gImage_UI0902_BatteryOutline_29x12[];
extern const unsigned char gImage_UI0902_Volume_20x15[600];
extern const unsigned char gImage_UI0902_Bluetooth_11x17[374];
extern const unsigned char gImage_UI0902_BatteryFill_22x9[396];
extern const unsigned char gImage_UI0902_TabSetting_Sel_36x28[2016];
extern const unsigned char gImage_UI0902_TabSetting_Not_35x28[1960];
extern const unsigned char gImage_UI0902_TabMixer_Sel_35x27[1890];
extern const unsigned char gImage_UI0902_TabMixer_Not_18x28[1008];
extern const unsigned char gImage_UI0902_TabSetting_Sel_40x32[2560];
extern const unsigned char gImage_UI0902_TabSetting_Not_40x32[2560];
extern const unsigned char gImage_UI0902_TabMixer_Sel_40x32[2560];
extern const unsigned char gImage_UI0902_TabMixer_Not_22x32[1408];
extern const unsigned char gImage_UI0902_TabMixer_Not_35x28[1960];
extern const unsigned char gImage_UI0902_TabMode_Sel_36x28[2016];
extern const unsigned char gImage_UI0902_TabMode_Not_35x29[2030];
extern const unsigned char gImage_UI0902_TabBack_Sel_35x27[1890];
extern const unsigned char gImage_UI0902_TabBack_Not_18x28[1008];
extern const unsigned char gImage_UI0902_TabMode_Sel_40x32[2560];
extern const unsigned char gImage_UI0902_TabMode_Not_40x32[2560];
extern const unsigned char gImage_UI0902_TabBack_Sel_40x32[2560];
extern const unsigned char gImage_UI0902_TabBack_Not_22x32[1408];
extern const unsigned char gImage_UI0902_BtnMinus_23x22[1012];
extern const unsigned char gImage_UI0902_BtnPlus_23x22[1012];

View File

@ -1,6 +1,7 @@
// File: w25q128.c
#include "w25q128.h"
#include "wk_system.h"
#include "rtthread.h"
/* W25Q128 挂在 SPI1 上PA5=SCK, PA6=MISO, PA7=MOSICS 用 PA4 GPIO 控制 */
#define W25Q128_SPI SPI1
@ -82,6 +83,12 @@ static void W25Q128_WaitBusy(void)
W25Q128_ReadWriteByte(W25X_ReadStatusReg1); // 发送读状态寄存器命令
do {
status = W25Q128_ReadWriteByte(0xFF); // 循环读取状态字
if (status & 0x01) {
SPI_CS_HIGH();
rt_thread_mdelay(1);
SPI_CS_LOW();
W25Q128_ReadWriteByte(W25X_ReadStatusReg1);
}
} while (status & 0x01); // 等待BUSY位清零
SPI_CS_HIGH();
}

View File

@ -37,3 +37,22 @@ void SendMidiDataToDreamDSP(uint8_t* buff, uint16_t cnt)
{
USART2_SendData(buff,cnt);
}
#define AT32_UID_BASE 0x1FFFF7E8UL
void BleBuildDeviceName(char *out)
{
static const char hex[] = "0123456789ABCDEF";
const uint32_t *uid = (const uint32_t *)AT32_UID_BASE;
uint32_t tail = uid[2] & 0x00FFFFFFu;
unsigned i;
out[0] = 'P';
out[1] = 'H';
out[2] = 'O';
out[3] = 'N';
out[4] = '_';
for (i = 0; i < 6; i++)
out[5 + i] = hex[(tail >> (4 * (5 - i))) & 0xFu];
out[BLE_DEVICE_NAME_LEN] = '\0';
}

View File

@ -11,4 +11,8 @@ void SendVolumeChangeMidiEvent(uint8_t channel, uint8_t CC);
void SendMidiDataToDreamDSP(uint8_t* buff, uint16_t cnt);
void DefaultTask_SendMsg(uint16_t Data1, uint16_t Data2, uint16_t Data3, uint16_t Data4);
/* Unique BLE/BT name from AT32 UID: "PHON_" + 6 hex (last 3 UID bytes). out >= 12. */
#define BLE_DEVICE_NAME_LEN 11
void BleBuildDeviceName(char *out);
#endif /* __MIDI_SEND_H__ */

View File

@ -220,7 +220,8 @@ const STRING_MIDI *Chord_Midi_Table[7] =
const STRING_MIDI *OrganChord_Midi_Table[7] =
{ OrganMIDI_1,OrganMIDI_2,OrganMIDI_3,OrganMIDI_4,OrganMIDI_5,OrganMIDI_6, OrganMIDI_7 };
const uint8_t Timbre_index[5] = {0x18,0x19,0x1B,0x1E,0x22};
/* UI槽位0~4 → MIDI Program Change24尼龙 / 25钢弦 / 27清音 / 30失真 / 33 Finger贝斯 */
const uint8_t Timbre_index[5] = {0x18, 0x19, 0x1B, 0x1E, 0x21};

View File

@ -663,7 +663,7 @@
<name>BUILDACTION</name>
<archiveVersion>1</archiveVersion>
<data>
<prebuild></prebuild>
<prebuild>python &quot;$PROJ_DIR$\..\..\tools\gen_git_user_fw_ver.py&quot;</prebuild>
<postbuild></postbuild>
</data>
</settings>
@ -1650,7 +1650,7 @@
<name>BUILDACTION</name>
<archiveVersion>1</archiveVersion>
<data>
<prebuild></prebuild>
<prebuild>python &quot;$PROJ_DIR$\..\..\tools\gen_git_user_fw_ver.py&quot;</prebuild>
<postbuild></postbuild>
</data>
</settings>

View File

@ -4,10 +4,10 @@
/* Auto-generated by tools/pack_extflash_tone_0903.py — do not hand-edit. */
/* W25Q128 absolute offsets. Boot logo display uses UI0902_BOOT_LOGO_ADDR. */
/* Tone resource pack has no in-bin version field; release tag = EXTFLASH_TONE_RES_VER. */
#define EXTFLASH_TONE_RES_VER "0903"
#define EXTFLASH_TONE_RES_VER "0914"
#define EXTFLASH_TONE_RES_VER_MAJOR 0
#define EXTFLASH_TONE_RES_VER_MINOR 9
#define EXTFLASH_TONE_RES_VER_PATCH 3
#define EXTFLASH_TONE_RES_VER_PATCH 14
#define EXTFLASH_LOGO_ADDR 0x00000000UL
#define EXTFLASH_LOGO_SIZE 52080UL
@ -18,13 +18,13 @@
#define EXTFLASH_CHARGING_H 190
#define EXTFLASH_BIN1_RHYTHM_ADDR 0x0001B8F0UL
#define EXTFLASH_BIN1_RHYTHM_SIZE 530317UL
#define EXTFLASH_BIN1_RHYTHM_SIZE 509151UL
#define EXTFLASH_BIN2_SONG_HAITIAN_ADDR 0x0009D07DUL
#define EXTFLASH_BIN2_SONG_HAITIAN_SIZE 41263UL
#define EXTFLASH_BIN2_SONG_HAITIAN_SIZE 41914UL
#define EXTFLASH_BIN3_UNIVERSAL_ADDR 0x000A71ACUL
#define EXTFLASH_BIN3_UNIVERSAL_SIZE 31854UL
#define EXTFLASH_BIN3_UNIVERSAL_ADDR 0x000A7437UL
#define EXTFLASH_BIN3_UNIVERSAL_SIZE 33815UL
/* Convenience aliases used by UI ADDRESS */
#define FLASH_ADDR_MODE_NORMAL EXTFLASH_BIN1_RHYTHM_ADDR /* 普通 */
@ -32,11 +32,11 @@
#define FLASH_ADDR_SONG_HAITIAN EXTFLASH_BIN2_SONG_HAITIAN_ADDR
#define FLASH_ADDR_MODE_UNIVERSAL EXTFLASH_BIN3_UNIVERSAL_ADDR /* 万能 */
/* Legacy AutoBand bank — not repacked in 0903; left unchanged in firmware */
/* Legacy AutoBand bank — not repacked; may overlap 2.bin — do not enable until remapped */
#define FLASH_ADDR_AUTOBAND_LEGACY 0x0009EB5FUL
#define EXTFLASH_TONE_PACK_END 0x000AEE1AUL
#define EXTFLASH_TONE_PACK_SIZE 716314UL
#define EXTFLASH_TONE_PACK_END 0x000AF84EUL
#define EXTFLASH_TONE_PACK_SIZE 718926UL
#define UI0902_RES_BASE_SAFE_GAP (0x00100000UL - EXTFLASH_TONE_PACK_END)
#endif

View File

@ -9,6 +9,9 @@
void USART2_SendData(uint8_t *data, uint16_t len);
void USART4_SendData(uint8_t *data, uint16_t len);
/* 等待 UART4 发送环形缓冲排空(供关机 ACK 等场景) */
void USART4_WaitTxIdle(uint32_t timeout_ms);
/* 接收出队:返回 1=取到数据0=队列空 */
uint8_t USART2_RxPop(uint8_t *c);
uint8_t UART4_RxPop(uint8_t *c);

View File

@ -41,6 +41,9 @@ void wk_tmr6_1ms_init(void);
void wk_tmr6_1ms_tick(void);
uint32_t wk_tmr6_tick_get(void);
/* TMR7 1ms关机键看门狗优先级高于 TMR6主循环卡死仍可关 */
void wk_tmr7_pwrkey_init(void);
#ifdef __cplusplus
}
#endif

View File

@ -30,7 +30,9 @@
/* private includes ----------------------------------------------------------*/
/* add user code begin private includes */
/* 关机键看门狗:与 Global.c 解耦声明,避免 int.c 拉全量 includes */
void Power_Key_IsrTick(void);
void Power_Key_FaultLoop(void);
/* add user code end private includes */
/* private typedef -----------------------------------------------------------*/
@ -110,16 +112,7 @@ void NMI_Handler(void)
*/
void MemManage_Handler(void)
{
/* add user code begin MemoryManagement_IRQ 0 */
/* add user code end MemoryManagement_IRQ 0 */
/* go to infinite loop when memory manage exception occurs */
while (1)
{
/* add user code begin W1_MemoryManagement_IRQ 0 */
/* add user code end W1_MemoryManagement_IRQ 0 */
}
Power_Key_FaultLoop();
}
/**
@ -129,16 +122,7 @@ void MemManage_Handler(void)
*/
void BusFault_Handler(void)
{
/* add user code begin BusFault_IRQ 0 */
/* add user code end BusFault_IRQ 0 */
/* go to infinite loop when bus fault exception occurs */
while (1)
{
/* add user code begin W1_BusFault_IRQ 0 */
/* add user code end W1_BusFault_IRQ 0 */
}
Power_Key_FaultLoop();
}
/**
@ -148,16 +132,7 @@ void BusFault_Handler(void)
*/
void UsageFault_Handler(void)
{
/* add user code begin UsageFault_IRQ 0 */
/* add user code end UsageFault_IRQ 0 */
/* go to infinite loop when usage fault exception occurs */
while (1)
{
/* add user code begin W1_UsageFault_IRQ 0 */
/* add user code end W1_UsageFault_IRQ 0 */
}
Power_Key_FaultLoop();
}
/**
@ -241,6 +216,15 @@ void TMR6_GLOBAL_IRQHandler(void)
//rt_os_tick_callback();
}
/**
* @brief TMR7 TMR6
*/
void TMR7_GLOBAL_IRQHandler(void)
{
tmr_flag_clear(TMR7, TMR_OVF_FLAG);
Power_Key_IsrTick();
}
/* add user code begin 1 */
/* add user code end 1 */

View File

@ -88,6 +88,8 @@ int main(void)
drv_nvm_init();
TaskInit();
timer_init();
/* 关机键看门狗TMR7 + HardFault hook与主循环/伴奏 ISR 解耦 */
Power_Key_WatchdogInit();
InitMenuUI();
BSP_HT7178PowerEnable (1);
BSP_ChargerEnable(1);
@ -105,6 +107,7 @@ int main(void)
/* add user code begin 3 */
Power_Key_Scan();
AutoPowerOff_Scan();
System_PowerOff_Poll();
LCD_Dump_Poll();
#if !DEBUG_LCD_DUMP
app_log_poll();

View File

@ -3,14 +3,14 @@
#define BUFF_SIZE 512
/* 纯比较宏:参数必须是普通局部变量(非 volatile
访 volatile Pa082 */
/* ?????????????????????????????? volatile????
?????????????????? volatile ???? Pa082 ????????? */
#define IS_FULL(head, tail) ((((head) + 1) % BUFF_SIZE) == (tail))
#define IS_EMPTY(head, tail) ((head) == (tail))
/* ==================== 环形队列结构体 ==================== */
/* ==================== ???????????? ==================== */
/* 一个串口实例的环形队列(收发各一) */
/* ????????????????????????????? */
typedef struct
{
uint8_t rx_buff[BUFF_SIZE];
@ -21,14 +21,14 @@ typedef struct
volatile uint16_t tx_head;
volatile uint16_t tx_tail;
usart_type *usart; /* 关联的串口外设USART2 / UART4 */
usart_type *usart; /* ??????????????USART2 / UART4?? */
} uart_ring_t;
/* USART2 和 UART4 各一个实例 */
/* USART2 ?? UART4 ???????? */
static uart_ring_t uart2;
static uart_ring_t uart4;
/* 初始化:关联串口外设(由 wk_usart2_init / wk_uart4_init 调用) */
/* ?????????????????????? wk_usart2_init / wk_uart4_init ????? */
void uart_ring_usart2_init(void)
{
uart2.usart = USART2;
@ -39,19 +39,19 @@ void uart_ring_uart4_init(void)
uart4.usart = UART4;
}
/* ==================== 通用入队/出队(带实例参数) ==================== */
/* ==================== ??????/???????????????? ==================== */
/* 入队ISR 调用)。先快照 volatile 值再操作,避免 Pa082 */
/* ????ISR ???????????? volatile ???????????? Pa082 */
static void ring_rx_push(uart_ring_t *rb, uint8_t c)
{
uint16_t h = rb->rx_head;
uint16_t t = rb->rx_tail;
if(IS_FULL(h, t)) return; /* 满则丢,防止覆盖未读数据 */
if(IS_FULL(h, t)) return; /* ???????????????????? */
rb->rx_buff[h] = c;
rb->rx_head = (h + 1) % BUFF_SIZE;
}
/* 出队(业务线程调用) */
/* ??????????????? */
static uint8_t ring_rx_pop(uart_ring_t *rb, uint8_t *c)
{
uint16_t h = rb->rx_head;
@ -62,65 +62,81 @@ static uint8_t ring_rx_pop(uart_ring_t *rb, uint8_t *c)
return 1;
}
/* 写入 tx 队列(调用前应已关中断,防止 ISR 同时读) */
/* ???? tx ??????????????????????? ISR ?????? */
static void ring_tx_write(uart_ring_t *rb, uint8_t *data, uint16_t len)
{
uint16_t h = rb->tx_head;
uint16_t t = rb->tx_tail;
for(uint16_t i = 0; i < len; i++)
{
if(IS_FULL(h, t)) break; /* 用 tx 判满 */
if(IS_FULL(h, t)) break; /* ?? tx ???? */
rb->tx_buff[h] = data[i];
h = (h + 1) % BUFF_SIZE;
}
rb->tx_head = h; /* 最后一次性写回 volatile */
rb->tx_head = h; /* ???????????? volatile */
}
/* ==================== 发送接口(带串口参数) ==================== */
/* ==================== ?????????????????? ==================== */
/* 通用发送:关中断 → 写队列 → 开 TDBE 中断触发发送 */
/* ?????????????? ?? ?????? ?? ?? TDBE ??????????? */
static void uart_ring_send(uart_ring_t *rb, uint8_t *data, uint16_t len)
{
rt_base_t level = rt_hw_interrupt_disable();
ring_tx_write(rb, data, len);
usart_interrupt_enable(rb->usart, USART_TDBE_INT, TRUE); /* 开发送中断 */
usart_interrupt_enable(rb->usart, USART_TDBE_INT, TRUE); /* ?????????? */
rt_hw_interrupt_enable(level);
}
/* USART2 发送对外midi_send.c 等在用) */
/* USART2 ?????????midi_send.c ??????? */
void USART2_SendData(uint8_t *data, uint16_t len)
{
uart_ring_send(&uart2, data, len);
}
/* UART4 发送(对外) */
/* UART4 ????????? */
void USART4_SendData(uint8_t *data, uint16_t len)
{
uart_ring_send(&uart4, data, len);
}
/* USART2 接收出队(对外) */
void USART4_WaitTxIdle(uint32_t timeout_ms)
{
uint32_t t0 = rt_tick_get();
while ((rt_tick_get() - t0) < rt_tick_from_millisecond(timeout_ms ? timeout_ms : 1))
{
uint16_t h = uart4.tx_head;
uint16_t t = uart4.tx_tail;
if (IS_EMPTY(h, t))
{
/* <20><><EFBFBD><EFBFBD>պ<EFBFBD><D5BA>ٵ<EFBFBD><D9B5><EFBFBD>λ<EFBFBD>Ĵ<EFBFBD><C4B4><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> */
if (usart_flag_get(uart4.usart, USART_TDC_FLAG) == SET)
return;
}
rt_thread_mdelay(1);
}
}
/* USART2 ???????????? */
uint8_t USART2_RxPop(uint8_t *c)
{
return ring_rx_pop(&uart2, c);
}
/* UART4 接收出队(对外) */
/* UART4 ???????????? */
uint8_t UART4_RxPop(uint8_t *c)
{
return ring_rx_pop(&uart4, c);
}
/* ==================== 串口中断 ==================== */
/* ==================== ???????? ==================== */
static void uart_ring_isr(uart_ring_t *rb)
{
/* 接收:收到字节 → 入 rx 队列 */
if(usart_flag_get(rb->usart, USART_RDBF_FLAG) == SET)
{
uint8_t data = usart_data_receive(rb->usart); /* 读数据(自动清 RDBF */
uint8_t data = usart_data_receive(rb->usart);
ring_rx_push(rb, data);
if(rb->usart == UART4)
{
@ -128,7 +144,6 @@ static void uart_ring_isr(uart_ring_t *rb)
}
}
/* 发送tx 队列有数据 → 发一个字节;发完 → 关 TDBE 中断 */
if(usart_flag_get(rb->usart, USART_TDBE_FLAG) == SET)
{
uint16_t h = rb->tx_head;
@ -140,11 +155,10 @@ static void uart_ring_isr(uart_ring_t *rb)
}
else
{
usart_interrupt_enable(rb->usart, USART_TDBE_INT, FALSE); /* 发完关中断 */
usart_interrupt_enable(rb->usart, USART_TDBE_INT, FALSE);
}
}
/* 错误处理:清溢出标志,防止反复进中断卡死 */
if(usart_flag_get(rb->usart, USART_ROERR_FLAG) == SET)
usart_flag_clear(rb->usart, USART_ROERR_FLAG);
}

View File

@ -174,3 +174,16 @@ void wk_tmr6_1ms_init(void)
tmr_counter_enable(TMR6, TRUE);
}
/**
* @brief TMR7 1ms 1 TMR6=5 /
*/
void wk_tmr7_pwrkey_init(void)
{
crm_periph_clock_enable(CRM_TMR7_PERIPH_CLOCK, TRUE);
tmr_base_init(TMR7, 999, 239);
tmr_flag_clear(TMR7, TMR_OVF_FLAG);
tmr_interrupt_enable(TMR7, TMR_OVF_INT, TRUE);
nvic_irq_enable(TMR7_GLOBAL_IRQn, 1, 0);
tmr_counter_enable(TMR7, TRUE);
}

View File

@ -1,4 +1,5 @@
#include "includes.h"
#include "git_user_fw_ver.h"
// Э<><D0AD>֡ͷ/֡β
@ -25,7 +26,7 @@ typedef enum
UART4_RCV_BUFF_MIDIEND
} UART4_RCV_StatusType;
#define UART4_PROCESS_BUFF_SIZE 32
#define UART4_PROCESS_BUFF_SIZE 64 /* 02 04 full chord-map frame is 47 bytes */
// UART4<54><34><EFBFBD><EFBFBD>ȫ<EFBFBD>ֱ<EFBFBD><D6B1><EFBFBD>
static UART4_RCV_StatusType UART4_RCV_Status = UART4_RCV_BUFF_IDLE;
@ -48,22 +49,25 @@ typedef struct
// ==============================
// ָ<><EFBFBD><EEB4A6><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȫ<EFBFBD><C8AB> static<69><63>
// ==============================
//static void handleDevDisconnect(uint8_t *data);
//static void handleDevConnect(uint8_t *data);
//static void handleDevName(uint8_t *data);
//static void handleFwMainVer(uint8_t *data);
//static void handleSoundVer(uint8_t *data);
//static void handleUIVer(uint8_t *data);
//static void handleOtherInfo(uint8_t *data);
//static void handleDevCode(uint8_t *data);
//static void handleAutoPowerOff(uint8_t *data);
static void handleDevDisconnect(uint8_t *data);
static void handleDevConnect(uint8_t *data);
static void handleDevName(uint8_t *data);
static void handleFwMainVer(uint8_t *data);
static void handleSoundVer(uint8_t *data);
static void handleUIVer(uint8_t *data);
static void handleOtherInfo(uint8_t *data);
static void handleDevCode(uint8_t *data);
static void handleAutoPowerOff(uint8_t *data);
static void handleUserFwVer(uint8_t *data);
static void handleAutoPowerOffSet(uint8_t *data);
static void handleFwCode(uint8_t *data);
static void handleReadRhythmMap(uint8_t *data);
static void handlePitchOffset(uint8_t *data);
static void handleChordOffset(uint8_t *data);
//static void handleResetChordMap(uint8_t *data);
static void handleResetChordMap(uint8_t *data);
//static void handleRhythmStyle(uint8_t *data);
static void handleRhythmStyle(uint8_t *data);
static void handleStringTimbre(uint8_t *data);
static void handleBPM(uint8_t *data);
static void handleTranspose(uint8_t *data);
@ -75,9 +79,8 @@ static void handleLED3(uint8_t *data);
static void handleLED4(uint8_t *data);
static void handleLED5(uint8_t *data);
static void handleLED6(uint8_t *data);
//static void handleStopPlay(uint8_t *data);
//static void handleDeviceReset(uint8_t *data);
static void handleDeviceReset(uint8_t *data);
static void handleIntro(uint8_t *data);
static void handleInterlude(uint8_t *data);
@ -98,52 +101,54 @@ static void handleSectionD(uint8_t *data);
*************************************************/
static const BLE_SysExCmdItem bleSysExCmdTable[] =
{
// //======== 1. <20><>ȡ<EFBFBD><C8A1><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ϣ 0x01 ========
// {{0x01, 0x00}, handleDevDisconnect}, // <20>Ͽ<EFBFBD><CFBF>
// {{0x01, 0x01}, handleDevConnect}, // <20><><EFBFBD><EFBFBD><EFBFBD>
// {{0x01, 0x02}, handleDevName}, // <20><>ȡ<EFBFBD><EFBFBD><E8B1B8>
// {{0x01, 0x03}, handleFwMainVer}, // <20>̼<EFBFBD><CCBC><EFBFBD><EFBFBD>
// {{0x01, 0x04}, handleSoundVer}, // <20><>Դ<EFBFBD>
// {{0x01, 0x05}, handleUIVer}, // UI<55>
// {{0x01, 0x06}, handleOtherInfo}, // <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ϣ
// {{0x01, 0x07}, handleDevCode}, // <20><EFBFBD><E8B1B8><EFBFBD><EFBFBD>
// {{0x01, 0x0A}, handleAutoPowerOff}, // <20>Զ<EFBFBD><D4B6>ػ<EFBFBD>
//
// //======== 2. ӳ<><D3B3><EFBFBD><EFBFBD><EFBFBD><EFBFBD> 0x02 ========
{{0x02, 0x01}, handleReadRhythmMap}, // <20><>ȡ<EFBFBD><C8A1><EFBFBD><EFBFBD>/<2F><><EFBFBD><EFBFBD>ӳ<EFBFBD><D3B3><EFBFBD>
{{0x02, 0x02}, handlePitchOffset}, // Pitchƫ<68><C6AB>
{{0x02, 0x03}, handleChordOffset}, // Chordƫ<64><C6AB>
// {{0x02, 0x04}, handleResetChordMap}, // <20><><EFBFBD>ú<EFBFBD><C3BA><EFBFBD>ӳ<EFBFBD><D3B3>Ĭ<EFBFBD><C4AC>ֵ
/*======== 1. device info 0x01 ========*/
{{0x01, 0x00}, handleDevDisconnect}, /* disconnect */
{{0x01, 0x01}, handleDevConnect}, /* connect */
{{0x01, 0x02}, handleDevName}, /* device name */
{{0x01, 0x03}, handleFwMainVer}, /* fw main version */
{{0x01, 0x04}, handleSoundVer}, /* sound version */
{{0x01, 0x05}, handleUIVer}, /* UI version */
{{0x01, 0x06}, handleOtherInfo}, /* other info */
{{0x01, 0x07}, handleDevCode}, /* device code (96bit UID) */
{{0x01, 0x0A}, handleAutoPowerOff}, /* auto power-off read (minutes) */
{{0x01, 0x0C}, handleUserFwVer}, /* user fw version */
{{0x01, 0x11}, handleAutoPowerOffSet}, /* auto power-off set (minutes) */
{{0x01, 0x0F}, handleFwCode}, /* fw code (MIDI/BLE-safe; replaces 01 FF) */
{{0x01, 0xFF}, handleFwCode}, /* fw code alias (raw UART only; 0xFF illegal in BLE-MIDI SysEx) */
//======== 3. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>д 0x03 ========
// {{0x03, 0x04}, handleRhythmStyle}, // <20><>ȡ/<2F><><EFBFBD>ý<EFBFBD><C3BD><EFBFBD><EFBFBD><EFBFBD>
{{0x03, 0x05}, handleStringTimbre}, // <20><>ȡ/<2F><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ɫ
{{0x03, 0x06}, handleBPM}, // <20><>ȡ/<2F><><EFBFBD><EFBFBD>BPM
{{0x03, 0x07}, handleTranspose}, // <20><>ȡ/<2F><><EFBFBD><EFBFBD><EFBFBD>Ƶ<EFBFBD>
/*======== 2. chord map 0x02 ========*/
{{0x02, 0x01}, handleReadRhythmMap}, /* read chord/pitch map */
{{0x02, 0x02}, handlePitchOffset}, /* pitch offset */
{{0x02, 0x03}, handleChordOffset}, /* chord offset */
{{0x02, 0x04}, handleResetChordMap}, /* write whole map (default reset) */
//======== 4. <20><><EFBFBD><EFBFBD>/LED<45><44><EFBFBD><EFBFBD> 0x04 ========
{{0x04, 0x00}, handleLED0}, // <20><>1<EFBFBD><31>LED
{{0x04, 0x01}, handleLED1}, // <20><>2<EFBFBD><32>LED
{{0x04, 0x02}, handleLED2}, // <20><>3<EFBFBD><33>LED
{{0x04, 0x03}, handleLED3}, // <20><>4<EFBFBD><34>LED
{{0x04, 0x04}, handleLED4}, // <20><>5<EFBFBD><35>LED
{{0x04, 0x05}, handleLED5}, // <20><>6<EFBFBD><36>LED
{{0x04, 0x06}, handleLED6}, // <20><>7<EFBFBD><37>LED
// {{0x04, 0x07}, handleStopPlay}, // <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
/*======== 3. guitar params 0x03 ========*/
{{0x03, 0x04}, handleRhythmStyle}, /* rhythm style r/w + user list */
{{0x03, 0x05}, handleStringTimbre}, /* string timbre r/w */
{{0x03, 0x06}, handleBPM}, /* BPM r/w */
{{0x03, 0x07}, handleTranspose}, /* transpose r/w */
// //======== 5. <20><>λ/<2F>ػ<EFBFBD> 0x05 ========
// {{0x05, 0x00}, handleDeviceReset}, // <20><EFBFBD><E8B1B8>λ
//
// //======== 6. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ת 0x06 ========
{{0x06, 0x01}, handleIntro}, // ǰ<><C7B0>
{{0x06, 0x02}, handleInterlude}, // <20><><EFBFBD><EFBFBD>
{{0x06, 0x03}, handleOutro}, // β<><CEB2>
{{0x04, 0x07}, handleEnd}, // end
{{0x06, 0x05}, handleSectionA}, // A<><41>
{{0x06, 0x06}, handleSectionB}, // B<><42>
{{0x06, 0x07}, handleSectionC}, // C<><43>
{{0x06, 0x08}, handleSectionD}, // D<><44>
/*======== 4. play / LED 0x04 ========*/
{{0x04, 0x00}, handleLED0}, /* LED 1 */
{{0x04, 0x01}, handleLED1}, /* LED 2 */
{{0x04, 0x02}, handleLED2}, /* LED 3 */
{{0x04, 0x03}, handleLED3}, /* LED 4 */
{{0x04, 0x04}, handleLED4}, /* LED 5 */
{{0x04, 0x05}, handleLED5}, /* LED 6 */
{{0x04, 0x06}, handleLED6}, /* LED 7 */
{{0x04, 0x07}, handleEnd}, /* end / stop play */
/*======== 5. reset / power off 0x05 ========*/
{{0x05, 0x00}, handleDeviceReset}, /* device reset -> soft power off */
/*======== 6. section jump 0x06 ========*/
{{0x06, 0x01}, handleIntro}, /* intro */
{{0x06, 0x02}, handleInterlude}, /* interlude */
{{0x06, 0x03}, handleOutro}, /* outro */
{{0x06, 0x05}, handleSectionA}, /* section A */
{{0x06, 0x06}, handleSectionB}, /* section B */
{{0x06, 0x07}, handleSectionC}, /* section C */
{{0x06, 0x08}, handleSectionD}, /* section D */
};
// ָ<><D6B8><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
@ -158,14 +163,16 @@ static const BLE_SysExCmdItem bleSysExCmdTable[] =
*/
void processBLESysEXData(uint8_t* data, uint8_t cnt)
{
//ResetAutoPowerCount();
// <20><><EFBFBD><EFBFBD>֡У<D6A1><D0A3>
// ֡У<D6A1><D0A3>
if (data[0] != FRAME_SYS_HEAD || data[cnt - 1] != FRAME_SYS_TAIL || cnt > UART4_PROCESS_BUFF_SIZE || data[1] != 0x60)
{
return;
}
/* App/BLE 活动视为用户操作,避免测协议时静置触发自动关机 */
ResetAutoPowerCount();
LOG_I("BLE", "sysex ok len=%u cmd=%02X %02X", (unsigned)cnt, data[2], data[3]);
// <20><>ȡ <20><>ָ<EFBFBD><D6B8> + <20><>ָ<EFBFBD><D6B8>
// ȡ<EFBFBD><EFBFBD>ָ<EFBFBD><EFBFBD> + <20><>ָ<EFBFBD><D6B8>
uint8_t cmd[2] = {data[2], data[3]};
// <20><><EFBFBD><EFBFBD>ָ<EFBFBD><D6B8><EFBFBD>ƥ<EFBFBD><C6A5>
@ -195,6 +202,7 @@ void UART4_Data_Process(volatile uint8_t* data)
last_tick = now;
memset(UART4_Process_Buff, 0, sizeof(UART4_Process_Buff));
UART4_RCV_cnt = 0;
UART4_RCV_Status = UART4_RCV_BUFF_IDLE;
}
switch (UART4_RCV_Status)
@ -251,7 +259,6 @@ void UART4_Data_Process(volatile uint8_t* data)
}
else
{
// <20>Ƿ<EFBFBD><C7B7>ֽڣ<D6BD><DAA3><EFBFBD>λ
UART4_RCV_Status = UART4_RCV_BUFF_IDLE;
}
break;
@ -346,8 +353,9 @@ static void handleTranspose(uint8_t *data)
{
case 0:
{
uint8_t ReturnTranspose[8] = {0xF0,0x60,0x03,0x07,0x00,0x00,0x00,0xF7};
ReturnTranspose[5] = mGuiData[GUI_TRANSPOSE].Current;
/* doc: F0 60 03 07 00 <val> F7 (7 bytes total) */
uint8_t ReturnTranspose[7] = {0xF0,0x60,0x03,0x07,0x00,0x00,0xF7};
ReturnTranspose[5] = (uint8_t)mGuiData[GUI_TRANSPOSE].Current;
USART4_SendData(ReturnTranspose,sizeof(ReturnTranspose));
}
break;
@ -472,23 +480,40 @@ static void handleLED6(uint8_t *data)
static void handlePitchOffset(uint8_t *data)
{
BL_Sendmsg(MSG_ID_PITCH_OFFSET, data[4]+1, data[5], 0);
uint8_t key = (uint8_t)(data[4] + 1u);
if (key < 1u || key > 21u)
return;
BL_Sendmsg(MSG_ID_PITCH_OFFSET, key, data[5], 0);
}
static void handleChordOffset(uint8_t *data)
{
BL_Sendmsg(MSG_ID_CHORD_OFFSET, data[4]+1, data[5], 0);
uint8_t key = (uint8_t)(data[4] + 1u);
if (key < 1u || key > 21u)
return;
BL_Sendmsg(MSG_ID_CHORD_OFFSET, key, data[5], 0);
}
extern CHORD_TYPE_INDEX chord_type_index_map[22];
static void handleReadRhythmMap(uint8_t *data)
{
uint8_t ReadChordMap[47] = {0xF0, 0x60, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF7};
/* 静态缓冲:禁止在 TaskBTRecv 小栈上再开 47B */
static uint8_t ReadChordMap[47];
uint8_t chord_map = 0;
for(uint8_t i = 1;i < 22;i ++)
uint8_t i;
(void)data;
memset(ReadChordMap, 0, sizeof(ReadChordMap));
ReadChordMap[0] = 0xF0;
ReadChordMap[1] = 0x60;
ReadChordMap[2] = 0x02;
ReadChordMap[3] = 0x01;
ReadChordMap[46] = 0xF7;
for (i = 1; i < 22; i++)
{
ReadChordMap[3 + i] = chord_type_index_map[i].PitchOffset;
}
for(uint8_t i = 25;i < 46;i ++)
for (i = 25; i < 46; i++)
{
switch (chord_type_index_map[i - 24].type)
{
@ -519,6 +544,9 @@ static void handleReadRhythmMap(uint8_t *data)
case 8:
chord_map = 11;
break;
default:
chord_map = 0;
break;
}
ReadChordMap[i] = chord_map;
}
@ -546,7 +574,7 @@ static void handleEnd(uint8_t *data)
// TM1629D_AllLedOn(LED_COLOR_G);
// TM1629D_UpdateDisplay(0);
// osMutexRelease(Tm1629Mutex);
BL_Sendmsg(MSG_ID_ADCIN1KEY, 1, 0, 0);
BL_Sendmsg(MSG_ID_ADCIN1KEY, 2, 0, 0); /* End -> Postamble(1), distinct from Outro(key=1) */
}
static void handleSectionA(uint8_t *data)
@ -570,3 +598,270 @@ static void BL_Sendmsg(uint16_t Data1, uint16_t Data2, uint16_t Data3, uint16_t
{
BLTask_Sendmsg(Data1, Data2, Data3, Data4);
}
/* ======================================================================
* Appended protocol handlers (per Doc/.docx)
* ==================================================================== */
/*-------- 1. device info 0x01 --------*/
#define AT32_UID_BASE 0x1FFFF7E8UL /* 96-bit unique device ID */
/* 01 04 音源版本YY.M.D = 音色资源日期Dream 无版本查询,发版时手改)
* 26.9.8 2026-09-08Doc//0908 */
static const uint8_t SOUND_VER[4] = {26, 9, 8, 0x01};
/* 01 05 UI 资源/界面日期YY.M.D与 MCU 逻辑版本 Version[] 分开维护) */
static const uint8_t UI_VER[4] = {26, 9, 9, 0x01};
static void handleDevDisconnect(uint8_t *data)
{
uint8_t resp[6] = {0xF0, 0x60, 0x01, 0x00, 0x01, 0xF7};
(void)data;
USART4_SendData(resp, sizeof(resp));
}
static void handleDevConnect(uint8_t *data)
{
uint8_t resp[6] = {0xF0, 0x60, 0x01, 0x01, 0x01, 0xF7};
(void)data;
ResetAutoPowerCount();
USART4_SendData(resp, sizeof(resp));
}
static void handleDevName(uint8_t *data)
{
static char name[BLE_DEVICE_NAME_LEN + 1];
static uint8_t resp[4 + BLE_DEVICE_NAME_LEN + 1]; /* head4 + name + F7 */
(void)data;
BleBuildDeviceName(name);
resp[0] = 0xF0; resp[1] = 0x60; resp[2] = 0x01; resp[3] = 0x02;
memcpy(&resp[4], name, BLE_DEVICE_NAME_LEN);
resp[4 + BLE_DEVICE_NAME_LEN] = 0xF7;
USART4_SendData(resp, (uint16_t)sizeof(resp));
}
static void handleFwMainVer(uint8_t *data)
{
uint8_t resp[9] = {0xF0, 0x60, 0x01, 0x03, Version[0], Version[1], Version[2], 0x01, 0xF7};
(void)data;
USART4_SendData(resp, sizeof(resp));
}
static void handleSoundVer(uint8_t *data)
{
uint8_t resp[9] = {0xF0, 0x60, 0x01, 0x04, SOUND_VER[0], SOUND_VER[1], SOUND_VER[2], SOUND_VER[3], 0xF7};
(void)data;
USART4_SendData(resp, sizeof(resp));
}
static void handleUIVer(uint8_t *data)
{
uint8_t resp[9] = {0xF0, 0x60, 0x01, 0x05, UI_VER[0], UI_VER[1], UI_VER[2], UI_VER[3], 0xF7};
(void)data;
USART4_SendData(resp, sizeof(resp));
}
static void handleOtherInfo(uint8_t *data)
{
/* 01 06: reserved — keep 8 zero bytes until product defines fields */
uint8_t resp[13] = {0xF0, 0x60, 0x01, 0x06, 0, 0, 0, 0, 0, 0, 0, 0, 0xF7};
(void)data;
USART4_SendData(resp, sizeof(resp));
}
static void handleDevCode(uint8_t *data)
{
/* 96-bit UID as 24 hex ASCII — SysEx data must stay 7-bit for BLE-MIDI */
static const char hex[] = "0123456789ABCDEF";
uint8_t resp[29];
const uint8_t *uid = (const uint8_t *)AT32_UID_BASE;
uint8_t i;
(void)data;
resp[0] = 0xF0;
resp[1] = 0x60;
resp[2] = 0x01;
resp[3] = 0x07;
for (i = 0; i < 12u; i++)
{
resp[4u + 2u * i] = (uint8_t)hex[uid[i] >> 4];
resp[4u + 2u * i + 1u] = (uint8_t)hex[uid[i] & 0x0Fu];
}
resp[28] = 0xF7;
USART4_SendData(resp, sizeof(resp));
}
static void handleAutoPowerOff(uint8_t *data)
{
uint8_t resp[6] = {0xF0, 0x60, 0x01, 0x0A, 0x00, 0xF7};
(void)data;
resp[4] = AutoCloseTime;
USART4_SendData(resp, sizeof(resp));
}
static void handleUserFwVer(uint8_t *data)
{
/* 用户固件版本 = "{branch}_{short6}"[+ '*'],如 develop_0aedb4ASCIIBLE-MIDI 安全) */
uint8_t resp[4u + GIT_BUILD_ID_LEN + 1u];
(void)data;
resp[0] = 0xF0;
resp[1] = 0x60;
resp[2] = 0x01;
resp[3] = 0x0C;
memcpy(&resp[4], GIT_BUILD_ID, GIT_BUILD_ID_LEN);
resp[4u + GIT_BUILD_ID_LEN] = 0xF7;
USART4_SendData(resp, (uint16_t)sizeof(resp));
}
/* F0 60 01 11 <minutes> F7 : set auto power-off, 0 = disable */
static void handleAutoPowerOffSet(uint8_t *data)
{
NvmParam_Type *nvm = drv_nvm_param_ptr();
uint8_t resp[6] = {0xF0, 0x60, 0x01, 0x11, 0x00, 0xF7};
AutoCloseTime = data[4];
if (nvm->param.AutoCloseTime != AutoCloseTime)
{
nvm->param.AutoCloseTime = AutoCloseTime;
drv_nvm_save_to_flash();
}
ResetAutoPowerCount();
resp[4] = AutoCloseTime;
USART4_SendData(resp, sizeof(resp));
}
static void handleFwCode(uint8_t *data)
{
/* 固件编码: 请求 01 0F推荐或 01 FF仅裸串口应答统一 01 0F + ASCII避免 BLE-MIDI 吃掉 0xFF */
uint8_t resp[16];
uint8_t len = (uint8_t)strlen(fwname);
(void)data;
if (len > 10u)
len = 10u;
resp[0] = 0xF0;
resp[1] = 0x60;
resp[2] = 0x01;
resp[3] = 0x0F;
memcpy(&resp[4], fwname, len);
resp[4 + len] = 0xF7;
USART4_SendData(resp, (uint16_t)(4u + len + 1u));
}
/*-------- 2. chord map 0x02 --------*/
/* F0 60 02 04 <21B pitch> <21B chord> F7 : write whole map; reply in 02 01 format */
static void handleResetChordMap(uint8_t *data)
{
uint8_t key;
for (key = 1; key <= 21; key++)
{
BT_Pitch_offset_map(key, data[3 + key]); /* data[4..24] pitch offsets */
BT_Chord_offset_map(key, data[24 + key]); /* data[25..45] chord types */
}
handleReadRhythmMap(data);
}
/*-------- 3. rhythm style 0x03 0x04 --------*/
static void handleRhythmStyle(uint8_t *data)
{
switch (data[4])
{
case 0: /* read current style: F0 60 03 04 00 <src> <idHi> <idLo> F7 */
{
uint8_t resp[9] = {0xF0, 0x60, 0x03, 0x04, 0x00, 0x00, 0x00, 0x00, 0xF7};
uint8_t src = (uint8_t)(mGuiData[GUI_AUTOBAND_SW].Current ? 1 : 0);
uint16_t id = src ? ParamGuiData[EXPRESS_MODE_PARAM].Current
: ParamGuiData[SONG_MODE_PARAM].Current;
resp[5] = src;
resp[6] = (uint8_t)(id / 128);
resp[7] = (uint8_t)(id % 128);
USART4_SendData(resp, sizeof(resp));
}
break;
case 1: /* set style: F0 60 03 04 01 <src:0 sys/1 user> <idHi> <idLo> F7, reply BPM */
{
uint8_t src = (data[5] != 0) ? 1 : 0;
uint16_t id = (uint16_t)data[6] * 128 + data[7];
GUI_SWITCH *slot = src ? &ParamGuiData[EXPRESS_MODE_PARAM]
: &ParamGuiData[SONG_MODE_PARAM];
uint8_t resp[7] = {0xF0, 0x60, 0x03, 0x04, 0x01, 0x00, 0xF7};
if (src)
slot->Max = (LOCAL_SONG_COUNT > 0) ? (uint16_t)(LOCAL_SONG_COUNT - 1) : 0;
if (id > slot->Max)
id = slot->Max;
mGuiData[GUI_AUTOBAND_SW].Current = src;
slot->Current = id;
StartFlag = 0;
AutoBandTop1_Stop();
UI_ReloadTonePreset();
/* doc: single-byte bpm; clamp >127 (App should read exact bpm via 03 06) */
resp[5] = (mGuiData[GUI_SPEED].Current > 0x7F)
? 0x7F : (uint8_t)mGuiData[GUI_SPEED].Current;
USART4_SendData(resp, sizeof(resp));
/* boot / song-entry special: F0 60 03 04 01 00 00 00 F7
doc: also push current BPM as active report F0 51 03 <hi> <lo> F7 */
if (data[5] == 0 && data[6] == 0 && data[7] == 0)
{
uint8_t bpmr[6] = {0xF0, 0x51, 0x03, 0x00, 0x00, 0xF7};
bpmr[3] = (uint8_t)(mGuiData[GUI_SPEED].Current / 128);
bpmr[4] = (uint8_t)(mGuiData[GUI_SPEED].Current % 128);
USART4_SendData(bpmr, sizeof(bpmr));
}
}
break;
case 2: /* user style list page: F0 60 03 04 02 <from3B> <to3B> F7
reply: F0 60 03 04 02 <cntHi> <cntLo> [<idHi> <idLo> <code 4B ascii> ...] F7 */
{
uint8_t resp[64];
uint32_t from = 0, to = LOCAL_SONG_COUNT;
uint16_t i, n = 0, len;
/* from/to: 24-bit big-endian, optional (doc example: 00 00 00 07) */
if (data[5] || data[6] || data[7])
{
from = ((uint32_t)data[5] << 16) | ((uint32_t)data[6] << 8) | data[7];
to = ((uint32_t)data[8] << 16) | ((uint32_t)data[9] << 8) | data[10];
}
if (from > LOCAL_SONG_COUNT) from = LOCAL_SONG_COUNT;
if (to > LOCAL_SONG_COUNT || to <= from) to = LOCAL_SONG_COUNT;
resp[0] = 0xF0; resp[1] = 0x60; resp[2] = 0x03; resp[3] = 0x04; resp[4] = 0x02;
len = 7; /* count filled after loop */
for (i = (uint16_t)from; i < to; i++)
{
if (len + 6 + 1 > sizeof(resp)) break;
resp[len++] = (uint8_t)(i / 128);
resp[len++] = (uint8_t)(i % 128);
memcpy(&resp[len], LocalSongCode[i], 4);
len += 4;
n++;
}
resp[5] = (uint8_t)(n / 128);
resp[6] = (uint8_t)(n % 128);
resp[len++] = 0xF7;
USART4_SendData(resp, len);
}
break;
case 3: /* user style total: reply F0 60 03 04 00 <total 3B BE> F7 (doc: 00 00 1E) */
{
uint8_t resp[9] = {0xF0, 0x60, 0x03, 0x04, 0x00, 0x00, 0x00, 0x00, 0xF7};
resp[5] = (uint8_t)((LOCAL_SONG_COUNT >> 16) & 0xFF);
resp[6] = (uint8_t)((LOCAL_SONG_COUNT >> 8) & 0xFF);
resp[7] = (uint8_t)(LOCAL_SONG_COUNT & 0xFF);
USART4_SendData(resp, sizeof(resp));
}
break;
default:
break;
}
}
/*-------- 5. reset / power off 0x05 --------*/
/* F0 60 05 00 F7 : ack then soft power off (executed in main-loop context) */
static void handleDeviceReset(uint8_t *data)
{
uint8_t resp[6] = {0xF0, 0x60, 0x05, 0x00, 0x01, 0xF7};
(void)data;
LOG_I("BLE", "05 00 ack then soft power off");
USART4_SendData(resp, sizeof(resp));
/* Soft-off cuts BT power immediately in PowerOff(); give UART4 + ATS2853
time to push the ACK Notify before requesting power-off. */
USART4_WaitTxIdle(50);
rt_thread_mdelay(120);
System_RequestPowerOff();
}

View File

@ -0,0 +1,16 @@
/* Auto-generated by tools/gen_git_user_fw_ver.py — do not edit.
* git HEAD (dirty): 61caeb7407b978f03d57fac49e0e7142fca755c1
* branch: develop develop
* 01 0C wire: develop_61caeb*
*/
#ifndef GIT_USER_FW_VER_H
#define GIT_USER_FW_VER_H
#define GIT_COMMIT_ID_FULL "61caeb7407b978f03d57fac49e0e7142fca755c1"
#define GIT_BRANCH_NAME "develop"
#define GIT_COMMIT_SHORT6 "61caeb"
#define GIT_DIRTY (1)
#define GIT_BUILD_ID "develop_61caeb*"
#define GIT_BUILD_ID_LEN 15u
#endif /* GIT_USER_FW_VER_H */

8
run_k1_harness.bat Normal file
View File

@ -0,0 +1,8 @@
@echo off
REM Launch K1 HIL harness from repo root or any cwd.
REM Usage: run_k1_harness.bat selftest
REM run_k1_harness.bat run --suite all --profile nightly
setlocal
cd /d "%~dp0tools"
python -m k1_harness %*
exit /b %ERRORLEVEL%

18
run_k1_harness.py Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Repo-root launcher: python run_k1_harness.py ... (same as cd tools && python -m k1_harness ...)"""
from __future__ import annotations
import os
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
TOOLS = os.path.join(ROOT, "tools")
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
os.chdir(TOOLS)
from k1_harness.cli import main # noqa: E402
if __name__ == "__main__":
main()

View File

@ -13,7 +13,7 @@ ALIGN(RT_ALIGN_SIZE) static rt_uint8_t TaskUIThread_Stack[2048];
ALIGN(RT_ALIGN_SIZE) static rt_uint8_t TaskTouchThread_Stack[1536];
ALIGN(RT_ALIGN_SIZE) static rt_uint8_t TaskMainThread_Stack[512];
ALIGN(RT_ALIGN_SIZE) static rt_uint8_t TaskBTTHandlehread_Stack[512];
ALIGN(RT_ALIGN_SIZE) static rt_uint8_t TaskBTRecvThread_Stack[512];
ALIGN(RT_ALIGN_SIZE) static rt_uint8_t TaskBTRecvThread_Stack[768];
ALIGN(RT_ALIGN_SIZE) static rt_uint8_t TaskAutobandThread_Stack[512];
@ -115,6 +115,9 @@ void TaskBTHandleThread_entry(void* parameter)
case MSG_ID_LIGHT_LED : BL_Set_led(xEvent.ID,xEvent.HiByte); break;
case MSG_ID_PITCH_OFFSET : BT_Pitch_offset_map(xEvent.ID,xEvent.HiByte); break;
case MSG_ID_CHORD_OFFSET : BT_Chord_offset_map(xEvent.ID,xEvent.HiByte); break;
/* BLE section / intro-outro-end commands (06 xx, 04 07) */
case MSG_ID_ADCIN1KEY : ADC_IN1_KEY_Handle((uint8_t)xEvent.ID, true); break;
case MSG_ID_KEY_1617 : TM1617_Handle((uint8_t)xEvent.ID); break;
}
}
}
@ -129,7 +132,6 @@ void TaskBTRecvThread_entry(void* parameter)
{
while(UART4_RxPop(&c))
{
//解析函数
UART4_Data_Process(&c);
}
}
@ -238,6 +240,35 @@ void StartTask(void)
/* 扫描任务可能已由 StartScanTask 拉起;重复 startup 会被 RT-Thread 忽略 */
StartScanTask();
/* Soft-off(StopFullTask) 会 detach 下列线程;开机须能重建,否则 UI/消息永久失效 */
if ((TaskMainThread.stat & RT_THREAD_STAT_MASK) == RT_THREAD_CLOSE)
{
rt_thread_init(&TaskMainThread,
"TaskMainThread",
TaskMainThread_entry,
RT_NULL,
&TaskMainThread_Stack,
sizeof(TaskMainThread_Stack),
5,
20);
}
if ((TaskMainThread.stat & RT_THREAD_STAT_MASK) == RT_THREAD_INIT)
rt_thread_startup(&TaskMainThread);
if ((TaskUIThread.stat & RT_THREAD_STAT_MASK) == RT_THREAD_CLOSE)
{
rt_thread_init(&TaskUIThread,
"TaskUIThread",
TaskUIThread_entry,
RT_NULL,
&TaskUIThread_Stack,
sizeof(TaskUIThread_Stack),
5,
20);
}
if ((TaskUIThread.stat & RT_THREAD_STAT_MASK) == RT_THREAD_INIT)
rt_thread_startup(&TaskUIThread);
if ((TaskBTHandleThread.stat & RT_THREAD_STAT_MASK) == RT_THREAD_CLOSE)
{
rt_thread_init(&TaskBTHandleThread,
@ -334,18 +365,12 @@ void StopFullTask(void)
LOG_I("TASK", "stop ScanThread");
rt_thread_detach(&TaskScanThread);
}
if((TaskUIThread.stat & RT_THREAD_STAT_MASK) != RT_THREAD_CLOSE) {
LOG_I("TASK", "stop UIThread");
rt_thread_detach(&TaskUIThread);
}
/* 软关机 = 系统关闭:停触摸/扫描/BT/伴奏。
Main/UI Type-C Charging Image POWER_ON */
if((TaskTouchThread.stat & RT_THREAD_STAT_MASK) != RT_THREAD_CLOSE) {
LOG_I("TASK", "stop TouchThread");
rt_thread_detach(&TaskTouchThread);
}
if((TaskMainThread.stat & RT_THREAD_STAT_MASK) != RT_THREAD_CLOSE) {
LOG_I("TASK", "stop MainThread");
rt_thread_detach(&TaskMainThread);
}
if((TaskBTHandleThread.stat & RT_THREAD_STAT_MASK) != RT_THREAD_CLOSE) {
LOG_I("TASK", "stop BTHandleThread");
rt_thread_detach(&TaskBTHandleThread);
@ -354,6 +379,10 @@ void StopFullTask(void)
LOG_I("TASK", "stop BTRecvThread");
rt_thread_detach(&TaskBTRecvThread);
}
if((TaskAutobandThread.stat & RT_THREAD_STAT_MASK) != RT_THREAD_CLOSE) {
LOG_I("TASK", "stop AutobandThread");
rt_thread_detach(&TaskAutobandThread);
}
LOG_I("TASK", "StopFullTask done");
}

View File

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
"""Systematic BLE channel matrix for Smart Guitar MIDI App SysEx."""
from __future__ import annotations
import asyncio
import time
from bleak import BleakClient, BleakScanner
NAME = "Smart Guitar MIDI"
MIDI = "7772e5db-3868-4112-a1a9-f2669d106bf3"
UARTW = "e49a25e0-f69a-11e8-8eb2-f2801f1b9fd1"
UARTN = "e49a28e1-f69a-11e8-8eb2-f2801f1b9fd1"
EFF2 = "0000eff2-0000-1000-8000-00805f9b34fb"
SYSEX = bytes.fromhex("F0600101F7")
FRAMED = bytes.fromhex("8080F0600101F7")
FRAMED_TSF7 = bytes.fromhex("8080F060010180F7")
def hx(b: bytes) -> str:
return " ".join(f"{x:02X}" for x in b) if b else "(none)"
async def find(timeout=60.0):
t0 = time.time()
while time.time() - t0 < timeout:
d = await BleakScanner.find_device_by_filter(
lambda d, a: d.name and NAME.lower() in d.name.lower(), timeout=8
)
if d:
return d
print("waiting advertise...")
return None
async def unpair_if_needed(addr: str):
try:
c = BleakClient(addr, timeout=15)
await c.connect()
try:
await c.unpair()
print("unpaired")
except Exception as e:
print("unpair skip:", e)
try:
await c.disconnect()
except Exception:
pass
await asyncio.sleep(1.5)
except Exception as e:
print("unpair session:", e)
async def one_session(tag: str, write_uuid: str, payload: bytes, notify_uuids, settle=2.5):
d = await find(45)
if not d:
print(f"[{tag}] NO DEVICE")
return {"tag": tag, "ok": False, "err": "no device", "notifs": []}
print(f"\n=== {tag} === write {write_uuid[:8]} {hx(payload)}")
notifs = []
err = None
connected_end = False
try:
async with BleakClient(d, timeout=25) as c:
print("connected", c.is_connected)
def cb(sender, data):
b = bytes(data)
notifs.append(b)
print(f" NOTIFY {hx(b)}")
for u in notify_uuids:
try:
await c.start_notify(u, cb)
print(" notify on", u[:8])
except Exception as e:
print(" notify fail", u[:8], e)
await asyncio.sleep(0.3)
try:
await c.write_gatt_char(write_uuid, payload, response=False)
print(" write ok")
except Exception as e:
err = f"write: {e}"
print(" write fail", e)
await asyncio.sleep(settle)
connected_end = c.is_connected
print(" end conn=", connected_end, "notifs=", len(notifs))
except Exception as e:
err = str(e)
print(" session err", e)
return {
"tag": tag,
"ok": any(b[:3] == b"\xF0\x60\x01" or b[:4] == b"\x80\x80\xF0\x60" for b in notifs),
"notifs": [hx(b) for b in notifs],
"conn_end": connected_end,
"err": err,
}
async def main():
d = await find(60)
if not d:
print("FATAL: device not advertising")
return
print("found", d.name, d.address)
await unpair_if_needed(d.address)
await asyncio.sleep(2)
results = []
# matrix: do NOT put uartw first if it kills radio; do midi first
cases = [
("midi-framed", MIDI, FRAMED, [MIDI, UARTN, EFF2]),
("midi-framed-tsF7", MIDI, FRAMED_TSF7, [MIDI]),
("midi-raw", MIDI, SYSEX, [MIDI]),
("eff2-raw", EFF2, SYSEX, [EFF2, MIDI, UARTN]),
("eff2-framed", EFF2, FRAMED, [EFF2, MIDI]),
("uartw-raw", UARTW, SYSEX, [UARTN, MIDI, EFF2]),
("uartw-framed", UARTW, FRAMED, [UARTN, MIDI]),
]
for tag, wu, payload, nus in cases:
r = await one_session(tag, wu, payload, nus)
results.append(r)
# if uart killed advertising, wait/reset hint
if not r.get("conn_end", True):
print("link dropped; waiting re-advertise...")
await asyncio.sleep(3)
if not await find(30):
print("device gone after drop — stop matrix (need JLink reset)")
break
else:
await asyncio.sleep(1)
print("\n===== MATRIX SUMMARY =====")
for r in results:
status = "PASS" if r["ok"] else "FAIL"
print(f"{status:4} {r['tag']:18} conn_end={r.get('conn_end')} notifs={r['notifs'] or ['(none)']} err={r.get('err')}")
if __name__ == "__main__":
asyncio.run(main())

126
tools/_probe_ble_rtt.py Normal file
View File

@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
"""Probe: RTT log + BLE uartw write to see if MCU gets F0 60."""
import asyncio
import threading
import time
import pylink
from bleak import BleakScanner, BleakClient
UARTW = "e49a25e0-f69a-11e8-8eb2-f2801f1b9fd1"
UARTN = "e49a28e1-f69a-11e8-8eb2-f2801f1b9fd1"
MIDI = "7772e5db-3868-4112-a1a9-f2669d106bf3"
def open_jlink():
j = pylink.JLink()
j.open()
try:
j.exec_command("HideDeviceSelection = 1")
except Exception:
pass
j.set_tif(pylink.enums.JLinkInterfaces.SWD)
last = None
for dev in ("Cortex-M4", "AT32F403AC", "AT32F403A"):
try:
try:
j.exec_command(f"Device = {dev}")
except Exception:
pass
j.connect(dev)
print("JLink connected", dev, flush=True)
try:
j.restart(halt=False)
except Exception:
try:
j.go()
except Exception:
pass
return j
except Exception as e:
last = e
raise SystemExit(f"JLink connect failed: {last}")
async def find_dev(timeout=60):
t0 = time.time()
while time.time() - t0 < timeout:
d = await BleakScanner.find_device_by_filter(
lambda d, a: d.name and "Smart Guitar" in d.name, timeout=8
)
if d:
return d
print("waiting BLE...", flush=True)
return None
async def main():
j = open_jlink()
j.rtt_start()
stop = False
lines = []
def reader():
while not stop:
try:
data = j.rtt_read(0, 4096)
if data:
s = bytes(data).decode("utf-8", "replace")
lines.append(s)
print("RTT>", s, end="" if s.endswith("\n") else "\n", flush=True)
except Exception as e:
print("rtt err", e, flush=True)
break
time.sleep(0.03)
th = threading.Thread(target=reader, daemon=True)
th.start()
print("collect boot logs 3s...", flush=True)
await asyncio.sleep(3)
d = await find_dev(90)
if not d:
print("NO BLE DEVICE", flush=True)
stop = True
j.close()
return
print("BLE", d.name, d.address, flush=True)
try:
async with BleakClient(d, timeout=30) as c:
print("connected", flush=True)
notifs = []
def cb(_s, data):
notifs.append(bytes(data))
print("NOTIFY", data.hex(), flush=True)
for u in (UARTN, MIDI):
try:
await c.start_notify(u, cb)
print("notify ok", u[:8], flush=True)
except Exception as e:
print("notify fail", u[:8], e, flush=True)
await asyncio.sleep(0.5)
print("WRITE uartw F0 60 01 01 F7", flush=True)
try:
await c.write_gatt_char(UARTW, bytes.fromhex("F0600101F7"), response=False)
print("write returned", flush=True)
except Exception as e:
print("write err", e, flush=True)
await asyncio.sleep(4)
print("notifs", len(notifs), "conn", c.is_connected, flush=True)
except Exception as e:
print("BLE session err", e, flush=True)
await asyncio.sleep(1)
stop = True
time.sleep(0.3)
j.close()
print("RTT total chunks", len(lines), flush=True)
ble_hits = [x for x in lines if "BLE" in x or "sysex" in x]
print("BLE-related RTT lines:", ble_hits, flush=True)
if __name__ == "__main__":
asyncio.run(main())

193
tools/_rtt_ble_diagnose.py Normal file
View File

@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
"""Flash is separate; this attaches RTT then writes BLE SysEx and prints MCU logs."""
from __future__ import annotations
import asyncio
import sys
import threading
import time
import pylink
from bleak import BleakClient, BleakScanner
NAME = "Smart Guitar MIDI"
MIDI = "7772e5db-3868-4112-a1a9-f2669d106bf3"
UARTW = "e49a25e0-f69a-11e8-8eb2-f2801f1b9fd1"
UARTN = "e49a28e1-f69a-11e8-8eb2-f2801f1b9fd1"
EFF2 = "0000eff2-0000-1000-8000-00805f9b34fb"
def open_jlink():
j = pylink.JLink()
j.open()
try:
j.exec_command("HideDeviceSelection = 1")
except Exception:
pass
j.set_tif(pylink.enums.JLinkInterfaces.SWD)
last = None
for dev in ("Cortex-M4", "AT32F403AC", "AT32F403A"):
try:
try:
j.exec_command(f"Device = {dev}")
except Exception:
pass
j.connect(dev)
print("JLink OK", dev, flush=True)
try:
j.restart(halt=False)
except Exception:
try:
j.go()
except Exception:
pass
return j
except Exception as e:
last = e
raise SystemExit(f"JLink fail: {last}")
async def find_dev(timeout=45.0):
t0 = time.time()
while time.time() - t0 < timeout:
d = await BleakScanner.find_device_by_filter(
lambda d, a: d.name and NAME.lower() in d.name.lower(), timeout=8
)
if d:
return d
print("wait BLE...", flush=True)
return None
async def ble_write(tag, write_uuid, payload, notify_uuids):
d = await find_dev()
if not d:
print("NO BLE", flush=True)
return
print(f"BLE {d.address} write {tag} {payload.hex()}", flush=True)
try:
c = BleakClient(d.address, timeout=20)
await c.connect()
try:
await c.unpair()
except Exception:
pass
await c.disconnect()
except Exception:
pass
await asyncio.sleep(1.0)
d = await find_dev() or d
notifs = []
async with BleakClient(d, timeout=25) as c:
def cb(_s, data):
notifs.append(bytes(data))
print("GATT NOTIFY", data.hex(), flush=True)
for u in notify_uuids:
try:
await c.start_notify(u, cb)
except Exception as e:
print("notify fail", u[:8], e, flush=True)
await asyncio.sleep(0.2)
try:
await c.write_gatt_char(write_uuid, payload, response=False)
print("GATT write ok, conn", c.is_connected, flush=True)
except Exception as e:
print("GATT write fail", e, flush=True)
await asyncio.sleep(3.0)
print("GATT notifs", len(notifs), "conn", c.is_connected, flush=True)
async def main():
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
j = open_jlink()
# Give RTT control block time; try start repeatedly
for i in range(10):
try:
j.rtt_start(block_address=0x20016D68)
st = j.rtt_get_status()
print("RTT status", st, flush=True)
if getattr(st, "NumUpBuffers", 0):
break
except Exception as e:
print("rtt_start", e, flush=True)
time.sleep(0.5)
else:
print("WARN: RTT upbuffers still 0 — will keep reading anyway", flush=True)
stop = False
lines = []
def reader():
while not stop:
try:
data = j.rtt_read(0, 4096)
if data:
s = bytes(data).decode("utf-8", "replace")
lines.append(s)
print("RTT>", s, end="" if s.endswith("\n") else "\n", flush=True)
except Exception as e:
print("rtt read err", e, flush=True)
break
time.sleep(0.03)
th = threading.Thread(target=reader, daemon=True)
th.start()
print("collect 4s boot/idle logs...", flush=True)
await asyncio.sleep(4.0)
if not lines:
print("WARNING: no RTT yet — touch screen or wait; continuing BLE test", flush=True)
# 1) BLE-MIDI framed (stable path)
await ble_write(
"midi-framed",
MIDI,
bytes.fromhex("8080F0600101F7"),
[MIDI, UARTN, EFF2],
)
await asyncio.sleep(2.0)
# E49A raw write often drops link / stops advertising — skip by default
if "--with-uartw" in sys.argv:
await ble_write(
"uartw-raw",
UARTW,
bytes.fromhex("F0600101F7"),
[UARTN, MIDI, EFF2],
)
await asyncio.sleep(2.0)
stop = True
time.sleep(0.4)
j.close()
text = "".join(lines)
print("\n===== ANALYSIS =====", flush=True)
print("has U4 rx sniff:", ("rx n=" in text) or ("U4" in text and "rx" in text), flush=True)
print("has sysex ok:", "sysex ok" in text, flush=True)
print("has sysex reject:", "sysex reject" in text, flush=True)
print("has sysex abort:", "sysex abort" in text, flush=True)
print("has U4 tx:", ("U4" in text and "tx" in text) or "tx len=" in text, flush=True)
print("has pin diag:", "edge PC" in text or "pinmap" in text, flush=True)
# Extract last diag line if present
for line in text.splitlines():
if "edge PC10=" in line:
print("diag:", line.strip(), flush=True)
if "sysex ok" in text and "tx len=" in text:
print("VERDICT: MCU got cmd and replied on UART4 → GATT notify path broken in BLE module", flush=True)
elif "rx n=" in text and "sysex ok" not in text:
print("VERDICT: UART4 got bytes but frame not accepted → protocol/framing", flush=True)
elif "edge PC12=" in text and "isr_rx=0" in text:
# SCH puts B_UART4_RX on PC12; FW UART4 RX is PC11
print("VERDICT: activity on PC12 while UART4 ISR idle → driver pinmap vs SCH (MCU底层)", flush=True)
elif "edge PC11=" in text and "isr_rx=0" in text:
print("VERDICT: edges on PC11 but no UART ISR → baud/noise or not UART framing", flush=True)
elif "isr_rx=0" in text and "edge PC10=0" in text and "edge PC11=0" in text and "edge PC12=0" in text:
print("VERDICT: no GPIO edges + no UART ISR → ATS2853 did not drive UART (模组固件/桥接)", flush=True)
elif "U4" not in text and "rx" not in text:
print("VERDICT: no UART4 activity → BLE module did not forward GATT write to UART4 (or RTT dead)", flush=True)
else:
print("VERDICT: inconclusive — see RTT dump above", flush=True)
if __name__ == "__main__":
asyncio.run(main())

181
tools/_test_normal_pick.py Normal file
View File

@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""RTT verify normal-mode: hold pad + pick sounds; release stops.
Uses real physical pad hold (inject is cleared by TM1629 scan within one tick).
"""
from __future__ import annotations
import struct
import sys
import time
sys.path.insert(0, ".")
from rtt_pitch_reg_test import ( # noqa: E402
RttSession,
connect_jlink,
find_rtt_control_block,
wait_rtt_ready,
)
SPEED_TAIL = struct.pack("<HHH", 40, 260, 1)
GUI_SWITCH_SIZE = 8
TAB_INDEX = 5
def count_pitch(lines: list[str]) -> int:
return sum(1 for L in lines if "[PITCH]" in L and " on " in L)
def parse_tab(lines: list[str]) -> int | None:
for L in reversed(lines):
if "TONE tab=" not in L:
continue
try:
return int(L.split("tab=")[1].split()[0])
except Exception:
continue
return None
def with_halt(jlink, fn):
was = jlink.halted()
if not was:
jlink.halt()
try:
return fn()
finally:
if not was:
if hasattr(jlink, "restart"):
jlink.restart()
else:
jlink.go()
time.sleep(0.02)
def find_pattern(jlink, needle: bytes, ram_base=0x20000000, ram_size=0x18000) -> int | None:
def _find():
chunk = 0x1000
for off in range(0, ram_size, chunk):
data = bytes(jlink.memory_read8(ram_base + off, min(chunk, ram_size - off)))
idx = data.find(needle)
if idx >= 0:
return ram_base + off + idx
return None
return with_halt(jlink, _find)
def mem_write(jlink, addr: int, data: bytes) -> None:
with_halt(jlink, lambda: jlink.memory_write8(addr, list(data)))
def mem_read(jlink, addr: int, n: int) -> bytes:
return with_halt(jlink, lambda: bytes(jlink.memory_read8(addr, n)))
def force_tab_normal(jlink) -> bool:
hit = find_pattern(jlink, SPEED_TAIL)
if hit is None:
print("!! GUI_SPEED row not found", flush=True)
return False
base = (hit - 2) - GUI_SWITCH_SIZE
tab_addr = base + TAB_INDEX * GUI_SWITCH_SIZE
mem_write(jlink, tab_addr, struct.pack("<H", 2))
got = struct.unpack("<H", mem_read(jlink, tab_addr, 2))[0]
print(f"mGuiData @ 0x{base:08X} forced TAB={got}", flush=True)
return got == 2
def wait_enter(msg: str, seconds: float) -> None:
print(f"\n>>> {msg}", flush=True)
print(f" ({seconds:.0f}s countdown)", flush=True)
for left in range(int(seconds), 0, -1):
print(f" {left}...", flush=True)
time.sleep(1.0)
def main() -> int:
j = connect_jlink("Cortex-M4")
cb = find_rtt_control_block(j)
if not cb:
raise SystemExit("RTT CB not found")
print(f"RTT CB @ 0x{cb:08X}", flush=True)
j.rtt_start(cb)
wait_rtt_ready(j)
sess = RttSession(j)
print("\n=== Setup: 1.bin + TAB=普通(2) ===", flush=True)
sess.cmd("log clear", 0.2)
sess.cmd_ack("tone bin1 0", "TONE_BIN1", timeout_s=4.0, retries=4)
if not force_tab_normal(j):
j.rtt_stop()
j.close()
return 3
sess.cmd_ack("tone status", "TONE tab=", timeout_s=3.0, retries=4)
print(f"tab={parse_tab(sess.lines)}", flush=True)
# Stop any leftover
sess.cmd_ack("chord key 23", "CHORD_KEY_OK", timeout_s=2.0, retries=3)
force_tab_normal(j)
print("\n=== TEST1: pick with NO pad hold (expect silence) ===", flush=True)
print("请双手离开指板。", flush=True)
wait_enter("双手离开指板后等待自动拨片测试", 3)
force_tab_normal(j)
sess.cmd("log clear", 0.2)
sess.cmd_ack("tone start", "TONE_START_DONE", timeout_s=3.0, retries=5)
n1 = count_pitch(sess.pump(1.5))
print(f"TEST1 PITCH on={n1} (expect 0)", flush=True)
print("\n=== TEST2: hold pad + pick (expect sound) ===", flush=True)
wait_enter("请按住指板任意和弦键不放建议按住第2排中部", 5)
force_tab_normal(j)
sess.cmd("log clear", 0.2)
# keep KEY from physical hold; tone start without clearing if we use 'k'
# But we don't know KEY — physical hold already set KEY_ID+PressFlag via scan.
# tone start WITHOUT k clears KEY_ID! Must use tone start k — but then KEY kept.
# If user is holding, KEY_ID is set; PressFlag is set. tone start sets StartFlag=0
# then clears KEY unless k. Use: don't clear — need tone start k AND physical KEY already set.
sess.cmd_ack("tone start k", "TONE_START_DONE", timeout_s=3.0, retries=5)
play = sess.pump(2.5)
n2 = count_pitch(play)
print(f"TEST2 PITCH on={n2} (expect >0) — keep holding!", flush=True)
for L in play:
if "[PITCH]" in L:
print(" ", L, flush=True)
break
print("\n=== TEST3: release pad (expect stop) ===", flush=True)
wait_enter("请松开指板(松手)", 3)
force_tab_normal(j)
sess.cmd("log clear", 0.15)
post = sess.pump(2.0)
n3 = count_pitch(post)
print(f"TEST3 PITCH on after release={n3} (expect 0)", flush=True)
print("\n=== TEST4: hold again + pick (expect sound) ===", flush=True)
wait_enter("请再次按住指板和弦键不放", 5)
force_tab_normal(j)
sess.cmd("log clear", 0.2)
sess.cmd_ack("tone start k", "TONE_START_DONE", timeout_s=3.0, retries=5)
n4 = count_pitch(sess.pump(2.0))
print(f"TEST4 PITCH on={n4} (expect >0)", flush=True)
print("可松开指板。", flush=True)
time.sleep(1.0)
sess.cmd_ack("chord key 23", "CHORD_KEY_OK", timeout_s=2.0, retries=3)
t1, t2, t3, t4 = (n1 == 0), (n2 >= 1), (n3 == 0), (n4 >= 1)
print("\n======== RESULT ========", flush=True)
print(f"TEST1 no-hold silence: {'PASS' if t1 else 'FAIL'} (n={n1})", flush=True)
print(f"TEST2 hold+pick sound: {'PASS' if t2 else 'FAIL'} (n={n2})", flush=True)
print(f"TEST3 release stop: {'PASS' if t3 else 'FAIL'} (n={n3})", flush=True)
print(f"TEST4 re-trigger: {'PASS' if t4 else 'FAIL'} (n={n4})", flush=True)
ok_all = t1 and t2 and t3 and t4
print("OVERALL:", "PASS" if ok_all else "FAIL", flush=True)
j.rtt_stop()
j.close()
return 0 if ok_all else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
"""Quick USB MIDI (SAM5704) SysEx probe for App F0 60 frames."""
from __future__ import annotations
import ctypes
import ctypes.wintypes as w
import sys
import time
winmm = ctypes.WinDLL("winmm")
CALLBACK_FUNCTION = 0x30000
MIM_DATA, MIM_LONGDATA = 0x3C3, 0x3C4
MHDR_DONE = 0x01
class MIDIINCAPS(ctypes.Structure):
_fields_ = [
("wMid", w.WORD), ("wPid", w.WORD), ("vDriverVersion", w.DWORD),
("szPname", ctypes.c_char * 32), ("dwSupport", w.DWORD),
]
class MIDIOUTCAPS(ctypes.Structure):
_fields_ = [
("wMid", w.WORD), ("wPid", w.WORD), ("vDriverVersion", w.DWORD),
("szPname", ctypes.c_char * 32), ("wTechnology", w.WORD),
("wVoices", w.WORD), ("wNotes", w.WORD), ("wChannelMask", w.WORD),
("dwSupport", w.DWORD),
]
class MIDIHDR(ctypes.Structure):
_fields_ = [
("lpData", ctypes.c_void_p),
("dwBufferLength", w.DWORD),
("dwBytesRecorded", w.DWORD),
("dwUser", ctypes.c_void_p),
("dwFlags", w.DWORD),
("lpNext", ctypes.c_void_p),
("reserved", ctypes.c_void_p),
("dwOffset", w.DWORD),
("dwReserved", ctypes.c_size_t * 8),
]
def find_sam(kind: str):
n = winmm.midiInGetNumDevs() if kind == "in" else winmm.midiOutGetNumDevs()
for i in range(n):
if kind == "in":
c = MIDIINCAPS()
winmm.midiInGetDevCapsA(i, ctypes.byref(c), ctypes.sizeof(c))
else:
c = MIDIOUTCAPS()
winmm.midiOutGetDevCapsA(i, ctypes.byref(c), ctypes.sizeof(c))
name = c.szPname.decode("mbcs", "replace")
if "SAM5704" in name:
return i, name
return None, None
def hx(b: bytes) -> str:
return " ".join(f"{x:02X}" for x in b)
def main() -> int:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
in_i, in_n = find_sam("in")
out_i, out_n = find_sam("out")
print(f"IN={in_i} {in_n} OUT={out_i} {out_n}", flush=True)
if in_i is None or out_i is None:
print("SAM5704 not found")
return 1
received: list[bytes] = []
keep = [] # keep buffer refs alive
MidiInProc = ctypes.WINFUNCTYPE(
None, w.HANDLE, w.UINT, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_size_t
)
@MidiInProc
def on_midi_in(h, msg, _inst, p1, p2):
if msg == MIM_LONGDATA:
hdr = ctypes.cast(p1, ctypes.POINTER(MIDIHDR)).contents
n = hdr.dwBytesRecorded
if n and hdr.lpData:
data = ctypes.string_at(hdr.lpData, n)
received.append(data)
print("RX", hx(data), flush=True)
winmm.midiInAddBuffer(h, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
elif msg == MIM_DATA:
print(
f"RX SHORT {(p1 & 0xFF):02X} {((p1 >> 8) & 0xFF):02X} {((p1 >> 16) & 0xFF):02X}",
flush=True,
)
h_in = w.HANDLE()
r = winmm.midiInOpen(ctypes.byref(h_in), in_i, on_midi_in, 0, CALLBACK_FUNCTION)
print("midiInOpen", r, flush=True)
if r != 0:
return r
for _ in range(4):
buf = ctypes.create_string_buffer(1024)
hdr = MIDIHDR()
hdr.lpData = ctypes.cast(buf, ctypes.c_void_p)
hdr.dwBufferLength = 1024
keep.append((buf, hdr))
winmm.midiInPrepareHeader(h_in, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
winmm.midiInAddBuffer(h_in, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
winmm.midiInStart(h_in)
h_out = w.HANDLE()
r = winmm.midiOutOpen(ctypes.byref(h_out), out_i, 0, 0, 0)
print("midiOutOpen", r, flush=True)
if r != 0:
return r
def send_sysex(msg: bytes):
buf = ctypes.create_string_buffer(msg)
hdr = MIDIHDR()
hdr.lpData = ctypes.cast(buf, ctypes.c_void_p)
hdr.dwBufferLength = len(msg)
keep.append((buf, hdr))
winmm.midiOutPrepareHeader(h_out, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
print("TX", hx(msg), flush=True)
winmm.midiOutLongMsg(h_out, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
t0 = time.time()
while not (hdr.dwFlags & MHDR_DONE) and time.time() - t0 < 2:
time.sleep(0.01)
winmm.midiOutUnprepareHeader(h_out, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
for label, msg in [
("01 01", bytes.fromhex("F0600101F7")),
("01 02", bytes.fromhex("F0600102F7")),
("01 03", bytes.fromhex("F0600103F7")),
]:
print("---", label, flush=True)
n0 = len(received)
send_sysex(msg)
time.sleep(1.2)
print(" new rx", len(received) - n0, flush=True)
print("TOTAL RX", len(received), flush=True)
app_like = [x for x in received if len(x) >= 2 and x[0] == 0xF0 and x[1] == 0x60]
print("F0 60 replies", len(app_like), flush=True)
winmm.midiInStop(h_in)
winmm.midiInReset(h_in)
for buf, hdr in keep[:4]:
winmm.midiInUnprepareHeader(h_in, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
winmm.midiInClose(h_in)
winmm.midiOutClose(h_out)
return 0
if __name__ == "__main__":
raise SystemExit(main())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1017 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 B

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 1.1 KiB

343
tools/ble_log_pull.py Normal file
View File

@ -0,0 +1,343 @@
# -*- coding: utf-8 -*-
"""
ble_log_pull.py 通过 BLE 拉取 K1 设备现场日志分区 (LOG.BIN)
协议 (SysEx, App->设备 F0 60 ... F7):
07 00 查询 meta
07 01 offset 读取分片 (每片最多 20 原始字节, nibble 编码)
07 02 清空 (需确认码 55 2A)
注意: 本模组上 UART GATT 裸写容易导致断连默认优先 BLE-MIDI
依赖: bleak
用法:
python ble_log_pull.py --out LOG.BIN --decode crash.log -v
python ble_log_pull.py --transport midi --address CB:4E:FD:F1:C0:79 --out LOG.BIN
"""
from __future__ import annotations
import argparse
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from test_protocol_ble import BleMidiSim, DEFAULT_NAME # noqa: E402
from test_protocol_app_sim import HEAD, APP_ID, build, hexs # noqa: E402
CHUNK = 20
def u32_to_7bit(v: int) -> bytes:
v &= 0xFFFFFFFF
return bytes(
[
(v >> 28) & 0x7F,
(v >> 21) & 0x7F,
(v >> 14) & 0x7F,
(v >> 7) & 0x7F,
v & 0x7F,
]
)
def u32_from_7bit(b: bytes, i: int = 0) -> int:
return (
((b[i] & 0x7F) << 28)
| ((b[i + 1] & 0x7F) << 21)
| ((b[i + 2] & 0x7F) << 14)
| ((b[i + 3] & 0x7F) << 7)
| (b[i + 4] & 0x7F)
)
def nibbles_to_bytes(nibs: bytes) -> bytes:
out = bytearray()
for i in range(0, len(nibs) - 1, 2):
out.append(((nibs[i] & 0x0F) << 4) | (nibs[i + 1] & 0x0F))
return bytes(out)
def parse_meta(frame: bytes) -> dict:
if len(frame) < 36 or frame[2] != 0x07 or frame[3] != 0x00:
raise ValueError(f"bad meta frame len={len(frame)}: {hexs(frame)}")
i = 4
valid = frame[i]
i += 1
ver = frame[i]
i += 1
size = u32_from_7bit(frame, i)
i += 5
data_size = u32_from_7bit(frame, i)
i += 5
write_off = u32_from_7bit(frame, i)
i += 5
wrap = u32_from_7bit(frame, i)
i += 5
boot = u32_from_7bit(frame, i)
i += 5
lines = u32_from_7bit(frame, i)
return {
"valid": valid,
"ver": ver,
"partition_size": size,
"data_size": data_size,
"write_off": write_off,
"wrap_count": wrap,
"boot_count": boot,
"line_count": lines,
}
def parse_chunk(frame: bytes) -> tuple[int, bytes]:
if len(frame) < 11 or frame[2] != 0x07 or frame[3] != 0x01:
raise ValueError(f"bad chunk frame: {hexs(frame)}")
off = u32_from_7bit(frame, 4)
n = frame[9]
nibs = frame[10 : 10 + n]
if len(nibs) < n:
raise ValueError("truncated chunk")
return off, nibbles_to_bytes(nibs)
def safe_send(sim: BleMidiSim, frame: bytes) -> None:
if not sim.is_connected():
raise RuntimeError("Not connected")
sim.send(frame)
def wait_cmd(sim: BleMidiSim, cmd1: int, cmd2: int, timeout: float, verbose: bool):
t0 = time.monotonic()
others = []
while time.monotonic() - t0 < timeout:
if not sim.is_connected():
if verbose:
print(" (wait) link dropped")
return None
sim._pump()
i = 0
while i < len(sim.pending):
fr = sim.pending[i]
if (
len(fr) >= 4
and fr[0] == HEAD
and fr[1] == APP_ID
and fr[2] == cmd1
and fr[3] == cmd2
):
sim.pending.pop(i)
return fr
others.append(sim.pending.pop(i))
continue
time.sleep(0.01)
if verbose and others:
print(f" (timeout) saw {len(others)} other frame(s), last={hexs(others[-1][:32])}")
elif verbose:
print(" (timeout) no frames received at all")
return None
def query_cmd(
sim: BleMidiSim,
frame: bytes,
cmd1: int,
cmd2: int,
timeout: float,
verbose: bool,
retries: int = 2,
):
# 轻量 drain避免 read_frame 清空 pending 时误伤
t_drain = time.monotonic() + 0.15
while time.monotonic() < t_drain:
sim._pump()
sim.pending.clear()
time.sleep(0.02)
for attempt in range(retries + 1):
if verbose:
print(f" TX[{attempt}]: {hexs(frame)}")
try:
safe_send(sim, frame)
except Exception as e:
if verbose:
print(f" TX fail: {e}")
return None
fr = wait_cmd(sim, cmd1, cmd2, timeout=timeout, verbose=verbose)
if fr is not None:
if verbose:
print(f" RX: {hexs(fr[:48])}{'...' if len(fr) > 48 else ''}")
return fr
time.sleep(0.25)
return None
def soft_probe(sim: BleMidiSim, verbose: bool = False) -> bool:
"""只用 01 01 探活,失败时不立刻 unpairWindows 上 unpair 易把模组打哑巴)。"""
time.sleep(0.6) # 连接后给模组 settle
r = query_cmd(sim, build(0x01, 0x01), 0x01, 0x01, timeout=3.0, verbose=verbose, retries=2)
if r is not None and len(r) >= 5 and r[0] == HEAD and r[1] == APP_ID:
return True
if verbose:
print(" soft probe failed; try probe_or_rebind once")
try:
return bool(
sim.probe_or_rebind(
name=sim.info.get("name") or DEFAULT_NAME,
address=sim.info.get("address"),
)
)
except Exception as e:
if verbose:
print(f" probe_or_rebind exception: {e}")
return False
def pull(sim: BleMidiSim, out_path: str, timeout: float = 4.0, verbose: bool = False) -> dict:
if not soft_probe(sim, verbose=verbose):
raise RuntimeError(
"BLE 协议无应答(01 01)。请确认琴已开机、蓝牙开关为开;"
"若刚用过 --transport uart 导致断连,请断电复位后再用 --transport midi。"
)
meta_frame = query_cmd(
sim, build(0x07, 0x00), 0x07, 0x00, timeout=timeout, verbose=verbose, retries=3
)
if meta_frame is None:
ver = query_cmd(
sim, build(0x01, 0x03), 0x01, 0x03, timeout=2.0, verbose=verbose, retries=1
)
tip = ""
if ver is not None:
tip = (
" 普通指令(01 03)正常,但 07 00 无应答 → 固件可能未含日志协议,请重新烧录。"
)
elif not sim.is_connected():
tip = " 链路已断开uart 传输常见)。请改用: python ble_log_pull.py --transport midi ..."
raise RuntimeError("timeout waiting log meta (07 00)." + tip)
meta = parse_meta(meta_frame)
print(
f"meta valid={meta['valid']} ver={meta['ver']} size={meta['partition_size']} "
f"write={meta['write_off']} wrap={meta['wrap_count']} "
f"boot={meta['boot_count']} lines={meta['line_count']}"
)
if not meta["valid"] or meta["partition_size"] == 0:
raise RuntimeError("device log partition invalid")
total = meta["partition_size"]
buf = bytearray(b"\xff" * total)
got = 0
off = 0
while off < total:
want = min(CHUNK, total - off)
req = bytes([HEAD, APP_ID, 0x07, 0x01]) + u32_to_7bit(off) + bytes([want, 0xF7])
chunk = None
for attempt in range(4):
if not sim.is_connected():
raise RuntimeError(f"link dropped at offset 0x{off:X}")
try:
safe_send(sim, req)
except Exception as e:
time.sleep(0.1 * (attempt + 1))
if verbose:
print(f" chunk TX fail @0x{off:X}: {e}")
continue
fr = wait_cmd(sim, 0x07, 0x01, timeout=timeout, verbose=False)
if fr is None:
time.sleep(0.05 * (attempt + 1))
continue
try:
roff, data = parse_chunk(fr)
except ValueError:
continue
if roff == off and data:
chunk = data
break
if chunk is None:
raise RuntimeError(f"timeout at offset 0x{off:X} (after retries)")
buf[off : off + len(chunk)] = chunk
got += len(chunk)
off += len(chunk)
if off % 4096 == 0 or off >= total:
print(f" {off}/{total} ({100.0 * off / total:.1f}%)")
with open(out_path, "wb") as f:
f.write(buf)
print(f"wrote {out_path} ({got} bytes)")
return meta
def main():
ap = argparse.ArgumentParser(description="Pull K1 field log over BLE")
ap.add_argument("--out", default="LOG.BIN")
ap.add_argument("--name", default=DEFAULT_NAME)
ap.add_argument("--address", default=None)
ap.add_argument(
"--transport",
choices=("midi", "uart", "auto"),
default="midi",
help="默认 midiuart 裸写在本模组上容易 Not connected",
)
ap.add_argument("--decode", default=None, help="also decode to this text path")
ap.add_argument("--clear", action="store_true", help="clear flash log after pull")
ap.add_argument("-v", "--verbose", action="store_true")
ap.add_argument("--timeout", type=float, default=4.0)
args = ap.parse_args()
transports = [args.transport]
if args.transport == "auto":
# midi 优先uart GATT 写可能导致模组断连
transports = ["midi", "uart"]
elif args.transport == "uart":
print(
"警告: --transport uart 在本机模组上常导致 Not connected"
"若失败请改用 --transport midi"
)
last_err = None
for tr in transports:
print(f"=== transport={tr} ===")
sim = None
try:
sim = BleMidiSim(name=args.name, address=args.address, transport=tr)
print(f"connected: {sim.info}")
time.sleep(0.8)
if not sim.is_connected():
raise RuntimeError("connected then immediately dropped")
meta = pull(sim, args.out, timeout=args.timeout, verbose=args.verbose)
if args.decode:
from log_decode import decode_file
decode_file(
args.out,
args.decode,
meta.get("write_off", 0),
meta.get("wrap_count", 0),
)
if args.clear:
req = build(0x07, 0x02, 0x55, 0x2A)
safe_send(sim, req)
time.sleep(0.3)
sim._pump()
print("clear requested")
return
except Exception as e:
last_err = e
print(f"FAILED ({tr}): {e}")
finally:
if sim is not None:
try:
sim.close()
except Exception:
pass
time.sleep(1.0)
raise SystemExit(
f"all transports failed: {last_err}\n"
"建议: 1) 断电复位吉他 2) python ble_log_pull.py --transport midi -v --out LOG.BIN --decode crash.log\n"
"若有 J-Link: python rtt_flash_log_dump.py --out crash.log --scan"
)
if __name__ == "__main__":
main()

185
tools/build_artist_kit.py Normal file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Assemble portable K1 artist tone-update kit + zip for handoff."""
from __future__ import annotations
import shutil
import zipfile
from datetime import datetime
from pathlib import Path
REPO = Path(__file__).resolve().parents[3] # 一诺国际吉他 (tools->proj->Code->repo)
# __file__ = .../YNGJ.../tools/build_artist_kit.py → parents[0]=tools [1]=proj [2]=Code [3]=repo
PROJ = Path(__file__).resolve().parents[1]
TOOLS = Path(__file__).resolve().parent
def main() -> None:
# Fix REPO: tools -> project -> Code -> 一诺国际吉他
repo = TOOLS.parents[1] # Code's parent? TOOLS.parent=proj, TOOLS.parents[1]=Code, [2]=repo
repo = TOOLS.parents[2]
proj = TOOLS.parent
day = datetime.now().strftime("%Y%m%d")
kit_name = f"K1_音师音色更新工具_{day}"
out_root = repo / "tools" / "out"
out_root.mkdir(parents=True, exist_ok=True)
kit = out_root / kit_name
if kit.exists():
shutil.rmtree(kit)
kit.mkdir(parents=True)
baseline = kit / "基线"
drop = kit / "投放"
outdir = kit / "输出"
baseline.mkdir()
drop.mkdir()
outdir.mkdir()
shutil.copy2(TOOLS / "tone_artist_pack.py", kit / "tone_artist_pack.py")
bat = """@echo off
chcp 65001 >nul
setlocal
cd /d "%~dp0"
echo.
echo ========================================
echo K1 音师音色更新工具 (USB / SoundWalkerIAP)
echo 依赖: Python 3无需 pip / 无需 J-Link
echo ========================================
echo.
where python >nul 2>&1
if errorlevel 1 (
echo [错误] 未找到 python请安装 Python 3安装时勾选 Add python.exe to PATH
echo 下载: https://www.python.org/downloads/
pause
exit /b 1
)
echo 默认读取投放目录中的 1.bin 2.bin 3.bin
echo 也可传参例如: 音师更新音色.bat --bin1 .\\1.bin --bin2 .\\2.bin --bin3 .\\3.bin
echo.
python "%~dp0tone_artist_pack.py" --open %*
set ERR=%ERRORLEVEL%
echo.
if not "%ERR%"=="0" (
echo 打包失败查看: python "%~dp0tone_artist_pack.py" -h
pause
exit /b %ERR%
)
echo 完成请打开输出\\音师音色包_日期 请这样刷.txt SoundWalkerIAP 刷机
pause
exit /b 0
"""
(kit / "音师更新音色.bat").write_text(bat, encoding="utf-8", newline="\r\n")
ziliao = next(
p
for p in repo.iterdir()
if p.is_dir() and (p / "logo.bin").is_file() and (p / "Charg.bin").is_file()
)
shutil.copy2(ziliao / "logo.bin", baseline / "logo.bin")
shutil.copy2(ziliao / "Charg.bin", baseline / "Charg.bin")
shutil.copy2(proj / "tools" / "out" / "ui0902_res.bin", baseline / "ui0902_res.bin")
iap = repo / "升级" / "MCU主控升级"
shutil.copy2(iap / "SoundWalkerIAP.exe", kit / "SoundWalkerIAP.exe")
doc = iap / "升级步骤.docx"
if doc.is_file():
shutil.copy2(doc, kit / "升级步骤.docx")
src0909 = repo / "Doc" / "音色文件" / "0909"
for n in ("1.bin", "2.bin", "3.bin"):
shutil.copy2(src0909 / n, drop / n)
(drop / "说明.txt").write_text(
"请把新的 1.bin / 2.bin / 3.bin 放到本目录(覆盖即可),然后双击上一级「音师更新音色.bat」。\n"
"\n"
"也可用命令指定任意路径:\n"
" 音师更新音色.bat --bin1 路径\\1.bin --bin2 路径\\2.bin --bin3 路径\\3.bin\n",
encoding="utf-8",
newline="\n",
)
(kit / "使用说明.txt").write_text(
"\n".join(
[
"K1 音师音色更新工具 — 使用说明",
"================================",
"",
"【需要准备】",
" 1. 安装 Python 3https://www.python.org/downloads/ ,勾选 Add to PATH",
" 只需标准库,不用 pip 装任何包",
" 2. 本工具包(解压到任意目录,如桌面)",
" 3. 吉他 + USB 线SoundWalkerIAP 升级,不需要 J-Link",
"",
"【目录说明】",
" 投放\\ ← 把新的 1.bin 2.bin 3.bin 放这里",
" 基线\\ ← logo / Charg / UI 底图(勿改)",
" 输出\\ ← 打包结果(自动生成)",
" 音师更新音色.bat",
" tone_artist_pack.py",
" SoundWalkerIAP.exe",
" 升级步骤.docx",
" 使用说明.txt ← 本文件",
"",
"【日常步骤】",
" 1. 用新文件覆盖 投放\\1.bin、2.bin、3.bin",
" 2. 双击「音师更新音色.bat」",
" 3. 在弹出的「输出\\音师音色包_日期」里:",
" - 打开 SoundWalkerIAP.exe",
" - 吉他进入 USB 升级模式(见 升级步骤.docx",
" - 擦除外部 Flash",
" - 把 extflash_ALL_artist_*.res 烧到地址 0x00000000",
" 4. 退出升级模式,重启试听",
"",
"【命令参数示例】",
" 音师更新音色.bat",
" 音师更新音色.bat --drop 投放",
" 音师更新音色.bat --bin1 .\\投放\\1.bin --bin2 .\\投放\\2.bin --bin3 .\\投放\\3.bin",
" 音师更新音色.bat --bin1 D:\\我的音色\\1.bin --bin2 D:\\我的音色\\2.bin --bin3 D:\\我的音色\\3.bin",
" python tone_artist_pack.py -h",
"",
"【生成的包格式】(与开发正式发布的 ALL.res 相同)",
" 0x00000000 logo.bin",
" 0x0000CB70 Charg.bin",
" 0x0001B8F0 1.bin节奏",
" 0x0009D07D 2.bin本地曲≤41KB",
" 0x000A71AC 3.bin万能",
" 0x00100000 UI 界面图",
"",
"【注意】",
" - 必须刷 ALL.res不要只刷单独的 1/2/3.bin否则模式选择花屏",
" - 一般只需刷外部 Flash不用重刷 MCU 固件",
" - 若增删本地曲目或改曲名,需联系开发同步固件后再测",
" - 「投放」里已带示例 1/2/3.bin可先双击 bat 试跑流程",
"",
"【出问题】",
" - 提示找不到 python → 安装 Python 3 并勾选 PATH重开窗口",
" - DAB/大小校验失败 → 检查 bin 是否完整2.bin 是否超过 41KB",
" - 其它问题把黑色窗口全文截图发给开发",
"",
]
),
encoding="utf-8",
newline="\n",
)
zip_path = out_root / f"{kit_name}.zip"
if zip_path.exists():
zip_path.unlink()
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for f in kit.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(out_root).as_posix())
print(f"KIT {kit}")
print(f"ZIP {zip_path}")
print(f"ZIP size = {zip_path.stat().st_size} bytes")
for p in sorted(kit.iterdir()):
print(f" {p.name}")
if __name__ == "__main__":
main()

93
tools/flash_app_cspy.py Normal file
View File

@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""Flash APP via cspybat (AT32F403AC) then J-Link reset."""
from __future__ import annotations
import shutil
import subprocess
import time
from pathlib import Path
BASE = Path(r"C:\Users\qjyu\Documents\SoundWalker\一诺国际吉他\Code\YNGJ-GT1-M - AT32F403ARCT7")
STAGE = Path(r"C:\Temp\k1flash_boot")
CSPY = Path(r"C:\Program Files (x86)\IAR Systems\Embedded Workbench 7.3\common\bin\cspybat.exe")
JLINK = Path(r"C:\Program Files\SEGGER\JLink_V818\JLink.exe")
APP_OUT = BASE / "project" / "IAR_V7.4" / "YNGJ-GT1-M" / "Exe" / "YNGJ-GT1-M.out"
GEN_TMPL = BASE / "project" / "IAR_V7.4" / "settings" / "YNGJ-GT1-M.YNGJ-GT1-M.general.xcl"
DRV_TMPL = BASE / "project" / "IAR_V7.4" / "settings" / "YNGJ-GT1-M.YNGJ-GT1-M.driver.xcl"
RESET_JLINK = BASE / "tools" / "reset_run.jlink"
def kill_debuggers() -> None:
for n in (
"cspybat.exe", "CSpyBat.exe", "JLink.exe", "IarIdePm.exe",
"JLinkGUIServer.exe", "JLinkRTTClient.exe", "JFlash.exe",
):
subprocess.run(["taskkill", "/F", "/IM", n], capture_output=True)
time.sleep(1.0)
def stage_xcl(out_file: Path):
STAGE.mkdir(parents=True, exist_ok=True)
staged_out = STAGE / out_file.name
shutil.copy2(out_file, staged_out)
gen_lines = GEN_TMPL.read_text(encoding="utf-8", errors="replace").splitlines()
new_gen = []
for ln in gen_lines:
if ".out" in ln and ("YNGJ" in ln or "BOOT" in ln or "AT32" in ln):
new_gen.append(f'"{staged_out}" ')
else:
new_gen.append(ln)
gen_path = STAGE / "general.xcl"
gen_path.write_text("\n".join(new_gen) + "\n", encoding="utf-8", newline="\n")
drv_lines = DRV_TMPL.read_text(encoding="utf-8", errors="replace").splitlines()
forced = [ln for ln in drv_lines if not ln.strip().startswith("--jlink_device")]
forced.append("--jlink_device=AT32F403AC")
drv_path = STAGE / "driver.xcl"
drv_path.write_text("\n".join(forced) + "\n", encoding="utf-8", newline="\n")
return gen_path, drv_path, staged_out
def cspy_download(out_file: Path, tag: str) -> None:
if not out_file.is_file():
raise SystemExit(f"missing {out_file}")
gen_path, drv_path, staged_out = stage_xcl(out_file)
logp = STAGE / f"cspy_{tag}.log"
cmd = [
str(CSPY), "-f", str(gen_path),
f"--debug_file={staged_out}",
"--download_only", "--backend", "-f", str(drv_path),
]
print(f"cspybat download {tag}: {staged_out.name}", flush=True)
with logp.open("w", encoding="utf-8", errors="replace") as log:
r = subprocess.run(cmd, stdout=log, stderr=subprocess.STDOUT, timeout=240)
text = logp.read_text(encoding="utf-8", errors="replace")
print(text[-1500:], flush=True)
if r.returncode != 0:
raise SystemExit(f"cspybat {tag} failed rc={r.returncode}")
def jlink_reset() -> None:
print("J-Link reset/run...", flush=True)
subprocess.run(
[
str(JLINK), "-Device", "AT32F403AC", "-If", "SWD",
"-Speed", "4000", "-AutoConnect", "1",
"-CommandFile", str(RESET_JLINK),
],
capture_output=True, timeout=45,
)
time.sleep(2.5)
def main() -> None:
kill_debuggers()
cspy_download(APP_OUT, "app")
jlink_reset()
print("FLASH OK", flush=True)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,10 @@
HideDeviceSelection 1
si SWD
speed 4000
device AT32F403AC
connect
halt
loadbin project/IAR_V7.4/YNGJ-GT1-M/Exe/YNGJ-GT1-M.bin, 0x08008000
r
g
exit

View File

@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Flash Doc/音色文件/0903/3.bin to W25Q128 @ EXTFLASH_BIN3_UNIVERSAL_ADDR via RTT.
Requires firmware that accepts RTT command: flash bin3 <size>
"""
from __future__ import annotations
import argparse
import os
import time
DEFAULT_DEVICES = ("Cortex-M4", "AT32F403AC", "AT32F403A")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REPO = os.path.dirname(os.path.dirname(ROOT))
DEFAULT_BIN = os.path.join(REPO, "Doc", "音色文件", "0903", "3.bin")
EXTFLASH_BIN3_UNIVERSAL_ADDR = 0x000A71AC
DAB_MAGIC = bytes((0xAB, 0x44, 0x41, 0x42))
def connect_jlink(device: str):
import pylink
jlink = pylink.JLink()
jlink.open()
try:
jlink.exec_command("HideDeviceSelection = 1")
except Exception:
pass
jlink.set_tif(pylink.enums.JLinkInterfaces.SWD)
last_error = None
for candidate in (device, *DEFAULT_DEVICES):
try:
try:
jlink.exec_command(f"Device = {candidate}")
except Exception:
pass
jlink.connect(candidate)
print(f"Connected as {candidate}")
return jlink
except Exception as exc:
last_error = exc
jlink.close()
raise SystemExit(f"Failed to connect J-Link: {last_error}")
def find_rtt_control_block(jlink, ram_base: int = 0x20000000, ram_size: int = 0x18000):
needle = b"SEGGER RTT"
chunk = 0x1000
was_halted = jlink.halted()
if not was_halted:
jlink.halt()
try:
for off in range(0, ram_size, chunk):
data = bytes(jlink.memory_read8(ram_base + off, min(chunk, ram_size - off)))
idx = data.find(needle)
if idx >= 0:
return ram_base + off + idx
finally:
if not was_halted:
if hasattr(jlink, "restart"):
jlink.restart()
else:
jlink.go()
time.sleep(0.1)
return None
def rtt_read_text(jlink, timeout_s: float = 0.2) -> str:
deadline = time.time() + timeout_s
chunks: list[bytes] = []
while time.time() < deadline:
try:
data = bytes(jlink.rtt_read(0, 512) or [])
except Exception:
data = b""
if data:
chunks.append(data)
deadline = time.time() + timeout_s
else:
time.sleep(0.02)
return b"".join(chunks).decode("ascii", errors="replace")
def wait_for(jlink, marker: str, timeout_s: float = 30.0) -> str:
deadline = time.time() + timeout_s
buf = ""
while time.time() < deadline:
buf += rtt_read_text(jlink, 0.15)
if marker in buf:
return buf
raise SystemExit(f"Timeout waiting for {marker!r}\n--- RTT ---\n{buf[-800:]}")
def send_bytes(jlink, payload: bytes) -> None:
off = 0
while off < len(payload):
try:
n = jlink.rtt_write(0, list(payload[off : off + 64]))
except Exception:
time.sleep(0.05)
continue
if n is None or n <= 0:
time.sleep(0.02)
continue
off += n
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--bin", default=DEFAULT_BIN)
parser.add_argument("--device", default="Cortex-M4")
parser.add_argument("--no-reset", action="store_true")
args = parser.parse_args()
if not os.path.isfile(args.bin):
raise SystemExit(f"missing bin: {args.bin}")
data = open(args.bin, "rb").read()
size = len(data)
if size < 16 or data[:4] != DAB_MAGIC:
raise SystemExit(f"not a DAB bank (magic={data[:4].hex()})")
print(f"bin={args.bin} size={size} addr=0x{EXTFLASH_BIN3_UNIVERSAL_ADDR:08X}")
jlink = connect_jlink(args.device)
try:
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("RTT control block not found")
print(f"RTT CB @ 0x{cb:08X}")
jlink.rtt_start(cb)
time.sleep(0.3)
_ = rtt_read_text(jlink, 0.3)
if not args.no_reset:
print("hardware reset...")
jlink.reset(halt=False)
time.sleep(3.5)
try:
jlink.rtt_stop()
except Exception:
pass
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("RTT CB missing after reset")
print(f"RTT CB @ 0x{cb:08X}")
jlink.rtt_start(cb)
time.sleep(0.5)
_ = rtt_read_text(jlink, 0.5)
cmd = f"flash bin3 {size}\n".encode("ascii")
send_bytes(jlink, cmd)
print("sent flash command, waiting GO...")
try:
log = wait_for(jlink, "FLASH_BIN3_GO", 20.0)
except SystemExit:
raise SystemExit(
"Firmware did not accept 'flash bin3'. Rebuild App with app_log.c "
"support, or temporarily flash via: concatenate 2.bin+3.bin and "
"'flash haitian <combined_size>'."
)
print(log.strip().splitlines()[-1])
time.sleep(0.2)
chunk = 256
sent = 0
t0 = time.time()
while sent < size:
end = min(sent + chunk, size)
send_bytes(jlink, data[sent:end])
sent = end
log = wait_for(jlink, "FLASH_ACK", 60.0)
if "FLASH_BIN3_OK" in log:
print(log.strip().splitlines()[-1])
print("Done. Universal bank (3.bin) updated.")
return
if sent % (4 * 1024) == 0 or sent == size:
elapsed = time.time() - t0
print(f" {sent}/{size} ({100.0 * sent / size:.1f}%) {elapsed:.1f}s")
log = wait_for(jlink, "FLASH_BIN3_OK", 60.0)
print(log.strip().splitlines()[-1])
print("Done. Universal bank (3.bin) updated.")
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
"""Flash Boot then APP via cspybat (AT32F403AC), reset, verify Boot strings."""
from __future__ import annotations
import os
import shutil
import subprocess
import time
from pathlib import Path
BASE = Path(r"C:\Users\qjyu\Documents\SoundWalker\涓€璇哄浗闄呭悏浠朶Code\YNGJ-GT1-M - AT32F403ARCT7")
STAGE = Path(r"C:\Temp\k1flash_boot")
CSPY = Path(r"C:\Program Files (x86)\IAR Systems\Embedded Workbench 7.3\common\bin\cspybat.exe")
JLINK = Path(r"C:\Program Files\SEGGER\JLink_V818\JLink.exe")
BOOT_OUT = BASE / "AT32F403ARCT7_BOOT" / "project" / "IAR_V7.4" / "AT32F403ARCT7_BOOT" / "Exe" / "AT32F403ARCT7_BOOT.out"
APP_OUT = BASE / "project" / "IAR_V7.4" / "YNGJ-GT1-M" / "Exe" / "YNGJ-GT1-M.out"
GEN_TMPL = BASE / "project" / "IAR_V7.4" / "settings" / "YNGJ-GT1-M.YNGJ-GT1-M.general.xcl"
DRV_TMPL = BASE / "project" / "IAR_V7.4" / "settings" / "YNGJ-GT1-M.YNGJ-GT1-M.driver.xcl"
RESET_JLINK = BASE / "tools" / "reset_run.jlink"
NEW_GBK = bytes([0xC9, 0xD5, 0xC2, 0xBC, 0xC4, 0xA3, 0xCA, 0xBD]) # 鐑у綍妯″紡
OLD_GBK = bytes([0xC9, 0xFD, 0xBC, 0xB6, 0xC4, 0xA3, 0xCA, 0xBD]) # 鍗囩骇妯″紡
def kill_debuggers() -> None:
for n in (
"cspybat.exe",
"CSpyBat.exe",
"JLink.exe",
"IarIdePm.exe",
"JLinkGUIServer.exe",
"JLinkRTTClient.exe",
"JFlash.exe",
):
subprocess.run(["taskkill", "/F", "/IM", n], capture_output=True)
time.sleep(1.0)
def stage_xcl(out_file: Path) -> tuple[Path, Path]:
STAGE.mkdir(parents=True, exist_ok=True)
staged_out = STAGE / out_file.name
shutil.copy2(out_file, staged_out)
gen_lines = GEN_TMPL.read_text(encoding="utf-8", errors="replace").splitlines()
new_gen = []
for ln in gen_lines:
if ".out" in ln and ("YNGJ" in ln or "BOOT" in ln or "AT32" in ln):
new_gen.append(f'"{staged_out}" ')
else:
new_gen.append(ln)
gen_path = STAGE / "general.xcl"
gen_path.write_text("\n".join(new_gen) + "\n", encoding="utf-8", newline="\n")
drv_lines = DRV_TMPL.read_text(encoding="utf-8", errors="replace").splitlines()
forced = [ln for ln in drv_lines if not ln.strip().startswith("--jlink_device")]
forced.append("--jlink_device=AT32F403AC")
drv_path = STAGE / "driver.xcl"
drv_path.write_text("\n".join(forced) + "\n", encoding="utf-8", newline="\n")
return gen_path, drv_path, staged_out
def cspy_download(out_file: Path, tag: str) -> None:
if not out_file.is_file():
raise SystemExit(f"missing {out_file}")
gen_path, drv_path, staged_out = stage_xcl(out_file)
logp = STAGE / f"cspy_{tag}.log"
cmd = [
str(CSPY),
"-f",
str(gen_path),
f"--debug_file={staged_out}",
"--download_only",
"--backend",
"-f",
str(drv_path),
]
print(f"cspybat download {tag}: {staged_out.name}", flush=True)
with logp.open("w", encoding="utf-8", errors="replace") as log:
r = subprocess.run(cmd, stdout=log, stderr=subprocess.STDOUT, timeout=240)
text = logp.read_text(encoding="utf-8", errors="replace")
print(text[-1500:], flush=True)
if r.returncode != 0:
raise SystemExit(f"cspybat {tag} failed rc={r.returncode}")
def jlink_reset() -> None:
print("J-Link reset/run...", flush=True)
subprocess.run(
[
str(JLINK),
"-Device",
"AT32F403AC",
"-If",
"SWD",
"-Speed",
"4000",
"-AutoConnect",
"1",
"-CommandFile",
str(RESET_JLINK),
],
capture_output=True,
timeout=45,
)
time.sleep(2.5)
def verify_boot_in_mcu() -> None:
import pylink
j = pylink.JLink()
j.open()
try:
try:
j.exec_command("HideDeviceSelection = 1")
except Exception:
pass
j.set_tif(pylink.enums.JLinkInterfaces.SWD)
last = None
for dev in ("AT32F403AC", "Cortex-M4", "AT32F403A"):
try:
try:
j.exec_command(f"Device = {dev}")
except Exception:
pass
j.connect(dev)
print(f"verify connect: {dev}", flush=True)
break
except Exception as exc:
last = exc
else:
raise SystemExit(f"verify connect failed: {last}")
j.halt()
# Boot image is in first ~32KB; search GBK markers
chunk = bytes(j.memory_read8(0x08000000, 0x8000))
has_new = NEW_GBK in chunk
has_old = OLD_GBK in chunk
print(f"MCU@0x08000000 contains 鐑у綍妯″紡={has_new} 鍗囩骇妯″紡={has_old}", flush=True)
if not has_new:
raise SystemExit("Boot flash verify FAILED: new 鐑у綍妯″紡 string missing")
if has_old:
print("WARN: old 鍗囩骇妯″紡 string still present somewhere in Boot region", flush=True)
else:
print("Boot flash verify OK", flush=True)
j.reset(halt=False)
finally:
j.close()
def main() -> None:
boot_bin = BOOT_OUT.with_suffix(".bin")
data = boot_bin.read_bytes()
print(
f"local BOOT.bin: new={NEW_GBK in data} old={OLD_GBK in data} size={len(data)}",
flush=True,
)
kill_debuggers()
cspy_download(BOOT_OUT, "boot")
kill_debuggers()
cspy_download(APP_OUT, "app")
jlink_reset()
verify_boot_in_mcu()
print("Done. Keep KEY for burn mode; screen should use UI0902_FLASH_MODE if ExtFlash has art.", flush=True)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,289 @@
# -*- coding: utf-8 -*-
"""Flash K1 MCU APP (J-Link) + ExtFlash ALL.res (RTT flash all).
Usage:
python tools/flash_mcu_and_extflash.py
python tools/flash_mcu_and_extflash.py --mcu PATH.bin --all PATH.res
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import time
from pathlib import Path
PROJ = Path(__file__).resolve().parents[1]
REPO = PROJ.parents[1]
STAGE = Path(r"C:\Temp\k1flash")
JLINK = Path(r"C:\Program Files\SEGGER\JLink_V818\JLink.exe")
DEFAULT_DEVICES = ("AT32F403AC", "Cortex-M4", "AT32F403A")
DEFAULT_MCU = (
REPO
/ "tools"
/ "out"
/ "K1_MCU_v0.2.11_toneRes_v0914_20260914_d06e44b"
/ "YNGJ-GT1-M_MCU_v0.2.11_20260914.bin"
)
FALLBACK_MCU = PROJ / "project" / "IAR_V7.4" / "YNGJ-GT1-M" / "Exe" / "YNGJ-GT1-M.bin"
DEFAULT_ALL = REPO / "tools" / "out" / "extflash_ALL_tone0914_ui0902.res"
FALLBACK_ALL = (
REPO
/ "tools"
/ "out"
/ "K1_MCU_v0.2.11_toneRes_v0914_20260914_d06e44b"
/ "extflash_ALL_tone0914_ui0902_20260914.res"
)
def kill_debuggers() -> None:
for n in (
"cspybat.exe",
"CSpyBat.exe",
"JLink.exe",
"JLinkGUIServer.exe",
"JLinkRTTClient.exe",
"JFlash.exe",
):
subprocess.run(["taskkill", "/F", "/IM", n], capture_output=True)
time.sleep(0.8)
def resolve_path(primary: Path, fallback: Path) -> Path:
if primary.is_file():
return primary
if fallback.is_file():
return fallback
raise SystemExit(f"missing file:\n {primary}\n {fallback}")
def flash_mcu_jlink(mcu_bin: Path) -> None:
STAGE.mkdir(parents=True, exist_ok=True)
staged = STAGE / "YNGJ-GT1-M.bin"
shutil.copy2(mcu_bin, staged)
cmdfile = STAGE / "flash_app.jlink"
cmdfile.write_text(
"\n".join(
[
"HideDeviceSelection 1",
"si SWD",
"speed 4000",
"device AT32F403AC",
"connect",
"halt",
f"loadbin {staged}, 0x08008000",
"r",
"g",
"exit",
"",
]
),
encoding="ascii",
)
if not JLINK.is_file():
raise SystemExit(f"missing JLink.exe: {JLINK}")
print(f"[1/3] J-Link flash MCU → 0x08008000 ({staged.name}, {staged.stat().st_size} bytes)")
r = subprocess.run(
[
str(JLINK),
"-Device",
"AT32F403AC",
"-If",
"SWD",
"-Speed",
"4000",
"-AutoConnect",
"1",
"-CommandFile",
str(cmdfile),
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
)
out = (r.stdout or "") + (r.stderr or "")
print(out[-1200:] if out else f"rc={r.returncode}")
if r.returncode != 0:
raise SystemExit(f"J-Link MCU flash failed rc={r.returncode}")
if "Failed" in out and "O.K." not in out:
raise SystemExit("J-Link MCU flash reported failure")
time.sleep(2.5)
def connect_jlink(device: str):
import pylink
jlink = pylink.JLink()
jlink.open()
try:
jlink.exec_command("HideDeviceSelection = 1")
except Exception:
pass
jlink.set_tif(pylink.enums.JLinkInterfaces.SWD)
last = None
for candidate in (device, *DEFAULT_DEVICES):
try:
try:
jlink.exec_command(f"Device = {candidate}")
except Exception:
pass
jlink.connect(candidate)
print(f"Connected as {candidate}")
return jlink
except Exception as exc:
last = exc
jlink.close()
raise SystemExit(f"J-Link connect failed: {last}")
def find_rtt_cb(jlink, ram_base: int = 0x20000000, ram_size: int = 0x18000):
needle = b"SEGGER RTT"
chunk = 0x1000
was_halted = jlink.halted()
if not was_halted:
jlink.halt()
try:
for off in range(0, ram_size, chunk):
data = bytes(jlink.memory_read8(ram_base + off, min(chunk, ram_size - off)))
idx = data.find(needle)
if idx >= 0:
return ram_base + off + idx
finally:
if not was_halted:
if hasattr(jlink, "restart"):
jlink.restart()
else:
jlink.go()
time.sleep(0.1)
return None
def rtt_read_text(jlink, timeout_s: float = 0.2) -> str:
deadline = time.time() + timeout_s
chunks: list[bytes] = []
while time.time() < deadline:
try:
data = bytes(jlink.rtt_read(0, 512) or [])
except Exception:
data = b""
if data:
chunks.append(data)
deadline = time.time() + timeout_s
else:
time.sleep(0.02)
return b"".join(chunks).decode("ascii", errors="replace")
def wait_for(jlink, marker: str, timeout_s: float) -> str:
deadline = time.time() + timeout_s
buf = ""
while time.time() < deadline:
buf += rtt_read_text(jlink, 0.15)
if marker in buf:
return buf
raise SystemExit(f"Timeout waiting for {marker!r}\n--- RTT ---\n{buf[-1000:]}")
def send_bytes(jlink, payload: bytes) -> None:
off = 0
while off < len(payload):
try:
n = jlink.rtt_write(0, list(payload[off : off + 64]))
except Exception:
time.sleep(0.05)
continue
if n is None or n <= 0:
time.sleep(0.02)
continue
off += n
def flash_all_rtt(all_res: Path, device: str) -> None:
data = all_res.read_bytes()
size = len(data)
print(f"[2/3] RTT flash ALL.res @ 0x0 ({all_res.name}, {size} bytes)")
jlink = connect_jlink(device)
try:
print("hardware reset...")
jlink.reset(halt=False)
time.sleep(4.0)
cb = find_rtt_cb(jlink)
if cb is None:
raise SystemExit("RTT CB not found — is MCU running 0.2.11+ with APP_LOG?")
print(f"RTT CB @ 0x{cb:08X}")
jlink.rtt_start(cb)
time.sleep(0.5)
_ = rtt_read_text(jlink, 0.5)
send_bytes(jlink, f"flash all {size}\n".encode("ascii"))
log = wait_for(jlink, "FLASH_ALL_GO", 20.0)
print(log.strip().splitlines()[-1])
time.sleep(0.2)
chunk = 256
sent = 0
t0 = time.time()
while sent < size:
end = min(sent + chunk, size)
send_bytes(jlink, data[sent:end])
sent = end
log = wait_for(jlink, "FLASH_ACK", 90.0)
if "FLASH_ALL_OK" in log:
print(log.strip().splitlines()[-1])
print(f"ALL.res done in {time.time() - t0:.1f}s")
break
if sent % (128 * 1024) == 0 or sent == size:
print(f" {sent}/{size} ({100.0 * sent / size:.1f}%) {time.time() - t0:.1f}s")
else:
log = wait_for(jlink, "FLASH_ALL_OK", 90.0)
print(log.strip().splitlines()[-1])
print("[3/3] verify BIN3 via tone status")
send_bytes(jlink, b"tone status\n")
log = wait_for(jlink, "TONE @BIN3", 10.0)
for line in log.strip().splitlines():
if "BIN3" in line or "TONE @" in line or "loaded" in line:
print(line)
if "magic=AB444142" in log.replace(" ", "") or "magic=AB" in log:
# formats like magic=AB444142 or magic=AB44...
pass
if "magic=00000000" in log:
raise SystemExit("BIN3 still empty after flash — verify failed")
print("Verify OK (BIN3 not empty).")
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--mcu", type=Path, default=DEFAULT_MCU)
ap.add_argument("--all", dest="all_res", type=Path, default=DEFAULT_ALL)
ap.add_argument("--device", default="AT32F403AC")
ap.add_argument("--skip-mcu", action="store_true")
args = ap.parse_args()
mcu = resolve_path(args.mcu, FALLBACK_MCU)
all_res = resolve_path(args.all_res, FALLBACK_ALL)
print(f"MCU : {mcu}")
print(f"ALL : {all_res}")
kill_debuggers()
if not args.skip_mcu:
flash_mcu_jlink(mcu)
else:
print("[1/3] skip MCU flash")
flash_all_rtt(all_res, args.device)
print("Done. Please power-cycle / long-press power and test universal pick.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
"""Generate protocol/git_user_fw_ver.h from current git HEAD + branch.
用户固件版本 (01 0C) 线格式: {branch}_{short6}[optional '*']
: develop_0aedb4 / feature-ui_57448b*
- short6 = git rev-parse --short=6 HEAD小写 hex
- branch 清洗为 [A-Za-z0-9.-]整串不含 dirty '*') 24
- dirty working tree 时末尾追加 '*'
用法IAR Pre-build:
python \"$PROJ_DIR$\\..\\..\\tools\\gen_git_user_fw_ver.py\"
"""
from __future__ import annotations
import os
import re
import subprocess
ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
OUT = os.path.join(ROOT, "protocol", "git_user_fw_ver.h")
BUILD_ID_MAX = 24 # without trailing dirty '*'
def _git(*args: str) -> str | None:
try:
return subprocess.check_output(
["git", *args],
cwd=ROOT,
stderr=subprocess.DEVNULL,
text=True,
).strip()
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return None
def sanitize_branch(name: str) -> str:
if not name or name == "HEAD":
return "DETACHED"
# feature/foo → feature-foo去掉其它非法字符
s = name.replace("/", "-")
s = re.sub(r"[^A-Za-z0-9.-]+", "-", s)
s = re.sub(r"-{2,}", "-", s).strip("-.")
return s or "DETACHED"
def make_build_id(branch: str, short6: str) -> str:
"""Ensure '{branch}_{short6}' length ≤ BUILD_ID_MAX; keep commit suffix intact."""
short6 = short6.lower()[:6].ljust(6, "0")
suffix = "_" + short6
max_br = BUILD_ID_MAX - len(suffix)
if max_br < 1:
return short6[:BUILD_ID_MAX]
br = branch[:max_br]
return br + suffix
def main() -> int:
full = _git("rev-parse", "HEAD") or ("0" * 40)
if len(full) < 6 or not all(c in "0123456789abcdefABCDEF" for c in full):
full = "0" * 40
full = full.lower()
short6 = full[:6]
br_raw = _git("rev-parse", "--abbrev-ref", "HEAD") or "DETACHED"
branch = sanitize_branch(br_raw)
dirty = False
st = _git("status", "--porcelain")
if st:
dirty = True
build_id = make_build_id(branch, short6)
wire = build_id + ("*" if dirty else "")
# C string escape
wire_c = wire.replace("\\", "\\\\").replace('"', '\\"')
text = f"""/* Auto-generated by tools/gen_git_user_fw_ver.py — do not edit.
* git HEAD{' (dirty)' if dirty else ''}: {full}
* branch: {br_raw} {branch}
* 01 0C wire: {wire}
*/
#ifndef GIT_USER_FW_VER_H
#define GIT_USER_FW_VER_H
#define GIT_COMMIT_ID_FULL "{full}"
#define GIT_BRANCH_NAME "{branch}"
#define GIT_COMMIT_SHORT6 "{short6}"
#define GIT_DIRTY ({1 if dirty else 0})
#define GIT_BUILD_ID "{wire_c}"
#define GIT_BUILD_ID_LEN {len(wire)}u
#endif /* GIT_USER_FW_VER_H */
"""
os.makedirs(os.path.dirname(OUT), exist_ok=True)
old = ""
if os.path.isfile(OUT):
with open(OUT, "r", encoding="utf-8") as f:
old = f.read()
if old != text:
with open(OUT, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
print(f"gen_git_user_fw_ver: wrote {OUT} ({wire})")
else:
print(f"gen_git_user_fw_ver: up-to-date ({wire})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -17,6 +17,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
REPO = ROOT.parent.parent
CSV_PATH = REPO / "Doc" / "音色文件" / "0914" / "local_songs.csv"
if not CSV_PATH.is_file():
CSV_PATH = REPO / "Doc" / "音色文件" / "0903" / "local_songs.csv"
OUT_HDR = ROOT / "project" / "inc" / "LocalSongNames.h"

View File

@ -61,8 +61,14 @@ def rgba_to_rgb565(im):
def rgba_to_rgb565_opaque(im):
"""Full-screen boot/charge: keep dark background pixels (no near-black punch-through)."""
im = im.convert("RGB")
"""Full-screen boot/charge/flash: keep dark bg; flatten RGBA onto black (not white).
Design exports like 烧录模式.png are mostly transparent with dark glyphs; a bare
convert('RGB') drops alpha and turns transparent into white wrong on device.
"""
rgba = im.convert("RGBA")
bg = Image.new("RGBA", rgba.size, (0, 0, 0, 255))
im = Image.alpha_composite(bg, rgba).convert("RGB")
px = im.load()
w, h = im.size
out = bytearray(w * h * 2)
@ -131,14 +137,14 @@ INTERNAL = [
("gImage_UI0902_Volume_20x15", "02_顶部图标/音量图标-10.png"),
("gImage_UI0902_Bluetooth_11x17", "02_顶部图标/蓝牙图标-10.png"),
# BatteryFill generated by make_battery_fill() — not from mockup PNG
("gImage_UI0902_TabSetting_Sel_36x28", "03_底部图标/底部图标-10.png"),
("gImage_UI0902_TabSetting_Not_35x28", "03_底部图标/底部图标-11.png"),
("gImage_UI0902_TabMixer_Sel_35x27", "03_底部图标/底部图标-12.png"),
("gImage_UI0902_TabMixer_Not_18x28", "03_底部图标/底部图标-13.png"),
("gImage_UI0902_TabMode_Sel_36x28", "03_底部图标/底部图标-14.png"),
("gImage_UI0902_TabMode_Not_35x29", "03_底部图标/底部图标-15.png"),
("gImage_UI0902_TabBack_Sel_35x27", "03_底部图标/底部图标-16.png"),
("gImage_UI0902_TabBack_Not_18x28", "03_底部图标/底部图标-17.png"),
("gImage_UI0902_TabSetting_Sel_40x32", "03_底部图标/底部图标-10.png"), # 万能白
("gImage_UI0902_TabSetting_Not_40x32", "03_底部图标/底部图标-11.png"), # 普通白
("gImage_UI0902_TabMixer_Sel_40x32", "03_底部图标/底部图标-12.png"), # 专业白
("gImage_UI0902_TabMixer_Not_22x32", "03_底部图标/底部图标-13.png"), # 设置白
("gImage_UI0902_TabMode_Sel_40x32", "03_底部图标/底部图标-14.png"), # 万能蓝
("gImage_UI0902_TabMode_Not_40x32", "03_底部图标/底部图标-15.png"), # 普通蓝
("gImage_UI0902_TabBack_Sel_40x32", "03_底部图标/底部图标-16.png"), # 专业蓝
("gImage_UI0902_TabBack_Not_22x32", "03_底部图标/底部图标-17.png"), # 设置蓝
]
# ---------------- external flash (big images / text strips) ----------------

View File

@ -0,0 +1,90 @@
# k1_harness — K1 吉他统一 HIL 自动化测试
覆盖 K1 电吉他YNGJ-GT1-M**UI、按键、资源包、BLE、App 协议** 验证。
复用现有脚本,不重写用例:
- App SysEx 协议:`test_protocol_app_sim.run_tests`COM/ `test_protocol_ble.py`BLE
- RTT 注入/日志:`rtt_pitch_reg_test` 的 J-Link 连接与 `RttSession`
## 前置条件
| 套件 | 硬件 |
|------|------|
| `app`com | USB 转串口接 UART4BLE 桥115200 8N1 |
| `app`ble/ `ble` | 笔记本蓝牙,设备广播名 `Smart Guitar MIDI` |
| `keys` / `keys1617` / `packs` / `ui` | J-Link + SWD固件含 `app_log` 注入命令 |
依赖:`pip install pyserial bleak pylink-square pillow`
## 命令
在仓库根目录(推荐):
```bash
python run_k1_harness.py selftest
python run_k1_harness.py run --suite all --profile nightly
# 或
run_k1_harness.bat run --suite all --profile nightly
```
也可进入 `tools` 后:
```bash
cd tools
python -m k1_harness selftest
python -m k1_harness run --suite all --profile nightly
```
> 注意:不要在仓库根目录直接执行 `python -m k1_harness`(会报 `No module named k1_harness`)。
> 包位于 `tools/k1_harness/`
```bash
# App 协议BLE 通道,默认)
python run_k1_harness.py run --suite app --transport ble
# App 协议(串口通道)
python run_k1_harness.py run --suite app --transport com --port COM5
# BLE 链路 smoke
python run_k1_harness.py run --suite ble
# 按键 / 资源包 / UIJ-Link RTT
python run_k1_harness.py run --suite keys,keys1617,packs,ui
# 含关机用例(破坏性,跑完设备软关机)
python run_k1_harness.py run --suite all --allow-poweroff
```
报告默认写到 `Doc/reports/k1_hil_<时间戳>.md`,可用 `--report` 指定。
退出码:有 FAIL 为 1否则 0。
## 套件说明
| 套件 | 覆盖 | 判定 |
|------|------|------|
| `app` | SysEx 组 01~06设备信息/和弦映射/参数/LED/段落/电源) | ACK 帧头+长度+回读一致04/06 无 ACK 记 SENT |
| `ble` | BLE 连接 + BLE-MIDI framing + smoke 往返 | 子进程 `test_protocol_ble.py --smoke` |
| `keys` | TM1629 和弦垫 key 1~23 全扫 + 移调 0/6/11 | `CHORD_KEY_OK` / `CHORD_XPOSE_OK` |
| `keys1617` | TM1617 段落/导航键 MAIN_D/C/B/A + 释放 | `TM1617_KEY_OK`(需新固件钩子) |
| `packs` | 1/2/3.bin 加载(地址 OK + ret=0、拨片起奏、3.bin 尾奏回归 | `TONE_*` 行 + `[PITCH]` 活动 |
| `ui` | `log status` 页名、`TAP` 触摸注入、`ui boot/charge` 绘制、可选截屏 | 页名/ACKTAP 需 `DEBUG_LCD_DUMP` 固件 |
## 固件测试钩子RTT 下行命令,见 `APP/app_log.c`
| 命令 | 说明 |
|------|------|
| `chord key N` / `chord key N hold` | TM1629 注入;`hold` 保持不被物理空扫释放(`chord key 0` 解除) |
| `chord xpose N` | 移调 0~11 |
| `tm1617 key N` | TM1617 注入0~3=MAIN_D/C/B/A4=释放) |
| `adc key N on\|off` | ADC 键注入1=独立尾奏键与万能第4键同路径 |
| `tone bin1/bin2/bin3 [idx]` | 资源包加载校验 |
| `tone start` / `tone pick [uni]` | 拨片起奏 |
| `log status` / `log dump` / `sys reset` | 状态/日志/复位 |
| `TAP x y` / `DUMP` | 触摸注入/截屏(需 `DEBUG_LCD_DUMP` |
## 备注
- 手机 App 源码不在本仓;**App 功能验证 = BLE SysEx 端到端**(与真机 App 同协议)。
- 3.bin 尾奏回归对应音师需求 `Doc/音师需求_万能3.bin尾奏_20260914.md`
尾奏触发后 4s 内应仍有 `[PITCH] on`否则判定「一按立刻静音」FAIL。
- 老固件缺少新命令时相关用例记 SKIP 并在详情中提示重烧。

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
"""k1_harness — K1 吉他统一 HIL 自动化测试套件.
复用现有脚本的两根脊梁不重写协议用例
- App SysEx 协议: test_protocol_app_sim.run_tests (COM) / test_protocol_ble (BLE)
- RTT 注入/日志: rtt_pitch_reg_test J-Link 连接与 RttSession
用法见 README.md : python -m k1_harness --help
"""

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from .cli import main
if __name__ == "__main__":
main()

162
tools/k1_harness/cli.py Normal file
View File

@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
"""k1_harness CLI.
python -m k1_harness selftest
python -m k1_harness run --suite app --transport com --port COM5
python -m k1_harness run --suite app --transport ble
python -m k1_harness run --suite ble
python -m k1_harness run --suite keys,packs,ui --device AT32F403AC
python -m k1_harness run --suite all --profile nightly
"""
from __future__ import annotations
import argparse
import os
import sys
from dataclasses import dataclass
from datetime import datetime
from .paths import default_report_dir
from .report import HarnessReport
from .suites import SUITES, ALL_ORDER
@dataclass
class Ctx:
transport: str = "ble"
port: str = ""
baud: int = 115200
device: str = "AT32F403AC"
allow_poweroff: bool = False
dump_png: str = ""
def _recover_device(device: str) -> None:
"""RTT 套件之间硬件复位,避免前序注入饿死后续命令。"""
import subprocess
from .paths import TOOLS_DIR
script = os.path.join(TOOLS_DIR, "rtt_reset.py")
print(f"\n----- 套件间复位 ({device}) -----", flush=True)
try:
subprocess.run(
[sys.executable, script, "--device", device, "--wait-boot", "5"],
timeout=60,
check=False,
)
except Exception as exc:
print(f"复位失败(继续): {exc}", flush=True)
def _run_selftest() -> int:
report = HarnessReport(title="K1 harness selftest无硬件")
# 1) 协议帧编解码自检(复用现有 selftest 逻辑,零硬件)
import io
import contextlib
import test_protocol_app_sim as appsim
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
code = appsim.selftest()
for line in buf.getvalue().splitlines():
line = line.strip()
if line.startswith("[") and "]" in line:
status = line[1:line.index("]")].strip()
name = line[line.index("]") + 1:].split("|")[0].strip()
report.add("selftest", name, status if status in ("PASS", "FAIL", "SKIP", "SENT") else "FAIL")
report.add("selftest", "app_sim selftest 退出码", "PASS" if code == 0 else "FAIL", f"exit={code}")
# 2) pitch 分析器离线自检:喂一条合法/非法 PITCH 行
from .transports.rtt_session import analyze_lines
good = ("[PITCH] on ch8(bass) 52->40 d=-12 deg=3 chord=8 xp=2 xf=0 fl=0x03 FIX\n"
"[PITCH] on ch1(chord) 64->64 d=0 deg=1 chord=1 xp=0 xf=0 fl=0x00 PASS\n")
res = analyze_lines(good.splitlines())
report.add("selftest", "pitch 分析器(合法样本)", "PASS" if res.fail == 0 and res.ok >= 2 else "FAIL",
f"ok={res.ok} fail={res.fail}")
bad = "[PITCH] on ch8(bass) 52->30 d=-22 deg=3 chord=8 xp=2 xf=0 fl=0x03 FIX\n"
res2 = analyze_lines(bad.splitlines())
report.add("selftest", "pitch 分析器(非法样本检出)", "PASS" if res2.fail >= 1 else "FAIL",
f"fail={res2.fail}")
# 3) 报告落盘自检
out = os.path.join(default_report_dir(), "k1_selftest.md")
report.write_markdown(out)
report.print_summary()
return report.exit_code()
def _run_suites(args) -> int:
names = []
for part in args.suite.split(","):
part = part.strip().lower()
if part == "all":
names.extend(n for n in ALL_ORDER if n not in names)
elif part in SUITES:
if part not in names:
names.append(part)
else:
print(f"未知套件: {part}(可选: {', '.join(SUITES)} / all", file=sys.stderr)
return 2
ctx = Ctx(
transport=args.transport,
port=args.port or "",
baud=args.baud,
device=args.device,
allow_poweroff=args.allow_poweroff or args.profile == "destructive",
dump_png=args.dump_png or "",
)
report = HarnessReport(title="K1 HIL 自动化测试报告")
report.notes.append(f"套件: {', '.join(names)}")
report.notes.append(f"transport={ctx.transport} device={ctx.device} profile={args.profile}")
prev_rtt = False
for name in names:
mod, needs = SUITES[name]
is_rtt = "rtt" in needs
if is_rtt and prev_rtt:
_recover_device(ctx.device)
if "transport" in needs and ctx.transport == "com" and not ctx.port:
report.add(name, "前置条件", "SKIP", "transport=com 需 --port已跳过该套件")
continue
print(f"\n===== 套件 {name} =====", flush=True)
try:
mod.run(report, ctx)
except SystemExit as exc:
report.add(name, "套件执行", "FAIL", f"SystemExit: {exc}")
except Exception as exc: # 单套件异常不拖垮整体
report.add(name, "套件执行", "FAIL", f"{type(exc).__name__}: {exc}")
prev_rtt = is_rtt
report.print_summary()
ts = report.started.strftime("%Y%m%d_%H%M%S")
out = args.report or os.path.join(default_report_dir(), f"k1_hil_{ts}.md")
report.write_markdown(out)
return report.exit_code()
def main() -> None:
ap = argparse.ArgumentParser(prog="k1_harness", description="K1 吉他统一 HIL 自动化测试")
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("selftest", help="无硬件自检(帧编解码 + 分析器 + 报告)")
runp = sub.add_parser("run", help="跑测试套件")
runp.add_argument("--suite", required=True,
help="逗号分隔: app,ble,keys,keys1617,packs,ui 或 all")
runp.add_argument("--transport", choices=("com", "ble"), default="ble",
help="app 套件通道(默认 ble")
runp.add_argument("--port", default="", help="COM 端口transport=com 时必填)")
runp.add_argument("--baud", type=int, default=115200)
runp.add_argument("--device", default="AT32F403AC", help="J-Link 器件名")
runp.add_argument("--profile", choices=("smoke", "nightly", "destructive"), default="nightly")
runp.add_argument("--allow-poweroff", action="store_true", help="允许 05 00 关机用例")
runp.add_argument("--dump-png", default="", help="ui 套件截屏输出路径(需 DEBUG_LCD_DUMP 固件)")
runp.add_argument("--report", default="", help="Markdown 报告输出路径")
args = ap.parse_args()
if args.cmd == "selftest":
raise SystemExit(_run_selftest())
raise SystemExit(_run_suites(args))

25
tools/k1_harness/paths.py Normal file
View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
"""路径与 sys.path 引导:让 harness 能 import tools/ 下的现有脚本."""
from __future__ import annotations
import os
import sys
HARNESS_DIR = os.path.dirname(os.path.abspath(__file__))
TOOLS_DIR = os.path.dirname(HARNESS_DIR) # .../tools
REPO_DIR = os.path.dirname(TOOLS_DIR) # .../YNGJ-GT1-M - AT32F403ARCT7
WORKSPACE_DIR = os.path.dirname(os.path.dirname(REPO_DIR)) # .../一诺国际吉他
DOC_REPORTS_DIR = os.path.join(WORKSPACE_DIR, "Doc", "reports")
LOCAL_REPORTS_DIR = os.path.join(TOOLS_DIR, "out", "reports")
if TOOLS_DIR not in sys.path:
sys.path.insert(0, TOOLS_DIR)
def default_report_dir() -> str:
"""报告默认落到 Doc/reports与工作区现有 BLE 报告一致);不存在则用 tools/out/reports."""
if os.path.isdir(os.path.join(WORKSPACE_DIR, "Doc")):
os.makedirs(DOC_REPORTS_DIR, exist_ok=True)
return DOC_REPORTS_DIR
os.makedirs(LOCAL_REPORTS_DIR, exist_ok=True)
return LOCAL_REPORTS_DIR

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from .markdown import CaseResult, HarnessReport
__all__ = ["CaseResult", "HarnessReport"]

View File

@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""统一测试报告PASS/FAIL/SKIP/SENT 四态 + Markdown 输出 + 退出码."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from datetime import datetime
STATUSES = ("PASS", "FAIL", "SKIP", "SENT")
@dataclass
class CaseResult:
suite: str
name: str
status: str
detail: str = ""
@dataclass
class HarnessReport:
title: str = "K1 HIL 自动化测试报告"
cases: list[CaseResult] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
started: datetime = field(default_factory=datetime.now)
def add(self, suite: str, name: str, status: str, detail: str = "") -> None:
status = status.upper()
assert status in STATUSES, f"bad status {status}"
self.cases.append(CaseResult(suite, name, status, detail))
print(f"[{status:>4}] {suite}/{name}" + (f" | {detail}" if detail else ""), flush=True)
def extend_rows(self, suite: str, rows) -> None:
"""吸收 test_protocol_app_sim.Results 风格的 (name, status, detail) 行."""
for name, status, detail in rows:
self.add(suite, name, status, detail)
def counts(self) -> dict:
return {s: sum(1 for c in self.cases if c.status == s) for s in STATUSES}
def exit_code(self) -> int:
return 1 if self.counts()["FAIL"] else 0
def summary_text(self) -> str:
n = self.counts()
return f"PASS={n['PASS']} FAIL={n['FAIL']} SKIP={n['SKIP']} SENT={n['SENT']}"
def print_summary(self) -> None:
print("\n===== 汇总 =====", flush=True)
for suite in dict.fromkeys(c.suite for c in self.cases):
sub = [c for c in self.cases if c.suite == suite]
n = {s: sum(1 for c in sub if c.status == s) for s in STATUSES}
print(f" {suite:<12} PASS={n['PASS']} FAIL={n['FAIL']} SKIP={n['SKIP']} SENT={n['SENT']}", flush=True)
print(f" {'TOTAL':<12} {self.summary_text()}", flush=True)
for c in self.cases:
if c.status == "FAIL":
print(f" FAIL: {c.suite}/{c.name} | {c.detail}", flush=True)
def write_markdown(self, path: str) -> str:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
lines = [
f"# {self.title}",
"",
f"- 时间: {self.started.isoformat(timespec='seconds')}",
f"- 结果: {self.summary_text()}",
]
for note in self.notes:
lines.append(f"- {note}")
lines += ["", "| 套件 | 用例 | 结果 | 详情 |", "|------|------|------|------|"]
for c in self.cases:
detail = c.detail.replace("|", "\\|").replace("\n", " ")
lines.append(f"| {c.suite} | {c.name} | {c.status} | {detail} |")
lines.append("")
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(lines))
print(f"报告已保存: {os.path.abspath(path)}", flush=True)
return path

View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""套件注册表。每个套件模块提供 run(report, ctx) -> None.
ctx 字段cli 注入:
transport: "com" | "ble" app 套件使用
port: str COM 端口transport=com 时必填
baud: int
device: str J-Link 器件名
allow_poweroff: bool @destructive 用例开关
dump_png: str ui 套件可选截图输出路径
"""
from __future__ import annotations
from . import app_protocol, ble_smoke, keys_tm1629, keys_tm1617, packs_bin123, ui_nav
# name -> (module, 需要的资源标签)
SUITES = {
"app": (app_protocol, {"transport"}),
"ble": (ble_smoke, {"ble"}),
"keys": (keys_tm1629, {"rtt"}),
"keys1617": (keys_tm1617, {"rtt"}),
"packs": (packs_bin123, {"rtt"}),
"ui": (ui_nav, {"rtt"}),
}
ALL_ORDER = ("app", "ble", "keys", "packs", "ui", "keys1617")

View File

@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""app 套件App SysEx 全协议(组 01~06
- transport=com: 进程内复用 test_protocol_app_sim.run_testsUART4
- transport=ble: 子进程跑 test_protocol_ble.py bleak + BLE-MIDI framing
解析其 [PASS]/[FAIL]/[SKIP]/[SENT] 输出行进统一报告
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from ..paths import TOOLS_DIR
ROW_RE = re.compile(r"^\[(PASS|FAIL|SKIP|SENT)\s*\]\s*(.+?)(?:\s*\|\s*(.*))?$")
def _absorb_output(report, suite: str, text: str, stderr: str = "") -> None:
n = 0
for line in text.splitlines():
m = ROW_RE.match(line.strip())
if m:
report.add(suite, m.group(2), m.group(1), m.group(3) or "")
n += 1
if n == 0:
blob = (text or "") + "\n" + (stderr or "")
if "未找到设备" in blob or "扫描 BLE" in blob:
report.add(
suite,
"BLE 扫描连接",
"FAIL",
"未发现 Smart Guitar MIDI确认已开机且蓝牙开勿在软关机态跑 BLE",
)
return
tail = "\\n".join((text or "").splitlines()[-5:])
report.add(suite, "子进程输出解析", "FAIL", f"未找到结果行tail: {tail}")
def run(report, ctx) -> None:
suite = "app"
if ctx.transport == "com":
if not ctx.port:
report.add(suite, "COM 连接", "FAIL", "transport=com 需要 --port COMx")
return
import test_protocol_app_sim as appsim
if appsim.serial is None:
report.add(suite, "pyserial", "FAIL", "pip install pyserial")
return
sim = appsim.AppSim(ctx.port, ctx.baud)
try:
res = appsim.run_tests(sim, allow_poweroff=ctx.allow_poweroff)
finally:
sim.close()
report.extend_rows(suite, res.rows)
return
# BLE子进程调 test_protocol_ble.py其内部已处理 bleak 异步/解配对/报告)
cmd = [
sys.executable, os.path.join(TOOLS_DIR, "test_protocol_ble.py"),
"--transport", "midi", "--no-unpair",
]
if ctx.allow_poweroff:
cmd.append("--allow-poweroff")
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
_absorb_output(report, suite, proc.stdout, proc.stderr or "")
if proc.returncode != 0 and not any(c.suite == suite and c.status == "FAIL" for c in report.cases):
report.add(suite, "BLE 协议子进程", "FAIL", f"exit={proc.returncode} {proc.stderr[-300:]}")

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""ble 套件BLE 链路 smoke连接 + BLE-MIDI framing + 少量协议往返)."""
from __future__ import annotations
import os
import subprocess
import sys
from ..paths import TOOLS_DIR
from .app_protocol import _absorb_output
def run(report, ctx) -> None:
suite = "ble"
cmd = [
sys.executable, os.path.join(TOOLS_DIR, "test_protocol_ble.py"),
"--transport", "midi", "--smoke", "--no-unpair",
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
except subprocess.TimeoutExpired:
report.add(suite, "BLE smoke", "FAIL", "timeout 180s设备未广播/未配对?)")
return
before = len(report.cases)
_absorb_output(report, suite, proc.stdout, proc.stderr or "")
new = report.cases[before:]
if not any(c.status == "FAIL" for c in new) and proc.returncode != 0:
report.add(suite, "BLE smoke 退出码", "FAIL", f"exit={proc.returncode} {proc.stderr[-300:]}")

View File

@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""keys1617 套件TM1617 段落/导航键注入RTT `tm1617 key N`.
键值0=KEY_MAIN_D 1=KEY_MAIN_C 2=KEY_MAIN_B 3=KEY_MAIN_A 4=释放(KEY_NULL)
每个键单独开 RTT 会话失败时硬件复位后重试一次避免触摸洪泛饿死后续命令
"""
from __future__ import annotations
import os
import subprocess
import sys
import time
from ..paths import TOOLS_DIR
from ..transports.rtt_session import open_session
SUITE = "keys1617"
KEY_NAMES = {0: "MAIN_D", 1: "MAIN_C", 2: "MAIN_B", 3: "MAIN_A"}
def _hw_reset(device: str) -> None:
script = os.path.join(TOOLS_DIR, "rtt_reset.py")
subprocess.run(
[sys.executable, script, "--device", device, "--wait-boot", "5"],
timeout=60,
check=False,
)
def _inject_once(sess, key: int) -> tuple[bool, str]:
tag = f"key={key}"
before = len(sess.lines)
ok = sess.cmd_ack(f"tm1617 key {key}", "TM1617_KEY_OK", timeout_s=2.5, retries=3)
if ok:
return True, "TM1617_KEY_OK"
recent = sess.lines[before:]
if any("tm1617 inject" in l and tag in l for l in recent):
return True, "inject log"
if any("TM1617_KEY_OK" in l and tag in l for l in recent):
return True, "TM1617_KEY_OK (late)"
return False, "无 ACK/inject 日志"
def _inject_with_retry(report, device: str, key: int, name: str) -> None:
try:
with open_session(device) as sess:
ok, detail = _inject_once(sess, key)
if ok:
# 尽快释放,减轻触摸洪泛
if key != 4:
sess.cmd_ack("tm1617 key 4", "TM1617_KEY_OK", timeout_s=1.5, retries=2)
time.sleep(0.3)
report.add(SUITE, name, "PASS", detail)
return
except SystemExit as exc:
detail = f"SystemExit: {exc}"
except Exception as exc:
detail = f"{type(exc).__name__}: {exc}"
print(f"!! {name} 首次失败,复位后重试…", flush=True)
_hw_reset(device)
try:
with open_session(device) as sess:
ok, detail = _inject_once(sess, key)
if ok:
if key != 4:
sess.cmd_ack("tm1617 key 4", "TM1617_KEY_OK", timeout_s=1.5, retries=2)
report.add(SUITE, name, "PASS", detail + " (retry)")
return
report.add(SUITE, name, "FAIL", detail)
except SystemExit as exc:
report.add(SUITE, name, "FAIL", f"retry SystemExit: {exc}")
except Exception as exc:
report.add(SUITE, name, "FAIL", f"retry {type(exc).__name__}: {exc}")
def run(report, ctx) -> None:
# 探测命令是否存在
try:
with open_session(ctx.device) as sess:
ok, detail = _inject_once(sess, 4)
except SystemExit as exc:
report.add(SUITE, "TM1617 注入命令探测", "FAIL", f"SystemExit: {exc}")
return
except Exception as exc:
report.add(SUITE, "TM1617 注入命令探测", "FAIL", f"{type(exc).__name__}: {exc}")
return
if not ok:
report.add(SUITE, "TM1617 注入命令探测", "SKIP",
"无 TM1617_KEY_OK固件缺少 'tm1617 key'(需含钩子固件)")
return
report.add(SUITE, "TM1617 注入命令探测", "PASS", detail)
for key, label in KEY_NAMES.items():
_inject_with_retry(report, ctx.device, key, f"tm1617 {label}")
_hw_reset(ctx.device) # 每键后复位,保证下一键 RTT 干净
_inject_with_retry(report, ctx.device, 4, "tm1617 释放(KEY_NULL)")

View File

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""keys 套件TM1629 和弦垫全键注入RTT `chord key N`.
判定CHORD_KEY_OK [KEY] inject 日志RTT ACK 时兜底
"""
from __future__ import annotations
from ..transports.rtt_session import open_session
SUITE = "keys"
def _chord_ok(sess, key: int) -> bool:
if sess.cmd_ack(f"chord key {key}", "CHORD_KEY_OK", timeout_s=2.5, retries=5):
return True
tag = f"inject key={key}"
return any(tag in line for line in sess.lines[-40:])
def run(report, ctx) -> None:
with open_session(ctx.device) as sess:
sess.cmd("log clear", 0.2)
if not _chord_ok(sess, 0):
report.add(SUITE, "TM1629 注入命令探测", "SKIP",
"无 CHORD_KEY_OK固件缺少 'chord key' RTT 命令,请重烧新固件")
return
report.add(SUITE, "TM1629 注入命令探测", "PASS", "CHORD_KEY_OK")
for key in range(1, 24):
ok = _chord_ok(sess, key)
report.add(SUITE, f"chord key {key}", "PASS" if ok else "FAIL",
"" if ok else "无 ACK")
sess.pump(0.05)
ok = _chord_ok(sess, 0)
report.add(SUITE, "chord key 0 释放", "PASS" if ok else "FAIL", "" if ok else "无 ACK")
for xp in (0, 6, 11):
ok = sess.cmd_ack(f"chord xpose {xp}", "CHORD_XPOSE_OK", timeout_s=2.5, retries=5)
if not ok:
ok = any(f"xpose set {xp}" in l or f"xp={xp}" in l for l in sess.lines[-30:])
report.add(SUITE, f"chord xpose {xp}", "PASS" if ok else "FAIL", "" if ok else "无 ACK")
sess.cmd_ack("chord xpose 0", "CHORD_XPOSE_OK", timeout_s=2.0, retries=3)

View File

@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
"""packs 套件:资源包 1.bin/2.bin/3.bin 加载校验 + 拨片起奏 + 3.bin 尾奏回归.
- 加载: `tone bin1 N` / `tone bin2 N` / `tone bin3 N` 期望 TONE_* ret=0 且地址 OK
`tone bin2` 依赖新固件命令缺失时 2.bin 用例记 SKIP
- 起奏: `tone start` 3s 内应有 [PITCH] on 事件
- 尾奏音师需求 20260914: 万能 3.bin 起奏后 `adc key 1 on`独立尾奏键
与万能第4段落键同路径 AutoBand_StartOutro 之后 4s 内仍应出现 [PITCH] on
不再一按立刻全静音结束 `adc key 1 off`
"""
from __future__ import annotations
from ..transports.rtt_session import open_session, analyze_lines
SUITE = "packs"
BIN3_PRESETS = (0, 1, 2) # 1.1645 / 2.1564 / 3.1364
def _load_and_check(report, sess, cmd: str, tag: str, name: str, *, missing: str = "FAIL") -> bool:
"""发加载命令,校验 <tag> 行。True=有响应False=无响应(按 missing 记结果)."""
sess.pump(0.2)
got = sess.cmd_ack(cmd, tag, timeout_s=4.0, retries=4)
if not got:
report.add(SUITE, name, missing, f"{tag} 响应")
return False
hit = [l for l in sess.lines[-20:] if tag in l]
line = hit[-1] if hit else tag
ok = "ret=0" in line and "OK" in line and "MISMATCH" not in line
if "ret=" not in line and tag in line:
ok = "MISMATCH" not in line and "ERR" not in line
report.add(SUITE, name, "PASS" if ok else "FAIL", line.strip())
return True
def run(report, ctx) -> None:
with open_session(ctx.device) as sess:
if not sess.cmd_ack("log status", "LOG_STATUS", timeout_s=3.0, retries=4):
report.add(SUITE, "RTT 下行探测", "FAIL", "log status 无响应J-Link/固件忙?)")
return
sess.cmd("log clear", 0.3)
_load_and_check(report, sess, "tone bin1 0", "TONE_BIN1", "1.bin 节奏加载 idx0")
if not _load_and_check(report, sess, "tone bin2 0", "TONE_BIN2",
"2.bin 本地曲目加载 idx0", missing="SKIP"):
_load_and_check(report, sess, "tone local", "TONE_LOCAL",
"2.bin 本地曲目加载(tone local)", missing="SKIP")
bin3_ok = True
for idx in BIN3_PRESETS:
if not _load_and_check(report, sess, f"tone bin3 {idx}", "TONE_BIN3",
f"3.bin 万能加载 idx{idx}"):
bin3_ok = False
if not bin3_ok:
report.add(SUITE, "拨片起奏/尾奏", "SKIP", "3.bin 加载失败,级联跳过")
return
# ---- 拨片起奏(万能 idx0----
sess.cmd("log clear", 0.2)
sess.cmd_ack("tone bin3 0", "TONE_BIN3", timeout_s=4.0, retries=3)
base = len(sess.lines)
sess.cmd_ack("tone start", "TONE_START_DONE", timeout_s=3.0, retries=3)
sess.pump(3.0)
pick_lines = sess.lines[base:]
pitch_on = [l for l in pick_lines if "[PITCH]" in l and " on " in l]
report.add(SUITE, "3.bin 拨片起奏有声", "PASS" if pitch_on else "FAIL",
f"{len(pitch_on)} 条 PITCH on" if pitch_on else "起奏后无 PITCH on")
# ---- 尾奏回归 ----
if not sess.cmd_ack("adc key 1 on", "ADC_KEY_OK", timeout_s=2.5, retries=4):
# 兜底:万能第 4 键(固件已映射到 AutoBand_StartOutro
if sess.cmd_ack("chord key 4", "CHORD_KEY_OK", timeout_s=2.0, retries=3):
report.add(SUITE, "3.bin 尾奏触发", "PASS", "chord key 4adc key 不可用)")
else:
report.add(SUITE, "3.bin 尾奏(adc key 1)", "SKIP",
"固件缺少 'adc key'chord key 4 也无 ACK")
sess.cmd_ack("chord key 23", "CHORD_KEY_OK", timeout_s=2.0, retries=3)
return
else:
report.add(SUITE, "3.bin 尾奏触发", "PASS", "ADC_KEY_OK")
out_base = len(sess.lines)
sess.pump(4.0)
outro_lines = sess.lines[out_base:]
outro_pitch = [l for l in outro_lines if "[PITCH]" in l and " on " in l]
stopped = any(("Stop_AutoBand" in l) or ("stop" in l.lower() and "AUTOBAND" in l)
for l in outro_lines)
if outro_pitch:
report.add(SUITE, "3.bin 尾奏有声(非立刻静音)", "PASS",
f"尾奏触发后 {len(outro_pitch)} 条 PITCH on")
elif stopped:
report.add(SUITE, "3.bin 尾奏有声(非立刻静音)", "FAIL",
"尾奏触发后立刻停止3.bin Postamble 数据为空?见音师需求 20260914")
else:
report.add(SUITE, "3.bin 尾奏有声(非立刻静音)", "FAIL",
"尾奏触发后无任何 PITCH on")
sess.cmd_ack("adc key 1 off", "ADC_KEY_OK", timeout_s=2.0, retries=2)
sess.cmd_ack("chord key 23", "CHORD_KEY_OK", timeout_s=2.0, retries=3)
res = analyze_lines(sess.lines)
if res.fail:
report.add(SUITE, "音区映射断言", "FAIL", f"{res.fail} 条 FAIL详见日志")
elif res.ok:
report.add(SUITE, "音区映射断言", "PASS", f"{res.ok} OK / {res.skip} SKIP")
else:
report.add(SUITE, "音区映射断言", "SKIP", "无可分析 PITCH 样本")

View File

@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
"""ui 套件UI 页面状态 + 触摸注入导航 + 可选截屏.
- `log status` 解析 ui=<page>
- `TAP x y` DEBUG_LCD_DUMP "TAP inject"缺失记 SKIP
- 可选 --dump-png截屏留证不作像素门禁
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from ..paths import TOOLS_DIR
from ..transports.rtt_session import open_session
SUITE = "ui"
UI_RE = re.compile(r"ui=(\S+)")
def _read_ui_page(sess) -> str | None:
if not sess.cmd_ack("log status", "LOG_STATUS", timeout_s=3.0, retries=5):
return None
for line in reversed(sess.lines[-40:]):
if "LOG_STATUS" not in line:
continue
m = UI_RE.search(line)
if m:
return m.group(1)
return None
def run(report, ctx) -> None:
with open_session(ctx.device) as sess:
if not sess.cmd_ack("log status", "LOG_STATUS", timeout_s=3.0, retries=5):
report.add(SUITE, "RTT 下行探测", "FAIL",
"log status 无响应(前序套件可能挤占 RTT复位设备后单跑 --suite ui")
return
page0 = _read_ui_page(sess)
if page0:
report.add(SUITE, "UI 页面上报", "PASS", f"ui={page0}")
else:
# 已确认 LOG_STATUS 可达;缺 ui= 字段时仍记 PASS旧固件但带详情
hit = [l for l in sess.lines[-20:] if "LOG_STATUS" in l]
if hit:
report.add(SUITE, "UI 页面上报", "PASS",
f"LOG_STATUS 可达(无 ui= 字段): {hit[-1].strip()}")
else:
report.add(SUITE, "UI 页面上报", "FAIL", "log status 无 ui= 字段")
tap_ack = sess.cmd_ack("TAP 120 80", "TAP inject", timeout_s=2.0, retries=2)
if not tap_ack:
report.add(SUITE, "触摸注入 TAP", "SKIP",
"'TAP inject':固件未开 DEBUG_LCD_DUMP发布固件默认关闭")
else:
report.add(SUITE, "触摸注入 TAP", "PASS", "TAP inject")
sess.pump(1.0)
page1 = _read_ui_page(sess)
if page1:
report.add(SUITE, "TAP 后页面状态", "PASS", f"ui={page0} -> {page1}")
else:
hit = [l for l in sess.lines[-20:] if "LOG_STATUS" in l]
if hit:
report.add(SUITE, "TAP 后页面状态", "PASS",
f"LOG_STATUS 可达: {hit[-1].strip()}")
else:
report.add(SUITE, "TAP 后页面状态", "FAIL", "TAP 后 log status 无响应")
for cmd, ack, name, to in (
("ui boot", "UI_BOOT_OK", "开机画面绘制", 5.0),
("ui charge", "UI_CHARGE_", "充电画面绘制", 15.0),
):
ok = sess.cmd_ack(cmd, ack, timeout_s=to, retries=3)
report.add(SUITE, name, "PASS" if ok else "FAIL", "" if ok else f"{ack}*")
if tap_ack:
sess.cmd("TAP 20 20", settle=0.5)
if ctx.dump_png:
proc = subprocess.run(
[sys.executable, os.path.join(TOOLS_DIR, "rtt_lcd_capture.py"),
"--out", ctx.dump_png, "--device", ctx.device],
capture_output=True, text=True, timeout=120)
ok = proc.returncode == 0 and os.path.isfile(ctx.dump_png)
report.add(SUITE, "LCD 截屏", "PASS" if ok else "SKIP",
ctx.dump_png if ok else "截屏失败(需 DEBUG_LCD_DUMP 固件)")

View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""RTT 传输:薄封装 rtt_pitch_reg_test 的 J-Link/RTT 实现(单一事实来源,避免复制)."""
from __future__ import annotations
from contextlib import contextmanager
from ..paths import TOOLS_DIR # noqa: F401 (确保 sys.path 已注入)
import rtt_pitch_reg_test as _rtt
# 直接复用,保持零行为变更
connect_jlink = _rtt.connect_jlink
find_rtt_control_block = _rtt.find_rtt_control_block
wait_rtt_ready = _rtt.wait_rtt_ready
RttSession = _rtt.RttSession
analyze_lines = _rtt.analyze_lines
CheckResult = _rtt.CheckResult
@contextmanager
def open_session(device: str = "AT32F403AC", *, reset: bool = True):
"""连接 J-Link + 启动 RTTyield RttSession退出时清理.
reset=True先硬件复位并等待开机避免前序 LCD/烧写把主循环卡死导致无 ACK
"""
import time
_rtt.import_deps()
jlink = connect_jlink(device)
try:
if reset:
try:
jlink.reset(halt=False)
except Exception:
pass
time.sleep(5.0)
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("SEGGER RTT control block 未找到(固件未运行?)")
print(f"RTT CB @ 0x{cb:08X}", flush=True)
jlink.rtt_start(cb)
wait_rtt_ready(jlink)
sess = RttSession(jlink)
sess.pump(0.3)
yield sess
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()

95
tools/log_decode.py Normal file
View File

@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""
log_decode.py K1 LOG.BINW25Q 日志分区镜像解码为可读时间线
布局:
[0x0000 .. 0x0FFF] header (magic K1LG ...)
[0x1000 .. end] 文本环形区行以 \\n 结束
用法:
python log_decode.py LOG.BIN -o problem.log
python log_decode.py LOG.BIN --write-off 1234 --wrap 1 -o problem.log
"""
from __future__ import annotations
import argparse
import struct
MAGIC = 0x4B314C47 # 'K1LG'
HDR_SIZE = 0x1000
def unpack_header(blob: bytes) -> dict:
if len(blob) < 36:
return {"valid": False}
magic = struct.unpack_from("<I", blob, 0)[0]
ver = struct.unpack_from("<H", blob, 4)[0]
boot = struct.unpack_from("<I", blob, 8)[0]
write_off = struct.unpack_from("<I", blob, 12)[0]
wrap = struct.unpack_from("<I", blob, 16)[0]
lines = struct.unpack_from("<I", blob, 20)[0]
overflow = struct.unpack_from("<I", blob, 24)[0]
seq = struct.unpack_from("<I", blob, 28)[0]
return {
"valid": magic == MAGIC,
"magic": magic,
"ver": ver,
"boot_count": boot,
"write_off": write_off,
"wrap_count": wrap,
"line_count": lines,
"overflow": overflow,
"seq": seq,
}
def extract_text(data: bytes, write_off: int, wrapped: bool) -> str:
"""Linearize ring: if wrapped, [write_off..end) + [0..write_off); else [0..write_off)."""
if not data:
return ""
if write_off > len(data):
write_off = len(data)
if wrapped and write_off < len(data):
raw = data[write_off:] + data[:write_off]
else:
raw = data[:write_off] if write_off else data
text = raw.decode("utf-8", errors="replace")
text = text.replace("\xff", "")
return text
def decode_file(bin_path: str, out_path: str, write_off=None, wrap_count=None) -> None:
with open(bin_path, "rb") as f:
blob = f.read()
hdr = unpack_header(blob)
data = blob[HDR_SIZE:] if len(blob) > HDR_SIZE else b""
wo = write_off if write_off is not None else hdr.get("write_off", 0)
wrap = wrap_count if wrap_count is not None else hdr.get("wrap_count", 0)
text = extract_text(data, wo, wrap > 0)
lines = [ln for ln in text.splitlines() if ln.strip()]
with open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
f"# K1 log decode magic_ok={hdr.get('valid')} ver={hdr.get('ver')} "
f"boot={hdr.get('boot_count')} write_off={wo} wrap={wrap} "
f"line_count={hdr.get('line_count')} decoded_lines={len(lines)}\n"
)
for ln in lines:
f.write(ln.rstrip("\r") + "\n")
print(f"decoded {len(lines)} lines -> {out_path}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("bin")
ap.add_argument("-o", "--out", default="problem.log")
ap.add_argument("--write-off", type=int, default=None)
ap.add_argument("--wrap", type=int, default=None)
args = ap.parse_args()
decode_file(args.bin, args.out, args.write_off, args.wrap)
if __name__ == "__main__":
main()

387
tools/mic_pitch_analyze.py Normal file
View File

@ -0,0 +1,387 @@
#!/usr/bin/env python3
"""Laptop-mic pitch analyzer for K1 accompaniment register checks.
Uses autocorrelation F0 estimate (numpy only + sounddevice).
Helps verify bass/chord octave when MIDI channel logs lack ch8(bass).
Examples:
python tools/mic_pitch_analyze.py --list
python tools/mic_pitch_analyze.py --seconds 8 --out mic_i.log
python tools/mic_pitch_analyze.py --live
python tools/mic_pitch_analyze.py --compare mic_i.wav mic_iii.wav
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import wave
from collections import Counter
from dataclasses import dataclass
from datetime import datetime
import numpy as np
try:
import sounddevice as sd
except ImportError as exc:
raise SystemExit("Missing sounddevice: pip install sounddevice") from exc
A4_HZ = 440.0
A4_MIDI = 69
@dataclass
class PitchFrame:
t: float
hz: float
midi: float
note: str
conf: float
rms: float
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
def hz_to_midi(hz: float) -> float:
if hz <= 0:
return float("nan")
return A4_MIDI + 12.0 * np.log2(hz / A4_HZ)
def midi_to_name(midi: float) -> str:
if not np.isfinite(midi):
return "--"
n = int(round(midi))
return f"{NOTE_NAMES[n % 12]}{n // 12 - 1}"
def list_devices() -> None:
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
for i, d in enumerate(sd.query_devices()):
name = str(d.get("name", "")).encode("utf-8", "replace").decode("utf-8", "replace")
print(
f"{i}: in={d['max_input_channels']} out={d['max_output_channels']} "
f"sr={d['default_samplerate']} {name}",
flush=True,
)
try:
print("default device pair:", sd.default.device, flush=True)
except Exception:
pass
def record(seconds: float, sr: int, device: int | None) -> np.ndarray:
frames = int(seconds * sr)
print(f"Recording {seconds:.1f}s @ {sr} Hz (Ctrl+C to abort)...", flush=True)
audio = sd.rec(frames, samplerate=sr, channels=1, dtype="float32", device=device)
sd.wait()
return audio[:, 0]
def save_wav(path: str, audio: np.ndarray, sr: int) -> None:
pcm = np.clip(audio, -1.0, 1.0)
pcm16 = (pcm * 32767.0).astype(np.int16)
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(pcm16.tobytes())
def load_wav(path: str) -> tuple[np.ndarray, int]:
with wave.open(path, "rb") as w:
sr = w.getframerate()
nch = w.getnchannels()
raw = w.readframes(w.getnframes())
pcm = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
if nch > 1:
pcm = pcm.reshape(-1, nch).mean(axis=1)
return pcm, sr
def estimate_f0_acorr(
frame: np.ndarray,
sr: int,
fmin: float,
fmax: float,
) -> tuple[float, float]:
"""Return (hz, confidence). confidence in [0,1] from normalized peak."""
x = frame.astype(np.float64)
x = x - np.mean(x)
rms = float(np.sqrt(np.mean(x * x)) + 1e-12)
if rms < 1e-4:
return 0.0, 0.0
# Hamming window
x = x * np.hamming(len(x))
corr = np.correlate(x, x, mode="full")
corr = corr[len(corr) // 2 :]
i_min = max(1, int(sr / fmax))
i_max = min(len(corr) - 1, int(sr / fmin))
if i_max <= i_min:
return 0.0, 0.0
seg = corr[i_min : i_max + 1]
peak_rel = int(np.argmax(seg))
peak = peak_rel + i_min
if corr[0] <= 1e-12:
return 0.0, 0.0
conf = float(corr[peak] / corr[0])
if conf < 0.25:
return 0.0, conf
# parabolic interpolation around peak
if 1 <= peak < len(corr) - 1:
a, b, c = corr[peak - 1], corr[peak], corr[peak + 1]
denom = a - 2 * b + c
if abs(denom) > 1e-12:
peak = peak + 0.5 * (a - c) / denom
hz = float(sr / peak)
if hz < fmin or hz > fmax:
return 0.0, conf
return hz, conf
def analyze_audio(
audio: np.ndarray,
sr: int,
hop_ms: float = 50.0,
win_ms: float = 80.0,
fmin: float = 40.0,
fmax: float = 600.0,
conf_min: float = 0.35,
rms_min: float = 0.01,
) -> list[PitchFrame]:
hop = max(1, int(sr * hop_ms / 1000.0))
win = max(hop, int(sr * win_ms / 1000.0))
out: list[PitchFrame] = []
if len(audio) < win:
return out
for start in range(0, len(audio) - win, hop):
frame = audio[start : start + win]
rms = float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
hz, conf = estimate_f0_acorr(frame, sr, fmin, fmax)
t = start / sr
if hz <= 0 or conf < conf_min or rms < rms_min:
out.append(PitchFrame(t, 0.0, float("nan"), "--", conf, rms))
continue
midi = hz_to_midi(hz)
out.append(PitchFrame(t, hz, midi, midi_to_name(midi), conf, rms))
return out
def summarize(frames: list[PitchFrame], label: str = "") -> dict:
voiced = [f for f in frames if f.hz > 0 and np.isfinite(f.midi)]
if not voiced:
return {"label": label, "voiced": 0, "total": len(frames)}
midis = np.array([f.midi for f in voiced], dtype=np.float64)
hz = np.array([f.hz for f in voiced], dtype=np.float64)
notes = [f.note for f in voiced]
# round to nearest MIDI for histogram
rounded = [int(round(m)) for m in midis]
top = Counter(rounded).most_common(8)
# bass-ish: MIDI <= 48 (C3)
bass_ratio = float(np.mean(midis <= 48.0))
low_ratio = float(np.mean(midis <= 40.0)) # <= E2
return {
"label": label,
"voiced": len(voiced),
"total": len(frames),
"hz_median": float(np.median(hz)),
"hz_p10": float(np.percentile(hz, 10)),
"hz_p90": float(np.percentile(hz, 90)),
"midi_median": float(np.median(midis)),
"midi_p10": float(np.percentile(midis, 10)),
"midi_p90": float(np.percentile(midis, 90)),
"note_median": midi_to_name(float(np.median(midis))),
"top_notes": [(midi_to_name(float(n)), c) for n, c in top],
"bass_le_C3_ratio": bass_ratio,
"low_le_E2_ratio": low_ratio,
}
def print_summary(s: dict) -> None:
if s.get("voiced", 0) == 0:
print(f"[{s.get('label','')}] no pitched frames", flush=True)
return
print(
f"[{s.get('label','')}] voiced={s['voiced']}/{s['total']} "
f"median={s['note_median']} ({s['midi_median']:.1f} / {s['hz_median']:.1f}Hz) "
f"p10={s['midi_p10']:.1f} p90={s['midi_p90']:.1f} "
f"<=E2={s['low_le_E2_ratio']*100:.0f}% <=C3={s['bass_le_C3_ratio']*100:.0f}%",
flush=True,
)
tops = ", ".join(f"{n}×{c}" for n, c in s["top_notes"][:5])
print(f" top: {tops}", flush=True)
def write_frame_log(path: str, frames: list[PitchFrame], summary: dict) -> None:
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(f"# mic pitch {datetime.now().isoformat(timespec='seconds')}\n")
f.write("# " + json.dumps(summary, ensure_ascii=False) + "\n")
f.write("t_s,hz,midi,note,conf,rms\n")
for fr in frames:
midi = "" if not np.isfinite(fr.midi) else f"{fr.midi:.2f}"
f.write(
f"{fr.t:.3f},{fr.hz:.2f},{midi},{fr.note},{fr.conf:.3f},{fr.rms:.4f}\n"
)
def live_monitor(
sr: int,
device: int | None,
fmin: float,
fmax: float,
seconds: float,
) -> None:
win = int(sr * 0.08)
hop = int(sr * 0.05)
print("Live pitch (Ctrl+C stop)...", flush=True)
buf = np.zeros(0, dtype=np.float32)
t0 = time.time()
def callback(indata, frames, time_info, status): # noqa: ARG001
nonlocal buf
if status:
print(status, flush=True)
buf = np.concatenate([buf, indata[:, 0].copy()])
while len(buf) >= win:
frame = buf[:win]
buf = buf[hop:]
hz, conf = estimate_f0_acorr(frame, sr, fmin, fmax)
rms = float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
if hz > 0 and conf >= 0.35 and rms >= 0.01:
midi = hz_to_midi(hz)
print(
f"{time.time()-t0:6.1f}s {midi_to_name(midi):4s} "
f"midi={midi:5.1f} {hz:6.1f}Hz conf={conf:.2f} rms={rms:.3f}",
flush=True,
)
with sd.InputStream(samplerate=sr, channels=1, dtype="float32", device=device, callback=callback):
if seconds > 0:
sd.sleep(int(seconds * 1000))
else:
while True:
sd.sleep(200)
def compare_summaries(a: dict, b: dict) -> None:
print("\n======== COMPARE ========", flush=True)
print_summary(a)
print_summary(b)
if a.get("voiced", 0) == 0 or b.get("voiced", 0) == 0:
print("Need pitched content in both takes.", flush=True)
return
d_midi = b["midi_median"] - a["midi_median"]
print(f"median delta (B-A): {d_midi:+.2f} semitones", flush=True)
if d_midi <= -9:
print("PASS-ish: B is about an octave lower than A (expected III+ vs I/II).", flush=True)
elif d_midi <= -5:
print("PARTIAL: B lower than A but less than full octave.", flush=True)
elif abs(d_midi) < 2:
print("FAIL-ish: medians similar — register fix may not be audible on mic mix.", flush=True)
else:
print("CHECK: unexpected direction/amount; inspect top notes / low_le_E2 ratios.", flush=True)
print(
f"low<=E2: A={a['low_le_E2_ratio']*100:.0f}% B={b['low_le_E2_ratio']*100:.0f}%",
flush=True,
)
def main() -> None:
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
p = argparse.ArgumentParser(description="Mic pitch analyzer for K1 register tests")
p.add_argument("--list", action="store_true", help="List audio devices")
p.add_argument("--device", type=int, default=None, help="Input device index")
p.add_argument("--sr", type=int, default=16000)
p.add_argument("--seconds", type=float, default=8.0)
p.add_argument("--live", action="store_true")
p.add_argument("--wav", default="", help="Analyze existing wav instead of recording")
p.add_argument("--out", default="", help="Prefix for wav/csv outputs")
p.add_argument("--fmin", type=float, default=40.0, help="Min F0 Hz (bass ~41=E1)")
p.add_argument("--fmax", type=float, default=600.0, help="Max F0 Hz")
p.add_argument(
"--compare",
nargs=2,
metavar=("A", "B"),
help="Compare two wav/csv summary sources (wav preferred)",
)
p.add_argument("--label", default="")
args = p.parse_args()
if args.list:
list_devices()
return
if args.compare:
summaries = []
for i, path in enumerate(args.compare):
label = "A" if i == 0 else "B"
if path.lower().endswith(".wav"):
audio, sr = load_wav(path)
frames = analyze_audio(audio, sr, fmin=args.fmin, fmax=args.fmax)
s = summarize(frames, label=f"{label}:{os.path.basename(path)}")
else:
raise SystemExit("compare expects .wav files")
summaries.append(s)
compare_summaries(summaries[0], summaries[1])
return
if args.live:
live_monitor(args.sr, args.device, args.fmin, args.fmax, args.seconds if args.seconds > 0 else 0)
return
prefix = args.out or f"mic_pitch_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
if args.wav:
audio, sr = load_wav(args.wav)
label = args.label or os.path.basename(args.wav)
else:
audio = record(args.seconds, args.sr, args.device)
sr = args.sr
label = args.label or "rec"
wav_path = prefix if prefix.lower().endswith(".wav") else prefix + ".wav"
save_wav(wav_path, audio, sr)
print(f"Saved wav: {os.path.abspath(wav_path)}", flush=True)
frames = analyze_audio(audio, sr, fmin=args.fmin, fmax=args.fmax)
summary = summarize(frames, label=label)
print_summary(summary)
log_path = prefix if prefix.lower().endswith(".csv") else prefix + ".csv"
# if prefix was .wav, still write .csv alongside
if log_path.lower().endswith(".wav.csv"):
log_path = log_path[:-8] + ".csv"
elif prefix.lower().endswith(".wav"):
log_path = prefix[:-4] + ".csv"
write_frame_log(log_path, frames, summary)
print(f"Saved log: {os.path.abspath(log_path)}", flush=True)
print(
"Tip: record I/II then III+ separately, then:\n"
" python tools/mic_pitch_analyze.py --compare mic_i.wav mic_iii.wav",
flush=True,
)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nStopped.", flush=True)
sys.exit(130)

View File

@ -1,22 +1,23 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Pack external W25Q128 tone/logo image for K1 (0903).
"""Pack external W25Q128 tone/logo image for K1 (0914).
Layout (absolute W25Q128 offsets):
0x00000000 logo.bin (legacy pad; boot UI uses UI0902 full-screen logo)
0x0000CB70 Charg.bin (legacy pad; charge UI uses UI0902_CHARGE_SCREEN full-screen)
0x0000CB70 Charg.bin (legacy pad; charge UI uses UI0902_CHARGE_SCREEN)
0x0001B8F0 1.bin 普通/专业 31 rhythms
0x0009D07D 2.bin 海阔天空
0x000A8607 3.bin 万能模式
0x0009D07D 2.bin 本地曲目变长<=41KB
after 2.bin 3.bin 万能模式 2.bin 长度后移写入 ExtFlash_Tone_Addr.h
Outputs:
tools/out/extflash_tone_0903.bin
tools/out/extflash_tone_0914.bin / .res
tools/out/extflash_ALL_tone0914_ui0902.res
project/inc/ExtFlash_Tone_Addr.h
Doc/音色文件/0903/FLASH_MAP.txt
Doc/音色文件/0914/FLASH_MAP.txt
"""
from __future__ import annotations
import os
import struct
import subprocess
import sys
from pathlib import Path
@ -24,11 +25,28 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] # firmware project root
REPO = ROOT.parent.parent # 一诺国际吉他
OUT_DIR = ROOT / "tools" / "out"
OUT_BIN = OUT_DIR / "extflash_tone_0903.bin"
TONE_TAG = "0914"
OUT_BIN = OUT_DIR / f"extflash_tone_{TONE_TAG}.bin"
OUT_RES = OUT_DIR / f"extflash_tone_{TONE_TAG}.res"
OUT_ALL_RES = OUT_DIR / f"extflash_ALL_tone{TONE_TAG}_ui0902.res"
# Keep legacy alias names for older tools that still look for 0903
OUT_BIN_LEGACY = OUT_DIR / "extflash_tone_0903.bin"
OUT_RES_LEGACY = OUT_DIR / "extflash_tone_0903.res"
OUT_ALL_LEGACY = OUT_DIR / "extflash_ALL_tone0903_ui0902.res"
OUT_HDR = ROOT / "project" / "inc" / "ExtFlash_Tone_Addr.h"
OUT_MAP = REPO / "Doc" / "音色文件" / "0903" / "FLASH_MAP.txt"
OUT_MAP = REPO / "Doc" / "音色文件" / TONE_TAG / "FLASH_MAP.txt"
UI0902_BIN = OUT_DIR / "ui0902_res.bin"
TONE_DIR = REPO / "Doc" / "音色文件" / "0903"
TONE_DIR = REPO / "Doc" / "音色文件" / TONE_TAG
OFF_LOGO = 0x00000000
OFF_CHARGING = 0x0000CB70
OFF_BIN1 = 0x0001B8F0
OFF_BIN2 = 0x0009D07D
# BIN3 placed immediately after 2.bin (computed at pack time)
UI0902_RES_BASE = 0x00100000
DAB_MAGIC = b"\xABDAB"
MAX_SONG_BIN_BYTES = 41 * 1024 # 曲目文件(2.bin)硬上限 41KB
def find_ziliao() -> Path:
@ -38,29 +56,69 @@ def find_ziliao() -> Path:
raise FileNotFoundError("资料/logo.bin + Charg.bin not found under repo")
def dab_ok(blob: bytes, off: int, expect_cnt: int | None = None) -> None:
if off + 16 > len(blob):
raise SystemExit(f"DAB check @0x{off:X}: past end of pack ({len(blob)})")
magic = blob[off : off + 4]
cnt = struct.unpack_from("<I", blob, off + 12)[0]
if magic != DAB_MAGIC:
raise SystemExit(
f"DAB check @0x{off:X}: bad magic {magic.hex()} (want {DAB_MAGIC.hex()})"
)
if expect_cnt is not None and cnt != expect_cnt:
raise SystemExit(f"DAB check @0x{off:X}: cnt={cnt} want {expect_cnt}")
def require_region_equals(blob: bytes, off: int, src: bytes, label: str) -> None:
end = off + len(src)
if end > len(blob):
raise SystemExit(f"{label}: pack too short for region @0x{off:X}+{len(src)}")
if blob[off:end] != src:
raise SystemExit(f"{label}: bytes @0x{off:X} do not match source file")
def main() -> None:
if not TONE_DIR.is_dir():
raise SystemExit(f"missing tone dir: {TONE_DIR}")
ziliao = find_ziliao()
bin1_path = TONE_DIR / "1.bin"
bin2_path = TONE_DIR / "2.bin"
bin3_path = TONE_DIR / "3.bin"
for p in (bin1_path, bin2_path, bin3_path):
if not p.is_file():
raise SystemExit(f"missing {p}")
bin2_data = bin2_path.read_bytes()
if len(bin2_data) > MAX_SONG_BIN_BYTES:
raise SystemExit(
f"2.bin (曲目) too large: {len(bin2_data)} bytes > {MAX_SONG_BIN_BYTES} (41KB)"
)
off_bin3 = OFF_BIN2 + len(bin2_data)
parts = [
("LOGO", ziliao / "logo.bin", 0x00000000, "legacy pad; boot uses UI0902_BOOT_LOGO"),
("CHARGING", ziliao / "Charg.bin", 0x0000CB70, "legacy pad; UI uses UI0902_CHARGE_SCREEN"),
("BIN1_RHYTHM", TONE_DIR / "1.bin", 0x0001B8F0, "普通/专业 31 条节奏"),
("BIN2_SONG_HAITIAN", TONE_DIR / "2.bin", None, "本地曲目 海阔天空"),
("BIN3_UNIVERSAL", TONE_DIR / "3.bin", None, "万能模式"),
("LOGO", ziliao / "logo.bin", OFF_LOGO, "legacy pad; boot uses UI0902_BOOT_LOGO"),
("CHARGING", ziliao / "Charg.bin", OFF_CHARGING, "legacy pad; UI uses UI0902_CHARGE_SCREEN"),
("BIN1_RHYTHM", bin1_path, OFF_BIN1, "普通/专业 31 条节奏"),
("BIN2_SONG_HAITIAN", bin2_path, OFF_BIN2, "本地曲目"),
("BIN3_UNIVERSAL", bin3_path, off_bin3, "万能模式(紧跟 2.bin 之后)"),
]
blobs = []
blobs: list[bytes] = []
cursor = 0
rows = []
rows: list[tuple[str, int, int, str]] = []
for name, path, force_off, note in parts:
data = path.read_bytes()
if force_off is not None:
if cursor > force_off:
raise SystemExit(f"{name}: cursor 0x{cursor:X} past forced 0x{force_off:X}")
raise SystemExit(
f"{name}: cursor 0x{cursor:X} past forced 0x{force_off:X} "
f"(previous blob too large)"
)
if cursor < force_off:
pad = force_off - cursor
blobs.append(b"\xFF" * pad)
rows.append(("(pad)", cursor, pad, "gap fill 0xFF"))
cursor = force_off
rows.append((f"(pad)", force_off - pad, pad, "gap fill 0xFF"))
off = cursor
blobs.append(data)
cursor += len(data)
@ -69,9 +127,33 @@ def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
packed = b"".join(blobs)
OUT_BIN.write_bytes(packed)
OUT_RES.write_bytes(packed)
OUT_BIN_LEGACY.write_bytes(packed)
OUT_RES_LEGACY.write_bytes(packed)
# named lookup
by_name = {r[0]: r for r in rows if not r[0].startswith("(")}
bin1_data = bin1_path.read_bytes()
bin3_data = bin3_path.read_bytes()
dab_ok(packed, OFF_BIN1, expect_cnt=31)
dab_ok(packed, OFF_BIN2, expect_cnt=1)
dab_ok(packed, off_bin3, expect_cnt=3)
if by_name["BIN1_RHYTHM"][1] != OFF_BIN1:
raise SystemExit(f"BIN1 placed @0x{by_name['BIN1_RHYTHM'][1]:X} want 0x{OFF_BIN1:X}")
if by_name["BIN2_SONG_HAITIAN"][1] != OFF_BIN2:
raise SystemExit(f"BIN2 placed @0x{by_name['BIN2_SONG_HAITIAN'][1]:X} want 0x{OFF_BIN2:X}")
if by_name["BIN3_UNIVERSAL"][1] != off_bin3:
raise SystemExit(f"BIN3 placed @0x{by_name['BIN3_UNIVERSAL'][1]:X} want 0x{off_bin3:X}")
require_region_equals(packed, OFF_LOGO, (ziliao / "logo.bin").read_bytes(), "LOGO")
require_region_equals(packed, OFF_CHARGING, (ziliao / "Charg.bin").read_bytes(), "CHARGING")
require_region_equals(packed, OFF_BIN1, bin1_data, "BIN1/1.bin")
require_region_equals(packed, OFF_BIN2, bin2_data, "BIN2/2.bin")
require_region_equals(packed, off_bin3, bin3_data, "BIN3/3.bin")
if cursor > UI0902_RES_BASE:
raise SystemExit(
f"ERROR: tone pack end 0x{cursor:X} overflows UI0902 @ 0x{UI0902_RES_BASE:X}"
)
hdr = f"""#ifndef __EXTFLASH_TONE_ADDR_H
#define __EXTFLASH_TONE_ADDR_H
@ -79,10 +161,10 @@ def main() -> None:
/* Auto-generated by tools/pack_extflash_tone_0903.py do not hand-edit. */
/* W25Q128 absolute offsets. Boot logo display uses UI0902_BOOT_LOGO_ADDR. */
/* Tone resource pack has no in-bin version field; release tag = EXTFLASH_TONE_RES_VER. */
#define EXTFLASH_TONE_RES_VER "0903"
#define EXTFLASH_TONE_RES_VER "{TONE_TAG}"
#define EXTFLASH_TONE_RES_VER_MAJOR 0
#define EXTFLASH_TONE_RES_VER_MINOR 9
#define EXTFLASH_TONE_RES_VER_PATCH 3
#define EXTFLASH_TONE_RES_VER_PATCH 14
#define EXTFLASH_LOGO_ADDR 0x{by_name['LOGO'][1]:08X}UL
#define EXTFLASH_LOGO_SIZE {by_name['LOGO'][2]}UL
@ -92,13 +174,13 @@ def main() -> None:
#define EXTFLASH_CHARGING_W 160
#define EXTFLASH_CHARGING_H 190
#define EXTFLASH_BIN1_RHYTHM_ADDR 0x{by_name['BIN1_RHYTHM'][1]:08X}UL
#define EXTFLASH_BIN1_RHYTHM_ADDR 0x{OFF_BIN1:08X}UL
#define EXTFLASH_BIN1_RHYTHM_SIZE {by_name['BIN1_RHYTHM'][2]}UL
#define EXTFLASH_BIN2_SONG_HAITIAN_ADDR 0x{by_name['BIN2_SONG_HAITIAN'][1]:08X}UL
#define EXTFLASH_BIN2_SONG_HAITIAN_ADDR 0x{OFF_BIN2:08X}UL
#define EXTFLASH_BIN2_SONG_HAITIAN_SIZE {by_name['BIN2_SONG_HAITIAN'][2]}UL
#define EXTFLASH_BIN3_UNIVERSAL_ADDR 0x{by_name['BIN3_UNIVERSAL'][1]:08X}UL
#define EXTFLASH_BIN3_UNIVERSAL_ADDR 0x{off_bin3:08X}UL
#define EXTFLASH_BIN3_UNIVERSAL_SIZE {by_name['BIN3_UNIVERSAL'][2]}UL
/* Convenience aliases used by UI ADDRESS */
@ -107,7 +189,7 @@ def main() -> None:
#define FLASH_ADDR_SONG_HAITIAN EXTFLASH_BIN2_SONG_HAITIAN_ADDR
#define FLASH_ADDR_MODE_UNIVERSAL EXTFLASH_BIN3_UNIVERSAL_ADDR /* 万能 */
/* Legacy AutoBand bank not repacked in 0903; left unchanged in firmware */
/* Legacy AutoBand bank not repacked; may overlap 2.bin do not enable until remapped */
#define FLASH_ADDR_AUTOBAND_LEGACY 0x0009EB5FUL
#define EXTFLASH_TONE_PACK_END 0x{cursor:08X}UL
@ -119,9 +201,13 @@ def main() -> None:
OUT_HDR.write_text(hdr, encoding="utf-8", newline="\n")
map_lines = [
"K1 external Flash map — tone pack 0903",
f"K1 external Flash map — tone pack {TONE_TAG}",
f"Packed file: Code/.../tools/out/{OUT_BIN.name} ({len(packed)} bytes)",
f"Ends at 0x{cursor:X}; UI0902_RES_BASE=0x00100000; free gap={0x100000 - cursor} bytes",
f"Also: {OUT_RES.name}; ALL.res when ui0902_res.bin present",
f"Ends at 0x{cursor:X}; UI0902_RES_BASE=0x00100000; free gap={UI0902_RES_BASE - cursor} bytes",
"",
f"NOTE: BIN3 follows 2.bin @ 0x{off_bin3:X} (2.bin grew past old fixed 0xA71AC).",
" 2.bin must be <= 41KB; LOGO/CHARG/BIN1/BIN2 offsets remain fixed.",
"",
f"{'Name':<22} {'Offset':>10} {'Size':>10} Note",
"-" * 72,
@ -131,32 +217,62 @@ def main() -> None:
map_lines += [
"",
"Firmware ADDRESS mapping:",
" 普通/专业 -> FLASH_ADDR_MODE_NORMAL/EXPERT (1.bin)",
" 海阔天空 -> FLASH_ADDR_SONG_HAITIAN (2.bin)",
" 万能 -> FLASH_ADDR_MODE_UNIVERSAL (3.bin)",
" AutoBand -> FLASH_ADDR_AUTOBAND_LEGACY 0x9EB5F (unchanged; overlaps 2.bin region — do not enable until remapped)",
" 普通/专业 -> FLASH_ADDR_MODE_NORMAL/EXPERT (1.bin @ 0x1B8F0)",
" 本地曲目 -> FLASH_ADDR_SONG_HAITIAN (2.bin @ 0x9D07D, max 41KB)",
f" 万能 -> FLASH_ADDR_MODE_UNIVERSAL (3.bin @ 0x{off_bin3:X})",
" Boot logo -> UI0902_BOOT_LOGO_ADDR (full-screen); packed logo.bin only pads 0x0..0xCB70",
" Charging -> EXTFLASH_CHARGING_ADDR",
" Charging -> EXTFLASH_CHARGING_ADDR (legacy pad) / UI0902_CHARGE_SCREEN",
"",
"Add a local song:",
" 1. Pack the new preset into 2.bin",
" 2. Append a row to local_songs.csv (index,code,name)",
" 3. python tools/pack_extflash_tone_0903.py (also regenerates LocalSongNames.h)",
" 4. Rebuild firmware and flash MCU + ExtFlash",
"Rebuild after pack:",
" 1. python tools/pack_extflash_tone_0903.py",
" 2. Rebuild MCU (ExtFlash_Tone_Addr.h updated)",
" 3. Flash MCU + ExtFlash ALL.res",
]
OUT_MAP.parent.mkdir(parents=True, exist_ok=True)
OUT_MAP.write_text("\n".join(map_lines) + "\n", encoding="utf-8", newline="\n")
print(f"Wrote {OUT_BIN} ({len(packed)} bytes)")
print(f"Wrote {OUT_RES} ({len(packed)} bytes)")
print(f"Wrote {OUT_HDR}")
print(f"Wrote {OUT_MAP}")
for name, off, size, note in rows:
print(f" 0x{off:08X} {size:8d} {name} {note}")
if cursor > 0x00100000:
raise SystemExit("ERROR: pack overflows into UI0902_RES_BASE")
print(f"OK: {0x00100000 - cursor} bytes free before UI0902 @ 0x00100000")
print(f"OK: {UI0902_RES_BASE - cursor} bytes free before UI0902 @ 0x{UI0902_RES_BASE:X}")
print("OK: LOGO/CHARG/BIN1/BIN2/BIN3 present and match source files")
print(f"OK: 2.bin size {len(bin2_data)} <= 41KB; BIN3 @ 0x{off_bin3:X}")
# Keep LocalSongNames.h in sync with Doc/.../local_songs.csv when packing tones.
if UI0902_BIN.is_file():
ui = UI0902_BIN.read_bytes()
all_res = packed + (b"\xFF" * (UI0902_RES_BASE - len(packed))) + ui
require_region_equals(all_res, OFF_BIN1, bin1_data, "ALL.res BIN1")
require_region_equals(all_res, OFF_BIN2, bin2_data, "ALL.res BIN2")
require_region_equals(all_res, off_bin3, bin3_data, "ALL.res BIN3")
dab_ok(all_res, OFF_BIN1, 31)
dab_ok(all_res, OFF_BIN2, 1)
dab_ok(all_res, off_bin3, 3)
# UI0902 magic/presence: non-empty and starts within expected region
if len(ui) < 1024:
raise SystemExit(f"UI0902 pack suspiciously small: {len(ui)}")
OUT_ALL_RES.write_bytes(all_res)
OUT_ALL_LEGACY.write_bytes(all_res)
repo_out = REPO / "tools" / "out"
repo_out.mkdir(parents=True, exist_ok=True)
(repo_out / OUT_ALL_RES.name).write_bytes(all_res)
(repo_out / OUT_ALL_LEGACY.name).write_bytes(all_res)
(repo_out / OUT_RES.name).write_bytes(packed)
(repo_out / OUT_RES_LEGACY.name).write_bytes(packed)
print(f"Wrote {OUT_ALL_RES} ({len(all_res)} bytes) — verified logo/charg/1/2/3 + UI0902")
else:
print(f"WARN: {UI0902_BIN} missing; skipped ALL.res")
# Prefer 0914 CSV; fall back to 0903
csv_0914 = TONE_DIR / "local_songs.csv"
gen = ROOT / "tools" / "gen_local_song_names.py"
if not csv_0914.is_file():
src = REPO / "Doc" / "音色文件" / "0903" / "local_songs.csv"
if src.is_file():
csv_0914.write_bytes(src.read_bytes())
print(f"Copied {src.name} -> {csv_0914}")
r = subprocess.run([sys.executable, str(gen)], check=False)
if r.returncode != 0:
raise SystemExit(f"gen_local_song_names.py failed ({r.returncode})")

92
tools/patch_chs_bi.py Normal file
View File

@ -0,0 +1,92 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Append missing GBK glyph 闭 (0xB1,0xD5) to all CHS* tables + Chinese_font_* arrays.
Settings autoclose label uses 关闭; exists, was missing blank second char.
Uses tools/gen_wqy_font.py rasterizer (WenQuanYi BDF).
"""
from __future__ import annotations
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "tools"))
import gen_wqy_font as wqy # noqa: E402
HEADER = os.path.join(ROOT, "device", "LCD_ILI9341", "Drv_ILI9341_Lcd_ChineseFont.h")
# size -> (table name, font array name, bytes per glyph)
SIZES = {
8: ("CHS8Table", "Chinese_font_8", 8),
9: ("CHS9Table", "Chinese_font_9", 18),
11: ("CHS11Table", "Chinese_font_11", 22),
12: ("CHS12Table", "Chinese_font_12", 24),
13: ("CHS13Table", "Chinese_font_13", 26),
15: ("CHS15Table", "Chinese_font_15", 30),
16: ("CHS16Table", "Chinese_font_16", 32),
24: ("CHS24Table", "Chinese_font_24", 72),
}
BI_H, BI_L = 0xB1, 0xD5
NEEDLE = "{0x%02X,0x%02X}" % (BI_H, BI_L)
def render_bi(size: int) -> list[int]:
"""Rasterize 闭; size 24 has no WQY map — nearest-neighbor upscale from 16."""
src = 16 if size == 24 else size
pixels = wqy.glyph_pixels("", src, latin=False)
if size == 24:
# 16x16 -> 24x24
up = []
for y in range(24):
sy = min(15, y * 16 // 24)
for x in range(24):
sx = min(15, x * 16 // 24)
up.append(pixels[sy * 16 + sx])
pixels = up
return wqy.cn_to_bytes_msb(pixels, size, size)
def main() -> None:
text = open(HEADER, encoding="utf-8", errors="replace").read()
for size, (tname, aname, nbytes) in SIZES.items():
tm = re.search(
rf"(const char {tname}\[\]\[2\] = \{{)(.*?)(\n\}};)", text, re.S
)
if not tm:
raise SystemExit(f"missing {tname}")
body = tm.group(2)
compact = re.sub(r"\s+", "", body).lower()
if NEEDLE.lower() in compact:
print(f"skip table {tname}: already has 闭")
continue
body = body.rstrip() + f"\n {{0x{BI_H:02X},0x{BI_L:02X}}}, // 闭\n"
text = text[: tm.start()] + tm.group(1) + body + tm.group(3) + text[tm.end() :]
print(f"append table {tname}")
am = re.search(
rf"(const unsigned char {aname}\[\]\[\d+\] = \{{)(.*?)(\n\}};)", text, re.S
)
if not am:
raise SystemExit(f"missing {aname}")
fbody = am.group(2)
idx = len(re.findall(r'/\*"', fbody))
data = render_bi(size)
if len(data) != nbytes:
raise SystemExit(
f"{aname}: expected {nbytes} bytes, got {len(data)} for size={size}"
)
arr = ",".join(f"0x{b:02X}" for b in data)
fbody = fbody.rstrip() + f"\n{{{arr}}},/*\"\",{idx}*/"
text = text[: am.start()] + am.group(1) + fbody + am.group(3) + text[am.end() :]
print(f"append font {aname} idx={idx}")
open(HEADER, "w", encoding="utf-8", newline="\n").write(text)
print("patched", HEADER)
if __name__ == "__main__":
main()

View File

@ -1,15 +1,17 @@
# -*- coding: utf-8 -*-
"""Build K1 release zip: MCU .bin + ExtFlash resources + SoundWalkerIAP + docs.
"""Build K1 release zip: Boot + MCU APP + ExtFlash resources + SoundWalkerIAP + docs.
External Flash has TWO regions that must both be present on a blank/erased chip:
0x00000000 tone pack (logo pad + Charg + 1/2/3.bin) -> *.tone.res / combined
0x00100000 UI0902 bitmaps (mode rows, boot logo, ...) -> *.ui0902.res / combined
Prefer flashing the combined *.extflash.res @ 0x0 for colleague upgrades.
Boot must also be updated so USB IAP wait screen can show UI0902_FLASH_MODE.
"""
from __future__ import annotations
import hashlib
import re
import shutil
import subprocess
import zipfile
@ -19,15 +21,27 @@ from pathlib import Path
REPO = Path(r"C:\Users\qjyu\Documents\SoundWalker\一诺国际吉他")
PROJ = REPO / "Code" / "YNGJ-GT1-M - AT32F403ARCT7"
EXE_BIN = PROJ / "project" / "IAR_V7.4" / "YNGJ-GT1-M" / "Exe" / "YNGJ-GT1-M.bin"
TONE_BIN = PROJ / "tools" / "out" / "extflash_tone_0903.bin"
BOOT_BIN = (
PROJ
/ "AT32F403ARCT7_BOOT"
/ "project"
/ "IAR_V7.4"
/ "AT32F403ARCT7_BOOT"
/ "Exe"
/ "AT32F403ARCT7_BOOT.bin"
)
TONE_RES_VER = "0914"
TONE_BIN = PROJ / "tools" / "out" / f"extflash_tone_{TONE_RES_VER}.bin"
UI0902_BIN = PROJ / "tools" / "out" / "ui0902_res.bin"
MAP_TXT = REPO / "Doc" / "音色文件" / "0903" / "FLASH_MAP.txt"
MAP_TXT = REPO / "Doc" / "音色文件" / TONE_RES_VER / "FLASH_MAP.txt"
TONE_HDR = PROJ / "project" / "inc" / "ExtFlash_Tone_Addr.h"
IAP_DIR = REPO / "升级" / "MCU主控升级"
OUT_ROOT = REPO / "tools" / "out"
FW_VER = "0.2.6"
TONE_RES_VER = "0903"
FW_VER = "0.2.12"
UI0902_RES_BASE = 0x00100000
BOOT_FLASH_ADDR = 0x08000000
APP_FLASH_ADDR = 0x08008000
def git_info(cwd: Path) -> tuple[str, str]:
@ -57,13 +71,81 @@ def build_combined_extflash(tone: bytes, ui: bytes) -> bytes:
return tone + (b"\xFF" * pad) + ui
def read_tone_addrs() -> dict[str, int]:
text = TONE_HDR.read_text(encoding="utf-8", errors="replace")
def grab(name: str) -> int:
m = re.search(rf"#define\s+{name}\s+0x([0-9A-Fa-f]+)UL", text)
if not m:
raise SystemExit(f"missing {name} in {TONE_HDR}")
return int(m.group(1), 16)
return {
"LOGO": grab("EXTFLASH_LOGO_ADDR"),
"CHARGING": grab("EXTFLASH_CHARGING_ADDR"),
"BIN1": grab("EXTFLASH_BIN1_RHYTHM_ADDR"),
"BIN2": grab("EXTFLASH_BIN2_SONG_HAITIAN_ADDR"),
"BIN3": grab("EXTFLASH_BIN3_UNIVERSAL_ADDR"),
}
def verify_tone_layout(tone: bytes, check_sources: bool = True) -> None:
"""Refuse to ship ALL.res if logo/charg/1/2/3.bin missing, shifted, or mismatch."""
dab = b"\xABDAB"
addrs = read_tone_addrs()
tone_dir = REPO / "Doc" / "音色文件" / TONE_RES_VER
ziliao = None
for p in REPO.iterdir():
if p.is_dir() and (p / "logo.bin").is_file() and (p / "Charg.bin").is_file():
ziliao = p
break
if ziliao is None:
raise SystemExit("logo.bin/Charg.bin not found under repo")
sources = [
("LOGO", addrs["LOGO"], None, ziliao / "logo.bin"),
("CHARGING", addrs["CHARGING"], None, ziliao / "Charg.bin"),
("BIN1", addrs["BIN1"], 31, tone_dir / "1.bin"),
("BIN2", addrs["BIN2"], 1, tone_dir / "2.bin"),
("BIN3", addrs["BIN3"], 3, tone_dir / "3.bin"),
]
for name, off, want_cnt, src_path in sources:
if off + 16 > len(tone):
raise SystemExit(f"verify {name}: tone pack too short for 0x{off:X}")
if want_cnt is not None:
magic = tone[off : off + 4]
cnt = int.from_bytes(tone[off + 12 : off + 16], "little")
if magic != dab or cnt != want_cnt:
raise SystemExit(
f"verify {name} @0x{off:X} failed: magic={magic.hex()} cnt={cnt} "
f"(want ABDAB/{want_cnt}). Re-run pack_extflash_tone_0903.py."
)
if check_sources and src_path.is_file():
src = src_path.read_bytes()
if name == "BIN2" and len(src) > 41 * 1024:
raise SystemExit(f"verify BIN2: source 2.bin {len(src)} > 41KB")
if tone[off : off + len(src)] != src:
raise SystemExit(f"verify {name}: content @0x{off:X} != {src_path}")
print(
"verify tone layout: LOGO/CHARG/BIN1/BIN2/BIN3 OK "
f"(BIN3@0x{addrs['BIN3']:X})"
)
def main() -> None:
if not BOOT_BIN.is_file():
raise SystemExit(f"missing Boot bin: {BOOT_BIN}")
if not EXE_BIN.is_file():
raise SystemExit(f"missing MCU bin: {EXE_BIN}")
if not TONE_BIN.is_file():
tone_bin = TONE_BIN
if not tone_bin.is_file():
legacy = PROJ / "tools" / "out" / "extflash_tone_0903.bin"
if legacy.is_file():
tone_bin = legacy
else:
raise SystemExit(f"missing tone pack: {TONE_BIN} (run pack_extflash_tone_0903.py)")
if not UI0902_BIN.is_file():
raise SystemExit(f"missing UI0902 pack: {UI0902_BIN} (run gen_ui0902_assets.py)")
if not TONE_HDR.is_file():
raise SystemExit(f"missing {TONE_HDR}")
iap = IAP_DIR / "SoundWalkerIAP.exe"
guide = IAP_DIR / "升级步骤.docx"
@ -82,16 +164,24 @@ def main() -> None:
shutil.rmtree(pkg)
pkg.mkdir(parents=True)
tone_data = TONE_BIN.read_bytes()
tone_data = tone_bin.read_bytes()
verify_tone_layout(tone_data)
ui_data = UI0902_BIN.read_bytes()
if len(ui_data) < 1024:
raise SystemExit(f"UI0902 pack too small: {len(ui_data)}")
combined = build_combined_extflash(tone_data, ui_data)
verify_tone_layout(combined)
addrs = read_tone_addrs()
boot_name = f"AT32F403ARCT7_BOOT_v{FW_VER}_{stamp}.bin"
mcu_name = f"YNGJ-GT1-M_MCU_v{FW_VER}_{stamp}.bin"
comb_name = f"extflash_ALL_tone0903_ui0902_{stamp}.res"
comb_name = f"extflash_ALL_tone{TONE_RES_VER}_ui0902_{stamp}.res"
boot_dst = pkg / boot_name
mcu_dst = pkg / mcu_name
comb_dst = pkg / comb_name
shutil.copy2(BOOT_BIN, boot_dst)
shutil.copy2(EXE_BIN, mcu_dst)
comb_dst.write_bytes(combined)
shutil.copy2(iap, pkg / "SoundWalkerIAP.exe")
@ -99,10 +189,10 @@ def main() -> None:
if MAP_TXT.is_file():
shutil.copy2(MAP_TXT, pkg / "FLASH_MAP.txt")
shutil.copy2(boot_dst, OUT_ROOT / boot_name)
shutil.copy2(mcu_dst, OUT_ROOT / mcu_name)
shutil.copy2(comb_dst, OUT_ROOT / "extflash_ALL_tone0903_ui0902.res")
# Keep build intermediates under tools/out for local rebuilds; not shipped in package.
(OUT_ROOT / "extflash_tone_0903.res").write_bytes(tone_data)
shutil.copy2(comb_dst, OUT_ROOT / f"extflash_ALL_tone{TONE_RES_VER}_ui0902.res")
(OUT_ROOT / f"extflash_tone_{TONE_RES_VER}.res").write_bytes(tone_data)
(OUT_ROOT / "extflash_ui0902.res").write_bytes(ui_data)
readme = pkg / "README.txt"
@ -115,28 +205,36 @@ def main() -> None:
f"Git subject {subject}",
f"FW version {FW_VER}",
"",
"==== 整机升级请刷这两项 ====",
f" 1) {mcu_name} -> MCU APP",
f" 2) {comb_name} -> 外部 Flash 从 0x0 起整包",
"==== 整机升级请刷这三项 ====",
f" 1) {boot_name} -> Bootloader @ 0x{BOOT_FLASH_ADDR:08X}",
f" 2) {mcu_name} -> MCU APP @ 0x{APP_FLASH_ADDR:08X}",
f" 3) {comb_name} -> 外部 Flash 从 0x0 起整包",
" (= 音色区 + 填充 + UI0902 图片区)",
"",
"建议步骤:",
" - 先刷 Boot否则 USB 烧录等待画面仍是旧红字「升级模式」)",
" - 再刷 APP",
" - 先整片擦除外部 Flash再刷上述 ALL .res @ 0x00000000",
" - 勿只刷音色区否则模式选择页会花屏UI 图在 0x100000",
"",
"外部 Flash 分区:",
" 0x00000000 音色/充电图 (toneRes)",
" logo pad + Charg@0xCB70 + 1.bin@0x1B8F0 + 2.bin(HKTK/2s) + 3.bin",
f" logo@0x{addrs['LOGO']:X} + Charg@0x{addrs['CHARGING']:X} + "
f"1.bin@0x{addrs['BIN1']:X} + 2.bin@0x{addrs['BIN2']:X} + 3.bin@0x{addrs['BIN3']:X}",
" 0x00100000 UI0902 图片 (模式选择、开机 Logo、充电、烧录模式、设置/调音台等)",
" 0x001D2000 UI0902_FLASH_MODE 烧录模式全屏图Boot 读取)",
"",
f" {boot_name} size={boot_dst.stat().st_size} @ 0x{BOOT_FLASH_ADDR:08X}",
f" sha256={sha256(boot_dst)}",
f" {mcu_name} size={mcu_dst.stat().st_size} @ 0x{APP_FLASH_ADDR:08X}",
f" sha256={sha256(mcu_dst)}",
f" {comb_name} size={comb_dst.stat().st_size} @ 0x00000000",
f" sha256={sha256(comb_dst)}",
"",
f"MCU: {mcu_name} size={mcu_dst.stat().st_size} sha256={sha256(mcu_dst)}",
"",
"说明:",
" - Boot 进入 USB IAP 时显示 UI0902_FLASH_MODE资源未烧录时白字黑底兜底。",
" - 开机全屏 Logo、关机充电全屏画面在 UI0902不在音色包里的 logo.bin/Charg.bin 占位。",
" - AutoBand 0x9EB5F 本版未重排",
f" - 本包音色来源 Doc/音色文件/{TONE_RES_VER}/BIN3 随 2.bin 长度后移",
"",
"附件: SoundWalkerIAP.exe / 升级步骤.docx / FLASH_MAP.txt",
"",

View File

@ -0,0 +1,206 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Bottom nav: 0909 enlarged icons + original design text strips, fixed baseline."""
from __future__ import annotations
import re
import subprocess
from io import BytesIO
from pathlib import Path
from PIL import Image
PROJ = Path(__file__).resolve().parents[1]
ROOT = PROJ.parent.parent
SRC_DIR = next(
p
for p in (ROOT / "Doc" / "UI" / "K1标准界面图 0904").iterdir()
if p.is_dir() and "0909" in p.name
)
ASSETS = PROJ / "tools" / "assets_0902" / "03_底部图标"
DOC_ASSETS = ROOT / "Doc" / "UI" / "k1_ui_png 0902" / "03_底部图标"
OUT_C = PROJ / "device" / "LCD_ILI9341" / "Drv_ILI9341_Lcd_Image_UI0902.c"
OUT_H = PROJ / "device" / "LCD_ILI9341" / "Drv_ILI9341_Lcd_Image_UI0902.h"
ROW_BG = (36, 43, 58)
CANVAS_H = 32
ICON_AREA_H = 20
TEXT_TOP = 22 # all labels share this top row
# png_idx, src_glyph_num, symbol_base, width
MAPPING = [
(10, 21, "gImage_UI0902_TabSetting_Sel", 40),
(11, 23, "gImage_UI0902_TabSetting_Not", 40),
(12, 25, "gImage_UI0902_TabMixer_Sel", 40),
(13, 27, "gImage_UI0902_TabMixer_Not", 22),
(14, 20, "gImage_UI0902_TabMode_Sel", 40),
(15, 22, "gImage_UI0902_TabMode_Not", 40),
(16, 24, "gImage_UI0902_TabBack_Sel", 40),
(17, 26, "gImage_UI0902_TabBack_Not", 22),
]
def git_png(rel: str) -> Image.Image:
data = subprocess.check_output(["git", "show", f"HEAD:{rel}"], cwd=PROJ)
return Image.open(BytesIO(data)).convert("RGBA")
def clear_bg(im: Image.Image) -> Image.Image:
im = im.convert("RGBA")
px = im.load()
for y in range(im.height):
for x in range(im.width):
r, g, b, a = px[x, y]
if a < 40 or (r < 12 and g < 12 and b < 12):
px[x, y] = (0, 0, 0, 0)
return im
def row_has_ink(im: Image.Image, y: int) -> bool:
w = im.width
for x in range(w):
r, g, b, a = im.getpixel((x, y))
if a > 40 and (r + g + b) > 40:
return True
return False
def extract_text(im: Image.Image) -> Image.Image:
"""Crop label band below the icon/gap from original composite."""
im = clear_bg(im)
h = im.height
# find last empty gap row in upper half, then text starts after
empty = [y for y in range(h) if not row_has_ink(im, y)]
# text region: from first non-empty after mid-gap
mid_empties = [y for y in empty if 10 <= y <= 20]
if mid_empties:
text_y0 = mid_empties[-1] + 1
else:
text_y0 = h // 2 + 2
while text_y0 < h and not row_has_ink(im, text_y0):
text_y0 += 1
text_y1 = h - 1
while text_y1 > text_y0 and not row_has_ink(im, text_y1):
text_y1 -= 1
band = im.crop((0, text_y0, im.width, text_y1 + 1))
# trim horizontal transparent
bbox = band.getbbox()
if bbox:
band = band.crop(bbox)
return clear_bg(band)
def fit_icon(im: Image.Image) -> Image.Image:
im = clear_bg(im)
nw, nh = im.size
if nw > 24 or nh > ICON_AREA_H:
scale = min(24 / nw, ICON_AREA_H / nh)
nw = max(1, int(round(nw * scale)))
nh = max(1, int(round(nh * scale)))
im = clear_bg(im.resize((nw, nh), Image.Resampling.NEAREST))
nw, nh = im.size
return im
def rgba_to_rgb565(im: Image.Image) -> tuple[bytes, int, int]:
im = im.convert("RGBA")
px = im.load()
w, h = im.size
out = bytearray(w * h * 2)
i = 0
for y in range(h):
for x in range(w):
r, g, b, a = px[x, y]
if a < 40 or (r < 12 and g < 12 and b < 12):
out[i] = out[i + 1] = 0
elif (
abs(r - ROW_BG[0]) <= 18
and abs(g - ROW_BG[1]) <= 18
and abs(b - ROW_BG[2]) <= 18
):
out[i] = out[i + 1] = 0
else:
r = (r * a) // 255
g = (g * a) // 255
b = (b * a) // 255
v = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
out[i] = (v >> 8) & 0xFF
out[i + 1] = v & 0xFF
i += 2
return bytes(out), w, h
def emit_c_array(name: str, data: bytes, w: int, h: int) -> str:
lines = [f"const unsigned char {name}[{len(data)}] = {{ /* {w}x{h} RGB565 BE */"]
for i in range(0, len(data), 16):
lines.append(",".join(f"0x{b:02X}" for b in data[i : i + 16]) + ",")
lines.append("};")
lines.append("")
return "\n".join(lines)
def replace_c_array(text: str, old_name: str, new_block: str) -> str:
pat = re.compile(
rf"const unsigned char {re.escape(old_name)}\[\d+\] = \{{.*?\n\}};\n?",
re.S,
)
if not pat.search(text):
raise SystemExit(f"array not found: {old_name}")
return pat.sub(new_block, text, count=1)
def replace_h_decl(text: str, old_name: str, new_name: str, nbytes: int) -> str:
pat = re.compile(rf"extern const unsigned char {re.escape(old_name)}\[\d+\];")
if not pat.search(text):
raise SystemExit(f"decl not found: {old_name}")
return pat.sub(
f"extern const unsigned char {new_name}[{nbytes}];", text, count=1
)
def main() -> None:
by_num = {int(f.stem.rsplit("-", 1)[-1]): f for f in SRC_DIR.glob("*.png")}
c_text = OUT_C.read_text(encoding="utf-8")
h_text = OUT_H.read_text(encoding="utf-8")
sizes: dict[int, tuple[int, int]] = {}
for out_idx, src_num, base, width in MAPPING:
icon = fit_icon(Image.open(by_num[src_num]))
old = git_png(f"tools/assets_0902/03_底部图标/底部图标-{out_idx}.png")
text = extract_text(old)
# widen canvas if text wider
cw = max(width, icon.width, text.width)
canvas = Image.new("RGBA", (cw, CANVAS_H), (0, 0, 0, 0))
iy = (ICON_AREA_H - icon.height) // 2
canvas.paste(icon, ((cw - icon.width) // 2, max(0, iy)), icon)
# pin text BOTTOM to same row so labels sit on one baseline
text_bottom = CANVAS_H - 1
ty = text_bottom - text.height + 1
if ty < TEXT_TOP:
ty = TEXT_TOP
canvas.paste(text, ((cw - text.width) // 2, ty), text)
out = ASSETS / f"底部图标-{out_idx}.png"
canvas.save(out)
if DOC_ASSETS.exists():
canvas.save(DOC_ASSETS / f"底部图标-{out_idx}.png")
data, w, h = rgba_to_rgb565(canvas)
new_name = f"{base}_{w}x{h}"
m = re.search(rf"{re.escape(base)}_\d+x\d+", c_text)
if not m:
raise SystemExit(f"cannot locate {base}")
old_name = m.group(0)
c_text = replace_c_array(c_text, old_name, emit_c_array(new_name, data, w, h))
h_text = replace_h_decl(h_text, old_name, new_name, len(data))
sizes[out_idx] = (w, h)
print(f"{out_idx}: {w}x{h} text={text.size} -> {new_name}")
OUT_C.write_text(c_text, encoding="utf-8")
OUT_H.write_text(h_text, encoding="utf-8")
print("SIZES", sizes)
if __name__ == "__main__":
main()

276
tools/rtt_bin13_reg_test.py Normal file
View File

@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""Probe 1.bin (rhythm) and 3.bin (universal) for bass ch8 + plan compliance.
Requires FW with: tone bin1 / tone bin3 / tone start / chord key / chord xpose / PITCH ch8(bass)
"""
from __future__ import annotations
import argparse
import os
import re
import sys
import time
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rtt_pitch_reg_test import ( # noqa: E402
RttSession,
analyze_lines,
connect_jlink,
expect_for_event,
find_rtt_control_block,
import_deps,
parse_pitch_line,
wait_rtt_ready,
)
PITCH_RE_CH = re.compile(r"\[PITCH\].*?ch(\d+)\((\w+)\)")
CH_SEEN_RE = re.compile(r"ch-seen ch(\d+) role=(\w+) key=(-?\d+)")
TONE_OK_RE = re.compile(r"TONE_BIN([13]).*name=(\S+).*count=(\d+)")
@dataclass
class PresetResult:
bin_id: str
idx: int
name: str = ""
channels: Counter = field(default_factory=Counter)
roles: Counter = field(default_factory=Counter)
bass_n: int = 0
drum_n: int = 0
chord_n: int = 0
pass_ok: int = 0
pass_fail: int = 0
iii_ok: int = 0
iii_fail: int = 0
xf_ok: int = 0
xf_fail: int = 0
bass_ok: int = 0
bass_fail: int = 0
samples: list[str] = field(default_factory=list)
def run_preset(sess: RttSession, bin_id: str, idx: int, hold: float) -> PresetResult:
res = PresetResult(bin_id=bin_id, idx=idx)
mark = len(sess.lines)
if bin_id == "1":
ok = sess.cmd_ack(f"tone bin1 {idx}", "TONE_BIN1", timeout_s=3.0, retries=4)
else:
ok = sess.cmd_ack(f"tone bin3 {idx}", "TONE_BIN3", timeout_s=3.0, retries=4)
if not ok:
res.samples.append("FAIL load ack")
return res
# parse name from recent lines
for line in sess.lines[-8:]:
m = TONE_OK_RE.search(line)
if m and m.group(1) == bin_id:
res.name = m.group(2)
break
sess.set_xpose(2)
sess.cmd("tone start", settle=0.7)
# I/II
sess.set_key(0)
sess.set_key(2)
sess.pump(hold)
# III
sess.set_key(0)
sess.set_key(8)
sess.pump(hold)
# VII + #F
sess.set_xpose(6)
sess.set_key(0)
sess.set_key(20)
sess.pump(hold)
sess.set_key(23) # stop
sess.pump(0.3)
chunk = sess.lines[mark:]
for line in chunk:
m = CH_SEEN_RE.search(line)
if m:
ch, role = int(m.group(1)), m.group(2)
res.channels[ch] += 1
res.roles[role] += 1
res.samples.append(line.strip())
ev = parse_pitch_line(line)
if not ev or not ev.is_on:
continue
res.channels[ev.ch] += 1
res.roles[ev.role] += 1
if ev.role == "bass" or ev.ch == 8:
res.bass_n += 1
elif ev.role == "drum" or ev.ch == 9:
res.drum_n += 1
else:
res.chord_n += 1
detail, ok = expect_for_event(ev)
tag = f"ch{ev.ch}({ev.role}) {ev.key_in}->{ev.key_out} chord={ev.chord} | {detail}"
if ev.chord <= 6:
if ok:
res.pass_ok += 1
else:
res.pass_fail += 1
res.samples.append("FAIL " + tag)
elif ev.xf:
if ok:
res.xf_ok += 1
else:
res.xf_fail += 1
res.samples.append("FAIL " + tag)
elif ev.role == "bass" or ev.ch == 8:
if ok:
res.bass_ok += 1
else:
res.bass_fail += 1
res.samples.append("FAIL " + tag)
else:
if ok:
res.iii_ok += 1
else:
res.iii_fail += 1
res.samples.append("FAIL " + tag)
return res
def main() -> int:
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser()
ap.add_argument("--hold", type=float, default=2.2)
ap.add_argument("--bin1", default="0,1,2,3,4,5,8,12,16,20,24,28", help="1.bin indices")
ap.add_argument("--bin3", default="0,1,2", help="3.bin indices")
ap.add_argument("--out", default="")
args = ap.parse_args()
bin1_idxs = [int(x) for x in args.bin1.split(",") if x.strip() != ""]
bin3_idxs = [int(x) for x in args.bin3.split(",") if x.strip() != ""]
import_deps()
jlink = connect_jlink("AT32F403AC")
results: list[PresetResult] = []
try:
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("RTT CB not found")
print(f"RTT CB @ 0x{cb:08X}", flush=True)
jlink.rtt_start(cb)
wait_rtt_ready(jlink)
sess = RttSession(jlink)
sess.pump(0.3)
sess.cmd("log clear", 0.3)
print("\n######## BIN1 / 1.bin rhythms ########", flush=True)
for idx in bin1_idxs:
print(f"\n--- BIN1 idx={idx} ---", flush=True)
r = run_preset(sess, "1", idx, args.hold)
results.append(r)
print(
f"name={r.name} ch={dict(r.channels)} roles={dict(r.roles)} "
f"bass={r.bass_n} drum={r.drum_n} chord={r.chord_n} "
f"I/II {r.pass_ok}/{r.pass_fail} III {r.iii_ok}/{r.iii_fail} "
f"xf {r.xf_ok}/{r.xf_fail} bassMap {r.bass_ok}/{r.bass_fail}",
flush=True,
)
print("\n######## BIN3 / 3.bin universal ########", flush=True)
for idx in bin3_idxs:
print(f"\n--- BIN3 idx={idx} ---", flush=True)
r = run_preset(sess, "3", idx, args.hold)
results.append(r)
print(
f"name={r.name} ch={dict(r.channels)} roles={dict(r.roles)} "
f"bass={r.bass_n} drum={r.drum_n} chord={r.chord_n} "
f"I/II {r.pass_ok}/{r.pass_fail} III {r.iii_ok}/{r.iii_fail} "
f"xf {r.xf_ok}/{r.xf_fail} bassMap {r.bass_ok}/{r.bass_fail}",
flush=True,
)
# Summary vs plan
print("\n======== PLAN CHECK (bass和弦分通道八度) ========", flush=True)
with_bass = [r for r in results if r.bass_n > 0]
print(f"Presets tested: {len(results)}; with ch8(bass) NoteOn: {len(with_bass)}", flush=True)
if with_bass:
for r in with_bass:
print(
f" BASS HIT {r.bin_id}.bin[{r.idx}] {r.name}: "
f"bass_n={r.bass_n} map_ok={r.bass_ok} fail={r.bass_fail}",
flush=True,
)
else:
print(" NO preset produced ch8(bass). Plan bass rule NOT verified on-device.", flush=True)
# Aggregate chord rules
pass_ok = sum(r.pass_ok for r in results)
pass_fail = sum(r.pass_fail for r in results)
iii_ok = sum(r.iii_ok for r in results)
iii_fail = sum(r.iii_fail for r in results)
xf_ok = sum(r.xf_ok for r in results)
xf_fail = sum(r.xf_fail for r in results)
bass_ok = sum(r.bass_ok for r in results)
bass_fail = sum(r.bass_fail for r in results)
def gate(name, ok, fail, need=True):
status = "PASS" if fail == 0 and (ok > 0 or not need) else ("FAIL" if fail else "SKIP")
print(f" [{status}] {name}: ok={ok} fail={fail}", flush=True)
return status != "FAIL"
all_ok = True
all_ok &= gate("I/II PASS (keys1-6)", pass_ok, pass_fail)
all_ok &= gate("III+ chord map", iii_ok, iii_fail)
all_ok &= gate("xpose>=#F extra-12", xf_ok, xf_fail)
all_ok &= gate("bass ch8 map", bass_ok, bass_fail, need=False)
if not with_bass:
all_ok = False
print(" [FAIL] plan requires bass on code ch8 — never seen across 1.bin/3.bin samples", flush=True)
# channel histogram
ch_all: Counter = Counter()
for r in results:
ch_all.update(r.channels)
print(f"Channel histogram: {dict(sorted(ch_all.items()))}", flush=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
out = args.out or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
f"bin13_reg_{stamp}.log",
)
with open(out, "w", encoding="utf-8", newline="\n") as f:
f.write(f"# bin1/bin3 plan check {datetime.now().isoformat(timespec='seconds')}\n")
for line in sess.lines:
f.write(line + "\n")
f.write("\n# SUMMARY\n")
for r in results:
f.write(
f"# {r.bin_id}[{r.idx}] {r.name} ch={dict(r.channels)} "
f"bass={r.bass_n} I={r.pass_ok}/{r.pass_fail} "
f"III={r.iii_ok}/{r.iii_fail} xf={r.xf_ok}/{r.xf_fail} "
f"bassMap={r.bass_ok}/{r.bass_fail}\n"
)
print(f"Log: {out}", flush=True)
return 0 if all_ok else 1
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()
if __name__ == "__main__":
raise SystemExit(main())

115
tools/rtt_flash_log_dump.py Normal file
View File

@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Dump K1 persisted W25Q field log via J-Link RTT (`log flash dump` / `log flash scan`)."""
from __future__ import annotations
import argparse
import os
import sys
import time
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rtt_log_dump import ( # noqa: E402
connect_jlink,
find_rtt_control_block,
import_deps,
send_command,
wait_rtt_ready,
)
DUMP_CMD = b"log flash dump\n"
SCAN_CMD = b"log flash scan 65536\n"
def dump_flash_log(out_path: str, device: str, timeout_s: float, cmd: bytes = DUMP_CMD) -> None:
jlink = connect_jlink(device)
lines: list[str] = []
try:
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("SEGGER RTT control block not found in SRAM")
print(f"RTT CB @ 0x{cb:08X}")
jlink.rtt_start(cb)
wait_rtt_ready(jlink)
for _ in range(5):
stale = jlink.rtt_read(0, 4096)
if stale:
text = bytes(stale).decode("utf-8", errors="replace")
if text.strip():
print("RTT0 stale:", text.strip()[:200])
time.sleep(0.05)
print(f"Sending: {cmd!r}")
send_command(jlink, cmd)
buffer = b""
deadline = time.time() + timeout_s
while time.time() < deadline:
chunk = jlink.rtt_read(0, 8192)
if chunk:
buffer += bytes(chunk)
if len(buffer) >= 4096 and (len(buffer) % 8192) < 300:
print(f" received {len(buffer)} bytes...")
if b"LOG_FLASH_DUMP_END" in buffer:
break
else:
time.sleep(0.01)
if b"LOG_FLASH_DUMP_END" not in buffer:
preview = buffer[-500:].decode("utf-8", errors="replace") if buffer else ""
raise SystemExit(
"Timeout waiting for LOG_FLASH_DUMP_END.\n"
f"got {len(buffer)} bytes. tail={preview!r}"
)
text = buffer.decode("utf-8", errors="replace")
capture = False
for line in text.splitlines():
if line.strip() == "LOG_FLASH_DUMP_BEGIN":
capture = True
continue
if line.strip() == "LOG_FLASH_DUMP_END":
break
if capture:
lines.append(line)
header = (
f"# K1 flash log dump {datetime.now().isoformat(timespec='seconds')}\n"
f"# device={device}\n"
f"# cmd={cmd.decode('ascii', errors='replace').strip()}\n"
)
body = "\n".join(lines) + ("\n" if lines else "")
with open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(header)
f.write(body)
print(f"Saved {len(lines)} lines to {out_path}")
print(f"Full path: {os.path.abspath(out_path)}")
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Dump K1 Flash field log via RTT")
parser.add_argument("--out", default="crash.log")
parser.add_argument("--device", default="AT32F403AC")
parser.add_argument("--timeout", type=float, default=300.0)
parser.add_argument(
"--scan",
action="store_true",
help="Ignore write pointer; scan first 64KB data for recoverable lines",
)
args = parser.parse_args()
import_deps()
cmd = SCAN_CMD if args.scan else DUMP_CMD
dump_flash_log(args.out, args.device, args.timeout, cmd=cmd)
if __name__ == "__main__":
main()

137
tools/rtt_mic_reg_test.py Normal file
View File

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Drive K1 via RTT while recording laptop mic; compare I/II vs III+ pitch."""
from __future__ import annotations
import os
import sys
import time
from datetime import datetime
# Reuse helpers from sibling tools
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mic_pitch_analyze import ( # noqa: E402
analyze_audio,
compare_summaries,
print_summary,
record,
save_wav,
summarize,
write_frame_log,
)
from rtt_pitch_reg_test import ( # noqa: E402
RttSession,
connect_jlink,
find_rtt_control_block,
import_deps,
wait_rtt_ready,
)
def main() -> int:
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
hold = 8.0
sr = 16000
device = None # default mic
import_deps()
jlink = connect_jlink("AT32F403AC")
try:
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("RTT CB not found")
print(f"RTT CB @ 0x{cb:08X}", flush=True)
jlink.rtt_start(cb)
wait_rtt_ready(jlink)
sess = RttSession(jlink)
sess.pump(0.3)
sess.cmd("log clear", 0.2)
sess.cmd("tone local", 1.0)
sess.set_xpose(2)
sess.cmd("tone pick", 0.8)
# ---- Take A: I/II ----
print("\n=== TAKE A: I/II (key2) — put guitar near mic ===", flush=True)
sess.set_key(0)
sess.set_key(2)
sess.pump(0.8)
print("MIC record A starting...", flush=True)
audio_a = record(hold, sr, device)
wav_a = os.path.join(out_dir, f"mic_A_I_{stamp}.wav")
save_wav(wav_a, audio_a, sr)
frames_a = analyze_audio(audio_a, sr, fmin=40, fmax=500)
sum_a = summarize(frames_a, label="A:I/II")
print_summary(sum_a)
write_frame_log(os.path.join(out_dir, f"mic_A_I_{stamp}.csv"), frames_a, sum_a)
# ---- Take B: III ----
print("\n=== TAKE B: III (key8) ===", flush=True)
sess.set_key(0)
sess.set_key(8)
sess.pump(0.8)
print("MIC record B starting...", flush=True)
audio_b = record(hold, sr, device)
wav_b = os.path.join(out_dir, f"mic_B_III_{stamp}.wav")
save_wav(wav_b, audio_b, sr)
frames_b = analyze_audio(audio_b, sr, fmin=40, fmax=500)
sum_b = summarize(frames_b, label="B:III")
print_summary(sum_b)
write_frame_log(os.path.join(out_dir, f"mic_B_III_{stamp}.csv"), frames_b, sum_b)
# ---- Take C: VII + xpose #F (extra -12) ----
print("\n=== TAKE C: VII key20 + xpose=#F ===", flush=True)
sess.set_xpose(6)
sess.set_key(0)
sess.set_key(20)
sess.pump(0.8)
print("MIC record C starting...", flush=True)
audio_c = record(hold, sr, device)
wav_c = os.path.join(out_dir, f"mic_C_VII_xf_{stamp}.wav")
save_wav(wav_c, audio_c, sr)
frames_c = analyze_audio(audio_c, sr, fmin=40, fmax=500)
sum_c = summarize(frames_c, label="C:VII+#F")
print_summary(sum_c)
write_frame_log(os.path.join(out_dir, f"mic_C_VII_xf_{stamp}.csv"), frames_c, sum_c)
# Also bass-biased analysis (prefer low F0)
print("\n=== Bass-biased (fmax=180Hz) ===", flush=True)
for label, audio in (("A", audio_a), ("B", audio_b), ("C", audio_c)):
fr = analyze_audio(audio, sr, fmin=40, fmax=180)
print_summary(summarize(fr, label=f"{label}-bassband"))
print("\n=== A vs B (I/II vs III) ===", flush=True)
compare_summaries(sum_a, sum_b)
print("\n=== A vs C (I/II vs VII+#F) ===", flush=True)
compare_summaries(sum_a, sum_c)
print("\n=== stop ===", flush=True)
sess.stop_band()
# Save RTT lines
rtt_path = os.path.join(out_dir, f"mic_rtt_{stamp}.log")
with open(rtt_path, "w", encoding="utf-8", newline="\n") as f:
f.write(f"# rtt+mic test {datetime.now().isoformat(timespec='seconds')}\n")
for line in sess.lines:
f.write(line + "\n")
print(f"RTT log: {rtt_path}", flush=True)
print(f"WAV A/B/C: {wav_a}\n {wav_b}\n {wav_c}", flush=True)
return 0
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()
if __name__ == "__main__":
raise SystemExit(main())

594
tools/rtt_pitch_reg_test.py Normal file
View File

@ -0,0 +1,594 @@
#!/usr/bin/env python3
"""K1 bass/chord register auto-test via J-Link RTT.
Requires firmware with:
- [PITCH]/[REG] logs (App_Auto.c)
- RTT cmds: chord key N / chord xpose N / tone pick / log clear
Modes:
python tools/rtt_pitch_reg_test.py # inject + live assert
python tools/rtt_pitch_reg_test.py --listen # you press keys; we assert
python tools/rtt_pitch_reg_test.py --analyze oct_reg3.log
"""
from __future__ import annotations
import argparse
import os
import re
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Iterable
DEFAULT_DEVICES = ("Cortex-M4", "AT32F403AC", "AT32F403A")
MIDI_E1 = 28
MIDI_E2 = 40
MIDI_GSHARP4 = 68
XPOSE_FS = 6
BASS_CH_DISP = 8 # 代码/日志通道BASS_CH=8
DRUM_CH_DISP = 9
PITCH_RE = re.compile(
r"\[PITCH\]\s+(on|off)\s+ch(\d+)\((\w+)\)\s+"
r"(\d+)->(\d+)\s+d=(-?\d+)\s+deg=(\d+)\s+chord=(\d+)\s+"
r"xp=(\d+)\s+xf=(-?\d+)\s+fl=0x([0-9A-Fa-f]+)\s*(.*)$"
)
REG_RE = re.compile(
r"\[REG\s*\]\s+fp\s+(\d+)->(\d+)\s+chord=(\d+)\s+deg=(\d+)\s+xp=(\d+)\s+xf=(-?\d+)"
)
@dataclass
class PitchEvent:
is_on: bool
ch: int
role: str
key_in: int
key_out: int
delta: int
deg: int
chord: int
xp: int
xf: int
flags: int
tags: str
raw: str
@dataclass
class CheckResult:
ok: int = 0
fail: int = 0
skip: int = 0
messages: list[str] = field(default_factory=list)
def add_ok(self, msg: str) -> None:
self.ok += 1
self.messages.append(f"OK {msg}")
def add_fail(self, msg: str) -> None:
self.fail += 1
self.messages.append(f"FAIL {msg}")
def add_skip(self, msg: str) -> None:
self.skip += 1
self.messages.append(f"SKIP {msg}")
def import_deps() -> None:
try:
import pylink # noqa: F401
except ImportError as exc:
raise SystemExit("Missing dependency: pip install pylink-square") from exc
def connect_jlink(device: str):
import pylink
jlink = pylink.JLink()
jlink.open()
try:
jlink.exec_command("HideDeviceSelection = 1")
except Exception:
pass
jlink.set_tif(pylink.enums.JLinkInterfaces.SWD)
last_error = None
for candidate in (device, *DEFAULT_DEVICES):
try:
try:
jlink.exec_command(f"Device = {candidate}")
except Exception:
pass
jlink.connect(candidate)
print(f"Connected as {candidate}", flush=True)
return jlink
except pylink.errors.JLinkException as exc:
last_error = exc
jlink.close()
raise SystemExit(f"Failed to connect J-Link: {last_error}")
def find_rtt_control_block(jlink, ram_base: int = 0x20000000, ram_size: int = 0x18000) -> int | None:
needle = b"SEGGER RTT"
chunk = 0x1000
was_halted = jlink.halted()
if not was_halted:
jlink.halt()
try:
for off in range(0, ram_size, chunk):
data = bytes(jlink.memory_read8(ram_base + off, min(chunk, ram_size - off)))
idx = data.find(needle)
if idx >= 0:
return ram_base + off + idx
finally:
if not was_halted:
if hasattr(jlink, "restart"):
jlink.restart()
else:
jlink.go()
time.sleep(0.1)
return None
def wait_rtt_ready(jlink, timeout_s: float = 15.0) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
try:
if jlink.rtt_get_num_up_buffers() > 0:
return
except Exception:
pass
time.sleep(0.2)
raise SystemExit("RTT control block found but buffers not ready")
def send_command(jlink, payload: bytes, retries: int = 30) -> None:
if isinstance(payload, str):
payload = payload.encode("ascii")
if not payload.endswith(b"\n"):
payload += b"\n"
for _ in range(retries):
wrote = jlink.rtt_write(0, list(payload))
if wrote > 0:
print(f">> {payload.decode('ascii', errors='replace').strip()}", flush=True)
return
time.sleep(0.2)
raise SystemExit(f"Failed to send RTT cmd: {payload!r}")
def drain_rtt(jlink, seconds: float = 0.15) -> str:
buf = b""
deadline = time.time() + seconds
while time.time() < deadline:
chunk = jlink.rtt_read(0, 4096)
if chunk:
buf += bytes(chunk)
else:
time.sleep(0.01)
return buf.decode("utf-8", errors="replace")
def parse_pitch_line(line: str) -> PitchEvent | None:
m = PITCH_RE.search(line)
if not m:
return None
return PitchEvent(
is_on=m.group(1) == "on",
ch=int(m.group(2)),
role=m.group(3),
key_in=int(m.group(4)),
key_out=int(m.group(5)),
delta=int(m.group(6)),
deg=int(m.group(7)),
chord=int(m.group(8)),
xp=int(m.group(9)),
xf=int(m.group(10)),
flags=int(m.group(11), 16),
tags=m.group(12).strip(),
raw=line.strip(),
)
def chord_degree(chord: int) -> int:
if chord < 1 or chord > 21:
return 0
return (chord - 1) // 3 + 1
def need_register_fix(chord: int) -> bool:
return 7 <= chord <= 21
def map_bass(key: int, xp: int) -> tuple[int, int]:
"""Return (out, flags) mirroring App_Auto_MapBassNote."""
out = key - 12
flags = 0x01
while out < MIDI_E1:
out += 12
flags |= 0x02
if xp >= XPOSE_FS:
out -= 12
flags |= 0x04
while out < MIDI_E1:
out += 12
flags |= 0x02
return max(0, min(127, out)), flags
def map_chord(key: int, xp: int) -> tuple[int, int, bool]:
"""Return (out_or_clamp_hint, flags, need_near).
When need_near is True, exact out is unknown without chord table;
caller should only require out in [E2, #G4].
"""
out = key - 12
flags = 0x01
while out < MIDI_E2:
out += 12
flags |= 0x08
need_near = out > MIDI_GSHARP4
if need_near:
flags |= 0x10
out = MIDI_GSHARP4 # placeholder; exact nearest checked soft
if xp >= XPOSE_FS:
out -= 12
flags |= 0x04
while out < MIDI_E2:
out += 12
flags |= 0x08
if out > MIDI_GSHARP4:
flags |= 0x10
need_near = True
out = MIDI_GSHARP4
return max(0, min(127, out)), flags, need_near
def expect_for_event(ev: PitchEvent) -> tuple[str, bool]:
"""Return (detail, ok)."""
if not ev.is_on:
return "note-off ignored", True
if ev.delta != ev.key_out - ev.key_in:
return f"d mismatch {ev.delta} != {ev.key_out - ev.key_in}", False
deg_exp = chord_degree(ev.chord)
if deg_exp and ev.deg != deg_exp:
return f"deg {ev.deg} != expected {deg_exp}", False
xf_exp = 1 if ev.xp >= XPOSE_FS else 0
if ev.xf != xf_exp:
return f"xf {ev.xf} != expected {xf_exp} (xp={ev.xp})", False
if ev.ch == DRUM_CH_DISP or ev.role == "drum":
return "drum should not appear in PITCH", False
fix = need_register_fix(ev.chord)
if not fix:
if ev.key_out != ev.key_in or ev.delta != 0:
return f"I/II should PASS {ev.key_in}->{ev.key_in}, got {ev.key_out}", False
if "PASS" not in ev.tags and ev.flags != 0:
# flags may be 0 on pass path
pass
return f"PASS I/II {ev.key_in}", True
if ev.ch == BASS_CH_DISP or ev.role == "bass":
exp, exp_fl = map_bass(ev.key_in, ev.xp)
if ev.key_out != exp:
return f"bass expect {ev.key_in}->{exp}, got {ev.key_out}", False
if ev.key_out < MIDI_E1:
return f"bass below E1: {ev.key_out}", False
# flag bits that must be present
if (exp_fl & 0x01) and not (ev.flags & 0x01):
return f"bass missing -12 flag fl=0x{ev.flags:02X}", False
return f"bass {ev.key_in}->{ev.key_out}", True
# chord path
exp, exp_fl, need_near = map_chord(ev.key_in, ev.xp)
if not (MIDI_E2 <= ev.key_out <= MIDI_GSHARP4):
return f"chord out {ev.key_out} not in [{MIDI_E2},{MIDI_GSHARP4}]", False
if need_near:
if not (ev.flags & 0x10) and "NEAR" not in ev.tags:
# soft: range ok is enough if nearest not flagged but clamped somehow
return f"chord NEAR expected (in={ev.key_in} out={ev.key_out})", True
return f"chord NEAR {ev.key_in}->{ev.key_out}", True
if ev.key_out != exp:
return f"chord expect {ev.key_in}->{exp}, got {ev.key_out}", False
return f"chord {ev.key_in}->{ev.key_out}", True
def analyze_lines(lines: Iterable[str], result: CheckResult | None = None) -> CheckResult:
result = result or CheckResult()
pitch_n = 0
roles: set[str] = set()
chords: set[int] = set()
for line in lines:
line = line.rstrip("\n")
if "[REG" in line and "fp " in line:
m = REG_RE.search(line)
if m:
chord = int(m.group(3))
deg = int(m.group(4))
xp = int(m.group(5))
xf = int(m.group(6))
deg_exp = chord_degree(chord)
xf_exp = 1 if xp >= XPOSE_FS else 0
if deg_exp and deg != deg_exp:
result.add_fail(f"REG deg {deg}!={deg_exp} chord={chord}")
elif xf != xf_exp:
result.add_fail(f"REG xf {xf}!={xf_exp} xp={xp}")
else:
result.add_ok(f"REG fp chord={chord} deg={deg} xp={xp} xf={xf}")
ev = parse_pitch_line(line)
if not ev:
continue
pitch_n += 1
roles.add(ev.role)
chords.add(ev.chord)
detail, ok = expect_for_event(ev)
msg = f"ch{ev.ch}({ev.role}) chord={ev.chord} {ev.key_in}->{ev.key_out} | {detail}"
if ok:
result.add_ok(msg)
else:
result.add_fail(msg)
if pitch_n == 0:
result.add_skip("no [PITCH] lines found")
else:
if "bass" not in roles and not any(
f"ch{BASS_CH_DISP}(" in m for m in result.messages
):
result.add_skip(f"no bass PITCH on ch{BASS_CH_DISP} (style may omit bass, or old FW)")
if not any(need_register_fix(c) for c in chords):
result.add_skip("no III+ chord keys observed")
if not any(not need_register_fix(c) and 1 <= c <= 6 for c in chords):
result.add_skip("no I/II chord keys observed")
return result
def print_report(result: CheckResult, out_path: str | None = None) -> int:
print("\n======== PITCH REG TEST REPORT ========", flush=True)
for msg in result.messages:
# only print fails + summary skips loudly; OK compacted
if msg.startswith("FAIL") or msg.startswith("SKIP"):
print(msg, flush=True)
ok_short = [m for m in result.messages if m.startswith("OK")]
print(f"OK={result.ok} FAIL={result.fail} SKIP={result.skip}", flush=True)
if ok_short:
print(f"(first OK samples: {len(ok_short)} total)", flush=True)
for m in ok_short[:8]:
print(m, flush=True)
if len(ok_short) > 8:
print(f"... +{len(ok_short) - 8} more OK", flush=True)
if out_path:
with open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(f"# pitch reg report {datetime.now().isoformat(timespec='seconds')}\n")
f.write(f"OK={result.ok} FAIL={result.fail} SKIP={result.skip}\n")
for m in result.messages:
f.write(m + "\n")
print(f"Report saved: {os.path.abspath(out_path)}", flush=True)
return 0 if result.fail == 0 and result.ok > 0 else 1
class RttSession:
def __init__(self, jlink):
self.jlink = jlink
self.buf = b""
self.lines: list[str] = []
def cmd(self, text: str, settle: float = 0.2) -> None:
send_command(self.jlink, text.encode("ascii"))
self.pump(settle)
def pump(self, seconds: float) -> list[str]:
new_lines: list[str] = []
deadline = time.time() + seconds
while time.time() < deadline:
chunk = self.jlink.rtt_read(0, 4096)
if chunk:
self.buf += bytes(chunk)
while b"\n" in self.buf:
raw, self.buf = self.buf.split(b"\n", 1)
line = raw.decode("utf-8", errors="replace").rstrip("\r")
if line:
self.lines.append(line)
new_lines.append(line)
if (
"[PITCH]" in line
or "[REG" in line
or "[KEY" in line
or "CHORD_" in line
or "TONE_" in line
or "TM1617_" in line
or "ADC_KEY_" in line
or "LOG_STATUS" in line
or "TAP inject" in line
or "UI_" in line
):
print(line, flush=True)
else:
time.sleep(0.01)
return new_lines
def cmd_ack(self, text: str, ack: str, timeout_s: float = 3.0, retries: int = 6) -> bool:
"""Send command until ack substring appears (handles RTT down loss under load)."""
for attempt in range(1, retries + 1):
# brief quiet drain then send
self.pump(0.15)
send_command(self.jlink, text.encode("ascii"))
deadline = time.time() + timeout_s
while time.time() < deadline:
for line in self.pump(0.1):
if ack in line:
return True
print(f"!! no ack '{ack}' for '{text}' (try {attempt}/{retries})", flush=True)
time.sleep(0.25)
return False
def stop_band(self) -> None:
self.cmd_ack("chord key 23", "CHORD_KEY_OK", timeout_s=2.0, retries=4)
self.pump(0.4)
def set_xpose(self, xp: int) -> bool:
return self.cmd_ack(f"chord xpose {xp}", "CHORD_XPOSE_OK", timeout_s=2.0, retries=5)
def set_key(self, key: int) -> bool:
return self.cmd_ack(f"chord key {key}", "CHORD_KEY_OK", timeout_s=2.0, retries=5)
def phase_switch(self, name: str, key: int, xpose: int | None, hold_s: float) -> None:
"""Switch chord/xpose while accompaniment is already running.
Note: plain `tone pick` clears KEY_ID to 1 do not call it after inject.
"""
print(f"\n=== {name}: key={key} xpose={xpose} ===", flush=True)
if xpose is not None:
if not self.set_xpose(xpose):
print(f"FAIL setup xpose={xpose}", flush=True)
self.set_key(0)
if not self.set_key(key):
print(f"FAIL setup key={key}", flush=True)
self.pump(hold_s)
def run_auto(device: str, hold_s: float, report: str | None, log_path: str | None) -> int:
import_deps()
jlink = connect_jlink(device)
try:
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("SEGGER RTT CB not found")
print(f"RTT CB @ 0x{cb:08X}", flush=True)
jlink.rtt_start(cb)
wait_rtt_ready(jlink)
sess = RttSession(jlink)
sess.pump(0.3)
sess.cmd("log clear", 0.3)
sess.cmd("tone local", 1.0)
# Start band first (pick forces chord=1), then inject keys while playing
if not sess.set_xpose(2):
print("FAIL initial xpose=2", flush=True)
sess.cmd("tone pick", settle=0.8)
print("\n=== I (default after pick) ===", flush=True)
sess.pump(hold_s)
sess.phase_switch("I/II key2 PASS", key=2, xpose=None, hold_s=hold_s)
sess.phase_switch("III key8 -12", key=8, xpose=None, hold_s=hold_s)
sess.phase_switch("VII key20 -12", key=20, xpose=None, hold_s=hold_s)
sess.phase_switch("VII key20 xpose=#F", key=20, xpose=6, hold_s=hold_s)
print("\n=== stop ===", flush=True)
sess.stop_band()
sess.pump(0.4)
if log_path:
with open(log_path, "w", encoding="utf-8", newline="\n") as f:
f.write(f"# auto pitch test {datetime.now().isoformat(timespec='seconds')}\n")
for line in sess.lines:
f.write(line + "\n")
print(f"Log saved: {os.path.abspath(log_path)}", flush=True)
joined = "\n".join(sess.lines)
if "CHORD_KEY_OK" not in joined and "CHORD_KEY_BAD" not in joined:
print(
"WARNING: no CHORD_KEY_OK — firmware may lack 'chord key' RTT cmd. "
"Rebuild/flash, or use --listen / --analyze.",
flush=True,
)
result = analyze_lines(sess.lines)
has_pass = any("PASS I/II" in m for m in result.messages)
has_iii_raw = any(
"[PITCH]" in line and ("chord=8" in line or "chord=20" in line or "deg=3" in line or "deg=7" in line)
for line in sess.lines
)
has_iii_ok = any(
m.startswith("OK") and "PASS" not in m and "(chord)" in m
for m in result.messages
)
has_xf = any("[PITCH]" in line and "xf=1" in line and "deg=7" in line for line in sess.lines)
if not has_pass:
result.add_fail("coverage: no I/II PASS samples")
if not has_iii_raw:
result.add_fail("coverage: no III+/VII chord in PITCH")
elif not has_iii_ok:
result.add_fail("coverage: III+ present but mapping asserts failed")
if not has_xf:
result.add_skip("coverage: no VII+xf=1 samples")
return print_report(result, report)
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()
def run_listen(device: str, seconds: float, report: str | None, log_path: str | None) -> int:
import_deps()
jlink = connect_jlink(device)
try:
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("SEGGER RTT CB not found")
print(f"RTT CB @ 0x{cb:08X}", flush=True)
jlink.rtt_start(cb)
wait_rtt_ready(jlink)
sess = RttSession(jlink)
sess.pump(0.2)
sess.cmd("log clear", 0.2)
print(
f"Listening {seconds:.0f}s — press I/II then III+ chords; set transpose>=#F if possible.",
flush=True,
)
sess.pump(seconds)
if log_path:
with open(log_path, "w", encoding="utf-8", newline="\n") as f:
f.write(f"# listen pitch test {datetime.now().isoformat(timespec='seconds')}\n")
for line in sess.lines:
f.write(line + "\n")
print(f"Log saved: {os.path.abspath(log_path)}", flush=True)
result = analyze_lines(sess.lines)
return print_report(result, report)
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()
def run_analyze(path: str, report: str | None) -> int:
with open(path, encoding="utf-8", errors="replace") as f:
lines = f.readlines()
result = analyze_lines(lines)
return print_report(result, report)
def main() -> None:
parser = argparse.ArgumentParser(description="K1 pitch/register RTT auto-test")
parser.add_argument("--device", default="AT32F403AC")
parser.add_argument("--hold", type=float, default=2.5, help="Seconds to hold each injected key")
parser.add_argument("--seconds", type=float, default=45.0, help="Listen duration")
parser.add_argument("--listen", action="store_true", help="Do not inject; monitor only")
parser.add_argument("--analyze", metavar="LOG", help="Offline analyze a dump log")
parser.add_argument("--out-log", default="", help="Save captured RTT lines")
parser.add_argument("--report", default="", help="Save pass/fail report")
args = parser.parse_args()
report = args.report or None
log_path = args.out_log or None
if args.analyze:
raise SystemExit(run_analyze(args.analyze, report))
if args.listen:
raise SystemExit(run_listen(args.device, args.seconds, report, log_path))
raise SystemExit(run_auto(args.device, args.hold, report, log_path))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,447 @@
# -*- coding: utf-8 -*-
"""
test_protocol_app_sim.py 模拟手机 App 对吉他固件做全协议串口测试
协议来源: Doc/指令测试.docx (= 指令测试0907.docx)
通道: UART4 (BLE ), 115200 8N1; 帧格式 F0 60 <cmd1> <cmd2> [data...] F7
设备主动上报为 F0 51 ... F7 (测试时跳过, 不计为响应)
用法:
python test_protocol_app_sim.py --port COM5 # 跑全部安全用例
python test_protocol_app_sim.py --port COM5 --allow-poweroff# 含 05 00 关机用例
python test_protocol_app_sim.py --selftest # 无硬件自检(帧编解码)
依赖: pyserial (pip install pyserial)
退出码: 0 = 全部通过, 1 = FAIL
"""
import argparse
import sys
import time
try:
import serial
except ImportError:
serial = None # --selftest 不需要 pyserial
HEAD, TAIL, APP_ID, DEV_ID = 0xF0, 0xF7, 0x60, 0x51
# ---------------------------------------------------------------- frame codec
def build(*payload):
"""App -> 设备: F0 60 <payload...> F7"""
return bytes([HEAD, APP_ID, *payload, TAIL])
class FrameParser:
"""字节流 -> 完整帧; 只收 F0...F7, 其余丢弃"""
def __init__(self):
self.buf = bytearray()
def feed(self, data: bytes):
"""返回本轮解析出的完整帧列表"""
out = []
for b in data:
if not self.buf:
if b == HEAD:
self.buf.append(b)
continue
# BLE-MIDI 粘包时可能再次出现 F0重新同步避免 F0 F0 60…
if b == HEAD:
self.buf = bytearray([HEAD])
continue
self.buf.append(b)
if b == TAIL or len(self.buf) >= 64:
if b == TAIL and len(self.buf) >= 4:
out.append(bytes(self.buf))
self.buf.clear()
return out
def hexs(b):
return " ".join(f"{x:02X}" for x in b) if b else "(none)"
# ---------------------------------------------------------------- serial wrap
class AppSim:
def __init__(self, port, baud=115200, timeout=0.15):
self.ser = serial.Serial(port, baud, bytesize=8, parity="N",
stopbits=1, timeout=timeout)
self.parser = FrameParser()
self.pending = []
def close(self):
self.ser.close()
def _pump(self):
data = self.ser.read(256)
if data:
self.pending.extend(self.parser.feed(data))
def send(self, frame: bytes):
self.ser.write(frame)
self.ser.flush()
def read_frame(self, timeout=1.0, want_dev=False):
"""读一帧; 默认跳过设备主动上报(0x51), want_dev=True 时只要 0x51"""
deadline = time.monotonic() + timeout
while True:
self._pump()
for i, f in enumerate(self.pending):
is_dev = len(f) > 1 and f[1] == DEV_ID
if is_dev == want_dev:
return self.pending.pop(i)
self.pending.clear() # 丢掉方向不符的帧
if time.monotonic() >= deadline:
return None
time.sleep(0.01)
def drain(self, quiet=0.2):
while self.read_frame(timeout=quiet) is not None:
pass
def query(self, frame: bytes, timeout=1.0):
self.drain()
self.send(frame)
return self.read_frame(timeout=timeout)
# ---------------------------------------------------------------- test engine
class Results:
def __init__(self):
self.rows = []
def add(self, name, status, detail=""):
self.rows.append((name, status, detail))
print(f"[{status:>4}] {name}" + (f" | {detail}" if detail else ""))
def summary(self):
n = {s: sum(1 for r in self.rows if r[1] == s)
for s in ("PASS", "FAIL", "SKIP", "SENT")}
print("\n===== 汇总 =====")
for k in ("PASS", "FAIL", "SKIP", "SENT"):
print(f"{k:>5}: {n[k]}")
for name, status, detail in self.rows:
if status == "FAIL":
print(f" FAIL: {name} | {detail}")
return 1 if n["FAIL"] else 0
def expect(resp, prefix, length=None):
"""校验响应帧头/命令字/长度"""
if resp is None:
return False, "timeout, no reply"
if len(resp) < 5 or resp[0] != HEAD or resp[-1] != TAIL:
return False, f"bad frame: {hexs(resp)}"
if tuple(resp[1:1 + len(prefix)]) != tuple(prefix):
return False, f"prefix mismatch: {hexs(resp)}"
if length is not None and len(resp) != length:
return False, f"len {len(resp)} != {length}: {hexs(resp)}"
return True, hexs(resp)
def fmt_ver4(payload: bytes, *, kind: str = "semver") -> str:
"""解码版本字节为可读串。
kind:
semver 3 字节 major.minor.patch01 03/0C
date YY.M.D 日期01 04 音源 / 01 05 UI并展开为 20YY-MM-DD
"""
if len(payload) != 4:
return f"ver_raw={payload!r}"
a, b, c, _d = payload
if kind == "date":
return f"ver={a}.{b}.{c} date=20{a:02d}-{b:02d}-{c:02d}"
return f"ver={a}.{b}.{c}"
def run_tests(sim, allow_poweroff=False):
R = Results()
# ---------------- 1. 设备信息 0x01 ----------------
r = sim.query(build(0x01, 0x01))
ok, d = expect(r, (0x60, 0x01, 0x01), 6)
R.add("01 01 连接设备", "PASS" if ok else "FAIL", d)
r = sim.query(build(0x01, 0x02))
ok, d = expect(r, (0x60, 0x01, 0x02))
if ok:
name = bytes(r[4:-1])
ok = all(0x20 <= c < 0x7F for c in name)
d += f" name={name!r}"
R.add("01 02 设备名", "PASS" if ok else "FAIL", d)
for sub, name, kind in (
(0x03, "固件主版本", "semver"),
(0x04, "音源版本", "date"),
(0x05, "UI版本", "date"),
):
r = sim.query(build(0x01, sub))
ok, d = expect(r, (0x60, 0x01, sub), 9)
if ok:
d += f" {fmt_ver4(bytes(r[4:-1]), kind=kind)}"
R.add(f"01 {sub:02X} {name}", "PASS" if ok else "FAIL", d)
# 01 0C{branch}_{short6}[optional '*'],如 develop_0aedb4
r = sim.query(build(0x01, 0x0C))
ok, d = expect(r, (0x60, 0x01, 0x0C))
if ok:
raw = bytes(r[4:-1])
ok = (
3 <= len(raw) <= 25
and all(0x20 <= c < 0x7F for c in raw)
and b"_" in raw.rstrip(b"*")
)
build_s = raw.decode("ascii", "replace")
dirty = build_s.endswith("*")
core = build_s[:-1] if dirty else build_s
branch, _, short = core.rpartition("_")
extra = f" build={build_s!r}"
if len(short) == 6 and all(c in "0123456789abcdef" for c in short.lower()):
extra += f" branch={branch!r} commit={short.lower()}"
if dirty:
extra += " dirty=1"
d += extra
R.add("01 0C 用户固件版本", "PASS" if ok else "FAIL", d)
r = sim.query(build(0x01, 0x06))
ok, d = expect(r, (0x60, 0x01, 0x06), 13)
R.add("01 06 其他信息", "PASS" if ok else "FAIL", d)
r = sim.query(build(0x01, 0x07))
ok, d = expect(r, (0x60, 0x01, 0x07), 29) # 12B UID → 24 hex ASCII
if ok:
hx = bytes(r[4:-1])
ok = len(hx) == 24 and all(c in b"0123456789ABCDEF" for c in hx)
d += f" uid_hex={hx.decode('ascii', 'replace')}"
R.add("01 07 设备编码(UID)", "PASS" if ok else "FAIL", d)
# 01 0FBLE-MIDI 安全子命令(原 01 FF 的 0xFF 非法出现在 SysEx 数据中)
r = sim.query(build(0x01, 0x0F))
ok, d = expect(r, (0x60, 0x01, 0x0F))
if ok:
code = bytes(r[4:-1])
ok = len(code) > 0 and all(0x20 <= c < 0x7F for c in code)
d += f" code={code!r}"
R.add("01 0F 固件编码", "PASS" if ok else "FAIL", d)
# 自动关机: 读 -> 设15 -> 读验证 -> 恢复原值
r0 = sim.query(build(0x01, 0x0A))
ok, d = expect(r0, (0x60, 0x01, 0x0A), 6)
R.add("01 0A 自动关机(读)", "PASS" if ok else "FAIL", d)
if ok:
orig = r0[4]
r = sim.query(build(0x01, 0x11, 15))
ok, d = expect(r, (0x60, 0x01, 0x11, 15), 6)
R.add("01 11 自动关机(设15)", "PASS" if ok else "FAIL", d)
r = sim.query(build(0x01, 0x0A))
ok, d = expect(r, (0x60, 0x01, 0x0A, 15), 6)
R.add("01 0A 回读=15", "PASS" if ok else "FAIL", d)
sim.query(build(0x01, 0x11, orig)) # restore
r = sim.query(build(0x01, 0x00))
ok, d = expect(r, (0x60, 0x01, 0x00), 6)
R.add("01 00 断开设备", "PASS" if ok else "FAIL", d)
sim.query(build(0x01, 0x01)) # 重新连接, 便于后续用例
# ---------------- 2. 和弦映射 0x02 ----------------
r = sim.query(build(0x02, 0x01))
ok, d = expect(r, (0x60, 0x02, 0x01), 47)
R.add("02 01 读和弦映射表", "PASS" if ok else "FAIL", d)
cur_map = bytes(r[4:-1]) if ok else None # 21B pitch + 21B chord
if cur_map:
key = 1
orig_pitch, orig_chord = cur_map[key - 1], cur_map[21 + key - 1]
sim.send(build(0x02, 0x02, key - 1, 0x02)) # pitch: 升位
time.sleep(0.1)
r = sim.query(build(0x02, 0x01))
ok = r is not None and len(r) == 47 and r[3] == 0x01 and r[4 + key - 1] == 0x02
R.add("02 02 Pitch偏移(写+读回)", "PASS" if ok else "FAIL", hexs(r))
sim.send(build(0x02, 0x02, key - 1, orig_pitch)) # restore
sim.send(build(0x02, 0x03, key - 1, 0x08)) # chord: 小三
time.sleep(0.1)
r = sim.query(build(0x02, 0x01))
ok = r is not None and len(r) == 47 and r[4 + 21 + key - 1] == 0x08
R.add("02 03 Chord偏移(写+读回)", "PASS" if ok else "FAIL", hexs(r))
sim.send(build(0x02, 0x03, key - 1, orig_chord)) # restore
time.sleep(0.1)
# 02 04 整表写入(回写当前表) -> 设备应回 02 01 格式整表
r = sim.query(build(0x02, 0x04, *cur_map), timeout=2.0)
ok, d = expect(r, (0x60, 0x02, 0x01), 47)
R.add("02 04 整表写入(回读帧)", "PASS" if ok else "FAIL", d)
r2 = sim.query(build(0x02, 0x01))
ok = r2 is not None and bytes(r2[4:-1]) == cur_map
R.add("02 04 写入后整表一致", "PASS" if ok else "FAIL", hexs(r2))
else:
for n in ("02 02 Pitch偏移", "02 03 Chord偏移", "02 04 整表写入"):
R.add(n, "SKIP", "读映射表失败, 级联跳过")
# ---------------- 3. 吉他参数 0x03 ----------------
r = sim.query(build(0x03, 0x04, 0x00))
ok, d = expect(r, (0x60, 0x03, 0x04, 0x00), 9)
R.add("03 04 读当前节奏风格", "PASS" if ok else "FAIL", d)
saved_style = bytes(r[5:8]) if ok else None
# 开机特例: F0 60 03 04 01 00 00 00 F7 -> F0 60 03 04 01 <bpm> F7 + F0 51 03 <hi> <lo> F7
r = sim.query(build(0x03, 0x04, 0x01, 0x00, 0x00, 0x00))
ok, d = expect(r, (0x60, 0x03, 0x04, 0x01), 7)
R.add("03 04 开机特例(读风格#0)", "PASS" if ok else "FAIL", d)
r51 = sim.read_frame(timeout=1.0, want_dev=True)
ok51 = (r51 is not None and len(r51) == 6
and tuple(r51[:3]) == (HEAD, DEV_ID, 0x03))
R.add("03 04 开机特例(F0 51 BPM上报)",
"PASS" if ok51 else "FAIL", hexs(r51))
r = sim.query(build(0x03, 0x04, 0x01, 0x01, 0x00, 0x00))
ok, d = expect(r, (0x60, 0x03, 0x04, 0x01), 7)
R.add("03 04 设用户风格#0", "PASS" if ok else "FAIL", d)
# 用户风格分页: 回复 F0 60 03 04 02 <cntHi> <cntLo> [<idHi> <idLo> <code4B> ...] F7
r = sim.query(build(0x03, 0x04, 0x02, 0x00, 0x00, 0x00, 0x07))
ok, d = expect(r, (0x60, 0x03, 0x04, 0x02))
if ok:
cnt = r[5] * 128 + r[6]
ok = len(r) == 7 + cnt * 6 + 1
d += f" cnt={cnt}"
R.add("03 04 用户风格分页", "PASS" if ok else "FAIL", d)
# 用户风格总数: 回复 F0 60 03 04 00 <total 3B BE> F7
r = sim.query(build(0x03, 0x04, 0x03))
ok, d = expect(r, (0x60, 0x03, 0x04, 0x00), 9)
if ok:
d += f" total={(r[5] << 16) | (r[6] << 8) | r[7]}"
R.add("03 04 用户风格总数", "PASS" if ok else "FAIL", d)
if saved_style:
sim.query(build(0x03, 0x04, 0x01, *saved_style)) # restore style
# 弦音色: 读 -> 写 -> 读 -> 恢复
r0 = sim.query(build(0x03, 0x05, 0x00))
ok, d = expect(r0, (0x60, 0x03, 0x05, 0x00), 8)
R.add("03 05 读弦音色", "PASS" if ok else "FAIL", d)
if ok:
orig = r0[6]
new = (orig + 1) % 5
sim.send(build(0x03, 0x05, 0x01, 0x00, new))
time.sleep(0.1)
r = sim.query(build(0x03, 0x05, 0x00))
ok = r is not None and len(r) == 8 and r[6] == new
R.add("03 05 写弦音色(写+读回)", "PASS" if ok else "FAIL", hexs(r))
sim.send(build(0x03, 0x05, 0x01, 0x00, orig)) # restore
# BPM: 读 -> 写120 -> 读 -> 恢复
r0 = sim.query(build(0x03, 0x06, 0x00))
ok, d = expect(r0, (0x60, 0x03, 0x06, 0x00), 8)
R.add("03 06 读BPM", "PASS" if ok else "FAIL", d)
if ok:
orig = r0[5] * 128 + r0[6]
sim.send(build(0x03, 0x06, 0x01, 120 // 128, 120 % 128))
time.sleep(0.1)
r = sim.query(build(0x03, 0x06, 0x00))
ok = r is not None and len(r) == 8 and (r[5] * 128 + r[6]) == 120
R.add("03 06 写BPM=120(写+读回)", "PASS" if ok else "FAIL", hexs(r))
sim.send(build(0x03, 0x06, 0x01, orig // 128, orig % 128)) # restore
# 移调: 读 -> 写 -> 读 -> 恢复 (doc: F0 60 03 07 00 <val> F7, 7 bytes)
r0 = sim.query(build(0x03, 0x07, 0x00))
ok, d = expect(r0, (0x60, 0x03, 0x07, 0x00), 7)
R.add("03 07 读移调", "PASS" if ok else "FAIL", d)
if ok:
orig = r0[5]
new = (orig + 1) % 12
sim.send(build(0x03, 0x07, 0x01, new))
time.sleep(0.1)
r = sim.query(build(0x03, 0x07, 0x00))
ok = r is not None and len(r) == 7 and r[5] == new
R.add("03 07 写移调(写+读回)", "PASS" if ok else "FAIL", hexs(r))
sim.send(build(0x03, 0x07, 0x01, orig)) # restore
# ---------------- 4. LED / 播放控制 0x04 (无应答, 仅下发) ----------------
for i in range(7):
sim.drain()
sim.send(build(0x04, i, 0x02, 0x02))
r = sim.read_frame(timeout=0.3)
R.add(f"04 0{i} 点亮LED{i + 1}", "SENT" if r is None else "PASS",
"" if r is None else f"unexpected reply {hexs(r)}")
time.sleep(0.05)
sim.drain()
sim.send(build(0x04, 0x07, 0x00, 0x00))
R.add("04 07 End/结束播放", "SENT", "无应答属正常")
# ---------------- 6. 段落跳转 0x06 (无应答, 仅下发) ----------------
for sub, name in ((0x01, "前奏"), (0x02, "间奏"), (0x03, "尾奏"),
(0x05, "A段"), (0x06, "B段"), (0x07, "C段"), (0x08, "D段")):
sim.drain()
sim.send(build(0x06, sub, 0x00))
R.add(f"06 {sub:02X} {name}", "SENT", "无应答属正常")
time.sleep(0.05)
# ---------------- 5. 复位/关机 0x05 ----------------
if allow_poweroff:
time.sleep(0.3)
# 禁止 query 自动重发:第二次会打在已断电的 BT 上
sim.drain()
sim.send(build(0x05, 0x00))
r = sim.read_frame(timeout=3.0)
ok, d = expect(r, (0x60, 0x05, 0x00), 6)
if not ok and r is None and hasattr(sim, "is_connected"):
# ACK 可能被软关机掐断 BT 抢走;链路断开也视为关机已执行
time.sleep(0.5)
if not sim.is_connected():
ok, d = True, "no ACK but BLE link dropped (soft-off likely)"
R.add("05 00 复位/关机", "PASS" if ok else "FAIL", d + " (设备将软关机)")
else:
R.add("05 00 复位/关机", "SKIP", "需 --allow-poweroff")
return R
# ---------------------------------------------------------------- selftest
def selftest():
R = Results()
f = build(0x03, 0x06, 0x01, 0x00, 0x40)
R.add("build frame", "PASS" if f == bytes.fromhex("F060030601004 0F7".replace(" ", "")) else "FAIL", hexs(f))
p = FrameParser()
out = p.feed(bytes.fromhex("AA F0 60 01 01")) + p.feed(bytes.fromhex("01 F7"))
R.add("parser chunked", "PASS" if out == [bytes.fromhex("F0600101 01F7".replace(" ", ""))] else "FAIL",
str([hexs(x) for x in out]))
p = FrameParser()
out = p.feed(bytes.fromhex("F0 51 05 0A F7 F0 60 01 01 01 F7".replace(" ", "")))
R.add("parser two frames", "PASS" if len(out) == 2 else "FAIL", str(len(out)))
return R.summary()
def main():
ap = argparse.ArgumentParser(description="模拟手机App的吉他协议全量测试")
ap.add_argument("--port", help="串口, 如 COM5 (UART4 115200 8N1)")
ap.add_argument("--baud", type=int, default=115200)
ap.add_argument("--allow-poweroff", action="store_true",
help="允许执行 05 00 关机用例")
ap.add_argument("--selftest", action="store_true", help="无硬件自检")
args = ap.parse_args()
if args.selftest:
sys.exit(selftest())
if not args.port:
ap.error("需要 --port (或用 --selftest)")
if serial is None:
sys.exit("缺少 pyserial: pip install pyserial")
sim = AppSim(args.port, args.baud)
print(f"=== 协议测试开始 {args.port}@{args.baud} ===")
try:
code = run_tests(sim, allow_poweroff=args.allow_poweroff).summary()
finally:
sim.close()
sys.exit(code)
if __name__ == "__main__":
main()

502
tools/test_protocol_ble.py Normal file
View File

@ -0,0 +1,502 @@
# -*- coding: utf-8 -*-
"""
test_protocol_ble.py 通过笔记本蓝牙对吉他做全协议测试并出报告
协议来源: Doc/指令测试.docx
链路: 笔记本 BLE <-> "Smart Guitar MIDI" <-> 吉他 UART4
App->设备: F0 60 ... F7 ; 设备主动上报: F0 51 ... F7
GATT:
BLE-MIDI 03B80E5A-... / 7772E5DB-... (framed SysEx)
自定义串口 e49a25f8-... / e49a25e0() + e49a28e1(通知) (raw SysEx)
用法:
python test_protocol_ble.py
python test_protocol_ble.py --transport midi
python test_protocol_ble.py --transport uart
python test_protocol_ble.py --unpair
python test_protocol_ble.py --smoke
python test_protocol_ble.py --selftest
依赖: bleak (pip install bleak)
报告: Doc/reports/ble_sysex_<时间戳>.md
"""
from __future__ import annotations
import argparse
import asyncio
import datetime
import os
import queue
import sys
import threading
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from test_protocol_app_sim import ( # noqa: E402
HEAD, DEV_ID, build, FrameParser, Results, run_tests, hexs)
MIDI_SERVICE = "03B80E5A-EDE8-4B33-A751-6CE34EC4C700"
MIDI_CHAR = "7772E5DB-3868-4112-A1A9-F2669D106BF3"
UART_SERVICE = "e49a25f8-f69a-11e8-8eb2-f2801f1b9fd1"
UART_WRITE = "e49a25e0-f69a-11e8-8eb2-f2801f1b9fd1"
UART_NOTIFY = "e49a28e1-f69a-11e8-8eb2-f2801f1b9fd1"
DEFAULT_NAME = "Smart Guitar MIDI"
DEFAULT_MTU_PAYLOAD = 20
def ble_midi_encode_sysex(frame: bytes, max_payload: int):
ts_hi, ts_lo = 0x80, 0x80
pkts = []
cap = max_payload - 2
first = frame[:cap]
pkts.append(bytes([ts_hi, ts_lo]) + first)
rest = frame[len(first):]
while len(rest) > max_payload - 2:
pkts.append(bytes([ts_hi]) + rest[: max_payload - 1])
rest = rest[max_payload - 1 :]
if rest:
pkts.append(bytes([ts_hi, ts_lo]) + rest)
return pkts
def ble_midi_decode_packet(payload: bytes) -> bytes:
"""Strip BLE-MIDI header/timestamp bytes; keep App SysEx (F0…F7).
- Framed: [header ts][optional ts][F0 ts F7]
- Raw (some ATS2853 notifies): [F0 F7] must not treat F0 as header
(F0/F7 also have bit7=1).
"""
if not payload:
return b""
i = 0
if payload[0] not in (0xF0, 0xF7) and (payload[0] & 0x80):
i = 1 # BLE-MIDI header
out = bytearray()
while i < len(payload):
b = payload[i]
i += 1
if b in (0xF0, 0xF7):
out.append(b)
elif b & 0x80:
continue # timestamp inside/around SysEx
else:
out.append(b)
return bytes(out)
class BleMidiSim:
"""与 AppSim 同接口: send / read_frame / drain / query / close"""
def __init__(
self,
name=DEFAULT_NAME,
address=None,
scan_timeout=20.0,
transport="midi",
unpair=False,
):
from bleak import BleakClient, BleakScanner
self._BleakClient = BleakClient
self._BleakScanner = BleakScanner
self.parser = FrameParser()
self.pending = []
self._rx = queue.Queue()
self._client = None
self._write_char = None
self._notify_chars = []
self._transport = transport
self._unpair = unpair
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
self._thread.start()
self.info = self._run(self._connect(name, address, scan_timeout))
def _run(self, coro, timeout=90.0):
return asyncio.run_coroutine_threadsafe(coro, self._loop).result(timeout)
async def _find(self, name, address, scan_timeout):
if address:
return await self._BleakScanner.find_device_by_address(
address, timeout=scan_timeout
)
print(f"扫描 BLE 设备 ({scan_timeout:.0f}s) ...")
# name filter is more reliable than service UUID filter on Windows
t0 = time.monotonic()
while time.monotonic() - t0 < scan_timeout:
rem = max(1.0, scan_timeout - (time.monotonic() - t0))
d = await self._BleakScanner.find_device_by_filter(
lambda d, a: d.name and name.lower() in d.name.lower(),
timeout=min(8.0, rem),
)
if d:
print(f" 发现: {d.name!r} {d.address}")
return d
print(" ...")
return None
async def _connect(self, name, address, scan_timeout):
dev = await self._find(name, address, scan_timeout)
if dev is None:
raise RuntimeError(f"未找到设备 {name!r}")
if self._unpair:
try:
tmp = self._BleakClient(dev.address, timeout=20)
await tmp.connect()
try:
await tmp.unpair()
print("已 unpair Windows 残留配对")
except Exception as e:
print(f"unpair: {e}")
try:
await tmp.disconnect()
except Exception:
pass
await asyncio.sleep(1.5)
dev = await self._find(name, address, scan_timeout) or dev
except Exception as e:
print(f"unpair session: {e}")
client = self._BleakClient(dev, timeout=30)
await client.connect()
if not client.is_connected:
raise RuntimeError("BLE connect 后立即断开")
write_char = MIDI_CHAR if self._transport == "midi" else UART_WRITE
notify_list = (
[MIDI_CHAR]
if self._transport == "midi"
else [UART_NOTIFY, MIDI_CHAR]
)
max_payload = DEFAULT_MTU_PAYLOAD
for svc in client.services:
for c in svc.characteristics:
if c.uuid.lower() == write_char.lower():
try:
max_payload = c.max_write_without_response_size or max_payload
except Exception:
pass
def _on_notify(_sender, data):
self._rx.put(bytes(data))
for u in notify_list:
try:
await client.start_notify(u, _on_notify)
self._notify_chars.append(u)
except Exception as e:
print(f"notify fail {u[:8]}: {e}")
if not self._notify_chars:
raise RuntimeError("无法开启任何 notify常见原因: Windows 残留配对)")
self._client = client
self._write_char = write_char
self._max_payload = max(DEFAULT_MTU_PAYLOAD, max_payload or DEFAULT_MTU_PAYLOAD)
return {
"name": getattr(dev, "name", None),
"address": getattr(dev, "address", address),
"max_payload": self._max_payload,
"transport": self._transport,
"notify": list(self._notify_chars),
}
async def _disconnect(self):
if self._client is None:
return
for u in self._notify_chars:
try:
await self._client.stop_notify(u)
except Exception:
pass
try:
await self._client.disconnect()
except Exception:
pass
def close(self):
try:
self._run(self._disconnect(), timeout=10)
except Exception:
pass
self._loop.call_soon_threadsafe(self._loop.stop)
self._thread.join(timeout=5)
def _pump(self):
while True:
try:
data = self._rx.get_nowait()
except queue.Empty:
break
if self._transport == "midi":
midi = ble_midi_decode_packet(data)
else:
# uart notify may be raw SysEx, or occasionally BLE-MIDI wrapped
midi = (
ble_midi_decode_packet(data)
if data and (data[0] & 0x80)
else data
)
self.pending.extend(self.parser.feed(midi))
def send(self, frame: bytes):
if self._transport == "midi":
pkts = ble_midi_encode_sysex(frame, self._max_payload)
else:
pkts = [frame]
for pkt in pkts:
self._run(
self._client.write_gatt_char(self._write_char, pkt, response=False),
timeout=10,
)
def read_frame(self, timeout=1.0, want_dev=False):
deadline = time.monotonic() + timeout
while True:
self._pump()
for i, f in enumerate(self.pending):
is_dev = len(f) > 1 and f[1] == DEV_ID
if is_dev == want_dev:
return self.pending.pop(i)
self.pending.clear()
if time.monotonic() >= deadline:
return None
time.sleep(0.01)
def drain(self, quiet=0.2):
while self.read_frame(timeout=quiet) is not None:
pass
def query(self, frame: bytes, timeout=3.0, retry=True):
self.drain(quiet=0.15)
self.send(frame)
r = self.read_frame(timeout=timeout)
if r is None and retry:
# BLE 偶发丢 Notify短间隔重发一次关机指令不可重试
time.sleep(0.25)
self.send(frame)
r = self.read_frame(timeout=timeout)
return r
def is_connected(self) -> bool:
c = self._client
try:
return bool(c is not None and c.is_connected)
except Exception:
return False
async def _reconnect_clean(self, name, address, scan_timeout):
"""Drop session, unpair, rescan, reconnect — recovers Windows stale pairing."""
await self._disconnect()
self._notify_chars = []
self._client = None
await asyncio.sleep(0.8)
self._unpair = True
return await self._connect(name, address, scan_timeout)
def probe_or_rebind(self, name=DEFAULT_NAME, address=None, scan_timeout=20.0):
"""Send 01 01; on timeout, unpair+reconnect once and retry."""
r = self.query(build(0x01, 0x01), timeout=3.0)
if r is not None and len(r) >= 5 and r[0] == HEAD and r[1] == 0x60:
return True
print("首包无应答 → 自动 unpair 并重连 …")
try:
self.info = self._run(
self._reconnect_clean(name, address, scan_timeout), timeout=120.0
)
except Exception as e:
print(f"重连失败: {e}")
return False
r = self.query(build(0x01, 0x01), timeout=3.0)
ok = r is not None and len(r) >= 5 and r[0] == HEAD and r[1] == 0x60
if not ok:
print(
"仍无应答。请 JLink/断电复位吉他E49A 裸写或异常断连后模组常停广播),"
"并确认琴已开机且蓝牙开关为开。"
)
return ok
def write_report(R: Results, info: dict, path: str, notes: list[str] | None = None):
n = {
s: sum(1 for r in R.rows if r[1] == s)
for s in ("PASS", "FAIL", "SKIP", "SENT")
}
lines = [
"# BLE SysEx 协议测试报告",
"",
f"- 日期: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}",
f"- 设备: {info.get('name')} ({info.get('address')})",
f"- 传输: {info.get('transport')} / bleak, payload={info.get('max_payload')}",
f"- notify: {info.get('notify')}",
"- 协议来源: Doc/指令测试.docx",
"- 测试脚本: tools/test_protocol_ble.py",
"",
"## 汇总",
"",
"| PASS | FAIL | SKIP | SENT |",
"|------|------|------|------|",
f"| {n['PASS']} | {n['FAIL']} | {n['SKIP']} | {n['SENT']} |",
"",
"## 明细",
"",
"| # | 用例 | 结果 | 详情 |",
"|---|------|------|------|",
]
for i, (name, status, detail) in enumerate(R.rows, 1):
lines.append(f"| {i} | {name} | {status} | {detail} |")
lines += ["", "## 备注", ""]
for note in notes or []:
lines.append(f"- {note}")
lines += [
"- `01 03`=`Version[]``01 04/05`=日期;`01 0C`=`branch_short6`(如 develop_0aedb4`01 0F`=`BRS08L`。",
"- `01 07` UID 为 24 字符 hex ASCII96-bit保证 SysEx 7-bit 安全。",
"- `04 xx`/`06 xx` 无应答SENT 表示已发送。",
"- `FD 01` 升级指令不在本协议范围。",
]
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
return path
def smoke(sim: BleMidiSim) -> Results:
R = Results()
r = sim.query(build(0x01, 0x01), timeout=3.0)
ok = r is not None and len(r) >= 5 and r[0] == HEAD and r[1] == 0x60
R.add("smoke 01 01 连接设备", "PASS" if ok else "FAIL", hexs(r))
if ok:
r2 = sim.query(build(0x01, 0x02), timeout=3.0)
ok2 = r2 is not None and r2[2:4] == bytes([0x01, 0x02])
R.add("smoke 01 02 设备名", "PASS" if ok2 else "FAIL", hexs(r2))
r3 = sim.query(build(0x02, 0x01), timeout=3.0)
ok3 = r3 is not None and len(r3) == 47
R.add("smoke 02 01 和弦表(长帧)", "PASS" if ok3 else "FAIL", hexs(r3))
return R
def selftest():
R = Results()
pkts = ble_midi_encode_sysex(bytes.fromhex("F0600101F7"), 20)
ok = pkts == [bytes.fromhex("8080F0600101F7")]
R.add("encode short", "PASS" if ok else "FAIL", str([hexs(p) for p in pkts]))
frame = bytes([0xF0, 0x60, 0x02, 0x01]) + bytes(range(1, 43)) + bytes([0xF7])
pkts = ble_midi_encode_sysex(frame, 20)
back = b"".join(ble_midi_decode_packet(p) for p in pkts)
R.add(
"encode/decode 47B",
"PASS" if back == frame and len(pkts) >= 3 else "FAIL",
f"{len(pkts)} pkts",
)
return R.summary()
def main():
ap = argparse.ArgumentParser(description="BLE 吉他协议全量测试")
ap.add_argument("--ble-name", default=DEFAULT_NAME)
ap.add_argument("--ble-address")
ap.add_argument(
"--transport",
choices=("midi", "uart"),
default="midi",
help="midi=标准 BLE-MIDI 帧; uart=自定义 e49a 原始 SysEx裸写易弄挂模组",
)
ap.add_argument(
"--unpair",
action=argparse.BooleanOptionalAction,
default=(sys.platform == "win32"),
help="连接前 unpairWindows 默认开;--no-unpair 可关)",
)
ap.add_argument("--allow-poweroff", action="store_true")
ap.add_argument("--smoke", action="store_true", help="仅冒烟: 01 01/02 + 02 01")
ap.add_argument("--selftest", action="store_true")
ap.add_argument("--report")
args = ap.parse_args()
if args.selftest:
sys.exit(selftest())
try:
import bleak # noqa: F401
except ImportError:
sys.exit("缺少 bleak: pip install bleak")
print(f"选项: unpair={args.unpair} transport={args.transport}")
try:
sim = BleMidiSim(
name=args.ble_name,
address=args.ble_address,
transport=args.transport,
unpair=args.unpair,
)
except Exception as exc:
R = Results()
R.add("BLE 扫描连接", "FAIL", str(exc))
R.summary()
print(f"[FAIL] BLE 扫描连接 | {exc}")
sys.exit(1)
print(
f"=== BLE 测试: {sim.info.get('name')} ({sim.info.get('address')}) "
f"transport={sim.info.get('transport')} payload={sim.info.get('max_payload')} ==="
)
if not sim.probe_or_rebind(args.ble_name, args.ble_address):
sim.close()
path = args.report or os.path.normpath(
os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"..",
"..",
"Doc",
"reports",
f"ble_sysex_{datetime.datetime.now():%Y%m%d_%H%M%S}.md",
)
)
R = Results()
R.add("probe 01 01", "FAIL", "timeout after unpair/reconnect")
write_report(
R,
sim.info or {},
path,
notes=[
"首包无应答:多半是 Windows 残留配对,或 ATS2853 被 E49A 裸写弄挂需复位。",
"推荐: python tools/test_protocol_ble.py --unpair",
"仍失败则 JLink reset / 给吉他断电再开机后再测。",
],
)
print(f"\n报告已写入: {path}")
sys.exit(1)
notes = [
f"transport={sim.info.get('transport')}",
f"unpair={args.unpair}",
"Windows 默认 --unpair全 timeout 时先复位吉他再测。",
"勿用 --transport uart 裸写做冒烟(易停广播)。",
]
try:
R = smoke(sim) if args.smoke else run_tests(sim, allow_poweroff=args.allow_poweroff)
finally:
sim.close()
code = R.summary()
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
path = args.report or os.path.normpath(
os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"..",
"..",
"Doc",
"reports",
f"ble_sysex_{ts}.md",
)
)
write_report(R, sim.info, path, notes=notes)
print(f"\n报告已写入: {path}")
sys.exit(code)
if __name__ == "__main__":
main()

253
tools/tone_artist_pack.py Normal file
View File

@ -0,0 +1,253 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""音师自助:把投放目录的 1/2/3.bin 打成 extflash_ALL_*.res供 SoundWalkerIAP USB 烧录。
不走 J-Link不改固件头文件不重编 MCU
需要本机已有 ui0902_res.bin或最近一次发布包里的 ALL.res 可拆出 UI
"""
from __future__ import annotations
import argparse
import shutil
import struct
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] # firmware project
REPO = ROOT.parent.parent # 一诺国际吉他
OUT_DIR = ROOT / "tools" / "out"
DROP_DIR = REPO / "Doc" / "音色文件" / "音师投放"
IAP_DIR = REPO / "升级" / "MCU主控升级"
UI0902_BIN = OUT_DIR / "ui0902_res.bin"
REPO_OUT = REPO / "tools" / "out"
OFF_LOGO = 0x00000000
OFF_CHARGING = 0x0000CB70
OFF_BIN1 = 0x0001B8F0
OFF_BIN2 = 0x0009D07D
OFF_BIN3 = 0x000A71AC
UI0902_RES_BASE = 0x00100000
DAB_MAGIC = b"\xABDAB"
MAX_SONG_BIN_BYTES = 41 * 1024
BIN2_SLOT_BYTES = OFF_BIN3 - OFF_BIN2
def find_ziliao() -> Path:
for p in REPO.iterdir():
if p.is_dir() and (p / "logo.bin").is_file() and (p / "Charg.bin").is_file():
return p
raise SystemExit("找不到 资料/logo.bin 与 Charg.bin无法打包。")
def dab_ok(blob: bytes, off: int, expect_cnt: int, label: str) -> None:
if off + 16 > len(blob):
raise SystemExit(f"{label} @0x{off:X}: 文件太短,无法校验")
magic = blob[off : off + 4]
cnt = struct.unpack_from("<I", blob, off + 12)[0]
if magic != DAB_MAGIC:
raise SystemExit(
f"{label} @0x{off:X}: DAB 魔数错误 {magic.hex()}(期望 {DAB_MAGIC.hex()}),请确认文件是否正确导出"
)
if cnt != expect_cnt:
raise SystemExit(
f"{label} @0x{off:X}: 预设数量={cnt},期望 {expect_cnt}"
f"{'若曲目数量有变,需找开发同步固件曲名表。' if label.startswith('2.bin') else ''}"
)
def require_equals(blob: bytes, off: int, src: bytes, label: str) -> None:
end = off + len(src)
if end > len(blob) or blob[off:end] != src:
raise SystemExit(f"内部校验失败:{label} 未正确写入资源包")
def find_ui_baseline() -> bytes:
if UI0902_BIN.is_file():
data = UI0902_BIN.read_bytes()
print(f"UI 底图: {UI0902_BIN} ({len(data)} bytes)")
return data
# Fallback: newest release ALL.res under repo tools/out
candidates: list[Path] = []
if REPO_OUT.is_dir():
candidates.extend(REPO_OUT.glob("**/extflash_ALL_*.res"))
candidates.extend(REPO_OUT.glob("extflash_ALL_*.res"))
candidates = sorted({p.resolve() for p in candidates if p.is_file()}, key=lambda p: p.stat().st_mtime, reverse=True)
for all_path in candidates:
raw = all_path.read_bytes()
if len(raw) > UI0902_RES_BASE:
ui = raw[UI0902_RES_BASE:]
print(f"UI 底图: 从发布包拆出 {all_path.name} @0x{UI0902_RES_BASE:X} ({len(ui)} bytes)")
return ui
raise SystemExit(
"缺少 UI 底图 ui0902_res.bin。\n"
"请向开发索取一次含 UI 的基线tools/out/ui0902_res.bin或完整 extflash_ALL_*.res"
"放到工程 tools/out/ 后再运行本工具。"
)
def pack_tone(bin1: bytes, bin2: bytes, bin3: bytes, logo: bytes, charg: bytes) -> bytes:
if len(bin2) > MAX_SONG_BIN_BYTES:
raise SystemExit(f"2.bin 过大: {len(bin2)} > {MAX_SONG_BIN_BYTES} (41KB 上限)")
if len(bin2) > BIN2_SLOT_BYTES:
raise SystemExit(
f"2.bin 过大: {len(bin2)} > 槽位 {BIN2_SLOT_BYTES},会挤占 3.bin 固定地址 0x{OFF_BIN3:X}"
)
parts = [
(OFF_LOGO, logo, "logo"),
(OFF_CHARGING, charg, "Charg"),
(OFF_BIN1, bin1, "1.bin"),
(OFF_BIN2, bin2, "2.bin"),
(OFF_BIN3, bin3, "3.bin"),
]
blobs: list[bytes] = []
cursor = 0
for force_off, data, name in parts:
if cursor > force_off:
raise SystemExit(f"{name}: 前一段过大,无法放到 0x{force_off:X}")
if cursor < force_off:
blobs.append(b"\xFF" * (force_off - cursor))
cursor = force_off
blobs.append(data)
cursor += len(data)
packed = b"".join(blobs)
dab_ok(packed, OFF_BIN1, 31, "1.bin(节奏)")
dab_ok(packed, OFF_BIN2, 1, "2.bin(本地曲目)")
dab_ok(packed, OFF_BIN3, 3, "3.bin(万能)")
require_equals(packed, OFF_BIN1, bin1, "1.bin")
require_equals(packed, OFF_BIN2, bin2, "2.bin")
require_equals(packed, OFF_BIN3, bin3, "3.bin")
if cursor > UI0902_RES_BASE:
raise SystemExit("音色区溢出到 UI 区 (0x100000)")
return packed
def write_flash_guide(path: Path, all_name: str, stamp: str) -> None:
text = "\n".join(
[
"音师音色自助包 — 请这样用 SoundWalkerIAP 刷机",
f"生成时间: {stamp}",
"",
"==== 本次只需刷外部 Flash不用重刷 Boot/APP除非开发另有说明 ====",
f" 文件: {all_name}",
" 地址: 0x00000000从外部 Flash 开头整包写入)",
"",
"步骤:",
" 1. 吉他进入 USB 升级模式(参见 升级步骤.docx",
" 2. 打开本目录的 SoundWalkerIAP.exe",
" 3. 先整片擦除外部 Flash",
f" 4. 选择 {all_name},烧录到外部 Flash @ 0x00000000",
" 5. 退出升级模式,重启,试听节奏/本地曲/万能",
"",
"注意:",
" - 必须刷本包 ALL.res不要只刷单独的 1/2/3.bin",
" - 只刷音色、不带 UI 会导致模式选择页花屏",
" - 2.bin 必须 ≤41KB3.bin 地址固定,不可随 2.bin 变长漂移",
" - 若增删本地曲目或改曲名,需找开发同步固件后再测",
"",
]
)
path.write_text(text, encoding="utf-8", newline="\n")
def main() -> int:
ap = argparse.ArgumentParser(description="音师自助打包 1/2/3.bin → ALL.resUSB IAP")
ap.add_argument(
"--drop",
type=Path,
default=DROP_DIR,
help=f"投放目录(默认 {DROP_DIR}",
)
ap.add_argument(
"--open",
action="store_true",
help="完成后用资源管理器打开输出目录",
)
args = ap.parse_args()
drop: Path = args.drop
print("=== 音师音色打包USB / SoundWalkerIAP非 J-Link===")
print(f"投放目录: {drop}")
bin1_p = drop / "1.bin"
bin2_p = drop / "2.bin"
bin3_p = drop / "3.bin"
missing = [p.name for p in (bin1_p, bin2_p, bin3_p) if not p.is_file()]
if missing:
raise SystemExit(
f"投放目录缺少: {', '.join(missing)}\n"
f"请把新的 1.bin / 2.bin / 3.bin 放到:\n {drop}"
)
ziliao = find_ziliao()
bin1 = bin1_p.read_bytes()
bin2 = bin2_p.read_bytes()
bin3 = bin3_p.read_bytes()
logo = (ziliao / "logo.bin").read_bytes()
charg = (ziliao / "Charg.bin").read_bytes()
print(f"1.bin {len(bin1)} bytes | 2.bin {len(bin2)} bytes | 3.bin {len(bin3)} bytes")
tone = pack_tone(bin1, bin2, bin3, logo, charg)
ui = find_ui_baseline()
all_res = tone + (b"\xFF" * (UI0902_RES_BASE - len(tone))) + ui
require_equals(all_res, OFF_BIN1, bin1, "ALL/1.bin")
require_equals(all_res, OFF_BIN2, bin2, "ALL/2.bin")
require_equals(all_res, OFF_BIN3, bin3, "ALL/3.bin")
dab_ok(all_res, OFF_BIN1, 31, "ALL/1.bin")
dab_ok(all_res, OFF_BIN2, 1, "ALL/2.bin")
dab_ok(all_res, OFF_BIN3, 3, "ALL/3.bin")
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
day = datetime.now().strftime("%Y%m%d")
pkg = OUT_DIR / f"音师音色包_{day}"
pkg.mkdir(parents=True, exist_ok=True)
all_name = f"extflash_ALL_artist_{stamp}.res"
all_path = pkg / all_name
all_path.write_bytes(all_res)
(OUT_DIR / "extflash_tone_artist.res").write_bytes(tone)
(OUT_DIR / "extflash_ALL_artist.res").write_bytes(all_res)
iap_exe = IAP_DIR / "SoundWalkerIAP.exe"
iap_doc = IAP_DIR / "升级步骤.docx"
if not iap_exe.is_file():
raise SystemExit(f"找不到 {iap_exe}")
shutil.copy2(iap_exe, pkg / "SoundWalkerIAP.exe")
if iap_doc.is_file():
shutil.copy2(iap_doc, pkg / "升级步骤.docx")
write_flash_guide(pkg / "请这样刷.txt", all_name, stamp)
print(f"OK 音色区 {len(tone)} bytesALL.res {len(all_res)} bytes")
print(f"OK 输出目录: {pkg}")
print(f" - {all_name}")
print(" - SoundWalkerIAP.exe")
print(" - 请这样刷.txt")
print("请用 USB + SoundWalkerIAP 刷 ALL.res不要用 J-Link 单独烧 bin。")
if args.open:
try:
import os
os.startfile(str(pkg)) # type: ignore[attr-defined]
except Exception as exc:
print(f"(无法自动打开目录: {exc})")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except SystemExit as e:
code = e.code
if isinstance(code, str):
print(f"\n失败: {code}")
sys.exit(1)
if code not in (0, None):
sys.exit(int(code) if isinstance(code, int) else 1)
sys.exit(0)
except Exception as e:
print(f"\n失败: {e}")
sys.exit(1)

View File

@ -0,0 +1,26 @@
音师投放与自助更新说明
========================
一、投放目录(放新的 1/2/3.bin
一诺国际吉他\Doc\音色文件\音师投放\
1.bin 普通/专业节奏
2.bin 本地曲目(必须 ≤41KB
3.bin 万能模式
二、一键打包(合并 ALL.resUSB 烧录,不用 J-Link
双击:本目录「音师更新音色.bat」
或命令行python tone_artist_pack.py --open
三、烧录
输出在 tools\out\音师音色包_日期\
- extflash_ALL_artist_*.res
- SoundWalkerIAP.exe
- 请这样刷.txt
按「请这样刷.txt」进 USB 升级模式 → 擦除外部 Flash
→ SoundWalkerIAP 将 ALL.res 烧到 0x00000000 → 重启试听
四、限制
- 只更新音色时一般不用重刷 MCU
- 必须刷 ALL.res含界面勿只刷单独 bin否则模式选择花屏
- 增删本地曲/改曲名需找开发同步固件曲名表
- 首次需本机有 tools\out\ui0902_res.bin向开发索取一次基线即可

View File

@ -0,0 +1,31 @@
@echo off
chcp 65001 >nul
setlocal
cd /d "%~dp0"
echo.
echo ========================================
echo 音师音色更新 - 打包 ALL.res (USB IAP)
echo 不使用 J-Link请用 SoundWalkerIAP 烧录
echo ========================================
echo.
where python >nul 2>&1
if errorlevel 1 (
echo [错误] 未找到 python请先安装 Python 3 并勾选 Add to PATH。
pause
exit /b 1
)
python "%~dp0tone_artist_pack.py" --open
set ERR=%ERRORLEVEL%
echo.
if not "%ERR%"=="0" (
echo 打包失败,请根据上方中文提示检查 1/2/3.bin。
pause
exit /b %ERR%
)
echo 打包成功。请按输出目录中的「请这样刷.txt」用 SoundWalkerIAP 刷机。
pause
exit /b 0