← 都来码 / Dolaima← 例程索引c/app/piano.c原文

c/app/piano.c · 45594 字节 · 下载原文

/* piano.c —— **钢琴**:黑白键 + 多点触控按和弦 + 示范曲 + 录音回放。
 * piano.c — **Piano**: black/white keys + multi-touch chords + a demo song + record/playback.
 *
 * ## 为什么它必须**轮询** `ui_touch_read` 而不是收事件
 * ## Why it must **poll** `ui_touch_read` instead of receiving events
 *
 * 事件式的触摸消息(`VML_MSG_TOUCHDOWN` 等)**只有槽位 0 会投**
 * Event-style touch messages (`VML_MSG_TOUCHDOWN` etc.) are **delivered to slot 0 only**
 * (`VmlHostRuntime.PostTouch`:所有槽位都更新查询状态,但只在 `slot == 0` 时投消息)。
 * (`VmlHostRuntime.PostTouch`: every slot updates the query state, but a message is posted only when `slot == 0`).
 * 所以光靠事件最多只能做**单指** —— 按不出和弦。
 * So events alone can give you **one finger** at most — you cannot play a chord.
 * 要十指同时按,就得每拍把 `ui_touch_read(slot)` 的 0–9 号槽位都查一遍。
 * To press ten fingers at once, you have to scan slots 0–9 of `ui_touch_read(slot)` on every tick.
 *
 * ⚠ 因此本程序**不用 `ui_timer_set`**:定时器消息会和触摸消息争同一个队列,
 * ⚠ That is why this program **does not use `ui_timer_set`**: timer messages would fight the touch messages for the same queue,
 *   而 `ui_msg_wait(m, 16)` 的超时天然就是"至少每 16ms 醒一次",一拍两用。
 *   whereas the timeout of `ui_msg_wait(m, 16)` is naturally "wake up at least every 16ms" — one tick serving two purposes.
 *
 * ## 键盘布局:**自适应列数 + 两行**
 * ## Keyboard layout: **adaptive column count + two rows**
 *
 * 竖屏一块屏只放一个八度的话,每个键宽 ~51dp 而高 ~540dp —— 成了 1:10 的**竖长条**,
 * If a portrait screen held only one octave, each key would be ~51dp wide and ~540dp tall — a 1:10 **vertical strip**,
 * 一点都不像琴键(用户原话:"画面是长条")。所以:
 * nothing like a piano key at all (the user's own words: "the picture is a long strip"). So:
 *
 *   · **每行几个键按屏宽定**(每个键至少约 44dp,手指才按得准),且取 **7 的整数倍** ——
 *   · **How many keys per row depends on the screen width** (each key at least ~44dp so a finger can hit it), rounded to a **multiple of 7** —
 *     保证一行正好是若干个**完整八度**(落在 B 上收尾,不会切在 mi/si 中间而缺黑键)。
 *     that guarantees a row is exactly a whole number of **complete octaves** (ending on B, never cut between mi/si with a black key missing).
 *     竖屏 360dp ⇒ 7 个(一个八度);平板 800dp ⇒ 14 个(两个八度)。
 *     Portrait 360dp ⇒ 7 keys (one octave); tablet 800dp ⇒ 14 keys (two octaves).
 *   · **两行** —— 上行比下行**高一个"每行八度数"**,音域直接翻倍,
 *   · **Two rows** — the upper row is **one "octaves per row" higher** than the lower one, which doubles the range directly,
 *     而且每行的高度只剩一半 ⇒ 键的宽高比回到像琴键的样子。
 *     and each row is only half as tall ⇒ the key aspect ratio goes back to looking like a piano key.
 *   · 每行高度再按"不超过宽度的 6 倍"收一道 —— 屏幕很高时别又把键拉成长条。
 *   · Each row's height is then capped at "no more than 6× its width" — on a very tall screen, don't stretch the keys into strips again.
 *
 * ## 引用计数:为什么不能"抬起就关音"
 * ## Reference counting: why you cannot "stop the note as soon as a finger lifts"
 *
 * 两根手指先后按同一个键、其中一根先抬起来时,**那个键不该停**
 * When two fingers press the same key one after the other and one of them lifts first, **that key must not stop**
 * (另一根还按着)。所以每个音记一个引用计数,**归零才真的关**。
 * (the other finger is still holding it). So every note keeps a reference count, and **only a count of zero really stops it**.
 * 表按**半音偏移**建(0..25,覆盖两行),一张就够 ——
 * The table is indexed by **semitone offset** (0..25, covering both rows) — one table is enough:
 * 分白键/黑键两张是"怎么画"的事,与"谁按着"无关。
 * splitting it into two tables for white/black keys is a "how to draw" matter, unrelated to "who is holding it".
 *
 * ## ⚠ C 前端的四条硬约束
 * ## ⚠ Four hard constraints of the C frontend
 *
 * ⚠ **2026-10-04 逐条复验:下面四条原来写的缺陷,1/2/3 都已修好,4 也已修好**(写法照旧保留,能跑就别动)。
 * ⚠ **Re-checked item by item on 2026-10-04: of the four defects below, 1/2/3 are all fixed, and 4 is fixed too** (the style stays; if it runs, do not touch it).
 *
 * 1. ~~**局部数组必须单独一行声明**(标量与数组同声明会让数组分不到槽位,连读取指令都不生成 ⇒ 变量恒为 0)~~
 * 1. ~~**A local array must be declared on a line of its own** (declaring a scalar and an array together left the array with no slot and no load instruction ⇒ the variable was always 0)~~
 *    ✅ 已修好:`int i, m[4], t;` 后 `m[1]+m[2]` 实测 **50**、`m[1]` 实测 **20**。
 *    ✅ fixed: with `int i, m[4], t;`, `m[1]+m[2]` measured **50** and `m[1]` measured **20**.
 * 2. ~~**一行一个变量**,别写 `int x = m[1], y = m[2];`。~~
 * 2. ~~**One variable per line**; do not write `int x = m[1], y = m[2];`.~~
 *    ✅ 已修好:`int x = m[1], y = m[2];`(m={0,5,9,0})实测 **X=5 Y=9**。
 *    ✅ fixed: `int x = m[1], y = m[2];` (m={0,5,9,0}) measured **X=5 Y=9**.
 * 3. ~~**全局数组的元素要先落局部变量再比较**(`calc.c` 记过)。~~
 * 3. ~~**A global array's element must be loaded into a local variable before being compared** (recorded in `calc.c`).~~
 *    ✅ 已修好:`int g[4]; g[0]=7; if (g[0]==7)` 实测成立(直接比较已正确)。
 *    ✅ fixed: `int g[4]; g[0]=7; if (g[0]==7)` measured true (direct comparison is now correct).
 * 4. ~~`#define` **不支持反斜杠续行** ⇒ 大表只能写成一行;本程序因此用~~
 * 4. ~~`#define` **does not support backslash line continuation** ⇒ a big table can only be written on one line; this program therefore uses~~
 *    ✅ 已修好:续行宏实测 **5**、多行宏 **42**、字符串续行 **hello** ⇒ **记谱字符串**(`"Cq Cq Gq"`)这个写法**照旧保留**(能跑就别动),
 *    ✅ fixed: continued macro measured **5**, multi-line macro **42**, continued string **hello** ⇒ the **notation string** style (`"Cq Cq Gq"`) **stays** (if it runs, do not touch it);
 *    但**新写的代码可以用续行宏**。
 *    but **new code may use backslash-continued macros**.
 */
#include <vml_ui.h>

/* ── 尺寸 ── */
/* ── Sizes ── */
static int SW;
static int SH;
static int BARH;      /* 顶部状态栏高 */
                      /* height of the top status bar */
static int KEYTOP;    /* 键盘上沿(第一行的顶) */
                      /* keyboard top edge (the top of the first row) */
static int RowKeyH;   /* **每一行**白键的高(不再是整块屏幕的高度) */
                      /* white-key height **per row** (no longer the whole screen's height) */
static int RowGap;    /* 两行之间的缝 —— 手指按错行时能看出来 */
                      /* gap between the two rows — visible when a finger lands on the wrong row */

/* ── 键盘布局 ── */
/* ── Keyboard layout ── */
static int PerRow;    /* 每行几个白键(7 的整数倍:7 或 14) */
                      /* white keys per row (a multiple of 7: 7 or 14) */
static int RowCount;  /* 行数(2) */
                      /* number of rows (2) */
static int RowStep;   /* 上下两行差几个半音(= 12 × 每行的八度数) */
                      /* semitones between the two rows (= 12 × octaves per row) */

/* ── 音域 ──
 * ── Range ──
 * `BaseNote` 是**第一行**的 do;第二行是 `BaseNote + RowStep`。
 * `BaseNote` is the do of the **first row**; the second row is `BaseNote + RowStep`.
 * 状态栏那两个「−/+」整体移调(两行一起走),比把键盘挤成一条窄键好得多。
 * The two "−/+" buttons in the status bar transpose everything (both rows move together), far better than squeezing the keyboard into one narrow row.
 */
static int BaseNote;

/* 音名(标在白键底部用)。
 * Note names (drawn at the bottom of the white keys).
 * ⚠ 用**两个一维 char 数组**而不是 `char* NAMES[12]` 或二维数组 ——
 * ⚠ Use **two one-dimensional char arrays** rather than `char* NAMES[12]` or a 2-D array —
 *   ~~本仓记过"全局**指针**数组在这条前端上会读出垃圾值",能不碰就不碰。~~
 *   ~~this repo has recorded that "a global **pointer** array reads garbage on this frontend"; avoid it if you can.~~
 *   ✅ **已修好(2026-10-04 复验)**:`char* keys[3] = {"AA","BB","CC"}` 实测三个都正确读出(`AA BB CC`)。
 *   ✅ **fixed (re-checked 2026-10-04)**: `char* keys[3] = {"AA","BB","CC"}` measured reading all three back correctly (`AA BB CC`).
 *   当前写法**照旧保留**(能跑就别动);**新写的代码可以用指针数组**。
 *   The current style **stays** (if it runs, do not touch it); **new code can use pointer arrays**.
 *   12 个半音里只有 5 个带升号(C# D# F# G# A#),另一个数组存 '#' 或 0。
 *   only 5 of the 12 semitones carry a sharp (C# D# F# G# A#); the other array holds '#' or 0.
 */
static char NAME0[12];
static char NAME1[12];

/* 白键半音偏移:do re mi fa sol la si */
/* white-key semitone offsets: do re mi fa sol la si */
static int WHITE_SEMI[7];
/* 第 i 个白键右上方有没有黑键(-1 = 没有:mi 和 si 右上方没有) */
/* is there a black key above-right of the i-th white key (-1 = no: mi and si have none) */
static int BLACK_AT[7];
/* 那个黑键的半音偏移 */
/* semitone offset of that black key */
static int BLACK_SEMI[7];

/* ── 手指状态 ── */
/* ── Finger state ── */
static int KeyDown[10];    /* 这个槽位按着哪个**音符号**;-1 = 没按 */
                           /* which **note number** this slot is holding; -1 = not pressed */
static int PrevDown[10];   /* 上一拍这个槽位按着没有 —— 用来取"按下沿"(见按钮那段) */
                           /* whether this slot was pressed on the previous tick — used to take the "pressed edge" (see the button section) */
static int Ref[26];        /* 每个半音偏移(相对 BaseNote)被几根手指按着 */
                           /* how many fingers are holding each semitone offset (relative to BaseNote) */

/* ── 示范曲 ── */
/* ── Demo song ── */
static char* SONG;
static int SongPos;        /* 解析到第几个字符 */
                           /* how far the parse has got (character index) */
static int SongOn;
static int SongTick;       /* 这一首已经走了几拍 */
                           /* how many ticks this song has already run */
static int SongNote;       /* 正在响的示范音(音符号);-1 = 没响 */
                           /* the demo note currently sounding (note number); -1 = silent */
static int SongOffAt;      /* 到这一拍就该关掉 */
                           /* at this tick it should be switched off */
static int SongNextAt;     /* 到这一拍取下一个音 */
                           /* at this tick take the next note */

/* ── 录音 ── */
/* ── Recording ── */
static int RecOn;
static int RecN;
static int RecTick[384];   /* 事件时刻(拍) */
                           /* event time (ticks) */
static int RecNote[384];   /* 音符号;-1 表示"关音" */
                           /* note number; -1 means "note off" */

/* ── 状态栏按钮(布局时算好,**画与命中同源**)── */
/* ── Status-bar buttons (computed at layout time; **drawing and hit-testing share one source**) ── */
/* 0=移调- 1=移调+ 2=示范曲 3=录音 4=**强制清除** */
/* 0=transpose- 1=transpose+ 2=demo song 3=record 4=**force clear** */
static int BtnX[5];
static int Lang;      /* 界面语言:开局查一次(ui_scr_get_language 是 syscall,别每帧调) */
                      /* UI language: queried once at startup (ui_scr_get_language is a syscall, don't call it every frame) */
static int BtnY;
static int BtnW;
static int BtnH;

/* ══════════════════════════════════════════════════════════════════════
 * 发声:手弹与示范曲**共用**这两个出口
 * Sound: the hand-played notes and the demo song **share** these two exits
 * ══════════════════════════════════════════════════════════════════════ */

static void p_on(int ch, int note, int vel)
{
    ui_tone_on(ch, note, vel);
    if (RecOn && RecN < 383) {
        RecTick[RecN] = ui_time_get_ms();
        RecNote[RecN] = note;
        RecN = RecN + 1;
    }
}

static void p_off(int ch, int note)
{
    ui_tone_off(ch, note);
    if (RecOn && RecN < 383) {
        RecTick[RecN] = ui_time_get_ms();
        RecNote[RecN] = -1;
        RecN = RecN + 1;
    }
}

/* ══════════════════════════════════════════════════════════════════════
 * 键位几何 —— **画与命中同源**(同一个函数算出来的矩形)
 * Key geometry — **drawing and hit-testing share one source** (the rectangle comes out of the same function)
 * ══════════════════════════════════════════════════════════════════════ */

static int white_w(void)
{
    return SW / PerRow;
}

/* 第 `row` 行第 `i` 个白键的**上沿** y。 */
/* the **top edge** y of the i-th white key in row `row`. */
static int row_top(int row)
{
    return KEYTOP + row * (RowKeyH + RowGap);
}

/* ══════════════════════════════════════════════════════════════════════
 * 「第几行第几个键 → 哪个半音偏移」——**画与命中共用的唯一真源**
 * "which row, which key → which semitone offset" — **the single source shared by drawing and hit-testing**
 *
 * ⚠ 这两个函数是踩出来的:最先绘制时只算了**行内**偏移、忘了加 `row * RowStep`,
 * ⚠ These two functions were born from a bug: at first the drawing side computed only the **in-row** offset and forgot to add `row * RowStep`,
 *   而命中那条**算对了**。于是表现成一对其怪的组合:
 *   while the hit-testing side **got it right**. The result was a bizarre pair of symptoms:
 *     · 按**上行** ⇒ 两行查的是同一个 `Ref` 键 ⇒ **下面那行一起亮**;
 *     · Pressing the **upper row** ⇒ both rows looked up the same `Ref` entry ⇒ **the lower row lit up too**;
 *     · 按**下行** ⇒ 查的键根本没人写过 ⇒ **声音是有的、却不高亮**。
 *     · Pressing the **lower row** ⇒ nobody had ever written that entry ⇒ **the sound played, but no highlight**.
 *   两处各算一遍就是两个真源,迟早对不上 —— 收成一个函数之后不可能再错开。
 *   Computing it in two places is two sources of truth, and sooner or later they disagree — once folded into one function they cannot drift apart again.
 * ══════════════════════════════════════════════════════════════════════ */

/* 第 `row` 行**多高**(相对 BaseNote 的半音数)。
 * **How high** row `row` is (semitones relative to BaseNote).
 *
 * ⚠ **下面那行是低音**(`row` 越大越低)—— 音高往上走、屏幕上也往上走,
 * ⚠ **The lower row is the bass** (larger `row` = lower) — pitch goes up and so does the screen position,
 *   与钢琴/电子琴的直觉一致。第一版写反了(下面反而高一个八度),
 *   matching the intuition of a piano or electric keyboard. The first version had it backwards (the lower row was an octave higher),
 *   标上音名才一眼看出来(下面是 C5、上面是 C4)。
 *   and it only became obvious once the note names were drawn on (C5 below, C4 above).
 *   这类"反了也能跑"的错误,靠**把语义画出来**(音名)比靠断言容易发现得多。
 *   For this kind of "backwards but still runs" mistake, **drawing the semantics** (the note names) reveals it far more easily than an assertion. */
static int row_shift(int row)
{
    return (RowCount - 1 - row) * RowStep;
}

/* 第 `row` 行第 `i` 个白键的半音偏移(相对 BaseNote)。 */
/* the semitone offset of the i-th white key in row `row` (relative to BaseNote). */
static int white_semi_at(int row, int i)
{
    return row_shift(row) + WHITE_SEMI[i - (i / 7) * 7] + (i / 7) * 12;
}

/* 第 `row` 行第 `i` 个白键右上方黑键的半音偏移(调用方先判 `BLACK_AT` 不是 -1)。 */
/* the semitone offset of the black key above-right of the i-th white key in row `row` (the caller first checks that `BLACK_AT` is not -1). */
static int black_semi_at(int row, int i)
{
    return row_shift(row) + BLACK_SEMI[i - (i / 7) * 7] + (i / 7) * 12;
}

/* 第 `row` 行第 `i` 个白键右上方黑键的矩形(out[0..3] = x y w h)。 */
/* the rectangle of the black key above-right of the i-th white key in row `row` (out[0..3] = x y w h). */
static void black_rect(int row, int i, int* out)
{
    int w;
    int bw;
    w = SW / PerRow;
    bw = w * 62 / 100;
    out[0] = (i + 1) * w - bw / 2;
    out[1] = row_top(row);
    out[2] = bw;
    out[3] = RowKeyH * 62 / 100;
}

/* ══════════════════════════════════════════════════════════════════════
 * 绘制
 * Drawing
 * ══════════════════════════════════════════════════════════════════════ */

static void draw(void)
{
    int i;
    int row;
    int r[4];
    int x;
    int w;
    int semi;
    int oct;
    int y0;
    int rel;
    char nm[4];

    ui_2d_clear(0xFF101820);

    /* ── 状态栏 ── */
    /* ── Status bar ── */
    ui_text_set_font(18, VML_FONT_BOLD, 0xFF7FD4FF, VML_ANCHOR_LEFT);
    ui_text_set_valign(VML_VANCHOR_MIDDLE);
    ui_2d_text_cur(10, BtnY + BtnH / 2, Lang == 0 ? "钢琴" : "Piano");

    /* ⚠ 这里原先还有一行"音域提示"(`C4-5` 之类),居中画的 ——
     * ⚠ There used to be a "range hint" line here (`C4-5` and the like), drawn centered —
     *   而 5 个按钮从 x≈80 起把右边占满了,那行字**大半被盖住、只露出一个 C**,
     *   but the 5 buttons fill the right side from x≈80 on, so that text was **mostly covered, with only one C sticking out**,
     *   看着像凭空多出来的字符(用户报的就是这个)。
     *   looking like a stray character out of nowhere (this is exactly what the user reported).
     *   既然音名已经标在每个白键上,那个提示本来就是重复的 —— 直接去掉。
     *   Since the note names are already drawn on every white key, that hint was redundant anyway — so it was removed.
     */

    for (i = 0; i < 5; i++) {
        /* 第 5 个(清除)用暖红底 —— 它是"急停",混在其余按钮里不好找 */
        /* the 5th button (clear) gets a warm red background — it is the "emergency stop", and it is hard to find among the other buttons */
        ui_2d_rect(BtnX[i], BtnY, BtnW, BtnH, i == 4 ? 0xFF6A2A2A : 0xFF2A3A4A, 1, 0, 8);
        ui_text_set_font(15, VML_FONT_BOLD, 0xFFDDEEFF, VML_ANCHOR_CENTER);
        ui_text_set_valign(VML_VANCHOR_MIDDLE);
        if (i == 0) ui_2d_text_cur(BtnX[i] + BtnW / 2, BtnY + BtnH / 2, "-");
        if (i == 1) ui_2d_text_cur(BtnX[i] + BtnW / 2, BtnY + BtnH / 2, "+");
        /* 英文按"这个按钮此刻的动作"给词(Demo/Stop、Rec/Stop)—— 中文那对 停/停录
           The English wording follows "what this button does right now" (Demo/Stop, Rec/Stop) — the Chinese pair "stop"/"stop recording"
           在窄按钮里放不下(StopRec 会溢出 40~52px 的键宽),而按钮各自的位置已经把
           would not fit in a narrow button (StopRec overflows the 40~52px key width), and each button's position already makes
           "停的是哪一个"说清楚了。
           clear which one is being stopped.
           */
        if (i == 2) ui_2d_text_cur(BtnX[i] + BtnW / 2, BtnY + BtnH / 2,
                                SongOn ? (Lang == 0 ? "停" : "Stop") : (Lang == 0 ? "示范" : "Demo"));
        if (i == 3) ui_2d_text_cur(BtnX[i] + BtnW / 2, BtnY + BtnH / 2,
                                RecOn ? (Lang == 0 ? "停录" : "Stop") : (Lang == 0 ? "录音" : "Rec"));
        if (i == 4) ui_2d_text_cur(BtnX[i] + BtnW / 2, BtnY + BtnH / 2, Lang == 0 ? "清音" : "Clear");
    }

    /* ── 两行白键 ── */
    /* ── Two rows of white keys ── */
    w = white_w();
    for (row = 0; row < RowCount; row++) {
        y0 = row_top(row);
        for (i = 0; i < PerRow; i++) {
            /* ⚠ 这里的 `semi` **必须**用 `white_semi_at(row, i)`(含行偏移)——
             * ⚠ The `semi` here **must** come from `white_semi_at(row, i)` (including the row shift) —
             *   先前写成"只有行内偏移",于是两行共用同一个 `Ref` 键:
             *   it used to carry the in-row offset only, so both rows shared the same `Ref` entry:
             *   按上行两行一起亮、按下行反而不亮(声音却有)。见那个函数的注释。
             *   pressing the upper row lit both rows, pressing the lower row lit nothing (though it did sound). See that function's comment.
             */
            semi = white_semi_at(row, i);
            x = i * w;
            ui_2d_rect(x + 1, y0 + 3, w - 2, RowKeyH, 0xFF000000, 1, 0, 6);   /* 底影 */
                                                                           /* bottom shadow */
            if (Ref[semi] > 0) {
                ui_2d_rect(x + 1, y0 + 2, w - 2, RowKeyH - 2, 0xFFFFD98A, 1, 0, 6);  /* 按下:暖色 */
                                                                                  /* pressed: warm color */
            } else {
                ui_2d_rect(x + 1, y0, w - 2, RowKeyH, 0xFFF2F2F4, 1, 0, 6);
            }
            ui_2d_rect(x + 1, y0, w - 2, RowKeyH, 0xFF8A8A92, 0, 2, 6);        /* 描边 */
                                                                           /* outline */

            /* 音名标在白键**底部**(黑键太窄,标了也看不清)。
             * The note name goes at the **bottom** of the white key (the black keys are too narrow to read a label on).
             * 八度用"相对中央 C 的八度号"—— `BaseNote` 就是第一行的 C。
             * The octave uses "the octave number relative to middle C" — `BaseNote` is the C of the first row.
             */
            /* ⚠ 坑 3(全局数组元素先落局部变量):`NAME0[...]` 的下标也先算出来,
             * ⚠ Trap 3 (load a global array's element into a local first): the index into `NAME0[...]` is computed up front as well,
             *   别在两次取用里各算一遍。
             *   don't compute it once for each of the two uses.
             */
            rel = semi - semi / 12 * 12;                    /* 这个音在八度内的位置 */
                                                            /* this note's position within the octave */
            oct = (BaseNote + semi - 60) / 12 + 4;          /* C4 = 中央 C */
                                                            /* C4 = middle C */
            if (oct < 0) oct = 0;
            if (oct > 9) oct = 9;                           /* 只画一位,画不下就夹住 */
                                                            /* only one digit is drawn; clamp it if it won't fit */
            if (NAME1[rel] == 0) {
                nm[0] = NAME0[rel];                         /* 白键:C4 / D4 … */
                                                            /* white key: C4 / D4 … */
                nm[1] = '0' + oct;
                nm[2] = 0;
            } else {
                nm[0] = NAME0[rel];                         /* 黑键名用不上,但留着不会错 */
                                                            /* the black-key name is unused here, but keeping it does no harm */
                nm[1] = '#';
                nm[2] = '0' + oct;
                nm[3] = 0;
            }
            ui_text_set_font(13, 0, 0xFF8A8A92, VML_ANCHOR_CENTER);
            ui_text_set_valign(VML_VANCHOR_BOTTOM);
            ui_2d_text_cur(x + w / 2, y0 + RowKeyH - 6, nm);
        }
    }

    /* ── 黑键(画在白键之上;每行各自铺一遍)── */
    /* ── Black keys (drawn on top of the white keys; laid down once for each row) ── */
    for (row = 0; row < RowCount; row++) {
        for (i = 0; i < PerRow; i++) {
            if (BLACK_AT[i - (i / 7) * 7] < 0) continue;
            semi = black_semi_at(row, i);
            black_rect(row, i, r);
            ui_2d_rect(r[0], r[1] + 2, r[2], r[3], 0xFF000000, 1, 0, 5);
            if (Ref[semi] > 0) {
                ui_2d_rect(r[0], r[1], r[2], r[3] - 2, 0xFF6A5A3A, 1, 0, 5);
            } else {
                ui_2d_rect(r[0], r[1], r[2], r[3], 0xFF1A1A20, 1, 0, 5);
            }
        }
    }

    ui_2d_present();
}

/* ══════════════════════════════════════════════════════════════════════
 * 命中
 * Hit-testing
 * ══════════════════════════════════════════════════════════════════════ */

/* 打中哪个键?返回**半音偏移**(相对 BaseNote,0..RowStep+12),-1 = 没打中。
 * Which key was hit? Returns the **semitone offset** (relative to BaseNote, 0..RowStep+12); -1 = nothing hit.
 * ⚠ **黑键优先** —— 它压在白键上方,先判白键会让黑键永远按不到。
 * ⚠ **Black keys first** — they sit on top of the white keys, so testing the white keys first would make the black keys unreachable forever.
 */
static int hit_key(int x, int y)
{
    int row;
    int i;
    int r[4];
    int w;

    if (y < KEYTOP) return -1;

    /* 落在哪一行?—— 按"行 + 缝"的周期算,再夹到合法行 */
    /* Which row did it land in? — compute it by the "row + gap" period, then clamp to a legal row */
    row = (y - KEYTOP) / (RowKeyH + RowGap);
    if (row < 0) row = 0;
    if (row >= RowCount) row = RowCount - 1;
    if (y >= row_top(row) + RowKeyH) return -1;   /* 正好落在两行之间那道缝里 */
                                                  /* it landed exactly in the gap between the two rows */

    /* 黑键优先 */
    /* Black keys first */
    for (i = 0; i < PerRow; i++) {
        if (BLACK_AT[i - (i / 7) * 7] < 0) continue;
        black_rect(row, i, r);
        if (x >= r[0] && x < r[0] + r[2] && y < r[1] + r[3]) return black_semi_at(row, i);
    }

    w = SW / PerRow;
    for (i = 0; i < PerRow; i++) {
        if (x >= i * w && x < (i + 1) * w) return white_semi_at(row, i);
    }
    return -1;
}

static int hit_btn(int x, int y)
{
    int i;
    if (y < BtnY || y >= BtnY + BtnH) return -1;
    for (i = 0; i < 5; i++) {
        if (x >= BtnX[i] && x < BtnX[i] + BtnW) return i;
    }
    return -1;
}

/* ══════════════════════════════════════════════════════════════════════
 * 示范曲:**记谱字符串**(音名 + 时值字母,`.` = 休止)
 * Demo song: a **notation string** (note names + duration letters, `.` = rest)
 *
 * 用字符串而不是 int 表,是因为 `#define` 不支持续行 ⇒ 一张几百项的 int 表
 * A string is used instead of an int table because `#define` does not support line continuation ⇒ a several-hundred-entry int table
 * 只能塞在一行里,写起来和改起来都是灾难;字符串天然可以按乐句分行。
 * could only be squeezed onto a single line, a disaster to write and to edit; a string can naturally be split by phrase.
 * ══════════════════════════════════════════════════════════════════════ */

static int name_to_semi(char c)
{
    if (c == 'C') return 0;
    if (c == 'D') return 2;
    if (c == 'E') return 4;
    if (c == 'F') return 5;
    if (c == 'G') return 7;
    if (c == 'A') return 9;
    if (c == 'B') return 11;
    return -1;
}

/* 取下一个音。返回 0 = 曲子完了。`note` 填 -1 表示休止。 */
/* Take the next note. Returns 0 = the song is finished. `note` set to -1 means a rest. */
static int song_next(int* note, int* hold)
{
    char c;
    int semi;

    while (SONG[SongPos] != 0) {
        c = SONG[SongPos];
        SongPos = SongPos + 1;
        if (c == ' ') continue;
        if (c == '.') { *note = -1; *hold = 300; return 1; }
        semi = name_to_semi(c);
        if (semi < 0) continue;
        *note = 60 + semi;          /* 示范曲固定在中央八度,与当前移调无关 */
                                    /* the demo song stays in the middle octave, independent of the current transposition */
        *hold = 380;
        if (SONG[SongPos] == 'h') { *hold = 760; SongPos = SongPos + 1; }
        else if (SONG[SongPos] == 'q') { SongPos = SongPos + 1; }
        return 1;
    }
    return 0;
}

/* ══════════════════════════════════════════════════════════════════════
 * 主循环
 * Main loop
 * ══════════════════════════════════════════════════════════════════════ */

/* 按钮的动作。
 * The button actions.
 * ⚠ **判定**(在轮询里取"按下沿")与**动作**分开:判定只能有一处,动作也只写一遍。
 * ⚠ **Detection** (taking the "pressed edge" in the polling loop) is kept apart from **action**: detection has exactly one place, and the action is written only once.
 */
static void apply_btn(int btn)
{
    int i;

    if (btn == 0 || btn == 1) {
        /* 移调前先把响着的都关掉 —— 否则那些音的"相对偏移"会跟着基准一起漂,
         * Shut down everything that is sounding before transposing — otherwise those notes' "relative offsets" drift along with the base,
         * 引用计数就成了错的(关音时会去减另一个键的账)。
         * and the reference counts become wrong (note-off would subtract from another key's account).
         */
        for (i = 0; i < 10; i++) {
            if (KeyDown[i] >= 0) { p_off(i, KeyDown[i]); KeyDown[i] = -1; }
        }
        for (i = 0; i < 26; i++) Ref[i] = 0;
        BaseNote = BaseNote + (btn == 0 ? -RowStep : RowStep);
        if (BaseNote < 36) BaseNote = 36;
        if (BaseNote > 84 - RowStep) BaseNote = 84 - RowStep;
    } else if (btn == 2) {
        if (SongOn) {
            SongOn = 0;
            if (SongNote >= 0) { p_off(0, SongNote); SongNote = -1; }
        } else {
            SongOn = 1; SongPos = 0; SongTick = 0;
            SongNote = -1; SongOffAt = 0; SongNextAt = 0;
        }
    } else if (btn == 3) {
        RecOn = 1 - RecOn;
        if (RecOn) RecN = 0;
    } else if (btn == 4) {
        /* **强制清除**(用户要的"急停"):把所有正在响的音立刻掐掉。
         * **Force clear** (the "emergency stop" the user asked for): cut off every sounding note immediately.
         *
         * ⚠ 三件事必须**一起**做,少一件这个键就是假的:
         * ⚠ Three things must be done **together**; leave one out and this key is a fake:
         *   ① `ui_tone_panic()` —— 立刻全停(**不是** `ui_tone_off_all()` 的淡出:
         *   ① `ui_tone_panic()` — stop everything at once (**not** the fade-out of `ui_tone_off_all()`:
         *      这个键的语义就是"马上安静",卡住的音再拖 90ms 没有意义);
         *      this key's whole point is "silence right now", and dragging a stuck note on for another 90ms means nothing);
         *   ② 清**引用计数** —— 不清的话,抬起手指时会去减一个早就清空的账,
         *   ② Clear the **reference counts** — otherwise, lifting a finger subtracts from an account that was emptied long ago,
         *      越减越负,之后那个键就再也起不来音了;
         *      going further and further negative, and after that the key can never sound again;
         *   ③ 清槽位的记账 —— 让"这一拍手指还按着"被当成**新按下**(重新起音)。
         *   ③ Clear the slot bookkeeping — so that "the finger is still holding on this tick" counts as a **fresh press** (retriggering the note).
         *      于是:还按着的手指会继续弹(那是它本来就该做的),
         *      The result: fingers still held keep playing (which is what they should do anyway),
         *      而**已经卡住/松开了却没收到 KeyUp 的槽位**从此不再发声。
         *      while **slots that are stuck, or were released without a KeyUp** stop sounding from then on.
         */
        for (i = 0; i < 26; i++) Ref[i] = 0;
        for (i = 0; i < 10; i++) KeyDown[i] = -1;
        ui_tone_panic();
    }
}

int main(void)
{
    int m[4];
    int t[3];
    int i;
    int slot;
    int semi;
    int note;
    int msgType;
    int n;
    int hold;
    int dirty;
    int hit;
    int btn;
    int perRowOct;

    WHITE_SEMI[0] = 0; WHITE_SEMI[1] = 2; WHITE_SEMI[2] = 4; WHITE_SEMI[3] = 5;
    WHITE_SEMI[4] = 7; WHITE_SEMI[5] = 9; WHITE_SEMI[6] = 11;
    BLACK_AT[0] = 0; BLACK_AT[1] = 1; BLACK_AT[2] = -1; BLACK_AT[3] = 3;
    BLACK_AT[4] = 4; BLACK_AT[5] = 5; BLACK_AT[6] = -1;
    BLACK_SEMI[0] = 1; BLACK_SEMI[1] = 3; BLACK_SEMI[2] = -1; BLACK_SEMI[3] = 6;
    BLACK_SEMI[4] = 8; BLACK_SEMI[5] = 10; BLACK_SEMI[6] = -1;

    /* 音名表(标在白键底部)—— 12 个半音,只有 5 个带升号 */
    /* Note-name table (drawn at the bottom of the white keys) — 12 semitones, only 5 of which carry a sharp */
    NAME0[0] = 'C'; NAME0[1] = 'C'; NAME0[2] = 'D'; NAME0[3] = 'D';
    NAME0[4] = 'E'; NAME0[5] = 'F'; NAME0[6] = 'F'; NAME0[7] = 'G';
    NAME0[8] = 'G'; NAME0[9] = 'A'; NAME0[10] = 'A'; NAME0[11] = 'B';
    NAME1[0] = 0; NAME1[1] = '#'; NAME1[2] = 0; NAME1[3] = '#';
    NAME1[4] = 0; NAME1[5] = 0; NAME1[6] = '#'; NAME1[7] = 0;
    NAME1[8] = '#'; NAME1[9] = 0; NAME1[10] = '#'; NAME1[11] = 0;

    for (i = 0; i < 10; i++) KeyDown[i] = -1;
    for (i = 0; i < 26; i++) Ref[i] = 0;

    BaseNote = 60;
    RecOn = 0; RecN = 0;
    SongOn = 0; SongPos = 0; SongTick = 0; SongNote = -1; SongOffAt = 0; SongNextAt = 0;
    SONG = "Cq Cq Gq Gq Aq Aq Gh Fq Fq Eq Eq Dq Dq Ch Gq Gq Fq Fq Eq Eq Dh Gq Gq Fq Fq Eq Eq Dh Cq Cq Gq Gq Aq Aq Gh Fq Fq Eq Eq Dq Dq Ch";

    Lang = ui_scr_get_language();
    ui_win_open_ex(Lang == 0 ? "钢琴" : "Piano", 0, 0, VML_WIN_PORTRAIT, VML_WIN_NO_GAMEPAD);

    SW = ui_scr_get_w();
    SH = ui_scr_get_h();
    if (SW <= 0) SW = 360;
    if (SH <= 0) SH = 620;

    /* ── 每行放几个白键:按屏宽自适应 ──
     * ── How many white keys go in a row: adapted to the screen width ──
     * 每键至少约 44dp 手指才按得准;取 **7 的整数倍** 保证一行是完整八度。
     * Each key at least ~44dp so a finger can hit it accurately; round to a **multiple of 7** so a row is complete octaves.
     */
    PerRow = SW / 44;
    PerRow = (PerRow / 7) * 7;
    if (PerRow < 7) PerRow = 7;
    if (PerRow > 14) PerRow = 14;

    /* ── 两行:音域翻倍,且每行只剩一半高 ⇒ 键不再是个竖长条 ── */
    /* ── Two rows: the range doubles, and each row is only half as tall ⇒ the keys are no longer vertical strips ── */
    RowCount = 2;
    perRowOct = PerRow / 7;             /* 每行有几个八度 */
                                        /* how many octaves per row */
    RowStep = 12 * perRowOct;           /* 上行比下行高这么多半音 */
                                        /* the upper row is this many semitones above the lower one */

    BARH = 54;
    KEYTOP = BARH + 10;
    RowGap = 6;
    RowKeyH = (SH - KEYTOP - 20 - RowGap * (RowCount - 1)) / RowCount;

    /* ⚠ 再按宽高比收一道:屏幕很高时(平板竖放)别又把键拉成长条。
     * ⚠ One more cap, on the aspect ratio: on a very tall screen (a tablet held upright) don't stretch the keys into strips again.
     *   真实琴键的宽高比大约 1:5~1:6,这里取 6 作上限。
     *   A real piano key's aspect ratio is about 1:5~1:6; 6 is taken as the cap here.
     */
    if (RowKeyH > (SW / PerRow) * 6) RowKeyH = (SW / PerRow) * 6;
    if (RowKeyH < 60) RowKeyH = 60;     /* 太矮就按不准了 */
                                        /* too short and you cannot hit it accurately */

    /* 5 个按钮:`- + 示范 录音 清音`。窄屏上按 4 个的宽度放不下,所以键宽按屏宽算。 */
    /* 5 buttons: `- + demo record clear`. Four buttons' worth of width does not fit on a narrow screen, so the key width follows the screen width. */
    BtnH = 32;
    BtnY = (BARH - BtnH) / 2;
    BtnW = (SW - 60) / 5;               /* 左边留出放「钢琴 + 音域」的地方 */
                                        /* leave room on the left for the "Piano + range" text */
    if (BtnW > 52) BtnW = 52;           /* 宽屏上别拉太宽 */
                                        /* don't stretch it too wide on a wide screen */
    if (BtnW < 40) BtnW = 40;           /* 太窄字就挤没了 */
                                        /* too narrow and the text gets squeezed away */
    BtnX[0] = SW - 5 * BtnW - 20;
    BtnX[1] = BtnX[0] + BtnW + 4;
    BtnX[2] = BtnX[1] + BtnW + 6;
    BtnX[3] = BtnX[2] + BtnW + 4;
    BtnX[4] = BtnX[3] + BtnW + 6;
    ui_scr_set_keep_on(1);
    draw();

    while (ui_win_is_closed() == 0) {
        msgType = ui_msg_wait(m, 16);
        if (msgType == VML_MSG_WINDOWCLOSE) break;

        dirty = 0;

        /* ── 琴键与按钮:**都在轮询里判** ────────────────────────────── */
        /* ── Keys and buttons: **both are decided in the polling loop** ────────────────────────────── */
        for (slot = 0; slot < 10; slot++) {
            if (ui_touch_read(slot, t) == 0) continue;

            /* 按钮:取**按下沿**(这一拍按下、上一拍没按)。
             * Buttons: take the **pressed edge** (pressed this tick, not pressed on the previous one).
             *
             * ⚠ 先前只在 slot 0 的**事件消息**里判按钮,于是
             * ⚠ Buttons used to be tested only in slot 0's **event message**, so
             *   "一根手指按着琴键、另一根手指去点清音"——**最需要急停的那个场合**——
             *   "one finger holding a key while another finger taps clear" — **the exact situation that most needs an emergency stop** —
             *   反而点不到:事件消息只有 slot 0 会投,而 slot 0 正按着琴键。
             *   could not be tapped at all: only slot 0 receives event messages, and slot 0 was holding a key.
             *   统一到轮询之后就都点得到了(16ms 一拍,人手点击至少 50ms,不会漏)。
             *   Once unified into the polling loop they are all reachable (16ms per tick, and a human tap lasts at least 50ms, so none are missed).
             */
            if (t[2] != 0 && PrevDown[slot] == 0) {
                btn = hit_btn(t[0], t[1]);
                if (btn >= 0) {
                    apply_btn(btn);
                    dirty = 1;
                    PrevDown[slot] = 1;
                    continue;               /* 这一拍就不当琴键处理了 */
                                            /* this tick is no longer treated as a key press */
                }
            }
            PrevDown[slot] = t[2] != 0;

            if (t[2] != 0) {
                semi = hit_key(t[0], t[1]);
            } else {
                semi = -1;                                      /* 抬起了 */
                                                                /* lifted */
            }

            note = semi < 0 ? -1 : BaseNote + semi;
            if (note != KeyDown[slot]) {
                /* 这个槽位换了音(含抬起):先把上一个音还回去 */
                /* This slot changed note (including lifting): give the previous note back first */
                if (KeyDown[slot] >= 0) {
                    hit = KeyDown[slot] - BaseNote;
                    if (hit >= 0 && hit < 26) {
                        if (Ref[hit] > 0) Ref[hit] = Ref[hit] - 1;
                        /* ⚠ **归零才真的关** —— 两根手指按同一个键时,
                         * ⚠ **Only reaching zero really stops it** — when two fingers hold the same key,
                         *   先抬起的那根不该把还按着的那根的音关掉
                         *   the finger that lifts first must not stop the note the other one is still holding
                         */
                        if (Ref[hit] == 0) p_off(slot, KeyDown[slot]);
                    }
                }
                KeyDown[slot] = note;
                if (note >= 0 && semi >= 0 && semi < 26) {
                    if (Ref[semi] == 0) p_on(slot, note, 100);
                    Ref[semi] = Ref[semi] + 1;
                }
                dirty = 1;
            }
        }

        /* ── 示范曲推进(单声部,起音与关音各按自己的拍子)──────────── */
        /* ── Demo-song advance (single voice; note-on and note-off each keep their own beat) ──────────── */
        if (SongOn) {
            SongTick = SongTick + 16;
            if (SongNote >= 0 && SongTick >= SongOffAt) {
                p_off(0, SongNote);
                SongNote = -1;
            }
            if (SongTick >= SongNextAt) {
                if (song_next(&n, &hold)) {
                    if (n >= 0) {
                        p_on(0, n, 96);
                        SongNote = n;
                        SongOffAt = SongTick + hold * 3 / 4;   /* 留 1/4 空隙,音才分得开 */
                                                              /* leave a 1/4 gap so the notes come out separated */
                    }
                    SongNextAt = SongTick + hold;
                } else {
                    SongOn = 0;
                    if (SongNote >= 0) { p_off(0, SongNote); SongNote = -1; }
                    dirty = 1;
                }
            }
        }

        if (dirty) draw();
    }

    for (i = 0; i < 10; i++) {
        if (KeyDown[i] >= 0) p_off(i, KeyDown[i]);
    }
    ui_tone_off_all();
    ui_scr_set_keep_on(0);
    ui_win_close();
    return 0;
}