32 Commits
Author SHA1 Message Date
NeilClack 6804bbb048 Ignore Emacs temporary files
build / nix-build (push) Canceled after 0s
2026-09-17 15:09:47 -05:00
NeilClack 21f13c6cd5 Consolidate Gitea CI into one event-routed workflow 2026-09-11 19:14:58 -05:00
NeilClack f37beaf07e Add schedule sync and schedule-aware playback 2026-09-11 19:14:55 -05:00
NeilClack a043ec84d8 Merge branch 'dev' 2026-09-11 15:08:22 -05:00
NeilClack 9fead922c4 Move Proclamations From Sub-Categories To Groups
build-dev / nix-build (push) Successful in 32s
The portal's v2 API replaced nested categories (proclamation_categories.parent_id)
with flat categories plus tag-like groups: a proclamation now belongs to one
category and optionally one group. This lets the Proclamation Options page drop
its special-cased two-level tree.

Database:
- Add the proclamation_groups table (id, name, sort_order) and a
  proclamations.group_id column.
- Drop parent_id from the proclamation_categories DDL so fresh databases get a
  flat table.
- runMigrations() creates the new table/column, then runs a one-time
  transactional v1->v2 migration keyed on PRAGMA user_version: every legacy
  sub-category becomes a group (its id is preserved), proclamations that
  pointed at a sub-category are remapped to (parent category, new group), and
  the now-stale sub-category rows are deleted. selected flags are never
  touched, so user choices survive. No network is needed, so offline kiosks
  migrate at startup; re-running is a no-op.
- Add replaceGroups() and getAllGroups(); stop reading/writing parent_id.

API sync:
- Add PATH_GROUPS ("/groups") and parseGroupsJson().
- parseProclamationsJson() now also reads groupId.
- syncProclamationsFromApi() fetches /proclamations, /categories and /groups,
  leaves the database untouched if any request or parse fails, then replaces
  categories, replaces groups and upserts proclamations.

Proclamation Options page:
- FlatItem is now Category / Group / Proclamation.
- Each category lists only the groups that have at least one member in that
  category, so impossible pairings (e.g. Physical/Prayers) never render.
- Proclamations with no group, or with a group the portal has removed, sit
  directly under the category. Groups are collapsible and use a distinct color.

Tests:
- test_db: groups round-trip plus a legacy->v2 migration test asserting the
  group is created, proclamations are remapped, selection is preserved and a
  second initialize() is a no-op.
- test_proclamation_sync / test_api_client: parse groupId, parse groups, and
  the live smoke test now also fetches /groups.

Docs: README database table and AGENTS notes updated for groups.
2026-09-10 19:49:47 -05:00
NeilClack 2b1040d3db Add OpenAPI v2 spec; remove draft docs
Track the portal v2 API as openapi.yaml and drop the
superseded portal_api_v2_schema.md and user_manual_draft.md.
2026-09-10 17:54:19 -05:00
NeilClack cf991c34af Merge branch 'dev'
# Conflicts:
#	.gitea/workflows/dev.yml
#	.gitea/workflows/main.yml
2026-09-01 17:50:57 -05:00
NeilClack b56601bb93 Remove Attic upload verification steps
build-dev / nix-build (push) Successful in 42s
2026-09-01 17:17:21 -05:00
NeilClack 72ba15891d Fix attic verification cleanup on read-only store paths
build-dev / nix-build (push) Failing after 41s
2026-09-01 17:08:26 -05:00
NeilClack 356629a5a1 Fix attic verify: fetch deps from cache.nixos.org
The verify step fetched the closure from Attic alone, but Attic's
upstream-cache filter (cache.nixos.org-1) intentionally skips nixpkgs deps
like sqlite, so `nix copy` failed on 'path is not valid'. Mirror the kiosk
updater and fetch from Attic + cache.nixos.org together, Attic first so a
truncated RadiantWave NAR still fails before the manifest is published.
2026-09-01 15:55:34 -05:00
NeilClack 6019be6359 Add attic verification
build-dev / nix-build (push) Canceled after 26s
2026-09-01 10:53:57 -05:00
NeilClack fee31c87b8 Add manifest upload to release workflow 2026-08-28 16:51:53 -05:00
NeilClack 5369532ab4 Merge pull request 'Fix audio device problems' (#1) from dev into main
Reviewed-on: #1
2026-08-28 21:30:12 +00:00
NeilClack 42b915bd25 fixed (bug) Audio device switch killing playback via stale SDL device id
build-dev / nix-build (push) Successful in 45s
recreateMixerOnDevice destroyed the active mixer before opening the
replacement. MIX_DestroyMixer quits SDL audio at refcount 0, tearing down
the device registry and renumbering device ids, so the cached id being
reopened went stale ('Invalid audio device instance ID') and the app went
permanently mute. Open the new mixer first while the old device is alive,
then tear the old one down; a failed switch now keeps the current output
playing instead of killing audio. Also log SDL_GetError() on open failure.
2026-08-28 16:18:36 -05:00
NeilClack d60a8bd7fb Add WiFi prompt
build-dev / nix-build (push) Successful in 46s
2026-08-28 15:35:57 -05:00
NeilClack ca7b8b7e6f Update audio pipeline with extensive logging and defualt with fallbacks
build-dev / nix-build (push) Successful in 45s
2026-08-28 15:04:42 -05:00
NeilClack bf2d4d1f89 Update build dependencies to properly link dynamically. 2026-08-28 15:03:58 -05:00
NeilClack 2917d33866 fixed (bug) Audio not looping after ending.
build-dev / nix-build (push) Successful in 46s
2026-08-28 13:55:20 -05:00
NeilClack 3f6201182e Publish live-update manifest to fileserver (dev)
build-dev / nix-build (push) Successful in 43s
After pushing the dev closure to Attic, write a small JSON manifest
(version + storePath) to the fileserver for kiosks to poll. storePath is
the readlink of the result-pkg out-link, so it matches exactly the
closure pushed to the dev Attic cache and can be fetched with
'nix copy --from' without building from source. Upload goes to
/mnt/infrastructure_data/files/radiantwave-dev/manifest.json via the
build-server's radiantwave-infra SSH alias.
2026-08-27 20:58:13 -05:00
NeilClack d84307bd0e fixed (bug) Audio output device not switching
build-dev / nix-build (push) Successful in 47s
2026-08-27 18:45:57 -05:00
NeilClack 43cd93bbd7 Switch from window-size to render-output-size 2026-08-25 11:30:20 -05:00
NeilClack 92ba9f39fb Switch from window-size to render-output-size 2026-08-25 11:28:02 -05:00
NeilClack 400c6a782b Scroller: use full window height on rotated/tall displays
build-dev / nix-build (push) Successful in 41s
Override the LETTERBOX logical presentation while the scroller is
active so it renders into the full physical window.  On a 1920x6480
rotated display the scroller now scrolls through all 6480 pixels of
vertical space instead of being confined to the 1920x1080 letterbox
region.  Text scale is unchanged (same font size, same pixel
rasterization); more lines are simply visible at once.

- onPush: save current logical presentation, switch to DISABLED with
  real window dimensions
- onPop: restore the saved LETTERBOX presentation
- resize handler in main.cpp: skip re-apply while scroller is active
  (it owns its own presentation and restores on pop)
2026-08-25 10:55:53 -05:00
NeilClack 9b0bd517c6 pad footer 2026-08-25 10:33:04 -05:00
NeilClack babb74c7ad Fix attic upload
build-dev / nix-build (push) Successful in 40s
2026-08-25 07:55:32 -05:00
NeilClack 47785cd4b9 Proclamations: sub-cats always expanded, stable list width
build-dev / nix-build (push) Successful in 43s
- Sub-categories start expanded and cannot be collapsed
- Only root categories toggle open/closed
- LEFT on a sub-category is ignored (navigates to parent for depth-2)
- [+]/[-] indicators only shown on root categories
- Bounding box width computed over all items to prevent
  horizontal shifting when groups expand or collapse
2026-08-25 07:15:59 -05:00
NeilClack 15583edef8 Re-order settings menu 2026-08-25 06:57:43 -05:00
NeilClack 63265cc22f Dynamically size the fullscreen window; present design space via LETTERBOX
build-dev / nix-build (push) Successful in 42s
SDL_CreateWindow hardcoded a 1920x1080 backbuffer while SDL_WINDOW_FULLSCREEN
let compositors run a different output mode; the mismatched buffer reached
the panel unscaled — quarter-size in the top-left on 4K sets, cropped ("4x
too large") on smaller non-1080p panels under cage/Wayland. Logical
presentation never got to map the fixed design space because its target
(the backbuffer) was pinned to stale dimensions.

Size the fullscreen window from SDL_GetCurrentDisplayMode so the first
commit already matches scanout 1:1, switch logical presentation to LETTERBOX
(aspect-safe bars on non-16:9 panels), and route both through new
kDesignWidth/kDesignHeight constants. Harden getLayoutSize() to always write
its out params and null-check the renderer/render-window before use.

Log requested/window/pixel-buffer/render-output sizes at startup and on
every resize event so field units self-diagnose this bug class into
~/.local/radiantwave/log.txt. SDL_WINDOW_HIGH_PIXEL_DENSITY stays off on
purpose: kiosk panels are not HiDPI, and keeping the pixel buffer equal to
the display mode keeps compositor scanout unscaled.
2026-08-23 17:06:05 -05:00
NeilClack 9e73c82641 Fix attic push
build-dev / nix-build (push) Successful in 43s
2026-08-23 15:12:35 -05:00
NeilClack f2a40cd019 Remove attic-client install step
build-dev / nix-build (push) Successful in 39s
2026-08-23 12:46:08 -05:00
NeilClack 5caf1d6ef8 Move to checksums.md5
build-dev / nix-build (push) Failing after 13s
Server is scheduled to generate this file nightly
2026-08-23 12:39:05 -05:00
NeilClack e17e881c00 Split builds 2026-08-23 12:38:44 -05:00
43 changed files with 3254 additions and 1170 deletions
+69 -22
View File
@@ -1,10 +1,14 @@
name: build
on:
# Manual only for now. Select the branch to build when dispatching:
# main -> APP_VERSION is the current git tag
# dev -> APP_VERSION is the current commit hash
# Manual dispatch: always builds the `dev` branch (regardless of the ref
# selected when dispatching) and publishes to the dev Attic cache + dev
# live-update manifest. Version fingerprint = short commit hash.
workflow_dispatch:
# Any new tag: builds the tagged revision and publishes a release to the
# release Attic cache + production live-update manifest. Version = the tag.
push:
tags: ['*']
jobs:
nix-build:
@@ -12,13 +16,17 @@ jobs:
steps:
# actions/checkout@v4 is unreliable on host-mode runners (its files are
# copied into the workspace instead of the repo), so clone explicitly.
# A full clone (no --depth) is required so `git describe --tags` on main
# resolves the real nearest tag instead of falling back to a hash.
# A manual dispatch always clones `dev`; a tag push clones the tag. A full
# clone (no --depth) lets `git describe --tags` resolve the real tag.
- name: Checkout
run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true
git config --global http.extraheader "Authorization: token ${{ github.token }}"
git clone --branch "${{ gitea.ref_name }}" \
REF="${{ gitea.ref_name }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
REF=dev
fi
git clone --branch "$REF" \
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
test -f flake.nix
git log --oneline -1
@@ -32,26 +40,32 @@ jobs:
git describe --tags --always 2>&1 || true
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
# Compute the version string baked into the binary's Home footer:
# main reports the git tag, dev reports the commit hash fingerprint.
# The key stays in the build step's environment (never echoed).
# Dev builds fingerprint as the short commit hash; releases report the
# tag. Persisted via GITHUB_ENV so the impure nix eval below reads it via
# builtins.getEnv.
- name: Compute version
id: version
run: |
if [ "${{ gitea.ref_name }}" = "main" ]; then
APP_VERSION="$(git describe --tags --always)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "APP_VERSION=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
else
APP_VERSION="$(git rev-parse --short HEAD)"
echo "APP_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV"
fi
echo "Branch: ${{ gitea.ref_name }} -> APP_VERSION=$APP_VERSION"
# checks is keyed per-system (checks.<system>.default); nix build's
# #attr shorthand can't resolve it, so resolve the current system inline.
# --impure lets the flake read APP_VERSION via builtins.getEnv
# (pure evals always get the package defaults). The offline test suite
# runs in the package's checkPhase.
# (pure evals always get the package defaults).
- name: Build package
run: |
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-pkg
# The offline test suite runs in the check derivation's checkPhase.
# This is a separate build/output from the package itself.
- name: Build + offline test suite
run: nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" --impure
run: |
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-checks
- name: Live portal tests
env:
@@ -66,10 +80,43 @@ jobs:
./build/test_license_service
'
- name: Push to Attic cache
# Manual dispatch -> dev cache; tag push -> release cache.
- name: Push to Attic dev cache
if: github.event_name == 'workflow_dispatch'
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_DEV }}
run: |
nix profile install github:zhaofengli/attic#attic-client \
--extra-experimental-features "nix-command flakes"
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave-dev ./result-pkg
attic login radiantwave https://attic.radiantwavetech.com "${{ secrets.ATTIC_TOKEN }}"
attic push radiantwave ./result-kiosk ./result-android ./result-commercial
- name: Push to Attic release cache
if: github.event_name != 'workflow_dispatch'
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_PROD }}
run: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave ./result-pkg
# Publish the manifest kiosks poll for live app updates. `storePath`
# must match exactly the closure pushed to the matching Attic cache above
# (readlink resolves the result-pkg out-link symlink to its real
# /nix/store path), so a kiosk can `nix copy --from` it without ever
# building from source. Upload mirrors the ISO scp pattern to the
# fileserver via the build-server's `radiantwave-infra` SSH alias.
- name: Publish dev manifest
if: github.event_name == 'workflow_dispatch'
run: |
STOREPATH=$(readlink -f ./result-pkg)
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json
cat manifest.json
scp manifest.json \
radiantwave-infra:/mnt/infrastructure_data/files/radiantwave-dev/manifest.json
- name: Publish release manifest
if: github.event_name != 'workflow_dispatch'
run: |
STOREPATH=$(readlink -f ./result-pkg)
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json
cat manifest.json
scp manifest.json \
radiantwave-infra:/mnt/infrastructure_data/files/radiantwave/manifest.json
+5
View File
@@ -33,3 +33,8 @@ Thumbs.db
# Compiled binary
bin/
# Emacs
*~
\#*\#
.#*
+15 -4
View File
@@ -102,6 +102,14 @@ suite and need the `RWT_LIVE_*` secrets to pass.
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
from it.
- Proclamations carry a flat `category_id` plus a tag-like `group_id`
(`proclamation_groups`). The Options page lists every category and, under it,
only the groups that have at least one member in that category; ungrouped
proclamations sit directly under the category. `runMigrations()` performs a
one-time v1→v2 migration (`PRAGMA user_version`) that converts legacy
`proclamation_categories.parent_id` sub-categories into groups and remaps
their proclamations, preserving selections. No network is needed for it, so
offline kiosks migrate on startup.
---
@@ -217,13 +225,16 @@ cached.
- Do not modify files in `archive/` — reference only.
- Build needs SDL3 + SDL3_ttf/image/mixer/net + GTest installed; CI uses
Gitea Actions on a self-hosted host runner (`build-server`, no docker).
The workflow lives in `.gitea/workflows/build.yml` and is **manual-only**
(`workflow_dispatch`); pick the branch when dispatching. It runs
The single workflow `.gitea/workflows/build.yml` has two triggers:
**`workflow_dispatch`** always builds the `dev` branch and publishes the dev
cache/manifest, and a **tag push** (`tags: ['*']`) builds the tag and
publishes the release cache/manifest. The event name routes the steps
(`github.event_name`). It runs
`nix build -L .#checks.<system>.default` (build + the offline test suite;
`checks` is keyed per-system, so the workflow resolves the current system
via `nix eval --impure --expr 'builtins.currentSystem'`). The build bakes
in a per-branch `APP_VERSION` — the current git tag on `main`, the current
commit hash on `dev`. The flake reads `APP_VERSION` from the environment via
in an `APP_VERSION` — the short commit hash on a manual dev run, the current
git tag on a tag push. The flake reads `APP_VERSION` from the environment via
`builtins.getEnv`, so the build must pass `--impure` (pure evals get the
package defaults). The live portal tests run from `nix develop` when the
`RWT_LIVE_*` secrets are set and need `RWT_LIVE_ACCOUNT_EMAIL`,
+8 -1
View File
@@ -112,7 +112,14 @@ add_executable(test_provisioning
tests/test_provisioning.cpp
)
target_include_directories(test_provisioning PRIVATE src)
target_link_libraries(test_provisioning PRIVATE SDL3::SDL3)
# provisioning.h pulls in app_state.h, so the test compiles against the SDL3,
# SDL3_mixer, and SQLite headers transitively (app_state.h -> audio_manager.h ->
# SDL3_mixer; app_state.h -> database_manager.h -> sqlite3.h).
target_link_libraries(test_provisioning PRIVATE
SDL3::SDL3
SDL3_mixer::SDL3_mixer
SQLite3::SQLite3
)
add_executable(test_assets
tests/test_assets.cpp
+3 -2
View File
@@ -484,8 +484,9 @@ SQLite at `~/.local/share/radiantwave/database.sqlite` (WAL mode).
| ------- | --------- |
| app_config | Key-value store: license, email, audio device, volume |
| logs | Application logs |
| proclamations | Synced proclamation text |
| proclamation_categories | Category metadata |
| proclamations | Synced proclamation text (category_id + group_id) |
| proclamation_categories | Flat category metadata |
| proclamation_groups | Tag-like groups that organize proclamations within a category |
| solfeggio_frequencies | 9 frequency presets |
| audio_files | Downloaded audio files |
+12
View File
@@ -112,6 +112,18 @@ in
export WLR_RENDERER=pixman
export WLR_NO_HARDWARE_CURSORS=1
# Prefer the PipeWire audio backend so SDL enumerates every sink
# (built-in analog, HDMI, and the loopback virtual speaker) instead of
# silently falling back to an ALSA-only device view that hides the
# PipeWire sinks and makes the device selector show a single entry.
export SDL_AUDIODRIVER=pipewire
if [ -n "$${XDG_RUNTIME_DIR:-}" ]; then
for i in {1..20}; do
[ -S "$XDG_RUNTIME_DIR/pipewire-0" ] && break
sleep 0.5
done
fi
cage -- ${radiantWaveApp}/bin/radiantwave > /tmp/kiosk.log 2>&1
fi
'';
+612
View File
@@ -0,0 +1,612 @@
openapi: 3.0.3
info:
title: Radiant Wave Portal API (v2)
version: 2.0.0
description: |
Read API used by the Radiant Wave kiosk plus the automation onboarding
webhook.
## Authentication
The v2 API is **not** API-key gated. The former bearer-token middleware in
`src/hooks.server.ts` is present but disabled, so v2 routes are served
without a global API key.
Two endpoints carry their own credential semantics:
- `GET /api/v2/proclamations` optionally accepts the paired
`x-license-key` / `x-email` headers to scope results to an entitled user.
Supplying neither returns the full catalogue; supplying only one, or a
pair that does not match, is rejected.
- `POST /api/v2/automations/create/account` is protected by an HMAC-SHA256
request signature (see the operation description).
servers:
- url: https://portal.radiantwavetech.com
description: Production
- url: http://localhost:5173
description: Local development
tags:
- name: Catalogue
description: Reference data for categories, groups, frequencies and palettes.
- name: Proclamations
description: Proclamation content, optionally scoped by license entitlement.
- name: License
description: License key validation.
- name: Schedule
description: A user's daily listening schedule, consumed by the kiosk.
- name: Automations
description: Signed machine-to-machine webhooks.
paths:
/api/v2/categories:
get:
tags: [Catalogue]
summary: List proclamation categories
operationId: listCategories
responses:
'200':
description: All proclamation categories.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Category'
/api/v2/proclamation-categories:
get:
tags: [Catalogue]
summary: List proclamation categories (dedicated alias)
description: Alias of `/api/v2/categories`; returns the identical payload.
operationId: listProclamationCategories
responses:
'200':
description: All proclamation categories.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Category'
/api/v2/groups:
get:
tags: [Catalogue]
summary: List proclamation groups
description: Groups are ordered by `sortOrder` ascending.
operationId: listGroups
responses:
'200':
description: All proclamation groups.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Group'
/api/v2/proclamation-groups:
get:
tags: [Catalogue]
summary: List proclamation groups (dedicated alias)
description: Alias of `/api/v2/groups`; returns the identical payload.
operationId: listProclamationGroups
responses:
'200':
description: All proclamation groups.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Group'
/api/v2/frequencies:
get:
tags: [Catalogue]
summary: List canonical solfeggio frequencies
description: |
Returns the nine canonical frequencies (ids 1-9) the kiosk expects.
These are static values, not the portal database autoincrement ids.
operationId: listFrequencies
responses:
'200':
description: The nine canonical frequencies.
content:
application/json:
schema:
type: array
minItems: 9
maxItems: 9
items:
$ref: '#/components/schemas/Frequency'
/api/v2/color-palettes:
get:
tags: [Catalogue]
summary: List color palettes per canonical frequency
description: |
Returns one 7-color palette for each canonical frequency id (1-9).
Palette rows stored in the database take precedence; ids without a row
fall back to the built-in canonical palette.
operationId: listColorPalettes
responses:
'200':
description: Nine palettes, one per canonical frequency id.
content:
application/json:
schema:
type: array
minItems: 9
maxItems: 9
items:
$ref: '#/components/schemas/ColorPalette'
/api/v2/proclamations:
get:
tags: [Proclamations]
summary: List proclamations
description: |
Without entitlement headers, returns the full proclamation catalogue.
With both `x-license-key` and `x-email`, returns only the proclamations
assigned to the matching user. Both headers must be present together and
the key must belong to the user.
Proclamations whose frequency has no canonical id (for example 936 Hz)
are omitted from the response.
operationId: listProclamations
parameters:
- in: header
name: x-license-key
schema:
type: string
required: false
description: License key. Must be supplied together with `x-email`.
- in: header
name: x-email
schema:
type: string
format: email
required: false
description: Account email. Must be supplied together with `x-license-key`.
responses:
'200':
description: Proclamation records with canonical frequency ids and embedded palettes.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Proclamation'
'400':
description: |
Missing entitlement or the supplied key/email pair is invalid.
Message is `not-authorized`, or
`this key doesn't appear to belong to you` when the key does not
belong to the specified user.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/license:
post:
tags: [License]
summary: Validate a license key
description: |
Validates that the license key exists, is active, and belongs to the
account matching `email_address`. A successful validation records
`lastValidatedAt` on the key.
operationId: validateLicense
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LicenseRequest'
responses:
'200':
description: The validated key and its owner email.
content:
application/json:
schema:
$ref: '#/components/schemas/LicenseResponse'
'400':
description: |
Validation failure or malformed body. Possible messages:
`content-type must be application/json`, `invalid JSON body`,
`request body is required`, `license_key is required`,
`email_address is required`, `license key not found`,
`license key is not active`, `user does not exist`,
`key does not belong to specified user`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'413':
description: The request body exceeded the 64 KiB limit.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: Rate limit exceeded (10 requests per minute per IP).
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/schedule:
get:
tags: [Schedule]
summary: Get a user's listening schedule (query-param auth)
description: |
Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates via
the paired `email` + `licenseKey` **query parameters** and validates only
that the key belongs to the account for that email. License
active/entitlement is deliberately not checked here: an invalid license
would already lock the kiosk out, so this endpoint only checks the
pairing.
Returns every Block with its start/end times and the ordered list of
selected Proclamation IDs. Overnight blocks (an end clock time before
the start) are supported and flagged with `overnight: true` and a
wrapping `durationMinutes`. Each block also carries `dynamicAudio`;
when true, the kiosk enables dynamic audio for the block's duration,
overriding any audio selections made on the device. Proclamation
content is not included; the kiosk fetches it separately via
`/api/v2/proclamations`.
operationId: getSchedule
parameters:
- in: query
name: email
required: true
schema:
type: string
format: email
description: Account email.
- in: query
name: licenseKey
required: true
schema:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
description: License key. The legacy alias `key` is also accepted.
responses:
'200':
description: The user's schedule (possibly empty).
content:
application/json:
schema:
$ref: '#/components/schemas/ScheduleResponse'
example:
email: jane@example.com
blocks:
- id: 3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f
startTime: '21:00'
startMinutes: 1260
endTime: '09:00'
endMinutes: 540
overnight: true
durationMinutes: 720
dynamicAudio: true
proclamationIds:
- b40fdcb9-703c-420e-af32-a081964c6f08
- 7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b
'400':
description: |
Missing/invalid parameters, unknown key or user, or the key does not
belong to the email's account. Possible messages: `not-authorized`,
`this key doesn't appear to belong to you`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: Rate limit exceeded (10 requests per minute per IP).
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/automations/create/account:
post:
tags: [Automations]
summary: Create an account and license key (signed webhook)
description: |
Machine-to-machine onboarding webhook. The request must be signed with
HMAC-SHA256 using `API_V2_ONBOARDING_SECRET`.
The canonical string that is signed is the newline-joined concatenation
of:
```
POST
<path>
<x-timestamp>
<x-nonce>
<raw request body>
```
where `<path>` is `/api/v2/automations/create/account`. The signature is
the lowercase hex digest.
On success, creates the user if they do not already exist, generates a
license key, emails it, and returns the user id. Invalid or missing
signatures return `401 { "error": "invalid signature" }` (the same
response is used for every signature failure, including replay and
timestamp skew).
operationId: createAutomationAccount
parameters:
- in: header
name: x-timestamp
required: true
schema:
type: string
description: Unix epoch seconds. Must be within 300 seconds of the server clock.
- in: header
name: x-nonce
required: true
schema:
type: string
description: Unique value per request. Replays within the skew window are rejected.
- in: header
name: x-signature
required: true
schema:
type: string
pattern: '^[0-9a-f]{64}$'
description: Lowercase hex HMAC-SHA256 of the canonical request string.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingRequest'
responses:
'200':
description: Account and license key created.
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingResponse'
'400':
description: |
Malformed or invalid request. Possible messages:
`request body is required`, `invalid JSON body`, or
`email is required`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'401':
description: Signature missing, malformed, expired, replayed, or incorrect.
content:
application/json:
schema:
type: object
required: [error]
properties:
error:
type: string
example: invalid signature
'413':
description: The request body exceeded the 64 KiB limit.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: |
Rate limit exceeded. Limits are 5 requests per minute per IP and
3 requests per 24 hours per email address.
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'500':
description: The account could not be created.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
components:
schemas:
Category:
type: object
required: [id, name, sortOrder]
properties:
id:
type: integer
name:
type: string
sortOrder:
type: integer
Group:
type: object
required: [id, name, sortOrder]
properties:
id:
type: integer
name:
type: string
sortOrder:
type: integer
Frequency:
type: object
required: [id, name, value]
properties:
id:
type: integer
minimum: 1
maximum: 9
description: Canonical id used by the kiosk.
name:
type: string
value:
type: integer
description: Frequency in Hz.
ColorPalette:
type: object
required: [solfeggioFrequencyId, colors]
properties:
solfeggioFrequencyId:
type: integer
minimum: 1
maximum: 9
colors:
type: array
description: Seven hex colors, ordered light to dark.
minItems: 1
items:
type: string
pattern: '^#[0-9A-Fa-f]{6}$'
Proclamation:
type: object
required: [id, name, content, categoryId, groupId, solfeggioFrequencyId]
properties:
id:
type: string
format: uuid
name:
type: string
content:
type: string
description: HTML content.
categoryId:
type: integer
groupId:
type: integer
solfeggioFrequencyId:
type: integer
minimum: 1
maximum: 9
colorPalette:
type: array
description: Present only when a palette exists for the canonical frequency id.
minItems: 1
items:
type: string
pattern: '^#[0-9A-Fa-f]{6}$'
LicenseRequest:
type: object
required: [license_key, email_address]
properties:
license_key:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
example: ABCD-EFGH-IJKL-MNOP
email_address:
type: string
format: email
maxLength: 254
LicenseResponse:
type: object
required: [key, email_address]
properties:
key:
type: string
example: ABCD-EFGH-IJKL-MNOP
email_address:
type: string
format: email
ScheduleBlock:
type: object
required:
[
id,
startTime,
startMinutes,
endTime,
endMinutes,
overnight,
durationMinutes,
dynamicAudio,
proclamationIds
]
properties:
id:
type: string
format: uuid
startTime:
type: string
example: '06:00'
description: Wall-clock start time as `HH:MM`.
startMinutes:
type: integer
minimum: 0
maximum: 1440
description: Minutes from midnight.
endTime:
type: string
example: '08:00'
description: Wall-clock end time as `HH:MM`.
endMinutes:
type: integer
minimum: 0
maximum: 1440
description: Minutes from midnight.
overnight:
type: boolean
description: True when the end clock time is at or before the start, i.e. the block crosses midnight.
durationMinutes:
type: integer
minimum: 1
description: Block length in minutes. An overnight block counts the wrap past midnight as the next day.
dynamicAudio:
type: boolean
description: |
When true, dynamic audio is enabled for the block's duration, overriding any
audio selections made on the device while the block is active.
proclamationIds:
type: array
description: IDs of the selected Proclamations, in play order.
items:
type: string
format: uuid
ScheduleResponse:
type: object
required: [email, blocks]
properties:
email:
type: string
format: email
blocks:
type: array
maxItems: 4
items:
$ref: '#/components/schemas/ScheduleBlock'
OnboardingRequest:
type: object
required: [email]
properties:
email:
type: string
format: email
maxLength: 254
name:
type: string
maxLength: 120
description: Optional display name. Defaults to the email local-part.
OnboardingResponse:
type: object
required: [success, message, user_id]
properties:
success:
type: boolean
message:
type: string
example: Account and license key created
user_id:
type: string
ErrorMessage:
type: object
required: [message]
properties:
message:
type: string
-191
View File
@@ -1,191 +0,0 @@
# Portal API v2 — current schema
The kiosk talks to `https://portal.radiantwavetech.com/api/v2`. This document
describes the current contract after the move to an **active/inactive license
model**. There are no subscription levels: a license is either **valid**
(subscribed) or not, and the kiosk treats a successful `POST /license`
validation as the device's "subscribed" flag.
Base URL: `https://portal.radiantwavetech.com/api/v2`
Authentication: `Authorization: Bearer sk_<hex>` on every request (the API key
is baked into the kiosk binary at build time).
---
## `POST /license`
Validates a license key / email pair.
Request:
```
POST /api/v2/license
Content-Type: application/json
Authorization: Bearer sk_<hex>
```
```json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"email_address": "user@example.com"
}
```
Response (200):
```json
{
"key": "XXXX-XXXX-XXXX-XXXX",
"email_address": "user@example.com"
}
```
Contract (kiosk `parseLicenseJson`):
- `key`: string, **required non-empty** (empty key ⇒ parse failure).
- `email_address`: string.
- Any other fields (e.g. the legacy `subscription_level`) are ignored. A 2xx
response means the license is valid and the subscription is **active**; the
kiosk stores no subscription state other than the `license_last_verified`
stamp that drives its 7-day offline grace window.
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | success object | License valid and owned by the email |
| 400 | `{"message": "license_key is required"}` | Missing `license_key` |
| 400 | `{"message": "email_address is required"}` | Missing `email_address` |
| 400 | `{"message": "license key not found"}` | No key with that value |
| 400 | `{"message": "license key is not active"}` | Key exists but inactive |
| 400 | `{"message": "user does not exist"}` | No user with that email |
| 400 | `{"message": "key does not belong to specified user"}` | Key owned by a different user |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
## `GET /proclamations`
Lists every proclamation. There is **no `subscriptionLevelId`** — any validated
license may access any proclamation.
Response (200):
```json
[
{
"id": "b40fdcb9-703c-420e-af32-a081964c6f08",
"name": "Peace & Relaxation",
"content": "<p>The text content, HTML formatted</p>",
"categoryId": 5,
"solfeggioFrequencyId": 5,
"colorPalette": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"]
}
]
```
Contract (kiosk `parseProclamationsJson` + `parseProclamationPalettesJson`):
- `id`: string UUID, **required non-empty** (empty ids are skipped).
- `name`: string, used as the display title. Kiosk strips HTML from it.
- `content`: string, **HTML** (the kiosk strips tags and unescapes entities
before rendering).
- `categoryId`: int, maps to a `/categories` id.
- `solfeggioFrequencyId`: int, **must be a canonical id (1–9)**.
- `colorPalette` (optional): array of exactly 7 `"#RRGGBB"` strings. When
absent, the kiosk falls back to `/color-palettes`, then to its own 528 Hz
seed.
- Legacy `subscriptionLevelId` fields in the payload are ignored.
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of proclamation records | Success |
| 400 | `{"message": "not-authorized"}` | Entitlement headers supplied but key or email does not exist |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
## `GET /categories`
Lists the proclamation categories.
Response (200):
```json
[
{
"id": 4,
"name": "Spiritual",
"parentId": 0,
"sortOrder": 0
}
]
```
Contract (kiosk `parseCategoriesJson`):
- `id`: int, **required non-zero** (zero ids are skipped).
- `name`: string.
- `parentId`: int, `null` maps to 0 (root).
- `sortOrder`: int.
---
## `POST /automations/create/account`
Creates (or reuses) a user account and issues a license key. **No
`subscription_level` is sent** — every created license is a standard active
license.
Request:
```
POST /api/v2/automations/create/account
Content-Type: application/json
Authorization: Bearer sk_<hex>
X-Timestamp: <unix seconds>
X-Nonce: <random uuid>
X-Signature: <hex HMAC-SHA256 of canonical request with the onboarding secret>
```
```json
{
"email": "user@example.com",
"name": "Optional Display Name"
}
```
- `email` (required, string).
- `name` (optional, string; defaults to the local part of `email`).
Response (200):
```json
{
"success": true,
"message": "Account and license key created",
"user_id": "uuid"
}
```
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | success object | Account (+ license key) created |
| 400 | `{"message": "email is required"}` | Missing/empty `email` |
| 401 | `{"error": "invalid signature"}` | Bad/expired/replayed HMAC signature |
| 429 | `{"message": "rate limit exceeded"}` | Too many requests from this IP/email |
| 500 | `{"message": "Failed to create account"}` | User creation failed |
---
## Readiness routes (not yet wired into the app)
- `GET /color-palettes` → `[{solfeggioFrequencyId, colors[7]}]`
- `GET /frequencies` → canonical id 1–9 + `value` in Hz
These exist for future sync steps; the kiosk does not call them yet.
+3 -3
View File
@@ -37,7 +37,7 @@ struct AudioManifestEntry {
// and keeps the database's audio_files table in sync with what is actually
// on disk.
//
// Fetches new_checksums.md5, compares each entry against the local file's MD5,
// Fetches checksums.md5, compares each entry against the local file's MD5,
// and re-downloads anything that is missing or whose checksum differs.
// Files present on disk but absent from the manifest are deleted, and the
// audio_files table is reconciled on every pass (and after each individual
@@ -68,7 +68,7 @@ public:
static constexpr const char *AUDIO_BASE_URL =
"https://files.radiantwavetech.com/audio/";
static constexpr const char *MANIFEST_FILENAME = "new_checksums.md5";
static constexpr const char *MANIFEST_FILENAME = "checksums.md5";
private:
void run();
@@ -88,7 +88,7 @@ private:
db::DatabaseManager *db_ = nullptr;
};
// Parses new_checksums.md5 manifest text into entries. Blank lines and lines
// Parses checksums.md5 manifest text into entries. Blank lines and lines
// starting with '#' are ignored. Lines that don't match a known format are
// skipped. Returns false if no valid entries were found.
bool parseManifest(const std::string &text,
+388 -31
View File
@@ -32,6 +32,7 @@ namespace radiantwave::audio
if (lower.find("analog") != std::string::npos ||
lower.find("built-in") != std::string::npos ||
lower.find("builtin") != std::string::npos ||
lower.find("virtual") != std::string::npos ||
lower.find("realtek") != std::string::npos ||
lower.find("hda") != std::string::npos ||
lower.find("speaker") != std::string::npos ||
@@ -76,7 +77,6 @@ namespace radiantwave::audio
enabled_ = cfg.audio.enabled;
currentDevice_ = cfg.audio.output_device;
currentTrack_ = cfg.audio.current_track;
if (!MIX_Init())
{
std::string err = SDL_GetError();
@@ -86,17 +86,68 @@ namespace radiantwave::audio
return false;
}
// Create mixer device with default audio device
mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr);
// Startup diagnostics: which audio backend SDL picked (PipeWire, ALSA,
// Pulse, dummy, ...) and what playback devices it actually sees. This is
// the first place a "only the built-in speakers show up" report points;
// a single device here means SDL itself never enumerated the HDMI sink.
const char *driver = SDL_GetCurrentAudioDriver();
std::string driverName = driver ? driver : "(none)";
int deviceCount = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&deviceCount);
std::string deviceNames;
if (devices)
{
for (int i = 0; i < deviceCount; ++i)
{
if (i)
{
deviceNames += ", ";
}
const char *n = SDL_GetAudioDeviceName(devices[i]);
deviceNames += n ? n : "(unnamed)";
}
SDL_free(devices);
}
logger::Logger::instance().info(
"Audio",
"SDL audio driver: " + driverName + "; " +
std::to_string(deviceCount) + " playback device(s) [" +
(deviceNames.empty() ? "(none)" : deviceNames) + "]");
// Try to open the mixer on the device previously persisted in config;
// fall back to the system default when the configured device is gone.
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
if (!currentDevice_.empty())
{
int count = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
if (devices)
{
for (int i = 0; i < count; ++i)
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
if (name && currentDevice_ == name)
{
targetId = devices[i];
break;
}
}
SDL_free(devices);
}
}
mixer_ = MIX_CreateMixerDevice(targetId, nullptr);
if (!mixer_)
{
std::string err = SDL_GetError();
logger::Logger::instance().warning("Audio",
"Could not open default audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
"Could not open audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
initialized_ = false;
return false;
}
currentDeviceId_ = targetId;
slots_[0].track = MIX_CreateTrack(mixer_);
slots_[1].track = MIX_CreateTrack(mixer_);
initialized_ = true;
@@ -135,6 +186,40 @@ namespace radiantwave::audio
return enabled_ ? (volume_ / 100.0f) : 0.0f;
}
SDL_PropertiesID AudioManager::createInfiniteLoopOptions()
{
// MIX_PlayTrack()'s options default to a single pass; requesting
// infinite loops is what keeps a track going until it is explicitly
// faded out or the app shuts down.
SDL_PropertiesID props = SDL_CreateProperties();
if (props)
{
SDL_SetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, -1);
}
return props;
}
bool AudioManager::playTrackLooping(int slotIndex)
{
if (!initialized_ || !mixer_ || slotIndex < 0 || slotIndex >= 2 ||
!slots_[slotIndex].track)
{
return false;
}
SDL_PropertiesID props = createInfiniteLoopOptions();
if (!props)
{
logger::Logger::instance().error(
"Audio", "Failed to create playback options for " +
displayNameFor(currentTrack_));
return false;
}
bool ok = MIX_PlayTrack(slots_[slotIndex].track, props);
SDL_DestroyProperties(props);
return ok;
}
void AudioManager::shutdown()
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -181,18 +266,62 @@ namespace radiantwave::audio
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
std::string raw = name ? name : "";
list.push_back({raw, mapToFriendlyDeviceName(raw)});
AudioDeviceItem item;
item.raw_name = raw;
item.friendly_name = mapToFriendlyDeviceName(raw);
item.id = devices[i];
list.push_back(item);
}
SDL_free(devices);
}
else
{
list.push_back({"", "Built-In Speakers"});
if (devices)
{
SDL_free(devices);
}
const char *driver = SDL_GetCurrentAudioDriver();
// Zero enumerated devices is a symptom, not a real output. Surface
// the truth (a single "System Default" entry that still routes to
// whatever the OS default is) instead of pretending a fabricated
// device, and say which backend was active so kiosk logs show why.
logger::Logger::instance().debug(
"Audio",
"No playback devices enumerated (audio driver: " +
std::string(driver ? driver : "none") +
"); falling back to the system default");
list.push_back({"", "System Default", 0});
}
return list;
}
std::string AudioManager::resolvePlaybackDeviceName(SDL_AudioDeviceID id)
{
if (id == 0 || id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
{
return "";
}
int count = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
std::string found;
if (devices)
{
for (int i = 0; i < count; ++i)
{
if (devices[i] == id)
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
found = name ? name : "";
break;
}
}
SDL_free(devices);
}
return found;
}
void AudioManager::setVolume(int volume_0_to_100)
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -253,14 +382,76 @@ namespace radiantwave::audio
bool AudioManager::switchDevice(const std::string &raw_device_name)
{
std::lock_guard<std::mutex> lock(mutex_);
currentDevice_ = raw_device_name;
logger::Logger::instance().debug("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(raw_device_name));
// Resolve the config's raw name to an SDL device id so the mixer is
// actually reopened on the requested device (""/unresolvable = default).
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
if (!raw_device_name.empty())
{
int count = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
if (devices)
{
for (int i = 0; i < count; ++i)
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
if (name && raw_device_name == name)
{
targetId = devices[i];
break;
}
}
SDL_free(devices);
}
}
return switchDeviceById(targetId);
}
bool AudioManager::switchDeviceById(SDL_AudioDeviceID id)
{
std::lock_guard<std::mutex> lock(mutex_);
// 0 is the synthetic "default / built-in" entry from getPlaybackDevices().
if (id == 0)
{
id = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
}
// No mixer yet: just record the choice for initialize().
if (!mixer_ || !initialized_)
{
currentDeviceId_ = id;
currentDevice_ = resolvePlaybackDeviceName(id);
if (configManager_)
{
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
{ cfg.audio.output_device = name; });
}
return true;
}
if (!recreateMixerOnDevice(id))
{
logger::Logger::instance().error("Audio",
"Failed to switch audio output device to: " + mapToFriendlyDeviceName(resolvePlaybackDeviceName(id)));
return false;
}
currentDeviceId_ = id;
// After recreation SDL may have normalized the id (e.g. default).
if (SDL_GetAudioDeviceName(id) == nullptr && id != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
{
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
}
currentDevice_ = resolvePlaybackDeviceName(currentDeviceId_);
logger::Logger::instance().info("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(currentDevice_));
if (configManager_)
{
configManager_->update([raw_device_name](config::AppConfig &cfg)
{ cfg.audio.output_device = raw_device_name; });
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
{ cfg.audio.output_device = name; });
}
return true;
@@ -272,6 +463,150 @@ namespace radiantwave::audio
return currentDevice_;
}
SDL_AudioDeviceID AudioManager::getCurrentDeviceId() const
{
std::lock_guard<std::mutex> lock(mutex_);
return currentDeviceId_;
}
bool AudioManager::handleDeviceEvent(const SDL_Event &event)
{
if (event.type != SDL_EVENT_AUDIO_DEVICE_ADDED &&
event.type != SDL_EVENT_AUDIO_DEVICE_REMOVED)
{
return false;
}
std::lock_guard<std::mutex> lock(mutex_);
if (!mixer_ || !initialized_)
{
return false;
}
// Only playback device events are relevant to the active output.
if (event.adevice.recording)
{
return false;
}
const SDL_AudioDeviceID changedId = event.adevice.which;
// If a specific device (not the default) was removed, fail over to the
// system default so playback keeps going on whatever remains.
if (event.type == SDL_EVENT_AUDIO_DEVICE_REMOVED &&
currentDeviceId_ != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK &&
changedId == currentDeviceId_)
{
logger::Logger::instance().warning("Audio",
"Active audio output device removed; failing over to the system default");
if (recreateMixerOnDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK))
{
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
currentDevice_ = "";
}
return true;
}
// A device arrived or a non-active device left: nothing to remap, but
// the audio settings page refreshes its list on its own timer.
return false;
}
// Must be called with mutex_ held. Creates a fresh mixer on the given
// device, recreating the two track slots and resuming the active track if
// one was playing.
//
// The new mixer is created BEFORE the current one is destroyed. Reversing
// the order (destroy-then-reopen) quits the whole SDL audio subsystem the
// moment the last device closes: MIX_DestroyMixer() follows up with
// SDL_QuitSubSystem(SDL_INIT_AUDIO), which tears down SDL's device registry
// and renumbers every enumerated device id on the way back up. The id this
// switch was asked to open is then stale and the reopen fails with
// "Invalid audio device instance ID". Keeping the current device open while
// the replacement opens also means a failed switch leaves the current
// output playing instead of going permanently mute.
bool AudioManager::recreateMixerOnDevice(SDL_AudioDeviceID targetId)
{
// Capture the playback state we want to preserve across the switch.
std::string resumeTrack = currentTrack_;
float fadeSeconds = slots_[activeSlot_].fadeInDuration > 0.0f
? slots_[activeSlot_].fadeInDuration
: 0.0f;
// Open the replacement mixer on the target device first, while the
// current device (and SDL's device registry) is still alive.
MIX_Mixer *newMixer = MIX_CreateMixerDevice(targetId, nullptr);
if (!newMixer)
{
std::string err = SDL_GetError();
logger::Logger::instance().error(
"Audio",
"Could not open audio output device "
+ mapToFriendlyDeviceName(resolvePlaybackDeviceName(targetId))
+ "; keeping the current device (MIX_CreateMixerDevice error: "
+ err + ")");
return false;
}
MIX_Track *newTracks[2];
for (int i = 0; i < 2; ++i)
{
newTracks[i] = MIX_CreateTrack(newMixer);
}
if (!newTracks[0] || !newTracks[1])
{
MIX_DestroyMixer(newMixer);
logger::Logger::instance().error(
"Audio", "Could not create mixer tracks; keeping the current device (SDL error: "
+ std::string(SDL_GetError()) + ")");
return false;
}
// Tear down the existing mixer (its tracks die with it) and adopt the
// new one.
for (int i = 0; i < 2; ++i)
{
slots_[i].track = nullptr;
if (slots_[i].audio)
{
MIX_DestroyAudio(slots_[i].audio);
slots_[i].audio = nullptr;
}
}
if (mixer_)
{
MIX_DestroyMixer(mixer_);
mixer_ = nullptr;
}
mixer_ = newMixer;
slots_[0].track = newTracks[0];
slots_[1].track = newTracks[1];
float gain = enabled_ ? (volume_ / 100.0f) : 0.0f;
MIX_SetMixerGain(mixer_, gain);
// Resume the previously active track at full gain (or restart its
// fade-in if it was still ramping).
if (!resumeTrack.empty())
{
if (loadTrackInto(activeSlot_, resumeTrack))
{
if (fadeSeconds > 0.0f)
{
startFadeIn(activeSlot_, fadeSeconds);
}
else
{
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
playTrackLooping(activeSlot_); // Loop playback
}
}
}
return true;
}
bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename)
{
if (!initialized_ || !mixer_ || !slots_[slotIndex].track)
@@ -322,7 +657,7 @@ namespace radiantwave::audio
slot.fadeInDuration = seconds;
slot.fadeInElapsed = 0.0f;
MIX_SetTrackGain(slot.track, 0.0f);
MIX_PlayTrack(slot.track, 0); // Loop playback
playTrackLooping(slotIndex); // Loop playback
logger::Logger::instance().info("Audio", "Started playback of " + displayNameFor(currentTrack_) + " (fade-in)");
}
@@ -336,7 +671,7 @@ namespace radiantwave::audio
return false;
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
MIX_PlayTrack(slots_[activeSlot_].track, 0); // Loop playback
playTrackLooping(activeSlot_); // Loop playback
// Explicit user selection: persist the manual track so it survives a
// restart and is used whenever dynamic audio is turned off. Dynamic
@@ -373,10 +708,11 @@ namespace radiantwave::audio
slot.fadeInElapsed = 0.0f;
slot.fadeOutRemaining = 0.0f;
MIX_SetTrackGain(slot.track, 1.0f);
if (!MIX_TrackPlaying(slot.track))
{
MIX_PlayTrack(slot.track, 0); // Loop playback
}
// Always restart: MIX_PlayTrack() re-starts a playing track too, so
// this cancels any in-flight stop-fade inherited from a quick
// pop->push within the fade window. Gating on MIX_TrackPlaying()
// left a fading track to finish its fade and go silently stopped.
playTrackLooping(activeSlot_); // Loop playback
return true;
}
@@ -411,10 +747,14 @@ namespace radiantwave::audio
if (!loadTrackInto(otherSlot, wav_filename))
return false;
// Fade out whatever is currently playing on the active slot.
// Fade out whatever is currently playing on the active slot. Clear any
// in-flight fade-in bookkeeping first so update() stops ramping this
// slot's gain up while the mixer fades it down.
TrackSlot &active = slots_[activeSlot_];
if (active.audio)
{
active.fadeInDuration = 0.0f;
active.fadeInElapsed = 0.0f;
MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast<Sint64>(fade * 1000.0)));
active.fadeOutRemaining = fade;
}
@@ -442,6 +782,10 @@ namespace radiantwave::audio
{
continue;
}
// The mixer owns the ramp now; drop any pending fade-in so the
// per-frame update() cannot fight the fade-out.
slot.fadeInDuration = 0.0f;
slot.fadeInElapsed = 0.0f;
MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast<Sint64>(fade * 1000.0)));
slot.fadeOutRemaining = fade;
faded = true;
@@ -476,7 +820,29 @@ namespace radiantwave::audio
{
TrackSlot &slot = slots_[i];
if (!slot.track)
{
continue;
}
// A slot mid stop-fade belongs to the mixer: skip both the fade-in
// ramp and the active-gain pin so nothing drives its gain up while
// the mixer ramps it down (this also keeps a quit/pop fade clean).
if (slot.fadeOutRemaining > 0.0f)
{
slot.fadeOutRemaining -= dt;
if (slot.fadeOutRemaining <= 0.0f)
{
slot.fadeOutRemaining = 0.0f;
// Free the audio once the fade-out has finished; the track
// object itself is reused.
if (slot.audio)
{
MIX_DestroyAudio(slot.audio);
slot.audio = nullptr;
}
}
continue;
}
if (slot.fadeInDuration > 0.0f)
{
@@ -493,21 +859,12 @@ namespace radiantwave::audio
// Keep the active track at full per-track gain; the mixer gain
// carries the user's volume / mute state.
MIX_SetTrackGain(slot.track, 1.0f);
}
if (slot.fadeOutRemaining > 0.0f)
// Self-heal: playback must continue indefinitely, so restart a
// track that stopped unexpectedly (there is no pause state and
// nothing else should legitimately stop the active track).
if (slot.audio && enabled_ && !MIX_TrackPlaying(slot.track))
{
slot.fadeOutRemaining -= dt;
if (slot.fadeOutRemaining <= 0.0f)
{
slot.fadeOutRemaining = 0.0f;
// Free the audio once the fade-out has finished; the track
// object itself is reused.
if (slot.audio)
{
MIX_DestroyAudio(slot.audio);
slot.audio = nullptr;
}
playTrackLooping(i);
}
}
}
+35
View File
@@ -1,6 +1,7 @@
#pragma once
#include "config/config_manager.h"
#include <SDL3/SDL.h>
#include <SDL3_mixer/SDL_mixer.h>
#include <mutex>
#include <string>
@@ -15,6 +16,7 @@ namespace radiantwave::audio {
struct AudioDeviceItem {
std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo
std::string friendly_name; // e.g. "Built-In Speakers"
SDL_AudioDeviceID id = 0; // SDL playback device id (0 = synthetic fallback)
};
// Maps raw technical audio device strings to user-friendly labels
@@ -51,7 +53,24 @@ public:
// Audio device selection
bool switchDevice(const std::string& raw_device_name);
bool switchDeviceById(SDL_AudioDeviceID id);
std::string getCurrentDevice() const;
SDL_AudioDeviceID getCurrentDeviceId() const;
// Handle an SDL audio device event (SDL_EVENT_AUDIO_DEVICE_ADDED / REMOVED).
// Returns true if the active device was affected and the mixer was recreated.
bool handleDeviceEvent(const SDL_Event& event);
// Pure helper (no SDL audio open): find the playback device with the given
// SDL id, returning its raw name ("" when not found / id invalid). Exposed
// for tests; the real lookup is through getPlaybackDevices().
static std::string resolvePlaybackDeviceName(SDL_AudioDeviceID id);
// Builds the SDL_PropertiesID passed to MIX_PlayTrack() for tracks that
// loop forever (MIX_PROP_PLAY_LOOPS_NUMBER = -1). Exposed for tests; no
// mixer, track, or audio device is required. The caller owns the property
// set and must release it with SDL_DestroyProperties().
static SDL_PropertiesID createInfiniteLoopOptions();
// Playback control
bool playTrack(const std::string& wav_filename);
@@ -84,6 +103,21 @@ private:
void startFadeIn(int slotIndex, float seconds);
std::string displayNameFor(const std::string& wav_filename) const;
// Starts the track on the given slot with infinite looping. The track must
// already be loaded on the slot. Calling this on a playing/stopped/fading
// track restarts it (cancelling any in-flight stop-fade).
bool playTrackLooping(int slotIndex);
// Recreates the mixer on the given playback device id (0 = system default).
// Called with mutex_ held. Preserves playback state: whichever track was
// playing on the active slot is reloaded and resumed at full gain, an
// in-progress fade is restarted. The replacement mixer is opened before the
// current one is destroyed so SDL's device ids stay valid (destroy-first
// quits the whole audio subsystem and orphans the id being opened).
// Returns false on failure without touching the current mixer, so the
// existing output keeps playing.
bool recreateMixerOnDevice(SDL_AudioDeviceID targetId);
config::ConfigManager* configManager_ = nullptr;
db::DatabaseManager* db_ = nullptr;
@@ -91,6 +125,7 @@ private:
bool enabled_ = true;
int volume_ = 80; // 0 - 100
std::string currentDevice_ = "";
SDL_AudioDeviceID currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
std::string currentTrack_ = "";
MIX_Mixer* mixer_ = nullptr;
+17 -4
View File
@@ -77,7 +77,7 @@ void AudioSettingsPage::onPush(AppState& state) {
} else if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
applyDeviceSelection();
}
modalState_ = ModalState::None;
} else if (modalState_ == ModalState::TrackSelector) {
@@ -156,7 +156,7 @@ void AudioSettingsPage::onResume(AppState& state) {
} else if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
applyDeviceSelection();
}
modalState_ = ModalState::None;
} else if (modalState_ == ModalState::TrackSelector) {
@@ -280,6 +280,19 @@ void AudioSettingsPage::refreshDeviceAndTrackLists() {
}
}
void AudioSettingsPage::applyDeviceSelection() {
if (!state_) return;
if (currentDeviceIndex_ < 0 || currentDeviceIndex_ >= static_cast<int>(devices_.size())) return;
const bool ok = state_->audio.switchDeviceById(devices_[currentDeviceIndex_].id);
if (ok) {
statusMessage_ = "Switched to " + devices_[currentDeviceIndex_].friendly_name;
} else {
statusMessage_ = "Device unavailable - kept the current output";
}
refreshDeviceAndTrackLists();
}
void AudioSettingsPage::enableDynamicAudio() {
if (!state_) return;
state_->config.update([](radiantwave::config::AppConfig& cfg) {
@@ -324,7 +337,7 @@ void AudioSettingsPage::handleEvent(const SDL_Event& event) {
if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
applyDeviceSelection();
}
} else if (modalState_ == ModalState::TrackSelector) {
if (modalSelectedIndex_ == 0) {
@@ -518,7 +531,7 @@ void AudioSettingsPage::render(SDL_Renderer* renderer) {
}
std::string devName = (currentDeviceIndex_ >= 0 && currentDeviceIndex_ < static_cast<int>(devices_.size()))
? devices_[currentDeviceIndex_].friendly_name : "Built-In Speakers";
? devices_[currentDeviceIndex_].friendly_name : "System Default";
deviceText_.ensure(renderer, fontHeader_, "Device: " + devName, white);
if (deviceText_.texture) {
SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height};
+4
View File
@@ -36,6 +36,10 @@ private:
void enableDynamicAudio();
void selectTrack(int trackIdx);
// Applies the currently selected device (devices_[currentDeviceIndex_]) via
// the audio manager and records user-facing success/failure feedback.
void applyDeviceSelection();
AppState* state_ = nullptr;
int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back
+1
View File
@@ -16,6 +16,7 @@ struct ProclamationConfig {
float scroll_speed = 1.0f; // scroll speed multiplier
int font_size = 32; // font size in pt
std::string current_id = ""; // currently active proclamation ID
bool schedule_active = false; // follow the portal's listening schedule
};
struct AppConfig {
+3
View File
@@ -250,6 +250,9 @@ void ConfigManager::loadAndMergeDefaults() {
if (proc_node.hasChild("current_id")) {
merged.proclamation.current_id = proc_node.getChild("current_id").asString(defaults.proclamation.current_id);
}
if (proc_node.hasChild("schedule_active")) {
merged.proclamation.schedule_active = proc_node.getChild("schedule_active").asBool(defaults.proclamation.schedule_active);
}
}
{
+4
View File
@@ -231,6 +231,9 @@ AppConfig parseConfigFromYaml(const std::string& yamlContent) {
if (proc_node.hasChild("current_id")) {
config.proclamation.current_id = proc_node.getChild("current_id").asString(config.proclamation.current_id);
}
if (proc_node.hasChild("schedule_active")) {
config.proclamation.schedule_active = proc_node.getChild("schedule_active").asBool(config.proclamation.schedule_active);
}
}
return config;
@@ -256,6 +259,7 @@ std::string serializeConfigToYaml(const AppConfig& config) {
ss << " scroll_speed: " << config.proclamation.scroll_speed << "\n";
ss << " font_size: " << config.proclamation.font_size << "\n";
ss << " current_id: \"" << config.proclamation.current_id << "\"\n";
ss << " schedule_active: " << (config.proclamation.schedule_active ? "true" : "false") << "\n";
return ss.str();
}
+281 -18
View File
@@ -131,6 +131,7 @@ bool DatabaseManager::runMigrations() {
is_active INTEGER DEFAULT 1,
selected INTEGER DEFAULT 0,
category_id INTEGER DEFAULT 0,
group_id INTEGER DEFAULT 0,
solfeggio_frequency_id INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -138,7 +139,12 @@ bool DatabaseManager::runMigrations() {
CREATE TABLE IF NOT EXISTS proclamation_categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS proclamation_groups (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
@@ -175,6 +181,23 @@ bool DatabaseManager::runMigrations() {
hex TEXT NOT NULL,
PRIMARY KEY (solfeggio_frequency_id, color_index)
);
CREATE TABLE IF NOT EXISTS schedule_blocks (
id TEXT PRIMARY KEY,
start_minutes INTEGER NOT NULL DEFAULT 0,
end_minutes INTEGER NOT NULL DEFAULT 0,
overnight INTEGER NOT NULL DEFAULT 0,
duration_minutes INTEGER NOT NULL DEFAULT 0,
dynamic_audio INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS schedule_block_proclamations (
block_id TEXT NOT NULL,
position INTEGER NOT NULL,
proclamation_id TEXT NOT NULL,
PRIMARY KEY (block_id, position)
);
)";
bool ok = executeSql(schema);
@@ -182,6 +205,7 @@ bool DatabaseManager::runMigrations() {
// Back-fill columns for databases created before these fields existed.
ensureColumn("proclamations", "selected", "selected INTEGER DEFAULT 0");
ensureColumn("proclamations", "category_id", "category_id INTEGER DEFAULT 0");
ensureColumn("proclamations", "group_id", "group_id INTEGER DEFAULT 0");
ensureColumn("proclamations", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
ensureColumn("audio_files", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
// Clean up the pre-active/inactive-license subscription level keys.
@@ -189,12 +213,72 @@ bool DatabaseManager::runMigrations() {
executeSql("DELETE FROM system_kv WHERE key IN "
"('subscription_level_id','subscription_level_name','subscription_level_description');");
}
if (ok) {
ok = migrateSubCategoriesToGroups();
}
if (ok) {
seedColorPalettes();
}
return ok;
}
// Schema migration v2: sub-categories -> tag-like groups.
//
// Pre-v2 databases modelled a two-level category tree via
// `proclamation_categories.parent_id`, and proclamations pointed at either a
// root or a sub-category. v2 flattens categories and turns each former
// sub-category into a group, remapping the proclamations that referenced it to
// its parent category + the new group. Selection flags are never touched, so
// the user's picks survive the upgrade. `PRAGMA user_version` records the
// migration so it runs exactly once; the whole thing is one transaction so a
// failure leaves the database untouched. No network is required, so this
// always runs at startup, including on offline kiosks.
bool DatabaseManager::migrateSubCategoriesToGroups() {
if (!db_) return false;
int userVersion = 0;
{
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "PRAGMA user_version;", -1, &stmt, nullptr) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
userVersion = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
}
}
if (userVersion >= 2) {
return true; // already migrated
}
// Fresh databases (created by the v2 schema) have no legacy parent_id
// column, so there is nothing to convert: just stamp the version.
if (!columnExists("proclamation_categories", "parent_id")) {
return executeSql("PRAGMA user_version = 2;");
}
bool migrated = executeSql(
"BEGIN;"
"INSERT OR IGNORE INTO proclamation_groups (id, name, sort_order) "
" SELECT id, name, sort_order FROM proclamation_categories WHERE parent_id != 0;"
"UPDATE proclamations "
" SET group_id = category_id, "
" category_id = COALESCE("
" (SELECT parent_id FROM proclamation_categories "
" WHERE id = proclamations.category_id), "
" category_id) "
" WHERE category_id IN "
" (SELECT id FROM proclamation_categories WHERE parent_id != 0);"
"DELETE FROM proclamation_categories WHERE parent_id != 0;"
"PRAGMA user_version = 2;"
"COMMIT;");
if (!migrated) {
executeSql("ROLLBACK;");
}
return migrated;
}
void DatabaseManager::seedColorPalettes() {
static const char* kSeedSql =
"INSERT OR IGNORE INTO color_palettes (solfeggio_frequency_id, color_index, hex) VALUES "
@@ -337,7 +421,8 @@ void DatabaseManager::bindProclamationRecord(sqlite3_stmt* stmt, const Proclamat
sqlite3_bind_int(stmt, 6, record.is_active ? 1 : 0);
sqlite3_bind_int(stmt, 7, record.selected ? 1 : 0);
sqlite3_bind_int(stmt, 8, record.category_id);
sqlite3_bind_int(stmt, 9, record.solfeggio_frequency_id);
sqlite3_bind_int(stmt, 9, record.group_id);
sqlite3_bind_int(stmt, 10, record.solfeggio_frequency_id);
}
bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
@@ -345,12 +430,13 @@ bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
if (!db_) return false;
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
"is_active, selected, category_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
"is_active=excluded.is_active, selected=excluded.selected, "
"category_id=excluded.category_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
"category_id=excluded.category_id, group_id=excluded.group_id, "
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -374,7 +460,8 @@ static ProclamationRecord readProclamationRecord(sqlite3_stmt* stmt, int firstCo
rec.is_active = (sqlite3_column_int(stmt, firstCol + 5) != 0);
rec.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
rec.category_id = sqlite3_column_int(stmt, firstCol + 7);
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 8);
rec.group_id = sqlite3_column_int(stmt, firstCol + 8);
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 9);
return rec;
}
@@ -384,7 +471,7 @@ std::vector<ProclamationRecord> DatabaseManager::getActiveProclamations() {
if (!db_) return result;
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
"selected, category_id, solfeggio_frequency_id "
"selected, category_id, group_id, solfeggio_frequency_id "
"FROM proclamations WHERE is_active = 1;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -414,8 +501,8 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
return false;
}
const char* insSql = "INSERT INTO proclamation_categories (id, name, parent_id, sort_order) "
"VALUES (?, ?, ?, ?);";
const char* insSql = "INSERT INTO proclamation_categories (id, name, sort_order) "
"VALUES (?, ?, ?);";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
@@ -425,8 +512,50 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
for (const auto& cat : records) {
sqlite3_bind_int(stmt, 1, cat.id);
sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 3, cat.parent_id);
sqlite3_bind_int(stmt, 4, cat.sort_order);
sqlite3_bind_int(stmt, 3, cat.sort_order);
if (sqlite3_step(stmt) != SQLITE_DONE) {
ok = false;
break;
}
sqlite3_reset(stmt);
sqlite3_clear_bindings(stmt);
}
sqlite3_finalize(stmt);
if (ok) {
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
} else {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
}
return ok;
}
bool DatabaseManager::replaceGroups(const std::vector<ProclamationGroupRecord>& records) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) {
return false;
}
bool ok = true;
if (sqlite3_exec(db_, "DELETE FROM proclamation_groups;", nullptr, nullptr, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
const char* insSql = "INSERT INTO proclamation_groups (id, name, sort_order) "
"VALUES (?, ?, ?);";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
for (const auto& group : records) {
sqlite3_bind_int(stmt, 1, group.id);
sqlite3_bind_text(stmt, 2, group.name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 3, group.sort_order);
if (sqlite3_step(stmt) != SQLITE_DONE) {
ok = false;
break;
@@ -499,12 +628,12 @@ bool DatabaseManager::syncProclamations(const std::vector<ProclamationRecord>& r
// Upsert each proclamation, preserving the stashed selection state.
if (ok) {
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
"is_active, selected, category_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
"is_active=excluded.is_active, category_id=excluded.category_id, "
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
"group_id=excluded.group_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -555,7 +684,7 @@ std::vector<ProclamationRecord> DatabaseManager::getAllProclamations() {
if (!db_) return result;
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
"selected, category_id, solfeggio_frequency_id FROM proclamations;";
"selected, category_id, group_id, solfeggio_frequency_id FROM proclamations;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
@@ -574,7 +703,7 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
std::vector<ProclamationCategoryRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, name, parent_id, sort_order FROM proclamation_categories "
const char* sql = "SELECT id, name, sort_order FROM proclamation_categories "
"ORDER BY sort_order ASC, id ASC;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -585,8 +714,31 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
ProclamationCategoryRecord rec;
rec.id = sqlite3_column_int(stmt, 0);
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
rec.parent_id = sqlite3_column_int(stmt, 2);
rec.sort_order = sqlite3_column_int(stmt, 3);
rec.sort_order = sqlite3_column_int(stmt, 2);
result.push_back(rec);
}
sqlite3_finalize(stmt);
return result;
}
std::vector<ProclamationGroupRecord> DatabaseManager::getAllGroups() {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<ProclamationGroupRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, name, sort_order FROM proclamation_groups "
"ORDER BY sort_order ASC, id ASC;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
}
while (sqlite3_step(stmt) == SQLITE_ROW) {
ProclamationGroupRecord rec;
rec.id = sqlite3_column_int(stmt, 0);
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
rec.sort_order = sqlite3_column_int(stmt, 2);
result.push_back(rec);
}
@@ -948,4 +1100,115 @@ std::vector<AudioFileRecord> DatabaseManager::getAudioFiles() {
return result;
}
// ─── Schedule Blocks Table ─────────────────────────────────────────────
bool DatabaseManager::replaceScheduleBlocks(const std::vector<ScheduleBlockRecord>& records) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) {
return false;
}
bool ok = true;
if (sqlite3_exec(db_, "DELETE FROM schedule_block_proclamations;", nullptr, nullptr, nullptr) != SQLITE_OK ||
sqlite3_exec(db_, "DELETE FROM schedule_blocks;", nullptr, nullptr, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
const char* blockSql = "INSERT INTO schedule_blocks "
"(id, start_minutes, end_minutes, overnight, duration_minutes, "
" dynamic_audio, sort_order) "
"VALUES (?, ?, ?, ?, ?, ?, ?);";
const char* procSql = "INSERT INTO schedule_block_proclamations "
"(block_id, position, proclamation_id) VALUES (?, ?, ?);";
for (size_t i = 0; i < records.size() && ok; ++i) {
const ScheduleBlockRecord& rec = records[i];
sqlite3_stmt* blockStmt = nullptr;
if (sqlite3_prepare_v2(db_, blockSql, -1, &blockStmt, nullptr) != SQLITE_OK) {
ok = false;
break;
}
sqlite3_bind_text(blockStmt, 1, rec.id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(blockStmt, 2, rec.start_minutes);
sqlite3_bind_int(blockStmt, 3, rec.end_minutes);
sqlite3_bind_int(blockStmt, 4, rec.overnight ? 1 : 0);
sqlite3_bind_int(blockStmt, 5, rec.duration_minutes);
sqlite3_bind_int(blockStmt, 6, rec.dynamic_audio ? 1 : 0);
sqlite3_bind_int(blockStmt, 7, static_cast<int>(i));
if (sqlite3_step(blockStmt) != SQLITE_DONE) ok = false;
sqlite3_finalize(blockStmt);
if (!ok) break;
for (size_t p = 0; p < rec.proclamation_ids.size(); ++p) {
sqlite3_stmt* procStmt = nullptr;
if (sqlite3_prepare_v2(db_, procSql, -1, &procStmt, nullptr) != SQLITE_OK) {
ok = false;
break;
}
sqlite3_bind_text(procStmt, 1, rec.id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(procStmt, 2, static_cast<int>(p));
sqlite3_bind_text(procStmt, 3, rec.proclamation_ids[p].c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(procStmt) != SQLITE_DONE) ok = false;
sqlite3_finalize(procStmt);
if (!ok) break;
}
}
if (ok) {
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
} else {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
}
return ok;
}
std::vector<ScheduleBlockRecord> DatabaseManager::getScheduleBlocks() {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<ScheduleBlockRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, start_minutes, end_minutes, overnight, duration_minutes, "
"dynamic_audio FROM schedule_blocks ORDER BY sort_order ASC;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
}
while (sqlite3_step(stmt) == SQLITE_ROW) {
ScheduleBlockRecord rec;
rec.id = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
rec.start_minutes = sqlite3_column_int(stmt, 1);
rec.end_minutes = sqlite3_column_int(stmt, 2);
rec.overnight = (sqlite3_column_int(stmt, 3) != 0);
rec.duration_minutes = sqlite3_column_int(stmt, 4);
rec.dynamic_audio = (sqlite3_column_int(stmt, 5) != 0);
result.push_back(std::move(rec));
}
sqlite3_finalize(stmt);
// Attach each block's proclamation ids in play order.
const char* procSql = "SELECT proclamation_id FROM schedule_block_proclamations "
"WHERE block_id = ? ORDER BY position ASC;";
for (auto& block : result) {
sqlite3_stmt* procStmt = nullptr;
if (sqlite3_prepare_v2(db_, procSql, -1, &procStmt, nullptr) != SQLITE_OK) {
break;
}
sqlite3_bind_text(procStmt, 1, block.id.c_str(), -1, SQLITE_TRANSIENT);
while (sqlite3_step(procStmt) == SQLITE_ROW) {
const unsigned char* text = sqlite3_column_text(procStmt, 0);
if (text) {
block.proclamation_ids.push_back(reinterpret_cast<const char*>(text));
}
}
sqlite3_finalize(procStmt);
}
return result;
}
} // namespace radiantwave::db
+40 -1
View File
@@ -16,13 +16,23 @@ struct ProclamationRecord {
bool is_active = true;
bool selected = false;
int category_id = 0; // maps to ProclamationCategoryRecord::id
int group_id = 0; // maps to ProclamationGroupRecord::id (0 = ungrouped)
int solfeggio_frequency_id = 0; // maps to the solfeggio frequency preset
};
struct ProclamationCategoryRecord {
int id = 0;
std::string name;
int parent_id = 0; // 0 = root category
int sort_order = 0;
};
// A group is a tag-like organizer: it is not owned by a category. A
// proclamation belongs to one category and may additionally belong to one
// group. Within the UI, a group appears under a category only when at least
// one proclamation pairs that (category_id, group_id).
struct ProclamationGroupRecord {
int id = 0;
std::string name;
int sort_order = 0;
};
@@ -44,6 +54,18 @@ struct AudioFileRecord {
int solfeggio_frequency_id = 0; // 0 = not associated with a solfeggio frequency
};
// One scheduled listening block, mirrored from GET /schedule. `proclamation_ids`
// is the ordered play list for the block.
struct ScheduleBlockRecord {
std::string id;
int start_minutes = 0;
int end_minutes = 0;
bool overnight = false;
int duration_minutes = 0;
bool dynamic_audio = true; // override the device audio for this block
std::vector<std::string> proclamation_ids;
};
// A single color in a solfeggio frequency's color palette (RGBA).
struct PaletteColor {
uint8_t r = 0;
@@ -90,6 +112,9 @@ public:
// Full sync: replaces all categories with the given set.
bool replaceCategories(const std::vector<ProclamationCategoryRecord>& records);
// Full sync: replaces all groups with the given set.
bool replaceGroups(const std::vector<ProclamationGroupRecord>& records);
// Full sync: upserts proclamations, deletes any not in `records`,
// preserves the existing `selected` state, and forces the proclamation
// identified by `defaultProclamationId` to remain selected.
@@ -102,6 +127,9 @@ public:
// Fetch all categories ordered by sort_order then id.
std::vector<ProclamationCategoryRecord> getAllCategories();
// Fetch all groups ordered by sort_order then id.
std::vector<ProclamationGroupRecord> getAllGroups();
// Toggle a single proclamation's selected flag.
bool setProclamationSelected(const std::string& id, bool selected);
@@ -138,12 +166,23 @@ public:
// palettes can be pushed from the portal once the API route exists.
bool replaceColorPalettes(const std::vector<PaletteRecord>& records);
// ── Schedule Blocks Table ─────────────────────────────────────────
// Full sync: replaces the stored schedule with `records` (the portal is the
// source of truth). Pass an empty vector to clear the schedule. Block order
// and per-block proclamation order are preserved exactly as given.
bool replaceScheduleBlocks(const std::vector<ScheduleBlockRecord>& records);
// Fetch all schedule blocks, ordered as stored, with their proclamation ids
// in play order.
std::vector<ScheduleBlockRecord> getScheduleBlocks();
// ── Event Logging Table ───────────────────────────────────────────
bool logEvent(const std::string& level, const std::string& component, const std::string& message);
private:
std::string resolvePath(const std::string& raw_path) const;
bool runMigrations();
bool migrateSubCategoriesToGroups();
bool executeSql(const std::string& sql);
bool columnExists(const std::string& table, const std::string& column);
bool ensureColumn(const std::string& table, const std::string& column,
+26 -2
View File
@@ -180,6 +180,16 @@ void Home::render(SDL_Renderer *renderer)
SDL_RenderFillRect(renderer, &line);
}
// Large bold reminder to connect WiFi, drawn below the menu while no
// network is joined (refreshed every 5s in update()).
if (wifiNote_.texture && ssid_.empty())
{
float noteX = (fw - wifiNote_.width) / 2.0f;
float noteY = settingsY_ + settings_.height + 40.0f;
SDL_FRect noteDst{noteX, noteY, wifiNote_.width, wifiNote_.height};
SDL_RenderTexture(renderer, wifiNote_.texture, nullptr, &noteDst);
}
// Bottom-right status footer (20pt, aligned to a virtual rectangle:
// names left-aligned at the rect's left edge, values right-aligned at
// the rect's right edge; the longest name's colon sits 4 spaces from its
@@ -281,10 +291,10 @@ void Home::render(SDL_Renderer *renderer)
fourSpaces.destroy();
const float margin = 20.0f;
float rectRight = fw - margin;
float rectRight = fw - margin - 50.0f;
float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW);
float lineY = fh - margin;
float lineY = fh - margin - 50.0f;
const float lineGap = 6.0f;
for (size_t i = footerNames_.size(); i-- > 0;)
{
@@ -352,6 +362,15 @@ void Home::createTextures(AppState &state)
TTF_CloseFont(font);
font = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 48);
if (font)
{
TTF_SetFontStyle(font, TTF_STYLE_BOLD);
wifiNote_ = renderText(state.renderer, font, "Please connect to WiFi", white);
TTF_SetFontStyle(font, TTF_STYLE_NORMAL);
TTF_CloseFont(font);
}
SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png");
if (bgSurface)
{
@@ -410,6 +429,11 @@ void Home::destroyTextures()
SDL_DestroyTexture(settings_.texture);
settings_.texture = nullptr;
}
if (wifiNote_.texture)
{
SDL_DestroyTexture(wifiNote_.texture);
wifiNote_.texture = nullptr;
}
for (auto &t : footerNames_)
t.destroy();
for (auto &t : footerValues_)
+1
View File
@@ -34,6 +34,7 @@ private:
TextLine play_;
TextLine settings_;
TextLine bg_;
TextLine wifiNote_;
AppState* state_ = nullptr;
float playY_ = 0;
+38 -4
View File
@@ -4,13 +4,47 @@
namespace radiantwave {
// Design resolution every page lays out against. The renderer's logical
// presentation (LETTERBOX) scales this fixed space onto whatever
// window/backbuffer the display actually provides.
inline constexpr int kDesignWidth = 1920;
inline constexpr int kDesignHeight = 1080;
// Returns the size pages should lay out against: the renderer's logical size
// when presentation is active, otherwise the real window size. The output
// params are always written — design constants are the last-resort fallback.
inline void getLayoutSize(SDL_Renderer *renderer, int *w, int *h)
{
if (!w || !h)
return;
*w = kDesignWidth;
*h = kDesignHeight;
if (!renderer)
return;
SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_DISABLED;
if (!SDL_GetRenderLogicalPresentation(renderer, w, h, &mode) ||
mode == SDL_LOGICAL_PRESENTATION_DISABLED || *w <= 0 || *h <= 0)
{
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), w, h);
int logicalW = 0;
int logicalH = 0;
if (SDL_GetRenderLogicalPresentation(renderer, &logicalW, &logicalH,
&mode) &&
mode != SDL_LOGICAL_PRESENTATION_DISABLED && logicalW > 0 &&
logicalH > 0) {
*w = logicalW;
*h = logicalH;
return;
}
SDL_Window *window = SDL_GetRenderWindow(renderer);
if (!window)
return;
int windowW = 0;
int windowH = 0;
SDL_GetWindowSize(window, &windowW, &windowH);
if (windowW > 0 && windowH > 0) {
*w = windowW;
*h = windowH;
}
}
+73 -10
View File
@@ -4,8 +4,10 @@
#include "app_state.h"
#include "home.h"
#include "logical_size.h"
#include "logger/logger.h"
#include "provisioning.h"
#include "scroller_page.h"
namespace {
// How long audio fades out before the app tears down on quit. Only applied
@@ -32,11 +34,26 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
auto *state = new AppState{};
// SDL_WINDOW_FULLSCREEN fills the desktop's native resolution, so the
// compositor never has to scale the moving scroller (scaling moving text
// causes flicker).
state->window =
SDL_CreateWindow("RadiantWave", 1920, 1080, SDL_WINDOW_FULLSCREEN);
// Size the fullscreen window from the display's actual mode so the very
// first buffer commit already matches what the compositor scanouts 1:1.
// Requesting a hardcoded size left the backbuffer at that size on
// displays running a different mode, and frames reached the panel
// unscaled (quarter-size on 4K, cropped on smaller panels).
const SDL_DisplayMode *displayMode =
SDL_GetCurrentDisplayMode(SDL_GetPrimaryDisplay());
int windowWidth =
(displayMode && displayMode->w > 0) ? displayMode->w
: radiantwave::kDesignWidth;
int windowHeight =
(displayMode && displayMode->h > 0) ? displayMode->h
: radiantwave::kDesignHeight;
// Borderless desktop fullscreen: the compositor never has to scale the
// moving scroller (scaling moving text causes flicker). Deliberately no
// SDL_WINDOW_HIGH_PIXEL_DENSITY — kiosk panels are not HiDPI, so keeping
// the pixel buffer equal to the display mode keeps scanout 1:1.
state->window = SDL_CreateWindow("RadiantWave", windowWidth, windowHeight,
SDL_WINDOW_FULLSCREEN);
if (!state->window) {
SDL_Log("Failed to create window: %s", SDL_GetError());
delete state;
@@ -59,16 +76,38 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
std::string(SDL_GetError()) + ")");
}
// Lock the renderer to a 1920x1080 logical coordinate space. All page
// layout code already targets this resolution, so everything renders at
// the correct size on any display — the GPU scales to native pixels.
if (!SDL_SetRenderLogicalPresentation(state->renderer, 1920, 1080,
SDL_LOGICAL_PRESENTATION_STRETCH)) {
// All page layout targets the fixed design resolution; LETTERBOX maps it
// onto the real window with aspect preserved (bars on non-16:9 panels).
if (!SDL_SetRenderLogicalPresentation(state->renderer,
radiantwave::kDesignWidth,
radiantwave::kDesignHeight,
SDL_LOGICAL_PRESENTATION_LETTERBOX)) {
radiantwave::logger::Logger::instance().warning(
"Graphics", "Could not set logical render size; layout may be "
"wrong on HiDPI displays");
}
{
// Record how the requested mode, window, pixel buffer, and render
// output actually compare — this is the evidence trail for
// display-scaling problems on field units.
int windowW = 0, windowH = 0;
SDL_GetWindowSize(state->window, &windowW, &windowH);
int pixelW = 0, pixelH = 0;
SDL_GetWindowSizeInPixels(state->window, &pixelW, &pixelH);
int outputW = 0, outputH = 0;
SDL_GetRenderOutputSize(state->renderer, &outputW, &outputH);
radiantwave::logger::Logger::instance().info(
"Graphics",
"Requested " + std::to_string(windowWidth) + "x" +
std::to_string(windowHeight) + ", window " +
std::to_string(windowW) + "x" + std::to_string(windowH) +
", pixels " + std::to_string(pixelW) + "x" +
std::to_string(pixelH) + ", render output " +
std::to_string(outputW) + "x" + std::to_string(outputH) +
", presentation LETTERBOX");
}
SDL_HideCursor();
// Initialize configuration, database, and network manager
@@ -157,6 +196,30 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
state.quitRequested = true;
}
if (event->type == SDL_EVENT_AUDIO_DEVICE_ADDED ||
event->type == SDL_EVENT_AUDIO_DEVICE_REMOVED) {
// Route device hotplug to the audio manager so an active device that
// disappears is failed over to the system default automatically.
state.audio.handleDeviceEvent(*event);
}
if (event->type == SDL_EVENT_WINDOW_RESIZED) {
radiantwave::logger::Logger::instance().info(
"Graphics", "Window resized to " +
std::to_string(event->window.data1) + "x" +
std::to_string(event->window.data2));
// Logical presentation persists across resizes; re-apply defensively
// in case backend state was reset underneath us. Skip while the
// scroller is active — it owns its own presentation (NONE, full
// window) and restores LETTERBOX in onPop.
if (!dynamic_cast<ScrollerPage *>(state.pages.current())) {
SDL_SetRenderLogicalPresentation(state.renderer,
radiantwave::kDesignWidth,
radiantwave::kDesignHeight,
SDL_LOGICAL_PRESENTATION_LETTERBOX);
}
}
if (!state.keybinds.handleEvent(*event)) {
state.pages.handleEvent(*event);
}
+80
View File
@@ -221,6 +221,86 @@ bool parseCreateAccountJson(const std::string& body, AccountBody& out) {
return true;
}
// ─── GET /schedule ─────────────────────────────────────────────────────
bool parseScheduleJson(const std::string& body, ScheduleResponse& out) {
radiantwave::json::JsonValue root;
if (!radiantwave::json::parseDocument(body, root) ||
root.type != radiantwave::json::JsonValue::Type::Object) {
return false;
}
ScheduleResponse parsed;
parsed.email = root.stringField("email");
const radiantwave::json::JsonValue* blocks = root.find("blocks");
if (blocks && blocks->type == radiantwave::json::JsonValue::Type::Array) {
for (const auto& entry : blocks->array) {
if (entry.type != radiantwave::json::JsonValue::Type::Object) continue;
ScheduleBlock block;
block.id = entry.stringField("id");
if (block.id.empty()) continue;
block.start_minutes = static_cast<int>(entry.numberField("startMinutes"));
block.end_minutes = static_cast<int>(entry.numberField("endMinutes"));
block.overnight = entry.boolField("overnight");
block.duration_minutes = static_cast<int>(entry.numberField("durationMinutes"));
// `dynamicAudio` is optional on the wire and defaults to true.
const radiantwave::json::JsonValue* dyn = entry.find("dynamicAudio");
block.dynamic_audio =
(dyn && dyn->type == radiantwave::json::JsonValue::Type::Bool) ? dyn->b : true;
const radiantwave::json::JsonValue* ids = entry.find("proclamationIds");
if (ids && ids->type == radiantwave::json::JsonValue::Type::Array) {
for (const auto& id : ids->array) {
if (id.type == radiantwave::json::JsonValue::Type::String &&
!id.str.empty()) {
block.proclamation_ids.push_back(id.str);
}
}
}
parsed.blocks.push_back(std::move(block));
}
}
out = std::move(parsed);
return true;
}
namespace {
// Percent-encodes a query-parameter value (RFC 3986 unreserved set).
std::string urlEncode(const std::string& value) {
CURL* curl = curl_easy_init();
if (!curl) return value;
char* escaped = curl_easy_escape(curl, value.c_str(), static_cast<int>(value.size()));
std::string result = escaped ? escaped : value;
if (escaped) curl_free(escaped);
curl_easy_cleanup(curl);
return result;
}
} // anonymous namespace
bool fetchSchedule(const std::string& email, const std::string& license_key,
ScheduleResponse& out) {
if (email.empty() || license_key.empty()) {
return false;
}
const std::string path = std::string(PATH_SCHEDULE) +
"?email=" + urlEncode(email) +
"&licenseKey=" + urlEncode(license_key);
HttpResponse response = httpGet(path);
if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false;
}
return parseScheduleJson(response.body, out);
}
AccountResult createAccount(const std::string& email, const std::string& name) {
AccountResult result;
+35
View File
@@ -12,10 +12,12 @@ constexpr const char* API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"
// Paths relative to API_BASE_URL.
constexpr const char* PATH_PROCLAMATIONS = "/proclamations";
constexpr const char* PATH_CATEGORIES = "/categories";
constexpr const char* PATH_GROUPS = "/groups";
constexpr const char* PATH_FREQUENCIES = "/frequencies";
constexpr const char* PATH_LICENSE = "/license";
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
constexpr const char* PATH_COLOR_PALETTES = "/color-palettes";
constexpr const char* PATH_SCHEDULE = "/schedule";
// Result of a single HTTP request.
struct HttpResponse {
@@ -118,4 +120,37 @@ struct AccountResult {
// to the user and is NOT part of the response. `name` may be empty.
AccountResult createAccount(const std::string& email, const std::string& name = "");
// ─── GET /schedule ─────────────────────────────────────────────────────
// One scheduled listening block. Wall-clock times are carried as minutes from
// midnight; overnight blocks wrap past midnight (end clock time <= start).
struct ScheduleBlock {
std::string id;
int start_minutes = 0;
int end_minutes = 0;
bool overnight = false;
int duration_minutes = 0;
// When true the kiosk enables dynamic audio for the block's duration,
// overriding the device's audio selection. Defaults to true: schedules are
// dynamic unless the portal explicitly says otherwise.
bool dynamic_audio = true;
// Selected proclamation ids, in play order.
std::vector<std::string> proclamation_ids;
};
struct ScheduleResponse {
std::string email;
std::vector<ScheduleBlock> blocks;
};
// Parse the JSON payload returned by the schedule endpoint. `dynamicAudio` is
// optional and defaults to true when absent. Returns false on malformed input.
bool parseScheduleJson(const std::string& body, ScheduleResponse& out);
// Fetch a user's schedule. Authenticates via the paired `email` + `license_key`
// query parameters. Returns true only on HTTP 2xx + successful parse; failures
// (including a missing/blank pair) leave `out` untouched.
bool fetchSchedule(const std::string& email, const std::string& license_key,
ScheduleResponse& out);
} // namespace radiantwave::api
+69 -2
View File
@@ -1,4 +1,5 @@
#include "network/proclamation_sync.h"
#include "db/db_keys.h"
#include "network/api_client.h"
#include "network/json.h"
@@ -66,6 +67,7 @@ bool parseProclamationsJson(const std::string& body,
rec.title = stripHtml(entry.stringField("name"));
rec.content = stripHtml(entry.stringField("content"));
rec.category_id = static_cast<int>(entry.numberField("categoryId"));
rec.group_id = static_cast<int>(entry.numberField("groupId"));
rec.solfeggio_frequency_id = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
out.push_back(std::move(rec));
}
@@ -88,7 +90,28 @@ bool parseCategoriesJson(const std::string& body,
if (rec.id == 0) continue;
rec.name = entry.stringField("name");
rec.parent_id = static_cast<int>(entry.numberField("parentId"));
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
out.push_back(std::move(rec));
}
return true;
}
bool parseGroupsJson(const std::string& body,
std::vector<radiantwave::db::ProclamationGroupRecord>& out) {
json::JsonValue root;
if (!json::parseDocument(body, root) || root.type != json::JsonValue::Type::Array) {
return false;
}
out.clear();
for (const auto& entry : root.array) {
if (entry.type != json::JsonValue::Type::Object) continue;
radiantwave::db::ProclamationGroupRecord rec;
rec.id = static_cast<int>(entry.numberField("id"));
if (rec.id == 0) continue;
rec.name = entry.stringField("name");
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
out.push_back(std::move(rec));
}
@@ -114,22 +137,66 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
return false;
}
api::HttpResponse groupsResponse =
api::httpGet(api::PATH_GROUPS);
if (!groupsResponse.transportOk ||
groupsResponse.status < 200 || groupsResponse.status >= 300) {
return false;
}
std::vector<radiantwave::db::ProclamationRecord> proclamations;
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
if (!parseProclamationsJson(proclamationsResponse.body, proclamations) ||
!parseCategoriesJson(categoriesResponse.body, categories)) {
!parseCategoriesJson(categoriesResponse.body, categories) ||
!parseGroupsJson(groupsResponse.body, groups)) {
return false;
}
if (!db.replaceCategories(categories)) {
return false;
}
if (!db.replaceGroups(groups)) {
return false;
}
if (!db.syncProclamations(proclamations, defaultProclamationId)) {
return false;
}
// Best-effort: a schedule fetch/store failure must never fail the
// proclamation sync or roll back the data just written.
syncScheduleFromApi(db);
return true;
}
bool syncScheduleFromApi(radiantwave::db::DatabaseManager& db) {
const std::string email = db.getKV(radiantwave::db::KV_EMAIL_ADDRESS);
const std::string license_key = db.getKV(radiantwave::db::KV_LICENSE_KEY);
if (email.empty() || license_key.empty()) {
return false;
}
api::ScheduleResponse schedule;
if (!api::fetchSchedule(email, license_key, schedule)) {
return false;
}
std::vector<radiantwave::db::ScheduleBlockRecord> records;
records.reserve(schedule.blocks.size());
for (const auto& block : schedule.blocks) {
radiantwave::db::ScheduleBlockRecord rec;
rec.id = block.id;
rec.start_minutes = block.start_minutes;
rec.end_minutes = block.end_minutes;
rec.overnight = block.overnight;
rec.duration_minutes = block.duration_minutes;
rec.dynamic_audio = block.dynamic_audio;
rec.proclamation_ids = block.proclamation_ids;
records.push_back(std::move(rec));
}
return db.replaceScheduleBlocks(records);
}
// ─── Color palettes (readiness code; not wired into the app flow) ─────
bool parseProclamationPalettesJson(const std::string& body,
+16 -3
View File
@@ -15,13 +15,26 @@ bool parseProclamationsJson(const std::string& body,
bool parseCategoriesJson(const std::string& body,
std::vector<radiantwave::db::ProclamationCategoryRecord>& out);
// Fetches proclamations + categories from the API and stores them in the DB,
// preserving any existing selection and forcing `defaultProclamationId` to
// stay selected. Returns true only if the API sync succeeded; on failure the
// Parse the JSON payload returned by the groups endpoint.
bool parseGroupsJson(const std::string& body,
std::vector<radiantwave::db::ProclamationGroupRecord>& out);
// Fetches proclamations + categories + groups from the API and stores them in
// the DB, preserving any existing selection and forcing `defaultProclamationId`
// to stay selected. Returns true only if the API sync succeeded; on failure the
// database is left untouched so the app can fall back to cached data.
//
// Also requests the user's schedule and replaces the stored schedule when the
// device has an email + license key. Schedule sync is best-effort: a schedule
// failure never fails (or rolls back) the proclamation sync.
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
const std::string& defaultProclamationId);
// Fetch GET /schedule for the device's paired email + license key and replace
// the stored schedule blocks. Skips (returns false) when either credential is
// missing. Returns true only on HTTP 2xx + successful parse + successful store.
bool syncScheduleFromApi(radiantwave::db::DatabaseManager& db);
// Parse color palettes embedded in the proclamations JSON payload. Each
// proclamation object may carry a "colorPalette" array of "#RRGGBB" strings;
// the palette is keyed by the proclamation's solfeggioFrequencyId. When
+124 -60
View File
@@ -148,6 +148,7 @@ void ProclamationOptionsPage::loadFromDb()
{
proclamations_ = state_->db.getAllProclamations();
categories_ = state_->db.getAllCategories();
groups_ = state_->db.getAllGroups();
state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID);
for (auto& p : proclamations_) {
@@ -173,6 +174,16 @@ void ProclamationOptionsPage::rebuildItems()
{
items_.clear();
// Schedule master toggle: enables/disables following the portal schedule.
// It is a plain on/off row (selected stores the on state) and sits above
// the always-on proclamation so it is always the first list entry.
items_.push_back({
FlatItem::Type::ScheduleToggle,
"schedule",
"Schedule Active",
0, false, state_->config.get().proclamation.schedule_active, false,
});
// Always-on top-level proclamation, never inside a category.
items_.push_back({
FlatItem::Type::Proclamation,
@@ -181,42 +192,55 @@ void ProclamationOptionsPage::rebuildItems()
0, false, true, true,
});
for (const auto& root : categories_) {
if (root.parent_id != 0) continue;
// Groups that actually exist, so a proclamation pointing at a group the
// portal has since removed falls back to the ungrouped bucket.
auto groupExists = [this](int groupId) {
return std::any_of(groups_.begin(), groups_.end(),
[groupId](const auto& g) { return g.id == groupId; });
};
for (const auto& category : categories_) {
items_.push_back({
FlatItem::Type::RootCategory,
std::to_string(root.id),
root.name,
FlatItem::Type::Category,
std::to_string(category.id),
category.name,
0, false, false, false,
});
for (const auto& sub : categories_) {
if (sub.parent_id != root.id) continue;
items_.push_back({
FlatItem::Type::SubCategory,
std::to_string(sub.id),
sub.name,
1, false, false, false,
});
// Groups are tag-like and global. A group is shown under a category
// only when it has at least one proclamation in that category, so
// impossible pairings (e.g. "Physical / Prayers") never render.
for (const auto& group : groups_) {
std::vector<const radiantwave::db::ProclamationRecord*> members;
for (const auto& p : proclamations_) {
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
if (p.category_id != sub.id) continue;
if (p.category_id != category.id || p.group_id != group.id) continue;
members.push_back(&p);
}
if (members.empty()) continue;
items_.push_back({
FlatItem::Type::Group,
std::to_string(group.id),
group.name,
1, true, false, false,
});
for (const auto* p : members) {
items_.push_back({
FlatItem::Type::Proclamation,
p.id,
p.title,
2, false, p.selected, false,
p->id,
p->title,
2, false, p->selected, false,
});
}
}
// Proclamations assigned directly to the root category.
// Proclamations with no group (or a stale group id) sit directly under
// the category.
for (const auto& p : proclamations_) {
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
if (p.category_id != root.id) continue;
if (p.category_id != category.id) continue;
if (p.group_id != 0 && groupExists(p.group_id)) continue;
items_.push_back({
FlatItem::Type::Proclamation,
p.id,
@@ -235,29 +259,34 @@ void ProclamationOptionsPage::rebuildVisible()
return;
}
// Tree walk: track the currently-open root and sub category (items_ is
// already ordered root -> sub -> proclamation, so each category dominates
// the items until the next category of equal or shallower depth).
int currentRoot = -1; // items_ index of the current root category
int currentSub = -1; // items_ index of the current sub-category
// Tree walk: track the currently-open category and group (items_ is
// already ordered category -> group -> proclamation, so each category
// dominates the items until the next category).
int currentCategory = -1; // items_ index of the current category
int currentGroup = -1; // items_ index of the current group
for (size_t i = 0; i < items_.size(); ++i) {
const FlatItem& item = items_[i];
if (item.type == FlatItem::Type::RootCategory) {
currentRoot = static_cast<int>(i);
currentSub = -1;
if (item.type == FlatItem::Type::ScheduleToggle) {
visible_.push_back(i);
continue;
}
if (item.type == FlatItem::Type::SubCategory) {
bool rootOpen = currentRoot >= 0 && items_[currentRoot].expanded;
if (rootOpen) {
currentSub = static_cast<int>(i);
if (item.type == FlatItem::Type::Category) {
currentCategory = static_cast<int>(i);
currentGroup = -1;
visible_.push_back(i);
continue;
}
if (item.type == FlatItem::Type::Group) {
bool categoryOpen = currentCategory >= 0 && items_[currentCategory].expanded;
if (categoryOpen) {
currentGroup = static_cast<int>(i);
visible_.push_back(i);
} else {
currentSub = -1;
currentGroup = -1;
}
continue;
}
@@ -266,9 +295,10 @@ void ProclamationOptionsPage::rebuildVisible()
bool parentVisible = false;
if (item.depth == 0) {
parentVisible = true; // the always-on top-level proclamation
} else if (currentRoot >= 0 && items_[currentRoot].expanded) {
} else if (currentCategory >= 0 && items_[currentCategory].expanded) {
parentVisible = (item.depth == 1) ||
(item.depth == 2 && currentSub >= 0 && items_[currentSub].expanded);
(item.depth == 2 && currentGroup >= 0 &&
items_[currentGroup].expanded);
}
if (parentVisible) visible_.push_back(i);
}
@@ -283,12 +313,24 @@ void ProclamationOptionsPage::toggleItem()
if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
item.expanded = !item.expanded;
rebuildVisible();
return;
}
if (item.type == FlatItem::Type::ScheduleToggle) {
item.selected = !item.selected;
const bool enabled = item.selected;
state_->config.update([enabled](radiantwave::config::AppConfig& cfg) {
cfg.proclamation.schedule_active = enabled;
});
statusMessage_ = enabled
? "Schedule active - playback follows the portal schedule"
: "Schedule inactive - playback uses your selections";
return;
}
if (item.isDefault) {
statusMessage_ = "The top-level proclamation is always on";
return;
@@ -388,7 +430,7 @@ void ProclamationOptionsPage::collapseCurrent()
{
if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
if (item.expanded) {
item.expanded = false;
rebuildVisible();
@@ -400,7 +442,7 @@ void ProclamationOptionsPage::expandCurrent()
{
if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
if (!item.expanded) {
item.expanded = true;
rebuildVisible();
@@ -417,8 +459,8 @@ void ProclamationOptionsPage::moveToParent()
int start = static_cast<int>(visible_[highlightIndex_]);
for (int i = start - 1; i >= 0; --i) {
const FlatItem& candidate = items_[i];
if ((candidate.type == FlatItem::Type::RootCategory ||
candidate.type == FlatItem::Type::SubCategory) &&
if ((candidate.type == FlatItem::Type::Category ||
candidate.type == FlatItem::Type::Group) &&
candidate.depth == item.depth - 1) {
for (size_t vi = 0; vi < visible_.size(); ++vi) {
if (visible_[vi] == static_cast<size_t>(i)) {
@@ -526,8 +568,8 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
if (searchActive_) return;
if (!visible_.empty()) {
const FlatItem& item = items_[visible_[highlightIndex_]];
if ((item.type == FlatItem::Type::RootCategory ||
item.type == FlatItem::Type::SubCategory) && item.expanded) {
if ((item.type == FlatItem::Type::Category ||
item.type == FlatItem::Type::Group) && item.expanded) {
collapseCurrent();
} else {
moveToParent();
@@ -568,6 +610,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
SDL_Color white = {255, 255, 255, 255};
SDL_Color gray = {170, 170, 170, 255};
SDL_Color categoryBlue = {100, 180, 255, 255};
SDL_Color groupBlue = {160, 210, 255, 255};
SDL_Color selectedOrange = {255, 170, 60, 255};
float titleY = 50.0f;
@@ -655,14 +698,18 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
SDL_RenderTexture(renderer, emptyHintText_.texture, nullptr, &dst);
}
} else {
// Compute the widest row so the block can be centered.
// Stable width: compute from every result so the list doesn't shift.
float maxTotalWidth = 0.0f;
for (const auto& r : searchResults_) {
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, proclamations_[r].title.c_str(), 0, &w, &h);
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
}
std::vector<float> textWidths(static_cast<size_t>(listSize));
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) {
const std::string& label = proclamations_[searchResults_[i]].title;
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
TTF_GetStringSize(fontItem_, proclamations_[searchResults_[i]].title.c_str(), 0, &w, &h);
textWidths[i] = static_cast<float>(w);
}
float sharedX = (fw - maxTotalWidth) / 2.0f;
@@ -695,19 +742,37 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
}
} else {
// --- Browse tree ---
// Compute the widest row so the block can be centered.
float maxTotalWidth = 0.0f;
std::vector<float> textWidths(visible_.size());
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
const FlatItem& item = items_[visible_[i]];
std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label;
// The schedule toggle renders its on/off state inline; category
// and group rows get an expand/collapse marker.
auto labelFor = [](const FlatItem& item) -> std::string {
if (item.type == FlatItem::Type::ScheduleToggle) {
return "Schedule Active: " +
std::string(item.selected ? "[ON]" : "[OFF]");
}
if (item.type == FlatItem::Type::Category ||
item.type == FlatItem::Type::Group) {
return (item.expanded ? "[-] " : "[+] ") + item.label;
}
return item.label;
};
// Stable width: compute from every item so the list doesn't
// shift left/right when groups expand or collapse.
float maxTotalWidth = 0.0f;
for (const auto& item : items_) {
std::string label = labelFor(item);
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
float total = item.depth * indent + static_cast<float>(w);
maxTotalWidth = std::max(maxTotalWidth, total);
}
std::vector<float> textWidths(visible_.size());
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
const FlatItem& item = items_[visible_[i]];
std::string label = labelFor(item);
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
textWidths[i] = static_cast<float>(w);
}
@@ -720,14 +785,13 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
const FlatItem& item = items_[visible_[i]];
float y = listTop + rowIndex * rowPitch;
std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
std::string label = labelFor(item);
SDL_Color color = white;
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
if (item.type == FlatItem::Type::Category) {
color = categoryBlue;
} else if (item.type == FlatItem::Type::Group) {
color = groupBlue;
} else if (item.selected || item.isDefault) {
color = selectedOrange;
}
+6 -5
View File
@@ -28,14 +28,14 @@ public:
private:
struct FlatItem {
enum class Type { RootCategory, SubCategory, Proclamation };
enum class Type { ScheduleToggle, Category, Group, Proclamation };
Type type = Type::Proclamation;
std::string id; // category id or proclamation id
std::string id; // category id, group id, proclamation id, or "schedule"
std::string label; // display text
int depth = 0; // 0 = root category / default, 1 = sub-category, 2 = proclamation
bool expanded = false; // categories only
bool selected = false; // proclamations only
int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation
bool expanded = false; // categories and groups only
bool selected = false; // proclamations and the schedule toggle
bool isDefault = false; // the always-on top-level proclamation
};
@@ -67,6 +67,7 @@ private:
AppState* state_ = nullptr;
std::vector<radiantwave::db::ProclamationRecord> proclamations_;
std::vector<radiantwave::db::ProclamationCategoryRecord> categories_;
std::vector<radiantwave::db::ProclamationGroupRecord> groups_;
std::vector<FlatItem> items_;
std::vector<size_t> visible_; // indices into items_, in display order
std::vector<size_t> searchResults_; // indices into proclamations_, search mode
+287 -276
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -1,5 +1,6 @@
#pragma once
#include "db/database_manager.h"
#include "fibonacci_line_pattern.h"
#include "page.h"
#include "scroller_streams.h"
@@ -82,6 +83,16 @@ private:
bool addLineAt(float baseY);
void advanceStream();
void switchAudioForCurrentStream();
// Re-reads the schedule from the DB/config and records which block (if any)
// matches the clock right now. Returns the matching index, or -1.
int currentScheduleBlockIndex() const;
// If the active block changed, latch a pending switch that is applied at the
// next proclamation boundary so the current proclamation is never cut off.
void pollScheduleChange();
// True when the current stream should use dynamic (frequency-following)
// audio. A schedule block with dynamicAudio=true forces it on; otherwise the
// configured audio.dynamic_audio setting applies.
bool effectiveDynamicAudio() const;
void updateAutoVelocity(float dt);
void ensureWordTexture(const std::string& word);
void ensureCharTexture(const std::string& glyph);
@@ -97,6 +108,17 @@ private:
int streamIndex_ = 0;
bool prefilling_ = false;
// Schedule playback. `scheduleActive_` mirrors config; `scheduleBlocks_` is
// the cached schedule; `activeBlockIndex_` is the block currently playing
// (-1 = normal mode / no matching block). A change is latched in
// `scheduleChangePending_` and applied when the current proclamation ends.
std::string timezone_;
std::vector<radiantwave::db::ScheduleBlockRecord> scheduleBlocks_;
bool scheduleActive_ = false;
int activeBlockIndex_ = -1;
bool scheduleChangePending_ = false;
float schedulePollTimer_ = 0.0f;
TTF_Font* font_ = nullptr;
int fontPxSize_ = 32;
float lineHeight_ = 0;
@@ -115,4 +137,8 @@ private:
float velocityDelay_ = 2.5f;
int velocityState_ = 0;
float velocityTimer_ = 0.0f;
int savedLogicalW_ = 0;
int savedLogicalH_ = 0;
SDL_RendererLogicalPresentation savedMode_ = SDL_LOGICAL_PRESENTATION_DISABLED;
};
+26 -2
View File
@@ -53,12 +53,35 @@ std::vector<ProclamationStream> buildStreams(
const std::unordered_map<int, std::vector<StreamColor>>& palettesByFreqId,
const std::vector<StreamColor>& fallbackPalette,
const std::unordered_map<int, db::AudioFileRecord>& audioByFreqId,
const std::string& defaultProclamationId)
const std::string& defaultProclamationId,
const std::vector<std::string>* scheduledIds)
{
const db::ProclamationRecord* defaultRecord = nullptr;
std::vector<const db::ProclamationRecord*> selected;
selected.reserve(proclamations.size());
if (scheduledIds) {
// A schedule supplies an explicit, ordered play list. Look each id up
// in the proclamation pool, skipping unknown ids and the default (which
// is interleaved as usual).
std::unordered_map<std::string, const db::ProclamationRecord*> byId;
byId.reserve(proclamations.size());
for (const auto& rec : proclamations) {
byId[rec.id] = &rec;
}
defaultRecord = byId.count(defaultProclamationId) ? byId[defaultProclamationId] : nullptr;
selected.reserve(scheduledIds->size());
for (const auto& id : *scheduledIds) {
if (id == defaultProclamationId) {
continue;
}
auto it = byId.find(id);
if (it != byId.end()) {
selected.push_back(it->second);
}
}
} else {
selected.reserve(proclamations.size());
for (const auto& rec : proclamations) {
if (rec.id == defaultProclamationId) {
defaultRecord = &rec;
@@ -69,6 +92,7 @@ std::vector<ProclamationStream> buildStreams(
}
selected.push_back(&rec);
}
}
auto makeStream = [&](const db::ProclamationRecord& rec, int repeat) {
ProclamationStream stream;
+18 -1
View File
@@ -25,6 +25,16 @@ struct StreamColor {
inline constexpr int kSelectedRepeatCount = 20;
inline constexpr int kDefaultRepeatCount = 10;
// True when `minutes` (minutes since midnight) falls inside a schedule block.
// Non-overnight blocks are [start, end); overnight blocks wrap past midnight
// and match [start, 1440) or [0, end).
inline bool minuteInScheduleBlock(int minutes, int start_minutes, int end_minutes,
bool overnight)
{
return overnight ? (minutes >= start_minutes || minutes < end_minutes)
: (minutes >= start_minutes && minutes < end_minutes);
}
struct ProclamationStream {
std::string name;
std::vector<std::string> words;
@@ -45,11 +55,18 @@ struct ProclamationStream {
// the default plays. `palettesByFreqId` and `audioByFreqId` are
// database-derived lookups; `fallbackPalette` is used for the default stream
// and unknown frequencies.
//
// When `scheduledIds` is non-null it replaces the `selected` flag as the source
// of the play order: the listed proclamation ids are played in the given order
// (unknown ids and the default id are skipped) and interleaved with the default
// exactly like a normal selection. An empty list falls back to the default
// only. Passing nullptr keeps the normal database-selection behavior.
std::vector<ProclamationStream> buildStreams(
const std::vector<db::ProclamationRecord>& proclamations,
const std::unordered_map<int, std::vector<StreamColor>>& palettesByFreqId,
const std::vector<StreamColor>& fallbackPalette,
const std::unordered_map<int, db::AudioFileRecord>& audioByFreqId,
const std::string& defaultProclamationId);
const std::string& defaultProclamationId,
const std::vector<std::string>* scheduledIds = nullptr);
} // namespace radiantwave::scroller
+18 -18
View File
@@ -19,18 +19,18 @@ namespace
{
constexpr int MENU_ITEM_COUNT = 9;
// 0 = WiFi Setup, 1 = Audio Options, 2 = Time Zone Setup, 3 = Proclamations,
// 4 = Email Address, 5 = License Key, 6 = Date Format,
// 7 = View System Logs, 8 = Back
// 0 = Proclamations, 1 = Audio Options, 2 = Email Address, 3 = License Key,
// 4 = WiFi Setup, 5 = Time Zone Setup, 6 = Date Format,
// 7 = System Logs, 8 = Back
constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = {
"WiFi Setup",
"Audio Options",
"Time Zone Setup",
"Proclamations",
"Audio Options",
"Email Address",
"License Key",
"WiFi Setup",
"Time Zone Setup",
"Date Format",
"View System Logs",
"System Logs",
"Back to Home",
};
@@ -78,15 +78,6 @@ void Settings::handleEvent(const SDL_Event &event)
switch (selectedItem_)
{
case 0:
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
break;
case 1:
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
break;
case 2:
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
break;
case 3:
// The Proclamation list is license-gated like Play.
if (state_->licenseTracker.isValid())
{
@@ -97,12 +88,21 @@ void Settings::handleEvent(const SDL_Event &event)
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
}
break;
case 4:
case 1:
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
break;
case 2:
state_->pages.push(*state_, std::make_unique<EmailAddressPage>());
break;
case 5:
case 3:
state_->pages.push(*state_, std::make_unique<LicenseKeyPage>());
break;
case 4:
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
break;
case 5:
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
break;
case 6:
state_->pages.push(*state_, std::make_unique<DateFormatPage>());
break;
+31
View File
@@ -133,6 +133,37 @@ std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, c
return std::string(buf);
}
int currentLocalMinutes(const std::string& iana_code) {
std::lock_guard<std::mutex> lock(g_tz_mutex);
const char* old_tz = std::getenv("TZ");
std::string old_tz_str = old_tz ? old_tz : "";
if (!iana_code.empty()) {
setenv("TZ", iana_code.c_str(), 1);
} else {
setenv("TZ", "UTC", 1);
}
tzset();
time_t now = std::time(nullptr);
struct tm tm_buf;
#if defined(_WIN32)
localtime_s(&tm_buf, &now);
#else
localtime_r(&now, &tm_buf);
#endif
if (!old_tz_str.empty()) {
setenv("TZ", old_tz_str.c_str(), 1);
} else {
unsetenv("TZ");
}
tzset();
return tm_buf.tm_hour * 60 + tm_buf.tm_min;
}
std::string getUtcTimestampIso() {
time_t now = std::time(nullptr);
struct tm gmt_buf;
+5
View File
@@ -22,6 +22,11 @@ const std::vector<CountryTimeZones>& getStandardTimeZones();
// Converts a UTC timestamp to the user's configured IANA timezone
std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, const std::string& format = "%Y-%m-%d %H:%M:%S %Z");
// Wall-clock minutes since midnight (0-1439) right now in `iana_code`. Used by
// the schedule to pick the active listening block. Falls back to UTC when the
// timezone is empty.
int currentLocalMinutes(const std::string& iana_code);
// Gets current UTC timestamp as ISO string
std::string getUtcTimestampIso();
+79 -10
View File
@@ -113,6 +113,58 @@ TEST(ApiClientTest, ParseCreateAccountJsonRejectsMalformed) {
EXPECT_FALSE(parseCreateAccountJson("[]", account));
}
TEST(ApiClientTest, ParseScheduleJson) {
const std::string body =
R"({"email":"jane@example.com","blocks":[
{"id":"b1","startTime":"06:00","startMinutes":360,"endTime":"08:00",
"endMinutes":480,"overnight":false,"durationMinutes":120,
"dynamicAudio":false,"proclamationIds":["p1","p2"]},
{"id":"b2","startTime":"21:00","startMinutes":1260,"endTime":"09:00",
"endMinutes":540,"overnight":true,"durationMinutes":720,
"dynamicAudio":true,"proclamationIds":[]}]})";
ScheduleResponse out;
EXPECT_TRUE(parseScheduleJson(body, out));
EXPECT_EQ(out.email, "jane@example.com");
ASSERT_EQ(out.blocks.size(), 2u);
EXPECT_EQ(out.blocks[0].id, "b1");
EXPECT_EQ(out.blocks[0].start_minutes, 360);
EXPECT_EQ(out.blocks[0].end_minutes, 480);
EXPECT_FALSE(out.blocks[0].overnight);
EXPECT_EQ(out.blocks[0].duration_minutes, 120);
EXPECT_FALSE(out.blocks[0].dynamic_audio);
ASSERT_EQ(out.blocks[0].proclamation_ids.size(), 2u);
EXPECT_EQ(out.blocks[0].proclamation_ids[0], "p1");
EXPECT_EQ(out.blocks[0].proclamation_ids[1], "p2");
EXPECT_EQ(out.blocks[1].id, "b2");
EXPECT_TRUE(out.blocks[1].overnight);
EXPECT_TRUE(out.blocks[1].dynamic_audio);
EXPECT_TRUE(out.blocks[1].proclamation_ids.empty());
}
TEST(ApiClientTest, ParseScheduleDefaultsDynamicAudioTrue) {
const std::string body =
R"({"email":"a@b.com","blocks":[
{"id":"b1","startMinutes":0,"endMinutes":60,"overnight":false,
"durationMinutes":60,"proclamationIds":["p1"]}]})";
ScheduleResponse out;
EXPECT_TRUE(parseScheduleJson(body, out));
ASSERT_EQ(out.blocks.size(), 1u);
EXPECT_TRUE(out.blocks[0].dynamic_audio);
}
TEST(ApiClientTest, ParseScheduleRejectsMalformed) {
ScheduleResponse out;
EXPECT_FALSE(parseScheduleJson("not json", out));
EXPECT_FALSE(parseScheduleJson("[1,2,3]", out)); // array, not object
// Missing id blocks are skipped, not fatal.
EXPECT_TRUE(parseScheduleJson(R"({"blocks":[{"startMinutes":0}]})", out));
EXPECT_TRUE(out.blocks.empty());
}
// ─── Offline: error message extraction ────────────────────────────────
TEST(ApiClientTest, ExtractErrorMessage) {
@@ -142,16 +194,25 @@ TEST(ApiClientTest, ParseProclamationsToleratesLegacyLevelField) {
EXPECT_EQ(out[1].category_id, 32);
}
TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) {
const std::string body =
R"([{"id":4,"name":"Spiritual","sortOrder":0,"parentId":null},
{"id":5,"name":"General","sortOrder":0,"parentId":4}])";
TEST(ApiClientTest, ParseFlatCategoriesAndGroups) {
const std::string categoriesBody =
R"([{"id":4,"name":"Spiritual","sortOrder":0},
{"id":5,"name":"General","sortOrder":1}])";
std::vector<radiantwave::db::ProclamationCategoryRecord> out;
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(body, out));
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0].parent_id, 0); // null parentId maps to 0 (root)
EXPECT_EQ(out[1].parent_id, 4);
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(categoriesBody, categories));
ASSERT_EQ(categories.size(), 2u);
EXPECT_EQ(categories[0].id, 4);
EXPECT_EQ(categories[1].id, 5);
const std::string groupsBody =
R"([{"id":9,"name":"Prayers","sortOrder":0}])";
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsBody, groups));
ASSERT_EQ(groups.size(), 1u);
EXPECT_EQ(groups[0].id, 9);
EXPECT_EQ(groups[0].name, "Prayers");
}
// ─── Live: GET /frequencies (harmless, always runs) ───────────────────
@@ -194,7 +255,15 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(catsResponse.body, categories));
EXPECT_GE(categories.size(), 20u);
EXPECT_FALSE(categories.empty());
HttpResponse groupsResponse = httpGet(PATH_GROUPS);
ASSERT_TRUE(groupsResponse.transportOk);
ASSERT_EQ(groupsResponse.status, 200);
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsResponse.body, groups));
EXPECT_FALSE(groups.empty());
}
// ─── Live: POST /license error paths (no credentials required) ─────────
+36
View File
@@ -8,9 +8,34 @@ TEST(AudioTest, FriendlyDeviceNameMapping) {
EXPECT_EQ(mapToFriendlyDeviceName("AMD Radeon HD Audio Controller HDMI"), "TV / HDMI Monitor");
EXPECT_EQ(mapToFriendlyDeviceName("USB Audio Headset Dongle"), "USB Audio Device");
EXPECT_EQ(mapToFriendlyDeviceName("bluez_sink.00_11_22_33_44_55"), "Bluetooth Audio");
EXPECT_EQ(mapToFriendlyDeviceName("Virtual Speaker"), "Built-In Speakers");
EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output");
}
TEST(AudioTest, ResolvePlaybackDeviceNameHandlesSentinels) {
// The sentinel and 0 mean "system default" and never correspond to a real
// enumerated playback device, so they resolve to the empty (default) name.
EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(0), "");
EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK), "");
}
TEST(AudioTest, GetPlaybackDevicesFallsBackWithIdZeroWhenNone) {
AudioManager am;
auto devices = am.getPlaybackDevices();
// Headless hosts (CI) expose no playback devices, so expect the synthetic
// fallback entry that carries id 0 (the "system default" marker).
if (devices.empty()) {
// No devices and no fallback would be a contract violation.
FAIL() << "getPlaybackDevices() returned an empty list without fallback";
}
for (const auto& d : devices) {
if (d.raw_name.empty()) {
EXPECT_EQ(d.id, 0u);
EXPECT_EQ(d.friendly_name, "System Default");
}
}
}
TEST(AudioTest, AudioManagerVolumeClamping) {
radiantwave::config::ConfigManager config;
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();
@@ -36,3 +61,14 @@ TEST(AudioTest, AudioManagerVolumeClamping) {
am.shutdown();
std::filesystem::remove(temp_path);
}
TEST(AudioTest, PlayOptionsLoopInfinite) {
// Regression: MIX_PlayTrack() must not default to a single pass. Tracks
// have to loop forever so playback never runs out mid-scroll; a track that
// stops (rather than attenuates) also can't be revived by raising app
// volume. Assert the options request infinite looping (-1).
SDL_PropertiesID props = AudioManager::createInfiniteLoopOptions();
ASSERT_NE(props, 0u);
EXPECT_EQ(SDL_GetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, 0), -1);
SDL_DestroyProperties(props);
}
+3
View File
@@ -44,6 +44,7 @@ TEST(YamlParserTest, ConfigRoundTrip) {
original.audio.volume = 60;
original.audio.dynamic_audio = false;
original.proclamation.scroll_speed = 1.8f;
original.proclamation.schedule_active = true;
std::string serialized = serializeConfigToYaml(original);
AppConfig parsed = parseConfigFromYaml(serialized);
@@ -53,6 +54,7 @@ TEST(YamlParserTest, ConfigRoundTrip) {
EXPECT_EQ(parsed.audio.volume, original.audio.volume);
EXPECT_EQ(parsed.audio.dynamic_audio, original.audio.dynamic_audio);
EXPECT_FLOAT_EQ(parsed.proclamation.scroll_speed, original.proclamation.scroll_speed);
EXPECT_TRUE(parsed.proclamation.schedule_active);
}
TEST(ConfigManagerTest, DefaultGeneration) {
@@ -68,6 +70,7 @@ TEST(ConfigManagerTest, DefaultGeneration) {
EXPECT_EQ(cfg.audio.volume, 80);
EXPECT_EQ(cfg.audio.dynamic_audio, true);
EXPECT_EQ(cfg.proclamation.font_size, 32);
EXPECT_FALSE(cfg.proclamation.schedule_active);
EXPECT_TRUE(std::filesystem::exists(temp_path));
std::filesystem::remove(temp_path);
+148 -4
View File
@@ -185,23 +185,119 @@ TEST(DatabaseManagerTest, ReplaceAndReadCategories) {
DbFixture fx("test_rw_db_cats.db");
std::vector<ProclamationCategoryRecord> cats;
cats.push_back({3, "Root", 0, 1});
cats.push_back({7, "Child", 3, 2});
cats.push_back({3, "Spiritual", 1});
cats.push_back({7, "Physical", 2});
ASSERT_TRUE(fx.db.replaceCategories(cats));
auto all = fx.db.getAllCategories();
ASSERT_EQ(all.size(), 2u);
EXPECT_EQ(all[0].id, 3);
EXPECT_EQ(all[0].parent_id, 0);
EXPECT_EQ(all[0].name, "Spiritual");
EXPECT_EQ(all[0].sort_order, 1);
EXPECT_EQ(all[1].id, 7);
EXPECT_EQ(all[1].parent_id, 3);
EXPECT_EQ(all[1].name, "Physical");
EXPECT_EQ(all[1].sort_order, 2);
// Replacing wipes previous categories.
ASSERT_TRUE(fx.db.replaceCategories({}));
EXPECT_EQ(fx.db.getAllCategories().size(), 0u);
}
TEST(DatabaseManagerTest, ReplaceAndReadGroups) {
DbFixture fx("test_rw_db_groups.db");
std::vector<ProclamationGroupRecord> groups;
groups.push_back({10, "Prayers", 2});
groups.push_back({4, "Affirmations", 1});
ASSERT_TRUE(fx.db.replaceGroups(groups));
auto all = fx.db.getAllGroups();
ASSERT_EQ(all.size(), 2u);
// Ordered by sort_order, not by id.
EXPECT_EQ(all[0].id, 4);
EXPECT_EQ(all[0].name, "Affirmations");
EXPECT_EQ(all[1].id, 10);
EXPECT_EQ(all[1].name, "Prayers");
ASSERT_TRUE(fx.db.replaceGroups({}));
EXPECT_EQ(fx.db.getAllGroups().size(), 0u);
}
TEST(DatabaseManagerTest, MigratesLegacySubCategoriesToGroups) {
const std::string path =
(std::filesystem::temp_directory_path() / "test_rw_db_migrate_v1.db").string();
if (std::filesystem::exists(path)) {
std::filesystem::remove(path);
}
// Build a pre-v2 database by hand: a two-level category tree and a
// proclamation selected inside a sub-category.
{
sqlite3* raw = nullptr;
ASSERT_EQ(sqlite3_open(path.c_str(), &raw), SQLITE_OK);
const char* legacy =
"CREATE TABLE proclamations ("
" id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL,"
" solfeggio_freq_hz REAL DEFAULT 528.0, category TEXT DEFAULT 'general',"
" is_active INTEGER DEFAULT 1, selected INTEGER DEFAULT 0,"
" category_id INTEGER DEFAULT 0, solfeggio_frequency_id INTEGER DEFAULT 0,"
" created_at DATETIME DEFAULT CURRENT_TIMESTAMP);"
"CREATE TABLE proclamation_categories ("
" id INTEGER PRIMARY KEY, name TEXT NOT NULL,"
" parent_id INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0);"
"INSERT INTO proclamation_categories (id, name, parent_id, sort_order)"
" VALUES (3,'Spiritual',0,1),(7,'Inner',3,2);"
"INSERT INTO proclamations (id,title,content,category_id,solfeggio_frequency_id,selected)"
" VALUES ('p1','Peace','x',7,2,1),('p2','Root-level','y',3,5,0);";
char* err = nullptr;
int rc = sqlite3_exec(raw, legacy, nullptr, nullptr, &err);
if (err) sqlite3_free(err);
sqlite3_close(raw);
ASSERT_EQ(rc, SQLITE_OK);
}
DatabaseManager db;
ASSERT_TRUE(db.initialize(path));
// Each former sub-category becomes a group; only the root remains a category.
auto groups = db.getAllGroups();
ASSERT_EQ(groups.size(), 1u);
EXPECT_EQ(groups[0].id, 7);
EXPECT_EQ(groups[0].name, "Inner");
auto cats = db.getAllCategories();
ASSERT_EQ(cats.size(), 1u);
EXPECT_EQ(cats[0].id, 3);
EXPECT_EQ(cats[0].name, "Spiritual");
// Proclamations pointing at the old sub-category are remapped to the root
// category + the new group; selection is preserved.
auto procs = db.getAllProclamations();
ASSERT_EQ(procs.size(), 2u);
for (const auto& p : procs) {
EXPECT_EQ(p.category_id, 3) << "proclamation " << p.id;
if (p.id == "p1") {
EXPECT_EQ(p.group_id, 7);
EXPECT_TRUE(p.selected);
} else if (p.id == "p2") {
EXPECT_EQ(p.group_id, 0);
EXPECT_FALSE(p.selected);
}
}
// Re-running the migration is a no-op (user_version gate + idempotent SQL).
db.close();
ASSERT_TRUE(db.initialize(path));
EXPECT_EQ(db.getAllGroups().size(), 1u);
EXPECT_EQ(db.getAllCategories().size(), 1u);
EXPECT_EQ(db.getAllProclamations().size(), 2u);
db.close();
std::filesystem::remove(path);
}
TEST(DatabaseManagerTest, SyncAudioFilesUpsertsAndDeletesMissing) {
DbFixture fx("test_rw_db_audio.db");
@@ -251,6 +347,54 @@ TEST(DatabaseManagerTest, SyncAudioFilesClearsEverythingWhenEmpty) {
EXPECT_EQ(fx.db.getAudioFiles().size(), 0u);
}
TEST(DatabaseManagerTest, ReplaceAndReadScheduleBlocks) {
DbFixture fx("test_rw_db_schedule.db");
ScheduleBlockRecord morning;
morning.id = "b1";
morning.start_minutes = 360;
morning.end_minutes = 480;
morning.overnight = false;
morning.duration_minutes = 120;
morning.dynamic_audio = false;
morning.proclamation_ids = {"p2", "p1"};
ScheduleBlockRecord night;
night.id = "b2";
night.start_minutes = 1260;
night.end_minutes = 540;
night.overnight = true;
night.duration_minutes = 720;
night.dynamic_audio = true;
night.proclamation_ids = {"p3"};
ASSERT_TRUE(fx.db.replaceScheduleBlocks({morning, night}));
auto rows = fx.db.getScheduleBlocks();
ASSERT_EQ(rows.size(), 2u);
EXPECT_EQ(rows[0].id, "b1");
EXPECT_EQ(rows[0].start_minutes, 360);
EXPECT_EQ(rows[0].end_minutes, 480);
EXPECT_FALSE(rows[0].overnight);
EXPECT_EQ(rows[0].duration_minutes, 120);
EXPECT_FALSE(rows[0].dynamic_audio);
// Play order is preserved, not sorted.
ASSERT_EQ(rows[0].proclamation_ids.size(), 2u);
EXPECT_EQ(rows[0].proclamation_ids[0], "p2");
EXPECT_EQ(rows[0].proclamation_ids[1], "p1");
EXPECT_EQ(rows[1].id, "b2");
EXPECT_TRUE(rows[1].overnight);
EXPECT_TRUE(rows[1].dynamic_audio);
ASSERT_EQ(rows[1].proclamation_ids.size(), 1u);
EXPECT_EQ(rows[1].proclamation_ids[0], "p3");
// A re-sync replaces the whole schedule.
ASSERT_TRUE(fx.db.replaceScheduleBlocks({}));
EXPECT_EQ(fx.db.getScheduleBlocks().size(), 0u);
}
TEST(DatabaseManagerTest, PalettesSeededAndRead) {
DbFixture fx("test_rw_db_palette.db");
+37 -8
View File
@@ -6,8 +6,8 @@ using namespace radiantwave::sync;
TEST(ProclamationSyncTest, ParseProclamationsJson) {
const std::string body =
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"solfeggioFrequencyId":2},
{"id":"p2","name":"Joy &amp; Calm","content":"text","categoryId":0,"solfeggioFrequencyId":0},
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"groupId":7,"solfeggioFrequencyId":2},
{"id":"p2","name":"Joy &amp; Calm","content":"text","categoryId":0,"groupId":0,"solfeggioFrequencyId":0},
{"notAnObject":true}])";
std::vector<ProclamationRecord> out;
@@ -18,12 +18,14 @@ TEST(ProclamationSyncTest, ParseProclamationsJson) {
EXPECT_EQ(out[0].title, "Peace");
EXPECT_EQ(out[0].content, "I am peace.");
EXPECT_EQ(out[0].category_id, 3);
EXPECT_EQ(out[0].group_id, 7);
EXPECT_EQ(out[0].solfeggio_frequency_id, 2);
EXPECT_EQ(out[1].id, "p2");
EXPECT_EQ(out[1].title, "Joy & Calm");
EXPECT_EQ(out[1].content, "text");
EXPECT_EQ(out[1].category_id, 0);
EXPECT_EQ(out[1].group_id, 0);
EXPECT_EQ(out[1].solfeggio_frequency_id, 0);
}
@@ -38,9 +40,10 @@ TEST(ProclamationSyncTest, ParseIgnoresLegacySubscriptionLevelField) {
}
TEST(ProclamationSyncTest, ParseCategoriesJson) {
// Categories are flat now; a legacy parentId field is simply ignored.
const std::string body =
R"([{"id":3,"name":"Spiritual","parentId":0,"sortOrder":1},
{"id":7,"name":"Inner","parentId":3,"sortOrder":2}])";
R"([{"id":3,"name":"Spiritual","sortOrder":1},
{"id":7,"name":"Physical","parentId":3,"sortOrder":2}])";
std::vector<ProclamationCategoryRecord> out;
EXPECT_TRUE(parseCategoriesJson(body, out));
@@ -48,18 +51,35 @@ TEST(ProclamationSyncTest, ParseCategoriesJson) {
EXPECT_EQ(out[0].id, 3);
EXPECT_EQ(out[0].name, "Spiritual");
EXPECT_EQ(out[0].parent_id, 0);
EXPECT_EQ(out[0].sort_order, 1);
EXPECT_EQ(out[1].id, 7);
EXPECT_EQ(out[1].name, "Inner");
EXPECT_EQ(out[1].parent_id, 3);
EXPECT_EQ(out[1].name, "Physical");
EXPECT_EQ(out[1].sort_order, 2);
}
TEST(ProclamationSyncTest, ParseGroupsJson) {
const std::string body =
R"([{"id":5,"name":"Prayers","sortOrder":0},
{"id":8,"name":"Affirmations","sortOrder":3}])";
std::vector<ProclamationGroupRecord> out;
EXPECT_TRUE(parseGroupsJson(body, out));
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0].id, 5);
EXPECT_EQ(out[0].name, "Prayers");
EXPECT_EQ(out[0].sort_order, 0);
EXPECT_EQ(out[1].id, 8);
EXPECT_EQ(out[1].name, "Affirmations");
EXPECT_EQ(out[1].sort_order, 3);
}
TEST(ProclamationSyncTest, RejectMalformedJson) {
std::vector<ProclamationRecord> procs;
std::vector<ProclamationCategoryRecord> cats;
std::vector<ProclamationGroupRecord> groups;
EXPECT_FALSE(parseProclamationsJson("not json", procs));
EXPECT_FALSE(parseProclamationsJson(R"({"id":"p1"})", procs)); // object, not array
@@ -69,6 +89,10 @@ TEST(ProclamationSyncTest, RejectMalformedJson) {
// Non-numeric ids are tolerated (row skipped), not fatal.
EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats));
EXPECT_EQ(cats.size(), 0u);
EXPECT_FALSE(parseGroupsJson("garbage", groups));
EXPECT_TRUE(parseGroupsJson(R"([{"id":"x"}])", groups));
EXPECT_EQ(groups.size(), 0u);
}
TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
@@ -78,9 +102,14 @@ TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
EXPECT_EQ(procs[0].id, "ok");
std::vector<ProclamationCategoryRecord> cats;
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","parentId":0}])", cats));
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","sortOrder":1}])", cats));
ASSERT_EQ(cats.size(), 1u);
EXPECT_EQ(cats[0].id, 9);
std::vector<ProclamationGroupRecord> groups;
EXPECT_TRUE(parseGroupsJson(R"([{"id":0,"name":"Bad"},{"id":4,"name":"Good","sortOrder":1}])", groups));
ASSERT_EQ(groups.size(), 1u);
EXPECT_EQ(groups[0].id, 4);
}
TEST(ProclamationSyncTest, StripHtmlEntities) {
+128
View File
@@ -17,6 +17,7 @@ using radiantwave::scroller::StreamColor;
using radiantwave::scroller::buildStreams;
using radiantwave::scroller::kDefaultRepeatCount;
using radiantwave::scroller::kSelectedRepeatCount;
using radiantwave::scroller::minuteInScheduleBlock;
const std::string kDefaultId = "default";
@@ -94,6 +95,62 @@ TEST(ScrollerStreamsTest, InterleavesSelectedWithDefault)
EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav");
}
TEST(ScrollerStreamsTest, InterleavedListCyclesBackToFirstSelected)
{
// Three selected proclamations: the built list must be
// [S1, Default, S2, Default, S3, Default]. ScrollerPage advances through
// this list with a modulo, so once the trailing default finishes the cycle
// restarts at the FIRST selected proclamation — the "always on" default
// must never accumulate or take over the queue.
ProclamationRecord b;
b.id = "B";
b.title = "B";
b.content = "bbbb";
b.selected = true;
b.solfeggio_frequency_id = 2;
ProclamationRecord c;
c.id = "C";
c.title = "C";
c.content = "cccc";
c.selected = true;
c.solfeggio_frequency_id = 3;
ProclamationRecord d;
d.id = "D";
d.title = "D";
d.content = "dddd";
d.selected = true;
d.solfeggio_frequency_id = 4;
ProclamationRecord def;
def.id = kDefaultId;
def.title = "Default";
def.content = "dddddddddd";
def.selected = true;
def.solfeggio_frequency_id = 5;
auto streams = buildStreams({b, def, c, d}, {}, palette(), {}, kDefaultId);
ASSERT_EQ(streams.size(), 6u);
EXPECT_EQ(streams[0].name, "B");
EXPECT_EQ(streams[1].name, "Default");
EXPECT_EQ(streams[2].name, "C");
EXPECT_EQ(streams[3].name, "Default");
EXPECT_EQ(streams[4].name, "D");
EXPECT_EQ(streams[5].name, "Default");
// Every default copy is independent playback state (fresh counters), so a
// copy finishing its passes advances to the next stream rather than
// restarting "the default" forever.
for (size_t i = 1; i < streams.size(); i += 2) {
EXPECT_EQ(streams[i].name, "Default");
EXPECT_EQ(streams[i].completed, 0) << "default copy " << i;
EXPECT_EQ(streams[i].wordsPlaced, 0) << "default copy " << i;
EXPECT_EQ(streams[i].repeat, kDefaultRepeatCount) << "default copy " << i;
}
}
TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected)
{
ProclamationRecord p1;
@@ -129,6 +186,77 @@ TEST(ScrollerStreamsTest, MissingDefaultStillStreamsSelected)
EXPECT_EQ(streams[0].repeat, kSelectedRepeatCount);
}
TEST(ScrollerStreamsTest, MinuteInScheduleBlockDaytime)
{
// 06:00 (360) to 08:00 (480), half-open.
EXPECT_FALSE(minuteInScheduleBlock(359, 360, 480, false));
EXPECT_TRUE(minuteInScheduleBlock(360, 360, 480, false));
EXPECT_TRUE(minuteInScheduleBlock(479, 360, 480, false));
EXPECT_FALSE(minuteInScheduleBlock(480, 360, 480, false));
}
TEST(ScrollerStreamsTest, MinuteInScheduleBlockOvernight)
{
// 21:00 (1260) to 09:00 (540) wraps past midnight.
EXPECT_TRUE(minuteInScheduleBlock(1260, 1260, 540, true));
EXPECT_TRUE(minuteInScheduleBlock(1439, 1260, 540, true));
EXPECT_TRUE(minuteInScheduleBlock(0, 1260, 540, true));
EXPECT_TRUE(minuteInScheduleBlock(539, 1260, 540, true));
EXPECT_FALSE(minuteInScheduleBlock(540, 1260, 540, true));
EXPECT_FALSE(minuteInScheduleBlock(1259, 1260, 540, true));
}
TEST(ScrollerStreamsTest, ScheduledIdsDriveOrderRegardlessOfSelection)
{
ProclamationRecord p1;
p1.id = "one";
p1.title = "One";
p1.content = "x";
p1.selected = false;
ProclamationRecord p2;
p2.id = "two";
p2.title = "Two";
p2.content = "y";
p2.selected = false;
ProclamationRecord def;
def.id = kDefaultId;
def.title = "Default";
def.content = "z";
// Explicit order two -> one, interleaved with the default. Unknown ids are
// skipped; the `selected` flag is ignored.
std::vector<std::string> scheduled = {"two", "missing", "one"};
auto streams = buildStreams({p1, p2, def}, {}, palette(), {}, kDefaultId, &scheduled);
ASSERT_EQ(streams.size(), 4u);
EXPECT_EQ(streams[0].name, "Two");
EXPECT_EQ(streams[1].name, "Default");
EXPECT_EQ(streams[2].name, "One");
EXPECT_EQ(streams[3].name, "Default");
}
TEST(ScrollerStreamsTest, EmptyScheduleFallsBackToDefault)
{
ProclamationRecord p1;
p1.id = "one";
p1.title = "One";
p1.content = "x";
p1.selected = true;
ProclamationRecord def;
def.id = kDefaultId;
def.title = "Default";
def.content = "y";
std::vector<std::string> scheduled; // empty block
auto streams = buildStreams({p1, def}, {}, palette(), {}, kDefaultId, &scheduled);
ASSERT_EQ(streams.size(), 1u);
EXPECT_EQ(streams[0].name, "Default");
}
TEST(ScrollerStreamsTest, SplitsGluedSentencePunctuation)
{
ProclamationRecord p1;
+18
View File
@@ -26,6 +26,24 @@ TEST(TimeZoneTest, UtcTimestampFormat) {
EXPECT_NE(iso.find("UTC"), std::string::npos);
}
TEST(TimeZoneTest, CurrentLocalMinutesInRangeAndMatchesUtc) {
int minutes = currentLocalMinutes("UTC");
EXPECT_GE(minutes, 0);
EXPECT_LT(minutes, 1440);
// Cross-check against the UTC wall clock. The two calls happen within the
// same second, so a minute-boundary rollover is vanishingly unlikely.
time_t now = std::time(nullptr);
struct tm gmt_buf;
gmtime_r(&now, &gmt_buf);
EXPECT_EQ(minutes, gmt_buf.tm_hour * 60 + gmt_buf.tm_min);
// A timezone that is known to differ from UTC must still be in range.
int ny = currentLocalMinutes("America/New_York");
EXPECT_GE(ny, 0);
EXPECT_LT(ny, 1440);
}
TEST(TimeZoneTest, FormatUtcToTimezone) {
// 2026-07-24 12:00:00 UTC
// Fixed epoch timestamp: 1784808000
-62
View File
@@ -1,62 +0,0 @@
Radiant Wave
User Manual
Version 1.0
Intro
Some thank you message and introduction to Radiant Waves.
Table of Contents
Setup
Connect to WiFi
Connection to the internet is required for Radiant Wave to operate.
Wifi screen appears first
UP/DOWN to navigate through detected WiFi networks
Enter to select one
Password prompt if applicable
I still need to test entering an incorrect WiFi password on a kiosk machine
Check for crashes or correct error messaging
Email Address
The next screen you’ll be presented with is the email address prompt.
When you purchased your RadiantWave system, you should have received an email at the address you provided during your purchase containing your license key.
Enter the email address where you received your license key here.
Note that you will not be able to enter an invalid email address, but you will be able to enter the incorrect email address. You will see how to change your email address later.
License Key
Grab your license key from your email, now is the time to use it!
A few notes on the License Key prompt:
You don't have to hold SHIFT or enable CAPS LOCK, because that's already done for you
You won't have to enter the dashes "-", as those are entered for you
You CAN enter an incorrect license key
You CAN fix it later
Same steps as the email address, just type out your license key and press enter.
The Main Menu
Now you're in! This is the main menu, and it doesn't do a whole lot. It's only purpose is to display some system information for you to quickly reference when needed, such as the email address and license key you currently have saved or the current system version you have running. Other than that, the Main Menu's only purpose is to provide navigation between the system settings, and the main attraction itself.
TODO
wifi
email
license
main menu
settings
time zone
date
logs
audio options
proclamations
play
updates