From c1ed35b6e11fd89006ac18f7893f07d4920e9dfc Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 29 Jun 2026 01:04:17 +0900 Subject: phase 2: audio core, argv file loading, playback, non-blocking seek (orchestrator-committed: Phase 2 agent (deepseek-v4-pro) completed the work and verified via build+run+screenshot, but its turn timed out before committing.) --- game/study_player/audio_adapter.rb | 109 ++++++ game/study_player/components.rb | 19 + game/study_player/config.rb | 17 + game/study_player/core.rb | 50 +++ game/study_player/input_adapter.rb | 99 +++++ game/study_player/layout.rb | 15 + game/study_player/study_player.rb | 604 +++++++++++++++++++++++++++-- game/study_player/systems/load_system.rb | 52 +++ game/study_player/systems/seek_system.rb | 43 ++ game/study_player/systems/update_system.rb | 48 +++ game/study_player/ui.rb | 11 + game/study_player/ui/main.rcss | 17 + game/study_player/ui/main.rml | 12 + mrbgems/raylib/src/raylib_bindings.c | 25 ++ src/main.c | 10 + 15 files changed, 1108 insertions(+), 23 deletions(-) create mode 100644 game/study_player/audio_adapter.rb create mode 100644 game/study_player/components.rb create mode 100644 game/study_player/config.rb create mode 100644 game/study_player/core.rb create mode 100644 game/study_player/input_adapter.rb create mode 100644 game/study_player/layout.rb create mode 100644 game/study_player/systems/load_system.rb create mode 100644 game/study_player/systems/seek_system.rb create mode 100644 game/study_player/systems/update_system.rb create mode 100644 game/study_player/ui.rb create mode 100644 game/study_player/ui/main.rcss create mode 100644 game/study_player/ui/main.rml diff --git a/game/study_player/audio_adapter.rb b/game/study_player/audio_adapter.rb new file mode 100644 index 0000000..d1e69ce --- /dev/null +++ b/game/study_player/audio_adapter.rb @@ -0,0 +1,109 @@ +# Study Player — Audio adapter (imperative shell). +# +# Wraps Rl::Music (LoadMusicStream / UnloadMusicStream / etc.) in a thin +# class that owns the handle and exposes play/pause/seek/update. +# +# The music handle is NOT stored in a flecs component (Ruby objects can't +# live in C struct components). It is owned by this adapter and held in +# the Runtime object. +# +# See: notes/study-player-rewrite-plan.md §5 (audio loading) + +module StudyPlayer + class AudioAdapter + attr_reader :music, :path + + def initialize + @music = nil + @path = nil + end + + # Load an audio file. Returns true on success, false on failure. + def load(path) + unload if @music + m = Rl.load_music_stream(path) + if m && Rl.music_valid?(m) + @music = m + @path = path + true + else + @music = nil + @path = nil + false + end + end + + def unload + return unless @music + Rl.stop_music_stream(@music) + Rl.unload_music_stream(@music) + @music = nil + @path = nil + end + + def loaded? + !!@music + end + + def play + return unless @music + Rl.play_music_stream(@music) + end + + def pause + return unless @music + Rl.pause_music_stream(@music) + end + + def resume + return unless @music + Rl.resume_music_stream(@music) + end + + def stop + return unless @music + Rl.stop_music_stream(@music) + end + + def playing? + return false unless @music + Rl.music_stream_playing?(@music) + end + + # Seek to an absolute position in seconds. + def seek(position_seconds) + return unless @music + Rl.seek_music_stream(@music, position_seconds) + end + + # Update the stream (must be called every frame while playing). + def update + return unless @music + Rl.update_music_stream(@music) + end + + # Get current playback position in seconds. + def current_time + return 0.0 unless @music + Rl.get_music_time_played(@music) + end + + # Get total duration in seconds. + def duration + return 0.0 unless @music + Rl.get_music_time_length(@music) + end + + # Set volume (0.0 to 1.0). + def volume=(v) + return unless @music + Rl.set_music_volume(@music, v) + end + + # Set playback speed / pitch (1.0 is normal). + def pitch=(p) + return unless @music + Rl.set_music_pitch(@music, p) + end + end +end diff --git a/game/study_player/components.rb b/game/study_player/components.rb new file mode 100644 index 0000000..0bb717a --- /dev/null +++ b/game/study_player/components.rb @@ -0,0 +1,19 @@ +# Study Player — Flecs component declarations. +# +# Components are real C structs declared at runtime via the flecs meta/reflection +# addon. Values are serialized as Ruby Hashes between C memory and Ruby. +# +# See: notes/study-player-rewrite-plan.md §3 (flecs component/state model) + +module StudyPlayer + # Declare components on a Flecs::World. Returns a Hash of component handles. + def self.declare_components(world) + { + audio_file: world.struct("AudioFile", "{string path; float duration;}"), + playback_state: world.struct("PlaybackState", "{bool loaded; bool playing; float current_time; int skip_auto_update; bool seek_pending; float seek_target;}"), + study_state: world.struct("StudyState", "{bool study_mode; bool was_in_silence; int last_silence_idx; bool smart_play_held;}"), + layout_dirty: world.struct("LayoutDirty", "{bool value;}"), + needs_load: world.tag("NeedsLoad"), + } + end +end diff --git a/game/study_player/config.rb b/game/study_player/config.rb new file mode 100644 index 0000000..342bcb7 --- /dev/null +++ b/game/study_player/config.rb @@ -0,0 +1,17 @@ +# Study Player — layout config persistence (Phase 2 stub; Phase 6 implements). +# +# See: notes/study-player-rewrite-plan.md §9 (layout persistence) + +module StudyPlayer + module Config + CFG_FILE = "study-player.cfg".freeze + + def self.load + {} + end + + def self.save(_layout) + true + end + end +end diff --git a/game/study_player/core.rb b/game/study_player/core.rb new file mode 100644 index 0000000..f402162 --- /dev/null +++ b/game/study_player/core.rb @@ -0,0 +1,50 @@ +# Study Player — Pure domain logic (no side effects). +# +# All functions are side-effect-free: no raylib, no flecs, no I/O. +# Testable without the engine. +# +# See: notes/study-player-rewrite-plan.md §8 (pure core API sketch) + +module StudyPlayer + module Core + # Format seconds as a display string. + # - < 1 hour: "M:SS" (e.g. "3:45", "12:03") + # - >= 1 hour: "H:MM:SS" (e.g. "1:23:45") + def self.format_time(total_seconds) + total = total_seconds.to_i + hours = total / 3600 + minutes = (total % 3600) / 60 + seconds = total % 60 + + if hours > 0 + format("%d:%02d:%02d", hours, minutes, seconds) + else + format("%d:%02d", minutes, seconds) + end + end + + # Seek target utilities (pure math, no audio I/O). + # + # clamp_seek_target ensures the seek position stays within [0, duration]. + def self.clamp_seek_target(target_seconds, duration) + return 0.0 if duration <= 0.0 + target_seconds.clamp(0.0, duration) + end + + # Compute absolute seek target from a ratio (0.0 to 1.0). + def self.ratio_to_seek(ratio, duration) + clamp_seek_target(ratio.to_f * duration, duration) + end + + # Compute absolute seek target from an offset (+/- seconds). + def self.offset_to_seek(current_time, offset_seconds, duration) + clamp_seek_target(current_time + offset_seconds, duration) + end + + # Compute ratio (0.0–1.0) from a time in seconds. + def self.time_to_ratio(current_time, duration) + return 0.0 if duration <= 0.0 + (current_time / duration).clamp(0.0, 1.0) + end + end +end diff --git a/game/study_player/input_adapter.rb b/game/study_player/input_adapter.rb new file mode 100644 index 0000000..d53d510 --- /dev/null +++ b/game/study_player/input_adapter.rb @@ -0,0 +1,99 @@ +# Study Player — Input adapter. +# +# Reads raylib keyboard input and translates it into flecs component +# mutations (seek intents, play/pause toggles, smart-play flags). +# +# Registered as a PRE_UPDATE system so intents are available before +# the seek/update systems run in ON_UPDATE. +# +# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design) + +module StudyPlayer + module InputAdapter + # Key bindings (from ../source/README.md): + # SPACE — play/pause toggle + # LEFT / RIGHT — seek -5s / +5s + # UP / DOWN — seek -15s / +15s + # J / L — seek -10% / +10% + # 0 .. 9 — seek to N×10% of duration + # ESC — quit + # + # Phase 4 adds: V/B (prev/next portion), SMART_PLAY hold override + + SEEK_SMALL = 5.0 # seconds: LEFT / RIGHT + SEEK_LARGE = 15.0 # seconds: UP / DOWN + SEEK_PCT_STEP = 0.10 # 10%: J / L + + def self.build_system(world, player_entity, runtime, playback_state) + world.system("Input", with: [], phase: Flecs::PRE_UPDATE) do + pb = player_entity.get(playback_state) + next unless pb + + # --- Play/pause toggle --- + if Rl.key_pressed?(:space) + if pb[:playing] + runtime.audio.pause + pb[:playing] = false + elsif pb[:loaded] + runtime.audio.resume + pb[:playing] = true + end + player_entity.set(playback_state, pb) + end + + # --- Seek: small step --- + duration = runtime.audio.duration + if Rl.key_pressed?(:right) + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_SMALL, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + elsif Rl.key_pressed?(:left) + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_SMALL, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + end + + # --- Seek: large step --- + if Rl.key_pressed?(:up) + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_LARGE, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + elsif Rl.key_pressed?(:down) + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_LARGE, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + end + + # --- Seek: percentage jump (J/L) --- + if Rl.key_pressed?(:j) && duration > 0 + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_PCT_STEP * duration, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + elsif Rl.key_pressed?(:l) && duration > 0 + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_PCT_STEP * duration, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + end + + # --- Seek: 0..9 → N×10% --- + if duration > 0 + (0..9).each do |n| + key = n.to_s.to_sym + if Rl.key_pressed?(key) + target = StudyPlayer::Core.ratio_to_seek(n * 0.1, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + end + end + end + end + end + end +end diff --git a/game/study_player/layout.rb b/game/study_player/layout.rb new file mode 100644 index 0000000..22bf26c --- /dev/null +++ b/game/study_player/layout.rb @@ -0,0 +1,15 @@ +# Study Player — layout map and defaults (Phase 2 stub; Phase 6 implements). +# +# See: notes/study-player-rewrite-plan.md §9 (layout persistence) + +module StudyPlayer + module Layout + def self.defaults + {} + end + + def self.apply(_ctx, _layout) + # Phase 6: walk layout keys and call Element#set_property on each. + end + end +end diff --git a/game/study_player/study_player.rb b/game/study_player/study_player.rb index 41e4fca..a6ee1e8 100644 --- a/game/study_player/study_player.rb +++ b/game/study_player/study_player.rb @@ -1,34 +1,592 @@ -# 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+. +# Study Player — composition root (Phase 2: audio core). # # Run: -# ./zig-out/bin/game game/study_player/study_player.rb +# ./zig-out/bin/game game/study_player/study_player.rb [/path/to/audio.mp3] +# +# With an audio file arg: opens straight into the player view with the file +# loaded and playing. Without an arg: shows the "no file loaded" splash. +# +# Note: all modules are inlined because mruby's default gembox lacks +# require/require_relative/load. Multi-file loading (with mruby-require) +# is a Phase 3 consideration. Individual module files are retained on disk +# as design documentation and for future use when a loader gem is added. +# +# See: notes/study-player-rewrite-plan.md (full design) + +# ============================================================================= +# Module: Core — Pure domain logic (no side effects) +# See: notes/study-player-rewrite-plan.md §8 (pure core API sketch) +# ============================================================================= + +module StudyPlayer + module Core + # Format seconds as a display string. + # - < 1 hour: "M:SS" (e.g. "3:45", "12:03") + # - >= 1 hour: "H:MM:SS" (e.g. "1:23:45") + def self.format_time(total_seconds) + total = total_seconds.to_i + hours = total / 3600 + minutes = (total % 3600) / 60 + seconds = total % 60 + + if hours > 0 + format("%d:%02d:%02d", hours, minutes, seconds) + else + format("%d:%02d", minutes, seconds) + end + end + + # Seek target utilities (pure math, no audio I/O). + # + # clamp_seek_target ensures the seek position stays within [0, duration]. + def self.clamp_seek_target(target_seconds, duration) + return 0.0 if duration <= 0.0 + target_seconds.clamp(0.0, duration) + end + + # Compute absolute seek target from a ratio (0.0 to 1.0). + def self.ratio_to_seek(ratio, duration) + clamp_seek_target(ratio.to_f * duration, duration) + end + + # Compute absolute seek target from an offset (+/- seconds). + def self.offset_to_seek(current_time, offset_seconds, duration) + clamp_seek_target(current_time + offset_seconds, duration) + end + + # Compute ratio (0.0–1.0) from a time in seconds. + def self.time_to_ratio(current_time, duration) + return 0.0 if duration <= 0.0 + (current_time / duration).clamp(0.0, 1.0) + end + end +end + +# ============================================================================= +# Module: AudioAdapter — Rl::Music lifecycle wrapper (imperative shell) +# See: notes/study-player-rewrite-plan.md §5 (audio loading) +# ============================================================================= + +module StudyPlayer + class AudioAdapter + attr_reader :music, :path + + def initialize + @music = nil + @path = nil + end + + # Load an audio file. Returns true on success, false on failure. + def load(path) + unload if @music + m = Rl.load_music_stream(path) + if m && Rl.music_valid?(m) + @music = m + @path = path + true + else + @music = nil + @path = nil + false + end + end + + def unload + return unless @music + Rl.stop_music_stream(@music) + Rl.unload_music_stream(@music) + @music = nil + @path = nil + end + + def loaded? + !!@music + end + + def play + return unless @music + Rl.play_music_stream(@music) + end + + def pause + return unless @music + Rl.pause_music_stream(@music) + end + + def resume + return unless @music + Rl.resume_music_stream(@music) + end + + def stop + return unless @music + Rl.stop_music_stream(@music) + end + + def playing? + return false unless @music + Rl.music_stream_playing?(@music) + end -Rl.init_window(1280, 720, "Study Player") -Rl.target_fps = 60 + # Seek to an absolute position in seconds. + def seek(position_seconds) + return unless @music + Rl.seek_music_stream(@music, position_seconds) + end -Rml.init -Rml.load_font("game/ui/LatoLatin-Regular.ttf") -Rml.load_font("game/ui/LatoLatin-Bold.ttf") + # Update the stream (must be called every frame while playing). + def update + return unless @music + Rl.update_music_stream(@music) + end -ui = Rml::Context.new("main") + # Get current playback position in seconds. + def current_time + return 0.0 unless @music + Rl.get_music_time_played(@music) + end -bg = Rl::Color.new(26, 26, 46, 255) -txt = Rl::Color.new(234, 234, 234, 255) -accent = Rl::Color.new(233, 69, 96, 255) + # Get total duration in seconds. + def duration + return 0.0 unless @music + Rl.get_music_time_length(@music) + end -Rl.while_window_open do - ui.process_input + # Set volume (0.0 to 1.0). + def volume=(v) + return unless @music + Rl.set_music_volume(@music, v) + end - 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) + # Set playback speed / pitch (1.0 is normal). + def pitch=(p) + return unless @music + Rl.set_music_pitch(@music, p) + end end +end + +# ============================================================================= +# Module: Components — Flecs component declarations +# See: notes/study-player-rewrite-plan.md §3 (flecs component/state model) +# ============================================================================= + +module StudyPlayer + # Declare components on a Flecs::World. Returns a Hash of component handles. + def self.declare_components(world) + { + audio_file: world.struct("AudioFile", "{string path; float duration;}"), + playback_state: world.struct("PlaybackState", "{bool loaded; bool playing; float current_time; int32_t skip_auto_update; bool seek_pending; float seek_target;}"), + study_state: world.struct("StudyState", "{bool study_mode; bool was_in_silence; int32_t last_silence_idx; bool smart_play_held;}"), + layout_dirty: world.struct("LayoutDirty", "{bool value;}"), + needs_load: world.tag("NeedsLoad"), + } + end +end + +# ============================================================================= +# Module: InputAdapter — Keyboard input handler +# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design) +# ============================================================================= + +module StudyPlayer + module InputAdapter + # Key bindings (from ../source/README.md): + # SPACE — play/pause toggle + # LEFT / RIGHT — seek -5s / +5s + # UP / DOWN — seek -15s / +15s + # J / L — seek -10% / +10% + # 0 .. 9 — seek to N×10% of duration + # ESC — quit + # + # Phase 4 adds: V/B (prev/next portion), SMART_PLAY hold override + + SEEK_SMALL = 5.0 # seconds: LEFT / RIGHT + SEEK_LARGE = 15.0 # seconds: UP / DOWN + SEEK_PCT_STEP = 0.10 # 10%: J / L + + def self.build_system(world, player_entity, runtime, playback_state) + world.system("Input", with: [], phase: Flecs::PRE_UPDATE) do + pb = player_entity.get(playback_state) + next unless pb + + # --- Play/pause toggle --- + if Rl.key_pressed?(:space) + if pb[:playing] + runtime.audio.pause + pb[:playing] = false + elsif pb[:loaded] + runtime.audio.resume + pb[:playing] = true + end + player_entity.set(playback_state, pb) + end + + # --- Seek: small step --- + duration = runtime.audio.duration + if Rl.key_pressed?(:right) + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_SMALL, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + elsif Rl.key_pressed?(:left) + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_SMALL, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + end + + # --- Seek: large step --- + if Rl.key_pressed?(:up) + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_LARGE, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + elsif Rl.key_pressed?(:down) + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_LARGE, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + end - ui.update - ui.render + # --- Seek: percentage jump (J/L) --- + if Rl.key_pressed?(:j) && duration > 0 + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_PCT_STEP * duration, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + elsif Rl.key_pressed?(:l) && duration > 0 + target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_PCT_STEP * duration, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + end + + # --- Seek: 0..9 → N×10% --- + if duration > 0 + (0..9).each do |n| + key = n.to_s.to_sym + if Rl.key_pressed?(key) + target = StudyPlayer::Core.ratio_to_seek(n * 0.1, duration) + pb[:seek_target] = target + pb[:seek_pending] = true + player_entity.set(playback_state, pb) + end + end + end + end + end + end +end + +# ============================================================================= +# System: LoadSystem — Reacts to NeedsLoad tag +# See: notes/study-player-rewrite-plan.md §5 (audio loading from argv) +# ============================================================================= + +module StudyPlayer + class LoadSystem + def self.build(world, player_entity, runtime, audio_file_comp, playback_state, needs_load_tag) + world.system("LoadAudio", with: [audio_file_comp], phase: Flecs::PRE_UPDATE) do |eid, af| + # Only react to entities that also have NeedsLoad + ent = world.entity_for(eid) + next unless ent.has?(needs_load_tag) + + path = af[:path].to_s + next if path.empty? + + success = runtime.audio.load(path) + if success + duration = runtime.audio.duration + pb = player_entity.get(playback_state) || {} + pb[:loaded] = true + pb[:playing] = true # auto-play on load + pb[:current_time] = 0.0 + pb[:skip_auto_update] = 0 + pb[:seek_pending] = false + pb[:seek_target] = 0.0 + player_entity.set(playback_state, pb) + + # Also update the AudioFile component with actual duration + af[:duration] = duration + ent.set(audio_file_comp, af) + + # Start playback + runtime.audio.play + else + # Load failed — clear the file path + af[:path] = "" + ent.set(audio_file_comp, af) + end + + # Clear the NeedsLoad tag (one-shot) + ent.remove(needs_load_tag) + end + end + end end + +# ============================================================================= +# System: UpdateSystem — Per-frame audio update + time management +# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design) +# ============================================================================= + +module StudyPlayer + class UpdateSystem + def self.build(world, player_entity, runtime, playback_state) + world.system("Update", with: [], phase: Flecs::ON_UPDATE) do + pb = player_entity.get(playback_state) + next unless pb && pb[:loaded] + + audio = runtime.audio + + # Always pump the stream (needed even when paused to keep buffers filled) + audio.update if audio.loaded? + + if pb[:playing] && audio.loaded? + if pb[:skip_auto_update] > 0 + # Non-blocking seek cooldown: skip sampling current time from the + # audio engine for N frames so it catches up without visual flicker. + pb[:skip_auto_update] -= 1 + else + # Normal time sampling + pb[:current_time] = audio.current_time + end + + # End-of-track detection: pause when playback reaches the end + duration = audio.duration + if duration > 0 && pb[:current_time] >= duration - 0.1 + audio.pause + pb[:playing] = false + pb[:current_time] = duration + end + + player_entity.set(playback_state, pb) + end + end + end + end +end + +# ============================================================================= +# System: SeekSystem — Applies pending seek (non-blocking) +# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design) +# ============================================================================= + +module StudyPlayer + class SeekSystem + # Number of frames to skip current_time auto-update after a seek. + # 3 frames at 60 fps = ~50 ms — enough for raylib's miniaudio stream + # to flush and report a stable time. + SKIP_FRAMES = 3 + + def self.build(world, player_entity, runtime, playback_state) + world.system("ApplySeek", with: [], phase: Flecs::PRE_UPDATE) do + pb = player_entity.get(playback_state) + next unless pb && pb[:loaded] && pb[:seek_pending] + + audio = runtime.audio + next unless audio.loaded? + + target = pb[:seek_target] + + # Apply the seek + audio.seek(target) + + # Update component state + pb[:current_time] = target + pb[:skip_auto_update] = SKIP_FRAMES + pb[:seek_pending] = false + + player_entity.set(playback_state, pb) + end + end + end +end + +# ============================================================================= +# Composition Root — init, components, systems, main loop +# ============================================================================= + +module StudyPlayer + SCREEN_W = 1280 + SCREEN_H = 720 + + # Runtime: owns non-serializable handles (Rl::Music) and the flecs world. + class Runtime + attr_accessor :world, :player_entity, :audio, :components + def initialize(world:, player_entity:, audio:, components:) + @world = world + @player_entity = player_entity + @audio = audio + @components = components + end + end + + def self.run + Rl.init_window(SCREEN_W, SCREEN_H, "Study Player") + Rl.target_fps = 60 + Rl.init_audio_device + + # --- Flecs world + components --- + world = Flecs::World.new + comps = declare_components(world) + af = comps[:audio_file] + pb = comps[:playback_state] + ss = comps[:study_state] + ld = comps[:layout_dirty] + nl = comps[:needs_load] + + # --- Singleton entity --- + player_entity = world.entity("study_player") + .set(af, { path: "", duration: 0.0 }) + .set(pb, { loaded: false, playing: false, current_time: 0.0, + skip_auto_update: 0, seek_pending: false, seek_target: 0.0 }) + .set(ss, { study_mode: false, was_in_silence: false, + last_silence_idx: -1, smart_play_held: false }) + .set(ld, { value: false }) + + # --- Audio adapter --- + audio = AudioAdapter.new + + # --- Runtime --- + runtime = Runtime.new( + world: world, + player_entity: player_entity, + audio: audio, + components: comps, + ) + + # --- Register systems --- + LoadSystem.build(world, player_entity, runtime, af, pb, nl) + SeekSystem.build(world, player_entity, runtime, pb) + InputAdapter.build_system(world, player_entity, runtime, pb) + UpdateSystem.build(world, player_entity, runtime, pb) + + # --- File drop check system (drag-and-drop fallback) --- + world.system("CheckFileDrop", with: [], phase: Flecs::PRE_UPDATE) do + if Rl.file_dropped? + files = Rl.load_dropped_files + if files && files.count > 0 + path = files.path_at(0) + if path && !path.empty? + ent = player_entity + ent.set(af, { path: path, duration: 0.0 }) + ent.add(nl) + end + end + Rl.unload_dropped_files(files) + end + end + + # --- Audio file from ARGV (Phase 2: opens straight into player view) --- + audio_arg = nil + begin + audio_arg = ARGV[0] if ARGV && ARGV.length > 0 + rescue + end + if audio_arg && !audio_arg.to_s.empty? + path = audio_arg.to_s + player_entity + .set(af, { path: path, duration: 0.0 }) + .add(nl) + end + + # --- Main loop --- + Rl.while_window_open do + dt = Rl.frame_time + + # Check for quit + if Rl.key_pressed?(:escape) + Rl.close_window + break + end + + # Run ECS systems + world.progress(dt) + + # --- Draw: minimal on-screen status for Phase 2 verification --- + Rl.draw(clear_color: Rl::Color.new(26, 26, 46, 255)) do + pb_current = player_entity.get(pb) + af_current = player_entity.get(af) + + if pb_current && pb_current[:loaded] + # Player view: show filename + time + play state + duration = af_current[:duration] + pos = pb_current[:current_time] + playing = pb_current[:playing] + skip_ct = pb_current[:skip_auto_update] + seeking = pb_current[:seek_pending] + path = af_current[:path].to_s + + # Filename (basename only) + fname = File.basename(path) + elapsed_str = Core.format_time(pos) + total_str = Core.format_time(duration) + ratio = Core.time_to_ratio(pos, duration) + + state_str = if seeking + "SEEKING..." + elsif skip_ct > 0 + "SEEK SETTLE (#{skip_ct})" + elsif playing + "PLAYING" + else + "PAUSED" + end + + # Title + Rl.draw_text(text: fname, + x: SCREEN_W / 2 - 200, y: 260, font_size: 24, + color: Rl::Color.new(234, 234, 234, 255)) + + # Time display + time_text = "#{elapsed_str} / #{total_str}" + Rl.draw_text(text: time_text, + x: SCREEN_W / 2 - 150, y: 310, font_size: 40, + color: Rl::Color.new(200, 200, 210, 255)) + + # Progress bar (simple rects) + bar_x = 100; bar_y = 380; bar_w = SCREEN_W - 200; bar_h = 12 + Rl.draw_rectangle(bar_x, bar_y, bar_w, bar_h, Rl::Color.new(40, 40, 60, 255)) + Rl.draw_rectangle(bar_x, bar_y, (bar_w * ratio).to_i, bar_h, + Rl::Color.new(233, 69, 96, 255)) + + # Play state + Rl.draw_text(text: state_str, + x: SCREEN_W / 2 - 60, y: 420, font_size: 18, + color: playing ? Rl::Color.new(100, 200, 100, 255) : Rl::Color.new(200, 100, 100, 255)) + + # Controls help + Rl.draw_text(text: "SPACE: play/pause LEFT/RIGHT: -5s/+5s UP/DOWN: -15s/+15s J/L: -10%/+10% 0-9: n×10% ESC: quit", + x: 20, y: SCREEN_H - 60, font_size: 14, + color: Rl::Color.new(120, 120, 140, 255)) + + # Debug: skip_auto_update counter + if skip_ct > 0 + Rl.draw_text(text: "seek cooldown: #{skip_ct}", + x: 20, y: SCREEN_H - 90, font_size: 12, + color: Rl::Color.new(200, 200, 60, 255)) + end + else + # Splash: no file loaded + Rl.draw_text(text: "Study Player", + x: SCREEN_W / 2 - 180, y: 260, font_size: 60, + color: Rl::Color.new(234, 234, 234, 255)) + Rl.draw_text(text: "Drop an audio file or run with: game study_player.rb ", + x: SCREEN_W / 2 - 330, y: 380, font_size: 18, + color: Rl::Color.new(233, 69, 96, 255)) + Rl.draw_text(text: "SPACE: start ESC: quit", + x: SCREEN_W / 2 - 100, y: 430, font_size: 16, + color: Rl::Color.new(120, 120, 140, 255)) + end + end + end + + # --- Shutdown --- + audio.unload if audio.loaded? + Rl.close_audio_device + end +end + +StudyPlayer.run diff --git a/game/study_player/systems/load_system.rb b/game/study_player/systems/load_system.rb new file mode 100644 index 0000000..49a621e --- /dev/null +++ b/game/study_player/systems/load_system.rb @@ -0,0 +1,52 @@ +# Study Player — Load system. +# +# Reacts to entities with the NeedsLoad tag. Calls AudioAdapter.load, +# then sets the PlaybackState component (loaded, playing, duration, +# current_time=0). +# +# Registered as PRE_UPDATE so other systems in ON_UPDATE see the new state +# in the same frame. +# +# See: notes/study-player-rewrite-plan.md §5 (audio loading from argv) + +module StudyPlayer + class LoadSystem + def self.build(world, player_entity, runtime, audio_file_comp, playback_state, needs_load_tag) + world.system("LoadAudio", with: [audio_file_comp], phase: Flecs::PRE_UPDATE) do |eid, af| + # Only react to entities that also have NeedsLoad + ent = world.entity_for(eid) + next unless ent.has?(needs_load_tag) + + path = af[:path].to_s + next if path.empty? + + success = runtime.audio.load(path) + if success + duration = runtime.audio.duration + pb = player_entity.get(playback_state) || {} + pb[:loaded] = true + pb[:playing] = true # auto-play on load + pb[:current_time] = 0.0 + pb[:skip_auto_update] = 0 + pb[:seek_pending] = false + pb[:seek_target] = 0.0 + player_entity.set(playback_state, pb) + + # Also update the AudioFile component with actual duration + af[:duration] = duration + ent.set(audio_file_comp, af) + + # Start playback + runtime.audio.play + else + # Load failed — clear the file path + af[:path] = "" + ent.set(audio_file_comp, af) + end + + # Clear the NeedsLoad tag (one-shot) + ent.remove(needs_load_tag) + end + end + end +end diff --git a/game/study_player/systems/seek_system.rb b/game/study_player/systems/seek_system.rb new file mode 100644 index 0000000..a11858a --- /dev/null +++ b/game/study_player/systems/seek_system.rb @@ -0,0 +1,43 @@ +# Study Player — Seek system (PRE_UPDATE). +# +# Applies a pending seek intent: +# 1. Calls Rl.seek_music_stream to the target position. +# 2. Sets current_time = seek_target (instant visual update). +# 3. Sets skip_auto_update = N to implement non-blocking seek cooldown. +# 4. Clears seek_pending. +# +# Running in PRE_UPDATE means the subsequent UpdateSystem (ON_UPDATE) +# sees the already-applied seek and honours the cooldown counter. +# +# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design) + +module StudyPlayer + class SeekSystem + # Number of frames to skip current_time auto-update after a seek. + # 3 frames at 60 fps = ~50 ms — enough for raylib's miniaudio stream + # to flush and report a stable time. + SKIP_FRAMES = 3 + + def self.build(world, player_entity, runtime, playback_state) + world.system("ApplySeek", with: [], phase: Flecs::PRE_UPDATE) do + pb = player_entity.get(playback_state) + next unless pb && pb[:loaded] && pb[:seek_pending] + + audio = runtime.audio + next unless audio.loaded? + + target = pb[:seek_target] + + # Apply the seek + audio.seek(target) + + # Update component state + pb[:current_time] = target + pb[:skip_auto_update] = SKIP_FRAMES + pb[:seek_pending] = false + + player_entity.set(playback_state, pb) + end + end + end +end diff --git a/game/study_player/systems/update_system.rb b/game/study_player/systems/update_system.rb new file mode 100644 index 0000000..5a1ebab --- /dev/null +++ b/game/study_player/systems/update_system.rb @@ -0,0 +1,48 @@ +# Study Player — Update system (ON_UPDATE). +# +# Every frame: +# 1. Calls Rl.update_music_stream (pumps the audio buffer). +# 2. Manages current_time: while skip_auto_update > 0, decrements it +# without sampling the audio engine; otherwise samples via +# Rl.get_music_time_played. This implements the non-blocking seek +# cooldown. +# 3. Detects end-of-track (current_time >= duration) and auto-pauses. +# +# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design) + +module StudyPlayer + class UpdateSystem + def self.build(world, player_entity, runtime, playback_state) + world.system("Update", with: [], phase: Flecs::ON_UPDATE) do + pb = player_entity.get(playback_state) + next unless pb && pb[:loaded] + + audio = runtime.audio + + # Always pump the stream (needed even when paused to keep buffers filled) + audio.update if audio.loaded? + + if pb[:playing] && audio.loaded? + if pb[:skip_auto_update] > 0 + # Non-blocking seek cooldown: skip sampling current time from the + # audio engine for N frames so it catches up without visual flicker. + pb[:skip_auto_update] -= 1 + else + # Normal time sampling + pb[:current_time] = audio.current_time + end + + # End-of-track detection: pause when playback reaches the end + duration = audio.duration + if duration > 0 && pb[:current_time] >= duration - 0.1 + audio.pause + pb[:playing] = false + pb[:current_time] = duration + end + + player_entity.set(playback_state, pb) + end + end + end + end +end diff --git a/game/study_player/ui.rb b/game/study_player/ui.rb new file mode 100644 index 0000000..2c3452e --- /dev/null +++ b/game/study_player/ui.rb @@ -0,0 +1,11 @@ +# Study Player — RmlUi wrapper (Phase 2 stub; Phase 5 implements UI). +# +# See: notes/study-player-rewrite-plan.md §6 (RmlUi UI structure) + +module StudyPlayer + class UI + def initialize(_runtime) + # Placeholder: Phase 5 loads game/study_player/ui/main.rml here. + end + end +end diff --git a/game/study_player/ui/main.rcss b/game/study_player/ui/main.rcss new file mode 100644 index 0000000..3fa724d --- /dev/null +++ b/game/study_player/ui/main.rcss @@ -0,0 +1,17 @@ +/* Phase 2: minimal stylesheet stub. Phase 5 adds the full UI. */ +body { + width: 100%; + height: 100%; + background-color: #1a1a2e; +} + +#study-player-root { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + color: #eaeaee; + font-size: 24; + text-align: center; +} diff --git a/game/study_player/ui/main.rml b/game/study_player/ui/main.rml new file mode 100644 index 0000000..38d0c54 --- /dev/null +++ b/game/study_player/ui/main.rml @@ -0,0 +1,12 @@ + + + Study Player + + + + +
+

Study Player (Phase 2)

+
+ +
diff --git a/mrbgems/raylib/src/raylib_bindings.c b/mrbgems/raylib/src/raylib_bindings.c index 50ee180..0da1fb6 100644 --- a/mrbgems/raylib/src/raylib_bindings.c +++ b/mrbgems/raylib/src/raylib_bindings.c @@ -68,6 +68,25 @@ rl_update_texture(mrb_state *mrb, mrb_value self) return mrb_nil_value(); } +/* + * FilePathList#path_at(index) -> String | nil + * + * Returns the path at the given index. The generated FilePathList struct + * wrapper only exposes `count`/`count=`; the `paths` member (char**) is + * a raw pointer the generator skips. This hand-written method provides + * indexed access for the drag-drop fallback in study-player Phase 2. + */ +static mrb_value +rl_fl_path_at(mrb_state *mrb, mrb_value self) +{ + mrb_int idx; + mrb_get_args(mrb, "i", &idx); + FilePathList *fl = (FilePathList *)DATA_PTR(self); + if (!fl || !fl->paths || idx < 0 || idx >= (mrb_int)fl->count) + return mrb_nil_value(); + return mrb_str_new_cstr(mrb, fl->paths[idx]); +} + static mrb_value rl_is_web(mrb_state *mrb, mrb_value self) { @@ -118,6 +137,12 @@ mrb_raylib_gem_init(mrb_state *mrb) mrb_define_module_function(mrb, rl, "update_texture", rl_update_texture, MRB_ARGS_REQ(2)); mrb_define_module_function(mrb, rl, "smaa_area_bytes", rl_smaa_area_bytes, MRB_ARGS_NONE()); mrb_define_module_function(mrb, rl, "smaa_search_bytes", rl_smaa_search_bytes, MRB_ARGS_NONE()); + + /* FilePathList#path_at: indexed access to dropped file paths (generator skips char**). */ + { + struct RClass *flc = mrb_class_get_under(mrb, rl, "FilePathList"); + mrb_define_method(mrb, flc, "path_at", rl_fl_path_at, MRB_ARGS_REQ(1)); + } } void diff --git a/src/main.c b/src/main.c index 1a7a59d..15a964b 100644 --- a/src/main.c +++ b/src/main.c @@ -12,6 +12,7 @@ #include #include #include +#include /* mrb_ary_new / mrb_ary_push (ARGV) */ #include /* mrb_const_get (web eval entry) */ #ifdef __EMSCRIPTEN__ #include @@ -174,6 +175,15 @@ main(int argc, char **argv) g_mrb = mrb; jamstack_bridge_init(mrb); + /* Expose argv[2..] as Ruby ARGV constant (Phase 2: study-player audio file). */ + { + 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); + } + size_t len = 0; char *src = read_file(script, &len); if (!src) { mrb_close(mrb); return 1; } -- cgit v1.2.3