From 2d4c58748641ddd8bc6acb54bf3e311a5649bd54 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 29 Jun 2026 00:15:20 +0900 Subject: phase 1: study-player rewrite plan + scaffold --- .agents/knowledge/study-player.md | 88 ++++++ GLOSSARY.md | 15 + game/study_player/study_player.rb | 34 +++ notes/study-player-rewrite-plan.md | 548 +++++++++++++++++++++++++++++++++++++ 4 files changed, 685 insertions(+) create mode 100644 .agents/knowledge/study-player.md create mode 100644 game/study_player/study_player.rb create mode 100644 notes/study-player-rewrite-plan.md diff --git a/.agents/knowledge/study-player.md b/.agents/knowledge/study-player.md new file mode 100644 index 0000000..a928dec --- /dev/null +++ b/.agents/knowledge/study-player.md @@ -0,0 +1,88 @@ +# Tribal knowledge: study-player rewrite (`StudyPlayer::`) + +Study-feature scar tissue for the rewrite of `../source/` into the +raylib-jamstack template. + +## At a glance +- **Key files:** `game/study_player/study_player.rb` (composition root), + `game/study_player/core.rb` (pure logic), `game/study_player/audio_adapter.rb`, + `game/study_player/ui.rb` + `game/study_player/ui/main.{rml,rcss}`. +- **Native helper:** `mrbgems/study_audio` (planned Phase 3) exposes raw silence + gaps because raylib `Wave` sample data is not accessible from Ruby. +- **Engine touch:** `src/main.c` needs a small patch to expose command-line args + as `ARGV` (Phase 2); otherwise the `argv → study-player view` requirement is + unreachable from Ruby. +- **Cross-refs:** rules `mruby-rebuild`, `dont-edit-generated`, `wsl-toolchain`; + knowledge `rmlui-binding`, `raylib-binding`, `flecs-binding`, `build-system`; + `notes/study-player-rewrite-plan.md`. + +## Why a native silence scanner is needed + +The generated raylib bindings expose `Rl.load_wave` and `Rl.wave_format`, but +`Wave.data` is a raw `void*` buffer that the generator intentionally skips. +There is no Ruby-visible `Wave#samples`. Ported silence detection therefore needs +a tiny C helper (`mrbgems/study_audio`) that does the I/O and returns a plain +Ruby array of `[start_seconds, end_seconds]` gaps. + +The **decision logic** (threshold comparison, 10 ms chunking, padding shrink, +portion math, auto-pause FSM) stays in pure Ruby under `StudyPlayer::Core`. +The native helper only solves the *data access* problem. + +##ARGV flow + +`src/main.c` currently only uses `argv[1]` as the script path. To satisfy +"audio file arg → study-player view", pass `argv[2..]` into the mruby VM as a +constant named `ARGV`: + +```c +mrb_value argv_ary = mrb_ary_new(mrb); +for (int i = 2; i < argc; i++) { + mrb_ary_push(mrb, argv_ary, mrb_str_new_cstr(mrb, argv[i])); +} +mrb_define_const(mrb, mrb->object_class, "ARGV", argv_ary); +``` + +Web builds inherit this because `Module.arguments` is mapped to `argc/argv` in +emscripten's generated `main()`. + +## Non-blocking seek (`skip_auto_update`) + +After `Rl.seek_music_stream`, `Rl.get_music_time_played` can briefly report the +old position. The original used `skipAutoUpdate = 3` frames to let the audio +engine settle. We model this as a Flecs component field: + +```ruby +PlaybackState = world.struct("PlaybackState", "{ ... int skip_auto_update; }") +``` + +A system applies the seek and sets the counter to 3; the update system skips +`get_music_time_played` while the counter is positive. + +## RmlUi layout quirks + +- Use `left:` / `top:` for absolute elements. RmlUi miscomputes `right:` in this + backend, so a panel `right: 60px; width: 240px` can render off-screen. +- For progress-bar clicks, use the event's `mouse_x` minus the element's + `absolute_left`, divided by `client_width`. +- Checkboxes in RmlUi read/write the `checked` attribute, not just the value. +- The RmlUi context should be initialized **after** `Rl.init_window` and fonts + must be loaded before the document. + +## Adding `mrbgems/study_audio` + +Because the RmlUi gem already forces `MRB_USE_CXX_EXCEPTION`, adding any gem is +an ABI toggle. Follow rule `mruby-rebuild`: + +```sh +rm -rf vendor/mruby/build +./rebuild.sh +``` + +## Constants to preserve + +- Silence threshold: **0.015**. +- Minimum silence duration: **0.75 seconds**. +- Padding zone: **0.25 seconds** on each side of a silence region. +- Default seek lead-in: **2 render frames** = `2.0 / 60.0` seconds + (ported from original; may tune later). +- Default `skip_auto_update`: **3 frames** at target 60 FPS. diff --git a/GLOSSARY.md b/GLOSSARY.md index 601f258..6d319a7 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -95,3 +95,18 @@ the lighter levels reach more changes is the whole point of the runtime work. | **NDJSON** | Newline-delimited JSON — one JSON object per line — the log/stream format (greppable, tailable). | JSON Lines (use NDJSON here), "log format" | | **ring buffer** | In-memory last-N log entries, queryable via eval (`Log.tail`, `Log.grep`). | log cache, history buffer | | **mutate vs observe** | P5: agents **mutate** behaviour by editing Ruby / hot-reloading (write path); they **observe** state via `.live/` + logs (read path). Keep the two paths distinct. | "read/write the game" (be specific which path) | + +## Study Player (planned) + +| Term | Definition | Aliases to avoid | +|---|---|---| +| **study mode** | Boolean toggle (`StudyState.study_mode`). When ON, auto-pause logic runs while playback is inside silence boundaries. | auto-pause mode, learning mode | +| **speaking portion** | A contiguous segment of meaningful audio between two silence regions; numbered 0-based. | section, segment, clip, part | +| **silence region** | A detected gap in the audio where amplitude stays below threshold for at least `min_duration`. Stored normalized (0–1). | gap, pause, quiet zone | +| **raw silence gap** | A silence interval returned by `StudyAudio.raw_silence_regions`, before the 0.25s padding is applied. | raw gap, un-shrunk silence | +| **padding zone** | The first 0.25s of a speaking portion, treated as safe headroom so auto-pause does not trigger during normal pauses. | grace period, buffer zone | +| **auto-pause** | The study-mode mechanism that pauses playback when entering or exiting a silence boundary. | auto-stop, silence break | +| **smart play** | Hold-to-override button/input that suppresses auto-pause while held. | hold play, override button | +| **section counter** | UI label showing current speaking portion and total, e.g. `"12/70"`. | portion label, counter | +| **non-blocking seek** | The `skip_auto_update` pattern: after a seek, skip N frames of current-time sampling so the audio engine catches up. | seek delay, seek cooldown | +| **seek cooldown** | The integer `PlaybackState.skip_auto_update` counter that implements non-blocking seek. | skip frames, seek settle | diff --git a/game/study_player/study_player.rb b/game/study_player/study_player.rb new file mode 100644 index 0000000..41e4fca --- /dev/null +++ b/game/study_player/study_player.rb @@ -0,0 +1,34 @@ +# Study Player — Phase 1 scaffold. +# +# Minimal entry stub that verifies the build and opens a window. It does not +# load audio, detect silence, or implement study mode; those are Phase 2+. +# +# Run: +# ./zig-out/bin/game game/study_player/study_player.rb + +Rl.init_window(1280, 720, "Study Player") +Rl.target_fps = 60 + +Rml.init +Rml.load_font("game/ui/LatoLatin-Regular.ttf") +Rml.load_font("game/ui/LatoLatin-Bold.ttf") + +ui = Rml::Context.new("main") + +bg = Rl::Color.new(26, 26, 46, 255) +txt = Rl::Color.new(234, 234, 234, 255) +accent = Rl::Color.new(233, 69, 96, 255) + +Rl.while_window_open do + ui.process_input + + Rl.draw(clear_color: bg) do + Rl.draw_text(text: "Study Player", + x: 1280 / 2 - 180, y: 300, font_size: 60, color: txt) + Rl.draw_text(text: "Phase 1 scaffold — audio + study mode coming in next phases", + x: 1280 / 2 - 320, y: 380, font_size: 20, color: accent) + end + + ui.update + ui.render +end diff --git a/notes/study-player-rewrite-plan.md b/notes/study-player-rewrite-plan.md new file mode 100644 index 0000000..e808999 --- /dev/null +++ b/notes/study-player-rewrite-plan.md @@ -0,0 +1,548 @@ +# Study-Player Rewrite — Phase 1 Port Design + +This document is the Phase 1 plan for porting the C/raylib `../source/` +study-player application into the `raylib-jamstack` stack +(Ruby/mruby + raylib + RmlUi + flecs + Jolt, Zig desktop / Emscripten web). + +The plan covers: + +1. How the approved principles (P1–P8) shape the rewrite. +2. A contract-by-contract mapping from the C source to Ruby/flecs/RmlUi. +3. The Flecs component/state model. +4. File/module layout under `game/study_player/`. +5. Audio file loading, including the `argv → study-player view` requirement. +6. RmlUi document/data-model structure. +7. The non-blocking seek design (the `skipAutoUpdate` pattern). +8. Phase boundaries and decisions the later phases must make. +9. Proposed glossary additions. + +> Execution rule for this plan: **Phase 1 is design + scaffold + clean build +> only.** No audio playback, no silence detection, no layout editor — those are +> Phases 2–6. + +--- + +## 1. Principles applied + +| Principle | How it shows up in this design | +|---|---| +| **P2 — Pure core / imperative shell** | `game/study_player/core.rb` holds side-effect-free math: time formatting, silence-region shrinking, portion navigation, seek-target calculation, and the auto-pause decision function. Systems in the imperative shell call core functions and apply the returned commands to `Rl::Music` streams. | +| **P1 + P7 — Features as extensions** | Each feature is a cohesive module under `game/study_player/`: audio adapter, analysis/navigation, study-mode autopause, UI sync, layout/config. The silence detector requires raw wave samples; that I/O is isolated in a small new mrbgem (`mrbgems/study_audio`) so the rest stays portable Ruby. | +| **P3 — No ambient state** | Runtime scalar state lives in Flecs components on a singleton `study_player` entity. Non-serializable handles (`Rl::Music`) and the silence-region array live in an explicit `StudyPlayer::Runtime` object created in the entry script and passed/closed-over by systems — not a hidden global singleton. | +| **P4 — Earn each pattern** | We do not transliterate C structs into Ruby classes for their own sake. Flecs components are used where owned, queryable, per-frame state solves a concrete problem (input → intent → apply → render). Pure value objects are plain Ruby `Data`/structs. | +| **P5 — Repo is a harness** | We inherit `.agents/rules/`, `.agents/knowledge/`, and `.agents/skills/`. Study-player-specific scar tissue goes into `.agents/knowledge/study-player.md`; the canonical vocabulary is proposed as additions to `GLOSSARY.md`. | +| **P6 — Document only the non-inferable** | All inferable Ruby/raylib mechanics are described only by name; the plan records *study-player-specific* scar tissue (raw wave access, ARGV flow, RmlUi layout quirks, the 0.25s padding rule, non-blocking seek). | +| **P8 — One canonical vocabulary** | We reuse existing template terms (`World`, `Component`, `System`, `phase`, `progress`, `DataModel`, `dirty`) and propose new glossary entries for study-player concepts (§9). | + +--- + +## 2. Source-to-destination mapping + +| C module | Key responsibility | Port destination | +|---|---|---| +| `src/types.h` (`PlayerState`, `SilenceRegion`, `UILayout`) | Shape of runtime state | Flecs component descriptors + pure value objects + RCSS layout variables. | +| `src/player.c/h` | Load/unload/play/pause/seek/update/format_time | `StudyPlayer::AudioAdapter` (imperative shell; wraps `Rl::Music` and `StudyAudio`). | +| `src/study.c/h` | Silence detection, portion navigation, auto-pause | `mrbgems/study_audio` (raw I/O scanner) + `StudyPlayer::Core` (pure math + decisions). | +| `src/ui.c/h` | Fonts, colours, drawing, keyboard/mouse input | `game/study_player/ui.rml/rcss` (rendering) + `StudyPlayer::InputAdapter` + Rml event handlers. | +| `src/config.c/h` | Load/save `UILayout` to `study-player.cfg` | `StudyPlayer::Config` (Ruby file I/O) + `StudyPlayer::Layout` (position/size map). | +| `src/layout_editor.c/h` | Drag-to-reposition UI elements | Phase 6: Rml document with drag events mutating layout map. | +| `src/main.c` | Window/audio init, main loop, tabs, drag-drop | Entry script `game/study_player/study_player.rb` + a tiny `src/main.c` patch to expose command-line args to Ruby (§5). | + +--- + +## 3. Flecs component / state model + +We use one singleton entity `study_player` plus a small set of state +tags/components. Components are C structs declared with `world.struct` so the +Flecs binding can serialize/deserialize them as Hashes. + +### Singleton components + +Declared in `game/study_player/components.rb`: + +```ruby +AudioFile = world.struct("AudioFile", "{ string path; float duration; }") +PlaybackState = world.struct("PlaybackState", "{ bool loaded; bool playing; float current_time; int skip_auto_update; bool seek_pending; float seek_target; }") +StudyState = world.struct("StudyState", "{ bool study_mode; bool was_in_silence; int last_silence_idx; bool smart_play_held; }") +LayoutDirty = world.struct("LayoutDirty", "{ bool value; }") # signals config save on tab switch / exit +``` + +Tag for load intents: + +```ruby +NeedsLoad = world.tag("NeedsLoad") +``` + +Entity: + +```ruby +player_entity = world.entity("study_player") + .set(AudioFile, { path: "", duration: 0.0 }) + .set(PlaybackState, { loaded: false, playing: false, current_time: 0.0, + skip_auto_update: 0, seek_pending: false, seek_target: 0.0 }) + .set(StudyState, { study_mode: true, was_in_silence: false, + last_silence_idx: -1, smart_play_held: false }) + .set(LayoutDirty, { value: false }) +``` + +### What is NOT in components + +- The `Rl::Music` handle: a Ruby object cannot safely live inside a C struct + component. It is owned by `StudyPlayer::Runtime#music`. +- The silence-region array: components support scalars and inline arrays, not a + variable-length list of pairs. Regions live in `StudyPlayer::Runtime#raw_regions` + and `#silence_regions` (denormalized pure-Ruby value objects). +- UI-specific data model: owned by the `Rml::Context` data model. + +### System phase assignments + +``` +ON_LOAD : (unused by us; flecs internal) +PRE_UPDATE: input_adapter_system, load_request_system, seek_apply_system +ON_UPDATE : update_system, study_system, ui_sync_system +ON_START : (unused) +``` + +The UI is drawn after `world.progress` inside a single `Rl.draw` block, with +`ctx.process_input` called before the progress step. + +### Runtime object + +Created in the entry script and closed over by systems: + +```ruby +Runtime = Struct.new(:world, :player_entity, :music, :raw_regions, + :silence_regions, :ctx, :ui_model, :layout) do + def loaded? = music && !music.nil? +end +``` + +Systems receive the runtime object via a top-level local/constant captured in +system blocks (Flecs system blocks are Ruby procs). This keeps non-serializable +state explicit and owned by the composition root. + +--- + +## 4. File / module layout + +``` +game/study_player/ + study_player.rb # composition root: init, components, runtime, loop + components.rb # Flecs component + tag declarations + core.rb # PURE domain logic (no Rl/ECS side effects) + config.rb # study-player.cfg load/save + layout.rb # layout map and defaults + audio_adapter.rb # Rl::Music lifecycle wrapper + input_adapter.rb # Rl keyboard + Rml event → component mutations + ui.rb # Rml context, data model, document + systems/ # registered from study_player.rb + load_system.rb # react to NeedsLoad, call AudioAdapter + Analysis + update_system.rb # UpdateMusicStream + current_time management + seek_system.rb # apply pending seeks, set skip_auto_update + study_system.rb # auto-pause FSM (core decision → apply) + ui_sync_system.rb # copy components → Rml data model dirty() + ui/ + main.rml # single document for splash + player view + main.rcss # positioning, colours, responsive anchors + fonts/ # (optional) study-player-specific fonts; otherwise reuse game/ui/* +``` + +### Module namespace + +All code lives in the `StudyPlayer` Ruby namespace. + +```ruby +module StudyPlayer + module Core; end + module Config; end + class AudioAdapter; end + class InputAdapter; end + class UI; end + class Runtime; end +end +``` + +--- + +## 5. Audio loading from argv + +### Current gap + +`src/main.c` only passes `argv[1]` (the script) to mruby. The rest of `argv` is +lost, so a Ruby script cannot read the audio file path from the command line. + +```c +const char *script = (argc > 1) ? argv[1] : "game/main.rb"; +``` + +### Required patch (Phase 2) + +Expose a Ruby `ARGV` constant containing `argv[2..]`. + +```c +mrb_value argv_ary = mrb_ary_new(mrb); +for (int i = 2; i < argc; i++) { + mrb_ary_push(mrb, argv_ary, mrb_str_new_cstr(mrb, argv[i])); +} +mrb_define_const(mrb, mrb->object_class, "ARGV", argv_ary); +``` + +This is a tiny engine change to `src/main.c`; it is necessary because the +bindings do not provide process args. Web builds use the same code path via +`Module.arguments` in `web/shell.html`. + +### Entry-point behaviour + +```ruby +script = __FILE__ +audio_arg = defined?(ARGV) && ARGV[0] ? ARGV[0] : nil + +# If an audio file was given, create a NeedsLoad entity immediately. +if audio_arg + player_entity + .set(AudioFile, { path: audio_arg, duration: 0.0 }) + .add(NeedsLoad) +else + # No arg: show the "drop a file here"/"use Load MP3" splash. + ui.show_splash +end +``` + +### Drag-and-drop fallback + +```ruby +world.system("CheckFileDrop", with: [], phase: Flecs::PRE_UPDATE) do + if Rl.is_file_dropped? # bound as predicate in generated API + files = Rl.load_dropped_files + if files.count > 0 + player_entity + .set(AudioFile, { path: files.paths[0], duration: 0.0 }) + .add(NeedsLoad) + end + Rl.unload_dropped_files(files) + end +end +``` + +`Rl.load_dropped_files` returns an `Rl::FilePathList` object; its `.paths` +accessor is provided by the generated struct wrapper. Only the first file is +used, matching the original. + +### Web build entry + +In `web/shell.html`, change `Module.arguments` to: + +```js +arguments: ['game/study_player/study_player.rb', '/path/to/audio.mp3'], +``` + +for the default web demo; for end-users the second arg is supplied at build or +upload-time. Document that web file upload is not a Phase 1 goal; Phase 2 +implements the argv path. + +--- + +## 6. RmlUi UI structure + +### One document, two visibility modes + +We use a single `main.rml` document. Two top-level `
`s are toggled with +`display: none` / `block` classes: + +- `#splash` — shown when no file is loaded. +- `#player` — shown when `AudioFile.loaded` is true. + +A checkbox for study mode and a help line are always visible. + +### Data model variables + +Bound in `StudyPlayer::UI#build_model`: + +| Variable | Direction | Purpose | +|---|---|---| +| `loaded` | computed | toggles `#player` / `#splash` visibility | +| `file_name` | computed | basename of the loaded audio file | +| `elapsed_str` | computed | `"H:MM:SS"` or `"M:SS"` from current time | +| `total_str` | computed | formatted total duration | +| `remain_str` | computed | remaining time | +| `progress_pct` | computed | integer 0..100 for the `%` label | +| `progress_ratio` | computed | float 0..1 for the fill bar width | +| `status_text` | computed | `"PLAYING"` / `"PAUSED"` | +| `status_alert` | computed | true when playing inside a silence region | +| `portion_label` | computed | `"N / total"` speaking portions | +| `study_mode` | two-way | checkbox state | +| `smart_play_held` | computed | smart-play button highlight state | +| `help_text` | value | keyboard help line | + +Values are mutable through `.value(...)`. Computed ones use `.bind { ... }`. + +### Controller events + +```ruby +model.event(:toggle_play) { runtime.toggle_play } +model.event(:prev_section) { runtime.seek_to_portion(:prev) } +model.event(:next_section) { runtime.seek_to_portion(:next) } +model.event(:smart_down) { runtime.set_smart_play(true) } +model.event(:smart_up) { runtime.set_smart_play(false) } +model.event(:seek_bar) { |ev| runtime.seek_to_ratio(seek_ratio_from_event(ev)) } +``` + +`seek_bar` computes ratio from the event's absolute mouse position and the +clicked element's geometry: + +```ruby +def seek_ratio_from_event(ev) + el = ev.current_element # or ev.target + x = ev.mouse_x - el.absolute_left + ratio = x.to_f / el.client_width + ratio.clamp(0.0, 1.0) +end +``` + +### RCSS layout strategy + +- The document is sized to the window (`width: 100%; height: 100%`). +- All interactive elements are `position: absolute` with `left:` and `top:` in + pixels. Use `left:`, **not** `right:`, because RmlUi miscomputes `right:` + positioning in the raylib backend (see `.agents/knowledge/rmlui-binding.md`). +- Default positions duplicate the original `UILayout` defaults. +- Layout overrides from `study-player.cfg` are applied after the document loads + by calling `Element#set_property` on each identified element. + +### Visual parity checklist (for Phase 5) + +- Background: deep slate (`#1a1a2e`). +- Title filename in muted grey, centered. +- Progress bar with rounded ends, accent fill. +- Elapsed / remaining times flanking the bar. +- Play/pause circle button with hover highlight. +- Section counter with prev/next circular buttons. +- Smart-play rounded rectangle with held state. +- Study-mode checkbox, bottom-right. +- Status text (`PLAYING` / `PAUSED`) turns darker red when inside a silence + region while playing. +- Window starts at the original 1920×1080 default. + +--- + +## 7. Non-blocking seek design + +### Problem + +After `Rl.seek_music_stream`, `Rl.get_music_time_played` may briefly report the +old position. If the UI reads current time every frame, the playhead visibly +flickers backwards. The C source solves this with `skipAutoUpdate`. + +### Design + +We model the seek as an **intent component** plus a **cooldown counter**. + +1. Input or UI sets `PlaybackState.seek_target` to the destination seconds and + `seek_pending = true`. +2. `seek_apply_system` (PRE_UPDATE) sees `seek_pending`, calls + `Rl.seek_music_stream(music, seek_target)`, sets `current_time = seek_target`, + sets `skip_auto_update = 3`, and clears the pending flag. +3. `update_system` (ON_UPDATE), while `skip_auto_update > 0`, only decrements it + and **does not** call `Rl.get_music_time_played`. It still calls + `Rl.update_music_stream` every frame. +4. Once the counter reaches 0, current time is sampled from the audio engine as + usual. + +```ruby +# seek_apply_system (PRE_UPDATE) +if pb[:seek_pending] + Rl.seek_music_stream(runtime.music, pb[:seek_target]) + pb[:current_time] = pb[:seek_target] + pb[:skip_auto_update] = 3 + pb[:seek_pending] = false +end + +# update_system (ON_UPDATE) +if pb[:loaded] && runtime.music + Rl.update_music_stream(runtime.music) + if pb[:playing] + if pb[:skip_auto_update] > 0 + pb[:skip_auto_update] -= 1 + else + pb[:current_time] = Rl.get_music_time_played(runtime.music) + end + end +end +``` + +### Relation to study auto-pause + +The original auto-pause also pauses and seeks at silence boundaries. Those +operations also set `skip_auto_update = 3`. The study system therefore produces +a `SeekPause` decision; `seek_apply_system` performs the seek and cooldown, +and a subsequent system or the same decision handler pauses playback. Separating +**decision** from **application** keeps `Core` pure and the shell predictable. + +### Frame count rationale + +`3` frames at 60 fps is ~50 ms — enough for raylib's miniaudio music stream to +flush and report a stable time. This is carried over from the C source; Phase 2 +will verify and tune if necessary. + +--- + +## 8. Pure core API sketch + +`game/study_player/core.rb` exports side-effect-free functions. Exact names may +be refined in Phase 3; the contracts below are the ones the rest of the app +expects. + +```ruby +module StudyPlayer::Core + # Time → formatted string, matching player_format_time semantics. + def format_time(seconds); end # => "M:SS" or "H:MM:SS" + + # Given raw silence gaps (sec/sec), shrink by padding and drop collapsed regions. + def analyze_silence(duration, raw_regions, padding: 0.25); end + # => [SilenceRegion(start_norm, end_norm), ...] + + # Portion math (0-based speaking portions). + def speaking_portion_start(silence_regions, portion); end # => normalized + def current_speaking_portion(silence_regions, position); end # => Integer + def total_speaking_portions(silence_regions); end # => Integer + def find_silence(silence_regions, position); end # => index or nil + def in_padding_zone?(duration, silence_regions, position, portion, padding: 0.25); end + + # Where to land when jumping to the start of portion N. + def portion_seek_target(duration, silence_regions, portion, + padding: 0.25, lead_frames: 3, fps: 60); end # => seconds + + # Auto-pause FSM. Returns one of: + # nil, { action: :pause_seek, target: seconds, was_in_silence: bool, last_silence_idx: int } + def auto_pause_decide(duration, silence_regions, current_time, playing, study_mode, + smart_play_held, space_held, was_in_silence, last_silence_idx); end +end +``` + +Tests can call these with synthetic arrays without touching raylib or ECS. + +### Native helper for raw gaps + +`mrbgems/study_audio/` is a new C mrbgem that exposes: + +```ruby +StudyAudio.raw_silence_regions(path, threshold: 0.015, min_duration: 0.75) +# => [[start_seconds, end_seconds], ...] +``` + +It uses raylib's `LoadWave`, `WaveFormat(..., 32, 1)`, scans 10 ms chunks for +peak amplitude, and returns the raw gaps (no padding). Added in Phase 3 before +the pure analysis module; because adding a gem flips the mruby ABI, we must run +`rm -rf vendor/mruby/build` once (rule `mruby-rebuild`). + +--- + +## 9. Layout persistence (config) + +`StudyPlayer::Config` reads/writes `study-player.cfg` from the current working +directory (the directory from which the binary is run), keeping the original +`exeDir` semantics where possible without `/proc/self/exe`. + +Layout keys mirror the original `UILayout` names so conversion is trivial: + +```ini +title_x=960.00 +title_y=60.00 +bar_x=336.00 +bar_y=460.00 +bar_width=1248.00 +bar_height=50.00 +button_center_x=960.00 +button_y=645.00 +... +``` + +At runtime, after loading `main.rml`, `StudyPlayer::Layout.apply(ctx, layout_hash)` +walks a mapping from layout keys to element IDs/styles and calls +`Element#set_property`. Dragging in the layout editor (Phase 6) mutates the same +hash and flags `LayoutDirty`; on app exit the config is saved. + +--- + +## 10. Build / test / verification strategy + +- Desktop: `./rebuild.sh` after any engine or mruby-gem change; `zig build run -- + game/study_player/study_player.rb [audio.mp3]` for end-to-end checks. +- Web: `EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh`, then the relay/agent + bridge for eval/snapshot verification. +- Type check: `./tools/check-types.sh` runs `rbs validate` + `steep check`. Game + code type errors are `:information`, not gate failures, but we keep signatures + clean. +- Visual verification: `JAMSTACK_SCREENSHOT=study-player.png ./zig-out/bin/game + game/study_player/study_player.rb audio.mp3` for an offscreen/dumped PNG. + +Because `$PATH` may include a Windows Ruby on `/mnt/c`, all build commands +strip `/mnt/c` first (rule `wsl-toolchain.md`). `rebuild.sh` already does this. + +--- + +## 11. Phase boundaries + +| Phase | What it owns | What Phase 1 leaves behind | +|---|---|---| +| **Phase 1** | This plan, scaffold script, clean build. | Component declarations, UI layout map, Runtime shape, pure-core contracts. | +| **Phase 2** | Patch `src/main.c` for `ARGV`; `AudioAdapter`; `load_system`; `update_system`; `seek_system`; entry-point wiring. | A working music player with load/play/pause/seek/time display from argv. | +| **Phase 3** | Add `mrbgems/study_audio`; `StudyPlayer::Core` (silence + navigation); apply analysis on load; section counter. | Player that detects speaking portions and navigates V/B. | +| **Phase 4** | `StudyPlayer::Core.auto_pause_decide`; `study_system`; smart-play hold; space override. | Auto-pause at silence boundaries. | +| **Phase 5** | `game/study_player/ui.rml/rcss`; `StudyPlayer::UI`; data model; event wiring; visual parity screenshot. | Complete playable UI. | +| **Phase 6** | `StudyPlayer::Config`; layout editor; final end-to-end + screenshot. | Polished, persistent layout, production-equivalent build. | + +--- + +## 12. Open decisions the later phases need to confirm + +1. **Native helper granularity.** Whether `StudyAudio.raw_silence_regions` returns + seconds or normalized values, and whether it returns the file duration too. +2. **String component safety.** The `AudioFile.path` string field in a Flecs + component: if copying long paths is problematic, switch to a Ruby-level + `runtime.path` variable and keep only a boolean/duration in the component. +3. **RmlUi audio file picker on web.** Web builds need a file `` because + drag-and-drop inside the canvas is more complex. Decide in Phase 2 whether to + add a small HTML overlay or defer to argv/upload. +4. **Layout editor scope.** Phase 6 has a simplified drag editor per the brief. + Decide which elements are draggable and whether disabling/enabling edit mode + is via a tab (like original) or a key toggle. +5. **Font embedding.** Reuse `game/ui/LatoLatin-Regular.ttf` for now; decide + later whether to embed a study-player-specific font. + +--- + +## 13. Proposed glossary additions + +These entries should be added to the repo `GLOSSARY.md` under a new +"Study Player" section as **planned** terms so later phases use them from the +start: + +| Term | Definition | Avoid calling it... | +|---|---|---| +| **study mode** | Boolean toggle (`StudyState.study_mode`). When ON, auto-pause logic runs while playback is inside silence boundaries. | auto-pause mode, learning mode | +| **speaking portion** | A contiguous segment of meaningful audio between two silence regions; numbered 0-based. | section, segment, clip, part | +| **silence region** | A detected gap in the audio where amplitude stays below threshold for at least `min_duration`. Stored normalized (0–1). | gap, pause, quiet zone | +| **raw silence gap** | A silence interval returned by `StudyAudio.raw_silence_regions`, before the 0.25s padding is applied. | raw gap, un-shrunk silence | +| **padding zone** | The first 0.25s of a speaking portion, treated as safe headroom so auto-pause does not trigger during normal pauses. | grace period, buffer zone | +| **auto-pause** | The study-mode mechanism that pauses playback when entering or exiting a silence boundary. | auto-stop, silence break | +| **smart play** | Hold-to-override button/input that suppresses auto-pause while held. | hold play, override button | +| **section counter** | UI label showing current speaking portion and total, e.g. `"12/70"`. | portion label, counter | +| **non-blocking seek** | The `skip_auto_update` pattern: after a seek, skip N frames of current-time sampling so the audio engine catches up. | seek delay, seek cooldown | +| **seek cooldown** | The integer `PlaybackState.skip_auto_update` counter that implements non-blocking seek. | skip frames, seek settle | + +--- + +## 14. Notes on the current template build + +The existing `./rebuild.sh` succeeded at the start of Phase 1, producing +`zig-out/bin/game`. No generated/vendor files were edited. Phase 1 will add only: + +- `notes/study-player-rewrite-plan.md` +- `game/study_player/study_player.rb` (minimal scaffold) +- `game/study_player/ui/` (placeholder) +- `.agents/knowledge/study-player.md` (scar-tissue companion) +- These entries also drive the proposed `GLOSSARY.md` update. + +Adding the `mrbgems/study_audio` mrbgem is a Phase 3 concern and requires +`rm -rf vendor/mruby/build` once, per rule `mruby-rebuild.md`. -- cgit v1.2.3