@ihabdevteam/core 0.121.0 → 0.123.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.121.0";
1344
+ export declare const CORE_VERSION = "0.123.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;
@@ -118,7 +118,7 @@ export default function WaveForm(props) {
118
118
  return () => { cancelled = true; };
119
119
  }, [noiseSrc, barsTotal, totalDuration, noiseSpeed]);
120
120
  /* eslint-enable react-hooks/set-state-in-effect */
121
- // 문장: 자기 최고치로 정규화해 세로 영역을 다 씀 × 재생 소리(볼륨).
121
+ // 문장: 자기 최고치로 정규화해 세로 영역을 다 씀 × 문제 소리 볼륨(부모가 effectiveSpeechVolume 을 mainVolume 으로 넘김).
122
122
  // 소음은 이 문장 최고치(sentenceMaxPeak)를 공통 분모로 써서 서로 비율이 유지된다.
123
123
  const mainAmp = Math.max(0, Math.min(1, mainVolume));
124
124
  const sentenceMaxPeak = useMemo(() => (wavePeaks && wavePeaks.length) ? (Math.max(...wavePeaks) || 1) : 1, [wavePeaks]);
@@ -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
  }
@@ -5,11 +5,11 @@
5
5
  border-radius(--radius-m, 20px)보다 작아서, Panel의 각진 배경이 Tooltip의 둥근 모서리 안쪽에 그대로
6
6
  보여 모서리가 제대로 표현되지 않았다 — Panel이 tooltip-content 전체를 꽉 채우므로 반지름을 맞춘다. */
7
7
  .noise-settings {
8
- /* 위만 16px — 머리(.tooltip-content__header)의 구분선이 이미 위를 가르므로 20px 이면 첫 줄만
9
- 떠 보였다(말귀 사용자 "여백이 넓어보이네?"). 1280px 에서 잰 눈에 보이는 틈:
10
- 구분선→첫 줄 36px, 줄→줄 33·32px. 16px 에서 32·33·32 로 줄 사이 리듬과 같아진다
11
- (8px 이면 24px 로 오히려 줄 사이보다 좁다). 좌우·아래는 그대로. */
12
- padding: 16px 20px 20px;
8
+ /* 위는 0 — 머리(.tooltip-content__header)의 구분선이 이미 위를 가르고, 첫 줄(.panel-row)이
9
+ 제 높이 가운데에 내용을 세우므로 패널 위 안쪽 여백이 겹치면 첫 줄만 떠 보인다.
10
+ 20px(말귀 사용자 "여백이 넓어보이네?") → 16px(0.119.0, 줄 사이 리듬에 맞춤) 뒤에도 떠 보인다는
11
+ 말귀 사용자 판단으로 0(0.122.0). 좌우·아래는 그대로. */
12
+ padding: 0 20px 20px;
13
13
  box-sizing: border-box;
14
14
  max-width: calc(100vw - 40px);
15
15
  border-radius: var(--radius-m);
@@ -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 } 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
+ // 실제 두 볼륨 — 전체 소리(큰 쪽 크기)를 소음 비율로 나눈다. 파형 막대도 이 값으로 그린다.
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,5 @@ 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, 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 })] }));
973
1012
  }
@@ -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>;
@@ -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.121.0";
49
+ export declare const CORE_VERSION = "0.123.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.121.0';
49
+ export const CORE_VERSION = '0.123.0';