@ihabdevteam/core 0.288.0 → 0.290.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 +96 -0
- package/dist/components.cjs.js +181 -181
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +186 -186
- package/dist/ihabdevteam-core.d.ts +1 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +22 -26
- package/dist/packages/core/src/components/Training/audioTimeline.d.ts +17 -1
- package/dist/packages/core/src/components/Training/audioTimeline.js +18 -2
- package/dist/packages/core/src/components/Training/oneShotSound.d.ts +54 -0
- package/dist/packages/core/src/components/Training/oneShotSound.js +86 -0
- package/dist/packages/core/src/components/Training/useAudioPreview.js +10 -19
- package/dist/packages/core/src/components/Training/useProgressTicker.js +63 -12
- package/dist/packages/core/src/components/WordTable.js +14 -19
- 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.290.0";
|
|
1655
1655
|
|
|
1656
1656
|
/** @public */
|
|
1657
1657
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -11,6 +11,7 @@ import { getWordPool } from '../../utils/wordPool';
|
|
|
11
11
|
import { useWordImage } from '../../hooks/useWordImage';
|
|
12
12
|
import { getTTSUrlResolver, getTTSFallbackUrlResolver } from '../../utils/ttsConfig';
|
|
13
13
|
import { claimSound, stopSoundIfOwner } from './currentSound';
|
|
14
|
+
import { playOneShot } from './oneShotSound';
|
|
14
15
|
import SymbolSvg from '../../logo/Symbol_speechear_black.svg';
|
|
15
16
|
import { resolveAssetUrl } from '../../utils/assets';
|
|
16
17
|
import './TrainingWordCard.css';
|
|
@@ -124,35 +125,30 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
124
125
|
}
|
|
125
126
|
});
|
|
126
127
|
}, [label, onPlayDone]);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
/* URL 하나를 재생하고 "정상 종료됐는지"로 resolve.
|
|
129
|
+
종료 경로(ended/error/reject)를 **한 번만** 처리하는 일은 `playOneShot` 이 맡는다 —
|
|
130
|
+
`audio.play()` 의 거절과 `error` 이벤트가 동시에 나는 경우가 있어, 가드가 없으면 폴백이
|
|
131
|
+
두 번 걸려 '모두 듣기' 순차 재생에서 소리가 겹친다. 그 가드가 **여기에만** 있었고
|
|
132
|
+
나머지 셋에는 없었다((85) 3단계에서 한 자리로 모았다).
|
|
133
|
+
|
|
134
|
+
**밖에서 멈추면(아래 `멈춤`·`handlePlay`) 이 약속은 안 풀린다** — `pause()` 는 `ended`
|
|
135
|
+
를 안 낸다. 일부러 그대로 뒀다: 풀리면 아래 `await playUrl(...)` 이 거짓을 받고
|
|
136
|
+
**기기 목소리 폴백으로 내려가** 멈춘 뒤에 말을 한다. `playOneShot().stop()` 을 쓰면
|
|
137
|
+
그 꼴이 되므로 여기서는 요소를 직접 세운다. */
|
|
130
138
|
const playUrl = useCallback((url) => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return;
|
|
141
|
-
settled = true;
|
|
142
|
-
setPlaying(false);
|
|
143
|
-
try {
|
|
144
|
-
onPlayDone?.();
|
|
145
|
-
}
|
|
146
|
-
catch { }
|
|
147
|
-
resolve(true);
|
|
148
|
-
};
|
|
149
|
-
audio.onerror = fail;
|
|
150
|
-
audio.play().catch(fail);
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
fail();
|
|
139
|
+
/* 끝은 **그 자리에서** 받는다(약속은 한 틱 늦다) — 옛 코드도 `onended`·`fail` 안에서
|
|
140
|
+
바로 되돌렸고, 그 사이에 남이 상태를 읽는다. */
|
|
141
|
+
const 소리 = playOneShot(url, (들었나) => {
|
|
142
|
+
setPlaying(false);
|
|
143
|
+
if (들었나) {
|
|
144
|
+
try {
|
|
145
|
+
onPlayDone?.();
|
|
146
|
+
}
|
|
147
|
+
catch { }
|
|
154
148
|
}
|
|
155
149
|
});
|
|
150
|
+
audioRef.current = 소리.el;
|
|
151
|
+
return 소리.done;
|
|
156
152
|
}, [onPlayDone]);
|
|
157
153
|
// 오디오 재생 핵심 로직 — 종료 시 resolve. 클릭 핸들러와 ref로 노출되는 play() 양쪽에서 공유.
|
|
158
154
|
// 재생 우선순위: ① 정식 음원 URL → ② 앱이 등록한 런타임 합성 폴백(예: Gemini-TTS) →
|
|
@@ -54,7 +54,23 @@ export declare function trackSegments(tl: Timeline, track: number): {
|
|
|
54
54
|
* **이 트랙 안에서 얼마나 왔나**(0~1).
|
|
55
55
|
*
|
|
56
56
|
* `elapsedMs` 는 **그 구간 안에서** 흐른 시간이다 — 소리 구간이면 `currentTime × 1000`.
|
|
57
|
-
*
|
|
57
|
+
*
|
|
58
|
+
* ### 소리 길이를 몰라도(0) 뜸이 있으면 **뜸으로 잰다**(2026-10-06)
|
|
59
|
+
*
|
|
60
|
+
* 전에는 길이를 모르면 **무조건 0** 을 줬다. 까닭은 *"모르는 것을 100% 로 그리면 마커가
|
|
61
|
+
* 끝에 붙어 있다 튄다"* 였는데, 흐른 시간을 **그 구간 길이로 자르므로**(아래 `흐른`)
|
|
62
|
+
* 앞뜸에서는 아무리 흘러도 `앞뜸/(앞뜸+뒤뜸)` 을 못 넘는다 — 끝에 붙지 않는다.
|
|
63
|
+
*
|
|
64
|
+
* 그 0 이 실제로 한 일은 **앞뜸 1초 동안 재생바를 죽이는 것**이었다. 말귀가 0.288.0 을
|
|
65
|
+
* 앱에서 재니 앞뜸 내내 `0.0%` 였다가 소리가 나는 순간 `38.3%` 로 튀었다 — 길이 재기가
|
|
66
|
+
* 0 을 줘서 여기로 들어온 것이다. 사용자가 (86) 에서 직접 짚은 자리다.
|
|
67
|
+
*
|
|
68
|
+
* 이제는 **소리를 0 으로 둔 시간표**로 잰다(앞뜸+뒤뜸이 폭을 다 쓴다). 길이를 알게 되면
|
|
69
|
+
* 그때 제 시간표로 바뀌므로 그 순간 값이 **뒤로 물러난다** — 어림이 늘 참보다 크기
|
|
70
|
+
* 때문이다(소리가 길수록 뜸의 몫이 준다). 죽은 바보다 낫다는 쪽을 택했다.
|
|
71
|
+
*
|
|
72
|
+
* > 파형(`timelineBarSpans`)은 아직 길이를 모르면 뜸을 안 그린다. 둘이 어긋나는 자리다 —
|
|
73
|
+
* > 뜸을 쓰는 훈련 갈래는 파형을 끄고 재생바만 보이는 쪽이 많아 먼저 바를 살렸다.
|
|
58
74
|
*/
|
|
59
75
|
export declare function timelineProgress(tl: Timeline, at: {
|
|
60
76
|
track: number;
|
|
@@ -26,11 +26,27 @@ export function trackSegments(tl, track) {
|
|
|
26
26
|
* **이 트랙 안에서 얼마나 왔나**(0~1).
|
|
27
27
|
*
|
|
28
28
|
* `elapsedMs` 는 **그 구간 안에서** 흐른 시간이다 — 소리 구간이면 `currentTime × 1000`.
|
|
29
|
-
*
|
|
29
|
+
*
|
|
30
|
+
* ### 소리 길이를 몰라도(0) 뜸이 있으면 **뜸으로 잰다**(2026-10-06)
|
|
31
|
+
*
|
|
32
|
+
* 전에는 길이를 모르면 **무조건 0** 을 줬다. 까닭은 *"모르는 것을 100% 로 그리면 마커가
|
|
33
|
+
* 끝에 붙어 있다 튄다"* 였는데, 흐른 시간을 **그 구간 길이로 자르므로**(아래 `흐른`)
|
|
34
|
+
* 앞뜸에서는 아무리 흘러도 `앞뜸/(앞뜸+뒤뜸)` 을 못 넘는다 — 끝에 붙지 않는다.
|
|
35
|
+
*
|
|
36
|
+
* 그 0 이 실제로 한 일은 **앞뜸 1초 동안 재생바를 죽이는 것**이었다. 말귀가 0.288.0 을
|
|
37
|
+
* 앱에서 재니 앞뜸 내내 `0.0%` 였다가 소리가 나는 순간 `38.3%` 로 튀었다 — 길이 재기가
|
|
38
|
+
* 0 을 줘서 여기로 들어온 것이다. 사용자가 (86) 에서 직접 짚은 자리다.
|
|
39
|
+
*
|
|
40
|
+
* 이제는 **소리를 0 으로 둔 시간표**로 잰다(앞뜸+뒤뜸이 폭을 다 쓴다). 길이를 알게 되면
|
|
41
|
+
* 그때 제 시간표로 바뀌므로 그 순간 값이 **뒤로 물러난다** — 어림이 늘 참보다 크기
|
|
42
|
+
* 때문이다(소리가 길수록 뜸의 몫이 준다). 죽은 바보다 낫다는 쪽을 택했다.
|
|
43
|
+
*
|
|
44
|
+
* > 파형(`timelineBarSpans`)은 아직 길이를 모르면 뜸을 안 그린다. 둘이 어긋나는 자리다 —
|
|
45
|
+
* > 뜸을 쓰는 훈련 갈래는 파형을 끄고 재생바만 보이는 쪽이 많아 먼저 바를 살렸다.
|
|
30
46
|
*/
|
|
31
47
|
export function timelineProgress(tl, at) {
|
|
32
48
|
const t = trackSegments(tl, at.track);
|
|
33
|
-
if (!t || !(t.totalMs > 0)
|
|
49
|
+
if (!t || !(t.totalMs > 0))
|
|
34
50
|
return 0;
|
|
35
51
|
const 구간 = t[at.kind];
|
|
36
52
|
const 흐른 = Math.min(Math.max(0, Number.isFinite(at.elapsedMs) ? at.elapsedMs : 0), 구간.durMs);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 한 번 울리고 끝나는 소리 — **만들어 걸고 끝을 아는 일**을 여기 한 자리에서 한다.
|
|
3
|
+
*
|
|
4
|
+
* `currentSound.ts` 가 *"지금 울리는 것은 하나"* 를 지킨다면, 이 파일은 그 하나를 **어떻게
|
|
5
|
+
* 만들어 거는가**를 지킨다. 전에는 맨 `new Audio(url)` 이 네 자리에 흩어져 있었다
|
|
6
|
+
* (낱말표 칸 · 훈련 낱말 카드 · 미리듣기 · 인쇄 종이). 넷이 같은 결정을 저마다 적었고,
|
|
7
|
+
* **한 자리만 안 따라갔다** — 끝을 한 번만 처리하는 가드가 낱말 카드에만 있었다.
|
|
8
|
+
*
|
|
9
|
+
* 그 가드가 왜 필요한지는 거기 적혀 있던 말 그대로다: `audio.play()` 의 거절과 `error`
|
|
10
|
+
* 이벤트가 **함께 나는** 경우가 있어, 가드가 없으면 폴백이 두 번 걸려 '모두 듣기' 순차
|
|
11
|
+
* 재생에서 소리가 겹친다. 낱말표와 미리듣기는 그 가드 없이 살았다 — 거기서는 끝 처리가
|
|
12
|
+
* 마침 두 번 불려도 해롭지 않은 꼴이었기 때문이고(자리 비우기는 두 번째가 빈손이다),
|
|
13
|
+
* 그것은 **운이지 설계가 아니다.**
|
|
14
|
+
*
|
|
15
|
+
* **자물쇠는 여기서 안 잡는다.** 셋이 잡는 결이 저마다 다르다 — 낱말 카드는 기기 목소리와
|
|
16
|
+
* 자리를 함께 쓰고, 미리듣기는 소리 **여럿**을 한 번 잡아 잇달아 틀고, 낱말표 칸은 하나다.
|
|
17
|
+
* 그 셋을 하나로 만들려면 뜻을 바꿔야 해서, 여기서는 **요소 하나의 삶**만 맡는다.
|
|
18
|
+
*
|
|
19
|
+
* ### 전체 소리 · 재생 속도는 **아직 안 따른다**
|
|
20
|
+
*
|
|
21
|
+
* 이 넷은 `volume`·`playbackRate` 를 건드리지 않는다 — 훈련 단계 재생기만 따른다. 그것을
|
|
22
|
+
* 따라야 하는지는 **사장님 결정을 기다리는 물음**이다(말귀 (85) 3단계). 지금 이 파일이 하는
|
|
23
|
+
* 일은 그 결정이 내려졌을 때 **고칠 자리를 하나로 만들어 두는 것**이다 — 아래 `playOneShot`
|
|
24
|
+
* 한 군데에 두 줄을 더하면 넷이 함께 따른다. 넷에 흩어진 채로 결정이 내려지면, 지금까지
|
|
25
|
+
* 그래 왔듯 **한 자리가 안 따라간다.**
|
|
26
|
+
*/
|
|
27
|
+
/** `playOneShot` 이 돌려주는 것 — 한 번 울리는 소리 하나의 삶 */
|
|
28
|
+
export interface OneShotSound {
|
|
29
|
+
/**
|
|
30
|
+
* 만든 요소. 화면이 떠날 때 멈추려고 쥐고 있는 쪽이 있어 내보낸다
|
|
31
|
+
* (`new Audio()` 로 만든 것은 DOM 에 없어서, 화면이 사라져도 저 혼자 계속 울린다).
|
|
32
|
+
*/
|
|
33
|
+
readonly el: HTMLAudioElement;
|
|
34
|
+
/** 멈춘다. `done` 은 **`false`** 로 풀린다 — 끝까지 들은 것이 아니다. */
|
|
35
|
+
stop: () => void;
|
|
36
|
+
/**
|
|
37
|
+
* 끝까지 들었으면 `true`, 멈췄거나 틀지 못했으면 `false`.
|
|
38
|
+
* **딱 한 번 풀린다** — 거절과 `error` 가 함께 와도 그렇다.
|
|
39
|
+
*/
|
|
40
|
+
readonly done: Promise<boolean>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 소리 하나를 틀고, 끝을 **한 번만** 알린다.
|
|
44
|
+
*
|
|
45
|
+
* `onEnd` 는 끝나는 **바로 그 자리에서**(약속의 다음 틱이 아니라) 부른다. 화면 상태를
|
|
46
|
+
* 거기서 되돌리는 쪽이 있어서다 — 약속으로만 알렸더니 `setPlaying(false)` 가 한 틱
|
|
47
|
+
* 늦어, 자물쇠를 남이 가져간 **그 순간**에 단어표 칸이 아직 "재생 중" 으로 보였다
|
|
48
|
+
* (시험 셋이 그것을 잡았다).
|
|
49
|
+
*
|
|
50
|
+
* 던지지 않는다 — 요소를 못 만드는 환경(`Audio` 가 없는 곳)에서도 `done` 이 `false` 로
|
|
51
|
+
* 풀린다. 부르는 쪽이 `try` 로 감싸지 않아도 되게 하려는 것이다. 넷 가운데 셋이 저마다
|
|
52
|
+
* `try` 를 적고 있었고, 그 안에서 하는 일이 조금씩 달랐다.
|
|
53
|
+
*/
|
|
54
|
+
export declare function playOneShot(url: string, onEnd?: (들었나: boolean) => void): OneShotSound;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 한 번 울리고 끝나는 소리 — **만들어 걸고 끝을 아는 일**을 여기 한 자리에서 한다.
|
|
3
|
+
*
|
|
4
|
+
* `currentSound.ts` 가 *"지금 울리는 것은 하나"* 를 지킨다면, 이 파일은 그 하나를 **어떻게
|
|
5
|
+
* 만들어 거는가**를 지킨다. 전에는 맨 `new Audio(url)` 이 네 자리에 흩어져 있었다
|
|
6
|
+
* (낱말표 칸 · 훈련 낱말 카드 · 미리듣기 · 인쇄 종이). 넷이 같은 결정을 저마다 적었고,
|
|
7
|
+
* **한 자리만 안 따라갔다** — 끝을 한 번만 처리하는 가드가 낱말 카드에만 있었다.
|
|
8
|
+
*
|
|
9
|
+
* 그 가드가 왜 필요한지는 거기 적혀 있던 말 그대로다: `audio.play()` 의 거절과 `error`
|
|
10
|
+
* 이벤트가 **함께 나는** 경우가 있어, 가드가 없으면 폴백이 두 번 걸려 '모두 듣기' 순차
|
|
11
|
+
* 재생에서 소리가 겹친다. 낱말표와 미리듣기는 그 가드 없이 살았다 — 거기서는 끝 처리가
|
|
12
|
+
* 마침 두 번 불려도 해롭지 않은 꼴이었기 때문이고(자리 비우기는 두 번째가 빈손이다),
|
|
13
|
+
* 그것은 **운이지 설계가 아니다.**
|
|
14
|
+
*
|
|
15
|
+
* **자물쇠는 여기서 안 잡는다.** 셋이 잡는 결이 저마다 다르다 — 낱말 카드는 기기 목소리와
|
|
16
|
+
* 자리를 함께 쓰고, 미리듣기는 소리 **여럿**을 한 번 잡아 잇달아 틀고, 낱말표 칸은 하나다.
|
|
17
|
+
* 그 셋을 하나로 만들려면 뜻을 바꿔야 해서, 여기서는 **요소 하나의 삶**만 맡는다.
|
|
18
|
+
*
|
|
19
|
+
* ### 전체 소리 · 재생 속도는 **아직 안 따른다**
|
|
20
|
+
*
|
|
21
|
+
* 이 넷은 `volume`·`playbackRate` 를 건드리지 않는다 — 훈련 단계 재생기만 따른다. 그것을
|
|
22
|
+
* 따라야 하는지는 **사장님 결정을 기다리는 물음**이다(말귀 (85) 3단계). 지금 이 파일이 하는
|
|
23
|
+
* 일은 그 결정이 내려졌을 때 **고칠 자리를 하나로 만들어 두는 것**이다 — 아래 `playOneShot`
|
|
24
|
+
* 한 군데에 두 줄을 더하면 넷이 함께 따른다. 넷에 흩어진 채로 결정이 내려지면, 지금까지
|
|
25
|
+
* 그래 왔듯 **한 자리가 안 따라간다.**
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* 소리 하나를 틀고, 끝을 **한 번만** 알린다.
|
|
29
|
+
*
|
|
30
|
+
* `onEnd` 는 끝나는 **바로 그 자리에서**(약속의 다음 틱이 아니라) 부른다. 화면 상태를
|
|
31
|
+
* 거기서 되돌리는 쪽이 있어서다 — 약속으로만 알렸더니 `setPlaying(false)` 가 한 틱
|
|
32
|
+
* 늦어, 자물쇠를 남이 가져간 **그 순간**에 단어표 칸이 아직 "재생 중" 으로 보였다
|
|
33
|
+
* (시험 셋이 그것을 잡았다).
|
|
34
|
+
*
|
|
35
|
+
* 던지지 않는다 — 요소를 못 만드는 환경(`Audio` 가 없는 곳)에서도 `done` 이 `false` 로
|
|
36
|
+
* 풀린다. 부르는 쪽이 `try` 로 감싸지 않아도 되게 하려는 것이다. 넷 가운데 셋이 저마다
|
|
37
|
+
* `try` 를 적고 있었고, 그 안에서 하는 일이 조금씩 달랐다.
|
|
38
|
+
*/
|
|
39
|
+
export function playOneShot(url, onEnd) {
|
|
40
|
+
let 풀기 = () => { };
|
|
41
|
+
const done = new Promise((res) => { 풀기 = res; });
|
|
42
|
+
/** 끝은 한 번이다 — 거절과 `error` 가 함께 오는 일이 실제로 있다 */
|
|
43
|
+
let 끝났나 = false;
|
|
44
|
+
const 끝 = (들었나) => {
|
|
45
|
+
if (끝났나)
|
|
46
|
+
return;
|
|
47
|
+
끝났나 = true;
|
|
48
|
+
try {
|
|
49
|
+
onEnd?.(들었나);
|
|
50
|
+
}
|
|
51
|
+
catch { /* 부르는 쪽의 실패로 약속을 묶어 두지 않는다 */ }
|
|
52
|
+
풀기(들었나);
|
|
53
|
+
};
|
|
54
|
+
let el;
|
|
55
|
+
try {
|
|
56
|
+
el = new Audio(url);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* 요소를 못 만들면 울릴 수 없다 — 부르는 쪽은 `done` 하나만 보면 된다.
|
|
60
|
+
빈 요소를 돌려주는 것은 `el` 을 쥐고 `pause()` 를 부르는 쪽 때문이다. */
|
|
61
|
+
끝(false);
|
|
62
|
+
return { el: {}, stop: () => { }, done };
|
|
63
|
+
}
|
|
64
|
+
el.onended = () => 끝(true);
|
|
65
|
+
el.onerror = () => 끝(false);
|
|
66
|
+
/* `play()` 의 거절도 "못 틀었다" 다(자동재생 막힘 등) */
|
|
67
|
+
try {
|
|
68
|
+
void el.play().catch(() => 끝(false));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
끝(false);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
el,
|
|
75
|
+
stop: () => {
|
|
76
|
+
/* `pause()` 는 `ended` 를 안 낸다 — 여기서 풀지 않으면 기다리는 쪽이 영원히 선다.
|
|
77
|
+
미리듣기의 잇단 재생이 멈춤 단추를 눌러도 다음 소리로 넘어가 버린 적이 있다. */
|
|
78
|
+
try {
|
|
79
|
+
el.pause();
|
|
80
|
+
}
|
|
81
|
+
catch { /* 이미 정리됐다 */ }
|
|
82
|
+
끝(false);
|
|
83
|
+
},
|
|
84
|
+
done,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { resolveSoundPath, getSignedOrPublicUrl, downloadSound } from '../../utils/storage';
|
|
4
4
|
import { claimSound, stopSoundIfOwner } from './currentSound';
|
|
5
|
+
import { playOneShot } from './oneShotSound';
|
|
5
6
|
function derivePreviewMeta(trainingData, typeKey) {
|
|
6
7
|
if (!trainingData)
|
|
7
8
|
return { previewCodes: [], previewBucket: null };
|
|
@@ -147,15 +148,12 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
147
148
|
const urls = previewUrls.filter(Boolean);
|
|
148
149
|
if (!urls.length)
|
|
149
150
|
return;
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
/* pause() 만으로는 ended 가 오지 않아 기다리던 약속이 풀리지 않는다 — 멈출 때 풀어
|
|
152
|
+
반복을 끝낸다. 그 일을 `playOneShot().stop()` 이 맡는다((85) 3단계로 한 자리에 모았다). */
|
|
153
|
+
let 지금소리 = null;
|
|
152
154
|
const 멈춤 = () => {
|
|
153
155
|
playingRef.current = false;
|
|
154
|
-
|
|
155
|
-
previewAudioRef.current?.pause();
|
|
156
|
-
}
|
|
157
|
-
catch { }
|
|
158
|
-
기다림풀기?.();
|
|
156
|
+
지금소리?.stop();
|
|
159
157
|
};
|
|
160
158
|
멈춤Ref.current = 멈춤;
|
|
161
159
|
const release = claimSound(멈춤);
|
|
@@ -168,19 +166,12 @@ export function useAudioPreview(trainingData, typeKey) {
|
|
|
168
166
|
if (!url)
|
|
169
167
|
continue;
|
|
170
168
|
setPlayingIndex(i);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
audio.onended = () => resolve();
|
|
177
|
-
audio.onerror = () => reject();
|
|
178
|
-
audio.play().catch(reject);
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
catch {
|
|
169
|
+
지금소리 = playOneShot(url);
|
|
170
|
+
previewAudioRef.current = 지금소리.el;
|
|
171
|
+
/* 끝까지 들었으면 다음 소리로. 못 틀었거나(error·거절) 멈췄으면 거기서 끝낸다 —
|
|
172
|
+
전에는 끝까지 들음은 `resolve`, 못 틀음은 `reject`, 멈춤은 또 `resolve` 라 길이 셋이었다. */
|
|
173
|
+
if (!(await 지금소리.done))
|
|
182
174
|
break;
|
|
183
|
-
}
|
|
184
175
|
}
|
|
185
176
|
release();
|
|
186
177
|
if (멈춤Ref.current === 멈춤) {
|
|
@@ -61,8 +61,28 @@ function audioDurationOf(a) {
|
|
|
61
61
|
* 아무 일도 안 한다.
|
|
62
62
|
*/
|
|
63
63
|
const 느린틀 = 400;
|
|
64
|
+
/** 이만큼 멀쩡했다가 다시 느려지면 **새 고비**로 센다(ms) */
|
|
65
|
+
const 고비사이 = 2000;
|
|
66
|
+
/** 고비를 이만큼까지만 알린다 — 그 뒤로는 조용히 둔다(콘솔이 넘치면 진짜 봐야 할 것이 묻힌다) */
|
|
67
|
+
const 고비한도 = 5;
|
|
64
68
|
export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer, leadInMs = 0, tailMs = 0, }) {
|
|
65
69
|
const rafRef = useRef(null);
|
|
70
|
+
/**
|
|
71
|
+
* **느린 틀을 한 번만 말하면, 두 번째로 재는 사람은 아무 말도 못 듣는다**(2026-10-06).
|
|
72
|
+
*
|
|
73
|
+
* 이 줄은 (77) 때 둘이 몇 시간 쓰고 넣은 것이다. 그런데 `devWarnOnce` 는 **한 판에 한 번**
|
|
74
|
+
* 이라, 처음 느렸을 때 한 줄 찍고 나면 그 뒤로는 칸이 숨든 말든 조용하다. 같은 밤에
|
|
75
|
+
* 말귀가 **두 번째**로 같은 함정에 빠져 "되돌아감" 을 보고했다가 거뒀다 — 탭이 뒤에 있어
|
|
76
|
+
* 틀이 눌린 그림이었다. 그때 콘솔은 조용했다.
|
|
77
|
+
*
|
|
78
|
+
* 그래서 **고비마다** 다시 말한다: 틀이 2초 넘게 멀쩡하다가 다시 느려지면 새 고비로 센다.
|
|
79
|
+
* 매 틀 찍지는 않는다(한 고비에 한 번) — 콘솔이 넘치면 진짜 봐야 할 것이 묻힌다.
|
|
80
|
+
*/
|
|
81
|
+
const 느린고비Ref = useRef(0);
|
|
82
|
+
/** 마지막으로 느렸던 때. **아직 없으면 `null`** — 0 을 쓰면 안 된다:
|
|
83
|
+
`performance.now()` 는 쪽이 열린 뒤부터 세므로 첫 1~2초 안에 느려지면
|
|
84
|
+
`지금 - 0` 이 고비사이를 못 넘어 **첫 고비를 통째로 놓친다**(시험이 잡았다). */
|
|
85
|
+
const 마지막느린때Ref = useRef(null);
|
|
66
86
|
const stop = useCallback(() => {
|
|
67
87
|
if (rafRef.current === null)
|
|
68
88
|
return;
|
|
@@ -79,14 +99,25 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
|
|
|
79
99
|
if (isDev) {
|
|
80
100
|
const 지금 = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
|
|
81
101
|
if (앞틀 > 0 && 지금 - 앞틀 > 느린틀) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
102
|
+
/** 멀쩡하다가 다시 느려졌으면 **새 고비**다 — 그때 다시 말한다 */
|
|
103
|
+
const 새고비 = 마지막느린때Ref.current === null || 지금 - 마지막느린때Ref.current > 고비사이;
|
|
104
|
+
마지막느린때Ref.current = 지금;
|
|
105
|
+
if (새고비 && 느린고비Ref.current < 고비한도) {
|
|
106
|
+
느린고비Ref.current += 1;
|
|
107
|
+
const 고비 = 느린고비Ref.current;
|
|
108
|
+
let 숨었나 = null;
|
|
109
|
+
try {
|
|
110
|
+
숨었나 = typeof document !== 'undefined' ? document.visibilityState : null;
|
|
111
|
+
}
|
|
112
|
+
catch { /* 문서 없는 데 */ }
|
|
113
|
+
devWarnOnce(`progress-ticker-slow-frames#${고비}`, `[ihab-core] 진행 사슬의 틀 사이가 ${Math.round(지금 - 앞틀)}ms 입니다(보통 16ms).`, 숨었나 === 'hidden'
|
|
114
|
+
? '이 칸이 **숨어 있어** 브라우저가 틀을 줄였습니다 — 재생바·파형은 틀마다 그려지므로, 지금 재는 값은 **사람이 보는 화면과 다릅니다.** 칸을 앞으로 꺼내고 다시 재십시오.'
|
|
115
|
+
: '칸은 보이는데 틀이 느립니다 — 같은 화면에서 무거운 일이 도는지 보십시오. 재생바·파형이 끊겨 보이는 것은 이 탓일 수 있습니다.', 고비 >= 고비한도 ? `(${고비}번째 — 더는 안 알립니다)` : `(${고비}번째)`);
|
|
85
116
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
117
|
+
}
|
|
118
|
+
else if (앞틀 > 0) {
|
|
119
|
+
/* 멀쩡한 틀이 이어지는 동안에는 고비가 안 자란다 — 위 `새고비` 가 시계로만 가름하므로
|
|
120
|
+
여기서 할 일은 없다. 이 가지는 읽는 사람을 위한 자리다. */
|
|
90
121
|
}
|
|
91
122
|
앞틀 = 지금;
|
|
92
123
|
}
|
|
@@ -97,7 +128,11 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
|
|
|
97
128
|
// 트랙 길이를 알면 progressOf 가 그것을 쓴다 — <audio> 길이는 모를 때만 본다
|
|
98
129
|
const 총길이 = 트랙길이알아 ? 0 : audioDurationOf(a);
|
|
99
130
|
const fill = fillRef.current;
|
|
100
|
-
|
|
131
|
+
/* 뜸이 있으면 **뜸을 포함한 시간선**으로 잰다. 없으면(기본 0) 전과 같은 길이다. */
|
|
132
|
+
const 뜸있다 = (leadInMs || 0) > 0 || (tailMs || 0) > 0;
|
|
133
|
+
/* **지금 뜸이 도는가** — 길이를 몰라도 뜸은 제 길이를 안다(아래 참고) */
|
|
134
|
+
const 뜸중 = 뜸있다 && (sequencer?.positionRef.current?.phase ?? 'sound') !== 'sound';
|
|
135
|
+
if (총길이 === null && !뜸중) {
|
|
101
136
|
/* 길이를 모른다(스트리밍 등으로 duration 이 Infinity·NaN 이고 되감을 수 있는 끝도 없다) —
|
|
102
137
|
마커를 0 에 붙여 두면 "안 움직이는 재생"으로 읽힌다. 진행을 모르니 마커를 숨긴다. */
|
|
103
138
|
try {
|
|
@@ -107,14 +142,28 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
|
|
|
107
142
|
catch { /* 떼어진 노드 */ }
|
|
108
143
|
}
|
|
109
144
|
else {
|
|
110
|
-
/*
|
|
111
|
-
|
|
145
|
+
/* **뜸이 도는 동안에는 길이를 몰라도 숨기지 않는다**(말귀 되돌아감 보고, 2026-10-06).
|
|
146
|
+
앱에서 앞뜸 1초 내내 재생바가 `0.0%` 에 멈췄다가 소리가 나는 순간 `38.3%` 로
|
|
147
|
+
튀었다 — 소리 길이 재기가 0 을 줘서 위 숨김 가지로 들어갔고, 그 1초 동안
|
|
148
|
+
**아무것도 안 그렸다.** 뜸은 제 길이를 아는 구간이다(`leadInMs`·`tailMs` 를
|
|
149
|
+
여기서 받고 있다). 그래서 길이를 모르면 **소리를 0 으로 둔 시간표**로 잰다 —
|
|
150
|
+
`timelineProgress` 에 그 사정을 적어 뒀다. 사용자가 (86) 에서 직접 짚은 자리다. */
|
|
151
|
+
const 잴길이 = 트랙길이알아 ? 트랙.duration : (총길이 || 0);
|
|
152
|
+
/* **어림으로 그리는 자리를 스스로 말하게 한다**(2026-10-06).
|
|
153
|
+
위 한 줄(`총길이 === null`)만 보던 것을 **길이가 0 인 것 전부**로 넓혔다 —
|
|
154
|
+
`<audio>` 가 `duration === 0` 을 주는 동안에도 같은 어림을 그리는데 그때는
|
|
155
|
+
말이 없었다. 말귀가 앱에서 두 번 다 길이를 알고 있었다고 재 왔으므로(0.744 ·
|
|
156
|
+
0.72, `readyState=4`) **이 가지는 지금 길에서는 안 타야 한다.** 타면 그 자리를
|
|
157
|
+
알아야 한다 — 안 타는 것을 **재서** 알 수 있게 띄운다. */
|
|
158
|
+
if (isDev && 뜸중 && !(잴길이 > 0)) {
|
|
159
|
+
devWarnOnce('progress-ticker-pad-without-duration', '[ihab-core] 뜸이 도는데 **소리 길이를 모릅니다** — 재생바가 뜸만으로 어림잡아 움직입니다.', '소리 길이 재기(measureAudioDuration)가 0 을 줬거나 `<audio>` 가 아직 안 실렸습니다. 길이를 알게 되면 마커가 그 순간 **뒤로 물러납니다**(어림은 늘 참보다 큽니다). 이 줄이 보이면 그 자리를 알려 주십시오 — 평소 길에서는 안 나와야 합니다.');
|
|
160
|
+
}
|
|
112
161
|
const pos = 뜸있다
|
|
113
162
|
? progressWithPad({
|
|
114
163
|
phase: sequencer?.positionRef.current?.phase ?? 'sound',
|
|
115
164
|
phaseElapsedMs: sequencer?.positionRef.current ? Math.max(0, Date.now() - sequencer.positionRef.current.at) : 0,
|
|
116
165
|
currentTime: a && typeof a.currentTime === 'number' ? a.currentTime : 0,
|
|
117
|
-
trackDurationSec:
|
|
166
|
+
trackDurationSec: 잴길이,
|
|
118
167
|
leadInMs: leadInMs || 0,
|
|
119
168
|
tailMs: tailMs || 0,
|
|
120
169
|
})
|
|
@@ -122,7 +171,9 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
|
|
|
122
171
|
currentTime: a && typeof a.currentTime === 'number' ? a.currentTime : 0,
|
|
123
172
|
sources: sourcesRef.current,
|
|
124
173
|
index: currentIndexRef.current,
|
|
125
|
-
|
|
174
|
+
/* 여기 올 때 `총길이` 는 널이 아니다 — 널이면 위에서 숨기고 왔다(뜸이 없으면 뜸중도 거짓이다).
|
|
175
|
+
타입만 좁혀 준다. */
|
|
176
|
+
audioDuration: 총길이 ?? 0,
|
|
126
177
|
});
|
|
127
178
|
try {
|
|
128
179
|
if (fill) {
|
|
@@ -24,6 +24,7 @@ import 말귀로고 from '../logo/Logo_speechear_kr.svg';
|
|
|
24
24
|
import { WORD_TABLE_PAPER, gridFor, CUT_CLASS, POS_LABEL, WORD_TYPE_LABEL, expandSlashLabel, dotDashStyle, REF_W_BOTH, REF_W_TEXT_ONLY, REF_W_IMAGE_ONLY, REF_H_BOTH, REF_H_TEXT_ONLY, REF_H_IMAGE_ONLY, } from './wordTableLayout';
|
|
25
25
|
import { useFitScale } from './useFitScale';
|
|
26
26
|
import { claimSound } from './Training/currentSound';
|
|
27
|
+
import { playOneShot } from './Training/oneShotSound';
|
|
27
28
|
// 외부(배럴·TodayPage·WordTableBlock 등) import 호환을 위해 재export
|
|
28
29
|
/* 순수 함수(printWordTable·chunkItems)는 여기서 재수출하지 않는다 — 컴포넌트 파일이
|
|
29
30
|
컴포넌트 아닌 것을 내보내면 Fast Refresh 가 깨져, 이 파일을 고칠 때마다 화면 상태가
|
|
@@ -105,7 +106,7 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
|
|
|
105
106
|
/* 자리만 비운다(내가 아직 주인일 때만) — 멈춤 함수를 부르면 사라진 화면의 상태를 건드린다 */
|
|
106
107
|
releaseRef.current?.();
|
|
107
108
|
}, []);
|
|
108
|
-
const handlePlay = useCallback(
|
|
109
|
+
const handlePlay = useCallback((e) => {
|
|
109
110
|
e.stopPropagation();
|
|
110
111
|
if (playing) {
|
|
111
112
|
stopRef.current?.();
|
|
@@ -114,24 +115,18 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
|
|
|
114
115
|
if (!audioUrl)
|
|
115
116
|
return;
|
|
116
117
|
setPlaying(true);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
await audio.play();
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
setPlaying(false);
|
|
133
|
-
releaseRef.current?.();
|
|
134
|
-
} // 재생 거절(자동재생 막힘 등) — 울리지 않으니 자리만 비운다
|
|
118
|
+
/* 만들어 거는 일은 `playOneShot` 하나가 한다((85) 3단계) — 전에는 여기서 요소를 직접
|
|
119
|
+
만들었고, 끝을 한 번만 처리하는 가드가 **없었다.** 해롭지 않았던 것은 끝 처리가
|
|
120
|
+
마침 두 번 불려도 빈손인 꼴이어서였고, 그것은 운이지 설계가 아니다.
|
|
121
|
+
재생 거절(자동재생 막힘 등)도 `done` 이 거짓으로 풀려 같은 길로 간다. */
|
|
122
|
+
/* 끝나거나 멈추면 **그 자리에서** 자리를 비운다 — 약속으로 받으면 한 틱 늦어, 남이
|
|
123
|
+
자물쇠를 가져간 순간에 이 칸이 아직 "재생 중" 으로 보인다.
|
|
124
|
+
이미 다음 사람이 가져갔으면 release 는 아무것도 안 한다. */
|
|
125
|
+
const 소리 = playOneShot(audioUrl, () => { setPlaying(false); releaseRef.current?.(); });
|
|
126
|
+
audioRef.current = 소리.el;
|
|
127
|
+
stopRef.current = 소리.stop;
|
|
128
|
+
/* 자물쇠를 잡으면 앞서 울리던 것(다른 칸·훈련 카드 등)이 멈추고, 남이 잡으면 stop 이 불려 내가 멈춘다 */
|
|
129
|
+
releaseRef.current = claimSound(소리.stop);
|
|
135
130
|
}, [playing, audioUrl]);
|
|
136
131
|
const hasImage = showImage && !!imageUrl && !imgFailed;
|
|
137
132
|
const refW = hasImage && showWord ? REF_W_BOTH : hasImage ? REF_W_IMAGE_ONLY : REF_W_TEXT_ONLY;
|