@ihabdevteam/core 0.321.0 → 0.323.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CHANGELOG.md +617 -0
  2. package/dist/components.cjs.js +183 -183
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +187 -187
  7. package/dist/ihabdevteam-core.d.ts +108 -66
  8. package/dist/packages/core/src/components/Accordion.js +3 -3
  9. package/dist/packages/core/src/components/Alert.js +2 -2
  10. package/dist/packages/core/src/components/BarChart.js +120 -119
  11. package/dist/packages/core/src/components/BottomNav.js +18 -18
  12. package/dist/packages/core/src/components/BulkBar.js +25 -25
  13. package/dist/packages/core/src/components/Button.js +16 -16
  14. package/dist/packages/core/src/components/Calendar.js +17 -17
  15. package/dist/packages/core/src/components/ChartSection.js +30 -30
  16. package/dist/packages/core/src/components/CountTile.js +38 -38
  17. package/dist/packages/core/src/components/DatePicker.js +59 -59
  18. package/dist/packages/core/src/components/Dialog.js +14 -14
  19. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
  20. package/dist/packages/core/src/components/FilterPanel.js +6 -6
  21. package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
  22. package/dist/packages/core/src/components/GameIntro.js +1 -1
  23. package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
  24. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
  25. package/dist/packages/core/src/components/Icon.js +2 -2
  26. package/dist/packages/core/src/components/ItemActions.js +16 -16
  27. package/dist/packages/core/src/components/LandingHeader.js +6 -6
  28. package/dist/packages/core/src/components/LineChart.js +24 -24
  29. package/dist/packages/core/src/components/Loader.js +5 -5
  30. package/dist/packages/core/src/components/NoticeBox.js +3 -3
  31. package/dist/packages/core/src/components/PageTitle.js +2 -2
  32. package/dist/packages/core/src/components/Pagination.js +4 -4
  33. package/dist/packages/core/src/components/Panel.js +2 -2
  34. package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
  35. package/dist/packages/core/src/components/PanelRow.js +8 -15
  36. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
  37. package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
  38. package/dist/packages/core/src/components/PaymentPlan.js +5 -5
  39. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
  40. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
  41. package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
  42. package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
  43. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +19 -26
  44. package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
  45. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +10 -10
  46. package/dist/packages/core/src/components/Popup/Popup.js +27 -27
  47. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
  48. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
  49. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
  50. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
  51. package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
  52. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
  53. package/dist/packages/core/src/components/Rating.js +20 -20
  54. package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
  55. package/dist/packages/core/src/components/RingChart.js +5 -5
  56. package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
  57. package/dist/packages/core/src/components/SegmentedControl.js +34 -34
  58. package/dist/packages/core/src/components/SideNav.js +37 -37
  59. package/dist/packages/core/src/components/Slider.js +2 -2
  60. package/dist/packages/core/src/components/SoundCompass.js +5 -5
  61. package/dist/packages/core/src/components/Table.js +257 -98
  62. package/dist/packages/core/src/components/Tabs.js +12 -12
  63. package/dist/packages/core/src/components/TextField.js +34 -34
  64. package/dist/packages/core/src/components/Toast.js +6 -6
  65. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
  66. package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
  67. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
  68. package/dist/packages/core/src/components/TopBanner.js +28 -28
  69. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
  70. package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
  71. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
  72. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
  73. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
  74. package/dist/packages/core/src/components/Training/TrainingBlock.js +12 -12
  75. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
  76. package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
  77. package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
  78. package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
  79. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
  80. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +83 -83
  81. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
  82. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +128 -128
  83. package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
  84. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
  85. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
  86. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +13 -13
  87. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
  88. package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
  89. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  90. package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
  91. package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
  92. package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
  93. package/dist/packages/core/src/components/Training/oneShotSound.js +14 -14
  94. package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
  95. package/dist/packages/core/src/components/Training/trainingMissStats.js +22 -22
  96. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
  97. package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
  98. package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
  99. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
  100. package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
  101. package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
  102. package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
  103. package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
  104. package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
  105. package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
  106. package/dist/packages/core/src/components/UpdateAlert.js +6 -6
  107. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
  108. package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
  109. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +18 -18
  110. package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
  111. package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
  112. package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
  113. package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
  114. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
  115. package/dist/packages/core/src/components/Word/wordScoreStats.js +17 -17
  116. package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
  117. package/dist/packages/core/src/components/Word/wordTableRecommend.js +13 -13
  118. package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
  119. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
  120. package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
  121. package/dist/packages/core/src/components/WordTable.js +130 -130
  122. package/dist/packages/core/src/components/barChartData.d.ts +16 -1
  123. package/dist/packages/core/src/components/barChartData.js +49 -29
  124. package/dist/packages/core/src/components/barChartTypes.js +8 -8
  125. package/dist/packages/core/src/components/tableTrackList.js +11 -11
  126. package/dist/packages/core/src/components/updateAlertStack.js +28 -28
  127. package/dist/packages/core/src/components/useBarChartControl.js +4 -6
  128. package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
  129. package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
  130. package/dist/packages/core/src/components/wordTableLayout.d.ts +22 -7
  131. package/dist/packages/core/src/components/wordTableLayout.js +5 -5
  132. package/dist/packages/core/src/context/I18nProvider.js +6 -6
  133. package/dist/packages/core/src/hooks/focusReturn.js +27 -27
  134. package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
  135. package/dist/packages/core/src/hooks/inputModality.js +14 -14
  136. package/dist/packages/core/src/hooks/touchToggle.js +39 -39
  137. package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
  138. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
  139. package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
  140. package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
  141. package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
  142. package/dist/packages/core/src/hooks/useEditableList.js +4 -4
  143. package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
  144. package/dist/packages/core/src/hooks/useFontScale.js +6 -6
  145. package/dist/packages/core/src/hooks/useIcon.js +7 -7
  146. package/dist/packages/core/src/hooks/useListControls.js +2 -2
  147. package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
  148. package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
  149. package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
  150. package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
  151. package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
  152. package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
  153. package/dist/packages/core/src/hooks/useTablePaging.d.ts +7 -7
  154. package/dist/packages/core/src/hooks/useTablePaging.js +40 -34
  155. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
  156. package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
  157. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
  158. package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
  159. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +3 -1
  160. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +14 -12
  161. package/dist/packages/core/src/i18n/locales/en.json +13 -2
  162. package/dist/packages/core/src/i18n/locales/ko.json +12 -1
  163. package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
  164. package/dist/packages/core/src/utils/audioMeter.js +5 -5
  165. package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
  166. package/dist/packages/core/src/utils/audioPeaks.js +18 -18
  167. package/dist/packages/core/src/utils/blockGrid.js +4 -4
  168. package/dist/packages/core/src/utils/chunkSkew.js +9 -9
  169. package/dist/packages/core/src/utils/clipboard.js +3 -3
  170. package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
  171. package/dist/packages/core/src/utils/date.js +12 -12
  172. package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
  173. package/dist/packages/core/src/utils/devWarn.js +7 -7
  174. package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
  175. package/dist/packages/core/src/utils/deviceKind.js +53 -10
  176. package/dist/packages/core/src/utils/errorMessage.js +7 -7
  177. package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
  178. package/dist/packages/core/src/utils/filterPanelSummary.js +16 -16
  179. package/dist/packages/core/src/utils/format.js +4 -4
  180. package/dist/packages/core/src/utils/getTrainingData.js +9 -9
  181. package/dist/packages/core/src/utils/imageConfig.js +17 -17
  182. package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
  183. package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
  184. package/dist/packages/core/src/utils/navScroll.js +6 -6
  185. package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
  186. package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
  187. package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
  188. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
  189. package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
  190. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  191. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  192. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  193. package/dist/packages/core/src/utils/pressable.d.ts +1 -1
  194. package/dist/packages/core/src/utils/pressable.js +3 -3
  195. package/dist/packages/core/src/utils/safeHref.js +4 -4
  196. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  197. package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
  198. package/dist/packages/core/src/utils/sessionTab.js +9 -9
  199. package/dist/packages/core/src/utils/speak.js +12 -12
  200. package/dist/packages/core/src/utils/textSort.d.ts +5 -5
  201. package/dist/packages/core/src/utils/textSort.js +47 -47
  202. package/dist/packages/core/src/utils/theme.js +3 -3
  203. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  204. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  205. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +2 -2
  206. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +48 -48
  207. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  208. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  209. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  210. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  211. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  212. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  213. package/dist/packages/core/src/utils/wornDate.js +14 -14
  214. package/dist/packages/core/src/version.d.ts +1 -1
  215. package/dist/packages/core/src/version.js +1 -1
  216. package/dist/utils.cjs.js +4 -4
  217. package/package.json +1 -1
@@ -19,7 +19,7 @@ import Button from '../Button';
19
19
  import Icon from '../Icon';
20
20
  import InfoTooltip from '../Tooltip/InfoTooltip';
21
21
  import { STATUS_MESSAGES, ANNOUNCED_STATUSES } from './trainingStepAudioPlayerTypes';
22
- import { 키보드로눌렀나 } from '../../hooks/inputModality';
22
+ import { wasKeyboardPress } from '../../hooks/inputModality';
23
23
  import { measureAudioDuration, waitForCanPlay } from './audioWaits';
24
24
  import { useWaveReady } from './useWaveReady';
25
25
  import { useObjectUrls } from './useObjectUrls';
@@ -83,17 +83,17 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
83
83
  const [stateLocal, setStateLocal] = useState('active');
84
84
  /* 초점을 가진 큰 '듣기' 단추를 누르면 그 단추가 사라진다 — 재생 모드가 되면 초점을 '재생 / 멈춤' 으로 옮긴다
85
85
  (옮기지 않으면 초점이 body 로 떨어져 키보드 사용자가 멈출 단추를 다시 찾아야 했다). */
86
- const 듣기초점옮기기Ref = useRef(false);
86
+ const moveFocusToListenRef = useRef(false);
87
87
  useEffect(() => {
88
- if (stateLocal !== 'playMode' || !듣기초점옮기기Ref.current)
88
+ if (stateLocal !== 'playMode' || !moveFocusToListenRef.current)
89
89
  return;
90
- 듣기초점옮기기Ref.current = false;
91
- const 지금 = typeof document !== 'undefined' ? document.activeElement : null;
92
- if (지금 && 지금 !== document.body)
90
+ moveFocusToListenRef.current = false;
91
+ const active = typeof document !== 'undefined' ? document.activeElement : null;
92
+ if (active && active !== document.body)
93
93
  return;
94
- const 재생멈춤 = btnsRef.current?.querySelectorAll('button')[1];
94
+ const playPauseBtn = btnsRef.current?.querySelectorAll('button')[1];
95
95
  try {
96
- 재생멈춤?.focus();
96
+ playPauseBtn?.focus();
97
97
  }
98
98
  catch (e) { }
99
99
  }, [stateLocal]);
@@ -107,7 +107,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
107
107
  * 그래서 번호는 `currentIndex` 에서 읽고, 여기서는 **밝힐지 말지만** 든다.
108
108
  * 둘을 따로 들면 언젠가 한쪽만 갱신되는 자리가 생긴다(실제로 `goToTrack` 이
109
109
  * 번호만 바꾸고 이 값은 효과에 기대고 있었다). */
110
- const [구간표시, set구간표시] = useState(false);
110
+ const [showRange, setShowRange] = useState(false);
111
111
  const [totalDuration, setTotalDuration] = useState(0);
112
112
  // 문장(메인) 재생 속도·볼륨 — 하단 소음설정 툴팁과 공유(NoisePlayerContext)
113
113
  const playbackRate = noisePlayer?.playbackRate ?? 1.0;
@@ -138,16 +138,16 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
138
138
  /* **시간표의 자리·의도·뜸 타이머·멈춘 자리는 진행기가 쥔다**((85) 2a~2c, 2026-10-06).
139
139
  묻는 쪽(소음 · 틱 · 토글 · 자물쇠)은 **자리를 받아 제 손으로 셈하지 않고** 진행기에게
140
140
  묻는다 — `isRunning()` 하나다. 그 셈이 세 벌로 흩어져 있던 것이 오늘 결함 여섯의 뿌리였다. */
141
- const 진행기 = usePlaybackSequencer({ mountedRef });
141
+ const sequencer = usePlaybackSequencer({ mountedRef });
142
142
  /* `onEnded` 가 적어 두는 "여기서부터 이어 간다" — 토글이 멈출 때 진행기에 넘긴다 */
143
- const 이어가기RefBox = useRef(null);
143
+ const continueRef = useRef(null);
144
144
  /* 이벤트 손잡이는 한 번만 붙으므로 prop 을 ref 로 들고 본다 — 안 그러면 낡은 값을 쓴다
145
145
  (이 파일이 `disabledRef`·`noiseSettingsRef` 에 쓰는 것과 같은 결). */
146
146
  const leadInMsRef = useRef(leadInMs);
147
147
  const tailMsRef = useRef(tailMs);
148
148
  useEffect(() => { leadInMsRef.current = Math.max(0, leadInMs || 0); }, [leadInMs]);
149
149
  useEffect(() => { tailMsRef.current = Math.max(0, tailMs || 0); }, [tailMs]);
150
- const { start: startTicker, stop: stopTicker, settleTo: settleTicker, } = useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer: 진행기, leadInMs, tailMs });
150
+ const { start: startTicker, stop: stopTicker, settleTo: settleTicker, } = useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer: sequencer, leadInMs, tailMs });
151
151
  /* 파형이 자리를 잡을 때까지 기다리는 규칙은 useWaveReady 가 들고 있다 —
152
152
  ref 둘·함수 둘에 "준비 안 됨으로 되돌리기"가 일곱 군데 흩어져 있던 것을 모았다. */
153
153
  const { onReadyChange: handleWaveReadyChange, waitForReady: waitForWaveReady, markNotReady: markWaveNotReady, } = useWaveReady({ showSoundWaveForm, showNoiseWaveForm });
@@ -174,9 +174,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
174
174
  noisePlayer,
175
175
  audioRef,
176
176
  playbackStateRef,
177
- sequencer: 진행기,
177
+ sequencer: sequencer,
178
178
  });
179
- const { startTTS: 합성시작, stopTTS } = useTTSPlayback({
179
+ const { startTTS: beginTTS, stopTTS } = useTTSPlayback({
180
180
  ttsText,
181
181
  playbackRate,
182
182
  // TTS 음성 = 문제 소리 = 전체 소리
@@ -201,11 +201,11 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
201
201
  setNoticeMessage(t('training.ttsUnavailable', '이 기기에서는 음성을 들려줄 수 없습니다.'));
202
202
  }
203
203
  catch (_) { }
204
- 합성시작(text);
204
+ beginTTS(text);
205
205
  return;
206
206
  }
207
- 소리잡기('tts');
208
- 합성시작(text);
207
+ acquireSound('tts');
208
+ beginTTS(text);
209
209
  };
210
210
  const requestedSources = Array.isArray(soundSrc)
211
211
  ? soundSrc.filter(Boolean)
@@ -228,17 +228,17 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
228
228
  *
229
229
  * 이제 세대 하나다. `멈춤의도()` 는 세대를 올리고 자리를 비운다.
230
230
  */
231
- const 멈춤의도 = () => { 진행기.abort(); };
231
+ const abortIntent = () => { sequencer.abort(); };
232
232
  /* 스피커는 하나(currentSound.ts) — 한 화면의 다른 재생기(문장 듣기·문제 듣기)·낱말 카드·미리 듣기가 울리면 이 재생기는
233
233
  멈춘다. 전에는 낱말 카드만 자물쇠를 잡아, 듣기 단계가 둘인 화면·듣기+카드 단계에서 두 소리가 함께 울렸다.
234
234
  멈춤 단추와 같게 멈춘다(이어 듣기 가능). 함수는 한 벌이어야 자물쇠가 '내 것' 을 알아본다. */
235
235
  const stopTTSRef = useRef(null);
236
- const 소리종류Ref = useRef(null);
237
- const 소리놓기Ref = useRef(null);
238
- const 소리멈춤Ref = useRef(null);
239
- if (!소리멈춤Ref.current) {
240
- 소리멈춤Ref.current = () => {
241
- 소리놓기Ref.current = null;
236
+ const soundKindRef = useRef(null);
237
+ const releaseSoundRef = useRef(null);
238
+ const stopSoundRef = useRef(null);
239
+ if (!stopSoundRef.current) {
240
+ stopSoundRef.current = () => {
241
+ releaseSoundRef.current = null;
242
242
  const a = audioRef.current;
243
243
  /* **뜸 중이면 요소는 멈춰 있다** — `!a.paused` 만 보면 이 문을 안 지나, 남이 스피커를
244
244
  가져가도 **뜸은 제 것인 줄 알고 깨어나 소리를 냈다**(가짜 시계 실측, 두 길 다:
@@ -247,8 +247,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
247
247
  울린다" 고 보고한 그 자리의 **뜸 판**이다.
248
248
  오늘 같은 꼴을 여섯 번째 만난다(소음 감시 · 재생판 · 토글 · 그 토글의 두 번째 벌 ·
249
249
  번호 단추 · 여기). 이제 `isRunning()` 하나로 묻는다. */
250
- if (소리종류Ref.current === 'audio' && a && (!a.paused || 진행기.isRunning())) {
251
- 멈춤의도();
250
+ if (soundKindRef.current === 'audio' && a && (!a.paused || sequencer.isRunning())) {
251
+ abortIntent();
252
252
  try {
253
253
  a.pause();
254
254
  }
@@ -259,7 +259,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
259
259
  fadeOutNoise();
260
260
  }
261
261
  }
262
- else if (소리종류Ref.current === 'tts') {
262
+ else if (soundKindRef.current === 'tts') {
263
263
  try {
264
264
  stopTTSRef.current?.();
265
265
  }
@@ -267,14 +267,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
267
267
  }
268
268
  };
269
269
  }
270
- const 소리잡기 = (종류) => {
271
- 소리종류Ref.current = 종류;
272
- 소리놓기Ref.current = claimSound(소리멈춤Ref.current);
270
+ const acquireSound = (kind) => {
271
+ soundKindRef.current = kind;
272
+ releaseSoundRef.current = claimSound(stopSoundRef.current);
273
273
  };
274
274
  // 멈춤·정지·완료 뒤에는 자리를 비운다 — 남이 울릴 때 끝난 재생기의 상태를 건드리지 않게
275
- const 소리놓기 = () => { 소리놓기Ref.current?.(); 소리놓기Ref.current = null; };
276
- useEffect(() => () => { if (소리멈춤Ref.current)
277
- stopSoundIfOwner(소리멈춤Ref.current); }, []);
275
+ const releaseSound = () => { releaseSoundRef.current?.(); releaseSoundRef.current = null; };
276
+ useEffect(() => () => { if (stopSoundRef.current)
277
+ stopSoundIfOwner(stopSoundRef.current); }, []);
278
278
  /* 이벤트 손잡이·비동기 흐름 안에서 **지금 값**을 읽어야 한다 — 효과가 다시 돌 때까지
279
279
  기다리면 이미 실은 소리에 옛 빠르기가 걸린다(이 파일의 `disabledRef`·`leadInMsRef` 와 같은 결). */
280
280
  const playbackRateRef = useRef(playbackRate);
@@ -285,7 +285,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
285
285
  * `load()` 는 명세대로 `playbackRate` 를 **`defaultPlaybackRate` 로 되돌린다.** 기본값을
286
286
  * 안 세워 두면 그 되돌림이 늘 1 이라, 소리를 실을 때마다 사용자가 고른 빠르기가 지워진다.
287
287
  */
288
- const 빠르기걸기 = useCallback((a) => {
288
+ const applyPlaybackRate = useCallback((a) => {
289
289
  const r = playbackRateRef.current || 1.0;
290
290
  try {
291
291
  a.defaultPlaybackRate = r;
@@ -309,12 +309,12 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
309
309
  * 자리를 하나로 모은다 — 흩어진 다섯을 글로 단속하지 않고 **셈으로 막는다**
310
310
  * (`test/audioPlaybackRateSurvivesLoad.test.tsx` 가 `load()` 자리 수를 센다).
311
311
  */
312
- const 소리싣기 = useCallback((a, src) => {
312
+ const loadSound = useCallback((a, src) => {
313
313
  a.src = src;
314
- 빠르기걸기(a); // defaultPlaybackRate 를 먼저 세워 둔다 — load() 가 그것으로 되돌린다
314
+ applyPlaybackRate(a); // defaultPlaybackRate 를 먼저 세워 둔다 — load() 가 그것으로 되돌린다
315
315
  a.load();
316
- 빠르기걸기(a); // 되돌린 뒤 한 번 더 — 기본값을 못 세우는 환경에서도 선다
317
- }, [빠르기걸기]);
316
+ applyPlaybackRate(a); // 되돌린 뒤 한 번 더 — 기본값을 못 세우는 환경에서도 선다
317
+ }, [applyPlaybackRate]);
318
318
  /**
319
319
  * **완료 연출을 걷는다** — 새 재생이 서면 지난 완료의 자취가 그 위에 남아선 안 된다.
320
320
  *
@@ -332,17 +332,17 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
332
332
  * 부른다(다시듣기 · ▶ · 번호 단추 · 불러오기가 다 그 길이다). 걷는 곳이 여럿이면
333
333
  * **한 곳이 안 따라간다** — 오늘 밤 내내 그 결이었다.
334
334
  */
335
- const 완료연출걷기 = useCallback(() => {
335
+ const clearDoneEffect = useCallback(() => {
336
336
  if (fillOutResetRef.current) {
337
337
  clearTimeout(fillOutResetRef.current);
338
338
  fillOutResetRef.current = null;
339
339
  }
340
340
  setFillCompletionPhase('idle');
341
341
  }, []);
342
- const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50, 앞뜸흐른 = 0) => {
342
+ const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50, leadElapsed = 0) => {
343
343
  /* 지난 완료의 접는 연출을 먼저 걷는다 — 위 설명 참고 */
344
- 완료연출걷기();
345
- const 내세대 = 진행기.generation();
344
+ clearDoneEffect();
345
+ const myGeneration = sequencer.generation();
346
346
  /* **이어 트는 것에는 앞뜸을 다시 안 준다**(사용자 신고, 2026-10-06).
347
347
  앞뜸은 *소리 앞*의 구간이다. 소리가 **이미 시작된** 것을 이어 트는데 앞뜸을 또 주면
348
348
  소리는 멈춘 자리(`currentTime`)에서 이어 가는데 **진행은 0 부터 다시 센다** — 말귀
@@ -356,16 +356,16 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
356
356
  > 소리 중에 멈췄다 이을 때도 귀를 다시 채비시키려 **앞뜸을 주는** 쪽이 나을 수도
357
357
  > 있다. 그러면 시간표를 *앞뜸 + 남은 소리 + 뒤뜸* 으로 다시 그려야 어긋나지 않는다.
358
358
  > 그것은 **사장님께 여쭐 일**이라 지금은 "앞뜸은 소리 앞" 이라는 뜻 그대로 둔다. */
359
- const 이어틀기 = !!a && a.currentTime > 0 && !a.ended;
359
+ const resuming = !!a && a.currentTime > 0 && !a.ended;
360
360
  /* 앞뜸에서 멈췄다 이으면 **남은 만큼만** 채운다 — 뜸도 시간표의 일부라 처음부터 다시
361
361
  세면 그 자리에서 이어지지 않는다(사용자 결정, 2026-10-06). */
362
- const 남은앞뜸 = Math.max(0, leadInMsRef.current - Math.max(0, 앞뜸흐른));
363
- if (남은앞뜸 > 0 && !이어틀기) {
362
+ const leadRemaining = Math.max(0, leadInMsRef.current - Math.max(0, leadElapsed));
363
+ if (leadRemaining > 0 && !resuming) {
364
364
  /* **스피커는 뜸이 시작될 때 잡는다**((85) 2b, 2026-10-06). 전에는 `await a.play()` 바로
365
365
  앞에서 잡았는데, 그러면 **앞뜸 내내 주인이 없다** — 그 사이 남이 소리를 내도 이쪽을
366
366
  못 멈추고, 뜸이 끝나면 둘이 함께 울린다. 소음은 이미 뜸부터 깔리니(0.268.0)
367
367
  **소리를 내고 있는 것이 맞다.** */
368
- 소리잡기('audio');
368
+ acquireSound('audio');
369
369
  startTicker();
370
370
  /* 앞뜸부터 **재생 중**이다 — 재생판이 이제 앞뜸부터 보이므로((86)) 단추도 ⏸ 로 서야
371
371
  누르는 뜻과 맞는다. ▶ 로 두면 "멈추려고 ▶ 를 누르는" 꼴이 된다. */
@@ -383,24 +383,24 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
383
383
  }
384
384
  catch (e) { }
385
385
  }
386
- if (!(await 진행기.waitPad(남은앞뜸, 'lead', 앞뜸흐른)))
386
+ if (!(await sequencer.waitPad(leadRemaining, 'lead', leadElapsed)))
387
387
  return false;
388
- if (!mountedRef.current || !진행기.isCurrent(내세대))
388
+ if (!mountedRef.current || !sequencer.isCurrent(myGeneration))
389
389
  return false;
390
390
  }
391
- 진행기.enterSound();
391
+ sequencer.enterSound();
392
392
  for (let i = 0; i < attempts; i++) {
393
- if (!mountedRef.current || !진행기.isCurrent(내세대))
393
+ if (!mountedRef.current || !sequencer.isCurrent(myGeneration))
394
394
  return false;
395
395
  try {
396
- 소리잡기('audio');
396
+ acquireSound('audio');
397
397
  await a.play();
398
398
  return true;
399
399
  }
400
400
  catch (err) {
401
401
  const errName = err instanceof Error ? err.name : (typeof err === 'object' && err && 'name' in err ? err.name : undefined);
402
402
  if (errName === 'AbortError') {
403
- if (!진행기.isCurrent(내세대))
403
+ if (!sequencer.isCurrent(myGeneration))
404
404
  return false;
405
405
  await new Promise((res) => setTimeout(res, delayMs));
406
406
  continue;
@@ -424,7 +424,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
424
424
  }
425
425
  catch (_) { }
426
426
  return false;
427
- }, [t, startTicker, 진행기, fadeInNoise, noiseSettingsRef, 완료연출걷기]);
427
+ }, [t, startTicker, sequencer, fadeInNoise, noiseSettingsRef, clearDoneEffect]);
428
428
  /**
429
429
  * **뒤뜸(사이 뜸)에서 멈췄다 이을 때** — 남은 만큼 돌고 하던 일을 잇는다.
430
430
  *
@@ -432,9 +432,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
432
432
  * 짧아지고 재생바가 그 순간 튄다 — 사용자가 짚은 것이 그 자리다. `onEnded` 의 사슬과
433
433
  * **같은 모양**이어야 하므로(진행바 켜기 · 소음 · 세대 가림 · `leave()`) 여기도 그대로 둔다.
434
434
  */
435
- const 뒤뜸이어가기 = useCallback((흐른) => {
436
- const 내세대 = 진행기.generation();
437
- const 남은 = Math.max(0, tailMsRef.current - Math.max(0, 흐른));
435
+ const continueAfterTail = useCallback((elapsed) => {
436
+ const myGeneration = sequencer.generation();
437
+ const remaining = Math.max(0, tailMsRef.current - Math.max(0, elapsed));
438
438
  startTicker();
439
439
  const ns = noiseSettingsRef.current;
440
440
  if (ns?.enabled && (ns.volume ?? 0) > 0) {
@@ -443,21 +443,21 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
443
443
  }
444
444
  catch (e) { }
445
445
  }
446
- const 잇기 = () => { try {
447
- 이어가기RefBox.current?.();
446
+ const resume = () => { try {
447
+ continueRef.current?.();
448
448
  }
449
449
  catch (e) { } };
450
- if (남은 <= 0) {
451
- 진행기.leave();
452
- 잇기();
450
+ if (remaining <= 0) {
451
+ sequencer.leave();
452
+ resume();
453
453
  return;
454
454
  }
455
- 진행기.waitPad(남은, 'tail', 흐른).then((이어도되나) => {
456
- 진행기.leave();
457
- if (이어도되나 && 진행기.isCurrent(내세대))
458
- 잇기();
455
+ sequencer.waitPad(remaining, 'tail', elapsed).then((mayContinue) => {
456
+ sequencer.leave();
457
+ if (mayContinue && sequencer.isCurrent(myGeneration))
458
+ resume();
459
459
  }).catch(() => { });
460
- }, [진행기, startTicker, fadeInNoise, noiseSettingsRef]);
460
+ }, [sequencer, startTicker, fadeInNoise, noiseSettingsRef]);
461
461
  const attachAudioListeners = useCallback(() => {
462
462
  const a = audioRef.current;
463
463
  if (!a)
@@ -465,7 +465,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
465
465
  const onPlay = () => {
466
466
  setPlaybackState("playing");
467
467
  setStateLocal("playMode");
468
- set구간표시(true);
468
+ setShowRange(true);
469
469
  startTicker();
470
470
  // 문장을 Web Audio 그래프(gain·미터)에 태운다 — 재생 제스처 시점에 resume, 컨텍스트가 달리고 나서야
471
471
  // 태우므로 그 전엔 요소가 제 볼륨으로 직접 울린다(무음 방지). 태운 뒤 볼륨은 GainNode 로 걸린다.
@@ -501,17 +501,17 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
501
501
  다음 소리가 있으면 그 소리의 **앞 뜸**이 이어 붙어 사이가 둘을 더한 만큼이 된다 —
502
502
  누를 때의 뜸과 사이의 뜸을 가르는 자리가 여기다. */
503
503
  const onEnded = () => {
504
- const 이어가기 = () => {
504
+ const continuePlay = () => {
505
505
  try {
506
506
  const sref = sourcesRef.current;
507
507
  const next = nextTrackIndex(sref, currentIndexRef.current);
508
508
  if (next !== null) {
509
509
  currentIndexRef.current = next;
510
510
  setCurrentIndex(next);
511
- set구간표시(true);
511
+ setShowRange(true);
512
512
  const nextSrc = sref?.[next] && sref[next].src;
513
513
  if (nextSrc && a) {
514
- 소리싣기(a, nextSrc);
514
+ loadSound(a, nextSrc);
515
515
  tryPlayWithRetry(a).catch(() => { });
516
516
  return;
517
517
  }
@@ -529,7 +529,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
529
529
  }
530
530
  catch (e) { }
531
531
  stopTicker();
532
- set구간표시(false);
532
+ setShowRange(false);
533
533
  // 마지막 트랙 종료 시 소음 정지
534
534
  fadeOutNoise();
535
535
  // Route through handleEnd (via ref) so stateLocal transitions to 'active' before onDone fires.
@@ -541,21 +541,21 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
541
541
  catch (e) { }
542
542
  };
543
543
  /* 멈췄다 다시 누를 때 **여기서부터** 잇는다 — 진행기가 그 자리를 안다 */
544
- 이어가기RefBox.current = 이어가기;
544
+ continueRef.current = continuePlay;
545
545
  if (tailMsRef.current > 0) {
546
546
  /* 기다리는 동안 진행바를 돌린다 — `pause` 가 이미 세웠을 수 있어 다시 켠다.
547
547
  그 사이 멈추거나 떠나면 `waitPad` 가 거짓을 주고 아무 일도 안 한다. */
548
548
  startTicker();
549
- 진행기.waitPad(tailMsRef.current, 'tail').then((이어도되나) => {
549
+ sequencer.waitPad(tailMsRef.current, 'tail').then((mayContinue) => {
550
550
  /* 뒤뜸이 끝났다 — 자리는 비우되 **세대는 안 올린다**(`abort` 가 아니다). 올리면
551
551
  바로 아래에서 이어 갈 사슬까지 제가 버린 것이 된다. */
552
- 진행기.leave();
553
- if (이어도되나)
554
- 이어가기();
552
+ sequencer.leave();
553
+ if (mayContinue)
554
+ continuePlay();
555
555
  }).catch(() => { });
556
556
  return;
557
557
  }
558
- 이어가기();
558
+ continuePlay();
559
559
  };
560
560
  a.addEventListener("play", onPlay);
561
561
  a.addEventListener("pause", onPause);
@@ -569,7 +569,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
569
569
  }
570
570
  catch (e) { }
571
571
  };
572
- }, [fadeInNoise, fadeOutNoise, tryPlayWithRetry, noiseSettingsRef, startTicker, stopTicker, 진행기]);
572
+ }, [fadeInNoise, fadeOutNoise, tryPlayWithRetry, noiseSettingsRef, startTicker, stopTicker, sequencer]);
573
573
  useEffect(() => {
574
574
  const cleanup = attachAudioListeners();
575
575
  return cleanup;
@@ -735,21 +735,21 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
735
735
  *
736
736
  * 차례는 이렇다: 돌림판 → 준비 끝 → **재생판·파형·재생바가 보임** → 앞뜸(소음) → 소리.
737
737
  */
738
- const 재생판켜기 = () => {
738
+ const openPlayPanel = () => {
739
739
  setStateLocal('playMode');
740
740
  setCurrentIndex(0);
741
- set구간표시(true);
741
+ setShowRange(true);
742
742
  };
743
743
  const loadAndPlay = async (a, src) => {
744
744
  markCompletedByEnd(false);
745
745
  fetchingRef.current = false;
746
- 소리싣기(a, src);
746
+ loadSound(a, src);
747
747
  await waitForCanPlay(a);
748
748
  // 기다리는 사이 떠났으면(false) 이어 가지 않는다 — 떼어진 <audio> 를 틀던 것
749
749
  if ((await waitForWaveReady()) === false)
750
750
  return;
751
751
  setIsFetchingUrl(false);
752
- 재생판켜기(); // 앞뜸이 시작되기 **전에** — 뜸 동안 판이 비지 않게
752
+ openPlayPanel(); // 앞뜸이 시작되기 **전에** — 뜸 동안 판이 비지 않게
753
753
  await tryPlayWithRetry(a);
754
754
  };
755
755
  const fetchAndPrepareFromTTSUrl = async (ttsUrl, playAfterLoad = false) => {
@@ -896,7 +896,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
896
896
  fetchingRef.current = false;
897
897
  if (first.src && a) {
898
898
  try {
899
- 소리싣기(a, first.src);
899
+ loadSound(a, first.src);
900
900
  // canplay 이벤트 대기 — load() 직후 play() 호출 시 발생하는 AbortError 방지.
901
901
  // 짧은 파일도 canplay가 빠르게 발생하므로 재생 지연 없음.
902
902
  // (같은 기다림이 이 파일 위쪽에 waitForCanPlay 로 이미 있었다. 여기에 한 벌
@@ -909,7 +909,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
909
909
  // 뒤늦게 나타나는 빈 화면 구간도 생기지 않도록 한다.
910
910
  if (showSpinner)
911
911
  setIsFetchingUrl(false);
912
- 재생판켜기(); // 앞뜸이 시작되기 **전에** — 뜸 동안 판이 비지 않게
912
+ openPlayPanel(); // 앞뜸이 시작되기 **전에** — 뜸 동안 판이 비지 않게
913
913
  if (a)
914
914
  await tryPlayWithRetry(a);
915
915
  }
@@ -924,7 +924,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
924
924
  }
925
925
  setStateLocal('playMode');
926
926
  setCurrentIndex(0);
927
- set구간표시(true);
927
+ setShowRange(true);
928
928
  }
929
929
  return finalSources.length > 0;
930
930
  }
@@ -944,7 +944,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
944
944
  * 300ms 에 ⏸ → 소음 `play()` 가 또 불리고 **같은 단어가 두 번 틀어졌다**(2622ms · 2945ms).
945
945
  * 내 못은 저장소 소리로만 돌려서 통과했다 — **고친 벌만 재고 있었다.**
946
946
  */
947
- const 요소토글 = async (a) => {
947
+ const toggleElement = async (a) => {
948
948
  /* **뜸 중이면 재생 중이다**(말귀 요청 (85) 0단계에서 찾은 결함, 2026-10-06).
949
949
  전에는 `a.paused` 만 봤다. 그런데 뜸 동안 메인 요소는 **멈춰 있다** — 앞뜸에는 아직
950
950
  `play()` 전이고 사이·뒤뜸에는 이미 `ended` 다. 그래서 ⏸ 로 보이는 단추를 뜸 중에
@@ -953,8 +953,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
953
953
  +1000ms 에 한 번, 원래 사슬이 닿는 +1500ms 에 또 한 번).
954
954
  감시 타이머가 0.268.0 에서 배운 것과 같은 것을 이 단추가 못 배우고 있었다 —
955
955
  뜸은 소리가 안 나는 구간이지 **멈춘 구간이 아니다.** */
956
- const 뜸도는중 = 진행기.isRunning();
957
- if (a.paused && !뜸도는중) {
956
+ const padRunning = sequencer.isRunning();
957
+ if (a.paused && !padRunning) {
958
958
  if (completedByEndRef.current) {
959
959
  await handleReplay();
960
960
  return;
@@ -964,26 +964,26 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
964
964
  다시 세지 않는다. */
965
965
  /* **멈춘 자리에서 이어 간다** — 어디서 몇 ms 였는지는 진행기가 안다.
966
966
  뜸도 시간표의 일부라 **남은 만큼만** 채운다(사용자 결정, 2026-10-06). */
967
- const 멈췄던곳 = 진행기.resume();
968
- if (멈췄던곳) {
967
+ const resumedAt = sequencer.resume();
968
+ if (resumedAt) {
969
969
  setPlaybackState("playing");
970
- if (멈췄던곳.phase === 'tail') {
971
- 뒤뜸이어가기(멈췄던곳.흐른);
970
+ if (resumedAt.phase === 'tail') {
971
+ continueAfterTail(resumedAt.elapsed);
972
972
  return;
973
973
  }
974
- await tryPlayWithRetry(a, 3, 50, 멈췄던곳.흐른);
974
+ await tryPlayWithRetry(a, 3, 50, resumedAt.elapsed);
975
975
  return;
976
976
  }
977
977
  await tryPlayWithRetry(a);
978
978
  setPlaybackState("playing");
979
979
  }
980
980
  else {
981
- 진행기.pause();
982
- 멈춤의도();
981
+ sequencer.pause();
982
+ abortIntent();
983
983
  a.pause();
984
984
  setPlaybackState("paused");
985
985
  /* 뜸 중에는 `pause` 사건이 안 온다(요소가 이미 멈춰 있다) — 그 사건이 하던 일을 여기서 한다 */
986
- if (뜸도는중) {
986
+ if (padRunning) {
987
987
  stopTicker();
988
988
  fadeOutNoise();
989
989
  }
@@ -1044,16 +1044,16 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1044
1044
  if (stateLocal === 'playMode') {
1045
1045
  /* 오디오 파일로 변환된 경우 audioRef로 제어. <audio> 는 늘 있으므로 그것만 보면 음성 합성으로 말하는 중(리졸버 없음·실패)에도
1046
1046
  이 길로 들어가 소리 없는 <audio> 를 재생하려 들고 말은 멈추지 않았다 — 합성기가 말하는 중이고 <audio> 에 소리가 없으면 합성기를 멈춘다. */
1047
- const 합성으로말함 = !!synth && (synth.speaking || synth.paused) && !(a && (a.currentSrc || a.getAttribute('src')));
1048
- if (a && !합성으로말함) {
1049
- await 요소토글(a); // 저장소 소리 갈래와 **같은 한 벌**
1047
+ const speakingByTTS = !!synth && (synth.speaking || synth.paused) && !(a && (a.currentSrc || a.getAttribute('src')));
1048
+ if (a && !speakingByTTS) {
1049
+ await toggleElement(a); // 저장소 소리 갈래와 **같은 한 벌**
1050
1050
  return;
1051
1051
  }
1052
1052
  // speechSynthesis fallback
1053
1053
  try {
1054
1054
  if (synth && synth.paused) {
1055
1055
  try {
1056
- 소리잡기('tts');
1056
+ acquireSound('tts');
1057
1057
  synth.resume();
1058
1058
  setPlaybackState('playing');
1059
1059
  }
@@ -1079,30 +1079,30 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1079
1079
  try {
1080
1080
  if (first.src && a) {
1081
1081
  try {
1082
- 소리싣기(a, first.src);
1083
- 재생판켜기(); // 앞뜸이 시작되기 **전에**
1082
+ loadSound(a, first.src);
1083
+ openPlayPanel(); // 앞뜸이 시작되기 **전에**
1084
1084
  if (a)
1085
1085
  await tryPlayWithRetry(a);
1086
1086
  }
1087
1087
  catch (e) { }
1088
1088
  }
1089
- 재생판켜기();
1089
+ openPlayPanel();
1090
1090
  return;
1091
1091
  }
1092
1092
  catch (e) { }
1093
1093
  }
1094
1094
  setStateLocal("load");
1095
1095
  // 앱이 준 경로 규칙(resolvePath 등)이 던져도 못 얻은 것과 같이 다룬다 — 거절이 흘러 'load' 에 갇혔다
1096
- let 얻음;
1096
+ let got;
1097
1097
  try {
1098
- 얻음 = await fetchAndPrepare(true);
1098
+ got = await fetchAndPrepare(true);
1099
1099
  }
1100
1100
  catch (e) {
1101
- 얻음 = false;
1101
+ got = false;
1102
1102
  }
1103
1103
  /* 쓸 소스를 하나도 못 얻으면(경로를 못 정한 코드뿐 등) 'load' 에 갇혀 듣기 단추가 사라지고 아무 조작도
1104
1104
  안 됐다 — 알리고 누를 수 있는 상태로 되돌린다. */
1105
- if (얻음 === false && mountedRef.current) {
1105
+ if (got === false && mountedRef.current) {
1106
1106
  try {
1107
1107
  setNoticeMessage(t('training.audioFetchError', '사운드 소스를 가져오지 못했습니다.'));
1108
1108
  }
@@ -1116,7 +1116,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1116
1116
  if (stateLocal === "playMode" || stateLocal === "active") {
1117
1117
  try {
1118
1118
  if (a)
1119
- await 요소토글(a);
1119
+ await toggleElement(a);
1120
1120
  }
1121
1121
  catch (e) {
1122
1122
  const errName = e instanceof Error ? e.name : (typeof e === 'object' && e && 'name' in e ? e.name : undefined);
@@ -1153,8 +1153,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1153
1153
  };
1154
1154
  const handleStop = () => {
1155
1155
  // 기다리던 트랙 이동·재시도가 정지 뒤에 소리를 내지 않게
1156
- 멈춤의도();
1157
- 소리놓기();
1156
+ abortIntent();
1157
+ releaseSound();
1158
1158
  /* **`ttsMode` 라고 `<audio>` 를 안 멈추면 안 된다**(2026-10-06). 전에는 여기서 `stopTTS()`
1159
1159
  만 하고 **일찍 돌아갔다.** 그런데 TTS 에는 길이 둘이다 — 리졸버가 있으면 **주소를 받아
1160
1160
  `<audio>` 로 튼다**(그 길에 뜸·파형·번호 단추가 다 달려 있다). 기기 목소리로 말할 때만
@@ -1182,7 +1182,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1182
1182
  setPlaybackState('stopped');
1183
1183
  setStateLocal('active');
1184
1184
  setProgress(0);
1185
- set구간표시(false);
1185
+ setShowRange(false);
1186
1186
  };
1187
1187
  /**
1188
1188
  * 트랙을 갈아 끼우고 재생한다 — 이전·다음·번호 단추가 모두 이 한 벌을 쓴다.
@@ -1207,28 +1207,28 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1207
1207
  `a.src` 를 다음 소리로 바꿔 놓아서다.
1208
1208
  그 토큰은 **세대와 같은 일**이었다((85) 2d) — `멈춤의도()` 가 둘 다 올리고 여기서
1209
1209
  둘 다 집었다. 세대 하나로 합친다: `abort()` 로 걷고, **그 뒤에** 제 세대를 집는다. */
1210
- 멈춤의도();
1211
- const 내세대 = 진행기.generation();
1210
+ abortIntent();
1211
+ const myGeneration = sequencer.generation();
1212
1212
  const a = audioRef.current;
1213
1213
  const sref = sourcesRef.current;
1214
1214
  const src = sref?.[index]?.src;
1215
1215
  try {
1216
1216
  markCompletedByEnd(false);
1217
1217
  setCurrentIndex(index);
1218
- set구간표시(true); // 재생이 시작되기 전에도 밝힌다 — 예전엔 효과가 해 주던 일
1218
+ setShowRange(true); // 재생이 시작되기 전에도 밝힌다 — 예전엔 효과가 해 주던 일
1219
1219
  currentIndexRef.current = index;
1220
1220
  if (!src || !a)
1221
1221
  return;
1222
1222
  // 같은 구간을 다시 트는 것이면 파형은 그대로다 — 괜히 기다리지 않는다.
1223
1223
  if (!sameSegment)
1224
1224
  markWaveNotReady();
1225
- 소리싣기(a, src);
1225
+ loadSound(a, src);
1226
1226
  if (!sameSegment && (await waitForWaveReady()) === false)
1227
1227
  return;
1228
- if (!진행기.isCurrent(내세대))
1228
+ if (!sequencer.isCurrent(myGeneration))
1229
1229
  return;
1230
1230
  await tryPlayWithRetry(a);
1231
- if (!진행기.isCurrent(내세대))
1231
+ if (!sequencer.isCurrent(myGeneration))
1232
1232
  return;
1233
1233
  setPlaybackState('playing');
1234
1234
  setStateLocal('playMode');
@@ -1269,8 +1269,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1269
1269
  const holdDelay = 200;
1270
1270
  const outDelay = 340;
1271
1271
  markCompletedByEnd(true);
1272
- 멈춤의도();
1273
- 소리놓기();
1272
+ abortIntent();
1273
+ releaseSound();
1274
1274
  try {
1275
1275
  setProgress(1);
1276
1276
  }
@@ -1305,7 +1305,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1305
1305
  setPlaybackState('stopped');
1306
1306
  }
1307
1307
  catch (e) { }
1308
- set구간표시(false);
1308
+ setShowRange(false);
1309
1309
  if (disabledRef.current)
1310
1310
  return;
1311
1311
  try {
@@ -1360,10 +1360,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1360
1360
  try {
1361
1361
  const a = audioRef.current;
1362
1362
  if (a && typeof a.playbackRate === 'number')
1363
- 빠르기걸기(a);
1363
+ applyPlaybackRate(a);
1364
1364
  }
1365
1365
  catch (e) { }
1366
- }, [playbackRate, 빠르기걸기]);
1366
+ }, [playbackRate, applyPlaybackRate]);
1367
1367
  // 문장(메인) 볼륨 반영 — 문제 소리 볼륨(effectiveSpeechVolume = 전체 소리). setElementVolume 이 그래프에 태운
1368
1368
  // 요소면 GainNode, 아니면 요소 volume 에 건다(iOS 실기기는 요소 volume 이 안 먹는다). 재생 중이면 소음과 같은 150ms 램프로 옮기고, 멈춰 있으면 바로 건다.
1369
1369
  // 백그라운드 탭에서는 RAF 가 멈춰 램프가 중간에 설 수 있으므로 끝값을 타이머로 한 번 더 박는다.
@@ -1374,7 +1374,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1374
1374
  return;
1375
1375
  const target = Math.max(0, Math.min(1, speechVolume));
1376
1376
  const ramp = speechRampRef.current;
1377
- const 끊기 = () => {
1377
+ const detach = () => {
1378
1378
  if (ramp.raf !== null) {
1379
1379
  cancelAnimationFrame(ramp.raf);
1380
1380
  ramp.raf = null;
@@ -1384,7 +1384,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1384
1384
  ramp.timer = null;
1385
1385
  }
1386
1386
  };
1387
- 끊기();
1387
+ detach();
1388
1388
  const from = getElementVolume(a);
1389
1389
  if (a.paused || Math.abs(from - target) < 0.001 || typeof requestAnimationFrame !== 'function') {
1390
1390
  try {
@@ -1403,11 +1403,11 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1403
1403
  ramp.raf = t < 1 ? requestAnimationFrame(step) : null;
1404
1404
  };
1405
1405
  ramp.raf = requestAnimationFrame(step);
1406
- ramp.timer = setTimeout(() => { 끊기(); try {
1406
+ ramp.timer = setTimeout(() => { detach(); try {
1407
1407
  setElementVolume(a, target);
1408
1408
  }
1409
1409
  catch (e) { } }, 200);
1410
- return 끊기;
1410
+ return detach;
1411
1411
  }, [speechVolume]);
1412
1412
  // fill (진행 마커) 클래스 및 초기 위치 계산 — RAF 루프에서 .style.left를 직접 갱신합니다.
1413
1413
  const isFillHolding = fillCompletionPhase === 'hold';
@@ -1426,12 +1426,12 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1426
1426
  누르면 소리를 받으러 가거나 "가져오지 못했습니다" 를 띄웠다 — 마우스와 같게 키보드도 막는다 */
1427
1427
  disabled: isDisabled,
1428
1428
  // 키보드로 눌렀을 때만 초점을 옮긴다 — 마우스로 누른 사람에게 '재생 / 멈춤' 초점 툴팁이 뜨지 않게
1429
- onClick: () => { 듣기초점옮기기Ref.current = 키보드로눌렀나(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1429
+ onClick: () => { moveFocusToListenRef.current = wasKeyboardPress(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1430
1430
  // 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
1431
1431
  onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, inert: !(stateLocal === "playMode" || displayedPlaying) || undefined, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), "aria-pressed": displayedPlaying, width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1432
1432
  handleJumpTo(idx);
1433
1433
  }
1434
- catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm,
1434
+ catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: showRange && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm,
1435
1435
  /* 뜸도 파형에 그린다((74)) — 2초 단어에 앞뒤 1초면 4초가 한 폭에 들어간다 */
1436
1436
  leadInMs: leadInMs, tailMs: tailMs, trackDurationSec: sources?.[currentIndex]?.duration ?? 0, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
1437
1437
  /* 그림은 "문장 = 기준" — 문제 소리(M÷R)가 아니라 전체 소리 M 으로 문장을 그리고 소음을 R 배로 키운다 */