feat(speech): add role-aware emotional TTS profiles
This commit is contained in:
+4
-1
@@ -125,7 +125,10 @@ public class AihrSpeechController {
|
||||
if (text.length() > MAX_TTS_CHARS) {
|
||||
text = text.substring(0, MAX_TTS_CHARS);
|
||||
}
|
||||
return speechService.synthesize(text, request == null ? null : request.voice())
|
||||
return speechService.synthesize(
|
||||
text,
|
||||
request == null ? null : request.voice(),
|
||||
request == null ? null : request.voiceProfile())
|
||||
.map(this::storeTtsAudio)
|
||||
.orElseGet(() -> R.fail("语音合成失败,已降级为文本展示"));
|
||||
}
|
||||
|
||||
+4
-1
@@ -11,7 +11,10 @@ public final class AihrSpeechDto {
|
||||
}
|
||||
}
|
||||
|
||||
public record TtsRequest(String text, String voice) {
|
||||
public record TtsRequest(String text, String voice, VoiceProfile voiceProfile) {
|
||||
}
|
||||
|
||||
public record VoiceProfile(String role, String voice, Double speed, String emotion) {
|
||||
}
|
||||
|
||||
public record TtsResponse(String audioUrl, String source, Long ossId, String inlineAudioUrl) {
|
||||
|
||||
+94
-5
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.VoiceProfile;
|
||||
import org.dromara.aihr.service.AihrModelSeedService.SpeechModel;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -15,8 +16,11 @@ import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* ASR/TTS 网关:调用 OpenAI 兼容 audio 接口(硅基流动 SenseVoice/CosyVoice2 等)。
|
||||
@@ -30,6 +34,19 @@ public class AihrSpeechService {
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final String DEFAULT_TTS_VOICE = "anna";
|
||||
private static final Pattern SAFE_VOICE = Pattern.compile("[A-Za-z0-9_./:-]{1,200}");
|
||||
private static final Map<String, String> ROLE_VOICES = Map.of(
|
||||
"mentor", "benjamin",
|
||||
"customer", "bella",
|
||||
"interviewer", "alex"
|
||||
);
|
||||
private static final Map<String, String> EMOTION_PROMPTS = Map.of(
|
||||
"warm", "请用沉稳、耐心、有经验的老师傅语气说",
|
||||
"professional", "请用沉稳、专业、清晰的语气说",
|
||||
"serious", "请用严肃、克制、带明显质疑的语气说",
|
||||
"intense", "请用情绪强烈、急切、有压力且明显不满的语气说",
|
||||
"calm", "请用平静、自然的语气说"
|
||||
);
|
||||
|
||||
private final AihrModelSeedService modelService;
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -75,17 +92,17 @@ public class AihrSpeechService {
|
||||
* 语音合成:POST {base}/audio/speech,返回 mp3 字节。
|
||||
*/
|
||||
public Optional<byte[]> synthesize(String text, String voice) {
|
||||
return synthesize(text, voice, null);
|
||||
}
|
||||
|
||||
public Optional<byte[]> synthesize(String text, String voice, VoiceProfile voiceProfile) {
|
||||
Optional<SpeechModel> model = modelService.speechModel("tts");
|
||||
if (model.isEmpty() || text == null || text.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
SpeechModel runtime = model.get();
|
||||
try {
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("model", runtime.modelName());
|
||||
body.put("input", AihrSensitiveText.forModel(text.trim()));
|
||||
body.put("voice", resolveVoice(runtime.modelName(), voice));
|
||||
body.put("response_format", "mp3");
|
||||
ObjectNode body = buildRequestBody(objectMapper, runtime, text, voice, voiceProfile);
|
||||
HttpRequest httpRequest = authorized(runtime, "/audio/speech")
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
|
||||
@@ -102,6 +119,35 @@ public class AihrSpeechService {
|
||||
}
|
||||
}
|
||||
|
||||
static ObjectNode buildRequestBody(ObjectMapper objectMapper, SpeechModel runtime, String text,
|
||||
String legacyVoice, VoiceProfile voiceProfile) {
|
||||
String role = normalizedRole(voiceProfile == null ? null : voiceProfile.role());
|
||||
boolean expressiveCosyVoice = isExpressiveCosyVoice(runtime);
|
||||
String requestedVoice = safeVoice(voiceProfile == null ? null : voiceProfile.voice());
|
||||
if (requestedVoice == null) {
|
||||
requestedVoice = safeVoice(legacyVoice);
|
||||
}
|
||||
if (requestedVoice == null && expressiveCosyVoice) {
|
||||
requestedVoice = ROLE_VOICES.get(role);
|
||||
}
|
||||
|
||||
String emotion = normalizedEmotion(voiceProfile == null ? null : voiceProfile.emotion(), role);
|
||||
String sanitizedText = AihrSensitiveText.forModel(text.trim());
|
||||
String input = voiceProfile != null && expressiveCosyVoice && EMOTION_PROMPTS.containsKey(emotion)
|
||||
? EMOTION_PROMPTS.get(emotion) + "。<|endofprompt|>" + sanitizedText
|
||||
: sanitizedText;
|
||||
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("model", runtime.modelName());
|
||||
body.put("input", input);
|
||||
body.put("voice", resolveVoice(runtime.modelName(), requestedVoice));
|
||||
if (voiceProfile != null) {
|
||||
body.put("speed", resolveSpeed(voiceProfile.speed(), emotion));
|
||||
}
|
||||
body.put("response_format", "mp3");
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 硅基流动 voice 格式为 "{model}:{voice}";调用方只传短名(如 anna/粤语音色名)时自动补模型前缀。
|
||||
*/
|
||||
@@ -110,6 +156,49 @@ public class AihrSpeechService {
|
||||
return value.contains(":") ? value : modelName + ":" + value;
|
||||
}
|
||||
|
||||
private static String normalizedRole(String role) {
|
||||
String value = role == null ? "" : role.trim().toLowerCase(Locale.ROOT);
|
||||
return ROLE_VOICES.containsKey(value) ? value : "neutral";
|
||||
}
|
||||
|
||||
private static String normalizedEmotion(String emotion, String role) {
|
||||
String value = emotion == null ? "" : emotion.trim().toLowerCase(Locale.ROOT);
|
||||
if (EMOTION_PROMPTS.containsKey(value)) {
|
||||
return value;
|
||||
}
|
||||
return switch (role) {
|
||||
case "mentor" -> "warm";
|
||||
case "customer" -> "serious";
|
||||
case "interviewer" -> "professional";
|
||||
default -> "calm";
|
||||
};
|
||||
}
|
||||
|
||||
private static double resolveSpeed(Double speed, String emotion) {
|
||||
if (speed != null && Double.isFinite(speed)) {
|
||||
return Math.max(0.7, Math.min(1.3, speed));
|
||||
}
|
||||
return switch (emotion) {
|
||||
case "intense" -> 1.12;
|
||||
case "serious" -> 1.04;
|
||||
case "warm" -> 0.92;
|
||||
case "professional" -> 0.96;
|
||||
default -> 0.98;
|
||||
};
|
||||
}
|
||||
|
||||
private static String safeVoice(String voice) {
|
||||
String value = voice == null ? "" : voice.trim();
|
||||
return SAFE_VOICE.matcher(value).matches() ? value : null;
|
||||
}
|
||||
|
||||
private static boolean isExpressiveCosyVoice(SpeechModel runtime) {
|
||||
return runtime != null
|
||||
&& "siliconflow".equalsIgnoreCase(runtime.providerCode())
|
||||
&& runtime.modelName() != null
|
||||
&& runtime.modelName().toLowerCase(Locale.ROOT).contains("cosyvoice");
|
||||
}
|
||||
|
||||
private HttpRequest.Builder authorized(SpeechModel runtime, String path) {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + path))
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.VoiceProfile;
|
||||
import org.dromara.aihr.service.AihrModelSeedService.SpeechModel;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@Tag("dev")
|
||||
class AihrSpeechServiceTest {
|
||||
|
||||
@Test
|
||||
void buildsDistinctMentorAndEmotionalCustomerVoicesWithoutBreakingLegacyRequests() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
SpeechModel runtime = new SpeechModel(
|
||||
"siliconflow",
|
||||
"FunAudioLLM/CosyVoice2-0.5B",
|
||||
"https://api.siliconflow.cn/v1",
|
||||
"test-key"
|
||||
);
|
||||
|
||||
var mentor = AihrSpeechService.buildRequestBody(
|
||||
mapper, runtime, "先承接情绪,再说明处理节点。", null,
|
||||
new VoiceProfile("mentor", null, null, null));
|
||||
var customer = AihrSpeechService.buildRequestBody(
|
||||
mapper, runtime, "你们到底什么时候处理?", null,
|
||||
new VoiceProfile("customer", null, 9.0, "intense"));
|
||||
var legacy = AihrSpeechService.buildRequestBody(
|
||||
mapper, runtime, "普通播报", "alex", null);
|
||||
|
||||
assertEquals("FunAudioLLM/CosyVoice2-0.5B:benjamin", mentor.path("voice").asText());
|
||||
assertEquals(0.92, mentor.path("speed").asDouble());
|
||||
assertTrue(mentor.path("input").asText().contains("老师傅语气"));
|
||||
assertEquals("FunAudioLLM/CosyVoice2-0.5B:bella", customer.path("voice").asText());
|
||||
assertEquals(1.3, customer.path("speed").asDouble());
|
||||
assertTrue(customer.path("input").asText().contains("情绪强烈"));
|
||||
assertEquals("FunAudioLLM/CosyVoice2-0.5B:alex", legacy.path("voice").asText());
|
||||
assertFalse(legacy.has("speed"));
|
||||
assertFalse(legacy.path("input").asText().contains("<|endofprompt|>"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user