Python利用讯飞语音API实现方言学习之PyQt5界面实现

时间:2021-09-10 23:03:48

Python利用讯飞语音API实现方言学习之语音接口封装
前言:前一篇完成了语音接口的封装,接下来完成PyQt5界面实现。

Qt Creator界面设计

PyQt使用非常顺畅,因为Qt提供了跨平台的整套解决方案,采用进行Qt Creator一站解决开发者全部需求,包括编码,帮助文档,界面设计。
Python利用讯飞语音API实现方言学习之PyQt5界面实现

软件主要分成两部分界面,简单文档编辑界面和合成的WAV播放器(暂未实现),分成上下两个部分,在界面呈现时会隐藏其中一个界面,并通过 模式 按钮切换界面。

ui文件转为py文件

安装了PyQt5之后就会在Python环境安装pyuic5.exe,通过这个工具,我们可以直接通过命令将ui文件转为py文件。

pyuic5 命令选项

Usage: pyuic5 [options] <ui-file>

Options: --version show program's version number and exit -h, --help show this help message and exit -p, --preview show a preview of the UI instead of generating code -o FILE, --output=FILE write generated code to FILE instead of stdout -x, --execute generate extra code to test and display the class -d, --debug show debug output -i N, --indent=N set indent width to N spaces, tab if N is 0 [default: 4] Code generation options: --import-from=PACKAGE generate imports of pyrcc5 generated modules in the style 'from PACKAGE import ...' --from-imports the equivalent of '--import-from=.' --resource-suffix=SUFFIX append SUFFIX to the basename of resource files [default: _rc]

将 voice_ui.ui 转为 voice_ui.py

 pyuic5 -o voice_ui.py voice_ui.ui

生成的文件py文件,主要包含 setupUi 和 retranslateUi 函数
Python利用讯飞语音API实现方言学习之PyQt5界面实现

界面类VoiceWidget实现

在生成的界面py文件基础上需要一个框架来承载它,这里就是setupUi参数项中的MainForm参数。因此需要自己写一个继承自Qt界面类的类来完成界面逻辑操作。

class VoiceWidget(QtWidgets.QWidget):
    __CONFIG_FILE = 'config.json'

    def __init__(self):
        super().__init__()
        self._select_docpath = None
        self.ui = Ui_MainForm()
        self.ui.setupUi(self)
        self.docs = Document() 
        self.selected_workdir(os.path.abspath('./workdir'))
        self.taskmgr = TaskManager(self)
        self.restore_ui()
        self.show()
        self.ui.dock_music_widget.hide()

    def restore_ui(self):
        pass

    def save_ui(self):
        pass

    def clean(self):
        pass
    ...

界面类主要处理按钮,滑块,列表,编辑器的事情。窗口设计在Qt Creator中已经完成。
界面采用组合设计,各个组件,Document,TaskManager生命周期与界面一致。

创建窗口

def main():
    app = QtWidgets.QApplication(sys.argv)
    m = VoiceWidget()
    m.setWindowTitle('Learn')
    ret = app.exec_()
    m.clean()
    sys.exit(ret)

信号连接

实际上信号连接可以通过Qt Cretor的 *Signals & Slots Editor 实现编辑,然后稍加修改,比如:

处理Button按钮的press信号:

self.save_pushButton.pressed.connect(MainForm.on_clicked_save)

处理文件列表的右键信号:

self.file_listWidget.itemClicked['QListWidgetItem*'].connect(MainForm.on_clicked_listwidgetitem)

处理水平滑块滑动信号:

self.voice1_horizontalSlider.valueChanged['int'].connect(MainForm.on_speed_changed)

处理语音选择下拉框信号:

self.voice1_comboBox.currentIndexChanged['int'].connect(MainForm.on_people_changed)

QtListWidget 文本不能动态显示bug修复

c++版本qt也存在该bug

bselect = item.isSelected()
self.ui.file_listWidget.takeItem(idx)
self.ui.file_listWidget.insertItem(idx, item)
item.setSelected(bselect)

文档类Document实现

Document类主要实现工作目录维护,txt和wav之间的状态关系。

def __init__(self):
   self.curdoc = None
   self._workdir = None
   self.alldocs = []

主要维护当前文档curdoc ,所有alldocs。工作目录,所有的信息都是在来自于工作目录,通过扫描工作目录建立信息组织。工作目录包含:cache、text、wav三个目录。

每一doc项包含一个词典

DOCSTATE = ['NORMAL', 'WAITING', 'DOING', 'DONE', 'FAIL']
doc = {
    'docpath': doc,
    'wavpath': wav,
    'wavstatus': '',
    'docstatus': 'NORMAL'
}

扫描文件,返回filters列表中筛选的项。

@classmethod
def scan_files(cls, srcdir, filters, recursive=False, depth=1):
    srcdirlen = len(srcdir.split(os.sep))
    files = []
    for parent, dirnames, filenames in os.walk(srcdir):
        l = len(parent.split(os.sep))
        if l - srcdirlen <= depth:
            for fi in filters:
                srcfilter = os.path.join(parent, fi)
                ls = glob.glob(srcfilter, recursive=recursive)
                files.extend(ls)
    return files

txtfiles = self.scan_files(self.subworkdir('text'), '*.txt')

任务管理类TaskManager实现

语音合成是一个耗时的过程,采用后台线程实现,我们采用Python线程实现。TaskManager 就是为了维护任务的删除与增加,内部实现主要采用高效的queue.Queue。

class TaskManager:
    def __init__(self, ui, maxtask=3):
        self.tasks = queue.Queue()
        self.intasks = []
        # self._value_lock = threading.Lock()
        # self.maxtask = maxtask
        self.ui = ui

        self.th = threading.Thread(target=do_combine_voice_task, args=(self,))
        self.th.setDaemon(True)
        self.th.start()
    def add_task(self, task):
        if task['docpath'] not in self.intasks:
            # with self._value_lock:
            self.intasks.append(task['docpath'])
            self.tasks.put(task)

   def remove_task(self, docpath):
       # with self._value_lock:
       self.intasks.remove(docpath)
    ...

程序运行就开启后台线程,等待从任务队列中实现获取合成任务,由于我需要动态删除任务,创建 self.intasks 辅助变量,Queue遍历是一个糟糕的问题。这也引入一个新问题,并发访问intasks会不会造成崩溃?对于我们操作不是非常频繁的方法,基本是不会。为了安全起见,采用threading.Lock()创建一个锁,保护执行线程会访问和界面会访问造成冲突的代码块。

结束队列,采用给队列里插入None标志,当取出None对象时,便认为任务结束。

do_combine_voice_task 实现

def do_combine_voice_task(taskmgr):
    while True:
        binterruput = False
        task_ = taskmgr._task()
        if task_ is None:
            taskmgr.tasks.task_done()
            return
        doc_state = 'NORMAL'
        docpath = task_['docpath']
        wavpath = task_['wavpath']
        tempwavpath = os.path.join(task_['cache'], Document.wavname(docpath))
        try:
            print('begin ', tempwavpath)
            if os.path.exists(wavpath): os.remove(wavpath)
            with wave.open(tempwavpath, 'w') as fp:
                fp.setparams(TTSSample.wav_params)
                nbytespersec = fp.getframerate() * fp.getnchannels() * fp.getsampwidth()
                empty_vb = b''.join([b'0'] * int(nbytespersec * 0.5))
                tts = TTSSample()
                with open(docpath, 'r', encoding='utf-8') as f:
                    content = f.read()
                lines = list(splitlines(content))
                for idx, line in enumerate(lines, 1):
                    taskmgr.ui.change_docstate(docpath, 'DOING', '%%%d' % ((100 * idx) // len(lines)))
                    for param in taskmgr.sessionparams():
                        tts.session_begin_params = str(param)
                        text_to_speech(tts, fp, line)
                    fp.writeframes(empty_vb)
                    if taskmgr.isintask(docpath) is False:
                        doc_state = 'NORMAL'
                        taskmgr._task_done(docpath)
                        remove_file(tempwavpath, ignore=True)
                        binterruput = True
                        break
                    time.sleep(0.01)
                if binterruput is True:
                    continue
            shutil.move(tempwavpath, wavpath)
            doc_state = 'DONE'
        except Exception as e:
            doc_state = 'FAIL'
            traceback.print_exc()
        finally:
            print('end ', tempwavpath)
            taskmgr.ui.change_docstate(docpath, doc_state)
            taskmgr._task_done(docpath)

splitlines实现

按照 ‘?’, ‘?’, ‘!’, ‘-‘, ‘.’, ‘。’, ‘:’, ‘:’, ‘,’, ‘,’, ‘/’ 作为一句话分割,此处采用yiled实现,但是我在使用的时候,却直接转为list,简直是多此一举!。主要是为了统计进度其实也可以按照字数来统计,此处是为了简单,反正我不缺这点内存。

def splitlines(content):
    def append_by(line):
        temp_ = ''.join(line)
        if len(temp_.strip()) > 2:
            return temp_

    def nextline(content, start):
        line = []
        idx = start
        for idx, word in enumerate(content[start:], start):
            if word in ['?', '?', '!', '-', '.', '。', ':', ':', ',', ',', '/']:
                line.append(word)
                break
            elif word in ['\n', '\r']:
                break
            else:
                line.append(word)

        return idx + 1, append_by(line)

    idx = 0
    while True:
        idx, line = nextline(content, idx)
        if line is None or len(line) < 1:
            if idx >= len(content):
                break
            else:
                continue
        yield line

Review代码反思

1、如何保护多线程访问,需要怎么保护访问变量才会恰到好处,不会过度保护或者裸奔?
2、由于界面只设计了设计模式,wav播放模式没有完成,要完成信号处理,两个全部堆在一块儿是不是给主框架窗口增加了负担,不便于扩展。是否考虑分开两种界面模式的代码?

工程下载

完整工程下载