diff --git a/ac-electron/package.json b/ac-electron/package.json index 9f9e22ef5d..581626a11b 100644 --- a/ac-electron/package.json +++ b/ac-electron/package.json @@ -1,6 +1,6 @@ { "name": "aesthetic-computer", - "version": "0.1.15", + "version": "0.1.16", "description": "Aesthetic Computer", "homepage": "https://aesthetic.computer", "author": "Aesthetic Computer ", diff --git a/ac-electron/renderer/flip-view.html b/ac-electron/renderer/flip-view.html index d92c165747..d97802248f 100644 --- a/ac-electron/renderer/flip-view.html +++ b/ac-electron/renderer/flip-view.html @@ -253,6 +253,81 @@ .flip-tab.right .tab-back { border-radius: 0 6px 6px 0; } + + /* Volume control - right side slider */ + .volume-control { + position: absolute; + right: -46px; + top: 50%; + transform: translateY(-50%); + z-index: 240; + width: 36px; + height: 180px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 6px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--ac-border); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(6px); + } + + .volume-label { + font-size: 10px; + letter-spacing: 0.12em; + color: var(--ac-text-muted); + } + + .volume-value { + font-size: 11px; + color: var(--ac-text); + } + + #volume-slider { + -webkit-appearance: slider-vertical; + appearance: slider-vertical; + writing-mode: bt-lr; + width: 12px; + height: 120px; + background: transparent; + cursor: pointer; + } + + #volume-slider::-webkit-slider-runnable-track { + width: 4px; + background: var(--ac-border-solid); + border-radius: 6px; + } + + #volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--ac-accent); + border: 1px solid var(--ac-border-active); + box-shadow: 0 0 10px var(--ac-accent-glow); + } + + #volume-slider::-moz-range-track { + width: 4px; + background: var(--ac-border-solid); + border-radius: 6px; + } + + #volume-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--ac-accent); + border: 1px solid var(--ac-border-active); + box-shadow: 0 0 10px var(--ac-accent-glow); + } /* Mode tags - flush against bottom of card border */ .mode-tags { @@ -659,6 +734,12 @@
+ +
+
VOL
+ +
25
+
@@ -728,12 +809,63 @@ const prodTag = document.getElementById('prod-tag'); const localTag = document.getElementById('local-tag'); const flipTabs = document.querySelectorAll('.flip-tab'); + const volumeSlider = document.getElementById('volume-slider'); + const volumeValue = document.getElementById('volume-value'); let showingTerminal = false; let localhostAvailable = false; let currentMode = 'production'; // 'production' or 'localhost' + + const DEFAULT_MASTER_VOLUME = 0.25; + const VOLUME_STORAGE_KEY = 'ac-master-volume'; const PROD_URL = 'https://aesthetic.computer/prompt?desktop'; const LOCAL_URL = 'http://localhost:8888/prompt?desktop'; + + function clampVolume(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return DEFAULT_MASTER_VOLUME; + return Math.min(1, Math.max(0, numeric)); + } + + function updateVolumeUI(value) { + const clamped = clampVolume(value); + volumeSlider.value = clamped.toString(); + volumeValue.textContent = Math.round(clamped * 100).toString(); + return clamped; + } + + function sendMasterVolume(value) { + const clamped = clampVolume(value); + const code = `(() => { + const v = ${clamped}; + if (window.AC && typeof window.AC.setMasterVolume === 'function') { + window.AC.setMasterVolume(v); + return true; + } + if (typeof window.acSetMasterVolume === 'function') { + window.acSetMasterVolume(v); + return true; + } + return false; + })()`; + webviewEl.executeJavaScript(code, true).catch(() => {}); + } + + function syncMasterVolume() { + const clamped = clampVolume(volumeSlider.value); + sessionStorage.setItem(VOLUME_STORAGE_KEY, String(clamped)); + sendMasterVolume(clamped); + } + + // Initialize per-window volume + const storedVolume = sessionStorage.getItem(VOLUME_STORAGE_KEY); + if (storedVolume !== null) { + updateVolumeUI(storedVolume); + } else { + updateVolumeUI(DEFAULT_MASTER_VOLUME); + sessionStorage.setItem(VOLUME_STORAGE_KEY, String(DEFAULT_MASTER_VOLUME)); + } + syncMasterVolume(); // Poll for localhost availability using an image load trick (works in webview) async function checkLocalhost() { @@ -807,6 +939,7 @@ const piece = extractPieceName(e.url); console.log('[flip] Navigated to piece:', piece); ipcRenderer.invoke('set-current-piece', piece); + syncMasterVolume(); }); webviewEl.addEventListener('did-navigate-in-page', (e) => { @@ -814,8 +947,13 @@ const piece = extractPieceName(e.url); console.log('[flip] In-page navigation to piece:', piece); ipcRenderer.invoke('set-current-piece', piece); + syncMasterVolume(); } }); + + webviewEl.addEventListener('dom-ready', () => { + syncMasterVolume(); + }); // Handle window.open() from webview (prompt.mjs + and - commands) // Note: Main process also handles this via setWindowOpenHandler @@ -851,6 +989,11 @@ localTag.addEventListener('click', () => { if (localhostAvailable) switchMode('localhost'); }); + + volumeSlider.addEventListener('input', () => { + updateVolumeUI(volumeSlider.value); + syncMasterVolume(); + }); // Container control button handlers const startContainerBtn = document.getElementById('start-container-btn'); diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 0f34bc1fe9..0773ed4365 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -625,6 +625,13 @@ window.acDISK_SEND = function (message) { !diskSendsConsumed ? diskSends.push(message) : window.acSEND(message); }; +// 🔊 Master volume control (for desktop app sliders, etc.) +window.AC = window.AC || {}; +window.AC.setMasterVolume = (value) => applyMasterVolume(value); +window.AC.getMasterVolume = () => masterVolume; +window.acSetMasterVolume = window.AC.setMasterVolume; +window.acGetMasterVolume = window.AC.getMasterVolume; + // 🔍 CDP Debug State - Expose piece state for browser automation // Pieces can call `_send({ type: "debug:state", content: {...} })` to update this window.acPieceState = null; @@ -2552,7 +2559,39 @@ async function boot(parsed, bpm = 60, resolution, debug) { sfxStreamGain, micStreamGain, micGainNode, - speakerGain; + speakerGain, + speakerProcessorNode; + + let masterVolume = 1; + let backgroundMusicBaseVolume = 1; + + function clampVolume(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 1; + return Math.max(0, Math.min(1, numeric)); + } + + function applyMasterVolume(nextValue) { + masterVolume = clampVolume(nextValue); + + if (speakerProcessorNode?.port) { + speakerProcessorNode.port.postMessage({ type: "volume", value: masterVolume }); + } + + if (backgroundMusicEl) { + backgroundMusicEl.volume = backgroundMusicBaseVolume * masterVolume; + } + + for (const id in streamAudio) { + const stream = streamAudio[id]; + if (stream?.audio) { + const base = Number.isFinite(stream.baseVolume) ? stream.baseVolume : 1; + stream.audio.volume = base * masterVolume; + } + } + + return masterVolume; + } let requestMicrophoneAmplitude, requestMicrophoneWaveform, @@ -2594,7 +2633,8 @@ async function boot(parsed, bpm = 60, resolution, debug) { "16 - textual criticism ambiguity", ]; - const backgroundMusicEl = document.createElement("audio"); + let backgroundMusicEl; + backgroundMusicEl = document.createElement("audio"); backgroundMusicEl.id = "background-music"; backgroundMusicEl.crossOrigin = "anonymous"; wrapper.appendChild(backgroundMusicEl); @@ -2614,7 +2654,8 @@ async function boot(parsed, bpm = 60, resolution, debug) { const ext = Safari ? "m4a" : "ogg"; backgroundMusicEl.src = origin + backgroundTrackURLs[n] + "." + ext; - backgroundMusicEl.volume = parseFloat(volume); + backgroundMusicBaseVolume = clampVolume(volume); + backgroundMusicEl.volume = backgroundMusicBaseVolume * masterVolume; if (audioContext) { backgroundMusicEl.play(); } @@ -2980,6 +3021,8 @@ async function boot(parsed, bpm = 60, resolution, debug) { }, ); + speakerProcessorNode = speakerProcessor; + // 🎵 WORKLET INITIALIZATION LOGGING // console.log(`🎵 WORKLET_CREATED: bpm=${sound.bpm}, audioTime=${audioContext.currentTime.toFixed(6)}s, totalSetupTime=${(performance.now() - audioStartTimestamp).toFixed(3)}ms`); @@ -3162,6 +3205,8 @@ async function boot(parsed, bpm = 60, resolution, debug) { speakerProcessor.connect(speakerGain); console.log("🔊 Speaker processor connected to audio graph!"); + applyMasterVolume(masterVolume); + activatedSoundCallback?.(); // Process any queued sound effects now that audioContext is available @@ -15404,7 +15449,8 @@ async function boot(parsed, bpm = 60, resolution, debug) { const audio = new Audio(); audio.crossOrigin = "anonymous"; audio.src = url; - audio.volume = volume ?? 1; + const baseVolume = clampVolume(volume ?? 1); + audio.volume = baseVolume * masterVolume; // Connect to AudioContext for analysis if available if (audioContext) { @@ -15414,13 +15460,13 @@ async function boot(parsed, bpm = 60, resolution, debug) { analyser.fftSize = 256; source.connect(analyser); analyser.connect(audioContext.destination); - streamAudio[id] = { audio, analyser, source }; + streamAudio[id] = { audio, analyser, source, baseVolume }; } catch (e) { // If already connected, just store the audio - streamAudio[id] = { audio }; + streamAudio[id] = { audio, baseVolume }; } } else { - streamAudio[id] = { audio }; + streamAudio[id] = { audio, baseVolume }; } audio.play().then(() => { @@ -15472,7 +15518,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { if (type === "stream:volume") { const { id, volume } = content; if (streamAudio[id]?.audio) { - streamAudio[id].audio.volume = Math.max(0, Math.min(1, volume)); + const baseVolume = clampVolume(volume); + streamAudio[id].baseVolume = baseVolume; + streamAudio[id].audio.volume = baseVolume * masterVolume; } return; } diff --git a/system/public/aesthetic.computer/lib/speaker.mjs b/system/public/aesthetic.computer/lib/speaker.mjs index e86d5947f4..9fa4975e76 100644 --- a/system/public/aesthetic.computer/lib/speaker.mjs +++ b/system/public/aesthetic.computer/lib/speaker.mjs @@ -151,6 +151,12 @@ class SpeakerProcessor extends AudioWorkletProcessor { return; } + if (msg.type === "volume") { + const nextVolume = clamp(msg.value ?? msg.content ?? msg.data ?? 1, 0, 1); + volume.amount.val = nextVolume; + return; + } + // 🎛️ VST Bridge Mode - enable/disable sending samples to native plugin if (msg.type === "vst:enable") { this.#vstBridgeEnabled = true;