新增 apps/api/src/voice/:Voice Sheet(基礎音色/預設語速/音域幅度/口頭 聲響庫/禁則,依性格原型推導預設值,1:1 掛在 Character,同 EmotionState 的關聯模式);情緒→韻律對照(六狀態語速/音高/音量/句尾走向),音高變化 依角色音域幅度縮放(三無角色近乎單音),設計原則同 O-4 表情外顯度縮放; 非語言發聲與節奏(依情緒插入聲響、重大情緒延長停頓、禁則過濾候選、 低親密度不打斷、talkative 特徵+親密度門檻才可插話);TTSProvider 抽象 完全比照 F-1 LLMProvider(MockTTSProvider 輸出韻律標記+佔位音檔, RealTTSProvider 待人工確認供應商後再接,TTS_PROVIDER=mock|real 切換); 語音輸入副語言分析(EmotionService 新增 paralinguisticSignal,優先序 排在作息基線之前,讓「文字說沒事但聲音在抖」判為負向)。 修正一個真實 bug:非語言發聲的停頓時長原本直接拿目前主導情緒的數值 當強度訊號,但平靜狀態下 calm 預設就是 100,導致「什麼事都沒發生」被 誤判成「強度最強的重大情緒事件」而觸發不該有的長停頓——修正為 CALM 一律視為強度 0(平靜是情緒事件的缺席,不是訊號)。 刻意簡化:副語言特徵(語速/音量/顫抖/停頓)採結構化輸入,不做真實音訊 分析,比照 M/N 群組「先用結構化輸入代替真實訊號處理」的一貫取捨。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
96 lines
3.3 KiB
TypeScript
96 lines
3.3 KiB
TypeScript
import { Body, Controller, Get, Inject, Param, Post, Query } from "@nestjs/common";
|
|
import { VoiceSheetService } from "./voice-sheet.service.js";
|
|
import { ProsodyService } from "./prosody.service.js";
|
|
import { NonverbalService } from "./nonverbal.service.js";
|
|
import { ParalinguisticService, type ParalinguisticFeatures } from "./paralinguistic.service.js";
|
|
import { TTS_PROVIDER, type TTSProvider } from "./tts-provider.js";
|
|
import { EmotionService } from "../emotion/emotion.service.js";
|
|
|
|
interface SeedVoiceSheetBody {
|
|
verbalTics?: string[];
|
|
forbiddenSounds?: string[];
|
|
}
|
|
|
|
interface SynthesizeBody {
|
|
text: string;
|
|
now?: string;
|
|
seed?: number;
|
|
}
|
|
|
|
interface AnalyzeInputBody {
|
|
userId: string;
|
|
text: string;
|
|
paralinguistic: ParalinguisticFeatures;
|
|
now?: string;
|
|
}
|
|
|
|
@Controller("voice")
|
|
export class VoiceController {
|
|
constructor(
|
|
private readonly voiceSheet: VoiceSheetService,
|
|
private readonly prosody: ProsodyService,
|
|
private readonly nonverbal: NonverbalService,
|
|
private readonly paralinguistic: ParalinguisticService,
|
|
private readonly emotion: EmotionService,
|
|
@Inject(TTS_PROVIDER) private readonly ttsProvider: TTSProvider,
|
|
) {}
|
|
|
|
@Post(":characterId/voice-sheet/seed-default")
|
|
async seedVoiceSheet(@Param("characterId") characterId: string, @Body() body: SeedVoiceSheetBody) {
|
|
return this.voiceSheet.seedDefault(characterId, body);
|
|
}
|
|
|
|
@Get(":characterId/voice-sheet")
|
|
async getVoiceSheet(@Param("characterId") characterId: string) {
|
|
return this.voiceSheet.get(characterId);
|
|
}
|
|
|
|
@Get(":characterId/:userId/prosody")
|
|
async getProsody(
|
|
@Param("characterId") characterId: string,
|
|
@Param("userId") userId: string,
|
|
@Query("now") now?: string,
|
|
) {
|
|
return this.prosody.resolveProsody(characterId, userId, now ? new Date(now) : undefined);
|
|
}
|
|
|
|
@Get(":characterId/:userId/nonverbal-cue")
|
|
async getNonverbalCue(
|
|
@Param("characterId") characterId: string,
|
|
@Param("userId") userId: string,
|
|
@Query("now") now?: string,
|
|
) {
|
|
return this.nonverbal.resolveCue(characterId, userId, now ? new Date(now) : undefined);
|
|
}
|
|
|
|
@Post(":characterId/:userId/synthesize")
|
|
async synthesize(
|
|
@Param("characterId") characterId: string,
|
|
@Param("userId") userId: string,
|
|
@Body() body: SynthesizeBody,
|
|
) {
|
|
const now = body.now ? new Date(body.now) : undefined;
|
|
const [voiceSheet, resolvedProsody, nonverbalCue] = await Promise.all([
|
|
this.voiceSheet.getOrSeedDefault(characterId),
|
|
this.prosody.resolveProsody(characterId, userId, now),
|
|
this.nonverbal.resolveCue(characterId, userId, now),
|
|
]);
|
|
return this.ttsProvider.synthesize({
|
|
text: body.text,
|
|
voiceSheet,
|
|
prosody: resolvedProsody,
|
|
nonverbalCue,
|
|
seed: body.seed,
|
|
});
|
|
}
|
|
|
|
// P-5 驗收用端點:同一段文字配不同副語言特徵,應該得到不同的情緒標記結果。
|
|
@Post(":characterId/analyze-input")
|
|
async analyzeInput(@Param("characterId") characterId: string, @Body() body: AnalyzeInputBody) {
|
|
const now = body.now ? new Date(body.now) : new Date();
|
|
const paralinguisticSignal = this.paralinguistic.deriveSignal(body.paralinguistic) ?? undefined;
|
|
const result = await this.emotion.processInput(characterId, body.text, { now, paralinguisticSignal });
|
|
return { ...result, paralinguisticSignal: paralinguisticSignal ?? null };
|
|
}
|
|
}
|