Python 从零开始制作自己的声音 - wave模块读写wav文件详解

Posted qfcy_

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Python 从零开始制作自己的声音 - wave模块读写wav文件详解相关的知识,希望对你有一定的参考价值。

计算机经常被用于处理音频这种真实世界中的数据。声音经过采样,量化和编码后,存储在音频文件,如wav文件中。
文章首先介绍wave模块的基础用法; 再通过生成一定频率声波的算法实现,来深入讲解wave库的使用。

目录

wave模块

wave模块提供了一个处理 wav 声音格式的便利接口, 可获取wav文件头信息, 从文件读取数据, 也可直接将bytes格式的数据写入wav文件。

wave.open()

wave.open(file, mode=None)
类似于普通的打开文件,函数接收两个参数,file为文件名或文件对象,mode可取"r",“rb”,“w”,“wb"四个值,其中"r"和"rb”, "w"和"wb"效果完全相同。如下:

>>> wave.open('音乐.wav','r')
<wave.Wave_read object at 0x0355E810>
>>> wave.open('test.wav','w')
<wave.Wave_write object at 0x0355E810>

以读模式打开的文件会返回Wave_read 对象, 写模式打开时会返回Wave_write 对象。

Wave_read

Wave_read 对象通过wave.open() 函数创建。wave文件记录了二进制的音频数据,由许多帧组成,一个采样对应一个帧,每一帧长度为1或2字节。

Wave_read.getnchannels():返回声道数量(1 为单声道,2 为立体声)

Wave_read.getsampwidth():返回采样字节长度 (每一帧的字节长度)。

Wave_read.getframerate():返回采样频率。

Wave_read.getnframes():返回音频总帧数。

Wave_read.getcomptype()Wave_read.getcompname():返回压缩类型。

Wave_read.readframes(n)
读取并返回以 bytes 对象表示的最多 n 帧音频。

Wave_read.tell()
返回当前文件指针位置。

Wave_read.setpos(pos)
设置文件指针到指定位置。

Wave_write

Wave_write 对象也通过wave.open() 函数创建。

Wave_write.setnchannels(n):设置声道数。

Wave_write.setsampwidth(n):设置采样字节长度为 n。

Wave_write.setframerate(n):设置采样频率为 n。

Wave_write.setnframes(n):设置总帧数为 n。(后来发现调用writeframes()中,wave模块会自动检测总帧数,实际上不需要设置)

Wave_write.setcomptype(type, name):设置压缩格式。(目前只支持 NONE 即无压缩格式。)

Wave_write.tell()
返回当前文件指针,其指针含义和 Wave_read.tell() 以及 Wave_read.setpos() 是一致的。

Wave_write.writeframes(data)(或writeframesraw(data)
写入bytes格式的音频帧,并更新 nframes。

Wave_write.close()
确保 nframes 是正确的,并在文件被 wave 打开时关闭它。 此方法会在对象收集时被调用。 如果输出流不可查找且 nframes 与实际写入的帧数不匹配时引发异常。

初步: 拼接音频

程序先将两段音频中的数据读入data1data2中,再将读取的数据拼接,写入result.wav。两段音频的采样频率、采样字节长度需要一致。

import wave

sampwidth = 1
framerate = 22050

with wave.open('音乐1.wav','rb') as f1:
    sampwidth = f1.getsampwidth()
    framerate = f1.getframerate()
    nframes1=f1.getnframes()
    data1=f1.readframes(nframes1)

with wave.open('音乐2.wav','rb') as f2:
    nframes2=f2.getnframes()
    data2=f2.readframes(nframes2)

with wave.open('result.wav','wb') as fw:
    fw.setnchannels(1)
    fw.setsampwidth(sampwidth)
    fw.setframerate(framerate)
    #fw.setnframes(nframes1+nframes2)
    fw.writeframesraw(data1)
    fw.writeframesraw(data2)

初次实现

现在开始制作自己的声音。程序生成一段频率为200Hz, 长度为1.8秒的蜂鸣声。

import wave
from winsound import PlaySound,SND_FILENAME

file = 'test.wav'
len_= 1.8 # 秒
frequency = 200
sampwidth = 1 #每一帧宽度(采样字节长度)
framerate = 22050 # 采样频率 (越大音质越好)
length = int(framerate * len_ * sampwidth)
para = [0b00000000]*(framerate//frequency//2*sampwidth)\\
       +[0b11111111]*(framerate//frequency//2*sampwidth) # 音频的一小段
data=bytes(para)

# 生成wav文件
with wave.open(file,'wb') as f:
    f.setnchannels(1)
    f.setsampwidth(sampwidth)
    f.setframerate(framerate)
    # f.setnframes(length) (可选)
    f.writeframes(data * (length // len(data)))

PlaySound(file,SND_FILENAME) # 播放生成的wav

再次实现

上述程序生成的是方波,并有一些缺陷,如para0b00000000b11111111的长度是整数且相同,导致生成的声音频率不精确,等等。
这里合成一段200Hz,长度为1.8秒的正弦波。

import wave,math
from winsound import PlaySound,SND_FILENAME

def generate(T,total,volume,sine=False):
    # T: 周期, total 总长度, 都以帧为单位
    if not sine:
        h = T / 2
        for i in range(total):
            if i % T >= h:
                yield volume
            else:
                yield 0
    else:
        # 计算方法: sin 的 T = 2*pi / w
        w = 2 * math.pi  / T; r = volume / 2
        for i in range(total):
            yield int(math.sin(w * i) * r + r)

file = 'test.wav'
len_= 1.8 # 秒
frequency = 200
sampwidth = 1
framerate = 22050
sine=True
volume = 255 # 音量, 0 - 255
data = bytes(generate(framerate / frequency, int(framerate*len_),
                              volume,sine)) # bytes能接收0-255整数型的迭代器

with wave.open(file,'wb') as f:
    f.setnchannels(1)
    f.setsampwidth(sampwidth)
    f.setframerate(framerate)
    f.writeframes(data)

PlaySound(file,SND_FILENAME)

运行程序会发现,正弦波听起来比方波更加柔和。
自己做的合成与Python内置的音频合成对比:

import winsound
# Beep(freq,duration),参数分别是频率和毫秒为单位的持续时间
winsound.Beep(200,1800)

发现, 前述程序很好地仿真了调用内置的Beep函数发声。
但音质有区别, 这是采样字节长度为1(只有8位)导致的, 还需要加大采样字节长度。
最终的程序如下:

import wave,math,struct
from winsound import PlaySound,SND_FILENAME
def generate(T,total,volume,sampwidth,sine=False):
    # T: 周期, total 总长度, 以帧为单位
    volume = min(volume * 2**(sampwidth*8),2**(sampwidth*8) - 1)
    if not sine:
        h = T / 2
        for i in range(total):
            if i % T >= h:
                yield volume
            else:
                yield 0
    else:
        w = 2 * math.pi  / T; r = volume / 2
        for i in range(total):
            # T = 2*pi / w
            yield int(math.sin(w * i) * r + r)

file = 'test.wav'
len_= 1.8 # 秒
frequency = 200
sampwidth = 2
framerate = 22050
sine=True
volume = 255
# 8位的wav文件的一帧是无符号8位整数, 而16位的一帧是有符号的整数(-32768至32767)。
if sampwidth == 1: # 8位
    lst = list(generate(framerate / frequency, int(framerate*len_),
                    volume,sampwidth,sine))
    data = bytes(lst)
elif sampwidth == 2:
    data = b'' # 16位
    lst = list(generate(framerate/frequency,
                        int(framerate*len_),
                        volume,sampwidth,sine))
    for digit in lst:
        data += struct.pack('<h',digit - 32768)

with wave.open(file,'wb') as f:
    # --snip-- (看前面)

PlaySound(file,SND_FILENAME)

使用matplotlib库查看生成的声波:

import matplotlib.pyplot as plt
# --snip--
plt.plot(range(len(lst)),lst)
plt.show()


写在最后:
程序还可再做改进, 例如模拟各种乐器的音色, 也就是细微改变生成的声波形状。如果程序中加入共振峰, 还可实现简单的语音合成?
但是, Windows系统已经自带了语音合成, 何必再开发一个呢?
下篇: Python 调用Windows内置的语音合成,并生成wav文件

串口助手Python从零开始制作温湿度串口上位机

文章目录

1. 项目介绍

该项目为本人的一次课设,在很多项目开发中,都需要通过上位机来控制或者读取 MCU、MPU 中的数据。上位机和设备间的通信协议有串口、CAN、RS485 等等。本项目基于 python 编写,将串口获取到的数据显示在上位机中,并将数据以可视化图形显示出来。废话少说,上图!!!


2. 功能简介

3. 开发过程

3.1 准备工作

本项目用到的库有 tkinter、pyserial、matplotlib、pyautogui、configparser、webbrowser 等,其中 pyserial 与 pyautogui 需要自行安装其余库皆是 python 自带库。如没有安装过这两个库可以使用以下命令安装。

pip install pyserial
pip install pyautogui

3.2 编写串口上位机界面

首先,先将上位机基本界面框架搭建好,此部分给出代码自行研究。

from tkinter import *
from tkinter.messagebox import *

import ctypes


class zsh_serial:
    def __init__(self):
        self.window = Tk()  # 实例化出一个父窗口
        # self.com = serial.Serial()
        self.serial_combobox = None
        self.bound_combobox = None
        self.txt = None

    def ui(self):
        ############################################
        # 窗口配置
        ############################################
        # self.window = Tk()  # 实例化出一个父窗口
        self.window.title("温湿度串口调试助手")
        # self.window.iconbitmap(default='data\\\\COM.ico')  # 修改 logo
        width = self.window.winfo_screenwidth()
        height = self.window.winfo_screenheight()
        print(width, height)
        win = 'x++'.format(880, 500, width // 3, height // 5)  # x 窗口大小,+10 +10 定义窗口弹出时的默认展示位置
        self.window.geometry(win)
        self.window.resizable(False, False)

        # 调用api设置成由应用程序缩放
        ctypes.windll.shcore.SetProcessDpiAwareness(1)
        # 调用api获得当前的缩放因子
        ScaleFactor = ctypes.windll.shcore.GetScaleFactorForDevice(0)
        # 设置缩放因子
        self.window.tk.call('tk', 'scaling', ScaleFactor / 75)

        ############################################
        # 串口设置子菜单 1
        ############################################

        # 串口设置
        group_serial_set = LabelFrame(self.window, text="串口设置")
        group_serial_set.grid(row=0, padx=10, pady=10)

        serial_label = Label(group_serial_set, text="串口号")
        serial_label.grid(row=0, column=0, padx=10, pady=10, sticky=W)
        self.serial_combobox = ttk.Combobox(group_serial_set, width=8)
        # self.serial_combobox['value'] = zsh_serial.getSerialPort()
        self.serial_combobox.grid(row=0, column=1, padx=10, pady=10)

        bound_label_set = Label(group_serial_set, text="波特率")
        bound_label_set.grid(row=1, column=0)
        self.bound_combobox = ttk.Combobox(group_serial_set, width=8)
        self.bound_combobox['value'] = ("9600", "19200", "38400", "57600", "115200", "128000")
        self.bound_combobox.grid(row=1, column=1)

        databits_label = Label(group_serial_set, text="数据位")
        databits_label.grid(row=2, column=0, pady=10)
        databits_combobox = ttk.Combobox(group_serial_set, width=8)
        databits_combobox['value'] = ("1", "1.5", "2")
        databits_combobox.grid(row=2, column=1)

        checkbits_label = Label(group_serial_set, text="校验位")
        checkbits_label.grid(row=3, column=0)
        checkbits_combobox = ttk.Combobox(group_serial_set, width=8)
        checkbits_combobox['value'] = ("None", "Odd", "Even")
        checkbits_combobox.grid(row=3, column=1)

        xxx_label = Label(group_serial_set, text="   ")
        xxx_label.grid(row=4, column=0, pady=1)

        # 接收设置
        recv_set = LabelFrame(self.window, text="接收设置")
        recv_set.grid(row=1, padx=10)

        recv_set_v = IntVar()
        recv_set_radiobutton1 = Radiobutton(recv_set, text="ASCII", variable=recv_set_v, value=1)
        recv_set_radiobutton1.grid(row=0, column=0, sticky=W, padx=10)
        recv_set_radiobutton2 = Radiobutton(recv_set, text="HEX", variable=recv_set_v, value=2)
        recv_set_radiobutton2.grid(row=0, column=1, sticky=W, padx=10)

        recv_set_v1 = IntVar()
        recv_set_v2 = IntVar()
        recv_set_v3 = IntVar()
        recv_set_checkbutton1 = Checkbutton(recv_set, text="自动换行", variable=recv_set_v1, onvalue=1, offvalue=2)
        recv_set_checkbutton1.grid(row=1, column=0, padx=10)
        recv_set_checkbutton2 = Checkbutton(recv_set, text="显示发送", variable=recv_set_v2, onvalue=1, offvalue=2)
        recv_set_checkbutton2.grid(row=2, column=0, padx=10)
        recv_set_checkbutton3 = Checkbutton(recv_set, text="显示时间", variable=recv_set_v3, onvalue=1, offvalue=2)
        recv_set_checkbutton3.grid(row=3, column=0, padx=10)

        # 串口操作
        group_serial_event = LabelFrame(self.window, text="串口操作")
        group_serial_event.grid(row=2, padx=10, pady=10)

        self.serial_btn_flag_str = StringVar()
        self.serial_btn_flag_str.set("串口未打开")
        label_name = Label(group_serial_event, textvariable=self.serial_btn_flag_str, bg='#ff001a', fg='#ffffff')
        label_name.grid(row=0, column=0, padx=55, pady=2)

        self.serial_btn_str = StringVar()
        self.serial_btn_str.set("打开串口")
        serial_btn = Button(group_serial_event, textvariable=self.serial_btn_str)
        serial_btn.grid(row=1, column=0, padx=55, pady=10)

        # 数据显示
        self.txt = Text(self.window, width=70, height=26.5, font=("SimHei", 10))
        self.txt.grid(row=0, rowspan=3, column=1, padx=8, pady=10, sticky='s')

        # 串口子菜单设置初值
        self.bound_combobox.set(self.bound_combobox['value'][4])
        databits_combobox.set(databits_combobox['value'][0])
        checkbits_combobox.set(checkbits_combobox['value'][0])
        recv_set_v.set(2)
        recv_set_v1.set(1)
        recv_set_v2.set(2)
        recv_set_v3.set(2)

        ############################################
        # 配置tkinter样式
        ############################################
        # self.window.config(menu=menubar)

        ############################################
        # 退出检测
        ############################################
        def bye():
            self.window.destroy()

        self.window.protocol("WM_DELETE_WINDOW", bye)

        # 窗口循环显示
        self.window.mainloop()


if __name__ == "__main__":
    mySerial = zsh_serial()
    mySerial.ui()

PS:当前版本仅支持 125%缩放 1920x1080分辨率 or 2560x1440分辨率
现在界面还是太简陋了,接下来增加 menu 菜单栏。这里用到了 ttk 子模块,因为 tkinter 没有下拉菜单控件,代码如下:

from tkinter import ttk  # 导入ttk模块,因为Combobox下拉菜单控件在ttk中

# ... 略

    	############################################
        # menu菜单
        ############################################
        menubar = Menu(self.window)  # 创建一个顶级菜单
        menu = MENU(self.window)
        filemenu1 = Menu(menubar, tearoff=False)  # 在顶级菜单menubar下, 创建一个子菜单filemenu1
        filemenu2 = Menu(menubar, tearoff=False)  # 在顶级菜单menubar下, 创建一个子菜单filemenu2
        filemenu3 = Menu(menubar, tearoff=False)  # 在顶级菜单menubar下, 创建一个子菜单filemenu3
        menubar.add_cascade(label="文件", menu=filemenu1)  # 为子菜单filemenu1取个名字
        menubar.add_cascade(label="工具", menu=filemenu2)  # 为子菜单filemenu2取个名字
        menubar.add_cascade(label="折线图", menu=filemenu3)  # 为子菜单filemenu3取个名字
        menubar.add_command(label="帮助", command=menu.callback7)
        menubar.add_command(label="关于", command=menu.callback8)
        filemenu1.add_command(label="更新检测", command=menu.callback9)  # 为子菜单filemenu1添加选项,取名"更新检测"
        filemenu1.add_command(label="获取源码", command=menu.callback1)  # 为子菜单filemenu1添加选项,取名"获取源码"
        filemenu1.add_command(label="博客教程", command=menu.callback10)  # 为子菜单filemenu1添加选项,取名"博客教程"
        filemenu1.add_separator()  # 添加一条分割线
        filemenu1.add_command(label="退出", command=menu.callback2)  # 为子菜单filemenu1添加选项,取名"关闭"
        filemenu2.add_command(label="刷新串口", command=self.cleanSerial)  # 为子菜单filemenu2添加选项,取名"刷新串口"
        filemenu2.add_command(label="截图", command=menu.callback4)  # 为子菜单filemenu2添加选项,取名"截图"
        filemenu3.add_command(label="温度图", command=menu.callback5)  # 为子菜单filemenu2添加选项,取名"温度图"
        filemenu3.add_command(label="湿度图", command=menu.callback6)  # 为子菜单filemenu2添加选项,取名"湿度图"
        
 # ... 略

这一步完成后,是运行不了的,我们要为菜单栏增加回调函数。

import webbrowser

class MENU:
    def __init__(self, init_window_name):
        self.init_window_name = init_window_name

    @staticmethod
    def callback1():
        print("--- 获取源码 ---")
        showwarning("warning", "Please follow the GPL3.0")
        webbrowser.open("https://github.com/Theo-s-Open-Source-Project")

    @staticmethod
    def callback2():
        print("--- 退出 ---")
        sys.exit()

    def callback3(self):
        print("--- 刷新串口 ---")

    @staticmethod
    def callback4():
        print("--- 截图 ---")
        # window_capture()
           
 # ... 略

到此,我们的界面已经搭建完成了,接下来就是注入灵魂的时候,为其增加功能函数。

3.3 功能实现

3.3.1 基本功能

在进行通信前,要先获取电脑可用串口进行连接,借助 pyserial 库的 serial.tools.list_ports.comports() 获取电脑目前所有串口号。

    @staticmethod
    def getSerialPort():
        port = []
        portList = list(serial.tools.list_ports.comports())
        # print(portList)

        if len(portList) == 0:
            print("--- 无串口 ---")
            port.append('None')
        else:
            for comport in portList:
                # print(list(comport)[0])
                # print(list(comport)[1])
                port.append(list(comport)[0])
                pass
        return port

获取到串口号后,将其显示在 tkinter 的 combobox 控件中。

self.serial_combobox['value'] = zsh_serial.getSerialPort()

接下来就是打开串口,这里不做详细讲解(如需要的话评论区留言🦄)给出具体实现代码。

    def openSerial(self, port, bps, timeout):
        """
        打开串口
        :param port: 端口号
        :param bps: 波特率
        :param timeout: 超时时间
        :return: True or False
        """
        ser_flag = False
        try:
            # 打开串口
            self.com = serial.Serial(port, bps, timeout=timeout)
            if self.com.isOpen():
                ser_flag = True
                # threading.Thread(target=self.readSerial, args=(self.com,)).start()
                # print("Debug: 串口已打开\\n")
            # else:
            #     print("Debug: 串口未打开")
        except Exception as e:
            print("error: ", e)
            error = "error: ".format(e)
            showerror('error', error)
        return self.com, ser_flag

将其与打开串口 button 事件进行绑定,代码如下:

 ... 
        self.serial_btn_str = StringVar()
        self.serial_btn_str.set("打开串口")
        serial_btn = Button(group_serial_event, textvariable=self.serial_btn_str, command=self.hit1)  # 添加点击事件
        serial_btn.grid(row=1, column=0, padx=55, pady=10)
 ...
    
    def hit1(self):
        """
        打开串口按钮回调
        """
        # print(self.com.isOpen())
        if self.com.isOpen():
            self.com.close()
            print("--- 串口未打开 ---")
            self.serial_btn_flag_str.set("串口未打开")
            self.serial_btn_str.set("打开串口")
        else:
            self.com, ser_flag = self.openSerial(self.serial_combobox.get(), self.bound_combobox.get(), None)
            if ser_flag:
                print("--- 串口已打开 ---")
                self.serial_btn_flag_str.set("串口已打开")
                self.serial_btn_str.set("关闭串口")

到此,一个串口调试助手的最基本功能就实现了,接下来就是让串口获取到的信息显示到上位机中的 txt 控件上。

我们该如何实时获取并打印串口中的数据呢,这里使用一个线程不断的去读取。

    def readSerial(self, com):
        """
        读取串口数据
        :return:
        """
        global serialData
        while True:
            if self.com.in_waiting:
                textSetial = self.com.read(self.com.in_waiting)
                serialData = textSetial
                # print(textSetial)
                self.txt.config(state=NORMAL)
                self.txt.insert(END, textSetial)
                self.txt.config(state=DISABLED)
            # print("Debug: thread_readSerial is running")

基本功能实现,但现在的上位机还是太单调了,接下来就是整活时间😋

3.3.2 整活

在最开始时,我们创建了一行菜单栏,接下来为其注入灵魂!

首先是这款上位机的重中之重 ”折线图“(注:当前版本的折线图数据非串口获取到到真实数据,仅做功能演示!!)

    def createTempWindow(self):
        """
        创建新的窗口
        """
        new_window = self.window
        new_window.title("温度折线图")
        new_window.geometry("720x480")
        # Button(new_window,
        #        text="This is new window").pack()

        # 创建一个容器, 没有画布时的背景
        frame = Frame(new_window, bg="#ffffff")
        frame.place(x=0, y=0, width=720, height=480)
        plt.rcParams['font.sans-serif'] = ['SimHei']  # 用来正常显示中文标签
        fig = plt.figure(figsize=(6以上是关于Python 从零开始制作自己的声音 - wave模块读写wav文件详解的主要内容,如果未能解决你的问题,请参考以下文章

串口助手Python从零开始制作温湿度串口上位机

java - 如何录制我的声音并将其制作成java文件?

急!!!!如何通过python制作一个简单的录音机,录制自己的声音采用8k采样,16位量化编码,观察其数值?

Python中的音频

转wave 文件解析

融云 Web 播放声音(AMR WAVE)