#include "mixer_widget.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr uint64_t lane_ticks = 15360; } class AutomationLane : public Fl_Widget { MixerWidget &mixer_; public: /** * @brief Creates an automation lane bound to its owning mixer. * * @param x Left edge in parent coordinates. * @param y Top edge in parent coordinates. * @param w Lane width in pixels; tick math requires a value greater than 1. * @param h Lane height in pixels. * @param m Mixer that owns the lane; stored by reference and must outlive it. */ AutomationLane(int x, int y, int w, int h, MixerWidget &m) : Fl_Widget(x, y, w, h), mixer_(m) { } /** * @brief Draws the lane grid, automation curve, points, and empty hint. * * Points for the selected track and parameter are sorted by tick, rendered * as an interpolated curve, and marked within the visible tick range. */ void draw() override { fl_push_clip(x(), y(), w(), h()); fl_color(fl_rgb_color(32, 35, 40)); fl_rectf(x(), y(), w(), h()); fl_font(FL_HELVETICA, 11); for (int i = 0; i <= 16; ++i) { int px = x() + i * (w() - 1) / 16; fl_color(fl_rgb_color(i % 4 ? 55 : 95, 60, 65)); fl_line(px, y(), px, y() + h()); if (i % 4 == 0) { char label[32]; std::snprintf(label, sizeof(label), "%llu", static_cast(mixer_.lane_start() + static_cast(i) * 960)); fl_color(FL_WHITE); fl_draw(label, px + 3, y() + 14); } } size_t count = 0; xtc_project_automation_count(mixer_.project(), &count); std::vector points; for (size_t i = 0; i < count; ++i) { xtc_automation_point a{}; xtc_project_get_automation(mixer_.project(), i, &a); if (a.track_id == mixer_.selected_track() && a.parameter == mixer_.parameter()) points.push_back(a); } std::sort(points.begin(), points.end(), [](const auto &a, const auto &b) { return a.tick < b.tick; }); float min = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 0.0f : -1.0f; float range = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 4.0f : 2.0f; auto py = [&](float v) { return y() + h() - 6 - static_cast((v - min) / range * (h() - 28)); }; auto value = [&](uint64_t tick) { if (points.empty()) return min; auto it = std::upper_bound(points.begin(), points.end(), tick, [](uint64_t t, const auto &a) { return t < a.tick; }); if (it == points.begin()) return it->value; if (it == points.end()) return points.back().value; const auto &a = *(it - 1); return a.value + static_cast(static_cast(tick - a.tick) / static_cast(it->tick - a.tick)) * (it->value - a.value); }; fl_color(fl_rgb_color(105, 200, 150)); if (!points.empty()) for (int px = 1; px < w(); ++px) { uint64_t a = mixer_.lane_start() + static_cast(px - 1) * lane_ticks / static_cast(w() - 1); uint64_t b = mixer_.lane_start() + static_cast(px) * lane_ticks / static_cast(w() - 1); fl_line(x() + px - 1, py(value(a)), x() + px, py(value(b))); } for (const auto &a : points) if (a.tick >= mixer_.lane_start() && a.tick <= mixer_.lane_start() + lane_ticks) { int px = x() + static_cast((a.tick - mixer_.lane_start()) * static_cast(w() - 1) / lane_ticks); fl_rectf(px - 4, py(a.value) - 4, 8, 8); } if (points.empty()) { fl_color(FL_WHITE); fl_draw("Click to add automation; right-click a point to remove. Values replace the " "static control.", x() + 12, y() + h() / 2); } fl_pop_clip(); } /** * @brief Handles FLTK input events and delegates unhandled events to the base widget. * * @param event FLTK event code. * @return Nonzero if the event was handled, zero otherwise. */ int handle(int event) override { if (event != FL_PUSH) return Fl_Widget::handle(event); if (Fl::event_button() != FL_LEFT_MOUSE && Fl::event_button() != FL_RIGHT_MOUSE) return 0; uint64_t tick = mixer_.lane_start() + static_cast(std::clamp(Fl::event_x() - x(), 0, w() - 1)) * lane_ticks / static_cast(w() - 1); tick = (tick + 120) / 240 * 240; tick = std::min(tick, XTC_PROJECT_MAX_TICK); float min = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 0.0f : -1.0f; float range = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 4.0f : 2.0f; float value = min + static_cast(std::clamp(y() + h() - 6 - Fl::event_y(), 0, h() - 28)) / static_cast(h() - 28) * range; if (Fl::event_button() == FL_RIGHT_MOUSE) { size_t count = 0; xtc_project_automation_count(mixer_.project(), &count); uint64_t distance = static_cast(8) * lane_ticks / static_cast(w() - 1) + 1, closest = UINT64_MAX; for (size_t i = 0; i < count; ++i) { xtc_automation_point a{}; xtc_project_get_automation(mixer_.project(), i, &a); uint64_t d = a.tick > tick ? a.tick - tick : tick - a.tick; if (a.track_id == mixer_.selected_track() && a.parameter == mixer_.parameter() && d < distance) { distance = d; closest = a.tick; } } if (closest != UINT64_MAX) mixer_.point(closest, 0, true); } else mixer_.point(tick, value); return 1; } }; /** * @brief Creates the mixer panel and its child controls. * * Builds the track chooser, gain/pan sliders, mute/solo buttons, output * meters, automation controls, and lane, then displays the given project. * * @param x Left edge in parent coordinates. * @param y Top edge in parent coordinates. * @param w Widget width in pixels. * @param h Widget height in pixels. * @param p Project to display; borrowed and must outlive its use. * @param before Callback invoked before each edit; may be nullptr. * @param context Opaque pointer forwarded to before. */ MixerWidget::MixerWidget(int x, int y, int w, int h, xtc_project *p, BeforeEdit before, void *context) : Fl_Group(x, y, w, h), project_(p), before_(before), context_(context) { begin(); tracks_ = new Fl_Choice(x + 70, y + 8, 240, 26, "Track"); tracks_->callback(select, this); gain_ = new Fl_Value_Slider(x + 70, y + 48, 320, 28, "Gain"); gain_->type(FL_HOR_NICE_SLIDER); gain_->bounds(0, 4); gain_->step(0.01); gain_->when(FL_WHEN_RELEASE); gain_->callback(control, this); pan_ = new Fl_Value_Slider(x + 480, y + 48, 320, 28, "Pan"); pan_->type(FL_HOR_NICE_SLIDER); pan_->bounds(-1, 1); pan_->step(0.01); pan_->when(FL_WHEN_RELEASE); pan_->callback(control, this); mute_ = new Fl_Check_Button(x + 340, y + 8, 90, 26, "Mute"); solo_ = new Fl_Check_Button(x + 440, y + 8, 90, 26, "Solo"); mute_->callback(control, this); solo_->callback(control, this); left_ = new Fl_Progress(x + 70, y + 90, 320, 20, "Output L"); right_ = new Fl_Progress(x + 480, y + 90, 320, 20, "Output R"); for (auto *meter : {left_, right_}) { meter->minimum(0); meter->maximum(1); meter->selection_color(fl_rgb_color(80, 180, 120)); } parameter_ = new Fl_Choice(x + 110, y + 126, 150, 26, "Automation"); parameter_->add("Gain|Pan"); parameter_->value(0); parameter_->callback(select, this); start_ = new Fl_Value_Input(x + 420, y + 126, 140, 26, "First tick"); start_->bounds(0, XTC_PROJECT_MAX_TICK - lane_ticks); start_->step(3840); start_->callback(select, this); auto *hint = new Fl_Box(x + 10, y + 160, w - 20, 24, "Gain 0..4; pan -1 left / +1 right. M/S toggles; arrows change gain, " "Shift+arrows pan; Up/Down selects track."); hint->labelsize(12); lane_ = new AutomationLane(x + 10, y + 192, w - 20, h - 222, *this); end(); set_project(p); } /** * @brief Returns the ID of the track selected in the track chooser. * * @return Selected track ID, or UINT64_MAX when the chooser selection does * not resolve to a project track. */ uint64_t MixerWidget::selected_track() const { xtc_track_info t{}; return xtc_project_get_track(project_, static_cast(tracks_->value()), &t) == XTC_OK ? t.id : UINT64_MAX; } /** * @brief Returns the automation parameter selected in the chooser. * * @return XTC_AUTOMATION_PAN when the chooser selects "Pan", otherwise * XTC_AUTOMATION_GAIN. */ xtc_automation_parameter MixerWidget::parameter() const { return parameter_->value() == 1 ? XTC_AUTOMATION_PAN : XTC_AUTOMATION_GAIN; } /** * @brief Returns the first tick shown by the automation lane. * * @return Clamped first tick in project ticks, or 0 when the input value is * not finite. */ uint64_t MixerWidget::lane_start() const { if (!std::isfinite(start_->value())) return 0; return static_cast( std::clamp(start_->value(), 0.0, static_cast(XTC_PROJECT_MAX_TICK - lane_ticks))); } /** * @brief Displays a different project and refreshes the controls. * * The current track selection is preserved only when the same project * pointer is passed again. The project is borrowed and must outlive use. * * @param p Project to display; must remain valid while shown. */ void MixerWidget::set_project(xtc_project *p) { uint64_t selected = project_ == p ? selected_track() : UINT64_MAX; project_ = p; tracks_->clear(); size_t n = 0, c = 0; xtc_project_get_counts(p, &n, &c); int index = 0; for (size_t i = 0; i < n; ++i) { xtc_track_info t{}; xtc_project_get_track(p, i, &t); std::string label; for (char ch : std::string(t.name)) { if (ch == '/' || ch == '\\' || ch == '_' || ch == '&') label += '\\'; label += ch; } tracks_->add(label.c_str()); if (t.id == selected) index = static_cast(i); } tracks_->value(n ? index : -1); selection(); redraw(); } /** * @brief Synchronizes the child controls with the selected track. * * Controls are deactivated when no valid track is selected. Gain falls * back to 1, and the remaining values come from the track info or zero when * unresolved. */ void MixerWidget::selection() { xtc_track_info t{}; bool valid = xtc_project_get_track(project_, static_cast(tracks_->value()), &t) == XTC_OK; for (Fl_Widget *w : {static_cast(gain_), static_cast(pan_), static_cast(mute_), static_cast(solo_), static_cast(lane_)}) { if (valid) w->activate(); else w->deactivate(); } gain_->value(valid ? t.gain : 1); pan_->value(t.pan); mute_->value(t.mute); solo_->value(t.solo); lane_->redraw(); } /** * @brief Updates the left and right output meters. * * Meter values are clamped to 1.0, while the over-range color is chosen * from the unclamped level. * * @param left Left-channel peak level. * @param right Right-channel peak level. */ void MixerWidget::peaks(float left, float right) { left_->value(std::min(left, 1.0f)); right_->value(std::min(right, 1.0f)); left_->selection_color(left > 1 ? FL_RED : FL_GREEN); right_->selection_color(right > 1 ? FL_RED : FL_GREEN); } /** * @brief Reports an edit result and refreshes the controls. * * A successful result invokes the widget callback so the owner can mark * the project dirty; a failure shows a modal alert. The controls are * resynchronized in both cases. * * @param r Result of the attempted edit. * @return The same result passed in. */ xtc_result MixerWidget::changed(xtc_result r) { if (r == XTC_OK) do_callback(); else fl_alert("Mixer edit failed: %s", xtc_result_string(r)); selection(); return r; } /** * @brief Applies gain, pan, mute, and solo to the selected track. * * The constructor's before-edit callback runs first; the edit is skipped * if it returns an error. On success the widget callback is invoked and * the controls are refreshed. * * @param gain Linear gain in the range 0..4. * @param pan Pan position in the range -1 (left)..1 (right). * @param mute Mute state: 0 or 1. * @param solo Solo state: 0 or 1. * @return XTC_OK on success, or the before-edit or project mutation error. */ xtc_result MixerWidget::mix(float gain, float pan, int mute, int solo) { xtc_result r = before_ ? before_(context_) : XTC_OK; if (r == XTC_OK) r = xtc_project_set_mix(project_, selected_track(), gain, pan, mute, solo); return changed(r); } /** * @brief Adds, replaces, or removes an automation point. * * The constructor's before-edit callback runs first; the edit is skipped * if it returns an error. On success the widget callback is invoked and * the controls are refreshed. * * @param tick Automation tick to modify; must be within the project range. * @param value New value; ignored when remove is true. * @param remove When true, removes the point instead of setting it. * @return XTC_OK on success, or the before-edit or project mutation error. */ xtc_result MixerWidget::point(uint64_t tick, float value, bool remove) { xtc_result r = before_ ? before_(context_) : XTC_OK; xtc_automation_point a{selected_track(), tick, parameter(), value}; if (r == XTC_OK) r = remove ? xtc_project_remove_automation(project_, a.track_id, a.parameter, tick) : xtc_project_set_automation(project_, &a); return changed(r); } /** * @brief FLTK callback for the gain, pan, mute, and solo controls. * * Reads all four controls and applies them together to the selected track * through mix(). * * @param c Opaque MixerWidget pointer supplied as callback context. */ void MixerWidget::control(Fl_Widget *, void *c) { auto *m = static_cast(c); m->mix(static_cast(m->gain_->value()), static_cast(m->pan_->value()), m->mute_->value(), m->solo_->value()); } /** * @brief FLTK callback for the track, parameter, and first-tick controls. * * @param c Opaque MixerWidget pointer supplied as callback context. */ void MixerWidget::select(Fl_Widget *, void *c) { static_cast(c)->selection(); } /** * @brief Handles keyboard shortcuts and forwards other events. * * M and S toggle mute and solo, Left/Right adjust gain (Shift adjusts pan), * and Up/Down change the selected track. Modified key presses fall through. * * @param e FLTK event code. * @return 1 when the event was consumed, otherwise the Fl_Group result. */ int MixerWidget::handle(int e) { if (e == FL_KEYDOWN && !(Fl::event_state() & (FL_CTRL | FL_ALT | FL_META))) { int key = Fl::event_key(); xtc_track_info t{}; if (xtc_project_get_track(project_, static_cast(tracks_->value()), &t) == XTC_OK) { if (key == 'm' || key == 's') { mix(t.gain, t.pan, key == 'm' ? !t.mute : t.mute, key == 's' ? !t.solo : t.solo); return 1; } if (key == FL_Left || key == FL_Right) { float d = key == FL_Left ? -0.05f : 0.05f; bool pan = (Fl::event_state() & FL_SHIFT) != 0; mix(pan ? t.gain : std::clamp(t.gain + d, 0.0f, 4.0f), pan ? std::clamp(t.pan + d, -1.0f, 1.0f) : t.pan, t.mute, t.solo); return 1; } if (key == FL_Up || key == FL_Down) { size_t n = 0, c = 0; xtc_project_get_counts(project_, &n, &c); tracks_->value(std::clamp(tracks_->value() + (key == FL_Up ? -1 : 1), 0, static_cast(n) - 1)); selection(); return 1; } } } return Fl_Group::handle(e); }