297 lines
8.8 KiB
Go
297 lines
8.8 KiB
Go
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
|
||
}
|