From 994c32c09d245d3879edc2023cfaff7832f406e6 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Fri, 27 Mar 2026 15:37:34 +0100 Subject: [PATCH] feat: add Music app features, fix Window Server bug --- kernel/src/Api/WinServer.cpp | 177 +++++++++----- programs/src/desktop/main.cpp | 10 + programs/src/music/main.cpp | 378 ++++++++++++++++++++++++++---- programs/src/music/visualizer.cpp | 217 ++++++++++++----- programs/src/music/visualizer.hpp | 14 +- 5 files changed, 623 insertions(+), 173 deletions(-) diff --git a/kernel/src/Api/WinServer.cpp b/kernel/src/Api/WinServer.cpp index 4e42226..c60e0ed 100644 --- a/kernel/src/Api/WinServer.cpp +++ b/kernel/src/Api/WinServer.cpp @@ -12,12 +12,24 @@ #include #include #include +#include namespace WinServer { static WindowSlot g_slots[MaxWindows]; static int g_uiScale = 1; static kcp::Mutex wsLock; + static constexpr uint64_t RetireGraceMs = 2000; + static constexpr int MaxRetiredBatches = MaxWindows * 4; + + struct RetiredBatch { + bool used; + int numPages; + uint64_t freeAfterMs; + uint64_t physPages[MaxPixelPages]; + }; + + static RetiredBatch g_retired[MaxRetiredBatches]; // RAII lock guard for WinServer operations struct WsGuard { @@ -25,9 +37,85 @@ namespace WinServer { ~WsGuard() { wsLock.Release(); } }; + static void FreePageBatchLocked(uint64_t* physPages, int numPages) { + for (int i = 0; i < numPages; i++) { + if (physPages[i] != 0) { + Memory::g_pfa->Free((void*)Memory::HHDM(physPages[i])); + physPages[i] = 0; + } + } + } + + static void ProcessRetiredLocked() { + uint64_t now = Timekeeping::GetMilliseconds(); + for (int i = 0; i < MaxRetiredBatches; i++) { + if (!g_retired[i].used) continue; + if (now < g_retired[i].freeAfterMs) continue; + FreePageBatchLocked(g_retired[i].physPages, g_retired[i].numPages); + g_retired[i].used = false; + g_retired[i].numPages = 0; + g_retired[i].freeAfterMs = 0; + } + } + + static void RetirePageBatchLocked(uint64_t* physPages, int numPages) { + if (numPages <= 0) return; + + int retireIdx = -1; + for (int i = 0; i < MaxRetiredBatches; i++) { + if (!g_retired[i].used) { + retireIdx = i; + break; + } + } + + if (retireIdx < 0) { + ProcessRetiredLocked(); + for (int i = 0; i < MaxRetiredBatches; i++) { + if (!g_retired[i].used) { + retireIdx = i; + break; + } + } + } + + if (retireIdx < 0) { + uint64_t oldest = ~0ULL; + for (int i = 0; i < MaxRetiredBatches; i++) { + if (g_retired[i].freeAfterMs < oldest) { + oldest = g_retired[i].freeAfterMs; + retireIdx = i; + } + } + if (retireIdx >= 0) { + Kt::KernelLogStream(Kt::ERROR, "WinServer") + << "Retire queue full, forcing early free of stale window pages"; + FreePageBatchLocked(g_retired[retireIdx].physPages, g_retired[retireIdx].numPages); + g_retired[retireIdx].used = false; + g_retired[retireIdx].numPages = 0; + g_retired[retireIdx].freeAfterMs = 0; + } + } + + if (retireIdx < 0) { + FreePageBatchLocked(physPages, numPages); + return; + } + + RetiredBatch& batch = g_retired[retireIdx]; + batch.used = true; + batch.numPages = numPages; + batch.freeAfterMs = Timekeeping::GetMilliseconds() + RetireGraceMs; + for (int i = 0; i < numPages; i++) { + batch.physPages[i] = physPages[i]; + physPages[i] = 0; + } + } + int Create(int ownerPid, uint64_t ownerPml4, const char* title, int w, int h, uint64_t& heapNext, uint64_t& outVa) { WsGuard guard; + ProcessRetiredLocked(); // Find a free slot int slotIdx = -1; for (int i = 0; i < MaxWindows; i++) { @@ -102,20 +190,15 @@ namespace WinServer { int Destroy(int windowId, int callerPid) { WsGuard guard; + ProcessRetiredLocked(); if (windowId < 0 || windowId >= MaxWindows) return -1; WindowSlot& slot = g_slots[windowId]; if (!slot.used || slot.ownerPid != callerPid) return -1; - // Do NOT unmap snapshot pages from the desktop here -- the desktop - // may be running on another CPU with stale TLB entries. Unmapping - // causes a page fault when the TLB entry is evicted. Just free the - // physical pages (PTEs stay present, pointing to freed memory -- - // the desktop reads garbage for at most one frame before its next - // Enumerate sees the window is gone). FreeUserHalf reclaims the - // desktop's page table entries when the desktop eventually exits. - // Unmap LIVE pixel pages from the owner's address space so that // FreeUserHalf() won't double-free them when the process exits. + // Physical page reclamation is deferred below to avoid stale-TLB + // consumers reading freed memory. { auto* ownerProc = Sched::GetProcessByPid(slot.ownerPid); if (ownerProc) { @@ -127,23 +210,26 @@ namespace WinServer { } } - // Free physical pixel pages and snapshot pages - for (int i = 0; i < slot.pixelNumPages; i++) { - if (slot.pixelPhysPages[i] != 0) { - Memory::g_pfa->Free((void*)Memory::HHDM(slot.pixelPhysPages[i])); - } - if (slot.snapshotPhysPages[i] != 0) { - Memory::g_pfa->Free((void*)Memory::HHDM(slot.snapshotPhysPages[i])); - } - } + // Retire both the owner-facing live pages and the compositor-facing + // snapshot pages. The desktop can still hold stale mappings to the + // snapshot pages for a short time after Enumerate notices the window + // is gone, so freeing them immediately turns that stale mapping into + // a use-after-free. + RetirePageBatchLocked(slot.pixelPhysPages, slot.pixelNumPages); + RetirePageBatchLocked(slot.snapshotPhysPages, slot.pixelNumPages); slot.used = false; + slot.pixelNumPages = 0; + slot.ownerVa = 0; + slot.desktopVa = 0; + slot.desktopPid = 0; return 0; } int Present(int windowId, int callerPid) { // Validate ownership under the lock (brief) wsLock.Acquire(); + ProcessRetiredLocked(); if (windowId < 0 || windowId >= MaxWindows) { wsLock.Release(); return -1; } WindowSlot& slot = g_slots[windowId]; if (!slot.used || slot.ownerPid != callerPid) { wsLock.Release(); return -1; } @@ -170,6 +256,7 @@ namespace WinServer { int Poll(int windowId, int callerPid, Montauk::WinEvent* outEvent) { WsGuard guard; + ProcessRetiredLocked(); if (windowId < 0 || windowId >= MaxWindows) return -1; WindowSlot& slot = g_slots[windowId]; if (!slot.used || slot.ownerPid != callerPid) return -1; @@ -183,6 +270,7 @@ namespace WinServer { int Enumerate(Montauk::WinInfo* outArray, int maxCount) { WsGuard guard; + ProcessRetiredLocked(); int count = 0; for (int i = 0; i < MaxWindows && count < maxCount; i++) { if (!g_slots[i].used) continue; @@ -202,6 +290,7 @@ namespace WinServer { uint64_t Map(int windowId, int callerPid, uint64_t callerPml4, uint64_t& heapNext) { WsGuard guard; + ProcessRetiredLocked(); if (windowId < 0 || windowId >= MaxWindows) return 0; WindowSlot& slot = g_slots[windowId]; if (!slot.used) return 0; @@ -245,12 +334,14 @@ namespace WinServer { int SendEvent(int windowId, const Montauk::WinEvent* event) { WsGuard guard; + ProcessRetiredLocked(); return SendEventLocked(windowId, event); } int Resize(int windowId, int callerPid, uint64_t ownerPml4, int newW, int newH, uint64_t& heapNext, uint64_t& outVa) { WsGuard guard; + ProcessRetiredLocked(); if (windowId < 0 || windowId >= MaxWindows) return -1; WindowSlot& slot = g_slots[windowId]; if (!slot.used || slot.ownerPid != callerPid) return -1; @@ -264,34 +355,17 @@ namespace WinServer { int numPages = (int)((bufSize + 0xFFF) / 0x1000); if (numPages > MaxPixelPages) return -1; - // Do NOT unmap/free snapshot pages from the desktop here. - // The desktop may be running on another CPU with stale TLB entries - // pointing to the old snapshot pages. Unmapping + freeing them - // causes a page fault on the desktop when the TLB entry is evicted. - // Instead, leave them mapped -- the desktop will get new snapshot - // pages on its next Map call (desktopVa is invalidated below). - // The old snapshot pages remain in the desktop's page tables and - // are reclaimed by FreeUserHalf when the desktop exits. - - // Unmap old LIVE pixel pages from owner's address space and free them. - // Free old snapshot pages too (don't leak). The desktop's PTEs still - // point to the old physical addresses, but we don't clear them (no - // UnmapUserIn for desktop) to avoid TLB-eviction page faults. The - // desktop may read one frame of garbage from the freed pages before - // re-mapping -- acceptable during a resize. + // Unmap old LIVE pixel pages from the owner's address space. Actual + // physical page reclamation is deferred below for both live and + // snapshot pages so stale desktop mappings cannot turn into + // use-after-free reads during remap. int oldNumPages = slot.pixelNumPages; for (int i = 0; i < oldNumPages; i++) { Memory::VMM::Paging::UnmapUserIn( ownerPml4, slot.ownerVa + (uint64_t)i * 0x1000); - if (slot.pixelPhysPages[i] != 0) { - Memory::g_pfa->Free((void*)Memory::HHDM(slot.pixelPhysPages[i])); - slot.pixelPhysPages[i] = 0; - } - if (slot.snapshotPhysPages[i] != 0) { - Memory::g_pfa->Free((void*)Memory::HHDM(slot.snapshotPhysPages[i])); - slot.snapshotPhysPages[i] = 0; - } } + RetirePageBatchLocked(slot.pixelPhysPages, oldNumPages); + RetirePageBatchLocked(slot.snapshotPhysPages, oldNumPages); // Allocate new live + snapshot pages and map live into owner's address space uint64_t userVa = heapNext; @@ -325,6 +399,7 @@ namespace WinServer { int SetCursor(int windowId, int callerPid, int cursor) { WsGuard guard; + ProcessRetiredLocked(); if (windowId < 0 || windowId >= MaxWindows) return -1; WindowSlot& slot = g_slots[windowId]; if (!slot.used || slot.ownerPid != callerPid) return -1; @@ -334,6 +409,7 @@ namespace WinServer { int SetScale(int scale) { WsGuard guard; + ProcessRetiredLocked(); if (scale < 0) scale = 0; if (scale > 2) scale = 2; g_uiScale = scale; @@ -357,30 +433,25 @@ namespace WinServer { void CleanupProcess(int pid) { WsGuard guard; + ProcessRetiredLocked(); for (int i = 0; i < MaxWindows; i++) { if (g_slots[i].used && g_slots[i].ownerPid == pid) { Kt::KernelLogStream(Kt::INFO, "WinServer") << "Cleaning up window " << i << " for exited PID " << pid; - // Do NOT unmap snapshot pages from the desktop here -- the - // desktop may be on another CPU with stale TLB entries. - // Just free the physical pages below (PTEs stay present, - // desktop reads garbage for one frame, then its next - // Enumerate sees the window is gone). - // Do NOT free live pixel pages here -- they are still mapped // in the owner's page tables and FreeUserHalf() (called right // after CleanupProcess) will free them. - // Free snapshot pages (not in the owner's page tables). - for (int p = 0; p < g_slots[i].pixelNumPages; p++) { - if (g_slots[i].snapshotPhysPages[p] != 0) { - Memory::g_pfa->Free((void*)Memory::HHDM(g_slots[i].snapshotPhysPages[p])); - g_slots[i].snapshotPhysPages[p] = 0; - } - } + // Snapshot pages can still be mapped into the desktop, so + // retire them instead of freeing them immediately. + RetirePageBatchLocked(g_slots[i].snapshotPhysPages, g_slots[i].pixelNumPages); g_slots[i].used = false; + g_slots[i].pixelNumPages = 0; + g_slots[i].ownerVa = 0; + g_slots[i].desktopVa = 0; + g_slots[i].desktopPid = 0; } // If this process had windows mapped INTO it (was the desktop viewer), diff --git a/programs/src/desktop/main.cpp b/programs/src/desktop/main.cpp index d045e03..2b717c3 100644 --- a/programs/src/desktop/main.cpp +++ b/programs/src/desktop/main.cpp @@ -398,6 +398,16 @@ bool desktop_poll_external_windows(DesktopState* ds) { for (int i = 0; i < ds->window_count; i++) { if (ds->windows[i].external && ds->windows[i].ext_win_id == extId) { found = true; + if (ds->windows[i].content_w != extWins[e].width || + ds->windows[i].content_h != extWins[e].height) { + ds->windows[i].content_w = extWins[e].width; + ds->windows[i].content_h = extWins[e].height; + ds->windows[i].dirty = true; + if (ds->windows[i].state != WIN_MINIMIZED && ds->windows[i].state != WIN_CLOSED) { + changed = true; + } + } + montauk::strncpy(ds->windows[i].title, extWins[e].title, MAX_TITLE_LEN); // Update dirty flag and cursor if (extWins[e].dirty) { ds->windows[i].dirty = true; diff --git a/programs/src/music/main.cpp b/programs/src/music/main.cpp index b3819d6..35f7c46 100644 --- a/programs/src/music/main.cpp +++ b/programs/src/music/main.cpp @@ -45,6 +45,7 @@ static constexpr int TRANSPORT_H = 44; static constexpr int MAX_FILES = 128; static constexpr int MAX_PATH = 256; +static constexpr int MAX_FILTER = 64; // Decode buffer: one MP3 frame = up to 1152 samples * 2 channels static constexpr int PCM_BUF_SAMPLES = 1152 * 2; @@ -62,6 +63,7 @@ static constexpr Color TRACK_BG = Color::from_rgb(0xDD, 0xDD, 0xDD); static constexpr Color WHITE = Color::from_rgb(0xFF, 0xFF, 0xFF); static constexpr Color LIST_HOVER = Color::from_rgb(0xE8, 0xF0, 0xFD); static constexpr Color LIST_PLAYING = Color::from_rgb(0xD0, 0xE2, 0xFB); +static constexpr Color LIST_SELECTED = Color::from_rgb(0xF0, 0xF5, 0xFC); static constexpr Color BTN_BG = Color::from_rgb(0xE8, 0xE8, 0xE8); static constexpr Color BTN_HOVER = Color::from_rgb(0xD8, 0xD8, 0xD8); static constexpr Color STOP_COLOR = Color::from_rgb(0xCC, 0x33, 0x33); @@ -100,6 +102,11 @@ struct PlayerState { // List UI int scroll_y; int hovered_item; + int selected_item; + int filtered_indices[MAX_FILES]; + int filtered_count; + char filter_text[MAX_FILTER]; + bool filter_active; // Playback PlayState play_state; @@ -166,6 +173,8 @@ struct PlayerState { static PlayerState g; +static int visible_items(); + // ============================================================================ // Pixel helpers // ============================================================================ @@ -366,6 +375,151 @@ static Rect visualizer_button_rect() { return {shuffle.x - TOOL_ICON_GAP - TOOL_ICON_W, shuffle.y, TOOL_ICON_W, TOOL_ICON_H}; } +static char ascii_lower(char c) { + return (c >= 'A' && c <= 'Z') ? (char)(c + 32) : c; +} + +static bool is_printable_ascii(char c) { + return c >= 32 && c < 127; +} + +static bool str_contains_case_insensitive(const char* text, const char* needle) { + if (!needle || !needle[0]) return true; + if (!text || !text[0]) return false; + + int text_len = montauk::slen(text); + int needle_len = montauk::slen(needle); + if (needle_len > text_len) return false; + + for (int i = 0; i <= text_len - needle_len; i++) { + bool match = true; + for (int j = 0; j < needle_len; j++) { + if (ascii_lower(text[i + j]) != ascii_lower(needle[j])) { + match = false; + break; + } + } + if (match) return true; + } + return false; +} + +static int filtered_raw_at(int visible_index) { + if (visible_index < 0 || visible_index >= g.filtered_count) return -1; + return g.filtered_indices[visible_index]; +} + +static bool filtered_contains(int raw_index) { + if (raw_index < 0) return false; + for (int i = 0; i < g.filtered_count; i++) { + if (g.filtered_indices[i] == raw_index) return true; + } + return false; +} + +static int selected_visible_index() { + for (int i = 0; i < g.filtered_count; i++) { + if (g.filtered_indices[i] == g.selected_item) return i; + } + return -1; +} + +static void clamp_list_scroll() { + int max_scroll = g.filtered_count - visible_items(); + if (max_scroll < 0) max_scroll = 0; + if (g.scroll_y < 0) g.scroll_y = 0; + if (g.scroll_y > max_scroll) g.scroll_y = max_scroll; +} + +static void ensure_selected_visible() { + int sel = selected_visible_index(); + if (sel < 0) { + clamp_list_scroll(); + return; + } + + int visible = visible_items(); + if (visible <= 0) { + g.scroll_y = 0; + return; + } + + if (sel < g.scroll_y) g.scroll_y = sel; + if (sel >= g.scroll_y + visible) g.scroll_y = sel - visible + 1; + clamp_list_scroll(); +} + +static void rebuild_filtered_view() { + int prev_selected = g.selected_item; + g.filtered_count = 0; + + for (int i = 0; i < g.file_count && g.filtered_count < MAX_FILES; i++) { + if (str_contains_case_insensitive(g.files[i].name, g.filter_text)) { + g.filtered_indices[g.filtered_count++] = i; + } + } + + if (!filtered_contains(g.hovered_item)) g.hovered_item = -1; + + if (g.filtered_count <= 0) { + g.selected_item = -1; + g.scroll_y = 0; + return; + } + + if (filtered_contains(prev_selected)) { + g.selected_item = prev_selected; + } else if (filtered_contains(g.current_track)) { + g.selected_item = g.current_track; + } else { + g.selected_item = g.filtered_indices[0]; + } + + clamp_list_scroll(); + ensure_selected_visible(); +} + +static void append_filter_char(char c) { + int len = montauk::slen(g.filter_text); + if (len >= MAX_FILTER - 1) return; + g.filter_text[len] = c; + g.filter_text[len + 1] = '\0'; + rebuild_filtered_view(); +} + +static void pop_filter_char() { + int len = montauk::slen(g.filter_text); + if (len <= 0) return; + g.filter_text[len - 1] = '\0'; + rebuild_filtered_view(); +} + +static void clear_filter() { + if (!g.filter_text[0]) return; + g.filter_text[0] = '\0'; + rebuild_filtered_view(); +} + +static void move_selection(int delta) { + if (g.filtered_count <= 0) { + g.selected_item = -1; + g.scroll_y = 0; + return; + } + + int sel = selected_visible_index(); + if (sel < 0) { + sel = delta >= 0 ? 0 : g.filtered_count - 1; + } else { + sel += delta; + if (sel < 0) sel = 0; + if (sel >= g.filtered_count) sel = g.filtered_count - 1; + } + + g.selected_item = g.filtered_indices[sel]; + ensure_selected_visible(); +} + // ============================================================================ // Directory scanning // ============================================================================ @@ -389,6 +543,8 @@ static void scan_directory() { f.is_mp3 = is_mp3; g.file_count++; } + + rebuild_filtered_view(); } // ============================================================================ @@ -680,6 +836,13 @@ static bool start_track(int index) { g.mp3_seek_discard_samples = 0; music_visualizer::reset(g.visualizer); + if (filtered_contains(index)) { + g.selected_item = index; + } else if (g.filtered_count > 0 && selected_visible_index() < 0) { + g.selected_item = g.filtered_indices[0]; + } + ensure_selected_visible(); + return true; } @@ -693,6 +856,16 @@ static void toggle_pause() { } } +static bool play_selected_or_default() { + if (g.selected_item >= 0 && filtered_contains(g.selected_item)) return start_track(g.selected_item); + if (g.filtered_count > 0) return start_track(g.filtered_indices[0]); + if (g.filter_text[0]) return false; + if (g.selected_item >= 0) return start_track(g.selected_item); + if (g.current_track >= 0) return start_track(g.current_track); + if (g.file_count > 0) return start_track(0); + return false; +} + static bool next_track(bool auto_advanced = false) { if (g.file_count == 0) return false; if (g.current_track < 0) return start_track(0); @@ -880,9 +1053,45 @@ static void render(uint32_t* pixels) { // Directory path in toolbar { + int max_dir_w = visualizer_rect.x - 20; + if (g.filter_active || g.filter_text[0]) { + char filter_query[24]; + char filter_label[48]; + const char* query = g.filter_text; + int qlen = montauk::slen(query); + if (qlen > 15) { + montauk::memcpy(filter_query, "...", 3); + montauk::memcpy(filter_query + 3, query + qlen - 15, 15); + filter_query[18] = '\0'; + query = filter_query; + } + + if (g.filter_text[0]) { + snprintf(filter_label, sizeof(filter_label), "%s%s%s", + g.filter_active ? "Find: " : "Filter: ", + query, + g.filter_active ? "_" : ""); + } else { + snprintf(filter_label, sizeof(filter_label), "%s", + g.filter_active ? "Find" : "Filter"); + } + + int pill_h = TOOL_ICON_H; + int pill_w = text_w(filter_label, FONT_SIZE_SM) + 14; + int pill_x = visualizer_rect.x - TOOL_ICON_GAP - pill_w; + int pill_y = (TOOLBAR_H - pill_h) / 2; + Color pill_bg = g.filter_active ? LIST_HOVER : BTN_BG; + Color pill_fg = g.filter_active ? ACCENT_DARK : DIM_TEXT; + if (pill_x > 80) { + px_fill_rounded(pixels, W, H, pill_x, pill_y, pill_w, pill_h, 6, pill_bg); + px_text(pixels, W, H, pill_x + 7, pill_y + (pill_h - fh_sm) / 2, + filter_label, pill_fg, FONT_SIZE_SM); + max_dir_w = pill_x - 12; + } + } + const char* display_dir = g.dir_path; int dw = text_w(display_dir, FONT_SIZE_SM); - int max_dir_w = visualizer_rect.x - 20; int dir_len = montauk::slen(g.dir_path); if (dw > max_dir_w && dir_len > 20) { display_dir = g.dir_path + montauk::slen(g.dir_path) - 20; @@ -897,19 +1106,25 @@ static void render(uint32_t* pixels) { px_hline(pixels, W, H, 0, list_end, W, BORDER_COLOR); if (!g.show_visualizer) { - for (int i = 0; i < visible_count && (i + g.scroll_y) < g.file_count; i++) { - int idx = i + g.scroll_y; + for (int i = 0; i < visible_count && (i + g.scroll_y) < g.filtered_count; i++) { + int idx = filtered_raw_at(i + g.scroll_y); + if (idx < 0) break; int iy = LIST_TOP + i * LIST_ITEM_H; Color bg = BG_COLOR; if (idx == g.current_track && g.play_state != PlayState::Stopped) bg = LIST_PLAYING; + else if (idx == g.selected_item) + bg = LIST_SELECTED; else if (idx == g.hovered_item) bg = LIST_HOVER; if (bg.r != BG_COLOR.r || bg.g != BG_COLOR.g || bg.b != BG_COLOR.b) px_fill(pixels, W, H, 0, iy, W, LIST_ITEM_H, bg); + if (idx == g.selected_item && !(idx == g.current_track && g.play_state != PlayState::Stopped)) + px_fill(pixels, W, H, 0, iy, 3, LIST_ITEM_H, ACCENT); + if (idx == g.current_track && g.play_state == PlayState::Playing) { px_triangle_right(pixels, W, H, 8, iy + 8, 8, 12, ACCENT); } else if (idx == g.current_track && g.play_state == PlayState::Paused) { @@ -928,19 +1143,25 @@ static void render(uint32_t* pixels) { tag, DIM_TEXT, FONT_SIZE_SM); } - if (g.file_count > visible_count && visible_count > 0) { + if (g.filtered_count > visible_count && visible_count > 0) { int list_h = list_end - LIST_TOP; - int sb_h = (visible_count * list_h) / g.file_count; + int sb_h = (visible_count * list_h) / g.filtered_count; if (sb_h < 20) sb_h = 20; - int sb_y = LIST_TOP + (g.scroll_y * (list_h - sb_h)) / (g.file_count - visible_count); + int sb_y = LIST_TOP + (g.scroll_y * (list_h - sb_h)) / (g.filtered_count - visible_count); px_fill_rounded(pixels, W, H, W - 6, sb_y, 4, sb_h, 2, TRACK_BG); } - if (g.file_count == 0) { - const char* msg = "No audio files found"; + if (g.filtered_count == 0) { + const char* msg = g.file_count == 0 ? "No audio files found" : "No matches for filter"; int mw = text_w(msg); int cy = LIST_TOP + (list_end - LIST_TOP) / 2 - fh / 2; px_text(pixels, W, H, (W - mw) / 2, cy, msg, DIM_TEXT); + if (g.file_count > 0 && g.filter_text[0]) { + char detail[96]; + snprintf(detail, sizeof(detail), "Current filter: %s", g.filter_text); + int dw = text_w(detail, FONT_SIZE_SM); + px_text(pixels, W, H, (W - dw) / 2, cy + fh_sm + 6, detail, DIM_TEXT, FONT_SIZE_SM); + } } } else { px_fill(pixels, W, H, content_panel.x, content_panel.y, content_panel.w, content_panel.h, TOOLBAR_BG); @@ -1060,8 +1281,8 @@ static bool handle_click(int mx, int my) { // File list click if (!g.show_visualizer && my >= LIST_TOP && my < lb && mx >= 0 && mx < W) { - int idx = (my - LIST_TOP) / LIST_ITEM_H + g.scroll_y; - if (idx >= 0 && idx < g.file_count) { + int idx = filtered_raw_at((my - LIST_TOP) / LIST_ITEM_H + g.scroll_y); + if (idx >= 0) { start_track(idx); return true; } @@ -1095,10 +1316,8 @@ static bool handle_click(int mx, int my) { // Play/Pause if (mx >= bx && mx < bx + btn_w) { - if (g.play_state == PlayState::Stopped && g.current_track >= 0) { - start_track(g.current_track); - } else if (g.play_state == PlayState::Stopped && g.file_count > 0) { - start_track(0); + if (g.play_state == PlayState::Stopped) { + play_selected_or_default(); } else { toggle_pause(); } @@ -1118,6 +1337,94 @@ static bool handle_click(int mx, int my) { return false; } +static bool handle_key(const Montauk::KeyEvent& key, bool& quit) { + if (!key.pressed) return false; + + if (key.scancode == 0x01) { // Escape + if (g.filter_active) { + if (g.filter_text[0]) clear_filter(); + else g.filter_active = false; + return true; + } + quit = true; + return false; + } + + if (key.ctrl && (key.ascii == 'f' || key.ascii == 'F' || key.ascii == 6)) { + g.filter_active = true; + return true; + } + + if (!key.ctrl && !key.alt && key.ascii == '/') { + g.filter_active = true; + return true; + } + + if (!g.show_visualizer && key.scancode == 0x48) { // Up + move_selection(-1); + return true; + } + if (!g.show_visualizer && key.scancode == 0x50) { // Down + move_selection(1); + return true; + } + if (!g.show_visualizer && + (key.ascii == '\n' || key.ascii == '\r' || key.scancode == 0x1C)) { + if (g.selected_item >= 0) { + start_track(g.selected_item); + g.filter_active = false; + return true; + } + } + + if (g.filter_active) { + if (key.ascii == '\b' || key.scancode == 0x0E) { + if (g.filter_text[0]) pop_filter_char(); + else g.filter_active = false; + return true; + } + if (!key.ctrl && !key.alt && is_printable_ascii(key.ascii)) { + append_filter_char(key.ascii); + return true; + } + return false; + } + + if (key.ascii == ' ') { + if (g.play_state == PlayState::Stopped) + play_selected_or_default(); + else + toggle_pause(); + return true; + } + if (key.ascii == 's' || key.ascii == 'S') { + stop_playback(); + return true; + } + if (key.ascii == 'h' || key.ascii == 'H') { + g.shuffle_enabled = !g.shuffle_enabled; + return true; + } + if (key.ascii == 'r' || key.ascii == 'R') { + cycle_repeat_mode(); + return true; + } + if (key.ascii == 'v' || key.ascii == 'V') { + g.show_visualizer = !g.show_visualizer; + return true; + } + if (key.scancode == 0x4D) { // Right arrow + next_track(false); + return true; + } + if (key.scancode == 0x4B) { // Left arrow + prev_track(); + return true; + } + + return false; +} + // ============================================================================ // Entry point // ============================================================================ @@ -1131,6 +1438,7 @@ extern "C" void _start() { g.current_track = -1; g.play_state = PlayState::Stopped; g.hovered_item = -1; + g.selected_item = -1; g.show_visualizer = false; g.repeat_mode = RepeatMode::Off; g.rng_state = (uint32_t)montauk::get_milliseconds() ^ 0xC0DEFACEu; @@ -1263,38 +1571,9 @@ extern "C" void _start() { // Keyboard if (ev.type == 0 && ev.key.pressed) { - if (ev.key.scancode == 0x01) { quit = true; break; } - if (ev.key.ascii == ' ') { - if (g.play_state == PlayState::Stopped && g.file_count > 0) - start_track(g.current_track >= 0 ? g.current_track : 0); - else - toggle_pause(); + if (handle_key(ev.key, quit)) redraw = true; - } - if (ev.key.ascii == 's' || ev.key.ascii == 'S') { - stop_playback(); - redraw = true; - } - if (ev.key.ascii == 'h' || ev.key.ascii == 'H') { - g.shuffle_enabled = !g.shuffle_enabled; - redraw = true; - } - if (ev.key.ascii == 'r' || ev.key.ascii == 'R') { - cycle_repeat_mode(); - redraw = true; - } - if (ev.key.ascii == 'v' || ev.key.ascii == 'V') { - g.show_visualizer = !g.show_visualizer; - redraw = true; - } - if (ev.key.scancode == 0x4D) { // Right arrow - next_track(false); - redraw = true; - } - if (ev.key.scancode == 0x4B) { // Left arrow - prev_track(); - redraw = true; - } + if (quit) break; } // Mouse @@ -1325,8 +1604,7 @@ extern "C" void _start() { // Hover tracking for file list int lb = list_bottom(); if (!g.show_visualizer && ev.mouse.y >= LIST_TOP && ev.mouse.y < lb) { - int new_hover = (ev.mouse.y - LIST_TOP) / LIST_ITEM_H + g.scroll_y; - if (new_hover >= g.file_count) new_hover = -1; + int new_hover = filtered_raw_at((ev.mouse.y - LIST_TOP) / LIST_ITEM_H + g.scroll_y); if (new_hover != g.hovered_item) { g.hovered_item = new_hover; redraw = true; @@ -1339,10 +1617,7 @@ extern "C" void _start() { // Scroll if (!g.show_visualizer && ev.mouse.scroll != 0) { g.scroll_y -= ev.mouse.scroll * 2; - int max_scroll = g.file_count - visible_items(); - if (max_scroll < 0) max_scroll = 0; - if (g.scroll_y < 0) g.scroll_y = 0; - if (g.scroll_y > max_scroll) g.scroll_y = max_scroll; + clamp_list_scroll(); redraw = true; } } @@ -1352,6 +1627,7 @@ extern "C" void _start() { g.win_w = ev.resize.w; g.win_h = ev.resize.h; pixels = (uint32_t*)(uintptr_t)montauk::win_resize(win_id, g.win_w, g.win_h); + ensure_selected_visible(); redraw = true; } } diff --git a/programs/src/music/visualizer.cpp b/programs/src/music/visualizer.cpp index 77306f0..daf2613 100644 --- a/programs/src/music/visualizer.cpp +++ b/programs/src/music/visualizer.cpp @@ -4,6 +4,8 @@ namespace music_visualizer { using namespace gui; +static constexpr int SLICE_FRAMES = 256; + static void px_fill(uint32_t* px, int bw, int bh, int x, int y, int w, int h, Color c) { if (w <= 0 || h <= 0) return; @@ -30,62 +32,127 @@ static void px_hline(uint32_t* px, int bw, int bh, } } -void reset(State& state) { - for (int i = 0; i < BAR_COUNT; i++) { - state.levels[i] = 0; - state.peaks[i] = 0; - state.peak_hold[i] = 0; +static Color mix_color(Color a, Color b, int t) { + t = gui_clamp(t, 0, 255); + int inv_t = 255 - t; + return { + (uint8_t)((a.r * inv_t + b.r * t + 127) / 255), + (uint8_t)((a.g * inv_t + b.g * t + 127) / 255), + (uint8_t)((a.b * inv_t + b.b * t + 127) / 255), + 255 + }; +} + +static int ordered_index(const State& state, int order) { + if (state.history_count < WAVE_HISTORY) return order; + int idx = state.write_pos + order; + if (idx >= WAVE_HISTORY) idx -= WAVE_HISTORY; + return idx; +} + +static void clear_accumulator(State& state) { + state.accum_frames = 0; + state.accum_abs_sum = 0; + state.accum_min = 32767; + state.accum_max = -32768; +} + +static void push_slice(State& state, int min_sample, int max_sample, int avg_abs) { + if (min_sample > max_sample) return; + + int idx = state.write_pos; + state.min_samples[idx] = (int16_t)min_sample; + state.max_samples[idx] = (int16_t)max_sample; + + int scaled_energy = (avg_abs * 100) / 32768; + scaled_energy = (scaled_energy * 3) / 2; + if (scaled_energy > 100) scaled_energy = 100; + state.energy[idx] = (uint8_t)scaled_energy; + + state.write_pos++; + if (state.write_pos >= WAVE_HISTORY) state.write_pos = 0; + if (state.history_count < WAVE_HISTORY) state.history_count++; +} + +static void sample_history(const State& state, int column, int total_columns, + int& min_sample, int& max_sample, int& energy) { + if (state.history_count <= 0) { + min_sample = 0; + max_sample = 0; + energy = 0; + return; } + + if (state.history_count == 1 || total_columns <= 1) { + int idx = ordered_index(state, state.history_count - 1); + min_sample = state.min_samples[idx]; + max_sample = state.max_samples[idx]; + energy = state.energy[idx]; + return; + } + + int denom = total_columns - 1; + int max_order = state.history_count - 1; + int scaled = column * max_order * 256; + int base = scaled / denom; + int frac = base & 0xFF; + int order0 = base >> 8; + int order1 = order0 < max_order ? order0 + 1 : order0; + + int idx0 = ordered_index(state, order0); + int idx1 = ordered_index(state, order1); + + int min0 = state.min_samples[idx0]; + int min1 = state.min_samples[idx1]; + int max0 = state.max_samples[idx0]; + int max1 = state.max_samples[idx1]; + int e0 = state.energy[idx0]; + int e1 = state.energy[idx1]; + + min_sample = (min0 * (256 - frac) + min1 * frac) / 256; + max_sample = (max0 * (256 - frac) + max1 * frac) / 256; + energy = (e0 * (256 - frac) + e1 * frac) / 256; +} + +void reset(State& state) { + for (int i = 0; i < WAVE_HISTORY; i++) { + state.min_samples[i] = 0; + state.max_samples[i] = 0; + state.energy[i] = 0; + } + state.write_pos = 0; + state.history_count = 0; + clear_accumulator(state); } void tick(State& state) { - for (int i = 0; i < BAR_COUNT; i++) { - if (state.levels[i] > 0) { - state.levels[i] = state.levels[i] > 3 ? (uint8_t)(state.levels[i] - 3) : 0; - } - if (state.peak_hold[i] < 6) { - state.peak_hold[i]++; - } else if (state.peaks[i] > 0) { - state.peaks[i] = state.peaks[i] > 2 ? (uint8_t)(state.peaks[i] - 2) : 0; - } - } + (void)state; } void feed_pcm(State& state, const int16_t* pcm, int frames, int channels) { if (!pcm || frames <= 0 || channels <= 0) return; - int local_max[BAR_COUNT] = {}; for (int i = 0; i < frames; i++) { - int bar = (i * BAR_COUNT) / frames; - if (bar < 0) bar = 0; - if (bar >= BAR_COUNT) bar = BAR_COUNT - 1; - - int amp = 0; + int mono = 0; for (int ch = 0; ch < channels; ch++) { - int sample = pcm[i * channels + ch]; - if (sample < 0) sample = -sample; - amp += sample; + mono += pcm[i * channels + ch]; } - amp /= channels; - if (amp > local_max[bar]) local_max[bar] = amp; - } + mono /= channels; - for (int i = 0; i < BAR_COUNT; i++) { - int scaled = (local_max[i] * 100) / 32768; - scaled = (scaled * 3) / 2; - if (scaled > 100) scaled = 100; + if (mono < state.accum_min) state.accum_min = mono; + if (mono > state.accum_max) state.accum_max = mono; - int blended = scaled; - if (scaled < state.levels[i]) { - blended = (state.levels[i] * 3 + scaled) / 4; + int abs_mono = mono; + if (abs_mono < 0) { + abs_mono = abs_mono == -32768 ? 32768 : -abs_mono; } + state.accum_abs_sum += abs_mono; + state.accum_frames++; - if (blended > state.levels[i]) { - state.levels[i] = (uint8_t)blended; - } - if (state.levels[i] > state.peaks[i]) { - state.peaks[i] = state.levels[i]; - state.peak_hold[i] = 0; + if (state.accum_frames >= SLICE_FRAMES) { + int avg_abs = state.accum_abs_sum / state.accum_frames; + push_slice(state, state.accum_min, state.accum_max, avg_abs); + clear_accumulator(state); } } } @@ -95,7 +162,13 @@ void render(uint32_t* pixels, int bw, int bh, const Rect& rect, Color track_bg, Color border) { if (!pixels || rect.w <= 0 || rect.h <= 0) return; - px_fill(pixels, bw, bh, rect.x, rect.y, rect.w, rect.h, Color::from_rgb(0xFA, 0xFB, 0xFD)); + Color panel_bg = Color::from_rgb(0xFA, 0xFB, 0xFD); + Color guide = mix_color(panel_bg, track_bg, 144); + Color guide_faint = mix_color(panel_bg, track_bg, 72); + Color inner_base = mix_color(Color::from_rgb(0xE9, 0xF2, 0xFF), accent, 64); + Color outer_base = mix_color(track_bg, accent_dark, 72); + + px_fill(pixels, bw, bh, rect.x, rect.y, rect.w, rect.h, panel_bg); px_hline(pixels, bw, bh, rect.x, rect.y, rect.w, border); px_hline(pixels, bw, bh, rect.x, rect.y + rect.h - 1, rect.w, border); @@ -105,35 +178,49 @@ void render(uint32_t* pixels, int bw, int bh, const Rect& rect, int inner_h = rect.h - 16; if (inner_w <= 0 || inner_h <= 0) return; - int gap = 2; - int bar_w = (inner_w - gap * (BAR_COUNT - 1)) / BAR_COUNT; - if (bar_w < 2) bar_w = 2; - int bars_w = bar_w * BAR_COUNT + gap * (BAR_COUNT - 1); - int start_x = inner_x + (inner_w - bars_w) / 2; - int base_y = inner_y + inner_h - 1; - int usable_h = inner_h - 6; - if (usable_h < 8) usable_h = 8; + int center_y = inner_y + inner_h / 2; + int amplitude = inner_h / 2 - 10; + if (amplitude < 12) amplitude = 12; - px_hline(pixels, bw, bh, inner_x, base_y, inner_w, track_bg); + px_hline(pixels, bw, bh, inner_x, center_y, inner_w, guide); + px_hline(pixels, bw, bh, inner_x, center_y - amplitude / 2, inner_w, guide_faint); + px_hline(pixels, bw, bh, inner_x, center_y + amplitude / 2, inner_w, guide_faint); - for (int i = 0; i < BAR_COUNT; i++) { - int x = start_x + i * (bar_w + gap); - int bar_h = (state.levels[i] * usable_h) / 100; - if (bar_h < 2 && state.levels[i] > 0) bar_h = 2; + if (state.history_count <= 0) return; - if (bar_h > 0) { - int y = base_y - bar_h; - int top_h = bar_h / 3; - if (top_h < 2) top_h = 2; - if (top_h > bar_h) top_h = bar_h; - px_fill(pixels, bw, bh, x, y + top_h, bar_w, bar_h - top_h, accent); - px_fill(pixels, bw, bh, x, y, bar_w, top_h, accent_dark); + for (int col = 0; col < inner_w; col++) { + int min_sample, max_sample, energy; + sample_history(state, col, inner_w, min_sample, max_sample, energy); + + int top = center_y - (max_sample * amplitude) / 32768; + int bottom = center_y - (min_sample * amplitude) / 32768; + if (top > bottom) { + int swap = top; + top = bottom; + bottom = swap; } - int peak_h = (state.peaks[i] * usable_h) / 100; - int peak_y = base_y - peak_h; - if (peak_y < inner_y) peak_y = inner_y; - px_fill(pixels, bw, bh, x, peak_y, bar_w, 2, border); + int pad = 1 + energy / 28; + top -= pad; + bottom += pad; + + top = gui_clamp(top, inner_y, inner_y + inner_h - 1); + bottom = gui_clamp(bottom, inner_y, inner_y + inner_h - 1); + if (bottom < top) bottom = top; + + int outer_t = 48 + (col * 160) / (inner_w > 1 ? (inner_w - 1) : 1); + int inner_t = 80 + (col * 175) / (inner_w > 1 ? (inner_w - 1) : 1); + Color outer = mix_color(outer_base, accent_dark, outer_t); + Color inner = mix_color(inner_base, accent, inner_t); + + int x = inner_x + col; + px_fill(pixels, bw, bh, x, top, 1, bottom - top + 1, outer); + + int inner_top = top + 1; + int inner_bottom = bottom - 1; + if (inner_bottom >= inner_top) { + px_fill(pixels, bw, bh, x, inner_top, 1, inner_bottom - inner_top + 1, inner); + } } } diff --git a/programs/src/music/visualizer.hpp b/programs/src/music/visualizer.hpp index d6a7966..13bc951 100644 --- a/programs/src/music/visualizer.hpp +++ b/programs/src/music/visualizer.hpp @@ -5,12 +5,18 @@ namespace music_visualizer { -static constexpr int BAR_COUNT = 24; +static constexpr int WAVE_HISTORY = 192; struct State { - uint8_t levels[BAR_COUNT]; - uint8_t peaks[BAR_COUNT]; - uint8_t peak_hold[BAR_COUNT]; + int16_t min_samples[WAVE_HISTORY]; + int16_t max_samples[WAVE_HISTORY]; + uint8_t energy[WAVE_HISTORY]; + int write_pos; + int history_count; + int accum_frames; + int accum_abs_sum; + int accum_min; + int accum_max; }; void reset(State& state);