2026-10-27 00:10:45 +01:00
|
|
|
#include "sequencer_widget.hpp"
|
2026-10-27 18:04:39 +01:00
|
|
|
#include "theme.hpp"
|
2026-10-27 00:53:41 +01:00
|
|
|
#include "xtc/xtc_midi.h"
|
2026-10-27 00:10:45 +01:00
|
|
|
#include <FL/Fl.H>
|
|
|
|
|
#include <FL/Fl_Scroll.H>
|
|
|
|
|
#include <FL/fl_draw.H>
|
|
|
|
|
#include <FL/fl_ask.H>
|
2026-10-27 06:28:25 +01:00
|
|
|
#include <FL/Fl_Menu_Item.H>
|
2026-10-27 00:10:45 +01:00
|
|
|
#include <algorithm>
|
|
|
|
|
#include <cstdio>
|
2026-10-27 15:46:41 +01:00
|
|
|
#include <sstream>
|
|
|
|
|
#include <new>
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Constructs the timeline widget with a borrowed project and seek callback.
|
|
|
|
|
*
|
|
|
|
|
* @param left Left coordinate in pixels.
|
|
|
|
|
* @param top Top coordinate in pixels.
|
|
|
|
|
* @param project Borrowed project; must outlive the widget's use of it.
|
|
|
|
|
* @param seek Callback used to request a transport seek.
|
|
|
|
|
* @param context Opaque callback context.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
SequencerWidget::SequencerWidget(int left, int top, xtc_project *project, Seek seek, void *context)
|
2026-10-27 01:37:21 +01:00
|
|
|
: Fl_Widget(left, top, 6300, 3700), project_(project), seek_(seek), context_(context)
|
|
|
|
|
{
|
|
|
|
|
extent();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Replaces the borrowed project and refreshes the widget's displayed state.
|
|
|
|
|
*
|
|
|
|
|
* @param project Borrowed project; must outlive the widget's use of it.
|
|
|
|
|
* @param reset Whether to clear selection, cursor, and drag state.
|
|
|
|
|
*/
|
2026-10-27 16:49:30 +01:00
|
|
|
bool SequencerWidget::select(uint64_t track, uint64_t clip)
|
|
|
|
|
{
|
|
|
|
|
size_t tracks = 0, clips = 0;
|
|
|
|
|
xtc_project_get_counts(project_,&tracks,&clips);
|
|
|
|
|
bool found = false;
|
|
|
|
|
for (size_t i = 0; i < tracks; ++i) {
|
|
|
|
|
xtc_track_info info{};
|
|
|
|
|
if (xtc_project_get_track(project_,i,&info) == XTC_OK && info.id == track) found = true;
|
|
|
|
|
}
|
|
|
|
|
if (!found) return false;
|
|
|
|
|
if (clip != UINT64_MAX) {
|
|
|
|
|
xtc_clip_info info{};
|
|
|
|
|
if (xtc_project_find_clip(project_,clip,&info) != XTC_OK || info.track_id != track) return false;
|
|
|
|
|
}
|
|
|
|
|
track_ = track; clip_ = clip; mode_ = 0; redraw();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 00:10:45 +01:00
|
|
|
void SequencerWidget::set_project(xtc_project *project, bool reset)
|
|
|
|
|
{
|
|
|
|
|
project_ = project;
|
2026-10-27 01:37:21 +01:00
|
|
|
if (reset) {
|
2026-10-27 15:46:41 +01:00
|
|
|
waves_.clear();
|
2026-10-27 01:37:21 +01:00
|
|
|
track_ = clip_ = UINT64_MAX;
|
|
|
|
|
cursor_ = 0;
|
|
|
|
|
mode_ = 0;
|
|
|
|
|
}
|
2026-10-27 15:46:41 +01:00
|
|
|
refresh_waveforms();
|
2026-10-27 01:37:21 +01:00
|
|
|
extent();
|
|
|
|
|
redraw();
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Converts a screen x coordinate to a bounded timeline tick snapped to a sixteenth note.
|
|
|
|
|
*
|
|
|
|
|
* @param px Screen x coordinate in pixels.
|
|
|
|
|
* @return Snapped tick within the supported project range.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
uint64_t SequencerWidget::tick(int px) const
|
|
|
|
|
{
|
2026-10-27 18:04:39 +01:00
|
|
|
return std::min(XTC_PROJECT_MAX_TICK, static_cast<uint64_t>(std::max(0, px - x() - 180)) * 960U /
|
2026-10-27 06:28:25 +01:00
|
|
|
static_cast<unsigned int>(scale_) / grid_ * grid_);
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Resizes the timeline's scrollable area to fit its tracks and clip ends.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
void SequencerWidget::extent()
|
|
|
|
|
{
|
|
|
|
|
size_t tracks, clips;
|
|
|
|
|
xtc_project_get_counts(project_, &tracks, &clips);
|
|
|
|
|
uint64_t end = 32U * 3840U;
|
|
|
|
|
for (size_t i = 0; i < clips; ++i) {
|
2026-10-27 01:37:21 +01:00
|
|
|
xtc_clip_info c{};
|
|
|
|
|
xtc_project_get_clip(project_, i, &c);
|
2026-10-27 00:10:45 +01:00
|
|
|
end = std::max(end, c.start_tick + c.length_tick + 3840U);
|
|
|
|
|
}
|
2026-10-27 18:04:39 +01:00
|
|
|
size(static_cast<int>(end * static_cast<unsigned int>(scale_) / 960U + 200U),
|
2026-10-27 00:10:45 +01:00
|
|
|
std::max(400, static_cast<int>(tracks) * 56 + 100));
|
2026-10-27 01:37:21 +01:00
|
|
|
if (parent())
|
|
|
|
|
parent()->redraw();
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Reports an edit result, refreshes the widget, and notifies listeners on success.
|
|
|
|
|
*
|
|
|
|
|
* @param result Result of the attempted operation.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
void SequencerWidget::changed(xtc_result result)
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (result != XTC_OK)
|
|
|
|
|
fl_alert("%s", xtc_result_string(result));
|
|
|
|
|
else {
|
2026-10-27 15:46:41 +01:00
|
|
|
refresh_waveforms();
|
2026-10-27 01:37:21 +01:00
|
|
|
extent();
|
|
|
|
|
do_callback();
|
|
|
|
|
}
|
2026-10-27 00:10:45 +01:00
|
|
|
redraw();
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Creates and selects a track through the arrangement edit hook.
|
|
|
|
|
* @return XTC_OK on success, or an error code describing the failure.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_result SequencerWidget::add_track()
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (before_edit) {
|
|
|
|
|
xtc_result r = before_edit(context_);
|
|
|
|
|
if (r != XTC_OK) {
|
|
|
|
|
changed(r);
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
uint64_t id;
|
|
|
|
|
xtc_result r = xtc_project_add_track(project_, "Track", &id);
|
|
|
|
|
if (r == XTC_OK) {
|
|
|
|
|
track_ = id;
|
|
|
|
|
clip_ = UINT64_MAX;
|
|
|
|
|
}
|
|
|
|
|
changed(r);
|
|
|
|
|
return r;
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Creates and selects a one-bar silent region at the timeline cursor.
|
|
|
|
|
* @return XTC_OK on success, or an error code describing the failure.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_result SequencerWidget::add_region()
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (before_edit) {
|
|
|
|
|
xtc_result r = before_edit(context_);
|
|
|
|
|
if (r != XTC_OK) {
|
|
|
|
|
changed(r);
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
size_t tracks, clips;
|
|
|
|
|
xtc_project_get_counts(project_, &tracks, &clips);
|
2026-10-27 00:10:45 +01:00
|
|
|
if (track_ == UINT64_MAX && tracks) {
|
2026-10-27 01:37:21 +01:00
|
|
|
xtc_track_info t{};
|
|
|
|
|
xtc_project_get_track(project_, 0, &t);
|
|
|
|
|
track_ = t.id;
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
|
|
|
|
uint64_t id;
|
|
|
|
|
xtc_result r = xtc_project_add_clip(project_, track_, "Region (silent)", cursor_, 3840U, &id);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK)
|
|
|
|
|
clip_ = id;
|
|
|
|
|
changed(r);
|
|
|
|
|
return r;
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Creates and selects a one-bar MIDI clip at the timeline cursor.
|
|
|
|
|
* @return XTC_OK on success, or an error code describing the failure.
|
|
|
|
|
*/
|
2026-10-27 00:53:41 +01:00
|
|
|
xtc_result SequencerWidget::add_midi_clip()
|
|
|
|
|
{
|
|
|
|
|
xtc_result r = before_edit ? before_edit(context_) : XTC_OK;
|
|
|
|
|
uint64_t id;
|
|
|
|
|
if (r == XTC_OK) {
|
|
|
|
|
if (track_ == UINT64_MAX) {
|
|
|
|
|
xtc_track_info t{};
|
2026-10-27 01:37:21 +01:00
|
|
|
r = xtc_project_get_track(project_, 0, &t);
|
|
|
|
|
track_ = r == XTC_OK ? t.id : UINT64_MAX;
|
2026-10-27 00:53:41 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_project_add_midi_clip(project_, track_, cursor_, 3840, &id);
|
|
|
|
|
if (r == XTC_OK)
|
|
|
|
|
clip_ = id;
|
2026-10-27 00:53:41 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
changed(r);
|
|
|
|
|
return r;
|
2026-10-27 00:53:41 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Removes the selected clip, or the selected track when no clip is selected.
|
|
|
|
|
* @return XTC_OK on success, or an error code describing the failure.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_result SequencerWidget::delete_selection()
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (before_edit) {
|
|
|
|
|
xtc_result r = before_edit(context_);
|
|
|
|
|
if (r != XTC_OK) {
|
|
|
|
|
changed(r);
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
xtc_result r =
|
|
|
|
|
clip_ != UINT64_MAX ? xtc_project_remove_clip(project_, clip_) : xtc_project_remove_track(project_, track_);
|
|
|
|
|
if (r == XTC_OK)
|
|
|
|
|
clip_ = track_ = UINT64_MAX;
|
|
|
|
|
changed(r);
|
|
|
|
|
return r;
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Adjusts timeline zoom within its supported bounds and updates the scroll extent.
|
|
|
|
|
*
|
|
|
|
|
* @param direction Zoom direction; positive zooms in, nonpositive zooms out.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
void SequencerWidget::zoom(int direction)
|
2026-10-27 01:37:21 +01:00
|
|
|
{
|
|
|
|
|
scale_ = std::clamp(direction > 0 ? scale_ * 2 : scale_ / 2, 24, 384);
|
|
|
|
|
extent();
|
|
|
|
|
redraw();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Draws the widget's current model, selection, and interaction feedback.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
void SequencerWidget::draw()
|
|
|
|
|
{
|
2026-10-27 18:04:39 +01:00
|
|
|
fl_push_clip(x(),y(),w(),h());
|
2026-10-27 01:37:21 +01:00
|
|
|
int cx, cy, cw, ch;
|
2026-10-27 18:04:39 +01:00
|
|
|
fl_clip_box(x(),y(),w(),h(),cx,cy,cw,ch);
|
|
|
|
|
fl_color(xtc_ui::background()); fl_rectf(cx,cy,cw,ch);
|
|
|
|
|
size_t tracks = 0, clips = 0, notes = 0;
|
|
|
|
|
xtc_project_get_counts(project_,&tracks,&clips); xtc_project_note_count(project_,¬es);
|
2026-10-27 00:10:45 +01:00
|
|
|
for (size_t i = 0; i < tracks; ++i) {
|
2026-10-27 18:04:39 +01:00
|
|
|
int py = y()+28+static_cast<int>(i)*56;
|
|
|
|
|
if (py+56 < cy || py > cy+ch) continue;
|
|
|
|
|
fl_color(i%2 ? fl_rgb_color(30,34,40) : fl_rgb_color(33,37,43));
|
|
|
|
|
fl_rectf(x()+180,py,w()-180,55);
|
|
|
|
|
}
|
|
|
|
|
fl_font(FL_HELVETICA,11);
|
|
|
|
|
int first = std::max(0,(cx-x()-180)/scale_);
|
|
|
|
|
int last = (cx+cw-x()-180)/scale_+1;
|
|
|
|
|
for (int beat = first; beat <= last; ++beat) {
|
|
|
|
|
int px = x()+180+beat*scale_;
|
|
|
|
|
fl_color(beat%4 ? fl_rgb_color(43,48,56) : fl_rgb_color(65,72,83));
|
|
|
|
|
fl_line(px,y()+28,px,y()+h());
|
|
|
|
|
if (beat%4 == 0) {
|
|
|
|
|
char label[32]; std::snprintf(label,sizeof(label),"%d",beat/4+1);
|
|
|
|
|
fl_color(xtc_ui::muted()); fl_draw(label,px+6,y()+19);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
fl_color(xtc_ui::muted()); fl_draw("TRACKS / 4:4",x()+12,y()+19);
|
|
|
|
|
for (size_t i = 0; i < tracks; ++i) {
|
|
|
|
|
xtc_track_info t{}; xtc_project_get_track(project_,i,&t);
|
|
|
|
|
int py = y()+28+static_cast<int>(i)*56;
|
|
|
|
|
if (py+56 < cy || py > cy+ch) continue;
|
|
|
|
|
Fl_Color color = xtc_ui::track(t.id);
|
|
|
|
|
fl_color(t.id == track_ ? fl_rgb_color(49,62,70) : xtc_ui::panel());
|
|
|
|
|
fl_rectf(x(),py,178,55);
|
|
|
|
|
fl_color(color); fl_rectf(x(),py,3,55);
|
|
|
|
|
fl_push_clip(x()+9,py,162,55);
|
|
|
|
|
fl_color(xtc_ui::text()); fl_font(FL_HELVETICA_BOLD,12);
|
|
|
|
|
fl_draw(t.name,x()+12,py+23);
|
|
|
|
|
char detail[64];
|
|
|
|
|
std::snprintf(detail,sizeof(detail),"%02zu %s%s%s",i+1,t.is_bus ? "BUS" : "TRACK",
|
|
|
|
|
t.mute ? " MUTE" : "",t.solo ? " SOLO" : "");
|
|
|
|
|
fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); fl_draw(detail,x()+12,py+42);
|
|
|
|
|
fl_pop_clip();
|
2026-10-27 00:10:45 +01:00
|
|
|
for (size_t j = 0; j < clips; ++j) {
|
2026-10-27 18:04:39 +01:00
|
|
|
xtc_clip_info c{}; xtc_project_get_clip(project_,j,&c);
|
|
|
|
|
if (c.track_id != t.id) continue;
|
|
|
|
|
int left = x()+180+static_cast<int>(c.start_tick*static_cast<unsigned int>(scale_)/960U);
|
|
|
|
|
int right = left+std::max(4,static_cast<int>(c.length_tick*static_cast<unsigned int>(scale_)/960U));
|
|
|
|
|
int a = std::max(left,cx), b = std::min(right,cx+cw);
|
|
|
|
|
if (b <= a) continue;
|
|
|
|
|
fl_color(fl_color_average(color,xtc_ui::background(),c.id == clip_ ? .75f : .45f));
|
|
|
|
|
fl_rectf(a,py+5,b-a,46);
|
|
|
|
|
fl_color(c.id == clip_ ? xtc_ui::text() : color); fl_rect(a,py+5,b-a,46);
|
|
|
|
|
fl_push_clip(a+1,py+6,std::max(0,b-a-2),44);
|
|
|
|
|
if (c.midi) {
|
|
|
|
|
/* Real note positions, pitch and duration; no placeholder waveform. */
|
|
|
|
|
unsigned int low = 127, high = 0;
|
|
|
|
|
for (size_t n = 0; n < notes; ++n) {
|
|
|
|
|
xtc_note_info note{}; xtc_project_get_note(project_,n,¬e);
|
|
|
|
|
if (note.clip_id == c.id) { low = std::min(low,note.pitch); high = std::max(high,note.pitch); }
|
|
|
|
|
}
|
|
|
|
|
unsigned int range = high >= low ? std::max(12U,high-low) : 12U;
|
|
|
|
|
fl_color(fl_color_average(color,FL_WHITE,.55f));
|
|
|
|
|
for (size_t n = 0; n < notes; ++n) {
|
|
|
|
|
xtc_note_info note{}; xtc_project_get_note(project_,n,¬e);
|
|
|
|
|
if (note.clip_id != c.id) continue;
|
|
|
|
|
uint64_t begin = std::max(note.start_tick,c.offset_tick);
|
|
|
|
|
uint64_t end = std::min(note.start_tick+note.length_tick,c.offset_tick+c.length_tick);
|
|
|
|
|
if (begin >= end) continue;
|
|
|
|
|
int nx = left+static_cast<int>((begin-c.offset_tick)*static_cast<unsigned int>(scale_)/960U);
|
|
|
|
|
int nw = std::max(2,static_cast<int>((end-begin)*static_cast<unsigned int>(scale_)/960U));
|
|
|
|
|
fl_rectf(nx,py+26+static_cast<int>((high-note.pitch)*18/range),nw,2);
|
|
|
|
|
}
|
|
|
|
|
} else draw_waveform(c,left,py);
|
|
|
|
|
fl_color(fl_color_average(color,xtc_ui::background(),.35f)); fl_rectf(a+1,py+6,std::max(0,b-a-2),15);
|
|
|
|
|
fl_font(FL_HELVETICA,11); fl_color(xtc_ui::text());
|
|
|
|
|
fl_draw(c.name,left+7,py+18);
|
2026-10-27 01:37:21 +01:00
|
|
|
fl_pop_clip();
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-27 18:04:39 +01:00
|
|
|
if (!tracks) {
|
|
|
|
|
fl_font(FL_HELVETICA_BOLD,18); fl_color(xtc_ui::text());
|
|
|
|
|
fl_draw("Your next track starts here",x()+220,y()+105);
|
|
|
|
|
fl_font(FL_HELVETICA,13); fl_color(xtc_ui::muted());
|
|
|
|
|
fl_draw("Create a MIDI track or import audio from the panel on the left.",x()+220,y()+135);
|
|
|
|
|
fl_draw("Arrange clips here. Open the piano roll to draw and edit MIDI notes.",x()+220,y()+161);
|
|
|
|
|
}
|
|
|
|
|
int playhead = x()+180+static_cast<int>(cursor_*static_cast<unsigned int>(scale_)/960U);
|
|
|
|
|
if (playhead >= cx && playhead <= cx+cw) {
|
|
|
|
|
fl_color(xtc_ui::accent()); fl_line(playhead,y(),playhead,y()+h());
|
|
|
|
|
fl_polygon(playhead-4,y(),playhead+4,y(),playhead,y()+6);
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
|
|
|
|
fl_pop_clip();
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @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.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
int SequencerWidget::handle(int event)
|
|
|
|
|
{
|
2026-10-27 06:28:25 +01:00
|
|
|
if (event == FL_PUSH && (Fl::event_button() == FL_LEFT_MOUSE || Fl::event_button() == FL_RIGHT_MOUSE)) {
|
2026-10-27 01:37:21 +01:00
|
|
|
take_focus();
|
|
|
|
|
mode_ = 0;
|
2026-10-27 00:10:45 +01:00
|
|
|
if (Fl::event_y() < y() + 28) {
|
|
|
|
|
uint64_t target = tick(Fl::event_x());
|
|
|
|
|
xtc_result r = seek_(context_, target);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK)
|
|
|
|
|
cursor_ = target;
|
|
|
|
|
else
|
|
|
|
|
fl_alert("Stop playback before seeking: %s", xtc_result_string(r));
|
|
|
|
|
redraw();
|
|
|
|
|
return 1;
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
size_t tracks, clips;
|
|
|
|
|
xtc_project_get_counts(project_, &tracks, &clips);
|
2026-10-27 00:10:45 +01:00
|
|
|
int row = (Fl::event_y() - y() - 28) / 56;
|
2026-10-27 01:37:21 +01:00
|
|
|
if (row < 0 || static_cast<size_t>(row) >= tracks)
|
|
|
|
|
return 1;
|
|
|
|
|
xtc_track_info t{};
|
|
|
|
|
xtc_project_get_track(project_, static_cast<size_t>(row), &t);
|
|
|
|
|
track_ = t.id;
|
2026-10-27 00:10:45 +01:00
|
|
|
clip_ = UINT64_MAX;
|
|
|
|
|
for (size_t j = clips; j > 0; --j) {
|
2026-10-27 01:37:21 +01:00
|
|
|
xtc_clip_info c{};
|
|
|
|
|
xtc_project_get_clip(project_, j - 1, &c);
|
2026-10-27 18:04:39 +01:00
|
|
|
int left = x() + 180 + static_cast<int>(c.start_tick * static_cast<unsigned int>(scale_) / 960U);
|
2026-10-27 15:46:41 +01:00
|
|
|
int right = left + std::max(4, static_cast<int>(c.length_tick * static_cast<unsigned int>(scale_) / 960U));
|
2026-10-27 00:10:45 +01:00
|
|
|
if (c.track_id == track_ && Fl::event_x() >= left && Fl::event_x() < right) {
|
2026-10-27 01:37:21 +01:00
|
|
|
clip_ = c.id;
|
|
|
|
|
original_ = c;
|
|
|
|
|
drag_x_ = Fl::event_x();
|
|
|
|
|
mode_ = Fl::event_x() - left < 7 ? 2 : right - Fl::event_x() < 7 ? 3 : 1;
|
|
|
|
|
break;
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-27 06:28:25 +01:00
|
|
|
if (Fl::event_button() == FL_RIGHT_MOUSE) {
|
|
|
|
|
mode_ = 0;
|
2026-10-27 15:46:41 +01:00
|
|
|
Fl_Menu_Item items[6]{};
|
2026-10-27 06:28:25 +01:00
|
|
|
items[0].text = "Duplicate clip (Ctrl+D)"; items[1].text = "Rename (F2)"; items[2].text = "Delete (undoable)";
|
2026-10-27 15:46:41 +01:00
|
|
|
items[3].text = "Split audio at cursor (S)"; items[4].text = "Audio gain and fades (A)";
|
2026-10-27 06:28:25 +01:00
|
|
|
const Fl_Menu_Item *picked = items->popup(Fl::event_x(),Fl::event_y());
|
|
|
|
|
if (picked == items) duplicate_selection();
|
|
|
|
|
else if (picked == items+1) { const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); }
|
|
|
|
|
else if (picked == items+2) delete_selection();
|
2026-10-27 15:46:41 +01:00
|
|
|
else if (picked == items+3) split_selection();
|
|
|
|
|
else if (picked == items+4) audio_properties_dialog();
|
2026-10-27 06:28:25 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
redraw();
|
|
|
|
|
return 1;
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
if (event == FL_DRAG)
|
|
|
|
|
return mode_ != 0;
|
2026-10-27 00:10:45 +01:00
|
|
|
if (event == FL_RELEASE && mode_) {
|
2026-10-27 01:37:21 +01:00
|
|
|
int mode = mode_;
|
|
|
|
|
mode_ = 0;
|
2026-10-27 06:28:25 +01:00
|
|
|
int64_t delta = static_cast<int64_t>(Fl::event_x() - drag_x_) * 960 / scale_ / static_cast<int64_t>(grid_) * static_cast<int64_t>(grid_);
|
2026-10-27 00:10:45 +01:00
|
|
|
int64_t start = static_cast<int64_t>(original_.start_tick);
|
|
|
|
|
int64_t length = static_cast<int64_t>(original_.length_tick);
|
|
|
|
|
int64_t offset = static_cast<int64_t>(original_.offset_tick);
|
|
|
|
|
uint64_t target = original_.track_id;
|
|
|
|
|
if (mode == 1) {
|
|
|
|
|
start = std::max<int64_t>(0, start + delta);
|
|
|
|
|
int row = (Fl::event_y() - y() - 28) / 56;
|
|
|
|
|
xtc_track_info t{};
|
2026-10-27 01:37:21 +01:00
|
|
|
if (row >= 0 && xtc_project_get_track(project_, static_cast<size_t>(row), &t) == XTC_OK)
|
|
|
|
|
target = t.id;
|
|
|
|
|
} else if (mode == 2) {
|
|
|
|
|
start += delta;
|
|
|
|
|
offset += delta;
|
|
|
|
|
length -= delta;
|
|
|
|
|
} else
|
|
|
|
|
length += delta;
|
|
|
|
|
if (start < 0 || offset < 0 || length <= 0)
|
|
|
|
|
return 1;
|
|
|
|
|
if (before_edit) {
|
|
|
|
|
xtc_result r = before_edit(context_);
|
|
|
|
|
if (r != XTC_OK) {
|
|
|
|
|
changed(r);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-27 00:10:45 +01:00
|
|
|
changed(xtc_project_edit_clip(project_, clip_, target, static_cast<uint64_t>(start),
|
2026-10-27 01:37:21 +01:00
|
|
|
static_cast<uint64_t>(length), static_cast<uint64_t>(offset)));
|
2026-10-27 00:10:45 +01:00
|
|
|
return 1;
|
|
|
|
|
}
|
2026-10-27 15:46:41 +01:00
|
|
|
if (event == FL_KEYDOWN && !Fl::event_state(FL_CTRL | FL_ALT)) {
|
|
|
|
|
if (Fl::event_key() == 's') { split_selection(); return 1; }
|
|
|
|
|
if (Fl::event_key() == 'a') { audio_properties_dialog(); return 1; }
|
|
|
|
|
}
|
2026-10-27 06:28:25 +01:00
|
|
|
if (event == FL_KEYDOWN && Fl::event_state(FL_CTRL) && Fl::event_key() == 'd') { duplicate_selection(); return 1; }
|
|
|
|
|
if (event == FL_KEYDOWN && Fl::event_key() == FL_F+2) {
|
|
|
|
|
const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); return 1;
|
|
|
|
|
}
|
|
|
|
|
if (event == FL_KEYDOWN && Fl::event_state(FL_ALT) &&
|
|
|
|
|
(Fl::event_key() == FL_Up || Fl::event_key() == FL_Down)) { move_track(Fl::event_key() == FL_Up ? -1 : 1); return 1; }
|
2026-10-27 01:37:21 +01:00
|
|
|
if (event == FL_KEYDOWN && Fl::event_key() == FL_Escape) {
|
|
|
|
|
mode_ = 0;
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
if (event == FL_KEYDOWN && Fl::event_key() == FL_Delete) {
|
|
|
|
|
delete_selection();
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
if (event == FL_FOCUS || event == FL_UNFOCUS)
|
|
|
|
|
return 1;
|
2026-10-27 00:10:45 +01:00
|
|
|
return Fl_Widget::handle(event);
|
|
|
|
|
}
|
2026-10-27 06:28:25 +01:00
|
|
|
|
|
|
|
|
xtc_result SequencerWidget::grid(uint64_t ticks)
|
|
|
|
|
{
|
|
|
|
|
if (ticks != 1 && ticks != 120 && ticks != 240 && ticks != 480 && ticks != 960) return XTC_ERR_INVALID_ARGUMENT;
|
|
|
|
|
grid_ = ticks; redraw(); return XTC_OK;
|
|
|
|
|
}
|
|
|
|
|
xtc_result SequencerWidget::duplicate_selection()
|
|
|
|
|
{
|
|
|
|
|
xtc_clip_info c{}; uint64_t id;
|
|
|
|
|
xtc_result r = xtc_project_find_clip(project_,clip_,&c);
|
|
|
|
|
if (r == XTC_OK && before_edit) r = before_edit(context_);
|
|
|
|
|
if (r == XTC_OK) r = xtc_project_duplicate_clip(project_,clip_,c.track_id,c.start_tick+c.length_tick,&id);
|
|
|
|
|
if (r == XTC_OK) clip_ = id;
|
|
|
|
|
changed(r); return r;
|
|
|
|
|
}
|
|
|
|
|
xtc_result SequencerWidget::rename_selection(const char *name)
|
|
|
|
|
{
|
|
|
|
|
xtc_result r = before_edit ? before_edit(context_) : XTC_OK;
|
|
|
|
|
if (r == XTC_OK) r = clip_ != UINT64_MAX ? xtc_project_rename_clip(project_,clip_,name) : xtc_project_rename_track(project_,track_,name);
|
|
|
|
|
changed(r); return r;
|
|
|
|
|
}
|
|
|
|
|
xtc_result SequencerWidget::move_track(int direction)
|
|
|
|
|
{
|
|
|
|
|
size_t tracks, clips, i; xtc_project_get_counts(project_,&tracks,&clips);
|
|
|
|
|
xtc_track_info track{};
|
|
|
|
|
for (i = 0; i < tracks; ++i) { xtc_project_get_track(project_,i,&track); if (track.id == track_) break; }
|
|
|
|
|
if (i == tracks || (direction < 0 ? i == 0 : i+1 == tracks)) return XTC_ERR_NOT_FOUND;
|
|
|
|
|
xtc_result r = before_edit ? before_edit(context_) : XTC_OK;
|
|
|
|
|
if (r == XTC_OK) r = xtc_project_move_track(project_,track_,direction < 0 ? i-1 : i+1);
|
|
|
|
|
changed(r); return r;
|
|
|
|
|
}
|
2026-10-27 15:46:41 +01:00
|
|
|
|
|
|
|
|
xtc_result SequencerWidget::split_selection()
|
|
|
|
|
{
|
|
|
|
|
uint64_t right;
|
|
|
|
|
const uint64_t split_tick = cursor_; // Stop/status refresh may move the playhead.
|
|
|
|
|
xtc_result result = before_edit ? before_edit(context_) : XTC_OK;
|
|
|
|
|
if (result == XTC_OK) result = xtc_project_split_audio(project_, clip_, split_tick, &right);
|
|
|
|
|
if (result == XTC_OK) clip_ = right;
|
|
|
|
|
changed(result);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
xtc_result SequencerWidget::audio_properties(float gain, uint64_t in, uint64_t out)
|
|
|
|
|
{
|
|
|
|
|
xtc_result result = before_edit ? before_edit(context_) : XTC_OK;
|
|
|
|
|
if (result == XTC_OK) result = xtc_project_set_clip_audio(project_, clip_, gain, in, out);
|
|
|
|
|
changed(result);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void SequencerWidget::audio_properties_dialog()
|
|
|
|
|
{
|
|
|
|
|
xtc_clip_info clip{};
|
|
|
|
|
if (xtc_project_find_clip(project_, clip_, &clip) != XTC_OK || clip.midi) {
|
|
|
|
|
fl_alert("Select an audio clip or silent region first.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
char defaults[128];
|
|
|
|
|
std::snprintf(defaults, sizeof(defaults), "%.9g %llu %llu", static_cast<double>(clip.gain),
|
|
|
|
|
static_cast<unsigned long long>(clip.fade_in_tick), static_cast<unsigned long long>(clip.fade_out_tick));
|
|
|
|
|
const char *text = fl_input("Linear gain (0..4), fade-in ticks, fade-out ticks (0 disables)", defaults);
|
|
|
|
|
if (!text) return;
|
|
|
|
|
std::istringstream input(text);
|
|
|
|
|
float gain; uint64_t in, out;
|
|
|
|
|
if (!(input >> gain >> in >> out) || !(input >> std::ws).eof()) {
|
|
|
|
|
fl_alert("Enter gain and two fade durations in ticks."); return;
|
|
|
|
|
}
|
|
|
|
|
audio_properties(gain, in, out);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void SequencerWidget::refresh_waveforms() try
|
|
|
|
|
{
|
|
|
|
|
// Control-thread loading only. Keep cached failures as well as successes;
|
|
|
|
|
// removed/relinked paths are evicted. Reopening rebuilds externally changed files.
|
|
|
|
|
size_t tracks, clips;
|
|
|
|
|
xtc_project_get_counts(project_, &tracks, &clips);
|
|
|
|
|
std::vector<Wave> next;
|
|
|
|
|
for (size_t i = 0; i < clips && next.size() < 64; ++i) {
|
|
|
|
|
xtc_clip_info c{};
|
|
|
|
|
xtc_project_get_clip(project_, i, &c);
|
|
|
|
|
if (!c.asset_path[0]) continue;
|
|
|
|
|
bool present = false;
|
|
|
|
|
for (const auto &wave : next) if (wave.path == c.asset_path) { present = true; break; }
|
|
|
|
|
if (present) continue;
|
|
|
|
|
Wave entry;
|
|
|
|
|
entry.path = c.asset_path;
|
|
|
|
|
for (auto &wave : waves_) if (wave.path == entry.path) {
|
|
|
|
|
entry.data = std::move(wave.data); present = true; break;
|
|
|
|
|
}
|
|
|
|
|
if (!present) {
|
|
|
|
|
xtc_waveform *summary = nullptr;
|
|
|
|
|
(void)xtc_waveform_create(c.asset_path, &summary);
|
|
|
|
|
entry.data.reset(summary);
|
|
|
|
|
}
|
|
|
|
|
next.push_back(std::move(entry));
|
|
|
|
|
}
|
|
|
|
|
waves_ = std::move(next);
|
|
|
|
|
} catch (const std::bad_alloc &) {
|
|
|
|
|
waves_.clear(); // Preview failure must not interrupt the model/history edit.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void SequencerWidget::draw_waveform(const xtc_clip_info &c, int left, int top)
|
|
|
|
|
{
|
|
|
|
|
const xtc_waveform *summary = nullptr;
|
|
|
|
|
for (const auto &wave : waves_) if (wave.path == c.asset_path) { summary = wave.data.get(); break; }
|
|
|
|
|
if (!summary) return;
|
|
|
|
|
unsigned int rate; size_t frames, bins;
|
|
|
|
|
if (xtc_waveform_info(summary, &rate, &frames, &bins) != XTC_OK) return;
|
|
|
|
|
double ticks_per_frame = static_cast<double>(xtc_project_tempo(project_)) * 960 / (rate * 60000.0);
|
|
|
|
|
fl_color(fl_rgb_color(30, 65, 80));
|
|
|
|
|
for (size_t bin = 0; bin < bins; ++bin) {
|
|
|
|
|
double start = static_cast<double>(bin * frames / bins) * ticks_per_frame;
|
|
|
|
|
double end = static_cast<double>((bin+1) * frames / bins) * ticks_per_frame;
|
|
|
|
|
double offset = static_cast<double>(c.offset_tick);
|
|
|
|
|
if (end <= offset || start >= offset + static_cast<double>(c.length_tick)) continue;
|
|
|
|
|
int px = left + static_cast<int>((std::max(start, offset) - offset) * scale_ / 960);
|
|
|
|
|
int width = std::max(1, static_cast<int>((end - std::max(start, offset)) * scale_ / 960));
|
|
|
|
|
float low, high;
|
|
|
|
|
if (xtc_waveform_peak(summary, bin, &low, &high) != XTC_OK) continue;
|
|
|
|
|
int y1 = top + 28 - static_cast<int>(std::clamp(high, -1.0f, 1.0f) * 19);
|
|
|
|
|
int y2 = top + 28 - static_cast<int>(std::clamp(low, -1.0f, 1.0f) * 19);
|
|
|
|
|
fl_rectf(px, y1, width, std::max(1, y2-y1+1));
|
|
|
|
|
}
|
|
|
|
|
}
|