@ihabdevteam/core 0.123.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.
- package/CHANGELOG.md +22 -0
- package/dist/components.cjs.js +176 -176
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +181 -181
- package/dist/ihabdevteam-core.d.ts +1 -1
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.d.ts +1 -1
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +23 -14
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.css +14 -6
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +5 -3
- package/dist/packages/core/src/components/Training/waveScale.d.ts +23 -0
- package/dist/packages/core/src/components/Training/waveScale.js +28 -0
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- 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.
|
|
1344
|
+
export declare const CORE_VERSION = "0.124.0";
|
|
1345
1345
|
|
|
1346
1346
|
/** @public */
|
|
1347
1347
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
//
|
|
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); 계산 후엔 렌더 높이(
|
|
132
|
-
|
|
133
|
-
const
|
|
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
|
-
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
const
|
|
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 :
|
|
173
|
-
// 렌더 높이(
|
|
174
|
-
const belowMin = waveHeightPx > 0 && eff
|
|
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 };
|
|
@@ -267,13 +267,17 @@
|
|
|
267
267
|
|
|
268
268
|
@keyframes audio-player-spinner { to { transform: rotate(360deg); } }
|
|
269
269
|
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,7 +97,7 @@ 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
|
+
// 실제 두 볼륨 — 전체 소리(큰 쪽 크기)를 소음 비율로 나눈다(소리용). 파형은 M·R 로 따로 그린다.
|
|
101
101
|
const noiseVolume = noisePlayer ? effectiveNoiseVolume(noisePlayer.settings) : 0;
|
|
102
102
|
// 소음을 끈 판(소음 종류 "없음")은 들리지 않는 소음에 문제 소리를 양보하지 않는다 — 전체 소리 그대로.
|
|
103
103
|
const speechVolume = noisePlayer?.settings.enabled ? effectiveSpeechVolume(noisePlayer.settings) : mainVolume;
|
|
@@ -1008,5 +1008,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1008
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 {
|
|
1009
1009
|
handleJumpTo(idx);
|
|
1010
1010
|
}
|
|
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,
|
|
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 })] }));
|
|
1012
1014
|
}
|
|
@@ -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
|
+
}
|