@ihabdevteam/core 0.281.0 → 0.284.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.
@@ -1651,7 +1651,7 @@ export declare function copyText(text: string): Promise<boolean>;
1651
1651
  *
1652
1652
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1653
1653
  */
1654
- export declare const CORE_VERSION = "0.281.0";
1654
+ export declare const CORE_VERSION = "0.284.0";
1655
1655
 
1656
1656
  /** @public */
1657
1657
  declare const coreDefault: Record<string, unknown>;
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { usePlaybackSequencer } from './usePlaybackSequencer';
3
4
  import { cx } from '../../utils/cx';
4
5
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
6
  import { useTranslation } from "react-i18next";
@@ -134,39 +135,27 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
134
135
  * **앞뒤 뜸 시간선에서 지금 어느 구간인가**(말귀 요청 (74), 2026-10-05).
135
136
  * 진행바(`useProgressTicker`)가 이것을 읽어 **소리가 안 나는 동안에도** 움직인다.
136
137
  */
137
- const 뜸Ref = useRef({ phase: 'sound', at: 0 });
138
+ /* 떠났나 — 불러오기(주소 받기·길이 재기·canplay·파형 대기)는 몇 초 걸리는데 그 사이 다음 문항으로 넘기거나
139
+ 팝업을 닫아도 흐름이 계속 돌아, **떼어진 <audio> 에서 play()** 가 불려 이전 문장이 조작 단추 없이 끝까지
140
+ 울렸다. 재생 직전에 본다. */
141
+ const mountedRef = useRef(true);
142
+ /* **시간표의 자리·의도·뜸 타이머는 진행기가 쥔다**((85) 2a, 2026-10-06).
143
+ 아래 `뜸Ref`·`뜸취소`·`뜸기다리기` 는 그것을 읽는 **얇은 껍데기**로 남는다 — 부르는
144
+ 자리가 아직 열 곳 넘어 한 번에 못 바꾼다. 2d 에서 걷는다. */
145
+ const 진행기 = usePlaybackSequencer({ mountedRef });
146
+ const 뜸Ref = 진행기.positionRef;
138
147
  /* 이벤트 손잡이는 한 번만 붙으므로 prop 을 ref 로 들고 본다 — 안 그러면 낡은 값을 쓴다
139
148
  (이 파일이 `disabledRef`·`noiseSettingsRef` 에 쓰는 것과 같은 결). */
140
149
  const leadInMsRef = useRef(leadInMs);
141
150
  const tailMsRef = useRef(tailMs);
142
151
  useEffect(() => { leadInMsRef.current = Math.max(0, leadInMs || 0); }, [leadInMs]);
143
152
  useEffect(() => { tailMsRef.current = Math.max(0, tailMs || 0); }, [tailMs]);
144
- const 뜸타이머Ref = useRef(null);
145
153
  /* 뒤뜸(소리가 끝난 뒤의 뜸)에서 멈췄나, 그리고 다시 누르면 어디서부터 이을 것인가 —
146
154
  아래 `handleToggle` 이 쓴다(말귀 요청 (85) 0단계에서 찾은 결함, 2026-10-06). */
147
155
  const 뒤뜸에서멈춤Ref = useRef(false);
148
156
  const 이어가기Ref = useRef(null);
149
- const 뜸취소 = useCallback(() => {
150
- if (뜸타이머Ref.current) {
151
- clearTimeout(뜸타이머Ref.current);
152
- 뜸타이머Ref.current = null;
153
- }
154
- 뜸Ref.current = { phase: 'sound', at: 0 };
155
- }, []);
156
- /** 뜸만큼 기다린다 — 그 사이 **재생 의도가 바뀌면 거짓**을 돌려 부르는 쪽이 그만둔다 */
157
- const 뜸기다리기 = useCallback((ms, phase) => {
158
- if (!(ms > 0))
159
- return Promise.resolve(true);
160
- const 의도 = 재생의도Ref.current;
161
- 뜸Ref.current = { phase, at: Date.now() };
162
- return new Promise((res) => {
163
- 뜸타이머Ref.current = setTimeout(() => {
164
- 뜸타이머Ref.current = null;
165
- res(mountedRef.current && 의도 === 재생의도Ref.current);
166
- }, ms);
167
- });
168
- }, []);
169
- useEffect(() => () => 뜸취소(), [뜸취소]);
157
+ /** 뜸만큼 기다린다 — 그 사이 **의도가 바뀌면 거짓**을 돌려 부르는 쪽이 그만둔다 */
158
+ const 뜸기다리기 = 진행기.waitPad;
170
159
  const { start: startTicker, stop: stopTicker, settleTo: settleTicker, } = useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, padRef: 뜸Ref, leadInMs, tailMs });
171
160
  /* 파형이 자리를 잡을 때까지 기다리는 규칙은 useWaveReady 가 들고 있다 —
172
161
  ref 둘·함수 둘에 "준비 안 됨으로 되돌리기"가 일곱 군데 흩어져 있던 것을 모았다. */
@@ -234,20 +223,21 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
234
223
  const multiSources = Array.isArray(sources) && sources.length > 0 ? sources : (Array.isArray(segments) && segments.length > 0 ? segments : null);
235
224
  const sourceButtonCount = Math.max(Array.isArray(multiSources) ? multiSources.length : 0, requestedSources.length);
236
225
  // resolveMapping 제거됨 — JSON 파일 의존성 없이 resolveSoundPath(code, bucketPath) 로 직접 경로 결정
237
- /* 떠났나 — 불러오기(주소 받기·길이 재기·canplay·파형 대기)는 몇 초 걸리는데 그 사이 다음 문항으로 넘기거나
238
- 팝업을 닫아도 흐름이 계속 돌아, **떼어진 <audio> 에서 play()** 가 불려 이전 문장이 조작 단추 없이 끝까지
239
- 울렸다. 재생 직전에 본다. */
240
- const mountedRef = useRef(true);
241
226
  useEffect(() => {
242
227
  mountedRef.current = true;
243
228
  return () => { mountedRef.current = false; };
244
229
  }, []);
245
- /* 재생 의도 — 멈춤·정지·완료·트랙 이동이 올린다. 대기 중이던 play() 는 멈추면 AbortError 로 거절되는데, 그것을 "다시 시도할
246
- 오류" 로만 보고 다시 부르면 **정지를 눌렀는데 소리가 다시 났다**. 시작할 때의 의도가 그대로일 때만 다시 시도한다. */
247
- const 재생의도Ref = useRef(0);
248
- /* 뜸도 함께 끊는다((74)) — 의도가 바뀌면 `뜸기다리기` 가 거짓을 주지만, 타이머를 남겨
249
- 두면 뜸만큼 늦게 깨어나 쓸데없이 한 번 돈다. 여기서 바로 지운다. */
250
- const 멈춤의도 = () => { 재생의도Ref.current += 1; jumpTokenRef.current += 1; 뜸취소(); };
230
+ /**
231
+ * **그만두는 뜻을 한 자리에서 올린다**((85) 2b, 2026-10-06).
232
+ *
233
+ * 전에는 **두 벌**이었다 — `재생의도Ref`(`play()` 를 기다리는 사이의 멈춤을 가린다)와
234
+ * 진행기의 **세대**(뜸을 기다리는 사이의 멈춤을 가린다). 올리는 자리가 넷인데 그중 둘만
235
+ * 세대를 올렸다(`멈춤의도` · `goToTrack`). 소리 자물쇠가 뺏어 갈 때(`소리멈춤Ref`)와
236
+ * 완료할 때는 **의도만** 올려서, 그 사이 돌던 뜸은 제 것인 줄 알고 깨어났다.
237
+ *
238
+ * 이제 세대 하나다. `멈춤의도()` 는 세대를 올리고 자리를 비운다.
239
+ */
240
+ const 멈춤의도 = () => { 진행기.abort(); jumpTokenRef.current += 1; };
251
241
  /* 스피커는 하나(currentSound.ts) — 한 화면의 다른 재생기(문장 듣기·문제 듣기)·낱말 카드·미리 듣기가 울리면 이 재생기는
252
242
  멈춘다. 전에는 낱말 카드만 자물쇠를 잡아, 듣기 단계가 둘인 화면·듣기+카드 단계에서 두 소리가 함께 울렸다.
253
243
  멈춤 단추와 같게 멈춘다(이어 듣기 가능). 함수는 한 벌이어야 자물쇠가 '내 것' 을 알아본다. */
@@ -259,15 +249,24 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
259
249
  소리멈춤Ref.current = () => {
260
250
  소리놓기Ref.current = null;
261
251
  const a = audioRef.current;
262
- if (소리종류Ref.current === 'audio' && a && !a.paused) {
263
- 재생의도Ref.current += 1;
264
- jumpTokenRef.current += 1;
252
+ /* **뜸 중이면 요소는 멈춰 있다** — `!a.paused` 만 보면 이 문을 안 지나, 남이 스피커를
253
+ 가져가도 **뜸은 제 것인 줄 알고 깨어나 소리를 냈다**(가짜 시계 실측, 두 길 다:
254
+ 앞뜸 300ms 에 남이 잡아도 1000ms 에 소리가 났다). 화면에 재생기가 둘인 자리(문장
255
+ 듣기 + 문제 듣기)와 낱말 카드가 그 길이다 — 치료사가 08-26 에 "1번·2번이 함께
256
+ 울린다" 고 보고한 그 자리의 **뜸 판**이다.
257
+ 오늘 같은 꼴을 여섯 번째 만난다(소음 감시 · 재생판 · 토글 · 그 토글의 두 번째 벌 ·
258
+ 번호 단추 · 여기). 이제 `isRunning()` 하나로 묻는다. */
259
+ if (소리종류Ref.current === 'audio' && a && (!a.paused || 진행기.isRunning())) {
260
+ 멈춤의도();
265
261
  try {
266
262
  a.pause();
267
263
  }
268
264
  catch (e) { }
269
- if (mountedRef.current)
265
+ if (mountedRef.current) {
270
266
  setPlaybackState('paused');
267
+ stopTicker();
268
+ fadeOutNoise();
269
+ }
271
270
  }
272
271
  else if (소리종류Ref.current === 'tts') {
273
272
  try {
@@ -326,10 +325,15 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
326
325
  빠르기걸기(a); // 되돌린 뒤 한 번 더 — 기본값을 못 세우는 환경에서도 선다
327
326
  }, [빠르기걸기]);
328
327
  const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
329
- const 의도 = 재생의도Ref.current;
328
+ const 내세대 = 진행기.generation();
330
329
  /* **앞 뜸** — 소리를 틀기 전에 기다린다. 기다리는 동안 진행바는 돌아야 하므로
331
330
  먼저 켠다(소리가 날 때 오는 `play` 이벤트가 아니라 **여기**가 시작이다). */
332
331
  if (leadInMsRef.current > 0) {
332
+ /* **스피커는 뜸이 시작될 때 잡는다**((85) 2b, 2026-10-06). 전에는 `await a.play()` 바로
333
+ 앞에서 잡았는데, 그러면 **앞뜸 내내 주인이 없다** — 그 사이 남이 소리를 내도 이쪽을
334
+ 못 멈추고, 뜸이 끝나면 둘이 함께 울린다. 소음은 이미 뜸부터 깔리니(0.268.0)
335
+ **소리를 내고 있는 것이 맞다.** */
336
+ 소리잡기('audio');
333
337
  startTicker();
334
338
  /* 앞뜸부터 **재생 중**이다 — 재생판이 이제 앞뜸부터 보이므로((86)) 단추도 ⏸ 로 서야
335
339
  누르는 뜻과 맞는다. ▶ 로 두면 "멈추려고 ▶ 를 누르는" 꼴이 된다. */
@@ -349,12 +353,12 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
349
353
  }
350
354
  if (!(await 뜸기다리기(leadInMsRef.current, 'lead')))
351
355
  return false;
352
- if (!mountedRef.current || 의도 !== 재생의도Ref.current)
356
+ if (!mountedRef.current || !진행기.isCurrent(내세대))
353
357
  return false;
354
358
  }
355
- 뜸Ref.current = { phase: 'sound', at: Date.now() };
359
+ 진행기.enterSound();
356
360
  for (let i = 0; i < attempts; i++) {
357
- if (!mountedRef.current || 의도 !== 재생의도Ref.current)
361
+ if (!mountedRef.current || !진행기.isCurrent(내세대))
358
362
  return false;
359
363
  try {
360
364
  소리잡기('audio');
@@ -364,7 +368,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
364
368
  catch (err) {
365
369
  const errName = err instanceof Error ? err.name : (typeof err === 'object' && err && 'name' in err ? err.name : undefined);
366
370
  if (errName === 'AbortError') {
367
- if (의도 !== 재생의도Ref.current)
371
+ if (!진행기.isCurrent(내세대))
368
372
  return false;
369
373
  await new Promise((res) => setTimeout(res, delayMs));
370
374
  continue;
@@ -479,7 +483,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
479
483
  그 사이 멈추거나 떠나면 `뜸기다리기` 가 거짓을 주고 아무 일도 안 한다. */
480
484
  startTicker();
481
485
  뜸기다리기(tailMsRef.current, 'tail').then((이어도되나) => {
482
- 뜸Ref.current = { phase: 'sound', at: 0 };
486
+ /* 뒤뜸이 끝났다 — 자리는 비우되 **세대는 안 올린다**(`abort` 가 아니다). 올리면
487
+ 바로 아래에서 이어 갈 사슬까지 제가 버린 것이 된다. */
488
+ 진행기.leave();
483
489
  if (이어도되나)
484
490
  이어가기();
485
491
  }).catch(() => { });
@@ -1080,10 +1086,15 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1080
1086
  // 기다리던 트랙 이동·재시도가 정지 뒤에 소리를 내지 않게
1081
1087
  멈춤의도();
1082
1088
  소리놓기();
1083
- if (ttsMode) {
1084
- stopTTS();
1085
- return;
1086
- }
1089
+ /* **`ttsMode` 라고 `<audio>` 를 안 멈추면 안 된다**(2026-10-06). 전에는 여기서 `stopTTS()`
1090
+ 만 하고 **일찍 돌아갔다.** 그런데 TTS 에는 길이 둘이다 — 리졸버가 있으면 **주소를 받아
1091
+ `<audio>` 로 튼다**(그 길에 뜸·파형·번호 단추가 다 달려 있다). 기기 목소리로 말할 때만
1092
+ `<audio>` 가 비어 있다. 그래서 합성 단어에서 [정지]를 눌러도 소리가 계속 돌았다 —
1093
+ 가짜 시계 실측: 정지 1200ms 인데 1600ms 에 `ended`, 다시 들으면 **1번이 두 번 나고
1094
+ 2번은 안 났다.**
1095
+ `stopTTS()` 는 기기 목소리를 멈추는 것이고, `<audio>` 를 멈추는 일은 **길과 상관없이**
1096
+ 해야 한다. 둘 다 한다. */
1097
+ stopTTSRef.current?.();
1087
1098
  const a = audioRef.current;
1088
1099
  try {
1089
1100
  if (a) {
@@ -1119,6 +1130,17 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1119
1130
  * 이동 토큰은 연타 방지다 — 파형을 기다리는 사이 더 최신 이동이 오면 이 호출은 물러선다.
1120
1131
  */
1121
1132
  const goToTrack = async (index, { sameSegment = false } = {}) => {
1133
+ /* **먼저 돌고 있는 시간표를 걷는다**(2026-10-06). `jumpTokenRef` 는 트랙 이동끼리만
1134
+ 가렸고, **뜸 사슬은 그대로 살아 있었다.** 그래서 사이 뜸에 번호 단추를 누르면
1135
+ 사슬이 둘이 된다 — 가짜 시계 실측(소리 둘 · 뜸 1000 · 사이 뜸 2100ms 에 [소스 1]):
1136
+ `b.mp3` 가 3100ms 와 3600ms 에 **두 번** 났다. 게다가 **누른 것은 1번인데 2번이
1137
+ 났다** — 이쪽이 뜸을 기다리는 동안 살아 있던 사슬이 `a.src` 를 다음 소리로 바꿔
1138
+ 놓아서, 뜸이 끝났을 때 실려 있던 것은 그 소리였다.
1139
+ `멈춤의도()` 가 재생 의도와 뜸 타이머를 함께 걷는다 — **토큰을 집기 전에** 부른다
1140
+ (그 안에서 `jumpTokenRef` 도 올리므로, 뒤에 부르면 제 토큰이 바로 낡는다). */
1141
+ 멈춤의도();
1142
+ 뒤뜸에서멈춤Ref.current = false;
1143
+ 이어가기Ref.current = null;
1122
1144
  const myToken = ++jumpTokenRef.current;
1123
1145
  const a = audioRef.current;
1124
1146
  const sref = sourcesRef.current;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * **지금 시간표의 어디인가 — 그것을 쥐는 한 자리**(말귀 요청 (85) 2단계, 2026-10-06).
3
+ *
4
+ * 재생기에는 이 주인이 없었다. 시간표는 이벤트 손잡이 · 타이머 · 토큰이 맞물린 **결과로
5
+ * 생길 뿐**이라, 알아야 하는 자리마다 메인 `<audio>` 를 보고 **제각기 짐작했다.** 그 짐작이
6
+ * 틀린 자리가 하루에 다섯 났다:
7
+ *
8
+ * 0.268.0 소음 감시가 "메인이 도나" 를 묻다가 **뜸에서 소음을 껐다**
9
+ * 0.278.0 재생판이 `play` 사건을 기다리다 **앞뜸 내내 비어 있었다**
10
+ * 0.278.0 토글이 `a.paused` 를 보다가 **뜸에서 소리를 두 번 냈다**
11
+ * 0.281.0 그 토글이 **두 벌**이라 한 벌만 고쳐져, 합성 단어에서 그대로 났다
12
+ * 0.282.0 번호 단추가 **돌던 뜸 사슬을 안 걷어** 다른 번호가 두 번 났다
13
+ *
14
+ * 다섯이 다 같은 물음이었다 — **"지금 시간표가 도는가, 돈다면 어디인가."**
15
+ *
16
+ * **2a 는 자리와 타이머만 옮긴다**(동작 불변). 엮는 일(`onEnded` · 토글 · 번호 단추)은 아직
17
+ * 재생기가 제 손으로 한다. 여기가 쥐는 것은 셋뿐이다:
18
+ *
19
+ * 자리 지금 어느 트랙의 어느 구간인가(`position`)
20
+ * 의도 그 자리가 아직 쓸모 있는가(`세대`) — 멈춤·정지·건너뛰기가 올린다
21
+ * 뜸 기다림 하나짜리 타이머. 세대가 바뀌면 깨어나도 **거짓**을 준다
22
+ *
23
+ * 쥐지 않는 것: 소리 요소 · 불러오기 · 소음 · 화면 상태. 그것들은 2b·2c 에서 온다.
24
+ */
25
+ export type PadPhase = 'lead' | 'sound' | 'tail';
26
+ export interface SequencerPosition {
27
+ phase: PadPhase;
28
+ /** 그 구간이 시작한 때(`Date.now()`). `sound` 이거나 안 돌면 0 */
29
+ at: number;
30
+ }
31
+ export interface PlaybackSequencer {
32
+ /** 틱·소음·토글이 읽는다 — 매 틀 읽히므로 ref 다 */
33
+ positionRef: React.RefObject<SequencerPosition>;
34
+ /** **시간표가 도는가** — 뜸 구간이면 메인 요소가 멈춰 있어도 참이다 */
35
+ isRunning: () => boolean;
36
+ /** 지금 세대. 이 값이 바뀌면 돌던 기다림은 버려진다 */
37
+ generation: () => number;
38
+ /**
39
+ * **지금 세대가 아직 그대로인가** — 긴 흐름이 중간중간 묻는다.
40
+ *
41
+ * `const 내세대 = generation()` 로 집어 두고 `isCurrent(내세대)` 로 묻는다. 뜸뿐 아니라
42
+ * `play()` 를 기다리는 사이에도 멈춤이 들어올 수 있어, 그 자리마다 이 물음이 필요하다.
43
+ */
44
+ isCurrent: (gen: number) => boolean;
45
+ /** 돌던 것을 그만둔다(세대를 올리고 뜸 타이머를 걷는다) */
46
+ abort: () => void;
47
+ /**
48
+ * **시험용 — 세대만 올린다.** 운영 코드에서 부를 일은 없다.
49
+ *
50
+ * 세대 비교가 일하는 길은 **타이머가 안 걷힌 채 깨어나는 때**인데, `abort()` 는 걷으므로
51
+ * 그 길이 안 열린다. 그래서 세대 비교를 지워도 아무 시험이 안 울렸다 — 시험에서 `abort()`
52
+ * 로 흉내 내려 했더니 **도우미가 타이머까지 걷어** 또 안 울렸다(자가 거짓말한 다섯 번째
53
+ * 자리다). 그 길을 **진짜로** 여는 문을 둔다.
54
+ */
55
+ __bumpGenerationForTest: () => void;
56
+ /**
57
+ * 뜸만큼 기다린다. 그 사이 세대가 바뀌거나 화면을 떠나면 **거짓**을 준다 —
58
+ * 부르는 쪽은 거짓이면 아무 일도 하지 않고 돌아간다.
59
+ */
60
+ waitPad: (ms: number, phase: Exclude<PadPhase, 'sound'>) => Promise<boolean>;
61
+ /** 소리 구간으로 들어간다 */
62
+ enterSound: () => void;
63
+ /**
64
+ * **시간표에서 나온다** — 자리를 비우되 **세대는 안 올린다.**
65
+ *
66
+ * `abort()` 와 가르는 자리다. 뒤뜸을 다 기다리고 **제대로 끝났을 때** 부른다 — 세대를
67
+ * 올리면 그 뒤에 이어 갈 사슬까지 제가 버린 것이 된다.
68
+ */
69
+ leave: () => void;
70
+ }
71
+ export declare function usePlaybackSequencer({ mountedRef }: {
72
+ mountedRef: React.RefObject<boolean>;
73
+ }): PlaybackSequencer;
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useRef } from 'react';
3
+ export function usePlaybackSequencer({ mountedRef }) {
4
+ const positionRef = useRef({ phase: 'sound', at: 0 });
5
+ const 세대Ref = useRef(0);
6
+ const 뜸타이머Ref = useRef(null);
7
+ /**
8
+ * **그만둘 때 타이머를 걷지 않는다 — 세대로 가린다.**
9
+ *
10
+ * 걷으면 그 약속은 **영영 안 풀린다.** 그러면 `waitPad(...).then(...)` 에 걸어 둔 뒷정리가
11
+ * 통째로 안 돈다 — 뒤뜸 쪽이 그렇다: `.then` 에서 `leave()` 로 자리를 비우는데, 그것이
12
+ * 안 돌면 자리가 `tail` 에 **영영 남아** `isRunning()` 이 참으로 굳는다(소음 감시가 속는다).
13
+ *
14
+ * 그래서 타이머는 그대로 두고 **세대만 올린다.** 늦게 깨어나도 세대가 달라 **거짓**을 주니
15
+ * 부르는 쪽은 아무 일도 안 하고 돌아가고, 뒷정리는 제대로 돈다. 늦게 터지는 타이머 하나는
16
+ * 아무 해도 없다.
17
+ *
18
+ * (처음에는 걷는 쪽으로 썼다가, 그 때문에 **세대 비교가 죽은 코드**가 된 것을 뒤늦게 봤다 —
19
+ * 걷어 버리면 세대를 볼 일이 없다. 둘 중 하나를 골라야 하는 자리였다.)
20
+ */
21
+ const abort = useCallback(() => {
22
+ 세대Ref.current += 1;
23
+ positionRef.current = { phase: 'sound', at: 0 };
24
+ }, []);
25
+ const waitPad = useCallback((ms, phase) => {
26
+ if (!(ms > 0))
27
+ return Promise.resolve(true);
28
+ const 내세대 = 세대Ref.current;
29
+ positionRef.current = { phase, at: Date.now() };
30
+ return new Promise((res) => {
31
+ const 내타이머 = setTimeout(() => {
32
+ if (뜸타이머Ref.current === 내타이머)
33
+ 뜸타이머Ref.current = null;
34
+ /* **깨어날 때 제 세대인지 본다.** 앞 기다림을 안 걷으므로 둘이 함께 깨어날 수 있는데,
35
+ 그때 낡은 쪽은 거짓을 받고 조용히 물러난다 — 사슬이 겹치지 않는 자리가 여기다. */
36
+ res(!!mountedRef.current && 내세대 === 세대Ref.current);
37
+ }, ms);
38
+ 뜸타이머Ref.current = 내타이머;
39
+ });
40
+ }, [mountedRef]);
41
+ const enterSound = useCallback(() => { positionRef.current = { phase: 'sound', at: Date.now() }; }, []);
42
+ const leave = useCallback(() => { positionRef.current = { phase: 'sound', at: 0 }; }, []);
43
+ /* **뜸 구간이면 돈다** — 메인 요소는 멈춰 있다(앞뜸은 `play()` 전, 뒤뜸은 `ended` 뒤).
44
+ `at > 0` 이 "소리 구간으로 돌려놓은 표시" 와 가른다. */
45
+ const isRunning = useCallback(() => {
46
+ const p = positionRef.current;
47
+ return p.phase !== 'sound' && p.at > 0;
48
+ }, []);
49
+ const generation = useCallback(() => 세대Ref.current, []);
50
+ const isCurrent = useCallback((gen) => gen === 세대Ref.current, []);
51
+ const __bumpGenerationForTest = useCallback(() => { 세대Ref.current += 1; }, []);
52
+ /* 화면을 떠나면 반드시 걷는다 — 마지막 그물 */
53
+ useEffect(() => () => {
54
+ if (뜸타이머Ref.current) {
55
+ clearTimeout(뜸타이머Ref.current);
56
+ 뜸타이머Ref.current = null;
57
+ }
58
+ }, []);
59
+ return { positionRef, isRunning, generation, isCurrent, abort, waitPad, enterSound, leave, __bumpGenerationForTest };
60
+ }
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.281.0";
49
+ export declare const CORE_VERSION = "0.284.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.281.0';
49
+ export const CORE_VERSION = '0.284.0';