feat: implement cozy sound effects for abacus with variable intensity

- Add realistic bead click sounds using Web Audio API synthesis
- Support variable intensity based on number of beads moved (1-5)
- Include app-wide sound controls in Style dropdown (enable/disable + volume)
- Settings persist in localStorage with existing style preferences
- SSR-safe implementation with graceful fallback
- Performance optimized with proper audio node cleanup

Sound characteristics:
- Dual-oscillator design (warm thock + sharp click)
- Sub-harmonic richness for multi-bead movements
- Exponential decay envelope for natural sound
- Lower frequencies and longer duration for heavier movements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-29 06:30:49 -05:00
parent 9191b12493
commit cea5fadbe4
5 changed files with 260 additions and 10 deletions

View File

@@ -169,6 +169,62 @@ export function AbacusDisplayDropdown({ isFullscreen = false }: AbacusDisplayDro
isFullscreen={isFullscreen}
/>
</FormField>
<FormField label="Sound Effects" isFullscreen={isFullscreen}>
<SwitchField
checked={config.soundEnabled}
onCheckedChange={(checked) => updateConfig({ soundEnabled: checked })}
isFullscreen={isFullscreen}
/>
</FormField>
{config.soundEnabled && (
<FormField label={`Volume: ${Math.round(config.soundVolume * 100)}%`} isFullscreen={isFullscreen}>
<input
type="range"
min="0"
max="1"
step="0.1"
value={config.soundVolume}
onChange={(e) => updateConfig({ soundVolume: parseFloat(e.target.value) })}
className={css({
w: 'full',
h: '2',
bg: isFullscreen ? 'rgba(255, 255, 255, 0.2)' : 'gray.200',
rounded: 'full',
appearance: 'none',
cursor: 'pointer',
_focusVisible: {
outline: 'none',
ring: '2px',
ringColor: isFullscreen ? 'blue.400' : 'brand.500'
},
'&::-webkit-slider-thumb': {
appearance: 'none',
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
cursor: 'pointer',
transition: 'all',
_hover: {
bg: isFullscreen ? 'blue.500' : 'brand.700',
transform: 'scale(1.1)'
}
},
'&::-moz-range-thumb': {
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
border: 'none',
cursor: 'pointer'
}
})}
onClick={(e) => e.stopPropagation()} // Prevent dropdown close
/>
</FormField>
)}
</div>
</div>
</DropdownMenu.Content>

View File

@@ -12,6 +12,8 @@ export interface AbacusDisplayConfig {
hideInactiveBeads: boolean
coloredNumerals: boolean
scaleFactor: number
soundEnabled: boolean
soundVolume: number
}
export interface AbacusDisplayContextType {
@@ -26,7 +28,9 @@ const DEFAULT_CONFIG: AbacusDisplayConfig = {
beadShape: 'diamond',
hideInactiveBeads: false,
coloredNumerals: false,
scaleFactor: 1.0 // Normalized for display, can be scaled per component
scaleFactor: 1.0, // Normalized for display, can be scaled per component
soundEnabled: true,
soundVolume: 0.8
}
const STORAGE_KEY = 'soroban-abacus-display-config'
@@ -50,7 +54,11 @@ function loadConfigFromStorage(): AbacusDisplayConfig {
coloredNumerals: typeof parsed.coloredNumerals === 'boolean'
? parsed.coloredNumerals : DEFAULT_CONFIG.coloredNumerals,
scaleFactor: typeof parsed.scaleFactor === 'number' && parsed.scaleFactor > 0
? parsed.scaleFactor : DEFAULT_CONFIG.scaleFactor
? parsed.scaleFactor : DEFAULT_CONFIG.scaleFactor,
soundEnabled: typeof parsed.soundEnabled === 'boolean'
? parsed.soundEnabled : DEFAULT_CONFIG.soundEnabled,
soundVolume: typeof parsed.soundVolume === 'number' && parsed.soundVolume >= 0 && parsed.soundVolume <= 1
? parsed.soundVolume : DEFAULT_CONFIG.soundVolume
}
}
} catch (error) {

View File

@@ -18,6 +18,8 @@ export interface AbacusDisplayConfig {
animated: boolean
interactive: boolean
gestures: boolean
soundEnabled: boolean
soundVolume: number
}
export interface AbacusDisplayContextType {
@@ -37,7 +39,9 @@ const DEFAULT_CONFIG: AbacusDisplayConfig = {
showNumbers: true,
animated: true,
interactive: false,
gestures: false
gestures: false,
soundEnabled: true,
soundVolume: 0.8
}
const STORAGE_KEY = 'soroban-abacus-display-config'
@@ -71,7 +75,11 @@ function loadConfigFromStorage(): AbacusDisplayConfig {
interactive: typeof parsed.interactive === 'boolean'
? parsed.interactive : DEFAULT_CONFIG.interactive,
gestures: typeof parsed.gestures === 'boolean'
? parsed.gestures : DEFAULT_CONFIG.gestures
? parsed.gestures : DEFAULT_CONFIG.gestures,
soundEnabled: typeof parsed.soundEnabled === 'boolean'
? parsed.soundEnabled : DEFAULT_CONFIG.soundEnabled,
soundVolume: typeof parsed.soundVolume === 'number' && parsed.soundVolume >= 0 && parsed.soundVolume <= 1
? parsed.soundVolume : DEFAULT_CONFIG.soundVolume
}
}
} catch (error) {

View File

@@ -5,6 +5,7 @@ import { useSpring, animated, config, to } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import NumberFlow from '@number-flow/react';
import { useAbacusConfig, getDefaultAbacusConfig } from './AbacusContext';
import { playBeadSound } from './soundManager';
// Types
export interface BeadConfig {
@@ -1335,7 +1336,9 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
animated: animated ?? contextConfig.animated,
interactive: interactive ?? contextConfig.interactive,
gestures: gestures ?? contextConfig.gestures,
showNumbers: showNumbers ?? contextConfig.showNumbers
showNumbers: showNumbers ?? contextConfig.showNumbers,
soundEnabled: contextConfig.soundEnabled,
soundVolume: contextConfig.soundVolume
};
// Calculate effective columns first, without depending on columnStates
const effectiveColumns = useMemo(() => {
@@ -1435,6 +1438,21 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
return;
}
// Calculate how many beads will change to determine sound intensity
const currentState = getPlaceState(bead.placeValue);
let beadMovementCount = 1; // Default for single bead movements
if (bead.type === 'earth') {
if (bead.active) {
// Deactivating: count beads from this position to end of active beads
beadMovementCount = currentState.earthActive - bead.position;
} else {
// Activating: count beads from current active count to this position + 1
beadMovementCount = (bead.position + 1) - currentState.earthActive;
}
}
// Heaven bead always moves just 1 bead
// Create enhanced event object
const beadClickEvent: BeadClickEvent = {
bead,
@@ -1452,13 +1470,33 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
// Legacy callback for backward compatibility
onClick?.(bead);
// Play sound if enabled with intensity based on bead movement count
if (finalConfig.soundEnabled) {
playBeadSound(finalConfig.soundVolume, beadMovementCount);
}
// Toggle the bead - NO MORE EFFECTIVECOLUMNS THREADING!
toggleBead(bead);
}, [onClick, callbacks, toggleBead, disabledColumns, disabledBeads]);
}, [onClick, callbacks, toggleBead, disabledColumns, disabledBeads, finalConfig.soundEnabled, finalConfig.soundVolume, getPlaceState]);
const handleGestureToggle = useCallback((bead: BeadConfig, direction: 'activate' | 'deactivate') => {
const currentState = getPlaceState(bead.placeValue);
// Calculate bead movement count for sound intensity
let beadMovementCount = 1;
if (bead.type === 'earth') {
if (direction === 'activate') {
beadMovementCount = Math.max(0, (bead.position + 1) - currentState.earthActive);
} else {
beadMovementCount = Math.max(0, currentState.earthActive - bead.position);
}
}
// Play sound if enabled with intensity
if (finalConfig.soundEnabled) {
playBeadSound(finalConfig.soundVolume, beadMovementCount);
}
if (bead.type === 'heaven') {
// Heaven bead: directly set the state based on direction
const newHeavenActive = direction === 'activate';
@@ -1484,7 +1522,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
earthActive: newEarthActive
});
}
}, [getPlaceState, setPlaceState]);
}, [getPlaceState, setPlaceState, finalConfig.soundEnabled, finalConfig.soundVolume]);
// Place value editing - FRESH IMPLEMENTATION
const [activeColumn, setActiveColumn] = React.useState<number | null>(null);
@@ -1502,12 +1540,28 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
// Convert column index to place value
const placeValue = (effectiveColumns - 1 - columnIndex) as ValidPlaceValues;
const currentState = getPlaceState(placeValue);
// Calculate how many beads change for sound intensity
const currentValue = (currentState.heavenActive ? 5 : 0) + currentState.earthActive;
const newHeavenActive = digit >= 5;
const newEarthActive = digit % 5;
// Count bead movements: heaven bead + earth bead changes
let beadMovementCount = 0;
if (currentState.heavenActive !== newHeavenActive) beadMovementCount += 1;
beadMovementCount += Math.abs(currentState.earthActive - newEarthActive);
// Play sound if enabled with intensity based on bead changes
if (finalConfig.soundEnabled && beadMovementCount > 0) {
playBeadSound(finalConfig.soundVolume, beadMovementCount);
}
setPlaceState(placeValue, {
heavenActive: digit >= 5,
earthActive: digit % 5
heavenActive: newHeavenActive,
earthActive: newEarthActive
});
}, [setPlaceState, effectiveColumns]);
}, [setPlaceState, effectiveColumns, finalConfig.soundEnabled, finalConfig.soundVolume, getPlaceState]);
// Keyboard handler - only active when interactive
React.useEffect(() => {

View File

@@ -0,0 +1,124 @@
'use client'
// AudioContext manager for generating abacus bead click sounds
let audioCtx: AudioContext | null = null
/**
* Gets or creates the global AudioContext instance
* SSR-safe - returns null in server environment
*/
export function getAudioContext(): AudioContext | null {
// SSR guard - only initialize on client
if (typeof window === 'undefined') return null
if (!audioCtx) {
// Support older Safari versions with webkit prefix
const AudioCtxClass = window.AudioContext || (window as any).webkitAudioContext
try {
audioCtx = new AudioCtxClass()
} catch (e) {
console.warn('AudioContext could not be initialized:', e)
return null
}
}
return audioCtx
}
/**
* Plays a realistic "cozy" bead click sound using Web Audio API
* Generates sound on-the-fly with no external assets
* @param volume - Volume level from 0.0 to 1.0
* @param intensity - Number of beads moved (1-5) to adjust sound heft
*/
export function playBeadSound(volume: number, intensity: number = 1): void {
const ctx = getAudioContext()
if (!ctx) return // No audio context available (SSR or initialization failed)
// Clamp volume to valid range
const clampedVolume = Math.max(0, Math.min(1, volume))
if (clampedVolume === 0) return // Skip if volume is zero
// Clamp intensity to reasonable range (1-5 beads)
const clampedIntensity = Math.max(1, Math.min(5, intensity))
const now = ctx.currentTime
// Calculate sound characteristics based on intensity
const intensityFactor = Math.sqrt(clampedIntensity) // Square root for natural scaling
const volumeMultiplier = 0.8 + (intensityFactor - 1) * 0.3 // 0.8 to 1.4 range
const durationMultiplier = 0.8 + (intensityFactor - 1) * 0.4 // Longer decay for more beads
const lowFreqBoost = 1 + (intensityFactor - 1) * 0.3 // Lower frequency for more heft
// Create gain node for volume envelope
const gainNode = ctx.createGain()
gainNode.connect(ctx.destination)
// Create primary oscillator for the warm "thock" sound
const lowOsc = ctx.createOscillator()
lowOsc.type = 'triangle' // Warmer than sine, less harsh than square
lowOsc.frequency.setValueAtTime(220 / lowFreqBoost, now) // Lower frequency for more heft
// Create secondary oscillator for the sharp "click" component
const highOsc = ctx.createOscillator()
highOsc.type = 'sine'
highOsc.frequency.setValueAtTime(1400, now) // Higher frequency for the tap clarity
// Optional third oscillator for extra richness on multi-bead movements
let richOsc: OscillatorNode | null = null
let richGain: GainNode | null = null
if (clampedIntensity > 2) {
richOsc = ctx.createOscillator()
richOsc.type = 'triangle'
richOsc.frequency.setValueAtTime(110, now) // Sub-harmonic for richness
richGain = ctx.createGain()
richGain.gain.setValueAtTime(clampedVolume * volumeMultiplier * 0.2 * (intensityFactor - 1), now)
richOsc.connect(richGain)
richGain.connect(gainNode)
}
// Create separate gain nodes for mixing the two main components
const lowGain = ctx.createGain()
const highGain = ctx.createGain()
lowGain.gain.setValueAtTime(clampedVolume * volumeMultiplier * 0.7, now) // Primary component
highGain.gain.setValueAtTime(clampedVolume * volumeMultiplier * 0.3, now) // Secondary accent
// Connect oscillators through their gain nodes to the main envelope
lowOsc.connect(lowGain)
highOsc.connect(highGain)
lowGain.connect(gainNode)
highGain.connect(gainNode)
// Calculate duration based on intensity
const baseDuration = 0.08 // 80ms base duration
const actualDuration = baseDuration * durationMultiplier
// Create exponential decay envelope for natural sound
gainNode.gain.setValueAtTime(1.0, now)
gainNode.gain.exponentialRampToValueAtTime(0.001, now + actualDuration)
// Start oscillators
lowOsc.start(now)
highOsc.start(now)
if (richOsc) richOsc.start(now)
// Stop oscillators at end of envelope
const stopTime = now + actualDuration
lowOsc.stop(stopTime)
highOsc.stop(stopTime)
if (richOsc) richOsc.stop(stopTime)
// Cleanup: disconnect nodes when sound finishes to prevent memory leaks
lowOsc.onended = () => {
lowOsc.disconnect()
highOsc.disconnect()
lowGain.disconnect()
highGain.disconnect()
gainNode.disconnect()
if (richOsc && richGain) {
richOsc.disconnect()
richGain.disconnect()
}
}
}