@ihabdevteam/core 0.273.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 +351 -0
- 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 +18 -2
- package/dist/packages/core/src/components/Button.css +10 -2
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +73 -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/TrainingStepCard.d.ts +17 -1
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +10 -1
- 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/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
|
@@ -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
|
+
}
|
|
@@ -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 {
|