239 lines
6.8 KiB
TypeScript
239 lines
6.8 KiB
TypeScript
import express from 'express';
|
|
import cors from 'cors';
|
|
import { Request, Response } from 'express';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import Readline from 'readline';
|
|
import { createHash } from 'crypto';
|
|
import ttsSdk from "microsoft-cognitiveservices-speech-sdk"
|
|
import { JSDOM } from 'jsdom';
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// 启用CORS
|
|
app.use(cors({
|
|
origin: '*',
|
|
methods: ['GET', 'POST', 'OPTIONS'],
|
|
allowedHeaders: ['Content-Type', 'Authorization']
|
|
}));
|
|
|
|
app.use(express.json());
|
|
|
|
app.use('/audio', express.static(path.join(__dirname, 'audio')));
|
|
// 从文本文件读取游戏事件
|
|
|
|
|
|
const prompts = [
|
|
{ voice: "zh-CN-XiaoxiaoNeural" },
|
|
{ voice: "zh-CN-YunxiNeural" },
|
|
{ voice: "zh-CN-XiaohanNeural" },
|
|
{ voice: "zh-CN-YunjianNeural" },
|
|
{ voice: "zh-CN-XiaoyiNeural" },
|
|
{ voice: "zh-CN-XiaochenNeural" },
|
|
]
|
|
/* const prompts = [
|
|
{ voice: "zh-CN-XiaoxiaoNeural" },
|
|
{ voice: "zh-CN-XiaochenNeural" },
|
|
{ voice: "zh-CN-YunyangNeural" },
|
|
{ voice: "zh-CN-XiaohanNeural" },
|
|
{ voice: "zh-CN-XiaomengNeural" },
|
|
{ voice: "zh-CN-XiaomoNeural" }
|
|
] */
|
|
const readEventsFromFile = async (filePath: string): Promise<any[]> => {
|
|
let lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(line => line.trim() !== '').map(line => JSON.parse(line));
|
|
|
|
for (const line of lines) {
|
|
if (line.type == "speak" || line.type == "wolfChat") {
|
|
let promptIndex = line.data.player - 1;
|
|
if (promptIndex < 0 || promptIndex >= prompts.length) {
|
|
console.error(`Invalid prompt index ${promptIndex} for line ${JSON.stringify(line)}`);
|
|
process.exit(1);
|
|
}
|
|
let speech = await resolveSpeech(line.data.message, prompts[promptIndex]!.voice)
|
|
line.data.voice = speech.voice;
|
|
line.data.message = speech.text;
|
|
}
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function createResolvablePromise() {
|
|
let resolve;
|
|
const promise = new Promise(r => {
|
|
resolve = r;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
let continueController = createResolvablePromise();
|
|
|
|
function waitForContinue() {
|
|
return continueController.promise;
|
|
}
|
|
const rl = Readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout
|
|
});
|
|
|
|
function setupEnterKeyListener() {
|
|
console.log("按回车键继续...");
|
|
|
|
rl.once('line', (input) => {
|
|
// 解析等待的 Promise
|
|
//@ts-ignore
|
|
continueController.resolve(input || 'continued');
|
|
continueController = createResolvablePromise();
|
|
});
|
|
}
|
|
|
|
app.post('/*', (req: Request, res: Response) => {
|
|
res.end('ok')
|
|
})
|
|
const filePath = path.join(__dirname, 'events_0.jsonl');
|
|
const events = await readEventsFromFile(filePath);
|
|
app.get('/:gameId/events', async (req: Request, res: Response) => {
|
|
const gameId = req.params.gameId;
|
|
let disconnected = false;
|
|
// 设置SSE响应头
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'Access-Control-Allow-Origin': '*'
|
|
});
|
|
|
|
const filePath = path.join(__dirname, 'events_0.jsonl');
|
|
const events = await readEventsFromFile(filePath);
|
|
|
|
console.log(`客户端已连接到游戏${gameId},准备发送${events.length}个事件`);
|
|
|
|
let eventIndex = 0;
|
|
|
|
for (let i = 0; i < events.length; i++) {
|
|
if (disconnected) break
|
|
const event = events[i];
|
|
|
|
setupEnterKeyListener(); // 设置监听回车
|
|
await waitForContinue();
|
|
|
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
res.socket?.write('');
|
|
console.log(`发送事件 ${eventIndex + 1}/${events.length}:`, event.type);
|
|
|
|
eventIndex++;
|
|
}
|
|
|
|
// 处理客户端断开连接
|
|
req.on('close', () => {
|
|
console.log(`客户端已断开连接`);
|
|
disconnected = true;
|
|
});
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`服务器启动,监听端口 ${PORT}`);
|
|
});
|
|
|
|
|
|
async function resolveSpeech(raw: string, voice: string): Promise<{
|
|
voice: string,
|
|
text: string
|
|
}> {
|
|
const wrappedXml = `<root>${raw}</root>`;
|
|
|
|
// 使用JSDOM解析XML
|
|
const dom = new JSDOM(wrappedXml, { contentType: 'text/xml' });
|
|
const document = dom.window.document;
|
|
|
|
const scriptElements: ({
|
|
type: 'action',
|
|
content: string
|
|
} | {
|
|
type: 'audio',
|
|
style: string | null,
|
|
content: string,
|
|
text: string,
|
|
segments: string[]
|
|
})[] = [];
|
|
|
|
document.querySelectorAll('action, audio').forEach(el => {
|
|
if (el.tagName.toLowerCase() === 'action') {
|
|
scriptElements.push({
|
|
type: 'action',
|
|
content: el.textContent?.trim() || ""
|
|
});
|
|
} else if (el.tagName.toLowerCase() === 'audio') {
|
|
const style = el.getAttribute('style');
|
|
const content = el.textContent?.trim() || "";
|
|
scriptElements.push({
|
|
type: 'audio',
|
|
style,
|
|
content: content,
|
|
text: content.split('|').join(''),
|
|
segments: content.split('|')
|
|
});
|
|
}
|
|
});
|
|
|
|
let ssml = `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="zh-CN">
|
|
<voice name="${voice}">
|
|
${scriptElements.filter(el => el.type === 'audio')
|
|
.map(el => `<mstts:express-as style="${el.style}" styledegree="1">${el.segments.join('')}</mstts:express-as>`)
|
|
.join('<break strength="medium" />')}
|
|
</voice>
|
|
</speak>`
|
|
|
|
const fileName = createHash('sha256')
|
|
.update(ssml)
|
|
.digest('hex');
|
|
const filePath = path.join(__dirname, `audio/${fileName}.mp3`);
|
|
|
|
console.log(`${fileName} -> ${scriptElements.filter(el => el.type === 'audio')
|
|
.map(el => `${el.segments.join('')}`)
|
|
.join('')} `);
|
|
|
|
|
|
let clientText = scriptElements.map(el => el.type == 'action' ? `<span class="action">(${el.content})</span>` : el.content).join(' ');
|
|
|
|
if (fs.existsSync(filePath)) {
|
|
return {
|
|
voice: fileName,
|
|
text: clientText
|
|
};
|
|
}
|
|
|
|
const speechConfig = ttsSdk.SpeechConfig.fromSubscription(
|
|
"6KW9CQeOlrSIHTodWJoiVD3aEtbbek3GwsKIbTfXBmVgngz2te0dJQQJ99BDACYeBjFXJ3w3AAAAACOGWMAe",
|
|
"eastus"
|
|
);
|
|
speechConfig.speechSynthesisVoiceName = voice;
|
|
|
|
const audioConfig = ttsSdk.AudioConfig.fromAudioFileOutput(filePath);
|
|
|
|
const synthesizer = new ttsSdk.SpeechSynthesizer(speechConfig, audioConfig);
|
|
console.log(`正在合成语音: ${ssml} - ${voice} - ${fileName}`);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
synthesizer.speakSsmlAsync(ssml,
|
|
function (result) {
|
|
if (result.reason === ttsSdk.ResultReason.SynthesizingAudioCompleted) {
|
|
console.log("synthesis finished.");
|
|
synthesizer.close();
|
|
resolve({
|
|
voice: fileName,
|
|
text: clientText
|
|
});
|
|
} else {
|
|
console.error("Speech synthesis canceled, " + result.errorDetails);
|
|
synthesizer.close();
|
|
reject(new Error(result.errorDetails || "Speech synthesis failed"));
|
|
}
|
|
},
|
|
function (err) {
|
|
console.trace("err - " + err);
|
|
synthesizer.close();
|
|
reject(err);
|
|
});
|
|
});
|
|
} |