summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-04-18 21:39:42 +0900
committerAdam Malczewski <[email protected]>2026-04-18 21:39:42 +0900
commite358b0bf0d4494d5e59cf7caae8b4918b8d513d7 (patch)
treefce21673a309a40c368c4d35d0c9f618bb19e425
parentbdd2542318e3d17c85389ae4c2a7a16b0a44f0ba (diff)
downloadstudy-player-e358b0bf0d4494d5e59cf7caae8b4918b8d513d7.tar.gz
study-player-e358b0bf0d4494d5e59cf7caae8b4918b8d513d7.zip
rework study modeHEADmain
-rw-r--r--.rules/ideas/wasm-web-port.md60
-rw-r--r--README.md25
-rw-r--r--src/main.c262
3 files changed, 246 insertions, 101 deletions
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 */