@ihabdevteam/core 0.124.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.124.0";
1344
+ export declare const CORE_VERSION = "0.126.0";
1345
1345
 
1346
1346
  /** @public */
1347
1347
  declare const coreDefault: Record<string, unknown>;
@@ -1363,13 +1363,13 @@ export declare type CoreNoiseSettings = {
1363
1363
  level: number;
1364
1364
  /**
1365
1365
  * 소음 비율 — 문제 소리 대비 소음의 배수, 0–2.0(0.1 칸). 1 = 소음과 문제 소리가 같은 크기.
1366
- * 문제 소리 = mainVolume × min(1, 1/noiseRatio), 소음 = mainVolume × min(1, noiseRatio).
1366
+ * 문제 소리 = mainVolume, 소음 들리는 크기 = mainVolume × noiseRatio(원본 소음 파일 기준).
1367
1367
  * 없으면 `level` 에서 옮긴다.
1368
1368
  */
1369
1369
  noiseRatio?: number;
1370
1370
  /** 소음 재생 속도(playbackRate). 0.75–1.25, 기본 1. */
1371
1371
  speed: number;
1372
- /** 전체 소리 — 두 소리 중 큰 쪽의 크기. 0–1, 기본 1. 실제 두 볼륨은 `noiseRatio` 로 나뉜다. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
1372
+ /** 전체 소리 = 문제 소리 볼륨. 0–1, 기본 1. 소음은 이 값 × `noiseRatio`. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
1373
1373
  mainVolume: number;
1374
1374
  /** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
1375
1375
  mainSpeed: number;
@@ -1417,6 +1417,8 @@ export declare type CoreScopeStorageConfig = {
1417
1417
  * @example 'https://cdn.example.com/sounds/noise'
1418
1418
  */
1419
1419
  noiseBaseUrl?: string;
1420
+ /** (선택) 소음 파일이 원본보다 몇 배 큰지(진폭). 기본 1 — configureStorage 의 같은 이름 참고. */
1421
+ noiseFileGain?: number;
1420
1422
  };
1421
1423
 
1422
1424
  /**
@@ -2221,11 +2223,15 @@ export declare function getDailySelection(): {
2221
2223
  };
2222
2224
 
2223
2225
  /**
2224
- * 엘리먼트에 연결된 AnalyserNode를 반환(없으면 생성·캐싱).
2225
- * 실패 시 null(미지원/이미 다른 그래프에 소스됨 등).
2226
+ * 엘리먼트에 연결된 AnalyserNode 를 반환(달리는 컨텍스트면 지금 태워서).
2227
+ * 컨텍스트가 suspended 면 **지금은 null** 을 주고 resume 뒤 태우기를 걸어 둔다 — 나중 값이 필요하면
2228
+ * `routeElementAudio` 를 기다린다. 실패 시 null(미지원/이미 다른 그래프에 소스됨 등).
2226
2229
  */
2227
2230
  export declare function getElementAnalyser(el: HTMLMediaElement | null | undefined): AnalyserNode | null;
2228
2231
 
2232
+ /** @public 요소에 걸려고 한 볼륨(0~1) — 램프 출발점. 건 적이 없으면 요소 volume. */
2233
+ export declare function getElementVolume(el: HTMLMediaElement): number;
2234
+
2229
2235
  /**
2230
2236
  * 잡은 값에서 **사람이 읽을 수 있는 원인**을 꺼낸다.
2231
2237
  *
@@ -2269,6 +2275,12 @@ export declare function getImageResolver(): ((word: string, homonum?: number) =>
2269
2275
  */
2270
2276
  export declare function getNoiseBaseUrl(): string;
2271
2277
 
2278
+ /**
2279
+ * 소음 파일이 원본보다 몇 배 큰지 — configureStorage 의 noiseFileGain. 없거나 양수가 아니면 1.
2280
+ * @public
2281
+ */
2282
+ export declare function getNoiseFileGain(): number;
2283
+
2272
2284
  /** @public 현재 메모리에 적재된 인덱스 (없으면 localStorage 시도) */
2273
2285
  export declare function getPairIndex(): PairIndex | null;
2274
2286
 
@@ -4646,14 +4658,20 @@ export declare function resolveTrainingPopupMeta(trainingData: Record<string, un
4646
4658
  };
4647
4659
 
4648
4660
  /**
4649
- * 오디오 레벨 미터용 Web Audio 유틸.
4661
+ * 오디오 레벨 미터 + 볼륨용 Web Audio 유틸.
4650
4662
  *
4651
- * HTMLMediaElement를 AnalyserNode에 연결해 실시간 레벨(RMS)을 읽을 수 있게 한다.
4652
- * - createMediaElementSource는 엘리먼트당 1회만 가능 → WeakMap으로 캐싱.
4653
- * - 분석을 위해 라우팅하면 destination 연결이 끊기면 무음이 되므로,
4654
- * source를 analyser와 destination 양쪽에 연결한다.
4655
- * - cross-origin 소스는 엘리먼트에 crossOrigin="anonymous"가 설정되고
4656
- * 서버가 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
+ * 출력이 무음이 되지 않는다(코어의 문장·소음 요소는 둘 다 붙인다).
4657
4675
  */
4658
4676
  /** 미터 AudioContext를 사용자 제스처 이후 resume(자동재생 정책 대응). */
4659
4677
  export declare function resumeAudioMeter(): void;
@@ -4786,6 +4804,14 @@ export declare interface RingChartSegment {
4786
4804
  /** 시맨틱 사이즈 → 지름(px)·링 두께(px) 프리셋 */
4787
4805
  export declare type RingChartSize = 'small' | 'medium' | 'large';
4788
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
+
4789
4815
  /**
4790
4816
  * @public 배열에서 무작위로 최대 n개를 뽑아 새 배열로 반환(원본 불변).
4791
4817
  * n이 길이 이상이면 전체를 셔플해 반환한다.
@@ -5089,6 +5115,13 @@ export declare function setAssetBaseUrl(baseUrl?: string | null): void;
5089
5115
  /** @public */
5090
5116
  export declare function setCrosswordLogger(fn: CrosswordLogger | null): void;
5091
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
+
5092
5125
  /** @public */
5093
5126
  export declare function setGameLeaderboardLoader(fn: LeaderboardFn): void;
5094
5127
 
@@ -5650,6 +5683,16 @@ export declare interface StorageResolvers {
5650
5683
  * @example 'https://cdn.example.com/sounds/noise'
5651
5684
  */
5652
5685
  noiseBaseUrl?: string;
5686
+ /**
5687
+ * (선택) `noiseBaseUrl` 의 소음 파일이 **원본 소음보다 몇 배 큰지**(진폭). 기본 1.
5688
+ *
5689
+ * 소음 비율 R 은 원본 파일 기준이라 `<audio>` 볼륨 = min(1, 전체 소리 × R ÷ 이 값) 이다.
5690
+ * 볼륨은 1 을 넘을 수 없으므로 원본 파일로는 1배 넘는 소음이 1 에서 멈춘다 — 2배로 키운 파일과
5691
+ * `noiseFileGain: 2` 를 함께 주면 0~2배가 정확히 들린다.
5692
+ *
5693
+ * @example 2
5694
+ */
5695
+ noiseFileGain?: number;
5653
5696
  }
5654
5697
 
5655
5698
  /** @public */
@@ -20,6 +20,8 @@ export type CoreScopeStorageConfig = {
20
20
  * @example 'https://cdn.example.com/sounds/noise'
21
21
  */
22
22
  noiseBaseUrl?: string;
23
+ /** (선택) 소음 파일이 원본보다 몇 배 큰지(진폭). 기본 1 — configureStorage 의 같은 이름 참고. */
24
+ noiseFileGain?: number;
23
25
  };
24
26
  /** @public */
25
27
  export type CoreScopeProps = {
@@ -11,6 +11,7 @@ export function CoreScope({ as: Component = 'div', className = '', children, sto
11
11
  exists: storage.exists,
12
12
  resolvePath: storage.resolvePath,
13
13
  noiseBaseUrl: storage.noiseBaseUrl,
14
+ noiseFileGain: storage.noiseFileGain,
14
15
  });
15
16
  }
16
17
  const classes = cx('ihabdevteam-core-scope', className);
@@ -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
@@ -21,10 +21,18 @@ export declare function noiseRatioOf(settings: Pick<CoreNoiseSettings, 'level' |
21
21
  type 비율설정 = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
22
22
  noiseRatio?: number;
23
23
  };
24
- /** @public 실제 소음 볼륨(0~1) = mainVolume × min(1, 비율). 켜짐 여부는 보지 않는다. */
25
- export declare function effectiveNoiseVolume(settings: 비율설정): number;
26
- /** @public 실제 문제 소리 볼륨(0~1) = mainVolume × min(1, 1/비율). 비율 0 이면 mainVolume. */
24
+ /** @public 문제 소리 볼륨(0~1) = mainVolume. 비율과 상관없다. */
27
25
  export declare function effectiveSpeechVolume(settings: 비율설정): number;
26
+ /**
27
+ * @public 소음이 **들리는 크기**(0~2, 원본 소음 파일 기준) = mainVolume × 비율. 켜짐 여부는 보지
28
+ * 않는다. 결과 기록·파형이 쓴다. 소음 `<audio>` 에 거는 값은 `noiseElementVolume`.
29
+ */
30
+ export declare function effectiveNoiseVolume(settings: 비율설정): number;
31
+ /**
32
+ * @public 소음 `<audio>` 볼륨(0~1) = min(1, 들리는 크기 ÷ gain). gain 은 소음 파일이 원본보다 몇 배
33
+ * 큰지 — 기본은 `configureStorage({ noiseFileGain })`(없으면 1).
34
+ */
35
+ export declare function noiseElementVolume(settings: 비율설정, gain?: number): number;
28
36
  /**
29
37
  * @public 설정에 조각을 덮는다 — `{ ...base, ...patch }` 에 비율 규칙 하나를 더한 것.
30
38
  *
@@ -32,8 +40,8 @@ export declare function effectiveSpeechVolume(settings: 비율설정): number;
32
40
  * 옛 `noiseOverride`). 그대로 덮으면 base 의 `noiseRatio`(기본값 1 등)가 남아 level 을 가려
33
41
  * 버리므로, 덮은 결과의 level·mainVolume 으로 비율을 셈해 **그 자리에서 박아 둔다** —
34
42
  * 박지 않고 비워 두면 뒤에 전체 소리만 바꿀 때 비율이 따라 변해 소음이 옛날처럼 제자리에 선다.
35
- * 비율이 1 을 넘으면 소음이 큰 쪽이므로 `mainVolume` 도 level/9 로 함께 박는다(옛날에 들리던
36
- * 두 크기 그대로 — 예: level 9·mainVolume 0.5 → 비율 2·mainVolume 1 = 소음 1·문제 0.5).
43
+ * `mainVolume` 은 그대로다 — 옛 뜻(문제 소리 = mainVolume, 소음 = level/9)이 새 셈(문제 = M, 소음 = M×R)
44
+ * 에서 들리던 크기 그대로 남는다(0.123.0 의 "R>1 이면 mainVolume = level/9" 는 0.125.0 에서 걷음).
37
45
  * `{ ...DEFAULT_NOISE_SETTINGS, ...saved }` 대신 이것을 쓴다.
38
46
  */
39
47
  export declare function mergeNoiseSettings(base: CoreNoiseSettings, patch?: Partial<CoreNoiseSettings> | null): CoreNoiseSettings;
@@ -50,7 +58,7 @@ export type NoisePlayerContextValue = {
50
58
  /** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. */
51
59
  playbackRate: number;
52
60
  setPlaybackRate: (rate: number) => void;
53
- /** 전체 소리 — 두 소리(문제 소리·소음) 중 큰 쪽의 크기. 0–1, 기본 1. 실제 볼륨은 effectiveSpeechVolume·effectiveNoiseVolume. */
61
+ /** 전체 소리 = 문제 소리 볼륨. 0–1, 기본 1. 소음은 이 값 × 소음 비율(noiseElementVolume 참고). */
54
62
  mainVolume: number;
55
63
  setMainVolume: (v: number) => void;
56
64
  };
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { createContext, useContext } from 'react';
3
+ import { getNoiseFileGain } from '../../utils/storage';
3
4
  // 소음 크기: 내부 level(0~9 연속) ↔ 표시 percent(0~100). 소음 설정 툴팁과 계정 설정("훈련" 패널)이
4
5
  // 동일한 매핑을 써야 하므로 여기서 한 번만 정의해 공유한다.
5
6
  // 소음 음원 파일을 클리핑 없는 한도(+7dB, 약 2.24배)까지 증폭한 뒤 맞춘 상한값 —
@@ -11,15 +12,16 @@ export const levelToPct = (level) => Math.round((Math.min(LEVEL_MAX, Math.max(0,
11
12
  /** @public */
12
13
  export const pctToLevel = (pct) => (pct / 100) * LEVEL_MAX;
13
14
  /* ── 소음 비율 ──────────────────────────────────────────────────────────────
14
- 전체 소리(`mainVolume` 0~1)는 **두 소리 중 큰 쪽의 크기**이고, 소음은 문제 소리에 대한
15
- **비율**(`noiseRatio` R, 0~2배)로 정한다.
16
- R ≤ 1: 문제 소리 = M, 소음 = M × R
17
- R > 1: 문제 소리 = M ÷ R, 소음 = M
18
- 곧 문제 소리 = M × min(1, 1/R), 소음 = M × min(1, R) — 어느 쪽도 1 을 넘지 않고, 0~2배가
19
- 전체 소리와 무관하게 정확히 들린다.
20
- (0.120.0 에서는 문제 소리 = M, 소음 = min(1, M×R) 이라 M 이 50% 를 넘으면 소음이 1 에 막혀
21
- 슬라이더 배수와 실제가 어긋났다 — 전체 80% 에 2배 → 실제 1.25배. 0.123.0 에서 고침.)
22
- 예전(0.119.0 까지)에는 소음 크기(level/9)가 전체 소리와 따로 움직였다. */
15
+ 전체 소리 M(`mainVolume` 0~1) = **문제 소리**. 소음은 문제 소리에 대한 **비율** R(`noiseRatio` 0~2배).
16
+ 문제 소리 = M — 비율과 상관없이 줄이지 않는다
17
+ 소음 들리는 크기 = M × R (0~2) — **원본 소음 파일 기준**
18
+ 소음 <audio> 볼륨 = min(1, M × R ÷ gain) — gain = 소음 파일이 원본보다 몇 배 큰지
19
+ (configureStorage 의 noiseFileGain, 기본 1)
20
+ <audio> 볼륨은 1 을 넘을 수 없어 원본 파일로는 1배 넘는 소음이 1 에서 멈춘다. 2배로 키운 파일
21
+ (gain 2)을 주면 0~2배가 정확히 들린다. 기본 gain 1 인 앱은 1배 초과만 1 에서 멈춘다.
22
+ 0.120.0: 문제 = M, 소음 = min(1, M×R) — M 이 크면 1 에 막힘.
23
+ 0.123.0: 문제 = M×min(1,1/R), 소음 = M×min(1,R) — 1~2배를 올려도 소음은 안 커지고 문제 소리만
24
+ 줄었다(말귀 사용자). 0.125.0 에서 키운 파일로 푼다. */
23
25
  /** @public 소음 비율 상한(배). */
24
26
  export const NOISE_RATIO_MAX = 2;
25
27
  /** @public 소음 비율 칸(배). */
@@ -41,29 +43,25 @@ export function noiseRatioOf(settings) {
41
43
  return 1;
42
44
  return 한칸반올림(비율자르기(소음 / 전체));
43
45
  }
46
+ const 전체소리 = (settings) => Math.min(1, Math.max(0, Number(settings.mainVolume) || 0));
47
+ /** @public 문제 소리 볼륨(0~1) = mainVolume. 비율과 상관없다. */
48
+ export function effectiveSpeechVolume(settings) {
49
+ return 전체소리(settings);
50
+ }
44
51
  /**
45
- * 전체 소리와 비율을 확정한다. `noiseRatio` 가 없는 옛 기록은 옛 뜻(문제 소리 = mainVolume,
46
- * 소음 = level/9)이 **들리던 크기 그대로** 남도록 옮긴다 — R = noiseRatioOf, 그리고 R > 1 이면
47
- * 소음이 큰 쪽이므로 전체 소리 = level/9.
52
+ * @public 소음이 **들리는 크기**(0~2, 원본 소음 파일 기준) = mainVolume × 비율. 켜짐 여부는 보지
53
+ * 않는다. 결과 기록·파형이 쓴다. 소음 `<audio>` 에 거는 값은 `noiseElementVolume`.
48
54
  */
49
- function 확정(settings) {
50
- const 전체 = Math.min(1, Math.max(0, Number(settings.mainVolume) || 0));
51
- const noiseRatio = noiseRatioOf(settings);
52
- const 옛기록 = !(typeof settings.noiseRatio === 'number' && Number.isFinite(settings.noiseRatio));
53
- if (옛기록 && noiseRatio > 1) {
54
- return { mainVolume: Math.min(LEVEL_MAX, Math.max(0, Number(settings.level) || 0)) / LEVEL_MAX, noiseRatio };
55
- }
56
- return { mainVolume: 전체, noiseRatio };
57
- }
58
- /** @public 실제 소음 볼륨(0~1) = mainVolume × min(1, 비율). 켜짐 여부는 보지 않는다. */
59
55
  export function effectiveNoiseVolume(settings) {
60
- const { mainVolume, noiseRatio } = 확정(settings);
61
- return mainVolume * Math.min(1, noiseRatio);
56
+ return 전체소리(settings) * noiseRatioOf(settings);
62
57
  }
63
- /** @public 실제 문제 소리 볼륨(0~1) = mainVolume × min(1, 1/비율). 비율 0 이면 mainVolume. */
64
- export function effectiveSpeechVolume(settings) {
65
- const { mainVolume, noiseRatio } = 확정(settings);
66
- return noiseRatio > 1 ? mainVolume / noiseRatio : mainVolume;
58
+ /**
59
+ * @public 소음 `<audio>` 볼륨(0~1) = min(1, 들리는 크기 ÷ gain). gain 은 소음 파일이 원본보다 몇 배
60
+ * 큰지 — 기본은 `configureStorage({ noiseFileGain })`(없으면 1).
61
+ */
62
+ export function noiseElementVolume(settings, gain = getNoiseFileGain()) {
63
+ const g = Number.isFinite(gain) && gain > 0 ? gain : 1;
64
+ return Math.min(1, effectiveNoiseVolume(settings) / g);
67
65
  }
68
66
  /**
69
67
  * @public 설정에 조각을 덮는다 — `{ ...base, ...patch }` 에 비율 규칙 하나를 더한 것.
@@ -72,16 +70,14 @@ export function effectiveSpeechVolume(settings) {
72
70
  * 옛 `noiseOverride`). 그대로 덮으면 base 의 `noiseRatio`(기본값 1 등)가 남아 level 을 가려
73
71
  * 버리므로, 덮은 결과의 level·mainVolume 으로 비율을 셈해 **그 자리에서 박아 둔다** —
74
72
  * 박지 않고 비워 두면 뒤에 전체 소리만 바꿀 때 비율이 따라 변해 소음이 옛날처럼 제자리에 선다.
75
- * 비율이 1 을 넘으면 소음이 큰 쪽이므로 `mainVolume` 도 level/9 로 함께 박는다(옛날에 들리던
76
- * 두 크기 그대로 — 예: level 9·mainVolume 0.5 → 비율 2·mainVolume 1 = 소음 1·문제 0.5).
73
+ * `mainVolume` 은 그대로다 — 옛 뜻(문제 소리 = mainVolume, 소음 = level/9)이 새 셈(문제 = M, 소음 = M×R)
74
+ * 에서 들리던 크기 그대로 남는다(0.123.0 의 "R>1 이면 mainVolume = level/9" 는 0.125.0 에서 걷음).
77
75
  * `{ ...DEFAULT_NOISE_SETTINGS, ...saved }` 대신 이것을 쓴다.
78
76
  */
79
77
  export function mergeNoiseSettings(base, patch) {
80
78
  const merged = { ...base, ...(patch ?? {}) };
81
79
  if (patch && 'level' in patch && !(typeof patch.noiseRatio === 'number')) {
82
- const 박을것 = 확정({ level: merged.level, mainVolume: merged.mainVolume });
83
- merged.noiseRatio = 박을것.noiseRatio;
84
- merged.mainVolume = 박을것.mainVolume;
80
+ merged.noiseRatio = noiseRatioOf({ level: merged.level, mainVolume: merged.mainVolume });
85
81
  }
86
82
  return merged;
87
83
  }
@@ -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';
@@ -97,10 +97,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
97
97
  // 문장(메인) 재생 속도·볼륨 — 하단 소음설정 툴팁과 공유(NoisePlayerContext)
98
98
  const playbackRate = noisePlayer?.playbackRate ?? 1.0;
99
99
  const mainVolume = noisePlayer?.mainVolume ?? 1.0;
100
- // 실제 두 볼륨 — 전체 소리(큰 쪽 크기)를 소음 비율로 나눈다(소리용). 파형은 M·R 로 따로 그린다.
100
+ // 문제 소리 = 전체 소리. noiseVolume 은 소음이 들리는 크기(M×R, 원본 파일 기준) — 0 이면 소음 파형을 안 그린다.
101
+ // 소음 <audio> 볼륨(파일 gain 으로 나눈 값)은 useNoiseFade 가 건다.
101
102
  const noiseVolume = noisePlayer ? effectiveNoiseVolume(noisePlayer.settings) : 0;
102
- // 소음을 끈 판(소음 종류 "없음")은 들리지 않는 소음에 문제 소리를 양보하지 않는다 — 전체 소리 그대로.
103
- const speechVolume = noisePlayer?.settings.enabled ? effectiveSpeechVolume(noisePlayer.settings) : mainVolume;
103
+ const speechVolume = noisePlayer ? effectiveSpeechVolume(noisePlayer.settings) : mainVolume;
104
104
  const [fillCompletionPhase, setFillCompletionPhase] = useState('idle');
105
105
  const [noticeMessage, setNoticeMessage] = useState("");
106
106
  const [noticePosition] = useState("top");
@@ -143,7 +143,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
143
143
  const { startTTS, stopTTS } = useTTSPlayback({
144
144
  ttsText,
145
145
  playbackRate,
146
- // TTS 음성 = 문제 소리 — 전체 소리가 아니라 비율로 나뉜 문제 소리 볼륨
146
+ // TTS 음성 = 문제 소리 = 전체 소리
147
147
  mainVolume: speechVolume,
148
148
  fillRef,
149
149
  waveProgressRef,
@@ -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,8 +954,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
950
954
  }
951
955
  catch (e) { }
952
956
  }, [playbackRate]);
953
- // 문장(메인) 볼륨 반영 — 문제 소리 볼륨(effectiveSpeechVolume). 전체 소리·소음 비율 어느 쪽을
954
- // 바꿔도 달라진다. 재생 중이면 소음과 같은 150ms 램프로 옮기고, 멈춰 있으면 바로 건다.
957
+ // 문장(메인) 볼륨 반영 — 문제 소리 볼륨(effectiveSpeechVolume = 전체 소리). setElementVolume 이 그래프에 태운
958
+ // 요소면 GainNode, 아니면 요소 volume 에 건다(iOS 실기기는 요소 volume 이 안 먹는다). 재생 중이면 소음과 같은 150ms 램프로 옮기고, 멈춰 있으면 바로 건다.
955
959
  // 백그라운드 탭에서는 RAF 가 멈춰 램프가 중간에 설 수 있으므로 끝값을 타이머로 한 번 더 박는다.
956
960
  const speechRampRef = useRef({ raf: null, timer: null });
957
961
  useEffect(() => {
@@ -971,10 +975,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
971
975
  }
972
976
  };
973
977
  끊기();
974
- const from = a.volume;
978
+ const from = getElementVolume(a);
975
979
  if (a.paused || Math.abs(from - target) < 0.001 || typeof requestAnimationFrame !== 'function') {
976
980
  try {
977
- a.volume = target;
981
+ setElementVolume(a, target);
978
982
  }
979
983
  catch (e) { }
980
984
  return;
@@ -983,14 +987,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
983
987
  const step = () => {
984
988
  const t = Math.min(1, (performance.now() - start) / 150);
985
989
  try {
986
- a.volume = from + (target - from) * t;
990
+ setElementVolume(a, from + (target - from) * t);
987
991
  }
988
992
  catch (e) { }
989
993
  ramp.raf = t < 1 ? requestAnimationFrame(step) : null;
990
994
  };
991
995
  ramp.raf = requestAnimationFrame(step);
992
996
  ramp.timer = setTimeout(() => { 끊기(); try {
993
- a.volume = target;
997
+ setElementVolume(a, target);
994
998
  }
995
999
  catch (e) { } }, 200);
996
1000
  return 끊기;
@@ -1,8 +1,8 @@
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';
5
- import { effectiveNoiseVolume } from './NoisePlayerContext';
4
+ import { resumeAudioMeter, routeElementAudio, setElementVolume, getElementVolume } from '../../utils/audioMeter';
5
+ import { noiseElementVolume } from './NoisePlayerContext';
6
6
  function generateNoiseUrl(type) {
7
7
  const rand = Math.floor(Math.random() * 10);
8
8
  switch (type) {
@@ -24,7 +24,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
24
24
  // 실제 로드된(재생되는) 소음 파일 URL — waveform이 화면=청취 일치하도록 이 src를 그린다.
25
25
  const [activeNoiseUrl, setActiveNoiseUrl] = useState(null);
26
26
  const noiseTypeLoadedRef = useRef(null);
27
- // volume = 실제 소음 볼륨(effectiveNoiseVolume — 전체 소리 × 소음 비율, 상한 1)
27
+ // volume = 소음 <audio> 볼륨(noiseElementVolume — 전체 소리 × 소음 비율 ÷ 파일 gain, 상한 1)
28
28
  const noiseSettingsRef = useRef(null);
29
29
  const noiseFadeRafRef = useRef(null);
30
30
  // 페이드 세대 토큰 — 하드스톱 타이머가 발화 시점에 "그 사이 새 페이드가 시작됐는지"를
@@ -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
  }
@@ -157,7 +161,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
157
161
  // 개별 레이스를 전부 틀어막는 대신, 잘못된 상태를 주기적으로 감지해 회수한다.
158
162
  // 두 번 연속(약 0.8초) 확인 후에 정지 — 멀티소스 트랙 전환처럼 메인이 잠깐 멈추는
159
163
  // 정상 구간에서 소음이 출렁이지 않게 하기 위한 유예다.
160
- const noiseVolume = noisePlayer ? effectiveNoiseVolume(noisePlayer.settings) : 0;
164
+ const noiseVolume = noisePlayer ? noiseElementVolume(noisePlayer.settings) : 0;
161
165
  const noiseInUse = !!noisePlayer?.settings.enabled && noiseVolume > 0;
162
166
  useEffect(() => {
163
167
  // 소음이 이번 재생 세션에서 아예 꺼져 있으면(noiseInUse=false) noiseEl은 절대
@@ -220,7 +224,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
220
224
  const noiseEl = noiseAudioRef.current;
221
225
  if (!noiseEl)
222
226
  return;
223
- const volume = ns ? effectiveNoiseVolume(ns) : 0;
227
+ const volume = ns ? noiseElementVolume(ns) : 0;
224
228
  if (!ns || !ns.enabled || volume === 0) {
225
229
  fadeOutNoise();
226
230
  return;
@@ -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)
@@ -8,7 +8,7 @@ type PlaybackState = 'stopped' | 'playing' | 'paused';
8
8
  export declare function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, waveProgressRef, setProgress, setPlaybackState, setStateLocal, fadeInNoise, fadeOutNoise, noiseSettingsRef, onDoneRef, handleEndRef, }: {
9
9
  ttsText?: string | string[] | null;
10
10
  playbackRate: number;
11
- /** 음성 볼륨 0–1 — 부르는 쪽이 문제 소리 볼륨(effectiveSpeechVolume)을 넘긴다 */
11
+ /** 음성 볼륨 0–1 — 문제 소리 = 전체 소리(effectiveSpeechVolume) */
12
12
  mainVolume: number;
13
13
  fillRef: RefObject<HTMLDivElement | null>;
14
14
  waveProgressRef: RefObject<HTMLDivElement | null>;
@@ -16,13 +16,13 @@ export type CoreNoiseSettings = {
16
16
  level: number;
17
17
  /**
18
18
  * 소음 비율 — 문제 소리 대비 소음의 배수, 0–2.0(0.1 칸). 1 = 소음과 문제 소리가 같은 크기.
19
- * 문제 소리 = mainVolume × min(1, 1/noiseRatio), 소음 = mainVolume × min(1, noiseRatio).
19
+ * 문제 소리 = mainVolume, 소음 들리는 크기 = mainVolume × noiseRatio(원본 소음 파일 기준).
20
20
  * 없으면 `level` 에서 옮긴다.
21
21
  */
22
22
  noiseRatio?: number;
23
23
  /** 소음 재생 속도(playbackRate). 0.75–1.25, 기본 1. */
24
24
  speed: number;
25
- /** 전체 소리 — 두 소리 중 큰 쪽의 크기. 0–1, 기본 1. 실제 두 볼륨은 `noiseRatio` 로 나뉜다. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
25
+ /** 전체 소리 = 문제 소리 볼륨. 0–1, 기본 1. 소음은 이 값 × `noiseRatio`. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
26
26
  mainVolume: number;
27
27
  /** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
28
28
  mainSpeed: number;
@@ -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;