From c002489cb597ff56fb461fde673c433d6455507e Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 9 Sep 2026 18:25:08 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=B2=A0=ED=95=99=EC=9E=90-?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EC=8A=A4=20=EB=A7=A4=ED=95=91=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=EC=99=80=20=EC=8B=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/db/20260910_seed_philosopher_voice.sql | 72 +++++++++++++++++++ .../scenario/entity/PhilosopherVoice.java | 53 ++++++++++++++ .../PhilosopherVoiceRepository.java | 10 +++ 3 files changed, 135 insertions(+) create mode 100644 docs/db/20260910_seed_philosopher_voice.sql create mode 100644 src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java create mode 100644 src/main/java/com/swyp/picke/domain/scenario/repository/PhilosopherVoiceRepository.java diff --git a/docs/db/20260910_seed_philosopher_voice.sql b/docs/db/20260910_seed_philosopher_voice.sql new file mode 100644 index 00000000..ebf6f695 --- /dev/null +++ b/docs/db/20260910_seed_philosopher_voice.sql @@ -0,0 +1,72 @@ +-- 철학자 → Fish Audio 보이스(reference_id) 초기 매핑 시드 +-- 관련 설계: 배틀_발행_시나리오_현행_vs_개선.md 2.5 +-- +-- 테이블 자체는 spring.jpa.hibernate.ddl-auto=update 가 PhilosopherVoice 엔티티를 보고 생성한다. +-- 이 스크립트는 초기 데이터만 넣는다. 환경별(dev / prod / local)로 1회 실행. +-- ON CONFLICT DO NOTHING 이라 재실행해도 안전하고, 어드민에서 수정한 값을 덮지 않는다. +-- 이후 매핑 변경/추가는 어드민 CRUD(/api/v1/admin/philosopher-voices)로 한다. + +INSERT INTO philosopher_voice (name, reference_id, voice_label, created_at, updated_at) VALUES + ('소크라테스', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('스마일즈', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('플라톤', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('톨스토이', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('맹자', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('공자', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('칙센트미하이', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('히포크라테스', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + + ('아리스토텔레스', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('벤담', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('한비자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('피터싱어', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('볼테르', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('푸코', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('토크빌', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('베카리아', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('스피노자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('애덤 그랜트', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('버크', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('쇼펜하우어', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('노자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('보르헤스', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('월드런', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + + ('홉스', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('비트겐슈타인', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('도스토옙스키', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('니체', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('밀', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('뒤르켐', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('베이컨', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('존 로크', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('루소', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('에피쿠로스', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('사르트르', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('간디', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('사마천', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('프로이트', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('카뮈', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('데카르트', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + + ('칸트', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('애들러', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('마키아벨리', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('엘리아데', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('프롬', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('융', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('롤스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('왈처', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('암베드카르', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('라이프니츠', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('카너먼', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('도킨스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('흄', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('보드리야르', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('몽테스키외', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('마이어스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('마르크스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('바흐', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + + ('아렌트', '90597c459f7a4438801589a5268c1628', '차분하고 지적인(여성)', now(), now()) +ON CONFLICT (name) DO NOTHING; diff --git a/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java b/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java new file mode 100644 index 00000000..ea659781 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java @@ -0,0 +1,53 @@ +package com.swyp.picke.domain.scenario.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 철학자 이름 → Fish Audio 보이스(reference_id) 매핑. + * 붙여넣기 파서가 시나리오 발화자에 맞는 보이스를 채울 때 조회한다. + */ +@Getter +@Entity +@Table(name = "philosopher_voice") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PhilosopherVoice extends BaseEntity { + + @Column(nullable = false, unique = true, length = 50) + private String name; + + @Column(name = "reference_id", nullable = false, length = 64) + private String referenceId; + + @Column(name = "voice_label", length = 100) + private String voiceLabel; + + @Column(length = 255) + private String note; + + @Builder + public PhilosopherVoice(String name, String referenceId, String voiceLabel, String note) { + this.name = name; + this.referenceId = referenceId; + this.voiceLabel = voiceLabel; + this.note = note; + } + + public void update(String referenceId, String voiceLabel, String note) { + if (referenceId != null && !referenceId.isBlank()) { + this.referenceId = referenceId; + } + if (voiceLabel != null) { + this.voiceLabel = voiceLabel; + } + if (note != null) { + this.note = note; + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/repository/PhilosopherVoiceRepository.java b/src/main/java/com/swyp/picke/domain/scenario/repository/PhilosopherVoiceRepository.java new file mode 100644 index 00000000..1f978314 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/repository/PhilosopherVoiceRepository.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.scenario.repository; + +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PhilosopherVoiceRepository extends JpaRepository { + Optional findByName(String name); + boolean existsByName(String name); +} From 8085e1c104a17280570b6510c962a0890d64e3d7 Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 9 Sep 2026 18:27:35 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=EC=97=90=20?= =?UTF-8?q?=EA=B0=90=EC=A0=95=20=ED=86=A4=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/AdminScenarioScriptRequest.java | 4 +- .../response/AdminScenarioScriptResponse.java | 4 +- .../admin/service/AdminScenarioService.java | 3 +- .../scenario/converter/ScenarioConverter.java | 2 + .../scenario/dto/request/ScriptRequest.java | 6 +- .../scenario/dto/response/ScriptResponse.java | 6 +- .../picke/domain/scenario/entity/Script.java | 13 +++- .../picke/domain/scenario/enums/Tone.java | 68 +++++++++++++++++++ .../scenario/service/ScenarioServiceImpl.java | 10 ++- .../service/ScenarioServiceImplTest.java | 10 +-- 10 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/swyp/picke/domain/scenario/enums/Tone.java diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java index 60563860..cf302c82 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java @@ -1,9 +1,11 @@ package com.swyp.picke.domain.admin.dto.scenario.request; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; public record AdminScenarioScriptRequest( String speakerName, SpeakerType speakerType, - String text + String text, + Tone tone ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java index 2fd0f253..91cb3c43 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.dto.scenario.response; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import lombok.Builder; @Builder @@ -9,5 +10,6 @@ public record AdminScenarioScriptResponse( Integer startTimeMs, SpeakerType speakerType, String speakerName, - String text + String text, + Tone tone ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java index 50a62517..242ea777 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java @@ -83,7 +83,8 @@ private List toScriptRequests(List sc .map(scriptRequest -> new ScriptRequest( scriptRequest.speakerName(), scriptRequest.speakerType(), - scriptRequest.text() + scriptRequest.text(), + scriptRequest.tone() )) .toList(); } diff --git a/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java b/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java index a5b73b06..afaad889 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java +++ b/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java @@ -95,6 +95,7 @@ private ScriptResponse toUserScriptResponse(Script script) { .speakerType(script.getSpeakerType()) .speakerName(script.getSpeakerName()) .text(cleanText) + .tone(script.getTone()) .build(); } @@ -120,6 +121,7 @@ private AdminScenarioScriptResponse toAdminScriptResponse(Script script) { .speakerType(script.getSpeakerType()) .speakerName(script.getSpeakerName()) .text(script.getText()) + .tone(script.getTone()) .build(); } diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java index 36984364..a3236262 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java @@ -1,9 +1,11 @@ package com.swyp.picke.domain.scenario.dto.request; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; public record ScriptRequest( String speakerName, SpeakerType speakerType, - String text -) {} \ No newline at end of file + String text, + Tone tone +) {} diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java index 7833ed20..060bad05 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.scenario.dto.response; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import lombok.Builder; @Builder @@ -9,5 +10,6 @@ public record ScriptResponse( Integer startTimeMs, SpeakerType speakerType, String speakerName, - String text -) {} \ No newline at end of file + String text, + Tone tone +) {} diff --git a/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java b/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java index 27e8b6e7..31b7022e 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.scenario.entity; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.global.common.BaseEntity; import jakarta.persistence.*; import lombok.AccessLevel; @@ -29,27 +30,33 @@ public class Script extends BaseEntity { private String speakerName; @Column(columnDefinition = "TEXT") - private String text; // SSML 태그가 포함된 텍스트 + private String text; // 오디오 효과 태그([sighing] 등)가 인라인으로 포함된 텍스트 + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Tone tone = Tone.NEUTRAL; @Column(name = "audio_url") private String audioUrl; @Builder - public Script(Integer startTimeMs, SpeakerType speakerType, String speakerName, String text) { + public Script(Integer startTimeMs, SpeakerType speakerType, String speakerName, String text, Tone tone) { this.startTimeMs = startTimeMs; this.speakerType = speakerType; this.speakerName = speakerName; this.text = text; + this.tone = tone == null ? Tone.NEUTRAL : tone; } public void updateAudioUrl(String audioUrl) { this.audioUrl = audioUrl; } - public void updateContent(SpeakerType speakerType, String speakerName, String newText) { + public void updateContent(SpeakerType speakerType, String speakerName, String newText, Tone newTone) { this.speakerType = speakerType; this.speakerName = speakerName; this.text = newText; + this.tone = newTone == null ? Tone.NEUTRAL : newTone; this.audioUrl = null; } diff --git a/src/main/java/com/swyp/picke/domain/scenario/enums/Tone.java b/src/main/java/com/swyp/picke/domain/scenario/enums/Tone.java new file mode 100644 index 00000000..6623ded0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/enums/Tone.java @@ -0,0 +1,68 @@ +package com.swyp.picke.domain.scenario.enums; + +import java.util.Arrays; +import java.util.Optional; +import lombok.Getter; + +/** + * 대사 한 줄의 감성적인 톤. 줄당 하나. 오디오 효과 태그(문장 중간 삽입, 다중)는 이 enum이 아니라 + * Script.text 인라인에 그대로 둔다. + * + *

{@link #tag} 는 Fish Audio S2/S2.1 이 인식하는 인라인 대괄호 표기. TTS 합성 직전 text 앞에 붙인다. + * {@link #NEUTRAL} 은 아무 태그도 붙이지 않는다. + */ +@Getter +public enum Tone { + NEUTRAL(null), + ANGRY("[angry]"), + SAD("[sad]"), + EMBARRASSED("[embarrassed]"), + EMPHASIS("[emphasis]"), + WHISPERING("[whispering]"), + SOFT_TONE("[soft tone]"), + BREATHY("[breathy]"), + EXCITED("[excited]"); + + private final String tag; + + Tone(String tag) { + this.tag = tag; + } + + public boolean hasTag() { + return tag != null; + } + + /** + * 기획자가 대본에 인라인으로 써넣은 톤 태그 문자열(예: {@code [angry]}, {@code [soft]})을 enum 으로 해석. + * 표기 흔들림({@code [soft]} → SOFT_TONE)도 흡수한다. 톤이 아닌 태그면 empty. + */ + public static Optional fromInlineTag(String rawTag) { + if (rawTag == null) { + return Optional.empty(); + } + String normalized = rawTag.trim().toLowerCase() + .replace("[", "").replace("]", "").trim(); + return switch (normalized) { + case "angry" -> Optional.of(ANGRY); + case "sad" -> Optional.of(SAD); + case "embarrassed" -> Optional.of(EMBARRASSED); + case "emphasis" -> Optional.of(EMPHASIS); + case "whispering", "whisper" -> Optional.of(WHISPERING); + case "soft", "soft tone" -> Optional.of(SOFT_TONE); + case "breathy" -> Optional.of(BREATHY); + case "excited" -> Optional.of(EXCITED); + default -> Optional.empty(); + }; + } + + public static Tone fromNameOrNeutral(String name) { + if (name == null || name.isBlank()) { + return NEUTRAL; + } + return Arrays.stream(values()) + .filter(tone -> tone.name().equalsIgnoreCase(name.trim())) + .findFirst() + .orElse(NEUTRAL); + } +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java index 243015fd..cd165aac 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java @@ -21,6 +21,7 @@ import com.swyp.picke.domain.scenario.enums.CreatorType; import com.swyp.picke.domain.scenario.enums.ScenarioStatus; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.domain.scenario.repository.ScenarioRepository; import com.swyp.picke.domain.vote.entity.BattleVote; import com.swyp.picke.domain.vote.repository.BattleVoteRepository; @@ -221,6 +222,7 @@ private boolean smartUpdateNodesToScenario(Scenario scenario, ScenarioCreateRequ .speakerType(scriptReq.speakerType()) .speakerName(autoSpeakerName) .text(scriptReq.text()) + .tone(scriptReq.tone()) .build()); } } @@ -291,9 +293,11 @@ private boolean updateScriptsSmartly(ScenarioNode existingNode, java.util.List Date: Wed, 9 Sep 2026 18:28:50 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=B2=A0=ED=95=99=EC=9E=90-?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EC=8A=A4=20=EB=A7=A4=ED=95=91=20=EC=96=B4?= =?UTF-8?q?=EB=93=9C=EB=AF=BC=20CRUD=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminPhilosopherVoiceController.java | 58 ++++++++++++++++ .../request/PhilosopherVoiceRequest.java | 10 +++ .../response/PhilosopherVoiceResponse.java | 21 ++++++ .../service/PhilosopherVoiceService.java | 67 +++++++++++++++++++ .../global/common/exception/ErrorCode.java | 2 + 5 files changed, 158 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java new file mode 100644 index 00000000..6019f230 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java @@ -0,0 +1,58 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.philosophervoice.request.PhilosopherVoiceRequest; +import com.swyp.picke.domain.admin.dto.philosophervoice.response.PhilosopherVoiceResponse; +import com.swyp.picke.domain.scenario.service.PhilosopherVoiceService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 철학자 보이스 API", description = "철학자 이름 → Fish Audio 보이스(reference_id) 매핑 관리") +@RestController +@RequestMapping("/api/v1/admin/philosopher-voices") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminPhilosopherVoiceController { + + private final PhilosopherVoiceService philosopherVoiceService; + + @Operation(summary = "철학자 보이스 매핑 목록") + @GetMapping + public ApiResponse> getAll() { + return ApiResponse.onSuccess(philosopherVoiceService.getAll()); + } + + @Operation(summary = "철학자 보이스 매핑 생성") + @PostMapping + public ApiResponse create(@Valid @RequestBody PhilosopherVoiceRequest request) { + return ApiResponse.onSuccess(philosopherVoiceService.create(request)); + } + + @Operation(summary = "철학자 보이스 매핑 수정") + @PatchMapping("/{id}") + public ApiResponse update( + @PathVariable Long id, + @Valid @RequestBody PhilosopherVoiceRequest request + ) { + return ApiResponse.onSuccess(philosopherVoiceService.update(id, request)); + } + + @Operation(summary = "철학자 보이스 매핑 삭제") + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + philosopherVoiceService.delete(id); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java new file mode 100644 index 00000000..c88d0f54 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.philosophervoice.request; + +import jakarta.validation.constraints.NotBlank; + +public record PhilosopherVoiceRequest( + @NotBlank String name, + @NotBlank String referenceId, + String voiceLabel, + String note +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java new file mode 100644 index 00000000..a76bc534 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java @@ -0,0 +1,21 @@ +package com.swyp.picke.domain.admin.dto.philosophervoice.response; + +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; + +public record PhilosopherVoiceResponse( + Long id, + String name, + String referenceId, + String voiceLabel, + String note +) { + public static PhilosopherVoiceResponse from(PhilosopherVoice entity) { + return new PhilosopherVoiceResponse( + entity.getId(), + entity.getName(), + entity.getReferenceId(), + entity.getVoiceLabel(), + entity.getNote() + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java b/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java new file mode 100644 index 00000000..9e762064 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java @@ -0,0 +1,67 @@ +package com.swyp.picke.domain.scenario.service; + +import com.swyp.picke.domain.admin.dto.philosophervoice.request.PhilosopherVoiceRequest; +import com.swyp.picke.domain.admin.dto.philosophervoice.response.PhilosopherVoiceResponse; +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; +import com.swyp.picke.domain.scenario.repository.PhilosopherVoiceRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PhilosopherVoiceService { + + private final PhilosopherVoiceRepository philosopherVoiceRepository; + + @Transactional(readOnly = true) + public List getAll() { + return philosopherVoiceRepository.findAll().stream() + .map(PhilosopherVoiceResponse::from) + .toList(); + } + + /** 파서가 발화자 철학자에 맞는 보이스를 채울 때 사용. 없으면 empty. */ + @Transactional(readOnly = true) + public Optional findReferenceIdByName(String philosopherName) { + if (philosopherName == null || philosopherName.isBlank()) { + return Optional.empty(); + } + return philosopherVoiceRepository.findByName(philosopherName.trim()) + .map(PhilosopherVoice::getReferenceId); + } + + @Transactional + public PhilosopherVoiceResponse create(PhilosopherVoiceRequest request) { + if (philosopherVoiceRepository.existsByName(request.name().trim())) { + throw new CustomException(ErrorCode.PHILOSOPHER_VOICE_DUPLICATED); + } + PhilosopherVoice saved = philosopherVoiceRepository.save(PhilosopherVoice.builder() + .name(request.name().trim()) + .referenceId(request.referenceId().trim()) + .voiceLabel(request.voiceLabel()) + .note(request.note()) + .build()); + return PhilosopherVoiceResponse.from(saved); + } + + @Transactional + public PhilosopherVoiceResponse update(Long id, PhilosopherVoiceRequest request) { + PhilosopherVoice entity = philosopherVoiceRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND)); + entity.update(request.referenceId(), request.voiceLabel(), request.note()); + return PhilosopherVoiceResponse.from(entity); + } + + @Transactional + public void delete(Long id) { + if (!philosopherVoiceRepository.existsById(id)) { + throw new CustomException(ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND); + } + philosopherVoiceRepository.deleteById(id); + } +} diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 601fa424..201c9c9c 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -70,6 +70,8 @@ public enum ErrorCode { SCENARIO_NOT_FOUND(HttpStatus.NOT_FOUND, "SCENARIO_404", "존재하지 않는 시나리오입니다."), SCENARIO_ALREADY_EXISTS(HttpStatus.CONFLICT, "SCENARIO_409_DUP", "해당 배틀에 이미 시나리오가 존재합니다."), SCENARIO_ALREADY_PUBLISHED(HttpStatus.CONFLICT, "SCENARIO_409_PUB", "이미 발행된 시나리오는 수정할 수 없습니다."), + PHILOSOPHER_VOICE_NOT_FOUND(HttpStatus.NOT_FOUND, "PHIL_VOICE_404", "존재하지 않는 철학자 보이스 매핑입니다."), + PHILOSOPHER_VOICE_DUPLICATED(HttpStatus.CONFLICT, "PHIL_VOICE_409_DUP", "이미 등록된 철학자 이름입니다."), // Tag TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "TAG_404", "존재하지 않는 태그입니다."), From 20befaf47f66c5246e67faad5635a9333d301e0f Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 9 Sep 2026 18:31:59 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20TTS=20=ED=95=A9=EC=84=B1=EC=97=90?= =?UTF-8?q?=20=EA=B0=90=EC=A0=95=20=ED=86=A4=20=EB=B0=98=EC=98=81=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20Fish=20=EB=AA=A8=EB=8D=B8=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=EB=A5=BC=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ScenarioAudioPipelineService.java | 3 +- .../tts/service/ElevenLabsTtsServiceImpl.java | 5 ++- .../tts/service/FishAudioTtsServiceImpl.java | 9 ++++- .../global/infra/tts/service/TtsService.java | 5 ++- .../infra/tts/service/TtsTextNormalizer.java | 39 +++++++++++++++++++ src/main/resources/application.yml | 1 + .../ScenarioAudioPipelineServiceTest.java | 7 ++-- 7 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java diff --git a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java index 029a0a9c..26a3e94b 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java +++ b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java @@ -63,7 +63,8 @@ public void generateAndMergeAudioAsync(Long scenarioId) { audioFile = ttsService.generateTtsWithSsml( script.getText(), script.getSpeakerType(), - scenario.getVoiceCode(script.getSpeakerType()) + scenario.getVoiceCode(script.getSpeakerType()), + script.getTone() ); // 새로 만든 조각 파일을 다음 수정을 위해 S3에 업로드 (chunks 폴더) diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/ElevenLabsTtsServiceImpl.java b/src/main/java/com/swyp/picke/global/infra/tts/service/ElevenLabsTtsServiceImpl.java index e037b809..49e2d767 100644 --- a/src/main/java/com/swyp/picke/global/infra/tts/service/ElevenLabsTtsServiceImpl.java +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/ElevenLabsTtsServiceImpl.java @@ -1,6 +1,7 @@ package com.swyp.picke.global.infra.tts.service; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; @@ -44,8 +45,8 @@ public class ElevenLabsTtsServiceImpl implements TtsService { private static final String ELEVENLABS_TTS_URL = "https://api.elevenlabs.io/v1/text-to-speech/"; @Override - public File generateTtsWithSsml(String rawText, SpeakerType speakerType, String customVoice) throws Exception { - String actingText = rawText.replaceAll("<[^>]*>", "").trim(); + public File generateTtsWithSsml(String rawText, SpeakerType speakerType, String customVoice, Tone tone) throws Exception { + String actingText = TtsTextNormalizer.normalize(rawText, tone).replaceAll("<[^>]*>", "").trim(); String voiceId = (customVoice != null && !customVoice.isBlank()) ? customVoice.trim() : getElevenLabsVoiceId(speakerType); diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java b/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java index 8376e0f1..1cb1bb14 100644 --- a/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java @@ -1,6 +1,7 @@ package com.swyp.picke.global.infra.tts.service; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; @@ -31,15 +32,19 @@ public class FishAudioTtsServiceImpl implements TtsService { @Value("${fishaudio.tts.url:https://api.fish.audio/v1/tts}") private String ttsUrl; + @Value("${fishaudio.tts.model:s2.1-pro}") + private String ttsModel; + @Override - public File generateTtsWithSsml(String rawText, SpeakerType speakerType, String customVoice) throws Exception { - String actingText = cleanTextForNaturalFlow(rawText); + public File generateTtsWithSsml(String rawText, SpeakerType speakerType, String customVoice, Tone tone) throws Exception { + String actingText = cleanTextForNaturalFlow(TtsTextNormalizer.normalize(rawText, tone)); String referenceId = resolveVoiceId(speakerType, customVoice); RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(fishAudioApiKey); + headers.set("model", ttsModel); Map requestBody = new HashMap<>(); requestBody.put("text", actingText); diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java index d4332b4f..3fc0aa9a 100644 --- a/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java @@ -1,8 +1,9 @@ package com.swyp.picke.global.infra.tts.service; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import java.io.File; public interface TtsService { - File generateTtsWithSsml(String text, SpeakerType speakerType, String customVoice) throws Exception; -} \ No newline at end of file + File generateTtsWithSsml(String text, SpeakerType speakerType, String customVoice, Tone tone) throws Exception; +} diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java new file mode 100644 index 00000000..bbcce623 --- /dev/null +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java @@ -0,0 +1,39 @@ +package com.swyp.picke.global.infra.tts.service; + +import com.swyp.picke.domain.scenario.enums.Tone; +import java.util.Map; + +/** + * TTS 전송 직전 대사 텍스트를 정리한다. + * + *

    + *
  • 줄의 대표 톤({@link Tone})을 Fish Audio S2/S2.1 인라인 표기로 맨 앞에 붙인다. {@code NEUTRAL} 은 아무것도 안 붙임.
  • + *
  • 기획자가 관용적으로 쓰는 효과 태그 표기를 Fish 정식 표기로 정규화한다({@code [pause]} → {@code [break]} 등).
  • + *
  • 대괄호 태그 자체는 제거하지 않는다 — Fish S2.1 이 인라인으로 해석한다.
  • + *
+ */ +public final class TtsTextNormalizer { + + private static final Map EFFECT_ALIASES = Map.of( + "[soft]", "[soft tone]", + "[pause]", "[break]", + "[long pause]", "[long-break]", + "[long-pause]", "[long-break]" + ); + + private TtsTextNormalizer() { + } + + public static String normalize(String rawText, Tone tone) { + String text = rawText == null ? "" : rawText; + + for (Map.Entry alias : EFFECT_ALIASES.entrySet()) { + text = text.replaceAll("(?i)" + java.util.regex.Pattern.quote(alias.getKey()), alias.getValue()); + } + + if (tone != null && tone.hasTag()) { + text = tone.getTag() + " " + text.stripLeading(); + } + return text.trim(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9d197bac..c218cec2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -94,6 +94,7 @@ fishaudio: api-key: ${FISHAUDIO_API_KEY} tts: url: https://api.fish.audio/v1/tts + model: ${FISHAUDIO_TTS_MODEL:s2.1-pro} voice-id: a: ${FISHAUDIO_VOICE_A} b: ${FISHAUDIO_VOICE_B} diff --git a/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineServiceTest.java b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineServiceTest.java index 6e0515e9..781f0ced 100644 --- a/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineServiceTest.java @@ -11,6 +11,7 @@ import com.swyp.picke.domain.scenario.enums.CreatorType; import com.swyp.picke.domain.scenario.enums.ScenarioStatus; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.domain.scenario.repository.ScenarioRepository; import com.swyp.picke.global.infra.media.service.FFmpegService; import com.swyp.picke.global.infra.s3.service.S3UploadService; @@ -124,9 +125,9 @@ void generateAndMergeAudioAsync_usesFallbackExecutableAndCompletesUploadFlow() t scenario.replaceVoiceSettings(Map.of(SpeakerType.NARRATOR, "voice-narrator")); when(scenarioRepository.findById(1L)).thenReturn(Optional.of(scenario)); - when(ttsService.generateTtsWithSsml(eq("First line"), eq(SpeakerType.NARRATOR), eq("voice-narrator"))) + when(ttsService.generateTtsWithSsml(eq("First line"), eq(SpeakerType.NARRATOR), eq("voice-narrator"), eq(Tone.NEUTRAL))) .thenReturn(ffmpegService.createSilenceFile(1000)); - when(ttsService.generateTtsWithSsml(eq("Second line"), eq(SpeakerType.NARRATOR), eq("voice-narrator"))) + when(ttsService.generateTtsWithSsml(eq("Second line"), eq(SpeakerType.NARRATOR), eq("voice-narrator"), eq(Tone.NEUTRAL))) .thenReturn(ffmpegService.createSilenceFile(1000)); AtomicInteger chunkIndex = new AtomicInteger(); @@ -149,7 +150,7 @@ void generateAndMergeAudioAsync_usesFallbackExecutableAndCompletesUploadFlow() t assertThat(scenario.getAudios()).containsEntry(AudioPathType.COMMON, "s3://scenario/merged/common.mp3"); assertThat(battle.getAudioDuration()).isNotNull().isGreaterThan(0); - verify(ttsService, times(2)).generateTtsWithSsml(anyString(), eq(SpeakerType.NARRATOR), eq("voice-narrator")); + verify(ttsService, times(2)).generateTtsWithSsml(anyString(), eq(SpeakerType.NARRATOR), eq("voice-narrator"), any(Tone.class)); verify(s3UploadService, times(3)).uploadFile(anyString(), any(File.class)); verify(battleRepository).save(battle); verify(scenarioRepository).saveAndFlush(scenario); From ca3f0025077b8c22d4e58a6bf969295573ed6017 Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 9 Sep 2026 18:31:59 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test:=20=EA=B0=90=EC=A0=95=20=ED=86=A4=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=EC=99=80=20=EC=B2=A0=ED=95=99?= =?UTF-8?q?=EC=9E=90=20=EB=B3=B4=EC=9D=B4=EC=8A=A4=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../picke/domain/scenario/enums/ToneTest.java | 54 ++++++++ .../service/PhilosopherVoiceServiceTest.java | 116 ++++++++++++++++++ .../tts/service/TtsTextNormalizerTest.java | 66 ++++++++++ 3 files changed, 236 insertions(+) create mode 100644 src/test/java/com/swyp/picke/domain/scenario/enums/ToneTest.java create mode 100644 src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java create mode 100644 src/test/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizerTest.java diff --git a/src/test/java/com/swyp/picke/domain/scenario/enums/ToneTest.java b/src/test/java/com/swyp/picke/domain/scenario/enums/ToneTest.java new file mode 100644 index 00000000..500c34a6 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/scenario/enums/ToneTest.java @@ -0,0 +1,54 @@ +package com.swyp.picke.domain.scenario.enums; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ToneTest { + + @Test + void fromInlineTag_기본_톤_태그를_해석한다() { + assertThat(Tone.fromInlineTag("[angry]")).contains(Tone.ANGRY); + assertThat(Tone.fromInlineTag("[excited]")).contains(Tone.EXCITED); + } + + @Test + void fromInlineTag_축약_표기도_흡수한다() { + assertThat(Tone.fromInlineTag("[soft]")).contains(Tone.SOFT_TONE); + assertThat(Tone.fromInlineTag("[soft tone]")).contains(Tone.SOFT_TONE); + assertThat(Tone.fromInlineTag("[whisper]")).contains(Tone.WHISPERING); + } + + @Test + void fromInlineTag_대괄호_없이_대소문자_섞여도_해석한다() { + assertThat(Tone.fromInlineTag(" Angry ")).contains(Tone.ANGRY); + } + + @Test + void fromInlineTag_효과_태그나_미지정은_empty() { + assertThat(Tone.fromInlineTag("[sighing]")).isEmpty(); + assertThat(Tone.fromInlineTag(null)).isEmpty(); + assertThat(Tone.fromInlineTag("[break]")).isEmpty(); + } + + @Test + void fromNameOrNeutral_null이거나_모르는_값이면_NEUTRAL() { + assertThat(Tone.fromNameOrNeutral(null)).isEqualTo(Tone.NEUTRAL); + assertThat(Tone.fromNameOrNeutral("")).isEqualTo(Tone.NEUTRAL); + assertThat(Tone.fromNameOrNeutral("ANGRY")).isEqualTo(Tone.ANGRY); + assertThat(Tone.fromNameOrNeutral("something-else")).isEqualTo(Tone.NEUTRAL); + } + + @Test + void NEUTRAL만_태그가_없다() { + assertThat(Tone.NEUTRAL.hasTag()).isFalse(); + for (Tone tone : Tone.values()) { + if (tone != Tone.NEUTRAL) { + assertThat(tone.hasTag()).isTrue(); + assertThat(tone.getTag()).startsWith("[").endsWith("]"); + } + } + assertThat(Optional.ofNullable(Tone.NEUTRAL.getTag())).isEmpty(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java b/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java new file mode 100644 index 00000000..49635abd --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java @@ -0,0 +1,116 @@ +package com.swyp.picke.domain.scenario.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.philosophervoice.request.PhilosopherVoiceRequest; +import com.swyp.picke.domain.admin.dto.philosophervoice.response.PhilosopherVoiceResponse; +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; +import com.swyp.picke.domain.scenario.repository.PhilosopherVoiceRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PhilosopherVoiceServiceTest { + + @Mock + private PhilosopherVoiceRepository philosopherVoiceRepository; + + @InjectMocks + private PhilosopherVoiceService philosopherVoiceService; + + private PhilosopherVoice entity(String name, String referenceId) { + return PhilosopherVoice.builder() + .name(name) + .referenceId(referenceId) + .voiceLabel("label") + .build(); + } + + @Test + void findReferenceIdByName_등록된_철학자면_reference_id_반환() { + when(philosopherVoiceRepository.findByName("소크라테스")) + .thenReturn(Optional.of(entity("소크라테스", "voice-a"))); + + assertThat(philosopherVoiceService.findReferenceIdByName(" 소크라테스 ")) + .contains("voice-a"); + } + + @Test + void findReferenceIdByName_미등록이거나_빈값이면_empty() { + when(philosopherVoiceRepository.findByName("루소")).thenReturn(Optional.empty()); + + assertThat(philosopherVoiceService.findReferenceIdByName("루소")).isEmpty(); + assertThat(philosopherVoiceService.findReferenceIdByName(null)).isEmpty(); + assertThat(philosopherVoiceService.findReferenceIdByName(" ")).isEmpty(); + } + + @Test + void create_이름이_중복이면_예외() { + when(philosopherVoiceRepository.existsByName("칸트")).thenReturn(true); + + assertThatThrownBy(() -> philosopherVoiceService.create( + new PhilosopherVoiceRequest("칸트", "voice-x", null, null))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PHILOSOPHER_VOICE_DUPLICATED); + + verify(philosopherVoiceRepository, never()).save(any()); + } + + @Test + void create_정상이면_저장하고_응답_반환() { + when(philosopherVoiceRepository.existsByName("칸트")).thenReturn(false); + when(philosopherVoiceRepository.save(any(PhilosopherVoice.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PhilosopherVoiceResponse response = philosopherVoiceService.create( + new PhilosopherVoiceRequest(" 칸트 ", " voice-x ", "Meursault", null)); + + assertThat(response.name()).isEqualTo("칸트"); + assertThat(response.referenceId()).isEqualTo("voice-x"); + verify(philosopherVoiceRepository).save(any(PhilosopherVoice.class)); + } + + @Test + void update_대상이_없으면_예외() { + when(philosopherVoiceRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> philosopherVoiceService.update(99L, + new PhilosopherVoiceRequest("칸트", "voice-x", null, null))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND); + } + + @Test + void update_reference_id를_교체한다() { + PhilosopherVoice found = entity("칸트", "voice-old"); + when(philosopherVoiceRepository.findById(1L)).thenReturn(Optional.of(found)); + + PhilosopherVoiceResponse response = philosopherVoiceService.update(1L, + new PhilosopherVoiceRequest("칸트", "voice-new", "새 라벨", "메모")); + + assertThat(response.referenceId()).isEqualTo("voice-new"); + assertThat(found.getReferenceId()).isEqualTo("voice-new"); + } + + @Test + void delete_대상이_없으면_예외() { + when(philosopherVoiceRepository.existsById(99L)).thenReturn(false); + + assertThatThrownBy(() -> philosopherVoiceService.delete(99L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND); + + verify(philosopherVoiceRepository, never()).deleteById(any()); + } +} diff --git a/src/test/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizerTest.java b/src/test/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizerTest.java new file mode 100644 index 00000000..d983c433 --- /dev/null +++ b/src/test/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizerTest.java @@ -0,0 +1,66 @@ +package com.swyp.picke.global.infra.tts.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.swyp.picke.domain.scenario.enums.Tone; +import org.junit.jupiter.api.Test; + +class TtsTextNormalizerTest { + + @Test + void NEUTRAL_톤은_태그를_붙이지_않는다() { + String result = TtsTextNormalizer.normalize("그건 궤변이다.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("그건 궤변이다."); + } + + @Test + void 톤이_있으면_Fish_인라인_표기를_맨_앞에_붙인다() { + String result = TtsTextNormalizer.normalize("그건 궤변이다.", Tone.ANGRY); + + assertThat(result).isEqualTo("[angry] 그건 궤변이다."); + } + + @Test + void SOFT_TONE은_soft_tone_표기로_붙는다() { + String result = TtsTextNormalizer.normalize("괜찮아요.", Tone.SOFT_TONE); + + assertThat(result).isEqualTo("[soft tone] 괜찮아요."); + } + + @Test + void 관용_효과_표기를_Fish_정식_표기로_정규화한다() { + String result = TtsTextNormalizer.normalize( + "그래서 [pause] 나는 [soft] 포기했다 [long pause] 결국.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("그래서 [break] 나는 [soft tone] 포기했다 [long-break] 결국."); + } + + @Test + void 효과_정규화는_대소문자를_무시한다() { + String result = TtsTextNormalizer.normalize("음 [PAUSE] 그리고 [Long Pause] 끝.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("음 [break] 그리고 [long-break] 끝."); + } + + @Test + void 문서에_없는_효과_태그는_그대로_둔다() { + String result = TtsTextNormalizer.normalize("[sighing] 나는 지쳤다.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("[sighing] 나는 지쳤다."); + } + + @Test + void 톤과_효과가_함께_있으면_톤은_앞_효과는_원위치() { + String result = TtsTextNormalizer.normalize("나는 [sighing] 포기했다.", Tone.SAD); + + assertThat(result).isEqualTo("[sad] 나는 [sighing] 포기했다."); + } + + @Test + void null_텍스트도_톤_태그만_남기고_안전하게_처리한다() { + String result = TtsTextNormalizer.normalize(null, Tone.EXCITED); + + assertThat(result).isEqualTo("[excited]"); + } +} From 3bd9b322b5d5f55aafc75127e57f72e0b98c3316 Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 9 Sep 2026 21:37:04 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20=EC=B2=A0=ED=95=99=EC=9E=90=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EC=8A=A4=20=EB=A7=A4=ED=95=91=EA=B3=BC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=ED=86=A4=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20ERD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/erd/philosopher-voice.puml | 43 +++++++++++++++++++++++++++++++++ docs/erd/scenario.puml | 10 ++++++++ 2 files changed, 53 insertions(+) create mode 100644 docs/erd/philosopher-voice.puml diff --git a/docs/erd/philosopher-voice.puml b/docs/erd/philosopher-voice.puml new file mode 100644 index 00000000..c7d2923f --- /dev/null +++ b/docs/erd/philosopher-voice.puml @@ -0,0 +1,43 @@ +@startuml philosopher-voice +hide circle +hide methods +skinparam linetype ortho + +entity "philosopher_voice" as philosopher_voice { + * id : BIGINT <> + -- + name : VARCHAR(50) <> + reference_id : VARCHAR(64) + voice_label : VARCHAR(100) <> + note : VARCHAR(255) <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "scenarios" as scenarios { + * id : BIGINT <> +} + +entity "scenario_voice_settings" as scenario_voice_settings { + * scenario_id : BIGINT <> + * speaker_type : VARCHAR(20) + -- + voice_code : VARCHAR(200) +} + +scenarios ||--o{ scenario_voice_settings : has +philosopher_voice ..> scenario_voice_settings : 파서가 voice_code 채움 + +note bottom of philosopher_voice + 철학자 이름 → Fish Audio 보이스(reference_id) 매핑. + 붙여넣기 파서가 시나리오 발화자(옵션 A/B 철학자)에 맞는 + reference_id 를 조회해 scenario_voice_settings.voice_code 에 채운다. + NARRATOR / USER 고정 보이스는 이 테이블이 아니라 config. + + FK 없음: scenario_voice_settings 가 name 이 아닌 완성된 + voice_code(reference_id) 를 저장하므로 논리적 참조만 존재. + 매핑 변경/추가는 어드민(/api/v1/admin/philosopher-voices)에서 한다. + 초기 데이터는 docs/db/20260910_seed_philosopher_voice.sql. +end note + +@enduml diff --git a/docs/erd/scenario.puml b/docs/erd/scenario.puml index 0e5d2e38..10e653da 100644 --- a/docs/erd/scenario.puml +++ b/docs/erd/scenario.puml @@ -41,6 +41,7 @@ entity "scenario_scripts" as scenario_scripts { speaker_type : VARCHAR(20) speaker_name : VARCHAR(100) text : TEXT + tone : VARCHAR(20) audio_url : VARCHAR(500) script_order : INT created_at : TIMESTAMP @@ -84,4 +85,13 @@ note right of scenarios voice_settings는 화자별 보이스 코드 저장 end note +note bottom of scenario_scripts + tone : 대사 한 줄의 감성적인 톤 (Tone enum) + NEUTRAL | ANGRY | SAD | EMBARRASSED | EMPHASIS + | WHISPERING | SOFT_TONE | BREATHY | EXCITED + NEUTRAL 이면 TTS 전송 시 톤 태그를 붙이지 않음. + 오디오 효과 태그([sighing] 등)는 text 인라인에 그대로 둔다. + tone 이 바뀌면 audio_url 을 비워 재합성한다. +end note + @enduml