M5StickCでWeb Radio→S/PDIF

内蔵 LED で S/PDIF 出力

M5StickC を使って、Web Radio を受信し、S/PDIF (TOSLINKとも) で出力するデバイスを作りました。AVアンプに接続しておけば、電源を入れるだけですぐに音楽が楽しめて便利です。放送中の曲名も表示されます。

見ての通り、出力は M5StickC 本体に内蔵されている赤色LEDです。

LEDは少々奥まったところにあるため、軽くドリルでケースを丸く削って穴を広げました。そこにS/PDIFケーブルの端子の先端を軽く差し込んでLEDに密着させることで十分に信号を送ることができます。位置が決まったら抜けないようラフにテープで止めておけばOK(?)。

丸型(Mini-Toslink) ケーブルにも対応(??)。


実装

まず対応ハードウェアについて。M5StickC シリーズの最新は M5StickC Plus2 ですが、これは今回の用途には不向きです。というのも、赤色LEDの位置が上部に移動しているほか、赤外線LEDが同じピンに割り当てられており、しかも赤外線LEDは100mA程度のハイパワー駆動が可能なように、バッテリー電源+トランジスタでドライブする仕様に変更されています。瞬間的にパワーが欲しい赤外線リモコン制作にはとても理想的ですが、これをS/PDIFで常時点灯させるとめちゃくちゃ発熱してしまう恐れがあります。もしやるなら赤色LEDを外付けしないとダメでしょう。*1

1つ前の M5StickC Plus なら大丈夫だと思います。今回は初期版を使用しています。
ちなみに、Plus/初期型のLEDはLOWで点灯と負論理になっていますが、S/PDIFはON↔︎OFFの切り替えタイミングで符号化する仕組みなので、どっちでも平気です。

M5StickC Plus2 のLED回路

M5StickC(初期型/Plus)のLED回路

加えて、初期型とPlusの ESP32-PICO-D4 のI2Sではより高精度なクロックを生成できる APLL が利用できます。


ソフトウェアは Arduino IDE + Arduino-ESP32 v3.3.4 で開発を行いました。

ライブラリとして最初は ESP8266Audio を試したのですが、うまく S/PDIF の出力ができませんでした。
i2s_channel_enable() を呼び出し忘れている箇所があったのを修正 したり、APLL でなくデフォルトのクロックに差し替えたりしたところ、S/PDIF信号自体は出るようになったのですが、なぜかレシーバーが認識してくれず。AudioOutputSPDIF は experimental 扱いなので何か問題があるのかもしれません。

別のライブラリとして arduino-audio-tools を選択。 Test Output to SPDIF example を試してみたところ、こちらはピン番号を10に変えるだけで問題なくsin波がS/PDIF信号として出力されることを確認できたので、これを採用しました。
このライブラリは使っているマイコンSDKを自動的に識別して最適な実装を選択するようになっており、非常に簡単に試せるのが素晴らしいですね。

MP3 のデコードのためには、このライブラリに加えて arduino-libhelix 等のデコーダーも libraries に加えておく必要があります。

とはいえ多少苦労した点も。Web Radio の example では SSID / password を ICYStream クラスに渡す形で書かれていますが、この方法でも動作はするものの、接続に失敗したり、接続に成功してもすぐに途中で途切れたりと、非常に不安定になってしまいました。
このライブラリは Arduino 以外でも利用できるように ESP32 用の WiFi 操作を自前で実装しているのですが、それと何か相性が悪いのかもしれません。ArduinoWiFi 実装ではそうした現象に出会ったことはなかったため、 以下のように Aruidno の WiFiClient インスタンスを使って初期化し、バッファも大きめにしたところ、安定させることができました。

WiFiClient client;
ICYStreamBuffered urlStream(client, 2048*32);

さらに、ICYStream だと数分に一度くらいの頻度で一瞬音が途切れることがあったため、 ICYStreamBuffered というバッファリング機能付きのクラスに差し替えることで、とても安定した動作となりました。

あとは画面出力や M5 ボタンを押した時に別の Web Radio に切り替える機能をつけて完成。ソースコードは以下です。
Web Radio の URL は適当に集めたものをいくつか登録してありますが、AAC など、MP3 + http (ICY) 以外のストリームには対応していない点に注意が必要です。おそらく AAC はPSRAMを搭載していない初期型 M5Stick ではメモリ不足でデコードできないと思われます。

追記
一部のWeb Radioでは曲の間にストリームが10秒止まるものがあり,その際にWDTが作動して再起動してしまっていたので、ライブラリの方に以下の修正を入れました。
Avoid task WDT invoked when the stream is temporarily stopped · NeoCat/arduino-audio-tools@51e531f · GitHub
他に接続エラー時にすぐにハンドリングできるよう URL_CLIENT_TIMEOUT / URL_HANDSHAKE_TIMEOUT を10秒程度に短くしたりしています。


ソースコードは以下:

#include <Arduino.h>
#include <M5Unified.h>
#include <WiFi.h>
#include "AudioTools.h"
#include "AudioTools/AudioCodecs/CodecMP3Helix.h"
#include "AudioTools/Disk/AudioSourceURL.h"
#include "AudioTools/Communication/AudioHttp.h"
#include "AudioTools/AudioLibs/SPDIFOutput.h"

const char *urls[] = {
  "http://ice7.securenetsystems.net/KCSM2",
  "http://radio.wanderingsheep.net:8000/jazzcafe?_ic2=0",
  "http://ksds-ice.streamguys1.com/ksds.mp3?_ic2=0",
  "http://vip2.fastcast4u.com/proxy/cjfla?mp=/1",
  "http://ottava2.out.airtime.pro/ottava2_a",
};
const char *names[] = {
  "KCSM",
  "Jazz Cafe",
  "KSDS Jazz",
  "Cool Jazz Fl",
  "OTTAVA",
};

const char *ssid = "<SSID>";
const char *password = "<PASSWORD>";

AudioInfo info(44100, 2, 16);
WiFiClient client;
ICYStreamBuffered urlStream(client, 2048*32);
AudioSourceURL source(urlStream, urls, "audio/mp3");
MP3DecoderHelix decoder;
SPDIFOutput out;
AudioPlayer player(source, out, decoder);

void printMetaData(MetaDataType type, const char* str, int len){
  Serial.print("==> ");
  Serial.print(toStr(type));
  Serial.print(": ");
  Serial.println(str);

  if (type == audio_tools::Title) {
    M5.Lcd.fillRect(0, 30, 160, 40, BLACK);
    M5.Lcd.setTextColor(GREEN);
    M5.Lcd.setCursor(5, 30);
    M5.Lcd.setTextFont(0);
    M5.Lcd.print(str);
  }
}

bool playing = false;
void drawName() {
  M5.Lcd.fillRect(0, 0, 160, 70, BLACK);
  if (playing) {
    M5.Lcd.setTextColor(BLUE);
    M5.Lcd.setCursor(20, 5);
    M5.Lcd.setTextFont(4);
    M5.Lcd.print(names[source.index()]);
    Serial.print("Playing ");
    Serial.println(names[source.index()]);
  } else {
    Serial.println("Stopped");
  }
}

// Arduino Setup
void setup(void) {  
  auto cfg = M5.config();
  M5.begin(cfg);

  // Open Serial
  Serial.begin(115200);
  AudioToolsLogger.begin(Serial, AudioToolsLogLevel::Warning);

  M5.Lcd.setRotation(1);
  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setTextColor(WHITE);
  M5.Lcd.setTextSize(1);
  M5.Lcd.setTextFont(1);
  M5.Lcd.setCursor(20, 70);
  M5.Lcd.print("Connecting ...");
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  // Try forever
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(1000);
  }
  Serial.println("Connected");

  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setTextColor(WHITE);
  M5.Lcd.setCursor(20, 70);
  M5.Lcd.setTextFont(0);
  M5.Lcd.print(WiFi.localIP());

  // start I2S
  Serial.println("starting SPDIF...");
  auto config = out.defaultConfig();
  config.copyFrom(info); 
  config.pin_data = 10;
  config.buffer_size = 384;
  config.buffer_count = 8;

  out.begin(config);

  player.setMetadataCallback(printMetaData);
  player.begin();
}

void loop() {
  M5.update();
  if (M5.BtnA.wasClicked()) {
    player.next();
    drawName();
  }

  if (player.copy() > 0) {
    if (!playing) {
      playing = true;
      drawName();
    }
  } else {
    if (playing) {
      playing = false;
      drawName();
      Serial.println("Stopped");
    }
  }
}

*1:まあ、初期型・PlusのLEDも、抵抗なしで電源・ピン直結かよ!というツッコミどころはあるのですが。