1
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
package main
|
||||
|
||||
// Edge TTS 客户端:微软 Edge 朗读私有 WebSocket 协议。
|
||||
// 实现对照 edge-tts 7.2.8(drm.py / constants.py / communicate.py)逐行校验:
|
||||
// Sec-MS-GEC 纯算法生成(无 HTTP 预请求),403 时读响应 Date 头校正时钟偏移重试。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
trustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
|
||||
winEpoch = 11644473600 // Unix 与 Windows 文件时间纪元的秒差(1601-01-01)
|
||||
originHeader = "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"
|
||||
audioURL = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
|
||||
audioFormat = "audio-24khz-48kbitrate-mono-mp3"
|
||||
maxChunkBytes = 4096 // 单请求文本字节上限(与 edge-tts 一致,超长分块多轮连接)
|
||||
)
|
||||
|
||||
type ttsClient struct {
|
||||
voice string
|
||||
rate string
|
||||
version string
|
||||
timeout time.Duration
|
||||
retries int
|
||||
clockSkew float64 // 403 时按服务端 Date 头累加校正
|
||||
}
|
||||
|
||||
// permanentErr 协议类错误,重试无意义(不重试)。
|
||||
type permanentErr struct{ err error }
|
||||
|
||||
func (e *permanentErr) Error() string { return e.err.Error() }
|
||||
func (e *permanentErr) Unwrap() error { return e.err }
|
||||
|
||||
// synthesize 合成一段文本,返回 mp3 字节;超长文本按字节上限分块、逐块独立连接后拼接。
|
||||
func (c *ttsClient) synthesize(ctx context.Context, text string) ([]byte, error) {
|
||||
text = escapeXML(cleanText(text))
|
||||
var buf []byte
|
||||
for _, chunk := range splitChunks(text, maxChunkBytes) {
|
||||
audio, err := c.synthesizeChunk(ctx, chunk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf = append(buf, audio...)
|
||||
}
|
||||
if len(buf) == 0 {
|
||||
return nil, gerror.New("合成结果为空")
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func (c *ttsClient) synthesizeChunk(ctx context.Context, text string) ([]byte, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.retries; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
audio, err := c.turn(ctx, text)
|
||||
if err == nil {
|
||||
return audio, nil
|
||||
}
|
||||
lastErr = err
|
||||
var pe *permanentErr
|
||||
if errors.As(err, &pe) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("重试 %d 次仍失败: %w", c.retries, lastErr)
|
||||
}
|
||||
|
||||
// turn 一次连接完成一帧文本的合成,返回 mp3 字节。
|
||||
func (c *ttsClient) turn(ctx context.Context, text string) ([]byte, error) {
|
||||
conn, err := c.dial(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 帧 1:speech.config(输出格式配置)
|
||||
configFrame := fmt.Sprintf(
|
||||
"X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n"+
|
||||
`{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},"outputFormat":"%s"}}}}`+"\r\n",
|
||||
dateString(), audioFormat)
|
||||
// 帧 2:ssml(xml:lang 固定 en-US,与 edge-tts 一致;X-Timestamp 尾缀 Z 是微软端约定)
|
||||
ssml := fmt.Sprintf(
|
||||
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"+
|
||||
"<voice name='%s'><prosody pitch='+0Hz' rate='%s' volume='+0%%'>%s</prosody></voice></speak>",
|
||||
c.voice, c.rate, text)
|
||||
ssmlFrame := fmt.Sprintf(
|
||||
"X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n%s",
|
||||
strings.ReplaceAll(uuid.NewString(), "-", ""), dateString(), ssml)
|
||||
|
||||
conn.SetWriteDeadline(time.Now().Add(c.timeout))
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(configFrame)); err != nil {
|
||||
return nil, fmt.Errorf("发送 speech.config 失败: %w", err)
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(ssmlFrame)); err != nil {
|
||||
return nil, fmt.Errorf("发送 ssml 失败: %w", err)
|
||||
}
|
||||
|
||||
var audio []byte
|
||||
for {
|
||||
conn.SetReadDeadline(time.Now().Add(c.timeout))
|
||||
mt, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读帧失败: %w", err)
|
||||
}
|
||||
switch mt {
|
||||
case websocket.TextMessage:
|
||||
path, err := textFramePath(payload)
|
||||
if err != nil {
|
||||
return nil, &permanentErr{err}
|
||||
}
|
||||
switch path {
|
||||
case "turn.end":
|
||||
if len(audio) == 0 {
|
||||
return nil, &permanentErr{gerror.New("无音频返回(文本或参数可能不被支持)")}
|
||||
}
|
||||
return audio, nil
|
||||
case "turn.start", "response", "audio.metadata":
|
||||
// 忽略
|
||||
default:
|
||||
return nil, &permanentErr{fmt.Errorf("未知响应 Path: %s", path)}
|
||||
}
|
||||
case websocket.BinaryMessage:
|
||||
data, err := audioFrameData(payload)
|
||||
if err != nil {
|
||||
return nil, &permanentErr{err}
|
||||
}
|
||||
audio = append(audio, data...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ttsClient) dial(ctx context.Context) (*websocket.Conn, error) {
|
||||
connID := strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
wsURL := fmt.Sprintf("%s?TrustedClientToken=%s&ConnectionId=%s&Sec-MS-GEC=%s&Sec-MS-GEC-Version=%s",
|
||||
audioURL, trustedClientToken, connID, c.secMsGec(), c.version)
|
||||
header := http.Header{
|
||||
"Pragma": {"no-cache"},
|
||||
"Cache-Control": {"no-cache"},
|
||||
"Origin": {originHeader},
|
||||
"User-Agent": {userAgent},
|
||||
"Accept-Language": {"en-US,en;q=0.9"},
|
||||
"Cookie": {"muid=" + muid() + ";"},
|
||||
}
|
||||
dialer := websocket.Dialer{
|
||||
EnableCompression: true,
|
||||
HandshakeTimeout: c.timeout,
|
||||
}
|
||||
conn, resp, err := dialer.DialContext(ctx, wsURL, header)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusForbidden {
|
||||
// 403 多为时钟偏差:读 Date 头校正偏移,下次握手即有效
|
||||
if serverTS, ok := parseRFC2616Date(resp.Header.Get("Date")); ok {
|
||||
c.clockSkew += serverTS - float64(time.Now().Unix())
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("握手失败: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// secMsGec 生成 Sec-MS-GEC 令牌(edge-tts drm.py generate_sec_ms_gec):
|
||||
// (UTC秒 + 时钟偏差 + 11644473600) 取整到 5 分钟窗 → ×1e9/100 转 Windows 100ns 刻度 →
|
||||
// 拼接 TrustedClientToken → SHA256 大写 hex。
|
||||
func (c *ttsClient) secMsGec() string {
|
||||
ticks := float64(time.Now().Unix()) + c.clockSkew
|
||||
ticks += winEpoch
|
||||
ticks -= math.Mod(ticks, 300)
|
||||
ticks *= 1e9 / 100
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%.0f%s", ticks, trustedClientToken)))
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
// muid 32 位大写 hex 随机串(Cookie: muid=...;)
|
||||
func muid() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return strings.ToUpper(hex.EncodeToString(b))
|
||||
}
|
||||
|
||||
// dateString JavaScript 风格 UTC 时间串(speech.config / ssml 帧头)
|
||||
func dateString() string {
|
||||
return time.Now().UTC().Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
|
||||
}
|
||||
|
||||
// parseRFC2616Date 解析 HTTP Date 头(如 Thu, 14 Aug 2026 12:34:56 GMT)
|
||||
func parseRFC2616Date(s string) (float64, bool) {
|
||||
t, err := time.Parse("Mon, 02 Jan 2006 15:04:05 MST", s)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return float64(t.Unix()), true
|
||||
}
|
||||
|
||||
// cleanText 剔除服务端不支持的 ASCII 控制字符(0-8、11-12、14-31 → 空格)
|
||||
func cleanText(s string) string {
|
||||
rs := []rune(s)
|
||||
for i, r := range rs {
|
||||
if (r >= 0 && r <= 8) || (r >= 11 && r <= 12) || (r >= 14 && r <= 31) {
|
||||
rs[i] = ' '
|
||||
}
|
||||
}
|
||||
return string(rs)
|
||||
}
|
||||
|
||||
// escapeXML 转义 XML 特殊字符(与 saxutils.escape 默认一致:& < >)
|
||||
func escapeXML(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// splitChunks 按字节上限在 rune 边界切分(超长文本分块,避免单请求过大)
|
||||
func splitChunks(s string, maxBytes int) []string {
|
||||
if len(s) <= maxBytes {
|
||||
return []string{s}
|
||||
}
|
||||
var chunks []string
|
||||
var buf []byte
|
||||
for _, r := range s {
|
||||
rb := string(r)
|
||||
if len(buf) > 0 && len(buf)+len(rb) > maxBytes {
|
||||
chunks = append(chunks, string(buf))
|
||||
buf = nil
|
||||
}
|
||||
buf = append(buf, rb...)
|
||||
}
|
||||
if len(buf) > 0 {
|
||||
chunks = append(chunks, string(buf))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// textFramePath 取文本帧的 Path 头(帧结构:头\r\n\r\n载荷)
|
||||
func textFramePath(payload []byte) (string, error) {
|
||||
idx := bytes.Index(payload, []byte("\r\n\r\n"))
|
||||
if idx < 0 {
|
||||
return "", gerror.New("文本帧缺头尾分隔")
|
||||
}
|
||||
return framePath(payload[:idx])
|
||||
}
|
||||
|
||||
// audioFrameData 解析二进制音频帧:前 2 字节大端头长度,头块(自身含尾部 \r\n)后即 mp3 载荷。
|
||||
// 终止帧:无 Content-Type 且载荷为空(实测头文本含尾部 \r\n,与 Python 的 headerLen+2 偏移等价)。
|
||||
func audioFrameData(payload []byte) ([]byte, error) {
|
||||
if len(payload) < 2 {
|
||||
return nil, gerror.New("音频帧缺头长度")
|
||||
}
|
||||
headerLen := int(binary.BigEndian.Uint16(payload[:2]))
|
||||
if 2+headerLen > len(payload) {
|
||||
return nil, gerror.New("音频帧头长度越界")
|
||||
}
|
||||
path, err := framePath(payload[2 : 2+headerLen])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path != "audio" {
|
||||
return nil, fmt.Errorf("二进制帧 Path 非 audio: %s", path)
|
||||
}
|
||||
data := payload[2+headerLen:]
|
||||
contentType, _ := frameHeader(payload[2:2+headerLen], "Content-Type")
|
||||
switch {
|
||||
case contentType == "":
|
||||
if len(data) == 0 {
|
||||
return nil, nil // 终止帧
|
||||
}
|
||||
return nil, gerror.New("无 Content-Type 却有数据")
|
||||
case contentType != "audio/mpeg":
|
||||
return nil, fmt.Errorf("意外的 Content-Type: %s", contentType)
|
||||
case len(data) == 0:
|
||||
return nil, gerror.New("audio/mpeg 帧缺音频数据")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func framePath(headers []byte) (string, error) {
|
||||
return frameHeader(headers, "Path")
|
||||
}
|
||||
|
||||
func frameHeader(headers []byte, key string) (string, error) {
|
||||
for _, line := range bytes.Split(headers, []byte("\r\n")) {
|
||||
k, v, ok := bytes.Cut(line, []byte(":"))
|
||||
if ok && string(k) == key {
|
||||
return string(v), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("帧缺 %s 头", key)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
// gentss:离线生成 TTS 朗读音频(Edge TTS 神经网络语音)并回填各 audio 字段。
|
||||
// 用法:
|
||||
//
|
||||
// go run ./cmd/gentss --list # 打印待生成清单
|
||||
// go run ./cmd/gentss --strategy=1 # 只生成第 1 计(8 节点剧本已导入)
|
||||
// go run ./cmd/gentss --only=script|content|scene|option|teach # 只生成指定内容源
|
||||
// go run ./cmd/gentss --force # 覆盖已生成 mp3 重新合成
|
||||
//
|
||||
// 幂等:mp3 文件已存在且字段已回填 → 跳过(--force 覆盖);文件在但字段空 → 只补回填。
|
||||
// 配置在 config.yml 的 tts 段;产物落 ui-src/static/generated/audio/,URL /static/generated/audio/...(与 PNG 素材同挂载)。
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
)
|
||||
|
||||
var staticDir = "ui-src/static/generated" // 打包入客户端的音频目录
|
||||
|
||||
type genFlags struct {
|
||||
listOnly bool
|
||||
only string // script | content | scene | option | teach
|
||||
strategy int // 0 = 全部计策
|
||||
force bool
|
||||
}
|
||||
|
||||
type ttsConfig struct {
|
||||
voice string
|
||||
rate string
|
||||
version string
|
||||
concurrency int
|
||||
timeout int
|
||||
retries int
|
||||
}
|
||||
|
||||
func main() {
|
||||
fl := parseFlags()
|
||||
cfg := loadTTSConfig()
|
||||
if fl.listOnly {
|
||||
printInventory(fl)
|
||||
return
|
||||
}
|
||||
client := &ttsClient{
|
||||
voice: cfg.voice,
|
||||
rate: cfg.rate,
|
||||
version: cfg.version,
|
||||
timeout: time.Duration(cfg.timeout) * time.Second,
|
||||
retries: cfg.retries,
|
||||
}
|
||||
tasks := enumerateTasks(fl)
|
||||
if len(tasks) == 0 {
|
||||
fmt.Println("[gentss] 无待生成任务")
|
||||
return
|
||||
}
|
||||
sem := make(chan struct{}, cfg.concurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, t := range tasks {
|
||||
t.force = fl.force
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(t task) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if err := runTask(client, t); err != nil {
|
||||
glog.Errorf(ctx, "[gentss] %s 失败: %v", taskDesc(t), err)
|
||||
}
|
||||
}(t)
|
||||
}
|
||||
wg.Wait()
|
||||
fmt.Println("[gentss] 完成")
|
||||
}
|
||||
|
||||
func parseFlags() genFlags {
|
||||
var fl genFlags
|
||||
flag.BoolVar(&fl.listOnly, "list", false, "只打印待生成清单")
|
||||
flag.StringVar(&fl.only, "only", "", "script | content | scene | option | teach,默认全部")
|
||||
flag.IntVar(&fl.strategy, "strategy", 0, "只处理指定计策(strategy_id),0=全部")
|
||||
flag.BoolVar(&fl.force, "force", false, "覆盖已生成音频重新合成")
|
||||
flag.Parse()
|
||||
return fl
|
||||
}
|
||||
|
||||
func loadTTSConfig() ttsConfig {
|
||||
return ttsConfig{
|
||||
voice: g.Cfg().MustGet(ctx, "tts.voice", "zh-CN-XiaoxiaoNeural").String(),
|
||||
rate: g.Cfg().MustGet(ctx, "tts.rate", "+0%").String(),
|
||||
version: g.Cfg().MustGet(ctx, "tts.version", "1-143.0.3650.75").String(),
|
||||
concurrency: g.Cfg().MustGet(ctx, "tts.concurrency", 2).Int(),
|
||||
timeout: g.Cfg().MustGet(ctx, "tts.timeout", 60).Int(),
|
||||
retries: g.Cfg().MustGet(ctx, "tts.retries", 3).Int(),
|
||||
}
|
||||
}
|
||||
|
||||
func taskDesc(t task) string {
|
||||
if t.kind == "script" {
|
||||
return fmt.Sprintf("node %d", t.nodeID)
|
||||
}
|
||||
return t.relPath
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package main
|
||||
|
||||
// 任务枚举与回填:扫描待合成内容 → 逐任务合成 mp3 → 写 ui-src/static/generated/audio/ → 回填各 audio 字段。
|
||||
// 幂等:文件已存在且字段已回填 → 跳过;--force 重生成覆盖;文件在但字段空(半途崩溃)→ 只补回填。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
|
||||
"36wisdom/biz/consts"
|
||||
)
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
// task 一条合成任务。kind 决定文件路径与回填字段。
|
||||
type task struct {
|
||||
kind string // script | content | scene | option | teach
|
||||
levelID int64 // scene/script/content/option 用
|
||||
nodeID int64 // script/content/option 用
|
||||
optionID int64
|
||||
strategyID int64 // teach 用
|
||||
text string
|
||||
relPath string // staticDir 相对路径,如 audio/1/2/content.mp3
|
||||
scriptJSON string // kind=script 时整列 script JSON
|
||||
force bool
|
||||
}
|
||||
|
||||
// scriptLine 台词行(回填 audio 时保持原字段)
|
||||
type scriptLine struct {
|
||||
Emotion string `json:"emotion"`
|
||||
Pinyin string `json:"pinyin"`
|
||||
Speaker string `json:"speaker"`
|
||||
Text string `json:"text"`
|
||||
Audio string `json:"audio,omitempty"`
|
||||
}
|
||||
|
||||
// ---------- 枚举 ----------
|
||||
|
||||
func enumerateTasks(fl genFlags) []task {
|
||||
var tasks []task
|
||||
only := map[string]bool{}
|
||||
if fl.only == "" {
|
||||
only["script"], only["content"], only["scene"], only["option"], only["teach"] = true, true, true, true, true
|
||||
} else {
|
||||
only[fl.only] = true
|
||||
}
|
||||
levelIDs, nodeIDs := strategyScope(fl)
|
||||
scopeLevel := func(m *gdb.Model) *gdb.Model {
|
||||
if fl.strategy > 0 {
|
||||
return m.WhereIn("level_id", levelIDs)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
if only["script"] {
|
||||
m := g.DB().Model(consts.TableSceneNode).Fields("id, level_id, script").Where("script IS NOT NULL AND script != ''")
|
||||
for _, r := range all(scopeLevel(m)) {
|
||||
tasks = append(tasks, task{
|
||||
kind: "script", levelID: r["level_id"].Int64(), nodeID: r["id"].Int64(),
|
||||
scriptJSON: r["script"].String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if only["content"] {
|
||||
m := g.DB().Model(consts.TableSceneNode).Fields("id, level_id, content").Where("content != '' AND (audio IS NULL OR audio = '')")
|
||||
for _, r := range all(scopeLevel(m)) {
|
||||
nid := r["id"].Int64()
|
||||
tasks = append(tasks, task{
|
||||
kind: "content", levelID: r["level_id"].Int64(), nodeID: nid, text: r["content"].String(),
|
||||
relPath: fmt.Sprintf("audio/%d/%d/content.mp3", r["level_id"].Int64(), nid),
|
||||
})
|
||||
}
|
||||
}
|
||||
if only["scene"] {
|
||||
m := g.DB().Model(consts.TableLevel).Fields("id, scene_content").Where("scene_content != '' AND (scene_audio IS NULL OR scene_audio = '')")
|
||||
if fl.strategy > 0 {
|
||||
m = m.Where("strategy_id", fl.strategy)
|
||||
}
|
||||
for _, r := range all(m) {
|
||||
lid := r["id"].Int64()
|
||||
tasks = append(tasks, task{
|
||||
kind: "scene", levelID: lid, text: r["scene_content"].String(),
|
||||
relPath: fmt.Sprintf("audio/%d/scene.mp3", lid),
|
||||
})
|
||||
}
|
||||
}
|
||||
if only["option"] {
|
||||
m := g.DB().Model(consts.TableNodeOption).Fields("id, node_id, text").Where("text != '' AND (audio IS NULL OR audio = '')")
|
||||
if fl.strategy > 0 {
|
||||
m = m.WhereIn("node_id", nodeIDs)
|
||||
}
|
||||
rows := all(m)
|
||||
levelOf := nodeLevelMap(rows)
|
||||
for _, r := range rows {
|
||||
oid, nid := r["id"].Int64(), r["node_id"].Int64()
|
||||
tasks = append(tasks, task{
|
||||
kind: "option", levelID: levelOf[nid], nodeID: nid, optionID: oid, text: r["text"].String(),
|
||||
relPath: fmt.Sprintf("audio/%d/%d/opt_%d.mp3", levelOf[nid], nid, oid),
|
||||
})
|
||||
}
|
||||
}
|
||||
if only["teach"] {
|
||||
m := g.DB().Model(consts.TableStrategy).Fields("id, teach_content").Where("teach_content != '' AND (teach_audio IS NULL OR teach_audio = '')")
|
||||
if fl.strategy > 0 {
|
||||
m = m.Where("id", fl.strategy)
|
||||
}
|
||||
for _, r := range all(m) {
|
||||
sid := r["id"].Int64()
|
||||
tasks = append(tasks, task{
|
||||
kind: "teach", strategyID: sid, text: r["teach_content"].String(),
|
||||
relPath: fmt.Sprintf("audio/teach/%d.mp3", sid),
|
||||
})
|
||||
}
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
// strategyScope 返回某计策涉及的 level_ids 与 node_ids(strategy<=0 时均 nil)
|
||||
func strategyScope(fl genFlags) ([]int64, []int64) {
|
||||
if fl.strategy <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var levelIDs []int64
|
||||
for _, v := range arr(g.DB().Model(consts.TableLevel).Where("strategy_id", fl.strategy), "id") {
|
||||
levelIDs = append(levelIDs, v.Int64())
|
||||
}
|
||||
var nodeIDs []int64
|
||||
for _, v := range arr(g.DB().Model(consts.TableSceneNode).WhereIn("level_id", levelIDs), "id") {
|
||||
nodeIDs = append(nodeIDs, v.Int64())
|
||||
}
|
||||
return levelIDs, nodeIDs
|
||||
}
|
||||
|
||||
// nodeLevelMap 选项行 → 所属关卡(跨表组装:先取 node_ids 再对 scene_node 批量查)
|
||||
func nodeLevelMap(optRows []gdb.Record) map[int64]int64 {
|
||||
nodeIDs := make([]int64, 0, len(optRows))
|
||||
for _, r := range optRows {
|
||||
nodeIDs = append(nodeIDs, r["node_id"].Int64())
|
||||
}
|
||||
out := map[int64]int64{}
|
||||
if len(nodeIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
for _, r := range all(g.DB().Model(consts.TableSceneNode).Fields("id, level_id").WhereIn("id", nodeIDs)) {
|
||||
out[r["id"].Int64()] = r["level_id"].Int64()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 执行 ----------
|
||||
|
||||
func runTask(c *ttsClient, t task) error {
|
||||
if t.kind == "script" {
|
||||
return runScriptTask(c, t)
|
||||
}
|
||||
if strings.TrimSpace(t.text) == "" {
|
||||
return nil
|
||||
}
|
||||
if !t.force && fileExists(filepath.Join(staticDir, t.relPath)) {
|
||||
backfill(t) // 幂等补回填:防上次写文件后未回填
|
||||
return nil
|
||||
}
|
||||
audio, err := c.synthesize(ctx, t.text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFile(filepath.Join(staticDir, t.relPath), audio); err != nil {
|
||||
return err
|
||||
}
|
||||
backfill(t)
|
||||
fmt.Printf("[gentss] ok %-7s %s\n", t.kind, t.relPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runScriptTask 整节点台词:逐行合成(无 audio 的行),全部完成后整列重写 script JSON。
|
||||
func runScriptTask(c *ttsClient, t task) error {
|
||||
var lines []scriptLine
|
||||
if err := json.Unmarshal([]byte(t.scriptJSON), &lines); err != nil {
|
||||
return fmt.Errorf("script JSON 解析失败: %v", err)
|
||||
}
|
||||
changed := false
|
||||
for i := range lines {
|
||||
if strings.TrimSpace(lines[i].Text) == "" {
|
||||
continue
|
||||
}
|
||||
rel := fmt.Sprintf("audio/%d/%d/%d.mp3", t.levelID, t.nodeID, i)
|
||||
file := filepath.Join(staticDir, rel)
|
||||
if t.force || !fileExists(file) {
|
||||
audio, err := c.synthesize(ctx, lines[i].Text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("第 %d 句合成失败: %w", i, err)
|
||||
}
|
||||
if err := writeFile(file, audio); err != nil {
|
||||
return fmt.Errorf("第 %d 句写文件失败: %w", i, err)
|
||||
}
|
||||
}
|
||||
url := "/static/generated/" + rel
|
||||
if lines[i].Audio != url {
|
||||
lines[i].Audio = url
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
b, err := json.Marshal(lines)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := g.DB().Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{"script": string(b)}).Where("id", t.nodeID).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Printf("[gentss] ok %-7s node %d(%d 句)\n", t.kind, t.nodeID, len(lines))
|
||||
return nil
|
||||
}
|
||||
|
||||
// backfill 回填 audio 字段(URL 前缀 /static/generated/ 与 PNG 素材同挂载)
|
||||
func backfill(t task) {
|
||||
url := "/static/generated/" + t.relPath
|
||||
var err error
|
||||
switch t.kind {
|
||||
case "content":
|
||||
_, err = g.DB().Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{"audio": url}).Where("id", t.nodeID).Update()
|
||||
case "scene":
|
||||
_, err = g.DB().Model(consts.TableLevel).Ctx(ctx).Data(g.Map{"scene_audio": url}).Where("id", t.levelID).Update()
|
||||
case "option":
|
||||
_, err = g.DB().Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{"audio": url}).Where("id", t.optionID).Update()
|
||||
case "teach":
|
||||
_, err = g.DB().Model(consts.TableStrategy).Ctx(ctx).Data(g.Map{"teach_audio": url}).Where("id", t.strategyID).Update()
|
||||
}
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "[gentss] 回填 %s 失败: %v", t.relPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 清单 ----------
|
||||
|
||||
func printInventory(fl genFlags) {
|
||||
tasks := enumerateTasks(fl)
|
||||
if len(tasks) == 0 {
|
||||
fmt.Println("[gentss] 无待生成任务")
|
||||
return
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, t := range tasks {
|
||||
counts[t.kind]++
|
||||
}
|
||||
fmt.Printf("待生成任务:script 节点 %d、内容 %d、开场 %d、选项 %d、学堂 %d\n",
|
||||
counts["script"], counts["content"], counts["scene"], counts["option"], counts["teach"])
|
||||
for _, t := range tasks {
|
||||
if t.kind == "script" {
|
||||
fmt.Printf(" script node %d (level %d)\n", t.nodeID, t.levelID)
|
||||
} else {
|
||||
fmt.Printf(" %-7s %s\n", t.kind, t.relPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 工具 ----------
|
||||
|
||||
// all 查询并取全部行(出错即退出,与 genasset 一致)
|
||||
func all(m *gdb.Model) gdb.Result {
|
||||
rows, err := m.Ctx(ctx).All()
|
||||
if err != nil {
|
||||
glog.Fatal(ctx, err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// arr 查询单列值
|
||||
func arr(m *gdb.Model, field string) gdb.Array {
|
||||
vals, err := m.Ctx(ctx).Array(field)
|
||||
if err != nil {
|
||||
glog.Fatal(ctx, err)
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
func writeFile(p string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o644)
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
Reference in New Issue
Block a user