diff --git a/app/apps/common/widgets/crispy/daisyui.py b/app/apps/common/widgets/crispy/daisyui.py
index 93d4b070..6b457d82 100644
--- a/app/apps/common/widgets/crispy/daisyui.py
+++ b/app/apps/common/widgets/crispy/daisyui.py
@@ -3,3 +3,20 @@ from crispy_forms.layout import Field
class Switch(Field):
template = "crispy-daisyui/layout/switch.html"
+
+
+class Range(Field):
+ template = "crispy-daisyui/layout/range.html"
+
+ def render(self, form, context, extra_context=None, **kwargs):
+ extra_context = extra_context or {}
+
+ # Build the measure (ticks + labels) from the field's min/max/step
+ attrs = form.fields[self.fields[0]].widget.attrs
+ if "min" in attrs and "max" in attrs:
+ step = attrs.get("step") or 1
+ extra_context["range_steps"] = list(
+ range(int(attrs["min"]), int(attrs["max"]) + 1, int(step))
+ )
+
+ return super().render(form, context, extra_context=extra_context, **kwargs)
diff --git a/app/apps/common/widgets/range.py b/app/apps/common/widgets/range.py
new file mode 100644
index 00000000..655824b8
--- /dev/null
+++ b/app/apps/common/widgets/range.py
@@ -0,0 +1,5 @@
+from django import forms
+
+
+class RangeInput(forms.NumberInput):
+ input_type = "range"
diff --git a/app/apps/users/forms.py b/app/apps/users/forms.py
index 84e4b129..cb8b4c1d 100644
--- a/app/apps/users/forms.py
+++ b/app/apps/users/forms.py
@@ -2,7 +2,9 @@ from datetime import timedelta
from apps.common.middleware.thread_local import get_current_user
from apps.users.models import APIToken
+from apps.common.widgets.crispy.daisyui import Range
from apps.common.widgets.crispy.submit import NoClassSubmit
+from apps.common.widgets.range import RangeInput
from apps.common.widgets.tom_select import TomSelect
from apps.users.models import UserSettings
from apps.accounts.models import Account
@@ -131,6 +133,20 @@ class UserSettingsForm(forms.ModelForm):
required=False,
)
+ volume = forms.IntegerField(
+ min_value=0,
+ max_value=10,
+ label=_("Volume"),
+ help_text=_("Set to 0 to mute all sounds"),
+ widget=RangeInput(
+ attrs={
+ "step": 1,
+ # Preview the selected volume
+ "_": "on change call playSound('sparkle', my valueAsNumber)",
+ }
+ ),
+ )
+
class Meta:
model = UserSettings
fields = [
@@ -141,6 +157,7 @@ class UserSettingsForm(forms.ModelForm):
"datetime_format",
"number_format",
"default_account",
+ "volume",
]
widgets = {
"default_account": TomSelect(clear_button=False, group_by="group"),
@@ -167,7 +184,7 @@ class UserSettingsForm(forms.ModelForm):
"start_page",
"default_account",
HTML('
'),
- "volume",
+ Range("volume"),
FormActions(
NoClassSubmit("submit", _("Save"), css_class="btn btn-primary"),
),
diff --git a/app/apps/users/migrations/0028_alter_usersettings_volume.py b/app/apps/users/migrations/0028_alter_usersettings_volume.py
new file mode 100644
index 00000000..d84302e8
--- /dev/null
+++ b/app/apps/users/migrations/0028_alter_usersettings_volume.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2.16 on 2026-09-12 21:53
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0027_alter_usersettings_timezone'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='usersettings',
+ name='volume',
+ field=models.PositiveIntegerField(default=10, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)], verbose_name='Volume'),
+ ),
+ ]
diff --git a/app/apps/users/models.py b/app/apps/users/models.py
index 11826e99..7b21216e 100644
--- a/app/apps/users/models.py
+++ b/app/apps/users/models.py
@@ -481,7 +481,7 @@ class UserSettings(models.Model):
mute_sounds = models.BooleanField(default=False)
volume = models.PositiveIntegerField(
default=10,
- validators=[MinValueValidator(1), MaxValueValidator(10)],
+ validators=[MinValueValidator(0), MaxValueValidator(10)],
verbose_name=_("Volume"),
)
diff --git a/app/templates/crispy-daisyui/layout/range.html b/app/templates/crispy-daisyui/layout/range.html
new file mode 100644
index 00000000..7be13f60
--- /dev/null
+++ b/app/templates/crispy-daisyui/layout/range.html
@@ -0,0 +1,29 @@
+{% load crispy_forms_field %}
+
+{% if field.is_hidden %}
+ {{ field }}
+{% else %}
+ <{% if tag %}{{ tag }}{% else %}fieldset{% endif %} id="div_{{ field.auto_id }}" class="fieldset{% if wrapper_class %} {{ wrapper_class }}{% endif %}{% if field.css_classes %} {{ field.css_classes }}{% endif %}">
+ {% if field.label and form_show_labels %}
+
+ {% endif %}
+
+ {% if field.errors %}
+ {% crispy_field field 'class' 'range range-error w-full' %}
+ {% else %}
+ {% crispy_field field 'class' 'range range-primary w-full' %}
+ {% endif %}
+ {% if range_steps %}
+
+ {% for step in range_steps %}|{% endfor %}
+
+
+ {% for step in range_steps %}{{ step|stringformat:"02d" }}{% endfor %}
+
+ {% endif %}
+
+ {% include 'crispy-daisyui/layout/help_text_and_errors.html' %}
+ {% if tag %}{{ tag }}{% else %}fieldset{% endif %}>
+{% endif %}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index ff8038e1..77974ec7 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -25,7 +25,7 @@
"chart.js": "^4.5.1",
"chartjs-chart-sankey": "^0.14.3",
"chartjs-plugin-zoom": "^2.2.0",
- "cuelume": "^0.1.2",
+ "cuelume": "^0.2.2",
"daisyui": "5.5.20",
"htmx.org": "^2.0.10",
"hyperscript.org": "^0.9.91",
@@ -1319,9 +1319,9 @@
}
},
"node_modules/cuelume": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/cuelume/-/cuelume-0.1.2.tgz",
- "integrity": "sha512-VxL0k9l1fyKGot3VKM+wm5Xd2K75fVeBHdNTrR1EHiqKRtD6S5Dxy/vY7hwl9gXp3QT/TIywFXUos77n39YZVQ==",
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/cuelume/-/cuelume-0.2.2.tgz",
+ "integrity": "sha512-TiNzJRjhddjC4jy4M7sv63V/86VaFhdIs6iHUCrc4CKhqypaxRBv1aedCBGebrz/cO7nroPkEfqHe/P7fOE2GQ==",
"license": "MIT"
},
"node_modules/daisyui": {
diff --git a/frontend/package.json b/frontend/package.json
index 85cf5755..8ee82716 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -32,7 +32,7 @@
"chart.js": "^4.5.1",
"chartjs-chart-sankey": "^0.14.3",
"chartjs-plugin-zoom": "^2.2.0",
- "cuelume": "^0.1.2",
+ "cuelume": "^0.2.2",
"daisyui": "5.5.20",
"htmx.org": "^2.0.10",
"hyperscript.org": "^0.9.91",
diff --git a/frontend/src/js/sounds.js b/frontend/src/js/sounds.js
index df884445..c3914810 100644
--- a/frontend/src/js/sounds.js
+++ b/frontend/src/js/sounds.js
@@ -1,4 +1,11 @@
import { play } from "cuelume";
-window.playSound = play;
+// User volume is stored as 0-10 (0 = muted); cuelume expects a 0-1 gain.
+// Loudness is perceived logarithmically, so a linear mapping makes adjacent
+// steps sound nearly identical. Squaring spreads them out (10 → 0dB, 5 → -12dB, 1 → -40dB).
+// Pass `volume` to override the user's saved volume (e.g. to preview a new value).
+window.playSound = (name, volume) => {
+ volume ??= JSON.parse(document.getElementById("volume")?.textContent ?? "10");
+ play(name, { volume: (volume / 10) ** 2 });
+};