diff --git a/.astylerc b/.astylerc index 7ace627..71e7526 100644 --- a/.astylerc +++ b/.astylerc @@ -2,3 +2,4 @@ style=attach indent-namespaces keep-one-line-blocks +indent-labels diff --git a/notes b/notes index 67f6f63..c70f153 100644 --- a/notes +++ b/notes @@ -11,42 +11,32 @@ project data { sample rate, channel count (1 or 2), length probably QSharedPointer to raw float array (size=length*channels, non-interleaved) } +} + +parameters { + standard extensions { + tXX - set tween time (in ticks) for previous parameter + ,XX - extra param bytes + } - pattern { - name, id - length in rows (duh) - time signature: beats/measure, rows/beat, ticks/row (can default to project global? except that doesn't make complete sense) - tempo change on enter (float, defaults to 0; only applied if >0) - when creating new pattern { - time signature set to project defaults (either static or fallback) - rows = 4 measures - } + InstrumentCore (stock instrument behavior) { + legato accepts tweens! must be first parameter entry - per-pattern channels; note continuity is defined by name - ^ by default, send note-off on entering a pattern without a channel of that name - also send note-off on old note when triggering a new one regardless of what instrument it is, - *AFTER* sending the new note-on (to not break or make things harder for legato instruments) - - command format - 01 C-5 v7F ... ... ... - instrument (port) number first; note-sharp-octave notation same as most trackers, but arbitrary number of a single type of parameter - [plugins receive commands more or less exactly as written; meaning is by convention more than anything, but there is a "standard" way of handling notes, handled by a library on the lua side] - - leave pitch bends to automation? or build them as per-tick messages from host? also, stepped by tick or smoothed per sample? - x note-on events send the actual note as a float value - - nope, separate event for cents (bcd? that would futz with interpolation though... signed byte, -100..100) - - unique port for globals (-2 internally, styled as (G), and placed by, get this, pressing g) { - what to do with the notes? - tXX - tempo (second tXX as high byte, .XX for fine tempo (0..100)) - > anything else? - } + vXX - volume; 00 .. FF -> 0.0 .. 1.0 (accepts tweens) + pXX - panning; signed byte? 00 as center (accepts tweens) + gXX/GXX - glissando (pitch bend); g=down, G=up; relative semitones (accepts tweens) + } + + global port (G) { + tXX - tempo (how to implement >255? fine tempo?) } } TODO { immediate frontburner { - instrumentcore tweens (unordered_multimap...) - actual support for commands in InstrumentCore + - iterator/reader abstraction for commands + - actual support for commands in InstrumentCore node function to release unneeded old data when stopping playback? } @@ -58,7 +48,7 @@ TODO { bugs to fix { -? graph connections sometimes spawn in duplicated :| - on starting playback, sometimes a "thunk" sneaks into the waveform? + - on starting playback, sometimes a "thunk" sneaks into the waveform? -? buffer underruns are being caused by some sync wonkiness between multiple workers } diff --git a/xybrid/nodelib/basics.h b/xybrid/nodelib/basics.h index a33e7b8..3bb3447 100644 --- a/xybrid/nodelib/basics.h +++ b/xybrid/nodelib/basics.h @@ -1,14 +1,21 @@ #pragma once +#include +#include + class QCborMap; class QCborValue; namespace Xybrid::NodeLib { // more precision than probably fits in a double, but it certainly shouldn't hurt - constexpr double PI = 3.141592653589793238462643383279502884197169399375105820974; - const double SEMI = 1.059463094359295264561825294946341700779204317494185628559; + const constexpr double PI = 3.141592653589793238462643383279502884197169399375105820974; + const constexpr double SEMI = 1.059463094359295264561825294946341700779204317494185628559; + /// Multiplier to compensate for the balance equation + // (1.0 / cos(PI*0.25)) + const constexpr double PAN_MULT = 1.414213562373095048801688724209698078569671875376948073176; - constexpr double shortStep = 0.0025; + /// Sane mimimum transition time to avoid clip artifacts + const constexpr double shortStep = 0.0025; struct ADSR { double a = 0.0, d = 0.0, s = 1.0, r = 0.0; @@ -21,4 +28,10 @@ namespace Xybrid::NodeLib { operator QCborMap() const; operator QCborValue() const; }; + + inline std::pair panSignal(double in, double pan) { + if (pan == 0.0) return { in, in }; + double s = (pan+1.0) * PI * 0.25; + return { in * (std::cos(s) * PAN_MULT), in * (std::sin(s) * PAN_MULT) }; + } } diff --git a/xybrid/nodelib/commandreader.cpp b/xybrid/nodelib/commandreader.cpp new file mode 100644 index 0000000..e18a1ab --- /dev/null +++ b/xybrid/nodelib/commandreader.cpp @@ -0,0 +1,54 @@ +#include "commandreader.h" +using namespace Xybrid::NodeLib; +using namespace Xybrid::Data; + +#include "data/porttypes.h" + + +CommandReader::CommandReader(CommandPort* p) { + p->pull(); + data = p->data; + dataSize = p->dataSize; +} + +CommandReader::operator bool() const { return dataSize >= cur+5; } +CommandReader& CommandReader::operator++() { + if (cur == static_cast(-1)) cur = 0; + else if (dataSize >= cur+5) cur += 5 + data[cur+4]*2; + return *this; +} + +ParamReader::ParamReader(const CommandReader& cr) : cr(cr) { + if (cr) { + pmax = cr.data[cr.cur+4]; + } +} + +ParamReader::operator bool() const { return pn >= 0 && pn < pmax; } +ParamReader &ParamReader::operator++() { + ++pn; + return *this; +} + +int16_t ParamReader::next(bool acceptsTweens, uint8_t num) const { + auto n = static_cast(pn); + while (++n < pmax) { + auto p = cr.param(n); + if (p != ',' && (acceptsTweens && p != 't')) break; + if (p == ',' && --num == 0) return cr.val(n); + } + return -1; +} + +int16_t ParamReader::tween() const { + auto n = static_cast(pn); + while (++n < pmax) { + auto p = cr.param(n); + if (p == 't') return cr.val(n); + if (p == ',') continue; + break; + } + return -1; +} + + diff --git a/xybrid/nodelib/commandreader.h b/xybrid/nodelib/commandreader.h new file mode 100644 index 0000000..e03960a --- /dev/null +++ b/xybrid/nodelib/commandreader.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +namespace Xybrid::Data { + class CommandPort; +} + +namespace Xybrid::NodeLib { + class ParamReader; + class CommandReader { + friend class ParamReader; + uint8_t* data; + size_t dataSize; + size_t cur = static_cast(-1); + public: + CommandReader(Data::CommandPort*); + CommandReader(std::shared_ptr p) : CommandReader(p.get()) { } + + operator bool() const; + CommandReader& operator++(); + + // data access + inline uint16_t noteId() const { return reinterpret_cast(data[cur]); } + inline int16_t note() const { return reinterpret_cast(data[cur+2]); } + inline uint8_t numParams() const { return data[cur+4]; } + inline uint8_t param(uint8_t num) const { return data[cur+5+num*2]; } + inline uint8_t val(uint8_t num) const { return data[cur+6+num*2]; } + }; + class ParamReader { + CommandReader cr; + int16_t pn = -1; + int16_t pmax = 0; + public: + ParamReader(const CommandReader&); + + operator bool() const; + ParamReader& operator++(); + + // data access + inline uint8_t param() const { return cr.param(static_cast(pn)); } + inline uint8_t val() const { return cr.val(static_cast(pn)); } + + int16_t next(bool acceptsTweens, uint8_t = 1) const; + int16_t tween() const; + }; +} diff --git a/xybrid/nodelib/instrumentcore.cpp b/xybrid/nodelib/instrumentcore.cpp index 9a12451..47bc4b5 100644 --- a/xybrid/nodelib/instrumentcore.cpp +++ b/xybrid/nodelib/instrumentcore.cpp @@ -3,6 +3,8 @@ using namespace Xybrid::NodeLib; using Note = InstrumentCore::Note; using Tween = InstrumentCore::Tween; +#include "nodelib/commandreader.h" + #include "data/porttypes.h" using namespace Xybrid::Data; @@ -19,7 +21,7 @@ void InstrumentCore::reset() { activeNotes.clear(); activeNotes.reserve(16+1); activeTweens.clear(); - activeTweens.reserve(16*3+1); + activeTweens.reserve(16*2+1); smpTime = 1.0 / audioEngine->curSampleRate(); time = 0; @@ -67,34 +69,87 @@ namespace { void InstrumentCore::process(CommandPort* i, AudioPort* o) { // first, parse through commands - i->pull(); - size_t mi = 0; - while (i->dataSize >= mi+5) { - uint16_t id = reinterpret_cast(i->data[mi]); - int16_t n = reinterpret_cast(i->data[mi+2]); + auto cr = CommandReader(i); + while (++cr) { + uint16_t id = cr.noteId(); + int16_t n = cr.note(); + Note* notePtr = nullptr; if (n > -1) { auto sc = activeNotes.try_emplace(id, id); auto& note = sc.first->second; - note.note = n; + notePtr = ¬e; + //auto& note = sc.first->second; if (!sc.second) { removeTweens(note, ¬e.note); // stop any note-value tweens - if (note.adsrPhase == 2) note = Note(id); // reinstantiate on replace + if (note.adsrPhase == 2) { note = Note(id); goto forceRetrigger; } // reinstantiate on replace + if (cr.numParams() > 0 && cr.param(0) == 't') { + startTween(note, ¬e.note, n, 0, cr.val(0)); + } else note.note = n; if (onNoteLegato) onNoteLegato(note); } else { + forceRetrigger: + note.note = n; note.time = note.adsrTime = -smpTime; // compensate for first-advance if (onNoteOn) onNoteOn(note); } - } else if (n < -1) { // note off + } else { // existing note if (auto ni = activeNotes.find(id); ni != activeNotes.end()) { auto& note = ni->second; - note.adsr.s = adsrVol(note.adsr, note.adsrPhase, note.adsrTime); - note.adsrPhase = 2; - note.adsrTime = -smpTime; - if (n == -3) note.adsr.r = shortStep; - if (onNoteOff) onNoteOff(note, n == -3); + notePtr = ¬e; + if (n < -1) { // note off + note.adsr.s = adsrVol(note.adsr, note.adsrPhase, note.adsrTime); + note.adsrPhase = 2; + note.adsrTime = -smpTime; + if (n == -3) note.adsr.r = shortStep; + if (onNoteOff) onNoteOff(note, n == -3); + } + } + } + + if (notePtr) { // params + auto& note = *notePtr; + auto pr = ParamReader(cr); + while (++pr) { + auto p = pr.param(); + auto v = pr.val(); + if (p == 't' || p == ',') continue; + // TODO: custom param stuff... + switch(p) { + case 'v': { + double vol = (1.0*v) / 255.0; + auto t = pr.tween(); + if (t <= 0) { + removeTweens(note, ¬e.volume); + note.volume = vol; + } else startTween(note, ¬e.volume, vol, shortStep, t); + break; + } + case 'p': { + double pan = std::clamp((1.0*static_cast(v)) / 127.0, -1.0, 1.0); + auto t = pr.tween(); + if (t <= 0) { + removeTweens(note, ¬e.pan); + note.pan = pan; + } else startTween(note, ¬e.pan, pan, shortStep, t); + break; + } + case 'g': // g/G - glissando + [[fallthrough]]; + case 'G': { + double nd = v; + if (p == 'g') nd *= -1.0; + auto t = pr.tween(); + if (t <= 0) { + removeTweens(note, ¬e.note); + note.note += nd; + } else startTween(note, ¬e.note, note.note + nd, shortStep, t); + break; + } + default: + break; + } } } - mi += 5 + i->data[mi+4]*2; } // then do the thing @@ -170,17 +225,16 @@ void InstrumentCore::removeTweens(InstrumentCore::Note& n, double* op) { } void InstrumentCore::removeTweens(InstrumentCore::Note& n) { activeTweens.erase(n.id); } -void InstrumentCore::startTween(InstrumentCore::Note& n, double* op, double val, double time, int16_t ticks) { +Tween& InstrumentCore::startTween(InstrumentCore::Note& n, double* op, double val, double time, int16_t ticks) { // remove anything already operating on the same note removeTweens(n, op); - activeTweens.emplace(std::make_pair(n.id, Tween(n, op, val, time, ticks))); + auto it = activeTweens.emplace(std::make_pair(n.id, Tween(n, op, val, time, ticks))); + return it->second; } double Note::ampMult() const { - double a = adsrVol(adsr, adsrPhase, adsrTime); - a *= a; - a *= a; - return a; + double a = adsrVol(adsr, adsrPhase, adsrTime) * volume; + return a*a; // most synthesizers use a curve of 40log(vol) dB... which simplifies to vol^2 } Tween::Tween(InstrumentCore::Note& n, double* op, double val, double time, int16_t ticks) { @@ -205,7 +259,9 @@ void Tween::startTick(Note& n, double tickTime) { } void Tween::process(Note& n) { + if (flags & 1) return; // already done if (timeEnd == timeStart) { + *op = valEnd; // instant flags |= 1; return; } diff --git a/xybrid/nodelib/instrumentcore.h b/xybrid/nodelib/instrumentcore.h index 22503cc..dd085e7 100644 --- a/xybrid/nodelib/instrumentcore.h +++ b/xybrid/nodelib/instrumentcore.h @@ -34,6 +34,9 @@ namespace Xybrid::NodeLib { double note; // floating point to allow smooth pitch bends double time = 0; + double volume = 1.0; + double pan = 0.0; + ADSR adsr; double adsrTime = 0; @@ -93,7 +96,7 @@ namespace Xybrid::NodeLib { void removeTweens(Note&, double*); /// Removes all tweens matching the specified note. void removeTweens(Note&); - void startTween(Note&, double*, double val, double time, int16_t ticks = -1); + Tween& startTween(Note&, double*, double val, double time, int16_t ticks = -1); }; } diff --git a/xybrid/nodes/gadget/gainbalance.cpp b/xybrid/nodes/gadget/gainbalance.cpp index b01c8de..75056a7 100644 --- a/xybrid/nodes/gadget/gainbalance.cpp +++ b/xybrid/nodes/gadget/gainbalance.cpp @@ -2,6 +2,9 @@ using Xybrid::Gadgets::GainBalance; using namespace Xybrid::Data; +#include "nodelib/basics.h" +using namespace Xybrid::NodeLib; + #include "data/porttypes.h" #include "config/pluginregistry.h" @@ -41,17 +44,14 @@ void GainBalance::init() { } void GainBalance::process() { // TODO: lerp from tick to tick? - const double PI = std::atan(1)*4; - const double M = 1.0 / std::cos(PI * 0.25); - double g = gain.load(); double b = balance.load(); // calculate multipliers double gm = std::pow(10.0, g / 20.0); // dBFS double s = (b+1.0) * PI * 0.25; - double lm = std::cos(s) * M; - double rm = std::sin(s) * M; + double lm = std::cos(s) * PAN_MULT; + double rm = std::sin(s) * PAN_MULT; auto in = std::static_pointer_cast(port(Port::Input, Port::Audio, 0)); auto out = std::static_pointer_cast(port(Port::Output, Port::Audio, 0)); diff --git a/xybrid/nodes/instrument/2x03.cpp b/xybrid/nodes/instrument/2x03.cpp index 5c65f08..ccda000 100644 --- a/xybrid/nodes/instrument/2x03.cpp +++ b/xybrid/nodes/instrument/2x03.cpp @@ -152,8 +152,11 @@ void I2x03::init() { } smp *= note.ampMult(); note.scratch[0] += smpTime * freq; - p->bufL[i] += static_cast(smp); - p->bufR[i] += static_cast(smp); + auto pn = panSignal(smp, note.pan); + p->bufL[i] += pn.first; + p->bufR[i] += pn.second; + //p->bufL[i] += static_cast(smp); + //p->bufR[i] += static_cast(smp); } }; }