#include "scroller_page.h" #include "app_state.h" #include "audio/audio_downloader.h" #include "logical_size.h" #include "proclamation_options_page.h" #include "scramble/fibonacci_scrambler.h" #include "timezone_helper.h" #include "utf8_utils.h" #include #include #include #include #include #include #include #include #include #include using radiantwave::FibonacciLinePattern; using radiantwave::scroller::ProclamationStream; namespace { constexpr float kFadeSeconds = 5.0f; constexpr float kVelocityMin = -3500.0f; constexpr float kVelocityMax = 3500.0f; } // anonymous namespace void ScrollerPage::onPush(AppState &state) { state_ = &state; // Save the current logical presentation (LETTERBOX at design resolution) // and switch to NONE so we render into the full physical window. This // lets the scroller scroll the entire vertical extent of tall/rotated // displays while keeping text at the same pixel scale. onPop restores // the original presentation for other pages. SDL_GetRenderLogicalPresentation(state.renderer, &savedLogicalW_, &savedLogicalH_, &savedMode_); // Use the renderer's actual output size in pixels, not the window size in // points. SDL_GetWindowSize() returns screen coordinates, which can be // smaller than the real drawable if the display has a content scale // applied (common on some monitors/compositors) — that mismatch caused // this page to lay out and center within a sub-region of the screen // instead of the full physical canvas. SDL_GetRenderOutputSize() gives us // the true pixel dimensions of whatever we're actually rendering into. int windowW = 0; int windowH = 0; SDL_GetRenderOutputSize(state.renderer, &windowW, &windowH); if (windowW <= 0) { windowW = radiantwave::kDesignWidth; } if (windowH <= 0) { windowH = radiantwave::kDesignHeight; } SDL_SetRenderLogicalPresentation(state.renderer, windowW, windowH, SDL_LOGICAL_PRESENTATION_DISABLED); screenW_ = static_cast(windowW); screenH_ = static_cast(windowH); // Lines are capped at the full screen width; the widest Fibonacci lines // are sized to fill it with whole words (peak word count derived from the // screen), independent of resolution. maxLineWidth_ = screenW_; TTF_Init(); buildStreams(state); createFont(state); createPatterns(); lines_.clear(); prefilling_ = true; prefillLines(); prefilling_ = false; // Start audio. In dynamic mode the current stream's solfeggio-frequency // track wins (when its file exists); otherwise the configured manual track // plays. A frequency file that is not on disk yet falls back to the manual // track rather than playing nothing. A schedule block with dynamicAudio=true // forces dynamic audio for its duration; false leaves the device setting be. std::string track = state.config.get().audio.current_track; if (effectiveDynamicAudio() && !streams_.empty()) { const ProclamationStream &stream = streams_[streamIndex_]; if (stream.solfeggio_frequency_id != 0 && !stream.audioFilename.empty() && std::filesystem::exists(audioFilePath(stream.audioFilename))) { track = stream.audioFilename; } } if (std::filesystem::exists(audioFilePath(track))) { state.audio.playTrackFadeIn(track, kFadeSeconds); } state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() { if (state_) { state_->pages.pop(*state_); } }); } void ScrollerPage::onResume(AppState &state) { state_ = &state; createFont(state); createPatterns(); rebuildWordTextures(); state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() { if (state_) { state_->pages.pop(*state_); } }); } void ScrollerPage::onPause(AppState &state) { state.keybinds.unbindAll(); destroyResources(); } void ScrollerPage::onPop(AppState &state) { // Restore the original logical presentation so other pages (Home, // Settings, etc.) render in the design-resolution LETTERBOX space. SDL_SetRenderLogicalPresentation(state.renderer, savedLogicalW_, savedLogicalH_, savedMode_); state.audio.stopTrackFadeOut(kFadeSeconds); state.keybinds.unbindAll(); destroyResources(); TTF_Quit(); } void ScrollerPage::handleEvent(const SDL_Event &) {} void ScrollerPage::update(float dt) { if (state_) { state_->audio.update(dt); } // Block boundaries are minute-granular, so a one-second poll is plenty and // avoids mutating the process timezone every frame. schedulePollTimer_ -= dt; if (schedulePollTimer_ <= 0.0f) { schedulePollTimer_ = 1.0f; pollScheduleChange(); } updateAutoVelocity(dt); scrollOffset_ += velocity_ * dt; float move = std::floor(scrollOffset_); if (move != 0.0f) { scrollOffset_ -= move; for (auto &line : lines_) { for (auto &w : line.words) { w.y += move; } } } if (lines_.empty()) { return; } if (velocity_ > 0.0f) { // Lines move downward. Recycle every line that scrolled fully off the // bottom this frame (movement is continuous, so one frame can cover // several line heights) and add a replacement just above the top. while (!lines_.empty() && lines_.back().words[0].y > screenH_) { float baseY = lines_.front().words[0].y - lineHeight_; lines_.pop_back(); if (!addLineAt(baseY)) { break; } } } else if (velocity_ < 0.0f) { // Lines move upward. Recycle every line that scrolled fully off the top // this frame and add a replacement just below the bottom. while (!lines_.empty() && lines_.front().words[0].y + lines_.front().words[0].height < 0.0f) { float baseY = lines_.back().words[0].y + lineHeight_; lines_.pop_front(); if (!addLineAt(baseY)) { break; } } } } void ScrollerPage::render(SDL_Renderer *renderer) { SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); SDL_RenderClear(renderer); for (const auto &line : lines_) { if (line.words.empty()) { continue; } float startX = std::floor((screenW_ - line.width) / 2.0f); for (const auto &w : line.words) { SDL_SetTextureColorMod(w.texture, line.r, line.g, line.b); SDL_FRect dst{startX + w.x, w.y, w.width, w.height}; SDL_RenderTexture(renderer, w.texture, nullptr, &dst); } } } void ScrollerPage::buildStreams(AppState &state) { streams_.clear(); fallbackPalette_.clear(); streamIndex_ = 0; scheduleChangePending_ = false; // Schedule state: resolve the active Block from the schedule manager (which // owns the fetched/cached payload and the local on/off toggle). The block // boundary is handled in advanceStream(), so rebuilding mid-playback never // truncates the current proclamation. const radiantwave::config::AppConfig cfg = state.config.get(); timezone_ = cfg.timezone; // Keep the engine's local toggle in step with config (it may have been // changed in System Settings or hot-reloaded). state.scheduleManager.setEnabled(cfg.proclamation.schedule_enabled); const int now = radiantwave::timezone::currentLocalMinutes(timezone_); const radiantwave::schedule::Weekday weekday = radiantwave::schedule::weekdayFromIndex( radiantwave::timezone::currentLocalWeekday(timezone_)); const radiantwave::schedule::Decision decision = state.scheduleManager.activeBlock(now, weekday); scheduleActive_ = state.scheduleManager.enabled(); if (decision.hasBlock) { activeBlockProclamationIds_ = decision.block.proclamation_ids; activeBlockDynamicAudio_ = decision.block.dynamic_audio; activeBlockIndex_ = 1; // any non-negative marker: a block is active } else { activeBlockProclamationIds_.clear(); activeBlockDynamicAudio_ = false; activeBlockIndex_ = -1; } auto proclamations = state.db.getActiveProclamations(); if (proclamations.empty()) { return; } for (const auto &c : state.db.getPaletteForFrequencyId(5)) { // 528 Hz palette fallbackPalette_.push_back({c.r, c.g, c.b, c.a}); } std::unordered_map> palettesByFreqId; for (const auto &rec : proclamations) { int freqId = rec.solfeggio_frequency_id; if (palettesByFreqId.count(freqId) != 0) { continue; } std::vector colors; for (const auto &c : state.db.getPaletteForFrequencyId(freqId)) { colors.push_back({c.r, c.g, c.b, c.a}); } palettesByFreqId[freqId] = std::move(colors); } std::unordered_map audioByFreqId; for (const auto &af : state.db.getAudioFiles()) { int freqId = af.solfeggio_frequency_id; // Self-heal for databases seeded from a legacy manifest that left the // frequency id at 0 for every row: recover the id from the filename. if (freqId == 0) { freqId = radiantwave::audio::frequencyIdForHz( radiantwave::audio::hzFromFilename(af.filename)); } if (freqId != 0) { audioByFreqId[freqId] = af; } } const std::vector* scheduledIds = nullptr; std::vector scheduledOrder; if (activeBlockIndex_ >= 0 && !activeBlockProclamationIds_.empty()) { scheduledOrder = activeBlockProclamationIds_; scheduledIds = &scheduledOrder; } streams_ = radiantwave::scroller::buildStreams( proclamations, palettesByFreqId, fallbackPalette_, audioByFreqId, DEFAULT_PROPLEAMATION_ID, scheduledIds); } bool ScrollerPage::scheduleBlockActiveNow() const { if (!state_) { return false; } const int now = radiantwave::timezone::currentLocalMinutes(timezone_); const radiantwave::schedule::Weekday weekday = radiantwave::schedule::weekdayFromIndex( radiantwave::timezone::currentLocalWeekday(timezone_)); return state_->scheduleManager.activeBlock(now, weekday).hasBlock; } void ScrollerPage::pollScheduleChange() { // The manager owns the local toggle; when off, there is never an active block. if (!state_) { return; } const bool activeNow = scheduleBlockActiveNow(); const bool hadBlock = activeBlockIndex_ >= 0; if (activeNow != hadBlock) { scheduleChangePending_ = true; } } bool ScrollerPage::effectiveDynamicAudio() const { // dynamicAudio=true on the active block forces dynamic audio, overriding // whatever the device selected. false means "don't override": the device's // own selection (dynamic or a fixed track) applies as usual. if (scheduleActive_ && activeBlockIndex_ >= 0 && activeBlockDynamicAudio_) { return true; } return state_ ? state_->config.get().audio.dynamic_audio : false; } void ScrollerPage::createFont(AppState &state) { if (font_) { TTF_CloseFont(font_); font_ = nullptr; } // A different font or size changes every rasterized glyph, so the scrambled // tile atlas is invalid. clearCharTextures() also runs on pause/pop, but // this covers a re-entry that re-creates the font without a destroy cycle. clearCharTextures(); fontPxSize_ = state.config.get().proclamation.font_size; if (fontPxSize_ <= 0) { fontPxSize_ = 32; } font_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", fontPxSize_); if (!font_) { return; } int w = 0; int h = 0; TTF_GetStringSize(font_, "Mg", 0, &w, &h); lineHeight_ = h > 0 ? static_cast(h) : static_cast(fontPxSize_); spaceWidth_ = measureWidth(" "); if (spaceWidth_ <= 0.0f) { spaceWidth_ = static_cast(fontPxSize_) * 0.5f; } } void ScrollerPage::createPatterns() { patterns_.clear(); patterns_.reserve(streams_.size()); for (size_t i = 0; i < streams_.size(); ++i) { int peak = computePeakWordCount(streams_[i].words); patterns_.emplace_back( peak, maxLineWidth_, spaceWidth_, [this](const std::string &word) { return measureWidth(word); }); } } int ScrollerPage::computePeakWordCount( const std::vector &words) const { if (words.empty() || screenW_ <= 0.0f) { return 1; } // Average rendered cost of one word, including its share of inter-word // spacing. The peak is the number of whole words that fit across the // screen, so the widest Fibonacci lines fill the width at natural spacing. double total = 0.0; for (const auto &w : words) { total += measureWidth(w); } double n = static_cast(words.size()); double perWord = (total + (n - 1.0) * spaceWidth_) / n; if (perWord <= 0.0) { perWord = 1.0; } int fit = static_cast(std::lround(screenW_ / perWord)); return fit < 1 ? 1 : fit; } void ScrollerPage::destroyResources() { clearCharTextures(); for (auto &entry : wordTextures_) { if (entry.second.texture) { SDL_DestroyTexture(entry.second.texture); } } wordTextures_.clear(); if (font_) { TTF_CloseFont(font_); font_ = nullptr; } } void ScrollerPage::rebuildWordTextures() { wordTextures_.clear(); for (auto &line : lines_) { float width = 0.0f; for (auto &item : line.words) { ensureWordTexture(item.text); const CachedWord &cached = wordTextures_[item.text]; if (!cached.texture) { continue; } item.texture = cached.texture; item.width = cached.width; item.height = cached.height; if (width > 0.0f) { width += spaceWidth_; } item.x = width; width += item.width; } line.width = width; } } void ScrollerPage::prefillLines() { if (streams_.empty() || lineHeight_ <= 0.0f) { return; } int n = static_cast(std::ceil(screenH_ / lineHeight_)) + 2; for (int i = 0; i < n; ++i) { float baseY = -lineHeight_ + static_cast(i) * lineHeight_; addLineAt(baseY); } } bool ScrollerPage::addLineAt(float baseY) { if (streams_.empty() || !font_) { return false; } ProclamationStream &stream = streams_[streamIndex_]; if (stream.words.empty()) { advanceStream(); return false; } FibonacciLinePattern &pattern = patterns_[streamIndex_]; // Capture palette state before any stream advance. std::vector paletteCopy = stream.palette; int lineColorIndex = stream.colorIndex; stream.colorIndex++; std::vector wordStrs; int placed = pattern.nextLine(stream.words, wordStrs); stream.wordsPlaced += placed; // First word of a fresh pass is the "start" of the proclamation; print to // stdout once so the console shows the current title while it plays. if (stream.wordsPlaced == placed) { std::printf("Playing %s\n", stream.name.c_str()); } bool advanced = false; while (!advanced && !stream.words.empty() && stream.wordsPlaced >= static_cast(stream.words.size())) { stream.wordsPlaced -= static_cast(stream.words.size()); stream.completed++; if (stream.completed >= stream.repeat) { stream.completed = 0; advanceStream(); advanced = true; } } ScrollerLine line; float width = 0.0f; for (const auto &text : wordStrs) { ensureWordTexture(text); const CachedWord &cached = wordTextures_[text]; if (!cached.texture) { continue; } WordItem item; item.text = text; item.texture = cached.texture; item.width = cached.width; item.height = cached.height; item.y = baseY; if (width > 0.0f) { width += spaceWidth_; } item.x = width; width += item.width; line.words.push_back(item); } if (line.words.empty()) { return false; } line.width = width; if (!paletteCopy.empty()) { const auto &c = paletteCopy[lineColorIndex % static_cast(paletteCopy.size())]; line.r = c.r; line.g = c.g; line.b = c.b; } if (!lines_.empty() && baseY < lines_.front().words[0].y) { lines_.push_front(std::move(line)); } else { lines_.push_back(std::move(line)); } return true; } void ScrollerPage::advanceStream() { if (streams_.empty()) { return; } if (scheduleChangePending_ && state_) { // A schedule block changed while the previous proclamation was playing. // Apply it only now, at a stream boundary, so the current proclamation // finishes instead of being cut off. buildStreams(*state_); createPatterns(); streamIndex_ = 0; } else { streamIndex_ = (streamIndex_ + 1) % static_cast(streams_.size()); } if (streams_.empty()) { return; } // Diagnostic: report the palette the incoming stream will render so the // console shows whether colors should change when this stream starts. const ProclamationStream &stream = streams_[streamIndex_]; if (!stream.palette.empty()) { const auto &c = stream.palette[0]; std::printf("[Scroller] -> %s freqId=%d palette[0]=#%02X%02X%02X\n", stream.name.c_str(), stream.solfeggio_frequency_id, c.r, c.g, c.b); } else { std::printf("[Scroller] -> %s freqId=%d palette=EMPTY\n", stream.name.c_str(), stream.solfeggio_frequency_id); } switchAudioForCurrentStream(); } void ScrollerPage::switchAudioForCurrentStream() { if (prefilling_ || streams_.empty() || !state_) { return; } // Dynamic audio only: with a fixed manual track selected the audio must // never switch as proclamations change. An active schedule block's // dynamicAudio flag overrides the device-wide setting. if (!effectiveDynamicAudio()) { return; } const ProclamationStream &stream = streams_[streamIndex_]; if (stream.solfeggio_frequency_id == 0 || stream.audioFilename.empty()) { return; // default stream: never touch audio } if (!std::filesystem::exists(audioFilePath(stream.audioFilename))) { return; // keep current track; retried on the next activation } // Crossfades over kFadeSeconds; a no-op when the requested track is the // same one already playing. state_->audio.playTrackCrossfade(stream.audioFilename, kFadeSeconds); } void ScrollerPage::updateAutoVelocity(float dt) { switch (velocityState_) { case 0: // ramping to the negative extreme velocity_ -= velocityStep_ * dt; if (velocity_ <= kVelocityMin) { velocity_ = kVelocityMin; velocityTimer_ = velocityDelay_; velocityState_ = 1; } break; case 1: // holding at the negative extreme velocityTimer_ -= dt; if (velocityTimer_ <= 0.0f) { velocityState_ = 2; } break; case 2: // ramping to the positive extreme velocity_ += velocityStep_ * dt; if (velocity_ >= kVelocityMax) { velocity_ = kVelocityMax; velocityTimer_ = velocityDelay_; velocityState_ = 3; } break; case 3: // holding at the positive extreme velocityTimer_ -= dt; if (velocityTimer_ <= 0.0f) { velocityState_ = 0; } break; default: velocityState_ = 0; break; } } void ScrollerPage::ensureWordTexture(const std::string &word) { if (wordTextures_.count(word) != 0) { return; } // Proclamation text is never rendered as human-readable type // ([This implementation] rendering pipeline). Each word is assembled from // per-glyph scrambled tiles instead: // 1. split the word into code points (utf8_utils.h) -- letters, digits, // and common punctuation such as ` , ' " . - all get their own tile; // 2. each code point's glyph is rasterized once, permuted into an N x N // tile by the Fibonacci affine map // (scramble/fibonacci_scrambler.h), and cached in charTextures_ // keyed by the glyph string (one scramble per code point per font // size, amortized O(N^2 * rounds)); // 3. the word surface is built by blitting each tile's top-left // glyphW x glyphH crop (the crop-window convention) at the glyph's // advance position. // Scrambling is per glyph rather than per word so a single tile serves // every occurrence of a code point across the whole proclamation set. // Word layout widths still come from TTF_GetStringSize(), so line // breaking, centering, and the Fibonacci line pattern are unchanged; only // the glyph pixels move. CachedWord cached; if (font_ && state_) { std::vector codePoints; if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty()) { std::vector glyphs; float totalWidth = 0.0f; float glyphHeight = 0.0f; for (uint32_t cp : codePoints) { const std::string glyphText = radiantwave::encodeUtf8(cp); if (glyphText.empty()) { continue; } ensureCharTexture(glyphText); CharEntry &entry = charTextures_[glyphText]; if (!entry.surface) { continue; } glyphs.push_back(&entry); totalWidth += entry.advance; if (entry.glyphH > glyphHeight) { glyphHeight = entry.glyphH; } } if (!glyphs.empty()) { const int wordW = std::max(1, static_cast(std::lround(totalWidth))); const int wordH = std::max(1, static_cast(std::lround(glyphHeight))); SDL_Surface *wordSurface = SDL_CreateSurface(wordW, wordH, SDL_PIXELFORMAT_RGBA8888); if (wordSurface) { // Every glyph surface carries the font's full line height // (probe-verified), so y = 0 keeps every crop on the shared // baseline and per-word textures stay line-height tall. float x = 0.0f; for (const CharEntry *entry : glyphs) { SDL_Rect srcRect{0, 0, static_cast(entry->glyphW), static_cast(entry->glyphH)}; SDL_Rect dstRect{static_cast(std::lround(x)), 0, static_cast(entry->glyphW), static_cast(entry->glyphH)}; SDL_BlitSurface(entry->surface, &srcRect, wordSurface, &dstRect); x += entry->advance; } cached.texture = SDL_CreateTextureFromSurface( state_->renderer, wordSurface); // The scroller steps glyphs in whole pixels, so bilinear // filtering can only smear them: nearest-neighbor keeps // every frame pixel-sharp. if (cached.texture) { SDL_SetTextureScaleMode(cached.texture, SDL_SCALEMODE_NEAREST); } cached.width = static_cast(wordSurface->w); cached.height = static_cast(wordSurface->h); SDL_DestroySurface(wordSurface); if (!cached.texture) { cached.width = 0.0f; cached.height = 0.0f; } } } } } wordTextures_[word] = cached; } void ScrollerPage::ensureCharTexture(const std::string &glyph) { if (charTextures_.count(glyph) != 0) { return; } // Rasterize, scramble, and cache one glyph tile. `charTextures_` is keyed // on the glyph's UTF-8 text, so a code point is scrambled at most once per // font size; createFont() clears the atlas whenever the font or its size // changes, and pause/pop clear it as part of destroyResources(). CharEntry entry; if (font_ && state_) { SDL_Surface *surface = TTF_RenderText_Blended(font_, glyph.c_str(), 0, {255, 255, 255, 255}); if (surface) { const int gW = surface->w; const int gH = surface->h; const int n = radiantwave::scramble::canvasSize(gW, gH); if (n > 0) { // The scrambler needs tightly packed RGBA8888; a TTF surface's // pitch can exceed gW * 4, so copy row by row. std::vector source(static_cast(gW) * gH * 4); const uint8_t *pixels = static_cast(surface->pixels); for (int y = 0; y < gH; ++y) { std::memcpy(source.data() + static_cast(y) * gW * 4, pixels + static_cast(y) * surface->pitch, static_cast(gW) * 4); } std::vector scrambled(static_cast(n) * n * 4); // Default key: compile-time seed (kDefaultSeed) and 4 rounds. // The fixed seed makes every device render the same glyph as // the same scrambled pattern, with no key to persist or ship. radiantwave::scramble::ScrambleConfig config; if (radiantwave::scramble::scramble( source.data(), scrambled.data(), gW, gH, config)) { SDL_Surface *tile = SDL_CreateSurface(n, n, SDL_PIXELFORMAT_RGBA8888); if (tile) { uint8_t *dst = static_cast(tile->pixels); for (int y = 0; y < n; ++y) { std::memcpy(dst + static_cast(y) * tile->pitch, scrambled.data() + static_cast(y) * n * 4, static_cast(n) * 4); } entry.surface = tile; entry.glyphW = static_cast(gW); entry.glyphH = static_cast(gH); entry.advance = measureWidth(glyph); if (entry.advance <= 0.0f) { entry.advance = entry.glyphW; } } } } SDL_DestroySurface(surface); } } charTextures_[glyph] = entry; } void ScrollerPage::clearCharTextures() { for (auto &entry : charTextures_) { if (entry.second.surface) { SDL_DestroySurface(entry.second.surface); } } charTextures_.clear(); } float ScrollerPage::measureWidth(const std::string &text) const { if (!font_) { return 0.0f; } int w = 0; int h = 0; TTF_GetStringSize(font_, text.c_str(), 0, &w, &h); return static_cast(w); } std::string ScrollerPage::audioFilePath(const std::string &filename) const { std::string path1 = std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") + "/.local/radiantwave/audio/" + filename; std::string path2 = "assets/audio/" + filename; return std::filesystem::exists(path1) ? path1 : path2; }