@ihabdevteam/core 0.282.0 → 0.286.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.282.0";
1654
+ export declare const CORE_VERSION = "0.286.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,40 +135,25 @@ 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~2c, 2026-10-06).
143
+ 묻는 쪽(소음 · 틱 · 토글 · 자물쇠)은 **자리를 받아 제 손으로 셈하지 않고** 진행기에게
144
+ 묻는다 — `isRunning()` 하나다. 그 셈이 세 벌로 흩어져 있던 것이 오늘 결함 여섯의 뿌리였다. */
145
+ const 진행기 = usePlaybackSequencer({ mountedRef });
146
+ /* `onEnded` 가 적어 두는 "여기서부터 이어 간다" — 토글이 멈출 때 진행기에 넘긴다 */
147
+ const 이어가기RefBox = useRef(null);
138
148
  /* 이벤트 손잡이는 한 번만 붙으므로 prop 을 ref 로 들고 본다 — 안 그러면 낡은 값을 쓴다
139
149
  (이 파일이 `disabledRef`·`noiseSettingsRef` 에 쓰는 것과 같은 결). */
140
150
  const leadInMsRef = useRef(leadInMs);
141
151
  const tailMsRef = useRef(tailMs);
142
152
  useEffect(() => { leadInMsRef.current = Math.max(0, leadInMs || 0); }, [leadInMs]);
143
153
  useEffect(() => { tailMsRef.current = Math.max(0, tailMs || 0); }, [tailMs]);
144
- const 뜸타이머Ref = useRef(null);
145
- /* 뒤뜸(소리가 끝난 뒤의 뜸)에서 멈췄나, 그리고 다시 누르면 어디서부터 이을 것인가 —
146
- 아래 `handleToggle` 이 쓴다(말귀 요청 (85) 0단계에서 찾은 결함, 2026-10-06). */
147
- const 뒤뜸에서멈춤Ref = useRef(false);
148
- 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(() => () => 뜸취소(), [뜸취소]);
170
- const { start: startTicker, stop: stopTicker, settleTo: settleTicker, } = useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, padRef: 뜸Ref, leadInMs, tailMs });
154
+ /** 뜸만큼 기다린다 — 그 사이 **의도가 바뀌면 거짓**을 돌려 부르는 쪽이 그만둔다 */
155
+ const 뜸기다리기 = 진행기.waitPad;
156
+ const { start: startTicker, stop: stopTicker, settleTo: settleTicker, } = useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer: 진행기, leadInMs, tailMs });
171
157
  /* 파형이 자리를 잡을 때까지 기다리는 규칙은 useWaveReady 가 들고 있다 —
172
158
  ref 둘·함수 둘에 "준비 안 됨으로 되돌리기"가 일곱 군데 흩어져 있던 것을 모았다. */
173
159
  const { onReadyChange: handleWaveReadyChange, waitForReady: waitForWaveReady, markNotReady: markWaveNotReady, } = useWaveReady({ showSoundWaveForm, showNoiseWaveForm });
@@ -194,7 +180,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
194
180
  noisePlayer,
195
181
  audioRef,
196
182
  playbackStateRef,
197
- padRef: 뜸Ref,
183
+ sequencer: 진행기,
198
184
  });
199
185
  const { startTTS: 합성시작, stopTTS } = useTTSPlayback({
200
186
  ttsText,
@@ -234,20 +220,21 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
234
220
  const multiSources = Array.isArray(sources) && sources.length > 0 ? sources : (Array.isArray(segments) && segments.length > 0 ? segments : null);
235
221
  const sourceButtonCount = Math.max(Array.isArray(multiSources) ? multiSources.length : 0, requestedSources.length);
236
222
  // resolveMapping 제거됨 — JSON 파일 의존성 없이 resolveSoundPath(code, bucketPath) 로 직접 경로 결정
237
- /* 떠났나 — 불러오기(주소 받기·길이 재기·canplay·파형 대기)는 몇 초 걸리는데 그 사이 다음 문항으로 넘기거나
238
- 팝업을 닫아도 흐름이 계속 돌아, **떼어진 <audio> 에서 play()** 가 불려 이전 문장이 조작 단추 없이 끝까지
239
- 울렸다. 재생 직전에 본다. */
240
- const mountedRef = useRef(true);
241
223
  useEffect(() => {
242
224
  mountedRef.current = true;
243
225
  return () => { mountedRef.current = false; };
244
226
  }, []);
245
- /* 재생 의도 — 멈춤·정지·완료·트랙 이동이 올린다. 대기 중이던 play() 는 멈추면 AbortError 로 거절되는데, 그것을 "다시 시도할
246
- 오류" 로만 보고 다시 부르면 **정지를 눌렀는데 소리가 다시 났다**. 시작할 때의 의도가 그대로일 때만 다시 시도한다. */
247
- const 재생의도Ref = useRef(0);
248
- /* 뜸도 함께 끊는다((74)) — 의도가 바뀌면 `뜸기다리기` 가 거짓을 주지만, 타이머를 남겨
249
- 두면 뜸만큼 늦게 깨어나 쓸데없이 한 번 돈다. 여기서 바로 지운다. */
250
- const 멈춤의도 = () => { 재생의도Ref.current += 1; jumpTokenRef.current += 1; 뜸취소(); };
227
+ /**
228
+ * **그만두는 뜻을 한 자리에서 올린다**((85) 2b, 2026-10-06).
229
+ *
230
+ * 전에는 **두 벌**이었다 — `재생의도Ref`(`play()` 를 기다리는 사이의 멈춤을 가린다)와
231
+ * 진행기의 **세대**(뜸을 기다리는 사이의 멈춤을 가린다). 올리는 자리가 넷인데 그중 둘만
232
+ * 세대를 올렸다(`멈춤의도` · `goToTrack`). 소리 자물쇠가 뺏어 갈 때(`소리멈춤Ref`)와
233
+ * 완료할 때는 **의도만** 올려서, 그 사이 돌던 뜸은 제 것인 줄 알고 깨어났다.
234
+ *
235
+ * 이제 세대 하나다. `멈춤의도()` 는 세대를 올리고 자리를 비운다.
236
+ */
237
+ const 멈춤의도 = () => { 진행기.abort(); jumpTokenRef.current += 1; };
251
238
  /* 스피커는 하나(currentSound.ts) — 한 화면의 다른 재생기(문장 듣기·문제 듣기)·낱말 카드·미리 듣기가 울리면 이 재생기는
252
239
  멈춘다. 전에는 낱말 카드만 자물쇠를 잡아, 듣기 단계가 둘인 화면·듣기+카드 단계에서 두 소리가 함께 울렸다.
253
240
  멈춤 단추와 같게 멈춘다(이어 듣기 가능). 함수는 한 벌이어야 자물쇠가 '내 것' 을 알아본다. */
@@ -259,15 +246,24 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
259
246
  소리멈춤Ref.current = () => {
260
247
  소리놓기Ref.current = null;
261
248
  const a = audioRef.current;
262
- if (소리종류Ref.current === 'audio' && a && !a.paused) {
263
- 재생의도Ref.current += 1;
264
- jumpTokenRef.current += 1;
249
+ /* **뜸 중이면 요소는 멈춰 있다** — `!a.paused` 만 보면 이 문을 안 지나, 남이 스피커를
250
+ 가져가도 **뜸은 제 것인 줄 알고 깨어나 소리를 냈다**(가짜 시계 실측, 두 길 다:
251
+ 앞뜸 300ms 에 남이 잡아도 1000ms 에 소리가 났다). 화면에 재생기가 둘인 자리(문장
252
+ 듣기 + 문제 듣기)와 낱말 카드가 그 길이다 — 치료사가 08-26 에 "1번·2번이 함께
253
+ 울린다" 고 보고한 그 자리의 **뜸 판**이다.
254
+ 오늘 같은 꼴을 여섯 번째 만난다(소음 감시 · 재생판 · 토글 · 그 토글의 두 번째 벌 ·
255
+ 번호 단추 · 여기). 이제 `isRunning()` 하나로 묻는다. */
256
+ if (소리종류Ref.current === 'audio' && a && (!a.paused || 진행기.isRunning())) {
257
+ 멈춤의도();
265
258
  try {
266
259
  a.pause();
267
260
  }
268
261
  catch (e) { }
269
- if (mountedRef.current)
262
+ if (mountedRef.current) {
270
263
  setPlaybackState('paused');
264
+ stopTicker();
265
+ fadeOutNoise();
266
+ }
271
267
  }
272
268
  else if (소리종류Ref.current === 'tts') {
273
269
  try {
@@ -326,10 +322,15 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
326
322
  빠르기걸기(a); // 되돌린 뒤 한 번 더 — 기본값을 못 세우는 환경에서도 선다
327
323
  }, [빠르기걸기]);
328
324
  const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
329
- const 의도 = 재생의도Ref.current;
325
+ const 내세대 = 진행기.generation();
330
326
  /* **앞 뜸** — 소리를 틀기 전에 기다린다. 기다리는 동안 진행바는 돌아야 하므로
331
327
  먼저 켠다(소리가 날 때 오는 `play` 이벤트가 아니라 **여기**가 시작이다). */
332
328
  if (leadInMsRef.current > 0) {
329
+ /* **스피커는 뜸이 시작될 때 잡는다**((85) 2b, 2026-10-06). 전에는 `await a.play()` 바로
330
+ 앞에서 잡았는데, 그러면 **앞뜸 내내 주인이 없다** — 그 사이 남이 소리를 내도 이쪽을
331
+ 못 멈추고, 뜸이 끝나면 둘이 함께 울린다. 소음은 이미 뜸부터 깔리니(0.268.0)
332
+ **소리를 내고 있는 것이 맞다.** */
333
+ 소리잡기('audio');
333
334
  startTicker();
334
335
  /* 앞뜸부터 **재생 중**이다 — 재생판이 이제 앞뜸부터 보이므로((86)) 단추도 ⏸ 로 서야
335
336
  누르는 뜻과 맞는다. ▶ 로 두면 "멈추려고 ▶ 를 누르는" 꼴이 된다. */
@@ -349,12 +350,12 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
349
350
  }
350
351
  if (!(await 뜸기다리기(leadInMsRef.current, 'lead')))
351
352
  return false;
352
- if (!mountedRef.current || 의도 !== 재생의도Ref.current)
353
+ if (!mountedRef.current || !진행기.isCurrent(내세대))
353
354
  return false;
354
355
  }
355
- 뜸Ref.current = { phase: 'sound', at: Date.now() };
356
+ 진행기.enterSound();
356
357
  for (let i = 0; i < attempts; i++) {
357
- if (!mountedRef.current || 의도 !== 재생의도Ref.current)
358
+ if (!mountedRef.current || !진행기.isCurrent(내세대))
358
359
  return false;
359
360
  try {
360
361
  소리잡기('audio');
@@ -364,7 +365,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
364
365
  catch (err) {
365
366
  const errName = err instanceof Error ? err.name : (typeof err === 'object' && err && 'name' in err ? err.name : undefined);
366
367
  if (errName === 'AbortError') {
367
- if (의도 !== 재생의도Ref.current)
368
+ if (!진행기.isCurrent(내세대))
368
369
  return false;
369
370
  await new Promise((res) => setTimeout(res, delayMs));
370
371
  continue;
@@ -432,7 +433,6 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
432
433
  다음 소리가 있으면 그 소리의 **앞 뜸**이 이어 붙어 사이가 둘을 더한 만큼이 된다 —
433
434
  누를 때의 뜸과 사이의 뜸을 가르는 자리가 여기다. */
434
435
  const onEnded = () => {
435
- 뒤뜸에서멈춤Ref.current = false;
436
436
  const 이어가기 = () => {
437
437
  try {
438
438
  const sref = sourcesRef.current;
@@ -472,14 +472,16 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
472
472
  }
473
473
  catch (e) { }
474
474
  };
475
- /* 토글이 뒤뜸에서 멈췄다가 다시 누를 때 **여기서부터** 잇는다 — 아래 `handleToggle` */
476
- 이어가기Ref.current = 이어가기;
475
+ /* 멈췄다 다시 누를 때 **여기서부터** 잇는다 — 진행기가 그 자리를 안다 */
476
+ 이어가기RefBox.current = 이어가기;
477
477
  if (tailMsRef.current > 0) {
478
478
  /* 기다리는 동안 진행바를 돌린다 — `pause` 가 이미 세웠을 수 있어 다시 켠다.
479
479
  그 사이 멈추거나 떠나면 `뜸기다리기` 가 거짓을 주고 아무 일도 안 한다. */
480
480
  startTicker();
481
481
  뜸기다리기(tailMsRef.current, 'tail').then((이어도되나) => {
482
- 뜸Ref.current = { phase: 'sound', at: 0 };
482
+ /* 뒤뜸이 끝났다 — 자리는 비우되 **세대는 안 올린다**(`abort` 가 아니다). 올리면
483
+ 바로 아래에서 이어 갈 사슬까지 제가 버린 것이 된다. */
484
+ 진행기.leave();
483
485
  if (이어도되나)
484
486
  이어가기();
485
487
  }).catch(() => { });
@@ -883,8 +885,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
883
885
  +1000ms 에 한 번, 원래 사슬이 닿는 +1500ms 에 또 한 번).
884
886
  감시 타이머가 0.268.0 에서 배운 것과 같은 것을 이 단추가 못 배우고 있었다 —
885
887
  뜸은 소리가 안 나는 구간이지 **멈춘 구간이 아니다.** */
886
- const 뜸 = 뜸Ref.current;
887
- const 뜸도는중 = 뜸.phase !== 'sound' && 뜸.at > 0;
888
+ const 뜸도는중 = 진행기.isRunning();
888
889
  if (a.paused && !뜸도는중) {
889
890
  if (completedByEndRef.current) {
890
891
  await handleReplay();
@@ -893,17 +894,19 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
893
894
  /* 뒤뜸에서 멈췄던 것이면 그 소리를 다시 틀지 않고 **그다음으로** 잇는다 — 끝난 요소에
894
895
  `play()` 를 걸면 방금 들은 소리가 처음부터 또 난다. 뜸은 이미 한 번 기다렸으니
895
896
  다시 세지 않는다. */
896
- if (뒤뜸에서멈춤Ref.current && 이어가기Ref.current) {
897
- 뒤뜸에서멈춤Ref.current = false;
897
+ /* **멈춘 자리에서 이어 간다** — 어디서 멈췄는지는 진행기가 안다.
898
+ `'restart'`(앞뜸이었다)면 아래로 흘러 처음부터 다시 돈다. */
899
+ const 이어갈것 = 진행기.resume();
900
+ if (typeof 이어갈것 === 'function') {
898
901
  setPlaybackState("playing");
899
- 이어가기Ref.current();
902
+ 이어갈것();
900
903
  return;
901
904
  }
902
905
  await tryPlayWithRetry(a);
903
906
  setPlaybackState("playing");
904
907
  }
905
908
  else {
906
- 뒤뜸에서멈춤Ref.current = 뜸도는중 && 뜸.phase === 'tail';
909
+ 진행기.pause(이어가기RefBox.current);
907
910
  멈춤의도();
908
911
  a.pause();
909
912
  setPlaybackState("paused");
@@ -1133,8 +1136,6 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1133
1136
  `멈춤의도()` 가 재생 의도와 뜸 타이머를 함께 걷는다 — **토큰을 집기 전에** 부른다
1134
1137
  (그 안에서 `jumpTokenRef` 도 올리므로, 뒤에 부르면 제 토큰이 바로 낡는다). */
1135
1138
  멈춤의도();
1136
- 뒤뜸에서멈춤Ref.current = false;
1137
- 이어가기Ref.current = null;
1138
1139
  const myToken = ++jumpTokenRef.current;
1139
1140
  const a = audioRef.current;
1140
1141
  const sref = sourcesRef.current;
@@ -5,7 +5,7 @@ type PlaybackState = 'stopped' | 'playing' | 'paused';
5
5
  * TrainingStepAudioPlayer에서 분리 — 소음(noise) 오디오 엘리먼트 생성/페이드인/페이드아웃/워치독을
6
6
  * 전담한다. audioRef(메인 문장 오디오)·playbackStateRef는 메인 컴포넌트 소유라 그대로 전달받는다.
7
7
  */
8
- export declare function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, padRef, }: {
8
+ export declare function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequencer, }: {
9
9
  noisePlayer: NoisePlayerContextValue | null;
10
10
  audioRef: RefObject<HTMLAudioElement | null>;
11
11
  playbackStateRef: RefObject<PlaybackState>;
@@ -17,10 +17,9 @@ export declare function useNoiseFade({ noisePlayer, audioRef, playbackStateRef,
17
17
  * 소음을 걷는다. 특히 아래 감시 타이머는 0.8초 뒤에 내리므로, 1000ms 뜸은 **800ms 에서
18
18
  * 소음이 꺼진다.** 뜸은 소리가 안 나는 구간이지 **재생이 끝난 구간이 아니다.**
19
19
  */
20
- padRef?: RefObject<{
21
- phase: 'lead' | 'sound' | 'tail';
22
- at: number;
23
- }>;
20
+ sequencer?: {
21
+ isRunning: () => boolean;
22
+ };
24
23
  }): {
25
24
  noiseAudioRef: RefObject<HTMLAudioElement | null>;
26
25
  activeNoiseUrl: string | null;
@@ -17,7 +17,7 @@ function generateNoiseUrl(type) {
17
17
  * TrainingStepAudioPlayer에서 분리 — 소음(noise) 오디오 엘리먼트 생성/페이드인/페이드아웃/워치독을
18
18
  * 전담한다. audioRef(메인 문장 오디오)·playbackStateRef는 메인 컴포넌트 소유라 그대로 전달받는다.
19
19
  */
20
- export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, padRef, }) {
20
+ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequencer, }) {
21
21
  const registerAnalyserRef = useRef(noisePlayer?.registerAnalyser);
22
22
  registerAnalyserRef.current = noisePlayer?.registerAnalyser;
23
23
  const noiseAudioRef = useRef(null);
@@ -35,11 +35,10 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, padRef,
35
35
  // 판별해, 이미 다른 의도(fadeIn 등)로 대체된 정지를 실행하지 않게 한다.
36
36
  const noiseFadeTokenRef = useRef(0);
37
37
  const noiseHardStopTimerRef = useRef(null);
38
- /** 지금 뜸 구간인가 — `at` 이 0 이면 뜸이 아니다(소리 구간으로 돌려놓은 표시) */
39
- const 뜸중 = useCallback(() => {
40
- const p = padRef?.current;
41
- return !!p && p.phase !== 'sound' && p.at > 0;
42
- }, [padRef]);
38
+ /* **"지금 시간표가 도는가" 는 진행기에게 묻는다**((85) 2c, 2026-10-06). 전에는 자리를
39
+ 받아 **제 손으로 셈했다**(`p.phase !== 'sound' && p.at > 0`) — 같은 셈이 틱·토글·여기
40
+ 세 벌이었다. 셈이 한 자리에 있어야 "뜸이란 무엇인가" 가 갈라지지 않는다. */
41
+ const 뜸중 = useCallback(() => !!sequencer?.isRunning(), [sequencer]);
43
42
  const cancelNoiseFade = useCallback(() => {
44
43
  if (noiseFadeRafRef.current !== null) {
45
44
  cancelAnimationFrame(noiseFadeRafRef.current);
@@ -0,0 +1,90 @@
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
+ * 멈출 때 `pause(이어가기)` 로 적고, 다시 누를 때 `resume()` 으로 묻는다. 답은 둘이다:
60
+ *
61
+ * `'restart'` 앞뜸에서 멈췄다 → **앞뜸부터 다시.** 앞뜸은 귀가 소음에 자리를 잡는
62
+ * 시간인데(0.268.0) 멈추면 소음도 멎으니, 남은 뜸만 채우면 그 시간이 잘린다.
63
+ * `(함수)` 사이·뒤뜸에서 멈췄다 → **그 자리에서 이어 간다**(다음 소리로, 마지막이면
64
+ * 완료로). 끝난 요소에 `play()` 를 걸면 방금 들은 소리가 처음부터 또 난다.
65
+ *
66
+ * 전에는 재생기가 ref 둘(`뒤뜸에서멈춤Ref` · `이어가기Ref`)로 들고 **멈추는 자리마다** 그
67
+ * 둘을 맞춰 적어야 했다 — 네 자리 중 한 곳만 빠뜨려도 틀린 데서 이어 간다.
68
+ * 어느 구간에서 멈췄는지는 **자리를 쥔 쪽**이 안다.
69
+ */
70
+ pause: (resume: (() => void) | null) => void;
71
+ /** 멈춘 자리에서 이어 갈 방법. 멈춘 적이 없으면 `null` */
72
+ resume: () => 'restart' | (() => void) | null;
73
+ /**
74
+ * 뜸만큼 기다린다. 그 사이 세대가 바뀌거나 화면을 떠나면 **거짓**을 준다 —
75
+ * 부르는 쪽은 거짓이면 아무 일도 하지 않고 돌아간다.
76
+ */
77
+ waitPad: (ms: number, phase: Exclude<PadPhase, 'sound'>) => Promise<boolean>;
78
+ /** 소리 구간으로 들어간다 */
79
+ enterSound: () => void;
80
+ /**
81
+ * **시간표에서 나온다** — 자리를 비우되 **세대는 안 올린다.**
82
+ *
83
+ * `abort()` 와 가르는 자리다. 뒤뜸을 다 기다리고 **제대로 끝났을 때** 부른다 — 세대를
84
+ * 올리면 그 뒤에 이어 갈 사슬까지 제가 버린 것이 된다.
85
+ */
86
+ leave: () => void;
87
+ }
88
+ export declare function usePlaybackSequencer({ mountedRef }: {
89
+ mountedRef: React.RefObject<boolean>;
90
+ }): PlaybackSequencer;
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useMemo, 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
+ /** 멈출 때의 자리 — `'restart'`(앞뜸이었다) 또는 이어 갈 함수, 안 멈췄으면 `null` */
22
+ const 멈춘자리Ref = useRef(null);
23
+ /* **`abort()` 보다 먼저 부른다** — `abort()` 는 자리를 비우므로 뒤에 부르면 "어느 구간에서
24
+ 멈췄나" 를 이미 잃는다. `pause()` 가 그 자리를 보고 적고, 그다음에 `abort()` 가 걷는다.
25
+ (`abort()` 가 이 적바림까지 지우게 했다가 못이 울렸다 — 멈춘 자리를 바로 지워 버려
26
+ 이을 때 앞 소리가 또 났다. `멈춤의도()` 안에서 둘이 잇따라 불린다.) */
27
+ const pause = useCallback((resume) => {
28
+ const p = positionRef.current;
29
+ const 뜸중 = p.phase !== 'sound' && p.at > 0;
30
+ 멈춘자리Ref.current = !뜸중 ? null : p.phase === 'lead' ? 'restart' : resume;
31
+ }, []);
32
+ const resume = useCallback(() => {
33
+ const v = 멈춘자리Ref.current;
34
+ 멈춘자리Ref.current = null;
35
+ return v;
36
+ }, []);
37
+ const abort = useCallback(() => {
38
+ 세대Ref.current += 1;
39
+ positionRef.current = { phase: 'sound', at: 0 };
40
+ }, []);
41
+ const waitPad = useCallback((ms, phase) => {
42
+ if (!(ms > 0))
43
+ return Promise.resolve(true);
44
+ const 내세대 = 세대Ref.current;
45
+ positionRef.current = { phase, at: Date.now() };
46
+ return new Promise((res) => {
47
+ const 내타이머 = setTimeout(() => {
48
+ if (뜸타이머Ref.current === 내타이머)
49
+ 뜸타이머Ref.current = null;
50
+ /* **깨어날 때 제 세대인지 본다.** 앞 기다림을 안 걷으므로 둘이 함께 깨어날 수 있는데,
51
+ 그때 낡은 쪽은 거짓을 받고 조용히 물러난다 — 사슬이 겹치지 않는 자리가 여기다. */
52
+ res(!!mountedRef.current && 내세대 === 세대Ref.current);
53
+ }, ms);
54
+ 뜸타이머Ref.current = 내타이머;
55
+ });
56
+ }, [mountedRef]);
57
+ const enterSound = useCallback(() => { positionRef.current = { phase: 'sound', at: Date.now() }; }, []);
58
+ const leave = useCallback(() => { positionRef.current = { phase: 'sound', at: 0 }; }, []);
59
+ /* **뜸 구간이면 돈다** — 메인 요소는 멈춰 있다(앞뜸은 `play()` 전, 뒤뜸은 `ended` 뒤).
60
+ `at > 0` 이 "소리 구간으로 돌려놓은 표시" 와 가른다. */
61
+ const isRunning = useCallback(() => {
62
+ const p = positionRef.current;
63
+ return p.phase !== 'sound' && p.at > 0;
64
+ }, []);
65
+ const generation = useCallback(() => 세대Ref.current, []);
66
+ const isCurrent = useCallback((gen) => gen === 세대Ref.current, []);
67
+ const __bumpGenerationForTest = useCallback(() => { 세대Ref.current += 1; }, []);
68
+ /* 화면을 떠나면 반드시 걷는다 — 마지막 그물 */
69
+ useEffect(() => () => {
70
+ if (뜸타이머Ref.current) {
71
+ clearTimeout(뜸타이머Ref.current);
72
+ 뜸타이머Ref.current = null;
73
+ }
74
+ }, []);
75
+ /**
76
+ * **판마다 새 객체면 안 된다**((85) 2c 에서 겪었다, 2026-10-06).
77
+ *
78
+ * 묻는 쪽이 이것을 **효과의 입력으로** 받는다. 판마다 새로 지으면 그 효과가 매번 다시
79
+ * 돌고, 손잡이 효과의 치우기가 **재생 도중 진행 사슬을 끊는다** — 그러면 뜸 동안 재생바가
80
+ * 멈춰 보인다. 0.271.0 에 세워 둔 문(`audioProgressChainRerender`)이 그 자리를 바로 잡았다.
81
+ * 안 세웠으면 "앞뜸에 재생바가 안 움직인다" 를 또 사용자가 짚을 뻔했다.
82
+ *
83
+ * 안쪽 것들은 전부 `useRef`·`useCallback` 이라 이미 고정이다 — 겉만 고정하면 된다.
84
+ */
85
+ return useMemo(() => ({ positionRef, isRunning, generation, isCurrent, abort, waitPad, enterSound, leave, pause, resume, __bumpGenerationForTest }), [isRunning, generation, isCurrent, abort, waitPad, enterSound, leave, pause, resume, __bumpGenerationForTest]);
86
+ }
@@ -1,6 +1,6 @@
1
1
  import { type RefObject } from 'react';
2
2
  import type { Source } from './trainingStepAudioPlayerTypes';
3
- export declare function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, padRef, leadInMs, tailMs, }: {
3
+ export declare function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer, leadInMs, tailMs, }: {
4
4
  audioRef: RefObject<HTMLAudioElement | null>;
5
5
  /** 진행 마커 — `left` 를 퍼센트로 민다 */
6
6
  fillRef: RefObject<HTMLDivElement | null>;
@@ -14,10 +14,12 @@ export declare function useProgressTicker({ audioRef, fillRef, waveProgressRef,
14
14
  * 뜸을 주면 **소리가 안 나는 동안에도 재생바가 움직여야 한다** — 전에는 `currentTime` 을
15
15
  * 그대로 따라 멈춰 있었다(말귀 실측: 누른 뒤 2029ms 에 `play`, 그때까지 채움 0%).
16
16
  */
17
- padRef?: RefObject<{
18
- phase: 'lead' | 'sound' | 'tail';
19
- at: number;
20
- }>;
17
+ sequencer?: {
18
+ positionRef: RefObject<{
19
+ phase: 'lead' | 'sound' | 'tail';
20
+ at: number;
21
+ }>;
22
+ };
21
23
  leadInMs?: number;
22
24
  tailMs?: number;
23
25
  }): {
@@ -61,7 +61,7 @@ function audioDurationOf(a) {
61
61
  * 아무 일도 안 한다.
62
62
  */
63
63
  const 느린틀 = 400;
64
- export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, padRef, leadInMs = 0, tailMs = 0, }) {
64
+ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer, leadInMs = 0, tailMs = 0, }) {
65
65
  const rafRef = useRef(null);
66
66
  const stop = useCallback(() => {
67
67
  if (rafRef.current === null)
@@ -111,8 +111,8 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
111
111
  const 뜸있다 = (leadInMs || 0) > 0 || (tailMs || 0) > 0;
112
112
  const pos = 뜸있다
113
113
  ? progressWithPad({
114
- phase: padRef?.current?.phase ?? 'sound',
115
- phaseElapsedMs: padRef?.current ? Math.max(0, Date.now() - padRef.current.at) : 0,
114
+ phase: sequencer?.positionRef.current?.phase ?? 'sound',
115
+ phaseElapsedMs: sequencer?.positionRef.current ? Math.max(0, Date.now() - sequencer.positionRef.current.at) : 0,
116
116
  currentTime: a && typeof a.currentTime === 'number' ? a.currentTime : 0,
117
117
  trackDurationSec: 트랙길이알아 ? 트랙.duration : (총길이 || 0),
118
118
  leadInMs: leadInMs || 0,
@@ -142,7 +142,7 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
142
142
  rafRef.current = requestAnimationFrame(step);
143
143
  };
144
144
  rafRef.current = requestAnimationFrame(step);
145
- }, [audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, padRef, leadInMs, tailMs]);
145
+ }, [audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer, leadInMs, tailMs]);
146
146
  /** 다 들었을 때처럼 끝에 붙여 놓아야 하는 자리 */
147
147
  const settleTo = useCallback((pos) => {
148
148
  stop();
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.282.0";
49
+ export declare const CORE_VERSION = "0.286.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.282.0';
49
+ export const CORE_VERSION = '0.286.0';