树莓派Zero W使用sounddevice播放音频仅生效一次需重启
问题背景
我受旋转电话改造谷歌助手项目启发,计划将Raspberry Pi Zero WH装入老式旋转电话,使用Python开发所需功能,首个基础功能为拿起听筒时播放拨号音。
但使用sounddevice模块播放音频时出现故障:音频仅首次播放正常,之后系统音频功能完全失效,即便使用aplay、speaker-test等系统工具也无法输出音频,必须重启设备才能恢复,初步推测是播放结束后相关资源未正确关闭释放。
硬件配置
- Raspberry Pi Zero WH
- USB Audio Adapter
我编写了继承Thread的DialTone类,可作为线程被核心应用调用,通过队列消息控制拨号音按需启停,代码末尾编写了main函数用于功能测试。该代码在Windows系统下可反复正常运行,但在树莓派上仅首次运行生效;且未运行Python音频程序时,直接使用aplay播放同一音频文件可反复正常无异常。
核心疑问:
- 是否是程序锁定了音频资源未释放?
- 为何进程退出后资源仍处于占用状态?
- 应当从哪些方向排查定位问题?
问题代码
class DialTone(Thread): def __init__(self, input_queue:Queue, device:int_or_str ) -> None: """Initialize the dialtone class Args: input_queue (Queue): A way to communicate with the thread """ super().__init__() global _DIAL_TONE_FILE self.input_queue = input_queue sd.default.device = device # Commit the dial-tone data to memory so we can start/end quickly try: self.data, self.fs = sf.read(_DIAL_TONE_FILE, always_2d=True) except Exception as e: logging.debug( type(e).__name__ + ":" + str(e) ) def run(self) -> None: """Play a dial tone in a continuous loop until we die """ while( True ): cmd = self.input_queue.get(True) if( cmd == DialToneComms.DT_START ): logging.debug("DT: starting dial tone") sd.play(self.data, self.fs, loop=True) logging.debug(sd.get_status()) elif( cmd == DialToneComms.DT_END ): logging.debug("DT: stopping dial tone") try: sd.stop(ignore_errors=False) except Exception as e: logging.error( type(e).__name__ + ":" + str(e) ) logging.debug(sd.get_status()) elif( cmd == DialToneComms.DT_KILL ): logging.debug("DT: killing self") try: sd.stop(ignore_errors=False) except Exception as e: logging.error( type(e).__name__ + ":" + str(e) ) logging.debug(sd.get_status()) break # Test this capability if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--device",type=int_or_str,help="Audio device to use", default=1) parser.add_argument("--play_time",type=float,help="how long to play the dial tone",default=10.0) parser.add_argument("--num_cycles", type=int, help="Number of play, wait cycles", default=2) args = parser.parse_args() # Setup logging logging.basicConfig(level=logging.DEBUG) dial_tone_queue = Queue() print( "Instantiating DialTone" ) dial_tone = DialTone(dial_tone_queue, args.device) print( "Starting DialTone Thread" ) dial_tone.start() # Play the dial tone a number of iterations for _ in range(args.num_cycles): print( "Playing dialtone for {} sec".format(args.play_time)) dial_tone_queue.put(DialToneComms.DT_START) time.sleep(args.play_time) dial_tone_queue.put(DialToneComms.DT_END) print("Waiting 2 seconds") time.sleep(2) print("Kill the thread") dial_tone_queue.put(DialToneComms.DT_KILL) print("Joining DialTone") dial_tone.join(_JOIN_WAIT_TIME) if( dial_tone.is_alive() ): logging.warning( "Dial tone thread is still alive! What gives?" ) else: logging.warning( "Dial tone thread is nice and dead")
运行输出对比
树莓派首次运行输出
(底部错误为停止未播放流导致:stop调用已终止流,kill调用再次执行停止触发报错)
Instantiating DialTone Starting DialTone Thread Playing dialtone for 3.0 sec DEBUG:root:DT: starting dial tone DEBUG:root: Waiting 2 seconds DEBUG:root:DT: stopping dial tone DEBUG:root: Playing dialtone for 3.0 sec DEBUG:root:DT: starting dial tone DEBUG:root: Waiting 2 seconds DEBUG:root:DT: stopping dial tone DEBUG:root: Playing dialtone for 3.0 sec DEBUG:root:DT: starting dial tone DEBUG:root: Waiting 2 seconds DEBUG:root:DT: stopping dial tone DEBUG:root: Kill the thread Joining DialTone DEBUG:root:DT: killing self ERROR:root:PortAudioError:Error stopping stream: Invalid stream pointer [PaErrorCode -9988] DEBUG:root: WARNING:root:Dial tone thread is nice and dead
树莓派第二次运行输出
(注意stop后指令执行顺序错乱,第二次运行无音频输出)
Instantiating DialTone Starting DialTone Thread Playing dialtone for 3.0 sec DEBUG:root:DT: starting dial tone DEBUG:root: Waiting 2 seconds DEBUG:root:DT: stopping dial tone Playing dialtone for 3.0 sec Waiting 2 seconds Playing dialtone for 3.0 sec Waiting 2 seconds Kill the thread Joining DialTone DEBUG:root: DEBUG:root:DT: starting dial tone DEBUG:root: DEBUG:root:DT: stopping dial tone WARNING:root:Dial tone thread is still alive! What gives? DEBUG:root: DEBUG:root:DT: starting dial tone DEBUG:root: DEBUG:root:DT: stopping dial tone DEBUG:root: DEBUG:root:DT: killing self ERROR:root:PortAudioError:Error stopping stream: Invalid stream pointer [PaErrorCode -9988] DEBUG:root:
Windows环境运行输出(运行正常)
Instantiating DialTone Starting DialTone Thread Playing dialtone for 3.0 sec DEBUG:root:DT: starting dial tone DEBUG:root: Waiting 2 seconds DEBUG:root:DT: stopping dial tone DEBUG:root: Playing dialtone for 3.0 sec DEBUG:root:DT: starting dial tone DEBUG:root: DEBUG:root:DT: stopping dial tone Waiting 2 seconds DEBUG:root: Playing dialtone for 3.0 sec DEBUG:root:DT: starting dial tone DEBUG:root: Waiting 2 seconds DEBUG:root:DT: stopping dial tone DEBUG:root: Kill the thread DEBUG:root:DT: killing self Joining DialTone ERROR:root:PortAudioError:Error stopping stream: Invalid stream pointer [PaErrorCode -9988] DEBUG:root: WARNING:root:Dial tone thread is nice and dead
问题原因与解决方案
这个问题本质是sounddevice依赖的PortAudio后端在ARM架构Linux系统上,跨线程调用流操作接口时会出现资源锁死、流指针失效的问题。Windows下PortAudio对多线程的容错性更好所以不会触发,而树莓派的ALSA音频子系统一旦被异常持有,就会锁死整个音频栈,哪怕进程退出也不会自动重置USB声卡的占用状态。
具体到现有代码,存在三个核心问题:
- 在主线程修改
sd.default.device全局配置,但实际音频流的创建、播放、停止操作都在子线程执行,PortAudio的上下文在两个线程间没有做同步,第一次调用sd.play创建的流在sd.stop后没有被正确销毁,残留的流指针直接占住了ALSA的音频设备句柄。 - 没有维护流的状态标记,重复调用
sd.play会创建新的流,但旧的流没有被关闭,多次启停后流指针错乱,就会出现命令执行顺序错位、线程无法正常退出的问题。 sd.stop()是全局停止所有流的接口,调用后不会等待流完全关闭就返回,在性能较弱的Pi Zero上,流资源释放的延迟会导致下一次播放调用拿到无效的设备句柄。
修复步骤
- 所有sounddevice的流操作全部放到同一个线程里执行,不要跨线程调用任何
sd.play/sd.stop接口。现有队列传命令的思路是可行的,只要保证所有音频API调用都在DialTone线程内即可,不要在主线程修改任何sounddevice相关配置。 - 自行维护播放状态和流对象,不要用全局的
sd.play/sd.stop,改用显式创建sd.OutputStream的方式,自己持有流实例,启停的时候直接操作这个实例,停止后主动调用close()释放资源,参考修改片段:def __init__(self, input_queue:Queue, device:int_or_str ) -> None: super().__init__() self.input_queue = input_queue self.device = device self.stream = None self.data, self.fs = sf.read(_DIAL_TONE_FILE, always_2d=True) self.is_playing = False def run(self) -> None: # 所有sounddevice配置都在线程内初始化 sd.default.device = self.device while True: cmd = self.input_queue.get(True) if cmd == DialToneComms.DT_START and not self.is_playing: # 显式创建输出流,循环填充音频数据 self.stream = sd.OutputStream( samplerate=self.fs, device=self.device, channels=self.data.shape[1], callback=lambda outdata, frames, time, status: outdata[:] = self.data[:frames] ) self.stream.start() self.is_playing = True elif cmd == DialToneComms.DT_END and self.is_playing: self.stream.stop() self.stream.close() # 主动释放流资源 self.stream = None self.is_playing = False elif cmd == DialToneComms.DT_KILL: if self.is_playing: self.stream.stop() self.stream.close() break - 修复后如果还是出现进程退出后音频占用的问题,可以在程序退出前主动调用
sd._terminate()强制关闭PortAudio上下文,释放所有ALSA句柄。 - 额外排查点:确认树莓派上的PortAudio版本为最新版,19.6以下版本的PortAudio对USB声卡的多线程支持存在已知bug,可执行
sudo apt install --reinstall portaudio19-dev python3-sounddevice升级到仓库最新版。
内容的提问来源于stack exchange,提问作者Will
相关产品推荐
相关产品推荐

