@ihabdevteam/core 0.122.0 → 0.124.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.122.0";
1344
+ export declare const CORE_VERSION = "0.124.0";
1345
1345
 
1346
1346
  /** @public */
1347
1347
  declare const coreDefault: Record<string, unknown>;
@@ -1362,13 +1362,14 @@ export declare type CoreNoiseSettings = {
1362
1362
  */
1363
1363
  level: number;
1364
1364
  /**
1365
- * 소음 비율 — 문제 소리(`mainVolume`) 대비 배수, 0–2.0(0.1 칸). 1 = 소음과 문제 소리가 같은 크기.
1366
- * 실제 소음 볼륨 = min(1, mainVolume × noiseRatio). 없으면 `level` 에서 옮긴다.
1365
+ * 소음 비율 — 문제 소리 대비 소음의 배수, 0–2.0(0.1 칸). 1 = 소음과 문제 소리가 같은 크기.
1366
+ * 문제 소리 = mainVolume × min(1, 1/noiseRatio), 소음 = mainVolume × min(1, noiseRatio).
1367
+ * 없으면 `level` 에서 옮긴다.
1367
1368
  */
1368
1369
  noiseRatio?: number;
1369
1370
  /** 소음 재생 속도(playbackRate). 0.75–1.25, 기본 1. */
1370
1371
  speed: number;
1371
- /** 전체 소리(= 문제 소리 볼륨). 0–1, 기본 1. 소음은 이 값 × `noiseRatio`. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
1372
+ /** 전체 소리 — 두 소리 중 큰 쪽의 크기. 0–1, 기본 1. 실제 두 볼륨은 `noiseRatio` 로 나뉜다. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
1372
1373
  mainVolume: number;
1373
1374
  /** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
1374
1375
  mainSpeed: number;
@@ -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
- // 문장: 자기 최고치로 정규화해 세로 영역을 다 씀 × 재생 소리(볼륨).
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 };
@@ -18,10 +18,13 @@ export declare const NOISE_RATIO_STEP = 0.1;
18
18
  export declare function noiseRatioOf(settings: Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
19
19
  noiseRatio?: number;
20
20
  }): number;
21
- /** @public 실제 소음 볼륨(0~1) = min(1, mainVolume × noiseRatioOf(settings)). 켜짐 여부는 보지 않는다. */
22
- export declare function effectiveNoiseVolume(settings: Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
21
+ type 비율설정 = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
23
22
  noiseRatio?: number;
24
- }): number;
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. */
27
+ export declare function effectiveSpeechVolume(settings: 비율설정): number;
25
28
  /**
26
29
  * @public 설정에 조각을 덮는다 — `{ ...base, ...patch }` 에 비율 규칙 하나를 더한 것.
27
30
  *
@@ -29,6 +32,8 @@ export declare function effectiveNoiseVolume(settings: Pick<CoreNoiseSettings, '
29
32
  * 옛 `noiseOverride`). 그대로 덮으면 base 의 `noiseRatio`(기본값 1 등)가 남아 level 을 가려
30
33
  * 버리므로, 덮은 결과의 level·mainVolume 으로 비율을 셈해 **그 자리에서 박아 둔다** —
31
34
  * 박지 않고 비워 두면 뒤에 전체 소리만 바꿀 때 비율이 따라 변해 소음이 옛날처럼 제자리에 선다.
35
+ * 비율이 1 을 넘으면 소음이 큰 쪽이므로 `mainVolume` 도 level/9 로 함께 박는다(옛날에 들리던
36
+ * 두 크기 그대로 — 예: level 9·mainVolume 0.5 → 비율 2·mainVolume 1 = 소음 1·문제 0.5).
32
37
  * `{ ...DEFAULT_NOISE_SETTINGS, ...saved }` 대신 이것을 쓴다.
33
38
  */
34
39
  export declare function mergeNoiseSettings(base: CoreNoiseSettings, patch?: Partial<CoreNoiseSettings> | null): CoreNoiseSettings;
@@ -45,7 +50,7 @@ export type NoisePlayerContextValue = {
45
50
  /** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. */
46
51
  playbackRate: number;
47
52
  setPlaybackRate: (rate: number) => void;
48
- /** 전체 소리(= 문제 소리 볼륨). 0–1, 기본 1. 소음은 이 값 × 소음 비율로 따라 움직인다. */
53
+ /** 전체 소리 — 두 소리(문제 소리·소음) 중 큰 쪽의 크기. 0–1, 기본 1. 실제 볼륨은 effectiveSpeechVolume·effectiveNoiseVolume. */
49
54
  mainVolume: number;
50
55
  setMainVolume: (v: number) => void;
51
56
  };
@@ -11,9 +11,15 @@ export const levelToPct = (level) => Math.round((Math.min(LEVEL_MAX, Math.max(0,
11
11
  /** @public */
12
12
  export const pctToLevel = (pct) => (pct / 100) * LEVEL_MAX;
13
13
  /* ── 소음 비율 ──────────────────────────────────────────────────────────────
14
- 전체 소리(`mainVolume` 0~1)가 문제 소리이고, 소음은 그에 대한 **비율**(`noiseRatio` 0~2배)로
15
- 정한다. 실제 소음 볼륨 = min(1, mainVolume × noiseRatio) — 전체 소리를 줄이면 소음도 따라
16
- 준다. 예전에는 소음 크기(level/9)가 전체 소리와 따로 움직였다. */
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)가 전체 소리와 따로 움직였다. */
17
23
  /** @public 소음 비율 상한(배). */
18
24
  export const NOISE_RATIO_MAX = 2;
19
25
  /** @public 소음 비율 칸(배). */
@@ -35,10 +41,29 @@ export function noiseRatioOf(settings) {
35
41
  return 1;
36
42
  return 한칸반올림(비율자르기(소음 / 전체));
37
43
  }
38
- /** @public 실제 소음 볼륨(0~1) = min(1, mainVolume × noiseRatioOf(settings)). 켜짐 여부는 보지 않는다. */
39
- export function effectiveNoiseVolume(settings) {
44
+ /**
45
+ * 전체 소리와 비율을 확정한다. `noiseRatio` 가 없는 옛 기록은 옛 뜻(문제 소리 = mainVolume,
46
+ * 소음 = level/9)이 **들리던 크기 그대로** 남도록 옮긴다 — R = noiseRatioOf, 그리고 R > 1 이면
47
+ * 소음이 큰 쪽이므로 전체 소리 = level/9.
48
+ */
49
+ function 확정(settings) {
40
50
  const 전체 = Math.min(1, Math.max(0, Number(settings.mainVolume) || 0));
41
- return Math.min(1, 전체 * noiseRatioOf(settings));
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
+ export function effectiveNoiseVolume(settings) {
60
+ const { mainVolume, noiseRatio } = 확정(settings);
61
+ return mainVolume * Math.min(1, noiseRatio);
62
+ }
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;
42
67
  }
43
68
  /**
44
69
  * @public 설정에 조각을 덮는다 — `{ ...base, ...patch }` 에 비율 규칙 하나를 더한 것.
@@ -47,12 +72,16 @@ export function effectiveNoiseVolume(settings) {
47
72
  * 옛 `noiseOverride`). 그대로 덮으면 base 의 `noiseRatio`(기본값 1 등)가 남아 level 을 가려
48
73
  * 버리므로, 덮은 결과의 level·mainVolume 으로 비율을 셈해 **그 자리에서 박아 둔다** —
49
74
  * 박지 않고 비워 두면 뒤에 전체 소리만 바꿀 때 비율이 따라 변해 소음이 옛날처럼 제자리에 선다.
75
+ * 비율이 1 을 넘으면 소음이 큰 쪽이므로 `mainVolume` 도 level/9 로 함께 박는다(옛날에 들리던
76
+ * 두 크기 그대로 — 예: level 9·mainVolume 0.5 → 비율 2·mainVolume 1 = 소음 1·문제 0.5).
50
77
  * `{ ...DEFAULT_NOISE_SETTINGS, ...saved }` 대신 이것을 쓴다.
51
78
  */
52
79
  export function mergeNoiseSettings(base, patch) {
53
80
  const merged = { ...base, ...(patch ?? {}) };
54
81
  if (patch && 'level' in patch && !(typeof patch.noiseRatio === 'number')) {
55
- merged.noiseRatio = noiseRatioOf({ level: merged.level, mainVolume: merged.mainVolume });
82
+ const 박을것 = 확정({ level: merged.level, mainVolume: merged.mainVolume });
83
+ merged.noiseRatio = 박을것.noiseRatio;
84
+ merged.mainVolume = 박을것.mainVolume;
56
85
  }
57
86
  return merged;
58
87
  }
@@ -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 } 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,8 +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
- // 실제 소음 볼륨 — 전체 소리 × 소음 비율(상한 1). 파형의 소음 막대도 이 값으로 그린다.
100
+ // 실제 두 볼륨 — 전체 소리(큰 쪽 크기)를 소음 비율로 나눈다(소리용). 파형은 M·R 로 따로 그린다.
101
101
  const noiseVolume = noisePlayer ? effectiveNoiseVolume(noisePlayer.settings) : 0;
102
+ // 소음을 끈 판(소음 종류 "없음")은 들리지 않는 소음에 문제 소리를 양보하지 않는다 — 전체 소리 그대로.
103
+ const speechVolume = noisePlayer?.settings.enabled ? effectiveSpeechVolume(noisePlayer.settings) : mainVolume;
102
104
  const [fillCompletionPhase, setFillCompletionPhase] = useState('idle');
103
105
  const [noticeMessage, setNoticeMessage] = useState("");
104
106
  const [noticePosition] = useState("top");
@@ -141,7 +143,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
141
143
  const { startTTS, stopTTS } = useTTSPlayback({
142
144
  ttsText,
143
145
  playbackRate,
144
- mainVolume,
146
+ // TTS 음성 = 문제 소리 — 전체 소리가 아니라 비율로 나뉜 문제 소리 볼륨
147
+ mainVolume: speechVolume,
145
148
  fillRef,
146
149
  waveProgressRef,
147
150
  setProgress,
@@ -947,15 +950,51 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
947
950
  }
948
951
  catch (e) { }
949
952
  }, [playbackRate]);
950
- // 문장(메인) 볼륨 반영
953
+ // 문장(메인) 볼륨 반영 — 문제 소리 볼륨(effectiveSpeechVolume). 전체 소리·소음 비율 어느 쪽을
954
+ // 바꿔도 달라진다. 재생 중이면 소음과 같은 150ms 램프로 옮기고, 멈춰 있으면 바로 건다.
955
+ // 백그라운드 탭에서는 RAF 가 멈춰 램프가 중간에 설 수 있으므로 끝값을 타이머로 한 번 더 박는다.
956
+ const speechRampRef = useRef({ raf: null, timer: null });
951
957
  useEffect(() => {
952
- try {
953
- const a = audioRef.current;
954
- if (a)
955
- a.volume = Math.max(0, Math.min(1, mainVolume));
958
+ const a = audioRef.current;
959
+ if (!a)
960
+ return;
961
+ const target = Math.max(0, Math.min(1, speechVolume));
962
+ const ramp = speechRampRef.current;
963
+ const 끊기 = () => {
964
+ if (ramp.raf !== null) {
965
+ cancelAnimationFrame(ramp.raf);
966
+ ramp.raf = null;
967
+ }
968
+ if (ramp.timer !== null) {
969
+ clearTimeout(ramp.timer);
970
+ ramp.timer = null;
971
+ }
972
+ };
973
+ 끊기();
974
+ const from = a.volume;
975
+ if (a.paused || Math.abs(from - target) < 0.001 || typeof requestAnimationFrame !== 'function') {
976
+ try {
977
+ a.volume = target;
978
+ }
979
+ catch (e) { }
980
+ return;
956
981
  }
957
- catch (e) { }
958
- }, [mainVolume]);
982
+ const start = performance.now();
983
+ const step = () => {
984
+ const t = Math.min(1, (performance.now() - start) / 150);
985
+ try {
986
+ a.volume = from + (target - from) * t;
987
+ }
988
+ catch (e) { }
989
+ ramp.raf = t < 1 ? requestAnimationFrame(step) : null;
990
+ };
991
+ ramp.raf = requestAnimationFrame(step);
992
+ ramp.timer = setTimeout(() => { 끊기(); try {
993
+ a.volume = target;
994
+ }
995
+ catch (e) { } }, 200);
996
+ return 끊기;
997
+ }, [speechVolume]);
959
998
  // fill (진행 마커) 클래스 및 초기 위치 계산 — RAF 루프에서 .style.left를 직접 갱신합니다.
960
999
  const isFillHolding = fillCompletionPhase === 'hold';
961
1000
  const isFillOutAnimating = fillCompletionPhase === 'out';
@@ -969,5 +1008,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
969
1008
  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 {
970
1009
  handleJumpTo(idx);
971
1010
  }
972
- 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: 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 })] }));
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, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
1012
+ /* 그림은 "문장 = 기준" — 문제 소리(M÷R)가 아니라 전체 소리 M 으로 문장을 그리고 소음을 R 배로 키운다 */
1013
+ 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 })] }));
973
1014
  }
@@ -8,6 +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
12
  mainVolume: number;
12
13
  fillRef: RefObject<HTMLDivElement | null>;
13
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
+ }
@@ -15,13 +15,14 @@ export type CoreNoiseSettings = {
15
15
  */
16
16
  level: number;
17
17
  /**
18
- * 소음 비율 — 문제 소리(`mainVolume`) 대비 배수, 0–2.0(0.1 칸). 1 = 소음과 문제 소리가 같은 크기.
19
- * 실제 소음 볼륨 = min(1, mainVolume × noiseRatio). 없으면 `level` 에서 옮긴다.
18
+ * 소음 비율 — 문제 소리 대비 소음의 배수, 0–2.0(0.1 칸). 1 = 소음과 문제 소리가 같은 크기.
19
+ * 문제 소리 = mainVolume × min(1, 1/noiseRatio), 소음 = mainVolume × min(1, noiseRatio).
20
+ * 없으면 `level` 에서 옮긴다.
20
21
  */
21
22
  noiseRatio?: number;
22
23
  /** 소음 재생 속도(playbackRate). 0.75–1.25, 기본 1. */
23
24
  speed: number;
24
- /** 전체 소리(= 문제 소리 볼륨). 0–1, 기본 1. 소음은 이 값 × `noiseRatio`. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
25
+ /** 전체 소리 — 두 소리 중 큰 쪽의 크기. 0–1, 기본 1. 실제 두 볼륨은 `noiseRatio` 로 나뉜다. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
25
26
  mainVolume: number;
26
27
  /** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
27
28
  mainSpeed: number;
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.122.0";
49
+ export declare const CORE_VERSION = "0.124.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.122.0';
49
+ export const CORE_VERSION = '0.124.0';