Files
Kokorone/apps/api/src/voice/mock-tts-provider.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

33 lines
1.6 KiB
TypeScript

import { Injectable } from "@nestjs/common";
import type { TTSProvider, TTSSynthesisInput, SynthesizedAudio } from "./tts-provider.js";
import { hashToSeed } from "../llm/seeded-random.js";
const CHARS_PER_SECOND_AT_NORMAL_RATE = 6; // Mock 階段的粗略估算,不代表真實語速
// P-4 MockTTSProvider:輸出韻律標記與佔位音檔參照,不呼叫任何真實語音服務——
// 讓語音管線的其他機制(韻律解析、非語言發聲、副語言分析)都能在沒有真實 TTS 供應商的情況下完整測試。
@Injectable()
export class MockTTSProvider implements TTSProvider {
async synthesize(input: TTSSynthesisInput): Promise<SynthesizedAudio> {
const seed = input.seed ?? hashToSeed(input.text);
const { prosody, nonverbalCue, voiceSheet } = input;
const spokenText = nonverbalCue.interjection ? `${nonverbalCue.interjection} ${input.text}` : input.text;
const durationEstimateMs =
nonverbalCue.pauseSeconds * 1000 + (spokenText.length / CHARS_PER_SECOND_AT_NORMAL_RATE / prosody.rateMultiplier) * 1000;
const prosodyMarkup =
`<voice timbre="${voiceSheet.baseTimbre}">` +
`<break time="${nonverbalCue.pauseSeconds.toFixed(1)}s"/>` +
`<prosody rate="${prosody.rateMultiplier.toFixed(2)}" pitch="${prosody.pitchShiftSemitones >= 0 ? "+" : ""}${prosody.pitchShiftSemitones.toFixed(1)}st" volume="${prosody.volumeMultiplier.toFixed(2)}" contour="${prosody.contour}">` +
spokenText +
`</prosody></voice>`;
return {
audioRef: `mock://tts/${seed}`,
prosodyMarkup,
durationEstimateMs: Math.round(durationEstimateMs),
};
}
}