feat: 글자속도·단어간격·문장간격 독립 조절 + atempo 기본 타임스트레치

- 타임스트레치 우선순위를 ffmpeg atempo(기본) → rubberband → librosa 로 변경
- MeloEngine: 문장 단위로 분리 후 문장 사이 무음(sentence_gap), 단어(어절) 사이
  무음(word_gap)을 타임스트레치와 독립적으로 삽입. speed 는 글자(음소) 속도만 조절
- API(TTSRequest)에 word_gap/sentence_gap 필드 추가, 전 엔진 시그니처 확장
- 프론트엔드에 '글자 속도/단어 간격/문장 간격' 슬라이더 추가, supports 플래그로
  미지원 엔진(supertonic/oute/cosy)에서는 간격 슬라이더 자동 비활성화
This commit is contained in:
claude
2026-08-25 23:54:48 +09:00
parent e16f88e534
commit c0665893a7
8 changed files with 181 additions and 50 deletions

View File

@@ -9,6 +9,10 @@ const els = {
speaker: $("speaker"),
speed: $("speed"),
speedVal: $("speedVal"),
wordGap: $("wordGap"),
wordGapVal: $("wordGapVal"),
sentGap: $("sentGap"),
sentGapVal: $("sentGapVal"),
pitch: $("pitch"),
pitchVal: $("pitchVal"),
generate: $("generate"),
@@ -76,7 +80,9 @@ function updateEngineInfo() {
: '<span class="cpu">CPU</span>';
const supports = eng.supports || {};
const ctrl = [
supports.speed ? "속도" : null,
supports.speed ? "글자속도" : null,
supports.word_gap ? "단어간격" : null,
supports.sentence_gap ? "문장간격" : null,
supports.pitch ? "피치" : null,
]
.filter(Boolean)
@@ -87,9 +93,11 @@ function updateEngineInfo() {
`· ${gpu}` +
(ctrl ? ` · 조절: ${ctrl}` : "");
// 지원하지 않는 컨트롤 비활성화
// 지원하지 않는 컨트롤 비활성화 (플래그가 명시적으로 false면 끔)
els.speed.disabled = supports.speed === false;
els.pitch.disabled = supports.pitch === false;
els.wordGap.disabled = supports.word_gap !== true;
els.sentGap.disabled = supports.sentence_gap !== true;
}
async function loadEngines() {
@@ -159,6 +167,8 @@ async function generate() {
speaker: els.speaker.value,
speed: parseFloat(els.speed.value),
pitch: parseFloat(els.pitch.value),
word_gap: parseFloat(els.wordGap.value),
sentence_gap: parseFloat(els.sentGap.value),
}),
});
if (!res.ok) {
@@ -189,6 +199,12 @@ els.language.addEventListener("change", populateSpeakers);
els.speed.addEventListener("input", () => {
els.speedVal.textContent = parseFloat(els.speed.value).toFixed(2) + "x";
});
els.wordGap.addEventListener("input", () => {
els.wordGapVal.textContent = Math.round(parseFloat(els.wordGap.value) * 1000) + " ms";
});
els.sentGap.addEventListener("input", () => {
els.sentGapVal.textContent = parseFloat(els.sentGap.value).toFixed(2) + " s";
});
els.pitch.addEventListener("input", () => {
const v = parseInt(els.pitch.value, 10);
els.pitchVal.textContent = (v > 0 ? "+" + v : v) + " 반음";

View File

@@ -42,12 +42,28 @@
<div class="sliders">
<div class="slider-row">
<div class="slider-head">
<span>속도</span>
<span>글자 속도</span>
<span class="slider-val" id="speedVal">1.40x</span>
</div>
<input type="range" id="speed" min="0.5" max="2.0" step="0.05" value="1.4" />
<div class="slider-scale"><span>느리게</span><span>빠르게</span></div>
</div>
<div class="slider-row">
<div class="slider-head">
<span>단어 간격</span>
<span class="slider-val" id="wordGapVal">0 ms</span>
</div>
<input type="range" id="wordGap" min="0" max="1.0" step="0.02" value="0" />
<div class="slider-scale"><span>붙여서</span><span>띄어서</span></div>
</div>
<div class="slider-row">
<div class="slider-head">
<span>문장 간격</span>
<span class="slider-val" id="sentGapVal">0.30 s</span>
</div>
<input type="range" id="sentGap" min="0" max="1.5" step="0.05" value="0.3" />
<div class="slider-scale"><span>짧게</span><span>길게</span></div>
</div>
<div class="slider-row">
<div class="slider-head">
<span>피치</span>