@ihabdevteam/core 0.275.0 → 0.281.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 +292 -1
- package/dist/components.cjs.js +185 -185
- package/dist/components.esm.css +2 -2
- 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 +1 -1
- package/dist/packages/core/src/components/Button.css +10 -2
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +61 -18
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.css +26 -0
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +94 -37
- package/dist/packages/core/src/components/Training/audioPlaybackRules.js +11 -26
- package/dist/packages/core/src/components/Training/audioTimeline.d.ts +81 -0
- package/dist/packages/core/src/components/Training/audioTimeline.js +71 -0
- 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.281.0";
|
|
1655
1655
|
|
|
1656
1656
|
/** @public */
|
|
1657
1657
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -402,9 +402,17 @@
|
|
|
402
402
|
|
|
403
403
|
|
|
404
404
|
|
|
405
|
+
/* **가려진 단추에는 테두리 선이 안 보인다**(말귀 요청 (88), 사용자 결정 2026-10-06 —
|
|
406
|
+
*"꽤 오래전부터 보더가 추가됐는데 보더 안 나오도록 해 줘"*, 같은 날 한 번 더 *"보더 제거"*).
|
|
407
|
+
선이 생긴 때는 `67c946d4`(2026-08-02)다 — 그전에는 `--color-color-darkWhite` 오타 때문에
|
|
408
|
+
`border` 선언이 통째로 무효라 **안 그려지고 있었다.** 오타를 고치며 `border-color:
|
|
409
|
+
var(--color-whiteLine)` 이 함께 살아났다. 고친 것은 맞는 일이었지만 쓰는 사람이 보던
|
|
410
|
+
모습은 선 없는 쪽이고, 그쪽을 골랐다.
|
|
411
|
+
**선은 투명으로 남긴다** — 없애면 단추가 위아래·좌우 1px 씩 줄어 옆 단추와 어긋난다
|
|
412
|
+
(말귀 실측 198.664 × 56 이 그대로여야 한다). */
|
|
405
413
|
.button--variant-disabled-covered {
|
|
406
|
-
border: var(--button-border, 1px solid
|
|
407
|
-
border-color:
|
|
414
|
+
border: var(--button-border, 1px solid transparent);
|
|
415
|
+
border-color: transparent;
|
|
408
416
|
color: transparent;
|
|
409
417
|
background-color: var(--color-darkWhite);
|
|
410
418
|
}
|
|
@@ -4,7 +4,6 @@ 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
6
|
import { padBarSpans } from './audioPlaybackRules';
|
|
7
|
-
import { getNoiseFileGain } from '../../utils/storage';
|
|
8
7
|
import './TrainingStepAudioPlayer.css';
|
|
9
8
|
/**
|
|
10
9
|
* waveform 바 최소 픽셀 — 렌더 높이가 이 값 이하이면 바를 그리지 않는다(문장·소음 공통).
|
|
@@ -226,35 +225,79 @@ export default function WaveForm(props) {
|
|
|
226
225
|
가름선은 **왜 비었나** 다. 뜸 자리는 **설계로** 소리가 없는 곳이라 비는 것이 맞고 —
|
|
227
226
|
거기가 끊기면 파형이 잘려 보인다. 소리 구간 안의 무음은 **그 소리가 실제로 조용한
|
|
228
227
|
것**이라, 그리면 거짓이 된다. */
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
228
|
+
/* **(87) 사용자 결정, 2026-10-06 — 뜸 자리에도 바닥을 안 깐다.**
|
|
229
|
+
*"소음 비율이 0 이거나 소음 종류가 없음일 때 노이즈 파형은 없어야 해. 흰 줄 나오는 것
|
|
230
|
+
안 나오게 해."* 0.266.0 에서 깔고 0.275.0 에서 뜸 자리로 좁힌 그 4px 를 **걷는다.**
|
|
231
|
+
그때 까닭은 "양끝이 잘려 보인다" 였는데(내가 놀이터에서 본 것이다), 쓰는 사람이
|
|
232
|
+
화면에서 보고 **빈 쪽을 골랐다.** 소음이 없으면 뜸 자리는 비어 있는 것이 맞다 —
|
|
233
|
+
거기는 정말로 아무 소리도 안 난다.
|
|
234
|
+
작은 막대를 숨기는 옛 규칙 하나만 남는다(소음이 있든 없든 같다). */
|
|
235
|
+
const val = eff == null ? 0.15 : (belowMin ? 0 : waveBarVal(eff));
|
|
236
|
+
const 숨긴다 = belowMin;
|
|
234
237
|
// 가로 위치 비율(0=왼쪽 lightMain ~ 1=오른쪽 lightSub) — 바 색을 위치에 따라 믹스
|
|
235
238
|
const mix = barsCountForMix > 1 ? i / (barsCountForMix - 1) : 0;
|
|
236
239
|
const style = { ['--val']: val, ['--i']: i, ['--mix']: mix };
|
|
237
240
|
// MIN_BAR_PX 이하로 숨겨지는 바는 최초 등장 애니메이션에서도 잠깐 비쳐 보이지 않도록 애니메이션 자체를 건너뛴다
|
|
238
241
|
const className = 숨긴다 ? 'audio-player__bar audio-player__bar--hidden' : 'audio-player__bar';
|
|
239
242
|
return _jsx("div", { className: className, style: style }, i);
|
|
240
|
-
}), [barsCountForMix, wavePeaks, sentenceMaxPeak, mainAmp, waveHeightPx,
|
|
243
|
+
}), [barsCountForMix, wavePeaks, sentenceMaxPeak, mainAmp, waveHeightPx, 구간]);
|
|
241
244
|
// 초기 progress CSS 변수 값 (React 상태 기준 — RAF 루프가 이후 직접 갱신합니다)
|
|
242
245
|
const progressStyle = { '--progress': String(Math.max(0, Math.min(1, progress))) };
|
|
243
246
|
// 컨테이너 실제 폭에 맞춰 정밀 계산한 바 간격 — outer에 걸어 .audio-player__wave-inner 등
|
|
244
247
|
// 하위 요소들의 CSS 변수(--bar-gap, 기본 3px)로 상속시킨다. 계산은 하되 값을 어디에도
|
|
245
248
|
// 바인딩하지 않아 항상 하드코딩된 기본값(3px)으로만 그려지던 문제 — 실제로 적용한다.
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
+
// 소음 막대 크기 a = (절대진폭 / 문장 최고치) × M × R ÷ gain — 문장과 같은 분모라 두 막대의 비가 곧 소음 비율.
|
|
250
|
+
// 봉우리는 실제로 재생하는 소음 파일(activeNoiseUrl)에서 뽑는다 — 그 파일이 원본보다 gain 배 크면(noiseFileGain,
|
|
251
|
+
// 말귀 noise_x2 = 2) 봉우리도 gain 배라 원본 기준으로 되돌린다. 소리 쪽 noiseElementVolume 과 같은 잣대(0.127.0).
|
|
252
|
+
/**
|
|
253
|
+
* **소음 막대는 소음 자신의 최고치로 잰다 — 그림만**(말귀 요청 (89), 사용자 결정 2026-10-06).
|
|
254
|
+
*
|
|
255
|
+
* 사용자가 정한 규칙(2026-09-13): **소음 1배 = 문제 소리와 같은 높이**, 2배까지는 더 크게.
|
|
256
|
+
* 0.124.0 의 셈은 `소음 봉우리 ÷ 문제 소리 최고치 × 비율` 이었다 — 두 파일의 봉우리가
|
|
257
|
+
* **같을 때만** 그 규칙이 선다. 말귀 실측:
|
|
258
|
+
*
|
|
259
|
+
* 짧은 이야기(녹음 문장) 문제 소리 45px 소음 1배 40~42px 2배 68~70px ← 선다
|
|
260
|
+
* 단어 훈련(합성 단어) 문제 소리 44px 소음 1배 20~23px 2배 41~46px ← 절반
|
|
261
|
+
*
|
|
262
|
+
* 합성 단어의 봉우리가 녹음 문장의 두 배쯤이라 **같은 셈으로 소음이 절반**으로 그려졌다.
|
|
263
|
+
* 사용자: *"그 기능이 사라졌네?"* — 사라진 것이 아니라 음원에 따라 서고 안 섰다.
|
|
264
|
+
*
|
|
265
|
+
* 이제 소음을 **제 최고치로** 잰다: 가장 큰 소음 막대 = `M × R`. 1배에서 문제 소리의 가장
|
|
266
|
+
* 큰 막대(`M`)와 같고 2배에서 눈금 끝(74px)에 닿는다 — **어느 음원에서도.** 파일 게인으로
|
|
267
|
+
* 되돌릴 것도 없다(제 최고치로 나누면 게인이 약분된다).
|
|
268
|
+
*
|
|
269
|
+
* **이것은 그림이다. 소리는 안 바뀐다.** 같은 "1배" 에서 합성 단어가 녹음 문장보다 소음
|
|
270
|
+
* 대비 두 배쯤 크게 실린다는 사실(봉우리 기준)은 그대로다 — 그건 훈련 조건의 문제라
|
|
271
|
+
* 소리를 맞출지는 따로 정한다. 그림은 **설정한 비율**을 보여 주고, 실제 진폭의 비는
|
|
272
|
+
* 더는 보여 주지 않는다. 그 맞바꿈을 알고 한 것이다.
|
|
273
|
+
*/
|
|
274
|
+
const noiseMaxPeak = useMemo(() => (noisePeaks && noisePeaks.length)
|
|
275
|
+
? (Math.max(...noisePeaks.map((v) => (Number.isFinite(v) ? Math.abs(v) : 0))) || 1)
|
|
276
|
+
: 1, [noisePeaks]);
|
|
277
|
+
const noiseAmp = mainAmp * Math.max(0, noiseRatio);
|
|
278
|
+
/**
|
|
279
|
+
* **칸 높이는 설정이 아니라 실제로 그릴 것으로 정한다**(말귀 요청 (83), 2026-10-06).
|
|
280
|
+
*
|
|
281
|
+
* 전에는 `max(1, R)` — **비율 설정**으로 칸을 키웠다. 그런데 막대는 **잰 진폭**으로 큰다
|
|
282
|
+
* (`a = 봉우리/문장최고치 × M × R ÷ gain`). 둘이 안 만난다: 소음 파일의 봉우리가 문장
|
|
283
|
+
* 최고치와 같지 않으면 막대는 `R` 에 못 닿는데 칸은 `R` 만큼 자란다.
|
|
284
|
+
*
|
|
285
|
+
* 말귀 실측(R=2): 칸 45 → 74px, 막대 20 → 41px. **막대는 분명히 두 배가 됐는데** 칸이
|
|
286
|
+
* 같이 커져 칸 대비 44% → 55% 라 **눈에는 그대로**였다 — 사용자: *"2배인데도 1배처럼
|
|
287
|
+
* 보이네?"* 설계는 "2배가 정확히 74px 에 닿는다"(사용자 결정 2026-09-13)인데, 그 말이
|
|
288
|
+
* 서려면 칸이 **닿을 수 있는 것**에 맞아야 한다.
|
|
289
|
+
*
|
|
290
|
+
* 이제 그릴 막대의 **실제 최대**로 칸을 잡는다. 그러면 R=2 에 봉우리가 문장의 절반인
|
|
291
|
+
* 흔한 경우 칸은 45px 에 머물고 막대만 20 → 41px 로 **눈에 두 배가 된다.** 칸은 소음이
|
|
292
|
+
* **정말로** 문장보다 커질 때만 자라고, 그때는 그 큰 막대가 꼭대기에 닿는다.
|
|
293
|
+
*/
|
|
294
|
+
/* 가장 큰 소음 막대가 `M × R` 이므로((89)) 그것이 곧 그릴 최대다 — 소음이 아직 안 왔으면 1 */
|
|
295
|
+
const 그릴최대 = (!noiseSrc || !noisePeaks || !noisePeaks.length) ? 1 : Math.max(1, noiseAmp);
|
|
249
296
|
const outerStyle = {
|
|
250
297
|
'--bar-gap': `${barGap}px`,
|
|
251
|
-
'--wave-h': `${waveBarPx(
|
|
298
|
+
'--wave-h': `${waveBarPx(그릴최대)}px`,
|
|
252
299
|
'--wave-h1': `${WAVE_H1_PX}px`,
|
|
253
300
|
};
|
|
254
|
-
// 소음 막대 크기 a = (절대진폭 / 문장 최고치) × M × R ÷ gain — 문장과 같은 분모라 두 막대의 비가 곧 소음 비율.
|
|
255
|
-
// 봉우리는 실제로 재생하는 소음 파일(activeNoiseUrl)에서 뽑는다 — 그 파일이 원본보다 gain 배 크면(noiseFileGain,
|
|
256
|
-
// 말귀 noise_x2 = 2) 봉우리도 gain 배라 원본 기준으로 되돌린다. 소리 쪽 noiseElementVolume 과 같은 잣대(0.127.0).
|
|
257
|
-
const noiseAmp = (mainAmp * Math.max(0, noiseRatio)) / getNoiseFileGain();
|
|
258
301
|
const noiseReady = !!noisePeaks;
|
|
259
302
|
// 소음 연산은 문장 연산(barsTotal 확정) 이후에 시작돼 항상 더 늦게 끝난다 — 문장 바가 먼저 나타났다가
|
|
260
303
|
// 소음 바만 뒤늦게 팝인하는 것을 막기 위해, 최초 등장 시엔 두 계산이 모두 끝난 뒤 함께 드러낸다.
|
|
@@ -277,12 +320,12 @@ export default function WaveForm(props) {
|
|
|
277
320
|
// 문장 바와 같은 이유로 메모 — 입력값이 바뀔 때만 재생성
|
|
278
321
|
const noiseBars = useMemo(() => Array.from({ length: noiseCount }).map((_, i) => {
|
|
279
322
|
const peak = noisePeaks && noisePeaks[i] != null ? noisePeaks[i] : null;
|
|
280
|
-
const eff = peak == null ? 0 : (peak /
|
|
323
|
+
const eff = peak == null ? 0 : (Math.abs(peak) / noiseMaxPeak) * noiseAmp;
|
|
281
324
|
/* **소음 막대는 안 숨긴다**(말귀 요청 (74), 2026-10-05). 전에는 렌더 높이가 작으면
|
|
282
325
|
통째로 안 그려(`--hidden`) **왼쪽이 비어 보였다** — 말귀 실측(480ms "굴"): 소음 층
|
|
283
326
|
99칸 중 **앞 15칸**, 단어 층 **앞 30칸**이 0. 사용자가 그 빈 자리를 짚었다.
|
|
284
327
|
소음은 바탕 결이라 작아도 **자리는 채워야 한다** — 바닥값으로 내린다.
|
|
285
|
-
문장 막대는
|
|
328
|
+
문장 막대는 작으면 늘 숨긴다 — 소음이 있든 없든((87) 에서 바닥을 걷었다). */
|
|
286
329
|
const val = Math.max(BAR_FLOOR_VAL, waveBarVal(eff));
|
|
287
330
|
const belowMin = false;
|
|
288
331
|
// 가로 위치 비율(0=왼쪽 deepMain ~ 1=오른쪽 deepSub) — 바 색을 위치에 따라 믹스
|
|
@@ -291,6 +334,6 @@ export default function WaveForm(props) {
|
|
|
291
334
|
// MIN_BAR_PX 이하로 숨겨지는 바는 최초 등장 애니메이션에서도 잠깐 비쳐 보이지 않도록 애니메이션 자체를 건너뛴다
|
|
292
335
|
const className = belowMin ? 'audio-player__bar audio-player__bar--hidden' : 'audio-player__bar';
|
|
293
336
|
return _jsx("div", { className: className, style: style }, i);
|
|
294
|
-
}), [noiseCount, noisePeaks,
|
|
337
|
+
}), [noiseCount, noisePeaks, noiseMaxPeak, noiseAmp, waveHeightPx]);
|
|
295
338
|
return (_jsxs("div", { className: "audio-player__wave", ref: outerRef, style: outerStyle, children: [noiseSrc && (_jsx("div", { className: `audio-player__wave-noise ${bothReady ? 'is-ready' : ''}`, "aria-hidden": "true", children: noiseBars })), showSoundWaveForm && (_jsxs(_Fragment, { children: [_jsx("div", { className: `audio-player__wave-inner audio-player__wave-unplayed ${mainReady ? 'is-ready' : ''}`, ref: waveformRef, children: bars }), _jsx("div", { className: `audio-player__wave-inner audio-player__wave-played ${mainReady ? 'is-ready' : ''}`, ref: waveProgressRef, style: progressStyle, children: bars })] }))] }));
|
|
296
339
|
}
|
|
@@ -30,6 +30,16 @@
|
|
|
30
30
|
border: 1px solid var(--color-whiteLine);
|
|
31
31
|
border-radius: 14px;
|
|
32
32
|
padding: 8px 12px;
|
|
33
|
+
/* **옆 단추와 같은 사다리를 탄다**(말귀 요청 (80), 2026-10-06).
|
|
34
|
+
전에는 높이를 안 잡아 속에 든 두 줄로 54px 이 됐다. 옆의 `훈련 설정` 은 `.button56` 인데
|
|
35
|
+
그것은 **폭 따라 내려가는 사다리**다 — 56 / 50(≤1024) / 46(≤768). 그래서 **769~1024px
|
|
36
|
+
에서만** 미터(54)가 단추(50)보다 커져, 미터를 켜고 끌 때마다 띠가 79↔75px 로 바뀌고
|
|
37
|
+
**위쪽 전체가 2px 들썩였다**(말귀 952px 실측: 재생기 y 196↔198, 파형 204↔206,
|
|
38
|
+
`다음 문제` 753↔755). 넓은 화면에서는 단추가 더 커서 안 났다 — 폭을 안 적으면 못 찾는다.
|
|
39
|
+
높이를 같은 사다리로 묶으면 **어느 폭에서도** 띠가 안 변한다. 테두리·안쪽 여백을 높이
|
|
40
|
+
안으로 넣어야 하므로 `border-box` 다. */
|
|
41
|
+
box-sizing: border-box;
|
|
42
|
+
height: 56px;
|
|
33
43
|
}
|
|
34
44
|
.pp-bottom__meter { display:flex; flex-direction:row; align-items:center; gap: var(--spacing-xs) }
|
|
35
45
|
.pp-bottom__meter-label { color: var(--color-gray); min-width: 28px }
|
|
@@ -42,6 +52,22 @@
|
|
|
42
52
|
.pp-bottom__settings--mobile { display: none }
|
|
43
53
|
}
|
|
44
54
|
|
|
55
|
+
/* 사다리의 나머지 두 칸 — `components/Button.css` 의 `.button56` 과 **같은 폭·같은 값**이다.
|
|
56
|
+
한쪽만 고치면 그 폭에서 다시 들썩인다. `test/bottomMetersMatchButtonLadder.test.ts` 가 센다. */
|
|
57
|
+
@media (max-width: 1024px) {
|
|
58
|
+
.pp-bottom__meters {
|
|
59
|
+
height: 50px;
|
|
60
|
+
/* 50px 안에 두 줄이 들어가야 한다 — 테두리 2 + 여백을 줄여 속을 40px 로 둔다 */
|
|
61
|
+
padding: 4px 12px;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
@media (max-width: 768px) {
|
|
65
|
+
.pp-bottom__meters {
|
|
66
|
+
height: 46px;
|
|
67
|
+
padding: 2px 10px;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
.pp-bottom__tooltip-trigger { display: inline-block; }
|
|
46
72
|
|
|
47
73
|
/* 폰에서 토글 단추 위에 떠서 보이는 미터 — 바닥 줄에 나란히 두면 미터 두 줄(222px)이
|
|
@@ -142,6 +142,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
142
142
|
useEffect(() => { leadInMsRef.current = Math.max(0, leadInMs || 0); }, [leadInMs]);
|
|
143
143
|
useEffect(() => { tailMsRef.current = Math.max(0, tailMs || 0); }, [tailMs]);
|
|
144
144
|
const 뜸타이머Ref = useRef(null);
|
|
145
|
+
/* 뒤뜸(소리가 끝난 뒤의 뜸)에서 멈췄나, 그리고 다시 누르면 어디서부터 이을 것인가 —
|
|
146
|
+
아래 `handleToggle` 이 쓴다(말귀 요청 (85) 0단계에서 찾은 결함, 2026-10-06). */
|
|
147
|
+
const 뒤뜸에서멈춤Ref = useRef(false);
|
|
148
|
+
const 이어가기Ref = useRef(null);
|
|
145
149
|
const 뜸취소 = useCallback(() => {
|
|
146
150
|
if (뜸타이머Ref.current) {
|
|
147
151
|
clearTimeout(뜸타이머Ref.current);
|
|
@@ -327,6 +331,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
327
331
|
먼저 켠다(소리가 날 때 오는 `play` 이벤트가 아니라 **여기**가 시작이다). */
|
|
328
332
|
if (leadInMsRef.current > 0) {
|
|
329
333
|
startTicker();
|
|
334
|
+
/* 앞뜸부터 **재생 중**이다 — 재생판이 이제 앞뜸부터 보이므로((86)) 단추도 ⏸ 로 서야
|
|
335
|
+
누르는 뜻과 맞는다. ▶ 로 두면 "멈추려고 ▶ 를 누르는" 꼴이 된다. */
|
|
336
|
+
setPlaybackState("playing");
|
|
330
337
|
/* **소음은 뜸부터 깔린다.** 사용자가 (74) 를 말할 때 기준으로 삼은 것이 "노이즈가
|
|
331
338
|
나오는 순간" 이었다 — 그 순간이 단어와 같이 오면 뜸을 둔 뜻이 없다. 소리가 터지지
|
|
332
339
|
않게 하려고 둔 구간인데 거기가 **완전한 무음**이면, 단어가 소음과 함께 들이닥치는
|
|
@@ -425,6 +432,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
425
432
|
다음 소리가 있으면 그 소리의 **앞 뜸**이 이어 붙어 사이가 둘을 더한 만큼이 된다 —
|
|
426
433
|
누를 때의 뜸과 사이의 뜸을 가르는 자리가 여기다. */
|
|
427
434
|
const onEnded = () => {
|
|
435
|
+
뒤뜸에서멈춤Ref.current = false;
|
|
428
436
|
const 이어가기 = () => {
|
|
429
437
|
try {
|
|
430
438
|
const sref = sourcesRef.current;
|
|
@@ -464,6 +472,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
464
472
|
}
|
|
465
473
|
catch (e) { }
|
|
466
474
|
};
|
|
475
|
+
/* 토글이 뒤뜸에서 멈췄다가 다시 누를 때 **여기서부터** 잇는다 — 아래 `handleToggle` */
|
|
476
|
+
이어가기Ref.current = 이어가기;
|
|
467
477
|
if (tailMsRef.current > 0) {
|
|
468
478
|
/* 기다리는 동안 진행바를 돌린다 — `pause` 가 이미 세웠을 수 있어 다시 켠다.
|
|
469
479
|
그 사이 멈추거나 떠나면 `뜸기다리기` 가 거짓을 주고 아무 일도 안 한다. */
|
|
@@ -639,6 +649,27 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
639
649
|
/* 즉시 채운다 — 아래에서 이어지는 재생이 다음 렌더를 기다리지 않고 이 값을 읽는다. */
|
|
640
650
|
sourcesRef.current = finalSources;
|
|
641
651
|
};
|
|
652
|
+
/**
|
|
653
|
+
* **재생판을 켠다 — 시간표가 시작할 때**(말귀 요청 (86), 2026-10-06).
|
|
654
|
+
*
|
|
655
|
+
* 전에는 `await tryPlayWithRetry(a)` **뒤**에 켰다. 그런데 앞뜸 기다림이 그 안에 있어서
|
|
656
|
+
* 돌림판을 끄고(`setIsFetchingUrl(false)`) 재생판을 켜는 사이에 **뜸 1000ms 가 통째로 끼었다.**
|
|
657
|
+
* 말귀 실측(40ms 간격): 1577ms 돌림판 꺼짐 → 2583ms 재생판 보임 — **정확히 앞뜸 1초가 빈칸.**
|
|
658
|
+
* 돌림판도 없고 듣기 단추도 없고 재생판은 투명하다. 소음만 난다.
|
|
659
|
+
*
|
|
660
|
+
* 사용자가 화면에서 짚었다: *"재생을 누르면 플레이어에 아무것도 안 나온다"*, 이어서
|
|
661
|
+
* *"재생바가 자꾸 단어 소리 시작할 때 나오는데 그러면 안 돼. 완전 처음 재생부터 나와야지."*
|
|
662
|
+
* 진행 **값**은 0.266.0 부터 앞뜸에서 출발했는데(재서 확인도 했다), 그 마커가 담긴 판이
|
|
663
|
+
* 가려져 있었다 — **값은 맞고 눈에는 안 보였다.** (74) 때 사용자가 말한 "노이즈가 나오는
|
|
664
|
+
* 순간부터 재생바도" 가 값으로만 서 있었다.
|
|
665
|
+
*
|
|
666
|
+
* 차례는 이렇다: 돌림판 → 준비 끝 → **재생판·파형·재생바가 보임** → 앞뜸(소음) → 소리.
|
|
667
|
+
*/
|
|
668
|
+
const 재생판켜기 = () => {
|
|
669
|
+
setStateLocal('playMode');
|
|
670
|
+
setCurrentIndex(0);
|
|
671
|
+
set구간표시(true);
|
|
672
|
+
};
|
|
642
673
|
const loadAndPlay = async (a, src) => {
|
|
643
674
|
markCompletedByEnd(false);
|
|
644
675
|
fetchingRef.current = false;
|
|
@@ -648,10 +679,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
648
679
|
if ((await waitForWaveReady()) === false)
|
|
649
680
|
return;
|
|
650
681
|
setIsFetchingUrl(false);
|
|
682
|
+
재생판켜기(); // 앞뜸이 시작되기 **전에** — 뜸 동안 판이 비지 않게
|
|
651
683
|
await tryPlayWithRetry(a);
|
|
652
|
-
setStateLocal('playMode');
|
|
653
|
-
setCurrentIndex(0);
|
|
654
|
-
set구간표시(true);
|
|
655
684
|
};
|
|
656
685
|
const fetchAndPrepareFromTTSUrl = async (ttsUrl, playAfterLoad = false) => {
|
|
657
686
|
const a = audioRef.current;
|
|
@@ -810,6 +839,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
810
839
|
// 뒤늦게 나타나는 빈 화면 구간도 생기지 않도록 한다.
|
|
811
840
|
if (showSpinner)
|
|
812
841
|
setIsFetchingUrl(false);
|
|
842
|
+
재생판켜기(); // 앞뜸이 시작되기 **전에** — 뜸 동안 판이 비지 않게
|
|
813
843
|
if (a)
|
|
814
844
|
await tryPlayWithRetry(a);
|
|
815
845
|
}
|
|
@@ -835,6 +865,55 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
835
865
|
setFetchDebugStatus((prev) => (prev === 'ready' ? 'ready' : 'idle'));
|
|
836
866
|
}
|
|
837
867
|
};
|
|
868
|
+
/**
|
|
869
|
+
* **`<audio>` 로 나는 소리의 재생/멈춤 — 이 판단은 여기 한 자리뿐이다**(2026-10-06).
|
|
870
|
+
*
|
|
871
|
+
* 같은 `if (a.paused) … else …` 가 `handleToggle` 안에 **두 벌** 있었다 — 저장소 소리 갈래와
|
|
872
|
+
* TTS 주소 갈래. 0.278.0 에서 "뜸 중이면 재생 중이다" 를 **한 벌에만** 가르쳤고, 합성 단어
|
|
873
|
+
* (TTS 주소 길)는 고쳐지지 않은 벌을 탔다. 말귀 실측(0.280.0, `같은 소리 고르기`): 앞뜸
|
|
874
|
+
* 300ms 에 ⏸ → 소음 `play()` 가 또 불리고 **같은 단어가 두 번 틀어졌다**(2622ms · 2945ms).
|
|
875
|
+
* 내 못은 저장소 소리로만 돌려서 통과했다 — **고친 벌만 재고 있었다.**
|
|
876
|
+
*/
|
|
877
|
+
const 요소토글 = async (a) => {
|
|
878
|
+
/* **뜸 중이면 재생 중이다**(말귀 요청 (85) 0단계에서 찾은 결함, 2026-10-06).
|
|
879
|
+
전에는 `a.paused` 만 봤다. 그런데 뜸 동안 메인 요소는 **멈춰 있다** — 앞뜸에는 아직
|
|
880
|
+
`play()` 전이고 사이·뒤뜸에는 이미 `ended` 다. 그래서 ⏸ 로 보이는 단추를 뜸 중에
|
|
881
|
+
누르면 **멈춤이 아니라 재생 갈래**를 탔다: 돌고 있는 뜸 사슬 옆에 뜸 사슬이 하나 더
|
|
882
|
+
서고, **다음 소리가 두 번 났다**(가짜 시계 실측 — 사이 뜸 500ms 에 누름: `b.mp3` 가
|
|
883
|
+
+1000ms 에 한 번, 원래 사슬이 닿는 +1500ms 에 또 한 번).
|
|
884
|
+
감시 타이머가 0.268.0 에서 배운 것과 같은 것을 이 단추가 못 배우고 있었다 —
|
|
885
|
+
뜸은 소리가 안 나는 구간이지 **멈춘 구간이 아니다.** */
|
|
886
|
+
const 뜸 = 뜸Ref.current;
|
|
887
|
+
const 뜸도는중 = 뜸.phase !== 'sound' && 뜸.at > 0;
|
|
888
|
+
if (a.paused && !뜸도는중) {
|
|
889
|
+
if (completedByEndRef.current) {
|
|
890
|
+
await handleReplay();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
/* 뒤뜸에서 멈췄던 것이면 그 소리를 다시 틀지 않고 **그다음으로** 잇는다 — 끝난 요소에
|
|
894
|
+
`play()` 를 걸면 방금 들은 소리가 처음부터 또 난다. 뜸은 이미 한 번 기다렸으니
|
|
895
|
+
다시 세지 않는다. */
|
|
896
|
+
if (뒤뜸에서멈춤Ref.current && 이어가기Ref.current) {
|
|
897
|
+
뒤뜸에서멈춤Ref.current = false;
|
|
898
|
+
setPlaybackState("playing");
|
|
899
|
+
이어가기Ref.current();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
await tryPlayWithRetry(a);
|
|
903
|
+
setPlaybackState("playing");
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
뒤뜸에서멈춤Ref.current = 뜸도는중 && 뜸.phase === 'tail';
|
|
907
|
+
멈춤의도();
|
|
908
|
+
a.pause();
|
|
909
|
+
setPlaybackState("paused");
|
|
910
|
+
/* 뜸 중에는 `pause` 사건이 안 온다(요소가 이미 멈춰 있다) — 그 사건이 하던 일을 여기서 한다 */
|
|
911
|
+
if (뜸도는중) {
|
|
912
|
+
stopTicker();
|
|
913
|
+
fadeOutNoise();
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
};
|
|
838
917
|
const handleToggle = async () => {
|
|
839
918
|
const a = audioRef.current;
|
|
840
919
|
if (!a && !ttsMode)
|
|
@@ -848,6 +927,13 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
848
927
|
// ttsText가 배열인 경우: 모든 텍스트의 URL을 미리 resolve해서 multi-source 재생
|
|
849
928
|
const ttsArr = Array.isArray(ttsText) ? ttsText.filter(Boolean) : (ttsText ? [ttsText] : []);
|
|
850
929
|
if (resolver && ttsArr.length > 0) {
|
|
930
|
+
/* **되들어옴을 막는다**(2026-10-06). 저장소 소리 갈래는 `fetchAndPrepare` 가
|
|
931
|
+
`fetchingRef` 로 막는데 이 갈래에는 그 문이 없었다 — 듣기 단추를 연달아 누르면
|
|
932
|
+
(`stateLocal` 이 아직 'active' 인 같은 틀 안에서) 리졸버와 재생 사슬이 **누른 만큼**
|
|
933
|
+
섰다. 못을 TTS 길로도 돌리자 바로 나왔다(연타 3번 → `play()` 3번). 두 길이 같은
|
|
934
|
+
일을 따로 한 벌씩 들고 있어서 한쪽 문이 다른 쪽에 없던 것이다. */
|
|
935
|
+
if (fetchingRef.current)
|
|
936
|
+
return;
|
|
851
937
|
/* 리졸버(문장 TTS는 서버 합성까지 기다린다)가 느릴 수 있어 스피너를 먼저 켠다 —
|
|
852
938
|
fetchAndPrepare 가 켜기 전 합성 대기 구간에 아무 표시가 없던 문제.
|
|
853
939
|
|
|
@@ -885,21 +971,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
885
971
|
이 길로 들어가 소리 없는 <audio> 를 재생하려 들고 말은 멈추지 않았다 — 합성기가 말하는 중이고 <audio> 에 소리가 없으면 합성기를 멈춘다. */
|
|
886
972
|
const 합성으로말함 = !!synth && (synth.speaking || synth.paused) && !(a && (a.currentSrc || a.getAttribute('src')));
|
|
887
973
|
if (a && !합성으로말함) {
|
|
888
|
-
|
|
889
|
-
// 전부 들은 뒤 다시 재생 — 마지막 트랙 이어듣기가 아니라 처음(1번)부터
|
|
890
|
-
// 다시 시작한다(일반 오디오 분기와 동일한 처리).
|
|
891
|
-
if (completedByEndRef.current) {
|
|
892
|
-
await handleReplay();
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
await tryPlayWithRetry(a);
|
|
896
|
-
setPlaybackState('playing');
|
|
897
|
-
}
|
|
898
|
-
else {
|
|
899
|
-
멈춤의도();
|
|
900
|
-
a.pause();
|
|
901
|
-
setPlaybackState('paused');
|
|
902
|
-
}
|
|
974
|
+
await 요소토글(a); // 저장소 소리 갈래와 **같은 한 벌**
|
|
903
975
|
return;
|
|
904
976
|
}
|
|
905
977
|
// speechSynthesis fallback
|
|
@@ -933,14 +1005,13 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
933
1005
|
if (first.src && a) {
|
|
934
1006
|
try {
|
|
935
1007
|
소리싣기(a, first.src);
|
|
1008
|
+
재생판켜기(); // 앞뜸이 시작되기 **전에**
|
|
936
1009
|
if (a)
|
|
937
1010
|
await tryPlayWithRetry(a);
|
|
938
1011
|
}
|
|
939
1012
|
catch (e) { }
|
|
940
1013
|
}
|
|
941
|
-
|
|
942
|
-
setCurrentIndex(0);
|
|
943
|
-
set구간표시(true);
|
|
1014
|
+
재생판켜기();
|
|
944
1015
|
return;
|
|
945
1016
|
}
|
|
946
1017
|
catch (e) { }
|
|
@@ -969,22 +1040,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
969
1040
|
return;
|
|
970
1041
|
if (stateLocal === "playMode" || stateLocal === "active") {
|
|
971
1042
|
try {
|
|
972
|
-
if (a
|
|
973
|
-
|
|
974
|
-
await handleReplay();
|
|
975
|
-
return;
|
|
976
|
-
}
|
|
977
|
-
if (a)
|
|
978
|
-
await tryPlayWithRetry(a);
|
|
979
|
-
setPlaybackState("playing");
|
|
980
|
-
}
|
|
981
|
-
else {
|
|
982
|
-
if (a) {
|
|
983
|
-
멈춤의도();
|
|
984
|
-
a.pause();
|
|
985
|
-
setPlaybackState("paused");
|
|
986
|
-
}
|
|
987
|
-
}
|
|
1043
|
+
if (a)
|
|
1044
|
+
await 요소토글(a);
|
|
988
1045
|
}
|
|
989
1046
|
catch (e) {
|
|
990
1047
|
const errName = e instanceof Error ? e.name : (typeof e === 'object' && e && 'name' in e ? e.name : undefined);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildTimeline, timelineBarSpans, timelineProgress } from './audioTimeline';
|
|
1
2
|
/**
|
|
2
3
|
* 재생 중에 내리는 **판단**들 — 화면도 오디오도 건드리지 않는 순수 계산.
|
|
3
4
|
*
|
|
@@ -50,18 +51,14 @@ export function progressOf({ currentTime, sources, index, audioDuration, }) {
|
|
|
50
51
|
* └────────────── 이 전체가 1 이다 ──────────────┘
|
|
51
52
|
*/
|
|
52
53
|
export function progressWithPad({ phase, phaseElapsedMs, currentTime, trackDurationSec, leadInMs, tailMs, }) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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, 흐름 / 총));
|
|
54
|
+
/* 셈은 `audioTimeline` 이 한다((85) 1단계) — 여기는 옛 꼴로 받아 그 값을 읽을 뿐이다.
|
|
55
|
+
한 트랙짜리 시간표를 지어 그 안에서 얼마나 왔는지 묻는다. */
|
|
56
|
+
const tl = buildTimeline({ durationsSec: [trackDurationSec], leadInMs, tailMs });
|
|
57
|
+
return timelineProgress(tl, {
|
|
58
|
+
track: 0,
|
|
59
|
+
kind: phase,
|
|
60
|
+
elapsedMs: phase === 'sound' ? (Number.isFinite(currentTime) ? currentTime : 0) * 1000 : phaseElapsedMs,
|
|
61
|
+
});
|
|
65
62
|
}
|
|
66
63
|
/**
|
|
67
64
|
* **파형이 그릴 막대 수를 앞·소리·뒤로 나눈다**((74) 참고).
|
|
@@ -70,20 +67,8 @@ export function progressWithPad({ phase, phaseElapsedMs, currentTime, trackDurat
|
|
|
70
67
|
* 뜸 구간 막대는 소리가 없으니 봉우리가 0 이다 — 거기는 **소음 파형이 채운다.**
|
|
71
68
|
*/
|
|
72
69
|
export function padBarSpans({ bars, trackDurationSec, leadInMs, tailMs, }) {
|
|
73
|
-
|
|
74
|
-
|
|
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)) };
|
|
70
|
+
/* 셈은 `audioTimeline` 이 한다((85) 1단계) */
|
|
71
|
+
return timelineBarSpans(buildTimeline({ durationsSec: [trackDurationSec], leadInMs, tailMs }), 0, bars);
|
|
87
72
|
}
|
|
88
73
|
/**
|
|
89
74
|
* 한 트랙이 끝났다 — 이어서 틀 것이 있는가.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **재생 시간표를 값으로 만든다** — 말귀 요청 (85) 1단계, 2026-10-06.
|
|
3
|
+
*
|
|
4
|
+
* 재생기에는 "지금 시간표의 어디인가" 를 쥔 주인이 없었다. 시간표는 이벤트 손잡이 · 타이머 ·
|
|
5
|
+
* 토큰이 맞물린 **결과로 생길 뿐**이라, 그것을 알아야 하는 자리마다 메인 `<audio>` 를 보고
|
|
6
|
+
* 제각기 짐작했다 — 소음 감시는 "메인이 도나" 를 묻다가 뜸에서 소음을 껐고(0.268.0), 토글은
|
|
7
|
+
* `a.paused` 를 보다가 뜸에서 소리를 두 번 냈고(0.278.0), 재생판은 `play` 사건을 기다리다
|
|
8
|
+
* 앞뜸 내내 비어 있었다(0.278.0). 새 개념이 하나 생길 때마다 네 군데를 가르쳐야 했다.
|
|
9
|
+
*
|
|
10
|
+
* 그 첫걸음으로 **시간표 자체를 값으로** 둔다. 여기에는 시계도 요소도 없다 — 길이와 뜸을
|
|
11
|
+
* 받아 **구간 목록**을 낼 뿐이다. 그 값을 읽는 것(진행 · 막대 나눔 · 소리 사이)도 여기 둔다.
|
|
12
|
+
*
|
|
13
|
+
* 소리 둘 · 앞뒤 뜸 1000ms · 소리 600ms
|
|
14
|
+
*
|
|
15
|
+
* 0 1000 1600 2600 3600 4200 5200
|
|
16
|
+
* | 앞뜸 | 소리1 | 뒤뜸 | 앞뜸 | 소리2 | 뒤뜸 |
|
|
17
|
+
* └──── 트랙 0 ────────┘└──── 트랙 1 ────────┘
|
|
18
|
+
* └─ 사이 2000 ─┘
|
|
19
|
+
*
|
|
20
|
+
* **진행과 막대는 트랙마다 따로 잰다**(지금 동작 그대로) — 재생바는 트랙이 바뀌면 처음으로
|
|
21
|
+
* 돌아가고, 파형도 그 트랙의 `앞뜸 + 소리 + 뒤뜸` 을 한 폭에 그린다.
|
|
22
|
+
*/
|
|
23
|
+
export type TimelineKind = 'lead' | 'sound' | 'tail';
|
|
24
|
+
export interface TimelineSegment {
|
|
25
|
+
kind: TimelineKind;
|
|
26
|
+
/** 몇 번째 소리의 구간인가(0 부터) */
|
|
27
|
+
track: number;
|
|
28
|
+
/** 시간표 처음부터 잰 시작(ms) */
|
|
29
|
+
startMs: number;
|
|
30
|
+
/** 길이(ms). 뜸이 0 이거나 소리 길이를 모르면 0 이다 — 구간은 빼지 않고 그대로 둔다 */
|
|
31
|
+
durMs: number;
|
|
32
|
+
}
|
|
33
|
+
export interface Timeline {
|
|
34
|
+
/** 트랙마다 앞뜸 · 소리 · 뒤뜸 셋 — 길이가 0 이어도 자리는 지킨다(번호로 찾을 수 있게) */
|
|
35
|
+
segments: TimelineSegment[];
|
|
36
|
+
totalMs: number;
|
|
37
|
+
trackCount: number;
|
|
38
|
+
}
|
|
39
|
+
/** 소리 길이들과 뜸으로 시간표를 짓는다. 길이를 모르는 소리는 0 으로 둔다. */
|
|
40
|
+
export declare function buildTimeline({ durationsSec, leadInMs, tailMs, }: {
|
|
41
|
+
durationsSec: ReadonlyArray<number | null | undefined>;
|
|
42
|
+
leadInMs?: number;
|
|
43
|
+
tailMs?: number;
|
|
44
|
+
}): Timeline;
|
|
45
|
+
/** 한 트랙의 세 구간. 없는 트랙이면 `null` */
|
|
46
|
+
export declare function trackSegments(tl: Timeline, track: number): {
|
|
47
|
+
lead: TimelineSegment;
|
|
48
|
+
sound: TimelineSegment;
|
|
49
|
+
tail: TimelineSegment;
|
|
50
|
+
/** 이 트랙의 `앞뜸 + 소리 + 뒤뜸` */
|
|
51
|
+
totalMs: number;
|
|
52
|
+
} | null;
|
|
53
|
+
/**
|
|
54
|
+
* **이 트랙 안에서 얼마나 왔나**(0~1).
|
|
55
|
+
*
|
|
56
|
+
* `elapsedMs` 는 **그 구간 안에서** 흐른 시간이다 — 소리 구간이면 `currentTime × 1000`.
|
|
57
|
+
* 길이를 모르는 소리(0)면 0 을 준다: 모르는 것을 100% 로 그리면 마커가 끝에 붙어 있다 튄다.
|
|
58
|
+
*/
|
|
59
|
+
export declare function timelineProgress(tl: Timeline, at: {
|
|
60
|
+
track: number;
|
|
61
|
+
kind: TimelineKind;
|
|
62
|
+
elapsedMs: number;
|
|
63
|
+
}): number;
|
|
64
|
+
/**
|
|
65
|
+
* **이 트랙의 막대를 앞뜸 · 소리 · 뒤뜸으로 나눈다.**
|
|
66
|
+
*
|
|
67
|
+
* 뜸이 없거나 소리 길이를 모르면 소리가 폭을 다 쓴다(전과 같다). 소리 막대는 **하나는
|
|
68
|
+
* 남는다** — 반올림이 겹쳐 0 이 되면 파형이 통째로 빈다.
|
|
69
|
+
*/
|
|
70
|
+
export declare function timelineBarSpans(tl: Timeline, track: number, bars: number): {
|
|
71
|
+
lead: number;
|
|
72
|
+
sound: number;
|
|
73
|
+
tail: number;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* **앞 소리가 끝나고 다음 소리가 날 때까지**(ms) — 뒤뜸 + 앞뜸.
|
|
77
|
+
* 마지막 트랙이거나 없는 트랙이면 0.
|
|
78
|
+
*/
|
|
79
|
+
export declare function gapAfterTrackMs(tl: Timeline, track: number): number;
|
|
80
|
+
/** 그 트랙의 소리가 **누른 뒤 몇 ms 에** 나는가(불러오는 시간은 빼고). 없는 트랙이면 `null` */
|
|
81
|
+
export declare function soundStartMs(tl: Timeline, track: number): number | null;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const 밀리 = (초) => Math.max(0, (typeof 초 === 'number' && Number.isFinite(초) ? 초 : 0) * 1000);
|
|
2
|
+
const 뜸값 = (ms) => Math.max(0, typeof ms === 'number' && Number.isFinite(ms) ? ms : 0);
|
|
3
|
+
/** 소리 길이들과 뜸으로 시간표를 짓는다. 길이를 모르는 소리는 0 으로 둔다. */
|
|
4
|
+
export function buildTimeline({ durationsSec, leadInMs = 0, tailMs = 0, }) {
|
|
5
|
+
const 앞 = 뜸값(leadInMs);
|
|
6
|
+
const 뒤 = 뜸값(tailMs);
|
|
7
|
+
const segments = [];
|
|
8
|
+
let 지금 = 0;
|
|
9
|
+
durationsSec.forEach((초, track) => {
|
|
10
|
+
for (const [kind, durMs] of [['lead', 앞], ['sound', 밀리(초)], ['tail', 뒤]]) {
|
|
11
|
+
segments.push({ kind, track, startMs: 지금, durMs });
|
|
12
|
+
지금 += durMs;
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
return { segments, totalMs: 지금, trackCount: durationsSec.length };
|
|
16
|
+
}
|
|
17
|
+
/** 한 트랙의 세 구간. 없는 트랙이면 `null` */
|
|
18
|
+
export function trackSegments(tl, track) {
|
|
19
|
+
const i = (track | 0) * 3;
|
|
20
|
+
if (track < 0 || i + 2 >= tl.segments.length)
|
|
21
|
+
return null;
|
|
22
|
+
const [lead, sound, tail] = [tl.segments[i], tl.segments[i + 1], tl.segments[i + 2]];
|
|
23
|
+
return { lead, sound, tail, totalMs: lead.durMs + sound.durMs + tail.durMs };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* **이 트랙 안에서 얼마나 왔나**(0~1).
|
|
27
|
+
*
|
|
28
|
+
* `elapsedMs` 는 **그 구간 안에서** 흐른 시간이다 — 소리 구간이면 `currentTime × 1000`.
|
|
29
|
+
* 길이를 모르는 소리(0)면 0 을 준다: 모르는 것을 100% 로 그리면 마커가 끝에 붙어 있다 튄다.
|
|
30
|
+
*/
|
|
31
|
+
export function timelineProgress(tl, at) {
|
|
32
|
+
const t = trackSegments(tl, at.track);
|
|
33
|
+
if (!t || !(t.totalMs > 0) || !(t.sound.durMs > 0))
|
|
34
|
+
return 0;
|
|
35
|
+
const 구간 = t[at.kind];
|
|
36
|
+
const 흐른 = Math.min(Math.max(0, Number.isFinite(at.elapsedMs) ? at.elapsedMs : 0), 구간.durMs);
|
|
37
|
+
return Math.max(0, Math.min(1, (구간.startMs - t.lead.startMs + 흐른) / t.totalMs));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* **이 트랙의 막대를 앞뜸 · 소리 · 뒤뜸으로 나눈다.**
|
|
41
|
+
*
|
|
42
|
+
* 뜸이 없거나 소리 길이를 모르면 소리가 폭을 다 쓴다(전과 같다). 소리 막대는 **하나는
|
|
43
|
+
* 남는다** — 반올림이 겹쳐 0 이 되면 파형이 통째로 빈다.
|
|
44
|
+
*/
|
|
45
|
+
export function timelineBarSpans(tl, track, bars) {
|
|
46
|
+
const 칸 = Math.max(0, Math.floor(bars));
|
|
47
|
+
const t = trackSegments(tl, track);
|
|
48
|
+
if (!t || 칸 === 0 || !(t.totalMs > 0) || (t.lead.durMs === 0 && t.tail.durMs === 0) || !(t.sound.durMs > 0)) {
|
|
49
|
+
return { lead: 0, sound: 칸, tail: 0 };
|
|
50
|
+
}
|
|
51
|
+
const 앞칸 = Math.round((t.lead.durMs / t.totalMs) * 칸);
|
|
52
|
+
const 뒤칸 = Math.round((t.tail.durMs / t.totalMs) * 칸);
|
|
53
|
+
const 소리칸 = Math.max(1, 칸 - 앞칸 - 뒤칸);
|
|
54
|
+
const 넘침 = 앞칸 + 소리칸 + 뒤칸 - 칸;
|
|
55
|
+
return { lead: Math.max(0, 앞칸 - Math.ceil(넘침 / 2)), sound: 소리칸, tail: Math.max(0, 뒤칸 - Math.floor(넘침 / 2)) };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* **앞 소리가 끝나고 다음 소리가 날 때까지**(ms) — 뒤뜸 + 앞뜸.
|
|
59
|
+
* 마지막 트랙이거나 없는 트랙이면 0.
|
|
60
|
+
*/
|
|
61
|
+
export function gapAfterTrackMs(tl, track) {
|
|
62
|
+
const 지금 = trackSegments(tl, track);
|
|
63
|
+
const 다음 = trackSegments(tl, track + 1);
|
|
64
|
+
if (!지금 || !다음)
|
|
65
|
+
return 0;
|
|
66
|
+
return 지금.tail.durMs + 다음.lead.durMs;
|
|
67
|
+
}
|
|
68
|
+
/** 그 트랙의 소리가 **누른 뒤 몇 ms 에** 나는가(불러오는 시간은 빼고). 없는 트랙이면 `null` */
|
|
69
|
+
export function soundStartMs(tl, track) {
|
|
70
|
+
return trackSegments(tl, track)?.sound.startMs ?? null;
|
|
71
|
+
}
|