#!/usr/bin/env node
/**
 * Qwen-Omni-Realtime "静默教练" POC 测试
 * ────────────────────────────────────────────────────────────────────────
 * 目的:验证 Qwen-Omni-Realtime 能否在"持续灌入患者语音"的同时,
 *       实时吐出教练文本提示,且【不把患者继续说话当成打断、不卡在轮次结束才出】。
 *
 * 验证手段:
 *   - 模拟患者说话:把一段患者语音(16kHz/mono/PCM16)按真实语速分块灌进去
 *   - 用【手动观察模式】turn_detection=null + modalities=["text"]
 *   - 每隔 TRIGGER_EVERY_MS 主动 commit + response.create 要一次提示
 *   - 打印每个文本 delta 相对"音频开始"的时间戳 → 直接看出实不实时
 *   - 灌音频和要提示是并行的 → 看"边说边出提示"成不成立
 *
 * 先决条件:
 *   1. 阿里云百炼 DashScope API Key(注意:不是 Gemini key)
 *        export DASHSCOPE_API_KEY=sk-xxxx
 *   2. ws 库:  pnpm add -w ws   (Node 原生 WebSocket 不支持自定义 header)
 *   3. 一段患者语音文件(16kHz 单声道 PCM16 raw,或标准 WAV):
 *        - 录一句,或用任意 TTS 合成,例:
 *          "医生你好,我那颗缺了的牙一直没补,种植是不是很贵啊?大概要多久?"
 *        - 转成 16k mono pcm:  ffmpeg -i in.mp3 -ar 16000 -ac 1 -f s16le patient.pcm
 *
 * 运行:
 *   DASHSCOPE_API_KEY=sk-xxx node scripts/qwen-omni-coach-poc.mjs patient.pcm
 *
 * ⚠️ 待确认(跑前核一下官方文档 https://help.aliyun.com/zh/model-studio/realtime):
 *   - WS_URL / MODEL 的确切取值(型号在更新)
 *   - 事件字段名若有出入按文档改(协议与 OpenAI Realtime 基本同构)
 */

import WebSocket from 'ws';
import { readFileSync } from 'node:fs';

// ─── 配置(跑前确认 MODEL 型号 + Key)──────────────────────────────
const API_KEY = process.env.DASHSCOPE_API_KEY;
const WS_URL = 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime';
const MODEL = process.env.QWEN_OMNI_MODEL || 'qwen3-omni-flash-realtime'; // 或 qwen-omni-turbo-realtime / qwen3.5-omni-*-realtime
const TRIGGER_EVERY_MS = 3000; // 每 3s 主动要一次提示(模拟"边听边提示")
const SAMPLE_RATE = 16000; // 16kHz mono PCM16
const CHUNK_MS = 100; // 每 100ms 灌一块(模拟实时麦克风)

// 教练人设:静默旁听、只出简短"下一步话术",不跟患者对话
const COACH_INSTRUCTIONS = `你是牙科诊所客服的【静默通话教练】。你在旁听客服与患者的电话。
你【绝不】直接和患者对话,只用一句话给客服【下一步该说什么 / 该提醒的点】。
患者刚提到的关切(如价格、疼痛、时间)→ 立刻给对应的应对话术。
输出极简:一句话,不寒暄、不解释。`;

if (!API_KEY) {
  console.error('❌ 缺 DASHSCOPE_API_KEY(阿里云百炼,不是 Gemini key)');
  process.exit(1);
}
const audioPath = process.argv[2];
if (!audioPath) {
  console.error('❌ 用法: node scripts/qwen-omni-coach-poc.mjs <patient.pcm | patient.wav>');
  process.exit(1);
}

// 读音频(.wav 跳过 44 字节头;.pcm 直接 raw)
let pcm = readFileSync(audioPath);
if (audioPath.toLowerCase().endsWith('.wav')) pcm = pcm.subarray(44);
const bytesPerChunk = (SAMPLE_RATE * 2 * CHUNK_MS) / 1000; // 16bit=2byte
const totalMs = Math.round((pcm.length / (SAMPLE_RATE * 2)) * 1000);
console.log(`🎧 患者语音 ${audioPath}: ${(pcm.length / 1024).toFixed(1)}KB ≈ ${totalMs}ms,按 ${CHUNK_MS}ms/块实时灌入`);

const t0 = Date.now();
const ts = () => `+${String(Date.now() - t0).padStart(5, ' ')}ms`;

const ws = new WebSocket(`${WS_URL}?model=${MODEL}`, {
  headers: { Authorization: `bearer ${API_KEY}` },
});

let firstDeltaAt = null;

ws.on('open', () => {
  console.log(`${ts()} 🔌 已连接,model=${MODEL}`);
  // 1) 配置会话:手动模式(关 VAD)+ 只出文字 + 教练人设
  send('session.update', {
    session: {
      modalities: ['text'], // 只出文本,不出语音 → 静默
      instructions: COACH_INSTRUCTIONS,
      input_audio_format: 'pcm16',
      turn_detection: null, // ⭐ 关键:关自动轮次检测 → 患者一直说不会被当打断/不会抢答
    },
  });
  streamAudio();
});

ws.on('message', (raw) => {
  let ev;
  try {
    ev = JSON.parse(raw.toString());
  } catch {
    return;
  }
  switch (ev.type) {
    case 'session.updated':
      console.log(`${ts()} ✅ 会话配置生效(turn_detection=null, modalities=[text])`);
      break;
    case 'response.text.delta': // 流式文本提示
      if (!firstDeltaAt) {
        firstDeltaAt = Date.now() - t0;
        process.stdout.write(`${ts()} 🟢 [首个提示文本] `);
      }
      process.stdout.write(ev.delta ?? '');
      break;
    case 'response.text.done':
      process.stdout.write(`  ⟵ done(${ts()})\n`);
      break;
    case 'response.done':
      break;
    case 'error':
      console.error(`${ts()} ❌ error:`, JSON.stringify(ev.error ?? ev));
      break;
    default:
      // 其它事件(speech_started/stopped 等)按需打开看
      // console.log(`${ts()} · ${ev.type}`);
      break;
  }
});

ws.on('close', (code) => console.log(`\n${ts()} 🔚 连接关闭 code=${code}`));
ws.on('error', (e) => console.error(`${ts()} ❌ ws error:`, e.message));

function send(type, payload = {}) {
  ws.send(JSON.stringify({ type, ...payload }));
}

// 按真实语速分块灌音频;并行地每 TRIGGER_EVERY_MS 主动要一次提示
async function streamAudio() {
  let triggerCount = 0;
  // 定时主动要提示(边灌边要 → 验证"边说边出提示")
  const timer = setInterval(() => {
    triggerCount++;
    console.log(`\n${ts()} 📨 [触发#${triggerCount}] commit + response.create(此刻患者还在说)`);
    send('input_audio_buffer.commit'); // 提交目前缓冲的音频
    send('response.create'); // 主动要一次教练提示
  }, TRIGGER_EVERY_MS);

  for (let off = 0; off < pcm.length; off += bytesPerChunk) {
    const chunk = pcm.subarray(off, off + bytesPerChunk);
    send('input_audio_buffer.append', { audio: chunk.toString('base64') });
    await sleep(CHUNK_MS); // 模拟实时:按音频时长节奏灌
  }
  console.log(`\n${ts()} 🎤 患者语音灌完`);
  clearInterval(timer);
  // 最后再要一次(收尾提示)
  send('input_audio_buffer.commit');
  send('response.create');
  setTimeout(() => {
    console.log(`\n📊 结果:首个提示文本到达 = ${firstDeltaAt ?? '未收到'}ms(音频总时长 ${totalMs}ms)`);
    console.log(`   → 若首个提示远早于音频结束,说明"边说边实时出提示"成立 ✅`);
    ws.close();
  }, 4000);
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
