Skip to content

Powermap's webcam selector opens the wrong device, and a failed or cleared switch leaves stale video on screen with no indication #123

Description

@mormegil6

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

  1. Open REAPER, insert sparta_powermap, open its editor.
  2. 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.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions