@ihabdevteam/core 0.266.0 → 0.268.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.266.0";
1654
+ export declare const CORE_VERSION = "0.268.0";
1655
1655
 
1656
1656
  /** @public */
1657
1657
  declare const coreDefault: Record<string, unknown>;
@@ -9039,7 +9039,7 @@ export declare type TrainingStepConfirmWithTextProps = {
9039
9039
  };
9040
9040
 
9041
9041
  /** @public */
9042
- export declare function TrainingStepListen({ index, stepIndex, isLast, title, onDone, btnLabel, soundSrc, bucketPath, usePreviewTextBtn, previewText, onPreviewTextBtn, disabled, _pageIndex, showSoundWaveForm, showNoiseWaveForm }: TrainingStepListenProps): JSX.Element;
9042
+ export declare function TrainingStepListen({ index, stepIndex, isLast, title, onDone, btnLabel, soundSrc, bucketPath, usePreviewTextBtn, previewText, onPreviewTextBtn, disabled, _pageIndex, showSoundWaveForm, showNoiseWaveForm, leadInMs, tailMs }: TrainingStepListenProps): JSX.Element;
9043
9043
 
9044
9044
  /** @public */
9045
9045
  export declare type TrainingStepListenProps = {
@@ -9063,6 +9063,19 @@ export declare type TrainingStepListenProps = {
9063
9063
  showSoundWaveForm?: boolean;
9064
9064
  /** 소음 waveform 표시 여부. 기본 true. */
9065
9065
  showNoiseWaveForm?: boolean;
9066
+ /**
9067
+ * **소리 앞에 두는 뜸**(밀리초). 기본 0 — 안 주면 전과 똑같이 돈다.
9068
+ * 그대로 `TrainingStepAudioPlayer` 로 간다.
9069
+ *
9070
+ * 앱은 재생기를 직접 세우지 않는다 — 훈련 러너 여섯이 전부
9071
+ * `TrainingPagePopup.ListenStep` 으로 짓고 그 길이 여기를 지난다. 그래서 재생기에만
9072
+ * 더하면 **앱까지 닿지 않는다**(말귀 실측, 2026-10-05: (74) 가 0.266.0 으로 나갔는데
9073
+ * 앱은 임시 자를 못 걷었다). 재생기에 프롭을 더하면 여기도 함께 더한다 —
9074
+ * `test/trainingStepListenForwards.test.ts` 가 빠진 것을 센다.
9075
+ */
9076
+ leadInMs?: number;
9077
+ /** **소리가 끝난 뒤 뜸**(밀리초). 기본 0. 위 `leadInMs` 와 같은 자리에서 왔다. */
9078
+ tailMs?: number;
9066
9079
  };
9067
9080
 
9068
9081
  export declare function TrainingStepOrderList({ index, stepIndex, isLast, title, onDone, onChange, context, }: TrainingStepOrderListProps): JSX.Element;
@@ -190,6 +190,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
190
190
  noisePlayer,
191
191
  audioRef,
192
192
  playbackStateRef,
193
+ padRef: 뜸Ref,
193
194
  });
194
195
  const { startTTS: 합성시작, stopTTS } = useTTSPlayback({
195
196
  ttsText,
@@ -286,6 +287,19 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
286
287
  먼저 켠다(소리가 날 때 오는 `play` 이벤트가 아니라 **여기**가 시작이다). */
287
288
  if (leadInMsRef.current > 0) {
288
289
  startTicker();
290
+ /* **소음은 뜸부터 깔린다.** 사용자가 (74) 를 말할 때 기준으로 삼은 것이 "노이즈가
291
+ 나오는 순간" 이었다 — 그 순간이 단어와 같이 오면 뜸을 둔 뜻이 없다. 소리가 터지지
292
+ 않게 하려고 둔 구간인데 거기가 **완전한 무음**이면, 단어가 소음과 함께 들이닥치는
293
+ 것은 그대로다. 뜸 동안 소음만 깔려 있어야 귀가 먼저 자리를 잡는다.
294
+ `onPlay` 에도 같은 호출이 있다 — 이미 돌고 있으면 `fadeInNoise` 는 지금 볼륨에서
295
+ 목표까지 이어 가므로(멀티소스 전환 때문에 그렇게 돼 있다) 두 번 불려도 끊기지 않는다. */
296
+ const ns = noiseSettingsRef.current;
297
+ if (ns?.enabled && (ns.volume ?? 0) > 0) {
298
+ try {
299
+ fadeInNoise(ns.volume);
300
+ }
301
+ catch (e) { }
302
+ }
289
303
  if (!(await 뜸기다리기(leadInMsRef.current, 'lead')))
290
304
  return false;
291
305
  if (!mountedRef.current || 의도 !== 재생의도Ref.current)
@@ -327,7 +341,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
327
341
  }
328
342
  catch (_) { }
329
343
  return false;
330
- }, [t, startTicker, 뜸기다리기]);
344
+ }, [t, startTicker, 뜸기다리기, fadeInNoise, noiseSettingsRef]);
331
345
  const attachAudioListeners = useCallback(() => {
332
346
  const a = audioRef.current;
333
347
  if (!a)
@@ -22,6 +22,19 @@ export type TrainingStepListenProps = {
22
22
  showSoundWaveForm?: boolean;
23
23
  /** 소음 waveform 표시 여부. 기본 true. */
24
24
  showNoiseWaveForm?: boolean;
25
+ /**
26
+ * **소리 앞에 두는 뜸**(밀리초). 기본 0 — 안 주면 전과 똑같이 돈다.
27
+ * 그대로 `TrainingStepAudioPlayer` 로 간다.
28
+ *
29
+ * 앱은 재생기를 직접 세우지 않는다 — 훈련 러너 여섯이 전부
30
+ * `TrainingPagePopup.ListenStep` 으로 짓고 그 길이 여기를 지난다. 그래서 재생기에만
31
+ * 더하면 **앱까지 닿지 않는다**(말귀 실측, 2026-10-05: (74) 가 0.266.0 으로 나갔는데
32
+ * 앱은 임시 자를 못 걷었다). 재생기에 프롭을 더하면 여기도 함께 더한다 —
33
+ * `test/trainingStepListenForwards.test.ts` 가 빠진 것을 센다.
34
+ */
35
+ leadInMs?: number;
36
+ /** **소리가 끝난 뒤 뜸**(밀리초). 기본 0. 위 `leadInMs` 와 같은 자리에서 왔다. */
37
+ tailMs?: number;
25
38
  };
26
39
  /** @public */
27
- export default function TrainingStepListen({ index, stepIndex, isLast, title, onDone, btnLabel, soundSrc, bucketPath, usePreviewTextBtn, previewText, onPreviewTextBtn, disabled, _pageIndex, showSoundWaveForm, showNoiseWaveForm }: TrainingStepListenProps): import("react").JSX.Element;
40
+ export default function TrainingStepListen({ index, stepIndex, isLast, title, onDone, btnLabel, soundSrc, bucketPath, usePreviewTextBtn, previewText, onPreviewTextBtn, disabled, _pageIndex, showSoundWaveForm, showNoiseWaveForm, leadInMs, tailMs }: TrainingStepListenProps): import("react").JSX.Element;
@@ -5,9 +5,9 @@ import TrainingStepAudioPlayer from './TrainingStepAudioPlayer';
5
5
  import "./TrainingStep.css";
6
6
  import TrainingStep from "./TrainingStep";
7
7
  /** @public */
8
- export default function TrainingStepListen({ index, stepIndex, isLast = false, title, onDone, btnLabel, soundSrc, bucketPath, usePreviewTextBtn, previewText = null, onPreviewTextBtn, disabled, _pageIndex, showSoundWaveForm, showNoiseWaveForm }) {
8
+ export default function TrainingStepListen({ index, stepIndex, isLast = false, title, onDone, btnLabel, soundSrc, bucketPath, usePreviewTextBtn, previewText = null, onPreviewTextBtn, disabled, _pageIndex, showSoundWaveForm, showNoiseWaveForm, leadInMs, tailMs }) {
9
9
  const { t } = useTranslation();
10
10
  const resolvedTitle = title ?? t('training.listenTitle', '제시하는 문장을 들어주세요.');
11
11
  const effectiveDisabled = disabled ?? (typeof stepIndex === 'number' && typeof index === 'number' ? index > stepIndex : false);
12
- return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, titleProps: { usePreviewTextBtn, previewText, onPreviewTextBtn }, children: _jsx(TrainingStepAudioPlayer, { soundSrc: soundSrc, ttsText: !soundSrc ? (previewText ?? resolvedTitle) : null, bucketPath: bucketPath, btnLabel: btnLabel || t('training.listenButton', '문장 듣기'), disabled: effectiveDisabled, className: "training-step__listen-sticky", showSoundWaveForm: showSoundWaveForm, showNoiseWaveForm: showNoiseWaveForm }, `audio-p${_pageIndex ?? 0}-s${index}`) }));
12
+ return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, titleProps: { usePreviewTextBtn, previewText, onPreviewTextBtn }, children: _jsx(TrainingStepAudioPlayer, { soundSrc: soundSrc, ttsText: !soundSrc ? (previewText ?? resolvedTitle) : null, bucketPath: bucketPath, btnLabel: btnLabel || t('training.listenButton', '문장 듣기'), disabled: effectiveDisabled, className: "training-step__listen-sticky", showSoundWaveForm: showSoundWaveForm, showNoiseWaveForm: showNoiseWaveForm, leadInMs: leadInMs, tailMs: tailMs }, `audio-p${_pageIndex ?? 0}-s${index}`) }));
13
13
  }
@@ -5,10 +5,22 @@ 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, padRef, }: {
9
9
  noisePlayer: NoisePlayerContextValue | null;
10
10
  audioRef: RefObject<HTMLAudioElement | null>;
11
11
  playbackStateRef: RefObject<PlaybackState>;
12
+ /**
13
+ * **앞뒤 뜸 구간인가**(말귀 요청 (74), 2026-10-05).
14
+ *
15
+ * 뜸 동안 메인 `<audio>` 는 **멈춰 있다** — 앞 뜸에는 아직 `play()` 전이고, 뒤 뜸에는
16
+ * 이미 `ended` 다. 그래서 "메인이 도나" 만 보는 자리들이 **뜸을 재생이 끝난 것으로 읽어**
17
+ * 소음을 걷는다. 특히 아래 감시 타이머는 0.8초 뒤에 내리므로, 1000ms 뜸은 **800ms 에서
18
+ * 소음이 꺼진다.** 뜸은 소리가 안 나는 구간이지 **재생이 끝난 구간이 아니다.**
19
+ */
20
+ padRef?: RefObject<{
21
+ phase: 'lead' | 'sound' | 'tail';
22
+ at: number;
23
+ }>;
12
24
  }): {
13
25
  noiseAudioRef: RefObject<HTMLAudioElement | null>;
14
26
  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, padRef, }) {
21
21
  const registerAnalyserRef = useRef(noisePlayer?.registerAnalyser);
22
22
  registerAnalyserRef.current = noisePlayer?.registerAnalyser;
23
23
  const noiseAudioRef = useRef(null);
@@ -35,6 +35,11 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
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
43
  const cancelNoiseFade = useCallback(() => {
39
44
  if (noiseFadeRafRef.current !== null) {
40
45
  cancelAnimationFrame(noiseFadeRafRef.current);
@@ -201,7 +206,8 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
201
206
  const a = audioRef.current;
202
207
  const mainPlaying = !!a && !a.paused && !a.ended;
203
208
  // speechSynthesis TTS 경로는 main <audio> 없이 재생되므로 playbackState도 함께 본다
204
- if (mainPlaying || playbackStateRef.current === 'playing') {
209
+ // 뜸 구간은 메인이 멈춰 있어도 **재생 중**이다 — 안 보면 1000ms 뜸의 800ms 에 소음이 꺼진다
210
+ if (mainPlaying || 뜸중() || playbackStateRef.current === 'playing') {
205
211
  misses = 0;
206
212
  return;
207
213
  }
@@ -212,7 +218,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
212
218
  }
213
219
  }, 400);
214
220
  return () => clearInterval(iv);
215
- }, [fadeOutNoise, noiseInUse, audioRef, playbackStateRef]);
221
+ }, [fadeOutNoise, noiseInUse, audioRef, playbackStateRef, 뜸중]);
216
222
  // 탭 백그라운드 전환 시 소음 즉시 정지 — 백그라운드 탭에서는 requestAnimationFrame이
217
223
  // 멈추거나 크게 스로틀돼 fadeOutNoise의 볼륨 램프가 완주하지 못할 수 있어, 페이드 없이
218
224
  // 바로 pause한다.
@@ -235,14 +241,14 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
235
241
  if (!noiseEl || !noiseEl.paused)
236
242
  return;
237
243
  const a = audioRef.current;
238
- const mainPlaying = (!!a && !a.paused && !a.ended) || playbackStateRef.current === 'playing';
244
+ const mainPlaying = (!!a && !a.paused && !a.ended) || 뜸중() || playbackStateRef.current === 'playing';
239
245
  const 지금 = noiseSettingsRef.current;
240
246
  if (mainPlaying && 지금?.enabled && 지금.volume > 0)
241
247
  fadeInNoise(지금.volume);
242
248
  };
243
249
  document.addEventListener('visibilitychange', handleVisibilityChange);
244
250
  return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
245
- }, [cancelNoiseFade, fadeInNoise, audioRef, playbackStateRef]);
251
+ }, [cancelNoiseFade, fadeInNoise, audioRef, playbackStateRef, 뜸중]);
246
252
  // noiseSettingsRef 최신화 (재생 이벤트 핸들러 클로저에서 사용)
247
253
  useEffect(() => {
248
254
  noiseSettingsRef.current = noisePlayer
@@ -278,7 +284,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
278
284
  noiseEl.src = url;
279
285
  noiseEl.load();
280
286
  const mainAudio = audioRef.current;
281
- if (mainAudio && !mainAudio.paused) {
287
+ if ((mainAudio && !mainAudio.paused) || 뜸중()) {
282
288
  canplay떼기Ref.current?.();
283
289
  const h = () => {
284
290
  noiseEl.removeEventListener('canplay', h);
@@ -290,7 +296,8 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
290
296
  껐어도 canplay 에서 1 로 다시 켜져 끝까지 났다(난이도가 뜻보다 어려워지고 기록과 들린 소리가 어긋남). */
291
297
  const cur = audioRef.current;
292
298
  const 지금 = noiseSettingsRef.current;
293
- if (cur && !cur.paused && !cur.ended && 지금?.enabled && 지금.volume > 0)
299
+ const 도나 = (!!cur && !cur.paused && !cur.ended) || 뜸중();
300
+ if (도나 && 지금?.enabled && 지금.volume > 0)
294
301
  fadeInNoise(지금.volume);
295
302
  };
296
303
  const 떼기 = () => noiseEl.removeEventListener('canplay', h);
@@ -308,7 +315,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
308
315
  // 나오는 버그가 있었다. 메인이 재생 중이 아니면 무조건 정지시킨다.
309
316
  const mainAudio = audioRef.current;
310
317
  // speechSynthesis TTS 경로는 메인 <audio> 없이 재생된다 — 감시 타이머처럼 playbackState 도 본다(안 보면 비율만 바꿔도 소음이 멈췄다)
311
- const mainPlaying = (!!mainAudio && !mainAudio.paused && !mainAudio.ended) || playbackStateRef.current === 'playing';
318
+ const mainPlaying = (!!mainAudio && !mainAudio.paused && !mainAudio.ended) || 뜸중() || playbackStateRef.current === 'playing';
312
319
  if (mainPlaying && pendingNoiseUrlRef.current) {
313
320
  /* 종류를 바꾼 뒤 150ms 페이드아웃 안에 다른 설정을 바꾸면, 아래 볼륨만 옮기는 갈래가 파일을 바꿀 페이드를 지워 화면의 종류와
314
321
  들리는 종류가 문장 끝까지 달랐다 — 기다리는 파일이 있으면 fadeInNoise 가 그것을 끼운다. */
@@ -342,7 +349,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
342
349
  }
343
350
  }
344
351
  }
345
- }, [noisePlayer?.settings, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type, cancelNoiseFade, fadeInNoise, fadeOutNoise, audioRef, playbackStateRef, noiseSettingsRef]);
352
+ }, [noisePlayer?.settings, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type, cancelNoiseFade, fadeInNoise, fadeOutNoise, audioRef, playbackStateRef, noiseSettingsRef, 뜸중]);
346
353
  // 소음 재생 속도(playbackRate) 반영 — 0.75~1.25. 엘리먼트 속성이라 src 변경에도 유지됨
347
354
  useEffect(() => {
348
355
  const el = noiseAudioRef.current;
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.266.0";
49
+ export declare const CORE_VERSION = "0.268.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.266.0';
49
+ export const CORE_VERSION = '0.268.0';