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 与实际写入的帧数不匹配时引发异常。
初步: 拼接音频
程序先将两段音频中的数据读入data1
和data2
中,再将读取的数据拼接,写入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
再次实现
上述程序生成的是方波,并有一些缺陷,如para
中0b0000000
和0b11111111
的长度是整数且相同,导致生成的声音频率不精确,等等。
这里合成一段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文件详解的主要内容,如果未能解决你的问题,请参考以下文章