@ihabdevteam/core 0.119.0 → 0.121.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.119.0";
1344
+ export declare const CORE_VERSION = "0.121.0";
1345
1345
 
1346
1346
  /** @public */
1347
1347
  declare const coreDefault: Record<string, unknown>;
@@ -1356,11 +1356,19 @@ export default coreDefault;
1356
1356
  export declare type CoreNoiseSettings = {
1357
1357
  enabled: boolean;
1358
1358
  type: CoreNoiseType;
1359
- /** 0–9. 0이면 소음 비활성화. (소음 음원을 +7dB 증폭한 뒤 상한을 4→9로 확장 — 실제 volume = level/9) */
1359
+ /**
1360
+ * 옛 소음 크기 0–9(실제 volume = level/9). **이제 소리는 `noiseRatio` 로 정한다** — 이 값은
1361
+ * `noiseRatio` 가 없는 옛 기록을 옮길 때만 읽는다(`noiseRatioOf`).
1362
+ */
1360
1363
  level: number;
1364
+ /**
1365
+ * 소음 비율 — 문제 소리(`mainVolume`) 대비 배수, 0–2.0(0.1 칸). 1 = 소음과 문제 소리가 같은 크기.
1366
+ * 실제 소음 볼륨 = min(1, mainVolume × noiseRatio). 없으면 `level` 에서 옮긴다.
1367
+ */
1368
+ noiseRatio?: number;
1361
1369
  /** 소음 재생 속도(playbackRate). 0.75–1.25, 기본 1. */
1362
1370
  speed: number;
1363
- /** 문장(메인) 재생 소리(볼륨). 0–1, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
1371
+ /** 전체 소리(= 문제 소리 볼륨). 0–1, 기본 1. 소음은 이 값 × `noiseRatio`. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
1364
1372
  mainVolume: number;
1365
1373
  /** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
1366
1374
  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
- noiseLevel?: number;
12
+ noiseVolume?: number;
13
13
  noiseSpeed?: number;
14
14
  mainVolume?: number;
15
15
  onReadyChange?: (ready: boolean) => void;
@@ -9,7 +9,7 @@ import './TrainingStepAudioPlayer.css';
9
9
  */
10
10
  const MIN_BAR_PX = 4;
11
11
  export default function WaveForm(props) {
12
- const { src, segments, progress = 0, waveProgressRef, noiseSrc, noiseLevel = 0, noiseSpeed = 1, totalDuration = 0, mainVolume = 1, onReadyChange, showSoundWaveForm = true } = props;
12
+ const { src, segments, progress = 0, waveProgressRef, noiseSrc, noiseVolume = 0, noiseSpeed = 1, totalDuration = 0, mainVolume = 1, onReadyChange, showSoundWaveForm = true } = props;
13
13
  const waveformRef = useRef(null);
14
14
  const outerRef = useRef(null);
15
15
  const [wavePeaks, setWavePeaks] = useState(null);
@@ -144,9 +144,9 @@ export default function WaveForm(props) {
144
144
  // 하위 요소들의 CSS 변수(--bar-gap, 기본 3px)로 상속시킨다. 계산은 하되 값을 어디에도
145
145
  // 바인딩하지 않아 항상 하드코딩된 기본값(3px)으로만 그려지던 문제 — 실제로 적용한다.
146
146
  const outerStyle = { '--bar-gap': `${barGap}px` };
147
- // 소음 바 높이 = (절대진폭 / 문장 최고치) × 실제 재생 볼륨(level/9 — TrainingStepAudioPlayer와 동일 공식).
147
+ // 소음 바 높이 = (절대진폭 / 문장 최고치) × 실제 소음 볼륨(effectiveNoiseVolume — 부모가 소리에 쓰는 값 그대로).
148
148
  // 문장과 같은 분모(sentenceMaxPeak)를 써서 문장 대비 소음 비율이 미터와 일치.
149
- const noiseAmp = Math.max(0, Math.min(9, noiseLevel)) / 9;
149
+ const noiseAmp = Math.max(0, Math.min(1, noiseVolume));
150
150
  const noiseReady = !!noisePeaks;
151
151
  // 소음 연산은 문장 연산(barsTotal 확정) 이후에 시작돼 항상 더 늦게 끝난다 — 문장 바가 먼저 나타났다가
152
152
  // 소음 바만 뒤늦게 팝인하는 것을 막기 위해, 최초 등장 시엔 두 계산이 모두 끝난 뒤 함께 드러낸다.
@@ -6,6 +6,32 @@ export declare const LEVEL_MAX = 9;
6
6
  export declare const levelToPct: (level: number) => number;
7
7
  /** @public */
8
8
  export declare const pctToLevel: (pct: number) => number;
9
+ /** @public 소음 비율 상한(배). */
10
+ export declare const NOISE_RATIO_MAX = 2;
11
+ /** @public 소음 비율 칸(배). */
12
+ export declare const NOISE_RATIO_STEP = 0.1;
13
+ /**
14
+ * @public 설정의 소음 비율(배). `noiseRatio` 가 있으면 그것(0~2 로 자름), 없으면 **옛 값에서
15
+ * 들리던 그대로** 옮긴다 — (level/9) ÷ mainVolume 을 0~2 로 자르고 0.1 단위 반올림.
16
+ * mainVolume 이 0 이면 옛 값으로는 비율을 알 수 없어 1.
17
+ */
18
+ export declare function noiseRatioOf(settings: Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
19
+ noiseRatio?: number;
20
+ }): number;
21
+ /** @public 실제 소음 볼륨(0~1) = min(1, mainVolume × noiseRatioOf(settings)). 켜짐 여부는 보지 않는다. */
22
+ export declare function effectiveNoiseVolume(settings: Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
23
+ noiseRatio?: number;
24
+ }): number;
25
+ /**
26
+ * @public 설정에 조각을 덮는다 — `{ ...base, ...patch }` 에 비율 규칙 하나를 더한 것.
27
+ *
28
+ * **조각에 `level` 은 있는데 `noiseRatio` 가 없으면** 그 조각은 옛 모양이다(저장된 옛 기록,
29
+ * 옛 `noiseOverride`). 그대로 덮으면 base 의 `noiseRatio`(기본값 1 등)가 남아 level 을 가려
30
+ * 버리므로, 덮은 결과의 level·mainVolume 으로 비율을 셈해 **그 자리에서 박아 둔다** —
31
+ * 박지 않고 비워 두면 뒤에 전체 소리만 바꿀 때 비율이 따라 변해 소음이 옛날처럼 제자리에 선다.
32
+ * `{ ...DEFAULT_NOISE_SETTINGS, ...saved }` 대신 이것을 쓴다.
33
+ */
34
+ export declare function mergeNoiseSettings(base: CoreNoiseSettings, patch?: Partial<CoreNoiseSettings> | null): CoreNoiseSettings;
9
35
  /** @public */
10
36
  export type NoisePlayerContextValue = {
11
37
  settings: CoreNoiseSettings;
@@ -19,7 +45,7 @@ export type NoisePlayerContextValue = {
19
45
  /** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. */
20
46
  playbackRate: number;
21
47
  setPlaybackRate: (rate: number) => void;
22
- /** 문장(메인) 재생 소리(볼륨). 0–1, 기본 1. */
48
+ /** 전체 소리(= 문제 소리 볼륨). 0–1, 기본 1. 소음은 이 값 × 소음 비율로 따라 움직인다. */
23
49
  mainVolume: number;
24
50
  setMainVolume: (v: number) => void;
25
51
  };
@@ -10,10 +10,57 @@ export const LEVEL_MAX = 9;
10
10
  export const levelToPct = (level) => Math.round((Math.min(LEVEL_MAX, Math.max(0, level)) / LEVEL_MAX) * 100);
11
11
  /** @public */
12
12
  export const pctToLevel = (pct) => (pct / 100) * LEVEL_MAX;
13
+ /* ── 소음 비율 ──────────────────────────────────────────────────────────────
14
+ 전체 소리(`mainVolume` 0~1)가 문제 소리이고, 소음은 그에 대한 **비율**(`noiseRatio` 0~2배)로
15
+ 정한다. 실제 소음 볼륨 = min(1, mainVolume × noiseRatio) — 전체 소리를 줄이면 소음도 따라
16
+ 준다. 예전에는 소음 크기(level/9)가 전체 소리와 따로 움직였다. */
17
+ /** @public 소음 비율 상한(배). */
18
+ export const NOISE_RATIO_MAX = 2;
19
+ /** @public 소음 비율 칸(배). */
20
+ export const NOISE_RATIO_STEP = 0.1;
21
+ const 비율자르기 = (r) => Math.min(NOISE_RATIO_MAX, Math.max(0, r));
22
+ const 한칸반올림 = (r) => Math.round(r * 10) / 10;
23
+ /**
24
+ * @public 설정의 소음 비율(배). `noiseRatio` 가 있으면 그것(0~2 로 자름), 없으면 **옛 값에서
25
+ * 들리던 그대로** 옮긴다 — (level/9) ÷ mainVolume 을 0~2 로 자르고 0.1 단위 반올림.
26
+ * mainVolume 이 0 이면 옛 값으로는 비율을 알 수 없어 1.
27
+ */
28
+ export function noiseRatioOf(settings) {
29
+ const { noiseRatio } = settings;
30
+ if (typeof noiseRatio === 'number' && Number.isFinite(noiseRatio))
31
+ return 비율자르기(noiseRatio);
32
+ const 소음 = Math.min(LEVEL_MAX, Math.max(0, Number(settings.level) || 0)) / LEVEL_MAX;
33
+ const 전체 = Number(settings.mainVolume);
34
+ if (!(전체 > 0))
35
+ return 1;
36
+ return 한칸반올림(비율자르기(소음 / 전체));
37
+ }
38
+ /** @public 실제 소음 볼륨(0~1) = min(1, mainVolume × noiseRatioOf(settings)). 켜짐 여부는 보지 않는다. */
39
+ export function effectiveNoiseVolume(settings) {
40
+ const 전체 = Math.min(1, Math.max(0, Number(settings.mainVolume) || 0));
41
+ return Math.min(1, 전체 * noiseRatioOf(settings));
42
+ }
43
+ /**
44
+ * @public 설정에 조각을 덮는다 — `{ ...base, ...patch }` 에 비율 규칙 하나를 더한 것.
45
+ *
46
+ * **조각에 `level` 은 있는데 `noiseRatio` 가 없으면** 그 조각은 옛 모양이다(저장된 옛 기록,
47
+ * 옛 `noiseOverride`). 그대로 덮으면 base 의 `noiseRatio`(기본값 1 등)가 남아 level 을 가려
48
+ * 버리므로, 덮은 결과의 level·mainVolume 으로 비율을 셈해 **그 자리에서 박아 둔다** —
49
+ * 박지 않고 비워 두면 뒤에 전체 소리만 바꿀 때 비율이 따라 변해 소음이 옛날처럼 제자리에 선다.
50
+ * `{ ...DEFAULT_NOISE_SETTINGS, ...saved }` 대신 이것을 쓴다.
51
+ */
52
+ export function mergeNoiseSettings(base, patch) {
53
+ const merged = { ...base, ...(patch ?? {}) };
54
+ if (patch && 'level' in patch && !(typeof patch.noiseRatio === 'number')) {
55
+ merged.noiseRatio = noiseRatioOf({ level: merged.level, mainVolume: merged.mainVolume });
56
+ }
57
+ return merged;
58
+ }
13
59
  export const DEFAULT_NOISE_SETTINGS = {
14
60
  enabled: true,
15
61
  type: 'defaultNoise',
16
- level: 4.5, // 소음 크기 기본값 50% (levelToPct(4.5) === 50)
62
+ level: 4.5, // 옛 소음 크기 — 이제는 noiseRatio 가 이긴다(옛 기록 호환용으로 남김)
63
+ noiseRatio: 1, // 소음 = 문제 소리와 같은 크기
17
64
  speed: 1,
18
65
  mainVolume: 1,
19
66
  mainSpeed: 1,
@@ -9,10 +9,10 @@ import Slider from '../Slider';
9
9
  import Button from '../Button';
10
10
  import useMediaQuery from '../../hooks/useMediaQuery';
11
11
  import { getNoiseTypeOptions } from '../Tooltip/tooltipConstants';
12
- import { useNoisePlayer, levelToPct, pctToLevel } from './NoisePlayerContext';
12
+ import { useNoisePlayer, noiseRatioOf, DEFAULT_NOISE_SETTINGS, NOISE_RATIO_MAX, NOISE_RATIO_STEP } from './NoisePlayerContext';
13
13
  import './NoiseSettingsTooltip.css';
14
14
  // 소음 종류 세그먼트의 "없음" — 별도 CoreNoiseType이 아니라 enabled=false를 의미(type은 마지막 값 유지).
15
- // enabled/level=0으로 이미 "무음"을 표현하고 있어(다른 곳의 소음 크기 "없음"과 동일 패턴), 새 타입을
15
+ // enabled/소음 비율=0으로 이미 "무음"을 표현하고 있어(다른 곳의 소음 크기 "없음"과 동일 패턴), 새 타입을
16
16
  // 추가하는 대신 세그먼트 선택값만 enabled 여부로 매핑한다.
17
17
  const NONE_KEY = 'none';
18
18
  // 문장(메인) 재생 속도
@@ -34,10 +34,9 @@ export default function NoiseSettingsTooltip({ anchorRect, onClose }) {
34
34
  const isMobile = useMediaQuery('(max-width: 767px)');
35
35
  const sliderSize = isMobile ? 40 : 24;
36
36
  const noisePlayer = useNoisePlayer();
37
- const level = noisePlayer?.settings.level ?? 0;
37
+ const ratio = noiseRatioOf(noisePlayer?.settings ?? DEFAULT_NOISE_SETTINGS);
38
38
  const noiseType = noisePlayer?.settings.type ?? 'defaultNoise';
39
39
  const noiseEnabled = noisePlayer?.settings.enabled ?? true;
40
- const pct = levelToPct(level);
41
40
  const playbackRate = noisePlayer?.playbackRate ?? 1;
42
41
  const mainVolume = noisePlayer?.mainVolume ?? 1;
43
42
  const volPct = Math.round(mainVolume * 100);
@@ -53,13 +52,14 @@ export default function NoiseSettingsTooltip({ anchorRect, onClose }) {
53
52
  }
54
53
  catch (e) { }
55
54
  }, [noisePlayer]);
56
- // 레벨만 반영 — enabled는 건드리지 않는다. 크기를 0으로 내려도 "소음 종류"가
55
+ // 비율만 반영 — enabled는 건드리지 않는다. 비율을 0으로 내려도 "소음 종류"가
57
56
  // "없음"으로 바뀌지 않아야 하므로(사용자가 종류는 유지한 채 잠깐 무음으로만 두려는 경우),
58
57
  // 끄고 켜는 것은 소음 종류 세그먼트(handleTypeChange)의 역할로만 남긴다.
59
- const handleLevelChange = useCallback((nextPct) => {
60
- const nextLevel = pctToLevel(nextPct);
58
+ // 0.1 칸을 부동소수(0.30000000000000004)로 저장하지 않게 한 칸 단위로 반올림한다.
59
+ const handleRatioChange = useCallback((next) => {
60
+ const r = Math.min(NOISE_RATIO_MAX, Math.max(0, Math.round(next * 10) / 10));
61
61
  try {
62
- noisePlayer?.setSettings({ level: nextLevel });
62
+ noisePlayer?.setSettings({ noiseRatio: r });
63
63
  }
64
64
  catch (e) { }
65
65
  }, [noisePlayer]);
@@ -74,8 +74,8 @@ export default function NoiseSettingsTooltip({ anchorRect, onClose }) {
74
74
  }
75
75
  catch (e) { }
76
76
  }, [noisePlayer]);
77
- return (_jsx(Tooltip, { anchorRect: anchorRect, offset: 12, onClose: onClose, ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), isMobilePopup: false, closeOnOutsideClick: false, header: _jsxs("div", { className: "noise-settings__header", children: [_jsx("span", { children: t('training.trainingSettingsTitle', '훈련 설정') }), _jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, ariaLabel: t('common.close', '닫기'), onClick: onClose })] }), children: _jsxs(Panel, { className: "noise-settings", useBorder: false, labelWidth: 72, children: [_jsx(SliderPanelRow, { title: t('training.playVolumeTitle', '재생 소리'), valueLabel: `${volPct}%`, value: volPct, min: 0, max: 100, step: 10, size: sliderSize, onChange: handlePlayVolumeChange }), _jsx(SliderPanelRow, { title: t('training.playSpeedTitle', '재생 속도'), valueLabel: `${playbackRate.toFixed(2)}x`, value: playbackRate, min: PLAY_SPEED_MIN, max: PLAY_SPEED_MAX, step: PLAY_SPEED_STEP, size: sliderSize, onChange: handlePlaySpeedChange }), _jsx(SliderPanelRow, { title: t('training.noiseLevelTitle', '소음 크기'), valueLabel: `${pct}%`, value: pct, min: 0, max: 100, step: 10, size: sliderSize, onChange: handleLevelChange }), _jsx(PanelRow, { variant: "custom", label: t('training.noiseTypeTitle', '소음 종류'), useFullWidth: true, children: _jsx(SegmentedControl, { ariaLabel: t('training.noiseTypeTitle', '소음 종류'), size: 40, shape: "rounded", options: [
77
+ return (_jsx(Tooltip, { anchorRect: anchorRect, offset: 12, onClose: onClose, ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), isMobilePopup: false, closeOnOutsideClick: false, header: _jsxs("div", { className: "noise-settings__header", children: [_jsx("span", { children: t('training.trainingSettingsTitle', '훈련 설정') }), _jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, ariaLabel: t('common.close', '닫기'), onClick: onClose })] }), children: _jsxs(Panel, { className: "noise-settings", useBorder: false, labelWidth: 72, children: [_jsx(SliderPanelRow, { title: t('training.playVolumeTitle', '전체 소리'), valueLabel: `${volPct}%`, value: volPct, min: 0, max: 100, step: 10, size: sliderSize, onChange: handlePlayVolumeChange }), _jsx(SliderPanelRow, { title: t('training.playSpeedTitle', '재생 속도'), valueLabel: `${playbackRate.toFixed(2)}x`, value: playbackRate, min: PLAY_SPEED_MIN, max: PLAY_SPEED_MAX, step: PLAY_SPEED_STEP, size: sliderSize, onChange: handlePlaySpeedChange }), _jsx(PanelRow, { variant: "custom", label: t('training.noiseTypeTitle', '소음 종류'), useFullWidth: true, children: _jsx(SegmentedControl, { ariaLabel: t('training.noiseTypeTitle', '소음 종류'), size: 40, shape: "rounded", options: [
78
78
  { key: NONE_KEY, label: t('training.noise.type.none', '없음') },
79
79
  ...getNoiseTypeOptions(t).map(opt => ({ key: opt.value, label: opt.label })),
80
- ], value: noiseEnabled ? noiseType : NONE_KEY, onChange: handleTypeChange }) })] }) }));
80
+ ], value: noiseEnabled ? noiseType : NONE_KEY, onChange: handleTypeChange }) }), _jsx(SliderPanelRow, { title: t('training.noiseRatioTitle', '소음 비율'), valueLabel: t('training.noiseRatioValue', '{{ratio}}배', { ratio: ratio.toFixed(1) }), value: ratio, min: 0, max: NOISE_RATIO_MAX, step: NOISE_RATIO_STEP, size: sliderSize, onChange: handleRatioChange })] }) }));
81
81
  }
@@ -16,7 +16,7 @@ import TrainingStepListen from './TrainingStepListen';
16
16
  import TrainingStepOrderList from './TrainingStepOrderList';
17
17
  import { getPayloadTimestamp } from './trainingPopupUtils';
18
18
  import { TrainingPagePopupContext } from './TrainingPagePopupContext';
19
- import { NoisePlayerContext, DEFAULT_NOISE_SETTINGS } from './NoisePlayerContext';
19
+ import { NoisePlayerContext, DEFAULT_NOISE_SETTINGS, mergeNoiseSettings } from './NoisePlayerContext';
20
20
  import { lockBodyScrollWithPadding } from '../../hooks/bodyScrollLock';
21
21
  import { Page, ListenStep, ConfirmStep, ConfirmWithTextStep, OrderListStep, CardStep, CardOrderStep } from './TrainingStepComponents';
22
22
  import TrainingResultPopup from '../Popup/TrainingResultPopup';
@@ -129,7 +129,8 @@ function TrainingPagePopupInner(props) {
129
129
  if (cancelled)
130
130
  return;
131
131
  // 저장된 설정이 있으면 병합, 없으면 진짜 기본값으로 확정 — 어느 쪽이든 이제부터 재생 허용.
132
- const merged = saved ? { ...noiseSettingsRef.current, ...saved } : { ...DEFAULT_NOISE_SETTINGS };
132
+ // mergeNoiseSettings — noiseRatio 가 없는 옛 기록은 기본값 비율(1)에 가리지 않고 level 에서 옮긴다.
133
+ const merged = saved ? mergeNoiseSettings(noiseSettingsRef.current, saved) : { ...DEFAULT_NOISE_SETTINGS };
133
134
  noiseSettingsRef.current = merged;
134
135
  setNoisePart(merged);
135
136
  reportAppliedNoiseSettings(merged);
@@ -137,7 +138,7 @@ function TrainingPagePopupInner(props) {
137
138
  const override = noiseOverrideRef.current;
138
139
  if (override) {
139
140
  const patch = typeof override === 'function' ? override(merged) : override;
140
- const applied = { ...merged, ...patch };
141
+ const applied = mergeNoiseSettings(merged, patch);
141
142
  noiseSettingsRef.current = applied;
142
143
  setNoisePart(applied);
143
144
  reportAppliedNoiseSettings(applied);
@@ -156,7 +157,7 @@ function TrainingPagePopupInner(props) {
156
157
  }, []);
157
158
  // noiseSettings에 병합 + 영속화(fire-and-forget) — setSettings/setPlaybackRate/setMainVolume가 공유
158
159
  const mergeAndSaveSettings = useCallback((next) => {
159
- const merged = { ...noiseSettingsRef.current, ...next };
160
+ const merged = mergeNoiseSettings(noiseSettingsRef.current, next);
160
161
  noiseSettingsRef.current = merged;
161
162
  setNoisePart(merged);
162
163
  // noiseOverride 판은 저장하지 않는다 — 판 중에 바꾼 것도 이 판에만.
@@ -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 } from './NoisePlayerContext';
10
+ import { useNoisePlayer, effectiveNoiseVolume } from './NoisePlayerContext';
11
11
  import { useNoiseFade } from './useNoiseFade';
12
12
  import { useTTSPlayback } from './useTTSPlayback';
13
13
  import './TrainingStepAudioPlayer.css';
@@ -97,6 +97,8 @@ 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). 파형의 소음 막대도 이 값으로 그린다.
101
+ const noiseVolume = noisePlayer ? effectiveNoiseVolume(noisePlayer.settings) : 0;
100
102
  const [fillCompletionPhase, setFillCompletionPhase] = useState('idle');
101
103
  const [noticeMessage, setNoticeMessage] = useState("");
102
104
  const [noticePosition] = useState("top");
@@ -207,8 +209,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
207
209
  catch (e) { }
208
210
  // 소음 동기 시작
209
211
  const ns = noiseSettingsRef.current;
210
- if (ns?.enabled && (ns?.level ?? 0) > 0) {
211
- fadeInNoise(ns.level / 9);
212
+ if (ns?.enabled && (ns?.volume ?? 0) > 0) {
213
+ fadeInNoise(ns.volume);
212
214
  }
213
215
  };
214
216
  const onPause = () => {
@@ -967,5 +969,5 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
967
969
  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 {
968
970
  handleJumpTo(idx);
969
971
  }
970
- 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 && (noisePlayer?.settings.level ?? 0) > 0) ? activeNoiseUrl : null, noiseLevel: noisePlayer?.settings.level ?? 0, 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 })] }));
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 })] }));
971
973
  }
@@ -1,5 +1,5 @@
1
1
  import { type RefObject } from 'react';
2
- import type { NoisePlayerContextValue } from './NoisePlayerContext';
2
+ import { type NoisePlayerContextValue } from './NoisePlayerContext';
3
3
  type PlaybackState = 'stopped' | 'playing' | 'paused';
4
4
  /**
5
5
  * TrainingStepAudioPlayer에서 분리 — 소음(noise) 오디오 엘리먼트 생성/페이드인/페이드아웃/워치독을
@@ -14,7 +14,7 @@ export declare function useNoiseFade({ noisePlayer, audioRef, playbackStateRef,
14
14
  activeNoiseUrl: string | null;
15
15
  noiseSettingsRef: RefObject<{
16
16
  enabled: boolean;
17
- level: number;
17
+ volume: number;
18
18
  type: string;
19
19
  } | null>;
20
20
  cancelNoiseFade: () => void;
@@ -2,6 +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
6
  function generateNoiseUrl(type) {
6
7
  const rand = Math.floor(Math.random() * 10);
7
8
  switch (type) {
@@ -23,6 +24,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
23
24
  // 실제 로드된(재생되는) 소음 파일 URL — waveform이 화면=청취 일치하도록 이 src를 그린다.
24
25
  const [activeNoiseUrl, setActiveNoiseUrl] = useState(null);
25
26
  const noiseTypeLoadedRef = useRef(null);
27
+ // volume = 실제 소음 볼륨(effectiveNoiseVolume — 전체 소리 × 소음 비율, 상한 1)
26
28
  const noiseSettingsRef = useRef(null);
27
29
  const noiseFadeRafRef = useRef(null);
28
30
  // 페이드 세대 토큰 — 하드스톱 타이머가 발화 시점에 "그 사이 새 페이드가 시작됐는지"를
@@ -155,10 +157,11 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
155
157
  // 개별 레이스를 전부 틀어막는 대신, 잘못된 상태를 주기적으로 감지해 회수한다.
156
158
  // 두 번 연속(약 0.8초) 확인 후에 정지 — 멀티소스 트랙 전환처럼 메인이 잠깐 멈추는
157
159
  // 정상 구간에서 소음이 출렁이지 않게 하기 위한 유예다.
158
- const noiseInUse = !!noisePlayer?.settings.enabled && (noisePlayer?.settings.level ?? 0) > 0;
160
+ const noiseVolume = noisePlayer ? effectiveNoiseVolume(noisePlayer.settings) : 0;
161
+ const noiseInUse = !!noisePlayer?.settings.enabled && noiseVolume > 0;
159
162
  useEffect(() => {
160
163
  // 소음이 이번 재생 세션에서 아예 꺼져 있으면(noiseInUse=false) noiseEl은 절대
161
- // 재생되지 않으므로(모든 fadeInNoise 호출부가 동일한 enabled/level 조건으로 감싸져
164
+ // 재생되지 않으므로(모든 fadeInNoise 호출부가 동일한 enabled/volume 조건으로 감싸져
162
165
  // 있음) 이 감시 자체가 no-op이다 — 타이머를 아예 돌리지 않는다.
163
166
  if (!noiseInUse)
164
167
  return;
@@ -208,20 +211,20 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
208
211
  // noiseSettingsRef 최신화 (재생 이벤트 핸들러 클로저에서 사용)
209
212
  useEffect(() => {
210
213
  noiseSettingsRef.current = noisePlayer
211
- ? { enabled: noisePlayer.settings.enabled, level: noisePlayer.settings.level, type: noisePlayer.settings.type }
214
+ ? { enabled: noisePlayer.settings.enabled, volume: noiseVolume, type: noisePlayer.settings.type }
212
215
  : null;
213
- }, [noisePlayer, noisePlayer?.settings.enabled, noisePlayer?.settings.level, noisePlayer?.settings.type]);
216
+ }, [noisePlayer, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type]);
214
217
  // 소음 설정 변경 → 오디오 반영
215
218
  useEffect(() => {
216
219
  const ns = noisePlayer?.settings;
217
220
  const noiseEl = noiseAudioRef.current;
218
221
  if (!noiseEl)
219
222
  return;
220
- if (!ns || !ns.enabled || ns.level === 0) {
223
+ const volume = ns ? effectiveNoiseVolume(ns) : 0;
224
+ if (!ns || !ns.enabled || volume === 0) {
221
225
  fadeOutNoise();
222
226
  return;
223
227
  }
224
- const volume = Math.min(1, ns.level / 9);
225
228
  if (noiseTypeLoadedRef.current !== ns.type) {
226
229
  // 타입 변경: 새 파일 로드
227
230
  const url = generateNoiseUrl(ns.type);
@@ -246,7 +249,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
246
249
  }
247
250
  }
248
251
  else {
249
- // 레벨만 변경(또는 mainVolume/mainSpeed 변경으로 settings 객체 참조만 바뀐 경우):
252
+ // 볼륨만 변경(소음 비율 또는 전체 소리 — 둘 다 소음 볼륨을 움직인다. mainSpeed 변경으로 settings 객체 참조만 바뀐 경우도):
250
253
  // 볼륨 반영은 메인이 실제로 재생 중일 때만. 재생 소리/속도가 같은 noiseSettings 객체에
251
254
  // 저장되면서, 문장 재생이 끝난 뒤 이 슬라이더만 바꿔도 이 useEffect가 재실행되는데
252
255
  // — 그때 noiseEl이 아직(레이스 등으로) paused가 아니면 볼륨만 올려버려 소음이 계속
@@ -281,7 +284,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
281
284
  }
282
285
  }
283
286
  }
284
- }, [noisePlayer?.settings, noisePlayer?.settings.enabled, noisePlayer?.settings.level, noisePlayer?.settings.type, cancelNoiseFade, fadeInNoise, fadeOutNoise, audioRef]);
287
+ }, [noisePlayer?.settings, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type, cancelNoiseFade, fadeInNoise, fadeOutNoise, audioRef]);
285
288
  // 소음 재생 속도(playbackRate) 반영 — 0.75~1.25. 엘리먼트 속성이라 src 변경에도 유지됨
286
289
  useEffect(() => {
287
290
  const el = noiseAudioRef.current;
@@ -18,7 +18,7 @@ export declare function useTTSPlayback({ ttsText, playbackRate, mainVolume, fill
18
18
  fadeOutNoise: (durationMs?: number, onComplete?: () => void) => void;
19
19
  noiseSettingsRef: RefObject<{
20
20
  enabled: boolean;
21
- level: number;
21
+ volume: number;
22
22
  type: string;
23
23
  } | null>;
24
24
  onDoneRef: RefObject<((ok?: boolean) => void) | undefined>;
@@ -75,8 +75,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
75
75
  setStateLocal('playMode');
76
76
  // TTS 시작 시 소음 동기 재생
77
77
  const ns = noiseSettingsRef.current;
78
- if (ns?.enabled && (ns?.level ?? 0) > 0) {
79
- fadeInNoise(ns.level / 9);
78
+ if (ns?.enabled && (ns?.volume ?? 0) > 0) {
79
+ fadeInNoise(ns.volume);
80
80
  }
81
81
  const tick = () => {
82
82
  try {
@@ -333,7 +333,8 @@
333
333
  "countSentences": "{{n}} sentences",
334
334
  "comingSoon": "Coming Soon",
335
335
  "nextQuestion": "Next",
336
- "noiseLevelTitle": "Noise Level",
336
+ "noiseRatioTitle": "Noise Ratio",
337
+ "noiseRatioValue": "{{ratio}}×",
337
338
  "noiseTypeTitle": "Noise Type",
338
339
  "playVolumeTitle": "Volume",
339
340
  "playSpeedTitle": "Speed",
@@ -331,9 +331,10 @@
331
331
  "countSentences": "{{n}}문장",
332
332
  "comingSoon": "준비 중",
333
333
  "nextQuestion": "다음 문제",
334
- "noiseLevelTitle": "소음 크기",
334
+ "noiseRatioTitle": "소음 비율",
335
+ "noiseRatioValue": "{{ratio}}배",
335
336
  "noiseTypeTitle": "소음 종류",
336
- "playVolumeTitle": "재생 소리",
337
+ "playVolumeTitle": "전체 소리",
337
338
  "playSpeedTitle": "재생 속도",
338
339
  "meter": {
339
340
  "sentence": "재생",
@@ -9,11 +9,19 @@ export type CoreNoiseType = 'defaultNoise' | 'street' | 'restaurant' | 'buzz';
9
9
  export type CoreNoiseSettings = {
10
10
  enabled: boolean;
11
11
  type: CoreNoiseType;
12
- /** 0–9. 0이면 소음 비활성화. (소음 음원을 +7dB 증폭한 뒤 상한을 4→9로 확장 — 실제 volume = level/9) */
12
+ /**
13
+ * 옛 소음 크기 0–9(실제 volume = level/9). **이제 소리는 `noiseRatio` 로 정한다** — 이 값은
14
+ * `noiseRatio` 가 없는 옛 기록을 옮길 때만 읽는다(`noiseRatioOf`).
15
+ */
13
16
  level: number;
17
+ /**
18
+ * 소음 비율 — 문제 소리(`mainVolume`) 대비 배수, 0–2.0(0.1 칸). 1 = 소음과 문제 소리가 같은 크기.
19
+ * 실제 소음 볼륨 = min(1, mainVolume × noiseRatio). 없으면 `level` 에서 옮긴다.
20
+ */
21
+ noiseRatio?: number;
14
22
  /** 소음 재생 속도(playbackRate). 0.75–1.25, 기본 1. */
15
23
  speed: number;
16
- /** 문장(메인) 재생 소리(볼륨). 0–1, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
24
+ /** 전체 소리(= 문제 소리 볼륨). 0–1, 기본 1. 소음은 이 값 × `noiseRatio`. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
17
25
  mainVolume: number;
18
26
  /** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
19
27
  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.119.0";
49
+ export declare const CORE_VERSION = "0.121.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.119.0';
49
+ export const CORE_VERSION = '0.121.0';