diff --git a/src/ipa/softisp/algorithms/agc.cpp b/src/ipa/softisp/algorithms/agc.cpp
index ca403300..b6fc7203 100644
--- a/src/ipa/softisp/algorithms/agc.cpp
+++ b/src/ipa/softisp/algorithms/agc.cpp
@@ -66,12 +66,37 @@ static constexpr float kExpProportionalGain = 0.04;
  */
 static constexpr float kExpMaxStep = 0.15;
 
+/*
+ * Errors above this threshold are far from the target, and the small
+ * proportional steps would need tens of statistics periods to get there
+ * (a statistics period being several frames, and the frame possibly long
+ * in low light). For those, jump by the ratio between the target and the
+ * measured MSV instead, bounded by kExpMaxJump per step. The proportional
+ * correction takes over near the target, so the convergence stays smooth.
+ */
+static constexpr float kExpLargeError = 0.5;
+static constexpr float kExpMaxJump = 2.0;
+
+/*
+ * Digital gain is applied by the ISP on top of the sensor exposure and
+ * analogue gain, only when those are exhausted. It doesn't add information,
+ * so the tuning file bounds it with maxDigitalGain (1.0 disables it).
+ */
+static constexpr double kDefaultMaxDigitalGain = 1.0;
+
 Agc::Agc()
 {
 }
 
-int Agc::init(IPAContext &context, [[maybe_unused]] const ValueNode &tuningData)
+int Agc::init(IPAContext &context, const ValueNode &tuningData)
 {
+	maxDigitalGain_ = tuningData["maxDigitalGain"].get<double>(kDefaultMaxDigitalGain);
+	if (maxDigitalGain_ < 1.0) {
+		LOG(IPASoftIspExposure, Warning)
+			<< "maxDigitalGain " << maxDigitalGain_ << " below 1.0, ignored";
+		maxDigitalGain_ = 1.0;
+	}
+
 	/*
 	 * Expose the frame duration limits the sensor can achieve in the
 	 * current mode. Whether the IPA can actually change the frame duration
@@ -116,10 +141,24 @@ int Agc::configure(IPAContext &context, [[maybe_unused]] const IPAConfigInfo &co
 		agc.maxFrameDuration = agc.minFrameDuration;
 	}
 	agc.vblank = cfg.vblankDef;
+	agc.dgain = 1.0;
+	context.configuration.agc.dgainMax = maxDigitalGain_;
 
 	return 0;
 }
 
+void Agc::prepare(IPAContext &context, [[maybe_unused]] const uint32_t frame,
+		  IPAFrameContext &frameContext, DebayerParams *params)
+{
+	/*
+	 * The digital gain scales the colour gains the ISP applies after black
+	 * level subtraction. This runs after the AWB has set the gains, as the
+	 * Agc algorithm is listed after Awb in the tuning file.
+	 */
+	frameContext.agc.digitalGain = context.activeState.agc.dgain;
+	params->gains *= frameContext.agc.digitalGain;
+}
+
 void Agc::queueRequest(IPAContext &context, [[maybe_unused]] const uint32_t frame,
 		       IPAFrameContext &frameContext, const ControlList &controls)
 {
@@ -201,6 +240,8 @@ void Agc::updateExposure(IPAContext &context, IPAFrameContext &frameContext, dou
 	int32_t &exposure = frameContext.sensor.exposure;
 	double &again = frameContext.sensor.gain;
 	int32_t &vblank = frameContext.sensor.vblank;
+	double &dgain = frameContext.agc.digitalGain;
+	const double dgainMax = context.configuration.agc.dgainMax;
 
 	int32_t vblankLo, vblankHi;
 	vblankRange(context, frameContext, vblankLo, vblankHi);
@@ -227,32 +268,47 @@ void Agc::updateExposure(IPAContext &context, IPAFrameContext &frameContext, dou
 	}
 
 	/*
-	 * Compute a proportional correction factor. The sign of the error
-	 * determines the direction: positive error means too dark (increase),
-	 * negative means too bright (decrease).
+	 * Compute the correction factor. The sign of the error determines the
+	 * direction: positive error means too dark (increase), negative means
+	 * too bright (decrease). Far from the target, jump by the measured
+	 * ratio; near it, apply a small proportional step.
 	 */
-	float step = std::clamp(static_cast<float>(error) * kExpProportionalGain,
-				-kExpMaxStep, kExpMaxStep);
-	float factor = 1.0f + step;
+	float factor;
+	if (std::abs(error) > kExpLargeError) {
+		factor = std::clamp(static_cast<float>(kExposureOptimal / std::max(exposureMSV, 0.1)),
+				    1.0f / kExpMaxJump, kExpMaxJump);
+	} else {
+		float step = std::clamp(static_cast<float>(error) * kExpProportionalGain,
+					-kExpMaxStep, kExpMaxStep);
+		factor = 1.0f + step;
+	}
 
 	if (factor > 1.0f) {
 		/*
 		 * Scene too dark: increase exposure first (lengthening the
-		 * frame when the limits allow it), then gain.
+		 * frame when the limits allow it), then analogue gain, then
+		 * digital gain.
 		 */
 		if (exposure < exposureMax) {
 			int32_t next = static_cast<int32_t>(exposure * factor);
 			exposure = std::max(next, exposure + 1);
-		} else {
+		} else if (again < context.configuration.agc.againMax) {
 			double next = again * factor;
 			if (next - again < context.configuration.agc.againMinStep)
 				again += context.configuration.agc.againMinStep;
 			else
 				again = next;
+		} else {
+			dgain = std::min(dgain * factor, dgainMax);
 		}
 	} else {
-		/* Scene too bright: decrease gain first, then exposure. */
-		if (again > context.configuration.agc.again10) {
+		/*
+		 * Scene too bright: decrease digital gain first, then analogue
+		 * gain, then exposure.
+		 */
+		if (dgain > 1.0) {
+			dgain = std::max(dgain * factor, 1.0);
+		} else if (again > context.configuration.agc.again10) {
 			double next = again * factor;
 			if (again - next < context.configuration.agc.againMinStep)
 				again -= context.configuration.agc.againMinStep;
@@ -272,14 +328,18 @@ void Agc::updateExposure(IPAContext &context, IPAFrameContext &frameContext, dou
 				    static_cast<int32_t>(context.configuration.agc.frameHeight),
 			    vblankLo, vblankHi);
 
+	dgain = std::clamp(dgain, 1.0, dgainMax);
+
 	context.activeState.agc.exposure = exposure;
 	context.activeState.agc.again = again;
 	context.activeState.agc.vblank = vblank;
+	context.activeState.agc.dgain = dgain;
 
 	LOG(IPASoftIspExposure, Debug)
 		<< "exposureMSV " << exposureMSV
 		<< " error " << error << " factor " << factor
 		<< " exp " << exposure << " again " << again
+		<< " dgain " << dgain
 		<< " vblank " << vblank << " (" << vblankLo << "-" << vblankHi << ")";
 }
 
@@ -293,6 +353,7 @@ void Agc::process(IPAContext &context,
 	utils::Duration exposureTime = cfg.lineDuration * frameContext.sensor.exposure;
 	metadata.set(controls::ExposureTime, exposureTime.get<std::micro>());
 	metadata.set(controls::AnalogueGain, frameContext.sensor.gain);
+	metadata.set(controls::DigitalGain, static_cast<float>(frameContext.agc.digitalGain));
 	if (cfg.vblankSupported) {
 		frameContext.agc.frameDuration =
 			cfg.lineDuration * (cfg.frameHeight + frameContext.sensor.vblank);
@@ -319,6 +380,7 @@ void Agc::process(IPAContext &context,
 		frameContext.sensor.exposure = context.activeState.agc.exposure;
 		frameContext.sensor.gain = context.activeState.agc.again;
 		frameContext.sensor.vblank = context.activeState.agc.vblank;
+		frameContext.agc.digitalGain = context.activeState.agc.dgain;
 		return;
 	}
 
@@ -344,8 +406,16 @@ void Agc::process(IPAContext &context,
 		return;
 	}
 
+	/*
+	 * The statistics are computed on the sensor data, before the ISP
+	 * applies the digital gain. Scale the histogram index by the digital
+	 * gain of the frame so that the MSV reflects the output brightness.
+	 */
+	const double digitalGain = frameContext.agc.digitalGain;
 	for (unsigned int i = 0; i < histogramSize; i++) {
-		unsigned int idx = (i - (i / yHistValsPerBinMod)) / yHistValsPerBin;
+		unsigned int scaled = std::min<unsigned int>(
+			static_cast<unsigned int>(i * digitalGain), histogramSize - 1);
+		unsigned int idx = (scaled - (scaled / yHistValsPerBinMod)) / yHistValsPerBin;
 		exposureBins[idx] += histogram[blackLevelHistIdx + i];
 	}
 
diff --git a/src/ipa/softisp/algorithms/agc.h b/src/ipa/softisp/algorithms/agc.h
index 1aa77737..6e9ba728 100644
--- a/src/ipa/softisp/algorithms/agc.h
+++ b/src/ipa/softisp/algorithms/agc.h
@@ -24,6 +24,9 @@ public:
 	void queueRequest(IPAContext &context, const uint32_t frame,
 			  IPAFrameContext &frameContext,
 			  const ControlList &controls) override;
+	void prepare(IPAContext &context, const uint32_t frame,
+		     IPAFrameContext &frameContext,
+		     DebayerParams *params) override;
 	void process(IPAContext &context, const uint32_t frame,
 		     IPAFrameContext &frameContext,
 		     const SwIspStats *stats,
@@ -34,6 +37,8 @@ private:
 	void vblankRange(const IPAContext &context, const IPAFrameContext &frameContext,
 			 int32_t &vblankLo, int32_t &vblankHi) const;
 	int32_t exposureMaxForVblank(const IPAContext &context, int32_t vblank) const;
+
+	double maxDigitalGain_;
 };
 
 } /* namespace ipa::softisp::algorithms */
diff --git a/src/ipa/softisp/ipa_context.h b/src/ipa/softisp/ipa_context.h
index b5577768..de188f14 100644
--- a/src/ipa/softisp/ipa_context.h
+++ b/src/ipa/softisp/ipa_context.h
@@ -42,6 +42,8 @@ struct IPASessionConfiguration {
 		/* Lines the sensor keeps between max exposure and frame length */
 		int32_t exposureMargin;
 		uint32_t frameHeight;
+		/* Digital gain applied by the ISP after the sensor gain is exhausted */
+		double dgainMax;
 	} agc;
 	struct {
 		std::optional<uint8_t> level;
@@ -56,6 +58,7 @@ struct IPAActiveState {
 		int32_t exposure;
 		double again;
 		int32_t vblank;
+		double dgain;
 		bool valid;
 		utils::Duration minFrameDuration;
 		utils::Duration maxFrameDuration;
@@ -91,6 +94,7 @@ struct IPAFrameContext : public FrameContext {
 		utils::Duration minFrameDuration;
 		utils::Duration maxFrameDuration;
 		utils::Duration frameDuration;
+		double digitalGain;
 	} agc;
 
 	float gamma;
