audioConfig.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  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:distant'
  9. | 'guidance:approaching'
  10. | 'guidance:ready'
  11. export interface AudioCueConfig {
  12. src: string
  13. volume: number
  14. loop: boolean
  15. loopGapMs: number
  16. backgroundMode: 'disabled' | 'guidance'
  17. }
  18. export interface GameAudioConfig {
  19. enabled: boolean
  20. masterVolume: number
  21. obeyMuteSwitch: boolean
  22. backgroundAudioEnabled: boolean
  23. distantDistanceMeters: number
  24. approachDistanceMeters: number
  25. readyDistanceMeters: number
  26. cues: Record<AudioCueKey, AudioCueConfig>
  27. }
  28. export interface PartialAudioCueConfig {
  29. src?: string
  30. volume?: number
  31. loop?: boolean
  32. loopGapMs?: number
  33. backgroundMode?: 'disabled' | 'guidance'
  34. }
  35. export interface GameAudioConfigOverrides {
  36. enabled?: boolean
  37. masterVolume?: number
  38. obeyMuteSwitch?: boolean
  39. backgroundAudioEnabled?: boolean
  40. distantDistanceMeters?: number
  41. approachDistanceMeters?: number
  42. readyDistanceMeters?: number
  43. cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
  44. }
  45. export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
  46. enabled: true,
  47. masterVolume: 1,
  48. obeyMuteSwitch: true,
  49. backgroundAudioEnabled: true,
  50. distantDistanceMeters: 80,
  51. approachDistanceMeters: 20,
  52. readyDistanceMeters: 5,
  53. cues: {
  54. session_started: {
  55. src: '/assets/sounds/session-start.wav',
  56. volume: 0.78,
  57. loop: false,
  58. loopGapMs: 0,
  59. backgroundMode: 'disabled',
  60. },
  61. 'control_completed:start': {
  62. src: '/assets/sounds/start-complete.wav',
  63. volume: 0.84,
  64. loop: false,
  65. loopGapMs: 0,
  66. backgroundMode: 'disabled',
  67. },
  68. 'control_completed:control': {
  69. src: '/assets/sounds/control-complete.wav',
  70. volume: 0.8,
  71. loop: false,
  72. loopGapMs: 0,
  73. backgroundMode: 'disabled',
  74. },
  75. 'control_completed:finish': {
  76. src: '/assets/sounds/finish-complete.wav',
  77. volume: 0.92,
  78. loop: false,
  79. loopGapMs: 0,
  80. backgroundMode: 'disabled',
  81. },
  82. 'punch_feedback:warning': {
  83. src: '/assets/sounds/warning.wav',
  84. volume: 0.72,
  85. loop: false,
  86. loopGapMs: 0,
  87. backgroundMode: 'disabled',
  88. },
  89. 'guidance:searching': {
  90. src: '/assets/sounds/guidance-searching.wav',
  91. volume: 0.48,
  92. loop: true,
  93. loopGapMs: 1800,
  94. backgroundMode: 'guidance',
  95. },
  96. 'guidance:distant': {
  97. src: '/assets/sounds/guidance-searching.wav',
  98. volume: 0.34,
  99. loop: true,
  100. loopGapMs: 4800,
  101. backgroundMode: 'guidance',
  102. },
  103. 'guidance:approaching': {
  104. src: '/assets/sounds/guidance-approaching.wav',
  105. volume: 0.58,
  106. loop: true,
  107. loopGapMs: 950,
  108. backgroundMode: 'guidance',
  109. },
  110. 'guidance:ready': {
  111. src: '/assets/sounds/guidance-ready.wav',
  112. volume: 0.68,
  113. loop: true,
  114. loopGapMs: 650,
  115. backgroundMode: 'guidance',
  116. },
  117. },
  118. }
  119. function clampVolume(value: number, fallback: number): number {
  120. return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback
  121. }
  122. function clampDistance(value: number, fallback: number): number {
  123. return Number.isFinite(value) && value > 0 ? value : fallback
  124. }
  125. function clampGap(value: number, fallback: number): number {
  126. return Number.isFinite(value) && value >= 0 ? value : fallback
  127. }
  128. export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null): GameAudioConfig {
  129. const cues: GameAudioConfig['cues'] = {
  130. session_started: { ...DEFAULT_GAME_AUDIO_CONFIG.cues.session_started },
  131. 'control_completed:start': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:start'] },
  132. 'control_completed:control': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:control'] },
  133. 'control_completed:finish': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:finish'] },
  134. 'punch_feedback:warning': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['punch_feedback:warning'] },
  135. 'guidance:searching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:searching'] },
  136. 'guidance:distant': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:distant'] },
  137. 'guidance:approaching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:approaching'] },
  138. 'guidance:ready': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:ready'] },
  139. }
  140. if (overrides && overrides.cues) {
  141. const keys = Object.keys(overrides.cues) as AudioCueKey[]
  142. for (const key of keys) {
  143. const cue = overrides.cues[key]
  144. if (!cue) {
  145. continue
  146. }
  147. if (typeof cue.src === 'string' && cue.src) {
  148. cues[key].src = cue.src
  149. }
  150. if (cue.volume !== undefined) {
  151. cues[key].volume = clampVolume(Number(cue.volume), cues[key].volume)
  152. }
  153. if (cue.loop !== undefined) {
  154. cues[key].loop = !!cue.loop
  155. }
  156. if (cue.loopGapMs !== undefined) {
  157. cues[key].loopGapMs = clampGap(Number(cue.loopGapMs), cues[key].loopGapMs)
  158. }
  159. if (cue.backgroundMode === 'disabled' || cue.backgroundMode === 'guidance') {
  160. cues[key].backgroundMode = cue.backgroundMode
  161. }
  162. }
  163. }
  164. return {
  165. enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_AUDIO_CONFIG.enabled,
  166. masterVolume: clampVolume(Number(overrides && overrides.masterVolume), DEFAULT_GAME_AUDIO_CONFIG.masterVolume),
  167. obeyMuteSwitch: overrides && overrides.obeyMuteSwitch !== undefined ? !!overrides.obeyMuteSwitch : DEFAULT_GAME_AUDIO_CONFIG.obeyMuteSwitch,
  168. backgroundAudioEnabled: overrides && overrides.backgroundAudioEnabled !== undefined
  169. ? !!overrides.backgroundAudioEnabled
  170. : true,
  171. distantDistanceMeters: clampDistance(
  172. Number(overrides && overrides.distantDistanceMeters),
  173. DEFAULT_GAME_AUDIO_CONFIG.distantDistanceMeters,
  174. ),
  175. approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters),
  176. readyDistanceMeters: clampDistance(
  177. Number(overrides && overrides.readyDistanceMeters),
  178. DEFAULT_GAME_AUDIO_CONFIG.readyDistanceMeters,
  179. ),
  180. cues,
  181. }
  182. }