Files
Kokorone/apps/api/src/emotion/emotion.service.ts
T
JefferyandClaude Sonnet 5 0e9401b891 feat: 完成 P 群組 — 語音子系統
新增 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>
2026-08-13 18:19:51 +08:00

161 lines
6.2 KiB
TypeScript

import { Injectable } from "@nestjs/common";
import type { EmotionTag, EmotionState } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
import { RuleBasedEmotionTagger } from "./emotion-tagger.js";
import { nextEmotionState, type TransitionContext } from "./emotion-state-machine.js";
import { toResponseStyle, type ResponseStyle } from "./response-style.js";
import { getArchetypeParams } from "../personality/archetype-params.js";
// D-2 時間衰減:預設半衰期 2 小時,實際速度由角色參數(G 群組)覆寫。
const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000;
// 低於此值視為「沒有明顯情緒」,dominantState 回報平靜。
const DOMINANT_EPSILON = 5;
// 標記強度低於此值視為刺激太弱,不足以真正觸發情緒轉移(高情緒觸發閾值的原型因此「難觸發」)。
const MIN_TRIGGER_INTENSITY = 0.25;
interface Dimensions {
calm: number;
joy: number;
sad: number;
alert: number;
shy: number;
grumpy: number;
}
const NON_CALM_TAGS: Exclude<EmotionTag, "CALM">[] = ["JOY", "SAD", "ALERT", "SHY", "GRUMPY"];
function fieldFor(tag: Exclude<EmotionTag, "CALM">): "joy" | "sad" | "alert" | "shy" | "grumpy" {
return tag.toLowerCase() as "joy" | "sad" | "alert" | "shy" | "grumpy";
}
function dominantState(dims: Dimensions): EmotionTag {
let best: EmotionTag = "CALM";
let bestValue = DOMINANT_EPSILON;
for (const tag of NON_CALM_TAGS) {
const value = dims[fieldFor(tag)];
if (value > bestValue) {
best = tag;
bestValue = value;
}
}
return best;
}
function decayDimensions(dims: Dimensions, elapsedMs: number, halfLifeMs: number): Dimensions {
const factor = elapsedMs > 0 ? Math.pow(0.5, elapsedMs / halfLifeMs) : 1;
const joy = dims.joy * factor;
const sad = dims.sad * factor;
const alert = dims.alert * factor;
const shy = dims.shy * factor;
const grumpy = dims.grumpy * factor;
const calm = Math.max(0, Math.min(100, 100 - (joy + sad + alert + shy + grumpy)));
return { calm, joy, sad, alert, shy, grumpy };
}
export interface ProcessInputOptions extends TransitionContext {
triggerThreshold?: number;
halfLifeMs?: number;
now?: Date;
// I-5:本輪對話沒有明確情緒訊號時,套用作息驅動的基線情緒(不覆蓋文字本身觸發的訊號)。
baselineSignal?: { tag: EmotionTag; intensity: number };
// P-5:語音輸入的副語言特徵(語速/音量/顫抖/停頓)分析出的訊號——文字說「沒事」但聲音在抖,
// 優先於作息基線(副語言是這一輪輸入本身的訊號,比時段基線更直接),但仍不覆蓋文字本身觸發的訊號。
paralinguisticSignal?: { tag: EmotionTag; intensity: number };
}
function toDomain(characterId: string, dims: Dimensions, updatedAt: Date): EmotionState {
return {
characterId,
calm: dims.calm,
joy: dims.joy,
sad: dims.sad,
alert: dims.alert,
shy: dims.shy,
grumpy: dims.grumpy,
updatedAt: updatedAt.toISOString(),
};
}
@Injectable()
export class EmotionService {
constructor(
private readonly prisma: PrismaService,
private readonly tagger: RuleBasedEmotionTagger,
) {}
async getState(characterId: string, now: Date = new Date()): Promise<EmotionState> {
const { dims, updatedAt } = await this.decayedDimensions(characterId, now);
await this.persist(characterId, dims, now);
return toDomain(characterId, dims, now);
}
async getResponseStyle(characterId: string, now: Date = new Date()): Promise<ResponseStyle> {
const state = await this.getState(characterId, now);
return toResponseStyle(dominantState(state));
}
async processInput(
characterId: string,
text: string,
options: ProcessInputOptions = {},
): Promise<{ state: EmotionState; style: ResponseStyle }> {
const now = options.now ?? new Date();
const { dims: decayed } = await this.decayedDimensions(characterId, now, options.halfLifeMs);
const currentDominant = dominantState(decayed);
// G-1:情緒觸發閾值預設由角色性格原型決定,呼叫端仍可明確覆寫。
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const archetypeParams = getArchetypeParams(character.personalityArchetype);
const triggerThreshold = options.triggerThreshold ?? archetypeParams.emotionTriggerThreshold;
const rawSignal = this.tagger.tag({ text, triggerThreshold });
// 情緒觸發閾值除了縮放強度,也要能讓「難觸發」原型對弱刺激完全不觸發(而不只是觸發得比較小力)。
let signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal;
if (signal.tag === "CALM" && options.paralinguisticSignal) {
signal = options.paralinguisticSignal;
} else if (signal.tag === "CALM" && options.baselineSignal) {
signal = options.baselineSignal;
}
const next = nextEmotionState(currentDominant, signal, options);
let updated: Dimensions;
if (next === currentDominant) {
updated = decayed;
} else if (next === "CALM") {
updated = { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 };
} else {
const intensityValue = Math.max(20, signal.intensity * 100);
updated = { calm: Math.max(0, 100 - intensityValue), joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 };
updated[fieldFor(next)] = intensityValue;
}
await this.persist(characterId, updated, now);
return { state: toDomain(characterId, updated, now), style: toResponseStyle(next) };
}
private async decayedDimensions(
characterId: string,
now: Date,
halfLifeMs = DEFAULT_HALF_LIFE_MS,
): Promise<{ dims: Dimensions; updatedAt: Date }> {
const row = await this.prisma.client.emotionState.upsert({
where: { characterId },
update: {},
create: { characterId },
});
const elapsedMs = now.getTime() - row.updatedAt.getTime();
const dims = decayDimensions(row, elapsedMs, halfLifeMs);
return { dims, updatedAt: row.updatedAt };
}
private async persist(characterId: string, dims: Dimensions, now: Date): Promise<void> {
await this.prisma.client.emotionState.update({
where: { characterId },
data: { ...dims, updatedAt: now },
});
}
}
export { dominantState };