Skip to content

Commit 6538579

Browse files
committed
Add device/effect copy and effect gallery import
- Copy an existing device or effect directly when creating a new one: - "Copy Device..." in the Device Gallery duplicates another device into the slot; the picker lists devices with the track(s) they are used on. - "Copy Effect..." in the Effects Gallery duplicates another effect in the same rack. - Add direct "Import Effect..." to the Effects Gallery, matching the existing device import. - Copies are in-memory clones via the existing serialize/deserialize paths (parameter clone for effects, kept in the domain layer).
1 parent dec2aa1 commit 6538579

24 files changed

Lines changed: 610 additions & 1 deletion

CHANGELOG

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ Release date:
55

66
New features:
77

8+
* Copy an existing device or effect when creating a new one
9+
- "Copy Device..." in the Device Gallery duplicates another device into the slot
10+
- The device list shows the track(s) each device is used on
11+
- "Copy Effect..." in the Effects Gallery duplicates another effect in the same rack
12+
- Add direct "Import Effect..." to the Effects Gallery, matching device import
13+
814
* Add a stereo oscilloscope to the Synth
915
- Live L/R waveform traces side by side on a new "Scope" tab
1016
- Reusable component that can later be added to other device dialogs

src/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ set(QML_SOURCE_FILES
5858
${QML_BASE_DIR}/Dialogs/ColumnSettingsDialog_TimingSettings.qml
5959
${QML_BASE_DIR}/Dialogs/CompressorDialog.qml
6060
${QML_BASE_DIR}/Dialogs/ConfirmationDialog.qml
61+
${QML_BASE_DIR}/Dialogs/CopyDeviceDialog.qml
62+
${QML_BASE_DIR}/Dialogs/CopyEffectDialog.qml
6163
${QML_BASE_DIR}/Dialogs/DelayCalculatorDialog.qml
6264
${QML_BASE_DIR}/Dialogs/DelayDialog.qml
6365
${QML_BASE_DIR}/Dialogs/DeleteUnusedPatternsDialog.qml

src/application/service/device_service.cpp

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,8 +598,11 @@ bool DeviceService::importDeviceSettings(int slotIndex, const QString & filePath
598598
if (!file.open(QIODevice::ReadOnly)) {
599599
return false;
600600
}
601-
const auto xml = QString::fromUtf8(file.readAll());
601+
return importDeviceSettingsFromXml(slotIndex, QString::fromUtf8(file.readAll()));
602+
}
602603

604+
bool DeviceService::importDeviceSettingsFromXml(int slotIndex, const QString & xml)
605+
{
603606
// Extract embedded data before deserializing the device so that a Sampler can resolve its
604607
// nahd:// sample paths while loading. In the file the <Device> element precedes the <Data>
605608
// blocks, so a single streaming pass would try to load samples before they are extracted.
@@ -617,6 +620,23 @@ bool DeviceService::importDeviceSettings(int slotIndex, const QString & filePath
617620
return importDeviceSettings(slotIndex, reader);
618621
}
619622

623+
bool DeviceService::copyDevice(int sourceSlot, int targetSlot)
624+
{
625+
if (sourceSlot == targetSlot || !device(static_cast<size_t>(sourceSlot))) {
626+
return false;
627+
}
628+
629+
QString xml;
630+
{
631+
NahdXmlWriter writer { xml };
632+
if (!exportDeviceSettings(sourceSlot, writer)) {
633+
return false;
634+
}
635+
}
636+
637+
return importDeviceSettingsFromXml(targetSlot, xml);
638+
}
639+
620640
bool DeviceService::importDeviceSettings(int slotIndex, ProjectReader & reader)
621641
{
622642
while (!reader.atEnd() && !reader.hasError()) {

src/application/service/device_service.hpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ class DeviceService : public QObject
8888
Q_INVOKABLE bool importDeviceSettings(int slotIndex, const QString & filePath);
8989
bool importDeviceSettings(int slotIndex, ProjectReader & reader);
9090

91+
//! Duplicate the device in sourceSlot into targetSlot (in-memory clone). Returns false if the
92+
//! source slot is empty or source and target are the same slot.
93+
bool copyDevice(int sourceSlot, int targetSlot);
94+
9195
struct DeviceTypeInfo
9296
{
9397
QString typeId;
@@ -109,6 +113,8 @@ class DeviceService : public QObject
109113
void synthUserPresetsChanged(const UserPresets & presets);
110114

111115
private:
116+
bool importDeviceSettingsFromXml(int slotIndex, const QString & xml);
117+
112118
DeviceService::DeviceS getDevice(std::string name, std::string typeId);
113119

114120
std::shared_ptr<SynthDevice> findFirstSynthDevice() const;

src/domain/effects/effect_rack.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,4 +361,34 @@ bool EffectRack::importEffectSettings(size_t index, ProjectReader & reader)
361361
return false;
362362
}
363363

364+
bool EffectRack::copyEffect(size_t sourceIndex, size_t targetIndex)
365+
{
366+
const std::lock_guard<std::recursive_mutex> lock { m_mutex };
367+
if (sourceIndex == targetIndex || sourceIndex >= m_effects.size() || !m_effects[sourceIndex]) {
368+
return false;
369+
}
370+
371+
const auto & source = m_effects[sourceIndex];
372+
auto clone = EffectFactory::createEffect(source->typeId(), source->type());
373+
if (!clone) {
374+
return false;
375+
}
376+
377+
clone->setEnabled(source->enabled());
378+
for (const auto & [name, parameter] : source->parameters()) {
379+
if (const auto target = clone->parameter(name); target) {
380+
target->get().update(parameter.value());
381+
}
382+
}
383+
clone->sync();
384+
385+
if (targetIndex >= m_effects.size()) {
386+
m_effects.resize(targetIndex + 1, nullptr);
387+
}
388+
m_effects[targetIndex] = std::move(clone);
389+
markChanged();
390+
391+
return true;
392+
}
393+
364394
} // namespace noteahead

src/domain/effects/effect_rack.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ class EffectRack
7272
bool exportEffectSettings(size_t index, ProjectWriter & writer) const;
7373
bool importEffectSettings(size_t index, ProjectReader & reader);
7474

75+
//! Duplicate the effect in sourceIndex into targetIndex (in-memory clone). Returns false if the
76+
//! source slot is empty or source and target are the same slot.
77+
bool copyEffect(size_t sourceIndex, size_t targetIndex);
78+
7579
private:
7680
void markChanged();
7781

src/unit_tests/device_rack_controller_test/device_rack_controller_test.cpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,45 @@ void DeviceRackControllerTest::test_confirmImportSettings_shouldImportAndNotify(
413413
QCOMPARE(synth->volume(), 0.42f);
414414
}
415415

416+
void DeviceRackControllerTest::test_copyDevice_shouldDuplicateAndNotify()
417+
{
418+
const auto audioEngine = std::make_shared<AudioEngine>();
419+
const auto deviceService = std::make_shared<DeviceService>(audioEngine, std::make_shared<DataService>());
420+
const auto editorService = std::make_shared<MockEditorService>();
421+
422+
const auto synth = std::dynamic_pointer_cast<SynthDevice>(DeviceFactory::createDevice(SynthDevice::typeIdString(), "TestSynth"));
423+
synth->setVolume(0.42f);
424+
deviceService->setDevice(0, synth);
425+
426+
DeviceRackController controller { deviceService, {}, editorService };
427+
QSignalSpy revisionSpy { &controller, &DeviceRackController::revisionChanged };
428+
429+
controller.copyDevice(0, 1);
430+
431+
QVERIFY(revisionSpy.count() > 0);
432+
QVERIFY(editorService->isModified());
433+
const auto copy = std::dynamic_pointer_cast<SynthDevice>(deviceService->device(1));
434+
QVERIFY(copy != nullptr);
435+
QCOMPARE(copy->volume(), 0.42f);
436+
}
437+
438+
void DeviceRackControllerTest::test_populatedDevices_shouldReturnOnlyFilledSlots()
439+
{
440+
const auto audioEngine = std::make_shared<AudioEngine>();
441+
const auto deviceService = std::make_shared<DeviceService>(audioEngine, std::make_shared<DataService>());
442+
const auto editorService = std::make_shared<MockEditorService>();
443+
444+
deviceService->setDevice(0, DeviceFactory::createDevice(SynthDevice::typeIdString(), "TestSynth"));
445+
deviceService->setDevice(2, DeviceFactory::createDevice(SamplerDevice::typeIdString(), "TestSampler"));
446+
447+
DeviceRackController controller { deviceService, {}, editorService };
448+
449+
const auto populated = controller.populatedDevices();
450+
QCOMPARE(populated.size(), 2);
451+
QCOMPARE(populated.at(0).toMap()["slotIndex"].toInt(), 0);
452+
QCOMPARE(populated.at(1).toMap()["slotIndex"].toInt(), 2);
453+
}
454+
416455
} // namespace noteahead
417456

418457
QTEST_GUILESS_MAIN(noteahead::DeviceRackControllerTest)

src/unit_tests/device_rack_controller_test/device_rack_controller_test.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ private slots:
3838
void test_importSettings_matchingType_shouldEmitConfirmationWithoutMismatch();
3939
void test_importSettings_differentType_shouldEmitConfirmationWithMismatch();
4040
void test_confirmImportSettings_shouldImportAndNotify();
41+
void test_copyDevice_shouldDuplicateAndNotify();
42+
void test_populatedDevices_shouldReturnOnlyFilledSlots();
4143
};
4244

4345
} // namespace noteahead

src/unit_tests/device_service_test/device_service_test.cpp

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,64 @@ void DeviceServiceTest::test_importDeviceSettings_emptySlot_shouldCreateDeviceFr
246246
QCOMPARE(synth->volume(), 0.75f);
247247
}
248248

249+
void DeviceServiceTest::test_copyDevice_shouldDuplicateParametersIntoTargetSlot()
250+
{
251+
const auto audioEngine = std::make_shared<AudioEngine>();
252+
const auto dataService = std::make_shared<DataService>();
253+
DeviceService service { audioEngine, dataService };
254+
255+
const auto synth = std::dynamic_pointer_cast<SynthDevice>(DeviceFactory::createDevice(SynthDevice::typeIdString(), "TestSynth"));
256+
synth->setVolume(0.75f);
257+
service.setDevice(0, synth);
258+
259+
QVERIFY(!service.device(1));
260+
QVERIFY(service.copyDevice(0, 1));
261+
262+
const auto copy = std::dynamic_pointer_cast<SynthDevice>(service.device(1));
263+
QVERIFY(copy);
264+
QCOMPARE(copy->typeId(), SynthDevice::typeIdString());
265+
QCOMPARE(copy->volume(), 0.75f);
266+
// The copy is an independent instance, not the same shared pointer.
267+
QVERIFY(copy != synth);
268+
// The source is left untouched.
269+
QCOMPARE(service.device(0), synth);
270+
}
271+
272+
void DeviceServiceTest::test_copyDevice_differentType_shouldReplaceTargetDevice()
273+
{
274+
const auto audioEngine = std::make_shared<AudioEngine>();
275+
const auto dataService = std::make_shared<DataService>();
276+
DeviceService service { audioEngine, dataService };
277+
278+
service.setDevice(0, DeviceFactory::createDevice(SynthDevice::typeIdString(), "TestSynth"));
279+
service.setDevice(1, DeviceFactory::createDevice(SamplerDevice::typeIdString(), "TestSampler"));
280+
281+
QCOMPARE(service.device(1)->typeId(), SamplerDevice::typeIdString());
282+
QVERIFY(service.copyDevice(0, 1));
283+
QCOMPARE(service.device(1)->typeId(), SynthDevice::typeIdString());
284+
}
285+
286+
void DeviceServiceTest::test_copyDevice_emptySource_shouldFail()
287+
{
288+
const auto audioEngine = std::make_shared<AudioEngine>();
289+
const auto dataService = std::make_shared<DataService>();
290+
DeviceService service { audioEngine, dataService };
291+
292+
QVERIFY(!service.device(0));
293+
QVERIFY(!service.copyDevice(0, 1));
294+
QVERIFY(!service.device(1));
295+
}
296+
297+
void DeviceServiceTest::test_copyDevice_sameSlot_shouldFail()
298+
{
299+
const auto audioEngine = std::make_shared<AudioEngine>();
300+
const auto dataService = std::make_shared<DataService>();
301+
DeviceService service { audioEngine, dataService };
302+
303+
service.setDevice(0, DeviceFactory::createDevice(SynthDevice::typeIdString(), "TestSynth"));
304+
QVERIFY(!service.copyDevice(0, 0));
305+
}
306+
249307
void DeviceServiceTest::test_exportImport_withEmbeddedData_shouldWork()
250308
{
251309
const auto audioEngine = std::make_shared<AudioEngine>();

src/unit_tests/device_service_test/device_service_test.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ private slots:
3232
void test_importDeviceSettings_shouldRestoreParameters();
3333
void test_importDeviceSettings_shouldReplaceDeviceIfTypeDiffers();
3434
void test_importDeviceSettings_emptySlot_shouldCreateDeviceFromFile();
35+
void test_copyDevice_shouldDuplicateParametersIntoTargetSlot();
36+
void test_copyDevice_differentType_shouldReplaceTargetDevice();
37+
void test_copyDevice_emptySource_shouldFail();
38+
void test_copyDevice_sameSlot_shouldFail();
3539
void test_exportImport_withEmbeddedData_shouldWork();
3640
void test_importDeviceSettings_embeddedData_emptySlot_shouldExtractDataBeforeLoadingSamples();
3741
void test_peekDeviceTypeInfo_synth_shouldReturnCorrectTypeInfo();

0 commit comments

Comments
 (0)