@ihabdevteam/core 0.270.0 → 0.272.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.270.0";
1654
+ export declare const CORE_VERSION = "0.272.0";
1655
1655
 
1656
1656
  /** @public */
1657
1657
  declare const coreDefault: Record<string, unknown>;
@@ -281,6 +281,46 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
281
281
  const 소리놓기 = () => { 소리놓기Ref.current?.(); 소리놓기Ref.current = null; };
282
282
  useEffect(() => () => { if (소리멈춤Ref.current)
283
283
  stopSoundIfOwner(소리멈춤Ref.current); }, []);
284
+ /* 이벤트 손잡이·비동기 흐름 안에서 **지금 값**을 읽어야 한다 — 효과가 다시 돌 때까지
285
+ 기다리면 이미 실은 소리에 옛 빠르기가 걸린다(이 파일의 `disabledRef`·`leadInMsRef` 와 같은 결). */
286
+ const playbackRateRef = useRef(playbackRate);
287
+ useEffect(() => { playbackRateRef.current = playbackRate || 1.0; }, [playbackRate]);
288
+ /**
289
+ * **재생 속도를 요소에 건다** — `playbackRate` 와 `defaultPlaybackRate` **둘 다**.
290
+ *
291
+ * `load()` 는 명세대로 `playbackRate` 를 **`defaultPlaybackRate` 로 되돌린다.** 기본값을
292
+ * 안 세워 두면 그 되돌림이 늘 1 이라, 소리를 실을 때마다 사용자가 고른 빠르기가 지워진다.
293
+ */
294
+ const 빠르기걸기 = useCallback((a) => {
295
+ const r = playbackRateRef.current || 1.0;
296
+ try {
297
+ a.defaultPlaybackRate = r;
298
+ }
299
+ catch (e) { }
300
+ try {
301
+ a.playbackRate = r;
302
+ }
303
+ catch (e) { }
304
+ }, []);
305
+ /**
306
+ * **소리를 싣는 **유일한** 자리**(말귀 요청 (78), 2026-10-06).
307
+ *
308
+ * 전에는 `a.src = …; a.load();` 가 **다섯 군데**에 흩어져 있었다 — 첫 재생 · 다음 트랙 ·
309
+ * TTS 길 둘 · 번호 단추. `load()` 가 빠르기를 되돌리는데 **다시 거는 자리가 없었고**,
310
+ * 빠르기를 보는 효과는 **값이 바뀔 때만** 돌아서(값은 그대로 0.75 다) 아무도 안 고쳤다.
311
+ * 말귀 실측: 훈련을 열 때 `playbackRate = 0.75`, 누르면 `1`, 소리 길이 583ms(0.75x 면 780ms).
312
+ *
313
+ * 청능훈련에서 **느리게 들려주는 것**은 흔히 쓰는 길이라 이 설정이 안 먹으면 쓸 수가 없다.
314
+ *
315
+ * 자리를 하나로 모은다 — 흩어진 다섯을 글로 단속하지 않고 **셈으로 막는다**
316
+ * (`test/audioPlaybackRateSurvivesLoad.test.tsx` 가 `load()` 자리 수를 센다).
317
+ */
318
+ const 소리싣기 = useCallback((a, src) => {
319
+ a.src = src;
320
+ 빠르기걸기(a); // defaultPlaybackRate 를 먼저 세워 둔다 — load() 가 그것으로 되돌린다
321
+ a.load();
322
+ 빠르기걸기(a); // 되돌린 뒤 한 번 더 — 기본값을 못 세우는 환경에서도 선다
323
+ }, [빠르기걸기]);
284
324
  const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
285
325
  const 의도 = 재생의도Ref.current;
286
326
  /* **앞 뜸** — 소리를 틀기 전에 기다린다. 기다리는 동안 진행바는 돌아야 하므로
@@ -395,8 +435,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
395
435
  set구간표시(true);
396
436
  const nextSrc = sref?.[next] && sref[next].src;
397
437
  if (nextSrc && a) {
398
- a.src = nextSrc;
399
- a.load();
438
+ 소리싣기(a, nextSrc);
400
439
  tryPlayWithRetry(a).catch(() => { });
401
440
  return;
402
441
  }
@@ -603,8 +642,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
603
642
  const loadAndPlay = async (a, src) => {
604
643
  markCompletedByEnd(false);
605
644
  fetchingRef.current = false;
606
- a.src = src;
607
- a.load();
645
+ 소리싣기(a, src);
608
646
  await waitForCanPlay(a);
609
647
  // 기다리는 사이 떠났으면(false) 이어 가지 않는다 — 떼어진 <audio> 를 틀던 것
610
648
  if ((await waitForWaveReady()) === false)
@@ -759,8 +797,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
759
797
  fetchingRef.current = false;
760
798
  if (first.src && a) {
761
799
  try {
762
- a.src = first.src;
763
- a.load();
800
+ 소리싣기(a, first.src);
764
801
  // canplay 이벤트 대기 — load() 직후 play() 호출 시 발생하는 AbortError 방지.
765
802
  // 짧은 파일도 canplay가 빠르게 발생하므로 재생 지연 없음.
766
803
  // (같은 기다림이 이 파일 위쪽에 waitForCanPlay 로 이미 있었다. 여기에 한 벌
@@ -895,8 +932,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
895
932
  try {
896
933
  if (first.src && a) {
897
934
  try {
898
- a.src = first.src;
899
- a.load();
935
+ 소리싣기(a, first.src);
900
936
  if (a)
901
937
  await tryPlayWithRetry(a);
902
938
  }
@@ -1040,8 +1076,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1040
1076
  // 같은 구간을 다시 트는 것이면 파형은 그대로다 — 괜히 기다리지 않는다.
1041
1077
  if (!sameSegment)
1042
1078
  markWaveNotReady();
1043
- a.src = src;
1044
- a.load();
1079
+ 소리싣기(a, src);
1045
1080
  if (!sameSegment && (await waitForWaveReady()) === false)
1046
1081
  return;
1047
1082
  if (myToken !== jumpTokenRef.current)
@@ -1177,10 +1212,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1177
1212
  try {
1178
1213
  const a = audioRef.current;
1179
1214
  if (a && typeof a.playbackRate === 'number')
1180
- a.playbackRate = playbackRate || 1.0;
1215
+ 빠르기걸기(a);
1181
1216
  }
1182
1217
  catch (e) { }
1183
- }, [playbackRate]);
1218
+ }, [playbackRate, 빠르기걸기]);
1184
1219
  // 문장(메인) 볼륨 반영 — 문제 소리 볼륨(effectiveSpeechVolume = 전체 소리). setElementVolume 이 그래프에 태운
1185
1220
  // 요소면 GainNode, 아니면 요소 volume 에 건다(iOS 실기기는 요소 volume 이 안 먹는다). 재생 중이면 소음과 같은 150ms 램프로 옮기고, 멈춰 있으면 바로 건다.
1186
1221
  // 백그라운드 탭에서는 RAF 가 멈춰 램프가 중간에 설 수 있으므로 끝값을 타이머로 한 번 더 박는다.
@@ -71,7 +71,23 @@ export declare const STATUS_MESSAGES: Record<FetchDebugStatus, string>;
71
71
  * **보이는 글은 안 바뀐다.** 눈으로 보는 사람에게는 전과 똑같고, 읽히는 것만 준다.
72
72
  * 사람이 쓸 말로 묶는 쪽((가))은 따로 정하는 자리다.
73
73
  *
74
- * @public
74
+ * **어디까지 쟀는지 적어 둔다.** 잰 것은 **속성**이다 — 말귀 앱 실측(0.270.0):
75
+ * `준비 완료` 에서 `aria-live="off"`, 앞의 둘은 `polite`. **낭독기가 실제로 안 읽는지는
76
+ * 안 쟀다** — 둘 다 낭독기를 띄워 보지 못했다.
77
+ *
78
+ * 그 둘이 갈릴 수 있는 자리가 하나 있다: 낭독기 가운데는 **살아 있는 자리(live region)의
79
+ * 설정을 그 자리가 나무에 붙을 때 한 번 읽고 쥐는** 것이 있다. 이 자리는 불러올 때마다 새로
80
+ * 붙고 그 안에서 글이 바뀌므로, 붙을 때 `polite` 였다가 `off` 로 바뀌면 **그 바뀜을 못 본
81
+ * 낭독기는 여전히 읽을 수 있다.** 더 단단한 꼴은 **읽힐 글을 아예 비우는 것**이다(빈 자리는
82
+ * 어떤 낭독기도 안 읽는다). 그러려면 보이는 글과 읽히는 글을 두 자리로 가른다.
83
+ *
84
+ * **안 바꿨다.** 지금 꼴도 맞는 꼴이고, 낭독기로 재 보지 않은 채 이론만으로 접근성 기계를
85
+ * 또 만지면 어느 쪽이 나은지 모른 채 바꾸는 것이 된다. **낭독기로 한 번 재면** 가름난다 —
86
+ * `준비 완료` 가 들리면 비우는 쪽으로 간다.
87
+ *
88
+ * 배럴로는 안 나간다 — 옆의 `STATUS_MESSAGES` · `STATUS_COLORS` 와 같다. 이 셋은 재생기가
89
+ * 제 안에서 쓰는 표다(처음에 `@public` 을 달았다가 걷었다. 안 나가는 것에 공개 표를 달면
90
+ * **쓸 수 있다는 거짓말**이 된다 — `api.json` 에도 안 잡혔다).
75
91
  */
76
92
  export declare const ANNOUNCED_STATUSES: Record<FetchDebugStatus, boolean>;
77
93
  export declare const STATUS_COLORS: Record<FetchDebugStatus, string>;
@@ -47,7 +47,23 @@ export const STATUS_MESSAGES = {
47
47
  * **보이는 글은 안 바뀐다.** 눈으로 보는 사람에게는 전과 똑같고, 읽히는 것만 준다.
48
48
  * 사람이 쓸 말로 묶는 쪽((가))은 따로 정하는 자리다.
49
49
  *
50
- * @public
50
+ * **어디까지 쟀는지 적어 둔다.** 잰 것은 **속성**이다 — 말귀 앱 실측(0.270.0):
51
+ * `준비 완료` 에서 `aria-live="off"`, 앞의 둘은 `polite`. **낭독기가 실제로 안 읽는지는
52
+ * 안 쟀다** — 둘 다 낭독기를 띄워 보지 못했다.
53
+ *
54
+ * 그 둘이 갈릴 수 있는 자리가 하나 있다: 낭독기 가운데는 **살아 있는 자리(live region)의
55
+ * 설정을 그 자리가 나무에 붙을 때 한 번 읽고 쥐는** 것이 있다. 이 자리는 불러올 때마다 새로
56
+ * 붙고 그 안에서 글이 바뀌므로, 붙을 때 `polite` 였다가 `off` 로 바뀌면 **그 바뀜을 못 본
57
+ * 낭독기는 여전히 읽을 수 있다.** 더 단단한 꼴은 **읽힐 글을 아예 비우는 것**이다(빈 자리는
58
+ * 어떤 낭독기도 안 읽는다). 그러려면 보이는 글과 읽히는 글을 두 자리로 가른다.
59
+ *
60
+ * **안 바꿨다.** 지금 꼴도 맞는 꼴이고, 낭독기로 재 보지 않은 채 이론만으로 접근성 기계를
61
+ * 또 만지면 어느 쪽이 나은지 모른 채 바꾸는 것이 된다. **낭독기로 한 번 재면** 가름난다 —
62
+ * `준비 완료` 가 들리면 비우는 쪽으로 간다.
63
+ *
64
+ * 배럴로는 안 나간다 — 옆의 `STATUS_MESSAGES` · `STATUS_COLORS` 와 같다. 이 셋은 재생기가
65
+ * 제 안에서 쓰는 표다(처음에 `@public` 을 달았다가 걷었다. 안 나가는 것에 공개 표를 달면
66
+ * **쓸 수 있다는 거짓말**이 된다 — `api.json` 에도 안 잡혔다).
51
67
  */
52
68
  export const ANNOUNCED_STATUSES = {
53
69
  idle: false, // 아직 아무 일도 안 일어났다
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useRef } from 'react';
3
3
  import { progressOf, progressWithPad } from './audioPlaybackRules';
4
+ import { devWarnOnce, isDev } from '../../utils/devWarn';
4
5
  /**
5
6
  * 재생 중 진행 표시를 매 프레임 갱신한다.
6
7
  *
@@ -43,6 +44,23 @@ function audioDurationOf(a) {
43
44
  catch { /* seekable 을 못 읽는 환경 */ }
44
45
  return null;
45
46
  }
47
+ /**
48
+ * **틀이 느리면 개발 중에 한 번 말해 준다.**
49
+ *
50
+ * 이 사슬은 매 틀 값을 쓴다. 그래서 `requestAnimationFrame` 이 눌리면 **셈이 멀쩡해도
51
+ * 마커가 멈춘 것처럼 보인다** — 숨은 칸에서는 브라우저가 틀을 **1초에 하나**로 줄인다.
52
+ *
53
+ * 2026-10-06 에 이 자리로 두 사람이 몇 시간을 썼다. 말귀가 앱에서 "재생바가 소리 중에
54
+ * 얼어붙고 경계에서만 뛴다" 를 재서 보냈고(요청 (77)), 나도 놀이터에서 같은 것을 봤다.
55
+ * 셈을 결정적으로 몰아 보고(`useProgressTicker.test.tsx`), 사슬이 끊기는지도 보고
56
+ * (`audioProgressChainRerender.test.tsx`) 나서야 **틀 세기를 달아** 1초에 하나인 것을
57
+ * 알았다. **값은 다 맞았고 틀이 안 돌았다.**
58
+ *
59
+ * 그 한 줄을 그때 알았으면 거기서 끝났을 일이다. 그래서 사슬이 스스로 말하게 한다 —
60
+ * 틀 사이가 400ms 를 넘으면 **칸이 보이는지와 함께** 한 번 찍는다. 운영 빌드에서는
61
+ * 아무 일도 안 한다.
62
+ */
63
+ const 느린틀 = 400;
46
64
  export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, padRef, leadInMs = 0, tailMs = 0, }) {
47
65
  const rafRef = useRef(null);
48
66
  const stop = useCallback(() => {
@@ -55,7 +73,23 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
55
73
  // 이미 돌고 있으면 겹쳐 걸지 않는다 — 두 사슬이 같은 값을 다투면 마커가 떤다
56
74
  if (rafRef.current !== null)
57
75
  return;
76
+ let 앞틀 = 0;
58
77
  const step = () => {
78
+ /* 틀 사이가 벌어졌나 — 값을 그리기 전에 본다(그리는 일과 무관하다) */
79
+ if (isDev) {
80
+ const 지금 = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
81
+ if (앞틀 > 0 && 지금 - 앞틀 > 느린틀) {
82
+ let 숨었나 = null;
83
+ try {
84
+ 숨었나 = typeof document !== 'undefined' ? document.visibilityState : null;
85
+ }
86
+ catch { /* 문서 없는 데 */ }
87
+ devWarnOnce('progress-ticker-slow-frames', `[ihab-core] 진행 사슬의 틀 사이가 ${Math.round(지금 - 앞틀)}ms 입니다(보통 16ms).`, 숨었나 === 'hidden'
88
+ ? '이 칸이 **숨어 있어** 브라우저가 틀을 줄였습니다 — 재생바·파형은 틀마다 그려지므로, 지금 재는 값은 **사람이 보는 화면과 다릅니다.** 칸을 앞으로 꺼내고 다시 재십시오.'
89
+ : '칸은 보이는데 틀이 느립니다 — 같은 화면에서 무거운 일이 도는지 보십시오. 재생바·파형이 끊겨 보이는 것은 이 탓일 수 있습니다.');
90
+ }
91
+ 앞틀 = 지금;
92
+ }
59
93
  try {
60
94
  const a = audioRef.current;
61
95
  const 트랙 = Array.isArray(sourcesRef.current) ? sourcesRef.current[currentIndexRef.current || 0] : undefined;
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.270.0";
49
+ export declare const CORE_VERSION = "0.272.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.270.0';
49
+ export const CORE_VERSION = '0.272.0';