From 6f95c60381d847e2a8370dd4749e11d15eb3a368 Mon Sep 17 00:00:00 2001 From: Max Amundsen Date: Wed, 4 Mar 2026 01:52:04 -0500 Subject: [PATCH] Add keyboard / sample edit view with piano widget --- src/main.cpp | 208 +++++++++++++++++++++++++++++++------ src/midi/midi.h | 5 + src/midi/midi_coremidi.cpp | 32 ++++++ src/midi/midi_win32.cpp | 27 +++++ 4 files changed, 239 insertions(+), 33 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index a3f37c0..46e024c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -79,7 +79,11 @@ struct AppState { #endif // Tab state - S32 right_panel_tab; // 0 = Properties, 1 = MIDI Devices + S32 right_panel_tab; // 0 = Properties, 1 = MIDI Devices + S32 bottom_panel_tab; // 0 = Item Editor, 1 = Sample Mapper + + // Piano state + S32 piano_mouse_note; // MIDI note held by mouse click (-1 = none) // Demo widget state B32 demo_checkbox_a; @@ -138,6 +142,68 @@ struct AppState { F32 panel_drag_start_size; // panel size when drag started }; +//////////////////////////////// +// Piano helpers + +#define PIANO_FIRST_NOTE 21 // A0 +#define PIANO_LAST_NOTE 108 // C8 +#define PIANO_BLACK_W 11.0f +#define PIANO_BLACK_H_PCT 0.6f + +static bool piano_is_black_key(int note) { + int n = note % 12; + return n == 1 || n == 3 || n == 6 || n == 8 || n == 10; +} + +// Velocity-based color: blue (vel 0) → green (mid) → red (vel 127) +static Clay_Color velocity_color(int32_t velocity) { + float t = (float)velocity / 127.0f; + float r, g, b; + if (t < 0.5f) { + float s = t * 2.0f; + r = 40.0f + s * (76.0f - 40.0f); + g = 120.0f + s * (175.0f - 120.0f); + b = 220.0f + s * (80.0f - 220.0f); + } else { + float s = (t - 0.5f) * 2.0f; + r = 76.0f + s * (220.0f - 76.0f); + g = 175.0f + s * (50.0f - 175.0f); + b = 80.0f + s * (40.0f - 80.0f); + } + return Clay_Color{r, g, b, 255}; +} + +// Piano input: handle mouse clicks on piano keys +static void update_piano_input(AppState *app) { + PlatformInput input = g_wstate.input; + + if (!input.mouse_down) { + app->piano_mouse_note = -1; + return; + } + + // Find hovered piano key — check black keys first (they're on top) + S32 hovered_note = -1; + for (int note = PIANO_FIRST_NOTE; note <= PIANO_LAST_NOTE; note++) { + if (piano_is_black_key(note) && Clay_PointerOver(CLAY_IDI("PKey", note))) { + hovered_note = note; + break; + } + } + if (hovered_note == -1) { + for (int note = PIANO_FIRST_NOTE; note <= PIANO_LAST_NOTE; note++) { + if (!piano_is_black_key(note) && Clay_PointerOver(CLAY_IDI("PKey", note))) { + hovered_note = note; + break; + } + } + } + + if (hovered_note != -1) { + app->piano_mouse_note = hovered_note; + } +} + //////////////////////////////// // Panel builders @@ -574,20 +640,7 @@ static void build_right_panel(AppState *app) { // Idle = dark gray Clay_Color box_color; if (dev->active) { - float t = (float)dev->velocity / 127.0f; - float r, g, b; - if (t < 0.5f) { - float s = t * 2.0f; - r = 40.0f + s * (76.0f - 40.0f); - g = 120.0f + s * (175.0f - 120.0f); - b = 220.0f + s * (80.0f - 220.0f); - } else { - float s = (t - 0.5f) * 2.0f; - r = 76.0f + s * (220.0f - 76.0f); - g = 175.0f + s * (50.0f - 175.0f); - b = 80.0f + s * (40.0f - 80.0f); - } - box_color = Clay_Color{r, g, b, 255}; + box_color = velocity_color(dev->velocity); } else if (dev->releasing) { box_color = Clay_Color{255, 255, 255, 255}; } else { @@ -696,26 +749,112 @@ static void build_log_panel(AppState *app) { .backgroundColor = lp_top, .custom = { .customData = lp_grad }, ) { - { - S32 sel = 0; - static const char *log_tabs[] = { "Log" }; - ui_tab_bar("LogTabRow", log_tabs, 1, &sel); - } + static const char *bottom_tabs[] = { "Item Editor", "Sample Mapper" }; + ui_tab_bar("BottomTabRow", bottom_tabs, 2, &app->bottom_panel_tab); - CLAY(CLAY_ID("LogContent"), - .layout = { - .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_GROW() }, - .padding = { uip(8), uip(8), uip(6), uip(6) }, - .childGap = uip(4), - .layoutDirection = CLAY_TOP_TO_BOTTOM, + if (app->bottom_panel_tab == 0) { + // Item Editor tab + CLAY(CLAY_ID("ItemEditorContent"), + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_GROW() }, + .padding = { uip(8), uip(8), uip(6), uip(6) }, + .childGap = uip(4), + .layoutDirection = CLAY_TOP_TO_BOTTOM, + } + ) { + CLAY(CLAY_ID("ItemEditorHighlight"), + .layout = { .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(1) } }, + .backgroundColor = g_theme.bg_lighter + ) {} + CLAY_TEXT(CLAY_STRING("Item Editor"), &g_text_config_normal); + } + } else { + // Sample Mapper tab — 88-key piano + CLAY(CLAY_ID("SampleMapperContent"), + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_GROW() }, + .padding = { uip(4), uip(4), uip(4), uip(4) }, + .layoutDirection = CLAY_TOP_TO_BOTTOM, + } + ) { + Clay_ElementId piano_id = CLAY_ID("PianoContainer"); + CLAY(piano_id, + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_GROW() }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + } + ) { + // Compute black key size proportional to white keys + F32 piano_avail_h = uis(app->log_height) - TAB_HEIGHT - uip(8); + F32 black_key_h = piano_avail_h * PIANO_BLACK_H_PCT; + if (black_key_h < uis(20)) black_key_h = uis(20); + + F32 white_key_w = ((F32)app->last_w - uip(8)) / 52.0f; + F32 black_key_w = white_key_w * 0.6f; + if (black_key_w < uis(8)) black_key_w = uis(8); + + // White keys (grow to fill width and height) + for (int note = PIANO_FIRST_NOTE; note <= PIANO_LAST_NOTE; note++) { + if (piano_is_black_key(note)) continue; + + B32 midi_held = midi_is_note_held(app->midi, note); + B32 mouse_held = app->piano_mouse_note == note; + Clay_Color bg; + if (midi_held) { + bg = velocity_color(midi_get_note_velocity(app->midi, note)); + } else if (mouse_held) { + bg = g_theme.accent; + } else { + bg = Clay_Color{240, 240, 240, 255}; + } + + CLAY(CLAY_IDI("PKey", note), + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_GROW() }, + }, + .backgroundColor = bg, + .border = { .color = {190, 190, 190, 255}, .width = { .right = 1 } }, + ) {} + } + + // Black keys (floating, attached to left white key) + for (int note = PIANO_FIRST_NOTE; note <= PIANO_LAST_NOTE; note++) { + if (!piano_is_black_key(note)) continue; + + Clay_ElementId parent_wkey = CLAY_IDI("PKey", note - 1); + B32 midi_held = midi_is_note_held(app->midi, note); + B32 mouse_held = app->piano_mouse_note == note; + Clay_Color bg; + if (midi_held) { + bg = velocity_color(midi_get_note_velocity(app->midi, note)); + } else if (mouse_held) { + bg = g_theme.accent; + } else { + bg = Clay_Color{25, 25, 30, 255}; + } + + CLAY(CLAY_IDI("PKey", note), + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED(black_key_w), + .height = CLAY_SIZING_FIXED(black_key_h), + }, + }, + .backgroundColor = bg, + .cornerRadius = { .topLeft = 0, .topRight = 0, .bottomLeft = uis(2), .bottomRight = uis(2) }, + .floating = { + .parentId = parent_wkey.id, + .zIndex = 100, + .attachPoints = { + .element = CLAY_ATTACH_POINT_CENTER_TOP, + .parent = CLAY_ATTACH_POINT_RIGHT_TOP, + }, + .attachTo = CLAY_ATTACH_TO_ELEMENT_WITH_ID, + }, + ) {} + } + } } - ) { - // Top highlight (beveled edge) - CLAY(CLAY_ID("LogHighlight"), - .layout = { .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(1) } }, - .backgroundColor = g_theme.bg_lighter - ) {} - CLAY_TEXT(CLAY_STRING("Output / Log"), &g_text_config_normal); } } } @@ -1068,6 +1207,7 @@ static void do_frame(AppState *app) { } ui_widgets_begin_frame(input); + update_piano_input(app); update_panel_splitters(app); // Build UI with Clay @@ -1147,6 +1287,8 @@ int main(int argc, char **argv) { app.show_props = 1; app.show_log = 1; app.show_midi_devices = 1; + app.bottom_panel_tab = 0; + app.piano_mouse_note = -1; app.demo_knob_unsigned = 75.0f; app.demo_knob_signed = 0.0f; app.demo_slider_h = 50.0f; diff --git a/src/midi/midi.h b/src/midi/midi.h index 311baed..bae3367 100644 --- a/src/midi/midi.h +++ b/src/midi/midi.h @@ -25,3 +25,8 @@ void midi_open_all_inputs(MidiEngine *engine); void midi_close_all_inputs(MidiEngine *engine); void midi_update(MidiEngine *engine, float dt); bool midi_is_input_active(MidiEngine *engine, int32_t device_index); + +// Per-note state: returns true if note (0-127) is currently held on any input device +bool midi_is_note_held(MidiEngine *engine, int32_t note); +// Returns the last note-on velocity (0-127) for a given note, or 0 if not held +int32_t midi_get_note_velocity(MidiEngine *engine, int32_t note); diff --git a/src/midi/midi_coremidi.cpp b/src/midi/midi_coremidi.cpp index 4e4d551..05dd45d 100644 --- a/src/midi/midi_coremidi.cpp +++ b/src/midi/midi_coremidi.cpp @@ -23,6 +23,10 @@ struct MidiEngine { _Atomic int32_t pending_note_off[MIDI_MAX_DEVICES]; _Atomic int32_t held_note_count[MIDI_MAX_DEVICES]; + // Per-note state (across all devices), set atomically from callback + _Atomic int32_t note_states[128]; // held count + _Atomic int32_t note_velocities[128]; // last note-on velocity + // Main thread only int32_t display_velocity[MIDI_MAX_DEVICES]; int32_t display_note[MIDI_MAX_DEVICES]; @@ -59,17 +63,30 @@ static void midi_read_callback(const MIDIPacketList *pktlist, void *readProcRefC atomic_store(&engine->pending_note_on_vel[device_idx], (int32_t)velocity); atomic_store(&engine->pending_note_num[device_idx], (int32_t)(note + 1)); atomic_fetch_add(&engine->held_note_count[device_idx], 1); + if (note < 128) { + atomic_fetch_add(&engine->note_states[note], 1); + atomic_store(&engine->note_velocities[note], (int32_t)velocity); + } } else { // Note-on with velocity 0 = note-off atomic_store(&engine->pending_note_off[device_idx], 1); int32_t count = atomic_fetch_sub(&engine->held_note_count[device_idx], 1); if (count <= 1) atomic_store(&engine->held_note_count[device_idx], 0); + if (note < 128) { + int32_t c = atomic_fetch_sub(&engine->note_states[note], 1); + if (c <= 1) atomic_store(&engine->note_states[note], 0); + } } } else if (kind == 0x80 && j + 2 < packet->length) { + uint8_t note_off = packet->data[j + 1]; j += 3; atomic_store(&engine->pending_note_off[device_idx], 1); int32_t count = atomic_fetch_sub(&engine->held_note_count[device_idx], 1); if (count <= 1) atomic_store(&engine->held_note_count[device_idx], 0); + if (note_off < 128) { + int32_t c = atomic_fetch_sub(&engine->note_states[note_off], 1); + if (c <= 1) atomic_store(&engine->note_states[note_off], 0); + } } else if (kind == 0xC0 || kind == 0xD0) { j += 2; // Program Change, Channel Pressure (2 bytes) } else if (kind == 0xF0) { @@ -189,6 +206,10 @@ void midi_close_all_inputs(MidiEngine *engine) { engine->display_note[i] = 0; engine->release_timers[i] = 0.0f; } + for (int32_t i = 0; i < 128; i++) { + atomic_store(&engine->note_states[i], 0); + atomic_store(&engine->note_velocities[i], 0); + } } void midi_update(MidiEngine *engine, float dt) { @@ -233,6 +254,17 @@ bool midi_is_input_active(MidiEngine *engine, int32_t device_index) { return engine->devices[device_index].active; } +bool midi_is_note_held(MidiEngine *engine, int32_t note) { + if (note < 0 || note > 127) return false; + return atomic_load(&engine->note_states[note]) > 0; +} + +int32_t midi_get_note_velocity(MidiEngine *engine, int32_t note) { + if (note < 0 || note > 127) return 0; + if (atomic_load(&engine->note_states[note]) <= 0) return 0; + return atomic_load(&engine->note_velocities[note]); +} + void midi_refresh_devices(MidiEngine *engine) { midi_close_all_inputs(engine); enumerate_midi_devices(engine); diff --git a/src/midi/midi_win32.cpp b/src/midi/midi_win32.cpp index fdea168..f157fff 100644 --- a/src/midi/midi_win32.cpp +++ b/src/midi/midi_win32.cpp @@ -18,6 +18,10 @@ struct MidiEngine { volatile LONG pending_note_off[MIDI_MAX_DEVICES]; // note-off received flag volatile LONG held_note_count[MIDI_MAX_DEVICES]; // number of notes currently held + // Per-note state (across all devices), set atomically from callback + volatile LONG note_states[128]; // held count + volatile LONG note_velocities[128]; // last note-on velocity + // Main thread only int32_t display_velocity[MIDI_MAX_DEVICES]; int32_t display_note[MIDI_MAX_DEVICES]; @@ -50,12 +54,20 @@ static void CALLBACK midi_in_callback(HMIDIIN hMidiIn, UINT wMsg, DWORD_PTR dwIn InterlockedExchange(&g_midi_engine->pending_note_on_vel[idx], (LONG)velocity); InterlockedExchange(&g_midi_engine->pending_note_num[idx], (LONG)(note + 1)); // +1 so 0 means "no pending" InterlockedIncrement(&g_midi_engine->held_note_count[idx]); + if (note < 128) { + InterlockedIncrement(&g_midi_engine->note_states[note]); + InterlockedExchange(&g_midi_engine->note_velocities[note], (LONG)velocity); + } } // Note-off (0x80) or note-on with velocity 0 (running status note-off) else if (kind == 0x80 || (kind == 0x90 && velocity == 0)) { InterlockedExchange(&g_midi_engine->pending_note_off[idx], 1); LONG count = InterlockedDecrement(&g_midi_engine->held_note_count[idx]); if (count < 0) InterlockedExchange(&g_midi_engine->held_note_count[idx], 0); + if (note < 128) { + LONG c = InterlockedDecrement(&g_midi_engine->note_states[note]); + if (c < 0) InterlockedExchange(&g_midi_engine->note_states[note], 0); + } } } @@ -105,6 +117,10 @@ void midi_close_all_inputs(MidiEngine *engine) { engine->display_note[i] = 0; engine->release_timers[i] = 0.0f; } + for (int32_t i = 0; i < 128; i++) { + engine->note_states[i] = 0; + engine->note_velocities[i] = 0; + } } void midi_update(MidiEngine *engine, float dt) { @@ -160,6 +176,17 @@ bool midi_is_input_active(MidiEngine *engine, int32_t device_index) { return engine->devices[device_index].active; } +bool midi_is_note_held(MidiEngine *engine, int32_t note) { + if (note < 0 || note > 127) return false; + return engine->note_states[note] > 0; +} + +int32_t midi_get_note_velocity(MidiEngine *engine, int32_t note) { + if (note < 0 || note > 127) return 0; + if (engine->note_states[note] <= 0) return 0; + return (int32_t)engine->note_velocities[note]; +} + void midi_refresh_devices(MidiEngine *engine) { midi_close_all_inputs(engine); engine->device_count = 0;