/mp3_player_linux_C

一个在 linux 上用 C 语言实现的 mp3 播放器(命令行)

Primary LanguageC

C 语言项目案例

MP3 播放器(命令行,Linux)

简单实现一个运行于Linux终端的 mp3 文件播放器.

在查看了多个 linux 相关的音频库api 和 cmus 的代码,以及群友的讨论之后,得出结论,多 媒体相关的项目是有门槛的,学习这些项目的前期内容多是音视频领域的知识,不了解的话连 库的 API 里的参数都不知道干什么用的;作为初学 C 语言的入门项目并不合适.这个项目目 前应该不会再往 cmus 的样式开发了,会作为一个简单的练习项目,接下来会实现一个菜单以 及暂停快进等功能,然后就会暂停开发了.

音频电流是连续变化的,和声波振动相对应.振动位移也是连续变化的,不可能从一个位置跳 跃到另一个位置, 对于声音的这种连续变化的波形信息,是无法完整记录下来的,因此,用计 算机记录声音时,必须对连续的声音进行一定的处理,处理方法就是采样和量化:

采样
在特定的时刻对模拟信号进行测量记录就叫做采样,采样只保留了声音的部分信 息,所以会损失声音的一些信息. 但是只要采样的点足够多,基本上就可以反映声音的原始 信息,损失的是极短时间内发生的一些变化,即很高的频率成分信息.
量化
采样得到的值的幅度可以是无穷多实数中的一个,这些值要用二进制表示,就必须 为每个值分配一个编码,显然,对无穷多个值分配编码是不可能的.如果把信号的幅度取值 的数目加以限制,量化后得到的值只能取有限个参考值,当实际值不在该范围内时,可以进 行舍入,比如限定取值在 0,0.1,0.2,0.3,…0.7,这些参考值,而实际采集到的值为 0.123V ,就把它算作是 0.1V, 这样,需要编码的值就大大减少了.这种处理就叫做量化.

经过上面两步,声音数据就成了可以被计算机处理的值了,这些值还要经过下面步骤才能称为 我们使用的音频文件:

编码
计算机是用编码的形式来表示符号和数值的,量化后得到的每个数据值,都可以用 一个分配的编码表示,这种将量化的值表示为不同编码的过程,就叫做编码.
压缩编码
经过编码后,每次采集得到的样本数据要保存,都需要大量的储存空间.事实 上,声音数据里,有很多冗余的数据,一方面,声音的变化是连续的,前后时刻的采集点间差 别并不大,因此没有必要完整记录每个样本的值.可以记录一个样本的值后,在记录下它与 后一个样本值之间的差值,因为差值较小,可以用较小的采样精度(量化位数–量化时参考 值的个数)表示,就减少了储存空间的开销.另一方面,不是所有的声音都会被人耳所感知, 人耳对同一时间发出的多种声音,通常并不能完全听到,总有比较 “重要” 的声音会盖过其 他声音,因此,我们可以将声音分为多个频段,对于这些人耳不”敏感”的声音,采用较小的采 样精度,这样也能大大减少储存空间的开销.

经过采集,量化,编码,压缩后的声音数据就是能反映原始声音信息的数据了,它是计算机可处 理的数据,不过需要将这些数据按一定方式进行组织形成文件之后才方便数据的保存和传输.

根据采样频率,量化位数,编码方式,压缩算法,文件组织方式等分为多种音频文件格式,我要 播放的就是 MP3 格式的音频文件. 因为最开始是学习的 https://github.com/Mapc1/Mp3-cli 该开源项目,所以使用的是 PulseAudio 和 mpg123 软 件提供的 API 进行功能的实现,它们一个提供应用程序和硬件的交互接口,一个负责将音频 文件解压缩,解码成硬件可处理的字节数据. PulseAudio 负责将数据流传输到硬件设备实现 播放音频数据, mpg123 负责将音频文件解码成硬件设备可读取的数据流,完成解码操作. 其 实也有其他软件可以实现,比如 ffmpeg 同时提供了解码和播放操作的API, 使用它就不需要 使用两个软件的 API 了; 不过 ffmpeg 需要额外安装,而 PulseAudio 大部分 Linux 发行 版都自带了,事实上, PulseAudio 的依赖库中有一个 libsndfile 库,它是用来提供音频文 件读写的,它将文件的解码操作隐藏到程序 API 中了, mpg123 则是一个音频文件解码和播 放的库,它应该是需要额外安装的,所以在用 mpg123 实现功能后,会考虑用 libsndfile 再 次实现播放功能.

PulseAudio 是一个 POSIX 操作系统上的音频服务器系统,意味着它是你的声音应用程序的 代理. 当音响数据在应用程序和硬件之间传递时,它对音响数据执行一些高级操作. 该程序 是所有相关的现代 Linux 发行版本的不可缺少的一部分,并且被多家供应商用于各种移动设 备.像是传输音频到不同的机器,改变歌曲格式或通道数,有或是将几种音频数据混合作为一 个输入/输出等都可以通过 PulseAudio 轻松完成.

我们先暂时只考虑实现最基本的播放 mp3 文件的功能,其他的以后再论.使用该系统开发自 己的音频播放程序可以阅读其开发者文档. 该文档给出了使用它开发客户端应用程序的 API 文档,因为我们现在只需要实现基本的播放功能,所以只用到给出的 Simple API.

Simple API

简单API 是为那些具有基本播放或记录功能的应用程序设计的.它只能支持每个连接单个数 据流,并且不支持处理复杂的功能,比如事件,通道映射和音量控制. 然而,这对大多数的程序 来说已经足够了. 下面的代码示例都是在线文档中的例子,在线文档中这些例子里使用的结 构体原型,函数原型等都可点击查看,需要查看详情的可以访问在线文档: Simple API .

连接

使用音频系统的第一步就是连接到音频服务器,通常是这样做到:

pa_simple *s;
pa_sample_spec ss;

ss.format = PA_SAMPLE_S16NE;
ss.channels = 2;
ss.rate = 44100;

s = pa_simple_new(NULL,		// 使用默认服务器
		  "Fooapp",	// 我们应用程序的名字
		  PA_STREAM_PLAYBACK,
		  NULL,		// 使用默认设备
		  "Music",	// 数据流的描述
		  &ss,		// 采样格式
		  NULL,		// 使用默认的通道映射
		  NULL,		// 使用默认的缓冲区属性
		  NULL,		// 忽略错误代码
		  );

到了这一步,s 就是返回的一个已连接的对象,或是有连接错误则为返回的 NULL 值.

传输数据

一旦与服务器建立了连接,就可以开始传输数据了.使用连接和普通的系统调用 read() 和 write() 函数非常相似. 主要的不同是它们叫做 pa_simple_read() 和 pa_simple_write(). 请注意,这些操作都是阻塞式的. pa_simple_write() 的函数原型:

int pa_simple_write(pa_simple *s,
const void *data,
size_t bytes,
int *error );
  • 第一个参数是服务器连接对象的指针
  • 第二个参数是储存数据的变量的指针
  • 第三个数据是写入的数据的字节数
  • 第四个参数是发生错误时记录错误代码的指针

该函数成功时返回 0 ,错误时返回负值. pa_simple_read() 的原型与 pa_simple_write() 的基本一致,不过是从服务器连接中读取数据而已.

控制

pa_simple_get_latency()
会分别返回播放或记录通道的总延迟.
pa_simple_flush()
会丢弃当前在缓冲区里的所有数据.

如果当前正有一个数据流在使用中,则下面的操作是可用的:

pa_simple_drain()
会等待所有已发送的数据完成播放.

清理

一旦播放或记录完成,连接就应该被关闭,资源也应该被释放. 这通过下面代码完成:

pa_simple_free(s);

mpg123

mpg123 是用于播放和解码音频文件的库,其 API 文档可以在这里查阅 https://mpg123.de/api/ . 我们第一步只想实现打开一个 mp3 文件,并将其播放出来的功 能,因此只需要用到 文件输入和解码 模块提供的功能,主要使用的函数就是 mpg123_open_fixed(), 其函数原型为:

MPG123_EXPORT int mpg123_open_fixed 	(
	        mpg123_handle *  	mh,
		const char *  	path,
		int  	channels,
		int  	encoding 
	);
  • 第一个参数为 libmpg123 的解码器句柄的指针,是一个不透明结构体(不用知道定义,可以 直接用它定义变量使用),多数的函数都以它作为第一个参数,并且通过它来操作读取的数 据.
  • 第二个参数是要打开的音频文件的路径;
  • 第三个参数是频道数(双通道2,单通道1);
  • 第四个参数是编码格式;

该函数会按固定的属性打开音频文件,并将其转换为字节流数据,然后可以通过句柄指针 mh 访问字节流数据,这些字节流数据还并没有进行解码,还需要通过 mpg123_read() 函数来读 取字节流数据并进行解码,其函数原型:

MPG123_EXPORT int mpg123_read(
mpg123_handle *mh,
void *outmemory,
size_t outmemsize,
size_t *done );
  • 第一个参数是解码器句柄
  • 第二个参数是解码后的数据写入的变量的地址
  • 第三个参数是最大的写入字节数
  • 第四个参数是实际解码数量的存放地址

该函数可以从字节流中解码出最大 outmemsize 数量的数据,而实际的解码数存放在指针 done 储存的地址所代表的变量中.

CMUS

cmus 是一个2005年就开始的老项目了,是一个功能完善的 linux 终端音频播放器,完全用 C 语言实现,如果有用 C 语言开发音频软件的学习需求,它会是一个很好的学习材料,但有学习 门槛,需要知道音视频相关知识,操作系统的知识以及相关算法知识,比如代码里很明显的 producer 和 consumer 表示他们使用了生产者和消费者的算法机制,所以对非计算机专业的 C 语言初学者可能不是很适合.

这个开发了(2022-2005)年的项目至今仍在活跃开发中,阅读其源代码可以学到很多知识,经 过17年的开发,代码已经变得更复杂庞大,所以我从第一次提交的代码开始阅读: initial commit.

这个初始提交的 AUTHOTS 文件里可以看到主要作者是 Timo Hirvonen <tihirvon@ee.oulu.fi>, 但是软件的初始设计方案也是由他人的点子来的,所以不要想着能 一下子读懂它整个代码.

实现播放功能

首先需要从命令行接收文件名/文件路径参数:

/* mp3 player on linux */
#include <stdio.h>

int main(int argc, char *argv[])
    {
      if(argc <= 1)
	{
	  fprintf (stderr, "Error!\nThis program requires the path of the mp3 files as an argument!\n");
	}
      else {
	printf("%s\n",argv[1]);
      }
      return 0;
    }

然后就可以用 mpg123 库将音频文件转为字节流数据

/* mp3 player on linux */
#include <stdint.h>
#include <fmt123.h>
#include <stddef.h>
#include <stdio.h>
#include <mpg123.h>

#define BUFSIZE 1024

int main(int argc, char *argv[])
    {
      mpg123_handle * handle;
      /* uint8_t buf[BUFSIZE]; // */
      char buf[BUFSIZE]; //
      if(argc <= 1)
	{
	  fprintf (stderr, "Error!\nThis program requires the path of the mp3 files as an argument!\n");
	}
      else {
	printf("%s\n",argv[1]);
      }

      handle = mpg123_new(NULL, NULL);
      mpg123_open_fixed(handle, argv[1], 2, MPG123_ENC_SIGNED_16);

      size_t decoded = 1;
      /* 从 handle 里读取 bufsize 大小的数据到 buf 中 */
      mpg123_read(handle, buf, BUFSIZE, &decoded);

      /* 通过 mpg123 将音频文件的数据解码读取为字节数据并存入 buf 中,
      下面的代码就可以进行字节流数据的播放了 */

      return 0;
    }

到了这里,我们已经把音频数据转为字节流数据了,我们能用 handle 访问字节流数据,上面 的例子里利用 handle 从字节流数据里读取了 BUFSIZE 大小的数据到 buf 中,下面就可以 将数据发送到声音服务器进行播放了

/* mp3 player on linux */
#include <fmt123.h>
#include <mpg123.h>
#include <pulse/def.h>
#include <pulse/simple.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

#define BUFSIZE 1024

int main(int argc, char *argv[]) {
  mpg123_handle *handle;
  /* uint8_t buf[BUFSIZE]; // */
  char buf[BUFSIZE]; //
  if (argc <= 1) {
    fprintf(stderr, "Error!\nThis program requires the path of the mp3 files "
                    "as an argument!\n");
  } else {
    printf("%s\n", argv[1]);
  }

  handle = mpg123_new(NULL, NULL);
  mpg123_open_fixed(handle, argv[1], 2, MPG123_ENC_SIGNED_16);

  size_t decoded = 1;
  /* 从 handle 里读取 bufsize 大小的数据到 buf 中 */
  mpg123_read(handle, buf, BUFSIZE, &decoded);

  pa_simple *s;
  pa_sample_spec ss;

  ss.format = PA_SAMPLE_S16NE;
  ss.channels = 2;
  ss.rate = 44100;

  /* 与声音服务器建立连接 */
  s = pa_simple_new(NULL, "mp3-player", PA_STREAM_PLAYBACK, NULL, "Audio", &ss,
                    NULL, NULL, NULL);

  /* 播放缓冲区 buf 里的内容 */
  pa_simple_write(s, buf, decoded, NULL);

  /* 播放结束,释放资源 */
  pa_simple_free(s);
  return 0;
}

上面的代码编译后可以运行,但是实际上可能没有任何声音,因为我们只读取了字节流数据前 1024 字节的数据,数据太少了,而且这些字节流数据是由音频文件解压缩,解码之后得到的, 所以1024字节的数据可能连个响都听不到,我们可以把 BUFSIZE 设置为 1024000 即 1000kb 的数据大小,然后再运行一次,我用来测试的音频文件可以播放大概 5 秒,而这个音 频文件时长270秒,大小为 4.1MB, 按照这个比例换算下,解压解码后的字节流数据总共约 52.7MB 的大小,下面是修改 BUFSIZE 后的代码:

/* mp3 player on linux */
#include <fmt123.h>
#include <mpg123.h>
#include <pulse/def.h>
#include <pulse/simple.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

#define BUFSIZE 1024000
/* #define BUFSIZE 409600 */

int main(int argc, char *argv[]) {
  mpg123_handle *handle;
  /* uint8_t buf[BUFSIZE]; // */
  char buf[BUFSIZE]; //
  if (argc <= 1) {
    fprintf(stderr, "Error!\nThis program requires the path of the mp3 files "
                    "as an argument!\n");
  } else {
    printf("%s\n", argv[1]);
  }

  handle = mpg123_new(NULL, NULL);
  mpg123_open_fixed(handle, argv[1], 2, MPG123_ENC_SIGNED_16);

  size_t decoded = 1;
  /* 从 handle 里读取 bufsize 大小的数据到 buf 中 */
  mpg123_read(handle, buf, BUFSIZE, &decoded);

  /* 下面要与声音服务器建立连接,播放 buf 里的数据 */
  pa_simple *s;
  pa_sample_spec ss;

  ss.format = PA_SAMPLE_S16NE;
  ss.channels = 2;
  ss.rate = 44100;

  s = pa_simple_new(NULL, "mp3-player", PA_STREAM_PLAYBACK, NULL, "Audio", &ss,
                    NULL, NULL, NULL);

  pa_simple_write(s, buf, decoded, NULL);

  /* 播放完毕释放资源 */
  pa_simple_free(s);
  return 0;
}

代码成功运行后播放了大概 5 秒的音乐,这证明上面的代码可以运行,并能完成播放功能.接 着我们就要让程序能够完整播放一整首歌曲. 完成播放整首的功能关键在于 mpg123_read() 和 pa_simple_write() 函数,前者从解码器句柄里读取音频文件经过处理后的字节流数据, 后者将数据传送到硬件,上面我们只读取了 BUFSIZE 大小的数据,并进行了播放,想要完整播 放,只需不断读取和播放就行了, mpg123_read() 的第四个参数是用于储存实际解码的字节 数的参数的地址,就是说该函数会将实际解码的字节数储存到该地址里,只要文件没有读取到 结尾,该字节数都一定会是大于0的,所以可以根据它来判断音频文件是否播放到结尾了,我们 可以利用一个 while 循环,循环结束的判断条件就是 mpg123_read() 的第三个参数是否大 于0,修改后的代码:

/* mp3 player on linux */
#include <fmt123.h>
#include <mpg123.h>
#include <pulse/def.h>
#include <pulse/simple.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

#define BUFSIZE 1024
/* #define BUFSIZE 409600 */

int main(int argc, char *argv[]) {
  mpg123_handle *handle;
  /* uint8_t buf[BUFSIZE]; // */
  char buf[BUFSIZE]; //
  if (argc <= 1) {
    fprintf(stderr, "Error!\nThis program requires the path of the mp3 files "
                    "as an argument!\n");
  } else {
    printf("%s\n", argv[1]);
  }

  handle = mpg123_new(NULL, NULL);
  mpg123_open_fixed(handle, argv[1], 2, MPG123_ENC_SIGNED_16);

  size_t decoded = 1;
  /* 从 handle 里读取 bufsize 大小的数据到 buf 中 */
  mpg123_read(handle, buf, BUFSIZE, &decoded);

  pa_simple *s;
  pa_sample_spec ss;

  ss.format = PA_SAMPLE_S16NE;
  ss.channels = 2;
  ss.rate = 44100;

  s = pa_simple_new(NULL, "mp3-player", PA_STREAM_PLAYBACK, NULL, "Audio", &ss,
                    NULL, NULL, NULL);

  while (decoded > 0) {
    mpg123_read(handle, buf, BUFSIZE, &decoded);
    pa_simple_write(s, buf, decoded, NULL);
  }
  /* 播放完毕释放资源 */
  pa_simple_free(s);
  return 0;
}

TODO 上面代码已经实现了基本的播放功能,但如果我要添加更多功能,上面的代码就不能这样组织,必 须编写成函数的形式,我暂时的想法是播放音乐的函数最好是只用接收音频文件的文件路径/ 名,然后调用该函数就能播放该音频文件了,因为如果要扩展功能肯定不会再用命令行参数输 入音频文件的路径了,总是需要一个界面的,就像我用过的命令行音乐播放器 CMUS 一样的界 面.

将播放的代码写成一个函数

编译运行

用于编译上面最终代码的 Makefile:

play_test: play_test.o
	gcc -o play_test play_test.o -lmpg123 -lpulse -lpulse-simple
paly_test.o:play_test.c
	gcc play_text.c -o play_test.o
clean:
	rm play.o play

编译命令:

make play_test

播放测试文件 boo.mp3

./play_test boo.mp3