基于M5StickC PLUS实现的网络电台收音机
本项目是基于M5StickC PLUS硬件平台搭建的网络电台收音机,通过WiFi模块连接网络解码电台的媒体流,最后通过外部的扬声器模块将对应的媒体播放出来。
标签
嵌入式系统
ESP32
M5StickC Plus
网络电台收音机
maskmoo
更新2022-09-06
842

1项目介绍

本项目是基于M5StickC PLUS硬件平台搭建的网络电台,模块通过WiFi模块连接网络并将电台的媒体流解码通过外部的扬声器模块将对应的语音播放出来。

  • 使用M5StickC Plus通过WiFi模块连接网络
  • 在M5StickC Plus上进行解码,并通过提供的扬声器模块播放音乐
  • 在M5StickC Plus上能够切换电台,并将电台的信息显示在LCD屏幕上
  • 在M5StickC Plus上能够调节音量,并将音量信息显示在LCD屏幕上

2设计思路

项目设计思路是从实现的目标出发,参考多种案例,选择电台媒体流和音乐媒体流作为音频源来源,以官方Arduino平台为基础。通过预想所要实现的目标,进行相关功能的开发和实现。

FvCkdHl--NwvaZG_tDgG116H6W4F

3 硬件简介及电路连接

 

M5StickC PLUS 简介:

M5StickC PLUS M5StickC的大屏幕版本,主控采用ESP32-PICO-D4模组,具备蓝牙4.2与WIFI功能,小巧的机身内部集成了丰富的硬件资源,如红外、RTC、麦克风、LED、IMU、按键、蜂鸣器、PMU等,在保留原有M5StickC功能的基础上加入了无源蜂鸣器,同时屏幕尺寸升级到1.14寸、135*240分辨率的TFT屏幕,相较之前的0.96寸屏幕增加18.7%的显示面积,电池容量达到120mAh,接口同样支持HAT与Unit系列产品。这个小巧玲珑的开发工具,能够激发你无限的创作可能。 M5StickC 能够帮助你快速的搭建物联网产品原型,简化整个的开发过程.即便是刚开始接触编程开发的初学者,也能够搭建出一些有趣的应用,并应用到实际生活中。

Speaker模块:

硬件连接:

硬件连接部分用到的ESP32的GPIO26引脚作为DAC2的输出引脚来驱动外置Speaker模块。连接实物图如下图所示,将M5StickC PLUS的G26引脚与Speaker模块的Ain引脚相连,并同时连接Vcc电源与Gnd引脚。

                                                         Fr4A5aTVcILT4LEE37Lp3qP-RzMd

 

4 媒体流链接获取 广播电台链接获取:

Step1 进入到世界广播地图的网站首页:https://worldradiomap.com/

Fqmjff6lP5lOVmk7Do3YN-wJPuYc

Step2 选择电台你想要收听的电台,进入到电台播放子页面。

FtfCq356GfKKazV68r5LO3cv-7Wj

Step3 按F12按钮进入开发者模式,对播放按钮处进行元素检测,观察对应网页的源码内容。

FhaW4Ilh88D3gMmkAnJNC9XWN5zQ

Step4 从右侧的元素内容中即可查看到对应的64k map流媒体链接

FiGBWdDcPK6vkCx4qHun4tHql-GH

 

音乐链接获取:

Step1 音乐流媒体链接是通过一个音乐检索网站  音乐直链搜索|音乐在线试听 - by 刘志进实验室 (liuzhijin.cn)进行检索获取。

Fhv5Nw437CrrgaA7s_pJZ8oEQEOK

Step2 通过检索后的结果,选择复制MP3数据流链接即可。

FpoehLzg02Yj7SDHn_6yuu7ldFJq

4 软件部分

软件部分是基于官方的M5Unified库的基础上进行开发的。

1 WiFi连接

模块启动后即进入WiFi连接部分,根据配置好的WiFi信息尝试进行连接,连接成功后则会进入到后续的应用逻辑。如果连接失败则会进入到无限重连的模式,直到WiFi连接成功为止。

  WiFi.disconnect();
  WiFi.softAPdisconnect(true);
  WiFi.mode(WIFI_STA);

#if defined ( WIFI_SSID ) &&  defined ( WIFI_PASS )
  WiFi.begin(WIFI_SSID, WIFI_PASS);
#else
  WiFi.begin();
#endif

  // Try forever
  while (WiFi.status() != WL_CONNECTED) {
    M5.Display.print(".");
    delay(100);
  }

2 流媒体获取及解码

通过第3部分的方式获取到15条流媒体链接后,按照ESP8266Audio library的网络数据流解析例程的函数调用方式进行,流媒体数据的获取和MP3音频数据的解码任务。

static constexpr const char* station_list[][2] =
{
  {"相声·有话好好说    "           , "http://music.163.com/song/media/outer/url?id=1343818701.mp3"},
  {"探清水河 - 张云雷  "         , "http://music.163.com/song/media/outer/url?id=568434921.mp3"},
  {"说书人           "              , "http://music.163.com/song/media/outer/url?id=1303019637.mp3"},
  {"中国校园之声      "              , "http://lhttp.qingting.fm/live/337/64k.mp3?app_id=web"},
  {"脱口秀 - 安全着陆 "              , "http://music.163.com/song/media/outer/url?id=1294910938.mp3"},
  {"北京文艺广播      "            , "http://lhttp.qingting.fm/live/333/64k.mp3?app_id=web"},
  {"小城夏天LBI利比   "             , "http://music.163.com/song/media/outer/url?id=1934251776.mp3"},
  {"北京体育广播      "           , "http://lhttp.qingting.fm/live/335/64k.mp3?app_id=web"},
  {"See Tình        "   , "http://music.163.com/song/media/outer/url?id=1944518589.mp3"},
  {"北京交通广播      "           , "http://lhttp.qingting.fm/live/336/64k.mp3?app_id=web"},
  {"再见莫妮卡        "                    , "http://music.163.com/song/media/outer/url?id=1824045033.mp3"},
  {"北京新闻广播       "           , "http://lhttp.qingting.fm/live/339/64k.mp3?app_id=web"},
  {"在你的身边        "              , "http://music.163.com/song/media/outer/url?id=475479888.mp3"},
  {"北京城市管理广播   "        , "http://162.213.197.54:80"},
  {"给你一瓶魔法药水   "        , "http://music.163.com/song/media/outer/url?id=1959667345.mp3"},
};

static void decodeTask(void*)
{
  for (;;)
  {
    delay(1);
    if (playindex != ~0u)
    {
      auto index = playindex;
      playindex = ~0u;
      stop();
      meta_text[0] = station_list[index][0];
      stream_title[0] = 0;
      meta_mod_bits = 3;
      file = new AudioFileSourceICYStream(station_list[index][1]);
      buff = new AudioFileSourceBuffer(file, preallocateBuffer, preallocateBufferSize);

      bool isAAC = false;
      decoder = isAAC ? (AudioGenerator*) new AudioGeneratorAAC(preallocateCodec, preallocateCodecSize) : (AudioGenerator*) new AudioGeneratorMP3(preallocateCodec, preallocateCodecSize);
      decoder->begin(buff, &out);
    }
    if (decoder && decoder->isRunning())
    {
      if (!decoder->loop()) { decoder->stop(); }
    }
  }
}

3 Speaker驱动部分是采用M5Unified的AudioOutputM5Speaker 类。

class AudioOutputM5Speaker : public AudioOutput
{
  public:
    AudioOutputM5Speaker(m5::Speaker_Class* m5sound, uint8_t virtual_sound_channel = 0)
    {
      _m5sound = m5sound;
      _virtual_ch = virtual_sound_channel;
    }
    virtual ~AudioOutputM5Speaker(void) {};
    virtual bool begin(void) override { return true; }
    virtual bool ConsumeSample(int16_t sample[2]) override
    {
      if (_tri_buffer_index < tri_buf_size)
      {
        _tri_buffer[_tri_index][_tri_buffer_index  ] = sample[0];
        _tri_buffer[_tri_index][_tri_buffer_index+1] = sample[1];
        _tri_buffer_index += 2;

        return true;
      }

      flush();
      return false;
    }
    virtual void flush(void) override
    {
      if (_tri_buffer_index)
      {
        _m5sound->playRaw(_tri_buffer[_tri_index], _tri_buffer_index, hertz, true, 1, _virtual_ch);
        _tri_index = _tri_index < 2 ? _tri_index + 1 : 0;
        _tri_buffer_index = 0;
        ++_update_count;
      }
    }
    virtual bool stop(void) override
    {
      flush();
      _m5sound->stop(_virtual_ch);
      for (size_t i = 0; i < 3; ++i)
      {
        memset(_tri_buffer[i], 0, tri_buf_size * sizeof(int16_t));
      }
      ++_update_count;
      return true;
    }

    const int16_t* getBuffer(void) const { return _tri_buffer[(_tri_index + 2) % 3]; }
    const uint32_t getUpdateCount(void) const { return _update_count; }

  protected:
    m5::Speaker_Class* _m5sound;
    uint8_t _virtual_ch;
    static constexpr size_t tri_buf_size = 640;
    int16_t _tri_buffer[3][tri_buf_size];
    size_t _tri_buffer_index = 0;
    size_t _tri_index = 0;
    size_t _update_count = 0;
};

4 显示驱动部分是基于M5的gfx驱动API完成的,实现在切换电台或者调整音量时的显示效果。

void gfxLoop(LGFX_Device* gfx)
{
  if (gfx == nullptr) { return; }
  if (header_height > 32)
  {
    if (meta_mod_bits)
    {
      gfx->startWrite();
      for (int id = 0; id < 1; ++id)
      {
        if (0 == (meta_mod_bits & (1<<id))) { continue; }
        meta_mod_bits &= ~(1<<id);
        size_t y = id * 12;
        if (y+12 >= header_height) { continue; }
        gfx->setCursor(4, 8 + y);
        gfx->fillRect(0, 8 + y, gfx->width(), 12, gfx->getBaseColor());
        gfx->print(meta_text[id]);
        gfx->print(" "); // Garbage data removal when UTF8 characters are broken in the middle.
      }
      gfx->display();
      gfx->endWrite();
    }
  }
  else
  {
    static int title_x;
    static int title_id;
    static int wait = INT16_MAX;

    if (meta_mod_bits)
    {
      if (meta_mod_bits & 1)
      {
        title_x = 4;
        title_id = 0;
        gfx->fillRect(0, 8, gfx->width(), 12, gfx->getBaseColor());
      }
      meta_mod_bits = 0;
      wait = 0;
    }

    if (--wait < 0)
    {
      int tx = title_x;
      int tid = title_id;
      wait = 3;
      gfx->startWrite();
      uint_fast8_t no_data_bits = 0;
      do
      {
        if (tx == 4) { wait = 255; }
        gfx->setCursor(tx, 8);
        const char* meta = meta_text[tid];
        if (meta[0] != 0)
        {
          gfx->print(meta);
          gfx->print("  /  ");
          tx = gfx->getCursorX();
          if (++tid == meta_text_num) { tid = 0; }
          if (tx <= 4)
          {
            title_x = tx;
            title_id = tid;
          }
        }
        else
        {
          if ((no_data_bits |= 1 << tid) == ((1 << meta_text_num) - 1))
          {
            break;
          }
          if (++tid == meta_text_num) { tid = 0; }
        }
      } while (tx < gfx->width());
      --title_x;
      gfx->display();
      gfx->endWrite();
    }
  }

  if (!gfx->displayBusy())
  { // draw volume bar
    static int px;
    uint8_t v = M5.Speaker.getChannelVolume(m5spk_virtual_channel);
    int x = v * (gfx->width()) >> 8;
    if (px != x)
    {
//      gfx->fillRect(x, 6, px - x, 2, px < x ? 0xAAFFAAu : 0u);
        gfx->setCursor(0, 50);
//        gfx->fillRect(0, 8 + y, gfx->width(), 12, gfx->getBaseColor());
        int vol_pre = (int)((float)(v/255.0)*100);
        gfx->printf("Vol:%d%%", vol_pre);
      gfx->display();
      px = x;
    }
  }
}

5 按键驱动部分是采用M5Unified的按键驱动逻辑,实现电台切换和音量调节的功能。

具体的按键规则是:

  • 当A键按下时会有“”嘟嘟“”的声音发出,模拟切换的音效;
  • 当短按A键时,表示向下切换电台功能;
  • 当短按两下A键时,表示向上切换电台功能;
  • 当长按A键时,表示增加音量功能;
  • 当长按B键时,表示减少音量功能;
  if (M5.BtnA.wasPressed())
  {
    M5.Speaker.tone(440, 50);
  }
  if (M5.BtnA.wasDeciedClickCount())
  {
    switch (M5.BtnA.getClickCount())
    {
    case 1:
      M5.Speaker.tone(1000, 100);
      if (++station_index >= stations) { station_index = 0; }
      play(station_index);
      break;

    case 2:
      M5.Speaker.tone(800, 100);
      if (station_index == 0) { station_index = stations; }
      play(--station_index);
      break;
    }
  }
  if (M5.BtnA.isHolding() || M5.BtnB.isPressed() || M5.BtnC.isPressed())
  {
    size_t v = M5.Speaker.getChannelVolume(m5spk_virtual_channel);
    int add = (M5.BtnB.isPressed()) ? -1 : 1;
    if (M5.BtnA.isHolding())
    {
      add = M5.BtnA.getClickCount() ? -1 : 1;
    }
    v += add;
    if (v <= 255)
    {
      M5.Speaker.setChannelVolume(m5spk_virtual_channel, v);
    }
  }

遇到的主要难题及解决方法

1 网络平台进行流媒体链接的获取,因为中国的广播电台多数都不提供网络流媒体地址,所以在进行链接获取这块也需要花些时间去查找;

解决办法:

首先是通过网上的一些开源项目查看其中的相关链接以及推测其获取途径

后来通过查看相关文章可以通过查看部分电台网站的源代码来找到对应的电台链接

附件下载
WebRadio.zip
团队介绍
嵌入式软件工程师
团队成员
maskmoo
评论
0 / 100
查看更多
目录
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2023 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号