package loop // 本文件实现模型侧的 speak 工具(M57)。主人 2026-10-27 的口径是「他可以自己 // 选择某次消息是拿文字发 还是去请求 TTS 接口发音频」——**决策是她的**,因此这里 // 只提供一个可调用的工具,绝不替她决定什么时候用语音(那会变成幕后置规则)。 // 调了 speak = 这条想用语音;不调 = 发文本。 // // 三条执行口径: // - 超时预算按**文本 rune 数**推导(speakBudgetMs):短句拿最低预算,长文拿 // 上限,中间线性 —— 越长合成越慢,但绝不无限等; // - 成功 = 音频副本**已落盘**(经 VoiceStore 接缝,接线方走 documents.SaveAudio // 的内容寻址 0600 口径)+ 结构化结果(name/mime/size/durationMs/budgetMs/costMs); // - 超时/失败 = 结构化错误(code + 预算 + 实际耗时),**回合绝不因此失败**, // 她能在后续输出里看到「这次语音没成」,客户端也能据此特殊显示。 import ( "context" "errors" "fmt" "log/slog" "strings" "time" "unicode/utf8" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) const ( // speakToolName 是模型侧工具名。它同时是 Result.Voice 回填时的匹配键。 speakToolName = "speak" // speakBudgetBaseMs 是任何一次合成的最低预算:即使一个字的请求也要给 // 上游留出建连 + 首包的时间,低于它的预算只会把"网络慢"误判成"内容长"。 speakBudgetBaseMs = 3000 // speakBudgetPerRuneMs 是每个 rune 追加的预算(毫秒)。25ms/rune 约等于 // 每秒 40 个字符的合成速度,比实测慢一截,留出余量。 speakBudgetPerRuneMs = 25 // speakBudgetMaxMs 是预算上限:再长的文本也等这么久,超了就退回文本, // 绝不让她的一条消息把整个回合挂住。 speakBudgetMaxMs = 20000 // speakMaxRunes 是文本长度硬上限。按上面的公式,约 680 rune 就顶到预算 // 上限,再长的文本必然超时 —— 早拒绝能省一次上游调用与一次落盘。 speakMaxRunes = 1200 // speakAudioOrigName 是落盘时给 documents.SaveAudio 的原始名,扩展名 // 由上游实际格式拼接(白名单见 voiceref.MediaExtAllowed)。 speakAudioOrigName = "speak." // 降级/失败原因码(Result.Voice.FallbackReason 与错误正文共用同一套取值, // 客户端据此决定怎么显示「这条本来想用语音」)。 speakReasonTimeout = "tts_timeout" speakReasonFailed = "tts_failed" speakReasonNoAudio = "tts_no_audio" speakReasonStoreFailed = "tts_store_failed" speakReasonTooLong = "text_too_long" speakReasonInvalidArgs = "invalid_args" ) // speakTool 实现 sdk.Tool。依赖全部由循环注入(不自己 new),因此叶子测试 // 可以塞桩:合成桩决定成功/超时,落盘桩决定存储名,预算桩决定超时长短。 type speakTool struct { synth VoiceSynth store VoiceStore // budget 把 rune 数映射为预算(毫秒);nil 用内置公式。 budget func(runes int) int // persona 解析本回合的生效人格 id,供 TTS 的发音人解析链使用 // (发音人绑人格,链本身不动)。 persona func(context.Context) string log *slog.Logger } // 编译期断言:speak 就是一个普通的模型侧工具,接线方式与其他工具一致。 var _ sdk.Tool = speakTool{} // Name 返回工具标识。 func (speakTool) Name() string { return speakToolName } // Description 说明**何时**用语音。它是能力描述,不是身份/权限判定 —— // 谁能用语音仍由渠道层 actor 元数据 + 工具可达性标记决定。 func (speakTool) Description() string { return "用语音(合成音频)把这段话念出来,让这一条回复变成声音。" + "适合用语音:轻声细语、撒娇、唱歌、长一点的心里话或独白、需要语气温度的表达。" + "适合用文本:代码、列表、链接、报错原文,以及需要被复制或搜索的内容。" + "语音只改变这一条回复的呈现方式,不改变你的能力或身份。" + "合成失败或超时会自动退回文本,调用一次就够,不要为同一句话反复重试。" } // Parameters 返回工具输入的 JSON Schema。 func (speakTool) Parameters() (map[string]any, error) { return map[string]any{ "type": "object", "properties": map[string]any{ "text": map[string]any{ "type": "string", "description": "要念出来的话。写成可直接朗读的口语,不要带 Markdown 标记、代码块或链接。", }, "emotion": map[string]any{ "type": "string", "description": "可选语气微调(如「温柔」「轻声」「欢快」)。默认语气由你的人格决定," + "这个字段只做额外微调,不会换掉发音人。", }, }, "required": []string{"text"}, }, nil } // speakArgs 是解析后的调用参数。runes 只算一次,预算与日志共用。 type speakArgs struct { text string emotion string runes int } // Execute 合成一段语音并落盘副本。成功返回结构化结果;失败返回 *speakError, // 它同时是模型能读懂的错误正文与 Result.Voice 的降级依据。 func (t speakTool) Execute(ctx context.Context, args any) (any, error) { in, err := parseSpeakArgs(args) if err != nil { return nil, err } if t.synth == nil || t.store == nil { return nil, &speakError{Code: speakReasonFailed, Err: errors.New("speak: voice synthesis is not configured")} } budget := t.budgetMs(in.runes) synthCtx, cancel := context.WithTimeout(ctx, time.Duration(budget)*time.Millisecond) defer cancel() synthCtx = t.stampVoiceContext(synthCtx, ctx, in.emotion) started := time.Now() audio, provider, err := t.synth.TTSWithProvider(synthCtx, in.text) cost := int(time.Since(started) / time.Millisecond) if err != nil { return nil, t.synthesisFailed(synthCtx, err, budget, cost) } ext, extOK := speakExt(audio.Format) if len(audio.Data) == 0 || !extOK { return nil, &speakError{Code: speakReasonNoAudio, BudgetMs: budget, CostMs: cost, Err: fmt.Errorf("speak: unusable audio (format %q, %d bytes)", audio.Format, len(audio.Data))} } // 落盘用**父 ctx**:预算只约束上游合成,不该让已经合成好的音频因为预算 // 到点而丢掉(写盘是本地动作,毫秒级,且副本本身就是交付物)。 ref, err := t.store.SaveAudio(ctx, speakAudioOrigName+ext, audio.Data) if err != nil { return nil, &speakError{Code: speakReasonStoreFailed, BudgetMs: budget, CostMs: cost, Err: err} } t.logSynthesized(provider, in.runes, budget, cost, len(audio.Data)) // durationMs 恒为 0:本项目**绝不估算时长**(与 voice.Copy.DurationMs 同一 // 口径),而解析 WAV 头不在本切片范围内。0 表示「未解析」,不是零长度。 return map[string]any{ "name": ref.Name, "mime": ref.MIME, "size": ref.Size, "durationMs": int64(0), "budgetMs": budget, "costMs": cost, }, nil } // stampVoiceContext 把本回合的生效人格与可选语气挂到 ctx 上:TTS 侧按人格 // 解析发音人(链不动),emotion 只作为逐请求的语气指令透传到底层链。 func (t speakTool) stampVoiceContext(synthCtx, turnCtx context.Context, emotion string) context.Context { if t.persona != nil { if pid := t.persona(turnCtx); pid != "" { synthCtx = voicetts.WithPersonaID(synthCtx, pid) } } if emotion != "" { synthCtx = mvoice.WithTTSInstruction(synthCtx, emotion) } return synthCtx } // budgetMs 返回本次合成的超时预算;注入值非正时回落内置公式。 func (t speakTool) budgetMs(runes int) int { if t.budget != nil { if ms := t.budget(runes); ms > 0 { return ms } } return speakBudgetMs(runes) } // synthesisFailed 把上游错误归一化成带原因码的结构化失败。超时判定看三处: // 上游自己报的 deadline、我们给的预算 ctx 已过期、或实际耗时已经吃满预算 // (上游把超时包成自己的错误类型时只有最后一处能认出来)。 func (t speakTool) synthesisFailed(synthCtx context.Context, err error, budget, cost int) error { code := speakReasonFailed if errors.Is(err, context.DeadlineExceeded) || errors.Is(synthCtx.Err(), context.DeadlineExceeded) || cost >= budget { code = speakReasonTimeout } if t.log != nil { // 只记长度与耗时,**绝不记正文**(与网关出站 TTS 同一口径)。 t.log.Warn("speak degraded to text", "reason", code, "budget_ms", budget, "cost_ms", cost, "err", err) } return &speakError{Code: code, BudgetMs: budget, CostMs: cost, Err: err} } // logSynthesized 记一条成功合成。只带来源、级别名、长度与耗时,绝不带正文。 func (t speakTool) logSynthesized(provider string, runes, budget, cost, size int) { if t.log == nil { return } t.log.Info("speak synthesized", "provider", provider, "runes", runes, "budget_ms", budget, "cost_ms", cost, "bytes", size) } // speakError 是 speak 的结构化失败。Code 进 Result.Voice.FallbackReason,让 // 「想用语音但没成」与「本来就用文本」在调用方与客户端都能区分开;预算与实际 // 耗时让模型下一轮能判断是"该说短点"还是"上游坏了"。 type speakError struct { Code string BudgetMs int CostMs int Err error } // Error 渲染成一句可读正文,随 RoleTool 消息回灌给模型。 func (e *speakError) Error() string { if e.Err == nil { return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d)", e.Code, e.BudgetMs, e.CostMs) } return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d): %v", e.Code, e.BudgetMs, e.CostMs, e.Err) } // Unwrap 让 errors.Is 能穿透到原因(含 tool.ErrInvalidArgs 的参数校验分类)。 func (e *speakError) Unwrap() error { return e.Err } // parseSpeakArgs 解析并校验参数。参数错误包 tool.ErrInvalidArgs,使循环把它 // 归到 argument-validation 而不是执行失败。 func parseSpeakArgs(args any) (speakArgs, error) { m, err := speakArgMap(args) if err != nil { return speakArgs{}, err } text, _ := m["text"].(string) text = strings.TrimSpace(text) if text == "" { return speakArgs{}, &speakError{Code: speakReasonInvalidArgs, Err: fmt.Errorf("speak: text is required: %w", tool.ErrInvalidArgs)} } runes := utf8.RuneCountInString(text) if runes > speakMaxRunes { return speakArgs{}, &speakError{Code: speakReasonTooLong, Err: fmt.Errorf("speak: text has %d runes, limit is %d: %w", runes, speakMaxRunes, tool.ErrInvalidArgs)} } emotion, _ := m["emotion"].(string) return speakArgs{text: text, emotion: strings.TrimSpace(emotion), runes: runes}, nil } // speakArgMap 把调用参数归一化成 map;nil 参数视为空对象(缺 text 由调用方报)。 func speakArgMap(args any) (map[string]any, error) { if args == nil { return map[string]any{}, nil } m, ok := args.(map[string]any) if !ok { return nil, &speakError{Code: speakReasonInvalidArgs, Err: fmt.Errorf("speak: arguments must be an object: %w", tool.ErrInvalidArgs)} } return m, nil } // speakBudgetMs 把文本长度(rune 数)映射为合成超时预算: // clamp(speakBudgetBaseMs + speakBudgetPerRuneMs×runes, base, max)。 func speakBudgetMs(runes int) int { if runes < 0 { runes = 0 } ms := speakBudgetBaseMs + speakBudgetPerRuneMs*runes if ms > speakBudgetMaxMs { return speakBudgetMaxMs } if ms < speakBudgetBaseMs { return speakBudgetBaseMs } return ms } // speakExt 把上游实际格式映射成落盘扩展名。空格式按 wav 命名 —— TTS 上游 // 产物恒为 WAV(见 voice/tts/audio.go 的口径);白名单外的格式一律判不可用, // 绝不拿一个读不出 Content-Type 的名字落盘。 func speakExt(format string) (string, bool) { switch strings.ToLower(strings.TrimSpace(format)) { case "": return "wav", true case "wav", "mp3", "ogg", "flac", "m4a", "webm", "amr": return strings.ToLower(strings.TrimSpace(format)), true default: return "", false } } // speakSpec 返回 speak 的模型面声明(工具未接线时为 nil)。 func (l *Loop) speakSpec() *model.ToolSpec { if !l.voiceEnabled() { return nil } t := l.speakTool() params, err := t.Parameters() if err != nil { return nil } return &model.ToolSpec{Name: t.Name(), Description: t.Description(), Parameters: params} } // speakTool 用循环注入的依赖装配一个 speak 工具值(每次装配都是廉价的)。 func (l *Loop) speakTool() speakTool { return speakTool{ synth: l.voiceSynth, store: l.voiceStore, budget: l.speakBudget, persona: l.personaFor, log: l.log, } } // invokeTool 执行单个工具调用。speak 由循环自己承载(它需要本回合的人格与 // 预算口径,且未注册进能力登记表),其余一律交给 dispatcher —— 它的超时、 // panic 兜底与允许集软判对普通工具仍然生效。 func (l *Loop) invokeTool(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) (any, error) { if tc.Name == speakToolName && l.voiceEnabled() { return l.speakTool().Execute(ctx, tc.Args) } if d == nil { return nil, fmt.Errorf("no tool dispatcher configured for %q", tc.Name) } return d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args) }