Summary
Selecting a camera in Powermap's "Display Window" dropdown frequently opens a different device than the one clicked, with no error. Sometimes that's a different-but-real device (the wrong feed just starts playing); just as often, nothing visibly happens at all - the previous device's last frame stays frozen on screen indefinitely, with no way to tell that the switch failed. Selecting "No camera" doesn't fully clear the display either - it blanks a small, wrongly-placed rectangle and leaves most of the previous frame visible underneath. Every case is silent: no error, no crash, no dialog - just the wrong picture, a frozen picture, or a half-cleared picture.
Environment
- SPARTA Powermap v1.5.3 (Build Jan 24 2026), VST3
- macOS, Apple Silicon
- REAPER 7.78
- Sources available: built-in webcam (FaceTime HD Camera), OBS Virtual Camera, an NDI virtual camera
Steps to reproduce
- Open REAPER, insert
sparta_powermap, open its editor.
- Start OBS's virtual camera and an NDI virtual camera source. Confirm both now appear in the "Display Window" dropdown alongside the built-in webcam - they do; the dropdown itself is not the problem.
- To make results legible, configure OBS and the NDI source to each output a distinct static test pattern (e.g. their own logos) instead of a live feed - that way every screenshot unambiguously shows which real device is actually delivering frames, independent of the dropdown's label.
- Click through the three camera entries repeatedly, in no particular order, watching what the preview actually shows versus what the dropdown claims is selected.
Expected: each selection opens the device just clicked, and "No camera" clears the preview.
Actual, across repeated trials against the same three-item list (NDI, FaceTime HD Camera, OBS Virtual Camera):
| Clicked |
Dropdown shows |
What actually displayed |
| NDI |
NDI |
NDI ✓ correct |
| FaceTime HD Camera |
FaceTime |
Nothing new - built-in camera's LED flashed briefly, no picture; NDI's frame stayed on screen |
| OBS Virtual Camera |
OBS |
FaceTime HD Camera's real feed - wrong device, but a clean, fully working switch |
| FaceTime HD Camera |
FaceTime |
FaceTime feed continues (can't tell from this alone if it re-opened or just never changed) |
| NDI |
NDI |
FaceTime feed stays on screen |
| OBS Virtual Camera |
OBS |
FaceTime feed stays on screen |
| NDI |
NDI |
OBS's pattern appears (a real change - from FaceTime to OBS) |
| OBS Virtual Camera |
OBS |
OBS's pattern stays on screen |
| FaceTime HD Camera |
FaceTime |
OBS's pattern stays on screen |
| NDI |
NDI |
NDI's pattern appears - correct again |
| FaceTime HD Camera |
FaceTime |
NDI's pattern stays on screen |
| OBS Virtual Camera |
OBS |
NDI's pattern stays on screen |
| NDI |
NDI |
FaceTime HD Camera's real feed opens - wrong device again |
| No camera |
No camera |
Only a small rectangle in the upper-left of the preview turns grey; the rest of the previous (FaceTime) frame remains fully visible |
No crash, no error, in any of these. Every device that opens is a real, working one - it's just frequently not the one clicked, or nothing opens at all and the display simply never updates.
Root cause
This turns out to be three separate defects in the same small area of code, which compound each other - the second and third are what make the first one so hard to notice, since they hide the evidence that a switch failed at all.
1. A stale positional index race between two independent enumerations
The dropdown is built from one enumeration, at plugin construction, never refreshed after:
// audio_plugins/_SPARTA_powermap_/src/PluginEditor.cpp:559-570
void PluginEditor::updateCameraList()
{
CB_webcam->clear();
CB_webcam->addItem("No camera", 1);
CB_webcam->addSeparator();
auto cameras = CameraDevice::getAvailableDevices();
for (int i = 0; i < cameras.size(); ++i)
CB_webcam->addItem(cameras[i], i + 2);
CB_webcam->setSelectedId(1);
}
A selection is resolved to a positional index only, then handed to JUCE's openDevice:
// audio_plugins/_SPARTA_powermap_/src/PluginEditor.cpp:536-545
void PluginEditor::cameraChanged()
{
cameraDevice.reset();
cameraPreviewComp.reset();
if (CB_webcam->getSelectedId() > 1)
cameraDeviceOpenResult (CameraDevice::openDevice (CB_webcam->getSelectedId() - 2), {});
else
resized();
}
CameraDevice::openDevice(index) performs its own, separate, fresh enumeration and indexes into that result to recover a device name - not the array updateCameraList() used:
// JUCE modules/juce_video/capture/juce_CameraDevice.cpp:203-212
// (identical at SPARTA's pinned JUCE commit 29396c22c93392d6738e021b83196283d6e4d850)
CameraDevice* CameraDevice::openDevice ([[maybe_unused]] int index, ...)
{
...
#if ! JUCE_ANDROID && ! JUCE_IOS
std::unique_ptr<CameraDevice> d (new CameraDevice (getAvailableDevices() [index], index,
minWidth, minHeight, maxWidth, maxHeight, useHighQuality));
The macOS backend then opens whichever real device's name matches the string it was handed:
// JUCE modules/juce_video/native/juce_CameraDevice_mac.h:444-450
// (identical at the same pinned commit)
void addInput()
{
if (currentInput == nil)
{
for (AVCaptureDevice* device : getCaptureDevices())
{
if (deviceName == nsStringToJuce ([device localizedName]))
So the numeric position chosen in step 2 is only meaningful if the enumeration in step 3 returns devices in the same order as the enumeration that built the dropdown, potentially much earlier. For fixed hardware that's reliable - a built-in camera's position doesn't move. For virtual/extension cameras it evidently isn't, and the trial log above shows it isn't even a stable rotation: the same clicked entry resolves to different real devices (or no device) from one attempt to the next. I have not instrumented AVCaptureDeviceDiscoverySession directly to confirm reordering is the specific OS-level mechanism - that's consistent with everything observed, not something proven with a logged before/after device list. The three-call structure above (build dropdown once; store a position; re-resolve that position independently, later) is confirmed directly from source and is sufficient on its own to explain a wrong-but-working device opening.
2. A failed switch is invisible - nothing invalidates the last frame
When the stale index resolves to a name that doesn't match any currently-enumerated device, openDevice() returns nullptr, and cameraDevice ends up null. From there, the picture-taking path silently no-ops every time it's asked to run:
// audio_plugins/_SPARTA_powermap_/src/PluginEditor.cpp:584-590
void PluginEditor::handleAsyncUpdate()
{
if (cameraDevice != nullptr){
SafePointer<PluginEditor> safeThis (this);
cameraDevice->takeStillPicture ([safeThis] (const Image& image) mutable { safeThis->imageReceived (image); });
}
}
No new frame ever arrives in that state, so incomingImage (last set in imageReceived(), PluginEditor.cpp:572-582) is never overwritten. But the timer that repaints the preview only checks whether the dropdown has something selected, not whether a device is actually open and delivering frames:
// audio_plugins/_SPARTA_powermap_/src/PluginEditor.cpp:448-449, 463-464
if(CB_webcam->getSelectedId()>1){
handleAsyncUpdate();
...
if (incomingImage.isValid())
lastSnapshot.setImage(incomingImage);
}
So every tick, it keeps repainting whatever incomingImage last held - the previous device's last good frame - forever, regardless of whether anything is actually open right now. A failed switch is visually indistinguishable from a successful one that just isn't updating; there is no error state, no blank frame, nothing to signal that cameraDevice is null. This is what the "stays on screen" rows in the trial log are: not a UI refresh bug, but a genuinely absent device whose failure is completely hidden.
3. "No camera" clears the wrong rectangle
Selecting "No camera" (id 1) tries to blank the preview explicitly:
// audio_plugins/_SPARTA_powermap_/src/PluginEditor.cpp:395-403
else if (comboBoxThatHasChanged == CB_webcam.get())
{
processor.setCameraID(CB_webcam->getSelectedId());
cameraChanged();
if(CB_webcam->getSelectedId()==1){
incomingImage.clear(previewArea);
lastSnapshot.setImage(incomingImage);
}
}
previewArea is declared as a Rectangle<int> (PluginEditor.h:80) and set with previewArea.setBounds(13, 60, 646, 323) (PluginEditor.cpp:182) - that's the editor window's layout rectangle used to position the preview component on screen, in the plugin editor's coordinate space. Image::clear() takes a rectangle in the image's own pixel coordinate space, which has no relationship to the editor's layout. Passing previewArea here clears an arbitrary, mismatched region of whatever incomingImage happens to contain - exactly the small, misplaced grey rectangle observed in testing, with the rest of the stale frame still fully visible around it.
Suggested fix
Three independent fixes, matching the three causes above:
For (1), resolve the device by name at selection time, from a fresh enumeration, rather than reusing a positional index computed against a stale snapshot:
// PluginEditor.cpp
void PluginEditor::cameraChanged()
{
cameraDevice.reset();
cameraPreviewComp.reset();
auto selectedId = CB_webcam->getSelectedId();
if (selectedId > 1)
{
auto cameras = CameraDevice::getAvailableDevices();
auto index = selectedId - 2;
if (isPositiveAndBelow (index, cameras.size()))
cameraDeviceOpenResult (CameraDevice::openDevice (index), {});
else
updateCameraList(); // list is stale relative to the OS; rebuild it
}
else
resized();
}
This doesn't eliminate the theoretical race (there's still a gap between this fresh enumeration and openDevice's own internal one), but it collapses the gap from "however long the plugin's been open" to "one function call," which should make it unobservable in practice.
For (2), explicitly invalidate the displayed frame whenever cameraDevice ends up null after an open attempt, rather than leaving incomingImage/lastSnapshot untouched - e.g. in cameraDeviceOpenResult, clear incomingImage and lastSnapshot when device == nullptr, and/or have timerCallback() guard on cameraDevice != nullptr rather than just the dropdown's selected id, so a failed switch produces a visibly blank preview instead of a frozen stale one.
For (3), clear the actual image buffer rather than a mismatched sub-rectangle of it - either incomingImage.clear(incomingImage.getBounds()), or more simply replace it wholesale (incomingImage = Image();) before updating lastSnapshot.
Also worth adding, independent of all three: re-running updateCameraList() when the dropdown is about to be shown (e.g. overriding showPopup), so a virtual camera started after the plugin loaded appears without closing and reopening the editor.
I only verified the macOS backend's name-matching in addInput() in this depth; I have not checked whether the Windows (DirectShow) or Linux backends have the same or a different vulnerability to the (1) race. Findings (2) and (3) are in shared, platform-independent PluginEditor.cpp code, so those should reproduce identically everywhere.
Happy to open a PR for these fixes - either together or split into separate PRs per cause, whichever's easier to review.
Summary
Selecting a camera in Powermap's "Display Window" dropdown frequently opens a different device than the one clicked, with no error. Sometimes that's a different-but-real device (the wrong feed just starts playing); just as often, nothing visibly happens at all - the previous device's last frame stays frozen on screen indefinitely, with no way to tell that the switch failed. Selecting "No camera" doesn't fully clear the display either - it blanks a small, wrongly-placed rectangle and leaves most of the previous frame visible underneath. Every case is silent: no error, no crash, no dialog - just the wrong picture, a frozen picture, or a half-cleared picture.
Environment
Steps to reproduce
sparta_powermap, open its editor.Expected: each selection opens the device just clicked, and "No camera" clears the preview.
Actual, across repeated trials against the same three-item list (NDI, FaceTime HD Camera, OBS Virtual Camera):
No crash, no error, in any of these. Every device that opens is a real, working one - it's just frequently not the one clicked, or nothing opens at all and the display simply never updates.
Root cause
This turns out to be three separate defects in the same small area of code, which compound each other - the second and third are what make the first one so hard to notice, since they hide the evidence that a switch failed at all.
1. A stale positional index race between two independent enumerations
The dropdown is built from one enumeration, at plugin construction, never refreshed after:
A selection is resolved to a positional index only, then handed to JUCE's
openDevice:CameraDevice::openDevice(index)performs its own, separate, fresh enumeration and indexes into that result to recover a device name - not the arrayupdateCameraList()used:The macOS backend then opens whichever real device's name matches the string it was handed:
So the numeric position chosen in step 2 is only meaningful if the enumeration in step 3 returns devices in the same order as the enumeration that built the dropdown, potentially much earlier. For fixed hardware that's reliable - a built-in camera's position doesn't move. For virtual/extension cameras it evidently isn't, and the trial log above shows it isn't even a stable rotation: the same clicked entry resolves to different real devices (or no device) from one attempt to the next. I have not instrumented
AVCaptureDeviceDiscoverySessiondirectly to confirm reordering is the specific OS-level mechanism - that's consistent with everything observed, not something proven with a logged before/after device list. The three-call structure above (build dropdown once; store a position; re-resolve that position independently, later) is confirmed directly from source and is sufficient on its own to explain a wrong-but-working device opening.2. A failed switch is invisible - nothing invalidates the last frame
When the stale index resolves to a name that doesn't match any currently-enumerated device,
openDevice()returnsnullptr, andcameraDeviceends up null. From there, the picture-taking path silently no-ops every time it's asked to run:No new frame ever arrives in that state, so
incomingImage(last set inimageReceived(),PluginEditor.cpp:572-582) is never overwritten. But the timer that repaints the preview only checks whether the dropdown has something selected, not whether a device is actually open and delivering frames:So every tick, it keeps repainting whatever
incomingImagelast held - the previous device's last good frame - forever, regardless of whether anything is actually open right now. A failed switch is visually indistinguishable from a successful one that just isn't updating; there is no error state, no blank frame, nothing to signal thatcameraDeviceis null. This is what the "stays on screen" rows in the trial log are: not a UI refresh bug, but a genuinely absent device whose failure is completely hidden.3. "No camera" clears the wrong rectangle
Selecting "No camera" (id 1) tries to blank the preview explicitly:
previewAreais declared as aRectangle<int>(PluginEditor.h:80) and set withpreviewArea.setBounds(13, 60, 646, 323)(PluginEditor.cpp:182) - that's the editor window's layout rectangle used to position the preview component on screen, in the plugin editor's coordinate space.Image::clear()takes a rectangle in the image's own pixel coordinate space, which has no relationship to the editor's layout. PassingpreviewAreahere clears an arbitrary, mismatched region of whateverincomingImagehappens to contain - exactly the small, misplaced grey rectangle observed in testing, with the rest of the stale frame still fully visible around it.Suggested fix
Three independent fixes, matching the three causes above:
For (1), resolve the device by name at selection time, from a fresh enumeration, rather than reusing a positional index computed against a stale snapshot:
This doesn't eliminate the theoretical race (there's still a gap between this fresh enumeration and
openDevice's own internal one), but it collapses the gap from "however long the plugin's been open" to "one function call," which should make it unobservable in practice.For (2), explicitly invalidate the displayed frame whenever
cameraDeviceends up null after an open attempt, rather than leavingincomingImage/lastSnapshotuntouched - e.g. incameraDeviceOpenResult, clearincomingImageandlastSnapshotwhendevice == nullptr, and/or havetimerCallback()guard oncameraDevice != nullptrrather than just the dropdown's selected id, so a failed switch produces a visibly blank preview instead of a frozen stale one.For (3), clear the actual image buffer rather than a mismatched sub-rectangle of it - either
incomingImage.clear(incomingImage.getBounds()), or more simply replace it wholesale (incomingImage = Image();) before updatinglastSnapshot.Also worth adding, independent of all three: re-running
updateCameraList()when the dropdown is about to be shown (e.g. overridingshowPopup), so a virtual camera started after the plugin loaded appears without closing and reopening the editor.I only verified the macOS backend's name-matching in
addInput()in this depth; I have not checked whether the Windows (DirectShow) or Linux backends have the same or a different vulnerability to the (1) race. Findings (2) and (3) are in shared, platform-independentPluginEditor.cppcode, so those should reproduce identically everywhere.Happy to open a PR for these fixes - either together or split into separate PRs per cause, whichever's easier to review.