From dc3ffe04c4982d21588feee16316eed3bfbd4896 Mon Sep 17 00:00:00 2001 From: Trey Orr Date: Mon, 2 Mar 2026 20:59:58 -0500 Subject: [PATCH 1/2] add sounds to join/leave events --- apps/web/src/lib/sounds.ts | 46 +++++++++++++++++++++++++ apps/web/src/routes/[room]/+page.svelte | 18 ++++++++++ 2 files changed, 64 insertions(+) create mode 100644 apps/web/src/lib/sounds.ts diff --git a/apps/web/src/lib/sounds.ts b/apps/web/src/lib/sounds.ts new file mode 100644 index 0000000..a2a8c39 --- /dev/null +++ b/apps/web/src/lib/sounds.ts @@ -0,0 +1,46 @@ +/** + * Notification sounds for peer join/leave events. + */ + +let audioCtx: AudioContext | null = null; + +function getAudioContext(): AudioContext { + if (!audioCtx) { + audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + } + return audioCtx; +} + +function playTone(frequencies: number[], durations: number[], volume = 0.15) { + const ctx = getAudioContext(); + let time = ctx.currentTime; + + for (let i = 0; i < frequencies.length; i++) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = 'sine'; + osc.frequency.setValueAtTime(frequencies[i], time); + + gain.gain.setValueAtTime(volume, time); + gain.gain.exponentialRampToValueAtTime(0.001, time + durations[i]); + + osc.connect(gain); + gain.connect(ctx.destination); + + osc.start(time); + osc.stop(time + durations[i]); + + time += durations[i] * 0.6; // slight overlap for smoother sound + } +} + +/** Ascending two-note chime for peer join. */ +export function playJoinSound() { + playTone([523.25, 659.25], [0.12, 0.18], 0.13); // C5 → E5 +} + +/** Descending two-note tone for peer leave. */ +export function playLeaveSound() { + playTone([493.88, 392.0], [0.12, 0.2], 0.1); // B4 → G4 +} diff --git a/apps/web/src/routes/[room]/+page.svelte b/apps/web/src/routes/[room]/+page.svelte index e7d24c0..9b89e62 100644 --- a/apps/web/src/routes/[room]/+page.svelte +++ b/apps/web/src/routes/[room]/+page.svelte @@ -3,12 +3,30 @@ import { goto } from "$app/navigation"; import { VocaRoom } from "@treyorr/voca-svelte"; import { onMount, onDestroy } from "svelte"; + import { playJoinSound, playLeaveSound } from "$lib/sounds"; const roomId = $derived(page.params.room); let room = $state(null); let passwordInput = $state(""); let roomPassword = $state(undefined); + // Track peer count to play join/leave sounds + let prevPeerSize = -1; // -1 = not yet initialized (skip initial load) + $effect(() => { + const currentSize = room?.peers.size ?? 0; + if (room?.status === "connected") { + if (prevPeerSize === -1) { + // First time seeing peers after connect — don't play sounds + prevPeerSize = currentSize; + } else if (currentSize > prevPeerSize) { + playJoinSound(); + } else if (currentSize < prevPeerSize) { + playLeaveSound(); + } + prevPeerSize = currentSize; + } + }); + // Config - SDK auto-converts http to ws const serverUrl = import.meta.env.DEV ? "http://localhost:3001" : undefined; const apiKey = import.meta.env.VITE_VOCA_API_KEY || ""; From 72f7c6973ad712f248d9182791c8fbecd1e82695 Mon Sep 17 00:00:00 2001 From: Trey Orr Date: Mon, 2 Mar 2026 21:13:11 -0500 Subject: [PATCH 2/2] sound settings --- apps/web/src/lib/SoundSettings.svelte | 223 ++++++++++++++++++++++++++ apps/web/src/lib/sounds.ts | 101 +++++++++++- apps/web/src/routes/+page.svelte | 3 + 3 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/SoundSettings.svelte diff --git a/apps/web/src/lib/SoundSettings.svelte b/apps/web/src/lib/SoundSettings.svelte new file mode 100644 index 0000000..f226eef --- /dev/null +++ b/apps/web/src/lib/SoundSettings.svelte @@ -0,0 +1,223 @@ + + + + + + +{#if open} + +{/if} + + diff --git a/apps/web/src/lib/sounds.ts b/apps/web/src/lib/sounds.ts index a2a8c39..f4ffc61 100644 --- a/apps/web/src/lib/sounds.ts +++ b/apps/web/src/lib/sounds.ts @@ -1,7 +1,16 @@ /** * Notification sounds for peer join/leave events. + * + * Supports custom sounds stored as base64 data URLs in localStorage, + * with configurable volume. Falls back to Web Audio API generated tones. */ +const STORAGE_KEYS = { + joinSound: 'voca:sound:join', + leaveSound: 'voca:sound:leave', + volume: 'voca:sound:volume', +} as const; + let audioCtx: AudioContext | null = null; function getAudioContext(): AudioContext { @@ -11,7 +20,71 @@ function getAudioContext(): AudioContext { return audioCtx; } -function playTone(frequencies: number[], durations: number[], volume = 0.15) { +/** Get the notification volume (0-1). Defaults to 0.5. */ +export function getVolume(): number { + try { + const stored = localStorage.getItem(STORAGE_KEYS.volume); + if (stored !== null) return Math.max(0, Math.min(1, parseFloat(stored))); + } catch { } + return 0.5; +} + +/** Set the notification volume (0-1). */ +export function setVolume(v: number) { + try { + localStorage.setItem(STORAGE_KEYS.volume, String(Math.max(0, Math.min(1, v)))); + } catch { } +} + +/** Get a custom sound data URL from localStorage. */ +export function getCustomSound(type: 'join' | 'leave'): string | null { + try { + return localStorage.getItem(type === 'join' ? STORAGE_KEYS.joinSound : STORAGE_KEYS.leaveSound); + } catch { + return null; + } +} + +/** Store a custom sound as a base64 data URL. */ +export function setCustomSound(type: 'join' | 'leave', dataUrl: string) { + try { + localStorage.setItem( + type === 'join' ? STORAGE_KEYS.joinSound : STORAGE_KEYS.leaveSound, + dataUrl, + ); + } catch { } +} + +/** Remove a custom sound, reverting to the default tone. */ +export function removeCustomSound(type: 'join' | 'leave') { + try { + localStorage.removeItem(type === 'join' ? STORAGE_KEYS.joinSound : STORAGE_KEYS.leaveSound); + } catch { } +} + +/** + * Read a File as a base64 data URL. + * Returns null if the file is too large (> 512KB). + */ +export function fileToDataUrl(file: File): Promise { + if (file.size > 512 * 1024) return Promise.resolve(null); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(file); + }); +} + +// ---------- playback ---------- + +function playCustom(dataUrl: string, volume: number) { + const audio = new Audio(dataUrl); + audio.volume = volume; + audio.play().catch(() => { }); +} + +function playGeneratedTone(frequencies: number[], durations: number[], volume: number) { const ctx = getAudioContext(); let time = ctx.currentTime; @@ -22,7 +95,7 @@ function playTone(frequencies: number[], durations: number[], volume = 0.15) { osc.type = 'sine'; osc.frequency.setValueAtTime(frequencies[i], time); - gain.gain.setValueAtTime(volume, time); + gain.gain.setValueAtTime(volume * 0.25, time); gain.gain.exponentialRampToValueAtTime(0.001, time + durations[i]); osc.connect(gain); @@ -31,16 +104,30 @@ function playTone(frequencies: number[], durations: number[], volume = 0.15) { osc.start(time); osc.stop(time + durations[i]); - time += durations[i] * 0.6; // slight overlap for smoother sound + time += durations[i] * 0.6; } } -/** Ascending two-note chime for peer join. */ +/** Play the join notification sound. */ export function playJoinSound() { - playTone([523.25, 659.25], [0.12, 0.18], 0.13); // C5 → E5 + const vol = getVolume(); + if (vol === 0) return; + const custom = getCustomSound('join'); + if (custom) { + playCustom(custom, vol); + } else { + playGeneratedTone([523.25, 659.25], [0.12, 0.18], vol); // C5 → E5 + } } -/** Descending two-note tone for peer leave. */ +/** Play the leave notification sound. */ export function playLeaveSound() { - playTone([493.88, 392.0], [0.12, 0.2], 0.1); // B4 → G4 + const vol = getVolume(); + if (vol === 0) return; + const custom = getCustomSound('leave'); + if (custom) { + playCustom(custom, vol); + } else { + playGeneratedTone([493.88, 392.0], [0.12, 0.2], vol); // B4 → G4 + } } diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index 030286a..6b5fc7b 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -9,6 +9,7 @@ import { Monitor, Moon, Sun } from "@lucide/svelte"; import { VocaClient, validatePassword } from "@treyorr/voca-svelte"; import { onMount } from "svelte"; + import SoundSettings from "$lib/SoundSettings.svelte"; let isCreating = $state(false); let error = $state(null); @@ -67,6 +68,8 @@ voca.vc + +