@ihabdevteam/core 0.273.0 → 0.275.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 +60 -0
- package/dist/components.cjs.js +173 -173
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +85 -85
- package/dist/ihabdevteam-core.d.ts +18 -2
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +16 -4
- package/dist/packages/core/src/components/Training/TrainingStepCard.d.ts +17 -1
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +10 -1
- package/dist/packages/core/src/utils/audioPeaks.d.ts +9 -2
- package/dist/packages/core/src/utils/audioPeaks.js +43 -8
- 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.275.0";
|
|
1655
1655
|
|
|
1656
1656
|
/** @public */
|
|
1657
1657
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -8990,7 +8990,7 @@ export declare function TrainingStepBullet({ state }: {
|
|
|
8990
8990
|
}): JSX.Element;
|
|
8991
8991
|
|
|
8992
8992
|
/** @public */
|
|
8993
|
-
export declare function TrainingStepCard({ index, stepIndex, isLast, title, onDone, onChange, context, selectable, hideLabel, hideAudio, maxCards }: TrainingStepCardProps): JSX.Element;
|
|
8993
|
+
export declare function TrainingStepCard({ index, stepIndex, isLast, title, onDone, onChange, context, selectable, hideLabel, hideAudio, maxCards, playAllGapMs }: TrainingStepCardProps): JSX.Element;
|
|
8994
8994
|
|
|
8995
8995
|
/** @public */
|
|
8996
8996
|
export declare type TrainingStepCardProps = {
|
|
@@ -9011,6 +9011,22 @@ export declare type TrainingStepCardProps = {
|
|
|
9011
9011
|
hideAudio?: boolean;
|
|
9012
9012
|
/** 최대 표시 카드 수 (기본값 3). */
|
|
9013
9013
|
maxCards?: number;
|
|
9014
|
+
/**
|
|
9015
|
+
* **`모두 듣기` 로 잇따라 날 때 낱말 사이에 두는 뜸**(밀리초). 기본 0 — 안 주면 전과 똑같다.
|
|
9016
|
+
*
|
|
9017
|
+
* **앞 카드가 끝난 뒤부터** 센다. 카드 하나를 눌렀을 때의 앞뜸이 아니고, 마지막 카드 뒤에도
|
|
9018
|
+
* 안 둔다(기다릴 다음이 없다).
|
|
9019
|
+
*
|
|
9020
|
+
* 전에는 사이가 **그 카드를 받아 오는 시간**이었다 — 받아 둔 것이 없으면 길고, 있으면
|
|
9021
|
+
* 거의 0 이다. 말귀 실측(`첫 음절이 같은 단어 맞히기` 카드 셋): 안 받아 둔 판 1895ms ·
|
|
9022
|
+
* 555ms, **같은 단추를 다시 누른 판 11ms · 9ms.** 낱말끼리 **견주는** 훈련인데 9ms 면
|
|
9023
|
+
* 세 낱말이 한 숨처럼 이어져 가를 수가 없다. 사이는 **들은 것을 붙드는 틈**이라 망 속도로
|
|
9024
|
+
* 정해질 것이 아니다.
|
|
9025
|
+
*
|
|
9026
|
+
* `TrainingStepAudioPlayer` 의 `leadInMs`·`tailMs` 와 같은 결이다(그쪽은 한 소리의 앞뒤,
|
|
9027
|
+
* 이쪽은 카드와 카드 사이). 뜸이 그렇듯 **재생 속도를 타지 않는다.**
|
|
9028
|
+
*/
|
|
9029
|
+
playAllGapMs?: number;
|
|
9014
9030
|
};
|
|
9015
9031
|
|
|
9016
9032
|
/** @public */
|
|
@@ -168,7 +168,9 @@ export default function WaveForm(props) {
|
|
|
168
168
|
return;
|
|
169
169
|
}
|
|
170
170
|
const windowSec = totalDuration > 0 ? totalDuration * (noiseSpeed || 1) : undefined;
|
|
171
|
-
|
|
171
|
+
/* 소음은 루프되는 바탕이라 **앞머리 무음을 건너뛴다**((82)) — 그 무음은 맨 처음 한 번만
|
|
172
|
+
지나가는 인코더 자국인데, 창이 짧으면 앞칸 예닐곱을 먹어 "앞쪽 소음이 약하다" 로 보였다. */
|
|
173
|
+
computePeaksFromUrl(noiseSrc, barsTotal, windowSec, true)
|
|
172
174
|
.then((peaks) => { if (!cancelled) {
|
|
173
175
|
setNoisePeaks(peaks);
|
|
174
176
|
if (!peaks)
|
|
@@ -215,10 +217,20 @@ export default function WaveForm(props) {
|
|
|
215
217
|
지워도 바탕 결이 그 자리를 지킨다. 소음 층이 **없으면 지킬 것이 없다**: 숨긴 자리가
|
|
216
218
|
그대로 구멍이 된다. 실측(2026-10-05, 13.09초 문장 · 앞뒤 뜸 1000ms · noiseSrc 없음):
|
|
217
219
|
94칸 중 **앞 0~15 · 뒤 81~93** 이 `--hidden` 이었다 — 눈에는 양끝이 잘려 보인다.
|
|
218
|
-
|
|
220
|
+
|
|
221
|
+
**다만 바닥은 뜸 자리에만 깐다**(말귀 요청 (82), 2026-10-06). 0.266.0 에서는 깔개가
|
|
222
|
+
없으면 **모든** 칸에 깔았는데, 그러면 **소리 구간 한가운데의 진짜 무음까지** 4px 막대로
|
|
223
|
+
그려져 **없는 소리를 있다고 그리게 된다**(말귀 실측: 소음 비율 0 에서 100칸 중 0 인 칸이
|
|
224
|
+
하나도 없고 최소가 바닥값 0.0889 — 사용자가 그 가로줄을 짚었다).
|
|
225
|
+
|
|
226
|
+
가름선은 **왜 비었나** 다. 뜸 자리는 **설계로** 소리가 없는 곳이라 비는 것이 맞고 —
|
|
227
|
+
거기가 끊기면 파형이 잘려 보인다. 소리 구간 안의 무음은 **그 소리가 실제로 조용한
|
|
228
|
+
것**이라, 그리면 거짓이 된다. */
|
|
219
229
|
const 깔개없다 = !noiseSrc;
|
|
220
|
-
const
|
|
221
|
-
const
|
|
230
|
+
const 뜸자리 = !소리안;
|
|
231
|
+
const 바닥깔까 = 깔개없다 && 뜸자리;
|
|
232
|
+
const val = eff == null ? 0.15 : (belowMin ? (바닥깔까 ? BAR_FLOOR_VAL : 0) : waveBarVal(eff));
|
|
233
|
+
const 숨긴다 = belowMin && !바닥깔까;
|
|
222
234
|
// 가로 위치 비율(0=왼쪽 lightMain ~ 1=오른쪽 lightSub) — 바 색을 위치에 따라 믹스
|
|
223
235
|
const mix = barsCountForMix > 1 ? i / (barsCountForMix - 1) : 0;
|
|
224
236
|
const style = { ['--val']: val, ['--i']: i, ['--mix']: mix };
|
|
@@ -31,6 +31,22 @@ export type TrainingStepCardProps = {
|
|
|
31
31
|
hideAudio?: boolean;
|
|
32
32
|
/** 최대 표시 카드 수 (기본값 3). */
|
|
33
33
|
maxCards?: number;
|
|
34
|
+
/**
|
|
35
|
+
* **`모두 듣기` 로 잇따라 날 때 낱말 사이에 두는 뜸**(밀리초). 기본 0 — 안 주면 전과 똑같다.
|
|
36
|
+
*
|
|
37
|
+
* **앞 카드가 끝난 뒤부터** 센다. 카드 하나를 눌렀을 때의 앞뜸이 아니고, 마지막 카드 뒤에도
|
|
38
|
+
* 안 둔다(기다릴 다음이 없다).
|
|
39
|
+
*
|
|
40
|
+
* 전에는 사이가 **그 카드를 받아 오는 시간**이었다 — 받아 둔 것이 없으면 길고, 있으면
|
|
41
|
+
* 거의 0 이다. 말귀 실측(`첫 음절이 같은 단어 맞히기` 카드 셋): 안 받아 둔 판 1895ms ·
|
|
42
|
+
* 555ms, **같은 단추를 다시 누른 판 11ms · 9ms.** 낱말끼리 **견주는** 훈련인데 9ms 면
|
|
43
|
+
* 세 낱말이 한 숨처럼 이어져 가를 수가 없다. 사이는 **들은 것을 붙드는 틈**이라 망 속도로
|
|
44
|
+
* 정해질 것이 아니다.
|
|
45
|
+
*
|
|
46
|
+
* `TrainingStepAudioPlayer` 의 `leadInMs`·`tailMs` 와 같은 결이다(그쪽은 한 소리의 앞뒤,
|
|
47
|
+
* 이쪽은 카드와 카드 사이). 뜸이 그렇듯 **재생 속도를 타지 않는다.**
|
|
48
|
+
*/
|
|
49
|
+
playAllGapMs?: number;
|
|
34
50
|
};
|
|
35
51
|
/** @public */
|
|
36
|
-
export default function TrainingStepCard({ index, stepIndex, isLast, title, onDone, onChange, context, selectable, hideLabel, hideAudio, maxCards }: TrainingStepCardProps): import("react").JSX.Element;
|
|
52
|
+
export default function TrainingStepCard({ index, stepIndex, isLast, title, onDone, onChange, context, selectable, hideLabel, hideAudio, maxCards, playAllGapMs }: TrainingStepCardProps): import("react").JSX.Element;
|
|
@@ -21,7 +21,7 @@ function withTimeout(promise, ms) {
|
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
23
|
/** @public */
|
|
24
|
-
export default function TrainingStepCard({ index, stepIndex, isLast = false, title, onDone, onChange, context = [], selectable = false, hideLabel = false, hideAudio = false, maxCards }) {
|
|
24
|
+
export default function TrainingStepCard({ index, stepIndex, isLast = false, title, onDone, onChange, context = [], selectable = false, hideLabel = false, hideAudio = false, maxCards, playAllGapMs = 0 }) {
|
|
25
25
|
const { t } = useTranslation();
|
|
26
26
|
const resolvedTitle = title ?? t('training.cardInstructions', '낱말 카드를 보고 소리를 듣고 확인하세요.');
|
|
27
27
|
const items = useMemo(() => Array.isArray(context) ? context : (context && context.items) || [], [context]);
|
|
@@ -76,6 +76,9 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
76
76
|
catch (e) { }
|
|
77
77
|
}
|
|
78
78
|
}, [selectable, onChange, currentState, onDone]);
|
|
79
|
+
/* 손잡이가 memo 라 **지금 값**을 ref 로 읽는다 — 이 파일이 `단계살아있음` 에 쓰는 것과 같은 결 */
|
|
80
|
+
const 사이뜸Ref = useRef(playAllGapMs);
|
|
81
|
+
useEffect(() => { 사이뜸Ref.current = Math.max(0, playAllGapMs || 0); }, [playAllGapMs]);
|
|
79
82
|
const handlePlayAll = useCallback(async () => {
|
|
80
83
|
if (playingAll || currentState === 'disabled')
|
|
81
84
|
return;
|
|
@@ -91,6 +94,12 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
|
|
|
91
94
|
if (!cardRef)
|
|
92
95
|
continue;
|
|
93
96
|
await Promise.race([withTimeout(cardRef.play(), PLAY_ALL_CARD_TIMEOUT_MS), 멈춤신호]);
|
|
97
|
+
/* **사이 뜸**(말귀 요청 (79), 2026-10-06) — 앞 카드가 **끝난 뒤부터** 센다.
|
|
98
|
+
마지막 뒤에는 안 둔다. 멈춤 신호와 함께 기다려, 누르는 순간 바로 걷힌다. */
|
|
99
|
+
const 사이 = Math.max(0, 사이뜸Ref.current || 0);
|
|
100
|
+
if (사이 > 0 && i < shown.length - 1 && !멈춤 && 단계살아있음.current) {
|
|
101
|
+
await Promise.race([new Promise((r) => { setTimeout(r, 사이); }), 멈춤신호]);
|
|
102
|
+
}
|
|
94
103
|
}
|
|
95
104
|
}
|
|
96
105
|
finally {
|
|
@@ -1,2 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 주소에서 소리를 받아 파형 막대 높이 배열을 낸다. 실패하면 null.
|
|
3
|
+
*
|
|
4
|
+
* `앞무음건너뛰기` 는 **루프되는 바탕 소리**(소음)에만 켠다 — 그 앞 무음은 맨 처음 한 번만
|
|
5
|
+
* 지나가는 인코더 자국이라 그림이 내내 보여 줄 것이 아니다. **문장에는 켜지 않는다**:
|
|
6
|
+
* 문장의 앞 무음은 진짜 그 소리의 일부이고, 앞뒤 뜸((74))이 그 위에서 셈된다.
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
export declare function computePeaksFromUrl(url: string, bars: number, windowSeconds?: number, 앞무음건너뛰기?: boolean): Promise<number[] | null>;
|
|
@@ -13,7 +13,23 @@ import { getErrorMessage } from './errorMessage';
|
|
|
13
13
|
// windowSeconds: 지정 시 파일의 처음 N초만 사용(소음을 문장 재생시간만큼만 그릴 때).
|
|
14
14
|
// 컴포넌트 상태를 참조하지 않는 순수 계산이라 모듈 스코프로 분리 — 매 렌더 재생성되지 않는다.
|
|
15
15
|
/** 내려받은 바이트를 디코딩해 봉우리를 센다 — 아래 `computePeaksFromUrl` 만 쓴다. */
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* **앞머리 무음을 건너뛸 때 쓰는 자**(말귀 요청 (82), 2026-10-06).
|
|
18
|
+
*
|
|
19
|
+
* mp3 는 인코더 지연 때문에 **앞에 무음이 붙는다.** 실측(2026-10-06, 소음 파일 넷):
|
|
20
|
+
* `noise1` 45.6ms · `noise4` 62.0ms · `noise2-01` 66.3ms · `noise3-01` 62.4ms —
|
|
21
|
+
* 하나도 빠짐없이 46~78ms 가 비어 있다.
|
|
22
|
+
*
|
|
23
|
+
* 긴 창에서는 안 보이는데 **창이 짧으면 그 무음이 앞칸 여럿을 먹는다** — 1초 창에 100칸이면
|
|
24
|
+
* 한 칸이 10ms 라 **앞 6~8칸**이 봉우리 0 이 된다(말귀가 화면에서 짚은 수와 같다).
|
|
25
|
+
* 게다가 소음은 **루프**라 그 무음은 맨 처음 한 번만 지나가는데, 그림은 그것을 내내 보여 준다.
|
|
26
|
+
*
|
|
27
|
+
* 0.001 은 디더까지 잡고 0.02 는 진짜 작은 소리를 잘라 낸다 — 가운데를 쓴다.
|
|
28
|
+
*/
|
|
29
|
+
const 무음기준 = 0.01;
|
|
30
|
+
/** 아무리 조용한 파일이라도 이만큼 넘게는 안 건너뛴다 — 통째로 미끄러지지 않게 */
|
|
31
|
+
const 건너뛰기한도초 = 0.3;
|
|
32
|
+
async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, 앞무음건너뛰기 = false) {
|
|
17
33
|
/* 컨텍스트는 **어느 길로 나가든** 닫는다 — 디코딩 실패(404·만료 주소의 HTML)·채널 없음에서 닫지 않아
|
|
18
34
|
파형 하나 실패할 때마다 AudioContext 가 하나씩 남았다(브라우저 동시 한도에 닿으면 이후 파형이 모두 실패). */
|
|
19
35
|
let audioCtx = null;
|
|
@@ -24,14 +40,26 @@ async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds) {
|
|
|
24
40
|
const channelData = decoded.numberOfChannels > 0 ? decoded.getChannelData(0) : null;
|
|
25
41
|
if (!channelData)
|
|
26
42
|
return null;
|
|
43
|
+
/* **앞머리 무음을 건너뛴다**(소음처럼 루프되는 바탕에서만 켠다). 소리 있는 첫 자리를 찾아
|
|
44
|
+
거기서부터 창을 연다 — 그래야 그린 결이 **실제로 들리는 결**이 된다. */
|
|
45
|
+
let 시작 = 0;
|
|
46
|
+
if (앞무음건너뛰기) {
|
|
47
|
+
const 한도 = Math.floor(건너뛰기한도초 * decoded.sampleRate);
|
|
48
|
+
const 끝 = Math.min(channelData.length, 한도);
|
|
49
|
+
while (시작 < 끝 && Math.abs(channelData[시작]) <= 무음기준)
|
|
50
|
+
시작++;
|
|
51
|
+
if (시작 >= 끝)
|
|
52
|
+
시작 = 0; // 통째로 조용한 파일 — 건너뛰지 않는다
|
|
53
|
+
}
|
|
54
|
+
const 남은 = channelData.length - 시작;
|
|
27
55
|
const total = (windowSeconds && windowSeconds > 0)
|
|
28
|
-
? Math.min(
|
|
29
|
-
:
|
|
56
|
+
? Math.min(남은, Math.floor(windowSeconds * decoded.sampleRate))
|
|
57
|
+
: 남은;
|
|
30
58
|
const usableLen = Math.max(bars, total);
|
|
31
59
|
const blockSize = Math.floor(usableLen / bars) || 1;
|
|
32
60
|
const peaks = new Array(bars).fill(0).map((_, i) => {
|
|
33
|
-
const start = i * blockSize;
|
|
34
|
-
const end = Math.min(start + blockSize, usableLen);
|
|
61
|
+
const start = 시작 + i * blockSize;
|
|
62
|
+
const end = Math.min(start + blockSize, 시작 + usableLen, channelData.length);
|
|
35
63
|
let max = 0;
|
|
36
64
|
for (let j = start; j < end; j++) {
|
|
37
65
|
const v = Math.abs(channelData[j]);
|
|
@@ -53,15 +81,22 @@ async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds) {
|
|
|
53
81
|
catch (e) { }
|
|
54
82
|
}
|
|
55
83
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
84
|
+
/**
|
|
85
|
+
* 주소에서 소리를 받아 파형 막대 높이 배열을 낸다. 실패하면 null.
|
|
86
|
+
*
|
|
87
|
+
* `앞무음건너뛰기` 는 **루프되는 바탕 소리**(소음)에만 켠다 — 그 앞 무음은 맨 처음 한 번만
|
|
88
|
+
* 지나가는 인코더 자국이라 그림이 내내 보여 줄 것이 아니다. **문장에는 켜지 않는다**:
|
|
89
|
+
* 문장의 앞 무음은 진짜 그 소리의 일부이고, 앞뒤 뜸((74))이 그 위에서 셈된다.
|
|
90
|
+
* @public
|
|
91
|
+
*/
|
|
92
|
+
export async function computePeaksFromUrl(url, bars, windowSeconds, 앞무음건너뛰기 = false) {
|
|
58
93
|
try {
|
|
59
94
|
const resp = await fetch(url);
|
|
60
95
|
// 404·만료 주소는 소리가 아니다 — 디코딩까지 가지 않는다
|
|
61
96
|
if (resp.ok === false)
|
|
62
97
|
return null;
|
|
63
98
|
const arrayBuffer = await resp.arrayBuffer();
|
|
64
|
-
return await decodeAndComputePeaks(arrayBuffer, bars, windowSeconds);
|
|
99
|
+
return await decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, 앞무음건너뛰기);
|
|
65
100
|
}
|
|
66
101
|
catch (e) {
|
|
67
102
|
try {
|