Skip to content

Sample channel shift (L/R swap) when rapidly appending stereo sources to an empty player queue #871

@axel10

Description

@axel10

Initial State: When a Player is initialized, its internal queue defaults to an Empty source which reports channels() == 1.

Initialization: When the player is appended to the Mixer, it is wrapped in a UniformSourceIterator. This iterator caches the initial metadata and prepares a ChannelCountConverter(from=1, to=2) along with a strict Take(1) reading window for its very first frame block.

The Race Condition: If the developer decodes the audio file before creating the player, calling player.append(stereo_source) occurs practically instantly after connection.

The Glitch:

The background audio thread pulls its very first sample through the Take(1) window.

This "pops" the newly appended stereo_source but only reads exactly 1 sample (the Left channel).

It treats this as a mono sample, redundantly duplicating it to both Left and Right outputs.

Desynchronization: Once this Take(1) block is exhausted, the iterator re-evaluates the queue, identifies the source as stereo (channels() == 2), and switches to stereo-to-stereo passthrough.

The Result: Because exactly one sample of the interleaved buffer is now missing, the entire remainder of the stream is desynchronized. The Right sample is played on the Left speaker, and the next Left sample is played on the Right speaker, resulting in a permanent L/R swap.

bug demo:

use rodio::{Decoder, DeviceSinkBuilder, Player};
use std::fs::File;

let handle = DeviceSinkBuilder::open_default_sink().expect("open default audio stream");

let file = File::open("stereo_music.m4a").unwrap();
let source = Decoder::try_from(file).unwrap();

let player = Player::connect_new(&handle.mixer());

player.append(source);

player.sleep_until_end();

The Fix
Changed the default channel count in src/source/empty.rs from 1 to 2.

By defaulting to a stereo (2-channel) dummy frame for sequence initialization, UniformSourceIterator safely allocates Take(2) buffering frames when idle.

When a synchronous append() occurs, it now cleanly maps both the L and R channels of the fresh frame before re-evaluating metadata, successfully blocking the irreversible 1-sample desync trap.

    fn channels(&self) -> ChannelCount {
    // nz!(1)
        nz!(2) // Default to 2 (stereo) to prevent a 1-sample channel shifting bug in the Queue when swapping to stereo sources.
    }

当 Player 初始化时,其内部队列默认为一个 Empty(空)源,该源报告的声道数 channels() == 1。当播放器被追加到 Mixer 时,它被包装在一个 UniformSourceIterator 中。该迭代器会缓存初始元数据,并为第一个帧块准备一个 ChannelCountConverter(from=1, to=2) 以及一个严格的 Take(1)(取 1 个采样)读取窗口。

如果开发者在创建播放器 之前 就完成了音频解码,那么在连接后调用 player.append(stereo_source) 几乎是瞬间发生的。后台音频线程通过 Take(1) 窗口拉取其第一个采样,这正好触发了新追加的 stereo_source。

具体过程如下:

它读取了正好 1 个采样(立体声文件的 左 声道),并将其视为单声道采样,冗余地复制到左、右输出。

一旦这个 Take(1) 块耗尽,迭代器会重新评估队列,正确识别出源为立体声(channels() == 2),并切换到立体声到立体声的直通模式。

由于交织缓冲区(interleaved buffer)中正好丢失了 一个 采样,导致流的剩余部分全部产生 1 个采样的脱节。

结果是:右 采样被播放到了 左 扬声器,而 左 采样被播放到了 右 扬声器,持续不断。

修复方案
将 src/source/empty.rs 中的默认声道数从 1 修改为 2。通过在空闲时默认使用立体声(2 声道)哑帧(dummy frame)进行序列初始化,UniformSourceIterator 在空闲时会安全地分配 Take(2) 缓冲帧。当同步发生快速 append() 时,它能在重新评估元数据之前,完整且干净地映射新帧的 左(L) 和 右(R) 声道,从而成功规避不可逆的 1 采样脱节陷阱。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions