#include "theme.hpp" #include "mixer_widget.hpp" #include "xtc/xtc_control.h" #include #include #include #include #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); if (a.step) return a.value; return a.value + static_cast(static_cast(tick - a.tick) / static_cast(it->tick - a.tick)) * (it->value - a.value); }; fl_color(xtc_ui::accent()); 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; } }; /* Fl_Menu_::add merges duplicate labels and interprets menu paths. Append a * unique temporary entry, then replace its text literally so IDs stay dense. */ static int literal_item(Fl_Choice *choice, const char *label) { std::string key="#"; while (choice->find_item(key.c_str())) key+="#"; int index=choice->add(key.c_str()); choice->replace(index,label); return index; } class VerticalPeak : public Fl_Progress { public: VerticalPeak(int x, int y, int w, int h, const char *label) : Fl_Progress(x, y, w, h, label) { labeltype(FL_NO_LABEL); } void draw() override { fl_color(xtc_ui::background()); fl_rectf(x(), y(), w(), h()); int height = static_cast((std::clamp(value(), -90.0f, 6.0f) + 90) / 96 * (h() - 2)); fl_color(selection_color()); fl_rectf(x() + 1, y() + h() - 1 - height, w() - 2, height); fl_color(xtc_ui::border()); fl_rect(x(), y(), w(), h()); } }; /* A flat pan dial: no embossed face, bevels or simulated lighting. */ class PanKnob : public Fl_Dial { bool centering_ = false; public: PanKnob(int x, int y, int w, int h) : Fl_Dial(x, y, w, h, "Pan") { bounds(-1, 1); step(.01); type(FL_LINE_DIAL); when(FL_WHEN_RELEASE); tooltip("Pan: -1 left, 0 center, +1 right. Double-click to center."); } void draw() override { fl_color(xtc_ui::background()); fl_rectf(x(), y(), w(), h()); int size = std::min(w(), h()) - 8, cx = x() + w() / 2, cy = y() + h() / 2; fl_color(xtc_ui::border()); fl_pie(cx - size / 2, cy - size / 2, size, size, 0, 360); fl_color(active_r() ? xtc_ui::accent() : xtc_ui::muted()); double angle = (value() * 140 - 90) * 3.141592653589793 / 180; fl_line_style(FL_SOLID, 2); fl_line(cx, cy, cx + static_cast(std::cos(angle) * (size / 2 - 4)), cy + static_cast(std::sin(angle) * (size / 2 - 4))); fl_line_style(0); if (Fl::focus() == this) { fl_color(xtc_ui::text()); fl_rect(x(), y(), w(), h()); } } int handle(int event) override { if (event == FL_PUSH && Fl::event_button() == FL_LEFT_MOUSE && Fl::event_clicks()) { centering_ = true; value(0); redraw(); do_callback(); return 1; } if (centering_ && (event == FL_DRAG || event == FL_RELEASE)) { if (event == FL_RELEASE) centering_ = false; return 1; } return Fl_Dial::handle(event); } }; /* FLTK owns these children. Identity is a model ID, never a dense track index. */ class MixerStrip : public Fl_Group { MixerWidget &owner_; Fl_Button *name_; Fl_Check_Button *mute_, *solo_; PanKnob *pan_; Fl_Value_Slider *gain_; Fl_Choice *route_; Fl_Box *pan_text_, *readout_; std::vector destinations_; static void control(Fl_Widget *widget, void *context) { auto *s = static_cast(context); // Copy values before the owner refreshes dependent widgets. const auto id = s->id; xtc_track_info track{}; size_t count = 0, clips = 0; xtc_project_get_counts(s->owner_.project(), &count, &clips); bool found = false; for (size_t i = 0; i < count; ++i) { xtc_project_get_track(s->owner_.project(), i, &track); if (track.id == id) { found = true; break; } } if (!found) return; const float gain = widget == s->gain_ ? (s->gain_->value() <= -60 ? 0.0f : std::min(4.0f, static_cast(std::pow(10, s->gain_->value() / 20)))) : track.gain; const float pan = widget == s->pan_ ? static_cast(s->pan_->value()) : track.pan; const int mute = widget == s->mute_ ? s->mute_->value() : track.mute; const int solo = widget == s->solo_ ? s->solo_->value() : track.solo; auto &owner = s->owner_; if (owner.select_track(id)) owner.mix(gain, pan, mute, solo); } public: uint64_t id; xtc_peak_meter meter[2]{}; MixerStrip(MixerWidget &owner, uint64_t track) : Fl_Group(0, 0, 148, 350), owner_(owner), id(track) { box(xtc_ui::flat); color(xtc_ui::panel()); begin(); name_ = new Fl_Button(0, 0, 120, 26); name_->callback( [](Fl_Widget *, void *c) { auto *s = static_cast(c); s->owner_.select_track(s->id); }, this); mute_ = new Fl_Check_Button(0, 0, 64, 24, "Mute"); solo_ = new Fl_Check_Button(0, 0, 64, 24, "Solo"); pan_ = new PanKnob(0, 0, 48, 48); pan_text_ = new Fl_Box(0, 0, 124, 18); route_ = new Fl_Choice(0, 0, 124, 24); route_->tooltip("Output route: Master or project bus"); route_->callback( [](Fl_Widget *, void *c) { auto *s = static_cast(c); int i = s->route_->value(); if (i < 0 || static_cast(i) >= s->destinations_.size()) return; auto bus = s->destinations_[static_cast(i)]; auto &owner = s->owner_; auto id = s->id; if (owner.select_track(id)) owner.route(bus); }, this); gain_ = new Fl_Value_Slider(0, 0, 42, 150, "dB"); gain_->align(FL_ALIGN_TOP); gain_->labelsize(10); gain_->type(FL_VERT_SLIDER); gain_->slider(FL_FLAT_BOX); gain_->slider_size(.10); gain_->bounds(20 * std::log10(4.0), -60); gain_->step(.1); gain_->when(FL_WHEN_RELEASE); gain_->tooltip( "Level in dB: bottom is silence, 0 dB is unity, top is +12.04 dB. Automation overrides this static value."); for (auto *w : {static_cast(gain_), static_cast(pan_), static_cast(mute_), static_cast(solo_)}) w->callback(control, this); readout_ = new Fl_Box(0, 0, 132, 24); readout_->labelsize(10); end(); xtc_ui::style(this); pan_->label(""); reset(); resize(0, 0, 148, 350); } void reset() { xtc_peak_meter_reset(meter); xtc_peak_meter_reset(meter + 1); } void sync(const xtc_track_info &track) { name_->copy_label((std::string(track.is_bus ? "Bus: " : "") + track.name).c_str()); name_->tooltip(track.is_bus ? "Bus — select for automation" : "Track — select for automation"); name_->color(owner_.selected_track() == id ? xtc_ui::track(id) : xtc_ui::panel()); gain_->value(track.gain > 0 ? std::max(-60.0, 20 * std::log10(static_cast(track.gain))) : -60); pan_->value(track.pan); mute_->value(track.mute); solo_->value(track.solo); char text[64]; std::snprintf(text, sizeof(text), "Pan %s %d%%", track.pan < 0 ? "L" : track.pan > 0 ? "R" : "C", static_cast(std::fabs(track.pan) * 100)); pan_text_->copy_label(text); route_->clear(); destinations_.clear(); destinations_.push_back(UINT64_MAX); literal_item(route_,"Master"); route_->value(0); size_t tracks = 0, clips = 0; xtc_project_get_counts(owner_.project(), &tracks, &clips); for (size_t i = 0; i < tracks; ++i) { xtc_track_info bus{}; xtc_project_get_track(owner_.project(), i, &bus); if (!bus.is_bus) continue; // Literal labels, including FLTK menu metacharacters. int index = literal_item(route_,(std::string("Bus: ")+bus.name).c_str()); destinations_.push_back(bus.id); if (bus.id == track.bus_id) route_->value(index); } if (track.is_bus) route_->deactivate(); else route_->activate(); redraw(); } void update(float left, float right, double elapsed) { xtc_peak_meter_update(meter, left, elapsed); xtc_peak_meter_update(meter + 1, right, elapsed); char text[96]; std::snprintf(text, sizeof(text), "L %.1f R %.1f dBFS%s", static_cast(meter[0].db), static_cast(meter[1].db), meter[0].clipped || meter[1].clipped ? " CLIP" : ""); readout_->copy_label(text); readout_->labelcolor(meter[0].clipped || meter[1].clipped ? FL_RED : xtc_ui::muted()); redraw(); } void resize(int x, int y, int w, int h) override { Fl_Widget::resize(x, y, w, h); name_->resize(x + 8, y + 8, w - 16, 26); mute_->resize(x + 6, y + 42, 50, 24); solo_->resize(x + 56, y + 42, 50, 24); pan_->resize(x + 105, y + 38, 38, 38); pan_text_->resize(x + 8, y + 78, w - 16, 18); route_->resize(x + 8, y + 104, w - 16, 24); gain_->resize(x + 10, y + 150, 44, h - 184); readout_->resize(x + 4, y + h - 30, w - 8, 24); redraw(); } void draw() override { Fl_Group::draw(); fl_color(xtc_ui::track(id)); fl_rectf(x() + 1, y() + 1, w() - 2, 3); fl_font(FL_HELVETICA, 10); for (int db : {12, 0, -12, -24, -48, -60}) { char text[12]; std::snprintf(text, sizeof(text), "%d", db); if (h() < 300 && db != 0 && db != -60) continue; int py = y() + 175 + static_cast((12.0412 - db) / 72.0412 * (h() - 212)); fl_color(xtc_ui::muted()); fl_draw(text, x() + 112, py); } int top = y() + 150, height = h() - 184; for (int side = 0; side < 2; ++side) { int px = x() + 70 + side * 15; fl_color(xtc_ui::background()); fl_rectf(px, top, 11, height); const float db = std::clamp(meter[side].db, -60.0f, 6.0f); int pixels = static_cast((db + 60) / 66 * height); fl_color(meter[side].clipped ? FL_RED : xtc_ui::accent()); fl_rectf(px, top + height - pixels, 11, pixels); fl_color(xtc_ui::border()); fl_rect(px, top, 11, height); fl_color(meter[side].clipped ? FL_RED : xtc_ui::border()); fl_rectf(px, top - 6, 11, 3); } } }; /** * @brief Creates the mixer panel and its child controls. * * Builds the track chooser, channel strips, pan knobs, 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); routes_ = new Fl_Choice(x + 600, y + 8, 180, 26, "Route"); routes_->callback( [](Fl_Widget *, void *c) { auto *m = static_cast(c); int index = m->routes_->value(); if (index >= 0 && static_cast(index) < m->route_ids_.size()) m->route(m->route_ids_[static_cast(index)]); }, this); bus_ = new Fl_Button(x + 820, y + 8, 110, 26, "Add bus"); bus_->callback([](Fl_Widget *, void *c) { static_cast(c)->add_bus(); }, this); left_ = new VerticalPeak(x + 70, y + 90, 320, 20, "Output L"); right_ = new VerticalPeak(x + 480, y + 90, 320, 20, "Output R"); for (auto *meter : {left_, right_}) { meter->minimum(-90); meter->maximum(6); } reset_ = new Fl_Button(x, y, 120, 26, "Reset peaks"); reset_->callback([](Fl_Widget *, void *c) { static_cast(c)->reset_meters(); }, this); bank_ = new Fl_Scroll(x, y + 40, w - 154, h - 172); bank_->type(Fl_Scroll::HORIZONTAL_ALWAYS); bank_->end(); master_panel_ = new Fl_Group(x + w - 144, y + 40, 144, h - 172); master_panel_->box(xtc_ui::flat); auto *master_name = new Fl_Box(x + w - 140, y + 48, 136, 26, "MASTER"); master_name->labelfont(FL_HELVETICA_BOLD); auto *master_hint = new Fl_Box(x + w - 140, y + 80, 136, 24, "Output monitor"); master_hint->labelsize(11); master_readout_ = new Fl_Box(x + w - 140, y + 340, 136, 40); master_readout_->labelsize(10); master_panel_->add(left_); master_panel_->add(right_); master_panel_->end(); reset_meters(); parameter_ = new Fl_Choice(x + 110, y + 146, 150, 26, "Automation"); parameter_->add("Gain|Pan"); parameter_->value(0); parameter_->callback(select, this); start_ = new Fl_Value_Input(x + 420, y + 146, 140, 26, "First tick"); start_->bounds(0, XTC_PROJECT_MAX_TICK - lane_ticks); start_->step(3840); start_->callback(select, this); mode_ = new Fl_Choice(x + 705, y + 146, 150, 26, "Interpolation"); mode_->add("Linear|Hold (step)"); mode_->value(0); hint_ = new Fl_Box(x, y, w, 18, "Select a strip for automation. M/S toggles; arrows adjust gain, Shift+arrows pan. Faders show " "static dB gain."); hint_->labelsize(11); lane_ = new AutomationLane(x + 10, y + h - 80, w - 20, 76, *this); end(); set_project(p); layout(); } /** * @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 { return selected_id_; } /** * @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) { bool reset = project_ != p; uint64_t selected = project_ == p ? selected_track() : UINT64_MAX; project_ = p; tracks_->clear(); routes_->clear(); routes_->add("Master"); route_ids_.clear(); route_ids_.push_back(UINT64_MAX); 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); literal_item(tracks_,(std::string(t.is_bus?"Bus: ":"Track: ")+t.name).c_str()); if(t.is_bus){literal_item(routes_,(std::string("Bus: ")+t.name).c_str());route_ids_.push_back(t.id);} if (t.id == selected) index = static_cast(i); } tracks_->value(n ? index : -1); selected_id_=UINT64_MAX; if(n){xtc_track_info track{};xtc_project_get_track(p,static_cast(index),&track);selected_id_=track.id;} sync_strips(reset); selection(); redraw(); } /** * @brief Synchronizes the child controls with the selected track. * * Reconcile strip values and routing with model IDs, highlight the selected * track, and keep its automation controls in sync. */ void MixerWidget::selection() { xtc_track_info t{}; bool valid = xtc_project_get_track(project_, static_cast(tracks_->value()), &t) == XTC_OK; if (valid) lane_->activate(); else lane_->deactivate(); for (auto *s : strips_) { xtc_track_info track{}; size_t count = 0, clips = 0; xtc_project_get_counts(project_, &count, &clips); for (size_t i = 0; i < count; ++i) { xtc_project_get_track(project_, i, &track); if (track.id == s->id) { s->sync(track); break; } } } if (valid && !t.is_bus) routes_->activate(); else routes_->deactivate(); routes_->value(0); for (size_t i = 0; i < route_ids_.size(); ++i) if (route_ids_[i] == t.bus_id) routes_->value(static_cast(i)); if (auto *selected = strip(selected_track())) { int target = bank_->xposition(); if (selected->x() < bank_->x()) target -= bank_->x() - selected->x(); else if (selected->x() + selected->w() > bank_->x() + bank_->w() - 12) target += selected->x() + selected->w() - (bank_->x() + bank_->w() - 12); bank_->scroll_to(std::max(0, target), 0); } 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) { auto now = std::chrono::steady_clock::now(); meter_elapsed_ = std::clamp(std::chrono::duration(now-peak_time_).count(), 0.0, 60.0); peak_time_ = now; (void)xtc_peak_meter_update(master_, left, meter_elapsed_); (void)xtc_peak_meter_update(master_+1, right, meter_elapsed_); display_peaks(left_, right_, master_, "Master"); char text[100];std::snprintf(text,sizeof(text),"L %.1f / R %.1f dBFS%s",static_cast(master_[0].db),static_cast(master_[1].db),master_clipped()?" CLIP":""); master_readout_->copy_label(text);master_readout_->align(FL_ALIGN_WRAP|FL_ALIGN_INSIDE); } /** * @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_parameter_key key{XTC_PARAMETER_TRACK,selected_track(),static_cast(parameter())}; if (r == XTC_OK) r = xtc_project_automate(project_,key,48000,tick,mode_->value() == 1 ? 1U : 0U,value,remove ? 1 : 0); return changed(r); } /** * @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 *widget, void *c) { auto *m=static_cast(c); if(widget==m->tracks_){xtc_track_info track{}; if(xtc_project_get_track(m->project_,static_cast(m->tracks_->value()),&track)==XTC_OK)m->selected_id_=track.id; } m->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 && dynamic_cast(Fl::focus())) return Fl_Group::handle(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)); select(tracks_,this); return 1; } } } return Fl_Group::handle(e); } void MixerWidget::display_peaks(Fl_Progress *left, Fl_Progress *right, xtc_peak_meter *meters, const char *prefix) { for (unsigned int side = 0; side < 2; ++side) { auto *bar = side ? right : left; char label[80]; std::snprintf(label, sizeof(label), "%s %c %.1f dBFS%s", prefix, side ? 'R' : 'L', static_cast(meters[side].db), meters[side].clipped ? " CLIP" : ""); bar->copy_label(label); bar->value(std::clamp(meters[side].db, -90.0f, 6.0f)); bar->selection_color(meters[side].clipped ? FL_RED : xtc_ui::accent()); } } void MixerWidget::track_peaks(float left, float right) { for(auto *s:strips_)if(s->id==selected_track())s->update(left,right,meter_elapsed_); } void MixerWidget::reset_meters() { for (unsigned int i = 0; i < 2; ++i) { xtc_peak_meter_reset(master_+i); } display_peaks(left_, right_, master_, "Master"); char text[100];std::snprintf(text,sizeof(text),"L %.1f / R %.1f dBFS%s",static_cast(master_[0].db),static_cast(master_[1].db),master_clipped()?" CLIP":""); master_readout_->copy_label(text);master_readout_->align(FL_ALIGN_WRAP|FL_ALIGN_INSIDE); for(auto *s:strips_){s->reset();s->update(0,0,0);} } xtc_result MixerWidget::add_bus() { uint64_t id; xtc_result r = before_ ? before_(context_) : XTC_OK; if (r == XTC_OK) r = xtc_project_add_bus(project_, "Bus", &id); if (r == XTC_OK) { changed(r); select_track(id); return r; } return changed(r); } xtc_result MixerWidget::route(uint64_t bus) { xtc_result r = before_ ? before_(context_) : XTC_OK; if (r == XTC_OK) r = xtc_project_route_track(project_, selected_track(), bus); return changed(r); } bool MixerWidget::select_track(uint64_t id) { size_t n = 0, clips = 0; xtc_project_get_counts(project_, &n, &clips); for (size_t i = 0; i < n; ++i) { xtc_track_info track{}; xtc_project_get_track(project_, i, &track); if (track.id == id) { selected_id_=id;tracks_->value(static_cast(i)); selection(); return true; } } return false; } Fl_Group *MixerWidget::strip(uint64_t id) const { for (auto *s : strips_) if (s->id == id) return s; return nullptr; } void MixerWidget::meters(const xtc_meter_snapshot &snapshot) { peaks(snapshot.master_left, snapshot.master_right); for (auto *s : strips_) { float left = 0, right = 0; for (size_t i = 0; i < std::min(snapshot.count, static_cast(XTC_PROJECT_MAX_TRACKS)); ++i) if (snapshot.tracks[i].id == s->id) { left = snapshot.tracks[i].left; right = snapshot.tracks[i].right; break; } s->update(left, right, meter_elapsed_); } } void MixerWidget::sync_strips(bool reset) { size_t n = 0, clips = 0; xtc_project_get_counts(project_, &n, &clips); bool rebuild = n != strips_.size(); for (size_t i = 0; i < n && !rebuild; ++i) { xtc_track_info t{}; xtc_project_get_track(project_, i, &t); rebuild = t.id != strips_[i]->id; } if (rebuild) { auto *previous = Fl_Group::current(); bank_->clear(); strips_.clear(); bank_->begin(); for (size_t i = 0; i < n; ++i) { xtc_track_info t{}; xtc_project_get_track(project_, i, &t); strips_.push_back(new MixerStrip(*this, t.id)); } bank_->end(); Fl_Group::current(previous); layout(); } if (reset) for (auto *s : strips_) s->reset(); } void MixerWidget::layout() { const int bank_height = std::max(250, h() - 172); tracks_->resize(x() + 48, y() + 4, 210, 26); routes_->resize(x() + 326, y() + 4, 160, 26); bus_->resize(x() + 500, y() + 4, 100, 26); reset_->resize(x() + 612, y() + 4, 112, 26); bank_->resize(x(), y() + 40, w() - 154, bank_height); int offset = bank_->xposition(); for (size_t i = 0; i < strips_.size(); ++i) strips_[i]->resize(bank_->x() + static_cast(i) * 156 - offset, bank_->y(), 148, bank_height - 16); master_panel_->resize(x() + w() - 144, y() + 40, 144, bank_height); // Master is pinned outside the horizontally scrolling bank. left_->resize(x() + w() - 118, y() + 118, 32, bank_height - 140); right_->resize(x() + w() - 66, y() + 118, 32, bank_height - 140); master_readout_->resize(x() + w() - 140, y() + 40 + bank_height - 52, 136, 42); left_->align(FL_ALIGN_TOP); right_->align(FL_ALIGN_TOP); left_->labelsize(10); right_->labelsize(10); parameter_->resize(x() + 84, y() + h() - 120, 100, 26); start_->resize(x() + 264, y() + h() - 120, 116, 26); mode_->resize(x() + 488, y() + h() - 120, 136, 26); hint_->resize(x() + 8, y() + h() - 92, w() - 16, 18); lane_->resize(x() + 8, y() + h() - 72, w() - 16, 68); redraw(); } void MixerWidget::resize(int x, int y, int w, int h) { Fl_Widget::resize(x, y, w, h); layout(); }