diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 50c93322a..25a15f1b4 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -147,6 +147,14 @@ export function initializeAudioOutput() { destinationGain.connect(audioContext.destination); } +export function setGlobalGain(gain) { + if (destinationGain == null) { + initializeAudioOutput(); + } + + destinationGain.gain.value = gain; +} + // input: AudioNode, channels: ?Array export const connectToDestination = (input, channels = [0, 1]) => { const ctx = getAudioContext(); diff --git a/website/src/repl/components/panel/SettingsTab.jsx b/website/src/repl/components/panel/SettingsTab.jsx index 768af4ffb..baa5506ba 100644 --- a/website/src/repl/components/panel/SettingsTab.jsx +++ b/website/src/repl/components/panel/SettingsTab.jsx @@ -1,6 +1,7 @@ -import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs'; +import { useMemo } from 'react'; +import { audioEngineTargets, defaultSettings, settingsMap, useSettings } from '../../../settings.mjs'; import { themes } from '@strudel/codemirror'; -import { isUdels } from '../../util.mjs'; +import { isUdels, setGlobalAudioVolume } from '../../util.mjs'; import { ButtonGroup } from './Forms.jsx'; import { AudioDeviceSelector } from './AudioDeviceSelector.jsx'; import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx'; @@ -31,23 +32,47 @@ function SelectInput({ value, options, onChange }) { ); } -function NumberSlider({ value, onChange, step = 1, ...rest }) { +function NumberSlider({ value, onChange, min, max, step = 1, ...rest }) { + const fractionalDigits = useMemo(() => { + const stepStr = step.toString(); + const decimalPointIdx = stepStr.indexOf('.'); + if (decimalPointIdx < 0) { + return 0; + } + + return stepStr.slice(decimalPointIdx + 1).length; + }, [step]); + + const textInputCharWidth = useMemo(() => { + const maxValueWholePartLength = Math.floor(max).toString().length; + return maxValueWholePartLength + '.'.length + fractionalDigits; + }, [max, fractionalDigits]); + return (
onChange(Number(e.target.value))} {...rest} /> onChange(Number(e.target.value))} + {...rest} />
); @@ -101,6 +126,7 @@ export function SettingsTab({ started }) { panelPosition, audioDeviceName, audioEngineTarget, + audioVolume, } = useSettings(); const shouldAlwaysSync = isUdels(); const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; @@ -135,6 +161,19 @@ export function SettingsTab({ started }) { }} /> + + {audioEngineTarget === audioEngineTargets.osc && ( + Has no effect when Audio Engine Target is OSC + )} + settingsMap.setKey('audioVolume', audioVolume)} + min={0} + max={100} + step={0.1} + disabled={audioEngineTarget === audioEngineTargets.osc} + /> + settingsMap.setKey('theme', theme)} /> diff --git a/website/src/repl/useReplContext.jsx b/website/src/repl/useReplContext.jsx index ba8616272..04d300412 100644 --- a/website/src/repl/useReplContext.jsx +++ b/website/src/repl/useReplContext.jsx @@ -14,7 +14,7 @@ import { resetLoadedSounds, initAudioOnFirstClick, } from '@strudel/webaudio'; -import { getAudioDevices, setAudioDevice, setVersionDefaultsFrom } from './util.mjs'; +import { getAudioDevices, setAudioDevice, setGlobalAudioVolume, setVersionDefaultsFrom } from './util.mjs'; import { StrudelMirror, defaultSettings } from '@strudel/codemirror'; import { clearHydra } from '@strudel/hydra'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -162,6 +162,7 @@ export function useReplContext() { // on first load, set stored audio device if possible useEffect(() => { const { audioDeviceName } = _settings; + if (audioDeviceName !== defaultAudioDeviceName) { getAudioDevices().then((devices) => { const deviceID = devices.get(audioDeviceName); @@ -173,6 +174,9 @@ export function useReplContext() { } }, []); + // set stored audio volume + useEffect(() => setGlobalAudioVolume(_settings.audioVolume), [_settings.audioVolume]); + // // UI Actions // diff --git a/website/src/repl/util.mjs b/website/src/repl/util.mjs index 905e16b09..2b1b4d9d3 100644 --- a/website/src/repl/util.mjs +++ b/website/src/repl/util.mjs @@ -1,6 +1,12 @@ import { evalScope, hash2code, logger } from '@strudel/core'; import { settingPatterns, defaultAudioDeviceName } from '../settings.mjs'; -import { getAudioContext, initializeAudioOutput, setDefaultAudioContext, setVersionDefaults } from '@strudel/webaudio'; +import { + getAudioContext, + initializeAudioOutput, + setDefaultAudioContext, + setGlobalGain, + setVersionDefaults, +} from '@strudel/webaudio'; import { getMetadata } from '../metadata_parser'; import { isTauri } from '../tauri.mjs'; import './Repl.css'; @@ -188,6 +194,19 @@ export const setAudioDevice = async (id) => { initializeAudioOutput(); }; +const NATURAL_LOG_10 = Math.log(1000); +const LOW_VOLUME_CONSTANT = Math.exp(0.1 * NATURAL_LOG_10); +export const setGlobalAudioVolume = (volume) => { + // Gain is calculated to adjust the volume to a logarithmic scale to match how us humans perceive loudness. + // Formula is taken from https://www.dr-lex.be/info-stuff/volumecontrols.html + volume /= 100; // [0, 1] + + let gain = volume >= 0.1 ? 0.001 * Math.exp(NATURAL_LOG_10 * volume) : volume * 10 * 0.001 * LOW_VOLUME_CONSTANT; + gain = Math.max(0, Math.min(gain, 1)); // just in case + + setGlobalGain(gain); +}; + export function setVersionDefaultsFrom(code) { try { const metadata = getMetadata(code); diff --git a/website/src/settings.mjs b/website/src/settings.mjs index e0e3ee7d2..54add2196 100644 --- a/website/src/settings.mjs +++ b/website/src/settings.mjs @@ -34,6 +34,7 @@ export const defaultSettings = { userPatterns: '{}', audioDeviceName: defaultAudioDeviceName, audioEngineTarget: audioEngineTargets.webaudio, + audioVolume: 50, }; let search = null;