@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.
@@ -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.125.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
- * 실패 시 null(미지원/이미 다른 그래프에 소스됨 등).
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
- * 오디오 레벨 미터용 Web Audio 유틸.
4661
+ * 오디오 레벨 미터 + 볼륨용 Web Audio 유틸.
4658
4662
  *
4659
- * HTMLMediaElement를 AnalyserNode에 연결해 실시간 레벨(RMS)을 읽을 수 있게 한다.
4660
- * - createMediaElementSource는 엘리먼트당 1회만 가능 → WeakMap으로 캐싱.
4661
- * - 분석을 위해 라우팅하면 destination 연결이 끊기면 무음이 되므로,
4662
- * source를 analyser와 destination 양쪽에 연결한다.
4663
- * - cross-origin 소스는 엘리먼트에 crossOrigin="anonymous"가 설정되고
4664
- * 서버가 CORS를 허용해야 분석이 가능하다(아니면 0으로 읽힘, 재생은 정상).
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
- return;
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 { getElementAnalyser, resumeAudioMeter } from '../../utils/audioMeter';
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
- // 미터: 재생 제스처 시점에 context resume 후 메인 analyser 등록(무음 방지)
207
+ // 문장을 Web Audio 그래프(gain·미터)에 태운다 — 재생 제스처 시점에 resume, 컨텍스트가 달리고 나서야
208
+ // 태우므로 그 전엔 요소가 제 볼륨으로 직접 울린다(무음 방지). 태운 뒤 볼륨은 GainNode 로 걸린다.
208
209
  try {
209
210
  resumeAudioMeter();
210
- registerAnalyserRef.current?.('main', getElementAnalyser(a));
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 = 전체 소리). 재생 중이면 소음과 같은 150ms 램프로 옮기고, 멈춰 있으면 바로 건다.
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.volume;
978
+ const from = getElementVolume(a);
974
979
  if (a.paused || Math.abs(from - target) < 0.001 || typeof requestAnimationFrame !== 'function') {
975
980
  try {
976
- a.volume = target;
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.volume = from + (target - from) * t;
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.volume = target;
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 { getElementAnalyser, resumeAudioMeter } from '../../utils/audioMeter';
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
- // 미터: 소음 재생 시점에 context resume 후 소음 analyser 등록
50
+ // 소음을 Web Audio 그래프(gain·미터)에 태운다 — 컨텍스트가 달리고 나서야 태우므로 그 전엔 요소가 직접 울린다.
51
+ // 볼륨은 setElementVolume 이 그래프면 GainNode, 아니면 요소 volume 에 건다(iOS 실기기는 요소 volume 이 안 먹는다).
51
52
  try {
52
53
  resumeAudioMeter();
53
- registerAnalyserRef.current?.('noise', getElementAnalyser(noiseEl));
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.volume;
63
+ const startVol = noiseEl.paused ? 0 : getElementVolume(noiseEl);
60
64
  if (noiseEl.paused)
61
- noiseEl.volume = 0;
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.volume = Math.max(0, Math.min(1, startVol + (targetVolume - startVol) * t));
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.volume;
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.volume = Math.max(0, startVol * (1 - t));
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.volume = 0;
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.volume;
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.volume = startVol + (volume - startVol) * t;
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
- * 오디오 레벨 미터용 Web Audio 유틸.
2
+ * 오디오 레벨 미터 + 볼륨용 Web Audio 유틸.
3
3
  *
4
- * HTMLMediaElement를 AnalyserNode에 연결해 실시간 레벨(RMS)을 읽을 수 있게 한다.
5
- * - createMediaElementSource는 엘리먼트당 1회만 가능 → WeakMap으로 캐싱.
6
- * - 분석을 위해 라우팅하면 destination 연결이 끊기면 무음이 되므로,
7
- * source를 analyser와 destination 양쪽에 연결한다.
8
- * - cross-origin 소스는 엘리먼트에 crossOrigin="anonymous"가 설정되고
9
- * 서버가 CORS를 허용해야 분석이 가능하다(아니면 0으로 읽힘, 재생은 정상).
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
- * 엘리먼트에 연결된 AnalyserNode를 반환(없으면 생성·캐싱).
15
- * 실패 시 null(미지원/이미 다른 그래프에 소스됨 등).
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
- * 오디오 레벨 미터용 Web Audio 유틸.
2
+ * 오디오 레벨 미터 + 볼륨용 Web Audio 유틸.
3
3
  *
4
- * HTMLMediaElement를 AnalyserNode에 연결해 실시간 레벨(RMS)을 읽을 수 있게 한다.
5
- * - createMediaElementSource는 엘리먼트당 1회만 가능 → WeakMap으로 캐싱.
6
- * - 분석을 위해 라우팅하면 destination 연결이 끊기면 무음이 되므로,
7
- * source를 analyser와 destination 양쪽에 연결한다.
8
- * - cross-origin 소스는 엘리먼트에 crossOrigin="anonymous"가 설정되고
9
- * 서버가 CORS를 허용해야 분석이 가능하다(아니면 0으로 읽힘, 재생은 정상).
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 _analysers = new WeakMap();
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
- * 엘리먼트에 연결된 AnalyserNode를 반환(없으면 생성·캐싱).
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
- source.connect(analyser);
53
- source.connect(ctx.destination);
54
- _analysers.set(el, analyser);
55
- return analyser;
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';
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.125.0";
49
+ export declare const CORE_VERSION = "0.126.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.125.0';
49
+ export const CORE_VERSION = '0.126.0';