本文大部分内容纯属个人基于ALSA官网的”PCM Interface“的理解,如有理解错误的地方,欢迎邮件告诉我 :)

0. PCM

Pulse-code modulation(PCM)

将模拟信号表示为数字信号的一种方法,它是计算机,CD,数字电话以及其他数字音频设备对数字音频信号的标准表示法。在一个PCM流当中,模拟信号是按照一定间隔(采样周期)进行采集,每个采样点的采样值则被近似地量化为最接近的一个值(由采样深度决定)。

Linear pulse-code-modulation(LPCM)

PCM中的一种类型,这种类型的量化值与模拟信号强度(响度)是呈线性关系。

Non-linear pulse-code-modulation

LPCM中的一种类型PCM不同,它的量化值相对与模拟信号强度呈非线性关系。例如:u-law, A-law等。

一个PCM流的质量取决与两方面:

在ALSA的PCM接口当中,PCM泛指所有离散时间内的离散采样音频信号。

1. 概述

1.1 PCM设备的两种类型

PCM设备(内核level)可以简单地分为”输出(playback)”和”输入(capture)”两类。其中的方向是站在ALSA应用的角度而言,即:

1.2 PCM设备和ALSA应用的ring buffer

具体参见这里

声卡设备在硬件层面有一个ring buffer,同时在alsa的kernel space,也维护一个ring buffer:

如下图所示:

ALSA_Periods

作为alsa lib用户,我们只需要关心alsa-lib中的ring buffer。它由两个指针维护:

  1. 对于 playback:
    • 当前硬件正在读的sample (START)
    • 上一次应用程序写的sample (END)
  2. 对于 capture:
    • 当前硬件正在写的sample (END)
    • 上一次应用程序读的sample (START)

(参考Wiki-ring buffer)

1.2.1 ring buffer - 单位(period, frame, sample)

buffer的size可以通过ALSA library的API进行修改。如果buffer设的太大,那么一次数据的传输需要的延迟会增加。为了解决这个问题,ALSA将buffer分为一系列的period(在OSS/Free语境中称为fragment),然后以period为单位进行数据的传输。

因此,在buffer里有以下几个单位:

它们的关系如下图所示:

ALSA application buffer

1.2.2 ring buffer - 访问方式(interleaved, non-interleaved)

Ring buffer 有三种访问方式:

  1. Interleaved access

     C0 C1 C2 C3 C0 C1 C2 C3 ....
    
  2. Non-interleaved access

     C0 C0 C0 C0 ................ C1 C1 C1 C1 ............. C2 C2 C2 C2 ............... C3 C3 C3 C3 ...........   注意:对于这种访问模式,每个通道有单独的ring buffer!
    
  3. Complex access

具体参见 PCM Ring Buffer

2. ALSA 设备打开 和 数据传输

2.1 阻塞和非阻塞打开设备

PCM设备可以以阻塞或非阻塞两种模式打开:

  1. 打开设备时,如果该设备当前正在被其他应用使用:

    • 阻塞:则调用的进程会被阻塞
    • 非阻塞:立即返回 -EBUSY给调用进程
  2. 打开设备的mode也会影响到它的标准I/O数据传输。当应用调用PCM API对该设备进行操作(例如: playback/capture)如果该设备没被其他应用使用,但是ring buffer为满(对于playback)/空(对于capture):

    • 阻塞:则调用进程会被阻塞
    • 非阻塞:立即返回 -EAGAIN给调用进程

    该阻塞模式模式可以通过snd_pcm_nonblock函数改变。

2.2 数据传输

2.2.1 读写传输(read/write)

可以用于传输interleaved/non-interleaved的数据,并且有阻塞和非阻塞两种模式

2.2.2 直接读写传输(mmap)

可以用于传输interleaved/non-interleaved/complex的数据。应用程序的调用顺序如下:

注意:

  1. 比较早的ALSA版本中,如果当前设备处于resample状态,则snd_pcm_wait可能break,具体请参考这里

2.2.2.1 snd_pcm_avail_update vs snd_pcm_avail

2.2.2.2 snd_pcm_mmap_begin()

这个API的返回参数的物理意义会根据不同的access type而不同,如下图所示:

mmap memory diagram

注意:这里的 offset 的单位实际上是 areas[x].step的个数。

因此,想要得到某个channel的当前period起始地址的话,可以使用类似如下的代码,适用于两种access type:

unsigned char* getMmapBeginAddress(const snd_pcm_channel_area_t area, snd_pcm_uframes_t offset)
{
    return (unsigned char*)(area.addr) + offset*(area.step/8) + area.first/8;
}

在得到了起始地址之后,如果要往里面填数据(例如填“0”)的话不能直接使用memset之类的API直接操作,而是要根据不同的access type来操作。这里提供一段片段,应该适用于两种access type:

// pcm_mmap_begin(&areas, &offset, &frames);

unsigned int byte_per_step = areas[0].step / 8;
 
for (unsigned i = 0; i != n_channel; ++i)
{
    unsigned char* start_address = getMmapBeginAddress(areas[i], offset);

    for (unsigned int j = 0; j != frames; ++j)
    {
        memset(start_address + (byte_per_step * j), 0, (m_pALSACfg->bitsPerSample)/8);
    }
}

// pcm_mmap_commit(offset, frames);

3. ALSA应用能看到的PCM设备状态迁移图

调用snd_pcm_state可以获取当前PCM设备的状态

SND_PCM_STATE_OPEN

表示PCM设备处于打开状态。

进入原因:

  1. snd_pcm_open调用成功
  2. snd_pcm_hw_params调用失败,目的是强制ALSA应用设置正确的硬件参数

SND_PCM_STATE_SETUP

表示PCM设备已经被正确地设置了硬件参数。此时,它正在等到snd_pcm_prepare来使设备对设置的操作(playback/capture)做准备。

进入原因:

  1. snd_pcm_hw_params调用成功
  2. snd_pcm_drop

SND_PCM_STATE_PREPARED

表示设备已经就绪。此时,ALSA应用可以调用snd_pcm_start,读或者写来进行操作。

进入原因:

  1. snd_pcm_prepare调用成功

SND_PCM_STATE_RUNNING

表示设备正在操作,即正在处理采样的数据。这个过程可以被snd_pcm_dropsnd_pcm_drain停止。

进入原因:

  1. snd_pcm_start调用成功
  2. 操作为playback,并且ALSA应用的frame超过了软件参数中设置的start threshold
  3. 操作为capture,并且ALSA应用要求读的frame超过了软件参数中设置的start threshold

SND_PCM_STATE_XRUN

表示设备overrun(capture)或者underrun(playback). 其中,前者表示ALSA应用没有及时将PCM设备的ring buffer中的数据读走,导致ring buffer满了;后者表示ALSA应用没有及时往PCM设备ring buffer里传数据,导致ring buffer空了。

进入这种情况后,建议调用snd_pcm_recover来恢复,也可以通过snd_pcm_prepare/snd_pcm_drop/snd_pcm_drain来离开此状态

进入原因:

  1. overrun/underrun发生
  2. PCM设备buffer中的frame数小于stop threshold

SND_PCM_STATE_DRAINING

表示capture设备正在等待ALSA应用将ring buffer中的数据读走。

对于playback模式的设备调用了snd_pcm_drain是不会进入这个状态的。

进入原因:

  1. 设备为capture模式,并且调用了snd_pcm_drain

SND_PCM_STATE_PAUSED

表示支持pause(可以通过snd_pcm_hw_params_can_pause来确定)的设备处于停止状态。

进入原因:

  1. 支持pause的设备调用snd_pcm_pause

SND_PCM_STATE_SUSPENDED

表示由于电源管理系统,使设备进入一种挂起状态。

对于支持resume的设备,建议调用snd_pcm_resume来离开该状态,直接进入SND_PCM_STATE_PREPARE状态(可以调用snd_pcm_hw_params_can_resume来确定)。对于不支持resume的设备,可以调用snd_pcm_prepare/snd_pcm_drop/snd_pcm_drain来离开该状态。

进入原因:

  1. 设备由于电源管理系统而进入该状态

SND_PCM_STATE_DISCONNECTED

表示设备不再连接系统,该状态下不再接受任何I/O调用。

不完整的状态迁移图

ALSA PCM stream FSM

4. 错误码

  1. -EPIPE 表示设备处于 xrun (underrun for playback or overrun for capture).

  2. -ESTRPIPE 表示设备处于 suspended. 这时候需要循环调用snd_pcm_resume() 直到它返回非-EAGAIN的error( setup )或者0 ( prepare ).

  3. -EBADFD 表示设备处于一个错误的状态,这意味着应用和alsa lib之间的握手协议已经错乱了.

  4. -ENOTTY, -ENODEV 表示设备已经被物理地移除了.

5. HW/SW 参数

5.1 HW 参数

ALSA PCM设备对于硬件参数,使用了一种”逐步限制”的机制(refinement system) - snd_pcm_hw_params_t. 应用程序在一开始设定所有支持的HW参数空间,然后通过设置其中的某几个参数为定值,直到其他参数都被限定为止。最后,将设定好了的参数空间install给该设备(snd_pcm_hw_params)。之后,HW参数就不得再改变了。

5.1.1 API

对于某个HW参数,会有以下几种形式的API可供使用:

5.1.2 rate/buffer/period的单位

5.1.3 periods vs buffer size vs buffer time

  1. buffer size/time指定了ring buffer的大小,它一定是period size/time的整数倍;
  2. periods实际也是用于指定buffer的大小的.

额外地,如果指定了periods而没有指定buffer size,则实际的buffer size将会位于:[periods*period_size, buffer_size max]

5.1.4 access type

  1. 选择不同的传递API(r/w or direct), 记得使用相应的access type, 否则会返回-EBADF
  2. 不是所有设备都满足NONINTERLEAVED/INTERLEAVED,例如我的声卡只支持INTERLEAVED,因此最好在设置前用test API判断下

5.1.5 format

  1. 物理长度和有效长度的关系 在使用read/write API的时候对于buffer的读写要使用物理长度
  2. LE 和 BE
  3. 浮点数类型可以通过与int一起作为一个Union来简化多字节的操作

5.2 SW 参数

软件参数可以在任意时刻修改,包括RUNNING状态。

5.2.1 avail_min

默认值一般 == period size, 用于snd_pcm_wait, 当available的值大于avail_min时,该函数返回。

5.2.2 start threshold

PCM设备状态自动进入RUNNING的事件。注意:

  1. start_threshold默认值为 1 frame
  2. 只对readi/writei有效,对于 direct access 无效

具体地,

如果你希望手动地使Playback PCM设备进入RUNNING状态(snd_pcm_start), 则可以将该值设的比buffer size更大(e.g. MAX_INT),可是要注意,如果ring buffer中没有数据或者数据不多,则手动start可能使设备进入 XRUN 状态!

5.3 典型配置项

  1. 打开设备,确定设备类型(capture/playback)
  2. 设置硬件参数:
    1. 分配参数空间内存
    2. 获取最大参数空间
    3. 判断并设置access type
    4. 设置数据类型:
      1. format
      2. channel (near)
      3. rate (near)
    5. 设置硬件中断频率: period_size/period_time(near). 典型做法为:设置period_time, 然后由它来获取period_size,以备用
    6. 设置ring buffer大小: buffer_size/buffer_time/periods(near). 典型做法为:设置buffer_time/periods, 然后由它来获取buffer_size,以备用
    7. 设置
    8. (可选)dump hw info
    9. 释放分配的参数空间内存
  3. 设置软件参数:
    1. 分配参数空间内存
    2. 获取当前软件参数空间
    3. (可选)设置start threshold (默认为0 frame)
    4. (可选)设置avail_min (默认为 1 period size)
    5. (可选)设置period event
    6. 设置
    7. (可选)dump sw info
    8. 释放分配的参数空间内存

6. STREAM状态

这里的流的状态(status)不同于上面所提到PCM设备的状态(snd_pcm_state), 通过snd_pcm_status_get_state返回的snd_pcm_status_t结构体记录。其中包括:

  1. timestamp of trigger: snd_pcm_status_get_trigger_tstamp()
  2. timestamp of last pointer update: snd_pcm_status_get_tstamp()
  3. delay(sample): snd_pcm_status_get_delay()
  4. available count(sample): snd_pcm_status_get_avail()
  5. maximum available samples: snd_pcm_status_get_avail_max()
  6. ADC over-range count(sample): snd_pcm_status_get_overrange()

6.1 获得stream状态,更新r/w指针

7. STREAM同步

8. PCM 命名规范

9. 配置ALSA Device

9.1 物理声卡设备

ALSA世界中,物理的声卡设备由card, device, subdevice指定。

9.2 ALSA设备

和上面提到的物理声卡设备不同,ALSA设备由字符串来指定,它们被定义在ALSA配置文件中(通常为: /usr/share/alsa/alsa.conf, 并在其中load额外的配置文件)。ALSA设备通常是plugin的封装(各种plugin也定义在在配置文件中)。

例如, /usr/share/alsa/alsa.conf 中,定义了ALSA设备”hw”, 它使用了Plugin:hw: (比较奇怪吧…看下面的注释)

9.3 ALSA plugin

ALSA提供了各种功能的plugin。以下记录了其中的某几个容易混淆的概念。在探索细节之前,先了解一下plugin的工作模式,如下所示:

plugin-basic

其中,slave 也可能是另一个plug。

9.3.1 plug

这个plugin可以用于自动转换各种硬件参数,包括: format, channels, rate. 使用plug插件的ALSA设备定义格式如下:

pcm.name {
        type plug               # Automatic conversion PCM
        slave STR               # Slave name
        # or
        slave {                 # Slave definition
                pcm STR         # Slave PCM name
                # or
                pcm { }         # Slave PCM definition
                [format STR]    # Slave format (default nearest) or "unchanged"
                [channels INT]  # Slave channels (default nearest) or "unchanged"
                [rate INT]      # Slave rate (default nearest) or "unchanged"
        }
        route_policy STR        # route policy for automatic ttable generation
                                # STR can be 'default', 'average', 'copy', 'duplicate'
                                # average: result is average of input channels
                                # copy: only first channels are copied to destination
                                # duplicate: duplicate first set of channels
                                # default: copy policy, except for mono capture - sum
        ttable {                # Transfer table (bi-dimensional compound of cchannels * schannels numbers)
                CCHANNEL {
                        SCHANNEL REAL   # route value (0.0 - 1.0)
                }
        }
        rate_converter STR      # type of rate converter
        # or
        rate_converter [ STR1 STR2 ... ]
                                # type of rate converter
                                # default value is taken from defaults.pcm.rate_converter
}

这里我的理解只停留在slave中的format, channels和rate这几个配置项。以format为例,假设,当前我要向一个playback设备,输入一个8KHz采样率,5秒长度的1KHz的正弦波,则该文件有8K * 5 = 40K个frame。我希望该设备以16KHz的采样率往外传输音频。此时,我需要一个plugplugin的设备,如下所示:

pcm.my_plug{
    type plug
    slave.pcm "hw:0,0"
    slave.rate 16000
}

这里的slave.rate 16000表示,my_plug的输出为16KHz的数据,作为slave,也就是"hw:0,0"的输入。这里有个先决条件是,"hw:0,0"在当前硬件参数空间中是支持16KHz的(可以用alsacap来测试)。

注意:在代码中打开该设备后,配置hw参数的时候设置的rate应该是与文件一致:8KHz (基本原则是:文件采样率是多少就应该以多少的采样率来读)

整个过程如下图所示:

plugin-plug

需要注意的是,采样率转换后的文件并不是完全符合上面的公式的,使用plug插件的采样率转换功能以后,开始的一小部分和结束的一小部分音频的数据会是0.例如,我有一个8000Hz采样率双通道16位采样深度的5秒正弦波,其大小是:160,000 Byte. 在转换成44.1KHz采样率以后,理论值是:160,000 / 8000 * 441000 = 882,000 Byte. 而实际值是884,736 Byte. 通过audacity分析实际音频文件,发现开始的3ms和结束的12ms的数据都是0,整个文件的长度变为了5.015秒。多出的15ms的数据大小 = 0.015 * 44100 * 2 * 2 = 2646 byte ,约等于比理想状态多出来的部分了。

反过来,对于capture设备,设备中的rate代表采样的频率,设置设备hw参数的代码中配置的rate代表文件的采样率。

9.3.2 file

这个plugin可以用于将写入对应的alsa设备的数据截取出来。要注意的是:这里截取的数据是读出或写入设备的数据,不等同于实际按时间顺序传给ALSA的数据。例如,我们在一个playback设备中配置了 file plugin ,即使我们在写入数据的过程中发生了underrun(即两次写入之间有间隙),截取出来的数据依然是连续的。所以,如果发生了声音卡顿等现象,做分段分析的时候,不能通过这种方式来排除ALSA前端应用的嫌疑。

10. 实践

10.1 从Chrome浏览器中下载音乐

基本思想是:

  1. 如果Chrome直接通过ALSA接口播放,则有两种情况:
    1. 打开defaultALSA设备进行播放,需要做:
      1. /usr/share/alsa/pulse-alsa.conf重命名,因为它会将defaultALSA设备设置为使用Plugin: pulse的设备;
      2. ~/.asoundrc加入:

         pcm.!default{
             type file
             slave {
                 pcm {
                     type hw
                     card 0
                     device 0
                 }
             }
             file "/tmp/a.wav"
         }
        
    2. 打开预定义的ALSA设备:hw(例如: hw:0,0):
      1. /usr/share/alsa/alsa.conf中,修改pcm.hwALSA设备的定义。将以下部分:

           type hw
           card $CARD
           device $DEV
           subdevice $SUBDEV
           hint {
               show {
                   @func refer
                   name defaults.namehint.extended
               }
               description "Direct hardware device without any conversions"
           }
        

        修改为:

           type file              # 使用file plugin来代替本来的hw plugin
           slave.pcm {
               type hw
               card 0
               device 0
           }
           file "/tmp/a.wav"
        
  2. 如果Chrome使用的是PulseAudio接口,那么需要做别的处理.

于是,我进行了如下步骤:

  1. 判断浏览器是否使用pulseaudio播放还是使用ALSA接口播放
    1. 暂停PulseAudio(关机前记得回复):

       $ echo "autospawn = no" > ~/.pulse/client.conf
       $ pactl exit
      
    2. 打开Chrome,用网易云音乐播放歌曲,果然无法播放了. 因此,Chrome使用的是PulseAudio接口。

  2. 修改/usr/share/alsa/中的Plugin: pulse定义:

     #pcm.pulse {
     #    type pulse
     #    hint {
     #        show on
     #        description "PulseAudio Sound Server"
     #    }
     #}
    
     pcm.pulse {
         type file
         slave {
             pcm "hw:0,1"
         }
         file "/tmp/c.wav"
     }
    
  3. 然而,并无任何ruan用。。。未完待续。

EDIT ON 2019-05-22

根据这个回答,通过以下指令即可录音:

# 先查看一下哪个sink在工作

$ pacmd # 使用pacmd的原因是它的list可以看到状态
>>> list-sink-inputs

# 找到状态为RUNNING的那个sink input,记录它的index

>>> exit

# 创建一个新的sink
$ pactl load-module module-null-sink sink_name=steam
# 将当前的sink-input输出到这个新创建的sink,而不是speaker对应的sink
$ pactl move-sink-input $INDEX steam
# 输出这个sink的数据(raw pcm)到lame,转成mp3
$ parec -d steam.monitor | lame -r -s 44.1 --bitwidth 16 --signed --little-endian - output.mp3

引用

[1] PCM interface

[2] Introduction to Sound Programming with ALSA

[3] PCM Ring Buffer

[4] A close look at ALSA

[5] Linux AlSA sound notes