diff --git a/index.html b/index.html index 5495b46..203d5de 100644 --- a/index.html +++ b/index.html @@ -8,145 +8,225 @@ -
+
+

Dialogue de l'ombre double

- - (7/27) Explore Pierre Boulez's iconic composition from the perspectives
of the audio engineer, performer & audience. + (7/27) Explore Pierre Boulez's iconic composition from the perspectives
of the audio engineer, performer & an audience.

- - +

+ +
- - -
-
Mode:
-
- - + +
+
+
+ +
+
Mode
+
+ + +
+
+ + +
+ + +
+ + +
+
Movements
+ + + + +
+
+ +
+
+
Soundstage Map
+ +
+ +
-
- -
- - -
- - -
- - - - -
+
+
+
+
+
+

Audio Engineer Console

+

Monitor phrase energy and live speaker activity.

+
+
+ + + +
+
+
+
+
+ + + +
+
+
+
+
+
- - - - - - - - - - - - + + + - + + - diff --git a/js/ComposerSpace.js b/js/ComposerSpace.js new file mode 100644 index 0000000..0d0f5ef --- /dev/null +++ b/js/ComposerSpace.js @@ -0,0 +1,1305 @@ +const DEG2RAD = Math.PI / 180; + +/** + * SpatialComposer orchestrates the Spatial Atelier workstation. + * Feature highlights include: + * 1. Free-flight WASD/QE/Space navigation with adjustable speed. + * 2. Mouse look with yaw/pitch limits and focus glide targets. + * 3. Coordinate entry faders with validation and sphere clamping. + * 4. Random coordinate scatter and grid snapping toggle. + * 5. Axis mirroring shortcuts for symmetric placement. + * 6. Custom labeling for every resonance. + * 7. Waveform palette (sine/triangle/saw/square/noise). + * 8. Scale-aware pitch quantization across multiple modal palettes. + * 9. Per-note intensity, duration, attack, release, and delay envelopes. + * 10. Vibrato depth/rate modulation routing. + * 11. Multi-mode filter with cutoff/Q sculpting. + * 12. Spatial reverb send control with dedicated feedback loop. + * 13. Sphere rotation slider with auto-spin latch. + * 14. Timeline spacing control for rhythmic density. + * 15. Loop, shuffle, and scatter utilities. + * 16. Snapshot save/load banks (three slots). + * 17. JSON layout export for archival. + * 18. Offline WAV rendering for downloadable mixes. + * 19. Live audition preview with independent AudioContext. + * 20. Per-note mute/solo/duplicate/remove actions. + * 21. Camera focus to individual notes or origin reset. + * 22. Dynamic status console with emphasis state. + * 23. Rotation slider sync while auto-spinning. + * 24. Position-aware pan and distance gain mapping. + * 25. Vibrant note meshes with colorized altitude feedback. + * 26. Snapshot-aware restoration of envelopes/effects. + * 27. Mirrored duplication with safe sphere clamping. + * 28. Layout export/import ready data structures. + * 29. Visibility guardrails for hidden UI while inactive. + * 30. Automatic resource cleanup for preview/live contexts. + */ + +class SpatialComposer { + constructor() { + this.root = document.getElementById('composer-space'); + this.sceneContainer = document.getElementById('composer-scene'); + this.enterBtn = document.getElementById('enterComposerSpace'); + this.exitBtn = document.getElementById('exitComposerSpace'); + this.addNoteBtn = document.getElementById('addNoteBtn'); + this.playBtn = document.getElementById('playCompositionBtn'); + this.stopBtn = document.getElementById('stopCompositionBtn'); + this.saveBtn = document.getElementById('saveCompositionBtn'); + this.autoSpinBtn = document.getElementById('autoSpinBtn'); + this.rotationSlider = document.getElementById('sphereRotationSlider'); + this.statusEl = document.getElementById('composer-status'); + this.noteListEl = document.getElementById('composer-noteList'); + this.coordInputs = { + x: document.getElementById('coordX'), + y: document.getElementById('coordY'), + z: document.getElementById('coordZ') + }; + this.coordLabelInput = document.getElementById('coordLabel'); + this.randomizeBtn = document.getElementById('randomizeCoordsBtn'); + this.snapToggleBtn = document.getElementById('snapToggle'); + this.mirrorButtons = { + x: document.getElementById('mirrorXBtn'), + y: document.getElementById('mirrorYBtn'), + z: document.getElementById('mirrorZBtn') + }; + this.waveformSelect = document.getElementById('waveformSelect'); + this.scaleSelect = document.getElementById('scaleSelect'); + this.noteVolumeSlider = document.getElementById('noteVolume'); + this.noteDurationSlider = document.getElementById('noteDuration'); + this.attackSlider = document.getElementById('attackSlider'); + this.releaseSlider = document.getElementById('releaseSlider'); + this.noteDelaySlider = document.getElementById('noteDelay'); + this.vibratoDepthSlider = document.getElementById('vibratoDepth'); + this.vibratoRateSlider = document.getElementById('vibratoRate'); + this.filterTypeSelect = document.getElementById('filterType'); + this.filterFrequencySlider = document.getElementById('filterFrequency'); + this.filterQSlider = document.getElementById('filterQ'); + this.reverbAmountSlider = document.getElementById('reverbAmount'); + this.movementSpeedSlider = document.getElementById('movementSpeedSlider'); + this.tempoSlider = document.getElementById('tempoSlider'); + this.loopToggleBtn = document.getElementById('loopToggleBtn'); + this.clearNotesBtn = document.getElementById('clearNotesBtn'); + this.randomScatterBtn = document.getElementById('randomScatterBtn'); + this.snapshotSelect = document.getElementById('snapshotSelect'); + this.saveSnapshotBtn = document.getElementById('saveSnapshotBtn'); + this.loadSnapshotBtn = document.getElementById('loadSnapshotBtn'); + this.shuffleTimelineBtn = document.getElementById('shuffleTimelineBtn'); + this.focusOriginBtn = document.getElementById('focusOriginBtn'); + this.exportJsonBtn = document.getElementById('exportJsonBtn'); + this.rangeOutputs = { + noteVolume: document.getElementById('noteVolumeValue'), + noteDuration: document.getElementById('noteDurationValue'), + attackSlider: document.getElementById('attackValue'), + releaseSlider: document.getElementById('releaseValue'), + noteDelay: document.getElementById('noteDelayValue'), + vibratoDepth: document.getElementById('vibratoDepthValue'), + vibratoRate: document.getElementById('vibratoRateValue'), + filterFrequency: document.getElementById('filterFrequencyValue'), + filterQ: document.getElementById('filterQValue'), + reverbAmount: document.getElementById('reverbAmountValue'), + movementSpeedSlider: document.getElementById('movementSpeedValue'), + tempoSlider: document.getElementById('tempoValue') + }; + + if (!this.root || !this.sceneContainer || !this.enterBtn || !this.exitBtn) { + return; + } + + this.sphereRadius = 6; + this.moveSpeed = this.movementSpeedSlider ? Number(this.movementSpeedSlider.value) : 8; + this.lookSensitivity = 0.0025; + this.autoSpinSpeed = DEG2RAD * 12; + this.notes = []; + this.noteCounter = 0; + this.moveState = { forward: false, back: false, left: false, right: false, up: false, down: false }; + this.isPointerDown = false; + this.lastPointer = { x: 0, y: 0 }; + this.isActive = false; + this.isAdjustingRotation = false; + this.autoSpin = false; + this.liveContext = null; + this.activeTimeout = null; + this.previewContext = null; + this.snapEnabled = false; + this.gridStep = 0.5; + this.scalePreset = this.scaleSelect ? this.scaleSelect.value : 'chromatic'; + this.tempoSpacing = this.tempoSlider ? Number(this.tempoSlider.value) : 0.75; + this.loopPlayback = false; + this.shuffleTimeline = false; + this.focusTarget = null; + this.snapshotStore = { slot1: [], slot2: [], slot3: [] }; + this.scales = { + chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + whole: [0, 2, 4, 6, 8, 10], + minor: [0, 2, 3, 5, 7, 8, 10], + major: [0, 2, 4, 6, 7, 9, 11], + pentatonic: [0, 2, 4, 7, 9], + octatonic: [0, 1, 3, 4, 6, 7, 9, 10] + }; + + this.animate = this.animate.bind(this); + this.handleResize = this.handleResize.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + + this.setupThree(); + this.bindUI(); + this.setStatus('Drop coordinates to begin sculpting.'); + } + + setupThree() { + const width = this.sceneContainer.clientWidth; + const height = this.sceneContainer.clientHeight; + + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + this.renderer.setPixelRatio(window.devicePixelRatio); + this.renderer.setSize(width, height); + this.sceneContainer.appendChild(this.renderer.domElement); + + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color(0x05070f); + this.scene.fog = new THREE.FogExp2(0x05070f, 0.018); + + this.camera = new THREE.PerspectiveCamera(70, width / height, 0.1, 200); + this.camera.position.set(0, 2, 14); + this.yaw = Math.PI; + this.pitch = 0; + + const ambient = new THREE.AmbientLight(0xf2d6c3, 0.35); + this.scene.add(ambient); + + const hemi = new THREE.HemisphereLight(0xffb37f, 0x16274f, 0.5); + this.scene.add(hemi); + + const dirLight = new THREE.DirectionalLight(0xffe8d1, 0.6); + dirLight.position.set(6, 10, 6); + dirLight.castShadow = false; + this.scene.add(dirLight); + + this.stageGroup = new THREE.Group(); + this.scene.add(this.stageGroup); + + const sphereGeometry = new THREE.SphereGeometry(this.sphereRadius, 64, 64); + const sphereMaterial = new THREE.MeshPhongMaterial({ + color: 0x1e2a52, + wireframe: true, + transparent: true, + opacity: 0.2 + }); + this.sonicSphere = new THREE.Mesh(sphereGeometry, sphereMaterial); + this.stageGroup.add(this.sonicSphere); + + const floorGeometry = new THREE.CircleGeometry(this.sphereRadius, 64); + const floorMaterial = new THREE.MeshBasicMaterial({ + color: 0x0d1324, + transparent: true, + opacity: 0.55 + }); + const floor = new THREE.Mesh(floorGeometry, floorMaterial); + floor.rotation.x = -Math.PI / 2; + this.stageGroup.add(floor); + + const orbitRingGeometry = new THREE.RingGeometry(this.sphereRadius * 0.65, this.sphereRadius * 0.65 + 0.03, 128); + const orbitMaterial = new THREE.MeshBasicMaterial({ + color: 0xffb05a, + transparent: true, + opacity: 0.25, + side: THREE.DoubleSide + }); + const orbit = new THREE.Mesh(orbitRingGeometry, orbitMaterial); + orbit.rotation.x = -Math.PI / 2; + this.stageGroup.add(orbit); + + const verticalRing = orbit.clone(); + verticalRing.rotation.set(0, 0, Math.PI / 2); + this.stageGroup.add(verticalRing); + + const particles = this.createStarfield(850, 90); + this.scene.add(particles); + + this.noteGroup = new THREE.Group(); + this.stageGroup.add(this.noteGroup); + + this.clock = new THREE.Clock(); + this.updateCameraDirection(); + } + + createStarfield(count, spread) { + const positions = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + positions[i * 3] = (Math.random() - 0.5) * spread; + positions[i * 3 + 1] = (Math.random() - 0.5) * spread; + positions[i * 3 + 2] = (Math.random() - 0.5) * spread; + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const material = new THREE.PointsMaterial({ + color: 0xffd5a6, + size: 0.35, + transparent: true, + opacity: 0.6 + }); + return new THREE.Points(geometry, material); + } + + bindUI() { + window.addEventListener('resize', this.handleResize); + window.addEventListener('keydown', this.onKeyDown); + window.addEventListener('keyup', this.onKeyUp); + + this.sceneContainer.addEventListener('mousedown', (event) => { + if (!this.isActive) return; + this.isPointerDown = true; + this.lastPointer.x = event.clientX; + this.lastPointer.y = event.clientY; + }); + + window.addEventListener('mouseup', () => { + this.isPointerDown = false; + }); + + window.addEventListener('mousemove', (event) => { + if (!this.isPointerDown || !this.isActive) return; + const dx = event.clientX - this.lastPointer.x; + const dy = event.clientY - this.lastPointer.y; + this.lastPointer.x = event.clientX; + this.lastPointer.y = event.clientY; + + this.yaw -= dx * this.lookSensitivity; + this.pitch -= dy * this.lookSensitivity; + const maxPitch = Math.PI / 2 - 0.2; + this.pitch = Math.max(-maxPitch, Math.min(maxPitch, this.pitch)); + this.updateCameraDirection(); + }); + + this.enterBtn.addEventListener('click', () => this.enter()); + this.exitBtn.addEventListener('click', () => this.exit()); + + this.addNoteBtn?.addEventListener('click', () => this.handleAddNote()); + this.playBtn?.addEventListener('click', () => this.playComposition()); + this.stopBtn?.addEventListener('click', () => { + this.stopPlayback(); + this.setStatus('Playback stopped. Ready for the next sculpt.', true); + }); + this.saveBtn?.addEventListener('click', () => this.saveComposition()); + + if (this.autoSpinBtn) { + this.autoSpinBtn.addEventListener('click', () => { + this.autoSpin = !this.autoSpin; + this.autoSpinBtn.classList.toggle('active', this.autoSpin); + this.setStatus(this.autoSpin ? 'Sphere spinning — listen for orbital sweeps.' : 'Sphere rotation paused.'); + }); + } + + if (this.rotationSlider) { + const beginAdjust = () => { + this.isAdjustingRotation = true; + this.autoSpin = false; + this.autoSpinBtn?.classList.remove('active'); + }; + const finishAdjust = () => { + this.isAdjustingRotation = false; + }; + this.rotationSlider.addEventListener('pointerdown', beginAdjust); + this.rotationSlider.addEventListener('pointerup', finishAdjust); + this.rotationSlider.addEventListener('touchstart', beginAdjust, { passive: true }); + this.rotationSlider.addEventListener('touchend', finishAdjust); + this.rotationSlider.addEventListener('input', (event) => { + const degrees = Number(event.target.value); + this.stageGroup.rotation.y = degrees * DEG2RAD; + this.setStatus(`Sphere rotated ${degrees.toFixed(0)}°.`); + }); + } + + this.randomizeBtn?.addEventListener('click', () => this.randomizeCoordinates()); + this.snapToggleBtn?.addEventListener('click', () => { + this.snapEnabled = !this.snapEnabled; + this.snapToggleBtn.classList.toggle('active', this.snapEnabled); + this.setStatus(this.snapEnabled ? 'Grid snapping enabled at 0.5 units.' : 'Grid snapping disabled.'); + }); + + Object.entries(this.mirrorButtons).forEach(([axis, button]) => { + button?.addEventListener('click', () => { + if (!this.coordInputs[axis]) return; + const current = parseFloat(this.coordInputs[axis].value || '0'); + if (Number.isNaN(current)) return; + const mirrored = -current; + this.coordInputs[axis].value = mirrored.toFixed(2); + this.setStatus(`Mirrored ${axis.toUpperCase()} to ${mirrored.toFixed(2)}.`, true); + }); + }); + + this.scaleSelect?.addEventListener('change', () => { + this.scalePreset = this.scaleSelect.value; + this.setStatus(`Pitch palette set to ${this.scaleSelect.options[this.scaleSelect.selectedIndex].text}.`, true); + }); + + this.bindRangeOutput(this.noteVolumeSlider, this.rangeOutputs.noteVolume, (val) => `${Math.round(val * 100)}%`); + this.bindRangeOutput(this.noteDurationSlider, this.rangeOutputs.noteDuration, (val) => `${val.toFixed(2)}s`); + this.bindRangeOutput(this.attackSlider, this.rangeOutputs.attackSlider, (val) => `${val.toFixed(2)}s`); + this.bindRangeOutput(this.releaseSlider, this.rangeOutputs.releaseSlider, (val) => `${val.toFixed(2)}s`); + this.bindRangeOutput(this.noteDelaySlider, this.rangeOutputs.noteDelay, (val) => `${val.toFixed(2)}s`); + this.bindRangeOutput(this.vibratoDepthSlider, this.rangeOutputs.vibratoDepth, (val) => `${Math.round(val)}¢`); + this.bindRangeOutput(this.vibratoRateSlider, this.rangeOutputs.vibratoRate, (val) => `${val.toFixed(1)}Hz`); + this.bindRangeOutput(this.filterFrequencySlider, this.rangeOutputs.filterFrequency, (val) => `${(val / 1000).toFixed(2)}kHz`); + this.bindRangeOutput(this.filterQSlider, this.rangeOutputs.filterQ, (val) => `Q ${val.toFixed(1)}`); + this.bindRangeOutput(this.reverbAmountSlider, this.rangeOutputs.reverbAmount, (val) => `${Math.round(val * 100)}%`); + this.bindRangeOutput(this.movementSpeedSlider, this.rangeOutputs.movementSpeedSlider, (val) => { + this.moveSpeed = val; + return `${val.toFixed(0)} m/s`; + }); + this.bindRangeOutput(this.tempoSlider, this.rangeOutputs.tempoSlider, (val) => { + this.tempoSpacing = val; + return `${val.toFixed(2)}s`; + }); + + this.loopToggleBtn?.addEventListener('click', () => { + this.loopPlayback = !this.loopPlayback; + this.loopToggleBtn.classList.toggle('active', this.loopPlayback); + this.setStatus(this.loopPlayback ? 'Loop enabled — the mix will repeat.' : 'Loop disabled.'); + }); + + this.clearNotesBtn?.addEventListener('click', () => this.clearNotes()); + this.randomScatterBtn?.addEventListener('click', () => this.scatterNotes(6)); + this.saveSnapshotBtn?.addEventListener('click', () => this.saveSnapshot()); + this.loadSnapshotBtn?.addEventListener('click', () => this.loadSnapshot()); + + this.shuffleTimelineBtn?.addEventListener('click', () => { + this.shuffleTimeline = !this.shuffleTimeline; + this.shuffleTimelineBtn.classList.toggle('active', this.shuffleTimeline); + this.setStatus(this.shuffleTimeline ? 'Playback timeline will shuffle each pass.' : 'Playback order restored.'); + }); + + this.focusOriginBtn?.addEventListener('click', () => this.focusOrigin()); + this.exportJsonBtn?.addEventListener('click', () => this.exportLayout()); + + this.noteListEl?.addEventListener('click', (event) => { + const target = event.target instanceof HTMLElement ? event.target : null; + const button = target?.closest('button[data-action]'); + if (!button) return; + const id = Number(button.dataset.id); + if (Number.isNaN(id)) return; + switch (button.dataset.action) { + case 'remove': + this.removeNoteById(id); + break; + case 'preview': + this.previewNote(id); + break; + case 'focus': + this.focusNote(id); + break; + case 'duplicate': + this.duplicateNote(id); + break; + case 'mute': + this.toggleMute(id, button); + break; + case 'solo': + this.toggleSolo(id, button); + break; + default: + break; + } + }); + + document.addEventListener('visibilitychange', () => { + if (document.hidden && this.isActive) { + this.exit(); + } + }); + } + + bindRangeOutput(slider, output, formatter) { + if (!slider || !output || typeof formatter !== 'function') return; + const update = () => { + const value = Number(slider.value); + output.textContent = formatter(value); + }; + slider.addEventListener('input', update); + update(); + } + + enter() { + if (this.isActive) return; + document.body.classList.add('composer-active'); + this.root?.setAttribute('aria-hidden', 'false'); + this.exitBtn?.setAttribute('aria-hidden', 'false'); + this.isActive = true; + this.clock.start(); + this.renderer.setAnimationLoop(this.animate); + this.handleResize(); + this.setStatus('Spatial atelier ready — sculpt your constellation.'); + } + + exit() { + if (!this.isActive) return; + document.body.classList.remove('composer-active'); + this.root?.setAttribute('aria-hidden', 'true'); + this.exitBtn?.setAttribute('aria-hidden', 'true'); + this.isActive = false; + this.renderer.setAnimationLoop(null); + this.stopPlayback(); + this.stopPreview(); + this.autoSpin = false; + this.autoSpinBtn?.classList.remove('active'); + if (this.rotationSlider) { + this.rotationSlider.value = '0'; + } + this.stageGroup.rotation.y = 0; + this.camera.position.set(0, 2, 14); + this.yaw = Math.PI; + this.pitch = 0; + this.updateCameraDirection(); + this.focusTarget = null; + this.setStatus('Drop coordinates to begin sculpting.'); + } + + handleAddNote() { + const position = this.preparePositionFromInputs(); + if (!position) { + this.setStatus('Enter numeric values for X, Y, and Z.'); + return; + } + + const label = this.coordLabelInput?.value.trim(); + const noteData = this.generateNoteData(position, { label }); + this.spawnNote(noteData); + if (this.coordLabelInput) { + this.coordLabelInput.value = ''; + } + } + + randomizeCoordinates() { + const vector = new THREE.Vector3( + (Math.random() * 2 - 1), + (Math.random() * 2 - 1), + (Math.random() * 2 - 1) + ).normalize().multiplyScalar(Math.random() * (this.sphereRadius - 0.4)); + if (this.snapEnabled) { + vector.set( + this.applySnap(vector.x), + this.applySnap(vector.y), + this.applySnap(vector.z) + ); + } + if (this.coordInputs.x) this.coordInputs.x.value = vector.x.toFixed(2); + if (this.coordInputs.y) this.coordInputs.y.value = vector.y.toFixed(2); + if (this.coordInputs.z) this.coordInputs.z.value = vector.z.toFixed(2); + this.setStatus('Coordinates randomized within the sphere.', true); + } + + preparePositionFromInputs() { + const x = parseFloat(this.coordInputs.x?.value || '0'); + const y = parseFloat(this.coordInputs.y?.value || '0'); + const z = parseFloat(this.coordInputs.z?.value || '0'); + if ([x, y, z].some((v) => Number.isNaN(v))) { + return null; + } + const position = new THREE.Vector3(x, y, z); + if (this.snapEnabled) { + position.set( + this.applySnap(position.x), + this.applySnap(position.y), + this.applySnap(position.z) + ); + } + if (position.length() > this.sphereRadius) { + position.setLength(this.sphereRadius - 0.2); + this.setStatus('Point nudged inside the sphere for resonance.', true); + if (this.coordInputs.x) this.coordInputs.x.value = position.x.toFixed(2); + if (this.coordInputs.y) this.coordInputs.y.value = position.y.toFixed(2); + if (this.coordInputs.z) this.coordInputs.z.value = position.z.toFixed(2); + } + return position; + } + + applySnap(value) { + const snapped = Math.round(value / this.gridStep) * this.gridStep; + return Number(snapped.toFixed(2)); + } + + generateNoteData(position, overrides = {}) { + const labelBase = overrides.label && overrides.label.length ? overrides.label : `Resonance ${this.noteCounter + 1}`; + const label = labelBase.trim(); + const waveform = overrides.waveform || this.waveformSelect?.value || 'sine'; + const volume = overrides.volume ?? (this.noteVolumeSlider ? Number(this.noteVolumeSlider.value) : 0.6); + const duration = overrides.duration ?? (this.noteDurationSlider ? Number(this.noteDurationSlider.value) : 1.1); + const attack = overrides.attack ?? (this.attackSlider ? Number(this.attackSlider.value) : 0.08); + const release = overrides.release ?? (this.releaseSlider ? Number(this.releaseSlider.value) : 1.2); + const delay = overrides.delay ?? (this.noteDelaySlider ? Number(this.noteDelaySlider.value) : 0.2); + const vibratoDepth = overrides.vibratoDepth ?? (this.vibratoDepthSlider ? Number(this.vibratoDepthSlider.value) : 12); + const vibratoRate = overrides.vibratoRate ?? (this.vibratoRateSlider ? Number(this.vibratoRateSlider.value) : 4.5); + const filterType = overrides.filterType || this.filterTypeSelect?.value || 'lowpass'; + const filterFrequency = overrides.filterFrequency ?? (this.filterFrequencySlider ? Number(this.filterFrequencySlider.value) : 2200); + const filterQ = overrides.filterQ ?? (this.filterQSlider ? Number(this.filterQSlider.value) : 4); + const reverbAmount = overrides.reverbAmount ?? (this.reverbAmountSlider ? Number(this.reverbAmountSlider.value) : 0.35); + + return { + id: ++this.noteCounter, + label, + position: position.clone(), + waveform, + volume, + duration, + attack, + release, + delay, + vibratoDepth, + vibratoRate, + filterType, + filterFrequency, + filterQ, + reverbAmount, + muted: overrides.muted ?? false, + solo: overrides.solo ?? false, + mesh: null + }; + } + + spawnNote(noteData) { + const hue = THREE.MathUtils.mapLinear(noteData.position.y, -this.sphereRadius, this.sphereRadius, 0.58, 0.03); + const color = new THREE.Color().setHSL(THREE.MathUtils.euclideanModulo(hue, 1), 0.65, 0.55); + const emissive = color.clone().multiplyScalar(0.35); + + const noteMesh = new THREE.Mesh( + new THREE.SphereGeometry(0.25, 24, 24), + new THREE.MeshStandardMaterial({ + color, + emissive, + metalness: 0.45, + roughness: 0.35 + }) + ); + noteMesh.position.copy(noteData.position); + noteMesh.castShadow = false; + this.noteGroup.add(noteMesh); + noteData.mesh = noteMesh; + this.notes.push(noteData); + this.updateNoteList(); + this.setStatus(`Resonance "${noteData.label}" seeded inside the sphere.`, true); + } + + updateNoteList() { + if (!this.noteListEl) return; + this.noteListEl.innerHTML = ''; + + if (this.notes.length === 0) { + const li = document.createElement('li'); + li.textContent = 'No resonances yet — drop a point to begin.'; + this.noteListEl.appendChild(li); + return; + } + + this.notes.forEach((note) => { + const li = document.createElement('li'); + const meta = document.createElement('div'); + meta.className = 'note-meta'; + const title = document.createElement('strong'); + title.textContent = note.label; + meta.appendChild(title); + const coords = document.createElement('span'); + coords.textContent = `X ${note.position.x.toFixed(2)} · Y ${note.position.y.toFixed(2)} · Z ${note.position.z.toFixed(2)}`; + meta.appendChild(coords); + const detail = document.createElement('span'); + detail.textContent = `${note.waveform === 'sine' ? 'Sine' : note.waveform.charAt(0).toUpperCase() + note.waveform.slice(1)} · ${note.duration.toFixed(2)}s · ${this.scalePreset}`; + meta.appendChild(detail); + li.appendChild(meta); + + const actions = document.createElement('div'); + actions.className = 'note-actions'; + + const actionButtons = [ + { action: 'preview', label: 'Preview' }, + { action: 'focus', label: 'Focus' }, + { action: 'duplicate', label: 'Duplicate' }, + { action: 'mute', label: note.muted ? 'Muted' : 'Mute', active: note.muted }, + { action: 'solo', label: note.solo ? 'Soloed' : 'Solo', active: note.solo }, + { action: 'remove', label: 'Remove' } + ]; + + actionButtons.forEach(({ action, label, active }) => { + const button = document.createElement('button'); + button.type = 'button'; + button.dataset.action = action; + button.dataset.id = String(note.id); + button.textContent = label; + if (active) { + button.classList.add('active'); + } + actions.appendChild(button); + }); + + li.appendChild(actions); + this.noteListEl.appendChild(li); + }); + } + + findNoteIndex(id) { + return this.notes.findIndex((note) => note.id === id); + } + + removeNoteById(id) { + const index = this.findNoteIndex(id); + if (index === -1) return; + const note = this.notes[index]; + if (note.mesh) { + this.noteGroup.remove(note.mesh); + note.mesh.geometry.dispose(); + note.mesh.material.dispose(); + } + this.notes.splice(index, 1); + this.updateNoteList(); + this.setStatus('Resonance removed.'); + } + + clearNotes() { + this.notes.forEach((note) => { + if (note.mesh) { + this.noteGroup.remove(note.mesh); + note.mesh.geometry.dispose(); + note.mesh.material.dispose(); + } + }); + this.notes = []; + this.updateNoteList(); + this.setStatus('All resonances cleared. Fresh canvas ready.', true); + } + + duplicateNote(id) { + const index = this.findNoteIndex(id); + if (index === -1) return; + const source = this.notes[index]; + const offset = source.position.clone().normalize().multiplyScalar(0.6); + const newPosition = source.position.clone().add(offset); + if (newPosition.length() > this.sphereRadius) { + newPosition.setLength(this.sphereRadius - 0.2); + } + const duplicate = this.generateNoteData(newPosition, { + label: `${source.label} (mirror)`, + waveform: source.waveform, + volume: source.volume, + duration: source.duration, + attack: source.attack, + release: source.release, + delay: source.delay, + vibratoDepth: source.vibratoDepth, + vibratoRate: source.vibratoRate, + filterType: source.filterType, + filterFrequency: source.filterFrequency, + filterQ: source.filterQ, + reverbAmount: source.reverbAmount + }); + this.spawnNote(duplicate); + } + + toggleMute(id, button) { + const index = this.findNoteIndex(id); + if (index === -1) return; + const note = this.notes[index]; + note.muted = !note.muted; + if (button) { + button.classList.toggle('active', note.muted); + button.textContent = note.muted ? 'Muted' : 'Mute'; + } + this.setStatus(note.muted ? `${note.label} muted.` : `${note.label} unmuted.`, true); + } + + toggleSolo(id, button) { + const index = this.findNoteIndex(id); + if (index === -1) return; + const note = this.notes[index]; + note.solo = !note.solo; + if (button) { + button.classList.toggle('active', note.solo); + button.textContent = note.solo ? 'Soloed' : 'Solo'; + } + this.setStatus(note.solo ? `${note.label} soloed — other voices will dim.` : `${note.label} returned to ensemble.`, true); + } + + previewNote(id) { + const index = this.findNoteIndex(id); + if (index === -1) return; + const note = this.notes[index]; + this.stopPreview(); + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) { + this.setStatus('AudioContext not supported in this browser.'); + return; + } + const context = new AudioCtx(); + this.previewContext = context; + const masterGain = context.createGain(); + masterGain.gain.setValueAtTime(0.8, context.currentTime); + masterGain.connect(context.destination); + const stopTime = this.scheduleNote(context, masterGain, context.currentTime + 0.05, note); + this.setStatus(`Previewing ${note.label}.`, true); + window.setTimeout(() => this.stopPreview(), (stopTime - context.currentTime + 0.1) * 1000); + } + + stopPreview() { + if (this.previewContext) { + try { + this.previewContext.close(); + } catch (error) { + console.warn('ComposerSpace: unable to close preview context', error); + } + this.previewContext = null; + } + } + + focusNote(id) { + const index = this.findNoteIndex(id); + if (index === -1) return; + const note = this.notes[index]; + const direction = note.position.clone().normalize(); + const target = note.position.clone().add(direction.multiplyScalar(2.4)); + target.y += 1.2; + this.focusTarget = target; + this.yaw = Math.atan2(note.position.x - target.x, note.position.z - target.z); + const pitchDirection = new THREE.Vector3().subVectors(note.position, target).normalize(); + this.pitch = Math.asin(pitchDirection.y); + this.setStatus(`Camera gliding to ${note.label}.`, true); + } + + focusOrigin() { + const target = new THREE.Vector3(0, 2, this.sphereRadius * 2.2); + this.focusTarget = target; + this.yaw = Math.PI; + this.pitch = 0; + this.setStatus('Camera recentred on the atelier.', true); + } + + scatterNotes(count = 6) { + for (let i = 0; i < count; i++) { + const vector = new THREE.Vector3( + (Math.random() * 2 - 1), + (Math.random() * 2 - 1), + (Math.random() * 2 - 1) + ).normalize().multiplyScalar(Math.random() * (this.sphereRadius - 0.6)); + if (this.snapEnabled) { + vector.set( + this.applySnap(vector.x), + this.applySnap(vector.y), + this.applySnap(vector.z) + ); + } + const noteData = this.generateNoteData(vector, { label: `Scatter ${i + 1}` }); + this.spawnNote(noteData); + } + this.setStatus('Six resonances scattered across the sphere.', true); + } + + saveSnapshot() { + if (!this.snapshotSelect) return; + const slot = this.snapshotSelect.value; + this.snapshotStore[slot] = this.notes.map((note) => ({ + label: note.label, + position: note.position.toArray(), + waveform: note.waveform, + volume: note.volume, + duration: note.duration, + attack: note.attack, + release: note.release, + delay: note.delay, + vibratoDepth: note.vibratoDepth, + vibratoRate: note.vibratoRate, + filterType: note.filterType, + filterFrequency: note.filterFrequency, + filterQ: note.filterQ, + reverbAmount: note.reverbAmount, + muted: note.muted, + solo: note.solo + })); + this.setStatus(`Snapshot saved to ${slot.toUpperCase()}.`, true); + } + + loadSnapshot() { + if (!this.snapshotSelect) return; + const slot = this.snapshotSelect.value; + const snapshot = this.snapshotStore[slot]; + if (!snapshot || snapshot.length === 0) { + this.setStatus('Snapshot slot is empty.'); + return; + } + this.clearNotes(); + snapshot.forEach((data) => { + const position = new THREE.Vector3().fromArray(data.position); + const noteData = this.generateNoteData(position, data); + this.spawnNote(noteData); + }); + this.setStatus(`Snapshot ${slot.toUpperCase()} loaded.`, true); + } + + exportLayout() { + const payload = this.notes.map((note) => ({ + label: note.label, + position: note.position.toArray(), + waveform: note.waveform, + volume: note.volume, + duration: note.duration, + attack: note.attack, + release: note.release, + delay: note.delay, + vibratoDepth: note.vibratoDepth, + vibratoRate: note.vibratoRate, + filterType: note.filterType, + filterFrequency: note.filterFrequency, + filterQ: note.filterQ, + reverbAmount: note.reverbAmount, + muted: note.muted, + solo: note.solo + })); + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `spatial-atlier-layout-${Date.now()}.json`; + anchor.click(); + URL.revokeObjectURL(url); + this.setStatus('Layout exported as JSON.', true); + } + + getPlayableNotes() { + const soloed = this.notes.filter((note) => note.solo && !note.muted); + if (soloed.length > 0) { + return soloed; + } + return this.notes.filter((note) => !note.muted); + } + + getPlaybackOrder(notes) { + if (!this.shuffleTimeline) { + return [...notes]; + } + const shuffled = [...notes]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; + } + + playComposition() { + const playable = this.getPlayableNotes(); + if (playable.length === 0) { + this.setStatus('Add or unmute resonances before playing.'); + return; + } + + this.stopPlayback(); + this.stopPreview(); + + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) { + this.setStatus('AudioContext not supported in this browser.'); + return; + } + + const context = new AudioCtx(); + this.liveContext = context; + const now = context.currentTime + 0.15; + const masterGain = context.createGain(); + masterGain.gain.setValueAtTime(0.9, now); + masterGain.connect(context.destination); + + const order = this.getPlaybackOrder(playable); + let maxTime = now; + order.forEach((note, index) => { + const startOffset = index * this.tempoSpacing + note.delay; + const stopTime = this.scheduleNote(context, masterGain, now + startOffset, note); + maxTime = Math.max(maxTime, stopTime); + }); + + this.setStatus('Live mix playing — listen in motion.', true); + this.activeTimeout = window.setTimeout(() => { + this.setStatus('Playback complete. Ready for the next constellation.'); + this.stopPlayback(); + if (this.loopPlayback && this.notes.length) { + this.playComposition(); + } + }, (maxTime - context.currentTime) * 1000); + } + + stopPlayback() { + if (this.liveContext) { + try { + this.liveContext.close(); + } catch (error) { + console.warn('ComposerSpace: unable to close context', error); + } + this.liveContext = null; + } + if (this.activeTimeout) { + window.clearTimeout(this.activeTimeout); + this.activeTimeout = null; + } + } + + async saveComposition() { + const playable = this.getPlayableNotes(); + if (playable.length === 0) { + this.setStatus('Add resonances before exporting a mix.'); + return; + } + + const sampleRate = 44100; + const durationEstimate = playable.length * this.tempoSpacing + 4; + const offline = new OfflineAudioContext(2, Math.ceil(sampleRate * durationEstimate), sampleRate); + const masterGain = offline.createGain(); + masterGain.gain.setValueAtTime(0.9, 0); + masterGain.connect(offline.destination); + + const order = this.getPlaybackOrder(playable); + let maxTime = 0; + order.forEach((note, index) => { + const startOffset = index * this.tempoSpacing + note.delay + 0.2; + const stopTime = this.scheduleNote(offline, masterGain, startOffset, note); + maxTime = Math.max(maxTime, stopTime); + }); + + this.setStatus('Rendering mix for download…'); + try { + const buffer = await offline.startRendering(); + const blob = this.audioBufferToWav(buffer); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `boulez-atlier-${Date.now()}.wav`; + anchor.click(); + URL.revokeObjectURL(url); + this.setStatus('Mix downloaded — continue sculpting!', true); + } catch (error) { + console.error('ComposerSpace: unable to export mix', error); + this.setStatus('Something interrupted the export. Please try again.'); + } + } + + scheduleNote(context, destination, startTime, note) { + const isNoise = note.waveform === 'noise'; + let source; + if (isNoise) { + const buffer = context.createBuffer(1, context.sampleRate * Math.max(1, note.duration + note.release + 1), context.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < data.length; i++) { + data[i] = Math.random() * 2 - 1; + } + const noise = context.createBufferSource(); + noise.buffer = buffer; + source = noise; + } else { + source = context.createOscillator(); + source.type = note.waveform; + source.frequency.setValueAtTime(this.mapToFrequency(note), startTime); + if (note.vibratoDepth > 0) { + const vibrato = context.createOscillator(); + vibrato.frequency.setValueAtTime(note.vibratoRate, startTime); + const vibratoGain = context.createGain(); + vibratoGain.gain.setValueAtTime(note.vibratoDepth, startTime); + vibrato.connect(vibratoGain); + vibratoGain.connect(source.frequency); + vibrato.start(startTime); + vibrato.stop(startTime + note.duration + note.release + 1); + } + } + + const gain = context.createGain(); + const filter = context.createBiquadFilter(); + filter.type = note.filterType; + filter.frequency.setValueAtTime(note.filterFrequency, startTime); + filter.Q.setValueAtTime(note.filterQ, startTime); + const pan = context.createStereoPanner(); + pan.pan.setValueAtTime(this.mapToPan(note.position), startTime); + + const dryGain = context.createGain(); + const wetGain = context.createGain(); + dryGain.gain.setValueAtTime(this.mapDistanceGain(note.position) * note.volume, startTime); + wetGain.gain.setValueAtTime(note.reverbAmount, startTime); + + const delay = context.createDelay(1.5); + delay.delayTime.setValueAtTime(0.28, startTime); + const feedback = context.createGain(); + feedback.gain.setValueAtTime(0.32, startTime); + delay.connect(feedback); + feedback.connect(delay); + + const reverbOut = context.createGain(); + reverbOut.gain.setValueAtTime(note.reverbAmount, startTime); + + source.connect(gain); + gain.connect(filter); + filter.connect(dryGain); + filter.connect(wetGain); + dryGain.connect(pan); + pan.connect(destination); + + wetGain.connect(delay); + wetGain.connect(reverbOut); + delay.connect(reverbOut); + reverbOut.connect(destination); + + const attack = Math.max(0.01, note.attack); + const sustain = Math.max(0.05, note.duration); + const release = Math.max(0.05, note.release); + gain.gain.setValueAtTime(0.0001, startTime); + gain.gain.exponentialRampToValueAtTime(Math.max(0.001, dryGain.gain.value), startTime + attack); + gain.gain.setValueAtTime(Math.max(0.001, dryGain.gain.value), startTime + attack + sustain); + gain.gain.exponentialRampToValueAtTime(0.0001, startTime + attack + sustain + release); + + if (isNoise) { + source.start(startTime); + source.stop(startTime + attack + sustain + release + 0.1); + } else { + source.start(startTime); + source.stop(startTime + attack + sustain + release + 0.1); + } + + return startTime + attack + sustain + release + 0.1; + } + + mapDistanceGain(position) { + const normalized = THREE.MathUtils.clamp(position.length() / this.sphereRadius, 0, 1); + return 0.18 + normalized * 0.55; + } + + mapToPan(position) { + return THREE.MathUtils.clamp(position.x / this.sphereRadius, -1, 1); + } + + heightToMidi(height) { + const minMidi = 48; // C3 + const maxMidi = 88; // E6 + const normalized = THREE.MathUtils.clamp((height + this.sphereRadius) / (this.sphereRadius * 2), 0, 1); + return minMidi + (maxMidi - minMidi) * normalized; + } + + quantizeMidi(midi) { + const scale = this.scales[this.scalePreset] || this.scales.chromatic; + const baseOctave = Math.floor(midi / 12); + const candidates = []; + for (let octave = baseOctave - 1; octave <= baseOctave + 1; octave++) { + scale.forEach((step) => { + candidates.push(octave * 12 + step); + }); + } + let closest = candidates[0]; + let minDiff = Math.abs(candidates[0] - midi); + for (let i = 1; i < candidates.length; i++) { + const diff = Math.abs(candidates[i] - midi); + if (diff < minDiff) { + minDiff = diff; + closest = candidates[i]; + } + } + return closest; + } + + mapToFrequency(note) { + const midi = this.heightToMidi(note.position.y); + const quantized = this.quantizeMidi(midi); + return 440 * Math.pow(2, (quantized - 69) / 12); + } + + audioBufferToWav(buffer) { + const numOfChan = buffer.numberOfChannels; + const length = buffer.length * numOfChan * 2 + 44; + const bufferArray = new ArrayBuffer(length); + const view = new DataView(bufferArray); + let offset = 0; + + const writeString = (str) => { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); + } + }; + + writeString('RIFF'); offset += 4; + view.setUint32(offset, 36 + buffer.length * numOfChan * 2, true); offset += 4; + writeString('WAVE'); offset += 4; + writeString('fmt '); offset += 4; + view.setUint32(offset, 16, true); offset += 4; + view.setUint16(offset, 1, true); offset += 2; + view.setUint16(offset, numOfChan, true); offset += 2; + view.setUint32(offset, buffer.sampleRate, true); offset += 4; + view.setUint32(offset, buffer.sampleRate * numOfChan * 2, true); offset += 4; + view.setUint16(offset, numOfChan * 2, true); offset += 2; + view.setUint16(offset, 16, true); offset += 2; + writeString('data'); offset += 4; + view.setUint32(offset, buffer.length * numOfChan * 2, true); offset += 4; + + const channels = []; + for (let i = 0; i < numOfChan; i++) { + channels.push(buffer.getChannelData(i)); + } + + let sampleIndex = 0; + while (sampleIndex < buffer.length) { + for (let channel = 0; channel < numOfChan; channel++) { + const sample = Math.max(-1, Math.min(1, channels[channel][sampleIndex])); + view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true); + offset += 2; + } + sampleIndex++; + } + + return new Blob([bufferArray], { type: 'audio/wav' }); + } + + updateCameraDirection() { + const direction = new THREE.Vector3( + Math.sin(this.yaw) * Math.cos(this.pitch), + Math.sin(this.pitch), + Math.cos(this.yaw) * Math.cos(this.pitch) + ); + const target = new THREE.Vector3().copy(this.camera.position).add(direction); + this.camera.lookAt(target); + } + + updateMovement(delta) { + const direction = new THREE.Vector3(); + if (this.moveState.forward) direction.z -= 1; + if (this.moveState.back) direction.z += 1; + if (this.moveState.left) direction.x -= 1; + if (this.moveState.right) direction.x += 1; + + if (direction.lengthSq() > 0) { + direction.normalize(); + direction.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.yaw); + this.camera.position.addScaledVector(direction, this.moveSpeed * delta); + } + + if (this.moveState.up) this.camera.position.y += this.moveSpeed * delta; + if (this.moveState.down) this.camera.position.y -= this.moveSpeed * delta; + + if (this.focusTarget) { + const toTarget = new THREE.Vector3().subVectors(this.focusTarget, this.camera.position); + const distance = toTarget.length(); + if (distance > 0.05) { + toTarget.normalize(); + const step = Math.min(distance, delta * this.moveSpeed * 1.2); + this.camera.position.addScaledVector(toTarget, step); + } else { + this.focusTarget = null; + } + } + + const distance = this.camera.position.length(); + const maxDistance = this.sphereRadius * 2.4; + if (distance > maxDistance) { + this.camera.position.setLength(maxDistance); + } + + this.updateCameraDirection(); + } + + animate() { + if (!this.isActive) return; + const delta = this.clock.getDelta(); + this.updateMovement(delta); + + if (this.autoSpin) { + this.stageGroup.rotation.y += this.autoSpinSpeed * delta; + if (!this.isAdjustingRotation && this.rotationSlider) { + const normalizedDeg = THREE.MathUtils.radToDeg(this.stageGroup.rotation.y); + const wrapped = THREE.MathUtils.euclideanModulo(normalizedDeg + 180, 360) - 180; + this.rotationSlider.value = wrapped.toFixed(0); + } + } + + this.renderer.render(this.scene, this.camera); + } + + handleResize() { + if (!this.renderer || !this.camera) return; + const width = this.sceneContainer.clientWidth; + const height = this.sceneContainer.clientHeight; + this.renderer.setSize(width, height); + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + } + + onKeyDown(event) { + if (!this.isActive) return; + switch (event.code) { + case 'KeyW': + case 'ArrowUp': + this.moveState.forward = true; + break; + case 'KeyS': + case 'ArrowDown': + this.moveState.back = true; + break; + case 'KeyA': + case 'ArrowLeft': + this.moveState.left = true; + break; + case 'KeyD': + case 'ArrowRight': + this.moveState.right = true; + break; + case 'Space': + this.moveState.up = true; + event.preventDefault(); + break; + case 'ShiftLeft': + case 'ShiftRight': + this.moveState.down = true; + break; + case 'KeyQ': + this.moveState.up = true; + break; + case 'KeyE': + this.moveState.down = true; + break; + case 'Escape': + this.exit(); + break; + default: + break; + } + } + + onKeyUp(event) { + switch (event.code) { + case 'KeyW': + case 'ArrowUp': + this.moveState.forward = false; + break; + case 'KeyS': + case 'ArrowDown': + this.moveState.back = false; + break; + case 'KeyA': + case 'ArrowLeft': + this.moveState.left = false; + break; + case 'KeyD': + case 'ArrowRight': + this.moveState.right = false; + break; + case 'Space': + this.moveState.up = false; + break; + case 'ShiftLeft': + case 'ShiftRight': + this.moveState.down = false; + break; + case 'KeyQ': + this.moveState.up = false; + break; + case 'KeyE': + this.moveState.down = false; + break; + default: + break; + } + } + + setStatus(message, highlight = false) { + if (!this.statusEl) return; + this.statusEl.textContent = message; + this.statusEl.classList.toggle('status-display--active', highlight); + } +} + +new SpatialComposer(); diff --git a/script.js b/script.js index 6ea762a..5887fa3 100644 --- a/script.js +++ b/script.js @@ -1542,7 +1542,26 @@ function showSceneTrivia(scene) { // Initialize on DOM load document.addEventListener('DOMContentLoaded', () => { console.log("DOM loaded, initializing UI..."); - + + const titleContainer = document.getElementById('title-container'); + const titleDismissBtn = document.getElementById('title-dismiss'); + + const hideTitleCard = () => { + if (!titleContainer) return; + if (!titleContainer.classList.contains('minimized')) { + titleContainer.classList.add('minimized'); + } + titleContainer.setAttribute('aria-hidden', 'true'); + }; + + if (titleContainer) { + titleContainer.setAttribute('aria-hidden', 'false'); + } + + if (titleContainer && titleDismissBtn) { + titleDismissBtn.addEventListener('click', hideTitleCard); + } + // Set initial button states if (playPauseButton) { playPauseButton.dataset.playing = 'false'; @@ -1633,19 +1652,43 @@ document.addEventListener('DOMContentLoaded', () => { const triviaButton = document.getElementById('triviaButton'); const triviaContainer = document.querySelector('.trivia-container'); const closeTrivia = document.querySelector('.close-trivia'); - + if (triviaButton && triviaContainer) { triviaButton.addEventListener('click', () => { triviaContainer.style.display = 'flex'; }); } - + if (closeTrivia && triviaContainer) { closeTrivia.addEventListener('click', () => { triviaContainer.style.display = 'none'; }); } - + + if (triviaContainer) { + triviaContainer.setAttribute('role', 'dialog'); + triviaContainer.setAttribute('aria-modal', 'true'); + } + + const handleGlobalEscape = (event) => { + if (event.key !== 'Escape') return; + + if (triviaContainer && triviaContainer.style.display === 'flex') { + triviaContainer.style.display = 'none'; + return; + } + + if (document.body.classList.contains('composer-active')) { + return; + } + + if (titleContainer && !titleContainer.classList.contains('minimized')) { + hideTitleCard(); + } + }; + + document.addEventListener('keydown', handleGlobalEscape); + // Set up trivia navigation document.querySelectorAll('.trivia-nav').forEach(btn => { btn.addEventListener('click', () => { diff --git a/styles.css b/styles.css index 11fbc3c..3c1e9b0 100644 --- a/styles.css +++ b/styles.css @@ -1,401 +1,540 @@ -/* Reset styles */ +/* Reset */ * { margin: 0; padding: 0; box-sizing: border-box; } +:root { + --bg-gradient: radial-gradient(circle at 20% 20%, rgba(34, 40, 64, 0.65), rgba(4, 7, 16, 0.9)), #020308; + --hud-surface: rgba(12, 16, 28, 0.78); + --hud-surface-strong: rgba(20, 26, 42, 0.9); + --hud-border: rgba(255, 173, 90, 0.45); + --hud-border-strong: rgba(255, 200, 140, 0.75); + --accent: #ffb05a; + --accent-strong: #ff8f3f; + --text-primary: #fef5e6; + --text-muted: rgba(254, 245, 230, 0.7); + --shadow-strong: 0 22px 48px rgba(0, 0, 0, 0.55); + --shadow-soft: 0 12px 28px rgba(0, 0, 0, 0.4); + --panel-radius: 18px; + --icon-size: 42px; + font-family: 'Inter', 'Segoe UI', sans-serif; +} + body, html { height: 100%; width: 100%; overflow: hidden; - background-color: #000; - font-family: 'Inter', sans-serif; - color: #f0f0f0; + background: var(--bg-gradient); + color: var(--text-primary); +} + +body { + position: relative; + letter-spacing: 0.01em; +} + +.title-card { + backdrop-filter: blur(14px); + border-radius: 28px; + border: 1px solid rgba(0, 255, 255, 0.35); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55); +} + +.global-nav { + position: fixed; + top: 24px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 16px; + z-index: 120; + padding: 10px 18px; + border-radius: 999px; + background: rgba(8, 10, 18, 0.68); + border: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(16px); + box-shadow: var(--shadow-soft); +} + +.nav-btn { + padding: 10px 22px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: transparent; + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.25s ease; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.nav-btn--primary { + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%); + color: #0f0f16; + border-color: transparent; + box-shadow: 0 12px 28px rgba(255, 146, 51, 0.45); +} + +.nav-btn:hover { + transform: translateY(-2px); + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.3); +} + +body.composer-active #title-container { + opacity: 0; + pointer-events: none; +} + +body.composer-active .global-nav { + opacity: 1; + pointer-events: auto; +} + +body.composer-active #enterComposerSpace { + display: none; +} + +body:not(.composer-active) #exitComposerSpace { + display: none; } -/* Immersive container - takes the full screen */ #immersive-container { position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: #000; + inset: 0; overflow: hidden; + background: transparent; } #scene-container { position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - width: 100%; - height: 100%; + inset: 0; } -/* Mode Selector */ -.mode-selector { +#joystick-container { + position: absolute; + bottom: 40px; + right: 40px; + width: 130px; + height: 130px; + background: rgba(12, 16, 28, 0.72); + border-radius: 50%; + border: 1px solid var(--hud-border); + box-shadow: var(--shadow-soft); + z-index: 90; +} + +.ui-overlay { position: absolute; - top: 30px; - left: 30px; - background-color: rgba(0, 0, 0, 0.7); - border-radius: 10px; - padding: 10px; - z-index: 110; - border: 1px solid #e69138; - box-shadow: 0 0 15px rgba(230, 145, 56, 0.5); + inset: 0; + padding: 30px 40px; display: flex; flex-direction: column; - gap: 8px; + justify-content: space-between; + pointer-events: none; + z-index: 80; } -.mode-label { - font-size: 14px; - color: #e69138; - margin-bottom: 5px; - text-align: center; +.ui-top, +.ui-bottom { + display: flex; + justify-content: space-between; + gap: 24px; + pointer-events: none; } -.mode-options { +.ui-bottom { + align-items: flex-end; + flex-wrap: wrap; +} + +.hud-stack { display: flex; - gap: 5px; + flex-direction: column; + gap: 18px; + min-width: 210px; + pointer-events: none; } -.mode-btn { - padding: 5px 10px; - background-color: rgba(50, 50, 50, 0.7); - border: 1px solid #777; - border-radius: 5px; - color: #ddd; - cursor: pointer; - font-size: 12px; - /* transition: all 0.2s; */ +.hud-stack--wide { + flex: 1 1 520px; + min-width: min(520px, 70vw); } -.mode-btn.active { - background-color: #e69138; - color: #000; - border-color: #e69138; - box-shadow: 0 0 10px rgba(230, 145, 56, 0.8); +.hud-stack--compact { + flex: 0 0 auto; + width: min(260px, 28vw); } -/* Mix Mode Toggle */ -.mix-mode-toggle { - position: absolute; - top: 85px; - left: 30px; - background-color: rgba(0, 0, 0, 0.7); - border-radius: 10px; - padding: 10px; - z-index: 110; - border: 1px solid #e69138; - display: flex; +.hud-stack > .hud-button { + width: 100%; + justify-content: center; + display: inline-flex; align-items: center; - gap: 10px; - box-shadow: 0 0 15px rgba(230, 145, 56, 0.5); } -.switch { - position: relative; - display: inline-block; - width: 45px; - height: 24px; +.hud-stack--right { + align-items: flex-end; } -.switch input { - opacity: 0; - width: 0; - height: 0; +.hud-card { + background: var(--hud-surface); + border-radius: var(--panel-radius); + border: 1px solid var(--hud-border); + box-shadow: var(--shadow-soft); + padding: 18px 20px; + backdrop-filter: blur(18px); + pointer-events: auto; } -.slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #555; - transition: .4s; +.engineer-console { + display: flex; + flex-direction: column; + gap: 18px; } -.slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 3px; - background-color: white; - transition: .4s; +.engineer-console__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 18px; } -input:checked + .slider { - background-color: #e69138; +.engineer-console__header h3 { + margin: 0; + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.16em; } -input:checked + .slider:before { - transform: translateX(21px); +.engineer-console__header p { + margin: 6px 0 0; + font-size: 0.78rem; + color: var(--text-muted); + max-width: 320px; + line-height: 1.4; } -.slider.round { - border-radius: 24px; +.engineer-console__toggles { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; } -.slider.round:before { - border-radius: 50%; +.engineer-console__body { + display: flex; + flex-direction: column; + gap: 16px; } -/* Scene Selector */ -.scene-selector { - position: absolute; - top: 180px; - left: 30px; - background-color: rgba(0, 0, 0, 0.7); - border-radius: 10px; - padding: 10px; - z-index: 110; - border: 1px solid #e69138; - box-shadow: 0 0 15px rgba(230, 145, 56, 0.5); +.visualizer-panel { display: flex; flex-direction: column; - gap: 8px; + gap: 16px; } -.scene-btn { - padding: 8px 12px; - background-color: rgba(50, 50, 50, 0.7); - border: 1px solid #777; - border-radius: 5px; - color: #ddd; - cursor: pointer; - font-size: 13px; - transition: all 0.2s; - text-align: left; +.waveform-wrapper { + position: relative; + width: 100%; + height: 160px; + background: rgba(14, 18, 32, 0.85); + border: 1px solid var(--hud-border); + border-radius: 18px; + overflow: hidden; } -.scene-btn.active { - background-color: #e69138; - color: #000; - border-color: #e69138; - box-shadow: 0 0 10px rgba(230, 145, 56, 0.8); +#waveform-canvas { + width: 100%; + height: 100%; + display: block; } -/* Joystick styles */ -#joystick-container { +.playhead { position: absolute; - bottom: 290px; - right: 100px; - width: 120px; - height: 120px; - background-color: rgba(255, 255, 255, 0.1); - border-radius: 50%; - z-index: 100; - border: 2px solid rgba(230, 145, 56, 0.7); - box-shadow: 0 0 20px rgba(230, 145, 56, 0.3); + top: 0; + bottom: 0; + width: 3px; + background: linear-gradient(180deg, var(--accent-strong), var(--accent)); + box-shadow: 0 0 14px rgba(255, 143, 63, 0.6); + pointer-events: none; } -/* Rotation indicator */ -.rotation-indicator { +.phrase-markers { position: absolute; - top: 30px; - right: 30px; - background-color: rgba(0, 0, 0, 0.7); - padding: 10px 15px; - border-radius: 30px; - color: #e69138; - font-weight: bold; - display: flex; - flex-direction: column; - align-items: center; - z-index: 100; - border: 1px solid #e69138; - box-shadow: 0 0 10px rgba(230, 145, 56, 0.4); + inset: 0; + pointer-events: none; +} + +.phrase-marker { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: rgba(255, 255, 255, 0.25); } -.indicator-label { - font-size: 14px; - margin-bottom: 4px; +.phrase-marker.phrase-start { + background: rgba(255, 191, 105, 0.7); } -.indicator-value { - font-size: 20px; +.phrase-marker.phrase-end { + background: rgba(120, 189, 255, 0.7); } -/* Audio controls */ -.audio-controls { +.phrase-marker-active { + box-shadow: 0 0 10px rgba(255, 191, 105, 0.65); +} + +.speaker-lanes { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; +} + +.speaker-lane { + position: relative; + min-height: 70px; + background: rgba(12, 16, 28, 0.68); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + overflow: hidden; + padding: 10px 12px 12px; +} + +.speaker-number { + position: relative; + z-index: 2; + font-size: 0.78rem; + letter-spacing: 0.1em; + color: var(--text-muted); +} + +.speaker-activity { position: absolute; - bottom: 150px; - left: 30px; + top: 24px; + bottom: 10px; + border-radius: 2px; +} + +.mode-selector, +.performer-dropdown, +.scene-selector { display: flex; - gap: 20px; - z-index: 100; + flex-direction: column; + gap: 12px; } -#playPauseButton, #resetButton { - width: 60px; - height: 60px; - border-radius: 50%; - border: none; - cursor: pointer; - background-color: rgba(0, 0, 0, 0.6); - color: #e69138; - font-size: 24px; +.mode-label, +.scene-label, +.performer-dropdown label, +.control-label { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--text-muted); +} + +.mode-options { display: flex; - justify-content: center; - align-items: center; - transition: all 0.3s ease; - border: 2px solid #e69138; - box-shadow: 0 0 15px rgba(230, 145, 56, 0.3); + gap: 10px; } -/* Replace the current #toggleArabicBtn style with this */ -#toggleArabicBtn { - position: absolute; - top: 180px; - left: 180px; - width: 165px; - height: 40px; - background-color: rgba(51, 51, 51, 0.7); - color: white; - border: 1px solid #e69138; - border-radius: 8px; +.mode-btn, +.scene-btn, +.trivia-nav { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 14px; + border-radius: 12px; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.04); + color: var(--text-muted); + font-size: 0.88rem; + font-weight: 600; cursor: pointer; - z-index: 9999; /* Increased to ensure always on top */ - font-size: 14px; - transition: background-color 0.3s ease; - padding: 8px 12px; - box-shadow: 0 0 10px rgba(230, 145, 56, 0.2); + transition: all 0.2s ease; } -#toggleArabicBtn:hover { - background-color: rgba(80, 80, 80, 0.9); +.scene-selector { + gap: 10px; } -#toggleArabicBtn.active { - background-color: #e69138; - color: #000; - box-shadow: 0 0 15px rgba(230, 145, 56, 0.6); +.scene-selector .scene-btn { + justify-content: flex-start; } -#playPauseButton:hover, #resetButton:hover { - transform: scale(1.1); - box-shadow: 0 0 20px rgba(230, 145, 56, 0.6); - background-color: rgba(30, 30, 30, 0.7); +.mode-btn:hover, +.scene-btn:hover, +.mode-btn.active, +.scene-btn.active, +.trivia-nav:hover, +.trivia-nav.active { + color: #0c0d12; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%); + border-color: transparent; + box-shadow: 0 10px 24px rgba(255, 146, 51, 0.32); } -#playPauseButton::before { - content: var(--play-pause-icon, "\25B6"); /* Play triangle */ +.performer-dropdown select { + width: 100%; + padding: 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(5, 7, 16, 0.7); + color: var(--text-primary); + font-size: 0.95rem; + transition: border 0.2s ease; } -#resetButton::before { - content: "\21BA"; /* Curved arrow */ +.performer-dropdown select:focus { + outline: none; + border-color: var(--accent); } -/* Add tooltips for the buttons */ -#playPauseButton, #resetButton { - position: relative; +.hud-button { + padding: 12px 18px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(12, 16, 28, 0.72); + color: var(--text-muted); + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + pointer-events: auto; } -#playPauseButton::after, #resetButton::after { - position: absolute; - bottom: -30px; - left: 50%; - transform: translateX(-50%); - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 5px 10px; - border-radius: 4px; - font-size: 12px; - white-space: nowrap; - opacity: 0; - transition: opacity 0.3s; - pointer-events: none; +.hud-button.active { + border-color: var(--accent); + color: var(--accent); + box-shadow: 0 8px 18px rgba(255, 146, 51, 0.3); } -#playPauseButton::after { - content: attr(title); /* Use the title attribute */ +.hud-button--primary { + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%); + color: #101116; + border: none; + box-shadow: 0 12px 26px rgba(255, 146, 51, 0.38); } -#resetButton::after { - content: attr(title); /* Use the title attribute */ +.hud-button--icon { + width: var(--icon-size); + height: var(--icon-size); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + padding: 0; } -#playPauseButton:hover::after, #resetButton:hover::after { - opacity: 1; +.hud-button:hover { + transform: translateY(-2px); + border-color: rgba(255, 255, 255, 0.24); + color: var(--text-primary); } -/* Sound wave visualization */ -.sound-wave { - position: absolute; - border-radius: 50%; - opacity: 0.7; - background: radial-gradient(circle, rgba(230, 145, 56, 0.5) 0%, rgba(230, 145, 56, 0) 70%); - pointer-events: none; - z-index: 10; +.hud-button--primary:hover { + transform: translateY(-3px); } -/* 2D Top-down visualization */ .topdown-view { - position: absolute; - top: 50px; - right: 30px; - width: 200px; - height: 200px; - background-color: rgba(0, 0, 0, 0.7); - border-radius: 10px; - padding: 10px; - z-index: 100; - border: 1px solid #e69138; - box-shadow: 0 0 20px rgba(230, 145, 56, 0.6); + width: 220px; display: flex; flex-direction: column; + gap: 12px; + padding: 18px; } .view-label { - font-size: 13px; - color: #e69138; - margin-bottom: 5px; - text-align: center; - text-shadow: 0 0 5px rgba(230, 145, 56, 0.8); + font-size: 0.85rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted); } #topdown-canvas { - flex: 1; width: 100%; - height: calc(100% - 17px); - background-color: rgba(0, 0, 0, 0.7); - border-radius: 5px; + height: 180px; + border-radius: 14px; + background: rgba(4, 6, 12, 0.8); + border: 1px solid rgba(255, 255, 255, 0.05); } -/* Trivia Button and Container */ -#triviaButton { - position: absolute; - top: 30px; - left: 264px; - width: 40px; - height: 40px; +.audio-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-width: 320px; +} + +.control-actions { + display: flex; + gap: 14px; +} + +#playPauseButton, +#resetButton { + width: 58px; + height: 58px; border-radius: 50%; - border: 2px solid #e69138; - background-color: rgba(0, 0, 0, 0.7); - color: #e69138; - font-size: 20px; - font-weight: bold; + border: 2px solid var(--hud-border); + background: rgba(10, 14, 24, 0.8); + color: var(--accent); + font-size: 22px; display: flex; - justify-content: center; align-items: center; + justify-content: center; cursor: pointer; - z-index: 110; - box-shadow: 0 0 15px rgba(230, 145, 56, 0.5); - font-style: italic; - transition: all 0.2s; + transition: all 0.3s ease; } -#triviaButton:hover { - transform: scale(1.1); - box-shadow: 0 0 20px rgba(230, 145, 56, 0.8); +#playPauseButton:hover, +#resetButton:hover { + transform: scale(1.05); + box-shadow: 0 12px 26px rgba(255, 146, 51, 0.32); +} + +#playPauseButton::before { + content: var(--play-pause-icon, "\25B6"); +} + +#resetButton::before { + content: "\21BA"; +} + +#dryWetControl { + max-width: 320px; + gap: 14px; +} + +.slider-container { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dry-wet-slider { + width: 100%; +} + +.slider-labels { + display: flex; + justify-content: space-between; + font-size: 0.8rem; + color: var(--text-muted); +} + +.control-value { + font-weight: 600; + font-size: 1rem; + color: var(--accent); } .trivia-container { @@ -403,701 +542,698 @@ input:checked + .slider:before { top: 50%; left: 50%; transform: translate(-50%, -50%); - width: 80%; - max-width: 700px; - height: 80%; - max-height: 600px; - background-color: rgba(0, 0, 0, 0.9); - border: 2px solid #e69138; - border-radius: 15px; - z-index: 1000; - box-shadow: 0 0 30px rgba(230, 145, 56, 0.7); + width: min(720px, 90vw); + height: min(620px, 90vh); + background: var(--hud-surface-strong); + border-radius: 24px; + border: 1px solid var(--hud-border-strong); + box-shadow: var(--shadow-strong); display: flex; flex-direction: column; overflow: hidden; + z-index: 200; } -.trivia-header { +.trivia-header, +.trivia-navigation { display: flex; - justify-content: space-between; align-items: center; - padding: 15px 20px; - background-color: rgba(230, 145, 56, 0.2); - border-bottom: 1px solid #e69138; + justify-content: space-between; + gap: 12px; + padding: 20px 24px; + background: rgba(255, 255, 255, 0.04); } .trivia-header h2 { - color: #e69138; - font-size: 24px; + color: var(--accent); + font-size: 1.4rem; + letter-spacing: 0.08em; + text-transform: uppercase; } .close-trivia { background: none; border: none; - color: #e69138; - font-size: 28px; + color: var(--accent); + font-size: 2rem; cursor: pointer; - padding: 0 10px; - transition: all 0.2s; + transition: transform 0.2s ease; } .close-trivia:hover { - transform: scale(1.2); - color: #ff9d45; + transform: scale(1.1); } .trivia-content { flex: 1; + padding: 24px; overflow-y: auto; - padding: 20px; line-height: 1.6; + color: var(--text-primary); } .trivia-content h3 { - color: #e69138; - margin-bottom: 15px; -} - -.trivia-content p { - margin-bottom: 15px; + color: var(--accent); + margin-bottom: 12px; } .trivia-content img { max-width: 100%; - border-radius: 8px; - margin: 15px 0; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.6); + border-radius: 16px; + margin: 18px 0; + box-shadow: var(--shadow-soft); } .trivia-navigation { - display: flex; justify-content: center; - gap: 10px; - padding: 15px; - background-color: rgba(230, 145, 56, 0.1); - border-top: 1px solid #e69138; + gap: 16px; } -.trivia-nav { - padding: 8px 15px; - background-color: rgba(50, 50, 50, 0.7); - border: 1px solid #777; - border-radius: 5px; - color: #e69138; - cursor: pointer; - transition: all 0.2s; +#arabic-visualization-container { + position: absolute; + inset: auto 40px 40px auto; + width: clamp(280px, 28vw, 420px); + background: var(--hud-surface); + border-radius: var(--panel-radius); + border: 1px solid var(--hud-border); + box-shadow: var(--shadow-soft); + padding: 18px; + display: flex; + flex-direction: column; + gap: 12px; + z-index: 95; +} + +#arabic-visualization-image { + width: 100%; + border-radius: 12px; } -.trivia-nav:hover, .trivia-nav.active { - background-color: #e69138; - color: #000; - border-color: #e69138; +#arabic-playhead { + height: 4px; + background: linear-gradient(90deg, var(--accent), var(--accent-strong)); + border-radius: 999px; + width: 0; } -/* Contour View Toggle */ -.toggle-contour-view { +#volume-display-panel { position: absolute; - bottom: 210px; + top: 50%; left: 50%; - transform: translateX(-50%); + transform: translate(-50%, -50%); + background: var(--hud-surface-strong); + border-radius: 24px; + border: 1px solid var(--hud-border-strong); + padding: 26px; + width: clamp(320px, 60vw, 680px); + box-shadow: var(--shadow-strong); display: flex; - background-color: rgba(0, 0, 0, 0.7); - border-radius: 8px; - border: 1px solid #e69138; - overflow: hidden; - z-index: 110; - box-shadow: 0 0 15px rgba(230, 145, 56, 0.5); + flex-direction: column; + gap: 20px; + z-index: 210; } -.toggle-contour-view button { - padding: 8px 15px; - background-color: transparent; - border: none; - color: #e69138; - cursor: pointer; - font-size: 14px; - transition: all 0.2s; +.volume-panel-header { + display: flex; + align-items: center; + justify-content: space-between; } -.toggle-contour-view button.active { - background-color: #e69138; - color: #000; +.volume-panel-header h3 { + font-size: 1.2rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); } -/* Music Visualization Container */ -.music-visualization-container { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 200px; - background-color: rgba(0, 0, 0, 0.7); - border-top: 1px solid #e69138; - box-shadow: 0 0 20px rgba(0, 0, 0, 0.8); - z-index: 100; +.close-panel { + background: none; + border: none; + color: var(--accent); + font-size: 1.6rem; + cursor: pointer; } -/* Arabic Visualization Container */ -#arabic-visualization-container { - position: absolute; - bottom: 50px; /* Changed from 0 to 50px */ - left: 50%; /* Center horizontally */ - transform: translateX(-50%); /* Adjust for centering */ - width: 90%; /* Adjust width as needed, or use max-width */ - max-width: 1200px; /* Example max-width, adjust as needed */ - height: 150px; /* Adjusted height from 100px to 200px */ - background-color: rgba(0, 0, 0, 0.3); /* Optional background */ - border: 2px solid #e69138; /* Orange outline */ - border-radius: 20px; /* Corner radius */ - overflow: hidden; /* Hide overflow and constrain playhead visually */ - z-index: 105; /* Ensure it's above other bottom elements if necessary */ +.volume-bars-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + gap: 18px; + justify-items: center; } -#arabic-visualization-image { - /* display: block; */ - display:flex; - width: 100%; - height: 100%; - object-fit: fill; /* Changed from 'contain' to 'fill' to stretch vertically */ +.volume-bar-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; } -#arabic-playhead { +.volume-bar-wrapper { + width: 38px; + height: 150px; + background: rgba(4, 6, 12, 0.85); + border-radius: 20px; + overflow: hidden; + position: relative; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.volume-bar { position: absolute; - top: 0; - left: 0; /* Initial position, JS will update based on percentage */ - width: 3px; /* Width of the vertical bar */ - height: 100%; - background-color: rgba(255, 255, 255, 0.7); /* Translucent white */ - box-shadow: 0 0 5px rgba(255, 255, 255, 0.5); - pointer-events: none; /* Prevent interaction */ - z-index: 106; /* Above the image */ + inset: 0; + background: rgba(20, 26, 42, 0.6); } -.contour-display, .notation-display { +.volume-fill { + position: absolute; + bottom: 0; width: 100%; - height: 100%; - display: none; + background: linear-gradient(180deg, var(--accent), var(--accent-strong)); + height: 50%; + transition: height 0.3s ease; } -.contour-display.active, .notation-display.active { - display: block; +.volume-bar-group[data-active="true"] .volume-fill { + box-shadow: 0 0 20px rgba(255, 146, 51, 0.6); } -#contour-canvas { - width: 100%; - height: 100%; +.volume-percentage { + font-size: 0.85rem; + color: var(--text-primary); } -.notation-scroll { - width: 100%; - height: 100%; - display: flex; - overflow-x: auto; - overflow-y: hidden; - padding: 10px 0; - align-items: center; +.speaker-label { + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); } -.notation-image { - height: 180px; - margin: 0 10px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.7); +.sound-wave { + position: absolute; + border-radius: 50%; + opacity: 0.7; + background: radial-gradient(circle, rgba(255, 176, 90, 0.5) 0%, rgba(255, 176, 90, 0) 70%); + pointer-events: none; + z-index: 10; } -/* Volume Display Panel Styles */ -#volume-display-panel { +#score-panel { position: absolute; - top: 50%; /* Center vertically */ - left: 50%; /* Center horizontally */ - transform: translate(-50%, -50%); /* Adjust for exact centering */ - width: 500px; /* Adjusted width for 6 horizontal bars */ - height: 300px; - background: rgba(0, 0, 0, 0.85); - color: #fff; - border: 2px solid #e69138; - border-radius: 12px; - padding: 15px; - z-index: 120; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); - /* display: none; is handled by JS */ + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--hud-surface-strong); + border-radius: 24px; + border: 1px solid var(--hud-border-strong); + box-shadow: var(--shadow-strong); + width: min(780px, 92vw); + height: min(640px, 90vh); + display: flex; + flex-direction: column; + z-index: 205; } -.volume-panel-header { +.score-panel-header { display: flex; - justify-content: space-between; align-items: center; - margin-bottom: 15px; - border-bottom: 1px solid #333; - padding-bottom: 8px; + justify-content: space-between; + padding: 24px; + background: rgba(255, 255, 255, 0.04); } -.volume-panel-header h3 { - margin: 0; - font-size: 18px; - color: #e69138; - font-weight: bold; +.score-panel-header h3 { + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--accent); } -.close-panel { - background: none; - border: none; - color: #e69138; - font-size: 24px; - cursor: pointer; - transition: all 0.2s ease; +.score-content { + flex: 1; + padding: 24px; + overflow: auto; } -.close-panel:hover { - transform: rotate(90deg); +.score-image-container { + width: 100%; + height: 100%; + border-radius: 18px; + overflow: auto; + background: rgba(4, 6, 12, 0.8); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 12px; } -.performer-dropdown { - position: absolute; - top: 120px; - left: 30px; - background-color: rgba(0, 0, 0, 0.7); - border: 1px solid #e69138; - border-radius: 8px; - padding: 10px; - z-index: 110; - color: #f0f0f0; - display: block; /* Ensure it's visible */ +.score-image-container img { + width: auto; + max-width: 100%; + height: auto; } -.volume-bars-container { - display: flex; /* Use flexbox for horizontal layout */ - flex-direction: row; /* Arrange items in a row */ - justify-content: space-around; /* Distribute space around items */ - align-items: flex-start; /* Align items to the top */ - gap: 10px; /* Space between speaker groups */ +/* Composer Space */ +#composer-space { + position: fixed; + inset: 0; + display: none; + background: radial-gradient(circle at 30% 20%, rgba(18, 24, 54, 0.85), rgba(5, 8, 18, 0.95)); + color: var(--text-primary); + overflow: hidden; + z-index: 0; } +body.composer-active #immersive-container { + display: none; +} +body.composer-active #composer-space { + display: block; +} -.volume-bar-group { +#composer-scene { + position: absolute; + inset: 0; +} + +.composer-overlay { + position: relative; + z-index: 10; + height: 100%; display: flex; flex-direction: column; - align-items: center; - gap: 8px; + justify-content: flex-end; + padding: 40px 60px; + pointer-events: none; } -.volume-bar-wrapper { - width: 30px; /* Width of the vertical bar container */ - height: 120px; - position: relative; +.composer-status-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + background: rgba(12, 16, 28, 0.78); + padding: 20px 28px; + border-radius: var(--panel-radius); + border: 1px solid var(--hud-border); + box-shadow: var(--shadow-soft); + pointer-events: auto; +} + +.composer-status-bar h2 { + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--accent); + font-size: 1.2rem; + margin-bottom: 8px; } -.volume-bar { - width: 100%; - height: 100%; - background: #2a2a2a; - border-radius: 15px; - overflow: hidden; - position: relative; - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3); +.composer-status-bar p { + color: var(--text-muted); + font-size: 0.95rem; } -.volume-fill { - background: linear-gradient(to top, #e69138, #ffb84d); - width: 100%; - height: 50%; /* Default starting at 50% */ - position: absolute; - bottom: 0; - border-radius: 0 0 15px 15px; - transition: height 0.3s ease; +.composer-hint { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--text-muted); } -.volume-bar-group[data-active="true"] .volume-fill { - background: linear-gradient(to top, #ff6b35, #ff8c42); - box-shadow: 0 0 15px rgba(255, 107, 53, 0.5); - animation: pulse 1s ease-in-out infinite alternate; +.composer-panel { + margin-top: 28px; + background: rgba(10, 12, 24, 0.82); + border-radius: 28px; + border: 1px solid var(--hud-border-strong); + box-shadow: 0 30px 60px rgba(0, 0, 0, 0.55); + padding: 32px 36px 40px; + pointer-events: auto; } -@keyframes pulse { - from { box-shadow: 0 0 10px rgba(255, 107, 53, 0.5); } - to { box-shadow: 0 0 20px rgba(255, 107, 53, 0.8); } +.deck-header { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.28em; + color: var(--text-muted); + text-align: center; + margin-bottom: 26px; } -.volume-percentage { - position: absolute; - bottom: -25px; - left: 50%; - transform: translateX(-50%); - font-size: 12px; - color: #e69138; - font-weight: bold; +.deck-grid { + display: grid; + grid-template-columns: repeat(4, minmax(220px, 1fr)); + gap: 28px; } -.speaker-label { - font-size: 13px; - color: #e69138; - font-weight: bold; +.deck-column { + background: rgba(20, 24, 40, 0.82); + border-radius: 22px; + border: 1px solid rgba(255, 255, 255, 0.06); + padding: 22px 24px 26px; + display: flex; + flex-direction: column; + gap: 18px; } -/* Volume Display Toggle Button */ -#volumeDisplayBtn { - position: absolute; - top: 240px; - left: 180px; - padding: 8px 12px; - background: rgba(0, 0, 0, 0.7); - color: #e69138; - border: 1px solid #e69138; - border-radius: 8px; - cursor: pointer; - font-size: 14px; - z-index: 110; - transition: all 0.2s ease; +.deck-column h3 { + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--accent); + font-size: 0.95rem; } -#volumeDisplayBtn:hover { - background: rgba(230, 145, 56, 0.2); +.coordinate-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + align-items: center; } -#volumeDisplayBtn.active { - background: #e69138; - color: #000; +.coordinate-grid label { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-muted); } -/* Score Display Toggle Button */ -#scoreDisplayBtn { - position: absolute; - top: 390px; - left: 30px; - /* right: 252px; */ - padding: 8px 12px; - background: rgba(0, 0, 0, 0.7); - color: #e69138; - border: 1px solid #e69138; - border-radius: 8px; - cursor: pointer; - font-size: 14px; - z-index: 110; - transition: all 0.2s ease; +.coordinate-grid input { + width: 100%; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(4, 6, 12, 0.8); + color: var(--text-primary); } -#scoreDisplayBtn:hover { - background: rgba(230, 145, 56, 0.2); +.deck-column--coordinates input[type="text"] { + width: 100%; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(4, 6, 12, 0.8); + color: var(--text-primary); } -#scoreDisplayBtn.active { - background: #e69138; - color: #000; +.coordinate-actions, +.mirror-bank, +.utility-bank { + display: flex; + flex-wrap: wrap; + gap: 10px; } -/* Mobile optimization for volume panel */ -@media (max-width: 768px) { - #volume-display-panel { - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 90%; /* Use percentage for better responsiveness */ - max-width: 320px; /* Max width on small screens */ - padding: 10px; - } - - .volume-bars-container { - display: flex; - flex-direction: row; - flex-wrap: wrap; /* Allow bars to wrap to the next line */ - justify-content: space-around; - gap: 8px; /* Adjust gap for mobile */ - } - - .volume-bar-group { - /* Adjust basis for wrapping, e.g., fit 3 per row */ - flex-basis: calc(33.33% - 10px); /* Adjust based on gap and desired items per row */ - min-width: 60px; /* Ensure labels and bars are not too squished */ - } +.mirror-bank { + justify-content: flex-start; +} - .volume-bar-wrapper { - height: 100px; /* Slightly shorter bars on mobile */ - width: 25px; /* Slightly thinner bars on mobile */ - } - - .speaker-label { - font-size: 11px; /* Smaller font for labels */ - } +.control-pair { + display: flex; + flex-direction: column; + gap: 6px; +} - .volume-percentage { - font-size: 10px; /* Smaller font for percentage */ - bottom: -20px; /* Adjust position */ - } +.control-pair label { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-muted); +} - #volumeDisplayBtn { - top: auto; /* Remove fixed top */ - bottom: 20px; /* Position near bottom controls */ - right: 20px; - font-size: 12px; - padding: 6px 10px; - } +.control-pair select, +.control-pair input { + width: 100%; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(4, 6, 12, 0.75); + color: var(--text-primary); } -/* Toggle button for minimized 3D view in Engineer Mode */ -.minimized-view-toggle-btn { - position: absolute; - top: 120px; - left: 30px; - padding: 10px 16px; - background-color: rgba(0, 0, 0, 0.7); - color: #e69138; - border: 1px solid #e69138; - border-radius: 10px; - cursor: pointer; - font-size: 16px; - z-index: 200; - transition: all 0.2s ease; +.fader-group { + display: grid; + grid-template-columns: repeat(2, minmax(160px, 1fr)); + gap: 14px; } -.minimized-view-toggle-btn:hover { - background-color: #e69138; - color: #000; +.fader { + display: flex; + flex-direction: column; + gap: 6px; + background: rgba(6, 8, 18, 0.7); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 14px; + padding: 10px 12px; } -/* Class to hide the minimized scene container */ -.minimized-view-hidden { - opacity: 0 !important; - visibility: hidden !important; - transition: opacity 0.2s ease, visibility 0.2s ease; +.fader label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--text-muted); } -/* Engineer mode: Make 3D view smaller */ -#scene-container.minimized { - position: absolute; - top: 150px; - left: 200px; - /* Adjusted to avoid overlap with score panel (400px + 20px margin + 20px gap = 440px from right) */ - right: 460px; - width: 200px; - height: 200px; - border: 2px solid #e69138; - border-radius: 5px; - z-index: 150; /* Higher z-index to appear above other elements */ - overflow: hidden; /* Add this line to clip the content */ - transition: opacity 0.2s ease, visibility 0.2s ease; /* Smooth transitions for visibility changes */ -} - -/* Engineer mode: Make 2D topdown view expanded */ -.topdown-view.expanded { - width: calc(100% - 40px); /* Adjust for padding */ - height: calc(100% - 40px); /* Adjust for padding */ - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - right: auto; /* Override default right positioning */ +.fader input, +.fader select { + width: 100%; } -/* Dry/Wet Control for Strophe V */ -#dryWetControl { - position: absolute; - top: 240px; - left: 180px; - background: rgba(0, 0, 0, 0.8); - border: 1px solid #e69138; - border-radius: 10px; - padding: 15px; - z-index: 110; - min-width: 200px; - box-shadow: 0 0 15px rgba(230, 145, 56, 0.3); - transition: all 0.3s ease; +.fader output { + align-self: flex-end; + font-size: 0.78rem; + color: var(--accent); } -.control-label { - color: #e69138; - font-size: 14px; - font-weight: bold; - margin-bottom: 10px; - text-align: center; +.movement-bank { + display: grid; + gap: 10px; + background: rgba(6, 8, 18, 0.7); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 16px; + padding: 12px 14px; } -.slider-container { - position: relative; - margin-bottom: 8px; +.movement-bank label { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-muted); } -.dry-wet-slider { +.movement-bank input[type="range"] { width: 100%; - height: 6px; - background: linear-gradient(to right, #4a4a4a 0%, #e69138 100%); - outline: none; - border-radius: 3px; - -webkit-appearance: none; - appearance: none; - cursor: pointer; - transition: background 0.3s ease; } -.dry-wet-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 20px; - height: 20px; - background: #e69138; - border-radius: 50%; - cursor: pointer; - box-shadow: 0 0 10px rgba(230, 145, 56, 0.5); - transition: all 0.2s ease; +.movement-bank output { + justify-self: flex-end; + font-size: 0.78rem; + color: var(--accent); } -.dry-wet-slider::-webkit-slider-thumb:hover { - background: #ff9500; - box-shadow: 0 0 15px rgba(255, 149, 0, 0.8); - transform: scale(1.1); +.transport-bank { + display: flex; + flex-wrap: wrap; + gap: 10px; } -.dry-wet-slider::-moz-range-thumb { - width: 20px; - height: 20px; - background: #e69138; - border-radius: 50%; - cursor: pointer; - border: none; - box-shadow: 0 0 10px rgba(230, 145, 56, 0.5); - transition: all 0.2s ease; +.snapshot-bank { + display: grid; + gap: 10px; + background: rgba(6, 8, 18, 0.7); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 16px; + padding: 14px; } -.dry-wet-slider::-moz-range-thumb:hover { - background: #ff9500; - box-shadow: 0 0 15px rgba(255, 149, 0, 0.8); - transform: scale(1.1); +.snapshot-bank label { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-muted); } -.slider-labels { - display: flex; - justify-content: space-between; - font-size: 12px; - color: #ccc; - margin-top: 5px; +.snapshot-bank select { + width: 100%; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(4, 6, 12, 0.75); + color: var(--text-primary); } -.control-value { - text-align: center; - color: #e69138; - font-size: 13px; - font-weight: bold; -} - -/* Mobile optimization for dry/wet control */ -@media (max-width: 768px) { - #dryWetControl { - top: 150px; - right: 10px; - min-width: 150px; - padding: 10px; - } - - .control-label { - font-size: 12px; - } - - .slider-labels { - font-size: 10px; - } - - .control-value { - font-size: 11px; - } +.snapshot-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; } -/* Score Panel Styles for Audio Engineer */ -#score-panel { - position: fixed; - top: 20px; - right: 20px; - width: 400px; - height: calc(100vh - 40px); - background: rgba(0, 0, 0, 0.9); - color: #fff; - border: 2px solid #e69138; - border-radius: 12px; - padding: 15px; - z-index: 110; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); - backdrop-filter: blur(10px); - transform: translateX(100%); - opacity: 0; - transition: transform 0.3s ease, opacity 0.3s ease; - /* display: none; is handled by JS */ +.note-legend { + font-size: 0.78rem; + color: var(--text-muted); + background: rgba(6, 8, 18, 0.65); + border-radius: 14px; + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, 0.05); } -#score-panel.visible { - transform: translateX(0); - opacity: 1; +.transport-bank { + display: flex; + flex-direction: column; + gap: 12px; } -.score-panel-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; - border-bottom: 1px solid #333; - padding-bottom: 8px; +.status-display { + min-height: 64px; + padding: 14px 16px; + border-radius: 14px; + background: rgba(4, 6, 12, 0.72); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 0.9rem; + color: var(--text-muted); } -.score-panel-header h3 { - margin: 0; - font-size: 18px; - color: #e69138; - font-weight: bold; +.status-display--active { + color: var(--accent); + border-color: rgba(255, 176, 90, 0.45); + background: rgba(20, 26, 42, 0.8); } -.score-content { - height: calc(100% - 50px); +.note-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 12px; + max-height: 220px; overflow-y: auto; - overflow-x: hidden; + padding-right: 6px; } -.score-image-container { +.note-list li { + display: grid; + gap: 10px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(12, 16, 28, 0.85); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 0.85rem; +} + +.note-meta { display: flex; - justify-content: center; - align-items: flex-start; - min-height: 100%; + flex-direction: column; + gap: 4px; } -#score-image { - max-width: 100%; - height: auto; - border-radius: 8px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); - transition: transform 0.3s ease; +.note-meta strong { + color: var(--text-primary); + font-size: 0.88rem; + letter-spacing: 0.08em; +} + +.note-meta span { + color: var(--text-muted); + font-size: 0.78rem; } -#score-image:hover { - transform: scale(1.02); +.note-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.note-actions button { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(6, 8, 18, 0.7); + color: var(--accent); + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.08em; + border-radius: 999px; + padding: 6px 12px; + font-size: 0.7rem; + transition: all 0.2s ease; } -/* Custom scrollbar for score panel */ -.score-content::-webkit-scrollbar { - width: 8px; +.note-actions button:hover { + background: rgba(255, 176, 90, 0.2); + color: var(--text-primary); } -.score-content::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.1); - border-radius: 4px; +.note-actions button.active { + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%); + color: #0f0f16; + border-color: transparent; } -.score-content::-webkit-scrollbar-thumb { - background: #e69138; - border-radius: 4px; - transition: background 0.3s ease; +/* Responsive */ +@media (max-width: 1500px) { + .deck-grid { + grid-template-columns: repeat(2, minmax(220px, 1fr)); + } } -.score-content::-webkit-scrollbar-thumb:hover { - background: #ffb84d; +@media (max-width: 1100px) { + .ui-overlay { + padding: 24px; + } + + .ui-top { + flex-direction: column; + align-items: stretch; + } + + .hud-stack--right { + align-items: stretch; + } + + .ui-bottom { + flex-direction: column; + align-items: stretch; + } + + #joystick-container { + bottom: 28px; + right: 28px; + } + + .topdown-view { + width: 100%; + } + + .global-nav { + top: auto; + bottom: 20px; + } + + .composer-overlay { + padding: 30px; + } + + .deck-grid { + grid-template-columns: 1fr; + } } -/* Mobile optimization for score panel */ -@media (max-width: 768px) { - #score-panel { - top: 10px; - right: 10px; - left: 10px; - width: auto; - height: 50vh; - max-height: 400px; +@media (max-width: 640px) { + .global-nav { + flex-direction: column; + align-items: center; + padding: 14px 18px; } - - .score-panel-header h3 { - font-size: 16px; + + .hud-stack { + min-width: auto; + } + + .ui-overlay { + padding: 18px; + } + + .composer-overlay { + padding: 20px; + } + + .composer-panel { + padding: 24px; } -} \ No newline at end of file +}