最近ESP32S3で遊び始めたのですが、以前製作したRadikoを聞くがESP32-audioI2Sというライブラリーを使用すると非常に簡単に出来る事が分かりました。ESP32-audioI2S の説明にホストにつないで再生する例として
// audio.connecttohost(“http://26373.live.streamtheworld.com:3690/XHQQ_FMAAC/HLSTS/playlist.m3u8”);
とm3u8形式が有ります。Radikoもこの形式です。Radikoを聞くを製作当時、m3u8形式をESP32で再生する方法が分からず、仕方なくAACファイルの再生で対応していました。
ハードウェア
ESP32-audioI2SはデジタルオーディオDACデコーダとしてI2Sモジュールを使用します。よって最小構成は本体とI2Sモジュールの2つでOKです。
- ESP32S3 (本体)
- 秋月さんで手に入ります。ESP32-S3-DevKitC-1-N8 ESP32-S3-WROOM-1開発ボード 8MB
- PCM5102 I2S
- デジタルオーディオDACデコーダモジュール アマゾン等色んな所で手に入ります。
- 使い方はPCM5102 I2Sで説明しています。
PCM5102の表と裏のパッドを説明通りにショートすれば下記の様に3本配線するだけでOKです。またI2Sのポートはソフトで指定出来ます。今回はGPIO18,17,08を使用していますが変更も可能です。

PCM5102A | ESP32S3 |
LCK | GPIO17 |
DIN | GPIO18 |
BCK | GPIO08 |
この構成で音はPCM5102のイヤフォンジャックから確認出来ます。もしスピーカーが良いならダイソーで売っているミニスピーカーをイヤフォンジャックにさせばスピーカーから聞けます。
ソフトウェア
先ずはライブラリー ”esp32-audioi2s” を追加して下さい。Arduino->ツールー>ライブラリの管理と進み、検索欄に”esp32-audioi2s”と入力して下さい。esp32-audioi2sが表示されるのでそれをインストール。
ESP32-audioI2Sの例を今回の回路に合わせて書き換えて実行するとサイトにつながって再生が始まりました。(実は拡張子m3u8のURLにはアクセス出来ませんでした。でもm3uもほとんどm3u8と同じ。サンプルのm3u8サイトはもう存在しない為にアクセス出来なかったと判断)
test.ino
#include "Arduino.h"
#include "Audio.h"
#include "WiFi.h"
//----------PCM5102 (I2S) ------------
#define I2S_BCLK 8
#define I2S_LRC 17
#define I2S_DOUT 18
Audio audio;
String ssid = "xxxxxxx";
String password = "yyyyyyy";
void setup()
{
Serial.begin(115200);
WiFi.disconnect();
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), password.c_str());
while (WiFi.status() != WL_CONNECTED) delay(1500);
audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
audio.setVolume(12); // default 0...21
audio.connecttohost("http://www.wdr.de/wdrlive/media/einslive.m3u");
}
void loop() {
audio.loop();
vTaskDelay(1);
}
これでconnecttohost()関数にURLを指定するればそのサイトにアクセス出来る事が分かりました。しかしRadikoは、アクセス時ヘッダーに”X-Radiko-AuthToken: 認証キー”を追加する必要が有ります。これを追加する方法は無いかと探しているとconnecttohost()関数に相手左記にユーザIDとPassWordを送る機能が有ることが分かりました。その場合の書式は以下の通り。
bool Audio::connecttohost(const char* host, const char* user, const char* pwd)
第2,3引数にユーザID、Passwordを文字列で渡す様です。この処理はAudio.ccpの約433行目の下記で行われています。
Audio.ccp
bool Audio::connecttohost(const char* host, const char* user, const char* pwd) { // user and pwd for authentification only, can be empty
bool res = false; // return value
char* c_host = NULL; // copy of host
uint16_t lenHost = 0; // length of hostname
uint16_t port = 0; // port number
uint16_t authLen = 0; // length of authorization
int16_t pos_slash = 0; // position of "/" in hostname
int16_t pos_colon = 0; // position of ":" in hostname
int16_t pos_ampersand = 0; // position of "&" in hostname
uint32_t timestamp = 0; // timeout surveillance
uint16_t hostwoext_begin = 0;
// char* authorization = NULL; // authorization
char* rqh = NULL; // request header
char* toEncode = NULL; // temporary memory for base64 encoding
char* h_host = NULL;
// https://edge.live.mp3.mdn.newmedia.nacamar.net:8000/ps-charivariwb/livestream.mp3;&user=ps-charivariwb;&pwd=ps-charivariwb-------
// | | | | |
// | | | | | (query string)
// ssl?| |<-----host without extension-------->|port|<----- --extension----------->|<-first parameter->|<-second parameter->.......
xSemaphoreTakeRecursive(mutex_playAudioData, 0.3 * configTICK_RATE_HZ);
// optional basic authorization
if(user && pwd) authLen = strlen(user) + strlen(pwd);
char authorization[base64_encode_expected_len(authLen + 1) + 1];
authorization[0] = '\0';
if(authLen > 0) {
char toEncode[authLen + 4];
strcpy(toEncode, user);
strcat(toEncode, ":");
strcat(toEncode, pwd);
b64encode((const char*)toEncode, strlen(toEncode), authorization);
}
if (host == NULL) { AUDIO_INFO("Hostaddress is empty"); stopSong(); goto exit;}
if (strlen(host) > 2048) { AUDIO_INFO("Hostaddress is too long"); stopSong(); goto exit;} // max length in Chrome DevTools
c_host = x_ps_strdup(host); // make a copy
h_host = urlencode(c_host, true);
trim(h_host); // remove leading and trailing spaces
lenHost = strlen(h_host);
if(!startsWith(h_host, "http")) { AUDIO_INFO("Hostaddress is not valid"); stopSong(); goto exit;}
if(startsWith(h_host, "https")) {m_f_ssl = true; hostwoext_begin = 8; port = 443;}
else {m_f_ssl = false; hostwoext_begin = 7; port = 80;}
// In the URL there may be an extension, like noisefm.ru:8000/play.m3u&t=.m3u
pos_slash = indexOf(h_host, "/", 10); // position of "/" in hostname
pos_colon = indexOf(h_host, ":", 10); if(isalpha(c_host[pos_colon + 1])) pos_colon = -1; // no portnumber follows
pos_ampersand = indexOf(h_host, "&", 10); // position of "&" in hostname
if(pos_slash > 0) h_host[pos_slash] = '\0';
if((pos_colon > 0) && ((pos_ampersand == -1) || (pos_ampersand > pos_colon))) {
port = atoi(c_host + pos_colon + 1); // Get portnumber as integer
h_host[pos_colon] = '\0';
}
setDefaults();
rqh = x_ps_calloc(lenHost + strlen(authorization) + 300, 1); // http request header
if(!rqh) {AUDIO_INFO("out of memory"); stopSong(); goto exit;}
strcat(rqh, "GET /");
if(pos_slash > 0){ strcat(rqh, h_host + pos_slash + 1);}
strcat(rqh, " HTTP/1.1\r\n");
strcat(rqh, "Host: ");
strcat(rqh, h_host + hostwoext_begin);
strcat(rqh, "\r\n");
strcat(rqh, "Icy-MetaData:1\r\n");
strcat(rqh, "Icy-MetaData:2\r\n");
strcat(rqh, "Accept:*/*\r\n");
strcat(rqh, "User-Agent: VLC/3.0.21 LibVLC/3.0.21\r\n");
if(authLen > 0) { strcat(rqh, "Authorization: Basic ");
strcat(rqh, authorization);
strcat(rqh, "\r\n"); }
strcat(rqh, "Accept-Encoding: identity;q=1,*;q=0\r\n");
strcat(rqh, "Connection: keep-alive\r\n\r\n");
if(m_f_ssl) { _client = static_cast(&clientsecure);}
else { _client = static_cast(&client); }
timestamp = millis();
_client->setTimeout(m_f_ssl ? m_timeout_ms_ssl : m_timeout_ms);
- 459から468行: ユーザIDとPassWordがBase64変換されauthorization配列に代入される。
- 495行: ヘッダー用配列を定義
- 508から510行: ここで、 authorizationがヘッダーに追加される。
そこで、
- 495行: ヘッダー用配列を念の為 200増やす
- 508から518行: ユーザーIDに”ー”が指定された場合、ヘッダーに
"X-Radiko-AuthToken: pwd"が追加され
、
それ以外は通常通り。
rqh = x_ps_calloc(lenHost + strlen(authorization) + 300 + 200, 1); // http request header
if(!rqh) {AUDIO_INFO("out of memory"); stopSong(); goto exit;}
strcat(rqh, "GET /");
if(pos_slash > 0){ strcat(rqh, h_host + pos_slash + 1);}
strcat(rqh, " HTTP/1.1\r\n");
strcat(rqh, "Host: ");
strcat(rqh, h_host + hostwoext_begin);
strcat(rqh, "\r\n");
strcat(rqh, "Icy-MetaData:1\r\n");
strcat(rqh, "Icy-MetaData:2\r\n");
strcat(rqh, "Accept:*/*\r\n");
strcat(rqh, "User-Agent: VLC/3.0.21 LibVLC/3.0.21\r\n");
if(authLen > 0) {
if(user == "-"){
strcat(rqh, "X-Radiko-AuthToken: ");
strcat(rqh, pwd);
}
else{
strcat(rqh, "Authorization: Basic ");
strcat(rqh, authorization);}
}
strcat(rqh, "\r\n");
}
strcat(rqh, "Accept-Encoding: identity;q=1,*;q=0\r\n");
strcat(rqh, "Connection: keep-alive\r\n\r\n");
if(m_f_ssl) { _client = static_cast(&clientsecure);}
else { _client = static_cast(&client); }
timestamp = millis();
_client->setTimeout(m_f_ssl ? m_timeout_ms_ssl : m_timeout_ms);
Arduinoライブラリーの中に有る”Audio.ccp”を上記の様に変更すれば connecttohost(“radiko_URL”, “-“, “認証番号”)でRadikoにアクセス出来ます。
Radiko認証キーの取得はRadikoを聞く(Arduino編)ーRadiko Playerの製作ーと同じです。radiko_02.inoの212行にRadiko(byte flg, String ID)関数が有ります。このpart3まで実行すると、認証キーとアクセス可能なラジオ局のリストが作成されます。
今回の回路に合わせて前回のプログラムを書き換えると下記の様になります。
radiko_s3.ino
#include <Arduino.h>
#include <WiFi.h>
#include <string.h>
#include "mbedtls/base64.h"
#include <HTTPClient.h>
#include <WebServer.h>
#include "SPIFFS.h"
#include "Audio.h"
Audio audio;
#define BUFF_MAX 34000
//----------PCM5102 (I2S) ------------
#define bclk 8
#define wclk 17
#define dout 18
// Enter your WiFi setup here:
const char *SSID = "xxxxxxxx";
const char *PASSWORD = "yyyyyyyy";
IPAddress ip(192, 168, 3, 250); // IP Address
IPAddress gateway(192,168, 3, 1); // Gateway Address
IPAddress dns(192,168, 3, 1); // Gateway Address
IPAddress subnet(255, 255, 255, 0); // Subnet Address
WebServer _server(80);
String _index_top =
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset='utf-8'>\n<meta name='viewport' content='width=device-width,initial-scale=1'>\n"
"<link rel='stylesheet' type='text/css' href='/radiko.css' >\n<title>Radiko</title>\n</head>\n"
"<body>\n<center>\n<div class='b_frame'>\n<br>\n<div class='t_font'><u>Radiko 1.0</u></div>\n<br>\n"
"<form method='get' id='abc'>\n<input name='1' id='123' style='display:none'>\n</form>\n"
"<form oninput='op.value=ab.value'>\n<span class='v_font'>Volume</span><br>\n"
"<input style='width:220px;' type='range' value='";
uint8_t _st_selected, _station_MAX, _volume;
bool _flg_play, _flg_mute, _flg_auto;
char _url[200], _auth[50];
String st_list[2], _auth_token;
void setup()
{
//----------------- part0 ------------------------------------------------------------------------------
Serial.begin(115200);
delay(500);
WiFi.disconnect();
WiFi.softAPdisconnect(true);
delay(500);
WiFi.mode(WIFI_STA);
WiFi.config(ip, gateway, subnet, dns);
WiFi.begin(SSID, PASSWORD);
while (WiFi.status() != WL_CONNECTED)
{
Serial.println("...Connecting to WiFi");
delay(1000);
}
Serial.println("Connected");
Serial.println(SSID);
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
//----------------- HTTP Server & SPIFFS ----------------------------------------------------------------------
_server.on("/", handleRoot);
_server.onNotFound(handleWebRequests);
_server.begin();
Serial.println("HTTP server started");
Serial.println("SPIFFS started");
SPIFFS.begin();
Serial.print("I2S Setup");
audio.setPinout(bclk, wclk, dout);
audio.setVolumeSteps(100);
Read_state();
Radiko_Key(_auth_token, st_list);
_flg_mute = false;
audio.setVolume(_volume);
if(_flg_auto) {
_flg_play = true;
Radiko_play(_st_selected);
}
else _flg_play = false;
}
void loop()
{
_server.handleClient();
if(_flg_play){
if (audio.isRunning()) audio.loop();
else audio.connecttohost(_url,"-", _auth);
}
}
void handleRoot()
{
String cmd,str;
int a,flg;
flg = 1;
cmd = _server.argName(0);
switch(cmd.toInt())
{
case 1: // Play, Mute, Auto, Save
cmd = _server.arg("1");
str = cmd.substring(2);
a = str.toInt();
flg = 0;
switch(cmd.charAt(0))
{
case '1': // volume data
_volume = a;
audio.setVolume(_volume);
break;
case '2': // station No
_st_selected = a;
Radiko_stop();
delay(50);
Radiko_play(_st_selected);
break;
case '3': // play
if(!_flg_play) Radiko_play(_st_selected);
break;
case '4': // mute
_flg_mute = !_flg_mute;
a = _volume;
if(_flg_mute) a = 5;
audio.setVolume(a);
break;
case '5': // auto flg
_flg_auto = !_flg_auto;
break;
case '6': // Save
Save_state();
break;
}
break;
case 2: // Stop
Radiko_stop();
break;
}
if(flg)
{
str = Make_index(str);
_server.send(200, "text/html",str);
}
else _server.send(204, "text/html","");
}
void Radiko_play(uint8_t _st_selected){
int a;
String str;
Radiko_Key(_auth_token, st_list);
str = "https://f-radiko.smartstream.ne.jp/" + get_st_name(_st_selected, st_list[0]) + "/_definst_/simul-stream.stream/playlist.m3u8";
for(a = 0; a < str.length(); a ++) _url[a] = str[a];
_url[a] = 0;
for(a = 0; a < _auth_token.length(); a ++) _auth[a] = _auth_token[a];
_auth[a] = 0;
audio.connecttohost(_url,"-", _auth);
_flg_play = true;
}
void Radiko_stop()
{
if(_flg_play) audio.stopSong();
_flg_play = false;
}
void Radiko_Key(String& _auth_token, String* st_list)
{
char * auth_key = "bcd151073c03b352e1ef2fd66c32209da9ca0afa" ;
HTTPClient http;
int key_length, offset;
char base64_encoded[32],token_p[32];
size_t base64_len;
const char *headerKeys[] = {"X-Radiko-AuthToken", "X-Radiko-KeyLength", "X-Radiko-KeyOffset"};
String str;
int a,b,c;
WiFiClient * stream;
uint8_t* _sound_buff;
_sound_buff = (uint8_t*)malloc(BUFF_MAX + 5);
//----------------- part1 ------------------------------------------------------------------------------
http.begin("https://radiko.jp/v2/api/auth1");
http.addHeader("User-Agent", "esp32/4");
http.addHeader("Accept", "*/*");
http.addHeader("X-Radiko-App", "pc_html5");
http.addHeader("X-Radiko-App-Version", "0.0.1");
http.addHeader("X-Radiko-User", "dummy_user");
http.addHeader("X-Radiko-Device", "pc");
http.collectHeaders(headerKeys, 3);
Serial.println("part1 = " + String(http.GET()));
offset = String(http.header(headerKeys[2])).toInt();
key_length = String(http.header(headerKeys[1])).toInt();
_auth_token = http.header(headerKeys[0]);
http.end();
token_p[0] = '\0';
strncpy(token_p, &auth_key[offset], key_length);
token_p[key_length] = '\0';
mbedtls_base64_encode((unsigned char *) base64_encoded, sizeof(base64_encoded),&base64_len, (unsigned char *) token_p, strlen(token_p));
//----------------- part2 ------------------------------------------------------------------------------
http.begin("https://radiko.jp/v2/api/auth2");
http.addHeader("X-Radiko-AuthToken", _auth_token);
http.addHeader("X-Radiko-Partialkey", base64_encoded);
http.addHeader("X-Radiko-User", "dummy_user");
http.addHeader("X-Radiko-Device", "pc");
Serial.println("part2 = " + String(http.GET()));
str = http.getString();
str = str.substring(0,str.indexOf(","));
http.end();
//----------------- part3 ------------------------------------------------------------------------------
// http.begin("https://radiko.jp/v2/station/list/[ID].xml");
str = "https://radiko.jp/v2/station/list/" + str + ".xml";
http.begin(str);
http.addHeader("X-Radiko-AuthToken", _auth_token);
Serial.println("part3 = " + String(http.GET()));
b = http.getSize();
c = 0;
stream = http.getStreamPtr();
while(b)
{
while(!(a = stream->available())) NOP();
stream->readBytes(&_sound_buff[c], a);
b -= a;
c += a;
}
_sound_buff[c] = 0;
str = String((char *)_sound_buff);
b = 0;
st_list[0] = st_list[1] = "";
while((a = str.indexOf("<id>")) != -1)
{
a += 4;
str = &str[a];
a = str.indexOf("</id>");
st_list[0] += str.substring(0,a);
st_list[0] += "\n";
a = a + 16;
str = &str[a];
a = str.indexOf("</name>");
st_list[1] += str.substring(0,a);
st_list[1] += "\n";
b ++;
}
_station_MAX = b;
for(a = 0; a < b; a ++)
Serial.println("No." + String(a) +" ID: " + get_st_name(a, st_list[0]) + " , name: " + get_st_name(a, st_list[1]));
Serial.println("station_MAX = " + String(b));
http.end();
free(_sound_buff);
}
String get_st_name(int num, String str){
int a;
if(num > -1){
a = 0;
while(num){
while(str[a] != 0xa) a ++;
a ++;
num --;
}
num = a;
a ++;
while(str[a] != 0xa) a ++;
str = str.substring(num,a);
}
else str = "";
return(str);
}
String Make_index(String str)
{
int a;
str = String(_volume);
str += "' id='vol' name='ab' onchange='set_data(1)'>\n<output name='op' class='v_font'>";
str += String(_volume);
str += "</output>\n<br>\n<form>\n<select id='_title' class='m_font' onchange='set_data(2)'>\n";
for(a = 0; a < _station_MAX; a ++)
{
str += "<option value='";
str += (String(a) + "'");
if(a == _st_selected) str += " selected>";
else str += ">";
str += get_st_name(a, st_list[1]);
str += "</option>\n";
}
str += "</select>\n</form>\n<br>\n<form method='get'>\n<button type='button' id='_play' onclick='set_data(3)' ";
if(_flg_play) str += "style ='background: red;'";
str += ">Play</button>\n<button type='button' id='_mute' onclick='set_data(4)' ";
if(_flg_mute) str += "style ='background: red;'";
str += ">Mute</button>\n<button type='submit' name='2' >Stop</button>\n<br>\n<span class='v_font' style ='margin-left:5%;'>Auto Start :</span>\n";
str += "<input type='radio' id='_auto' style ='margin-left:5%;' onclick='set_data(5)'";
if(_flg_auto) str += " checked ";
str += "/>\n<button type='button' style ='margin-left:16%;' onclick='set_data(6)'";
str += ">Save</button>\n</form>\n</div>\n</center>\n<script src='./radiko.js'></script>\n</body>\n</html>";
str = _index_top + str;
return str;
}
void handleWebRequests()
{
String dataType = "text/plain";
String path;
File dataFile;
path = _server.uri();
if(path.endsWith(".txt")) dataType = "text/plain";
else if(path.endsWith(".css")) dataType = "text/css";
else if(path.endsWith(".js")) dataType = "application/javascript";
else if(path.endsWith(".ico")) dataType = "image/html";
dataFile = SPIFFS.open(path.c_str(), "r");
_server.streamFile(dataFile, dataType);
dataFile.close();
delay(5);
}
void Save_state()
{
String str;
File dataFile;
str = String(_volume) + "\n" + String(_st_selected) +"\n" + String(_flg_auto) + "\n";
dataFile = SPIFFS.open("/state.txt", "w");
dataFile.print(str);
dataFile.close();
}
void Read_state()
{
String str;
File dataFile;
dataFile = SPIFFS.open("/state.txt", "r");
str = dataFile.readStringUntil('\n');
_volume = str.toInt();
str = dataFile.readStringUntil('\n');
_st_selected = str.toInt();
str = dataFile.readStringUntil('\n');
_flg_auto = str.toInt();
dataFile.close();
}
- Arduinoのスケッチです。
- 前回、認証キーとラジオ局を取得する関数 Radiko(byte flg, String ID)を Radiko_Key(String& _auth_token, String* st_list)と定義し直しています。
- Radiko_Key(String& _auth_token, String* st_list) この関数を呼ぶと、引数_auth_tokenに認証キーが、st_listにアクセス可能なラジオ局が文字列となって帰って来ます。
- RadikoのURLは169行で作成しています。
- URLは “https://f-radiko.smartstream.ne.jp/ Station ID /_definst/simul-stream.stream/playlist.m3u8″ です。
- Station IDの箇所に聞きたいラジオ局のIDを指定します。
- ラジオ局ID取り出し用に関数get_st_name()を作成しています。
- audio.connecttohost()関数は引数が char*型なので、169行でString型に保存したURLを170,171行でcharの配列_url[]にコピーしています。
- 同様に認証キーもcharの配列_auth[]にコピー
- 174行のaudio.connecttohost(_url,”-“, _auth);でサイトにアクセス開始です。
- 92行のloop()関数
- audio.isRunning():
- Ture:実行中。ー>続けて再生 audio.loop()を実行
- False:最初のm3u8リスト終了。次の演奏リスト取得ー>audio.connecttohost(_url,”-“, _auth);
- これで連続で再生されます。
- audio.isRunning():
後は前回と同じです。プログラムはこれとは別に
- ホームページで使用する
- JAVA SCRIPT: radiko.js
- CSSファイル: radiko.css
- 初期設定ファイル: state.txt
- faviconファイル: favicon.ico
が有ります。全部まとめてここに保存しました。
プログラムをコンパイルして実行。ブラウザで192.168.3.250をアクセスすると

この画面が表示されます。使い方は、Radikoを聞く(Arduino編)ーRadiko Playerの製作ーと一緒です。
最後に
Radikoを聞く(Arduino編)ーRadiko Playerの製作ーに比べこれは格段に落ちなくなりました。でもたまに落ちるのですが原因は大体ネット関係です。
- WiFIの状態を改善する。ー> ルーターとの間の障害物を無くす、 ルータに近づく
- ネットが遅い。 ー> ネットの空いている時間にもう一度試す
この様な場合、ESP32S3にリセットがかかり再起動します。なので、Auto Startをオンし一度Saveするれば起動時に同じラジオ局を再生するので結果的に続けてラジオが聞けます。
今回のRadiko URL “https://f-radiko.smartstream.ne.jp/〜” はタイムフリー用のURLでした。再生している番組が実際のものより約3から4分おくれるのはその為と思います。また、タイムフリーは継続して1日3時間までという条件が有ります。3時間連続で聞いた事が無いので分かりませんが、多分そこで落ちると思います。