Program Listing for File settings.hpp#

Return to documentation for file (src/core/settings.hpp)

#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include <array>
#include <optional>
#include <vector>

#include "trigger/trigger_types.hpp"

namespace mosaic {

// Each subsystem owns its settings struct. All structs must be trivially
// default-constructible so AppSettings{} always produces safe defaults.

// ── Camera intrinsic + extrinsic calibration ───────────────────────────────
// Populated by CalibrationManager after a checkerboard calibration run.
// Stored per camera so it survives between sessions.
struct CalibrationData {
    bool calibrated = false;
    double rmsError = -1.0; // reprojection error in pixels; -1 = uncalibrated

    // Camera matrix (3×3, row-major): [fx 0 cx; 0 fy cy; 0 0 1]
    std::array<double, 9> cameraMatrix = {1, 0, 0, 0, 1, 0, 0, 0, 1};
    // Distortion coefficients: k1, k2, p1, p2, k3
    std::array<double, 5> distCoeffs = {};
    // 4×4 homogeneous RT relative to camera 0 (row-major).
    // Camera 0 always has identity here.
    std::array<double, 16> extrinsicRt = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
    // Populated by RoomCalibrationManager (multi-camera room/extrinsic
    // calibration) — distinct from `calibrated`, which only ever means
    // "intrinsics done". extrinsicRt stays at its default identity while
    // this is false.
    bool extrinsicCalibrated = false;

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<CalibrationData> from_json(const QJsonObject&);
};

// ── Per-camera parameters ──────────────────────────────────────────────────
struct CameraParameters {
    // Identity (filled by the camera backend when a real device is connected)
    QString serialNumber;
    QString friendlyName = "Camera";

    // Image / Acquisition
    int width     = 1920;
    int height    = 1080;
    int offsetX   = 0;
    int offsetY   = 0;
    bool reverseX = false;
    // No longer offered in CameraCardW — confirmed on real room-11
    // hardware that this camera generation's ReverseY node exists but
    // rejects writes outright ("Node is not writable", every camera, every
    // session). Field kept so an already-persisted value round-trips
    // rather than silently vanishing on next save.
    bool reverseY       = false;
    QString pixelFormat = "BGR8";
    bool specifyFps     = true;
    double fps          = 25.0; // acA1920-25gc's max sustained rate

    // Exposure. Defaults to "Once" rather than "Off": this camera generation
    // (acA1920-25gc) has no working manual-gain path (see gainAuto below),
    // and auto-calibrating once at preview start then holding steady for
    // the whole recording gives a scene-matched image without the
    // brightness/color drift "Continuous" can introduce mid-recording —
    // real risk for anything downstream that assumes consistent lighting
    // frame-to-frame (e.g. expression/gaze confidence, luminance measures).
    QString exposureAuto       = "Once"; // "Off" | "Once" | "Continuous"
    double exposureTimeUs      = 10000.0;
    double exposureAutoLowerUs = 100.0;
    double exposureAutoUpperUs = 50000.0;

    // Gain. Defaults to "Once", not "Off": this camera generation only
    // exposes the older SFNC 1.x "GainRaw" node, not the modern "Gain" (dB)
    // node — manual/"Off" gain is a silent no-op here (see the "Gain" write
    // in video_grabber.cpp's apply_image_params(), which only fires when
    // gainAuto=="Off" and simply skips if the modern node isn't present).
    // CameraCardW no longer offers "Off" or a manual gainDb field for
    // exactly this reason — gainDb is kept so an already-persisted value
    // round-trips, but has no effect on this hardware.
    QString gainAuto       = "Once"; // "Off" | "Once" | "Continuous" (UI: Once/Continuous only)
    double gainDb          = 0.0;
    double gainAutoLowerDb = 0.0;
    double gainAutoUpperDb = 24.0;

    // Processing
    double gamma             = 1.0;
    double blackLevel        = 0.0;
    QString balanceWhiteAuto = "Once"; // "Off" | "Once" | "Continuous"
    // Manual Red/Blue balance gains, only applied when balanceWhiteAuto ==
    // "Off". Neutral default (1.0 = no correction, matching the camera's
    // own factory-default reference value) until tuned by hand while
    // watching the live preview — "Once" re-converges fresh on every
    // camera open with no readback/persistence anywhere in this codebase,
    // so the resulting tint can drift session to session; "Off" + these
    // fixed ratios makes color reproducible across restarts.
    double balanceRatioRed  = 1.0;
    double balanceRatioBlue = 1.0;
    // Saturation/contrast/brightness have no GenICam node on this camera
    // generation at all (no on-camera ISP) — CameraCardW no longer offers
    // them. Fields kept for persistence/forward-compat only (a future
    // camera model might expose the matching node).
    double saturation           = 1.0; // 0.0–2.0
    double contrast             = 1.0; // 0.0–2.0
    double brightness           = 0.5; // 0.0–1.0
    double autoTargetBrightness = 0.5; // 0.0–1.0
    int digitalShift            = 0;   // 0–4 bits (12→8 or 16→8 conversions)

    // Hardware trigger input (e.g. TTL pulse on Basler Line1, or a GigE
    // Vision Action Command broadcast when set to "Action1" — see
    // gige_action_command.hpp). Defaults to Action1 enabled: room 11's
    // camera fleet is meant to be synchronized this way out of the box for
    // any newly-added camera, without a manual per-camera opt-in step.
    bool hwTriggerEnabled   = true;
    QString hwTriggerSource = "Action1"; // "Line1" | "Line2" | "Line3" | "Software" | "Action1"
    double hwTriggerDelayUs = 0.0;       // microseconds

    // Live pose/gaze analysis (Real-time tab) opt-out for this camera. The
    // live pipeline shares one MediaPipe subprocess across every camera at a
    // fixed ~2fps/camera budget (see MainWindow's throttle lambda) — turning
    // this off for a camera the user doesn't care about live frees up that
    // camera's slice of the shared budget without touching recording at all.
    bool liveAnalysisEnabled = true;

    // Test pattern — shown in monitor when no real camera is connected
    QString testPattern = "Off"; // "Off" | "ColorBars" | "Horizontal" | "Vertical"

    // Calibration (filled by CalibrationManager after a checkerboard run)
    CalibrationData calibration;

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<CameraParameters> from_json(const QJsonObject&);
};

// ── Video ──────────────────────────────────────────────────────────────────
struct VideoSettings {
    // Global encoding
    QString codec  = "h264_nvenc"; // "h264_nvenc" | "hevc_nvenc" | "libx264"
    QString preset = "p4";         // GPU: p1-p7  |  CPU: fast, medium, slow
    int crf        = 23;           // quality for CPU encoder (17=best, 28=worst)
    int bitrate    = 5000;         // kbit/s (GPU encoder)

    // Upper bound on the number of configured cameras this app will ever
    // treat as a live rig (room 11 has 6; 32 is a generous safety margin,
    // not a hardware limit). Every code path that populates `cameras`
    // before a VideoManager::open() call — VideoSettings::from_json() and
    // Application::initialize()'s programmatic room-11 seed — must reserve
    // at least this much capacity first. VideoManager::open() binds a raw
    // `const CameraParameters&` into each VideoGrabber for that grabber's
    // entire lifetime (see VideoGrabber::Impl::params in video_grabber.cpp);
    // any reallocation of this vector after that point (e.g. from
    // push_back() growing past capacity) silently invalidates every such
    // reference, causing a use-after-free the next time a camera control is
    // edited. VideoSettingsW's constructor also reserves this same cap for
    // the identical reason on behalf of CameraCardW's references.
    static constexpr int kMaxCameras = 32;

    // Per-camera configurations (one entry per added camera)
    std::vector<CameraParameters> cameras;

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<VideoSettings> from_json(const QJsonObject&);
};

// ── Per-microphone parameters ──────────────────────────────────────────────
struct MicrophoneParameters {
    QString deviceId; // OS device identifier
    QString friendlyName = "Microphone";
    int sampleRate       = 44100; // Hz
    int channels         = 2;
    int bufferSize       = 512; // frames per callback

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<MicrophoneParameters> from_json(const QJsonObject&);
};

// ── Audio ──────────────────────────────────────────────────────────────────
struct AudioSettings {
    // Global encoding applied to all recordings
    QString codec = "pcm_s16le"; // "pcm_s16le" | "flac" | "aac" | "mp3"

    // Upper bound on the number of configured microphones this app will ever
    // treat as a live monitoring/recording rig — mirrors VideoSettings::
    // kMaxCameras exactly, see its doc comment for the full use-after-free
    // rationale. AudioManager::start_monitoring()/start() bind a raw
    // `const MicrophoneParameters&` into each AudioRecorder for that
    // recorder's entire lifetime (see AudioRecorder::m_params), and
    // AudioSettingsW's constructor reserve()s this same cap against the
    // same vector on behalf of MicrophoneCardW's references. 16 matches the
    // reserve(16) call AudioSettingsW's constructor already made before
    // this fix (a literal, not a hardware limit) — no known rig needs
    // anywhere close to this many microphones.
    static constexpr int kMaxMicrophones = 16;

    // Per-device configurations
    std::vector<MicrophoneParameters> microphones;

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<AudioSettings> from_json(const QJsonObject&);
};

// ── Per-keyboard-trigger configuration ────────────────────────────────────
struct KeyTriggerConfig {
    QString name         = "Trigger";
    QString keySeq       = ""; // e.g. "F1", "Ctrl+Space" — empty = unbound
    bool enabled         = true;
    TriggerAction action = TriggerAction::Log;

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<KeyTriggerConfig> from_json(const QJsonObject&);
};

// ── Serial port trigger configuration ─────────────────────────────────────
// Receives trigger events via RS-232/USB-serial.  Uses Qt6::SerialPort.
// A trigger fires when an incoming byte satisfies the match rule.
struct SerialTriggerConfig {
    QString name     = "Serial Trigger";
    QString portName = ""; // "COM3", "/dev/ttyUSB0" — empty = disabled
    int baudRate     = 9600;
    int dataBits     = 8;
    QString parity   = "None"; // "None" | "Even" | "Odd"
    double stopBits  = 1.0;    // 1.0 | 1.5 | 2.0
    // matchMode controls what incoming bytes trigger an event:
    //   "AnyByte" — any received byte fires the trigger
    //   "NonZero" — only non-zero bytes fire
    //   "Exact"   — only bytes matching matchValue (hex: "0xFF") fire
    QString matchMode    = "AnyByte";
    QString matchValue   = "";
    bool enabled         = false;
    TriggerAction action = TriggerAction::Log;

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<SerialTriggerConfig> from_json(const QJsonObject&);
};

// ── Parallel port trigger configuration ───────────────────────────────────
// One entry per LPT port (typically just one: LPT1 at 0x378).
// Implemented on Windows via InpOut32; a stub is used elsewhere.
struct ParallelPortConfig {
    QString portAddress  = "0x378"; // data register hex address (LPT1 = 0x378)
    int pollRateMs       = 1;       // polling interval in milliseconds
    bool enabled         = false;
    bool invertLogic     = false; // true = active-low (common in TTL circuits)
    TriggerAction action = TriggerAction::Log;

    // Drives the Control register's INIT pin (bit 2, portAddr+2 — physically
    // separate from the Data register this class reads, so no conflict) high
    // when a recording starts and low when it stops, so an external device
    // listening on that pin (e.g. an EEG amplifier) logs a marker for
    // MOSAIC's own recording start/stop. See ParallelPortTrigger::
    // set_recording_marker().
    bool sendRecordingMarker = false;

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<ParallelPortConfig> from_json(const QJsonObject&);
};

// ── Trigger ────────────────────────────────────────────────────────────────
struct TriggerSettings {
    bool receiveEnabled = true;

    std::vector<KeyTriggerConfig> keyboardTriggers;
    std::vector<SerialTriggerConfig> serialTriggers;
    std::vector<ParallelPortConfig> parallelPorts;

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<TriggerSettings> from_json(const QJsonObject&);
};

// ── Record ─────────────────────────────────────────────────────────────────
struct RecordSettings {
    QString directory       = "./recordings";
    QString videoBasename   = "video";
    QString audioBasename   = "audio";
    QString triggerBasename = "trigger";
    QString separator       = "_";
    bool addTimestamp       = true;
    QString timestampFormat = "yyyy-MM-dd_hh-mm-ss";

    // Which channels to record
    bool enableVideo   = true;
    bool enableAudio   = true;
    bool enableTrigger = true;

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<RecordSettings> from_json(const QJsonObject&);
};

struct AnalysisSettings {
    // Hugging Face access token for the gated pyannote speaker-diarization
    // models (Analysis tab's Speaker Diarization plugin). Persisted here
    // (rather than kept transient/re-entered per session) so the user
    // doesn't have to re-paste it every run — the tradeoff being that it's
    // stored in plaintext in this profile's settings.json, same as every
    // other field in AppSettings.
    QString hfToken;

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<AnalysisSettings> from_json(const QJsonObject&);
};

// ── Room (multi-camera extrinsic calibration) settings ─────────────────────
// The room's shared reference plane, defined once via RoomCalibrationW's
// "Use last shot as plane" action (the ChArUco board is laid flat on the
// target surface for one shot). Consumed by analysis/run_gaze_fusion.py via
// the "room" section RecordManager writes into session_meta.json — a plane
// isn't camera-specific, so it lives here rather than inside CalibrationData.
struct RoomSettings {
    std::array<double, 3> planePoint  = {0, 0, 0};
    std::array<double, 3> planeNormal = {0, 0, 1};
    bool planeDefined                 = false;

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<RoomSettings> from_json(const QJsonObject&);
};

// ── Real-time tab settings ──────────────────────────────────────────────────
// Preferences for the live pose/gaze analytics dashboard (Real-time tab).
// Per-camera opt-out lives on CameraParameters::liveAnalysisEnabled instead
// of a field here, so it travels correctly with its camera on reorder/removal.
struct RealtimeSettings {
    bool enabled                  = true; // show live analysis in the tab at all
    bool autoPauseDuringRecording = true; // pause pose/gaze inference while recording
    int detectionWindowBuckets    = 24; // sparkline rolling-window bucket count (~2min @ 5s/bucket)

    [[nodiscard]] QJsonObject to_json() const;
    [[nodiscard]] static std::optional<RealtimeSettings> from_json(const QJsonObject&);
};

// ── Application aggregate ──────────────────────────────────────────────────
struct AppSettings {
    static constexpr int k_schema_version = 1;

    VideoSettings video;
    AudioSettings audio;
    TriggerSettings trigger;
    RecordSettings record;
    AnalysisSettings analysis;
    RoomSettings room;
    RealtimeSettings realtime;

    // Persist to / restore from a JSON file.
    // save() returns false only on I/O error (not on validation issues).
    [[nodiscard]] bool save(const QString& path) const;
    [[nodiscard]] static std::optional<AppSettings> load(const QString& path);

    // Platform-standard config location:  ~/.config/CSRU/mosaic/settings.json
    [[nodiscard]] static QString default_path();
};

} // namespace mosaic