mirror of
https://github.com/imgfloat/server.git
synced 2026-03-22 23:10:38 +00:00
Add volume limiter
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -19,4 +19,16 @@ public class CanvasEvent {
|
||||
return event;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public String getChannel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
public CanvasSettingsRequest getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
UPDATE channels
|
||||
SET max_volume_db = 0;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user