@ihabdevteam/core 0.125.0 → 0.126.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 +25 -0
- package/dist/components.cjs.js +177 -177
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +181 -181
- package/dist/ihabdevteam-core.d.ts +35 -10
- package/dist/packages/core/src/components/AudioMeter.js +13 -5
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +13 -8
- package/dist/packages/core/src/components/Training/useNoiseFade.js +15 -11
- package/dist/packages/core/src/utils/audioMeter.d.ts +31 -9
- package/dist/packages/core/src/utils/audioMeter.js +135 -23
- package/dist/packages/core/src/utils.d.ts +1 -1
- package/dist/packages/core/src/utils.js +1 -1
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +1 -1
|
@@ -1341,7 +1341,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1341
1341
|
*
|
|
1342
1342
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1343
1343
|
*/
|
|
1344
|
-
export declare const CORE_VERSION = "0.
|
|
1344
|
+
export declare const CORE_VERSION = "0.126.0";
|
|
1345
1345
|
|
|
1346
1346
|
/** @public */
|
|
1347
1347
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -2223,11 +2223,15 @@ export declare function getDailySelection(): {
|
|
|
2223
2223
|
};
|
|
2224
2224
|
|
|
2225
2225
|
/**
|
|
2226
|
-
* 엘리먼트에 연결된 AnalyserNode를 반환(
|
|
2227
|
-
*
|
|
2226
|
+
* 엘리먼트에 연결된 AnalyserNode 를 반환(달리는 컨텍스트면 지금 태워서).
|
|
2227
|
+
* 컨텍스트가 suspended 면 **지금은 null** 을 주고 resume 뒤 태우기를 걸어 둔다 — 나중 값이 필요하면
|
|
2228
|
+
* `routeElementAudio` 를 기다린다. 실패 시 null(미지원/이미 다른 그래프에 소스됨 등).
|
|
2228
2229
|
*/
|
|
2229
2230
|
export declare function getElementAnalyser(el: HTMLMediaElement | null | undefined): AnalyserNode | null;
|
|
2230
2231
|
|
|
2232
|
+
/** @public 요소에 걸려고 한 볼륨(0~1) — 램프 출발점. 건 적이 없으면 요소 volume. */
|
|
2233
|
+
export declare function getElementVolume(el: HTMLMediaElement): number;
|
|
2234
|
+
|
|
2231
2235
|
/**
|
|
2232
2236
|
* 잡은 값에서 **사람이 읽을 수 있는 원인**을 꺼낸다.
|
|
2233
2237
|
*
|
|
@@ -4654,14 +4658,20 @@ export declare function resolveTrainingPopupMeta(trainingData: Record<string, un
|
|
|
4654
4658
|
};
|
|
4655
4659
|
|
|
4656
4660
|
/**
|
|
4657
|
-
* 오디오 레벨
|
|
4661
|
+
* 오디오 레벨 미터 + 볼륨용 Web Audio 유틸.
|
|
4658
4662
|
*
|
|
4659
|
-
* HTMLMediaElement를
|
|
4660
|
-
* - createMediaElementSource는 엘리먼트당 1회만 가능 → WeakMap으로 캐싱.
|
|
4661
|
-
* -
|
|
4662
|
-
*
|
|
4663
|
-
*
|
|
4664
|
-
*
|
|
4663
|
+
* HTMLMediaElement 를 한 그래프에 태운다: `source → GainNode → (AnalyserNode, destination)`.
|
|
4664
|
+
* - createMediaElementSource 는 엘리먼트당 1회만 가능 → WeakMap 으로 캐싱.
|
|
4665
|
+
* - **볼륨은 GainNode 로 건다**(0.126.0). 실제 iPhone·iPad 에서는 `HTMLMediaElement.volume` 을
|
|
4666
|
+
* 스크립트로 바꿔도 소리에 안 먹는다(Apple 문서 — 늘 1). 그래프에 태운 요소는 volume 을 1 로
|
|
4667
|
+
* 두고 `gain` 으로 크기를 정하므로 기기와 상관없이 먹는다. 미터(analyser)는 gain 뒤라 들리는
|
|
4668
|
+
* 크기를 읽는다(요소 volume 이 소스 출력에 걸리던 전과 같은 결).
|
|
4669
|
+
* - **달리는(running) AudioContext 에만 태운다.** 태운 순간부터 요소 소리는 destination 연결로만
|
|
4670
|
+
* 나가므로, suspended(자동재생 정책) 컨텍스트에 태우면 resume 전까지 무음이다. 그래서 suspended
|
|
4671
|
+
* 이면 resume 이 끝난 뒤에 태우고, 그 전에는 요소가 제 볼륨으로 직접 울린다.
|
|
4672
|
+
* - Web Audio 가 없거나 태우기에 실패하면(미지원·이미 다른 그래프에 소스됨 등) 요소 volume 으로 물러선다.
|
|
4673
|
+
* - cross-origin 소스는 엘리먼트에 crossOrigin="anonymous" 가 있고 서버가 CORS 를 허용해야 그래프
|
|
4674
|
+
* 출력이 무음이 되지 않는다(코어의 문장·소음 요소는 둘 다 붙인다).
|
|
4665
4675
|
*/
|
|
4666
4676
|
/** 미터 AudioContext를 사용자 제스처 이후 resume(자동재생 정책 대응). */
|
|
4667
4677
|
export declare function resumeAudioMeter(): void;
|
|
@@ -4794,6 +4804,14 @@ export declare interface RingChartSegment {
|
|
|
4794
4804
|
/** 시맨틱 사이즈 → 지름(px)·링 두께(px) 프리셋 */
|
|
4795
4805
|
export declare type RingChartSize = 'small' | 'medium' | 'large';
|
|
4796
4806
|
|
|
4807
|
+
/**
|
|
4808
|
+
* 엘리먼트를 그래프에 태우고 AnalyserNode 를 준다(이미 태웠으면 그것).
|
|
4809
|
+
* 컨텍스트가 suspended 면 resume 을 기다린 뒤 태운다 — resume 이 안 되면(제스처 없음) null 이고
|
|
4810
|
+
* 요소는 제 볼륨으로 계속 울린다. Web Audio 미지원·실패도 null.
|
|
4811
|
+
* @public
|
|
4812
|
+
*/
|
|
4813
|
+
export declare function routeElementAudio(el: HTMLMediaElement | null | undefined): Promise<AnalyserNode | null>;
|
|
4814
|
+
|
|
4797
4815
|
/**
|
|
4798
4816
|
* @public 배열에서 무작위로 최대 n개를 뽑아 새 배열로 반환(원본 불변).
|
|
4799
4817
|
* n이 길이 이상이면 전체를 셔플해 반환한다.
|
|
@@ -5097,6 +5115,13 @@ export declare function setAssetBaseUrl(baseUrl?: string | null): void;
|
|
|
5097
5115
|
/** @public */
|
|
5098
5116
|
export declare function setCrosswordLogger(fn: CrosswordLogger | null): void;
|
|
5099
5117
|
|
|
5118
|
+
/**
|
|
5119
|
+
* @public 요소 볼륨(0~1)을 건다. 그래프에 태운 요소면 요소 volume 은 1 로 두고 GainNode 에
|
|
5120
|
+
* (짧은 시정수로 매끄럽게) 걸고 'gain', 아니면 요소 volume 에 걸고 'element'. 부르는 쪽이 매 프레임
|
|
5121
|
+
* 불러 램프를 만들어도 된다.
|
|
5122
|
+
*/
|
|
5123
|
+
export declare function setElementVolume(el: HTMLMediaElement, volume: number): 'gain' | 'element';
|
|
5124
|
+
|
|
5100
5125
|
/** @public */
|
|
5101
5126
|
export declare function setGameLeaderboardLoader(fn: LeaderboardFn): void;
|
|
5102
5127
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { cx } from '../utils/cx';
|
|
4
|
-
import { useEffect, useRef } from 'react';
|
|
5
|
-
import { getElementAnalyser, resumeAudioMeter, readLevel } from '../utils/audioMeter';
|
|
4
|
+
import { useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { getElementAnalyser, resumeAudioMeter, readLevel, routeElementAudio } from '../utils/audioMeter';
|
|
6
6
|
import './AudioMeter.css';
|
|
7
7
|
// 레벨 표시 평활화(빠른 어택 / 느린 릴리스)
|
|
8
8
|
const RELEASE = 0.86;
|
|
@@ -27,14 +27,22 @@ export default function AudioMeter({ analyser, audioElement, orientation = 'hori
|
|
|
27
27
|
const fillRef = useRef(null);
|
|
28
28
|
const valueRef = useRef(null);
|
|
29
29
|
const rafRef = useRef(null);
|
|
30
|
+
/* 컨텍스트가 아직 suspended 면 getElementAnalyser 는 지금 null 을 준다(달리는 컨텍스트에만 태운다 —
|
|
31
|
+
audioMeter 머리말). resume 뒤 태워지면 그 노드로 다시 돈다. */
|
|
32
|
+
const [routed, setRouted] = useState(null);
|
|
30
33
|
useEffect(() => {
|
|
31
|
-
const node = analyser ?? getElementAnalyser(audioElement);
|
|
34
|
+
const node = analyser ?? getElementAnalyser(audioElement) ?? (routed && routed.el === audioElement ? routed.node : null);
|
|
32
35
|
const fill = fillRef.current;
|
|
33
36
|
if (!node || !fill) {
|
|
34
37
|
fill?.style.setProperty('--meter-level', '0');
|
|
35
38
|
if (valueRef.current)
|
|
36
39
|
valueRef.current.textContent = formatRelDb(floorDb, refDb, displayMin, displayMax);
|
|
37
|
-
|
|
40
|
+
let cancelled = false;
|
|
41
|
+
if (!analyser && audioElement) {
|
|
42
|
+
void routeElementAudio(audioElement).then((n) => { if (!cancelled && n)
|
|
43
|
+
setRouted({ el: audioElement, node: n }); });
|
|
44
|
+
}
|
|
45
|
+
return () => { cancelled = true; };
|
|
38
46
|
}
|
|
39
47
|
resumeAudioMeter();
|
|
40
48
|
const buffer = new Float32Array(node.fftSize);
|
|
@@ -78,7 +86,7 @@ export default function AudioMeter({ analyser, audioElement, orientation = 'hori
|
|
|
78
86
|
cancelAnimationFrame(rafRef.current);
|
|
79
87
|
rafRef.current = null;
|
|
80
88
|
};
|
|
81
|
-
}, [analyser, audioElement, floorDb, refDb, displayMin, displayMax]);
|
|
89
|
+
}, [analyser, audioElement, routed, floorDb, refDb, displayMin, displayMax]);
|
|
82
90
|
const lengthCss = typeof length === 'number' ? `${length}px` : length;
|
|
83
91
|
const isVertical = orientation === 'vertical';
|
|
84
92
|
const trackStyle = lengthCss
|
|
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
|
|
6
6
|
// TrainingItem, TrainingGroup import 제거됨 (resolveMapping 제거로 불필요)
|
|
7
7
|
import { downloadSound, getSignedOrPublicUrl, resolveSoundPath } from '../../utils/storage';
|
|
8
8
|
import { getTTSUrlResolver } from '../../utils/ttsConfig';
|
|
9
|
-
import {
|
|
9
|
+
import { resumeAudioMeter, routeElementAudio, setElementVolume, getElementVolume } from '../../utils/audioMeter';
|
|
10
10
|
import { useNoisePlayer, effectiveNoiseVolume, effectiveSpeechVolume, noiseRatioOf } from './NoisePlayerContext';
|
|
11
11
|
import { useNoiseFade } from './useNoiseFade';
|
|
12
12
|
import { useTTSPlayback } from './useTTSPlayback';
|
|
@@ -204,10 +204,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
204
204
|
setStateLocal("playMode");
|
|
205
205
|
set구간표시(true);
|
|
206
206
|
startTicker();
|
|
207
|
-
//
|
|
207
|
+
// 문장을 Web Audio 그래프(gain·미터)에 태운다 — 재생 제스처 시점에 resume, 컨텍스트가 달리고 나서야
|
|
208
|
+
// 태우므로 그 전엔 요소가 제 볼륨으로 직접 울린다(무음 방지). 태운 뒤 볼륨은 GainNode 로 걸린다.
|
|
208
209
|
try {
|
|
209
210
|
resumeAudioMeter();
|
|
210
|
-
|
|
211
|
+
void routeElementAudio(a).then((an) => { try {
|
|
212
|
+
registerAnalyserRef.current?.('main', an);
|
|
213
|
+
}
|
|
214
|
+
catch (e) { } });
|
|
211
215
|
}
|
|
212
216
|
catch (e) { }
|
|
213
217
|
// 소음 동기 시작
|
|
@@ -950,7 +954,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
950
954
|
}
|
|
951
955
|
catch (e) { }
|
|
952
956
|
}, [playbackRate]);
|
|
953
|
-
// 문장(메인) 볼륨 반영 — 문제 소리 볼륨(effectiveSpeechVolume = 전체 소리).
|
|
957
|
+
// 문장(메인) 볼륨 반영 — 문제 소리 볼륨(effectiveSpeechVolume = 전체 소리). setElementVolume 이 그래프에 태운
|
|
958
|
+
// 요소면 GainNode, 아니면 요소 volume 에 건다(iOS 실기기는 요소 volume 이 안 먹는다). 재생 중이면 소음과 같은 150ms 램프로 옮기고, 멈춰 있으면 바로 건다.
|
|
954
959
|
// 백그라운드 탭에서는 RAF 가 멈춰 램프가 중간에 설 수 있으므로 끝값을 타이머로 한 번 더 박는다.
|
|
955
960
|
const speechRampRef = useRef({ raf: null, timer: null });
|
|
956
961
|
useEffect(() => {
|
|
@@ -970,10 +975,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
970
975
|
}
|
|
971
976
|
};
|
|
972
977
|
끊기();
|
|
973
|
-
const from = a
|
|
978
|
+
const from = getElementVolume(a);
|
|
974
979
|
if (a.paused || Math.abs(from - target) < 0.001 || typeof requestAnimationFrame !== 'function') {
|
|
975
980
|
try {
|
|
976
|
-
a
|
|
981
|
+
setElementVolume(a, target);
|
|
977
982
|
}
|
|
978
983
|
catch (e) { }
|
|
979
984
|
return;
|
|
@@ -982,14 +987,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
982
987
|
const step = () => {
|
|
983
988
|
const t = Math.min(1, (performance.now() - start) / 150);
|
|
984
989
|
try {
|
|
985
|
-
a
|
|
990
|
+
setElementVolume(a, from + (target - from) * t);
|
|
986
991
|
}
|
|
987
992
|
catch (e) { }
|
|
988
993
|
ramp.raf = t < 1 ? requestAnimationFrame(step) : null;
|
|
989
994
|
};
|
|
990
995
|
ramp.raf = requestAnimationFrame(step);
|
|
991
996
|
ramp.timer = setTimeout(() => { 끊기(); try {
|
|
992
|
-
a
|
|
997
|
+
setElementVolume(a, target);
|
|
993
998
|
}
|
|
994
999
|
catch (e) { } }, 200);
|
|
995
1000
|
return 끊기;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { noiseAssets } from '../../sounds/noiseAssets';
|
|
4
|
-
import {
|
|
4
|
+
import { resumeAudioMeter, routeElementAudio, setElementVolume, getElementVolume } from '../../utils/audioMeter';
|
|
5
5
|
import { noiseElementVolume } from './NoisePlayerContext';
|
|
6
6
|
function generateNoiseUrl(type) {
|
|
7
7
|
const rand = Math.floor(Math.random() * 10);
|
|
@@ -47,24 +47,28 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
47
47
|
return;
|
|
48
48
|
noiseFadeTokenRef.current += 1;
|
|
49
49
|
cancelNoiseFade();
|
|
50
|
-
//
|
|
50
|
+
// 소음을 Web Audio 그래프(gain·미터)에 태운다 — 컨텍스트가 달리고 나서야 태우므로 그 전엔 요소가 직접 울린다.
|
|
51
|
+
// 볼륨은 setElementVolume 이 그래프면 GainNode, 아니면 요소 volume 에 건다(iOS 실기기는 요소 volume 이 안 먹는다).
|
|
51
52
|
try {
|
|
52
53
|
resumeAudioMeter();
|
|
53
|
-
|
|
54
|
+
void routeElementAudio(noiseEl).then((an) => { try {
|
|
55
|
+
registerAnalyserRef.current?.('noise', an);
|
|
56
|
+
}
|
|
57
|
+
catch (e) { } });
|
|
54
58
|
}
|
|
55
59
|
catch (e) { }
|
|
56
60
|
// 이미 재생 중이면(멀티소스 연달아 재생 — 단어1→단어2 전환마다 onPlay가 다시 호출됨)
|
|
57
61
|
// 볼륨을 0으로 리셋하지 않고 현재 볼륨에서 목표까지 부드럽게 이어간다 — 트랙 전환 때마다
|
|
58
62
|
// 소음이 뚝 끊겼다 다시 차오르던 문제 방지. 이미 목표 볼륨이면 램프가 사실상 no-op이다.
|
|
59
|
-
const startVol = noiseEl.paused ? 0 : noiseEl
|
|
63
|
+
const startVol = noiseEl.paused ? 0 : getElementVolume(noiseEl);
|
|
60
64
|
if (noiseEl.paused)
|
|
61
|
-
noiseEl
|
|
65
|
+
setElementVolume(noiseEl, 0);
|
|
62
66
|
noiseEl.play().catch(() => { });
|
|
63
67
|
const startTime = performance.now();
|
|
64
68
|
const step = () => {
|
|
65
69
|
const t = Math.min(1, (performance.now() - startTime) / durationMs);
|
|
66
70
|
try {
|
|
67
|
-
noiseEl
|
|
71
|
+
setElementVolume(noiseEl, startVol + (targetVolume - startVol) * t);
|
|
68
72
|
}
|
|
69
73
|
catch (e) { }
|
|
70
74
|
if (t < 1) {
|
|
@@ -85,11 +89,11 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
85
89
|
const token = ++noiseFadeTokenRef.current;
|
|
86
90
|
cancelNoiseFade();
|
|
87
91
|
const startTime = performance.now();
|
|
88
|
-
const startVol = noiseEl
|
|
92
|
+
const startVol = getElementVolume(noiseEl);
|
|
89
93
|
const step = () => {
|
|
90
94
|
const t = Math.min(1, (performance.now() - startTime) / durationMs);
|
|
91
95
|
try {
|
|
92
|
-
noiseEl
|
|
96
|
+
setElementVolume(noiseEl, startVol * (1 - t));
|
|
93
97
|
}
|
|
94
98
|
catch (e) { }
|
|
95
99
|
if (t < 1) {
|
|
@@ -123,7 +127,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
123
127
|
}
|
|
124
128
|
try {
|
|
125
129
|
if (!noiseEl.paused) {
|
|
126
|
-
noiseEl
|
|
130
|
+
setElementVolume(noiseEl, 0);
|
|
127
131
|
noiseEl.pause();
|
|
128
132
|
onComplete?.();
|
|
129
133
|
}
|
|
@@ -267,12 +271,12 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
267
271
|
else {
|
|
268
272
|
// 이미 재생 중 — 볼륨만 부드럽게 조절
|
|
269
273
|
cancelNoiseFade();
|
|
270
|
-
const startVol = noiseEl
|
|
274
|
+
const startVol = getElementVolume(noiseEl);
|
|
271
275
|
const startTime = performance.now();
|
|
272
276
|
const ramp = () => {
|
|
273
277
|
const t = Math.min(1, (performance.now() - startTime) / 150);
|
|
274
278
|
try {
|
|
275
|
-
noiseEl
|
|
279
|
+
setElementVolume(noiseEl, startVol + (volume - startVol) * t);
|
|
276
280
|
}
|
|
277
281
|
catch (e) { }
|
|
278
282
|
if (t < 1)
|
|
@@ -1,19 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 오디오 레벨
|
|
2
|
+
* 오디오 레벨 미터 + 볼륨용 Web Audio 유틸.
|
|
3
3
|
*
|
|
4
|
-
* HTMLMediaElement를
|
|
5
|
-
* - createMediaElementSource는 엘리먼트당 1회만 가능 → WeakMap으로 캐싱.
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* HTMLMediaElement 를 한 그래프에 태운다: `source → GainNode → (AnalyserNode, destination)`.
|
|
5
|
+
* - createMediaElementSource 는 엘리먼트당 1회만 가능 → WeakMap 으로 캐싱.
|
|
6
|
+
* - **볼륨은 GainNode 로 건다**(0.126.0). 실제 iPhone·iPad 에서는 `HTMLMediaElement.volume` 을
|
|
7
|
+
* 스크립트로 바꿔도 소리에 안 먹는다(Apple 문서 — 늘 1). 그래프에 태운 요소는 volume 을 1 로
|
|
8
|
+
* 두고 `gain` 으로 크기를 정하므로 기기와 상관없이 먹는다. 미터(analyser)는 gain 뒤라 들리는
|
|
9
|
+
* 크기를 읽는다(요소 volume 이 소스 출력에 걸리던 전과 같은 결).
|
|
10
|
+
* - **달리는(running) AudioContext 에만 태운다.** 태운 순간부터 요소 소리는 destination 연결로만
|
|
11
|
+
* 나가므로, suspended(자동재생 정책) 컨텍스트에 태우면 resume 전까지 무음이다. 그래서 suspended
|
|
12
|
+
* 이면 resume 이 끝난 뒤에 태우고, 그 전에는 요소가 제 볼륨으로 직접 울린다.
|
|
13
|
+
* - Web Audio 가 없거나 태우기에 실패하면(미지원·이미 다른 그래프에 소스됨 등) 요소 volume 으로 물러선다.
|
|
14
|
+
* - cross-origin 소스는 엘리먼트에 crossOrigin="anonymous" 가 있고 서버가 CORS 를 허용해야 그래프
|
|
15
|
+
* 출력이 무음이 되지 않는다(코어의 문장·소음 요소는 둘 다 붙인다).
|
|
10
16
|
*/
|
|
11
17
|
/** 미터 AudioContext를 사용자 제스처 이후 resume(자동재생 정책 대응). */
|
|
12
18
|
export declare function resumeAudioMeter(): void;
|
|
13
19
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
20
|
+
* 엘리먼트를 그래프에 태우고 AnalyserNode 를 준다(이미 태웠으면 그것).
|
|
21
|
+
* 컨텍스트가 suspended 면 resume 을 기다린 뒤 태운다 — resume 이 안 되면(제스처 없음) null 이고
|
|
22
|
+
* 요소는 제 볼륨으로 계속 울린다. Web Audio 미지원·실패도 null.
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
export declare function routeElementAudio(el: HTMLMediaElement | null | undefined): Promise<AnalyserNode | null>;
|
|
26
|
+
/**
|
|
27
|
+
* 엘리먼트에 연결된 AnalyserNode 를 반환(달리는 컨텍스트면 지금 태워서).
|
|
28
|
+
* 컨텍스트가 suspended 면 **지금은 null** 을 주고 resume 뒤 태우기를 걸어 둔다 — 나중 값이 필요하면
|
|
29
|
+
* `routeElementAudio` 를 기다린다. 실패 시 null(미지원/이미 다른 그래프에 소스됨 등).
|
|
16
30
|
*/
|
|
17
31
|
export declare function getElementAnalyser(el: HTMLMediaElement | null | undefined): AnalyserNode | null;
|
|
32
|
+
/**
|
|
33
|
+
* @public 요소 볼륨(0~1)을 건다. 그래프에 태운 요소면 요소 volume 은 1 로 두고 GainNode 에
|
|
34
|
+
* (짧은 시정수로 매끄럽게) 걸고 'gain', 아니면 요소 volume 에 걸고 'element'. 부르는 쪽이 매 프레임
|
|
35
|
+
* 불러 램프를 만들어도 된다.
|
|
36
|
+
*/
|
|
37
|
+
export declare function setElementVolume(el: HTMLMediaElement, volume: number): 'gain' | 'element';
|
|
38
|
+
/** @public 요소에 걸려고 한 볼륨(0~1) — 램프 출발점. 건 적이 없으면 요소 volume. */
|
|
39
|
+
export declare function getElementVolume(el: HTMLMediaElement): number;
|
|
18
40
|
/** AnalyserNode의 현재 레벨(RMS, 0~1). */
|
|
19
41
|
export declare function readLevel(analyser: AnalyserNode, buffer: Float32Array<ArrayBuffer>): number;
|
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 오디오 레벨
|
|
2
|
+
* 오디오 레벨 미터 + 볼륨용 Web Audio 유틸.
|
|
3
3
|
*
|
|
4
|
-
* HTMLMediaElement를
|
|
5
|
-
* - createMediaElementSource는 엘리먼트당 1회만 가능 → WeakMap으로 캐싱.
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* HTMLMediaElement 를 한 그래프에 태운다: `source → GainNode → (AnalyserNode, destination)`.
|
|
5
|
+
* - createMediaElementSource 는 엘리먼트당 1회만 가능 → WeakMap 으로 캐싱.
|
|
6
|
+
* - **볼륨은 GainNode 로 건다**(0.126.0). 실제 iPhone·iPad 에서는 `HTMLMediaElement.volume` 을
|
|
7
|
+
* 스크립트로 바꿔도 소리에 안 먹는다(Apple 문서 — 늘 1). 그래프에 태운 요소는 volume 을 1 로
|
|
8
|
+
* 두고 `gain` 으로 크기를 정하므로 기기와 상관없이 먹는다. 미터(analyser)는 gain 뒤라 들리는
|
|
9
|
+
* 크기를 읽는다(요소 volume 이 소스 출력에 걸리던 전과 같은 결).
|
|
10
|
+
* - **달리는(running) AudioContext 에만 태운다.** 태운 순간부터 요소 소리는 destination 연결로만
|
|
11
|
+
* 나가므로, suspended(자동재생 정책) 컨텍스트에 태우면 resume 전까지 무음이다. 그래서 suspended
|
|
12
|
+
* 이면 resume 이 끝난 뒤에 태우고, 그 전에는 요소가 제 볼륨으로 직접 울린다.
|
|
13
|
+
* - Web Audio 가 없거나 태우기에 실패하면(미지원·이미 다른 그래프에 소스됨 등) 요소 volume 으로 물러선다.
|
|
14
|
+
* - cross-origin 소스는 엘리먼트에 crossOrigin="anonymous" 가 있고 서버가 CORS 를 허용해야 그래프
|
|
15
|
+
* 출력이 무음이 되지 않는다(코어의 문장·소음 요소는 둘 다 붙인다).
|
|
10
16
|
*/
|
|
11
17
|
let _ctx = null;
|
|
12
|
-
const
|
|
18
|
+
const _graphs = new WeakMap();
|
|
19
|
+
/** 요소에 걸려고 한 볼륨(0~1) — 그래프에 태울 때 gain 초깃값, 읽을 때 기준값 */
|
|
20
|
+
const _desired = new WeakMap();
|
|
21
|
+
const _pending = new WeakMap();
|
|
22
|
+
let _unlockInstalled = false;
|
|
23
|
+
const 자르기 = (v) => Math.max(0, Math.min(1, Number.isFinite(v) ? v : 0));
|
|
24
|
+
/** 첫 사용자 제스처(및 이후 제스처)마다 멈춘 컨텍스트를 깨운다 — iOS 는 제스처 안에서만 resume 이 먹는다 */
|
|
25
|
+
function installGestureUnlock() {
|
|
26
|
+
if (_unlockInstalled || typeof document === 'undefined')
|
|
27
|
+
return;
|
|
28
|
+
_unlockInstalled = true;
|
|
29
|
+
const 깨우기 = () => { try {
|
|
30
|
+
if (_ctx && _ctx.state !== 'running')
|
|
31
|
+
void _ctx.resume();
|
|
32
|
+
}
|
|
33
|
+
catch { /* noop */ } };
|
|
34
|
+
for (const type of ['pointerdown', 'touchend', 'keydown']) {
|
|
35
|
+
document.addEventListener(type, 깨우기, { capture: true, passive: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
13
38
|
function getContext() {
|
|
14
39
|
if (_ctx)
|
|
15
40
|
return _ctx;
|
|
@@ -18,6 +43,7 @@ function getContext() {
|
|
|
18
43
|
if (!Ctor)
|
|
19
44
|
return null;
|
|
20
45
|
_ctx = new Ctor();
|
|
46
|
+
installGestureUnlock();
|
|
21
47
|
return _ctx;
|
|
22
48
|
}
|
|
23
49
|
catch {
|
|
@@ -31,33 +57,119 @@ export function resumeAudioMeter() {
|
|
|
31
57
|
}
|
|
32
58
|
catch { /* noop */ }
|
|
33
59
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
* 실패 시 null(미지원/이미 다른 그래프에 소스됨 등).
|
|
37
|
-
*/
|
|
38
|
-
export function getElementAnalyser(el) {
|
|
39
|
-
if (!el)
|
|
40
|
-
return null;
|
|
41
|
-
const cached = _analysers.get(el);
|
|
60
|
+
function build(ctx, el) {
|
|
61
|
+
const cached = _graphs.get(el);
|
|
42
62
|
if (cached)
|
|
43
63
|
return cached;
|
|
44
|
-
const ctx = getContext();
|
|
45
|
-
if (!ctx)
|
|
46
|
-
return null;
|
|
47
64
|
try {
|
|
48
65
|
const source = ctx.createMediaElementSource(el);
|
|
66
|
+
const gain = ctx.createGain();
|
|
49
67
|
const analyser = ctx.createAnalyser();
|
|
50
68
|
analyser.fftSize = 1024;
|
|
51
69
|
analyser.smoothingTimeConstant = 0.6;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
// 태우기 직전의 요소 볼륨을 gain 으로 옮기고 요소는 1 로 — 순서가 바뀌면 한 순간 두 번 곱해진다
|
|
71
|
+
gain.gain.value = 자르기(_desired.get(el) ?? el.volume);
|
|
72
|
+
source.connect(gain);
|
|
73
|
+
gain.connect(analyser);
|
|
74
|
+
gain.connect(ctx.destination);
|
|
75
|
+
try {
|
|
76
|
+
el.volume = 1;
|
|
77
|
+
}
|
|
78
|
+
catch { /* noop */ }
|
|
79
|
+
const graph = { gain, analyser };
|
|
80
|
+
_graphs.set(el, graph);
|
|
81
|
+
return graph;
|
|
56
82
|
}
|
|
57
83
|
catch {
|
|
58
84
|
return null;
|
|
59
85
|
}
|
|
60
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* 엘리먼트를 그래프에 태우고 AnalyserNode 를 준다(이미 태웠으면 그것).
|
|
89
|
+
* 컨텍스트가 suspended 면 resume 을 기다린 뒤 태운다 — resume 이 안 되면(제스처 없음) null 이고
|
|
90
|
+
* 요소는 제 볼륨으로 계속 울린다. Web Audio 미지원·실패도 null.
|
|
91
|
+
* @public
|
|
92
|
+
*/
|
|
93
|
+
export function routeElementAudio(el) {
|
|
94
|
+
if (!el)
|
|
95
|
+
return Promise.resolve(null);
|
|
96
|
+
const cached = _graphs.get(el);
|
|
97
|
+
if (cached)
|
|
98
|
+
return Promise.resolve(cached.analyser);
|
|
99
|
+
const ctx = getContext();
|
|
100
|
+
if (!ctx)
|
|
101
|
+
return Promise.resolve(null);
|
|
102
|
+
if (ctx.state === 'running')
|
|
103
|
+
return Promise.resolve(build(ctx, el)?.analyser ?? null);
|
|
104
|
+
const pending = _pending.get(el);
|
|
105
|
+
if (pending)
|
|
106
|
+
return pending;
|
|
107
|
+
const p = Promise.resolve()
|
|
108
|
+
.then(() => ctx.resume())
|
|
109
|
+
.then(() => (ctx.state === 'running' ? build(ctx, el)?.analyser ?? null : null))
|
|
110
|
+
.catch(() => null)
|
|
111
|
+
.finally(() => { _pending.delete(el); });
|
|
112
|
+
_pending.set(el, p);
|
|
113
|
+
return p;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 엘리먼트에 연결된 AnalyserNode 를 반환(달리는 컨텍스트면 지금 태워서).
|
|
117
|
+
* 컨텍스트가 suspended 면 **지금은 null** 을 주고 resume 뒤 태우기를 걸어 둔다 — 나중 값이 필요하면
|
|
118
|
+
* `routeElementAudio` 를 기다린다. 실패 시 null(미지원/이미 다른 그래프에 소스됨 등).
|
|
119
|
+
*/
|
|
120
|
+
export function getElementAnalyser(el) {
|
|
121
|
+
if (!el)
|
|
122
|
+
return null;
|
|
123
|
+
const cached = _graphs.get(el);
|
|
124
|
+
if (cached)
|
|
125
|
+
return cached.analyser;
|
|
126
|
+
const ctx = getContext();
|
|
127
|
+
if (!ctx)
|
|
128
|
+
return null;
|
|
129
|
+
if (ctx.state === 'running')
|
|
130
|
+
return build(ctx, el)?.analyser ?? null;
|
|
131
|
+
void routeElementAudio(el);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* @public 요소 볼륨(0~1)을 건다. 그래프에 태운 요소면 요소 volume 은 1 로 두고 GainNode 에
|
|
136
|
+
* (짧은 시정수로 매끄럽게) 걸고 'gain', 아니면 요소 volume 에 걸고 'element'. 부르는 쪽이 매 프레임
|
|
137
|
+
* 불러 램프를 만들어도 된다.
|
|
138
|
+
*/
|
|
139
|
+
export function setElementVolume(el, volume) {
|
|
140
|
+
const v = 자르기(volume);
|
|
141
|
+
_desired.set(el, v);
|
|
142
|
+
const graph = _graphs.get(el);
|
|
143
|
+
if (graph) {
|
|
144
|
+
try {
|
|
145
|
+
if (el.volume !== 1)
|
|
146
|
+
el.volume = 1;
|
|
147
|
+
}
|
|
148
|
+
catch { /* noop */ }
|
|
149
|
+
const param = graph.gain.gain;
|
|
150
|
+
try {
|
|
151
|
+
const now = graph.gain.context.currentTime;
|
|
152
|
+
param.cancelScheduledValues(now);
|
|
153
|
+
param.setTargetAtTime(v, now, 0.01);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
try {
|
|
157
|
+
param.value = v;
|
|
158
|
+
}
|
|
159
|
+
catch { /* noop */ }
|
|
160
|
+
}
|
|
161
|
+
return 'gain';
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
el.volume = v;
|
|
165
|
+
}
|
|
166
|
+
catch { /* noop */ }
|
|
167
|
+
return 'element';
|
|
168
|
+
}
|
|
169
|
+
/** @public 요소에 걸려고 한 볼륨(0~1) — 램프 출발점. 건 적이 없으면 요소 volume. */
|
|
170
|
+
export function getElementVolume(el) {
|
|
171
|
+
return _desired.get(el) ?? el.volume;
|
|
172
|
+
}
|
|
61
173
|
/** AnalyserNode의 현재 레벨(RMS, 0~1). */
|
|
62
174
|
export function readLevel(analyser, buffer) {
|
|
63
175
|
analyser.getFloatTimeDomainData(buffer);
|
|
@@ -4,7 +4,7 @@ export { configureStorage, downloadSound, fileExists, getSignedOrPublicUrl, reso
|
|
|
4
4
|
export type { StorageResolvers } from './utils/storage';
|
|
5
5
|
export { configureTTS, getTTSUrlResolver, getTTSFallbackUrlResolver, clearAudioExistsCache } from './utils/ttsConfig';
|
|
6
6
|
export { configurePronunciationResolver, getPronunciationResolver } from './utils/pronunciationConfig';
|
|
7
|
-
export { getElementAnalyser, resumeAudioMeter, readLevel } from './utils/audioMeter';
|
|
7
|
+
export { getElementAnalyser, resumeAudioMeter, readLevel, routeElementAudio, setElementVolume, getElementVolume } from './utils/audioMeter';
|
|
8
8
|
export { configureImageResolver, getImageResolver, checkImageExists, getImageExistsCached, prewarmImageCache, bulkSetImageExistence, clearImageExistenceCache, getImageExistenceStats } from './utils/imageConfig';
|
|
9
9
|
export { buildPairIndex, getPairIndex, clearPairIndexCache } from './utils/wordPairIndex';
|
|
10
10
|
export type { PairIndex, PairIndexEntry, PairInput } from './utils/wordPairIndex';
|
|
@@ -3,7 +3,7 @@ export { getAssetBaseUrl, resolveAssetUrl, setAssetBaseUrl } from './utils/asset
|
|
|
3
3
|
export { configureStorage, downloadSound, fileExists, getSignedOrPublicUrl, resolveSoundPath, getNoiseBaseUrl, getNoiseFileGain } from './utils/storage';
|
|
4
4
|
export { configureTTS, getTTSUrlResolver, getTTSFallbackUrlResolver, clearAudioExistsCache } from './utils/ttsConfig';
|
|
5
5
|
export { configurePronunciationResolver, getPronunciationResolver } from './utils/pronunciationConfig';
|
|
6
|
-
export { getElementAnalyser, resumeAudioMeter, readLevel } from './utils/audioMeter';
|
|
6
|
+
export { getElementAnalyser, resumeAudioMeter, readLevel, routeElementAudio, setElementVolume, getElementVolume } from './utils/audioMeter';
|
|
7
7
|
export { configureImageResolver, getImageResolver, checkImageExists, getImageExistsCached, prewarmImageCache, bulkSetImageExistence, clearImageExistenceCache, getImageExistenceStats } from './utils/imageConfig';
|
|
8
8
|
export { buildPairIndex, getPairIndex, clearPairIndexCache } from './utils/wordPairIndex';
|
|
9
9
|
export { minimalPairDistractors, filterContrastCandidates, hasStructureContrast, buildMultiPartPosContrast, hasSel } from './utils/wordContrast';
|