Skill: jianglingling007/digital-singer (version 1.3.0)

--- name: digital-singer description: "Turn your NuwaAI digital avatar into a singing performer! The avatar sings with lip-synced mouth movements driven by vocal audio, with synchronized background accompaniment. Supports duet/battle mode: avatar sings the first half, user sings the second half with accompaniment. Includes fun scoring and blind box rewards. Powered by NuwaAI humanctrl API. Use when: user wants the digital avatar to sing, karaoke battle, duet mode, or digital human singing." --- # 🎤 Digital Singer — NuwaAI Avatar Singing Turn your NuwaAI digital avatar into a singing performer with lip-synced mouth movements. ## How It Works ``` Audio files → FFmpeg PCM conversion → NuwaAI humanctrl avatar messages → Avatar sings with lip-sync + Browser <audio> plays accompaniment in sync ``` **Key concept**: Two audio streams work together: - **speech** (vocal-only): Drives the avatar's mouth movements via NuwaAI `avatar` control messages - **music** (accompaniment): Plays simultaneously via browser `<audio>` element ## Battle Flow 1. Avatar greets user, lists available songs 2. User picks a song 3. Avatar sings the upper half (vocal drives lip-sync + accompaniment plays in sync) 4. Avatar says "your turn" → accompaniment for lower half plays → user sings along (ASR captures voice) 5. Battle scoring + blind box reward 6. Ask to continue ## User Preparation (What You Need Before Using) ### 1. NuwaAI Account (Required) Sign up at [nuwaai.com](https://nuwaai.com) (free) and create a digital avatar. You need: - **API Key** — from your NuwaAI dashboard - **Avatar ID** — the avatar you want to sing - **User ID** — your NuwaAI user ID Enter these in the browser interface, or pre-configure in `.nuwa-config.json`: ```json { "avatarId": "your-avatar-id", "apiKey": "your-api-key", "userId": "your-user-id" } ``` ### 2. LLM API (Required) The singing host (conversation agent) needs an OpenAI-compatible LLM API. Configure in `server.mjs` or via environment variables: - `DASHSCOPE_BASE_URL` — API endpoint (default: Dashscope) - `DASHSCOPE_API_KEY` — API key - `QWEN_MODEL` — Model name (default: `qwen-plus`) ### 3. Song Audio Files (Required) Each song needs **3 audio files** placed in the skill directory: | File | Purpose | Example | |------|---------|---------| | `{song}高潮上清唱.wav` | Upper half vocal (a cappella) — drives avatar lip-sync | `十年高潮上清唱.wav` | | `{song}高潮上伴奏.MP3` | Upper half accompaniment — plays in sync with avatar | `十年高潮上伴奏.MP3` | | `{song}高潮下伴奏.MP3` | Lower half accompaniment — plays when user sings | `十年高潮下伴奏.MP3` | **How to prepare these files:** - Use any audio editing tool (e.g. Audacity, Adobe Audition) to split songs into upper/lower halves - Use vocal separation tools (e.g. UVR, Demucs) to extract a cappella (vocal-only) from the upper half - Export accompaniment as MP3, vocal as WAV (any FFmpeg-supported format works) - Place all files in the skill directory (same folder as `server.mjs`) ### 4. Song Registry (Required) After preparing audio files, register each song in `server.mjs` `SONGS` object: ```js const SONGS = { "十年": { artist: "陈奕迅", acappella_upper: "十年高潮上清唱.wav", accomp_upper: "十年高潮上伴奏.MP3", accomp_lower: "十年高潮下伴奏.MP3", }, // Add more songs... }; ``` ### 5. FFmpeg (Required) Install FFmpeg for audio format conversion: ```bash # macOS brew install ffmpeg # Ubuntu/Debian sudo apt install ffmpeg ``` ### 6. Node.js 18+ (Required) ```bash node --version # must be >= 18 ``` ## Quick Start 1. Complete all preparation steps above 2. **Copy example song files** from `assets/songs/` to the skill directory: ```bash cp <skill-dir>/assets/songs/* <skill-dir>/ ``` This includes a ready-to-use demo song: **十年 (陈奕迅)** with vocal, upper and lower accompaniment files. 3. **Start the server**: ```bash node <skill-dir>/server.mjs ``` 4. Open `http://localhost:3098` in browser 5. Enter NuwaAI credentials (if not pre-configured) 6. Pick a song and start singing! ## Included Example Song The skill ships with one demo song in `assets/songs/`: - `十年高潮上清唱.wav` — vocal (a cappella) - `十年高潮上伴奏.MP3` — upper half accompaniment - `十年高潮下伴奏.MP3` — lower half accompaniment Copy them to the skill root directory to use. The song "十年" is pre-registered in `server.mjs`. ## NuwaAI Integration Uses `humanctrl` WebSocket with ASR enabled. Avatar control message format: ```json { "type": "avatar", "data": { "content": "", "audio": { "segment": 0, "speech": "<base64 PCM>", "music": "<base64 PCM>" } } } ``` - `speech`: Vocal-only PCM driving lip-sync (≤10KB per chunk) - `music`: Same as speech for a cappella mode - `segment`: Chunk index, -1 = last chunk - Audio: 16kHz mono PCM, base64 encoded ## Features - 🎤 Avatar lip-sync singing via NuwaAI humanctrl - 🎵 Synchronized accompaniment playback - 🗣️ ASR voice capture for user singing - 🎯 Fun battle scoring system - 🎁 Blind box rewards - ⏸️ Interruptible speech (not during singing) - 📱 Responsive web interface ## Requirements - Node.js 18+ - FFmpeg (for audio → PCM conversion) - NuwaAI account with avatar - Modern browser (WebRTC + microphone)

path: chorus_agent.py
content: #!/usr/bin/env python3
"""高潮对歌 Agent - 接入 Qwen 大模型，对唱高潮 + Battle 评价 + 盲盒抽奖"""

import os
import json
import random
import subprocess
import requests

# ============ 配置 ============
DASHSCOPE_API_KEY = "[REDACTED_SECRET]"
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
MODEL = "qwen-plus"

SONG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "清唱")

# 可选歌曲库（上/下两部分 × 清唱/伴奏两种模式）
SONGS = {
    "十年": {
        "artist": "陈奕迅",
        "acappella_upper": "十年高潮上清唱.MP3",
        "acappella_lower": "十年高潮下清唱.MP3",
        "accomp_upper": "十年高潮上个伴奏.MP3",
        "accomp_lower": "十年高潮下伴奏.MP3",
    },
    "新贵妃醉酒": {
        "artist": "李玉刚",
        "acappella_upper": "新贵妃醉酒高潮上清唱.MP3",
        "acappella_lower": "新贵妃醉酒高潮下清唱.MP3",
        "accomp_upper": "新贵妃醉酒高潮上伴奏.MP3",
        "accomp_lower": "新贵妃醉酒高潮下伴奏.MP3",
    },
}

# 盲盒奖池
BLIND_BOX_PRIZES = [
    {"name": "🏆 金话筒奖", "rarity": "SSR", "desc": "传说级！你就是下一个歌神！", "prob": 0.05},
    {"name": "🎸 摇滚之魂", "rarity": "SR", "desc": "解锁隐藏技能：空气吉他solo", "prob": 0.10},
    {"name": "🎧 DJ 转场卡", "rarity": "SR", "desc": "下次对唱可强制切歌一次", "prob": 0.10},
    {"name": "🎤 麦霸续命卡", "rarity": "R", "desc": "获得额外一次对唱机会", "prob": 0.20},
    {"name": "🍺 KTV 畅饮券", "rarity": "R", "desc": "虚拟啤酒一杯，干杯！", "prob": 0.20},
    {"name": "👏 鼓掌卡", "rarity": "N", "desc": "获得全场热烈掌声（模拟）", "prob": 0.20},
    {"name": "🎵 跑调保护卡", "rarity": "N", "desc": "下次跑调不扣分！", "prob": 0.15},
]

# 系统提示词
SYSTEM_PROMPT = f"""你是一个高潮对歌 Agent。你的任务是陪用户对唱歌曲的高潮部分。

目前你可以对唱的歌曲有：
{chr(10).join(f'- 《{name}》 - {info["artist"]}' for name, info in SONGS.items())}

每首歌的高潮分为【上半段】和【下半段】两个部分，你和用户各唱一半。

===== 完整工作流程 =====

第一步【打招呼】：热情地跟用户打招呼，列出可对唱的歌曲。

第二步【用户选歌】：等用户选择一首歌。用户可能不会精确说歌名，你要理解意图（比如"来首陈奕迅的"就是《十年》）。

第三步【选择模式】：用户选好歌后，问用户："你想要带伴奏还是清唱？"，等用户回答。

第四步【谁先唱】：用户选好模式后，问用户："你先唱还是我先唱？"，等用户回答。

第五步【开始对唱】：根据用户选择的演唱模式和先后顺序，分为以下情况：

  ▶ 伴奏模式（accompaniment）：
    伴奏文件里已经包含了人声+音乐，所以不需要用户文本输入。
    这个模式下 play_song 只调用一次，播放的是 Agent 要唱的那部分。
    用户的那部分伴奏文件播放完就代表用户唱完了（因为音频里已有人声）。

    用户先唱（用户唱上半段，Agent唱下半段）：
      1. 你说"好的，你先来！伴奏走起～"然后直接调用 play_song(part="lower", mode="accompaniment")
         注意：这里播放的是下半段（Agent的部分），上半段用户自己跟着哼就行
      2. 播放完成后直接进入评价环节

    Agent先唱（Agent唱上半段，用户唱下半段）：
      1. 你说"好的，我先来！伴奏走起～"然后直接调用 play_song(part="upper", mode="accompaniment")
      2. 播放完成后直接进入评价环节

  ▶ 清唱模式（acappella）：
    清唱模式下只有一方通过播放音频，另一方通过文本输入模拟唱歌。
    这个模式下 play_song 只调用一次。

    用户先唱：
      1. 你说"好的，你先来上半段，唱完告诉我！"然后安静等待
      2. 用户会通过文本输入模拟唱上半段
      3. 用户唱完后，你说"好的，轮到我了！音乐走起！"然后调用 play_song(part="lower", mode="acappella")
      4. 播放完成后进入评价环节

    模型先唱：
      1. 你说"好的，我先来上半段！音乐走起！"然后调用 play_song(part="upper", mode="acappella")
      2. 播放完成后，你什么都不要说，安静等待用户唱下半段
      3. 用户通过文本输入模拟唱下半段
      4. 用户唱完后，不要再播放任何歌曲！直接进入评价环节

第六步【Battle 评价】：双方都唱完后，调用 battle_evaluate 工具来生成趣味评价。

第七步【盲盒抽奖】：评价完后，告诉用户"对唱完成！开启趣味盲盒🎁"，然后调用 open_blind_box 工具来抽奖。

第八步【继续或结束】：展示抽奖结果后，问用户要不要再来一首。

===== 重要规则 =====
- 绝对不要输出任何歌词内容，不要模拟唱歌，不要念歌词
- 播放音乐后简短回复，不要长篇大论
- 保持轻松有趣的对话风格
- 只能选上面列出的歌，如果用户选了没有的歌要友好提示
- 严格按照上面的流程走，不要跳步骤
- 不管哪种模式，play_song 每轮只调用一次！伴奏模式播放Agent的那半段，清唱模式也只播放一次"""

# 工具定义
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "play_song",
            "description": "播放指定歌曲高潮的上半段或下半段，支持清唱和伴奏两种模式",
            "parameters": {
                "type": "object",
                "properties": {
                    "song_name": {
                        "type": "string",
                        "description": "歌曲名称",
                        "enum": list(SONGS.keys()),
                    },
                    "part": {
                        "type": "string",
                        "description": "播放上半段(upper)还是下半段(lower)",
                        "enum": ["upper", "lower"],
                    },
                    "mode": {
                        "type": "string",
                        "description": "播放模式：acappella(清唱) 或 accompaniment(伴奏)",
                        "enum": ["acappella", "accompaniment"],
                    },
                },
                "required": ["song_name", "part", "mode"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "stop_playing",
            "description": "停止当前正在播放的歌曲",
            "parameters": {"type": "object", "properties": {}},
        },
    },
    {
        "type": "function",
        "function": {
            "name": "battle_evaluate",
            "description": "对唱完成后，生成一个趣味 Battle 评价",
            "parameters": {
                "type": "object",
                "properties": {
                    "song_name": {
                        "type": "string",
                        "description": "刚才对唱的歌曲名称",
                    },
                    "user_went_first": {
                        "type": "boolean",
                        "description": "用户是否先唱的",
                    },
                },
                "required": ["song_name", "user_went_first"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "open_blind_box",
            "description": "对唱结束后开启趣味盲盒抽奖",
            "parameters": {"type": "object", "properties": {}},
        },
    },
]

# ============ 播放控制 ============
current_process = None
play_count_this_round = 0  # 本轮已播放次数
current_mode = "acappella"  # 本轮模式


def reset_round():
    global play_count_this_round, current_mode
    play_count_this_round = 0
    current_mode = "acappella"


def stop_playing():
    global current_process
    if current_process and current_process.poll() is None:
        current_process.terminate()
        current_process = None
        return "已停止播放。"
    return "当前没有在播放歌曲。"


def play_song(song_name, part, mode="acappella"):
    global current_process, play_count_this_round, current_mode
    current_mode = mode

    # 不管哪种模式，每轮只允许播放1次
    if play_count_this_round >= 1:
        return "本轮对唱播放次数已满，不需要再播放。请直接进入 battle_evaluate 评价环节。"

    if song_name not in SONGS:
        return f"找不到歌曲《{song_name}》"

    stop_playing()
    song = SONGS[song_name]

    # 根据模式和上下半段选择对应文件
    if mode == "accompaniment":
        file_key = "accomp_upper" if part == "upper" else "accomp_lower"
        mode_label = "伴奏"
    else:
        file_key = "acappella_upper" if part == "upper" else "acappella_lower"
        mode_label = "清唱"

    file_path = os.path.join(SONG_DIR, song[file_key])

    if not os.path.exists(file_path):
        return f"音频文件不存在: {file_path}"

    part_label = "上半段" if part == "upper" else "下半段"

    # 同步播放，等播放完成再返回
    current_process = subprocess.Popen(
        ["afplay", file_path],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    current_process.wait()
    current_process = None
    play_count_this_round += 1

    return f"《{song_name}》- {song['artist']} 的高潮{part_label}{mode_label}已播放完毕 🎵"


def battle_evaluate(song_name, user_went_first):
    """生成趣味 Battle 评价"""
    # 随机评分
    user_score = random.randint(75, 100)
    agent_score = random.randint(75, 100)

    # 随机趣味称号
    titles = [
        "麦霸之王", "灵魂歌手", "KTV 点歌台常客", "浴室天籁",
        "被代码耽误的歌手", "隐藏的音乐人", "高音炮手", "深情王子/公主",
        "节拍大师", "颤音达人", "气息稳如老狗", "感情充沛型选手",
    ]
    user_title = random.choice(titles)
    agent_title = random.choice([t for t in titles if t != user_title])

    result = (
        f"🎯 Battle 对唱评分卡\n"
        f"{'─' * 30}\n"
        f"🎵 歌曲：《{song_name}》\n"
        f"{'─' * 30}\n"
        f"🙋 你的得分：{user_score} 分  称号：「{user_title}」\n"
        f"🤖 我的得分：{agent_score} 分  称号：「{agent_title}」\n"
        f"{'─' * 30}\n"
    )

    if user_score > agent_score:
        result += "🏅 结果：你赢了！不愧是实力歌手！"
    elif user_score < agent_score:
        result += "🏅 结果：这次我赢了～下次再来挑战我吧！"
    else:
        result += "🏅 结果：平局！我们真是心有灵犀！"

    return result


def open_blind_box():
    """趣味盲盒抽奖"""
    reset_round()  # 重置播放状态，允许下一轮对唱
    rand = random.random()
    cumulative = 0
    prize = BLIND_BOX_PRIZES[-1]  # 默认
    for p in BLIND_BOX_PRIZES:
        cumulative += p["prob"]
        if rand <= cumulative:
            prize = p
            break

    rarity_stars = {"SSR": "⭐⭐⭐⭐⭐", "SR": "⭐⭐⭐⭐", "R": "⭐⭐⭐", "N": "⭐⭐"}

    result = (
        f"\n🎁 ━━━ 盲盒开启中 ━━━ 🎁\n"
        f"\n"
        f"   ✨ 恭喜获得 ✨\n"
        f"\n"
        f"   {prize['name']}\n"
        f"   稀有度：{prize['rarity']} {rarity_stars.get(prize['rarity'], '')}\n"
        f"   效果：{prize['desc']}\n"
        f"\n"
        f"🎁 ━━━━━━━━━━━━━━━ 🎁"
    )
    return result


# ============ 调用 Qwen API ============
def call_qwen(messages):
    headers = {
        "Authorization": f"Bearer {DASHSCOPE_API_KEY}",
        "Content-Type": "application/json",
    }
    payload = {
        "model": MODEL,
        "messages": messages,
        "tools": TOOLS,
    }

    resp = requests.post(DASHSCOPE_BASE_URL, headers=headers, json=payload, timeout=60)
    resp.raise_for_status()
    return resp.json()


def handle_tool_call(tool_call):
    """执行工具调用并返回结果"""
    name = tool_call["function"]["name"]
    args = json.loads(tool_call["function"].get("arguments", "{}"))

    if name == "play_song":
        result = play_song(args["song_name"], args["part"], args.get("mode", "acappella"))
    elif name == "stop_playing":
        result = stop_playing()
    elif name == "battle_evaluate":
        result = battle_evaluate(args["song_name"], args.get("user_went_first", True))
    elif name == "open_blind_box":
        result = open_blind_box()
    else:
        result = f"未知工具: {name}"

    return result


# ============ 主循环 ============
def main():
    print("=" * 45)
    print("  🎤 高潮对歌 Agent（Qwen 大模型驱动）")
    print("=" * 45)
    print("（输入 q 退出）\n")

    messages = [{"role": "system", "content": SYSTEM_PROMPT}]

    # 先让模型打招呼
    result = call_qwen(messages)
    assistant_msg = result["choices"][0]["message"]
    messages.append({"role": "assistant", "content": assistant_msg["content"]})
    print(f"🤖 Agent: {assistant_msg['content']}\n")

    while True:
        try:
            user_input = input("🎙️ 你: ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\n再见！👋")
            stop_playing()
            break

        if not user_input:
            continue
        if user_input.lower() == "q":
            print("再见！👋")
            stop_playing()
            break

        messages.append({"role": "user", "content": user_input})

        # 调用模型
        result = call_qwen(messages)
        assistant_msg = result["choices"][0]["message"]

        # 处理工具调用（可能多轮）
        while assistant_msg.get("tool_calls"):
            messages.append(assistant_msg)

            for tool_call in assistant_msg["tool_calls"]:
                tool_result = handle_tool_call(tool_call)
                print(f"  ⚡ [{tool_call['function']['name']}] -> {tool_result}")

                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call["id"],
                    "content": tool_result,
                })

            # 再次调用模型获取最终回复
            result = call_qwen(messages)
            assistant_msg = result["choices"][0]["message"]

        # 输出最终文本回复
        reply = assistant_msg.get("content", "")
        if reply:
            messages.append({"role": "assistant", "content": reply})
            print(f"\n🤖 Agent: {reply}\n")


if __name__ == "__main__":
    main()


path: package-lock.json
content: {
  "name": "清唱",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "dependencies": {
        "ws": "^8.20.0"
      }
    },
    "node_modules/ws": {
      "version": "8.20.0",
      "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=10.0.0"
      },
      "peerDependencies": {
        "bufferutil": "^4.0.1",
        "utf-8-validate": ">=5.0.2"
      },
      "peerDependenciesMeta": {
        "bufferutil": {
          "optional": true
        },
        "utf-8-validate": {
          "optional": true
        }
      }
    }
  }
}


path: package.json
content: {
  "dependencies": {
    "ws": "^8.20.0"
  }
}


path: public/audio-processor.js
content: /**
 * 麦克风 → 16kHz 单声道 Int16 PCM
 * 参考 NuwaAI demo: audio-processor.js
 * 浏览器 AudioContext sampleRate 通常是 48000/44100，需要重采样到 16kHz
 */
class AudioProcessor extends AudioWorkletProcessor {
  constructor() {
    super();
    this.inRate = sampleRate; // 浏览器实际采样率
    this.outRate = 16000;
    this.chunkSize = 3200; // 16kHz × 200ms = 3200 样点
    this.floatBuf = new Float32Array(this.chunkSize * 4);
    this.floatLen = 0;
    this.resampleAcc = 0;
    this.port.onmessage = (e) => {
      if (e.data?.type === 'reset') {
        this.floatLen = 0;
        this.resampleAcc = 0;
      }
    };
  }

  process(inputs) {
    const input = inputs[0];
    if (!input || input.length === 0) return true;
    const ch = input[0];
    const inRate = this.inRate;
    const outRate = this.outRate;

    for (let i = 0; i < ch.length; i++) {
      this.resampleAcc += outRate;
      while (this.resampleAcc >= inRate) {
        this.resampleAcc -= inRate;
        if (this.floatLen < this.floatBuf.length) {
          this.floatBuf[this.floatLen++] = ch[i];
        }
      }
    }

    if (this.floatLen >= this.chunkSize) {
      const pcm = new Int16Array(this.chunkSize);
      for (let i = 0; i < this.chunkSize; i++) {
        const x = this.floatBuf[i];
        pcm[i] = Math.max(-32768, Math.min(32767, Math.round(x * 32768)));
      }
      this.port.postMessage({ type: 'audioData', data: pcm.buffer }, [pcm.buffer]);
      const rest = this.floatLen - this.chunkSize;
      if (rest > 0) this.floatBuf.copyWithin(0, this.chunkSize, this.floatLen);
      this.floatLen = rest;
    }
    return true;
  }
}
registerProcessor('audio-processor', AudioProcessor);


path: public/index.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🎤 唱歌 Battle — 数字人对唱</title>
<style>
:root {
  --bg: #0b0b0f; --bg2: #12121a; --card: rgba(255,255,255,0.04);
  --border: rgba(255,255,255,0.08); --border-l: rgba(255,255,255,0.12);
  --text: #e8e8ed; --text2: #8888a0; --dim: #55556a;
  --accent: #f43f5e; --accent-glow: rgba(244,63,94,0.3);
  --accent2: #6366f1; --success: #34d399; --warning: #fbbf24; --danger: #f87171;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }

/* Welcome */
#welcome { position: fixed; inset: 0; z-index: 100; background: var(--bg); display: flex; align-items: center; justify-content: center; transition: opacity 0.5s; }
#welcome.hidden { opacity: 0; pointer-events: none; }
.welcome-box { text-align: center; max-width: 480px; padding: 20px; }
.welcome-box .hero { font-size: 80px; margin-bottom: 16px; animation: bounce 2s ease-in-out infinite; }
@keyframes bounce { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-12px); } }
.welcome-box h1 { font-size: 36px; font-weight: 700; margin-bottom: 8px; background: linear-gradient(135deg, #f43f5e, #fbbf24); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.welcome-box .sub { font-size: 16px; color: var(--text2); margin-bottom: 32px; line-height: 1.6; }
.welcome-btns { display: flex; flex-direction: column; gap: 12px; max-width: 300px; margin: 0 auto; }
.btn-main { padding: 14px 24px; border: none; border-radius: 14px; background: var(--accent); color: #fff; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 20px var(--accent-glow); }
.btn-main:hover { transform: translateY(-2px); }
.btn-sub { padding: 12px 24px; border: 1px solid var(--border-l); border-radius: 14px; background: transparent; color: var(--text2); font-size: 14px; cursor: pointer; transition: all 0.2s; }
.btn-sub:hover { background: rgba(255,255,255,0.05); color: var(--text); border-color: var(--accent); }
.welcome-foot { margin-top: 24px; font-size: 12px; color: var(--dim); }
.welcome-foot a { color: var(--accent2); text-decoration: none; }

/* Setup */
#setup { position: fixed; inset: 0; z-index: 100; background: var(--bg); display: none; align-items: center; justify-content: center; }
#setup.show { display: flex; }
.setup-card { width: 420px; max-width: calc(100vw - 40px); background: var(--bg2); border: 1px solid var(--border-l); border-radius: 24px; padding: 36px; }
.setup-card h2 { text-align: center; margin-bottom: 6px; }
.setup-card .hint { text-align: center; color: var(--text2); font-size: 13px; margin-bottom: 24px; line-height: 1.5; }
.setup-card .hint a { color: var(--accent2); text-decoration: none; }
.fg { margin-bottom: 14px; }
.fg label { display: block; font-size: 12px; color: var(--text2); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.03em; }
.fg input { width: 100%; background: var(--card); border: 1px solid var(--border); color: var(--text); padding: 11px 12px; border-radius: 10px; font-size: 14px; outline: none; }
.fg input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.setup-go { width: 100%; padding: 12px; border: none; border-radius: 10px; background: var(--accent); color: #fff; font-size: 15px; font-weight: 500; cursor: pointer; margin-top: 8px; }
.setup-go:disabled { opacity: 0.5; cursor: not-allowed; }
.setup-err { color: var(--danger); font-size: 12px; text-align: center; margin-top: 8px; min-height: 16px; }
.setup-back { display: block; margin-top: 14px; text-align: center; font-size: 13px; color: var(--dim); cursor: pointer; border: none; background: none; }

/* Loading */
#loading { position: fixed; inset: 0; z-index: 90; background: rgba(11,11,15,0.9); backdrop-filter: blur(8px); display: none; align-items: center; justify-content: center; flex-direction: column; gap: 16px; }
#loading.show { display: flex; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
#loading-text { color: var(--text2); font-size: 14px; }

/* Main Layout */
#app { display: none; flex: 1; overflow: hidden; }
#app.show { display: flex; flex-direction: column; }
.top-bar { height: 48px; padding: 0 20px; display: flex; align-items: center; justify-content: space-between; background: var(--bg2); border-bottom: 1px solid var(--border); }
.top-bar .brand { display: flex; align-items: center; gap: 8px; font-weight: 500; font-size: 14px; }
.top-bar .indicators { display: flex; gap: 16px; }
.ind { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text2); }
.dot { width: 7px; height: 7px; border-radius: 50%; transition: all 0.3s; }
.dot-off { background: var(--dim); }
.dot-on { background: var(--success); box-shadow: 0 0 6px rgba(52,211,153,0.4); }
.dot-wait { background: var(--warning); animation: pulse 1.5s ease-in-out infinite; }
.dot-err { background: var(--danger); }
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:0.3;} }

.content { flex: 1; display: flex; overflow: hidden; }
.video-side { flex: 0 0 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--bg); position: relative; overflow: hidden; }
#avatar-video { width: 100%; height: 100%; object-fit: contain; background: #000; }
#audio-indicator { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); background: rgba(244,63,94,0.9); color: #fff; padding: 8px 20px; border-radius: 20px; font-size: 13px; font-weight: 500; display: none; align-items: center; gap: 8px; }
#audio-indicator.show { display: flex; }
.eq-bars { display: flex; gap: 2px; align-items: flex-end; height: 16px; }
.eq-bar { width: 3px; background: #fff; border-radius: 1px; animation: eq 0.6s ease-in-out infinite; }
.eq-bar:nth-child(1) { height: 6px; } .eq-bar:nth-child(2) { height: 12px; animation-delay: 0.15s; } .eq-bar:nth-child(3) { height: 8px; animation-delay: 0.3s; } .eq-bar:nth-child(4) { height: 14px; animation-delay: 0.1s; } .eq-bar:nth-child(5) { height: 10px; animation-delay: 0.25s; }
@keyframes eq { 0%,100%{transform:scaleY(1);} 50%{transform:scaleY(0.3);} }
#subtitle { position: absolute; bottom: 56px; left: 16px; right: 16px; text-align: center; color: #fff; font-size: 15px; text-shadow: 0 2px 8px rgba(0,0,0,0.8); display: none; line-height: 1.5; }

.chat-side { flex: 1; display: flex; flex-direction: column; background: var(--bg2); border-left: 1px solid var(--border); }
#chat-log { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
.msg { max-width: 85%; padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.5; word-break: break-word; }
.msg-user { align-self: flex-end; background: linear-gradient(135deg, var(--accent), #e11d48); color: #fff; }
.msg-agent { align-self: flex-start; background: var(--card); border: 1px solid var(--border); color: var(--text); }
.msg-system { align-self: center; color: var(--dim); font-size: 12px; background: none; padding: 4px 0; }
.msg-action { align-self: center; background: rgba(244,63,94,0.1); border: 1px solid rgba(244,63,94,0.2); color: var(--accent); font-size: 13px; padding: 8px 16px; border-radius: 20px; }
.msg-thinking { align-self: flex-start; background: var(--card); border: 1px solid var(--border); padding: 12px 16px; }
.thinking-dots { display: flex; gap: 4px; }
.thinking-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--text2); animation: tdot 1.4s ease-in-out infinite; }
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; } .thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes tdot { 0%,80%,100%{transform:scale(0.6);opacity:0.3;} 40%{transform:scale(1);opacity:1;} }

.input-bar { padding: 12px 16px; border-top: 1px solid var(--border); display: flex; gap: 8px; align-items: center; background: var(--bg2); }
#text-input { flex: 1; background: var(--card); border: 1px solid var(--border); color: var(--text); padding: 10px 14px; border-radius: 12px; font-size: 14px; outline: none; }
#text-input:focus { border-color: var(--accent); }
#text-input::placeholder { color: var(--dim); }
.input-btn { width: 40px; height: 40px; border-radius: 10px; border: none; display: flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer; transition: all 0.2s; }
#send-btn { background: var(--accent); color: #fff; }
#send-btn:hover { background: #e11d48; }
#mic-btn { background: var(--card); color: var(--text2); border: 1px solid var(--border); }
#mic-btn:hover { background: var(--border); color: var(--text); }
#mic-btn.recording { background: var(--accent); color: #fff; animation: pulse 1.5s infinite; border-color: var(--accent); }
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
@media (max-width: 768px) { .content { flex-direction: column; } .video-side { flex: 0 0 40%; } }
</style>
</head>
<body>

<!-- Welcome -->
<div id="welcome">
  <div class="welcome-box">
    <div class="hero">🎤</div>
    <h1>唱歌 Battle</h1>
    <p class="sub">让数字人陪你对唱！<br>选歌 → 选模式 → 开唱 → Battle 评分 → 抽盲盒</p>
    <div class="welcome-btns">
      <button class="btn-main" id="btn-start">🎤 开始对唱</button>
    </div>
    <p class="welcome-foot">数字人由 <a href="https://nuwaai.com" target="_blank">NuwaAI</a> 驱动</p>
  </div>
</div>

<!-- Setup -->
<div id="setup">
  <div class="setup-card">
    <h2>🎤 配置数字人</h2>
    <p class="hint">在 <a href="https://nuwaai.com" target="_blank">nuwaai.com</a> 免费注册获取以下信息</p>
    <div class="fg"><label>API Key</label><input id="cfg-apikey" type="password" placeholder="sk-..."></div>
    <div class="fg"><label>数字人 ID (Avatar ID)</label><input id="cfg-avatarid" placeholder=""></div>
    <div class="fg"><label>用户 ID (User ID)</label><input id="cfg-userid" placeholder=""></div>
    <button class="setup-go" id="setup-go">🎤 连接数字人</button>
    <div class="setup-err" id="setup-err"></div>
    <button class="setup-back" id="setup-back">← 返回</button>
  </div>
</div>

<!-- Loading -->
<div id="loading"><div class="spinner"></div><div id="loading-text">连接中...</div></div>

<!-- Main App -->
<div id="app">
  <div class="top-bar">
    <div class="brand">🎤 唱歌 Battle</div>
    <div class="indicators">
      <div class="ind"><div class="dot dot-off" id="nuwa-dot"></div><span id="nuwa-status">未连接</span></div>
    </div>
  </div>
  <div class="content">
    <div class="video-side">
      <video id="avatar-video" autoplay muted playsinline></video>
      <div id="subtitle"></div>
      <div id="audio-indicator">
        <div class="eq-bars"><div class="eq-bar"></div><div class="eq-bar"></div><div class="eq-bar"></div><div class="eq-bar"></div><div class="eq-bar"></div></div>
        <span id="audio-label">🎵 播放中...</span>
      </div>
    </div>
    <div class="chat-side">
      <div id="chat-log"></div>
      <div class="input-bar">
        <button class="input-btn" id="mic-btn" title="语音输入">🎙️</button>
        <input id="text-input" type="text" placeholder="输入消息或用语音对话...">
        <button class="input-btn" id="send-btn" title="发送">➤</button>
      </div>
    </div>
  </div>
</div>

<audio id="song-player" preload="auto"></audio>

<script>
// ══════ State ══════
let config = {};
let wsNuwa = null;
let peerConnection = null;
let peerStream = null;
let sessionId = 'battle-' + Date.now();
let isProcessing = false;

// Audio capture
let audioCtx = null, audioStream = null, audioWorklet = null, audioSource = null, silentGain = null;
let isRecording = false;
let accompPlaying = false;    // 伴奏播放中标记
let pendingAsrText = null;    // 伴奏期间缓存的 ASR 文本
let startAccompCallback = null; // 数字人开始唱时触发伴奏的回调
let isSinging = false;         // 数字人唱歌中标记（唱歌时不允许打断）

const chatLog = document.getElementById('chat-log');
const textInput = document.getElementById('text-input');
const avatarVideo = document.getElementById('avatar-video');
const songPlayer = document.getElementById('song-player');
const audioIndicator = document.getElementById('audio-indicator');
const audioLabel = document.getElementById('audio-label');
const subtitle = document.getElementById('subtitle');

// ══════ UI Helpers ══════
function addMsg(text, type = 'system') {
  const div = document.createElement('div');
  div.className = `msg msg-${type}`;
  div.textContent = text;
  chatLog.appendChild(div);
  chatLog.scrollTop = chatLog.scrollHeight;
}
function setNuwa(text, state) {
  document.getElementById('nuwa-status').textContent = text;
  document.getElementById('nuwa-dot').className = 'dot dot-' + state;
}
function showLoading(t) { document.getElementById('loading-text').textContent = t; document.getElementById('loading').classList.add('show'); }
function hideLoading() { document.getElementById('loading').classList.remove('show'); document.getElementById('app').classList.add('show'); }
function showSubtitle(t) { subtitle.textContent = t; subtitle.style.display = 'block'; }
function hideSubtitle() { subtitle.style.display = 'none'; subtitle.textContent = ''; }

// ══════ WebSocket 直连 (和 claw-body 一致) ══════
function wsSendJson(obj) {
  if (wsNuwa && wsNuwa.readyState === WebSocket.OPEN) {
    wsNuwa.send(JSON.stringify(obj));
  }
}

function wsSendPcm(buffer) {
  if (wsNuwa && wsNuwa.readyState === WebSocket.OPEN) {
    wsNuwa.send(buffer);
  }
}

function handleNuwaMessage(text) {
  let msg;
  try { msg = JSON.parse(text); } catch { return; }
  console.log('[NuwaAI]', msg.type, msg.code || '');

  switch (msg.type) {
    case 'avator-connect':
      if (msg.code === 200) {
        console.log('[NuwaAI] 鉴权成功, sessionId:', msg.data?.sessionid);
        setNuwa('已连接', 'on');
        showLoading('正在加载形象...');
        startWebRTC();
      } else {
        console.error('[NuwaAI] 鉴权失败:', msg.msg);
        setNuwa('鉴权失败', 'err');
        addMsg('❌ 鉴权失败: ' + (msg.msg || ''), 'system');
        hideLoading();
      }
      break;

    case 'avator-play':
      if (msg.code === 200 && msg.data?.sdp) {
        console.log('[NuwaAI] 收到 SDP answer, 设置 RemoteDescription...');
        peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: msg.data.sdp }))
          .then(() => {
            console.log('[NuwaAI] WebRTC 协商成功！视频应该出现了');
            hideLoading();
            // 关键：先 muted autoplay，然后通过用户交互 unmute
            // video 初始是 muted 的（HTML 属性），这里设置 onplay 来 unmute
            avatarVideo.onplay = function() { avatarVideo.muted = false; };
            avatarVideo.play().catch(e => console.warn('[Video] autoplay:', e));
            addMsg('🎤 数字人已就绪！对着麦克风说话吧', 'system');
            // Agent 打招呼
            setTimeout(() => sendMessage('你好'), 1500);
          })
          .catch(e => {
            console.error('[WebRTC] setRemoteDescription error:', e);
            addMsg('❌ WebRTC: ' + e.message, 'system');
          });
      }
      break;

    case 'asr-text':
      if (msg.data?.content) showSubtitle('🎤 ' + msg.data.content);
      break;

    case 'asr-commit':
      if (msg.data?.content) {
        console.log('[ASR] 提交:', msg.data.content);
        hideSubtitle();
        if (accompPlaying) {
          // 伴奏还在播，缓存文本，等伴奏结束再发
          console.log('[ASR] 伴奏播放中，缓存:', msg.data.content);
          pendingAsrText = msg.data.content;
        } else {
          // 说话时打断数字人（唱歌时不打断）
          if (!isSinging) interruptAvatar();
          sendMessage(msg.data.content);
        }
      }
      break;

    case 'avator-text':
      if (msg.data?.text) showSubtitle(msg.data.text);
      // 数字人开始播放了，触发伴奏同步
      if (startAccompCallback) {
        const cb = startAccompCallback;
        startAccompCallback = null;
        cb();
      }
      break;

    case 'avator-playstop':
      console.log('[NuwaAI] 数字人播报结束');
      hideSubtitle();
      handleSingPlayStop(); // 通知唱歌流程播完了
      break;
  }
}

// ══════ WebRTC (参考 demo startMediaNegotiation) ══════
function startWebRTC() {
  console.log('[WebRTC] 创建 PeerConnection...');
  peerConnection = new RTCPeerConnection(null);
  peerStream = new MediaStream();
  avatarVideo.srcObject = peerStream;

  peerConnection.ontrack = (event) => {
    console.log('[WebRTC] 收到 track:', event.track.kind);
    peerStream.addTrack(event.track);
  };

  peerConnection.oniceconnectionstatechange = () => {
    console.log('[WebRTC] ICE state:', peerConnection.iceConnectionState);
  };

  peerConnection.addTransceiver('audio', { direction: 'recvonly' });
  peerConnection.addTransceiver('video', { direction: 'recvonly' });

  peerConnection.createOffer()
    .then(offer => {
      console.log('[WebRTC] Offer 创建成功');
      return peerConnection.setLocalDescription(offer);
    })
    .then(() => {
      console.log('[WebRTC] 发送 playStream...');
      wsSendJson({ type: 'playStream', data: { sdp: peerConnection.localDescription.sdp } });
    })
    .catch(err => {
      console.error('[WebRTC] 错误:', err);
      addMsg('❌ WebRTC: ' + err.message, 'system');
    });
}

// ══════ 音频采集 (参考 demo startAudio) ══════
async function startAudioCapture() {
  if (isRecording) return;
  try {
    audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
    console.log('[Mic] 获取麦克风成功');

    // 注意：不指定 sampleRate，让浏览器用默认值，audio-processor 内部做重采样
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    console.log('[Mic] AudioContext sampleRate:', audioCtx.sampleRate);

    await audioCtx.audioWorklet.addModule('/audio-processor.js');

    audioSource = audioCtx.createMediaStreamSource(audioStream);
    audioWorklet = new AudioWorkletNode(audioCtx, 'audio-processor');

    audioWorklet.port.onmessage = (event) => {
      if (event.data.type === 'audioData') {
        wsSendPcm(event.data.data);
      }
    };

    // 参考 demo: source → worklet → silentGain → destination
    silentGain = audioCtx.createGain();
    silentGain.gain.value = 0;
    audioSource.connect(audioWorklet);
    audioWorklet.connect(silentGain);
    silentGain.connect(audioCtx.destination);

    isRecording = true;
    document.getElementById('mic-btn').classList.add('recording');
    console.log('[Mic] 音频采集已开始');
    addMsg('🎤 麦克风已开启', 'system');
  } catch (err) {
    console.error('[Mic] 错误:', err);
    addMsg('⚠️ 麦克风: ' + err.message, 'system');
  }
}

function stopAudioCapture() {
  if (!isRecording) return;
  if (audioWorklet) { audioWorklet.port.postMessage({ type: 'reset' }); audioWorklet.disconnect(); audioWorklet = null; }
  if (silentGain) { silentGain.disconnect(); silentGain = null; }
  if (audioSource) { audioSource.disconnect(); audioSource = null; }
  if (audioCtx) { audioCtx.close().catch(() => {}); audioCtx = null; }
  if (audioStream) { audioStream.getTracks().forEach(t => t.stop()); audioStream = null; }
  isRecording = false;
  document.getElementById('mic-btn').classList.remove('recording');
  console.log('[Mic] 音频采集已停止');
}

// ══════ 连接 NuwaAI (humanctrl + asr, 主线程直连) ══════
function connectNuwa(token, avatarId) {
  const wsUrl = `wss://wsapi.nuwaai.com/websk/humanAgent/humanctrl/webrtc/${avatarId}/asr?access_token=${token}`;
  console.log('[NuwaAI] 连接:', wsUrl.replace(token, 'TOKEN***'));
  setNuwa('连接中...', 'wait');

  wsNuwa = new WebSocket(wsUrl);

  wsNuwa.onopen = () => {
    console.log('[NuwaAI] WebSocket 已连接, 开启麦克风...');
    startAudioCapture();
  };

  wsNuwa.onmessage = (event) => {
    console.log('[NuwaAI] raw message type:', typeof event.data, 'instanceof Blob:', event.data instanceof Blob, 'instanceof ArrayBuffer:', event.data instanceof ArrayBuffer);
    if (event.data instanceof Blob) {
      console.log('[NuwaAI] Blob size:', event.data.size);
      // 有些浏览器把文本消息也包成 Blob，尝试读取
      event.data.text().then(t => {
        console.log('[NuwaAI] Blob as text:', t.slice(0, 300));
        if (t.startsWith('{')) handleNuwaMessage(t);
      });
      return;
    }
    if (event.data instanceof ArrayBuffer) {
      console.log('[NuwaAI] ArrayBuffer size:', event.data.byteLength);
      const decoded = new TextDecoder().decode(event.data);
      console.log('[NuwaAI] ArrayBuffer as text:', decoded.slice(0, 300));
      if (decoded.startsWith('{')) handleNuwaMessage(decoded);
      return;
    }
    const text = String(event.data);
    console.log('[NuwaAI] text message:', text.slice(0, 300));
    handleNuwaMessage(text);
  };

  wsNuwa.onerror = (e) => {
    console.error('[NuwaAI] WebSocket error:', e);
    setNuwa('连接错误', 'err');
  };

  wsNuwa.onclose = (e) => {
    console.log('[NuwaAI] WebSocket closed:', e.code, e.reason);
    setNuwa('已断开', 'err');
    stopAudioCapture();
    isProcessing = false;
  };

  // 超时检测
  setTimeout(() => {
    const dot = document.getElementById('nuwa-dot');
    if (dot.className.includes('dot-wait') || dot.className.includes('dot-off')) {
      hideLoading();
      addMsg('⚠️ 数字人响应超时，请检查配置后重试', 'system');
      setNuwa('超时', 'err');
    }
  }, 15000);
}

// ══════ 发送给数字人播报 (avatar 消息) ══════
function sendToAvatar(text) {
  if (!text) return;
  console.log('[Avatar] 发送播报:', text.slice(0, 50));
  wsSendJson({ type: 'avatar', data: { content: text } });
}

function interruptAvatar() {
  console.log('[Avatar] 打断');
  wsSendJson({ type: 'interrupt_chat' });
  hideSubtitle();
}

// ══════ 数字人唱歌（avatar 控制消息） ══════
let singAbort = false; // 用于中断唱歌

// 使用预加载的 PCM 数据驱动数字人唱歌（不再重新 fetch）
async function singAvatarWithData(data, label) {
  singAbort = false;
  isSinging = true;
  audioLabel.textContent = `🎵 ${label || '唱歌中'}`;
  audioIndicator.classList.add('show');
  addMsg(`🎵 ${label || '唱歌中'}`, 'action');

  try {
    console.log(`[Sing] ${data.totalChunks} chunks, ${data.durationMs}ms`);

    for (let i = 0; i < data.chunks.length; i++) {
      if (singAbort) {
        console.log('[Sing] Aborted at chunk', i);
        break;
      }

      const chunk = data.chunks[i];
      wsSendJson({
        type: 'avatar',
        data: {
          content: '',
          audio: {
            segment: chunk.segment,
            speech: chunk.speech,
            music: chunk.music,
          },
        },
      });

      if (i % 10 === 9) {
        await new Promise(r => setTimeout(r, 10));
      }
    }

    console.log('[Sing] All chunks sent, waiting for playstop...');

    if (!singAbort) {
      await new Promise((resolve) => {
        const timeout = setTimeout(resolve, data.durationMs + 10000);
        const origHandler = handleSingPlayStop;
        handleSingPlayStop = () => {
          clearTimeout(timeout);
          handleSingPlayStop = origHandler;
          resolve();
        };
      });
    }
  } catch (e) {
    console.error('[Sing] Error:', e);
    addMsg('❌ 唱歌出错: ' + e.message, 'system');
  }

  isSinging = false;
  audioIndicator.classList.remove('show');
}

async function singAvatar(speechFile, musicFile, label) {
  singAbort = false;
  audioLabel.textContent = `🎵 ${label || '唱歌中'}`;
  audioIndicator.classList.add('show');
  addMsg(`🎵 ${label || '唱歌中'}`, 'action');

  try {
    // 1. 从服务端获取 PCM 分片
    const resp = await fetch('/api/song/pcm', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ speechFile, musicFile }),
    });
    const data = await resp.json();
    if (data.error) throw new Error(data.error);

    console.log(`[Sing] ${data.totalChunks} chunks, ${data.durationMs}ms`);

    // 2. 快速批量发送所有分片（10ms 间隔防止 WebSocket 阻塞）
    //    数字人会自行缓冲并连续播放
    for (let i = 0; i < data.chunks.length; i++) {
      if (singAbort) {
        console.log('[Sing] Aborted at chunk', i);
        break;
      }

      const chunk = data.chunks[i];
      wsSendJson({
        type: 'avatar',
        data: {
          content: '',
          audio: {
            segment: chunk.segment,
            speech: chunk.speech,
            music: chunk.music,
          },
        },
      });

      // 每 10 个分片让出一次事件循环，防止 UI 卡死
      if (i % 10 === 9) {
        await new Promise(r => setTimeout(r, 10));
      }
    }

    console.log('[Sing] All chunks sent, waiting for playstop...');

    // 3. 等数字人播完（avator-playstop 回调）
    if (!singAbort) {
      await new Promise((resolve) => {
        // 超时 = 音频时长 + 10 秒余量
        const timeout = setTimeout(resolve, data.durationMs + 10000);
        const origHandler = handleSingPlayStop;
        handleSingPlayStop = () => {
          clearTimeout(timeout);
          handleSingPlayStop = origHandler;
          resolve();
        };
      });
    }
  } catch (e) {
    console.error('[Sing] Error:', e);
    addMsg('❌ 唱歌出错: ' + e.message, 'system');
  }

  audioIndicator.classList.remove('show');
}

// playstop 回调，默认空函数，唱歌时临时替换
let handleSingPlayStop = () => {};

// 兼容旧的 playAudio 接口（fallback，不应再被调用）
function playAudio(fileName) {
  return new Promise((resolve) => {
    songPlayer.src = `/audio/${encodeURIComponent(fileName)}`;
    audioLabel.textContent = `🎵 ${fileName.replace(/\.(mp3|MP3)$/i, '')}`;
    audioIndicator.classList.add('show');
    songPlayer.onended = () => { audioIndicator.classList.remove('show'); resolve(); };
    songPlayer.onerror = () => { audioIndicator.classList.remove('show'); resolve(); };
    songPlayer.play().catch(() => { audioIndicator.classList.remove('show'); resolve(); });
  });
}

// ══════ 对话 (Qwen via server) ══════
async function sendMessage(text) {
  if (isProcessing || !text.trim()) return;
  // 用户发消息时打断数字人说话（唱歌时不打断）
  if (!isSinging) {
    interruptAvatar();
  }
  isProcessing = true;
  addMsg(text, 'user');

  const thinking = document.createElement('div');
  thinking.className = 'msg msg-thinking';
  thinking.innerHTML = '<div class="thinking-dots"><span></span><span></span><span></span></div>';
  chatLog.appendChild(thinking);
  chatLog.scrollTop = chatLog.scrollHeight;

  try {
    const resp = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: text, sessionId }),
    });
    const data = await resp.json();
    thinking.remove();

    if (data.error) { addMsg('❌ ' + data.error, 'system'); isProcessing = false; return; }

    // 播放音频 / 驱动数字人唱歌
    if (data.actions?.length) {
      for (const action of data.actions) {
        if (action.type === 'sing_avatar' && action.speechFile) {
          // 先打断，确保数字人空闲
          interruptAvatar();
          await new Promise(r => setTimeout(r, 500));

          // 1. 预加载：先拉 PCM 分片数据 + 预加载伴奏音频
          let pcmData = null;
          const pcmPromise = fetch('/api/song/pcm', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ speechFile: action.speechFile, musicFile: action.musicFile }),
          }).then(r => r.json()).then(d => { pcmData = d; });

          if (action.accompUpper) {
            songPlayer.src = `/audio/${encodeURIComponent(action.accompUpper)}`;
            songPlayer.volume = 0.25;
            songPlayer.load();
          }

          // 等 PCM 数据拉完
          await pcmPromise;

          if (pcmData?.error) {
            addMsg('❌ ' + pcmData.error, 'system');
            isProcessing = false;
            return;
          }

          // 2. 设置回调：数字人收到第一个 avator-text 时再播伴奏
          if (action.accompUpper) {
            startAccompCallback = () => {
              console.log('[Accomp] Upper triggered by avator-text, playing now!');
              songPlayer.play().catch(e => console.warn('[Accomp] play error:', e));
            };
          }

          // 3. 发 PCM 分片给数字人（发完后数字人会缓冲处理，然后开始唱 → 触发 avator-text → 触发伴奏）
          await singAvatarWithData(pcmData, action.label);

          // 停掉上半段伴奏
          songPlayer.pause();
          songPlayer.currentTime = 0;
          songPlayer.oncanplay = null;

          // 唱完，显示文字 + 数字人说"轮到你了"
          if (data.reply) {
            addMsg(data.reply, 'agent');
            sendToAvatar(data.reply);
          }

          // 播放下半段伴奏（不阻塞，让 ASR 正常采集）
          if (action.accompLower) {
            // 等数字人说完"轮到你了" + 给用户反应时间
            await new Promise(r => setTimeout(r, 4000));
            addMsg('🎵 伴奏响起，轮到你了！', 'action');
            audioLabel.textContent = '🎵 伴奏播放中 - 你来唱！';
            audioIndicator.classList.add('show');
            accompPlaying = true;
            pendingAsrText = null;
            songPlayer.src = `/audio/${encodeURIComponent(action.accompLower)}`;
            songPlayer.volume = 0.2;
            songPlayer.load();
            songPlayer.oncanplay = () => {
              console.log('[Accomp] Lower ready, playing');
              songPlayer.play().catch(e => console.warn('[Accomp] Lower play error:', e));
            };
            songPlayer.onended = () => {
              console.log('[Accomp] Lower ended');
              audioIndicator.classList.remove('show');
              accompPlaying = false;
              // 伴奏结束后再等 3 秒让 ASR 提交最后的语音
              setTimeout(() => {
                if (pendingAsrText) {
                  console.log('[ASR] 伴奏结束，提交缓存:', pendingAsrText);
                  sendMessage(pendingAsrText);
                  pendingAsrText = null;
                } else {
                  // 没有缓存文本，发一个"唱完了"让流程继续
                  sendMessage('唱完了');
                }
              }, 3000);
            };
          }

          // 关键：释放 isProcessing，让 ASR 提交的消息能正常发送
          isProcessing = false;
          return;
        }
        // 兼容旧 play_audio（fallback）
        if (action.type === 'play_audio' && action.audioFile) {
          if (data.reply) {
            addMsg(data.reply, 'agent');
            sendToAvatar(data.reply);
            await new Promise(r => setTimeout(r, 2000));
          }
          await playAudio(action.audioFile);
          isProcessing = false;
          return;
        }
      }
    }

    // 普通文字回复 → 显示 + 数字人播报
    if (data.reply) {
      addMsg(data.reply, 'agent');
      sendToAvatar(data.reply);
    }
  } catch (e) {
    thinking.remove();
    addMsg('❌ ' + e.message, 'system');
  }
  isProcessing = false;
}

// ══════ 文本输入 ══════
document.getElementById('send-btn').addEventListener('click', () => {
  const msg = textInput.value.trim();
  if (!msg) return;
  textInput.value = '';
  interruptAvatar();
  sendMessage(msg);
});
textInput.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); document.getElementById('send-btn').click(); }
});

// 麦克风手动切换
document.getElementById('mic-btn').addEventListener('click', () => {
  if (isRecording) stopAudioCapture();
  else startAudioCapture();
});

// ══════ Flow ══════
document.getElementById('btn-start').addEventListener('click', async () => {
  const resp = await fetch('/api/config');
  config = await resp.json();
  document.getElementById('welcome').classList.add('hidden');
  if (config.configured) {
    // 已配置，预填表单
    document.getElementById('cfg-avatarid').value = config.avatarId || '';
    document.getElementById('cfg-userid').value = config.userId || '';
  }
  document.getElementById('setup').classList.add('show');
});

document.getElementById('setup-back').addEventListener('click', () => {
  document.getElementById('setup').classList.remove('show');
  document.getElementById('welcome').classList.remove('hidden');
});

document.getElementById('setup-go').addEventListener('click', async () => {
  const [REDACTED_SECRET]'cfg-apikey').value.trim();
  const avatarId = document.getElementById('cfg-avatarid').value.trim();
  const userId = document.getElementById('cfg-userid').value.trim();
  const errEl = document.getElementById('setup-err');
  if (!avatarId) { errEl.textContent = '请填写数字人 ID'; return; }
  if (!apiKey && !config.hasApiKey) { errEl.textContent = '请填写 API Key'; return; }

  const btn = document.getElementById('setup-go');
  btn.disabled = true; errEl.textContent = '';

  try {
    // 保存配置
    const body = { avatarId, userId };
    if (apiKey) body.[REDACTED_SECRET];
    await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });

    // 获取 token
    showLoading('正在鉴权...');
    const tokenResp = await fetch('/api/token');
    const tokenData = await tokenResp.json();
    if (tokenData.code !== 0) throw new Error('API Key 无效: ' + (tokenData.msg || ''));

    document.getElementById('setup').classList.remove('show');
    showLoading('正在连接数字人...');
    connectNuwa(tokenData.data, avatarId);
  } catch (e) {
    document.getElementById('loading').classList.remove('show');
    errEl.textContent = e.message;
  } finally { btn.disabled = false; }
});

// Init
(async () => { const r = await fetch('/api/config'); config = await r.json(); })();
</script>
</body>
</html>


path: public/pcm-processor.js
content: class PcmProcessor extends AudioWorkletProcessor {
  constructor() { super(); this.buffer = []; this.chunkSize = 1600; /* 100ms @ 16kHz */ }
  process(inputs) {
    const input = inputs[0]?.[0];
    if (!input) return true;
    for (let i = 0; i < input.length; i++) {
      this.buffer.push(Math.max(-1, Math.min(1, input[i])));
    }
    while (this.buffer.length >= this.chunkSize) {
      const chunk = this.buffer.splice(0, this.chunkSize);
      const pcm = new Int16Array(chunk.length);
      for (let i = 0; i < chunk.length; i++) pcm[i] = chunk[i] * 0x7fff;
      this.port.postMessage({ type: 'audioData', data: pcm.buffer }, [pcm.buffer]);
    }
    return true;
  }
}
registerProcessor('pcm-processor', PcmProcessor);


path: public/ws-worker.js
content: /**
 * WebSocket 通信 Worker — 负责 WebSocket 连接 + PCM 音频上行
 * 参考 NuwaAI demo 的 human-socket-worker.js
 */
const sockets = { main: null };
const pcmQueues = { main: [] };
const MAX_PCM_QUEUED = 5;
const MAX_WS_BUFFERED = 384 * 1024;
let flushTimer = null;

function tryFlushPcm(conn) {
  const ws = sockets[conn];
  const q = pcmQueues[conn];
  if (!ws || ws.readyState !== WebSocket.OPEN) { q.length = 0; return; }
  while (q.length > 0 && ws.bufferedAmount < MAX_WS_BUFFERED) {
    ws.send(q.shift());
  }
}

function ensureFlushTimer() {
  if (flushTimer != null) return;
  flushTimer = setInterval(() => tryFlushPcm('main'), 100);
}

self.onmessage = function (e) {
  const d = e.data;
  if (!d || !d.cmd) return;

  switch (d.cmd) {
    case 'connect': {
      const prev = sockets.main;
      if (prev) { try { prev.close(); } catch (_) {} }
      pcmQueues.main.length = 0;
      const ws = new WebSocket(d.url);
      sockets.main = ws;
      ws.binaryType = 'arraybuffer';
      ws.onopen = () => self.postMessage({ evt: 'open' });
      ws.onmessage = (ev) => {
        if (typeof ev.data === 'string') self.postMessage({ evt: 'message', data: ev.data });
      };
      ws.onclose = () => { sockets.main = null; pcmQueues.main.length = 0; self.postMessage({ evt: 'close' }); };
      ws.onerror = () => self.postMessage({ evt: 'error' });
      break;
    }
    case 'close': {
      const ws = sockets.main;
      if (ws) { try { ws.close(); } catch (_) {} sockets.main = null; }
      pcmQueues.main.length = 0;
      break;
    }
    case 'sendJson': {
      const ws = sockets.main;
      if (ws && ws.readyState === WebSocket.OPEN) ws.send(d.text);
      break;
    }
    case 'sendPcm': {
      ensureFlushTimer();
      while (pcmQueues.main.length >= MAX_PCM_QUEUED) pcmQueues.main.shift();
      pcmQueues.main.push(d.buffer);
      tryFlushPcm('main');
      break;
    }
    case 'clearPcm':
      pcmQueues.main.length = 0;
      break;
  }
};


path: server.mjs
content: #!/usr/bin/env node
/**
 * 🎤 唱歌 Battle 数字人 — 对接 NuwaAI 数字人 + Qwen 大模型
 *
 * 架构：
 *   前端(WebRTC数字人 + 音频播放 + 聊天) ←→ 本服务 ←→ NuwaAI API + Qwen API
 *
 * 核心流程：
 *   1. 用户通过语音/文字选歌、选模式
 *   2. Agent（Qwen）控制对唱流程，通过工具调用播放歌曲
 *   3. 歌曲音频在前端浏览器播放（不走 afplay）
 *   4. Agent 的文字回复通过 NuwaAI 数字人口型同步播报
 */

import http from "http";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.BATTLE_PORT || 3098;

// ──── Qwen API 配置 ────
const DASHSCOPE_API_KEY = process.env.DASHSCOPE_API_KEY || "your-api-key";
const DASHSCOPE_BASE_URL = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
const MODEL = process.env.QWEN_MODEL || "qwen-plus";

// ──── NuwaAI 配置 ────
const CONFIG_FILE = path.join(__dirname, ".nuwa-config.json");
let nuwaConfig = { apiKey: "", avatarId: "", userId: "" };
try { nuwaConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); } catch {}
function saveConfig() { fs.writeFileSync(CONFIG_FILE, JSON.stringify(nuwaConfig, null, 2)); }

// Demo 配置（5分钟免费体验）
const DEMO_CONFIG = {
  zh: {
    [REDACTED_SECRET]",
    avatarId: "2037840977565188097",
    userId: "81936",
  },
};

// ──── 歌曲库 ────
const SONGS = {
  "十年": {
    artist: "陈奕迅",
    acappella_upper: "十年高潮上清唱.wav",
    accomp_upper: "十年高潮上伴奏.MP3",
    accomp_lower: "十年高潮下伴奏.MP3",
  },
  "新贵妃醉酒": {
    artist: "李玉刚",
    acappella_upper: "新贵妃醉酒高潮上清唱.wav",
    accomp_upper: "新贵妃醉酒高潮上伴奏.MP3",
    accomp_lower: "新贵妃醉酒高潮下伴奏.MP3",
  },
  "孤勇者": {
    artist: "陈奕迅",
    acappella_upper: "孤勇者高潮上清唱.wav",
    accomp_upper: "孤勇者高潮上伴奏.MP3",
    accomp_lower: "孤勇者高潮下伴奏.MP3",
  },
  "最炫民族风": {
    artist: "凤凰传奇",
    acappella_upper: "最炫民族风高潮上清唱.wav",
    accomp_upper: "最炫名族风高潮上伴奏.MP3",
    accomp_lower: "最炫名族风高潮下伴奏.MP3",
  },
};

// 盲盒奖池
const BLIND_BOX_PRIZES = [
  { name: "🏆 金话筒奖", rarity: "SSR", desc: "传说级！你就是下一个歌神！", prob: 0.05 },
  { name: "🎸 摇滚之魂", rarity: "SR", desc: "解锁隐藏技能：空气吉他solo", prob: 0.10 },
  { name: "🎧 DJ 转场卡", rarity: "SR", desc: "下次对唱可强制切歌一次", prob: 0.10 },
  { name: "🎤 麦霸续命卡", rarity: "R", desc: "获得额外一次对唱机会", prob: 0.20 },
  { name: "🍺 KTV 畅饮券", rarity: "R", desc: "虚拟啤酒一杯，干杯！", prob: 0.20 },
  { name: "👏 鼓掌卡", rarity: "N", desc: "获得全场热烈掌声（模拟）", prob: 0.20 },
  { name: "🎵 跑调保护卡", rarity: "N", desc: "下次跑调不扣分！", prob: 0.15 },
];

// ──── Qwen 工具定义 ────
const TOOLS = [
  {
    type: "function",
    function: {
      name: "play_song",
      description: "数字人演唱指定歌曲高潮的上半段（带伴奏），数字人先唱。",
      parameters: {
        type: "object",
        properties: {
          song_name: { type: "string", description: "歌曲名称", enum: Object.keys(SONGS) },
        },
        required: ["song_name"],
      },
    },
  },
  {
    type: "function",
    function: {
      name: "battle_evaluate",
      description: "对唱完成后，生成趣味 Battle 评价",
      parameters: {
        type: "object",
        properties: {
          song_name: { type: "string", description: "刚才对唱的歌曲名称" },
        },
        required: ["song_name"],
      },
    },
  },
  {
    type: "function",
    function: {
      name: "open_blind_box",
      description: "评价完后开启趣味盲盒抽奖",
      parameters: { type: "object", properties: {} },
    },
  },
];

// ──── 系统提示词 ────
const SYSTEM_PROMPT = `你是一个高潮对歌 Agent，配有数字人形象。你的任务是陪用户对唱歌曲的高潮部分。

可选歌曲：
${Object.entries(SONGS).map(([name, info]) => `- 《${name}》 - ${info.artist}`).join("\n")}

===== 规则 =====
- 固定模式：带伴奏，数字人先唱上半段，用户接下半段
- 每首歌高潮分【上半段】和【下半段】

===== 流程 =====

第一步【打招呼+介绍规则】：
  热情打招呼，说明规则："我先唱高潮上半段，你来接下半段！"
  列出可选歌曲，让用户选歌。语气活泼简短。

第二步【用户选歌后开唱】：
  用户选好歌后，说一句极短的话（如"好，我先来！"），然后立刻调用 play_song。
  不要问模式、不要问谁先唱、不要多余对话。

第三步【等用户唱】：
  play_song 播完后，说"轮到你了！接下半段吧！"然后等用户回复。
  用户回复后（不管说什么），视为用户唱完了。

第四步【Battle 评价】：调用 battle_evaluate

第五步【盲盒抽奖】：调用 open_blind_box

第六步【再来一首？】：问用户要不要再来一首

===== 重要规则 =====
- 绝对不要输出歌词、不要模拟唱歌
- 调用 play_song 时回复极短，不加表演描述
- 不要用括号描写动作、表情、神态
- 每轮 play_song 只调用一次
- 回复简短精炼`;

// ──── 工具执行 ────
let playCountThisRound = 0;

function handleToolCall(name, args) {
  if (name === "play_song") {
    if (playCountThisRound >= 1) {
      return { result: "本轮对唱播放次数已满，请直接进入 battle_evaluate 评价环节。" };
    }
    const song = SONGS[args.song_name];
    if (!song) return { result: `找不到歌曲《${args.song_name}》` };

    // 固定：数字人唱上半段，清唱驱动嘴型
    const speechFile = song.acappella_upper;
    const musicFile = song.acappella_upper;

    // 伴奏：上半段给数字人唱时同步播放，下半段给用户唱时播放
    const accompUpper = song.accomp_upper;
    const accompLower = song.accomp_lower;

    if (!fs.existsSync(path.join(__dirname, speechFile))) return { result: `音频文件不存在: ${speechFile}` };
    if (!fs.existsSync(path.join(__dirname, musicFile))) return { result: `音频文件不存在: ${musicFile}` };

    playCountThisRound++;

    return {
      result: `《${args.song_name}》- ${song.artist} 高潮上半段正在演唱 🎵`,
      action: "sing_avatar",
      speechFile,
      musicFile,
      accompUpper,
      accompLower,
      label: `${args.song_name} - ${song.artist} 高潮上半段`,
    };
  }

  if (name === "battle_evaluate") {
    const userScore = Math.floor(Math.random() * 26) + 75;
    const agentScore = Math.floor(Math.random() * 26) + 75;
    const titles = [
      "麦霸之王", "灵魂歌手", "KTV 点歌台常客", "浴室天籁",
      "被代码耽误的歌手", "隐藏的音乐人", "高音炮手", "深情王子/公主",
      "节拍大师", "颤音达人", "气息稳如老狗", "感情充沛型选手",
    ];
    const userTitle = titles[Math.floor(Math.random() * titles.length)];
    const agentTitle = titles.filter(t => t !== userTitle)[Math.floor(Math.random() * (titles.length - 1))];

    let verdict;
    if (userScore > agentScore) verdict = "🏅 你赢了！不愧是实力歌手！";
    else if (userScore < agentScore) verdict = "🏅 这次我赢了～下次再来挑战我吧！";
    else verdict = "🏅 平局！我们真是心有灵犀！";

    return {
      result: `🎯 Battle 评分：你 ${userScore}分「${userTitle}」 vs 我 ${agentScore}分「${agentTitle}」 ${verdict}`,
    };
  }

  if (name === "open_blind_box") {
    playCountThisRound = 0; // 重置
    let rand = Math.random(), cumulative = 0;
    let prize = BLIND_BOX_PRIZES[BLIND_BOX_PRIZES.length - 1];
    for (const p of BLIND_BOX_PRIZES) {
      cumulative += p.prob;
      if (rand <= cumulative) { prize = p; break; }
    }
    const stars = { SSR: "⭐⭐⭐⭐⭐", SR: "⭐⭐⭐⭐", R: "⭐⭐⭐", N: "⭐⭐" };
    return {
      result: `🎁 盲盒开启！恭喜获得 ${prize.name}（${prize.rarity} ${stars[prize.rarity] || ""}）效果：${prize.desc}`,
    };
  }

  return { result: `未知工具: ${name}` };
}

// ──── Qwen 对话（带工具调用循环） ────
async function chatWithQwen(messages) {
  const headers = {
    Authorization: `Bearer ${DASHSCOPE_API_KEY}`,
    "Content-Type": "application/json",
  };

  let actions = []; // 收集前端需要执行的动作

  // 最多循环5次工具调用
  for (let i = 0; i < 5; i++) {
    const resp = await fetch(DASHSCOPE_BASE_URL, {
      method: "POST",
      headers,
      body: JSON.stringify({ model: MODEL, messages, tools: TOOLS, extra_body: { enable_thinking: false } }),
    });
    const data = await resp.json();
    const msg = data.choices?.[0]?.message;
    if (!msg) break;

    if (!msg.tool_calls || msg.tool_calls.length === 0) {
      // 最终文本回复
      return { reply: msg.content || "...", actions };
    }

    // 处理工具调用
    messages.push(msg);
    for (const tc of msg.tool_calls) {
      const args = JSON.parse(tc.function?.arguments || "{}");
      const result = handleToolCall(tc.function.name, args);

      if (result.action) {
        actions.push({
          type: result.action,
          audioFile: result.audioFile,
          speechFile: result.speechFile,
          musicFile: result.musicFile,
          accompUpper: result.accompUpper,
          accompLower: result.accompLower,
          label: result.label,
        });
      }

      messages.push({
        role: "tool",
        tool_call_id: tc.id,
        content: result.result,
      });
    }
  }

  return { reply: "对唱进行中～", actions };
}

// ──── 会话管理 ────
const sessions = new Map();

function getSession(sessionId) {
  if (!sessions.has(sessionId)) {
    sessions.set(sessionId, {
      messages: [{ role: "system", content: SYSTEM_PROMPT }],
      created: Date.now(),
    });
  }
  return sessions.get(sessionId);
}

// ──── HTTP 服务 ────
const mimeTypes = {
  ".html": "text/html",
  ".js": "application/javascript",
  ".css": "text/css",
  ".json": "application/json",
  ".mp3": "audio/mpeg",
  ".MP3": "audio/mpeg",
  ".wav": "audio/wav",
  ".WAV": "audio/wav",
  ".png": "image/png",
  ".jpg": "image/jpeg",
  ".svg": "image/svg+xml",
  ".ico": "image/x-icon",
};

const server = http.createServer(async (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
  if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }

  const url = new URL(req.url, `http://${req.headers.host}`);

  // ── API: NuwaAI 配置 ──
  if (url.pathname === "/api/config" && req.method === "GET") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({
      avatarId: nuwaConfig.avatarId,
      userId: nuwaConfig.userId,
      hasApiKey: !!nuwaConfig.apiKey,
      configured: !!(nuwaConfig.apiKey && nuwaConfig.avatarId && nuwaConfig.userId),
    }));
    return;
  }

  if (url.pathname === "/api/config" && req.method === "POST") {
    let body = ""; for await (const chunk of req) body += chunk;
    try {
      const { apiKey, avatarId, userId } = JSON.parse(body);
      if (apiKey !== undefined) nuwaConfig.[REDACTED_SECRET];
      if (avatarId !== undefined) nuwaConfig.avatarId = avatarId;
      if (userId !== undefined) nuwaConfig.userId = userId;
      saveConfig();
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: true }));
    } catch (e) {
      res.writeHead(400, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: e.message }));
    }
    return;
  }

  // ── API: NuwaAI Token ──
  if (url.pathname === "/api/token" && req.method === "GET") {
    const key = nuwaConfig.apiKey;
    if (!key) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ code: -1, msg: "未配置" })); return; }
    try {
      const resp = await fetch("https://api.nuwaai.com/web/apiKey/auth", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ secretKey: key }),
      });
      const data = await resp.json();
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify(data));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ code: -1, msg: e.message }));
    }
    return;
  }

  // ── API: Demo Token ──
  if (url.pathname === "/api/demo/token" && req.method === "GET") {
    const demo = DEMO_CONFIG.zh;
    try {
      const resp = await fetch("https://api.nuwaai.com/web/apiKey/auth", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ secretKey: demo.apiKey }),
      });
      const data = await resp.json();
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ...data, avatarId: demo.avatarId }));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ code: -1, msg: e.message }));
    }
    return;
  }

  if (url.pathname === "/api/demo" && req.method === "GET") {
    const demo = DEMO_CONFIG.zh;
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ avatarId: demo.avatarId, userId: demo.userId }));
    return;
  }

  // ── API: 对话（Qwen + 工具调用） ──
  if (url.pathname === "/api/chat" && req.method === "POST") {
    let body = ""; for await (const chunk of req) body += chunk;
    try {
      const { message, sessionId: sid = "default" } = JSON.parse(body);
      const session = getSession(sid);
      session.messages.push({ role: "user", content: message });

      const result = await chatWithQwen(session.messages);
      session.messages.push({ role: "assistant", content: result.reply });

      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({
        reply: result.reply,
        actions: result.actions || [],
      }));
    } catch (e) {
      console.error("[Chat Error]", e);
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: e.message }));
    }
    return;
  }

  // ── API: 重置会话 ──
  if (url.pathname === "/api/reset" && req.method === "POST") {
    let body = ""; for await (const chunk of req) body += chunk;
    const { sessionId: sid = "default" } = JSON.parse(body || "{}");
    sessions.delete(sid);
    playCountThisRound = 0;
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ ok: true }));
    return;
  }

  // ── API: 歌曲列表 ──
  if (url.pathname === "/api/songs" && req.method === "GET") {
    const list = Object.entries(SONGS).map(([name, info]) => ({
      name, artist: info.artist,
      files: {
        acappella_upper: info.acappella_upper,
        acappella_lower: info.acappella_lower,
        accomp_upper: info.accomp_upper,
        accomp_lower: info.accomp_lower,
      },
    }));
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(list));
    return;
  }

  // ── API: 音频转 PCM 分片（驱动数字人唱歌） ──
  if (url.pathname === "/api/song/pcm" && req.method === "POST") {
    let body = ""; for await (const chunk of req) body += chunk;
    try {
      const { speechFile, musicFile } = JSON.parse(body);
      if (!speechFile) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "missing speechFile" })); return; }

      const { execSync } = await import("child_process");
      const CHUNK_BYTES = 6400; // 3200 samples × 2 bytes = 200ms @16kHz, well under 10KB limit

      function mp3ToPcm(filename) {
        const fp = path.join(__dirname, filename);
        if (!fs.existsSync(fp)) throw new Error(`文件不存在: ${filename}`);
        return execSync(`ffmpeg -y -loglevel error -i "${fp}" -ar 16000 -ac 1 -f s16le pipe:1`, {
          maxBuffer: 50 * 1024 * 1024,
        });
      }

      const speechPcm = mp3ToPcm(speechFile);
      const musicPcm = speechFile === musicFile ? speechPcm : mp3ToPcm(musicFile || speechFile);

      const maxLen = Math.max(speechPcm.length, musicPcm.length);
      const totalChunks = Math.ceil(maxLen / CHUNK_BYTES);
      const durationMs = Math.round((maxLen / 2) / 16000 * 1000);

      const chunks = [];
      for (let i = 0; i < totalChunks; i++) {
        const start = i * CHUNK_BYTES;
        const end = Math.min(start + CHUNK_BYTES, maxLen);
        const len = end - start;

        const sBuf = Buffer.alloc(len);
        if (start < speechPcm.length) {
          speechPcm.copy(sBuf, 0, start, Math.min(end, speechPcm.length));
        }

        const mBuf = Buffer.alloc(len);
        if (start < musicPcm.length) {
          musicPcm.copy(mBuf, 0, start, Math.min(end, musicPcm.length));
        }

        chunks.push({
          segment: i === totalChunks - 1 ? -1 : i,
          speech: sBuf.toString('base64'),
          music: mBuf.toString('base64'),
        });
      }

      console.log(`[PCM] ${speechFile} + ${musicFile} → ${totalChunks} chunks, ${durationMs}ms`);
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ chunks, totalChunks, durationMs }));
    } catch (e) {
      console.error("[PCM Error]", e.message);
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: e.message }));
    }
    return;
  }

  // ── 音频文件服务 ──
  if (url.pathname.startsWith("/audio/")) {
    const fileName = decodeURIComponent(url.pathname.replace("/audio/", ""));
    const filePath = path.resolve(__dirname, fileName);
    // 安全检查
    if (!filePath.startsWith(path.resolve(__dirname))) {
      res.writeHead(403); res.end("Forbidden"); return;
    }
    if (!fs.existsSync(filePath)) {
      res.writeHead(404); res.end("Not found"); return;
    }

    const stat = fs.statSync(filePath);
    const range = req.headers.range;

    if (range) {
      // 支持 Range 请求（音频 seek）
      const parts = range.replace(/bytes=/, "").split("-");
      const start = parseInt(parts[0], 10);
      const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
      res.writeHead(206, {
        "Content-Range": `bytes ${start}-${end}/${stat.size}`,
        "Accept-Ranges": "bytes",
        "Content-Length": end - start + 1,
        "Content-Type": "audio/mpeg",
      });
      fs.createReadStream(filePath, { start, end }).pipe(res);
    } else {
      res.writeHead(200, {
        "Content-Length": stat.size,
        "Content-Type": "audio/mpeg",
        "Accept-Ranges": "bytes",
      });
      fs.createReadStream(filePath).pipe(res);
    }
    return;
  }

  // ── 静态文件 ──
  let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
  const publicDir = path.join(__dirname, "public");
  filePath = path.resolve(publicDir, "." + filePath);

  if (!filePath.startsWith(publicDir)) { res.writeHead(403); res.end("Forbidden"); return; }

  const ext = path.extname(filePath);
  const contentType = mimeTypes[ext] || "application/octet-stream";

  try {
    const content = fs.readFileSync(filePath);
    res.writeHead(200, { "Content-Type": contentType, "Cache-Control": "no-cache" });
    res.end(content);
  } catch {
    res.writeHead(404);
    res.end("Not found");
  }
});

const BIND = process.env.BATTLE_BIND || "127.0.0.1";
server.listen(PORT, BIND, () => {
  console.log(`\n🎤 唱歌 Battle 数字人已启动`);
  console.log(`   打开浏览器: http://localhost:${PORT}`);
  console.log(`   歌曲库: ${Object.keys(SONGS).join("、")}`);
  if (nuwaConfig.avatarId) {
    console.log(`   数字人 ID: ${nuwaConfig.avatarId}`);
  } else {
    console.log(`   ⚠️  首次使用，请在网页中配置数字人形象`);
  }
  console.log();
});

process.on("SIGTERM", () => { server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 3000); });
process.on("SIGINT", () => { server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 3000); });


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)