mirror of
https://github.com/koodo-reader/koodo-reader.git
synced 2026-04-29 03:13:37 -04:00
fix bug
Former-commit-id: 279a92966786748029a3d7542a9b75877a401c74
This commit is contained in:
291
edge-tts.js
Normal file
291
edge-tts.js
Normal file
@@ -0,0 +1,291 @@
|
||||
const { randomBytes } = require("crypto");
|
||||
const { WebSocket } = require("ws");
|
||||
const FORMAT_CONTENT_TYPE = new Map([
|
||||
["raw-16khz-16bit-mono-pcm", "audio/basic"],
|
||||
["raw-48khz-16bit-mono-pcm", "audio/basic"],
|
||||
["raw-8khz-8bit-mono-mulaw", "audio/basic"],
|
||||
["raw-8khz-8bit-mono-alaw", "audio/basic"],
|
||||
|
||||
["raw-16khz-16bit-mono-truesilk", "audio/SILK"],
|
||||
["raw-24khz-16bit-mono-truesilk", "audio/SILK"],
|
||||
|
||||
["riff-16khz-16bit-mono-pcm", "audio/x-wav"],
|
||||
["riff-24khz-16bit-mono-pcm", "audio/x-wav"],
|
||||
["riff-48khz-16bit-mono-pcm", "audio/x-wav"],
|
||||
["riff-8khz-8bit-mono-mulaw", "audio/x-wav"],
|
||||
["riff-8khz-8bit-mono-alaw", "audio/x-wav"],
|
||||
|
||||
["audio-16khz-32kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-16khz-64kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-16khz-128kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-24khz-48kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-24khz-96kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-24khz-160kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-48khz-96kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-48khz-192kbitrate-mono-mp3", "audio/mpeg"],
|
||||
|
||||
["webm-16khz-16bit-mono-opus", "audio/webm; codec=opus"],
|
||||
["webm-24khz-16bit-mono-opus", "audio/webm; codec=opus"],
|
||||
|
||||
["ogg-16khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=16000"],
|
||||
["ogg-24khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=24000"],
|
||||
["ogg-48khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=48000"],
|
||||
]);
|
||||
|
||||
class Service {
|
||||
ws = null;
|
||||
|
||||
executorMap;
|
||||
bufferMap;
|
||||
|
||||
timer = null;
|
||||
|
||||
constructor() {
|
||||
this.executorMap = new Map();
|
||||
this.bufferMap = new Map();
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const connectionId = randomBytes(16).toString("hex").toLowerCase();
|
||||
let url = `wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&ConnectionId=${connectionId}`;
|
||||
console.log(url);
|
||||
let ws = new WebSocket(url, {
|
||||
host: "speech.platform.bing.com",
|
||||
origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.66 Safari/537.36 Edg/103.0.1264.44",
|
||||
},
|
||||
});
|
||||
console.log(ws);
|
||||
return new Promise((resolve, reject) => {
|
||||
ws.on("open", () => {
|
||||
resolve(ws);
|
||||
});
|
||||
ws.on("close", (code, reason) => {
|
||||
// 服务器会自动断开空闲超过30秒的连接
|
||||
this.ws = null;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
for (let [key, value] of this.executorMap) {
|
||||
value.reject(`连接已关闭: ${reason} ${code}`);
|
||||
}
|
||||
this.executorMap.clear();
|
||||
this.bufferMap.clear();
|
||||
console.info(`连接已关闭: ${reason} ${code}`);
|
||||
});
|
||||
|
||||
ws.on("message", (message, isBinary) => {
|
||||
let pattern = /X-RequestId:(?<id>[a-z|0-9]*)/;
|
||||
if (!isBinary) {
|
||||
console.debug("收到文本消息:%s", message);
|
||||
let data = message.toString();
|
||||
if (data.includes("Path:turn.start")) {
|
||||
// 开始传输
|
||||
let matches = data.match(pattern);
|
||||
let requestId = matches.groups.id;
|
||||
console.debug(`开始传输:${requestId}……`);
|
||||
this.bufferMap.set(requestId, Buffer.from([]));
|
||||
} else if (data.includes("Path:turn.end")) {
|
||||
// 结束传输
|
||||
let matches = data.match(pattern);
|
||||
let requestId = matches.groups.id;
|
||||
|
||||
let executor = this.executorMap.get(requestId);
|
||||
if (executor) {
|
||||
this.executorMap.delete(matches.groups.id);
|
||||
let result = this.bufferMap.get(requestId);
|
||||
executor.resolve(result);
|
||||
console.debug(`传输完成:${requestId}……`);
|
||||
} else {
|
||||
console.debug(`请求已被丢弃:${requestId}`);
|
||||
}
|
||||
}
|
||||
} else if (isBinary) {
|
||||
let separator = "Path:audio\r\n";
|
||||
let data = message;
|
||||
let contentIndex = data.indexOf(separator) + separator.length;
|
||||
|
||||
let headers = data.slice(2, contentIndex).toString();
|
||||
let matches = headers.match(pattern);
|
||||
let requestId = matches.groups.id;
|
||||
|
||||
let content = data.slice(contentIndex);
|
||||
|
||||
console.debug(
|
||||
`收到音频片段:${requestId} Length: ${content.length}\n${headers}`
|
||||
);
|
||||
|
||||
let buffer = this.bufferMap.get(requestId);
|
||||
if (buffer) {
|
||||
buffer = Buffer.concat([buffer, content]);
|
||||
this.bufferMap.set(requestId, buffer);
|
||||
} else {
|
||||
console.debug(`请求已被丢弃:${requestId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
ws.on("error", (error) => {
|
||||
console.error(`连接失败: ${error}`);
|
||||
reject(`连接失败: ${error}`);
|
||||
});
|
||||
ws.on("ping", (data) => {
|
||||
console.debug("ping %s", data);
|
||||
});
|
||||
ws.on("pong", (data) => {
|
||||
console.debug("pong %s", data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async convert(ssml, format) {
|
||||
if (this.ws == null || this.ws.readyState != WebSocket.OPEN) {
|
||||
console.info("准备连接服务器……");
|
||||
let connection = await this.connect();
|
||||
this.ws = connection;
|
||||
console.info("连接成功!");
|
||||
}
|
||||
const requestId = randomBytes(16).toString("hex").toLowerCase();
|
||||
let result = new Promise((resolve, reject) => {
|
||||
// 等待服务器返回后这个方法才会返回结果
|
||||
this.executorMap.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
// 发送配置消息
|
||||
let configData = {
|
||||
context: {
|
||||
synthesis: {
|
||||
audio: {
|
||||
metadataoptions: {
|
||||
sentenceBoundaryEnabled: "false",
|
||||
wordBoundaryEnabled: "false",
|
||||
},
|
||||
outputFormat: format,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
let configMessage =
|
||||
`X-Timestamp:${Date()}\r\n` +
|
||||
"Content-Type:application/json; charset=utf-8\r\n" +
|
||||
"Path:speech.config\r\n\r\n" +
|
||||
JSON.stringify(configData);
|
||||
console.info(`开始转换:${requestId}……`);
|
||||
console.debug(`准备发送配置请求:${requestId}\n`, configMessage);
|
||||
this.ws.send(configMessage, (configError) => {
|
||||
if (configError) {
|
||||
console.error(`配置请求发送失败:${requestId}\n`, configError);
|
||||
}
|
||||
|
||||
// 发送SSML消息
|
||||
let ssmlMessage =
|
||||
`X-Timestamp:${Date()}\r\n` +
|
||||
`X-RequestId:${requestId}\r\n` +
|
||||
`Content-Type:application/ssml+xml\r\n` +
|
||||
`Path:ssml\r\n\r\n` +
|
||||
ssml;
|
||||
console.debug(`准备发送SSML消息:${requestId}\n`, ssmlMessage);
|
||||
this.ws.send(ssmlMessage, (ssmlError) => {
|
||||
if (ssmlError) {
|
||||
console.error(`SSML消息发送失败:${requestId}\n`, ssmlError);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 收到请求,清除超时定时器
|
||||
if (this.timer) {
|
||||
console.debug("收到新的请求,清除超时定时器");
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
// 设置定时器,超过10秒没有收到请求,主动断开连接
|
||||
console.debug("创建新的超时定时器");
|
||||
this.timer = setTimeout(() => {
|
||||
if (this.ws && this.ws.readyState == WebSocket.OPEN) {
|
||||
console.debug("已经 10 秒没有请求,主动关闭连接");
|
||||
this.ws.close(1000);
|
||||
this.timer = null;
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
let data = await Promise.race([
|
||||
result,
|
||||
new Promise((resolve, reject) => {
|
||||
// 如果超过 20 秒没有返回结果,则清除请求并返回超时
|
||||
setTimeout(() => {
|
||||
this.executorMap.delete(requestId);
|
||||
this.bufferMap.delete(requestId);
|
||||
reject("转换超时");
|
||||
}, 10000);
|
||||
}),
|
||||
]);
|
||||
console.info(`转换完成:${requestId}`);
|
||||
console.info(`剩余 ${this.executorMap.size} 个任务`);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
const service = new Service();
|
||||
const retry = async function (fn, times, errorFn, failedMessage) {
|
||||
let reason = {
|
||||
message: failedMessage ?? "多次尝试后失败",
|
||||
errors: [],
|
||||
};
|
||||
for (let i = 0; i < times; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (errorFn) {
|
||||
errorFn(i, error);
|
||||
}
|
||||
reason.errors.push(error);
|
||||
}
|
||||
}
|
||||
throw reason;
|
||||
};
|
||||
const ra = async (text) => {
|
||||
console.debug(`请求正文:${text}`);
|
||||
try {
|
||||
let format = "webm-24khz-16bit-mono-opus";
|
||||
if (Array.isArray(format)) {
|
||||
throw `无效的音频格式:${format}`;
|
||||
}
|
||||
if (!FORMAT_CONTENT_TYPE.has(format)) {
|
||||
throw `无效的音频格式:${format}`;
|
||||
}
|
||||
|
||||
let ssml = text;
|
||||
if (ssml == null) {
|
||||
throw `转换参数无效`;
|
||||
}
|
||||
let result = await retry(
|
||||
async () => {
|
||||
let result = await service.convert(ssml, format);
|
||||
return result;
|
||||
},
|
||||
3,
|
||||
(index, error) => {
|
||||
console.warn(`第${index}次转换失败:${error}`);
|
||||
},
|
||||
"服务器多次尝试后转换失败"
|
||||
);
|
||||
return result;
|
||||
// response.sendDate = true;
|
||||
// response
|
||||
// .status(200)
|
||||
// .setHeader("Content-Type", FORMAT_CONTENT_TYPE.get(format));
|
||||
// response.end(result);
|
||||
} catch (error) {
|
||||
console.error(`发生错误, ${error.message}`);
|
||||
// response.status(503).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
// ra(
|
||||
// `<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"> <voice name="zh-CN-XiaoxiaoNeural"><prosody rate="0%" pitch="0%">如果喜欢这个项目的话请点个 Star 吧。</prosody ></voice > </speak >`
|
||||
// );
|
||||
|
||||
module.exports = { ra };
|
||||
291
main.js
291
main.js
@@ -13,8 +13,7 @@ const Store = require("electron-store");
|
||||
const store = new Store();
|
||||
const fs = require("fs");
|
||||
const configDir = app.getPath("userData");
|
||||
const { randomBytes } = require("crypto");
|
||||
const { WebSocket } = require("ws");
|
||||
const { ra } = require("./edge-tts");
|
||||
const dirPath = path.join(configDir, "uploads");
|
||||
let mainWin;
|
||||
let readerWindow;
|
||||
@@ -251,291 +250,3 @@ app.on("window-all-closed", () => {
|
||||
app.on("open-file", (e, pathToFile) => {
|
||||
filePath = pathToFile;
|
||||
});
|
||||
|
||||
const FORMAT_CONTENT_TYPE = new Map([
|
||||
["raw-16khz-16bit-mono-pcm", "audio/basic"],
|
||||
["raw-48khz-16bit-mono-pcm", "audio/basic"],
|
||||
["raw-8khz-8bit-mono-mulaw", "audio/basic"],
|
||||
["raw-8khz-8bit-mono-alaw", "audio/basic"],
|
||||
|
||||
["raw-16khz-16bit-mono-truesilk", "audio/SILK"],
|
||||
["raw-24khz-16bit-mono-truesilk", "audio/SILK"],
|
||||
|
||||
["riff-16khz-16bit-mono-pcm", "audio/x-wav"],
|
||||
["riff-24khz-16bit-mono-pcm", "audio/x-wav"],
|
||||
["riff-48khz-16bit-mono-pcm", "audio/x-wav"],
|
||||
["riff-8khz-8bit-mono-mulaw", "audio/x-wav"],
|
||||
["riff-8khz-8bit-mono-alaw", "audio/x-wav"],
|
||||
|
||||
["audio-16khz-32kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-16khz-64kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-16khz-128kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-24khz-48kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-24khz-96kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-24khz-160kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-48khz-96kbitrate-mono-mp3", "audio/mpeg"],
|
||||
["audio-48khz-192kbitrate-mono-mp3", "audio/mpeg"],
|
||||
|
||||
["webm-16khz-16bit-mono-opus", "audio/webm; codec=opus"],
|
||||
["webm-24khz-16bit-mono-opus", "audio/webm; codec=opus"],
|
||||
|
||||
["ogg-16khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=16000"],
|
||||
["ogg-24khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=24000"],
|
||||
["ogg-48khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=48000"],
|
||||
]);
|
||||
|
||||
class Service {
|
||||
ws = null;
|
||||
|
||||
executorMap;
|
||||
bufferMap;
|
||||
|
||||
timer = null;
|
||||
|
||||
constructor() {
|
||||
this.executorMap = new Map();
|
||||
this.bufferMap = new Map();
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const connectionId = randomBytes(16).toString("hex").toLowerCase();
|
||||
let url = `wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&ConnectionId=${connectionId}`;
|
||||
console.log(url);
|
||||
let ws = new WebSocket(url, {
|
||||
host: "speech.platform.bing.com",
|
||||
origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.66 Safari/537.36 Edg/103.0.1264.44",
|
||||
},
|
||||
});
|
||||
console.log(ws);
|
||||
return new Promise((resolve, reject) => {
|
||||
ws.on("open", () => {
|
||||
resolve(ws);
|
||||
});
|
||||
ws.on("close", (code, reason) => {
|
||||
// 服务器会自动断开空闲超过30秒的连接
|
||||
this.ws = null;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
for (let [key, value] of this.executorMap) {
|
||||
value.reject(`连接已关闭: ${reason} ${code}`);
|
||||
}
|
||||
this.executorMap.clear();
|
||||
this.bufferMap.clear();
|
||||
console.info(`连接已关闭: ${reason} ${code}`);
|
||||
});
|
||||
|
||||
ws.on("message", (message, isBinary) => {
|
||||
let pattern = /X-RequestId:(?<id>[a-z|0-9]*)/;
|
||||
if (!isBinary) {
|
||||
console.debug("收到文本消息:%s", message);
|
||||
let data = message.toString();
|
||||
if (data.includes("Path:turn.start")) {
|
||||
// 开始传输
|
||||
let matches = data.match(pattern);
|
||||
let requestId = matches.groups.id;
|
||||
console.debug(`开始传输:${requestId}……`);
|
||||
this.bufferMap.set(requestId, Buffer.from([]));
|
||||
} else if (data.includes("Path:turn.end")) {
|
||||
// 结束传输
|
||||
let matches = data.match(pattern);
|
||||
let requestId = matches.groups.id;
|
||||
|
||||
let executor = this.executorMap.get(requestId);
|
||||
if (executor) {
|
||||
this.executorMap.delete(matches.groups.id);
|
||||
let result = this.bufferMap.get(requestId);
|
||||
executor.resolve(result);
|
||||
console.debug(`传输完成:${requestId}……`);
|
||||
} else {
|
||||
console.debug(`请求已被丢弃:${requestId}`);
|
||||
}
|
||||
}
|
||||
} else if (isBinary) {
|
||||
let separator = "Path:audio\r\n";
|
||||
let data = message;
|
||||
let contentIndex = data.indexOf(separator) + separator.length;
|
||||
|
||||
let headers = data.slice(2, contentIndex).toString();
|
||||
let matches = headers.match(pattern);
|
||||
let requestId = matches.groups.id;
|
||||
|
||||
let content = data.slice(contentIndex);
|
||||
|
||||
console.debug(
|
||||
`收到音频片段:${requestId} Length: ${content.length}\n${headers}`
|
||||
);
|
||||
|
||||
let buffer = this.bufferMap.get(requestId);
|
||||
if (buffer) {
|
||||
buffer = Buffer.concat([buffer, content]);
|
||||
this.bufferMap.set(requestId, buffer);
|
||||
} else {
|
||||
console.debug(`请求已被丢弃:${requestId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
ws.on("error", (error) => {
|
||||
console.error(`连接失败: ${error}`);
|
||||
reject(`连接失败: ${error}`);
|
||||
});
|
||||
ws.on("ping", (data) => {
|
||||
console.debug("ping %s", data);
|
||||
});
|
||||
ws.on("pong", (data) => {
|
||||
console.debug("pong %s", data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async convert(ssml, format) {
|
||||
if (this.ws == null || this.ws.readyState != WebSocket.OPEN) {
|
||||
console.info("准备连接服务器……");
|
||||
let connection = await this.connect();
|
||||
this.ws = connection;
|
||||
console.info("连接成功!");
|
||||
}
|
||||
const requestId = randomBytes(16).toString("hex").toLowerCase();
|
||||
let result = new Promise((resolve, reject) => {
|
||||
// 等待服务器返回后这个方法才会返回结果
|
||||
this.executorMap.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
// 发送配置消息
|
||||
let configData = {
|
||||
context: {
|
||||
synthesis: {
|
||||
audio: {
|
||||
metadataoptions: {
|
||||
sentenceBoundaryEnabled: "false",
|
||||
wordBoundaryEnabled: "false",
|
||||
},
|
||||
outputFormat: format,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
let configMessage =
|
||||
`X-Timestamp:${Date()}\r\n` +
|
||||
"Content-Type:application/json; charset=utf-8\r\n" +
|
||||
"Path:speech.config\r\n\r\n" +
|
||||
JSON.stringify(configData);
|
||||
console.info(`开始转换:${requestId}……`);
|
||||
console.debug(`准备发送配置请求:${requestId}\n`, configMessage);
|
||||
this.ws.send(configMessage, (configError) => {
|
||||
if (configError) {
|
||||
console.error(`配置请求发送失败:${requestId}\n`, configError);
|
||||
}
|
||||
|
||||
// 发送SSML消息
|
||||
let ssmlMessage =
|
||||
`X-Timestamp:${Date()}\r\n` +
|
||||
`X-RequestId:${requestId}\r\n` +
|
||||
`Content-Type:application/ssml+xml\r\n` +
|
||||
`Path:ssml\r\n\r\n` +
|
||||
ssml;
|
||||
console.debug(`准备发送SSML消息:${requestId}\n`, ssmlMessage);
|
||||
this.ws.send(ssmlMessage, (ssmlError) => {
|
||||
if (ssmlError) {
|
||||
console.error(`SSML消息发送失败:${requestId}\n`, ssmlError);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 收到请求,清除超时定时器
|
||||
if (this.timer) {
|
||||
console.debug("收到新的请求,清除超时定时器");
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
// 设置定时器,超过10秒没有收到请求,主动断开连接
|
||||
console.debug("创建新的超时定时器");
|
||||
this.timer = setTimeout(() => {
|
||||
if (this.ws && this.ws.readyState == WebSocket.OPEN) {
|
||||
console.debug("已经 10 秒没有请求,主动关闭连接");
|
||||
this.ws.close(1000);
|
||||
this.timer = null;
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
let data = await Promise.race([
|
||||
result,
|
||||
new Promise((resolve, reject) => {
|
||||
// 如果超过 20 秒没有返回结果,则清除请求并返回超时
|
||||
setTimeout(() => {
|
||||
this.executorMap.delete(requestId);
|
||||
this.bufferMap.delete(requestId);
|
||||
reject("转换超时");
|
||||
}, 10000);
|
||||
}),
|
||||
]);
|
||||
console.info(`转换完成:${requestId}`);
|
||||
console.info(`剩余 ${this.executorMap.size} 个任务`);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
const service = new Service();
|
||||
const retry = async function (fn, times, errorFn, failedMessage) {
|
||||
let reason = {
|
||||
message: failedMessage ?? "多次尝试后失败",
|
||||
errors: [],
|
||||
};
|
||||
for (let i = 0; i < times; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (errorFn) {
|
||||
errorFn(i, error);
|
||||
}
|
||||
reason.errors.push(error);
|
||||
}
|
||||
}
|
||||
throw reason;
|
||||
};
|
||||
const ra = async (text) => {
|
||||
console.debug(`请求正文:${text}`);
|
||||
try {
|
||||
let format = "webm-24khz-16bit-mono-opus";
|
||||
if (Array.isArray(format)) {
|
||||
throw `无效的音频格式:${format}`;
|
||||
}
|
||||
if (!FORMAT_CONTENT_TYPE.has(format)) {
|
||||
throw `无效的音频格式:${format}`;
|
||||
}
|
||||
|
||||
let ssml = text;
|
||||
if (ssml == null) {
|
||||
throw `转换参数无效`;
|
||||
}
|
||||
let result = await retry(
|
||||
async () => {
|
||||
let result = await service.convert(ssml, format);
|
||||
return result;
|
||||
},
|
||||
3,
|
||||
(index, error) => {
|
||||
console.warn(`第${index}次转换失败:${error}`);
|
||||
},
|
||||
"服务器多次尝试后转换失败"
|
||||
);
|
||||
return result;
|
||||
// response.sendDate = true;
|
||||
// response
|
||||
// .status(200)
|
||||
// .setHeader("Content-Type", FORMAT_CONTENT_TYPE.get(format));
|
||||
// response.end(result);
|
||||
} catch (error) {
|
||||
console.error(`发生错误, ${error.message}`);
|
||||
// response.status(503).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
// ra(
|
||||
// `<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"> <voice name="zh-CN-XiaoxiaoNeural"><prosody rate="0%" pitch="0%">如果喜欢这个项目的话请点个 Star 吧。</prosody ></voice > </speak >`
|
||||
// );
|
||||
|
||||
10
public/lib/kookit/kookit.min.js
vendored
10
public/lib/kookit/kookit.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -90,6 +90,7 @@
|
||||
"Descend": "降序",
|
||||
"Ascend": "升序",
|
||||
"Sort by Name": "按书名",
|
||||
"Turn on Bionic Reading": "开启仿生阅读",
|
||||
"Token": "凭证",
|
||||
"Book not exist": "书籍不存在",
|
||||
"Please authorize your account, and fill the following box with the token": "登录授权后,您将获得一串代码,请将代码填入以下输入框即可完成绑定",
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"Open url with built-in browser": "Open url with built-in browser",
|
||||
"Light Mode": "Light Mode",
|
||||
"Night Mode": "Night Mode",
|
||||
"Turn on Bionic Reading": "Turn on Bionic Reading",
|
||||
"Follow OS": "Follow OS",
|
||||
"Use first page as PDF cover": "Use first page as PDF cover",
|
||||
"You may see this error when the book you're importing is not supported by Koodo Reader, try converting it with Calibre": "You may see this error when the book you're importing is not supported by Koodo Reader, try converting it with Calibre",
|
||||
|
||||
@@ -28,6 +28,7 @@ class DeleteIcon extends React.Component<DeleteIconProps, DeleteIconStates> {
|
||||
deleteItems.forEach((item: any, index: number) => {
|
||||
if (this.props.mode === "tags") {
|
||||
item === this.props.tagName && TagUtil.clear(item);
|
||||
this.handleDeleteTagFromNote(item);
|
||||
return;
|
||||
}
|
||||
if (item.key === this.props.itemKey) {
|
||||
@@ -56,6 +57,17 @@ class DeleteIcon extends React.Component<DeleteIconProps, DeleteIconStates> {
|
||||
}
|
||||
});
|
||||
};
|
||||
handleDeleteTagFromNote = (tagName: string) => {
|
||||
let noteList = this.props.notes.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
tag: item.tag.filter((subitem) => subitem !== tagName),
|
||||
};
|
||||
});
|
||||
localforage.setItem("notes", noteList).then(() => {
|
||||
this.props.handleFetchNotes();
|
||||
});
|
||||
};
|
||||
handleDeletePopup = (isOpenDelete: boolean) => {
|
||||
this.setState({ isOpenDelete });
|
||||
if (!isOpenDelete) {
|
||||
|
||||
@@ -20,6 +20,7 @@ class SettingSwitch extends React.Component<
|
||||
isShadow: StorageUtil.getReaderConfig("isShadow") === "yes",
|
||||
isItalic: StorageUtil.getReaderConfig("isItalic") === "yes",
|
||||
isInvert: StorageUtil.getReaderConfig("isInvert") === "yes",
|
||||
isBionic: StorageUtil.getReaderConfig("isBionic") === "yes",
|
||||
isHideBackground:
|
||||
StorageUtil.getReaderConfig("isHideBackground") === "yes",
|
||||
isHideFooter: StorageUtil.getReaderConfig("isHideFooter") === "yes",
|
||||
@@ -99,6 +100,9 @@ class SettingSwitch extends React.Component<
|
||||
case "isInvert":
|
||||
this._handleChange("isInvert");
|
||||
break;
|
||||
case "isBionic":
|
||||
this._handleChange("isBionic");
|
||||
break;
|
||||
case "isHideFooter":
|
||||
this.handleChange("isHideFooter");
|
||||
break;
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface SettingSwitchState {
|
||||
isUnderline: boolean;
|
||||
isItalic: boolean;
|
||||
isInvert: boolean;
|
||||
isBionic: boolean;
|
||||
isHideHeader: boolean;
|
||||
isHidePageButton: boolean;
|
||||
isHideMenuButton: boolean;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.header-search-box {
|
||||
width: 150px;
|
||||
height: 38px;
|
||||
border-radius: 19px;
|
||||
border-radius: 22px;
|
||||
border-style: none;
|
||||
outline: none;
|
||||
padding-left: 20px;
|
||||
|
||||
@@ -103,4 +103,4 @@ class SelectBook extends React.Component<BookListProps, BookListState> {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(SelectBook);
|
||||
export default withRouter(SelectBook as any);
|
||||
|
||||
@@ -125,27 +125,44 @@ class TextToSpeech extends React.Component<
|
||||
}
|
||||
};
|
||||
async handleRead() {
|
||||
let text = this.state.nodeList[this.state.nodeIndex];
|
||||
let currentText = this.state.nodeList[this.state.nodeIndex];
|
||||
|
||||
let style = "background: #f3a6a68c";
|
||||
this.props.htmlBook.rendition.highlightNode(text, style);
|
||||
text = text
|
||||
this.props.htmlBook.rendition.highlightNode(currentText, style);
|
||||
currentText = currentText
|
||||
.replace(/\s\s/g, "")
|
||||
.replace(/\r/g, "")
|
||||
.replace(/\n/g, "")
|
||||
.replace(/\t/g, "")
|
||||
.replace(/\f/g, "");
|
||||
let nextText = "";
|
||||
if (this.state.nodeIndex < this.state.nodeList.length - 1) {
|
||||
nextText = this.state.nodeList[this.state.nodeIndex + 1];
|
||||
nextText = nextText
|
||||
.replace(/\s\s/g, "")
|
||||
.replace(/\r/g, "")
|
||||
.replace(/\n/g, "")
|
||||
.replace(/\t/g, "")
|
||||
.replace(/\f/g, "");
|
||||
}
|
||||
await this.handleSpeech(
|
||||
text,
|
||||
currentText,
|
||||
nextText,
|
||||
StorageUtil.getReaderConfig("voiceIndex") || 0,
|
||||
StorageUtil.getReaderConfig("voiceSpeed") || 1
|
||||
);
|
||||
this.setState({ nodeIndex: this.state.nodeIndex + 1 });
|
||||
}
|
||||
handleSpeech = async (text: string, voiceIndex: number, speed: number) => {
|
||||
handleSpeech = async (
|
||||
currentText: string,
|
||||
nextText: string,
|
||||
voiceIndex: number,
|
||||
speed: number
|
||||
) => {
|
||||
if (voiceIndex > this.state.nativeVoices.length) {
|
||||
let edgeVoice =
|
||||
this.state.edgeVoices[voiceIndex - this.state.nativeVoices.length];
|
||||
await EdgeUtil.readAloud(text, edgeVoice.ShortName);
|
||||
await EdgeUtil.readAloud(currentText, nextText, edgeVoice.ShortName);
|
||||
let player = EdgeUtil.getPlayer();
|
||||
|
||||
player.onended = async (event) => {
|
||||
@@ -157,7 +174,7 @@ class TextToSpeech extends React.Component<
|
||||
};
|
||||
} else {
|
||||
var msg = new SpeechSynthesisUtterance();
|
||||
msg.text = text;
|
||||
msg.text = currentText;
|
||||
msg.voice = window.speechSynthesis.getVoices()[voiceIndex];
|
||||
msg.rate = speed;
|
||||
window.speechSynthesis.speak(msg);
|
||||
|
||||
@@ -168,6 +168,10 @@ export const readerSettingList = [
|
||||
title: "Invert color",
|
||||
propName: "isInvert",
|
||||
},
|
||||
{
|
||||
title: "Turn on Bionic Reading",
|
||||
propName: "isBionic",
|
||||
},
|
||||
{
|
||||
title: "Hide footer",
|
||||
propName: "isHideFooter",
|
||||
|
||||
@@ -21,6 +21,7 @@ import { removeExtraQuestionMark } from "../../utils/commonUtil";
|
||||
import CFI from "epub-cfi-resolver";
|
||||
import mhtml2html from "mhtml2html";
|
||||
import rtfToHTML from "@iarna/rtf-to-html";
|
||||
import { binicReadingProcess } from "../../utils/serviceUtils/bionicUtil";
|
||||
|
||||
declare var window: any;
|
||||
let lock = false; //prevent from clicking too fasts
|
||||
@@ -184,6 +185,7 @@ class Viewer extends React.Component<ViewerProps, ViewerState> {
|
||||
});
|
||||
StyleUtil.addDefaultCss();
|
||||
tsTransform();
|
||||
binicReadingProcess();
|
||||
rendition.setStyle(
|
||||
StyleUtil.getCustomCss(
|
||||
true,
|
||||
@@ -277,6 +279,7 @@ class Viewer extends React.Component<ViewerProps, ViewerState> {
|
||||
this.handleContentScroll(chapter, bookLocation.chapterHref);
|
||||
StyleUtil.addDefaultCss();
|
||||
tsTransform();
|
||||
binicReadingProcess();
|
||||
this.handleBindGesture();
|
||||
lock = true;
|
||||
setTimeout(function () {
|
||||
@@ -294,7 +297,9 @@ class Viewer extends React.Component<ViewerProps, ViewerState> {
|
||||
let contentList = contentBody.getElementsByTagName("a");
|
||||
let targetContent = Array.from(contentList).filter((item, index) => {
|
||||
item.setAttribute("style", "");
|
||||
return item.textContent === chapter && index === chapterIndex;
|
||||
return (
|
||||
item.textContent === chapter && Math.abs(index - chapterIndex) <= 1
|
||||
);
|
||||
});
|
||||
if (targetContent.length > 0) {
|
||||
contentBody.scrollTo(0, targetContent[0].offsetTop);
|
||||
|
||||
@@ -108,7 +108,7 @@ class BookList extends React.Component<BookListProps, BookListState> {
|
||||
//返回排序后的图书index
|
||||
SortUtil.sortBooks(this.props.books, this.props.bookSortCode) || []
|
||||
);
|
||||
if (this.props.mode === "shelf" && books.length === 0) {
|
||||
if (books.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -183,7 +183,9 @@ class BookList extends React.Component<BookListProps, BookListState> {
|
||||
<>
|
||||
<ViewMode />
|
||||
<SelectBook />
|
||||
{!this.props.isSelectBook && <ShelfSelector />}
|
||||
<div style={this.props.isSelectBook ? { display: "none" } : {}}>
|
||||
<ShelfSelector />
|
||||
</div>
|
||||
<div
|
||||
className="book-list-container-parent"
|
||||
style={
|
||||
|
||||
@@ -5,7 +5,6 @@ import { NavListProps, NavListState } from "./interface";
|
||||
import DeleteIcon from "../../../components/deleteIcon";
|
||||
import toast from "react-hot-toast";
|
||||
import CFI from "epub-cfi-resolver";
|
||||
import StorageUtil from "../../../utils/serviceUtils/storageUtil";
|
||||
class NavList extends React.Component<NavListProps, NavListState> {
|
||||
constructor(props: NavListProps) {
|
||||
super(props);
|
||||
@@ -15,6 +14,7 @@ class NavList extends React.Component<NavListProps, NavListState> {
|
||||
}
|
||||
//跳转到图书的指定位置
|
||||
async handleJump(cfi: string) {
|
||||
//书签跳转
|
||||
if (!cfi) {
|
||||
toast(this.props.t("Wrong bookmark"));
|
||||
return;
|
||||
@@ -59,9 +59,7 @@ class NavList extends React.Component<NavListProps, NavListState> {
|
||||
cfi: bookLocation.cfi,
|
||||
})
|
||||
);
|
||||
let style =
|
||||
"background: " +
|
||||
(StorageUtil.getReaderConfig("backgroundColor") || "#f3a6a68c");
|
||||
let style = "background: #f3a6a68c";
|
||||
this.props.htmlBook.rendition.highlightNode(bookLocation.text, style);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +105,7 @@ class NavigationPanel extends React.Component<
|
||||
cfi: bookLocation.cfi,
|
||||
})
|
||||
);
|
||||
let style =
|
||||
"background: " +
|
||||
(StorageUtil.getReaderConfig("backgroundColor") ||
|
||||
"#f3a6a68c");
|
||||
let style = "background: #f3a6a68c";
|
||||
this.props.htmlBook.rendition.highlightNode(
|
||||
bookLocation.text,
|
||||
style
|
||||
|
||||
@@ -89,7 +89,11 @@ class Sidebar extends React.Component<SidebarProps, SidebarState> {
|
||||
? `icon-${item.icon} active-icon`
|
||||
: `icon-${item.icon}`
|
||||
}
|
||||
style={this.props.isCollapsed ? { marginLeft: "-25px" } : {}}
|
||||
style={
|
||||
this.props.isCollapsed
|
||||
? { position: "fixed", marginLeft: "-6px" }
|
||||
: {}
|
||||
}
|
||||
></span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,11 +21,6 @@ import LoadingDialog from "../../components/dialogs/loadingDialog";
|
||||
import TipDialog from "../../components/dialogs/TipDialog";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
//判断是否为触控设备
|
||||
const is_touch_device = () => {
|
||||
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
};
|
||||
|
||||
class Manager extends React.Component<ManagerProps, ManagerState> {
|
||||
timer!: NodeJS.Timeout;
|
||||
constructor(props: ManagerProps) {
|
||||
@@ -74,9 +69,6 @@ class Manager extends React.Component<ManagerProps, ManagerState> {
|
||||
this.props.handleFetchList();
|
||||
}
|
||||
componentDidMount() {
|
||||
if (is_touch_device() && !StorageUtil.getReaderConfig("isTouch")) {
|
||||
StorageUtil.setReaderConfig("isTouch", "yes");
|
||||
}
|
||||
this.props.handleReadingState(false);
|
||||
}
|
||||
|
||||
@@ -85,7 +77,7 @@ class Manager extends React.Component<ManagerProps, ManagerState> {
|
||||
};
|
||||
render() {
|
||||
let { books } = this.props;
|
||||
if (isMobile) {
|
||||
if (isMobile && document.location.href.indexOf("192.168") === -1) {
|
||||
return (
|
||||
<>
|
||||
<p className="waring-title">
|
||||
|
||||
@@ -27,7 +27,7 @@ export const handleLinkJump = async (event: any, rendition: any = {}) => {
|
||||
}
|
||||
if (href.indexOf("/#") === -1) {
|
||||
let chapterInfo = rendition.resolveChapter(href);
|
||||
rendition.goToChapter(
|
||||
await rendition.goToChapter(
|
||||
chapterInfo.index,
|
||||
chapterInfo.href,
|
||||
chapterInfo.title
|
||||
@@ -38,7 +38,7 @@ export const handleLinkJump = async (event: any, rendition: any = {}) => {
|
||||
await rendition.goToNode(doc.body.querySelector("#" + id) || doc.body);
|
||||
} else if (href && rendition.resolveChapter(href)) {
|
||||
let chapterInfo = rendition.resolveChapter(href);
|
||||
rendition.goToChapter(
|
||||
await rendition.goToChapter(
|
||||
chapterInfo.index,
|
||||
chapterInfo.href,
|
||||
chapterInfo.title
|
||||
|
||||
90
src/utils/serviceUtils/bionicUtil.tsx
Normal file
90
src/utils/serviceUtils/bionicUtil.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { getIframeDoc } from "./docUtil";
|
||||
import StorageUtil from "./storageUtil";
|
||||
|
||||
/* Insert one Node after another Node */
|
||||
const insertAfter = (newNode, existingNode) => {
|
||||
if (existingNode.nextSibling !== undefined)
|
||||
existingNode.parentNode.insertBefore(newNode, existingNode.nextSibling);
|
||||
else existingNode.parentNode.appendChild(newNode);
|
||||
};
|
||||
|
||||
/* process all children of a Node*/
|
||||
const HalfBold = (parentElement) => {
|
||||
/* iterating through all children of the parent*/
|
||||
for (var i = 0; parentElement.childNodes[i] !== undefined; i++) {
|
||||
/* if the child is a text element*/
|
||||
if (
|
||||
parentElement.childNodes[i].nodeName === "#text" &&
|
||||
parentElement.childNodes[i].textContent.trim().length !== 0
|
||||
) {
|
||||
var recentNode = parentElement.childNodes[i];
|
||||
var newNodeCount = 0;
|
||||
/* add bold and non-bold elements*/
|
||||
parentElement.childNodes[i].textContent
|
||||
.split(/(\s+|\S+)/)
|
||||
// eslint-disable-next-line
|
||||
.forEach((word) => {
|
||||
if (word.length === 0) return;
|
||||
var trimmedWordLength = word.trim().length;
|
||||
if (trimmedWordLength === 0) {
|
||||
let textNode = document.createTextNode(word);
|
||||
insertAfter(textNode, recentNode);
|
||||
newNodeCount++;
|
||||
recentNode = textNode;
|
||||
return;
|
||||
}
|
||||
|
||||
var length = Math.floor(trimmedWordLength / 2);
|
||||
if (length === 0) length = 1;
|
||||
|
||||
const bold = document.createElement("b");
|
||||
bold.innerHTML = word.slice(0, length);
|
||||
insertAfter(bold, recentNode);
|
||||
newNodeCount++;
|
||||
recentNode = bold;
|
||||
|
||||
if (word.length === 1) return;
|
||||
let textNode = document.createTextNode(word.slice(length));
|
||||
insertAfter(textNode, recentNode);
|
||||
newNodeCount++;
|
||||
recentNode = textNode;
|
||||
});
|
||||
/* and remove the original text element*/
|
||||
parentElement.removeChild(parentElement.childNodes[i]);
|
||||
i += newNodeCount - 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* a way to stop from processing certain nodes*/
|
||||
var ignoreTags = {
|
||||
B: true,
|
||||
META: true,
|
||||
LINK: true,
|
||||
SCRIPT: true,
|
||||
STYLE: true,
|
||||
};
|
||||
|
||||
export const processDocumentBody = (element) => {
|
||||
/* we check all Nodes in the body*/
|
||||
if (element === null) return;
|
||||
if (element.body === undefined) return;
|
||||
var collection = element.body.getElementsByTagName("*");
|
||||
for (var i = 0; collection[i] !== undefined; i++) {
|
||||
if (ignoreTags[collection[i].nodeName]) continue;
|
||||
if (collection[i].nodeType !== 1) continue;
|
||||
if (collection[i].nodeName === "IFRAME") {
|
||||
processDocumentBody(collection[i].contentDocument);
|
||||
} else {
|
||||
if (collection[i].childNodes.length === 0) continue;
|
||||
HalfBold(collection[i]);
|
||||
}
|
||||
}
|
||||
};
|
||||
export const binicReadingProcess = () => {
|
||||
let doc = getIframeDoc();
|
||||
if (!doc) return;
|
||||
if (StorageUtil.getReaderConfig("isBionic") === "yes") {
|
||||
processDocumentBody(doc);
|
||||
}
|
||||
};
|
||||
@@ -2,20 +2,32 @@ import { voiceList } from "../../constants/voiceList";
|
||||
|
||||
class EdgeUtil {
|
||||
static player: AudioBufferSourceNode;
|
||||
//`<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"> <voice name="zh-CN-XiaoxiaoNeural"> <prosody rate="0%" pitch="0%">如果喜欢这个项目的话请点个 Star 吧。</prosody ></voice > </speak >`
|
||||
static async readAloud(text: string, voiceName: string) {
|
||||
let audioBuffer = await window
|
||||
.require("electron")
|
||||
.ipcRenderer.invoke("edge-tts", {
|
||||
text: this.createSSML(text, voiceName),
|
||||
static currentAudioBuffer: any;
|
||||
static nextAudioBuffer: any;
|
||||
static async readAloud(
|
||||
currentText: string,
|
||||
nextText: string,
|
||||
voiceName: string
|
||||
) {
|
||||
let audioBuffer =
|
||||
this.nextAudioBuffer ||
|
||||
(await window.require("electron").ipcRenderer.invoke("edge-tts", {
|
||||
text: this.createSSML(currentText, voiceName),
|
||||
format: "",
|
||||
});
|
||||
}));
|
||||
|
||||
let ctx = new AudioContext();
|
||||
let audio = await ctx.decodeAudioData(this.toArrayBuffer(audioBuffer));
|
||||
this.player = ctx.createBufferSource();
|
||||
this.player.buffer = audio;
|
||||
this.player.connect(ctx.destination);
|
||||
this.player.start(ctx.currentTime);
|
||||
this.nextAudioBuffer =
|
||||
nextText &&
|
||||
(await window.require("electron").ipcRenderer.invoke("edge-tts", {
|
||||
text: this.createSSML(nextText, voiceName),
|
||||
format: "",
|
||||
}));
|
||||
}
|
||||
static pauseAudio() {
|
||||
if (this.player && this.player.stop) {
|
||||
|
||||
@@ -21,17 +21,14 @@ export const getSelection = () => {
|
||||
};
|
||||
let lock = false; //prevent from clicking too fasts
|
||||
const arrowKeys = async (rendition: any, keyCode: number, event: any) => {
|
||||
if (
|
||||
document.querySelector(".editor-box") ||
|
||||
document.querySelector(".navigation-search-title")
|
||||
) {
|
||||
if (document.querySelector(".editor-box")) {
|
||||
return;
|
||||
}
|
||||
if (keyCode === 37 || keyCode === 38) {
|
||||
event.preventDefault();
|
||||
await rendition.prev();
|
||||
}
|
||||
if (keyCode === 39 || keyCode === 40 || keyCode === 32) {
|
||||
if (keyCode === 39 || keyCode === 40) {
|
||||
event.preventDefault();
|
||||
await rendition.next();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user