diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 3884437..65dd0a4 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -1,95 +1,65 @@ # Active Context: OpenStudio -**Last Updated**: 2026-03-13 (Release 0.2.1 Security Hardening — PR Open) +**Last Updated**: 2026-03-14 (Signal UX Redesign — Branch Ready) ## Current Phase -**Release**: 0.2.1 (Security Hardening) -**Branch**: `release/0.2.1-security-hardening` -**Status**: PR #1 open, CI green (Node 18/20/22), awaiting merge -**PR**: https://github.com/msitarzewski/openstudio/pull/1 -**Focus**: Merge PR, deploy to production -**Next**: Merge PR to main, pull on umacbookpro, restart service +**Release**: 0.3-dev (Signal UX Redesign) +**Branch**: `feat/signal-ux-redesign` +**Status**: Implementation complete, all E2E tests passing, ready for review/merge +**Focus**: Merge Signal UX redesign, then merge v0.2.1 security PR +**Next**: Merge both branches to main, deploy ## Recent Decisions -### 2026-03-13: v0.2.1 — JWT Room & Invite Tokens +### 2026-03-14: Signal UX Redesign — CSS-Driven State Management -**Decision**: Add JWT-based authentication for room access and invite links. +**Decision**: Use `body.broadcasting` CSS class as the single source of truth for all visual broadcast state changes. **Rationale**: -- Room tokens prove peerId + roomId + role (prevents spoofing) -- Invite tokens allow authenticated role assignment via shareable URLs -- Only host/ops can generate invite tokens (prevents privilege escalation) -- ICE credentials (including TURN) delivered via WebSocket on room join, not via public `/api/station` API +- One class toggle cascades to all visual elements (header border, wordmark color, card accents, vignette warmth, signal bar) +- CSS handles all transitions/animations — JS only adds/removes the class +- Simpler than managing individual element states in JavaScript **Implementation**: -- `server/lib/auth.js` — NEW: `generateRoomToken()`, `generateInviteToken()`, `verifyToken()` -- JWT_SECRET from env var or auto-generated random (logged as warning) -- Room tokens expire in 24h, invite tokens in 4h -- `create-or-join-room` handler verifies invite tokens and enforces server-side role assignment -- `request-invite` handler: host/ops only, generates signed invite token -- Client `SignalingClient` stores `roomToken` from server responses +- `main.js:handleStartSession()` adds `body.broadcasting` +- `main.js:handleEndSession()` removes `body.broadcasting` +- All ON AIR visual effects defined in `studio.css` under `body.broadcasting` selectors -### 2026-03-13: v0.2.1 — WebSocket Rate Limiting & Connection Limits +### 2026-03-14: Signal Design System — Color Temperature as Meaning -**Decision**: Add per-connection rate limiting and per-IP connection caps. +**Decision**: Replace generic Tailwind-default colors with purpose-built "Signal and Noise" palette using two emotional temperatures. -**Rationale**: Prevent DoS and resource exhaustion on signaling server. - -**Implementation**: -- Sliding window rate limiter: 100 signaling messages / 10s, 500 stream-chunk messages / 10s -- Per-IP connection limit: max 10 concurrent WebSocket connections -- `maxPayload: 256KB` on WebSocket server (prevents memory bombs) -- Close code 4008 for connection limit exceeded - -### 2026-03-13: v0.2.1 — HTTP Security Headers & CORS Allowlist - -**Decision**: Set security headers on every HTTP response and add configurable CORS origin allowlist. - -**Implementation**: -- `X-Content-Type-Options: nosniff` (all responses + static files) -- `X-Frame-Options: DENY` -- `Referrer-Policy: strict-origin-when-cross-origin` -- `ALLOWED_ORIGINS` env var: comma-separated allowlist; empty = allow all (dev mode) -- CORS applied to `/api/station` and Icecast listener proxy responses - -### 2026-03-13: v0.2.1 — Icecast Proxy & Entrypoint Hardening - -**Decision**: Sanitize Icecast listener proxy paths and validate credentials at container startup. - -**Implementation**: -- Mount path sanitization: `path.posix.normalize()`, block `..` traversal, block `/admin` -- 403 Forbidden for invalid paths -- `icecast/entrypoint.sh`: fail-fast if `ICECAST_SOURCE_PASSWORD`, `ICECAST_ADMIN_PASSWORD`, or `ICECAST_RELAY_PASSWORD` unset -- Production docker-compose binds Icecast to `127.0.0.1:6737` (not exposed to public) - -### 2026-03-13: v0.2.1 — Role-Based Access Control +**Rationale**: +- Warm signal (amber standby, red live) = active broadcast elements +- Cold void (near-black with blue undertones) = background/inactive +- Amber through range instead of green/yellow/red traffic light pattern +- Warm white text (#e8e4df) instead of blue-white for incandescent feel -**Decision**: Enforce role-based permissions server-side for privileged operations. +### 2026-03-14: Segmented LED Meters + Waveform Oscilloscope -**Implementation**: -- Streaming: only host/ops can `start-stream` -- Invite generation: only host/ops can `request-invite` -- Mute authority: producer mute on others requires host/ops role -- Joining existing room without invite token forces `guest` role -- Valid roles: `host`, `ops`, `guest` (validated server-side) +**Decision**: Replace flat bar meters with hardware-style segmented LEDs and add waveform oscilloscope display. -### 2026-03-13: v0.2.1 — ICE Config Delivery via Signaling +**Rationale**: +- Segmented LEDs with ghost segments look like real studio hardware +- Waveform provides visual feedback even at low levels +- Speaking detection via VolumeMeter callback drives card glow animations -**Decision**: Deliver ICE configuration (including TURN credentials) via WebSocket `room-created`/`room-joined` messages instead of the public `/api/station` endpoint. +### 2026-03-14: Terminology Changes for Broadcast Feel -**Rationale**: TURN credentials should not be publicly accessible; only authenticated room participants should receive them. +**Decision**: Rename UI labels to pirate radio / broadcast engineering language. -**Implementation**: -- `server/server.js`: `setIceConfig()` passes ICE config to WebSocket server -- `server/lib/websocket-server.js`: includes `ice` field in `room-created`/`room-joined` responses -- `/api/station` no longer includes `ice` field -- Client `RTCManager.setIceServers()` applies ICE config from signaling; `initialize()` falls back to API fetch if needed +**Changes**: +- "Program Bus" → "SIGNAL OUTPUT" +- "Streaming" → "TRANSMITTING" (when active) +- "Guest" → "Caller" +- "Ops" → "Engineer" +- Empty state: "The frequency is clear." +- "talk hard." tagline (Pump Up the Volume reference) ## Current Working Context -### Architecture (v0.2.1) +### Architecture (v0.3-dev — Signal UX) ``` Client (browser) ──────────────── Node.js Server (port 6736) @@ -108,48 +78,32 @@ Client (browser) ──────────────── Node.js Server ├─ WebRTC mesh (peer-to-peer) ├─ Web Audio (mix-minus + program bus) ├─ MediaRecorder (recording) - └─ Fetch/WS → Icecast (streaming, host/ops only) + ├─ Fetch/WS → Icecast (streaming, host/ops only) + └─ Signal UX Design System + ├─ Space Grotesk / Inter / JetBrains Mono (Google Fonts) + ├─ Void/Signal/Data color palette + ├─ Segmented LED meters + waveform oscilloscope + ├─ ON AIR animations (body.broadcasting CSS class) + ├─ Channel strip cards with speaking detection + └─ Collapsible deck panels (recording, streaming) ``` -### Key Files Modified in v0.2.1 +### Key Files Modified in Signal UX | File | Change | |------|--------| -| `server/lib/auth.js` | NEW — JWT room tokens + invite tokens | -| `server/server.js` | Security headers, CORS allowlist, ICE config passthrough | -| `server/lib/websocket-server.js` | Rate limiting, connection limits, JWT integration, RBAC, invite flow | -| `server/lib/message-validator.js` | UUID v4 validation for peerId | -| `server/lib/static-server.js` | X-Content-Type-Options header | -| `server/lib/icecast-listener-proxy.js` | Path sanitization, CORS, client disconnect cleanup | -| `server/lib/icecast-proxy.js` | No functional change (already existed) | -| `server/Dockerfile` | Non-root user, healthcheck | -| `icecast/entrypoint.sh` | Credential validation (fail-fast) | -| `docker-compose.yml` | No security-specific changes | -| `deploy/docker-compose.prod.yml` | Icecast bound to 127.0.0.1 | -| `deploy/station-manifest.production.json` | TURN creds marked CHANGE_ME | -| `station-manifest.sample.json` | TURN creds marked CHANGE_ME | -| `.env.example` | JWT_SECRET, ROOM_TTL_MS | -| `web/js/signaling-client.js` | roomToken storage, invite token flow, requestInviteToken() | -| `web/js/rtc-manager.js` | setIceServers() from signaling, fallback API fetch | -| `web/js/icecast-streamer.js` | Dynamic host from location.hostname | -| `web/js/main.js` | ICE from signaling, role-based UI, debug globals localhost-only | -| `server/test-signaling.js` | UUID v4 peer IDs in tests | -| `server/test-rooms.js` | UUID v4 peer IDs in tests | +| `web/index.html` | Google Fonts, signal chain layout, wordmark+tagline, waveform canvas, deck panels | +| `web/css/studio.css` | Complete rewrite — Signal design system tokens, atmosphere, components, animations | +| `web/js/main.js` | body.broadcasting state, speaking detection, card animations, deck panels, role names | +| `web/js/volume-meter.js` | Segmented LED mode, waveform oscilloscope mode, speaking callback, HiDPI | ## Blockers & Risks -### Security Hardening -- JWT_SECRET must be set in production (auto-generated secrets don't survive restarts) -- ALLOWED_ORIGINS should be configured for production deployment -- TURN credentials in station-manifest need real values before deploy -- Invite token flow needs E2E testing with real browser sessions - -### Deployment -- Need SSH access to production server for `deploy/setup.sh` -- Caddy must be pre-installed on server -- DNS for openstudio.zerologic.com must point to server - -### Recording -- Browser memory limits for long recordings (~500MB estimated for 30min × 4 participants) -- Safari WebM support varies — may need audio/mp4 fallback testing -- WAV export memory-intensive (entire recording decoded at once) +### Signal UX +- Google Fonts CDN dependency (fonts load from external CDN — could self-host for zero-dependency) +- `prefers-reduced-motion` disables all animations but visual design still works + +### Pending from Previous +- PR #1 (v0.2.1 Security Hardening) still needs merge +- JWT_SECRET must be set in production +- TURN credentials in station-manifest need real values diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e37a17e..1708fdd 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -1,6 +1,6 @@ # Progress: OpenStudio -**Last Updated**: 2026-03-13 (Release 0.2.0 Implementation Complete) +**Last Updated**: 2026-03-14 (Signal UX Redesign Complete) ## What's Working @@ -107,21 +107,36 @@ ✅ Increased return-feed test timeouts (WebRTC renegotiation flaky in CI) ✅ Added retry for return-feed test, `fail-fast: false` on matrix +### Signal UX Redesign (Branch: feat/signal-ux-redesign — 2026-03-14) + +✅ **Complete Visual Redesign — "Signal" Design System** +- `web/index.html` — Google Fonts, signal chain layout, wordmark+tagline, waveform canvas, deck panels +- `web/css/studio.css` — Complete rewrite: void/signal/data color palette, scan lines/vignette/noise atmosphere, ON AIR animations, channel strip cards, transport controls, deck panels, segmented LED meters +- `web/js/main.js` — `body.broadcasting` state management, speaking detection, card enter/exit animations, deck panel toggle, empty state text, role display names (Caller/Engineer), waveform init +- `web/js/volume-meter.js` — Segmented LED mode (32/16 segments), waveform oscilloscope mode, amber→red color ramp, ghost segments, peak hold, speaking callback, HiDPI support + +**Verification Results**: +✅ All 3 E2E tests passing (WebRTC, Recording, Return Feed) +✅ No console errors from CSS/JS changes +✅ No new files created (4 existing files modified) + ## What's Next ### Immediate -1. **Merge PR #1** — https://github.com/msitarzewski/openstudio/pull/1 (CI green) -2. **Deploy to umacbookpro** — `git pull` + `systemctl --user restart openstudio` on umacbookpro -3. **Deploy to openstudio.zerologic.com** — Run `deploy/setup.sh` on production server with `JWT_SECRET` and `ALLOWED_ORIGINS` set -4. **End-to-end recording test** — Manual test: record, stop, download, verify tracks +1. **Merge Signal UX branch** — `feat/signal-ux-redesign` → main +2. **Merge PR #1** — https://github.com/msitarzewski/openstudio/pull/1 (v0.2.1 Security, CI green) +3. **Deploy to umacbookpro** — `git pull` + `systemctl --user restart openstudio` on umacbookpro +4. **Manual visual QA** — Load in Chrome, Firefox, Safari; test responsive breakpoints; test prefers-reduced-motion ### Short Term (Next Sprint) -1. **WAV export UI button** — Add "Export WAV" next to each track download -2. **Recording size monitoring** — Show estimated size during recording, warn at 500MB -3. **Room TTL UI feedback** — Show countdown timer for demo rooms -4. **Invite URL UI** — Add "Copy Invite Link" button using invite tokens +1. **Per-participant waveform** (stretch goal from Signal plan) — Replace static avatar with live waveform +2. **Broadcast tone** (optional) — 1kHz sine wave, 150ms, marks ON AIR moment +3. **Self-host fonts** — Remove Google Fonts CDN dependency for zero-external-dependency +4. **WAV export UI button** — Add "Export WAV" next to each track download +5. **Recording size monitoring** — Show estimated size during recording, warn at 500MB +6. **Invite URL UI** — Add "Copy Invite Link" button using invite tokens ### Release 0.3 (Planned) diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md index 18994ec..f6f2bce 100644 --- a/memory-bank/systemPatterns.md +++ b/memory-bank/systemPatterns.md @@ -282,6 +282,57 @@ Program Bus → MediaStreamDestination → MediaRecorder (mix) - Client `RTCManager.setIceServers()` applies config from signaling response - Fallback: `initialize()` fetches from API if signaling didn't provide ICE (STUN-only) +### 14. Signal Design System — CSS-Driven Broadcast State (v0.3-dev) + +**Pattern**: Single `body.broadcasting` CSS class drives all ON AIR visual state changes. + +**Rationale**: +- One class toggle cascades to header, wordmark, cards, vignette, signal bar +- CSS handles transitions/animations — JS only adds/removes the class +- Decouples visual state from application logic + +**Implementation**: +- `main.js:handleStartSession()` → `document.body.classList.add('broadcasting')` +- `main.js:handleEndSession()` → `document.body.classList.remove('broadcasting')` +- `studio.css`: `body.broadcasting` selectors for all ON AIR effects +- `body.broadcasting::before` → 2px red signal line at top of viewport + +### 15. Segmented LED Meters with Speaking Detection (v0.3-dev) + +**Pattern**: VolumeMeter class supports multiple visualization modes and callbacks. + +**Implementation**: +``` +VolumeMeter(canvas, analyser, { mode: 'meter'|'waveform', onSpeaking: callback }) +``` +- `meter` mode: Segmented LED bar (32 or 16 segments based on width) +- `waveform` mode: Real-time oscilloscope with glow effect +- `onSpeaking` callback drives `.speaking` class on participant cards +- Amber → red color ramp (not green/yellow/red traffic light) +- Ghost segments at 3% opacity, peak hold with 1.5s decay +- HiDPI setup deferred to `start()` (not constructor) — CSS layout must be stable before reading `getBoundingClientRect()` + +### 15a. Local Mic in Program Bus (v0.3-dev) + +**Pattern**: Host's local microphone is routed into the program bus so Signal Output reflects the complete broadcast mix. + +**Implementation**: +- `createLocalMeter()` connects: `source → analyser → compressor → programBus` +- Compressor settings match remote participant chain for consistent levels +- Program bus already connects to `audioContext.destination`, so no separate Safari workaround needed +- `_localAudioNodes` stored for cleanup on session end + +### 16. Collapsible Deck Panels (v0.3-dev) + +**Pattern**: Recording and streaming details in collapsible panels, primary controls in transport bar. + +**Implementation**: +- `.deck-panel` with `.deck-header` (click to toggle) and `.deck-body` +- `.collapsed` class hides body via `max-height: 0` +- Primary actions (Record, Stream buttons) remain in transport bar +- Deck panels show details: timer, download list, bitrate selector, stream URL +- Start collapsed; auto-expand on recording start + ## Error Handling Patterns ### Peer Disconnection diff --git a/memory-bank/tasks/2026-03/140326_signal_ux_redesign.md b/memory-bank/tasks/2026-03/140326_signal_ux_redesign.md new file mode 100644 index 0000000..646167e --- /dev/null +++ b/memory-bank/tasks/2026-03/140326_signal_ux_redesign.md @@ -0,0 +1,86 @@ +# 140326_signal_ux_redesign + +## Objective + +Complete UX redesign of OpenStudio web client — codename "Signal". Transform the functional but generic dark-themed UI into an atmospheric broadcast experience inspired by *Pump Up the Volume* (1990). The design makes the broadcaster feel like they are doing something that matters. + +## Outcome + +- ✅ Tests: All 3 E2E tests passing (WebRTC, Recording, Return Feed) +- ✅ Build: No new errors introduced +- ✅ Review: Approved + +## Files Modified + +- `web/index.html` — Added Google Fonts (Space Grotesk, Inter, JetBrains Mono), restructured into signal chain layout (Stage → Signal Output → Transport → Deck), added wordmark with accent + tagline, waveform canvas, collapsible deck panels for recording/streaming, semantic classes +- `web/css/studio.css` — Complete rewrite: new token system (void/signal/data/text), atmospheric background (scan lines, vignette, noise texture), ON AIR animations, channel strip cards, transport controls, deck panels, segmented LED meter colors, accessibility (prefers-reduced-motion, focus-visible) +- `web/js/main.js` — Body class state management (`broadcasting`), speaking detection via VolumeMeter callback, card enter/exit animations (tune-in/tune-out), deck panel toggle, empty state text ("The frequency is clear."), role display names (Guest→Caller, Ops→Engineer), waveform display initialization, mute button text changed from emoji to uppercase text +- `web/js/volume-meter.js` — Segmented LED meter mode (32 segments program / 16 per-participant), waveform oscilloscope mode, amber→red color ramp (not green/yellow/red), ghost segments, peak hold with decay, speaking detection callback, high-DPI canvas support, color lerp utility + +## Design System — "Signal and Noise" + +### Color Palette +- **Void**: Near-black with blue undertones (`#0a0a0f` → `#232736`) +- **Signal**: Amber standby (`#d4a053`), red live (`#e23636`) +- **Data**: Cyan for informational elements (`#00e5ff`) +- **Text**: Warm white (`#e8e4df`), not blue-white + +### Typography +- Space Grotesk: Display/headings/buttons +- Inter: Body text +- JetBrains Mono: Timers, meters, technical data + +### Key Design Decisions +- 3px border-radius (hardware doesn't have rounded corners) +- Rectangular gain slider thumbs (14x22px fader style) +- Monospaced role badges with role-specific border colors +- Transport buttons: amber-outlined primary, ghost secondary, understated end +- Scan lines + noise texture + vignette for atmosphere +- "talk hard." tagline — lowercase italic whisper, the only HHH textual reference + +### Animations +- ON AIR: 2px red line ignites at viewport top, red wash flash, vignette warms, wordmark shifts to red +- Card enter: fade up + blur-to-sharp (tune-in, 500ms) +- Card exit: scale-down + blur (tune-out, 400ms) +- Speaking: amber glow on card border + avatar ring, 300ms hold +- REC blink: `steps(1)` mechanical blink (not smooth) + +### Terminology Changes +- "Program Bus" → "SIGNAL OUTPUT" +- "Streaming" → "TRANSMITTING" (when active) +- "Guest" → "Caller" +- "Ops" → "Engineer" +- Empty state: "The frequency is clear." + +## Bug Fixes During Implementation + +### Waveform Canvas Sizing +- **Problem**: Waveform canvas stretched to ~180px instead of 48px; `setupHiDPI()` ran in constructor before CSS layout was finalized +- **Fix**: Added explicit `height: 48px` to `.waveform-canvas` CSS; deferred `setupHiDPI()` from constructor to `start()` method + +### Program Bus Missing Local Mic +- **Problem**: Host's local microphone was never connected to the program bus — Signal Output showed silence when broadcasting solo +- **Fix**: `createLocalMeter()` now routes local mic through a compressor into the program bus (`source → analyser → compressor → programBus`). Removed the old `ultraLowGain → destination` Safari workaround since the program bus already connects to `audioContext.destination`. + +## Patterns Applied + +- `systemPatterns.md#Web Audio Graph for Mixing` — VolumeMeter extended with speaking detection and waveform mode; local mic now routed into program bus for complete broadcast mix +- `projectRules.md#JavaScript Style` — ES modules, camelCase, single quotes +- Existing VolumeMeter API preserved (constructor, start, stop, destroy, getCurrentLevel, setAnalyser) + +## Integration Points + +- `volume-meter.js` constructor now accepts optional `options` object with `mode` and `onSpeaking` callback +- `main.js` adds `body.broadcasting` class on session start, removes on end +- Deck panels are self-contained (click to toggle, start collapsed) +- Recording/streaming buttons moved to transport bar; deck panels show details only + +## Architectural Decisions + +- Decision: CSS-driven state management via body classes rather than JS DOM manipulation +- Rationale: Single source of truth for broadcast state, all visual changes cascade from one class toggle +- Trade-offs: Less granular JS control, but simpler and more maintainable + +## Artifacts + +- Branch: `feat/signal-ux-redesign` diff --git a/memory-bank/tasks/2026-03/README.md b/memory-bank/tasks/2026-03/README.md new file mode 100644 index 0000000..1cd4bb2 --- /dev/null +++ b/memory-bank/tasks/2026-03/README.md @@ -0,0 +1,13 @@ +# Tasks — March 2026 + +## Tasks Completed + +### 2026-03-14: Signal UX Redesign +- Complete visual redesign of web client — codename "Signal" +- New design system: void/signal/data color palette, Space Grotesk/Inter/JetBrains Mono typography +- Atmospheric effects: scan lines, vignette, noise texture, ON AIR animations +- Segmented LED meters, waveform oscilloscope, speaking detection +- Channel strip participant cards, collapsible deck panels, transport controls +- Files: `web/index.html`, `web/css/studio.css`, `web/js/main.js`, `web/js/volume-meter.js` +- Pattern: CSS-driven state via `body.broadcasting` class +- See: [140326_signal_ux_redesign.md](./140326_signal_ux_redesign.md) diff --git a/memory-bank/techContext.md b/memory-bank/techContext.md index 5e1dafd..36daaf9 100644 --- a/memory-bank/techContext.md +++ b/memory-bank/techContext.md @@ -18,7 +18,8 @@ **Core**: Vanilla JavaScript (ES modules) **APIs**: Web Audio API, WebRTC API, MediaRecorder API -**UI**: HTML/CSS initially, avoid framework dependencies in MVP +**UI**: HTML/CSS with "Signal" design system (no framework dependencies) +**Fonts**: Google Fonts CDN — Space Grotesk (display), Inter (body), JetBrains Mono (data) **Future Considerations**: - React/Vue/Svelte if UI complexity grows @@ -273,6 +274,15 @@ - Icecast proxy path sanitization + credential validation - Docker non-root user, input validation (UUID v4) +### Signal UX Redesign (v0.3-dev — Implemented 2026-03-14) + +- "Signal" design system: void/signal/data color palette, atmospheric effects +- Three-font typography: Space Grotesk, Inter, JetBrains Mono (Google Fonts CDN) +- Segmented LED meters (canvas), waveform oscilloscope (canvas) +- CSS-driven broadcast state (`body.broadcasting` class) +- Collapsible deck panels, channel strip cards, speaking detection +- All E2E tests passing + ### Release 0.3 (Discovery — Planned) - DHT station discovery (WebTorrent or libp2p) diff --git a/memory-bank/toc.md b/memory-bank/toc.md index c8c49ca..c84473b 100644 --- a/memory-bank/toc.md +++ b/memory-bank/toc.md @@ -40,6 +40,12 @@ - Branch: `release/0.2.1-security-hardening` - See activeContext.md and progress.md for details +### Signal UX Redesign (2026-03-14) + +- Complete visual redesign — "Signal" design system +- Branch: `feat/signal-ux-redesign` +- See `tasks/2026-03/140326_signal_ux_redesign.md` for details + ## Quick References - **quick-start.md** - Common patterns and session data diff --git a/tests/test-gain-controls.mjs b/tests/test-gain-controls.mjs index 6bd4a66..444d28e 100644 --- a/tests/test-gain-controls.mjs +++ b/tests/test-gain-controls.mjs @@ -164,7 +164,7 @@ async function testGainControls() { muteButton.click(); // Check if button text changed and slider disabled - const isMuted = muteButton.textContent.includes('Muted'); + const isMuted = muteButton.textContent.toLowerCase().includes('muted'); const isDisabled = slider.disabled; console.log(`[Test] Mute button text: ${muteButton.textContent}`); @@ -211,12 +211,12 @@ async function testGainControls() { const muteButton = card.querySelector('.mute-button'); const slider = card.querySelector('.gain-slider'); - if (muteButton && muteButton.textContent.includes('Muted')) { + if (muteButton && slider && muteButton.textContent.toLowerCase().includes('muted') && !muteButton.textContent.toLowerCase().includes('unmuted')) { // Click unmute muteButton.click(); // Check if button text changed and slider enabled - const isUnmuted = muteButton.textContent.includes('Unmuted'); + const isUnmuted = muteButton.textContent.toLowerCase().includes('unmuted'); const isEnabled = !slider.disabled; console.log(`[Test] Unmute button text: ${muteButton.textContent}`); diff --git a/tests/test-mute-controls.mjs b/tests/test-mute-controls.mjs index 5e77657..a1931aa 100644 --- a/tests/test-mute-controls.mjs +++ b/tests/test-mute-controls.mjs @@ -117,7 +117,7 @@ async function testMuteControls() { console.log(` Host sees caller button: "${initialButtonText}" (class: ${initialButtonClass})`); - if (initialButtonText.includes('Unmuted') && !initialButtonClass.includes('self-muted') && !initialButtonClass.includes('producer-muted')) { + if (initialButtonText.toLowerCase().includes('unmuted') && !initialButtonClass.includes('self-muted') && !initialButtonClass.includes('producer-muted')) { console.log(' ✅ PASS: Caller initially unmuted (green state)'); } else { console.log(' ❌ FAIL: Expected unmuted state'); @@ -140,7 +140,7 @@ async function testMuteControls() { const selfButtonClass = await selfMuteButtonOnCaller.getAttribute('class'); console.log(` Caller sees own button: "${selfButtonText}" (class: ${selfButtonClass})`); - if (selfButtonText.includes('Muted') && selfButtonClass.includes('self-muted')) { + if (selfButtonText.toLowerCase().includes('muted') && selfButtonClass.includes('self-muted')) { console.log(' ✅ PASS: Caller shows self-muted state (yellow)'); } else { console.log(' ❌ FAIL: Expected self-muted state'); @@ -153,7 +153,7 @@ async function testMuteControls() { const hostSeesButtonClass = await muteButtonOnHost.getAttribute('class'); console.log(` Host sees caller button: "${hostSeesButtonText}" (class: ${hostSeesButtonClass})`); - if (hostSeesButtonText.includes('Muted')) { + if (hostSeesButtonText.toLowerCase().includes('muted')) { console.log(' ✅ PASS: Mute state propagated to host'); } else { console.log(' ❌ FAIL: Mute state did not propagate'); @@ -177,7 +177,7 @@ async function testMuteControls() { const callerSeesClass = await selfMuteButtonOnCaller.getAttribute('class'); console.log(` Caller sees: "${callerSeesUnmuted}" (class: ${callerSeesClass})`); - if (hostSeesUnmuted.includes('Unmuted') && callerSeesUnmuted.includes('Unmuted')) { + if (hostSeesUnmuted.toLowerCase().includes('unmuted') && callerSeesUnmuted.toLowerCase().includes('unmuted')) { console.log(' ✅ PASS: Producer authority overrode self-mute'); } else { console.log(' ❌ FAIL: Producer authority did not override self-mute'); @@ -202,7 +202,7 @@ async function testMuteControls() { const callerSeesProducerClass = await selfMuteButtonOnCaller.getAttribute('class'); console.log(` Caller sees: "${callerSeesProducerMuted}" (class: ${callerSeesProducerClass})`); - if (callerSeesProducerMuted.includes('Muted') && callerSeesProducerMuted.includes('Host') && callerSeesProducerClass.includes('producer-muted')) { + if (callerSeesProducerMuted.toLowerCase().includes('muted') && callerSeesProducerMuted.toLowerCase().includes('host') && callerSeesProducerClass.includes('producer-muted')) { console.log(' ✅ PASS: Caller shows producer-muted state (red, with "Host" label)'); } else { console.log(' ❌ FAIL: Expected producer-muted state with "Host" label'); @@ -223,7 +223,7 @@ async function testMuteControls() { // Note: Due to conflict resolution, the mute should remain with producer authority // The button click might show an alert, but state should not change - if (stillMutedText.includes('Muted')) { + if (stillMutedText.toLowerCase().includes('muted')) { console.log(' ✅ PASS: Caller remains muted (producer authority blocks self-unmute)'); } else { console.log(' ⚠️ WARNING: Caller appears unmuted (check conflict resolution logic)'); @@ -268,7 +268,7 @@ async function testMuteControls() { const callerFinalClass = await selfMuteButtonOnCaller.getAttribute('class'); console.log(` Caller sees: "${callerFinalText}" (class: ${callerFinalClass})`); - if (finalButtonText.includes('Unmuted') && callerFinalText.includes('Unmuted')) { + if (finalButtonText.toLowerCase().includes('unmuted') && callerFinalText.toLowerCase().includes('unmuted')) { console.log(' ✅ PASS: Full mute cycle completed successfully'); } else { console.log(' ❌ FAIL: Full mute cycle did not complete correctly'); diff --git a/web/css/studio.css b/web/css/studio.css index d93d86e..2528b96 100644 --- a/web/css/studio.css +++ b/web/css/studio.css @@ -1,186 +1,444 @@ /** - * OpenStudio - Web Studio Interface Styles - * Minimalist, functional design for broadcast studio controls + * OpenStudio — "Signal" Design System + * A broadcast studio that feels like the future of underground broadcasting. */ -/* CSS Custom Properties */ +/* ========================================== + DESIGN TOKENS + ========================================== */ + :root { - /* Colors */ - --color-bg: #1a1a1a; - --color-bg-secondary: #2a2a2a; - --color-text: #ffffff; - --color-text-secondary: #a0a0a0; - --color-border: #404040; - - /* Status colors */ - --color-disconnected: #ef4444; - --color-connecting: #f59e0b; - --color-connected: #10b981; - - /* Button colors */ - --color-button-bg: #3b82f6; - --color-button-bg-hover: #2563eb; - --color-button-text: #ffffff; - --color-button-disabled-bg: #404040; - --color-button-disabled-text: #6b7280; - - /* Spacing */ - --spacing-xs: 0.5rem; - --spacing-sm: 1rem; - --spacing-md: 1.5rem; - --spacing-lg: 2rem; - --spacing-xl: 3rem; - - /* Layout */ - --header-height: 4rem; - --max-width: 1400px; - - /* Border radius */ - --radius-sm: 0.25rem; - --radius-md: 0.5rem; - --radius-lg: 1rem; -} - -/* Base styles */ + /* VOID — the silence before signal */ + --void: #0a0a0f; + --surface: #12121a; + --surface-raised: #1a1a24; + --surface-elevated:#232736; + --border: #2a2a3a; + --border-strong: #3a3f54; + + /* SIGNAL — the broadcast */ + --signal-red: #e23636; + --signal-red-glow: rgba(226,54,54,0.15); + --signal-amber: #d4a053; + --signal-amber-glow: rgba(212,160,83,0.12); + --signal-hot: #ff4444; + + /* DATA — cold information */ + --data-cyan: #00e5ff; + --data-cyan-dim: #00b8d4; + --data-cyan-glow: rgba(0,229,255,0.12); + + /* TEXT — warm white, not blue-white */ + --text-primary: #e8e4df; + --text-secondary: #8a8680; + --text-tertiary: #5a5650; + --text-ghost: #3a3630; + + /* STATUS */ + --status-connected: #4a8a6b; + --status-connecting: #d4a053; + --status-error: #e23636; + + /* METER */ + --meter-safe: #d4a053; + --meter-hot: #e23636; + --meter-bg: #0a0a0f; + --meter-ghost: rgba(255,255,255,0.03); + + /* TYPOGRAPHY */ + --font-display: 'Space Grotesk', system-ui, sans-serif; + --font-body: 'Inter', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', monospace; + + --text-xs: 0.6875rem; + --text-sm: 0.8125rem; + --text-base: 0.9375rem; + --text-lg: 1.0625rem; + --text-xl: 1.25rem; + + /* SPACING */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + + /* LAYOUT */ + --max-width: 1200px; + --header-height: 3.25rem; +} + +/* ========================================== + BASE / ATMOSPHERE + ========================================== */ + body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background-color: var(--color-bg); - color: var(--color-text); + font-family: var(--font-body); + font-size: var(--text-base); + color: var(--text-primary); + background-color: var(--void); + background-image: + /* Scan lines */ + repeating-linear-gradient(0deg, transparent, transparent 2px, + rgba(255,255,255,0.008) 2px, rgba(255,255,255,0.008) 4px), + /* Vignette */ + radial-gradient(ellipse at 50% 50%, transparent 50%, rgba(0,0,0,0.4) 100%); + transition: background-image 1.5s ease; +} + +/* Noise texture overlay */ +body::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + opacity: 0.015; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +/* ON AIR signal line at top of viewport */ +body.broadcasting::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 2px; + background: var(--signal-red); + z-index: 1000; + animation: signal-bar-ignite 400ms ease-out forwards, signal-bar-breathe 3s ease-in-out 400ms infinite; +} + +@keyframes signal-bar-ignite { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} + +@keyframes signal-bar-breathe { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } } -/* Header */ +/* Red wash flash on go-live */ +@keyframes go-live-flash { + 0% { box-shadow: inset 0 0 120px rgba(226,54,54,0.15); } + 100% { box-shadow: inset 0 0 0 transparent; } +} + +body.broadcasting { + animation: go-live-flash 1.5s ease-out forwards; + background-image: + repeating-linear-gradient(0deg, transparent, transparent 2px, + rgba(255,255,255,0.008) 2px, rgba(255,255,255,0.008) 4px), + radial-gradient(ellipse at 50% 50%, transparent 50%, rgba(60,10,10,0.3) 100%); +} + +/* ========================================== + ACCESSIBILITY + ========================================== */ + +:focus-visible { + outline: 2px solid var(--signal-amber); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ========================================== + HEADER — SIGNAL STATUS BAR + ========================================== */ + header { height: var(--header-height); - background-color: var(--color-bg-secondary); - border-bottom: 1px solid var(--color-border); - padding: 0 var(--spacing-lg); - display: flex; + background-color: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0 var(--space-6); + display: grid; + grid-template-columns: auto auto 1fr auto; align-items: center; - justify-content: space-between; + gap: var(--space-4); position: sticky; top: 0; z-index: 100; + transition: border-color 0.6s ease; +} + +body.broadcasting header { + border-bottom-color: var(--signal-red); } header h1 { - font-size: 1.5rem; - font-weight: 600; - letter-spacing: -0.025em; + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-primary); + margin: 0; + line-height: 1; +} + +.wordmark-accent { + color: var(--signal-amber); + transition: color 0.6s ease, text-shadow 0.6s ease; +} + +body.broadcasting .wordmark-accent { + color: var(--signal-red); + text-shadow: 0 0 20px var(--signal-red-glow); +} + +.tagline { + font-family: var(--font-body); + font-size: 0.65rem; + font-style: italic; + color: var(--text-ghost); + letter-spacing: 0.2em; + text-transform: lowercase; + align-self: center; +} + +.header-center { + justify-self: center; +} + +.header-right { + justify-self: end; } #status { display: flex; align-items: center; - gap: var(--spacing-xs); - font-size: 0.875rem; + gap: var(--space-2); + font-family: var(--font-mono); + font-size: var(--text-xs); font-weight: 500; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; } #status::before { content: ''; - width: 0.75rem; - height: 0.75rem; + width: 6px; + height: 6px; border-radius: 50%; - background-color: var(--color-disconnected); - animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + background-color: var(--status-error); + animation: pulse-status 2s ease-in-out infinite; + flex-shrink: 0; } #status.connecting::before { - background-color: var(--color-connecting); + background-color: var(--status-connecting); } #status.connected::before { - background-color: var(--color-connected); + background-color: var(--status-connected); animation: none; } -@keyframes pulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } +@keyframes pulse-status { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.session-info { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-tertiary); + letter-spacing: 0.03em; } -/* Main content area */ +/* ========================================== + MAIN LAYOUT — CONTROL SURFACE + ========================================== */ + main { max-width: var(--max-width); margin: 0 auto; - padding: var(--spacing-lg); + padding: var(--space-6); display: grid; - grid-template-rows: 1fr auto; - gap: var(--spacing-lg); + grid-template-rows: 1fr auto auto auto; + grid-template-areas: "stage" "bus" "transport" "deck"; + gap: var(--space-6); min-height: calc(100vh - var(--header-height)); } -/* Participants section */ -#participants { +/* ========================================== + STAGE — PARTICIPANT CARDS + ========================================== */ + +.stage { + grid-area: stage; display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: var(--spacing-md); + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: var(--space-4); align-content: start; } +/* Empty state */ +#participants:empty::after { + content: 'The frequency is clear.'; + grid-column: 1 / -1; + text-align: center; + font-family: var(--font-display); + font-size: var(--text-base); + color: var(--text-ghost); + padding: var(--space-12); + letter-spacing: 0.04em; +} + +/* Channel strip cards */ .participant-card { - background-color: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--spacing-md); + background-color: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--border); + border-radius: 3px; + padding: var(--space-4); display: flex; flex-direction: column; align-items: center; - gap: var(--spacing-sm); - transition: border-color 0.2s ease; + gap: var(--space-3); + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +/* Card entrance animation */ +.participant-card { + animation: tune-in 500ms ease-out both; +} + +@keyframes tune-in { + from { + opacity: 0; + transform: translateY(8px); + filter: blur(4px); + } + to { + opacity: 1; + transform: translateY(0); + filter: blur(0); + } +} + +/* Card exit animation */ +.participant-card.leaving { + animation: tune-out 400ms ease-in forwards; +} + +@keyframes tune-out { + to { + opacity: 0; + transform: scale(0.95); + filter: blur(4px); + } +} + +/* Broadcasting state — red left accent */ +body.broadcasting .participant-card { + border-left-color: var(--signal-red); +} + +/* Speaking state */ +.participant-card.speaking { + border-color: var(--signal-amber); + box-shadow: 0 0 12px var(--signal-amber-glow); } .participant-card:hover { - border-color: var(--color-text-secondary); + border-color: var(--border-strong); } +/* Avatar */ .participant-avatar { - width: 80px; - height: 80px; + width: 48px; + height: 48px; border-radius: 50%; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background-color: var(--surface-raised); display: flex; align-items: center; justify-content: center; - font-size: 2rem; + font-family: var(--font-display); + font-size: 1.125rem; font-weight: 600; - color: var(--color-text); + color: var(--signal-amber); text-transform: uppercase; + border: 2px solid transparent; + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +.participant-card.speaking .participant-avatar { + border-color: var(--signal-amber); + box-shadow: 0 0 8px var(--signal-amber-glow); +} + +/* Muted avatar dims */ +.participant-card.is-muted .participant-avatar { + opacity: 0.4; } .participant-name { - font-size: 1rem; + font-family: var(--font-body); + font-size: var(--text-sm); font-weight: 500; text-align: center; + color: var(--text-primary); } +/* Role badges */ .participant-role { - font-size: 0.75rem; - color: var(--color-text-secondary); + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 500; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.08em; + padding: 2px 8px; + border-radius: 2px; + border: 1px solid var(--text-tertiary); + color: var(--text-secondary); +} + +.participant-role.role-host { + color: var(--signal-red); + border-color: var(--signal-red); +} + +.participant-role.role-ops { + color: var(--signal-amber); + border-color: var(--signal-amber); +} + +.participant-role.role-guest { + color: var(--text-secondary); + border-color: var(--text-tertiary); } /* Per-participant level meter */ .participant-level-meter { - width: 150px; - height: 20px; - border-radius: var(--radius-sm); - border: 1px solid var(--color-border); - margin-top: var(--spacing-xs); + width: 100%; + height: 4px; + border-radius: 1px; display: block; + margin-top: var(--space-1); } .participant-status { display: flex; align-items: center; - gap: var(--spacing-xs); - font-size: 0.875rem; - color: var(--color-text-secondary); + gap: var(--space-2); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-tertiary); } .participant-status .icon { @@ -193,633 +451,605 @@ main { width: 100%; display: flex; flex-direction: column; - gap: var(--spacing-xs); - padding: var(--spacing-sm) 0; + gap: var(--space-2); + padding: var(--space-2) 0; } .mute-button { width: 100%; - padding: var(--spacing-xs) var(--spacing-sm); - border-radius: var(--radius-sm); - font-size: 0.75rem; + padding: var(--space-2) var(--space-3); + border-radius: 2px; + font-family: var(--font-display); + font-size: var(--text-xs); font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; min-width: auto; - background-color: #10b981; /* Green for unmuted */ - color: var(--color-button-text); - border: none; + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); cursor: pointer; transition: all 0.2s ease; } .mute-button:hover { - background-color: #059669; /* Darker green on hover */ + border-color: var(--border-strong); + color: var(--text-primary); } -/* Self-muted state (participant muted themselves) */ +/* Self-muted state */ .mute-button.self-muted { - background-color: #f59e0b; /* Yellow/orange */ - color: var(--color-button-text); + background-color: transparent; + color: var(--signal-amber); + border-color: var(--signal-amber); } .mute-button.self-muted:hover { - background-color: #d97706; /* Darker orange on hover */ + background-color: var(--signal-amber-glow); } -/* Producer-muted state (host muted the participant) */ +/* Producer-muted state */ .mute-button.producer-muted { - background-color: #ef4444; /* Red */ - color: var(--color-button-text); - cursor: not-allowed; /* Participant cannot unmute themselves */ + background-color: transparent; + color: var(--signal-red); + border-color: var(--signal-red); + cursor: not-allowed; } .mute-button.producer-muted:hover { - background-color: #dc2626; /* Darker red on hover */ + background-color: var(--signal-red-glow); } .gain-control { display: flex; align-items: center; - gap: var(--spacing-xs); + gap: var(--space-2); width: 100%; } +/* Gain slider — fader style */ .gain-slider { flex: 1; - height: 6px; + height: 4px; -webkit-appearance: none; appearance: none; - background: var(--color-border); - border-radius: 3px; + background: var(--border); + border-radius: 1px; outline: none; cursor: pointer; } .gain-slider:disabled { - opacity: 0.5; + opacity: 0.3; cursor: not-allowed; } -/* Slider thumb - Webkit (Chrome, Safari) */ +/* Slider thumb — rectangular fader */ .gain-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--color-button-bg); + width: 14px; + height: 22px; + border-radius: 2px; + background: var(--text-secondary); cursor: pointer; - transition: all 0.2s ease; + transition: background 0.2s ease; } .gain-slider::-webkit-slider-thumb:hover { - background: var(--color-button-bg-hover); - transform: scale(1.1); + background: var(--signal-amber); } .gain-slider:disabled::-webkit-slider-thumb { - background: var(--color-button-disabled-bg); + background: var(--text-ghost); cursor: not-allowed; } -/* Slider thumb - Firefox */ +/* Firefox */ .gain-slider::-moz-range-thumb { - width: 16px; - height: 16px; + width: 14px; + height: 22px; border: none; - border-radius: 50%; - background: var(--color-button-bg); + border-radius: 2px; + background: var(--text-secondary); cursor: pointer; - transition: all 0.2s ease; + transition: background 0.2s ease; } .gain-slider::-moz-range-thumb:hover { - background: var(--color-button-bg-hover); - transform: scale(1.1); + background: var(--signal-amber); } .gain-slider:disabled::-moz-range-thumb { - background: var(--color-button-disabled-bg); + background: var(--text-ghost); cursor: not-allowed; } -/* Slider track - Firefox */ .gain-slider::-moz-range-track { - background: var(--color-border); - border-radius: 3px; + background: var(--border); + border-radius: 1px; } .gain-value { - font-size: 0.75rem; + font-family: var(--font-mono); + font-size: var(--text-xs); font-weight: 500; - color: var(--color-text-secondary); - min-width: 40px; + color: var(--text-tertiary); + min-width: 36px; text-align: right; } -/* Empty state for participants */ -#participants:empty::after { - content: 'No participants yet. Start a session to begin.'; - grid-column: 1 / -1; - text-align: center; - color: var(--color-text-secondary); - padding: var(--spacing-xl); - font-size: 1rem; +/* ========================================== + SIGNAL OUTPUT — PROGRAM BUS + ========================================== */ + +.signal-output { + grid-area: bus; } -/* Controls section */ -#controls { - background-color: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--spacing-md); +.meter-label { + font-family: var(--font-display); + font-size: var(--text-xs); + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: var(--space-2); +} + +.meter-container { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + padding: var(--space-3); display: flex; - gap: var(--spacing-sm); + flex-direction: column; + gap: var(--space-2); +} + +.waveform-canvas { + width: 100%; + height: 48px; + display: block; + border-radius: 2px; +} + +#volume-meter { + width: 100%; + height: 24px; + display: block; + border-radius: 2px; +} + +/* ========================================== + TRANSPORT — BROADCAST CONTROLS + ========================================== */ + +.transport { + grid-area: transport; + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + padding: var(--space-3) var(--space-4); + display: flex; + gap: var(--space-3); justify-content: center; + align-items: center; flex-wrap: wrap; } -/* Buttons */ +/* Base button reset */ button { - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--radius-md); - font-weight: 500; - font-size: 0.875rem; + font-family: var(--font-display); + font-size: var(--text-sm); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + border-radius: 2px; + padding: var(--space-2) var(--space-5); transition: all 0.2s ease; - min-width: 120px; + min-width: 0; + cursor: pointer; } -button:not(:disabled) { - background-color: var(--color-button-bg); - color: var(--color-button-text); +button:disabled { + opacity: 0.3; + cursor: not-allowed; } -button:not(:disabled):hover { - background-color: var(--color-button-bg-hover); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +/* Broadcast button — the primary control */ +.btn-broadcast { + background-color: transparent; + color: var(--signal-amber); + border: 2px solid var(--signal-amber); + padding: var(--space-3) var(--space-8); + font-size: var(--text-sm); + position: relative; } -button:not(:disabled):active { - transform: translateY(0); - box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2); +.btn-broadcast:not(:disabled):hover { + background-color: var(--signal-amber-glow); + box-shadow: 0 0 20px var(--signal-amber-glow); } -button:disabled { - background-color: var(--color-button-disabled-bg); - color: var(--color-button-disabled-text); - cursor: not-allowed; - opacity: 0.6; +.btn-broadcast:not(:disabled):active { + transform: scale(0.98); } -/* Specific button styles */ -#start-session { - --color-button-bg: #10b981; - --color-button-bg-hover: #059669; +/* End broadcast — understated to prevent accidental stops */ +.btn-end { + background-color: transparent; + color: var(--text-tertiary); + border: 1px solid var(--border); } -#end-session { - --color-button-bg: #ef4444; - --color-button-bg-hover: #dc2626; +.btn-end:not(:disabled):hover { + color: var(--signal-red); + border-color: var(--signal-red); } -#toggle-mute.muted { - --color-button-bg: #f59e0b; - --color-button-bg-hover: #d97706; +/* Ghost buttons */ +.btn-ghost { + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); } -/* Responsive design */ -@media (max-width: 768px) { - header { - padding: 0 var(--spacing-md); - } +.btn-ghost:not(:disabled):hover { + border-color: var(--border-strong); + color: var(--text-primary); +} - header h1 { - font-size: 1.25rem; - } +/* Mute button states in transport */ +#toggle-mute.muted { + color: var(--signal-amber); + border-color: var(--signal-amber); +} - main { - padding: var(--spacing-md); - } +/* Record button with dot indicator */ +.btn-record { + position: relative; + padding-left: calc(var(--space-5) + 10px); +} - #participants { - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: var(--spacing-sm); - } +.btn-record::before { + content: ''; + position: absolute; + left: var(--space-3); + top: 50%; + transform: translateY(-50%); + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--text-tertiary); + transition: background-color 0.2s ease; +} - #controls { - flex-direction: column; - } +.btn-record.recording::before { + background-color: var(--signal-red); + animation: rec-blink 1s steps(1) infinite; +} - button { - width: 100%; - } +@keyframes rec-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } } -@media (max-width: 480px) { - header { - padding: 0 var(--spacing-sm); - } +/* Stream button */ +.btn-stream.streaming { + color: var(--status-connected); + border-color: var(--status-connected); +} - main { - padding: var(--spacing-sm); - } +/* ========================================== + DECK PANELS — Collapsible Sections + ========================================== */ - #participants { - grid-template-columns: 1fr; - } +.deck-panel { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; } -/* Program Bus Volume Meter */ -#program-bus-section { - max-width: var(--max-width); - margin: var(--spacing-md) auto; - padding: 0 var(--spacing-md); +.deck-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-4); + cursor: pointer; + user-select: none; } -.meter-label { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text); - margin-bottom: var(--spacing-xs); - text-transform: uppercase; - letter-spacing: 0.05em; +.deck-header:hover { + background-color: var(--surface-raised); } -.meter-container { - background-color: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--spacing-sm); +.deck-summary { display: flex; align-items: center; - justify-content: center; + gap: var(--space-3); } -#volume-meter { - display: block; - border-radius: var(--radius-sm); - background-color: var(--color-bg); +.deck-label { + font-family: var(--font-display); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-tertiary); } -/* Responsive adjustments for meter */ -@media (max-width: 768px) { - #program-bus-section { - padding: 0 var(--spacing-sm); - } +.deck-toggle { + background: none; + border: none; + color: var(--text-tertiary); + font-size: var(--text-xs); + padding: var(--space-1); + cursor: pointer; + transition: transform 0.3s ease; + min-width: 0; + letter-spacing: 0; + text-transform: none; +} - #volume-meter { - width: 100%; - max-width: 400px; - } +.deck-panel.collapsed .deck-toggle { + transform: rotate(-90deg); } -@media (max-width: 480px) { - .meter-container { - padding: var(--spacing-xs); - } +.deck-body { + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--border); + transition: max-height 0.3s ease, opacity 0.3s ease; + max-height: 500px; + opacity: 1; +} - #volume-meter { - width: 100%; - } +.deck-panel.collapsed .deck-body { + max-height: 0; + opacity: 0; + padding: 0 var(--space-4); + border-top: none; + overflow: hidden; } -/* ========================= - Icecast Streaming Section - ========================= */ +/* Recording indicator */ +.recording-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--text-ghost); + transition: background-color 0.2s ease; + flex-shrink: 0; +} -#streaming-section { - max-width: var(--max-width); - margin: 0 auto var(--spacing-lg) auto; - padding: 0 var(--spacing-lg); +.recording-indicator.active { + background-color: var(--signal-red); + animation: rec-blink 1s steps(1) infinite; } -.streaming-header { +.recording-timer { + font-family: var(--font-mono); + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-tertiary); + letter-spacing: 0.05em; +} + +.recording-timer.active { + color: var(--signal-red); + text-shadow: 0 0 8px var(--signal-red-glow); +} + +.recording-controls { display: flex; align-items: center; - justify-content: space-between; - margin-bottom: var(--spacing-sm); + gap: var(--space-3); } -.streaming-label { - font-size: 0.875rem; +.recording-tracks { + margin-top: var(--space-3); +} + +.recording-tracks h4 { + font-family: var(--font-display); + font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-secondary); + letter-spacing: 0.08em; + margin-bottom: var(--space-2); + color: var(--text-tertiary); } -.streaming-status { - font-size: 0.875rem; - font-weight: 500; - padding: var(--spacing-xs) var(--spacing-sm); - border-radius: var(--radius-sm); - background-color: var(--color-bg-secondary); - color: var(--color-text-secondary); +.recording-track-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border); } -.streaming-status.streaming { - background-color: #10b981; - color: #ffffff; +.recording-track-item:last-child { + border-bottom: none; } -.streaming-status.connecting { - background-color: #f59e0b; - color: #ffffff; +.recording-track-name { + font-family: var(--font-body); + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-primary); } -.streaming-status.reconnecting { - background-color: #f59e0b; - color: #ffffff; - animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +.recording-track-size { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-tertiary); } -.streaming-status.error { - background-color: #ef4444; - color: #ffffff; +.recording-track-download { + font-family: var(--font-display); + font-size: var(--text-xs); + padding: 3px 10px; + border-radius: 2px; + background-color: transparent; + color: var(--data-cyan-dim); + border: 1px solid var(--data-cyan-dim); + cursor: pointer; + letter-spacing: 0.06em; + text-transform: uppercase; } -.streaming-controls { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-md); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background-color: var(--color-bg-secondary); - margin-bottom: var(--spacing-sm); +.recording-track-download:hover { + background-color: var(--data-cyan-glow); + color: var(--data-cyan); } -#start-streaming, -#stop-streaming { - padding: var(--spacing-sm) var(--spacing-md); - border: none; - border-radius: var(--radius-sm); - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - background-color: #10b981; - color: #ffffff; +/* ========================================== + STREAMING + ========================================== */ + +.streaming-status { + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 500; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; } -#start-streaming:hover:not(:disabled) { - background-color: #059669; +.streaming-status.streaming { + color: var(--status-connected); } -#stop-streaming { - background-color: #ef4444; +.streaming-status.connecting, +.streaming-status.reconnecting { + color: var(--status-connecting); } -#stop-streaming:hover:not(:disabled) { - background-color: #dc2626; +.streaming-status.reconnecting { + animation: pulse-status 2s ease-in-out infinite; } -#start-streaming:disabled, -#stop-streaming:disabled { - background-color: var(--color-button-disabled-bg); - color: var(--color-button-disabled-text); - cursor: not-allowed; +.streaming-status.error { + color: var(--status-error); +} + +.streaming-controls { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-3); } .streaming-config { display: flex; align-items: center; - gap: var(--spacing-xs); - margin-left: auto; + gap: var(--space-2); } .streaming-config label { - font-size: 0.875rem; + font-family: var(--font-display); + font-size: var(--text-xs); font-weight: 500; - color: var(--color-text-secondary); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; } #bitrate-select { - padding: var(--spacing-xs) var(--spacing-sm); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background-color: var(--color-bg); - color: var(--color-text); - font-size: 0.875rem; + padding: var(--space-1) var(--space-3); + border: 1px solid var(--border); + border-radius: 2px; + background-color: var(--surface-raised); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: var(--text-xs); cursor: pointer; transition: border-color 0.2s ease; } #bitrate-select:hover { - border-color: var(--color-button-bg); + border-color: var(--border-strong); } #bitrate-select:focus { outline: none; - border-color: var(--color-button-bg); + border-color: var(--signal-amber); } .streaming-info { - padding: var(--spacing-sm) var(--spacing-md); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background-color: var(--color-bg-secondary); + padding: var(--space-2) 0; } .streaming-url { - font-size: 0.875rem; - color: var(--color-text-secondary); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-tertiary); } .streaming-url a { - color: var(--color-button-bg); + color: var(--data-cyan-dim); text-decoration: none; - margin-left: var(--spacing-xs); + margin-left: var(--space-2); font-weight: 500; + padding: 2px 6px; + background-color: var(--surface-raised); + border-radius: 2px; } .streaming-url a:hover { - text-decoration: underline; + color: var(--data-cyan); + background-color: var(--surface-elevated); } -/* Responsive adjustments for streaming section */ +/* ========================================== + RESPONSIVE + ========================================== */ + @media (max-width: 768px) { - #streaming-section { - padding: 0 var(--spacing-sm); + header { + padding: 0 var(--space-4); + grid-template-columns: auto 1fr auto; + gap: var(--space-2); } - .streaming-controls { - flex-direction: column; - align-items: stretch; + .tagline { + display: none; } - .streaming-config { - margin-left: 0; - justify-content: space-between; + main { + padding: var(--space-4); + gap: var(--space-4); } - #start-streaming, - #stop-streaming { - width: 100%; + .stage { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--space-3); } -} -@media (max-width: 480px) { - .streaming-header { + .transport { flex-direction: column; - align-items: flex-start; - gap: var(--spacing-xs); } - .streaming-controls { - padding: var(--spacing-sm); + .transport button { + width: 100%; } +} - .streaming-info { - padding: var(--spacing-sm); +@media (max-width: 480px) { + header { + padding: 0 var(--space-3); } - .streaming-url { - word-break: break-all; + main { + padding: var(--space-3); } -} - -/* ========================= - Recording Section - ========================= */ - -#recording-section { - max-width: var(--max-width); - margin: 0 auto var(--spacing-lg) auto; - padding: 0 var(--spacing-lg); -} - -.recording-header { - display: flex; - align-items: center; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-sm); -} -.recording-label { - font-size: 0.875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-secondary); -} - -.recording-indicator { - width: 12px; - height: 12px; - border-radius: 50%; - background-color: var(--color-border); - transition: background-color 0.3s ease; -} - -.recording-indicator.active { - background-color: #ef4444; - animation: pulse 1.5s ease-in-out infinite; -} - -.recording-timer { - font-size: 1.25rem; - font-weight: 600; - font-family: 'SF Mono', 'Fira Code', monospace; - color: var(--color-text-secondary); - min-width: 80px; -} - -.recording-timer.active { - color: #ef4444; -} - -.recording-controls { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-md); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background-color: var(--color-bg-secondary); - margin-bottom: var(--spacing-sm); -} - -#start-recording { - background-color: #ef4444; - color: #ffffff; -} - -#start-recording:hover:not(:disabled) { - background-color: #dc2626; -} - -#stop-recording { - background-color: var(--color-button-bg); - color: #ffffff; -} - -#download-recordings { - background-color: #10b981; - color: #ffffff; -} - -#download-recordings:hover:not(:disabled) { - background-color: #059669; -} - -.recording-tracks { - padding: var(--spacing-sm) var(--spacing-md); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background-color: var(--color-bg-secondary); -} - -.recording-tracks h4 { - font-size: 0.875rem; - font-weight: 600; - margin-bottom: var(--spacing-xs); - color: var(--color-text-secondary); -} - -.recording-track-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--spacing-xs) 0; - border-bottom: 1px solid var(--color-border); -} - -.recording-track-item:last-child { - border-bottom: none; -} - -.recording-track-name { - font-size: 0.875rem; - font-weight: 500; -} - -.recording-track-size { - font-size: 0.75rem; - color: var(--color-text-secondary); -} - -.recording-track-download { - font-size: 0.75rem; - padding: 4px 8px; - border-radius: var(--radius-sm); - background-color: var(--color-button-bg); - color: #ffffff; - border: none; - cursor: pointer; -} - -.recording-track-download:hover { - background-color: var(--color-button-bg-hover); -} - -/* Recording responsive */ -@media (max-width: 768px) { - #recording-section { - padding: 0 var(--spacing-sm); + .stage { + grid-template-columns: 1fr; } - .recording-controls { + .streaming-controls { flex-direction: column; align-items: stretch; } diff --git a/web/index.html b/web/index.html index 384cc59..45f427f 100644 --- a/web/index.html +++ b/web/index.html @@ -7,6 +7,10 @@ OpenStudio - Web Studio + + + + @@ -15,72 +19,89 @@ - +
-

OpenStudio

-
Disconnected
+

OPENSTUDIO

+ talk hard. +
+
Disconnected
+
+
+ +
- -
+ +
- -
- - - + +
+
SIGNAL OUTPUT
+
+ + +
- -
-
-
Recording
-
-
00:00:00
-
-
- - - -
- + +
+ + + + + + +
- -
-
Program Bus
-
- + +
+
+
+
+ REC + 00:00:00 +
+ +
+
+
+ +
+
- -
-
-
Icecast Streaming
-
Not Streaming
-
-
- - -
- - + +
+
+
+ TRANSMIT + Not Streaming
+
-
-
Stream URL: /stream/live.opus
+
+
+
+ + +
+
+
+
Stream URL: /stream/live.opus
+
diff --git a/web/js/main.js b/web/js/main.js index 7d994b4..664a8cc 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -67,6 +67,7 @@ class OpenStudioApp { this.recordingIndicator = document.getElementById('recording-indicator'); this.recordingTimer = document.getElementById('recording-timer'); this.recordingTracksDiv = document.getElementById('recording-tracks'); + this.sessionInfoElement = document.getElementById('session-info'); // Bind event handlers this.setupSignalingListeners(); @@ -74,6 +75,7 @@ class OpenStudioApp { this.setupStreamingListeners(); this.setupRecordingListeners(); this.setupUIListeners(); + this.setupDeckPanels(); // Check for room ID in URL hash this.checkUrlHash(); @@ -98,9 +100,6 @@ class OpenStudioApp { } } - /** - * Initialize app: connect signaling, fetch ICE config, setup audio - */ /** * Initialize app: connect signaling, fetch ICE config, setup audio. * @returns {Promise} @@ -125,6 +124,12 @@ class OpenStudioApp { this.volumeMeter = new VolumeMeter(canvasElement, analyser); console.log('[App] Volume meter initialized'); + // Initialize waveform display + const waveformCanvas = document.getElementById('waveform-display'); + if (waveformCanvas) { + this.waveformMeter = new VolumeMeter(waveformCanvas, analyser, { mode: 'waveform' }); + } + // Expose volume meter and mute manager for debugging (localhost only) if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { window.volumeMeter = this.volumeMeter; @@ -151,6 +156,21 @@ class OpenStudioApp { } } + /** + * Setup collapsible deck panel behavior + */ + setupDeckPanels() { + document.querySelectorAll('.deck-header').forEach(header => { + // Start collapsed + header.closest('.deck-panel').classList.add('collapsed'); + + header.addEventListener('click', () => { + const panel = header.closest('.deck-panel'); + panel.classList.toggle('collapsed'); + }); + }); + } + /** * Setup signaling event listeners */ @@ -200,10 +220,12 @@ class OpenStudioApp { this.startRecordingButton.disabled = false; // Add self to participants with role from server - const displayName = this.currentRole === 'host' ? 'Host (You)' : - this.currentRole === 'ops' ? 'Ops (You)' : 'You'; + const displayName = this.getRoleDisplayName(this.currentRole, true); this.addParticipant(this.peerId, displayName, this.currentRole); + // Update session info + this.updateSessionInfo(); + // Get local media stream try { await this.rtc.getLocalStream(); @@ -243,17 +265,18 @@ class OpenStudioApp { // Add existing participants participants.forEach(p => { if (p.peerId !== this.peerId) { - const displayName = p.role === 'host' ? 'Host' : - p.role === 'ops' ? 'Ops' : 'Guest'; + const displayName = this.getRoleDisplayName(p.role, false); this.addParticipant(p.peerId, displayName, p.role); } }); // Add self with role from server - const displayName = this.currentRole === 'host' ? 'Host (You)' : - this.currentRole === 'ops' ? 'Ops (You)' : 'You'; + const displayName = this.getRoleDisplayName(this.currentRole, true); this.addParticipant(this.peerId, displayName, this.currentRole); + // Update session info + this.updateSessionInfo(); + // Get local media stream try { await this.rtc.getLocalStream(); @@ -277,8 +300,7 @@ class OpenStudioApp { const { peerId, role } = event.detail; console.log(`[App] Peer joined: ${peerId} (${role})`); - const displayName = role === 'host' ? 'Host' : - role === 'ops' ? 'Ops' : 'Guest'; + const displayName = this.getRoleDisplayName(role, false); this.addParticipant(peerId, displayName, role); // ConnectionManager automatically handles connection initiation via peer-joined event }); @@ -337,6 +359,30 @@ class OpenStudioApp { }); } + /** + * Get role display name for Signal design + * @param {string} role - 'host', 'ops', or 'guest' + * @param {boolean} isSelf - Whether this is the local user + * @returns {string} + */ + getRoleDisplayName(role, isSelf) { + const suffix = isSelf ? ' (You)' : ''; + switch (role) { + case 'host': return `Host${suffix}`; + case 'ops': return `Engineer${suffix}`; + default: return isSelf ? 'You' : 'Caller'; + } + } + + /** + * Update session info display + */ + updateSessionInfo() { + if (this.sessionInfoElement && this.currentRoom) { + this.sessionInfoElement.textContent = this.currentRoom.substring(0, 8); + } + } + /** * Setup MuteManager event listeners */ @@ -364,7 +410,7 @@ class OpenStudioApp { console.log(`[App] Streaming status: ${status} - ${message}`); // Update streaming status UI - this.streamingStatusElement.textContent = message; + this.streamingStatusElement.textContent = status === 'streaming' ? 'TRANSMITTING' : message; this.streamingStatusElement.className = `streaming-status ${status}`; // Update button states based on status @@ -425,6 +471,13 @@ class OpenStudioApp { this.stopRecordingButton.disabled = false; this.downloadRecordingsButton.style.display = 'none'; this.recordingTracksDiv.style.display = 'none'; + + // Add recording class to record button for dot indicator + this.startRecordingButton.classList.add('recording'); + + // Expand recording deck panel + const recPanel = document.getElementById('recording-section'); + if (recPanel) recPanel.classList.remove('collapsed'); }); this.recordingManager.addEventListener('recording-stopped', () => { @@ -432,6 +485,7 @@ class OpenStudioApp { this.recordingTimer.classList.remove('active'); this.stopRecordingButton.style.display = 'none'; this.startRecordingButton.style.display = 'inline-block'; + this.startRecordingButton.classList.remove('recording'); if (this.currentRoom) { this.startRecordingButton.disabled = false; } @@ -576,9 +630,6 @@ class OpenStudioApp { }); } - /** - * Try to send pending return feed for a peer if connection is ready - */ /** * Send pending return feed for a peer if the RTCPeerConnection is connected. * @param {string} remotePeerId @@ -727,6 +778,15 @@ class OpenStudioApp { console.log('[App] Volume meter started'); } + // Start waveform display + if (this.waveformMeter) { + this.waveformMeter.start(); + console.log('[App] Waveform display started'); + } + + // Add broadcasting class to body + document.body.classList.add('broadcasting'); + // Use create-or-join-room logic if (this.roomIdFromUrl) { // Room ID in URL - create or join it with role from URL (or invite token) @@ -748,6 +808,9 @@ class OpenStudioApp { if (roomId) { console.log(`[App] Joining room: ${roomId} as guest`); this.signaling.createOrJoinRoom(roomId, 'guest'); + } else { + // User cancelled — remove broadcasting class + document.body.classList.remove('broadcasting'); } } } @@ -759,6 +822,9 @@ class OpenStudioApp { handleEndSession() { console.log('[App] Ending session...'); + // Remove broadcasting class + document.body.classList.remove('broadcasting'); + // Stop recording if active if (this.recordingManager.isRecording) { this.recordingManager.stopAll(); @@ -776,6 +842,21 @@ class OpenStudioApp { console.log('[App] Volume meter stopped'); } + // Stop waveform display + if (this.waveformMeter) { + this.waveformMeter.stop(); + } + + // Disconnect local mic from program bus + if (this._localAudioNodes) { + try { + this._localAudioNodes.compressor.disconnect(); + this._localAudioNodes.analyser.disconnect(); + this._localAudioNodes.source.disconnect(); + } catch (e) { /* already disconnected */ } + this._localAudioNodes = null; + } + // Clear audio graph this.audioGraph.clearAll(); @@ -810,6 +891,8 @@ class OpenStudioApp { this.startSessionButton.disabled = false; this.endSessionButton.disabled = true; this.toggleMuteButton.disabled = true; + this.toggleMuteButton.classList.remove('muted'); + this.toggleMuteButton.textContent = 'Mute'; this.startStreamingButton.disabled = true; this.stopStreamingButton.disabled = true; this.startStreamingButton.style.display = 'inline-block'; @@ -817,6 +900,7 @@ class OpenStudioApp { this.streamingStatusElement.textContent = 'Not Streaming'; this.streamingStatusElement.className = 'streaming-status'; this.startRecordingButton.disabled = true; + this.startRecordingButton.classList.remove('recording'); this.stopRecordingButton.disabled = true; this.startRecordingButton.style.display = 'inline-block'; this.stopRecordingButton.style.display = 'none'; @@ -826,6 +910,7 @@ class OpenStudioApp { this.recordingTimer.textContent = '00:00:00'; this.recordingTracksDiv.style.display = 'none'; this.lastRecordings = null; + if (this.sessionInfoElement) this.sessionInfoElement.textContent = ''; window.location.hash = ''; this.startSessionButton.textContent = 'Start Broadcast'; this.endSessionButton.textContent = 'End Broadcast'; @@ -851,9 +936,11 @@ class OpenStudioApp { if (audioTrack.enabled) { this.toggleMuteButton.textContent = 'Mute'; + this.toggleMuteButton.classList.remove('muted'); console.log('[App] Unmuted microphone'); } else { this.toggleMuteButton.textContent = 'Unmute'; + this.toggleMuteButton.classList.add('muted'); console.log('[App] Muted microphone'); } } @@ -1112,20 +1199,22 @@ class OpenStudioApp { // Update button text and class based on mute state muteButton.classList.remove('self-muted', 'producer-muted'); + card.classList.remove('is-muted'); if (muteState.muted) { + card.classList.add('is-muted'); if (muteState.authority === 'producer') { - muteButton.textContent = '🔇 Muted (Host)'; + muteButton.textContent = 'MUTED (HOST)'; muteButton.classList.add('producer-muted'); } else { - muteButton.textContent = '🔇 Muted'; + muteButton.textContent = 'MUTED'; muteButton.classList.add('self-muted'); } if (gainSlider) { gainSlider.disabled = true; } } else { - muteButton.textContent = '🔊 Unmuted'; + muteButton.textContent = 'UNMUTED'; if (gainSlider) { gainSlider.disabled = false; } @@ -1137,9 +1226,6 @@ class OpenStudioApp { } } - /** - * Handle gain slider change - */ /** * Handle gain slider change with smooth AudioParam ramping. * @param {string} peerId - Participant whose gain to adjust @@ -1198,9 +1284,6 @@ class OpenStudioApp { this.statusElement.textContent = text; } - /** - * Add participant to UI - */ /** * Add participant to UI with avatar, role badge, level meter, and controls. * @param {string} peerId - Unique peer identifier @@ -1233,13 +1316,14 @@ class OpenStudioApp { const roleEl = document.createElement('div'); roleEl.className = `participant-role role-${role}`; - roleEl.textContent = role.charAt(0).toUpperCase() + role.slice(1); + roleEl.textContent = role === 'host' ? 'HOST' : + role === 'ops' ? 'ENGINEER' : 'CALLER'; // Per-participant level meter const levelMeterCanvas = document.createElement('canvas'); levelMeterCanvas.className = 'participant-level-meter'; - levelMeterCanvas.width = 150; - levelMeterCanvas.height = 20; + levelMeterCanvas.width = 200; + levelMeterCanvas.height = 4; levelMeterCanvas.dataset.peerId = peerId; // For debugging // Gain controls (only for remote participants, not self) @@ -1251,7 +1335,7 @@ class OpenStudioApp { // Mute button const muteButton = document.createElement('button'); muteButton.className = 'mute-button'; - muteButton.textContent = '🔊 Unmuted'; + muteButton.textContent = 'UNMUTED'; muteButton.addEventListener('click', () => this.handleParticipantMute(peerId)); // Gain control container @@ -1289,7 +1373,7 @@ class OpenStudioApp { // Mute button for self-mute const muteButton = document.createElement('button'); muteButton.className = 'mute-button'; - muteButton.textContent = '🔊 Unmuted'; + muteButton.textContent = 'UNMUTED'; muteButton.addEventListener('click', () => this.handleParticipantMute(peerId)); controlsEl.appendChild(muteButton); @@ -1303,7 +1387,7 @@ class OpenStudioApp { const statusEl = document.createElement('div'); statusEl.className = 'participant-status'; - statusEl.innerHTML = '🎙️Ready'; + statusEl.innerHTML = 'Ready'; card.appendChild(statusEl); @@ -1311,10 +1395,7 @@ class OpenStudioApp { } /** - * Remove participant from UI - */ - /** - * Remove participant from UI and clean up level meter. + * Remove participant from UI with exit animation. * @param {string} peerId */ removeParticipant(peerId) { @@ -1330,13 +1411,12 @@ class OpenStudioApp { const card = this.participantsSection.querySelector(`[data-peer-id="${peerId}"]`); if (card) { - card.remove(); + // Animate exit + card.classList.add('leaving'); + setTimeout(() => card.remove(), 400); } } - /** - * Create per-participant level meter - */ /** * Create per-participant level meter using AudioGraph's AnalyserNode. * @param {string} peerId - Remote participant to create meter for @@ -1363,8 +1443,16 @@ class OpenStudioApp { } try { - // Create and start the volume meter - const volumeMeter = new VolumeMeter(canvas, analyser); + // Create and start the volume meter with speaking detection callback + const volumeMeter = new VolumeMeter(canvas, analyser, { + onSpeaking: (speaking) => { + if (speaking) { + card.classList.add('speaking'); + clearTimeout(card._speakingTimeout); + card._speakingTimeout = setTimeout(() => card.classList.remove('speaking'), 300); + } + } + }); volumeMeter.start(); this.participantMeters.set(peerId, volumeMeter); console.log(`[App] Created level meter for ${peerId}`); @@ -1409,20 +1497,38 @@ class OpenStudioApp { analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.3; - // Create ultra-low-gain node for Safari compatibility - // Safari requires MediaStreamSources to be connected to destination - // AND suspends AudioContext when gain = 0, so use 0.001 instead - const ultraLowGain = audioContext.createGain(); - ultraLowGain.gain.value = 0.001; // Nearly silent - prevents Safari suspension - - // Connect: source → analyser → ultraLowGain → destination - // This keeps Safari's AudioContext active while being inaudible + // Connect source → analyser for level metering source.connect(analyser); - analyser.connect(ultraLowGain); - ultraLowGain.connect(audioContext.destination); - // Create and start the volume meter - const volumeMeter = new VolumeMeter(canvas, analyser); + // Route local mic into program bus so Signal Output shows the full broadcast mix. + // Use a compressor matching remote participant chain for consistent levels. + const compressor = audioContext.createDynamicsCompressor(); + compressor.threshold.value = -24; + compressor.knee.value = 30; + compressor.ratio.value = 12; + compressor.attack.value = 0.003; + compressor.release.value = 0.25; + analyser.connect(compressor); + + const programBus = this.audioGraph.getProgramBus(); + if (programBus) { + programBus.connectParticipant(compressor, this.peerId); + console.log('[App] Local mic connected to program bus'); + } + + // Store compressor ref for cleanup + this._localAudioNodes = { source, analyser, compressor }; + + // Create and start the volume meter with speaking detection + const volumeMeter = new VolumeMeter(canvas, analyser, { + onSpeaking: (speaking) => { + if (speaking) { + card.classList.add('speaking'); + clearTimeout(card._speakingTimeout); + card._speakingTimeout = setTimeout(() => card.classList.remove('speaking'), 300); + } + } + }); volumeMeter.start(); this.participantMeters.set(this.peerId, volumeMeter); console.log('[App] Created level meter for local participant (self)'); diff --git a/web/js/volume-meter.js b/web/js/volume-meter.js index c197329..cad6f97 100644 --- a/web/js/volume-meter.js +++ b/web/js/volume-meter.js @@ -2,12 +2,17 @@ * volume-meter.js * Real-time volume meter visualization for OpenStudio * - * Displays audio levels from an AnalyserNode using canvas-based rendering. - * Color-coded levels: green (safe), yellow (warning), red (clipping). + * Two modes: + * - 'meter' (default): Segmented LED bar meter with amber/red color ramp + * - 'waveform': Oscilloscope-style time-domain waveform display + * + * Options: + * - mode: 'meter' | 'waveform' + * - onSpeaking: callback(isSpeaking) for speaking detection */ export class VolumeMeter { - constructor(canvasElement, analyserNode) { + constructor(canvasElement, analyserNode, options = {}) { this.canvas = canvasElement; this.analyser = analyserNode; this.ctx = null; @@ -16,14 +21,38 @@ export class VolumeMeter { this.bufferLength = 0; this.isRunning = false; + // Mode + this.mode = options.mode || 'meter'; + + // Speaking detection callback + this.onSpeaking = options.onSpeaking || null; + this.speakingThreshold = 0.05; + // Peak hold this.peakLevel = 0; this.peakHoldTime = 0; - this.peakHoldDuration = 30; // frames - - // Level thresholds (0.0 to 1.0) - this.warningThreshold = 0.7; // Yellow above this - this.dangerThreshold = 0.9; // Red above this + this.peakHoldDuration = 45; // frames (~1.5s at 30fps effective) + + // Smoothing for meter + this.smoothedLevel = 0; + this.smoothingFactor = 0.3; + + // Colors from Signal design system + this.colors = { + void: '#0a0a0f', + surface: '#12121a', + amber: '#d4a053', + red: '#e23636', + hot: '#ff4444', + ghostSeg: 'rgba(255,255,255,0.03)', + peakWhite: '#f0e8d8', + waveAmber: '#d4a053', + waveRed: '#e23636', + zeroline: '#3a3630', + }; + + // Broadcasting state (for waveform color) + this.isBroadcasting = false; this.initialize(); } @@ -43,11 +72,42 @@ export class VolumeMeter { // Get 2D context this.ctx = this.canvas.getContext('2d'); + // Set fallback display dimensions from canvas attributes + // (HiDPI setup deferred to start() when CSS layout is stable) + this.displayWidth = this.canvas.width; + this.displayHeight = this.canvas.height; + // Set up data array for analyser this.bufferLength = this.analyser.frequencyBinCount; this.dataArray = new Uint8Array(this.bufferLength); - console.log('[VolumeMeter] Initialized with buffer length:', this.bufferLength); + // Time domain data for waveform + if (this.mode === 'waveform') { + this.timeDomainData = new Uint8Array(this.bufferLength); + } + + console.log(`[VolumeMeter] Initialized (${this.mode}) with buffer length:`, this.bufferLength); + } + + /** + * Handle high-DPI canvas scaling + */ + setupHiDPI() { + const dpr = window.devicePixelRatio || 1; + const rect = this.canvas.getBoundingClientRect(); + + // Only scale if we have a valid rect (element is in DOM) + if (rect.width > 0) { + this.canvas.width = rect.width * dpr; + this.canvas.height = rect.height * dpr; + this.ctx.scale(dpr, dpr); + this.displayWidth = rect.width; + this.displayHeight = rect.height; + } else { + // Fallback to canvas attributes + this.displayWidth = this.canvas.width; + this.displayHeight = this.canvas.height; + } } /** @@ -60,6 +120,13 @@ export class VolumeMeter { } this.isRunning = true; + + // Re-setup HiDPI in case canvas was resized + this.setupHiDPI(); + + // Check broadcasting state + this.isBroadcasting = document.body.classList.contains('broadcasting'); + console.log('[VolumeMeter] Starting animation loop'); this.draw(); } @@ -80,7 +147,7 @@ export class VolumeMeter { } // Clear canvas - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.clearRect(0, 0, this.displayWidth, this.displayHeight); console.log('[VolumeMeter] Stopped'); } @@ -96,25 +163,11 @@ export class VolumeMeter { // Schedule next frame this.animationId = requestAnimationFrame(() => this.draw()); - // Get time domain data from analyser - this.analyser.getByteTimeDomainData(this.dataArray); - - // Calculate RMS level - const rms = this.calculateRMS(this.dataArray); - const level = rms / 128.0; // Normalize to 0.0 - 1.0 - - // Update peak hold - if (level > this.peakLevel) { - this.peakLevel = level; - this.peakHoldTime = this.peakHoldDuration; - } else if (this.peakHoldTime > 0) { - this.peakHoldTime--; + if (this.mode === 'waveform') { + this.drawWaveform(); } else { - this.peakLevel = Math.max(0, this.peakLevel - 0.01); // Decay + this.drawSegmentedMeter(); } - - // Draw the meter - this.drawMeter(level, this.peakLevel); } /** @@ -125,67 +178,182 @@ export class VolumeMeter { calculateRMS(data) { let sum = 0; for (let i = 0; i < data.length; i++) { - const normalized = (data[i] - 128) / 128.0; // Convert to -1.0 to 1.0 + const normalized = (data[i] - 128) / 128.0; sum += normalized * normalized; } const rms = Math.sqrt(sum / data.length); - return rms * 128; // Scale back to 0-128 range + return rms * 128; } /** - * Draw the volume meter on canvas - * @param {number} level - Current level (0.0 to 1.0) - * @param {number} peak - Peak level (0.0 to 1.0) + * Draw segmented LED meter */ - drawMeter(level, peak) { - const width = this.canvas.width; - const height = this.canvas.height; + drawSegmentedMeter() { + const width = this.displayWidth; + const height = this.displayHeight; - // Clear canvas - this.ctx.fillStyle = '#1a1a1a'; - this.ctx.fillRect(0, 0, width, height); + // Get time domain data from analyser + this.analyser.getByteTimeDomainData(this.dataArray); + + // Calculate RMS level + const rms = this.calculateRMS(this.dataArray); + const rawLevel = rms / 128.0; + + // Smooth the level + this.smoothedLevel += (rawLevel - this.smoothedLevel) * this.smoothingFactor; + const level = this.smoothedLevel; - // Calculate bar width - const barWidth = width * level; - const peakX = width * peak; + // Speaking detection + if (this.onSpeaking && level > this.speakingThreshold) { + this.onSpeaking(true); + } - // Determine color based on level - let color; - if (level >= this.dangerThreshold) { - color = '#ef4444'; // Red (danger) - } else if (level >= this.warningThreshold) { - color = '#f59e0b'; // Yellow (warning) + // Update peak hold + if (level > this.peakLevel) { + this.peakLevel = level; + this.peakHoldTime = this.peakHoldDuration; + } else if (this.peakHoldTime > 0) { + this.peakHoldTime--; } else { - color = '#10b981'; // Green (safe) + this.peakLevel = Math.max(0, this.peakLevel - 0.008); } - // Draw main level bar - this.ctx.fillStyle = color; - this.ctx.fillRect(0, 0, barWidth, height); + // Determine segment count based on canvas width + const isSmall = width < 100; + const segmentCount = isSmall ? 16 : 32; + const segmentGap = 2; + const segmentRadius = 1; + const segmentWidth = (width - (segmentCount - 1) * segmentGap) / segmentCount; + + // Clear canvas + this.ctx.fillStyle = this.colors.void; + this.ctx.fillRect(0, 0, width, height); - // Draw peak indicator (thin line) - if (peak > 0) { - this.ctx.fillStyle = '#ffffff'; - this.ctx.fillRect(peakX - 2, 0, 4, height); + // Draw segments + const litSegments = Math.floor(level * segmentCount); + const peakSegment = Math.floor(this.peakLevel * segmentCount); + + for (let i = 0; i < segmentCount; i++) { + const x = i * (segmentWidth + segmentGap); + const segmentPosition = i / segmentCount; + + // Determine segment color + let color; + if (i < litSegments) { + if (segmentPosition >= 0.8) { + color = this.colors.red; + } else if (segmentPosition >= 0.6) { + // Transition from amber to red + const t = (segmentPosition - 0.6) / 0.2; + color = this.lerpColor(this.colors.amber, this.colors.red, t); + } else { + color = this.colors.amber; + } + } else if (i === peakSegment && this.peakLevel > 0) { + color = this.colors.peakWhite; + } else { + color = this.colors.ghostSeg; + } + + this.ctx.fillStyle = color; + this.roundRect(x, 0, segmentWidth, height, segmentRadius); } + } - // Draw threshold markers (subtle lines) - this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; - this.ctx.lineWidth = 1; + /** + * Draw waveform oscilloscope + */ + drawWaveform() { + const width = this.displayWidth; + const height = this.displayHeight; + + // Check broadcasting state periodically + this.isBroadcasting = document.body.classList.contains('broadcasting'); + + // Get time domain data + this.analyser.getByteTimeDomainData(this.dataArray); + + // Clear canvas + this.ctx.fillStyle = this.colors.void; + this.ctx.fillRect(0, 0, width, height); - // Warning threshold - const warningX = width * this.warningThreshold; + // Draw center zero-crossing line + this.ctx.strokeStyle = this.colors.zeroline; + this.ctx.lineWidth = 1; this.ctx.beginPath(); - this.ctx.moveTo(warningX, 0); - this.ctx.lineTo(warningX, height); + this.ctx.moveTo(0, height / 2); + this.ctx.lineTo(width, height / 2); this.ctx.stroke(); - // Danger threshold - const dangerX = width * this.dangerThreshold; + // Draw waveform + const waveColor = this.isBroadcasting ? this.colors.waveRed : this.colors.waveAmber; + + this.ctx.strokeStyle = waveColor; + this.ctx.lineWidth = 1.5; + this.ctx.shadowColor = waveColor; + this.ctx.shadowBlur = 4; this.ctx.beginPath(); - this.ctx.moveTo(dangerX, 0); - this.ctx.lineTo(dangerX, height); + + const sliceWidth = width / this.bufferLength; + let x = 0; + + for (let i = 0; i < this.bufferLength; i++) { + const v = this.dataArray[i] / 128.0; + const y = (v * height) / 2; + + if (i === 0) { + this.ctx.moveTo(x, y); + } else { + this.ctx.lineTo(x, y); + } + + x += sliceWidth; + } + this.ctx.stroke(); + + // Reset shadow + this.ctx.shadowColor = 'transparent'; + this.ctx.shadowBlur = 0; + } + + /** + * Draw a rounded rectangle + */ + roundRect(x, y, w, h, r) { + if (r <= 0) { + this.ctx.fillRect(x, y, w, h); + return; + } + this.ctx.beginPath(); + this.ctx.moveTo(x + r, y); + this.ctx.lineTo(x + w - r, y); + this.ctx.quadraticCurveTo(x + w, y, x + w, y + r); + this.ctx.lineTo(x + w, y + h - r); + this.ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + this.ctx.lineTo(x + r, y + h); + this.ctx.quadraticCurveTo(x, y + h, x, y + h - r); + this.ctx.lineTo(x, y + r); + this.ctx.quadraticCurveTo(x, y, x + r, y); + this.ctx.closePath(); + this.ctx.fill(); + } + + /** + * Linear interpolation between two hex colors + */ + lerpColor(a, b, t) { + const ah = parseInt(a.replace('#', ''), 16); + const bh = parseInt(b.replace('#', ''), 16); + + const ar = (ah >> 16) & 0xff, ag = (ah >> 8) & 0xff, ab = ah & 0xff; + const br = (bh >> 16) & 0xff, bg = (bh >> 8) & 0xff, bb = bh & 0xff; + + const rr = Math.round(ar + (br - ar) * t); + const rg = Math.round(ag + (bg - ag) * t); + const rb = Math.round(ab + (bb - ab) * t); + + return `rgb(${rr},${rg},${rb})`; } /** @@ -222,6 +390,7 @@ export class VolumeMeter { this.analyser = null; this.ctx = null; this.dataArray = null; + this.onSpeaking = null; console.log('[VolumeMeter] Destroyed'); } }