feat(speech): add role-aware emotional TTS profiles

This commit is contained in:
2026-07-18 18:27:14 +08:00
parent c0538ad8c3
commit 68d5600534
14 changed files with 228 additions and 45 deletions
@@ -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("语音合成失败,已降级为文本展示"));
}
@@ -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) {
@@ -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))
@@ -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|>"));
}
}