Add volume limiter

This commit is contained in:
2026-03-13 16:36:21 +01:00
parent e7af8907b4
commit 3bcd6d6747
14 changed files with 374 additions and 22 deletions

View File

@@ -68,6 +68,7 @@ public class SchemaMigration implements ApplicationRunner {
addColumnIfMissing("channels", columns, "canvas_width", "REAL", "1920");
addColumnIfMissing("channels", columns, "canvas_height", "REAL", "1080");
addColumnIfMissing("channels", columns, "max_volume_db", "REAL", "0");
}
private void ensureAssetTables() {

View File

@@ -1,5 +1,7 @@
package dev.kruhlmann.imgfloat.model.api.request;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Positive;
public class CanvasSettingsRequest {
@@ -10,9 +12,14 @@ public class CanvasSettingsRequest {
@Positive
private final double height;
public CanvasSettingsRequest(double width, double height) {
@DecimalMin(value = "-60.0")
@DecimalMax(value = "0.0")
private final Double maxVolumeDb;
public CanvasSettingsRequest(double width, double height, Double maxVolumeDb) {
this.width = width;
this.height = height;
this.maxVolumeDb = maxVolumeDb;
}
public double getWidth() {
@@ -23,4 +30,8 @@ public class CanvasSettingsRequest {
return height;
}
public Double getMaxVolumeDb() {
return maxVolumeDb;
}
}

View File

@@ -19,4 +19,16 @@ public class CanvasEvent {
return event;
}
public Type getType() {
return type;
}
public String getChannel() {
return channel;
}
public CanvasSettingsRequest getPayload() {
return payload;
}
}

View File

@@ -32,6 +32,9 @@ public class Channel {
private double canvasHeight = 1080;
@Column(name = "max_volume_db", nullable = false)
private Double maxVolumeDb = 0.0;
@Column(name = "allow_channel_emotes_for_assets", nullable = false)
private boolean allowChannelEmotesForAssets = true;
@@ -85,6 +88,14 @@ public class Channel {
this.canvasHeight = canvasHeight;
}
public Double getMaxVolumeDb() {
return maxVolumeDb;
}
public void setMaxVolumeDb(Double maxVolumeDb) {
this.maxVolumeDb = maxVolumeDb;
}
public boolean isAllowChannelEmotesForAssets() {
return allowChannelEmotesForAssets;
}

View File

@@ -213,7 +213,11 @@ public class ChannelDirectoryService {
public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
Channel channel = getOrCreateChannel(broadcaster);
return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight());
return new CanvasSettingsRequest(
channel.getCanvasWidth(),
channel.getCanvasHeight(),
channel.getMaxVolumeDb()
);
}
public CanvasSettingsRequest updateCanvasSettings(String broadcaster, CanvasSettingsRequest req, String actor) {
@@ -221,24 +225,52 @@ public class ChannelDirectoryService {
Channel channel = getOrCreateChannel(broadcaster);
double beforeWidth = channel.getCanvasWidth();
double beforeHeight = channel.getCanvasHeight();
Double beforeMaxVolumeDb = channel.getMaxVolumeDb();
channel.setCanvasWidth(req.getWidth());
channel.setCanvasHeight(req.getHeight());
if (req.getMaxVolumeDb() != null) {
channel.setMaxVolumeDb(req.getMaxVolumeDb());
}
channelRepository.save(channel);
CanvasSettingsRequest response = new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight());
CanvasSettingsRequest response = new CanvasSettingsRequest(
channel.getCanvasWidth(),
channel.getCanvasHeight(),
channel.getMaxVolumeDb()
);
messagingTemplate.convertAndSend(topicFor(broadcaster), CanvasEvent.updated(broadcaster, response));
if (
beforeWidth != channel.getCanvasWidth() ||
beforeHeight != channel.getCanvasHeight() ||
!Objects.equals(beforeMaxVolumeDb, channel.getMaxVolumeDb())
) {
List<String> changes = new ArrayList<>();
if (beforeWidth != channel.getCanvasWidth() || beforeHeight != channel.getCanvasHeight()) {
changes.add(
String.format(
Locale.ROOT,
"canvas %.0fx%.0f -> %.0fx%.0f",
beforeWidth,
beforeHeight,
channel.getCanvasWidth(),
channel.getCanvasHeight()
)
);
}
if (!Objects.equals(beforeMaxVolumeDb, channel.getMaxVolumeDb())) {
changes.add(
String.format(
Locale.ROOT,
"max volume %.0f dB -> %.0f dB",
beforeMaxVolumeDb == null ? 0.0 : beforeMaxVolumeDb,
channel.getMaxVolumeDb() == null ? 0.0 : channel.getMaxVolumeDb()
)
);
}
auditLogService.recordEntry(
channel.getBroadcaster(),
actor,
"CANVAS_UPDATED",
String.format(
Locale.ROOT,
"Canvas updated to %.0fx%.0f (was %.0fx%.0f)",
channel.getCanvasWidth(),
channel.getCanvasHeight(),
beforeWidth,
beforeHeight
)
"Canvas settings updated" + (changes.isEmpty() ? "" : " (" + String.join(", ", changes) + ")")
);
}
return response;
@@ -266,6 +298,15 @@ public class ChannelDirectoryService {
BAD_REQUEST,
"Canvas height must be a whole number within [1 to " + canvasMaxSizePixels + "]"
);
if (req.getMaxVolumeDb() != null) {
double maxVolumeDb = req.getMaxVolumeDb();
if (!Double.isFinite(maxVolumeDb) || maxVolumeDb < -60 || maxVolumeDb > 0) {
throw new ResponseStatusException(
BAD_REQUEST,
"Max volume must be within [-60 to 0] dB"
);
}
}
}
public ChannelScriptSettingsRequest getChannelScriptSettings(String broadcaster) {

View File

@@ -0,0 +1,5 @@
ALTER TABLE channels ADD COLUMN max_volume_db REAL NOT NULL DEFAULT 0;
UPDATE channels
SET max_volume_db = 0
WHERE max_volume_db IS NULL;

View File

@@ -0,0 +1,2 @@
UPDATE channels
SET max_volume_db = 0;

View File

@@ -1,13 +1,15 @@
const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"];
export function createAudioManager({ assets, globalScope = globalThis }) {
export function createAudioManager({ assets, globalScope = globalThis, maxVolumeDb = 0 }) {
const audioControllers = new Map();
const pendingAudioUnlock = new Set();
const limiter = createAudioLimiter({ globalScope, maxVolumeDb });
audioUnlockEvents.forEach((eventName) => {
globalScope.addEventListener(eventName, () => {
limiter.resume();
if (!pendingAudioUnlock.size) return;
pendingAudioUnlock.forEach((controller) => safePlay(controller, pendingAudioUnlock));
pendingAudioUnlock.forEach((controller) => safePlay(controller, pendingAudioUnlock, limiter.resume));
pendingAudioUnlock.clear();
});
});
@@ -41,6 +43,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
element.onended = () => handleAudioEnded(asset.id);
audioControllers.set(asset.id, controller);
applyAudioSettings(controller, asset, true);
limiter.connectElement(element);
return controller;
}
@@ -62,6 +65,8 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
element.playbackRate = speed * pitch;
const volume = Math.max(0, Math.min(2, asset.audioVolume ?? 1));
element.volume = Math.min(volume, 1);
limiter.connectElement(element);
limiter.resume();
}
function getAssetVolume(asset) {
@@ -72,6 +77,8 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
if (!element) return 1;
const volume = getAssetVolume(asset);
element.volume = Math.min(volume, 1);
limiter.connectElement(element);
limiter.resume();
return volume;
}
@@ -113,7 +120,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
controller.element.currentTime = 0;
const originalDelay = controller.delayMs;
controller.delayMs = 0;
safePlay(controller, pendingAudioUnlock);
safePlay(controller, pendingAudioUnlock, limiter.resume);
controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0;
}
@@ -123,11 +130,13 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
temp.preload = "auto";
temp.controls = false;
applyAudioElementSettings(temp, asset);
limiter.connectElement(temp);
const controller = { element: temp };
temp.onended = () => {
limiter.disconnectElement(temp);
temp.remove();
};
safePlay(controller, pendingAudioUnlock);
safePlay(controller, pendingAudioUnlock, limiter.resume);
}
function handleAudioPlay(asset, shouldPlay) {
@@ -139,7 +148,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
}
if (asset.audioLoop) {
controller.delayMs = controller.baseDelayMs;
safePlay(controller, pendingAudioUnlock);
safePlay(controller, pendingAudioUnlock, limiter.resume);
} else {
playOverlappingAudio(asset);
}
@@ -160,7 +169,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
return;
}
controller.delayTimeout = setTimeout(() => {
safePlay(controller, pendingAudioUnlock);
safePlay(controller, pendingAudioUnlock, limiter.resume);
}, controller.delayMs);
}
@@ -187,6 +196,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
if (audio.delayTimeout) {
clearTimeout(audio.delayTimeout);
}
limiter.disconnectElement(audio.element);
audio.element.pause();
audio.element.currentTime = 0;
audio.element.src = "";
@@ -202,11 +212,16 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
playAudioImmediately,
autoStartAudio,
clearAudio,
releaseMediaElement: limiter.disconnectElement,
setMaxVolumeDb: limiter.setMaxVolumeDb,
};
}
function safePlay(controller, pendingUnlock) {
function safePlay(controller, pendingUnlock, resumeAudio) {
if (!controller?.element) return;
if (resumeAudio) {
resumeAudio();
}
const playPromise = controller.element.play();
if (playPromise?.catch) {
playPromise.catch(() => {
@@ -214,3 +229,105 @@ function safePlay(controller, pendingUnlock) {
});
}
}
function createAudioLimiter({ globalScope, maxVolumeDb }) {
const AudioContextImpl = globalScope.AudioContext || globalScope.webkitAudioContext;
if (!AudioContextImpl) {
return {
connectElement: () => {},
disconnectElement: () => {},
setMaxVolumeDb: () => {},
resume: () => {},
};
}
let context = null;
let limiterNode = null;
let pendingMaxVolumeDb = maxVolumeDb;
const sourceNodes = new WeakMap();
const pendingElements = new Set();
function ensureContext() {
if (context) {
return context;
}
context = new AudioContextImpl();
limiterNode = context.createDynamicsCompressor();
limiterNode.knee.value = 0;
limiterNode.ratio.value = 20;
limiterNode.attack.value = 0.003;
limiterNode.release.value = 0.25;
limiterNode.connect(context.destination);
applyMaxVolumeDb(pendingMaxVolumeDb);
return context;
}
function applyMaxVolumeDb(value) {
const next = Number.isFinite(value) ? value : 0;
pendingMaxVolumeDb = next;
if (limiterNode) {
limiterNode.threshold.value = next;
}
}
function connectElement(element) {
if (!element) return;
if (sourceNodes.has(element)) {
return;
}
if (!context || context.state !== "running") {
pendingElements.add(element);
return;
}
try {
const source = context.createMediaElementSource(element);
source.connect(limiterNode);
sourceNodes.set(element, source);
} catch (error) {
// Ignore elements that cannot be connected to the audio graph.
}
}
function flushPending() {
if (!pendingElements.size) {
return;
}
const elements = Array.from(pendingElements);
pendingElements.clear();
elements.forEach(connectElement);
}
function disconnectElement(element) {
pendingElements.delete(element);
const source = sourceNodes.get(element);
if (source) {
source.disconnect();
sourceNodes.delete(element);
}
}
function setMaxVolumeDb(value) {
applyMaxVolumeDb(value);
}
function resume() {
const ctx = ensureContext();
if (ctx.state === "running") {
flushPending();
return;
}
ctx.resume()
.then(() => {
flushPending();
})
.catch(() => {});
}
return {
connectElement,
disconnectElement,
setMaxVolumeDb,
resume,
};
}

View File

@@ -7,6 +7,7 @@ export function createMediaManager({ state, audioManager, draw, obsBrowser, supp
function clearMedia(assetId) {
const element = mediaCache.get(assetId);
if (isVideoElement(element)) {
audioManager.releaseMediaElement(element);
element.src = "";
element.remove();
}

View File

@@ -43,7 +43,10 @@ export class BroadcastRenderer {
typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !this.obsBrowser;
this.canPlayProbe = document.createElement("video");
this.audioManager = createAudioManager({ assets: this.state.assets });
this.audioManager = createAudioManager({
assets: this.state.assets,
maxVolumeDb: this.state.audioSettings.maxVolumeDb,
});
this.mediaManager = createMediaManager({
state: this.state,
audioManager: this.audioManager,
@@ -152,7 +155,12 @@ export class BroadcastRenderer {
}
const width = Number.isFinite(settings.width) ? settings.width : this.state.canvasSettings.width;
const height = Number.isFinite(settings.height) ? settings.height : this.state.canvasSettings.height;
const maxVolumeDb = Number.isFinite(settings.maxVolumeDb)
? settings.maxVolumeDb
: this.state.audioSettings.maxVolumeDb;
this.state.canvasSettings = { width, height };
this.state.audioSettings = { maxVolumeDb };
this.audioManager.setMaxVolumeDb(maxVolumeDb);
this.resizeCanvas();
}

View File

@@ -1,6 +1,7 @@
export function createBroadcastState() {
return {
canvasSettings: { width: 1920, height: 1080 },
audioSettings: { maxVolumeDb: 0 },
assets: new Map(),
mediaCache: new Map(),
renderStates: new Map(),

View File

@@ -6,6 +6,8 @@ const elements = {
addAdminButton: document.getElementById("add-admin-btn"),
canvasWidth: document.getElementById("canvas-width"),
canvasHeight: document.getElementById("canvas-height"),
maxVolumeDb: document.getElementById("max-volume-db"),
maxVolumeLabel: document.getElementById("max-volume-label"),
canvasStatus: document.getElementById("canvas-status"),
canvasSaveButton: document.getElementById("save-canvas-btn"),
allowChannelEmotes: document.getElementById("allow-channel-emotes"),
@@ -204,6 +206,12 @@ async function addAdmin(usernameFromAction) {
function renderCanvasSettings(settings) {
if (elements.canvasWidth) elements.canvasWidth.value = Math.round(settings.width);
if (elements.canvasHeight) elements.canvasHeight.value = Math.round(settings.height);
if (elements.maxVolumeDb) {
const volumeDb = Number.isFinite(settings.maxVolumeDb) ? settings.maxVolumeDb : DEFAULT_MAX_VOLUME_DB;
const sliderValue = dbToSlider(volumeDb);
elements.maxVolumeDb.value = sliderValue;
setMaxVolumeLabel(volumeDb);
}
}
async function fetchCanvasSettings() {
@@ -211,7 +219,7 @@ async function fetchCanvasSettings() {
const data = await fetchJson("/canvas", {}, "Failed to load canvas settings");
renderCanvasSettings(data);
} catch (error) {
renderCanvasSettings({ width: 1920, height: 1080 });
renderCanvasSettings({ width: 1920, height: 1080, maxVolumeDb: 0 });
showToast("Using default canvas size. Unable to load saved settings.", "warning");
}
}
@@ -219,6 +227,7 @@ async function fetchCanvasSettings() {
async function saveCanvasSettings() {
const width = Number(elements.canvasWidth?.value);
const height = Number(elements.canvasHeight?.value);
const maxVolumeDb = sliderToDb(Number(elements.maxVolumeDb?.value));
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
showToast("Please enter a valid width and height.", "info");
return;
@@ -227,6 +236,10 @@ async function saveCanvasSettings() {
showToast("Please enter whole-number dimensions for the canvas size.", "info");
return;
}
if (!Number.isFinite(maxVolumeDb) || maxVolumeDb > MAX_VOLUME_DB || maxVolumeDb < MIN_VOLUME_DB) {
showToast(`Max volume must be between ${MIN_VOLUME_DB} and ${MAX_VOLUME_DB} dBFS.`, "info");
return;
}
if (elements.canvasStatus) elements.canvasStatus.textContent = "Saving...";
setButtonBusy(elements.canvasSaveButton, true, "Saving...");
try {
@@ -235,7 +248,7 @@ async function saveCanvasSettings() {
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ width, height }),
body: JSON.stringify({ width, height, maxVolumeDb }),
},
"Failed to save canvas",
);
@@ -253,6 +266,114 @@ async function saveCanvasSettings() {
}
}
const MIN_VOLUME_DB = -60;
const MAX_VOLUME_DB = 0;
const DEFAULT_MAX_VOLUME_DB = 0;
function clamp(value, min, max) {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function sliderToDb(value) {
const clamped = clamp(value, 0, 100);
return MIN_VOLUME_DB + (clamped / 100) * (MAX_VOLUME_DB - MIN_VOLUME_DB);
}
function dbToSlider(value) {
const clamped = clamp(value, MIN_VOLUME_DB, MAX_VOLUME_DB);
return Math.round(((clamped - MIN_VOLUME_DB) / (MAX_VOLUME_DB - MIN_VOLUME_DB)) * 100);
}
function setMaxVolumeLabel(dbValue) {
if (!elements.maxVolumeLabel) return;
const rounded = Math.round(dbValue * 10) / 10;
elements.maxVolumeLabel.textContent = `${rounded} dBFS`;
}
const demoAudioState = {
context: null,
oscillator: null,
gain: null,
compressor: null,
timeoutId: null,
isPlaying: false,
previewUnavailable: false,
};
function stopVolumeDemo() {
if (demoAudioState.timeoutId) {
clearTimeout(demoAudioState.timeoutId);
demoAudioState.timeoutId = null;
}
if (demoAudioState.oscillator) {
try {
demoAudioState.oscillator.stop();
} catch (_) {}
demoAudioState.oscillator.disconnect();
demoAudioState.oscillator = null;
}
if (demoAudioState.gain) {
demoAudioState.gain.disconnect();
demoAudioState.gain = null;
}
if (demoAudioState.compressor) {
demoAudioState.compressor.disconnect();
demoAudioState.compressor = null;
}
demoAudioState.isPlaying = false;
}
function startVolumeDemo(maxVolumeDb) {
const AudioContextImpl = window.AudioContext || window.webkitAudioContext;
if (!AudioContextImpl) {
if (!demoAudioState.previewUnavailable) {
showToast("Audio preview is not supported in this browser.", "info");
demoAudioState.previewUnavailable = true;
}
return;
}
const context = demoAudioState.context || new AudioContextImpl();
demoAudioState.context = context;
if (!demoAudioState.compressor) {
const compressor = context.createDynamicsCompressor();
compressor.knee.value = 0;
compressor.ratio.value = 20;
compressor.attack.value = 0.003;
compressor.release.value = 0.25;
compressor.connect(context.destination);
demoAudioState.compressor = compressor;
}
demoAudioState.compressor.threshold.value = maxVolumeDb;
if (!demoAudioState.gain) {
const gain = context.createGain();
gain.gain.value = 0.8;
gain.connect(demoAudioState.compressor);
demoAudioState.gain = gain;
}
if (!demoAudioState.oscillator) {
const oscillator = context.createOscillator();
oscillator.type = "sine";
oscillator.frequency.value = 440;
oscillator.connect(demoAudioState.gain);
oscillator.start();
demoAudioState.oscillator = oscillator;
}
demoAudioState.isPlaying = true;
context.resume().catch(() => {});
if (demoAudioState.timeoutId) {
clearTimeout(demoAudioState.timeoutId);
}
demoAudioState.timeoutId = setTimeout(stopVolumeDemo, 800);
}
function handleVolumeSliderInput() {
if (!elements.maxVolumeDb) return;
const nextDb = sliderToDb(Number(elements.maxVolumeDb.value));
setMaxVolumeLabel(nextDb);
startVolumeDemo(nextDb);
}
function renderScriptSettings(settings) {
if (elements.allowChannelEmotes) {
elements.allowChannelEmotes.checked = settings.allowChannelEmotesForAssets !== false;
@@ -356,3 +477,6 @@ fetchScriptSettings();
if (elements.deleteAccountButton) {
elements.deleteAccountButton.addEventListener("click", deleteAccount);
}
if (elements.maxVolumeDb) {
elements.maxVolumeDb.addEventListener("input", handleVolumeSliderInput);
}

View File

@@ -70,6 +70,12 @@
Height
<input id="canvas-height" type="number" min="100" step="10" />
</label>
<label>
Max output volume
<input id="max-volume-db" type="range" min="0" max="100" step="1" value="100" />
<span id="max-volume-label" class="form-helper">0 dBFS</span>
<span class="form-helper">Drag to preview. Left = much quieter, right = louder.</span>
</label>
</div>
<h3>Integrations</h3>

View File

@@ -11,6 +11,7 @@ import static org.mockito.Mockito.when;
import dev.kruhlmann.imgfloat.model.AssetType;
import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
import dev.kruhlmann.imgfloat.model.api.response.AssetView;
@@ -246,6 +247,17 @@ class ChannelDirectoryServiceTest {
assertThat(saved.allowedDomains()).isEmpty();
}
@Test
void updatesCanvasMaxVolumeDb() {
CanvasSettingsRequest request = new CanvasSettingsRequest(1920, 1080, -12.0);
CanvasSettingsRequest saved = service.updateCanvasSettings("caster", request, "caster");
assertThat(saved.getMaxVolumeDb()).isEqualTo(-12.0);
Channel channel = channelRepository.findById("caster").orElseThrow();
assertThat(channel.getMaxVolumeDb()).isEqualTo(-12.0);
}
private byte[] samplePng() throws IOException {
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
ByteArrayOutputStream out = new ByteArrayOutputStream();