Răsfoiți Sursa

implemented audio vizualizer for backgrounds

UwU 1 lună în urmă
părinte
comite
f7694699af

+ 4 - 1
CMakeLists.txt

@@ -586,7 +586,10 @@ if(BUILD_TESTING)
         src/WallpaperEngine/Testing/Cases/MouseCoordinates.cpp
         src/WallpaperEngine/Testing/Cases/PropertyParser.cpp
         src/WallpaperEngine/Testing/Cases/ScalingModes.cpp
-        src/WallpaperEngine/Testing/Cases/CornerColor.cpp)
+        src/WallpaperEngine/Testing/Cases/CornerColor.cpp
+        src/WallpaperEngine/Testing/Cases/JsonVectorParsing.cpp
+        src/WallpaperEngine/Testing/Cases/ModuleNamespaceTiming.cpp
+        src/WallpaperEngine/Testing/Cases/AudioSensitivity.cpp)
 endif()
 
 add_library(

+ 49 - 0
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -276,6 +276,26 @@ std::optional<bool> ApplicationContext::resolveObjectVisibility (int id, const s
     return std::nullopt;
 }
 
+std::optional<float> ApplicationContext::resolveAudioSensitivity (int id, const std::string& name) const {
+    // "*" is a wildcard default applied to every audio-reactive object with no more specific
+    // match - checked last so a specific id/name override always wins over it, regardless of the
+    // (alphabetically ordered) iteration order of the underlying map.
+    std::optional<float> wildcard;
+
+    for (const auto& [token, multiplier] : this->settings.general.audioSensitivity) {
+	if (token == "*") {
+	    wildcard = multiplier;
+	    continue;
+	}
+
+	if (matchesObjectToken (token, id, name)) {
+	    return multiplier;
+	}
+    }
+
+    return wildcard;
+}
+
 void ApplicationContext::loadSettingsFromArgv () {
     std::string lastScreen;
 
@@ -787,6 +807,35 @@ void ApplicationContext::loadSettingsFromArgv () {
 	)
 	.append ();
 
+    configurationGroup.add_argument ("--list-audio-objects")
+	.help ("List objects/properties whose script reacts to music (via engine.registerAudioBuffers), with their "
+	       "current minvalue/maxvalue/frequency/smoothing")
+	.flag ()
+	.store_into (this->settings.general.onlyListAudioObjects);
+
+    configurationGroup.add_argument ("--audio-sensitivity")
+	.help ("Scales how much an audio-reactive object's music-driven properties swing around their authored "
+	       "midpoint, matched by id or name: 0 locks it (no pulse), 1 is the wallpaper's original behavior, "
+	       ">1 exaggerates it. Use \"*\" as the id to set a default for every audio-reactive object with no "
+	       "more specific match. Format: <id-or-name-or-*>=<multiplier>. Can be repeated")
+	.action ([this] (const std::string& value) -> void {
+	    const std::string::size_type equals = value.find ('=');
+
+	    if (equals == std::string::npos) {
+		sLog.exception ("--audio-sensitivity expects <id-or-name>=<multiplier>, got '" + value + "'");
+	    }
+
+	    const std::string target = value.substr (0, equals);
+	    const std::string multiplierStr = value.substr (equals + 1);
+
+	    try {
+		this->settings.general.audioSensitivity[target] = std::stof (multiplierStr);
+	    } catch (const std::exception&) {
+		sLog.exception ("--audio-sensitivity: '" + multiplierStr + "' is not a valid number");
+	    }
+	})
+	.append ();
+
     auto& debuggingGroup = program.add_group ("Debugging options");
 
     debuggingGroup.add_argument ("-z", "--dump-structure")

+ 14 - 0
src/WallpaperEngine/Application/ApplicationContext.h

@@ -33,6 +33,15 @@ public:
      */
     [[nodiscard]] std::optional<bool> resolveObjectVisibility (int id, const std::string& name) const;
 
+    /**
+     * Resolves the --audio-sensitivity multiplier for an object/layer, matching id or name, or
+     * falling back to a "*" wildcard default if one was given and no more specific match exists.
+     *
+     * @return the configured multiplier, or nullopt if this object has no override (use the
+     *         wallpaper's original minvalue/maxvalue unchanged)
+     */
+    [[nodiscard]] std::optional<float> resolveAudioSensitivity (int id, const std::string& name) const;
+
     enum WINDOW_MODE {
 	NORMAL_WINDOW = 0,
 	/** Draw to the window server desktop */
@@ -83,12 +92,15 @@ public:
 	struct {
 	    bool onlyListProperties;
 	    bool onlyListObjects;
+	    bool onlyListAudioObjects;
 	    bool dumpStructure;
 	    bool disableParticles;
 	    /** Objects/layers to force-hide, matched by id or name */
 	    std::vector<std::string> disabledObjects;
 	    /** Objects/layers to force-show, matched by id or name */
 	    std::vector<std::string> enabledObjects;
+	    /** Audio-reactive pulse amplitude multiplier per object, matched by id or name; 0 = locked/no pulse */
+	    std::map<std::string, float> audioSensitivity;
 	    std::filesystem::path assets;
 	    /** Background to load (provided as the final argument) as fallback for multi-screen setups */
 	    std::filesystem::path defaultBackground;
@@ -187,9 +199,11 @@ public:
         .general = {
             .onlyListProperties = false,
             .onlyListObjects = false,
+            .onlyListAudioObjects = false,
             .dumpStructure = false,
             .disabledObjects = {},
             .enabledObjects = {},
+            .audioSensitivity = {},
             .assets = "",
             .defaultBackground = "",
             .screenBackgrounds = {},

+ 179 - 4
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -14,6 +14,7 @@
 
 #include "WallpaperEngine/Data/Dumpers/StringPrinter.h"
 #include "WallpaperEngine/Data/Parsers/ProjectParser.h"
+#include "WallpaperEngine/Data/Utils/AudioSensitivity.h"
 
 #include "WallpaperEngine/Data/Model/Property.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
@@ -105,7 +106,9 @@ WallpaperApplication::WallpaperApplication (ApplicationContext& context) : m_con
     }
 
     this->setupProperties ();
+    this->setupAudioSensitivity ();
     this->listObjects ();
+    this->listAudioObjects ();
     this->setupBrowser ();
     this->initializePlaylists ();
 }
@@ -550,6 +553,9 @@ struct HotswapRequest {
     std::optional<std::string> audioScreen;
     /** 0-128, see --ambient-volume */
     std::optional<std::string> ambientVolume;
+    /** True once at least one "audio-sensitivity=id=multiplier" line was seen */
+    bool audioSensitivityProvided = false;
+    std::map<std::string, std::string> audioSensitivity;
 };
 
 std::string trimHotswapToken (const std::string& value) {
@@ -640,6 +646,15 @@ HotswapRequest parseHotswapRequest (std::istream& file) {
 		request.propertiesProvided = true;
 		request.properties[value.substr (0, propSeparator)] = value.substr (propSeparator + 1);
 	    }
+	} else if (key == "audio-sensitivity") {
+	    const auto sensSeparator = value.find ('=');
+
+	    if (sensSeparator == std::string::npos) {
+		sLog.error ("Hotswap: ignoring malformed audio-sensitivity line: ", value);
+	    } else {
+		request.audioSensitivityProvided = true;
+		request.audioSensitivity[value.substr (0, sensSeparator)] = value.substr (sensSeparator + 1);
+	    }
 	} else {
 	    sLog.error ("Hotswap: ignoring unknown control file key: ", key);
 	}
@@ -670,7 +685,7 @@ void WallpaperApplication::checkHotswapRequest () {
 	&& !request.xray.has_value () && !request.scaling.has_value () && !request.zoom.has_value ()
 	&& !request.disableParallax.has_value () && !request.cornerColor.has_value ()
 	&& !request.speed.has_value () && !request.audioScreen.has_value () && !request.ambientVolume.has_value ()
-	&& !request.propertiesProvided) {
+	&& !request.propertiesProvided && !request.audioSensitivityProvided) {
 	sLog.error ("Hotswap requested but control file was empty");
 	return;
     }
@@ -722,9 +737,21 @@ void WallpaperApplication::checkHotswapRequest () {
 	}
     }
 
-    // volume/xray/speed-only requests are pure live setters with nothing to reload. Properties,
-    // like layers, are baked into the scene graph at parse time, so they need the same reload.
-    if (!request.path.has_value () && !request.layersProvided && !request.propertiesProvided) {
+    if (request.audioSensitivityProvided) {
+	for (const auto& [target, value] : request.audioSensitivity) {
+	    try {
+		this->m_context.settings.general.audioSensitivity[target] = std::stof (value);
+	    } catch (const std::exception&) {
+		sLog.error ("Hotswap: ignoring invalid audio-sensitivity value: ", value);
+	    }
+	}
+    }
+
+    // volume/xray/speed-only requests are pure live setters with nothing to reload. Properties
+    // and audio sensitivity, like layers, are baked into the scene graph at parse time, so they
+    // need the same reload.
+    if (!request.path.has_value () && !request.layersProvided && !request.propertiesProvided
+	&& !request.audioSensitivityProvided) {
 	return;
     }
 
@@ -751,6 +778,7 @@ void WallpaperApplication::checkHotswapRequest () {
 	    auto project = this->loadBackground (targetPath);
 
 	    this->setupPropertiesForProject (*project);
+	    this->setupAudioSensitivityForProject (*project);
 
 	    background = std::move (project);
 
@@ -1122,6 +1150,153 @@ void WallpaperApplication::listObjects () const {
     }
 }
 
+namespace {
+struct AudioReactiveProperty {
+    std::string name;
+    DynamicValue* value;
+};
+
+void considerAudioReactive (
+    std::vector<AudioReactiveProperty>& result, const std::string& name, const UserSettingUniquePtr& setting
+) {
+    if (!setting || !setting->value) {
+	return;
+    }
+
+    const auto& source = setting->value->getScriptSource ();
+
+    if (source.has_value () && source->find ("registerAudioBuffers") != std::string::npos) {
+	result.push_back ({ name, setting->value.get () });
+    }
+}
+
+// Mirrors exactly the set of fields ScriptableObject/CImage/CText/CParticle register with the
+// script engine (see Scripting/ScriptableObject.cpp, Render/Objects/CImage.cpp, CText.cpp,
+// CParticle.cpp) - "origin" is always the base object's own field (never overridden per-type),
+// while scale/angles/visible fall back to the generic group* fields only for object types that
+// don't provide their own (Sound, Light, plain groups).
+std::vector<AudioReactiveProperty> collectAudioReactiveProperties (const Object& object) {
+    std::vector<AudioReactiveProperty> result;
+
+    considerAudioReactive (result, "origin", object.origin);
+
+    if (object.is<Image> ()) {
+	const auto* image = object.as<Image> ();
+	considerAudioReactive (result, "scale", image->scale);
+	considerAudioReactive (result, "angles", image->angles);
+	considerAudioReactive (result, "visible", image->visible);
+	considerAudioReactive (result, "alpha", image->alpha);
+	considerAudioReactive (result, "color", image->color);
+	considerAudioReactive (result, "parallaxDepth", image->parallaxDepth);
+    } else if (object.is<Text> ()) {
+	const auto* text = object.as<Text> ();
+	considerAudioReactive (result, "scale", text->scale);
+	considerAudioReactive (result, "color", text->color);
+	considerAudioReactive (result, "alpha", text->alpha);
+	considerAudioReactive (result, "visible", text->visible);
+	considerAudioReactive (result, "pointSize", text->pointSize);
+	considerAudioReactive (result, "text", text->text);
+	considerAudioReactive (result, "parallaxDepth", text->parallaxDepth);
+    } else if (object.is<Particle> ()) {
+	const auto* particle = object.as<Particle> ();
+	considerAudioReactive (result, "scale", particle->scale);
+	considerAudioReactive (result, "angles", particle->angles);
+	considerAudioReactive (result, "visible", particle->visible);
+	considerAudioReactive (result, "parallaxDepth", particle->parallaxDepth);
+    } else {
+	considerAudioReactive (result, "scale", object.groupScale);
+	considerAudioReactive (result, "angles", object.groupAngles);
+	considerAudioReactive (result, "visible", object.groupVisible);
+    }
+
+    return result;
+}
+
+float readScriptPropertyFloat (DynamicValue& value, const std::string& key) {
+    auto& properties = value.getProperties ();
+    const auto it = properties.find (key);
+
+    return it != properties.end () && it->second && it->second->value ? it->second->value->getFloat () : 0.0f;
+}
+} // namespace
+
+void WallpaperApplication::listAudioObjectsForProject (const std::string& background, const Project& project) const {
+    if (!project.wallpaper->is<Scene> ()) {
+	return;
+    }
+
+    const auto scene = project.wallpaper->as<Scene> ();
+
+    sLog.out ("Audio-reactive objects for ", background, ":");
+
+    for (const auto& object : scene->objects) {
+	for (const auto& reactive : collectAudioReactiveProperties (*object)) {
+	    sLog.out (
+		"  ", object->id, " - ", object->name, " (", reactive.name, "): minvalue=",
+		readScriptPropertyFloat (*reactive.value, "minvalue"), " maxvalue=",
+		readScriptPropertyFloat (*reactive.value, "maxvalue"), " frequency=",
+		readScriptPropertyFloat (*reactive.value, "frequency"), " smoothing=",
+		readScriptPropertyFloat (*reactive.value, "smoothing")
+	    );
+	}
+    }
+}
+
+void WallpaperApplication::listAudioObjects () const {
+    if (!this->m_context.settings.general.onlyListAudioObjects) {
+	return;
+    }
+
+    for (const auto& [background, info] : this->m_backgrounds) {
+	this->listAudioObjectsForProject (background, *info);
+    }
+}
+
+void WallpaperApplication::setupAudioSensitivityForProject (const Project& project) const {
+    if (!project.wallpaper->is<Scene> ()) {
+	return;
+    }
+
+    const auto scene = project.wallpaper->as<Scene> ();
+
+    for (const auto& object : scene->objects) {
+	const auto sensitivity = this->m_context.resolveAudioSensitivity (object->id, object->name);
+
+	if (!sensitivity.has_value ()) {
+	    continue;
+	}
+
+	for (const auto& reactive : collectAudioReactiveProperties (*object)) {
+	    auto& scriptProps = reactive.value->getProperties ();
+	    const auto minIt = scriptProps.find ("minvalue");
+	    const auto maxIt = scriptProps.find ("maxvalue");
+
+	    if (minIt == scriptProps.end () || maxIt == scriptProps.end () || !minIt->second->value
+		|| !maxIt->second->value) {
+		continue;
+	    }
+
+	    const auto [newMin, newMax] = WallpaperEngine::Data::Utils::scaleAudioRange (
+		minIt->second->value->getFloat (), maxIt->second->value->getFloat (), sensitivity.value ()
+	    );
+
+	    minIt->second->value->update (newMin, DynamicValue::UpdateSource::User);
+	    maxIt->second->value->update (newMax, DynamicValue::UpdateSource::User);
+
+	    sLog.debug (
+		"Applying audio sensitivity ", sensitivity.value (), " to ", object->id, " - ", object->name, " (",
+		reactive.name, ")"
+	    );
+	}
+    }
+}
+
+void WallpaperApplication::setupAudioSensitivity () {
+    for (const auto& [background, info] : this->m_backgrounds) {
+	this->setupAudioSensitivityForProject (*info);
+    }
+}
+
 void WallpaperApplication::setupBrowser () {
     // The main engine process never hosts CEF directly - CEF only supports one
     // CefInitialize()/CefShutdown() pair per process, so a process that might later need to stop

+ 8 - 0
src/WallpaperEngine/Application/WallpaperApplication.h

@@ -67,6 +67,14 @@ private:
     void listObjects () const;
     void listObjectsForProject (const std::string& background, const Project& project) const;
 
+    /** Applies --audio-sensitivity overrides for every loaded background */
+    void setupAudioSensitivity ();
+    void setupAudioSensitivityForProject (const Project& project) const;
+
+    /** Prints audio-reactive objects/properties for every loaded background, triggered by --list-audio-objects */
+    void listAudioObjects () const;
+    void listAudioObjectsForProject (const std::string& background, const Project& project) const;
+
     void setupBrowser ();
     void setupOutput ();
     void setupAudio ();

+ 9 - 7
src/WallpaperEngine/Audio/AudioStream.cpp

@@ -484,7 +484,11 @@ int AudioStream::resampleAudio (uint8_t* out_buf, const int out_size) {
 
     out_nb_channels = av_get_channel_layout_nb_channels (out_channel_layout);
 #else
-    out_nb_channels = this->getContext ()->ch_layout.nb_channels;
+    // this must be the channel count swr_convert() below will actually write out, not the input
+    // file's channel count - a mono sound resampled to a stereo driver output would otherwise get
+    // a buffer sized for one channel while swr_convert() (configured via m_audioContext.getChannels()
+    // in initialize()) writes two, overflowing it
+    out_nb_channels = this->m_audioContext.getChannels ();
 #endif
     ret = av_samples_alloc_array_and_samples (
 	&resampled_data, &out_linesize, out_nb_channels, out_nb_samples, this->m_audioContext.getFormat (), 0
@@ -562,11 +566,9 @@ int AudioStream::resampleAudio (uint8_t* out_buf, const int out_size) {
 }
 
 int AudioStream::decodeFrame (uint8_t* audioBuffer, const int bufferSize) {
-    static int audio_pkt_size = 0;
-
     // block until there's any data in the buffers
     while (this->m_audioContext.getApplicationContext ().state.general.keepRunning) {
-	while (audio_pkt_size > 0 && this->m_audioContext.getApplicationContext ().state.general.keepRunning) {
+	while (this->m_audioPacketSize > 0 && this->m_audioContext.getApplicationContext ().state.general.keepRunning) {
 	    int got_frame = 0;
 	    int ret = avcodec_receive_frame (this->getContext (), this->m_decodeFrame);
 
@@ -585,11 +587,11 @@ int AudioStream::decodeFrame (uint8_t* audioBuffer, const int bufferSize) {
 
 	    if (this->m_decodePacket->size < 0) {
 		// if error, skip frame
-		audio_pkt_size = 0;
+		this->m_audioPacketSize = 0;
 		break;
 	    }
 
-	    audio_pkt_size -= this->m_decodePacket->size;
+	    this->m_audioPacketSize -= this->m_decodePacket->size;
 	    int data_size = 0;
 
 	    if (got_frame) {
@@ -610,7 +612,7 @@ int AudioStream::decodeFrame (uint8_t* audioBuffer, const int bufferSize) {
 
 	this->dequeuePacket ();
 
-	audio_pkt_size = this->m_decodePacket->size;
+	this->m_audioPacketSize = this->m_decodePacket->size;
     }
 
     return 0;

+ 2 - 0
src/WallpaperEngine/Audio/AudioStream.h

@@ -182,6 +182,8 @@ private:
     AVPacket* m_decodePacket = nullptr;
     /** The AV frame used while decoding this stream */
     AVFrame* m_decodeFrame = nullptr;
+    /** Bytes left to decode from m_decodePacket, carried between decodeFrame() calls */
+    int m_audioPacketSize = 0;
 
     /**
      * Packet queue information

+ 26 - 8
src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp

@@ -19,7 +19,7 @@ void pa_stream_notify_cb (pa_stream* stream, void* /*userdata*/) {
 	    sLog.error ("Cannot open stream for capture. Audio processing is disabled");
 	    break;
 	case PA_STREAM_READY:
-	    sLog.debug ("Capture stream ready");
+	    sLog.debug ("Audio processing: capture stream ready");
 	    break;
 	default:
 	    break;
@@ -127,6 +127,8 @@ void pa_server_info_cb (pa_context* ctx, const pa_server_info* info, void* userd
     attr.fragsize = bytesPerSec * 10 / 100;
     attr.maxlength = attr.fragsize + bytesPerSec * 750 / 100;
 
+    sLog.debug ("Audio processing: capturing from monitor source '", monitor_name, "' (default sink)");
+
     if (pa_stream_connect_record (recorder->captureStream, monitor_name.c_str (), &attr, PA_STREAM_ADJUST_LATENCY)
 	!= 0) {
 	sLog.error ("Failed to connect to input for recording");
@@ -251,15 +253,31 @@ void PulseAudioPlaybackRecorder::update () {
 	f1 = 0.0f;
 
 	if (f2 > 0.0f) {
-	    f1 = 0.35f * log10 (f2);
+	    // log10(magnitude) is unbounded and usually negative at ordinary listening volumes, but
+	    // scripts/shaders consuming this expect roughly a 0 (quiet) - 1 (loud) range; empirically
+	    // chosen from real capture logs, may need retuning for very quiet/loud setups.
+	    constexpr float kLoudnessOffset = 1.0f;
+	    f1 = 0.35f * log10 (f2) + kLoudnessOffset;
 	}
 
-	this->m_FFTdestination64[band]
-	    = fmin (1.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 63.0f) * 1.0f - 0.5f)));
-	this->m_FFTdestination32[band >> 1]
-	    = fmin (1.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 31.0f) * 1.0f - 0.5f)));
-	this->m_FFTdestination16[band >> 2]
-	    = fmin (1.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 15.0f) * 1.0f - 0.5f)));
+	this->m_FFTdestination64[band] = fmax (
+	    0.0f, fmin (1.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 63.0f) * 1.0f - 0.5f)))
+	);
+	this->m_FFTdestination32[band >> 1] = fmax (
+	    0.0f, fmin (1.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 31.0f) * 1.0f - 0.5f)))
+	);
+	this->m_FFTdestination16[band >> 2] = fmax (
+	    0.0f, fmin (1.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 15.0f) * 1.0f - 0.5f)))
+	);
+    }
+
+    static int diagnosticCounter = 0;
+    if (++diagnosticCounter >= 100) {
+	diagnosticCounter = 0;
+	sLog.debug (
+	    "Audio processing: audio16[0..3] = ", this->audio16[0], ", ", this->audio16[1], ", ", this->audio16[2],
+	    ", ", this->audio16[3]
+	);
     }
 }
 

+ 36 - 2
src/WallpaperEngine/Data/JSON.h

@@ -2,6 +2,7 @@
 
 #include "Builders/ColorBuilder.h"
 
+#include <algorithm>
 #include <glm/detail/qualifier.hpp>
 #include <glm/detail/type_vec1.hpp>
 #include <nlohmann/json.hpp>
@@ -45,9 +46,42 @@ public:
     }
     template <int length, typename type, glm::qualifier qualifier>
     [[nodiscard]] glm::vec<length, type, qualifier> get () const {
-	return VectorBuilder::parse<length, type, qualifier> (this->base ().get<std::string> ());
+	const auto& node = this->base ();
+
+	// Most real scenes store vectors as "x y z" strings (VectorBuilder's only format), but some
+	// fields in the wild (text objects' "size"/"padding") show up as a bare number (uniform
+	// across every component) or a JSON array instead - callers like optional(key, default)
+	// below rely on this being noexcept, so fall back to zero and log rather than throw.
+	try {
+	    if (node.is_array ()) {
+		glm::vec<length, type, qualifier> result (static_cast<type> (0));
+		const auto count = std::min<std::size_t> (node.size (), static_cast<std::size_t> (length));
+
+		for (std::size_t i = 0; i < count; i++) {
+		    result[static_cast<int> (i)] = static_cast<type> (node.at (i).template get<double> ());
+		}
+
+		return result;
+	    }
+
+	    if (node.is_number ()) {
+		return glm::vec<length, type, qualifier> (static_cast<type> (node.template get<double> ()));
+	    }
+
+	    return VectorBuilder::parse<length, type, qualifier> (node.template get<std::string> ());
+	} catch (const std::exception& e) {
+	    sLog.error ("Cannot parse vector value (", node.dump (), "): ", e.what ());
+	    return glm::vec<length, type, qualifier> (static_cast<type> (0));
+	}
+    }
+    [[nodiscard]] Model::Color get () const {
+	try {
+	    return ColorBuilder::parse (this->base ().get<std::string> ());
+	} catch (const std::exception& e) {
+	    sLog.error ("Cannot parse color value (", this->base ().dump (), "): ", e.what ());
+	    return ColorBuilder::White;
+	}
     }
-    [[nodiscard]] Model::Color get () const { return ColorBuilder::parse (this->base ().get<std::string> ()); }
     [[nodiscard]] base_type require (const std::string& key, const std::string& message) const {
 	auto base = this->base ();
 	const auto it = base.find (key);

+ 17 - 0
src/WallpaperEngine/Data/Utils/AudioSensitivity.h

@@ -0,0 +1,17 @@
+#pragma once
+
+#include <utility>
+
+namespace WallpaperEngine::Data::Utils {
+/**
+ * Recomputes an audio-reactive script property's minvalue/maxvalue around their authored midpoint,
+ * scaled by `multiplier`: 0 collapses the range to the midpoint (locked, no pulse), 1 is a no-op
+ * (the wallpaper's original authored behavior), and >1 exaggerates the swing.
+ */
+inline std::pair<float, float> scaleAudioRange (float originalMin, float originalMax, float multiplier) {
+    const float mid = (originalMin + originalMax) / 2.0f;
+    const float half = (originalMax - originalMin) / 2.0f * multiplier;
+
+    return { mid - half, mid + half };
+}
+} // namespace WallpaperEngine::Data::Utils

+ 26 - 20
src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp

@@ -19,7 +19,7 @@ static constexpr int InvalidVectorInstanceId = 0;
 #define VEC_MAGIC_CHECK_EXCEPTION(container, components)                                                               \
     do {                                                                                                               \
 	if (!container || container->magic != (int)(VEC_OPAQUE_MAGIC + components)) {                                  \
-	    return JS_EXCEPTION;                                                                                       \
+	    return JS_ThrowTypeError (ctx, "invalid or mismatched vector%d instance", components);                     \
 	}                                                                                                              \
     } while (0)
 #define VEC_MAGIC_CHECK_ERROR(container, components)                                                                   \
@@ -163,34 +163,40 @@ JSValue vector_property_get (JSContext* ctx, JSValueConst obj_val, JSAtom atom,
 
     VEC_MAGIC_CHECK_EXCEPTION (container, components);
 
+    // An exotic get_property handler intercepts *every* property lookup on instances of this
+    // class - unlike a normal object, nothing here automatically falls through to the prototype
+    // chain. Vector methods (multiply, add, dot, cross, normalize, mix, ...) only exist on the
+    // class prototype, never as own properties of an instance, so anything other than x/y/z/w
+    // has to be looked up there manually below.
     const char* name = JS_AtomToCString (ctx, atom);
 
-    if (name == nullptr) {
-	return JS_EXCEPTION;
-    }
+    if (name != nullptr) {
+	ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
+	const auto value = vector_get<components> (container->value);
 
-    ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
-    const auto value = vector_get<components> (container->value);
-
-    if (strcmp (name, "x") == 0) {
-	return JS_NewFloat64 (ctx, value.x);
-    }
-    if (strcmp (name, "y") == 0) {
-	return JS_NewFloat64 (ctx, value.y);
-    }
-    if constexpr (components >= 3) {
-	if (strcmp (name, "z") == 0) {
-	    return JS_NewFloat64 (ctx, value.z);
+	if (strcmp (name, "x") == 0) {
+	    return JS_NewFloat64 (ctx, value.x);
 	}
+	if (strcmp (name, "y") == 0) {
+	    return JS_NewFloat64 (ctx, value.y);
+	}
+	if constexpr (components >= 3) {
+	    if (strcmp (name, "z") == 0) {
+		return JS_NewFloat64 (ctx, value.z);
+	    }
 
-	if constexpr (components >= 4) {
-	    if (strcmp (name, "w") == 0) {
-		return JS_NewFloat64 (ctx, value.w);
+	    if constexpr (components >= 4) {
+		if (strcmp (name, "w") == 0) {
+		    return JS_NewFloat64 (ctx, value.w);
+		}
 	    }
 	}
     }
 
-    return JS_EXCEPTION;
+    JSValue proto = JS_GetClassProto (ctx, classId);
+    JSValue result = JS_GetProperty (ctx, proto, atom);
+    JS_FreeValue (ctx, proto);
+    return result;
 }
 
 template JSValue vector_property_get<2> (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver);

+ 7 - 0
src/WallpaperEngine/Scripting/EngineObject.cpp

@@ -127,6 +127,7 @@ JSValue audio_buffer_get_values (
     const auto it = engineInstances.find (engineInstanceId);
 
     if (it == engineInstances.end ()) {
+	sLog.error ("registerAudioBuffers: no EngineObject found for instance ", engineInstanceId, " - returning zeros");
 	return result;
     }
 
@@ -139,6 +140,12 @@ JSValue audio_buffer_get_values (
 	data = recorder.audio64;
     }
 
+    static int diagnosticCounter = 0;
+    if (++diagnosticCounter >= 500) {
+	diagnosticCounter = 0;
+	sLog.debug ("registerAudioBuffers: average[0..3] = ", data[0], ", ", data[1], ", ", data[2], ", ", data[3]);
+    }
+
     for (int i = 0; i < resolution; i++) {
 	JS_SetPropertyUint32 (ctx, result, i, JS_NewFloat64 (ctx, data[i]));
     }

+ 98 - 11
src/WallpaperEngine/Scripting/ScriptEngine.cpp

@@ -96,11 +96,11 @@ JSValue ScriptEngine::dynamicToJs (DynamicValue& value) const {
 	case DynamicValue::Boolean:
 	    return JS_NewBool (this->m_context, value.getBool ());
 	case DynamicValue::Vec2:
-	    return this->m_adapters.vec2->instantiate (value);
+	    return this->m_adapters.vec2->instantiate (value, true);
 	case DynamicValue::Vec3:
-	    return this->m_adapters.vec3->instantiate (value);
+	    return this->m_adapters.vec3->instantiate (value, true);
 	case DynamicValue::Vec4:
-	    return this->m_adapters.vec4->instantiate (value);
+	    return this->m_adapters.vec4->instantiate (value, true);
 	default:
 	    return JS_UNDEFINED;
     }
@@ -114,8 +114,13 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
     // scalar types returned directly
     int tag = JS_VALUE_GET_TAG (val);
 
+    // update()'s contract is "return the new value"; falling off the end of a function (or an
+    // early "if (cond) return x;" with no else) yields undefined and is meant as "nothing to
+    // change this frame", not "reset this property to zero" - DynamicValue::update(source) with
+    // no value does the latter (it's meant for genuinely-null JSON properties at parse time, see
+    // DynamicValueParser), and calling it here would zero out (e.g. scale -> 0, i.e. invisible)
+    // any property whose script doesn't explicitly return on every path.
     if (tag == JS_TAG_UNDEFINED || tag == JS_TAG_UNINITIALIZED || tag == JS_TAG_NULL) {
-	source.update (DynamicValue::UpdateSource::Script);
 	return;
     }
 
@@ -153,7 +158,7 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
 	    JS_FreeValue (ctx, w);
 	});
 
-	if (!JS_IsNumber (x) || JS_IsNumber (y)) {
+	if (!JS_IsNumber (x) || !JS_IsNumber (y)) {
 	    sLog.exception ("Vector's x and y components must be numbers");
 	}
 
@@ -578,25 +583,93 @@ void ScriptEngine::queueScript (const std::string& key, DynamicValue& currentVal
 	return;
     }
 
-    // load the script and store it
-    JSValue module = JS_Eval (this->m_context, source->c_str (), source->size (), key.c_str (), JS_EVAL_TYPE_MODULE);
+    // Compile-only first: JS_Eval(..., JS_EVAL_TYPE_MODULE) alone compiles AND evaluates in one
+    // step, but its return value is the module's completion value, which for ES modules is always
+    // a Promise (see js_evaluate_module in quickjs.c) - never the namespace object exported
+    // functions live on. Compiling separately keeps the raw JS_TAG_MODULE value around (for its
+    // JSModuleDef*) so the real namespace can be fetched via JS_GetModuleNamespace afterward.
+    JSValue compiledModule = JS_Eval (
+	this->m_context, source->c_str (), source->size (), key.c_str (),
+	JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY
+    );
+
+    if (JS_IsException (compiledModule)) {
+	logJSException (this->m_context, key.c_str ());
+	return;
+    }
 
+    auto* moduleDef = static_cast<JSModuleDef*> (JS_VALUE_GET_PTR (compiledModule));
+
+    // Register the entry (with a placeholder module value) and point m_runningModule at it before
+    // evaluating below - top-level module code commonly does
+    // `export var scriptProperties = createScriptProperties()...finish();`, and
+    // scriptpropertiescreator_finish() (ScriptPropertiesObject.cpp) resolves the current object's
+    // JSON-parsed scriptproperties through getRunningModule(), which must already point here.
     auto inserted = this->m_scriptModules.emplace (
 	key,
 	LoadedModule {
 	    .value = currentValue,
-	    .module = module,
+	    .module = JS_UNDEFINED,
 	}
     );
 
     if (!inserted.second) {
+	JS_FreeValue (this->m_context, compiledModule);
+	return;
+    }
+
+    this->m_runningModule = &inserted.first->second;
+
+    // JS_EvalFunction consumes compiledModule and runs the module body, returning a Promise - for
+    // a module with no top-level await (true of every property script so far) this resolves or
+    // rejects synchronously, so its state can be inspected immediately. This is a Promise object,
+    // NOT a thrown exception: JS_IsException() on it is always false even when the module's
+    // top-level code threw, since that exception gets caught by the module machinery and stored
+    // as the promise's rejection reason instead.
+    JSValue evalResult = JS_EvalFunction (this->m_context, compiledModule);
+
+    if (JS_PromiseState (this->m_context, evalResult) == JS_PROMISE_REJECTED) {
+	JSValue reason = JS_PromiseResult (this->m_context, evalResult);
+	const char* str = JS_ToCString (this->m_context, reason);
+	sLog.error ("ScriptEngine [", key, "] module evaluation rejected: ", str != nullptr ? str : "(no message)");
+	if (str) {
+	    JS_FreeCString (this->m_context, str);
+	}
+	JS_FreeValue (this->m_context, reason);
+	JS_FreeValue (this->m_context, evalResult);
+	this->m_runningModule = nullptr;
+	this->m_scriptModules.erase (inserted.first);
+	return;
+    }
+
+    JS_FreeValue (this->m_context, evalResult);
+
+    JSValue module = JS_GetModuleNamespace (this->m_context, moduleDef);
+
+    if (JS_IsException (module)) {
+	logJSException (this->m_context, key.c_str ());
+	this->m_runningModule = nullptr;
+	this->m_scriptModules.erase (inserted.first);
 	return;
     }
 
+    inserted.first->second.module = module;
+
     JS_SetPropertyStr (this->m_context, this->m_globalThis, "thisLayer", this->m_adapters.object->instantiate (object));
 
-    // script properties do not need update as they're connected directly to the source data
-    this->m_runningModule = &inserted.first->second;
+    // init() receives the property's static/base value exactly once, before update() starts being
+    // called every tick - scripts commonly stash it (e.g. to scale a captured base value by a
+    // live multiplier, see audio-reactive scale scripts) and would otherwise read an undefined
+    // base forever, throwing out of every single update() call.
+    JSValue initArgs[] = { this->dynamicToJs (currentValue) };
+    JSValue initResult = this->call (module, 1, initArgs, "init");
+
+    if (JS_IsException (initResult)) {
+	logJSException (this->m_context, key.c_str ());
+    }
+
+    JS_FreeValue (this->m_context, initResult);
+    JS_FreeValue (this->m_context, initArgs[0]);
 
     // check if there's an update method and run it
     JSValue args[] = { this->dynamicToJs (currentValue) };
@@ -608,6 +681,7 @@ void ScriptEngine::queueScript (const std::string& key, DynamicValue& currentVal
     });
 
     if (JS_IsException (result)) {
+	logJSException (this->m_context, key.c_str ());
 	return;
     }
 
@@ -621,7 +695,7 @@ void ScriptEngine::tick () {
     // run any pending notifications
 
     // run all update methods
-    for (auto& module : this->m_scriptModules | std::views::values) {
+    for (auto& [key, module] : this->m_scriptModules) {
 	this->m_runningModule = &module;
 
 	JSValue args[] = { this->dynamicToJs (module.value) };
@@ -632,9 +706,22 @@ void ScriptEngine::tick () {
 	});
 
 	if (JS_IsException (result)) {
+	    logJSException (this->m_context, key.c_str ());
 	    continue;
 	}
 
+	if (key.starts_with ("scale_")) {
+	    static int scaleDiagnosticCounter = 0;
+	    if (++scaleDiagnosticCounter >= 300) {
+		scaleDiagnosticCounter = 0;
+		sLog.debug (
+		    "scale script '", key, "': update() returned tag=", JS_VALUE_GET_TAG (result),
+		    ", current vec3 = (", module.value.getVec3 ().x, ", ", module.value.getVec3 ().y, ", ",
+		    module.value.getVec3 ().z, ")"
+		);
+	    }
+	}
+
 	jsToDynamicValue (this->m_context, result, module.value);
     }
 }

+ 19 - 5
src/WallpaperEngine/Scripting/ScriptPropertiesObject.cpp

@@ -4,6 +4,7 @@
 #include "EngineObject.h"
 #include "ScriptEngine.h"
 #include "WallpaperEngine/Data/Utils/ScopeGuard.h"
+#include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Render/Wallpapers/CScene.h"
 
 using namespace WallpaperEngine::Scripting;
@@ -27,8 +28,12 @@ JSValue scriptproperties_property_get (JSContext* ctx, JSValueConst obj_val, JSA
 
     const char* name = JS_AtomToCString (ctx, atom);
 
+    // This exotic getter intercepts every property read on the object, not just the named
+    // slider/setting ones - well-known symbol lookups (Symbol.toPrimitive, Symbol.iterator, etc)
+    // that the engine or a script might probe for don't stringify to a C string here, and that is
+    // not an error condition, just "not one of our named properties".
     if (name == nullptr) {
-	return JS_EXCEPTION;
+	return JS_UNDEFINED;
     }
 
     ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
@@ -43,7 +48,7 @@ JSValue scriptproperties_property_get (JSContext* ctx, JSValueConst obj_val, JSA
 
 	return container->object.getEngine ().dynamicToJs (*it->second->value);
     } catch (const std::exception& e) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "scriptProperties.%s: %s", name, e.what ());
     }
 }
 
@@ -58,7 +63,9 @@ JSValue scriptpropertiescreator_add (JSContext* ctx, JSValueConst this_val, int
     // no need to do anything, any add call should just return itself
     // we'll set them either way as what comes in the DynamicValue
     // TODO: PROPERLY IMPLEMENT THIS CHAIN AT SOME POINT
-    return this_val;
+    // this_val is a borrowed reference: returning it as-is under-counts its refcount by one per
+    // chained .addSlider() call, freeing the creator object while script code still uses it.
+    return JS_DupValue (ctx, this_val);
 }
 
 JSValue scriptpropertiescreator_finish (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
@@ -68,7 +75,10 @@ JSValue scriptpropertiescreator_finish (JSContext* ctx, JSValueConst this_val, i
     // get all the properties and set the right values
     const auto* module = container->object.getEngine ().getRunningModule ();
 
+    sLog.debug ("scriptpropertiescreator_finish: running module = ", static_cast<const void*> (module));
+
     if (module == nullptr) {
+	sLog.error ("scriptpropertiescreator_finish: no running module - scriptProperties will be undefined");
 	return JS_UNDEFINED;
     }
 
@@ -166,11 +176,15 @@ ScriptPropertiesObject::ScriptPropertiesObject (ScriptEngine& engine, Render::Wa
 	this->m_engine.getContext (), this->m_creatorPrototype, "finish",
 	JS_NewCFunction (this->m_engine.getContext (), scriptpropertiescreator_finish, "finish", 0), JS_PROP_ENUMERABLE
     );
+    // scriptpropertiescreator_create takes a trailing `magic` argument (JSCFunctionMagic), so this
+    // must use JS_CFUNC_generic_magic, not JS_CFUNC_generic - the plain variant leaves `magic` as
+    // whatever garbage is in the unused argument slot, which then never matches a real entry in
+    // scriptPropertiesObjectInstances.
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_engine.getGlobalThis (), "createScriptProperties",
 	JS_NewCFunctionMagic (
-	    this->m_engine.getContext (), scriptpropertiescreator_create, "createScriptProperties", 0, JS_CFUNC_generic,
-	    m_instanceId
+	    this->m_engine.getContext (), scriptpropertiescreator_create, "createScriptProperties", 0,
+	    JS_CFUNC_generic_magic, m_instanceId
 	),
 	JS_PROP_ENUMERABLE
     );

+ 21 - 6
src/WallpaperEngine/Scripting/ScriptableObject.cpp

@@ -3,6 +3,7 @@
 #include "ScriptEngine.h"
 #include "WallpaperEngine/Data/Utils/ScopeGuard.h"
 
+#include <cstdint>
 #include <ranges>
 
 using namespace WallpaperEngine::Render;
@@ -31,13 +32,27 @@ const std::map<std::string, ScriptableObject::PropertyEntry>& ScriptableObject::
 }
 
 void ScriptableObject::registerProperty (const std::string& name, DynamicValue& value) {
-    auto inserted = this->m_properties.emplace (
-	name, PropertyEntry { .key = name + "_" + std::to_string (this->getId ()), .value = value }
-    );
-
-    if (!inserted.second) {
-	return;
+    if (const auto existing = this->m_properties.find (name); existing != this->m_properties.end ()) {
+	if (&existing->second.value == &value) {
+	    return;
+	}
+
+	// A derived class's own field (e.g. CImage's "scale", the one localTransform() actually
+	// renders) is overriding the generic groupScale/groupAngles/groupVisible fallback
+	// ScriptableObject's base constructor already registered under the same name. Only stop
+	// tracking the stale one here - do NOT unqueue/re-evaluate its already-queued script: it can
+	// still be the engine's "currently running module" mid-registration, and reusing the same
+	// key/filename for a fresh JS_Eval() risks colliding with QuickJS's own module identity for
+	// the one just freed. It's fine to leave it ticking harmlessly in the background.
+	this->m_properties.erase (existing);
     }
 
+    // Includes the DynamicValue's own address so two different registrations under the same
+    // name (see above) never end up sharing a script engine key/module filename.
+    const std::string key = name + "_" + std::to_string (this->getId ()) + "_"
+	+ std::to_string (reinterpret_cast<uintptr_t> (&value));
+
+    const auto inserted = this->m_properties.emplace (name, PropertyEntry { .key = key, .value = value });
+
     this->getScene ().getScriptEngine ().queueScript (inserted.first->second.key, inserted.first->second.value, *this);
 }

+ 93 - 0
src/WallpaperEngine/Testing/Cases/AudioSensitivity.cpp

@@ -0,0 +1,93 @@
+#include <catch2/catch_approx.hpp>
+#include <catch2/catch_test_macros.hpp>
+
+#include "WallpaperEngine/Application/ApplicationContext.h"
+#include "WallpaperEngine/Data/Utils/AudioSensitivity.h"
+
+using WallpaperEngine::Application::ApplicationContext;
+using WallpaperEngine::Data::Utils::scaleAudioRange;
+
+namespace {
+ApplicationContext makeContext () {
+    static const char* argv[] = { "linux-wallpaperengine" };
+    return ApplicationContext (1, const_cast<char**> (argv));
+}
+} // namespace
+
+TEST_CASE ("resolveAudioSensitivity matches by object id") {
+    auto context = makeContext ();
+    context.settings.general.audioSensitivity["24"] = 0.5f;
+
+    const auto result = context.resolveAudioSensitivity (24, "transbig");
+
+    REQUIRE (result.has_value ());
+    CHECK (result.value () == Catch::Approx (0.5f));
+}
+
+TEST_CASE ("resolveAudioSensitivity matches by object name") {
+    auto context = makeContext ();
+    context.settings.general.audioSensitivity["transbig"] = 0.5f;
+
+    const auto result = context.resolveAudioSensitivity (24, "transbig");
+
+    REQUIRE (result.has_value ());
+    CHECK (result.value () == Catch::Approx (0.5f));
+}
+
+TEST_CASE ("resolveAudioSensitivity falls back to the * wildcard with no specific match") {
+    auto context = makeContext ();
+    context.settings.general.audioSensitivity["*"] = 0.25f;
+
+    const auto result = context.resolveAudioSensitivity (99, "unrelated-object");
+
+    REQUIRE (result.has_value ());
+    CHECK (result.value () == Catch::Approx (0.25f));
+}
+
+TEST_CASE ("resolveAudioSensitivity prefers a specific match over the * wildcard") {
+    auto context = makeContext ();
+    context.settings.general.audioSensitivity["*"] = 0.25f;
+    context.settings.general.audioSensitivity["24"] = 2.0f;
+
+    const auto result = context.resolveAudioSensitivity (24, "transbig");
+
+    REQUIRE (result.has_value ());
+    CHECK (result.value () == Catch::Approx (2.0f));
+}
+
+TEST_CASE ("resolveAudioSensitivity returns nullopt with no match at all") {
+    auto context = makeContext ();
+    context.settings.general.audioSensitivity["24"] = 0.5f;
+
+    CHECK_FALSE (context.resolveAudioSensitivity (99, "unrelated-object").has_value ());
+}
+
+TEST_CASE ("scaleAudioRange at multiplier 1 leaves the original range untouched") {
+    const auto [min, max] = scaleAudioRange (0.9f, 1.1f, 1.0f);
+
+    CHECK (min == Catch::Approx (0.9f));
+    CHECK (max == Catch::Approx (1.1f));
+}
+
+TEST_CASE ("scaleAudioRange at multiplier 0 collapses to the midpoint - locked, no pulse") {
+    const auto [min, max] = scaleAudioRange (0.9f, 1.1f, 0.0f);
+
+    CHECK (min == Catch::Approx (1.0f));
+    CHECK (max == Catch::Approx (1.0f));
+    CHECK (min == max);
+}
+
+TEST_CASE ("scaleAudioRange at multiplier 2 doubles the swing around the same midpoint") {
+    const auto [min, max] = scaleAudioRange (0.9f, 1.1f, 2.0f);
+
+    CHECK (min == Catch::Approx (0.8f));
+    CHECK (max == Catch::Approx (1.2f));
+}
+
+TEST_CASE ("scaleAudioRange works for a midpoint other than 1.0") {
+    // e.g. an alpha-driven pulse authored around 0.5 instead of a scale pulse around 1.0
+    const auto [min, max] = scaleAudioRange (0.3f, 0.7f, 0.5f);
+
+    CHECK (min == Catch::Approx (0.4f));
+    CHECK (max == Catch::Approx (0.6f));
+}

+ 54 - 0
src/WallpaperEngine/Testing/Cases/JsonVectorParsing.cpp

@@ -0,0 +1,54 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include "WallpaperEngine/Data/JSON.h"
+
+using WallpaperEngine::Data::JSON::JSON;
+
+// Real wallpapers usually store vec2/vec3 properties as "x y z" strings, but some (seen on text
+// objects' "size"/"padding") show up as a bare number or a JSON array instead - optional(key,
+// default) is documented noexcept, so parsing must tolerate all of these instead of throwing.
+
+TEST_CASE ("Vector properties parse from the usual space-separated string") {
+    const JSON data = { { "padding", "1.5 2.5" } };
+
+    const auto value = data.optional<glm::vec2> ("padding", glm::vec2 (0.0f));
+
+    CHECK (value.x == 1.5f);
+    CHECK (value.y == 2.5f);
+}
+
+TEST_CASE ("Vector properties tolerate a bare number instead of a string") {
+    const JSON data = { { "padding", 5 } };
+
+    const auto value = data.optional<glm::vec2> ("padding", glm::vec2 (0.0f));
+
+    CHECK (value.x == 5.0f);
+    CHECK (value.y == 5.0f);
+}
+
+TEST_CASE ("Vector properties tolerate a JSON array instead of a string") {
+    const JSON data = { { "padding", { 3, 4 } } };
+
+    const auto value = data.optional<glm::vec2> ("padding", glm::vec2 (0.0f));
+
+    CHECK (value.x == 3.0f);
+    CHECK (value.y == 4.0f);
+}
+
+TEST_CASE ("Vector properties fall back to zero instead of crashing on a malformed string") {
+    const JSON data = { { "padding", "not-a-vector" } };
+
+    const auto value = data.optional<glm::vec2> ("padding", glm::vec2 (0.0f));
+
+    CHECK (value.x == 0.0f);
+    CHECK (value.y == 0.0f);
+}
+
+TEST_CASE ("Vector properties fall back to the default when the key is missing") {
+    const JSON data = { { "other", "1 2" } };
+
+    const auto value = data.optional<glm::vec2> ("padding", glm::vec2 (9.0f, 9.0f));
+
+    CHECK (value.x == 9.0f);
+    CHECK (value.y == 9.0f);
+}

+ 250 - 0
src/WallpaperEngine/Testing/Cases/ModuleNamespaceTiming.cpp

@@ -0,0 +1,250 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include "quickjs.h"
+
+#include <cstring>
+#include <string>
+
+// Isolated repro for the queueScript() timing question: if we set some external C++ state
+// *before* calling JS_EvalFunction() on a compile-only-obtained module, does the module's
+// top-level code (executed synchronously inside JS_EvalFunction, for a non-async/no-top-level-
+// await module) actually observe that state when it calls a bound native function?
+// This exists to verify the mechanism ScriptEngine::queueScript() relies on (setting
+// m_runningModule before evaluating a script's module body so createScriptProperties()/finish()
+// can resolve the right object) independent of the full engine/scene/GL stack.
+
+namespace {
+int g_probeValue = -1;
+
+JSValue probe (JSContext* ctx, JSValueConst /*this_val*/, int /*argc*/, JSValueConst* /*argv*/) {
+    return JS_NewInt32 (ctx, g_probeValue);
+}
+} // namespace
+
+TEST_CASE ("QuickJS module top-level code sees external state set before JS_EvalFunction") {
+    JSRuntime* rt = JS_NewRuntime ();
+    JSContext* ctx = JS_NewContext (rt);
+
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+    JS_SetPropertyStr (ctx, globalObj, "probe", JS_NewCFunction (ctx, probe, "probe", 0));
+    JS_FreeValue (ctx, globalObj);
+
+    const char* source = "export var x = probe();";
+
+    JSValue compiled = JS_Eval (ctx, source, strlen (source), "<test-module>", JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
+
+    REQUIRE_FALSE (JS_IsException (compiled));
+
+    auto* moduleDef = static_cast<JSModuleDef*> (JS_VALUE_GET_PTR (compiled));
+
+    // set the external state *after* compiling but *before* evaluating - mirrors
+    // ScriptEngine::queueScript() setting m_runningModule right before JS_EvalFunction
+    g_probeValue = 777;
+
+    JSValue evalResult = JS_EvalFunction (ctx, compiled);
+
+    REQUIRE_FALSE (JS_IsException (evalResult));
+    JS_FreeValue (ctx, evalResult);
+
+    JSValue ns = JS_GetModuleNamespace (ctx, moduleDef);
+
+    REQUIRE_FALSE (JS_IsException (ns));
+
+    JSValue x = JS_GetPropertyStr (ctx, ns, "x");
+    int32_t xVal = -1;
+    JS_ToInt32 (ctx, &xVal, x);
+
+    JS_FreeValue (ctx, x);
+    JS_FreeValue (ctx, ns);
+
+    REQUIRE (xVal == 777);
+
+    JS_FreeContext (ctx);
+    JS_FreeRuntime (rt);
+}
+
+// `this_val` is a *borrowed* reference (JSValueConst) - a C function returning it directly
+// under-counts the object's refcount by one per chained call, since the interpreter treats
+// whatever a JSValue-returning function hands back as owned and frees it once done. A version of
+// this test exercising the non-duplicating path deliberately isn't kept here since it corrupts
+// the heap and segfaults the whole test binary by design - only the fixed path is verified below.
+namespace {
+int g_chainableFinalizedCount = 0;
+JSClassID g_chainableClassId = 0;
+
+void chainableFinalizer (JSRuntime* /*rt*/, JSValueConst /*val*/) { g_chainableFinalizedCount++; }
+
+JSValue chainFixed (JSContext* ctx, JSValueConst this_val, int /*argc*/, JSValueConst* /*argv*/) {
+    return JS_DupValue (ctx, this_val);
+}
+
+JSValue makeChainable (JSContext* ctx, JSValueConst /*this_val*/, int /*argc*/, JSValueConst* /*argv*/) {
+    JSValue obj = JS_NewObjectClass (ctx, g_chainableClassId);
+    JS_SetOpaque (obj, reinterpret_cast<void*> (1));
+    return obj;
+}
+} // namespace
+
+TEST_CASE ("Duplicating this_val before returning it from a chained method keeps refcounting correct") {
+    g_chainableFinalizedCount = 0;
+
+    JSRuntime* rt = JS_NewRuntime ();
+    JSContext* ctx = JS_NewContext (rt);
+
+    JS_NewClassID (rt, &g_chainableClassId);
+    JSClassDef classDef = { .class_name = "Chainable", .finalizer = chainableFinalizer };
+    JS_NewClass (rt, g_chainableClassId, &classDef);
+
+    JSValue proto = JS_NewObject (ctx);
+    JS_DefinePropertyValueStr (
+	ctx, proto, "add", JS_NewCFunction (ctx, chainFixed, "add", 0), JS_PROP_ENUMERABLE
+    );
+    JS_SetClassProto (ctx, g_chainableClassId, proto);
+
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+    JS_SetPropertyStr (ctx, globalObj, "make", JS_NewCFunction (ctx, makeChainable, "make", 0));
+    JS_FreeValue (ctx, globalObj);
+
+    const char* source = "export var x = make().add().add().add().add();";
+    JSValue compiled = JS_Eval (
+	ctx, source, strlen (source), "<test-chain-fixed>", JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY
+    );
+
+    REQUIRE_FALSE (JS_IsException (compiled));
+
+    auto* moduleDef = static_cast<JSModuleDef*> (JS_VALUE_GET_PTR (compiled));
+    JSValue evalResult = JS_EvalFunction (ctx, compiled);
+
+    REQUIRE (JS_PromiseState (ctx, evalResult) == JS_PROMISE_FULFILLED);
+    JS_FreeValue (ctx, evalResult);
+
+    // must not have been finalized yet - the module's `x` binding still holds a live reference
+    JSValue ns = JS_GetModuleNamespace (ctx, moduleDef);
+    JSValue x = JS_GetPropertyStr (ctx, ns, "x");
+    REQUIRE (JS_IsObject (x));
+    REQUIRE (g_chainableFinalizedCount == 0);
+
+    JS_FreeValue (ctx, x);
+    JS_FreeValue (ctx, ns);
+    JS_FreeContext (ctx);
+    JS_FreeRuntime (rt);
+
+    // freeing the context/runtime drops the last reference - finalizes exactly once, not zero
+    // (leak) and not more than once (double free)
+    REQUIRE (g_chainableFinalizedCount == 1);
+}
+
+TEST_CASE ("A module-level throw does not surface via JS_IsException on JS_EvalFunction's result") {
+    JSRuntime* rt = JS_NewRuntime ();
+    JSContext* ctx = JS_NewContext (rt);
+
+    // calling a method on undefined mirrors the real bug: createScriptProperties() can return
+    // undefined (e.g. no matching instance registered), and the script's chained .addSlider(...)
+    // call on that throws - but as a *module-level* throw, not a C-level JS_EXCEPTION.
+    const char* source = "export var x = undefined.someMethod();";
+
+    JSValue compiled = JS_Eval (ctx, source, strlen (source), "<test-throwing-module>", JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
+
+    REQUIRE_FALSE (JS_IsException (compiled));
+
+    JSValue evalResult = JS_EvalFunction (ctx, compiled);
+
+    // this is the trap: the promise wrapper itself is never an "exception" JSValue, even though
+    // the module body threw - JS_IsException(evalResult) alone can never detect this.
+    REQUIRE_FALSE (JS_IsException (evalResult));
+    REQUIRE (JS_PromiseState (ctx, evalResult) == JS_PROMISE_REJECTED);
+
+    JSValue reason = JS_PromiseResult (ctx, evalResult);
+    const char* str = JS_ToCString (ctx, reason);
+    REQUIRE (str != nullptr);
+    CHECK (std::string (str).find ("undefined") != std::string::npos);
+
+    JS_FreeCString (ctx, str);
+    JS_FreeValue (ctx, reason);
+    JS_FreeValue (ctx, evalResult);
+
+    JS_FreeContext (ctx);
+    JS_FreeRuntime (rt);
+}
+
+namespace {
+JSClassID g_exoticProtoClassId = 0;
+
+// mirrors vector_property_get's shape: an exotic getter that only special-cases one hardcoded
+// name, matching how VectorAdapter only special-cases x/y/z/w.
+JSValue exoticGetOnlyX (JSContext* ctx, JSValueConst /*obj_val*/, JSAtom atom, JSValueConst /*receiver*/) {
+    const char* name = JS_AtomToCString (ctx, atom);
+    if (name != nullptr && strcmp (name, "x") == 0) {
+	JS_FreeCString (ctx, name);
+	return JS_NewFloat64 (ctx, 42.0);
+    }
+    if (name != nullptr) {
+	JS_FreeCString (ctx, name);
+    }
+
+    // falls back to a real prototype lookup instead of throwing for anything not special-cased -
+    // this is what lets obj.someMethod(...) reach a method defined on the class prototype.
+    JSValue proto = JS_GetClassProto (ctx, g_exoticProtoClassId);
+    JSValue result = JS_GetProperty (ctx, proto, atom);
+    JS_FreeValue (ctx, proto);
+    return result;
+}
+} // namespace
+
+TEST_CASE ("An exotic get_property handler that only special-cases some names must still reach "
+	   "prototype methods for everything else") {
+    JSRuntime* rt = JS_NewRuntime ();
+    JSContext* ctx = JS_NewContext (rt);
+
+    JS_NewClassID (rt, &g_exoticProtoClassId);
+    JSClassExoticMethods exotic = { .get_property = exoticGetOnlyX };
+    JSClassDef classDef = { .class_name = "ExoticWithProtoMethod", .exotic = &exotic };
+    JS_NewClass (rt, g_exoticProtoClassId, &classDef);
+
+    JSValue proto = JS_NewObject (ctx);
+    JS_SetPropertyStr (
+	ctx, proto, "double",
+	JS_NewCFunction (
+	    ctx,
+	    [] (JSContext* ctx, JSValueConst, int, JSValueConst*) -> JSValue { return JS_NewInt32 (ctx, 84); }, "double",
+	    0
+	)
+    );
+    JS_SetClassProto (ctx, g_exoticProtoClassId, proto);
+
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+    JS_SetPropertyStr (
+	ctx, globalObj, "make",
+	JS_NewCFunction (
+	    ctx,
+	    [] (JSContext* ctx, JSValueConst, int, JSValueConst*) -> JSValue {
+		JSValue obj = JS_NewObjectClass (ctx, g_exoticProtoClassId);
+		JS_SetOpaque (obj, reinterpret_cast<void*> (1));
+		return obj;
+	    },
+	    "make", 0
+	)
+    );
+    JS_FreeValue (ctx, globalObj);
+
+    // direct data property (x) still resolves via the exotic handler itself
+    const char* xSource = "make().x";
+    JSValue xResult = JS_Eval (ctx, xSource, strlen (xSource), "<x>", JS_EVAL_TYPE_GLOBAL);
+    REQUIRE_FALSE (JS_IsException (xResult));
+    double xVal = -1;
+    JS_ToFloat64 (ctx, &xVal, xResult);
+    CHECK (xVal == 42.0);
+    JS_FreeValue (ctx, xResult);
+
+    // a prototype method call must actually reach the method, not throw
+    const char* methodSource = "make().double()";
+    JSValue methodResult = JS_Eval (ctx, methodSource, strlen (methodSource), "<method>", JS_EVAL_TYPE_GLOBAL);
+    REQUIRE_FALSE (JS_IsException (methodResult));
+    int32_t methodVal = -1;
+    JS_ToInt32 (ctx, &methodVal, methodResult);
+    CHECK (methodVal == 84);
+    JS_FreeValue (ctx, methodResult);
+
+    JS_FreeContext (ctx);
+    JS_FreeRuntime (rt);
+}

+ 2 - 1
src/main.cpp

@@ -49,7 +49,8 @@ int main (int argc, char* argv[]) {
 
 	app = new WallpaperEngine::Application::WallpaperApplication (appContext);
 
-	if (appContext.settings.general.onlyListProperties || appContext.settings.general.onlyListObjects) {
+	if (appContext.settings.general.onlyListProperties || appContext.settings.general.onlyListObjects
+	    || appContext.settings.general.onlyListAudioObjects) {
 	    delete app;
 	    return 0;
 	}