901 lines
34 KiB
C++
901 lines
34 KiB
C++
#include "theme.hpp"
|
|
#include "mixer_widget.hpp"
|
|
#include "xtc/xtc_control.h"
|
|
#include <FL/Fl.H>
|
|
#include <FL/Fl_Box.H>
|
|
#include <FL/Fl_Check_Button.H>
|
|
#include <FL/Fl_Button.H>
|
|
#include <FL/Fl_Choice.H>
|
|
#include <FL/Fl_Progress.H>
|
|
#include <FL/Fl_Value_Input.H>
|
|
#include <FL/Fl_Input_.H>
|
|
#include <FL/Fl_Value_Slider.H>
|
|
#include <FL/Fl_Dial.H>
|
|
#include <FL/Fl_Scroll.H>
|
|
#include <FL/fl_ask.H>
|
|
#include <FL/fl_draw.H>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
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<unsigned long long>(mixer_.lane_start() + static_cast<uint64_t>(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<xtc_automation_point> 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<int>((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<float>(static_cast<double>(tick - a.tick) / static_cast<double>(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<uint64_t>(px - 1) * lane_ticks / static_cast<uint64_t>(w() - 1);
|
|
uint64_t b =
|
|
mixer_.lane_start() + static_cast<uint64_t>(px) * lane_ticks / static_cast<uint64_t>(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<int>((a.tick - mixer_.lane_start()) * static_cast<uint64_t>(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<uint64_t>(std::clamp(Fl::event_x() - x(), 0, w() - 1)) *
|
|
lane_ticks / static_cast<uint64_t>(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<float>(std::clamp(y() + h() - 6 - Fl::event_y(), 0, h() - 28)) /
|
|
static_cast<float>(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<uint64_t>(8) * lane_ticks / static_cast<uint64_t>(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<int>((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<int>(std::cos(angle) * (size / 2 - 4)),
|
|
cy + static_cast<int>(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<uint64_t> destinations_;
|
|
static void control(Fl_Widget *widget, void *context)
|
|
{
|
|
auto *s = static_cast<MixerStrip *>(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<float>(std::pow(10, s->gain_->value() / 20))))
|
|
: track.gain;
|
|
const float pan = widget == s->pan_ ? static_cast<float>(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<MixerStrip *>(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<MixerStrip *>(c);
|
|
int i = s->route_->value();
|
|
if (i < 0 || static_cast<size_t>(i) >= s->destinations_.size())
|
|
return;
|
|
auto bus = s->destinations_[static_cast<size_t>(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<Fl_Widget *>(gain_), static_cast<Fl_Widget *>(pan_),
|
|
static_cast<Fl_Widget *>(mute_), static_cast<Fl_Widget *>(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<double>(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<int>(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<double>(meter[0].db),
|
|
static_cast<double>(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<int>((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<int>((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<MixerWidget *>(c);
|
|
int index = m->routes_->value();
|
|
if (index >= 0 && static_cast<size_t>(index) < m->route_ids_.size())
|
|
m->route(m->route_ids_[static_cast<size_t>(index)]);
|
|
},
|
|
this);
|
|
bus_ = new Fl_Button(x + 820, y + 8, 110, 26, "Add bus");
|
|
bus_->callback([](Fl_Widget *, void *c) { static_cast<MixerWidget *>(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<MixerWidget *>(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<uint64_t>(
|
|
std::clamp(start_->value(), 0.0, static_cast<double>(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<int>(i);
|
|
}
|
|
tracks_->value(n ? index : -1);
|
|
selected_id_=UINT64_MAX;
|
|
if(n){xtc_track_info track{};xtc_project_get_track(p,static_cast<size_t>(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<size_t>(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<int>(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<double>(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<double>(master_[0].db),static_cast<double>(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<unsigned int>(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<MixerWidget *>(c);
|
|
if(widget==m->tracks_){xtc_track_info track{};
|
|
if(xtc_project_get_track(m->project_,static_cast<size_t>(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_Input_ *>(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<size_t>(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<int>(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<double>(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<double>(master_[0].db),static_cast<double>(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<int>(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<size_t>(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<int>(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();
|
|
}
|