audioConfig.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. export type AudioCueKey =
  2. | 'session_started'
  3. | 'control_completed:start'
  4. | 'control_completed:control'
  5. | 'control_completed:finish'
  6. | 'punch_feedback:warning'
  7. | 'guidance:searching'
  8. | 'guidance:approaching'
  9. | 'guidance:ready'
  10. export interface AudioCueConfig {
  11. src: string
  12. volume: number
  13. loop: boolean
  14. loopGapMs: number
  15. }
  16. export interface GameAudioConfig {
  17. enabled: boolean
  18. masterVolume: number
  19. obeyMuteSwitch: boolean
  20. approachDistanceMeters: number
  21. cues: Record<AudioCueKey, AudioCueConfig>
  22. }
  23. export interface PartialAudioCueConfig {
  24. src?: string
  25. volume?: number
  26. loop?: boolean
  27. loopGapMs?: number
  28. }
  29. export interface GameAudioConfigOverrides {
  30. enabled?: boolean
  31. masterVolume?: number
  32. obeyMuteSwitch?: boolean
  33. approachDistanceMeters?: number
  34. cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
  35. }
  36. export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
  37. enabled: true,
  38. masterVolume: 1,
  39. obeyMuteSwitch: true,
  40. approachDistanceMeters: 20,
  41. cues: {
  42. session_started: {
  43. src: '/assets/sounds/session-start.wav',
  44. volume: 0.78,
  45. loop: false,
  46. loopGapMs: 0,
  47. },
  48. 'control_completed:start': {
  49. src: '/assets/sounds/start-complete.wav',
  50. volume: 0.84,
  51. loop: false,
  52. loopGapMs: 0,
  53. },
  54. 'control_completed:control': {
  55. src: '/assets/sounds/control-complete.wav',
  56. volume: 0.8,
  57. loop: false,
  58. loopGapMs: 0,
  59. },
  60. 'control_completed:finish': {
  61. src: '/assets/sounds/finish-complete.wav',
  62. volume: 0.92,
  63. loop: false,
  64. loopGapMs: 0,
  65. },
  66. 'punch_feedback:warning': {
  67. src: '/assets/sounds/warning.wav',
  68. volume: 0.72,
  69. loop: false,
  70. loopGapMs: 0,
  71. },
  72. 'guidance:searching': {
  73. src: '/assets/sounds/guidance-searching.wav',
  74. volume: 0.48,
  75. loop: true,
  76. loopGapMs: 1800,
  77. },
  78. 'guidance:approaching': {
  79. src: '/assets/sounds/guidance-approaching.wav',
  80. volume: 0.58,
  81. loop: true,
  82. loopGapMs: 950,
  83. },
  84. 'guidance:ready': {
  85. src: '/assets/sounds/guidance-ready.wav',
  86. volume: 0.68,
  87. loop: true,
  88. loopGapMs: 650,
  89. },
  90. },
  91. }
  92. function clampVolume(value: number, fallback: number): number {
  93. return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback
  94. }
  95. function clampDistance(value: number, fallback: number): number {
  96. return Number.isFinite(value) && value > 0 ? value : fallback
  97. }
  98. function clampGap(value: number, fallback: number): number {
  99. return Number.isFinite(value) && value >= 0 ? value : fallback
  100. }
  101. export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null): GameAudioConfig {
  102. const cues: GameAudioConfig['cues'] = {
  103. session_started: { ...DEFAULT_GAME_AUDIO_CONFIG.cues.session_started },
  104. 'control_completed:start': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:start'] },
  105. 'control_completed:control': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:control'] },
  106. 'control_completed:finish': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:finish'] },
  107. 'punch_feedback:warning': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['punch_feedback:warning'] },
  108. 'guidance:searching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:searching'] },
  109. 'guidance:approaching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:approaching'] },
  110. 'guidance:ready': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:ready'] },
  111. }
  112. if (overrides && overrides.cues) {
  113. const keys = Object.keys(overrides.cues) as AudioCueKey[]
  114. for (const key of keys) {
  115. const cue = overrides.cues[key]
  116. if (!cue) {
  117. continue
  118. }
  119. if (typeof cue.src === 'string' && cue.src) {
  120. cues[key].src = cue.src
  121. }
  122. if (cue.volume !== undefined) {
  123. cues[key].volume = clampVolume(Number(cue.volume), cues[key].volume)
  124. }
  125. if (cue.loop !== undefined) {
  126. cues[key].loop = !!cue.loop
  127. }
  128. if (cue.loopGapMs !== undefined) {
  129. cues[key].loopGapMs = clampGap(Number(cue.loopGapMs), cues[key].loopGapMs)
  130. }
  131. }
  132. }
  133. return {
  134. enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_AUDIO_CONFIG.enabled,
  135. masterVolume: clampVolume(Number(overrides && overrides.masterVolume), DEFAULT_GAME_AUDIO_CONFIG.masterVolume),
  136. obeyMuteSwitch: overrides && overrides.obeyMuteSwitch !== undefined ? !!overrides.obeyMuteSwitch : DEFAULT_GAME_AUDIO_CONFIG.obeyMuteSwitch,
  137. approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters),
  138. cues,
  139. }
  140. }