@ihabdevteam/core 0.123.0 → 0.125.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.
Files changed (28) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +1 -1
  6. package/dist/ihabdevteam-core.cjs.js +178 -178
  7. package/dist/ihabdevteam-core.d.ts +21 -3
  8. package/dist/packages/core/src/CoreScope.d.ts +2 -0
  9. package/dist/packages/core/src/CoreScope.js +1 -0
  10. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.d.ts +1 -1
  11. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +23 -14
  12. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +14 -6
  13. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +29 -33
  14. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.css +14 -6
  15. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +9 -8
  16. package/dist/packages/core/src/components/Training/useNoiseFade.js +4 -4
  17. package/dist/packages/core/src/components/Training/useTTSPlayback.d.ts +1 -1
  18. package/dist/packages/core/src/components/Training/waveScale.d.ts +23 -0
  19. package/dist/packages/core/src/components/Training/waveScale.js +28 -0
  20. package/dist/packages/core/src/types/training.d.ts +2 -2
  21. package/dist/packages/core/src/utils/storage.d.ts +15 -0
  22. package/dist/packages/core/src/utils/storage.js +8 -0
  23. package/dist/packages/core/src/utils.d.ts +1 -1
  24. package/dist/packages/core/src/utils.js +1 -1
  25. package/dist/packages/core/src/version.d.ts +1 -1
  26. package/dist/packages/core/src/version.js +1 -1
  27. package/dist/utils.cjs.js +2 -2
  28. 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.123.0";
1344
+ export declare const CORE_VERSION = "0.125.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
  /**
@@ -2269,6 +2271,12 @@ export declare function getImageResolver(): ((word: string, homonum?: number) =>
2269
2271
  */
2270
2272
  export declare function getNoiseBaseUrl(): string;
2271
2273
 
2274
+ /**
2275
+ * 소음 파일이 원본보다 몇 배 큰지 — configureStorage 의 noiseFileGain. 없거나 양수가 아니면 1.
2276
+ * @public
2277
+ */
2278
+ export declare function getNoiseFileGain(): number;
2279
+
2272
2280
  /** @public 현재 메모리에 적재된 인덱스 (없으면 localStorage 시도) */
2273
2281
  export declare function getPairIndex(): PairIndex | null;
2274
2282
 
@@ -5650,6 +5658,16 @@ export declare interface StorageResolvers {
5650
5658
  * @example 'https://cdn.example.com/sounds/noise'
5651
5659
  */
5652
5660
  noiseBaseUrl?: string;
5661
+ /**
5662
+ * (선택) `noiseBaseUrl` 의 소음 파일이 **원본 소음보다 몇 배 큰지**(진폭). 기본 1.
5663
+ *
5664
+ * 소음 비율 R 은 원본 파일 기준이라 `<audio>` 볼륨 = min(1, 전체 소리 × R ÷ 이 값) 이다.
5665
+ * 볼륨은 1 을 넘을 수 없으므로 원본 파일로는 1배 넘는 소음이 1 에서 멈춘다 — 2배로 키운 파일과
5666
+ * `noiseFileGain: 2` 를 함께 주면 0~2배가 정확히 들린다.
5667
+ *
5668
+ * @example 2
5669
+ */
5670
+ noiseFileGain?: number;
5653
5671
  }
5654
5672
 
5655
5673
  /** @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);
@@ -9,7 +9,7 @@ export default function WaveForm(props: {
9
9
  onSegmentClick?: (idx: number) => void;
10
10
  waveProgressRef?: MutableRefObject<HTMLDivElement | null>;
11
11
  noiseSrc?: string | null;
12
- noiseVolume?: number;
12
+ noiseRatio?: number;
13
13
  noiseSpeed?: number;
14
14
  mainVolume?: number;
15
15
  onReadyChange?: (ready: boolean) => void;
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { computePeaksFromUrl } from '../../utils/audioPeaks';
5
+ import { waveBarPx, waveBarVal, WAVE_H1_PX } from './waveScale';
5
6
  import './TrainingStepAudioPlayer.css';
6
7
  /**
7
8
  * waveform 바 최소 픽셀 — 렌더 높이가 이 값 이하이면 바를 그리지 않는다(문장·소음 공통).
@@ -9,7 +10,7 @@ import './TrainingStepAudioPlayer.css';
9
10
  */
10
11
  const MIN_BAR_PX = 4;
11
12
  export default function WaveForm(props) {
12
- const { src, segments, progress = 0, waveProgressRef, noiseSrc, noiseVolume = 0, noiseSpeed = 1, totalDuration = 0, mainVolume = 1, onReadyChange, showSoundWaveForm = true } = props;
13
+ const { src, segments, progress = 0, waveProgressRef, noiseSrc, noiseRatio = 0, noiseSpeed = 1, totalDuration = 0, mainVolume = 1, onReadyChange, showSoundWaveForm = true } = props;
13
14
  const waveformRef = useRef(null);
14
15
  const outerRef = useRef(null);
15
16
  const [wavePeaks, setWavePeaks] = useState(null);
@@ -118,8 +119,9 @@ export default function WaveForm(props) {
118
119
  return () => { cancelled = true; };
119
120
  }, [noiseSrc, barsTotal, totalDuration, noiseSpeed]);
120
121
  /* eslint-enable react-hooks/set-state-in-effect */
121
- // 문장: 자기 최고치로 정규화해 세로 영역을 다 씀 × 문제 소리 볼륨(부모가 effectiveSpeechVolume 을 mainVolume 으로 넘김).
122
- // 소음은 이 문장 최고치(sentenceMaxPeak)를 공통 분모로 써서 서로 비율이 유지된다.
122
+ // **그림의 눈금은 "문장 = 기준"** — 소리 셈(문제 소리 = M×min(1,1/R))과 달리 문장을 줄이지 않고
123
+ // 소음을 R 배로 키워 비율을 보인다. 막대 크기 a: 문장 = peak/문장최고치 × 전체 소리(M),
124
+ // 소음 = 같은 분모 × M × 소음 비율(R). a → 픽셀은 구간별 눈금(waveScale — 1 까지 45px, 2 에 74px).
123
125
  const mainAmp = Math.max(0, Math.min(1, mainVolume));
124
126
  const sentenceMaxPeak = useMemo(() => (wavePeaks && wavePeaks.length) ? (Math.max(...wavePeaks) || 1) : 1, [wavePeaks]);
125
127
  const barsCountForMix = barsTotal || 40;
@@ -128,9 +130,10 @@ export default function WaveForm(props) {
128
130
  const bars = useMemo(() => Array.from({ length: barsCountForMix }).map((_, i) => {
129
131
  const peak = wavePeaks && wavePeaks[i] != null ? wavePeaks[i] : null;
130
132
  const eff = peak == null ? null : Math.min(1, (peak / sentenceMaxPeak) * mainAmp);
131
- // 로딩 시 placeholder(0.15); 계산 후엔 렌더 높이(eff×waveHeightPx)가 MIN_BAR_PX 이하면 숨김(0)
132
- const belowMin = eff != null && waveHeightPx > 0 && eff * waveHeightPx <= MIN_BAR_PX;
133
- const val = eff == null ? 0.15 : (belowMin ? 0 : eff);
133
+ // 로딩 시 placeholder(0.15); 계산 후엔 렌더 높이(waveBarPx)가 MIN_BAR_PX 이하면 숨김(0).
134
+ // waveHeightPx 는 "칸이 배치됐나" 만 본다 — 모를 때(0) 지우면 안 된다.
135
+ const belowMin = eff != null && waveHeightPx > 0 && waveBarPx(eff) <= MIN_BAR_PX;
136
+ const val = eff == null ? 0.15 : (belowMin ? 0 : waveBarVal(eff));
134
137
  // 가로 위치 비율(0=왼쪽 lightMain ~ 1=오른쪽 lightSub) — 바 색을 위치에 따라 믹스
135
138
  const mix = barsCountForMix > 1 ? i / (barsCountForMix - 1) : 0;
136
139
  const style = { ['--val']: val, ['--i']: i, ['--mix']: mix };
@@ -143,10 +146,16 @@ export default function WaveForm(props) {
143
146
  // 컨테이너 실제 폭에 맞춰 정밀 계산한 바 간격 — outer에 걸어 .audio-player__wave-inner 등
144
147
  // 하위 요소들의 CSS 변수(--bar-gap, 기본 3px)로 상속시킨다. 계산은 하되 값을 어디에도
145
148
  // 바인딩하지 않아 항상 하드코딩된 기본값(3px)으로만 그려지던 문제 — 실제로 적용한다.
146
- const outerStyle = { '--bar-gap': `${barGap}px` };
147
- // 소음 바 높이 = (절대진폭 / 문장 최고치) × 실제 소음 볼륨(effectiveNoiseVolume — 부모가 소리에 쓰는 값 그대로).
148
- // 문장과 같은 분모(sentenceMaxPeak)를 써서 문장 대비 소음 비율이 미터와 일치.
149
- const noiseAmp = Math.max(0, Math.min(1, noiseVolume));
149
+ // 칸 높이 = 눈금 끝 max(1, R) 의 픽셀 높이 — R ≤ 1 이면 45px 그대로, R 2 면 74px. 소음이 안 보이면 1.
150
+ // 막대(::after)는 늘 1배 높이(--wave-h1)를 scaleY 하므로 칸이 자라는 동안에도 문장 막대 크기는 그대로다.
151
+ const waveScaleEnd = noiseSrc ? Math.max(1, noiseRatio) : 1;
152
+ const outerStyle = {
153
+ '--bar-gap': `${barGap}px`,
154
+ '--wave-h': `${waveBarPx(waveScaleEnd)}px`,
155
+ '--wave-h1': `${WAVE_H1_PX}px`,
156
+ };
157
+ // 소음 막대 크기 a = (절대진폭 / 문장 최고치) × M × R — 문장과 같은 분모라 두 막대의 비가 곧 소음 비율.
158
+ const noiseAmp = mainAmp * Math.max(0, noiseRatio);
150
159
  const noiseReady = !!noisePeaks;
151
160
  // 소음 연산은 문장 연산(barsTotal 확정) 이후에 시작돼 항상 더 늦게 끝난다 — 문장 바가 먼저 나타났다가
152
161
  // 소음 바만 뒤늦게 팝인하는 것을 막기 위해, 최초 등장 시엔 두 계산이 모두 끝난 뒤 함께 드러낸다.
@@ -169,10 +178,10 @@ export default function WaveForm(props) {
169
178
  // 문장 바와 같은 이유로 메모 — 입력값이 바뀔 때만 재생성
170
179
  const noiseBars = useMemo(() => Array.from({ length: noiseCount }).map((_, i) => {
171
180
  const peak = noisePeaks && noisePeaks[i] != null ? noisePeaks[i] : null;
172
- const eff = peak == null ? 0 : Math.min(1, (peak / sentenceMaxPeak) * noiseAmp);
173
- // 렌더 높이(eff×waveHeightPx)가 MIN_BAR_PX 이하면 숨김(0)
174
- const belowMin = waveHeightPx > 0 && eff * waveHeightPx <= MIN_BAR_PX;
175
- const val = belowMin ? 0 : eff;
181
+ const eff = peak == null ? 0 : (peak / sentenceMaxPeak) * noiseAmp;
182
+ // 렌더 높이(waveBarPx — 2 에서 자름)가 MIN_BAR_PX 이하면 숨김(0)
183
+ const belowMin = waveHeightPx > 0 && waveBarPx(eff) <= MIN_BAR_PX;
184
+ const val = belowMin ? 0 : waveBarVal(eff);
176
185
  // 가로 위치 비율(0=왼쪽 deepMain ~ 1=오른쪽 deepSub) — 바 색을 위치에 따라 믹스
177
186
  const mix = noiseCount > 1 ? i / (noiseCount - 1) : 0;
178
187
  const style = { ['--val']: val, ['--i']: i, ['--mix']: mix };
@@ -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
  }
@@ -267,13 +267,17 @@
267
267
 
268
268
  @keyframes audio-player-spinner { to { transform: rotate(360deg); } }
269
269
 
270
- .audio-player__wave { position: absolute; width: 100%; height: 90%; z-index: 60; display: flex; align-items: center; justify-content: center; pointer-events: none; }
270
+ /* 칸 높이 --wave-h — WaveForm 이 눈금 끝 max(1, 소음 비율)의 픽셀 높이로 준다(waveScale.ts: 1배 45px =
271
+ slider 50px 의 90%, 2배 74px). 비율을 옮기면 막대 등장과 같은 곡선으로 따라 자란다. */
272
+ .audio-player__wave { position: absolute; width: 100%; height: var(--wave-h, 90%); transition: height 350ms cubic-bezier(.22,.9,.3,1); z-index: 60; display: flex; align-items: center; justify-content: center; pointer-events: none; }
271
273
 
272
274
  /* 두 레이어(미재생/재생)를 겹쳐서 진행도를 시각화합니다 */
273
275
  .audio-player__wave-inner { display: flex; align-items: center; gap: var(--bar-gap, 3px); padding: 0; width: 100%; margin: 0 auto; justify-content: center; z-index: 60; height: 100%; pointer-events: none; cursor: default; }
274
276
  .audio-player__wave-inner:not(.is-ready) .audio-player__bar::after { opacity: 0; }
275
277
  .audio-player__wave-inner.is-ready .audio-player__bar::after {
276
- animation: wave-bar-in 350ms cubic-bezier(.22,.9,.3,1) both;
278
+ /* backwards — 등장이 끝난 뒤에는 기본 transform 이 이어받아, --val 이 바뀌면(비율·전체 소리) 아래
279
+ transition 으로 부드럽게 옮긴다. both 면 끝 키프레임이 붙들어 --val 변화가 뚝 끊겨 보였다. */
280
+ animation: wave-bar-in 350ms cubic-bezier(.22,.9,.3,1) backwards;
277
281
  animation-delay: calc(var(--i, 0) * 2ms);
278
282
  }
279
283
  @keyframes wave-bar-in {
@@ -289,9 +293,9 @@
289
293
  .audio-player__wave-played { position: absolute; left: 0; right: 0; top: 0; bottom: 0; /* clip-path 으로 재생된 구간만 표시 — RAF 루프가 --progress 를 직접 갱신합니다 */ clip-path: inset(0 calc((1 - var(--progress, 0)) * 100%) 0 0); pointer-events: none; }
290
294
  .audio-player__bar { flex: 0 0 var(--bar-w, 3px); height: 100%; position: relative; box-sizing: border-box; }
291
295
  /* 미재생 바: 사운드 크기에 비례한 opacity (0 ~ 70%). 가로 위치 비율(--mix)로 lightMain~lightSub 그라디언트 */
292
- .audio-player__wave-unplayed .audio-player__bar::after { content: ""; position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%) scaleY(var(--val, 0.15)); transform-origin: center center; height: 100%; background: color-mix(in srgb, var(--color-lightMain), var(--color-lightSub) calc(var(--mix, 0) * 100%)); border-radius: 999px; opacity: 1; z-index: 61; }
296
+ .audio-player__wave-unplayed .audio-player__bar::after { content: ""; position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%) scaleY(var(--val, 0.15)); transform-origin: center center; height: var(--wave-h1, 100%); transition: transform 350ms cubic-bezier(.22,.9,.3,1); background: color-mix(in srgb, var(--color-lightMain), var(--color-lightSub) calc(var(--mix, 0) * 100%)); border-radius: 999px; opacity: 1; z-index: 61; }
293
297
  /* 재생된 바: 사운드 크기에 비례한 opacity (0 ~ 70%). 가로 위치 비율(--mix)로 lightMain~lightSub 그라디언트 */
294
- .audio-player__wave-played .audio-player__bar::after { content: ""; position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%) scaleY(var(--val, 0.15)); transform-origin: center center; height: 100%; background: color-mix(in srgb, var(--color-lightMain), var(--color-lightSub) calc(var(--mix, 0) * 100%)); border-radius: 999px; opacity: 1; z-index: 62; }
298
+ .audio-player__wave-played .audio-player__bar::after { content: ""; position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%) scaleY(var(--val, 0.15)); transform-origin: center center; height: var(--wave-h1, 100%); transition: transform 350ms cubic-bezier(.22,.9,.3,1); background: color-mix(in srgb, var(--color-lightMain), var(--color-lightSub) calc(var(--mix, 0) * 100%)); border-radius: 999px; opacity: 1; z-index: 62; }
295
299
 
296
300
  /* 소음 waveform — 문장 바 뒤에 깔리는 은은한 레이어. 진하기는 --noise-op(소음 크기)로 조절 */
297
301
  .audio-player__wave-noise {
@@ -303,7 +307,9 @@
303
307
  .audio-player__wave-noise .audio-player__bar::after {
304
308
  content: ""; position: absolute; left: 0; right: 0; top: 50%;
305
309
  transform: translateY(-50%) scaleY(var(--val, 0.1));
306
- transform-origin: center center; height: 100%;
310
+ /* 막대는 늘 1배 높이(--wave-h1)를 scaleY — 소음은 2배까지 --val 이 1 을 넘는다(waveScale) */
311
+ transform-origin: center center; height: var(--wave-h1, 100%);
312
+ transition: transform 350ms cubic-bezier(.22,.9,.3,1);
307
313
  background: color-mix(in srgb, var(--color-deepMain), var(--color-deepSub) calc(var(--mix, 0) * 100%));
308
314
  border-radius: 999px;
309
315
  opacity: 1; z-index: 58;
@@ -311,7 +317,9 @@
311
317
  /* 문장 바(.audio-player__wave-inner)와 동일한 진입 연출 — is-ready 전까지 숨겨서 계산 완료 시 동시에 드러난다 */
312
318
  .audio-player__wave-noise:not(.is-ready) .audio-player__bar::after { opacity: 0; }
313
319
  .audio-player__wave-noise.is-ready .audio-player__bar::after {
314
- animation: wave-bar-in 350ms cubic-bezier(.22,.9,.3,1) both;
320
+ /* backwards — 등장이 끝난 뒤에는 기본 transform 이 이어받아, --val 이 바뀌면(비율·전체 소리) 아래
321
+ transition 으로 부드럽게 옮긴다. both 면 끝 키프레임이 붙들어 --val 변화가 뚝 끊겨 보였다. */
322
+ animation: wave-bar-in 350ms cubic-bezier(.22,.9,.3,1) backwards;
315
323
  animation-delay: calc(var(--i, 0) * 2ms);
316
324
  }
317
325
 
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
7
7
  import { downloadSound, getSignedOrPublicUrl, resolveSoundPath } from '../../utils/storage';
8
8
  import { getTTSUrlResolver } from '../../utils/ttsConfig';
9
9
  import { getElementAnalyser, resumeAudioMeter } from '../../utils/audioMeter';
10
- import { useNoisePlayer, effectiveNoiseVolume, effectiveSpeechVolume } from './NoisePlayerContext';
10
+ import { useNoisePlayer, effectiveNoiseVolume, effectiveSpeechVolume, noiseRatioOf } from './NoisePlayerContext';
11
11
  import { useNoiseFade } from './useNoiseFade';
12
12
  import { useTTSPlayback } from './useTTSPlayback';
13
13
  import './TrainingStepAudioPlayer.css';
@@ -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
- // 실제 두 볼륨 — 전체 소리(큰 쪽 크기)를 소음 비율로 나눈다. 파형 막대도 이 값으로 그린다.
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,
@@ -950,8 +950,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
950
950
  }
951
951
  catch (e) { }
952
952
  }, [playbackRate]);
953
- // 문장(메인) 볼륨 반영 — 문제 소리 볼륨(effectiveSpeechVolume). 전체 소리·소음 비율 어느 쪽을
954
- // 바꿔도 달라진다. 재생 중이면 소음과 같은 150ms 램프로 옮기고, 멈춰 있으면 바로 건다.
953
+ // 문장(메인) 볼륨 반영 — 문제 소리 볼륨(effectiveSpeechVolume = 전체 소리). 재생 중이면 소음과 같은 150ms 램프로 옮기고, 멈춰 있으면 바로 건다.
955
954
  // 백그라운드 탭에서는 RAF 가 멈춰 램프가 중간에 설 수 있으므로 끝값을 타이머로 한 번 더 박는다.
956
955
  const speechRampRef = useRef({ raf: null, timer: null });
957
956
  useEffect(() => {
@@ -1008,5 +1007,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1008
1007
  onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1009
1008
  handleJumpTo(idx);
1010
1009
  }
1011
- catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, onSegmentClick: handleJumpTo, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseVolume: noiseVolume, noiseSpeed: noisePlayer?.settings.speed ?? 1, mainVolume: speechVolume, onReadyChange: handleWaveReadyChange }))] })] }), _jsx("audio", { ref: audioRef, crossOrigin: "anonymous", preload: "auto" })] }), !showSoundWaveForm && (_jsxs("p", { className: "audio-player__no-waveform caption-1", children: [_jsx(Icon, { icon: "triangle-alert", size: 14 }), t('training.noSoundWaveform', '소리 비교 문제는 웨이브폼을 표현하지 않습니다.')] })), _jsx(Toast, { message: noticeMessage, position: noticePosition, onClose: handleClearNotice })] }));
1010
+ catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, onSegmentClick: handleJumpTo, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
1011
+ /* 그림은 "문장 = 기준" — 문제 소리(M÷R)가 아니라 전체 소리 M 으로 문장을 그리고 소음을 R 배로 키운다 */
1012
+ mainVolume: mainVolume, onReadyChange: handleWaveReadyChange }))] })] }), _jsx("audio", { ref: audioRef, crossOrigin: "anonymous", preload: "auto" })] }), !showSoundWaveForm && (_jsxs("p", { className: "audio-player__no-waveform caption-1", children: [_jsx(Icon, { icon: "triangle-alert", size: 14 }), t('training.noSoundWaveform', '소리 비교 문제는 웨이브폼을 표현하지 않습니다.')] })), _jsx(Toast, { message: noticeMessage, position: noticePosition, onClose: handleClearNotice })] }));
1012
1013
  }
@@ -2,7 +2,7 @@
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { noiseAssets } from '../../sounds/noiseAssets';
4
4
  import { getElementAnalyser, resumeAudioMeter } from '../../utils/audioMeter';
5
- import { effectiveNoiseVolume } from './NoisePlayerContext';
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
  // 페이드 세대 토큰 — 하드스톱 타이머가 발화 시점에 "그 사이 새 페이드가 시작됐는지"를
@@ -157,7 +157,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
157
157
  // 개별 레이스를 전부 틀어막는 대신, 잘못된 상태를 주기적으로 감지해 회수한다.
158
158
  // 두 번 연속(약 0.8초) 확인 후에 정지 — 멀티소스 트랙 전환처럼 메인이 잠깐 멈추는
159
159
  // 정상 구간에서 소음이 출렁이지 않게 하기 위한 유예다.
160
- const noiseVolume = noisePlayer ? effectiveNoiseVolume(noisePlayer.settings) : 0;
160
+ const noiseVolume = noisePlayer ? noiseElementVolume(noisePlayer.settings) : 0;
161
161
  const noiseInUse = !!noisePlayer?.settings.enabled && noiseVolume > 0;
162
162
  useEffect(() => {
163
163
  // 소음이 이번 재생 세션에서 아예 꺼져 있으면(noiseInUse=false) noiseEl은 절대
@@ -220,7 +220,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
220
220
  const noiseEl = noiseAudioRef.current;
221
221
  if (!noiseEl)
222
222
  return;
223
- const volume = ns ? effectiveNoiseVolume(ns) : 0;
223
+ const volume = ns ? noiseElementVolume(ns) : 0;
224
224
  if (!ns || !ns.enabled || volume === 0) {
225
225
  fadeOutNoise();
226
226
  return;
@@ -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>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 파형 세로 눈금 — 막대 크기 a(문장 단위: 문장 최고치 × 전체 소리 = 1)를 픽셀 높이로 옮긴다.
3
+ *
4
+ * 소음 비율이 1 을 넘으면 소음 막대가 문장보다 커야 보인다(말귀 사용자 "최대를 1로 두었을 때
5
+ * 2배는 영역을 더 늘려서"). 그런데 재생 카드(.audio-player__panel 120px)와 조작 줄 자리는 그대로라
6
+ * 쓸 수 있는 세로가 모자란다 — 가운데 선(y=45)에서 위로 45px 이 카드 윗변, 아래로 37px 이
7
+ * 조작 줄 윗변이다. 그래서 **구간별 눈금**(사용자 결정, 2026-09-13):
8
+ *
9
+ * a ≤ 1 h = a × H1 — 지금과 똑같다(H1 = 45px, slider 50px 의 90%)
10
+ * 1 < a ≤ 2 h = H1 + (a − 1) × (HMAX − H1) — 2배가 정확히 HMAX(74px = 2 × 37)에 닿는다
11
+ *
12
+ * 1~2 구간은 한 칸에 29px 로 기울기가 다르다. 겹침·넘침은 test/waveScale.test.ts 가 CSS 치수로 잰다.
13
+ */
14
+ /** @public 1배 막대 높이(px) — 지금까지의 파형 칸 높이. */
15
+ export declare const WAVE_H1_PX = 45;
16
+ /** @public 2배 막대 높이(px) — 카드 윗변·조작 줄을 넘지 않는 최대. */
17
+ export declare const WAVE_HMAX_PX = 74;
18
+ /** @public 눈금 끝(배). */
19
+ export declare const WAVE_A_MAX = 2;
20
+ /** @public 막대 크기 a(0~2, 넘으면 자름) → 픽셀 높이. */
21
+ export declare function waveBarPx(a: number): number;
22
+ /** @public 막대 `--val` — 1배 높이(H1)에 대한 배율. 막대 ::after 는 H1 높이를 scaleY(--val) 한다. */
23
+ export declare function waveBarVal(a: number): number;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 파형 세로 눈금 — 막대 크기 a(문장 단위: 문장 최고치 × 전체 소리 = 1)를 픽셀 높이로 옮긴다.
3
+ *
4
+ * 소음 비율이 1 을 넘으면 소음 막대가 문장보다 커야 보인다(말귀 사용자 "최대를 1로 두었을 때
5
+ * 2배는 영역을 더 늘려서"). 그런데 재생 카드(.audio-player__panel 120px)와 조작 줄 자리는 그대로라
6
+ * 쓸 수 있는 세로가 모자란다 — 가운데 선(y=45)에서 위로 45px 이 카드 윗변, 아래로 37px 이
7
+ * 조작 줄 윗변이다. 그래서 **구간별 눈금**(사용자 결정, 2026-09-13):
8
+ *
9
+ * a ≤ 1 h = a × H1 — 지금과 똑같다(H1 = 45px, slider 50px 의 90%)
10
+ * 1 < a ≤ 2 h = H1 + (a − 1) × (HMAX − H1) — 2배가 정확히 HMAX(74px = 2 × 37)에 닿는다
11
+ *
12
+ * 1~2 구간은 한 칸에 29px 로 기울기가 다르다. 겹침·넘침은 test/waveScale.test.ts 가 CSS 치수로 잰다.
13
+ */
14
+ /** @public 1배 막대 높이(px) — 지금까지의 파형 칸 높이. */
15
+ export const WAVE_H1_PX = 45;
16
+ /** @public 2배 막대 높이(px) — 카드 윗변·조작 줄을 넘지 않는 최대. */
17
+ export const WAVE_HMAX_PX = 74;
18
+ /** @public 눈금 끝(배). */
19
+ export const WAVE_A_MAX = 2;
20
+ /** @public 막대 크기 a(0~2, 넘으면 자름) → 픽셀 높이. */
21
+ export function waveBarPx(a) {
22
+ const x = Math.min(WAVE_A_MAX, Math.max(0, Number(a) || 0));
23
+ return x <= 1 ? x * WAVE_H1_PX : WAVE_H1_PX + (x - 1) * (WAVE_HMAX_PX - WAVE_H1_PX);
24
+ }
25
+ /** @public 막대 `--val` — 1배 높이(H1)에 대한 배율. 막대 ::after 는 H1 높이를 scaleY(--val) 한다. */
26
+ export function waveBarVal(a) {
27
+ return waveBarPx(a) / WAVE_H1_PX;
28
+ }
@@ -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;
@@ -58,6 +58,16 @@ export interface StorageResolvers {
58
58
  * @example 'https://cdn.example.com/sounds/noise'
59
59
  */
60
60
  noiseBaseUrl?: string;
61
+ /**
62
+ * (선택) `noiseBaseUrl` 의 소음 파일이 **원본 소음보다 몇 배 큰지**(진폭). 기본 1.
63
+ *
64
+ * 소음 비율 R 은 원본 파일 기준이라 `<audio>` 볼륨 = min(1, 전체 소리 × R ÷ 이 값) 이다.
65
+ * 볼륨은 1 을 넘을 수 없으므로 원본 파일로는 1배 넘는 소음이 1 에서 멈춘다 — 2배로 키운 파일과
66
+ * `noiseFileGain: 2` 를 함께 주면 0~2배가 정확히 들린다.
67
+ *
68
+ * @example 2
69
+ */
70
+ noiseFileGain?: number;
61
71
  }
62
72
  /**
63
73
  * Storage resolver를 런타임에 주입합니다.
@@ -100,3 +110,8 @@ export declare function downloadSound(filePath: string): Promise<Blob>;
100
110
  * @public
101
111
  */
102
112
  export declare function getNoiseBaseUrl(): string;
113
+ /**
114
+ * 소음 파일이 원본보다 몇 배 큰지 — configureStorage 의 noiseFileGain. 없거나 양수가 아니면 1.
115
+ * @public
116
+ */
117
+ export declare function getNoiseFileGain(): number;
@@ -116,3 +116,11 @@ export async function downloadSound(filePath) {
116
116
  export function getNoiseBaseUrl() {
117
117
  return getResolvers()?.noiseBaseUrl ?? '/sounds/noise';
118
118
  }
119
+ /**
120
+ * 소음 파일이 원본보다 몇 배 큰지 — configureStorage 의 noiseFileGain. 없거나 양수가 아니면 1.
121
+ * @public
122
+ */
123
+ export function getNoiseFileGain() {
124
+ const g = Number(getResolvers()?.noiseFileGain);
125
+ return Number.isFinite(g) && g > 0 ? g : 1;
126
+ }
@@ -1,6 +1,6 @@
1
1
  export { speak, stopSpeaking } from './utils/speak';
2
2
  export { getAssetBaseUrl, resolveAssetUrl, setAssetBaseUrl } from './utils/assets';
3
- export { configureStorage, downloadSound, fileExists, getSignedOrPublicUrl, resolveSoundPath, getNoiseBaseUrl } from './utils/storage';
3
+ export { configureStorage, downloadSound, fileExists, getSignedOrPublicUrl, resolveSoundPath, getNoiseBaseUrl, getNoiseFileGain } from './utils/storage';
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';