说明
使用 QTextToSpeech 的来实现阅读
QTextToSpeech 用法
正常情况下,应该正常的
如果不能发声的话,嘿嘿,可是使用了精简版的系统,那就没啥办法了
from PyQt6 import QtTextToSpeech, QtWidgets
import sys,time
'''
引擎:
['winrt', 'sapi', 'mock']
引擎的声音:
mock ['Bob', 'Anne'] # 这个引擎不能用
sapi ['Microsoft Huihui Desktop'] # 这个可以,只有一个声音
winrt ['Microsoft Huihui', 'Microsoft Yaoyao', 'Microsoft Kangkang'] # 这个可以
'''
# txt = '混沌未分天地乱茫茫渺渺无人见混沌未分天地乱茫茫渺渺无人见' # 无标点
# txt = '混沌未分天地乱,茫茫渺渺无人见.混沌未分天地乱,茫茫渺渺无人见.' # 加入英文标点
txt = '混沌未分天地乱,茫茫渺渺无人见。混沌未分天地乱,茫茫渺渺无人见。' # 加入中文标点
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
tts = QtTextToSpeech.QTextToSpeech(app)
# print(QtTextToSpeech.QTextToSpeech.availableEngines()) # ['winrt', 'sapi', 'mock']
tts.setEngine('winrt')
tts.setVoice(tts.availableVoices()[1])
# tts.setPitch(-0.1)
# tts.setRate(0.05)
# print(tts.engine(),tts.voice().name(), [voice.name() for voice in tts.availableVoices()] )
start = time.time()
tts.say(txt)
# button = QtWidgets.QPushButton('退出')
# button.clicked.connect(app.quit)
# button.show()
# locale = QtCore.QLocale.system()
# print(f'amText = {locale.amText()}')
# print(f'bcp47Name = {locale.bcp47Name()}')
# print(f'currencySymbol = {locale.currencySymbol()}')
# print(f'dateTimeFormat = {locale.dateTimeFormat()}')
# print(f'dayName = {locale.dayName(1,locale.FormatType.ShortFormat)}')
def on_tts_ready(state):
print()
if state == QtTextToSpeech.QTextToSpeech.State.Ready:
app.quit()
# QtCore.QTimer.singleShot(5000,app.quit)
tts.stateChanged.connect(on_tts_ready)
SystemExit(app.exec())
print( f'{txt, len(txt), time.time() - start}')
# ('混沌未分天地乱茫茫渺渺无人见混沌未分天地乱茫茫渺渺无人见', 28, 7.014833927154541)
# ('混沌未分天地乱,茫茫渺渺无人见.混沌未分天地乱,茫茫渺渺无人见.', 32, 7.7734575271606445)
# ('混沌未分天地乱,茫茫渺渺无人见。混沌未分天地乱,茫茫渺渺无人见。', 32, 8.660011291503906)
界面 + TTS
程序样式如下图:
无框,拖动,右键菜单,滚轮改变透明度
自动缩放界面,估计在高分屏下会有问题,自己修改
功能按键:
- ESC : 退出
- Space: 暂停/继续
import time
from PyQt6 import QtTextToSpeech, QtCore, QtWidgets,QtGui
import sys,os,json5,math,re
from PyQt6.QtGui import QCloseEvent, QKeyEvent, QMouseEvent, QWheelEvent
chapter_line = -1 # 段落索引,从 1 开始,注意调整
file_idx = 0 # 文件索引,从 1 开始,注意调整
txt = '' # 保存文章内容
p_list = [] # 保存分割后的段落 去除空行
short_word_list = [] # 保存分割段落后的短句
short_word_idx = -1 # 保存当前的短句索引 从 1 开始,注意调整
# reg_str = r"[^。?”]+[?。”]+"
# 用句号分割
pat_str1 = r'[^“]{30,}。'
pat_str2 = r'[^“]*?“.*?[。!?]”'
pat_str3 = f'{pat_str1}|{pat_str2}'
reg_str = pat_str3
reg = re.compile(reg_str)
x=y=500
opacity = 1
isReadChapter = False
################################ 读取配置 ##########################################
# file_idx 文章的索引
# chapter_line 段落的索引
###################################################################################
def load_setting():
global chapter_line,file_idx,short_word_idx
global x,y,opacity
setting_file = f'{xyj_dir+"progress.txt"}'
if not os.path.exists(setting_file):
file_idx = 1
chapter_line = 1
short_word_idx = 1
return
# 读取配置文件
with open(xyj_dir+"progress.txt",'r',encoding='utf-8') as f:
dic = json5.loads(f.read())
file_idx = dic['file_idx']
chapter_line = dic['line']
short_word_idx = dic['words']
if file_idx == 0:
file_idx =1
if file_idx > len(file_list):
file_idx = len(file_list)
if chapter_line == 0:
chapter_line =1
if short_word_idx == 0:
short_word_idx =1
x = dic['x']
y = dic['y']
opacity = dic['opacity']
################################ 加载文章内容,并分段 ################################
# txt 保存文章内容
# p_list 保存分割后的段落 去除空行
###################################################################################
def load_chapater():
global txt,p_list,short_word_list,chapter_line,short_word_idx
with open(xyj_dir+file_list[file_idx-1],'r',encoding='utf-8') as f:
txt = f.read()
p_list = txt.split('\n')
# 移除列表中的空字符串
# p_list = [x.strip() for x in p_list if x.strip()!=''] # comment @ 2024年1月31日
if chapter_line > len(p_list):
chapter_line = 1
paragraph = p_list[chapter_line-1]
short_word_list = reg.findall(paragraph)
# 针对没有匹配到的段落
if not short_word_list:
short_word_list.append(paragraph)
if short_word_idx > len(short_word_list):
short_word_idx = 1
# print(1111,short_word_list)
window.title_lab.setText(file_list[file_idx-1])
################################ 重新设置窗口大小 ################################
# 重新计算合适的大小,并调整窗口
###################################################################################
def resize():
t = f'{chapter_line} {short_word_list[short_word_idx-1]}'
txt_len = len(t)
txt_pix_len = txt_len * window.zh_width * window.line_height
width = math.ceil(math.sqrt(txt_pix_len)*2) + 100
if width < 350:
width = 350
height = math.ceil(txt_pix_len/width) + 40
# 窗口右侧位置不变
ox = window.pos().x()
rw = window.width()
window.resize(width,height)
window.move(ox+rw-width,window.pos().y())
################################ 重新设置窗口大小 ################################
# 重新计算合适的大小,并调整窗口
###################################################################################
def read_and_show():
global short_word_list,short_word_idx
shortwords = short_word_list[short_word_idx-1]
# t = f'{window.lable_lineheight_css}{chapter_line:02}: {p_list[chapter_line-1]}'
t = f'{window.lable_lineheight_css}{chapter_line}/{len(p_list)}:{short_word_idx}/{len(short_word_list)}: {shortwords}'
label.setText(t)
# 重新设置窗口大小
resize()
tts.say(shortwords)
################################ tts 状态变化 ################################
###################################################################################
def On_TTS_State_Changed(state: QtTextToSpeech.QTextToSpeech.State):
global chapter_line,file_idx,short_word_idx,short_word_list,isReadChapter
# ==== 设置tts的音量
tts.setVolume(100)
# ==== tts Ready状态
if state == QtTextToSpeech.QTextToSpeech.State.Ready:
# 开始状态,读取配置
if isReadChapter == False:
load_chapater()
read_and_show()
isReadChapter = True
return
short_word_idx += 1
# 段落没读完
if short_word_idx <= len(short_word_list):
read_and_show()
else :
chapter_line += 1
short_word_idx = 1 # 到达新的段落, 短句索引复位
# add 2024年1月31日
while chapter_line <= len(p_list) and p_list[chapter_line-1].strip()=='' :
chapter_line += 1
# 2024年1月31日 <= end
if chapter_line <= len(p_list):
paragraph = p_list[chapter_line-1]
short_word_list = reg.findall(paragraph)
# 移除列表中的空字符串
short_word_list = [x.strip() for x in short_word_list if x.strip()!='']
# 针对没有匹配到的段落
if not short_word_list:
short_word_list.append(paragraph)
read_and_show()
else:
file_idx += 1
if file_idx > len(file_list): # 章节已经全部读完 退出
file_idx -= 1
chapter_line -= 1
short_word_idx = len(short_word_list)
window.label.setText("章节已经全部读完")
return
# 重新设置 章节的行数
chapter_line = 1
load_chapater()
read_and_show()
################################ Quit() ################################
########################################################################
def QtQuit():
tts.blockSignals(True)
tts.stop()
QtCore.QTimer.singleShot(100,app.quit)
################################ 界面类 ################################
class MWidget(QtWidgets.QWidget):
# =========================== __init__() ===========================
def __init__(self):
super().__init__()
# ------------ 状态和属性 ------------
self.setWindowFlags(QtCore.Qt.WindowType.WindowStaysOnTopHint | QtCore.Qt.WindowType.Tool | QtCore.Qt.WindowType.FramelessWindowHint)
# Qt::WA_NoSystemBackground
# self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground,True)
self.setFocusPolicy(QtCore.Qt.FocusPolicy.ClickFocus)
# 位置和透明度
self.move(x,y)
self.setWindowOpacity(opacity)
# ------------ 标题部分 ------------
self.title = QtWidgets.QWidget()
title_layout = QtWidgets.QHBoxLayout(self.title)
title_layout.setContentsMargins(0,3,0,3)
self.title_lab = QtWidgets.QLabel()
title_layout.addStretch(1)
title_layout.addWidget(self.title_lab)
title_layout.addStretch(1)
self.title.setStyleSheet('background:#e8e8ff')
# self.title_lab 设置字体
font = self.title_lab.font()
font.setFamily('楷体')
# font.setPixelSize(14 )
font.setPointSize(10)
font.setBold(True)
self.title_lab.setFont(font)
self.title_lab.setText("ESC : 退出 空格: 暂停/继续")
## ------------ 主体部分 ------------
self.label = QtWidgets.QLabel()
# self.label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) # comment @ 2024年1月31日
# 设置换行
self.label.setWordWrap(True)
# 设置对其方式
self.label.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop)
self.label.setContentsMargins(5,1,1,0)
# self.label 设置字体
# font = self.label.font()
# font.setFamily('楷体')
# font.setPixelSize(12)
font.setBold(False)
font.setPointSize(10)
font_metric = QtGui.QFontMetrics(font)
self.zh_width = font_metric.horizontalAdvance('中')
self.line_height = math.ceil(font_metric.height() * 1.2)
self.lable_lineheight_css = f'<p style="line-height:{self.line_height}px;">'
# self.lable_lineheight_css = ''
self.label.setFont(font)
# ------------ 简单布局 ------------
self.main_lay = QtWidgets.QVBoxLayout(self)
# 加入标题
self.main_lay.addWidget(self.title)
# 加入主体
self.main_lay.addWidget(self.label,1)
# 加入状态栏
# self.main_lay.addWidget(self.status_bar)
# 设置
self.main_lay.setStretch(1,1)
self.main_lay.setContentsMargins(0,0,0,0)
self.main_lay.setSpacing(1)
# ------------ tts ------------
tts.setVolume(0)
tts.stateChanged.connect(On_TTS_State_Changed)
tts.say("hello")
time.sleep(1)
# ------------ 上下文菜单 ------------
self.action = QtGui.QAction('progress.txt')
self.action.triggered.connect(self.contextMenu)
self.addAction(self.action)
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.ActionsContextMenu)
# =========================== 关闭事件 ===========================
def closeEvent(self, a0: QCloseEvent | None) -> None:
QtQuit()
return super().closeEvent(a0)
# =========================== 鼠标点击事件 ===========================
def mousePressEvent(self, a0: QMouseEvent | None) -> None:
self.start_pos = a0.pos()
return super().mousePressEvent(a0)
# =========================== 鼠标释放事件 ===========================
def mouseReleaseEvent(self, a0: QMouseEvent | None) -> None:
self.start_pos = QtCore.QPoint()
return super().mouseReleaseEvent(a0)
# =========================== 鼠标移动事件 ===========================
def mouseMoveEvent(self, a0: QMouseEvent | None) -> None:
move_pos = a0.pos()
self.move(self.pos()+ move_pos-self.start_pos )
# self.start_pos = move_pos
return super().mouseMoveEvent(a0)
# =========================== 按键按压事件 ===========================
def keyPressEvent(self, a0: QKeyEvent | None) -> None:
if a0.key() == QtCore.Qt.Key.Key_Escape:
QtQuit()
elif a0.key() == QtCore.Qt.Key.Key_Space:
if tts.state() == QtTextToSpeech.QTextToSpeech.State.Paused:
tts.resume()
elif tts.state() == QtTextToSpeech.QTextToSpeech.State.Speaking:
tts.pause()
else:
return super().keyPressEvent(a0)
# =========================== 鼠标滚轮事件 ===========================
# 修改 window 的不透明度
def wheelEvent(self, a0: QWheelEvent | None) -> None:
delta = a0.angleDelta()
opacity = self.windowOpacity()
if delta.y() < 0:
opacity -= 0.1
opacity = 0.2 if opacity < 0.2 else opacity
if delta.y() > 0:
opacity += 0.1
opacity = 1 if opacity >= 1 else opacity
self.setWindowOpacity(opacity)
return super().wheelEvent(a0)
# =========================== 菜单事件 handler ===========================
def contextMenu(self):
path = os.getcwd()
path = f'{path}/{xyj_dir}progress.txt'
#打开txt文件
os.startfile(path)
pass
################################ __main__ ################################
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
xyj_dir = r'小说/西游记/'
# 获取文章列表
file_list = os.listdir(xyj_dir)[1:]
tts = QtTextToSpeech.QTextToSpeech(app)
load_setting()
#=========================== 简单界面 ===========================
window = MWidget()
label = window.label
window.show()
window.activateWindow()
# print(window.baseSize().width(),window.baseSize().height())
# print(window.sizeIncrement().width(),window.sizeIncrement().height())
app.exec()
with open(xyj_dir+"progress.txt",'w',encoding='utf-8') as f:
_json = fr'''{{
{'"file_idx"':10}: {file_idx:5}, // 章节序序号
{'"line"':10}: {chapter_line:5}, // 章节段落号
{'"words"':10}: {short_word_idx:5}, // 分词号
{'"x"':10}: {window.pos().x():5}, // 窗口位置 x
{'"y"':10}: {window.pos().y():5}, // 窗口位置 y
{'"opacity"':10}: {window.windowOpacity():5}, // 不透明
}}'''
# dic = json5.loads(_json)
# f.write(json5.dumps(dic,indent=4))
f.write(_json)