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:
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
124
packages/abacus-react/src/soundManager.ts
Normal file
124
packages/abacus-react/src/soundManager.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user