You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

树莓派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声卡的占用状态。
具体到现有代码,存在三个核心问题:

  1. 在主线程修改sd.default.device全局配置,但实际音频流的创建、播放、停止操作都在子线程执行,PortAudio的上下文在两个线程间没有做同步,第一次调用sd.play创建的流在sd.stop后没有被正确销毁,残留的流指针直接占住了ALSA的音频设备句柄。
  2. 没有维护流的状态标记,重复调用sd.play会创建新的流,但旧的流没有被关闭,多次启停后流指针错乱,就会出现命令执行顺序错位、线程无法正常退出的问题。
  3. sd.stop()是全局停止所有流的接口,调用后不会等待流完全关闭就返回,在性能较弱的Pi Zero上,流资源释放的延迟会导致下一次播放调用拿到无效的设备句柄。

修复步骤

  1. 所有sounddevice的流操作全部放到同一个线程里执行,不要跨线程调用任何sd.play/sd.stop接口。现有队列传命令的思路是可行的,只要保证所有音频API调用都在DialTone线程内即可,不要在主线程修改任何sounddevice相关配置。
  2. 自行维护播放状态和流对象,不要用全局的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
    
  3. 修复后如果还是出现进程退出后音频占用的问题,可以在程序退出前主动调用sd._terminate()强制关闭PortAudio上下文,释放所有ALSA句柄。
  4. 额外排查点:确认树莓派上的PortAudio版本为最新版,19.6以下版本的PortAudio对USB声卡的多线程支持存在已知bug,可执行sudo apt install --reinstall portaudio19-dev python3-sounddevice升级到仓库最新版。

内容的提问来源于stack exchange,提问作者Will

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.29 10:12:21