# 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`.