Skip to content

Commit 15af83b

Browse files
committed
Implement random modulation for MIDI CC automations
1 parent a20982b commit 15af83b

18 files changed

Lines changed: 248 additions & 26 deletions

CHANGELOG

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ New features:
77

88
* Apply track cut/copy/paste also for MIDI CC and PB automations
99

10+
* Implement Random modulation for MIDI CC automations
11+
1012
Bug fixes:
1113

1214
Other:

src/application/models/midi_cc_automations_model.cpp

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ QVariant MidiCcAutomationsModel::data(const QModelIndex & index, int role) const
146146
return midiCcAutomation.modulation().offset;
147147
case DataRole::Modulation_Sine_Inverted:
148148
return midiCcAutomation.modulation().inverted;
149+
case DataRole::Modulation_Type:
150+
return static_cast<int>(midiCcAutomation.modulation().type);
149151
case DataRole::EventsPerBeat:
150152
return static_cast<quint64>(midiCcAutomation.eventsPerBeat() > 0 ? midiCcAutomation.eventsPerBeat() : m_linesPerBeat);
151153
case DataRole::LineOffset:
@@ -244,6 +246,14 @@ bool MidiCcAutomationsModel::setData(const QModelIndex & index, const QVariant &
244246
changed = true;
245247
}
246248
} break;
249+
case DataRole::Modulation_Type: {
250+
auto modulation = midiCcAutomation.modulation();
251+
if (const auto newType = static_cast<MidiCcAutomation::ModulationParameters::ModulationType>(value.toInt()); modulation.type != newType) {
252+
modulation.type = newType;
253+
midiCcAutomation.setModulation(modulation);
254+
changed = true;
255+
}
256+
} break;
247257
case DataRole::EventsPerBeat: {
248258
const auto newEventsPerBeat = static_cast<uint8_t>(value.toUInt());
249259
const auto targetValue = newEventsPerBeat == m_linesPerBeat ? 0 : newEventsPerBeat;
@@ -313,6 +323,7 @@ QHash<int, QByteArray> MidiCcAutomationsModel::roleNames() const
313323
{ static_cast<int>(DataRole::Modulation_Sine_Amplitude), "modulationSineAmplitude" },
314324
{ static_cast<int>(DataRole::Modulation_Sine_Offset), "modulationSineOffset" },
315325
{ static_cast<int>(DataRole::Modulation_Sine_Inverted), "modulationSineInverted" },
326+
{ static_cast<int>(DataRole::Modulation_Type), "modulationType" },
316327
{ static_cast<int>(DataRole::EventsPerBeat), "eventsPerBeat" },
317328
{ static_cast<int>(DataRole::LineOffset), "lineOffset" }
318329
};
@@ -348,7 +359,7 @@ void MidiCcAutomationsModel::applyAll()
348359
void MidiCcAutomationsModel::changeController(int index, quint8 controller)
349360
{
350361
if (index >= 0 && static_cast<size_t>(index) < m_midiCcAutomations.size()) {
351-
auto& midiCcAutomation = m_midiCcAutomations[static_cast<size_t>(index)];
362+
auto && midiCcAutomation = m_midiCcAutomations[static_cast<size_t>(index)];
352363
if (midiCcAutomation.controller() != controller) {
353364
midiCcAutomation.setController(controller);
354365
m_midiCcAutomationsChanged.erase(midiCcAutomation);
@@ -359,4 +370,20 @@ void MidiCcAutomationsModel::changeController(int index, quint8 controller)
359370
}
360371
}
361372

373+
void MidiCcAutomationsModel::changeModulationType(int index, int type)
374+
{
375+
if (index >= 0 && static_cast<size_t>(index) < m_midiCcAutomations.size()) {
376+
auto && midiCcAutomation = m_midiCcAutomations[static_cast<size_t>(index)];
377+
auto modulation = midiCcAutomation.modulation();
378+
if (static_cast<int>(modulation.type) != type) {
379+
modulation.type = static_cast<MidiCcAutomation::ModulationParameters::ModulationType>(type);
380+
midiCcAutomation.setModulation(modulation);
381+
m_midiCcAutomationsChanged.erase(midiCcAutomation);
382+
m_midiCcAutomationsChanged.insert(midiCcAutomation);
383+
emit dataChanged(this->index(index), this->index(index), { static_cast<int>(DataRole::Modulation_Type) });
384+
juzzlin::L(TAG).info() << "MIDI CC automation modulation type changed via invokable: " << midiCcAutomation.toString().toStdString();
385+
}
386+
}
387+
}
388+
362389
} // namespace noteahead

src/application/models/midi_cc_automations_model.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class MidiCcAutomationsModel : public QAbstractListModel
4949
Modulation_Sine_Amplitude,
5050
Modulation_Sine_Offset,
5151
Modulation_Sine_Inverted,
52+
Modulation_Type,
5253
EventsPerBeat,
5354
LineOffset
5455
};
@@ -75,6 +76,7 @@ class MidiCcAutomationsModel : public QAbstractListModel
7576

7677
Q_INVOKABLE void applyAll();
7778
Q_INVOKABLE void changeController(int index, quint8 controller);
79+
Q_INVOKABLE void changeModulationType(int index, int type);
7880

7981
signals:
8082
void midiCcAutomationChanged(const MidiCcAutomation & midiCcAutomation);

src/application/service/automation_service.cpp

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
#include <algorithm>
2626
#include <cmath> // For std::sin and M_PI
27+
#include <random>
2728
#include <ranges>
2829

2930
#include <QXmlStreamReader>
@@ -76,13 +77,13 @@ void AutomationService::addMidiCcAutomationWithId(const MidiCcAutomation & autom
7677
juzzlin::L(TAG).info() << "MIDI CC Automation added (with ID): " << automation.toString().toStdString();
7778
}
7879

79-
void AutomationService::addMidiCcModulation(quint64 automationId, quint64 cycles, float amplitude, float offset, bool inverted)
80+
void AutomationService::addMidiCcModulation(quint64 automationId, int type, quint64 cycles, float amplitude, float offset, bool inverted)
8081
{
8182
if (const auto iter = std::ranges::find_if(m_automations.midiCc, [&](auto && existingAutomation) {
8283
return automationId == existingAutomation.id();
8384
});
8485
iter != m_automations.midiCc.end()) {
85-
MidiCcAutomation::ModulationParameters modulation { static_cast<float>(cycles), amplitude, offset, inverted };
86+
MidiCcAutomation::ModulationParameters modulation { static_cast<MidiCcAutomation::ModulationParameters::ModulationType>(type), static_cast<float>(cycles), amplitude, offset, inverted };
8687
iter->setModulation(modulation);
8788
notifyChangedLines(*iter);
8889
juzzlin::L(TAG).info() << "MIDI CC Modulation added to automation id " << automationId;
@@ -388,10 +389,15 @@ AutomationService::EventList AutomationService::renderMidiCcToEventsByLine(size_
388389
double interpolatedValue = interpolator.getValue(static_cast<size_t>(line));
389390

390391
double totalModulation = 0.0;
391-
if (modulation.cycles > 0.f && modulation.amplitude > 0.f) {
392+
if (modulation.cycles > 0.f || modulation.amplitude > 0.f) {
392393
const double phase = interpolation.line1 > interpolation.line0 ? static_cast<double>(line - interpolation.line0) / (static_cast<double>(interpolation.line1 - interpolation.line0)) : 0;
393-
const double sineWave = (modulation.inverted ? -1 : 1) * std::sin(phase * modulation.cycles * 2 * M_PI);
394-
totalModulation = sineWave * modulation.amplitude / 100.0; // Amplitude is a percentage
394+
double modulationValue = 0.0;
395+
if (modulation.type == MidiCcAutomation::ModulationParameters::ModulationType::SineWave) {
396+
modulationValue = sineModulationValue(modulation, phase);
397+
} else if (modulation.type == MidiCcAutomation::ModulationParameters::ModulationType::Random) {
398+
modulationValue = randomModulationValue(automation.id(), modulation, phase);
399+
}
400+
totalModulation = modulationValue * modulation.amplitude / 100.0; // Amplitude is a percentage
395401
}
396402
totalModulation += modulation.offset / 100.0;
397403
interpolatedValue += interpolatedValue * totalModulation;
@@ -405,6 +411,22 @@ AutomationService::EventList AutomationService::renderMidiCcToEventsByLine(size_
405411
return events;
406412
}
407413

414+
double AutomationService::sineModulationValue(const MidiCcAutomation::ModulationParameters & modulation, double phase) const
415+
{
416+
return (modulation.inverted ? -1 : 1) * std::sin(phase * modulation.cycles * 2 * M_PI);
417+
}
418+
419+
double AutomationService::randomModulationValue(size_t automationId, const MidiCcAutomation::ModulationParameters & modulation, double phase) const
420+
{
421+
const int sampleIndex = static_cast<int>(std::floor(phase * modulation.cycles));
422+
if (sampleIndex < modulation.cycles) {
423+
std::mt19937 gen(static_cast<uint32_t>(automationId + sampleIndex));
424+
std::uniform_real_distribution<double> dist(-1.0, 1.0);
425+
return (modulation.inverted ? -1 : 1) * dist(gen);
426+
}
427+
return 0.0; // Return to 0
428+
}
429+
408430
AutomationService::EventList AutomationService::renderPitchBendToEventsByLine(size_t pattern, size_t track, size_t column, size_t line, size_t tick) const
409431
{
410432
EventList events;
@@ -484,8 +506,13 @@ AutomationService::EventList AutomationService::renderMidiCcToEventsByColumn(siz
484506
double totalModulation = 0.0;
485507
if (modulation.cycles > 0.f || modulation.amplitude > 0.f) {
486508
const double phase = interpolation.line1 > interpolation.line0 ? static_cast<double>(line - interpolation.line0) / (static_cast<double>(interpolation.line1 - interpolation.line0)) : 0;
487-
const double sineWave = (modulation.inverted ? -1 : 1) * std::sin(phase * modulation.cycles * 2 * M_PI);
488-
totalModulation = sineWave * modulation.amplitude / 100.0;
509+
double modulationValue = 0.0;
510+
if (modulation.type == MidiCcAutomation::ModulationParameters::ModulationType::SineWave) {
511+
modulationValue = sineModulationValue(modulation, phase);
512+
} else if (modulation.type == MidiCcAutomation::ModulationParameters::ModulationType::Random) {
513+
modulationValue = randomModulationValue(automation.id(), modulation, phase);
514+
}
515+
totalModulation = modulationValue * modulation.amplitude / 100.0;
489516
}
490517
totalModulation += modulation.offset / 100.0;
491518
interpolatedValue += interpolatedValue * totalModulation;

src/application/service/automation_service.hpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class AutomationService : public QObject
5353
Q_INVOKABLE quint64 addMidiCcAutomation(quint64 pattern, quint64 track, quint64 column, quint8 controller, quint64 line0, quint64 line1, quint8 value0, quint8 value1, QString comment, quint8 eventsPerBeat, quint8 lineOffset);
5454
quint64 addMidiCcAutomation(const MidiCcAutomation & automation);
5555
void addMidiCcAutomationWithId(const MidiCcAutomation & automation);
56-
Q_INVOKABLE void addMidiCcModulation(quint64 automationId, quint64 cycles, float amplitude, float offset, bool inverted);
56+
Q_INVOKABLE void addMidiCcModulation(quint64 automationId, int type, quint64 cycles, float amplitude, float offset, bool inverted);
5757

5858
//! Adds automation for MIDI pitch bend.
5959
//! \param value0 Start value from -100% to +100%.
@@ -111,6 +111,9 @@ public slots:
111111
EventList renderMidiCcToEventsByColumn(size_t pattern, size_t track, size_t column, size_t tick, size_t ticksPerLine, size_t linesPerBeat) const;
112112
EventList renderPitchBendToEventsByColumn(size_t pattern, size_t track, size_t column, size_t tick, size_t ticksPerLine) const;
113113

114+
double sineModulationValue(const MidiCcAutomation::ModulationParameters & modulation, double phase) const;
115+
double randomModulationValue(size_t automationId, const MidiCcAutomation::ModulationParameters & modulation, double phase) const;
116+
114117
struct Automations
115118
{
116119
MidiCcAutomationList midiCc;

src/common/constants.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,16 @@ QString xmlValueTrue()
604604
return "true";
605605
}
606606

607+
QString xmlValueSineWave()
608+
{
609+
return "SineWave";
610+
}
611+
612+
QString xmlValueRandom()
613+
{
614+
return "Random";
615+
}
616+
607617
} // namespace NahdXml
608618

609619
} // namespace noteahead::Constants

src/common/constants.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ QString xmlKeySideChainSettings();
182182

183183
QString xmlValueFalse();
184184
QString xmlValueTrue();
185+
QString xmlValueSineWave();
186+
QString xmlValueRandom();
185187

186188
} // namespace NahdXml
187189
} // namespace noteahead::Constants

src/domain/midi_cc_automation.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ void MidiCcAutomation::serializeToXml(QXmlStreamWriter & writer) const
154154

155155
if (m_modulation.cycles > 0.f || m_modulation.amplitude > 0.f || m_modulation.offset != 0.f) {
156156
writer.writeStartElement(Constants::NahdXml::xmlKeyModulation());
157+
writer.writeAttribute(Constants::NahdXml::xmlKeyType(), m_modulation.type == ModulationParameters::ModulationType::SineWave ? Constants::NahdXml::xmlValueSineWave() : Constants::NahdXml::xmlValueRandom());
157158
writer.writeAttribute(Constants::NahdXml::xmlKeyCycles(), QString::number(static_cast<int>(m_modulation.cycles)));
158159
writer.writeAttribute(Constants::NahdXml::xmlKeyAmplitude(), QString::number(static_cast<int>(m_modulation.amplitude)));
159160
writer.writeAttribute(Constants::NahdXml::xmlKeyOffset(), QString::number(static_cast<int>(m_modulation.offset)));
@@ -188,6 +189,16 @@ MidiCcAutomation::MidiCcAutomationU MidiCcAutomation::deserializeFromXml(QXmlStr
188189
interpolationParameters.value1 = static_cast<quint8>(attributes.value(Constants::NahdXml::xmlKeyValue1()).toUInt());
189190
} else if (!reader.name().compare(Constants::NahdXml::xmlKeyModulation())) {
190191
const auto attributes = reader.attributes();
192+
if (attributes.hasAttribute(Constants::NahdXml::xmlKeyType())) {
193+
const auto type = attributes.value(Constants::NahdXml::xmlKeyType());
194+
if (!type.compare(Constants::NahdXml::xmlValueRandom())) {
195+
modulationParameters.type = ModulationParameters::ModulationType::Random;
196+
} else {
197+
modulationParameters.type = ModulationParameters::ModulationType::SineWave;
198+
}
199+
} else {
200+
modulationParameters.type = ModulationParameters::ModulationType::SineWave;
201+
}
191202
modulationParameters.cycles = static_cast<float>(attributes.value(Constants::NahdXml::xmlKeyCycles()).toInt());
192203
modulationParameters.amplitude = static_cast<float>(attributes.value(Constants::NahdXml::xmlKeyAmplitude()).toInt());
193204
modulationParameters.offset = static_cast<float>(attributes.value(Constants::NahdXml::xmlKeyOffset()).toInt());

src/domain/midi_cc_automation.hpp

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,21 @@ class MidiCcAutomation : public Automation
4949

5050
struct ModulationParameters
5151
{
52+
enum class ModulationType
53+
{
54+
SineWave,
55+
Random
56+
};
57+
58+
ModulationType type = ModulationType::SineWave;
5259
float cycles = 0.f;
5360
float amplitude = 0.f;
5461
float offset = 0.f;
5562
bool inverted = false;
5663

5764
bool operator==(const ModulationParameters & other) const
5865
{
59-
return cycles == other.cycles && amplitude == other.amplitude && offset == other.offset && inverted == other.inverted;
66+
return type == other.type && cycles == other.cycles && amplitude == other.amplitude && offset == other.offset && inverted == other.inverted;
6067
}
6168

6269
bool operator!=(const ModulationParameters & other) const

src/unit_tests/automation_service_test/automation_service_test.cpp

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ void AutomationServiceTest::test_renderMidiCcToEventsByLine_withModulation_shoul
267267
quint8 value0 = 64;
268268
quint8 value1 = 64;
269269
const auto automationId = automationService.addMidiCcAutomation(pattern, track, column, controller, line0, line1, value0, value1, {}, true, 8, 0);
270-
automationService.addMidiCcModulation(automationId, 1, 50.0f, 0.0f, false);
270+
automationService.addMidiCcModulation(automationId, 0, 1, 50.0f, 0.0f, false);
271271

272272
const auto tick = 0;
273273
// Line 0: Base 64, Phase 0, Sine 0, Modulation 0
@@ -282,6 +282,39 @@ void AutomationServiceTest::test_renderMidiCcToEventsByLine_withModulation_shoul
282282
QCOMPARE(automationService.renderToEventsByLine(pattern, track, column, 4, tick).at(0)->midiCcData()->value(), 64);
283283
}
284284

285+
void AutomationServiceTest::test_renderMidiCcToEventsByLine_withRandomModulation_shouldRenderModulatedEvents()
286+
{
287+
AutomationService automationService;
288+
289+
quint64 pattern = 0;
290+
quint64 track = 1;
291+
quint64 column = 2;
292+
quint8 controller = 64;
293+
quint8 line0 = 0;
294+
quint8 line1 = 4;
295+
quint8 value0 = 64;
296+
quint8 value1 = 64;
297+
// ID will be 1
298+
const auto automationId = automationService.addMidiCcAutomation(pattern, track, column, controller, line0, line1, value0, value1, {}, true, 8, 0);
299+
// Type 1 = Random, 1 cycle, 100% amplitude
300+
automationService.addMidiCcModulation(automationId, 1, 1, 100.0f, 0.0f, false);
301+
302+
const auto tick = 0;
303+
// For automationId = 1 and sampleIndex = 0, std::mt19937 seeded with 1:
304+
// first value from dist(-1, 1) is roughly -0.131538
305+
// value = 64 + 64 * (-0.131538) = 64 - 8.4 = 55.6 -> 56
306+
const auto val0 = automationService.renderToEventsByLine(pattern, track, column, 0, tick).at(0)->midiCcData()->value();
307+
QVERIFY(val0 != 64);
308+
309+
// Should stay same for line 1, 2, 3 (within same cycle)
310+
QCOMPARE(automationService.renderToEventsByLine(pattern, track, column, 1, tick).at(0)->midiCcData()->value(), val0);
311+
QCOMPARE(automationService.renderToEventsByLine(pattern, track, column, 2, tick).at(0)->midiCcData()->value(), val0);
312+
QCOMPARE(automationService.renderToEventsByLine(pattern, track, column, 3, tick).at(0)->midiCcData()->value(), val0);
313+
314+
// Return to 0 at the end (line 4, phase 1.0)
315+
QCOMPARE(automationService.renderToEventsByLine(pattern, track, column, 4, tick).at(0)->midiCcData()->value(), 64);
316+
}
317+
285318
void AutomationServiceTest::test_renderMidiCcToEventsByLine_withInvertedModulation_shouldRenderModulatedEvents()
286319
{
287320
AutomationService automationService;
@@ -295,7 +328,7 @@ void AutomationServiceTest::test_renderMidiCcToEventsByLine_withInvertedModulati
295328
quint8 value0 = 64;
296329
quint8 value1 = 64;
297330
const auto automationId = automationService.addMidiCcAutomation(pattern, track, column, controller, line0, line1, value0, value1, {}, true, 8, 0);
298-
automationService.addMidiCcModulation(automationId, 1, 50.0f, 0.0f, true);
331+
automationService.addMidiCcModulation(automationId, 0, 1, 50.0f, 0.0f, true);
299332

300333
const auto tick = 0;
301334
// Line 0: Base 64, Phase 0, Sine 0, Modulation 0
@@ -325,13 +358,13 @@ void AutomationServiceTest::test_renderMidiCcToEventsByLine_withOffset_shouldRen
325358
const auto automationId = automationService.addMidiCcAutomation(pattern, track, column, controller, line0, line1, value0, value1, {}, true, 8, 0);
326359

327360
// Test positive offset (+50%)
328-
automationService.addMidiCcModulation(automationId, 0, 0.0f, 50.0f, false);
361+
automationService.addMidiCcModulation(automationId, 0, 0, 0.0f, 50.0f, false);
329362
const auto tick = 0;
330363
// 64 + 64 * 0.5 = 96
331364
QCOMPARE(automationService.renderToEventsByLine(pattern, track, column, 0, tick).at(0)->midiCcData()->value(), 96);
332365

333366
// Test negative offset (-50%)
334-
automationService.addMidiCcModulation(automationId, 0, 0.0f, -50.0f, false);
367+
automationService.addMidiCcModulation(automationId, 0, 0, 0.0f, -50.0f, false);
335368
// 64 + 64 * -0.5 = 32
336369
QCOMPARE(automationService.renderToEventsByLine(pattern, track, column, 0, tick).at(0)->midiCcData()->value(), 32);
337370
}

0 commit comments

Comments
 (0)