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 }