audioConfig.ts 5.3 KB

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