@ihabdevteam/core 0.289.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 +61 -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/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 +48 -10
- 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) →
|
|
@@ -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
|
}
|
|
@@ -117,15 +148,22 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
|
|
|
117
148
|
**아무것도 안 그렸다.** 뜸은 제 길이를 아는 구간이다(`leadInMs`·`tailMs` 를
|
|
118
149
|
여기서 받고 있다). 그래서 길이를 모르면 **소리를 0 으로 둔 시간표**로 잰다 —
|
|
119
150
|
`timelineProgress` 에 그 사정을 적어 뒀다. 사용자가 (86) 에서 직접 짚은 자리다. */
|
|
120
|
-
|
|
121
|
-
|
|
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>` 가 아직 안 실렸습니다. 길이를 알게 되면 마커가 그 순간 **뒤로 물러납니다**(어림은 늘 참보다 큽니다). 이 줄이 보이면 그 자리를 알려 주십시오 — 평소 길에서는 안 나와야 합니다.');
|
|
122
160
|
}
|
|
123
161
|
const pos = 뜸있다
|
|
124
162
|
? progressWithPad({
|
|
125
163
|
phase: sequencer?.positionRef.current?.phase ?? 'sound',
|
|
126
164
|
phaseElapsedMs: sequencer?.positionRef.current ? Math.max(0, Date.now() - sequencer.positionRef.current.at) : 0,
|
|
127
165
|
currentTime: a && typeof a.currentTime === 'number' ? a.currentTime : 0,
|
|
128
|
-
trackDurationSec:
|
|
166
|
+
trackDurationSec: 잴길이,
|
|
129
167
|
leadInMs: leadInMs || 0,
|
|
130
168
|
tailMs: tailMs || 0,
|
|
131
169
|
})
|
|
@@ -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;
|