Skip to content

Commit 2a3aff4

Browse files
committed
fix: preserve quiet volume controls
1 parent fa87646 commit 2a3aff4

6 files changed

Lines changed: 83 additions & 28 deletions

File tree

apps/macos/Sources/ORAMApp/Views/ContentView.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,7 +1726,7 @@ private struct VolumeStrip: View {
17261726
.fill(DashboardTheme.inset(theme))
17271727
Rectangle()
17281728
.fill(fillColor.opacity(0.72))
1729-
.frame(height: geo.size.height * min(max(currentValue, 0), 2) / 2)
1729+
.frame(height: geo.size.height * CGFloat(position(for: currentValue)))
17301730
Rectangle()
17311731
.fill(DashboardTheme.secondary(theme).opacity(0.45))
17321732
.frame(height: 1)
@@ -1758,7 +1758,13 @@ private struct VolumeStrip: View {
17581758
private func valueFor(y: CGFloat, height: CGFloat) -> Double {
17591759
let clamped = max(0, min(height, height - y))
17601760
let normalized = Double(clamped / max(height, 1))
1761-
return pow(normalized, 0.65) * 2
1761+
return pow(normalized, 1.8) * 2
1762+
}
1763+
1764+
private func position(for volume: Double) -> Double {
1765+
let clamped = min(max(volume, 0), 2)
1766+
guard clamped > 0 else { return 0 }
1767+
return pow(clamped / 2, 1 / 1.8)
17621768
}
17631769
}
17641770

src/oram/web/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def _get_state_snapshot() -> dict[str, Any]:
119119
"source_type": layer.source_type.value,
120120
"layer_mode": layer.layer_mode.value,
121121
"duration": round(layer.duration_seconds, 2),
122-
"volume": round(layer.volume, 2),
122+
"volume": round(layer.volume, 3),
123123
"pan": round(layer.pan, 2),
124124
"muted": layer.muted,
125125
"solo": layer.solo,

src/oram/web/static/app.js

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@
437437
// volume strip — vertical fill column (skip updates while dragging OR during post-drag cooldown)
438438
const volStrip = row.querySelector('.vol-strip');
439439
if (volStrip && !volStrip._dragging && !volStrip._cooldown) {
440-
const rawVol = Math.round(volumeToStripPos(layer.volume || 1));
440+
const rawVol = Math.round(volumeToStripPos(layerVolume(layer)));
441441
volStrip.dataset.value = rawVol;
442442
updateVolStripVisual(volStrip);
443443
}
@@ -1135,7 +1135,7 @@
11351135

11361136
// exponential curve: knob position 0-200 → volume 0-200%
11371137
// gives much more resolution at low volumes for quiet layers
1138-
// pos 0 → vol 0 (true mute), pos 100 → vol 1.0 (unity), pos 200 → vol 2.0
1138+
// pos 0 → vol 0 (true mute), pos ~136 → vol 1.0 (unity), pos 200 → vol 2.0
11391139
function stripPosToVolume(pos) {
11401140
if (pos <= 0) return 0; // true mute at zero
11411141
const norm = pos / 200; // 0..1
@@ -1151,22 +1151,43 @@
11511151
return Math.round(uncurved * 200);
11521152
}
11531153

1154+
function layerVolume(layer) {
1155+
const vol = Number(layer?.volume);
1156+
return Number.isFinite(vol) ? Math.max(0, Math.min(2, vol)) : 1;
1157+
}
1158+
1159+
function stripValue(strip, fallback = 100) {
1160+
const val = parseInt(strip?.dataset?.value ?? '', 10);
1161+
return Number.isFinite(val) ? Math.max(0, Math.min(200, val)) : fallback;
1162+
}
1163+
1164+
function formatVolume(vol) {
1165+
const clamped = Math.max(0, Math.min(2, vol));
1166+
return clamped < 0.1 ? clamped.toFixed(3) : clamped.toFixed(2);
1167+
}
1168+
1169+
function volumeLabel(vol) {
1170+
if (vol <= 0) return 'mute';
1171+
if (vol < 0.01) return Math.max(0.1, Math.round(vol * 1000) / 10) + '%';
1172+
return Math.round(vol * 100) + '%';
1173+
}
1174+
11541175
function updateVolStripVisual(strip) {
1155-
const val = parseInt(strip.dataset.value) || 100;
1176+
const val = stripValue(strip);
11561177
const pct = Math.max(0, Math.min(100, (val / 200) * 100));
11571178
const fill = strip.querySelector('.vol-strip-fill');
11581179
if (fill) fill.style.height = pct + '%';
11591180
const vol = stripPosToVolume(val);
11601181
strip.classList.toggle('hot', vol > 1.1);
1161-
strip.classList.toggle('silent', vol < 0.01);
1182+
strip.classList.toggle('silent', vol <= 0);
11621183
strip.setAttribute('aria-valuenow', String(val));
1163-
strip.setAttribute('aria-valuetext', vol.toFixed(2) + '×');
1164-
strip.dataset.display = vol < 0.01 ? 'mute' : Math.round(vol * 100) + '%';
1184+
strip.setAttribute('aria-valuetext', formatVolume(vol) + '×');
1185+
strip.dataset.display = volumeLabel(vol);
11651186
}
11661187

11671188
function commitStripValue(strip) {
1168-
const val = parseInt(strip.dataset.value) || 100;
1169-
const vol = stripPosToVolume(val).toFixed(2);
1189+
const val = stripValue(strip);
1190+
const vol = formatVolume(stripPosToVolume(val));
11701191
sendCommand('set volume layer ' + strip.dataset.target + ' ' + vol);
11711192
}
11721193

@@ -1197,7 +1218,7 @@
11971218
didLongPress = false;
11981219
strip.classList.add('dragging');
11991220
startY = e.clientY;
1200-
startVal = parseInt(strip.dataset.value) || 100;
1221+
startVal = stripValue(strip);
12011222
strip.setPointerCapture(e.pointerId);
12021223
showFloatingValue();
12031224

@@ -1253,7 +1274,7 @@
12531274
e.preventDefault();
12541275
const step = e.shiftKey ? 1 : 4;
12551276
const delta = e.deltaY < 0 ? step : -step;
1256-
const cur = parseInt(strip.dataset.value) || 100;
1277+
const cur = stripValue(strip);
12571278
const newVal = Math.max(0, Math.min(200, cur + delta));
12581279
strip.dataset.value = newVal;
12591280
updateVolStripVisual(strip);
@@ -1270,7 +1291,7 @@
12701291

12711292
strip.addEventListener('keydown', (e) => {
12721293
const step = e.shiftKey ? 1 : 5;
1273-
const cur = parseInt(strip.dataset.value) || 100;
1294+
const cur = stripValue(strip);
12741295
let newVal = cur;
12751296
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { newVal = Math.min(200, cur + step); e.preventDefault(); }
12761297
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { newVal = Math.max(0, cur - step); e.preventDefault(); }
@@ -1651,7 +1672,7 @@
16511672
unity.className = 'mixer-fader-unity';
16521673
fader.appendChild(fill);
16531674
fader.appendChild(unity);
1654-
const vol = layer.volume || 1;
1675+
const vol = layerVolume(layer);
16551676
const pos = volumeToStripPos(vol);
16561677
const pct = Math.max(0, Math.min(100, (pos / 200) * 100));
16571678
fill.style.height = pct + '%';
@@ -1663,7 +1684,7 @@
16631684
// dB readout
16641685
const dbLabel = document.createElement('div');
16651686
dbLabel.className = 'mixer-fader-db';
1666-
dbLabel.textContent = vol < 0.01 ? 'mute' : Math.round(vol * 100) + '%';
1687+
dbLabel.textContent = volumeLabel(vol);
16671688
ch.appendChild(dbLabel);
16681689

16691690
// pan track
@@ -1709,10 +1730,10 @@
17091730

17101731
// mute/solo clicks
17111732
muteBtn.addEventListener('click', () => {
1712-
sendCommand('mute ' + muteBtn.dataset.target);
1733+
sendCommand('mute layer ' + muteBtn.dataset.target);
17131734
});
17141735
soloBtn.addEventListener('click', () => {
1715-
sendCommand('solo ' + soloBtn.dataset.target);
1736+
sendCommand('solo layer ' + soloBtn.dataset.target);
17161737
});
17171738
});
17181739

@@ -1751,14 +1772,14 @@
17511772
// update fader (if not dragging)
17521773
const fader = ch.querySelector('.mixer-fader-wrap');
17531774
if (fader && !fader._dragging && !fader._cooldown) {
1754-
const vol = layer.volume || 1;
1775+
const vol = layerVolume(layer);
17551776
const pos = volumeToStripPos(vol);
17561777
const pct = Math.max(0, Math.min(100, (pos / 200) * 100));
17571778
const fill = fader.querySelector('.mixer-fader-fill');
17581779
if (fill) fill.style.height = pct + '%';
17591780
fader.classList.toggle('hot', vol > 1.1);
17601781
const dbLabel = ch.querySelector('.mixer-fader-db');
1761-
if (dbLabel) dbLabel.textContent = vol < 0.01 ? 'mute' : Math.round(vol * 100) + '%';
1782+
if (dbLabel) dbLabel.textContent = volumeLabel(vol);
17621783
}
17631784

17641785
// update mute/solo states
@@ -1791,7 +1812,7 @@
17911812
fader._dragging = true;
17921813
startY = e.clientY;
17931814
const layerData = state.layers?.[target - 1];
1794-
startVal = volumeToStripPos(layerData?.volume || 1);
1815+
startVal = volumeToStripPos(layerVolume(layerData));
17951816
fader.setPointerCapture(e.pointerId);
17961817
});
17971818

@@ -1804,15 +1825,15 @@
18041825
fill.style.height = pct + '%';
18051826
fader.classList.toggle('hot', stripPosToVolume(newVal) > 1.1);
18061827
const vol = stripPosToVolume(newVal);
1807-
dbLabel.textContent = vol < 0.01 ? 'mute' : Math.round(vol * 100) + '%';
1828+
dbLabel.textContent = volumeLabel(vol);
18081829
fader._currentVal = newVal;
18091830
});
18101831

18111832
fader.addEventListener('pointerup', () => {
18121833
if (!fader._dragging) return;
18131834
fader._dragging = false;
18141835
if (fader._currentVal != null) {
1815-
const vol = stripPosToVolume(fader._currentVal).toFixed(2);
1836+
const vol = formatVolume(stripPosToVolume(fader._currentVal));
18161837
sendCommand('set volume layer ' + target + ' ' + vol);
18171838
startCooldown();
18181839
// sync the corresponding layer vol-strip
@@ -1830,7 +1851,7 @@
18301851
if (fader._dragging) {
18311852
fader._dragging = false;
18321853
if (fader._currentVal != null) {
1833-
const vol = stripPosToVolume(fader._currentVal).toFixed(2);
1854+
const vol = formatVolume(stripPosToVolume(fader._currentVal));
18341855
sendCommand('set volume layer ' + target + ' ' + vol);
18351856
startCooldown();
18361857
}
@@ -1918,7 +1939,7 @@
19181939
muteBtn.className = 'vol-expanded-mute' + (layer.muted ? ' on' : '');
19191940
muteBtn.textContent = layer.muted ? 'unmute' : 'mute';
19201941
muteBtn.addEventListener('click', () => {
1921-
sendCommand('mute ' + target);
1942+
sendCommand('mute layer ' + target);
19221943
muteBtn.classList.toggle('on');
19231944
muteBtn.textContent = muteBtn.classList.contains('on') ? 'unmute' : 'mute';
19241945
});
@@ -1928,13 +1949,13 @@
19281949
document.body.appendChild(overlay);
19291950

19301951
// sync initial value from strip
1931-
let currentVal = parseInt(strip.dataset.value) || 100;
1952+
let currentVal = stripValue(strip);
19321953

19331954
function updateVisual() {
19341955
const pct = Math.max(0, Math.min(100, (currentVal / 200) * 100));
19351956
fill.style.height = pct + '%';
19361957
const vol = stripPosToVolume(currentVal);
1937-
valueEl.textContent = vol < 0.01 ? 'mute' : Math.round(vol * 100) + '%';
1958+
valueEl.textContent = volumeLabel(vol);
19381959
}
19391960
updateVisual();
19401961

@@ -1961,7 +1982,7 @@
19611982
fader.addEventListener('pointerup', () => {
19621983
if (!isDragging) return;
19631984
isDragging = false;
1964-
const vol = stripPosToVolume(currentVal).toFixed(2);
1985+
const vol = formatVolume(stripPosToVolume(currentVal));
19651986
sendCommand('set volume layer ' + target + ' ' + vol);
19661987
// sync back to layer strip
19671988
strip.dataset.value = currentVal;

tests/test_command_parser.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,17 @@ def test_set_volume_layer(self):
188188
assert result.target == 1
189189
assert result.volume == 0.5
190190

191+
def test_set_volume_preserves_quiet_and_mute(self):
192+
quiet = parser.parse("set volume layer 1 0.009")
193+
assert isinstance(quiet, SetVolumeAction)
194+
assert quiet.target == 1
195+
assert quiet.volume == 0.009
196+
197+
mute = parser.parse("set volume layer 1 0")
198+
assert isinstance(mute, SetVolumeAction)
199+
assert mute.target == 1
200+
assert mute.volume == 0
201+
191202
def test_set_volume_percent(self):
192203
result = parser.parse("volume layer two 50")
193204
assert isinstance(result, SetVolumeAction)

tests/test_daemon_api.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ def test_daemon_dashboard_control_endpoints(tmp_path):
116116
assert volume.status_code == 200
117117
assert client.get("/state").json()["layers"][0]["volume"] == 0.5
118118

119+
quiet_volume = client.post("/layer/volume", json={"target": 1, "volume": 0.009})
120+
assert quiet_volume.status_code == 200
121+
assert client.get("/state").json()["layers"][0]["volume"] == 0.009
122+
123+
mute_volume = client.post("/layer/volume", json={"target": 1, "volume": 0})
124+
assert mute_volume.status_code == 200
125+
assert client.get("/state").json()["layers"][0]["volume"] == 0.0
126+
119127
exported = client.post("/layer/export", json={"target": 1})
120128
assert exported.status_code == 200
121129
assert exported.json()["status"] == "ok"

tests/test_web_api.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ def test_invalid_command_returns_unknown_not_500(self, client):
6565
data = resp.json()
6666
assert data["action"]["action"] == "unknown"
6767

68+
def test_volume_command_preserves_quiet_and_mute(self, client):
69+
quiet = client.post("/api/command", json={"text": "set volume layer 1 0.009"})
70+
assert quiet.status_code == 200
71+
assert client.get("/api/state").json()["layers"][0]["volume"] == 0.009
72+
73+
mute = client.post("/api/command", json={"text": "set volume layer 1 0"})
74+
assert mute.status_code == 200
75+
assert client.get("/api/state").json()["layers"][0]["volume"] == 0.0
76+
6877

6978
class TestClearLayerEndpoint:
7079
"""POST /api/clear-layer with typed request."""

0 commit comments

Comments
 (0)