fix:TTS 속도 1.5x + Neural2 음성 변경 + 자막 문장 단위 싱크

TTS 개선:
- 음성: ko-KR-Wavenet-A → ko-KR-Neural2-C (남성, 자연스럽고 개성있는 음성)
- 속도: 1.0x → 1.5x (기존 대비 50% 빠르게)
- 피치: 0.0 → 2.0 (더 에너지 있는 톤)

자막 싱크 버그 수정:
- 장면 전체 나레이션을 한 블록으로 표시 → 문장 단위로 분리 표시
- 각 문장 타이밍을 글자 수 비례로 자동 계산
- 문장 분리 로직: 마침표/느낌표/물음표 기준, 폴백으로 쉼표 분리
- 장면 끝 0.3초 여백으로 자연스러운 전환

시나리오 프롬프트:
- 나레이션 문장 길이 규칙 추가 (한 문장 15~25자)
- 반드시 마침표/느낌표/물음표로 문장 구분하도록 명시
- 장면당 글자 수 60~100자로 밀도 향상

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-15 12:59:36 +09:00
parent 1d512e8acc
commit e093c7b7e7
3 changed files with 62 additions and 16 deletions

View File

@@ -184,7 +184,7 @@ public function concatNarrations(array $audioPaths, array $scenes, string $outpu
}
/**
* ASS 자막 파일 생성
* ASS 자막 파일 생성 (한 문장 단위로 분리, 음성 싱크)
*
* @param array $scenes [{scene_number, narration, duration}, ...]
*/
@@ -221,14 +221,42 @@ public function generateAssSubtitle(array $scenes, string $outputPath): string
continue;
}
$startTime = $this->formatAssTime($currentTime);
$endTime = $this->formatAssTime($currentTime + $duration);
// 나레이션을 문장 단위로 분리
$sentences = $this->splitIntoSentences($narration);
// 긴 텍스트는 줄바꿈
$text = $this->wrapText($narration, 12);
$text = str_replace("\n", "\\N", $text);
// 총 글자 수 기준으로 각 문장의 시간 비율 계산
$totalChars = array_sum(array_map('mb_strlen', $sentences));
// 나레이션이 차지하는 시간 (장면 끝에 0.3초 여백)
$activeTime = max($duration - 0.3, $duration * 0.9);
$offset = 0.0;
$ass .= "Dialogue: 0,{$startTime},{$endTime},Default,,0,0,0,,{$text}\n";
foreach ($sentences as $sentence) {
$sentence = trim($sentence);
if (empty($sentence)) {
continue;
}
$ratio = mb_strlen($sentence) / max($totalChars, 1);
$sentDuration = max($activeTime * $ratio, 0.8);
// 장면 경계 초과 방지
if ($offset + $sentDuration > $duration) {
$sentDuration = $duration - $offset;
}
if ($sentDuration <= 0) {
break;
}
$startTime = $this->formatAssTime($currentTime + $offset);
$endTime = $this->formatAssTime($currentTime + $offset + $sentDuration);
$text = $this->wrapText($sentence, 14);
$text = str_replace("\n", "\\N", $text);
$ass .= "Dialogue: 0,{$startTime},{$endTime},Default,,0,0,0,,{$text}\n";
$offset += $sentDuration;
}
$currentTime += $duration;
}
@@ -238,6 +266,22 @@ public function generateAssSubtitle(array $scenes, string $outputPath): string
return $outputPath;
}
/**
* 나레이션 텍스트를 문장 단위로 분리
*/
private function splitIntoSentences(string $text): array
{
// 마침표, 느낌표, 물음표, 물결표 뒤에서 분리
$sentences = preg_split('/(?<=[.!?~。!?])\s*/', $text, -1, PREG_SPLIT_NO_EMPTY);
// 분리 안 되고 30자 이상이면 쉼표에서 분리
if (count($sentences) <= 1 && mb_strlen($text) > 30) {
$sentences = preg_split('/(?<=[,])\s*/', $text, -1, PREG_SPLIT_NO_EMPTY);
}
return ! empty($sentences) ? $sentences : [$text];
}
/**
* 최종 합성: 영상 + 나레이션 + BGM + 자막
*/