@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.
- package/CHANGELOG.md +128 -0
- package/dist/components.cjs.js +15 -15
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +17 -17
- package/dist/ihabdevteam-core.d.ts +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +61 -60
- package/dist/packages/core/src/components/Training/useNoiseFade.d.ts +4 -5
- package/dist/packages/core/src/components/Training/useNoiseFade.js +5 -6
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +90 -0
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +86 -0
- package/dist/packages/core/src/components/Training/useProgressTicker.d.ts +7 -5
- package/dist/packages/core/src/components/Training/useProgressTicker.js +4 -4
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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 ||
|
|
353
|
+
if (!mountedRef.current || !진행기.isCurrent(내세대))
|
|
353
354
|
return false;
|
|
354
355
|
}
|
|
355
|
-
|
|
356
|
+
진행기.enterSound();
|
|
356
357
|
for (let i = 0; i < attempts; i++) {
|
|
357
|
-
if (!mountedRef.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 (
|
|
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
|
-
/*
|
|
476
|
-
이어가기
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
897
|
-
|
|
897
|
+
/* **멈춘 자리에서 이어 간다** — 어디서 멈췄는지는 진행기가 안다.
|
|
898
|
+
`'restart'`(앞뜸이었다)면 아래로 흘러 처음부터 다시 돈다. */
|
|
899
|
+
const 이어갈것 = 진행기.resume();
|
|
900
|
+
if (typeof 이어갈것 === 'function') {
|
|
898
901
|
setPlaybackState("playing");
|
|
899
|
-
|
|
902
|
+
이어갈것();
|
|
900
903
|
return;
|
|
901
904
|
}
|
|
902
905
|
await tryPlayWithRetry(a);
|
|
903
906
|
setPlaybackState("playing");
|
|
904
907
|
}
|
|
905
908
|
else {
|
|
906
|
-
|
|
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,
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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,
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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,
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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,
|
|
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:
|
|
115
|
-
phaseElapsedMs:
|
|
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,
|
|
145
|
+
}, [audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer, leadInMs, tailMs]);
|
|
146
146
|
/** 다 들었을 때처럼 끝에 붙여 놓아야 하는 자리 */
|
|
147
147
|
const settleTo = useCallback((pos) => {
|
|
148
148
|
stop();
|