From e358b0bf0d4494d5e59cf7caae8b4918b8d513d7 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 18 Apr 2026 21:39:42 +0900 Subject: rework study mode --- .rules/ideas/wasm-web-port.md | 60 ++++++++++ README.md | 25 ++-- src/main.c | 262 +++++++++++++++++++++++++++--------------- 3 files changed, 246 insertions(+), 101 deletions(-) create mode 100644 .rules/ideas/wasm-web-port.md diff --git a/.rules/ideas/wasm-web-port.md b/.rules/ideas/wasm-web-port.md new file mode 100644 index 0000000..6ae5959 --- /dev/null +++ b/.rules/ideas/wasm-web-port.md @@ -0,0 +1,60 @@ +# WASM / Web Port + +## Overview + +Port the audio engine and study mode logic to WASM, exposing a JavaScript API so a web frontend can drive it. + +Raylib has built-in Emscripten support, so two approaches are possible: + +1. **Full raylib WASM port** — keep the raylib-rendered UI, compile everything to WASM with Emscripten. +2. **Headless WASM module + JS frontend** — strip rendering, export a C API, build a custom HTML/CSS/JS UI. + +## What ports easily + +- **Silence detection** — pure C math on WAV sample data, no platform dependencies. +- **Study mode state machine** — segment navigation, auto-pause logic, padding zones. +- **Raylib rendering** — built-in `emscripten_set_main_loop` support. + +## What needs adaptation + +### File loading +No native drag-and-drop. Options: +- JavaScript `FileReader` API → pass bytes into WASM memory via `EM_ASM` or exported function. +- Fetch from URL → Emscripten's async file fetching. +- Requires ~50-100 lines of JS↔C glue. + +### Main loop +Replace `while (!WindowShouldClose())` with `emscripten_set_main_loop(update_frame, 0, 1)`. Extract the loop body into a single `update_frame()` function. + +### JavaScript API (if using custom web frontend) +Export functions via `EMSCRIPTEN_KEEPALIVE`: +- `load_track(uint8_t *data, int len)` — load audio from buffer +- `play()`, `pause()`, `resume()` +- `seek(float seconds)` +- `get_progress()` — returns current time +- `get_duration()` +- `get_segment_count()` +- `get_current_segment()` +- `get_segments()` — returns silence region data +- `set_study_mode(bool enabled)` +- `is_playing()` + +~10-15 wrapper functions total. + +## Effort estimates + +| Task | Effort | +|------|--------| +| Emscripten build setup (Makefile target, flags) | ~1 day | +| Main loop adaptation (`emscripten_set_main_loop`) | ~2 hours | +| File loading JS↔C bridge | ~half day | +| C API exports for JS | ~half day | +| Full raylib-rendered WASM port (approach 1) | **~2 days total** | +| Custom JS/HTML/CSS frontend (approach 2) | **~4-5 days total** | + +## Notes + +- The core audio logic (silence detection, segment navigation, study mode) requires zero changes. +- Raylib's audio uses miniaudio internally, which has Emscripten support via Web Audio API. +- Consider using Emscripten's `-s ALLOW_MEMORY_GROWTH=1` since audio files can be large. +- File size limit may be a concern — browsers typically handle files up to a few hundred MB. diff --git a/README.md b/README.md index 6179658..d701b93 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,14 @@ Run the `.exe` on Windows (or under Wine). Drag an `.mp3` file onto the window t | Key | Action | |---|---| | **Drag & drop** | Load and play an `.mp3` file | -| **Space** | Play/pause (see Study Mode below) | +| **C** | Pause (does nothing if already paused) | +| **N** | Play from start of current section (does nothing if already playing) | +| **Space (hold)** | Resume/override study mode auto-pause (never pauses) | | **Up Arrow** | Play | | **Down Arrow** | Pause (rewinds 1s) | | **Left Arrow** | Seek backward 5 seconds | | **Right Arrow** | Seek forward 5 seconds | -| **V** | Jump to start of current section (press again for previous) | +| **V** | Jump to start of current section (if in padding/silence, jumps to previous) | | **B** | Jump to start of next section | | **0–9** | Jump to 0%–90% of the track | | **Click progress bar** | Seek to position | @@ -65,19 +67,24 @@ When the audio is loaded, the app analyzes it to detect silence gaps between spo **How it works:** -- **Auto-pause at silence** — Playback automatically pauses when a silence gap is reached, giving you time to process what was said. -- **Space (when paused at silence)** — Skips past the silence and resumes at the next speaking section. -- **Hold Space** — Prevents auto-pause; playback continues straight through silence gaps. -- **Space (while playing)** — Pauses and jumps back to the start of the current speaking section, so you can re-listen. -- **V** — Jump to the start of the current speaking section. Press again to go to the previous section. Auto-resumes playback. -- **B** — Jump to the start of the next speaking section. Auto-resumes playback. +The app detects silence gaps in the audio and adds a 0.25-second padding zone between silence and speech. Each speaking section is separated by these gaps. + +- **Auto-pause at silence entry** — When playback crosses from the padding zone into a silence gap, it automatically pauses and jumps the playhead forward to the start of the next speaking section (just inside the padding). +- **Auto-pause at silence exit** — If audio plays through a silence gap (e.g. via Space override), it auto-pauses when exiting the silence into the next padding/speech zone. +- **C** — Pauses playback at the current position. Does nothing if already paused. +- **N** — Seeks to the start of the current speaking section and resumes playback. Does nothing if already playing. +- **Space (hold)** — Overrides study mode auto-pauses while held. If paused, pressing Space resumes playback. Space never pauses — it only resumes/overrides. +- **V** — Jumps to the start of the current speaking section. If the playhead is in the padding zone or in silence, jumps to the previous section instead. +- **B** — Jumps to the start of the next speaking section. + +Section navigation buttons (◀ ▶) are also available next to the section counter. **Visual indicators:** - The "PLAYING"/"PAUSED" text and play button turn a darker red during silence sections. - The section counter below the buttons shows your current position (e.g. "12/70"). -When Study Mode is **off**, Space simply toggles play/pause with a 1-second rewind on pause. +When Study Mode is **off**, C pauses and N resumes without any section-seeking behavior. Space still only resumes (never pauses). ### Project Structure diff --git a/src/main.c b/src/main.c index 9bd9ee0..d61c32c 100644 --- a/src/main.c +++ b/src/main.c @@ -25,9 +25,9 @@ typedef struct { int silenceCount; bool studyMode; bool wasInSilence; /* for detecting silence entry */ - bool pausedAtSilence; /* paused by study mode at silence */ + int lastSilenceIdx; /* index of silence region we were last in, or -1 */ int skipAutoUpdate; /* frames to skip auto-updating currentTime */ - double lastVPress; /* timestamp of last V press for double-tap */ + double lastVPress; /* unused, kept for struct compat */ } PlayerState; static int strcasecmp_ext(const char *a, const char *b) @@ -194,6 +194,27 @@ static int total_speaking_portions(PlayerState *s) return s->silenceCount + 1; } +/* Get the seek target (in seconds) for jumping to a speaking portion. + Lands 2 render-frames (~33ms) into the padding zone. */ +static float segment_seek_target(PlayerState *s, int portion) +{ + float pos = speaking_portion_start(s, portion); + float target = pos * s->duration + (2.0f / 60.0f); + if (target < 0.0f) target = 0.0f; + if (target > s->duration) target = s->duration; + return target; +} + +/* Check if normalized position is in the padding zone of a speaking portion. + The padding zone is [speaking_start, speaking_start + 0.25s/duration]. */ +static bool in_padding_zone(PlayerState *s, float pos, int portion) +{ + if (s->duration <= 0.0f) return false; + float padNorm = 0.25f / s->duration; + float start = speaking_portion_start(s, portion); + return (pos >= start && pos < start + padNorm); +} + static void draw_text_centered(Font font, const char *text, float y, float fontSize, Color color) { float spacing = fontSize * 0.03f; @@ -291,6 +312,7 @@ int main(void) PlayerState state = { 0 }; state.studyMode = true; + state.lastSilenceIdx = -1; /* Layout constants */ const float titleY = 60.0f; @@ -301,7 +323,8 @@ int main(void) const float statusY = barY + barHeight + 30.0f; const float btnY = statusY + 120.0f + 55.0f; /* below status text + gap */ const float btnRadius = 55.0f; - const float btnSpacing = 150.0f; + const float btnSpacing = 150.0f; /* currently unused */ + (void)btnSpacing; const float btnCenterX = SCREEN_W / 2.0f; const float helpY = SCREEN_H - 80.0f; @@ -351,127 +374,122 @@ int main(void) /* --- Button clicks --- */ if (state.loaded) { - /* Seek back button */ + /* Seek back button (disabled) */ +#if 0 if (button_hit(btnCenterX - btnSpacing, btnY, btnRadius)) { seek_to(&state, state.currentTime - 5.0f); - state.pausedAtSilence = false; } +#endif - /* Play/pause button */ + /* Play/pause button: acts like c (if playing) or n (if paused) */ if (button_hit(btnCenterX, btnY, btnRadius)) { if (state.playing) { PauseMusicStream(state.music); - if (state.studyMode) - { - /* Jump to start of current speaking part */ - float pos = state.currentTime / state.duration; - int portion = current_speaking_portion(&state, pos); - float target = speaking_portion_start(&state, portion) * state.duration; - SeekMusicStream(state.music, target); - state.currentTime = target; - } - else - { - float rewind = state.currentTime - 1.0f; - if (rewind < 0.0f) rewind = 0.0f; - SeekMusicStream(state.music, rewind); - state.currentTime = rewind; - } state.playing = false; - state.pausedAtSilence = false; } else { - if (state.studyMode && state.pausedAtSilence) - { - /* Resume after the silence gap */ - float pos = state.currentTime / state.duration; - int silIdx = find_silence_at(&state, pos); - if (silIdx >= 0) - { - float target = state.silence[silIdx].end * state.duration; - SeekMusicStream(state.music, target); - state.currentTime = target; - } - state.pausedAtSilence = false; - } ResumeMusicStream(state.music); state.playing = true; } } - /* Seek forward button */ + /* Seek forward button (disabled) */ +#if 0 if (button_hit(btnCenterX + btnSpacing, btnY, btnRadius)) { seek_to(&state, state.currentTime + 5.0f); - state.pausedAtSilence = false; } - } +#endif - /* --- Keyboard input --- */ - if (state.loaded && IsKeyPressed(KEY_SPACE)) - { - if (state.playing) + /* Section nav buttons (prev/next section, same as V/B) */ { - PauseMusicStream(state.music); - if (state.studyMode) + float progress = (state.duration > 0.0f) ? state.currentTime / state.duration : 0.0f; + int portion = current_speaking_portion(&state, progress) + 1; + int total = total_speaking_portions(&state); + if (portion > total) portion = total; + char portionBuf[32]; + snprintf(portionBuf, sizeof(portionBuf), "%d/%d", portion, total); + float portionY = btnY + btnRadius + 80; + float portionSpacing = szSmall * 0.03f; + Vector2 portionSize = MeasureTextEx(fontSmall, portionBuf, szSmall, portionSpacing); + float portionX = (SCREEN_W - portionSize.x) / 2.0f; + float secBtnRadius = 35.0f; + float secBtnGap = 30.0f; + float secPrevX = portionX - secBtnGap - secBtnRadius; + float secNextX = portionX + portionSize.x + secBtnGap + secBtnRadius; + float secBtnY = portionY + szSmall / 2.0f; + + if (button_hit(secPrevX, secBtnY, secBtnRadius)) { float pos = state.currentTime / state.duration; - int portion = current_speaking_portion(&state, pos); - float target = speaking_portion_start(&state, portion) * state.duration; - SeekMusicStream(state.music, target); - state.currentTime = target; - } - else - { - float rewind = state.currentTime - 1.0f; - if (rewind < 0.0f) rewind = 0.0f; - SeekMusicStream(state.music, rewind); - state.currentTime = rewind; + int p = current_speaking_portion(&state, pos); + bool inSil = (find_silence_at(&state, pos) >= 0); + bool inPad = in_padding_zone(&state, pos, p); + if ((inSil || inPad) && p > 0) p--; + float target = segment_seek_target(&state, p); + seek_to(&state, target); + state.wasInSilence = false; + state.lastSilenceIdx = -1; } - state.playing = false; - state.pausedAtSilence = false; - } - else - { - if (state.studyMode && state.pausedAtSilence) + if (button_hit(secNextX, secBtnY, secBtnRadius)) { - /* Skip past silence gap */ float pos = state.currentTime / state.duration; - int silIdx = find_silence_at(&state, pos); - if (silIdx >= 0) - { - float target = state.silence[silIdx].end * state.duration; - SeekMusicStream(state.music, target); - state.currentTime = target; - } - state.pausedAtSilence = false; + int p = current_speaking_portion(&state, pos); + if (p < total - 1) p++; + float target = segment_seek_target(&state, p); + seek_to(&state, target); + state.wasInSilence = false; + state.lastSilenceIdx = -1; } - ResumeMusicStream(state.music); - state.playing = true; } } - /* V = jump to start of current speaking part (double-tap for previous) */ + /* --- Keyboard input --- */ + /* C = pause only (does nothing if already paused) */ + if (state.loaded && IsKeyPressed(KEY_C) && state.playing) + { + PauseMusicStream(state.music); + state.playing = false; + } + + /* N = play only (does nothing if already playing); seeks to start of current speaking portion */ + if (state.loaded && IsKeyPressed(KEY_N) && !state.playing) + { + float pos = state.currentTime / state.duration; + int portion = current_speaking_portion(&state, pos); + float target = segment_seek_target(&state, portion); + seek_to(&state, target); + ResumeMusicStream(state.music); + state.playing = true; + } + + /* Space (held) = override study-mode auto-pause; resume if paused; never pauses */ + if (state.loaded && IsKeyPressed(KEY_SPACE) && !state.playing) + { + ResumeMusicStream(state.music); + state.playing = true; + } + + /* V = jump to current or previous speaking portion */ if (state.loaded && IsKeyPressed(KEY_V)) { float pos = state.currentTime / state.duration; int portion = current_speaking_portion(&state, pos); - double now = GetTime(); - bool doubleTap = (now - state.lastVPress) < 0.5; - state.lastVPress = now; - if (doubleTap && portion > 0) - { + /* If in silence or in the padding zone of current portion, go to previous */ + bool inSil = (find_silence_at(&state, pos) >= 0); + bool inPad = in_padding_zone(&state, pos, portion); + if ((inSil || inPad) && portion > 0) portion--; - } - float target = speaking_portion_start(&state, portion) * state.duration; + + float target = segment_seek_target(&state, portion); seek_to(&state, target); - state.pausedAtSilence = false; state.wasInSilence = false; + state.lastSilenceIdx = -1; } /* B = jump to start of next speaking part */ @@ -481,10 +499,10 @@ int main(void) int portion = current_speaking_portion(&state, pos); int total = total_speaking_portions(&state); if (portion < total - 1) portion++; - float target = speaking_portion_start(&state, portion) * state.duration; + float target = segment_seek_target(&state, portion); seek_to(&state, target); - state.pausedAtSilence = false; state.wasInSilence = false; + state.lastSilenceIdx = -1; } /* --- 2.3 Click-to-seek on progress bar --- */ @@ -555,16 +573,33 @@ int main(void) if (state.studyMode && state.duration > 0.0f) { float pos = state.currentTime / state.duration; - bool nowInSilence = (find_silence_at(&state, pos) >= 0); + int silIdx = find_silence_at(&state, pos); + bool nowInSilence = (silIdx >= 0); if (nowInSilence && !state.wasInSilence && !IsKeyDown(KEY_SPACE)) { - /* Just entered silence — auto-pause */ + /* Just entered silence — auto-pause and jump to next segment */ + float target = segment_seek_target(&state, silIdx + 1); + PauseMusicStream(state.music); + SeekMusicStream(state.music, target); + state.currentTime = target; + state.playing = false; + state.skipAutoUpdate = 3; + } + else if (!nowInSilence && state.wasInSilence && !IsKeyDown(KEY_SPACE)) + { + /* Just exited silence into padding/speech — auto-pause here */ + int portion = current_speaking_portion(&state, pos); + float target = segment_seek_target(&state, portion); PauseMusicStream(state.music); + SeekMusicStream(state.music, target); + state.currentTime = target; state.playing = false; - state.pausedAtSilence = true; + state.skipAutoUpdate = 3; } + state.wasInSilence = nowInSilence; + if (nowInSilence) state.lastSilenceIdx = silIdx; } } } @@ -622,13 +657,22 @@ int main(void) /* --- Buttons --- */ Vector2 mousePos = GetMousePosition(); - /* Seek back button */ + /* Seek back button (disabled) */ +#if 0 float sbx = btnCenterX - btnSpacing; float sdx1 = mousePos.x - sbx, sdy1 = mousePos.y - btnY; bool hoverBack = (sdx1*sdx1 + sdy1*sdy1) <= (btnRadius*btnRadius); DrawCircle((int)sbx, (int)btnY, btnRadius, (Color){ 50, 50, 70, 255 }); if (hoverBack) DrawCircle((int)sbx, (int)btnY, btnRadius, btnHoverColor); draw_seek_back_icon(sbx, btnY, 50, textColor); + { + float lbl_spacing = szHelp * 0.03f; + const char *lbl = "5"; + float lblFontSize = szHelp * 0.8f; + Vector2 lblSz = MeasureTextEx(fontHelp, lbl, lblFontSize, lbl_spacing); + DrawTextEx(fontHelp, lbl, (Vector2){ sbx - lblSz.x/2, btnY - lblSz.y/2 }, lblFontSize, lbl_spacing, (Color){ 50, 50, 70, 255 }); + } +#endif /* Play/pause button — color reflects silence/speech */ Color playBtnColor = (state.playing && inSilence) ? (Color){ 160, 40, 55, 255 } : accentColor; @@ -642,22 +686,56 @@ int main(void) else draw_play_icon(ppx, btnY, 50, textColor); - /* Seek forward button */ + /* Seek forward button (disabled) */ +#if 0 float sfx = btnCenterX + btnSpacing; float sdx2 = mousePos.x - sfx, sdy2 = mousePos.y - btnY; bool hoverFwd = (sdx2*sdx2 + sdy2*sdy2) <= (btnRadius*btnRadius); DrawCircle((int)sfx, (int)btnY, btnRadius, (Color){ 50, 50, 70, 255 }); if (hoverFwd) DrawCircle((int)sfx, (int)btnY, btnRadius, btnHoverColor); draw_seek_fwd_icon(sfx, btnY, 50, textColor); + { + float lbl_spacing = szHelp * 0.03f; + const char *lbl = "5"; + float lblFontSize = szHelp * 0.8f; + Vector2 lblSz = MeasureTextEx(fontHelp, lbl, lblFontSize, lbl_spacing); + DrawTextEx(fontHelp, lbl, (Vector2){ sfx - lblSz.x/2, btnY - lblSz.y/2 }, lblFontSize, lbl_spacing, (Color){ 50, 50, 70, 255 }); + } +#endif - /* Speaking portion counter below buttons */ + /* Speaking portion counter with prev/next section buttons */ { int portion = current_speaking_portion(&state, progress) + 1; int total = total_speaking_portions(&state); if (portion > total) portion = total; char portionBuf[32]; snprintf(portionBuf, sizeof(portionBuf), "%d/%d", portion, total); - draw_text_centered(fontSmall, portionBuf, btnY + btnRadius + 80, szSmall, mutedColor); + float portionY = btnY + btnRadius + 80; + float portionSpacing = szSmall * 0.03f; + Vector2 portionSize = MeasureTextEx(fontSmall, portionBuf, szSmall, portionSpacing); + float portionX = (SCREEN_W - portionSize.x) / 2.0f; + DrawTextEx(fontSmall, portionBuf, (Vector2){ portionX, portionY }, szSmall, portionSpacing, mutedColor); + + /* Section nav buttons */ + float secBtnRadius = 35.0f; + float secBtnGap = 30.0f; + float secPrevX = portionX - secBtnGap - secBtnRadius; + float secNextX = portionX + portionSize.x + secBtnGap + secBtnRadius; + float secBtnY = portionY + szSmall / 2.0f; + + /* Prev section button */ + float sd3 = mousePos.x - secPrevX, sd4 = mousePos.y - secBtnY; + bool hoverSecPrev = (sd3*sd3 + sd4*sd4) <= (secBtnRadius*secBtnRadius); + DrawCircle((int)secPrevX, (int)secBtnY, secBtnRadius, (Color){ 50, 50, 70, 255 }); + if (hoverSecPrev) DrawCircle((int)secPrevX, (int)secBtnY, secBtnRadius, btnHoverColor); + draw_seek_back_icon(secPrevX, secBtnY, 30, textColor); + + /* Next section button */ + float sd5 = mousePos.x - secNextX, sd6 = mousePos.y - secBtnY; + bool hoverSecNext = (sd5*sd5 + sd6*sd6) <= (secBtnRadius*secBtnRadius); + DrawCircle((int)secNextX, (int)secBtnY, secBtnRadius, (Color){ 50, 50, 70, 255 }); + if (hoverSecNext) DrawCircle((int)secNextX, (int)secBtnY, secBtnRadius, btnHoverColor); + draw_seek_fwd_icon(secNextX, secBtnY, 30, textColor); } } else @@ -672,7 +750,7 @@ int main(void) { float helpSpacing = szHelp * 0.03f; float padding = 40.0f; - DrawTextEx(fontHelp, "Space: play/pause Arrows: seek V/B: prev/next section 0-9: jump", + DrawTextEx(fontHelp, "C: pause N: play Space(hold): override V/B: prev/next Arrows: seek 0-9: jump", (Vector2){ padding, helpY }, szHelp, helpSpacing, mutedColor); /* Study mode checkbox - right aligned on same line */ -- cgit v1.2.3