akasakaのアイコン画像
akasaka 2025年03月12日作成 (2025年03月13日更新) © CC BY-NC 4+
製作品 製作品 閲覧数 360
akasaka 2025年03月12日作成 (2025年03月13日更新) © CC BY-NC 4+ 製作品 製作品 閲覧数 360

ESP32で歌詞表示機能付きCDプレイヤーを作ってみた

ESP32で歌詞表示機能付きCDプレイヤーを作ってみた

はじめに

音楽CDって、懐かしいですね。私にとって、CDはたぶん一番お気に入りのフォーマットです。

音が綺麗し、オープンリールやレコードよりメンテナンスが少ない。更に小さくてコレクションは管理しやすい。

コレクターとして、もちろんプレイヤーも何台持っていますけど
中学生であった頃からずっと自分のCDプレイヤーを作りたかった。
残念ながら当時にCDドライブが高くて変な実験には使えなかったんだし、マイコンの開発環境も数十万円ほどの値段することは例外ではなかった。

20年以上が過ぎてCDを日常生活に聴いている方の数が少なくなってCDドライブもジャンクコーナーに頻繁に揃えています。そしてマイコンはホビーとしても簡単に使えるの時代になりました。

ということで、自分のCDプレイヤーを作ってみましょう。

目標

今回の記事が割と長くなる想定ですので、先に結果を見せていきます。

動画

結果として、作られた実機に以下の機能が含まれています:

  • CD-AudioのTOC読み込み(ISO9660も試したけど今回IDEバスの実装が遅すぎて意味はない)
  • CDの再生・早送り・巻き戻し・シャッフル再生
  • 使用中のドライブがチェンジャーであれば複数CDの連続再生(手元にあるテスト用のCD-C68Eはかなりガラクタなので別のチェンジャーを譲ってくれる方がいらっしゃいましたらコメントから声をかけて頂ければと大変助かります!!)
  • CD TEXTがCDにあれば(さらにドライブ側で対応がある場合)トラック名などを表示する
  • CD TEXTがないときはMusicBrainzやCDDBからトラック名データを取得する
  • LRCLib, QQ MusicやNetEaseなどから歌詞データを取得して再生中に表示する
  • 再生中トラック情報を Last.fm に展開する

再生中のトラックは・・

リアルタイムでLast.fmのプロフィールに展開されています

  • MP3やAACフォーマットのインターネットラジオ配信を再生する
  • Bluetoothでスマホから音声を受信して再生する
  • さらにBluetoothでつないでるスマホのプレイヤーを操作できる
  • 赤外線リモコンから操作可能(PS2のDVDリモコンを使用)

部品を揃う

SATAやSCSIなどのインターフェースの実装は難ありだから今回はIDE(ATAPI)で進めていきましょう。

そもそもIDE時代のCDROMうちにCDからオーディオを再生できるドライブが多い。一部の機種に再生ボタンが採用されて、電源だけを追加すれば簡単のCDプレイヤーを作れます。

キャプションを入力できます

残念ながらCDROMのアナログ音声出力の音質はだいぶ酷いです。なのでデジタル出力があるものを探していきましょう。

上のドライブは使えます。下のドライブはジャンクコーナーに戻すしか・・

マイコンとして、電子工作ホビーに珍しくはない ESP32-WROVER を採用することに決まりました。ROM 8メガ、RAM 4メガがありましてJavaっぽいのC++コードでも平気です。

SPDIF受信ICとしては Wolfson WM8805 を使用します。すでにEOLなのにあっちこっちにまだ購入できるしジッター防止や耐ノイズは現行のICには比較もない(と聞いたことがあります)。

ということで音声側の回路は大体データシート通りとなります:

クリックで拡大

IDEバスを実装するために PCA9555D という16ビットI2Cトライステートレジスターを使用します。かなり遅いですが、今回はATAPIコマンドしか通信しませんので十分です。また次のrevを作る時間が来たらちょっと速いSPIベースの何とかをつけとくかもしれません。

キャプションを入力できます

ディスプレイとして、LCDだったらダサいしOLEDだったらつまらないし前回の記事に直したVFDを使用。ちなみにVFDの元々の設置機材はこれ:

UNO同好会 様の動画から引用

オークションサイトとかよりずっと集まりまくって、遂に使用目的が現れた。

キャプションを入力できます

基板を作る

先ほどの回路図通り基板を作ります。今回は初めてオートルーティングを使用しませんでしたがあまり良くはないだと思います。まあ動作してる限りは大丈夫…

キャプションを入力できます

キャプションを入力できます

とあるウェブサイトから生産を依頼して、届いてからパーツを取付します。この時点で採用した5Vレギュレーターモジュールは12V入力から動かないことを気づいた (゜U。)

モジュールを改造して解決できましたけど次のrevに要修正…

ご存知ですか?「PCB」って、「Printed Cirno Board」の意味

開発環境を準備する

こんなにデカいプロジェクトを開発するなら Serial.print だけでデバッグを済ますことができないでしょう。

neocode様の記事 に従って秋月電子から購入したFT2232の評価基板をJTAGデバッガーとして使用できました。

キャプションを入力できます

接続は以下の通りです(基板のシルクは間違っているのでご注意ください)

ESP32 FT2232 信号
GPIO13 AD0 TCK
GPIO12 AD1 TDI
GPIO15 AD2 TDO
GPIO14 AD3 TMS
TX BD0 ログや初回ファームアップ用UART
RX BD1 ログや初回ファームアップ用UART

注意点: 開発中に、I2Sなどを初期化するとJTAGが使えなくなることが多かったです。私のジグの接触不良かESP32の特性かわかりませんでした。

ファームを開発する

設計

今回のファームを複数のモジュールに分けていきます:

  • ESPer-CORE: 基板・プラットフォームに関してのいろいろ。I2CとかSPDIFとかデータ圧縮や解凍処理とか・・・
  • ESPer-CDP: CDの再生に関連するのすべてです。ATAPI制御とか、ステート管理とか・・・
  • ESPer-GUI: GUIや画像処理のモジュール
  • プロジェクトのsrcフォルダー: ユーザーインターフェース、任意機能(BT、インターネットラジオ)の実装

ESPer-GUIの基本設計

ビットマップデータの操作に関しての記事はいくつもありますのでレンダリングの基本は今回省略させていただきます。UIユニットだけの設計をチラッと見ていきましょう。

例のモバイルOSみたいに、レンダリング単体をViewというものにしました。

一つのViewに複数のサブViewが入ってる事が可能ですが、各Viewは自分のステートだけを管理しています。例えばラベル付きプログレスバーを実装しようとしたら合成のViewのset_value(int)メソッド:

  • プログレスインジケーターのピクセル幅などを再計算する
  • プログレスバーを描きなおす必要があれば set_needs_display() で描きフラグを立ち上がって、render() にて実際のピクセルデータを生成する
  • 一応SubViewのラベルのset_value()を呼び出して文字列表示関係の同様処理をラベルに任せる

そしてディスプレイドライバーはフレーム毎に描きフラグが立てるViewだけをディスプレイに出力します。

キャプションを入力できます
GUIモジュール動作確認の動画

ドライブの制御

※ ATAPIやIDEに関連する資料(特に ACS-3, SFF-8090やSFF-8020)はとても長くて完全な解説は普段の記事に入りきれない。さらに簡単なドライブ操作にほとんどの情報に使い道はない。このため、今回の解説に結構省略をさせていただきます。詳しく調べたい方は直接に資料をご参照ください。

IDEバスの基本

先ずは物理的な信号バスから解説していきましょう。

IDEバスは40芯ケーブルを使用しているのに、簡単に低速で通信するために以下のピンだけが必要です:

  • DD0~DD15, 16ビットのデータバス(双方向)
  • #RST: リセット信号
  • #CS0, #CS1, A0~A2: 書き込む・読み込むレジスターの選択
  • #DIOW: デバイスへ書き込みストローブ(ホスト側発信)
  • #DIOR: デバイスから読み込みストローブ(ホスト側発信)

基本の通信シーケンスは:

  • #CS0, #CS1, A0, A1, A2 の状態を設定して書き込みしたい・読み込みしたいレジスター番号を設定する
  • 書き込みをする場合はDD0~DD15に書き込みしたい値を設定していったん#DIOWを0にする
  • 読み込みをしたい場合はDD0~DD15をHi-Zにして#DIORを0にする。#DIORが0になってる間はデバイスがDD0~DD15に出力値を設定しホストが読み込める。

基本レジスターの数もそんなに多くはない。

  • 0x1F0: データレジスター
  • 0x1F1: ホストに読み込むときはエラーレジスター、デバイスに書き込む時はフィーチャーレジスター
  • 0x1F2: セクターカウントレジスター (HDDのセクターの数を設定すること)
  • 0x1F3: セクター番号レジスター (HDDの使用中セクターを選択すること)
  • 0x1F4: シリンダーロー (HDDの使用中トラック・シリンダー番号の下桁)
  • 0x1F5: シリンダーハイ (上桁)
  • 0x1F6: ドライブセレクトレジスター
  • 0x1F7: 読み込むときはステータスレジスター、書き込むときはコマンドレジスター
  • 0x3F6: 読み込むときはAlternate Status、書き込むときはデバイスコントロール

本来HDDを接続するためのバスだからレジスターの名称はこんな目的に適当です。

ATAPIというもの

昔々CDROMはSCSIインターフェースを使用して作られていて、IDEバスと使用不可能でした。そしてIDEバスの拡張より、SCSIコマンドをIDEで通す方法を作られ「ATAPI」というものが現れた。

その略語の意味は、ATA Packet Interface: ATA (といえばIDE)用パケットインターフェース。SCSIコマンドをパケットに埋め込んでからIDEのレジスター経由で通信する方法。

ATAPI通信の基本シーケンスは:

  • コマンドレジスター(0x1F7)にWRITE_PACKET(0xA0)コマンドを書き込む。これでデバイスにコマンドの送信開始を伝えます。
  • データレジスター(0x1F0)にパケットを2byteずつで書き込む。DD0~DD7はpkt[i], DD8~DD15はpkt[i + 1]とする。ATAPI基準によりパケットの長さは12byteか16byteに定義されているけど、未だに16byteを使用しているデバイスを見たことがありません。
  • デバイスから応答を受信する想定であれば、想定しているbyteの数を読み込む。デバイスから読み込める限りステータスレジスター(0x1F7)のDRQフラグは1になっているので読み込みすべきbyteの数が不明であればこれで判定できる。

もっと詳しく知りたい方はosaskのWikiをご参照ください。

ドライブの初期化処理

  • #RSTをパルスしてバスリセットを行います
  • コマンドレジスターに 0xFF91(ソフトリセット) を書き込みします
  • この時点でシリンダーレジスターを読みだして ロー = 0x14 と ハイ = 0xEB である場合はATAPIデバイスが接続されているサインです。
  • コマンドレジスターに 0xFF90 を書き込みします。これでドライブのセルフテスト処理を実行します。終わったらエラーレジスターに 0xFF01 の値がある場合は成功。
  • シリンダーハイに0xFF02, シリンダーローに0xFF00を書き込んでPIOバッファーを 0x200 byteと設定します。
  • コマンドレジスターに 0xA1 (IDENTIFY_PACKET_DEVICE) を書き込んで応答からドライブのモデルなどを取得します。
struct ATAPI_PKT IdentifyPacket { uint16_t general_config; uint16_t reserved1; uint16_t specific_config; uint16_t reserved2[7]; char serial_no[20]; uint16_t reserved3[3]; char firmware_rev[8]; char model[40]; // 残りはとりあえず不要 }; // ------ read_response(&rslt, sizeof(Responses::IdentifyPacket), true); xSemaphoreGive(semaphore); char buf[41] = { 0 }; strncpy(buf, rslt.model, 40); ata_str_to_human(buf, 41); info.model = std::string(buf); strncpy(buf, rslt.firmware_rev, 40); ata_str_to_human(buf, 41); info.firmware = std::string(buf); strncpy(buf, rslt.serial_no, 40); ata_str_to_human(buf, 41); info.serial = std::string(buf); packet_size = ((rslt.general_config & 0x1) != 0) ? 16 : 12; ESP_LOGI(LOG_TAG, "Drive Model = '%s', packet size = %i", info.model.c_str(), packet_size);

ここまでエラーなしでたどり着いたら初期化が成功であり、他のコマンドを送ることが可能になります。

コマンドの詳細はATAPIやMMCの資料に書いてありますので再生開始などの処理を省略させていただきます。これからググっても情報がほぼ見つからない関係だけを説明させていただきます。

CD-TEXTの読み出しと解析

CDTEXTデータを読み込むにはREAD TOC/PMA/ATIP (0x43)コマンドのFormat引数を 5 に設定します。資料に Reserved と書いてあるのに実行して応答データブロックに 2byteのデータのbyte数と00 00 であれば、残りのデータはCDTEXTである可能性が高いです。(試したうちに2004年~のドライブはこんな通りでした)

CDTEXTデータであれば以下みたいな形式となります。

キャプションを入力できます

文字列のデータブロックごとのフォーマットは下記のstruct通り

struct CDTextPack { enum Kind: uint8_t { TITLE = 0x80, ARTIST = 0x81, // 残りはとりあえず未使用 }; Kind kind; // 文字列の種類 uint8_t track_no; // payloadの0byte目が関連するトラック番号 uint8_t sequence_no; // データブロックのシーケンス番号 uint8_t char_pos: 4; // track_noに指定されているトラックの名前にpayloadにある文字列の位置 uint8_t block_no: 3; // 言語フラグ (0 = 英語) bool wide_char: 1; // 全角エンコーディングフラグ char payload[12]; // track_noに与えるchar_pos以降の文字列データ。0x00以降は (track_no+1) に与える文字列データ。一件前のトラックと同様文字列であれば0x09 0x00です。 uint16_t checksum; // ?不明 };

全角エンコーディングなどを確認できるCDをコレクションに見つかりませんでしたので、今回は英数字だけのデータを解析できる処理を作ってみた:

CDTextPack * cur; std::vector<std::string> tmp_artists(album.tracks.size() + 1); std::vector<std::string> tmp_titles(album.tracks.size() + 1); uint8_t last_seq_no = 0xFF; int cur_trk_no_artist = 0; int cur_trk_no_title = 0; for(int pos = 0; pos < raw_data.size(); pos += sizeof(CDTextPack)) { cur = (CDTextPack*) &raw_data[pos]; last_seq_no++; if(cur->sequence_no != last_seq_no) { ESP_LOGE(LOG_TAG, "Seq no jump from %i to %i at pos=%i, bail out!", last_seq_no, cur->sequence_no, pos); break; } if(cur->block_no != 0) continue; // 英数字以外はとりあえず非対応 if(cur->wide_char) continue; // 全角はとりあえず非対応 if(cur->kind == CDTextPack::Kind::TITLE) { cur_trk_no_title = cur->track_no; for(int i = 0; i < sizeof(cur->payload); i++) { const char p = cur->payload[i]; if(p == 0) { cur_trk_no_title++; } else if(p == 0x9 && cur_trk_no_title > 0) { tmp_titles[cur_trk_no_title] = tmp_titles[cur_trk_no_title - 1]; } else { tmp_titles[cur_trk_no_title] += p; } } } else if(cur->kind == CDTextPack::Kind::ARTIST) { cur_trk_no_artist = cur->track_no; for(int i = 0; i < sizeof(cur->payload); i++) { const char p = cur->payload[i]; if(p == 0) { cur_trk_no_artist++; } else if(p == 0x9 && cur_trk_no_title > 0) { tmp_artists[cur_trk_no_artist] = tmp_artists[cur_trk_no_title - 1]; } else { tmp_artists[cur_trk_no_artist] += p; } } } } if(album.artist.empty()) album.artist = tmp_artists[0]; if(album.title.empty()) album.title = tmp_titles[0]; for(int i = 0; i < tmp_artists.size(); i++) { ESP_LOGV(LOG_TAG, "Artist %i: %s", i, tmp_artists[i].c_str()); if(i > 0 && album.tracks[i - 1].artist.empty() && tmp_artists[0] != tmp_artists[i]) { album.tracks[i - 1].artist = tmp_artists[i]; } } for(int i = 0; i < tmp_titles.size(); i++) { ESP_LOGV(LOG_TAG, "Title %i: %s", i, tmp_titles[i].c_str()); if(i > 0 && album.tracks[i - 1].title.empty()) { album.tracks[i - 1].title = tmp_titles[i]; } }

このようにデータを処理すればartistとtitleの同件数の配列を揃えます。両方ともの0件目はCD全体に与えるデータです(アルバムアーティストとアルバム名)。

キャプションを入力できます

インターネットからのトラック名データの取得

もしかしてCDTEXTがなくても、ESP32がインターネットに繋げられるからトラック名データを取得できるんでしょう。

昔々、こういうデータを取得したいのでしたらGracenoteとかのサービスを使用するしかなかった。さらにアクセス料が高かった(未だに高い)

現代はクラウドソーシングで情報を集まる MusicbrainzGNUDB というサービスがありまして、ある限りまで無償で使えますのでこれらを使ってみましょう。

CDのトラックリストを取得するためにまずはハッシュを計算することが必要です。

  • ハッシュ元の文字列を作る:
    (これ以降16進数を使用)
    2桁で最初のトラック番号 (ほぼ確実に"01") +
    2桁で最後のトラック番号(データトラック含む)+
    8桁のLeadOutの位置(MSFからフレームに変換したもの)+
    99× (トラックの再生時間をMSFからフレームに変換した値。該当番号のトラックがCDに入ってない場合は00000000)
  • 作った文字列をSHA-1でハッシュする
  • SHA-1ハッシュの20byteをRFC822に従ったBase64に変換する

コードにするとこんな感じです:

const std::string MusicBrainzMetadataProvider::generate_id(const Album& album) { mbedtls_sha1_context ctx; mbedtls_sha1_init(&ctx); mbedtls_sha1_starts_ret(&ctx); char temp[16] = { 0 }; unsigned char sha[20] = { 0 }; size_t dummy; sprintf(temp, "%02X", album.tracks[0].disc_position.number); mbedtls_sha1_update_ret(&ctx, (unsigned char*) temp, strlen(temp)); sprintf(temp, "%02X", album.tracks.back().disc_position.number); mbedtls_sha1_update_ret(&ctx, (unsigned char*) temp, strlen(temp)); sprintf(temp, "%08X", MSF_TO_FRAMES(album.lead_out)); mbedtls_sha1_update_ret(&ctx, (unsigned char*) temp, strlen(temp)); for (int i = 0; i < 99; i++) { if(i < album.toc.size()) { sprintf(temp, "%08X", MSF_TO_FRAMES(album.tracks[i].disc_position.position)); mbedtls_sha1_update_ret(&ctx, (unsigned char*) temp, strlen(temp)); } else { mbedtls_sha1_update_ret(&ctx, (unsigned char*) "00000000", 8); } } mbedtls_sha1_finish_ret(&ctx, sha); mbedtls_sha1_free(&ctx); char * mbid = rfc822_binary(sha, 20, &dummy); ESP_LOGV(LOG_TAG, "Disc ID = %s", mbid); const std::string rslt = std::string(mbid); free(mbid); return rslt; }

これで計算したBase64文字列をMusicbrainzAPIに入れてCDの情報を取得できます。例えばガチに手元にあった「Alstroemeria Records — The Brilliant Flowers」を処理させると、結果の文字列は F9XV7VTnpufEOtGcaHcitqk0k2s-となる。APIのURLにつけると: https://musicbrainz.org/ws/2/discid/F9XV7VTnpufEOtGcaHcitqk0k2s-?inc=recordings+artist-credits

キャプションを入力できます

ちゃんと取得できていますね!

ただし、一部のレア盤はMusicbrainzでも知らないものがある。GNUDBの場合は色々のCDRリリースも入ってるけどCDDBのスペックはちょっとめんどくさい… コメントさえもコメントではなくてスペックに定義されています!(゜U。)

なので懐かしいのlibCDDBを使いましょう。

void CDDBMetadataProvider::fetch_album(Album& album) { int matches = 0; cddb_disc_t * disc = NULL; cddb_track_t * trk = NULL; auto cddb = cddb_new(); if(!cddb) { ESP_LOGE(LOG_TAG, "memory allocation failed"); return; } cddb_log_set_level(cddb_log_level_t::CDDB_LOG_INFO); cddb_cache_disable(cddb); // GNUDBはCDDBより外れたハッシング処理を使用しているのでキャッシュ使えないし、ファーム側でキャッシュしますのでライブラリーのキャッシング処理を無効にする cddb_set_server_name(cddb, server.c_str()); // GNUDBのサーバーを設定する disc = cddb_disc_new(); if(!disc) { ESP_LOGE(LOG_TAG, "memory allocation failed"); goto bail; } // まずはアルバムの再生時間を設定する(データトラックを含む) cddb_disc_set_length(disc, FRAMES_TO_SECONDS(MSF_TO_FRAMES(album.lead_out))); // さらにTOCを展開する for(int i = 0; i < album.toc.size(); i++) { trk = cddb_track_new(); if(!trk) { ESP_LOGE(LOG_TAG, "memory allocation failed"); goto bail; } cddb_disc_add_track(disc, trk); cddb_track_set_frame_offset(trk, MSF_TO_FRAMES(album.tracks[i].disc_position.position)); } // CDDB IDを計算する cddb_disc_calc_discid(disc); ESP_LOGI(LOG_TAG, "Disc ID = %08x", cddb_disc_get_discid(disc)); // GNUDBから該当アルバム一覧を取得する matches = cddb_query(cddb, disc); if(matches == -1) { ESP_LOGE(LOG_TAG, "Query failed: (%i) %s", cddb_errno(cddb), cddb_error_str(cddb_errno(cddb))); } else if(matches > 1) { // 複数の結果があればどれが正しいがわからないので結果なしふりする ESP_LOGE(LOG_TAG, "Multiple matches found. Ignoring as there is no way to choose (for now)"); } else if(matches == 0) { ESP_LOGW(LOG_TAG, "No matches"); } else { bool success = cddb_read(cddb, disc); if(!success) { ESP_LOGE(LOG_TAG, "Read failed: (%i) %s", cddb_errno(cddb), cddb_error_str(cddb_errno(cddb))); } else { if(album.title.empty()) album.title = std::string(cddb_disc_get_title(disc)); if(album.artist.empty()) album.artist = std::string(cddb_disc_get_artist(disc)); trk = cddb_disc_get_track_first(disc); for(int i = 0; i < album.tracks.size(); i++) { if(trk != NULL) { const char * tmp = nullptr; if(album.tracks[i].title.empty()) { tmp = cddb_track_get_title(trk); if(tmp != nullptr) album.tracks[i].title = std::string(tmp); } if(album.tracks[i].artist.empty()) { tmp = cddb_track_get_artist(trk); if(tmp != nullptr && strcmp(tmp, album.artist.c_str()) != 0) album.tracks[i].artist = std::string(tmp); } trk = cddb_disc_get_track_next(disc); } } } } bail: if(disc) cddb_disc_destroy(disc); if(cddb) cddb_destroy(cddb); }

この二つのデータベースを合わせるとほぼ何でものCDのトラック名を表示できます。残念ながらCDDB時代からGNUDBに残ってる怪しい記録がありますのでアルバムによって面白い結果が出るときもある:

例のアルバムはググ訳されたのかな (゜U。)

最後に取得したデータをMusicbrainzのために計算したIDを使用してLittleFSのフォルダーに保存します。圧縮したらアルバムごとに300~500byteしかかからないので1MBのパーティションにかなりでかいコレクション全体のデータを詰められると思います。

歌詞データの取得と再生

じゃあトラック名があるので歌詞データも取得してみましょう。ディスプレイが小さくて一行ずつに表示するしかないのでタイミング付きLRC形式を使用していきます。

LRCのフォーマットはとても簡単です:

[00:05.00]一行目の歌詞 [00:12.00]二行目の歌詞 [00:21.00]三行目の歌詞 …… [mm:ss.xx]最後の歌詞

Wikiに従って mm=分、ss=秒、xx=1/100秒の書式で入力する っての定義なのに一部のファイルに mm:ss.yyy であり yyy = ミリ秒でも見たことあります。(特にNetEaseから取得したデータ)

一行ずつに以下の処理を実行すればミリ秒単位でタイムスタンプ付きの配列を取得できます:

void LyricProvider::process_lrc_line(const std::string& line, std::vector<Lyric>& lyrics) { ESP_LOGD(LOG_TAG,"Line = %s", line.c_str()); std::string content = ""; std::vector<int> times = {}; int time = 0; content.reserve(line.length()); bool token = false; unsigned int min = 0; unsigned int sec = 0; unsigned int centis = 0; int offset_millis = 0; bool skip_token = false; bool first_token = false; unsigned int* timepos = &min; if(line.substr(0, 9) == "[offset:") { offset_millis = -1 * std::stoi(line.substr(9, line.length() - 11)); ESP_LOGI(LOG_TAG, "Offset = %i", offset_millis); } else { for(const char c: line) { if(!token) { if(c == '[') { token = true; skip_token = false; min = 0; sec = 0; centis = 0; } else if(!first_token) { ESP_LOGV(LOG_TAG, "LRC line must start from token: %s", line.c_str()); break; } else { if(!content.empty() || c != ' ') content += c; } } else if(skip_token) { // トークン終了までスキップ if(c == ']') { token = false; } } else { if(c == ']') { token = false; int total_millis; if(centis < 100) { total_millis = (centis + 100 * (sec + 60 * min)) * 10; } else { // ミリ秒単位を検出すれば別の計算式を使用 total_millis = (centis + 1000 * (sec + 60 * min)); } times.push_back(total_millis); ESP_LOGD(LOG_TAG, "Time: %02im%02is.%02i = %i ms", min, sec, centis, total_millis); first_token = true; } else if(c >= '0' && c <= '9') { // 数字の読み込み *timepos *= 10; *timepos += (c - '0'); } else if(c == ':') { // mm:ss の区切り timepos = &sec; } else if(c == '.') { // ss.xx の区切り timepos = &centis; } else if(c == ' ') { // スペースは無視 } else { skip_token = true; } } } } if(!times.empty() && !content.empty()) { for(unsigned int ftime: times) { lyrics.push_back(Lyric { .millisecond = ftime, .line = content }); } } }

データ取得の元はいろいろ揃っていますが普通のREST-API呼び出しなので省略させていただきます。

CDのタイムスタンプはミリ秒単位じゃなくて1/75秒のフレームを使用しているのに…とある同人曲でテストしてみたら完璧じゃないか!

キャプションを入力できます

ドライブのテスト結果(推奨ドライブ一覧)

そもそもATAPIのスペックはかなりゆるくて、メーカーによりコマンドに違う反応は珍しくない。

例えば MS-DOS時代のドライブを使用すれば、専用ドライバーと使うつもりで設計したドライブの可能性が高いなので制御が難しくなる可能性があります。かなり最近のものを使うとオーディオCD再生機能は完全に載ってない可能性もあります。

さらにデジタルオーディオ出力のある機種も珍しい。デジタルオーディオ端子に"Reserved"や"Unused"又は"S1"などの表記がありましたらほぼ確実にデジタルオーディオを出力できないし中の回路からも引き出せません。"Digital Audio"が書いてるのに出力は一切してないのも数台発見したことある。

クリック推奨

試したドライブのうちに

  • NEC ND-3500A (08/2004) — 完璧に使えます。CD TEXT対応であるし、再生・早送り・巻き戻しなどをちゃんとできてます。

  • Teac DV-W58G (02/2005) — 大体使えます。早送り・巻き戻しを出来ていない。そもそもATAPIのスペック上でデジタル出力がある場合はこのコマンドを対応しなくてもいいって書いてるけどNECのほうはできてるのに?ファーム側で早送りなどを実装して対策。再生開始やポーズ解除ときに少しのノイズが出ます。

  • Philips/Lite-On DH-20A4P 又は Buffalo DVSM-XE1219FB — DV-W58Gと同様。さらに自分の一台が劣化してるかどうかわからないですけど振動がヤバくてメカがうるさい。

  • Panasonic SW-9583S — TOC読み込みは速くて操作なども安定ですが端子はDigitalAudio表記があるのにデジタル出力信号がない(ずっと5Vのまま)。

  • Matsushita SR-8171 — JAE50コネクター付きのノートPC用DVDドライブ。変換基板を使用すれば大体使えますけどTOC読み込みは3回目まで失敗することが多い。さらにデジタル出力なし。

  • Teac CD-C68E (02/1997) — チェンジャードライブ。TOC読み込みに失敗することが多いのでファーム側で20回まで再読み込みすることで対策。空いてるスロットをコマンドで選択できないのでディスクが入ってないスロットを用意してイジェクトすることができない。デジタル出力もない。

  • NEC CDR-1400C (01/1997) — Media Type Codeが怪しい。DOOR_OPENやCLOSED_UNKNOWN(0x0)しか返さない。後方のときにオーディオ再生コマンドを送るとちゃんと再生できる。バスの読み込みタイミングによりDRQフラグやBSYフラグが立てたままに残ることが多い。開閉メカのタイミング合わせしづらいので修理も難あり。

  • Cyber Drive CW038D (02/2002) — Media Type Codeが怪しい、さらにオーディオCDのMTCを認識しても表面の再生ボタンで再生できるのに、ATAPI再生コマンド(Play Audio MSF)に一切反応なし。とりあえず非対応。

  • Teac CD-516E (04/1997) — デジタル出力なし。非同期処理はできない。ということでディスク読み込み最中にMedia Type Codeのポーリングをしたら読み込み処理が一旦止まってしまい数秒後に再開する。ということでポーリング間隔により無限に読み込み中のままになる可能性があります。とりあえず非対応。

  • LG Super Multi GSA-4163B (11/2004) — デジタル出力もないし初期化処理以降の動作は怪しい。うちの台が壊れてるかも?

Bluetooth機能の実装

ATAPIコマンドの送受信だけはMSXぐらいでもできるからESP32に任せるのはもったいないでしょう。だからちょっとだけ機能を増やしてみよう!

Bluetoothで音声を受信するために ESP32-A2DP というライブラリーを採用しました。

使用は非常に簡単であり言葉で説明することよりコードで見せる方がわかりやすい。

BluetoothMode::BluetoothMode(const PlatformSharedResources res, ModeHost * host): a2dp(*res.router->get_output_port()), stopEject(res.keypad, (1 << 0)), playPause(res.keypad, (1 << 1)), prev(res.keypad, (1 << 4)), next(res.keypad, (1 << 5)), Mode(res, host) { rootView = new BluetoothView({{0, 0}, {160, 32}}); _that = this; } void BluetoothMode::setup() { rootView->set_disconnected(); AudioToolsLogger.begin(Serial, AudioToolsLogLevel::Warning); // CDの音声入力を無効にしてI2S出力を有効にする resources.router->activate_route(Platform::AudioRoute::ROUTE_INTERNAL_CPU); Core::Services::WLAN::stop(); // パフォーマンスの観点からWiFiをシャットダウンした方がいい // A2DPを起動する a2dp.set_avrc_metadata_attribute_mask(ESP_AVRC_MD_ATTR_TITLE | ESP_AVRC_MD_ATTR_ARTIST); a2dp.set_avrc_metadata_callback(avrc_metadata_callback); a2dp.set_avrc_rn_playstatus_callback(avrc_rn_playstatus_callback); a2dp.activate_pin_code(true); a2dp.start(("ESPer-CDP_" + Core::Services::WLAN::chip_id()).c_str(), true); a2dp.set_discoverability(esp_bt_discovery_mode_t::ESP_BT_GENERAL_DISCOVERABLE); } void BluetoothMode::loop() { if(a2dp.is_connected()) { rootView->set_connected(a2dp.get_peer_name()); // 接続されているデバイス名を取得して表示する // ボタンの状態によりAVRCコマンドを送信する if(playPause.is_clicked()) { if(cur_sts == ESP_AVRC_PLAYBACK_PLAYING || cur_sts == ESP_AVRC_PLAYBACK_FWD_SEEK || cur_sts == ESP_AVRC_PLAYBACK_REV_SEEK) { a2dp.pause(); } else { a2dp.play(); } } else if(next.is_clicked()) a2dp.next(); else if(prev.is_clicked()) a2dp.previous(); else if(stopEject.is_clicked()) a2dp.stop(); } else { if(a2dp.pin_code() != 0) { // 接続用の暗証番号があれば表示する rootView->set_pairing(a2dp.pin_code()); // 確認ボタンが押下されたら接続を許可する if(playPause.is_clicked()) { rootView->set_wait(); a2dp.confirm_pin_code(); delay(1000); } } else { rootView->set_disconnected(); } } delay(125); } void BluetoothMode::play_status_callback(esp_avrc_playback_stat_t sts) { cur_sts = sts; rootView->set_playing(cur_sts == ESP_AVRC_PLAYBACK_PLAYING || cur_sts == ESP_AVRC_PLAYBACK_FWD_SEEK || cur_sts == ESP_AVRC_PLAYBACK_REV_SEEK); } void BluetoothMode::metadata_callback(uint8_t id, const char *text) { rootView->update_metadata(text, (esp_avrc_md_attr_mask_t) id); } void BluetoothMode::teardown() { rootView->set_wait(); if(a2dp.is_connected()) { a2dp.disconnect(); } a2dp.end(); resources.router->activate_route(Platform::AudioRoute::ROUTE_NONE_INACTIVE); // a2dp.end(true) を呼び出すとドライバーのメモリーを解放されてしまいBluetoothの再起動ができなくなります。なので自分でドライバーをシャットダウンする。 esp_bluedroid_disable(); esp_bt_controller_disable(); // WiFiを再起動する Core::Services::WLAN::start(); }

インターネットラジオ再生の実装

インターネットラジオの再生もESP32ベースのプロジェクトに珍しくないでしょう。Arduino Audio Toolsというフレームワークを採用すれば超簡単です。サンプルコードを見てみると意外とやりやすい:

ICYStream urlStream(wifi, password); AudioSourceURL source(urlStream, urls, "audio/mp3"); I2SStream i2s; MP3DecoderHelix decoder; AudioPlayer player(source, i2s, decoder); void printMetaData(MetaDataType type, const char* str, int len){ Serial.print("==> "); Serial.print(toStr(type)); Serial.print(": "); Serial.println(str); } void setup() { auto cfg = i2s.defaultConfig(TX_MODE); i2s.begin(cfg); player.setMetadataCallback(printMetaData); player.begin(); } void loop() { player.copy(); }

・・・もちろん、こちらで実行してみたらサンプルコードでもそのままで動かない (゜U。)

うちのESP32が比較的に遅いなのか、通信環境が遅いなのかわかりませんでしたが大体何でもの配信URLを再生しようとしたら数秒後止まってしまう。あとはMP3フォーマットにAACエンコーディングした音声データを返す怪しい配信とかも発見したりして… 結局説明さえもムズイ再生パイプラインが現れた。

基本的にこんな通り動作しています:

  1. HTTP要求を送信する。一部の配信はTransfer-Encoding: chunkedというヘッダーを付けてチャンクで分けたデータを送信し、Arduino Audio Toolsに何かの問題がありそうで数百チャンク後にHTTP受信が止まってしまう。なのでチャンク通信を非対応している HTTP V1.0 を強制的に使用する。
  2. Content-Typeヘッダーから配信データのエンコーディングを取得して適当なデコーダーを生成する。
  3. デコーダーから発生するサンプリングレートやビットレート変更イベントをDAC制御部に通知させるように設定する
  4. 128KBほどのエンコードされたデータをデカいけど遅いRAM(PSRAM・SPIRAM)にバッファリングさせる。通信環境によりデータのビットレートより速く受信することもたまにあるので余裕を残して実際のバッファー容量を 256KB とする。
  5. 遅いRAMから定期的にデータをデコーダーに展開してデコードされたPCMデータを速いけど小さいDRAMにバッファリングする。実験によりバッファー容量が8KBほどは適当でした。
  6. I2SのDMA割り込みによってPCMデータをDRAMバッファーからもっと速いけどもっと小さいIRAMに展開する。IRAMからI2S-DACがDMAで拾っていけるのでここからの処理はすべてハード側です。

RTOSにちゃんとタスクスイッチをさせるためにピッタリのdelay()も入っています。ソースコードの可読性は非常に下手ですけど基板の隣に置いているノートPCと同じほど安定で再生できてるからまぁいいや。

完成品

完成品の動作はこちらの動画でご覧ください

これからの予定

  • ちゃんとオーディオっぽいの箱を作る(いつもお世話になっているとあるメーカーのアルミケースのカスタム依頼を検討していますがWiFiは通れないの気がする…)
  • Bluetoothヘッドホンやスピーカーの接続を対応する?
  • 先ほど作った時計と同様にGithubのCI/CDを使用して自動バージョンアップ機能
  • 基板のrev.2を作ってI2Cエキスパンダーの代わりにSPIのものを使用してドライブからのオーディオデータやファイルの読み出しを目指す
  • ↑をできれば MOD/XM/S3M, VGZ や MP3/AACの再生機能

現在の基板でISO9660をマウントしてみたら何かいけそうですが昔の5.25FDDぐらいの速度になってしまうのであまり意味がない。

キャプションを入力できます

参考資料

akasakaのアイコン画像
初めまして。 札幌在中の変な外国人、DJあかさか でございます。 ホビーとしていろいろの電子機器をいじってたり作ってたりするのでたまに記事も書きたいだとおもいます。 現実ガジェット研究所(現実LABS)の主メンバーです → www.genjit.su よろしくお願いいたしますー
ログインしてコメントを投稿する