@ihabdevteam/core 0.265.0 → 0.267.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 +80 -0
- package/dist/components.cjs.js +183 -183
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +183 -183
- package/dist/ihabdevteam-core.d.ts +39 -3
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.d.ts +5 -0
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +38 -8
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.d.ts +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +106 -41
- package/dist/packages/core/src/components/Training/TrainingStepListen.d.ts +14 -1
- package/dist/packages/core/src/components/Training/TrainingStepListen.js +2 -2
- package/dist/packages/core/src/components/Training/audioPlaybackRules.d.ts +45 -0
- package/dist/packages/core/src/components/Training/audioPlaybackRules.js +53 -0
- package/dist/packages/core/src/components/Training/trainingStepAudioPlayerTypes.d.ts +23 -0
- package/dist/packages/core/src/components/Training/useProgressTicker.d.ts +13 -1
- package/dist/packages/core/src/components/Training/useProgressTicker.js +20 -9
- 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.267.0";
|
|
1655
1655
|
|
|
1656
1656
|
/** @public */
|
|
1657
1657
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -8926,7 +8926,7 @@ declare function TrainingStep_2({ index, stepIndex, isLast, title, titleProps, c
|
|
|
8926
8926
|
export { TrainingStep_2 as TrainingStep }
|
|
8927
8927
|
|
|
8928
8928
|
/** @public */
|
|
8929
|
-
declare function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath, onDone, disabled, ttsText, className, showSoundWaveForm, showNoiseWaveForm }: TrainingStepAudioPlayerProps): JSX.Element;
|
|
8929
|
+
declare function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath, onDone, disabled, ttsText, className, showSoundWaveForm, showNoiseWaveForm, leadInMs, tailMs }: TrainingStepAudioPlayerProps): JSX.Element;
|
|
8930
8930
|
export { TrainingStepAudioPlayer as AudioPlayer }
|
|
8931
8931
|
export { TrainingStepAudioPlayer }
|
|
8932
8932
|
|
|
@@ -8944,6 +8944,29 @@ export declare interface TrainingStepAudioPlayerProps {
|
|
|
8944
8944
|
showSoundWaveForm?: boolean;
|
|
8945
8945
|
/** 소음 waveform 표시 여부. 기본 true. */
|
|
8946
8946
|
showNoiseWaveForm?: boolean;
|
|
8947
|
+
/**
|
|
8948
|
+
* **소리가 나기 전 뜸**(밀리초). 기본 0 — 안 주면 전과 같다(말귀 요청 (74), 2026-10-05).
|
|
8949
|
+
*
|
|
8950
|
+
* 단어 음원은 앞뒤 무음이 **50~70ms** 뿐이라 누르는 순간 소리가 터진다(말귀 실측:
|
|
8951
|
+
* 648ms 중 앞 60·뒤 59, 480ms "굴" 중 앞 71·뒤 52, 최고 진폭의 1% 를 소리 있음 선으로).
|
|
8952
|
+
* 듣는 사람이 채비할 틈이 없어 **첫 음절을 놓친다.** 문장 음원은 녹음에 뜸이 있어 괜찮다.
|
|
8953
|
+
*
|
|
8954
|
+
* **음원 파일은 안 고친다**(사용자 판단) — 수천 개고, 무음을 넣으면 길이를 쓰는 자리
|
|
8955
|
+
* (숙제·인쇄·분석)가 함께 틀어진다. 그래서 **재생기가 뜸을 준다.**
|
|
8956
|
+
*
|
|
8957
|
+
* 이 뜸은 **파형과 재생바에도 그대로 보인다** — 2초 단어에 앞뒤 1초면 **4초가 한 폭**에
|
|
8958
|
+
* 들어가고, 재생바는 **뜸이 시작될 때** 출발한다(소리가 날 때가 아니다).
|
|
8959
|
+
*/
|
|
8960
|
+
leadInMs?: number;
|
|
8961
|
+
/**
|
|
8962
|
+
* **소리가 끝난 뒤 뜸**(밀리초). 기본 0. 위 `leadInMs` 와 같은 자리에서 왔다.
|
|
8963
|
+
*
|
|
8964
|
+
* 여러 소리를 잇따라 틀면 앞 것의 뒤 뜸 + 뒤 것의 앞 뜸이 놓여 **사이가 둘을 더한 만큼**
|
|
8965
|
+
* 된다(각 1000 이면 2000ms). **누를 때의 뜸과 사이의 뜸은 다른 것**이라 가른다 — 앞엣것은
|
|
8966
|
+
* 채비할 틈이고, 사이의 것은 들은 것을 붙들고 다음 것과 **견주는 틈**이다(같은지 다른지
|
|
8967
|
+
* 맞히기는 그 견줌이 과제 자체다).
|
|
8968
|
+
*/
|
|
8969
|
+
tailMs?: number;
|
|
8947
8970
|
}
|
|
8948
8971
|
|
|
8949
8972
|
/** @public */
|
|
@@ -9016,7 +9039,7 @@ export declare type TrainingStepConfirmWithTextProps = {
|
|
|
9016
9039
|
};
|
|
9017
9040
|
|
|
9018
9041
|
/** @public */
|
|
9019
|
-
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;
|
|
9020
9043
|
|
|
9021
9044
|
/** @public */
|
|
9022
9045
|
export declare type TrainingStepListenProps = {
|
|
@@ -9040,6 +9063,19 @@ export declare type TrainingStepListenProps = {
|
|
|
9040
9063
|
showSoundWaveForm?: boolean;
|
|
9041
9064
|
/** 소음 waveform 표시 여부. 기본 true. */
|
|
9042
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;
|
|
9043
9079
|
};
|
|
9044
9080
|
|
|
9045
9081
|
export declare function TrainingStepOrderList({ index, stepIndex, isLast, title, onDone, onChange, context, }: TrainingStepOrderListProps): JSX.Element;
|
|
@@ -15,4 +15,9 @@ export default function WaveForm(props: {
|
|
|
15
15
|
mainVolume?: number;
|
|
16
16
|
onReadyChange?: (ready: boolean) => void;
|
|
17
17
|
showSoundWaveForm?: boolean;
|
|
18
|
+
/** 앞뒤 뜸(ms) — 파형이 그 구간까지 한 폭에 그린다(말귀 요청 (74), 2026-10-05) */
|
|
19
|
+
leadInMs?: number;
|
|
20
|
+
tailMs?: number;
|
|
21
|
+
/** 지금 트랙 소리의 길이(초) — 뜸과 견주어 막대를 나눈다 */
|
|
22
|
+
trackDurationSec?: number;
|
|
18
23
|
}): import("react").JSX.Element;
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { computePeaksFromUrl } from '../../utils/audioPeaks';
|
|
5
5
|
import { waveBarPx, waveBarVal, WAVE_H1_PX } from './waveScale';
|
|
6
|
+
import { padBarSpans } from './audioPlaybackRules';
|
|
6
7
|
import { getNoiseFileGain } from '../../utils/storage';
|
|
7
8
|
import './TrainingStepAudioPlayer.css';
|
|
8
9
|
/**
|
|
@@ -10,8 +11,10 @@ import './TrainingStepAudioPlayer.css';
|
|
|
10
11
|
* 그려지는 바는 항상 이 값을 넘는 높이만 표시하며(가로 폭은 BAR_WIDTH=3px), 그 이하는 숨긴다.
|
|
11
12
|
*/
|
|
12
13
|
const MIN_BAR_PX = 4;
|
|
14
|
+
/** 막대가 아무리 작아도 그려지는 바닥 — 뜸 구간이 빈 칸으로 남지 않게((74)) */
|
|
15
|
+
const BAR_FLOOR_VAL = waveBarVal(MIN_BAR_PX / WAVE_H1_PX);
|
|
13
16
|
export default function WaveForm(props) {
|
|
14
|
-
const { src, segments, progress = 0, waveProgressRef, noiseSrc, noiseRatio = 0, noiseSpeed = 1, totalDuration = 0, mainVolume = 1, onReadyChange, showSoundWaveForm = true } = props;
|
|
17
|
+
const { src, segments, progress = 0, waveProgressRef, noiseSrc, noiseRatio = 0, noiseSpeed = 1, totalDuration = 0, mainVolume = 1, onReadyChange, showSoundWaveForm = true, leadInMs = 0, tailMs = 0, trackDurationSec = 0 } = props;
|
|
15
18
|
const waveformRef = useRef(null);
|
|
16
19
|
const outerRef = useRef(null);
|
|
17
20
|
const [wavePeaks, setWavePeaks] = useState(null);
|
|
@@ -186,20 +189,43 @@ export default function WaveForm(props) {
|
|
|
186
189
|
const barsCountForMix = barsTotal || 40;
|
|
187
190
|
// 최대 300개 바가 재생/미재생 두 레이어에 그려지므로, 볼륨 슬라이더 드래그 등으로 부모가
|
|
188
191
|
// 연속 재렌더될 때 매번 전체 JSX를 재생성하지 않도록 입력값이 바뀔 때만 다시 만든다.
|
|
192
|
+
/**
|
|
193
|
+
* **앞·소리·뒤로 나눈 막대 수**(말귀 요청 (74), 2026-10-05).
|
|
194
|
+
*
|
|
195
|
+
* 뜸을 주면 파형도 그 전체를 그려야 한다 — 2초 단어에 앞뒤 1초면 **4초가 한 폭**이고
|
|
196
|
+
* 소리는 가운데 절반만 차지한다. 뜸이 0 이면 `{ lead: 0, sound: 전부, tail: 0 }` 이라
|
|
197
|
+
* 아래 셈이 **전과 똑같이** 돈다.
|
|
198
|
+
*/
|
|
199
|
+
const 구간 = useMemo(() => padBarSpans({ bars: barsCountForMix, trackDurationSec, leadInMs, tailMs }), [barsCountForMix, trackDurationSec, leadInMs, tailMs]);
|
|
189
200
|
const bars = useMemo(() => Array.from({ length: barsCountForMix }).map((_, i) => {
|
|
190
|
-
|
|
201
|
+
/* 뜸 구간 막대는 소리가 없다 — 봉우리 0. 그 자리는 **소음 파형이 채운다**(아래 바닥값).
|
|
202
|
+
소리 구간은 봉우리를 그 폭에 다시 고른다(전에는 막대 번호가 곧 봉우리 번호였다). */
|
|
203
|
+
const 소리안 = i >= 구간.lead && i < 구간.lead + 구간.sound;
|
|
204
|
+
const 봉우리번호 = 구간.sound > 0 && 소리안
|
|
205
|
+
? Math.min(barsCountForMix - 1, Math.round(((i - 구간.lead) / 구간.sound) * barsCountForMix))
|
|
206
|
+
: -1;
|
|
207
|
+
const peak = 소리안
|
|
208
|
+
? (wavePeaks && wavePeaks[봉우리번호] != null ? wavePeaks[봉우리번호] : null)
|
|
209
|
+
: (wavePeaks ? 0 : null);
|
|
191
210
|
const eff = peak == null ? null : Math.min(1, (peak / sentenceMaxPeak) * mainAmp);
|
|
192
211
|
// 로딩 시 placeholder(0.15); 계산 후엔 렌더 높이(waveBarPx)가 MIN_BAR_PX 이하면 숨김(0).
|
|
193
212
|
// waveHeightPx 는 "칸이 배치됐나" 만 본다 — 모를 때(0) 지우면 안 된다.
|
|
194
213
|
const belowMin = eff != null && waveHeightPx > 0 && waveBarPx(eff) <= MIN_BAR_PX;
|
|
195
|
-
|
|
214
|
+
/* **문장 막대를 숨기는 까닭은 뒤에 소음 층이 깔려 있어서다** — 뜻 없는 1px 막대를
|
|
215
|
+
지워도 바탕 결이 그 자리를 지킨다. 소음 층이 **없으면 지킬 것이 없다**: 숨긴 자리가
|
|
216
|
+
그대로 구멍이 된다. 실측(2026-10-05, 13.09초 문장 · 앞뒤 뜸 1000ms · noiseSrc 없음):
|
|
217
|
+
94칸 중 **앞 0~15 · 뒤 81~93** 이 `--hidden` 이었다 — 눈에는 양끝이 잘려 보인다.
|
|
218
|
+
그래서 깔개가 없을 때만 문장 막대가 바닥값을 진다. */
|
|
219
|
+
const 깔개없다 = !noiseSrc;
|
|
220
|
+
const val = eff == null ? 0.15 : (belowMin ? (깔개없다 ? BAR_FLOOR_VAL : 0) : waveBarVal(eff));
|
|
221
|
+
const 숨긴다 = belowMin && !깔개없다;
|
|
196
222
|
// 가로 위치 비율(0=왼쪽 lightMain ~ 1=오른쪽 lightSub) — 바 색을 위치에 따라 믹스
|
|
197
223
|
const mix = barsCountForMix > 1 ? i / (barsCountForMix - 1) : 0;
|
|
198
224
|
const style = { ['--val']: val, ['--i']: i, ['--mix']: mix };
|
|
199
225
|
// MIN_BAR_PX 이하로 숨겨지는 바는 최초 등장 애니메이션에서도 잠깐 비쳐 보이지 않도록 애니메이션 자체를 건너뛴다
|
|
200
|
-
const className =
|
|
226
|
+
const className = 숨긴다 ? 'audio-player__bar audio-player__bar--hidden' : 'audio-player__bar';
|
|
201
227
|
return _jsx("div", { className: className, style: style }, i);
|
|
202
|
-
}), [barsCountForMix, wavePeaks, sentenceMaxPeak, mainAmp, waveHeightPx]);
|
|
228
|
+
}), [barsCountForMix, wavePeaks, sentenceMaxPeak, mainAmp, waveHeightPx, 구간, noiseSrc]);
|
|
203
229
|
// 초기 progress CSS 변수 값 (React 상태 기준 — RAF 루프가 이후 직접 갱신합니다)
|
|
204
230
|
const progressStyle = { '--progress': String(Math.max(0, Math.min(1, progress))) };
|
|
205
231
|
// 컨테이너 실제 폭에 맞춰 정밀 계산한 바 간격 — outer에 걸어 .audio-player__wave-inner 등
|
|
@@ -240,9 +266,13 @@ export default function WaveForm(props) {
|
|
|
240
266
|
const noiseBars = useMemo(() => Array.from({ length: noiseCount }).map((_, i) => {
|
|
241
267
|
const peak = noisePeaks && noisePeaks[i] != null ? noisePeaks[i] : null;
|
|
242
268
|
const eff = peak == null ? 0 : (peak / sentenceMaxPeak) * noiseAmp;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
269
|
+
/* **소음 막대는 안 숨긴다**(말귀 요청 (74), 2026-10-05). 전에는 렌더 높이가 작으면
|
|
270
|
+
통째로 안 그려(`--hidden`) **왼쪽이 비어 보였다** — 말귀 실측(480ms "굴"): 소음 층
|
|
271
|
+
99칸 중 **앞 15칸**, 단어 층 **앞 30칸**이 0. 사용자가 그 빈 자리를 짚었다.
|
|
272
|
+
소음은 바탕 결이라 작아도 **자리는 채워야 한다** — 바닥값으로 내린다.
|
|
273
|
+
문장 막대는 이 층이 있을 때만 숨긴다(위 `깔개없다`). */
|
|
274
|
+
const val = Math.max(BAR_FLOOR_VAL, waveBarVal(eff));
|
|
275
|
+
const belowMin = false;
|
|
246
276
|
// 가로 위치 비율(0=왼쪽 deepMain ~ 1=오른쪽 deepSub) — 바 색을 위치에 따라 믹스
|
|
247
277
|
const mix = noiseCount > 1 ? i / (noiseCount - 1) : 0;
|
|
248
278
|
const style = { ['--val']: val, ['--i']: i, ['--mix']: mix };
|
|
@@ -2,4 +2,4 @@ import './TrainingStepAudioPlayer.css';
|
|
|
2
2
|
export type { SoundSource, TrainingStepAudioPlayerProps } from './trainingStepAudioPlayerTypes';
|
|
3
3
|
import type { TrainingStepAudioPlayerProps } from './trainingStepAudioPlayerTypes';
|
|
4
4
|
/** @public */
|
|
5
|
-
export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath, onDone, disabled, ttsText, className, showSoundWaveForm, showNoiseWaveForm }: TrainingStepAudioPlayerProps): import("react").JSX.Element;
|
|
5
|
+
export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath, onDone, disabled, ttsText, className, showSoundWaveForm, showNoiseWaveForm, leadInMs, tailMs }: TrainingStepAudioPlayerProps): import("react").JSX.Element;
|
|
@@ -26,7 +26,7 @@ import { useAnchorRect } from './useAnchorRect';
|
|
|
26
26
|
import { useProgressTicker } from './useProgressTicker';
|
|
27
27
|
import { nextTrackIndex, shouldIgnorePause, buildSegments, totalDurationOf, normalizeSources } from './audioPlaybackRules';
|
|
28
28
|
/** @public */
|
|
29
|
-
export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath, onDone, disabled = false, ttsText = null, className, showSoundWaveForm = true, showNoiseWaveForm = true }) {
|
|
29
|
+
export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath, onDone, disabled = false, ttsText = null, className, showSoundWaveForm = true, showNoiseWaveForm = true, leadInMs = 0, tailMs = 0 }) {
|
|
30
30
|
const { t } = useTranslation();
|
|
31
31
|
const label = btnLabel;
|
|
32
32
|
// 소음 재생
|
|
@@ -130,7 +130,40 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
130
130
|
아홉 군데에 흩어져 있었다. */
|
|
131
131
|
/* 함수만 꺼내 쓴다 — 훅이 돌려주는 객체는 매 렌더 새것이라 그대로 의존성에 넣으면
|
|
132
132
|
이벤트 배선이 매 렌더 다시 붙는다. 안쪽 셋은 useCallback 이라 안정적이다. */
|
|
133
|
-
|
|
133
|
+
/**
|
|
134
|
+
* **앞뒤 뜸 시간선에서 지금 어느 구간인가**(말귀 요청 (74), 2026-10-05).
|
|
135
|
+
* 진행바(`useProgressTicker`)가 이것을 읽어 **소리가 안 나는 동안에도** 움직인다.
|
|
136
|
+
*/
|
|
137
|
+
const 뜸Ref = useRef({ phase: 'sound', at: 0 });
|
|
138
|
+
/* 이벤트 손잡이는 한 번만 붙으므로 prop 을 ref 로 들고 본다 — 안 그러면 낡은 값을 쓴다
|
|
139
|
+
(이 파일이 `disabledRef`·`noiseSettingsRef` 에 쓰는 것과 같은 결). */
|
|
140
|
+
const leadInMsRef = useRef(leadInMs);
|
|
141
|
+
const tailMsRef = useRef(tailMs);
|
|
142
|
+
useEffect(() => { leadInMsRef.current = Math.max(0, leadInMs || 0); }, [leadInMs]);
|
|
143
|
+
useEffect(() => { tailMsRef.current = Math.max(0, tailMs || 0); }, [tailMs]);
|
|
144
|
+
const 뜸타이머Ref = useRef(null);
|
|
145
|
+
const 뜸취소 = useCallback(() => {
|
|
146
|
+
if (뜸타이머Ref.current) {
|
|
147
|
+
clearTimeout(뜸타이머Ref.current);
|
|
148
|
+
뜸타이머Ref.current = null;
|
|
149
|
+
}
|
|
150
|
+
뜸Ref.current = { phase: 'sound', at: 0 };
|
|
151
|
+
}, []);
|
|
152
|
+
/** 뜸만큼 기다린다 — 그 사이 **재생 의도가 바뀌면 거짓**을 돌려 부르는 쪽이 그만둔다 */
|
|
153
|
+
const 뜸기다리기 = useCallback((ms, phase) => {
|
|
154
|
+
if (!(ms > 0))
|
|
155
|
+
return Promise.resolve(true);
|
|
156
|
+
const 의도 = 재생의도Ref.current;
|
|
157
|
+
뜸Ref.current = { phase, at: Date.now() };
|
|
158
|
+
return new Promise((res) => {
|
|
159
|
+
뜸타이머Ref.current = setTimeout(() => {
|
|
160
|
+
뜸타이머Ref.current = null;
|
|
161
|
+
res(mountedRef.current && 의도 === 재생의도Ref.current);
|
|
162
|
+
}, ms);
|
|
163
|
+
});
|
|
164
|
+
}, []);
|
|
165
|
+
useEffect(() => () => 뜸취소(), [뜸취소]);
|
|
166
|
+
const { start: startTicker, stop: stopTicker, settleTo: settleTicker, } = useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, padRef: 뜸Ref, leadInMs, tailMs });
|
|
134
167
|
/* 파형이 자리를 잡을 때까지 기다리는 규칙은 useWaveReady 가 들고 있다 —
|
|
135
168
|
ref 둘·함수 둘에 "준비 안 됨으로 되돌리기"가 일곱 군데 흩어져 있던 것을 모았다. */
|
|
136
169
|
const { onReadyChange: handleWaveReadyChange, waitForReady: waitForWaveReady, markNotReady: markWaveNotReady, } = useWaveReady({ showSoundWaveForm, showNoiseWaveForm });
|
|
@@ -207,7 +240,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
207
240
|
/* 재생 의도 — 멈춤·정지·완료·트랙 이동이 올린다. 대기 중이던 play() 는 멈추면 AbortError 로 거절되는데, 그것을 "다시 시도할
|
|
208
241
|
오류" 로만 보고 다시 부르면 **정지를 눌렀는데 소리가 다시 났다**. 시작할 때의 의도가 그대로일 때만 다시 시도한다. */
|
|
209
242
|
const 재생의도Ref = useRef(0);
|
|
210
|
-
|
|
243
|
+
/* 뜸도 함께 끊는다((74)) — 의도가 바뀌면 `뜸기다리기` 가 거짓을 주지만, 타이머를 남겨
|
|
244
|
+
두면 뜸만큼 늦게 깨어나 쓸데없이 한 번 돈다. 여기서 바로 지운다. */
|
|
245
|
+
const 멈춤의도 = () => { 재생의도Ref.current += 1; jumpTokenRef.current += 1; 뜸취소(); };
|
|
211
246
|
/* 스피커는 하나(currentSound.ts) — 한 화면의 다른 재생기(문장 듣기·문제 듣기)·낱말 카드·미리 듣기가 울리면 이 재생기는
|
|
212
247
|
멈춘다. 전에는 낱말 카드만 자물쇠를 잡아, 듣기 단계가 둘인 화면·듣기+카드 단계에서 두 소리가 함께 울렸다.
|
|
213
248
|
멈춤 단추와 같게 멈춘다(이어 듣기 가능). 함수는 한 벌이어야 자물쇠가 '내 것' 을 알아본다. */
|
|
@@ -247,6 +282,16 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
247
282
|
stopSoundIfOwner(소리멈춤Ref.current); }, []);
|
|
248
283
|
const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
|
|
249
284
|
const 의도 = 재생의도Ref.current;
|
|
285
|
+
/* **앞 뜸** — 소리를 틀기 전에 기다린다. 기다리는 동안 진행바는 돌아야 하므로
|
|
286
|
+
먼저 켠다(소리가 날 때 오는 `play` 이벤트가 아니라 **여기**가 시작이다). */
|
|
287
|
+
if (leadInMsRef.current > 0) {
|
|
288
|
+
startTicker();
|
|
289
|
+
if (!(await 뜸기다리기(leadInMsRef.current, 'lead')))
|
|
290
|
+
return false;
|
|
291
|
+
if (!mountedRef.current || 의도 !== 재생의도Ref.current)
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
뜸Ref.current = { phase: 'sound', at: Date.now() };
|
|
250
295
|
for (let i = 0; i < attempts; i++) {
|
|
251
296
|
if (!mountedRef.current || 의도 !== 재생의도Ref.current)
|
|
252
297
|
return false;
|
|
@@ -282,7 +327,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
282
327
|
}
|
|
283
328
|
catch (_) { }
|
|
284
329
|
return false;
|
|
285
|
-
}, [t]);
|
|
330
|
+
}, [t, startTicker, 뜸기다리기]);
|
|
286
331
|
const attachAudioListeners = useCallback(() => {
|
|
287
332
|
const a = audioRef.current;
|
|
288
333
|
if (!a)
|
|
@@ -321,45 +366,63 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
321
366
|
// 소음 동기 일시정지
|
|
322
367
|
fadeOutNoise();
|
|
323
368
|
};
|
|
369
|
+
/* **뒤 뜸**((74), 2026-10-05) — 소리가 끝나도 바로 넘기지 않는다. 그 사이 진행바는
|
|
370
|
+
계속 돌아(`뜸Ref.phase = 'tail'`) 뜸이 시간선의 일부로 보인다.
|
|
371
|
+
다음 소리가 있으면 그 소리의 **앞 뜸**이 이어 붙어 사이가 둘을 더한 만큼이 된다 —
|
|
372
|
+
누를 때의 뜸과 사이의 뜸을 가르는 자리가 여기다. */
|
|
324
373
|
const onEnded = () => {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
a
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
374
|
+
const 이어가기 = () => {
|
|
375
|
+
try {
|
|
376
|
+
const sref = sourcesRef.current;
|
|
377
|
+
const next = nextTrackIndex(sref, currentIndexRef.current);
|
|
378
|
+
if (next !== null) {
|
|
379
|
+
currentIndexRef.current = next;
|
|
380
|
+
setCurrentIndex(next);
|
|
381
|
+
set구간표시(true);
|
|
382
|
+
const nextSrc = sref?.[next] && sref[next].src;
|
|
383
|
+
if (nextSrc && a) {
|
|
384
|
+
a.src = nextSrc;
|
|
385
|
+
a.load();
|
|
386
|
+
tryPlayWithRetry(a).catch(() => { });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
338
389
|
}
|
|
339
390
|
}
|
|
391
|
+
catch (e) { }
|
|
392
|
+
setPlaybackState("stopped");
|
|
393
|
+
setProgress(1);
|
|
394
|
+
try {
|
|
395
|
+
if (a)
|
|
396
|
+
try {
|
|
397
|
+
a.currentTime = 0;
|
|
398
|
+
}
|
|
399
|
+
catch (e) { }
|
|
400
|
+
}
|
|
401
|
+
catch (e) { }
|
|
402
|
+
stopTicker();
|
|
403
|
+
set구간표시(false);
|
|
404
|
+
// 마지막 트랙 종료 시 소음 정지
|
|
405
|
+
fadeOutNoise();
|
|
406
|
+
// Route through handleEnd (via ref) so stateLocal transitions to 'active' before onDone fires.
|
|
407
|
+
// Direct onDoneRef.current(true) would fire while stateLocal === 'playMode'.
|
|
408
|
+
try {
|
|
409
|
+
if (!disabledRef.current && handleEndRef.current)
|
|
410
|
+
handleEndRef.current();
|
|
411
|
+
}
|
|
412
|
+
catch (e) { }
|
|
413
|
+
};
|
|
414
|
+
if (tailMsRef.current > 0) {
|
|
415
|
+
/* 기다리는 동안 진행바를 돌린다 — `pause` 가 이미 세웠을 수 있어 다시 켠다.
|
|
416
|
+
그 사이 멈추거나 떠나면 `뜸기다리기` 가 거짓을 주고 아무 일도 안 한다. */
|
|
417
|
+
startTicker();
|
|
418
|
+
뜸기다리기(tailMsRef.current, 'tail').then((이어도되나) => {
|
|
419
|
+
뜸Ref.current = { phase: 'sound', at: 0 };
|
|
420
|
+
if (이어도되나)
|
|
421
|
+
이어가기();
|
|
422
|
+
}).catch(() => { });
|
|
423
|
+
return;
|
|
340
424
|
}
|
|
341
|
-
|
|
342
|
-
setPlaybackState("stopped");
|
|
343
|
-
setProgress(1);
|
|
344
|
-
try {
|
|
345
|
-
if (a)
|
|
346
|
-
try {
|
|
347
|
-
a.currentTime = 0;
|
|
348
|
-
}
|
|
349
|
-
catch (e) { }
|
|
350
|
-
}
|
|
351
|
-
catch (e) { }
|
|
352
|
-
stopTicker();
|
|
353
|
-
set구간표시(false);
|
|
354
|
-
// 마지막 트랙 종료 시 소음 정지
|
|
355
|
-
fadeOutNoise();
|
|
356
|
-
// Route through handleEnd (via ref) so stateLocal transitions to 'active' before onDone fires.
|
|
357
|
-
// Direct onDoneRef.current(true) would fire while stateLocal === 'playMode'.
|
|
358
|
-
try {
|
|
359
|
-
if (!disabledRef.current && handleEndRef.current)
|
|
360
|
-
handleEndRef.current();
|
|
361
|
-
}
|
|
362
|
-
catch (e) { }
|
|
425
|
+
이어가기();
|
|
363
426
|
};
|
|
364
427
|
a.addEventListener("play", onPlay);
|
|
365
428
|
a.addEventListener("pause", onPause);
|
|
@@ -373,7 +436,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
373
436
|
}
|
|
374
437
|
catch (e) { }
|
|
375
438
|
};
|
|
376
|
-
}, [fadeInNoise, fadeOutNoise, tryPlayWithRetry, noiseSettingsRef, startTicker, stopTicker]);
|
|
439
|
+
}, [fadeInNoise, fadeOutNoise, tryPlayWithRetry, noiseSettingsRef, startTicker, stopTicker, 뜸기다리기]);
|
|
377
440
|
useEffect(() => {
|
|
378
441
|
const cleanup = attachAudioListeners();
|
|
379
442
|
return cleanup;
|
|
@@ -1167,7 +1230,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1167
1230
|
onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, inert: !(stateLocal === "playMode" || displayedPlaying) || undefined, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), "aria-pressed": displayedPlaying, width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
|
|
1168
1231
|
handleJumpTo(idx);
|
|
1169
1232
|
}
|
|
1170
|
-
catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm,
|
|
1233
|
+
catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm,
|
|
1234
|
+
/* 뜸도 파형에 그린다((74)) — 2초 단어에 앞뒤 1초면 4초가 한 폭에 들어간다 */
|
|
1235
|
+
leadInMs: leadInMs, tailMs: tailMs, trackDurationSec: sources?.[currentIndex]?.duration ?? 0, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
|
|
1171
1236
|
/* 그림은 "문장 = 기준" — 문제 소리(M÷R)가 아니라 전체 소리 M 으로 문장을 그리고 소음을 R 배로 키운다 */
|
|
1172
1237
|
mainVolume: mainVolume, onReadyChange: handleWaveReadyChange }))] })] }), _jsx("audio", { ref: audioRef, crossOrigin: "anonymous", preload: "auto" })] }), !showSoundWaveForm && (_jsxs("p", { className: "audio-player__no-waveform caption-1", children: [_jsx(Icon, { icon: "triangle-alert", size: 14 }), t('training.noSoundWaveform', '소리 비교 문제는 웨이브폼을 표현하지 않습니다.')] })), _jsx(Toast, { message: noticeMessage, position: noticePosition, onClose: handleClearNotice })] }));
|
|
1173
1238
|
}
|
|
@@ -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
|
}
|
|
@@ -26,6 +26,51 @@ export declare function progressOf({ currentTime, sources, index, audioDuration,
|
|
|
26
26
|
/** `<audio>` 가 알려 주는 길이 — 트랙별 길이를 모를 때만 쓴다 */
|
|
27
27
|
audioDuration: number;
|
|
28
28
|
}): number;
|
|
29
|
+
/**
|
|
30
|
+
* **앞뒤 뜸을 포함한 한 트랙의 진행** — 0~1(말귀 요청 (74), 2026-10-05).
|
|
31
|
+
*
|
|
32
|
+
* 단어 음원은 앞뒤 무음이 **50~70ms** 뿐이라 누르는 순간 소리가 터진다(말귀 실측:
|
|
33
|
+
* 648ms 중 앞 60·뒤 59, 480ms "굴" 중 앞 71·뒤 52). 듣는 사람이 채비할 틈이 없어 첫
|
|
34
|
+
* 음절을 놓치고, 비교 과제(1·2 두 소리)에서는 둘 사이도 붙어 어디서 끊겼는지 헷갈린다.
|
|
35
|
+
* **음원 파일은 안 고친다** — 수천 개고, 무음을 넣으면 길이를 쓰는 자리(숙제·인쇄·분석)가
|
|
36
|
+
* 함께 틀어진다. 그래서 **재생기가 뜸을 준다.**
|
|
37
|
+
*
|
|
38
|
+
* 그러면 진행도 **뜸을 포함한 시간선**으로 재야 한다. 전에는 `currentTime` 을 그대로 따라
|
|
39
|
+
* **소리가 안 나는 동안은 움직일 길이 없었다**(말귀 실측: 누른 뒤 2029ms 에 `play`, 그때까지
|
|
40
|
+
* 채움 0%). 재생바는 **뜸이 시작될 때 출발한다.**
|
|
41
|
+
*
|
|
42
|
+
* 0ms leadIn leadIn+소리 leadIn+소리+tail
|
|
43
|
+
* | 앞 뜸 | 소리 | 뒤 뜸 |
|
|
44
|
+
* └────────────── 이 전체가 1 이다 ──────────────┘
|
|
45
|
+
*/
|
|
46
|
+
export declare function progressWithPad({ phase, phaseElapsedMs, currentTime, trackDurationSec, leadInMs, tailMs, }: {
|
|
47
|
+
/** 지금 어느 구간인가 */
|
|
48
|
+
phase: 'lead' | 'sound' | 'tail';
|
|
49
|
+
/** 뜸 구간에서 흐른 밀리초(`sound` 에서는 안 본다) */
|
|
50
|
+
phaseElapsedMs: number;
|
|
51
|
+
/** `<audio>` 의 `currentTime`(초) */
|
|
52
|
+
currentTime: number;
|
|
53
|
+
/** 이 트랙 소리의 길이(초). 모르면 0 */
|
|
54
|
+
trackDurationSec: number;
|
|
55
|
+
leadInMs: number;
|
|
56
|
+
tailMs: number;
|
|
57
|
+
}): number;
|
|
58
|
+
/**
|
|
59
|
+
* **파형이 그릴 막대 수를 앞·소리·뒤로 나눈다**((74) 참고).
|
|
60
|
+
*
|
|
61
|
+
* 2초 단어에 앞뒤 1초씩이면 **4초가 한 폭**에 들어가고, 소리는 가운데 절반만 차지한다.
|
|
62
|
+
* 뜸 구간 막대는 소리가 없으니 봉우리가 0 이다 — 거기는 **소음 파형이 채운다.**
|
|
63
|
+
*/
|
|
64
|
+
export declare function padBarSpans({ bars, trackDurationSec, leadInMs, tailMs, }: {
|
|
65
|
+
bars: number;
|
|
66
|
+
trackDurationSec: number;
|
|
67
|
+
leadInMs: number;
|
|
68
|
+
tailMs: number;
|
|
69
|
+
}): {
|
|
70
|
+
lead: number;
|
|
71
|
+
sound: number;
|
|
72
|
+
tail: number;
|
|
73
|
+
};
|
|
29
74
|
/**
|
|
30
75
|
* 한 트랙이 끝났다 — 이어서 틀 것이 있는가.
|
|
31
76
|
*
|
|
@@ -32,6 +32,59 @@ export function progressOf({ currentTime, sources, index, audioDuration, }) {
|
|
|
32
32
|
const t = Number.isFinite(currentTime) ? currentTime : 0;
|
|
33
33
|
return Math.max(0, Math.min(1, t / 길이));
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* **앞뒤 뜸을 포함한 한 트랙의 진행** — 0~1(말귀 요청 (74), 2026-10-05).
|
|
37
|
+
*
|
|
38
|
+
* 단어 음원은 앞뒤 무음이 **50~70ms** 뿐이라 누르는 순간 소리가 터진다(말귀 실측:
|
|
39
|
+
* 648ms 중 앞 60·뒤 59, 480ms "굴" 중 앞 71·뒤 52). 듣는 사람이 채비할 틈이 없어 첫
|
|
40
|
+
* 음절을 놓치고, 비교 과제(1·2 두 소리)에서는 둘 사이도 붙어 어디서 끊겼는지 헷갈린다.
|
|
41
|
+
* **음원 파일은 안 고친다** — 수천 개고, 무음을 넣으면 길이를 쓰는 자리(숙제·인쇄·분석)가
|
|
42
|
+
* 함께 틀어진다. 그래서 **재생기가 뜸을 준다.**
|
|
43
|
+
*
|
|
44
|
+
* 그러면 진행도 **뜸을 포함한 시간선**으로 재야 한다. 전에는 `currentTime` 을 그대로 따라
|
|
45
|
+
* **소리가 안 나는 동안은 움직일 길이 없었다**(말귀 실측: 누른 뒤 2029ms 에 `play`, 그때까지
|
|
46
|
+
* 채움 0%). 재생바는 **뜸이 시작될 때 출발한다.**
|
|
47
|
+
*
|
|
48
|
+
* 0ms leadIn leadIn+소리 leadIn+소리+tail
|
|
49
|
+
* | 앞 뜸 | 소리 | 뒤 뜸 |
|
|
50
|
+
* └────────────── 이 전체가 1 이다 ──────────────┘
|
|
51
|
+
*/
|
|
52
|
+
export function progressWithPad({ phase, phaseElapsedMs, currentTime, trackDurationSec, leadInMs, tailMs, }) {
|
|
53
|
+
const 앞 = Math.max(0, leadInMs);
|
|
54
|
+
const 뒤 = Math.max(0, tailMs);
|
|
55
|
+
const 소리 = Math.max(0, (Number.isFinite(trackDurationSec) ? trackDurationSec : 0) * 1000);
|
|
56
|
+
const 총 = 앞 + 소리 + 뒤;
|
|
57
|
+
/* 길이를 모르면(소리 0) 뜸만으로는 진행을 그릴 수 없다 — 0 을 준다. 모르는 것을
|
|
58
|
+
100% 로 그리면 마커가 끝에 붙어 있다가 튄다(`progressOf` 와 같은 판단). */
|
|
59
|
+
if (!(총 > 0) || !(소리 > 0))
|
|
60
|
+
return 0;
|
|
61
|
+
const 흐름 = phase === 'lead' ? Math.min(Math.max(0, phaseElapsedMs), 앞)
|
|
62
|
+
: phase === 'sound' ? 앞 + Math.min(Math.max(0, (Number.isFinite(currentTime) ? currentTime : 0) * 1000), 소리)
|
|
63
|
+
: 앞 + 소리 + Math.min(Math.max(0, phaseElapsedMs), 뒤);
|
|
64
|
+
return Math.max(0, Math.min(1, 흐름 / 총));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* **파형이 그릴 막대 수를 앞·소리·뒤로 나눈다**((74) 참고).
|
|
68
|
+
*
|
|
69
|
+
* 2초 단어에 앞뒤 1초씩이면 **4초가 한 폭**에 들어가고, 소리는 가운데 절반만 차지한다.
|
|
70
|
+
* 뜸 구간 막대는 소리가 없으니 봉우리가 0 이다 — 거기는 **소음 파형이 채운다.**
|
|
71
|
+
*/
|
|
72
|
+
export function padBarSpans({ bars, trackDurationSec, leadInMs, tailMs, }) {
|
|
73
|
+
const 칸 = Math.max(0, Math.floor(bars));
|
|
74
|
+
const 앞 = Math.max(0, leadInMs);
|
|
75
|
+
const 뒤 = Math.max(0, tailMs);
|
|
76
|
+
const 소리 = Math.max(0, (Number.isFinite(trackDurationSec) ? trackDurationSec : 0) * 1000);
|
|
77
|
+
const 총 = 앞 + 소리 + 뒤;
|
|
78
|
+
/* 뜸이 없거나 길이를 모르면 전과 같다 — 소리가 폭을 다 쓴다 */
|
|
79
|
+
if (칸 === 0 || !(총 > 0) || (앞 === 0 && 뒤 === 0) || !(소리 > 0))
|
|
80
|
+
return { lead: 0, sound: 칸, tail: 0 };
|
|
81
|
+
const 앞칸 = Math.round((앞 / 총) * 칸);
|
|
82
|
+
const 뒤칸 = Math.round((뒤 / 총) * 칸);
|
|
83
|
+
/* 소리 막대가 **하나는 남아야 한다** — 반올림이 겹쳐 0 이 되면 파형이 통째로 빈다 */
|
|
84
|
+
const 소리칸 = Math.max(1, 칸 - 앞칸 - 뒤칸);
|
|
85
|
+
const 넘침 = 앞칸 + 소리칸 + 뒤칸 - 칸;
|
|
86
|
+
return { lead: Math.max(0, 앞칸 - Math.ceil(넘침 / 2)), sound: 소리칸, tail: Math.max(0, 뒤칸 - Math.floor(넘침 / 2)) };
|
|
87
|
+
}
|
|
35
88
|
/**
|
|
36
89
|
* 한 트랙이 끝났다 — 이어서 틀 것이 있는가.
|
|
37
90
|
*
|
|
@@ -32,6 +32,29 @@ export interface TrainingStepAudioPlayerProps {
|
|
|
32
32
|
showSoundWaveForm?: boolean;
|
|
33
33
|
/** 소음 waveform 표시 여부. 기본 true. */
|
|
34
34
|
showNoiseWaveForm?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* **소리가 나기 전 뜸**(밀리초). 기본 0 — 안 주면 전과 같다(말귀 요청 (74), 2026-10-05).
|
|
37
|
+
*
|
|
38
|
+
* 단어 음원은 앞뒤 무음이 **50~70ms** 뿐이라 누르는 순간 소리가 터진다(말귀 실측:
|
|
39
|
+
* 648ms 중 앞 60·뒤 59, 480ms "굴" 중 앞 71·뒤 52, 최고 진폭의 1% 를 소리 있음 선으로).
|
|
40
|
+
* 듣는 사람이 채비할 틈이 없어 **첫 음절을 놓친다.** 문장 음원은 녹음에 뜸이 있어 괜찮다.
|
|
41
|
+
*
|
|
42
|
+
* **음원 파일은 안 고친다**(사용자 판단) — 수천 개고, 무음을 넣으면 길이를 쓰는 자리
|
|
43
|
+
* (숙제·인쇄·분석)가 함께 틀어진다. 그래서 **재생기가 뜸을 준다.**
|
|
44
|
+
*
|
|
45
|
+
* 이 뜸은 **파형과 재생바에도 그대로 보인다** — 2초 단어에 앞뒤 1초면 **4초가 한 폭**에
|
|
46
|
+
* 들어가고, 재생바는 **뜸이 시작될 때** 출발한다(소리가 날 때가 아니다).
|
|
47
|
+
*/
|
|
48
|
+
leadInMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* **소리가 끝난 뒤 뜸**(밀리초). 기본 0. 위 `leadInMs` 와 같은 자리에서 왔다.
|
|
51
|
+
*
|
|
52
|
+
* 여러 소리를 잇따라 틀면 앞 것의 뒤 뜸 + 뒤 것의 앞 뜸이 놓여 **사이가 둘을 더한 만큼**
|
|
53
|
+
* 된다(각 1000 이면 2000ms). **누를 때의 뜸과 사이의 뜸은 다른 것**이라 가른다 — 앞엣것은
|
|
54
|
+
* 채비할 틈이고, 사이의 것은 들은 것을 붙들고 다음 것과 **견주는 틈**이다(같은지 다른지
|
|
55
|
+
* 맞히기는 그 견줌이 과제 자체다).
|
|
56
|
+
*/
|
|
57
|
+
tailMs?: number;
|
|
35
58
|
}
|
|
36
59
|
export declare const STATUS_MESSAGES: Record<FetchDebugStatus, string>;
|
|
37
60
|
export declare const STATUS_COLORS: Record<FetchDebugStatus, string>;
|
|
@@ -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, padRef, leadInMs, tailMs, }: {
|
|
4
4
|
audioRef: RefObject<HTMLAudioElement | null>;
|
|
5
5
|
/** 진행 마커 — `left` 를 퍼센트로 민다 */
|
|
6
6
|
fillRef: RefObject<HTMLDivElement | null>;
|
|
@@ -8,6 +8,18 @@ export declare function useProgressTicker({ audioRef, fillRef, waveProgressRef,
|
|
|
8
8
|
waveProgressRef: RefObject<HTMLDivElement | null>;
|
|
9
9
|
sourcesRef: RefObject<Source[] | null>;
|
|
10
10
|
currentIndexRef: RefObject<number>;
|
|
11
|
+
/**
|
|
12
|
+
* **앞뒤 뜸을 포함한 시간선에서 지금 어느 구간인가**(말귀 요청 (74), 2026-10-05).
|
|
13
|
+
*
|
|
14
|
+
* 뜸을 주면 **소리가 안 나는 동안에도 재생바가 움직여야 한다** — 전에는 `currentTime` 을
|
|
15
|
+
* 그대로 따라 멈춰 있었다(말귀 실측: 누른 뒤 2029ms 에 `play`, 그때까지 채움 0%).
|
|
16
|
+
*/
|
|
17
|
+
padRef?: RefObject<{
|
|
18
|
+
phase: 'lead' | 'sound' | 'tail';
|
|
19
|
+
at: number;
|
|
20
|
+
}>;
|
|
21
|
+
leadInMs?: number;
|
|
22
|
+
tailMs?: number;
|
|
11
23
|
}): {
|
|
12
24
|
start: () => void;
|
|
13
25
|
stop: () => void;
|