Jelajahi Sumber

implemented audio responsive wallpapers, better management of audio in scene wallpapers, fixed issue which caused some wallpapers to crash

UwU 1 bulan lalu
induk
melakukan
3859f7e367

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

@@ -687,6 +687,31 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.flag ()
 	.action ([this] (const std::string& value) -> void { this->settings.audio.audioprocessing = false; });
 
+    audioGroup.add_argument ("--audio-screen")
+	.help (
+	    "Only the background on this screen (matches --screen-root names) produces audio; every other screen "
+	    "is muted regardless of its own sound. Default: no restriction, every screen can produce sound."
+	)
+	.action ([this] (const std::string& value) -> void {
+	    if (!value.empty ()) {
+		this->settings.audio.audioScreen = value;
+	    }
+	});
+
+    audioGroup.add_argument ("--ambient-volume")
+	.help (
+	    "Separate volume (0-128) for non-video backgrounds (scene sound objects and web wallpapers), instead "
+	    "of --volume; video wallpapers always keep using --volume. Web wallpapers only support mute (0) vs "
+	    "unmuted (any other value), since CEF exposes no analog volume control. Default: same as --volume."
+	)
+	.action ([this] (const std::string& value) -> void {
+	    try {
+		this->settings.audio.ambientVolume = std::stoi (value);
+	    } catch (const std::exception&) {
+		sLog.exception ("Invalid --ambient-volume value: ", value);
+	    }
+	});
+
     auto& screenshotGroup = program.add_group ("Screenshot options");
 
     screenshotGroup.add_argument ("--screenshot")
@@ -854,6 +879,9 @@ void ApplicationContext::loadSettingsFromArgv () {
 	}
 
 	this->settings.audio.volume = std::max (0, std::min (this->settings.audio.volume, 128));
+	if (this->settings.audio.ambientVolume.has_value ()) {
+	    this->settings.audio.ambientVolume = std::max (0, std::min (*this->settings.audio.ambientVolume, 128));
+	}
 	this->settings.screenshot.delay
 	    = std::max<uint32_t> (0, std::min<uint32_t> (this->settings.screenshot.delay, 5));
 

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

@@ -164,6 +164,10 @@ public:
 	    int volume;
 	    bool automute;
 	    bool audioprocessing;
+	    /** Only this screen (matches --screen-root names) produces audio; nullopt = no restriction */
+	    std::optional<std::string> audioScreen;
+	    /** 0-128, applied to non-video (scene sound + web) backgrounds instead of volume; nullopt = use volume */
+	    std::optional<int> ambientVolume;
 	} audio;
 
 	// Mouse input settings
@@ -233,6 +237,8 @@ public:
             .volume = 15,
             .automute = false,
             .audioprocessing = true,
+            .audioScreen = std::nullopt,
+            .ambientVolume = std::nullopt,
         },
         .mouse = {
             .enabled = true,

+ 84 - 3
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -476,6 +476,7 @@ void WallpaperApplication::advancePlaylist (
 	    wallpaper->setZoom (this->resolveScreenZoom (screen));
 	    wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
 	    this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
+	    this->applyAudioPolicy ();
 	}
 
 	this->m_context.settings.general.screenBackgrounds[screen] = nextPath;
@@ -545,6 +546,10 @@ struct HotswapRequest {
     std::optional<std::string> cornerColor;
     /** floating-point playback speed multiplier, e.g. "0.5" */
     std::optional<std::string> speed;
+    /** screen name to restrict audio to, or "" to clear the restriction, see --audio-screen */
+    std::optional<std::string> audioScreen;
+    /** 0-128, see --ambient-volume */
+    std::optional<std::string> ambientVolume;
 };
 
 std::string trimHotswapToken (const std::string& value) {
@@ -565,8 +570,9 @@ std::string trimHotswapToken (const std::string& value) {
 /**
  * Parses the control file. Supports the original bare-path-on-one-line format for backwards
  * compatibility, plus key=value lines (path/layers/disable-object/enable-object/volume/xray/scaling/zoom/
- * disable-parallax/corner-color/speed/property) so a single request can carry more than just the
- * background path. "property=name=value" (repeatable) carries --set-property-equivalent overrides.
+ * disable-parallax/corner-color/speed/audio-screen/ambient-volume/property) so a single request can carry
+ * more than just the background path. "property=name=value" (repeatable) carries --set-property-equivalent
+ * overrides.
  */
 HotswapRequest parseHotswapRequest (std::istream& file) {
     HotswapRequest request;
@@ -621,6 +627,10 @@ HotswapRequest parseHotswapRequest (std::istream& file) {
 	    request.cornerColor = value;
 	} else if (key == "speed") {
 	    request.speed = value;
+	} else if (key == "audio-screen") {
+	    request.audioScreen = value;
+	} else if (key == "ambient-volume") {
+	    request.ambientVolume = value;
 	} else if (key == "property") {
 	    const auto propSeparator = value.find ('=');
 
@@ -659,7 +669,8 @@ void WallpaperApplication::checkHotswapRequest () {
     if (!request.path.has_value () && !request.layersProvided && !request.volume.has_value ()
 	&& !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.propertiesProvided) {
+	&& !request.speed.has_value () && !request.audioScreen.has_value () && !request.ambientVolume.has_value ()
+	&& !request.propertiesProvided) {
 	sLog.error ("Hotswap requested but control file was empty");
 	return;
     }
@@ -692,6 +703,14 @@ void WallpaperApplication::checkHotswapRequest () {
 	this->applySpeedHotswap (*request.speed);
     }
 
+    if (request.audioScreen.has_value ()) {
+	this->applyAudioScreenHotswap (*request.audioScreen);
+    }
+
+    if (request.ambientVolume.has_value ()) {
+	this->applyAmbientVolumeHotswap (*request.ambientVolume);
+    }
+
     if (request.layersProvided) {
 	this->m_context.settings.general.disabledObjects = request.disabledObjects;
 	this->m_context.settings.general.enabledObjects = request.enabledObjects;
@@ -770,6 +789,8 @@ void WallpaperApplication::checkHotswapRequest () {
 	}
     }
 
+    this->applyAudioPolicy ();
+
     if (request.path.has_value ()) {
 	this->m_context.settings.general.defaultBackground = *request.path;
     }
@@ -994,6 +1015,55 @@ void WallpaperApplication::applySpeedHotswap (const std::string& value) {
     sLog.out ("Hotswap: applied speed ", speed, " live");
 }
 
+void WallpaperApplication::applyAudioScreenHotswap (const std::string& value) {
+    this->m_context.settings.audio.audioScreen = value.empty () ? std::nullopt : std::optional<std::string> (value);
+
+    this->applyAudioPolicy ();
+
+    sLog.out ("Hotswap: applied audio screen '", value, "' live");
+}
+
+void WallpaperApplication::applyAmbientVolumeHotswap (const std::string& value) {
+    int volume;
+
+    try {
+	volume = std::stoi (value);
+    } catch (const std::exception&) {
+	sLog.error ("Hotswap: ignoring invalid ambient volume value: ", value);
+	return;
+    }
+
+    this->m_context.settings.audio.ambientVolume = std::max (0, std::min (volume, 128));
+
+    this->applyAudioPolicy ();
+
+    sLog.out ("Hotswap: applied ambient volume ", volume, " live");
+}
+
+void WallpaperApplication::applyAudioPolicy () {
+    if (!this->m_renderContext) {
+	return;
+    }
+
+    const auto& audioScreen = this->m_context.settings.audio.audioScreen;
+    const auto& ambientVolume = this->m_context.settings.audio.ambientVolume;
+
+    std::map<WallpaperEngine::Render::CWallpaper*, bool> wantMuted;
+
+    for (const auto& [screen, wallpaper] : this->m_renderContext->getWallpapers ()) {
+	const bool screenMuted = audioScreen.has_value () && screen != *audioScreen;
+	auto [it, inserted] = wantMuted.try_emplace (wallpaper.get (), screenMuted);
+
+	if (!inserted) {
+	    it->second = it->second && screenMuted;
+	}
+    }
+
+    for (const auto& [wallpaper, muted] : wantMuted) {
+	wallpaper->setAudioPolicy (muted, ambientVolume);
+    }
+}
+
 void WallpaperApplication::setupPropertiesForProject (const Project& project) {
     for (const auto& [key, cur] : project.properties) {
 	auto override = this->m_context.settings.general.properties.find (key);
@@ -1129,6 +1199,8 @@ void WallpaperApplication::runWebHost () {
     Input::MouseClickStatus lastRight = Input::Released;
     uint32_t lastDesiredWidth = shm->desiredWidth.load (std::memory_order_relaxed);
     uint32_t lastDesiredHeight = shm->desiredHeight.load (std::memory_order_relaxed);
+    bool lastAudioMuted = shm->audioMuted.load (std::memory_order_relaxed);
+    browser->GetHost ()->SetAudioMuted (lastAudioMuted);
 
     while (!shm->quitRequested.load (std::memory_order_acquire)) {
 	CefDoMessageLoopWork ();
@@ -1142,6 +1214,13 @@ void WallpaperApplication::runWebHost () {
 	    browser->GetHost ()->WasResized ();
 	}
 
+	const bool desiredAudioMuted = shm->audioMuted.load (std::memory_order_relaxed);
+
+	if (desiredAudioMuted != lastAudioMuted) {
+	    lastAudioMuted = desiredAudioMuted;
+	    browser->GetHost ()->SetAudioMuted (lastAudioMuted);
+	}
+
 	CefMouseEvent evt;
 	evt.x = static_cast<int> (shm->mouseX.load (std::memory_order_relaxed));
 	evt.y = static_cast<int> (shm->mouseY.load (std::memory_order_relaxed));
@@ -1446,6 +1525,8 @@ void WallpaperApplication::prepareOutputs () {
 	    m_renderContext->setWallpaper (screenName, shared);
 	}
     }
+
+    this->applyAudioPolicy ();
 }
 
 void WallpaperApplication::setupOpenGLDebugging () {

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

@@ -140,6 +140,23 @@ private:
      */
     void applySpeedHotswap (const std::string& value);
 
+    /**
+     * Pushes a new --audio-screen restriction live (empty value clears it back to "no
+     * restriction"), then re-applies audio policy to every currently rendered wallpaper.
+     */
+    void applyAudioScreenHotswap (const std::string& value);
+
+    /** Pushes a new --ambient-volume (0-128) live, then re-applies audio policy to every currently rendered wallpaper */
+    void applyAmbientVolumeHotswap (const std::string& value);
+
+    /**
+     * Recomputes and pushes CWallpaper::setAudioPolicy() to every currently rendered wallpaper
+     * based on settings.audio.audioScreen/ambientVolume. Span-group screens share a single
+     * CWallpaper instance across several screen names, so this groups by instance first and
+     * only mutes one if none of its screens match audioScreen.
+     */
+    void applyAudioPolicy ();
+
     /**
      * Figures out what background a given screen is currently showing, so a layers-only
      * hotswap can reload it without the caller having to resend the path

+ 2 - 0
src/WallpaperEngine/Audio/AudioContext.cpp

@@ -7,6 +7,8 @@ AudioContext::AudioContext (Drivers::AudioDriver& driver) : m_driver (driver) {
 int AudioContext::addStream (AudioStream* stream) const { return this->m_driver.addStream (stream); }
 void AudioContext::removeStream (int streamId) const { this->m_driver.removeStream (streamId); }
 
+void AudioContext::setStreamVolume (int streamId, int volume) const { this->m_driver.setStreamVolume (streamId, volume); }
+
 AVSampleFormat AudioContext::getFormat () const { return this->m_driver.getFormat (); }
 
 int AudioContext::getSampleRate () const { return this->m_driver.getSampleRate (); }

+ 8 - 0
src/WallpaperEngine/Audio/AudioContext.h

@@ -38,6 +38,14 @@ namespace Audio {
 	 */
 	void removeStream (int streamId) const;
 
+	/**
+	 * Overrides the volume used to mix a single stream, instead of the driver's global volume
+	 *
+	 * @param streamId The stream to change
+	 * @param volume 0-128, or a negative value to go back to using the global volume
+	 */
+	void setStreamVolume (int streamId, int volume) const;
+
 	/**
 	 * TODO: MAYBE THIS SHOULD BE OUR OWN DEFINITIONS INSTEAD OF LIBRARY SPECIFIC ONES?
 	 *

+ 8 - 0
src/WallpaperEngine/Audio/Drivers/AudioDriver.h

@@ -48,6 +48,14 @@ namespace Audio {
 	     */
 	    virtual void removeStream (int streamId) = 0;
 
+	    /**
+	     * Overrides the volume used to mix a single stream, instead of the driver's global volume
+	     *
+	     * @param streamId The stream to change
+	     * @param volume 0-128, or a negative value to go back to using the global volume
+	     */
+	    virtual void setStreamVolume (int streamId, int volume) = 0;
+
 	    /**
 	     * Updates status of the different audio settings
 	     */

+ 14 - 2
src/WallpaperEngine/Audio/Drivers/SDLAudioDriver.cpp

@@ -57,10 +57,12 @@ void audio_callback (void* userdata, uint8_t* streamData, int length) {
 		len1 = streamLength;
 	    }
 
-	    // mix the audio
+	    // mix the audio, using this stream's own volume override if it has one
+	    const int streamVolume = buffer->volume.load (std::memory_order_relaxed);
+
 	    SDL_MixAudioFormat (
 		streamDataPointer, &buffer->audio_buf[buffer->audio_buf_index], driver->getSpec ().format, len1,
-		driver->getApplicationContext ().state.audio.volume
+		streamVolume >= 0 ? streamVolume : driver->getApplicationContext ().state.audio.volume
 	    );
 
 	    streamLength -= len1;
@@ -145,6 +147,16 @@ void SDLAudioDriver::removeStream (int streamId) {
     SDL_UnlockMutex (this->m_streamListMutex);
 }
 
+void SDLAudioDriver::setStreamVolume (int streamId, int volume) {
+    SDL_LockMutex (this->m_streamListMutex);
+
+    if (const auto it = this->m_streams.find (streamId); it != this->m_streams.end ()) {
+	it->second->volume.store (volume, std::memory_order_relaxed);
+    }
+
+    SDL_UnlockMutex (this->m_streamListMutex);
+}
+
 const std::map<int, SDLAudioBuffer*>& SDLAudioDriver::getStreams () { return this->m_streams; }
 
 AVSampleFormat SDLAudioDriver::getFormat () const {

+ 5 - 0
src/WallpaperEngine/Audio/Drivers/SDLAudioDriver.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <atomic>
 #include <map>
 #include <vector>
 
@@ -19,6 +20,8 @@ struct SDLAudioBuffer {
     uint8_t audio_buf[(MAX_AUDIO_FRAME_SIZE * 3) / 2] = { 0 };
     unsigned int audio_buf_size = 0;
     unsigned int audio_buf_index = 0;
+    /** Per-stream volume override (0-128), -1 = use the driver's global volume */
+    std::atomic<int> volume { -1 };
 };
 
 /**
@@ -36,6 +39,8 @@ public:
     int addStream (AudioStream* stream) override;
     /** @inheritdoc */
     void removeStream (int streamId) override;
+    /** @inheritdoc */
+    void setStreamVolume (int streamId, int volume) override;
     /**
      * @return All the registered audio streams
      */

+ 2 - 2
src/WallpaperEngine/Data/Model/Object.h

@@ -612,8 +612,8 @@ struct TextData {
     std::string alignment;
     /** Vertical alignment: "top", "center", "bottom" */
     std::string verticalalign;
-    /** Padding inside the bounding box */
-    int padding;
+    /** Padding inside the bounding box (x = horizontal, y = vertical) */
+    glm::vec2 padding;
     /** The effects applied to this text after the glyphs are rendered */
     std::vector<ImageEffectUniquePtr> effects;
     // TODO: PARSE LIMITS TOO!

+ 1 - 1
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -139,7 +139,7 @@ TextUniquePtr ObjectParser::parseText (const JSON& it, const Project& project, O
 	    .parallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (0.0f)),
 	    .alignment = it.optional ("horizontalalign", it.optional ("alignment", std::string ("center"))),
 	    .verticalalign = it.optional ("verticalalign", std::string ("center")),
-	    .padding = it.optional ("padding", 0),
+	    .padding = it.optional ("padding", glm::vec2 (0.0f)),
 	    .effects = effects.has_value () ? parseEffects (*effects, project) : std::vector<ImageEffectUniquePtr> {},
 	}
     );

+ 2 - 0
src/WallpaperEngine/Render/CWallpaper.cpp

@@ -284,6 +284,8 @@ void CWallpaper::render (
 
 void CWallpaper::setPause (bool newState) { }
 
+void CWallpaper::setAudioPolicy (bool muted, std::optional<int> ambientVolume) { }
+
 void CWallpaper::setupFramebuffers () {
     const uint32_t width = this->getWidth ();
     const uint32_t height = this->getHeight ();

+ 9 - 0
src/WallpaperEngine/Render/CWallpaper.h

@@ -51,6 +51,15 @@ public:
 
     virtual void setPause (bool newState);
 
+    /**
+     * Applies the current audio policy to this wallpaper (see --audio-screen/--ambient-volume):
+     * whether it should be muted outright (its screen isn't the designated audio screen), and
+     * the volume non-video content should use instead of the main --volume, if any.
+     *
+     * Default no-op; overridden by wallpaper types that actually produce audio.
+     */
+    virtual void setAudioPolicy (bool muted, std::optional<int> ambientVolume);
+
     [[nodiscard]] const AssetLocator& getAssetLocator () const;
     AudioContext& getAudioContext () const;
     [[nodiscard]] const WallpaperState& getState () const;

+ 8 - 0
src/WallpaperEngine/Render/Objects/CSound.cpp

@@ -35,3 +35,11 @@ void CSound::load () {
 }
 
 void CSound::render () { }
+
+void CSound::setVolumeOverride (std::optional<int> volume) {
+    const int driverValue = volume.has_value () ? *volume : -1;
+
+    for (const auto& entry : this->m_audioStreams) {
+	this->getScene ().getAudioContext ().setStreamVolume (entry.first, driverValue);
+    }
+}

+ 5 - 0
src/WallpaperEngine/Render/Objects/CSound.h

@@ -1,5 +1,7 @@
 #pragma once
 
+#include <optional>
+
 #include "WallpaperEngine/Audio/AudioStream.h"
 #include "WallpaperEngine/Render/CObject.h"
 
@@ -19,6 +21,9 @@ public:
 
     void render () override;
 
+    /** Overrides the volume (0-128) this sound's streams mix at instead of the global volume; nullopt = global */
+    void setVolumeOverride (std::optional<int> volume);
+
 protected:
     void load ();
 

+ 5 - 6
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -622,22 +622,21 @@ void CText::render () {
     // post-scale size so edges land on the padded box boundary regardless of "scale".
     const float scaledHalfWidth = m_quadSize.x * 0.5f * scale.x;
     const float scaledHalfHeight = m_quadSize.y * 0.5f * scale.y;
-    const float padding = static_cast<float> (m_text.padding);
 
     float offsetX;
     if (m_text.alignment == "left") {
-	offsetX = -m_text.size.x * 0.5f + padding + scaledHalfWidth;
+	offsetX = -m_text.size.x * 0.5f + m_text.padding.x + scaledHalfWidth;
     } else if (m_text.alignment == "right") {
-	offsetX = m_text.size.x * 0.5f - padding - scaledHalfWidth;
+	offsetX = m_text.size.x * 0.5f - m_text.padding.x - scaledHalfWidth;
     } else {
 	offsetX = 0.0f;
     }
 
     float offsetY;
     if (m_text.verticalalign == "top") {
-	offsetY = -m_text.size.y * 0.5f + padding + scaledHalfHeight;
+	offsetY = -m_text.size.y * 0.5f + m_text.padding.y + scaledHalfHeight;
     } else if (m_text.verticalalign == "bottom") {
-	offsetY = m_text.size.y * 0.5f - padding - scaledHalfHeight;
+	offsetY = m_text.size.y * 0.5f - m_text.padding.y - scaledHalfHeight;
     } else {
 	offsetY = 0.0f;
     }
@@ -681,7 +680,7 @@ void CText::render () {
 	    "[text-debug] '", m_text.name, "' pointSize=", m_text.pointSize->value->getFloat (), " scale=", scale.x, ",",
 	    scale.y, " pixelSize=", pixelSize, " quadSize=", m_quadSize.x, ",", m_quadSize.y,
 	    " scaledHalf=", scaledHalfWidth, ",", scaledHalfHeight, " size=", m_text.size.x, ",", m_text.size.y,
-	    " padding=", m_text.padding, " align=", m_text.alignment, "/", m_text.verticalalign, " origin=", origin.x,
+	    " padding=", m_text.padding.x, ",", m_text.padding.y, " align=", m_text.alignment, "/", m_text.verticalalign, " origin=", origin.x,
 	    ",", origin.y, " offset=", offsetX, ",", offsetY, " scene=", scene_w, ",", scene_h, " gl_origin=",
 	    gl_origin.x, ",", gl_origin.y, " effects=", m_text.effects.size (), " passes=", m_passes.size ()
 	);

+ 10 - 0
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -435,3 +435,13 @@ const CObject* CScene::getObject (int id) const {
     const auto object = this->m_objects.find (id);
     return object == this->m_objects.end () ? nullptr : object->second;
 }
+
+void CScene::setAudioPolicy (bool muted, std::optional<int> ambientVolume) {
+    const std::optional<int> volume = muted ? std::optional<int> (0) : ambientVolume;
+
+    for (const auto& entry : this->m_objects) {
+	if (entry.second->is<Objects::CSound> ()) {
+	    entry.second->as<Objects::CSound> ()->setVolumeOverride (volume);
+	}
+    }
+}

+ 2 - 0
src/WallpaperEngine/Render/Wallpapers/CScene.h

@@ -45,6 +45,8 @@ public:
     [[nodiscard]] const std::vector<CObject*>& getObjectsByRenderOrder () const;
     [[nodiscard]] const CObject* getObject (int id) const;
 
+    void setAudioPolicy (bool muted, std::optional<int> ambientVolume) override;
+
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
     void updateMouse (const glm::ivec4& viewport);

+ 28 - 11
src/WallpaperEngine/Render/Wallpapers/CVideo.cpp

@@ -37,21 +37,38 @@ CVideo::CVideo (
 CVideo::~CVideo () { this->m_player->decrementUsageCount (); }
 
 void CVideo::renderFrame (const glm::ivec4& viewport) {
-    // ensure the video's audio follows audio detection rules
-    if (this->getContext ().getApp ().getContext ().settings.audio.enabled
-	&& this->m_muted != this->getAudioContext ().getDriver ().getAudioDetector ().anythingPlaying ()) {
-	this->m_muted = !this->m_muted;
-
-	if (this->m_muted) {
-	    this->m_player->setMuted ();
-	} else {
-	    this->m_player->clearMuted ();
-	}
-    }
+    // ensure the video's audio follows audio detection rules and --audio-screen
+    this->updateMuteState ();
 
     this->m_player->render ();
 }
 
+void CVideo::updateMuteState () {
+    if (!this->getContext ().getApp ().getContext ().settings.audio.enabled) {
+	return;
+    }
+
+    const bool desiredMuted
+	= this->m_forceMuted || this->getAudioContext ().getDriver ().getAudioDetector ().anythingPlaying ();
+
+    if (this->m_muted == desiredMuted) {
+	return;
+    }
+
+    this->m_muted = desiredMuted;
+
+    if (this->m_muted) {
+	this->m_player->setMuted ();
+    } else {
+	this->m_player->clearMuted ();
+    }
+}
+
+void CVideo::setAudioPolicy (bool muted, std::optional<int> ambientVolume) {
+    this->m_forceMuted = muted;
+    this->updateMuteState ();
+}
+
 const Data::Model::Video& CVideo::getVideo () const { return *this->getWallpaperData ().as<Data::Model::Video> (); }
 
 void CVideo::setVolume (double volume) { this->m_player->setVolume (volume); }

+ 7 - 0
src/WallpaperEngine/Render/Wallpapers/CVideo.h

@@ -29,14 +29,21 @@ public:
     /** Pushes a new playback speed multiplier to the underlying mpv player without a reload, see --speed */
     void setSpeed (double speed);
 
+    /** ambientVolume is ignored - video wallpapers always use --volume, only muted matters here */
+    void setAudioPolicy (bool muted, std::optional<int> ambientVolume) override;
+
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
 
     friend class CWallpaper;
 
 private:
+    void updateMuteState ();
+
     GLPlayerUniquePtr m_player;
 
     bool m_muted = false;
+    /** Forced mute from --audio-screen (this screen isn't the designated audio screen) */
+    bool m_forceMuted = false;
 };
 } // namespace WallpaperEngine::Render::Wallpapers

+ 10 - 0
src/WallpaperEngine/Render/Wallpapers/CWeb.cpp

@@ -112,6 +112,16 @@ void CWeb::spawnHost (const std::filesystem::path& resolvedBackgroundPath) {
     this->m_hostPid = pid;
 }
 
+void CWeb::setAudioPolicy (bool muted, std::optional<int> ambientVolume) {
+    if (this->m_shm == nullptr) {
+	return;
+    }
+
+    const bool shouldMute = muted || (ambientVolume.has_value () && *ambientVolume == 0);
+
+    this->m_shm->audioMuted.store (shouldMute, std::memory_order_relaxed);
+}
+
 void CWeb::setSize (const int width, const int height) {
     this->m_width = width > 0 ? width : this->m_width;
     this->m_height = height > 0 ? height : this->m_height;

+ 3 - 0
src/WallpaperEngine/Render/Wallpapers/CWeb.h

@@ -40,6 +40,9 @@ public:
 
     void setSize (int width, int height);
 
+    /** ambientVolume==0 (or muted) mutes the host's CEF browser; CEF exposes no analog volume control */
+    void setAudioPolicy (bool muted, std::optional<int> ambientVolume) override;
+
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
     void updateMouse (const glm::ivec4& viewport);

+ 88 - 2
src/WallpaperEngine/Scripting/EngineObject.cpp

@@ -2,6 +2,10 @@
 #include "ScriptEngine.h"
 #include "WallpaperEngine/Logging/Log.h"
 
+#include "WallpaperEngine/Audio/Drivers/AudioDriver.h"
+#include "WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h"
+#include "WallpaperEngine/Render/Wallpapers/CScene.h"
+
 #include <ranges>
 
 using namespace WallpaperEngine::Scripting;
@@ -105,6 +109,74 @@ JSValue engine_set_interval (JSContext* ctx, JSValueConst this_val, int argc, JS
     return JS_NewCFunctionData (ctx, engine_stop_interval, 2, magic, 1, args);
 }
 
+// Backs the "average"/"left"/"right" getters on the object returned by registerAudioBuffers().
+// The recorder only ever produces one (mono) spectrum - see PulseAudioPlaybackRecorder - so all
+// three read the same data, matching how CPass already binds it to both the Left and Right
+// g_AudioSpectrum shader uniforms.
+JSValue audio_buffer_get_values (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    int engineInstanceId = 0;
+    int resolution = 32;
+
+    JS_ToInt32 (ctx, &engineInstanceId, func_data[0]);
+    JS_ToInt32 (ctx, &resolution, func_data[1]);
+
+    JSValue result = JS_NewArray (ctx);
+
+    const auto it = engineInstances.find (engineInstanceId);
+
+    if (it == engineInstances.end ()) {
+	return result;
+    }
+
+    const auto& recorder = it->second.getScene ().getAudioContext ().getDriver ().getRecorder ();
+    const float* data = recorder.audio32;
+
+    if (resolution == 16) {
+	data = recorder.audio16;
+    } else if (resolution == 64) {
+	data = recorder.audio64;
+    }
+
+    for (int i = 0; i < resolution; i++) {
+	JS_SetPropertyUint32 (ctx, result, i, JS_NewFloat64 (ctx, data[i]));
+    }
+
+    return result;
+}
+
+// engine.registerAudioBuffers(resolution): resolution must be 16, 32 or 64 (falls back to 32
+// otherwise), matching the AUDIO_RESOLUTION_* constants below. Returns an object whose
+// average/left/right properties are re-read from the live FFT spectrum every access, so scripts
+// that poll them from an update() callback see current values each frame.
+JSValue engine_register_audio_buffers (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    int resolution = 32;
+
+    if (argc > 0) {
+	JS_ToInt32 (ctx, &resolution, argv[0]);
+    }
+
+    if (resolution != 16 && resolution != 32 && resolution != 64) {
+	resolution = 32;
+    }
+
+    JSValue result = JS_NewObject (ctx);
+    static constexpr const char* properties[] = { "average", "left", "right" };
+
+    for (const char* property : properties) {
+	JSValue closureData[] = { JS_NewInt32 (ctx, magic), JS_NewInt32 (ctx, resolution) };
+
+	JS_DefinePropertyGetSet (
+	    ctx, result, JS_NewAtom (ctx, property),
+	    JS_NewCFunctionData (ctx, audio_buffer_get_values, 0, 0, 2, closureData),
+	    JS_NewCFunction (ctx, engine_set_value, "set", 1), JS_PROP_ENUMERABLE
+	);
+    }
+
+    return result;
+}
+
 JSValue engine_set_timeout (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
     if (argc < 1) {
 	return JS_EXCEPTION;
@@ -137,6 +209,10 @@ JSValue engine_set_timeout (JSContext* ctx, JSValueConst this_val, int argc, JSV
 
 EngineObject::EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene) :
     m_scene (scene), m_engine (engine), m_instanceId (++EngineInstanceId), m_classId (0) {
+    // required so setInterval/setTimeout/registerAudioBuffers's magic-encoded instance id can find
+    // their way back to this object from the free-standing JS callback functions above
+    engineInstances.emplace (this->m_instanceId, *this);
+
     this->m_definition = { .class_name = "IEngine" };
     JS_NewClassID (this->m_engine.getRuntime (), &this->m_classId);
     JS_NewClass (this->m_engine.getRuntime (), this->m_classId, &this->m_definition);
@@ -176,14 +252,16 @@ EngineObject::EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& sc
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_instance, "setInterval",
 	JS_NewCFunctionMagic (
-	    this->m_engine.getContext (), engine_set_interval, "setInterval", 2, JS_CFUNC_generic, this->m_instanceId
+	    this->m_engine.getContext (), engine_set_interval, "setInterval", 2, JS_CFUNC_generic_magic,
+	    this->m_instanceId
 	),
 	JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_instance, "setTimeout",
 	JS_NewCFunctionMagic (
-	    this->m_engine.getContext (), engine_set_timeout, "setTimeout", 2, JS_CFUNC_generic, this->m_instanceId
+	    this->m_engine.getContext (), engine_set_timeout, "setTimeout", 2, JS_CFUNC_generic_magic,
+	    this->m_instanceId
 	),
 	JS_PROP_ENUMERABLE
     );
@@ -192,6 +270,14 @@ EngineObject::EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& sc
 	JS_NewCFunction (this->m_engine.getContext (), engine_open_user_shortcut, "openUserShortcut", 0),
 	JS_PROP_ENUMERABLE
     );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "registerAudioBuffers",
+	JS_NewCFunctionMagic (
+	    this->m_engine.getContext (), engine_register_audio_buffers, "registerAudioBuffers", 1,
+	    JS_CFUNC_generic_magic, this->m_instanceId
+	),
+	JS_PROP_ENUMERABLE
+    );
     // TODO: ADD THE REST OF THE DEFINITION!
 }
 

+ 2 - 0
src/WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h

@@ -31,6 +31,8 @@ struct WebHostSharedMemory {
 
     // Main process -> host process
     std::atomic<bool> quitRequested { false };
+    // Whether the browser's audio should be muted, see --audio-screen/--ambient-volume
+    std::atomic<bool> audioMuted { false };
     std::atomic<double> mouseX { 0.0 };
     std::atomic<double> mouseY { 0.0 };
     // Matches WallpaperEngine::Input::MouseClickStatus (Released = 0, Clicked = 1)